【資料結構與演算法】三個經典案例帶你瞭解動態規劃

qwer1030274531發表於2020-10-17

一、什麼是動態規劃

文章開頭說到,動態規劃是透過解決一個個小問題從而解決整個大問題的,因此我們一般會建立一個 陣列來記錄每一個小問題的解決過程,那麼我們就能很清楚的看到整個問題的解決過程。

建立一個表其實就是透過陣列巢狀陣列的方式來實現的,假如我們需要一個下圖所示的表

在這裡插入圖片描述
我們只需要透過陣列內巢狀陣列即可

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

我們下面的三個案例也會反覆用到這樣的表,所以一定要理解

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

斐波那契數列是遞迴中最經典的一個例子,並且程式碼寫起來也極其得簡潔

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

看似簡潔的程式碼,其實內部有很多不足之處,我們可以用樹結構來分析一下遞迴的過程,假設我們要獲取斐波那契數列中第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]}1234567891011

這種方法就是一個最簡單的動態規劃,即透過陣列的形式記錄著求取斐波那契數列這個大問題過程中每一個小問題的值,最後可以直接透過陣列獲取到我們想要的值。

因為其沒有重複求值的缺點,因此效率肯定比遞迴的效率高,我們可以來驗證一下

// 遞迴求取的開始時間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
*/12345678910111213141516171819202122232425

從結果我們能很明顯地看到,僅僅第40個值對於動態規劃來說根本不算什麼,使用的時間幾乎為0,而遞迴卻因重複求值使用了2s以上的時間

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

首先先來看看問題需求:這裡有兩個字串,即  raven 和  havoc,現在我們要封裝一個函式,來獲取這兩個字串的所有最大公共子串,結果就是最大的公共子串為  av,並且最大的公共子串長度為2

首先看到這個問題,我覺得一般大家想到的辦法都是跟圖示一樣
在這裡插入圖片描述
但這是一種簡單粗暴的方法,把它用程式碼實現的話,中間也會有很多的重複比較的部分,所以我們這裡也可以透過動態規劃來解決此辦法,即建立一個表,用來記錄第一個字串的每一個字元跟第二個字串每一個字元的比較結果,最後再透過觀察表來判斷最大公共子串和最大公共子串的長度

假設現在有這樣兩個字串: abaccd 和  badacef,我們要求它倆的最大公共子串

可以先建一個 8 * 7 的表,如圖所示

在這裡插入圖片描述
行的表頭表示的是第一個字串的第n個字元;列的表頭表示的是第二個字串第m個字元

因此行的表頭或列的表頭為0對應的格子應當都為0,因為字串沒有第0個字元,最少是從第1個開始的,結果如下:

在這裡插入圖片描述
我們先找到行表頭為1的這一行從左往右看,表示拿第一個字串的第一個字元與第二個字串的每一個字元進行比較,若不相同,則在對應格子裡填0,表示的是連續相同字元的長度為0;若相同,則先看看該格子的左上角那個格子裡的數  n是多少,然後在該格子裡填  n + 1

為什麼當相同時要在該格子中填入比左上角的值大1的數呢?因為左上角的格子表示的是第一個字串當前字元的前一個字元與第二個字串當前字元的前一個字元比較後的連續相同字元長度

我們來看一下第一行的填寫過程: /yunnan/

在這裡插入圖片描述
第二行表示的是拿第一個字串的第二個字元與第二個字串的每個字元的比較,過程如圖所示:

在這裡插入圖片描述
第三行表示的是拿第一個字串的第三個字元與第二個字串的每個字元的比較,過程如圖所示:

在這裡插入圖片描述
在上圖第三行的填寫過程中,第一個字串的第三個字元與第二個字串的第二個字元比較相同時,我們檢視了一下該格子左上角的值,即判斷了第一個字元當前字元的前一個字元與第二個字元當前字元的前一個字元比較後的連續字元長度為多少

剩下的三行填寫過程如下圖所示:

