第6週 木構造と二分木
INDEX

本日の目標

  • 木構造について理解する。
  • 二分木を使って、幅優先探索と深さ優先探索することができる。
  • 二分探索木を構成して、探索することができる。
  • 二分探索木における、計算量を理解する。
  • ヒープを構成して、利用することができる。
  • ヒープ・ソートを理解して利用することができる。

予習・復習

 以下のスライドを利用して、予習と復習をしよう。復習では、自分の理解度を確認するために、実際にプログラムを作成し、意図する結果が得られるか確認しよう。
  1. 木構造と二分木
  2. ヒープ

本日の講義・演習予定

  1. 木構造
  2. 二分探索木
  3. ヒープ
  4. ヒープ・ソート
  5. 演習問題
  6. 提出課題
内 容
  1. 木構造
  2. 二分探索木
  3. ヒープ
  4. ヒープ・ソート


木構造

木構造

木構造

 木構造とは、データを格納したノード(node/節)と、ノードとノードを連結するリンク(edge/枝)から構成されます。全体はルート(root/根)を頂点として、植物の木の枝が下向きに広がっていくように構成されます。あるノードに注目した時、そのノードの直下にリンクしたノードを子(child)と呼び、子の直上のノードを親(parent)と呼びます。親は子を複数持つことができますが、子は親を一つしか持つことができません。子を持たないノードをリーフ(leaf/葉)と呼びます。
 ルートの位置をレベル0として、その子の高さをレベル1、孫の高さをレベル2のように子孫の高さをレベルとして表します。 tree

n分木

 一つのノードに最大n個の子ノードを連結できる木構造をn分木と言います。
tree_n

2分木

 一つのノードに最大2個の子ノードを連結できる木構造を2分木と言います。すべてのノードが2個の子ノードを持つことですべてのレベルが満たされる場合を完全2分木、すべてのノードが2個の子ノードを持つが一部のレベルが欠けている場合を全2分木。また。すべてのノードが1個の子ノードしか持たない場合を最小二分木と言います。
tree_binary

二分木の巡回方法

 二分木を使って探索する方法には、幅優先探索と深さ優先探索があります。また、深さ優先探索においては、「行きがけ」、「通りがけ」、「帰りがけ」と呼ばれる3つの特別な巡回の方法があります。

行きがけ、通りがけ、帰りがけ

 ノードを巡る方法には、ノードの左側を通る際に訪問する「行きがけ」、ノードの下側を通る際に訪問する「通りがけ」、ノードの右側を通る際に訪問していく「帰りがけ」の3つの場合があります。 tree_traverse
  • 行きがけ(pre-order)
  • ノードに立ち寄る -> 左部分木をなぞる -> 右部分木をなぞる
  • 通りがけ(in-order)
  • 左部分木をなぞる -> ノードに立ち寄る -> 右部分木をなぞる
  • 帰りがけ(postorder)
  • 左部分木をなぞる -> 右部分木をなぞる -> ノードに立ち寄る

幅優先探索

 あるノードを出発点として、同一レベルの隣接する左側のノードから右側に辿っていきます。それが終わると、次に高いレベルへと移って先と同様に左から右を辿っていく方法を幅優先探索と言います。
btree_breadth

深さ優先探索

 あるノードを出発点として、リンクで連結された子ノードを辿っていきます。リーフまで到達したら、親ノードに戻り反対側の連結された子ノードを辿っていく方法を深さ優先探索と言います。「行きがけ」、「通りがけ」、「帰りがけ」はそれぞれ深さ優先探索の方法の一つになります。
btree_depth2
Fig.1.2.3.1 行きがけ
btree_depth3
Fig.1.2.3.2 通りがけ
btree_depth1
Fig.1.2.3.3 帰りがけ


二分探索木

二分探索木

 二分木を構成する各ノードは探索のためのキーを持つものとします。このとき、すべてのノードのキーが下図のように
binary_tree
・左部分木のノードのキーは、そのノードのキーより小さい。
・右部分木のノードのキーは、そのノードのキーより大きい。
の条件を満たす二分木を二分探索木と言います。


