Project Euler # 14: Почему мой алгоритм TreeMap медленнее, чем грубая сила? - программирование

Project Euler # 14: Почему мой алгоритм TreeMap медленнее, чем грубая сила?

Предыстория: я впервые изучил С++ и Java в школе несколько лет назад, но за последние 9 лет я не много программировал, поскольку моя предыдущая карьера не требовала этого.

Я решил изучить Project Euler, чтобы освежить мое программирование и решить проблему 14, которая предлагает найти целое число от одного до миллиона с самой длинной последовательностью Collatz. (Последовательность Collatz продолжается, начиная с начального числа, умножая число на 3 и добавляя 1, если это нечетно, или уменьшает число, если оно равно. Процесс продолжается до тех пор, пока число не достигнет 1.)

Сначала я решил проблему с использованием грубой силы, как показано в приведенном ниже коде.

int n;
long temp; // long is necessary since some Collatz sequences go outside scope of int
int[] n_length = new int[1000000];
    for(n = 0; n < 1000000; n++){
        temp = n + 1;
        n_length[n] = 1;
        while (temp > 1){
            n_length[n]++;
            if (temp % 2 == 0) temp = temp/2;
            else temp = 3*temp + 1;

        }
    }
int max = 0;
    int max_index = 0;
    for (int i = 0; i < 1000000; i++){
        if (n_length[i] > max){
            max = n_length[i];
            max_index = i;
        }
    }
    System.out.println("The number with the longest Collatz sequence is " + (max_index + 1));

Я думал, что этот подход будет неэффективным, поскольку он выполняет алгоритм значительно чаще, чем необходимо. Любое число, которое является частью предыдущей последовательности последовательности Collatz, будет эффективно определять свою последовательность, и поэтому вы в конечном итоге вычисляете последовательность каждого отдельного номера каждый раз, когда она появляется в последовательности Collatz.

Я решил, что было бы лучше сохранить каждый номер на карте, как только он появится в последовательности Collatz, поэтому вам нужно будет только рассчитать его. Для этого я использовал TreeMap с числами, используемыми в качестве ключей, и ассоциированной длиной последовательности Collatz, как значение, и использовал рекурсивную функцию для вставки каждого числа в карту, как только он появился в последовательности Collatz. (См. Код ниже.)

public static TreeMap<Long, Integer> tm = new TreeMap<Long, Integer>();
public static void main(String[] args) {

    tm.put((long)1, 1);
    int maxVal = 1;
    long keyWithMaxVal = 1;
    int maybeMax;
    for (long i = 2; i <= 1000000; i++){
        if(!(tm.containsKey(i))){
            maybeMax = addKey(i);
            if (maybeMax >= maxVal){
                maxVal = maybeMax;
                keyWithMaxVal = i;
            }
        }
    }
    System.out.println("The number with the longest Collatz sequence is " + keyWithMaxVal + " with length " + maxVal);
}
public static int addKey(long key){

    while (!(tm.containsKey(key))){
        if (key % 2 == 0){
            tm.put(key, 1 +addKey(key/2));
        }
        else{
            tm.put(key, 1 + addKey(3*key + 1));
        }
    }
    return tm.get(key);
}

Я использовал TreeMap, так как он автоматически сортирует ключи при вводе, так как я повторяю цикл for, я могу быстро проверить, были ли ключи уже вставлены, и избегать вызова метода addKey для добавления ключей, если только не нужно. Я думал, что этот алгоритм будет намного, намного быстрее.

Однако, когда я действительно запускал код, я с удивлением обнаружил, что алгоритм грубой силы мгновенно пришел к ответу, а рекурсивный алгоритм TreeMap занял гораздо больше времени, около 6 секунд. Когда я модифицировал свои программы до 5 миллионов, а не миллиона, разница стала еще более выраженной. Я добавил код для каждой программы, чтобы убедиться, что вторая программа работает меньше, чем первая, и действительно я определил, что метод addKey вызывается только один раз для каждого ключа, тогда как количество циклов while, необходимое для итерации в первой программе была равна сумме длин всех чисел последовательностей Collatz (т.е. намного чаще, чем число вызовов методов во втором алгоритме).

