前端也能學算法:由淺入深講解貪心算法

貪心算法是一種很常見的算法思想,並且很好理解,由於它符合人們通常的思惟習慣。下面咱們由淺入深的來說講貪心算法。javascript

找零問題

咱們先來看一個比較簡單的問題:前端

假設你是一個商店老闆,你須要給顧客找零n元錢,你手上有的錢的面值爲:100元,50元,20元,5元,1元。請問如何找零使得所須要的錢幣數量最少?

例子:你須要找零126元,則所需錢幣數量最少的方案爲100元1找,20元1張,5元1張,1元1張。java

這個問題在生活中很常見,買東西的時候常常會遇到,那咱們通常是怎麼思考的呢?假設咱們須要找零126元,咱們先看看能找的最大面值是多少,咱們發現126比100大,那確定能夠找一張100塊,而後剩下26元,再看26能匹配的最大面值是多少,發現是20,那找一張20的,還剩6塊,一樣的思路,找一張5塊的和1塊的。這其實就是貪心算法的思想,每次都很貪心的去找最大的匹配那個值,而後再找次大的。這個算法代碼也很好寫:git

const allMoney = [100, 50, 20, 5, 1];  // 表示咱們手上有的面值
function changeMoney(n, allMoney) {
  const length = allMoney.length;
  const result = [];    // 存儲結果的數組,每項表示對應面值的張數
  for(let i = 0; i < length; i++) {
    if(n >= allMoney[i]) {
      // 若是須要找的錢比面值大,那就能夠找,除一下看看能找幾張
      result[i] = parseInt(n / allMoney[i]);
      n = n - result[i] * allMoney[i];   // 更新剩下須要找的錢
    } else {
      // 不然不能找
      result[i] = 0;
    }
  }
  
  return result;
}

const result = changeMoney(126, allMoney);
console.log(result);   // [1, 0, 1, 1, 1]

貪心算法

上面的找零問題就是貪心算法,每次都去貪最大面值的,發現貪不了了,再去貪次大的。從概念上講,貪心算法是:github

image-20200220105715893

從上面的定義能夠看出,並非全部問題均可以用貪心算法來求解的,由於它每次拿到的只是局部最優解,局部最優解組合起來並不必定是全局最優解。下面咱們來看一個這樣的例子:算法

揹包問題

揹包問題也是一個很經典的算法問題,題目以下:segmentfault

有一個小偷,他進到了一個店裏要偷東西,店裏有不少東西,每一個東西的價值是v,每一個東西的重量是w。可是小偷只有一個揹包,他揹包總共能承受的重量是W。請問怎麼拿東西能讓他拿到的價值最大?

其實揹包問題細分下來又能夠分紅兩個問題:0-1揹包和分數揹包。數組

0-1揹包:指的是對於某個商品來講,你要麼不拿,要麼全拿走,不能只拿一半或者只拿三分之二。能夠將商品理解成金磚,你要麼整塊拿走,要麼不拿,不能拿半塊。

分數揹包:分數揹包就是跟0-1揹包相反的,你能夠只拿一部分,能夠拿一半,也能夠拿三分之二。能夠將商品理解成金砂,能夠只拿一部分。測試

下面來看個例子:spa

image-20200220110835213

這個問題用咱們平時的思惟也很好想,要拿到總價值最大,那咱們就貪唄,就拿最貴的,即價值除以重量的數最大的。可是每次都拿最貴的,是否是最後總價值最大呢?咱們先假設上面的例子是0-1揹包,最貴的是v1,而後是v2,v3。咱們先拿v1, 揹包還剩40,拿到總價值是60,而後拿v2,揹包還剩20,拿到總價值是160。而後就拿不下了,由於v3的重量是30,咱們揹包只剩20了,裝不下了。可是這個顯然不是全局最優解,由於咱們明顯能夠看出,若是咱們拿v2,v3,揹包恰好裝滿,總價值是220,這纔是最優解。因此0-1揹包問題不能用貪心算法。

