Hello,我是 Alex 007,一个热爱计算机编程和硬件设计的小白,为啥是007呢?因为叫 Alex 的人太多了,再加上每天007的生活,Alex 007就诞生了。
动态规划-诺基亚耐摔性测试
老师说蓝桥杯被列为A类竞赛,让我再把算法好好看看,我一想我那可怜的算法,当时就想跟老师说:
碰巧今天LeetCode每日一题考到了动态规划,问了工作室的几个学弟学妹,对动态规划也掌握的也不是很好,觉得要写一篇文章好好讲讲。
一、引子(诺基亚耐摔性测试)
1.题目描述
我以前啊,特别喜欢收藏一些东西,其中就包括那个从楼上掉下来还能砸穿地板的神机——诺基亚。
当然这些都只是吹嘘啊,有一天呢我就突然想测试一下诺基亚手机的耐摔性到底怎么样,于是就找了栋100层的大楼,看看能摔倒几层,当手机在低楼层往下扔的时候,到地上都不会碎,而在高楼层的时候往下扔手机才会碎。
所以大楼中间必定存在一个临界的楼层,在临界楼层以下怎么扔,手机都是不碎的,并且这个手机还可以继续做测试,但如果超过了临界楼层,手机就会被摔碎,并且碎了之后手机就不能再用了。
假设我手里有N个诺基亚可以用来做检测,问最少要扔多少次才能找到这个临界楼层。
2.题目分析
刚看到这个题的时候我脑子里冒出来好多想法,什么迭代、递归、二分、中值定理,一大堆,但是都一一排除了,一时间也没有什么好想法。
那就只能从最简单的情况开始一步一步的分析:
1个手机:
如果只有一个手机的话,肯定不能采用二分法上来就从50层开始扔,要是直接碎了就没得玩了,虽然确定了范围在0~50层之间,但是手机摔碎了,不能继续再测试了。
所以,当只有一个手机的时候,我们只能用最笨的方法,从第一层开始,一层一层的扔,直到摔碎才能找到临界楼层。
这种前提下,最差的情况要扔100次,最好的情况只需要扔1次就可以了。
无限个手机:
如果我收藏了很多诺基亚手机,扔完了还有,这种情况下,我们就可以采用二分法了,首先在50层开始扔,如果手机碎了那么临界楼层就在1 ~ 50层之间,否则就在51 ~ 100层之间,然后继续用二分法测试。
这种情况下,我们假设最多要扔M次,满足条件2M>=100,解得M>=log2100,而log2100=2log210,我们可以估计一下log210的大小:log28=3<log210<log216=4,所以可以计算出M>6,因此要扔7次就可以找到临界楼层。
2个手机:
接下来我们就要分析一个比较复杂的情况,只有两个手机,别看就这两个手机,情况还挺复杂,我们假设第一个手机为A,第二个手机为B。
- 等间隔测试
A手机分别尝试10层、20层、30层、……100层,如果A手机在某次测试的时候碎了,则用B尝试A-9、A-8、A-7、……A-1层。
举个例子,假设A手机在尝试第10层的时候没碎,但是在尝试第20层的时候碎了,说明临界楼层在10 ~ 20层之间,接下来用B手机依次尝试11层、12层、13层、……19层,总有一层会碎的,不碎的话说明20层就是临界楼层。
此时,A最多扔10次,B最多扔9次,因此等间隔测试最多尝试19次即可找到临界楼层。
- 不等间隔测试
我们让A每次跨越的距离缩小一层,这样B所要测试的楼层也会相应的减少一层。
假设A手机第一次测试的楼层在第n层,第二次测试的楼层要跨越n-1层在第2n-1层,第三次测试的楼层跨越n-2层在第3n-3层,以此类推,假设我们最后可以让A只跨越1层,那么可以列出计算式:
n + ( n − 1 ) + ( n − 2 ) + . . . + 3 + 2 + 1 = n ( n − 1 ) 2 > = 100 n + (n - 1) + (n - 2) + ... + 3 + 2 + 1 = \frac{n(n - 1)}{2}>=100 n+(n−1)+(n−2)+...+3+2+1=2n(n−1)>=100
解得n>=13.65,我们取n=14,那么A的测试楼层就是:14、27、39、50、60、69、77、84、90、95、99、100
,所以A最多测试12次。
假设A手机在14层的时候碎了,B手机要测试1 ~ 13层,加上A手机的一次要测试14次。
假设A手机在27层的时候碎了,A手机测试了2次,B手机要测试15 ~ 26层,也就是12次,加起来还是要测试14次。
所以,第二种方案要测试12 ~ 14次即可找到临界楼层,最多就需要14次。
N个手机:
现在我们再来讨论一下有N个手机的情况,为了让问题的普适性再增强一点,再假设有T层楼,这样问题就变成了,有T层楼和N个手机,求出最坏情况下经过多少次测试能找到临界楼层。
如果把它用数学语言来描述的话,就是求一个函数:
M
(
T
,
N
)
=
?
M(T,N)=?
M(T,N)=?
这里我们还是先从最简单的情况开始分析,可以画一个表格,行表示楼层T,列表示手机数N:
T \ N | 1 | 2 | 3 | … | n |
---|---|---|---|---|---|
1 | 1 | 1 | 1 | 1 | 1 |
2 | 2 | ||||
3 | 3 | ||||
… | … | ||||
n | n |
我们先把比较简单的N层楼1个手机和N个手机1层楼的情况分析完,并且填入到表格中,接下来分析其它的情况。
假设第1次扔手机在第k层,如果手机碎了,那么临界楼层在1 ~ k层之间,接下来的问题就变成了在1 ~ k层之间用N - 1个手机测试:
M ( k − 1 , N − 1 ) = ? M(k-1,N-1)=? M(k−1,N−1)=?
如果A手机在第k层没碎,接下来的问题就变成了在k+1 ~ T层之间用N个手机测试:
M
(
T
−
k
,
N
)
=
?
M(T-k,N)=?
M(T−k,N)=?
我们的目标是求出最坏的情况需要测试多少次,因此需要对两种情况的测试取最大值,同时还要加上第1次测试的次数:
M
k
(
T
,
N
)
=
1
+
m
a
x
{
M
k
(
k
−
1
,
N
−
1
)
,
M
k
(
T
−
k
,
N
)
}
M_k(T,N)=1+max\{M_k(k-1,N-1),M_k(T-k,N)\}
Mk(T,N)=1+max{Mk(k−1,N−1),Mk(T−k,N)}
现在还有个k没有确定值,不过可以确定其范围是:1<=k<=T,可以通过循环来计算,我们是想求最优情况下扔多少次手机才能找到临界楼层,因此可以列出下式:
M
(
T
,
N
)
=
m
i
n
{
M
1
(
T
,
N
)
,
M
2
(
T
,
N
)
,
M
3
(
T
,
N
)
,
.
.
.
,
M
k
(
T
,
N
)
,
.
.
.
,
M
T
(
T
,
N
)
}
M(T,N)=min\{M_1(T,N),M_2(T,N),M_3(T,N),...,M_k(T,N),...,M_T(T,N)\}
M(T,N)=min{M1(T,N),M2(T,N),M3(T,N),...,Mk(T,N),...,MT(T,N)}
3.Code
有了这个公式,并且公式里的值都确定了,我们就可以开始敲代码解决问题了:
import sys
if __name__ == '__main__':
floors, phones = 100, 10
# 1.创建dp数组
dp = [[0 for _ in range(phones)] for _ in range(floors)]
# 2.填充第一列
for t in range(floors):
dp[t][0] = t + 1
# 3.填充第一行
for n in range(phones):
dp[0][n] = 1
# 4.根据公式求解其它值
for t in range(1, floors):
for n in range(1, phones):
minNum = sys.maxsize
for k in range(1, t + 1):
minNum = min(minNum, 1 + max(dp[k - 1][n - 1], dp[t - k - 1][n]))
dp[t][n] = minNum
for floor in range(10):
print(f"Floor = {floor + 1}:\t", end="")
for num in range(10):
print(dp[floor][num], end="\t")
print()
下面奉上标准答案:
二、定义
如果你能够完全理解上面讲的内容并且能够自己实现,那么恭喜你,通过这么一个简单的扔手机的问题,我们就把无数人头疼的动态规划初步理解了。
可能你现在还有点懵,不过没关系,动态规划是一种用途很广的问题求解方法,它本身并不是一个特定的算法,而是一种思想,一种手段。对动态规划的掌握情况很大程度上能直接影响一个选手的分析和建模能力。
我们先来看看动态规划的几个核心概念。
1.阶段和阶段变量
用动态规划求解一个问题时,需要将问题的全部过程恰当的分成若干个相互联系的阶段,以便按照一定的次序去求解。
描述阶段的变量称为阶段变量,通常用k表示,阶段的划分一般是根据时间和空间的自然特征来划分,同时阶段的划分要便于把问题转换成多阶段决策过程。
Mk(T,N)
2.状态和状态变量
某一阶段的出发位置称为状态,通常一个阶段包含若干状态。一般的,状态可由变量来描述,用来描述状态的变量称为状态变量。
M50(T,N)
3.状态转移方程
前一阶段的终点是后一阶段的起点,对前一阶段的状态做出某种决策产生后一阶段的状态,这种关系描述了由k阶段到k+1阶段状态的演变规律,称为状态转移方程。
Mk(T,N)=1+max{Mk(k-1,N-1),Mk(T-k,N)}
4.总结
动态规划最重要的是掌握它的核心思想:将原问题分解成子问题进行求解。
解决动态规划问题大致有以下四步:
- 状态划分:划分子问题
第1次扔手机在第k层
- 手机碎了 =》 在1 ~ k层之间用N - 1个手机测试
- 手机没碎 =》 在k+1 ~ T层之间用N个手机测试
- 状态表示:如何让计算机理解子问题
子问题的求解方程:
- 手机碎了 =》 M(k-1,N-1)
- 手机没碎 =》 M(T-k,N)
- 状态转移:父问题是如何由子问题推导出来的
状态转移方程:Mk(T,N)=1+max{Mk(k-1,N-1),Mk(T-k,N)}
- 确定边界:明确动态规划的初始状态、最小子问题、最终状态
三、最长公共子序列Logest Common Subsequence
最长公共序列问题是动态规划的经典例题,在做题之前首先要明确几个概念:
- 子序列
一个序列X=x1x2x3…xn,删除其中任意若干项,剩余的序列叫做X的一个子序列。 - 公共子序列
如果序列Z既是序列X的子序列,同时也是Y的子序列,则称序列Z为序列X和序列Y的公共子序列,空序列是任何两个序列的公共子序列。 - 最长公共子序列
序列X和序列Y的公共子序列中长度最长的叫做序列X和序列Y的最长公共子序列。
1.划分状态
设序列X=x1x2x3…xm,序列Y=y1y2y3…yn,序列Z=z1z2z3…zk是序列X和序列Y的最长公共子序列。
- 如果Xm=Yn,那么Zk=Xm=Yn,且Zk-1是Xm-1,Yn-1的一个最长公共子序列;
- 如果Xm!=Yn,且Zk!=Xm,那么Z是Xm-1,Y的一个最长公共子序列;
- 如果Xm!=Yn,且Zk!=Yn,那么Z是X,Yn-1的一个最长公共子序列;
2.状态表示
从划分子状态可以看出,如果Xm=Yn,那么我们应该求解Xm-1,Yn-1的一个LCS,并且将Xm=Yn加入到这个LCS的末尾,这样得到的一个新的LCS就是所求最长公共子序列。
如果Xm!=Yn,需要求解两个子问题,分别是Xm-1,Y的LCS和X,Yn-1的LCS,两个LCS中较长者就是X和Y的一个LCS。
可以看出LCS问题具有重叠子问题性质,为了求X和Y的LCS,需要分别求出Xm-1,Y的LCS和X,Yn-1的LCS,子问题中又包含了求出Xm-1,Yn-1的LCS的子问题的子问题。
3.状态转移
根据上面的分析,我们可以得出下面的公式:
L C S ( i , j ) = { 0 , i = 0 , j = 0 1 + L C S ( i − 1 , j − 1 ) , i , j > 0 , x i = y i m a x { L C S ( i , j − 1 ) , L C S ( i − 1 , j ) } , i , j > 0 , x i ! = y i LCS(i,j)=\left\{ \begin{aligned} 0,& {i=0,j=0}\\ 1+LCS(i-1,j-1),& i,j>0,x_i=y_i\\ max\{LCS(i,j-1),LCS(i-1,j)\},& i,j>0,x_i!=y_i\\ \end{aligned} \right. LCS(i,j)=⎩⎪⎨⎪⎧0,1+LCS(i−1,j−1),max{LCS(i,j−1),LCS(i−1,j)},i=0,j=0i,j>0,xi=yii,j>0,xi!=yi
接下来就可以直接根据这个公式coding了。
4.Code
def LcsTraceBack(dp, directions, string, length1, length2):
s = []
# dp数组不为None时
while dp[length1][length2]:
char = directions[length1][length2]
# 匹配成功,插入该字符,并向左上角找下一个
if char == 'ok':
s.append(string[length1 - 1])
length1 -= 1
length2 -= 1
# 根据标记,向左找下一个
if char == 'left':
length2 -= 1
# 根据标记,向上找下一个
if char == 'up':
length1 -= 1
s.reverse()
return ''.join(s)
def Lcs(str1, str2):
length1, length2 = len(str1), len(str2)
# 生成字符串长度加1的0矩阵DP用来保存对应位置匹配的结果
DP = [[0 for _ in range(length2 + 1)] for _ in range(length1 + 1)]
# direction用来记录转移方向
directions = [['' for _ in range(length2 + 1)] for _ in range(length1 + 1)]
for p1 in range(length1):
for p2 in range(length2):
# 字符匹配成功,则该位置的值为左上方的值加1
if str1[p1] == str2[p2]:
DP[p1 + 1][p2 + 1] = DP[p1][p2] + 1
directions[p1 + 1][p2 + 1] = 'ok'
# 左值大于上值,则该位置的值为左值,并标记回溯时的方向为left
elif DP[p1 + 1][p2] > DP[p1][p2 + 1]:
DP[p1 + 1][p2 + 1] = DP[p1 + 1][p2]
directions[p1 + 1][p2 + 1] = 'left'
# 上值大于左值,则该位置的值为上值,并标记回溯时的方向为up
else:
DP[p1 + 1][p2 + 1] = DP[p1][p2 + 1]
directions[p1 + 1][p2 + 1] = 'up'
return LcsTraceBack(DP, directions, str1, length1, length2), directions
if __name__ == '__main__':
answer, direction = Lcs('ABCBDAB', 'BDCABA')
print(answer)