题目再现

P1004 [NOIP 2000 提高组] 方格取数

题目背景

NOIP 2000 提高组 T4

题目描述

设有 $N \times N$ 的方格图 $(N \le 9)$,我们将其中的某些方格中填入正整数,而其他的方格中则放入数字 $0$。如下图所示(见样例):

某人从图的左上角的 $A$ 点出发,可以向下行走,也可以向右走,直到到达右下角的 $B$ 点。在走过的路上,他可以取走方格中的数(取走后的方格中将变为数字 $0$)。
此人从 $A$ 点到 $B$ 点共走两次,试找出 $2$ 条这样的路径,使得取得的数之和为最大。

输入格式

输入的第一行为一个整数 $N$(表示 $N \times N$ 的方格图),接下来的每行有三个整数,前两个表示位置,第三个数为该位置上所放的数。一行单独的 $0$ 表示输入结束。

输出格式

只需输出一个整数,表示 $2$ 条路径上取得的最大的和。

输入输出样例 #1

输入 #1
8
2 3 13
2 6  6
3 5  7
4 4 14
5 2 21
5 6  4
6 3 15
7 2 14
0 0  0
输出 #1
67

说明/提示

数据范围:$1\le N\le 9$。

🧠 算法思路简述

  • 设计状态 dp[t][x1][x2] 表示总共走了 t 步时,第一个人位于 (x1, y1),第二个人位于 (x2, y2),其中 y1 = t - x1,y2 = t - x2。
  • 状态转移由四种组合(右右、右下、下右、下下)构成。
  • 每步加上当前两个位置的格子值(如果两个点重合只加一次)。

🧭 调试全过程分阶段回顾

阶段 0:初始提交

  • 代码状态:safe_max4(t - 1, …) 内部访问的是 dp[t][…]
  • 表面现象:样例输出是 29,部分点为乱值
  • 根本原因:t = 0 时会访问 dp[-1][…],数组下标越界,触发 undefined behavior(UB)
  • 修正措施:加越界保护,如 if (t < 1) return -1;

阶段 1:补越界之后仍错

  • 代码状态:加了 t < 1 的保护返回 -1
  • 表面现象:样例仍输出 29
  • 根本原因bool a = (x1 > 0 && t - x1 > 0),误判 y1 = 0 的格子不合法,把第一列所有转移砍掉了
  • 修正措施:条件写成 >= 0,或仅保留 x1 > 0,其余交由 dp==-1 判定

阶段 2:误判偏移能抵消不等式

  • 代码状态:传 safe_max4(t - 1, …),内部又访问 dp[t - 1][…]
  • 表面现象:仍然错误
  • 根本原因:即使逻辑上你偏移了 t,也无法改变 y=0 时前驱仍为 y=0 的事实,转移仍然被过滤
  • 修正措施:明确 y=0 是合法状态,条件必须为 >= 0

阶段 3:最终修正通过所有测试点

  • 代码状态:所有转移判断均使用 >= 0,路径判断逻辑完全合理
  • 结果:样例 67,自测样例输出 30,全部 AC

🧱 错误要点拆解

1. 越界 ≠ 安全读取

你可能觉得“我只是读一下数组又没写,能有啥事?”但只要下标非法就是 Undefined Behavior,编译器可能直接跳过某个分支甚至整个函数逻辑。

2. 第一行 / 第一列被误判为非法

动态规划中,坐标 (x, y=0) 是完全合法的状态,其前驱 (x, y-1) 有可能不存在,但 (x-1, y) 却是合法的。因此不应把 y=0 直接排除在外。

3. “我偏移了 t,所以 y = 0 不会出现”?错。

即便你在外层传入的是 t - 1,内部再访问 dp[t - 1][...]y = t - x 的表达仍然成立,y=0 的状态仍然会出现,只不过你自己绕了一下写法,逻辑没变。

🎯 最终 AC 代码

#include <bits/stdc++.h>

using namespace std;

const int N = 9;
int mp[N][N], dp[N*2][N][N];

int safe_max4(int t, int x1, int x2) {
    if (t < 1) return -1;
    bool a = (x1 > 0 && t-x1 >= 0);
    bool b = (x2 > 0 && t-x2 >= 0);
    return max(
        max(a && b ? dp[t-1][x1-1][x2-1] : -1, dp[t-1][x1][x2]),
        max(a ? dp[t-1][x1-1][x2] : -1,
            b ? dp[t-1][x1][x2-1] : -1)
    );
}

int main() {
    int n; cin >> n;
    
    int x, y, v;
    while (cin >> x >> y >> v, x || y || v) mp[x-1][y-1] = v;

    memset(dp, -1, sizeof dp);
    dp[0][0][0] = mp[0][0];
    for (int t = 0; t <= n*2-2; ++t)
    for (int x1 = 0; x1 < n; ++x1)
    for (int x2 = 0; x2 < n; ++x2) {
        int y1 = t-x1, y2 = t-x2;
        if (y1 < 0 || y2 < 0 || y1 >= n || y2 >= n) continue;
        
        int cur = mp[x1][y1];
        if (x1 != x2 || y1 != y2)
            cur += mp[x2][y2];

        int &res = dp[t][x1][x2];
        int pre = safe_max4(t, x1, x2);
        if (pre >= 0) res = pre + cur;
    }
    
    cout << dp[n*2-2][n-1][n-1] << endl;
    return 0;
}

✅ 经验建议整理

  • 先保证不越界,再谈状态合法性:数组访问不越界是底线,状态转移是否合法可以由 dp 值判断(如 dp==-1)。
  • 善用最小极端数据做边界验证:例如 3×3、所有值集中在边角、第一列/行等,能暴露大部分 off-by-one 错误。
  • 优先写成 >= 再回调测试收紧:对边界的放松更能捕获真实状态,必要时再收紧逻辑。
  • 开启编译器或运行时检查工具:如 -fsanitize=address,undefined 或使用 Valgrind,可以第一时间发现 dp[-1] 之类的致命 UB。

🎬 结语

这是一道经典的“不是算法难,是实现难”的动态规划问题。你可能设计了一个很漂亮的状态转移,但只要下标错一个、边界漏一层,最终结果就天差地别。

这道题让我再一次认识到:**写对一个状态转移不难,写对所有边界才是硬功夫。**希望你在看完这篇笔记之后,也能不再被越界和 off-by-one 坑住啦!