三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

CQUPT 2025级 数据科学与大数据技术英才班 周测#14

CQUPT 2025级 数据科学与大数据技术英才班 周测#14

CQUPT 2025级 数据科学与大数据技术英才班 周测#14

题目目录

题号 题目 主要模型
A P2240 部分背包问题 单位价值排序贪心
B P1208 混合牛奶 单价排序贪心
C P1223 排队接水 排序贪心、交换论证
D P1803 凌乱的 yyy / 线段覆盖 区间贪心
E P3817 小 A 的糖果 从左到右局部修正
F P1090 合并果子 优先队列贪心

A. P2240 部分背包问题

一、题目大意

有 (N) 堆金币,第 (i) 堆金币:

  • 总重量为 (m_i);
  • 总价值为 (v_i)。

背包最多装重量 (T)。

与普通 01 背包不同的是:

每一堆金币都可以任意分割。

也就是说,可以只拿某一堆金币的一部分,并且分割之后单位重量的价值不会改变。

要求求出背包最多能够装走多少价值的金币,答案保留两位小数。

题目中:

[
N\le100,\qquad T\le1000
]

且 (1\le m_i,v_i\le100)。


二、题解前关键信号识别

这道题最重要的一句话不是:

有一个容量有限的背包。

而是:

金币可以任意分割。

如果一个物品可以被拆分,那么我们不再需要纠结:

这一堆到底拿还是不拿?

而应该考虑:

每占用 1 单位背包容量,哪堆金币能带来更多价值?

因此对于第 (i) 堆金币,计算:

[
\frac{v_i}{m_i}
]

也就是:

单位重量价值。

显然,背包容量应该优先留给单位价值更高的金币。

所以第一反应应该是:

计算单位价值↓
按照单位价值从大到小排序↓
依次尽可能多拿

这就是最经典的:

部分背包贪心。


三、数据规模与复杂度判断

(N\le100),数据规模非常小。

即使进行排序:

[
O(N\log N)
]

也完全没有问题。

排序以后只需要扫描一次:

[
O(N)
]

所以总时间复杂度:

[
O(N\log N)
]

空间复杂度:

[
O(N)
]


为什么不需要动态规划?

如果是 01 背包:

每件物品只能全部拿或者全部不拿。

那么:

单位价值最高

不一定意味着应该优先拿。

但本题允许任意分割。

假设有两堆金币:

A:1 千克价值 10
B:1 千克价值 5

如果背包只剩 0.3 千克容量:

拿 A 的 0.3 千克 → 价值 3
拿 B 的 0.3 千克 → 价值 1.5

无论剩余多少容量,单位价值更高的金币始终更优。

所以可以直接贪心。


四、核心思路

对于每堆金币记录:

m   // 总重量
v   // 总价值
p   // 单位重量价值

其中:

[
p=\frac vm
]

然后按照:

p 从大到小

排序。


情况 1:当前整堆金币都装得下

如果:

m[i]<=T

那么全部装入:

T-=m[i];
ans+=v[i];

情况 2:当前整堆装不下

假设背包还剩:

T

单位价值是:

p[i]

那么可以拿价值:

[
T\times p_i
]

即:

ans+=T*p[i];

背包已经装满,可以结束。


五、参考代码

#include<bits/stdc++.h>
using namespace std;const int N=110;struct Gold
{int m,v;double p;
}a[N];int n,t;bool cmp(Gold x,Gold y)
{return x.p>y.p;
}int main()
{scanf("%d%d",&n,&t);for(int i=1;i<=n;i++){scanf("%d%d",&a[i].m,&a[i].v);a[i].p=1.0*a[i].v/a[i].m;}sort(a+1,a+n+1,cmp);double ans=0;for(int i=1;i<=n;i++){if(t>=a[i].m){t-=a[i].m;ans+=a[i].v;}else{ans+=t*a[i].p;t=0;break;}}printf("%.2lf\n",ans);return 0;
}

六、错因回溯

错误 1:按照总价值排序

例如:

A:重量 100,价值 100
B:重量 1,价值 50

如果只看总价值:

A 更大。

但单位价值:

[
A=1,\qquad B=50
]

显然应该优先拿 B。

因此本题真正比较的是:

单位容量能够带来的收益。


错误 2:按照重量从小到大排序

轻不代表价值高。

例如:

A:重量 1,价值 1
B:重量 2,价值 100

显然不能只因为 A 更轻就优先拿 A。


错误 3:把它当成 01 背包

看到:

背包
容量
价值

就直接想到动态规划,是很常见的惯性思维。

但首先应该检查:

物品能不能分割?

本题明确可以任意分割,因此性质完全不同。


错误 4:整数除法

