浅谈贪心算法
浅谈贪心算法
贪心算法,指在问题求解时,每一步都做出“当前看起来最好的决策”。它没有固定的算法模板,灵活性强。在 OI 领域,无论是入门组,还是省选,NOI,或多或少都出过贪心题。可见贪心的重要性之大。
使用贪心算法解决问题,必须满足 “无后效性”。满足 “无后效性” 不一定当前的决策对后续选择绝对无影响,只要满足当前决策不会影响后续最优解的选择即可。反之,动态规划问题必须满足当前决策对后续决策绝对无影响。在动态规划问题中,我们开多层状态,主要目的在于满足无后效性。
诚然,贪心策略的选择是重要的,但结论的证明也不可忽视。下文将给出部分贪心常用证明方法。
证明
邻项交换法
此类题目往往需确定一个决策顺序,当交换两者决策顺序,不影响其他决策时,可使用邻项交换法确定决策顺序。
例题精讲
共有 \(n\) 个物品,每个物品有两个值,分别为 \(w_i,s_i\)。
你可以对这 \(n\) 个物品任意排序。每个物品的贡献 \(W_i=\sum\limits_{j=i+1}^n w_j-s_i\),最小化 $\max{W_i} $ 。
\(1\le n \le 5\times 10^4\)
Source
一看数据范围,排序题。大概率是按照某种策略进行排序然后模拟。
难点在于排序策略。当然多重贪心加上随机化有可能获得可观的分数。
我们用邻项交换的方式思考本题。因为不难发现,交换 \(i,i+1\) 物品后,不会对其他物品产生影响。
从上到下的物品贡献可以表示为 \(\sum\limits_{k=1}^{i-1}w_k-s_i\)。
下文记 \(sum=\sum\limits_{k=1}^{i-1}w_k\)。
交换前,两个物品的贡献分别为 \(sum-s_i,sum+w_i-s_{i+1}\)。
交换后则分别为 \(sum-s_{i+1},sum+w_{i+1}-s_i\)。
因此,当满足
交换可能会使答案更优。
下面的操作基于以下原则,需要记住。
\(\max(a,b)<c \Leftrightarrow a<c\text{且}b<c\)
\(\max(a,b)>c \Leftrightarrow a>c\text{或}b>c\)
\(\max(a+b,a+c)=a+\max(b,c)\)
因此,上式可以消去 \(sum\),得
也就得到了交换条件。
依据 \(\max(a,b)>c \Leftrightarrow a>c\text{或}b>c\),我们将上式拆开。
因为 \(s_i,w_i,s_{i+1},w_{i+1}\) 恒大于 \(0\),故下列条件一定满足。
证明还是依据 \(\max(a,b)>c \Leftrightarrow a>c\text{或}b>c\)。这里不再赘述。
前文提及,若交换条件
成立,则一定有
成立
前面已经证明,一式一定成立,我们只需关心第二个式子。
还是把它拆开,显然 \(w_{i+1}-s_i<-s_i\) 必定成立,我们只需关心 \(w_{i+1}-s_i<w_i-s_{i+1}\)。
移项,得。
证毕.
归纳法
数学归纳法
一般的,证明一个与正整数 \(n\) 有关的命题,可使用数学归纳法。具体步骤如下。
- (归纳奠基)证明当 \(n=n_0(n_0\in\operatorname{\N_{+}})\) 时成立。
- (归纳递推)以 “当 \(n=k(k\in \operatorname{\N_{+}},k\ge n_0)\) 时命题成立” 为条件,推出 “当 \(n=k+1\)” 时命题也成立。
由上述二步骤即可断定命题对 从 \(n_0\) 开始的所有正整数 \(n\) 都成立。
正确性. 由皮亚诺公理中 “归纳公理“ 可得,若某个性质在 \(0\) 成立,并且对于任意自然数 \(n\),若该性质在 \(n\) 成立,则它在 \(S(n)\) 也成立,那么该性质对于所有自然数都成立。事实上,数学归纳法和 “归纳公理” 本质相同,这也就意味着,数学归纳法作为一个公理,正确性是毋庸置疑的。
我们亦可这样理解,皮亚诺公理规定,任何一个自然数 \(i\) 有且仅有一个后继 \(i'\),既然 \(P(i)\Rightarrow P(i')\),且证得 \(P(s)\) 成立,则从 \(P(s)\) 可推得任意在区间内的 \(x\),命题 \(P\) 都成立。
Claim. 对于任意的自然数 \(n\),都有 \(1+2+3+4+\dots +n=\dfrac{n(n+1)}{2}\).
Proof.
最小的自然数为 \(1\),显然当 \(n=1\) 时命题成立.
设当 \(n=k(k\in \operatorname{\N})\) 时,命题成立,即得 \(1+2+3+4+\dots k=\dfrac{k(k+1)}{2}\),下面证明当 \(n=k+1\) 时命题成立,即证 \(1+2+3+4+\dots k+1=\dfrac{(k+1)(k+2)}{2}\).
命题成立.
即对于任意的自然数 \(n\),都有 \(1+2+3+4+\dots +n=\dfrac{n(n+1)}{2}\).
应用归纳法证明经典贪心算法
Kruskal 最小生成树算法
算法流程. 将所有候选边按照边权升序排序,依次决策。若加入该边,图上无环,则加入。否则跳过。当加入 \(n-1\) 条边后决策结束。时间复杂度 \(O(m\log m)\),其中 \(m\) 为边数。
一般的,我们认为,Kruskal 的时间复杂度只与边数 \(m\) 有关。这是一个和自然数有关的命题,考虑使用数学归纳法证明之。
Proof.
-
(归纳奠基)当 \(m=1\) 时显然成立.
-
(归纳递推)设当 \(m=k\) 时,执行 Kruskal 算法所取的边集为 \(T'\),还未选择的,且加入 \(T'\) 后不会形成环的,边权最小的边为 \(e\)。当 \(m=k+1\) 时,最小生成树为 \(T\)。下面证明 \(T=T'+e\)。
- 若 \(e\in T\),显然成立.
- 若 \(e\notin T\),将 \(e\) 放入边集 \(T\) 中,会形成环。环上必定存在至少一条边 \(f\) 不在 \(T'\) 中,我们分析 \(f\) 和 \(e\) 的关系.
- \(f<e\),执行 kruskal 算法时,\(f\) 应当在 \(e\) 之前被选。但 \(f\) 不应当在 \(T'\) 中(否则当前会形成环),矛盾.
- \(f>e\),此时,我们通过舍弃 \(f\),使用 \(e\) 可获得比原本 MST 更小的生成树。矛盾.
综上所述,\(f=e\)。命题 \(e\notin T\) 不成立.
综上,我们证明了执行 Kruskal 算法,每一步添加的边必定在 MST 中.
基于贪心思想的区间覆盖问题
区间完全覆盖问题
有 \(n\) 条线段,第 \(i\) 条线段覆盖区间 \([l,r]\),求至少需要多少条线段,覆盖区间 \([1,m]\)。
这是一个经典问题。我们采用贪心的策略解决,具体如下。
Greedy select. 令 \(pos\) 为当前已覆盖区间右端点,找到一条左端点 \(\le pos+1\),右端点最大的线段,并更新 \(pos\)。
Proof. 该策略每次选择的为合法的,右端点最大的线段。每次的选择能够将 \(pos\) 扩展到最大,即将已覆盖区间扩展到最大。
我们亦可从全局最优性证明。由于每次选择区间必须合法,显然,\(pos\) 越大,可选择的区间越多。故该贪心策略必定导致答案更优不劣。
Example.
namespace solution
{
#define x first
#define y second
typedef pair<int,int> PII;
vector <PII> line;
int n,m;
void solve()
{
cin>>n>>m;
for(int i=1;i<=n;i++)
{
int l,r;
cin>>l>>r;
line.push_back(PII(l,r));
}
sort(line.begin(),line.end());
int pos = 1,tot = 0,ans = 0;
while(pos < m)
{
int maxn = 0;
for(int i = tot;i < line.size();i++)
{
if(line[i].x <= pos + 1) maxn = max(maxn,line[i].y);
else
{
tot = i;
break;
}
}
pos = maxn;
ans ++;
}
cout<<ans<<"\n";
return;
}
}
给定 \(k\) 条线段,询问 \(q\) 次,每次询问最少用多少条线段覆盖区间 \([l,r]\)。
一个比较显然的策略是,对于每次询问,令 \(pos=l\) 执行上述 Greedy select。但复杂度并不理想。
Claim. 从 \(i\) 经线段 \([i,j]\) 跳到 \(j\) 后,执行 Greedy select 选择的线段和前面选择的必不重复。
Proof. 若从 \(j\) 贪心选择线段和前面重复,则前面可选择该线段以获得更大的 \(pos\)。矛盾。
因此,对于任意的 \(i\),执行 \(k\) 次 Greedy select 是 独立 的,不受前面选择影响。据此,我们预处理 \(f_{i,j}\) 表示 \(i\) 执行 \(2^j\) 次 Greedy select 扩展到的 \(pos\)。对于每次询问,从 \(l\) 倍增跳即可。
Example.
for(int i=1,j=1,maxn = 0;i<=n*2;i++)
{
while(j <= k && p[j].x <= i)
{
r = max(r,p[j].y+1);
j++;
}
f[0][i] = r;
}
for(int i=1;i<=20;i++)
for(int j=1;j<=n;j++) f[i][j] = f[i-1][f[i-1][j]];
给定一个长度为 \(n\) 的环,有 \(k\) 个区域被覆盖,求最少需要几条线段覆盖整个环。
Source:洛谷 P6902 [ICPC2014 WF] Surveillance
该问题和上面类似,只是将序列变为环。常见的处理思路是断环为链。具体的,将所有跨过 \(1\) 的线段 \([l,r]\) 视为 \([l,n+r]\)。一条线段 \([l,r]\) 跨过 \(1\) 当且仅当 \(l>r\)。
Example. paste
最大不相交区间数问题
数轴上有 \(n\) 个区间 \([a_i,b_i]\),选择尽可能多的区间,使得区间两两不相交。
Greedy select. 将所有区间按照右端点排序,若右端点相同,按照左端点排序。顺序选模拟即可。
Proof / Analysis. 考虑反证法。假设贪心算法执行完毕后,扔掉其选择的一段区间 \([u,v]\),可选择另外若干条新区间 \([a_1,b_1],[a_2,b_2]\dots\) 且不会对其他已选择区间造成影响。分类讨论新区间与 \([u,v]\) 的关系。
-
\([a_i,b_i]\subseteq [u,v]\),这种情形可推广为 \(b_i\le v\)。根据贪心算法,不可能。
-
\(b_i\ge v\) 前面提到,不可能存在 \(b_i\le v\)。故交换二者不可能更优。
证毕.
带需求约束的最小集合覆盖问题
在一条数轴上,给定若干区间 \([l_i,r_i]\),每个区间都有一个约束 \(k_i\),要求区间内 至少 有 \(k\) 个点被染色,求满足所有约束条件下,至少染色多少个点。
P1250 种树 P1986 元旦晚会 SP116 POI2015 KIN
CSP-S2024 超速检测
Greedy Select. 考虑将所有询问区间离线到数轴上。将所有区间按照右端点排序。若该区间已经满足条件,忽略。否则从右边开始,枚举没染色的点染色。我们一定尽可能希望给一个区间染色能影响下一个区间,故从右往左染色。实现时可使用树状数组加速。
Proof. 由于我们总是从每个区间的右端开始满足需求,对于每一个区间,我们在尽可能靠右的位置放置必要的点以满足它的需求。任何尝试减少点数的方法都会导致某些区间无法满足需求,因为去除任何点都会导致至少一个区间的覆盖需求不足。
Example.
void solve()
{
n = read<int>(),m = read<int>();
bit.clear();
ask.clear();
memset(tree,0,sizeof(tree));
for(int i=1;i<=m;i++)
{
int a,b,c;
a = read<int>(),b = read<int>(),c = read<int>();
a ++,b ++;
ask.push_back({a,b,c});
}
sort(ask.begin(),ask.end());
int res = 0;
for(auto [a,b,c]:ask)
{
int tot = bit.query(b) - bit.query(a-1);
if(tot >= c) continue;
for(int i = b;i>=a;i--)
{
if(tot >= c) break;
if(!tree[i])
{
tree[i] = 1;
bit.update(i,1);
tot ++,res ++;
}
}
}
print(res,'\n');
return;
}
反悔贪心
反悔贪心的思想在于,每次执行当前最优策略。若扫到后面有更优的,就撤销这次操作,选取更优的操作。
这样每次只需保证当前最优即可。下面给出部分例题。
CF865D Buy Low Sell High
已知接下来 \(n\) 天的股票价格,每天可以买进一股股票,卖出一股股票,或者什么也不做。求 \(n\) 天结束后的收益最大值。
\(2\le n\le 3\times 10^5\)。
Source
dp 并不好设计状态,考虑贪心。
考虑在第 \(i\) 天卖出时,我们一定期望买进的价格最小。显然该做法有局限性。若在第 \(i\) 天卖出时,第 \(j\) 天买进最优,但可能第 \(k\) 天卖出 \(j\) 更优,由局部最优解无法推出全局最优解。
在 [CSP-J2019] 纪念品 一题中,题目允许当天买进又卖出,而当天既买进又卖出不会影响答案。本题仍可利用该性质。假设已经在第 \(i\) 天,以 \(a_i\) 价格购进 \(1\) 股票,且在第 \(j\) 天以 \(a_j\) 价格卖出,获利 \(a_j-a_i\)。但有 \(a_k-a_i > a_j-a_i\)。
注意到 \(a_k-a_i=(a_k-a_j)+(a_j-a_i)\)。因此,第 \(j\) 天卖出同时,向堆里新插入 \(a_j\) 即可完成上述拼接。
[USACO09OPEN] Work Scheduling G | 用时一定模型
有 \(n\) 个工作,每个工作都花费 \(1\) 单位时间。工作时间从 \(0\) 时刻开始。共有 \(n\) 个工作。任意时刻,可选择任意工作完成。每个工作都有一个截止时间 \(d_i\),若能在截止时间内完成工作,可获利 \(p_i\)。求最大获利。
Source
对于此类时间限制问题,首先考虑将所有限制放到数轴上。从 \(0\) 时刻向右扫描数轴进行决策。
考虑模拟,当一个任务可在规定时间内完成时,就去做,累计贡献。当超出时间限制时,若 \(p_i>p_j(j<i)\),做 \(i\) 而不做 \(j\) 是更优的。毕竟同样消耗 \(1\) 单位时间,同时,当有多个 \(p_j < p_i\) 时,扔掉 \(p_j\) 最小的是最优的即可。又因为我们已经将所有任务映射到数轴上,即按照截止时间升序排序,故最多舍弃一个任务即可符合时间要求。用堆模拟即可。
void solve()
{
n = read<int>();
for(int i=1;i<=n;i++){int d,p;d = read<int>(),p = read<int>();edge.push_back(PII(d,p));}
sort(edge.begin(),edge.end());
int time = 0,res = 0;
for(auto [d,p]:edge)
{
if(time < d) {time ++;res += p;q.push(p);}
else if(q.size() && q.top() < p){res += (p - q.top());q.pop();q.push(p);}
}
print(res,'\n');
return;
}
[JSOI2007] 建筑抢修 | 价值一定模型
有 \(n\) 个任务,第 \(i\) 个任务需要做 \(t_i\) 时间,要求在 \(p_i\) 时刻前完成。求最多完成多少任务。
Source
和时间有关的决策,首先将决策对象映射到数轴上。即按照 \(p_i\) 将任务升序排序。
对每个任务进行决策,若当前时间 \(+t_i\le p_i\) 直接做任务即可。否则,若舍弃前面任务,做这个任务,不可能在当前使答案更优,且最多舍弃一个。若舍弃多个无法保证正确性。
但我们期望,在做的任务数相同情况下,当前时间最小。因此,使用堆存储 已经做的任务,需要花费的时间 \(t_i\)。当前任务时间不够时,尝试舍弃花费时间最大的任务。若合法,全且舍弃会使当前时间变小,则舍弃,否则不做当前任务。
void solve()
{
n = read<int>();
for(int i=1;i<=n;i++){int t1,t2;t1 = read<int>(),t2 = read<int>();edge.push_back(PII(t2,t1));}
sort(edge.begin(),edge.end());
int time = 0,tot = 0;
for(auto [t2,t1]:edge)
{
if(time + t1 <= t2) {time += t1;q.push(t1);tot ++;}
else if(q.top() > t1 && time - q.top() + t1 <= t2){time = time - q.top() + t1;q.pop();q.push(t1);}
}
print(tot,'\n');
return;
}
总结:价值一定,贪时间最小。时间一定,贪价值最大,都需要保证当前状态不变。
小Z的AK计划 | 价值一定模型拓展 & 逆向处理
数轴上有 \(n\) 个点,保证坐标大于 \(0\)。从原点向右扫描,速度为 \(1\) 单位每秒。当扫到点 \(i\) 时,可选择花费 \(t_i\) 时间获得 \(1\) 贡献。也可以选择继续扫描。求在 \(m\) 秒时间内,最多获得多少贡献。
我们认为,若当前时间 \(>m\),立即停止扫描。若有当前正在进行的任务,立即停止,且不会获得贡献。
Source
既然每个物品价值一致,花费代价不一。我们肯定希望尽可能做代价小的,但这不好操作。
正难则反。不妨在时间限制范围内,每个物品都去做。当时间超限时,从代价大的物品开始取消,直到时间符合限制,再继续扫描。
上述操作维护了对于每个端点 \(i\),从 \(0\) 走到 \(i\) 在时间范围内可获得的最大价值。因此,我们得一路取 \(\max\)。
void solve()
{
n = read<int>(),m = read<int>();
for(int i=1;i<=n;i++){int x,t;x = read<int>(),t = read<int>();node.push_back(PII(x,t));}
sort(node.begin(),node.end());
int time = 0;
int lst = 0,tot = 0,maxn = 0;
for(auto [x,t]:node)
{
time += x - lst,lst = x;
time += t,tot ++,q.push(t);
while(time > m && q.size()){time -= q.top();tot --;q.pop();}
if(time > m) break;
maxn = max(maxn,tot);
}
print(maxn,'\n');
return;
}
[国家集训队] 种树
给定一个有 \(n\) 个点的环,点 \(i\) 具有 \(a_i\) 的贡献。选择一个点 \(i\) 后,与其相邻的点都不可选。要求选择 \(m\) 个点,求最大贡献,或报告无解。
Source
考虑一个很 naive 的贪心,将所有点按照价值降序排序,然后模拟。这么做会有问题,可能出现 \(a_{i-1}+a_{i+1} >a_i\) 情形。
这也容易反悔,选择 \(i\) 后,将 \(i+1,i-1\) 标记为删除。此时 \(i+1,i-1\) 一起选仍是合法方案,故在 \(i\) 的位置新插入一个节点,权值为 \(a_{i+1},a_{i-1}\)。删除点用链表维护即可。
双倍经验:种树。
void solve()
{
n = read<int>(),k = read<int>();
for(int i=1;i<=n;i++) {int t;t = read<int>();q.push(PII(t,i));a[i] = t;}
for(int i=1;i<=n;i++) {pre[i] = i-1;nxt[i] = i+1;}
while(k--)
{
while(vis[q.top().y]) q.pop();
auto now = q.top();ans += now.x;q.pop();
vis[pre[now.y]] = vis[nxt[now.y]] = 1;
now.x = a[pre[now.y]] + a[nxt[now.y]] - now.x,a[now.y] = now.x;
nxt[now.y] = nxt[nxt[now.y]],pre[now.y] = pre[pre[now.y]];
nxt[pre[now.y]] = now.y,pre[nxt[now.y]] = now.y;
q.push(now);
maxn = max(maxn,ans);
}
print(maxn,'\n');
return;
}
本文作者:SXqwq,转载请注明原文链接:https://www.cnblogs.com/SXqwq/p/18542720