反悔贪心

前置知识:

反悔贪心,顾名思义,就是在朴素贪心的基础上加上【反悔】操作,做增量更新,以修正答案。

反悔贪心的模板操作可以看前三道例题。

例题

题目 备注
P2949 [USACO09OPEN] Work Scheduling G 存在非反悔贪心解法,本身也很板子,可以想一想
iai617 生存游戏 同 P2949
CF865D Buy Low Sell High 反悔贪心模板题
CF730I Olympiad in Programming and Sports 比较明显的反悔贪心,用三个堆实现
iai69 火车 稍微难想一点的反悔贪心,可以堆/ set / map 实现
P1792 [国家集训队] 种树 很难想的反悔贪心,需要用到双向链表
P3620 [APIO/CTSC2007] 数据备份 P1792 的兄弟题

P2949 [USACO09OPEN] Work Scheduling G

可能第一眼看到要求最值会联想到 dp,但很快就会发现,dp 的状态很难表示,而且有后效性,维度太多复杂度又吃不住,考虑贪心。
这里有两种贪心的方法。

解法 1:反向扫描,纯粹的贪心

把问题变一下:从某个时刻 \(D_i\) 开始能够做一项任务,完成会有 \(P_i\) 的收益。

这样贪心思路就很明确了,每次都挑手上能做的、收益最高的那一个任务即可,不用考虑需不需要给后面任务留时间的问题,因为后面的任务还没有开始。

当前手上收益最高的任务,可以用堆维护。

AC 代码提交记录

#include <bits/stdc++.h>
#define ll long long

using namespace std;

const int MAXN=1e5+5;
int n;
ll ans;

struct node {
	ll d, p;
} tsk[MAXN];

priority_queue <ll, vector<ll>, less<ll> > q;

int main()
{
//	freopen("P2949_3.in", "r", stdin);
	cin >> n;
	for (int i = 1; i <= n; ++i) {
		cin >> tsk[i].d >> tsk[i].p;
	}
	tsk[++n] = {0, 0};
	sort(tsk+1, tsk+n+1, [](node a, node b){return (a.d>b.d)||(a.d==b.d&&a.p>b.p);});
	q.push(tsk[1].p);
	ll j = tsk[1].d;
	for (int i = 2; i <= n; ++i) {
		j = min(j, tsk[i-1].d);
		while ( (!q.empty()) && (j > tsk[i].d) ) {
			ans+=q.top(); q.pop(); --j;
		}
		q.push(tsk[i].p);
	}
	cout << ans << endl;
}

解法 2:正向扫描,反悔贪心

正向扫描需要解决的最大问题就是:需不需要给后面任务留时间。比如对于这组数据:

4
1 5
2 3
3 7
3 4

最大收益是 \(5+7+4=16\),在这里就是把 \(i=2\) 的那一个任务放弃,把时间留给 \(i=4\) 的那一个任务了。

我们可以利用反悔贪心的思想来考虑这个问题。
也就是说,讲任务按照时间从前往后排序之后,每次碰到一个任务,就先接手开干。然后考虑:

  • 如果新遇到的任务和已有的任务时间不冲突,那么就接受。
  • 如果冲突了,那么,和手上收益最低的那个任务比较收益谁更大,考虑用新任务替换是不是赚,并且对收益做增量更新。

这里需要维护已有任务的最低收益,可以用堆。

AC 代码提交记录

#include <bits/stdc++.h>
#define ll long long

using namespace std;

const int MAXN=1e5+5;
int n;
long long ans;

struct node {
	ll d, p;
} tsk[MAXN];

priority_queue <ll, vector<ll>, greater<ll>> q;

int main()
{
//	freopen("P2949_3.in", "r", stdin);
	cin >> n;
	for (int i = 1; i <= n; ++i) { cin >> tsk[i].d >> tsk[i].p; }
	sort(tsk+1, tsk+n+1, [](node a, node b){return a.d<b.d;});
	for (int i = 1; i <= n; ++i) {
		if (tsk[i].d > q.size()) {
			ans += tsk[i].p;
			q.push(tsk[i].p);
		} else if (tsk[i].p > q.top()) {
			ans += tsk[i].p-q.top();
			q.pop();
			q.push(tsk[i].p);
		}
	}
	cout << ans << endl; return 0;
}

