数据结构与算法三个经典案例带你了解动态规划

Posted 「零一」

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了数据结构与算法三个经典案例带你了解动态规划相关的知识,希望对你有一定的参考价值。

本系列文章【数据结构与算法】所有完整代码已上传 github,想要完整代码的小伙伴可以直接去那获取,可以的话欢迎点个Star哦~下面放上跳转链接

我们在面对很多问题时,会通过递归去解决问题,虽然递归的代码写起来非常的简洁,但效率不高,无法高效地将递归的代码转化成机器代码。

递归的思想是通过从问题的顶部开始,不断解决其中的小问题,使得问题得以解决 ;而我们本文要讲的动态规划的思想正好和递归的思想相反,其主要思想是先从一个个小问题开始解决,直到所有小问题都解决了,整个问题就得以解答。

那么就通过动态规划的的三个使用案例来体会动态规划的思想吧

  • 公众号:前端印象
  • 不定时有送书活动,记得关注~
  • 关注后回复对应文字领取:【面试题】、【前端必看电子书】、【数据结构与算法完整代码】、【前端技术交流群】

高级算法——动态规划

一、什么是动态规划

文章开头说到,动态规划是通过解决一个个小问题从而解决整个大问题的,因此我们一般会创建一个数组来记录每一个小问题的解决过程,那么我们就能很清楚的看到整个问题的解决过程。

创建一个表其实就是通过数组嵌套数组的方式来实现的,假如我们需要一个下图所示的表


我们只需要通过数组内嵌套数组即可

[
	[true, true, false, true],
	[false, false, true, true],
	[true, false, false, false],
	[false, false, false, true]
]

我们下面的三个案例也会反复用到这样的表,所以一定要理解

二、案例一:斐波那契数列

斐波那契数列是递归中最经典的一个例子,并且代码写起来也极其得简洁

function fibonacci1(n) 
	if(n == 1 || n == 2) return 1;
	return fibonacci(n - 1) + fibonacci(n - 2)

看似简洁的代码,其实内部有很多不足之处,我们可以用树结构来分析一下递归的过程,假设我们要获取斐波那契数列中第6个数,则递归过程如下图所示


很明显得看到,递归过程中,有很多值重复求了不止一次,例如 4 和 3 ,若我们要获取得数比6还大的话,重复求值的现象会更加的明显,这无疑是很消耗性能的,因此我们可以通过动态规划的方式来消除这种现象。

假设我们要获取斐波那契数列第n个数的值,首先我们可以创建个数组,从第一个数开始,在该数组中记录每个索引位置上的值

function fibonacci2(n) 
	// 记录斐波那契数列 第1个数 到 第n个数 的所有值
	let arr = [1, 1]
	// 获取n个数之前所有的值,并存储在arr中
	for(let i = 2; i < n; i++) 
		arr[i] = arr[i - 1] + arr[i - 2]
	
	
	// 直接从arr中返回我们要的值
	return arr[n - 1]

这种方法就是一个最简单的动态规划,即通过数组的形式记录着求取斐波那契数列这个大问题过程中每一个小问题的值,最后可以直接通过数组获取到我们想要的值。

因为其没有重复求值的缺点,因此效率肯定比递归的效率高,我们可以来验证一下

// 递归求取的开始时间
let start1 = Date.now()
// 递归求取第40个数的值
console.log(fibonacci1(40))
// 递归求取的结束时间
let end1 = Date.now()

// 动态规划求值的开始时间
let start2 = Date.now()
// 动态规划求取第40个数的值
console.log(fibonacci2(40));
// 动态规划求值的结束时间
let end2 = Date.now()

console.log(`
递归所用时间:$end1 - start1 ms
动态规划所用时间:$end2 - start2 ms
`);
/*
102334155
102334155

递归所用时间:2476 ms
动态规划所用时间:0 ms
*/

从结果我们能很明显地看到,仅仅第40个值对于动态规划来说根本不算什么,使用的时间几乎为0,而递归却因重复求值使用了2s以上的时间

三、案例二:寻找最大公共子串

首先先来看看问题需求:这里有两个字符串,即 ravenhavoc,现在我们要封装一个函数,来获取这两个字符串的所有最大公共子串,结果就是最大的公共子串为 av,并且最大的公共子串长度为2

首先看到这个问题,我觉得一般大家想到的办法都是跟图示一样

但这是一种简单粗暴的方法,把它用代码实现的话,中间也会有很多的重复比较的部分,所以我们这里也可以通过动态规划来解决此办法,即创建一个表,用来记录第一个字符串的每一个字符跟第二个字符串每一个字符的比较结果,最后再通过观察表来判断最大公共子串和最大公共子串的长度