プログラム例
binary_tree.c
#include <stdio.h>
#include <stdlib.h>

struct node {
    int data;
    struct node *left;
    struct node *right;
};
typedef struct node Node;

void insert(int data);
Node *search(int key);
void preorder_traversal(Node *root);
void inorder_traversal(Node *root);
void postorder_traversal(Node *root);

Node *root = NULL;

/* データの追加 */
void insert(int data)
{
    Node *new = (Node *)malloc(sizeof(Node));   //新ノード生成
    Node *current,*parent;

    new->data = data;   //新ノードのメンバーへデータ設定
    new->left = NULL;
    new->right = NULL;

    if(root==NULL){  //ルートが空ならば
        root = new;
    }
    else{
        current = root; //ルートからスタート
        parent = NULL;
        while(1){
            parent = current;   //親を設定
            //左の枝へ追加(小さい値)
            if(data < parent->data){
                current = current->left; //左ノードへ移動
                if(current == NULL){
                    parent->left = new; //左ノードが空なら新規ノード追加
                    return ;
                }
            }
            //右の枝へ追加(大きい値)
            else{
                current = current->right; //右ノードへ移動
                if(current == NULL){
                    parent->right = new;    //右ノードが空なら新規ノード追加
                    return ;
                }
            }
        }
    }
}

/* 探索 */
Node *search(int key)
{
    Node *current = root;   //ルートからスタート
    
    printf("Visiting: ");

    while(current->data != key){
        if(current != NULL) printf("%d -> ", current->data);

        //左側探索
        if(current->data > key)
            current = current->left;
        //右側探索
        else
            current = current->right;

        //探索失敗(
        if(current == NULL) return NULL;
    }
    return current;
}

/* 巡回(行きがけ) */
void preorder_traversal(Node *root)
{
    if(root != NULL){
        printf("%d ",root->data);
        pre_order_traversal(root->left);
        pre_order_traversal(root->right);
    }
}

/* 巡回(通りがけ) */
void inorder_traversal(Node *root)
{
	    /***
	       ここを埋めて完成させる
	    ***/
}

/* 巡回(帰りがけ) */
void postorder_traversal(Node *root)
{
	    /***
	       ここを埋めて完成させる
	    ***/
}
空欄の回答
/* 巡回(通りがけ) */
    if(root != NULL){
        inorder_traversal(root->left);
        printf("%d ",root->data);
        inorder_traversal(root->right);
    }
 
/* 巡回(帰りがけ) */
    if(root != NULL){
        post_order_traversal(root->left);
        post_order_traversal(root->right);
        printf("%d ",root->data);
    }
 


計算量

 ノード数をn、木の高さをhとすると、完全2分木における関係は \[n = 1 + 2^1 + 2^2 + \dots + 2^{n-1} = 2^h - 1\] と表すことができます。もし、探索対象のノードが一番下に位置した場合、その比較回数はhとなります。したがって \[h = \log_2 (n+1)\] となることから、オーダーは \[O(\log n)\] となります。また、最小二分木の場合には、n = hとなることから、オーダーは\(O(n)\)となります。

演習

  1. 上記の二分探索木を使った探索プログラム例binary_tree.cにおいて、関数preorder_traversal()、inordertraversal()、postorder_traversal()がそれぞれ、2分木を正しく「行きがけ」、「通りがけ」、「帰りがけ」で巡回することをmain関数を追加して確認しなさい。
  2. 上記のプログラム例binary_tree.cにおいて、二分探索木での探索の手順について関数search()を使って、確認しなさい。

ヒープ

 親ノードが常に子ノードより大きく(もしくは小さく)ならない二分木をヒープと言います。

ヒープ

 親ノードが常に子ノードより大きく(もしくは小さく)ならないように配置された場合、ルートには必ず木を構成するすべてのノードのうち一番小さな値(もしくは一番大きな値)が配置されることになります。ノードは左側から埋めていき、穴がないように配置されます。親ノードは必ず2つの子ノードを持ちますが、最後のノードだけは左側の子だけの場合もあります。
 ヒープは、下図のように配列を使って表すことができます。
