1. 程式人生 > >前端學算法之算法模式

前端學算法之算法模式

前端 fib 二維 長度 答案 dash 空數組 我們 分數背包問題

前面的話

  本文將詳細介紹算法模式,包括遞歸、動態規劃和貪心算法

遞歸

  遞歸是一種解決問題的方法,它解決問題的各個小部分,直到解決最初的大問題。通常涉及函數調用自身

  能夠像下面這樣直接調用自身的方法或函數,是遞歸函數:

var recursiveFunction = function(someParam){ 
  recursiveFunction(someParam);
};

  能夠像下面這樣間接調用自身的函數,也是遞歸函數:

var recursiveFunction1 = function(someParam){ 
  recursiveFunction2(someParam);
};
var recursiveFunction2 = function(someParam){ recursiveFunction1(someParam); };

  假設現在必須要執行recursiveFunction,結果是什麽?單單就上述情況而言,它會一直執行下去。因此,每個遞歸函數都必須要有邊界條件,即一個不再遞歸調用的條件(停止點), 以防止無限遞歸

【JavaScript調用棧大小的限制】

  如果忘記加上用以停止函數遞歸調用的邊界條件,會發生什麽呢?遞歸並不會無限地執行下去;瀏覽器會拋出錯誤,也就是所謂的棧溢出錯誤(stack overflow error)

  每個瀏覽器都有自己的上限,可用以下代碼測試:

var i = 0;
function recursiveFn () { 
  i++;
  recursiveFn();
}
try { 
  recursiveFn();
} catch (ex) {
  alert(i =  + i +  error:  + ex);
}

  在Chrome中,這個函數執行了15699次,而後瀏覽器拋出錯誤RangeError: Maximum call stack size exceeded(超限錯誤:超過最大調用棧大小)

  ECMAScript 6有尾調用優化(tail call optimization)。如果函數內最後一個操作是調用函數,會通過“跳轉指令”(jump) 而不是“子程序調用”(subroutine call)來控制。也就是說,在ECMAScript 6中,這裏的代碼可以一直執行下去。所以,具有停止遞歸的邊界條件非常重要

【斐波那契數列】

  斐波那契數列的定義如下:

  1、1和2的斐波那契數是 1;

  2、n(n>2)的斐波那契數是(n-1)的斐波那契數加上(n-2)的斐波那契數

  下面來實現斐波那契函數

function fibonacci(num){
  if (num === 1 || num === 2){ 
    return 1;
  }
  return fibonacci(num - 1) + fibonacci(num - 2);
}

  我們已經知道,當n大於2時,Fibonacci(n)等於Fibonacci(n-1)+Fibonacci(n-2)。現在,斐波那契函數實現完畢。試著找出6的斐波那契數,其會產生如下函數調用:

技術分享圖片

  也可以用非遞歸的方式實現斐波那契函數:

function fib(num){
 var n1 = 1,
    n2 = 1,
    n = 1;
 for (var i = 3; i<=num; i++){
  n = n1 + n2;
  n1 = n2;
  n2 = n;
 }
 return n;
} 

  為何用遞歸呢?更快嗎?遞歸並不比普通版本更快,反倒更慢。但要知道,遞歸更容易理解,並且它所需的代碼量更少。所以,用遞歸通常是因為它更容易解決問題

動態規劃

  動態規劃(Dynamic Programming,DP)是一種將復雜問題分解成更小的子問題來解決的優化技術

  動態規劃和分而治之(歸並排序和快速排序算法中用到的那種)是不同的方法。分而治之方法是把問題分解成相互獨立的子問題,然後組合它們的答案,而動態規劃則是將問題分解成相互依賴的子問題

  用動態規劃解決問題時,要遵循三個重要步驟:

  1、定義子問題

  2、實現要反復執行而解決子問題的部分

  3、識別並求解出邊界條件。

  能用動態規劃解決的一些著名的問題如下

  1、背包問題:給出一組項目,各自有值和容量,目標是找出總值最大的項目的集合。這個問題的限制是,總容量必須小於等於“背包”的容量

  2、最長公共子序列:找出一組序列的最長公共子序列(可由另一序列刪除元素但不改變余下元素的順序而得到)

  3、矩陣鏈相乘:給出一系列矩陣,目標是找到這些矩陣相乘的最高效辦法(計算次數盡可能少)。相乘操作不會進行,解決方案是找到這些矩陣各自相乘的順序

  4、硬幣找零:給出面額為d1…dn的一定數量的硬幣和要找零的錢數,找出有多少種找零的方法

  5、圖的全源最短路徑:對所有頂點對(u, v),找出從頂點u到頂點v的最短路徑

