(5千字)由淺入深講解動態規劃(JS版)-鋼條切割,最大公共子序列,最短編輯距離

斐波拉契數列

首先咱們來看看斐波拉契數列,這是一個你們都很熟悉的數列:javascript

// f = [1, 1, 2, 3, 5, 8]
f(1) = 1;
f(2) = 1;
f(n) = f(n-1) + f(n -2); // n > 2

有了上面的公式,咱們很容易寫出計算f(n)的遞歸代碼:html

function fibonacci_recursion(n) {
  if(n === 1 || n === 2) {
    return 1;
  }
  
  return fibonacci_recursion(n - 1) + fibonacci_recursion(n - 2);
}

const res = fibonacci_recursion(5);
console.log(res);   // 5

如今咱們考慮一下上面的計算過程,計算f(5)的時候須要f(4)與f(3)的值,計算f(4)的時候須要f(3)與f(2)的值,這裏f(3)就重複算了兩遍。在咱們已知f(1)和f(2)的狀況下,咱們其實只須要計算f(3),f(4),f(5)三次計算就好了,可是從下圖可知,咱們總共計算了8次,裏面f(3), f(2), f(1)都有屢次重複計算。若是n不是5,而是一個更大的數,計算次數更是指數倍增加。不考慮已知1和2的狀況的話,這個遞歸算法的時間複雜度是$O(2^n)$。java

非遞歸的斐波拉契數列

爲了解決上面指數級的時間複雜度,咱們不能用遞歸算法了,而要用一個普通的循環算法。應該怎麼作呢?咱們只須要加一個數組,裏面記錄每一項的值就好了,爲了讓數組與f(n)的下標相對應,咱們給數組開頭位置填充一個0算法

const res = [0, 1, 1];
f(n) = res[n];

咱們須要作的就是給res數組填充值,而後返回第n項的值就好了:數組

function fibonacci_no_recursion(n) {
  const res = [0, 1, 1];
  for(let i = 3; i <= n; i++){
    res[i] = res[i-1] + res[i-2];
  }
  
  return res[n];
}

const num = fibonacci_no_recursion(5);
console.log(num);   // 5

上面的方法就沒有重複計算的問題,由於咱們把每次的結果都存到一個數組裏面了,計算f(n)的時候只須要將f(n-1)和f(n-2)拿出來用就好了,由於是從小往大算,因此f(n-1)和f(n-2)的值以前就算好了。這個算法的時間複雜度是O(n),比$O(2^n)$好的多得多。這個算法其實就用到了動態規劃的思想。數據結構

動態規劃

動態規劃主要有以下兩個特色測試

  1. 最優子結構:一個規模爲n的問題能夠轉化爲規模比他小的子問題來求解。換言之,f(n)能夠經過一個比他規模小的遞推式來求解,在前面的斐波拉契數列這個遞推式就是f(n) = f(n-1) + f(n -2)。通常具備這種結構的問題也能夠用遞歸求解,可是遞歸的複雜度過高。
  2. 子問題的重疊性:若是用遞歸求解,會有不少重複的子問題,動態規劃就是修剪了重複的計算來下降時間複雜度。可是由於須要存儲中間狀態,空間複雜度是增長了。

其實動態規劃的難點是概括出遞推式,在斐波拉契數列中,遞推式是已經給出的,可是更多狀況遞推式是須要咱們本身去概括總結的。spa

鋼條切割問題

先看看暴力窮舉怎麼作,以一個長度爲5的鋼條爲例:code

上圖紅色的位置表示能夠下刀切割的位置,每一個位置能夠有切和不切兩種狀態,總共是$2^4 = 16$種,對於長度爲n的鋼條,這個狀況就是$2^{n-1}$種。窮舉的方法就不寫代碼了,下面直接來看遞歸的方法:htm

遞歸方案

仍是以上面那個長度爲5的鋼條爲例,假如咱們只考慮切一刀的狀況,這一刀的位置能夠是1,2,3,4中的任意位置,那切割以後,左右兩邊的長度分別是:

// [left, right]: 表示切了後左邊,右邊的長度
[1, 4]: 切1的位置
[2, 3]: 切2的位置
[3, 2]: 切3的位置
[4, 1]: 切4的位置