可是分數揹包能夠用貪心,由於咱們老是能夠拿最貴的。咱們先拿了v1, v2,發現v3裝不下了,那就不裝完了嘛,裝三分之二就好了。下面咱們用貪心來實現一個分數揹包:

const products = [
  {id:1, v: 60, w: 10}, 
  {id:2, v: 100, w: 20}, 
  {id:3, v: 120, w: 30}
];    // 新建一個數組表示商品列表,每一個商品加個id用於標識

function backpack(W, products) {
  const sortedProducts = products.sort((product1, product2) => {
    const price1 = product1.v / product1.w;
    const price2 = product2.v / product2.w;
    if(price1 > price2) {
      return -1;
    } else if(price1 < price2) {
      return 1;
    }
    
    return 0;
  });  // 先對商品按照價值從大到小排序
  
  const result = []; // 新建數組接收結果
  let allValue = 0;  // 拿到的總價值
  const length = sortedProducts.length;
  
  for(let i = 0; i < length; i++) {
    const sortedProduct = sortedProducts[i];
    if(W >= sortedProduct.w) {
      // 整個拿完
      result.push({
        id: sortedProduct.id,
        take: 1,     // 拿的數量
      });
      W = W - sortedProduct.w;
      allValue = allValue + sortedProduct.v;
    } else if(W > 0) {
      // 只能拿一部分
      result.push({
        id: sortedProduct.id,
        take: W / sortedProduct.w,     
      });
      allValue = allValue + sortedProduct.v * (W / sortedProduct.w);
      W = 0; // 裝滿了
    } else {
      // 不能拿了
      result.push({
        id: sortedProduct.id,
        take: 0,     
      });
    }
  }
  
  return {result: result, allValue: allValue};
}

// 測試一下
const result = backpack(50, products);
console.log(result);

運行結果:

image-20200220113537290

0-1揹包

前面講過0-1揹包不能用貪心求解,咱們這裏仍是講講他怎麼來求解吧。要解這個問題須要用到動態規劃的思想,關於動態規劃的思想,能夠看看我這篇文章,若是你只想看看貪心算法,能夠跳過這一部分。假設咱們揹包放了n個商品,W是咱們揹包的總容量,咱們這時擁有的總價值是$D(n, W)$。咱們考慮最後一步,

假如咱們不放最後一個商品,則總價值爲$D(n-1, W)$

假設咱們放了最後一個商品,則總價值爲最後一個商品加上前面已經放了的價值,表示爲$v_n + D(n-1, W-w_n)$,這時候須要知足的條件是$ W >= w_n$,即最後一個要放得下。

咱們要求的最大解其實就是上述兩個方案的最大值,表示以下:

$$ D(n, W) = max(D(n-1, W), v_n + D(n-1, W-w_n)) $$

遞歸解法

有了遞推公式,咱們就能夠用遞歸解法了:

const products = [
  {id:1, v: 60, w: 10}, 
  {id:2, v: 100, w: 20}, 
    {id:3, v: 120, w: 30}
];    // 新建一個數組表示商品列表,每一個商品加個id用於標識

function backpack01(n, W, products) {
  if(n < 0 || W <= 0) {
    return 0;
  }
  
  const noLast = backpack01(n-1, W, products);  // 不放最後一個
  
  let getLast = 0;
  if(W >= products[n].w){  // 若是最後一個放得下
    getLast = products[n].v + backpack01(n-1, W-products[n].w, products);
  }
  
  const result = Math.max(noLast, getLast);
  
  return result;
}

// 測試一下
const result = backpack01(products.length-1, 50, products);
console.log(result);   // 220

動態規劃

遞歸的複雜度很高,咱們用動態規劃重寫一下:

const products = [
  {id:1, v: 60, w: 10}, 
  {id:2, v: 100, w: 20}, 
    {id:3, v: 120, w: 30}
];    // 新建一個數組表示商品列表,每一個商品加個id用於標識

