Несподівані часи роботи коду HashSet


28

Тож спочатку у мене був цей код:

import java.util.*;

public class sandbox {
    public static void main(String[] args) {
        HashSet<Integer> hashSet = new HashSet<>();
        for (int i = 0; i < 100_000; i++) {
            hashSet.add(i);
        }

        long start = System.currentTimeMillis();

        for (int i = 0; i < 100_000; i++) {
            for (Integer val : hashSet) {
                if (val != -1) break;
            }

            hashSet.remove(i);
        }

        System.out.println("time: " + (System.currentTimeMillis() - start));
    }
}

Для запуску вкладених циклів на моєму комп’ютері потрібно 4 секунди, і я не розумію, чому це зайняло так багато часу. Зовнішній цикл працює 100 000 разів, внутрішній цикл повинен працювати 1 раз (оскільки будь-яке значення хеш-версії ніколи не буде -1), а вилучення елемента з HashSet - це O (1), тому операцій повинно бути близько 200 000. Якщо типово 100 000 000 операцій за секунду, то як же мій код займає 4 секунди?

Крім того, якщо hashSet.remove(i);коментований рядок , код займає лише 16 мс. Якщо внутрішній цикл для коментується (але ні hashSet.remove(i);), код займає лише 8 мс.


4
Я підтверджую ваші висновки. Я міг би міркувати про причину, але, сподіваюся, хтось розумний опублікує захоплююче пояснення.
хелвуд

1
Схоже, що for valпетля - це річ, яка займає час. Це removeвсе ще дуже швидко. Якась накладна настройка нового ітератора після зміни набору ...?
хелвуд

@apangin дав гарне пояснення в stackoverflow.com/a/59522575/108326, чому for valцикл повільний. Однак зауважте, що цикл взагалі не потрібен. Якщо ви хочете перевірити, чи є в наборі значення, відмінні від -1, перевірити це було б набагато ефективніше hashSet.size() > 1 || !hashSet.contains(-1).
Маркуск

Відповіді:


32

Ви створили крайовий випадок використання HashSet, де алгоритм погіршується до квадратичної складності.

Ось спрощений цикл, який займає так багато часу:

for (int i = 0; i < 100_000; i++) {
    hashSet.iterator().next();
    hashSet.remove(i);
}

async-profiler показує, що майже весь час проводиться всередині java.util.HashMap$HashIterator()конструктора:

    HashIterator() {
        expectedModCount = modCount;
        Node<K,V>[] t = table;
        current = next = null;
        index = 0;
        if (t != null && size > 0) { // advance to first entry
--->        do {} while (index < t.length && (next = t[index++]) == null);
        }
    }

Виділена лінія - це лінійний цикл, який шукає перше не порожнє відро в хеш-таблиці.

Оскільки Integerмає тривіальне значення hashCode(тобто хеш-код дорівнює самому числу), виходить, що послідовні цілі числа здебільшого займають послідовні відра в хеш-таблиці: число 0 переходить до першого відра, число 1 переходить до другого відра тощо.

Тепер ви вилучаєте послідовні числа від 0 до 99999. У найпростішому випадку (коли у відрі міститься один ключ), видалення ключа реалізується як видалення відповідного елемента з масиву ковша. Зауважте, що таблиця після видалення не ущільнюється і не переробляється.

Отже, чим більше ключів ви видалите з початку масиву ковша, тим довше HashIteratorпотрібно знайти перше непусте відро.

Спробуйте видалити ключі з іншого кінця:

hashSet.remove(100_000 - i);

Алгоритм стане різко швидшим!


1
Ага, я натрапив на це, але відхилив його після перших кількох запусків і подумав, що це може бути деяка оптимізація JIT і перейшов до аналізу через JITWatch. Потрібно було запустити async-профілер першим. Чорт!
Adwait Kumar

1
Досить цікаво. Якщо ви робите що - щось подібне до наступного в циклі, він прискорює його за рахунок зменшення розміру внутрішньої карти: if (i % 800 == 0) { hashSet = new HashSet<>(hashSet); }.
Сірий - Так перестань бути злим
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.