七、JavaScriptはツリー構造を実装する(1)
1. ツリー構造の概要
1.1 ツリー構造の簡単な理解
配列/リンク リスト/ハッシュ テーブルに対するツリー構造の利点:
配列:
- 利点:添字値によってアクセスでき、効率が高い。
- 欠点: データを検索する場合、検索効率を向上させるために、まずデータを並べ替えて順序付けされた配列を生成する必要があり、要素の挿入と削除の場合は、大量の移動操作が必要です。
リンクされたリスト:
- 利点: データの挿入および削除操作は非常に効率的です。
- デメリット:検索効率が低く、目的のデータが見つかるまで最初から検索する必要がある、リンクリストの途中にデータを挿入・削除する必要がある場合、挿入・削除の効率が高くない。
ハッシュ表:
- 利点: ハッシュ テーブルの挿入/クエリ/削除効率が非常に高い。
- 欠点:スペース使用率は高くなく、基礎となる配列内の多くのユニットは使用されません。また、ハッシュ テーブル内の要素の順序が狂っており、ハッシュ テーブル内の要素を固定された順序で走査することができません。ハッシュをすぐに見つけることができない これらの特別な値は、テーブル内の最大値または最小値です。
ツリー構造:
- 長所: ツリー構造は、上記の 3 つの構造の長所を組み合わせ、また、ツリー構造内のデータが順序付けされている、検索効率が高い、などの欠点を補います (効率が必ずしも高いわけではありません)。空間利用率が高く、最大値や最小値などを素早く取得できる。
一般に、各データ構造には独自の特定のアプリケーション シナリオがあります。
ツリー構造:
- ツリー (Tree) : n (n ≥ 0) 個のノードの有限の集合。n = 0 の場合、それは空のツリーと呼ばれます。
空ではないツリー (n > 0) には、次のプロパティがあります。
- 番号にはルートと呼ばれる特別なノードがあり、**r** で表されます。
- 残りのノードは、互いに交差しない m (m > 0) 個の有限集合 T1、T2、...、Tm に分割でき、各集合自体がツリーであり、元のツリーのサブツリー (SubTree) と呼ばれます。)。
樹木に関する一般的な用語:
- ノードの次数 (Degree) :ノードのサブツリーの数。たとえば、ノード B の次数は 2 です。
- ツリーの次数:ツリー内のすべてのノードの最大次数。上の図に示すように、ツリーの次数は 2 です。
- リーフノード (Leaf) :上図の H、I などの次数 0 のノード(リーフノードとも呼ばれます)。
- 親ノード (Parent) : 次数が 0 ではないノードは親ノードと呼ばれます。上の図に示すように、ノード B はノード D とノード E の親ノードです。
- 子ノード (Child) : B が D の親ノードの場合、D は B の子ノードです。
- 兄弟: 同じ親ノードを持つノードは互いに兄弟ノードです。たとえば、上の図の B と C、D と E は互いに兄弟ノードです。
- パスとパス長: パスとは、あるノードから別のノードへの通過を指します。パスに含まれるエッジの数をパス長と呼びます。たとえば、A->H のパス長は 3、A->H のパス長は 3、A->H のパス長は 3、A->H のパス長は 3、A->H のパス長は 3、A->H のパス長は 3、A->H のパス長は 3、A->H のパス長は 3、A->H のパス長は 3
- ノードレベル (Level) :ルートノードはレベル 1 であり、他のノードのレベルは親ノードのレベルに 1 を加えたレベルであると規定されています。たとえば、ノード B と C のレベルは 2 です。
- ツリーの深さ (Depth) :ツリーのすべてのノード間の最大レベルはツリーの深さです。上の図に示すように、ツリーの深さは 4 です。
1.2. ツリー構造の表現
- 最も一般的な表現:
図に示すように、ツリー構造の構成は、ノードが 1 つずつ接続されたリンク リストに似ています。ただし、各親ノードの子ノードの数に応じて、各親ノードが必要とする参照の数も異なります。
この方法の欠点は、ノードへの参照の数を決定できないことです。
- 息子兄弟表記:
この表現方法では、次のような各ノードのデータを完全に記録できます。
//ノードA
ノード{
//データの保存
this.data = データ
//一律に左側の子ノードのみを記録します
this.leftChild = B
//一律に右側の最初の兄弟ノードのみを記録します
this.rightSibling = null
}
//ノードB
ノード{
this.data = データ
this.leftChild = E
this.rightSibling = C
}
//ノードF
ノード{
this.data = データ
this.leftChild = null
this.rightSibling = null
}
この表記法の利点は、各ノード内の参照の数が決定的であることです。
- 息子・兄弟表記回転
以下は、息子と兄弟の表記法で構成されるツリー構造です。
時計回りに 45 度回転させた後、次のようになります。
これは二分木になり、そこから任意の木は二分木によってシミュレートできると結論付けることができます。
二分木
2.1. バイナリツリーの概要
バイナリ ツリーの概念: ツリー内の各ノードが最大2 つの子ノードしか持てない場合、そのようなツリーはバイナリ ツリーと呼ばれます。
バイナリ ツリーは、その単純さだけでなく、ほとんどすべてのツリーをバイナリ ツリーとして表現できるため、非常に重要です。
二分木の構成:
- バイナリ ツリーは空、つまりノードがない場合もあります。
- バイナリ ツリーが空でない場合は、ルート ノードと、その左サブツリー TL および右サブツリー TR と呼ばれる 2 つの互いに素なバイナリ ツリーで構成されます。
バイナリ ツリーの 5 つの形式:
二分木の特徴:
- 二分木の i 番目の層の最大のノード ツリーは次のとおりです。 2(i-1)、i >= 1;
- 深さ k のバイナリ ツリー内のノードの最大合計数は次のとおりです: 2k - 1、k >= 1。
- 空ではないバイナリ ツリーについて、n0 が次数 2 のリーフ ノードの数を表し、n2 が非リーフ ノードの数を表す場合、この 2 つは次の関係を満たします: n0 = n2 + 1。下図: H、E、I、J、G はリーフ ノード、合計数は 5、A、B、C、F は次数 2 の非リーフ ノード、合計数は 4、n0 = の法則を満たします。 n2+1。
2.2. 特殊なバイナリツリー
完璧な二分木
パーフェクトバイナリツリー(Perfect Binary Tree)は、フルバイナリツリー(Full Binary Tree)とも呼ばれ、バイナリツリーでは、最下層のリーフノードを除き、ノードの各層には2つの子ノードがあり、完全なバイナリツリーを構成します。
完全なバイナリ ツリー:
- バイナリ ツリーの最後の層を除いて、各層のノード数は最大値に達しています。
- また、最終層の葉ノードは左から右に連続して存在しており、右側の数個の葉ノードのみが欠落しています。
- 完全なバイナリ ツリーは、特別な完全なバイナリ ツリーです。
上の画像では、H に右側の子がないため、完全な二分木ではありません。
2.3. 二分木データの保存
一般的なバイナリ ツリーの保存方法は、配列とリンク リストです。
配列を使用します。
- 完全なバイナリ ツリー: データを上から下、左から右に保存します。
配列ストレージを使用する場合、データをフェッチすることも非常に便利です。左の子ノードのシリアル番号は親ノードのシリアル番号 * 2に等しく、右の子ノードのシリアル番号はそのシリアル番号に等しいです。親ノードの * 2 + 1。
- 不完全なバイナリ ツリー:不完全なバイナリ ツリーは、上記のスキームに従って保存される完全なバイナリ ツリーに変換する必要があり、大量のストレージ スペースを無駄にします。
リンクリストを使用する
バイナリ ツリーの最も一般的な保存方法はリンク リストです。各ノードはノードにカプセル化され、ノードには保存されたデータ、左ノードへの参照、右ノードへの参照が含まれます。
3. 二分探索木
3.1. 二分探索木について知る
二分探索ツリー( BST、二分探索ツリー)。バイナリ ソート ツリーおよび二分探索ツリーとも呼ばれます。
二分探索ツリーは、空にすることもできる二分木です。
空でない場合は、次のプロパティが満たされます。
- 条件 1:空ではない左側のサブツリーのすべてのキー値が、そのルート ノードのキー値より小さい。
- 条件 2: 空ではない右サブツリーのすべてのキー値が、そのルート ノードのキー値より大きい。
- 条件 3: 左右のサブツリー自体も二分探索ツリーです。
八、JavaScriptはツリー構造を実装する(2)
1. 二分探索木のカプセル化
二分探索木の基本的なプロパティ:
図に示すように、二分探索ツリーには 4 つの最も基本的な属性があります。ノードを指すルート (ルート)、ノード内のキー(key) 、左ポインタ(right)、および右ポインタ(right) です。
したがって、二分探索ツリーでルート属性を定義することに加えて、各ノードに left、right、key の 3 つの属性を含むノード内部クラスも定義する必要があります。
//二分探索木をカプセル化する
関数 BinarySearchTree(){
// ノードの内部クラス
関数ノード(キー){
this.key = キー
this.left = null
this.right = null
}
//属性
this.root = null
}
二分探索ツリーに対する一般的な操作:
- insert(key): 新しいキーをツリーに挿入します。
- search(key): ツリー内でキーを検索し、ノードが存在する場合は true を返し、ノードが存在しない場合は false を返します。
- inOrderTraverse: 順序トラバーサルによってすべてのノードをトラバースします。
- preOrderTraverse: 事前順序トラバーサルを通じてすべてのノードをトラバースします。
- postOrderTraverse: ポストオーダートラバーサルを通じてすべてのノードをトラバースします。
- min: ツリー内の最小の値/キーを返します。
- max: ツリー内の最大の値/キーを返します。
- Remove(key): ツリーからキーを削除します。
1. データを挿入する
実装のアイデア:
- まず、受信したキーに従ってノード オブジェクトを作成します。
- 次に、ルート ノードが存在するかどうかを判断し、存在しない場合は this.root = newNode を渡し、新しいノードを二分探索木のルート ノードとして直接使用します。
- ルート ノードがある場合は、内部メソッド insertNode() を再定義して挿入ポイントを見つけます。
//メソッドの挿入: 外部ユーザーに公開されるメソッド
BinarySearchTree.prototype.insert = function(key){
//1. キーに従ってノードを作成する
let newNode = 新しいノード(キー)
//2. ルートノードが存在するかどうかを確認する
if (this.root == null) {
this.root = 新しいノード
// ルートノードが存在する場合
}それ以外 {
this.insert(this.root, newNode)
}
}
内部メソッド insert() の実装アイデア:
2 つの入力ノードの比較に従って、新しいノードが正常に挿入されるまで、新しいノードの適切な挿入位置を検索し続けます。
newNode.key < node.key の場合は左を見てください。
- ケース 1: ノードに左の子ノードがない場合、直接挿入します。
- ケース 2: ノードに左の子ノードがある場合、左の子ノードなしで new が正常に挿入されるまで、insert() を再帰的に呼び出します。この状況が満たされなくなると、insert() は呼び出されなくなり、再帰が停止します。
newNode.key >= node.key の場合、左側の検索と同様に右側を検索します。
- ケース 1: ノードに右側の子ノードがない場合、次を直接挿入します。
- ケース 2: ノードに正しい子ノードがある場合、insert メソッドに渡されたノードに正しい子ノードがなく、newNode に正常に挿入されるまで、insert() が再帰的に呼び出されます。
insert() コードの実装:
//内部的に使用される挿入メソッド: ノードが左から挿入されたか右から挿入されたかを比較するために使用されます。
BinarySearchTree.prototype.insert= function(node, newNode){ //newNode.key < node.key 左検索 if(newNode.key < node.key){ //ケース 1: ノードに左の子ノードがない場合、直接挿入 if (node.left == null) { node.left = newNode //ケース 2: ノードには左の子ノードがあり、再帰的に insert() を呼び出します }else{ this.insert(node.left, newNode) }
// newNode.key >= node.key が右の場合
}else{ //ケース 1: ノードに右の子ノードがない場合、直接挿入 if(node.right == null){ node.right == newNode //ケース 2 : ノードには右側の子ノードがあり、引き続き insert() を再帰的に呼び出します }else{ this.insert(node.right, newNode) } } }
2. データを走査する
この走査はすべてのバイナリ ツリーに対して機能します。一般的なバイナリ ツリー トラバーサル メソッドは次の 3 つです。
- プリオーダートラバーサル。
- インオーダートラバーサル。
- ポストオーダートラバーサル。
あまり使用されませんが、レイヤー順序のトラバーサルもあります。
2.1. 事前注文トラバーサル
事前注文トラバーサルのプロセスは次のとおりです。
- まず、ルート ノードを走査します。
- 次に、その左側のサブツリーをトラバースします。
- 最後に、その右側のサブツリーをトラバースします。
上の図に示すように、バイナリ ツリーのノード トラバーサル順序は、A -> B -> D -> H -> I -> E -> C -> F -> G です。
コード:
//プリオーダートラバーサル
//取得したキーの処理を容易にするハンドラー関数を組み込む
BinarySearchTree.prototype.preOrderTraversal = function(handler){ this.preOrderTraversalNode(this.root, handler) }// ノードを横断するための内部メソッドをカプセル化します。
BinarySearchTree.prototype.preOrderTraversalNode = function(node,handler){ if (node != null) { //1. 渡されたノード ハンドラーを処理します(node.key) //2. 左側のサブツリー内のノードを走査します。 preOrderTraversalNode(node.left, handler) //3. 右側のサブツリー内のノードを走査します this.preOrderTraversalNode(node.right, handler) } }
2.2. インオーダートラバーサル
実装のアイデア:
- まず、その左側のサブツリーをトラバースします。
- 次に、ルート (親) ノードを走査します。
- 最後に、その右側のサブツリーをトラバースします。
出力ノードの順序は次のようになります: 3 -> 5 -> 6 -> 7 -> 8 -> 9 -> 10 -> 11 -> 12 -> 13 -> 14 -> 15 -> 18 -> 20 -> 25.
コード:
// インオーダートラバーサル
BinarySearchTree.prototype.midOrderTraversal = function(handler){
this.midOrderTraversalNode(this.root, ハンドラー)
}
BinarySearchTree.prototype.midOrderTraversalNode = function(ノード, ハンドラー){
if (ノード != null) {
//1. 左側のサブツリー内のノードを走査します
this.midOrderTraversalNode(node.left, ハンドラー)
//2. 処理ノード
ハンドラー(ノード.キー)
//3. 右側のサブツリー内のノードを走査します
this.midOrderTraversalNode(node.right, ハンドラー)
}
}
2.3. ポストオーダートラバーサル
実装のアイデア:
- まず、その左側のサブツリーをトラバースします。
- 次に、その右側のサブツリーをトラバースします。
- 最後に、ルート (親) ノードがトラバースされます。
出力ノードの順序は次のようになります: 3 -> 6 -> 5 -> 8 -> 10 -> 9 -> 7 -> 12 -> 14 -> 13 -> 18 -> 25 -> 20 -> 15 -> 11.
コード:
//後序遍历
BinarySearchTree.prototype.postOrderTraversal = function(handler){ this.postOrderTraversalNode(this.root, handler) }BinarySearchTree.prototype.postOrderTraversalNode = function(node, handler){ if (node != null) { //1. 左側のサブツリーのノードを走査します this.postOrderTraversalNode(node.left, handler) //2. を走査します this.postOrderTraversalNode(node.right, handler)の右サブツリー ノード
//3. ノード
ハンドラー(node.key)の処理
}
}
3. データの検索
3.1. 最大値と最小値を求める
二分探索ツリーで最大値を見つけるのは非常に簡単です。最小値は二分探索ツリーの左端にあり、最大値は二分探索ツリーの右端にあります。次の図に示すように、最大の値を取得するには、常に左/右を検索するだけで済みます。
コード:
//最大値を求める
BinarySearchTree.prototype.max = function () { //1. ルート ノードを取得 let node = this.root //2. ノード値を保存するキーを定義 let key = null //3.ノードが null になるまで右方向に検索し続けます while (node != null) { key = node.key node = node.right } return key }
//最小値を求める
BinarySearchTree.prototype.min = function(){ //1. ルート ノードを取得 let node = this.root //2. ノード値を保存するキーを定義 let key = null //3.ノードが null になるまで、順番に左に検索し続けます while (node != null) { key = node.key node = node.left } return key }
3.2. 特定の値を見つける
ルートノードから検索対象のノードのキー値を比較し、node.key < rootの場合は左方向に、node.key > root の場合は右方向に検索するか、null が見つかるまで検索します。再帰/ループで実装されます。
実装コード:
//特定のキーを検索する
BinarySearchTree.prototype.search = function(key){
//1. ルートノードを取得する
let ノード = this.root
//2. ループ検索キー
while(ノード != null){
if (キー < ノード.キー) {
//ルート(親)ノードより小さい場合は左を見る
ノード = ノード.左
// ルート (親) ノードより大きい場合は、右側で探します
}else if(キー > ノード.キー){
ノード = ノード.右
}それ以外{
trueを返す
}
}
falseを返す
}
4. データの削除
実装のアイデア:
ステップ 1 : 最初に削除する必要があるノードを見つけます。見つからない場合は削除する必要はありません。
まず、削除するノードを保存する変数 current 、その親ノードを保存する変数parent、および現在のノードが親の左側のノードかどうかを保存する変数 isLeftChild を定義します。これにより、ノードの方向を変更するのに便利です。後でノードを削除するときに関連ノードを削除します。
実装コード:
//1.1. 変数を定義する
現在の = this.root にします
親 = null にする
let isLeftChild = true
//1.2. 削除されたノードの検索を開始します
while (current.key != key) {
親 = 現在
// 未満の場合は左方向に検索
if (キー < current.key) {
isLeftChild = true
現在 = 現在.左
} それ以外{
isLeftChild = false
現在 = 現在.右
}
// まだ見つからない最後のノードを検索します
if (current == null) {
falseを返す
}
}
// while ループの終了後: current.key = key
ステップ 2 : 見つかった指定されたノードを削除します。次の 3 つのケースがあります。
- リーフノードを削除します。
- 子ノードが 1 つだけあるノードを削除します。
- 2 つの子を持つノードを削除します。
4.1. ケース 1: 子ノードがない
子ノードが存在しない場合には、次の 2 つのケースもあります。
- リーフ ノードがルート ノードの場合、this.root = null を直接渡してルート ノードを削除します。
- 次の図に示すように、リーフ ノードがルート ノードではない場合も 2 つの状況があります。
コード:
//ケース 1: リーフ ノードが削除される (子ノードなし)
if (current.left == null && current.right ==null) { if (current == this.root) { this.root = null }else if (isLeftChild){ parent.left = null } else { parent.right = null } }
4.2. ケース 2: 子ノードがある
次の 6 つの状況があります。
current に左の子ノードがある場合(current.right == null):
- ケース 1: 現在のノードがルート ノードです。
- ケース 2: 現在のノードは親ノードの左の子ノードです (isLeftChild == true)。
- ケース 3: 現在のノードは親ノードの右の子ノードです (isLeftChild == false)。
current に右の子ノードがある場合(current.left = null):
- ケース 4: current はルート ノード (current == this.root)。
- ケース 5: 現在のノードは親ノードの左の子ノードです (isLeftChild == true)。
- ケース 6: 現在のノードは親ノードの右の子ノードです (isLeftChild == false)。
4.3. ケース 3: 2 つの子ノードがある
この状況は非常に複雑ですが、まず、次の二分探索木に基づいてこのような問題について説明します。
ノード9を削除する
ノード 9 を削除した後も元の二分木が依然として二分探索木であることを保証するという前提の下では、2 つの方法があります。
- 方法 1: ノード 9 の左側のサブツリーから適切なノードを選択してノード 9 を置き換えます。ノード 8 が要件を満たしていることがわかります。
- 方法 2: ノード 9 の右側のサブツリーから適切なノードを選択してノード 9 を置き換えます。ノード 10 が要件を満たしていることがわかります。
ノード 7 を削除します
ノード 7 を削除した後も元の二分木が依然として二分探索木であることを保証するという前提の下では、2 つの方法があります。
- 方法 1: ノード 7 の左側のサブツリーから適切なノードを選択してノード 7 を置き換えます。ノード 5 が要件を満たしていることがわかります。
- 方法 2: ノード 7 の右側のサブツリーから適切なノードを選択してノード 7 を置き換えます。ノード 8 が要件を満たしていることがわかります。
ノード15を削除します
ノード 15 を削除した後も元のツリー バイナリ ツリーが引き続きバイナリ検索ツリーであることを保証するという前提の下では、次の 2 つの方法もあります。
- モード 1: ノード 15 の左側のサブツリーから適切なノードを選択してノード 15 を置き換えます。ノード 14 が要件を満たしていることがわかります。
- モード 2: ノード 15 の右側のサブツリーから適切なノードを選択してノード 15 を置き換えます。ノード 18 が要件を満たしていることがわかります。
ルールの概要:削除するノードに 2 つの子ノードがある場合、または子ノードにも子ノードがある場合、削除するノードの下にある子ノードの中から適切なノードを見つけて、現在のノードを置き換える必要があります。。
current が削除されるノードを表すために使用される場合、適切なノードは以下を指します。
- 現在の左サブツリー内の現在よりも少し小さいノードが、現在の左サブツリー内の最大値です。
- 現在よりわずかに大きい現在の右サブツリー内のノードが、現在の右サブツリー内の最小値です。
先代と後継者
二分探索ツリーでは、これら 2 つの特別なノードには特別な名前が付いています。
- 現在よりも少し小さいノードは、現在のノードの先行ノードと呼ばれます。たとえば、次の図のノード 5 はノード 7 の前身です。
- 現在よりも少し大きいノードは、現在のノードの後継ノードと呼ばれます。たとえば、次の図のノード 8 はノード 7 の後継ノードです。
コード:
- current の後継を見つけるには、 current の右側のサブツリーで最小値を見つける必要があります。
- 先行関数を探すときは、現在の関数の左側のサブツリーで最大値を見つける必要があります。
4.4. 検索後継の実現
//ノードを削除
BinarySearchTree.prototype.remove = function(key){ /*-----1. 削除するノードを検索します-----*/ let current = this.root letparent = null let isLeftChild =本当
while (current.key != key) { parent = current // 未満の場合は左へ検索 if (key < current.key) { isLeftChild = true current = current.left } else{ isLeftChild = false current = current.right } //まだ見つからない最後のノードを検索します if (current == null) { return false } } //while ループの終了後: current.key = key
/*--2. 状況に応じてノードを削除します -----*/
//ケース 1: 葉ノードが削除されます (子ノードなし)
if (current.left == null && current.right == null) { if (current == this.root) { this.root = null }else if(isLeftChild){ parent.left = null }else { parent.right =null } } //ケース 2: 削除されたノードには子ノード / / 現在に左の子ノードがある場合 else if(current.right == null){ if (current == this.root) { this.root = current.left } else if(isLeftChild) { parent.left =現在の左
} else{ parent.right = current.left } //current に右側の子ノードがある場合 } else if(current.left == null){ if (current == this.root) { this.root = current.right } else if(isLeftChild) { parent.left = current.right } else{ parent.right = current.right } } //ケース 3: 削除されたノードには 2 つの子ノードがある else{ //1. 後続ノードを取得します let success.right =この .getSuccessor(current)
//2.判断是否根节点
if (current == this.root) {
this.root = successor
}else if (isLeftChild){
parent.left = successor
}else{
parent.right = successor
}//3. 後続の左子ノードを削除されたノードの左子ノードに変更します
success.left = current.left
}
}//サクセサを見つけるメソッドをカプセル化
BinarySearchTree.prototype.getSuccessor = function(delNode){ //1. 見つかったサクセサを保存する変数を定義します let success = delNode let current = delNode.right let sucksersParent = delNode//2. 現在の右のサブツリー ノードを見つけるためのループ
while(current != null){ suckerParent = success successor = current current = current.left }//3. 見つかった後継ノードが、削除されたノードの直接の右ノードであるかどうかを判断します
if(successor != delNode.right){ suckerParent.left = success.right successer.right = delNode.right } return successive }
2. バランスツリー
二分探索木の欠点:
挿入されたデータが順序付けされたデータである場合、二分探索木の深さが大きくなりすぎ、二分探索木のパフォーマンスに重大な影響を与えます。
アンバランスな木
- 連続データを挿入した後、二分探索ツリー内のデータの分布は不均一になります。このツリーを不均衡。
- バランスの取れた二分木では、挿入/検索などの操作の効率はO(logN)です。
- 不均衡二分木の場合、これは連結リストを書くことと等価であり、検索効率はO(N)になります。
ツリーバランス
より速い時間 O(logN) でツリーを操作できるようにするには、ツリーが常にバランスが取れていることを確認する必要があります。
- 少なくともそれらの大部分はバランスが取れており、この時点の時間計算量も O(logN) に近くなります。
- これには、ツリー内の各ノードの左側の子孫ノードの数が、右側の子孫ノードの数とできるだけ等しくなる必要があります。
一般的なバランスの取れたツリー
- AVL ツリー:これは、各ノードに追加のデータを格納することでツリーのバランスを保つ、最も初期の種類のバランスのとれたツリーです。AVL ツリーはバランスのとれたツリーであるため、その時間計算量も O(logN) です。しかし、その全体的な効率は赤黒ツリーほど良くなく、開発ではあまり使用されません。
- 赤黒ツリー:いくつかの特性によってツリーのバランスも維持され、時間計算量も O(logN) になります。挿入/削除などの操作を実行する場合、AVL ツリーよりもパフォーマンスが優れているため、バランスド ツリーのアプリケーションは基本的に赤黒ツリーになります。
9、グラフィックの赤黒の木
1. 赤黒木の5つのルール
二分探索ツリーの基本ルールに準拠することに加えて、赤黒ツリーには次の機能も追加されています。
- ルール 1: ノードは赤または黒のいずれかです。
- ルール 2: ルート ノードは黒です。
- ルール 3: 各リーフ ノードは黒い空のノード (NIL ノード) です。
- ルール 4: 各赤いノードの子は両方とも黒です (各リーフからルートまでのすべてのパス上に 2 つの連続した赤いノードがあることは不可能です)。
- ルール 5: 任意のノードからその各リーフ ノードへのすべてのパスには、同じ数の黒いノードが含まれます。
赤黒木の相対的なバランス
前の 5 つのルールの制約により、赤黒木の次の重要な特性が保証されます。
- ルートからリーフ ノードまでの最長パスが最短パスの2 倍を超えることはありません。
- その結果、ツリーは基本的にバランスが取れています。
- 絶対的なバランスは存在しませんが、最悪の場合でもツリーが依然として効率的であることは保証できます。
最長パスが最短パスの 2 倍を超えてはいけないのはなぜですか?
- プロパティ 4 は、パス上に 2 つの接続された赤いノードが存在できないことを決定します。
- したがって、最長のパスは赤色のノードと黒色のノードによって交互に形成される必要があります。
- ルート ノードとリーフ ノードは両方とも黒であるため、最短パスは黒ノードである可能性があり、最長パスには赤ノードよりも黒ノードの方が多くなければなりません。
- プロパティ 5 は、すべてのパスに同じ数の黒いノードがあることを決定します。
- これは、どのパスも他のパスの 2 倍を超える長さは存在できないことを意味します。
2. 赤黒木の3つの変化
新しいノードを挿入すると、ツリーのバランスが崩れる可能性がありますが、次の 3 つの変換方法によってツリーのバランスを保つことができます。
- 変色;
- 左に回転;
- 右に回る;
2.1. 変色
再び赤黒ツリーの規則に準拠するには、赤のノードを黒に変えるか、黒のノードを赤に変える必要があります。
挿入された新しいノードは通常、赤いノードです。
-
挿入されたノードがredの場合、ほとんどの場合、赤黒ツリーのルールに違反しません。
-
黒いノードを挿入すると、必然的にパス上に余分な黒いノードが発生するため、調整が困難になります。
-
赤いノードは赤と赤の接続につながる可能性がありますが、この状況は色の交換と回転によって調整できます。
2.2. 左回転
ノードX をルートとして二分探索ツリーを反時計回りに回転すると、親ノードの元の位置がその右側の子ノードに置き換えられ、左側の子ノードの位置が親ノードに置き換えられます。
詳細な説明:
上に示すように、左回転後:
- ノード X はノード a の元の位置を置き換えます。
- ノード Y はノード X の元の位置を置き換えます。
- ノード X の左部分木a は、ノード X の左部分木 のままです(ここでは、X の左部分木にはノードが 1 つしかありません。複数のノードがある場合も同様です。以下同じです)。
- ノード Y の右サブツリーc は依然として ノード Y の右サブツリーです。
- ノード Y の左側のサブツリーb は 左側に変換されて、ノード X の右側のサブツリーになります。
さらに、二分探索木は左回転後も二分探索木のままです。
2.3. 右回転
ノードX をルートとして二分探索ツリーを時計回りに回転すると、親ノードの元の位置がその左側の子ノードに置き換えられ、右側の子ノードの位置が親ノードに置き換えられます。
詳細な説明:
上の図に示すように、右回転後は次のようになります。
- ノード X はノード a の元の位置を置き換えます。
- ノード Y はノード X の元の位置を置き換えます。
- ノード X の右部分木a は、 ノード X の右部分木のままです (ここでは、X の右部分木にはノードが 1 つしかありませんが、複数のノードにも当てはまります。以下同様です)。
- ノード Y の左側のサブツリーb は 、ノード Y の左側のサブツリーのままです。
- ノード Y の右サブツリー c は右に変換されて、ノード X の左サブツリーになります。
また、二分探索木は右回転後も二分探索木のままです。
第三に、赤黒ツリーの挿入操作
まず、赤黒ツリーの 5 つのルールが満たされることが保証されている場合、新しく挿入されたノードは赤ノードでなければならないことを明確にする必要があります。
説明の便宜上、新たに挿入したノードをN (Node)、Nの親ノードをP (Parent)、Pの兄弟ノードをU (Uncle)、親ノードを4つのノードと定義します。 U は次のようにG (おじいちゃん) です。図に示すように:
3.1. 状況 1
新しいノード N がツリーのルートに挿入されるとき、そのノードには親がありません。
この場合、ルール 2 を満たすには、赤のノードを黒のノードに変更するだけで済みます。
3.2. 状況 2
新規ノードNの親ノードPは黒ノードであり、この時点では変更の必要はない。
このとき、ルール4とルール5は両方とも満たされる。新しいノードは赤ですが、新しいノード N には 2 つの黒ノード NIL があり、そこに至るパス上の黒ノードの数は依然として等しいため、ルール 5 が満たされます。
3.3. 状況 3
ノード P は赤、ノード U も赤ですが、このときノード G は黒、つまり父が赤、叔父が赤、祖先が黒でなければなりません。
この場合、次のものが必要です。
- まず親ノード P を黒に変更します。
- 次に、叔父ノード U を黒に変更します。
- 最後に、祖父母ノード G を赤に変えます。
つまり、以下の図に示すように、父は黒、叔父は黒、祖先は赤です。
発生する可能性のある問題:
- N の祖父母ノード G の親ノードも赤である可能性があり、これはルール 4 に違反します。このとき、ノードの色は再帰的に調整できます。
- 再帰的調整がルート ノードに到達すると、下図のノード A とノード B に示すように、ルート ノードを回転する必要があります。具体的な状況については後ほど説明します。
3.4. 状況 4
ノード P は赤ノード、ノード U は黒ノード、ノード N はノード P の左の子ノードです。このとき、ノード G は黒ノード、つまり、父 赤 叔父 黒 祖先 黒でなければなりません。
この場合、次のものが必要です。
- 最初に色を変更します。親ノード P を黒に変更し、祖父母ノード G を赤に変更します。
- 逆回転: 祖父母ノード G をルートとして右回転。
3.5. 状況 5
ノード P は赤ノード、ノード U は黒ノード、ノード N はノード P の右側の子ノードです。このとき、ノード G は黒ノード、つまり、父 赤 叔父 黒 祖先 黒でなければなりません。
この場合、次のものが必要です。
- 回転後の図 b に示すように、まずノード P をルートとして左に回転します。
- 次に、 図cに示すように、赤ノードPと黒ノードBをまとめて赤ノードN1とみなし、新たに挿入した赤ノードNを赤ノードP1とみなします。このとき、全体がケース4に変形する。
その後、ケース 4 に従って処理できます。
-
最初に色を変更します。N1 ノードの親ノード P1 を黒に変更し、祖父母ノード G を赤に変更します。
-
回転後: 回転後の図 e に示すように、祖父母ノード G をルートとして右回転します。
-
最後に、図 f に示すように、ノード N1 と P1 を変換して戻し、ノード N の挿入を完了します。