多路归并


优先队列与多路归并进阶笔记

优先队列(堆)不仅可以用来求极值,更是解决“第 K 大/小问题”“有序状态转移”的核心数据结构。以下按难度递增整理了五种经典模型。

1. 基础模型:两个有序序列的第 K 小和

来源文件P_1631_序列合并.cpp

  • 🏷️ 题面抽象:给定两个长度为 $N$ 的升序数组 $A$ 和 $B$。从 $A$ 和 $B$ 中各任取一个数相加,求所有可能组合中最小的 $N$ 个和。
  • 💡 核心思路
  • 暴力痛点:全组合有 $N^2$ 种,直接排序必定超时。
  • 多路归并:将问题转化为合并 $N$ 个有序表。以 $B$ 数组为基准,初始构造 $N$ 个组合:$A[1]+B[1], A[1]+B[2], \dots, A[1]+B[N]$,将它们压入小根堆,并记录此时用的是 $A$ 的哪个索引(初始都是 1)。
  • 动态转移:每次从堆顶取出最小的和输出。如果取出的组合是 $A[i]+B[j]$,那么它的下一个潜在较小候选者必定是 $A[i+1]+B[j]$。将原和减去 $A[i]$ 加上 $A[i+1]$ 后压入堆中即可。循环 $N$ 次完成。
点击查看代码
struct Node{
	int sum;
 //   int cnt;
	int ia;
	bool operator < (const Node &v) const{
		return sum > v.sum;
	}	
};



void  solve() {
int n;
cin >> n;
vector<int> a(n+1),b(n+1);
for(int i=1;i<=n;i++)
 cin >> a[i];
for(int i=1;i<=n;i++)
  cin >> b[i];
priority_queue<Node> q;
for(int i=1;i<=n;i++){
	 q.push({a[1] + b[i],1});
}

//不会重复
int N = n;
while(N --){
	auto [sum,ia] = q.top();
	q.pop();
	cout << sum << ' ';
	if(ia + 1 <= n){
		q.push({sum - a[ia] + a[ia + 1],ia + 1});
	}
}


}

2. 进阶模型:N 个有序序列的第 K 小和 (多路转二路)

来源文件P_2409_Y的积木.cpp

  • 🏷️ 题面抽象:有 $N$ 组积木,每组有若干个。从每组中恰好挑出一个积木,求所有可能组合中前 $K$ 小的体积和。
  • 💡 核心思路
  • 降维打击:直接处理 $N$ 个数组的状态过于复杂。我们可以利用结合律,将多路归并转化为重复的二路归并
  • 滚动合并:先将第 1 组和第 2 组进行二路归并(套用上面的《序列合并》逻辑),只保留前 $K$ 小的组合,形成一个新的长度为 $K$ 的数组。
  • 然后将这个新数组与第 3 组合并,再保留前 $K$ 小... 依次类推,合并 $N-1$ 次后,最终数组里的就是全局前 $K$ 小的和。
点击查看代码
//多路转二路归并
struct Node{
	int sum;

	int ia;
	bool operator < (const Node &v) const{
		return sum > v.sum;
	}	
};


void  solve() {
    int n,k;
	cin >> n >> k;
	vector<vector<int>> g(n,vector<int> ());
	for(int i=0;i<n;i++){
		int m;
		cin >> m;
		for(int j=1;j<=m;j++){
			int t;
			cin >> t;
			g[i].push_back(t);
		}
		sort(g[i].begin(),g[i].end());
	}
	//二路合并
	auto merge = [&](vector<int> &a,vector<int> &b){
		priority_queue<Node> q;
		for(int i=0;i<b.size();i++){
			q.push({a[0] + b[i],0});
		}
		vector<int> res;
		int K = k;
		while(K --){
			auto [sum,ia] = q.top();
			q.pop();
			res.push_back(sum);
			if(ia + 1 < a.size()){
				q.push({sum - a[ia] + a[ia + 1],ia + 1});
			}
		}
		return res;
	};
    for(int i=1;i<n;i++){
		g[i] = merge(g[i-1],g[i]);
	} 
	for(int i=0;i<k;i++){
		cout << g[n-1][i] << ' ';
		
	}
}


3. 动态自生成归并:丑数模型

来源文件超级丑数.cpp

  • 🏷️ 题面抽象:给定一组质数集合,规定丑数是质因数只能从该集合中取的正整数。求第 $N$ 个丑数。
  • 💡 核心思路
  • 动态多路归并:这本质上还是多路归并,但每个“路”的数组不是预先给定的,而是由已经求出的丑数动态生成的
  • 状态定义:维护一个已经求出的丑数数组 dp。堆中维护节点 {当前生成值, 使用的质数索引 p, 对应的 dp 数组指针 d}
  • 转移逻辑:每次取出堆顶最小的数,如果与 dp 尾部元素不重复,则加入 dp。随后,生成该质数路线上的下一个候选值:dp[d + 1] * primes[p],将其压入堆中。
点击查看代码
  struct Node{
      int x;
      int p;//路径
      int d;//丑数数组走到哪了
      bool operator < (const Node &v) const{
         return x > v.x;
      }
   };
