贪心算法--背包问题、均分纸牌、货币选择、区间调度、最小字典序

贪心算法

一、基本概念:

所谓贪心算法是指,在对问题求解时,总是做出在当前看来是最好的选择。也就是说,不从整体最优上加以考虑,他所做出的仅是在某种意义上的局部最优解。
贪心算法没有固定的算法框架,算法设计的关键是贪心策略的选择。必须注意的是,贪心算法不是对所有问题都能得到整体最优解,选择的贪心策略必须具备无后效性,即某个状态以后的过程不会影响以前的状态,只与当前状态有关。
所以对所采用的贪心策略一定要仔细分析其是否满足无后效性。

二、贪心算法的基本思路:

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

三、贪心算法适用的问题

贪心策略适用的前提是:局部最优策略能导致产生全局最优解。
实际上,贪心算法适用的情况很少。一般,对一个问题分析是否适用于贪心算法,可以先选择该问题下的几个实际数据进行分析,就可做出判断。

四、贪心算法的实现框架

从问题的某一初始解出发;
while (能朝给定总目标前进一步)
{ 
      利用可行的决策,求出可行解的一个解元素;
}
由所有解元素组合成问题的一个可行解;

五、贪心策略的选择

因为用贪心算法只能通过解局部最优解的策略来达到全局最优解,因此,一定要注意判断问题是否适合采用贪心算法策略,找到的解是否一定是问题的最优解。

六、例题分析

6.1 背包问题

下面是一个可以试用贪心算法解的题目,贪心解的确不错,可惜不是最优解。

有一个背包,背包容量是M=150。有7个物品,物品可以分割成任意大小。
要求尽可能让装入背包中的物品总价值最大,但不能超过总容量。
物品 A B C D E F G
重量 35 30 60 50 40 10 25
价值 10 40 30 50 35 40 30
分析:
目标函数: ∑pi最大
约束条件是装入的物品总重量不超过背包容量:∑wi<=M( M=150)
(1)根据贪心的策略,每次挑选价值最大的物品装入背包,得到的结果是否最优?
(2)每次挑选所占重量最小的物品装入是否能得到最优解?
(3)每次选取单位重量价值最大的物品,成为解本题的策略。
值得注意的是,贪心算法并不是完全不可以使用,贪心策略一旦经过证明成立后,它就是一种高效的算法。比如,求最小生成树的Prim算法和Kruskal算法都是漂亮的贪心算法。
贪心算法还是很常见的算法之一,这是由于它简单易行,构造贪心策略不是很困难。可惜的是,它需要证明后才能真正运用到题目的算法中。
一般来说,贪心算法的证明围绕着:整个问题的最优解一定由在贪心策略中存在的子问题的最优解得来的。
对于例题中的3种贪心策略,都是无法成立(无法被证明)的,解释如下:
(1)贪心策略:选取价值最大者。反例:
W=30
物品:A B C
重量:28 12 12
价值:30 20 20
根据策略,首先选取物品A,接下来就无法再选取了,可是,选取B、C则更好。
(2)贪心策略:选取重量最小。它的反例与第一种策略的反例差不多。
W=30
物品:A B C
重量:28 12 12
价值:50 20 20
(3)贪心策略:选取单位重量价值最大的物品。反例:
W=30
物品:A B C
重量:28 20 10
价值:28 20 10
根据策略,三种物品单位重量价值一样,程序无法依据现有策略作出判断,如果选择A,则答案错误。

6.2 均分纸牌问题

有 N 堆纸牌,编号分别为 1,2,…, N。每堆上有若干张,但纸牌总数必为 N 的倍数。可以在任一堆上取若于张纸牌,然后移动。

移牌规则为:在编号为 1 堆上取的纸牌,只能移到编号为 2 的堆上;在编号为 N 的堆上取的纸牌,只能移到编号为 N-1 的堆上;其他堆上取的纸牌,可以移到相邻左边或右边的堆上。

现在要求找出一种移动方法,用最少的移动次数使每堆上纸牌数都一样多。

例如 N=4,4 堆纸牌数分别为:

① 9 ② 8 ③ 17 ④ 6

移动3次可达到目的:

从 ③ 取 4 张牌放到 ④ (9 8 13 10) -> 从 ③ 取 3 张牌放到 ②(9 11 10 10)-> 从 ② 取 1 张牌放到①(10 10 10 10)。
  
算法分析:设a[i]为第I堆纸牌的张数(0<=I<=n),v为均分后每堆纸牌的张数,s为最小移动次数。

我们用贪心算法,按照从左到右的顺序移动纸牌。如第I堆的纸牌数不等于平均值,则移动一次(即s加1),分两种情况移动:

1.若a[i]>v,则将a[i]-v张从第I堆移动到第I+1堆;

