← 目录 第 28 / 60 节 全部 60 节

28 · 双端队列与优先队列

把 BFS 的 queue 拆成两种新工具:deque 用两端插入把 0-1 BFS 做到 O(V+E),priority_queue 用堆顶取最值解决多路归并与 Top-K。重点在“队头最小”的不变式与比较器方向。
普及/提高-预计 60 分钟dequepriority_queue0-1 BFS
本节出现的新函数 / 新语法(先看懂再用)

第 26 节的 BFS 只有一条规矩:所有点从队尾进、从队头出。这条规矩之所以对,是因为每条边的长度都等于 1 —— 先被发现的点一定不比后发现的远。可一旦边权出现 0,这个前提就断了:一个点是绕了三步才发现的,距离却可能和第一步就发现的点一样。“第几层”不再等于“多远”,所以要换容器。

本节的另一半内容方向刚好相反:有些问题根本不关心先后顺序,只关心“当前最小的是谁”。这时用 priority_queue(二叉堆),每次取最值 O(log n),付出的代价是彻底放弃顺序。

三种容器,三种规矩:queue 守顺序,deque 守“距离只差 0 或 1”,priority_queue 守“堆顶最值”。选错了不是慢一点,是答案错。
容器元素怎么进出能拿到什么单次代价用在哪种题上
queue<int>队尾进、队头出最旧的元素O(1)边权全为 1 的 BFS、按层扩散
deque<int>两端都能进、两端都能出最旧的和最新的O(1)0-1 BFS、滑动窗口最值(第 19 节)
priority_queue<int>随便进、只能从堆顶出当前最大/最小O(log n)每步取当前最优、多路归并、Top-K

表里只有 deque 那一行是新的容器,另外两个前面都见过。但“见过”和“能说清它为什么能两端 O(1) 插入”是两件事 —— deque 的内存是分段的:一段一段申请,用一张表记录各段的首地址,头插就往前一段挂,尾插就往后一段挂,所以不像 vector 那样要把整块数据搬家。代价是元素不保证连续,&dq[0] + n 这种指针算术在 deque 上是危险的。

新函数拆解 · deque:双端队列的两端操作
deque<int> dq; dq.push_back(x); dq.push_front(x); dq.pop_back(); dq.pop_front(); dq.front(); dq.back();
组成含义 / 格式要求
deque<int>头文件 <deque>(bits 已包含)。带下标的双端队列,两端插入删除都是 O(1)。
push_back(x) / push_front(x)在队尾 / 队头插入一个元素。这是 deque 与 vector 的实质区别:vector 的 push_front 是不存在的,insert(begin(), x) 是 O(n)。
pop_back() / pop_front()删除队尾 / 队头元素,O(1)。返回 void,不返回值;要取值必须先 back() / front()。
front() / back()读队头 / 队尾,O(1),不删除。容器为空时调用是未定义行为,通常表现为 RE。
empty() / size()判空 / 元素个数。pop 之前先判空,是本节所有代码的固定动作。
dq[i]支持下标访问,O(1)。但一旦用下标,说明你只需要随机访问,那就该用 vector 了。
最小可运行示例
示例
deque<int> dq;
dq.push_back(1);     // [1]
dq.push_back(2);     // [1, 2]
dq.push_front(0);    // [0, 1, 2]      只有 deque 能这么干
cout << dq.front() << dq.back();        // 02
dq.pop_front();      // [1, 2]
dq.pop_back();       // [1]

deque<int> d2;
// d2.front();       // 空容器上取 front:未定义行为,现场 RE
if (!d2.empty()) d2.pop_front();        // 正确写法:先判空
注意

deque 没有 reserve(),也不能用 memcpy / 指针遍历 —— 内存是分段的。

0-1 BFS 里只有边权为 0 才允许 push_front。把边权为 1 的也插到队头,队列的距离序列不再递增,同一个点会被反复松弛,答案虽然还对,但最坏退化成 SPFA 量级 → TLE。

