贪心算法
1. 理论基础
贪心的本质就是选择每一阶段的局部最优,从而达到全局最优。
使用贪心最好的策略就是举反例,如果想不到反例,就试一试贪心。
刷题或者面试的时候,手动模拟一下感觉可以局部最优推出整体最优,而且想不到反例,那么就试一试贪心。
贪心的解题步骤:
- 将问题分解为若干个子问题
- 找出适合的贪心策略
- 求解每一个子问题的最优解
- 将局部最优解堆叠成全局最优解
2. 分发饼干
题目:假设你是一位很棒的家长,想要给你的孩子们一些小饼干。但是,每个孩子最多只能给一块饼干。对每个孩子 i,都有一个胃口值 g[i],这是能让孩子们满足胃口的饼干的最小尺寸;并且每块饼干 j,都有一个尺寸 s[j] 。如果 s[j] >= g[i],我们可以将这个饼干 j 分配给孩子 i ,这个孩子会得到满足。你的目标是尽可能满足越多数量的孩子,并输出这个最大数值。
题目分析:这里的局部最优就是大饼干喂给胃口大的,充分利用饼干尺寸喂饱一个,全局最优就是喂饱尽可能多的小孩。可以尝试使用贪心策略,先将饼干数组和小孩数组排序,然后从后向前遍历小孩数组,用大饼干优先满足胃口大的,并统计满足小孩数量。
想清楚局部最优,想清楚全局最优,感觉局部最优是可以推出全局最优,并想不出反例,那么就试一试贪心。
class Solution {
public int findContentChildren(int[] g, int[] s) {
Arrays.sort(g);
Arrays.sort(s);
int start = 0;
int count = 0;
for (int i = 0; i < s.length && start < g.length; i++) {
if (s[i] >= g[start]) {
start++;
count++;
}
}
return count;
}
}
class Solution {
public int findContentChildren(int[] g, int[] s) {
Arrays.sort(g);
Arrays.sort(s);
int count = 0;
int start = s.length - 1;
for (int index = g.length - 1; index >= 0; index--) {
if(start >= 0 && g[index] <= s[start]) {
start--;
count++;
}
}
return count;
}
}
3. 摆动序列
题目:如果连续数字之间的差严格地在正数和负数之间交替,则数字序列称为摆动序列。第一个差(如果存在的话)可能是正数或负数。少于两个元素的序列也是摆动序列。给定一个整数序列,返回作为摆动序列的最长子序列的长度。 通过从原始序列中删除一些(也可以不删除)元素来获得子序列,剩下的元素保持其原始顺序。
思路一
贪心解法
局部最优:删除单调坡度上的节点(不包括单调坡度两端的节点),那么这个坡度就可以有两个局部峰值。
整体最优:整个序列有最多的局部峰值,从而达到最长摆动序列。
实际操作上,其实连删除的操作都不用做,因为题目要求的是最长摆动子序列的长度,所以只需要统计数组的峰值数量就可以了(相当于是删除单一坡度上的节点,然后统计长度)。这就是贪心所贪的地方,让峰值尽可能的保持峰值,然后删除单一坡度上的节点。
在代码实现还有一些技巧,例如在统计峰值的时候,数值最左边和最右边是不好统计的。例如序列[2,5],它的峰值数量是2,如果靠统计差值来计算峰值个数就需要考虑数组最左面和最右面的特殊情况。所以可以针对序列[2,5],可以假设为[2,2,5],这样它就有坡度了即preDiff = 0。
class Solution {
public int wiggleMaxLength(int[] nums) {
if (nums.length <= 1) {
return nums.length;
}
int curDiff = 0;
int preDiff = 0;
int count = 1;
for (int i = 1; i < nums.length; i++) {
curDiff = nums[i] - nums[i - 1];
if ((curDiff > 0 && preDiff <= 0) || (curDiff < 0 && preDiff >= 0)) {
count++;
preDiff = curDiff;
}
}
return count;
}
}
动态规划
对于当前这个数,要么是作为山峰(即nums[i] > nums[i-1]),要么是作为山谷(即nums[i] < nums[i - 1])。
- 设dp状态
dp[i][0] ,表示考虑前i个数,第i个数作为山峰的摆动子序列的最长长度 - 设dp状态
dp[i][1] ,表示考虑前i个数,第i个数作为山谷的摆动子序列的最长长度
则转移方程为:
dp[i][0] = max(dp[i][0], dp[j][1] + 1) ,其中0 < j < i 且nums[j] < nums[i] ,表示将nums[i]接到前面某个山谷后面,作为山峰。dp[i][1] = max(dp[i][1], dp[j][0] + 1) ,其中0 < j < i 且nums[j] > nums[i] ,表示将nums[i]接到前面某个山峰后面,作为山谷。
初始状态:由于一个数可以接到前面的某个数后面,也可以以自身为子序列的起点,所以初始状态为:dp[0][0] = dp[0][1] = 1 。
class Solution {
public int wiggleMaxLength(int[] nums) {
int dp[][] = new int[nums.length][2];
dp[0][0] = dp[0][1] = 1;
for (int i = 1; i < nums.length; i++){
dp[i][0] = dp[i][1] = 1;
for (int j = 0; j < i; j++){
if (nums[j] > nums[i]){
dp[i][1] = Math.max(dp[i][1], dp[j][0] + 1);
}
if (nums[j] < nums[i]){
dp[i][0] = Math.max(dp[i][0], dp[j][1] + 1);
}
}
}
return Math.max(dp[nums.length - 1][0], dp[nums.length - 1][1]);
}
}
4. 最大子序和
题目:给定一个整数数组 nums ,找到一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。
题目分析:暴力解法也可以实现,但是就没有意思了。
贪心算法:
-
局部最优:当前“连续和”为负数的时候立刻放弃,从下一个元素重新计算“连续和”,因为负数加上下一个元素 “连续和”只会越来越小。全局最优:选取最大“连续和”。从代码角度上来讲:遍历nums,从头开始用count累积,如果count一旦加上nums[i]变为负数,那么就应该从nums[i+1]开始从0累积count了,因为已经变为负数的count,只会拖累总和。 -
区间的终止位置,其实就是如果count取到最大值了,及时记录下来了。相当于是用result记录最大子序和区间和(变相的算是调整了终止位置)。
class Solution {
public int maxSubArray(int[] nums) {
if (nums.length == 1){
return nums[0];
}
int sum = Integer.MIN_VALUE;
int count = 0;
for (int i = 0; i < nums.length; i++){
count += nums[i];
sum = Math.max(sum, count);
if (count <= 0){
count = 0;
}
}
return sum;
}
}
class Solution {
public int maxSubArray(int[] nums) {
int ans = Integer.MIN_VALUE;
int[] dp = new int[nums.length];
dp[0] = nums[0];
ans = dp[0];
for (int i = 1; i < nums.length; i++){
dp[i] = Math.max(dp[i-1] + nums[i], nums[i]);
ans = Math.max(dp[i], ans);
}
return ans;
}
}
5. 买卖股票的最佳时机II
题目:给定一个数组,它的第 i 个元素是一支给定股票第 i 天的价格。设计一个算法来计算你所能获取的最大利润。你可以尽可能地完成更多的交易(多次买卖一支股票)。注意:你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。
题目分析:要想到最终利润是可以分解的,就很容易了,把利润分解为每天为单位的维度。只需要收集正利润的区间,只需要关注最终利润,而不需要记录区间。局部最优:收集每天的正利润,全局最优:求得最大利润。
class Solution {
public int maxProfit(int[] prices) {
int result = 0;
for (int i = 1; i < prices.length; i++) {
result += Math.max(prices[i] - prices[i - 1], 0);
}
return result;
}
}
6. 跳跃游戏
题目:给定一个非负整数数组,你最初位于数组的第一个位置。数组中的每个元素代表你在该位置可以跳跃的最大长度。判断你是否能够到达最后一个位置。
题目分析:跳几步无所谓,关键在于可跳的覆盖范围,问题就转化为了跳跃覆盖范围能不能覆盖到终点。每次移动取最大跳跃步数(得到最大的覆盖范围),每移动一个单位,就更新最大覆盖范围。贪心算法局部最优解:每次取最大跳跃步数(取最大覆盖范围),整体最优解:最后得到整体最大覆盖范围,看是否能到终点。
class Solution {
public boolean canJump(int[] nums) {
if (nums.length == 1) {
return true;
}
int coverRange = 0;
for (int i = 0; i <= coverRange; i++) {
coverRange = Math.max(coverRange, i + nums[i]);
if (coverRange >= nums.length - 1) {
return true;
}
}
return false;
}
}
7. 跳跃游戏II
题目:给定一个非负整数数组,你最初位于数组的第一个位置。数组中的每个元素代表你在该位置可以跳跃的最大长度。你的目标是使用最少的跳跃次数到达数组的最后一个位置。说明: 假设你总是可以到达数组的最后一个位置。
题目分析:贪心的思路,局部最优:当前可移动距离尽可能多走,如果还没到终点,步数再加一。整体最优:一步尽可能多走,从而达到最小步数。
要从覆盖范围出发,不管怎么跳,覆盖范围内一定是可以跳到的,以最小的步数增加覆盖范围,覆盖范围一旦覆盖了终点,得到的就是最小步数!这里需要统计两个覆盖范围,当前这一步的最大覆盖和下一步最大覆盖。
移动下标只要遇到当前覆盖最远距离的下标,直接步数加一,不考虑是不是终点的情况。想要达到这样的效果,只要让移动下标,最大只能移动到nums.size - 2的地方就可以了。因为:
- 如果移动下标等于当前覆盖最大距离下标, 需要再走一步(即ans++),因为最后一步一定是可以到的终点。
- 如果移动下标不等于当前覆盖最大距离下标,说明当前覆盖最远距离就可以直接达到终点了,不需要再走一步。
class Solution {
public int jump(int[] nums) {
int result = 0;
int end = 0;
int temp = 0;
for (int i = 0; i <= end && end < nums.length - 1; ++i) {
temp = Math.max(temp, i + nums[i]);
if (i == end) {
end = temp;
result++;
}
}
return result;
}
}
8. K次取反后最大化的数组和
题目:给定一个整数数组 A,我们只能用以下方法修改该数组:我们选择某个索引 i 并将 A[i] 替换为 -A[i],然后总共重复这个过程 K 次。(我们可以多次选择同一个索引 i。)以这种方式修改数组后,返回数组可能的最大和。
解题思路:
- 第一步:将数组按照绝对值大小从大到小排序,注意要按照绝对值的大小
- 第二步:从前向后遍历,遇到负数将其变为正数,同时K–
- 第三步:如果K还大于0,那么反复转变数值最小的元素,将K用完
- 第四步:求和
class Solution {
public int largestSumAfterKNegations(int[] A, int K) {
if (A.length == 1) return k % 2 == 0 ? A[0] : -A[0];
Arrays.sort(A);
int sum = 0;
int idx = 0;
for (int i = 0; i < K; i++) {
if (i < A.length - 1 && A[idx] < 0) {
A[idx] = -A[idx];
if (A[idx] >= Math.abs(A[idx + 1])) idx++;
continue;
}
A[idx] = -A[idx];
}
for (int i = 0; i < A.length; i++) {
sum += A[i];
}
return sum;
}
}
class Solution {
public int largestSumAfterKNegations(int[] nums, int K) {
nums = IntStream.of(nums)
.boxed()
.sorted((o1, o2) -> Math.abs(o2) - Math.abs(o1))
.mapToInt(Integer::intValue).toArray();
int len = nums.length;
for (int i = 0; i < len; i++) {
if (nums[i] < 0 && K > 0) {
nums[i] = -nums[i];
K--;
}
}
if (K % 2 == 1) nums[len - 1] = -nums[len - 1];
return Arrays.stream(nums).sum();
}
}
9. 加油站
题目:在一条环路上有 N 个加油站,其中第 i 个加油站有汽油 gas[i] 升。你有一辆油箱容量无限的的汽车,从第 i 个加油站开往第 i+1 个加油站需要消耗汽油 cost[i] 升。你从其中的一个加油站出发,开始时油箱为空。如果你可以绕环路行驶一周,则返回出发时加油站的编号,否则返回 -1。
说明:
- 如果题目有解,该答案即为唯一答案。
- 输入数组均为非空数组,且长度相同。
- 输入数组中的元素均为非负数。
题目分析:
暴力方法遍历每一个加油站为起点的情况,模拟一圈。for循环适合模拟从头到尾的遍历,而while循环适合模拟环形遍历,要善于使用while!
贪心算法:
- 情况一:如果gas的总和小于cost总和,那么无论从哪里出发,一定是跑不了一圈的
- 情况二:rest[i] = gas[i]-cost[i]为一天剩下的油,i从0开始计算累加到最后一站,如果累加没有出现负数,说明从0出发,油就没有断过,那么0就是起点。
- 情况三:如果累加的最小值是负数,汽车就要从非0节点出发,从后向前,看哪个节点能这个负数填平,能把这个负数填平的节点就是出发节点。
class Solution {
public int canCompleteCircuit(int[] gas, int[] cost) {
int sum = 0;
int min = 0;
for (int i = 0; i < gas.length; i++) {
sum += (gas[i] - cost[i]);
min = Math.min(sum, min);
}
if (sum < 0) return -1;
if (min >= 0) return 0;
for (int i = gas.length - 1; i > 0; i--) {
min += (gas[i] - cost[i]);
if (min >= 0) return i;
}
return -1;
}
}
可以换一个思路,首先如果总油量减去总消耗大于等于零那么一定可以跑完一圈,说明 各个站点的加油站 剩油量rest[i]相加一定是大于等于零的。每个加油站的剩余量rest[i]为gas[i] - cost[i]。i从0开始累加rest[i],和记为curSum,一旦curSum小于零,说明[0, i]区间都不能作为起始位置,起始位置从i+1算起,再从0计算curSum。那么局部最优:当前累加rest[j]的和curSum一旦小于0,起始位置至少要是j+1,因为从j开始一定不行。全局最优:找到可以跑一圈的起始位置。
class Solution {
public int canCompleteCircuit(int[] gas, int[] cost) {
int curSum = 0;
int totalSum = 0;
int index = 0;
for (int i = 0; i < gas.length; i++) {
curSum += gas[i] - cost[i];
totalSum += gas[i] - cost[i];
if (curSum < 0) {
index = (i + 1) % gas.length ;
curSum = 0;
}
}
if (totalSum < 0) return -1;
return index;
}
}
10. 分发糖果
题目:老师想给孩子们分发糖果,有 N 个孩子站成了一条直线,老师会根据每个孩子的表现,预先给他们评分。你需要按照以下要求,帮助老师给这些孩子分发糖果:
- 每个孩子至少分配到 1 个糖果。
- 相邻的孩子中,评分高的孩子必须获得更多的糖果。
题目分析:一定是要确定一边之后,再确定另一边,例如比较每一个孩子的左边,然后再比较右边,如果两边一起考虑一定会顾此失彼。
先确定右边评分大于左边的情况(也就是从前向后遍历)此时局部最优:只要右边评分比左边大,右边的孩子就多一个糖果,全局最优:相邻的孩子中,评分高的右孩子获得比左边孩子更多的糖果。(因为如果从前向后遍历,根据 ratings[i + 1] 来确定 ratings[i] 对应的糖果,那么每次都不能利用上前一次的比较结果了。所以确定左孩子大于右孩子的情况一定要从后向前遍历!
再确定左孩子大于右孩子的情况(从后向前遍历)就取candyVec[i + 1] + 1 和 candyVec[i] 最大的糖果数量,candyVec[i]只有取最大的才能既保持对左边candyVec[i - 1]的糖果多,也比右边candyVec[i + 1]的糖果多。
class Solution {
public int candy(int[] ratings) {
int[] candyVec = new int[ratings.length];
candyVec[0] = 1;
for (int i = 1; i < ratings.length; i++) {
if (ratings[i] > ratings[i - 1]) {
candyVec[i] = candyVec[i - 1] + 1;
} else {
candyVec[i] = 1;
}
}
for (int i = ratings.length - 2; i >= 0; i--) {
if (ratings[i] > ratings[i + 1]) {
candyVec[i] = Math.max(candyVec[i], candyVec[i + 1] + 1);
}
}
int ans = 0;
for (int s : candyVec) {
ans += s;
}
return ans;
}
}
11. 柠檬水找零
题目:在柠檬水摊上,每一杯柠檬水的售价为 5 美元。顾客排队购买你的产品,(按账单 bills 支付的顺序)一次购买一杯。每位顾客只买一杯柠檬水,然后向你付 5 美元、10 美元或 20 美元。你必须给每个顾客正确找零,也就是说净交易是每位顾客向你支付 5 美元。注意,一开始你手头没有任何零钱。如果你能给每位顾客正确找零,返回 true ,否则返回 false 。
题目分析:有如下三种情况:
- 情况一:账单是5,直接收下。
- 情况二:账单是10,消耗一个5,增加一个10
- 情况三:账单是20,优先消耗一个10和一个5,如果不够,再消耗三个5
美元10只能给账单20找零,而美元5可以给账单10和账单20找零,美元5更万能!
所以局部最优:遇到账单20,优先消耗美元10,完成本次找零。全局最优:完成全部账单的找零。
class Solution {
public boolean lemonadeChange(int[] bills) {
int cash_5 = 0;
int cash_10 = 0;
for (int i = 0; i < bills.length; i++) {
if (bills[i] == 5) {
cash_5++;
} else if (bills[i] == 10) {
cash_5--;
cash_10++;
} else if (bills[i] == 20) {
if (cash_10 > 0) {
cash_10--;
cash_5--;
} else {
cash_5 -= 3;
}
}
if (cash_5 < 0 || cash_10 < 0) return false;
}
return true;
}
}
|