形式化题目

给定 mm 个长度均为 nn 的非负整数序列 A1,A2,…,AmA_1, A_2, \dots, A_m,其中 Ak=(ak,1,…,ak,n)A_k = (a_{k,1}, \dots, a_{k,n})。从每个序列各选一个元素求和,得到 nmn^m 个和(按重数计,不同的取法即使和相同也算两项),记这个多重集合为

S={∑k=1mak,ik  |  1⩽ik⩽n},∣S∣=nm.S = \left\{\sum_{k=1}^{m} a_{k,i_k} \;\middle|\; 1 \leqslant i_k \leqslant n\right\}, \qquad |S| = n^m .

求 SS 中最小的 nn 个值,按非降序输出。相等的值按出现次数各占一个输出位置。

样例中 A1=(1,2,3)A_1 = (1,2,3),A2=(2,2,3)A_2 = (2,2,3),3×3=93 \times 3 = 9 个和为

a2,1=2a_{2,1}=2 a2,2=2a_{2,2}=2 a2,3=3a_{2,3}=3
a1,1=1a_{1,1}=1 3 3 4
a1,2=2a_{1,2}=2 4 4 5
a1,3=3a_{1,3}=3 5 5 6

前 3 小是 3,3,43, 3, 4,即样例输出 3 3 4。

正解

思路

朴素做法:枚举 nmn^m 种取法、求每个和、排序取前 nn。m=1000m = 1000 时 nm=21000n^m = 2^{1000},连生成都做不到。

观察一:每轮只留前 nn 个数。 先只看前两个序列,设 F2F_2 是 {a1,x+a2,y}\{a_{1,x} + a_{2,y}\} 中最小的 nn 个和。再并入 A3A_3 时,把 F2F_2 换成完整的 n2n^2 个和没有意义:如果某个和用到了 F2F_2 中排在最小 nn 个之后的元素,那么「F2F_2 的每个元素 +min⁡A3+ \min A_3」已经给出了 nn 个不超过它的和,它进不了前 nn。于是可以递推:

F1=sort⁡(A1)[:n],Fk+1=top⁡n{ Fk[x]+Ak+1[y] },F_1 = \operatorname{sort}(A_1)[:n], \qquad F_{k+1} = \operatorname{top}_n\bigl\{\, F_k[x] + A_{k+1}[y] \,\bigr\},

答案就是 FmF_m。同理,每个序列里第 nn 个之后的元素永远用不上,先各自排序、截断到 nn 个。

观察二:单轮「取前 nn 小」= 行列单调矩阵上的 nn 路归并。 设两张表 A,BA, B 都已非降,把 n2n^2 个和排成矩阵

M[i][j]=A[i]+B[j](0⩽i,j<n).M[i][j] = A[i] + B[j] \qquad (0 \leqslant i, j < n).

因为 A,BA, B 非降,矩阵在行、列两个方向都单调不减:M[i][j]⩽M[i][j+1]M[i][j] \leqslant M[i][j+1] 且 M[i][j]⩽M[i+1][j]M[i][j] \leqslant M[i+1][j]。取前 nn 小就变成在这张矩阵上做多路归并:

  • 小顶堆的初始元素只有左上角 (M[0][0],0,0)(M[0][0], 0, 0);
  • 弹出堆顶(当前最小的候选和)写进答案,再把它的右邻和下邻推入堆(下一段给出不重不漏的推法);
  • 弹 nn 次结束。

之所以可以只推右邻和下邻,而不必事先建出整张 n2n^2 的表,就是因为行列单调:每个格子不比自己左上方的格子小,「边界」始终能覆盖住所有还没被弹出的最小值。

去重。 每个格子有两个前驱,直接推两个邻居会让同一个格子多次入堆。这里不去维护「已入堆集合」,而是约定每个格子的唯一生成者:

  • 第 00 列的格子 (i,0)(i, 0):只在弹出 (i−1,0)(i-1, 0) 时顺延生成;
  • 第 j>0j > 0 列的格子 (i,j)(i, j):只在弹出左邻 (i,j−1)(i, j-1) 时生成。

