第n个丑数

45

只有2、3、5这三个质因数的数字被称为丑数

例如:

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

1 可以看作是 2^0。

我正在寻找第 n 个丑数。请注意,随着 n 的增大,这些数字的分布非常稀疏。

我编写了一个简单的程序来计算给定数字是否为丑数。对于 n > 500,它变得非常缓慢。我尝试使用记忆化 - 观察到:ugly_number * 2ugly_number * 3ugly_number * 5 都是丑数。但即使这样也很慢。我尝试使用类似于对数筛选法 (感谢Anon) 的概念,因为这将把乘法问题转换为加法问题,但是还没有得到太多帮助。想与大家分享一下。有什么有趣的想法吗?

使用类似于 埃拉托色尼筛法 的概念(感谢 Anon)

    for (int i(2), uglyCount(0); ; i++) {
        if (i % 2 == 0)
            continue;
        if (i % 3 == 0)
            continue;
        if (i % 5 == 0)
            continue;
        uglyCount++;
        if (uglyCount == n - 1)
            break;
    }

i是第n个丑数。

即使如此,这也相当缓慢。我正在尝试查找第1500个丑数。


28
为什么这些数字被称为丑数? - SLaks
1
在涉及整数运算的问题中,避免使用浮点数。 - ruslik
11
这被称为汉明数:http://en.wikipedia.org/wiki/Regular_number#Algorithms - Khaled Alshaya
6
我认为这个问题等同于迭代指数(x1, x2, x3)在2 ** x1 * 3 ** x2 * 5 ** x3中,以便产品按数字顺序输出。 - President James K. Polk
2
http://online-judge.uva.es/p/v1/136.html - starblue
显示剩余7条评论
13个回答

42
Java中一个简单快速的解决方案。使用了Anon.描述的方法。
这里TreeSet仅是一个容器,能够返回其中最小的元素。(不存储重复元素。)
    int n = 20;
    SortedSet<Long> next = new TreeSet<Long>();
    next.add((long) 1);

    long cur = 0;
    for (int i = 0; i < n; ++i) {
        cur = next.first();
        System.out.println("number " + (i + 1) + ":   " + cur);

        next.add(cur * 2);
        next.add(cur * 3);
        next.add(cur * 5);
        next.remove(cur);
    }

由于第1000个丑数是51200000,将它们存储在bool[]中不是真正的选择。

编辑
作为从工作中(调试愚蠢的Hibernate)的娱乐活动,这里有完全线性的解决方案。感谢marcog 的想法!

    int n = 1000;

    int last2 = 0;
    int last3 = 0;
    int last5 = 0;

    long[] result = new long[n];
    result[0] = 1;
    for (int i = 1; i < n; ++i) {
        long prev = result[i - 1];

        while (result[last2] * 2 <= prev) {
            ++last2;
        }
        while (result[last3] * 3 <= prev) {
            ++last3;
        }
        while (result[last5] * 5 <= prev) {
            ++last5;
        }

        long candidate1 = result[last2] * 2;
        long candidate2 = result[last3] * 3;
        long candidate3 = result[last5] * 5;

        result[i] = Math.min(candidate1, Math.min(candidate2, candidate3));
    }

    System.out.println(result[n - 1]);

这个想法是,为了计算a[i],我们可以使用a[j]*2来代替某些j < i。但我们还需要确保1)a[j]*2 > a[i - 1],并且2)j最小可能。
然后,a[i] = min(a[j]*2, a[k]*3, a[t]*5)


