索引ファイルを作る

C言語で開発を行っている時、簡単な索引ファイルを作りたいことがあります。 例えば、電話番号文字列から、レコード番号を求めたりする場合です。 今のトレンドでは、「SQLにクエリー投げて・・・」ということになるのでしょうが、 そこそこの速度がだせて、簡単に実装できるものを考えます。


キー検索とは?

 2分岐検索や、バランス木などについては、多くのアルゴリズム本がありますので、そちらを参照してください。 ここでは、問題の解き方を掘り下げながら、新しいファイルフォーマットを考えます。

 データがレコード形式で保存されていて、ある文字列をキーに、レコードを探したいというのは、よくある需要です。 他にも、特定条件に一致するレコードをを全部抽出したいという需要もありますが、ここでは、キー検索にまとを絞って考えます。
 この問題を単純化すると、ある文字列と特定の数値を登録しておいて、その文字列から数値を求めるということになります。 それほど難しくなさそうなのですが、挿入、削除しても、高速に調べるようにするには、ちょっとした工夫が必要です。

簡単なアルゴリズムを考える

 今回の目的は、文字列から数値を求めることです。 すると当然、数値をファイルのどこかに記録しておく必要があります。 そして、その「どこか」を高速に検索できればいいわけです。
これを簡単にするために、文字列そのもので分岐していく場所を決定することを考えます。 1バイトは8ビットですが、これを上位4ビットと下位4ビットに分けます。 すると、"0123"という文字列は、0x3,0x0,0x3,0x1,0x3,0x2,0x3,0x3という4ビットのデータ8個に分解できます。 この1つ1つで、分岐テーブルを作り、データを検索します。
 4ビット単位なんて時代錯誤のように見えるかもしれませんが、これを8ビット単位で行うと、 分岐テーブルは256組必要になり、1組のデータが32ビットですので、1024バイト必要になります。 これが文字列の各部分で確保されていくことになり、元データファイルに比べて、かなり大きいファイルになります。 実は、4ビットで行ってもファイルサイズは、かなり大きくなります。 でも、8ビットで行うよりはマシであろうということで、今回は4ビットで行うことにします。

#define U32 unsigned int
#define M_BR        0xfffffffc      // 分岐テーブルマスク
#define M_ST        0x00000003      // ステータスマスク
#define S_NL        0               // データなし
#define S_BR        1               // 本情報は分岐テーブルへのオフセット
#define S_DT        2               // 本情報はデータへのオフセット
#define S_RS        3               // (予約)
typedef struct _abr // 分岐テーブル構造体
    {
    U32 up;         // 上位へのリンク
    U32 me;         // 自分自身へのリンク
    U32 br[16];     // 16分岐テーブル(4bit分)
    } ABR;

 こんな構造体を作ってみました。U32は、符号なし32ビットの整数です。 他のデータを含め、データは4バイトアラインで登録するものとします。 構造体の各メンバーは、ファイル上のオフセットを表しています(結果4ギガバイトまでのファイルしか扱えません)
 すると、ファイルオフセットは、4バイトアラインされているため、下2ビットは必ず0になります。 この部分に、「分岐先の情報」を詰め込みます。 詰め込みを行うため、分岐先データオフセットを取り出すマスク(M_BR)と 分岐先情報を取り出すマスク(M_ST)を用意します。 詰め込むといっても、分岐先データがない(=0)分岐した先も、分岐テーブル(=1)分岐先はデータ(=2)という3つの情報だけです。(なので2ビットで入ります)

typedef struct _det // データエントリ構造体
    {
    U32 len;        // データ部長さ
    int sval;       // 戻り値
    } DET;

 分岐先がデータであった場合、上のような構造体をファイル上に設け、この部分に続いて、データそのものを4バイト単位に拡張して登録することにします。この構造体にあるsvalこそ、今回の答え(求めるべき値)を格納しておく場所です。

