-
本文章系列为笔者leetcode刷题的记录、复习与分享,有错误的地方欢迎指正。
贪心算法(又称贪婪算法)是指,在对问题求解时,总是做出在当前看来是最好的选择。也就是说,不从整体最优上加以考虑,他所做出的是在某种意义上的局部最优解。贪心算法不是对所有问题都能得到整体最优解,关键是贪心策略的选择,选择的贪心策略必须具备无后效性,即某个状态以前的过程不会影响以后的状态,只与当前状态有关。
分配问题
分发饼干
思路: 把孩子和饼干排序,给最小饥饿度的孩子分配最小的能饱腹的饼干。
class Solution {
public:
int findContentChildren(vector<int>& g, vector<int>& s) {
int child = 0;
int cookies = 0;
sort(g.begin(),g.end());
sort(s.begin(),s.end());
while(child < g.size() && cookies < s.size()){
if(g[child]<=s[cookies]) child++;
cookies++;
}
return child;
}
};
分发糖果
思路:需要简单的两次遍历,把所有孩子的糖果数初始化为 1;先从左往右遍历一遍,如果右边孩子的评分比
左边的高,则右边孩子的糖果数更新为左边孩子的糖果数加1;再从右往左遍历一遍,如果左边孩子的评分比右边的高,且左边孩子当前的糖果数不大于右边孩子的糖果数,则左边孩子的糖果数更新为右边孩子的糖果数加1。
这里的贪心策略即为,在每次遍历中,只考虑并更新相邻一侧的大小关系。
class Solution {
public:
int candy(vector<int>& ratings) {
int size = ratings.size();
if(size<2) return size;
vector<int> candy(size,1);
for(int i =0;i <size-1;i++){
if(ratings[i+1]>ratings[i]){
candy[i+1] = candy[i]+1;
}
}
for(int i = size -1;i>0;i--){
if(ratings[i-1]>ratings[i]){
candy[i-1] = max(candy[i-1],candy[i]+1);
}
}
return accumulate(candy.begin(),candy.end(),0);
}
};
种花问题
思路:从左向右遍历花坛,在可以种花的地方就种一朵,能种就种(因为在任一种花时候,不种都不会得到更优解),就是一种贪心的思想。
这里可以种花的条件是:
- 自己为空
- 左边为空 或者 自己是最左
- 右边为空 或者 自己是最右 代码如下:
class Solution {
public:
bool canPlaceFlowers(vector<int>& flowerbed, int n) {
int s = flowerbed.size();
for(int i =0;i<s;i++){
if(flowerbed[i] ==0 && (i==0 || flowerbed[i-1]==0 ) && (i==s-1 || flowerbed[i+1]==0)){
n--;
if(n<=0) return true;
flowerbed[i] =1;
}
}
return n<=0;
}
};
买卖股票的最佳时机
思路: 如果所有上涨交易日都买卖,所有下降交易日都不买卖,一定是利益最大化的,所以可以把所有的上坡全部收集到。把可能跨越多天的买卖都化解成相邻两天的买卖,等价于每天都在买卖,(注意计算的过程并不是实际的交易过程,4 次买入和 4次卖出,等价于在第1天买入,第5天卖出。)
代码如下:
class Solution {
public:
int maxProfit(vector<int>& prices) {
int ans = 0;
for(int i = 1; i < prices.size(); i++){
ans += max(0, prices[i] - prices[i-1]);
}
return ans;
}
};
根据身高重建队列
思路:本题需要进行排序和插入操作
按照身高来排序,一定要从大到小排(身高相同的话则k小站前面),则排序后前面的节点一定比当前的节点高,那么当前节点可以放心插到下标为n的位置,这样就确定了当前节点前面一定有n个比它高的元素
所以在按照身高从大到小排序后:
- 局部最优:优先按身高高的people的k来插入。插入操作过后的people满足队列属性
- 全局最优:最后都做完插入操作,整个队列满足题目队列属性
class Solution {
public:
vector<vector<int>> reconstructQueue(vector<vector<int>>& people) {
sort(people.begin(), people.end(), [](vector<int> A, vector<int> B) {
if (A[0] == B[0]) return A[1] < B[1];
return A[0] > B[0];
});
vector<vector<int>> ans;
for (int i = 0;i < people.size();i++) {
ans.insert(ans.begin() + people[i][1], people[i]);
}
return ans;
}
};
但使用vector是非常费时的,C++中vector(可以理解是一个动态数组,底层是普通数组实现的)如果插入元素大于预先普通数组大小,vector底部会有一个扩容的操作,即申请两倍于原先普通数组的大小,然后把数据拷贝到另一个更大的数组上,最后释放原数组内存。
所以使用vector(动态数组)来insert,是费时的,插入再拷贝的话,单纯一个插入的操作就是O(n^2)了,甚至可能拷贝好几次,就不止O(n^2)了。
所以大小不定、频繁的插入改为链表更好
class Solution {
public:
// 身高从大到小排(身高相同k小的站前面)
static bool cmp(const vector<int> a, const vector<int> b) {
if (a[0] == b[0]) return a[1] < b[1];
return a[0] > b[0];
}
vector<vector<int>> reconstructQueue(vector<vector<int>>& people) {
sort (people.begin(), people.end(), cmp);
list<vector<int>> que; // list底层是链表实现,插入效率比vector高的多
for (int i = 0; i < people.size(); i++) {
int position = people[i][1]; // 插入到下标为position的位置
std::list<vector<int>>::iterator it = que.begin();
while (position--) { // 寻找在插入位置
it++;
}
que.insert(it, people[i]);
}
return vector<vector<int>>(que.begin(), que.end());
}
};
知识补充
List封装了链表,Vector封装了数组, list和vector得最主要的区别在于vector使用连续内存存储的,他支持[]运算符,而list是以链表形式实现的,不支持[]
Vector对于随机访问的速度很快,但是对于插入尤其是在头部插入元素速度很慢,在尾部插入速度很快。list容器就是一个双向链表,List对于随机访问速度慢得多,因为可能要遍历整个链表才能做到,但是对于插入和删除就高效的多了,不需要拷贝和移动数据,只需要改变指针的指向就可以了。另外对于新添加的元素,Vector有一套算法,而List可以任意加入。
区间问题
无重叠区间
思路:
先把区间按照结尾的大小进行增序排序,每次选择结尾最小且和前一个选择的区间不重叠的区间。
class Solution {
public:
int eraseOverlapIntervals(vector<vector<int>>& intervals) {
if(intervals.empty()) return 0;
int n = intervals.size();
if(n<2) return 0;
sort(intervals.begin(),intervals.end(), [](vector<int> a,vector<int>b){
return a[1]<b[1];
});
int count =0,prev = intervals[0][1];
for(int i = 1;i<n;i++){
if(intervals[i][0]<prev){
count++;
} else {
prev = intervals[i][1];
}
}
return count;
}
};
用最少数量的箭引爆气球
思路:区间贪心问题:给定n个闭区间[x,y],插入一个点使得落到最多的闭区间中,问最少需要确定多少个点
- 区间结尾排序
- 拿当前区间的右端作为标杆(在保证当面区间被射到的前提下,尽可能找多的重合区间)。
- 只要下一个区间的左端<=标杆下一个区间的左端<=标杆,则重合,继续寻求与下一个区间重合
- 直到遇到不重合的区间,弓箭数+1
- 拿新区间的右端作为标杆,重复以上步骤
代码如下:
class Solution {
public:
int findMinArrowShots(vector<vector<int>>& points) {
int n = points.size();
if(n<=1) return n;
sort(points.begin(), points.end(), [](vector<int> A, vector<int> B){
return A[1]<B[1];//不可以用开头排序,因为不能确保包含的区间是否重合(如[0,9],[0,6],[7,8])
});
int cur = points[0][1],count = 1;
for(int i = 1; i<n;i++){
if(points[i][0]>cur){
count++;
cur = points[i][1];
}
}
return count;
}
};
划分字母区间
思路:
由于同一个字母只能出现在同一个片段,显然同一个字母的第一次出现的下标位置和最后一次出现的下标位置必须出现在同一个片段。因此需要遍历字符串,得到每个字母最后一次出现的下标位置
在得到每个字母最后一次出现的下标位置之后,可以使用贪心的方法将字符串划分为尽可能多的片段,具体做法如下。
-
从左到右遍历字符串,遍历的同时维护当前片段的开始下标
start和结束下标end,初始时start=end=0 -
对于每个访问到的字母,得到当前字母的最后一次出现的下标位置,令
end=max(end,end1) -
当访问到下标end 时,当前片段访问结束,当前片段的长度为
end−start+1,将当前片段的长度添加到返回值,然后令start=end+1,继续寻找下一个片段。
重复上述过程,直到遍历完字符串。
class Solution {
public:
vector<int> partitionLabels(string S) {
int map[26] = {-1};
vector<int> res;
int star = 0, end = 0;
for(int i = 0; i < S.size(); i++) {
map[S[i] - 'a'] = i;
}
for(int i = 0; i < S.size();i++){
end = max(end, map[S[i] - 'a']);
if(i == end){
res.push_back(end-star+1);
star = i+1;
}
}
return res;
}
};
非递减数列
思路:本题是要维持一个非递减的数列,所以遇到递减的情况时(nums[i] > nums[i + 1]),要么将前面的元素缩小,要么将后面的元素放大
通过举例发现,有时候我们需要修改前面较大的数字(比如前两个例子需要修改4),有时候却要修改后面较小的那个数字,并且与再前一个数字大小有关系,来尽量让数列位置递增
举例:
4,2,3
-1,4,2,3
2,3,4,2,4
4(i)
/\ 3
/ \ /
/ \/
/ 2(i+1)
-1(i+1)
4 (i) 4
/\ /
/ \ /
/ \ /
3 \ /
(i-1) \/
2(i+1)
由上图可知
- 当nums[i + 1] >= nums[i-1]时,缩小nums[i],因为此时增大nums[i+1]会破坏后面的递增数列
- 当nums[i + 1] < nums[i-1]时,放大nums[i+1],因为nums[i+1]太小了比nums[i-1]还小,为了保持递增只能放大nums[i+1]
class Solution {
public:
bool checkPossibility(vector<int>& nums) {
int s = nums.size();
if(s <= 2) return true;
bool flag = nums[0] > nums[1] ? false:true;
for (int i = 1; i < s - 1; i++) {
if (nums[i] > nums[i + 1]) {
if(flag){
if(nums[i-1] <= nums[i+1])
nums[i] = nums[i+1];
else
nums[i+1] = nums[i];
flag = false;
} else
return false;
}
}
return true;
}
};