动态规划 (dynamic programming)
有趣的定义
<p>How should I explain dynamic programming to a 4-year-old?</p>
<p>底下有个42K赞同的答案,是这样说的:</p>
<p>writes down "1+1+1+1+1+1+1+1 =" on a sheet of paper</p>
<p>"What's that equal to?"</p>
<p>counting "Eight!"</p>
<p>writes down another "1+" on the left</p>
<p>"What about that?"</p>
<p>quickly "Nine!"</p>
<p>"How'd you know it was nine so fast?"</p>
<p>"You just added one more"</p>
<p>"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'"</p>
斐波那契数列
什么是斐波那契额数列呢?
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数组。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。