7
如果你不理解某个东西,就请问一下。不要仅仅去“修复”它。 - Nikita Rybak
4
“第二种解决方案并非完全线性——for循环内的3个while循环无法被描述为常数时间。”——嗯,完全错误。每个lasti的范围从0到最多n,* 总共一次*,因此它们是O(n) 总共。换句话说,对于for循环的每次迭代,3个内部循环的平均迭代次数<= 1,这确实是常数时间。 - Jim Balter
1
虽然如此,while循环是必要的吗?prev不会是最后三个之一吗?就像这里的顶级解决方案:https://dev59.com/VW035IYBdhLWcg3wSOGr - Kakira
1
@Kakira if是足够的,但有时候两个甚至三个都必须同时推进;在链接的解决方案中,这两个if是顺序执行的,而不是互斥的;我认为Dijkstra本人用while写下了这个算法,以确保正确性没有任何疑问,他的理由是这样的。 - Will Ness
1
内部的while循环导致了错误的读取,看起来我们可以将last2、last3或last5移动超过1个,但实际上不行。:( 第一次阅读时,last2是指针还是2的幂值令人困惑。:( 真的没有理由这样做。我们不会循环超过1次。 - Alex Peter
显示剩余2条评论

12
我正在寻找第n个丑数。请注意,随着n的增大,这些数字非常稀疏分布。
我编写了一个简单的程序来计算给定数字是否为丑数。
这似乎不是解决你要解决的问题的正确方法 - 它有点像shlemiel算法。
您是否熟悉用于查找质数的Eratosthenes筛选法?类似的东西(利用每个丑数都是另一个丑数的2、3或5倍的知识)可能更适合解决这个问题。
与Sieve的比较并不意味着"保留一个bool数组,并在上升时消除可能性"。我更多地是指基于先前结果生成解决方案的一般方法。其中Sieve获取一个数字,然后从候选集中删除所有它的倍数,而对于这个问题的一个好算法将从一个空集开始,然后将每个丑数的正确倍数添加到该集合中。

3
这解决了快速找到第n个数字的问题。您还应该添加,同时遍历2、3、5的倍数将消除对布尔数组的需求。 - moinudin
我对埃拉托色尼筛法很熟悉。一开始,我考虑生成所有丑数的排序列表,但这并不是很干净。然后我尝试了一个简单的解决方案(显然非常慢)。使用埃拉托色尼筛法可以帮助我在O(U(n))的时间复杂度内解决问题,其中U(n)是第n个丑数。 - Anil Katti
@Anil,你不必将元素存储在数组中,可以使用任何其他类型的容器,例如堆。这可以轻松地给出O(n*logn)。还有一种由marcog描述的方法:它会给出O(n),但有点棘手。 - Nikita Rybak
1
@Anil:当我将筛法与此进行比较时,我并不是指“保留一个布尔数组,并在上升过程中消除可能性”,而是更多地参考了基于先前结果生成解决方案的一般方法。筛法获得结果后,从候选集中删除所有它的倍数,而对于这个问题的好算法应该从一个空集开始,然后将每个丑数的正确倍数添加到其中。 - Anon.

9

我的答案是参考了 Nikita Rybak 给出的正确答案。这样人们就可以看到从第一种方法的想法过渡到第二种方法的过程。

from collections import deque
def hamming():
    h=1;next2,next3,next5=deque([]),deque([]),deque([])
    while True:
        yield h
        next2.append(2*h)
        next3.append(3*h)
        next5.append(5*h)
        h=min(next2[0],next3[0],next5[0])
        if h == next2[0]: next2.popleft()
        if h == next3[0]: next3.popleft()
        if h == next5[0]: next5.popleft()

Nikita Rybak的第一个方法与现在的不同之处在于,不再将下一个候选项添加到单个数据结构(即Tree set)中,而是可以将它们分别添加到3个FIFO列表中。这样,每个列表始终保持排序,下一个最小的候选项必须始终位于这些列表中一个或多个的头部。
如果我们消除上述三个列表的使用,就会得到Nikita Rybak答案中的第二种实现。这是通过仅在需要时评估那些候选项(要包含在三个列表中)来完成的,因此无需存储它们。
简而言之:
在第一种方法中,我们将每个新候选项放入单个数据结构中,这很糟糕,因为太多东西被不明智地混合在一起。这种贫乏的策略不可避免地导致每次查询结构时的O(log(tree size))时间复杂度。然而,通过将它们放入单独的队列中,您将看到每个查询仅需要O(1),因此整体性能降低到O(n)!!!这是因为每个三个列表已经按自己的方式排序了。

6

我相信你可以在次线性时间内解决这个问题,可能是O(n^{2/3})。

简单来说,如果你简化问题只允许因子为2和3,你可以通过搜索第一个不小于第n个丑数的最小2的幂,并生成一个O(n^{1/2})候选列表来实现O(n^{1/2})时间。这段代码可以让你了解如何做到这一点。它依赖于只包含2和3的幂的第n个数字具有其指数总和为O(n^{1/2})的质因数分解。

def foo(n):
  p2 = 1  # current power of 2
  p3 = 1  # current power of 3
  e3 = 0  # exponent of current power of 3
  t = 1   # number less than or equal to the current power of 2
  while t < n:
    p2 *= 2
    if p3 * 3 < p2:
      p3 *= 3
      e3 += 1
    t += 1 + e3
  candidates = [p2]
  c = p2
  for i in range(e3):
    c /= 2
    c *= 3
    if c > p2: c /= 2
    candidates.append(c)
  return sorted(candidates)[n - (t - len(candidates))]

相同的思路可以适用于三个允许的因子,但代码变得更加复杂。因式分解的幂之和降至O(n^{1/3}),但你需要考虑更多的候选项,更精确地说是O(n^{2/3})。


是的,n^{2/3} 是正确的,尽管我没有理解你在这里的论点。这是通过枚举 i,j,k 三元组来完成的,以不超过序列的第 n 个成员的估计值为限(因为 ln2、ln3、ln5 是已知的)。代码和链接请参见此答案 - Will Ness
很遗憾,这个唯一快速的解决方案只有很少的投票。根据我的估计,它可以轻松地找到第一百万个丑数,大约在10^253左右。 - gnasher729
@gnasher729 第一百万个 Hamming 数是实际上是 5.19312780448E+83 - Will Ness
这段程序针对100和10000有效(已经验证结果正确--返回的值是序列中索引n的值,从零开始计数),但是在处理1000时会因为“列表索引超出范围”而失败。https://ideone.com/6hnIxg - Will Ness

6
这里有很多好的答案,但我有些难以理解,特别是包括被接受的答案在内的所有答案如何保持Dijkstra原始论文中的公理2:
公理2. 如果x在序列中,则2 * x,3 * x和5 * x也在序列中。
经过一些白板演示,变得清晰的是,公理2在算法的每次迭代中都不是一个不变量,而实际上是算法本身的目标。在每次迭代中,我们尝试恢复公理2中的条件。如果last是结果序列S中的最后一个值,则公理2可以简单地重新表述为:
对于S中的某个x,S中的下一个值是2x、3x和5x的最小值,大于last。让我们称之为公理2'。
因此,如果我们可以找到x,我们可以在常数时间内计算出2x、3x和5x的最小值,并将其添加到S中。
但是,我们如何找到x呢?一种方法是我们不寻找;相反,每当我们向S中添加一个新元素e时,我们计算2e、3e和5e,并将它们添加到最小优先级队列中。由于此操作保证e在S中,因此仅提取PQ的顶部元素就满足公理2'。
这种方法有效,但问题是我们生成了一堆可能不会使用的数字。请参见答案以获取示例;如果用户想要S中的第5个元素(5),那么此时的PQ保存着6 6 8 9 10 10 12 15 15 20 25。我们能不能不浪费这个空间呢?
原来我们可以做得更好。我们不必存储所有这些数字,而只需为每个倍数维护三个计数器,即2i3j5k。这些是下一个在S中的数字的候选项。当我们选择其中一个时,我们只增加相应的计数器,而不增加其他两个计数器。通过这样做,我们不会急于生成所有的倍数,从而解决了第一种方法中的空间问题。
让我们看一个n = 8的演示,即数字9。我们从1开始,正如Dijkstra论文中的公理1所述。
+---------+---+---+---+----+----+----+-------------------+
| #       | i | j | k | 2i | 3j | 5k | S                 |
+---------+---+---+---+----+----+----+-------------------+
| initial | 1 | 1 | 1 | 2  | 3  | 5  | {1}               |
+---------+---+---+---+----+----+----+-------------------+
| 1       | 1 | 1 | 1 | 2  | 3  | 5  | {1,2}             |
+---------+---+---+---+----+----+----+-------------------+
| 2       | 2 | 1 | 1 | 4  | 3  | 5  | {1,2,3}           |
+---------+---+---+---+----+----+----+-------------------+
| 3       | 2 | 2 | 1 | 4  | 6  | 5  | {1,2,3,4}         |
+---------+---+---+---+----+----+----+-------------------+
| 4       | 3 | 2 | 1 | 6  | 6  | 5  | {1,2,3,4,5}       |
+---------+---+---+---+----+----+----+-------------------+
| 5       | 3 | 2 | 2 | 6  | 6  | 10 | {1,2,3,4,5,6}     |
+---------+---+---+---+----+----+----+-------------------+
| 6       | 4 | 2 | 2 | 8  | 6  | 10 | {1,2,3,4,5,6}     |
+---------+---+---+---+----+----+----+-------------------+
| 7       | 4 | 3 | 2 | 8  | 9  | 10 | {1,2,3,4,5,6,8}   |
+---------+---+---+---+----+----+----+-------------------+
| 8       | 5 | 3 | 2 | 10 | 9  | 10 | {1,2,3,4,5,6,8,9} |
+---------+---+---+---+----+----+----+-------------------+

请注意,第6次迭代时S没有增长,因为最小的候选项6已经在之前添加过了。为了避免记住所有先前元素的问题,我们修改算法,在相应倍数等于最小候选项时递增所有计数器。这使我们得到以下Scala实现。
def hamming(n: Int): Seq[BigInt] = {
  @tailrec
  def next(x: Int, factor: Int, xs: IndexedSeq[BigInt]): Int = {
    val leq = factor * xs(x) <= xs.last
    if (leq) next(x + 1, factor, xs)
    else x
  }

  @tailrec
  def loop(i: Int, j: Int, k: Int, xs: IndexedSeq[BigInt]): IndexedSeq[BigInt] = {
    if (xs.size < n) {
      val a = next(i, 2, xs)
      val b = next(j, 3, xs)
      val c = next(k, 5, xs)
      val m = Seq(2 * xs(a), 3 * xs(b), 5 * xs(c)).min

      val x = a + (if (2 * xs(a) == m) 1 else 0)
      val y = b + (if (3 * xs(b) == m) 1 else 0)
      val z = c + (if (5 * xs(c) == m) 1 else 0)

      loop(x, y, z, xs :+ m)
    } else xs
  }

  loop(0, 0, 0, IndexedSeq(BigInt(1)))
}

Iterator.from(6).drop(1).next() 的值是多少?难道不是 7 吗?如果是这样的话,那就意味着这段代码是错误的。请问,通过这段代码生成的第1000个 Hamming 数是多少?是不是51200000? - Will Ness
这段代码是错误的。它会产生例如14=72,21=73,22=11*2等结果(https://ideone.com/uOFrnK)。 - Will Ness
@WillNess已修复,感谢找到这个错误。我没有尝试生成1000个数字,但我测试了15个。此外,如果我要使用这个代码生成一个大序列,我可能会使用可变序列,并尝试避免重复使用 BigInt 乘法。 - Abhijit Sarkar

4
基本上,搜索可以采用O(n)算法:
考虑保留一部分丑数的历史记录。现在,在每个步骤中,您需要找到下一个丑数。它应该等于历史记录中的某个数字乘以2、3或5。选择它们中最小的一个,将其添加到历史记录中,并删除其中一些数字,以便列表中最小的数字乘以5大于最大值。
这将很快,因为下一个数字的搜索将是简单的:
min(largest * 2, smallest * 5, one from the middle * 3),
它比列表中的最大数字大。如果它们很稀缺,那么列表将始终只包含少量数字,因此要乘以3的数字的搜索将很快。

2

这里是一个正确的ML解决方案。函数ugly()将返回一个汉明数流(惰性列表)。可以在此流上使用函数nth。

这使用筛法,下一个元素仅在需要时才计算。

datatype stream = Item of int * (unit->stream);
fun cons (x,xs) = Item(x, xs);
fun head (Item(i,xf)) = i;
fun tail (Item(i,xf)) = xf();
fun maps f xs = cons(f (head xs), fn()=> maps f (tail xs));

fun nth(s,1)=head(s)
  | nth(s,n)=nth(tail(s),n-1);

fun merge(xs,ys)=if (head xs=head ys) then
                   cons(head xs,fn()=>merge(tail xs,tail ys))
                 else if (head xs<head ys) then
                   cons(head xs,fn()=>merge(tail xs,ys))
                 else
                   cons(head ys,fn()=>merge(xs,tail ys));

fun double n=n*2;
fun triple n=n*3;

fun ij()=
    cons(1,fn()=>
      merge(maps double (ij()),maps triple (ij())));

fun quint n=n*5;

fun ugly()=
    cons(1,fn()=>
      merge((tail (ij())),maps quint (ugly())));

这是第一年的计算机科学课程作业 :-)



2
要在O(n^(2/3))的时间复杂度内找到第n个丑数,jonderry的算法非常适合。请注意,参与计算的数字是巨大的,因此任何试图检查一个数字是否是丑数的算法都没有机会。
按升序找出前n个丑数可以很容易地通过使用优先队列在O(nlogn)的时间和O(n)的空间内完成:创建一个带有最小数字的优先队列,最初仅包括数字1。然后重复n次:从优先队列中删除最小的数字x。如果x之前没有被删除过,则x是下一个更大的丑数,并将2x、3x和5x添加到优先队列中。(如果有人不知道“优先队列”这个术语,它就像堆排序算法中的堆一样)。以下是算法的开头部分:
1 -> 2 3 5
1 2 -> 3 4 5 6 10
1 2 3 -> 4 5 6 6 9 10 15
1 2 3 4 -> 5 6 6 8 9 10 12 15 20
1 2 3 4 5 -> 6 6 8 9 10 10 12 15 15 20 25
1 2 3 4 5 6 -> 6 8 9 10 10 12 12 15 15 18 20 25 30
1 2 3 4 5 6 -> 8 9 10 10 12 12 15 15 18 20 25 30
1 2 3 4 5 6 8 -> 9 10 10 12 12 15 15 16 18 20 24 25 30 40

执行时间的证明:我们从队列中提取了n次一个丑陋的数字。我们最初在队列中有一个元素,提取一个丑陋的数字后,我们添加三个元素,将数字增加2。因此,在找到n个丑陋的数字之后,我们在队列中最多有2n + 1个元素。提取一个元素可以在对数时间内完成。我们提取的不仅仅是丑陋的数字,但最多只有n个丑陋的数字加上2n-1个其他数字(这些数字可能在n-1步之后出现在筛子中)。因此,总时间小于以对数时间移除3n个项目= O(n log n),总空间最多为2n + 1个元素= O(n)。


找到Hamming序列的前n个成员是一个O(n)时间复杂度的计算,n log n是完全不必要的。 接受的答案的第二个版本(在“edit”下)是O(n)。 (它也是Dijkstra写的,一直到while - if实际上已经足够了,但他写道使用while在正确性方面没有任何疑问。) - Will Ness

1

使用3个并行发生器,在每次迭代中选择最小的数字,以下是一个C程序,可以在不到1秒的时间内计算出2128以下的所有丑陋数字:

#include <limits.h>
#include <stdio.h>

#if 0
typedef unsigned long long ugly_t;
#define UGLY_MAX  (~(ugly_t)0)
#else
typedef __uint128_t ugly_t;
#define UGLY_MAX  (~(ugly_t)0)
#endif

int print_ugly(int i, ugly_t u) {
    char buf[64], *p = buf + sizeof(buf);

    *--p = '\0';
    do { *--p = '0' + u % 10; } while ((u /= 10) != 0);
    return printf("%d: %s\n", i, p);
}

int main() {
    int i = 0, n2 = 0, n3 = 0, n5 = 0;
    ugly_t u, ug2 = 1, ug3 = 1, ug5 = 1;
#define UGLY_COUNT  110000
    ugly_t ugly[UGLY_COUNT];

    while (i < UGLY_COUNT) {
        u = ug2;
        if (u > ug3) u = ug3;
        if (u > ug5) u = ug5;
        if (u == UGLY_MAX)
            break;
        ugly[i++] = u;
        print_ugly(i, u);
        if (u == ug2) {
            if (ugly[n2] <= UGLY_MAX / 2)
                ug2 = 2 * ugly[n2++];
            else
                ug2 = UGLY_MAX;
        }
        if (u == ug3) {
            if (ugly[n3] <= UGLY_MAX / 3)
                ug3 = 3 * ugly[n3++];
            else
                ug3 = UGLY_MAX;
        }
        if (u == ug5) {
            if (ugly[n5] <= UGLY_MAX / 5)
                ug5 = 5 * ugly[n5++];
            else
                ug5 = UGLY_MAX;
        }
    }
    return 0;
}

以下是输出的最后10行:

100517: 338915443777200000000000000000000000000
100518: 339129266201729628114355465608000000000
100519: 339186548067800934969350553600000000000
100520: 339298130282929870605468750000000000000
100521: 339467078447341918945312500000000000000
100522: 339569540691046437734055936000000000000
100523: 339738624000000000000000000000000000000
100524: 339952965770562084651663360000000000000
100525: 340010386766614455386112000000000000000
100526: 340122240000000000000000000000000000000

以下是适用于QuickJS的Javascript版本:

import * as std from "std";

function main() {
    var i = 0, n2 = 0, n3 = 0, n5 = 0;
    var u, ug2 = 1n, ug3 = 1n, ug5 = 1n;
    var ugly = [];

    for (;;) {
        u = ug2;
        if (u > ug3) u = ug3;
        if (u > ug5) u = ug5;
        ugly[i++] = u;
        std.printf("%d: %s\n", i, String(u));
        if (u >= 0x100000000000000000000000000000000n)
            break;
        if (u == ug2)
            ug2 = 2n * ugly[n2++];
        if (u == ug3)
            ug3 = 3n * ugly[n3++];
        if (u == ug5)
            ug5 = 5n * ugly[n5++];
    }
    return 0;
}
main();

1
你知道这个吗?链接的答案的代码可以在0.02秒内计算出第十亿个H.N.,并且在大约2秒内计算出第一万亿个H.N.(在Ideone上)。 - Will Ness
1
@WillNess:贡献了不起!但是Haskell对于非爱好者来说太过陌生。您公布的时间是否包括精确值的计算和转换为十进制? - chqrlie
我的意思是,三元组是精确的,当然。Haskell已经提供了指数和十进制打印功能,所以我没有重新实现它。解释器对2^1334*3^335*5^404做出响应,立即打印结果(打印后显示0.02秒)。将其添加到Ideone上的代码很容易,但我不想使输出混乱。 - Will Ness
我已经在Ideone条目中添加了完整的精确数字输出;对于第10亿个数字,运行时间没有改变。但是对于第1万亿个数字,时间增加了将近一秒钟,超过了之前的2秒钟。 - Will Ness
@WillNess:这个算法简单而优雅,Louis Klauder的顶带想法非常出色。使用基于2的对数进行计算可以节省约10%的时间。关于算法只有一个评论:您在最后对带进行了排序,但您只需要在排序数组中找到第m个条目,这可以通过自定义分区函数更快地计算。 Haskell是否提供这样的原语?与枚举循环相比,排序时间可能可以忽略不计... - chqrlie
显示剩余4条评论

1
我想我们可以使用“动态规划(DP)”来计算“第n个丑数”。详细解释可以在http://www.geeksforgeeks.org/ugly-numbers/找到。
#include <iostream>
#define MAX 1000

using namespace std;

// Find Minimum among three numbers
long int min(long int x, long int y, long int z) {

    if(x<=y) {
        if(x<=z) {
            return x;
        } else {
            return z;
        }
    } else {
        if(y<=z) {
            return y;
        } else {
            return z;
        }
    }   
}


// Actual Method that computes all Ugly Numbers till the required range
long int uglyNumber(int count) {

    long int arr[MAX], val;

    // index of last multiple of 2 --> i2
    // index of last multiple of 3 --> i3
    // index of last multiple of 5 --> i5
    int i2, i3, i5, lastIndex;

    arr[0] = 1;
    i2 = i3 = i5 = 0;
    lastIndex = 1;


    while(lastIndex<=count-1) {

        val = min(2*arr[i2], 3*arr[i3], 5*arr[i5]);

        arr[lastIndex] = val;
        lastIndex++;

        if(val == 2*arr[i2]) {
            i2++;
        }
        if(val == 3*arr[i3]) {
            i3++;
        }
        if(val == 5*arr[i5]) {
            i5++;
        }       
    }

    return arr[lastIndex-1];

}

// Starting point of program
int main() {

    long int num;
    int count;

    cout<<"Which Ugly Number : ";
    cin>>count;

    num = uglyNumber(count);

    cout<<endl<<num;    

    return 0;
}

我们可以看到它相当快,只需更改 MAX 的值即可计算更高的丑数

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接