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