错误:

a[i].p=a[i].v/a[i].m;

如果:

v=3
m=2

整数除法结果会变成:

1

而不是:

1.5

应该写:

a[i].p=1.0*a[i].v/a[i].m;

七、边界易错点

1. 最后一堆只拿一部分

不能因为整堆放不下就直接跳过。

这恰恰是本题与 01 背包最大的区别。

2. 背包可能装不满所有金币

一旦容量变成 0:

break;

即可。

3. 输出两位小数

printf("%.2lf\n",ans);

4. 即使所有金币都能装下

循环自然会把全部价值累加,不需要特殊处理。


八、下次触发信号

以后看到:

有容量限制

物品可以任意切割

切割以后单位收益不变

应该立刻想到:

单位收益
=
价值 / 消耗

然后:

单位收益最高的优先

核心触发信号:

可以分割 + 容量有限 + 最大化收益 = 按单位价值贪心。

同时要牢记:

部分背包 → 贪心01 背包 → 通常不能这样贪

B. P1208 混合牛奶

一、题目大意

奶制品公司每天需要采购 (n) 单位牛奶。

有 (m) 个奶农,第 (i) 个奶农:

  • 每单位牛奶价格为 (p_i);
  • 最多能够提供 (a_i) 单位牛奶。

可以向一个奶农购买:

0 ~ a[i]

之间任意整数数量的牛奶。

题目保证所有奶农总供应量足够满足公司的需求,要求求出满足需求所需要的最小费用。

数据范围中:

[
m\le5000
]

需求量和单个奶农供应量最大为 (2\times10^6),牛奶单价最大为 1000。


二、题解前关键信号识别

本题最直接的问题是:

同样购买 1 单位牛奶,从谁那里买最划算?

答案显然是:

价格最低的奶农。

因此:

便宜的牛奶

应该尽可能先买。

只有便宜的奶农已经没有牛奶了,才有必要向更贵的奶农购买。

所以:

按单价从小到大排序↓
优先把最便宜的买完↓
再买第二便宜的↓
直到满足需求

三、数据规模与复杂度判断

奶农数量:

[
m\le5000
]

排序复杂度:

[
O(m\log m)
]

之后扫描一次:

[
O(m)
]

因此总复杂度:

[
O(m\log m)
]

空间复杂度:

[
O(m)
]

完全可以接受。


四、核心思路

按牛奶单价:

p[i]

从小到大排序。

定义:

need

表示还需要多少牛奶。


情况 1:当前奶农的牛奶全部需要

如果:

need>=a[i]

那么全部购买:

ans+=1LL*p[i]*a[i];
need-=a[i];

情况 2:只需要当前奶农的一部分

如果:

need<a[i]

只买:

need

单位即可:

ans+=1LL*need*p[i];
need=0;

完成采购。


为什么这样一定最优?

假设当前有:

A 奶农:3 元/单位
B 奶农:5 元/单位

一个方案中出现了:

还有 A 的牛奶可以买
却先购买了 B 的牛奶

那么把购买 B 的一单位牛奶替换成 A:

费用减少 2

而牛奶总量不变。

所以任何最优方案中:

更贵的牛奶被购买之前,所有更便宜且需要的供应都应该先被使用。

因此按价格从低到高购买一定最优。


五、参考代码

#include<bits/stdc++.h>
using namespace std;
typedef long long LL;const int N=5010;struct Farmer
{int p,a;
}f[N];int n,m;bool cmp(Farmer x,Farmer y)
{return x.p<y.p;
}int main()
{scanf("%d%d",&n,&m);for(int i=1;i<=m;i++)scanf("%d%d",&f[i].p,&f[i].a);sort(f+1,f+m+1,cmp);LL ans=0;int need=n;for(int i=1;i<=m&&need>0;i++){int buy=min(need,f[i].a);ans+=1LL*buy*f[i].p;need-=buy;}printf("%lld\n",ans);return 0;
}

六、错因回溯

错误 1:按照供应量排序

供应多,并不代表便宜。

假设:

A:1000 单位,10 元
B:10 单位,1 元

即使 B 的供应量很少,也一定应该优先购买。


错误 2:按照总价 p*a 排序

我们可以购买奶农的一部分牛奶。

真正决定每多买一单位牛奶成本的是:

p

而不是:

p*a

错误 3:最后一个奶农也全部买完

公司只要求:

买够 (n) 单位。

如果最后还缺 5 单位,而当前奶农有 100 单位:

只需要买 5

不能把剩下 100 全部购买。


错误 4:费用使用 int

虽然原题范围接近 32 位整数边界,但竞赛中涉及:

数量 × 单价

建议直接使用:

long long

避免乘法中间过程溢出。


