补题日记:26-1-13
Table of Contents
Powered by GhostFace's Emacs.
前言:
Ctorch诞生的第179天,小小的庆祝一下。
再次宣传我们团队的项目:https://github.com/ShengFlow/CTorch/tree/feature-nn.Module
这是一个纯C++写的高性能ML库,我们将会在2026.2.16(除夕夜24:00)发布第一个内测版本(RC 1),敬请期待。
这是一次比赛的赛后补题记录&题解(Part 2)。
本文由 Emacs 29.0 Org-mode 写成,在此感谢GNU做出的开源软件运动。
Problem C:P1434 [SHOI2002] 滑雪
这道题是我认为非常有价值的一道题目,它的价值不在于思维复杂,而是提供了一个「搜索与动态规划之间的桥梁」。
它告诉我们:“记忆化搜索就是动态规划的一种实现方式”,换言之,记忆化搜索与动态规划是「一体两面」的关系,任何动态规划都可以写成记忆化搜索。
我们后文会阐述这一点。
题目大意:
给定一个矩阵,寻找矩阵中的一条最长下降路径。
WriteUp:
这道题目在一本通·提高篇里仍然有。
这道题很明显应该使用搜索解决,然而,当你把一个裸的dfs提交上去时,你就会发现,这道题TLE了。
原因是什么呢?我们重复搜索了太多的点(或者说:「解空间」)。
那么怎么解决这个问题呢,我们考虑一下,是否在任意时刻,我们搜索到矩阵中 点 \(A_{i,j}\) 时,其结果都是一样的?
答案是显然的,对于任何一个点,从这个点出发的「最长下降路径长度」始终不变。
那么对于同一个点,我们就没必要在同样的结果上花费时间进行搜索。
由此,我们可以使用一个数组 \(memo\) 存储搜索过的结果,其中 \(memo_{i,j}\) 代表从 \(A_{i,j}\) 出发的「最长下降路径长度」。
正如我们一开始所说,“记忆化搜索和动态规划是一体两面的关系”,对于这道题,我们就有两种方法解决,它们是等价的。
一、记忆化搜索
如上文所述,我们需要改造一下dfs主函数,使其返回一个 \(int\) 型的值,代表从 \(A_{i,j}\) 出发的「最长下降路径长度」。
随后,在每次搜索之前,我们需要检查一下 \(memo\) 数组中是否存在结果,如果存在,直接返回,不再搜索。
这个套路是通用的,当我们发现问题的 「重叠子问题」 时,我们就可以使用记忆化搜索。
代码如下:
#include <bits/stdc++.h>
using namespace std;
static const int MAXN = 105;
static int height[MAXN][MAXN];
static int memo[MAXN][MAXN];
static int n, m;
static const int dx[] = {0, 0, 1, -1};
static const int dy[] = {1, -1, 0, 0};
// 自顶向下的记忆化搜索
static int dfs(int x, int y) {
int& res = memo[x][y]; // 这里使用了引用,即“&”,它不会拷贝变量,更省内存,尤其是在使用vector这种容器时,拷贝会极其费时费力。
if (res != -1) return res; // 已计算过则直接返回
res = 1; // 至少包含当前节点
for (int dir = 0; dir < 4; ++dir) {
const int nx = x + dx[dir];
const int ny = y + dy[dir];
// 越界检查
if (nx < 1 || nx > n || ny < 1 || ny > m) continue;
// 只能向更低处移动
if (height[x][y] <= height[nx][ny]) continue;
res = max(res, dfs(nx, ny) + 1);
}
return res;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
if (!(cin >> n >> m)) return 0;
for (int i = 1; i <= n; ++i) {
for (int j = 1; j <= m; ++j) {
cin >> height[i][j];
}
}
// 初始化 memo 为 -1 表示未访问
memset(memo, -1, sizeof(memo));
int answer = 0;
for (int i = 1; i <= n; ++i) {
for (int j = 1; j <= m; ++j) {
answer = max(answer, dfs(i, j));
}
}
cout << answer;
return 0;
}
二、动态规划
接下来我们考虑如何改为动态规划。
分析一下这两种方法的本质,你就会发现:记忆化搜索是自顶向下的,我们从原问题出发,递归求解子问题。
而动态规划则是自底向上的,我们从最小的子问题出发,推出原问题的解。
对于这道题而言,最小的子问题是什么呢?
最小的一个问题就是最低点的dp值,显然等于1.
我们注意到,当前点只能由比它更低的点转移来,而且等于周围四个点中结果的最大值加一(当然,必须比当前点低)。
不难写出dp方程如下: $$f_{i,j} = max(f_{i-1,j},f_{i+1,j},f_{i,j-1},f_{i,j+1}) + 1$$ ,各个点需要比当前点低。
我们发现,高点的dp值依赖低点的dp值,因此我们的顺序就是十分重要的,我们需要优先写出低点的dp值。
这个过程可以使用优先队列实现。
代码如下:
#include <bits/stdc++.h>
using namespace std;
static constexpr int MAXN = 105;
static int height[MAXN][MAXN];
static int dp[MAXN][MAXN]; // dp[i][j] 表示从点 (i,j) 出发的最长路径
static int n, m;
struct Cell {
int i, j, h;
// 重载
bool operator>(const Cell& other) const { return h > other.h; }
};
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
if (!(cin >> n >> m)) return 0;
priority_queue<Cell, vector<Cell>, greater<Cell>> pq;
for (int i = 1; i <= n; ++i) {
for (int j = 1; j <= m; ++j) {
cin >> height[i][j];
dp[i][j] = 1; // 初始状态:至少包含自身
pq.push({i, j, height[i][j]});
}
}
int answer = 0;
while (!pq.empty()) {
auto [x, y, cur_h] = pq.top();
pq.pop();
const int current_dp = dp[x][y];
answer = max(answer, current_dp);
// 尝试向四个方向扩展(从低到高处理,所以是反向传播)
const int dx[] = {-1, 1, 0, 0}; // 上下左右
const int dy[] = {0, 0, -1, 1};
for (int dir = 0; dir < 4; ++dir) {
const int nx = x + dx[dir];
const int ny = y + dy[dir];
// 越界检查
if (nx < 1 || nx > n || ny < 1 || ny > m) continue;
// 只能从更高的邻居转移过来(反向思考:当前点更新其高处邻居)
if (height[nx][ny] <= cur_h) continue;
dp[nx][ny] = max(dp[nx][ny], current_dp + 1);
}
}
cout << answer;
return 0;
}
这里面使用了运算符重载这个技巧,类似的功能也可以使用lambda表达式实现,这里不再赘述,可以自己学习,非常简单。
总结:
很明显,这道题目既是一道搜索,也是一道dp,可以这么说,dp就是记忆化搜索。
当一道dp我们无法写出其状态转移方程时,不妨试试写一个记忆化搜索,两者是等价的。
他们的区别就是搜索需要使用递归栈,而dp不需要;dp会扩展每一个状态,而搜索只会扩展需要的状态。
这种「按需扩展、计算」的方法也被称为“懒计算”,这种思想贯彻到了多个方面,比如线段树的lazy-tag,C++模版元编程的表达式模版。
另外,如果各位跟笔者一样,学过汇编,就会明白,什么是递归栈、爆栈。
结语:
由于本蒟蒻的时间不是很充足,今天仅仅分析了一道题目。
但是仍然是很有价值的,这道题目堪称是记忆化搜索和理解dp的典型。
剩余的题目会在明天继续。
扩展知识:C++模版元编程、懒计算、尾递归优化、lambda表达式、运算符重载。
如有笔误,欢迎指正,感激不尽!
2026.1.13.

浙公网安备 33010602011771号