新函数拆解 · priority_queue 的三参数模板
priority_queue<T, Container, Compare> q;
组成含义 / 格式要求
T(第 1 个参数)元素类型。可以是 int、long long、pair<int,int>,或自己写的 struct。
Container(第 2 个)底层容器,默认 vector<T>。要求随机访问 + push_back + pop_back,所以只能是 vector 或 deque,写 list 会编译失败。平时照抄 vector<T>。
Compare(第 3 个)比较器,回答“谁排在后面”。默认 less<T>,于是 top() 是最大值;写 greater<T> 则 top() 是最小值(第 17 节用过)。
三个参数必须写全想换比较器,前两个也得一起写出来。只写 priority_queue<int, greater<int>> 会得到一屏指向 <bits/stl_queue.h> 的模板报错 —— 因为第二个位置固定是容器。
自定义比较器写函数对象:struct Cmp { bool operator()(const Node &x, const Node &y) const { return x.w > y.w; } },然后 priority_queue<Node, vector<Node>, Cmp> q; 不能直接传函数名,除非用 decltype 取它的类型。
Compare 的语义Cmp(a, b) == true 表示 a 的优先级更低、排在 b 后面。所以想要小根堆,就要让“大的”优先级低 —— 传 greater,或写 x.w > y.w。
最小可运行示例
示例
// 写法一:小根堆
priority_queue<int, vector<int>, greater<int>> small;

// 写法二:pair 默认按 first 比,first 相同再比 second
priority_queue<pair<int, int>> p;              // 默认大根堆
p.push({2, 5}); p.push({2, 3}); p.push({1, 9});
cout << p.top().first << ' ' << p.top().second; // 2 5(first 同,second 大的在前)

// 写法三:自定义比较器,按 w 小的优先
struct Node { int w, id; };
struct Cmp { bool operator()(const Node &x, const Node &y) const { return x.w > y.w; } };
priority_queue<Node, vector<Node>, Cmp> q;
注意

priority_queue 不能遍历、不能删除任意元素、不能修改已入堆元素的值(改了也不会重排)。要“改值”就再 push 一份新的,取出时和记录里的最新值比一下,旧的那份就跳过。

pair 的默认比较是字典序:{dist, id} 放进小根堆,距离小的先出,距离相同时 id 小的先出。如果题目对同距离的顺序敏感,就别依赖这个次序。

例题 · 最小函数值洛谷 P2085普及/提高-去洛谷做这题 ↗

有 n 个函数 F_i(x) = a_i·x² + b_i·x + c_i,定义域是正整数 x = 1, 2, 3, …。把所有函数在所有正整数上取到的值合在一起,求其中最小的 m 个,按升序输出(重复的值各算一个)。

输入格式:第一行两个整数 n、m;接下来 n 行,每行三个正整数 a_i、b_i、c_i。

输出格式:m 行,每行一个整数,依次为第 1 小、第 2 小……第 m 小的函数值。

数据范围:1 ≤ n, m ≤ 10⁴,a_i 恒为正(保证每个函数在正整数上单调递增)。

把无穷多个值砍成 n 个有序序列

第一步:每个函数自己是有序的。a_i > 0,抛物线开口向上,对称轴在 x = −b_i/(2a_i) < 0,所以整数部分 x ≥ 1 整段都落在增区间上:F_i(1) ≤ F_i(2) ≤ F_i(3) ≤ …。这一步把“无穷多个值”变成了“一条递增序列”。

第二步:问题变成 n 路归并。每个函数贡献一条递增序列,全局前 m 小就是这 n 条序列归并后的前 m 项。这和第 17 节合并果子是同一个结构:每一步在 n 个候选里取最小。

第三步:候选只需要 n 个。对第 i 个函数,只把“它当前还没输出的最小项”放进堆。堆顶就是全体候选的最小值,输出它,然后让这个函数往前走一格(x 加 1)再放回去。其他函数的候选没变,不用动。

为什么不会漏。堆里始终装着每条序列“尚未输出部分”的首元素。全局最小必然等于某条序列尚无输出的首元素,所以它一定在堆里 —— 取堆顶就是取全局最小。

为什么只推进 m 次。输出 m 个数就结束;每个函数最多被推进 m 次,堆的大小恒为 n,总复杂度 O((n + m) log n)。n = m = 10⁴ 时是 2×10⁴ 次堆操作、每次约 14 次比较,总共几十万次比较,可以忽略不计。