2.若a[i]<v,则将v-a[i]张从第I+1堆移动到第I堆。

为了设计的方便,我们把这两种情况统一看作是将a[i]-v从第I堆移动到第I+1堆,移动后有a[i]=v; a[I+1]=a[I+1]+a[i]-v.

在从第I+1堆取出纸牌补充第I堆的过程中可能回出现第I+1堆的纸牌小于零的情况。

例如:
n=3,三堆指派数为1 2 27 ,这时v=10,为了使第一堆为10,要从第二堆移9张到第一堆,而第二堆只有2张可以移,这是不是意味着刚才使用贪心法是错误的呢?
我们继续按规则分析移牌过程,从第二堆移出9张到第一堆后,第一堆有10张,第二堆剩下-7张,在从第三堆移动17张到第二堆,刚好三堆纸牌都是10,最后结果是对的,我们在移动过程中,只是改变了移动的顺序,而移动次数不便,因此此题使用贪心法可行的。

#include <iostream>
using namespace std;

int main() {
	int c[500];
	int n, sum = 0;
	cin >> n;
	for (int i = 0; i < n; i++) {
		cin >> c[i];
		sum += c[i];
	}
	int avg = sum / n;
	int count = 0;
	for (int i = 0; i < n; i++) {
		if (c[i] != avg) {
			c[i + 1] += c[i] - avg;
			count++;
		}
	}
	cout << count << endl;	
}

可以看到最核心的那个循环的思想是这样的:

从第一堆牌开始处理,如果第一堆牌整好是avg那么就放在一边不管了。

如果第一堆牌不是avg,那么就要把第二堆牌(合法的移动只有从2移到1,这也是这个算法的精髓之处)移动几张到第一堆,恰好使第一堆等于avg,从而只考虑第二堆开始到第N堆为止这些堆如何搞的子问题。然后依次递归下去。

这里的一个小技巧是认为牌数可以为负数,这样才能继续下去。综上,这个步骤是合理的。但是看不出来是最优的。可见,贪心法确实是比较容易实现,因为比较符合人类直觉,但是不好证明。

再反过来看一下前面提到的几点,可行性满足,不可取消,每一次操作都是直接赋值,局部最优,当前情况下,只能从右往左移动,且贪心地想尽快让第一堆满足约束。

至于为什么是最优解,(最少的步骤),要看这个问题到底是不是具有贪心选择性的。也就是看是不是全局最优解是由局部最优解产生的。对于这个事情,需要严格的数学证明才行。

http://www.zhihu.com/question/27883948 在知乎上问了这个问题。

6.3 货币选择问题

问题描述:分别有1,5,10,50,100元,分别有5,2,2,3,5张纸币。问若要支付k元,则需要多少张纸币?

问题分析:

我们只需要遵循“优先使用面值大的硬币”即可。

1.尽可能多的使用100元(即最大的);

2.余下部分尽可能多的使用50元;

3.余下部分尽可能多的使用10元;

4.余下部分尽可能多的使用5元;

5.余下部分使用1元;

#include <iostream>
#include <algorithm>
using namespace std;

const int N = 5;
int MCount[N] = { 5,2,2,3,5 };
int Value[N] = { 1,5,10,50,100 };

void moneyCount(int money) {
	int count = 0;
	int c;
	for (int i = N - 1; i >= 0; i--) {  //要使付钱的张数最少,所以从面值大的开始付
		c = min(money / Value[i], MCount[i]);  
		//将所需付的钱除以Value[i],可知要付多少张,如果张数小于当前拥有的张数,就直接先付多少张,
		//如果张数大于当前拥有的张数,那只能付当前拥有的张数,所以取两者的最小值。
		money -= c * Value[i];  //剩余要付的钱
		count += c;
		if (money <= 0) break;  //已经付完了可以提前退出
	}
	if (money > 0)
	{
		cout << "已经付了所有的钱,还是不够!" << endl;
	}
	else {
		cout << count << endl;
	}
}

int main() {
	int money;
	cin >> money;
	moneyCount(money);
	
}
6.4 区间调度的问题

问题描述:

有n项工作,每项工作分别在si时间开始,在ti时间结束。对于每项工作,你都可以选择参与与否.如果选择了参与,那么自始至终都必须全程参与,此外,参与工作的时间段不能重复(即使是开始的瞬间和结束的瞬间的重叠也是不允许的)。你的目标是参与尽可能多的工作,那么最多能参与多少项工作呢?
1≤n≤100000
1≤si≤ti≤10^9
输入:
第一行:n
第二行:n个整数空格隔开,代表n个工作的开始时间
第三行:n个整数空格隔开,代表n个工作的结束时间
样例输入:
5
1 2 4 6 8
3 5 7 9 10
样例输出:
3
说明:选取工作1,3,5