function backpack01(W, products) {
  const d = [];      // 初始化一個數組放計算中間值,其實爲二維數組,後面填充裏面的數組
  const length = products.length;
  
  // i表示行,爲商品個數,數字爲 0 -- (length - 1)
  // j表示列,爲揹包容量,數字爲 0 -- W
  for(let i = 0; i < length; i++){
    d.push([]);
    for(let j = 0; j <= W; j++) {
      if(j === 0) {
        // 揹包容量爲0
        d[i][j] = 0;
      } else if(i === 0) {
        if(j >= products[i].w) {
          // 能夠放下第一個商品
          d[i][j] = products[i].v;
        } else {
          d[i][j] = 0;
        }
      } else {
        const noLast = d[i-1][j];
        
        let getLast = 0;
        if(j >= products[i].w) {
          getLast = products[i].v + d[i-1][j - products[i].w];
        }
        
        if(noLast > getLast) {
          d[i][j] = noLast;
        } else {
          d[i][j] = getLast;
        }
      }
    }
  }
  
  console.log(d);
  return d[length-1][W];
}

// 測試一下
const result = backpack01(50, products);
console.log(result);   // 220

回溯最優解

爲了可以輸出最優解,咱們須要將每一個最後放入的商品記錄下來,而後從最後往前回溯,將前面的代碼改造以下:

const products = [
  {id:1, v: 60, w: 10}, 
  {id:2, v: 100, w: 20}, 
    {id:3, v: 120, w: 30}
];    // 新建一個數組表示商品列表,每一個商品加個id用於標識

function backpack01(W, products) {
  const d = [];      // 初始化一個數組放計算中間值,其實爲二維數組,後面填充裏面的數組
  const res = [];    // 記錄每次放入的最後一個商品, 一樣爲二維數組
  const length = products.length;
  
  // i表示行,爲商品個數,數字爲 0 -- (length - 1)
  // j表示列,爲揹包容量,數字爲 0 -- W
  for(let i = 0; i < length; i++){
    d.push([]);
    res.push([]);
    for(let j = 0; j <= W; j++) {
      if(j === 0) {
        // 揹包容量爲0
        d[i][j] = 0;
        res[i][j] = null;  
      } else if(i === 0) {
        if(j >= products[i].w) {
          // 能夠放下第一個商品
          d[i][j] = products[i].v;
          res[i][j] = products[i];
        } else {
          d[i][j] = 0;
          res[i][j] = null;
        }
      } else {
        const noLast = d[i-1][j];
        
        let getLast = 0;
        if(j >= products[i].w) {
          getLast = products[i].v + d[i-1][j - products[i].w];
        }
        
        if(noLast > getLast) {
          d[i][j] = noLast;
        } else {
          d[i][j] = getLast;
          res[i][j] = products[i];   // 記錄最後一個商品
        }
      }
    }
  }
  
  // 回溯res, 獲得最優解
  let tempW = W;
  let tempI = length - 1;
  const bestSol = [];
  while (tempW > 0 && tempI >= 0) {
    const last = res[tempI][tempW];
    bestSol.push(last);
    tempW = tempW - last.w;
    tempI = tempI - 1;
  }
  
  console.log(d);
  console.log(bestSol);
  return {
    totalValue: d[length-1][W],
    solution: bestSol
  }
}

// 測試一下
const result = backpack01(50, products);
console.log(result);   // 220

上面代碼的輸出:

image-20200220144941561

數字拼接問題

再來看一個貪心算法的問題,加深下理解,這個問題以下:

image-20200220153438242