iai617 生存游戏

P2949 [USACO09OPEN] Work Scheduling G

AC 代码提交记录

#include <bits/stdc++.h>

using namespace std;

const int MAXN=2e5+5;
int n, d, ans;
long long c, x[MAXN], a[MAXN];

priority_queue <long long> q;

int main()
{
	cin >> n >> c >> d;
	for (int i = 1; i <= n; ++i) { cin >> x[i] >> a[i]; }
	x[++n] = d;
	for (int i = 1; i <= n; ++i) {
		c -= x[i]-x[i-1];
		if (c < 0) {
			while (!q.empty() && c < 0) {	c += q.top(); q.pop(); ++ans; }
			if (c < 0) { cout << "Impossible" << endl; return 0; }
		}
		q.push(a[i]);
	}
	cout << ans << endl;
	return 0;
}

CF865D Buy Low Sell High

反悔贪心模板题。

假设现在是第 \(i\) 天,对于前 \(i-1\) 天【尚未购买】的股票,将其加入【代购清单】。
当前的最优策略是:从代购清单中选择价格最小的那一天买入,并在第 \(i\) 天卖出。

但局部的最优解不一定是全局最优解,比如下面这组数据就可以卡掉普通贪心:

4
1 2 100 100

但我们可以发现的是,虽然题目说一天只能进行一次操作,但在一天内买入和卖出的操作是可以完美抵消的,也就是【做了跟没做一样】。

所以,我们可以【先买着】,等发现更优的情况再【反悔】。

具体而言,如果现在是第 \(i\) 天,我在第 \(j\ (j<i)\) 天贪心地选择买入,而在第 \(i\) 天卖出。但我只将第 \(j\) 天的价格移出代购清单,而在代购清单中加入两遍第 \(i\) 天的价格。

这样一来,如果我选择反悔,那么只需要在第 \(i\) 天再卖出,一买一卖完美抵消,对答案没有影响。

如果我在后续第 \(k\ (i<k)\) 天卖出第 \(i\) 天的股票,由于代购清单中加入了两遍第 \(i\) 天的价格,同样不影响答案。

比如,对于上述的数据:

  • 在第一天无法卖出,将 1 加入代购清单。
    • 【此时的代购清单:1,收益为 0 元。】
  • 第二天的价格大于第一天,所以先买着,将 1 移出代购清单,再往里面加入两个 2
    • 【此时的代购清单为:2, 2,收益为 1 元,即第一天买入,第二天卖出。】
  • 第三天的价格大于第二天,也大于第一天。此时可以反悔上一步的操作,将一个 2 移出清单,收益加上 100-2,将两个 100 加入清单。
    • 【此时代购清单:2, 100, 100,收益为 99 元,即第一天买入,第三天卖出。】
  • 第四天价格大于第二天,可以在第二天买入,第四天卖出。(如果选择反悔或在第三天买入,那么额外收益为 0)
    • 【此时的代购清单:100, 100, 100, 100,收益为 197 元,即第一、二天买入,第三、四天卖出。】
  • 最终答案即为 197 元。

AC 代码提交记录

#include <bits/stdc++.h>
#define ll long long

using namespace std;

int n;
long long p, ans;

priority_queue <ll, vector<ll>, greater<ll> > q;

int main()
{
	cin >> n;
	for (int i = 1; i <= n; ++i) {
		cin >> p;
		if ( (!q.empty()) && (p > q.top())) { ans += p-q.top(); q.pop(); q.push(p); }
		q.push(p);
	}
	cout << ans << endl;
	return 0;
}

CF730I Olympiad in Programming and Sports

先把编程能力最大的 \(p\) 个人丢到编程团队中,然后通过反悔贪心修改选择。
有两种情况:

  • 有一个新人,加入了体育团队;
  • 有一个原来在编程团队中的人,加入了体育团队,然后从新人中挑了一个加入编程团队。

因此需要维护 3 个堆(下文用 \(q.top().x\) 表示堆顶表示的人的能力值):

  • \(q_1\)\(q_2\) 分别维护 【新人】 的编程能力 \(a_i\) 和体育能力 \(b_i\)
  • \(q_3\) 维护 【已经加入编程团队的人】 通过反悔加入体育团队后对答案的修改量 \(b_i-a_i\)

