[路飞]丑数ii

159 阅读5分钟

记录 1 道算法题

丑数ii

leetcode-cn.com/problems/ug…


丑数是指因数含有是 2,3,5 的数。

1 也被作为丑数

可以使用堆进行解决,因为题目的要求是返回 第 n 个丑数。

丑数集合按照升序排列,所以是找第 n 小的丑数,使用大顶堆解决。

也可以使用动态规划解决,使用3个指针。

每一个丑数都是2,3,5的倍数,所以每一个丑数都可以是前面的某一个丑数的 2,3,5倍。

例如 4 是 2 的倍数,所以 4 是丑数, 8 是 2 的倍数也是前面的丑数 4 的倍数。

所以丑数也是累积的,我们就可以根据前面的丑数来推算后面的丑数,例如 2 可以推出 4, 6, 10。然后 4 又可以推出 8, 12, 20。

同时我们也发现会有重复的数,比如 2 和 3 都能推出 6。

有了这些规律之后,怎么进行代码上的推导呢。

首先我们准备一个数组,为了直接对照下标,我们让数组从下标为 1 开始,然后设置 arr[1] = 1 作为累积的基础,1 可以推导出 2, 3, 5。

假设我们存好的数组是 [0, 1, 2, 3, 4, 5]

下标 1, 值 1, 推导出 2, 3, 5

下标 2, 值 2, 推导出 4, 6, 10

下标 3, 值 3, 推导出 6, 9, 15

下标 4, 值 4, 推导出 8, 12, 20

问题是数组里面每一个下标的值怎么设置,因为初始化的时候,我们只知道 [empty, 1], arr[1] = 1,以及 1 推导出的 3 个数。上帝视角可以知道下标为 2 的值应该为 2,但代码不知道,我们要做的就是推算 下标为 2 的时候,值应该是什么。

每一个下标的值应该从之前的数的 2,3,5倍里面选择。

下标为 2 的值就应该从 1 * 2, 1 * 3, 1 * 5 里面选择。保持升序排列,我们选择最小的数。

此时, 下标 2, 值 2, 可以推导出 4, 6, 10

但是 上一次推导的 3 和 5 还没存起来。我们需要有一个指针指着他们。假设我们这么设计指针,每个倍数档都有自己的记录,上一个数 1 有 3 个分支, 1 * 2, 1 * 3,1 * 4。分别被 n1,n2,n3 指着。

    0 1
2     ↑
3     ↑
5     ↑

下标 2, 值 2,所推导出的 4, 6, 10,其中代表着前面的数的 3 倍的分支的值 6,前面的数的 5 倍的分支的值 10 永远比前一个分支大,所以必须先分配前面的分支才行,这时可以不更新指针,让他继续指着之前的分支,我们可以通过让指针停在 1 这里。永远可以得到 1 * 3,1 * 5。

而 2 已经被分配了。他可以向前走一步,更新到 下标 2, 值 2 的 2 倍分支中。

    0 1 2
2       ↑
3     ↑
5     ↑

于是我们就在 2 * 2,1 * 3, 1 * 5 之中推导下标 3 的值,3 倍分支进行了分配,可以往前走一步。

最小的数是 3, 所以 下标 3, 值 3, 可以推导出 6, 9, 15。

    0 1 2 3
2       ↑
3       ↑
5     ↑

我们可以看到更新了 3 倍分支之后,取到了上次排着队等分配的 6。

下一个下标 4 就是比较 2 * 2, 2 * 3, 1 * 5。最小是 4,2倍分支再走一步。

    0 1 2 3 4
2         ↑
3       ↑
5     ↑

下一个下标 5,比较 3 * 2, 2 * 3, 1 * 5, 最小是 5。

    0 1 2 3 4 5
2         ↑
3       ↑
5       ↑

这时我们发现 下一个下标 6 的时候,比较 3 * 2, 2 * 3, 2 * 5。出现了两个 6。我们可以理解为 2 倍分支和 3倍分支都得到了分配。所以都往前走一步。如果不是同时走的话,下一轮还能得到 6。就会重复存 6。

    0 1 2 3 4 5 6
2           ↑
3         ↑
5       ↑

可能有人会觉得 0, 1, 2, 3, 4, 5, 6 是下标,其实这些是值,只是刚好这些都是丑数而已,我们计算倍数是根据前一个数的值进行计算的,所以是丑数。

下标 7, 比较 4 * 2, 3 * 3, 2 * 5,最小是8

    0 1 2 3 4 5 6 8
2             ↑
3         ↑
5       ↑

之后只要重复一直比较最小值就行。循环的条件就是下标,我们要第 n 个,就循环到 下标为 n 就可以了。比如我要第 6 个, 累积到下标为 6 的时候,已经能得到结果了。

最后总结一下,三个指针之所以可以按照倍数来设置,是因为后面的丑数都是前面的丑数的2, 3,5倍。而且是递增的。如果某个倍数分支(丑数下标指着的丑数)没有分配进数组存起来,这个倍数后面的数只会越来越大,所以必须前面的先分配,于是就让他停在当前的丑数下标,指着这个丑数,指代这个丑数的多少倍,直到他被分配就指着下一个推导的数。

另一种视图:

2 的倍数 根据下标 2, 4, 6, 8

3 的倍数 根据下标 3, 6, 9, 12

5 的倍数 根据下标 5, 10,15,20

可以看到是依次取的,所以我们需要 3 个指针,分别指向 2, 3, 5倍。

代码如下:

    function nthUglyNumber(n) {
        const arr = new Array(n + 1)
        arr[1] = 1
        let n1 = 1,
          n2 = 1,
          n3 = 1
        for (let i = 2; i <= n; i++) {
            // 得到推导的数
          const a = arr[n1] * 2,
            b = arr[n2] * 3,
            c = arr[n3] * 5
            
            // 比较最小值
          const num = Math.min(a, b, c)
          // 存起来,实现升序排列
          arr[i] = num
          // 确认是哪个分支的,因为只知道最小值,还不知道是谁的。
          if (num === a) {
            n1++
          }
          if (num === b) {
            n2++
          }
          if (num === c) {
            n3++
          }
        }

        return arr[n]
    }

结束