這個問題看起來也不難,咱們有時候也會遇到相似的問題,咱們能夠很直觀的想到一個解法:看哪一個數字的第一個數字大,把他排前面,好比32和94,把第一位是9的94放前面,獲得9432,確定比32放前面的3294大。這其實就是按照字符串大小來排序嘛,字符大的排前面,可是這種解法正確嗎?咱們再來看兩個數字,假如咱們有728和7286,按照字符序,7286排前面,獲得7286728,可是這個值沒有728放前面的7287286大。說明單純的字符序是搞不定這個的,對於兩個數字a,b,若是他們的長度同樣,那按照字符序就沒問題,若是他們長度不同,這個解法就不必定對了,那怎麼辦呢?其實也簡單,咱們看看a+b和b+a拼成的數字,哪一個大就好了。

假設
a = 728
b = 7286
字符串: a + b = "7287286"
字符串: b + a = "7286728"
比較下這兩個字符串, a + b比較大,a放前面就好了, 反之放到後面

上述算法就是一個貪心,這裏貪的是什麼的?貪的是a + b的值,要大的那個。在實現的時候,能夠本身寫個冒泡,也能夠直接用數組的sort方法:

const nums = [32, 94, 128, 1286, 6, 71];

function getBigNum(nums) {
  nums.sort((a, b) => {
    const ab = `${a}${b}`;
    const ba = `${b}${a}`;
    
    if(ab > ba) {
      return -1;   // ab大,a放前面
    } else if (ab < ba) {
      return 1;  
    }
    
    return 0;
  });
  
  return nums;
}

const res = getBigNum(nums);
console.log(res);    // [94, 71, 6, 32, 1286, 128]

活動選擇問題

活動選擇問題稍微難一點,也能夠用貪心,可是須要貪的東西沒前面的題目那麼直觀,咱們先來看看題目:

image-20200220155950342

這個問題應該這麼思考:爲了能儘可能多的安排活動,咱們在安排一個活動時,應該儘可能給後面的活動多留時間,這樣後面有機會能夠安排更多的活動。換句話說就是,應該把結束時間最先的活動安排在第一個,再剩下的時間裏面繼續安排結束時間早的活動。這裏的貪心其實貪的就是結束時間早的,這個結論其實能夠用數學來證實的:

image-20200220161538654

下面來實現下代碼:

const activities = [
  {start: 1, end: 4},
  {start: 3, end: 5},
  {start: 0, end: 6},
  {start: 5, end: 7},
  {start: 3, end: 9},
  {start: 5, end: 9},
  {start: 6, end: 10},
  {start: 8, end: 11},
  {start: 8, end: 12},
  {start: 2, end: 14},
  {start: 12, end: 16},
];

function chooseActivity(activities) {
  // 先按照結束時間從小到大排序
  activities.sort((act1, act2) => {
    if(act1.end < act2.end) {
      return -1;
    } else if(act1.end > act2.end) {
      return 1;
    }
    
    return 0;
  });
  
  const res = [];  // 接收結果的數組
  let lastEnd = 0; // 記錄最後一個活動的結束時間
  
  for(let i = 0; i < activities.length; i++){
    const act = activities[i];
    if(act.start >= lastEnd) {
      res.push(act);
      lastEnd = act.end
    }
  }
  
  return res;
}

// 測試一下
const result = chooseActivity(activities);
console.log(result);

上面代碼的運行結果以下:

image-20200220163750591

總結

貪心算法的重點就在一個貪字,要找到貪的對象,而後不斷的貪,最後把目標貪完,輸出最優解。要注意的是,每次貪的時候其實拿到的都只是局部最優解,局部最優解不必定組成全局最優解,好比0-1揹包,對於這種問題是不能用貪心的,要用其餘方法求解。

文章的最後,感謝你花費寶貴的時間閱讀本文,若是本文給了你一點點幫助或者啓發,請不要吝嗇你的贊和GitHub小星星,你的支持是做者持續創做的動力。

歡迎關注個人公衆號進擊的大前端第一時間獲取高質量原創~

「前端進階知識」系列文章源碼地址: https://github.com/dennis-jiang/Front-End-Knowledges

1270_300二維碼_2.png

相關文章
相關標籤/搜索