补题日记:26-1-13

Table of Contents

  1. 前言:
  2. Problem C:P1434 [SHOI2002] 滑雪
    1. 题目大意:
    2. WriteUp:
      1. 一、记忆化搜索
      2. 二、动态规划
    3. 总结:
  3. 结语:

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.

posted @ 2026-01-13 22:34  Ghost-Face  阅读(13)  评论(0)    收藏  举报