この構造(フォーマット)のよいところ悪いところ

 このデータそのものを分岐先のアドレスにしようというアイデアが、よそにあるのかはわかりません。 データはそれぞれ内容が違うので、この分岐による木は、ある程度の形に成長するはずです。 もちろん、電話番号であれば、先頭はほとんど"0"ですので、最初の2節くらいは、1本しか分岐しないことになり、 効率が悪い部分がでるのも事実です。

 ただ、全体の中央付近など、挿入するデータの大小関係について、一定のルールでバランスする木は、 木のバランスを維持するための動作を、挿入時点で行うものがほとんどであり、 これが、データ登録時の時間的コストを押し上げています。

 今回の「木」はバランス調整を行いません。 行わなくても、「そこそこ」維持されるハズですし、 そもそもバランスとれるような(調整可能な)フォーマットになっていません。
 バランスをとることなく、データが冗長になったとしても、ある程度の検索速度がでるのであれば、 プログラムコードは、小さくなって、見やすくなるハズです。

キー検索open/closeの実装

typedef struct _atree
    {
    int fd;         // ファイルディスクプリタ
    ABR ab;         // 最終参照ABR
    ABR ab2;        // 新規作成用ABR
    int pt;         // 最終参照ABR内分岐P
    DET de;         // データエントリ実体
    unsigned char bx[AR_MAXLEN];    // 検索文字列の比較用バッファ
    } ATREE;

 まず、このライブラリ全体を通して使う構造体を定義します。 1つの索引ファイルを扱うためには、この構造体が必要です。
 fdは、索引ファイルをopenしたファイルディスクプリタで、 これを使ってread/writeします。
 前述のABR(ブランチ用のテーブル)は、新規作成時(分割時)には、 2つ必要ですので、これも確保しておきます。
 ptは、ABR内のポインタというか、4ビットごと(16通りごと) で検索する際のポインタです。
 deは、データエントリ構造体で、読み書き用に1つバッファとして確保しています。
 bxは、検索文字列そのものの比較用バッファです。

//
//  検索ファイルをオープンする
//
int ar_open(ATREE* a, char *filename)
    {
    int i;
    char buf[256];

    memset(a,0x00,sizeof(ATREE));   // 構造体をクリア
    a->fd = open(filename,O_RDWR | O_CREAT,0600);
    if (a->fd < 0) return ARE_OPEN;
    i = lseek(a->fd,0,SEEK_END);
    if (i == 0)     // 作成直後
        {
        memset(buf,0x00,256);
        strcpy(buf,AR_SIGN);        // 先頭256バイトに適当なサイン
        write(a->fd,&buf,256);
        memset(&(a->ab),0x00,sizeof(ABR));  // 分岐の大本を作成
        a->ab.me = 256;             // 自らのoffsetは256
        write(a->fd,&(a->ab),sizeof(ABR));
        }
    return 0;
    }


//
//  検索ファイルをクローズする
//
int ar_close(ATREE* a)
    {
    if (a->fd) close(a->fd);
    a->fd = 0;
    return 0;
    }

 まず、利用プログラム側で、ATREE構造体を確保し、そのアドレスと、索引ファイル名を指定して、 ar_open()を呼び出します。使用が終われば、ar_close()を呼び出します。
 ファイル先頭256バイトは、これがAtreeファイルであることを確認するために、サインを記述していますが、 チェックしていません。チェックが必要な場合、lseekの戻り値が0のif文にelseをつけて、 先頭から256バイト読み込んで検証してください。
 新規作成の場合、ファイルサイズが0となりますので、これを検出して、 先頭256バイトをサイン用に確保した直後に、検索開始ポイントとなる、 分岐テーブルを1つ確保します。 まだ、索引データは何も登録されていないので、全部0にしておき、 meというメンバーに、自らのファイルオフセットである256をセットします。

キーデータ登録部分の実装

