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