这样每个格子恰好被生成一次,代价是弹出 (i,0)(i,0) 时要多做一次「换行」判断:

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):每弹出一个最小和,最多放进两个新候选。右下方向一律不推,正是为了让每个格子只被生成一次。

用样例验证一遍:A=(1,2,3)A = (1,2,3),B=(2,2,3)B = (2,2,3)。

轮次 堆中候选 (和,i,j)(和, i, j) 弹出的和 注释
1 (3,0,0)(3,0,0) 33 左上角,然后推 (1,0)=4(1,0)=4 与 (0,1)=3(0,1)=3
2 (3,0,1), (4,1,0)(3,0,1),\ (4,1,0) 33 (0,1)(0,1) 出堆,推 (0,2)=4(0,2)=4
3 (4,0,2), (4,1,0)(4,0,2),\ (4,1,0) 44 和相同时用下标打破平局,取哪张都不影响值

弹出序列 3,3,43, 3, 4 与样例一致。两次 33 来自矩阵中两个不同的格子 (0,0)(0,0) 与 (0,1)(0,1),各占一个输出位置——这正是题目要求的多重集语义。

正确性要点:任何尚未弹出的格子 (i,j)≠(0,0)(i,j) \neq (0,0),沿「生成者链」向上回溯,一定有一个链上元素已经在堆里或已被弹出,而这个元素的值 ⩽M[i][j]\leqslant M[i][j](行列单调);因此堆顶永远不大于任何未弹出格子的值,弹出的第 kk 个就是第 kk 小。由观察一对 k=1,2,…,mk = 1, 2, \dots, m 归纳,最后一轮得到的正是全局最小的 nn 个和。

代码

Python 版:

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++ 版(同一算法):

cpp
/**
 * 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;
}

复杂度

设一轮合并处理的两张表长度均为 nn。

  • 预处理:mm 个序列各排序一次,O(mnlog⁡n)O(m n \log n)。
  • 合并:共 m−1m - 1 轮,每轮弹 nn 次、每次最多推 2 个元素,堆规模不超过 n+1n + 1,单次堆操作 O(log⁡n)O(\log n),故每轮 O(nlog⁡n)O(n \log n),合计 O(mnlog⁡n)O(m n \log n)。

总时间 O(mnlog⁡n)O(m n \log n),与堆优化的正解同阶;空间 O(mn)O(mn) 用于存输入,堆与结果各 O(n)O(n)。取 m=1000,n=2000m = 1000, n = 2000 时规模约 2×1062 \times 10^6 次弹出、4×1064 \times 10^6 次堆操作,Python 侧属于「可能 TLE 但不降级算法」的范围(heapq 由 C 实现,本题最大真实数据实测 0.145s、37.5 MB)。

两个实现细节:

  • 为什么弹 nn 次而不是弹到堆空? 我们只要前 nn 小,堆里的候选数始终是 O(n)O(n),多弹只会浪费。
  • 为什么 if j == 0 and i + 1 < n 之后就结束推入,还要再写一个 if j + 1 < n? 前一个分支负责「换行」,后一个负责「同行右移」,两者对同一个格子是并列关系而非互斥:弹出 (i,0)(i,0) 时既要推 (i+1,0)(i+1,0),也要推 (i,1)(i,1)。

总结

  • 题目要求 nmn^m 个和中的前 nn 小,先做减法:只需保留前 nn 小的中间状态,巨型状态空间立刻塌缩成一张长度 nn 的表。
  • 合并两张非降表的加法表是行列双单调的矩阵,取前 nn 小等价于在它上面做 nn 路归并;单轮的复杂度从 O(n2)O(n^2) 降到 O(nlog⁡n)O(n \log n)。
  • 多路归并的常见坑是重复入堆。本题用「每格只由固定的一个前驱生成」替代哈希判重,逻辑更短也更快;每个格子的生成者是左邻,第一列则顺延到下一行开头。
  • 总复杂度 O(mnlog⁡n)O(m n \log n)。同一做法换成 C++ 只需把小顶堆换成 priority_queue,其余结构完全一致。