] > Tree(1)

第 8 回 木(1)

本日の内容


このドキュメントは http://edu.net.c.dendai.ac.jp/ 上で公開されています。

8-1. 順序木

順序木の定義

順序木を使うと、与えた要素を低いコストで順序順にとりだすことが可能にな ります。 順序木とは、各頂点に値を持つ二分木のうち、次の性質を持つものです。

  1. 左の枝に接続している全ての頂点の要素は、この頂点の要素より小さい。
  2. 右の枝に接続している全ての頂点の要素は、この頂点の要素より大きい。
順序木

このような性質を持っている木に対して新たな値を格納することを考えます。 値を格納できる場所を探す際、各頂点の持つ値と比較していくことで、頂点の 右の枝の方面か左の枝の方面か選ぶことができます。 すると、木の深さ分だけ値を比較することで挿入可能な場所を捜し出せること になります。 木の深さは、都合が良い場合で全頂点数 N に対して O( log N ) 程度になりますので、挿入の手間もその程度で収まります。 また、最小、最大の値もそれぞれ木の一番左と右の要素になりますから、やは り木の深さ程度の手間で見つけられます。

演習8-1

値 1, 2, 3, 4 を持つ順序木を全て書きだしなさい。

ヒント

全ての二分木の形に対して、それぞれに格納の方法が一通りだけ可能です。

C++ での順序木

C++ では外見上、木構造を提供していませんが、 std::map と std::multimap は内部的に順序木を使ってます。 格納できる値はキーと値の二種類です。map は重複するキーを許さず、 multimap は重複キーを許します。 map や multimap を使うには引数に、キーの型、値の型、キーの比較をするク ラス std::less テンプレートを指定します。 値を挿入する insert メソッドを使うには挿入するための特別な型のオブジェ クトを作らなければなりません。そのため、map や multimap で作った型 x に対して、x::value_type という別のクラスを用意し、このインスタンスを使っ て挿入します。 iterator は begin(), end(), lower_bound(), upper_bound() などのメソッ ドで得られます。 なお、 map ではさらに オブジェクト[キー] という構文で検索、 更新が可能です。以下にサンプルプログラムを示します。


#include <iostream>
#include <string>
#include <map>
#include <functional>
using std::string;
using std::cout;
using std::endl;
using std::ostream;
using std::multimap;
using std::less;
typedef multimap<int ,string , less<int> > MMAP;
typedef MMAP::value_type Mvalue;
ostream& operator<<(ostream& os, const Mvalue& p){
  os << p.second << ": " << p.first;
  return os;
}
main(){
  MMAP m;
  m.insert(Mvalue(100,"02kc963"));
  m.insert(Mvalue(62,"02kc903"));
  m.insert(Mvalue(85,"02kc923"));
  m.insert(Mvalue(73,"02kc911"));
  m.insert(Mvalue(85,"02kc987"));
  cout << "size = " << m.size() << endl;
  for(MMAP::iterator i=m.begin(),end=m.end(); i!=end; i++){
    cout << *i << endl;
  }
  cout << "---------------------------------" << endl;
  for(MMAP::iterator i=m.lower_bound(70), end=m.upper_bound(90);
      i!=end; i++){
    cout << *i << endl;
  }
}

なお、ここで用いる std::less というテンプレートは以下のように定義されていま す。 < が定義されている型に対して、このテンプレートはクラス(struct)を定 義し、そのインスタンス x に対して、元の型の値 y,z に対して x(y,z) で y<z の値を返すものです。 下に評価用のテストプログラムを示します。

stl_function.h

template <class _Tp>
struct less : public binary_function<_Tp,_Tp,bool> 
{
  bool operator()(const _Tp& __x, const _Tp& __y) const { return __x < __y; }
};

#include <iostream>
#include <functional>
using std::cout;
using std::endl;
main(){
  std::less<int> l;
  cout << l(1,2) << endl;
  cout << l(1,1) << endl;
  cout << l(2,1) << endl;
}

補足