heap_array
添字は1から始めることにして、先頭要素は使わないことにします。ルートの添字を1として、高いレベルになると添字が大きくなります。同じレベルでは左ノードから右に向かって添字が大きくなります。このようにノードを配置すると、i番目の要素a[i]の左右の子ノードをそれぞれa[i*2]、a[i*2+1]として、親ノードをa[i/2]として扱うことができます。

ヒープ化

 ランダムに配置されたデータ列をヒープに構成する方法を考えます。ヒープは親と子の関係において大小関係が成り立つ構成である必要があります。ですから、子を持つ親ノードに注目して、子が親よりも大きい(もしくは小さい)場合にノードを入れ替えるようにします。子を持つ親ノードは、ヒープサイズをnとするとn/2で得ることができます。下図の例では、ヒープサイズが9なので、注目する親ノードの添字はi=9/2=4となります。i=4を親ノードとする部分木においては、子ノードの方が親ノードよりも大きいのでノードを交換します。左側の子ノードの方が右側の子ノードよりも大きいので、交換の対象は左側の子ノードとなります。次に添字iを1つ減じて、i=3を親ノードとする部分木に移動します。ここでも子ノードの方が親ノードよりも大きいのでノードを交換します。これを繰り返すことで残りの部分木ごとのヒープを実現していきます。ただし、下図のi=1の場合では、親ノードと子ノードを交換すると子ノードと孫ノードのヒープ関係が崩れてしまうため、子ノードに移動して孫ノードとの交換処理が必要になります。もし、次世代のヒープ関係が適切でなければ、ひ孫ノード、玄孫ノードというように子のないノード(リーフ)まで処理を継続しなければなりません。このような場合は再帰的に処理することで簡単に対応することができます。
 heapify

ノードの追加

 新規のノードはヒープの最後、すなわち配列の末尾に追加されます。新しいノードの追加によってヒープはヒープではなくなる可能性があります。再びヒープに復元するための操作をシフトアップshift upと呼びます。親子の関係が親>子(もしくは親<子)の関係になっているかを調べて、そうでなければデータを交換していきます。追加したノードが適切な位置に移動するまで、上方にずらしていく処理ということになります。
heap_shift_up

ノードの削除

 ヒープからのルートの削除を考えます。ヒープからルートを削除する場合は、空いたルートに最後のノードを移動して埋めます。するとヒープが崩れてしまう可能性があります。再びヒープを復元するための操作をすることになります。親>子(もしくは親<子)の関係が成立するまで、ルートに移動したノードを下にずらす操作をheap_downもしくはshift_downと呼びます。

heap_shift_down

[プログラム例]
heap.c
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#include <math.h>
#define swap(x,y) { int tmp=x; x=y; y=tmp; }
#define MAX 32

void ins_node(int value);
void del_node(void);
void build_heap(void);
void heapify_max(int i);
void heapify_min(int i);
void shift_up(int index);
void heap_down(int index);
int is_empty(void);
void print_tree(void);

//int a[MAX] = {0};
int a[MAX] = {0,21,12,8,65,22,40,34,17,7,0,};
int heapsize=9;

/* 乱数によるデータ生成 */
void init_data(int n)
{
	int i;

	if(n>=MAX){
		 printf("N is over max number.\n");
		exit(EXIT_FAILURE);
	}
	srand((unsigned)time(NULL));
	for(i=1; i<n; i++)
		a[i] = rand()%50+2;	
}

/* ヒープ化 */
void build_heap(void)
{
	int i = heapsize/2;    //右下端部分木からスタート

	while(i>0)
		heapify_min(i--); //ルートまで繰り返す
}

/* ヒープ化(rootへ最小値を) */
void heapify_min(int i)
{
	int smallest = i;	//親
	int left = smallest;	//左の子
	int right = smallest;	//右の子

	if(a[i*2]) left = i*2;
	if(a[i*2+1]) right = i*2+1;

	if(left <= heapsize && a[left] <a[i]) smallest = left;	//左子>親?
	if(right <= heapsize && a[right] < a[smallest]) smallest = right;	//右子>(左、親)
	if(smallest != i){	//最大が親でなければ交換
		swap(a[i],a[smallest]);
		heapify_min(smallest);
	}
}

