动态规划实验报告

动态规划实践报告

作业题目:算法第三章 3(2026) 题目「7-4 最低通行费」

项目 内容
课程 算法设计与分析
题目 7-4 最低通行费(动态规划)
姓名 / 学号 (待填)
完成日期 2026-11-03

0. 题意确认与关键建模

题目描述:一个商人要穿过一个 N×N 的正方形网格,从左上角 (1,1) 进、右下角 (N,N) 出。每穿过一个小方格需要花费 1 个单位时间,商人必须在 2N-1 个单位时间内穿出。经过每个小方格都需要缴纳费用 a[i][j],求穿出网格所需的最小总费用。不能对角穿越,只能向上下左右四个方向移动,且不能离开网格。

关键一步:从 (1,1) 到 (N,N),若只向右、向下,需要走 2N-2 步、经过 2N-1 个格子,正好等于时限。一旦允许向上或向左,步数会多出偶数步,必然超过 2N-1 而超时。

因此:

「2N-1 的时间限制」等价于「路径只能向右或向下」,即只能沿单调路径前进。

这一步把原问题从「带步数约束的网格移动」收缩为「有向无环图(DAG)上的最短路」,也是动态规划能够成立的前提——网格图的边只朝右、朝下,不存在反向边,因此存在天然的拓扑序,可以按序填表。


1. 实践报告

1.1 根据最优子结构性质,列出递归方程式

(1)状态定义

f(i,j):从 (1,1) 出发,只向右或向下移动,到达 (i,j) 时,路径上所有格子费用(包含起点与终点 a[i][j])累加所能取得的最小值。

(2)最优子结构性质

设 P 是到达 (i,j) 的一条费用最小的路径。由于只允许向右、向下,P 的最后一步只能来自 (i-1,j)(向下走)或 (i,j-1)(向右走),故 P 去掉最后一步后,一定是到达某个前驱 p 的一条路径,其费用为 f(i,j) - a[i][j]。

若该前缀不是从 (1,1) 到 p 的最小费用路径,则将其替换为更优的前缀后,得到的整条路径费用更小,与 P 的最优性矛盾。故:

到达 (i,j) 的最优解,必然由其前驱的最优解构成 —— 最优子结构成立。

同时,f(i,j) 的取值只与位置 (i,j) 有关,与「如何走到这里」无关,满足无后效性。

(3)递归方程式

f(i,j) = a[i][j] + min{ f(i-1,j), f(i,j-1) },    1 ≤ i,j ≤ N,  (i,j) ≠ (1,1)

(4)边界条件

f(1,1) = a[1][1]                       // 起点,费用只有自身
f(0,j) = f(i,0) = +∞                   // 哨兵:越界视为不可达

哨兵的作用是保证首行、首列的元素只有一个有效前驱(例如 f(1,2) 只能由 f(1,1) 转移而来,f(0,2)=+∞ 不会干扰 min)。

等价的显式写法(不使用哨兵时):

f(1,j) = f(1,j-1) + a[1][j]            // 第一行只能一直向右
f(i,1) = f(i-1,1) + a[i][1]            // 第一列只能一直向下

(5)原问题的最优值

f(N,N)

1.2 填表法中表的维度、填表范围和填表顺序,以及原问题的最优值对应的元素

(1)表的维度

使用二维数组 f[0..N][0..N],规模为 (N+1) × (N+1)。

  • 第 0 行、第 0 列为哨兵(值为 +∞),不参与实际含义;
  • 有效元素共 N² 个,即 f[1..N][1..N];
  • 另需存储费用矩阵 a[1..N][1..N](输入数据)。

(2)填表范围

i = 1, 2, ..., N
j = 1, 2, ..., N

其中 (1,1) 不参与递推,单独赋初值 f(1,1) = a[1][1]。

(3)填表顺序

外层 i 从小到大递增,内层 j 从小到大递增,即按行优先(从左上到右下)的顺序逐格计算。

理由:f(i,j) 只依赖两个已确定位置的元素——

  • f(i-1,j):正上方,位于上一行,本行计算时已计算完毕;
  • f(i,j-1):正左方,位于同一行的前一列,刚刚被计算过。

