动态规划 (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); 
}

enter image description here

那么这段代码有什么问题呢?
不难看出,这就是一个二叉树,
这颗二叉树的的高度是N-1,节点数接近2的N-1次方,时间复杂度高达O(2^n);
其中,递归执行了大量的重复计算
enter image description here
相同颜色的代表被重复执行,假设我们求的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前就进入下一轮函数,所以需要保存当前的栈,这就会带来一个问题,轮数过多,就有爆栈的可能,所以这种情况下我们需要对数据量的掌控很充分,否则还是不太建议。
image.png

那么,我们看到,之前求的这个是从后往前求的数据,这样确实会造成栈一直存储,数据量大了就爆栈,所以我们可以改用从前往后,用迭代的方式来写

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)。
那么,还能优化吗?

仔细观察可以发现,上述的结果集,其实只和前面两个数有关
image.png

那我就不需要存下来整个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
image.png

这样的一个问题,可以变成如何量化两个字符串的相似度?

计算机只认识数字,所以要解答开篇的问题,我们就要先来看,如何量化两个字符串之间的相似程度呢?有一个非常著名的量化方法,那就是编辑距离(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后的值,进行匹配。
那么这种分开的情况,叫做分而治之。
image.png

那递归式是不是就能写出来了?

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数组来辅助解决问题。

两值相等的情况
image.png

两值不等的情况
image.png

完整表格
image.png

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数组。


jansen
130 声望16 粉丝

学习不能止步,学习就是兴趣!终生学习是目标