Итак, почему первый алгоритм намного быстрее второго? Это потому, что массив примитивов в первом алгоритме требует меньше ресурсов, чем объекты TreeMap Wrapper во втором? Является ли поиск на карте проверкой того, существует ли ключ уже медленнее, чем я ожидал (не должно быть время журнала?)? Рекурсивные методы, требующие большого количества вызовов методов, изначально медленнее? Или есть что-то еще, что я пропускаю

4b9b3361

Ответ 1

В дополнение к причинам, уже упомянутым в других ответах, основная причина для реализации на основе массива намного быстрее, вероятно, из-за того, что он имеет большую выгоду от эффектов кэширования процессора:

  • Ваши две отдельные маленькие жесткие петли будут полностью вписываться в кеш <инструкций > L0 современного процессора (он может содержать 1536 декодированных микроопераций на Sandy Bridge). Запуск этих двух последовательно будет намного быстрее, чем один цикл с большим количеством инструкций, которые не вписываются в этот кеш. Учитывая, что второй цикл очень мал, вполне вероятно, что его инструкции уже были предварительно запрограммированы и декодированы как микрооператоры и будут помещаться в Loop Block Buffer (28 микроопераций).

    loop block buffer source: hardwaresecrets.com

  • В отношении доступа к данным существует большая локальность ссылки. Как в первом, так и в втором циклах, где вы выполняете последовательный доступ. Там также используется prefetcher, потому что ваш шаблон доступа полностью предсказуем.


В связи с этими двумя темами и более, я хотел бы рекомендовать вам посмотреть этот отличный "навык": 95% производительности - это чистые типичные модели Мартин Томпсон, который более подробно обсуждает эти и другие темы.

Ответ 2

Этот код проверяет, сколько времени требуется, чтобы найти самую длинную последовательность collatz для чисел от 1 до 5 миллионов. Он использует три разных метода: итеративный, рекурсивный и сохраняющий результаты в хэш-карте.

Результат выглядит следующим образом

iterative
time = 2013ms
max n: 3732423, length: 597
number of iterations: 745438133

recursive
time = 2184ms
max n: 3732423, length: 597
number of iterations: 745438133

with hash map
time = 7463ms
max n: 3732423, length: 597
number of iterations: 15865083

Итак, для решения хэш-карты количество шагов, которые должна выполнить программа, почти в 50 раз меньше. Несмотря на это, он более чем в 3 раза медленнее, и я думаю, что основной причиной этого является тот факт, что простые математические операции над числами, например. добавление, умножение и т.д. намного быстрее, чем операции с хэш-картами.

import java.util.function.LongUnaryOperator;
import java.util.HashMap;

public class Collatz {
  static int iterations = 0;
  static HashMap<Long, Long> map = new HashMap<>();

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

  static long collatzLength(long n) {
    iterations++;
    int length = 1;
    while(n > 1) {
      iterations++;
      n = nextColl(n);
      length++;
    }
    return length;
  }

  static long collatzLengthMap(long n) {
    iterations++;
    if(n == 1) return 1;
    else return map.computeIfAbsent(n, x -> collatzLengthMap(nextColl(x)) + 1);
  }

  static long collatzLengthRec(long n) {
    iterations++;
    if(n == 1) return 1;
    else return collatzLengthRec(nextColl(n)) + 1;
  }

  static void test(String msg, LongUnaryOperator f) {
    iterations = 0;
    long max = 0, maxN = 0;
    long start = System.nanoTime();
    for(long i = 1; i <= 5000000; i++) {
      long length = f.applyAsLong(i);
      if(length > max) {
        max = length;
        maxN = i;
      }
    }
    long end = System.nanoTime();
    System.out.println(msg);
    System.out.println("time = " + ((end - start)/1000000) + "ms");
    System.out.println("max n: " + maxN + ", length: " + max);
    System.out.println("number of iterations: " + iterations);
    System.out.println();
  }