在每次选择开始前,先确定堆中的人员是否有变动,比如 \(q_1\)\(q_2\) 中的人有没有加入任一团队、\(q_3\) 中的人有没有反悔加入体育团队。

比较上文所说两种选择对应对答案的贡献 【 \(q_2.top().x\) 】 和 【 \(q1.top().x+q3.top().x\) 】,取较大者计入答案后更改人员所属队伍即可。

AC 代码提交记录

#include <bits/stdc++.h>

using namespace std;

const int MAXN=3005;
int n, p, s, a[MAXN], b[MAXN], team[MAXN], ans;

struct node {
	int x, id;
	bool operator < (const node &a) const
	{
		return x < a.x;
	}
};

priority_queue <node> q1, q2, q3;

int main()
{
//	freopen("CF730I_39.in", "r", stdin);
	cin >> n >> p >> s;
	for (int i = 1; i <= n; ++i) { cin >> a[i]; q1.push( {a[i],i} ); }
	
	// 将编程能力最强的人先加入队伍
	for (int i = 1; i <= p; ++i) {
		ans += q1.top().x;
		team[q1.top().id] = 1;
		q1.pop();
	}
	
	// 一开始被选上的人加入反悔堆q3,未被选上加入体育组待选堆q2
	for (int i = 1; i <= n; ++i) {
		cin >> b[i];
		if (team[i]) { q3.push( {b[i]-a[i],i} ); }
		else         { q2.push( {b[i],i}); }
	}

	// 选择体育组
	for (int i = 1; i <= s; ++i) {
		while (team[q1.top().id]) { q1.pop(); }
		while (team[q3.top().id] == 2) { q3.pop(); }
		while (team[q2.top().id]) { q2.pop(); }
		if (q2.top().x > q3.top().x+q1.top().x) {
			// 选择新人加入体育组
			ans += q2.top().x;
			team[q2.top().id] = 2;
			q2.pop();
		} else {
			// 反悔,把编程组的人拉到体育组来
			ans += q3.top().x + q1.top().x;
			team[q3.top().id] = 2; q3.pop();
			team[q1.top().id] = 1;
			q3.push( {b[q1.top().id]-a[q1.top().id],q1.top().id} ); q1.pop();
		}
	}
	
	// 输出
	cout << ans << endl;
	for (int i = 1; i <= n; ++i) { if (team[i]==1) { cout << i << " "; } }
	cout << endl;
	for (int i = 1; i <= n; ++i) { if (team[i]==2) { cout << i << " "; } }
	return 0;
}

iai69 火车

有一个比较显然的贪心思路,就是目的地较近的乘客,优于目的地较远的乘客。

如果 \(x\) 名乘客在 \(i\) 下车,又有 \(x\) 名乘客在 \(j\ (i<j)\) 下车,当这 \(x\) 名乘客在 \(i\) 下车了之后,就可以在 \(i\) 处留出 \(x\) 个空位以供选择。

  • 如果这 \(x\) 个位置接着留空,结果其实选 \(i\) 和选 \(j\) 是一样的;
  • 如果这 \(x\) 个位置上有人在到达 \(j\) 以前有人到达了目的地,那么选 \(i\) 就赚了。

所以说,空位必然优先留给目的地较近的乘客。

考虑反悔贪心。

每到一站,先让目的地在这里的乘客下车,计入答案。

接下来我们可以假想,把车上剩余的未到其目的地的乘客都【赶】下车,再重新选择。

对于尚未到达目的地的乘客,无论在哪一站被【赶】下车,其结果都是没有计入答案,等价于一开始没有选择。

  • 如果此时乘客数量没有超过上限,就把所有乘客再拉上车;
  • 如果此时乘客数量超过上限了,就把目的地最远的那一批乘客赶下车。

实现方面,可以开一个堆记录当前车上的乘客的目的地,做懒删除。

另外的写法是用 map 替代 priority_queue,既可以求出最远的车站,又方便修改到达终点站下车的那些乘客。