七、边界易错点

1. 需求量可能已经是 0

此时答案就是:

0

循环条件:

need>0

可以自然处理。

2. 相同价格顺序无所谓

因为它们的单位成本完全一样。

3. 总供应量足够

题目已经保证可以完成采购。

4. 不需要真的一单位一单位购买

如果奶农能提供 (10^6) 单位牛奶,不应该循环 (10^6) 次。

直接:

buy=min(need,a[i]);

批量计算即可。


八、下次触发信号

看到:

需要购买一定数量

每个来源有不同单价

每个来源有供应上限

可以购买其中任意数量

要求总费用最小

立刻想到:

单价最低
→ 优先尽可能买满

核心触发信号:

同质资源采购 + 不同单价 = 从便宜到贵。


C. P1223 排队接水

一、题目大意

有 (n) 个人排队使用一个水龙头。

第 (i) 个人接水需要:

[
T_i
]

时间。

需要安排一个排队顺序,使所有人的:

平均等待时间最小。

一个人的等待时间不包含他自己的接水时间。

如果两个人接水时间相同,则编号较小的人必须排在前面。

题目中:

[
1\le n\le1000,\qquad 1\le T_i\le10^6
]

并要求输出:

  1. 排队顺序;
  2. 最小平均等待时间,保留两位小数。

二、题解前关键信号识别

假设两个人:

A 接水需要 3 分钟
B 接水需要 10 分钟

如果:

A → B

那么:

A 等待 0
B 等待 3

总等待:

[
3
]

如果:

B → A

那么:

B 等待 0
A 等待 10

总等待:

[
10
]

显然:

时间短的人应该排在前面。

因此应该按照:

[
T_i
]

从小到大排序。

这是一类非常经典的:

短任务优先贪心。


三、数据规模与复杂度判断

(n\le1000)。

排序:

[
O(n\log n)
]

扫描计算等待时间:

[
O(n)
]

总复杂度:

[
O(n\log n)
]

空间复杂度:

[
O(n)
]


四、核心思路

为什么短的人应该在前面?

可以使用一个非常重要的贪心证明方法:

交换论证

假设在某个方案中,相邻两个人:

A → B

并且:

[
T_A>T_B
]

设排在他们之前的人总共花了 (S) 时间。


原来的顺序

A 的等待时间:

[
S
]

B 的等待时间:

[
S+T_A
]

两个人贡献:

[
2S+T_A
]


交换之后

变成:

B → A

B 的等待时间:

[
S
]

A 的等待时间:

[
S+T_B
]

贡献:

[
2S+T_B
]

因为:

[
T_B<T_A
]

所以交换以后等待时间更小。

因此只要存在:

前面的人比后面的人慢

就可以交换,并且答案不会变差。

不断交换以后,最终一定得到:

接水时间从小到大

的顺序。

这就证明了贪心策略。


如何计算总等待时间?

排序以后:

t1 t2 t3 ... tn

第一个人等待:

[
0
]

第二个人等待:

[
t_1
]

第三个人等待:

[
t_1+t_2
]

……

可以逐个累加:

sum+=wait;
wait+=t[i];

也可以观察每个人的接水时间会被后面多少个人等待:

第 1 个人影响 n-1 人
第 2 个人影响 n-2 人
...

因此:

[
sum=\sum_{i=1}^{n} T_i(n-i)
]


五、参考代码

#include<bits/stdc++.h>
using namespace std;
typedef long long LL;const int N=1010;struct Person
{int t,id;
}a[N];int n;bool cmp(Person x,Person y)
{if(x.t!=y.t)return x.t<y.t;return x.id<y.id;
}int main()
{scanf("%d",&n);for(int i=1;i<=n;i++){scanf("%d",&a[i].t);a[i].id=i;}sort(a+1,a+n+1,cmp);for(int i=1;i<=n;i++)printf("%d%c",a[i].id,i==n?'\n':' ');LL sum=0;for(int i=1;i<=n;i++)sum+=1LL*a[i].t*(n-i);printf("%.2lf\n",1.0*sum/n);return 0;
}

六、错因回溯

错误 1:时间长的人先排

有人可能会认为:

先解决最麻烦的人。

但长任务排在前面,会让后面的很多人全部等待这段长时间。

真正希望的是:

尽量减少对后面大量人的阻塞。

所以应该短任务优先。


错误 2:只输出排序后的接水时间

题目第一行要求输出的是:

人的编号。

所以排序时必须保留:

id

不能只存时间。


错误 3:相同时间没有按照编号排序

题目明确规定:

接水时间相同时,编号小的人在前。

因此比较函数必须写:

if(x.t!=y.t)return x.t<y.t;return x.id<y.id;

