This commit is contained in:
krahets
2025-03-14 17:51:03 +08:00
parent c458348df2
commit e81bc45c43
31 changed files with 392 additions and 394 deletions
@@ -2,58 +2,58 @@
comments: true
---
# 12.3   Building binary tree problem
# 12.3   Building a binary tree problem
!!! question
Given the pre-order traversal `preorder` and in-order traversal `inorder` of a binary tree, construct the binary tree and return the root node of the binary tree. Assume that there are no duplicate values in the nodes of the binary tree (as shown in Figure 12-5).
Given the pre-order traversal `preorder` sequence and the in-order traversal `inorder` sequence of a binary tree, construct the binary tree and return its root node. Assume there are no duplicate node values in the binary tree (as shown in Figure 12-5).
![Example data for building a binary tree](build_binary_tree_problem.assets/build_tree_example.png){ class="animation-figure" }
<p align="center"> Figure 12-5 &nbsp; Example data for building a binary tree </p>
### 1. &nbsp; Determining if it is a divide and conquer problem
### 1. &nbsp; Determining if it is a divide-and-conquer problem
The original problem of constructing a binary tree from `preorder` and `inorder` is a typical divide and conquer problem.
The original problem of building a binary tree from the `preorder` and the `inorder` sequences is a typical divide-and-conquer problem.
- **The problem can be decomposed**: From the perspective of divide and conquer, we can divide the original problem into two subproblems: building the left subtree and building the right subtree, plus one operation: initializing the root node. For each subtree (subproblem), we can still use the above division method, dividing it into smaller subtrees (subproblems), until the smallest subproblem (empty subtree) is reached.
- **The subproblems are independent**: The left and right subtrees are independent of each other, with no overlap. When building the left subtree, we only need to focus on the parts of the in-order and pre-order traversals that correspond to the left subtree. The same applies to the right subtree.
- **Solutions to subproblems can be combined**: Once the solutions for the left and right subtrees (solutions to subproblems) are obtained, we can link them to the root node to obtain the solution to the original problem.
- **The problem can be decomposed**: From the perspective of divide-and-conquer, we can divide the original problem into two subproblemsbuilding the left subtree and building the right subtreeplus one operation of initializing the root node. For each subtree (subproblem), we continue applying the same approach, partitioning it into smaller subtrees (subproblems), until reaching the smallest subproblem (an empty subtree).
- **The subproblems are independent**: The left and right subtrees do not overlap. When building the left subtree, we only need the segments of the in-order and pre-order traversals that correspond to the left subtree. The same approach applies to the right subtree.
- **Solutions to subproblems can be combined**: Once we have constructed the left and right subtrees (the subproblem solutions), we can attach them to the root node to obtain the solution to the original problem.
### 2. &nbsp; How to divide the subtrees
Based on the above analysis, this problem can be solved using divide and conquer, **but how do we use the pre-order traversal `preorder` and in-order traversal `inorder` to divide the left and right subtrees?**
Based on the above analysis, this problem can be solved using divide-and-conquer. **However, how do we use the pre-order traversal `preorder` sequence and the in-order traversal `inorder` sequence to divide the left and right subtrees?**
By definition, `preorder` and `inorder` can be divided into three parts.
By definition, both the `preorder` and `inorder` sequences can be divided into three parts:
- Pre-order traversal: `[ Root | Left Subtree | Right Subtree ]`, for example, the tree in the figure corresponds to `[ 3 | 9 | 2 1 7 ]`.
- In-order traversal: `[ Left Subtree | Root | Right Subtree ]`, for example, the tree in the figure corresponds to `[ 9 | 3 | 1 2 7 ]`.
- Pre-order traversal: `[ Root | Left Subtree | Right Subtree ]`. For example, in the figure, the tree corresponds to `[ 3 | 9 | 2 1 7 ]`.
- In-order traversal: `[ Left Subtree | Root | Right Subtree ]`. For example, in the figure, the tree corresponds to `[ 9 | 3 | 1 2 7 ]`.
Using the data in the figure above, we can obtain the division results as shown in Figure 12-6.
Using the data from the preceding figure, we can follow the steps shown in the next figure to obtain the division results:
1. The first element 3 in the pre-order traversal is the value of the root node.
2. Find the index of the root node 3 in `inorder`, and use this index to divide `inorder` into `[ 9 | 3 1 2 7 ]`.
3. Based on the division results of `inorder`, it is easy to determine the number of nodes in the left and right subtrees as 1 and 3, respectively, thus dividing `preorder` into `[ 3 | 9 | 2 1 7 ]`.
2. Find the index of the root node 3 in the `inorder` sequence, and use this index to split `inorder` into `[ 9 | 3 1 2 7 ]`.
3. According to the split of the `inorder` sequence, it is straightforward to determine that the left and right subtrees contain 1 and 3 nodes, respectively, so we can split the `preorder` sequence into `[ 3 | 9 | 2 1 7 ]` accordingly.
![Dividing the subtrees in pre-order and in-order traversals](build_binary_tree_problem.assets/build_tree_preorder_inorder_division.png){ class="animation-figure" }
![Dividing the subtrees in pre-order and in-order traversals](build_binary_tree_problem.assets/build_tree_pre-order_in-order_division.png){ class="animation-figure" }
<p align="center"> Figure 12-6 &nbsp; Dividing the subtrees in pre-order and in-order traversals </p>
### 3. &nbsp; Describing subtree intervals based on variables
### 3. &nbsp; Describing subtree ranges based on variables
Based on the above division method, **we have now obtained the index intervals of the root, left subtree, and right subtree in `preorder` and `inorder`**. To describe these index intervals, we need the help of several pointer variables.
Based on the above division method, **we have now obtained the index ranges of the root, left subtree, and right subtree in the `preorder` and `inorder` sequences**. To describe these index ranges, we use several pointer variables.
- Let the index of the current tree's root node in `preorder` be denoted as $i$.
- Let the index of the current tree's root node in `inorder` be denoted as $m$.
- Let the index interval of the current tree in `inorder` be denoted as $[l, r]$.
- Let the index of the current tree's root node in the `preorder` sequence be denoted as $i$.
- Let the index of the current tree's root node in the `inorder` sequence be denoted as $m$.
- Let the index range of the current tree in the `inorder` sequence be denoted as $[l, r]$.
As shown in Table 12-1, the above variables can represent the index of the root node in `preorder` as well as the index intervals of the subtrees in `inorder`.
As shown in Table 12-1, these variables represent the root nodes index in the `preorder` sequence and the index ranges of the subtrees in the `inorder` sequence.
<p align="center"> Table 12-1 &nbsp; Indexes of the root node and subtrees in pre-order and in-order traversals </p>
<div class="center-table" markdown>
| | Root node index in `preorder` | Subtree index interval in `inorder` |
| | Root node index in `preorder` | Subtree index range in `inorder` |
| ------------- | ----------------------------- | ----------------------------------- |
| Current tree | $i$ | $[l, r]$ |
| Left subtree | $i + 1$ | $[l, m-1]$ |
@@ -61,7 +61,7 @@ As shown in Table 12-1, the above variables can represent the index of the root
</div>
Please note, the meaning of $(m-l)$ in the right subtree root index is "the number of nodes in the left subtree", which is suggested to be understood in conjunction with Figure 12-7.
Please note that $(m-l)$ in the right subtree root index represents "the number of nodes in the left subtree." It may help to consult Figure 12-7 for a clearer understanding.
![Indexes of the root node and left and right subtrees](build_binary_tree_problem.assets/build_tree_division_pointers.png){ class="animation-figure" }
@@ -69,7 +69,7 @@ Please note, the meaning of $(m-l)$ in the right subtree root index is "the numb
### 4. &nbsp; Code implementation
To improve the efficiency of querying $m$, we use a hash table `hmap` to store the mapping of elements in `inorder` to their indexes:
To improve the efficiency of querying $m$, we use a hash table `hmap` to store the mapping from elements in the `inorder` sequence to their indexes:
=== "Python"
@@ -256,7 +256,7 @@ To improve the efficiency of querying $m$, we use a hash table `hmap` to store t
[class]{}-[func]{buildTree}
```
Figure 12-8 shows the recursive process of building the binary tree, where each node is established during the "descending" process, and each edge (reference) is established during the "ascending" process.
Figure 12-8 shows the recursive process of building the binary tree. Each node is created during the "descending" phase of the recursion, and each edge (reference) is formed during the "ascending" phase.
=== "<1>"
![Recursive process of building a binary tree](build_binary_tree_problem.assets/built_tree_step1.png){ class="animation-figure" }
@@ -287,12 +287,12 @@ Figure 12-8 shows the recursive process of building the binary tree, where each
<p align="center"> Figure 12-8 &nbsp; Recursive process of building a binary tree </p>
Each recursive function's division results of `preorder` and `inorder` are shown in Figure 12-9.
Each recursive function's division of the `preorder` and `inorder` sequences is illustrated in Figure 12-9.
![Division results in each recursive function](build_binary_tree_problem.assets/built_tree_overall.png){ class="animation-figure" }
![Division in each recursive function](build_binary_tree_problem.assets/built_tree_overall.png){ class="animation-figure" }
<p align="center"> Figure 12-9 &nbsp; Division results in each recursive function </p>
<p align="center"> Figure 12-9 &nbsp; Division in each recursive function </p>
Assuming the number of nodes in the tree is $n$, initializing each node (executing a recursive function `dfs()`) takes $O(1)$ time. **Thus, the overall time complexity is $O(n)$**.
Assuming the binary tree has $n$ nodes, initializing each node (calling the recursive function `dfs()`) takes $O(1)$ time. **Therefore, the overall time complexity is $O(n)$**.
The hash table stores the mapping of `inorder` elements to their indexes, with a space complexity of $O(n)$. In the worst case, when the binary tree degenerates into a linked list, the recursive depth reaches $n$, using $O(n)$ stack frame space. **Therefore, the overall space complexity is $O(n)$**.
Because the hash table stores the mapping from `inorder` elements to their indexes, it requires $O(n)$ space. In the worst case, if the binary tree degenerates into a linked list, the recursive depth can reach $n$, consuming $O(n)$ stack space. **Hence, the overall space complexity is $O(n)$**.
@@ -4,10 +4,10 @@ comments: true
# 12.1 &nbsp; Divide and conquer algorithms
<u>Divide and conquer</u>, fully referred to as "divide and rule", is an extremely important and common algorithm strategy. Divide and conquer is usually based on recursion and includes two steps: "divide" and "conquer".
<u>Divide and conquer</u> is an important and popular algorithm strategy. As the name suggests, the algorithm is typically implemented recursively and consists of two steps: "divide" and "conquer".
1. **Divide (partition phase)**: Recursively decompose the original problem into two or more sub-problems until the smallest sub-problem is reached and the process terminates.
2. **Conquer (merge phase)**: Starting from the smallest sub-problem with a known solution, merge the solutions of the sub-problems from bottom to top to construct the solution to the original problem.
1. **Divide (partition phase)**: Recursively break down the original problem into two or more smaller sub-problems until the smallest sub-problem is reached.
2. **Conquer (merge phase)**: Starting from the smallest sub-problem with known solution, we construct the solution to the original problem by merging the solutions of sub-problems in a bottom-up manner.
As shown in Figure 12-1, "merge sort" is one of the typical applications of the divide and conquer strategy.
@@ -20,27 +20,27 @@ As shown in Figure 12-1, "merge sort" is one of the typical applications of the
## 12.1.1 &nbsp; How to identify divide and conquer problems
Whether a problem is suitable for a divide and conquer solution can usually be judged based on the following criteria.
Whether a problem is suitable for a divide-and-conquer solution can usually be decided based on the following criteria.
1. **The problem can be decomposed**: The original problem can be decomposed into smaller, similar sub-problems and can be recursively divided in the same manner.
1. **The problem can be broken down into smaller ones**: The original problem can be divided into smaller, similar sub-problems and such process can be recursively done in the same manner.
2. **Sub-problems are independent**: There is no overlap between sub-problems, and they are independent and can be solved separately.
3. **Solutions to sub-problems can be merged**: The solution to the original problem is obtained by merging the solutions of the sub-problems.
3. **Solutions to sub-problems can be merged**: The solution to the original problem is derived by combining the solutions of the sub-problems.
Clearly, merge sort meets these three criteria.
1. **The problem can be decomposed**: Recursively divide the array (original problem) into two sub-arrays (sub-problems).
1. **The problem can be broken down into smaller ones**: Recursively divide the array (original problem) into two sub-arrays (sub-problems).
2. **Sub-problems are independent**: Each sub-array can be sorted independently (sub-problems can be solved independently).
3. **Solutions to sub-problems can be merged**: Two ordered sub-arrays (solutions to the sub-problems) can be merged into one ordered array (solution to the original problem).
## 12.1.2 &nbsp; Improving efficiency through divide and conquer
## 12.1.2 &nbsp; Improve efficiency through divide and conquer
**Divide and conquer can not only effectively solve algorithm problems but often also improve algorithm efficiency**. In sorting algorithms, quicksort, merge sort, and heap sort are faster than selection, bubble, and insertion sorts because they apply the divide and conquer strategy.
The **divide-and-conquer strategy not only effectively solves algorithm problems but also often enhances efficiency**. In sorting algorithms, quick sort, merge sort, and heap sort are faster than selection sort, bubble sort, and insertion sort because they apply the divide-and-conquer strategy.
Then, we may ask: **Why can divide and conquer improve algorithm efficiency, and what is the underlying logic?** In other words, why are the steps of decomposing a large problem into multiple sub-problems, solving the sub-problems, and merging the solutions of the sub-problems into the solution of the original problem more efficient than directly solving the original problem? This question can be discussed from the aspects of the number of operations and parallel computation.
We may have a question in mind: **Why can divide and conquer improve algorithm efficiency, and what is the underlying logic?** In other words, why is breaking a problem into sub-problems, solving them, and combining their solutions to address the original problem offer more efficiency than directly solving the original problem? This question can be analyzed from two aspects: operation count and parallel computation.
### 1. &nbsp; Optimization of operation count
Taking "bubble sort" as an example, it requires $O(n^2)$ time to process an array of length $n$. Suppose we divide the array from the midpoint into two sub-arrays as shown in Figure 12-2, then the division requires $O(n)$ time, sorting each sub-array requires $O((n / 2)^2)$ time, and merging the two sub-arrays requires $O(n)$ time, with the total time complexity being:
Taking "bubble sort" as an example, it requires $O(n^2)$ time to process an array of length $n$. Suppose we divide the array from the midpoint into two sub-arrays as shown in Figure 12-2, such division requires $O(n)$ time. Sorting each sub-array requires $O((n / 2)^2)$ time. And merging the two sub-arrays requires $O(n)$ time. Thus, the overall time complexity is:
$$
O(n + (\frac{n}{2})^2 \times 2 + n) = O(\frac{n^2}{2} + 2n)
@@ -50,7 +50,7 @@ $$
<p align="center"> Figure 12-2 &nbsp; Bubble sort before and after array partition </p>
Next, we calculate the following inequality, where the left and right sides are the total number of operations before and after the partition, respectively:
Let's calculate the following inequality, where the left side represents the total number of operations before division and the right side represents the total number of operations after division, respectively:
$$
\begin{aligned}
@@ -60,19 +60,19 @@ n(n - 4) & > 0
\end{aligned}
$$
**This means that when $n > 4$, the number of operations after partitioning is fewer, and the sorting efficiency should be higher**. Please note that the time complexity after partitioning is still quadratic $O(n^2)$, but the constant factor in the complexity has decreased.
**This means that when $n > 4$, the number of operations after partitioning is fewer, leading to better performance**. Please note that the time complexity after partitioning is still quadratic $O(n^2)$, but the constant factor in the complexity has decreased.
Further, **what if we keep dividing the sub-arrays from their midpoints into two sub-arrays** until the sub-arrays have only one element left? This idea is actually "merge sort," with a time complexity of $O(n \log n)$.
We can go even further. **How about keeping dividing the sub-arrays from their midpoints into two sub-arrays** until the sub-arrays have only one element left? This idea is actually "merge sort," with a time complexity of $O(n \log n)$.
Furthermore, **what if we set several more partition points** and evenly divide the original array into $k$ sub-arrays? This situation is very similar to "bucket sort," which is very suitable for sorting massive data, and theoretically, the time complexity can reach $O(n + k)$.
Let's try something a bit different again. **How about splitting into more partitions instead of just two?** For example, we evenly divide the original array into $k$ sub-arrays? This approach is very similar to "bucket sort," which is very suitable for sorting massive data. Theoretically, the time complexity can reach $O(n + k)$.
### 2. &nbsp; Optimization through parallel computation
We know that the sub-problems generated by divide and conquer are independent of each other, **thus they can usually be solved in parallel**. This means that divide and conquer can not only reduce the algorithm's time complexity, **but also facilitate parallel optimization by the operating system**.
We know that the sub-problems generated by divide and conquer are independent of each other, **which means that they can be solved in parallel.** As a result, divide and conquer not only reduces the algorithm's time complexity, **but also facilitates parallel optimization by modern operating systems.**
Parallel optimization is especially effective in environments with multiple cores or processors, as the system can process multiple sub-problems simultaneously, making fuller use of computing resources and significantly reducing the overall runtime.
Parallel optimization is particularly effective in environments with multiple cores or processors. As the system can process multiple sub-problems simultaneously, fully utilizing computing resources, the overall runtime is significantly reduced.
For example, in the "bucket sort" shown in Figure 12-3, we distribute massive data evenly across various buckets, then the sorting tasks of all buckets can be distributed to different computing units, and the results are merged after completion.
For example, in the "bucket sort" shown in Figure 12-3, we break massive data evenly into various buckets. The jobs of sorting each bucket can be allocated to available computing units. Once all jobs are done, all sorted buckets are merged to produce the final result.
![Bucket sort's parallel computation](divide_and_conquer.assets/divide_and_conquer_parallel_computing.png){ class="animation-figure" }
@@ -80,22 +80,22 @@ For example, in the "bucket sort" shown in Figure 12-3, we distribute massive da
## 12.1.3 &nbsp; Common applications of divide and conquer
On one hand, divide and conquer can be used to solve many classic algorithm problems.
Divide and conquer can be used to solve many classic algorithm problems.
- **Finding the closest point pair**: This algorithm first divides the set of points into two parts, then finds the closest point pair in each part, and finally finds the closest point pair that spans the two parts.
- **Large integer multiplication**: For example, the Karatsuba algorithm, which breaks down large integer multiplication into several smaller integer multiplications and additions.
- **Matrix multiplication**: For example, the Strassen algorithm, which decomposes large matrix multiplication into multiple small matrix multiplications and additions.
- **Tower of Hanoi problem**: The Tower of Hanoi problem can be solved recursively, a typical application of the divide and conquer strategy.
- **Solving inverse pairs**: In a sequence, if a number in front is greater than a number behind, these two numbers form an inverse pair. Solving the inverse pair problem can utilize the idea of divide and conquer, with the aid of merge sort.
- **Finding the closest pair of points**: This algorithm works by dividing the set of points into two halves. Then it recursively finds the closest pair in each half. Finally it considers pairs that span the two halves to find the overall closest pair.
- **Large integer multiplication**: One algorithm is called Karatsuba. It breaks down large integer multiplication into several smaller integer multiplications and additions.
- **Matrix multiplication**: One example is the Strassen algorithm. It breaks down a large matrix multiplication into multiple small matrix multiplications and additions.
- **Tower of Hanoi problem**: The Tower of Hanoi problem can be solved recursively, a typical application of the divide-and-conquer strategy.
- **Solving inversion pairs**: In a sequence, if a preceding number is greater than a following number, then these two numbers constitute an inversion pair. Solving inversion pair problem can utilize the idea of divide and conquer, with the aid of merge sort.
On the other hand, divide and conquer is very widely applied in the design of algorithms and data structures.
Divide and conquer is also widely applied in the design of algorithms and data structures.
- **Binary search**: Binary search divides an ordered array from the midpoint index into two parts, then decides which half to exclude based on the comparison result between the target value and the middle element value, and performs the same binary operation in the remaining interval.
- **Binary search**: Binary search divides a sorted array into two halves from the midpoint index. And then based on the comparison result between the target value and the middle element value, one half is discarded. The search continues on the remaining half with the same process until the target is found or there is no remaining element.
- **Merge sort**: Already introduced at the beginning of this section, no further elaboration is needed.
- **Quicksort**: Quicksort selects a pivot value, then divides the array into two sub-arrays, one with elements smaller than the pivot and the other with elements larger than the pivot, and then performs the same partitioning operation on these two parts until the sub-array has only one element.
- **Bucket sort**: The basic idea of bucket sort is to distribute data to multiple buckets, then sort the elements within each bucket, and finally retrieve the elements from the buckets in order to obtain an ordered array.
- **Trees**: For example, binary search trees, AVL trees, red-black trees, B-trees, B+ trees, etc., their operations such as search, insertion, and deletion can all be considered applications of the divide and conquer strategy.
- **Heap**: A heap is a special type of complete binary tree, whose various operations, such as insertion, deletion, and heapification, actually imply the idea of divide and conquer.
- **Hash table**: Although hash tables do not directly apply divide and conquer, some hash collision resolution solutions indirectly apply the divide and conquer strategy, for example, long lists in chained addressing being converted to red-black trees to improve query efficiency.
- **Quicksort**: Quicksort picks a pivot value to divide the array into two sub-arrays, one with elements smaller than the pivot and the other with elements larger than the pivot. Such process goes on against each of these two sub-arrays until they hold only one element.
- **Bucket sort**: The basic idea of bucket sort is to distribute data to multiple buckets. After sorting the elements within each bucket, retrieve the elements from the buckets in order to obtain an ordered array.
- **Trees**: For example, binary search trees, AVL trees, red-black trees, B-trees, and B+ trees, etc. Their operations, such as search, insertion, and deletion, can all be regarded as applications of the divide-and-conquer strategy.
- **Heap**: A heap is a special type of complete binary tree. Its various operations, such as insertion, deletion, and heapify, actually imply the idea of divide and conquer.
- **Hash table**: Although hash tables do not directly apply divide and conquer, some hash collision resolution solutions indirectly apply the strategy. For example, long lists in chained addressing may be converted to red-black trees to improve query efficiency.
It can be seen that **divide and conquer is a subtly pervasive algorithmic idea**, embedded within various algorithms and data structures.
@@ -4,13 +4,13 @@ comments: true
# 12.4 &nbsp; Tower of Hanoi Problem
In both merge sorting and building binary trees, we decompose the original problem into two subproblems, each half the size of the original problem. However, for the Tower of Hanoi, we adopt a different decomposition strategy.
In both merge sort and binary tree construction, we break the original problem into two subproblems, each half the size of the original problem. However, for the Tower of Hanoi, we adopt a different decomposition strategy.
!!! question
Given three pillars, denoted as `A`, `B`, and `C`. Initially, pillar `A` is stacked with $n$ discs, arranged in order from top to bottom from smallest to largest. Our task is to move these $n$ discs to pillar `C`, maintaining their original order (as shown in Figure 12-10). The following rules must be followed during the disc movement process:
We are given three pillars, denoted as `A`, `B`, and `C`. Initially, pillar `A` has $n$ discs, arranged from top to bottom in ascending size. Our task is to move these $n$ discs to pillar `C`, maintaining their original order (as shown in Figure 12-10). The following rules apply during the movement:
1. A disc can only be picked up from the top of a pillar and placed on top of another pillar.
1. A disc can be removed only from the top of a pillar and must be placed on the top of another pillar.
2. Only one disc can be moved at a time.
3. A smaller disc must always be on top of a larger disc.
@@ -18,11 +18,11 @@ In both merge sorting and building binary trees, we decompose the original probl
<p align="center"> Figure 12-10 &nbsp; Example of the Tower of Hanoi </p>
**We denote the Tower of Hanoi of size $i$ as $f(i)$**. For example, $f(3)$ represents the Tower of Hanoi of moving $3$ discs from `A` to `C`.
**We denote the Tower of Hanoi problem of size $i$ as $f(i)$**. For example, $f(3)$ represents moving $3$ discs from pillar `A` to pillar `C`.
### 1. &nbsp; Consider the base case
### 1. &nbsp; Consider the base cases
As shown in Figure 12-11, for the problem $f(1)$, i.e., when there is only one disc, we can directly move it from `A` to `C`.
As shown in Figure 12-11, for the problem $f(1)$—which has only one discwe can directly move it from `A` to `C`.
=== "<1>"
![Solution for a problem of size 1](hanota_problem.assets/hanota_f1_step1.png){ class="animation-figure" }
@@ -32,7 +32,7 @@ As shown in Figure 12-11, for the problem $f(1)$, i.e., when there is only one d
<p align="center"> Figure 12-11 &nbsp; Solution for a problem of size 1 </p>
As shown in Figure 12-12, for the problem $f(2)$, i.e., when there are two discs, **since the smaller disc must always be above the larger disc, `B` is needed to assist in the movement**.
For $f(2)$—which has two discs—**we rely on pillar `B` to help keep the smaller disc above the larger disc**, as illustrated in the following figure:
1. First, move the smaller disc from `A` to `B`.
2. Then move the larger disc from `A` to `C`.
@@ -52,17 +52,17 @@ As shown in Figure 12-12, for the problem $f(2)$, i.e., when there are two discs
<p align="center"> Figure 12-12 &nbsp; Solution for a problem of size 2 </p>
The process of solving the problem $f(2)$ can be summarized as: **moving two discs from `A` to `C` with the help of `B`**. Here, `C` is called the target pillar, and `B` is called the buffer pillar.
The process of solving $f(2)$ can be summarized as: **moving two discs from `A` to `C` with the help of `B`**. Here, `C` is called the target pillar, and `B` is called the buffer pillar.
### 2. &nbsp; Decomposition of subproblems
For the problem $f(3)$, i.e., when there are three discs, the situation becomes slightly more complicated.
For the problem $f(3)$—that is, when there are three discsthe situation becomes slightly more complicated.
Since we already know the solutions to $f(1)$ and $f(2)$, we can think from a divide-and-conquer perspective and **consider the two top discs on `A` as a unit**, performing the steps shown in Figure 12-13. This way, the three discs are successfully moved from `A` to `C`.
Since we already know the solutions to $f(1)$ and $f(2)$, we can adopt a divide-and-conquer perspective and **treat the top two discs on `A` as a single unit**, performing the steps shown in Figure 12-13. This allows the three discs to be successfully moved from `A` to `C`.
1. Let `B` be the target pillar and `C` the buffer pillar, and move the two discs from `A` to `B`.
1. Let `B` be the target pillar and `C` the buffer pillar, then move the two discs from `A` to `B`.
2. Move the remaining disc from `A` directly to `C`.
3. Let `C` be the target pillar and `A` the buffer pillar, and move the two discs from `B` to `C`.
3. Let `C` be the target pillar and `A` the buffer pillar, then move the two discs from `B` to `C`.
=== "<1>"
![Solution for a problem of size 3](hanota_problem.assets/hanota_f3_step1.png){ class="animation-figure" }
@@ -78,23 +78,23 @@ Since we already know the solutions to $f(1)$ and $f(2)$, we can think from a di
<p align="center"> Figure 12-13 &nbsp; Solution for a problem of size 3 </p>
Essentially, **we divide the problem $f(3)$ into two subproblems $f(2)$ and one subproblem $f(1)$**. By solving these three subproblems in order, the original problem is resolved. This indicates that the subproblems are independent, and their solutions can be merged.
Essentially, **we decompose $f(3)$ into two $f(2)$ subproblems and one $f(1)$ subproblem**. By solving these three subproblems in sequence, the original problem is solved, indicating that the subproblems are independent and their solutions can be merged.
From this, we can summarize the divide-and-conquer strategy for solving the Tower of Hanoi shown in Figure 12-14: divide the original problem $f(n)$ into two subproblems $f(n-1)$ and one subproblem $f(1)$, and solve these three subproblems in the following order.
From this, we can summarize the divide-and-conquer strategy for the Tower of Hanoi, illustrated in Figure 12-14. We divide the original problem $f(n)$ into two subproblems $f(n-1)$ and one subproblem $f(1)$, and solve these three subproblems in the following order:
1. Move $n-1$ discs with the help of `C` from `A` to `B`.
2. Move the remaining one disc directly from `A` to `C`.
3. Move $n-1$ discs with the help of `A` from `B` to `C`.
1. Move $n-1$ discs from `A` to `B`, using `C` as a buffer.
2. Move the remaining disc directly from `A` to `C`.
3. Move $n-1$ discs from `B` to `C`, using `A` as a buffer.
For these two subproblems $f(n-1)$, **they can be recursively divided in the same manner** until the smallest subproblem $f(1)$ is reached. The solution to $f(1)$ is already known and requires only one move.
For each $f(n-1)$ subproblem, **we can apply the same recursive partition** until we reach the smallest subproblem $f(1)$. Because $f(1)$ is already known to require just a single move, it is trivial to solve.
![Divide and conquer strategy for solving the Tower of Hanoi](hanota_problem.assets/hanota_divide_and_conquer.png){ class="animation-figure" }
![Divide-and-conquer strategy for solving the Tower of Hanoi](hanota_problem.assets/hanota_divide_and_conquer.png){ class="animation-figure" }
<p align="center"> Figure 12-14 &nbsp; Divide and conquer strategy for solving the Tower of Hanoi </p>
<p align="center"> Figure 12-14 &nbsp; Divide-and-conquer strategy for solving the Tower of Hanoi </p>
### 3. &nbsp; Code implementation
In the code, we declare a recursive function `dfs(i, src, buf, tar)` whose role is to move the $i$ discs on top of pillar `src` with the help of buffer pillar `buf` to the target pillar `tar`:
In the code, we define a recursive function `dfs(i, src, buf, tar)` which moves the top $i$ discs from pillar `src` to pillar `tar`, using pillar `buf` as a buffer:
=== "Python"
@@ -305,7 +305,7 @@ In the code, we declare a recursive function `dfs(i, src, buf, tar)` whose role
[class]{}-[func]{solveHanota}
```
As shown in Figure 12-15, the Tower of Hanoi forms a recursive tree with a height of $n$, each node representing a subproblem, corresponding to an open `dfs()` function, **thus the time complexity is $O(2^n)$, and the space complexity is $O(n)$**.
As shown in Figure 12-15, the Tower of Hanoi problem can be visualized as a recursive tree of height $n$. Each node represents a subproblem, corresponding to a call to `dfs()`, **Hence, the time complexity is $O(2^n)$, and the space complexity is $O(n)$.**
![Recursive tree of the Tower of Hanoi](hanota_problem.assets/hanota_recursive_tree.png){ class="animation-figure" }
@@ -313,6 +313,6 @@ As shown in Figure 12-15, the Tower of Hanoi forms a recursive tree with a heigh
!!! quote
The Tower of Hanoi originates from an ancient legend. In a temple in ancient India, monks had three tall diamond pillars and $64$ differently sized golden discs. The monks continuously moved the discs, believing that when the last disc is correctly placed, the world would end.
The Tower of Hanoi originates from an ancient legend. In a temple in ancient India, monks had three tall diamond pillars and $64$ differently sized golden discs. They believed that when the last disc was correctly placed, the world would end.
However, even if the monks moved a disc every second, it would take about $2^{64} \approx 1.84×10^{19}$ seconds, approximately 585 billion years, far exceeding current estimates of the age of the universe. Thus, if the legend is true, we probably do not need to worry about the world ending.
However, even if the monks moved one disc every second, it would take about $2^{64} \approx 1.84×10^{19}$ approximately 585 billion yearsfar exceeding current estimates of the age of the universe. Thus, if the legend is true, we probably do not need to worry about the world ending.
+2 -2
View File
@@ -9,9 +9,9 @@ icon: material/set-split
!!! abstract
Difficult problems are decomposed layer by layer, each decomposition making them simpler.
Difficult problems are decomposed layer by layer, with each decomposition making them simpler.
Divide and conquer reveals an important truth: start with simplicity, and nothing is complex anymore.
Divide and conquer unveils a profound truth: begin with simplicity, and complexity dissolves.
## Chapter contents
@@ -4,12 +4,12 @@ comments: true
# 12.5 &nbsp; Summary
- Divide and conquer is a common algorithm design strategy, which includes dividing (partitioning) and conquering (merging) two stages, usually implemented based on recursion.
- The basis for judging whether it is a divide and conquer algorithm problem includes: whether the problem can be decomposed, whether the subproblems are independent, and whether the subproblems can be merged.
- Merge sort is a typical application of the divide and conquer strategy, which recursively divides the array into two equal-length subarrays until only one element remains, and then starts merging layer by layer to complete the sorting.
- Introducing the divide and conquer strategy can often improve algorithm efficiency. On one hand, the divide and conquer strategy reduces the number of operations; on the other hand, it is conducive to parallel optimization of the system after division.
- Divide and conquer can solve many algorithm problems and is widely used in data structure and algorithm design, where its presence is ubiquitous.
- Compared to brute force search, adaptive search is more efficient. Search algorithms with a time complexity of $O(\log n)$ are usually based on the divide and conquer strategy.
- Binary search is another typical application of the divide and conquer strategy, which does not include the step of merging the solutions of subproblems. We can implement binary search through recursive divide and conquer.
- In the problem of constructing binary trees, building the tree (original problem) can be divided into building the left and right subtree (subproblems), which can be achieved by partitioning the index intervals of the pre-order and in-order traversals.
- In the Tower of Hanoi problem, a problem of size $n$ can be divided into two subproblems of size $n-1$ and one subproblem of size $1$. By solving these three subproblems in sequence, the original problem is consequently resolved.
- Divide and conquer is a common algorithm design strategy that consists of two stagesdivide (partition) and conquer (merge)—and is generally implemented using recursion.
- To determine whether a problem is suited for a divide and conquer approach, we check if the problem can be decomposed, whether the subproblems are independent, and whether the subproblems can be merged.
- Merge sort is a typical example of the divide and conquer strategy. It recursively splits an array into two equal-length subarrays until only one element remains, and then merges these subarrays layer by layer to complete the sorting.
- Introducing the divide and conquer strategy often improves algorithm efficiency. On one hand, it reduces the number of operations; on the other hand, it facilitates parallel optimization of the system after division.
- Divide and conquer can be applied to numerous algorithmic problems and is widely used in data structures and algorithm design, appearing in many scenarios.
- Compared to brute force search, adaptive search is more efficient. Search algorithms with a time complexity of $O(\log n)$ are typically based on the divide and conquer strategy.
- Binary search is another classic application of the divide-and-conquer strategy. It does not involve merging subproblem solutions and can be implemented via a recursive divide-and-conquer approach.
- In the problem of constructing binary trees, building the tree (the original problem) can be divided into building the left subtree and right subtree (the subproblems). This can be achieved by partitioning the index ranges of the preorder and inorder traversals.
- In the Tower of Hanoi problem, a problem of size $n$ can be broken down into two subproblems of size $n-1$ and one subproblem of size $1$. By solving these three subproblems in sequence, the original problem is resolved.