每次输出的六个动作
// 核心只有三步:取堆顶 -> 输出 -> 同一函数推进一格
Node cur = pq.top();
pq.pop();
cout << cur.val << '\n';
int nx = cur.x + 1;                   // 只推进这一个函数,别的候选不动
long long nv = f[cur.id].a * nx * nx + f[cur.id].b * nx + f[cur.id].c;
pq.push({nv, cur.id, nx});

函数值最大能到 a_i·x² 量级,x 可以到 10⁴,所以乘积必须用 long long 存,这一点在代码里靠 long long a 让整条表达式自动提升。

完整可编译代码(C++17)
#include <bits/stdc++.h>
using namespace std;

struct Func { long long a, b, c; };   // 系数只存一份,堆结点里不重复存

struct Node {
    long long val;                    // 这个函数当前能给出的最小值
    int id, x;                        // 来自第 id 个函数,自变量取 x
    // 小根堆:val 大的"排在后面"。方向和 sort 的 cmp 相反(第 17 节)
    bool operator<(const Node &o) const { return val > o.val; }
};

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n, m;
    cin >> n >> m;
    vector<Func> f(n);
    priority_queue<Node> pq;          // 大小恒为 n:每个函数只放一个候选

    for (int i = 0; i < n; ++i) {
        cin >> f[i].a >> f[i].b >> f[i].c;
        long long v = f[i].a + f[i].b + f[i].c;   // F_i(1) = a+b+c,a 是 long long 自动提升
        pq.push({v, i, 1});
    }

    for (int t = 0; t < m; ++t) {
        Node cur = pq.top();
        pq.pop();                     // pop 不返回值,所以先把堆顶存下来
        cout << cur.val << '\n';

        // 只让同一个函数往前走一格:其他函数的候选本来就在堆里,不需要重放
        const Func &g = f[cur.id];
        int nx = cur.x + 1;
        long long nv = g.a * nx * nx + g.b * nx + g.c;
        pq.push({nv, cur.id, nx});
    }
    return 0;
}
逐行拆解
代码片段含义与理由
struct Func { long long a, b, c; };系数单独存在 vector 里。堆里每个结点只带 id,重算时回表取系数,结点小、拷贝便宜。
bool operator<(const Node &o) const { return val > o.val; }整个算法的方向由这一行决定。写成 val < o.val 就是大根堆,每次弹出当前最大值,答案整片错。末尾的 const 不能漏,漏了报错指向模板深处。
long long v = f[i].a + f[i].b + f[i].c;初始候选是 x = 1 的值。因为 a 是 long long,加法的每一步都按 64 位算,不会溢出。
priority_queue<Node> pq;三个模板参数这里只写了第一个,因为另外两个用默认值(vector<Node> 和 less<Node>),而 less<Node> 会去调我们刚重载的 operator<。
Node cur = pq.top(); pq.pop();先复制再删。pq.pop() 返回 void,直接写 cur = pq.pop() 编译不过。
cout << cur.val << '\n';输出的是值,不是下标、也不是 x。这里最容易在改代码时手滑写成 cur.id。
const Func &g = f[cur.id];引用而不是拷贝,避免每次迭代复制三个 long long;const 只是表明不修改。
pq.push({nv, cur.id, nx});把同一个函数的下一个值放回去。写成别的 id,那条序列就会断层,答案偏大。
pq 的大小恒为 n初始 n 个候选,每轮一出一进,所以堆永远是 n 个元素。O(m log n) 的前提就靠这一点。
同一个堆,换个方向:Top-K 只留 k 个

问题:n 个数里找出最大的 k 个(k 远小于 n),n 可以到 10⁶。排序是 O(n log n),而只留 k 个候选可以做到 O(n log k)。

做法:维护一个大小为 k 的小根堆。堆顶是“当前第 k 大”,也就是候选里最差的那个。新来的数只有比堆顶大,才可能挤进前 k,于是弹掉堆顶、把新数放进去;否则直接扔掉。

为什么这样对:扫描到任意位置时,堆里恰好是“已扫描部分最大的 min(k, 已扫描个数) 个”。归纳一步:新数 x 若 ≤ 堆顶,则堆里已有 k 个数 ≥ 堆顶 ≥ x,x 挤不进前 k(最多与第 k 名并列);若 x > 堆顶,堆顶是 k 个里最差的,用 x 换掉它,剩下的仍然是最大的 k 个。

