ホーム » スタッフ (ページ 42)
「スタッフ」カテゴリーアーカイブ
文字列のハッシュ関数
文字列のハッシュ値
ここまでで説明した事例は、電話番号をキーとするものであり、余りを求めるだけといったような簡単な計算で、ハッシュ値が求められた。しかし、一般的には文字列といったような名前から、ハッシュ値が欲しいことが普通だろう。
ハッシュ値は、簡単な計算で、見た目デタラメな値が求まればいい。 (ただしく言えば、ハッシュ値の出現確率が極力一様であること)。一見規則性が解らない値として、文字であれば文字コードが考えられる。複数の文字で、これらの文字コードを加えるなどの計算をすれば、 偏りの少ない値を取り出すことができる。
int hash_func( char s[] ) { int sum = 0 ; for( int i = 0 ; s[i] != '¥0' ; i++ ) { sum = sum + s[i] ; } return sum % SIZE ; }
文字列順で異なる値となるように
前述のハッシュ関数は、”ABC”さんと”CBA”さんでは、同じハッシュ値が求まってしまう。文字列順で異なる値が求まるように改良してみる。
int hash_func( char s[] ) { int sum = 0 ; for( int i = 0 ; s[i] != '¥0' ; i++ ) { sum = sum*2 + s[i] ; // sum = (sum * 小さい素数 + s[i]) % 大きい素数 ; } return sum % SIZE ; }
上記のプログラムの、sum = sum*2 + s[i] では、2倍していった数を最後に SIZE で割っているだけなので、文字が長い場合文字コードの値の違いが sum の中に残らない場合も考えられる。こういった場合には、以下のような方法も考えられる。大きな素数で割ることで、余りの中に、元の数の値の違いの影響が残る。これは、疑似乱数生成での剰余法(or 線形合同法)の考え方を取り入れた方法ともいえる。
#define PRIME_B 大きな素数 #define PRIME_A 小さな素数 int hash_func( char s[] ) { int sum = 0 ; for( int i = 0 ; s[i] != '¥0' ; i++ ) { sum = (sum * PRIME_A + s[i]) % PRIME_B ; } return sum % SIZE ; }
データベースの物理設計
データベース後半課題
データベース後半の課題は「卒業研究の対象をデータベースとして設計」とする。
情報系の卒研テーマであれば、処理対象のデータの中にはデータベースで管理するのがふさわしい対象について設計せよ。実験系の卒研テーマであれば、実験結果の表をデータベースで管理するとした場合の設計を行うこと。どちらでもない卒研で、卒研のテーマの中にデータベース化すべき対象が無い場合は、身の回りの帳票(例えばコンビニのレシートなど)をデータベース化することを検討すること。
レポートで記載する内容は、以下の通りとする。
- 卒業研究におけるデータベース化する対象の説明
- データベースをトップダウン設計する際の
- 実体と関連を抽出するまでの説明
- 正規化を行う経過の説明
- 上記を踏まえたトップダウン設計でのER図
- データベースをボトムアップ設計する際の
- 対象とする帳票に相当するデータの一例と説明
- レベル分けや正規化を行う経過の説明
- 上記を踏まえたボトムアップ設計でのER図
- 考察
- トップダウン設計とボトムアップ設計に違いがあれば、設計の見直しの過程の説明
- 両設計方法から分かったこと
データベースの物理設計
データベースの物理的設計は、データベースの格納法法や管理方法を決定する。この際には、ディスク容量の見積もりやメモリ量の見積もりが重要となる。
ディスク容量の見積もり
データベースでは、B木(以降で解説予定)などが用いられることが1つのB木のノード(データブロック)の構造をおおまかに示す。各データブロックには、そのブロックを管理するためのページ制御の情報と、実データへのポインタとなるスロット情報と、実データからなる。
実データは、すべてのデータが固定長であれば、そのデータ長とブロック毎のデータ数にページ制御の容量を加えれば良い。しかし、データ長は可変であることが多い。この場合は、データの更新でデータ長が長くなると、その後ろのデータをずらす処理が頻発すると、データ管理の効率が悪い。
そこで、実データの間には、データ長が増えた時の空き領域を設けておく。この比率がPCTFREEと呼ばれ、この領域が埋まった時にのみデータをずらす処理を行う。
また、データベースへのデータの削除を行う場合、データが1つ消える度にデータブロックの構成を変化させると効率が悪く、通常はデータ削除の目印をつけるだけとすることが多い。データ削除で空きがふえた時だけ、データブロックの構成を変えたり、データ追加の際にデータを追加する。この比率は、PCTUSEDと呼ばれる。
このため、ハードディスク容量の見積もりでは、PCTFREE,PCTUSEDを考慮する必要がある。
一般的には、容量を減らす観点であれば、PCTFREEはなるべく小さく、PCTUSEDはなるべく大きい方が望ましいが、データの更新で追加・削除・修正が頻発するのであれば、PCTFREEはある程度大きく、PCTUSEDはある程度小さい方がよい。このため、PCTFREE+PCTUSED < 100 となるようにチューニングすることが多い。
また、実際のデータとは別に、データを高速に検索するためのインデックスファイルが作られるので、この容量も別途考慮が必要となる。
補足:残り予定:トランザクション処理, 内部構造, テスト前レポート課題
プログラムの処理時間の測り方
今回の課題レポートでは、テスト素点が良くレポート提出でも加点が少ないと思われる人でも、まじめに取り組んだレポート提出が多かった。この中で、興味深いレポートで、2分探索木の検索が偏っていたらO(N),バランスが良ければO(log N)の検証をしているものがあった。ただ、計測方法がちょっと残念だったので解説。
処理時間の計測方法
速度の検証をするにしてもデータ数も少なく、検索は一瞬で終わってしまうので、以下のような 1000 回ループで時間を測定していた。1000回といったループで計測するのは正しいアプローチ。
for( int i = 0 ; i < 1000 ; i++ ) { if ( find( top , key ) ) printf( "みつかった" ) ; else printf( "みつからない" ) ; }
ただ、この方法では、find() の処理時間以上に、printf() の処理時間の方が問題となる。
printf の処理時間
printf は、ただでさえも、第一引数の中にある “%” フォーマットの分析をするという面倒な処理をしているので、かなり複雑な処理をしている関数。メモリ容量の少ない組込み系のシステムを構築する時は、%フォーマットの分析のコードも大きくなるので、使うのを避けることも多い。
また、printf は、%フォーマットの解析に加え、出力バッファリングなどを経て、最終的に文字を標準出力に出力する。標準出力への出力が発生すると、OSのシステムコールを呼び出し、結果を表示するターミナルプログラムは、画面への文字の出力や、画面からはみ出た文字のスクロールアップなどの処理を行う。このため、find() 関数の処理時間以上に手間がかかっている。このため、精度の低い結果となってしまっている。
つまり、自分の関数の処理時間を計測したいなら、余計な時間がかかる printf などの入出力処理は時間計測に含めないこと。
time コマンド
プログラムの処理時間を測る場合には、測定対象のプログラムの処理時間の他にも、最近のOSではマルチタスクだからこそ、他の並列処理にとられている時間、OSでマルチタスクの切替に係る時間なども含まれてしまう。
このため Linux 環境でプロセスの処理時間を測定する場合には、time コマンドを用いる。
((( ちょっと時間のかかりそうな /usr/lib 配下のファイルの一覧出力 ))) $ find /usr/lib -type f -print (略) ((( time コマンドは、time の後ろに計測したい処理を書く ))) $ time find /usr/lib -type f -print (略) 0.00s user 0.33s system 26% cpu 1.242 total ((( 比較のために出力を /dev/null に捨てて実行 ))) $ time find /usr/lib -type f -print > /dev/null find /usr/lib -type f -print > /dev/null 0.00s user 0.25s system 87% cpu 0.287 total
この例では、find 自体の処理時間 user 時間 0秒、find が 出力命令のようなシステムコールを実行したことによる OS の処理時間 system 時間 0.33 秒、出力なども含めて見かけ上の本当の処理時間が 1.242 秒というのがわかる。
この結果を見ても、如何に出力処理が遅いのかが分かる。
だからこそ、自分のプログラムの処理時間として使う場合は、user 時間の部分を使うこと。
プロファイラによる解析
プログラムの処理が遅い場合の原因を究明する場合には、プロファイラというプログラムを用いることが多い。
例えば gcc などのコンパイラのためのプロファイラの gprof では、プログラムの処理に一定時間で割り込みをかけ、そのタイミング毎にどの関数の処理中だったのかを調べることで、全体の処理時間の何パーセントをその関数の処理をしていた…ということを計測できる。(統計的プロファイラ)
((( 測定対象のプログラムにプロファイラ用の情報を埋め込んでコンパイル ))) $ gcc -pg foobar.c ((( プログラムを実行すると gmon.out という統計情報が出力される ))) $ ./a.out ((( gprof で統計結果の確認 ))) $ gprof ./a.out gmon.out
といっても、割り込みをかけて計測しているので、プロファイラの結果は精度の高いデータとは言えない。
ハッシュ衝突の対策
前回の授業で説明したハッシュ法は、データから簡単な計算(ハッシュ関数)で求まるハッシュ値をデータの記憶場所とする。しかし、異なるデータでも同じハッシュ値が求まった場合、どうすれば良いか?
ハッシュ法を簡単なイメージで説明すると、100個の椅子(ハッシュ表)が用意されていて、1クラスの学生が自分の電話番号の末尾2桁の場所に座るようなもの。自分のイスに座ろうとしたら、同じハッシュ値の人が先に座っていたら、どこに座るべきだろうか?
オープンアドレス法
先の椅子取りゲームの例え話であれば、先に座っている人がいた場合、最も簡単な椅子に座る方法は、隣が空いているか確認して空いていたらそこに座ればいい。
これをプログラムにしてみると、以下のようになる。このハッシュ法は、求まったアドレスの場所にこだわらない方式でオープンアドレス法と呼ばれる。
// オープンアドレス法 // table[] は大域変数で0で初期化されているものとする。 // 配列に電話番号と名前を保存 void entry( int phone , name ) { int idx = hash_func( phone ) ; while( table[ idx ].phone != 0 ) idx = (idx + 1) % HASH_SIZE ; // ひとつ後ろの席 } // idx++ でないのは何故? table[ idx ].phone = phone ; strcpy( table[ idx ].name , name ) ; } // 電話番号から名前を調べる char* search( int phone ) { int idx = hash_func( phone ) ; while( table[ idx ].phone != 0 ) { if ( table[ idx ].phone == phone ) return table[ idx ].name ; idx = (idx + 1) % HASH_SIZE ; // ひとつ後ろの席 } // idx++ でないのは何故? return NULL ; // 見つからなかった }
注意:このプログラムは、ハッシュ表すべてにデータが埋まった場合、無限ループとなるので、実際にはもう少し改良が必要である。
この実装方法であれば、ハッシュ表にデータが少ない場合は、ハッシュ値を計算すれば終わり。よって、処理時間のオーダはO(1)となる。しかし、ハッシュ表がほぼ埋まっている状態だと、残りわずかな空き場所を探すようなもの。
チェイン法
前に述べたオープンアドレス法は、ハッシュ衝突が発生した場合、別のハッシュ値を求めそこに格納する。配列で実装した場合であれば、ハッシュ表のサイズ以上の データ件数を保存することはできない。
チェイン法は、同じハッシュ値のデータをグループ化して保存する方法。 同じハッシュ値のデータは、リスト構造とするのが一般的。ハッシュ値を求めたら、そのリスト構造の中からひとつづつ目的のデータを探す処理となる。
この処理にかかる時間は、データ件数が少なければ、O(1) となる。しかし、ハッシュ表のサイズよりかなり多いデータ件数が保存されているのであれば、ハッシュ表の先に平均「N/ハッシュ表サイズ」件のデータがリスト構造で並んでいることになるので、O(N) となってしまう。
#define SIZE 100 int hash_func( int ph ) { return ph % SIZE ; } struct PhoneNameList { int phone ; char name[ 20 ] ; struct PhoneNameList* next ; } ; struct PhoneNameList* hash[ SIZE ] ; // NULLで初期化 struct PhoneNameList* cons( int ph , char* nm , struct PhoneNameList* nx ) { struct PhoneNameList* ans ; ans = (struct PhoneNameList*)malloc( sizeof( struct PhoneNameList ) ) ; if ( ans != NULL ) { ans->phone = ph ; strcpy( ans->name , nm ) ; ans->next = nx ; } return ans ; } void entry( int phone , char* name ) { int idx = hash_func( phone ) ; hash[ idx ] = cons( phone , name , hash[ idx ] ) ; } char* search( int phone ) { int idx = hash_func( phone ) ; struct PhoneNameList* p ; for( p = hash[ idx ] ; p != NULL ; p = p->next ) { if ( p->phone == phone ) return p->name ; } return NULL ; }
データベースの設計と正規形
データベースの設計において、重要な正規形についての説明
正規形
データベースにおいて、様々な不整合を防ぐために正しい設計が必要であることを 改めて説明し、それには正規形としての条件を満たしている必要があることを説明する。
第一正規形は、すべての要素が原子値である条件を満たせばいい。 要素の中が複数の項目であったり表形式のデータがあると、 表構造のリレーショナルデータベースにはできない。
- 中央省庁のデータ表記を統一:河野太郎行政・規制改革担当相のTweet
データベースと直接関係しないけど、データは原子値じゃないと困るというお話。
キーの説明:超キー(スーパーキー)とは、データベースで1つのデータを 選び出すために必要なデータ項目であり、複数の項目で1データを指定 できる場合もある。
候補キーとは、必要最小限の項目となっているものを指す。 1項目が抜けても選別できなくなるようであれば、候補キーとは言わない。 主キーとは、候補キーのなかで管理の都合上便利なもの。
データ項目の値が決まると、他のデータ項目が自動的に決まるものは、 従属関係があるという。
![]() |
![]() |
第1正規化 | 第2正規化 |
第二正規形は、部分従属がなく、すべての非キーデータ項目が、候補キーに 完全従属する場合をいう。
- 完全従属とは、候補キーを構成する全てのデータ項目に、非キーデータ項目が従属していること。
- 部分従属とは、候補キーを構成するデータ項目の一部のデータ項目に、非キー項目が従属していること。
この例において、単価は商品が決まれば自動的に求まる情報。 (単価が日々変化することはないという条件で…) これは、部分従属となる。他に部分従属となっている属性は何か?
- 推移従属性とは、データ項目でA→B→Cと、次々と値が求められる関係を指す。
第三正規形とは、 候補キー以外の非キーデータ項目は、候補キーに完全従属し、 かつどの候補キーにも推移従属しない関係をいう。
![]() |
第3正規化 |
上記の例では、単価と個数が決まれば、金額が求まる推移従属の関係が含まれている。
おまけ:BC正規形,第4,5正規形
この他にも、 さらに非キーからキーに関数従属性がある場合にそれを取り除く、 ボイスコッド正規形(BC正規化)。 「対称性のある多値従属性(キーを決めると複数データが該当)」を分解して得られる第4正規形や、 「元になるテーブルの結合従属性を維持して分解することにより得られる第5正規形などがある。
トップダウン設計・ボトムアップ設計
データベースの設計にあたって、実際の設計手順の説明を行う。
トップダウン設計では、対象業務を記述し、その中から名詞となっている実体を抽出する。 さらに動詞や形容詞のように記載されている関連を抽出する。 抽出した実体・関連では、あいまいであったり冗長であったりするので、整理したうえで、 その実体・関連をER図に表す。
ボトムアップ設計では、対象業務で実際に使われている入力帳票や結果の出力などを 見ながら、第1正規形を満たすように表を作っていく作業からおこなう。
トップダウン設計やボトムアップ設計で、 ER図や第一正規形を満たすような表が出来上がったら、 その属性の中で従属性を確認しながら、第2正規形・第3正規形へと整理していく。
ハッシュ法(導入)
前半は中間試験の返却と解説を行う。後半は次のテーマのハッシュ法の導入話。
ここまでの授業では、配列(データ検索は、登録順保存ならO(N)、2分探索ならO(log N)となる、2分探索ができるのは配列がランダムアクセスができるからこそ)、単純リスト(データ検索(シーケンシャルアクセスしかできないのでO(N)となる)、2分探索木( O(log N) ) といった手法を説明してきた。しかし、もっと高速なデータ検索はできないのであろうか?
究極のシンプルなやり方(メモリの無駄)
最も簡単なアルゴリズムは、電話番号から名前を求めるようなデータベースであれば、電話番号自身を配列添え字番号とする方法がある。しかしながら、この方法は大量のメモリを必要とする。
// メモリ無駄遣いな超高速方法 struct PhoneName { int phone ; char name[ 20 ] ; } ; // 電話番号は6桁とする。 struct PhoneName table[ 1000000 ] ; // 携帯電話番号ならどーなる!?!? // 配列に電話番号と名前を保存 void entry( int phone , char* name ) { table[ phone ].phone = phone ; strcpy( table[ phone ].name , name ) ; } // 電話番号から名前を調べる char* search( int phone ) { return table[ phone ].name ; }
しかし、50人程度のデータであれば、電話番号の末尾2桁を取り出した場合、同じ数値の人がいることは少ないであろう。であれば、電話番号の末尾2桁の値を配列の添え字番号として、データを保存すれば、配列サイズは100件となり、メモリの無駄を減らすことができる。
ハッシュ法
先に述べたように、データの一部を取り出して、それを配列の添え字番号として保存することで、高速にデータを読み書きできるようにするアルゴリズムはハッシュ法と呼ばれる。データを格納する表をハッシュ表、データの一部を取り出した添え字番号はハッシュ値、ハッシュ値を得るための関数がハッシュ関数と呼ばれる。
// ハッシュ衝突を考えないハッシュ法 #define HASH_SIZE 100 ; struct PhoneName table[ HASH_SIZE ] ; // ハッシュ関数 int hash_func( int phone ) { return phone % HASH_SIZE ; } // 配列に電話番号と名前を保存 void entry( int phone , name ) { int idx = hash_func( phone ) ; table[ idx ].phone = phone ; strcpy( table[ idx ].name , name ) ; } // 電話番号から名前を調べる char* search( int phone ) { int idx = hash_func( phone ) ; return table[ idx ].name ; }
ただし、上記のプログラムでは、電話番号の末尾2桁が偶然他の人と同じになることを考慮していない。
例えば、データ件数が100件あれば、同じ値の人も出てくるであろう。このように、異なるデータなのに同じハッシュ値が求まることを、ハッシュ衝突と呼ぶ。
たとえ話で言うなら、100個の椅子が連番付きで並んでいて、自分の電話番号末尾2桁の場所に座ろうとしたら、先に座っている人がいるような状態である。このような状態で、あなたなら何処に座るだろうか?
ハッシュ関数に求められる特性
ハッシュ関数は、できる限り同じような値が求まるものは、ハッシュ衝突が多発するので、避けなければならない。例えば、6桁の電話番号の先頭2桁であれば、電話番号の局番であり、同じ学校の人でデータを覚えたら、同じ地域の人でハッシュ衝突が発生してしまう。また、ハッシュ値を計算するのに、配列の空き場所を一つ一つ探すような方式では、データ件数に比例した時間がかかり、高速なアルゴリズムでなくなってしまう。このことから、ハッシュ関数には以下のような特徴が必要となる。
- 同じハッシュ値が発生しづらい(一見してデタラメのように見える値)
- 簡単な計算で求まること。
- 同じデータに対し常に、同じハッシュ値が求まること。
データベーステスト前授業
本来は、データベースの正規形に関連する話題を説明する予定であったが、後期の中間試験以降の範囲なので、新しい内容の説明は止めることとした。代わりに、過去のテスト問題の事例を示し、レポート課題を作成する時間とした。
Linux演習
B木とデータベース
2分探索木の考え方を拡張したもので、B木がある。
B木の構造
2分木では、データの増減で木の組換えの発生頻度が高い。そこで、1つのノード内に複数のデータを一定数覚える方法をとる。B木では、位数=Nに対し、最大2N個のデータ d0..d2N-1 と、2N+1本のポインタ p0..p2N から構成される。piの先には、di-1< x < di を満たすデータが入った B木のノードを配置する。ただし、データの充填率を下げないようにするため、データは最小でもN個、最大で2N個を保存する。下図は位数2のB木の例を示す。
B木からデータの検索
データを探す場合は、ノード内のデータ di の中から探し、見つからない場合は、ポインタの先のデータを探す。位数がある程度大きい場合、ノード内の検索は2分探索法が使用できる。また、1つのノード内の検索が終われば、探索するデータ件数は、1/N〜1/2Nとなることから、指数的に対象件数が減っていく。よって、検索時間のオーダは、O(logN) となる。
B木へのデータの追加
B木にデータを追加する場合は、ノード内に空きがあれば、単純にデータの追加を行う。ノード内のデータが2N個を越える場合は、以下のような処理を行う。
ノード内のデータと追加データを並べ、その中央値を選ぶ。この中央値より大きいデータは、新たにつくられたノードに移す。中央値のデータは上のノードに追加処理を行う。このような方法を取ることで、2分木のような木の偏りが作られにくい構造となるようにする。
データを削除する場合も同様に、データ件数がN個を下回る場合は、隣接するノードからデータを取ってくることで、N個を下回らないようにする。
B木とデータベース
このB木の構造は、一般的にデータベースのデータを保存するために広く利用されている。
データベースシステムでは、データを効率よく保存するだけでなく、データの一貫性が保たれるように作られている。
例えば、データベースのシステムが途中でクラッシュした場合でも、データ更新履歴の情報を元にデータを元に戻し、データを再投入して復旧できなければならない。データを複数の所からアクセスした場合に、その順序から変な値にならないように、排他制御も行ってくれる。
データベースで最も使われているシステムは、データすべてを表形式で扱うリレーショナル・データベースである。
((リレーショナル・データベースの例)) STUDENT[] RESULT[] ID | name | grade | course ID | subject | point -----+----------+-------+-------- -----+---------+------- 1001 | t-saitoh | 5 | EI 1001 | math | 83 1002 | sakamoto | 4 | E 1001 | english | 65 1003 | aoyama | 4 | EI 1002 | english | 90 外部キー ((SQLの例 2つの表の串刺し)) -- 60点以上の学生名,科目名,点数を出力 -- select STUDENT.name, RESULT.subject, RESULT.point --射影-- from STUDENT , RESULT --結合-- where STUDENT.ID == RESULT.ID -- 串刺し -- --選択-- and RESULT.point >= 60 ; ((上記SQLをC言語で書いた場合)) for( st = 0 ; st < 3 ; st++ ) // 結合(from) for( re = 0 ; re < 3 ; re++ ) if ( student[ st ].ID == result[ re ].ID // 選択(where) && result[ re ].point >= 60 ) printf( "%s %s %d" , // 射影(select) student[ st ].name , result[ re ].subject , result[ re ].point ) ;
B+木
データベースの処理では、目的のデータを O(log N) で見つける以外にも、全データに対する処理も重要である。この場合、全てのデータに対する処理では、単純なB木では再帰呼び出しが必要となる。しかし、他の表でも再帰処理を伴うと、プログラムは複雑になってしまう。
そこで、B木のデータを横方向に並べて処理を行う場合に、その処理が簡単になるように B+木が用いられる。
この方法では、末端のノードは、隣接するノードへのポインタを持つ。下図で示すB+木では、青で示す検索用のB木の部分と、赤で示す順次処理を行うためのシーケンスセットの部分から構成される。