トップアルゴリズム > ハッシュ法

ハッシュ法

ハッシュ法とは

高速にデータを挿入、検索、削除、更新を行う手段に使われるのがハッシュ法である。 大抵のプログラムには備わっているので、通常はそれを使えばよい。

地図システムのように、データはシンプルであるがデータ量が膨大な場合には 汎用的なクラスライブラリでは対応できないことがある。

ハッシュ法ではハッシュ値のぶつかりを避ける方法として、チェインを作る方法と オープンアドレス法が知られている[1]。

前者の方がやや高度で、削除ではチェインの繋ぎ変えも必要となり、管理用のメモリもいる。

後者もなるべくぶつかりを避けようとすると、プログラムが複雑になる。

しかし、ぶつかりを覚悟すれば実装はシンプルである。 シンプルであるがゆえに、少々ぶつかりがあっても、パフォーマンスがよいことを経験している。

ハッシュ値としては、空きと削除の二つ分の数値は使えない。

文字列に対するハッシュ関数

自分がよく使うハッシュ関数を下に示す。 実際には、ハッシュサイズ(HASHSIZE) はもっと大きな値を使うが、 ここでは説明のために小さな値としている。

下のプログラムでは、"abs", "atof" など12個の関数名について、 ハッシュ値(ハッシュ関数で得られた値)を求めている。

#include <stdio.h> 

#define HASHSIZE  19
// 素数 ... , 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, 61
char *func[] = {
    "abs",   "atof",  "atoi",  "atol",  "close", "feof", 
    "fgetc", "fgets", "fopen", "fputc", "fputs", "free"
};

int hash(char *key) {
    int n, h = 0;
    for (n = 0; key[n] != '\0'; n++) {
        h = (h * 137 + key[n]) % HASHSIZE;
    }
    return h;
}

int main() {
    int n;
    printf("HASHSIZE = %d\n", HASHSIZE);
    for (n = 0; n < sizeof(func)/sizeof(char*); n++) {
        printf("%-6s", func[n]);
    }
    printf("\n");
    for (n = 0; n < sizeof(func)/sizeof(char*); n++) {
        printf(" %2d   ", hash(func[n]));
    }
}

実行結果を下に示す。

HASHSIZE = 19
abs   atof  atoi  atol  close feof  fgetc fgets fopen fputc fputs free
  7     3     6     9    13     7    18    15    11    14    11     3

ハッシュ法による文字列管理では、ハッシュサイズに等しい要素数を持つ配列が用いられる。 例えば "abs" のハッシュ値は7であるから、下図に示すように、 第7番目(先頭が第0番目)の配列要素にそのアドレスが格納される。 "atof"、"atoi" と順に割り当てていく。 "feof" の番になると、第7番目の要素は既に割り当て済みなので、配列の添え字を進めて、 未割り当て要素を探す。 この例では、直ぐ隣が空きであったため、"feof"には第8番目の要素が割り当てられる。 同様に、"fputs"、"free" も本来のハッシュ値とは違う場所が割り当てられる。

ある文字列例えば "feof" が登録されているかどうかを調べるには、 "feof"のハッシュ値が 7 であるから、もし、第7要素が空きであれば、登録されていないことになる。 何らかの文字列が登録されていたら、この文字列と "feof" を比較する。 もし、一致すれば登録されていたことになる。一致しなかった場合、配列の添え字を先に進めて、 ここが空きなら、"feof" は登録されていないことが確定する。 何らかの文字列が登録されていた場合は前と同じ処理を繰り返す。 なお、添え字が配列の末尾まで達したら、 配列の先頭に戻って同じことを行う。

衝突により、本来の場所と異なる位置に登録されることがあるため、 文字列の比較は2回以上になるケースもあるが、衝突がなければ、文字列の比較は精々1回で済む。

以上のように、ハッシュ法は登録処理も検索処理も非常に高速に行われるため、 広く使われる技術である。 ハッシュ表に格納するのは、文字列とは限らず、数値とか構造体データなど、どのようなデータでもよい。

なるべく衝突を減らすためには、ハッシュ表のサイズは、 登録する文字列の個数に比べて、十分に余裕を持たせることが必要である。 また、HASHSIZEはきりのいい数字にせず、素数にした方がよい。 このハッシュ関数では 137 という中途半端な数値が使われているが、 これもなるべく衝突が起こらないようにするためである。

以上述べたことをプログラム化したものを下に示す。

