【刷题日记】贪心算法经典题目

大家好,我是白晨,一个不是很能熬夜,但是也想日更的人✈。如果喜欢这篇文章,点个赞关注一下白晨吧!你的支持就是我最大的动力!

【刷题日记】贪心算法经典题目_第1张图片

文章目录

  • 前言
  • 贪心算法经典题目
    • 1.分割平衡字符串
    • 2.买卖股票的最佳时机
    • 3.跳跃游戏
    • 4.多机调度问题
    • 5.活动选择
    • 6.最多可以参加的会议数目
    • 7.无重叠区间
  • 总结

前言


观前提示:此文章需要一定贪心算法的基础。

大家好呀,我是白晨。

贪心算法算是一种比较耳熟能详的算法,只要求出局部最优解就可以得到整体的最优解,而且面试很喜欢出这种问题。但是,贪心算法其实并不好想,特别是有些问题比较绕的时候,你可能根本就想不到贪心算法。动态规划这个算法是从整体出发求整体最优,而贪心算法是从局部出发求整体最优,所以很多时候贪心算法并不好用,而且动态规划说是难,但是掌握方法后使用起来非常爽,而贪心算法规律比较难找,每一种题都有不同的具体贪心思想。

所以,白晨选出了一些贪心算法的几道经典题目,以帮助大家理解贪心算法。题目难度由低到高,都是比较经典的题型。可能不是很难,但是有些题目很绕,看了答案后,会觉得自己被侮辱了,这就是贪心算法。


贪心算法经典题目


1.分割平衡字符串


【刷题日记】贪心算法经典题目_第2张图片

原题链接:分割平衡字符串

解析

这道题要求尽可能多的切割平衡字符串

  • 我们通过观察例题以及推理可得,要想得到最多切割,切割处来的字符串必须为先 RL 或者先 LR 。绝对不能出现 L R 相互交错出现。
  • 也就是说分割时,不能出现嵌套。
  • 贪心策略: 不要有嵌套的平衡,只要达到平衡,就立即分割
  • 故可以定义一个变量 cnt ,在遇到不同的字符时,向不同的方向变化,设 cnt 为 0 时达到平衡,分割数更新。
  • 比如:
    从左往右扫描字符串s,遇到 L , cnt + 1,遇到 R ,cnt - 1
    cnt 为0时即,更新记录平衡字符串子串的变量 ++num
class Solution {
public:
    int balancedStringSplit(string s) {
        int len = s.size();
        int cnt = 0, num = 0;

        for (int i = 0; i < len; ++i)
        {
            // 遇到R,--cnt
            if (s[i] == 'R')
                --cnt;
            // 遇到L,++cnt
            else
                ++cnt;
			// 判断:如果cnt等于0,说明要切割
            if (cnt == 0)
                ++num;
        }

        return num;
    }
};

2.买卖股票的最佳时机


【刷题日记】贪心算法经典题目_第3张图片

原题链接:买卖股票的最佳时机

解析

这道题非常具有迷惑性,也特别容易想复杂,当你看到答案的时候可能会想找个豆腐撞死。

  • 法1:贪心算法

    其实你完全没有必要去想怎么买卖才能利益最大化,因为这个题目是给出了后面几天的股价的,所以我们可以到第二天再做决策。

    所以,第二天股价涨时,把我们就买入前一天的股票,再前一天的股票卖出,也就是第二天得到了利润

    如果第二天跌了,我们第一天就不买入

    总结一下就是:

    • 连续上涨交易日: 则第一天买最后一天卖收益最大,等价于每天都买卖

    【刷题日记】贪心算法经典题目_第4张图片

    • 连续下降交易日: 则不买卖收益最大,即不会亏钱。

    故可以遍历整个股票交易日价格列表,在所有上涨交易日都买卖(赚到所有利润),所有下降交易日都不买卖(永不亏钱)。

class Solution {
public:
    int maxProfit(vector<int>& prices) {
        int day = prices.size();
        int profit = 0;

        for (int i = 1; i < day; ++i)
        {
            // 股价比前一天高就卖出
            if (prices[i] > prices[i - 1])
                profit += prices[i] - prices[i - 1];
        }

        return profit;
    }
};
  • 法二:动态规划

    这道题也能用动态规划做,就是很麻烦,而且效果没有贪心好。如果没有学过动态规划的同学可以不用看这种方法。

【刷题日记】贪心算法经典题目_第5张图片

状态