依赖方向恒为「上」和「左」,因此「i 递增、j 递增」的顺序天然构成递推关系的合法拓扑序,不会出现「用到尚未计算的值」的情况,也无需递归或记忆化。

(4)原问题最优值对应的表格元素

f[N][N],即二维表的右下角元素,同时也是填表过程中最后一个被填入的元素。

(5)填表过程示例(样例输入,N = 5)

输入的费用矩阵 a:

 1   4   6   8  10
 2   5   9  15  17
 4   6  12  18  20
 7   8  19  22  25
 9   6  20  25  30

逐格填出的 DP 表 f:

 1   5  11  19  29
 3   8  17  32  46
 7  13  25  43  63
14  21  40  62  87
23  27  47  72 102
  • 最少通行费:f[5][5] = 102
  • 对应最优路径:(1,1) → (2,1) → (3,1) → (3,2) → (4,2) → (5,2) → (5,3) → (5,4) → (5,5)
  • 路径经过格子数:9 = 2N-1,恰好用满时间限制
  • 路径费用校验:1+2+4+6+8+6+20+25+30 = 102 ✓
  • 暴力校验:枚举全部 C(8,4) = 70 条单调路径,最小费用同为 102、路径一致 ✓
  • 空间优化版(一维滚动数组)结果同为 102 ✓

1.3 算法的时间复杂度和空间复杂度分析

(1)时间复杂度

  • 状态总数:N²(f[1..N][1..N]);
  • 每个状态的转移代价:1 次 min 比较 + 1 次加法,为 O(1);
  • 因此:T(N) = Θ(N²)。

本题 N ≤ 100,运算量约 10⁴ 量级,可在 1ms 内完成,完全满足要求。

(2)空间复杂度

  • 需要存储 DP 表 f((N+1)×(N+1))与费用矩阵 a(N×N),故 S(N) = Θ(N²);
  • 若只需输出最小费用、不需要回溯路径,可使用一维滚动数组压缩到 Θ(N):
// 一维滚动数组版本
vector<int> g(n + 1, INF);
for (int i = 1; i <= n; i++)
    for (int j = 1; j <= n; j++) {
        if (i == 1 && j == 1) { g[j] = a[i][j]; continue; }
        g[j] = a[i][j] + min(g[j], g[j - 1]);
    }
// 结果:g[n]

其正确性在于:处理到第 i 行第 j 列时,g[j] 中保存的仍是上一行的 f(i-1,j)(尚未被覆盖),而 g[j-1] 已在本次内层循环中被更新为 f(i,j-1),恰好就是所需的两项前驱。

  • 取舍:压缩空间后丢失整张表,无法回溯最优路径;若题目要求输出方案,则必须在 Θ(N²) 空间与「额外维护 O(N) 前驱指针」之间做选择。

(3)与其他解法的对比

若删除「2N-1 时限」这一约束、允许四个方向自由移动,问题就退化为一般网格图上的最短路,需要用 Dijkstra 算法,时间 O(N² log N),且路径可能绕行。本题的时间限制正是为了排除绕行、把问题化为 DAG 上的动态规划,这也是本题的题眼所在。


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

做这道题之前,我一直误以为动态规划的难点是「把递推式写出来」。真正动手才发现,递推式往往是最简单的一步,难的是把题目文字翻译成一个可以递推的状态。本题就是典型:题面给的是「时间限制」,不是「移动限制」。我第一反应是「网格最短路,跑 BFS / Dijkstra」,甚至想过用状压记录走过哪些格子。直到把 2N-1 这个数字和 2N-2 步对上,才意识到这条时间限制真正掐掉的是「绕路」,等价于强制只向右或向下走——网格随即变成一个 DAG,DP 才有立足点。所以做题的次序应该是:先看清解空间的形状(是不是 DAG、有没有重叠子问题),再谈方程。

第二点体会是状态定义要带「目的性」,并且尽量让公式短。我第一版写的是「到达 (i,j) 之前的最小费用」(不含 a[i][j]),边界写起来别扭,递推时反复漏加当前格费用,还得多写一个 ans = f[N][N] + a[N][N] 收尾。改成「含 (i,j)」后,方程式和边界同时变短。状态定义不是越直观越好,而是要让转移和边界同时最简。