/* ノードの追加(右下端) */
void ins_node(int value)
{
    if(heapsize>MAX){
        printf("Error: Heap array is overflow.\n");
        exit(EXIT_FAILURE);
    }
    else {
        heapsize++;
        a[heapsize]=value;  //右下端に追加
        shift_up(heapsize); //再ヒープ(シフトアップ)
    }
}

/* シフトアップ
   ノード追加による
   右下端から上に向かってヒープ(ルート最小) */
void shift_up(int i)
{
    int parent,tmp;

    if(i != 0){
        parent = i/2; //親
        if(a[parent] > a[i]){ //親の方が大きければ
           swap(a[parent],a[i]);  //交換
           shift_up(parent);  //再帰的にヒープ
        }
    }
}

/* ノードの削除(root) */
void del_node(void)
{
    if(is_empty())
    {
        printf("Heap is empty.\n");
        exit(EXIT_FAILURE);
    }
    a[1] = a[heapsize];
    heapsize--;

    if(heapsize>0) heap_down(1);
}

/* ヒープダウン
   ノード削除による
   ルートから下に向かってヒープ(ルート最小) */
void heap_down(int i)
{
    int smallest;   //左右の子の小さい方の添字
    int left = i*2;       //left child index
    int right = left+1;  //right child index
    
    if(right>heapsize){	//右に子が無い
        if(left>heapsize)	//左右に子が無い
            return;
        else
            smallest = left; //左側の子しか無い場合
    }
    else {
        if(a[left]<=a[right])  //左右の子のうち小さい方は
            smallest = left;
        else
            smallest = right;
    }

    if(a[i] > a[smallest]){     //子の方が小さければ
        swap(a[i],a[smallest]);
        heap_down(smallest);       //下に移動
    }
}

/* 木構造の表示 */
void print_tree(void)
{
	int i,j,k;
	int count=1,height,ns,smax;
	int sidespace,space;

	height = (int)(log(heapsize)/log(2))+1;	//木の高さ 

	for(i=0; i<height; i++){		//レベル
		sidespace = pow(2,height-i-1)-1;
		for(k=0; k<sidespace; k++) printf("  ");	//左側空白
		space = pow(2,height-i)-1;
		ns = pow(2,i);
		for(j=0; j<ns && count<=heapsize; j++){	//レベルの最大数
			printf("%2d",a[count++]);
			for(k=0; k<space && j+1<ns; k++)
				if(j%2) printf("  "); else printf("--");	//空白
		}
		printf("\n");
	}
	printf("\n");
}

/* 空か? */
int is_empty(void)
{
	return !heapsize;
}

int main(void)
{
	printf("[Before]\n");
    print_tree();

	printf("[Heap(min)]\n");
    build_heap();
    print_tree();

	printf("[Delete Node]\n");
    del_node();
    print_tree();

	printf("[Insert Node(10)]\n");
    ins_node(10);
    print_tree();
 
    return 0;
}
注意:上記のソースコードには、未定義関数が含まれています。

ヒープ・ソート

ヒープ・ソート

ヒープサイズをn、ヒープを格納する配列aの先頭要素a[0]は使用しないものとして、ルートノードはa[1]に格納するものとします。ヒープソート(降順)の手順は、以下のようになります。ただし、あらかじめヒープ化されているデータ列を対象とします。もし、ヒープとなっていなければ事前にヒープ化しておかなければなりません。
  1. ルートノード(最小値)を取り出す。
  2. ヒープの一番下の右端のノードをルート位置に移動する。(配列のa[1]と末尾要素a[n]とを交換する)
  3. ルートノードとその子ノードを比較し、子ノードの方が小さければ交換する。二つの子ノードを持つ場合には小さい方の子ノードと交換する。
  4. 交換した子ノードに移動して、親ノードとなる。親ノードの方が子ノードよりも小さいか、もしくはリーフに到達するまで手順(3)を繰り返す。すべてのノードを取り出したら終了する。