//
//  登録する
//  str:検索すべき文字列
//  sval:登録すべき番号(0以上を渡す)
//  戻り値:0=成功,0未満=なんらかのエラー
//
int ar_regist(ATREE* a,unsigned char* str,int sval)
    {
    int btp,ofs,len,i,dst,st,pt2,dfs=0;

    ofs = PROOT;                // 最初の分岐テーブル位置(=256)を代入しておく
    len = strlen(str) * 2 + 2;  // 検索文字列の長さ (\0込み,4bit単位)
    a->bx[0] = '\0';            // データ実体バッファをクリア

    for(btp = 0;btp < len;btp++)
        {
        a->pt = str[btp/2];         // 今回の4ビットを取り出す
        if ((btp%2) == 0) a->pt /= 16;	// バイト内前半は上位4ビット
        a->pt &= 0x0f;					// 4ビットでマスク
        lseek(a->fd,ofs,SEEK_SET);                  // offset位置にシークして
        st = read(a->fd,&(a->ab),sizeof(ABR));      // ABR構造体を読み込む
        if (st != sizeof(ABR)) return ARE_BROKEN;   // 読めない=壊れている

        dst = a->ab.br[a->pt] & M_ST;   // ステータスを取り出す
        ofs = a->ab.br[a->pt] & M_BR;   // オフセットを取り出す

        if (dst == S_NL)                // データが存在しないのでエントリ登録
            {
            a->de.len = (strlen(str) + 4) & M_BR;   // \0込み4バイトアライン
            if (a->de.len >= AR_MAXLEN) return ARE_TOOLONG;
            a->de.sval = sval;                      // sval登録
            memset(a->bx,0x00,a->de.len);           // データ実体バッファクリア
            strcpy(a->bx,str);                      // 登録文字列をコピー
            a->ab.br[a->pt] = lseek(a->fd,0,SEEK_END) | S_DT;   // 分岐テーブル更新
            write(a->fd,&(a->de),sizeof(DET));      // DET書き込み
            write(a->fd,&(a->bx),a->de.len);        // データ実体書き込み
            lseek(a->fd,a->ab.me,SEEK_SET);         // 分岐テーブル自身に移動
            write(a->fd,&(a->ab),sizeof(ABR));      // 分岐テーブルを更新書き込み
            return 0;
            }

        if (dst == S_DT)                // データ一致 or データ衝突
            {
            // 衝突したデータとDETを読み込む
            if (a->bx[0] == '\0')   // 衝突は1候補と確定しているので、読み込んでない場合だけ
                {
                dfs = ofs;                      // DETのオフセットを控えておく
                lseek(a->fd,ofs,SEEK_SET);                      // offset位置にシークして
                st = read(a->fd,&(a->de),sizeof(DET));          // DET構造体を読み込む
                if (st != sizeof(DET)) return ARE_BROKEN+1;       // DETが読めなかった
                if (a->de.len >= AR_MAXLEN) return ARE_BROKEN+2;  // 登録されている長さがおかしい
                st = read(a->fd,&(a->bx),a->de.len);            // データ実体を読む
                }

            // データは完全に一致した = sval更新して終了
            if (btp+1 == len)
                {
                a->de.sval = sval;
                lseek(a->fd,dfs,SEEK_SET);
                write(a->fd,&(a->de),sizeof(DET));
                return 0;
                }

            // まだ完全に一致していないデータが衝突したので、新しい分岐テーブルを作成する
            memset(&(a->ab2),0x00,sizeof(ABR));     // ab2をクリア
            a->ab2.up = a->ab.me;       // 新しい分岐テーブルの親は今読んだテーブル
            a->ab2.me = lseek(a->fd,0,SEEK_END);    // 新しい分岐テーブルはファイル終端
            pt2 = a->bx[(btp+1)/2]; 	            // 衝突したデータの分岐先を計算する
            if (((btp+1)%2) == 0) pt2 /= 16;		// 新しい分岐データ登録位置を求める
            pt2 &= 0x0f;
            a->ab2.br[pt2] = a->ab.br[a->pt];       // 新しい分岐データを登録
            st = write(a->fd,&(a->ab2),sizeof(ABR));    // 書き込み

            // 元の分岐テーブルを修正する
            a->ab.br[a->pt] = S_BR | a->ab2.me;     // 新分岐テーブルを指すようにする
            lseek(a->fd,a->ab.me,SEEK_SET);         // 自分の位置にシークして
            write(a->fd,&(a->ab),sizeof(ABR));      // ABR構造体を更新する
            ofs = a->ab.br[a->pt] & M_BR;           // 次回に備えてオフセットを取り出す
            }
        }
    return ARE_BROKEN+4;
    }

 さて、いよいよデータ登録です。 索引ファイルにデータを登録する処理としては、短い部類だとは思うのですが、 見通しは悪いですね。少しづつ解説します。

    ofs = PROOT;                // 最初の分岐テーブル位置(=256)を代入しておく
    len = strlen(str) * 2 + 2;  // 検索文字列の長さ (\0込み,4bit単位)
    a->bx[0] = '\0';            // データ実体バッファをクリア

 この部分で、検索に入る前段階の処理をしています。 変数ofsには、次に読み込むべき分岐テーブル(またはデータヘッダ)のファイル上オフセットが入ってることになっていますので、最初の分岐テーブル(検索対象データ1バイト目の上位4ビットに対応)のアドレスである256が代入されています。
 また、4ビットごとに検索しますので、検索ループ回数は文字列長さの2倍(文字列長さには、文字列終端の0x00が入っていませんので、その分2を足しています)そしてデータ実体バッファをクリアして準備完了です。 (書きかけ)