假设现在有这样两个字符串:abaccdbadacef,我们要求它俩的最大公共子串

可以先建一个 8 * 7 的表,如图所示


行的表头表示的是第一个字符串的第n个字符;列的表头表示的是第二个字符串第m个字符

因此行的表头或列的表头为0对应的格子应当都为0,因为字符串没有第0个字符,最少是从第1个开始的,结果如下:


我们先找到行表头为1的这一行从左往右看,表示拿第一个字符串的第一个字符与第二个字符串的每一个字符进行比较,若不相同,则在对应格子里填0,表示的是连续相同字符的长度为0;若相同,则先看看该格子的左上角那个格子里的数 n 是多少,然后在该格子里填 n + 1

为什么当相同时要在该格子中填入比左上角的值大1的数呢?因为左上角的格子表示的是第一个字符串当前字符的前一个字符与第二个字符串当前字符的前一个字符比较后的连续相同字符长度

我们来看一下第一行的填写过程:


第二行表示的是拿第一个字符串的第二个字符与第二个字符串的每个字符的比较,过程如图所示:


第三行表示的是拿第一个字符串的第三个字符与第二个字符串的每个字符的比较,过程如图所示:


在上图第三行的填写过程中,第一个字符串的第三个字符与第二个字符串的第二个字符比较相同时,我们查看了一下该格子左上角的值,即判断了第一个字符当前字符的前一个字符与第二个字符当前字符的前一个字符比较后的连续字符长度为多少

剩下的三行填写过程如下图所示:


最终的表格如下图所示:


从表中我们可以看到,最大的公共子串长度为2,一共有两个长度为2的公共子串,分别是第一个字符串的第2个字符到第3个字符和第一个字符串的第3个字符到第4个字符,即 baac

根据上面的方法,我们来用代码封装一下求取最大公共子串的函数

function publicStr(s1, s2) 
    // 创建一个表
    let table = []

    // 记录最大的公共子串长度
    let max = 0

    // 子串进行比较,将表填完整
    for(let i = 0; i <= s1.length; i++) 
        table[i] = []
        for(let j = 0; j <= s2.length; j++) 
        	// 若行表头或列表头为0,格子里填0
            if(i == 0 || j == 0) table[i][j] = 0;
            // 若字符比对不相同
            else if(s1[i - 1] !== s2[j - 1]) table[i][j] = 0;
            // 字符比对相同
            else 
            	// 当前格子的值等于左上角格子的值+1
                table[i][j] = table[i - 1][j - 1] + 1
                // 判断max是否为最大公共子串的长度
                if(table[i][j] > max) max = table[i][j]
             
        
    

    // 记录所有的最大公共子串的信息
    let items = []
    
    // 遍历整个表,找到所有子串长度为max的子串的最后一个字符的索引
    for(let i = 0; i < s1.length; i ++) 
        let current = table[i]

        for(let j = 0; j < s2.length; j ++) 
            if(current[j] === max) items.push(i)
        
    

    console.log(`最大子串长度为$max`);
    console.log(`长度为$max的子串有:`);
    for(let i in items) 
        let start = items[i] - max
        console.log(`$s1.slice(start, start + max)`);
    

我们用上述例子来验证一下该函数是否正确,同时我还打印了一下表的结果,大家可以跟实例中的比对一下是否正确

let s1 = 'abaccd'
let s2 = 'badacef'

publicStr(s1, s2)
/* 打印结果:
	最大公共子串长度为2
	长度为2的子串有:
	ba
	ac
	表:[
		  [0, 0, 0, 0, 0, 0, 0, 0],
		  [0, 0, 1, 0, 1, 0, 0, 0],
		  [0, 1, 0, 0, 0, 0, 0, 0],
		  [0, 0, 2, 0, 1, 0, 0, 0],
		  [0, 0, 0, 0, 0, 2, 0, 0],
		  [0, 0, 0, 0, 0, 1, 0, 0],
		  [0, 0, 0, 1, 0, 0, 0, 0]
		]
*/

四、案例三:背包问题

背包问题也算是一个非常经典的问题,假设现在你的面前有4种珠宝,它们的重量分别为 3345 ,它们的价值分别为 4679,现在你有一个能装下重量为 8 的物品,请问你会如何挑选才能使利益最大化?

当然最简单的办法就是写出所有的组合,然后计算每种组合的价值,然后就能获得利益最大化的方案

这用递归实现是非常简单的,代码如下

