图着色问题详解:邻接矩阵与DFS算法在PTA L2-023中的应用
2026/8/11 7:05:52 网站建设 项目流程

1. 项目概述:从一道算法题看图的着色与DFS遍历

最近在准备算法竞赛或者刷题的朋友,肯定对PTA(程序设计类实验辅助教学平台)上的L2级别题目不陌生。L2-023“图着色问题”就是其中一道非常经典的、考察图论基础与深度优先搜索(DFS)应用的题目。乍一看标题,“图着色”、“DFS”、“C++”、“邻接矩阵”,这几个关键词组合在一起,就勾勒出了一个清晰的解题轮廓:我们需要用C++语言,基于邻接矩阵这种数据结构来存储图,并利用DFS算法去判断一个给定的着色方案是否合法。

这不仅仅是一道简单的“是”或“否”的判断题。它背后涉及的是图论中一个著名的NP难问题——图的着色问题(Graph Coloring)的简化版本。在实际场景中,这个问题可以映射到很多领域:比如编译器的寄存器分配(相邻的变量不能分配到同一个寄存器)、制定课程表(同一时间不能安排有冲突的课程)、无线通信的频率分配(相邻基站不能使用相同频率)等等。因此,掌握这道题的解法,不仅仅是学会一个算法模板,更是理解一种将复杂现实约束抽象为图模型并加以解决的思维方式。

今天,我就以一个过来人的身份,带大家从头到尾拆解这道L2-023。我会重点分享如何用邻接矩阵存图、如何设计DFS遍历逻辑来验证着色方案,以及在这个过程中那些容易踩坑的细节。无论你是正在刷题的学生,还是对图算法感兴趣的开发者,相信这篇结合了原理、代码与实战心得的分享,都能让你有所收获。

2. 核心思路与方案选型:为什么是邻接矩阵和DFS?

拿到题目,第一步不是急着写代码,而是彻底理解题意并选择合适的数据结构与算法。题目要求我们判断给定的颜色分配方案,是否满足:1) 相邻顶点颜色不同;2) 恰好使用了K种颜色。这是一个典型的图遍历验证问题。

2.1 数据结构选型:邻接矩阵 vs. 邻接表

存图,逃不开邻接矩阵和邻接表这两种经典结构。

  • 邻接矩阵:用一个二维数组G[V][V]表示,G[i][j] = 1表示顶点i和j之间有边。对于无向图,矩阵是对称的。

    • 优点:实现极其简单直观,检查任意两个顶点是否相邻(即是否有边)是O(1)的时间复杂度,这对于本题需要频繁判断“相邻点颜色是否相同”的操作非常友好。
    • 缺点:空间复杂度是O(V²),对于顶点数V很大(比如上万)但边数E很少的稀疏图,会造成巨大的空间浪费。不过,PTA的题目通常数据规模可控,L2级别的图V通常在10³量级以内,使用邻接矩阵完全可行且代码简洁。
  • 邻接表:用一个数组vector<int> Adj[V]表示,Adj[i]这个向量里存储了所有与顶点i相邻的顶点编号。

    • 优点:空间复杂度是O(V+E),适合稀疏图,节省内存。
    • 缺点:判断两个特定顶点是否相邻,需要遍历其中一个顶点的邻接链表,最坏情况是O(V)。代码实现相对矩阵稍复杂。

选择理由:对于本题,核心操作是“给定一个着色方案,遍历每个顶点,检查其所有邻居的颜色是否与之相同”。使用邻接矩阵,我们可以通过一个简单的双重循环(外层遍历所有顶点i,内层遍历所有顶点j)来模拟这一检查,内层判断G[i][j]==1 && color[i]==color[j]即可,逻辑清晰直白。虽然理论上邻接表遍历邻居的效率更高(O(degree(i))),但邻接矩阵的O(V)检查在数据规模不大时完全可接受,且代码更容易写对,在竞赛或限时场景下,“简单可靠”往往比“极致优化”更重要。因此,我选择使用邻接矩阵作为本题的存储结构。

2.2 算法选型:DFS vs. BFS vs. 直接枚举

