This commit is contained in:
krahets
2025-10-17 05:33:23 +08:00
parent 9278f3c659
commit 68bb9afb16
113 changed files with 35936 additions and 0 deletions
@@ -0,0 +1,224 @@
---
comments: true
---
# 12.2   分割統治検索戦略
私たちは検索アルゴリズムが主に2つのカテゴリに分類されることを学びました。
- **総当たり検索**:データ構造を走査することで実装され、時間計算量は $O(n)$ です。
- **適応検索**:独特なデータ組織形式や事前情報を利用し、時間計算量は $O(\log n)$ または $O(1)$ に達することができます。
実際、**時間計算量が $O(\log n)$ の検索アルゴリズムは通常分割統治戦略に基づいています**。例えば、二分探索や木などです。
- 二分探索の各ステップは、問題(配列内でターゲット要素を検索する)をより小さな問題(配列の半分でターゲット要素を検索する)に分割し、配列が空になるかターゲット要素が見つかるまで続けます。
- 木は分割統治のアイデアを表現し、二分探索木、AVL木、ヒープなどのデータ構造では、様々な操作の時間計算量は $O(\log n)$ です。
二分探索の分割統治戦略は以下の通りです。
- **問題を分割できる**:二分探索は元の問題(配列内での検索)を部分問題(配列の半分での検索)に再帰的に分割し、中間要素とターゲット要素を比較することで実現されます。
- **部分問題は独立している**:二分探索では、各ラウンドで一つの部分問題を処理し、他の部分問題に影響されません。
- **部分問題の解をマージする必要がない**:二分探索は特定の要素を見つけることを目的としているため、部分問題の解をマージする必要がありません。部分問題が解決されると、元の問題も解決されます。
分割統治は検索効率を向上させることができます。なぜなら、総当たり検索はラウンドごとに1つの選択肢しか除去できませんが、**分割統治は選択肢の半分を除去できるからです**。
### 1.   分割統治に基づく二分探索の実装
前の章では、二分探索は反復に基づいて実装されました。今度は、分割統治(再帰)に基づいて実装します。
!!! question
長さ $n$ の順序付けられた配列 `nums` が与えられ、すべての要素が一意である場合、要素 `target` を見つけてください。
分割統治の観点から、検索区間 $[i, j]$ に対応する部分問題を $f(i, j)$ と表します。
元の問題 $f(0, n-1)$ から開始して、以下のステップで二分探索を実行します。
1. 検索区間 $[i, j]$ の中点 $m$ を計算し、それを使用して検索区間の半分を除去します。
2. 半分のサイズに縮小された部分問題を再帰的に解決します。これは $f(i, m-1)$ または $f(m+1, j)$ になる可能性があります。
3. `target` が見つかるか区間が空になってリターンするまで、ステップ `1.``2.` を繰り返します。
以下の図は、配列内で要素 $6$ を探す二分探索の分割統治過程を示しています。
![二分探索の分割統治過程](binary_search_recur.assets/binary_search_recur.png){ class="animation-figure" }
<p align="center"> 図 12-4 &nbsp; 二分探索の分割統治過程 </p>
実装コードでは、問題 $f(i, j)$ を解決するために再帰関数 `dfs()` を宣言します:
=== "Python"
```python title="binary_search_recur.py"
def dfs(nums: list[int], target: int, i: int, j: int) -> int:
"""二分探索:問題 f(i, j)"""
# 区間が空の場合、対象要素がないことを示すため、-1 を返す
if i > j:
return -1
# 中点インデックス m を計算
m = (i + j) // 2
if nums[m] < target:
# 再帰部分問題 f(m+1, j)
return dfs(nums, target, m + 1, j)
elif nums[m] > target:
# 再帰部分問題 f(i, m-1)
return dfs(nums, target, i, m - 1)
else:
# 対象要素を発見したため、そのインデックスを返す
return m
def binary_search(nums: list[int], target: int) -> int:
"""二分探索"""
n = len(nums)
# 問題 f(0, n-1) を解く
return dfs(nums, target, 0, n - 1)
```
=== "C++"
```cpp title="binary_search_recur.cpp"
/* 二分探索:問題 f(i, j) */
int dfs(vector<int> &nums, int target, int i, int j) {
// 区間が空の場合、対象要素が存在しないことを示すため、-1 を返す
if (i > j) {
return -1;
}
// 中点インデックス m を計算
int m = i + (j - i) / 2;
if (nums[m] < target) {
// 再帰的な部分問題 f(m+1, j)
return dfs(nums, target, m + 1, j);
} else if (nums[m] > target) {
// 再帰的な部分問題 f(i, m-1)
return dfs(nums, target, i, m - 1);
} else {
// 対象要素が見つかったため、そのインデックスを返す
return m;
}
}
/* 二分探索 */
int binarySearch(vector<int> &nums, int target) {
int n = nums.size();
// 問題 f(0, n-1) を解く
return dfs(nums, target, 0, n - 1);
}
```
=== "Java"
```java title="binary_search_recur.java"
/* 二分探索:問題 f(i, j) */
int dfs(int[] nums, int target, int i, int j) {
// 区間が空の場合、対象要素が存在しないことを示すため、-1 を返す
if (i > j) {
return -1;
}
// 中点インデックス m を計算
int m = i + (j - i) / 2;
if (nums[m] < target) {
// 再帰的な部分問題 f(m+1, j)
return dfs(nums, target, m + 1, j);
} else if (nums[m] > target) {
// 再帰的な部分問題 f(i, m-1)
return dfs(nums, target, i, m - 1);
} else {
// 対象要素が見つかったため、そのインデックスを返す
return m;
}
}
/* 二分探索 */
int binarySearch(int[] nums, int target) {
int n = nums.length;
// 問題 f(0, n-1) を解く
return dfs(nums, target, 0, n - 1);
}
```
=== "C#"
```csharp title="binary_search_recur.cs"
[class]{binary_search_recur}-[func]{DFS}
[class]{binary_search_recur}-[func]{BinarySearch}
```
=== "Go"
```go title="binary_search_recur.go"
[class]{}-[func]{dfs}
[class]{}-[func]{binarySearch}
```
=== "Swift"
```swift title="binary_search_recur.swift"
[class]{}-[func]{dfs}
[class]{}-[func]{binarySearch}
```
=== "JS"
```javascript title="binary_search_recur.js"
[class]{}-[func]{dfs}
[class]{}-[func]{binarySearch}
```
=== "TS"
```typescript title="binary_search_recur.ts"
[class]{}-[func]{dfs}
[class]{}-[func]{binarySearch}
```
=== "Dart"
```dart title="binary_search_recur.dart"
[class]{}-[func]{dfs}
[class]{}-[func]{binarySearch}
```
=== "Rust"
```rust title="binary_search_recur.rs"
[class]{}-[func]{dfs}
[class]{}-[func]{binary_search}
```
=== "C"
```c title="binary_search_recur.c"
[class]{}-[func]{dfs}
[class]{}-[func]{binarySearch}
```
=== "Kotlin"
```kotlin title="binary_search_recur.kt"
[class]{}-[func]{dfs}
[class]{}-[func]{binarySearch}
```
=== "Ruby"
```ruby title="binary_search_recur.rb"
[class]{}-[func]{dfs}
[class]{}-[func]{binary_search}
```
=== "Zig"
```zig title="binary_search_recur.zig"
[class]{}-[func]{dfs}
[class]{}-[func]{binarySearch}
```
@@ -0,0 +1,298 @@
---
comments: true
---
# 12.3 &nbsp; 二分木構築問題
!!! question
二分木の前順走査 `preorder` シーケンスと中順走査 `inorder` シーケンスが与えられた場合、二分木を構築してそのルートノードを返してください。二分木に重複するノード値がないと仮定します(以下の図に示すように)。
![二分木構築のサンプルデータ](build_binary_tree_problem.assets/build_tree_example.png){ class="animation-figure" }
<p align="center"> 図 12-5 &nbsp; 二分木構築のサンプルデータ </p>
### 1. &nbsp; 分割統治問題かどうかの判定
`preorder``inorder` シーケンスから二分木を構築する元の問題は、典型的な分割統治問題です。
- **問題を分解できる**:分割統治の観点から、元の問題を2つの部分問題(左の部分木の構築と右の部分木の構築)とルートノードの初期化という1つの操作に分割できます。各部分木(部分問題)について、同じアプローチを継続的に適用し、より小さな部分木(部分問題)に分割し、最小の部分問題(空の部分木)に到達するまで続けます。
- **部分問題は独立している**:左と右の部分木は重複しません。左の部分木を構築する際、左の部分木に対応する中順走査と前順走査のセグメントのみが必要です。右の部分木にも同じアプローチが適用されます。
- **部分問題の解を組み合わせることができる**:左と右の部分木(部分問題の解)を構築したら、それらをルートノードに接続して元の問題の解を取得できます。
### 2. &nbsp; 部分木の分割方法
上記の分析に基づいて、この問題は分割統治を使用して解決できます。**しかし、前順走査 `preorder` シーケンスと中順走査 `inorder` シーケンスを使用して左と右の部分木をどのように分割すればよいでしょうか?**
定義により、`preorder``inorder` シーケンスの両方を3つの部分に分割できます:
- 前順走査:`[ ルート | 左の部分木 | 右の部分木 ]`。例えば、図では、木は `[ 3 | 9 | 2 1 7 ]` に対応します。
- 中順走査:`[ 左の部分木 | ルート | 右の部分木 ]`。例えば、図では、木は `[ 9 | 3 | 1 2 7 ]` に対応します。
前の図のデータを使用して、次の図に示すステップに従って分割結果を取得できます:
1. 前順走査の最初の要素3がルートノードの値です。
2. `inorder` シーケンス内でルートノード3のインデックスを見つけ、このインデックスを使用して `inorder``[ 9 | 3 1 2 7 ]` に分割します。
3. `inorder` シーケンスの分割に従って、左と右の部分木がそれぞれ1個と3個のノードを含むことが簡単に決定できるため、`preorder` シーケンスを `[ 3 | 9 | 2 1 7 ]` に対応して分割できます。
![前順走査と中順走査での部分木の分割](build_binary_tree_problem.assets/build_tree_preorder_inorder_division.png){ class="animation-figure" }
<p align="center"> 図 12-6 &nbsp; 前順走査と中順走査での部分木の分割 </p>
### 3. &nbsp; 変数に基づく部分木範囲の記述
上記の分割方法に基づいて、**`preorder``inorder` シーケンスにおけるルート、左の部分木、右の部分木のインデックス範囲を取得しました**。これらのインデックス範囲を記述するために、いくつかのポインタ変数を使用します。
- 現在の木のルートノードの `preorder` シーケンスでのインデックスを $i$ とします。
- 現在の木のルートノードの `inorder` シーケンスでのインデックスを $m$ とします。
- 現在の木の `inorder` シーケンスでのインデックス範囲を $[l, r]$ とします。
以下の表に示すように、これらの変数は `preorder` シーケンスでのルートノードのインデックスと `inorder` シーケンスでの部分木のインデックス範囲を表します。
<p align="center"> 表 12-1 &nbsp; 前順走査と中順走査でのルートノードと部分木のインデックス </p>
<div class="center-table" markdown>
| | `preorder` でのルートノードインデックス | `inorder` での部分木インデックス範囲 |
| ------------- | ------------------------------------- | ----------------------------------- |
| 現在の木 | $i$ | $[l, r]$ |
| 左の部分木 | $i + 1$ | $[l, m-1]$ |
| 右の部分木 | $i + 1 + (m - l)$ | $[m+1, r]$ |
</div>
右の部分木のルートインデックスの $(m-l)$ は「左の部分木のノード数」を表すことに注意してください。より明確な理解のために、以下の図を参照することが役立つ場合があります。
![ルートノードと左右の部分木のインデックス](build_binary_tree_problem.assets/build_tree_division_pointers.png){ class="animation-figure" }
<p align="center"> 図 12-7 &nbsp; ルートノードと左右の部分木のインデックス </p>
### 4. &nbsp; コード実装
$m$ の問い合わせの効率を向上させるために、ハッシュテーブル `hmap` を使用して `inorder` シーケンスの要素からそのインデックスへのマッピングを格納します:
=== "Python"
```python title="build_tree.py"
def dfs(
preorder: list[int],
inorder_map: dict[int, int],
i: int,
l: int,
r: int,
) -> TreeNode | None:
"""二分木の構築:分割統治"""
# 部分木の区間が空のとき終了
if r - l < 0:
return None
# ルートノードを初期化
root = TreeNode(preorder[i])
# m をクエリして左部分木と右部分木を分割
m = inorder_map[preorder[i]]
# 部分問題:左部分木を構築
root.left = dfs(preorder, inorder_map, i + 1, l, m - 1)
# 部分問題:右部分木を構築
root.right = dfs(preorder, inorder_map, i + 1 + m - l, m + 1, r)
# ルートノードを返す
return root
def build_tree(preorder: list[int], inorder: list[int]) -> TreeNode | None:
"""二分木を構築"""
# ハッシュテーブルを初期化、中順走査の要素からインデックスへのマッピングを保存
inorder_map = {val: i for i, val in enumerate(inorder)}
root = dfs(preorder, inorder_map, 0, 0, len(inorder) - 1)
return root
```
=== "C++"
```cpp title="build_tree.cpp"
/* 二分木の構築:分割統治 */
TreeNode *dfs(vector<int> &preorder, unordered_map<int, int> &inorderMap, int i, int l, int r) {
// 部分木の区間が空の場合に終了
if (r - l < 0)
return NULL;
// ルートノードを初期化
TreeNode *root = new TreeNode(preorder[i]);
// m を問い合わせて左右の部分木を分割
int m = inorderMap[preorder[i]];
// 部分問題:左の部分木を構築
root->left = dfs(preorder, inorderMap, i + 1, l, m - 1);
// 部分問題:右の部分木を構築
root->right = dfs(preorder, inorderMap, i + 1 + m - l, m + 1, r);
// ルートノードを返す
return root;
}
/* 二分木の構築 */
TreeNode *buildTree(vector<int> &preorder, vector<int> &inorder) {
// ハッシュテーブルを初期化し、中間順序の要素からインデックスへのマッピングを格納
unordered_map<int, int> inorderMap;
for (int i = 0; i < inorder.size(); i++) {
inorderMap[inorder[i]] = i;
}
TreeNode *root = dfs(preorder, inorderMap, 0, 0, inorder.size() - 1);
return root;
}
```
=== "Java"
```java title="build_tree.java"
/* 二分木の構築:分割統治 */
TreeNode dfs(int[] preorder, Map<Integer, Integer> inorderMap, int i, int l, int r) {
// 部分木の区間が空の場合に終了
if (r - l < 0)
return null;
// ルートノードを初期化
TreeNode root = new TreeNode(preorder[i]);
// m を問い合わせて左右の部分木を分割
int m = inorderMap.get(preorder[i]);
// 部分問題:左の部分木を構築
root.left = dfs(preorder, inorderMap, i + 1, l, m - 1);
// 部分問題:右の部分木を構築
root.right = dfs(preorder, inorderMap, i + 1 + m - l, m + 1, r);
// ルートノードを返す
return root;
}
/* 二分木の構築 */
TreeNode buildTree(int[] preorder, int[] inorder) {
// ハッシュテーブルを初期化し、中間順序の要素からインデックスへのマッピングを格納
Map<Integer, Integer> inorderMap = new HashMap<>();
for (int i = 0; i < inorder.length; i++) {
inorderMap.put(inorder[i], i);
}
TreeNode root = dfs(preorder, inorderMap, 0, 0, inorder.length - 1);
return root;
}
```
=== "C#"
```csharp title="build_tree.cs"
[class]{build_tree}-[func]{DFS}
[class]{build_tree}-[func]{BuildTree}
```
=== "Go"
```go title="build_tree.go"
[class]{}-[func]{dfsBuildTree}
[class]{}-[func]{buildTree}
```
=== "Swift"
```swift title="build_tree.swift"
[class]{}-[func]{dfs}
[class]{}-[func]{buildTree}
```
=== "JS"
```javascript title="build_tree.js"
[class]{}-[func]{dfs}
[class]{}-[func]{buildTree}
```
=== "TS"
```typescript title="build_tree.ts"
[class]{}-[func]{dfs}
[class]{}-[func]{buildTree}
```
=== "Dart"
```dart title="build_tree.dart"
[class]{}-[func]{dfs}
[class]{}-[func]{buildTree}
```
=== "Rust"
```rust title="build_tree.rs"
[class]{}-[func]{dfs}
[class]{}-[func]{build_tree}
```
=== "C"
```c title="build_tree.c"
[class]{}-[func]{dfs}
[class]{}-[func]{buildTree}
```
=== "Kotlin"
```kotlin title="build_tree.kt"
[class]{}-[func]{dfs}
[class]{}-[func]{buildTree}
```
=== "Ruby"
```ruby title="build_tree.rb"
[class]{}-[func]{dfs}
[class]{}-[func]{build_tree}
```
=== "Zig"
```zig title="build_tree.zig"
[class]{}-[func]{dfs}
[class]{}-[func]{buildTree}
```
以下の図は、二分木を構築する再帰過程を示しています。各ノードは再帰の「下降」段階で作成され、各エッジ(参照)は「上昇」段階で形成されます。
=== "<1>"
![二分木構築の再帰過程](build_binary_tree_problem.assets/built_tree_step1.png){ class="animation-figure" }
=== "<2>"
![built_tree_step2](build_binary_tree_problem.assets/built_tree_step2.png){ class="animation-figure" }
=== "<3>"
![built_tree_step3](build_binary_tree_problem.assets/built_tree_step3.png){ class="animation-figure" }
=== "<4>"
![built_tree_step4](build_binary_tree_problem.assets/built_tree_step4.png){ class="animation-figure" }
=== "<5>"
![built_tree_step5](build_binary_tree_problem.assets/built_tree_step5.png){ class="animation-figure" }
=== "<6>"
![built_tree_step6](build_binary_tree_problem.assets/built_tree_step6.png){ class="animation-figure" }
=== "<7>"
![built_tree_step7](build_binary_tree_problem.assets/built_tree_step7.png){ class="animation-figure" }
=== "<8>"
![built_tree_step8](build_binary_tree_problem.assets/built_tree_step8.png){ class="animation-figure" }
=== "<9>"
![built_tree_step9](build_binary_tree_problem.assets/built_tree_step9.png){ class="animation-figure" }
<p align="center"> 図 12-8 &nbsp; 二分木構築の再帰過程 </p>
各再帰関数の `preorder` と `inorder` シーケンスの分割は以下の図に示されています。
![各再帰関数での分割](build_binary_tree_problem.assets/built_tree_overall.png){ class="animation-figure" }
<p align="center"> 図 12-9 &nbsp; 各再帰関数での分割 </p>
二分木が $n$ 個のノードを持つと仮定すると、各ノードの初期化(再帰関数 `dfs()` の呼び出し)には $O(1)$ 時間がかかります。**したがって、全体の時間計算量は $O(n)$ です**。
ハッシュテーブルは `inorder` 要素からそのインデックスへのマッピングを格納するため、$O(n)$ スペースが必要です。最悪の場合、二分木が連結リストに退化すると、再帰の深さは $n$ に達し、$O(n)$ のスタックスペースを消費する可能性があります。**したがって、全体の空間計算量は $O(n)$ です**。
@@ -0,0 +1,101 @@
---
comments: true
---
# 12.1 &nbsp; 分割統治アルゴリズム
<u>分割統治</u>は重要で人気のあるアルゴリズム戦略です。名前が示すように、アルゴリズムは通常再帰的に実装され、「分割」と「統治」の2つのステップから構成されます。
1. **分割(分割段階)**:元の問題を再帰的に2つ以上の小さな部分問題に分解し、最小の部分問題に到達するまで続けます。
2. **統治(マージ段階)**:解決方法が既知の最小の部分問題から開始し、部分問題の解をボトムアップ方式でマージして元の問題の解を構築します。
以下の図に示すように、「マージソート」は分割統治戦略の典型的な応用の一つです。
1. **分割**:元の配列(元の問題)を再帰的に2つの副配列(部分問題)に分割し、副配列が1つの要素のみになるまで(最小の部分問題)続けます。
2. **統治**:順序付けられた副配列(部分問題の解)をボトムアップでマージして、順序付けられた元の配列(元の問題の解)を取得します。
![マージソートの分割統治戦略](divide_and_conquer.assets/divide_and_conquer_merge_sort.png){ class="animation-figure" }
<p align="center"> 図 12-1 &nbsp; マージソートの分割統治戦略 </p>
## 12.1.1 &nbsp; 分割統治問題を特定する方法
問題が分割統治解決に適しているかどうかは、通常以下の基準に基づいて決定できます。
1. **問題をより小さなものに分解できる**:元の問題をより小さく類似した部分問題に分割でき、そのような過程を同じ方法で再帰的に実行できます。
2. **部分問題は独立している**:部分問題間に重複がなく、独立しており、個別に解決できます。
3. **部分問題の解をマージできる**:元の問題の解は、部分問題の解を組み合わせることで導出されます。
明らかに、マージソートはこれら3つの基準を満たしています。
1. **問題をより小さなものに分解できる**:配列(元の問題)を再帰的に2つの副配列(部分問題)に分割します。
2. **部分問題は独立している**:各副配列は独立してソートできます(部分問題は独立して解決できます)。
3. **部分問題の解をマージできる**:2つの順序付けられた副配列(部分問題の解)を1つの順序付けられた配列(元の問題の解)にマージできます。
## 12.1.2 &nbsp; 分割統治による効率の向上
**分割統治戦略はアルゴリズム問題を効果的に解決するだけでなく、しばしば効率を向上させます**。ソートアルゴリズムでは、クイックソート、マージソート、ヒープソートは、分割統治戦略を適用しているため、選択ソート、バブルソート、挿入ソートよりも高速です。
私たちの心には疑問があるかもしれません:**なぜ分割統治はアルゴリズムの効率を向上させることができ、その根本的な論理は何ですか?** つまり、問題を部分問題に分解し、それらを解決し、それらの解を組み合わせて元の問題に対処することが、元の問題を直接解決するよりも効率的である理由は何ですか?この質問は2つの側面から分析できます:操作数と並列計算。
### 1. &nbsp; 操作数の最適化
「バブルソート」を例にとると、長さ $n$ の配列を処理するのに $O(n^2)$ 時間が必要です。以下の図に示すように、配列を中点から2つの副配列に分割するとします。そのような分割には $O(n)$ 時間が必要です。各副配列のソートには $O((n / 2)^2)$ 時間が必要です。そして2つの副配列のマージには $O(n)$ 時間が必要です。したがって、全体の時間計算量は:
$$
O(n + (\frac{n}{2})^2 \times 2 + n) = O(\frac{n^2}{2} + 2n)
$$
![配列分割前後のバブルソート](divide_and_conquer.assets/divide_and_conquer_bubble_sort.png){ class="animation-figure" }
<p align="center"> 図 12-2 &nbsp; 配列分割前後のバブルソート </p>
以下の不等式を計算してみましょう。左側は分割前の総操作数を表し、右側は分割後の総操作数をそれぞれ表します:
$$
\begin{aligned}
n^2 & > \frac{n^2}{2} + 2n \newline
n^2 - \frac{n^2}{2} - 2n & > 0 \newline
n(n - 4) & > 0
\end{aligned}
$$
**これは $n > 4$ の場合、分割後の操作数が少なく、より良いパフォーマンスにつながることを意味します**。分割後の時間計算量は依然として二次 $O(n^2)$ ですが、計算量の定数係数が減少していることに注意してください。
さらに進むことができます。**副配列をその中点からさらに2つの副配列に分割し続けて、副配列が1つの要素のみになるまで続けたらどうでしょうか?** このアイデアは実際には「マージソート」で、時間計算量は $O(n \log n)$ です。
少し違うことを試してみましょう。**2つではなく、より多くの分割に分割したらどうでしょうか?** 例えば、元の配列を $k$ 個の副配列に均等に分割しますか?このアプローチは「バケットソート」と非常に似ており、大量のデータのソートに非常に適しています。理論的には、時間計算量は $O(n + k)$ に達することができます。
### 2. &nbsp; 並列計算による最適化
分割統治によって生成される部分問題は互いに独立していることが分かっています。**これは、それらを並列で解決できることを意味します。** その結果、分割統治はアルゴリズムの時間計算量を減らすだけでなく、**現代のオペレーティングシステムによる並列最適化も促進します。**
並列最適化は、複数のコアやプロセッサを持つ環境で特に効果的です。システムが複数の部分問題を同時に処理できるため、計算リソースを完全に活用し、全体的な実行時間が大幅に短縮されます。
例えば、以下の図に示す「バケットソート」では、大量のデータを様々なバケットに均等に分解します。各バケットのソート作業は、利用可能な計算ユニットに割り当てることができます。すべての作業が完了すると、すべてのソートされたバケットがマージされて最終結果が生成されます。
![バケットソートの並列計算](divide_and_conquer.assets/divide_and_conquer_parallel_computing.png){ class="animation-figure" }
<p align="center"> 図 12-3 &nbsp; バケットソートの並列計算 </p>
## 12.1.3 &nbsp; 分割統治の一般的な応用
分割統治は多くの古典的なアルゴリズム問題を解決するために使用できます。
- **最近点対の発見**:このアルゴリズムは点の集合を2つの半分に分割することで動作します。そして各半分で再帰的に最近点対を見つけます。最後に、2つの半分にまたがるペアを考慮して、全体の最近点対を見つけます。
- **大整数の乗算**:一つのアルゴリズムはKaratsubaと呼ばれます。大整数の乗算をいくつかの小さな整数の乗算と加算に分解します。
- **行列の乗算**:一例はStrassenアルゴリズムです。大きな行列の乗算を複数の小さな行列の乗算と加算に分解します。
- **ハノイの塔問題**:ハノイの塔問題は再帰的に解決でき、分割統治戦略の典型的な応用です。
- **転倒対の解決**:シーケンスで、前の数が後の数より大きい場合、これら2つの数は転倒対を構成します。転倒対問題の解決は、マージソートの助けを借りて、分割統治のアイデアを利用できます。
分割統治はアルゴリズムとデータ構造の設計にも広く応用されています。
- **二分探索**:二分探索は、ソート済み配列を中点インデックスから2つの半分に分割します。そして、ターゲット値と中間要素値の比較結果に基づいて、一方の半分が破棄されます。同じプロセスで残りの半分で検索が続行され、ターゲットが見つかるか残りの要素がなくなるまで続きます。
- **マージソート**:この節の冒頭ですでに紹介したため、さらなる詳述は不要です。
- **クイックソート**:クイックソートはピボット値を選択して配列を2つの副配列に分割し、一方はピボットより小さい要素、もう一方はピボットより大きい要素を持ちます。このプロセスは、これら2つの副配列のそれぞれに対して、1つの要素のみを保持するまで続きます。
- **バケットソート**:バケットソートの基本的なアイデアは、データを複数のバケットに分散させることです。各バケット内の要素をソートした後、バケットから順序よく要素を取得して順序付けられた配列を取得します。
- **木**:例えば、二分探索木、AVL木、赤黒木、B木、B+木など。その操作(検索、挿入、削除)はすべて分割統治戦略の応用と見なすことができます。
- **ヒープ**:ヒープは特別なタイプの完全二分木です。その様々な操作(挿入、削除、ヒープ化)は、実際に分割統治のアイデアを含意しています。
- **ハッシュテーブル**:ハッシュテーブルは直接分割統治を適用しませんが、一部のハッシュ衝突解決ソリューションは間接的にこの戦略を適用します。例えば、チェイン法の長いリストは、クエリ効率を向上させるために赤黒木に変換される場合があります。
**分割統治は巧妙に浸透するアルゴリズムアイデア**であり、様々なアルゴリズムとデータ構造に組み込まれていることが分かります。
@@ -0,0 +1,318 @@
---
comments: true
---
# 12.4 &nbsp; ハノイの塔問題
マージソートと二分木構築の両方で、元の問題を2つの部分問題に分解し、それぞれが元の問題のサイズの半分でした。しかし、ハノイの塔では、異なる分解戦略を採用します。
!!! question
3つの柱があり、それぞれ `A``B``C` と表記されます。最初、柱 `A` には $n$ 枚の円盤があり、上から下に向かって昇順のサイズで配置されています。私たちのタスクは、これらの $n$ 枚の円盤を柱 `C` に移動し、元の順序を維持することです(以下の図に示すように)。移動中には以下のルールが適用されます:
1. 円盤は柱の上部からのみ取り除くことができ、別の柱の上部に置く必要があります。
2. 一度に移動できるのは1枚の円盤のみです。
3. 小さい円盤は常に大きい円盤の上にある必要があります。
![ハノイの塔の例](hanota_problem.assets/hanota_example.png){ class="animation-figure" }
<p align="center"> 図 12-10 &nbsp; ハノイの塔の例 </p>
**サイズ $i$ のハノイの塔問題を $f(i)$ と表記します**。例えば、$f(3)$ は3枚の円盤を柱 `A` から柱 `C` に移動することを表します。
### 1. &nbsp; 基本ケースを考える
以下の図に示すように、問題 $f(1)$(円盤が1枚のみ)については、`A` から `C` に直接移動できます。
=== "<1>"
![サイズ1の問題の解](hanota_problem.assets/hanota_f1_step1.png){ class="animation-figure" }
=== "<2>"
![hanota_f1_step2](hanota_problem.assets/hanota_f1_step2.png){ class="animation-figure" }
<p align="center"> 図 12-11 &nbsp; サイズ1の問題の解 </p>
$f(2)$(円盤が2枚)については、**柱 `B` の助けを借りて小さい円盤を大きい円盤の上に保つ**必要があります。以下の図に示すように:
1. まず、小さい円盤を `A` から `B` に移動します。
2. 次に、大きい円盤を `A` から `C` に移動します。
3. 最後に、小さい円盤を `B` から `C` に移動します。
=== "<1>"
![サイズ2の問題の解](hanota_problem.assets/hanota_f2_step1.png){ class="animation-figure" }
=== "<2>"
![hanota_f2_step2](hanota_problem.assets/hanota_f2_step2.png){ class="animation-figure" }
=== "<3>"
![hanota_f2_step3](hanota_problem.assets/hanota_f2_step3.png){ class="animation-figure" }
=== "<4>"
![hanota_f2_step4](hanota_problem.assets/hanota_f2_step4.png){ class="animation-figure" }
<p align="center"> 図 12-12 &nbsp; サイズ2の問題の解 </p>
$f(2)$ を解決する過程は次のように要約できます:**`B` の助けを借りて2枚の円盤を `A` から `C` に移動する**。ここで、`C` をターゲット柱、`B` をバッファ柱と呼びます。
### 2. &nbsp; 部分問題の分解
問題 $f(3)$(つまり、円盤が3枚の場合)については、状況がやや複雑になります。
すでに $f(1)$ と $f(2)$ の解が分かっているので、分割統治の観点を採用し、**`A` の上の2枚の円盤を1つの単位として扱い**、以下の図に示すステップを実行できます。これにより、3枚の円盤を `A` から `C` に正常に移動できます。
1. `B` をターゲット柱、`C` をバッファ柱として、2枚の円盤を `A` から `B` に移動します。
2. 残りの円盤を `A` から直接 `C` に移動します。
3. `C` をターゲット柱、`A` をバッファ柱として、2枚の円盤を `B` から `C` に移動します。
=== "<1>"
![サイズ3の問題の解](hanota_problem.assets/hanota_f3_step1.png){ class="animation-figure" }
=== "<2>"
![hanota_f3_step2](hanota_problem.assets/hanota_f3_step2.png){ class="animation-figure" }
=== "<3>"
![hanota_f3_step3](hanota_problem.assets/hanota_f3_step3.png){ class="animation-figure" }
=== "<4>"
![hanota_f3_step4](hanota_problem.assets/hanota_f3_step4.png){ class="animation-figure" }
<p align="center"> 図 12-13 &nbsp; サイズ3の問題の解 </p>
本質的に、**$f(3)$ を2つの $f(2)$ 部分問題と1つの $f(1)$ 部分問題に分解します**。これら3つの部分問題を順次解決することで、元の問題が解決され、部分問題が独立しており、それらの解をマージできることを示しています。
ここから、以下の図に示すハノイの塔の分割統治戦略を要約できます。元の問題 $f(n)$ を2つの部分問題 $f(n-1)$ と1つの部分問題 $f(1)$ に分割し、以下の順序でこれら3つの部分問題を解決します:
1. `C` をバッファとして使用し、$n-1$ 枚の円盤を `A` から `B` に移動します。
2. 残りの円盤を `A` から直接 `C` に移動します。
3. `A` をバッファとして使用し、$n-1$ 枚の円盤を `B` から `C` に移動します。
各 $f(n-1)$ 部分問題について、**同じ再帰分割を適用でき**、最小の部分問題 $f(1)$ に到達するまで続けます。$f(1)$ は単一の移動のみが必要であることがすでに分かっているため、解決するのは簡単です。
![ハノイの塔を解決するための分割統治戦略](hanota_problem.assets/hanota_divide_and_conquer.png){ class="animation-figure" }
<p align="center"> 図 12-14 &nbsp; ハノイの塔を解決するための分割統治戦略 </p>
### 3. &nbsp; コード実装
コードでは、再帰関数 `dfs(i, src, buf, tar)` を定義します。これは柱 `src` から上の $i$ 枚の円盤を柱 `tar` に移動し、柱 `buf` をバッファとして使用します:
=== "Python"
```python title="hanota.py"
def move(src: list[int], tar: list[int]):
"""円盤を移動"""
# src の上から円盤を取り出す
pan = src.pop()
# 円盤を tar の上に置く
tar.append(pan)
def dfs(i: int, src: list[int], buf: list[int], tar: list[int]):
"""ハノイの塔問題 f(i) を解く"""
# src に円盤が 1 つだけ残っている場合、それを tar に移動
if i == 1:
move(src, tar)
return
# 部分問題 f(i-1):tar の助けを借りて src の上の i-1 個の円盤を buf に移動
dfs(i - 1, src, tar, buf)
# 部分問題 f(1):残りの 1 個の円盤を src から tar に移動
move(src, tar)
# 部分問題 f(i-1):src の助けを借りて buf の上の i-1 個の円盤を tar に移動
dfs(i - 1, buf, src, tar)
def solve_hanota(A: list[int], B: list[int], C: list[int]):
"""ハノイの塔問題を解く"""
n = len(A)
# B の助けを借りて A の上の n 個の円盤を C に移動
dfs(n, A, B, C)
```
=== "C++"
```cpp title="hanota.cpp"
/* 円盤を移動 */
void move(vector<int> &src, vector<int> &tar) {
// src の最上部から円盤を取り出す
int pan = src.back();
src.pop_back();
// 円盤を tar の最上部に配置
tar.push_back(pan);
}
/* ハノイの塔問題 f(i) を解く */
void dfs(int i, vector<int> &src, vector<int> &buf, vector<int> &tar) {
// src に円盤が1つだけ残っている場合、それを tar に移動
if (i == 1) {
move(src, tar);
return;
}
// 部分問題 f(i-1):tar の助けを借りて、上位 i-1 個の円盤を src から buf に移動
dfs(i - 1, src, tar, buf);
// 部分問題 f(1):残りの1つの円盤を src から tar に移動
move(src, tar);
// 部分問題 f(i-1):src の助けを借りて、上位 i-1 個の円盤を buf から tar に移動
dfs(i - 1, buf, src, tar);
}
/* ハノイの塔問題を解く */
void solveHanota(vector<int> &A, vector<int> &B, vector<int> &C) {
int n = A.size();
// B の助けを借りて、上位 n 個の円盤を A から C に移動
dfs(n, A, B, C);
}
```
=== "Java"
```java title="hanota.java"
/* 円盤を移動 */
void move(List<Integer> src, List<Integer> tar) {
// src の最上部から円盤を取り出す
Integer pan = src.remove(src.size() - 1);
// 円盤を tar の最上部に配置
tar.add(pan);
}
/* ハノイの塔問題 f(i) を解く */
void dfs(int i, List<Integer> src, List<Integer> buf, List<Integer> tar) {
// src に円盤が1つだけ残っている場合、それを tar に移動
if (i == 1) {
move(src, tar);
return;
}
// 部分問題 f(i-1):tar の助けを借りて、上位 i-1 個の円盤を src から buf に移動
dfs(i - 1, src, tar, buf);
// 部分問題 f(1):残りの1つの円盤を src から tar に移動
move(src, tar);
// 部分問題 f(i-1):src の助けを借りて、上位 i-1 個の円盤を buf から tar に移動
dfs(i - 1, buf, src, tar);
}
/* ハノイの塔問題を解く */
void solveHanota(List<Integer> A, List<Integer> B, List<Integer> C) {
int n = A.size();
// B の助けを借りて、上位 n 個の円盤を A から C に移動
dfs(n, A, B, C);
}
```
=== "C#"
```csharp title="hanota.cs"
[class]{hanota}-[func]{Move}
[class]{hanota}-[func]{DFS}
[class]{hanota}-[func]{SolveHanota}
```
=== "Go"
```go title="hanota.go"
[class]{}-[func]{move}
[class]{}-[func]{dfsHanota}
[class]{}-[func]{solveHanota}
```
=== "Swift"
```swift title="hanota.swift"
[class]{}-[func]{move}
[class]{}-[func]{dfs}
[class]{}-[func]{solveHanota}
```
=== "JS"
```javascript title="hanota.js"
[class]{}-[func]{move}
[class]{}-[func]{dfs}
[class]{}-[func]{solveHanota}
```
=== "TS"
```typescript title="hanota.ts"
[class]{}-[func]{move}
[class]{}-[func]{dfs}
[class]{}-[func]{solveHanota}
```
=== "Dart"
```dart title="hanota.dart"
[class]{}-[func]{move}
[class]{}-[func]{dfs}
[class]{}-[func]{solveHanota}
```
=== "Rust"
```rust title="hanota.rs"
[class]{}-[func]{move_pan}
[class]{}-[func]{dfs}
[class]{}-[func]{solve_hanota}
```
=== "C"
```c title="hanota.c"
[class]{}-[func]{move}
[class]{}-[func]{dfs}
[class]{}-[func]{solveHanota}
```
=== "Kotlin"
```kotlin title="hanota.kt"
[class]{}-[func]{move}
[class]{}-[func]{dfs}
[class]{}-[func]{solveHanota}
```
=== "Ruby"
```ruby title="hanota.rb"
[class]{}-[func]{move}
[class]{}-[func]{dfs}
[class]{}-[func]{solve_hanota}
```
=== "Zig"
```zig title="hanota.zig"
[class]{}-[func]{move}
[class]{}-[func]{dfs}
[class]{}-[func]{solveHanota}
```
以下の図に示すように、ハノイの塔問題は高さ $n$ の再帰木として視覚化できます。各ノードは部分問題を表し、`dfs()` の呼び出しに対応します。**したがって、時間計算量は $O(2^n)$、空間計算量は $O(n)$ です。**
![ハノイの塔の再帰木](hanota_problem.assets/hanota_recursive_tree.png){ class="animation-figure" }
<p align="center"> 図 12-15 &nbsp; ハノイの塔の再帰木 </p>
!!! quote
ハノイの塔は古代の伝説に由来します。古代インドの寺院で、僧侶たちは3本の高いダイヤモンドの柱と、異なるサイズの $64$ 枚の金の円盤を持っていました。彼らは、最後の円盤が正しく置かれたとき、世界が終わると信じていました。
しかし、僧侶たちが1秒に1枚の円盤を移動したとしても、約 $2^{64} \approx 1.84×10^{19}$ —約5850億年—かかり、宇宙の年齢の現在の推定をはるかに超えています。したがって、この伝説が真実であれば、世界の終わりについて心配する必要はおそらくないでしょう。
@@ -0,0 +1,22 @@
---
comments: true
icon: material/set-split
---
# 第 12 章 &nbsp; 分割統治
![分割統治](../assets/covers/chapter_divide_and_conquer.jpg){ class="cover-image" }
!!! abstract
困難な問題は層を重ねて分解され、各分解によってより単純になります。
分割統治は深い真理を明らかにします:単純さから始めれば、複雑さは解決される。
## 章の内容
- [12.1 &nbsp; 分割統治アルゴリズム](divide_and_conquer.md)
- [12.2 &nbsp; 分割統治探索戦略](binary_search_recur.md)
- [12.3 &nbsp; 二分木構築問題](build_binary_tree_problem.md)
- [12.4 &nbsp; ハノイの塔問題](hanota_problem.md)
- [12.5 &nbsp; まとめ](summary.md)
@@ -0,0 +1,15 @@
---
comments: true
---
# 12.5 &nbsp; まとめ
- 分割統治は一般的なアルゴリズム設計戦略で、分割(分割)と統治(マージ)の2つの段階から構成され、一般的に再帰を使用して実装されます。
- 問題が分割統治アプローチに適しているかどうかを判断するために、問題が分解可能かどうか、部分問題が独立しているかどうか、部分問題をマージできるかどうかを確認します。
- マージソートは分割統治戦略の典型的な例です。配列を再帰的に2つの等しい長さの副配列に分割し、1つの要素のみが残るまで続け、次にこれらの副配列を層ごとにマージしてソートを完了します。
- 分割統治戦略の導入は、しばしばアルゴリズムの効率を向上させます。一方では操作数を減らし、他方では分割後のシステムの並列最適化を促進します。
- 分割統治は多数のアルゴリズム問題に適用でき、データ構造とアルゴリズム設計で広く使用され、多くのシナリオに現れます。
- 総当たり検索と比較して、適応検索はより効率的です。時間計算量が $O(\log n)$ の検索アルゴリズムは、通常分割統治戦略に基づいています。
- 二分探索は分割統治戦略のもう一つの古典的な応用です。部分問題の解のマージを含まず、再帰的な分割統治アプローチで実装できます。
- 二分木構築問題では、木の構築(元の問題)を左の部分木と右の部分木の構築(部分問題)に分割できます。これは前順走査と中順走査のインデックス範囲を分割することで実現できます。
- ハノイの塔問題では、サイズ $n$ の問題をサイズ $n-1$ の2つの部分問題とサイズ $1$ の1つの部分問題に分解できます。これら3つの部分問題を順次解決することで、元の問題が解決されます。