// 封装一个判断大小的函数
function max(v1, v2) 
	return v1 > v2 ? v1 : v2


// 主函数,用于判断当前背包容量下,存放某个物品的最大收益
// 参数:背包容量、存放每个物品重量的数组、存放每个物品价值的数组、物品标号
function knapsack(capacity, size, value, n) 
	// 如果没有物品了或者背包没容量了,则最大收益为0
	if(n == 0 || capacity == 0) return 0;
	// 物品n的重量大于背包容量
	else if(size[n - 1] > capacity) 
		// 返回上一个物品的最大收益
		return knapsack(capacity, size, value, n - 1)
	
	// 物品n的重量小于背包容量
	else 
		// 此时有两种选择:第一种:拿该物品 ; 第二种:不拿该物品
		// 我们要取其中收益最大的方案,因此用到max函数
		return max(value[n - 1] + knapsack(capacity - size[n - 1], size, value, n - 1), knapsack(capacity, size, value, n - 1))
	


// 代码测试
let capacity = 8
let size = [3, 3, 4, 5]
let value = [4, 6, 7, 9]
let n = 4
let res = knapsack(capacity, size, value, n)
console.log(res)        // 15 , 表示最大收益价值为15

正如我们文章开头所说的,这样的递归效率总归是不太高的,因此我们要将其用动态规划实现,并且我们将需求改变一下,不光要求出最大收益价值,还要知道是拿了哪几样物品。

同样的,我们先创建一个表,用来记录每一种物品在任一背包容量下的最大收益


很明显,当背包容量为0时,我们能获得的最大收益一定为0;表中物品编号为0的这一行全部都要填上0,因为这是我们添加的对照行,并没有编号为0的物品,因此结果如图所示:


现在我们从编号为1的物品开始,判断其在背包容量为 1 ~ 8 的情况下,我们能获取到的最大利益为多少。显而易见,物品1的重量为3,因此当背包容量小于3时,最大收益都为0;当背包容量大于等于3时,因为还没有考虑别的物品,因此我们能获取的最大收益就等于物品1的价值,即等于4,结果如图所示:


接着我们考虑编号为2的物品在背包容量为 1 ~ 8 的情况下,我们能获取到的最大利益为多少。

首先知道物品2的重量为3,因此在背包容量小于3时,我们无法放入物品2,那么此时的最大收益就等于在当前背包容量下,放入物品1的最大收益;

当背包容量大于等于3时,我们能放入物品2,因此我们现在有两种选择:第一种就是不放物品2,那么我们就只能放物品1,所以我们能获得的最大收益就等于在此背包容量下放入物品1的最大收益;第二种就是放物品2,因为我们已经放了物品2了,只剩一个物品1了,所以此时的最大收益就等于物品2的价值 + 背包剩余容量下放入物品1的最大收益。我们要取这两种情况中收益最大的方案

填表过程如下图所示:


接着我们又考虑编号为3的物品在背包容量为 1 ~ 8 的情况下,我们能获取到的最大利益为多少。

首先知道物品3的重量为4,因此在背包容量小于4时,我们无法放入物品3,那么我们还需要考虑的就有物品1和物品2,从上一步骤得知,物品2的最大收益时在考虑了物品1的基础上得出的,因此我们只需要考虑放入物品2的最大收益即可,那么此时的最大收益就等于在当前背包容量下,放入物品2的最大收益;

当背包容量大于等于4时,我们能放入物品4,与上一个步骤类似,我们有两种选择,即放物品3和不放物品3

填表结果如下图所示:

同理,最后一行的填表过程如下图所示:

最终的填表结果如下图所示:


在表中可以很明显地看到,我们在背包容量为8的情况下,能获取到的最大收益为15

此时,我们还需要倒着推回去,判断一下是拿了哪几样物品才获取到的最大收益

首先找到最大收益对应的格子为物品4,然后我们判断一下该收益是否等于前一种物品(物品3)的最大收益,若等于,则表示没有放入物品4;否则表示放入了物品4。

为什么会这样判断呢?因为我们说过,在判断一个物品在某背包容量下的最大收益时,当物品重量大于背包容量或者我们选择不放入该物品时,此时的最大收益就等于前一种物品在此背包容量下的最大收益

所以这里能判断,我们放入了物品4,则此时背包容量只剩 8 - 5 = 3,所以我们找到物品3在背包容量等于3情况下最大收益对应的格子,同样判断一下上一种物品(物品2)的最大收益是否等于此格子中的最大收益,当前判断为相等,因此我们没有放入物品3

