动态规划与字符串的编辑距离
动态规划
动态规划(dynamic programming)是解决多阶段决策问题常用的最优化理论,该理论由美国数学家Bellman等人在1957年提出,用于研究多阶段决策过程的优化问题。其原理就是把多阶段决策过程转化为一系列的单阶段决策问题,利用各个阶段之间的递推关系,逐个确定每个阶段的最优化决策,最终堆叠出多阶段决策的最优化结果。
在很多情况下动态规划比穷举高效,但应用动态规划法解题的效率,取决于问题的类型。对于多项式时间的问题,动态规划法可能得到多项式时间复杂度的高效算法,但是对于NP问题,动态规划法也只能得到指数时间复杂度的算法。
动态规划法可以用于含有线性或非线性递推关系的最优解问题,但这些问题都必须满足最优化原理和子问题的“无后向性”。
最优化原理是问题的最优子结构的性质,如果一个问题的最优子结构是不论过去状态和决策如何,对前面的决策所形成的状态而言,其后的决策必须构成最优策略。也就是说,不管之前决策是否是最优决策,都必须保证从现在开始的决策是在之前决策基础上的最优决策,则这样的最优子结构就符合最优化原理。
“无后向性”是指当各个阶段的子问题确定以后,对于某个特定阶段的子问题来说,它之前的各个阶段的子问题的决策只影响该阶段的决策,对该阶段之后的决策不产生影响,也就是说,每个阶段的决策仅受之前决策的影响,但是不影响之后各阶段的决策。
基本思想
和分治法一样,动态规划解决复杂问题的思路是对问题进行分解,通过求解小规模的子问题再反推出原问题的结果。但动态规划是沿着决策阶段分解子问题的,决策的阶段可以随时间划分,也可以对着问题的演化状态划分。动态规划法的子问题可以不是互相独立的,通常有包含关系,甚至两个子问题可以有两个相同的子子问题。
动态规划法没有具体的实现模式,可以用带备忘录的递归方法实现,也可以根据堆叠子问题之间的递推公式用递推的方法实现。从算法设计的角度分析,一般需要四个步骤:定义最优子问题、定义状态、定义决策和状态转换方程以及确定边界条件。
四个步骤
1.定义最优子问题
定义最优子问题就是确定问题的优化目标以及如何决策最优解,并对决策过程划分阶段。阶段可以理解为一个问题从开始到解决需要经过的环节,这些环节前后关联。
2.定义状态
状态既是决策的对象,也是决策的结果,对于每个阶段来说,对起始状态施加决策,使得状态发生改变,得到决策的结果状态。初始状态经过每一个阶段的决策之后,最终得到的状态就是问题的解。只有一个决策序列能得到最优解。状态必须满足“无后向性”要求,必要时可以增加状态的维度,引入更多的约束条件。
3.定义决策和状态转换方程
决策就是能使状态发生转变的选择动作,如果选择动作有多个,则决策就是取其中能使得阶段结果最优的那一个。状态转换方程是描述状态转换关系的一系列等式,也就是从n-1阶段到n阶段演化的规律。状态转换取决于子问题的堆叠方式,如果状态定义得不合适,就会导致子问题之间没有重叠,就不存在状态转换关系了。算法就退化为朴素递归搜索算法。
4.确定边界条件
对于递归加备忘录方式(记忆搜索)实现的动态规划方法,边界条件实际上就是递归终结条件,无需额外的计算。对于使用递推关系直接实现的动态规划方法,需要确定状态转换方程的递推式的初始条件和边界条件,否则无法开始递推计算。
三个例子
1.装配站问题
装配站问题的阶段划分比较清晰,把工件从一个装配站移到下一个装配站就可以看作是一个阶段,其子问题就可以定义为从一个装配站转移到下一个装配站,直到最后一个装配站完成工件组装。
其实质就是在不同的装配线之间选择装配站,使得工件装配完成的时间最短,其状态
s
[
i
,
j
]
s[i,j]
s[i,j]就可以定义为通过第
i
i
i条装配线的第
j
j
j个装配站所需要的最短时间。
其决策就是选择在当前工作线上的下一个工作站继续装配,或者花费一定的开销将其转移到另一条工作线上的下一个工作站继续装配。如果定义
a
[
i
,
j
]
a[i,j]
a[i,j]为第
i
i
i条工作线的第
j
j
j个装配站需要的装配时间,
k
[
i
,
j
]
k[i,j]
k[i,j]为从另一条工作线转移到第
i
i
i条工作线的第
j
j
j个装配站需要的转移开销,则装配站问题的状态转换方程可以描述为:
s
[
1
,
j
=
m
i
n
(
s
[
1
,
j
−
1
]
+
a
[
1
,
j
]
,
s
[
2
,
j
−
1
+
k
[
1
,
j
]
+
a
[
1
,
j
]
]
)
]
s[1,j=min(s[1,j-1]+a[1,j],s[2,j-1+k[1,j]+a[1,j]])]
s[1,j=min(s[1,j−1]+a[1,j],s[2,j−1+k[1,j]+a[1,j]])]
s
[
2
,
j
=
m
i
n
(
s
[
2
,
j
−
1
]
+
a
[
2
,
j
]
,
s
[
1
,
j
−
1
+
k
[
2
,
j
]
+
a
[
2
,
j
]
]
)
]
s[2,j=min(s[2,j-1]+a[2,j],s[1,j-1+k[2,j]+a[2,j]])]
s[2,j=min(s[2,j−1]+a[2,j],s[1,j−1+k[2,j]+a[2,j]])]
初始条件就是工件通过第一个装配站的时间,对于两条装配线来说,工件通过第一个装配站的时间虽然不相同,但是都是确定的值,就是移入装配线的开销加上第一个装配站的装配时间。因此装配站问题的边界条件就是:
s
[
1
,
1
]
=
k
[
1
,
1
]
+
a
[
1
,
1
]
s[1,1]=k[1,1]+a[1,1]
s[1,1]=k[1,1]+a[1,1]
s
[
2
,
1
]
+
k
[
2
,
2
]
+
a
[
2
,
2
]
s[2,1]+k[2,2]+a[2,2]
s[2,1]+k[2,2]+a[2,2]
2.背包问题
每选择装一个物品就可以看作一个阶段,其子问题就可以定义为每次向背包中装一个物品,直到超过背包的最大容量为止。
背包问题本身是一个线性过程,但是简单将状态定义为装入的物品编号,也就是定义
s
[
i
]
s[i]
s[i]为装入第
i
i
i件物品后获得的最大价值,则子问题无法满足“无后向性”要求,原因是之前的任何一个决策都会影响到所有的后序决策(因为装入物品后背包容量发生了变化),因此需要增加一个维度的约束。考虑到每装入一个物品,背包的剩余容量就会减少,故而选择将背包容量也包含在状态定义中。最终背包问题的状态
s
[
i
,
j
]
s[i,j]
s[i,j]定义为将第
i
i
i件物品装入容量为
j
j
j的背包中所能获得的最大价值。
背包问题的决策很简单,就是判断装入第
i
i
i件物品获得的收益最大还是不装入第
i
i
i件物品获得的收益最大。如果不装入第
i
i
i件物品,则背包内物品的价值仍然是
s
[
i
−
1
,
j
]
s[i-1,j]
s[i−1,j]状态,如果装入第
i
i
i件物品,则背包内物品的价值就变成
s
[
i
,
j
−
V
i
]
+
P
i
s[i,j-V_i]+P_i
s[i,j−Vi]+Pi状态,其中
V
i
V_i
Vi和
P
i
P_i
Pi分别是第
i
i
i件物品的容积和价值,决策的状态转换方程就是:
s
[
i
,
j
]
=
m
a
x
(
s
[
i
−
1
,
j
]
,
s
[
i
,
j
−
V
i
]
+
P
i
)
s[i,j]=max(s[i-1,j],s[i,j-V_i]+P_i)
s[i,j]=max(s[i−1,j],s[i,j−Vi]+Pi)
背包问题的边界条件很简单,就是没有装入任何物品的状态:
s
[
0
,
V
m
a
x
]
=
0
s[0,V_{max}]=0
s[0,Vmax]=0
3.最长公共子序列问题
可以按照问题的演化状态划分阶段,这需要首先定义状态,有了状态的定义,只要状态发生了变化,就可以认为是一个阶段。
如果定义
s
t
r
1
[
1...
i
]
str1[1...i]
str1[1...i]为第一个字符串前
i
i
i个字符组成的子串,定义
s
t
r
2
[
1...
j
]
str2[1...j]
str2[1...j]为第二个字符串的前
j
j
j个字符组成的子串,则最长公共子序列问题的状态
s
[
i
,
j
]
s[i,j]
s[i,j]定义为
s
t
r
1
[
1...
i
]
str1[1...i]
str1[1...i]与
s
t
r
2
[
1...
j
]
str2[1...j]
str2[1...j]的最长公共子序列长度。
决策方式就是判断
s
t
r
1
[
i
]
str1[i]
str1[i]和
s
t
r
2
[
j
]
str2[j]
str2[j]的关系,如果
s
t
r
1
[
i
]
str1[i]
str1[i]和
s
t
r
2
[
j
]
str2[j]
str2[j]相同,则公共子序列的长度应该是
s
[
i
−
1
,
j
−
1
]
+
1
s[i-1,j-1]+1
s[i−1,j−1]+1,否则就分别尝试匹配
s
t
r
1
[
1...
i
−
1
]
str1[1...i-1]
str1[1...i−1]与
s
t
r
2
[
1...
j
]
str2[1...j]
str2[1...j]的最长公共子串,以及
s
t
r
1
[
1...
i
]
str1[1...i]
str1[1...i]与
s
t
r
2
[
1...
j
−
1
]
str2[1...j-1]
str2[1...j−1]的最长公共子串,然后取二者中较大的那个值作为
s
[
i
,
j
]
s[i,j]
s[i,j]的值。状态转换方程就是:
s
[
i
,
j
]
=
s
[
i
−
1
,
j
−
1
]
+
1
;
s
t
r
1
[
i
]
与
s
t
r
2
[
j
]
相
同
s[i,j]=s[i-1,j-1]+1;str1[i]与str2[j]相同
s[i,j]=s[i−1,j−1]+1;str1[i]与str2[j]相同
s
[
i
,
j
]
=
m
a
x
(
s
[
i
,
j
−
1
]
,
s
[
i
−
1
,
j
]
)
;
s
t
r
1
[
i
]
与
s
t
r
2
[
j
]
不
相
同
s[i,j]=max(s[i,j-1],s[i-1,j]);str1[i]与str2[j]不相同
s[i,j]=max(s[i,j−1],s[i−1,j]);str1[i]与str2[j]不相同
确定边界条件要从决策方式入手,当两个字符串中的一个长度为0的时候,其公共子序列长度肯定是0,因此其边界条件就是:
s
[
i
,
j
]
=
0
;
i
=
0
或
j
=
0
s[i,j]=0;i=0或j=0
s[i,j]=0;i=0或j=0
字符串的编辑距离
把两个字符串的相似度定义为:将一个字符串转换成另外一个字符串时需要付出的代价。转换可以采用插入、删除和替换三种编辑方式,因此转换的代价就是对字符串的编辑次数。字符串转换的方式不唯一,不同的转换方法需要的编辑次数也不一样,最少的那个编辑次数就是字符串的编辑距离。
采用朴素的递归算法时间复杂度是
O
(
3
n
)
O(3^n)
O(3n),对于两个长度为5的字符串“SNOWY”和“SUNNY”,递归调用的次数是241次,接近于
3
5
3^5
35这个量级,当字符串的长度非常大的时候,这个算法将不能接受。因此要采用动态规划法对这个算法进行改进。
解决策略
这个问题的阶段划分不是很明显。首先定义问题的状态,在从状态转换关系入手定义阶段和子问题的递推关系。假设source字符串有n个字符,target字符串有m个字符,如果将问题定义为求解将source的
[
1
⋯
n
]
[1\cdots n]
[1⋯n]个字符转换为target的
[
1
⋯
m
]
[1\cdots m]
[1⋯m]个字符所需要的最少编辑次数(编辑距离),则其子问题就可以定义为将source的前
[
1
⋯
i
]
[1\cdots i]
[1⋯i]个字符转换为target的
[
1
⋯
j
]
[1\cdots j]
[1⋯j]个字符所需要的最少编辑次数,这就是这个问题的最优子结构。因此,将状态
d
[
i
,
j
]
d[i,j]
d[i,j]定义为从子串source
[
1
⋯
i
]
[1\cdots i]
[1⋯i]到子串target
[
1
⋯
j
]
[1\cdots j]
[1⋯j]之间的编辑距离。
根据决策方式,
d
[
i
,
j
]
d[i,j]
d[i,j]的递推关系分为两种情况,分别是source
[
i
]
[i]
[i]等于target
[
j
]
[j]
[j]和source
[
i
]
[i]
[i]不等于target
[
j
]
[j]
[j],两种情况下
d
[
i
,
j
]
d[i,j]
d[i,j]的递推关系如下:
d
[
i
,
j
]
=
d
[
i
−
1
,
j
−
1
]
+
0
;
s
o
u
r
c
e
[
i
]
等
于
t
a
r
g
e
t
[
j
]
d[i,j]=d[i-1,j-1]+0;source[i]等于target[j]
d[i,j]=d[i−1,j−1]+0;source[i]等于target[j]
d
[
i
,
j
]
=
m
i
n
(
d
[
i
,
j
−
1
]
+
1
,
d
[
i
−
1
,
j
]
+
1
,
d
[
i
−
1
,
j
−
1
]
+
1
)
;
s
o
u
r
c
e
[
i
]
不
等
于
t
a
r
g
e
t
[
j
]
d[i,j]=min(d[i,j-1]+1,d[i-1,j]+1,d[i-1,j-1]+1);source[i]不等于target[j]
d[i,j]=min(d[i,j−1]+1,d[i−1,j]+1,d[i−1,j−1]+1);source[i]不等于target[j]
当target字符串是空字符串时,编辑距离相当于将source字符串中的字符逐个删除的次数,可以确定一个边界条件为:
d
[
i
,
0
]
=
s
o
u
r
c
e
字
符
串
的
长
度
d[i,0]=source字符串的长度
d[i,0]=source字符串的长度
同样如果source字符串的长度为0,编辑距离相当于在source字符串中逐个插入target字符的次数,另一个边界条件为:
d
[
0
,
j
]
=
t
a
r
g
e
t
字
符
串
的
长
度
d[0,j]=target字符串的长度
d[0,j]=target字符串的长度
算法实现
def Levenshtein_Distance(s1, s2):
len_s1 = len(s1)
len_s2 = len(s2)
dp = [[0 for _ in range(len_s2+1)] for _ in range(len_s1 + 1)]
for i in range(len_s1 + 1):
for j in range(len_s2 + 1):
if i == 0:
dp[i][j] = j
elif j == 0:
dp[i][j] = i
elif s1[i - 1] == s2[j - 1]:
dp[i][j] = dp[i - 1][j - 1]
else:
dp[i][j] = 1 + min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1])
return dp[len_s1][len_s2]
def main():
option = input('是否选择使用默认字符串(Y/N): ')
if option == 'Y':
s1 = "I love you"
s2 = "I have been loving you for my whole life"
else:
s1 = str(input('请输入第一个字符串:'))
s2 = str(input('请输入第二个字符串:'))
distance = Levenshtein_Distance(s1, s2)
print("The distance from \""+s1+"\" to \""+s2+"\" is "+str(distance)+".")
main()
运行
在fish终端中的运行结果如下:
致谢
感谢广大网友。
主要参考内容:
[1]《算法的乐趣》——王晓华
[2]https://blog.youkuaiyun.com/qq_24003469/article/details/88972822