第三点是边界和哨兵不是形式主义。用 +∞ 哨兵能省掉一堆 if,但 INF 的取值很关键:0x3f3f3f3f 加上 100 仍然安全,而如果图省事写 INT_MAX,INF + a[i][j] 直接溢出成负数,min 会毫不犹豫挑中那个溢出的值——程序不报错,答案却悄悄变错。这类 bug 最难受的地方在于它「看起来对」。我后来的自查办法是把整张 DP 表和手算的 3×3 小例子逐格对照,错误通常集中在第一行、第一列。

第四点是填表顺序其实就是依赖关系的可视化。只依赖左、上,行优先就一定合法;如果哪天依赖跑到了右下,就得反向填或者换枚举维度。顺序错了不会报错,只会读到还没算的初值,这也是 DP 里最隐蔽的一类错误,所以我现在写 DP 前会先画一张小网格,把箭头标出来再写循环。

第五点,贪心在这里是明确失效的。我构造了一个 3×3 反例:

1 8 3
5 5 1
3 7 9

每步都走「邻居里更便宜的那个」会得到 1+5+3+7+9 = 25,而最优是 1+5+5+1+9 = 21(先向下、再向下、再向右、再向右)。贪心只看一步之内的局部代价,DP 则把所有可能的前驱都算出来再取最小值——DP 的本质不是「更聪明的选择」,而是「不提前选择」。这也让我理解了为什么 DP 要付出 O(N²) 的空间:它花钱把所有子问题的答案都存下来,换取「每一步都能看到全局最优」。

第六点是验证比推理重要。递推式写得再顺,也可能错在某个下标上。我这次的做法是:先用暴力枚举所有单调路径对拍(5×5 只有 70 条,3×3 只有 6 条),确认 DP 值与最优值一致;再用滚动数组写第二版,两版答案互相印证。DP 出错的症状往往是「答案只差一点」,光看代码很难定位,必须靠对拍和打印中间表。

最后一点是关于记忆化搜索和递推的关系。两者是同一个递推式的两种写法:一个自顶向下带备忘录,一个自底向上按拓扑序填表。本题依赖简单、状态全有效,迭代填表更省常数;但如果状态空间稀疏、存在大量到不了的状态,记忆化搜索只算「真正用到的」那些,反而更划算。所以「会不会写记忆化搜索」不是技巧问题,而是判断「哪些状态值得算」的能力。

总的来说,动态规划给我的感觉是用空间换掉「选择」这件事:把「每一步该怎么决策」这个指数级的难题,拆成 N² 个「只需看两个前驱」的小问题,然后老老实实按顺序填完。它的门槛在建模和边界,它的美感在顺序。


附录:完整代码

// 7-4 最低通行费 —— 动态规划
#include <cstdio>
#include <algorithm>
#include <vector>
using namespace std;

const int INF = 0x3f3f3f3f;

int main() {
    int n;
    if (scanf("%d", &n) != 1) return 0;
    vector<vector<int>> a(n + 1, vector<int>(n + 1, 0));
    for (int i = 1; i <= n; i++)
        for (int j = 1; j <= n; j++)
            scanf("%d", &a[i][j]);

    // f[i][j]: 从 (1,1) 只向右/向下走到 (i,j),含 a[i][j] 的最小总费用
    vector<vector<int>> f(n + 1, vector<int>(n + 1, INF));

    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= n; j++) {
            if (i == 1 && j == 1) { f[i][j] = a[i][j]; continue; }
            f[i][j] = a[i][j] + min(f[i - 1][j], f[i][j - 1]);
        }
    }

    printf("%d\n", f[n][n]);
    return 0;
}

样例运行结果

输入:
5
1 4 6 8 10
2 5 9 15 17
4 6 12 18 20
7 8 19 22 25
9 6 20 25 30

输出:
102
posted @ 2026-11-03 12:16  youci  阅读(9)  评论(0)    收藏  举报