分紅了左右兩部分,那左右兩部分又能夠繼續切,每部分切一刀,又變成了兩部分,又能夠繼續切。這不就將一個長度爲5的問題,分解成了4個小問題嗎,那最優的方案就是這四個小問題裏面最大的那個值,同時不要忘了咱們也能夠一刀都不切,這是第五個小問題,咱們要的答案其實就是這5個小問題裏面的最大值。寫成公式就是,對於長度爲n的鋼條,最佳收益公式是:

  • $r_n$ : 表示咱們求解的目標,長度爲n的鋼條的最大收益
  • $p_n$: 表示鋼條徹底不切的狀況
  • $r_1 + r_{n-1}$: 表示切在1的位置,分爲了左邊爲1,右邊爲n-1長度的兩端,他們的和是這種方案的最優收益
  • 咱們的最大收益就是不切和切在不一樣狀況的子方案裏面找最大值

上面的公式已經能夠用遞歸求解了:

const p = [0, 1, 5, 8, 9, 10, 17, 17, 20, 24, 30]; // 下標表示鋼條長度,值表示對應價格

function cut_rod(n) {
  if(n === 1) return 1;
  
  let max = p[n];
  for(let i = 1; i < n; i++){
    let sum = cut_rod(i) + cut_rod(n - i);
    if(sum > max) {
      max = sum;
    }
  }
  
  return max;
}

cut_rod(9);  // 返回 25

上面的公式還能夠簡化,假如咱們長度9的最佳方案是切成2 3 2 2,用前面一種算法,第一刀將它切成2 75 4,而後兩邊再分別切最終均可以獲得2 3 2 2,因此5 4方案最終結果和2 7方案是同樣的,都會獲得2 3 2 2,若是這兩種方案,兩邊都繼續切,其實還會有重複計算。那長度爲9的切第一刀,左邊的值確定是1 -- 9,咱們從1依次切過來,若是後面繼續對左邊的切割,那繼續切割的那個左邊值一定是咱們前面算過的一個左邊值。好比5 4切割成2 3 4,其實等價於第一次切成2 7,第一次若是是3 6,若是繼續切左邊,切爲1 2 6,其實等價於1 8,都是前面切左邊爲1的時候算過的。因此若是咱們左邊依次是從1切過來的,那麼就沒有必要再切左邊了,只須要切右邊。因此咱們的公式能夠簡化爲: $$ r_n = \max_{1<=i<=n}(pi+r_{n-i}) $$ 繼續用遞歸實現這個公式:

const p = [0, 1, 5, 8, 9, 10, 17, 17, 20, 24, 30]; // 下標表示鋼條長度,值表示對應價格

function cut_rod2(n) {
  if(n === 1) return 1;
  
  let max = p[n];
  for(let i = 1; i <= n; i++){
    let sum = p[i] + cut_rod2(n - i);
    if(sum > max) {
      max = sum;
    }
  }
  
  return max;
}

cut_rod2(9);  // 結果仍是返回 25

上面的兩個公式都是遞歸,複雜度都是指數級的,下面咱們來說講動態規劃的方案。

動態規劃方案

動態規劃方案的公式和前面的是同樣的,咱們用第二個簡化了的公式: $$ r_n = \max_{1<=i<=n}(pi+r_{n-i}) $$ 動態規劃就是不用遞歸,而是從底向上計算值,每次計算上面的值的時候,下面的值算好了,直接拿來用就行。因此咱們須要一個數組來記錄每一個長度對應的最大收益。

const p = [0, 1, 5, 8, 9, 10, 17, 17, 20, 24, 30]; // 下標表示鋼條長度,值表示對應價格

function cut_rod3(n) {
  let r = [0, 1];   // r數組記錄每一個長度的最大收益
  
  for(let i = 2; i <=n; i++) {
    let max = p[i];
    for(let j = 1; j <= i; j++) {
      let sum = p[j] + r[i - j];
      
      if(sum > max) {
        max = sum;
      }
    }
    
    r[i] = max;
  }
  
  console.log(r);
  return r[n];
}

cut_rod3(9);  // 結果仍是返回 25

咱們還能夠把r數組也打出來看下,這裏面存的是每一個長度對應的最大收益:

r = [0, 1, 5, 8, 10, 13, 17, 18, 22, 25]

使用動態規劃將遞歸的指數級複雜度降到了雙重循環,即$O(n^2)$的複雜度。

輸出最佳方案

上面的動態規劃雖然計算出來最大值,可是咱們並非知道這個最大值對應的切割方案是什麼,爲了知道這個方案,咱們還須要一個數組來記錄切割一次時左邊的長度,而後在這個數組中回溯來找出切割方案。回溯的時候咱們先取目標值對應的左邊長度,而後右邊剩下的長度右繼續去這個數組找最優方案對應的左邊切割長度。假設咱們左邊記錄的數組是:

leftLength = [0, 1, 2, 3, 2, 2, 6, 1, 2, 3]

咱們要求長度爲9的鋼條的最佳切割方案:

