ハッシュは本当に遅いのか?いや、遅くない(反語)

この記事を簡潔にまとめた記事「ハッシュは二分木(ツリー)より速い」を掲載しました。(2008-08-04)



ハッシュと二分木(ツリー)*1では普通はハッシュの方が速いとされる(ハッシュは挿入、参照、削除の時間計算量がO(1)、二分木はO(log N))。しかし、ハッシュは動作が複雑なため、実際はあまり二分木と速度が変わらないとも言われる。

それをC++で検証した記事を見つけた。結論は、二分木よりハッシュの方が遅いというものだ。


ハッシュは本当に速いのか? - S34
http://www.s34.co.jp/cpptechdoc/article/hash/
ハッシュは本当に遅いのか - 山に生きる
http://d.hatena.ne.jp/pmoky/20050502/1114966341



では、JavaのHashSetとTreeSet(またはHashMapやTreeMap)でも同じことが起こるのだろうかと思い、検証してみた。

データの数Nを、10000, 20000, ... , 640000と変化させて、そのときにかかったデータの挿入、参照、削除の時間を調べてみる。当然、データ数が多くなると繰り返しもそれだけ行われているわけで、1個あたりどれだけかかったのか、ということが重要なのでNで割った時間も計算する。

その結果が↓だ。


HashSetの結果



(挿入) hash(10000): 15 [ms], 1.5265906999999999 [ms/elements]
(参照) hash(10000): 6 [ms], 0.687322 [ms/elements]
(削除) hash(10000): 15 [ms], 1.5254732 [ms/elements]


(挿入) hash(20000): 13 [ms], 0.66115945 [ms/elements]
(参照) hash(20000): 7 [ms], 0.3906921 [ms/elements]
(削除) hash(20000): 10 [ms], 0.5364369 [ms/elements]


(挿入) hash(40000): 29 [ms], 0.7438585 [ms/elements]
(参照) hash(40000): 20 [ms], 0.522028625 [ms/elements]
(削除) hash(40000): 32 [ms], 0.8135810499999999 [ms/elements]


(挿入) hash(80000): 105 [ms], 1.3176360375 [ms/elements]
(参照) hash(80000): 93 [ms], 1.172062375 [ms/elements]
(削除) hash(80000): 125 [ms], 1.5711808250000001 [ms/elements]


(挿入) hash(160000): 163 [ms], 1.02348553125 [ms/elements]
(参照) hash(160000): 110 [ms], 0.690080725 [ms/elements]
(削除) hash(160000): 132 [ms], 0.82666058125 [ms/elements]


(挿入) hash(320000): 1011 [ms], 3.16175571875 [ms/elements]
(参照) hash(320000): 224 [ms], 0.700526359375 [ms/elements]
(削除) hash(320000): 263 [ms], 0.82408169375 [ms/elements]


(挿入) hash(640000): 2686 [ms], 4.198237834375 [ms/elements]
(参照) hash(640000): 481 [ms], 0.7527174375 [ms/elements]
(削除) hash(640000): 553 [ms], 0.86452459375 [ms/elements]

TreeSetの結果



(挿入) tree(10000): 14 [ms], 1.4922847 [ms/elements]
(参照) tree(10000): 15 [ms], 1.5876599 [ms/elements]
(削除) tree(10000): 24 [ms], 2.4928586999999998 [ms/elements]


(挿入) tree(20000): 25 [ms], 1.2912255499999998 [ms/elements]
(参照) tree(20000): 38 [ms], 1.9112065999999999 [ms/elements]
(削除) tree(20000): 42 [ms], 2.1247393 [ms/elements]


(挿入) tree(40000): 54 [ms], 1.368253525 [ms/elements]
(参照) tree(40000): 109 [ms], 2.7254651 [ms/elements]
(削除) tree(40000): 106 [ms], 2.664130475 [ms/elements]


(挿入) tree(80000): 117 [ms], 1.46314685 [ms/elements]
(参照) tree(80000): 313 [ms], 3.914603675 [ms/elements]
(削除) tree(80000): 303 [ms], 3.7911208 [ms/elements]