キーデータ検索部分の実装

//
//  検索する
//  str:検索すべき文字列
//  戻り値:0未満=何らかのエラー,0以上=登録番号
//
int ar_search(ATREE* a,unsigned char* str)
    {
    int btp,ofs,len,i,dst,st;

    ofs = PROOT;                // 最初の分岐テーブル位置を代入しておく
    len = strlen(str) * 2 + 2;  // 検索文字列の長さ (\0込み,4bit単位)

    for(btp = 0;btp < len;btp++)
        {
        a->pt = str[btp/2];          // 今回の4ビットを取り出す
        if ((btp%2) ==0) a->pt /= 16;    // 上位4ビットが先
        a->pt &= 0x0f;

        lseek(a->fd,ofs,SEEK_SET);                   // offset位置にシークして
        st = read(a->fd,&(a->ab),sizeof(ABR));        // ABR構造体を読み込む
        if (st != sizeof(ABR)) return ARE_BROKEN;   // 読めない=壊れている

        dst = a->ab.br[a->pt] & M_ST; // ステータスを取り出す
        ofs = a->ab.br[a->pt] & M_BR; // オフセットを取り出す

        if (dst == S_NL) return ARE_NOTFOUND;       // データが存在しないので未発見確定
        if (ofs < PROOT) return ARE_BROKEN;          // 壊れている
        if (dst == S_DT)            // データ候補を発見した
            {
            lseek(a->fd,ofs,SEEK_SET);                       // offset位置にシークして
            st = read(a->fd,&(a->de),sizeof(DET));            // DET構造体を読み込む
            if (st != sizeof(DET)) return ARE_BROKEN;       // DETが読めなかった
            if (a->de.len >= AR_MAXLEN) return ARE_BROKEN;    // 登録されている長さがおかしい
            st = read(a->fd,&(a->bx),a->de.len);           // データ実体を読む
            if (st != (int)a->de.len) return ARE_BROKEN;         // データが読めなかった
            if (strcmp(a->bx,str) != 0) return ARE_NOTFOUND; // データ不一致=未発見
            return a->de.sval;                               // 発見したので、svalを返す
            }
        }
    return ARE_BROKEN;
    }

 検索は登録に比べれば簡単です。(書きかけ)


無作為研究所トップページに戻る

copyright(c)2007 by MUSAKUI-LABO