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));
    }
}

私のコンピューターでネストされたforループを実行するのに約4秒かかり、なぜそんなに長くかかったのか分かりません。外側のループは100,000回実行され、内側のforループは1回実行される必要があります(hashSetの値が-1になることはないため)。また、HashSetからのアイテムの削除はO(1)なので、約200,000回の操作が必要です。通常、1秒あたり100,000,000回の操作がある場合、コードの実行に4秒かかるのはなぜですか?

さらに、行hashSet.remove(i);がコメント化されている場合、コードの所要時間はわずか16ミリ秒です。内部のforループがコメント化されている場合(ただし、コメント化されていない場合hashSet.remove(i);)、コードは8ミリ秒しかかかりません。


4
調査結果を確認します。その理由は推測できますが、うまくいけば誰かが興味深い説明を投稿してくれるでしょう。
khelwood

1
for valループは時間がかかるもののようです。remove非常に速く、まだです。セットが変更された後に新しいイテレータを設定するなんらかのオーバーヘッド...?
khelwood

@apanginは、stackoverflow.com / a / 59522575/108326でfor valループが遅い理由を説明しています。ただし、ループはまったく必要ないことに注意してください。セットに-1とは異なる値があるかどうかを確認する場合は、確認する方がはるかに効率的hashSet.size() > 1 || !hashSet.contains(-1)です。
markusk

回答:


32

HashSetアルゴリズムが2次の複雑度に低下する、限界的なユースケースを作成しました。

これは、時間がかかる単純化されたループです。

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等、2番目のバケットに進み

次に、0から99999までの連続する番号を削除します。最も単純なケース(バケットに単一のキーが含まれている場合)では、キーの削除は、バケット配列内の対応する要素をnullにすることとして実装されます。テーブルは削除後に圧縮または再ハッシュされないことに注意してください。

したがって、バケット配列の最初から削除するキーが多いほど、HashIterator空でない最初のバケットを見つける必要が長くなります。

もう一方の端からキーを削除してみてください:

hashSet.remove(100_000 - i);

アルゴリズムは劇的に速くなります!


1
ああ、私はこれに遭遇しましたが、最初の数回の実行後にそれを却下し、これはJITの最適化の可能性があると考え、JITWatchによる分析に移行しました。最初にasync-profilerを実行する必要があります。くそー!
Adwait Kumar

1
とても興味深い。ループで次のようなことを行うと、内部マップのサイズを小さくすることでスピードアップしますif (i % 800 == 0) { hashSet = new HashSet<>(hashSet); }
グレー-
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.