定义状态 F ( i , 0 ) F(i,0) F(i,0) 表示第 i 天交易完后手里没有股票的最大利润, F ( i , 1 ) F(i,1) F(i,1)表示第i 天交易完后手里持有一支股票的最大利润。

class Solution {
public:
    int maxProfit(vector<int>& prices) {
        int day = prices.size();
        vector<vector<int> > ret(day, vector<int>(2, 0));
        // 第一天的状态
        ret[0][0] = 0;
        ret[0][1] = -prices[0];

        for (int i = 1; i < day; ++i)
        {
        	// 每天选一次
            ret[i][0] = max(ret[i - 1][0], ret[i - 1][1] + prices[i]);
            ret[i][1] = max(ret[i - 1][1], ret[i - 1][0] - prices[i]);
        }

        return ret[day - 1][0];
    }
};

优化算法:

  • 因为今天的状态只与前一天的状态有关,所以我们可以用两个局部变量存放前一天的状态。空间复杂度可以由 O ( n ) O(n) O(n) 优化到 O ( 1 ) O(1) O(1)
class Solution {
public:
    int maxProfit(vector<int>& prices) {
        int day = prices.size();
        int ret[2];
        ret[0] = 0;
        ret[1] = -prices[0];
        // 存放昨天的状态
        int tmp0 = ret[0];
        int tmp1 = ret[1];

        for (int i = 1; i < day; ++i)
        {
            ret[0] = max(tmp0, tmp1 + prices[i]);
            ret[1] = max(tmp1, tmp0 - prices[i]);
            tmp0 = ret[0];
            tmp1 = ret[1];
        }

        return ret[0];
    }
};

3.跳跃游戏


【刷题日记】贪心算法经典题目_第6张图片

原题链接:跳跃游戏

解析

贪心策略
设想一下,对于数组中的任意一个位置 y,我们如何判断它是否可以到达?

根据题目的描述,只要存在一个位置x,它本身可以到达,并且它跳跃的最大长度为 x + n u m s [ x ] x + nums[x] x+nums[x],这个值大于等于 y,即 x + n u m s [ x ] ≥ y x+nums[x] ≥ y x+nums[x]y ,那么位置 y 也可以到达。
换句话说,对于每一个可以到达的位置 x,它使得 x + 1 , x + 2 , ⋯ , x + n u m s [ x ] x+1, x+2,⋯,x+nums[x] x+1,x+2,,x+nums[x] 这些连续的位置都可以到达。

这样以来,我们依次遍历数组中的每一个位置,并实时维护最远可以到达的位置。对于当前遍历到的位置x如果它在最远
可以到达的位置的范围内,那么我们就可以从起点通过若干次跳跃到达该位置,因此我们可以用 x + n u m s [ x ] x+nums[x] x+nums[x]更新 最远可以
到达的位置。
在遍历的过程中,如果最远可以到达的位置大于等于数组中的最后一个位置,那就说明最后一个位置可达,我们就可以直接
返回True作为答案。反之,如果在遍历结束后,最后一个位置仍然不可达,我们就返回 False 作为答案。、

以题目中的示例一
[2, 3, 1, 1, 4]
为例:
我们一开始在位置 0,可以跳跃的最大长度为 2,因此最远可以到达的位置被更新为 2;
我们遍历到位置 1,由于 1≤2,因此位置 1 可达。我们用 1加上它可以跳跃的最大长度3,将最远可以到达的位置更新为
4。由于4大于等于最后一个位置4,因此我们直接返回 True。
我们再来看看题目中的示例二
[3, 2, 1, 0, 4]
我们一开始在位置0,可以跳跃的最大长度为3,因此最远可以到达的位置被更新为3;
我们遍历到位置1,由于1≤3,因此位置1可达,加上它可以跳跃的最大长度2得到3,没有超过最远可以到达的位置;
位置 2、位置 3 同理,最远可以到达的位置不会被更新;
我们遍历到位置4,由于4>3,因此位置4不可达,我们也就不考虑它可以跳跃的最大长度了。
在遍历完成之后,位置4仍然不可达,因此我们返回 False

class Solution {
public:
    bool canJump(vector<int>& nums) {
        int maxLen = 0;
        int len = nums.size();

        for(int i = 0; i < len; ++i)
        {
            // 必须要判断前面最大能不能到达这个点
            // 能到达才更新,不能到达就不更新
            if(i <= maxLen)
                maxLen = max(i + nums[i], maxLen);
            
            // 遇到最大到达距离大于等于最后一个数的下标就返回true
            if(maxLen >= len - 1)
                return true;
            
        }

        return false;
    }
};

