【问题标题】:Memoization Efficiency Problems (Collatz Hailstone Sequence)记忆效率问题(Collat​​z Hailstone 序列)
【发布时间】:2016-01-29 00:56:35
【问题描述】:

在过去的几天里,我对调查给定数字的冰雹序列 (Collatz conjecture) 的长度特别感兴趣(更多是从算法而不是数学角度)。实现递归算法可能是计算长度的最简单方法,但对我来说似乎是不必要的计算时间浪费。许多序列重叠;以 3 的冰雹序列为例:

3 -> 10 -> 5 -> 16 -> 8 -> 4 -> 2 -> 1

长度为 7;更具体地说,需要 7 次操作才能达到 1。如果我们再采用 6:

6 -> 3 -> ...

我们立即注意到我们已经计算过了,所以我们只需添加 3 的序列长度,而不是再次遍历所有这些数字,从而大大减少了计算每个数字的序列长度所需的操作次数。

我尝试使用 HashMap 在 Java 中实现这一点(考虑到 O(1) 概率获取/放置复杂度,这似乎是合适的):

import java.util.HashMap;

/* NOTE: cache.put(1,0); is called in main to act as the
 * 'base case' of sorts. 
 */

private static HashMap<Long, Long> cache = new HashMap<>();

/* Returns length of sequence, pulling prerecorded value from
 * from cache whenever possible, and saving unrecorded values
 * to the cache.
 */
static long seqLen(long n) {
    long count = 0, m = n;
    while (true) {
        if (cache.containsKey(n)) {
            count += cache.get(n);
            cache.put(m, count);
            return count;
        }
        else if (n % 2 == 0) {
            n /= 2;
        }
        else {
            n = 3*n + 1;
        }
        count++;
    }
}

seqLen 本质上所做的就是从给定的数字开始,并通过该数字的 Hailstone 序列,直到遇到 cache 中已经存在的数字,在这种情况下,它将添加到 @ 的当前值987654328@,然后将 HashMap 中的值和关联的序列长度记录为(key,val) 对。

我还有以下相当标准的递归算法进行比较:

static long recSeqLen(long n) {
    if (n == 1) {
        return 0;
    }
    else if (n % 2 == 0) {
        return 1 + recSeqLen(n / 2);
    }
    else return 1 + recSeqLen(3*n + 1);
}

日志算法应该,在所有方面,都比简单的递归方法运行得快很多。然而在大多数情况下,它根本不会运行得那么快,而且对于更大的输入,它实际上运行更慢。运行以下代码产生的时间随着 n 大小的变化而有很大差异:

long n = ... // However many numbers I want to calculate sequence
             // lengths for.

long st = System.nanoTime();
// Iterative logging algorithm
for (long i = 2; i < n; i++) {
    seqLen(i);
}
long et = System.nanoTime();
System.out.printf("HashMap algorithm: %d ms\n", (et - st) / 1000000);

st = System.nanoTime();
// Using recursion without logging values:
for (long i = 2; i < n; i++) {
    recSeqLen(i);
}
et = System.nanoTime();
System.out.printf("Recusive non-logging algorithm: %d ms\n",
                    (et - st) / 1000000);
  • n = 1,000:两种算法都在 2 毫秒左右
  • n = 100,000:迭代日志记录约为 65 毫秒,递归非日志记录约为 75 毫秒
  • n = 1,000,000: ~500ms 和 ~900ms
  • n = 10,000,000: ~14,000ms 和 ~10,000ms

较高的值会出现内存错误,因此无法检查模式是否继续。

所以我的问题是:为什么日志记录算法突然开始比 n 值较大的朴素递归算法花费 更长


编辑:

完全放弃 HashMaps 并选择简单的数组结构(以及消除检查值是否在数组中的部分开销)会产生所需的效率:

private static final int CACHE_SIZE = 80000000;
private static long[] cache = new long[CACHE_SIZE];

static long seqLen(long n) {
    int count = 0;
    long m = n;

    do {
        if (n % 2 == 0) {
            n /= 2;
        }
        else {
            n = 3*n + 1;
        }
        count++;
    } while (n > m);

    count += cache[(int)n];
    cache[(int)m] = count;
    return count;
}

迭代整个缓存大小(8000 万)现在只需 3 秒,而使用递归算法则需要 93 秒。 HashMap 算法会引发内存错误,因此甚至无法进行比较,但考虑到它在较低值下的行为,我感觉它不能很好地进行比较。

【问题讨论】:

  • Prolog 中的类似问题:stackoverflow.com/questions/30026151/…
  • 部分问题是对于大(参数)值很少(重新)使用缓存条目:仅缓存“奇数结果”,确定前(半)百万(奇数)长度进入 293698参数> 1e6的缓存长度,其中9138个被使用,73个最多两次。对 8e7 感到好奇:23741549 个条目 ">8e7",729540 重复使用,6077 两次。

标签: java performance memoization collatz


【解决方案1】:

即兴发挥,我猜它会花费大量时间重新分配哈希映射。听起来你是从空开始的,然后继续往里面添加东西。这意味着随着它的大小增长,它将需要分配更大的内存块来存储您的数据,并重新计算所有元素的哈希值,即 O(N)。尝试将大小预分配给您希望放入的内容。更多讨论请见https://docs.oracle.com/javase/8/docs/api/java/util/HashMap.html

【讨论】:

  • 我确实使用默认值(16 容量和 0.75 负载)初始化了我的 HashMap,但是更改这些值似乎对算法的速度几乎没有影响。
  • @SilverSylvester:我能够重现您的结果,并且我同意到 10M 时,缓存的性能开始变差。我想也许你得到了很多缓存未命中,所以我写了一个缓存更多的版本,它的性能更差。在这一点上我最好的猜测是,对于大 N,序列非常稀疏,以至于您没有足够的缓存命中来支付开销。你可以在这里看到我的尝试:gist.github.com/not-napoleon/47a2baece1f23678aad3
  • 我认为您可能是对的,开销太大。我已经对我的原始问题添加了一个编辑,它概述了一个按预期工作的算法,根本不使用 HashMap 结构。事后看来,HashMap 可能不是数据结构的最佳选择。无论如何,数据都是按顺序记录的,因此不需要 O(1) 访问绑定到某个键的值,我可以让数组索引代表键并获得 O(1) 访问。
猜你喜欢
  • 1970-01-01
  • 2013-03-07
  • 1970-01-01
  • 2016-05-29
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2022-01-14
  • 1970-01-01
相关资源
最近更新 更多