错误 4:把自己的接水时间算进等待时间

题目说明:

等待时间不包括自己接水的时间。

所以最后一个人的接水时间不会贡献给任何人的等待。

公式中:

a[n].t*(n-n)=0

正好体现这一点。


错误 5:使用 int 保存总等待时间

最坏情况下,总等待时间可以达到很大数量级。

因此:

long long sum;

更加安全。


七、边界易错点

1. (n=1)

只有一个人:

等待时间 = 0
平均等待时间 = 0.00

2. 所有人接水时间相同

必须按照:

1 2 3 ... n

输出。

3. 平均值必须使用浮点除法

不能写:

sum/n

再转成 double

应该:

1.0*sum/n

4. 输出两位小数

printf("%.2lf\n",...);

八、下次触发信号

以后看到:

一台机器 / 一个窗口

多个人或任务依次处理

后面的任务必须等待前面完成

要求总等待时间或平均等待时间最小

应该想到:

短任务优先。

尤其看到目标函数中:

一个任务越靠前
就会影响越多后面的人

更应该考虑:

让耗时小的任务承担更大的影响次数

核心触发信号:

单机排队 + 最小总等待时间 = 按处理时间升序。

同时记住一个重要证明方法:

贪心顺序不确定时,尝试比较两个相邻元素交换前后的答案。


D. P1803 凌乱的 yyy / 线段覆盖

一、题目大意

有 (n) 场比赛。

第 (i) 场比赛:

  • 开始时间为 (a_i);
  • 结束时间为 (b_i)。

如果选择参加一场比赛,就必须完整参加,不能同时参加两场比赛。

要求:

最多能够参加多少场比赛。

题目中:

[
1\le n\le10^6
]

且:

[
0\le a_i<b_i\le10^6
]

数据规模非常大。


二、题解前关键信号识别

这是非常经典的:

区间选择问题。

每场比赛可以表示成一条时间轴上的区间:

[a[i], b[i]]

我们要选择尽可能多的:

两两不冲突的区间。

这时应该思考:

选择当前比赛时,什么条件能够给后面的比赛留下最多机会?

答案是:

结束得越早越好。

例如:

比赛 A:1 → 10
比赛 B:2 → 3

虽然 A 开始更早,但选完 A 后:

直到 10 才能参加下一场。

选 B:

3 就空闲了。

显然 B 为后面的选择留下了更多空间。

因此:

按结束时间从小到大排序。


三、数据规模与复杂度判断

(n\le10^6)。

暴力枚举所有比赛子集:

[
2^n
]

完全不可能。

即使动态规划,如果状态设计复杂,也没有必要。

排序:

[
O(n\log n)
]

之后扫描一次:

[
O(n)
]

总复杂度:

[
O(n\log n)
]

对 (10^6) 规模是合理的。

空间复杂度:

[
O(n)
]


四、核心思路

将所有比赛按照:

结束时间 b

从小到大排序。

定义:

last

表示:

当前最后选择的一场比赛的结束时间。

依次扫描每场比赛。

如果:

a[i]>=last

说明这场比赛开始时,上一场已经结束。

可以参加:

ans++;
last=b[i];

否则:

当前比赛与已经选择的最后一场冲突

直接跳过。


为什么结束时间最早一定最好?

假设现在可以选择:

A:结束时间 5
B:结束时间 8

并且两场都能接在当前方案后面。

如果选择 A:

5 之后可以继续选择。

如果选择 B:

8 之后才能继续选择。

所有在:

8 以后

能参加的比赛,在:

5 以后

也同样可以参加。

而选择 A 还可能额外参加开始时间在:

[5,8)

之间的比赛。

因此:

选择结束时间更早的比赛绝不会让未来变差。


五、参考代码

#include<bits/stdc++.h>
using namespace std;const int N=1000010;struct Match
{int l,r;
}a[N];int n;bool cmp(Match x,Match y)
{return x.r<y.r;
}int main()
{scanf("%d",&n);for(int i=1;i<=n;i++)scanf("%d%d",&a[i].l,&a[i].r);sort(a+1,a+n+1,cmp);int ans=0;int last=-1;for(int i=1;i<=n;i++){if(a[i].l>=last){ans++;last=a[i].r;}}printf("%d\n",ans);return 0;
}

六、错因回溯

错误 1:按照开始时间最早排序

开始得早并没有价值。

例如:

A:0 → 100
B:1 → 2
C:2 → 3
D:3 → 4

如果先选 A:

只能参加 1 场。

而:

B → C → D

可以参加 3 场。


错误 2:优先选择持续时间最短的比赛

持续时间短也不一定最好。

例如:

A:10 → 12

