第n个丑数:从暴力枚举到动态规划+多指针的学习笔记

第n个丑数:从暴力枚举到动态规划+多指针的学习笔记

在算法训练与工程实现中,"以最小代价生成目标序列" 是一类非常典型的优化场景。第n个丑数问题看似简单,却能很好地体现从暴力思路到线性复杂度、从正确性到效率的完整思考路径,也能映射出实际工程中避免无效计算、精准控制状态、减少冗余操作的设计思想。本文以朴素推导为主,不做过度包装,专注思路演进与细节理解。

一、问题回顾

给定整数 n,返回第 n 个丑数

丑数定义:

  • 只包含质因数 2、3、5 的正整数;
  • 1 是第一个丑数。

前几项丑数:

1, 2, 3, 4, 5, 6, 8, 9, 10, 12, ...

要求:在 n 较大时(如 10³、10⁴、10⁵ 级别)仍能稳定、高效地给出结果。


二、最直观思路:暴力判断(从正确性出发)

最容易想到的做法是:

  1. 从 1 开始逐个遍历整数;
  2. 对每个数,判断它是否只由 2、3、5 因子构成;
  3. 统计是丑数的个数,直到找到第 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 得到。

这意味着:

  • 我们不需要去判断任何一个数是不是丑数
  • 只需要从已有的丑数里生成下一个

思路立刻升级:从"遍历+判断"变成"定向生成+有序维护"。

但随之而来两个问题:

  1. 生成的数会乱序,无法直接取第 n 个;
  2. 不同路径会生成相同数字(如 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]

  1. 计算三个候选值:
    • num2 = dp[p2] * 2
    • num3 = dp[p3] * 3
    • num5 = dp[p5] * 5
  2. 取三者最小值作为当前丑数,保证严格递增
  3. 如果当前丑数等于某个候选值,就将对应指针后移一位:
    • 若等于 num2p2++
    • 若等于 num3p3++
    • 若等于 num5p5++

这里必须使用独立的 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 个丑数从暴力到最优解的过程,本质是三次思维升级:

  1. 从**"筛选所有数"升级为"只生成目标数"**;
  2. 从**"无序生成"升级为"按序生成"**;
  3. 从**"可能重复"升级为"精准去重"**。

动态规划+多指针并没有复杂数学推导,而是用最朴素的方式,把每一步多余计算都砍掉 ,只保留必要的状态与转移。这种思路在算法训练中是基础,在实际工程中同样适用------很多高性能逻辑,并不是靠花哨技巧,而是靠精准定义状态、严格控制迭代、彻底消除无效计算来实现的。

手动模拟前 10 个丑数的生成过程,观察指针移动与去重逻辑,能更稳定地掌握这一类生成型问题的通用解法。

相关推荐
人间打气筒(Ada)11 小时前
Linux学习~日志文件参考
linux·运维·服务器·学习·日志·log·问题修复
浅念-11 小时前
C/C++内存管理
c语言·开发语言·c++·经验分享·笔记·学习
凌晨7点11 小时前
DSP学习F28004x数据手册:第13章-ADC
单片机·嵌入式硬件·学习
No丶slovenly11 小时前
flutter笔记-输入框
前端·笔记·flutter
liuchangng12 小时前
Agent Skills 核心笔记_20260212095535
笔记
Hello eveybody12 小时前
什么是动态规划(DP)?(Python版)
python·动态规划
野犬寒鸦12 小时前
从零起步学习并发编程 || 第九章:Future 类详解及CompletableFuture 类在项目实战中的应用
java·开发语言·jvm·数据库·后端·学习
山北雨夜漫步12 小时前
点评day01,Session实现登录
笔记
蒸蒸yyyyzwd12 小时前
cpp os 计网学习笔记
笔记·学习