上のプログラムではstd::cout << Mvalue の値; という 構文で値を表示できるように以下のような関数を用意しました。 C++ 言語ではこのように演算子を再定義できます(乱用すると混乱のもとです が)。 一般のオブジェクト指向の構文では、オブジェクトとオブジェクトの間に入る演算子 は、左側のオブジェクトのメソッドになります。 そのため、ここで定義するのも、 cout のオブジェクト型である std::ostream ク ラスの演算子 << の再定義になります。 なお、この場合は Mvalue 型の表示にパブリックメンバしか使わなかったので、 この定義だけで済みますが、もしプライベートメンバを使用しなければならな い時は、そのクラスの宣言時にこの演算子の再定義に対してfriend 指定をする必要があります。


std::ostream& operator<<(std::ostream& os, const Mvalue& p){
  os << p.second << ": " << p.first;
  return os;
}

Java での順序木

java.util.Collections クラスには様々な static メソッドが定義されていて、 Collections.sort() メソッドは List をソートします。 従って、要素を一回だけ整列するだけなら、先週の java.util.LinkedList を 用い、 Collections.sort() メソッドを使うだけで要素を整列できます。 但し一回につき O( Nlog N ) 程度の時間がかかります。

ところが、要素を増やしたり減らしたり変更したりと、ダイナミックに値の列が 変化する時、毎回 sort() メソッドを呼び出すことになり効率的な処理になり ません。 そのため、順序木を用いる必要があります。 Java では java.util.TreeMap というクラスが順序木を内部で実現しており、 データの挿入、削除、検索、昇順の取り出しなどが高速(データ数 N に対して O( log N ) 程度) でできます。 データを入れるには put メソッドを使いますが、キーに使用できるのは java.lang.Comparable インターフェイスを実装しているオブジェクトです。 その他、java.util.Comparator インタフェースを実装しているクラスのオブ ジェクトをコンストラクタに渡すこともできます。

put メソッドにはキーと値の二つを java.lang.Object 型で与えます。したがっ て int のような基本データ型をキーとして与えるには注意が必要です。 int は基本データ型であってオブジェクトではないので、キーとして使うには ラッパークラスjava.lang.Integer でオブジェクトに変換する必要があります。 また、java.lang.Integer は java.lang.Comparable を実装していますのでそ のままキーとして使えます。

TreeMap のイテレータを得るには、親クラスである java.util.Map のメソッ ド entrySet() を使います。 これは java.util.Map.Entry 型のオブジェクトを要素に持つ java.util.Set 型のオブジェクトが得られます。 この java.util.Set 型のオブジェクトに対して iterator() メソッドを使っ てイテレータを得ることができます。 イテレータに対して next() メソッドを送ると java.util.Map.Entry 型のオ ブジェクトが得られます。 Map.Entry には getKey(), getValue() メソッドがあります。 特定のキーの値を取り出すには getKey()メソッドを使います。 これは値がオブジェクトとして直接得られます。 一方、キーで出力範囲を指定するには subMap() メソッドを使います。 subMap() メソッドでは java.util.SortedMap 型のオブジェクトが得られるだ けですので、さらに、 entrySet().iterator() とメソッドを使う必要があり ます。 以下にサンプルコードを示します。


import java.lang.Integer;
import java.util.*;
class TestTree{
    public static void main(String arg[]){
	TreeMap m = new TreeMap();
	m.put(new Integer(100), "02kc963");
	m.put(new Integer(62), "02kc903");
	m.put(new Integer(85), "02kc923");
	m.put(new Integer(73), "02kc911");
	m.put(new Integer(85), "02kc987");//重複キーはサポートされず上書き
	System.out.println("size ="+m.size());
	Set s = m.entrySet();
	Iterator i=s.iterator();
	while(i.hasNext()){
	    Map.Entry e = (Map.Entry) i.next();
	    System.out.println(e.getValue()+": "+e.getKey());
	}
	System.out.println("---------------------------------");
	i=m.subMap(new Integer(70), new Integer(90)).entrySet().iterator();
	while(i.hasNext()){
	    Map.Entry e = (Map.Entry) i.next();
	    System.out.println(e.getValue()+": "+e.getKey());
	}
    }
}

