最低通行费:动态规划求解实践报告

这周的作业是写一份实践报告:按照动态规划法的求解步骤,分析第三章那道 7-4 最低通行费——列出递归方程式、说明填表法怎么填、分析复杂度,最后谈谈对动态规划的理解。

题目本身的递推式很短,几分钟就能写完;真正花时间的是想清楚"为什么可以只往右和往下走"。下面按作业要求的顺序记一遍。

问题描述

一个商人要穿过一个 N×N 的正方形网格,从左上角 (1,1) 进、右下角 (N,N) 出。每穿越一个小方格要花 1 个单位时间,并且要按该格交纳一定的通行费;他必须在 (2N-1) 个单位时间内穿越出去。求最少要交多少费用。

移动能上、下、左、右四个方向,不能走对角线,也不能走出网格。数据范围:1 ≤ N < 100,每格费用是不大于 100 的整数。

先把"时间限制"翻译成"路径限制"

这一步看着像废话,其实是整道题的入口。

从 (1,1) 到 (N,N) 的曼哈顿距离是 2(N-1) = 2N-2,所以任何走法至少要走 2N-2 步、至少经过 2N-1 个格子(含起点和终点)。而题目给的时限恰好就是 2N-1 —— 也就是说,一步都不能多走。

只要向左或向上回头一次,步数就会超过 2N-2,时间必然超标。于是合法路径只剩下一类:每一步都向右或向下,恰好 N-1 次向下、N-1 次向右。

问题由此变成一个干净的结构:在一堆"只往右、只往下"的单调路径里,找总费用最小的那条。四方向带环的最短路(那得用 Dijkstra)在这里根本不会出现,因为时间条件把它排除掉了。

1 动态规划的求解步骤

1.1 最优子结构性质与递归方程式

状态的定义(这一步最要紧)

设 dp[i][j] 表示:从 (1,1) 出发、只向右或向下走、到达 (i,j) 的所有路径中,通行费总和的最小值。费用按格子收,包含起点 (1,1) 和终点 (i,j) 这两格自身的费用,也就是这条路径上所有格子的 a 值之和。

最优子结构性质

考虑最优路径的最后一步。当 i ≥ 2、j ≥ 2 时,(i,j) 的前驱只可能是两个:(i-1,j)(从上面走下来)或 (i,j-1)(从左边走过来)。

假设最优解是从 (i-1,j) 走进 (i,j) 的。那么这条路径中"从 (1,1) 到 (i-1,j)"的那一段,必须是到达 (i-1,j) 的最优路径——否则把它换成一条更省钱的路径,再加上 a[i][j],就得到一条到达 (i,j) 且总费用更小的路径,与"最优"矛盾。

于是原问题的最优值可以由两个规模更小的子问题的最优值直接组合出来:

\[dp[i][j] = \min\left(dp[i-1][j],\ dp[i][j-1]\right) + a[i][j], \qquad 2 \le i, j \le N \]

边界条件

第一行只能一路从左边走过来,第一列只能一路从上面走下来,起点则没有前驱,需要直接赋值:

\[dp[1][1] = a[1][1] \]

\[dp[1][j] = dp[1][j-1] + a[1][j], \qquad 2 \le j \le N \]

\[dp[i][1] = dp[i-1][1] + a[i][1], \qquad 2 \le i \le N \]

有了这三行,第一行第一列就变成"只有一个来源"的累加,第二行第二列开始才用 min 的式子。原问题要求的就是从 (1,1) 到 (N,N) 的最小费用,正好是 dp[N][N]。

边界还有一种等价的写法:把 dp[0][j] 和 dp[i][0] 都置成正无穷,并单独给 dp[1][1] 赋值。这样 min 式子在第一行第一列会自动退化成"只剩唯一合法来源"。两种写法等价,分开写更好懂,我写代码时用的是分开写的那种。

无后效性