在這裡插入圖片描述
最終的表格如下圖所示: /xian/

在這裡插入圖片描述
從表中我們可以看到,最大的公共子串長度為2,一共有兩個長度為2的公共子串,分別是第一個字串的第2個字元到第3個字元和第一個字串的第3個字元到第4個字元,即  ba 和  ac

根據上面的方法,我們來用程式碼封裝一下求取最大公共子串的函式

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)}`);
    }}1234567891011121314151617181920212223242526272829303132333435363738394041424344

我們用上述例子來驗證一下該函式是否正確,同時我還列印了一下表的結果,大家可以跟例項中的比對一下是否正確

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]
		]
*/12345678910111213141516171819

四、案例三:揹包問題

揹包問題也算是一個非常經典的問題,假設現在你的面前有4種珠寶,它們的重量分別為  3 、 3 、 4 、 5 ,它們的價值分別為  4 、 6 、 7 、 9,現在你有一個能裝下重量為  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 = 8let size = [3, 3, 4, 5]let value = [4, 6, 7, 9]let n = 4let res = knapsack(capacity, size, value, n)console.log(res)        // 15 , 表示最大收益價值為15123456789101112131415161718192021222324252627282930

正如我們文章開頭所說的,這樣的遞迴效率總歸是不太高的,因此我們要將其用動態規劃實現,並且我們將需求改變一下,不光要求出最大收益價值,還要知道是拿了哪幾樣物品。 xinyang/

同樣的,我們先建立一個表,用來記錄每一種物品在任一揹包容量下的最大收益

在這裡插入圖片描述
很明顯,當揹包容量為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的最大收益。我們要取這兩種情況中收益最大的方案

填表過程如下圖所示: /tianjing/

在這裡插入圖片描述
接著我們又考慮編號為3的物品在揹包容量為  1 ~ 8 的情況下,我們能獲取到的最大利益為多少。

首先知道物品3的重量為4,因此在揹包容量小於4時,我們無法放入物品3,那麼我們還需要考慮的就有物品1和物品2,從上一步驟得知,物品2的最大收益時在考慮了物品1的基礎上得出的,因此我們只需要考慮放入物品2的最大收益即可,那麼此時的最大收益就等於在當前揹包容量下,放入物品2的最大收益;

當揹包容量大於等於4時,我們能放入物品4,與上一個步驟類似,我們有兩種選擇,即放物品3和不放物品3

填表結果如下圖所示:

在這裡插入圖片描述

同理,最後一行的填表過程如下圖所示:

在這裡插入圖片描述

最終的填表結果如下圖所示:

在這裡插入圖片描述
在表中可以很明顯地看到,我們在揹包容量為8的情況下,能獲取到的最大收益為15

此時,我們還需要倒著推回去,判斷一下是拿了哪幾樣物品才獲取到的最大收益

首先找到最大收益對應的格子為物品4,然後我們判斷一下該收益是否等於前一種物品(物品3)的最大收益,若等於,則表示沒有放入物品4;否則表示放入了物品4。 luoyang/

為什麼會這樣判斷呢?因為我們說過,在判斷一個物品在某揹包容量下的最大收益時,當物品重量大於揹包容量或者我們選擇不放入該物品時,此時的最大收益就等於前一種物品在此揹包容量下的最大收益

所以這裡能判斷,我們放入了物品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(',')}`);}123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960

我們透過上面舉得例子來驗證一下封裝好的函式的正確性,為了方便大家進行驗證,我同時列印了程式碼中的表,可以和前面例子中最終填完的表進行比對

let size = [3, 3, 4, 5]let value = [4, 6, 7, 9]let capacity = 8let n = 4knapsack(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]
		]
*/12345678910111213141516

可以看到,利用動態規劃的方式解決揹包問題,我們能清晰地看到整個問題地解決過程,還可以透過回溯的方式知道是放入了哪些物品獲得的最大收益

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/30239065/viewspace-2727595/,如需轉載,請註明出處,否則將追究法律責任。

相關文章