Top-K 的核心代码
// n 个数里最大的 k 个,按降序输出
priority_queue<int, vector<int>, greater<int>> q;   // 小根堆,堆顶 = 第 k 大
for (int i = 0; i < n; ++i) {
    int x;
    cin >> x;
    if ((int)q.size() < k) q.push(x);               // 前 k 个直接进
    else if (x > q.top()) {                         // 只有更大才值得换
        q.pop();
        q.push(x);
    }
}
vector<int> ans;
while (!q.empty()) { ans.push_back(q.top()); q.pop(); }   // 弹出是升序
reverse(ans.begin(), ans.end());                   // 翻成降序

两个必须记住的细节:①堆的大小没到 k 时不能读 q.top()(空堆 RE),所以先判 size;②求“最小的 k 个”时方向完全反过来 —— 用大根堆,堆顶是第 k 小,条件是 x < 堆顶。堆顶永远是“当前候选里最差的那个”,被替换的永远是它。

代价对比:n = 10⁶、k = 10 时,log k ≈ 3.3,总比较约 3.3×10⁶ 次;排序要 2×10⁷ 次,差一个量级。k 接近 n 时两者差不多,直接 sort 更省事。

0-1 BFS:只有两种边权时,deque 比堆便宜

模型:n × m 的网格,每格有权 0 或 1。从左上角走到右下角,每步上下左右,进入一格的代价等于该格的权值,求最小总代价。

普通 BFS 不行。BFS 的正确性依赖“先发现的更近”,而这里边权有 0:同一个点可能被一条长路先发现(层数多),却被一条短路后到达(总代价小)。层数和距离脱钩了。

Dijkstra 行,但贵。nm 个点各有一个 dist,每次取最小要堆,复杂度 O(nm log(nm))。可是这里边权只有 0 和 1,dist 的最小值只有两种可能:当前值 d,或 d + 1。两种值只需要两个“桶”,而 deque 恰好能当两个桶用 —— 这就是 0-1 BFS。

规矩只有两条:从队头取点 u 扩展;若走到 v 的边权是 0,把 v push_front,若是 1,把 v push_back。

为什么队头一定是最小的。设当前 dq 里的距离序列非递减,且只含 d 与 d + 1 两种值。从队头取出 dist = d 的点 u:边权 0 的新点距离也是 d,插到队头不破坏非递减;边权 1 的新点距离是 d + 1,插到队尾也不破坏非递减。两条规矩都是为了保住这个不变式。于是每次取出的都是当前最小,和 Dijkstra 一样,只是“堆”退化成了两个桶。

0-1 BFS 的完整骨架
#include <bits/stdc++.h>
using namespace std;

const int N = 1005;
const int INF = 0x3f3f3f3f;
int n, m;
int w[N][N], d[N][N];
int dx[4] = {-1, 1, 0, 0}, dy[4] = {0, 0, -1, 1};

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> n >> m;
    for (int i = 0; i < n; ++i)
        for (int j = 0; j < m; ++j) {
            cin >> w[i][j];
            d[i][j] = INF;
        }

    deque<int> dq;                       // 存编号 x*m+y,省一个 pair 的拷贝
    d[0][0] = 0;
    dq.push_front(0);                    // 源点距离最小,从队头进
    while (!dq.empty()) {
        int id = dq.front();
        dq.pop_front();
        int x = id / m, y = id % m;      // 注意除的是列数 m,不是行数 n
        for (int k = 0; k < 4; ++k) {
            int nx = x + dx[k], ny = y + dy[k];
            if (nx < 0 || nx >= n || ny < 0 || ny >= m) continue;
            int nd = d[x][y] + w[nx][ny];
            if (nd >= d[nx][ny]) continue;   // 不比已知更优,就不必再进队
            d[nx][ny] = nd;
            if (w[nx][ny] == 0) dq.push_front(nx * m + ny);   // 0 边:插队头
            else                dq.push_back(nx * m + ny);    // 1 边:排队尾
        }
    }
    cout << d[n - 1][m - 1] << '\n';
    return 0;
}
0-1 BFS 的四个要点
代码片段含义与理由
deque<int> dq; dq.push_front(0);源点必须从队头进:它的距离 0 是全图最小的。用 push_back 也碰巧对,但会破坏“队头最小”的阅读直觉。
int x = id / m, y = id % m;编号是按行铺开的,所以模和除都用列数 m。用成 n 时只有方阵能过,非方阵立刻 WA —— 这类错极难自查。
if (nd >= d[nx][ny]) continue;这里没有 visited,而且不能有:同一个点可能入队两次 —— 先被一条 1 边松弛到 d + 1,再被一条 0 边改小到 d(新的一份插到队头,会先出队)。所以判断条件必须是 nd < d[nx][ny]。这一行也是复杂度的保证:每个点的 dist 最多被改小一次,即最多入队两次,总代价 O(V + E)。
w[nx][ny] == 0 ? push_front : push_back整个算法只有这一行在区分两种边。判断的对象是进入 v 的这条边的权值,不是 u 的权值,也不是 dist 的奇偶。写错就退化成 SPFA。
cout << d[n-1][m-1]终点代价含终点自己的权值(因为代价计在“进入”上)。若题面把代价定义在出发格,就要改成 d[n-1][m-1] + w[0][0] —— 读题时确认一次。
这一节的 7 个坑,每个都有具体症状

