动态规划 (dynamic programming)
有趣的定义
How should I explain dynamic programming to a 4-year-old?
底下有个42K赞同的答案,是这样说的:
writes down "1+1+1+1+1+1+1+1 =" on a sheet of paper
"What's that equal to?"
counting "Eight!"
writes down another "1+" on the left
"What about that?"
quickly "Nine!"
"How'd you know it was nine so fast?"
"You just added one more"
"So you didn't need to recount because you remembered there were eight!Dynamic Programming is just a fancy way to say 'remembering stuff to save time later'"
斐波那契数列
什么是斐波那契额数列呢?
fb(0) = 1;
fb(1) = 1;
fb(2) = 2;
fb(n) = fb(n-2) + fb(n-1);
和青蛙跳台阶,人爬楼梯问题一样,都是属于斐波那契数列。
那么,如何求一个斐波那契数列呢?
function fib(n) {
if(n<2) return 1;
return fib(n - 1) + fib(n - 2);
}
那么这段代码有什么问题呢?
不难看出,这就是一个二叉树,
这颗二叉树的的高度是N-1,节点数接近2的N-1次方,时间复杂度高达O(2^n);
其中,递归执行了大量的重复计算
相同颜色的代表被重复执行,假设我们求的N稍微大一点的话,那就是指数级别的。
可以把这段代码粘贴到控制台跑一下
https://www.cs.usfca.edu/~gal...
既然会有那么多重复的出现,可以用一个map来存储,这也叫做备忘录算法,很多情况下还是需要用到的。
function fibout() {
let hash = new Map();
return function(n) {
if(hash.has(n)) return hash.get(n);
if(n < 2) return 1;
let res = fib(n - 1) + fib(n - 2);
hash.set(n, res);
return res;
}
}
let fib = fibout();
这样来分析下时间复杂度O(2n) => O(n),空间复杂度O(n);
那么这段代码会有什么问题呢?
因为return前就进入下一轮函数,所以需要保存当前的栈,这就会带来一个问题,轮数过多,就有爆栈的可能,所以这种情况下我们需要对数据量的掌控很充分,否则还是不太建议。
那么,我们看到,之前求的这个是从后往前求的数据,这样确实会造成栈一直存储,数据量大了就爆栈,所以我们可以改用从前往后,用迭代的方式来写
function fib(n) {
let dp = [1,1];
for(i = 2; i <=n; i++) {
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[n];
}
这样写的话,就不存在栈溢出的问题了,全程就一个栈。
斐波那契,就是一个典型的,用于解释动态规划的一个最简单的例子
可以看到,上述的代码的时间复杂度,最终降低到了真O(n)的水平,但是空间复杂度也是O(n)。
那么,还能优化吗?
仔细观察可以发现,上述的结果集,其实只和前面两个数有关
那我就不需要存下来整个dp了。
所以可以优化为
function fib(n) {
let pre = 1;
let cur = 1;
let next = 0;
for(i = 2; i <=n; i++) {
next = pre + cur;
pre = cur;
cur = next;
}
return next;
}
这样,空间复杂度就降低到了O(1)的水平。
通过上述例子,可以看出,遇到动态规划的题目,可以找到它的状态转移方程,就是类似上述的dp[i] = dp[i - 1] + dp[i - 2]
,就是我不需要感知所有的值,我只需要拿到之前计算好的值来算就可以了。
下面来一个稍微复杂一点的例子。
说这个例子前,我们先想一下,当你在搜索框中,一不小心输错单词时,假如这个单词不存在,或者在某个语句中大概率是另一个单词,搜索引擎会非常智能地检测出你的拼写错误,并提示你。你是否想过,这个功能是怎么实现的呢?
比如我搜索funf
,它会问你,是不是想搜fund
?
这样的一个问题,可以变成如何量化两个字符串的相似度?
计算机只认识数字,所以要解答开篇的问题,我们就要先来看,如何量化两个字符串之间的相似程度呢?有一个非常著名的量化方法,那就是编辑距离(Edit Distance)。
顾名思义,编辑距离指的就是,将一个字符串转化成另一个字符串,需要的最少编辑操作次数(比如增加一个字符、删除一个字符、替换一个字符)。编辑距离越大,说明两个字符串的相似程度越小;相反,编辑距离就越小,说明两个字符串的相似程度越大。对于两个完全相同的字符串来说,编辑距离就是0。
根据所包含的编辑操作种类的不同,编辑距离有多种不同的计算方式,比较著名的有莱文斯坦距离(Levenshtein distance)
和最长公共子串长度(Longest common substring length)
。其中,莱文斯坦距离允许增加、删除、替换字符这三个编辑操作,最长公共子串长度只允许增加、删除字符这两个编辑操作。
实际上,完成搜索引擎的这个功能并不是只需要一个编辑距离就够了的,但是编辑距离确实是其中非常重要的一环。
我们今天就来看一下,最长公共子序列(Longest Common Subsequence)
,
网上有对最长公共子串和子序列进行分类
比如:
a[] = “abcde”
b[] = “bce”
那么:
最长子串:”bc”
最长子序列:”bce”
显然的,上述搜索引擎用的肯定是子序列的这个描述。。
假设有两个字符串
str1 = educational
str2 = advantage
可以发现,匹配两个字符串,可以分成三种情况
两个字符位完全一致,
假设hello 和 tomato, 它们的最后一位都是o,那是不是可以分解为
hell和tomat的最长子串加上o?
这种情况,叫减而治之
但是更多的情况,是最后两位不等,那么应该咋办呢?
也就是用str1 - l后的str1,去和str2匹配或者是str1和str2减去e后的值,进行匹配。
那么这种分开的情况,叫做分而治之。
那递归式是不是就能写出来了?
function getMaxSubStr(str1, str2) {
let len1 = str1.length;
let len2 = str2.length;
if(!len1 || !len2) return 0;
let max = 0;
if(str1[len1 - 1] == str2[len2 - 1]) {
max = 1 + getMaxSubStr(str1.substring(0, len1 - 1), str2.substring(0, len2 - 1));
} else {
max = Math.max(getMaxSubStr(str1.substring(0, len1 - 1), str2), getMaxSubStr(str1, str2.substring(0, len2 - 1)))
}
return max;
}
这个的问题和斐波那契的问题类似。
那么如何用dp改造一下呢?
大家可以想一下
因为我们这里有两个字符串,所以我们需要一个二维的dp数组来辅助解决问题。
两值相等的情况
两值不等的情况
完整表格
function getMaxSubStr(str1, str2) {
let m = str1.length;
let n = str2.length;
var dp = Array.from(new Array(m + 1), () => new Array(n + 1).fill(0));
for(var i = 1; i <= m; i++) {
for( var j = 1; j <= n; j++) {
if(str1[i-1] == str2[j-1]) {
dp[i][j] = dp[i - 1][j - 1] + 1;
} else {
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
}
}
}
return dp[m][n];
}
如果是要求最长公共子序列的值呢?大家 自己想一下
下面,我们来看一个动态规划的实例
https://leetcode-cn.com/probl...
这里呢,出现了第三个常用的思路,就是最后一个我选或者不选,选了啥样的,不选了啥样的?
// n份工作
// 可以用工作的份数,作为dp坐标。
// var jobScheduling = function(startTime, endTime, profit) {
// };
// dp[3] {
// 一,就是不选它,那么不选它就是选前一组的最大报酬
// dp[2],
// 二,选它,既然选了它,那就得找到它的前一个能选的n。
// 主要是因为有一个endtime和starttime的指标,我们必须找到当前工作的startTime匹配前一个的endTime
// dp[pres[3]] + profit[3];
// }
// 那么,如何求得这个pre呢?我们可以看到,这个pre就只和starttime和endtime相关,而这两个都是已经有了的,那我们就可以先求得pre。
// start: 1, 3。 -1
// start: 2, 4。 -1
// start: 3, 5. 0
// function getPres(startTime, endTime) {
// let res = [-1];
// // for(var i = 0; i < startTime.length; i++) {
// // if(startTime[i] < endTime[i - 1]) {
// // endTime i --;
// // }
// // }
// let cur = 1;
// let pre = cur - 1;
// while(cur < startTime.length) {
// if(pre < 0 || startTime[cur] >= endTime[pre]) {
// res[cur] = pre;
// cur++;
// pre = cur - 1;
// }
// else {
// pre--
// }
// return res;
// }
// }
// 这个时候,我们就找到了每个job的前一个任务。
// 那上面的dp就好写了吧
// var jobScheduling = function(startTime, endTime, profit) {
// let pres = getPres(startTime, endTime);
// let dp = [];
// dp[-1] = 0;
// for(var i = 0; i < profit.length; i++) {
// dp[i] = Math.max(dp[i - 1], dp[pres[i]] + profit[i]);
// }
// return dp[profit.length - 1];
// };
// 但是我们忽略了一个情况,就是这个数组,它给过来的时候是按照startTime排序的,而不是按照endTime排序的,就会导致,dp[n - 1]很大,结果我dp[pres] +
// 那就得对endtime还要进行排序。
var jobScheduling = function(startTime, endTime, profit) {
const jobs = getJobs(startTime, endTime, profit);
const pre = getPre(jobs);
let dp = [];
dp[-1] = 0;
for(var i = 0; i < jobs.length; i++) {
dp[i] = Math.max(dp[i-1], dp[pre[i]] + jobs[i][2]);
}
return dp[jobs.length - 1];
};
function getJobs(startTime, endTime, profit) {
let res = [];
for(i = 0; i < startTime.length; i++) {
res.push([startTime[i], endTime[i], profit[i]]);
}
return res.sort(([s1,e1], [s2,e2]) => e1-e2);
}
function getPre(jobs) {
let len = jobs.length;
let cur = 1;
let pre = cur - 1;
let res = [-1];
while(cur < len) {
if(pre < 0 || jobs[cur][0] >= jobs[pre][1]) {
res[cur] = pre;
pre = cur;
cur++;
} else {
pre--;
}
}
return res;
}
这里求pre的方式,神似kmp算法中的核心,求next数组。