feat: Add the chapter of divide and conquer (#609)

* Add the chapter of divide and conquer.
Add the section of divide and conquer algorithm.
Add the section of build tree problem.

* Update build_tree.py
This commit is contained in:
Yudong Jin 2023-07-16 04:24:04 +08:00 committed by GitHub
parent f5ea4fa1c6
commit 51a4c5089e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 342 additions and 17 deletions

View file

@ -0,0 +1,54 @@
"""
File: build_tree.py
Created Time: 2023-07-15
Author: Krahets (krahets@163.com)
"""
import sys, os.path as osp
sys.path.append(osp.dirname(osp.dirname(osp.abspath(__file__))))
from modules import *
def dfs(
preorder: list[int],
inorder: list[int],
hmap: dict[int, int],
i: int,
l: int,
r: int,
) -> TreeNode | None:
"""构建二叉树 DFS"""
# 子树区间为空时终止
if r - l < 0:
return None
# 初始化根节点
root = TreeNode(preorder[i])
# 查询 m ,从而划分左右子树
m = hmap[preorder[i]]
# 递归构建左子树
root.left = dfs(preorder, inorder, hmap, i + 1, l, m - 1)
# 递归构建右子树
root.right = dfs(preorder, inorder, hmap, i + 1 + m - l, m + 1, r)
# 返回根节点
return root
def build_tree(preorder: list[int], inorder: list[int]) -> TreeNode | None:
"""构建二叉树"""
# 初始化哈希表,存储 inorder 元素到索引的映射
hmap = {val: i for i, val in enumerate(inorder)}
root = dfs(preorder, inorder, hmap, 0, 0, len(inorder) - 1)
return root
"""Driver Code"""
if __name__ == "__main__":
preorder = [3, 9, 2, 1, 7]
inorder = [9, 3, 1, 2, 7]
print(f"前序遍历 = {preorder}")
print(f"中序遍历 = {inorder}")
root = build_tree(preorder, inorder)
print("构建的二叉树为:")
print_tree(root)

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

View file

@ -0,0 +1,176 @@
# 构建二叉树问题
!!! question
给定一个二叉树的前序遍历 `preorder` 和中序遍历 `inorder` ,请从中构建二叉树,返回二叉树的根节点。
![构建二叉树的示例数据](build_binary_tree.assets/build_tree_example.png)
原问题定义为从 `preorder``inorder` 构建二叉树。我们首先从分治的角度分析这道题:
- **问题可以被分解**:从分治的角度切入,我们可以将原问题划分为两个子问题:构建左子树、构建右子树,加上一步操作:初始化根节点。而对于每个子树(子问题),我们仍然可以复用以上划分方法,将其划分为更小的子树(子问题),直至达到最小子问题(空子树)时终止。
- **子问题是独立的**:左子树和右子树是相互独立的,它们之间没有交集。在构建左子树时,我们只需要关注中序遍历和前序遍历或后序遍历中与左子树对应的部分。右子树同理。
- **子问题的解可以合并**:一旦我们得到了左子树和右子树,我们可以将它们链接到根节点上,从而得到原问题的解。
根据以上分析,这道题是可以使用分治来求解的,但问题是:**如何通过前序遍历 `preorder` 和中序遍历 `inorder` 来划分左子树和右子树呢**
根据定义,`preorder` 和 `inorder` 都可以被划分为三个部分:
- 前序遍历:`[ 根节点 | 左子树 | 右子树 ]` ,例如上图 `[ 3 | 9 | 2 1 7 ]`
- 中序遍历:`[ 左子树 | 根节点 右子树 ]` ,例如上图 `[ 9 | 3 | 1 2 7 ]`
以上图数据为例,我们可以通过以下三步得到上述的划分结果:
1. 前序遍历的首元素 3 为根节点的值;
2. 查找根节点在 `inorder` 中的索引,基于该索引可将 `inorder` 划分为 `[ 9 | 3 1 2 7 ]`
3. 根据 `inorder` 划分结果,可得左子树和右子树分别有 1 个和 3 个节点,从而可将 `preorder` 划分为 `[ 3 | 9 | 2 1 7 ]`
![在前序和中序遍历中划分子树](build_binary_tree.assets/build_tree_preorder_inorder_division.png)
至此,**我们已经推导出根节点、左子树、右子树在 `preorder``inorder` 中的索引区间**。而为了描述这些索引区间,我们需要借助几个指针变量:
- 将当前树的根节点在 `preorder` 中的索引记为 $i$
- 将当前树的根节点在 `inorder` 中的索引记为 $m$
- 将当前树在 `inorder` 中的索引区间记为 $[l, r]$
下表整理了根节点、左子树和右子树的索引区间在这些变量下的具体表示。
<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.assets/build_tree_division_pointers.png)
接下来就可以实现代码了。为了提升查询 $m$ 的效率,我们借助一个哈希表 `hmap` 来存储 `inorder` 列表元素到索引的映射。
=== "Java"
```java title="build_tree.java"
[class]{build_tree}-[func]{dfs}
[class]{build_tree}-[func]{buildTree}
```
=== "C++"
```cpp title="build_tree.cpp"
[class]{}-[func]{dfs}
[class]{}-[func]{buildTree}
```
=== "Python"
```python title="build_tree.py"
[class]{}-[func]{dfs}
[class]{}-[func]{build_tree}
```
=== "Go"
```go title="build_tree.go"
[class]{}-[func]{dfs}
[class]{}-[func]{buildTree}
```
=== "JavaScript"
```javascript title="build_tree.js"
[class]{}-[func]{dfs}
[class]{}-[func]{buildTree}
```
=== "TypeScript"
```typescript title="build_tree.ts"
[class]{}-[func]{dfs}
[class]{}-[func]{buildTree}
```
=== "C"
```c title="build_tree.c"
[class]{}-[func]{dfs}
[class]{}-[func]{buildTree}
```
=== "C#"
```csharp title="build_tree.cs"
[class]{build_tree}-[func]{dfs}
[class]{build_tree}-[func]{buildTree}
```
=== "Swift"
```swift title="build_tree.swift"
[class]{}-[func]{dfs}
[class]{}-[func]{buildTree}
```
=== "Zig"
```zig title="build_tree.zig"
[class]{}-[func]{dfs}
[class]{}-[func]{buildTree}
```
=== "Dart"
```dart title="build_tree.dart"
[class]{}-[func]{dfs}
[class]{}-[func]{buildTree}
```
下图展示了构建二叉树的递归过程,各个节点是在向下“递”的过程中建立的,而各条边是在向上“归”的过程中建立的。
=== "<1>"
![built_tree_step1](build_binary_tree.assets/built_tree_step1.png)
=== "<2>"
![built_tree_step2](build_binary_tree.assets/built_tree_step2.png)
=== "<3>"
![built_tree_step3](build_binary_tree.assets/built_tree_step3.png)
=== "<4>"
![built_tree_step4](build_binary_tree.assets/built_tree_step4.png)
=== "<5>"
![built_tree_step5](build_binary_tree.assets/built_tree_step5.png)
=== "<6>"
![built_tree_step6](build_binary_tree.assets/built_tree_step6.png)
=== "<7>"
![built_tree_step7](build_binary_tree.assets/built_tree_step7.png)
=== "<8>"
![built_tree_step8](build_binary_tree.assets/built_tree_step8.png)
=== "<9>"
![built_tree_step9](build_binary_tree.assets/built_tree_step9.png)
=== "<10>"
![built_tree_step10](build_binary_tree.assets/built_tree_step10.png)
设树的节点数量为 $n$ ,初始化每一个节点(执行一个递归函数 `dfs()` )使用 $O(1)$ 时间。**因此总体时间复杂度为 $O(n)$** 。
哈希表存储 `inorder` 元素到索引的映射,空间复杂度为 $O(n)$ 。最差情况下,即二叉树退化为链表时,递归深度达到 $n$ ,使用 $O(n)$ 的栈帧空间。**因此总体空间复杂度为 $O(n)$** 。

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

