mirror of
https://github.com/krahets/hello-algo.git
synced 2024-12-27 13:26:29 +08:00
build
This commit is contained in:
parent
867ecf6d92
commit
3694f7ea24
10 changed files with 163 additions and 181 deletions
|
@ -162,7 +162,7 @@ comments: true
|
||||||
|
|
||||||
!!! question "尾节点指向什么?"
|
!!! question "尾节点指向什么?"
|
||||||
|
|
||||||
我们将链表的最后一个节点称为「尾节点」,其指向的是“空”,在 Java, C++, Python 中分别记为 `null`, `nullptr`, `None` 。在不引起歧义的前提下,本书都使用 `null` 来表示空。
|
我们将链表的最后一个节点称为「尾节点」,其指向的是“空”,在 Java, C++, Python 中分别记为 $\text{null}$ , $\text{nullptr}$ , $\text{None}$ 。在不引起歧义的前提下,本书都使用 $\text{null}$ 来表示空。
|
||||||
|
|
||||||
!!! question "如何称呼链表?"
|
!!! question "如何称呼链表?"
|
||||||
|
|
||||||
|
@ -901,7 +901,7 @@ comments: true
|
||||||
|
|
||||||
## 4.2.4. 常见链表类型
|
## 4.2.4. 常见链表类型
|
||||||
|
|
||||||
**单向链表**。即上述介绍的普通链表。单向链表的节点包含值和指向下一节点的指针(引用)两项数据。我们将首个节点称为头节点,将最后一个节点成为尾节点,尾节点指向 `null` 。
|
**单向链表**。即上述介绍的普通链表。单向链表的节点包含值和指向下一节点的指针(引用)两项数据。我们将首个节点称为头节点,将最后一个节点成为尾节点,尾节点指向 $\text{null}$ 。
|
||||||
|
|
||||||
**环形链表**。如果我们令单向链表的尾节点指向头节点(即首尾相接),则得到一个环形链表。在环形链表中,任意节点都可以视作头节点。
|
**环形链表**。如果我们令单向链表的尾节点指向头节点(即首尾相接),则得到一个环形链表。在环形链表中,任意节点都可以视作头节点。
|
||||||
|
|
||||||
|
|
|
@ -4,7 +4,7 @@ comments: true
|
||||||
|
|
||||||
# 5.3. 双向队列
|
# 5.3. 双向队列
|
||||||
|
|
||||||
对于队列,我们只能在头部删除或在尾部添加元素,而「双向队列 Deque」更加灵活,在其头部和尾部都能执行元素添加或删除操作。
|
对于队列,我们仅能在头部删除或在尾部添加元素。然而,「双向队列 Deque」提供了更高的灵活性,允许在头部和尾部执行元素的添加或删除操作。
|
||||||
|
|
||||||
![双向队列的操作](deque.assets/deque_operations.png)
|
![双向队列的操作](deque.assets/deque_operations.png)
|
||||||
|
|
||||||
|
@ -12,7 +12,7 @@ comments: true
|
||||||
|
|
||||||
## 5.3.1. 双向队列常用操作
|
## 5.3.1. 双向队列常用操作
|
||||||
|
|
||||||
双向队列的常用操作见下表,方法名需根据语言来确定。
|
双向队列的常用操作如下表所示,具体的方法名称需要根据所使用的编程语言来确定。
|
||||||
|
|
||||||
<div class="center-table" markdown>
|
<div class="center-table" markdown>
|
||||||
|
|
||||||
|
@ -27,7 +27,7 @@ comments: true
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
相同地,我们可以直接使用编程语言实现好的双向队列类。
|
同样地,我们可以直接使用编程语言中已实现的双向队列类。
|
||||||
|
|
||||||
=== "Java"
|
=== "Java"
|
||||||
|
|
||||||
|
@ -291,15 +291,15 @@ comments: true
|
||||||
|
|
||||||
## 5.3.2. 双向队列实现 *
|
## 5.3.2. 双向队列实现 *
|
||||||
|
|
||||||
与队列类似,双向队列同样可以使用链表或数组来实现。
|
双向队列的实现与队列类似,可以选择链表或数组作为底层数据结构。
|
||||||
|
|
||||||
### 基于双向链表的实现
|
### 基于双向链表的实现
|
||||||
|
|
||||||
回忆上节内容,由于可以方便地删除链表头节点(对应出队操作),以及在链表尾节点后添加新节点(对应入队操作),因此我们使用普通单向链表来实现队列。
|
回顾上一节内容,我们使用普通单向链表来实现队列,因为它可以方便地删除头节点(对应出队操作)和在尾节点后添加新节点(对应入队操作)。
|
||||||
|
|
||||||
而双向队列的头部和尾部都可以执行入队与出队操作,换言之,双向队列的操作是“首尾对称”的,也需要实现另一个对称方向的操作。因此,双向队列需要使用「双向链表」来实现。
|
对于双向队列而言,头部和尾部都可以执行入队和出队操作。换句话说,双向队列需要实现另一个对称方向的操作。为此,我们采用「双向链表」作为双向队列的底层数据结构。
|
||||||
|
|
||||||
我们将双向链表的头节点和尾节点分别看作双向队列的队首和队尾,并且实现在两端都能添加与删除节点。
|
我们将双向链表的头节点和尾节点视为双向队列的队首和队尾,同时实现在两端添加和删除节点的功能。
|
||||||
|
|
||||||
=== "LinkedListDeque"
|
=== "LinkedListDeque"
|
||||||
![基于链表实现双向队列的入队出队操作](deque.assets/linkedlist_deque.png)
|
![基于链表实现双向队列的入队出队操作](deque.assets/linkedlist_deque.png)
|
||||||
|
@ -1342,7 +1342,7 @@ comments: true
|
||||||
|
|
||||||
### 基于数组的实现
|
### 基于数组的实现
|
||||||
|
|
||||||
与基于数组实现队列类似,我们也可以使用环形数组来实现双向队列。在实现队列的基础上,增加实现“队首入队”和“队尾出队”方法即可。
|
与基于数组实现队列类似,我们也可以使用环形数组来实现双向队列。在队列的实现基础上,仅需增加“队首入队”和“队尾出队”的方法。
|
||||||
|
|
||||||
=== "ArrayDeque"
|
=== "ArrayDeque"
|
||||||
![基于数组实现双向队列的入队出队操作](deque.assets/array_deque.png)
|
![基于数组实现双向队列的入队出队操作](deque.assets/array_deque.png)
|
||||||
|
@ -2134,6 +2134,6 @@ comments: true
|
||||||
|
|
||||||
## 5.3.3. 双向队列应用
|
## 5.3.3. 双向队列应用
|
||||||
|
|
||||||
双向队列同时表现出栈与队列的逻辑,**因此可以实现两者的所有应用,并且提供更高的自由度**。
|
双向队列兼具栈与队列的逻辑,**因此它可以实现这两者的所有应用场景,同时提供更高的自由度**。
|
||||||
|
|
||||||
我们知道,软件的“撤销”功能需要使用栈来实现;系统把每一次更改操作 `push` 到栈中,然后通过 `pop` 实现撤销。然而,考虑到系统资源有限,软件一般会限制撤销的步数(例如仅允许保存 $50$ 步),那么当栈的长度 $> 50$ 时,软件就需要在栈底(即队首)执行删除,**但栈无法实现,此时就需要使用双向队列来替代栈**。注意,“撤销”的核心逻辑仍然是栈的先入后出,只是双向队列可以更加灵活地实现。
|
我们知道,软件的“撤销”功能通常使用栈来实现:系统将每次更改操作 `push` 到栈中,然后通过 `pop` 实现撤销。然而,考虑到系统资源的限制,软件通常会限制撤销的步数(例如仅允许保存 $50$ 步)。当栈的长度超过 $50$ 时,软件需要在栈底(即队首)执行删除操作。**但栈无法实现该功能,此时就需要使用双向队列来替代栈**。请注意,“撤销”的核心逻辑仍然遵循栈的先入后出原则,只是双向队列能够更加灵活地实现一些额外逻辑。
|
||||||
|
|
|
@ -4,9 +4,9 @@ comments: true
|
||||||
|
|
||||||
# 5.2. 队列
|
# 5.2. 队列
|
||||||
|
|
||||||
「队列 Queue」是一种遵循先入先出(first in, first out)数据操作规则的线性数据结构。顾名思义,队列模拟的是排队现象,即外面的人不断加入队列尾部,而处于队列头部的人不断地离开。
|
「队列 Queue」是一种遵循先入先出(First In, First Out)规则的线性数据结构。顾名思义,队列模拟了排队现象,即新来的人不断加入队列的尾部,而位于队列头部的人逐个离开。
|
||||||
|
|
||||||
我们将队列头部称为「队首」,队列尾部称为「队尾」,将把元素加入队尾的操作称为「入队」,删除队首元素的操作称为「出队」。
|
我们把队列的头部称为「队首」,尾部称为「队尾」,把将元素加入队尾的操作称为「入队」,删除队首元素的操作称为「出队」。
|
||||||
|
|
||||||
![队列的先入先出规则](queue.assets/queue_operations.png)
|
![队列的先入先出规则](queue.assets/queue_operations.png)
|
||||||
|
|
||||||
|
@ -14,7 +14,7 @@ comments: true
|
||||||
|
|
||||||
## 5.2.1. 队列常用操作
|
## 5.2.1. 队列常用操作
|
||||||
|
|
||||||
队列的常用操作见下表。需要注意,不同编程语言的方法名是不同的,在这里我们采用与栈相同的方法命名。
|
队列的常见操作如下表所示。需要注意的是,不同编程语言的方法名称可能会有所不同。我们在此采用与栈相同的方法命名。
|
||||||
|
|
||||||
<div class="center-table" markdown>
|
<div class="center-table" markdown>
|
||||||
|
|
||||||
|
@ -26,7 +26,7 @@ comments: true
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
我们可以直接使用编程语言实现好的队列类。
|
我们可以直接使用编程语言中现成的队列类。
|
||||||
|
|
||||||
=== "Java"
|
=== "Java"
|
||||||
|
|
||||||
|
@ -260,11 +260,11 @@ comments: true
|
||||||
|
|
||||||
## 5.2.2. 队列实现
|
## 5.2.2. 队列实现
|
||||||
|
|
||||||
队列需要一种可以在一端添加,并在另一端删除的数据结构,也可以使用链表或数组来实现。
|
为了实现队列,我们需要一种数据结构,可以在一端添加元素,并在另一端删除元素。因此,链表和数组都可以用来实现队列。
|
||||||
|
|
||||||
### 基于链表的实现
|
### 基于链表的实现
|
||||||
|
|
||||||
我们将链表的「头节点」和「尾节点」分别看作是队首和队尾,并规定队尾只可添加节点,队首只可删除节点。
|
对于链表实现,我们可以将链表的「头节点」和「尾节点」分别视为队首和队尾,规定队尾仅可添加节点,而队首仅可删除节点。
|
||||||
|
|
||||||
=== "LinkedListQueue"
|
=== "LinkedListQueue"
|
||||||
![基于链表实现队列的入队出队操作](queue.assets/linkedlist_queue.png)
|
![基于链表实现队列的入队出队操作](queue.assets/linkedlist_queue.png)
|
||||||
|
@ -275,7 +275,7 @@ comments: true
|
||||||
=== "pop()"
|
=== "pop()"
|
||||||
![linkedlist_queue_pop](queue.assets/linkedlist_queue_pop.png)
|
![linkedlist_queue_pop](queue.assets/linkedlist_queue_pop.png)
|
||||||
|
|
||||||
以下是使用链表实现队列的示例代码。
|
以下是用链表实现队列的示例代码。
|
||||||
|
|
||||||
=== "Java"
|
=== "Java"
|
||||||
|
|
||||||
|
@ -926,16 +926,16 @@ comments: true
|
||||||
|
|
||||||
### 基于数组的实现
|
### 基于数组的实现
|
||||||
|
|
||||||
数组的删除首元素的时间复杂度为 $O(n)$ ,这会导致出队操作效率低下。然而,我们可以采取下述的巧妙方法来避免这个问题。
|
由于数组删除首元素的时间复杂度为 $O(n)$ ,这会导致出队操作效率较低。然而,我们可以采用以下巧妙方法来避免这个问题。
|
||||||
|
|
||||||
考虑借助一个变量 `front` 来指向队首元素的索引,并维护变量 `queSize` 来记录队列长度。我们定义 `rear = front + queSize` ,该公式计算出来的 `rear` 指向“队尾元素索引 $+1$ ”的位置。
|
我们可以使用一个变量 `front` 指向队首元素的索引,并维护一个变量 `queSize` 用于记录队列长度。定义 `rear = front + queSize` ,这个公式计算出的 `rear` 指向队尾元素之后的下一个位置。
|
||||||
|
|
||||||
在该设计下,**数组中包含元素的有效区间为 `[front, rear - 1]`** ,进而
|
基于此设计,**数组中包含元素的有效区间为 [front, rear - 1]**,进而:
|
||||||
|
|
||||||
- 对于入队操作,将输入元素赋值给 `rear` 索引处,并将 `queSize` 自增 $1$ 即可;
|
- 对于入队操作,将输入元素赋值给 `rear` 索引处,并将 `queSize` 增加 1 ;
|
||||||
- 对于出队操作,仅需将 `front` 自增 $1$ ,并将 `queSize` 自减 $1$ 即可;
|
- 对于出队操作,只需将 `front` 增加 1 ,并将 `queSize` 减少 1 ;
|
||||||
|
|
||||||
观察发现,入队与出队操作都仅需单次操作即可完成,时间复杂度皆为 $O(1)$ 。
|
可以看到,入队和出队操作都只需进行一次操作,时间复杂度均为 $O(1)$ 。
|
||||||
|
|
||||||
=== "ArrayQueue"
|
=== "ArrayQueue"
|
||||||
![基于数组实现队列的入队出队操作](queue.assets/array_queue.png)
|
![基于数组实现队列的入队出队操作](queue.assets/array_queue.png)
|
||||||
|
@ -946,9 +946,9 @@ comments: true
|
||||||
=== "pop()"
|
=== "pop()"
|
||||||
![array_queue_pop](queue.assets/array_queue_pop.png)
|
![array_queue_pop](queue.assets/array_queue_pop.png)
|
||||||
|
|
||||||
细心的同学可能会发现一个问题:在不断入队与出队的过程中,`front` 和 `rear` 都在向右移动,**在到达数组尾部后就无法继续移动了**。为解决此问题,**我们考虑将数组看作是首尾相接的**,这样的数组被称为「环形数组」。
|
你可能会发现一个问题:在不断进行入队和出队的过程中,`front` 和 `rear` 都在向右移动,**当它们到达数组尾部时就无法继续移动了**。为解决此问题,我们可以将数组视为首尾相接的「环形数组」。
|
||||||
|
|
||||||
对于环形数组,我们需要令 `front` 或 `rear` 在越过数组尾部后,直接绕回到数组头部接续遍历。这种周期性规律可以通过「取余操作」来实现,详情请见以下代码。
|
对于环形数组,我们需要让 `front` 或 `rear` 在越过数组尾部时,直接回到数组头部继续遍历。这种周期性规律可以通过“取余操作”来实现,代码如下所示。
|
||||||
|
|
||||||
=== "Java"
|
=== "Java"
|
||||||
|
|
||||||
|
@ -1624,13 +1624,11 @@ comments: true
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
以上实现的队列仍存在局限性,即长度不可变。不过这个问题很容易解决,我们可以将数组替换为列表(即动态数组),从而引入扩容机制。有兴趣的同学可以尝试自行实现。
|
以上实现的队列仍然具有局限性,即其长度不可变。然而,这个问题不难解决,我们可以将数组替换为动态数组,从而引入扩容机制。有兴趣的同学可以尝试自行实现。
|
||||||
|
|
||||||
## 5.2.3. 两种实现对比
|
两种实现的对比结论与栈一致,在此不再赘述。
|
||||||
|
|
||||||
与栈的结论一致,在此不再赘述。
|
## 5.2.3. 队列典型应用
|
||||||
|
|
||||||
## 5.2.4. 队列典型应用
|
- **淘宝订单**。购物者下单后,订单将加入队列中,系统随后会根据顺序依次处理队列中的订单。在双十一期间,短时间内会产生海量订单,高并发成为工程师们需要重点攻克的问题。
|
||||||
|
- **各类待办事项**。任何需要实现“先来后到”功能的场景,例如打印机的任务队列、餐厅的出餐队列等。队列在这些场景中可以有效地维护处理顺序。
|
||||||
- **淘宝订单**。购物者下单后,订单就被加入到队列之中,随后系统再根据顺序依次处理队列中的订单。在双十一时,在短时间内会产生海量的订单,如何处理「高并发」则是工程师们需要重点思考的问题。
|
|
||||||
- **各种待办事项**。任何需要实现“先来后到”的功能,例如打印机的任务队列、餐厅的出餐队列等等。
|
|
||||||
|
|
|
@ -4,11 +4,11 @@ comments: true
|
||||||
|
|
||||||
# 5.1. 栈
|
# 5.1. 栈
|
||||||
|
|
||||||
「栈 Stack」是一种遵循先入后出(first in, last out)数据操作规则的线性数据结构。我们可以将栈类比为放在桌面上的一摞盘子,如果需要拿出底部的盘子,则需要先将上面的盘子依次取出。
|
「栈 Stack」是一种遵循先入后出(First In, Last Out)原则的线性数据结构。
|
||||||
|
|
||||||
“盘子”是一种形象比喻,我们将盘子替换为任意一种元素(例如整数、字符、对象等),就得到了栈数据结构。
|
我们可以将栈类比为桌面上的一摞盘子,如果需要拿出底部的盘子,则需要先将上面的盘子依次取出。我们将盘子替换为各种类型的元素(如整数、字符、对象等),就得到了栈数据结构。
|
||||||
|
|
||||||
我们将这一摞元素的顶部称为「栈顶」,将底部称为「栈底」,将把元素添加到栈顶的操作称为「入栈」,将删除栈顶元素的操作称为「出栈」。
|
在栈中,我们把堆叠元素的顶部称为「栈顶」,底部称为「栈底」。将把元素添加到栈顶的操作叫做「入栈」,而删除栈顶元素的操作叫做「出栈」。
|
||||||
|
|
||||||
![栈的先入后出规则](stack.assets/stack_operations.png)
|
![栈的先入后出规则](stack.assets/stack_operations.png)
|
||||||
|
|
||||||
|
@ -16,7 +16,7 @@ comments: true
|
||||||
|
|
||||||
## 5.1.1. 栈常用操作
|
## 5.1.1. 栈常用操作
|
||||||
|
|
||||||
栈的常用操作见下表,方法名需根据编程语言来确定,此处我们以常见的 `push` , `pop` , `peek` 为例。
|
栈的常用操作如下表所示,具体的方法名需要根据所使用的编程语言来确定。在此,我们以常见的 `push()` , `pop()` , `peek()` 命名为例。
|
||||||
|
|
||||||
<div class="center-table" markdown>
|
<div class="center-table" markdown>
|
||||||
|
|
||||||
|
@ -28,7 +28,7 @@ comments: true
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
我们可以直接使用编程语言实现好的栈类。 某些语言并未专门提供栈类,但我们可以直接把该语言的「数组」或「链表」看作栈来使用,并通过“脑补”来屏蔽无关操作。
|
通常情况下,我们可以直接使用编程语言内置的栈类。然而,某些语言可能没有专门提供栈类,这时我们可以将该语言的「数组」或「链表」视作栈来使用,并通过“脑补”来忽略与栈无关的操作。
|
||||||
|
|
||||||
=== "Java"
|
=== "Java"
|
||||||
|
|
||||||
|
@ -258,15 +258,15 @@ comments: true
|
||||||
|
|
||||||
## 5.1.2. 栈的实现
|
## 5.1.2. 栈的实现
|
||||||
|
|
||||||
为了更加清晰地了解栈的运行机制,接下来我们来自己动手实现一个栈类。
|
为了深入了解栈的运行机制,我们来尝试自己实现一个栈类。
|
||||||
|
|
||||||
栈规定元素是先入后出的,因此我们只能在栈顶添加或删除元素。然而,数组或链表都可以在任意位置添加删除元素,因此 **栈可被看作是一种受约束的数组或链表**。换言之,我们可以“屏蔽”数组或链表的部分无关操作,使之对外的表现逻辑符合栈的规定即可。
|
栈遵循先入后出的原则,因此我们只能在栈顶添加或删除元素。然而,数组和链表都可以在任意位置添加和删除元素,**因此栈可以被视为一种受限制的数组或链表**。换句话说,我们可以“屏蔽”数组或链表的部分无关操作,使其对外表现的逻辑符合栈的特性。
|
||||||
|
|
||||||
### 基于链表的实现
|
### 基于链表的实现
|
||||||
|
|
||||||
使用「链表」实现栈时,将链表的头节点看作栈顶,将尾节点看作栈底。
|
使用链表来实现栈时,我们可以将链表的头节点视为栈顶,尾节点视为栈底。
|
||||||
|
|
||||||
对于入栈操作,将元素插入到链表头部即可,这种节点添加方式被称为“头插法”。而对于出栈操作,则将头节点从链表中删除即可。
|
对于入栈操作,我们只需将元素插入链表头部,这种节点插入方法被称为“头插法”。而对于出栈操作,只需将头节点从链表中删除即可。
|
||||||
|
|
||||||
=== "LinkedListStack"
|
=== "LinkedListStack"
|
||||||
![基于链表实现栈的入栈出栈操作](stack.assets/linkedlist_stack.png)
|
![基于链表实现栈的入栈出栈操作](stack.assets/linkedlist_stack.png)
|
||||||
|
@ -845,7 +845,7 @@ comments: true
|
||||||
|
|
||||||
### 基于数组的实现
|
### 基于数组的实现
|
||||||
|
|
||||||
使用「数组」实现栈时,考虑将数组的尾部当作栈顶。这样设计下,「入栈」与「出栈」操作就对应在数组尾部「添加元素」与「删除元素」,时间复杂度都为 $O(1)$ 。
|
在基于「数组」实现栈时,我们可以将数组的尾部作为栈顶。在这样的设计下,入栈与出栈操作就分别对应在数组尾部添加元素与删除元素,时间复杂度都为 $O(1)$ 。
|
||||||
|
|
||||||
=== "ArrayStack"
|
=== "ArrayStack"
|
||||||
![基于数组实现栈的入栈出栈操作](stack.assets/array_stack.png)
|
![基于数组实现栈的入栈出栈操作](stack.assets/array_stack.png)
|
||||||
|
@ -856,7 +856,7 @@ comments: true
|
||||||
=== "pop()"
|
=== "pop()"
|
||||||
![array_stack_pop](stack.assets/array_stack_pop.png)
|
![array_stack_pop](stack.assets/array_stack_pop.png)
|
||||||
|
|
||||||
由于入栈的元素可能是源源不断的,因此可以使用支持动态扩容的「列表」,这样就无需自行实现数组扩容了。以下是示例代码。
|
由于入栈的元素可能会源源不断地增加,因此我们可以使用动态数组,这样就无需自行处理数组扩容问题。以下为示例代码。
|
||||||
|
|
||||||
=== "Java"
|
=== "Java"
|
||||||
|
|
||||||
|
@ -1308,28 +1308,28 @@ comments: true
|
||||||
|
|
||||||
### 支持操作
|
### 支持操作
|
||||||
|
|
||||||
两种实现都支持栈定义中的各项操作,数组实现额外支持随机访问,但这已经超出栈的定义范畴,一般不会用到。
|
两种实现都支持栈定义中的各项操作。数组实现额外支持随机访问,但这已超出了栈的定义范畴,因此一般不会用到。
|
||||||
|
|
||||||
### 时间效率
|
### 时间效率
|
||||||
|
|
||||||
在数组(列表)实现中,入栈与出栈操作都是在预先分配好的连续内存中操作,具有很好的缓存本地性,效率很好。然而,如果入栈时超出数组容量,则会触发扩容机制,那么该次入栈操作的时间复杂度为 $O(n)$ 。
|
在基于数组的实现中,入栈和出栈操作都是在预先分配好的连续内存中进行,具有很好的缓存本地性,因此效率较高。然而,如果入栈时超出数组容量,会触发扩容机制,导致该次入栈操作的时间复杂度变为 $O(n)$ 。
|
||||||
|
|
||||||
在链表实现中,链表的扩容非常灵活,不存在上述数组扩容时变慢的问题。然而,入栈操作需要初始化节点对象并修改指针,因而效率不如数组。进一步地思考,如果入栈元素不是 `int` 而是节点对象,那么就可以省去初始化步骤,从而提升效率。
|
在链表实现中,链表的扩容非常灵活,不存在上述数组扩容时效率降低的问题。但是,入栈操作需要初始化节点对象并修改指针,因此效率相对较低。不过,如果入栈元素本身就是节点对象,那么可以省去初始化步骤,从而提高效率。
|
||||||
|
|
||||||
综上所述,当入栈与出栈操作的元素是基本数据类型(例如 `int` , `double` )时,则结论如下:
|
综上所述,当入栈与出栈操作的元素是基本数据类型(如 `int` , `double` )时,我们可以得出以下结论:
|
||||||
|
|
||||||
- 数组实现的栈在触发扩容时会变慢,但由于扩容是低频操作,因此 **总体效率更高**;
|
- 基于数组实现的栈在触发扩容时效率会降低,但由于扩容是低频操作,因此平均效率更高;
|
||||||
- 链表实现的栈可以提供 **更加稳定的效率表现**;
|
- 基于链表实现的栈可以提供更加稳定的效率表现;
|
||||||
|
|
||||||
### 空间效率
|
### 空间效率
|
||||||
|
|
||||||
在初始化列表时,系统会给列表分配“初始容量”,该容量可能超过我们的需求。并且扩容机制一般是按照特定倍率(比如 2 倍)进行扩容,扩容后的容量也可能超出我们的需求。因此,**数组实现栈会造成一定的空间浪费**。
|
在初始化列表时,系统会为列表分配“初始容量”,该容量可能超过实际需求。并且,扩容机制通常是按照特定倍率(例如 2 倍)进行扩容,扩容后的容量也可能超出实际需求。因此,**基于数组实现的栈可能造成一定的空间浪费**。
|
||||||
|
|
||||||
当然,由于节点需要额外存储指针,因此 **链表节点比数组元素占用更大**。
|
然而,由于链表节点需要额外存储指针,**因此链表节点占用的空间相对较大**。
|
||||||
|
|
||||||
综上,我们不能简单地确定哪种实现更加省内存,需要 case-by-case 地分析。
|
综上,我们不能简单地确定哪种实现更加节省内存,需要针对具体情况进行分析。
|
||||||
|
|
||||||
## 5.1.4. 栈典型应用
|
## 5.1.4. 栈典型应用
|
||||||
|
|
||||||
- **浏览器中的后退与前进、软件中的撤销与反撤销**。每当我们打开新的网页,浏览器就将上一个网页执行入栈,这样我们就可以通过「后退」操作来回到上一页面,后退操作实际上是在执行出栈。如果要同时支持后退和前进,那么则需要两个栈来配合实现。
|
- **浏览器中的后退与前进、软件中的撤销与反撤销**。每当我们打开新的网页,浏览器就会将上一个网页执行入栈,这样我们就可以通过「后退」操作回到上一页面。后退操作实际上是在执行出栈。如果要同时支持后退和前进,那么需要两个栈来配合实现。
|
||||||
- **程序内存管理**。每当调用函数时,系统就会在栈顶添加一个栈帧,用来记录函数的上下文信息。在递归函数中,向下递推会不断执行入栈,向上回溯阶段时出栈。
|
- **程序内存管理**。每次调用函数时,系统都会在栈顶添加一个栈帧,用于记录函数的上下文信息。在递归函数中,向下递推阶段会不断执行入栈操作,而向上回溯阶段则会执行出栈操作。
|
||||||
|
|
|
@ -4,8 +4,8 @@ comments: true
|
||||||
|
|
||||||
# 5.4. 小结
|
# 5.4. 小结
|
||||||
|
|
||||||
- 栈是一种遵循先入后出的数据结构,可以使用数组或链表实现。
|
- 栈是一种遵循先入后出原则的数据结构,可通过数组或链表来实现。
|
||||||
- 在时间效率方面,栈的数组实现具有更好的平均效率,但扩容时会导致单次入栈操作的时间复杂度劣化至 $O(n)$ 。相对地,栈的链表实现具有更加稳定的效率表现。
|
- 从时间效率角度看,栈的数组实现具有较高的平均效率,但在扩容过程中,单次入栈操作的时间复杂度会降低至 $O(n)$ 。相比之下,基于链表实现的栈具有更为稳定的效率表现。
|
||||||
- 在空间效率方面,栈的数组实现会造成一定空间浪费,然而链表节点比数组元素占用内存更大。
|
- 在空间效率方面,栈的数组实现可能导致一定程度的空间浪费。但需要注意的是,链表节点所占用的内存空间比数组元素更大。
|
||||||
- 队列是一种遵循先入先出的数据结构,可以使用数组或链表实现。对于两种实现的时间效率与空间效率对比,与上述栈的结论相同。
|
- 队列是一种遵循先入先出原则的数据结构,同样可以通过数组或链表来实现。在时间效率和空间效率的对比上,队列的结论与前述栈的结论相似。
|
||||||
- 双向队列的两端都可以添加与删除元素。
|
- 双向队列是一种具有更高自由度的队列,它允许在两端进行元素的添加和删除操作。
|
||||||
|
|
|
@ -4,31 +4,29 @@ comments: true
|
||||||
|
|
||||||
# 7.4. AVL 树 *
|
# 7.4. AVL 树 *
|
||||||
|
|
||||||
在「二叉搜索树」章节中提到,在进行多次插入与删除操作后,二叉搜索树可能会退化为链表。此时所有操作的时间复杂度都会由 $O(\log n)$ 劣化至 $O(n)$ 。
|
在二叉搜索树章节中,我们提到了在多次插入和删除操作后,二叉搜索树可能退化为链表。这种情况下,所有操作的时间复杂度将从 $O(\log n)$ 恶化为 $O(n)$。
|
||||||
|
|
||||||
如下图所示,执行两步删除节点后,该二叉搜索树就会退化为链表。
|
如下图所示,经过两次删除节点操作,这个二叉搜索树便会退化为链表。
|
||||||
|
|
||||||
![AVL 树在删除节点后发生退化](avl_tree.assets/avltree_degradation_from_removing_node.png)
|
![AVL 树在删除节点后发生退化](avl_tree.assets/avltree_degradation_from_removing_node.png)
|
||||||
|
|
||||||
<p align="center"> Fig. AVL 树在删除节点后发生退化 </p>
|
<p align="center"> Fig. AVL 树在删除节点后发生退化 </p>
|
||||||
|
|
||||||
再比如,在以下完美二叉树中插入两个节点后,树严重向左偏斜,查找操作的时间复杂度也随之发生劣化。
|
再例如,在以下完美二叉树中插入两个节点后,树将严重向左倾斜,查找操作的时间复杂度也随之恶化。
|
||||||
|
|
||||||
![AVL 树在插入节点后发生退化](avl_tree.assets/avltree_degradation_from_inserting_node.png)
|
![AVL 树在插入节点后发生退化](avl_tree.assets/avltree_degradation_from_inserting_node.png)
|
||||||
|
|
||||||
<p align="center"> Fig. AVL 树在插入节点后发生退化 </p>
|
<p align="center"> Fig. AVL 树在插入节点后发生退化 </p>
|
||||||
|
|
||||||
G. M. Adelson-Velsky 和 E. M. Landis 在其 1962 年发表的论文 "An algorithm for the organization of information" 中提出了「AVL 树」。**论文中描述了一系列操作,使得在不断添加与删除节点后,AVL 树仍然不会发生退化**,进而使得各种操作的时间复杂度均能保持在 $O(\log n)$ 级别。
|
G. M. Adelson-Velsky 和 E. M. Landis 在其 1962 年发表的论文 "An algorithm for the organization of information" 中提出了「AVL 树」。论文中详细描述了一系列操作,确保在持续添加和删除节点后,AVL 树不会退化,从而使得各种操作的时间复杂度保持在 $O(\log n)$ 级别。换句话说,在需要频繁进行增删查改操作的场景中,AVL 树能始终保持高效的数据操作性能,具有很好的应用价值。
|
||||||
|
|
||||||
换言之,在频繁增删查改的使用场景中,AVL 树可始终保持很高的数据增删查改效率,具有很好的应用价值。
|
|
||||||
|
|
||||||
## 7.4.1. AVL 树常见术语
|
## 7.4.1. AVL 树常见术语
|
||||||
|
|
||||||
「AVL 树」既是「二叉搜索树」又是「平衡二叉树」,同时满足这两种二叉树的所有性质,因此又被称为「平衡二叉搜索树」。
|
「AVL 树」既是二叉搜索树也是平衡二叉树,同时满足这两类二叉树的所有性质,因此也被称为「平衡二叉搜索树」。
|
||||||
|
|
||||||
### 节点高度
|
### 节点高度
|
||||||
|
|
||||||
在 AVL 树的操作中,需要获取节点「高度 Height」,所以给 AVL 树的节点类添加 `height` 变量。
|
在操作 AVL 树时,我们需要获取节点的高度,因此需要为 AVL 树的节点类添加 `height` 变量。
|
||||||
|
|
||||||
=== "Java"
|
=== "Java"
|
||||||
|
|
||||||
|
@ -157,7 +155,7 @@ G. M. Adelson-Velsky 和 E. M. Landis 在其 1962 年发表的论文 "An algorit
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
「节点高度」是最远叶节点到该节点的距离,即走过的「边」的数量。需要特别注意,**叶节点的高度为 0 ,空节点的高度为 -1**。我们封装两个工具函数,分别用于获取与更新节点的高度。
|
「节点高度」是指从该节点到最远叶节点的距离,即所经过的“边”的数量。需要特别注意的是,叶节点的高度为 0 ,而空节点的高度为 -1 。我们将创建两个工具函数,分别用于获取和更新节点的高度。
|
||||||
|
|
||||||
=== "Java"
|
=== "Java"
|
||||||
|
|
||||||
|
@ -325,7 +323,7 @@ G. M. Adelson-Velsky 和 E. M. Landis 在其 1962 年发表的论文 "An algorit
|
||||||
|
|
||||||
### 节点平衡因子
|
### 节点平衡因子
|
||||||
|
|
||||||
节点的「平衡因子 Balance Factor」是 **节点的左子树高度减去右子树高度**,并定义空节点的平衡因子为 0 。同样地,我们将获取节点平衡因子封装成函数,以便后续使用。
|
节点的「平衡因子 Balance Factor」定义为节点左子树的高度减去右子树的高度,同时规定空节点的平衡因子为 0 。我们同样将获取节点平衡因子的功能封装成函数,方便后续使用。
|
||||||
|
|
||||||
=== "Java"
|
=== "Java"
|
||||||
|
|
||||||
|
@ -450,13 +448,13 @@ G. M. Adelson-Velsky 和 E. M. Landis 在其 1962 年发表的论文 "An algorit
|
||||||
|
|
||||||
## 7.4.2. AVL 树旋转
|
## 7.4.2. AVL 树旋转
|
||||||
|
|
||||||
AVL 树的独特之处在于「旋转 Rotation」的操作,其可 **在不影响二叉树中序遍历序列的前提下,使失衡节点重新恢复平衡**。换言之,旋转操作既可以使树保持为「二叉搜索树」,也可以使树重新恢复为「平衡二叉树」。
|
AVL 树的特点在于「旋转 Rotation」操作,它能够在不影响二叉树的中序遍历序列的前提下,使失衡节点重新恢复平衡。换句话说,**旋转操作既能保持树的「二叉搜索树」属性,也能使树重新变为「平衡二叉树」**。
|
||||||
|
|
||||||
我们将平衡因子的绝对值 $> 1$ 的节点称为「失衡节点」。根据节点的失衡情况,旋转操作分为 **右旋、左旋、先右旋后左旋、先左旋后右旋**,接下来我们来一起来看看它们是如何操作的。
|
我们将平衡因子绝对值 $> 1$ 的节点称为「失衡节点」。根据节点失衡情况的不同,旋转操作分为四种:右旋、左旋、先右旋后左旋、先左旋后右旋。下面我们将详细介绍这些旋转操作。
|
||||||
|
|
||||||
### Case 1 - 右旋
|
### 右旋
|
||||||
|
|
||||||
如下图所示(节点下方为「平衡因子」),从底至顶看,二叉树中首个失衡节点是 **节点 3**。我们聚焦在以该失衡节点为根节点的子树上,将该节点记为 `node` ,将其左子节点记为 `child` ,执行「右旋」操作。完成右旋后,该子树已经恢复平衡,并且仍然为二叉搜索树。
|
如下图所示,节点下方为平衡因子。从底至顶看,二叉树中首个失衡节点是“节点 3”。我们关注以该失衡节点为根节点的子树,将该节点记为 `node` ,其左子节点记为 `child` ,执行「右旋」操作。完成右旋后,子树已经恢复平衡,并且仍然保持二叉搜索树的特性。
|
||||||
|
|
||||||
=== "<1>"
|
=== "<1>"
|
||||||
![右旋操作步骤](avl_tree.assets/avltree_right_rotate_step1.png)
|
![右旋操作步骤](avl_tree.assets/avltree_right_rotate_step1.png)
|
||||||
|
@ -470,13 +468,13 @@ AVL 树的独特之处在于「旋转 Rotation」的操作,其可 **在不影
|
||||||
=== "<4>"
|
=== "<4>"
|
||||||
![avltree_right_rotate_step4](avl_tree.assets/avltree_right_rotate_step4.png)
|
![avltree_right_rotate_step4](avl_tree.assets/avltree_right_rotate_step4.png)
|
||||||
|
|
||||||
进而,如果节点 `child` 本身有右子节点(记为 `grandChild` ),则需要在「右旋」中添加一步:将 `grandChild` 作为 `node` 的左子节点。
|
此外,如果节点 `child` 本身有右子节点(记为 `grandChild` ),则需要在「右旋」中添加一步:将 `grandChild` 作为 `node` 的左子节点。
|
||||||
|
|
||||||
![有 grandChild 的右旋操作](avl_tree.assets/avltree_right_rotate_with_grandchild.png)
|
![有 grandChild 的右旋操作](avl_tree.assets/avltree_right_rotate_with_grandchild.png)
|
||||||
|
|
||||||
<p align="center"> Fig. 有 grandChild 的右旋操作 </p>
|
<p align="center"> Fig. 有 grandChild 的右旋操作 </p>
|
||||||
|
|
||||||
“向右旋转”是一种形象化的说法,实际需要通过修改节点指针实现,代码如下所示。
|
“向右旋转”是一种形象化的说法,实际上需要通过修改节点指针来实现,代码如下所示。
|
||||||
|
|
||||||
=== "Java"
|
=== "Java"
|
||||||
|
|
||||||
|
@ -646,9 +644,9 @@ AVL 树的独特之处在于「旋转 Rotation」的操作,其可 **在不影
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Case 2 - 左旋
|
### 左旋
|
||||||
|
|
||||||
类似地,如果将取上述失衡二叉树的“镜像”,那么则需要「左旋」操作。
|
相应的,如果考虑上述失衡二叉树的“镜像”,则需要执行「左旋」操作。
|
||||||
|
|
||||||
![左旋操作](avl_tree.assets/avltree_left_rotate.png)
|
![左旋操作](avl_tree.assets/avltree_left_rotate.png)
|
||||||
|
|
||||||
|
@ -660,7 +658,7 @@ AVL 树的独特之处在于「旋转 Rotation」的操作,其可 **在不影
|
||||||
|
|
||||||
<p align="center"> Fig. 有 grandChild 的左旋操作 </p>
|
<p align="center"> Fig. 有 grandChild 的左旋操作 </p>
|
||||||
|
|
||||||
观察发现,**「左旋」和「右旋」操作是镜像对称的,两者对应解决的两种失衡情况也是对称的**。根据对称性,我们可以很方便地从「右旋」推导出「左旋」。具体地,只需将「右旋」代码中的把所有的 `left` 替换为 `right` 、所有的 `right` 替换为 `left` ,即可得到「左旋」代码。
|
可以观察到,**右旋和左旋操作在逻辑上是镜像对称的,它们分别解决的两种失衡情况也是对称的**。基于对称性,我们可以轻松地从右旋的代码推导出左旋的代码。具体地,只需将「右旋」代码中的把所有的 `left` 替换为 `right` ,将所有的 `right` 替换为 `left` ,即可得到「左旋」代码。
|
||||||
|
|
||||||
=== "Java"
|
=== "Java"
|
||||||
|
|
||||||
|
@ -830,17 +828,17 @@ AVL 树的独特之处在于「旋转 Rotation」的操作,其可 **在不影
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Case 3 - 先左后右
|
### 先左旋后右旋
|
||||||
|
|
||||||
对于下图的失衡节点 3 ,**单一使用左旋或右旋都无法使子树恢复平衡**,此时需要「先左旋后右旋」,即先对 `child` 执行「左旋」,再对 `node` 执行「右旋」。
|
对于下图中的失衡节点 3,仅使用左旋或右旋都无法使子树恢复平衡。此时需要先左旋后右旋,即先对 `child` 执行「左旋」,再对 `node` 执行「右旋」。
|
||||||
|
|
||||||
![先左旋后右旋](avl_tree.assets/avltree_left_right_rotate.png)
|
![先左旋后右旋](avl_tree.assets/avltree_left_right_rotate.png)
|
||||||
|
|
||||||
<p align="center"> Fig. 先左旋后右旋 </p>
|
<p align="center"> Fig. 先左旋后右旋 </p>
|
||||||
|
|
||||||
### Case 4 - 先右后左
|
### 先右旋后左旋
|
||||||
|
|
||||||
同理,取以上失衡二叉树的镜像,则需要「先右旋后左旋」,即先对 `child` 执行「右旋」,然后对 `node` 执行「左旋」。
|
同理,对于上述失衡二叉树的镜像情况,需要先右旋后左旋,即先对 `child` 执行「右旋」,然后对 `node` 执行「左旋」。
|
||||||
|
|
||||||
![先右旋后左旋](avl_tree.assets/avltree_right_left_rotate.png)
|
![先右旋后左旋](avl_tree.assets/avltree_right_left_rotate.png)
|
||||||
|
|
||||||
|
@ -848,18 +846,18 @@ AVL 树的独特之处在于「旋转 Rotation」的操作,其可 **在不影
|
||||||
|
|
||||||
### 旋转的选择
|
### 旋转的选择
|
||||||
|
|
||||||
下图描述的四种失衡情况与上述 Cases 逐个对应,分别需采用 **右旋、左旋、先右后左、先左后右** 的旋转操作。
|
下图展示的四种失衡情况与上述案例逐个对应,分别需要采用右旋、左旋、先右后左、先左后右的旋转操作。
|
||||||
|
|
||||||
![AVL 树的四种旋转情况](avl_tree.assets/avltree_rotation_cases.png)
|
![AVL 树的四种旋转情况](avl_tree.assets/avltree_rotation_cases.png)
|
||||||
|
|
||||||
<p align="center"> Fig. AVL 树的四种旋转情况 </p>
|
<p align="center"> Fig. AVL 树的四种旋转情况 </p>
|
||||||
|
|
||||||
具体地,在代码中使用 **失衡节点的平衡因子、较高一侧子节点的平衡因子** 来确定失衡节点属于上图中的哪种情况。
|
在代码中,我们通过判断失衡节点的平衡因子以及较高一侧子节点的平衡因子的正负号,来确定失衡节点属于上图中的哪种情况。
|
||||||
|
|
||||||
<div class="center-table" markdown>
|
<div class="center-table" markdown>
|
||||||
|
|
||||||
| 失衡节点的平衡因子 | 子节点的平衡因子 | 应采用的旋转方法 |
|
| 失衡节点的平衡因子 | 子节点的平衡因子 | 应采用的旋转方法 |
|
||||||
| ------------------ | ---------------- | ---------------- |
|
| ---------------- | ---------------- | ---------------- |
|
||||||
| $>0$ (即左偏树) | $\geq 0$ | 右旋 |
|
| $>0$ (即左偏树) | $\geq 0$ | 右旋 |
|
||||||
| $>0$ (即左偏树) | $<0$ | 先左旋后右旋 |
|
| $>0$ (即左偏树) | $<0$ | 先左旋后右旋 |
|
||||||
| $<0$ (即右偏树) | $\leq 0$ | 左旋 |
|
| $<0$ (即右偏树) | $\leq 0$ | 左旋 |
|
||||||
|
@ -867,7 +865,7 @@ AVL 树的独特之处在于「旋转 Rotation」的操作,其可 **在不影
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
为方便使用,我们将旋转操作封装成一个函数。至此,**我们可以使用此函数来旋转各种失衡情况,使失衡节点重新恢复平衡**。
|
为了便于使用,我们将旋转操作封装成一个函数。**有了这个函数,我们就能对各种失衡情况进行旋转,使失衡节点重新恢复平衡**。
|
||||||
|
|
||||||
=== "Java"
|
=== "Java"
|
||||||
|
|
||||||
|
@ -1190,7 +1188,7 @@ AVL 树的独特之处在于「旋转 Rotation」的操作,其可 **在不影
|
||||||
|
|
||||||
### 插入节点
|
### 插入节点
|
||||||
|
|
||||||
「AVL 树」的节点插入操作与「二叉搜索树」主体类似。不同的是,在插入节点后,从该节点到根节点的路径上会出现一系列「失衡节点」。所以,**我们需要从该节点开始,从底至顶地执行旋转操作,使所有失衡节点恢复平衡**。
|
「AVL 树」的节点插入操作与「二叉搜索树」在主体上类似。唯一的区别在于,在 AVL 树中插入节点后,从该节点到根节点的路径上可能会出现一系列失衡节点。因此,**我们需要从这个节点开始,自底向上执行旋转操作,使所有失衡节点恢复平衡**。
|
||||||
|
|
||||||
=== "Java"
|
=== "Java"
|
||||||
|
|
||||||
|
@ -1461,7 +1459,7 @@ AVL 树的独特之处在于「旋转 Rotation」的操作,其可 **在不影
|
||||||
|
|
||||||
### 删除节点
|
### 删除节点
|
||||||
|
|
||||||
「AVL 树」删除节点操作与「二叉搜索树」删除节点操作总体相同。类似地,**在删除节点后,也需要从底至顶地执行旋转操作,使所有失衡节点恢复平衡**。
|
类似地,在二叉搜索树的删除节点方法的基础上,需要从底至顶地执行旋转操作,使所有失衡节点恢复平衡。
|
||||||
|
|
||||||
=== "Java"
|
=== "Java"
|
||||||
|
|
||||||
|
@ -1962,13 +1960,13 @@ AVL 树的独特之处在于「旋转 Rotation」的操作,其可 **在不影
|
||||||
|
|
||||||
### 查找节点
|
### 查找节点
|
||||||
|
|
||||||
「AVL 树」的节点查找操作与「二叉搜索树」一致,在此不再赘述。
|
AVL 树的节点查找操作与二叉搜索树一致,在此不再赘述。
|
||||||
|
|
||||||
## 7.4.4. AVL 树典型应用
|
## 7.4.4. AVL 树典型应用
|
||||||
|
|
||||||
- 组织存储大型数据,适用于高频查找、低频增删场景;
|
- 组织和存储大型数据,适用于高频查找、低频增删的场景;
|
||||||
- 用于建立数据库中的索引系统;
|
- 用于构建数据库中的索引系统;
|
||||||
|
|
||||||
!!! question "为什么红黑树比 AVL 树更受欢迎?"
|
!!! question "为什么红黑树比 AVL 树更受欢迎?"
|
||||||
|
|
||||||
红黑树的平衡条件相对宽松,因此在红黑树中插入与删除节点所需的旋转操作相对更少,节点增删操作相比 AVL 树的效率更高。
|
红黑树的平衡条件相对宽松,因此在红黑树中插入与删除节点所需的旋转操作相对较少,在节点增删操作上的平均效率高于 AVL 树。
|
||||||
|
|
|
@ -7,7 +7,7 @@ comments: true
|
||||||
「二叉搜索树 Binary Search Tree」满足以下条件:
|
「二叉搜索树 Binary Search Tree」满足以下条件:
|
||||||
|
|
||||||
1. 对于根节点,左子树中所有节点的值 $<$ 根节点的值 $<$ 右子树中所有节点的值;
|
1. 对于根节点,左子树中所有节点的值 $<$ 根节点的值 $<$ 右子树中所有节点的值;
|
||||||
2. 任意节点的左子树和右子树也是二叉搜索树,即也满足条件 `1.` ;
|
2. 任意节点的左、右子树也是二叉搜索树,即同样满足条件 `1.` ;
|
||||||
|
|
||||||
![二叉搜索树](binary_search_tree.assets/binary_search_tree.png)
|
![二叉搜索树](binary_search_tree.assets/binary_search_tree.png)
|
||||||
|
|
||||||
|
@ -21,10 +21,10 @@ comments: true
|
||||||
|
|
||||||
- 若 `cur.val < num` ,说明目标节点在 `cur` 的右子树中,因此执行 `cur = cur.right` ;
|
- 若 `cur.val < num` ,说明目标节点在 `cur` 的右子树中,因此执行 `cur = cur.right` ;
|
||||||
- 若 `cur.val > num` ,说明目标节点在 `cur` 的左子树中,因此执行 `cur = cur.left` ;
|
- 若 `cur.val > num` ,说明目标节点在 `cur` 的左子树中,因此执行 `cur = cur.left` ;
|
||||||
- 若 `cur.val = num` ,说明找到目标节点,跳出循环并返回该节点即可;
|
- 若 `cur.val = num` ,说明找到目标节点,跳出循环并返回该节点;
|
||||||
|
|
||||||
=== "<1>"
|
=== "<1>"
|
||||||
![查找节点步骤](binary_search_tree.assets/bst_search_step1.png)
|
![bst_search_step1](binary_search_tree.assets/bst_search_step1.png)
|
||||||
|
|
||||||
=== "<2>"
|
=== "<2>"
|
||||||
![bst_search_step2](binary_search_tree.assets/bst_search_step2.png)
|
![bst_search_step2](binary_search_tree.assets/bst_search_step2.png)
|
||||||
|
@ -35,7 +35,7 @@ comments: true
|
||||||
=== "<4>"
|
=== "<4>"
|
||||||
![bst_search_step4](binary_search_tree.assets/bst_search_step4.png)
|
![bst_search_step4](binary_search_tree.assets/bst_search_step4.png)
|
||||||
|
|
||||||
二叉搜索树的查找操作和二分查找算法如出一辙,也是在每轮排除一半情况。循环次数最多为二叉树的高度,当二叉树平衡时,使用 $O(\log n)$ 时间。
|
二叉搜索树的查找操作与二分查找算法的工作原理一致,都是每轮排除一半情况。循环次数最多为二叉树的高度,当二叉树平衡时,使用 $O(\log n)$ 时间。
|
||||||
|
|
||||||
=== "Java"
|
=== "Java"
|
||||||
|
|
||||||
|
@ -244,10 +244,10 @@ comments: true
|
||||||
|
|
||||||
给定一个待插入元素 `num` ,为了保持二叉搜索树“左子树 < 根节点 < 右子树”的性质,插入操作分为两步:
|
给定一个待插入元素 `num` ,为了保持二叉搜索树“左子树 < 根节点 < 右子树”的性质,插入操作分为两步:
|
||||||
|
|
||||||
1. **查找插入位置**:与查找操作类似,我们从根节点出发,根据当前节点值和 `num` 的大小关系循环向下搜索,直到越过叶节点(遍历到 $\text{null}$ )时跳出循环;
|
1. **查找插入位置**:与查找操作相似,从根节点出发,根据当前节点值和 `num` 的大小关系循环向下搜索,直到越过叶节点(遍历至 $\text{null}$ )时跳出循环;
|
||||||
2. **在该位置插入节点**:初始化节点 `num` ,将该节点放到 $\text{null}$ 的位置 ;
|
2. **在该位置插入节点**:初始化节点 `num` ,将该节点置于 $\text{null}$ 的位置;
|
||||||
|
|
||||||
二叉搜索树不允许存在重复节点,否则将会违背其定义。因此若待插入节点在树中已经存在,则不执行插入,直接返回即可。
|
二叉搜索树不允许存在重复节点,否则将违反其定义。因此,若待插入节点在树中已存在,则不执行插入,直接返回。
|
||||||
|
|
||||||
![在二叉搜索树中插入节点](binary_search_tree.assets/bst_insert.png)
|
![在二叉搜索树中插入节点](binary_search_tree.assets/bst_insert.png)
|
||||||
|
|
||||||
|
@ -542,34 +542,34 @@ comments: true
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
为了插入节点,需要借助 **辅助节点 `pre`** 保存上一轮循环的节点,这样在遍历到 $\text{null}$ 时,我们也可以获取到其父节点,从而完成节点插入操作。
|
为了插入节点,我们需要利用辅助节点 `pre` 保存上一轮循环的节点,这样在遍历至 $\text{null}$ 时,我们可以获取到其父节点,从而完成节点插入操作。
|
||||||
|
|
||||||
与查找节点相同,插入节点使用 $O(\log n)$ 时间。
|
与查找节点相同,插入节点使用 $O(\log n)$ 时间。
|
||||||
|
|
||||||
### 删除节点
|
### 删除节点
|
||||||
|
|
||||||
与插入节点一样,我们需要在删除操作后维持二叉搜索树的“左子树 < 根节点 < 右子树”的性质。首先,我们需要在二叉树中执行查找操作,获取待删除节点。接下来,根据待删除节点的子节点数量,删除操作需要分为三种情况:
|
与插入节点类似,我们需要在删除操作后维持二叉搜索树的“左子树 < 根节点 < 右子树”的性质。首先,我们需要在二叉树中执行查找操作,获取待删除节点。接下来,根据待删除节点的子节点数量,删除操作需分为三种情况:
|
||||||
|
|
||||||
**当待删除节点的子节点数量 $= 0$ 时**,表明待删除节点是叶节点,直接删除即可。
|
当待删除节点的子节点数量 $= 0$ 时,表示待删除节点是叶节点,可以直接删除。
|
||||||
|
|
||||||
![在二叉搜索树中删除节点(度为 0)](binary_search_tree.assets/bst_remove_case1.png)
|
![在二叉搜索树中删除节点(度为 0)](binary_search_tree.assets/bst_remove_case1.png)
|
||||||
|
|
||||||
<p align="center"> Fig. 在二叉搜索树中删除节点(度为 0) </p>
|
<p align="center"> Fig. 在二叉搜索树中删除节点(度为 0) </p>
|
||||||
|
|
||||||
**当待删除节点的子节点数量 $= 1$ 时**,将待删除节点替换为其子节点即可。
|
当待删除节点的子节点数量 $= 1$ 时,将待删除节点替换为其子节点即可。
|
||||||
|
|
||||||
![在二叉搜索树中删除节点(度为 1)](binary_search_tree.assets/bst_remove_case2.png)
|
![在二叉搜索树中删除节点(度为 1)](binary_search_tree.assets/bst_remove_case2.png)
|
||||||
|
|
||||||
<p align="center"> Fig. 在二叉搜索树中删除节点(度为 1) </p>
|
<p align="center"> Fig. 在二叉搜索树中删除节点(度为 1) </p>
|
||||||
|
|
||||||
**当待删除节点的子节点数量 $= 2$ 时**,删除操作分为三步:
|
当待删除节点的子节点数量 $= 2$ 时,删除操作分为三步:
|
||||||
|
|
||||||
1. 找到待删除节点在 **中序遍历序列** 中的下一个节点,记为 `nex` ;
|
1. 找到待删除节点在“中序遍历序列”中的下一个节点,记为 nex;
|
||||||
2. 在树中递归删除节点 `nex` ;
|
2. 在树中递归删除节点 `nex` ;
|
||||||
3. 使用 `nex` 替换待删除节点;
|
3. 使用 `nex` 替换待删除节点;
|
||||||
|
|
||||||
=== "<1>"
|
=== "<1>"
|
||||||
![删除节点(度为 2)步骤](binary_search_tree.assets/bst_remove_case3_step1.png)
|
![bst_remove_case3_step1](binary_search_tree.assets/bst_remove_case3_step1.png)
|
||||||
|
|
||||||
=== "<2>"
|
=== "<2>"
|
||||||
![bst_remove_case3_step2](binary_search_tree.assets/bst_remove_case3_step2.png)
|
![bst_remove_case3_step2](binary_search_tree.assets/bst_remove_case3_step2.png)
|
||||||
|
@ -580,7 +580,7 @@ comments: true
|
||||||
=== "<4>"
|
=== "<4>"
|
||||||
![bst_remove_case3_step4](binary_search_tree.assets/bst_remove_case3_step4.png)
|
![bst_remove_case3_step4](binary_search_tree.assets/bst_remove_case3_step4.png)
|
||||||
|
|
||||||
删除节点操作也使用 $O(\log n)$ 时间,其中查找待删除节点 $O(\log n)$ ,获取中序遍历后继节点 $O(\log n)$ 。
|
删除节点操作同样使用 $O(\log n)$ 时间,其中查找待删除节点需要 $O(\log n)$ 时间,获取中序遍历后继节点需要 $O(\log n)$ 时间。
|
||||||
|
|
||||||
=== "Java"
|
=== "Java"
|
||||||
|
|
||||||
|
@ -1137,9 +1137,9 @@ comments: true
|
||||||
|
|
||||||
### 排序
|
### 排序
|
||||||
|
|
||||||
我们知道,「中序遍历」遵循“左 $\rightarrow$ 根 $\rightarrow$ 右”的遍历优先级,而二叉搜索树遵循“左子节点 $<$ 根节点 $<$ 右子节点”的大小关系。因此,在二叉搜索树中进行中序遍历时,总是会优先遍历下一个最小节点,从而得出一条重要性质:**二叉搜索树的中序遍历序列是升序的**。
|
我们知道,二叉树的中序遍历遵循“左 $\rightarrow$ 根 $\rightarrow$ 右”的遍历顺序,而二叉搜索树满足“左子节点 $<$ 根节点 $<$ 右子节点”的大小关系。因此,在二叉搜索树中进行中序遍历时,总是会优先遍历下一个最小节点,从而得出一个重要性质:**二叉搜索树的中序遍历序列是升序的**。
|
||||||
|
|
||||||
借助中序遍历升序的性质,我们在二叉搜索树中获取有序数据仅需 $O(n)$ 时间,而无需额外排序,非常高效。
|
利用中序遍历升序的性质,我们在二叉搜索树中获取有序数据仅需 $O(n)$ 时间,无需额外排序,非常高效。
|
||||||
|
|
||||||
![二叉搜索树的中序遍历序列](binary_search_tree.assets/bst_inorder_traversal.png)
|
![二叉搜索树的中序遍历序列](binary_search_tree.assets/bst_inorder_traversal.png)
|
||||||
|
|
||||||
|
@ -1147,21 +1147,21 @@ comments: true
|
||||||
|
|
||||||
## 7.3.2. 二叉搜索树的效率
|
## 7.3.2. 二叉搜索树的效率
|
||||||
|
|
||||||
假设给定 $n$ 个数字,最常用的存储方式是「数组」,那么对于这串乱序的数字,常见操作的效率为:
|
假设给定 $n$ 个数字,最常见的存储方式是「数组」。对于这串乱序的数字,常见操作的效率如下:
|
||||||
|
|
||||||
- **查找元素**:由于数组是无序的,因此需要遍历数组来确定,使用 $O(n)$ 时间;
|
- **查找元素**:由于数组是无序的,因此需要遍历数组来确定,使用 $O(n)$ 时间;
|
||||||
- **插入元素**:只需将元素添加至数组尾部即可,使用 $O(1)$ 时间;
|
- **插入元素**:只需将元素添加至数组尾部即可,使用 $O(1)$ 时间;
|
||||||
- **删除元素**:先查找元素,使用 $O(n)$ 时间,再在数组中删除该元素,使用 $O(n)$ 时间;
|
- **删除元素**:先查找元素,使用 $O(n)$ 时间,再在数组中删除该元素,使用 $O(n)$ 时间;
|
||||||
- **获取最小 / 最大元素**:需要遍历数组来确定,使用 $O(n)$ 时间;
|
- **获取最小 / 最大元素**:需要遍历数组来确定,使用 $O(n)$ 时间;
|
||||||
|
|
||||||
为了得到先验信息,我们也可以预先将数组元素进行排序,得到一个「排序数组」,此时操作效率为:
|
为了获得先验信息,我们可以预先将数组元素进行排序,得到一个「排序数组」。此时操作效率如下:
|
||||||
|
|
||||||
- **查找元素**:由于数组已排序,可以使用二分查找,平均使用 $O(\log n)$ 时间;
|
- **查找元素**:由于数组已排序,可以使用二分查找,平均使用 $O(\log n)$ 时间;
|
||||||
- **插入元素**:先查找插入位置,使用 $O(\log n)$ 时间,再插入到指定位置,使用 $O(n)$ 时间;
|
- **插入元素**:先查找插入位置,使用 $O(\log n)$ 时间,再插入到指定位置,使用 $O(n)$ 时间;
|
||||||
- **删除元素**:先查找元素,使用 $O(\log n)$ 时间,再在数组中删除该元素,使用 $O(n)$ 时间;
|
- **删除元素**:先查找元素,使用 $O(\log n)$ 时间,再在数组中删除该元素,使用 $O(n)$ 时间;
|
||||||
- **获取最小 / 最大元素**:数组头部和尾部元素即是最小和最大元素,使用 $O(1)$ 时间;
|
- **获取最小 / 最大元素**:数组头部和尾部元素即是最小和最大元素,使用 $O(1)$ 时间;
|
||||||
|
|
||||||
观察发现,无序数组和有序数组中的各项操作的时间复杂度是“偏科”的,即有的快有的慢;**而二叉搜索树的各项操作的时间复杂度都是对数阶,在数据量 $n$ 很大时有巨大优势**。
|
观察可知,无序数组和有序数组中的各项操作的时间复杂度呈现“偏科”的特点,即有的快有的慢。**然而,二叉搜索树的各项操作的时间复杂度都是对数阶,在数据量 $n$ 较大时具有显著优势**。
|
||||||
|
|
||||||
<div class="center-table" markdown>
|
<div class="center-table" markdown>
|
||||||
|
|
||||||
|
@ -1176,13 +1176,9 @@ comments: true
|
||||||
|
|
||||||
## 7.3.3. 二叉搜索树的退化
|
## 7.3.3. 二叉搜索树的退化
|
||||||
|
|
||||||
理想情况下,我们希望二叉搜索树的是“左右平衡”的(详见「平衡二叉树」章节),此时可以在 $\log n$ 轮循环内查找任意节点。
|
在理想情况下,我们希望二叉搜索树是“平衡”的,这样就可以在 $\log n$ 轮循环内查找任意节点。
|
||||||
|
|
||||||
如果我们动态地在二叉搜索树中插入与删除节点,**则可能导致二叉树退化为链表**,此时各种操作的时间复杂度也退化之 $O(n)$ 。
|
然而,如果我们在二叉搜索树中不断地插入和删除节点,可能导致二叉树退化为链表,这时各种操作的时间复杂度也会退化为 $O(n)$ 。
|
||||||
|
|
||||||
!!! note
|
|
||||||
|
|
||||||
在实际应用中,如何保持二叉搜索树的平衡,也是一个需要重要考虑的问题。
|
|
||||||
|
|
||||||
![二叉搜索树的平衡与退化](binary_search_tree.assets/bst_degradation.png)
|
![二叉搜索树的平衡与退化](binary_search_tree.assets/bst_degradation.png)
|
||||||
|
|
||||||
|
@ -1190,6 +1186,6 @@ comments: true
|
||||||
|
|
||||||
## 7.3.4. 二叉搜索树常见应用
|
## 7.3.4. 二叉搜索树常见应用
|
||||||
|
|
||||||
- 系统中的多级索引,高效查找、插入、删除操作。
|
- 用作系统中的多级索引,实现高效的查找、插入、删除操作。
|
||||||
- 各种搜索算法的底层数据结构。
|
- 作为某些搜索算法的底层数据结构。
|
||||||
- 存储数据流,保持其已排序。
|
- 用于存储数据流,以保持其有序状态。
|
||||||
|
|
|
@ -4,7 +4,7 @@ comments: true
|
||||||
|
|
||||||
# 7.1. 二叉树
|
# 7.1. 二叉树
|
||||||
|
|
||||||
「二叉树 Binary Tree」是一种非线性数据结构,代表着祖先与后代之间的派生关系,体现着“一分为二”的分治逻辑。类似于链表,二叉树也是以节点为单位存储的,节点包含「值」和两个「指针」。
|
「二叉树 Binary Tree」是一种非线性数据结构,代表着祖先与后代之间的派生关系,体现着“一分为二”的分治逻辑。与链表类似,二叉树的基本单元是节点,每个节点包含一个「值」和两个「指针」。
|
||||||
|
|
||||||
=== "Java"
|
=== "Java"
|
||||||
|
|
||||||
|
@ -127,9 +127,9 @@ comments: true
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
节点的两个指针分别指向「左子节点」和「右子节点」,并且称该节点为两个子节点的「父节点」。给定二叉树某节点,将“左子节点及其以下节点形成的树”称为该节点的「左子树」,右子树同理。
|
节点的两个指针分别指向「左子节点」和「右子节点」,同时该节点被称为这两个子节点的「父节点」。当给定一个二叉树的节点时,我们将该节点的左子节点及其以下节点形成的树称为该节点的「左子树」,同理可得「右子树」。
|
||||||
|
|
||||||
除了叶节点外,每个节点都有子节点和子树。例如,若将下图的“节点 2”看作父节点,那么其左子节点和右子节点分别为“节点 4”和“节点 5”,左子树和右子树分别为“节点 4 及其以下节点形成的树”和“节点 5 及其以下节点形成的树”。
|
**在二叉树中,除叶节点外,其他所有节点都包含子节点和非空子树**。例如,在以下示例中,若将“节点 2”视为父节点,则其左子节点和右子节点分别是“节点 4”和“节点 5”,左子树是“节点 4 及其以下节点形成的树”,右子树是“节点 5 及其以下节点形成的树”。
|
||||||
|
|
||||||
![父节点、子节点、子树](binary_tree.assets/binary_tree_definition.png)
|
![父节点、子节点、子树](binary_tree.assets/binary_tree_definition.png)
|
||||||
|
|
||||||
|
@ -137,16 +137,16 @@ comments: true
|
||||||
|
|
||||||
## 7.1.1. 二叉树常见术语
|
## 7.1.1. 二叉树常见术语
|
||||||
|
|
||||||
二叉树的术语较多,建议尽量理解并记住。后续可能遗忘,可以在需要使用时回来查看确认。
|
二叉树涉及的术语较多,建议尽量理解并记住。
|
||||||
|
|
||||||
- 「根节点 Root Node」:二叉树最顶层的节点,其没有父节点;
|
- 「根节点 Root Node」:位于二叉树顶层的节点,没有父节点;
|
||||||
- 「叶节点 Leaf Node」:没有子节点的节点,其两个指针都指向 $\text{null}$ ;
|
- 「叶节点 Leaf Node」:没有子节点的节点,其两个指针均指向 $\text{null}$ ;
|
||||||
- 节点所处「层 Level」:从顶至底依次增加,根节点所处层为 1 ;
|
- 节点的「层 Level」:从顶至底递增,根节点所在层为 1 ;
|
||||||
- 节点「度 Degree」:节点的子节点数量。二叉树中,度的范围是 0, 1, 2 ;
|
- 节点的「度 Degree」:节点的子节点的数量。在二叉树中,度的范围是 0, 1, 2 ;
|
||||||
- 「边 Edge」:连接两个节点的边,即节点指针;
|
- 「边 Edge」:连接两个节点的线段,即节点指针;
|
||||||
- 二叉树「高度」:二叉树中根节点到最远叶节点走过边的数量;
|
- 二叉树的「高度」:从根节点到最远叶节点所经过的边的数量;
|
||||||
- 节点「深度 Depth」 :根节点到该节点走过边的数量;
|
- 节点的「深度 Depth」 :从根节点到该节点所经过的边的数量;
|
||||||
- 节点「高度 Height」:最远叶节点到该节点走过边的数量;
|
- 节点的「高度 Height」:从最远叶节点到该节点所经过的边的数量;
|
||||||
|
|
||||||
![二叉树的常用术语](binary_tree.assets/binary_tree_terminology.png)
|
![二叉树的常用术语](binary_tree.assets/binary_tree_terminology.png)
|
||||||
|
|
||||||
|
@ -154,11 +154,11 @@ comments: true
|
||||||
|
|
||||||
!!! tip "高度与深度的定义"
|
!!! tip "高度与深度的定义"
|
||||||
|
|
||||||
值得注意,我们通常将「高度」和「深度」定义为“走过边的数量”,而有些题目或教材会将其定义为“走过节点的数量”,此时高度或深度都需要 + 1 。
|
请注意,我们通常将「高度」和「深度」定义为“走过边的数量”,但有些题目或教材可能会将其定义为“走过节点的数量”。在这种情况下,高度和深度都需要加 1 。
|
||||||
|
|
||||||
## 7.1.2. 二叉树基本操作
|
## 7.1.2. 二叉树基本操作
|
||||||
|
|
||||||
**初始化二叉树**。与链表类似,先初始化节点,再构建引用指向(即指针)。
|
**初始化二叉树**。与链表类似,首先初始化节点,然后构建引用指向(即指针)。
|
||||||
|
|
||||||
=== "Java"
|
=== "Java"
|
||||||
|
|
||||||
|
@ -306,7 +306,7 @@ comments: true
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**插入与删除节点**。与链表类似,插入与删除节点都可以通过修改指针实现。
|
**插入与删除节点**。与链表类似,通过修改指针来实现插入与删除节点。
|
||||||
|
|
||||||
![在二叉树中插入与删除节点](binary_tree.assets/binary_tree_add_remove.png)
|
![在二叉树中插入与删除节点](binary_tree.assets/binary_tree_add_remove.png)
|
||||||
|
|
||||||
|
@ -420,17 +420,17 @@ comments: true
|
||||||
|
|
||||||
!!! note
|
!!! note
|
||||||
|
|
||||||
插入节点会改变二叉树的原有逻辑结构,删除节点往往意味着删除了该节点的所有子树。因此,二叉树中的插入与删除一般都是由一套操作配合完成的,这样才能实现有意义的操作。
|
需要注意的是,插入节点可能会改变二叉树的原有逻辑结构,而删除节点通常意味着删除该节点及其所有子树。因此,在二叉树中,插入与删除操作通常是由一套操作配合完成的,以实现有实际意义的操作。
|
||||||
|
|
||||||
## 7.1.3. 常见二叉树类型
|
## 7.1.3. 常见二叉树类型
|
||||||
|
|
||||||
### 完美二叉树
|
### 完美二叉树
|
||||||
|
|
||||||
「完美二叉树 Perfect Binary Tree」的所有层的节点都被完全填满。在完美二叉树中,叶节点的度为 $0$ ,其余所有节点的度都为 $2$ ;若树高度 $= h$ ,则节点总数 $= 2^{h+1} - 1$ ,呈标准的指数级关系,反映着自然界中常见的细胞分裂。
|
「完美二叉树 Perfect Binary Tree」除了最底层外,其余所有层的节点都被完全填满。在完美二叉树中,叶节点的度为 $0$ ,其余所有节点的度都为 $2$ ;若树高度为 $h$ ,则节点总数为 $2^{h+1} - 1$ ,呈现标准的指数级关系,反映了自然界中常见的细胞分裂现象。
|
||||||
|
|
||||||
!!! tip
|
!!! tip
|
||||||
|
|
||||||
在中文社区中,完美二叉树常被称为「满二叉树」,请注意与完满二叉树区分。
|
在中文社区中,完美二叉树常被称为「满二叉树」,请注意区分。
|
||||||
|
|
||||||
![完美二叉树](binary_tree.assets/perfect_binary_tree.png)
|
![完美二叉树](binary_tree.assets/perfect_binary_tree.png)
|
||||||
|
|
||||||
|
@ -440,8 +440,6 @@ comments: true
|
||||||
|
|
||||||
「完全二叉树 Complete Binary Tree」只有最底层的节点未被填满,且最底层节点尽量靠左填充。
|
「完全二叉树 Complete Binary Tree」只有最底层的节点未被填满,且最底层节点尽量靠左填充。
|
||||||
|
|
||||||
**完全二叉树非常适合用数组来表示**。如果按照层序遍历序列的顺序来存储,那么空节点 `null` 一定全部出现在序列的尾部,因此我们就可以不用存储这些 null 了。
|
|
||||||
|
|
||||||
![完全二叉树](binary_tree.assets/complete_binary_tree.png)
|
![完全二叉树](binary_tree.assets/complete_binary_tree.png)
|
||||||
|
|
||||||
<p align="center"> Fig. 完全二叉树 </p>
|
<p align="center"> Fig. 完全二叉树 </p>
|
||||||
|
@ -456,7 +454,7 @@ comments: true
|
||||||
|
|
||||||
### 平衡二叉树
|
### 平衡二叉树
|
||||||
|
|
||||||
「平衡二叉树 Balanced Binary Tree」中任意节点的左子树和右子树的高度之差的绝对值 $\leq 1$ 。
|
「平衡二叉树 Balanced Binary Tree」中任意节点的左子树和右子树的高度之差的绝对值不超过 1 。
|
||||||
|
|
||||||
![平衡二叉树](binary_tree.assets/balanced_binary_tree.png)
|
![平衡二叉树](binary_tree.assets/balanced_binary_tree.png)
|
||||||
|
|
||||||
|
@ -464,9 +462,9 @@ comments: true
|
||||||
|
|
||||||
## 7.1.4. 二叉树的退化
|
## 7.1.4. 二叉树的退化
|
||||||
|
|
||||||
当二叉树的每层的节点都被填满时,达到「完美二叉树」;而当所有节点都偏向一边时,二叉树退化为「链表」。
|
当二叉树的每层节点都被填满时,达到「完美二叉树」;而当所有节点都偏向一侧时,二叉树退化为「链表」。
|
||||||
|
|
||||||
- 完美二叉树是一个二叉树的“最佳状态”,可以完全发挥出二叉树“分治”的优势;
|
- 完美二叉树是理想情况,可以充分发挥二叉树“分治”的优势;
|
||||||
- 链表则是另一个极端,各项操作都变为线性操作,时间复杂度退化至 $O(n)$ ;
|
- 链表则是另一个极端,各项操作都变为线性操作,时间复杂度退化至 $O(n)$ ;
|
||||||
|
|
||||||
![二叉树的最佳与最二叉树的最佳和最差结构差情况](binary_tree.assets/binary_tree_corner_cases.png)
|
![二叉树的最佳与最二叉树的最佳和最差结构差情况](binary_tree.assets/binary_tree_corner_cases.png)
|
||||||
|
@ -488,23 +486,23 @@ comments: true
|
||||||
|
|
||||||
## 7.1.5. 二叉树表示方式 *
|
## 7.1.5. 二叉树表示方式 *
|
||||||
|
|
||||||
我们一般使用二叉树的「链表表示」,即存储单位为节点 `TreeNode` ,节点之间通过指针(引用)相连接。本文前述示例代码展示了二叉树在链表表示下的各项基本操作。
|
我们通常使用二叉树的「链表表示」,即存储单位为节点 `TreeNode` ,节点之间通过指针相连接。本文前述示例代码展示了二叉树在链表表示下的各项基本操作。
|
||||||
|
|
||||||
那能否可以用「数组表示」二叉树呢?答案是肯定的。先来分析一个简单案例,给定一个「完美二叉树」,将节点按照层序遍历的顺序编号(从 0 开始),那么可以推导得出父节点索引与子节点索引之间的「映射公式」:**设节点的索引为 $i$ ,则该节点的左子节点索引为 $2i + 1$ 、右子节点索引为 $2i + 2$** 。
|
那么,能否用「数组」来表示二叉树呢?答案是肯定的。先来分析一个简单案例,给定一个「完美二叉树」,将节点按照层序遍历的顺序编号(从 0 开始),那么可以推导得出父节点索引与子节点索引之间的“映射公式”:**若节点的索引为 $i$ ,则该节点的左子节点索引为 $2i + 1$ ,右子节点索引为 $2i + 2$** 。
|
||||||
|
|
||||||
**本质上,映射公式的作用就是链表中的指针**。对于层序遍历序列中的任意节点,我们都可以使用映射公式来访问子节点。因此,可以直接使用层序遍历序列(即数组)来表示完美二叉树。
|
**本质上,映射公式的作用相当于链表中的指针**。对于层序遍历序列中的任意节点,我们都可以使用映射公式来访问其子节点。因此,我们可以将二叉树的层序遍历序列存储到数组中,利用以上映射公式来表示二叉树。
|
||||||
|
|
||||||
![完美二叉树的数组表示](binary_tree.assets/array_representation_mapping.png)
|
![完美二叉树的数组表示](binary_tree.assets/array_representation_mapping.png)
|
||||||
|
|
||||||
<p align="center"> Fig. 完美二叉树的数组表示 </p>
|
<p align="center"> Fig. 完美二叉树的数组表示 </p>
|
||||||
|
|
||||||
然而,完美二叉树只是个例,二叉树中间层往往存在许多空节点(即 `null` ),而层序遍历序列并不包含这些空节点,并且我们无法单凭序列来猜测空节点的数量和分布位置,**即理论上存在许多种二叉树都符合该层序遍历序列**。显然,这种情况无法使用数组来存储二叉树。
|
然而,完美二叉树只是一个特例。在二叉树的中间层,通常存在许多 $\text{null}$ ,而层序遍历序列并不包含这些 $\text{null}$ 。我们无法仅凭序列来推测空节点的数量和分布位置,**这意味着理论上存在许多种二叉树都符合该层序遍历序列**。显然,在这种情况下,我们无法使用数组来存储二叉树。
|
||||||
|
|
||||||
![给定数组对应多种二叉树可能性](binary_tree.assets/array_representation_without_empty.png)
|
![给定数组对应多种二叉树可能性](binary_tree.assets/array_representation_without_empty.png)
|
||||||
|
|
||||||
<p align="center"> Fig. 给定数组对应多种二叉树可能性 </p>
|
<p align="center"> Fig. 给定数组对应多种二叉树可能性 </p>
|
||||||
|
|
||||||
为了解决此问题,考虑按照完美二叉树的形式来表示所有二叉树,**即在序列中使用特殊符号来显式地表示“空位”**。如下图所示,这样处理后,序列(数组)就可以唯一表示二叉树了。
|
为了解决这个问题,我们可以考虑按照完美二叉树的形式来表示所有二叉树,**并在序列中使用特殊符号来显式地表示 $\text{null}$**。如下图所示,这样处理后,层序遍历序列就可以唯一表示二叉树了。
|
||||||
|
|
||||||
=== "Java"
|
=== "Java"
|
||||||
|
|
||||||
|
@ -587,10 +585,10 @@ comments: true
|
||||||
|
|
||||||
<p align="center"> Fig. 任意类型二叉树的数组表示 </p>
|
<p align="center"> Fig. 任意类型二叉树的数组表示 </p>
|
||||||
|
|
||||||
回顾「完全二叉树」的定义,其只有最底层有空节点,并且最底层的节点尽量靠左,因而所有空节点都一定出现在层序遍历序列的末尾。**因为我们先验地确定了空位的位置,所以在使用数组表示完全二叉树时,可以省略存储“空位”**。因此,完全二叉树非常适合使用数组来表示。
|
**完全二叉树非常适合使用数组来表示**。回顾「完全二叉树」的定义,$\text{null}$ 只出现在最底层,并且最底层的节点尽量靠左。这意味着,**所有空节点一定出现在层序遍历序列的末尾**。由于我们事先知道了所有 $\text{null}$ 的位置,因此在使用数组表示完全二叉树时,可以省略存储它们。
|
||||||
|
|
||||||
![完全二叉树的数组表示](binary_tree.assets/array_representation_complete_binary_tree.png)
|
![完全二叉树的数组表示](binary_tree.assets/array_representation_complete_binary_tree.png)
|
||||||
|
|
||||||
<p align="center"> Fig. 完全二叉树的数组表示 </p>
|
<p align="center"> Fig. 完全二叉树的数组表示 </p>
|
||||||
|
|
||||||
数组表示有两个优点: 一是不需要存储指针,节省空间;二是可以随机访问节点。然而,当二叉树中的“空位”很多时,数组中只包含很少节点的数据,空间利用率很低。
|
数组表示具有两个显著优点:首先,它不需要存储指针,从而节省了空间;其次,它允许随机访问节点。然而,当二叉树中存在大量 $\text{null}$ 时,数组中包含的节点数据比重较低,导致有效空间利用率降低。
|
||||||
|
|
|
@ -4,15 +4,15 @@ comments: true
|
||||||
|
|
||||||
# 7.2. 二叉树遍历
|
# 7.2. 二叉树遍历
|
||||||
|
|
||||||
从物理结构角度看,树是一种基于链表的数据结构,因此遍历方式也是通过指针(即引用)逐个遍历节点。同时,树还是一种非线性数据结构,这导致遍历树比遍历链表更加复杂,需要使用搜索算法来实现。
|
从物理结构的角度来看,树是一种基于链表的数据结构,因此其遍历方式是通过指针逐个访问节点。然而,树是一种非线性数据结构,这使得遍历树比遍历链表更加复杂,需要借助搜索算法来实现。
|
||||||
|
|
||||||
常见的二叉树遍历方式有层序遍历、前序遍历、中序遍历、后序遍历。
|
二叉树常见的遍历方式包括层序遍历、前序遍历、中序遍历和后序遍历等。
|
||||||
|
|
||||||
## 7.2.1. 层序遍历
|
## 7.2.1. 层序遍历
|
||||||
|
|
||||||
「层序遍历 Level-Order Traversal」从顶至底、一层一层地遍历二叉树,并在每层中按照从左到右的顺序访问节点。
|
「层序遍历 Level-Order Traversal」从顶部到底部逐层遍历二叉树,并在每一层按照从左到右的顺序访问节点。
|
||||||
|
|
||||||
层序遍历本质上是「广度优先搜索 Breadth-First Traversal」,其体现着一种“一圈一圈向外”的层进遍历方式。
|
层序遍历本质上属于「广度优先搜索 Breadth-First Traversal」,它体现了一种“一圈一圈向外扩展”的逐层搜索方式。
|
||||||
|
|
||||||
![二叉树的层序遍历](binary_tree_traversal.assets/binary_tree_bfs.png)
|
![二叉树的层序遍历](binary_tree_traversal.assets/binary_tree_bfs.png)
|
||||||
|
|
||||||
|
@ -20,7 +20,7 @@ comments: true
|
||||||
|
|
||||||
### 算法实现
|
### 算法实现
|
||||||
|
|
||||||
广度优先遍历一般借助「队列」来实现。队列的规则是“先进先出”,广度优先遍历的规则是“一层层平推”,两者背后的思想是一致的。
|
广度优先遍历通常借助「队列」来实现。队列遵循“先进先出”的规则,而广度优先遍历则遵循“逐层推进”的规则,两者背后的思想是一致的。
|
||||||
|
|
||||||
=== "Java"
|
=== "Java"
|
||||||
|
|
||||||
|
@ -248,13 +248,13 @@ comments: true
|
||||||
|
|
||||||
**时间复杂度**:所有节点被访问一次,使用 $O(n)$ 时间,其中 $n$ 为节点数量。
|
**时间复杂度**:所有节点被访问一次,使用 $O(n)$ 时间,其中 $n$ 为节点数量。
|
||||||
|
|
||||||
**空间复杂度**:当为满二叉树时达到最差情况,遍历到最底层前,队列中最多同时存在 $\frac{n + 1}{2}$ 个节点,使用 $O(n)$ 空间。
|
**空间复杂度**:在最差情况下,即满二叉树时,遍历到最底层之前,队列中最多同时存在 $\frac{n + 1}{2}$ 个节点,占用 $O(n)$ 空间。
|
||||||
|
|
||||||
## 7.2.2. 前序、中序、后序遍历
|
## 7.2.2. 前序、中序、后序遍历
|
||||||
|
|
||||||
相对地,前、中、后序遍历皆属于「深度优先遍历 Depth-First Traversal」,其体现着一种“先走到尽头,再回头继续”的回溯遍历方式。
|
相应地,前序、中序和后序遍历都属于「深度优先遍历 Depth-First Traversal」,它体现了一种“先走到尽头,再回溯继续”的遍历方式。
|
||||||
|
|
||||||
如下图所示,左侧是深度优先遍历的的示意图,右上方是对应的递归实现代码。深度优先遍历就像是绕着整个二叉树的外围“走”一圈,走的过程中,在每个节点都会遇到三个位置,分别对应前序遍历、中序遍历、后序遍历。
|
如下图所示,左侧是深度优先遍历的示意图,右上方是对应的递归实现代码。深度优先遍历就像是绕着整个二叉树的外围“走”一圈,在这个过程中,在每个节点都会遇到三个位置,分别对应前序遍历、中序遍历和后序遍历。
|
||||||
|
|
||||||
![二叉搜索树的前、中、后序遍历](binary_tree_traversal.assets/binary_tree_dfs.png)
|
![二叉搜索树的前、中、后序遍历](binary_tree_traversal.assets/binary_tree_dfs.png)
|
||||||
|
|
||||||
|
@ -590,4 +590,4 @@ comments: true
|
||||||
|
|
||||||
**时间复杂度**:所有节点被访问一次,使用 $O(n)$ 时间,其中 $n$ 为节点数量。
|
**时间复杂度**:所有节点被访问一次,使用 $O(n)$ 时间,其中 $n$ 为节点数量。
|
||||||
|
|
||||||
**空间复杂度**:当树退化为链表时达到最差情况,递归深度达到 $n$ ,系统使用 $O(n)$ 栈帧空间。
|
**空间复杂度**:在最差情况下,即树退化为链表时,递归深度达到 $n$ ,系统占用 $O(n)$ 栈帧空间。
|
||||||
|
|
|
@ -4,22 +4,14 @@ comments: true
|
||||||
|
|
||||||
# 7.5. 小结
|
# 7.5. 小结
|
||||||
|
|
||||||
### 二叉树
|
- 二叉树是一种非线性数据结构,体现“一分为二”的分治逻辑。每个二叉树节点包含一个值以及两个指针,分别指向其左子节点和右子节点。
|
||||||
|
- 对于二叉树中的某个节点,其左(右)子节点及其以下形成的树被称为该节点的左(右)子树。
|
||||||
- 二叉树是一种非线性数据结构,代表着“一分为二”的分治逻辑。二叉树的节点包含「值」和两个「指针」,分别指向左子节点和右子节点。
|
- 二叉树的相关术语包括根节点、叶节点、层、度、边、高度和深度等。
|
||||||
- 选定二叉树中某节点,将其左(右)子节点以下形成的树称为左(右)子树。
|
- 二叉树的初始化、节点插入和节点删除操作与链表操作方法类似。
|
||||||
- 二叉树的术语较多,包括根节点、叶节点、层、度、边、高度、深度等。
|
- 常见的二叉树类型有完美二叉树、完全二叉树、满二叉树和平衡二叉树。完美二叉树是最理想的状态,而链表是退化后的最差状态。
|
||||||
- 二叉树的初始化、节点插入、节点删除操作与链表的操作方法类似。
|
- 二叉树可以用数组表示,方法是将节点值和空位按层序遍历顺序排列,并根据父节点与子节点之间的索引映射关系来实现指针。
|
||||||
- 常见的二叉树类型包括完美二叉树、完全二叉树、完满二叉树、平衡二叉树。完美二叉树是理想状态,链表则是退化后的最差状态。
|
- 二叉树的层序遍历是一种广度优先搜索方法,它体现了“一圈一圈向外”的分层遍历方式,通常通过队列来实现。
|
||||||
- 二叉树可以使用数组表示,具体做法是将节点值和空位按照层序遍历的顺序排列,并基于父节点和子节点之间的索引映射公式实现指针。
|
- 前序、中序、后序遍历皆属于深度优先搜索,它们体现了“走到尽头,再回头继续”的回溯遍历方式,通常使用递归来实现。
|
||||||
|
- 二叉搜索树是一种高效的元素查找数据结构,其查找、插入和删除操作的时间复杂度均为 $O(\log n)$ 。当二叉搜索树退化为链表时,各项时间复杂度会劣化至 $O(n)$ 。
|
||||||
### 二叉树遍历
|
- AVL 树,也称为平衡二叉搜索树,它通过旋转操作,确保在不断插入和删除节点后,树仍然保持平衡。
|
||||||
|
- AVL 树的旋转操作包括右旋、左旋、先右旋再左旋、先左旋再右旋。在插入或删除节点后,AVL 树会从底向顶执行旋转操作,使树重新恢复平衡。
|
||||||
- 二叉树层序遍历是一种广度优先搜索,体现着“一圈一圈向外”的层进式遍历方式,通常借助队列来实现。
|
|
||||||
- 前序、中序、后序遍历是深度优先搜索,体现着“走到头、再回头继续”的回溯遍历方式,通常使用递归实现。
|
|
||||||
|
|
||||||
### 二叉搜索树
|
|
||||||
|
|
||||||
- 二叉搜索树是一种高效的元素查找数据结构,查找、插入、删除操作的时间复杂度皆为 $O(\log n)$ 。二叉搜索树退化为链表后,各项时间复杂度劣化至 $O(n)$ ,因此如何避免退化是非常重要的课题。
|
|
||||||
- AVL 树又称平衡二叉搜索树,其通过旋转操作,使得在不断插入与删除节点后,仍然可以保持二叉树的平衡(不退化)。
|
|
||||||
- AVL 树的旋转操作分为右旋、左旋、先右旋后左旋、先左旋后右旋。在插入或删除节点后,AVL 树会从底至顶地执行旋转操作,使树恢复平衡。
|
|
||||||
|
|
Loading…
Reference in a new issue