贪心算法是一种很常见的算法思想,并且很好理解,由于它符合人们通常的思惟习惯。下面咱们由浅入深的来说讲贪心算法。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
从上面的定义能够看出,并非全部问题均可以用贪心算法来求解的,由于它每次拿到的只是局部最优解,局部最优解组合起来并不必定是全局最优解。下面咱们来看一个这样的例子:算法
背包问题也是一个很经典的算法问题,题目以下:segmentfault
有一个小偷,他进到了一个店里要偷东西,店里有不少东西,每一个东西的价值是v,每一个东西的重量是w。可是小偷只有一个背包,他背包总共能承受的重量是W。请问怎么拿东西能让他拿到的价值最大?
其实背包问题细分下来又能够分红两个问题:0-1背包和分数背包。数组
0-1背包:指的是对于某个商品来讲,你要么不拿,要么全拿走,不能只拿一半或者只拿三分之二。能够将商品理解成金砖,你要么整块拿走,要么不拿,不能拿半块。分数背包:分数背包就是跟0-1背包相反的,你能够只拿一部分,能够拿一半,也能够拿三分之二。能够将商品理解成金砂,能够只拿一部分。测试
下面来看个例子:spa
这个问题用咱们平时的思惟也很好想,要拿到总价值最大,那咱们就贪呗,就拿最贵的,即价值除以重量的数最大的。可是每次都拿最贵的,是否是最后总价值最大呢?咱们先假设上面的例子是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);
运行结果:
前面讲过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
上面代码的输出:
再来看一个贪心算法的问题,加深下理解,这个问题以下:
这个问题看起来也不难,咱们有时候也会遇到相似的问题,咱们能够很直观的想到一个解法:看哪一个数字的第一个数字大,把他排前面,好比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]
活动选择问题稍微难一点,也能够用贪心,可是须要贪的东西没前面的题目那么直观,咱们先来看看题目:
这个问题应该这么思考:为了能尽可能多的安排活动,咱们在安排一个活动时,应该尽可能给后面的活动多留时间,这样后面有机会能够安排更多的活动。换句话说就是,应该把结束时间最先的活动安排在第一个,再剩下的时间里面继续安排结束时间早的活动。这里的贪心其实贪的就是结束时间早的,这个结论其实能够用数学来证实的:
下面来实现下代码:
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);
上面代码的运行结果以下:
贪心算法的重点就在一个贪字,要找到贪的对象,而后不断的贪,最后把目标贪完,输出最优解。要注意的是,每次贪的时候其实拿到的都只是局部最优解,局部最优解不必定组成全局最优解,好比0-1背包,对于这种问题是不能用贪心的,要用其余方法求解。
文章的最后,感谢你花费宝贵的时间阅读本文,若是本文给了你一点点帮助或者启发,请不要吝啬你的赞和GitHub小星星,你的支持是做者持续创做的动力。
欢迎关注个人公众号进击的大前端第一时间获取高质量原创~
“前端进阶知识”系列文章源码地址: https://github.com/dennis-jiang/Front-End-Knowledges