  public static void main(String[] args) {
    test("iterative", Collatz::collatzLength);
    test("recursive", Collatz::collatzLengthRec);
    test("with hash map", Collatz::collatzLengthMap);
  }
}

Ответ 3

Я сделал несколько изменений в вашем коде, и это кажется более быстрым, хотя и не мгновенным.

Как правило, я пытался избавиться от ненужного, повторного доступа к карте.

Замена TreeMap с помощью HashMap изменяет некоторые операции O (log n) на O (1). Вы никогда не используете отсортированное свойство TreeMap, просто его метод содержит.

Переход назад в основной цикл уменьшает количество случаев, когда условие maybeMax >= maxVal истинно.

import java.util.HashMap;

public class Test {
  public static HashMap<Long, Integer> tm = new HashMap<Long, Integer>();

  public static void main(String[] args) {
    tm.put((long) 1, 1);
    int maxVal = 1;
    long keyWithMaxVal = 1;
    int maybeMax;
    for (long i = 1000000; i >= 2; i--) {
      if (!(tm.containsKey(i))) {
        maybeMax = addKey(i);
        if (maybeMax >= maxVal) {
          maxVal = maybeMax;
          keyWithMaxVal = i;
        }
      }
    }
    System.out.println("The number with the longest Collatz sequence is "
        + keyWithMaxVal + " with length " + maxVal);
  }

  public static int addKey(long key) {
    Integer boxedValue = tm.get(key);
    if (boxedValue == null) {
      if (key % 2 == 0) {
        int value = 1 + addKey(key / 2);
        tm.put(key, value);
        return value;
      } else {
        int value = 1 + addKey(3 * key + 1);
        tm.put(key, value);
        return value;
      }
    }
    return boxedValue.intValue();
  }
}

Ответ 4

Я думаю, что авто (un) бокс является источником проблемы. Даже Руководство по программированию Java SE 8 упоминает это:

Производительность результирующего списка, вероятно, будет плохая, так как она помещает или распаковывает при каждой операции get или set. Это достаточно быстро для случайного использования, но было бы глупо использовать его в критическом внутреннем цикле производительности.

Ответ 5

Как указано другими, вы должны переключиться на HashMap вместо использования TreeMap, чтобы уменьшить сложность операций вставки и извлечения.

Однако оптимальное использование HashMap зависит от установки его начальной емкости. Если вы этого не сделаете, то после того, как ваши вставки превысят емкость по умолчанию, HashMap перераспределит большую таблицу, и ваши элементы в конечном итоге получат хэширование в новую таблицу. Это замедлит выполнение вашей программы.

Минимальное изменение:

public static HashMap<Long, Integer> tm = new HashMap<Long, Integer>(1000000, 1.0);

HashMap(int initialCapacity, float loadFactor)
Создает пустой HashMap с заданной начальной мощностью и коэффициентом загрузки.
Документация по Java

Здесь мы заявляем, что хотим, чтобы HashMap имел емкость 1000000 (способную удерживать много элементов) с коэффициентом загрузки 1,0 (вставки должны превышать 100% емкости до того, как произойдет повторная запись).

Ответ 6

H, я думаю, что keeptsKey отвечает за этот результат.

TreeMap ContainsKey - это O (log (n))

https://github.com/benblack86/java-snippets/blob/master/resources/java_collections.pdf

И согласно http://en.wikipedia.org/wiki/Collatz_conjecture:

Самая длинная прогрессия для любого начального стартового номера менее 100 млн. - 63 728 127, что имеет 949 шагов.

Мы будем рассматривать сложность Collatz как C.

Итак, в вашем первом случае у вас есть:

O (n * C + n) = O (n * (C + 1)) = O (k * n)

И в рекурсивном решении:

O (n * (log (n) + C * log (n))) = O (k * n * log (n))

(Я не очень уверен в рекурсивной части, но я уверен, что это больше 1, потому что внутри рекурсивной функции, которую вы вызываете снова, содержитKey)