第n个丑数:从暴力枚举到动态规划+多指针的学习笔记
在算法训练与工程实现中,"以最小代价生成目标序列" 是一类非常典型的优化场景。第n个丑数问题看似简单,却能很好地体现从暴力思路到线性复杂度、从正确性到效率的完整思考路径,也能映射出实际工程中避免无效计算、精准控制状态、减少冗余操作的设计思想。本文以朴素推导为主,不做过度包装,专注思路演进与细节理解。
一、问题回顾
给定整数 n,返回第 n 个丑数 。
丑数定义:
- 只包含质因数 2、3、5 的正整数;
- 1 是第一个丑数。
前几项丑数:
1, 2, 3, 4, 5, 6, 8, 9, 10, 12, ...
要求:在 n 较大时(如 10³、10⁴、10⁵ 级别)仍能稳定、高效地给出结果。
二、最直观思路:暴力判断(从正确性出发)
最容易想到的做法是:
- 从 1 开始逐个遍历整数;
- 对每个数,判断它是否只由 2、3、5 因子构成;
- 统计是丑数的个数,直到找到第 n 个。
核心判断逻辑(伪代码思路)
bool isUgly(int x) {
if (x <= 0) return false;
while (x % 2 == 0) x /= 2;
while (x % 3 == 0) x /= 3;
while (x % 5 == 0) x /= 5;
return x == 1;
}
存在的问题
- 绝大多数数字都不是丑数,会做大量无效除法与判断;
- 数字越大,非丑数密度越高,浪费的计算量越大;
- 时间复杂度不可控,本质是用"筛选"代替"生成",在大规模场景下不具备实用价值。
这也是很多算法题的共同特点:能做对 ≠ 能用,尤其在对延迟、吞吐量有要求的场景中,必须从源头减少无效操作。
三、从定义反推:丑数的生成性质
回到定义:
丑数只由 2、3、5 相乘得到,且 1 是基础丑数。
可以直接得出一条关键性质:
所有丑数,都可以由更小的丑数 ×2 / ×3 / ×5 得到。
这意味着:
- 我们不需要去判断任何一个数是不是丑数;
- 只需要从已有的丑数里生成下一个。
思路立刻升级:从"遍历+判断"变成"定向生成+有序维护"。
但随之而来两个问题:
- 生成的数会乱序,无法直接取第 n 个;
- 不同路径会生成相同数字(如 2×3=6,3×2=6),存在重复。
四、动态规划 + 多指针:精准生成与去重
为了只生成丑数、按序生成、不重复生成 ,可以采用动态规划配合多指针的方案。
这套方案的本质是:用空间记录状态,用指针控制迭代,用取最小保证有序,用多分支判断完成去重。
1. 状态设计
定义数组 dp:
dp[i]表示第i+1个丑数;- 初始化
dp[0] = 1,对应第一个丑数。
2. 指针的意义
既然每个新丑数只能来自:
- 某个旧丑数 ×2
- 某个旧丑数 ×3
- 某个旧丑数 ×5
我们用三个指针分别标记当前应该参与乘法的位置:
p2:下一个要 ×2 的丑数下标p3:下一个要 ×3 的丑数下标p5:下一个要 ×5 的丑数下标
初始时都指向 0,即从第一个丑数开始。
3. 递推规则
对每一个新位置 dp[i]:
- 计算三个候选值:
num2 = dp[p2] * 2num3 = dp[p3] * 3num5 = dp[p5] * 5
- 取三者最小值作为当前丑数,保证严格递增;
- 如果当前丑数等于某个候选值,就将对应指针后移一位:
- 若等于
num2,p2++ - 若等于
num3,p3++ - 若等于
num5,p5++
- 若等于
这里必须使用独立的 if 而非 else if ,目的是:
当多个候选值相等时(如 6 同时由 ×2 和 ×3 得到),多个指针同时后移,从根源上去重,避免后续重复生成同一数字。
五、实现代码(C++)
cpp
class Solution {
public:
int nthUglyNumber(int n) {
if (n == 1) return 1;
vector<int> dp(n);
dp[0] = 1;
int p2 = 0, p3 = 0, p5 = 0;
for (int i = 1; i < n; ++i) {
int num2 = dp[p2] * 2;
int num3 = dp[p3] * 3;
int num5 = dp[p5] * 5;
dp[i] = min(min(num2, num3), num5);
if (dp[i] == num2) p2++;
if (dp[i] == num3) p3++;
if (dp[i] == num5) p5++;
}
return dp[n-1];
}
};
六、复杂度与可扩展性思考
时间复杂度
- 只进行一轮从 1 到 n-1 的遍历;
- 每一步仅包含固定次数的乘法、比较、指针移动;
- 时间复杂度为 O(n),是该问题理论下界。
空间复杂度
- 使用长度为 n 的 dp 数组保存历史丑数;
- 空间复杂度 O(n)。
在工程视角下:
- 如果 n 上限已知且不大,这种空间换时间的方式非常划算;
- 若追求极致空间,可以只保留最近几个关键值,但会明显降低可读性与可维护性,除非资源极度受限,否则不建议。
可扩展启发
这套思路不局限于丑数:
- 只要目标序列满足:由基础因子从已有元素生成;
- 且需要有序、不重复 ;
都可以使用:
动态规划记录状态 + 多指针控制迭代位置 + 取最小保证有序。
例如:
- 超级丑数(质因数不限于 2、3、5)
- 某些带限制的倍数序列生成
- 流式生成有序目标集合
都可以在这套框架上扩展,只需要调整指针数量与候选生成规则。
七、小结
第 n 个丑数从暴力到最优解的过程,本质是三次思维升级:
- 从**"筛选所有数"升级为"只生成目标数"**;
- 从**"无序生成"升级为"按序生成"**;
- 从**"可能重复"升级为"精准去重"**。
动态规划+多指针并没有复杂数学推导,而是用最朴素的方式,把每一步多余计算都砍掉 ,只保留必要的状态与转移。这种思路在算法训练中是基础,在实际工程中同样适用------很多高性能逻辑,并不是靠花哨技巧,而是靠精准定义状态、严格控制迭代、彻底消除无效计算来实现的。
手动模拟前 10 个丑数的生成过程,观察指针移动与去重逻辑,能更稳定地掌握这一类生成型问题的通用解法。