なお、上のようにすると重複キーが使えず、期待したとおりには値を扱うこと ができません。 C++ では重複キーを許さない map と重複キーを許す multimap が使えました が、 java.util.TreeMap では許されません。 例のように点数順に並べようとしても、同点の人が最大で一人しか存在できな いのです。 このような場合、重複キーを許すには、「同一であることがわからない」よう な比較子を与えれば解決します。 java.util.Comparator を実装するには compare() メソッドを与える必要があ ります。compare(x,y) は x<y なら正の数、 compare(x,y) が等しいなら 0、 compare(x,y) なら負の数を整数値で返さねばなりません。 ここで、等しくても 1 を返すようにしてしまえば同じキーを使っても別のキー と認識され、同じキーで複数の要素を与えることができます。 以下のクラスを付け足し TreeMap のオブジェクト生成でこの比較子を与える と上記のプログラムは期待通りに動きます。


import java.util.Comparator;
public class Comp implements Comparator {
    public int compare(Object o1, Object o2) {
	int result=((Integer)o1).compareTo((Integer)o2);
	if(result==0){
	    result=1;
	}
	return result;
    }
}
	Comp c=new Comp();
	TreeMap m = new TreeMap(c);

C 言語での順序木

C 言語では順序木を作るのに、構造体とポインタを使います。構造体の中にキー と、値と、左右の子へのポインタを格納します。 そして、値を付け加える関数 add(TREE **t, キー, 値)は、次のように再帰的に 処理します。

  1. 注目している木の頂点 **t に格納されているキー (*t)->key との大小によ り左 (*t)->left か右 (*t)->right かを選択し、どちらかの頂点に対 して改めて add((*t)->left, キー, 値) か add((*t)->right, キー, 値) かを再帰的に実行する。
  2. もし、(*t) が NULL だったらそこに頂点を付け加える。

なお、頂点を付け加える処理で NULL だったポインタの値を書き換えたいので、 ポインタの格納されている番地を引数に渡してポインタの内容を書き換えてま す。


#include <stdio.h>
#include <stdlib.h>
#include <string.h>
typedef struct tr {
  int key;
  char *id;
  struct tr *left,*right;
} TREE;
void add(TREE **t, int p, char *i){
  TREE *newnode;
  if(*t==NULL){
    *t=(TREE *)malloc(sizeof(TREE));
    (*t)->key=p;
    (*t)->id=i;
    (*t)->left=NULL;
    (*t)->right=NULL;
  }else{
    if((*t)->key>p){
      add(&((*t)->left),p,i);
    }else{
      add(&((*t)->right),p,i);
    }
  }
}
void show(TREE *t){
  if(t!=NULL){
    show(t->left);
    printf("%s: %d\n",t->id,t->key);
    show(t->right);
  }
}
main(){
  TREE *t=NULL;
  add(&t,100,"02kc963");
  add(&t,62,"02kc903");
  add(&t,85,"02kc923");
  add(&t,73,"02kc911");
  add(&t,85,"02kc987");
  show(t);
}

なお、このままでは木の構造がわかりづらいので、木のどの頂点にあるかを表 示する関数 showstructure()を作りました。 呼び出す時は、木の根の頂点へのポインタを t として showstructure(t,""); として呼び出します。


void showstructure(TREE *t,char *depth){
  char *p,*q;
  if(t!=NULL){
    p=(char *)malloc((strlen(depth)+2)*sizeof(char));
    printf("%s: %d\n",depth,t->key);
    strcpy(p,depth);
    for(q=p;*q!='\0';q++);
    *(q+1)='\0';
    *q='l';
    showstructure(t->left,p);
    *q='r';
    showstructure(t->right,p);
    free(p);
  }
}

演習8-2

  1. 上のプログラム例において、 showstructure() を呼び出しなさい。
  2. 表示された結果を元に、木の構造を求め図に書きなさい。

8-2. クイックソート

順序木は中央の頂点の値は左側のどの頂点の値よりも大きく、右側のどの頂点 の値よりも小さいです。 従って、順序木が出来上がった時、木の構造を縦に自然に潰すと、値の小さい 頂点から大きい頂点まで整列することがわかります。

順序木
潰した順序木