// 改版
class Solution {
public:
    bool canJump(vector<int>& nums) {
        int maxLen = 0;
        int len = nums.size();

        for(int i = 0; i < len; ++i)
        {
            // 通过上面的代码,相信很多人已经发现,如果到最大到达距离达不了遍历中的某一个元素
            // 那么,最后一定到不了最后一个元素
            // 因为最大到达距离是覆盖式的,能到最后一个元素必然可以到达前面的元素
            // 所以我们可以先判断是否能到这个元素,能到达就更新,不能到达就直接返回false
            if(maxLen < i)
                return false;
            
            maxLen = max(i + nums[i], maxLen);
        }
		// 遍历完成说明必然可以到达最后一个元素,直接返回true
        return true;
    }
};

4.多机调度问题


某工厂有 n 个独立的作业,由m台相同的机器进行加工处理。作业 i 所需的加工时间为 ti ,任何作业在被处理时不能中
断,也不能进行拆分处理。现厂长请你给他写一个函数:算出 n 个作业由 m 台机器加工处理的最短时间

贪心思路:

  • 如果 n < = m n <= m n<=m ,那么只需要返回任务中时间最长的一项任务即可
  • 如果 n > m n > m n>m
    1. 首先,我们可以将任务按时间长短排序,按降序排列。
    2. 其次,我们针对每一项任务,都要选出当前所有机器中执行任务时间最短的机器来执行。
    3. 最后,选出执行任务总时间最长的一个机器,返回其执行任务所需要的时间。
bool cmp(const int& x, const int& y)
{
	return x > y;
}

int greedStrategy(const vector<int>& works, vector<int>& machines)
{
	// 按工作时间长短排序
	sort(works.begin(), works.end(), cmp);
	int workNum = works.size();
	int machineNum = machines.size();
	// 如果 n <= m ,返回任务中时间最长的一项任务
	if (workNum <= machineNum)
		return works[0];
	// 选择机器执行每一个任务
	for (int i = 0; i < workNum; ++i)
	{
		int flag = 0;
		// 选择现在执行任务花费时间最少的机器
		for (int j = 1; j < machineNum; ++j)
		{
			if (machines[j] < machines[flag])
				flag = j;
		}
		// 执行本次任务
		machines[flag] += works[i];
	}
	// 选出用时最长的机器
	int max = 0;
	for(int e : machines)
	{ 
		if (e > max)
			max = e;
	}
	// 返回时间
	return max;
}

5.活动选择


n个需要在同一天使用同一个教室的活动a1, a2, …, an教室同一时刻只能由一个活动使用每个活动a[i]都有一个开始时间s[i]和结束时间f[i]。一旦被选择后,活动a[i]就占据半开时间区间[s[i],f[i])。如果[s[i],f[i])和[s[j],f[j])互不重叠,a[i]和a[j]两个活动就可以被安排在这一天。求使得尽量多的活动能不冲突的举行的最大数量

【刷题日记】贪心算法经典题目_第7张图片

  • 每次选取结束时间最早的活动,可以得到最优解。按这种方法选择,可以为未安排活动留下尽可能多的时间。如上图所示的活动集合S,其中各项活动按照结束时间单调递增排序

面对活动选择会遇到以下三种情况:

  1. 开始时间和结束时间都不同

【刷题日记】贪心算法经典题目_第8张图片

这里我们选择结束时间早的。

  1. 开始时间相同,结束时间不同

【刷题日记】贪心算法经典题目_第9张图片

这里我们选择结束时间早的

  1. 开始时间不同,结束时间相同

【刷题日记】贪心算法经典题目_第10张图片

  • 这里我们先要判断是否两个活动都在上个被举行活动结束之后
  • 两个都在之后,选择遍历先遇到的即可
  • 一个在之前,一个在之后,选择之后的
  • 两个都没有在之后,不选
  • 当然,这里的两个只是例子,实际情况可能有多个结束时间相同的活动,判断逻辑与上面相同。
bool cmp(const pair<int, int>& a, const pair<int, int>& b)
{
	return a.second < b.second;
}

int greedyActivitySelector(const vector<pair<int, int>>& act)
{
	// 按照结束时间从早到晚排序
	sort(act.begin(), act.end(), cmp);
	// 记录一个活动的结束时间
	int end = 1;
	int num = 0;

	// 遍历每一个活动
	for (auto e : act)
	{
		// 当一个活动的开始时间大于等于前一个被举行活动的结束时间时,执行这个活动
		if (e.first >= end)
		{
			end = e.second;
			++num;
		}
	}
	return num;
}