【最少硬幣找零問題】

  最少硬幣找零問題是硬幣找零問題的一個變種。硬幣找零問題是給出要找零的錢數,以及可用的硬幣面額d1…dn及其數量,找出有多少種找零方法。最少硬幣找零問題是給出要找零的錢數,以及可用的硬幣面額d1…dn及其數量,找到所需的最少的硬幣個數

  例如,有以下面額(硬幣):d1=1,d2=5,d3=10,d4=25。如果要找36美分的零錢,可以用1個25美分、1個10美分和1個便士(1美分)。如何將這個解答轉化成算法?最少硬幣找零的解決方案是找到n所需的最小硬幣數。但要做到這一點,首先得找到對每個x<n的解。然後,我們將解建立在更小的值的解的基礎上

  來看看算法:

function MinCoinChange(coins){

    var cache = {};

    this.makeChange = function(amount) {
        var me = this;
        if (!amount) {
            return [];
        }
        if (cache[amount]) {
            return cache[amount];
        }
        var min = [], newMin, newAmount;
        for (var i=0; i<coins.length; i++){
            var coin = coins[i];
            newAmount = amount - coin;
            if (newAmount >= 0){
                newMin = me.makeChange(newAmount);
            }
            if (
                newAmount >= 0 &&
                (newMin.length < min.length-1 || !min.length) &&
                (newMin.length || !newAmount)
                ){
                min = [coin].concat(newMin);
                console.log(new Min  + min +  for  + amount);
            }
        }
        return (cache[amount] = min);
    };
}

  為了更有條理,創建了一個類,解決給定面額的最少硬幣找零問題

  MinCoinChange類接收coins參數(行{1}),代表問題中的面額。對美國的硬幣系統而言,它是[1, 5, 10, 25]。我們可以隨心所欲傳遞任何面額。此外,為了更加高效且不重復計算值,我們使用了cache(行{2})。

  接下來是makeChange方法,它也是一個遞歸函數,找零問題由它解決。首先,若amount不為正(< 0),就返回空數組(行{3});方法執行結束後,會返回一個數組,包含用來找零的各個面額的硬幣數量(最少硬幣數)。接著,檢查cache緩存。若結果已緩存(行{4}),則直接返回結果;否則,執行算法。

  我們基於coins參數(面額)解決問題。因此,對每個面額(行{5}),我們都計算newAmount(行{6})的值,它的值會一直減小,直到能找零的最小錢數(別忘了本算法對所有的x < amount都會計算makeChange結果)。若newAmount是合理的值(正值),我們也會計算它的找零結果(行{7})

  最後,我們判斷newAmount是否有效,minValue (最少硬幣數)是否是最優解,與此同時minValue和newAmount是否是合理的值({行10})。若以上判斷都成立,意味著有一個比之前更優的答案(行{11},以5美分為例,可以給5便士或者1個5美分鎳幣,1個5美分鎳幣是最優解)。 最後,返回最終結果(行{12})

  測試一下這個算法:

var minCoinChange = new MinCoinChange([1, 5, 10, 25]); 
console.log(minCoinChange.makeChange(36));

  要知道,如果我們檢查cache變量,會發現它存儲了從1到36美分的所有結果。以上代碼的結果是[1, 10, 25]

【背包問題】

  背包問題是一個組合優化問題。它可以描述如下:給定一個固定大小、能夠攜重W的背包,以及一組有價值和重量的物品,找出一個最佳解決方案,使得裝入背包的物品總重量不超過W,且總價值最大

  下面是一個例子:

物品 重量   價值
1     2     3
2     3     4
3     4     5

  考慮背包能夠攜帶的重量只有5。對於這個例子,我們可以說最佳解決方案是往背包裏裝入物品1和物品2,這樣,總重量為5,總價值為7。

  來看看背包算法:

function knapSack(capacity, weights, values, n) {
    var i, w, a, b, kS = [];
    for (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; //max(a,b)
                console.log(a +  can be part of the solution);
            } else{
                kS[i][w] = kS[i-1][w];
            }
        }
        console.log(kS[i].join());
    }
    return kS[n][capacity];
}

  來看看這個算法是如何工作的。

  行{1}:首先,初始化將用於尋找解決方案的矩陣ks[n+1][capacity+1]

  行{2}:忽略矩陣的第一列和第一行,只處理索引不為0的列和行。

  行{3}:物品i的重量必須小於約束(capacity)才有可能成為解決方案的一部分;否則,總重量就會超出背包能夠攜帶的重量,這是不可能發生的。發生這種情況時,只要忽略它,用之前的值就可以了(行{5})。

  行{4}:當找到可以構成解決方案的物品時,選擇價值大的那個。

  行{6}:最後,問題的解決方案就在這個二維表格右下角的後一個格子裏

  可以用開頭的例子來測試這個算法:

var values = [3, 4, 5],   
    weights = [2, 3, 4],   
    capacity = 5,   
    n = values.length; 
    console.log(knapSack(capacity, weights, values, n)); //輸出 7 

  下圖舉例說明了例子中kS矩陣的構造:

技術分享圖片

  這個算法只輸出背包攜帶物品價值的大值,而不列出實際的物品。我們可以增加下面的附加函數來找出構成解決方案的物品:

function findValues(n, capacity, kS, weights, values){
    var i=n, k=capacity;
    console.log(Items that are part of the solution:);
    while (i>0 && k>0){
        if (kS[i][k] !== kS[i-1][k]){
            console.log(item +i+ can be part of solution w,v:  + weights[i-1] + , + values[i-1]);
            i--;
            k = k - kS[i][k];
        } else {
            i--;
        }
    }
}

  可以在knapsack函數的行{6}之前調用這個函數。執行完整的算法,會得到如下輸出:

解決方案包含以下物品:
物品2,重量:4,價值:3
物品1,重量:3,價值:2
總價值:7 

【最長公共子序列(LCS)】

  另一個經常被當作編程挑戰問題的動態規劃問題是最長公共子序列(LCS):找出兩個字符串序列的長子序列的長度。長子序列是指,在兩個字符串序列中以相同順序出現,但不要求連續(非字符串子串)的字符串序列。

  考慮如下例子:

技術分享圖片

  再看看下面這個算法:

function lcs2(wordX, wordY) {
    var m = wordX.length,
        n = wordY.length,
        l = [],
        solution = [],
        i, j, a, b;
    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; //max(a,b)

                solution[i][j] = (l[i][j] == l[i - 1][j]) ? top : left;
            }
        }
        console.log(l[i].join());
        console.log(solution[i].join());
    }
    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);
}

  如果用‘acbaed‘和‘abcadf‘兩個字符串執行上面的算法,我們將得到輸出4。用於構建結果的矩陣l看起來像下面這樣。我們也可以用附加的算法來跟蹤LCS的值

技術分享圖片

  通過上面的矩陣,我們知道LCS算法的結果是長度為4的acad

【矩陣鏈相乘】

  矩陣鏈相乘是另一個可以用動態規劃解決的著名問題。這個問題是要找出一組矩陣相乘的最佳方式(順序)

  n行m列的矩陣A和m行p列的矩陣B相乘,結果是n行p列的矩陣C

  考慮我們想做A*B*C*D的乘法。因為乘法滿足結合律,所以我們可以讓這些矩陣以任意順序相乘。因此,考慮如下情況:

A是一個10行100列的矩陣 
B是一個100行5列的矩陣 
C是一個5行50列的矩陣
D是一個50行1列的矩陣
A*B*C*D的結果是一個10行1列的矩陣 

  在這個例子裏,相乘的方式有五種

  1、(A(B(CD))):乘法運算的次數是1750次

  2、((AB)(CD)):乘法運算的次數是5300次

  3、(((AB)C)D):乘法運算的次數是8000次

  4、((A(BC))D):乘法運算的次數是75 500次。

  5、(A((BC)D)):乘法運算的次數是31 000次

  相乘的順序不一樣,要進行的乘法運算總數也有很大差異。那麽,要如何構建一個算法,求出少的乘法運算操作次數?矩陣鏈相乘的算法如下:

function matrixChainOrder(p, n) {

    var i, j, k, l, q,
        m = [], s=[];

    for (i = 1; i <= n; i++){
        m[i] = [];
        m[i][i] = 0;

    }

    for (i = 0; i <= n; i++){ //to help printing the optimal solution
        s[i] = []; //auxiliary
        for (j=0; j<=n; j++){
            s[i][j] = 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 = cost/scalar multiplications
                q = m[i][k] + m[k+1][j] + p[i-1]*p[k]*p[j]; //{1}
                if (q < m[i][j]){
                    m[i][j] = q;
                    s[i][j]=k; // s[i,j] = Second auxiliary table that stores k
                }
            }
        }
    }

    console.log(m);
    console.log(s);

    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(")");
    }
}

  整個算法中重要的是行{1},神奇之處全都在這一行。它計算了給定括號順序的乘法運算次數,並將值保存在輔助矩陣m中

  執行修改後的算法,也能得到括號的最佳順序(A[1](A[2](A[3]A[4]))),並可以轉化為 (A(B(CD)))

貪心算法

  貪心算法遵循一種近似解決問題的技術,期盼通過每個階段的局部最優選擇(當前好的解),從而達到全局的最優(全局最優解)。它不像動態規劃算法那樣計算更大的格局

【最少硬幣找零問題】

  最少硬幣找零問題也能用貪心算法解決。大部分情況下的結果是最優的,不過對有些面額而言,結果不會是優的

function MinCoinChange(coins){
 var coins = coins; //{1}
 this.makeChange = function(amount) {
  var change = [],
      total = 0;
  for (var i=coins.length; i>=0; i--){ //{2}
    var coin = coins[i];
    while (total + coin <= amount) { //{3}
      change.push(coin); //{4}
      total += coin; //{5}
    }
  }
  return change;
 };
} 

  和動態規劃方法相似, 我們傳遞面額參數,實例化MinCoinChange(行{1})。對每個面額(行{2}——從大到小),把它的值和total相加後,total需要小於amount(行{3})。我們會將當前面額coin添加到結果中(行{4}),也會將它和total相加(行{5})

  這個解法很簡單。從大面額的硬幣開始,拿盡可能多的這種硬幣找零。當無法再拿更多這種價值的硬幣時,開始拿第二大價值的硬幣,依次繼續

  用和DP方法同樣的測試代碼測試:

var minCoinChange = new MinCoinChange([1, 5, 10, 25]);
console.log(minCoinChange.makeChange(36)); 

  結果依然是[25, 10, 1],和用DP得到的一樣。下圖闡釋了算法的執行過程:

技術分享圖片

  然而,如果用[1, 3, 4]面額執行貪心算法,會得到結果[4, 1, 1]。如果用動態規劃的解法,會得到優的結果[3, 3]

  比起動態規劃算法而言,貪心算法更簡單、更快。然而,如我們所見,它並不總是得到優答案。但是綜合來看,它相對執行時間來說,輸出了一個可以接受的解

【分數背包問題】

  求解分數背包問題的算法與動態規劃版本稍有不同。在0-1背包問題中,只能向背包裏裝入完整的物品,而在分數背包問題中,我們可以裝入分數的物品。我們用前面用過的例子來比較兩者的差異,如下所示:

物品 重量   價值
1     2     3
2     3     4
3     4     5

  在動態規劃的例子裏,考慮背包能夠攜帶的重量只有5。而在這個例子裏,我們可以說最佳解決方案是往背包裏裝入物品1和物品2,總重量為5,總價值為7

  如果在分數背包問題中考慮相同的容量,得到的結果是一樣的。因此,我們考慮容量為6的情況

  在這種情況下,解決方案是裝入物品1和物品2,還有25%的物品3。這樣,重量為6的物品總價值為8.25

function knapSack(capacity, values, weights) {
 var n = values.length,
      load = 0, i = 0, val = 0;
 for (i = 0; i < n && load < capacity; i++) { //{1}
  if (weights[i] <= (capacity - load)) { //{2}
    val += values[i];
    load += weights[i];
  } else {
    var r = (capacity - load) / weights[i]; //{3}
    val += r * values[i];
    load += weights[i];
  }
 }
 return w;
} 

  行{1}:總重量少於背包容量,繼續叠代,裝入物品

  行{2}:如果物品可以完整地裝入背包,就將其價值和重量分別計入背包已裝入物品的總價值(val)和總重量(load)

  行{3}:如果物品不能完整地裝入背包,計算能夠裝入部分的比例(r)

  如果在0-1背包問題中考慮同樣的容量6,我們就會看到,物品1和物品3組成了解決方案。在這種情況下,對同一個問題應用不同的解決方法,會得到兩種不同的結果

前端學算法之算法模式