虽然只持续 2 个单位时间,但可能结束得很晚。

我们真正关心的是:

从什么时候开始重新获得选择自由。

所以应该看:

结束时间

而不是:

区间长度

错误 3:判断条件写成 >

如果:

上一场结束时间 = 5
下一场开始时间 = 5

两场并不重叠。

所以可以连续参加。

应该写:

a[i].l>=last

而不是:

a[i].l>last

错误 4:选择了比赛却没有更新 last

只有真正参加了当前比赛时:

last=a[i].r;

未选择的比赛不能影响后面的判断。


七、边界易错点

1. (n) 高达 (10^6)

数组必须开够:

const int N=1000010;

2. 起始时间可以是 0

因此如果使用:

last=0;

也是可以的。

参考代码使用:

last=-1;

更加直观地表示“当前没有参加任何比赛”。

3. 相同结束时间

无论先处理哪一个,只要按照结束时间不降排列,贪心性质不会被破坏。

4. 只要求数量

不需要保存具体选择了哪些比赛。


八、下次触发信号

看到:

很多时间区间

选择的区间不能重叠

要求选择数量最多

应当马上想到经典区间贪心:

按结束时间升序↓
能选就选

核心触发信号:

最多选择互不相交区间 = 优先结束最早。

要区分几个常见问题:

最多选择不相交区间
→ 按结束时间合并区间
→ 通常按开始时间区间覆盖
→ 又是另一类贪心

不能看到“区间”两个字就套同一个模板。


E. P3817 小 A 的糖果

一、题目大意

有 (n) 个糖果盒。

第 (i) 个盒子中有:

[
a_i
]

颗糖。

每次可以从任意一个盒子中吃掉一颗糖。

要求最终满足:

[
a_i+a_{i+1}\le x
]

对所有相邻糖果盒都成立。

求:

最少需要吃掉多少颗糖。

题目中:

[
2\le n\le10^5
]

并且:

[
0\le a_i,x\le10^9
]

因此最终答案需要注意整数范围。


二、题解前关键信号识别

这道题没有:

排序

也没有明显的:

最大值 / 最小值选择

所以初学者很容易看不出它是贪心。

关键在于观察限制:

[
a_i+a_{i+1}\le x
]

这是一个:

只涉及相邻两个位置的局部限制。

可以从左到右依次处理。

当处理到:

a[i-1] 和 a[i]

时,前面的:

a[1] ... a[i-1]

已经全部处理完毕。

如果当前:

[
a_{i-1}+a_i>x
]

必须吃掉:

[
a_{i-1}+a_i-x
]

颗糖。

问题是:

应该从左边盒子吃,还是从右边盒子吃?

应该尽量:

只修改当前的右边盒子 (a_i)。

因为左边的 (a_{i-1}) 已经和:

a[i-2]

共同满足了前一个条件。

回头修改它没有任何额外好处。


三、数据规模与复杂度判断

(n\le10^5)。

如果尝试搜索:

每颗糖到底从哪个盒子吃

显然不可行。

但由于约束只和相邻位置有关,可以从左到右一次解决。

时间复杂度:

[
O(n)
]

空间复杂度:

[
O(n)
]

甚至可以优化到:

[
O(1)
]

额外空间。


四、核心思路

为了让处理方式统一,可以人为定义:

a[0]=0;

然后从:

i=1

开始处理。

每次检查:

[
a_{i-1}+a_i
]


如果没有超过 (x)

if(a[i-1]+a[i]<=x)

什么都不需要做。


如果超过 (x)

超出的数量:

[
d=a_{i-1}+a_i-x
]

必须至少吃掉这么多颗糖。

我们直接从:

当前盒子 a[i]

中吃掉:

a[i]-=d;

答案:

ans+=d;

为什么需要先处理 a[1]

考虑:

a1=10
a2=0
x=5

如果直接从:

a1+a2

开始,并要求只修改 a2

需要吃 5
但 a2 本来只有 0

就出问题了。

所以设置:

a[0]=0;

首先检查:

[
a_0+a_1\le x
]

实际上就是保证:

[
a_1\le x
]

如果 a1>x

先把 a1 减到 x。

这样之后每一步的超出量都一定可以从当前 a[i] 中扣除。


为什么从右边吃一定最优?

处理:

a[i-1] + a[i]

时:

a[i-1]

已经被确定。

因为:

a[i-2]+a[i-1]<=x

已经满足。

现在当前这一对超出了 d

无论如何:

至少都必须吃掉 (d) 颗。

我们恰好吃掉:

d

颗,所以当前操作数量已经是最少可能值。

并且全部从 a[i] 中吃还有一个好处:

a[i] 越小,下一组 a[i]+a[i+1] 越容易满足。

