【问题标题】:Hashmap memoization slower than directly computing the answerHashmap memoization 比直接计算答案慢
【发布时间】:2016-11-21 03:00:44
【问题描述】:

我一直在玩 Project Euler 挑战,以帮助提高我对 Java 的了解。特别是,我为problem 14 编写了以下代码,它要求您找到最长的 Collat​​z 链,该链的起始数字低于 1,000,000。它的工作原理是假设子链极有可能出现不止一次,并且通过将它们存储在缓存中,不会进行冗余计算。

Collat​​z.java:

import java.util.HashMap;

public class Collatz {
    private HashMap<Long, Integer> chainCache = new HashMap<Long, Integer>();

    public void initialiseCache() {
        chainCache.put((long) 1, 1);
    }

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

    public int collatzChain(long n) {
        if(chainCache.containsKey(n)) {
            return chainCache.get(n);
        }
        else {
            int count = 1 + collatzChain(collatzOp(n));     
            chainCache.put(n, count);
            return count;
        }
    }  
}

ProjectEuler14.java:

public class ProjectEuler14 {
    public static void main(String[] args) {
        Collatz col = new Collatz();
    
        col.initialiseCache();
        long limit = 1000000;
    
        long temp = 0;
        long longestLength = 0;
        long index = 1;
    
        for(long i = 1; i < limit; i++) {
            temp = col.collatzChain(i);
            if(temp > longestLength) {
                longestLength = temp;
                index = i;
            }
        }
        System.out.println(index + " has the longest chain, with length " + longestLength);
    }
}

这行得通。而根据 Windows Powershell 的“measure-command”命令,执行大约需要 1708 毫秒(1.708 秒)。

但是,在阅读了论坛之后,我注意到有些人编写了看似幼稚的代码,从头开始计算每条链,但执行时间似乎比我好得多。我(从概念上)拿了一个答案,并将其翻译成 Java:

NaiveProjectEuler14.java:

public class NaiveProjectEuler14 {
    public static void main(String[] args) {
        int longest = 0;
        int numTerms = 0;
        int i;
        long j;

        for (i = 1; i <= 10000000; i++) {
            j = i;
            int currentTerms = 1;

            while (j != 1) {
                currentTerms++;
    
                if (currentTerms > numTerms){
                    numTerms = currentTerms;
                    longest = i;
                }
    
                if (j % 2 == 0){
                    j = j / 2;
                }
                else{
                    j = 3 * j + 1;
                }
            }
        }
        System.out.println("Longest: " + longest + " (" + numTerms + ").");
    }
}

在我的机器上,这也给出了正确的答案,但它在 0.502 毫秒内给出了答案 - 是我原始程序速度的三分之一。起初我认为创建 HashMap 可能会有一点开销,而且所花费的时间太短,无法得出任何结论。但是,如果我将两个程序的上限从 1,000,000 增加到 10,000,000,NaiveProjectEuler14 需要 4709 毫秒(4.709 秒),而 ProjectEuler14 需要高达 25324 毫秒(25.324 秒)!

为什么 ProjectEuler14 需要这么长时间?我能理解的唯一解释是,在 HashMap 数据结构中存储大量对会增加巨大的开销,但我不明白为什么会这样。我还尝试记录在程序过程中存储的(键,值)对的数量(1,000,000 的情况下为 2,168,611 对,10,000,000 的情况下为 21,730,849 对),并向 HashMap 构造函数提供略高于该数字的数量它最多只需要调整自己的大小一次,但这似乎不会影响执行时间。

有人对为什么记忆版本慢很多有任何理由吗?

【问题讨论】:

  • 你试过增加Hashmap的初始容量吗?
  • 另外你的 hashmap 只是一个数组,为什么不直接使用数组呢,它会更快,不涉及自动装箱。
  • @krzyk 是的,正如我在倒数第二段中提到的,我尝试将初始容量增加到((键,值)对存储)/0.75(0.75 是默认负载因子)并且没有执行时间的变化。
  • 您是否尝试过使用分析器来查看是什么花费了他们的时间?给出的答案可能是正确的,但您仍然想知道为什么某些东西很慢然后测量它。

标签: java performance hashmap memoization


【解决方案1】:

这种不幸的现实有一些原因:

  • 立即获取并检查空值,而不是 containsKey
  • 代码使用了一个额外的方法被调用
  • 映射存储原始类型的包装对象(整数、长整数)
  • 将字节码转换为机器码的 JIT 编译器可以做更多的计算
  • 缓存的比例不大,比如斐波那契

类似的会是

public static void main(String[] args) {
    int longest = 0;
    int numTerms = 0;
    int i;
    long j;

    Map<Long, Integer> map = new HashMap<>();

    for (i = 1; i <= 10000000; i++) {
        j = i;

        Integer terms = map.get(i);
        if (terms != null) {
            continue;
        }
        int currentTerms = 1;

        while (j != 1) {
            currentTerms++;

            if (currentTerms > numTerms){
                numTerms = currentTerms;
                longest = i;
            }

            if (j % 2 == 0){
                j = j / 2;

                // Maybe check the map only here
                Integer m = map.get(j);
                if (m != null) {
                    currentTerms += m;
                    break;
                }
            }
            else{
                j = 3 * j + 1;
            }
        }
        map.put(j, currentTerms);
    }
    System.out.println("Longest: " + longest + " (" + numTerms + ").");
}

这并没有真正做足够的记忆。为了增加不检查 3*j+1 的参数,在一定程度上减少了未命中(但也可能跳过 meoized 值)。

记忆来自于每次调用的繁重计算。如果函数因为深度递归而不是计算而花费很长时间,那么每次函数调用的记忆开销就会变成负数。

【讨论】:

    猜你喜欢
    • 2018-07-15
    • 2018-10-23
    • 2014-06-06
    • 1970-01-01
    • 2021-11-30
    • 1970-01-01
    • 2013-12-24
    • 2018-05-30
    • 2014-12-08
    相关资源
    最近更新 更多