动态规划之走格子问题
问题一:求最小(大)路径和
有一个由数字组成的规格为 n × m n\times m n×m 的矩阵,初始在左上角,要求每次只能向下或向右移动,问该数字矩阵从最左上角到最右下角的最小路径和是多少?(路径和就是将某路径中的所有权值全部加起来的总和)
算法分析
这类题会很自然地想到用暴力搜索法,但是暴力法在数据范围过大的情况下是难以胜任的。出现这种情况的原因和斐波那契数列中的递归法一样——会出现大量的重复工作,耗费大量时间。
比如对于某个数字矩阵:
1 3 5 7 8 6 4 2 5 0 1 3 4 8 7 2 \begin{equation*} \begin{matrix} 1 & 3 & 5 & 7\\ 8 & 6 & 4 & 2\\ 5 & 0 & 1 & 3\\ 4 & 8 & 7 & 2 \end{matrix} \end{equation*} 1854360854177232
从位置(1,1)到(3,3)这之间存在的路径有以下六条:
- 1 → 3 → 5 → 4 → 1 1→3→5→4→1 1→3→5→4→1
- 1 → 3 → 6 → 4 → 1 1→3→6→4→1 1→3→6→4→1
- 1 → 3 → 6 → 0 → 1 1→3→6→0→1 1→3→6→0→1
- 1 → 8 → 6 → 4 → 1 1→8→6→4→1 1→8→6→4→1
- 1 → 8 → 6 → 0 → 1 1→8→6→0→1 1→8→6→0→1
- 1 → 8 → 5 → 0 → 1 1→8→5→0→1 1→8→5→0→1
因此,在利用暴力法从位置 ( 1 , 1 ) (1,1) (1,1) 到位置 ( 3 , 3 ) (3,3) (3,3) 处,再到最右下角位置 ( 4 , 4 ) (4,4) (4,4) 时,我们都需要尝试这六种走法,并分别与路径 1 → 3 → 2 1→3→2 1→3→2 以及 1 → 7 → 2 1→7→2 1→7→2 进行求和后再比较并求出其中的最短路,可见这样的算法是极其低效的。而优化的方法也正是通过生成一个和原矩阵大小相同的二维表,来将从起点到每个位置的最小路径和记录下来,进而以直接取值来替代多次重复递归来达到优化目的。下面详细介绍一下该算法的具体实现。
首先我们需要来填写一张和题目中给出的矩阵大小相同的表格,如下:
第一步:初始化。由于在上侧和左侧的边界位置(即最上方一行和最左边一列),其路径只有一条(最上方的每个格子的前一个格子只能来自左方、最左边的每个格子的前一个格子只能来自上方),我们别无选择,因此可以直接填写,如下:
第二步:确定某个位置的最小路径和。拿位置坐标为 ( 2 , 2 ) (2,2) (2,2) 的这个点来说,由于走到这一点只能来自其上方或左方,因此为了使得走到这一点时的路径和最小就需要从其上方或左方中选出值较小的那个点作为其前驱结点。于是我们在填写位置坐标为 ( 2 , 2 ) (2,2) (2,2) 这个点的最小路径和时,所用的公式为:
d p [ 2 , 2 ] = m i n ( d p [ 2 , 1 ] , d p [ 1 , 2 ] ) + v a l u e [ 2 , 2 ] = m i n ( 9 , 4 ) + 6 = 4 + 6 = 10 dp[2,2] = min(dp[2,1],dp[1,2]) + value[2,2] = min(9,4) + 6 = 4+6 =10 dp[2,2]=min(dp[2,1],dp[1,2])+value[2,2]=min(9,4)+6=4+6=10
其中
d
p
[
x
,
y
]
dp[x,y]
dp[x,y] 表示位置坐标为
(
x
,
y
)
(x,y)
(x,y) 的最小路径和,
v
a
l
u
e
[
x
,
y
]
value[x,y]
value[x,y] 表示位置坐标为
(
x
,
y
)
(x,y)
(x,y) 的权值。
于是,现在的表格内容如下:
接下来继续执行这个过程,直到该表格的所有空格都填写完,那么最终
d
p
[
n
,
m
]
dp[n,m]
dp[n,m] 中的内容即为我们所需的答案。
回看上面的过程,不难得出求解本题的状态转换方程为:
d p [ i ] [ j ] = m i n ( d p [ i ] [ j − 1 ] , d p [ i − 1 ] [ j ] ) + v a l u e [ i ] [ j ] dp[i][j] = min( dp[i][j-1],dp[i-1][j] ) + value[i][j] dp[i][j]=min(dp[i][j−1],dp[i−1][j])+value[i][j]
下面给出求解本题的完整代码:
#include<iostream>
using namespace std;
const int N=10000;
int dp[N][N],value[N][N];
int n,m;
int main()
{
cin>>n>>m;
for(int i=1;i<=n;i++) //录入初始数字矩阵
for(int j=1;j<=m;j++)
cin>>value[i][j];
for(int i=1;i<=n;i++) // 初始化最上方第一行
dp[1][i]=dp[1][i-1]+value[1][i];
for(int i=1;i<=n;i++) // 初始化最左边第一列
dp[i][1]=dp[i-1][1]+value[i][1];
for(int i=2;i<=n;i++)
for(int j=2;j<=m;j++) // 开始进行状态转移(即填写dp表)
dp[i][j]=min(dp[i][j-1],dp[i-1][j])+value[i][j];
cout<<dp[n][m]<<endl;
return 0;
}
注意到上面的程序中用了两个二维数组( d p [ N ] [ N ] dp[N][N] dp[N][N] 和 v a l u e [ N ] [ N ] value[N][N] value[N][N]),如果 N = 100000 N=10 0000 N=100000,则该程序在编译的时候就会报错:内存不足!这是此程序的一个缺陷,我们来试着优化一下。
首先很容易看出的是,没有必要用二维的 v a l u e [ N ] [ N ] value[N][N] value[N][N] 数组。因为我们可以直接将原数字矩阵放进 d p [ N ] [ N ] dp[N][N] dp[N][N] 中,然后把初始化和动态转移操作都放在 d p [ N ] [ N ] dp[N][N] dp[N][N] 数组中进行。这样,我们就把动态转移方程变为:
d p [ i ] [ j ] = m i n ( d p [ i ] [ j − 1 ] + d p [ i − 1 ] [ j ] ) + d p [ i ] [ j ] dp[i][j] = min( dp[i][j-1] + dp[i-1][j] ) + dp[i][j] dp[i][j]=min(dp[i][j−1]+dp[i−1][j])+dp[i][j]
接下来想办法对 d p [ N ] [ N ] dp[N][N] dp[N][N] 数组进行降维,这就需要回到我们的状态转移方程上。由于该方程在寻找最小路径和的过程中对某个具体位置进行更新时实际上只关心两处:该位置的上方、左方,那我们在填表时就不必将之前填写的所有信息都存进一个二维数组中,而是将这些信息通过循环来不断更新进一个一维数组中。具体做法如下(这里依然用上面的数字矩阵来进行分析)。
假设当前维护的一维数组为 f p [ N ] fp[N] fp[N]。
第一步:初始化第一行。由于最上面那一行的行走方式只有一种(从左向右行走),因此我们可以直接填写,于是得到 f p [ N ] = { 1 , 4 , 9 , 16 } fp[N]=\{1,4,9,16\} fp[N]={1,4,9,16},如下:
注:可以视当前数组中的内容为 d p [ 1 ] [ 1 ] ∼ d p [ 1 ] [ 4 ] dp[1][1] \sim dp[1][4] dp[1][1]∼dp[1][4] 中的路径和。
第二步:开始进行状态转移(更新一维数组 f p [ N ] fp[N] fp[N])。此步骤是一个迭代过程,其主要有如下两步:
- 填写第一个格子(注意:当前位置为 ( 2 , 1 ) (2,1) (2,1)),由于最左边的那一列(即第一个格子)的行走方式只有一种(来自上方),因此可以直接填写,于是得到:
- 填写其余格子。比如接下来填写第 2 个格子(注意:当前位置为 ( 2 , 2 ) (2,2) (2,2)),由于当前第 1 个格子里存放的是到位置 ( 2 , 1 ) (2,1) (2,1) 的最小路径和(即 f p [ 1 ] = d p [ 2 ] [ 1 ] = 9 fp[1]=dp[2][1]=9 fp[1]=dp[2][1]=9),第 2 个格子里存放的是到位置 ( 1 , 2 ) (1,2) (1,2) 的最小路径和(即 f p [ 2 ] = d p [ 1 ] [ 2 ] = 4 fp[2]=dp[1][2]=4 fp[2]=dp[1][2]=4),那么根据公式:
d p [ i ] [ j ] = m i n ( d p [ i ] [ j − 1 ] + d p [ i − 1 ] [ j ] ) + d p [ i ] [ j ] dp[i][j] = min( dp[i][j-1] + dp[i-1][j] ) + dp[i][j] dp[i][j]=min(dp[i][j−1]+dp[i−1][j])+dp[i][j]
我们将其转换到数组 f p [ N ] fp[N] fp[N] 中就可以得到:
f p [ i ] = m i n ( f p [ i − 1 ] , f p [ i ] ) + v a l u e N o w fp[i] = min( fp[i-1],fp[i] ) + valueNow fp[i]=min(fp[i−1],fp[i])+valueNow
其中, v a l u e N o w valueNow valueNow 表示当前输入的第 d p [ i ] [ j ] dp[i][j] dp[i][j] 个数字,即当前值。
接下来反复执行上面的步骤,最终就能将从最左上角到最右下角的最小路径和求出并存放进
f
p
[
N
]
fp[N]
fp[N] 中。
下面给出改进后(将二维数组转变为一维数组)的完整代码:
#include<iostream>
using namespace std;
const int N=100000;
int dp[N];
int n,m;
int main()
{
int n,m,temp;
cin>>n>>m;
for(int i=1;i<=m;i++){ //第一步:初始化
cin>>temp;
dp[i]=dp[i-1]+temp;
}
for(int i=2;i<=n;i++) //第二步
{
cin>>temp; //得到当前值
dp[1]+=temp; //第二步中的第①步:求第一个值
for(int j=2;j<=m;j++){ //第二步中的第②步:求其余值
cin>>temp;
dp[j]=min(dp[j-1],dp[j])+temp;
}
}
cout<<dp[m]<<endl; //输出最终结果
return 0;
}
可以发现,在改进的算法中,即使
N
N
N 取值到了 10 0000 依然能够正常运行,因为该算法将空间复杂度优化到了
O
(
m
)
O(m)
O(m)。
注:如果要求解的是最大路径和则只需要将上面的状态转移方程转换为:
d p [ i ] = m a x ( d p [ i − 1 ] , d p [ i ] ) + v a l u e N o w dp[i] = max( dp[i-1],dp[i] ) + valueNow dp[i]=max(dp[i−1],dp[i])+valueNow
即可。
问题二:求路径总数
给一个规格为 n × m n\times m n×m 的矩阵,初始在最左上角,要求每次只能向下或向右移动,求到最右下角的方法数(值会很大,要求将结果对 1 0000 0007 取模)。
算法分析
我们同样需要来填写一个同给出矩阵规格一致的表格,如下(假设规格为 4 × 4 4\times4 4×4):
由于在上侧和左侧的边界位置(即最上方一行和最左边一列),其路径只有一条(最上方的每个格子的前一个格子只能来自左方、最左边的每个格子的前一个格子只能来自上方),我们别无选择,因此在边界位置的路径有且仅有1条。于是可以将该表格的上、左侧边界内容完善,如下:
对于其余的每个格子,其路径来源总是由两部分组成:
- 从该格子的上方而来;
- 从该格子的左方而来。
于是可以得到求解该问题的状态转移方程为:
d p [ i ] [ j ] = d p [ i ] [ j − 1 ] + d p [ i − 1 ] [ j ] dp[i][j] = dp[i][j-1] + dp[i-1][j] dp[i][j]=dp[i][j−1]+dp[i−1][j]
比如接下来从位置 ( 2 , 2 ) (2,2) (2,2) 到位置 ( 4 , 4 ) (4,4) (4,4) 的 9 个格子的完善步骤如下图所示:
据此可以写出求解路径总数的完整代码如下:
#include<iostream>
using namespace std;
const int N=100;
const int MOD=100000007;
int dp[N][N];
int n,m;
void DP()
{
for(int i=1;i<=m;i++) dp[1][i]=1; // 初始化第一行
for(int i=1;i<=n;i++) dp[i][1]=1; // 初始化第一列
for(int i=2;i<=n;i++) // 完善整个表格
for(int j=2;j<=m;j++)
dp[i][j]=(dp[i][j-1]+dp[i-1][j])%MOD;
}
int main()
{
cin>>n>>m;
DP();
cout<<dp[n][m]<<endl;
return 0;
}
同样地,本题也能将使用的 d p [ N ] [ N ] dp[N][N] dp[N][N] 数组优化为一维数组,即将动态转移方程变为:
d p [ j ] = d p [ j − 1 ] + d p [ j ] dp[j] = dp[j-1] + dp[j] dp[j]=dp[j−1]+dp[j]
改进后的代码如下:
#include<iostream>
using namespace std;
const int N=105;
const int MOD=100000007;
int dp[N];
int n,m;
void DP()
{
dp[1]=1; // 初始化第一个格子
for(int i=1;i<=n;i++) // 通过二重循环遍历整个表单以更新dp[N]中的数据
for(int j=2;j<=m;j++)
dp[j]=(dp[j-1]+dp[j])%MOD;
}
int main()
{
cin>>n>>m;
DP();
cout<<dp[m]<<endl;
return 0;
}
趁热打铁!!!
接下来看两道道非常经典的例题:
蓝桥杯 历届试题 格子刷油漆
蓝桥杯 算法训练 数字三角形
蓝桥杯 历届试题 格子刷油漆