6.最多可以参加的会议数目


【刷题日记】贪心算法经典题目_第11张图片

原题链接:最多可以参加的会议数目

这个题与上题类似,我们可以使用贪心算法,按照结束时间先后去参加,先结束的先参加。

但是这道题上道题不同的地方在于,会议有持续时间,而且可以只选择一天去,所以多个会议持续时间重叠并可能不影响参加会议,这道题的难度更大。

【刷题日记】贪心算法经典题目_第12张图片

法一:贪心算法硬解

思路详解:

直接莽夫,记录下已经参加会议过的天。每次选择结束最早的会议,遍历这个会议的开始日期到结束日期,判断是否有未参加会议的天,如果有,就参加,如果没有,就不参加。

class Solution {
public:
	int maxEvents(vector<vector<int>>& events) {
        // 按照结束顺序排升序,如果结束时间相同,就将开始时间早的排在前
		sort(events.begin(), events.end(), [](vector<int>& a, vector<int>& b) {
			if (a[1] != b[1]) return a[1] < b[1];
			return a[0] < b[0];
			});
		// 记录参会次数
		int num = 0;
        // 创建一个数组,判断这天有没有使用
		vector<bool> used(100001, false);

		for (auto e : events)
		{
            // 遍历一个会议的开始到结束,判断是否有空闲的天
			for (int i = e[0]; i <= e[1]; ++i)
			{
				if (!used[i])
				{
       
					used[i] = true;
					++num;
					break;
				}
			}
		}

		return num;
	}
};

这种方法可以说简单粗暴,但是相应的,时间复杂度取决于题目给的数据,如果给出一组例如 [ 1 , 1 ] , [ 1 , 2 ] , [ 1 , 3 ] , [ 1 , 4 ] . . . . . . . [ 1 , 10000 ] [1,1],[1,2],[1,3],[1,4].......[1,10000] [1,1],[1,2],[1,3],[1,4].......[1,10000]
开始时间相同,但是结束时间不同的数据,每一次都要从开始到结束遍历,时间复杂度非常之高,会导致时间太长而无法通过。

所以,直接莽行不太通。

法二:队列+贪心

思路解析:

  1. 这次我们按照开始顺序进行排序,排成升序。
  2. 然后,从第一天开始走,遇到这天开始的会议按照结束时间加入小根堆,这样可以保证,结束时间早的会议先被使用。
  3. 其次,由于一天只能参加一个会议,所以有些会议可能参加不了,如 [[1,4],[4,4],[2,2],[3,4],[1,1]] ,里面的 [ 4 , 4 ] [4,4] [4,4] 就因为前面的会议太多,第四天参加不了,第五天会议已经结束而被迫放弃这个会议。所以,我们每天要放弃掉已经过时的会议。
  4. 接下来,选出小根堆的堆顶,也就是选择结束时间最早的那一个会议参加。
  5. 重复 2~4 步,直到堆为空。
class Solution {
public:
	int maxEvents(vector<vector<int>>& events) {
		priority_queue<int, vector<int>, greater<int>> pq;
		sort(events.begin(), events.end());
		int curDay = 1;
		int i = 0;
		int n = events.size();
		int ans = 0;
		while (i < n || !pq.empty()) {
			// 队列存储已经开始的会议, 按结束时间小根堆排序 
			while (i < n && events[i][0] == curDay) {
				pq.push(events[i][1]);
				i++;
			}
			// 已经结束的会议出堆
			while (!pq.empty() && pq.top() < curDay) {
				pq.pop();
			}
			// 贪心思想, 选择最早结束的会议参加 
			if (!pq.empty()) {
				pq.pop();
				ans++;
			}
			curDay++;
		}
		return ans;
	}
};

7.无重叠区间


【刷题日记】贪心算法经典题目_第13张图片

原题链接:无重叠区间

法一:

要求最小移除的区间数,也就是求最大不重叠的区间,所以我们可以按照活动选择的方法,先求出最大不重叠区间数,再用总区间数减去最大不重叠区间数,就可以得到最小移除的区间数。