#include <stdio.h> 

#define HASHSIZE  19
char *hashTable[HASHSIZE];

// 素数 ... , 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, 61
char *func[] = {        // 登録する単語
    "abs",   "atof",  "atoi",  "atol",  "close", "feof", 
    "fgetc", "fgets", "fopen", "fputc", "fputs", "free"
};

char *check[] = {        // チェックする単語
    "strcmp", "atof",  "printf", "free"
};

int hash(char *key) {
    int n, h = 0;
    for (n = 0; key[n] != '\0'; n++) {
        h = (h * 137 + key[n]) % HASHSIZE;
    }
    return h;
}

int put(char *str) {
    int n, h = hash(str);
    for (n = 0; n < HASHSIZE; n++) {
        int ix = (h + n) % HASHSIZE;
        if (hashTable[ix] == NULL) {
            hashTable[ix] = str;        
            return ix;                // 新規登録
        } else if (strcmp(hashTable[ix], str) == 0) {
            return ix;                // 登録済み
        }
    }
    printf("Error: Hash Table Full");
    return -1;
}

int contain(char *str) {
    int n, h = hash(str);
    for (n = 0; n < HASHSIZE; n++) {
        int ix = (h + n) % HASHSIZE;
        if (hashTable[ix] == NULL) {
            return -1;                // 登録なし
        } else if (strcmp(hashTable[ix], str) == 0) {
            return ix;                // 登録済み
        }
    }
    return -1;
}

int main() {
    int n;
    printf("HASHSIZE = %d\n", HASHSIZE);
    for (n = 0; n < sizeof(func)/sizeof(char*); n++) {
        put(func[n]);
    }
    for (n = 0; n < sizeof(check)/sizeof(char*); n++) {
        printf("\"%s\"は登録されて%s\n", 
                check[n], contain(check[n]) >= 0 ? "います" : "いません");
    }
}

実行結果を以下に示す。

HASHSIZE = 19
"strcmp"は登録されていません
"atof"は登録されています
"printf"は登録されていません
"free"は登録されています

文字列に日本語を含む場合には、int hash(unsigned char *key) とするか、 または h * 137 + key[n]h * 137 + (key[n] & 0xff) に変える。

数値のハッシュ法の例

地図システムではノード(折れ線データの頂点)数は膨大である。 7,000万程度のノードの id(8バイト) と整数化した経度(4バイト)と緯度(4バイト)の管理を HashMap で行うにはノートパソコンでは主メモリが足りない。そこで、次のようにした。

簡単なオープンアドレス方式とした。メモリ使用量削減から、ノード情報は class 定義はせず、 long[] nodes(ノードid)、int[] lons、int[] lats の三つの配列で管理する。

パフォーマンス上は全く問題がなかった。MAXSIZE は 100000000 でもいいであろうが、 メモリに余裕があるため、130000000 とした[2023.1.28]。

ハッシュ表に当たるのが long[] nodes である。int[] lons、lats には無駄があるが、 class を導入するよりはメモリ使用量は少なくて済む。

    static int MAXSIZE = 130000000;
    static long[] nodes = new long[MAXSIZE];    // node id
    static int[] lons = new int[MAXSIZE];
    static int[] lats = new int[MAXSIZE];

    static int hash(long id) {
        return (int)(Math.abs(id * 137) % MAXSIZE);
    }

    static int put(long id, int lon, int lat) {
        int n, h = hash(id);
        for (n = 0; n < MAXSIZE; n++) {
            int ix = (h + n) % MAXSIZE;
            if (nodes[ix] == 0) {
                nodes[ix] = id;
                lons[ix] = lon;
                lats[ix] = lat;        
                num_nodes++;
                if (num_nodes % 1000000 == 0) {
                    System.out.printf("%d万\r", num_nodes / 10000);
                }
                return ix;                // 新規登録
            } else if (nodes[ix] == id) {
                return ix;                // 登録済み
            }
        }
        System.out.println("Error: Hash Table Full");
        return -1;
    }

    static int get(long id) {
        int n, h = hash(id);
        for (n = 0; n < MAXSIZE; n++) {
            int ix = (h + n) % MAXSIZE;
            if (nodes[ix] == 0) {
                return -1;                // 登録なし
            } else if (nodes[ix] == id) {
                return ix;                // 登録済み
            }
        }
        return -1;  // 存在しない
    }

リファレンス

[1] hash ハッシュ
[2] ハッシュ法