1. 找到leftLength[9], 發現值爲3,記錄下3爲一次切割
2. 左邊切了3以後,右邊還剩6,又去找leftLength[6],發現值爲6,記錄下6爲一次切割長度
3. 又切了6以後,發現還剩0,切完了,結束循環;若是還剩有鋼條繼續按照這個方式切
4. 輸出最佳長度爲[3, 6]

改造代碼以下:

const p = [0, 1, 5, 8, 9, 10, 17, 17, 20, 24, 30]; // 下標表示鋼條長度,值表示對應價格

function cut_rod3(n) {
  let r = [0, 1];   // r數組記錄每一個長度的最大收益
  let leftLength = [0, 1];  // 數組leftLength記錄切割一次時左邊的長度
  let solution = [];
  
  for(let i = 2; i <=n; i++) {
    let max = p[i];
    leftLength[i] = i;     // 初始化左邊爲整塊不切
    for(let j = 1; j <= i; j++) {
      let sum = p[j] + r[i - j];
      
      if(sum > max) {
        max = sum;
        leftLength[i] = j;  // 每次找到大的值,記錄左邊的長度
      } 
    }
    
    r[i] = max;
  }
  
  // 回溯尋找最佳方案
  let tempN = n;
  while(tempN > 0) {
    let left = leftLength[tempN];
    solution.push(left);
    tempN = tempN - left;
  }
  
  console.log(leftLength);  // [0, 1, 2, 3, 2, 2, 6, 1, 2, 3]
  console.log(solution);    // [3, 6]
  console.log(r);           // [0, 1, 5, 8, 10, 13, 17, 18, 22, 25]
  return {max: r[n], solution: solution};
}

cut_rod3(9);  // {max: 25, solution: [3, 6]}

最長公共子序列

上敘問題也能夠用暴力窮舉來求解,先列舉出X字符串全部的子串,假設他的長度爲m,則總共有$2^m$種狀況,由於對於X字符串中的每一個字符都有留着和不留兩種狀態,m個字符的全排列種類就是$2^m$種。那對應的Y字符串就有$2^n$種子串, n爲Y的長度。而後再遍歷找出最長的公共子序列,這個複雜度很是高,我這裏就不寫了。

咱們觀察兩個字符串,若是他們最後一個字符相同,則他們的LCS就是兩個字符串都去調最後一個字符的LCS再加一,若是他們最後一個字符不相同,那他們的LCS就是X去掉最後一個字符與Y的LCS,或者是X與Y去掉最後一個字符的LCS,是他們兩個中較長的那一個。寫成數學公式就是:

看着這個公式,一個規模爲(i, j)的問題轉化爲了規模爲(i-1, j-1)的問題,這不就又能夠用遞歸求解了嗎?

遞歸方案

公式都有了,不廢話,直接寫代碼:

function lcs(str1, str2) {
  let length1 = str1.length;
  let length2 = str2.length;
  
  if(length1 === 0 || length2 === 0) {
    return 0;
  }
  
  let shortStr1 = str1.slice(0, -1);
  let shortStr2 = str2.slice(0, -1);
  if(str1[length1 - 1] === str2[length2 -  1]){
    return lcs(shortStr1, shortStr2) + 1;
  } else {
    let lcsShort2 = lcs(str1, shortStr2);
    let lcsShort1 = lcs(shortStr1, str2);
    
    return lcsShort1 > lcsShort2 ? lcsShort1 : lcsShort2;
  }
}

let result = lcs('ABBCBDE', 'DBBCD');
console.log(result);   // 4

動態規劃

遞歸雖然能實現咱們的需求,可是複雜度是在過高,長一點的字符串須要的時間是指數級增加的。咱們仍是要用動態規劃來求解,根據咱們前面講的動態規劃原理,咱們須要從小的往大的算,每算出一個值都要記下來。由於c(i, j)裏面有兩個變量,咱們須要一個二維數組才能存下來。注意這個二維數組的行數是X的長度加一,列數是Y的長度加一,由於第一行和第一列表示X或者Y爲空串的狀況。代碼以下:

function lcs2(str1, str2) {
  let length1 = str1.length;
  let length2 = str2.length;
  
  // 構建一個二維數組
  // i表示行號,對應length1 + 1
  // j表示列號, 對應length2 + 1
  // 第一行和第一列所有爲0
  let result = [];
  for(let i = 0; i < length1 + 1; i++){
    result.push([]); //初始化每行爲空數組
    for(let j = 0; j < length2 + 1; j++){
      if(i === 0) {
        result[i][j] = 0; // 第一行所有爲0
      } else if(j === 0) {
        result[i][j] = 0; // 第一列所有爲0
      } else if(str1[i - 1] === str2[j - 1]){
        // 最後一個字符相同
        result[i][j] = result[i - 1][j - 1] + 1;
      } else{
        // 最後一個字符不一樣
        result[i][j] = result[i][j - 1] > result[i - 1][j] ? result[i][j - 1] : result[i - 1][j];
      }
    }
  }
  
  console.log(result);
  return result[length1][length2]
}