class Solution {
public:
	static bool cmp(const vector<int>& a, const vector<int>& b)
	{
		return a[1] < b[1];
	}
	int eraseOverlapIntervals(vector<vector<int>>& intervals) {
		if (intervals.size() == 0)
			return 0;
        // 按结束时间从早到晚排序
		sort(intervals.begin(), intervals.end(), cmp);
        // 由于区间可能为负,并且第一个结束的区间一定是我们选择的不重叠的,所以我们这里先记录第一个区间
        // 记录不重叠区间的数量
		int num = 1;
        // 记录上一个不重叠区间的结束
		int end = intervals[0][1];
		
		for (int i = 1; i < intervals.size(); ++i)
		{
            // 如果上一个不重叠区间的结束小于等于这个区间的开始,这个区间为不重叠区间
			if (end <= intervals[i][0])
			{
				end = intervals[i][1];
				++num;
			}
		}

		return intervals.size() - num;
	}
};

当然,这个题也可以用范围for

class Solution {
public:
	static bool cmp(const vector<int>& a, const vector<int>& b)
	{
		return a[1] < b[1];
	}
	int eraseOverlapIntervals(vector<vector<int>>& intervals) {
		if (intervals.size() == 0)
			return 0;
		sort(intervals.begin(), intervals.end(), cmp);
		int num = 0;
        // 把end设置为int类型的最小值
		int end = INT_MIN;

		for (auto e : intervals)
		{
			if (end <= e[0])
			{
				end = e[1];
				++num;
			}
		}

		return intervals.size() - num;
	}
};

这种方法时间复杂度为 O ( n l o g n ) O(nlogn) O(nlogn) ,可以说已经是这个题最简单的算法。

法二:

根据题目求解:

如果我们按照起点对区间进行排序,贪心算法就可以起到很好的效果。
贪心策略:
当按照起点先后顺序考虑区间的时候。我们利用一个 prev 指针追踪刚刚添加到最终列表中的区间。遍历的时候,可能遇到图中的三种情况:

  • 情况一:
    当前考虑的两个区间不重叠:

【刷题日记】贪心算法经典题目_第14张图片

在这种情况下,不移除任何区间,将 prev 赋值为后面的区间,移除区间数量不变。

  • 情况二:
    两个区间重叠,后一个区间的终点在前一个区间的终点之前:

【刷题日记】贪心算法经典题目_第15张图片

这种情况下,我们可以简单地只用后一个区间。这是显然的,因为后一个区间的长度更小,可以留下更多的空间(AA 和BB),容纳更多的区间。因此, prev 更新为当前区间,移除区间的数量 + 1。

  • 情况三:
    两个区间重叠,后一个区间的终点在前一个区间的终点之后:

    【刷题日记】贪心算法经典题目_第16张图片

    这种情况下,我们用贪心策略处理问题,直接移除后一个区间。这样也可以留下更多的空间,容纳更多的区间。因此,prev不变,移除区间的数量 + 1。

class Solution {
public:
	static bool cmp(const vector<int>& a, const vector<int>& b)
	{
		//按起点递增排序
		if (a[0] != b[0])
			return a[0] < b[0];
		return a[1] < b[1];
	}
	int eraseOverlapIntervals(vector<vector<int>>& intervals) {
		if (intervals.empty())
			return 0;
        // 排序
		sort(intervals.begin(), intervals.end(), cmp);
        // 删除的个数
		int Dnum = 0;
        // 保存前一个不重叠区间的下标
		int prev = 0;

		for (int i = 1; i < intervals.size(); ++i)
		{
            // 当区间重叠时
			if (intervals[prev][1] > intervals[i][0])
			{
                // 判断是否为情况2
				if (intervals[prev][1] > intervals[i][1])
				{
					prev = i;
				}
				++Dnum;
			}
            //情况3
			else
			{
				prev = i;
			}

		}

		return Dnum;
	}
};

总结


我们简单总结一下贪心算法的解题过程:

  1. 建立数学模型来描述问题;
  2. 把求解的问题分成若干个子问题;
  3. 对每一子问题求解,得到子问题的局部最优解;
  4. 把子问题的局部最优解合成原来解问题的一个解

这是一个新的系列 ——【刷题日记】,白晨开这个系列的初衷是为了分享一些不同算法经典题型,以便于大家更好的学习编程。如果大家喜欢这个专栏的话——点击查看【刷题日记】所有文章。

如果解析有不对之处还请指正,我会尽快修改,多谢大家的包容。

如果大家喜欢这个系列,还请大家多多支持啦!

如果这篇文章有帮到你,还请给我一个大拇指小星星 ⭐️支持一下白晨吧!喜欢白晨【刷题日记】系列的话,不如关注白晨,以便看到最新更新哟!!!

我是不太能熬夜的白晨,我们下篇文章见。

你可能感兴趣的:(刷题日记,贪心算法,算法,c语言,c++,leetcode)