资讯动态

蓝桥杯国赛题解:从矩阵覆盖到DAG最小路径覆盖的算法建模

发布时间:2026/8/29 8:57:47 来源:尧图企业网站定制
1. 从“估计人数”到图论建模一道国赛题的解题心路最近在复盘蓝桥杯历届真题特别是国赛级别的题目时遇到了第十届Java B组国赛的这道“估计人数”。题目名字听起来有点社会学调查的味道但实际内核却是一道非常经典的图论问题考察的是对有向无环图DAG进行最小路径覆盖的建模与求解能力。很多同学第一次接触这类题目可能会觉得无从下手因为从“估计人数”到“图论”、“路径覆盖”之间的思维跳跃有点大。我当时也是卡了很久直到把整个问题拆解清楚才豁然开朗。今天我就把自己完整的解题思路、核心原理、代码实现以及那些容易踩的坑系统地梳理一遍。无论你是正在备赛的选手还是对算法建模感兴趣的朋友相信这篇从实际问题到抽象模型再回归代码实现的完整推演都能给你带来实实在在的收获。简单来说题目会给你一个矩阵矩阵中的每个位置可能是0或1。1表示这个位置有人可以理解为活动参与者出现在该地点或时间点。但这些“出现记录”可能是零散的、重复的。题目假设每个人的行动轨迹是单向的、不回头、不交叉的例如一个人只能从左上往右下移动或者按照时间顺序出现在不同位置。我们需要根据这些离散的“1”估算出最少有多少个独立的人即最少路径数才能覆盖所有出现“1”的位置。这本质上就是要求我们找出一条条“路径”每条路径代表一个人的可能轨迹用最少的路径覆盖所有为“1”的点。2. 问题本质剖析为什么是最小路径覆盖理解这道题的关键在于完成两次“翻译”第一次是把矩阵翻译成图第二次是把“最少人数”翻译成图论概念。2.1 从矩阵到有向图建立点的可达关系题目给出的矩阵例如1 0 1 0 0 1 0 1 0 0 1 0我们不能简单地把所有“1”的位置都算作一个人因为同一个人可能出现在多个位置。题目的核心约束通常也是这类题的常见设定是一个人只能向某个固定方向移动比如只能向右列增加和/或向下行增加。我们以此为例。首先我们把矩阵中每个值为1的格子看作图中的一个节点。节点的编号可以按行优先顺序来比如(0,0)的“1”是节点1(0,2)的“1”是节点2(1,1)的“1”是节点3以此类推。接着我们需要建立节点之间的有向边。边的意义是如果从节点A对应的位置能够通过题目允许的移动方式如下右移一步到达节点B对应的位置并且B位置也是1那么我们就认为A和B可能是同一个人移动路径上的前后两个点从而从A向B连一条有向边。注意这里“一步到达”是关键。以只能向右或向下移动为例对于节点A (i, j)我们需要查看其右侧的格子(i, j1)和下方的格子(i1, j)。如果这些格子是1则对应节点B建立一条A-B的边。这个过程遍历所有“1”节点就能构建出一个有向图。更重要的一点是由于移动方向是单向的比如坐标只能增加这个有向图是不会有环的。你不可能从一个坐标大的点通过允许的移动方式再回到一个坐标小的点。因此我们得到的是一个有向无环图DAG。这是应用后续经典算法的重要前提。2.2 从“最少人数”到“最小路径覆盖”现在我们有了一个DAG图中的每个节点都需要被“覆盖”。一条“路径”对应一个人的行动轨迹。我们要用最少的路径覆盖所有节点。在图论中这有一个专门的名词DAG的最小路径覆盖。最小路径覆盖问题有一个非常优美且高效的解法它可以通过二分图最大匹配来求解。其核心公式是最小路径覆盖数 节点总数 - 最大匹配数这里的“最大匹配数”指的是在转换后的二分图中的最大匹配数。这个结论是如何得出的呢我们可以这样直观理解初始状态最“奢侈”的方案就是每个节点都作为一条独立的路径。那么路径数等于节点总数。路径合并如果存在一条边 u - v意味着u和v有可能在同一条路径上。如果我们“合并”这两条路径把u所在的路径和v所在的路径首尾相连总路径数就会减少1条。匹配的实质在二分图模型中我们为每个原始节点u创建两个分身一个在二分图左部作为起点考虑一个在二分图右部作为终点考虑。如果原图中有边u-v我们就在二分图中从左部的u到右部的v连一条边。在这个二分图中找到一个匹配比如左u匹配右v其含义就是我们将节点u和节点v连接了起来即把u路径的结尾和v路径的开头进行了合并。每成功匹配一对就相当于减少了一条独立路径。最大匹配因此我们能找到的匹配对数越多可以合并的路径就越多最终剩下的独立路径即最少人数就越少。最大匹配数就代表了最大可能的合并次数。所以最少所需路径估计的最少人数 总节点数 - 最大合并次数二分图最大匹配数。至此我们完成了整个问题的建模矩阵 - DAG - 二分图 - 求最大匹配 - 套公式得出答案。3. 算法核心匈牙利算法与二分图构建理论打通了接下来就是实现。整个算法的流程可以清晰地分为四步。3.1 第一步数据读取与节点编号首先我们需要读取矩阵并给每一个值为1的格子分配一个唯一的节点ID。同时为了后续建边方便我们还需要记录每个节点ID对应的原始坐标(r, c)。int m scanner.nextInt(); // 行数 int n scanner.nextInt(); // 列数 int[][] matrix new int[m][n]; int nodeId 1; // 节点ID从1开始方便后续处理 int[][] idMap new int[m][n]; // 记录每个位置对应的节点ID0表示不是节点 Listint[] nodes new ArrayList(); // 存储节点信息nodes.get(i)[0]是行[1]是列i从0开始对应节点ID-1 for (int i 0; i m; i) { for (int j 0; j n; j) { matrix[i][j] scanner.nextInt(); if (matrix[i][j] 1) { idMap[i][j] nodeId; nodes.add(new int[]{i, j}); nodeId; } } } int nodeCount nodeId - 1; // 总的“1”的个数即节点总数3.2 第二步构建DAG邻接表与二分图边集根据移动规则例如向右或向下遍历所有节点建立原DAG的邻接关系并同步构建二分图的边。我们定义二分图左部是原图的所有节点作为路径起点的潜力右部也是原图的所有节点作为路径终点的潜力。如果原图中存在边 u - v则在二分图中添加一条从左u到右v的边。// 假设移动方向是向右 (0,1) 或向下 (1,0) int[][] directions {{0, 1}, {1, 0}}; ListInteger[] graph new ArrayList[nodeCount 1]; // 原DAG邻接表索引从1开始 for (int i 1; i nodeCount; i) graph[i] new ArrayList(); // 二分图的边可以用邻接表存储左部点u连接的所有右部点v ListInteger[] bgGraph new ArrayList[nodeCount 1]; // 二分图邻接表 for (int i 1; i nodeCount; i) bgGraph[i] new ArrayList(); for (int idx 0; idx nodes.size(); idx) { int uId idx 1; // 当前节点ID int r nodes.get(idx)[0]; int c nodes.get(idx)[1]; for (int[] dir : directions) { int nr r dir[0]; int nc c dir[1]; // 检查新位置是否在矩阵范围内且值为1 if (nr 0 nr m nc 0 nc n matrix[nr][nc] 1) { int vId idMap[nr][nc]; // 目标节点ID graph[uId].add(vId); // 原DAG添加边 bgGraph[uId].add(vId); // 二分图添加边左uId - 右vId } } }3.3 第三步匈牙利算法求解二分图最大匹配这是整个算法的核心。匈牙利算法用于求解二分图的最大匹配。我们需要为每个左部节点u寻找一个右部节点v进行匹配。算法需要两个关键数组matchR[v]记录右部节点v当前匹配的左部节点是哪个。初始为0表示未匹配。visited[]在为一特定左部节点u寻找增广路时标记右部节点是否已被访问过防止死循环。int[] matchR new int[nodeCount 1]; // matchR[v] u, 表示右部点v匹配了左部点u int ans 0; // 最大匹配数 for (int u 1; u nodeCount; u) { boolean[] visited new boolean[nodeCount 1]; // 每次为u寻找增广路前重置访问标记 if (dfs(u, visited, matchR, bgGraph)) { ans; } } // 匈牙利算法DFS函数 private static boolean dfs(int u, boolean[] visited, int[] matchR, ListInteger[] bgGraph) { for (int v : bgGraph[u]) { // 遍历左部点u所有可达的右部点v if (!visited[v]) { visited[v] true; // 如果右部点v未被匹配或者已经匹配的左部点matchR[v]可以找到新的匹配递归 if (matchR[v] 0 || dfs(matchR[v], visited, matchR, bgGraph)) { matchR[v] u; // 将v匹配给u return true; } } } return false; // u无法找到新的匹配 }3.4 第四步计算并输出结果根据公式最小路径覆盖数即估计的最少人数 节点总数 - 最大匹配数。int minPathCover nodeCount - ans; System.out.println(minPathCover);4. 完整代码实现与逐行解析将以上步骤整合并添加必要的输入处理就得到了完整的AC代码。下面我给出一个完整的、带有详细注释的版本。import java.util.*; public class Main { public static void main(String[] args) { Scanner scanner new Scanner(System.in); int m scanner.nextInt(); int n scanner.nextInt(); int[][] matrix new int[m][n]; int[][] idMap new int[m][n]; // 坐标到节点ID的映射 Listint[] nodes new ArrayList(); // 存储所有节点坐标 int nodeId 1; // 1. 读取矩阵给每个‘1’分配ID for (int i 0; i m; i) { for (int j 0; j n; j) { matrix[i][j] scanner.nextInt(); if (matrix[i][j] 1) { idMap[i][j] nodeId; nodes.add(new int[]{i, j}); nodeId; } } } int nodeCount nodeId - 1; // 节点总数 if (nodeCount 0) { System.out.println(0); // 没有‘1’人数为0 return; } // 2. 构建二分图邻接表 (左部点u - 右部点v 的集合) ListInteger[] bgGraph new ArrayList[nodeCount 1]; for (int i 1; i nodeCount; i) bgGraph[i] new ArrayList(); // 定义移动方向向右和向下 int[][] dirs {{0, 1}, {1, 0}}; for (int idx 0; idx nodes.size(); idx) { int uId idx 1; int r nodes.get(idx)[0]; int c nodes.get(idx)[1]; for (int[] d : dirs) { int nr r d[0]; int nc c d[1]; if (nr 0 nr m nc 0 nc n matrix[nr][nc] 1) { int vId idMap[nr][nc]; bgGraph[uId].add(vId); // 左uId - 右vId } } } // 3. 匈牙利算法求最大匹配 int[] matchR new int[nodeCount 1]; // matchR[v] u int maxMatch 0; for (int u 1; u nodeCount; u) { boolean[] visited new boolean[nodeCount 1]; if (dfs(u, visited, matchR, bgGraph)) { maxMatch; } } // 4. 输出最小路径覆盖数即最少人数 int minPeople nodeCount - maxMatch; System.out.println(minPeople); scanner.close(); } private static boolean dfs(int u, boolean[] visited, int[] matchR, ListInteger[] graph) { for (int v : graph[u]) { if (!visited[v]) { visited[v] true; // 如果v没有匹配或者v的匹配点可以找到新匹配 if (matchR[v] 0 || dfs(matchR[v], visited, matchR, graph)) { matchR[v] u; return true; } } } return false; } }关键行解析idMap矩阵它建立了从坐标到我们内部节点ID的快速查询。当发现一个可达的‘1’时能立刻知道它的节点ID是多少效率远高于每次都去nodes列表里线性查找。二分图邻接表bgGraph这里存储的是从左部点到右部点的边。注意在匈牙利算法的DFS中我们只从左部点出发去寻找右部点这个结构正合适。匈牙利算法中的visited数组这个数组是针对右部节点的。它的作用是在为当前左部节点u寻找增广路时标记哪些右部节点已经被尝试匹配过防止在本次DFS中陷入循环。每次为新的左部节点u调用dfs时这个数组必须重新初始化。matchR[v] u这个赋值操作是匹配的核心意味着我们将右部节点v“许配”给了左部节点u。如果后续有更“合适”的左部节点通过递归dfs找到这个关系会被更改这正是匈牙利算法能找到最大匹配的灵活性所在。5. 实战中的边界条件与易错点分析理论正确代码清晰但在实际解题和调试中以下几个坑点需要特别注意。5.1 节点编号从1开始还是从0开始这是一个细节但处理不好会导致数组越界或逻辑错误。我强烈建议节点ID从1开始。原因如下我们的matchR数组索引代表右部节点ID。如果节点ID从0开始那么matchR[0] x表示右部节点0匹配了左部节点x。但是在匈牙利算法的判断条件if (matchR[v] 0)中0就有了双重含义既可能表示节点ID 0也可能表示“未匹配”。这会产生歧义和错误。将节点ID设为从1开始那么数组matchR的索引0位置就空置不用matchR[v] 0可以清晰无误地表示“右部节点v尚未匹配”。代码的逻辑清晰度大大提升。5.2 移动方向的设定必须与题目一致这是建模的第一步也是最重要的一步。题目中关于“一个人的移动轨迹”是如何定义的是只能向右 ({0, 1})还是只能向下 ({1, 0})或者是向右和向下 ({0,1}, {1,0})甚至可能是向右、向下、向右下 ({0,1}, {1,0}, {1,1})务必仔细审题方向定义错了建出来的图就全错了答案自然不对。在比赛时如果样例没过首先应该检查的就是方向数组dirs是否符合题意。上述代码给出的是“向右或向下”的常见情况你需要根据具体题目要求修改。5.3 当矩阵中没有‘1’时这是一个简单的边界条件但容易被忽略。如果整个矩阵都是0那么节点总数nodeCount为0。此时我们的bgGraph数组长度如果定义为nodeCount1就是1循环等操作可能出错或者直接输出0 - 0 0时可能因为除以零等问题导致异常。稳妥的做法是在早期判断nodeCount如果为0直接输出0并返回。5.4 二分图边集的去重问题一般不需要在我们的建图方式中对于一个左部节点u我们遍历其允许方向上的邻居。只要邻居是‘1’就添加一条边u - v。由于移动方向是确定的对于固定的u和方向v是唯一的所以不会添加重复的边u-v。因此通常不需要用Set来去重用List即可。但如果移动规则可能导致从u到v有两条不同的路径例如允许走对角线和平移两步则可能需要考虑去重不过在这类题中极少出现。5.5 匈牙利算法的递归深度与性能对于蓝桥杯的规模节点数通常几百递归实现的匈牙利算法完全够用代码也简洁。但是如果节点数达到几千递归深度可能引发栈溢出。这时可以考虑以下方案使用BFS版本的匈牙利算法Hopcroft-Karp算法其复杂度更优为 O(E√V)。或者使用递归时设置JVM的栈大小-Xss。对于国赛本题递归实现是标准且安全的选择。6. 测试用例与调试技巧自己构造测试用例是验证代码正确性的最好方法。6.1 基础测试用例输入 3 4 1 0 1 0 0 1 0 1 0 0 1 0 矩阵 1 0 1 0 0 1 0 1 0 0 1 0 节点编号(坐标) 1(0,0), 2(0,2), 3(1,1), 4(1,3), 5(2,2) 建边向右/向下 1 - 2 (右) 1 - 3 (下但(1,0)是0无此边) 2 - 4 (下但(1,2)是0无此边右出界) 3 - 4 (右) 3 - 5 (下) 4 - ? (右出界下出界) 5 - ? (右出界下出界) 二分图边 左1 - 右2 左3 - 右4 左3 - 右5 匈牙利匹配 一种可能的最大匹配是 {1-2, 3-4} 或 {1-2, 3-5}最大匹配数2。 最少路径 5 - 2 3。 验证三条路径可以是 [1], [2, 4], [3, 5] 或 [1, 2], [3, 4], [5] 等。确实无法用少于3条路径覆盖所有5个点。6.2 极端测试用例全0矩阵2 2\n0 0\n0 0应输出0。全1矩阵小规模2 2\n1 1\n1 1节点数4。边1-2, 1-3; 2-4; 3-4。最大匹配可以匹配 1-2, 3-4 最大匹配数2。最少路径4-22。验证路径可以是 [1-2-4] 和 [3]或 [1-3-4] 和 [2]。单行单列1 5\n1 1 1 1 1只能向右。此时图是一条链最大匹配数41-2, 2-3, 3-4, 4-5最少路径5-41。正确因为一个人可以从头走到尾。6.3 调试技巧如果代码结果不对可以按以下步骤排查打印中间变量在构建bgGraph后打印出来检查边是否符合预期。例如对于上面的基础用例应该打印出类似1:[2], 2:[], 3:[4,5], 4:[], 5:[]的结构。手动模拟匈牙利算法对于小样例用纸笔跟着代码逻辑走一遍看matchR数组的变化是否正确。检查方向数组这是最高频的错误点。检查节点编号映射确保idMap正确填充在通过坐标(nr, nc)查找vId时idMap[nr][nc]不能为0。7. 算法扩展与思维提升解决这道题不仅仅是学会了一个模板更重要的是掌握了一种建模思想如何将看似是“人数估计”的离散覆盖问题转化为图论的“最小路径覆盖”问题并进一步转化为经典的“二分图最大匹配”问题。这种“转化-求解”的能力是算法竞赛和解决复杂工程问题的核心。7.1 如果移动规则更复杂怎么办题目有时会规定更复杂的移动规则比如可以走“日”字形象棋中的马或者允许向四个方向移动但路径不能交叉。只要移动规则仍然保证路径是单向的、不会走回头路即形成的图依然是DAG那么上述模型和算法就依然适用。你只需要修改建图时遍历邻居的方向数组dirs即可。例如如果允许向右、向下、向右下那么dirs就加上{1, 1}。7.2 理解其与“最大独立集”、“最小点覆盖”的关系在二分图理论中有几个著名的定理König定理最小点覆盖数 最大匹配数最大独立集数 节点总数 - 最小点覆盖数 节点总数 - 最大匹配数你会发现DAG的最小路径覆盖数公式上恰好等于其对应二分图的最大独立集数。这并非巧合而是有深刻的图论对偶关系在里头。理解这些关联能让你对图论的整体认知上升一个层次。7.3 在实际项目中的启发虽然“估计人数”是一个简化模型但这种思想在现实中很有用。例如任务调度与依赖分析有多个任务有些任务必须在另一些之后完成依赖关系形成DAG问最少需要多少个串行的工作流路径才能完成所有任务。版本管理中的分支合并一系列提交记录节点某些提交是基于另一些的有向边估算代码仓库中独立开发线分支的最小数量。掌握这个算法你就拥有了将一类“覆盖”问题化归为经典图论问题的能力这是比背熟代码更重要的事情。

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

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

免费获取报价