B木の構造とデータベース
2分探索木の考え方を拡張したもので、B木がある。
B木の構造
B木では、データの増減で木の組換えの発生頻度が高い。そこで、1つのノード内に複数のデータを一定数覚える方法をとる。B木では、位数=Nに対し、最大2N個のデータd0..d2N-1と、2N+1本のポインタp0..p2Nから構成される。piの先には、di-1<x<di を満たすデータが入った B木のノードを配置する。ただし、データの充填率を下げないようにするため、データは最小でもN個、最大で2N個を保存する。
B木からデータの検索
データを探す場合は、ノード内のデータ diの中から探し、見つからない場合は、ポインタの先のデータを探す。位数がある程度大きい場合、ノード内の検索は2分探索法が使用できる。また、1つのノード内の検索が終われば、探索するデータ件数は、1/N〜1/2Nとなることから、指数的に対象件数が減っていく。よって、検索時間のオーダは、O(N) O(logN) 資料修正注意となる。 (さらに…)
式の構文木と評価
2分木の応用ということで、2項演算子の構文木と、意思決定木の説明を行う。また、これらを用いてコンパイラを作るための知識を解説する。
2項演算と構文木
演算子を含む式が与えられたとして、それを保存する場合、演算式の2分木で扱うと都合が良い。
+ / \ 1 * / \ 2 3
演算子の木のノードで、末端は数値であることに注目し、右枝・左枝がNULLなら数値(data部にはその数値)、それ以外は演算子(data部には演算子の文字コード)として扱うとして…
(さらに…)
意志決定木と式を表す木
意志決定木
2分木の応用で最も単純な物として、意志決定木がある。
yes/no の答えを回答すると、最終的に「あなたの性格は✕✕です」と表示するようなヤツ。
struct Tree { char* q_a ; struct Tree* yes ; struct Tree* no ; } ; top \ あなたは勉強が好き? /yes \no ものづくりは好き? 人と話すのが好き? /yes \no /yes \no 技術者タイプ ◯◯◯ 営業タイプ ◯◯◯
2分探索木の演習
先週までで、2分探索木の説明を終えたので、今日は演習を行う。
課題テーマ(基本)
2分探索木を使ったプログラムの作成。データ構造は、以下の中から出席番号にて選ぶ。(出席番号%3)
- 名前と生年月日(年月日は別要素が望ましい)
- 名前と電話番号
- 学科・学年・出席番号・名前の名簿
上記のデータ構造にて、データを入力し「保存」、その後「検索」し目的のデータを表示する機能を実装せよ。
定番ではあるが、(a)プログラム説明、(b)プログラムリスト、(c)動作検証、(d)動作考察の観点で評価を行う。
課題テーマ(オプション)
上記の基本テーマは簡単であるため、オプションテーマを以下に示す。基本テーマの代わりにオプションテーマにてレポートを提出せよ。
数値をデータとする2分木を、その構造がイメージできるようにアスキーアート的に、あるいはグラフィック的に表示するプログラムを作成せよ。
(例1) (例2) {[29] 51 [(60) 75 (80)]} 51 / \ (例3) 51 29 75 | / \ +------+-------+ 60 80 | | 29 75 | +---+---+ | | 60 80
2分探索木への追加
2分木へのデータの追記
前回の授業で、2分探索木のデータに対する処理を説明したので、今回は木にデータを追加する処理。
struct Tree { int data ; struct Tree* left ; struct Tree* right ; } ; struct Tree* tcons( int x, struct Tree*L, struct Tree*R ) { struct Tree* ans ; ans = (struct Tree*)malloc( sizeof( struct Tree ) ) ; if ( ans != NULL ) { ans->data = x ; ans->left = L ; ans->right = R ; } return ans ; } struct Tree* top = NULL ; void insert( int x ) { struct Tree** tail = &top ; // 書換えるべき末端ポインタへのポインタ while( (*tail) != NULL ) { if ( (*tail)->data == x ) break ; // 同じデータは重複するので、追加処理は行わない else if ( (*tail)->data > x ) tail = &( (*tail)->left ) ; // 左の枝を降りていく else tail = &( (*tail)->right ) ; // 右の枝を降りていく } if ( (*tail) == NULL ) (*tail) = tcons( x , NULL , NULL ) ; }
単純リストの時は、2重ポインタの混ざった式の部分的に、型を意識する説明をした。今回は、”tail = &( (*tail)->left )“の赤のカッコが省略されたら、青のカッコが省略されたら、どちらが文法エラーかを「演算子の優先順位」の話を交えながら説明する。 (さらに…)
2分探索木
2分木(2分探索木)
struct Tree { int data ; struct Tree* left ; struct Tree* right ; } ; struct Tree* tcons( int x, struct Tree*L, struct Tree*R ) { struct Tree* ans ; ans = (struct Tree*)malloc( sizeof( struct Tree ) ) ; if ( ans != NULL ) { ans->data = x ; ans->left = L ; ans->right = R ; } return ans ; } void main() { struct Tree* top = tcons( 52 , tcons( 24 , tcons( 10 , NULL , NULL ) , tcons( 35 , NULL , NULL ) ) , tcons( 73 , tcons( 60 , NULL , NULL ) , tcons( 95 , NULL , NULL ) ) ) ; }
出来上がった木構造のイメージ
top \ 52 / \ / \ 24 73 / \ / \ 10 35 60 95
リストの利点/欠点と双方向リスト
リストを使った集合演算のように、データを連ねたリストは、単純リストとか線形リストと呼ばれる。特徴はデータ数に応じてメモリを確保する点や、途中へのデータの挿入削除が得意な点があげられる。一方で、配列は想定最大データ件数で宣言してしまうと、実際のデータ数が少ない場合、メモリの無駄も発生する。しかし、想定件数と実データ件数がそれなりに一致していれば、無駄も必要最小限となる。リスト構造では、次のデータへのポインタを必要とすることから、常にポインタ分のメモリは、データにのみ注目すれば無駄となる。
シーケンシャルアクセス・ランダムアクセス
もう1つの欠点がシーケンシャルアクセスとなる。テープ上に記録された情報を読む場合、後ろのデータを読むには途中データを読み飛ばす必要があり、データ件数に比例したアクセス時間を要する。このような N番目 データ参照に、O(N)の時間を要するものは、シーケンシャルアクセスと呼ばれる。
一方、配列はどの場所であれ、一定時間でデータの参照が可能であり、これは ランダムアクセスと呼ばれる。N番目のアクセス時間がO(1)を要する。
このため、プログラム・エディタの文字データの管理などに単純リストを用いた場合、1つ前の行に移動するには、先頭から編集行までの移動で O(N) の時間がかかり、大量の行数の編集では、使いものにならない。ここで、シーケンシャルアクセスでも1つ前にもどるだけでも処理時間を改善してみよう。 (さらに…)
構造体と実体について
構造体と実体の違いや、Javaに慣れている学生さんに あらためて、データ構造のイメージを持って欲しいので、 以下のコードを示す。
struct Complex { double re ; double im ; } ; struct Complex2 { double* pre ; double* pim ; } ; void main() { // メモリ確保失敗のNULLチェックは省略 struct Complex a ; struct Complex* p ; struct Complex2 b ; struct Complex2* q ; a.re = 1.2 ; a.im = 2.3 ; p = (struct Complex*)malloc( sizeof( struct Complex ) ) ; p->re = 1.2 ; p->im = 2.3 ; // in Java // p = new Complex ; // p.re = 1.2 ; // p.im = 2.3 ; b.pre = (double*)malloc( sizeof( double ) ) ; *(b.pre) = 1.2 ; b.pim = (double*)malloc( sizeof( double ) ) ; *(b.pim) = 2.3 ; q = (struct Comple2*)malloc( sizeof( struct Complex2 ) ) ; q->pre = (double*)malloc( sizeof( double ) ) ; *(q->pre) = 1.2 ; q->pim = (double*)malloc( sizeof( double ) ) ; *(q->pim) = 2.3 ; }
上記プログラムのメモリの格納イメージ図を以下に示す。

2分木の生成
先週に2分木に対する再帰などを交えたプログラムの説明をしたので、 今週は木の生成について、AVL木などを交えて説明。 後半は、情報処理センターで演習。
#include <stdio.h> #include <stdlib.h> // 2分木の宣言 struct Tree { int data ; struct Tree* left ; struct Tree* right ; } ; // 木の根 struct Tree* top = NULL ; // 木のノードを構築する補助関数 struct Tree* tcons( int x , struct Tree* l , struct Tree* r ) { struct Tree* n ; n = (struct Tree*)malloc( sizeof( struct Tree ) ) ; if ( n != NULL ) { n->data = x ; n->left = l ; n->right = r ; } return n ; } // 木を表示する補助関数。枝の左右がわかる様に... void print( struct Tree* p ) { if ( p == NULL ) { printf( "x" ) ; } else { printf( "(" ) ; print( p->left ) ; printf( " %d " , p->data ) ; print( p->right ) ; printf( ")" ) ; } } // 木に1件のデータを追加する補助関数 void entry( int x ) { struct Tree** ppt = &top ; while( (*ppt) != NULL ) { if ( (*ppt)->data == x ) break ; else if ( (*ppt)->data > x ) ppt = &( (*ppt)->left ) ; else ppt = &( (*ppt)->right ) ; } if ( *ppt == NULL ) (*ppt) = tcons( x , NULL , NULL ) ; } int main() { entry( 51 ) ; entry( 26 ) ; entry( 76 ) ; entry( 60 ) ; print( top ) ; // ((x 26 x) 51 ((x 60 x) 76 x)) top = NULL ; entry( 26 ) ; entry( 51 ) ; entry( 60 ) ; entry( 76 ) ; print( top ) ; // (x 26 (x 51 (x 60 (x 76 x)))) }
再帰関数の処理と再帰方程式
前回の授業で、処理速度のオーダ記法について解説し、 実際例と処理速度の問題について解説する。 後半は、再帰呼び出しの処理速度見積もりのための再帰方程式を示す。
特殊な処理時間の見積もり
前回授業の最後に検討しておくようにと伝えた、 の処理時間のオーダについて、Nが巨大となった時の、最大項を見つければ良いことから、
が、N→∞において 発散するのか収束するのかを求める方法にて説明する。
また、2つのアルゴリズムがNの増加で処理時間が変化する時の事例として、 「データ件数が10件で、最大選択ソートが10msec、クイックソートが20msec出会った場合、 データ100件では、どちらが速いか?、この結果から、 データ件数がいくつ以上であれば、どちらの方法が良いか?」 といった問題について説明する。
最大選択ソートであれば、 より、
であるとする。 一方、クイックソートは、
より、
であるとする。
より、
。
より、
。
これより、データ100件の場合には、 となる。 また、
となりクイックソートの方が速い。
さらに、 となるNを求めれば、N件以上は クイックソートが速い…といった件数を求められる。
再帰関数と再帰方程式
再帰関数は、自分自身の処理の中に「問題を小さくした」自分自身の呼び出しを含む関数。
// 階乗 int fact( int x ) { if ( x <= 1 ) return 1 ; else return x * fact( x-1 ) ; } // ピラミッド体積 int pyra( int x ) { if ( x <= 1 ) return 1 ; else return x*x + pyra( x-1 ) ; } // フィボナッチ数列 int fib( int x ) { if ( x <= 2 ) return 1 ; else return fib( x-1 ) + fib( x-2 ) ; }
これらの関数の結果について考えるとともに、この計算の処理時間を説明する。 最初のfact(),pyra()については、 x=1の時は、関数呼び出し,x<=1,return といった一定の処理時間を要し、 で表せる。 x>1の時は、関数呼び出し,x<=1,*,x-1,returnの処理に加え、x-1の値で再帰の処理時間がかかる。 このことから、
で表せる。 これを代入によって解けば、
で表せる。 こういった式は、再帰方程式と呼ばれ、一般式を求めることとなる。
最後に、再帰方程式の事例として、ハノイの塔の処理時間について説明し、 数学的帰納法での証明を示す。
ハノイの塔
ハノイの塔は、3本の塔にN枚のディスクを積み、ディスクの上により大きいディスクを積まずに、移動させるパズル。 ハノイの塔の移動回数を とした場合、 少ない枚数での回数の考察から、
ということが予想される。
この予想が常に正しいことを証明するために、ハノイの塔の処理を、 最も下のディスク1枚と、その上の(N-1)枚のディスクに分けて考える。 これより、
ということが言える。 ディスクが 枚で、予想が正しいと仮定すると、
枚では、
となり、 枚でも、予想が正しいことが証明された。 よって数学的帰納法により、1枚以上で予想が常に成り立つことが証明できた。