7-4 最低通行费 —— 动态规划实践报告
一、问题分析
读题后我发现这道题有一个关键的“陷阱”,必须先想明白,否则会误以为要开三维状态。
商人从左上角 (1,1) 走到右下角 (N,N),每走一格花 1 单位时间,必须在 2N-1 单位时间内走出去。
关键观察:
从左上角到右下角,若只往右和下走,路径长度恰好是 (N-1)+(N-1) = 2N-2 步,经过 2N-1 个格子。
题目要求“必须在 (2N-1) 个单位时间穿越出去”,而每穿越一个方格花 1 单位时间,也就是说最多只能经过 2N-1 个格子。
而任何从左上到右下的路径(允许上下左右),最短长度都不可能少于 2N-2 步(曼哈顿距离下界),即至少经过 2N-1 个格子。
所以“必须 ≤ 2N-1”就等价于“必须恰好 = 2N-1”,也就等价于路径中只能向右或向下,不能向左或向上(否则会绕路,格子数超过 2N-1)。
这一步是我觉得这道题最巧妙的地方:题面里“不能对角穿越、可以向上下左右”看似给了四个方向,实际上时间约束把可行路径限制成了只能向右、向下。所以本题本质就是经典的“数字三角形 / 矩阵最小路径和”问题。
二、1.1 最优子结构性质与递归方程式
状态定义
设 dp[i][j] 表示:从起点 (1,1) 走到格子 (i,j),且只走右、下方向时,所需要缴纳的最小总费用(包含 (i,j) 这个格子本身的费用)。
设 cost[i][j] 为格子 (i,j) 的费用。
最优子结构分析
要走到 (i,j),最后一步只能来自:
上方的 (i-1,j)(向下走一步到达),或
左方的 (i,j-1)(向右走一步到达)。
到达 (i,j) 的最优路径,其前缀一定也是到达 (i-1,j) 或 (i,j-1) 的最优路径——否则把前缀替换成更优的,整体会更优,矛盾。这正是最优子结构。
由此得到递归方程式
边界条件
dp[1][1] = cost[1][1](起点费用必须算)。
第一行:只能从左边过来,dp[1][j] = dp[1][j-1] + cost[1][j](j ≥ 2)。
第一列:只能从上边过来,dp[i][1] = dp[i-1][1] + cost[i][1](i ≥ 2)。
为避免越界访问,可以把 dp[0][] 和 dp[][0] 视为无穷大(实现时我用 INF 初始化并单独处理边界,或直接用上面的边界公式)。
二、1.2 填表法:维度、范围、顺序与最优值位置
表的维度:二维表 dp[N+1][N+1](下标从 1 开始,方便和题目格子编号对应)。
填表范围:i 从 1 到 N,j 从 1 到 N。
填表顺序:按行从上到下、每行从左到右(即 i 递增、j 递增)。这样计算 dp[i][j] 时,dp[i-1][j](上一行)和 dp[i][j-1](本行左边)都已经算好,满足依赖关系。这属于自底向上的迭代填表,避免了递归的重复计算。
原问题最优值:dp[N][N],即从起点到右下角的最小总费用。
用样例验证一下思路(样例 N=5):按此方程推下去得到 dp[5][5] = 109,和题目给的 1+2+5+7+9+12+19+21+33 = 109 一致,说明建模正确。
二、1.3 复杂度分析
时间复杂度:表格共 N×N 个元素,每个元素只做一次 min 和一次加法,O(1) 转移,所以总时间是 O(N²)。对于 N<100,最多约 10⁴ 次运算,远小于 400 ms 时限。
空间复杂度:需要 N×N 的 dp 表,为 O(N²),N<100 时约 10⁴ 个 int,约 40 KB,在 64 MB 内存限制内绰绰有余。
可以进一步用滚动数组把空间压到 O(N)(因为 dp[i][j] 只依赖上一行和本行左边),但 N<100 时没必要,保留二维表可读性更好。这点我在报告里作为“优化思考”提出,体现对空间优化的理解。
三、C++ 实现
cpp
include
include
using namespace std;
const int MAXN = 105;
int cost[MAXN][MAXN];
int dp[MAXN][MAXN];
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int N;
cin >> N;
for (int i = 1; i <= N; ++i)
for (int j = 1; j <= N; ++j)
cin >> cost[i][j];
// 边界与填表
dp[1][1] = cost[1][1];
for (int j = 2; j <= N; ++j)
dp[1][j] = dp[1][j - 1] + cost[1][j]; // 第一行
for (int i = 2; i <= N; ++i)
dp[i][1] = dp[i - 1][1] + cost[i][1]; // 第一列
for (int i = 2; i <= N; ++i)
for (int j = 2; j <= N; ++j)
dp[i][j] = min(dp[i - 1][j], dp[i][j - 1]) + cost[i][j];
cout << dp[N][N] << '\n';
return 0;
}
四、对动态规划算法的理解和体会
这次作业让我对 DP 有了几点更具体的认识:
“读懂约束”比“套模板”更重要。 一开始我看到“可以向上下左右移动”,第一反应是想开 (i, j, 步数) 的三维 DP 甚至最短路。但仔细推一下步数下界才发现,2N-1 这个约束直接把四个方向砍成了两个方向,问题瞬间退化成经典模型。DP 的第一步永远是准确刻画“什么状态下合法”,约束条件本身就是解题的钥匙。
最优子结构的本质是“无后效性 + 可替换性”。 能写 dp[i][j] = min(上, 左) + cost 的前提是:走到 (i,j) 之后,后面的走法和之前怎么来的无关;并且前缀若更优,整体一定更优。这两点想清楚了,方程几乎是自然写出来的,而不是硬背。
填表顺序是“依赖关系”的直接体现。 我习惯先画出 dp[i][j] 依赖哪些格子(这里是上方和左方),再决定遍历顺序(行优先、列优先都行,只要依赖先算)。顺序错了结果就错,这是迭代 DP 最容易踩的坑。
空间优化是理解了依赖范围之后的“副产品”。 这让我理解到:DP 的空间复杂度取决于“状态转移的宽度”。
浙公网安备 33010602011771号