二分探索木[1] ― 初期設定、追加、検索

<目次>

<概要> これ以後の資料では、「二分探索木」と呼ばれるデータ構造を解説する。 二分探索木に関する基本操作は次の五つである。 この資料では、まず、最初の三つの基本操作を解説する。 次の資料(二分探索木[2])では二分探索木の走査を先に説明し、 最後の資料(二分探索木[3])ではデータ要素の削除を説明する (データ要素の削除処理がもっとも難しいため)。

【注意】 二分木に関して、離散数学(1年次)やグラフ理論(2年次)にて学習していることを前提としている。第1節にて二分木に関する用語を述べているが、ただ単に用語を列挙しているだけに過ぎない。
1. 二分木(用語解説)

二分木に関する用語を手短に述べておこう(注:離散数学にて学習しているはず)。

下図のように、各ノードから高々2本の枝が出ている木(根付き木)のことを「二分木」と呼ぶ。
この図を見て分かるように、各ノードから出ている枝の本数は「高々2本(0本、1本、2本のいずれか)」であって、「ちょうど2本」ではないことに注意して欲しい。

【定義】 二分木の各ノードは次の二種類に分類される。 さらに、もっとも上に位置するノードを、その二分木の「根」と呼ぶ。

【用語解説】
*「根」
もっとも上に位置するノードを、その二分木の「根」と呼ぶ。
*「親」
任意のノードvに対して、上方向の枝によって接続された(ただ一つの)ノードのことをvの「親」と呼ぶ。
*「子供」「左側の子供」「右側の子供」「兄弟」
任意のノードvに対して、下方向の枝によって接続されたノードのことをvの「子供」と呼ぶ。さらに、左側に位置する子供を「左側の子供」と呼び、右側に位置する子供を「右側の子供」と呼ぶ。さらに、同じ親を持つ二人の子供を「兄弟」と呼ぶこともある。
*「子孫」「先祖」または「祖先」
任意のノードvとwに対して、vからスタートして枝を下方向に辿ってwに到達できるとき、wをvの「子孫」と呼び、逆に、vをwの「先祖」または「祖先」と呼ぶ。
*「vを根とする部分木」
ノードvとvの子孫だけからなる部分的な二分木を「vを根とする部分木」と呼ぶ。
*「vの左部分木」「vの右部分木」
ノードvに対して、vの左側の子供を根とする部分木のことを「vの左部分木」と呼び、vの右側の子供を根とする部分木のことを「vの右部分木」と呼ぶ。
*「節点」
「ノード」のことを「節点」と呼ぶ人もいる。この授業では「ノード」を使用する。
*「リンク」
「枝」のことを「リンク」と呼ぶことがある(枝がポインタによって実現されるため)。

【参考】 「左側の子供」、「右側の子供」と言う用語を使用するときには、各ノードの子供の間に順序関係を(暗黙のうちに)仮定している。直感的に言えば、左側の子供を「兄」と見なし、右側の子供を「弟」と見なしている。子供の間に順序を仮定した二分木を、正確には、「順序二分木」と呼ぶ。この授業で扱う二分木(二分探索木)はすべて順序二分木なのだが、単に二分木と略す。
2. 二分探索木

適当な順序関係(大小関係など)が定義されているデータ要素を格納した二分木が次の条件を満たすとき、その二分木を「二分探索木」と呼ぶ。 下図の左側は、上の条件を直感的に図示している。 また、下図の右側は、整数をデータ要素とする二分探索木の具体例を(やや抽象的に)示している。 あらゆる内部ノードに関して上の条件が成り立っていることを確認して欲しい。

【注意(重要)】 任意の二分探索木に関して、通常、次の条件を仮定する。
   《仮定》  各ノードに格納されたデータ要素は互いに相異なる。
この授業では、この仮定をもとにプログラムを作成していく。 例えば、データ要素の追加処理では、追加対象のデータ要素がすでに二分探索木に格納されているときには何もせずに終了する、といった処理を行う。この仮定を忘れないで欲しい。

【参考】
3. ノードを表す自己参照構造体

二分探索木の各ノードは、次のメンバからなる自己参照構造体として定義される。 データ要素は目的に応じて変化するが、この授業ではもっとも単純に、整数値をデータ要素とする二分探索木を扱う。従って、各ノードは、次のような自己参照構造体として定義される。
struct node {
  int value;                  /* データ要素             */
  struct node * left;         /* 左側の子供へのポインタ */
  struct node * right;        /* 右側の子供へのポインタ */
};