そこで、与えられた配列に対して、順序木に格納しているように値を振り分け ることで、配列の中身を整列できます。 つまり、次のような手順になります。

  1. 配列が与えられた時、その配列の中で中央の頂点へ入れる値を一つ決 め、
  2. 左側に配置する値と右側に配置する値を分類します。
  3. そして、左側、右側双方で同様のことを繰り返します。

まず、配列の中で中央の頂点に入れる値を勝手に 0 番目の値と決めてしまい ましょう。 次に分類ですが、配列の左と右の両方から見て行き、中央の頂点より大きい値 を左から探し、中央の頂点より小さい値を右から探すようにします。 そして、両方とも見つかったら交換します。 このようにすると最終的に左側に小さい値、右側に大きい値が得られます。 そして、左と右に再帰的に同じ処理をすれば全ての値が整列できます。 このような整列法を クイックソートと言います。 この整列法は現在知られている整列法のうち、実際実装して使用する上ではもっ とも速い整列法として知られています。


#include <stdio.h>
int a[]={7,10,3,6,1,9,2,4,5,8,-1};
void show(){
  int i;
  for(i=0;a[i]!=-1;i++){
    printf("%d ",a[i]);
  }
  printf("\n");
}
void partition(int from, int to){
  int s,x,f=from,t=to;
  if(from==to){
    return;
  }else{
    s=a[from];
    while(f!=t){
      while((a[f]<s)&&(f!=to)){f++;};
      while((a[t]>s)&&(t!=from)){t--;};
      x=a[f];
      a[f]=a[t];
      a[t]=x;
    }
    partition(from,f);
    partition(++t,to);
  }
}
void quicksort(){
  partition(0,9);
}
main(){
  show();
  quicksort();
  show();
}

演習8-3

上のプログラムを手で動きを追っていき、どのように配列が整列されるか解析 しなさい。 例えば partition を呼び出す時のパラメータ、その時の配列の様子などを 列挙して動きを理解しなさい。

8-3. バランス木

木に対する処理の実行速度は木の深さに依存します。 頂点数 N の木は一番コンパクトに詰め込むと O( log N ) の深さになりますが、 一直線に頂点が並ぶと O( N ) になってしまいます。 これは木に対する操作のコストに響きます。 クイックソートにおいても、あらかじめ整列されたデータを与えられた場合など、 partition 関数を呼び出す深さが O( N ) 回になってしまうので、最悪実行時間は O( N2 ) になります。

このように木の形により計算効率は大きく変わります。 したがって、効率の良い木を作ることは重要です。

効率のよい木にする一つのアイディアは回転です。演習8-1で見たように同じ データでどのようにも二分木に格納できますので木の構造を都合が良いように 変えることです。 そのために行うことは根の頂点を変更することです。

順序木
回転

このアイディアで木の深さを O( log N ) に保つ方式にはスプレー木二色木赤黒木Red-Brack-Tree と呼ばれるものがあります。 赤黒木は効率が良く、C++ や Java の内部で実現されています。しかしこれら の手順は複雑なため、ここでは省略します。

この他、二分木という構造を変えて効率を良くした木に2-3木B木B+B* などがあります。 詳しくは、インターネットやクリフォード・シェーファーの本などを参照して 下さい(但しこの本には赤黒木のことは書いてありません)。

8-4. ヒープ木

順序木はデータの数と木の形が決まるとデータの格納方 法は一通りしかありません。 そのため、データを入力順に格納すると、木の形が入力の順番により決定して しまいます。 そこで、順序木とは別のルールを定めます。 順序木では三つのデータのうち、中間の値が根となる二分木を考えましたが、 最大の値を根とする木を考えます。このような木をヒープ木と言 います。 このようにすると、木の形に対して、何通りもデータの格納方法が得られます。 特に決まったヒープに対して新たなデータを付け加える場合も、左側にも右側に も付け足すことができます。

演習8-4

次の木に 1,2,3,4 の値の格納の仕方は何通りあるか?

4頂点の完全二分木