(挿入) tree(160000): 334 [ms], 2.09372804375 [ms/elements]
(参照) tree(160000): 718 [ms], 4.49078025625 [ms/elements]
(削除) tree(160000): 720 [ms], 4.50348263125 [ms/elements]


(挿入) tree(320000): 982 [ms], 3.068847609375 [ms/elements]
(参照) tree(320000): 1715 [ms], 5.360803540625 [ms/elements]
(削除) tree(320000): 1710 [ms], 5.346012028125 [ms/elements]


(挿入) tree(640000): 2017 [ms], 3.152671115625 [ms/elements]
(参照) tree(640000): 4580 [ms], 7.156315868750001 [ms/elements]
(削除) tree(640000): 4120 [ms], 6.437939825 [ms/elements]


おおおおおおおおお!!!!!

HashSetの方が速いじゃないか!!(参照、削除は)Nの影響も受けていないように見える(つまりO(1))。一方TreeSetはNの増加に従って遅くなっている。その時間もO(log N)っぽい。教科書通りの結果だ。


とりあえずJavaではHashSetの方が速いようだ。



おいおい、ちょっと待った。HashSetだって挿入にかかった時間は長くなっていってるじゃないか。

そんな声が聞こえてきそうだか、これはHashSetの動作原理を考えれば当たり前だ。HashSetはデフォルトでは初期容量16、負荷係数0.75のハッシュテーブルを作成する。つまり、ハッシュテーブルのサイズが16で、ハッシュテーブルに16*0.75=12個分を超えるのデータが挿入されたら、ハッシュテーブルは2倍のサイズ(この場合は32)に拡張される。このときに、配列の生成、ハッシュの再計算などのコストが発生するわけだ。

この結果、「データ数が多い→テーブルの拡張がたくさん行われる→挿入は遅くなる」、ということになる。


挿入が遅いんじゃ、結局HashSetも大して変わらないんじゃないか。

そんなことはない。ハッシュテーブルの拡張を行わないようにするには次のようにすればいい。

Set set = new HashSet<String>(n * 4 / 3 + 1);

これで、ハッシュテーブルの初期容量がn*4/3に設定される。4/3をかけるのは、負荷係数を考慮してのことだ。こうしておけば、n個のデータが一切ハッシュの衝突を起こさずにハッシュテーブルに格納されたとしても、容量*0.75がnを超えることはない(4/3*0.75=1なので。+1してるのは、n*4/3が割り切れず切り捨てられたときのため)。


さて、ではこの設定(hash2とする)でHashSetの時間を計測してみると・・・。

HashSetの結果(テーブル拡張なし)



(挿入) hash2(10000): 12 [ms], 1.2928179 [ms/elements]
(参照) hash2(10000): 5 [ms], 0.5780344000000001 [ms/elements]
(削除) hash2(10000): 8 [ms], 0.8285130999999999 [ms/elements]


(挿入) hash2(20000): 12 [ms], 0.6072420000000001 [ms/elements]
(参照) hash2(20000): 8 [ms], 0.40608515 [ms/elements]
(削除) hash2(20000): 10 [ms], 0.506475 [ms/elements]


(挿入) hash2(40000): 24 [ms], 0.6130457749999999 [ms/elements]
(参照) hash2(40000): 20 [ms], 0.5092407 [ms/elements]
(削除) hash2(40000): 30 [ms], 0.759384225 [ms/elements]


(挿入) hash2(80000): 58 [ms], 0.7295305750000001 [ms/elements]
(参照) hash2(80000): 56 [ms], 0.7091613625 [ms/elements]
(削除) hash2(80000): 64 [ms], 0.8034331124999999 [ms/elements]


(挿入) hash2(160000): 119 [ms], 0.7443561249999999 [ms/elements]
(参照) hash2(160000): 147 [ms], 0.9210598 [ms/elements]
(削除) hash2(160000): 132 [ms], 0.82502280625 [ms/elements]


(挿入) hash2(320000): 253 [ms], 0.791681446875 [ms/elements]
(参照) hash2(320000): 224 [ms], 0.7015600093750001 [ms/elements]
(削除) hash2(320000): 272 [ms], 0.8514306624999999 [ms/elements]


