学びたてホヤホヤのアウトプットになります。
説明が間違っている場合などは、ご指摘いただけますと幸いです。
二分木(binary tree)
データ構造の1つで、1つの親ノードが2つの子ノードを持つもの。
二分木は大量のデータを効率よく探索できるメリットがある。
以下、二分木の種類。
全二分木(full binary tree)
全てのノードが「子ノードを持たない もしくは 2つの子ノードを持つ」二分木のこと。
つまり、子ノードを1つだけ持つようなノードは存在しないということ。
完全二分木(perfect binary tree)
全てのリーフノードが同じ深さを持つ二分木のこと。
親-子-孫 の3階層ある場合、リーフノードは4つになるということ。
一方で、場合によっては「最下層を除いた全ての階層はノードで満たされ、かつ最下層においてはリーフノードが左側から順に詰められている二分木」のことを指すこともある(こちらも「完全二分木」ではあるものの「(complete binary tree)」と言う)。
また、完全二分木は以下の性質を持つ。
- ルートノードを0階層とした場合、その階層(i)が持つノードの数は「2^i」となる。
- リーフノード以外の内部ノードの合計は「2^i -1」となる。
二分探索木( binary search tree)
ツリーの各ノードの値が「左の子ノード ≤ 親 ≤ 右の子ノード」という制約を持つ二分木のこと。
二分探索木は、データの探索を簡単にするもの。
探したい値(キー)が決まると、まずはルートノードから探索を始める。ルートノードの値とキーを比較し、もしキーが小さい場合は左の子ノード、大きい場合は右の子ノードへと探索を続ける。これをキーが見つかるまで繰り返し行う。
一方で、例えば「1,2,3,4,5」という値を持つノードがあり、ルートノードが5、その左の子ノードが4、その左の子ノードが3、
その左の子ノードが2、その左の子ノードが1となってしまっている場合、探索効率は非常に悪くなってしまう。
平衡二分探索木(self-balancing binary search tree)
ルートノードから各リーフノードまでの高さができるだけ等しくなっている二分木。
この状態だと、二分探索木の探索効率が最も良くなる。
平衡二分探索木の作り方
- 配列の先頭を開始位置、末尾を終了位置と設定する(配列全体を範囲とする場合)。
- 部分木を作成する関数へ「配列、開始位置、終了位置」という引数を渡す。
- 配列の中央の値を二分探索木のルートノードに設定する。
- 部分木を作成する関数へ「配列、開始位置、終了位置 -1」という引数を渡し、再帰的に左の部分木を作る。
- 部分木を作成する関数へ「配列、中央(開始位置) +1、終了位置」という引数を渡し、再帰的に右の部分木を作る。
- 配列の要素が1つになったら、それを新たなノードとしてツリーに追加する。
class BinarySearchTree{
public int data;
public BinarySearchTree left;
public BinarySearchTree right;
public BinarySearchTree(int data, BinarySearchTree left, BinarySearchTree right){
this.data = data;
this.left = left;
this.right = right;
}
}
class Main{
public static BinarySearchTree 関数②(int[] arr, int start, int end) {
// 配列の要素が1つになったら、それを新たなノードとしてツリーに追加する。
if(start == end) return new BinarySearchTree(arr[start], null,null);
// 配列の中央の値を二分探索木のルートノードに設定する。
int mid = (int) Math.floor((start+end)/2);
// 部分木を作成する関数へ「配列、開始位置、終了位置 -1」という引数を渡し、再帰的に左の部分木を作る。
BinarySearchTree left = null;
if(mid-1 >= start) left = 関数②(arr, start, mid-1);
// 部分木を作成する関数へ「配列、中央(開始位置) +1、終了位置」という引数を渡し、再帰的に右の部分木を作る。
BinarySearchTree right = null;
if(mid+1 <= end) right = 関数②(arr, mid+1, end);
// 中央の値をrootとしてreturnする。
BinarySearchTree root = new BinarySearchTree(arr[mid], left, right);
return root;
}
public static BinarySearchTree 関数①(int[] nums) {
if(nums.length == 0) return null;
// 配列の先頭を開始位置、末尾を終了位置と設定する。
// 部分木を作成する関数へ「配列、開始位置、終了位置」という引数を渡す。
return 関数②(nums, 0, nums.length-1);
}
public static void main(String[] args){
BinarySearchTree res = 関数①(int型の配列);
}
}
平衡二分探索木の中で探索する場合は、以下のようにして探す(探しているデータが存在するか否かの探索)。
※ 上記に追加する形を想定。
// 再帰の場合
public static boolean 関数③(int key, BinarySearchTree array){
// そもそも平衡二分探索木(array)がnullならfalse。
if(array == null) return false;
// 平衡二分探索木の中からキー(探索している値)と一致する値があればtrue。
if(array.data == key) return true;
// 現在のノードよりキーが小さければ左の子ノードに、大きければ右の子ノードに移って探索を続ける。
if(array.data > key) return 関数③(key, array.left);
else return 関数③(key, array.right);
}
// 反復の場合
public static boolean 関数③(int key, BinarySearchTree array){
BinarySearchTree iterator = array;
while(iterator != null){
if(iterator.data == key) return true;
if(iterator.data > key) iterator = iterator.left;
else iterator = iterator.right;
}
// iteratorがnullになっても(平衡二分探索木を全て探しても)キーが見つからない場合はfalse。
return false;
}
走査
走査とは、特定のルールに従って各ノードをみて回ることを指す。
そんな走査には、深さ優先走査と幅優先走査がある。
深さ優先走査
ノードから接続している未訪問のノードへ進み、進むことができなくなったら戻るという走査戦略。
以下のような手順をとる。
- 初期ノードから探索を開始。
- 現在のノードに隣接する未訪問のノードがある場合は、そのノードを訪問しそのノードを「現在のノード」とする。
- ステップ2を繰り返す。
- 現在のノードに隣接する未訪問のノードがない場合は、直前に訪れたノードに戻る。
- ステップ2を繰り返す。
- 全てのノードを訪問するか、または目的のノードが見つかった場合に探索を終了する。
迷路の解を求める場合などに有効。
幅優先走査
現在のノードと同じ階層にあるノードに対して探索を行うという走査戦略。
以下のような手順を取る。
- 初期ノードを訪問し、訪問済みのリストやキューに追加。
- 現在のノードに隣接する未訪問のノード全てを訪問し、それぞれを訪問済みのリストやキューに追加。
- 全ての隣接ノードを訪問した後、キューから次のノードを取り出し、それを新たな「現在のノード」とする。
- ステップ2を繰り返す。
- 全てのノードを訪問するか、または目的のノードが見つかった場合に探索を終了する。
最短経路を求める場合などに有効。
深さ優先走査
深さ優先探索は可能な限り深くノードを探索する戦略だが、以下の探索順序の種類が存在する。
前順走査(pre-order traversal)
まずルートノード(N)を調査する。
次に、左の子ノード(L)を再帰的に探索する。
最後に右の子ノード(R)を再帰的に探索する。
この探索の順序は NLR と記述される。
間順走査(in-order traversal)
まず左の子ノード(L)を再帰的に探索する。
次に、ルートノード(N)を調査する。
最後に右の子ノード(R)を再帰的に探索する。
この探索の順序は LNR と記述される。
ノードの値の昇順表示などに使用される。
後順走査(post-order traversal)
まず左の子ノード(L)を再帰的に探索する。
次に、右の子ノード(R)を再帰的に探索する。
最後にルートノード(N)を調査する。
この探索の順序は LRN と記述される。
逆間順走査(reverse in-order traversal)
まず右の子ノード(R)を再帰的に探索する。
次に、ルートノード(N)を調査する。
最後に左の子ノード(L)を再帰的に探索する。
この探索の順序は RNL と記述される。
ノードの値の降順表示に使用される.
二分探索木への挿入
二分探索木への挿入は、挿入後も「左の子ノード < 親ノード < 右の子ノード」という構成を維持する必要がある。
- 関数Insert(root, v)という、既存のルートノード(root)と、挿入したい新しい値(v)を受け取る関数を用意。
- 新しい値(v)を適切な位置に挿入するために、現在見ているノードの値と比較し、vがそのノードの値よりも大きければ右の子ノードへ、小さければ左の子ノードへ移動する。
- 二分探索木を探索し、リーフノードに到達したら、そのノードはvの親ノードとなる。そしてvを、その親ノードの左または右に挿入する。
- 親ノードが存在しない場合、v二分探索木の新しいルートノードとなる。
二分探索木からの削除
二分探索木からの削除は、挿入よりも複雑になる、
理由としては、削除するノードの位置にどのノードを置くのか決めなければならないため。さらに、その新たに置かれたノード以下の部分木も、適切に二分探索木の構造を維持しなければならないため。
二分探索木からノードを削除するアルゴリズムは、以下の5つのケースを考慮する必要がある。
その1:ツリーが空の場合、または削除対象のノードが存在しない場合
既存のツリーを返す。
その2:削除対象のノードがリーフノードの場合
そのノードを削除する。
その3:削除対象のノードが左側に子ノードを持たない場合
削除対象のノードの右側の部分木を、親ノードの部分木に置き換える。
その4:削除対象のノードが右側に子ノードを持たない場合
削除対象のノードの左側の部分木を、親ノードの部分木に置き換える。
その5:削除対象のノードが左右に子ノードを持つ場合
まず、削除対象のノード(N)の後続ノード(削除対象のノードの値よりも大き値のノードの中で、最も小さい値を持つノード)を見つける。
後続ノード(S)は、削除対象のノードの右側の部分木に必ず存在する。
5-1:後続ノードの親ノード(SP)が削除対象のノード(N)と同じ場合
後続ノード(S)が削除対象のノード(N)の直接の子である場合、後続ノード(S)をその親ノード(SP)に置き換える。
後続ノード(S)は、削除対象のノード(N)より大きい最小値であるため左部分木は常にnullとなっている。
5-2:後続ノードの親ノード(SP)が削除対象のノード(N)と異なる場合
後続ノード(S)が削除対象のノード(N)の右部分木の深部にある場合、以下の処理を行う。
まず、後続ノードの親ノード(SP)から後続ノード(S)を取り除き、その位置に後続ノード(S)の右部分木を移す。
次に、削除対象のノード(N)の位置に後続ノード(S)を置き換える。この結果、後続ノード(S)が削除対象ノード(N)の親ノード(P)の新しい子ノードとなる。
最後に、削除対象のノード(N)の左部分木を後続ノード(S)の左部分木とする。これにより、削除対象のノード(N)の左部分木が保持され、後続ノード(S)が削除対象のノード(N)の全ての子を引き継ぐ。
二分ヒープ(binary heap)
二分ヒープは、親ノードの値がその子ノードの値と等しいかそれより大きい(または小さい)という特徴を持つ。
これにより、最大ヒープ(親ノードの値が子ノードの値よりも大きい)または、最小ヒープ(親ノードの値が子ノードの値よりも小さい)が形成することができる。
兄弟ノード(同階層のノード)間での大小については制約はない。
また、二分ヒープは完全二分木の性質(全ての階層が左から右へ順に埋まっている状態)を持つため、配列として表現することも可能。
二分ヒープは完全二分木であることから、配列のインデックスをiとすると以下の規則を見出せる。
- i番目のノードの左の子ノード: left(i) = 2i + 1
- i番目のノードの右の子ノード: right(i) = 2i + 2
- i番目のノードの親ノード: parent(i) = floor((i-1) / 2)
[7,11,16,20,28,39,22,45,32,41] という配列があった場合、以下となることがわかる。
0番目のノードの左の子ノード: left(0) = 1 → arr[1]は11でarr[0]の左の子ノード
0番目のノードの右の子ノード: right(0) = 2 → arr[2]は16でarr[0]の右の子ノード
4番目のノードの左の子ノード: left(4) = 9 → arr[9]は41でarr[4]の左の子ノード
4番目のノードの右の子ノード: right(4) = 10 → arr[10]は存在しないのでarr[4]の右の子ノードは存在しない
4番目のノードの親ノード: parent(4) = 1 → arr[1]は11でarr[4]の親ノード
Max-Heapify
Max-Heapify関数は、最大ヒープというデータ構造を維持するための関数。あるノードとその子ノードが最大ヒープのルールを守るよう、必要に応じてノード間のデータを入れ替える処理を行う。
次のようなステップで進められます。
- ある特定のノード(i)を起点にし、そのノード(i)が、自分自身とその子ノードの中で最大の値であることを確認する。
- ノード(i)が最大の値を持つ場合は、処理を終了する。
- ノード(i)が子ノードよりも小さい値を持つ場合は、そのノード(i)と最大の値を持つ子ノードを交換する(スワップ)。
- スワップによって移動した子ノードに対して、再度Max-Heapify関数を適用する。つまり、その子ノードが新しい場所で最大値を持つことを確認し、必要であればさらにスワップする。
- すべてのノードが最大ヒープのルールを守るようになるまで繰り返す。
上記は特定のノードに対して最大ヒープの特徴を持たせることはできるが、配列全体に対して最大ヒープの特徴を持たせることはできない。
配列全体に対して最大ヒープの特徴を持たせるためには、配列の中央からスタートし、配列の先頭(ルートノード)に向かって maxHeapify関数を各ノードに適用することが必要となる。
配列の中央からスタートする理由としては、ヒープは完全二分木であるがゆえに、配列で表現した場合その半分以上はリーフノードとなる。リーフノードには子ノードが存在しないため、リーフノードは「親ノードが子ノードより大きい」というヒープの条件を満たすことが保証される。
配列全体に対する最大ヒープの実装例。
import java.util.Arrays;
import java.util.ArrayList;
class Heap{
// 配列i番目の左の子ノードの要素番号を返す
public static int left(int i){
return 2*i + 1;
}
// 配列i番目の右の子ノードの要素番号を返す
public static int right(int i){
return 2*i + 2;
}
// 配列i番目の親ノードの要素番号を返す
public static int parent(int i){
return (int)Math.floor((i-1)/2);
}
// maxHeapify関数
public static void maxHeapify(ArrayList<Integer> arr, int i){
int l = Heap.left(i);
int r = Heap.right(i);
// 現在の要素番号を最大と仮定
int biggest = i;
// 配列の中に要素番号lが存在する 且つ 要素番号lの値がbiggestの値よりも大きい場合はbiggestを更新
if(l < arr.size() && arr.get(l) > arr.get(biggest)) biggest = l;
// 配列の中に要素番号rが存在する 且つ 要素番号rの値がbiggestの値よりも大きい場合はbiggestを更新
if(r < arr.size() && arr.get(r) > arr.get(biggest)) biggest = r;
// biggestの値が更新されていた場合
if(biggest != i ){
// 要素番号i番目の値と、biggestの値を入れ替える
int temp = arr.get(i);
arr.set(i, arr.get(biggest));
arr.set(biggest, temp);
// 入れ替えたノードの位置が正しいか、maxHeapifyを呼び出して確認
Heap.maxHeapify(arr, biggest);
}
}
public static void buildMaxHeap(ArrayList<Integer> arr){
int middle = Heap.parent(arr.size()-1);
// 配列の中央からルートノードまでmaxHeapify関数を適用する
for(int i = middle; i >=0; i--){
Heap.maxHeapify(arr,i);
}
}
}
class Main{
public static void main(String[] args){
ArrayList<Integer> heap = new ArrayList<>(Arrays.asList(new Integer[]{7,11,39,16,20,28,22,45,32,41}));
System.out.println(heap); // → [7, 11, 39, 16, 20, 28, 22, 45, 32, 41]
Heap.buildMaxHeap(heap);
System.out.println(heap); // → [45, 41, 39, 32, 20, 28, 22, 16, 11, 7]
}
}
ヒープソート
ヒープソートは、二分ヒープのデータ構造を用いてデータのソートを行うアルゴリズム。
- 与えられたデータから最大ヒープを作成する。
- ヒープから最大値(ルートノード)を取り出し、最後のリーフノードと交換する。
- ヒープとして考慮する範囲から最大値を除く。
- ここまでの結果ヒープの性質が崩れている可能性があるため、maxHeapify関数を呼び出し、再び最大ヒープを作成する。
- これらの手順をヒープのサイズが1になるまで繰り返す(配列全体がソートされたということ)。
例:[2, 10, 8, 5, 1]という配列の場合。
最初の最大ヒープ: [10, 8, 5, 2, 1]
1回目: [8, 5, 2, 1] と 10
2回目: [5, 2, 1] と 8, 10
3回目: [2, 1] と 5, 8, 10
4回目: [1] と 2, 5, 8, 10
5回目: [] と 1, 2, 5, 8, 10
ヒープソートの実装例
import java.util.Arrays;
import java.util.ArrayList;
class Heap{
public static int left(int i){
return 2*i + 1;
}
public static int right(int i){
return 2*i + 2;
}
public static int parent(int i){
return (int)Math.floor((i-1)/2);
}
public static void maxHeapify(ArrayList<Integer> arr, int end, int i){
int l = Heap.left(i);
int r = Heap.right(i);
int biggest = i;
if(l <= end && arr.get(l) > arr.get(biggest)) biggest = l;
if(r <= end && arr.get(r) > arr.get(biggest)) biggest = r;
if(biggest != i ){
int temp = arr.get(i);
arr.set(i, arr.get(biggest));
arr.set(biggest, temp);
Heap.maxHeapify(arr, end, biggest);
}
}
public static void buildMaxHeap(ArrayList<Integer> arr){
int middle = Heap.parent(arr.size()-1);
for(int i = middle; i >=0; i--){
Heap.maxHeapify(arr, arr.size()-1, i);
}
}
public static void heapSort(ArrayList<Integer> arr){
// arrを最大ヒープに
Heap.buildMaxHeap(arr);
int end = arr.size() - 1;
while(end > 0){
// ヒープの最大ノードと最後のリーフノードを入れ替える
int temp = arr.get(end);
arr.set(end, arr.get(0));
arr.set(0, temp);
// ヒープとして考慮する範囲から最大値を除く
end--;
Heap.maxHeapify(arr, end, 0);
}
}
}
class Main{
public static void main(String[] args){
ArrayList<Integer> heap = new ArrayList<>(Arrays.asList(new Integer[]{7,11,39,16,20,28,22,45,32,41}));
System.out.println(heap); // → [7, 11, 39, 16, 20, 28, 22, 45, 32, 41]
Heap.buildMaxHeap(heap);
System.out.println(heap); // → [45, 41, 39, 32, 20, 28, 22, 16, 11, 7]
}
}
優先度付きキュー(priority queue)
優先度付きキューとは、要素が優先度順に格納されるキューのことを指す。
具体的な操作としては、以下などが挙げられる。
- top(): 最も高い優先度を持つ要素を取得する。
- insert(x): 要素xを優先度に基づいて適切な位置に挿入する。
- pop(): 最も高い優先度を持つ要素をキューから取り除き、その要素を返す。
ヒープは特定の順序でデータを効率的に管理するデータ構造のため、優先度付きキューの実装に使用される。
優先度付きキューの実装例
import java.util.Arrays;
import java.util.ArrayList;
class Heap{
public static void buildMaxHeap(ArrayList<Integer> arr){
int middle = Heap.parent(arr.size()-1);
for(int i = middle; i >=0; i--){
Heap.maxHeapify(arr, arr.size()-1, i);
}
}
public static void maxHeapify(ArrayList<Integer> arr, int end, int i){
int l = Heap.left(i);
int r = Heap.right(i);
int biggest = i;
if(l <= end && arr.get(l) > arr.get(biggest)) biggest = l;
if(r <= end && arr.get(r) > arr.get(biggest)) biggest = r;
if(biggest != i ){
int temp = arr.get(i);
arr.set(i, arr.get(biggest));
arr.set(biggest, temp);
Heap.maxHeapify(arr, end, biggest);
}
}
public static int left(int i){
return 2*i + 1;
}
public static int right(int i){
return 2*i + 2;
}
public static int parent(int i){
return (int)Math.floor((i-1)/2);
}
}
// 優先度付きキュー
class PriorityQueue{
public ArrayList<Integer> maxHeap;
public PriorityQueue(ArrayList<Integer> arr){
this.maxHeap = new ArrayList<Integer>();
for(Integer i: arr) this.maxHeap.add(i);
Heap.buildMaxHeap(this.maxHeap);
}
public int top(){
// maxHeapの先頭要素を返す
return this.maxHeap.get(0);
}
public int pop(){
// maxHeapのルートノードの値を変数へ格納
Integer temp = this.maxHeap.get(0);
// maxHeapの最後の要素の値をルートノードに設定
this.maxHeap.set(0, this.maxHeap.get(this.maxHeap.size()-1));
// maxHeapの最後の要素を削除
this.maxHeap.remove(this.maxHeap.size()-1);
// 最大ヒープを構築
Heap.maxHeapify(this.maxHeap, this.maxHeap.size()-1, 0);
// 最初に格納しておいたルートノードの値を返す
return temp;
}
public void insert(int x){
// 新しい要素をmaxHeapの最後に追加
this.maxHeap.add(x);
// 新しく追加した要素(x)のインデックスを取得
int i = this.maxHeap.size()-1;
// 新しく追加した要素(x)の親のインデックスを取得
int parent = Heap.parent(i);
// 新しく追加した要素(x)を適切な位置に配置
while(parent >= 0 && this.maxHeap.get(parent) < x){
Integer temp = this.maxHeap.get(i);
this.maxHeap.set(i, this.maxHeap.get(parent));
this.maxHeap.set(parent, temp);
i = parent;
parent = Heap.parent(i);
}
}
}
class Main{
public static void main(String[] args){
ArrayList<Integer> heap = new ArrayList<>(Arrays.asList(new Integer[]{7,11,39,16,20,28,22,45,32,41}));
System.out.println(heap); // → [7, 11, 39, 16, 20, 28, 22, 45, 32, 41]
PriorityQueue pq = new PriorityQueue(heap);
System.out.println(pq.maxHeap); // → [45, 41, 39, 32, 20, 28, 22, 16, 11, 7]
pq.insert(3);
System.out.println(pq.maxHeap); // → [45, 41, 39, 32, 20, 28, 22, 16, 11, 7, 3]
pq.insert(60);
System.out.println(pq.maxHeap); // → [60, 41, 45, 32, 20, 39, 22, 16, 11, 7, 3, 28]
System.out.println(pq.pop()); // → 60
System.out.println(pq.maxHeap); // → [45, 41, 39, 32, 20, 28, 22, 16, 11, 7, 3]
}
}
最後に
以前DB設計をした際にインデックスのことを考えるためにB-treeのことを少し調べましたが、今ならもう少し理解できたりするのかな、、?と思いました(全然そんなことはないかもしれないけど)。