算法第三章作业
动态规划算法学习与实践——最低通行费问题
一、实践报告
1.1 最优子结构分析及递归方程
(1)问题分析
本题给定一个 (N × N) 的正方形网格,每个小方格都有对应的通行费用。商人需要从网格左上角出发,到达右下角,并且必须在 (2N - 1) 个单位时间内完成通行。每经过一个小方格需要花费一个单位时间,要求在满足时间限制的情况下,使所支付的总费用最少。
由于商人从左上角到右下角至少需要经过 (2N - 1) 个小方格,而题目规定的时间正好也是 (2N - 1) 个单位时间,因此只能选择最短路径。也就是说,商人只能向右或者向下移动,不能通过向上、向左移动来绕路,否则就会超过规定时间。
因此,这道题可以使用动态规划算法,将从起点到终点的大问题分解成多个小问题,逐步计算到达每个方格所需要的最小费用。
(2)最优子结构分析
假设商人已经到达网格中的某个位置 ((i, j)),那么他到达这个位置之前,只有两种可能:
第一种是从上方的方格 ((i - 1, j)) 向下移动到当前位置。
第二种是从左边的方格 ((i,j-1)) 向右移动到当前位置。
由于商人只能向右或向下移动,所以到达当前位置的最小费用,就是上方和左边两个位置中最小的通行费用,再加上当前方格本身的费用。
也就是说,如果到达某个位置的路径不是最优的,那么就可以用一条费用更低的路径替换它,从而得到更优的结果。因此,该问题具有最优子结构性质,可以通过动态规划求解。
(3)状态定义
定义二维数组:
dp[i][j]
表示从网格左上角出发,到达第 i 行、第 j 列的方格时所需要支付的最小总费用。
其中,i 表示当前所在的行,j 表示当前所在的列。需要注意的是,dp[i][j] 计算的是从起点到当前位置的累计费用,因此包含当前方格本身的费用。
设 a[i][j] 表示第 i 行、第 j 列方格的通行费用。
(4)状态转移方程
根据上面的分析,到达当前位置只能从上方或者左边进入,因此需要比较这两个位置的最小费用。
当 (i>1) 且 (j>1) 时,状态转移方程为:
dp[i][j]=min(dp[i-1][j],dp[i][j-1])+a[i][j]
其中:
- (dp[i-1][j]):从上方到达当前位置之前所需要的最小费用。
- (dp[i][j-1]):从左边到达当前位置之前所需要的最小费用。
- (a[i][j]):当前方格需要支付的费用。
在这两种情况中取较小值,再加上当前方格的费用,就能得到到达当前位置的最小总费用。
(5)边界条件
为了让状态转移能够正常进行,需要先确定初始状态和边界条件。
① 起点位置:
商人从左上角出发,因此初始费用就是起点方格本身的费用:
dp[1][1]=a[1][1]
② 第一行:
由于商人只能向右移动,因此到达第一行其他方格只能从左边进入:
dp[1][j]=dp[1][j-1]+a[1][j]
③ 第一列:
由于商人只能向下移动,因此到达第一列其他方格只能从上方进入:
dp[i][1]=dp[i-1][1]+a[i][1]
确定这些边界条件后,就可以利用状态转移方程计算其他位置的最小费用。
1.2 填表法设计
(1)表的维度
根据上面的状态定义,建立一个二维数组:
dp[N+1][N+1]
其中:
- 行表示网格的行数,范围为 1 到 N。
- 列表示网格的列数,范围为 1 到 N。
- 每个表格元素表示从左上角到达对应位置所需要的最小总费用。
这里使用二维数组存储计算结果,可以方便地记录每个位置的最小通行费用,并为后续状态的计算提供数据。
(2)填表范围
填表范围为整个 (N*N) 网格,即:
1≤i≤N,1≤j≤N
首先计算起点位置 dp[1][1],然后依次计算第一行和第一列的状态,最后计算剩余位置的最小费用。
(3)填表顺序
采用从左上角到右下角、从上到下、从左到右的顺序进行填表。
首先确定起点的费用,然后依次填充第一行和第一列。接着从第二行第二列开始,按照状态转移方程计算其他位置。
这样安排的原因是,当前方格的状态只依赖于它上方和左边已经计算好的状态。按照这个顺序填表,可以保证每次计算时所需要的数据都已经得到。
(4)原问题的最优值
当整个二维表格填充完成后,到达右下角位置 ((N,N)) 所需要的最小费用,就是原问题的最优解。
因此,最终结果为:
dp[N][N]
即从左上角到右下角,在规定时间内完成通行所需要支付的最少费用。
(5)C++ 代码实现
#include <iostream>
#include <algorithm>
using namespace std;
int main() {
int N;
cin >> N;
int a[105][105];
int dp[105][105] = {0};
// 输入每个方格的通行费用
for (int i = 1; i <= N; i++) {
for (int j = 1; j <= N; j++) {
cin >> a[i][j];
}
}
// 起点位置的初始费用
dp[1][1] = a[1][1];
// 计算第一行
for (int j = 2; j <= N; j++) {
dp[1][j] = dp[1][j - 1] + a[1][j];
}
// 计算第一列
for (int i = 2; i <= N; i++) {
dp[i][1] = dp[i - 1][1] + a[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])
+ a[i][j];
}
}
// 输出最小通行费用
cout << dp[N][N] << endl;
return 0;
}
代码中使用二维数组 a 保存每个方格的通行费用,使用二维数组 dp 保存到达每个位置所需要的最小费用。
程序首先读取网格数据,并初始化起点、第一行和第一列。之后通过两层循环,依次计算其他方格的最小通行费用。每次计算时,利用 min() 函数比较上方和左边两个位置的费用,选择较小的一条路径,再加上当前方格的费用。
最后输出 dp[N][N],就可以得到从左上角到右下角的最少通行费用。
1.3 时间复杂度和空间复杂度分析
(1)时间复杂度分析
程序主要通过两层循环完成动态规划的填表过程。
外层循环遍历网格的每一行,内层循环遍历每一列,每个位置只需要进行一次比较和加法操作,所花费的时间为常数。
因此,对于一个 N*N 的网格,总共需要计算约 (N^2) 个状态,时间复杂度为:
T(N)=O(N^2)
(2)空间复杂度分析
程序使用两个二维数组 a 和 dp,分别保存网格费用和动态规划的计算结果,每个数组都需要存储 N*N 个元素。
因此,空间复杂度为:
S(N)=O(N^2)
二、对动态规划算法的理解和体会
通过这次最低通行费问题的练习,我对动态规划算法的理解更加深入了一些。之前学习动态规划时,虽然知道它需要将大问题分解成小问题,但真正遇到题目时,还是需要花一些时间去分析状态之间的关系。这次通过网格路径问题,让我对动态规划的求解过程有了更加直观的认识。
我觉得这道题比较重要的一点是确定状态的含义。一开始需要想清楚 dp[i][j] 到底表示什么,明确它表示从起点到当前位置所需要的最小总费用后,就可以进一步分析到达当前位置有哪些可能。由于只能向右和向下移动,所以当前位置只能由上方或者左边到达,再从两种情况中选择费用较小的一种,整个思路就逐渐清晰了。
通过这次填表过程,我也体会到动态规划中计算顺序的重要性。当前状态需要使用前面已经计算好的结果,因此不能随意安排填表顺序。按照从左上角到右下角的方向逐步计算,就能够避免重复计算,也能让整个过程更加清楚。
另外,我发现动态规划并不是所有题目都能直接套用同一个公式,关键还是要根据题目找出状态之间的联系。这次练习让我认识到,解决动态规划问题时,应该先理解题目,确定状态的含义,再分析状态转移方程和边界条件,最后才是编写代码。
总的来说,通过这次实践,我对动态规划的基本求解步骤有了更清晰的认识,也锻炼了自己分析问题和设计算法的能力。以后遇到类似的算法题,我会尝试先从简单的情况入手,寻找其中的规律,再逐步完善自己的解题思路,而不是一开始就急着写代码。

浙公网安备 33010602011771号