計算量

 ヒープ・ソートは、ヒープのルートノードとヒープの最後のノードとの交換と、交換によって崩れたヒープの再ヒープ化の繰り返しによって行われます。これらの回数はノード数がn個ならばn-1回繰り返すことで完了します。
 ヒープのルートノードと最後のノードの交換の計算量は、n-1回の処理が行われることから、 \[O(n)\] となります。一方、再ヒープ化にかかる計算量は、対象となるヒープサイズが「ヒープのルートノードと最後のノードの交換」によって1ずつ減じられることから、各回ごとの計算量の合計として求める必要があります。1回当たりの計算量は、ルートに配置したノードがshift downによって一番下までずれた場合、木の高さをd、3つのノードからなる部分木の比較回数が2であることから、 \[ 2 \times d \] となります。完全二分木の場合、高さdにあるノードの数は\(2^d\)であることから \[ 2 \times d \times 2^d\] となります。オーダーは増加率の小さな項と係数を無視できることから、高さdにおける比較回数を\( d 2^d\)として、ヒープ全体におけるヒープ化の回数は、 \[ \sum_{i=1}^{d} i2^i = (d-1)2^{d+1} +2 \] ここで、ヒープサイズnと木の高さdとの関係が \(d = log(n+1) - 1\)であることから、 \[上式 < d(2^{d+1} - 1) \\ = n \cdot (\log(n+1) - 1) \] よってオーダーは、 \[O(n \log n)\] となります。ルートノードと最後のノードとの交換の計算量を合わせても、 \[O(n) + O(n \log n) \\ = O(n \log n)\] となります。

expand_lessBack to TOP

演習問題

  1. 次の配列{0, 65, 22, 51,17,8,30,28,12,}はルートを最大値とするヒープとなっています。このヒープにデータ36を追加した後、ヒープになるように木を再構成する過程を図示しなさい。ただし、先頭の要素は使用しないものとします。また、配列の最大要素数は16とします。

  2. 次の配列{0,7,12,18,15,22,20,31,16,35,28}はルートを最小値するヒープになっています。ルートノードを削除した後、ヒープになるように木を再構成する過程を図示しなさい。ただし、先頭の要素は使用しないものとします。

  3. データ列 {5,11,3,7,1}を昇順でヒープソートする過程を配列を含めて図示しなさい。

  4. データ列 {5,11,3,7,1}を降順でヒープソートする過程を配列を含めて図示しなさい。

  5. ルートノードが最大値になるようにヒープ化する関数heapify_max(int i)を作成しなさい。textのサンプルプログラムheap.cの関数build_heap()内における関数heapify_min()の呼び出しを、heapify_max()に置き換えることで、データ列{0,21,12,8,65,22,40,34,17,7,0,}がヒープ化されることを確認すること。

OPEN ANSWER
  1. 省略

  2. 省略

  3. 省略

  4. 省略

  5. 解答例

  6. void heapify_max(int i)
    {
    	int largest = i;	//親
    	int left = 0;	//左の子
    	int right = 0;	//右の子
    
    	if(a[i*2]) left = i*2;
    	if(a[i*2+1]) right = i*2+1;
    
    	if(left <= heapsize && a[left] > a[i]) largest = left;	//左子>親?
    	if(right <= heapsize && a[right] > a[largest]) largest = right;	//右子>(左、親)
    	if(largest != i){	//最大が親でなければ交換
    		swap(a[i],a[largest]);
    		heapify_max(largest);
    	}
    }
    

先頭に戻る

今週の確認テスト テスト(PDF)