(挿入) hash2(640000): 529 [ms], 0.8271747875 [ms/elements]
(参照) hash2(640000): 475 [ms], 0.7435184671875 [ms/elements]
(削除) hash2(640000): 567 [ms], 0.8864233281250001 [ms/elements]

速い!!なんて理想的な結果なんだ・・・。



こう考えると、一番最初に上げた二つの記事では、ハッシュへの挿入、削除を合計して時間を計算しているので、挿入時のハッシュテーブル再構築の負荷がハッシュのパフォーマンスを低下させているように思える(もしかするとC++固有の特性(最適化の結果、ライブラリの設計ミスなど)なのかもしれないが)。



結局、初期容量を的確に設定してやれば、時間計算量は教科書どおりハッシュはO(1)、二分木はO(log N)になるみたいだ(↓横軸はlogスケール。Hashは横ばい、Treeは直線的に増加しているのがわかる)。



結論。やっぱりハッシュは二分木よりも速い



最後に、今回作成したプログラムを載せておく。

import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.TreeSet;

public class HashVsTreeMain {
    public static void main(String[] args) {
        String typeName;
        typeName = "hash";
        //typeName = "tree";
        //typeName = "hash2";

        for (int n = 10000; n < 1000000; n *= 2) {
            // setを生成。
            Set<String> set;
            if ("hash".equals(typeName)) {
                // HashSetを生成。
                set = new HashSet<String>();
            } else if ("tree".equals(typeName)) {
                // TreeSetを生成。
                set = new TreeSet<String>();
            } else {
                // HashSetを、データ挿入時にテーブルの拡張が行われないように
                // 初期容量を設定して生成。
                set = new HashSet<String>(n * 4 / 3);
            }

            // 挿入、参照、削除にかかる時間を表示。
            time(set, typeName, n);
        }
    }

    static void time(Set<String> set, String typeName, int n) {
        // 重複のないn個のランダムな文字列を生成して、stringListに格納する。
        List<String> stringList = new ArrayList<String>();
        Set<String> strings = new TreeSet<String>();
        while (strings.size() < n) {
            strings.add(getRandomString(8));
        }
        for (String string : strings) {
            stringList.add(string);
        }

        // 時間を計るための変数begin, endを生成。
        long begin;
        long end;

        // 挿入にかかる時間を測定して表示。
        begin = System.nanoTime();
        for (String string : stringList) {
            set.add(string);
        }
        end = System.nanoTime();
        System.out.print("(挿入) ");
        show(typeName, begin, end, n);

        // 挿入順に取り出すと平均的なパフォーマンスが得られない場合があるので
        // 一応stringListをシャッフルしておく。
        Collections.shuffle(stringList);

        // 参照にかかる時間を測定して表示。
        begin = System.nanoTime();
        for (String string : stringList) {
            set.contains(string);
        }
        end = System.nanoTime();
        System.out.print("(参照) ");
        show(typeName, begin, end, n);

        // 削除にかかる時間を測定して表示。
        begin = System.nanoTime();
        for (String string : stringList) {
            set.remove(string);
        }
        end = System.nanoTime();
        System.out.print("(削除) ");
        show(typeName, begin, end, n);

        // 空行を表示。
        System.out.println();
    }

    // 長さlengthのランダムな文字列を生成して返す。
    static String getRandomString(int length) {
        if (length > 0) {
            char[] chars = new char[length];
            int digit;

            for (int charIndex = 0; charIndex < chars.length; charIndex++) {
                digit = (int) (26 * Math.random());
                chars[charIndex] = (char) ('a' + digit);
            }

            return new String(chars);
        } else if (length == 0) {
            return "";
        } else {
            throw new IllegalArgumentException(Integer.toString(length));
        }
    }

    // 結果を表示
    static void show(String type, long begin, long end, int n) {
        System.out.print(type);
        System.out.print("(");
        System.out.print(n);
        System.out.print("): ");
        System.out.print((end - begin) / 1000000);
        System.out.print(" [ms], ");
        System.out.print(((end - begin) / 1000.0) / n);
        System.out.println(" [ms/elements]");
    }
}


※実行環境: Windows XP, Pentium4 3.33GHz, JRE5.0

*1:JavaのTreeSet、TreeMapが用いているのは、正確にはRed-Black treeというアルゴリズムらしい。