验证着色方案,本质是验证图的每条边连接的两个顶点颜色是否不同。这不需要我们“搜索”一种着色方案,而是“检查”一个给定的方案。

  • 直接枚举所有边:最朴素的方法。读取着色方案后,遍历所有边(即邻接矩阵中所有为1的G[i][j],且i<j以避免重复),检查color[i]color[j]是否相等。这种方法逻辑最简单,也完全正确。
  • DFS/BFS遍历:从某个顶点开始,遍历整个连通分量(或整个图),在遍历过程中,检查当前顶点与其下一个将要访问的邻居顶点(对于DFS是递归深入,对于BFS是放入队列前)的颜色是否冲突。这种方法更贴近“图遍历”的经典教学场景,能很好地练习DFS/BFS的应用。

选择理由:题目要求用DFS,那我们就用DFS。但我们要理解,在这里DFS并非必须,而是一种实现方式。用DFS的好处是,它为解决更复杂的着色问题(比如寻找一种可行的着色方案)打下了基础。我们的DFS函数可以设计得非常简单:其任务不是着色,而是以当前顶点u为起点,检查它的所有邻居v。如果发现color[u] == color[v],则立即返回失败;否则,如果邻居v还未被访问过,则递归地对v进行同样的检查。注意,这里不需要“状态回溯”,因为颜色是固定的,我们只是在做验证。

2.3 颜色种类校验的陷阱

题目明确要求“颜色数必须等于K”,而不是小于等于K。这是一个非常关键的边界条件,也是本题主要的坑点之一。我们需要在检查完所有边的颜色冲突后,额外统计实际用到的颜色种类数。

统计方法:最直接的是用一个set<int>来存储所有color[i],最后看set.size()是否等于K。但注意,颜色编号题目并未指定范围,使用set是通用且安全的。也有人用大小为N的bool数组,但前提是知道颜色编号范围且不大。用set是更稳妥的选择。

3. 代码实现与核心细节拆解

理清了思路,我们开始动手实现。我会分模块讲解代码,并穿插解释每个细节背后的考量。

3.1 数据结构定义与输入处理

#include <iostream> #include <vector> #include <set> #include <cstring> // 用于memset using namespace std; const int MAXV = 510; // 根据题目数据范围设定,适当留有余量 int G[MAXV][MAXV]; // 邻接矩阵 int color[MAXV]; // 存储每个顶点的颜色 bool visited[MAXV]; // DFS访问标记数组 int V, E, K; // 顶点数、边数、颜色数
  • 常量定义MAXV设为510,是因为题目通常V在500左右,多开一点防止边界溢出。这是刷题时的好习惯。
  • 全局变量:将图、颜色、访问数组以及基本参数设为全局变量,可以避免在DFS函数中传递大量参数,简化代码。在算法竞赛中这是常见做法。