当前背包容量仍为3,我们找到物品2在背包容量等于3情况下最大收益对应的格子,判断当前最大收益不等于上一种物品(物品1)在背包容量为3情况下的最大收益,因此我们放入了物品2

则此时背包容量为 3 - 3 = 0了,无法再放入任何物品了,所以我们就可以得出结论,我们在放入物品2和物品4的情况下收益最大,最大收益价值为15


上面讲解了背包问题的动态规划思路,下面我们用代码来实现一下

function knapsack(capacity, size, value, n) 
    // 返回较大的值
    function max(v1, v2) 
        return v1 > v2 ? v1 : v2
    

    let table = []

    // 生成长度为n的表
    for(let i = 0; i <= n; i++) 
        table[i] = []
    
    
    // 判断每种物品面对不同背包容量时的最大收益
    for(let i = 0; i <= n; i++) 
        for(let j = 0; j <= capacity; j++) 
            // 物品种类序列为0或者背包容量为0时,最大收益为0
            if(i == 0 || j == 0) table[i][j] = 0;
            // 背包容量小于物品重量时,最大收益等于上一种物品在此背包容量下的最大收益
            else if(size[i - 1] > j) 
                table[i][j] = table[i - 1][j]
            
            /* 背包容量大于物品重量时,最大收益分两种情况:
               第一种情况:不放此物品。则最大收益等于上一种物品在此背包容量下的最大收益;
               第二种情况:放此物品。则最大收益等于该物品的收益加上剩余背包容量下,上一种物品的最大收益
            */ 
            else 
                table[i][j] = max(table[i - 1][j], value[i - 1] + table[i - 1][j - size[i - 1]])
            
        
    

    // 最大收益值
    let max_value = 0
    let which = -1
    // 寻找在背包容量为capacity时的最大收益值,以及最大收益值所对应的物品种类
    for(let i in table) 
        let k = table[i][capacity]
        if(k > max_value) 
            max_value = k
            which = i
         
    

    // 记录所装的物品
    let goods = []
    // 记录背包剩余容量
    let rest = capacity
    
    while(rest > 0 && which > 0) 
        // 若此时的最大收益不等于上一种物品在此背包容量下的最大收益,则放了此物品;否则就没放此物品
        if(table[which][rest] !== table[which - 1][rest]) 
            goods.push(which)
            rest -= size[which - 1]         
        
        which --
    

    console.log(`背包最大的收益为:$max_value,拿取的物品有$goods.join(',')`);

我们通过上面举得例子来验证一下封装好的函数的正确性,为了方便大家进行验证,我同时打印了代码中的表,可以和前面例子中最终填完的表进行比对

let size = [3, 3, 4, 5]
let value = [4, 6, 7, 9]
let capacity = 8
let n = 4

knapsack(capacity, size, value, n)
/*	打印结果:
	背包最大的收益为:15,拿取的物品有4,2
	表:[
		  [0, 0, 0, 0, 0, 0, 0, 0, 0],
		  [0, 0, 0, 4, 4, 4, 4, 4, 4],
		  [0,  0,  0,  6, 6, 6, 10, 10, 10],
		  [0,  0,  0,  6, 7, 7, 10, 13, 13],
		  [0,  0,  0,  6, 7, 9, 10, 13, 15]
		]
*/

可以看到,利用动态规划的方式解决背包问题,我们能清晰地看到整个问题地解决过程,还可以通过回溯的方式知道是放入了哪些物品获得的最大收益

五、结束语

高级算法中的动态规划应用就讲到这里吧,【数据结构与算法】这个专栏最后还只剩一篇文章了,即贪心算法,我会在之后发出来,也希望大家持续关注,多多支持~

大家可以关注我,之后我还会一直更新别的数据结构与算法的文章来供大家学习,并且我会把这些文章放到【数据结构与算法】这个专栏里,供大家学习使用。

然后大家可以关注一下我的微信公众号:前端印象,等这个专栏的文章完结以后,我会把每种数据结构和算法的笔记放到公众号上,大家可以去那获取。

或者也可以去我的github上获取完整代码,欢迎大家点个Star

我是Lpyexplore,一个因Python爬虫而进入前端的探索者。创作不易,喜欢的加个关注,点个收藏,给个赞~

以上是关于数据结构与算法三个经典案例带你了解动态规划的主要内容,如果未能解决你的问题,请参考以下文章

动态规划入门——经典的完全背包与多重背包问题

《数据结构与算法之美》27——初识动态规划

数据结构与算法——动态规划算法

《数据结构与算法之美》28——动态规划理论

数据结构与算法简记--动态规划

背包型动态规划