因此不会对未来造成坏影响。


五、参考代码

#include<bits/stdc++.h>
using namespace std;
typedef long long LL;const int N=100010;int n;
LL x,a[N];int main()
{scanf("%d%lld",&n,&x);for(int i=1;i<=n;i++)scanf("%lld",&a[i]);a[0]=0;LL ans=0;for(int i=1;i<=n;i++){if(a[i-1]+a[i]>x){LL d=a[i-1]+a[i]-x;ans+=d;a[i]-=d;}}printf("%lld\n",ans);return 0;
}

六、错因回溯

错误 1:只从 i=2 开始

例如:

n=2
x=510 0

如果直接处理:

a1+a2

并试图全部从 a2 吃:

a2 会变成负数。

所以首先需要处理:

a1>x

的情况。

使用:

a[0]=0;

可以把这个边界统一进普通循环。


错误 2:超出以后从左边盒子吃

处理到第 (i) 个位置时:

a[i-1]

已经参与并满足了前一个限制。

如果继续修改左边:

虽然不会破坏前面的“不超过 x”,但对于下一组约束没有任何帮助。

而减少:

a[i]

既解决当前问题,又能帮助:

a[i]+a[i+1]

这一组。

所以应该修改右边。


错误 3:一次只吃一颗糖进行模拟

假设超出:

1000000000

颗。

如果一颗一颗吃:

while(...)
{a[i]--;ans++;
}

复杂度可能极高。

直接计算:

d=a[i-1]+a[i]-x;

一次处理完即可。


错误 4:修改了答案但没有修改数组

例如:

ans+=d;

却忘记:

a[i]-=d;

那么处理下一对:

a[i]+a[i+1]

时仍然使用原来的糖果数量,会重复计算。


错误 5:答案使用 int

(n\le10^5),单个 (a_i\le10^9)。

累计吃掉的糖果数量可能明显超过普通 32 位整数范围,因此应该使用:

long long

七、边界易错点

1. (x=0)

最终所有相邻盒子之和都必须为 0。

参考代码仍然可以正常处理。

2. 原序列已经全部合法

每一步:

a[i-1]+a[i]<=x

答案自然为 0。

3. 第一盒本身大于 (x)

必须先降低第一盒。

a[0]=0 可以自然完成这件事。

4. 修改后的 a[i] 要继续参与下一次判断

这正是贪心过程的一部分。


八、下次触发信号

以后看到:

对相邻位置有限制

可以通过减少、增加或修改当前位置解决冲突

前面的状态一旦处理好就不需要再回头

应该尝试:

从左到右扫描↓
发现当前局部不合法↓
用最小代价把它刚好修到合法↓
继续处理后面

核心触发信号:

局部相邻约束 + 修改具有单向影响 = 从左到右局部修正贪心。

这道题也特别说明:

贪心不等于排序。

贪心真正的本质是:

每一步做一个可以证明“不比其他选择差”的局部决策。


F. P1090 合并果子

一、题目大意

有 (n) 堆果子,第 (i) 堆有:

[
a_i
]

个果子。

每次可以选择两堆果子进行合并。

假设选择的两堆大小分别为:

[
x,\ y
]

那么:

  • 产生一堆大小为 (x+y) 的新果子;
  • 本次合并消耗体力:

[
x+y
]

不断进行合并,直到最终只剩下一堆。

要求:

最小化所有合并操作的总体力消耗。

题目中:

[
1\le n\le10^4
]

每堆果子数量:

[
1\le a_i\le2\times10^4
]

题目保证最终答案小于 (2^{31})。


二、题解前关键信号识别

最重要的观察是:

一堆果子一旦被合并形成新堆,之后还有可能继续参与后面的合并。

也就是说:

越早合并进去的重量,会被重复计算越多次。

例如:

1 2 9

如果先合并:

1+2=3

花费:

3

再:

3+9=12

总花费:

[
3+12=15
]

而如果先:

2+9=11

再:

1+11=12

总花费:

[
11+12=23
]

明显更大。

因此:

那些会被重复计算多次的果子,应该尽可能轻。

所以每次应该:

选择当前最小的两堆果子进行合并。


三、数据规模与复杂度判断

如果每次都:

遍历数组找最小的两堆

一次需要:

[
O(n)
]

一共要合并:

[
n-1
]

次。

复杂度:

[
O(n^2)
]

对于本题 (n=10^4) 虽然部分情况下可能勉强,但并不是合理实现。

我们需要一种数据结构支持:

快速取出最小元素
插入新元素

这正是:

小根堆 / 优先队列。

每次:

取两个最小值:O(log n)
插入新值:O(log n)

