440 likes | 561 Views
基礎的なデータ構造. ・ スタックとキュー ・ リスト ・ バケツとハッシュ. データを記憶する. ・ コンピュータの基礎的な操作の1つは、データ(情報)を記憶すること ・ しかし、どのように記憶するかで、利用効率が変わる 記憶させる手間、検索する手間... - 来たデータを本に 1 ページ目から順に書き込んでいく - 棚を作って整理する ・ 使用用途によって、記憶の仕方(データの構造)を工夫する必要がある. データを記憶する記憶の仕方を、データ構造という. コンピュータの記憶の仕方. ・ コンピュータの記憶領域は、メモリ。メモリ 1 単位に値が1つ入る
E N D
基礎的なデータ構造 ・ スタックとキュー ・ リスト ・ バケツとハッシュ
データを記憶する ・コンピュータの基礎的な操作の1つは、データ(情報)を記憶すること ・しかし、どのように記憶するかで、利用効率が変わる 記憶させる手間、検索する手間... -来たデータを本に1ページ目から順に書き込んでいく -棚を作って整理する ・使用用途によって、記憶の仕方(データの構造)を工夫する必要がある データを記憶する記憶の仕方を、データ構造という
コンピュータの記憶の仕方 ・コンピュータの記憶領域は、メモリ。メモリ1単位に値が1つ入る -来たデータを本に1ページ目から順に書き込んでいく メモリを配列で確保して、来たデータを、添え字の小さいところから順に書き込んでいく。スタック、キューなど -棚を作って整理する リスト、ヒープ、2分木、バケツ、ハッシュなど どのようなデータ構造があるのか、順に見ていこう
配列による記憶 ・問題です。配列にデータを記憶させたいとします。さて、ここに、データ(数字だと思っていいでしょう)が1つずつやってきます。さて、どうやって記憶しますか? 最後に記録したところの次に書き足せばいいんじゃない? その通り! ・ ただし、メモリには「書き込まれていない」という状態がないので、どこが最後かわからない ・ たとえあっても、メモリ全体を見渡せるわけではないので、「書き込まれたところの最後」を見つけるのは大変 ・ なので、「書き込んだ最後の場所」を記憶する変数を使う
スタック ・ 例を見てみましょう 値 値 配列 カウンタ 0 1 2 ・ このように、「配列」と「最後に書いた場所のカウンタ」からなるデータの構造を スタック という (カウンタのことを、スタックポインタ といいます)
値の消去 ・今度は、値を記憶するだけでなく、読み出し、および、読み出したものを消去する方法も考えましょう - どれでもいいから1個読み出して消したい 最後に記録したところを読み出して、カウンタを1減らす - ●番目の値を消したい ●番目に最後の値を移して、カウンタを1減らす - ▲という値を持つものを消したい スキャンして見つけないといけない... カウンタ 5 配列 値 値 値 値 値
スタックの関数 ・スタックを実現するには、以下のような関数を作ればよい ・使うときは、スタックと記憶する値をセットで渡して呼び出す int STACK_push ( STACK *S, int a){ if ( S->t == S->max ) return (1); // overflow error S->h[S->t] = a; S->t ++; return (0); } int STACK_pop ( STACK *S, int *a){ if ( S->t == 0 ) return (1); // underflow error S->t --; *a = S->h[S->t]; return (0); } typedef struct { int *h;// array for data int end; // size of array int t; // counter } STACK t end h[] 値 値 値 値 値
スタックの利用例 ・文字列を読み込んで、逆向きにする ABCDEFGH ・ワープロの undo 機能 end h[]
~余談~:あふれないスタック? ・スタックは、値をたくさん書き込むと、配列からあふれてしまう。そりゃそうだ。 ・しかし、あらかじめ書き込みの上限がわからないこともある。 (例えば、数字がたくさんあるファイルを読んで、スタックにためる、といった場合。最初にスキャンすればわかるけど…) ・あふれたら、新しく大きな配列を取り直して、内容をコピーする、というようにすれば、あふれても対処できる ・しかし、サイズを1つずつ大きくしたのでは、毎回あふれてコピーすることになり、無駄が多い
~余談~:あふれないスタック? (2) ・配列を取り直す際には、例えば、現在のサイズの2倍の大きさの配列を取り直すようにする 一度あふれたあとは、同時に使われているメモリの大きさは、常に記憶している要素の数の3倍で抑えられる ・コピーにかかる手間の総量も、現在の配列の大きさの2倍で抑えられる 計算量の意味で、損失はない
~余談~:あふれないスタック? (3) ・コードを書くと、このようになる void STACK_push ( STACK *S, int a){ if ( S->t == S->max ){ // overflow error int i, *h = malloc (sizeof(int)*max*2 ); // using realloc is easy for ( i=0 ; i<S->t ; i++ ) h[i] = S->h[i]; free ( S->h ); S->h = h; } S->h[S->t] = a; S->t ++; }
F I L O ・ どれでもいいから1個読み出して消したい 例えば、ユーザーが指定した点に★マークを書いて、消しゴムボタンを押したときに全部消す、というようなときに使う(場所を記憶する) - 最後に書き込んだものが、最初に読み出されることになる - このような、データの読み出され方を FILO(First In Last Out)という カウンタ 5 値 値 値 値 値 配列
キュー、FIFO ・ 読み出し&消去をするときに、「最初に書き込んだものが最初に出てくる」ようにしたい (FIFO、 First In First Outという) 例えば、ユーザーが指定した点に★マークを書いて、消しゴムボタンを押したときに、最初のほうから消していく、というようなときに使う(場所を記憶する) 窓口サービスなどは、皆このタイプ ・ このようなデータ構造をキューという - 最後に書き込んだものが、最後に読み出されることになる カウンタ 5 値 値 値 値 値 配列
キューのカウンタ ・ キューを実現するには、「どこが最初に書き込まれたか」を覚えておく必要がある つまり、書き込む場所と、読み込む場所を覚えておくため、2つカウンタが必要 読み出す場所を、先頭という意味で head、 書き込む場所を、最後と言う意味で tailとよぶ head 2 tail 7 値 値 値 値 値 配列
あふれたら ・ スタックは、配列の大きさ以上の書き込みをするとあふれる ・ キューは、記憶している数が、配列の大きさ以上でなくてもあふれる あふれたときは、tailを配列の先頭に戻す headも、最後まで来たら先頭に戻す ・ 本当にあふれるのは、taillが headを追い越すとき head 5 tail 10 値 値 値 値 値 配列 0
値 値 値 値 値 値 値 値 値 値 追い抜きの調整 ・tailが head を追い抜くときは、実は調整が必要 ・ 配列の数と同じだけ書き込むと、 tailは headにおいつく つまり、両者は等しくなる これは、何も書き込んでいないときと同じ 両者を区別できない! ・ 解決策は - 両者を区別するためのフラグを用意する - (配列の大きさ-1)までしか書き込まない head 5 tail 5 配列
キューの関数 ・スタックを実現するには、以下のような関数を作ればよい ・使うときは、スタックと記憶する値をセットで渡して呼び出す int QUEUE_ins ( QUEUE *Q, int a){ if (( Q->t +1 ) % Q->end == Q->s ) return (1); // overflow error Q->h[Q->t] = a; Q->t = ( Q->t +1 ) % Q->end; return (0); } int QUEUE_ext ( QUEUE *S, int *a){ if ( Q->s == Q->t ) return (1); // underflow error *a = Q->h[Q->s]; Q->s = ( Q->s +1 ) % Q->end; return (0); } typedef struct { int *h;// array for data int end; // size of array int s; // counter for head int t; // counter for tail } QUEUE t s end h[] 値 値 値
キューの利用例 ・1つずつ数を入力し、ときどき5個ずつ書き出す ・マウスカーソルの軌跡を描画する (マウスの座標を連続的にキューに蓄え、1秒間に30コマとか、ある程度時間が経過したものから消す) end h[]
リスト: 順番を守って挿入削除 ・ 配列は簡単で便利だが、「順番を維持して挿入/削除」をするときに弱みが出る ・ 他の操作の便利さを犠牲にしてもいいから、この点を何とかできないだろうか??? ランダムアクセス性(k番目の要素へのアクセスが一手でできる)を犠牲にしてもよいとする - 窓口サービスで、割り込みを許す、あるいは途中のキャンセルを許す場合 - 文章の編集(段落単位とか)をする場合。途中に絵を挿入するたびに、全部移動させたくない
アイディア:鎖と同じ構造を ・ 鎖は挿入削除が簡単。でも、k 番目を見つけるのは面倒 この構造をまねよう ・ 鎖の構造は、隣との隣接関係が確保されていて、場所は自由。場所が自由なのはともかくとして、隣接関係を記録することにしよう ・ 記憶する場所それぞれ(セルとよぶ)に、お隣さん(両隣)の情報を記録。つまり、各セルは3つの値からなる - 記憶するもの - 前のセル(場所か名前、つまりポインタか添え字) - 後ろのセル(場所か名前、つまりポインタか添え字) 1 5 7 3
挿入削除の戦略 ・ 鎖をつけかえるときは何をするか? 入れるところ、抜くものの周り、の隣接構造を切る 要素に対して、「隣は何か」を変える ・ 要素をある場所に挿入するときは、挿入する場所の前後の要素の隣接関係を切り、それを入れる ・ 要素を削除するときは、その要素の前後の要素を直結する ・ 両方とも順番を保ったまま、定数手でできる 1 5 7 3
ポインタを利用した構造体 ・ このような構造体を定義して、新しく記憶するものが来るたびに、新しくメモリを確保するようにする 配列の大きさのような、上限の制約がなくなる 注意: Cで書くときは、LISTの定義 にLISTを使うので、下記のように する必要あり ・ リストの最初のセル(head という) と最後のセル(tailという)を覚えて おく必要がある (LISTで覚えてもいい) typedef struct { LIST *prv; // pointer LIST *nxt; // pointer int h; // value } LIST typedef struct _LIST_ { struct _LIST_ *prv; // pointer struct _LIST_ *nxt; // pointer int h; // value } LIST
コード(初期化) ・初期化では、LIST 構造を1つ用意して、前と次を自分にして、空のリストを表現 int LIST_init ( LIST *L ){ L->prv = L; L->nxt = L; } ・セルを挿入すると、●の次と前がそれぞれリストの headと tailになる ● ● 1 5 7 3
挿入 ・挿入は、入れる前のセル(あるいは後ろのセル)を指定して行う。前と後ろのポインタを付け替える int LIST_ins ( LIST *l, LIST *p ){ p->nxt->prv = l; l->nxt = p->nxt; p->nxt = l; l->prv = p; } ・つぎ変えの順番を変えると、正常につぎかえられなくなる ● 1 5 7 3
削除 ・削除は、入れる前のセル(あるいは後ろのセル)を指定して行う。前と後ろのポインタを付け替える int LIST_del ( LIST *l ){ l->nxt->prv = l->prv; l->prv->nxt = l->nxt; } ・l のポインタはいじらなくて良い (消したときの情報が残っているので、復帰するときに使える) ● 1 5 7 3
普通の教科書では ・ 一般に、教科書では、リストの両端は NULL のポインタを指すことにする 次/前を指すポインタが NULL なら、そこは端である、と判定する ・ 理論的にきれいだが、プログラミングのときは厄介 隣がNULLのとき、例外処理をしなければいけない NULLの前に挿入、ということはできないので、挿入を2通り(○○の前に入れる、○○の後に入れる、を用意して、入れる場所によって使い分けなければいけない リスト 1 5 7 3
リストをたどるループ ・リストをたどりたいときは、●から始めて、後ろへ1つずつポインタをたどり、●まで戻ったら終了する LIST *p; int e; for ( p=●->nxt ; p!=● ; p=p->nxt ){ e = p->h; … } ・逆向きのときは prv を使用 ● 1 5 7 3
復元 ・直前に除去したセルを復元するには、前と後ろのポインタが指すセルの間に、自分を挿入すればよい int LIST_recov ( LIST *l ){ LIST_ins ( l, l->prv); } ・消した順番の逆順に復元 するのであれば、いくつものセルを復元することができる (消したものは、片方向リストにすればよい) ● 1 5 7 3 1 5 7 3
リストの利用例 ・1から nの数字をランダムなセルの次に順番に挿入して、ランダムな列を作る (同じ数字が2度出てこないので、乱数列とは違う) ・時系列にそって、現れたり消えたりするデータを保持する (ある値を持つセルがうまく見つけられるように工夫がいる)
リストの利用例2 ・値を2つもつデータが n個ある。 (x1,y1) ,…, (xn,yn)とする ・yが大きいものから k/nだけデータを集めたときの、 xの値が k‘/m番目である要素が、各 k,k’について知りたい ・普通にやると O( n2 log n)時間 ・ リストを使ってうまくやると O( n( m+log n))時間
リストの利用例2 (2) ・データを x個でソートして、リストにする ・0/m番目から m/m番目を見つける ・次に y でソートした列も作り、 y が小さい順にリストから抜いていく ・0/m番目から m/m番目を更新する。必要に応じて、左右に1つ動かせばよい ・ 感覚的には、O( n2 log n) O( n( m+log n))時間 で n倍速くなる感じ
片方向リスト ・ 削除がなければ、リストは後ろへのポインタを覚えるだけでできる ・ 例えば、スライドの作成? ・k個のソートされた数列の合併 ● 1 5 7 3
配列を利用したリスト ・ ポインタを使うとわかりづらい、あるいは、1つずつセルの管理をするのが面倒な場合、配列を使ったリストが使える 利点 添え字がポインタの代わりになるので、 各セルに変数を割当てたい、というよう なときに面倒がない 欠点 配列なので、大きさを変えるのが面倒 ・ 実用では、意外と、 大きさを変えないですむことが多い ・ リスト全体の構造体を 作ることになる typedef struct { int *prv; // index to previous int *nxt; // index to next int *h; // value } ALIST
配列リストの例 ・h, prv, nxt の各配列の i 番目の要素が、セル i のh, nxt, prv ・0番目なり、最後なりを特別なセルにして、リストをたどるときは必ずそこからたどるようにする (そうでないと、どれが「生きているところ」かわからない) ・最初の空リストは、最後のセルの前後を最後のセルにして表現 0 1 2 3 4 (●) h 値 値 値 値 typedef struct { int *prv; // index to previous int *nxt; // index to next int *h; // value } ALIST prv 2 4 0 3 1 nxt 0 1 3 4 2
バケツ ・ キューにしてもリストにしても、順番どおりに記憶することはできるが、参照がしづらい 記憶した数字の中で、1桁のものを全部出してください ・ 少し構造を持たせて、検索が楽になるようにしたい ・ まず、値による分類から始めよう
バケツのアイディア ・ 数字を記憶する際に、それぞれの値ごとに分類して記憶することにする ・ 例えば、0から 99 までの値を持つ数字がいくつも入力され、それを10の位の値で分類する ・10の位、0 から 9に対して、1つずつリストなり配列なりを持たせればよい
バケツの利用例 ・ 例えば、10の位で数値をソートするときに使える ・ 例えば、疎な行列の転置をするときに使える A: 1,9,5 B: 1,2 C: 1,6,7 D: 4,5
応用:Radix(基数) ソート ・ バケツの、桁ごとのソートを繰り返すと、数値のソートができる ・ 各数値の下の位から順番にバケツソートをしていく。バケツ内の順番は変えないこと ・ バケツが2ついる。ただし、1つにすることもできる。その際は、バケツを一回スキャンして全部をつなげる必要がある。
ハッシュ ・ バケツの分類精度を上げようとすると、バケツの数を増やさなくてはいけない 大きなメモリが必要。ソートするときのように、全てのバケツをスキャンする、という操作にも時間がかかる ・ 正確に分類できなくてもいいから、分類そこそこ、メモリもそこそこ、検索もそこそこ、という間を取れないだろうか?
ハッシュのアイディア ・ 例えば文字列をしまうデータ構造を考える。 ・ 「この文字列Sはデータの中にありますか?」という質問に高速で答えるためにはバケツは便利だが、非常に多くのバケツが必要 ・ しかし、文字列の数自体は少ないだろう ・ そこで、例えば、頭2文字だけでバケツを作ったらどうだろう? 3文字目以降が異なっても、頭2文字が同じなら同じバケツに入る。バケツを共有してメモリを節約している そのため、バケツが空なら、Sはない、と簡単に答えられるが、バケツが空でないときは、バケツの中身を全部調べて、Sがあるかないかチェックしなければならない
文字列のバケツ ・ 「バケツは数値しか入らないんじゃない?」 ・ その通りなので、文字列は他の場所にしまい、バケツにはそのインデックスを入れる 1: ABCABC 2: ABBBBB 3: CCCBBB ・ 「頭2文字」は数値に変換する。例えばABCからなる文字列の場合は、A=0,B=1,C=2 と思って、3進数2桁の数と解釈 AB 1 、CC 8 となる
データの偏り ・ 文字列をしまう場合、文字列の頭2文字がある程度均質であればいいが、偏っていたらどうしよう? あるバケツにデータ集中して、のこりは空ばかり、となる ・ ならば、頭2文字でなく、文字列から整数への、均一な写像関数を持ってきたらどうだろうか? (これをハッシュ関数、データに対する写像の値をハッシュキー、ハッシュ値とよぶ) 実用を考えれば、似たものが異なる値を持つほうがいい ・ 例えば、x1,x2,x3 ,…に対して、 (x1)1+(x2)2+(x3)3 や、 ((x1+1)x2+1)x3… などの、バケツの大きさ(ハッシュの大きさ)の剰余をとったものを使う
ハッシュの大きさ ・ さて、ハッシュ関数のおかげで偏りなくデータをしまえそうだが、ハッシュの大きさはどうしたらいいのだろうか? 基本的に、バケツにデータがたくさん入っていると検索に時間がかかるので、なるべく異なるデータが同じところに入らないようにしたほうがいい もともと、データをとっておくところは必要なので、それと同じ位はメモリを使ってもいいんじゃないか? ・ ならば、例えば、データの数の定数倍にする。計算量の意味でも損失なし ・ データが増えてきたら、スタックと同じように、サイズを2倍に変更して作り直せばよい
まとめ ・スタックとキュー: 配列とカウンタをセットにして、逐次的にくるデータに対応 ・リスト: 隣接構造を記憶することで、順番を保ちながらの挿入削除を効率化 ・バケツ: 値による分類で検索を容易に ・ハッシュ: ハッシュキーを使ったバケツで、分類精度とメモリ効率の良さを両立させる