在前面的文章中(js算法初窺02(排序算法02-歸並、快速以及堆排)我們學習了如何用分治法來實現歸並排序,那么動態規划跟分治法有點類似,但是分治法是把問題分解成互相獨立的子問題,最后組合它們的結果,而動態規划則是把問題分解成互相依賴的子問題。
那么我還有一個疑問,前面講了遞歸,那么遞歸呢?分治法和動態規划像是一種手段或者方法,而遞歸則是具體的做操作的工具或執行者。無論是分治法還是動態規划或者其他什么有趣的方法,都可以使用遞歸這種工具來“執行”代碼。
用動態規划來解決問題主要分為三個步驟:1、定義子問題,2、實現要反復執行來解決子問題的部分(比如用遞歸來“反復”),3、識別並求解出邊界條件。這么說有點懵逼....那么我們試試用動態規划來解決一些經典的問題。
一、最少硬幣找零問題
最少硬幣找零問題是硬幣找零問題的一個變種。硬幣找零問題是給出要找零的錢數,以及可用的硬幣面額以及對應的數量,找出有多少種找零的方法。最少硬幣找零問題則是要找出其中所需最少數量的硬幣。比如我們有1,5,10,25面額的硬幣,如果要找36面額的錢,要如何找零呢?答案是一個25,一個10,一個1。這就是答案。那么如何把上面的問題轉換成算法來解決呢?畢竟有了計算機很快速簡單的就可以得到結果,不用我們再費力地用人腦去解決問題了,下面我們就來看一下代碼:
//最少硬幣找零 function MinCoinChange(coins) { // coins是有多少種面額的錢幣。 // 這里我們直接把構造函數傳進來的參數用私有變量存儲一下。 var coins = coins; // 緩存結果集的變量對象 var cache = {}; // 定義一個構造函數的私有方法, this.makeChange = function (amount) { // 這里的this指向的就是this.makeChange私有函數本身,把它賦值給一個變量是為了不用在每次調用的時候都要計算(個人見解) var me = this; // amount就是我們要找零的錢數,如果為非正數,直接返回空數組,因為你找零的錢數不應該為負數。 if(!amount) { return []; }; // cache[amount]的判斷是為了在重復計算前面已經計算過的結果時可以直接返回結果 // 避免重復計算所造成的時間浪費 if(cache[amount]) { return cache[amount]; }; // min用來存儲最終結果的數組,newMin和newAmount分別是在邏輯的執行過程中,用於存儲當前的符合條件的找零數組和找零錢數的。 var min = [],newMin,newAmount; // 我們循環coins的長度。通過循環,我們為每一個conis數組中的面額都進行下面的邏輯操作。(主要是為當前coin做遞歸) for(var i = 0; i < coins.length; i++) { // 選擇coins中的當前面額。 var coin = coins[i]; // 我們用要找零的錢數減去當前要找零的面額。並存儲為newAmount變量。 newAmount = amount - coin; // 在當前循環的遞歸中,如果newAmount是不小於0的值,也就是合法的找零的錢數,我們同樣為該數調用找零方法。 // 這里就是有點類似分而治之的那種策略了,遞歸求解。 if(newAmount >= 0) { newMin = me.makeChange(newAmount); }; // 在前面符合條件的newAmount遞歸后會進入下一個值得邏輯執行,然后就會到這里的邏輯判斷 // 下面的if判斷主要是判斷是否是當前的最優解,如果是,那么就放入我們最終的數組內。 console.log(!min.length,min.length) if(newAmount >= 0 && (newMin.length < min.length - 1 || !min.length) && (newMin.length || !newAmount)) { min = [coin].concat(newMin); //console.log('new Min' + min + 'for' + amount); } }; //cache存儲了1到amount之間的所有結果 //console.log(cache) return (cache[amount] = min); }; }; var minCoinChange = new MinCoinChange([1,5,10,25]); console.log(minCoinChange.makeChange(36))
這是用動態規划的方法來解決最少硬幣找零問題,那么我們再來看看如何用貪心算法求解最少硬幣找零的問題。那么什么是貪心算法呢?貪心算法在有最優子結構的問題中尤為有效。最優子結構的意思是局部最優解能決定全局最優解。簡單地說,問題能夠分解成子問題來解決,子問題的最優解能遞推到最終問題的最優解。貪心算法與動態規划的不同在於它對每個子問題的解決方案都做出選擇,不能回退。動態規划則會保存以前的運算結果,並根據以前的結果對當前進行選擇,有回退功能。
我們還是來看下代碼:
function MinCoinChange(coins) { var coins = coins; this.makeChange = function (amount) { var change = [],total = 0; for(var i = coins.length; i >= 0; i--) { var coin = coins[i]; while(total + coin <= amount) { change.push(coin); total += coin; } } return change; }; } var minCoinChange = new MinCoinChange([1,5,10,25]); console.log(minCoinChange.makeChange(36))
我們看上面的代碼,主要邏輯跟動態規划十分相似,只是代碼本身要簡單了不少。貪心算法從我們的硬幣中最大的開始拿,直到拿不了了再去拿下一個,直到返回最終結果。那么我們看看兩種解決方法有什么不通過。動態規划會通過cache來緩存之前的計算結果,在當前的計算結果中與之前的對比,選擇兩者之間的最優解。而貪心算法則只是選擇了當前的最優解,不會回退,也不會去存儲記錄之前的解決方案。
二、背包問題
背包問題其實是一個組合優化問題,問題是這樣的,給定一個固定大小,能攜帶重量為W的背包,以及一組有價值和重量的物品,找出一個最佳解決方案,使得裝入背包的物品總重量不超過W,且總價值是最大的。這個問題有兩個版本,一個是0-1背包問題,該版本只允許背包里裝入完整的物品,不能拆分。還有另外一個是可以裝入分數物品。我們后面會用貪心算法來解決分數背包問題。
我們來看代碼:
//背包問題 function knapSack(capacity,weights,values,n) { var i,w,a,b,kS = []; for (var i = 0; i <= n; i++) { kS[i] = []; } for(i = 0; i <= n; i++) { for(w = 0; w <= capacity; w++) { if(i == 0 || w == 0) { kS[i][w] = 0; } else if(weights[i - 1] <= w) { a = values[i - 1] + kS[i - 1][w - weights[i - 1]]; b = kS[i - 1][w]; kS[i][w] = (a > b) ? a : b; } else { kS[i][w] = kS[i - 1][w]; } } } findValues(n,capacity,kS,weights,values); return kS[n][capacity]; }; function findValues(n,capacity,kS,weights,values) { var i = n,k = capacity; console.log('解決方案包括以下物品:'); while(i > 0 && k > 0) { if(kS[i][k] !== kS[i - 1][k]) { console.log('物品' + i + ',重量:' + weights[i- 1] + ',價值:' + values[i - 1]); i--; k = k - kS[i][k]; } else { i--; } } } var values = [3,4,5],weights = [2,3,4],capacity = 5,n = values.length; console.log(knapSack(capacity,weights,values,n))
上面的代碼中,我們最開始初始化一個矩陣,用來存放各種解決方案,而且要注意裝入背包的物品i必須小於capacity,也就是小於背包可容納的重量,才可以成為裝入背包的一部分,不然你一個物品就超過了背包可容納的重量,這是不允許的。並且當有兩個物品重量相同的時候,我們選擇價值較大的哪一個。
其實上面的算法還可以繼續優化,這里不做多講,大家有興趣可以深入學習。
貪心算法的分數背包問題:
分數背包問題和0-1背包問題類似,只是我們可以在分數背包中加入部分的物品。代碼並不難,大家自己寫一下就明白了。
function knapSack(capacity,values,weights) { var n = values.length,load = 0,i = 0,val = 0; for(i = 0; i < n && load < capacity; i++) { if(weights[i] <= (capacity - load)) { val += values[i]; load += weights[i]; } else { var r = (capacity - load) / weights[i]; val += r * values[i]; load += weights[i]; } } return val; } var values = [3,4,5],weights = [2,3,4],capacity = 6; console.log(knapSack(capacity,values,weights))
三、最長公共子序列問題
該問題是這樣的,找出兩個字符串序列中的最長子序列的長度。最長子序列是指,在兩個字符串序列中以相同的順序出現,但不要求一定是連續的字符串序列。
//最長公共子序列LCS function lcs(wordX,wordY) { var m = wordX.length,n = wordY.length,l = [],i,j,a,b; var solution = []; for (i = 0; i <= m; ++i) { l[i] = []; solution[i] = []; for(j = 0; j <= n; ++j) { l[i][j] = 0; solution[i][j] = '0'; } } for(i = 0; i <= m; i++) { for(j = 0; j <= n; j++) { if(i == 0 || j == 0) { l[i][j] = 0; } else if(wordX[i - 1] == wordY[j - 1]) { l[i][j] = l[i - 1][j - 1] + 1; solution[i][j] = 'diagonal'; } else { a = l[i - 1][j]; b = l[i][j - 1]; l[i][j] = (a > b) ? a : b; solution[i][j] = (l[i][j] == l[i - 1][j]) ? 'top' : 'left'; } } } printSolution(solution,l,wordX,wordY,m,n); return l[m][n]; } function printSolution(solution,l,wordX,wordY,m,n) { var a = m,b = n,i,j, x = solution[a][b], answer = ''; while(x !== '0') { if(solution[a][b] === 'diagonal') { answer = wordX[a - 1] + answer; a--; b--; } else if(solution[a][b] === 'left') { b--; } else if(solution[a][b] === 'top') { a--; } x = solution[a][b]; } console.log('lcs:' + answer); } lcs("acbaed","abcadf");
四、矩陣鏈相乘
該問題是要找出一組矩陣相乘的最佳方式(順序),在開始之前,有必要給大家簡單講解一下矩陣相乘,簡單來說就是,加入一個n行m列的矩陣A和m行p列的矩陣B相乘,會得到一個n行p列的矩陣C。要注意,只有一個矩陣的行與另一個矩陣的列相同兩個矩陣才可以想乘。
那么如果我想有A,B,C,D四個矩陣相乘,由於乘法滿足結合律(小學數學知識點)。所以我們可以這樣(A(B(CD))),或者這樣((AB)(CD))等五種相乘的方法,但是要注意的是,每種相乘的順序不一樣,我們的計算量也是不一樣的。所以,我們來構建一個函數,找出計算量最少的相乘方法。這就是矩陣鏈相乘問題了。
//矩陣鏈相乘 function matrixChainOrder(p,n) { var i,j,k,l,q,m = []; //輔助矩陣s var s = []; for(i = 0; i <= n; i++) { s[i] = []; for(j = 0; j <= n; j++) { s[i][j] = 0; } } for(i = 0; i <= n; i++) { m[i] = []; m[i][i] = 0; }; for(l = 2; l < n; l++) { for(i = 1; i <= n - l + 1; i++) { j = i + l - 1; m[i][j] = Number.MAX_SAFE_INTEGER; for(k = i; k <= j - 1; k++) { q = m[i][k] + m[k + 1][j] + p[i - 1]*p[k]*p[j]; if(q < m[i][j]) { m[i][j] = q; s[i][j] = k;//輔助矩陣 } } } } printOptimalParenthesis(s,1,n - 1); return m[1][n - 1]; } function printOptimalParenthesis(s,i,j) { if(i == j) { console.log("A[" + i + "]"); } else { console.log("("); printOptimalParenthesis(s,i,s[i][j]); printOptimalParenthesis(s,s[i][j] + 1,j); console.log(")"); } } var p = [10,100,5,50,1,100]; n = p.length; console.log(matrixChainOrder(p,n));
最后,由於本人水平有限,能力與大神仍相差甚遠,若有錯誤或不明之處,還望大家不吝賜教指正。非常感謝!