总共进行 (n-1) 次。

时间复杂度:

[
O(n\log n)
]

空间复杂度:

[
O(n)
]


四、核心思路

使用小根堆:

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

将所有果子堆加入优先队列。

然后不断进行:

取出最小的一堆 x
取出第二小的一堆 y合并:
z=x+y答案:
ans+=z新果堆:
把 z 重新放入优先队列

直到:

优先队列只剩一个元素。

为什么一定选择最小的两堆?

可以从“每个原始果子会被计算多少次”理解。

最终的合并过程可以看成一棵二叉树:

           总果堆/     \...     ...

叶子是最开始的每一堆果子。

某一堆果子位于树中越深:

它的重量就会在合并费用中被重复计算越多次。

因此我们希望:

重量大的果堆
→ 尽量浅重量小的果堆
→ 可以更深

而每次选择最小的两堆合并,正是在构造满足这个性质的最优合并树。

这就是经典的:

Huffman 贪心思想。


一个更直观的理解

假设当前有:

[
a\le b\le c
]

无论如何,至少有两堆需要先合并。

如果先让大的 (c) 参与合并:

c

就会提前进入新果堆,并在以后再次被计算。

而选择:

a 和 b

可以让重复参与后续合并的重量尽量小。

因此应该合并最小两堆。


五、参考代码

#include<bits/stdc++.h>
using namespace std;
typedef long long LL;int n;priority_queue<int,vector<int>,greater<int> > q;int main()
{scanf("%d",&n);for(int i=1;i<=n;i++){int x;scanf("%d",&x);q.push(x);}LL ans=0;while(q.size()>1){int x=q.top();q.pop();int y=q.top();q.pop();int z=x+y;ans+=z;q.push(z);}printf("%lld\n",ans);return 0;
}

六、错因回溯

错误 1:每次选择最大的两堆

大的果堆如果过早参与合并:

后续每次合并都会再次计算这部分大重量。

所以通常会造成很高的总代价。

本题恰好应该反过来:

小的尽早合并
大的尽量晚参与

错误 2:只排序一次,然后依次合并

例如初始:

1 2 3 100

第一次:

1+2=3

产生了一堆新的:

3

现在所有果堆变成:

3 3 100

下一次应该重新选择:

3 和 3

所以:

新产生的果堆必须重新参与“找最小值”。

只对原数组排序一次,然后机械地从左往右处理是不够的。


错误 3:合并后没有把新果堆放回去

合并:

x+y

以后,新果堆还必须参与后续合并。

因此:

q.push(x+y);

不能漏。


错误 4:循环次数写成 n

(n) 堆果子最终合成 1 堆,只需要:

[
n-1
]

次合并。

最自然的判断方式不是手动数次数,而是:

while(q.size()>1)

错误 5:不会写小根堆

C++ 默认:

priority_queue<int> q;

是:

大根堆。

每次 top() 得到最大值。

本题需要最小值,所以应该写:

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

七、边界易错点

1. (n=1)

只有一堆果子:

根本不需要合并

答案:

0

此时:

while(q.size()>1)

一次也不会执行。

2. 新果堆可能比原来的很多果堆更小

所以一定要重新加入小根堆,而不能简单追加到固定顺序中。

3. 答案建议使用 long long

虽然原题保证答案小于 (2^{31}), 但这种“多次累加合并代价”的题目养成使用:

long long

的习惯更安全。

4. 每次必须取两个不同的堆

因此是:

top → pop
top → pop

然后再合并。


八、下次触发信号

以后看到:

有很多堆 / 文件 / 木板 / 节点

每次合并两个

合并代价等于两者之和

合并后的新对象还会继续参与合并

要求所有合并总代价最小

应该立刻想到:

每次取当前最小的两个↓
合并↓
重新放回

对应数据结构:

小根堆

核心触发信号:

反复合并 + 代价为两者之和 + 求总代价最小 = Huffman 贪心。


六道题的知识递进

这六道题放在一起,可以形成一条比较完整的贪心入门路线。


第一层:最直观的“优先选更赚的”

P2240 部分背包问题

单位容量有限↓
比较单位价值↓
单位价值高的优先

核心:

收益 / 成本。


第二层:最直观的“优先选更便宜的”

P1208 混合牛奶

需要固定数量资源↓
不同来源价格不同↓
便宜的先买

核心:

成本低的资源优先使用。


第三层:开始学习证明贪心

P1223 排队接水

谁应该排前面?↓
比较相邻两个任务↓
交换顺序↓
短任务在前更优

核心:

交换论证。

这一步非常重要。

从这题以后不能只满足于:

“我感觉这样贪应该对。”

而应该开始问:

为什么这样贪一定不会更差?