int main() { // 读取图的基本信息 cin >> V >> E >> K; memset(G, 0, sizeof(G)); // 初始化邻接矩阵为0(无边) for (int i = 0; i < E; ++i) { int a, b; cin >> a >> b; // 题目顶点编号通常从1开始,我们存储时也按此习惯,方便映射 G[a][b] = G[b][a] = 1; // 无向图 } // ... 后续处理查询 }

注意:顶点编号的起始索引(0还是1)必须与题目输入保持一致。PTA题目通常从1开始,所以我们的数组也从下标1开始使用,下标0空置。这一点务必仔细读题。

3.2 DFS验证函数的设计

这是核心函数,它负责验证以顶点u所在的连通分量内,是否存在相邻点同色的情况。

bool dfsCheck(int u) { visited[u] = true; // 标记当前顶点已访问 // 遍历所有顶点,找到u的邻居 for (int v = 1; v <= V; ++v) { if (G[u][v] == 1) { // v是u的邻居 if (color[u] == color[v]) { return false; // 发现冲突,立即返回false } if (!visited[v]) { // 如果邻居v还没被检查过 if (!dfsCheck(v)) { // 递归检查v所在的子图 return false; // 如果子图检查失败, propagate失败 } } } } return true; // 所有邻居检查完毕,均无冲突 }

关键点解析

  1. 递归终止条件:递归的“深度”由图的连通性决定。当某个顶点的所有邻居都被访问过,或者发现颜色冲突时,递归就会返回。
  2. 冲突检测位置:在判断vu的邻居后,立即检查颜色是否相同。这个检查必须在递归进入v之前进行。因为我们要保证每一条边的两个端点颜色不同。
  3. 访问数组的作用visited数组防止对同一个顶点进行重复检查,避免无限递归。注意,这里visited的含义是“该顶点是否已在本轮方案验证中被DFS过程处理过”,每一轮新的方案验证都需要重新初始化这个数组。
  4. 返回值传递:一旦在某个递归层发现冲突,通过return false将失败状态层层传递回最开始的调用处,效率很高。

3.3 主逻辑与颜色种类校验

主函数中,我们需要处理多个查询。

int queryNum; cin >> queryNum; while (queryNum--) { set<int> colorSet; // 1. 读取一种着色方案 for (int i = 1; i <= V; ++i) { cin >> color[i]; colorSet.insert(color[i]); // 顺便收集颜色种类 } // 2. 检查颜色种类数是否为K if (colorSet.size() != K) { cout << "No" << endl; continue; // 直接判断下一个方案 } // 3. 初始化访问数组,准备DFS验证 memset(visited, false, sizeof(visited)); bool isValid = true; // 4. 图可能不连通,需要对每个未访问的顶点启动DFS for (int i = 1; i <= V; ++i) { if (!visited[i]) { if (!dfsCheck(i)) { isValid = false; break; // 一个连通分量失败,整个方案即失败 } } } // 5. 输出结果 cout << (isValid ? "Yes" : "No") << endl; }

核心步骤解读

  1. 边读边存:在读取每个顶点颜色时,直接插入set,利用其自动去重的特性。
  2. 先验条件判断:在启动耗时的DFS遍历之前,先判断颜色数。如果不等于K,直接输出“No”,可以节省大量时间。这是一个重要的优化。
  3. 处理非连通图for循环从1到V,对每个未访问的顶点调用dfsCheck。这确保了即使图有多个连通分量,每个分量都会被检查到。dfsCheck(i)会标记并检查顶点i所在整个连通分量。
  4. 提前退出:在遍历连通分量的循环中,一旦某个分量检查失败 (isValid=false),立即break,不再检查剩余分量,提升效率。

4. 常见“坑点”与调试心得

即便思路清晰,实现这道题时还是有几个地方容易出错。下面是我在多次提交中总结出来的“血泪教训”。

4.1 坑点一:对“K种颜色”的理解偏差

这是最大的坑。题目描述是“需要使每种颜色都被使用”,即颜色种类数必须等于K,而不是小于等于K。

  • 错误做法:只检查了相邻点颜色不同,没有检查颜色数。
  • 错误做法:用数组统计颜色,但默认颜色编号是连续整数且从1开始。如果方案是{1, 3, 5},K=3,用数组统计colorCount[1]++, colorCount[3]++, colorCount[5]++,然后遍历1到K发现colorCount[2]==0就判错。但颜色编号可能不是从1开始,也可能不连续。所以必须用set来统计实际出现的不同颜色编号
  • 测试用例:V=3,边(1,2), (2,3),K=2。方案{1, 2, 1}是合法的(用了1和2两种颜色)。方案{1, 1, 2}是非法的(相邻点1和2同色)。方案{1, 2, 2}是非法的(相邻点2和3同色)。方案{1, 2, 3}也是非法的(用了1,2,3三种颜色,不等于K=2)。

4.2 坑点二:DFS函数中的重复检查与访问标记

dfsCheck中,我们遍历所有顶点v来判断是否为邻居。对于无向图,边(u, v)(v, u)是等价的。

  • 潜在问题:当检查顶点u时,我们会检查邻居v。当递归进入v后,又会检查其邻居u,此时color[u] == color[v]已经在上一轮检查过,虽然因为visited[u]==true不会再次递归,但依然会执行一次if (color[v] == color[u])的判断。这不会导致逻辑错误,但有一点点冗余。
  • 这不是Bug:这种冗余检查是无害的,代码逻辑是正确的。更精细的写法可以只遍历v > u的邻居,但会稍微增加代码复杂度。在竞赛中,清晰正确比微优化更重要。

4.3 坑点三:每轮查询初始化的重要性

visited数组必须在每一轮新的着色方案验证前,重新初始化为false。如果忘了重置,上一轮方案的访问状态会影响到下一轮,导致DFS可能跳过某些顶点,造成误判。

  • 牢记:在while (queryNum--)循环内部,读取完color数组后,紧接着就要memset(visited, false, sizeof(visited));

4.4 坑点四:顶点编号起始索引

这是一个低级但常见的错误。题目输入“顶点数V,边数E”,然后输入E行,每行两个整数代表一条边。务必确认这些整数是从0开始还是从1开始。PTA的图论题目,绝大多数情况下顶点编号是从1开始的连续整数。我们的数组(Gcolorvisited)也应该从下标1开始使用,将下标0的空间空出或忽略。如果按从0开始处理,会导致数组越界或逻辑错误。

4.5 调试技巧与测试用例设计

自己设计几个小而精的测试用例,比盲目提交更有效。

  1. 基础用例(连通图)

    输入: 3 2 2 // V=3, E=2, K=2 1 2 2 3 1 // 1个查询 1 2 1 // 方案 输出应为:Yes
  2. 颜色数不足K

    ... (图同上) 1 1 1 1 // 只用了1种颜色,K=2 输出应为:No
  3. 颜色数超过K

    ... (图同上) 1 1 2 3 // 用了3种颜色,K=2 输出应为:No
  4. 非连通图

    输入: 5 3 3 // 两个连通分量:(1-2-3) 和 (4-5) 1 2 2 3 4 5 1 1 2 3 4 5 // 5种颜色?不,检查实际种类:{1,2,3,4,5} size=5 != K=3 输出应为:No

    另一个非连通图合法案例:

    ... (图同上) 1 1 2 1 3 3 // 颜色集合 {1,2,3} size=3 == K=3,且各分量内无冲突 输出应为:Yes
  5. 自环与重边?:通常PTA的测试数据不会包含自环(边连接同一顶点),但理论上我们的邻接矩阵处理自环G[i][i]=1会导致DFS中自己检查自己颜色,肯定冲突。重边G[i][j]被多次赋值为1,不影响逻辑。我们的代码能正确处理这些情况。

在本地运行这些用例,确保输出完全正确,再提交到在线判题系统,能大大提高一次通过的几率。

5. 算法扩展与性能思考

虽然我们用了DFS,但正如之前分析的,直接枚举边是更直观的解法。这里给出一个“枚举边”版本的伪代码作为对比:

bool checkByEdge() { set<int> colorSet(color + 1, color + V + 1); // 用数组区间构造set if (colorSet.size() != K) return false; for (int i = 1; i <= V; ++i) { for (int j = i + 1; j <= V; ++j) { // j从i+1开始,避免重复检查边(i,j)和(j,i) if (G[i][j] == 1 && color[i] == color[j]) { return false; } } } return true; }

两种方法的对比

  • 时间复杂度:都是 O(V²)(因为邻接矩阵遍历是O(V²),枚举边最坏也是O(V²))。对于稀疏图,枚举边法内层循环j可以优化为只遍历邻居,但需要邻接表支持。
  • 空间复杂度:都是 O(V²)(邻接矩阵)。
  • 可读性:枚举边法更直白,更容易理解“检查每条边”这个题意。DFS法更体现“遍历”思想。
  • 适用性:DFS法是解决“寻找一种着色方案”或“判断图是否可K着色”等更一般性问题的基石。本题只是其一个特例(验证给定方案)。

关于性能:在V=500的量级下,O(V²)=250,000次操作,对于多组查询(比如100组),总操作数在10^7量级,在C++中是完全可以在1秒内完成的。如果V更大,达到几千,就需要考虑使用邻接表存储,并将验证算法优化到O(V+E)的复杂度。

最后,这道L2-023“图着色问题”是一个很好的综合练习,它串联了图的基本存储(邻接矩阵)、遍历算法(DFS)、条件判断(颜色种类)以及细致的边界处理。理解并熟练实现它,对你掌握图论算法的基本思维和代码实现能力都大有裨益。在平时练习时,不妨两种方法(DFS验证和枚举边验证)都实现一遍,并思考如果题目变成“判断该图是否可K着色”(这是一个经典的回溯算法问题),又该如何修改代码。多进行这样的举一反三,算法能力才能真正得到提升。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询