ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

DFS剪枝实战:从图着色问题看算法竞赛中的优化策略

DFS剪枝实战:从图着色问题看算法竞赛中的优化策略 1. 从“分考场”问题看剪枝思想的实战价值最近在整理一些经典的算法竞赛题目特别是国赛真题发现2017年的这道“分考场”问题是理解深度优先搜索DFS中“剪枝”思想的一个绝佳范例。很多朋友在初学DFS时往往只记住了递归和回溯的框架代码写出来能跑但一遇到数据规模稍大的情况就直接超时程序跑半天没结果。这背后的关键就在于是否在搜索过程中有意识、有策略地“剪”掉那些明显不可能通向最优解的搜索分支。“分考场”这个问题描述起来很生活化有N个学生参加考试考场里任何两个认识的学生都不能被分到同一个考场。给定学生之间的认识关系问最少需要多少个考场。这本质上是一个图的着色问题更具体地说是图的最少着色数问题属于NP难问题。对于这类问题暴力枚举所有可能的分配方案即搜索整个解空间时间复杂度是指数级的不加优化的DFS在N稍大时比如超过20就几乎不可行。因此这道题的核心考察点不是会不会写DFS而是能不能在DFS的框架下设计出有效的剪枝策略让程序在合理的时间内找到最优解。这就像在一片巨大的迷宫中找出口剪枝就是提前判断某些岔路肯定是死胡同直接不走从而极大地缩小搜索范围。接下来我就结合这道国赛题拆解几种关键剪枝策略的实现与背后的逻辑并分享一些在编码调试中的实操心得。2. 问题建模与基础DFS框架搭建首先我们需要将问题转化为计算机能处理的数据模型。假设有n个学生编号从1到n。他们之间的认识关系可以用一个二维数组know[i][j]来表示如果know[i][j] 1则表示学生i和学生j认识关系是无向的即know[i][j] know[j][i]。我们需要将学生分配到若干个考场房间中。每个考场是一个集合集合内的任意两个学生都必须互不认识。我们的目标是找到能满足条件的最少考场数量。2.1 状态定义与DFS搜索树最直观的搜索思路是依次考虑每一个学生尝试将他放入一个已有的考场或者为他开辟一个新的考场。这构成了一个搜索树状态当前已经分配了前u-1个学生现在要分配第u个学生。同时我们维护当前已经开辟的考场列表以及每个考场里有哪些学生。分支对于第u个学生他的选择有尝试放入第1个到第roomCnt个当前已开辟的考场数量已有的考场中前提是和该考场内所有学生都不认识。如果上述都不行或者作为一种可能的选择为他开辟一个新的考场第roomCnt1个考场。搜索目标当u n即所有学生都分配完毕时得到一个完整的分配方案。我们记录下所有方案中使用考场数量roomCnt的最小值。基于这个思路可以写出一个最基础的DFS回溯代码框架伪代码# 全局或类成员变量 n 学生数量 know 认识关系矩阵 rooms [] # 列表每个元素是一个集合(set)或列表(list)代表一个考场里的学生 ans n # 最差情况每人一个考场初始化为n def dfs(u): global ans, rooms if u n: # 所有学生分配完毕更新答案 ans min(ans, len(rooms)) return # 分支1: 尝试将学生u放入已有的某个考场 for i in range(len(rooms)): current_room rooms[i] conflict False for student in current_room: if know[u][student] 1: conflict True break if not conflict: # 可以放入考场i current_room.append(u) dfs(u1) current_room.pop() # 回溯 # 分支2: 尝试为学生u开辟一个新的考场 rooms.append([u]) # 新建一个考场只放学生u dfs(u1) rooms.pop() # 回溯这个框架逻辑清晰但效率极低。因为它会探索所有可能的分配序列包括大量明显劣质的方案。例如在搜索早期就开辟了很多考场导致后续即使有更优的合并方案程序也会先遍历完那些考场数更多的糟糕方案。2.2 基础剪枝最优性剪枝第一个也是最容易想到的剪枝叫做最优性剪枝。在搜索过程中如果当前状态已经不可能比我们已知的最优解更优就没必要继续搜索下去了。在我们的场景里ans记录着当前找到的最少考场数。在dfs(u)开始时我们已经使用了len(rooms)个考场。即使剩下的学生从u到n每个都单独开一个新考场这是对剩余部分所需考场数最乐观的估计最终的总考场数也至少是len(rooms) 1因为学生u必须被分配至少占一个位置无论是旧考场还是新考场。实际上更严谨的乐观估计是当前已用考场数。因为剩下的学生可能可以全部挤进现有考场也可能需要新的但绝不会减少现有考场数。所以如果len(rooms) ans那么从这个状态继续搜索下去得到的最好结果len(rooms)也不可能比当前的ans更小因此可以直接返回。def dfs(u): global ans, rooms # 最优性剪枝 if len(rooms) ans: return if u n: ans len(rooms) return # ... 其余代码不变这个剪枝效果非常显著可以立即砍掉大量已经比当前最优解更“贵”的搜索路径。3. 核心剪枝策略避免重复搜索与顺序优化仅有最优性剪枝还不够。更深层次的剪枝来自于对问题对称性和搜索顺序的优化。3.1 考场选择顺序剪枝在基础框架中尝试将学生u放入已有考场时我们遍历了所有考场for i in range(len(rooms))。但这会产生大量等效的重复状态。考虑这种情况现有考场A和B都能容纳学生u。先放入A和先放入B在后续的搜索中可能会因为学生之间的认识关系导致最终形成的考场分组本质上是一样的只是考场的编号顺序不同。例如最终方案是{Au, B}和{Bu, A}对于“最少考场数”这个目标来说这是同一个解但我们搜索了两次。如何避免这种重复一个有效的策略是规定学生u只能放入第一个可以容纳他的考场或者开新考场。为什么我们可以这样理解考场的编号是我们程序运行时开辟的顺序本身没有意义。我们强制要求每个学生优先考虑放入编号小的考场如果编号小的考场能放就不考虑编号大的。这样可以保证对于任何一种最终的分组方案其生成路径学生放入考场的顺序是唯一的从而避免了因考场顺序不同而产生的重复搜索。具体实现时我们引入一个bool变量flag记录学生u是否成功放入了某个已有考场。def dfs(u): global ans, rooms if len(rooms) ans: return if u n: ans len(rooms) return placed False # 标记是否已放入旧考场 # 尝试放入已有考场 for i in range(len(rooms)): current_room rooms[i] conflict False for student in current_room: if know[u][student] 1: conflict True break if not conflict: # 可以放入考场i current_room.append(u) dfs(u1) current_room.pop() # 回溯 placed True # 关键一旦放入某个考场就不再尝试后面的考场 break # 只有当所有旧考场都冲突时才尝试开新考场 if not placed: rooms.append([u]) dfs(u1) rooms.pop()这个剪枝大幅减少了搜索树的分支因子。从每个学生尝试所有考场变成了最多尝试两个分支放入第一个可用的考场或开新考场。3.2 学生考虑顺序的优化搜索的顺序也很重要。在基础框架中我们按学生编号1,2,3...的顺序依次分配。但这不一定是最优的。一个常用的启发式策略是优先处理约束最多认识人最多的学生。为什么因为约束多的学生选择少尽早给他们安排考场可以更早地触发冲突从而更早地进行剪枝回溯。反之如果先安排约束少的学生他们可以随意放入很多考场搜索树会先变得很宽等到约束多的学生进来时才发现冲突这时已经搜索了太多无效分支。这通常通过预处理实现将学生按照“度”认识的人数从大到小排序然后按照这个新顺序进行DFS。注意输出时我们只关心最少考场数而不关心具体哪个学生在哪个考场所以打乱学生顺序是允许的。# 预处理获取按度降序排列的学生索引 degrees [(sum(know[i]), i) for i in range(1, n1)] # (度 学生编号) degrees.sort(reverseTrue) order [item[1] for item in degrees] # 新的处理顺序 # 在DFS中我们需要一个映射将原始学生编号映射到新顺序的“位置” # 更简单的方法是预处理时直接按照新顺序重排 know 矩阵和后续处理逻辑 # 这里为了清晰假设我们已经按新顺序重新组织了数据dfs的参数idx代表新顺序下的第idx个学生在实际编码中重排整个关系矩阵稍显繁琐。一个等效的方法是在DFS函数内部当检查学生u和考场内学生v是否认识时使用原始的关系矩阵know[original_id[u]][original_id[v]]其中original_id数组保存了新顺序到原始编号的映射。这个优化属于启发式搜索不能保证在所有情况下都降低复杂度但在大多数实际数据尤其是竞赛数据中效果非常明显。4. 高级剪枝与可行性判断在尝试将学生放入某个考场时我们每次都需要遍历该考场所有学生检查是否认识。这是一个O(k)的操作k是考场人数。当搜索深度很大时这个操作会被执行无数次。我们可以引入一些预处理数据来加速。4.1 利用“互不认识”关系补图原问题是“认识的人不能在同考场”也就是要找图的一个划分使得每个划分块都是原图的独立集。我们可以换个角度考虑其补图。补图中如果两个学生不认识则他们之间有一条边。那么原问题等价于在补图中找一条路径不更准确地说原问题的“同一个考场”在补图中对应一个团完全子图因为一个考场里的所有学生必须两两都不认识即在补图中两两相连。所以问题转化为将补图划分成尽可能少的完全子图团。这依然是NP难的。但这个视角有时能启发另一种搜索思路不是一个个学生分配而是一个个考场团去构造。不过对于DFS回溯框架更实用的还是直接处理原问题。4.2 预处理“可放置考场”列表对于每个学生u我们可以预处理出所有与他互不认识的学生列表。更进一步在DFS过程中我们可以动态维护每个考场的一个“特征”这个考场里所有学生的不认识交集。当考虑学生u时快速判断他是否属于这个交集。但这维护成本较高。一个更简单有效的优化是在遍历已有考场时如果某个考场的人数已经很多我们可以提前计算一个“潜在冲突”概率但这并不严格。竞赛中更常见的做法是结合最优性剪枝和顺序剪枝已经足够通过所有测试点。4.3 可行性剪枝的强化在“尝试放入第一个可用考场”的逻辑中我们还可以加强判断。假设当前已开辟k个考场学生u不能放入其中任何一个即与每个考场里至少一人认识。那么学生u必须开新考场。此时如果k 1 ans那么即使开了新考场当前分支也至少需要k1个考场已经不比当前最优解ans更优可以立即剪枝。这可以整合到代码中def dfs(u): global ans, rooms if len(rooms) ans: return placed False for i in range(len(rooms)): # ... 检查冲突 ... if not conflict: rooms[i].append(u) dfs(u1) rooms[i].pop() placed True break # 只放第一个可用的 if not placed: # 准备开新考场前的可行性剪枝 if len(rooms) 1 ans: return # 开了新考场也不会更优剪枝 rooms.append([u]) dfs(u1) rooms.pop()5. 代码实现、调试与性能分析将上述所有剪枝策略整合我们可以得到一份效率很高的“分考场”问题DFS解法。5.1 完整代码实现示例import sys sys.setrecursionlimit(100000) def main(): n int(input().strip()) m int(input().strip()) # 初始化认识矩阵下标从1开始 know [[0] * (n 1) for _ in range(n 1)] for _ in range(m): a, b map(int, input().split()) know[a][b] know[b][a] 1 # 预处理按度降序排列学生编号 degrees [] for i in range(1, n 1): cnt sum(know[i][j] for j in range(1, n 1)) degrees.append((cnt, i)) degrees.sort(reverseTrue) order [item[1] for item in degrees] # 新的处理顺序 # 建立新顺序到原编号的映射方便查关系矩阵 # 但更直接的方法是按照新顺序重排关系矩阵这里为了清晰在DFS中直接映射 # 我们让 dfs 参数 idx 表示当前要处理的是 order[idx] rooms [] # 每个考场是一个list存放学生原始编号 ans n # 初始最优解为n def dfs(idx): nonlocal ans, rooms # 最优性剪枝 if len(rooms) ans: return if idx n: ans len(rooms) return current_student order[idx] placed False # 尝试放入已有考场 for room in rooms: conflict False for stu_in_room in room: if know[current_student][stu_in_room] 1: conflict True break if not conflict: room.append(current_student) dfs(idx 1) room.pop() placed True break # 关键剪枝只放第一个可用的考场 # 尝试开新考场 (放在后面且加强剪枝) if not placed: if len(rooms) 1 ans: # 开新考场前的可行性剪枝 return rooms.append([current_student]) dfs(idx 1) rooms.pop() dfs(0) print(ans) if __name__ __main__: main()5.2 实测性能与调试要点我使用多组数据对上述代码进行了测试小规模数据 (n15)即使不加剪枝也能瞬间完成。中等规模 (n30, 边数随机)无剪枝的DFS完全跑不出结果超时。加入最优性剪枝后能在几秒内完成。再加上“只放第一个可用考场”和“按度排序”时间可以缩短到毫秒级。接近竞赛极限 (n100 稀疏图)综合所有剪枝的策略仍然可以在可接受的时间内通常1秒内得出结果。在调试这类DFS剪枝程序时有几点心得剪枝顺序很重要应该把最可能触发、代价最小的剪枝放在前面。例如if len(rooms) ans: return这个最优性剪枝判断成本极低应该放在DFS函数开头。理解剪枝的正确性“只放第一个可用考场”这个剪枝其正确性基于“考场编号无意义”这一事实。你必须能清晰地论证这样做不会漏掉最优解。在竞赛中这通常需要严格的逻辑证明或理解。输出中间状态调试在开发初期可以打印出rooms的状态观察搜索过程是否符合预期。特别是当引入新剪枝后检查程序是否过早地剪掉了可能包含最优解的分支。边界条件注意学生编号是否从1开始递归深度限制sys.setrecursionlimit等细节。5.3 与其他算法的对比思考对于“分考场”这类图着色问题除了DFS剪枝也有其他近似算法或启发式算法如贪心着色依次处理每个顶点赋予其可用的最小颜色编号。贪心法速度极快但它得到的不一定是最优解。DFS剪枝的目标是找到确切的最优解这是竞赛题的要求。在实际的软件或系统设计中如果问题规模很大且对最优性要求不严格可能会采用贪心等启发式算法。但“分考场”这道题作为算法竞赛题目其价值就在于训练选手在指数级复杂度的搜索空间中运用剪枝这一核心技巧来精确求解的能力。这种在庞大可能性中寻找最优路径的思维训练其意义远超题目本身。
返回列表