从题目中的最多的字眼可以知道这道题目考察的是贪心的问题,所以我们可以从贪心的思路来分析问题与解决问题。其中贪心的难点是如何想出一个策略使得在这种策略之下使得问题得到最优的解

对于这道问题我们可以这样想,先看看从时间开始比较早的来看一下是否满足题目的要求,对于这种区间的问题,最好在途中画出图来帮助我们更好地理解这个问题,
在这里插入图片描述
可以发现选择时间比较早的确实可以得到题目中的答案,但是这种方案是否为最优呢?我们不妨可以多举出几个例子来证明自己的猜想,在某些情况下,有的开始的时间比较早,但是结束的时间很晚,导致中间覆盖的区间很多,以至于整个区间覆盖过的区间都不能够选择,那么可以知道这种方案并不是最优的

此外我们也可以选择花费时间比较短的区间,但是举出几个例子之后发现也是不行的,因为有的区间虽然花费时间比较短但是它可能覆盖了好几个区间导致覆盖的区间也不能够选择,所以这个方案也不是最优的

还有一种方法是看结束时间,多举出几个例子发现这个方案的确是最优的,结束的时间越早,那么它可以选择的工作就相对来说是比较多的,确定较策略之后那么代码就不是特别难了,其中涉及到的是一些细节的问题

我们需要对所有的结束时间进行排序,选择最早结束的区间,然后选择出没有被这个区间覆盖的而且开始时间大于这个区间的结束时间的下一个区间

#include <iostream>
#include <utility>
using namespace std;
//输入
const int n = 8;
int S[n] = { 1,2,4,6,8,10,12,14 };
int T[n] = { 3,5,7,9,16,11,13,18 };

pair<int, int> itv[n];//对工作排序的pair数组
int solve()
{
	//对pair进行字典序比较
	//为了让结束时间早的工作排在前面,把T存入first,把S存入second
	for (int i = 0; i < n; i++) {

		itv[i].first = T[i];
		itv[i].second = S[i];
	}
	sort(itv, itv + n);

	int t = itv[0].first;  //选取结束的时间最早的工作
	int count = 1;//选取的结果
	for (int i = 1; i < n; i++) {
		if (t < itv[i].second) {  //选择出开始时间大于上一次的结束时间的区间,这样才不会覆盖
			{
				count++;
				t = itv[i].first;	//结束时间=当前选择工作的结束时间			
			}
			
		}
	}
	return count;
}

int main() {
	int k = solve();
	cout << k << endl;
	return 0;
}

pair的基本用法总结参考
https://blog.csdn.net/sevenjoin/article/details/81937695

6.5 最小字典序:

题目描述:
给定长度为N的字符串为S,要构造一个长度为N的字符串T。起初,T 是一个空串,随后反复进行下列任意操作。
①:从S的头部删除一个字符串,加到T的尾部,
②:从S的尾部删除一个字符,加到T的尾部
目标是要构造字典序尽可能小的字符串T。(字典序是指从前到后比较两个字符串大小的方法。首先比较第1个字符,
如果不同则第1个字符较小的字符串更小,如果相同则继续比较第2个字符…如此继续,来比较整个字符串的大小)
限制条件:
1<=N<=2000
字符串S只包含大写英文字母.
输入样例:
6
ACDBCB
输出样例:
ABCBCD

思路分析:
从字典序的性质来看,不管字符串T的尾部有多大,只要前面够小就可以,所以我们可以采用贪心算法
①:不断去开头和尾部中较小的一个字符放到T的尾部
②:针对开头和尾部字符相同的情况,我们希望能够尽早使用更小的字符,所以就要比较下一个字符的大小。下一个字符也有可能相同。
因此:
按照字典序比较S和将S反转后的字符串S’
如果S较小,就从S的开头取一个字符,追加到T尾部
如果S’较小,就从S的尾部取出一个字符,追加到T尾部。
如果相同则取哪个都可以。

#include <iostream>
#include <cstring>

using namespace std;
int N;
string sort(string s) {
	int start = 0;  //头指针
	int end = N - 1; //尾指针
	string t;
	int i = 0;
	while (start < end ) {  
		if (s[start] < s[end]) {   //头更小,加到T的尾部;
			//cout << s[start];
			t.push_back(s[start]);
			start++;
		}
		else {                    //尾更小,加到T的尾部;
			//cout << s[end];
			t.push_back(s[end]);
			end--;
		}
		i++;
	}
	//cout << s[start];
	t.push_back(s[end]);   //最后都一个,start==end,认为是头或尾都可以
	return t;
}

int main() {

	cin >> N;
	string s;
	cin >> s;	
	cout << sort(s);
}
  • 0
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值