空 deque 上调用 front() / back() / pop_front():未定义行为,几乎必然 RE。0-1 BFS 里忘记 push 源点,或者把 while 写成 do-while,第一轮就访问空容器。

0-1 BFS 把 0 边的 push_front 写成 push_back:距离序列不再非递减,同一个点被反复松弛,答案仍然正确但会超时(TLE)。这种错在随机小数据上完全看不出来,必须用长链数据压。

0-1 BFS 用 int x = id / n:只有 n == m 时才对,矩形网格一测就 WA。牢记“行优先编号,除和模都用列数”。

priority_queue 自定义比较器方向写反(想要小根堆却写 return val < o.val;):只有一个函数时看不出问题,n 个函数立刻 WA。方向口诀:cmp 写“谁在前”,operator< 写“谁在后”。

Top-K 用默认大根堆还写 if (x > q.top()):堆顶是候选里最大的,新数几乎永远比不过它,结果输出的是前 k 个输入数 → WA(数据 5 1 9 3 7 2、k = 3 时会输出 1 5 9,正确答案 9 7 5)。

用 int 存函数值或前缀乘积:P2085 里 a·x² 可到 10¹¹,溢出成负数后堆顶跑到最小端,输出一整片负数 → WA。凡是“平方”“乘积”“累加”,先估一遍量级。

想修改已经 push 进堆的元素(比如把某个点的 dist 改小后直接改结构体字段):堆内部顺序不会重排,之后 top() 拿到的是错的值 → WA。正确做法是 push 一份新值,取出时判旧值并跳过。

自己动手复现

关掉页面,把 P2085 的代码从零敲一遍并提交。

自测数据:2 4 / 1 1 1 / 1 1 2,两个函数的值是 3,7,13,21 和 4,8,14,22,答案应为 3 4 7 8。手算一遍再对拍。

改一改①:把 operator< 里的 val > o.val 改成 val < o.val(变成大根堆),同一组数据,输出从 3 4 7 8 变成 4 8 14 22 —— 四个数全取自第二个函数,第一个函数一次都没出队。只改一个字符,却是本节最高频的错。

改一改②:Top-K 代码把 greater<int> 删掉变成大根堆,跑 n = 6、k = 3、数据 5 1 9 3 7 2。正确答案是 9 7 5,改完得到 1 5 9 —— 亲手确认一次“堆顶是谁决定了一切”。

数学补给 · log 到底省了多少:堆的高度、Top-K 与两个桶

堆是一棵完全二叉树:n 个结点时高度 h = ⌊log₂ n⌋ + 1。一次 push 或 pop 沿着一条从根到叶的路径上下移动,最多比较 h 次,所以是 O(log n)。堆的所有复杂度都来自这一条路径。

n⌊log₂ n⌋堆高 hn·log₂n 次比较n·log₂k(k = 10)次比较
10³91010⁴3.3×10³
10⁵16171.7×10⁶3.3×10⁵
10⁶19202.0×10⁷3.3×10⁶
10⁸26272.7×10⁹3.3×10⁸

