メモリ管理・スタック領域とヒープ領域
ここまでの授業では、プログラムを動かすうえでアルゴリズムとデータ構造を中心に話をしてきた。しかしプログラムの中で利用しているデータがどういったメモリで管理されているのかを正しく理解する必要がある。そこで、局所変数のようなデータを保存するためのスタック領域と、new 命令で確保されるヒープ領域についてその使われ方などについて理解する。
C言語やJavaでのメモリ領域(静的領域とスタック領域)
C言語では、データ領域は、定数領域、静的変数領域、スタック領域、ヒープ領域 で構成される。また、変数にはスコープという変数が使える範囲がある。
定数領域は、値が変化しないデータが保存される。
静的変数領域は、プログラムが起動したときに確保され、プログラム終了と共にデータが消される。
スタック領域は、関数が呼び出される時に確保され、関数を抜ける時にデータが消される。関数の引数や関数の局所変数などは、この領域に保存される。
#include <stdio.h> int x = 123 ; // 静的大域変数 const int y = 234 ; // 静的大域変数(定数) 再代入不可 void foo() { int b = 345 ; // 動的局所変数 b++ ; printf( "%d %d\n" , x , b ) ; } void bar( int a ) { int b = 456 ; // 動的局所変数 static int c = 789 ; // 静的局所変数 x++ ; b++ ; c++ ; printf( "%d %d %d\n" , x , b , c ) ; foo() ; } int main() { int z = 890 ; // 動的局所変数 bar( z ) ; bar( z ) ; printf( "%d\n" , z ) ; return 0 ; } // 実行結果 // 124 457 790 // 124 346 // 125 457 791 // 125 346 // 890
大域変数は混乱の元
以下のようなプログラムでは、foo() を実行すると”0 1 2″ と表示され、main の中で foo() を3回呼び出しているので、”012,012,012″と表示されると勘違いするかもしれない。しかし、xが大域変数(Javaでは大域変数は無いけど)であるため、foo() の処理の中で x=3 となっているため、mainの中では、2回目のループが動かないため、”0 1 2″と表示されるだけである。
こういったように、誰もが使える変数を、どこからでも呼び出せる状態にしておくとプログラムの間違いが発生しやすい。
// C言語での大域変数の問題 int x ; void foo() { // 0 1 2 と出力 for( x = 0 ; x < 3 ; x++ ) printf( "%d\n" , x ) ; } int main() { // 0 1 2 を出力する処理を 3回繰り返すと 0 1 2,0 1 2,0 1 2 と出力される? for( x = 0 ; x < 3 ; x++ ) foo() ; return 0 ; }
// Javaでの大域変数の問題 public class Main { public static int x = 0 ; // 静的クラス変数 public static void foo() { // 0 1 2 と出力 for( x = 0 ; x < 3 ; x++ ) System.out.println( x ) ; } public static void main(String[] args) throws Exception { for( x = 0 ; x < 3 ; x++ ) // 0 1 2 を出力する処理を 3回繰り返すと 0 1 2,0 1 2,0 1 2 と出力される? foo() ; } }
こういう場合は、正しく局所変数を用いて、関数内でのみ使う変数 x を宣言すれば、上記のような間違いを防ぐことができる。関数内で宣言される変数は関数に入る度にメモリを確保し、関数を抜ける時にメモリ領域が消される。
// C言語での大域変数の解決のために局所変数を使う void foo() { // 0 1 2 と出力 int x ; for( x = 0 ; x < 3 ; x++ ) printf( "%d\n" , x ) ; } int main() { // 0 1 2 を出力する処理を 3回繰り返す int x ; for( x = 0 ; x < 3 ; x++ ) foo() ; return 0 ; }
// Javaでの大域変数の解決のために局所変数を使う public class Main { public static void foo() { // 0 1 2 と出力 int x ; for( x = 0 ; x < 3 ; x++ ) System.out.println( x ) ; } public static void main(String[] args) throws Exception { int x ; for( x = 0 ; x < 3 ; x++ ) // 0 1 2 を出力する処理を 3回繰り返す foo() ; } }
一方で、関数が呼び出された回数を確認したい…という用途であれば、下記のように大域変数を使うこともあるが、これだと x を間違って使われる可能性がある。
int x = 0 ; void foo() { x++ ; printf( "%d\n" , x ) ; } int main() { foo() ; foo() ; return 0 ; }
このために、C言語では静的局所変数というのがある。関数内で static で宣言された変数は、その関数の中でしか使えないが、プログラムが起動した時に変数領域が作られ初期化され、プログラムが終了した時にデータ領域が消される。
void foo() { static int x = 0 ; x++ ; printf( "%d\n" , x ) ; } int main() { foo() ; foo() ; }
Javaでは、プログラムの中でデータが間違ってアクセスされることを防ぐために、大域変数という考え方は存在しない。その代わりにクラス内で共通に利用できる変数ということで、静的なクラス変数が用いられる。static なクラス変数は、クラスがロードされた時点でメモリに確保され、プログラム終了まで保持される。
public class MyClass { public static int count = 0; // クラス変数 public static void main(String[] args) { MyClass.count++; // インスタンスを作らずにアクセス System.out.println(MyClass.count); } }
スタック領域
スタック領域は、ここまでに述べた様に「関数を呼び出す際にメモリ領域が確保・初期化され、関数が終わるとメモリ領域は消される。
以下のような main から bar() , foo() が呼び出される処理では、
- 関数呼び出し時には、戻り番地の保存、実引数の確保が行われ、
- 関数に入った時点で局所変数の領域が確保される。
- 関数が終わると、実引数・局所変数の領域が消され、スタックから取り出された戻り番地に処理を移行する。
このような関数呼び出しでは、最後(Last)に確保した変数が最初(First)に忘れればいいというデータなので、Last In First Out の スタック構造が使われる。
ヒープ領域
リスト処理のようなプログラムでは、データを覚える領域は、関数が終わった後も使われる領域なので、局所変数のように「関数が終わったらそのデータの場所が不要になる」といったLast In First Out のようなスタックで管理することは難しい。
データを確保したメモリがどの時点まで使われるのか解らない場合、スタック構造を使うことはできない。こういったデータは、ヒープメモリ(ヒープ領域)を用いる。
C言語であれば、ヒープメモリの場所の確保には malloc() 関数が用いられ、不要となった時に free() 関数でメモリの開放が必要である。free() を忘れたプログラムが、ずっと動いた状態になると再利用されないメモリ領域が発生(メモリリーク)し、その領域が大量になると、他のプログラムに悪影響がでてくる。(最悪、仮想メモリの利用でスワッピングが多発するかもしれない)
#include <stdio.h> #include <stdlib.h> struct A { // class A { int data ; // int data ; } ; // } int main() { struct A * ptr = (struct A*)malloc( sizeof( struct A ) ) ; // ptr = new A ; ptr->data = 123 ; // ptr.data = 123 ; free( ptr ) ; // Javaでは free() は不要 return 0 ; }
共有の発生したデータの扱い
C言語では、ヒープメモリの管理するには色々と複雑なことが発生する。
例えば、以下のようなリストの和集合のプログラムをC言語とJavaで示す。
このプログラムでは、リスト a, b の和集合を c に代入している。また、不要となったリストを捨てるために list_free() という関数を作成している。ただし Java では、不要となったメモリ領域の開放は不要だが、C言語との対比のために next に null を代入する処理で代用してある。
#include <stdio.h> #include <stdlib.h> struct List { int data ; struct List* next ; } ; struct List* newListNode( int x , struct List* p ) { struct List* ans = (struct List*)malloc( sizeof( struct List ) ) ; if ( ans != NULL ) { ans->data = x ; ans->next = p ; } return ans ; } void list_print( struct List* p ) { for( ; p != NULL ; p = p->next ) printf( "%d " , p->data ) ; printf( "\n" ) ; } int find( struct List* p , int key ) { for( ; p != NULL ; p = p->next ) if ( p->data == key ) return 1 ; return 0 ; } struct List* list_union( struct List* a , struct List* b ) { struct List* ans = a ; for( ; b != NULL ; b = b->next ) if ( !find( a , b->data ) ) ans = newListNode( b->data , ans ) ; return ans ; } void list_free( struct List* p ) { if ( p != NULL ) { list_free( p->next ) ; printf( "*%d " , p->data ) ; free( p ) ; } } int main(void){ struct List* a = newListNode( 11 , newListNode( 22 , NULL ) ) ; struct List* b = newListNode( 11 , newListNode( 33 , NULL ) ) ; struct List* c = list_union( a , b ) ; list_print( a ) ; list_print( b ) ; list_print( c ) ; list_free( c ) ; list_free( b ) ; list_free( a ) ; return 0 ; // free(): double free detected in tcache 2 // Aborted (core dumped) }
import java.util.*; class ListNode { int data ; ListNode next ; ListNode( int x , ListNode p ) { this.data = x ; this.next = p ; } } public class Main { public static void list_print( ListNode p ) { for( ; p != null ; p = p.next ) System.out.print( p.data + " " ) ; System.out.println() ; } public static boolean find( ListNode p , int key ) { for( ; p != null ; p = p.next ) if ( p.data == key ) return true ; return false ; } public static ListNode list_union( ListNode a , ListNode b ) { ListNode ans = a ; for( ; b != null ; b = b.next ) if ( !find( a , b.data ) ) ans = new ListNode( b.data , ans ) ; return ans ; } public static void list_free( ListNode p ) { if ( p != null ) { list_free( p.next ) ; System.out.print( "*" + p.data + " " ) ; p.next = null ; } } public static void main(String[] args) throws Exception { ListNode a = new ListNode( 11 , new ListNode( 22 , null ) ) ; ListNode b = new ListNode( 11 , new ListNode( 33 , null ) ) ; ListNode c = list_union( a , b ) ; // 33,11,22 list_print( a ) ; list_print( b ) ; list_print( c ) ; list_free( c ) ; System.out.println() ; list_free( b ) ; System.out.println() ; list_free( a ) ; } }
このプログラムを実行すると、c には和集合のリストが出来上がるが、33の先のデータは a とデータの一部を共有している。
この状態で、リスト全体を消すための list_free(c); list_free(b); list_free(a); を実行すると、list_free(c) の時点で a の先のリストは解放されている。このため、list_free(a) を実行すると、解放済みのデータ領域をさらに解放する処理が行われるが、すでに存在していないデータを消す処理が実行できない。
C言語のプログラムを動かすと、プログラム実行時にエラーが発生する。しかし、Java で書かれた「ほぼ同様」のプログラムは問題なく動作する。
参照カウンタ法
上記の問題は、a の先のリストが c の一部とデータを共有しているために発生する。この解決方法として簡単な方法では、参照カウンタ法が用いられる。
参照カウンタ法では、データを参照するポインタの数をデータと共に保存する。
- データの中にポインタ数を覚える参照カウンタを設け、データを生成した時に1とする。
- 処理の中で共有が発生すると、参照カウンタをカウントアップする。
- データを捨てる際には、参照カウンタをカウントダウンし、0になったら本当にそのデータを消す。
// 参照カウンタの説明用プログラム class ListNode { int refc ; // 参照カウンタ int data ; // データ ListNode next ; // 次のポインタ ListNode( int x , ListNode p ) { this.refc = 0 ; this.data = x ; this.next = p ; } } ; public class Main { public static ListNode copy( ListNode p ) { p.refc++ ; // 共有が発生したら参照カウンタを増やす。 return p ; } // 集合和を求める処理 public static ListNode list_union( ListNode a , ListNode b ) { ListNode ans = copy( a ) ; // ~~~~~~~~~共有が発生するのでrefc++ for( ; b != null ; b = b.next ) if ( !find( ans , b.data ) ) ans = new ListNode( b.data , ans ) ; return ans ; } public static void list_del( ListNode p ) { // 再帰で全廃棄 if ( p != null && --(p.refc) <= 0 ) { // 参照カウンタを減らし // ~~~~~~~~~~ list_del( p.next ) ; // 0ならば本当に消す free( p ) ; }//~~~~~~~~~ Javaでは存在しない関数(説明用) } public static void main(String[] args) throws Exception { ListNode a = new ListNode( 11 , new ListNode( 22 , null ) ) ; ListNode b = new ListNode( 11 , new ListNode( 33 , null ) ) ; ListNode c = list_union( a , b ) ; // a,b,cを使った処理 // 処理が終わったのでa,b,cを捨てる list_del( c ) ; list_del( b ) ; list_del( a ) ; }
ただし、Java ではこういった処理を記述しなくても、内部で参照カウンタ法を実行しているため、このような処理を書く必要はない。
unix i-nodeで使われている参照カウンタ
unixのファイルシステムの基本的構造 i-node では、1つのファイルを別の名前で参照するハードリンクという機能がある。このため、ファイルの実体には参照カウンタが付けられている。unix では、ファイルを生成する時に参照カウンタを1にする。ハードリンクを生成すると参照カウンタをカウントアップ”+1″する。ファイルを消す場合は、基本的に参照カウンタのカウントダウン”-1″が行われ、参照カウンタが”0″になるとファイルの実体を消去する。
以下に、unix 環境で 参照カウンタがどのように使われているのか、コマンドで説明していく。
$ echo a > a.txt $ ls -al *.txt -rw-r--r-- 1 t-saitoh t-saitoh 2 12月 21 10:07 a.txt ~~~ # ここが参照カウンタの値 $ ln a.txt b.txt # ハードリンクでコピーを作る $ ls -al *.txt -rw-r--r-- 2 t-saitoh t-saitoh 2 12月 21 10:07 a.txt -rw-r--r-- 2 t-saitoh t-saitoh 2 12月 21 10:07 b.txt ~~~ # 参照カウンタが増えているのが分かる $ rm a.txt # 元ファイルを消す $ ls -al *.txt -rw-r--r-- 1 t-saitoh t-saitoh 2 12月 21 10:07 b.txt ~~~ # 参照カウンタが減っている $ ln -s b.txt c.txt # シンボリックリンクでコピーを作る $ ls -al *.txt -rw-r--r-- 1 t-saitoh t-saitoh 2 12月 21 10:07 b.txt lrwxrwxrwx 1 t-saitoh t-saitoh 5 12月 21 10:10 c.txt -> b.txt $ rm b.txt # 元ファイルを消す $ ls -al *.txt lrwxrwxrwx 1 t-saitoh t-saitoh 5 12月 21 10:10 c.txt -> b.txt $ cat c.txt # c.txt は存在するけどその先の実体 b.txt は存在しない cat: c.txt: そのようなファイルやディレクトリはありません
ハッシュ法
ここまでの授業では、配列(データ検索は、登録順保存ならO(N)、2分探索ならO(log N)となる、2分探索ができるのは配列がランダムアクセスができるからこそ)、単純リスト(データ検索(シーケンシャルアクセスしかできないのでO(N)となる)、2分探索木( O(log N) ) といった手法を説明してきた。しかし、もっと高速なデータ検索はできないのであろうか?
究極のシンプルなやり方(メモリの無駄)
最も簡単なアルゴリズムは、電話番号から名前を求めるようなデータベースであれば、電話番号自身を配列添え字番号とする方法がある。しかしながら、この方法は大量のメモリを必要とする。
import java.util.*; class PhoneName { int phone ; // (例) 27-2925 String name ; PhoneName( int ph , String nm ) { this.phone = ph ; this.name = nm ; } } public class Main { public static PhoneName[] table ; public static void entry( int ph , String nm ) { table[ ph ] = new PhoneName( ph , nm ) ; } public static String find( int ph ) { return table[ ph ].name ; } public static void main(String[] args) throws Exception { table = new PhoneName[ 1000000 ] ; // 無駄にでかい entry( 272925 , "tsaitoh" ) ; entry( 621111 , "nit-fukui") ; entry( 123456 , "forger" ) ; System.out.println( find( 621111 ) ) ; } }
しかし、50人程度のデータであれば、電話番号の末尾2桁を取り出した場合、同じ数値の人がいることは少ないであろう。であれば、電話番号の末尾2桁の値を配列の添え字番号として、データを保存すれば、配列サイズは100件となり、メモリの無駄を減らすことができる。
ハッシュ法
先に述べたように、データの一部を取り出して、それを配列の添え字番号として保存することで、高速にデータを読み書きできるようにするアルゴリズムはハッシュ法と呼ばれる。データを格納する表をハッシュ表、データの一部を取り出した添え字番号はハッシュ値、ハッシュ値を得るための関数がハッシュ関数と呼ばれる。
import java.util.*; class PhoneName { int phone ; // 27-2925 String name ; PhoneName( int ph , String nm ) { this.phone = ph ; this.name = nm ; } } public class Main { public static PhoneName[] table ; public static void entry( int ph , String nm ) { table[ ph ] = new PhoneName( ph , nm ) ; } public static String find( int ph ) { return table[ ph ].name ; } public static void main(String[] args) throws Exception { table = new PhoneName[ 1000000 ] ; entry( 272925 , "tsaitoh" ) ; entry( 621111 , "nit-fukui") ; entry( 123456 , "forger" ) ; System.out.println( find( 621111 ) ) ; } }
ただし、上記のプログラムでは、電話番号の末尾2桁が偶然他の人と同じになることを考慮していない。
例えば、データ件数が100件あれば、同じ値の人も出てくるであろう。このように、異なるデータなのに同じハッシュ値が求まることを、ハッシュ衝突と呼ぶ。
ハッシュ関数に求められる特性
ハッシュ関数は、できる限り同じような値が求まるものは、ハッシュ衝突が多発するので、避けなければならない。例えば、6桁の電話番号の先頭2桁であれば、電話番号の局番であり、同じ学校の人でデータを覚えたら、同じ地域の人でハッシュ衝突が発生してしまう。また、ハッシュ値を計算するのに、配列の空き場所を一つ一つ探すような方式では、データ件数に比例した時間がかかり、高速なアルゴリズムとは言えない。このことから、ハッシュ関数には以下のような特徴が必要となる。
- 同じハッシュ値が発生しづらい(一見してデタラメのように見える値)
- 簡単な計算で求まること。
- 同じデータに対し常に、同じハッシュ値が求まること。
ここで改めて、異なるデータでも同じハッシュ値が求まった場合、どうすれば良いのだろうか?
ハッシュ法を簡単なイメージで説明すると、100個の椅子(ハッシュ表)が用意されていて、1クラスの学生が自分の電話番号の末尾2桁(ハッシュ関数)の場所(ハッシュ値)に座るようなもの。自分のイスに座ろうとしたら、同じハッシュ値の人が先に座っていたら、どこに座るべきだろうか?
オープンアドレス法
先の椅子取りゲームの例え話であれば、先に座っている人がいた場合、最も簡単な椅子に座る方法は、隣が空いているか確認して空いていたらそこに座ればいい。
これをプログラムにしてみると、以下のようになる。このハッシュ法は、求まったアドレスの場所にこだわらない方式でオープンアドレス法と呼ばれる。
import java.util.*; class PhoneName { int phone ; // 27-2925 String name ; PhoneName( int ph , String nm ) { this.phone = ph ; this.name = nm ; } } public class Main { public static PhoneName[] table ; public static int hash_func( int ph ) { return ph % 100 ; } public static void entry( int ph , String nm ) { int idx = hash_func( ph ) ; while( table[ idx ] != null ) idx = (idx + 1) % 100 ; table[ idx ] = new PhoneName( ph , nm ) ; } public static String find( int ph ) { int idx = hash_func( ph ) ; for( ; table[ idx ] != null ; idx = (idx + 1) % 100 ) if ( table[ idx ].phone == ph ) return table[ idx ].name ; return null ; } public static void main(String[] args) throws Exception { table = new PhoneName[ 100 ] ; entry( 272925 , "tsaitoh" ) ; entry( 621111 , "nit-fukui") ; entry( 123425 , "forger" ) ; System.out.println( find( 272925 ) ) ; System.out.println( find( 123425 ) ) ; } }
注意:このプログラムは、ハッシュ表すべてにデータが埋まった場合、無限ループとなるので、実際にはもう少し改良が必要である。
この実装方法であれば、ハッシュ表にデータが少ない場合は、ハッシュ値を計算すれば終わり。よって、処理時間のオーダはO(1)となる。しかし、ハッシュ表がほぼ埋まっている状態だと、残りわずかな空き場所を探すようなもの。
文字列のハッシュ値
ここまでで説明した事例は、電話番号をキーとするものであり、余りを求めるだけといったような簡単な計算で、ハッシュ値が求められた。しかし、一般的には文字列といったような名前から、ハッシュ値が欲しいことが普通だろう。
ハッシュ値は、簡単な計算で、見た目デタラメな値が求まればいい。 (ただしく言えば、ハッシュ値の出現確率ができるだけ一様であること)。一見規則性が解らない値として、文字であれば文字コードが考えられる。複数の文字で、これらの文字コードを加えるなどの計算をすれば、 偏りの少ない値を取り出すことができる。
public static int hash_func( String nm ) { int s = 0 ; for( int i = 0 ; i < nm.length() ; i++ ) s += nm.charAt( i ) ; return s % 100 ; }
文字列順で異なる値となるように
前述のハッシュ関数は、”ABC”さんと”CBA”さんでは、同じハッシュ値が求まってしまう。文字列順で異なる値が求まるように改良してみる。
public static int hash_func( String nm ) { int s = 0 ; for( int i = 0 ; i < nm.length() ; i++ ) s += (nm.charAt( i ) + s * 小さい素数) % 大きい素数 ; return s % 100 ; }
以下の方法は、繰り返しの度に s に小さい素数を掛けることで、数値全体に文字の影響がでるようにしている。これだけだと計算途中の s の値が最終的な100個に収めるための “% 100” で下2桁に影響がでないことから、大きい素数で余りを求めてみた。この計算方法は、疑似乱数を生み出す線形合同法の考え方を参考にした。
チェイン法
前に述べたオープンアドレス法は、ハッシュ衝突が発生した場合、別のハッシュ値を求めそこに格納する。配列で実装した場合であれば、ハッシュ表のサイズ以上の データ件数を保存することはできない。
チェイン法は、同じハッシュ値のデータをグループ化して保存する方法。 同じハッシュ値のデータは、リスト構造とするのが一般的。ハッシュ値を求めたら、そのリスト構造の中からひとつづつ目的のデータを探す処理となる。
この処理にかかる時間は、データ件数が少なければ、O(1) となる。しかし、ハッシュ表のサイズよりかなり多いデータ件数が保存されているのであれば、ハッシュ表の先に平均「N/ハッシュ表サイズ」件のデータがリスト構造で並んでいることになるので、O(N) となってしまう。
import java.util.*; class PhoneNameNode { int phone ; // 27-2925 String name ; PhoneNameNode next ; PhoneNameNode( int ph , String nm , PhoneNameNode nx ) { this.phone = ph ; this.name = nm ; this.next = nx ; } } public class Main { public final static int table_size = 100 ; public static PhoneNameNode[] table ; public static int hash_func( int ph ) { return ph % table_size ; } public static void entry( int ph , String nm ) { int idx = hash_func( ph ) ; table[ idx ] = new PhoneNameNode( ph , nm , table[ idx ] ) ; } public static String find( int ph ) { int idx = hash_func( ph ) ; for( PhoneNameNode p = table[ idx ] ; p != null ; p = p.next ) if ( p.phone == ph ) return p.name ; return null ; } public static void main(String[] args) throws Exception { table = new PhoneNameNode[ table_size ] ; for( int i = 0 ; i < table_size ; i++ ) table[ i ] = null ; entry( 521125 , "tomoko" ) ; entry( 272925 , "saitoh" ) ; entry( 621160 , "mike" ) ; System.out.println( find( 272925 ) ) ; System.out.println( find( 521125 ) ) ; } }
理解度確認
毎年、冬休み期間中の自主的な理解度確認として、CBT を用いた理解度確認を行っています。今年も実施しますので、下記のシステムにログインし情報構造論では「ソフトウェア」(50分) を受講して下さい。
- https://cbt.kosen-ac.jp/
- 認証には、MS-365 のアカウントとパスワードでログインしてください。
データベースとB木
2分探索木の考え方を拡張したものでB木があり、データベースシステムではB木を基本としたデータ構造が活用されている。
B木の構造
2分木では、データの増減で木の組換えの発生頻度が高い。そこで、1つのノード内に複数のデータを一定数覚える方法をとる。B木では、位数=Nに対し、最大2N個のデータ d0, … , d2N-1 と、2N+1本のポインタ p0, … , p2N から構成される。pi の先には、di-1< x < di を満たすデータが入った B木のノードを配置する。ただし、データの充填率を下げないようにするため、データは最小でもN個、最大で2N個を保存する。下図は位数2のB木の例を示す。
B木からデータの検索
データを探す場合は、ノード内のデータ di の中から探し、見つからない場合は、ポインタの先のデータを探す。位数がある程度大きい場合、ノード内の検索は2分探索法が使用できる。また、1つのノード内の検索が終われば、探索するデータ件数は、1/N〜1/2Nとなることから、指数的に対象件数が減っていく。よって、検索時間のオーダは、O( log N ) となる。
B木へのデータの追加
B木にデータを追加する場合は、ノード内に空きがあれば、単純にデータの追加を行う。ノード内のデータが2N個を越える場合は、以下のような処理を行う。
ノード内のデータと追加データを並べ、その中央値を選ぶ。この中央値より大きいデータは、新たにつくられたノードに移す。中央値のデータは上のノードに追加処理を行う。このような方法を取ることで、2分木のような木の偏りが作られにくい構造となるようにする。
データを削除する場合も同様に、データ件数がN個を下回る場合は、隣接するノードからデータを取ってくることで、N個を下回らないようにする。
B木とデータベース
このB木の構造は、一般的にデータベースのデータを保存するために広く利用されている。
データベースシステムでは、データを効率よく保存するだけでなく、データの一貫性が保たれるように作られている。
例えば、データベースのシステムが途中でクラッシュした場合でも、データ更新履歴の情報を元にデータを元に戻し、データを再投入して復旧できなければならない。データを複数の所からアクセスした場合に、その順序から変な値にならないように、排他制御も行ってくれる。
データベースで最も使われているシステムは、データすべてを表形式で扱うリレーショナル・データベースである。
((リレーショナル・データベースの例)) STUDENT[] RESULT[] ID | name | grade | course ID | subject | point -----+----------+-------+-------- -----+---------+------- 1001 | t-saitoh | 5 | EI 1001 | math | 83 1002 | sakamoto | 4 | E 1001 | english | 65 1003 | aoyama | 4 | EI 1002 | english | 90 外部キー ((SQLの例 2つの表の串刺し)) -- 60点以上の学生名,科目名,点数を出力 -- select STUDENT.name, RESULT.subject, RESULT.point --射影-- from STUDENT , RESULT --結合-- where STUDENT.ID = RESULT.ID -- 串刺し -- --選択-- and RESULT.point >= 60 ; ((上記SQLを Java で書いた場合)) STUDENT[] student = { ... } ; RESULT[] result = { ... } ; for( int st = 0 ; st < student.length ; st++ ) // 結合(from) for( int re = 0 ; re < result.length ; re++ ) if ( student[ st ].ID == result[ re ].ID // 選択(where) && result[ re ].point >= 60 ) System.out.println( student[ st ].name + " " // 射影(select) + result[ re ].subject + " " + result[ re ].point ) ;
- 学生と成績(Paiza.ioでSQL)
- Javaで書いたデータベースの串刺し
B+木
データベースの処理では、目的のデータを O(log N) で見つける以外にも、全データに対する処理も重要である。この場合、全てのデータに対する処理では、単純なB木では再帰呼び出しが必要となる。しかし、他の表でも再帰処理を伴うと、プログラムは複雑になってしまう。
そこで、B木のデータを横方向に並べて処理を行う場合に、その処理が簡単になるように B+木が用いられる。
この方法では、末端のノードは、隣接するノードへのポインタを持つ。下図で示すB+木では、青で示す検索用のB木の部分と、赤で示す順次処理を行うためのシーケンスセットの部分から構成される。
木構造の探索・深さ優先探索・幅優先探索
深さ優先探索(前順)
以前紹介した2分木の表示では、再帰呼び出しで木の処理を記述していた。
しかし、スタックを使って未処理のノードを保存しながらノードに対する処理を行えば、ループ処理で「すべての木のノード」に対する処理を記述できる。このような木に対する処理は、末端方向に処理が進むことが優先されるため深さ優先探索と呼ぶ。
以下のプログラムで示す深さ優先探索では、ノードに対する処理を行ってから左の枝葉の処理が行われる。このような深さ優先探索は前順(pre-order)と呼ぶ。
このような探索は、ゲームですべての戦略の先読みを行う処理で用いられる。
import java.util.*; class BTreeNode { int data ; BTreeNode left ; BTreeNode right ; BTreeNode( int x , BTreeNode l , BTreeNode r ) { this.data = x ; this.left = l ; this.right = r ; } } public class Main { // 再帰呼び出しによる木の操作 = 深さ優先探索(前順) public static void print_rec_depth_first_pre_order( BTreeNode p ) { if ( p != null ) { System.out.print( p.data + " " ) ; print_rec_depth_first_pre_order( p.left ) ; print_rec_depth_first_pre_order( p.right ) ; } } // 深さ優先探索(前順) public static void print_depth_first_pre_order( BTreeNode p ) { // 未処理ノードを保存するスタック LinkedList stack = new LinkedList<>() ; stack.addFirst( p ) ; // push while( !stack.isEmpty() ) { // スタックトップを取り出す p = stack.removeFirst() ; // pop if ( p != null ) { System.out.print( p.data + " " ) ; // 枝を未処理スタックに保存 stack.addFirst( p.right ) ; // 右を後回し stack.addFirst( p.left ) ; } } } public static void main(String[] args) throws Exception { BTreeNode top = new BTreeNode( 42 , new BTreeNode( 27 , new BTreeNode( 13 , null , null ) , new BTreeNode( 38 , null , null ) ) , new BTreeNode( 72 , new BTreeNode( 64 , null , null ) , new BTreeNode( 81 , null , null ) ) ) ; // 再帰による深さ優先探索(前順) print_rec_depth_first_pre_order( top ) ; System.out.println() ; // スタックを使った深さ優先探索(前順) print_depth_first_pre_order( top ) ; System.out.println() ; } }
- 2分探索木の深さ優先探索、幅優先探索(Paiza.io)
幅優先探索
同じように、未処理のノードを待ち行列に保存しながら、ノードに対する処理を行うことでも、すべての木に対する処理が記述できる。この場合、根本の処理をすべて終えてから、末端に対する処理が行われる。このような処理は幅優先探索と呼ぶ。
幅優先探索の処理と、前順の深さ優先探索を比べると、ノードを覚えるデータ構造に待ち行列を使う(幅優先探索)か、スタックを使う(深さ優先探索)の違いしかない。
![]() |
![]() |
幅優先探索は、ゲームの戦略で先読みを行う場合に用いられるが、チェス・将棋・囲碁といったすべての先読みを行うと手数が爆発的に増加するため、不利な戦略の先読みを途中で打ち切る必要がある場合に有用である。
public class Main { // 幅優先探索 public static void print_breadth_first( BTreeNode p ) { // 未処理ノードを保存する待ち行列 LinkedList queue = new LinkedList<>() ; queue.addFirst( p ) ; // put while( !queue.isEmpty() ) { // 待ち行列の先頭を取り出す p = queue.removeLast() ; // get if ( p != null ) { System.out.print( p.data + " " ) ; // 枝を未処理待ち行列に追加 queue.addFirst( p.left ) ; // 左から処理 queue.addFirst( p.right ) ; } } } public static void main(String[] args) throws Exception { BTreeNode top = (略) ; // 幅優先探索で表示 print_breadth_first( top ) ; System.out.println() ; } }
深さ優先探索(中順)
2分探索木のようなデータで、昇順で並べて表示する必要がある場合、再帰呼び出しのプログラムでは、「左枝の処理、ノードへの処理、右枝の処理」の順で記述する必要がある。このような深さ優先探索は中順(in-order)と呼ばれる。しかし、先に説明した再帰による深さ優先探索(前順/pre-order)では、「ノードへの処理、左枝の処理、右の枝の処理」で記述されている。
スタックを用いた深さ優先探索で、2分探索木の昇順のように中順で処理を行うには、未処理のノードの保存順序に工夫が必要となる。
import java.util.*; public class Main { // 再帰呼び出しによる木の操作 = 深さ優先探索(中順) public static void print_rec_depth_first_in_order( BTreeNode p ) { if ( p != null ) { print_rec_depth_first_in_order( p.left ) ; System.out.print( p.data + " " ) ; print_rec_depth_first_in_order( p.right ) ; } } // 深さ優先探索(中順) public static void print_depth_first_in_order( BTreeNode p ) { // 未処理ノードを保存するスタック LinkedList stack = new LinkedList<>() ; for( ;; ) { // ノードを保存しながら左末端まで進む while( p != null ) { stack.addFirst( p ) ; p = p.left ; } // 未処理が残っていないなら終了 if ( stack.isEmpty() ) break ; // スタックトップを取り出す p = stack.removeFirst() ; // pop System.out.print( p.data + " " ) ; p = p.right ; } } public static void main(String[] args) throws Exception { BTreeNode top = (略) ; // 再帰による深さ優先探索(中順) print_rec_depth_first_in_order( top ) ; System.out.println() ; // スタックを使った深さ優先探索(中順) print_depth_first_in_order( top ) ; System.out.println() ; } }
2分木による構文木
コンパイラの処理の流れ
構文の構造を表すために、2分木を使うという話をこの後に行うが、その前にコンパイラが機械語を生成するまでの処理の流れについて説明をする。
Cコンパイラのソース ↓ プリプロセッサ (#define,#includeなどの処理) ↓ コンパイラ ・字句解析(ソースコードをトークンに切り分ける) ・構文解析(トークンから構文木を生成) ・最適化(命令を効率よく動かすために命令を早い命令に書き換え) ・コード生成(構文木から中間コードを生成) | | リンカでライブラリと結合 (+)←---ライブラリ ↓ 機械語
バイトコードインタプリタ
通常、コンパイラとかインタプリタの説明をすると、Java がコンパイラとか、JavaScript はインタプリタといった説明となる。しかし、最近のこういった言語がどのように処理されるのかは、微妙である。
(( Java の場合 )) foo.java (ソースコード) ↓ Java コンパイラ foo.class (中間コード) ↓ JRE(Java Runtime Engine)の上で 中間コードをインタプリタ方式で実行
あらかじめコンパイルされた中間コードを、JREの上でインタプリタ的に実行するものは、バイトコードインタプリタ方式と呼ぶ。
ただし、JRE でのインタプリタ実行では遅いため、最近では JIT コンパイラ(Just-In-Time Compiler)により、中間コードを機械語に変換してから実行する。
また、JavaScriptなどは(というか最近のインタプリタの殆どPython,PHP,Perl,…は)、一般的にはインタプリタに分類されるが、実行開始時に高級言語でかかれたコードから中間コードを生成し、そのバイトコードをインタプリタ的に動かしている。
しかし、インタプリタは、ソースコードがユーザの所に配布されて実行するので、プログラムの内容が見られてしまう。プログラムの考え方が盗まれてしまう。このため、変数名を短くしたり、空白を除去したり(…部分的に暗号化したり)といった難読化を行うのが一般的である。
字句解析と構文解析
トークンと正規表現(字句解析)
字句解析でトークンを表現するために、規定されたパターンの文字列を表現する方法として、正規表現(regular expression)が用いられる。
((正規表現の書き方)) 選言 「abd|acd」は、abd または acd にマッチする。 グループ化 「a(b|c)d」は、真ん中の c|b をグループ化 量化 パターンの後ろに、繰り返し何回を指定 ? 直前パターンが0個か1個 「colou?r」 * 直前パターンが0個以上繰り返す 「go*gle」は、ggle,gogle,google + 直前パターンが1個以上繰り返す 「go+gle」は、gogle,google,gooogle
正規表現は、sed,awk,Perl,PHPといった文字列処理の得意なプログラム言語でも利用できる。こういった言語では、以下のようなパターンを記述できる。
[文字1-文字2...] 文字コード1以上、文字コード2以下 「[0-9]+」012,31415,...数字の列 ^ 行頭にマッチ $ 行末にマッチ ((例)) [a-zA-Z_][a-zA-Z_0-9]* C言語の変数名にマッチする正規表現
構文とバッカス記法
言語の文法(構文)を表現する時、バッカス記法(BNF)が良く使われる。
((バッカス記法)) <表現> ::= <表現1...> | <表現2...> | <表現3...> | ... ;
例えば、加減乗除記号と数字だけの式の場合、以下の様なBNFとなる。
((加減乗除式のバッカス記法)) <加算式> ::= <乗算式> '+' <乗算式> 【要注意】わざと間違っている部分あり | <乗算式> '-' <乗算式> | <乗算式> ; <乗算式> ::= <数字> '*' <乗算式> | <数字> '/' <乗算式> | <数字> ; <数字> ::= [0-9]+ ;
上記のバッカス記法には、間違いがある。”1+2+3″を正しく認識できない。どこが間違っているだろうか?
このような構文が与えられた時、”1+23*456″と入力されたものを、“1,+,23,*,456”と区切る処理が、字句解析である。
また、バッカス記法での文法に合わせ、以下のような構文木を生成するのが構文解析である。
+ / \ 1 * / \ 23 456
2項演算と構文木
演算子を含む式が与えられたとして、古いコンパイラではそれを逆ポーランド変換して計算命令を生成していた。しかし最近の複雑な言語では、計算式や命令を処理する場合、その式(または文)の構造を表す2分木(構文木)を生成する。。
+ / \ 1 * / \ 2 3
演算子の木のノードで、末端は数値であることに注目し、右枝・左枝がNULLなら数値(data部にはその数値)、それ以外は演算子(data部には演算子の文字コード)として扱うとして、上記の構文木のデータを作る処理と、その構文木の値を計算するプログラムを示す。
import java.util.*; class ExpTreeNode { String op_val ; ExpTreeNode left ; ExpTreeNode right ; ExpTreeNode( String op , ExpTreeNode l , ExpTreeNode r ) { this.op_val = op ; this.left = l ; this.right = r ; } ExpTreeNode( String val ) { this.op_val = val ; this.left = null ; this.right = null ; } } public class Main { public static int eval( ExpTreeNode p ) { if ( p.left == null && p.right == null ) { return Integer.parseInt( p.op_val ) ; } else { int l_val = eval( p.left) ; int r_val = eval( p.right ) ; switch( p.op_val ) { case "+" : return l_val + r_val ; case "*" : return l_val * r_val ; } return -1 ; // error } } public static void main(String[] args) throws Exception { ExpTreeNode top = new ExpTreeNode( "+" , new ExpTreeNode( "1" ) , new ExpTreeNode( "*" , new ExpTreeNode( "2" ) , new ExpTreeNode( "3" ) ) ) ; System.out.println( eval( top ) ) ; } }
- 2分木による構文木(Paiza.io)
オブジェクト指向を使った構文木(テスト範囲外)
前述の2分木を用いた構文木では、式を構成する「整数値」の式、「式 演算子 式」の演算式の二つのパターンを、無理やり1つのクラスで扱っていた。整数値と演算子の文字列の違いがあるため、数値も文字列で保存したものを parseInt() で数値に変換している。
こういった、大きくは式と分類できるけど、数値を表す式、演算子からなる式と異なるデータの場合、Java では、オブジェクト指向プログラミングの抽象クラスと仮想関数のテクニックを用いる。式を表現する基底クラス ExpNode から、数値を表す ExpNodeInteger , 演算子からなる式を表す ExpNodeOperator を派生させ、式の値を求める際には、各クラスに応じた計算処理を行う仮想関数 eval() を使って処理を行っている。
import java.util.* ; // 抽象基底クラス abstract class ExpNode { abstract public int eval() ; } // 整数で具体化 class ExpNodeInteger extends ExpNode { int value ; ExpNodeInteger( int i ) { this.value = i ; } // 仮想関数 public int eval() { return this.value ; } } // 演算子で具体化 class ExpNodeOperator extends ExpNode { String op ; ExpNode left ; ExpNode right ; ExpNodeOperator( String s , ExpNode l , ExpNode r ) { this.op = s ; this.left = l ; this.right = r ; } // 仮想関数 public int eval() { int l_val = this.left.eval() ; int r_val = this.right.eval() ; switch( this.op ) { case "+" : return l_val + r_val ; case "*" : return l_val * r_val ; } return -1 ; } } public class Main { public static void main(String[] args) throws Exception { ExpNode top = new ExpNodeOperator( "+" , new ExpNodeInteger( 1 ) , new ExpNodeOperator( "*" , new ExpNodeInteger( 2 ) , new ExpNodeInteger( 3 ) ) ) ; System.out.println( top.eval() ) ; } }
2分木の応用(意思決定木と演算子)
データをO(log N)で検索するための2分探索木以外の2分木のデータ構造について解説を行う。
意思決定木
意思決定木の説明ということで、yes/noクイズの例を示しながら、2分木になっていることを 説明しプログラムを紹介。
((意思決定木の例:小さい子供が発熱した時)) 38.5℃以上の発熱がある? no/ \yes 元気がある? むねがひいひい? yes/ \no no/ \yes 様子をみる 氷枕で病院 解熱剤で病院 速攻で病院
このような判断を行うための情報は、yesの木 と noの木の2つの枝を持つデータである。これは2分木と同じである。左右に枝のあるものは質問であり、yesの枝もnoの枝もない末端は最終決断を表す。このようなデータ構造は意思決定木と呼ばれ、質問と決断の処理は以下のように記述ができる。
import java.util.* ; import java.util.Scanner ; class TreeNode { String qa ; TreeNode yes ; TreeNode no ; TreeNode( String s , TreeNode y , TreeNode n ) { this.qa = s ; this.yes = y ; this.no = n ; } } ; public class Main { public static void main(String[] args) throws Exception { Scanner sc = new Scanner( System.in ) ; TreeNode top = new TreeNode( "38.5℃以上の発熱がある?" , new TreeNode( "胸がひぃひぃ?" , new TreeNode( "速攻で病院" , null , null ) , new TreeNode( "解熱剤で病院" , null , null ) ) , new TreeNode( "元気がある?" , new TreeNode( "様子をみる", null , null ) , new TreeNode( "氷枕で病院", null , null ) ) ) ; TreeNode p = top ; while( p.yes != null || p.no != null ) { System.out.println( "Question:" + p.qa ) ; if ( sc.nextInt() == 1 ) { System.out.println( "yes" ) ; p = p.yes ; } else { System.out.println( "no" ) ; p = p.no ; } } System.out.println( "Answer:" + p.qa ) ; } }
- 意思決定木(Paiza.io)
struct Tree { char *qa ; struct Tree* yes ; struct Tree* no ; } ; struct Tree* dtree( char *s , struct Tree* l , struct Tree* r ) { struct Tree* n ; n = (struct Tree*)malloc( sizeof( struct Tree ) ) ; if ( n != NULL ) { n->qa = s ; n->yes = l ; n->no = r ; } return n ; } void main() { struct Tree* p = dtree( "38.5℃以上の発熱がある?" , dtree( "胸がひぃひぃ?" , dtree( "速攻で病院", NULL,NULL ) , dtree( "解熱剤で病院",NULL,NULL ) ) , dtree( "元気がある?" , dtree( "様子をみる", NULL,NULL ) , dtree( "氷枕で病院", NULL,NULL ) ) ) ; // 決定木をたどる struct Tree* d = p ; while( d->yes != NULL || d->no != NULL ) { printf( "%s¥n" , d->qa ) ; scanf( "%d" , &ans ) ; // 回答に応じてyes/noの枝に進む。 if ( ans == 1 ) // yesを選択 d = d->yes ; else if ( ans == 0 ) // noを選択 d = d->no ; } // 最終決定を表示 printf( "%s¥n" , d->qa ) ; }
2分木の応用として次週以降に式の表現の説明を行うけど、その前に計算式の一般論の説明を行う。
逆ポーランド記法
一般的に 1*2 + 3*4 と記載すると、数学的には演算子の優先順位を考慮して、(1*2)+(3*4) のように乗算を先に行う。このような優先順位を表現する時に、()を使わない方法として、逆ポーランド記法がある。
演算子の書き方には、前置記法、中置記法、後置記法があり、後置記法は、「2と3を掛ける、それに1を加える」と捉えると、日本語の処理と似ている。
中置記法 1+2*3 前置記法 +,1,*,2,3 後置記法 1,2,3,*,+ # 1と「2と3をかけた値」をたす。
後置記法は、一般的に逆ポーランド記法(Reverse Polish Notation)とも呼ばれ、式をコンピュータで実行する際の処理と似ている。
演算子の右結合・左結合
例えば、”1/2*3″という式が与えられたとする。この結果は、1/6だろうか?3/2だろうか?
一般的な数学では、優先順位が同じ演算子が並んだ場合、左側から計算を行う。つまり”1/2*3″は、”(1/2)*3″を意味する。こういった左側の優先順位が高い演算子は左結合の演算子という。
ただしC言語やJavaでは、”a = b = c = 0″ と書くと、”a = (b = (c = 0))” として扱われる。こういった代入演算子は、 右結合の演算子である。
理解度確認
以下の式を指定された書き方で表現せよ。
逆ポーランド記法 1,2,*,3,4,*,+ を中置記法で表現せよ。 中置記法 (1+2)*3-4*5 を逆ポーランド記法で表現せよ。
以前の情報処理技術者試験では、スタックの概念の理解の例題として、逆ポーランド記法への変換アルゴリズムのプログラム作成が出題されることが多かったが、最近は出題されることはなくなってきた。
2分探索木への追加とAVLと2分ヒープ
2分探索木にデータを追加
前回の2分探索木では、基本的な説明ということで、木の生成では直接木構造を生成していた。しかし、本来であれば、扱うデータに応じて木を生成することが求められる。
Javaの場合
以下の Javaのプログラムの関数 entry( … ) では、再帰を使いながら、value を追加した木を返す関数で記述した。
import java.util.*; class BTreeNode { int data ; BTreeNode left ; BTreeNode right ; BTreeNode( int x , BTreeNode l , BTreeNode r ) { this.data = x ; this.left = l ; this.right = r ; } } public class Main { public static void print_tree( BTreeNode p ) { // 木構造をわかりやすくするために()をつけて表示 if ( p != null ) { System.out.print( "(" ) ; print_tree( p.left ) ; System.out.print( p.data ) ; print_tree( p.right ) ; System.out.print( ")" ) ; } } public static BTreeNode entry( BTreeNode p , int value ) { if ( p == null ) { return new BTreeNode( value , null , null ) ; } else { if ( p.data == value ) ; // 何もしない else if ( p.data > value ) p.left = entry( p.left , value ) ; else p.right = entry( p.right , value ) ; return p ; } } public static void main(String[] args) throws Exception { BTreeNode top = null ; top = entry( top , 42 ) ; top = entry( top , 27 ) ; top = entry( top , 72 ) ; top = entry( top , 13 ) ; top = entry( top , 38 ) ; top = entry( top , 64 ) ; top = entry( top , 81 ) ; print_tree( top ) ; System.out.println() ; } } // 動作結果 // (((13)27(38))42((64)72(81)))
- 2分探索木へのデータの追加(Paiza.io)
しかしながらこのプログラムでは、以下のように小さい順序でデータを与えると、右に伸びる木が作られてしまう。
// 不均一な木ができる場合 public static void main(String[] args) throws Exception { BTreeNode top = null ; top = entry( top , 13 ) ; top = entry( top , 27 ) ; top = entry( top , 38 ) ; top = entry( top , 42 ) ; top = entry( top , 64 ) ; top = entry( top , 72 ) ; top = entry( top , 81 ) ; print_tree( top ) ; System.out.println() ; } // 動作結果 // (13(27(38(42(64(72(81)))))))
このような不均一な木が生成されてしまうと、本来であればデータ探索の処理時間は O( log N ) であるが、最悪の状態では O( N ) となってしまう。
AVL木
こういった偏った木が生成されてしまった時には、以下のような処理を加えると、不均一な状態を改善できる。
depth() 関数は与えられた木の深さを求める関数であり、左枝と右枝が大きく違う場所で 木の右回転を行う rotate_right() を実行する。
public class Main { public static int depth( BTreeNode p ) { // 木の深さを求める if ( p == null ) { return 0 ; } else { int l = depth( p.left ) ; int r = depth( p.right ) ; if ( l > r ) return 1 + l ; else return 1 + r ; } } public static BTreeNode rotate_right( BTreeNode p ) { BTreeNode q = p.left ; // p BTreeNode r = q.right ; // / \ q.right = p ; // q p.left = r ; // / \ return q ; // r } public static void main(String[] args) throws Exception { BTreeNode top = null ; top = entry( top , 86 ) ; top = entry( top , 53 ) ; top = entry( top , 92 ) ; top = entry( top , 11 ) ; top = entry( top , 65 ) ; top = entry( top , 22 ) ; // 不均一な状態を確認 print_tree( top ) ; System.out.println() ; System.out.println( "p.left=" + depth( top.left ) ) ; System.out.println( "p.right=" + depth( top.right ) ) ; // top の右回転を実行 top = rotate_right( top ) ; // 改善された状態を確認 print_tree( top ) ; System.out.println() ; System.out.println( "p.left=" + depth( top.left ) ) ; System.out.println( "p.right=" + depth( top.right ) ) ; } }
- AVL木と右回転(Paiza.io)
上記のプログラムでは、左枝が3段で右枝が1段であり不均一な状態となっている。
そこで、最上段の86の左枝を上に移動させるように枝の繋ぎ方を rotate_right() により変更している。実際には、繋ぎ変えを左回転させる処理も作る必要がある。
このように、木の枝の深さが不均一な場所で、このような処理を加えて均一化を図った木構造は AVL 木と呼ぶ。
AVL木は、考案した Velski と Landis の名前が由来(Adelson-Velskii and Landis’ tree)
2分ヒープ(binary heap)
2分探索木では、1つのノードにつき2つのポインタを持ち、データ1件あたりのメモリの使用量が多い。通常の「配列の先頭から昇順にデータを並べる2分探索法」では、途中にデータを挿入する場合、データを後ろにずらす必要があるため、O(N)の処理時間を要する。
これらの問題の解決法の1つとして、2分ヒープがある。左右に均一に成長している2分探索木で、上から番号を以下の様に振ると、i番目のデータの左の枝は 2×i+1 番目、右の枝は 2×i+2 番目であることが判る。
このような順序で配列にデータを保存する方法が2分ヒープである。この方式ならアルゴリズムの説明は省略するが、入れるべきノードの場所でのデータの入れ替えで実現できるため O( log( N ) )で挿入が可能となる。
import java.util.*; public class Main { public static int find_heap( int[] array , int idx , int key ) { while( idx < array.length ) { if ( array[ idx ] == key ) return idx ; // 見つかった else if ( array[ idx ] > key ) idx = idx*2 + 1 ; else idx = idx*2 + 2 ; } return -1 ; // 見つからなかった } public static void main(String[] args) throws Exception { int[] a = { 53 , 11 , 86 , 10 , 22 , 65 , 92 } ; System.out.println( find_heap( a , 0 , 22 ) ) ; } }
- 2分ヒープ(Paiza.io)
レポート課題
以下のようなデータを扱う2分探索木のプログラムを作成せよ。以下の箇条書き番号の中から、(出席番号 % 3+1)のデータについてプログラムを作ること。
- 名前(name)と電話番号(phone)
- 名前(name)と誕生日(year,mon,day)
- 名前(name)とメールアドレス(mail)
プログラムは以下の機能を持つこと。
- 1行1件でデータを入力し、2分木に追加できること。
- 全データを昇順(or降順)で表示できること。
- 検索条件を入力し、目的のデータを探せること。
レポートでは、(a)プログラムリスト,(b)その説明,(c)動作検証結果,(d)考察 を記載すること。
2分探索木
配列やリスト構造のデータの中から、目的となるデータを探す場合、配列であれば2分探索法が用いられる。これにより、配列の中からデータを探す処理は、O(log N)となる。(ただし事前にデータが昇順に並んでいる必要あり)
// 2分探索法 import java.util.*; public class Main { public static int bin_search( int[] a , int key , int L , int R ) { // Lは探す範囲の左端 // Rは探す範囲の右端+1(注意!!) while( R > L ) { int m = (L + R) / 2 ; if ( a[m] == key ) return m ; else if ( a[m] > key ) R = m ; else L = m + 1 ; } return -1 ; } public static void main(String[] args) throws Exception { int[] array = { 11, 13, 27, 38, 42, 64, 72, 81 } ; System.out.println( bin_search( array , 64 , 0 , array.length ) ) ; System.out.println( bin_search( array , 14 , 0 , array.length ) ) ; } }
// 2分探索法 int array[ 8 ] = { 11, 13 , 27, 38, 42, 64, 72 , 81 } ; int bin_search( int a[] , int key , int L , int R ) { // Lは、範囲の左端 // Rは、範囲の右端+1 (注意!!) while( R > L ) { int m = (L + R) / 2 ; if ( a[m] == key ) return m ; else if ( a[m] > key ) R = m ; else L = m + 1 ; } return -1 ; // 見つからなかった } void main() { printf( "%d¥n" , bin_search( array , 0 , 8 ) ) ; }
一方、リスト構造ではデータ列の真ん中のデータを取り出すには、先頭からアクセスするしかないのでO(N)の処理時間がかかり、極めて効率が悪い。リスト構造のようにデータの追加が簡単な特徴をもったまま、もっとデータを高速に探すことはできないものか?
2分探索木
ここで、データを探すための効率の良い方法として、2分探索木(2分木)がある。以下の木のデータでは、分離する部分に1つのデータと、左の枝(下図赤)と右の枝(下図青)がある。
この枝の特徴は何だろうか?この枝では、中央のデータ例えば42の左の枝には、42未満の数字の枝葉が繋がっている。同じように、右の枝には、42より大きな数字の枝葉が繋がっている。この構造であれば、64を探したいなら、42より大きい→右の枝、72より小さい→左の枝、64が見つかった…と、いう風にデータを探すことができる。
特徴としては、1回の比較毎にデータ件数は、(N-1)/2件に減っていく。よって、この方法であれば、O(log N)での検索が可能となる。これを2分探索木とよぶ。
このデータ構造をプログラムで書いてみよう。
import java.util.*; class BTreeNode { int data ; BTreeNode left ; BTreeNode right ; BTreeNode( int x , BTreeNode l , BTreeNode r ) { this.data = x ; this.left = l ; this.right = r ; } } public class Main { public static void print_tree( BTreeNode p ) { if ( p != null ) { print_tree( p.left ) ; System.out.print( p.data + " " ) ; print_tree( p.right ) ; } } public static int tree_search( BTreeNode p , int key ) { while( p != null ) { System.out.println( "p.data=" + p.data + " : key=" + key ) ; if ( p.data == key ) return key ; else if ( p.data > key ) p = p.left ; else p = p.right ; } return -1 ; } public static void main(String[] args) throws Exception { BTreeNode top = new BTreeNode( 42 , new BTreeNode( 27 , new BTreeNode( 13 , null , null ) , new BTreeNode( 38 , null , null ) ) , new BTreeNode( 72 , new BTreeNode( 64 , null , null ) , new BTreeNode( 81 , null , null ) ) ) ; print_tree( top ) ; System.out.println() ; if ( tree_search( top , 64 ) >= 0 ) System.out.println( "Find." ) ; } }
struct Tree { struct Tree* left ; int data ; struct Tree* right ; } ; // 2分木を作る補助関数 struct Tree* tcons( struct Tree* L , int d , struct Tree* R ) { struct Tree* n = (struct Tree*)malloc( sizeof( struct Tree ) ) ; if ( n != NULL ) { /* (A) */ n->left = L ; n->data = d ; n->right = R ; } return n ; } // 2分探索木よりデータを探す int tree_search( struct List* p , int key ) { while( p != NULL ) { if ( p->data == key ) return key ; else if ( p->data > key ) p = p->left ; else p = p->right ; } return -1 ; // 見つからなかった } struct Tree* top = NULL ; void main() { // 木構造をtcons()を使って直接生成 (B) top = tcons( tcons( tcons( NULL , 13 , NULL ) , 27 , tcons( NULL , 38 , NULL ) ) , 42 , tcons( tcons( NULL , 64 , NULL ) , 72 , tcons( NULL , 81 , NULL ) ) ) ; printf( "%d¥n" , tree_search( top , 64 ) ) ; }
この方式の注目すべき点は、struct Tree {…} で宣言しているデータ構造は、2つのポインタと1つのデータを持つという点では、双方向リストとまるっきり同じである。データ構造の特徴の使い方が違うだけである。
理解度確認
- main() の (B) の部分 “top = tcons(…)” において、末端部に NULL を入れる理由を答えよ。
2分木に対する処理
2分探索木に対する簡単な処理を記述してみよう。
// (略) public class Main { public static void print_tree( BTreeNode p ) { if ( p != null ) { print_tree( p.left ) ; System.out.print( p.data + " " ) ; print_tree( p.right ) ; } } public static int count( BTreeNode p ) { // データ件数を数える // 考えてみよう } public static int sum( BTreeNode p ) { // 合計を求める // 考えてみよう } public static int max( BTreeNode p ) { // 最大値を探す // 考えてみよう } public static void main(String[] args) throws Exception { BTreeNode top = /*(略)*/ ; print_tree( top ) ; System.out.println() ; System.out.println( count( top ) ) ; // データ件数を数える System.out.println( sum( top ) ) ; // 合計を求める System.out.println( max( top ) ) ; // 最大値を探す } }
下記のC言語のプログラムをヒントとしておく。
// データを探す int search( struct Tree* p , int key ) { // 見つかったらその値、見つからないと-1 while( p != NULL ) { if ( p->data == key ) return key ; else if ( p->data > key ) p = p->left ; else p = p->right ; } return -1 ; } // データを全表示 void print( struct Tree* p ) { if ( p != NULL ) { print( p->left ) ; printf( "%d¥n" , p->data ) ; print( p->right ) ; } } // データ件数を求める int count( struct Tree* p ) { if ( p == NULL ) return 0 ; else return 1 + count( p->left ) + count( p->right ) ; } // データの合計を求める int sum( struct Tree* p ) { if ( p == NULL ) return 0 ; else return p->data + count( p->left ) + count( p->right ) ; } // データの最大値 int max( struct Tree* p ) { while( p->right != NULL ) p = p->right ; return p->data ; }
これらの関数では、木構造の全てに対する処理を実行する場合には、再帰呼び出しが必要となる。
双方向リストとdeque
deque(両端キュー)
双方向循環リストを使うと、(1)先頭にデータを挿入(addFirst/unshift)、(2)先頭のデータを取り出す(removeFirst/shift)、(3)末尾にデータを追加(addLast/push)、(4)末尾のデータを取り出す(removeLast/pop)、といった処理が簡単に記述できる。この4つの処理を使うと、単純リスト構造で説明した、待ち行列(queue)やスタック(stack) が実現できる。この特徴を持つデータ構造は、先頭・末尾の両端を持つ待ち行列ということで、deque (double ended queue) とも呼ばれる。
先頭への挿入/削除
末尾への挿入削除
import java.util.*; class BDListNode { BDListNode prev ; int data ; BDListNode next ; BDListNode( BDListNode p , int d , BDListNode n ) { this.prev = p ; this.data = d ; this.next = n ; } BDListNode() { this.prev = this ; this.data = -1 ; // dummy this.next = this ; } } public class Main { static void addFirst( BDListNode p , int d ) { p.next = new BDListNode( p , d , p.next ) ; p.next.next.prev = p.next ; } static int removeFirst( BDListNode p ) { int ans = p.next.data ; p.next = p.next.next ; p.next.prev = p ; return ans ; } static void addLast( BDListNode p , int d ) { p.prev = new BDListNode( p.prev , d , p ) ; p.prev.prev.next = p.prev ; } static int removeLast( BDListNode p ) { int ans = p.prev.data ; p.prev = p.prev.prev ; p.prev.next = p ; return ans ; } public static void main(String[] args) throws Exception { BDListNode top = new BDListNode() ; // 先頭で Stack addFirst( top , 11 ) ; addFirst( top , 22 ) ; System.out.println( removeFirst( top ) ) ; System.out.println( removeFirst( top ) ) ; // 末尾で Stack addLast( top , 33 ) ; addLast( top , 44 ) ; System.out.println( removeLast( top ) ) ; System.out.println( removeLast( top ) ) ; // Queue として使う addFirst( top , 55 ) ; addFirst( top , 66 ) ; System.out.println( removeLast( top ) ) ; System.out.println( removeLast( top ) ) ; } }
Javaでの LinkedList(Deque) のクラスの使い方
Javaで Deque のような双方向リスト(2重リスト)を使うには、LinkedList<E> を用いる。クラス宣言などで <E> の内側に型Eを指定する機能はジェネリクスと呼ばれる。クラス名に Deque というのもあるけど、LinkedList などの内部の処理を記述するために使われているものなので、ユーザが直接 Deque クラスを使うことはない。
LinkedList でデータ構造を宣言する場合には、LinkedList<E> のように、どのテータ構造を要素にもつ LinkedList なのかを指定する。ただし int,double といったプリミティブ型を型の欄に書くことはできない。こういった場合には Integer,Double といったオブジェクトを記載すること。
また、リストの要素で繰り返しをする場合には、for( 型 item : LinkedList型 ) {…} のように書けば、繰り返しが記述できる。
import java.util.*; public class Main { public static void main(String[] args) throws Exception { LinkedList<Integer> lst = new LinkedList<Integer>() ; lst.addFirst( 11 ) ; // lst の先頭に11を挿入 lst.addFirst( 22 ) ; // lst の先頭に22を挿入 for( Integer item : lst ) { System.out.println( item ) ; } lst.addLast( 33 ) ; // lst の末尾に33を追加 // 全要素の繰り返し for( Integer item : lst ) { System.out.println( item ) ; } // 先頭を取り出す while( !lst.isEmpty() ) { // isEmpty() : lst の要素が空か判定する System.out.println( lst.removeFirst() ) ; // lstの先頭要素を取り出す } } }
LinkedList クラスは双方向リストだけど、単純リスト専用のクラスは無い。このため、これまでの授業のように自分でクラスを宣言して使う必要がある。
ArrayList クラス
これと同じように自由な配列サイズのデータクラスとして、ArrayListクラスも存在する。以下の例は、ArrayList<Integer> の使用例。
ArrayList はジェネリクスを使った、要素数を増やすことができる配列である。
import java.util.*; public class Main { public static void main(String[] args) throws Exception { ArrayList<Integer> lst = new ArrayList<Integer>() ; lst.add( 11 ) ; lst.add( 22 ) ; for( Integer item : lst ) { System.out.println( item ) ; } lst.add( 33 ) ; for( Integer item : lst ) { System.out.println( item ) ; } for( int x = 0 ; x < lst.size() ; x++ ) { System.out.println( lst.get( x ) ) ; } } }
型推論
上記のプログラムで、LinkedList<Integer> lst = new LinkedList<Integer>() ; で宣言しているが、初期化の右辺と左辺で型を揃える必要があるが、複雑な型だとプログラムが煩雑になる。このため、最近の Java には型推論の機能があるため、下の様に型を省略して記述できる。
LinkedList<Integer> lst = new LinkedList<Integer>() ; // 型推論なし LinkedList<Integer> lst = new LinkedList<>() ; // 後半のIntegerは明らか var lst = new LinkedList<Integer>() ; // lst の型は new LinkedList<Integer> で明らか for( Integer item : lst ) { ... } // 型推論なし for( var item : lst ) { ... } // 型推論でIntegerを省略
LinkedListとArrayListの違い
LinkedList や ArrayList は同じ使い方ができるようにadd(int,E),get(int)クラスメソッド等が定義されている。しかし、LinkedList は双方向リスト、ArrayList は配列を使って実装されているので、同じ名前のメソッドでもそのメソッドの処理時間には、以下のような特徴がある。
処理時間のオーダ | LinkedList | ArrayList | メソッドの機能 |
---|---|---|---|
addFirst(E e) / add(E e) |
O(1) | O(1) | リストの末尾に要素を追加 |
add(int index, E e) | 指定した場所探しにO(N) その場所に挿入O(1) |
指定した場所探しにO(1) その場所に挿入O(N) |
リストの指定したインデックスに要素を追加 |
addFirst(E e) / add(0, E e) | 先頭に挿入O(1) | 先頭に挿入O(N) | リストの先頭に要素を追加 |
E get(int index) | O(N) | O(1) | リストのインデックス番目を参照 |
理解確認
- ジェネリックス ArrayList, LinkedList を使って逆順のリストを作る、最小値を探すプログラムを作成せよ。
LinkedListで逆順,最小値を返す練習問題 - 型推論とは何か?
ランダムアクセス・シーケンシャルアクセスから双方向リスト
ランダムアクセスO(1)とシーケンシャルアクセスO(N)の説明を踏まえ、リスト構造のO(N)の改善にむけた解説を行う。
リスト構造の利点と欠点
リストを使った集合演算のように、データを連ねたリストは、単純リストとか線形リストと呼ばれる。特徴はデータ数に応じてメモリを確保する点や、途中へのデータの挿入削除が得意な点があげられる。一方で、配列は想定最大データ件数で宣言してしまうと、実際のデータ数が少ない場合、メモリの無駄も発生する。しかし、想定件数と実データ件数がそれなりに一致していれば、無駄も必要最小限となる。リスト構造では、次のデータへのポインタを必要とすることから、常にポインタ分のメモリは、データにのみ注目すれば無駄となる。
例えば、整数型のデータを最大 MAX 件保存したいけど、実際は それ以下の、平均 N 件扱うとする。この時のメモリの使用量 M は、以下のようになるであろう。(sizeof()はC言語での指定した型のメモリByte数を返す演算子)
配列の場合 | リスト構造の場合 |
(ただしヒープ管理用メモリ使用量は無視) |
シーケンシャルアクセス・ランダムアクセス
もう1つのリストの欠点はシーケンシャルアクセス。テープ上に記録された情報を読む場合、後ろのデータを読むには途中データを読み飛ばす必要があり、データ件数に比例したアクセス時間を要する。このような N番目 データ参照に、O(N)の時間を要するものは、シーケンシャルアクセスと呼ばれる。
一方、配列はどの場所であれ、一定時間でデータの参照が可能であり、これは ランダムアクセスと呼ばれる。N番目のアクセス時間がO(1)を要する。配列であれば、N/2 番目のデータをO(1)で簡単に取り出せるから2分探索法が有効だが、リスト構造であれば、N/2番目のデータを取り出すのにO(N)かかってしまう。
このため、プログラム・エディタの文字データの管理などに単純リストを用いた場合、1つ前の行に移動するには、先頭から編集行までの移動で O(N) の時間がかかり、大量の行数の編集では、使いものにならない。ここで、シーケンシャルアクセスでも1つ前にもどるだけでも処理時間を改善してみよう。
単純リストから双方向リストへ
ここまで説明してきた単純リストは、次のデータへのポインタを持つ。ここで、1つ後ろのデータ(N番目からN+1番目)をアクセスするのは簡単だけど、1つ前のデータ(N-1番目)を参照しようと思ったら、先頭から(N-1)番目を辿るしかない。でも、これは O(N) の処理であり時間がかかる処理。
ではどうすればよいのか?
この場合、一つ前のデータの場所を覚えているポインタがあれば良い。
Javaの場合
import java.util.*; class BDListNode { BDListNode prev ; int data ; BDListNode next ; BDListNode( BDListNode p , int d , BDListNode n ) { this.prev = p ; this.data = d ; this.next = n ; } } public class Main { public static void main(String[] args) throws Exception { BDListNode top = new BDListNode( null , 1 , new BDListNode( null , 2 , new BDListNode( null , 3 , null ) ) ) ; top.next.prev = top ; top.next.next.prev = top.next ; for( BDListNode p = top ; p != null ; p = p.next ) System.out.println( p.data ) ; // 1,2,3 for( BDListNode p = top.next.next ; p != null ; p = p.prev ) System.out.println( p.data ) ; // 3,2,1 } }
C言語の場合
// 双方向リストの宣言 struct BD_List { struct BD_List* prev ; // 1つ前のデータへのポインタ int data ; struct BD_List* next ; // 次のデータへのポインタ } ;
このデータ構造は、双方向リスト(bi-directional list)と呼ばれる。では、簡単なプログラムを書いてみよう。双方向リストのデータを簡単に生成するための補助関数から書いてみる。
// リスト生成補助関数 struct BD_List* bd_cons( struct BD_List* p , int d , struct BD_List* n ) { struct BD_List* ans ; ans = (struct BD_List*)malloc( sizeof( struct BD_List ) ) ; if ( ans != NULL ) { ans->prev = p ; ans->data = d ; ans->next = n ; } return ans ; } void main() { struct BD_List* top ; struct BD_List* p ; // 順方向のポインタでリストを生成 top = bd_cons( NULL , 1 , bd_cons( NULL , 2 , bd_cons( NULL , 3 , NULL ) ) ) ; // 逆方向のポインタを埋める top->next->prev = top ; top->next->next->prev = top->next ; // リストを辿る処理 for( p = top ; p->next != NULL ; p = p->next ) printf( "%d\n" , p->data ) ; for( ; p->prev != NULL ; p = p->prev ) printf( "%d\n" , p->data ) ; }
双方向リストの関数作成
以上の説明で、双方向の基礎的なプログラムの意味が分かった所で、練習問題。
先のプログラムでは、1,2,3 を要素とするリストを、ナマで記述していた。実際には、どんなデータがくるか分からないし、指定したポインタ p の後ろに、データを1件挿入する処理 bd_insert( p , 値 ) , また、p の後ろのデータを消す処理 bd_delete( p ) を書いてみよう。
Javaの場合
public class Main { // 指定したノードの後ろに1件追加 static void bd_insert( BDListNode p , int d ) { p.next = new BDListNode( null , d , p.next ) ; p.next.next.prev = p.next ; p.next.prev = p ; } // 指定したノードの後ろを1件削除 static void bd_delete( BDListNode p ) { p.next = p.next.next ; p.next.prev = p ; } public static void main(String[] args) throws Exception { // 双方向リストの追加,削除 BDListNode top2 = new BDListNode( null , 1 , new BDListNode( null , 2 , new BDListNode( null , 4 , null ) ) ) ; top2.next.prev = top2 ; top2.next.next.prev = top2.next ; // 途中に挿入の実験 bd_insert( top2.next , 3 ) ; for( BDListNode p = top2 ; p != null ; p = p.next ) System.out.println( p.data ) ; // 1,2,3,4 for( BDListNode p = top2.next.next.next ; p != null ; p = p.prev ) System.out.println( p.data ) ; // 4,3,2,1 // 途中を削除の実験 bd_delete( top2.next ) ; for( BDListNode p = top2 ; p != null ; p = p.next ) System.out.println( p.data ) ; // 1,2,4 for( BDListNode p = top2.next.next ; p != null ; p = p.prev ) System.out.println( p.data ) ; // 4,2,1 } }
C言語の場合
// 双方向リストの指定場所 p の後ろに、値 d を要素とするデータを挿入せよ。 void bd_insert( struct BD_List* p , int d ) { struct BD_List*n = bd_cons( p , d , p->next ) ; if ( n != NULL ) { p->next->prev = n ; p->next = n ; } } // 双方向リストの指定場所 p の後ろのデータを消す処理は? void bd_delete( struct BD_List* p ) { struct BD_List* d = p->next ; d->next->prev = p ; p->next = d->next ; free( d ) ; } // この手のリスト処理のプログラムでは、命令の順序が重要となる。 // コツとしては、修正したい箇所の遠くの部分を操作する処理から // 書いていくと間違いが少ない。
番兵と双方向循環リスト
前述の bd_insert() だが、データの先頭にデータを挿入したい場合は、どう呼び出せば良いだろうか?
bd_insert() で、末尾にデータを挿入する処理は、正しく動くだろうか?
同じく、bd_delete() だが、データの先頭のデータを消したい場合は、どう呼び出せば良いだろうか?
また、データを消す場合、最後の1件のデータが消えて、データが0件になる場合、bd_delete() は正しく動くだろうか?
こういった問題が発生した場合、データが先頭・末尾で思ったように動かない時、0件になる場合に動かない時、特別処理でプログラムを書くことは、プログラムを読みづらくしてしまう。そこで、一般的には 循環リストの時にも紹介したが、番兵(Sentinel) を置くことが多い。
しかし、先頭用の番兵、末尾用の番兵を2つ用意するぐらいなら、循環リストにした方が便利となる。このような双方向リストでの循環した構造は、双方向循環リスト(bi-directional ring list)と呼ばれる。