View file

@ -0,0 +1,89 @@
# 分治算法
「分治 Divide and Conquer」全称分而治之是一种非常重要的算法策略。分治通常基于递归实现包括“分”和“治”两部分主要步骤如下
1. **分(划分阶段)**:递归地将原问题分解为两个或多个子问题,直至到达最小子问题时终止;
2. **治(合并阶段)**:从已知解的最小子问题开始,从底至顶地将子问题的解进行合并,从而构建出原问题的解;
之前学过的「归并排序」是分治策略的典型应用之一,对于该算法:
1. **分**:递归地将原数组(原问题)划分为两个子数组(子问题),直到子数组只剩一个元素(最小子问题)。
2. **治**:从底至顶地将有序的子数组进行合并,从而得到有序的原数组。
![归并排序的分治策略](divide_and_conquer.assets/divide_and_conquer_merge_sort.png)
一个问题是否适合使用分治解决,通常可以参考以下几个判断依据:
1. **问题可以被分解**:原问题可以被分解成规模更小、类似的子问题,以及能够以相同方式递归地进行划分。
2. **子问题是独立的**:子问题之间是没有重叠的,互相没有依赖,可以被独立解决。
3. **子问题的解可以被合并**:原问题的解可以通过合并子问题的解得来。
归并排序显然是满足以上三条判断依据的:
1. 递归地将数组(原问题)划分为两个子数组(子问题),当子数组只有一个元素时天然有序;
2. 每个子数组都可以独立地进行排序,因此子问题是独立的;
3. 两个有序子数组(子问题的解)可以被合并为一个有序数组(原问题的解);
## 通过分治提升效率
分治不仅可以有效地解决算法问题,**往往还可以提升算法效率**。在排序算法中,归并排序相较于选择、冒泡、插入排序更快,就是因为其应用了分治策略。
那么,我们不禁发问:**为什么分治可以提升算法效率,其底层逻辑是什么**?换句话说,将大问题分解为多个子问题、解决子问题、将子问题的解合并为原问题的解,这三步为什么比直接解决原问题效率更高?这个问题可以从操作数量和并行计算两方面来讨论。
### 操作数量优化
以「冒泡排序」为例,其处理一个长度为 $n$ 的数组需要 $O(n^2)$ 时间。假设我们把数组从中点分为两个子数组,则划分需要 $O(n)$ 时间,排序每个子数组需要 $O((n/2)^2)$ 时间,合并两个子数组需要 $O(n)$ 时间,总体时间复杂度为:
$$
O(n + (n/2)^2 \times 2 + n) = O(n^2 / 2 + 2n)
$$
![划分数组前后的冒泡排序](divide_and_conquer.assets/divide_and_conquer_bubble_sort.png)
接下来,我们计算以下不等式,其左边和右边分别为划分前和划分后的操作总数:
$$
\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)$ ,即复杂度并没有降低,只是其中的常数项变小了。
那么,**如果我们把子数组不断地再从中点划分为两个子数组**,直至子数组只剩一个元素时停止划分呢?这就达到了「归并排序」的情况,时间复杂度为 $O(n \log n)$ 。
再思考,**如果我们多设置几个划分点**,将原数组平均划分为 $k$ 个子数组呢?这种情况就与「桶排序」非常类似了,它非常适合排序海量数据,理论上时间复杂度可以达到 $O(n + k)$ 。
### 并行计算优化
我们知道,分治生成的子问题是相互独立的,**因此通常可以并行解决**。也就是说,分治不仅可以降低算法的时间复杂度,**还有利于操作系统的并行优化**。
并行优化在多核或多处理器的环境中尤其有效,因为系统可以同时处理多个子问题,更加充分地利用计算资源,从而显著减少总体的运行时间。
例如在桶排序中,我们将海量的数据平均分配到各个桶中,则可所有桶的排序任务分散到各个计算单元,完成后再进行结果合并。
![桶排序的并行计算](divide_and_conquer.assets/divide_and_conquer_parallel_computing.png)
## 分治常见应用
一方面,分治可以用来解决许多经典算法问题:
- **寻找最近点对**:该算法首先将点集分成两部分,然后分别找出两部分中的最近点对,最后再找出跨越两部分的最近点对。
- **大整数乘法**:例如 Karatsuba 算法,它是将大整数乘法分解为几个较小的整数的乘法和加法。
- **矩阵乘法**:例如 Strassen 算法,它是将大矩阵乘法分解为多个小矩阵的乘法和加法。
- **汉诺塔问题**:汉诺塔问题可以视为典型的分治策略,通过递归解决。
- **求解逆序对**:在一个序列中,如果前面的数字大于后面的数字,那么这两个数字构成一个逆序对。求解逆序对问题可以通过分治的思想,借助归并排序进行求解。
另一方面,**分治在算法和数据结构的设计中应用非常广泛**,举几个已经学过的例子:
- **二分查找**:二分查找是将有序数组从中点索引分为两部分,然后根据目标值与中间元素值比较结果,决定排除哪一半区间,然后在剩余区间执行相同的二分操作。
- **归并排序**:文章开头已介绍,不再赘述。
- **快速排序**:快速排序是选取一个基准值,然后把数组分为两个子数组,一个子数组的元素比基准值小,另一子数组的元素比基准值大,然后再对这两部分进行相同的划分操作,直至子数组只剩下一个元素。
- **桶排序**:桶排序的基本思想是将数据分散到多个桶,然后对每个桶内的元素进行排序,最后将各个桶的元素依次取出,从而得到一个有序数组。
- **树**例如二叉搜索树、AVL 树、红黑树、B 树、B+ 树等,它们的查找、插入和删除等操作都可以视为分治的应用。
- **堆**:堆是一种特殊的完全二叉树,其各种操作,如插入、删除和堆化,实际上都隐含了分治的思想。
- **哈希表**:虽然哈希表来并不直接应用分治,但某些哈希冲突解决策略间接应用了分治策略,例如,链式地址中的长链表会被转化为红黑树,以提升查询效率。
可以看出,**分治是一种“润物细无声”的算法思想**,隐含在各种算法与数据结构之中。

