オーダー記法と再帰処理の導入
先週に、2重の繰り返し処理の時間分析をやったので、次のステップに。
2分探索法の処理時間
データを探す処理において、単純検索より速い方法ということで、2分探索法の処理速度見積もりを行う。
// 2分探索法 O(log N) int a[ 1000 ] = { 対象となるデータ } ; int size = N ; // データ数 N int L = 0 ; // L=下限のデータの場所 int R = size ; // R=上限のデータ+1の場所 while( L != R ) { int M = (L + R) / 2 ; // 計算は整数型で行われることに注意 if ( a[M] == key ) // 見つかった break ; else if ( a[M] < key ) // |L |M. |R L = M + 1 ; // |----------|-+---------| else // |L---------|M| R = M ; // |M+1------|R }
上記のようなプログラムの場合、処理に要する時T(N)は、
処理は、対象となるデータ件数が繰り返し毎に半分(正確には(N-1)/2だけどここでは厳密な分析はしない)となり、対象データ件数が1件になれば処理が終わる。このことから、データ件数Nとループ回数Mの間には以下の関係が成り立つ。
よって の関係が成り立つ。よって、
は、以下のように表せる。
# T(N)の式の中では、logの底については書かないことが一般的。(後の練習問題を参照)
単純なソート(最大選択法)の処理時間
次に、並べ替え処理の処理時間について考える。
int a[ 1000 ] = { 対象となるデータ } ; int size = N ; for( int i = 0 ; i < size - 1 ; i++ ) { int tmp ; // i..size-1 の範囲で一番大きいデータの場所を探す int m = i ; for( int j = i + 1 ; j < size ; j++ ) { if ( a[j] > a[m] ) m = j ; } // 一番大きいデータを先頭に移動 tmp = a[i] ; a[i] = a[m] ; a[m] = tmp ; }
このプログラムの処理時間T(N)は… (参考 数列の和の公式)
となる。
オーダー記法
ここまでのアルゴリズムをまとめると以下の表のようになる。ここで処理時間に大きく影響する部分は、最後の項の部分であり、特にその項の係数は、コンピュータの処理性能に影響を受けるが、アルゴリズムの優劣を考える場合は、それぞれ、
の部分の方が重要である。
単純サーチ | |
2分探索法 | |
最大選択法 |
そこで、アルゴリズムの優劣を議論する場合は、この処理時間の見積もりに最も影響する項で、コンピュータの性能によって決まる係数を除いた部分を抽出した式で表現する。これをオーダー記法と言う。
単純サーチ | オーダーNのアルゴリズム | |
2分探索法 | オーダー log N のアルゴリズム | |
最大選択法 | オーダー N2 のアルゴリズム |
練習問題
- ある処理のデータ数Nに対する処理時間が、
であった場合、オーダー記法で書くとどうなるか?
- コンピュータで2分探索法で、データ100件で10[μsec]かかったとする。
データ10000件なら何[sec]かかるか?
(ヒント: 底変換の公式) の処理時間を要するアルゴリズムを、オーダー記法で書くとどうなるか?また、このようなアルゴリズムの例を答えよ。
の処理時間を要するアルゴリズムを、オーダー記法で書くとどうなるか?
(ヒント: ロピタルの定理)
- 2と4の解説
- 1は、N→∞において、N2<<2Nなので、O(2N) 。厳密に回答するなら、練習問題4と同様の説明を行う。
- 3は、O(1)。誤答の例:O(0)と書いちゃうと、T(N)=Tα×0=0になってしまう。事例は、電話番号を、巨大配列の”電話番号”番目の場所に記憶するといった方法。(これはハッシュ法で改めて講義予定)
再帰呼び出しの予習
次の講義の基礎を確認という意味で、再帰呼出しと簡単な処理の例を説明する。
最初に定番の階乗(fact)
次に、フィボナッチ数列の場合
次の講義への導入問題
ここで示す導入問題をすべて答えるには、若干の予習が必要です。まずはどういう考え方をすれば解けるかな…を考えてみてください。
- fact(N)の処理時間を、
のような式で表現し、処理時間をオーダ記法で答えよ。
- 以下のプログラムの実行結果を答えよ。また、関数sum()の処理時間を対象となるデータ件数N=R-Lを用いて
のような式で表現せよ。
int a[] = { 1 , 5 , 8 , 9 , 2 , 3 , 4 , 7 } ; int sum( int a[] , int L , int R ) { if ( R-L == 1 ) { return a[L] ; } else { int M = (L + R) / 2 ; return sum( a , L , M ) + sum( a , M , R ) ; } } int main() { printf( "%d¥n" , sum( a , 0 , 8 ) ) ; return 0 ; }
情報構造論の質問の回答
今日は、遠隔授業形式で初めての情報構造論を行った。
ペン書きを交えながらの説明はそれなりにうまくいったかな。
今日は、2重forループの処理時間の分析の説明を行ったけど、最後の説明は次回の授業への橋渡しで(To be continued…)的に終わらせたんだけど、積極的な学生さんからチャットで質問があったので、解説しちゃえ。
2重forループの処理時間の見積もり
内容は、以下のようなプログラムが、foo(100)で1msecかかったとして、foo(1000)は何秒かかる?
int foo( int n ) { int c = 0 ; for( int i = 0 ; i < n ; i++ ) for( int j = 0 ; j < i ; j++ ) c++ ; return c ; }
勘がいい人なら、2重forループだし処理時間は に比例するから、
が10倍なら処理時間は100倍なので 100msec と速攻で答えるかもしれない。でもここはもう少し複雑な見積もりの基礎を説明するのが目標ということで、もう少し厳密な説明を示す。
授業でも解説したが、このプログラムの処理時間は、以下の式で表せる。
よって、の条件で解けばいい…。
ただ、未知変数がと3つある癖に、与えられた式は1つだけ。本来なら、この方程式は解けない。
こういった処理速度を予測するといったニーズでは、通常Nの値は、ある程度大きな値。しかも、正確な時間を求めるのではなく、大まかな見積もりがしたいだけ。だとすると、 の項目は、
に比べて小さな値のはずなので、最初の方程式も以下の式で考えればいい。
そうなると、なので、
となる。
よって、となる。
授業が終わってのアンケート、初めての遠隔授業だったけど、おべんちゃらも入ってるだろうけど「わかりやすかった」との言葉をもらえました。学生さんの感想の中には「先生の操作を近くの画面で見れるので、説明も理解しやすかった。トイレにいても授業に参加できます!」との楽しい💩感想ももらえました。👍
制御構文の理解
コロナ対策で、4年向けの情報構造論も始められないので、自習用資料。
制御構文の理解
基本として、C言語の制御構文の意味をフローチャートで表すと以下のようになる。
条件分岐if
繰り返しwhile, for, do-while
繰り返し命令の中では、break文でループを抜ける、continue文で次の繰り返し先頭に飛ぶ。フローチャートで表すと、上記の図中の赤、青の部分。
条件分岐switch
条件分岐のswitch文は、以下のようなフローチャートで示すことができる。
ただしcase の後には定数式しか書けない。 一般的には、分岐処理のAAA;BBB;CCC;の後ろには、break を書くのが一般的。繰り返し処理の中にswitch文があった場合、break命令では、switch文を抜けるだけで、ループを抜け出す訳ではない。
もし、breakを書き忘れると、以下のようなフローチャートになるので要注意。
このフローチャートを見てわかるように、breakが無いと、X=Bの時、処理BBB,CCCが実行することになる。
文の定義
C言語では、文とは(大雑把に言うと)以下の定義である。複文の所が要注意。
for()の後には1つの文が書ける。単純な計算式であれば、式;で1つの文なので、{,} は本来不要。forの中で複数の処理を書きたい時は、{ 文 文 … } のように複文で1つの文の塊をつくる。
((( C言語の文 ))) // 式 : 計算式が ; で終わっているもの 式 ; // if文 if ( 式 ) 文 else 文 // else以下略あり // while文 while( 式 ) 文 // for文 for( 式 ; 式 ; 式 ) 文 // do-while文 do 文 while( 式 ) ; // セミコロンまででdo-while文 // 複文 { 文 文... } // 複数の文を1つの文として扱う // 空文 ; // for(;;); これも文法としては正しい
例題
上記の制御構文の意味を踏まえた上で、過去のテスト問題より具体例で説明。
以下のプログラムの動作順序を(A)〜(M)の記号で答えよ。
答えは20ステップ目までで良い。
前述の文の定義を踏まえ、前述の問題の中では、以下の様な命令の塊(ブロック)が存在する。
ここで、水色部分(c)の if 文の break は{,} は不要なのか?という質問をする人が多いけど、if(式) { 文 } と書いても、if(式) 文 でも、文の部分に複文を使うか使わないかの違いにすぎない。
これを踏まえて、フローチャートを書くと以下の通り。
よって、この問題の回答は、以下のようになる。
A(i=0)→ | B(i==0)→D(j=0)→ | E(j==0)→G→H→ F→ | |
E(j==1)→G→H→I(break)→ | |||
J(j=0)→ | K(j==0)→M→L→ | ||
K(j==1)→ | C(i=1)→ | ||
B(i==1)→D(j=0)→ | E(j==0)→G→H→ F→ | ||
E(j==1)→G→H→I(break)→ | |||
J(j=0)→ | K(j==0)→M→L→ | ||
K(j==1)→M→L→ | |||
K(j==2)→ | C(i=2)→ | ||
B(i==2) |
第2回レポート課題
(1) 以下の rep1-1〜rep1-4 の中から1つを選び、処理順序の結果を答えよ。rep-1-(出席番号%4+1)を答えること。(例:出席番号10番の人はrep1-3)
rep-1-1
rep-1-2
rep-1-3
rep-1-4
(2) 以下の foo(n) , bar(n) の関数で、プログラムの(a)〜(i)の計算式の1命令の実行に10[nsec]とする。(それ以外の実行時間は無視して良い)
引数が n=2 , n=4 の場合、各関数foo(n),bar(n)の返り値 cnt の値、foo(n),bar(n) の実行にかかる時間を答えよ。ただし、答えた理由がわかるような情報をつけること。
例えば、命令に実行回数を数えるためのチェックマークの数がわかる資料でも良い。
nの値 | foo(n) | bar(n) | ||
---|---|---|---|---|
cnt | 処理時間 | cnt | 処理時間 | |
n=2 | ||||
n=4 |
(3) n=1024 の時、foo(n),bar(n)はどんな値になると思いますか?
どのように考えて計算すればいいか、考え方を提案してください。
レポート課題は、上記(1),(2),(3)を簡単に(A4×1〜2ページ)まとめ、この共有フォルダに提出して下さい。提出するファイル名は、出席番号-名前-レポート名.拡張子としてください。提出締め切りは、(2020/04/24までとする)
2020年度情報構造論ガイダンス
基本的なガイダンス
情報構造論のシラバスを、ここに示す。プログラムを作成する上で、どのような考え方で作れば処理速度が速いのかを議論する。基本的に、4回のテストのたびに、レポート課題を実施する。各テスト毎の評価は、テスト素点と、「テスト素点×60%+レポート評価×40%」の良い方とする。テストに自信のない人は、レポート課題をきちんと提出すること。ただし、今後の休講などの影響で評価方法は随時変更を行う。
プログラムを評価する3つのポイント
まずは以下を読む前に、質問。
- あなたが良いプログラムを作るために何を考えて作りますか? ※1
- ここまでの段階で3つの要点を考えメモしておいてください。
- ガイダンス最初のレポートに使います。
具体的な言葉で要点を考えると、いろいろなものがでてくるだろうが、端的なポイントにまとめると、次の3つに分類できるはずである。
- プログラムの速度
- プログラムのわかり易さ
- メモリの使用量
プログラムを作る場合、この3要素がトレードオフの関係にある。プログラムの速度を優先すると、プログラムが分かり難くなったり、メモリを大量浪費するものだったりする。
メモリの使用量の影響
メモリを大量に使用すると、どういった影響がでるのか? OSの機能を知らないと、メモリ(主記憶)を使い果たしたら、プログラムが動かないと思うかもしれないけど、最近のOSは仮想メモリ機能があるため、主記憶がメモリが足りなければ待機状態のプロセスのメモリを補助記憶に保存することで、プログラムを動かすことはできる。(仮想記憶)
しかし、プロセスが切り替わる度に、補助記憶への読み書きが発生するため、処理性能は低下する。(スワッピング)
ソフトウェアとアルゴリズムとプログラム
用語として、ソフトウェア、アルゴリズム、プログラムという表現があるが、この違いは何か?
- アルゴリズム – 計算手順の考え方。
- プログラム – アルゴリズムを特定のプログラム言語によって記述したもの。
- ソフトウェア – プログラムと、その処理に必要なデータ。(日本語を変換するプログラムは、日本語の辞書データが無いと動かない)
- パラダイム – プログラムをどう表現すると分かりやすいか?
トレードオフ関係をプログラムで確認
例えば、配列の中から、目的データを探すプログラムの場合、最も簡単なプログラムは以下の方法であろう。
// ((case-1)) // 単純サーチ O(N) #define SIZE 1024 int a[ SIZE ] ; // 配列 int size ; // 実際のデータ数(Nとする) int key ; // 探すデータ for( int i = 0 ; i < size ; i++ ) if ( a[i] == key ) break ;
しかし、もっと早く探したいのであれば、2分探索法を用いるだろう。でも、このプログラムは、case-1 のプログラムよりは分かり難い。(速度⇔わかり易さ)
// ((case-2)) // 2分探索法 int L=0 , R=size ; // プログラムは複雑になった while( L != R ) { int M = (L + R) / 2 ; if ( a[M] == key ) break ; else if ( a[M] < key ) L = M + 1 ; else R = M ; }
でももっと速いプログラムとしたければ、大量のメモリを使えば一発でデータを探せる。(速度⇔メモリ使用量)
// ((case-3)) // 添字がデータ O(1) // 探すデータが電話番号 272925 のような 6 桁ならば int a[ 1000000 ] ; a[ 272925 ] = 272925 ; // 処理速度はクソ速いけど、メモリは大量消費
良いプログラムを作るとは
プログラムを作る時には、メモリが大量に使えるのなら、速いものを使えばいい。だけど実際には、そのシステムには限られた予算があるだろう。
実際には、限られた予算からメモリやCPUが決まり、その会社の人員やら経験やらでプログラム開発に使える時間がきまる。プログラムをデザインするとは、限られた条件の中で、適切な速度のコンピュータ、適切な量のメモリでコンピュータを用意し、限られた納期の中でシステムを完成させることである。
皆さんも、ゲームを買った時、処理速度が遅くてキャラクターがカクカク動いたら幻滅するでしょ?ゲームがバグですぐに変な動きしたらキレるでしょ!発売日の予定どおりに買えなかったらさみしいでしょ!!プログラムがでかすぎてローディングに時間がかかったら、寝ちゃうでしょ!!!
第1回課題
- 最初の※1で、あなたが考えた「良いプログラムを作るために考えること」を3つあげてください。
- あなたが考えた3つは、それぞれ、「速度、わかりやすさ、メモリの使用量」のどれに関係すると思いますか?(複数に関連する場合もあります)
- 仮想メモリ、スワッピング、プログラミング・パラダイムの用語の中から1つを選び、意味を調べて簡単にまとめてください。
この3つの内容を簡単に(A4×1ページ)まとめ、この共有フォルダに提出して下さい。提出するファイル名は、出席番号-名前-レポート名.拡張子 (例 01-愛上男-第1回課題.pdf) としてください。提出締め切りは、(2020/04/17までとする)
(2020/04/10,13:40) 当初のフォルダが書き込み禁止状態だったので、提出場所を変更、書き込み許可を与えたので改めて提出をお願いします。
情報構造論2019-講義録
- 2019年度情報構造論ガイダンス
- ループ処理時間とオーダー記法と再帰
- 処理時間のオーダー(回答編)
- 再帰呼び出しと再帰方程式
- ポインタを使った処理
- ソートアルゴリズム
- ポインタの加算と配列アドレス
- 効率のよいメモリ使用と動的メモリ確保
- malloc()とfree()
- mallocを使った課題
- リスト構造について
- リスト処理
- リストへの追加処理
- スタックと待ち行列
- 集合とリスト処理
- 双方向リスト
- 2分探索木
- 2分探索木にデータ追加と演習
- AVL木と2分ヒープ
- 意思決定木と構文解析
- 演算子と2分木による式の表現
- B木とデータベース
- ハッシュ法
- 文字列のハッシュ値と共有のあるデータの取り扱い
- 動的メモリ確保(malloc()とfreelist)
- オブジェクト指向と情報構造論と演習
オブジェクト指向と情報構造論と演習
データ構造を扱うプログラムの書き方を説明してきたので、それらを便利に書くためのオブジェクト指向の入り口を紹介する。
データ指向のプログラム記述
名前と年齢のデータを扱うプログラムを書く時、私なら以下のようなプログラムを作成する。
このプログラムの書き方では、saitohというデータにset_NameAge() , print_NameAge() を呼び出していて、データに対して処理を加えるという雰囲気がでている。このようにプログラムを書くと、saitoh というデータに対して命令するイメージとなり、擬人化したデータに向かってset,printしろ…って命令しているように見える。
// 名前と年齢の構造体 struct NameAge { char name[ 20 ] ; int age ; } ; // NameAgeを初期化する関数 void set_NameAge( struct NameAge* p , char s[] , int a ) { strcpy( p->name , s ) ; p->age = a ; } // NameAgeを表示する関数 void print_NameAge( struct NameAge* p ) { printf( "%s %d¥n" , p->name , p->age ) ; } void main() { struct NameAge saitoh ; set_NameAge( &saitoh, "t-saitoh" , 53 ) ; print_NameAge( &saitoh ) ; // NameAge の中身を知らなくても、 // set_NameAge(),print_NameAge() の中身を見なくても、 // saitoh を set して print する....という雰囲気は伝わるよね!! }
このプログラムでは、例えば、データに誕生日も覚えたいという改良を加えるとしても、main の前のデータ構造と関数の部分は色々と書き換えることになるだろうけど、main の内部はあまり変わらないだろう。こういう状態なので、プログラムを作成するときには、データ構造とそれを扱う関数を記述する人と、データ構造を使う人(main内部を書く人)と、分業ができるようになる。
隠蔽化
このような記述では、データ構造の中身を知らなくても、main で、setしてprintして…という処理の雰囲気は分かる。さらに、set_NameAge()とか、print_NameAge() の処理の中身を知らなくても、設定するとか表示するとか…は予想できる。
これは、NameAge というデータをブラックボックス化して捉えていると見れる。データ構造の中身を知らなくてもプログラムを理解できることは、データ構造の隠蔽化という。また、関数の中身を知らなくても理解できることは、手続きの隠蔽化という。
オブジェクト指向プログラミング
前述のように、プログラムを書く時には、データ構造とそのデータを扱う関数を一緒に開発するのが一般的である。オブジェクト指向プログラミングでは、データ構造とその関数(メソッドと呼ぶ)をまとめてクラスと呼ぶ。
class NameAge { private: // データ構造の宣言 char name[ 20 ] ; int age ; public: // メソッドの定義 void set( char s[] , int a ) { // 初期化関数 strcpy( name , s ) ; age = a ; } void print() { // 表示関数 printf( "%s %d¥n" , name , age ) ; } } ; void main() { NameAge saitoh ; saitoh.set( "t-saitoh" , 53 ) ; saitoh.print() ; }
このプログラムでは、saitoh というデータ(具体的なデータはオブジェクトと呼ぶ)に対して、set() , print() を呼び出している。
オブジェクト指向では、データに対して private を指定すると、クラス以外でその要素を扱うことができなくなる。これにより、クラスを設計する人と、クラスを使う人を明確に分けることができ、クラスを使う人が、クラス内部の変数を勝手に触ることを禁止できる。
プログラムを記述する時には、データ件数を数える時に、カウンタの初期化を忘れて動かないといった、初期化忘れも問題となる。オブジェクト指向のプログラム言語では、こういうミスを減らすために、データ初期化専用の関数(コンストラクタ)を定義することで、初期化忘れを防ぐことができる。
// コンストラクタを使う例 class NameAge { // 略 public: NameAge( char s[] , int a ) { // データ初期化専用の関数 strcpy( name , s ) ; // コンストラクタと呼ぶ age = a ; } // 略 } ; void main() { NameAge saitoh( "t-saitoh" , 53 ) ; // オブジェクトの宣言と初期化をまとめて記述できる。 saitoh.print() ; }
プログラムにオブジェクト指向を取り入れると、クラスを利用する人とクラスを記述する人で分業ができ、クラスを記述する人は、クラスを利用するプログラマーに迷惑をかけずにプログラムを修正できる。
この結果、クラスを記述する人はプログラムを常により良い状態に書き換えることができるようになる。このように、よりよく改善を常に行うことはリファクタリングと呼ばれ、オブジェクト指向を取り入れる大きな原動力となる。。
最近のC++なら
最近のオブジェクト指向プログラミングは、テンプレート機能と組み合わせると、単純リスト処理が以下のように書けてしまう。struct 宣言やmalloc()なんて出てこない。(^_^;
#include <iostream> #include <forward_list> #include <algorithm> int main() { // std::forward_list<>線形リスト std::forward_list<int> lst{ 1 , 2 , 3 } ; // リスト先頭に 0 を挿入 lst.push_front( 0 ) ; // 以下のような処理を最新のC++なら... // for( struct List*p = top ; p != NULL ; p = p->next ) // printf( "%d¥n" , p->data ) ; // 通常の反復子iteratorを使って書いてみる。 // auto は、lst の型推論。 // 本来なら、std::forward_list<int>::iterator itr = lst.begin() と書く。 for( auto itr = lst.begin() ; itr != lst.end() ; itr++ ) { std::cout << *itr << std::endl ; } // 同じ処理を algorithm を使って書く。 std::for_each( lst.begin() , lst.end() , []( int x ) { // 配列参照のコールバック関数 std::cout << x << std::endl ; } ); // 特に書かなくてもデストラクタがlstを捨ててくれる。 return 0 ; }
関数ポインタ
前プログラムのC++のfor_each アルゴリズムでは、コールバック関数が使われていたが、この仕組みを分かるために関数ポインタの考え方が重要。
int add( int x , int y ) { return x + y ; } int mul( int x , int y ) { return x * y ; } void main() { int (*f)( int , int ) ; // fは2つのintを引数とする関数へのポインタ f = add ; // f = add( ... ) ; ではないことに注意 printf( "%d¥n" , (*f)( 3 , 4 ) ) ; // 3+4=7 f = mul ; printf( "%d¥n" , (*f)( 3 , 4 ) ) ; // 3*4=12 }
演習(ハッシュ法)
ハッシュ法のプログラム(オープンアドレス法もしくはチェイン法)を用いて、
(1)名前と電話番号,(2)名前と住所,(3)名前と誕生日について、名前をキーとして検索するプログラムを作成せよ。
原則として「出席番号 % 3 + 1」の番号のテーマに取り組むこと。
レポートを作成する際には、ハッシュ関数を変更してどういった変化があるか確認せよ。
ハッシュサイズは、10〜20件程度で良い。
動的メモリ確保(malloc()とfreelist)
C言語では、動的メモリ領域をどのように管理していくのか解説する。
局所変数とスタック
局所変数は、関数に入った時に作られるメモリ領域であり、関数の処理を抜けると自動的に開放されるデータ領域である。
関数の中で関数が呼び出されると、スタックには戻り番地情報を保存し、関数に移動する。最初の処理で局所変数領域が確保され、関数を終えると局所変数は開放される。
この局所変数の確保と開放は、最後に確保された領域を最初に開放される(Last In First Out)ことから、スタック上に保存される。
baz()の中で、「*((&c)+8) = 123 ;」を実行したら、bar()のxを書き換えられるかも…
動的メモリ領域とフリーリスト
動的なメモリ領域(ヒープ領域)は、malloc()関数で処理用のメモリを借り、free()関数で使わなくなったメモリを返却する。
この返却されたメモリ領域は、改めて malloc() が呼び出されたときに再利用を行う。この再利用するメモリ領域は、簡単に扱えるようにリスト構造にして保存する。この free された再利用候補のリスト構造は、free_list と呼ばれる。
mallocが一定サイズの場合
仕組みを理解する第1歩として、free_list の考え方を説明するために、malloc() でのメモリサイズが一定として説明を行う。free_list には、貸し出すためのメモリ空間をリスト構造で繋がった状態にしておく。
malloc() が呼び出される度に、free_list の先頭から貸し出すメモリを取り出し(a=malloc(),b=malloc(),c=malloc()まで)、free() が呼び出されると、返却されたメモリは、free_list の先頭につないでおく。
任意サイズのメモリ確保の場合
最初のステップでの説明は、mallocのメモリサイズを一定としていたが、本来は確保するメモリサイズが指定する。この場合は、以下の様に管理されている。mallocで貸し出されるメモリ空間には、ヒープメモリの利用者が使うブロックの前に、次のメモリブロックへのポインタとブロックサイズを記憶する領域をつけておく。こういったメモリブロックを free_list の考え方と同じようにリスト構造となるようにつないで保存されている。
この図の一番下の赤部分は、次のメモリブロックへのポインタとブロックサイズの大きさが20byteの場合の例。
malloc() で、指定されたサイズのものが、free_list の中にあれば、それを使う。malloc(40)
丁度いいサイズが無い場合は、それより大きいメモリブロックの後半を切り分けて、貸し出す。malloc(60)
free()の処理とメモリブロックの併合
この例の最後の処理では、20byte,60byte,40byte,50byteが併合された例。併合後のブロックサイズは、すこしいい加減に書いてある。
使用されていたメモリブロックが free() で返却された場合は、free_list につないでいく。ただし、単純にリストに繋ぐだけであれば、malloc(),free() を繰り返すと、小さなメモリブロックばかりになってしまい、大きいメモリのmalloc()ができなくなる。
そこで、free() で返却される際には、隣り合うメモリブロックと併合できるかを確認し、大きなメモリブロックになるような処理を行う。
また、隣り合うメモリブロックが併合できるかの判定が簡単になるように、free_listにつなぐ際は、次のメモリブロックへのポインタは、昇順となるように並べる。
一般的には、上記のようにmalloc(),free()を行うが(K&Rのmallocアルゴリズム)、mallocのサイズが小さい場合には小さいメモリブロック毎にnextブロックポインタやブロックサイズを記憶する場合、メモリのムダが多い。
そこで、最初に説明した一定サイズのmalloc()の手法で、8byte専用のfreelist,16byte専用のfreelist,32byte専用のfreelistのように2Nbyteのfreelistで管理する。10byteといった中途半端なサイズの時は、それより大きい16byteのfreelistを使う。(dlmallocのアルゴリズム)
ヒープメモリの断片化
ヒープメモリの malloc() , free() を繰り返すと、最悪、以下の図の様に、使用中領域(赤)とfreeされた未使用領域(黒)が交互に並ぶ状態が発生するかもしれない。この場合、全体の未使用領域の合計では十分なサイズでも、小さなメモリブロックばかりとなって、大きなメモリブロックを要求されても十分な大きさのメモリが見つからない状態が発生する場合がある。
この状態をヒープメモリの断片化といい、使用しづらい小さなメモリブロックはヒープホールと呼ばれる。
(補足) 断片化
断片化というと、OSではハードディスクの断片化(フラグメンテーション)を思い浮かべるかもしれない。ハードディスクの断片化とは、ファイル領域の割り当てとファイルの削除を繰り返すことで、ファイルのセクタが不連続となり、アクセス効率が悪くなる現象。OSによっては、ファイル実体の位置を動かすことで断片化を改善できる。以下の図のようにフラグメンテーションを防ぐための実体の移動を行う最適化はデフラグと呼ばれる。
上記の図では、上の青の図が断片化が発生している事例で、a1→a2,a2→a3の時にヘッド移動(シーク時間)が発生する。下の赤の図のように、デフラグ処理を施すことでシーク時間が減らせる。
Windows が 95,98,Me といった時代ではOSが不安定で、フラグメントが多く発生する場合Windowsがフリーズすることが多く、OSが不安定になったらデフラグを実行する…というテクニックが定番だった。最新のWindowsでは、デフラグが自動的に実行されるのでユーザが意識的に実行する機会はほぼなくなった。
文字列のハッシュ値と共有のあるデータの取り扱い
文字列のハッシュ値
ここまでで説明した事例は、電話番号をキーとするものであり、余りを求めるだけといったような簡単な計算で、ハッシュ値が求められた。しかし、一般的には文字列といったような名前から、ハッシュ値が欲しいことが普通だろう。
ハッシュ値は、簡単な計算で、見た目デタラメな値が求まればいい。 (ただしく言えば、ハッシュ値の出現確率が極力一様であること)。一見規則性が解らない値として、文字であれば文字コードが考えられる。複数の文字で、これらの文字コードを加えるなどの計算をすれば、 偏りの少ない値を取り出すことができる。
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 ; }
共有のあるデータの取扱の問題
リスト構造で集合計算の和集合を求める処理を考える。
// 集合和を求める処理 struct List* join( struct List* a , struct List* b ) { struct List* ans = b ; for( ; a != NULL ; a = a->next ) if ( !find( ans , a->data ) ) ans = cons( a->data , ans ) ; return ans ; } void list_del( struct List* p ) { // ダメなプログラムの例 while( p != NULL ) { // for( ; p != NULL ; p = p->next ) struct List* d = p ; // free( p ) ; p = p->next ; free( d ) ; } } void main() { // リストの生成 struct List* a = cons( 1 , cons( 2 , cons( 3 , NULL ) ) ) ; struct List* b = cons( 2 , cons( 3 , cons( 4 , NULL ) ) ) ; struct List* c = join( a , b ) ; // c = { 1, 1, 2, 3 } // ~~~~~~~ ここは b // a,b,cを使った処理 // 処理が終わったのでa,b,cを捨てる list_del( a ) ; list_del( b ) ; list_del( c ) ; // list_del(b)ですでに消えている } // このためメモリー参照エラー発生
このようなプログラムでは、c=join(a,b) ; が終わると下の図のようなデータ構造となる。しかし処理が終わってリスト廃棄list_del(c), list_del(b), listdel(a)を行おうとすると、bの先のデータは廃棄済みなのに、list_del(c)の実行時に、その領域を触ろうとして異常が発生する。
参照カウンタ法
上記の問題は、b の先のリストが c の一部とデータを共有しているために発生する。この解決方法として簡単な方法では、参照カウンタ法が用いられる。
参照カウンタ法では、データを参照するポインタの数をデータと共に保存する。
- データの中にポインタ数を覚える参照カウンタを設け、データを生成した時に1とする。
- 処理の中で共有が発生すると、参照カウンタをカウントアップする。
- データを捨てる際には、参照カウンタをカウントダウンし、0になったら本当にそのデータを消す。
struct List { int refc ; // 参照カウンタ int data ; // データ struct List* next ; // 次のポインタ } ; void list_del( strcut List* p ) { // 再帰で全廃棄 if ( p != NULL && --(p->refc) <= 0 ) { // 参照カウンタを減らし list_del( p->next ) ; // 0ならば本当に消す free( p ) ; } }
ただし、参照カウンタ法は、循環リストではカウンタが0にならないので、取扱いが苦手。
参照カウンタ法が用いられている事例をあげよ。
ガベージコレクタ
では、循環リストの発生するようなデータで、共有が発生するような場合には、どのようにデータを管理すれば良いだろうか?
最も簡単な方法は、処理が終わっても、使い終わったメモリを返却しない、方法である。ただし、このままでは、メモリを使い切ってしまう。
そこで、廃棄処理をしないまま、ゴミだらけになってしまったメモリ空間を再利用するのが、ガベージコレクタである。
ガベージコレクタは、貸し出すメモリ空間が無くなった時に起動され、
- すべてのメモリ空間に、「不要」の目印をつける。(unmark処理)
- 変数に代入されているデータが参照している先のデータは「使用中」の目印をつける。(mark処理)
- その後、「不要」の目印がついている領域は、だれも使っていないので回収する。(sweep処理)
この方式は、マークアンドスイープ法と呼ばれる。ただし、このようなガベージコレクタが動く場合は、他の処理ができず処理が中断されるので、コンピュータの操作性という点では問題となる。
最近のプログラミング言語では、参照カウンタとガベージコレクタを取り混ぜた方式でメモリ管理をする機能が組み込まれている。このようなシステムでは、局所変数のような生成され関数終了といったすぐに不要となる領域は、ガベージコレクタで管理し、大域変数のような長期間保管するデータはガベージコレクタで管理される。
ハッシュ法
ここまでの授業では、配列(データ検索は、登録順保存ならO(N)、2分探索ならO(log N)となる)、単純リスト(データ検索(シーケンシャルアクセスしかできないので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桁であれば、電話番号の局番であり、同じ学校の人でデータを覚えたら、同じ地域の人でハッシュ衝突が発生してしまう。また、ハッシュ値を計算するのに、配列の空き場所を一つ一つ探すような方式では、データ件数に比例した時間がかかり、高速なアルゴリズムでなくなってしまう。このことから、ハッシュ関数には以下のような特徴が必要となる。
- 同じハッシュ値が発生しづらい(一見してデタラメのように見える値)
- 簡単な計算で求まること。
- 同じデータに対し常に、同じハッシュ値が求まること。
オープンアドレス法
先の椅子取りゲームの例え話であれば、先に座っている人がいた場合、最も簡単な椅子に座る方法は、隣が空いているか確認して空いていたらそこに座ればいい。
これをプログラムにしてみると、以下のようになる。このハッシュ法は、求まったアドレスの場所にこだわらない方式でオープンアドレス法と呼ばれる。
// オープンアドレス法 // table[] は大域変数で0で初期化されているものとする。 // 配列に電話番号と名前を保存 void entry( int phone , name ) { int idx = hash_func( phone ) ; while( table[ idx ].phone != 0 ) idx = (idx + 1) % HASH_SIZE ; } 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 ; } return NULL ; // 見つからなかった }
注意:このプログラムは、ハッシュ表すべてにデータが埋まった場合、無限ループとなるので、実際にはもう少し改良が必要である。
チェイン法
前に述べたオープンアドレス法は、ハッシュ衝突が発生した場合、別のハッシュ値を求めそこに格納する。配列で実装した場合であれば、ハッシュ表以上の データ件数を保存することはできない。
チェイン法は、同じハッシュ値のデータをグループ化して保存する方法。 同じハッシュ値のデータは、リスト構造とするのが一般的。
#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 ; }
講義録に動くサンプルコードを併記
長男からプログラミングの授業の質問が LINE で流れてきて、大学の先生の資料を覗き見。その大学の課題ではサンプルコードの配布がしっかりしている。私の講義録でもサンプルコードは掲載しているけど、プロジェクタで掲示しながら授業をするため、#include 行を省略したり、main を void 宣言したりと、そのまま入れても動かない。
そこで、ダウンロードすれば「そのまま動くサンプルコード」を積極的に入れるようにしてみよう。
まずは、後期の情報構造論の半分ほどのサンプルコードを動く様に加筆し、テキストファイルへのリンクを埋め込んだ。
データベースについても、学内向けのSQLite3を使った実験環境にて動作ができるように、若干の改良を加え、リンクを埋め込んだ。
前期分は、来年度の授業の中で修正していこう。