不同的搜索路径,可能会绕到完全相同的状态——把算过的状态缓存起来,下次直接查表,不用再重新展开一次搜索。
12.3 节的剪枝解决的是:一条分支还没走到底,就已经能看出走不通,于是提前放弃,省下后面白费的递归。本节要解决的是另一种、更隐蔽的浪费:有些分支各自看起来都合法,会一路正常地递归下去——但走着走着,两条完全不同的分支可能会殊途同归,绕到了完全相同的一个"状态"上,而代码并不知道这一点,于是把这个状态从头到尾又重新搜索了一遍。
8.2 节已经用斐波那契数列演出过这个思路:fib(4) 展开成一棵树后,fib(2) 被不同的分支各算了一次;解决办法是把算过的结果存起来,下次查表直接用,不重新递归,这就是记忆化(Memoization)。但斐波那契毕竟只是一条简单的线性递推,状态空间也就是一个数字 n。本节要把同样的思路用到真正的搜索问题上——状态不再是一个简单的数字,而是像"网格里的一个格子"这样更复杂的东西,搜索的方式也是 DFS,和 12.1~12.3 节一脉相承。
给定一个 R×C 的网格,每个格子有一个高度值。可以从当前格子滑向上下左右相邻的格子,但只能往严格更低的地方滑(目标格子的高度必须比当前格子小)。问:整个网格里,能滑的最长路径(经过的格子数)是多少?——这是信息学竞赛里"记忆化搜索"最经典的入门题(滑雪问题)。
用一个 3×3 的小网格来演示:
9→6→5→4→1(下、右、右、下),每一步都严格变小,一共 5 个格子dfs(i,j) 表示"从格子 (i,j) 出发,能滑的最长路径有多少个格子"——整个网格的答案,就是所有格子的 dfs(i,j) 中的最大值。dfs(i,j) 天然满足递归关系:dfs(i,j) = 1 + max{ dfs(相邻且更低的格子) },如果四周没有比它更低的格子,dfs(i,j) = 1(滑到这里就停了)。最直接的想法:对每个格子都调用一次 dfs(i,j),取所有结果里的最大值。dfs 本身就是一次 DFS——从 (i,j) 出发,往四个方向试探,只要相邻格子更低就递归下去,四个方向都不能走时返回 1。
| 1 | int h[55][55]; // 网格高度 |
| 2 | int R, C; |
| 3 | int dx[4] = {-1,1,0,0}, dy[4] = {0,0,-1,1}; // 上下左右 |
| 4 | |
| 5 | int dfs(int i, int j) // 从 (i,j) 出发能滑的最长路径 |
| 6 | { |
| 7 | int best = 1; // 至少能"滑"这一个格子本身 |
| 8 | for (int k = 0; k < 4; k++) |
| 9 | { |
| 10 | int ni = i + dx[k], nj = j + dy[k]; |
| 11 | if (ni < 0 || ni >= R || nj < 0 || nj >= C) { continue; } // 出界 |
| 12 | if (h[ni][nj] >= h[i][j]) { continue; } // 没有更低,不能滑 |
| 13 | best = max(best, 1 + dfs(ni, nj)); // ★ 每次都重新递归,即使 (ni,nj) 之前算过 |
| 14 | } |
| 15 | return best; |
| 16 | } |
问题出在第 13 行:从不同格子出发的搜索,很可能会绕到同一个格子。以刚才的 3×3 网格为例,格子 (1,1)=5 既能从 (0,1)=8 往下滑到达,也能从 (1,0)=6 往右滑到达——两条完全不同的搜索分支,都会各自调用一次 dfs(1,1),把"从 5 出发还能滑多远"这件事重新搜索一遍:
(1,1)=5 这个格子(粉色)在两条分支里都被当作起点重新搜索了一次——两次调用 dfs(1,1) 算出的答案完全相同(都是从 5 出发能滑 3 个格子:5→4→1)。网格越大,这种"殊途同归"的情况会越来越多,朴素 DFS 的调用次数会随格子数指数级增长。解决办法和 8.2 节一样:用一个数组把每个状态的答案缓存起来,进入 dfs(i,j) 时先查表,如果这个格子已经算过,直接返回缓存的结果,不再重新展开搜索。这里的"状态"是二维的 (i,j),所以缓存数组也开成二维。
| 1 | int dp[55][55]; // 记忆表:dp[i][j] 表示 dfs(i,j) 是否已经算过、算出的值是多少 |
| 2 | |
| 3 | int dfs(int i, int j) |
| 4 | { |
| 5 | if (dp[i][j] != -1) { return dp[i][j]; } // ✅ 查表:算过就直接返回,不再往下搜索 |
| 6 | int best = 1; |
| 7 | for (int k = 0; k < 4; k++) |
| 8 | { |
| 9 | int ni = i + dx[k], nj = j + dy[k]; |
| 10 | if (ni < 0 || ni >= R || nj < 0 || nj >= C) { continue; } |
| 11 | if (h[ni][nj] >= h[i][j]) { continue; } |
| 12 | best = max(best, 1 + dfs(ni, nj)); |
| 13 | } |
| 14 | return dp[i][j] = best; // ✅ 存表:算完顺手存起来,再返回 |
| 15 | } |
dfs(1,1) 时,如果分支 A 已经算过并存进了 dp[1][1],就直接把结果拿来用,连四个方向的 for 循环都不会执行。第 14 行把"返回"和"存表"合并成一行:算出 best 之后,先赋值给 dp[i][j],同一个表达式的值又是这次要返回的结果,一举两得。加上读入数据和主函数,对每个格子调用一次 dfs,取所有结果里的最大值:
| 1 | #include <iostream> |
| 2 | #include <cstring> |
| 3 | using namespace std; |
| 4 | |
| 5 | int h[55][55], dp[55][55], R, C; |
| 6 | int dx[4] = {-1,1,0,0}, dy[4] = {0,0,-1,1}; |
| 7 | |
| 8 | int dfs(int i, int j) |
| 9 | { |
| 10 | if (dp[i][j] != -1) { return dp[i][j]; } |
| 11 | int best = 1; |
| 12 | for (int k = 0; k < 4; k++) |
| 13 | { |
| 14 | int ni = i + dx[k], nj = j + dy[k]; |
| 15 | if (ni < 0 || ni >= R || nj < 0 || nj >= C) { continue; } |
| 16 | if (h[ni][nj] >= h[i][j]) { continue; } |
| 17 | best = max(best, 1 + dfs(ni, nj)); |
| 18 | } |
| 19 | return dp[i][j] = best; |
| 20 | } |
| 21 | |
| 22 | int main() |
| 23 | { |
| 24 | cin >> R >> C; |
| 25 | for (int i = 0; i < R; i++) |
| 26 | for (int j = 0; j < C; j++) cin >> h[i][j]; |
| 27 | memset(dp, -1, sizeof(dp)); // ★ 全部初始化成 -1,表示"还没算过" |
| 28 | |
| 29 | int ans = 0; |
| 30 | for (int i = 0; i < R; i++) |
| 31 | for (int j = 0; j < C; j++) |
| 32 | ans = max(ans, dfs(i, j)); // 每个格子都可能是最长路径的起点 |
| 33 | |
| 34 | cout << ans << endl; |
| 35 | } |
| 写法 | 每个状态被计算的次数 | 时间复杂度 |
|---|---|---|
| ③ 朴素 DFS | 可能被多条分支重复计算,最坏情况下随格子数指数增长 | 最坏 O(2^(R×C)) 级别 |
| ④⑤ 记忆化 DFS | 每个状态只计算一次,之后全部查表 | O(R×C)(状态数)× O(4)(每个状态的转移) |
并不是所有 DFS 都能直接套上记忆化,需要满足两个条件:
| 条件 | 在滑雪问题里对应什么 |
|---|---|
| ① 状态能被清晰地定义、编号 | 用 (i,j) 这一对坐标就能唯一确定一个状态,可以直接当数组下标 |
| ② 状态转移无后效性(不能有环) | 只能往严格更低的格子滑——高度值只减不增,保证不会滑回原来的格子,状态之间不会形成循环依赖 |
-1 初始化 dp 数组,就是因为最短路径长度也可能是一些边界情况下的合法值。如果偷懒直接把数组默认初始化为 0,一旦某个状态的正确答案恰好是 0,就会被误判成"没算过"而重复计算;反过来,如果某个状态还没算过,却被误当成"已经算出 0"直接返回,答案就错了。养成习惯:初始化用一个不可能是合法答案的值(比如 -1),或者干脆用一个额外的 bool visited[][] 数组专门记录"是否算过"。dfs(A) 需要 dfs(B),dfs(B) 又需要 dfs(A)),递归永远无法触底,直接栈溢出。记忆化搜索能生效的前提,是状态之间的依赖关系构成一张有向无环图(DAG)——本题正是靠"严格递减"这个条件天然保证了不会成环。dp 数组要开在全局(或用 static),不能开在 dfs 函数内部当局部变量——否则每次调用都会重新创建一份空的记忆表,等于完全没有缓存效果。数组大小也要按题目给定的最大范围开够,避免越界。