按过期时间升序扫描,用小根堆维护暂定要卖的商品:堆大小超过当前 d 就弹出最小利润,O(N log N) 完成反悔贪心。
OJ: roj
题目 ID: 3043
难度:提高
标签:贪心堆二叉堆python
创建: 2026-10-01 11:52
更新: 2026-10-07 13:50
形式化题目
给定
求一个商品子集
按题面样例 1,
正解
思路
先明确可行性判据。 把候选子集
必要性:前
朴素做法一:把时间摊成背包。 令
朴素做法二:按利润降序占最晚空位。 把商品按 for t in range(d, 0, -1),最坏仍是
两个朴素做法的共同瓶颈:都在反复扫描同一段时间轴。要跳出这个瓶颈,需要换一个提问方式。
关键转变:不预先决定“卖在哪一天”,只决定“卖不卖”。
有了可行性判据,“具体排在哪天”总可以事后再补,于是按
把候选商品按
升序排列,对每个前缀,入选件数不能超过该前缀中最大的那个 。
(把这一步拆开说清楚:处理完所有
换个更具体的说法:扫描到第
于是算法成形了——按
- 每读到一件商品,先假设它卖得出去,把
放进去; - 扫到第
天时,如果集合大小超过 ,就必须丢掉一件; - 丢哪一件?丢利润最小的那件。
第 3 步的安全性来自一次交换论证:被丢的那件利润
“随时插入、随时取最小”正是小根堆的接口:heappush 之后若堆大小超过 heappop 弹出最小值。
对照代码,扫描过程只做三件事:heappush(heap, p) 对应“先收下”,if len(heap) > d 对应“容量不够”,heappop(heap) 对应“退掉利润最小的”。
用第二组样例(
| 步骤 | 过期时间 |
利润 |
动作 | 堆内利润(升序) | 堆大小 | 容量 |
|---|---|---|---|---|---|---|
| 1 | 1 | 2 | 压入 2 | 1 | 1 | |
| 2 | 1 | 20 | 压入 20,超容量弹 2 | 1 | 1 | |
| 3 | 2 | 8 | 压入 8 | 2 | 2 | |
| 4 | 2 | 100 | 压入 100,超容量弹 8 | 2 | 2 | |
| 5 | 3 | 10 | 压入 10 | 3 | 3 | |
| 6 | 10 | 50 | 压入 50 | 4 | 10 | |
| 7 | 20 | 5 | 压入 5 | 5 | 20 |
表的列依次是步数、当前商品的过期时间与利润、本步动作、堆内容、堆大小和本步的容量上限
最优性证明。 交换论证只能说明“单步看起来不吃亏”,要说明全局最优需要一条不变量。为了避免跟踪“商品序号”,改为按最大截止日期
:所有 的商品; :按 升序处理完 后堆的状态(即处理完所有 的商品、并已弹过之后); :多重集 中最大的 个元素之和; :从 中选出的、件数不超过 的可行子集的最大利润和。
归纳假设(对
现在处理
-
下界(先证 (a)
可行,于是它是候选解):对 , 中 的商品就是 中 的那些(新商品的 ),由归纳假设 (a) 其数量 ;对 ,数量等于 。所以 满足可行性判据。它的前 大给出一个件数 的可行解,故 。 -
上界:任取可行子集
且 。拆成 ,其中 、 。由归纳假设 ;把 中最大的 个取出、再并上 ,得到 的一个子多重集 ,则 注意
恰好含有 个元素。由可行性判据(取 )知 ,所以 。 - 若
: ,而 是 中最大的 个,截取前 大得到 ,结论成立。 - 若
:此时 。又分两种:没弹过元素时 ,则 ;弹过元素时 ,而 ,故 。
- 若
所以
取
实现要点。 读入有两处要小心:题面允许数据间任意穿插空格与空行,所以必须整体 split() 而不能按行解析;真实数据文件以 DOS 结束符 0x1A 结尾,需要把它替换成空白,否则 int() 会抛 ValueError。多组数据用 for n in data: 一路读到 EOF,不需要预先知道组数;sum([]) 直接给出 0。
代码
代码分两层:best_profit 负责单组的“按 solve 只管读入、按组切分和输出。deals 里存的是 sorted(deals) 天然按过期时间升序,不必写 key=;函数内的 d、p 与题面的
Python 版:
#!/usr/bin/env python3
# Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
# rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
# rainboy的学习导航网站: https://idx.roj.ac.cn
# create_at: 2026-10-01 11:52
# update_at: 2026-10-01 11:52
import sys
from collections.abc import Iterator
from heapq import heappop, heappush
DOS_EOF = b'\x1a' # 老数据文件的 DOS 结束符,当作空白丢弃
def best_profit(deals: list[tuple[int, int]]) -> int:
"""deals 里每项是 (过期时间 d, 利润 p),返回这一组商品的最大收益。
按 d 升序扫描,堆里只装"暂定要卖"的利润,堆的大小就是这些商品占用的天数:
截至第 d 天最多卖出 d 件,所以处理完过期时间为 d 的一批后,堆若超过 d 个,
就弹出最小的那个利润——它被放弃,换来的名额留给更值钱的商品。
"""
heap: list[int] = []
for d, p in sorted(deals): # 元组比较先看 d,天然按过期时间升序
heappush(heap, p)
if len(heap) > d: # 第 1..d 天一共只有 d 个摊位
heappop(heap) # 忍痛丢掉利润最小的那件
return sum(heap)
def solve() -> None:
raw = sys.stdin.buffer.read().replace(DOS_EOF, b' ')
data: Iterator[int] = iter(map(int, raw.split()))
out: list[str] = []
for n in data: # 每组用例以 N 开头,读到文件结尾为止
deals: list[tuple[int, int]] = []
for _ in range(n):
p, d = next(data), next(data) # 题面顺序:先利润 p_i,后过期时间 d_i
deals.append((d, p))
out.append(str(best_profit(deals)))
print('\n'.join(out))
if __name__ == "__main__":
solve()C++ 版(同一算法):
/**
* Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
* rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
* rainboy的学习导航网站: https://idx.roj.ac.cn
* create_at: 2026-10-06 15:50
* update_at: 2026-10-06 15:50
*/
// 3043 超市:反悔贪心(按过期时间升序扫描 + 小根堆)
// 可行性判据:把子集按 d 升序排好后,第 j 小的 d 必须 >= j,
// 所以按 d 扫描时只需保证:处理完所有 d <= D 的商品后,暂定集合大小 <= D。
// 扫描到商品 (p, d) 时,手头所有过期时间 <= d 的商品只能占第 1..d 天,
// 一旦堆大小超过 d,就丢掉利润最小的那件(可用时间相同,留大的不吃亏)。
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
// 小根堆:暂定要卖的商品利润;堆内元素之和就是当前最优收益
priority_queue<ll, vector<ll>, greater<ll> > heap;
int main() {
// 题面允许数据间任意穿插空格与空行,直接按空白流式读入直到 EOF
ll n;
while (cin >> n) {
// 用 pair 的天然排序:first = d(过期时间),second = p(利润)
vector<pair<ll, ll> > deals; // deals[i] = (d_i, p_i)
deals.resize(n);
for (ll i = 0; i < n; ++i) {
ll p, d;
cin >> p >> d;
deals[i] = make_pair(d, p);
}
sort(deals.begin(), deals.end()); // 按过期时间 d 升序
while (!heap.empty()) heap.pop(); // 清空上一组的堆
for (ll i = 0; i < n; ++i) {
ll d = deals[i].first;
ll p = deals[i].second;
heap.push(p); // 先假设这件商品卖得出去
ll sz = heap.size();
if (sz > d) heap.pop(); // 第 1..d 天只有 d 个摊位,退掉最小利润
}
ll ans = 0;
while (!heap.empty()) {
ans += heap.top();
heap.pop();
}
cout << ans << "\n";
}
return 0;
}复杂度
- 时间复杂度:单组排序
,每件商品至多一次 heappush和一次heappop,各,合计 。相比两个 的朴素做法( 时约 次操作),本做法只需 量级的基本操作,与 C++ 的二叉堆 / 并查集标准做法同阶。 - 空间复杂度:
, deals存个二元组、 heap最多个整数。真实数据 d1.in的 15 组数据合计运行 0.052 s、峰值 26.2 MB,相对 1000 ms / 128 MB 的限制十分宽裕。
总结
本题的核心是把"哪一天卖"这个显式排期问题,等价替换成"卖不卖"这个前缀容量问题:把商品按过期时间升序扫描,候选集合的大小一旦超过当前天数 0x1A 当空白处理。
图示解析
这张图串起本题从建模到得到答案的主线:
每组商品: (利润 p_i, 过期时间 d_i)
`- 换成 (d_i, p_i) 并按 d 升序扫描 <= 容量 d 只增不减
`- 先假设每件都卖, 压入小根堆
|- 堆大小 <= d -> 全部卖得出去, 继续
`- 堆大小 > d -> 第 1..d 天只有 d 个摊位, 必须丢掉一件
`- 丢掉堆中最小的利润 <= 反悔: 名额留给后面更贵的
`- 扫完后堆内元素之和 = 最大收益先看第一层为什么要换成