//多重路径归并
void  solve() {
    int n;
    cin >> n;
    vector<int> primes(4);
    for(int i=0;i<4;i++)
        cin >> primes[i];
 vector<int> dp;
        dp.push_back(1);
        int len = primes.size();
        priority_queue<Node> q;
        for(int i=0;i<len;i++){
            q.push({dp[0]*primes[i],i,0});
        }
  //   int N = n;
     while(dp.size() < n){
         auto [x,p,d] = q.top();
         q.pop();
         if(dp.back() != x)
           dp.push_back(x);

         q.push({dp[d + 1] * primes[p],p,d + 1 });
     }
   cout << dp[n - 1] << endl;
}

4. 批量贪心与余数处理:果子合并变体

来源文件智乃的果子.cpp

  • 🏷️ 题面抽象:给定若干堆果子,已知其重量和数量。每次可以将两个果子合并,合并的代价是它们的重量之和,新果子的重量也是它们之和。求将所有果子合并为 1 个(或无法继续合并)的最小总代价(带取模)。
  • 💡 核心思路:哈夫曼树的变体,但数据量要求必须进行批量处理
  • 痛点:数量 cnt 可能非常大,一个一个合并会 TLE。
  • 同级批量合并:由于优先合并重量最小的果子,当遇到重量为 $W$、数量为 $cnt$ 的果子时,它们两两合并是最优的。直接计算 $cnt / 2$ 对果子合并的代价,并将生成的新果子 {2W, cnt / 2} 压入堆中。
  • 余数跨级合并:如果 $cnt$ 为奇数,剩下的 1 个果子怎么办?必须将其与当前堆中下一个最小的果子强行合并。从堆中再 pop 出一个果子,与这单个果子合并计算代价,并将新生成的果子和剩余的同级果子重新推回堆中。
点击查看代码
void solve() {
    int n;
    cin >> n;
    vector<pii> a(n + 1);
    for (int i = 1; i <= n; i++) {
        int u, v;
        cin >> u >> v;
        a[i] = {v, u};
    }
    int ans = 0;
    priority_queue<pii, vector<pii>, greater<pii>> q;

    for (int i = 1; i <= n; i++) {
        q.push(a[i]);
    }
    // first 是 重量 second 是数量

    while (q.size()) {
        auto [w, cnt] = q.top();
        q.pop();
        // 数量/2 贡献是  cnt/2 * 2w
        int tmp = (((cnt / 2) * w % mod) * 2) % mod;
        ans = (ans + tmp) % mod;
        if (cnt / 2 > 0) q.push({2 * w, cnt / 2});
        int less = cnt % 2;
        // 和其他的合并
        if (less > 0) {
            if (q.empty()) break;
            auto [nw, ncnt] = q.top();
            q.pop();
            ans = (ans + (nw + w) % mod) % mod;
            q.push({nw + w, 1});
            if (ncnt > 1) {
                q.push({nw, ncnt - 1});
            }
        }
    }
    cout << ans % mod << endl;
}

5. 状态空间的高维搜索:K-th Largest 组合代价

来源文件E_Cookies.cpp

  • 🏷️ 题面抽象:从给定的数组中选择 $K$ 个元素(可重复选择),求所有可能组合中第 $X$ 大的元素和。
  • 💡 核心思路:正难则反,将“求最大”转化为“从理想最大值开始扣除最小代价”。
  • 初始化基准:先将数组降序排序。理想最大和显然是 $A[1] \times K$(把 $K$ 个名额全给最大值)。
  • 代价优先队列:堆中维护的是“代价(cost)”,即相比于理想最大和,我们折损了多少。节点结构 {当前处于的数组索引 i, 已经降级的元素个数 cnt, 当前累计的代价 cost}
  • 降级转移树:初始代价是将一个 $A[1]$ 降级为 $A[2]$,代价为 $A[1] - A[2]$。每次取出最小代价后,衍生出两个分支:
  1. 深度降级:把当前正在降级的这个元素,继续往下降低一档,从 $A[i]$ 降为 $A[i+1]$(代价增加 $A[i] - A[i+1]$)。
  2. 广度降级:再拉一个 $A[1]$ 过来,将其也降级为 $A[i]$(条件是降级的总个数 cnt < K,代价增加 $A[1] - A[i]$)。
  • 每次从堆中弹出一个代价,理想最大值 - 当前代价 即为按降序生成的下一个和。循环 $X-1$ 次即可找到答案。
点击查看代码
struct Node {
    int i;
    int cnt;
    int cost;

    bool operator<(const Node &v) const {
        return cost > v.cost;
    }
};

void solve() {
    int n, k, x;
    cin >> n >> k >> x;
    vector<int> a(n + 1);
    for (int i = 1; i <= n; i++)
        cin >> a[i];
    sort(a.begin() + 1, a.end(), greater<int>());
    priority_queue<Node> q;
    int sum = a[1] * k;

    cout << sum << endl;
    x--;
    q.push({2, 1, a[1] - a[2]});
    while (x > 0 && q.size()) {
        auto [i, cnt, cost] = q.top();
        q.pop();
        // 前一个变成 这一个
        cout << sum - cost << endl;
        x--;
        if (cnt < k) {
            int cost2 = a[1] - a[i];
            q.push({i, cnt + 1, cost2 + cost});
        }

        // 当前 变成 下一个
        if (i + 1 <= n) {
            int cost2 = a[i] - a[i + 1];
            q.push({i + 1, cnt, cost + cost2});
        }
    }
}
posted @ 2026-05-16 19:46  r_123  阅读(26)  评论(0)    收藏  举报