本日の提出課題

    データ列{11,3,9,15,57,25,18,2,77,6}から二分探索木を構成して、その二分探索木をもとにノードの値を昇順と降順で表示する下記のプログラムkadai4-6.cを完成させなさい。データ列の昇順と降順の表示には、それぞれ関数print_ascend(Node *node)と関数print_descend(Node *node)を定義して利用すること。加えて、両関数とも再帰呼び出しを使って実現すること。

    [実行例]
    昇順 : 2 3 6 9 11 15 18 25 57 77 
    降ca順 : 77 57 25 18 15 11 9 6 3 2 
    ノード数: 10
    

    [プログラム kadai4-6.c]
    #include <stdio.h>
    #include <stdlib.h>
    #define N 10
    
    struct node {
        int data;
        struct node *left;
        struct node *right;
    };
    typedef struct node Node;
    
    Node *root = NULL;
    
    /* ノードの追加 */
    void insert(int data)
    {
        Node *new, *current, *parent;
    
        new = (Node *)malloc(sizeof(Node));
    	if(new == NULL){
    		fprintf(stderr,"Error: Memory allocation failure.\n");
    		exit(1);	//異常終了
    	}
        new->data = data;
        new->left = NULL;
        new->right = NULL;
    
        if(root==NULL){		//ルートが空の時
            root = new;
        }
        else {
            current = root;	//ルートからはじめる
            parent = NULL;
            while(1){
                parent = current;
                if(data < parent->data){    //左へ(小さい)
                    current = current->left;	//左へ移動
                    if(current == NULL){		//左枝にノードがなければ
                        parent->left = new;
                        return ;
                    }
                }
                else {                      //右へ(大きい)
                    current = current->right;	//右へ移動
                    if(current == NULL){		//右枝にノードがなければ
                        parent->right = new;
                        return ;
                    }
                }
            }
        }
    }
    
    /* 昇順に表示 */
    void print_ascend(Node *node)
    {
    	/***
    	ここを埋めて完成させる
    	***/
    }
    
    /* 降順に表示 */
    void print_descend(Node *node)
    {
    	/***
    	ここを埋めて完成させる
    	***/
    }
    
    /* ノード数の取得 */
    int getsize(Node *node)
    {
    	if(tree==NULL) return 0;
    
    	return getsize(node->left) + getsize(node->right) + 1;
    }
    
    /* 探索木の削除(領域解放) */
    void delete_tree(Node *node)
    {
    	if(node==NULL) return;
    	
    	delete_tree(node->right);
    	delete_tree(node->left);
    	free(node);
    }
    
    int main(void)
    {
        int a[] = {11,3,9,15,57,25,18,2,77,6};
        int i;
    
        for(i=0; i<N; i++) insert(a[i]);	//二分探索木の構成
    
        printf("昇順 : ");
    	print_ascend(root);
        printf("\n");
    
        printf("降順 : ");
    	print_descend(root);
        printf("\n");
    
    	printf("ノード数: %d\n", getsize(root));
    	
    	delete_tree(root);
    	
        return 0;
    }
    



  • [提出方法]
    • 電子メールの添付ファイルとして提出してください。宛先は指定のアドレスです。
    • 表題は、課題6とします。
    • メール本文には、関数print_decsend()の時間計算量オーダーを記しなさい。
    • 添付ファイルは下記の2点です。
    • 1) ソースコード・ファイル kadai4-6.c
      2) 実行結果画面(ウインドウのみ)のハードコピーの画像ファイル kadai4-6.jpg

  • [実行結果画面のハードコピーの取り方]
    1. [メニュー]→[アクセサリ]→Snipping Toolを起動する。ただし、Snipping Toolのウインドウが実行画面に重ならないように、必要に応じてウインドウを移動すること。
    2. タイトルバーを含めた実行結果画面をマウスドラッグして選択して、キャプチャする。
    3. メニューから「ファイル」→「名前をつけて保存」を選択する。ファイルの種類にはJPG形式を選択して、保存先フォルダを選び、ファイル名を付けて「保存」する。

  • [評価について]
  • プログラムの内容の評価とは別に以下の要件を満たすことを評価の前提とする。
    1. ソースコードがC言語で記述されていること。
    2. 提出されたソースコードファイルをコンパイルし、ワーニングやエラーが出力されないこと。scanfをscanf_sとすべきワーニングについては除外する。
    3. 提出されたソースコードファイルから生成される実行形式ファイルを実行した結果、所定の出力結果が得られること。
    4. 提出されたソースコードは、インデントや適当な改行が施された見やすい状態であること。
    5. 変数の利用目的、処理や判定の意味など必要と思われる解説を簡潔にコメント文として付けること。
    6. 提出された実行結果の画面コピーは、コマンドプロンプトのウインドウのみとすること。

  • [提出期限]
  • 2020年 1月1日(水)午後2時まで