二分木のうち、根から葉、左から右という風に頂点を増やしてできた木を 完全二分木と言います。 ヒープ木では次のように完全二分木を保ったまま頂点を追加することができま す。

  1. まず、得られた値を完全二分木を保つように付け加えます。
    手順1
  2. そして、付け加えた頂点と親の頂点との関係を調べます。 もし、親より大きい値であれば、親とその頂点の値を交換します。
    手順2
  3. そして、さらに同様にその付け加えた値とその親の関係を調べます。
    手順3

このようにすると、付け加えた値の付近だけしかヒープ木の条件が壊れてませ ん。そして、付け加えた値と親とを順に交換していくことでいつかはヒープ木 の条件を満たすことになります。 したがって、付け加える手間は完全二分木の深さに比例することがわかります ので、頂点数 N に対して O( log N ) の手間になります。

一方、根の値はヒープ木全体に対して最大値になっています。 これを一つずつ取り出せれば、入れたデータを大きい順に整列できます。 しかし、取り出す際に完全二分木を保つ必要があります。

  1. そのため、根の値を取り除いた後、完全二分木を保つために取り除くべき頂点 の値を根に移動します。
    手順1
  2. そして、根と二つの子との間で最大値が親になるように値を交換していきます。
    手順2

交換した値を次々子との間で値を交換していくといずれはすべてがヒープ木の 条件を満たすことになります。 このようにすると O( log N ) の手間で最大の値を得られることになります。 残ったヒープ木は、完全二分木であり、根に二番目に大きい値があります。 したがって、根から値を取り除き続ければ大きい順に値が得られるので、デー タを整列できることになります。 このような整列法をヒープソートと言います。 N 個のデータのそれぞれに対して、付け加える手間も、取り除く手間もともに O( log N ) なので、 全体で O( Nlog N ) とクイックソートと同じオーダになります。 なお、クイックソートでは与えるデータにより効率が落ちましたが、ヒープソー トではそのようなことはありません。 その一方、都合のよいデータに対してはクイックソートでは数倍効率が良くな ります。

ヒープ木は完全二分木で作れるので、次のように配列で実現できます。 根から順に 0, 1, 2 と番号を付けると、 n 番目の頂点に対する親と子はそれ ぞれ ( n- 1 ) /2 番目と、 2 n+ 1 2 n+ 2 番目になります。

手順2

#include <stdio.h>
int a[]={7,10,3,6,1,9,2,4,5,8,-1};
int b[10];
int size=0;
void add(int n){
  int i=size++,next,c;
  b[i]=n;
  while(i>0){
    next=(i-1)/2;
    if(b[i]>b[next]){
      c=b[i];
      b[i]=b[next];
      b[next]=c;
      i=next;
    }else{
      break;
    }
  }
}
int pop(){
  int value=b[0];
  int i=0,c,next1,next;
  b[0]=b[--size];
  while(i<size){
    next=2*i+1;
    if(next<size){
      if(next+1<size){
	if(b[next]<b[next+1]){
	  next++;
	}
      }
      if(b[i]<b[next]){
	c=b[i];
	b[i]=b[next];
	b[next]=c;
	i=next;
      }else{
	break;
      }
    }else{
      break;
    }
  }
  return value;
}
main(){
  int i;
  for(i=0;a[i]!=-1;i++){
    printf("%d ",a[i]);
    add(a[i]);
  }
  printf("\n");
  while(size!=0){
    printf("%d ",pop());
  }
  printf("\n");
}

参考

ヒープソート

ヒープソートでは、実際は値を取り出す際に、ヒープを実現している配列の後 ろから取り出した値を格納します。 これは、値を取り出すたびにうしろから使わなくなった領域ができてきますの で、そこに大きい順に値を入れていくと最終的に小さい順に値を整列できます。

定理

データの数と木の形が決まると、順序木にデータを格納する方法は一通りしか ない。

証明

根の頂点の値は、左側にある頂点よりも大きく、右側にある頂点よりも小さい ので、左側の頂点の数と右側の頂点の数から、頂点に格納すべき値と、左側、 右側に分けられるべきデータが決まる。 これを帰納的に適応すると、根から順に頂点に割り当てるデータが一通りに決 まる。(証明終り)


坂本直志 <sakamoto@c.dendai.ac.jp>
東京電機大学工学部情報通信工学科