Greedy Algorithm
贪心算法在每一步的选择中都会采取在当前状态下的最优决策,并希望由此产生的最终结果也是全局最优。
决策:局部最优(以希望能全局最优)
与一般的搜索(也称暴力搜索/蛮力搜索/枚举回溯)和动态规划相比,贪心算法的不同之处在于:它不对整个状态空间进行遍历或计算,而是始终按照局部最优的选择执行下去,不再回头。
搜索和动态规划会遍历整个状态空间。搜索有回溯,是蛮力遍历;动态规划是把状态分阶段+按顺序,然后选取代表+提炼最优子结构,从而更高效地处理所有状态。它两都是基于全局考量的,所以求解一定是对的。
正因为这个特性(不遍历整个状态空间),贪心算法不一定能得到正确的结果,除非可以证明。即证明:按照特定方法做出的局部最优选择,依然可以得到全局最优结果。
贪心,一定要证明。
举个例子,零钱兑换。
题目:给定一个整数数组 coins(表示不同面额的硬币)和一个整数 amount(表示总金额),计算并返回可以凑成总金额的最少硬币个数。如果没有任何一种硬币组合能组成总金额,就返回 -1。假设每种硬币的数量是无限的。
我们平时兑换零钱,是会尽量选择面额大的(因为这样可以更快地兑完)直到它不能兑换了为止,然后剩余的再用小面额的凑齐(策略一致)。
“每次都选尽量大的面值”就是一个贪心思想。
那我们思考,它对吗?不一定对。
比如用 [10,9,1]
兑换 18(状态图如下),如果用贪心策略兑换,就会走图中的红色路径,即需要 9 个硬币(1 个 10 和 8 个 1),而最优解是绿色路径,即需要 2 个硬币(2 个 9)。
如果是暴力搜索,它会遍历所有状态
[剩余金额, 已用硬币数]
,然后取最小的硬币数。虽然搜索也会考虑贪心选的那条路径,但它会遍历所有边所有点。
贪心算法不一定能得到正确的解,在大部分题目上不能随便使用。如果要使用贪心算法,就必须先证明它的正确性。
除了数学归纳法和反证法之外,还有三种常用的证明方法。
决策包容性是从“状态空间”这一本质出发的一个证明方法。包容,即包含,指子集包含。子集,是可达边界构成的子集。
贪心算法实际上是在状态空间中按照局部最优策略找了一条路径。如果我们能证明:从一个点出发,它还能到达最优解,即并不会丢失最优解的可达性。那么,这样的贪心就是对的。
比如用 [5,10,20]
来兑换零钱,贪心算法是成立的,因为它们的面值互成倍数,此时能用 1 张 10 块兑换的必然也能用 2 张 5 块的来兑换。在这种情况下,“每次都选尽量大的面值”的局部最优策略是有包容性的。
再来看两个例子。
题目1:柠檬水找零。每杯柠檬水售价为 5,给定一个整数数组 bills,bills[i] 里存着第 i 位顾客付的账,值只会是 5、10、20,判断能否给每位顾客正确找零。注意:一开始手头并没有任何零钱。
代码如下:
var lemonadeChange = function(bills) {
const moneys = {
5: 0,
10: 0,
20: 0
};
// 找零:用贪心策略 “在可选的面额里取最大的”
const exchange = function(amount) {
for (let x of [20, 10, 5]) {
// 用循环减法替代除法和%
while (amount && x <= amount && moneys[x] > 0) {
amount -= x;
moneys[x]--;
}
}
return amount === 0;
};
// 遍历账单
for (let x of bills) {
if (!exchange(x - 5)) return false;
moneys[x]++;
}
return true;
};
题目2:分发饼干。给定一个孩子的胃口数组 g 和一个饼干的尺寸数组 s,求能满足最多孩子的个数。孩子 i 的胃口值 g[i],饼干 j 的尺寸值 s[j],如果 s[j] >= g[i],那就可以将这个饼干 j 分配给孩子 i ,此时孩子 i 会得到满足。
代码如下:
var findContentChildren = function(g, s) {
// 原地排序:小-大
g.sort((a, b) => a - b);
s.sort((a, b) => a - b);
let ans = 0;
let j = 0, sn = s.length;
// 贪心策略:对于孩子 child,选择一个能满足其胃口的“最小”饼干
for (let child of g) {
while (j < sn && child > s[j]) j++;
if (j >= sn) break;
ans++;
j++;
}
return ans;
};
以上两个例子,能都用决策包容性来证明贪心理论的正确性,所以可以用贪心算法求解。
决策包容性,只看一条边。
在思考贪心算法时,有时候不容易直接证明局部最优决策的正确性,此时可以往后多扩展一步,进而对当前的决策进行验证。扩展决策范围,即往后多看一步。
决策扩展范围,是看两条边。
来看两个例子。
题目1:跳跃游戏。给定一个非负整数数组 nums,其中 nums[i] 表示在该位置可以跳跃的最大长度。起初是位于数组的第一个位置,求跳到数组最后一个位置的最少跳跃次数。
代码如下:
var jump = function(nums) {
// 目的地:最后一个位置的下标
let target = nums.length - 1;
let ans = 0;
let cur = 0;
while (cur < target) {
let jumping = cur + nums[cur];
// 如果本位置跳一下就能到达目的地,则直接返回+1
if (jumping >= target) return ans+1;
// 否则,判断下一步跳哪里
// 贪心策略:在当前位置的可跳范围内,选一个能跳“最远”的那个位置
let farest = -1;
let next = -1; // 下个位置
for (let j = cur + 1; j <= jumping; j++) {
if (j + nums[j] > farest) {
farest = j + nums[j];
next = j;
}
}
// 卡这里了,走不动了,也到不了目的地了
if (next === -1) return -1;
// 否则,跳至下个位置,步数+1
cur = next;
ans++;
}
return ans;
};
题目2:买卖股票的最佳时机。给定一个数组 prices,其中 prices[i] 表示股票第 i 天的价格,求能获得的最大利润。说明:任何时候最多只能持有一股股票,在每一天中可以购买或出售,不限交易次数。
代码如下:
var maxProfit = function(prices) {
let ans = 0;
for (let i = 0; i < prices.length - 1; i++){
// 贪心策略:如果明天涨,那就买入(以此获得所有上涨区间的全部收益)呃~预言家式炒股
ans += Math.max(prices[i + 1] - prices[i], 0);
}
return ans;
};
以上两个例子,都能用“扩展决策范围+决策包容性”来证明贪心理论的正确性,所以可以用贪心算法求解。
多用于求顺序的题目,即按照某个顺序来完成一个任务。贪心理论认为按照某个顺序排列是比较优的,此时要证明它,就可以用邻项交换法。
证明:无论什么情况下,只要沿着逆序方向走,就会让答案变差。
这样就能证明按有序的方向走,必然能得到最优解。贪心算法的实现就是碰到逆序的就给它局部邻项交换下,此时得到的序列就是最优序列。
至于“逆序/有序”的定义,就得看具体场景了。举个例子。
题目1:完成所有任务的最少初始能量。给定一个任务数组 tasks,tasks[i] = [actuali, minimumi],其中,第一个元素表示完成第 i 个任务需要耗费的实际能量,第二个元素表示开始第 i 个任务前需要达到的最低能量。求完成所有任务的最少初始能量。
代码如下:
var minimumEffort = function(tasks) {
// 贪心策略:按元素的 actual - minimum 升序排列,以此顺序完成任务
tasks.sort((a, b) => a[0] - a[1] - (b[0] - b[1]));
// 倒着计算初始能量
let ans = 0;
for (let i = tasks.length - 1; i >= 0; i--) {
ans = Math.max(ans + tasks[i][0], tasks[i][1]);
}
return ans;
};
贪心算法在每一步的决策中都采取局部最优策略,并希望由此导致的最终结果也是全局最优的。它不会遍历整个状态空间,因此贪心算法不一定能得到正确的结果,除非可以证明。
如果要使用贪心算法,就必须先证明它的正确性。除去熟知的数学归纳法和反证法之外,还有三种重要的证明方法,分别是决策包容性、扩展决策范围和邻项交换法。它们也可以结合使用,以证明做什么决策是最好的。
搜索、动态规划和贪心是一脉相承的,都基于状态。不同之处在于搜索和动态规划会遍历整个状态空间,而贪心不会。正因为如此,贪心算法得到的结果不一定正确(除非可以证明),而基于全局状态的算法求出的最优解一定是正确的。
用贪心能做的题目,一般也都可以用搜索或者动态规划来求解,只是贪心一般是最高效的。所以,遇到问题,先想想搜索和动态规划,先对整体的状态空间有个了解。如果时间复杂度太高或是运行太慢,再考虑贪心(此时,也可能会更容易一些)。搜索→动态规划→贪心,是一个比较好的思路。
还是要格外强调:任何时候做题不要先想贪心。因为在不考虑时间复杂度的情况下,用搜索和动态规划总是可以求解的。同时,贪心一定要证明。
Copyright© 2013-2020
All Rights Reserved 京ICP备2023019179号-8