ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

DFS超时怎么办?记忆化搜索原理与实战应用

DFS超时怎么办?记忆化搜索原理与实战应用 1. DFS为什么会超时从指数爆炸说起1.1 DFS的核心思想与适用场景说到DFS搜索算法搞过C编程的朋友应该都不陌生。深度优先搜索的核心思想其实就是“一条路走到黑撞了南墙再回头”——从一个状态出发沿着一条分支递归到底走不通了再回溯到上一个分岔口换一条路继续试。这种自顶向下的递归遍历方式天然适合解决路径探索、排列组合、图连通性判定、迷宫寻路这类问题。我当年第一次接触DFS是在写一个迷宫求解程序。当时觉得这玩意儿太奇妙了一个递归函数加上一个visited数组就能在二维矩阵里自动寻找从起点到终点的路径。后来刷题多了才发现DFS远没有看起来那么“天真”它真正让人头疼的地方在于指数级的复杂度。一个裸写的DFS如果每个状态有2个分支递归深度是n那么整棵递归树的节点数是2^0 2^1 ... 2^n也就是O(2^n)级别。n20的时候还能勉强跑完n30就开始卡顿等到n40以上基本就是等一个“天荒地老”。很多初学者在LeetCode上写DFS样例能过一提交就超时Time Limit Exceeded问题往往就出在这里。1.2 重复子问题DFS超时的真正元凶裸DFS为什么会这么慢关键在于大量重复子问题的反复计算。拿最经典的斐波那契数列来举例。递归定义是fib(n) fib(n-1) fib(n-2)代码写起来很简单int fib(int n) { if (n 1) return n; return fib(n - 1) fib(n - 2); }这代码逻辑完全正确但性能惨不忍睹。画一下递归树你会发现计算fib(5)需要先算fib(4)和fib(3)算fib(4)又需要fib(3)和fib(2)算fib(3)又需要fib(2)和fib(1)……你会发现fib(3)被反复算了不止一次而是整整2次fib(2)被算了3次。随着n增大重复计算的次数呈指数增长。这里就是问题的关键同一棵递归树里大量节点的入参完全相同计算结果也必然相同但我们却把同样的计算过程重复执行了一遍又一遍白白浪费CPU时间。打个生活化的比方你每天都要做西红柿炒鸡蛋如果每次都从洗西红柿、打鸡蛋开始一天做三顿饭就重复洗刷三次。正常人都会一次性把西红柿洗好切好鸡蛋打好放冰箱用的时候直接下锅。记忆化搜索干的就是这件事——把已经算好的结果缓存起来下次遇到一模一样的输入直接查表返回不再重新递归展开。2. 记忆化搜索的核心原理与思维转变2.1 什么是记忆化用一个数组记住算过的答案记忆化搜索Memoization Search的思路说穿了就一句话在DFS递归过程中用一个数组或哈希表把已计算状态的结果保存下来下次遇到相同状态时直接取用不再递归计算。听起来特别简单对吧但真正难的不在“记”这个动作而在于两个前置问题什么情况下需要记忆化用什么作为状态的标识第一个问题其实有规律可循。当你发现DFS的递归函数里入参相同的情况下输出一定相同而且同一个入参组合会被多次访问这两条同时满足就可以套上记忆化。这种性质在算法领域叫“重叠子问题”是动态规划的两大基石之一另一个是“最优子结构”。至于第二个问题通常是拿出递归函数的所有入参把它们打包成一个“状态”。一维参数用一维数组二维参数用二维数组参数再多就用哈希表配一个结构体key。数组的下标就是状态参数数组的值就是该状态的答案。2.2 记忆化搜索的通用模板与状态设计我在实战中用得最多的记忆化DFS模板长这样// memo数组初始化为-1表示该状态尚未计算 int memo[N]; memset(memo, -1, sizeof(memo)); int dfs(int state) { // 1. 如果已经算过直接返回 if (memo[state] ! -1) { return memo[state]; } // 2. 递归出口 / 边界条件 if (state 0) { return base_value; } // 3. 核心递归逻辑逐步拆解子问题 int ans ...; // 根据题意计算 for (/* 所有可能的下一步选择 */) { ans combine(ans, dfs(next_state)); } // 4. 返回前记住结果 return memo[state] ans; }这个模板的精髓在于第4步——返回值之前先把答案存进memo数组下次再遇到这个state第一步就直接返回memo[state]整棵递归树的重复分支被彻底掐断。提示memo数组初始化为-1而不是0是一个非常重要的细节。因为有些题目的真实答案可能就是0如果用0做“未计算”的标记就会把已算出的0误判为“还没算”导致重复计算甚至死循环。状态设计这块我踩过不少坑。最典型的一个错误是只记了部分参数漏了影响结果的维度。比如递归函数是dfs(pos, sum)你却只用memo[pos]来做记忆化sum不同时答案可能不同直接导致错误结果。记住一个原则凡是能影响返回值变化的入参都必须纳入记忆化数组的维度。2.3 状态定义失败的两个经典教训先说第一个把状态定义在变化的量上。我有个朋友写一个递归题状态用了一个全局变量cur递归函数里每次修改cur再往下传。他想当然地用memo[cur]做记忆化结果发现同一个cur在不同递归路径下对应的“未来结果”完全不一样。这是因为cur只是路径的中间状态真正决定未来结果的还有递归深度、已用资源等其他因素。教训就是记忆化数组的每一维都必须对应递归函数的自有入参而不是全局变量的即时值。第二个教训是误把回溯型DFS套上记忆化。有一类DFS问题需要在路径上选择、撤销比如八皇后、全排列每一步的选择会影响后续可选集合这种问题严格来说不能用简单的memo[N]来记忆化。因为记忆化要求“从状态A出发的结果是确定且唯一的”而带路径选择的DFS从同一状态出发后续结果会因为前面路径上已经用了哪些元素而改变。如果非要套记忆化必须把“已使用集合”也维度化通常用状态压缩来表示这就是另一层话题了。3. 从零到一经典题目实战拆解3.1 入门案例斐波那契数列的DFS改造我们继续用斐波那契数列来实操。先写一个带memo数组的记忆化版本#include cstdio #include cstring const int MAXN 1005; long long memo[MAXN]; long long fib(int n) { if (n 1) { return n; } if (memo[n] ! -1) { return memo[n]; } return memo[n] fib(n - 1) fib(n - 2); } int main() { memset(memo, -1, sizeof(memo)); int n 50; printf(fib(%d) %lld\n, n, fib(n)); return 0; }分析一下复杂度变化。裸DFS的时间复杂度是O(2^n)而记忆化之后每个n只需要计算一次总计算量是O(n)空间开销是O(n)的memo数组。从指数级直接降到线性级这就是记忆化的威力。实际跑一下你会发现n50的裸递归版本几乎卡死而记忆化版本瞬间出结果。我记忆中当年自己第一次把这两版代码分别跑起来对比时那种震撼感到现在还记得——同一个算法思想只加了一个数组性能差距就有天壤之别。3.2 进阶案例滑雪问题矩阵中的最长递减路径斐波那契只是热身真正能体现DFS记忆化价值的是二维状态问题。这里我拿LeetCode 329“矩阵中的最长递增路径”来说或者竞赛圈常说的“滑雪问题”一个矩阵每个格子有高度你可以从任意格子出发每次只往上下左右四个方向中高度严格递减的格子走问最长能走多少步。裸DFS的思路很直接枚举每个起点递归地向四个方向更低的位置走统计步数。#include vector #include algorithm using namespace std; class Solution { private: int rows, cols; vectorvectorint matrix; vectorvectorint memo; int dx[4] {-1, 1, 0, 0}; int dy[4] {0, 0, -1, 1}; int dfs(int x, int y) { // 已经算过直接返回已记录的最长路径长度 if (memo[x][y] ! 0) { return memo[x][y]; } int best 1; // 自身至少是1步 for (int k 0; k 4; k) { int nx x dx[k]; int ny y dy[k]; // 边界检查 严格递减条件 if (nx 0 nx rows ny 0 ny cols matrix[nx][ny] matrix[x][y]) { best max(best, dfs(nx, ny) 1); } } return memo[x][y] best; } public: int longestIncreasingPath(vectorvectorint mat) { if (mat.empty() || mat[0].empty()) return 0; matrix mat; rows mat.size(); cols mat[0].size(); memo.assign(rows, vectorint(cols, 0)); int ans 0; for (int i 0; i rows; i) { for (int j 0; j cols; j) { ans max(ans, dfs(i, j)); } } return ans; } };这段代码里memo[x][y]表示从格子(x, y)出发能走出的最长递减路径长度。每个格子只被真正计算一次其余都是查表总复杂度从理论上界的O((rowscols)^2)降到O(rowscols)。注意dfs里初始化的best 1不是0因为每个格子自身也算一步这是初学者很容易漏掉的边界条件。我复盘这个题目时发现记忆化DFS的思路和直接从递推式写DP相比优势在于“天然贴合搜索语义”——你不需要在脑子里先构建状态转移顺序只需要从某个点出发递归地“相信子问题能算出来”可读性和调试性都更高。3.3 多维度状态数位DP中的记忆化搜索再往前一步当状态维度变多记忆化搜索的优势会更加明显。以“统计[L, R]区间内不含连续数字”这类数位DP题为例递归函数通常长这样long long dfs(int pos, int prev_digit, bool limited, bool started) { ... }这里的状态包含四维当前处理到第几位、前一位数字、是否被上限限制、是否已经开始。写成递推DP这个状态转移方程需要仔细梳理边界但用记忆化DFS只需要按原问题的搜索逻辑把四个参数当作数组下标存起来就行long long memo[pos][prev_digit][limited][started];注意涉及数位DP时limited和started其实是枚举分支的时候临时加的控制参数它们的组合数量不大。通常记忆化可以只针对“非limited且已started”的状态缓存因为limited状态每个数字只能走一次缓存了也复用不上。但为了简单起见全维度缓存也无妨只是浪费一点空间。实战中我一般把memo全部初始化为-1然后对“非限制状态”做记忆化限制状态直接裸递归这样的写法最稳妥也最容易排查。4. 记忆化搜索与动态规划一条路的两面4.1 为什么说记忆化搜索是“带缓存的DFS”学算法有个有趣的现象很多人第一次学动态规划DP时被状态转移方程搞得一头雾水但如果你先学会了DFS记忆化再回头看DP会发现很多题目瞬间豁然开朗。实际上记忆化搜索本质就是自顶向下的动态规划。它从最终要解决的问题出发不断拆解成子问题递归求解把子问题的解存起来复用。而经典递推DP是自底向上的先算出最小子问题的解再一层层往上推导出更大问题的解。两种写法解的是同一道题用的是同一条状态转移关系只是“遍历方向”不同。以斐波那契为例递推DP是f[0] 0; f[1] 1; for (int i 2; i n; i) { f[i] f[i - 1] f[i - 2]; }而记忆化DFS的memo[n] fib(n-1) fib(n-2)两者状态定义完全一致计算顺序相反。4.2 递推DP与记忆化搜索的对比选型我在实际做题中总结了一套选型标准对比维度递推DP记忆化搜索思维难度需要先定义计算顺序符合自然递归思维实现难度状态转移方程写错难调只需改裸DFS容易验证空间开销通常全部状态数组懒计算只算用到的状态状态转移顺序依赖强依赖需保证子问题先算无依赖天然递归典型适用场景一维线性递推、背包图上搜索、二维矩阵、数位DP关键结论是当状态转移存在明显的“顺序依赖”时比如最长上升子序列递推DP更高效当状态之间是图状关系、互相交错引用时记忆化搜索反而更好写。举一个最典型的场景在有向图上做最长路径统计状态之间可能有环或交叉。你很难用递推DP确定一个安全的计算顺序但记忆化DFS加一个访问标记就可以处理。有些书本会把这种“记忆化DFS”称为“DAG上的动态规划”本质就是在DAG有向无环图上做递归缓存。4.3 从记忆化到递推的状态转移方程推导对于想进一步掌握DP的同学我建议用“先写DFS、再转递推”的方式练手。具体分三步先写裸DFS不考虑性能只保证逻辑正确。递归函数的参数就是状态维度return的就是问题答案。加上memo数组做记忆化确认复杂度达标、结果正确。观察递归的调用方向把memo改成从头开始计算的递推数组配合循环顺序写成标准DP。这个过程做完一遍你对状态定义、边界条件、计算顺序的理解会非常扎实。我见过不少同学直接背状态转移方程但遇到变体题就蒙圈反过来从DFS推DP的同学往往能举一反三。5. 常见问题与排查技巧实录5.1 记忆化搜索踩过的3个坑这么多年用下来我整理了记忆化搜索最常见的三个坑每个都是真金白银换来的教训。坑一memo数组初值选择错误。前面提过-1和0的选择不是小事。如果你的答案是自然数包括0初始化为-1最安全如果答案可能是-1则要初始化为一个绝对不可能的值比如INT_MIN。千万别用默认的0去碰答案是0的题否则会误判“已计算”导致结果错误。坑二递归深度过大导致栈溢出。当n达到10^6级别时DFS递归层数过深程序直接爆栈Stack Overflow。C默认栈空间在Windows下通常是1MBLinux下是8MB左右。解决思路有两个一是把递归改成显式栈迭代二是把记忆化搜索改成自底向上的递推DP绕开递归调用。我一般优先选后者因为代码改动通常不大。坑三状态参数里有“限制条件”维度的误缓存。比如数位DP中的limited标志它表示当前位是否被上界约束。如果天真地把所有状态都缓存经常会出现“不同限制条件下结果相同却因为缓存互相覆盖而算错”的情况。我的经验法是只在非限制条件下写memo限制条件直接裸递归。很多教材里也是这样处理的官方术语叫“记忆化只缓存‘自由状态’”。5.2 性能排查与优化思路如果你写完记忆化DFS仍然超时我会按这个顺序排查检查状态维度是否完整是不是漏了某个参数导致缓存命中率极低检查memo数组是否真的被有效使用打印一下memo的填充次数和命中次数如果命中率低于30%说明状态设计有问题或者递归分支太“分散”每个状态只被访问一次记忆化形同虚设。检查是否有多余的重复初始化和全局变量污染递归函数里如果依赖外部可变变量比如全局累加器极容易在回溯中串味这是比超时更隐蔽的逻辑错误。如果状态空间本身就极大比如n10^7O(n)的缓存可能内存不够——这时候考虑用unordered_map做稀疏缓存只存真正被访问到的状态。关于调试我有个非常推荐的技巧在小数据量下打印每次递归的入参和返回结果和裸DFS版本做交叉验证。我经常打印一个简单的case人工核对两版输出是否一致确认记忆化没有改变语义再放心提交。注意记忆化搜索的正确性依赖于“无后效性”——也就是某状态的答案只取决于小于它的子状态与到达它的路径无关。如果题目本身不满足无后效性比如路径选择影响后续选择强行加记忆化会得到错误答案。判断标准很简单把递归函数看成纯函数相同入参是否永远得到相同结果如果是就能记忆化如果不确定先用小规模暴力验证。最后再说一个我的个人体会我刷了大概300多道算法题之后的一个明显变化是拿到一个搜索类问题第一反应不再是“裸DFS能不能过”而是“状态是什么能不能记忆化”。这种思维的转变比多背几道模板题重要得多。真正把记忆化搜索用熟练之后你会发现它不只是DFS的加速器更是一把理解动态规划的钥匙。很多看起来高深的状态压缩DP、树形DP拆开来看底层都是“DFS 缓存”这个组合的变体。建议刚开始学的朋友先把斐波那契和滑雪问题亲手各写三遍裸DFS一遍、递归记忆化一遍、递推DP一遍。这三遍跑完你对搜索和动态规划的理解会比单纯刷十道题都扎实。最后再分享一个小彩蛋在C里如果状态是二维数组且规模偏大可以优先用vectorvectorint而不是定长的二维数组方便配合assign函数快速重置如果用到unordered_map做稀疏缓存务必自定义哈希函数或采用pairint,int转long long的压缩技巧否则哈希碰撞严重时反而拖慢速度。这些小细节都是实战中拿时间换来的经验。
返回列表