let result = lcs2('ABCBDAB', 'BDCABA');
console.log(result);   // 4

上面的result就是咱們構造出來的二維數組,對應的表格以下,每一格的值就是c(i, j),若是$X_i = Y_j$,則它的值就是他斜上方的值加一,若是$X_i \neq Y_i$,則它的值是上方或者左方較大的那一個。

輸出最長公共子序列

要輸出LCS,思路仍是跟前面切鋼條的相似,把每一步操做都記錄下來,而後再回溯。爲了記錄操做咱們須要一個跟result二維數組同樣大的二維數組,每一個格子裏面的值是當前值是從哪裏來的,固然,第一行和第一列仍然是0。每一個格子的值要麼從斜上方來,要麼上方,要麼左方,因此:

1. 咱們用1來表示當前值從斜上方來
2. 咱們用2表示當前值從左方來
3. 咱們用3表示當前值從上方來

看代碼:

function lcs3(str1, str2) {
  let length1 = str1.length;
  let length2 = str2.length;
  
  // 構建一個二維數組
  // i表示行號,對應length1 + 1
  // j表示列號, 對應length2 + 1
  // 第一行和第一列所有爲0
  let result = [];
  let comeFrom = [];   // 保存來歷的數組
  for(let i = 0; i < length1 + 1; i++){
    result.push([]); //初始化每行爲空數組
    comeFrom.push([]);
    for(let j = 0; j < length2 + 1; j++){
      if(i === 0) {
        result[i][j] = 0; // 第一行所有爲0
        comeFrom[i][j] = 0;
      } else if(j === 0) {
        result[i][j] = 0; // 第一列所有爲0
        comeFrom[i][j] = 0;
      } else if(str1[i - 1] === str2[j - 1]){
        // 最後一個字符相同
        result[i][j] = result[i - 1][j - 1] + 1;
        comeFrom[i][j] = 1;      // 值從斜上方來
      } else if(result[i][j - 1] > result[i - 1][j]){
        // 最後一個字符不一樣,值是左邊的大
        result[i][j] = result[i][j - 1];
        comeFrom[i][j] = 2;
      } else {
        // 最後一個字符不一樣,值是上邊的大
        result[i][j] = result[i - 1][j];
        comeFrom[i][j] = 3;
      }
    }
  }
  
  console.log(result);
  console.log(comeFrom);
  
  // 回溯comeFrom數組,找出LCS
  let pointerI = length1;
  let pointerJ = length2;
  let lcsArr = [];   // 一個數組保存LCS結果
  while(pointerI > 0 && pointerJ > 0) {
    console.log(pointerI, pointerJ);
    if(comeFrom[pointerI][pointerJ] === 1) {
      lcsArr.push(str1[pointerI - 1]);
      pointerI--;
      pointerJ--;
    } else if(comeFrom[pointerI][pointerJ] === 2) {
      pointerI--;
    } else if(comeFrom[pointerI][pointerJ] === 3) {
      pointerJ--;
    }
  }
  
  console.log(lcsArr);   // ["B", "A", "D", "B"]
  //如今lcsArr順序是反的
  lcsArr = lcsArr.reverse();
  
  return {
    length: result[length1][length2], 
    lcs: lcsArr.join('')
  }
}

let result = lcs3('ABCBDAB', 'BDCABA');
console.log(result);   // {length: 4, lcs: "BDAB"}

最短編輯距離

這是leetcode上的一道題目,題目描述以下:

這道題目的思路跟前面最長公共子序列很是像,咱們一樣假設第一個字符串是$X=(x_1, x_2 ... x_m)$,第二個字符串是$Y=(y_1, y_2 ... y_n)$。咱們要求解的目標爲$r$, $r[i][j]$爲長度爲$i$的$X$和長度爲$j$的$Y$的解。咱們一樣從兩個字符串的最後一個字符開始考慮:

  1. 若是他們最後一個字符是同樣的,那最後一個字符就不須要編輯了,只須要知道他們前面一個字符的最短編輯距離就好了,寫成公式就是:若是$Xi = Y_j$,$r[i][j] = r[i-1][j-1]$。
  2. 若是他們最後一個字符是不同的,那最後一個字符確定須要編輯一次才行。那最短編輯距離就是$X$去掉最後一個字符與$Y$的最短編輯距離,再加上最後一個字符的一次;或者是是$Y$去掉最後一個字符與$X$的最短編輯距離,再加上最後一個字符的一次,就看這兩個數字哪一個小了。這裏須要注意的是$X$去掉最後一個字符或者$Y$去掉最後一個字符,至關於在$Y$上進行插入和刪除,可是除了插入和刪除兩個操做外,還有一個操做是替換,若是是替換操做,並不會改變兩個字符串的長度,替換的時候,距離爲$r[i][j]=r[i-1][j-1]+1$。最終是在這三種狀況裏面取最小值,寫成數學公式就是:若是$Xi \neq Y_j$,$r[i][j] = \min(r[i-1][j], r[i][j-1],r[i-1][j-1]) + 1$。
  3. 最後就是若是$X$或者$Y$有任意一個是空字符串,那爲了讓他們同樣,就往空的那個插入另外一個字符串就好了,最短距離就是另外一個字符串的長度。數學公式就是:若是$i=0$,$r[i][j] = j$;若是$j=0$,$r[i][j] = i$。

上面幾種狀況總結起來就是 $$ r[i][j]= \begin{cases} j, & \text{if}\ i=0 \ i, & \text{if}\ j=0 \ r[i-1][j-1], & \text{if}\ X_i=Y_j \ \min(r[i-1][j], r[i][j-1], r[i-1][j-1]) + 1, & \text{if} \ X_i\neq Y_j \end{cases} $$

遞歸方案

老規矩,有了遞推公式,咱們先來寫個遞歸:

const minDistance = function(str1, str2) {
    const length1 = str1.length;
    const length2 = str2.length;

    if(!length1) {
        return length2;
    }

    if(!length2) {
        return length1;
    }

    const shortStr1 = str1.slice(0, -1);
    const shortStr2 = str2.slice(0, -1); 

    const isLastEqual = str1[length1-1] === str2[length2-1];

    if(isLastEqual) {
        return minDistance(shortStr1, shortStr2);
    } else {
        const shortStr1Cal = minDistance(shortStr1, str2);
        const shortStr2Cal = minDistance(str1, shortStr2);
        const updateCal = minDistance(shortStr1, shortStr2);

        const minShort = shortStr1Cal <= shortStr2Cal ? shortStr1Cal : shortStr2Cal;
        const minDis = minShort <= updateCal ? minShort : updateCal;

        return minDis + 1;
    }
}; 

//測試一下
let result = minDistance('horse', 'ros');
console.log(result);  // 3

result = minDistance('intention', 'execution');
console.log(result);  // 5

動態規劃

上面的遞歸方案提交到leetcode會直接超時,由於複雜度過高了,指數級的。仍是上咱們的動態規劃方案吧,跟前面相似,須要一個二維數組來存放每次執行的結果。

const minDistance = function(str1, str2) {
    const length1 = str1.length;
    const length2 = str2.length;

    if(!length1) {
        return length2;
    }

    if(!length2) {
        return length1;
    }

    // i 爲行,表示str1
    // j 爲列,表示str2
    const r = [];
    for(let i = 0; i < length1 + 1; i++) {
        r.push([]);
        for(let j = 0; j < length2 + 1; j++) {
            if(i === 0) {
                r[i][j] = j;
            } else if (j === 0) {
                r[i][j] = i;
            } else if(str1[i - 1] === str2[j - 1]){ // 注意下標,i,j包括空字符串,長度會大1
                r[i][j] = r[i - 1][j - 1];
            } else {
                r[i][j] = Math.min(r[i - 1][j ], r[i][j - 1], r[i - 1][j - 1]) + 1;
            }
        }
    }

    return r[length1][length2];
};

//測試一下
let result = minDistance('horse', 'ros');
console.log(result);  // 3

result = minDistance('intention', 'execution');
console.log(result);  // 5

上述代碼由於是雙重循環,因此時間複雜度是$O(n^2)$。

總結

動態規劃的關鍵點是要找出遞推式,有了這個遞推式咱們能夠用遞歸求解,也能夠用動態規劃。用遞歸時間複雜度一般是指數級增加,因此咱們有了動態規劃。動態規劃的關鍵點是從小往大算,將每個計算記過的值都記錄下來,這樣咱們計算大的值的時候直接就取到前面計算過的值了。動態規劃能夠大大下降時間複雜度,可是增長了一個存計算結果的數據結構,空間複雜度會增長。這也算是一種用空間換時間的策略了。

原文出處:https://www.cnblogs.com/dennisj/p/12298123.html

相關文章
相關標籤/搜索