View file

@ -0,0 +1,2 @@
# 分治

View file

@ -205,24 +205,28 @@ nav:
- 11.9. &nbsp; 计数排序: chapter_sorting/counting_sort.md
- 11.10. &nbsp; 基数排序: chapter_sorting/radix_sort.md
- 11.11. &nbsp; 小结: chapter_sorting/summary.md
- 12. &nbsp; &nbsp; 回溯:
- 12. &nbsp; &nbsp; 分治:
- chapter_divide_and_conquer/index.md
- 12.1. &nbsp; 分治算法New: chapter_divide_and_conquer/divide_and_conquer.md
- 12.2. &nbsp; 构建树问题New: chapter_divide_and_conquer/build_binary_tree.md
- 13. &nbsp; &nbsp; 回溯:
- chapter_backtracking/index.md
- 12.1. &nbsp; 回溯算法: chapter_backtracking/backtracking_algorithm.md
- 12.2. &nbsp; 全排列问题: chapter_backtracking/permutations_problem.md
- 12.3. &nbsp; 子集和问题: chapter_backtracking/subset_sum_problem.md
- 12.4. &nbsp; N 皇后问题: chapter_backtracking/n_queens_problem.md
- 12.5. &nbsp; 小结: chapter_backtracking/summary.md
- 13. &nbsp; &nbsp; 动态规划:
- 13.1. &nbsp; 回溯算法: chapter_backtracking/backtracking_algorithm.md
- 13.2. &nbsp; 全排列问题: chapter_backtracking/permutations_problem.md
- 13.3. &nbsp; 子集和问题: chapter_backtracking/subset_sum_problem.md
- 13.4. &nbsp; N 皇后问题: chapter_backtracking/n_queens_problem.md
- 13.5. &nbsp; 小结: chapter_backtracking/summary.md
- 14. &nbsp; &nbsp; 动态规划:
- chapter_dynamic_programming/index.md
- 13.1. &nbsp; 初探动态规划New: chapter_dynamic_programming/intro_to_dynamic_programming.md
- 13.2. &nbsp; DP 问题特性New: chapter_dynamic_programming/dp_problem_features.md
- 13.3. &nbsp; DP 解题思路New: chapter_dynamic_programming/dp_solution_pipeline.md
- 13.4. &nbsp; 0-1 背包问题New: chapter_dynamic_programming/knapsack_problem.md
- 13.5. &nbsp; 完全背包问题New: chapter_dynamic_programming/unbounded_knapsack_problem.md
- 13.6. &nbsp; 编辑距离问题New: chapter_dynamic_programming/edit_distance_problem.md
- 13.7. &nbsp; 小结New: chapter_dynamic_programming/summary.md
- 14. &nbsp; &nbsp; 附录:
- 14.1. &nbsp; 编程环境安装: chapter_appendix/installation.md
- 14.2. &nbsp; 一起参与创作: chapter_appendix/contribution.md
- 14.1. &nbsp; 初探动态规划New: chapter_dynamic_programming/intro_to_dynamic_programming.md
- 14.2. &nbsp; DP 问题特性New: chapter_dynamic_programming/dp_problem_features.md
- 14.3. &nbsp; DP 解题思路New: chapter_dynamic_programming/dp_solution_pipeline.md
- 14.4. &nbsp; 0-1 背包问题New: chapter_dynamic_programming/knapsack_problem.md
- 14.5. &nbsp; 完全背包问题New: chapter_dynamic_programming/unbounded_knapsack_problem.md
- 14.6. &nbsp; 编辑距离问题New: chapter_dynamic_programming/edit_distance_problem.md
- 14.7. &nbsp; 小结New: chapter_dynamic_programming/summary.md
- 15. &nbsp; &nbsp; 附录:
- 15.1. &nbsp; 编程环境安装: chapter_appendix/installation.md
- 15.2. &nbsp; 一起参与创作: chapter_appendix/contribution.md
- 参考文献:
- chapter_reference/index.md