每轮只保留前 n 个和:在 A[i]+B[j] 行列单调的加法表上做 n 路归并,弹 n 次堆顶并按生成约定去重,总复杂度 O(mn log n)。
OJ: roj
题目 ID: 3044
难度:提高
标签:堆二叉堆多路归并python
创建: 2026-10-01 11:52
更新: 2026-10-07 13:50
形式化题目
给定
求
样例中
| 3 | 3 | 4 | |
| 4 | 4 | 5 | |
| 5 | 5 | 6 |
前 3 小是 3 3 4。
正解
思路
朴素做法:枚举
观察一:每轮只留前
答案就是
观察二:单轮「取前
因为
- 小顶堆的初始元素只有左上角
; - 弹出堆顶(当前最小的候选和)写进答案,再把它的右邻和下邻推入堆(下一段给出不重不漏的推法);
- 弹
次结束。
之所以可以只推右邻和下邻,而不必事先建出整张
去重。 每个格子有两个前驱,直接推两个邻居会让同一个格子多次入堆。这里不去维护「已入堆集合」,而是约定每个格子的唯一生成者:
- 第
列的格子 :只在弹出 时顺延生成; - 第
列的格子 :只在弹出左邻 时生成。
这样每个格子恰好被生成一次,代价是弹出
flowchart TD
Pop["弹出堆顶 (i, j)"] --> IsFirst{"j == 0 ?"}
IsFirst -->|"是"| Down["推入 (i+1, 0):换到下一行开头"]
IsFirst -->|"否"| NoDown["不推下一行"]
Down --> Right["推入 (i, j+1):同行的右邻"]
NoDown --> Right
Right --> Pop
图中的循环体对应代码里的 for _ in range(n):每弹出一个最小和,最多放进两个新候选。右下方向一律不推,正是为了让每个格子只被生成一次。
用样例验证一遍:
| 轮次 | 堆中候选 |
弹出的和 | 注释 |
|---|---|---|---|
| 1 | 左上角,然后推 |
||
| 2 | |||
| 3 | 和相同时用下标打破平局,取哪张都不影响值 |
弹出序列
正确性要点:任何尚未弹出的格子
代码
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:55
import heapq
import sys
from itertools import islice
def merge_top_n(a: list[int], b: list[int], n: int) -> list[int]:
"""把「前若干个序列能凑出的最小 n 个和」再叠加一个序列 b,返回新的最小 n 个和。
a、b 都非降序且各含 n 个数;只需考虑加法表 a[i] + b[j](n x n 个格子)中最小的 n 个。
坑点是去重:每个格子只能入堆一次,所以固定每个格子的生成者——非第一列的 (i, j)
只由左邻 (i, j-1) 生成;第一列的 (i, 0) 则在弹出 (i-1, 0) 时顺延生成。
"""
table = [(a[0] + b[0], 0, 0)] # 候选三元组 (和, 与 a 配对的下标, 与 b 配对的下标)
result: list[int] = []
for _ in range(n):
total, i, j = heapq.heappop(table)
result.append(total)
if j == 0 and i + 1 < n: # 第一列:顺延出下一行的开头 a[i+1] + b[0]
heapq.heappush(table, (a[i + 1] + b[0], i + 1, 0))
if j + 1 < n: # 同一行内把 b 往后挪一格
heapq.heappush(table, (a[i] + b[j + 1], i, j + 1))
return result
def solve() -> None:
data = iter(map(int, sys.stdin.buffer.read().split()))
out: list[str] = []
for _ in range(next(data)): # T 组测试用例
m, n = next(data), next(data)
rows: list[list[int]] = []
for _ in range(m):
length = n # 题面保证每个序列恰好 n 个数
rows.append(sorted(islice(data, length))) # 只有最小的 n 个数可能参与答案,先排好序
top = rows[0] # 只取第一个序列时,「最小的 n 个和」就是它自己
for row in rows[1:]:
top = merge_top_n(top, row, n)
out.append(' '.join(map(str, top)))
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 16:00
* update_at: 2026-10-06 16:00
*/
#include <algorithm>
#include <functional>
#include <iostream>
#include <queue>
using namespace std;
const int MAXN = 2005;
typedef long long ll;
int m, n;
ll cur[MAXN]; // 前若干个序列能凑出的最小的 n 个和,非降序
ll row[MAXN]; // 当前读入的这一个序列,排序后前 n 个才可能参与答案
ll res[MAXN]; // 合并 cur 与 row 之后得到的新的最小的 n 个和
// 小顶堆里存的是打包后的候选和:key = (和 << 22) | (i << 11) | j。
// 非负数的数值大小先由高位决定,所以对 key 做小顶堆等价于按和排序。
// i、j 都不超过 n-1 <= 1999 < 2^11,占 11 位。
priority_queue<ll, vector<ll>, greater<ll> > heap;
// 把 cur 与 row(都非降序,长度均为 n)加法表里最小的 n 个和写入 res。
// 加法表在行、列两个方向都单调不减,等价于在矩阵上做多路归并。
// 每个格子只由固定的一个前驱生成:非第一列由左邻生成,第一列由上一行开头顺延生成。
void merge_top_n() {
while (!heap.empty()) heap.pop();
heap.push(((cur[0] + row[0]) << 22));
for (int t = 0; t < n; t++) {
ll key = heap.top();
heap.pop();
ll sum = key >> 22;
ll i = (key >> 11) & 2047;
ll j = key & 2047;
res[t] = sum;
// 第 0 列:顺延生成下一行的开头
if (j == 0 && i + 1 < n) {
heap.push(((cur[i + 1] + row[0]) << 22) | ((i + 1) << 11));
}
// 同一行内向右推进一格
if (j + 1 < n) {
heap.push(((cur[i] + row[j + 1]) << 22) | (i << 11) | (j + 1));
}
}
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int T;
cin >> T;
while (T--) {
cin >> m >> n;
for (int k = 0; k < m; k++) {
for (int i = 0; i < n; i++) {
cin >> row[i];
}
sort(row, row + n);
if (k == 0) {
// 只有一个序列时,最小的 n 个和就是它自己
for (int i = 0; i < n; i++) cur[i] = row[i];
} else {
merge_top_n();
for (int i = 0; i < n; i++) cur[i] = res[i];
}
}
for (int i = 0; i < n; i++) {
if (i > 0) cout << ' ';
cout << cur[i];
}
cout << '\n';
}
return 0;
}复杂度
设一轮合并处理的两张表长度均为
- 预处理:
个序列各排序一次, 。 - 合并:共
轮,每轮弹 次、每次最多推 2 个元素,堆规模不超过 ,单次堆操作 ,故每轮 ,合计 。
总时间 heapq 由 C 实现,本题最大真实数据实测 0.145s、37.5 MB)。
两个实现细节:
- 为什么弹
次而不是弹到堆空? 我们只要前 小,堆里的候选数始终是 ,多弹只会浪费。 - 为什么
if j == 0 and i + 1 < n之后就结束推入,还要再写一个if j + 1 < n? 前一个分支负责「换行」,后一个负责「同行右移」,两者对同一个格子是并列关系而非互斥:弹出时既要推 ,也要推 。
总结
- 题目要求
个和中的前 小,先做减法:只需保留前 小的中间状态,巨型状态空间立刻塌缩成一张长度 的表。 - 合并两张非降表的加法表是行列双单调的矩阵,取前
小等价于在它上面做 路归并;单轮的复杂度从 降到 。 - 多路归并的常见坑是重复入堆。本题用「每格只由固定的一个前驱生成」替代哈希判重,逻辑更短也更快;每个格子的生成者是左邻,第一列则顺延到下一行开头。
- 总复杂度
。同一做法换成 C++ 只需把小顶堆换成 priority_queue,其余结构完全一致。