速记:2¹⁰ = 1024 ≈ 10³,所以每 10 次二分大约跨越 3 个十进制位。log₂(10⁶) = 19.9,log₂(10⁹) = 29.9 —— 十亿也就 30 层。竞赛里 n ≤ 10⁶ 时,log n 的常数按 20 估,堆操作 10⁶ 次约 2×10⁷ 次比较,在 1 秒内很宽裕。

Top-K 的收益就来自把 log n 换成 log k:n = 10⁶、k = 10 时是 3.3×10⁶ 对 2.0×10⁷,差 6 倍;k = 10³ 时是 10⁷ 对 2×10⁷,只差 2 倍 —— k 越大越不值得用堆,这是选择排序还是堆的量化依据。

0-1 BFS 省掉的则是整个 log。Dijkstra 每次要取 dist 最小的点,所以需要堆;而边权只有 0 和 1 时,队列中的 dist 只可能是 d 与 d + 1 两个值,“取最小”退化成“看队头属于哪个桶”,O(log V) 变成 O(1),总复杂度从 O(E log V) 降到 O(V + E)。

这类“值域很小的最短路”有统一名字:桶排序式的最短路(Dial 算法)。边权是 0…W 时开 W + 1 个桶循环使用,复杂度 O(E + W·V);W = 1 就是本节的双端队列版本。

举一反三 · 想清楚这些再往下走
  1. 如果边权只有 0、1、2 三种,deque 还够用吗?想想需要几个“桶”,以及为什么不能简单地用两个 deque 循环装(提示:取最小的时候要同时看两个桶的头)。
  2. P2085 如果 m 到 10⁹ 而 n 只有 10,堆还合适吗?提示:二分答案 mid,数一数每个函数有几个 x 满足 F_i(x) ≤ mid —— 解一元二次不等式,O(n log C) 就能定位第 m 小。
  3. Top-K 要改成“只求第 k 大、不要排序”时,还能更快吗?回忆第 13 节的 nth_element 与快速选择,为什么它的期望复杂度是 O(n) 而不是 O(n log k)?
  4. 把 0-1 BFS 的 deque 直接换成 priority_queue,答案还对吗?复杂度变成多少?在什么形状的数据上这两种写法几乎没差别?
  5. P2085 如果 a_i 允许为负,单调性就没了。这时该怎么办?提示:抛物线顶点在 x₀ = −b/(2a),从顶点往两边各是一条单调序列,相当于每个函数贡献两条序列。
  6. 为什么 0-1 BFS 的队列里元素距离“最多只差 1”?如果图里有 −1 权边,这个性质会怎么坏掉?再想想为什么负权图需要 Bellman-Ford 而不是 Dijkstra。
  7. 用 deque 求滑动窗口最大值(第 19 节单调队列)时,为什么被淘汰的元素从队尾弹,而不是队头?把“队头”和“队尾”的角色说清楚,你就真的懂 deque 了。
自测:能全部回答再进下一节
挑战题 · 序列合并
洛谷 P1631普及/提高-去洛谷做这题 ↗

有两个长度都是 N 的序列 A、B。在 A 中取一个数、在 B 中取一个数相加,一共能得到 N² 个和。求这些和里最小的 N 个,按升序输出。

输入格式:第一行一个整数 N;第二行 N 个整数 A_i;第三行 N 个整数 B_i。

输出格式:一行 N 个整数,为最小的 N 个和,从小到大排列,用空格隔开。

数据范围:1 ≤ N ≤ 10⁵,1 ≤ A_i, B_i ≤ 10⁹。

引导性提示(先只看这里)

N² 个和存都存不下(N = 10⁵ 时是 10¹⁰ 个),必须利用“有序”。先把 A、B 都升序排序,再观察结构。

固定 A_i,序列 A_i + B_1, A_i + B_2, …, A_i + B_N 是递增的(因为 B 已排序)。于是 N² 个和 = N 个递增序列的并,问题变成多路归并。

要求全局第 1 小,只需在这 N 个序列的首元素里取最小 —— 用一个堆维护这 N 个候选,取出之后只把被取走的那条序列往前推一格。

想看思路了?点开完整思路

排序。把 A、B 都升序排。设排好后 A_1 ≤ … ≤ A_N,B_1 ≤ … ≤ B_N。