以下给出一种基于堆的实现。

AC 代码提交记录

#include <bits/stdc++.h>

using namespace std;

const int MAXN=1e5+5;
int n, s, t, c;
long long cnt[MAXN], sum, ans;
bool pushed[MAXN];

priority_queue <int> q;

int main()
{
	cin >> n >> s;
	for (int i = 1; i < n; ++i) {
		cin >> t >> c;

		// 下车
		sum -= cnt[i];
		ans += cnt[i];
		cnt[i] = 0;

        // 上车
		if (!pushed[t]) { q.push(t); pushed[t]=true; }
		cnt[t] += c;
		sum += c;
		
		// 反悔,把最远的乘客赶下车
		while (sum > s) {
			while (!cnt[q.top()]) { pushed[q.top()]=false; q.pop(); }
			long long d = min(sum-s, cnt[q.top()]);
			sum -= d;
			cnt[q.top()] -= d;
			if (cnt[q.top()] == 0) { pushed[q.top()]=false; q.pop(); }
		}
	}
	cout << ans+sum << endl;
}

P1792 [国家集训队] 种树

为了方便表述,这里用 \(pre[i]\) 表示 \(i\) 的上一棵树、\(nxt[i]\) 表示 \(i\) 的下一棵树。

看到这道题,第一眼想到的是 dp,和打家劫舍很像,但这道题额外限制了树的数量,需要 dp 方程额外增加一维,复杂度直接爆炸。

考虑贪心。
首先,每次都选择美观度最大的那一棵树,可想而知是错误的,比如下面这组数据:

5 2
-1 2 3 2 -1

最优选择是 2+2=4

在这个例子中,我们用 3 左右的两棵美观度为 2 的树将其替换掉了。

可想而知:由于每次都选择了美观度最大的那棵树 \(i\),那么【只用】它左边的树 \(pre[i]\)、或【只用】它右边的树 \(nxt[i]\) 进行替换,结果都不可能是最优。
换句话说,如果一棵树 \(i\) 的美观度比它左边的树 \(pre[i]\) 大、也比它右边的树 \(nxt[i]\) 大,在左右两棵树至少有一棵不能选的情况下,\(i\) 这棵树必然在待选清单中。

【因此,如果每次贪心地种下一棵美观度最高的树,结果只有两种:要么这棵树在最终的选择中,要么这棵树被同时替换为了左右两边两棵树。】

考虑反悔贪心,每次都贪心地选择美观度最大的那一棵树 \(i\),暂时将其加入待选清单。
接下来我们要做的事情就是,明确一种方式进行反悔,【将选择的树 \(i\) 从待选清单中移除,并将其左右两棵树 \(pre[i]\)\(nxt[i]\) 加入待选清单】

这里可以做增量更新。
【每当暂时选择了 \(i\) 时,将 \(i\)\(pre[i]\)\(nxt[i]\) 三棵树合在一起,放在 \(i\) 这棵树的位置,美观度为 \(A_{pre[i]}+A_{nxt[i]}-A_i\) 。】

这样一来,如果我再次选择了 \(i\) 位置上的树,两次选择的美观度之和为 \(A_i+A_{pre[i]}+A_{nxt[i]}-A_i=A_{pre[i]}+A_{nxt[i]}\)。这两次选择的结果也就等价于:【没有选择 \(i\),而是选择了 \(pre[i]\)\(nxt[i]\)

就结果而言,既然现在选择了 \(pre[i]\)\(nxt[i]\),那么 \(pre[pre[i]]\)\(nxt[nxt[i]]\)\(i\) 这三棵树是不能选择的。

由于在上一步中,我们将 \(pre[i]\)\(i\)\(nxt[i]\) 三棵树合成了一棵新的树 \(j\),因此撇去上述三棵不能选择的树、并且保留反悔机会的操作,也就等价于:【对于这棵新的树 \(j\),将 \(pre[j]\)\(j\)\(nxt[j]\) 三棵树合在一起,美观度为 \(A_{pre[j]}+A_{nxt[j]}-A_j\)