dp[i][j] 的取值只由 dp[i-1][j]、dp[i][j-1] 和 a[i][j] 决定,和"具体沿着哪条路径走到 (i,j)"无关。正因如此,状态里只需要坐标 (i,j),不需要把走过的路线也记进状态——这是能把二维表压成 N×N 个数的前提。

1.2 填表法的维度、范围、顺序与最优值位置

表的维度

一张二维表 dp[1..N][1..N],共 N² 个元素。dp[i][j] 对应一个子问题:"从 (1,1) 走到 (i,j) 的最小费用"。程序里数组多开一行一列(下标从 1 开始),输入的网格 a 用同样大小的二维表。

填表范围

i = 1..N、j = 1..N,整张表 N² 个元素全部都要填,没有可以跳过的格子。因为每个 dp[i][j] 都会被它右边和下边的格子当成来源用到,漏一个后面就会读到错的值。

这一点和其他几种常见的表不太一样:矩阵链相乘只需要填上三角(i < j),0-1 背包的二维表也只用到一部分。这里的子问题是"哪个格子",N² 个都有意义。

填表顺序

只要保证算 dp[i][j] 时,dp[i-1][j](上一行同列)和 dp[i][j-1](本行左邻)都已经算好就行。最简单的是行优先,i 从小到大、j 从小到大:

for i ← 1 to N
    for j ← 1 to N
        if i = 1 and j = 1
            dp[i][j] ← a[i][j]
        else if i = 1
            dp[i][j] ← dp[i][j-1] + a[i][j]
        else if j = 1
            dp[i][j] ← dp[i-1][j] + a[i][j]
        else
            dp[i][j] ← min(dp[i-1][j], dp[i][j-1]) + a[i][j]

顺序的本质是依赖关系的一张拓扑序。两个来源都在 (i,j) 的左上方向,所以任何"i 增、j 也增"的扫描方式都合法:行优先、列优先、甚至按 i+j 从小到大的反对角线顺序都可以。反过来,如果从右下往左上填,就会读到还没算出来的值。

原问题的最优值是哪个元素

表的右下角元素 dp[N][N]。它对应的子问题就是"从 (1,1) 走到 (N,N) 的最小费用",和题目要求完全一致。左上角 dp[1][1] = a[1][1] 是这张表的起点,其余元素都是中间过程。

填表过程走一遍

用一个 4×4 的网格:

4
2 5 3 9
7 1 4 2
6 8 5 3
1 4 7 2

按上面的顺序填出来的 dp 表(程序实际跑出来的结果):

dp j=1 j=2 j=3 j=4
i=1 2 7 10 19
i=2 9 8 12 14
i=3 15 16 17 17
i=4 16 20 24 19

几个位置对照着看比较清楚:

  • dp[1][1] = 2、dp[1][2] = 2 + 5 = 7……第一行就是一路累加。
  • dp[2][2] = min(dp[1][2], dp[2][1]) + a[2][2] = min(7, 9) + 1 = 8,从上面来更贵,所以取了左边。
  • 答案是 dp[4][4] = 19,对应的路径是 (1,1) → (1,2) → (2,2) → (2,3) → (2,4) → (3,4) → (4,4),费用 2+5+1+4+2+3+2 = 19。

顺手用"枚举所有单调路径"的暴力程序对了 400 组 N ≤ 6 的随机数据,结果全部一致。

另外提一句起点:这里按"起点也要交费"处理(dp[1][1] = a[1][1])。如果题意改成起点不收费,只要把这一行改成 0,递推式和填表顺序都不用动——边界是唯一会受这种口径影响的地方。

1.3 时间复杂度与空间复杂度

时间复杂度

填表是两层循环,共 N² 个格子;每个格子的转移只做一次比较和一次加法,是 O(1)。再加上读入 N² 个费用,总时间复杂度

\[O(N^2) \]

和暴力比一下就知道省在哪:一条单调路径由"N-1 次向下"在 2N-2 步里的位置唯一确定,共有

\[C_{2N-2}^{N-1} \]