转化。对每个 i,S_i = (A_i + B_1, A_i + B_2, …, A_i + B_N) 是一条非递减序列。N² 个和正是这 N 条序列的全部元素。求最小的 N 个 = N 路归并取前 N 项。

候选。每个序列只把首元素 A_i + B_1 放进堆:堆结点存 (和, i, j),i 是行号,j 是 B 的下标。堆里初始有 N 个元素。

循环 N 次。取堆顶输出;若 j < N,把同一行的下一个 (A_i + B_{j+1}, i, j+1) 放回堆。一出一进,堆大小恒为 N。

正确性。堆里始终是每条序列“尚未输出部分”的首元素,而序列内部非递减,所以全局最小必定是某个序列的首元素 —— 它一定在堆里。取出后只推进该序列,其余序列的首元素不变,不变式保持。

为什么只推进一格不会漏。S_i 的第 j+1 项只有在第 j 项被取走之后,才可能成为全局最小(它 ≥ 第 j 项,而第 j 项当时就在堆里且更小)。

复杂度。排序 O(N log N),归并 N 次、每次 O(log N),总共 O(N log N),N = 10⁵ 轻松过。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

const int N = 100005;
int n;
long long a[N], b[N];

struct Node {
    long long val;          // 和:A_i + B_j
    int i, j;               // 来自 A 的第 i 个、B 的第 j 个(1-indexed)
    // 小根堆:val 大的排在后面(与 sort 的 cmp 反着写)
    bool operator<(const Node &o) const { return val > o.val; }
};

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> n;
    for (int i = 1; i <= n; ++i) cin >> a[i];
    for (int i = 1; i <= n; ++i) cin >> b[i];
    sort(a + 1, a + n + 1);
    sort(b + 1, b + n + 1);      // 两个都要排,只排一个序列就不单调了

    priority_queue<Node> pq;
    for (int i = 1; i <= n; ++i)
        pq.push({a[i] + b[1], i, 1});    // 每条序列只放首元素,共 N 个候选

    for (int t = 1; t <= n; ++t) {
        Node cur = pq.top();
        pq.pop();                        // pop 不返回值,先存堆顶
        cout << cur.val << (t == n ? '\n' : ' ');

        if (cur.j < n) {                 // 该行还有下一个,放回去
            int nj = cur.j + 1;
            pq.push({a[cur.i] + b[nj], cur.i, nj});
        }
    }
    return 0;
}
关键片段拆解
片段作用
sort(a + 1, a + n + 1); sort(b + 1, b + n + 1);两个序列都必须升序。只排 A 的话,固定 A_i 得到的 A_i + B_j 就不再单调,“堆里放首元素”的前提直接崩了。
pq.push({a[i] + b[1], i, 1});初始候选是每条序列的最小项,共 N 个。不要把所有 N² 个和都塞进去 —— 内存和时间都不允许。
bool operator<(const Node &o) const { return val > o.val; }取最小 → 小根堆 → operator< 里写 val > o.val。方向反了就是每次输出当前最大,答案整片错。
cout << cur.val << (t == n ? '\n' : ' ');行末不能多空格,用一个条件表达式控制分隔符。写成先输出再判空也等价,但别两边都加空格。
if (cur.j < n)边界:j = N 时该行已经取完,不能再 push。不加这个判断会访问 b[N+1],读到 0 或脏数据 → WA。
pq.push({a[cur.i] + b[nj], cur.i, nj});只推进 cur 所在的这一行,i 必须沿用 cur.i。写成别的行号会让某条序列断层、另一条重复,答案错。
long long valA_i、B_i 到 10⁹,两个相加到 2×10⁹,已经贴着 int 上限;必须 long long。
这题最容易错的地方
  • 只排序一个序列:固定 A_i 后 A_i + B_j 不单调,堆里放的“首元素”不是最小,WA。
  • 把 N² 个和全部算出来再排序:N = 10⁵ 时是 10¹⁰ 个 long long,内存直接爆(MLE / RE)。
  • 弹出后忘记 push 下一项:堆会提前变空,循环还没走完就对空堆 top() → RE;只忘了 j 加 1(一直 push 同一个 j):输出一堆重复值 → WA。
  • 用 int 存和:2×10⁹ 溢出成负数后堆序全乱 → WA。
  • 行末多输出一个空格:本机看不出,严格比对时会 WA。
延伸阅读 / 参考