这样一来,反悔贪心的思路就理清了:

  1. 选择一棵美观度最大的树 \(i\),将 \(A_i\) 计入答案,计数器+1;
  2. \(pre[i]\)\(nxt[i]\) 标记为不可选择;
  3. \(i\) 的位置上放一棵假想的树 \(j\),下列所有的更改,都在 \(i\) 的位置上进行:
    1. \(j\) 的美观度 \(A_j\) 更新为 \(A_{pre[i]}+A_{nxt[i]}-A_i\)
    2. 更新双向链表中的 \(pre[j]\)\(nxt[j]\)\(pre[nxt[j]]\)\(nxt[pre[j]]\) 四项;
  4. 重复上述三步,直到计数器达到了规定的数量 \(m\),输出答案。

AC 代码提交记录

#include <bits/stdc++.h>

using namespace std;

const int MAXN=2e5+5;
int n, m, a[MAXN], ans, pre[MAXN], nxt[MAXN];
bool fbd[MAXN];

struct node {
	int id, a;
	bool operator < (const node &x) const
	{
		return a < x.a;
	}
};

priority_queue <node> q;

void del(int id)
{
	fbd[pre[id]] = fbd[nxt[id]] = true;
	pre[id] = pre[pre[id]];
	nxt[id] = nxt[nxt[id]];
	nxt[pre[id]] = id;
	pre[nxt[id]] = id;
}

int main()
{
//	freopen("P1792_1.in", "r", stdin);
	cin >> n >> m;
	if (n < 2*m) { cout << "Error!" << endl; return 0; }
	for (int i = 1; i <= n; ++i) {
		cin >> a[i];
		q.push( (node){i,a[i]} );
		pre[i] = (i==1? n: i-1);
		nxt[i] = (i==n? 1: i+1);
	}
	for (int i = 1; i <= m; ++i) {
		while (fbd[q.top().id]) { q.pop(); }
		node c = q.top(); q.pop();
		ans += c.a;
		a[c.id] = a[pre[c.id]] + a[nxt[c.id]] - a[c.id];
		q.push( (node){c.id, a[c.id]} );
		del(c.id);
	}
	cout << ans << endl;
}

P3620 [APIO/CTSC2007] 数据备份

显然,相邻办公楼之间相连,必然优于比非相邻办公楼相连。

类似差分,记录下所有 \(d_i=x_i-x_{i-1}\) 的值,然后做法同 P1792

需要注意的是,由于要求的是最小值,并且不在办公楼不在环上,所以需要设 \(d_1=d_{n+1}=+\infty\),保证不会选择使用 \(d_1+d_3\) 替换 \(d_2\),或者用 \(d_{n-1}+d_{n+1}\) 替换 \(d_n\)

AC 代码提交记录

#include <bits/stdc++.h>
#define ll long long

using namespace std;

const int MAXN=1e5+5;
int n, k, pre[MAXN], nxt[MAXN];
ll x[MAXN], val[MAXN], ans;
bool vis[MAXN];

struct node {
	int id;
	long long val;
	bool operator < (const node &x) const
	{
		return val > x.val;
	}
};

priority_queue <node> q;

void del(int id)
{
	vis[pre[id]] = vis[nxt[id]] = true;
	pre[id] = pre[pre[id]];
	nxt[id] = nxt[nxt[id]];
	pre[nxt[id]] = id;
	nxt[pre[id]] = id;
}

int main()
{
//	freopen("P3620_2.in", "r", stdin);
	cin >> n >> k;
	for (int i = 1; i <= n; ++i) { cin >> x[i]; }
	for (int i = 2; i <= n; ++i) {
		pre[i] = i-1;
		nxt[i] = i+1;
		val[i] = x[i] - x[i-1];
		q.push( (node){i, val[i]} );
	}
	val[1] = val[n+1] = 0x3f3f3f3f;
	for (int i = 1; i <= k; ++i) {
		while (vis[q.top().id]) { q.pop(); }
		node c = q.top(); q.pop();
		ans += c.val;
		val[c.id] = val[pre[c.id]] + val[nxt[c.id]] - val[c.id];
		q.push( (node){c.id,val[c.id]} );
		del(c.id);
	}
	cout << ans << endl; return 0;
}
posted @ 2023-10-05 17:47  LittleDrinks  阅读(22)  评论(0编辑  收藏  举报