第四层:经典区间贪心

P1803 凌乱的 yyy

很多互相冲突的区间↓
希望选择最多↓
给未来留下最多空间↓
优先结束最早

核心:

当前选择要尽量少限制未来。


第五层:认识“贪心不一定排序”

P3817 小 A 的糖果

相邻位置出现冲突↓
从左到右处理↓
每次只做必须做的最小修改

核心:

局部约束 + 单向扫描。


第六层:动态维护当前最优选择

P1090 合并果子

当前最小两个↓
合并产生新元素↓
新元素重新参与选择

因此普通排序已经不够,需要:

优先队列

核心:

每一步都要动态找到当前最优对象。


贪心题的完整思考流程

与 DFS、BFS 不同,贪心通常没有一个固定模板。

真正困难的是:

怎么发现贪心策略,以及怎么证明它。

做题时可以按照下面的顺序思考。


1. 题目要求最大化还是最小化什么?

先明确目标函数:

最大价值
最小费用
最小等待时间
最多区间
最少修改次数
最小合并代价

不要一上来就考虑代码。


2. 当前有哪几种选择?

例如:

P2240

当前容量给哪堆金币?

P1223

谁排在前面?

P1803

当前选择哪一个区间?

P1090

当前合并哪两堆?

贪心就是要回答:

当前这一步应该选谁?


3. 有没有明显的排序依据?

常见排序关键字:

单位价值
价格
处理时间
结束时间
起始时间
截止时间
重量

但不要认为:

贪心 = sort。

P3817 就完全不需要排序。


4. 当前选择会怎样影响未来?

这是非常重要的问题。

例如 P1803:

当前比赛结束越晚
→ 后面能参加的比赛越少

所以应该:

结束越早越好。

P1090:

越早合并进去的重量
→ 后面会被重复计算越多次

所以应该:

轻的尽量早点合并。

5. 能不能用交换论证?

如果问题涉及:

排列顺序
处理顺序
选择顺序

可以尝试:

假设最优方案中有两个相邻元素不符合我的贪心规则,把它们交换会怎样?

如果交换后:

答案不会变差

就说明可以不断交换,直到形成贪心顺序。

代表题:

P1223 排队接水

6. 能不能证明“当前选择不会限制未来”?

例如 P1803:

选择结束时间最早的比赛以后:

任何选择结束更晚比赛之后能够完成的后续方案,它都同样可以完成。

所以:

结束最早

不会比其他选择差。


7. 是否每一步只需要“刚好修复”当前问题?

例如 P3817:

当前超出:

[
d
]

那么:

至少必须吃 d 颗

我们恰好:

吃 d 颗

既不少,也不多。

这种:

每一步只支付不可避免的最低代价

也是很常见的贪心信号。


8. 最优对象会不会动态变化?

如果只排序一次就足够:

P2240
P1208
P1223
P1803

普通:

sort

即可。

如果操作以后会产生新的候选:

P1090

就需要:

priority_queue

动态维护最优元素。


六道题最终触发信号总结

题目 看到什么 应触发什么
P2240 可以分割、容量有限、最大价值 单位价值最高优先
P1208 固定需求、不同单价、供应有限 最便宜优先购买
P1223 单窗口排队、最小等待时间 短任务优先
P1803 最多选择互不重叠区间 结束最早优先
P3817 相邻限制、允许减少、从左向右 局部刚好修复
P1090 两两合并、代价为和、新对象继续参与 每次合并最小两项

最后:怎样判断一道题可能是贪心?

贪心题经常具有这样的特点:

问题规模很大
↓
暴力搜索不可能状态很多
↓
完整 DP 又显得过重但每一步似乎存在一个
“永远不会吃亏”的选择

这时不要直接凭感觉写代码,而应该继续问:

1. 我的局部最优策略到底是什么?2. 为什么选它不会让未来变差?3. 如果不这样选,能不能把某个最优方案调整成这样?4. 调整之后答案是否不会变差?5. 是否存在反例能够击穿我的策略?

尤其应该学会三种最基础的贪心证明思维:

交换论证↓
把“不符合贪心顺序”的两个对象交换替换论证↓
把最优方案中的某个选择替换成贪心选择局部必要代价↓
证明当前至少必须付出这么多,
而我们的方案恰好只付出这么多

对应到本场比赛:

P2240 / P1208
→ 替换思想P1223
→ 交换论证P1803
→ 不限制未来 / 替换思想P3817
→ 局部必要代价P1090
→ 最小元素优先参与重复代价

当做贪心题时开始主动寻找:

“为什么这个选择永远不会比其他选择差?”

而不是只记住:

sort(...)

才算真正开始掌握贪心。

← 返回列表