把每个素数看成一个生产者,只维护它乘以第几个丑数;用小根堆取 K 个候选的最小值,打平者同步推进去重,O(N log K)。
OJ: roj
题目 ID: 2042
难度:普及+/提高-
标签:动态规划多路归并堆python
创建: 2026-10-01 04:42
更新: 2026-10-07 13:50
形式化题目
给定素数集合
(
求所有丑数从小到大排成的序列中第
样例 4 19 / 2 3 5 7 的丑数序列开头是
第 19 项是
正解
思路
先看直接枚举为什么行不通。 最直接的想法是从
所以要把目标改成:按从小到大的顺序,一个一个地把丑数"造"出来,而不是去找。
关键观察:每个素数自带一条单调递增的候选队列。 固定
其中
因为
反过来说:任何丑数都能写成某个
必须处理打平。 同一个值可能有多种拆分方式:样例里
从 (候选值, 素数下标):
- 堆顶就是全局最小候选值
,也就是下一个丑数, 读出; - 把
追加进 ; - 循环弹出所有堆顶等于
的元素,把对应素数的下标 ,再把新候选 推回堆——这一轮的打平者被一次性消费干净; - 重复
轮,输出 。
天真的实现里,"取最小"要扫 humble5 弹出 456706 次(humble11 只有 346678 次(
下面这段表格把样例的前 12 轮摊开。列是每个素数当前给出的候选值,加粗的是本轮的最小值,最后一列是这一轮被一起推进的素数。
| 轮次 | 取出 | 本轮推进 | ||||
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 5 | 7 | 2 | 2 |
| 2 | 4 | 3 | 5 | 7 | 3 | 3 |
| 3 | 4 | 6 | 5 | 7 | 4 | 2 |
| 4 | 6 | 6 | 5 | 7 | 5 | 5 |
| 5 | 6 | 6 | 10 | 7 | 6 | 2, 3 |
| 6 | 8 | 9 | 10 | 7 | 7 | 7 |
| 7 | 8 | 9 | 10 | 14 | 8 | 2 |
| 8 | 10 | 9 | 10 | 14 | 9 | 3 |
| 9 | 10 | 12 | 10 | 14 | 10 | 2, 5 |
| 10 | 12 | 12 | 15 | 14 | 12 | 2, 3 |
| 11 | 14 | 15 | 15 | 14 | 14 | 2, 7 |
| 12 | 16 | 15 | 15 | 21 | 15 | 3, 5 |
观察重点有两处。第一,每一列都是单调递增的,这正是"生产者队列"的性质,也是下标只增不减的依据。第二,第 5、9、10、11、12 轮的加粗格子都不止一个:第 5 轮
代码
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 04:42
# update_at: 2026-10-01 04:47
import sys
from heapq import heapify, heappop, heappush
def nth_humble(primes: list[int], n: int) -> int:
"""返回质因数全部取自 primes 的正整数中第 n 小的那个(1 不算丑数)。"""
# 每个素数 p_j 只管一条候选队列 a[0]*p_j, a[1]*p_j, ...;idx[j] 是它已用掉的乘数个数。
idx = [0] * len(primes)
a = [1] # a[0]=1 只是乘数起点,本身不是丑数
heap = [(p, j) for j, p in enumerate(primes)] # 堆元素带 j,弹出后才知道该推进哪个素数
heapify(heap)
for _ in range(n):
cur = heap[0][0] # 堆顶是所有队列的最小候选 = 下一个丑数
a.append(cur)
while heap[0][0] == cur: # 打平的队列一起推进,否则下一轮会重复产出 cur
_value, j = heappop(heap)
idx[j] += 1
heappush(heap, (a[idx[j]] * primes[j], j)) # 新候选引用刚生成的 cur,因而严格大于 cur
return a[n] # a[0] 是哨兵,第 n 个丑数正是 a[n]
def solve() -> None:
data = iter(map(int, sys.stdin.buffer.read().split()))
k, n = next(data), next(data)
primes = [next(data) for _ in range(k)]
print(nth_humble(primes, n))
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 09:33
* update_at: 2026-10-06 09:33
*/
// 丑数:质因数全部取自给定素数集合 S 的正整数(1 不算)。
// 多路归并:每个素数 p_j 对应一条候选队列 a[0]*p_j, a[1]*p_j, ...(a[0]=1 只是起点),
// 队列单调递增,用下标 idx[j] 表示已用掉几个乘数;下一个丑数是 K 个队首的最小值。
// 用小根堆取最小,O(N log K)。
#include <cstdio>
#include <queue>
#include <vector>
typedef long long ll;
const int MAXK = 105;
const int MAXN = 100005;
int k, n;
int p[MAXK]; // p[j]:素数集合 S 的第 j 个素数(下标从 0 开始)
ll idx[MAXK]; // idx[j]:素数 p_j 已经用掉的乘数个数,当前候选是 a[idx[j]] * p[j]
ll a[MAXN]; // a[0]=1 只是乘数起点不算丑数,a[i] 是从小到大第 i 个丑数
// 堆元素 (候选值, 素数下标):小根堆按候选值比较,弹出后才知道该推进哪个素数
struct Cand {
ll v;
int j;
};
// 堆的比较器:候选值小的先出堆
struct CandCmp {
bool operator()(const Cand &x, const Cand &y) const {
return x.v > y.v;
}
};
std::priority_queue<Cand, std::vector<Cand>, CandCmp> heap;
// 多路归并:循环 n 轮,每轮取堆顶最小候选作为下一个丑数,
// 打平(多个队列同时给出最小值)的队列一起推进,避免重复产出
ll solve() {
a[0] = 1; // 乘数起点哨兵,本身不算丑数
for (int j = 0; j < k; j++) {
idx[j] = 0;
Cand c;
c.v = p[j];
c.j = j;
heap.push(c); // 每条队列的初始候选 a[0] * p[j] = p[j]
}
for (int i = 1; i <= n; i++) {
ll cur = heap.top().v; // 所有队首的最小值就是下一个丑数
a[i] = cur;
// 把所有等于 cur 的队首都推进一格:它们已经交付过 cur,不许再交付第二次
while (heap.top().v == cur) {
int j = heap.top().j;
heap.pop();
idx[j]++;
Cand c;
c.v = a[idx[j]] * p[j]; // 新候选引用刚生成的 cur,因而严格大于 cur
c.j = j;
heap.push(c);
}
}
return a[n];
}
int main() {
scanf("%d %d", &k, &n);
for (int j = 0; j < k; j++) scanf("%d", &p[j]);
printf("%lld\n", solve());
return 0;
}复杂度
- 时间复杂度:
。 - 堆的规模始终是
,所以每次堆操作 ; 时 。 - 总操作次数是
:被弹出的候选值 就是某个已产出的丑数 ,而 每含一个属于 的素因子 就会被弹出一次(此时 也是丑数,早已在序列里)。题目保证答案在 signed 32-bit 内,即 ,所以每个 至多 30 个素因子,总弹出次数不超过 ,推入次数与它相等。 - 实测打平比例远小于上界:
的 humble5弹出 456706 次(), 的 humble11只有 346678 次()。
- 堆的规模始终是
- 空间复杂度:
。丑数数组存 个数,堆存 个元素,下标数组 个。
总结
这道题的核心是把"枚举值域"换成"沿着序列生成":每个素数
两个容易写错的地方:一是打平必须一起推进(while heap[0][0] == cur 而不是 if),否则 append 再推进下标,这样新候选值才能引用到刚生成的丑数,同时保证它严格大于刚取出的值,不会死循环。用堆替代线性扫,把每轮取最小从
12 组真实测试数据全部通过,最慢一组 0.125 s、峰值内存 18.6 MB,相对 1000 ms / 128 MB 的限制余量充足。
图示解析
这张图串起本题从建模到得到答案的主线:
素数集合 S
`- 观察:p_j 的候选是 a[0]*p_j, a[1]*p_j, a[2]*p_j ... 单调递增
`- 每条队列只需一个下标 idx_j
`- 下一个丑数 = min_j a[idx_j] * p_j
|- 若有多个队列同时等于最小值
| `- 把它们一起推进,避免同一个值产出两次
`- 用下标推进 K 次 -> 取第 N 个先看"每个素数对应一条单调队列"这一步:它把"枚举所有