提示:DDU,供自己复习使用。欢迎大家前来讨论~
文章目录
- 贪心算法Part01
- 一、理论基础
- 1.1 什么是贪心
- 贪心算法解法:
- 动态规划解法:
- 1.2 贪心一般解题步骤
- 二、题目
- 题目一:455.分发饼干
- 解题思路:
- 其他思路
- 题目二:376.摆动序列
- 解题思路:
- 思路二:动态规划
- 题目三: 53. 最大子序和
- 解题思路
- 暴力解法
- 贪心解法
- 动态规划
- 总结
贪心算法Part01
**说白了就是常识性推导加上举反例**
一、理论基础
1.1 什么是贪心
贪心的本质是选择每一阶段的局部最优,从而达到全局最优。
贪心算法是“走一步看一步”,而动态规划是“先规划再行动”。
- 贪心算法适合那些每一步的局部最优选择能够导致全局最优解的问题;
- 动态规划适合那些问题可以分解成重叠子问题,且可以通过解决这些子问题来构建原问题解的情况。
经典问题:背包问题
假设你是一个小偷,偷到了一个背包,背包的容量有限(比如10公斤)。你面前有若干件物品,每件物品都有自己的重量和价值。你希望带走尽可能多的价值,但同时不能超过背包的重量限制。
贪心算法解法:
- 排序:首先,你把所有物品按照价值和重量的比例(价值/重量)从高到低排序。
- 选择:然后,你从最上面开始,依次尝试把物品放入背包,直到背包装满或者没有更多的物品。
这种方法简单快速,但并不保证你带走的是价值最高的组合。因为你只考虑了当前价值最高的物品,而没有考虑其他物品组合的可能性。
动态规划解法:
- 创建表格:你创建一个表格,表格的行代表物品,列代表背包的容量(从0到10公斤)。
- 填表:对于每个物品和每个容量,你计算如果选择这个物品(如果不超过当前容量)和不选择这个物品(即选择前一个容量的最大价值)的最大价值。
- 选择:通过表格,你可以找到在不超过背包容量的情况下,能够带走的最大价值的组合。
这种方法需要更多的计算,但它能够保证你找到的是价值最高的组合,因为它考虑了所有可能的物品组合。
- 贪心算法:快速但不一定最优,适用于那些每一步选择局部最优能够导致全局最优的问题。
- 动态规划:虽然计算量大,但能够保证找到最优解,适用于那些可以分解为重叠子问题的问题。
1.2 贪心一般解题步骤
贪心算法一般分为如下四步:
- 将问题分解为若干个子问题
- 找出适合的贪心策略
- 求解每一个子问题的最优解
- 将局部最优解堆叠成全局最优解
做题的时候,只要想清楚 局部最优 是什么,如果推导出全局最优,其实就够了。
二、题目
题目一:455.分发饼干
455. 分发饼干
解题思路:
为了满足更多的小孩,就不要造成饼干尺寸的浪费。
大尺寸的饼干既可以满足胃口大的孩子也可以满足胃口小的孩子,那么就应该优先满足胃口大的。
这里的局部最优就是大饼干喂给胃口大的,充分利用饼干尺寸喂饱一个,全局最优就是喂饱尽可能多的小孩。
可以尝试使用贪心策略,先将饼干数组和小孩数组排序。
然后从后向前遍历小孩数组,用大饼干优先满足胃口大的,并统计满足小孩数量。
代码如下:
// 版本一
class Solution {
public:
int findContentChildren(vector<int>& g, vector<int>& s) {
sort(g.begin(), g.end());
sort(s.begin(), s.end());
int index = s.size() - 1; // 饼干数组的下标
int result = 0;
for (int i = g.size() - 1; i >= 0; i--) { // 遍历胃口
if (index >= 0 && s[index] >= g[i]) { // 遍历饼干
result++;
index--;
}
}
return result;
}
};
- 时间复杂度:O(nlogn)
- 空间复杂度:O(1)
从代码中可以看出使用了一个 index 来控制饼干数组的遍历,遍历饼干并没有再起一个 for 循环,而是采用自减的方式,这也是常用的技巧。
有的同学看到要遍历两个数组,就想到用两个 for 循环,那样逻辑其实就复杂了。
其他思路
也可以换一个思路,小饼干先喂饱小胃口
class Solution {
public:
int findContentChildren(vector<int>& g, vector<int>& s) {
sort(g.begin(),g.end());
sort(s.begin(),s.end());
int index = 0;
for(int i = 0; i < s.size(); i++) { // 饼干
if(index < g.size() && g[index] <= s[i]){ // 胃口
index++;
}
}
return index;
}
};
- 时间复杂度:O(nlogn)
- 空间复杂度:O(1)
两个循环的顺序改变了,先遍历的饼干,在遍历的胃口,这是因为遍历顺序变了,我们是从小到大遍历。
题目二:376.摆动序列
376. 摆动序列
解题思路:
局部最优:删除单调坡度上的节点(不包括单调坡度两端的节点),那么这个坡度就可以有两个局部峰值。
整体最优:整个序列有最多的局部峰值,从而达到最长摆动序列。
局部最优推出全局最优,并举不出反例,那么试试贪心!
**实际操作上,其实连删除的操作都不用做,因为题目要求的是最长摆动子序列的长度,所以只需要统计数组的峰值数量就可以了(相当于是删除单一坡度上的节点,然后统计长度)**这就是贪心所贪的地方,让峰值尽可能的保持峰值,然后删除单一坡度上的节点
在计算是否有峰值的时候,大家知道遍历的下标 i ,计算 prediff(nums[i] - nums[i-1]) 和 curdiff(nums[i+1] - nums[i]),如果prediff < 0 && curdiff > 0
或者 prediff > 0 && curdiff < 0
此时就有波动就需要统计。
这是我们思考本题的一个大体思路,但本题要考虑三种情况:
- 情况一:上下坡中有平坡
- 情况二:数组首尾两端
- 情况三:单调坡中有平坡
// 版本二
class Solution {
public:
int wiggleMaxLength(vector<int>& nums) {
if (nums.size() <= 1) return nums.size();
int curDiff = 0; // 当前一对差值
int preDiff = 0; // 前一对差值
int result = 1; // 记录峰值个数,序列默认序列最右边有一个峰值 (这里处理的平坡的情况)
for (int i = 0; i < nums.size() - 1; i++) {
curDiff = nums[i + 1] - nums[i];
// 出现峰值(左右的差值不一样)
if ((preDiff <= 0 && curDiff > 0) || (preDiff >= 0 && curDiff < 0)) {
result++;
preDiff = curDiff; // 注意这里,只在摆动变化的时候更新prediff
}
}
return result;
}
};
思路二:动态规划
考虑用动态规划的思想来解决这个问题。
很容易可以发现,对于我们当前考虑的这个数,要么是作为山峰(即 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
。
C++代码如下:
class Solution {
public:
int dp[1005][2];
int wiggleMaxLength(vector<int>& nums) {
memset(dp, 0, sizeof dp);
dp[0][0] = dp[0][1] = 1;
for (int i = 1; i < nums.size(); ++i) {
dp[i][0] = dp[i][1] = 1;
for (int j = 0; j < i; ++j) {
if (nums[j] > nums[i]) dp[i][1] = max(dp[i][1], dp[j][0] + 1);
}
for (int j = 0; j < i; ++j) {
if (nums[j] < nums[i]) dp[i][0] = max(dp[i][0], dp[j][1] + 1);
}
}
return max(dp[nums.size() - 1][0], dp[nums.size() - 1][1]);
}
};
- 时间复杂度:O(n^2)
- 空间复杂度:O(n)
进阶
可以用两棵线段树来维护区间的最大值
- 每次更新
dp[i][0]
,则在tree1
的nums[i]
位置值更新为dp[i][0]
- 每次更新
dp[i][1]
,则在tree2
的nums[i]
位置值更新为dp[i][1]
- 则 dp 转移方程中就没有必要 j 从 0 遍历到 i-1,可以直接在线段树中查询指定区间的值即可。
时间复杂度:O(nlog n)
空间复杂度:O(n)
题目三: 53. 最大子序和
53. 最大子数组和
解题思路
暴力解法
暴力解法的思路,第一层 for 就是设置起始位置,第二层 for 循环遍历数组寻找最大值
class Solution {
public:
int maxSubArray(vector<int>& nums) {
int result = INT32_MIN;
int count = 0;
for (int i = 0; i < nums.size(); i++) { // 设置起始位置
count = 0;
for (int j = i; j < nums.size(); j++) { // 每次从起始位置i开始遍历寻找最大值
count += nums[j];
result = count > result ? count : result;
}
}
return result;
}
};
- 时间复杂度:O(n^2)
- 空间复杂度:O(1)
贪心解法
- 贪心算法的核心:贪心算法在每一步都选择当前看起来最优的选项,希望这样能够达到全局最优。例子中,“贪”的是当前的正数,以期望累积更大的和。
- 局部最优:在贪心算法中,如果当前元素是正数,就将其加入到当前的子数组中,因为正数会增加当前的连续和。如果当前元素是负数,就从下一个元素开始新的子数组,因为负数会减少当前的连续和。
- 全局最优:虽然贪心算法在每一步都做出了局部最优的选择,但并不保证能够得到全局最优解。例子中,贪心算法可能无法找到整个数组中的最大连续正和。
- 代码实现:在代码层面,使用一个变量
count
来累积当前的连续和。当count
加上当前元素nums[i]
变为负数时,就将count
重置为0,从nums[i+1]
开始新的累积。 - 区间终止位置:贪心算法在这个问题中不调整区间的终止位置,因为它总是贪心地选择当前的正数,直到遇到下一个负数。这样可能无法找到真正的最大子数组和。
- 动态规划:与贪心算法不同,动态规划会考虑所有可能的子数组,通过填表的方式来记录每个子数组的和,并选择最大的那个。这种方法可以保证找到全局最优解。
- 暴力解法:暴力解法会尝试所有可能的子数组组合,计算它们的和,然后选择最大的一个。这种方法虽然能够找到全局最优解,但效率较低。
区间的终止位置,其实就是如果 count 取到最大值了,及时记录下来了。例如如下代码:
if (count > result) result = count;
这样相当于是用 result 记录最大子序和区间和(变相的算是调整了终止位置)。
如动画所示:
红色的起始位置就是贪心每次取 count 为正数的时候,开始一个区间的统计。
那么不难写出如下 C++代码(关键地方已经注释)
class Solution {
public:
int maxSubArray(vector<int>& nums) {
int result = INT32_MIN;
int count = 0;
for (int i = 0; i < nums.size(); i++) {
count += nums[i];
if (count > result) { // 取区间累计的最大值(相当于不断确定最大子序终止位置)
result = count;
}
if (count <= 0) count = 0; // 相当于重置最大子序起始位置,因为遇到负数一定是拉低总和
}
return result;
}
};
- 时间复杂度:O(n)
- 空间复杂度:O(1)
当然题目没有说如果数组为空,应该返回什么,所以数组为空的话返回啥都可以了。
动态规划
class Solution {
public:
int maxSubArray(vector<int>& nums) {
if (nums.size() == 0) return 0;
vector<int> dp(nums.size(), 0); // dp[i]表示包括i之前的最大连续子序列和
dp[0] = nums[0];
int result = dp[0];
for (int i = 1; i < nums.size(); i++) {
dp[i] = max(dp[i - 1] + nums[i], nums[i]); // 状态转移公式
if (dp[i] > result) result = dp[i]; // result 保存dp[i]的最大值
}
return result;
}
};
- 时间复杂度:O(n)
- 空间复杂度:O(n)
小结:
本题的贪心思路其实并不好想,这也进一步验证了,别看贪心理论很直白,有时候看似是常识,但贪心的题目一点都不简单!
总结
- 贪心算法的基本理论,局部最优但不一定是全局最优。
- 贪心算法的组合问题,经典的背包问题。