LeetCode 丑数 II:从暴力到动态规划的完整思路梳理

8 阅读4分钟

一、什么是丑数?

丑数(Ugly Number)指的是 只包含质因子 2、3、5 的正整数

也就是说,一个数如果可以写成:

2^a × 3^b × 5^c

其中 a、b、c ≥ 0,那它就是丑数。

前几个丑数如下:

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

注意:1 也是丑数(这是题目规定的起点)。

题目要求的是:

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


二、思路一:最直观的暴力解法

基本想法

  • 从 1 开始,一个个判断是不是丑数
  • 每遇到一个丑数就计数
  • 直到找到第 n 个

判断一个数是否为丑数

不断除以 2 / 3 / 5,直到不能再除:

while (x % 2 == 0) x /= 2;
while (x % 3 == 0) x /= 3;
while (x % 5 == 0) x /= 5;
return x == 1;

问题

  • n 最大可以到 1690
  • 中间会跳过大量非丑数
  • 时间复杂度极高

结论:会超时,不适合面试和实战


三、思路二:小顶堆 + 去重

核心思想

  • 每个丑数都可以由之前的丑数 × 2 / 3 / 5 得到
  • 用一个 最小堆(PriorityQueue) 每次取当前最小的丑数
  • 用 HashSet 去重,避免重复加入

代码示例

class Solution {
    public int nthUglyNumber(int n) {
        HashSet<Long> set = new HashSet<>();
        PriorityQueue<Long> pq = new PriorityQueue<>();

        set.add(1L);
        pq.add(1L);

        int res = 1;
        long[] factors = {2, 3, 5};

        for (int i = 0; i < n; i++) {
            long ugly = pq.poll();
            res = (int) ugly;

            for (long f : factors) {
                long next = ugly * f;
                if (!set.contains(next)) {
                    set.add(next);
                    pq.add(next);
                }
            }
        }
        return res;
    }
}

优缺点分析

优点:

  • 思路直观
  • 容易理解
  • 不会漏解

缺点:

  • 需要堆 + HashSet
  • 空间复杂度高
  • 时间复杂度 O(n log n)

结论:能过,但不是最优解


四、最优解:动态规划 + 三指针

这是面试和刷题中最标准、最优雅的解法

1. 核心观察

所有丑数的生成方式只有三种:

某个较小的丑数 × 2
某个较小的丑数 × 3
某个较小的丑数 × 5

并且,丑数序列是递增的


2. 定义状态

用数组 dp[i] 表示:

i 个丑数

初始化:

dp[1] = 1

3. 三个指针的含义

我们维护三个指针:

p2:下一个要乘 2 的位置
p3:下一个要乘 3 的位置
p5:下一个要乘 5 的位置

初始状态:

p2 = p3 = p5 = 1

4. 每一步如何生成下一个丑数?

对第 i 个丑数:

候选值:
dp[p2] * 2
dp[p3] * 3
dp[p5] * 5

dp[i] = 三者中的最小值

谁产生了这个最小值,对应的指针就往后移动。

注意:可能同时有多个指针都要移动(避免重复)


5. 动态规划代码

class Solution {
    public int nthUglyNumber(int n) {
        int[] dp = new int[n + 1];
        dp[1] = 1;

        int p2 = 1, p3 = 1, p5 = 1;

        for (int i = 2; i <= n; i++) {
            int t2 = dp[p2] * 2;
            int t3 = dp[p3] * 3;
            int t5 = dp[p5] * 5;

            dp[i] = Math.min(t2, Math.min(t3, t5));

            if (dp[i] == t2) p2++;
            if (dp[i] == t3) p3++;
            if (dp[i] == t5) p5++;
        }
        return dp[n];
    }
}

6. 举个具体例子

已知:

dp = [1]
p2 = p3 = p5 = 1

生成过程:

i = 2:
候选:2, 3, 5
dp[2] = 2
p2++

i = 3:
候选:4, 3, 5
dp[3] = 3
p3++

i = 4:
候选:4, 6, 5
dp[4] = 4
p2++

i = 5:
候选:6, 6, 5
dp[5] = 5
p5++

这样保证了:

  • 丑数有序
  • 不重复
  • 不遗漏

五、三种方法对比总结

方法时间复杂度空间复杂度特点
暴力判断极高会超时
堆 + 去重O(n log n)思路直观
动态规划 + 三指针O(n)O(n)最优解,强烈推荐

六、这道题真正考察什么?

  • 能否发现「丑数是由已有丑数生成的」
  • 是否具备将问题转化为动态规划的能力
  • 对指针移动和去重逻辑的理解是否扎实

这道题是一个非常经典的「由生成规则反推 DP 状态」的例子,理解后对很多题目都有帮助。


七、总结

  • 丑数不是判断题,而是生成题
  • 最优解不是堆,而是三指针 DP
  • 指针的本质是:控制下一个合法生成位置

如果你能完整推导出这个解法,说明你对动态规划的理解已经非常扎实了。