尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

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

深度优先搜索与回溯算法精解:从路径约束问题到通用解题框架

深度优先搜索与回溯算法精解:从路径约束问题到通用解题框架 1. 从一道经典国赛题说起路径之谜的挑战最近在整理历年算法竞赛的经典题目翻到了2016年蓝桥杯国赛C A组的这道“路径之谜”。题目本身描述并不复杂但想要在赛场上稳定、高效地解出来却需要选手对深度优先搜索DFS和回溯算法有非常扎实的理解和清晰的实现思路。这道题可以说是检验一个选手是否真正掌握了DFS回溯思想的“试金石”。很多朋友在初次接触时要么被庞大的搜索空间吓到要么在回溯的细节上处理不当导致超时或答案错误。今天我就结合自己多次讲解和实现这道题的经验从头到尾拆解一下解题思路并分享几个在编码实践中极易踩坑的关键点。简单来说题目模拟了一个在网格中寻路并解开谜题的场景。你有一个 n x n 的方格棋盘在题目中n4但我们的算法需要能处理一般情况棋盘的左上角(0,0)是起点右下角(n-1, n-1)是终点。你从起点出发只能向右或向下移动最终要到达终点。这听起来像是最简单的动态规划入门题——求路径总数。但“谜”就在于路径上的附加条件棋盘的“上边”和“左边”各有一排数字。具体来说在棋盘的上方对于每一列从第0列到第n-1列都有一个数字表示你最终走过的完整路径中踏入该列的格子次数必须等于这个数字。同样在棋盘的左侧对于每一行从第0行到第n-1行也有一个数字表示路径中踏入该行的格子次数必须等于这个数字。你的任务就是找到一条从(0,0)到(n-1, n-1)的、仅能向右或向下走的路径使得它同时满足所有行和列的计数约束。题目保证有唯一解并且需要以特定格式输出这条路径经过的所有格子的坐标。为什么说它经典因为它完美地将路径搜索与状态约束结合在一起。单纯的DFS可以枚举所有路径但如何高效地利用行列计数进行剪枝以及如何设计回溯时状态恢复的逻辑是本题的核心。下面我们就一步步拆解。2. 问题建模与核心状态设计面对任何搜索题第一步也是最重要的一步是设计搜索状态。状态设计的好坏直接决定了代码的复杂度、可读性以及能否通过剪枝避免无效搜索。2.1 理解约束的本质首先我们必须精确理解“踏入该行/列的格子次数”是什么意思。假设我们有一条路径(0,0) - (0,1) - (1,1) - (2,1) - (2,2) - (3,2) - (3,3)。行计数对于第0行路径经过了(0,0)和(0,1)两个格子所以踏入第0行的次数是2。列计数对于第1列路径经过了(0,1), (1,1), (2,1)三个格子所以踏入第1列的次数是3。题目给出的约束就是我们找到的路径其行计数数组必须与输入的行约束数组完全一致列计数数组必须与输入的列约束数组完全一致。这给了我们一个强烈的提示在DFS遍历过程中我们必须动态维护两个数组row_cnt[i]当前路径已走入第 i 行的次数。col_cnt[j]当前路径已走入第 j 列的次数。当我们尝试将格子(x, y)加入当前路径时row_cnt[x]和col_cnt[y]就需要分别加1。当我们回溯从路径中移除格子(x, y)时这两个计数就需要分别减1。这是回溯算法的典型操作。2.2 设计搜索状态与剪枝策略搜索状态至少需要包含当前坐标(x, y)表示搜索进行到的位置。路径记录path一个容器如vector按顺序存储已走过的格子坐标用于最终输出。行/列计数数组row_cnt,col_cnt如上所述。终点坐标(n-1, n-1)作为搜索终止条件。有了状态接下来就要设计剪枝。无剪枝的DFS会探索所有可能的右下路径其数量是组合数 C(2n-2, n-1)当n4时是20尚可接受但n稍大就会指数爆炸。我们必须利用约束进行“可行性剪枝”。核心剪枝策略1即时超额检查在准备走入格子(x, y)之前我们先检查如果走入这个格子row_cnt[x] 1是否会超过题目给定的行约束row_target[x]或者col_cnt[y] 1是否会超过列约束col_target[y]如果超过那么这一步走法直接无效跳过。这个剪枝能提前终止大量不可能满足最终约束的搜索分支。核心剪枝策略2最终总量检查这是一个更强、更有效的剪枝。我们注意到从起点到终点总共需要走2n-1步因为从(0,0)到(n-1,n-1)需要向右走n-1步向下走n-1步。这也意味着路径的总格子数即路径长度是固定的2n-1。 那么所有行计数之和、所有列计数之和都应该等于这个总步数2n-1。即sum(row_target) 2n-1且sum(col_target) 2n-1。题目给出的数据必然满足此条件。我们可以利用这个总量进行剪枝。在DFS过程中我们维护当前已走的步数step也就是path.size()。当我们处于(x, y)时剩余需要访问的格子数是total_steps_needed (2n-1) - step。 同时我们计算剩余需要满足的行计数总和row_remain sum(row_target[i] - row_cnt[i]) 列同理col_remain。 一个必要的可行性条件是row_remain col_remain total_steps_needed。如果row_remain或col_remain已经小于total_steps_needed说明剩下的步数即使全部用来填补某些行或列也达不到目标要求了可以提前回溯。如果row_remain或col_remain大于total_steps_needed那更不可能因为每一步只能贡献1个行计数和1个列计数。实际上在每一步这个等式都应该成立这是一个非常强的约束。我们可以在递归入口处检查row_remain是否等于col_remain以及它们是否等于剩余步数。虽然计算总和有点开销但剪枝效果极好。核心剪枝策略3最终匹配检查终极剪枝当我们走到终点(n-1, n-1)时路径长度刚好是2n-1。此时我们不能直接认为找到答案因为可能有一条路径走到了终点但行/列计数与目标不完全一致。所以终点处的检查是row_cnt数组必须与row_target数组逐元素相等col_cnt与col_target亦然。只有全部相等才算找到唯一解。找到后应立即停止所有搜索通过全局标志或直接退出。2.3 方向选择与移动顺序题目规定只能向右或向下走。从当前点(x, y)我们可以尝试两个方向向右(x, y1) 需满足y1 n。向下(x1, y) 需满足x1 n。这里有一个重要的实操细节尝试的顺序。虽然对于有唯一解的题目顺序不影响找到答案但它影响搜索树的展开顺序。按照题目通常的输出要求或者为了调试时更直观我们可以按照“右”先于“下”的顺序来尝试。这样找到的第一条合法路径其坐标序列可能与出题人的预期顺序一致。在编码时我们可以定义一个方向数组dirs {{0, 1}, {1, 0}}来循环处理。3. DFS回溯的代码实现与逐行解析理论清晰后我们来看代码实现。我会用C进行演示并加入大量注释来解释每一处关键操作。#include iostream #include vector using namespace std; int n; // 棋盘大小 vectorint row_target; // 目标行计数 vectorint col_target; // 目标列计数 vectorint row_cnt; // 当前行计数 vectorint col_cnt; // 当前列计数 vectorpairint, int path; // 当前路径 bool found false; // 是否已找到答案的标志 // 计算剩余所需行/列计数的总和 int sum_remaining(const vectorint target, const vectorint current) { int remain 0; for (int i 0; i n; i) { remain target[i] - current[i]; } return remain; } void dfs(int x, int y) { // 1. 将当前节点加入路径并更新计数 path.push_back({x, y}); row_cnt[x]; col_cnt[y]; // 2. 终止条件到达终点 if (x n - 1 y n - 1) { // 检查是否完全满足目标约束 bool ok true; for (int i 0; i n; i) { if (row_cnt[i] ! row_target[i] || col_cnt[i] ! col_target[i]) { ok false; break; } } if (ok) { found true; // 找到答案 // 输出路径格式为坐标序列 for (size_t i 0; i path.size(); i) { cout path[i].first * n path[i].second; // 输出格子编号 if (i ! path.size() - 1) cout ; } cout endl; } // 无论是否找到答案都需要回溯退出当前分支 path.pop_back(); row_cnt[x]--; col_cnt[y]--; return; } // 3. 剪枝计算剩余步数和剩余需求 int steps_remaining (2 * n - 1) - path.size(); // 剩余需要走的步数 int row_remain sum_remaining(row_target, row_cnt); int col_remain sum_remaining(col_target, col_cnt); // 关键剪枝剩余需求必须等于剩余步数且行、列剩余需求相等 if (row_remain ! steps_remaining || col_remain ! steps_remaining || row_remain ! col_remain) { path.pop_back(); row_cnt[x]--; col_cnt[y]--; return; } // 4. 尝试两个方向右、下 // 方向数组右(0,1), 下(1,0) int dirs[2][2] {{0, 1}, {1, 0}}; for (int d 0; d 2; d) { int nx x dirs[d][0]; int ny y dirs[d][1]; // 检查新坐标是否在棋盘内 if (nx 0 nx n ny 0 ny n) { // 剪枝预检查加入(nx, ny)后是否会超出目标约束 if (row_cnt[nx] 1 row_target[nx] col_cnt[ny] 1 col_target[ny]) { dfs(nx, ny); if (found) return; // 找到答案立即层层返回终止所有搜索 } } } // 5. 回溯所有方向尝试完毕恢复状态返回上一层 path.pop_back(); row_cnt[x]--; col_cnt[y]--; } int main() { cin n; row_target.resize(n); col_target.resize(n); for (int i 0; i n; i) cin col_target[i]; // 注意输入顺序先列上边 for (int i 0; i n; i) cin row_target[i]; // 后行左边 row_cnt.assign(n, 0); col_cnt.assign(n, 0); path.reserve(2 * n); // 预分配空间避免频繁扩容 found false; dfs(0, 0); // 从起点开始搜索 return 0; }代码关键点解析状态更新与回溯的对称性这是回溯算法的核心纪律。在dfs函数开头我们push_back并增加计数在函数任何可能返回的地方找到终点、剪枝失败、所有方向尝试完都必须对称地执行pop_back和减少计数。上述代码中我们在三个地方执行了回溯操作终点返回前、剪枝返回前、函数最后。确保状态完全恢复是避免bug的重中之重。输入顺序题目描述是“上边”和“左边”输入样例通常是先给“上边”的列约束再给“左边”的行约束。这一点要仔细看题变量命名对应好。输出格式题目要求输出路径上格子的编号编号规则是行号 * n 列号。所以(0,0)是0(0,1)是1(1,0)是4当n4时。我们存储的是坐标对(x, y)输出时进行转换。找到答案立即终止在递归调用dfs(nx, ny)后我们检查found标志。如果为真说明子调用已经找到了答案并输出当前调用以及所有上层调用都无需再继续搜索其他分支直接return。这是一种高效的全局终止方式。预分配内存path.reserve(2 * n)不是必须的但是一个好习惯。我们知道路径最大长度是2n-1提前预留空间可以避免vector在增长过程中多次重新分配和复制对性能有微小提升。4. 深度剖析为什么必须回溯与剪枝的艺术很多初学者理解了DFS要回溯但写起来总是出错根源在于对“状态”和“搜索树”的理解不够形象。4.1 回溯的本质搜索树上的“归位”把整个DFS过程想象成走一棵巨大的迷宫树。每个树节点代表一个棋盘状态当前位置、当前路径、当前行列计数。从父节点状态A尝试一个方向走到子节点状态B意味着你在状态A的基础上做出了一个选择从而衍生出状态B。 回溯就是当你探索完以状态B为根的所有子树无论是否找到答案后你必须回到状态A才能去尝试状态A下的另一个选择另一个方向。如果你不回溯你的“当前状态”还停留在B的一些修改上那么尝试A的其他选择时初始条件就是错的。在上面的代码中状态A的“现场”包括path的末尾、row_cnt[x]和col_cnt[y]的值。当我们递归调用dfs(nx, ny)进入状态B前我们已经修改了现场push和增加计数。递归调用返回后意味着状态B及其子孙都探索完了我们必须把现场恢复成进入状态B之前的样子pop和减少计数这样才算是真正回到了状态A才能进行下一轮循环尝试另一个方向。一个常见的致命错误只在函数最后写一次回溯语句但在中途return比如剪枝return的地方忘了恢复状态。这会导致状态混乱搜索结果完全不可预测。确保每条返回路径都经过状态恢复点或者像我们代码中那样在函数开头修改状态在末尾统一恢复而中途return前先恢复再return。4.2 剪枝策略的效率对比与选择我们提到了三种剪枝它们的开销和效果不同即时超额检查开销极小就是两次数组访问和比较。它能第一时间阻止“局部不可能”的走法是最基础的剪枝必须做。最终总量检查开销中等需要循环计算剩余需求总和。但它能剪掉大量“全局已不可能”的分支。例如某一行剩下的需求是3但剩余步数只有2那么整个分支都不用走了。这个剪枝效果非常显著强烈建议加上。最终匹配检查这是在终点做的是判断是否找到答案的必要条件不算严格意义上的搜索过程剪枝。在实际运行中对于n4的官方用例不加任何剪枝的DFS也能瞬间跑完。但我们的代码应该具备通用性和鲁棒性。加上“最终总量检查”后算法在面对更大的n比如n6, 7或者约束更强的变种题时性能优势会非常明显。这是一种“以少量计算换取搜索空间大幅减少”的典型策略。4.3 路径记录的技巧与输出优化我们使用vectorpairint,int来记录路径。在找到答案需要输出时我们遍历这个vector。这里有一个可优化的点如果题目只要求输出一条路径我们可以在找到答案时将path复制到一个全局的answer路径中然后在主函数里输出。这样做的原因是我们的回溯会在输出后继续执行path会被清空。但在我们上面的代码中由于找到答案后设置了found标志并立即层层返回path在输出时正好是完整的答案路径所以没有问题。另一种记录方式是使用一个一维数组int path[MAX_STEPS]用索引step来记录每一步的格子编号。输出时直接循环打印数组即可。这种方式更节省空间但可读性稍差。vector的动态性更符合C现代编程习惯。5. 从解题到举一反三DFS回溯的通用模式“路径之谜”的解法可以抽象出一个解决此类“约束满足搜索题”的通用框架定义状态明确哪些变量组合起来能唯一描述搜索进行到哪一步。通常包括当前位置、已做出的选择集合路径、以及由这些选择衍生出的中间约束状态如本题的行列计数。设计递归函数函数参数至少包含当前状态。在函数内部 a.边界条件判断是否到达目标状态如本题的到达终点且约束全满足。是则处理答案并返回。 b.剪枝根据当前状态和约束判断是否可能到达目标。不可能则立即回溯返回。 c.枚举选择列出在当前状态下所有合法的下一步选择。 d.迭代尝试对于每一个合法选择 i.做出选择更新状态修改路径、更新约束计数等。 ii.递归深入调用自身进入下一层搜索。 iii.撤销选择恢复状态这是回溯的关键。初始化与启动设置好初始状态从起始点开始调用递归函数。将这个模式应用到其他题目比如八皇后、数独、全排列等都是完全适用的。区别只在于“状态”的定义和“选择”的枚举方式不同。例如在数独中状态当前填好的棋盘、每个行/列/宫格中数字的使用情况。选择在某个空位上填入1-9中当前行、列、宫格尚未使用的数字。剪枝如果某个空位没有任何数字可填则回溯。回溯尝试填入一个数字更新使用情况递归填下一个返回后擦除数字并恢复使用情况。最后分享一个我调试这类题目的心得当你的代码结果不对时不要急于看整个搜索过程。首先在递归函数的开头和结尾回溯前打印出关键状态比如当前坐标、路径、行列计数。用一个极小的用例比如n2手动模拟对比你的程序输出和手动推导的状态变化。十有八九问题就出在某个地方的状态更新或恢复没有做对称。耐心地单步调试或打印日志是解决回溯问题bug的最有效方法。
返回列表