资讯动态

C++实现最小权顶点覆盖:分支限界与位集剪枝全解析

发布时间:2026/10/14 20:06:03 来源:尧图企业网站定制
简介这是一份用C实现最小权顶点覆盖问题的完整求解资源采用优先队列式分支限界法对应算法设计与分析第六章的编程任务。代码包含算法主程序、MinHeap优先队列模板头文件以及input.txt输入样例可帮助读者理解分支限界策略、顶点权值约束与最优解输出流程。资源压缩包共3个文件分别以cpp、h、txt类型组织整体仅2KB结构紧凑、便于直接调试运行。已有2828人浏览学习适合正在学习算法设计与分析、需要参考完整实现思路或完成同类作业的高校学生与编程爱好者。通过阅读源码和样例文件可以快速掌握最小权顶点覆盖问题的建模方法、优先队列在状态空间搜索中的应用以及如何输出最小权和最优选择向量。1. 最小权顶点覆盖问题的C实现先想清楚边界再动手最小权顶点覆盖这几个字看起来像教科书定义真正上手之后会发现坑都在数据组织和剪枝上。这份完整的 C 代码包把位集存储、加权匹配下界、分支限界搜索打包成可以直接编译运行的工程输入一个带权无向图输出顶点覆盖子集和对应总权重配套贪心上界和随机图校验工具。适合两种人一种是在算法课程设计里需要把每个剪枝理由讲清楚的学生另一种是要在自己项目里嵌入可解释的 NP-hard 求解器、又不方便引入重型依赖库的开发者。先说边界它追求最优解而不是近似解中等规模图上表现可用别拿它和线性时间的近似算法比速度。2. 问题定义与算法选型为什么分支限界才是带权场景的正解2.1 带权问题和无权问题的本质差别以及输入输出约定最小权顶点覆盖Minimum Weight Vertex Cover, MWVC的正式定义是给定无向图 G(V,E)每条边 e(u,v) 至少要有一个端点被选进覆盖集合 C在给定顶点权重 w:V→R 的前提下最小化 ∑_{v∈C} w(v)。当所有顶点权重都为 1 时它就退化成经典的最小顶点覆盖问题。两者都是 NP-hard但实际求解的难度分布差别很大。无权场景下可以用最大匹配和核化技巧处理很多图有权场景下这些技巧的剪枝效果会被权重分布稀释搜索算法对下界质量的要求也更高。代码包的读入格式不长但必须固定否则后续图和结果对不上。第一行是顶点数 n 和边数 m第二行是 n 个顶点的权重按顶点编号顺序排列之后 m 行是边。顶点编号从 1 开始内部会自动映射到 0 基索引。一个典型的输入长这样6 7 2 3 1 4 1 5 1 2 1 3 2 4 3 4 3 5 4 6 5 6这个例子值得手动走一遍。顶点 1 到 6 的权重分别是 2、3、1、4、1、5边覆盖要求每条边至少有一端被选中。最优解是选顶点 1、3、4、5总权重 21418。注意最低权重的顶点 3 和 5 都在解里而权重最高的顶点 6 被排除这就说明“选最少顶点数”和“选最小总权重”是两种不同的目标也是这个题目值得单独立项的原因。输出格式分两行第一行是最优总权重第二行是选中的顶点编号升序列表。这样的设计是为了方便和暴力验证脚本做机械化比对避免肉眼从一堆二进制位里数编号。2.2 暴力枚举、动态规划、分支限界三种方案怎么选动手写代码之前先做选型对比。暴力枚举子集是 2^n 的指数复杂度n20 的图大概一百万次检查n30 就上到十亿级别只能作为小规模验证基准不能当主算法。动态规划按树宽做确实能多项式求解但难点不在 DP 本身而在先把图分解成树结构这一步对任意图要付出很高代价工程上很不划算。分支限界法面向一般图用上下界裁掉大量不可能的搜索分支虽然最坏情况还是指数级但中规模图上配合一个紧下界往往能在几秒内出最优解。方案适用规模实现成本可解释性目标暴力枚举子集n ≤ 20低强最优解树分解动态规划树宽小的图高弱最优解分支限界几百顶点以内的图中强最优解贪心近似任意大图低强可行解这个代码包选的是分支限界因为它的代码量可控又能在一般图上保证最优解还方便在实验报告里展示“剪掉了多少个节点”这种直观数据。贪心近似的代码也附在里面但不负责精确性只用来生成初始上界这个角色在下一节展开讲。2.3 上下界框架搜索树、上界候选与下界剪枝分支限界的核心是维护两个界。上界 best 是当前找到的最优可行解权重任何一个状态只要“当前已选权重 剩余未覆盖部分的下界”已经 ≥ best就说明这个分支不可能产生更好解直接剪掉。下界的选取直接决定剪枝效率。常见做法是用线性规划松弛解但在 C 里引入 LP 求解器对课程设计和轻量工程都太重。这个代码包采用一种更轻量但严格正确的下界在尚未覆盖的边集上构造一个带权贪心匹配匹配中每条边 (u,v) 贡献 min(w[u], w[v])把这些贡献求和作为下界。为什么它正确因为对于匹配中的任意一条边 (u,v)顶点覆盖至少要在 u 和 v 里选一个且不同的匹配边没有共享端点所以这些 min 贡献是互不冲突的独立需求。这个下界不依赖 LP代码只有十几行配合预先排序的边列表在搜索过程中能快速算出一组可用的下限。搜索树的分支规则同样重要。在任意 DFS 状态里先找一条尚未被覆盖的边 (u,v)这条边要求 u 和 v 至少有一个被选中于是自然分成两个分支选 u或者选 v。这里不需要“不选”分支因为那条边必须被覆盖少一个分支等于把搜索空间减半。如果图里某些顶点已经被选它们关联的边会在地图中标记为已覆盖不再作为分支依据。整棵搜索树的根状态是贪心解给出的上界然后 DFS 不断收紧 best直到所有可能状态被遍历或剪掉。这个框架实现出来后剩下的问题就集中在数据结构怎么组织位掩码、下界排序怎么和 DFS 状态协同这些会在下一章用代码拆开讲。3. 代码核心实现位集存储、分支策略与剪枝函数3.1 数据结构如何把一个顶点覆盖了哪些边压成位集代码里最关键的数据结构不是邻接矩阵而是位掩码。每个顶点维护一个边长度的位掩码表示这个顶点一旦被选中哪些边会因此被覆盖。同时维护一个全局的 covered 位集表示当前搜索状态下已覆盖的边集合。覆盖关系用按位或来合并判断边是否覆盖则用位查询。n 和 m 规模不同位集的实现也要分开处理。简单场景下如果 m ≤ 64可以直接用 uint64_t 一次搞定完整代码包里提供的是多块实现用 vector 按 64 位切分支持任意 m。查找第一个未覆盖边的操作变成遍历块找第一个非全 1 的块块内用 ctz 指令找具体边索引。这个设计避免了大图上反复用 bitset 动态拷贝的额外开销。核心位集类如下struct EdgeBitSet { vectorunsigned long long bits; int m; // 总边数 EdgeBitSet(int m_) : m(m_), bits((m_ 63) 6, 0) { int tail m_ 63; if (tail) bits.back() (~0ULL) tail; // 把超出 m 的高位置成 1避免越界 } inline void setBit(int id) { bits[id 6] | 1ULL (id 63); } inline bool testBit(int id) const { return (bits[id 6] (id 63)) 1ULL; } inline void orWith(const EdgeBitSet other) { for (size_t i 0; i bits.size(); i) bits[i] | other.bits[i]; } int firstZero() const { for (size_t i 0; i bits.size(); i) { if (bits[i] ! ~0ULL) { return (int)(i 6) __builtin_ctzll(~bits[i]); } } return m; // 所有边都已覆盖 } };逻辑说明EdgeBitSet 固定按 64 位分块setBit 和 testBit 分别做赋值和查询orWith 用于在 DFS 里把选中顶点的边掩码合并进全局 covered。firstZero 返回第一个未被覆盖的边索引内部取反后扫描低位连续零的个数比逐条边 for 循环检查快得多。参数方面m 是总边数bits 数组长度由 (m63)/64 决定注意位数组末尾多出的部分可能包含无效位。构造函数里那一行 tail 处理非常关键把超出 m 的高位全部置成 1firstZero 才不会在无效位返回越界索引。这个坑后面还会在避坑章节重点展开。图的顶点侧数据我用邻接表 adj[n]但邻接表只服务于两件事贪心上界构造时统计顶点关联边数以及把每个顶点对应的边掩码 eByV[v] 预计算好。搜索主循环不再遍历邻接表所有覆盖判断都走位集所以密集图和稀疏图在搜索阶段的差距被明显拉平。3.2 分支策略选哪条未覆盖边、优先试哪个顶点分支边选择直接影响剪枝效率。代码里简单而有效的方式是每次取 firstZero 返回的边因为位集的存储顺序天然决定了它总是优先拿到编号最小的未覆盖边这个顺序不需要额外维护。更复杂的选择策略比如挑当前剩余图中权重影响最大的边可以提高剪枝率但每次重新计算候选的代价很高完整包里留了开关来切换默认关掉。选 u 还是选 v 先分支也有讲究。代码默认优先选权重更小的那个端点理由很简单小权重顶点被选进覆盖集对答案的冲击小先走这个分支能更快找到一个更紧的上界从而帮另一侧的剪枝。这个排序不保证最优但实践经验里大权重顶点分支往往解质量差把它放到后面剪枝时受益更多。DFS 主函数如下// 成员变量n, m, w, edges, eByV, best, bestSelected void dfs(EdgeBitSet covered, long long curWeight, const vectorchar selected) { if (curWeight best) return; int ei covered.firstZero(); if (ei m) { if (curWeight best) { best curWeight; bestSelected selected; } return; } int u edges[ei].first; int v edges[ei].second; long long lb lowerBound(covered); if (curWeight lb best) return; // 剪枝判断 // 优先尝试权重较小的端点 int first (w[u] w[v]) ? u : v; int second (first u) ? v : u; if (!selected[first]) { vectorchar n1 selected; n1[first] 1; EdgeBitSet nc1 covered; nc1.orWith(eByV[first]); dfs(nc1, curWeight w[first], n1); } if (!selected[second]) { vectorchar n2 selected; n2[second] 1; EdgeBitSet nc2 covered; nc2.orWith(eByV[second]); dfs(nc2, curWeight w[second], n2); } }这段代码有几个参数值得展开。curWeight 是当前已选顶点的权重累加selected 是每个顶点是否被选的标记数组它在每层递归里拷贝一次换来的是实现简单和回溯方便。EdgeBitSet 也按下值传每个深度的 covered 独立两个分支互不污染。剪枝判断 curWeight lb best 放在选边之后这样 lowerBound 能用到当前最紧的 covered 状态。一个容易被忽略的细节是firstZero 取到的边如果两个端点都还没被选那么两个分支都有效如果某端点已经被选这条边理论上已经被覆盖firstZero 根本不会返回它。所以 selected 检查更像是防御性兜底保证代码在边掩码被误改时也不会产生非法状态。3.3 下界函数边排序缓存与贪心匹配实现下界函数在整棵搜索树里调用频率最高。它每次在未覆盖边集上做一次带权贪心匹配遍历按 min(w[u], w[v]) 降序排列的边列表遇到未覆盖且两端都还没被匹配占用的边就把它取走累计 min(w[u], w[v])。vectorint lowerOrder; // 成员变量在 initMask 里按权重排好 long long lowerBound(const EdgeBitSet covered) { vectorchar used(n, 0); long long lb 0; for (int id : lowerOrder) { if (covered.testBit(id)) continue; // 已覆盖的边不参与匹配 int u edges[id].first; int v edges[id].second; if (used[u] || used[v]) continue; // 匹配边不能共享端点 used[u] used[v] 1; lb min(w[u], w[v]); } return lb; }逻辑说明lowerOrder 只排序一次因为顶点权重和边集合在整个求解中都不变变化的只有 covered 状态所以可以提前缓存排序。排序键是 min(两端权重)这样贪心倾向把贡献大的边先占上下界会更大剪枝效果更好。遍历时用 used 数组记录匹配已占用的端点保证匹配的有效性。这个下界的正确性依赖匹配边不相交的特点每条匹配边贡献的是 min(w[u], w[v]) 之和。一个值得注意的参数细节lowerBound 每次都要申请并清零一个长度为 n 的 used 数组深度很大时高频分配会影响性能。完整代码包里把它改成传入固定缓冲并在函数入口置零避免 vector 反复构造。这个优化在 n 超过 5000 时效果明显但教学示例里保持直观写法更容易看懂。3.4 参数表换图时只需要改哪里参数位置默认值说明n, m输入文件首行运行时读取顶点数和边数w[]输入第二行运行时读取顶点权重必须与编号一一对应edges[]输入后续行运行时读取边列表1 基读入内部变 0 基lowerOrderlowerBound 成员initMask 初始化按 min(w) 降序权重不变的图只需构建一次PREFER_LIGHT_FIRSTdfs 分支顺序true先分支权重小的一端全等权重时可关闭TrackNodeCount统计开关false打开后记录搜索节点数用于评估剪枝质量这些参数里最常需要动的是 PREFER_LIGHT_FIRST。碰到权重全部相等或接近的图权重排序对分支顺序的影响减弱这时改成始终走第一个端点能省一次比较开销。对大部分实际图保持默认即可。4. 避坑与常见问题排查五个真实的翻车现场4.1 现象位集数组越界导致随机崩溃我在第一次跑 200 条边的图时程序偶尔在 firstZero 或 orWith 里崩溃用调试器看地址随机偏移时好时坏。现象最迷惑的地方在于崩溃位置不在固定的代码行换个输入文件甚至能正常跑完。原因出在 EdgeBitSet 的最后一个块。如果边数不是 64 的整数倍最后一个块的高位没有对应任何边默认值是 0。firstZero 会把 0 位当作未覆盖边返回一个超过 m 的索引后续访问 edges 数组自然越界。解决方法是构造函数里强制把填充位置成 1让无效位看起来像“已覆盖”。从那以后我每次实现位集都会先检查边界块里有没有做好掩码收尾这是位集类最容易踩的第一道坑。4.2 现象下界过强把真正的最优解剪掉了把 40 个顶点的随机图喂进去程序正常跑完但输出的解权重比暴力验证的结果要重。最麻烦的是它并不是每次出错只在某些图密度下翻车。后来我把下界函数单独抽出来对一个固定 covered 状态对比贪心匹配下界和实际最优剩余覆盖权重发现下界偶尔会大于真实最优值。查代码发现原因lowerBound 里遍历边时跳过了 covered 边但我在做“已覆盖”判断时用错了位集状态用了当前分支的 covered 传参结果 some 已覆盖的边被算进了匹配导致匹配边互相冲突下界被无意义地抬高。解决方法是把下界函数做成独立可测的单元用暴力解在随机状态上校验“下界 ≤ 真实剩余最优”这个不等式。这里要记住一个关键原则下界可以松但不能超过真实最优值否则剪枝会杀错人。松一点只是慢紧错了就是错。4.3 现象顶点编号 1 基和 0 基混用输出出现 0 号顶点有一次在顶点数为 8 的图上跑完输出列表里出现了 0 和另一个编号 8怎么看怎么不对劲。边和权重的输入明明都是 1 到 8怎么会有 0原因很简单内部代码把输入转成 0 基下标输出模块忘了加回 1。于是下标 4 打印成 4 而不是 5而下标 0 又对应原来的 1加上排序后列表就出现了一个诡异的 0。解决方法是把输出模块里所有“打印顶点编号”的地方统一加 1并且加一行调试断言验证最大下标不超过 n-1。这以后所有 I/O 相关测试我都先用一个 3 顶点小图人工核对一遍再上大图。4.4 现象递归深度过大导致栈溢出图是长链状结构时边数只有 300 条左右DFS 跑到一半抛栈溢出。我一开始以为是指数级状态太多后来发现链状图上每选一个顶点只能覆盖一条新边搜索深度会逼近 n默认系统栈根本扛不住。解决方法是把递归改成显式栈迭代。把 covered、curWeight、selected 打包成状态节点放进一个 vector 手动维护每次从栈顶取状态处理、把分支状态压回去。这样内存增长是线性的不会消耗调用栈图规模多大都不会爆栈。单纯在编译器参数里加大栈只是一种逃跑策略。工程上只要深度可能超过几百层就默认不走递归。4.5 现象随机图生成器跑出来的图全是不连通图用随机图生成器做压测时发现生成的图一半顶点是孤立点顶点覆盖集无比大但分支限界的搜索行为和平时的图完全不一样压测结果没有参考价值。原因是生成算法按固定概率两两连边稀疏图里自然会大量出现度数为 1 或 0 的顶点图的结构和真实场景脱节。解决方法是生成器先保证连通性。常见做法是先随机生成一棵生成树再按概率叠加额外边这样图密度可控搜索空间结构也更接近实际。生成器要固定随机种子保证每次回归测试用同一批图否则跑完不好对比剪枝效果。5. 验证与接口设计把代码接到自己项目里的完整流程5.1 用暴力解交叉验证小图上一次跑进 30 秒任何精确算法的代码都先过暴力验证再谈性能。对于 n ≤ 20 的图暴力枚举所有子集并验证覆盖约束只需几秒。我用下面这个 Python 脚本生成基准答案def brute_mwvc(n, edges, weights): best_cost float(inf) best_mask 0 for mask in range(1 n): ok True for u, v in edges: if not ((mask u) 1) and not ((mask v) 1): ok False break if not ok: continue cost sum(weights[i] for i in range(n) if (mask i) 1) if cost best_cost: best_cost cost best_mask mask return best_cost, best_mask逻辑说明mask 是 n 位二进制数第 i 位代表顶点 i 是否被选。对每个 mask 先检查所有边是否至少有一个端点被选中再计算权重和。n ≤ 20 时这个循环最多一百万次跑起来毫无压力。比对时注意两边权重都用整数不要引入浮点。C 输出第一行是 best第二行是顶点列表用脚本读出来和 best_mask 里的位一一对应即可。我自己习惯把这个验证脚本放在测试目录里每改一次下界或分支逻辑就跑一遍全部小图用例确保没有回归。5.2 随机图压测四个指标决定剪枝质量验证正确性之后还要压测我一般统计四个量搜索节点数、剪枝次数、总耗时、最优解收敛过程。节点数越多说明下界越松剪枝次数相对总节点数的比例可以评估下界质量耗时和输入图规模做标定判断出手工改参数是否有效。随机图生成器的写法直接影响压测可信度。如果只是两两按概率连边很容易生成一堆孤立点。我用的生成器先建一条路径保证连通再按概率叠加额外边def gen_connected_weighted_graph(n, p, seed42): random.seed(seed) w [random.randint(1, 20) for _ in range(n)] edges [] for i in range(n - 1): edges.append((i, i 1)) for i in range(n): for j in range(i 2, n): if random.random() p: edges.append((i, j)) return w, edges逻辑说明第一条循环保证从顶点 0 到 n-1 形成一条链图必然连通。第二个循环按概率 p 补边控制图的密度。固定 seed 是必须的这样两次压测之间可以精确对比剪枝率变化。压测结果观察两个趋势一是边数翻倍时耗时是否指数增长二是剪枝率是否稳定在某个区间。如果某个图密度下节点数突然暴涨优先检查 lowerBound 里的排序缓存有没有被错误复用因为不同图的权重不同缓存一旦串场就是灾难。5.3 接口改造从命令行程序到函数调用完整代码包本体是命令行程序读文件出结果。接入自己工程时一般要改造成函数接口最小调用方式是先构造 MWVC 对象传入顶点数、权重、边表和邻接表预计算边掩码后调用 solve再从 best 和 bestSelected 里取结果。MWVC solver; solver.n n; solver.w weights; solver.edges edgeList; solver.adj adjList; solver.initMask(); // 预计算 eByV 和 lowerOrder solver.solve(); // 内部先贪心上界再 DFS cout solver.best \n; for (int i 0; i n; i) if (solver.bestSelected[i]) cout i 1 ;关键是把 initMask 和 solve 分离。initMask 只依赖图的静态结构可以在同一张图上多次调用 solve 而不重复建边掩码solve 会修改 best 和 bestSelected所以第二次调用前必须重置这两个状态。我自己的项目对接里栽过一个跟头忘在第二次调用 solve 前把 best 重置为极大值导致第二次解永远被第一次的上界卡住。这个问题不算复杂但排查时盯着输出怎样都不对后来加了一行断言才发现状态没清干净。从那以后凡是带缓存和可变状态的求解器我都会单独提供 reset 接口并在构建时默认跑一次空图验证状态正确。6. 进阶技巧给求解器加时间预算复杂图上也能可控收场6.1 时间预算与最优解差距报告中等规模图上分支限界最怕的是跑很久却不见收敛。实际使用中可以先给一个时间预算超时直接返回当前上界同时记录此时已经找到的最优权重和预估下界。代码里加一个超时检查点就很直接using namespace std::chrono; auto t0 steady_clock::now(); long long timeLimitMs 2000; bool timeout false; void dfs(EdgeBitSet covered, long long curWeight, const vectorchar selected) { if (timeout) return; long long elapsed duration_castmilliseconds(steady_clock::now() - t0).count(); if (elapsed timeLimitMs) { timeout true; return; } // 后续分支和剪枝逻辑保持不变 // ... }逻辑说明每次进入 dfs 先检查时间超时置 timeout 后所有更深层分支直接返回相当于把剩余搜索空间放弃。这样做不会破坏已经找到的可行解只是结果上界可能不是最优的。输出差距报告时用“当前上界 - 当前下界”得到近似程度给工程调用方一个明确的置信度。6.2 观察剪枝率收敛曲线我自己加这个超时功能后最喜欢做的一件事是跑一张 120 顶点的中等密度图把时间预算分别设成 0.5 秒、2 秒、10 秒观察 bestWeight 的下降轨迹。你会发现大部分改进都集中在前几秒越往后收敛越慢。这个现象很常见不是代码写得差而是搜索树后半段剩下的都是剪不掉的难分支。如果曲线是阶梯形即长时间权重不变后突然跳变说明下界对那部分搜索空间失效这时候值得去检查 lowerBound 里匹配贪心有没有漏掉某些特殊结构。如果曲线极平说明贪心上界已经接近最优可以直接在外部业务里接受这个近似值。从那以后我每次改换图规模都会先跑一遍暴力交叉验证再跑一遍带时间预算的压测确保正确性和性能边界都在掌握里再交付。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