条。N = 20 时这个数约 3.5×10¹⁰,枚举完全不可能。动态规划把"枚举路径"换成了"枚举状态",每个状态只算一次,代价从指数级降到多项式级。

按题目范围 N < 100,N² < 10⁴,本机对 n = 99 的随机数据实测约 0.01 秒。

空间复杂度

需要存 dp 表 N×N,加上输入的网格 a 也是 N×N,所以空间复杂度是

\[O(N^2) \]

N = 99 时两张表加起来不到 100 KB,没有任何压力。

能不能再省

能。dp[i][j] 只依赖"上一行同列"和"本行左邻",所以整张表其实不必保留:用两行滚动即可,甚至一行也行——dp[j] 在被覆盖前正好是上一行的 dp[i-1][j],覆盖后就是本行的 dp[i][j]。这样空间降到 O(N)。

但代价是丢掉历史信息:算完之后没法回溯出最优路径到底是哪一条。这份作业要求说明表的维度、范围和最优值位置,所以我保留了完整的二维表;如果题目要输出路径,也必须保留整张表。

2 对动态规划算法的理解和体会

第一,这道题的难点在读题,不在递推。

如果只注意到"四方向移动",这就是个带正权、可能有环的图最短路,得请 Dijkstra 出场。正是"必须在 2N-1 个单位时间内"这一句把可行路径压成了单调路径,才让"每个格子只有两个来源"成立。现在回头看,我觉得读题时先把约束条件翻译成"解的结构",比盯着题目想公式有用得多。

第二,三个前提缺一不可。

最优子结构保证"用子问题的最优值能拼出父问题的最优值";无后效性保证"状态里存的信息足够,不需要记录历史";重叠子问题保证"存下来有价值"。这道题从 (1,1) 到 (i,j) 的路径有 \(C_{i+j-2}^{i-1}\) 条,如果不用表,同一批子问题会被反复算;反过来,如果子问题之间根本不重叠,那分治就够了,用不上动态规划。

第三,真正要反复推敲的是状态的定义。

把 dp[i][j] 定义成"含起点和终点费用的路径和",第一行第一列就必须单独累加;如果定义成"到 (i,j) 为止、但不含 (i,j) 自己的费用",递推式就要写成 min(dp[i-1][j] + a[i-1][j], dp[i][j-1] + a[i][j-1]) 这种绕口的样子,还容易写错下标。我觉得状态定义一旦精确到"包含哪些格子、允许哪些走法",递推式和边界几乎是自动出来的——这是动态规划里最需要练、也最容易翻车的部分。

第四,填表顺序就是依赖图的拓扑序,它是推出来的,不是背出来的。

行优先之所以可行,是因为两个来源恰好都在"左上"。换个问题顺序就得变:区间 DP(dp[i][j] 依赖 dp[i][k] 和 dp[k+1][j])要按区间长度递增填,完全背包的内层循环必须正序才能让同一件物品重复使用,都是同一个道理。想不清楚顺序的时候,画一下状态之间的依赖箭头,选一条"箭头都指向已算格"的顺序就行。

第五,空间优化是有代价的,先写朴素版更稳。

滚动数组能把 O(N²) 降到 O(N),但会丢掉路径信息;能优化的前提是"旧值不再被需要"。我的习惯是先老老实实写二维表、跑对,再考虑压维——一上来就写一维数组,出错时很难判断是递推式错了还是覆盖时机错了。

最后是一个具体的教训:第一行和第一列最容易翻车。

如果只写 dp[1][1] = a[1][1],然后对所有 i, j ≥ 2 直接套 min 式子,第一行和第一列的格子会去读 dp[0][j]、dp[i][0] 这些根本没定义的位置,结果是错的(甚至看不出来错,因为小数据可能碰巧对上)。现在的习惯是把边界和递推式分开写,写完用一个小网格手算一遍再对照——这节课的几个 DP 题里,我出错的地方基本都是边界。

posted @ 2026-11-03 00:40  Gerate  阅读(4)  评论(0)    收藏  举报