算法導論知識梳理(四):高級設計和分析技術

本章主要介紹了動態規劃和貪心算法,也是平時較爲常見的高級設計算法,同時也提供了攤還分析的連接,我的認爲相對前兩個來講也是比較好理解了,因此就直接貼了個連接,內容和書本上也是同樣的。算法

動態規劃

動態規劃與分治算類似,都是經過組合子問題的解來求解原問題。分治法將問題劃分爲互不相交的子問題,遞歸地求解子問題,再將它們的解組合起來,求出原問題的解。與之相反,動態規劃應用於子問題重疊的狀況,即不一樣的子問題具備公共的子子問題。在這種狀況下,分治法會作許多沒必要要的工做,它會反覆地求解那些公共子子問題。而動態規劃算法對每一個子子問題只求解一次,將其保存在一個表格中,從而無須每次求解一個子子問題都從新計算,避免了這種沒必要要的計算工做。數組

動態規劃方法一般用來求解最優化問題。一般會經過以下4個步驟設計一個動態規劃算法:安全

  1. 刻畫一個最優解的結構特性
  2. 遞歸地定義最優解的值
  3. 計算最優解的值,一般採用自底向上的方法
  4. 利用計算出的信息構造一個最優解

就光是上述內容看下來,估計你們都是一臉懵逼的,下面就將拿書上的鋼條切割問題爲例進行詳細說明,後面的分析也會圍繞這個例子展開: 給定一段長度爲n英寸的鋼條和一個價格表pi(i=1,2,...,n),將其切割爲短鋼條出售,求切割鋼條方案,使得銷售收益rn最大(不考慮切割工序成本,鋼條的長度均爲整英寸)。下面給出的是一個價格表的樣例:數據結構

長度i 1 2 3 4 5 6 7 8 9 10
價格pi 1 5 8 9 10 17 17 20 24 30

樸素遞歸算法

解決這個問題,咱們能夠很容易地想到使用以前的分治法自頂向下遞歸求解,分解問題爲長度爲i的部分不切割,剩餘部分切割(子問題)。那麼其代碼就應該是這樣的(爲了簡單起見,求解過程只求出了最大收益值,不返回具體切割方案):性能

const priceArr = [0,1,5,8,9,10,17,17,20,24,30];
function CutRod(priceArr, n) {
  if(n === 0) return priceArr[0];
  // 初始化最大收益
  let profit = 0;
  for(let i = 1; i <= n; i++) {
    // 概括最大收益
    profit = Math.max(profit, priceArr[i] + CutRod(priceArr, n - i));
  }
  return profit;
}
console.log(CutRod(priceArr, 10)); // 30
複製代碼

可是,其實這段代碼問題很大,仔細分析一下這段代碼,不難發現,它對反覆地求解相同的子問題。上圖說話,當n=4時,求解過程優化

咱們能夠明顯地看到,這一過程其實一直在求解重複的子問題,而且,隨着n的增大,運行時間會爆炸式地增加。ui

動態規劃方案

經過上述分析,能夠知道,樸素遞歸算反之因此效率低,是由於它反覆求解相同的子問題。所以,動態規劃方法仔細安排求解順序,對每一個子問題只求解一次,並將結果保存下來。若是隨後再次須要此子問題的解,只需查找保存的結構,而沒必要從新計算。這種解決方案,是典型的時空權衡的列子。編碼

動態規劃有兩種等價的實現方法:帶備忘的自定向下法和自底向上法。下面就分別看下具體實現:spa

  1. 帶備忘的自定向下法
function MemoizedCutRod(priceArr, n) {
  // 經過arr將最大收益結果保存起來,用-1表明無值狀態
  const arr = new Array(n).fill(-1);
  MemoizedCutRodAux(priceArr, n, arr);
  return arr;
}
function MemoizedCutRodAux(priceArr, n, arr) {
  // 當有值時,直接返回對應的值便可
  if(arr[n] >= 0) return arr[n];
  let profit = 0;
  if(n === 0) profit = priceArr[0];
  else {
    for(let i = 1; i <= n; i++) {
      profit = Math.max(profit, priceArr[i] + MemoizedCutRodAux(priceArr, n - i, arr))
    }
  }
  arr[n] = profit;
  return profit;
}
複製代碼

總結思路和樸素遞歸算法是同樣的,只是多了一個用來保存n在不一樣值時的最大收益的數組.net

  1. 自底向上法