参考までに、自己参照構造体を使った二分探索木のイメージを示す。 これは、上で抽象的に示した二分探索木の具体例と同じものである。なお、この図には、「二分探索木を指すポインタ変数」(次節参照)も示している。

【注意(重要)】 この図を見て分かるように、指すべき子供を持たない自己参照メンバは、必ず、NULLに設定する。これを忘れないで欲しい。
4. 二分探索木を指す変数

【変数 root 】 二分探索木を指す変数には、二分探索木の根を指し示すポインタを格納する。 その変数名は何でも構わないのだが、この授業で作成するプログラムでは、「root」という変数名を使用する。「root」を見たら、何も説明していなくても、二分探索木(の根)を指すポインタ変数のことだと解釈して欲しい。 参考までに、ポインタ変数 root は、(例えば)main関数の中で次のように宣言される。
int main(void){

   struct node * root;     /* 二分探索木(の根)を指すポインタ変数 */

   ... 二分探索木に関する適当な処理  ... 

   return 0;
}

【仮引数 rt 】 連結リストの場合と同様に、初期設定関数、追加関数、削除関数では、ポインタ変数rootに対して「ポインタ変数のポインタ渡し」を使用する。そこで、各関数の仮引数宣言のところでは、rootを短縮した「rt」という仮引数名を使用する。「rt」という仮引数を見たら、「ポインタ変数rootに対してポインタ変数のポインタ渡しを行っている」と解釈して欲しい。
5. 初期設定関数:二分探索木の初期設定

【目的】 初期設定関数は、連結リストの場合と同様に、ポインタ変数 root に NULL を設定する処理だけを行う(言い換えると、空の二分探索木を作成する)。

【初期設定関数の実装】
連結リストの場合と同様に、仮引数 rt が指すメモリ領域(変数rootのメモリ領域)にNULLを設定する。
void init( struct node * * rt ){
   *rt = NULL;
}

【初期設定関数の呼び出し】
連結リストの場合と同様に、実引数に変数rootへのポインタを指定する。
int main(void){

   struct node * root;     /* 二分探索木(の根)を指すポインタ変数 */

   init(&root);             /* 二分探索木の初期設定 */

   ... 二分探索木に関する適当な処理  ... 

   return 0;
}
6. 追加関数:データ要素の追加

【目的】 追加関数には次の二つの引数が与えられる。
    *rt
二分探索木(の根)へのポインタ root。(注)ポインタ渡しを使用する。従って、仮引数rtの値は変数rootへのポインタになっている。
    val
新たなデータ要素(追加対象の値)。
これらが与えられたとき、追加関数は、次のように処理する。

【処理手順】(参考:授業では、図を使って解説する。)
(A) 二分探索木が空だったとき、次のように処理して終了する。
⇒⇒⇒このステップの動作例
(1)
新たなノードを生成する。
(2)
新たなノードにデータ要素valを設定する。
(3)
新たなノードの自己参照メンバ(leftとright)にNULLを設定する。
(4)
ポインタ変数rootに新たなノードへのポインタを代入する(注:ポインタ渡しを使用する)。
(B)
二分探索木が空でなかったとき、valを登録すべき位置を求めなければならない。 これは、大まかに言って、次のような処理を用いて求めることができる。
  • 根からスタートして、各ノードに格納されているデータ要素とvalを比較しながら、二分探索木の中を(下方向に)降りて行く。
具体的には、次のように処理する。
(0)
作業用ポインタ変数の宣言
次の二つのポインタ変数(struct node*型)を用意する。
    current
二分探索木の中を降りて行く途中のノード(以下では、「比較対象ノード」と呼ぶ)へのポインタを保持する。
    parent
