动态规划算法其实并不复杂,动态规划算法的实际操作核心是“做笔记”,即将前期运算的结果保留下来成为后续运算的基础信息,说白了其实就是用空间换取时间。
其实在我们平时编程时已经初步接触到了许多使用动态规划算法的问题:菲波那切数列、生兔子问题、跳台阶问题,如果选择使用数组(列表)来解决这些问题,那么就是使用了动态规划的思想(才意识到?)接下来的博文将从几个经典的动态规划题目来大致展示这个算法的思想。
找零钱问题
给你n种面额的纸币和目标金额quota,因为要保护环境,请问如何使用最少的纸币数达到目标金额?最少使用几张纸币?
刚看到这个题目,不少朋友会第一时间想到贪心法。没错贪心法是一种解决方案,在后续的博文中会详细地展示贪心法,但这篇博文讲的是动态规划算法贪心法只能模糊求解,在找零钱的边界问题上会面临比较复杂的问题,所以我们老实的用动态规划实现。
Talk is cheap, show me the code.
python
money = [2,3,7] # 设置纸币的面额
quota = int(input("please tell me the quota")) # 设置目标金额
dp = [100]*(quota + 1) # 建立dp列表,100的意思是取一个大数,理论上是正无穷,这里100就可以了。列表下标i代表当前的金额,dp[i]代表使用的纸币数
for j in money:
dp[j] = 1 # 初始化dp列表,将拥有的纸币面额所对应的金额最小纸币数设为1
i = money[0] # 从拥有纸币面额开始,比设置的最小纸币面额小的金额是达不到的
while i <= quota:
for j in money: # 对于所有纸币
if i > j and dp[i] > dp[i - j] + 1: # 如果此时的金额满足减去纸币j>0的条件(此金额的前一项金额i-j已求出最小纸币数)并且当前的纸币数dp[i]并非最小纸币数
dp[i] = dp[i - j] + 1 # 更新dp[i]为z[i-j]+1,即上一项的值加面额j一张纸币
i = i + 1 # 下一个金额
print(dp[-1]) # 输出目标金额的最小纸币数
上面代码的注释写得很详细,我们就不再多讲这个问题的代码了,我们来看更内在的问题。找零钱问题的递推式是A[n]=min(A[n-k(i)] 1<=i<=m),这个递推式主要体现在上述代码的更新条件上,而这个更新就是找零钱问题最重要的一个成分,各位朋友不难看出这个递推式的重要性。这引出了一个对于动态规划算法的理解:动态规划算法就是数列的递推(斐波那契数列的递推式为A[n]=A[n-1]+A[n-2])。
根据找零钱这个问题我们可以初步总结出使用动态规划算法的一般步骤
- 通过分析问题找到递推式
- 通过递推式创建dp表(一般为列表或二维表)
- 根据已有信息初始化dp表
- 根据递推式更新dp表,求出最终的结果
通过找零钱问题,我们还可以注意到动态规划算法适用于要求局部最优的问题,即如果要计算第i步的最优解,第i步前期的所有步骤都必须是最优解。接下来我们用另一个经典例题加深一下对动态规划算法的理解。
最大递增子序列
有一个数列[x1,x2,x3,x4,x5……,xn],这个数列有一个子序列[xk1,xk2,xk3……xkm],子序列满足k1<k2<k3……<km,xk1<xk2<xk3……<xkm,这个数列称为递增子序列。现给定一个数列,求最大递增子序列的长度。
我们一步一步地解决这个问题。
- 首先想着创建一个列表用于储存前期计算的结果,那么列表的下标代表什么?列表内的数值又代表什么呢?我们知道动态规划算法是一步一步地接近结果,所以想到下标i代表数列的第i-1个值的位置,dp[i]当然就是当前的最优解。直白的说,z[i]就是以数列的第i+1个数为结尾时的最大递增子序列的长度。有一个道理我不说大家也明白:dp列表的值肯定不会变小。
- 然后寻找递推式,在dp列表中,如果数列的第i个比数列i前面的第j个大,此时的dp[i]是不是应该变为dp[j]+1呢?不要着急,我们还要比较此时dp[i]与dp[j]+1的大小,大家都明白该怎么做。
- 编程解决问题
python
z=[5,3,4,8,6,7]
dp=[1]*len(z)
i=0
while i < len(z):
j=i-1
while j >=0:
if z[i] > z[j] and dp[i] < dp[j]+1:
dp[i]=dp[j]+1
j = j - 1
i = i + 1
print(dp)
看到这里,大家想必对动态规划有了初步的认识,但目前为止这些用动态规划解决的问题似乎都不是必须用动态规划算法来解决的。接下来我们来看一些更难的经典例子。
Path sum: two ways
给定N^N的矩阵,每个格子中都有有正整数,每到一个格子就加上这个格子对应的正整数,计算从矩阵左上方走到矩阵右下方的和最小是多少。(你只能向右和向下移动)。
这个问题如果不用动态分析直接穷举也可以做出来,建议小伙伴抽出时间去试一试
这个问题使用动态规划其实很简单,对于任意一个格子,因为规定只能向右和向下移动,所以对于任意一个格子,只需比较左格和上格就可以了,哪个少就从哪个走。因为此时左格和上格都是最优解,所以这样求出的当前格子也是最优解。
递推式A(i,j)=min(A(i-1,j),A(i,j-1))+v(i,j)
python
def small(x,y):
if x < y:
return x
else:
return y # 定义一个函数,返回两数的较小值
dp=[
[131,456,23,658,257],
[154,709,465,15,76],
[346,437,653,724,45],
[9,20,546,240,547],
[14,8,360,57,367]
]
i = 0
while i < 5:
j = 0
while j < 5:
if i == 0 and j == 0:
dp[i][j] = dp[i][j]
elif i == 0:
dp[i][j] = dp[i][j-1] + dp[i][j]
elif j == 0:
dp[i][j] = dp[i-1][j] + dp[i][j] # 前三个表达式都是初始化,也是处理dp表的边界
else:
dp[i][j] = small(dp[i][j-1],dp[i-1][j]) + dp[i][j]
j = j + 1
i = i + 1
print(dp)
在看完这道题后,除了感觉简单之外,可能也会说这限制太大了,只能向右和向下走,接下来我们看这道题的加强版
Path sum: three ways
给定N^N的矩阵,每个格子中都有有正整数,每到一个格子就加上这个格子对应的正整数,计算从矩阵最上层的某一位置走到矩阵最下层的和最小是多少。(你只能水平移动和向下移动)。
这个问题看似和上一个问题差距不大确实没有什么差别,但是要解决一个动态规划算法最重要的问题:前期的计算结果是什么?如果水平方向上只能向右走,那么左格就是当前格的前期计算结果;可是这道题你能在水平方向上左右动,左格是右格的前期计算结果,右格是左格的前期计算结果,究竟是谁推出谁呢?
Talk is cheap, show me the code.
python
def small(x,y):
if x < y:
return x
else:
return y
dp=[
[287,193,25,49,83],
[197,23,45,76,108],
[93,78,374,568,709],
[623,496,329,764,327],
[13,103,143,563,413]
]
position = int(input("please tell me the position")) # 设置开始的位置
if position > 0 and position < 4:
k1 = position - 1
k2 = position + 1
while k1 >= 0:
dp[0][k1] = dp[0][k1+1] + dp[0][k1]
k1 = k1 - 1
while k2 < 5:
dp[0][k2] = dp[0][k2-1] + dp[0][k2]
k2 = k2 + 1
elif position == 0:
k1 = position + 1
while k1 < 5:
dp[0][k1] = dp[0][k1 - 1] + dp[0][k1]
else:
k2 = position - 1
while k2 >= 0:
dp[0][k2] = dp[0][k2+1] + dp[0][k2] # 初始化dp表,处理边界问题
i = 1 # 从第二行开始
while i < 5:
z = [0] * 5 # 设立列表z,用处是储存当前行每个位置的最优解
z[0] = dp[i-1][0] + dp[i][0] # 左侧的开头
j = 1
while j < 5:
z[j] = small(z[j-1],dp[i-1][j]) + dp[i][j]
j = j + 1 # 从左侧遍历到右侧,这里与上一个问题一样,只是把计算结果暂时放在数组z中而不是立即更新dp表
z[4] = small(z[4],dp[i-1][4] + dp[i][4]) # 右侧的开头,此时列表z中已有左侧的前期计算结果,所以要进行一次比较
j = 3
while j >= 0:
z[j] = small(z[j],small(z[j+1] + dp[i][j],dp[i-1][j] + dp[i][j])) # 从右侧遍历到左侧,因为列表z中已有左侧的前期计算结果,所以要更新当前格的最优解,取当前最优解、上格加当前格的数值和右格加当前格的数值的最小值
j = j - 1
j = 0
while j < 5:
dp[i][j] = z[j]
j = j + 1 # 将当前的z列表的结果赋给dp表这一行的对应位置,完成这一行的更新
i = i + 1
print(dp)
表面上看我们只是从左往右遍历了一次,又从右往左遍历了一次,但就是这两次遍历组合起来就完成了左格、上格、右格三个方向的比较,其中最主要的就是完成了左格与右格的比较。这个问题的重点在于我们将一整行作为一个整体进行递推,这体现了水平方向递推的关联性。
在看完这两个问题后,大家对dp表有了一定的了解,接下来我们再看一道利用dp表的经典动态规划题。
两个字符串最大公共子序列
有两个字符串s1和s2,求两字符串的最大公共子序列。
例:s1=ABCDABD s2=CABDCBD 最大公共子序列为ABCBD
- 首先想到建立dp表,大小为len(s1)^len(s2),dp(x,y)的值代表以s1第x个结尾的子序列和以s2第y个结尾的子序列的最大公共子序列。
- 这个dp表,肯定是越向右下角数值越大。那么数值是怎么一步一步地变大的呢?我们分析得出递推式
当前格 | 递推式 | 条件 |
---|---|---|
dp(x,y) | dp(x-1,y-1)+1 | s1[x]==s2[y] |
dp(x,y) | max(dp(x-1,y),dp(x,y-1)) | s1[x]!=s2[y] |
琢磨这个表达式的意思,不难理解,如果s1[x]与s2[y]相等,则在dp[x-1,y-1]的基础上加一;如果s1[x]与s2[y]不相等,因为s1与s2地位相等,取dp[x-1,y]和dp[x,y-1]的较大值。
python
def large(x,y):
if x > y:
return x
else:
return y #定义一个函数,返回两数较大值
s1=input()
s2=input() #获取字符串
dp=[
[0,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0],
] #建立了10^10的矩阵,但是只有右下方9^9的矩阵是有效的,做上放的0在建立矩阵时完成对矩阵的初始化
y = 1 #从1开始而不是从0开始是因为初始化的要求,这样在程序运行时不会出现边界问题
while y < len(s2):
x = 1
while x < len(s1):
if s2[y] == s1[x]:
dp[y][x] = dp[y-1][x-1] + 1
else:
dp[y][x] = large(dp[y][x-1],dp[y-1][x])
x = x + 1
y = y + 1
print(dp)
Dijkstra算法
Dijkstra算法是很重要的算法。Dijkstra算法既可以看做基础算法的延伸,也可以单独当做一个算法。Dijkstra算法主要用于解决单源路程最短问题(如果有不知道的朋友请自行百度)。在讲解Dijkstra算法算法前,我们要了解无向图与邻接矩阵的概念和两者间相互转化的方法。
无向图
无向图是图废话,通常的呈现状态是n个点,点与点之间有线相连,整个图中没有孤立的点,点与点相连的线称为边,每条边有一个正数,称为边的权重,可以理解为两点的距离。
邻接矩阵
无向图是人类可以理解的图,但很显然我们不能直接无向图输入电脑。要处理无向图,必须使用电脑能理解的形式——邻接矩阵,邻接矩阵的横轴x表示所有的点,纵轴y也表示所有的点,(x,y)表示点x与点y的距离。无向图的邻接矩阵总斜对称的正方形。
v | v1 | v2 | v3 | v4 | v5 |
---|---|---|---|---|---|
v1 | 0 | v1-v2 | v1-v3 | v1-v4 | v1-v5 |
v2 | v2-v1 | 0 | v1-v3 | v1-v4 | v1-v5 |
v3 | v3-v1 | v3-v2 | 0 | v3-v4 | v3-v5 |
v4 | v4-v1 | v4-v2 | v4-v3 | 0 | v4-v5 |
v5 | v5-v1 | v5-v2 | v5-v3 | v5-v4 | 0 |
如果两点不相连,权重理论上用无穷表示,在程序中用绝对的大数或标记表示。
有向图
有向图就是在无向图的基础上,对所有边标注方向,即权重是单向的。两点间的距离一个为正数,一个为无穷。
在了解图与邻接矩阵后,我们就可以开始学习Dijkstra算法。
以动态规划的眼光看,Dijkstra算法是从原点出发,一步一步逐渐计算到所有点的最短路程。单源路程最短问题的难点在于v1–>v2的距离并不一定是两点之间最短,这与现实中的“两点之间线段最短”有区别,v1–>v2的最短路径可能是v1–>v–>v2,即通过与v1和v2都相连的中间点v进行“中转”。对于两点之间的最短路径,中转点的数量不会大于1个,如果觉得奇怪思考一下就可以理解
那么如何解决单源最短路程问题?我们通过Dijkstra算法的第一步,也是最简单的一步来引入解决单源最短路程问题的核心思想。
我们假设原点为A,那么很明显此时到A路程最短的点是就是直接与A相连的点中权重最小的那一个点,这里指的是点C,路径为A–>C。那么会不会是A–>B–>C呢?不可能。如果A–>B+B–>C < A–>C,那么A–>B < A–>C,C就不是与A相连的点中权重最小的点了。显然,找的第一个点就是与原点相连且权重最小的点。接下来我们用类似的方法找到第3个点,第4个点,第5个点……
上图为例,定义C{A},U{B,C,D,E,F},C表示已经找到最短路径的点,U表示还未找到最短路径的点。每找到一个点,就把U中的点移入C。
当C中只有原点时找第二个点是容易的,那么当C中有n个点时,如何寻找第n+1个点呢?我的想法是把C看做整体,然后像第一步那样寻找下一个点。这里有一个视频直观地展示了解决单源最短路程问题的过程。
不想看视频的朋友可以看下面的步骤。
选取原点。直接相连的有4、8,选取4。更新直接相连的点的路程为当前最小值。
确定第一个点。直接相连的有8、12,选取8。更新直接相连的点的路程为当前最小值。
直接相连的有9,12,15,选取9。更新直接相连的点的路程为当前最小值。
直接相连的11,12,15,选取11。更新直接相连的点的路程为当前最小值。
直接相连的12,15,21,25,选取12。更新直接相连的点的路程为当前最小值。
直接相连的14,19,21,选取14。更新直接相连的点的路程为当前最小值。
直接相连的19,21,选取19。更新直接相连的点的路程为当前最小值。
选取最后一个点
python
call=[0,0,0,0,0,0,0]# 显示有无进行最短路径计算
d=1000
dis=[d,d,d,d,d,d,d]# 记录到每个点的最短路径
graph=[
[0,2,5,0,0,0,0],
[2,0,4,6,10,0,0],
[5,4,0,2,0,0,0],
[0,6,2,0,0,1,0],
[0,10,0,0,0,3,5],
[0,0,0,1,3,0,9],
[0,0,0,0,5,9,0]]# 邻接矩阵
dis[0]=0
call[0]=1 # 初始化原点
while 0 in call: # 只要还有点没有被扫描到
length=1000
i = 0
while i < len(call): # 扫描所有点
if call[i]: # 如果点i被扫描过
z=graph[i] # 获取与点i相连的所有点(除了自己之外,0表示不相连)
j = 0
while j < len(call): # 遍历与点i相连的所有点
if z[j] != 0 and call[j]==0: # 如果点j与i相连并且没有被扫描到
if dis[i] + z[j] < length: # 如果点j的路程比目前的最小计算结果还要小
length = dis[i] + z [j] # 更新当前的最小计算结果
k = j # 保存当前的点j
j = j + 1
i = i + 1
dis[k] = length # 更新点k
call[k] = 1 # 表示k已被扫描
print(dis)
动态规划算法目前就展示到这里。我也只是初学算法,对算法只有皮毛的理解。动态算法只有形的理解,缺少对动态规划算法核心的思考,有错误的地方请指正,讲解不清晰易懂请见谅。第一次写博客,排版不好看就不好看吧