function BottomUpCutRod(priceArr, n) {
  const arr = new Array(n).fill(0);
  let profit = 0;
  for(let i = 1; i <= n; i++) {
    profit = 0;
    for(let j = 1; j <= i; j++) {
      // 直接去arr中去最大收益結果,由於是自低向上的過程,因此arr[i-j]一定有值
      profit = Math.max(profit, priceArr[j] + arr[i - j]);
    }
    arr[i] = profit;
  }
  return arr;
}
複製代碼

自底向上法其實更爲簡單,感受也沒啥好說的==。

那麼,什麼時候採用動態規劃法呢?適合應用動態規劃法求解最優化問題應具有兩個要素:最優子結構和子問題重疊。

  1. 若是一個問題的最優解包含其子問題的最優解,那麼就稱此問題具備最優子結構性質(這種性質也意味着能夠採用貪心策略)。
  2. 若是遞歸算法反覆求解相同的子問題,那麼就稱最優化問題具備重疊子問題性質。

另外一個典型的經過動態規劃法解決的問題就是求解最長公共子序列問題,考慮到篇(lan)幅(de)原(xie)因,就直接引用一下別人不錯的文章,點我跳轉

貪心算法

在求解最優化問題時,貪心算法更爲簡單高效。它在每一步都作出當時看起來最佳的選擇。即它老是作出局部最優的選擇,寄但願這樣的選擇能致使全局最優解。

其求解過程可分爲如下步驟:

  1. 肯定問題的最優子結構
  2. 設計一個遞歸算法
  3. 證實若是咱們作出一個貪心選擇,則只剩下一個子問題
  4. 證實貪心選擇老是安全的
  5. 設計一個遞歸算法實現貪心策略
  6. 將遞歸算法轉換爲迭代算法

一個典型的問題就是活動選擇問題。假設有n個活動的集合S={a1,a2,...,an},這些活動使用同一個資源(例如同一個階梯教室),而這個資源在某個時刻只能供一個活動使用,每一個活動都有一個開始時間si和結束時間fi,其中0<=si<=fi,時間段爲半開區間[si,fi)。此問題中,咱們但願選出一個最大兼容活動集,即活動個數達到最大。

例如,以下活動集合S:

先寫出其遞歸式,c[i,j]表示集合S[i,j]的最優解的大小,若活動ak在集合c[i,j]的最優解中,遞歸式就以下:

那麼咱們應該採起何種貪心策略?通常的能夠有如下兩種方式:

  1. 優先選取持續時間最短的活動
  2. 優先選擇最先結束的活動

策略一的一個選取方案爲i={2,4,8,11}; 策略二的一個選取方案爲i={1,4,8,11}。 代碼就不寫了,由於相對來講是比較簡單的。

霍夫曼編碼

另外一個典型的貪心算法場景就是霍夫曼編碼:根據每一個字符的出現頻率,對不一樣的字母採用變長編碼(賦予高頻字短編碼,賦予低頻字長編碼)進行編碼。這一過程採用的就是貪心策略。通常地,咱們只考慮前綴碼(沒有任何碼字是其餘碼字的前綴)。例如某個文件中值出現了6個不一樣字符a-f,每一個字符的出現頻率爲:

a b c d e f
頻率(千次) 45 13 12 16 9 5
定長編碼 000 001 010 011 100 101
變長編碼 0 101 100 111 1101 1100

那麼變長編碼是如何被構建出來的?答案是經過構建霍夫曼樹,將低頻率的項兩兩合併,從根節點出發,到某個節點的路徑,即爲變長編碼。以下圖:

優缺點

優勢:簡單,高效,其通常會被用作輔助算法。

缺點:只考慮到了局部最優解,沒有回溯處理。但在大多數狀況下,局部最優並不表示全局最優(例如0-1揹包問題)。

攤還分析

在攤還分析中,咱們求數據結構的一個操做序列中所執行的全部操做的平均時間,來評價操做的代價。攤還分析不一樣於平均狀況分析,它並不涉及機率,它能夠保證最壞狀況下每一個操做的平均性能。

能夠看出,攤還分析並非一種算法,而是就是用來評價一系列操做的平均代價,經常使用的技術有三種:聚合分析、覈算法、勢能法。這裏就貼個連接,有興趣的能夠了解一下,這個文章的內容和書上基本上都是同樣的。點我跳轉

總結一下,動態規劃和貪心算法這兩種設計更多的是須要理解其思想,而後就是嘗試用這些思想解決一些實際問題,才能真正理解,畢竟實踐出真知。

相關文章
相關標籤/搜索