比較対象ノードの親ノード(一つ前の比較対象ノード)へのポインタを保持する。
(1)
currentの初期設定
まず、currentに「二分探索木の根」へのポインタを初期設定する。
(2)
追加場所の探索処理
次に、currentがNULLになるまで(言い換えると、比較対象ノードがなくなるまで)か、または、valがすでに格納されていることが分かるまで、次の処理を繰り返す。 (⇒⇒⇒このステップの動作例
   
(*)
valと比較対象ノードのデータ要素valueを比較し、その比較結果に応じて次のように処理する。
   
(a)
val=valueのとき、valがすでに格納されていたことが分かるので、何もせずに終了する。
   
(b)
val<valueのとき、比較対象ノードの左部分木のどこかにvalを格納しなければならない。そこで、現在の比較対象ノードの左側へ降りて行く。具体的には、次を実行する。
  • parent = current    (現在の比較対象ノードへのポインタを保存)
  • current = current->left    (左に降りて行く)
   
(c)
val>valueのとき、比較対象ノードの右部分木のどこかにvalを格納しなければならない。そこで、現在の比較対象ノードの右側へ降りて行く。具体的には、次を実行する。
  • parent = current    (現在の比較対象ノードへのポインタを保存)
  • current = current->right    (右に降りて行く)

(3)
新たなデータ要素valの登録
以上の繰り返し処理を行っている間に、いつかはcurrentの値がNULLになる。NULLになった時点で val を登録すべき位置が確定している。その位置とは、最後の比較対象ノード(注:parentが指しているノード)の「左側の子供(の位置)」かまたは「右側の子供(の位置)」である。どちら側に登録すべきかは、valと最後の比較対象ノードのデータ要素を比較することによって決めることができる。そこで、次のように処理する。 (⇒⇒⇒このステップの動作例
   
(a)
まず、新たなノードを生成して、そのデータ要素メンバにvalを設定し、自己参照メンバにNULLを設定する。
   
(b)
次に、追加対象のデータ要素valとparentが指しているノードのデータ要素valueを比較する。
   
(c)
val<valueのとき、新たなノードを、parentが指しているノードの左側の子供として登録する。
   
(d)
val>valueのとき、新たなノードを、parentが指しているノードの右側の子供として登録する。
(処理手順終わり)

【プログラム】 以下に、追加関数のプログラムを示す。
void add( struct node * * rt, int val ){
   struct node * current, * parent, * new_node;
   
   /* (A) 二分探索木が空だったとき */
   if( *rt == NULL ){
     new_node = malloc(sizeof(struct node));       /* (1) */   
     new_node->value = val;                        /* (2) */
     new_node->left  = NULL;                       /* (3) */
     new_node->right = NULL;
     *rt = new_node;                               /* (4) */
     return;                                       /* 終了 */
   }

   /* (B) 二分探索木が空でなかったとき */
   current = *rt;                                  /* (1) */
   while( current != NULL ){                       /* (2) */ 
     if( val == current->value ){ return; }        /* (2)(a) */
     if( val < current->value ){                   /* (2)(c) */
       parent = current;
       current = current->left;
     } else {                                      /* (2)(d) */
       parent = current;
       current = current->right;
     }
   }

   /* (B)(3) valの登録 */
   new_node = malloc(sizeof(struct node));         /* (3)(a) */ 
   new_node->value = val;
   new_node->left  = NULL;
   new_node->right = NULL;
   if( val < parent->value ){                      /* (3)(b) */
     parent->left = new_node;
   } else {                                        /* (3)(c) */
     parent->right = new_node;
   }
}
7. 検索関数:データ要素の検索

【目的】 検索関数には次の二つの引数が与えられる。
    root
二分探索木(の根)へのポインタ root。(注)ポインタ渡しは使用しない。
    val
検索対象のデータ要素(いまの場合、整数値)。
これらが与えられたとき、検索関数は次のように処理する。

【処理手順】(参考:授業では、図を使って解説する。)
valを保持したノードが存在するか否かを判定するためには、根からスタートして、 各ノードのデータ要素とvalを比較しながら、二分探索木の中を下方向に降りて行けばよい。これは、parentが不要な点を除いて、追加関数の追加場所を探索する処理と同じである。

(0)
作業用ポインタ変数の宣言
次のポインタ変数(struct node*型)を用意する。
    current
二分探索木の中を降りて行く途中のノード(比較対象ノード)へのポインタを保持する。
(1)
currentの初期設定
まず、currentに「二分探索木の根」へのポインタを初期設定する。
(2)
探索処理
次に、currentがNULLになるまで(言い換えると、比較対象ノードがなくなるまで)か、または、valがすでに登録されていることが分かるまで、次の処理を繰り返す。 (⇒⇒⇒このステップの動作例[1]⇒⇒⇒このステップの動作例[2]
   
(*)
valと比較対象ノードのデータ要素valueを比較し、その比較結果に応じて次のように処理する。
   
(a)
val=valueのとき、currentを戻り値として返す(終了)。
   
(b)
val<valueのとき、現在の比較対象ノードの左側へ降りていく。具体的には、次を実行する。
  • current = current->left    (左に降りて行く)
   
(c)
val>valueのとき、現在の比較対象ノードの右側へ降りて行く。具体的には、次を実行する。
  • current = current->right    (右に降りて行く)
(3)
currentがNULLになったとき
currentがNULLとなって上記の繰り返し処理が完了したら、 それはvalを保持したノードが存在しないことを示している。 そこで、NULLを戻り値として返す(終了)。

【プログラム】 以下に、検索関数のプログラムを示す。 なお、二分探索木の内容が変化しないため、 ポインタ変数のポインタ渡しを使用する必要はないことに注意して欲しい。
struct node * search( struct node * root, int val ){
  struct node * current;

  current = root;                                            /* (1) */
  while( current != NULL ){                                  /* (2) */
     if( val == current->value ){ return current; }          /* (2)(a) */
     if( val < current->value ){                             /* (2)(b) */
       current = current->left;
     } else {                                                /* (2)(c) */
       current = current->right;
     }
  }

  return NULL;                                               /* (3) */
}
8. プログラム例

以下に、初期設定関数、追加関数、検索関数を使用した簡単なプログラムを示す。 このmain関数では、配列Aに格納された整数(最後の-1を除く)を二分探索木に登録して、そのあと検索関数を利用しながら二分探索木の内容を表示している。 なお、この処理内容に特別な意図はない。
/**********************************************************************
 * ヘッダファイル
 **********************************************************************/
#include <stddef.h>                      /*-- NULL , size_t --*/
#include <stdio.h>                       /*-- printf関数    --*/
#include <stdlib.h>                      /*-- malloc関数    --*/

/**********************************************************************
 * 二分探索木のノードを表す自己参照構造体
 **********************************************************************/
struct node {
  int value;                             /*-- データ要素     --*/
  struct node * left;                    /*-- 左側の子供へのポインタ --*/
  struct node * right;                   /*-- 右側の子供へのポインタ --*/
};

/**********************************************************************
 * 基本操作関数(プロトタイプ宣言)
 **********************************************************************/
void init( struct node * * rt );                        /*-- 初期設定 --*/
void add( struct node * * rt, int val );                /*-- 追加 --*/ 
struct node* search( struct node * root, int val );     /*-- 検索 --*/

/**********************************************************************
 * main関数
 * 以下では、配列Aに格納された正整数(最後の-1を除く)を二分探索木に登録し、
 * 検索関数を利用することによって二分探索木の内容を表示している。
 * なお、配列Aの最後に格納されている -1 は、登録対象の整数列の終わりを
 * 示すための目印である。
 * (このような、「終わり」を表す目印を総称して「番兵」と呼ぶ。)
 **********************************************************************/
int main(void){
  int A[] = { 29, 13, 5, 19, 15, 47, 35, 33, 40, 51, 65, -1 }; 
  int i;

  struct node * root;          /* 二分探索木(の根)を指すポインタ変数 */
  struct node * p;             /* 作業用変数 */

  /*-★ 二分探索木の初期設定 --*/
  init( &root );

  /*-★ 各A[i]を二分探索木に追加する --*/
  i = 0;
  while( A[i] > 0 ){ 
    add( &root, A[i] ); 
    i++;
  }

  /*-★ 二分探索木の根へのポインタを表示 --*/
  printf("root=%p\n", root);

  /*-★ 各A[i]を検索して、A[i]を保持するノードの内容を表示する --*/
  i = 0;
  while( A[i] > 0 ){ 
    p = search( root, A[i] ); 
    printf("%p:[%d;%p,%p]\n", p, p->value, p->left, p->right);
    i++;
  }

  return 0;
}

/**********************************************************************
 * 基本操作関数(関数本体の定義)
 **********************************************************************/
/*-★ 初期設定関数 --*/
void init( struct node * * rt ){
  *rt = NULL;
}

/*-★ 追加関数(データ要素を追加する) --*/
void add( struct node * * rt, int val ){
   struct node * current, * parent, * new_node;
   
   /* (A) 二分探索木が空だったとき */
   if( *rt == NULL ){
     new_node = malloc(sizeof(struct node));       /* (1) */   
     new_node->value = val;                        /* (2) */
     new_node->left  = NULL;                       /* (3) */
     new_node->right = NULL;
     *rt = new_node;                               /* (4) */
     return;                                       /* 終了 */
   }

   /* (B) 二分探索木が空でなかったとき */
   current = *rt;                                  /* (1) */
   while( current != NULL ){                       /* (2) */ 
     if( val == current->value ){ return; }        /* (2)(a) */
     if( val < current->value ){                   /* (2)(c) */
       parent = current;
       current = current->left;
     } else {                                      /* (2)(d) */
       parent = current;
       current = current->right;
     }
   }

   /* (B)(3) valの登録 */
   new_node = malloc(sizeof(struct node));         /* (3)(a) */ 
   new_node->value = val;
   new_node->left  = NULL;
   new_node->right = NULL;
   if( val < parent->value ){
     parent->left = new_node;                      /* (3)(b) */
   } else {
     parent->right = new_node;                     /* (3)(c) */
   }
}

/*-★ 検索関数(データ要素を検索する) --*/
struct node * search( struct node * root, int val ){
  struct node * current;

  current = root;
  while( current != NULL ){
     if( val == current->value ){ return current; }
     if( val < current->value ){
       current = current->left;
     } else {
       current = current->right;
     }
  }

  return NULL;
}
(出力結果)
root=0x804a050
0x804a050:[29;0x804a060,0x804a0a0]
0x804a060:[13;0x804a070,0x804a080]
0x804a070:[5;(nil),(nil)]
0x804a080:[19;0x804a090,(nil)]
0x804a090:[15;(nil),(nil)]
0x804a0a0:[47;0x804a0b0,0x804a0e0]
0x804a0b0:[35;0x804a0c0,0x804a0d0]
0x804a0c0:[33;(nil),(nil)]
0x804a0d0:[40;(nil),(nil)]
0x804a0e0:[51;(nil),0x804a0f0]
0x804a0f0:[65;(nil),(nil)]

【問題】 A[0]を追加した直後の二分探索木、A[1]を追加した直後の二分探索木、A[2]を追加した直後の二分探索木、... のそれぞれの図を示せ。

【補足】 二分探索木の内容表示関数やメモリ解放関数は、二分探索木の走査(トラバーサル)を学習するときに解説する。現時点では、各データ要素を検索することによって各ノードの内容を表示して欲しい。また、メモリ領域を解放する必要もない。
9. 演習問題

【注意】

【問題0】 前節のプログラムを(そのまま)実現せよ。

【問題1】 前節で示したプログラムに関して、main関数内の追加処理を次のように変更せよ。
  /*-★ 各A[i]を二分探索木に追加する --*/
  i = 0;
  while( A[i] > 0 ){ 
    add( &root, A[i] ); 
    A[i]を追加した直後の二分探索木の内容を表示する
    i++;
  }
内容表示の仕方は前節のプログラムを参照せよ。

【問題2】 整数列を格納したint型の配列A=A[0], A[1], ..., A[n-1]が与えられたとき、 A[0]〜A[n-1]をすべて登録した二分探索木を構築して、 その二分探索木へのポインタを戻り値として返す関数を作成せよ。 関数名をarray_to_treeにすること。

【問題3】 次の要件を満たす関数を作成せよ。関数名を delete_leaf にすること。

【問題4】 この問題では、順序を逆転した二分探索木を考える。 すなわち、任意の内部ノードvに対して、vの左部分木に含まれるデータ要素はvのデータ要素より大きく、vの右部分木に含まれるデータ要素はvのデータ要素より小さい、といった条件を満たす二分探索木を考える。この二分探索木に対して、初期設定関数、追加関数、検索関数を作成せよ。さらに、上のプログラム例と同様のmain関数を作成せよ。

【補助問題】(注:これは、問題5以降のための予備的な問題である。)
下図は二種類の二分探索木を(やや直感的に)図示している。 また、二分探索木の構造(リンク情報)だけを示している。
この図では、データ要素をまったく示していないが、二分探索木の定義から(論理的に)次のような問に答えることができる。 そこで、それぞれの二分探索木に対して、これらの問に答えよ。

【問題5】 次のように処理するプログラムを作成せよ。 〈参考〉登録順序を見つけて、問題2で作成した関数を使用すればよい。 従って、この問題の目的は、登録順序をうまく発見すること(だけ)である。 余計なことを考える必要はない。

【問題6】 二分探索木に登録されている整数値の中の最小値を保持したノードへのポインタを返す関数を作成せよ。少し具体的に言うと、次のように処理する関数を作成せよ。 なお、関数名を mim_node にすること。 〈参考〉上記の「補助問題」を参照すること。

【問題7】 二分探索木へのポインタを引数として受け取って、最大値を保持したノードへのポインタを返す関数を作成せよ。関数名を max_node にすること。 〈参考〉上記の「補助問題」を参照すること。

【追加の演習問題】 ここをクリック
日本大学文理学部情報システム解析学科 S.Toda / 2005年12月10日