# 从最短编辑距离问题到初识动态规划
先说些题外话:
作为一个算法小白,我在领到最短编辑距离这个讲题的时候头就秃了一半,在后面看动态规划的时候剩下一半还不够我秃的👨🦲。
今天的题目虽然是从最短编辑距离到初识动态规划,但实际上,要解最短编辑距离,还是得从动态规划开始。
动态规划(Dynamic Programming),是一种用于求解最优问题的算法,相对其他算法(如回溯算法:穷举所有可能,时间复杂度为指数级),动态规划可以显著地降低时间复杂度。但是动态规划并不好学,而且因为某种莫名的力量,是众所周知的难学。主要是因为动态规划“求解问题的过程不太符合人类常规的思维方式”。
动态规划将问题分为多个决策阶段,每个阶段做一个决策(选择),记录每个阶段做完决策的状态,通过上一个阶段的状态推导出下一个阶段的状态,动态地推演。(划重点)
# 0-1背包问题
学习动态规划,主要还是多练,~~所以这是一节习题课......~~下面我们先通过一个非常著名的0-1背包问题,认识一下动态规划。
问:
有一个承重为w的背包,可装载的物品共有n个,在重量分别为weight(一个代表不同物品重量的数组)的物品中,可装载的重量最大值是多少?
如:背包可装9kg,5个物品,提供的物品有[2,2,4,6,3]kg
解:
我们将问题分解为物品总个数个阶段,每个阶段决策当前物品是否装入背包,每个阶段状态即当前总重量是否可达。即,挨个选择要不要这个物品。这个过程可以画一张表格来表示,1表示状态可达,0表示状态不可达。
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | |
---|---|---|---|---|---|---|---|---|---|---|
w=2 | 1 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
w=2 | 1 | 0 | 1 | 0 | 1 | 0 | 0 | 0 | 0 | 0 |
w=4 | 1 | 0 | 1 | 0 | 1 | 0 | 1 | 0 | 1 | 0 |
w=6 | 1 | 0 | 1 | 0 | 1 | 0 | 1 | 0 | 1 | 0 |
w=3 | 1 | 0 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 |
这个表格我们称为状态转移表,有了状态转移表,就可以用代码来描述这个表格:
定义数组=>初始化边界值=>推演
// weight:物品重量,n:物品个数,w:背包可承载重量
function knapsack(weight = [2,2,4,6,3], n = 5, w = 9) {
// 定义二维数组
let state = []
for(let i = 0; i < n; i++) {
state[i]=[]
}
// 初始化第一行数据
state[0][0]=true
if(weight[0] < w){
state[0][weight[0]] = true
}
// 动态规划状态转移
for(let i = 1; i < n; i++) {
// 决策为不放入背包
for(let j = 0; j <= w; j++) {
if(state[i-1][j]) state[i][j]=state[i-1][j]
}
// 决策为放入背包
for(let j = 0; j <= w - weight[i]; j++) {
if(state[i-1][j]) state[i][j+weight[i]]=true
}
}
for(let i = w; i >= 0; i--) {
if(state[n-1][i]) return i
}
return 0
}
结论:
这串代码的时间复杂度为O(n*w),但是额外申请了一个n*(w+1)的数组。
所以有时候我们说动态规划是拿空间来换时间。
优化:
实际上,这段代码还可以优化成一维数组:
function knapsack2(weight = [2,2,4,6,3], n = 5, w = 9) {
// 定义数组
let state = []
// 初始化
state[0]=true
if(weight[0] < w){
state[weight[0]] = true
}
// 动态规划状态转移
for(let i = 1; i < n; i++) {
// 决策为放入背包
for(let j = w-weight[i]; j >= 0; j--) { // 为了避免重复计算,这里从大到小开始遍历
if(state[j]) state[j+weight[i]]=true
}
}
for(let i = w; i >= 0; i--) {
if(state[i]) return i
}
return 0
}
问题升级:
如果每个物品有不同的价值,求可装入物品的最大价值是多少?
解:
对上面的状态转移表做一下优化,将状态值是/否换成当前总价值。
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | |
---|---|---|---|---|---|---|---|---|---|---|
w=2,val=1 | 0 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
w=2,val=2 | 0 | 0 | 1 | 0 | 3 | 0 | 0 | 0 | 0 | 0 |
w=4,val=4 | 0 | 0 | 1 | 0 | 3 | 0 | 5 | 0 | 7 | 0 |
w=6,val=6 | 0 | 0 | 1 | 0 | 3 | 0 | 5 | 0 | 7 | 0 |
w=3,val=2 | 0 | 0 | 1 | 0 | 3 | 3 | 5 | 5 | 7 | 7 |
用代码来描述:
// weight:物品重量,value:物品价值,n:物品个数,w:背包可承载重量
function knapsack3(weight = [2,2,4,6,3], value = [1,2,4,6,2], n = 5, w = 9) {
// 定义二维数组
let state = []
for(let i = 0; i < n; i++) {
state[i]=[]
for(let j = 0; j <= w; j++) {
state[i][j]=0
}
}
// 初始化
if(weight[0] < w){
state[0][weight[0]] = value[0]
}
// 动态规划状态转移
for(let i = 1; i < n; i++) {
// 决策为不放入背包
for(let j = 0; j <= w; j++) {
if(state[i-1][j] > 0) state[i][j]=state[i-1][j]
}
// 决策为放入背包
for(let j = 0; j <= w - weight[i]; j++) {
if(state[i-1][j] > 0) state[i][j+weight[i]] = Math.max(state[i][j+weight[i]],state[i-1][j]+value[i])
}
}
// 找最大值
let max = 0
for(let i = 0; i <= w; i++) {
if(state[n-1][i] > max) max = state[n-1][i]
}
return max
}
同样对这段代码可以做空间复杂度优化:
// weight:物品重量,value:物品价值,n:物品个数,w:背包可承载重量
function knapsack4(weight = [2,2,4,6,3], value = [1,2,4,6,2], n = 5, w = 9) {
// 定义二维数组
let state = []
for(let j = 0; j <= w; j++) {
state[j]=0
}
// 初始化
if(weight[0] < w){
state[weight[0]] = value[0]
}
console.log(state);
// 动态规划状态转移
for(let i = 1; i < n; i++) {
// 决策为放入背包
for(let j = w - weight[i]; j >= 0; j--) {
if(state[j] > 0) state[j+weight[i]] = Math.max(state[j+weight[i]],state[j]+value[i])
}
}
// 找最大值
let max = 0
for(let i = 0; i <= w; i++) {
if(state[i] > max) max = state[i]
}
return max
}
# 最短编辑距离
# 什么是最短编辑距离
我们常用的搜索引擎的拼写纠错功能的实现,就涉及到最短编辑距离。
编辑距离指的就是,将一个字符串转化成另一个字符串,需要的最少编辑操作次数(比如增加一个字符、删除一个字符、替换一个字符)。编辑距离越大,说明两个字符串的相似程度越小;相反,编辑距离就越小,说明两个字符串的相似程度越大。(《数据结构与算法之美》)
# 莱文斯坦距离
莱文斯坦距离(Levenshtein distance)是一种常用的计算最短编辑距离的方法,仅允许增加、删除、替换这三种编辑操作在改变字符串,描述两个字符串的差异大小。
以"make"和"mark"为例:
假如从"make"=>"mar",需要a步,那么从"make"=>"mark"需要增加一个"k",总计a+1步;
假如从"mak"=>"mark",需要b步,那么从"make"=>"mark"需要删除一个"e",总计b+1步;
假如从"mak"=>"mar",需要c步,那么从"make"=>"mark"需要替换"e"为"k",总计c+1步;
而替换的操作又分最后一个单词是否相同两种情况,相同的情况下,总计c步;
当我们求两个单词的最短编辑距离时,就可以通过上一步操作来推演:
min(a+1,b+1,c+1)
或min(a+1,b+1,c)
假设"make"的第0-i个字符与"mark"的第0-j个字符之间的最短编辑距离用d[i][j]表示,那么在上面的例子中a可以表示为d[i][j-1],b可以表示为d[i-1][j],c可以表示为d[i-1][j-1],于是得到:
d[i][j]=min(d[i][j-1]+1,d[i-1][j]+1,d[i-1][j-1]+1)
或d[i][j]=min(d[i][j-1]+1,d[i-1][j]+1,d[i-1][j-1])
我们称之为状态转移方程。
现在有了状态转移方程,怎么画状态转移表呢?像上面的背包问题一样,推演的过程需要初始值,在这里,我们从空字符串开始考虑。从空字符串到任何一个字符串的最短编辑距离,就是这个字符串的长度。
于是可以画出状态转移表,这样思考起来会更形象:
'' | 'm' | 'ma' | 'mak' | 'make' | |
---|---|---|---|---|---|
'' | 0 | 1 | 2 | 3 | 4 |
'm' | 1 | ||||
'ma' | 2 | ||||
'mar' | 3 | ||||
'mark' | 4 |
初始化边界值之后,逐行逐列填写。
例如第二行第二列,"m"-"m",末尾相同,取min(d[i][j-1]+1,d[i-1][j]+1,d[i-1][j-1])
,填0。
再比如,"m"-"ma",末尾不同,取min(d[i][j-1]+1,d[i-1][j]+1,d[i-1][j-1]+1)
,填1。
最终填好的状态转移表如下,从"make"到"mark"需要两步。
'' | 'm' | 'ma' | 'mak' | 'make' | |
---|---|---|---|---|---|
'' | 0 | 1 | 2 | 3 | 4 |
'm' | 1 | 0 | 1 | 2 | 3 |
'ma' | 2 | 1 | 0 | 1 | 2 |
'mar' | 3 | 2 | 1 | 1 | 2 |
'mark' | 4 | 3 | 2 | 2 | 2 |
写出推演代码:
定义数组=>特殊情况处理=>初始化边界值=>推演
/**
* @param {string} word1
* @param {string} word2
* @return {number}
*/
var minDistance = function(word1, word2) {
let n = word1.length,
m = word2.length;
// 有一个字符串为空串
if (n * m == 0) {
return n + m;
}
// DP 数组
let D = [];
// 边界状态初始化
// 初始化列
for (let i = 0; i < n + 1; i++) {
D[i] = [i];
}
// 初始化第一行
for (let j = 1; j < m + 1; j++) {
D[0].push(j);
}
// 计算所有 DP 值
for (let i = 1; i < n + 1; i++) {
for (let j = 1; j < m + 1; j++) {
let a = D[i - 1][j] + 1,
b = D[i][j - 1] + 1,
c = D[i - 1][j - 1];
if (word1.charAt(i - 1) != word2.charAt(j - 1)) {
c += 1;
}
D[i][j] = Math.min(a, b, c);
}
}
return D[n][m];
};
# 最长公共子串
最长公共子串(Longest common substring length)描述的并不是最短编辑距离,而是两个字符串的相似程度,仅允许增加或删除两种操作。虽然不是用于计算最短编辑距离,但也是实现拼写纠错的一种思路。
假设有两个字符串a="dinanec"、b="dynamic",就像背包问题一样,维护一个state二维数组在存储当前最长公共子串:
当a[i]和b[j]相同时,最长公共子串为state[i-1][j-1]+1、state[i-1][j]、state[i][j-1]中的最大值;
当a[i]和b[j]不同时,最长公共子串为state[i-1][j-1]、state[i-1][j]、state[i][j-1]中的最大值;
同样先思考边界值问题,如果其中一个字符串只有一个字符,那么它在另一个字符串中出现之前,最长公共子串为0,出现之后,最长公共子串为1。
再画一个状态转移表可能更形象:
'd' | 'dy' | 'dyn' | 'dyna' | 'dynam' | 'dynami' | 'dynamic' | |
---|---|---|---|---|---|---|---|
'd' | 1 | 1 | 1 | 1 | 1 | 1 | 1 |
'di' | 1 | 1 | |||||
'din' | 1 | ||||||
'dina' | 1 | ||||||
'dinan' | 1 | ||||||
'dinane' | 1 | ||||||
'dinanec' | 1 |
初始化边界值,然后逐行逐列填写。
例如,第二行第二列,"di"-"dy",末尾不同,取max(state[i-1][j-1],state[i-1][j],state[i][j-1])
,填1。
第四行第四列,"dina"-"dyna",末尾相同,取max(state[i-1][j-1]+1,state[i-1][j],state[i][j-1])
,填3。
'd' | 'dy' | 'dyn' | 'dyna' | 'dynam' | 'dynami' | 'dynamic' | |
---|---|---|---|---|---|---|---|
'd' | 1 | 1 | 1 | 1 | 1 | 1 | 1 |
'di' | 1 | 1 | 1 | 1 | 1 | 2 | 2 |
'din' | 1 | 1 | 2 | 2 | 2 | 2 | 2 |
'dina' | 1 | 1 | 2 | 3 | 3 | 3 | 3 |
'dinan' | 1 | 1 | 2 | 3 | 3 | 3 | 3 |
'dinane' | 1 | 1 | 2 | 3 | 3 | 3 | 3 |
'dinanec' | 1 | 1 | 2 | 3 | 3 | 3 | 4 |
翻译成代码:
/**
* @param {string} text1
* @param {string} text2
* @return {number}
*/
var longestCommonSubsequence = function(text1, text2) {
let l1 = text1.length, l2 = text2.length;
if(l1*l2===0) return 0
let state = [], a = text1.charAt(0), b = text2.charAt(0);
// 初始化第一行
state[0]=[]
for(let i = 0; i < l2; i++){
state[0][i] = a === text2.charAt(i)
? 1
: i===0
? 0
: state[0][i-1]
}
// 初始化第一列
for(let i = 1; i < l1; i++){
state[i]=[]
state[i][0] = b === text1.charAt(i)
? 1
: i===0
? 0
: state[i-1][0]
}
// 行行推进
for(let i = 1; i < l1; i++){
for(let j = 1; j < l2; j++){
state[i][j] = text1.charAt(i) === text2.charAt(j)
? Math.max(state[i-1][j-1]+1,state[i-1][j],state[i][j-1])
: Math.max(state[i-1][j-1],state[i-1][j],state[i][j-1])
}
}
return state[l1-1][l2-1]
};
# 实际场景
将用户输入与词库中的单词进行一一比较,取编辑距离最短的单词,或相似度最高的单词,提示给用户,这是拼写纠错的基本原理。但真正的搜索引擎肯定不止这么简单,一来词库数据量很大,二来要支持海量搜索,性能要求就会比较高,所以一般有所取舍。比如取出编辑距离最小的top10,根据热门搜索程度来决定,比如用多种算法,如上面这两种,然后取交集,再做取舍,再比如通过当前用户特有的搜索习惯作为优先词库,等等。
# 其他经典动态规划问题
# 变异杨辉三角问题
在杨辉三角中,某一个节点的值来自于上一层相邻两个节点的和。将它改造一下,假如每一层都可以随意填写数字,代表经过这个节点的路径长度,求到达底层的最短路径。
# 零钱兑换
有一个整数数组coins,表示不同面额的硬币,每种硬币个数无限,再指定一个总金额amount,表示总金额。求可凑成总金额所需的最少的硬币个数。
# 最长递增子序列
有一个整数数组nums,求最长递增子序列长度。
# 参考
《数据结构与算法之美》王争