ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

图着色问题实战:DFS回溯与剪枝策略在考场分配中的应用

2026/8/27 5:44:59 拓冰建站 浏览量
图着色问题实战:DFS回溯与剪枝策略在考场分配中的应用 1. 项目概述一场关于“分考场”的算法实战最近在整理蓝桥杯的历年真题翻到了2017年国赛C组这道“分考场”的题目。乍一看标题你可能会觉得这像是个简单的排列组合或者模拟题但真正上手后才发现它是一道非常经典的、考察图论和搜索算法综合应用的问题。这道题的核心是要求我们在给定一些学生之间存在“认识关系”的约束下如何用最少的考场完成所有学生的考试安排并且保证任意两个互相认识的学生不在同一个考场。这听起来是不是很像现实中的考场分配或者会议分组问题没错这类问题在资源调度、冲突避免等场景下有着广泛的应用。对于正在准备算法竞赛尤其是蓝桥杯的同学来说这道题是一个绝佳的练手材料。它不像纯模板题那样直接套用算法就能解决而是需要你深刻理解“图着色”问题的本质并灵活运用深度优先搜索DFS和剪枝策略。我在第一次做这道题时也走了不少弯路比如尝试用贪心策略结果发现并不总能得到最优解。后来经过反复推敲和优化才找到了一个既清晰又高效的解法。接下来我就把自己从理解题意、分析思路到代码实现再到优化调试的完整过程以及其中踩过的坑和总结的经验毫无保留地分享给大家。无论你是算法新手还是想深化对DFS回溯理解的同学相信这篇分享都能给你带来实实在在的收获。2. 问题本质与建模从现实场景到图论抽象2.1 核心需求解析我们先抛开代码把问题还原到最本质的场景。假设你是教务老师有一批学生要考试。你手里有一份名单上面记录了某些学生彼此认识可能是好朋友或者有过合作。为了防止作弊学校规定互相认识的学生绝对不能安排在同一个考场。你的任务是使用尽可能少的考场把所有学生都安排下去。这里有几个关键约束条件硬性约束冲突约束如果学生A和学生B认识那么他们必须被分配到不同的考场。这是问题的核心限制不能违反。优化目标在满足所有硬性约束的前提下使用的考场总数要最少。这直接关系到资源利用效率。无其他限制题目通常不限制每个考场的人数或者认为考场容量无限只关心“认识关系”这一种冲突。所以这绝不是一个简单的“按顺序分配”的问题。因为A和B认识B和C认识但A和C不认识的情况非常普遍。如果你先把A和B分开到考场1和2然后遇到C时发现C和B认识不能去考场2C和A不认识理论上可以去考场1。但这样分配是否就是最优的呢可能后面有一个D认识A但不认识C如果你把C放进了考场1D就只能去新考场3而如果当初把C放到新考场2D就能和A一起在考场1从而节省一个考场。这种“后效性”决定了我们必须系统地搜索所有可能的分配方案并找出最优解。2.2 图论模型建立如何把上述文字描述转化为计算机可以处理的数据模型呢这里就需要引入图论的思想这也是解决本题最关键的一步。我们可以把每个学生看作图中的一个“顶点”Node。 把**“认识关系”看作连接两个顶点的一条“边”Edge。 这样所有学生和他们的认识关系就构成了一张无向图**因为认识是相互的。那么原问题就等价于给这张无向图的每个顶点学生涂上一种颜色考场编号要求有边直接相连的两个顶点必须涂上不同的颜色。我们的目标是使用尽可能少的颜色考场来完成涂色。这就是计算机科学中著名的“图着色问题”Graph Coloring Problem更具体地说是求图的“色数”Chromatic Number即所需的最少颜色数。图着色问题是NP-Hard的意味着没有已知的多项式时间算法能解决所有情况。但对于本题中数据规模通常学生数N 100的竞赛题我们可以通过精心设计的深度优先搜索DFS加剪枝来求解。为什么是DFS回溯因为这是一个典型的组合优化问题。我们需要为第一个学生尝试分配考场1然后为第二个学生尝试分配现有考场如果不与考场内任何人冲突或者开辟新考场依次类推。每为一个学生做出一种选择就进入下一层递归处理下一个学生。如果处理完所有学生就记录下当前使用的考场数作为一个候选解。如果在中途发现当前使用的考场数已经超过了历史最优解那么这条分支就没有继续搜索的必要了剪枝。通过递归和回溯我们就能系统地枚举所有可能的分配方案尽管是指数级的并利用剪枝大幅减少搜索空间从而在有限时间内找到最优解。注意很多同学第一反应是用贪心算法比如“每次为当前学生分配可用的、编号最小的考场”。这种策略简单快速但只能得到可行解不能保证是最优解即考场数最少。在竞赛中这类问题通常要求最优解所以必须使用搜索回溯。3. 算法设计与核心数据结构3.1 数据存储如何高效表示“认识关系”在编码之前选择合适的数据结构来存储“认识关系”图的边至关重要它直接影响到后续冲突检查的效率。方案一邻接矩阵用一个二维数组g[N][N]N为学生最大数量来存储。g[i][j] 1表示学生i和学生j认识g[i][j] 0表示不认识。初始化时全部为0然后根据输入将对应的位置置为1。优点检查两个学生i和j是否认识只需要O(1)的时间即if(g[i][j]1)。缺点空间复杂度为O(N^2)当N很大时比如10^4可能会占用过多内存。但对于本题N100空间完全不是问题。方案二邻接表为每个学生维护一个列表如Vector里面存放所有与他认识的学生编号。优点空间复杂度为O(M)M为边数对于稀疏图即认识关系不多的情况更节省空间。缺点检查学生i是否与某个考场里的所有学生都不认识时需要遍历i的邻接表并与考场内每个学生比对效率稍低。实战选择 在竞赛场景下鉴于N通常较小100且为了追求极致的操作速度冲突检查会非常频繁我强烈推荐使用邻接矩阵。代码更简洁且常数时间复杂度的查询优势巨大。我们后续的讨论和代码都将基于邻接矩阵。3.2 搜索状态定义与递归函数设计我们需要在搜索过程中跟踪哪些关键状态当前正在处理哪个学生用索引idx表示从0或1开始到N-1或N结束。当前的考场分配情况我们需要知道每个考场里已经安排了哪些学生。这样当处理新学生时才能判断他能否进入某个已有考场。当前已经使用的考场数量记为room_cnt。用于与全局最优解ans比较进行剪枝。全局最优解记为ans初始化为一个最大值比如学生总数N因为最差情况一人一个考场。基于此DFS递归函数的骨架可以这样设计// 假设学生编号从1到n int g[105][105]; // 邻接矩阵1表示认识 int n, m; // n学生数m认识关系数 int ans 105; // 最优解初始化为最大值 // 关键数据结构记录每个考场里的学生 vectorint room[105]; // room[i] 是一个vector存储了被分配到第i个考场的学生编号 int room_cnt 0; // 当前已使用的考场数 void dfs(int idx) { // 当前要处理第idx个学生 // 剪枝如果当前考场数已经大于等于已知最优解没必要继续 if (room_cnt ans) return; // 递归终点所有学生都分配完毕 if (idx n) { ans min(ans, room_cnt); return; } // 核心部分尝试将学生idx分配到现有的某个考场或者开辟一个新考场 // ... (具体实现见下文) }3.3 核心操作冲突检查与分配尝试对于当前学生idx我们的选择是尝试将其放入每一个已存在的考场编号从1到room_cnt。尝试开辟一个新考场编号为room_cnt1将其放入。对于选择1在放入之前必须进行冲突检查遍历该考场room[r]中所有的学生stu检查g[idx][stu]是否为1。如果存在任意一个为1说明idx与该考场中某人认识冲突不能放入。只有与考场内所有人都不认识才能放入。放入后递归处理下一个学生dfs(idx1)然后需要回溯将idx从考场r中移除room[r].pop_back()以恢复状态尝试其他可能性。对于选择2开辟新考场是总是可行的因为没有人与空考场冲突。操作是room_cnt将idx加入room[room_cnt]然后dfs(idx1)回溯时需要将idx从新考场移除并且room_cnt--。这里有一个非常重要的优化点剪枝 在尝试将idx放入已有考场时我们不需要尝试所有room_cnt个考场。因为考场是没有“身份标识”的只有里面的学生集合有区别。假设现有3个考场考场1有学生{1,2}考场2有学生{3}考场3有学生{4}。对于学生5如果他能放进考场2那么他也能放进考场3假设都不冲突。但从搜索空间来看先尝试放考场2和先尝试放考场3最终探索的路径是对称的会重复搜索。为了避免这种重复我们可以规定一个策略只尝试将学生放入“第一个”可以放入的已有考场。更具体地说我们按考场编号从小到大尝试。对于学生idx我们遍历r 1到room_cnt。如果发现考场r可以放入不冲突我们就放入然后递归回溯后不再尝试后面的考场r1, r2, ...。这样能避免大量对称状态的重复搜索极大提升效率。这个剪枝通常被称为“避免重复状态剪枝”或“对称性剪枝”。实操心得这个剪枝是本题能从搜索题变成可解题的关键。没有它当N30左右时可能就超时了。加上它N100的数据也能在秒级内通过。其原理是我们只关心每个考场的学生集合而不关心考场的“编号”。强行规定一个尝试顺序只放第一个可行的就保证了相同的集合组合只被搜索一次。4. 完整代码实现与逐行解析理解了算法框架和剪枝策略我们来看完整的C代码实现。我会加上详细的注释并解释一些易错点。#include iostream #include vector using namespace std; const int MAXN 105; int n, m; int g[MAXN][MAXN]; // 邻接矩阵 vectorint room[MAXN]; // 考场列表room[i]存储第i个考场的学生 int room_cnt 0; // 当前使用的考场数 int ans MAXN; // 最优解初始化为最大可能值一人一考场 /** * 深度优先搜索函数 * param idx 当前要分配的学生编号从1开始 */ void dfs(int idx) { // 最优性剪枝如果当前考场数已经不小于已知最优解这条分支不可能更优直接返回 if (room_cnt ans) { return; } // 递归终点所有学生都已分配完毕 if (idx n) { // 更新最优解 ans room_cnt; return; } // 策略尝试将学生idx放入现有的某个考场 for (int r 1; r room_cnt; r) { bool can_place true; // 检查与考场r内所有学生是否冲突 for (int stu : room[r]) { if (g[idx][stu] 1) { // 如果认识则冲突 can_place false; break; } } // 如果不冲突尝试放入 if (can_place) { room[r].push_back(idx); // 放入 dfs(idx 1); // 递归处理下一个学生 room[r].pop_back(); // 回溯取出学生idx // 关键剪枝只放入第一个可行的考场避免对称状态重复搜索 // 一旦找到可以放入的考场并回溯后就不再尝试后面的考场 return; } } // 如果所有现有考场都冲突则尝试开辟一个新考场 room_cnt; // 增加考场数 room[room_cnt].push_back(idx); // 在新考场放入学生idx dfs(idx 1); // 递归处理下一个学生 // 回溯 room[room_cnt].pop_back(); room_cnt--; } int main() { // 输入数据 cin n m; // 初始化邻接矩阵 for (int i 1; i n; i) { for (int j 1; j n; j) { g[i][j] 0; } } // 读入认识关系 for (int i 0; i m; i) { int a, b; cin a b; g[a][b] g[b][a] 1; // 无向图双向标记 } // 从第一个学生开始深度优先搜索 dfs(1); // 输出最少需要的考场数 cout ans endl; return 0; }代码关键点解析全局变量使用g,room,room_cnt,ans,n都定义为全局变量这样在DFS函数中可以直接访问和修改避免了函数参数传递的复杂性。这是竞赛代码中常见的做法。递归终点与更新答案当idx n时说明前n个学生都已分配完毕此时room_cnt就是一个完整的可行解。我们用它来更新全局最优解ans。冲突检查循环for (int stu : room[r])使用了C11的范围for循环清晰遍历考场r内的所有学生。如果发现g[idx][stu] 1立即标记冲突并跳出循环。核心剪枝的实现在尝试放入已有考场的循环中一旦成功放入can_place为true在递归调用dfs(idx1)并回溯pop_back之后直接执行return而不再继续尝试r1等后续考场。这就是前面提到的“只放第一个可行考场”的强力剪枝。开辟新考场的逻辑如果所有现有考场都冲突那么for循环会正常结束不会提前return。程序会执行到循环之后的“开辟新考场”代码块。这里顺序执行room_cnt、push_back、dfs、pop_back、room_cnt--完成了状态的前进与回溯。输入处理注意认识关系是无向的所以需要同时设置g[a][b]和g[b][a]为1。5. 算法优化与性能分析5.1 时间复杂度与剪枝效果如果不进行任何剪枝最坏情况下我们需要枚举每个学生分配到任意考场包括新考场的所有可能性。对于第i个学生最多有i个现有考场和一个新考场可选所以搜索树的大小是阶乘级别的约为O(n!)这是完全不可接受的。我们的剪枝策略极大地压缩了状态空间最优性剪枝if (room_cnt ans) return;。一旦当前路径的考场数已经不少于当前最优解整条分支剪掉。在搜索初期找到一个较优解后这个剪枝效果非常明显。对称性剪枝只尝试放入第一个可行的已有考场。这避免了因考场编号不同但实质分配方案相同的重复搜索。这是减少状态数的核心。经过双重剪枝后实际搜索的状态数远小于理论最坏情况。对于N100的随机数据通常能在很短的时间内1秒内得出结果。但对于精心构造的极端数据比如所有学生都互不认识或者所有学生都互相认识算法依然高效。在互不认识时最优解是1算法会尝试将第一个学生放入考场1第二个学生因为与考场1的第一个人“不认识”所以可以放入根据“只放第一个可行考场”策略所有学生都会被塞进第一个考场搜索几乎是一条直线。在所有人都互相认识时最优解是N算法会为每个学生开辟新考场搜索路径也是唯一的。5.2 潜在优化方向探讨虽然上述代码已经足够通过蓝桥杯的评测但我们还可以探讨一些进一步的优化思路这些思路在图着色和搜索问题中很常见搜索顺序优化节点排序我们目前是按学生编号1,2,3...的顺序进行分配的。一个常见的优化是优先处理“度数大”认识的人多的学生。因为限制多的学生认识很多人更难安排早点处理他们可以更早地引发冲突触发剪枝从而更快地减少搜索树。实现方法是在DFS开始前将学生按度数从大到小排序并记录一个映射关系。不过输出时需要按原顺序所以需要额外处理。可行性剪枝加强在决定是否将学生idx放入考场r时我们只检查了直接冲突。还有一种更强的“前瞻性”剪枝估算一下剩余未分配的学生至少还需要多少个考场。一个简单的下界是剩余学生中找出一个最大的团两两互相认识的学生子集这个团的大小就是至少还需要的新考场数。但求最大团本身也是NP-Hard问题通常用启发式方法估算实现复杂性价比需要权衡。位运算优化对于冲突检查如果N很大比如几百可以用位运算bitset来加速。用一个bitsetN表示一个考场的学生集合用另一个bitsetN表示某个学生的“认识集合”。检查冲突就变成了两个bitset的“与”运算是否为空可以在O(N/word_size)内完成比遍历Vector快。但对于N100邻接矩阵的O(1)检查已经足够快。注意事项在竞赛中正确性和代码简洁性优先。除非必要不要过度优化。上述的排序优化有时效果显著但增加了代码复杂度。对于本题给定的数据范围基础版本加核心剪枝已经完全够用。建议先掌握基础版本学有余力再研究优化。6. 调试技巧与常见问题实录在实际编写和调试这道题时我和很多同学都遇到过一些典型问题。这里把它们总结出来方便大家排查。6.1 常见错误与排查表问题现象可能原因解决方案输出结果总是等于学生数n忘记实现或错误实现了“尝试放入已有考场”的逻辑导致每个学生都只能开新考场。检查dfs函数中遍历已有考场的循环是否正确冲突检查逻辑是否准确。确保can_place变量被正确使用。程序运行超时TLE缺少关键的剪枝尤其是“只放第一个可行考场”的return语句。导致搜索空间爆炸。确认在成功放入已有考场并回溯后是否立即return不再尝试后续考场。结果错误比最优解大1. 剪枝过于激进错误地剪掉了包含最优解的分支。2. 冲突判断逻辑有误把不认识的当成认识的了。1. 检查剪枝条件if (room_cnt ans)确保是而不是。时剪枝是安全的因为即使相等也不会得到更优解。2. 检查输入部分邻接矩阵的赋值是否正确是否是无向图。用简单数据测试。递归深度过大导致栈溢出学生数n很大如1000递归深度达到n层。本题官方数据n100一般不会溢出。如果自行测试大数据可考虑改为迭代加深搜索或调整系统栈大小。但更应检查算法是否因缺少剪枝而产生了指数级的多余递归调用。输出结果比最优解小这是最严重错误说明算法找到了违反约束认识的人在同一个考场的解。重点检查冲突检查代码。确保遍历了考场内所有学生 (for (int stu : room[r]))并且判断条件是g[idx][stu] 1。6.2 调试与测试用例设计自己设计测试用例是验证程序正确性的好方法最小测试n1, m0。答案应为1。无关系测试n5, m0。所有学生互不认识答案应为1。可以测试你的程序能否把所有学生放进一个考场。全关系测试n5且任意两人都认识需要输入m10对关系。答案应为5。测试程序是否会为每人开新考场。链式关系n4关系为(1,2), (2,3), (3,4)。这是一个链最少考场数是2如{1,3}, {2,4}。可以测试程序的分配策略。典型三角关系n3关系为(1,2), (1,3), (2,3)。这是一个三角形两两认识答案应为3。随机中型测试用程序生成n10左右的随机图用手算或小规模枚举验证结果。调试建议在DFS函数入口添加打印语句输出当前idx、room_cnt和各考场学生情况可以非常清晰地看到搜索路径和回溯过程对于理解算法和查找逻辑错误非常有帮助。7. 从“分考场”到更广泛的图着色应用通过这道题我们深入实践了图着色问题的回溯解法。其实这个模型的应用远不止于安排考场。寄存器分配在编译器优化中将程序中的变量分配到有限的CPU寄存器。如果两个变量在同一时刻可能都要被使用即“冲突”它们就不能分配到同一个寄存器。目标是用最少的寄存器覆盖所有变量。任务调度安排不能同时运行的任务共享同一资源到不同的时间片。冲突的任务不能在同一时间片目标是用最少的时间片完成所有任务。频率分配为无线电台分配通信频率相邻的电台可能产生干扰必须使用不同频率目标是最小化使用的频率种类。数独游戏也可以转化为图着色问题每个格子是顶点同行、同列、同九宫格内的格子互为“冲突边”颜色是1-9的数字。解决这类问题的核心思路都是一致的建模为图用颜色代表资源用DFS剪枝搜索最优分配方案。不同的是冲突关系的定义、图的稠密程度以及可能有的额外约束。回过头看“分考场”这道题它之所以经典就在于它用一个非常生活化的场景包装了一个深刻的图论问题。它考察的不仅仅是你会不会写DFS更考察你是否具备将实际问题抽象为数学模型的能力以及是否掌握在搜索中运用有效剪枝来优化性能的技巧。在平时练习时不妨多思考一下代码中每一个剪枝的“为什么”并尝试构造数据去验证它的效果。当你真正理解后再遇到类似的资源分配、冲突避免问题你就能很快地抓住本质设计出正确的算法了。