This commit is contained in:
krahets 2023-08-17 05:12:05 +08:00
parent f0826da7f6
commit 97c532b228
67 changed files with 1481 additions and 1066 deletions

View file

@ -24,7 +24,7 @@ comments: true
![页面编辑按键](contribution.assets/edit_markdown.png)
<p align="center"> Fig. 页面编辑按键 </p>
<p align="center"> 图:页面编辑按键 </p>
图片无法直接修改,需要通过新建 [Issue](https://github.com/krahets/hello-algo/issues) 或评论留言来描述问题,我们会尽快重新绘制并替换图片。

View file

@ -48,7 +48,12 @@ comments: true
1. 下载并安装 [Swift](https://www.swift.org/download/)。
2. 在 VSCode 的插件市场中搜索 `swift` ,安装 [Swift for Visual Studio Code](https://marketplace.visualstudio.com/items?itemName=sswg.swift-lang)。
## 16.1.9. &nbsp; Rust 环境
## 16.1.9. &nbsp; Dart 环境
1. 下载并安装 [Dart](https://dart.dev/get-dart) 。
2. 在 VSCode 的插件市场中搜索 `dart` ,安装 [Dart](https://marketplace.visualstudio.com/items?itemName=Dart-Code.dart-code) 。
## 16.1.10. &nbsp; Rust 环境
1. 下载并安装 [Rust](https://www.rust-lang.org/tools/install)。
2. 在 VSCode 的插件市场中搜索 `rust` ,安装 [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)。

File diff suppressed because it is too large Load diff

View file

@ -4,15 +4,21 @@ comments: true
# 4.2. &nbsp; 链表
内存空间是所有程序的公共资源,排除已被占用的内存空间,空闲内存空间通常散落在内存各处。在上一节中,我们提到存储数组的内存空间必须是连续的,而当需要申请一个非常大的数组时,空闲内存中可能没有这么大的连续空间。与数组相比,链表更具灵活性,它可以被存储在非连续的内存空间中
内存空间是所有程序的公共资源,在一个复杂的系统运行环境下,空闲的内存空间可能散落在内存各处。我们知道,存储数组的内存空间必须是连续的,而当数组非常大时,内存可能无法提供如此大的连续空间。此时链表的灵活性优势就体现出来了
「链表 Linked List」是一种线性数据结构其每个元素都是一个节点对象各个节点之间通过指针连接从当前节点通过指针可以访问到下一个节点。**由于指针记录了下个节点的内存地址,因此无需保证内存地址的连续性**,从而可以将各个节点分散存储在内存各处。
链表中的「节点 Node」包含两项数据一是节点「值 Value」二是指向下一节点的「引用 Reference」或称「指针 Pointer」。
「链表 Linked List」是一种线性数据结构其中的每个元素都是一个节点对象各个节点通过“引用”相连接。引用记录了下一个节点的内存地址我们可以通过它从当前节点访问到下一个节点。这意味着链表的各个节点可以被分散存储在内存各处它们的内存地址是无需连续的。
![链表定义与存储方式](linked_list.assets/linkedlist_definition.png)
<p align="center"> Fig. 链表定义与存储方式 </p>
<p align="center"> 图:链表定义与存储方式 </p>
观察上图,链表中的每个「节点 Node」对象都包含两项数据节点的“值”、指向下一节点的“引用”。
- 链表的首个节点被称为“头节点”,最后一个节点被称为“尾节点”。
- 尾节点指向的是“空”,它在 Java, C++, Python 中分别被记为 $\text{null}$ , $\text{nullptr}$ , $\text{None}$ 。
- 在 C, C++, Go, Rust 等支持指针的语言中,上述的“引用”应被替换为“指针”。
如以下代码所示,链表以节点对象 `ListNode` 为单位,每个节点除了包含值,还需额外保存下一节点的引用(指针)。因此在相同数据量下,**链表通常比数组占用更多的内存空间**。
=== "Java"
@ -20,7 +26,7 @@ comments: true
/* 链表节点类 */
class ListNode {
int val; // 节点值
ListNode next; // 指向下一节点的指针(引用
ListNode next; // 指向下一节点的引用
ListNode(int x) { val = x; } // 构造函数
}
```
@ -31,7 +37,7 @@ comments: true
/* 链表节点结构体 */
struct ListNode {
int val; // 节点值
ListNode *next; // 指向下一节点的指针(引用)
ListNode *next; // 指向下一节点的指针
ListNode(int x) : val(x), next(nullptr) {} // 构造函数
};
```
@ -43,7 +49,7 @@ comments: true
"""链表节点类"""
def __init__(self, val: int):
self.val: int = val # 节点值
self.next: Optional[ListNode] = None # 指向下一节点的指针(引用
self.next: Optional[ListNode] = None # 指向下一节点的引用
```
=== "Go"
@ -52,9 +58,9 @@ comments: true
/* 链表节点结构体 */
type ListNode struct {
Val int // 节点值
Next *ListNode // 指向下一节点的指针(引用)
Next *ListNode // 指向下一节点的指针
}
// NewListNode 构造函数,创建一个新的链表
func NewListNode(val int) *ListNode {
return &ListNode{
@ -98,7 +104,7 @@ comments: true
/* 链表节点结构体 */
struct ListNode {
int val; // 节点值
struct ListNode *next; // 指向下一节点的指针(引用)
struct ListNode *next; // 指向下一节点的指针
};
typedef struct ListNode ListNode;
@ -130,7 +136,7 @@ comments: true
/* 链表节点类 */
class ListNode {
var val: Int // 节点值
var next: ListNode? // 指向下一节点的指针(引用
var next: ListNode? // 指向下一节点的引用
init(x: Int) { // 构造函数
val = x
@ -145,9 +151,9 @@ comments: true
pub fn ListNode(comptime T: type) type {
return struct {
const Self = @This();
val: T = 0, // 节点值
next: ?*Self = null, // 指向下一节点的指针(引用)
next: ?*Self = null, // 指向下一节点的指针
// 构造函数
pub fn init(self: *Self, x: i32) void {
@ -164,7 +170,7 @@ comments: true
/* 链表节点类 */
class ListNode {
int val; // 节点值
ListNode? next; // 指向下一节点的指针(引用
ListNode? next; // 指向下一节点的引用
ListNode(this.val, [this.next]); // 构造函数
}
```
@ -178,19 +184,21 @@ comments: true
#[derive(Debug)]
struct ListNode {
val: i32, // 节点值
next: Option<Rc<RefCell<ListNode>>>, // 指向下一节点的指针(引用)
next: Option<Rc<RefCell<ListNode>>>, // 指向下一节点的指针
}
```
我们将链表的首个节点称为「头节点」,最后一个节点称为「尾节点」。尾节点指向的是“空”,在 Java, C++, Python 中分别记为 $\text{null}$ , $\text{nullptr}$ , $\text{None}$ 。在不引起歧义的前提下,本书都使用 $\text{None}$ 来表示空。
## 4.2.1. &nbsp; 链表常用操作
**链表初始化方法**。建立链表分为两步,第一步是初始化各个节点对象,第二步是构建引用指向关系。完成后,即可以从链表的头节点(即首个节点)出发,通过指针 `next` 依次访问所有节点。
### 初始化链表
建立链表分为两步,第一步是初始化各个节点对象,第二步是构建引用指向关系。初始化完成后,我们就可以从链表的头节点出发,通过引用指向 `next` 依次访问所有节点。
=== "Java"
```java title="linked_list.java"
/* 初始化链表 1 -> 3 -> 2 -> 5 -> 4 */
// 初始化各个节点
// 初始化各个节点
ListNode n0 = new ListNode(1);
ListNode n1 = new ListNode(3);
ListNode n2 = new ListNode(2);
@ -207,7 +215,7 @@ comments: true
```cpp title="linked_list.cpp"
/* 初始化链表 1 -> 3 -> 2 -> 5 -> 4 */
// 初始化各个节点
// 初始化各个节点
ListNode* n0 = new ListNode(1);
ListNode* n1 = new ListNode(3);
ListNode* n2 = new ListNode(2);
@ -224,7 +232,7 @@ comments: true
```python title="linked_list.py"
# 初始化链表 1 -> 3 -> 2 -> 5 -> 4
# 初始化各个节点
# 初始化各个节点
n0 = ListNode(1)
n1 = ListNode(3)
n2 = ListNode(2)
@ -292,7 +300,7 @@ comments: true
```c title="linked_list.c"
/* 初始化链表 1 -> 3 -> 2 -> 5 -> 4 */
// 初始化各个节点
// 初始化各个节点
ListNode* n0 = newListNode(1);
ListNode* n1 = newListNode(3);
ListNode* n2 = newListNode(2);
@ -309,7 +317,7 @@ comments: true
```csharp title="linked_list.cs"
/* 初始化链表 1 -> 3 -> 2 -> 5 -> 4 */
// 初始化各个节点
// 初始化各个节点
ListNode n0 = new ListNode(1);
ListNode n1 = new ListNode(3);
ListNode n2 = new ListNode(2);
@ -343,7 +351,7 @@ comments: true
```zig title="linked_list.zig"
// 初始化链表
// 初始化各个节点
// 初始化各个节点
var n0 = inc.ListNode(i32){.val = 1};
var n1 = inc.ListNode(i32){.val = 3};
var n2 = inc.ListNode(i32){.val = 2};
@ -391,15 +399,17 @@ comments: true
n3.borrow_mut().next = Some(n4.clone());
```
在编程语言中,数组整体是一个变量,比如数组 `nums` 包含元素 `nums[0]` , `nums[1]`。而链表是由多个分散的节点对象组成,**我们通常将头节点当作链表的代称**,比如以上代码中的链表可被记做链表 `n0`
数组整体是一个变量,比如数组 `nums` 包含元素 `nums[0]` , `nums[1]`,而链表是由多个独立的节点对象组成的。**我们通常将头节点当作链表的代称**,比如以上代码中的链表可被记做链表 `n0`
## 4.2.1. &nbsp; 链表优
### 插入节
**链表中插入与删除节点的操作效率高**。如果我们想在链表中间的两个节点 `A` , `B` 之间插入一个新节点 `P` ,我们只需要改变两个节点指针即可,时间复杂度为 $O(1)$ ;相比之下,数组的插入操作效率要低得多。
**在链表中插入节点非常容易**。假设我们想在相邻的两个节点 `n0` , `n1` 之间插入一个新节点 `P` ,则只需要改变两个节点引用(指针)即可,时间复杂度为 $O(1)$ 。
相比之下,在数组中插入元素的时间复杂度为 $O(n)$ ,在大数据量下的效率较低。
![链表插入节点](linked_list.assets/linkedlist_insert_node.png)
<p align="center"> Fig. 链表插入节点 </p>
<p align="center"> 图:链表插入节点 </p>
=== "Java"
@ -533,11 +543,15 @@ comments: true
}
```
在链表中删除节点也非常方便,只需改变一个节点的指针即可。如下图所示,尽管在删除操作完成后,节点 `P` 仍然指向 `n1` ,但实际上 `P` 已经不再属于此链表,因为遍历此链表时无法访问到 `P`
### 删除节点
在链表中删除节点也非常简便,只需改变一个节点的引用(指针)即可。
请注意,尽管在删除操作完成后节点 `P` 仍然指向 `n1` ,但实际上遍历此链表已经无法访问到 `P` ,这意味着 `P` 已经不再属于该链表了。
![链表删除节点](linked_list.assets/linkedlist_remove_node.png)
<p align="center"> Fig. 链表删除节点 </p>
<p align="center"> 图:链表删除节点 </p>
=== "Java"
@ -714,9 +728,9 @@ comments: true
}
```
## 4.2.2. &nbsp; 链表缺
### 访问节
**链表访问节点效率较低**。如上节所述,数组可以在 $O(1)$ 时间下访问任意元素。然而链表无法直接访问任意节点,因为程序需要从头节点出发,逐个向后遍历,直至找到目标节点。也就是说,如果想要访问链表中第 $i$ 个节点,则需要向后遍历 $i - 1$ 轮
**在链表访问节点的效率较低**。如上节所述,我们可以在 $O(1)$ 时间下访问数组中的任意元素。链表则不然,程序需要从头节点出发,逐个向后遍历,直至找到目标节点。也就是说,访问链表的第 $i$ 个节点需要循环 $i - 1$ 轮,时间复杂度为 $O(n)$
=== "Java"
@ -887,11 +901,9 @@ comments: true
}
```
**链表的内存占用较大**。链表以节点为单位,每个节点除了包含值,还需额外保存下一节点的引用(指针)。这意味着在相同数据量的情况下,链表比数组需要占用更多的内存空间。
### 查找节点
## 4.2.3. &nbsp; 链表常用操作
**遍历链表查找**。遍历链表,查找链表内值为 `target` 的节点,输出节点在链表中的索引。
遍历链表,查找链表内值为 `target` 的节点,输出节点在链表中的索引。此过程也属于「线性查找」。
=== "Java"
@ -1086,13 +1098,31 @@ comments: true
}
```
## 4.2.4. &nbsp; 常见链表类型
## 4.2.2. &nbsp; 数组 VS 链表
**单向链表**。即上述介绍的普通链表。单向链表的节点包含值和指向下一节点的指针(引用)两项数据。我们将首个节点称为头节点,将最后一个节点成为尾节点,尾节点指向空 $\text{None}$ 。
下表总结对比了数组和链表的各项特点与操作效率。由于它们采用两种相反的存储策略,因此各种性质和操作效率也呈现对立的特点。
<div class="center-table" markdown>
| | 数组 | 链表 |
| ---------- | ------------------------ | ------------ |
| 存储方式 | 连续内存空间 | 离散内存空间 |
| 缓存局部性 | 友好 | 不友好 |
| 容量扩展 | 长度不可变 | 可灵活扩展 |
| 内存效率 | 占用内存少、浪费部分空间 | 占用内存多 |
| 访问元素 | $O(1)$ | $O(n)$ |
| 添加元素 | $O(n)$ | $O(1)$ |
| 删除元素 | $O(n)$ | $O(1)$ |
</div>
## 4.2.3. &nbsp; 常见链表类型
**单向链表**。即上述介绍的普通链表。单向链表的节点包含值和指向下一节点的引用两项数据。我们将首个节点称为头节点,将最后一个节点成为尾节点,尾节点指向空 $\text{None}$ 。
**环形链表**。如果我们令单向链表的尾节点指向头节点(即首尾相接),则得到一个环形链表。在环形链表中,任意节点都可以视作头节点。
**双向链表**。与单向链表相比,双向链表记录了两个方向的指针(引用)。双向链表的节点定义同时包含指向后继节点(下一节点)和前驱节点(上一节点)的指针。相较于单向链表,双向链表更具灵活性,可以朝两个方向遍历链表,但相应地也需要占用更多的内存空间。
**双向链表**。与单向链表相比,双向链表记录了两个方向的引用。双向链表的节点定义同时包含指向后继节点(下一节点)和前驱节点(上一节点)的引用(指针。相较于单向链表,双向链表更具灵活性,可以朝两个方向遍历链表,但相应地也需要占用更多的内存空间。
=== "Java"
@ -1100,8 +1130,8 @@ comments: true
/* 双向链表节点类 */
class ListNode {
int val; // 节点值
ListNode next; // 指向后继节点的指针(引用
ListNode prev; // 指向前驱节点的指针(引用
ListNode next; // 指向后继节点的引用
ListNode prev; // 指向前驱节点的引用
ListNode(int x) { val = x; } // 构造函数
}
```
@ -1112,8 +1142,8 @@ comments: true
/* 双向链表节点结构体 */
struct ListNode {
int val; // 节点值
ListNode *next; // 指向后继节点的指针(引用)
ListNode *prev; // 指向前驱节点的指针(引用)
ListNode *next; // 指向后继节点的指针
ListNode *prev; // 指向前驱节点的指针
ListNode(int x) : val(x), next(nullptr), prev(nullptr) {} // 构造函数
};
```
@ -1125,8 +1155,8 @@ comments: true
"""双向链表节点类"""
def __init__(self, val: int):
self.val: int = val # 节点值
self.next: Optional[ListNode] = None # 指向后继节点的指针(引用
self.prev: Optional[ListNode] = None # 指向前驱节点的指针(引用
self.next: Optional[ListNode] = None # 指向后继节点的引用
self.prev: Optional[ListNode] = None # 指向前驱节点的引用
```
=== "Go"
@ -1135,10 +1165,10 @@ comments: true
/* 双向链表节点结构体 */
type DoublyListNode struct {
Val int // 节点值
Next *DoublyListNode // 指向后继节点的指针(引用)
Prev *DoublyListNode // 指向前驱节点的指针(引用)
Next *DoublyListNode // 指向后继节点的指针
Prev *DoublyListNode // 指向前驱节点的指针
}
// NewDoublyListNode 初始化
func NewDoublyListNode(val int) *DoublyListNode {
return &DoublyListNode{
@ -1159,8 +1189,8 @@ comments: true
prev;
constructor(val, next, prev) {
this.val = val === undefined ? 0 : val; // 节点值
this.next = next === undefined ? null : next; // 指向后继节点的指针(引用
this.prev = prev === undefined ? null : prev; // 指向前驱节点的指针(引用
this.next = next === undefined ? null : next; // 指向后继节点的引用
this.prev = prev === undefined ? null : prev; // 指向前驱节点的引用
}
}
```
@ -1175,8 +1205,8 @@ comments: true
prev: ListNode | null;
constructor(val?: number, next?: ListNode | null, prev?: ListNode | null) {
this.val = val === undefined ? 0 : val; // 节点值
this.next = next === undefined ? null : next; // 指向后继节点的指针(引用
this.prev = prev === undefined ? null : prev; // 指向前驱节点的指针(引用
this.next = next === undefined ? null : next; // 指向后继节点的引用
this.prev = prev === undefined ? null : prev; // 指向前驱节点的引用
}
}
```
@ -1187,8 +1217,8 @@ comments: true
/* 双向链表节点结构体 */
struct ListNode {
int val; // 节点值
struct ListNode *next; // 指向后继节点的指针(引用)
struct ListNode *prev; // 指向前驱节点的指针(引用)
struct ListNode *next; // 指向后继节点的指针
struct ListNode *prev; // 指向前驱节点的指针
};
typedef struct ListNode ListNode;
@ -1210,8 +1240,8 @@ comments: true
/* 双向链表节点类 */
class ListNode {
int val; // 节点值
ListNode next; // 指向后继节点的指针(引用
ListNode prev; // 指向前驱节点的指针(引用
ListNode next; // 指向后继节点的引用
ListNode prev; // 指向前驱节点的引用
ListNode(int x) => val = x; // 构造函数
}
```
@ -1222,8 +1252,8 @@ comments: true
/* 双向链表节点类 */
class ListNode {
var val: Int // 节点值
var next: ListNode? // 指向后继节点的指针(引用
var prev: ListNode? // 指向前驱节点的指针(引用
var next: ListNode? // 指向后继节点的引用
var prev: ListNode? // 指向前驱节点的引用
init(x: Int) { // 构造函数
val = x
@ -1238,10 +1268,10 @@ comments: true
pub fn ListNode(comptime T: type) type {
return struct {
const Self = @This();
val: T = 0, // 节点值
next: ?*Self = null, // 指向后继节点的指针(引用)
prev: ?*Self = null, // 指向前驱节点的指针(引用)
next: ?*Self = null, // 指向后继节点的指针
prev: ?*Self = null, // 指向前驱节点的指针
// 构造函数
pub fn init(self: *Self, x: i32) void {
@ -1259,8 +1289,8 @@ comments: true
/* 双向链表节点类 */
class ListNode {
int val; // 节点值
ListNode next; // 指向后继节点的指针(引用
ListNode prev; // 指向前驱节点的指针(引用
ListNode next; // 指向后继节点的引用
ListNode prev; // 指向前驱节点的引用
ListNode(this.val, [this.next, this.prev]); // 构造函数
}
```
@ -1275,10 +1305,10 @@ comments: true
#[derive(Debug)]
struct ListNode {
val: i32, // 节点值
next: Option<Rc<RefCell<ListNode>>>, // 指向后继节点的指针(引用)
prev: Option<Rc<RefCell<ListNode>>>, // 指向前驱节点的指针(引用)
next: Option<Rc<RefCell<ListNode>>>, // 指向后继节点的指针
prev: Option<Rc<RefCell<ListNode>>>, // 指向前驱节点的指针
}
/* 构造函数 */
impl ListNode {
fn new(val: i32) -> Self {
@ -1293,9 +1323,9 @@ comments: true
![常见链表种类](linked_list.assets/linkedlist_common_types.png)
<p align="center"> Fig. 常见链表种类 </p>
<p align="center"> 图:常见链表种类 </p>
## 4.2.5. &nbsp; 链表典型应用
## 4.2.4. &nbsp; 链表典型应用
单向链表通常用于实现栈、队列、散列表和图等数据结构。
@ -1305,7 +1335,7 @@ comments: true
双向链表常被用于需要快速查找前一个和下一个元素的场景。
- **高级数据结构**比如在红黑树、B 树中,我们需要知道一个节点的父节点,这可以通过在节点中保存一个指向父节点的指针来实现,类似于双向链表。
- **高级数据结构**比如在红黑树、B 树中,我们需要访问节点的父节点,这可以通过在节点中保存一个指向父节点的引用来实现,类似于双向链表。
- **浏览器历史**:在网页浏览器中,当用户点击前进或后退按钮时,浏览器需要知道用户访问过的前一个和后一个网页。双向链表的特性使得这种操作变得简单。
- **LRU 算法**在缓存淘汰算法LRU我们需要快速找到最近最少使用的数据以及支持快速地添加和删除节点。这时候使用双向链表就非常合适。

View file

@ -4,13 +4,15 @@ comments: true
# 4.3. &nbsp; 列表
**数组长度不可变导致实用性降低**。在许多情况下,我们事先无法确定需要存储多少数据,这使数组长度的选择变得困难。若长度过小,需要在持续添加数据时频繁扩容数组;若长度过大,则会造成内存空间的浪费。
**数组长度不可变导致实用性降低**。在实际中,我们可能事先无法确定需要存储多少数据,这使数组长度的选择变得困难。若长度过小,需要在持续添加数据时频繁扩容数组;若长度过大,则会造成内存空间的浪费。
为解决此问题,出现了一种被称为「动态数组 Dynamic Array」的数据结构即长度可变的数组也常被称为「列表 List」。列表基于数组实现继承了数组的优点并且可以在程序运行过程中动态扩容。在列表中,我们可以自由添加元素,而无需担心超过容量限制。
为解决此问题,出现了一种被称为「动态数组 Dynamic Array」的数据结构即长度可变的数组也常被称为「列表 List」。列表基于数组实现继承了数组的优点并且可以在程序运行过程中动态扩容。我们可以在列表中自由添加元素,而无需担心超过容量限制。
## 4.3.1. &nbsp; 列表常用操作
**初始化列表**。通常我们会使用“无初始值”和“有初始值”的两种初始化方法。
### 初始化列表
我们通常使用“无初始值”和“有初始值”这两种初始化方法。
=== "Java"
@ -130,7 +132,9 @@ comments: true
let list2: Vec<i32> = vec![1, 3, 2, 5, 4];
```
**访问与更新元素**。由于列表的底层数据结构是数组,因此可以在 $O(1)$ 时间内访问和更新元素,效率很高。
### 访问元素
列表本质上是数组,因此可以在 $O(1)$ 时间内访问和更新元素,效率很高。
=== "Java"
@ -247,7 +251,9 @@ comments: true
list[1] = 0; // 将索引 1 处的元素更新为 0
```
**在列表中添加、插入、删除元素**。相较于数组,列表可以自由地添加与删除元素。在列表尾部添加元素的时间复杂度为 $O(1)$ ,但插入和删除元素的效率仍与数组相同,时间复杂度为 $O(N)$ 。
### 插入与删除元素
相较于数组,列表可以自由地添加与删除元素。在列表尾部添加元素的时间复杂度为 $O(1)$ ,但插入和删除元素的效率仍与数组相同,时间复杂度为 $O(n)$ 。
=== "Java"
@ -475,7 +481,9 @@ comments: true
list.remove(3); // 删除索引 3 处的元素
```
**遍历列表**。与数组一样,列表可以根据索引遍历,也可以直接遍历各元素。
### 遍历列表
与数组一样,列表可以根据索引遍历,也可以直接遍历各元素。
=== "Java"
@ -658,7 +666,9 @@ comments: true
}
```
**拼接两个列表**。给定一个新列表 `list1` ,我们可以将该列表拼接到原列表的尾部。
### 拼接列表
给定一个新列表 `list1` ,我们可以将该列表拼接到原列表的尾部。
=== "Java"
@ -757,7 +767,9 @@ comments: true
list.extend(list1);
```
**排序列表**。排序也是常用的方法之一。完成列表排序后,我们便可以使用在数组类算法题中经常考察的「二分查找」和「双指针」算法。
### 排序列表
完成列表排序后,我们便可以使用在数组类算法题中经常考察的“二分查找”和“双指针”算法。
=== "Java"
@ -842,15 +854,15 @@ comments: true
list.sort(); // 排序后,列表元素从小到大排列
```
## 4.3.2. &nbsp; 列表实现 *
## 4.3.2. &nbsp; 列表实现
为了帮助加深对列表的理解,我们在此提供一个简易版列表实现。需要关注三个核心点:
许多编程语言都提供内置的列表,例如 Java, C++, Python 等。它们的实现比较复杂,各个参数的设定也非常有考究,例如初始容量、扩容倍数等。感兴趣的读者可以查阅源码进行学习。
为了帮助你理解列表的工作原理,我们在此提供一个简易版列表实现,重点包括:
- **初始容量**:选取一个合理的数组初始容量。在本示例中,我们选择 10 作为初始容量。
- **数量记录**:声明一个变量 size用于记录列表当前元素数量并随着元素插入和删除实时更新。根据此变量我们可以定位列表尾部以及判断是否需要扩容。
- **扩容机制**:插入元素时可能超出列表容量,此时需要扩容列表。扩容方法是根据扩容倍数创建一个更大的数组,并将当前数组的所有元素依次移动至新数组。在本示例中,我们规定每次将数组扩容至之前的 2 倍。
本示例旨在帮助读者直观理解列表的工作机制。实际编程语言中,列表实现更加标准和复杂,各个参数的设定也非常有考究,例如初始容量、扩容倍数等。感兴趣的读者可以查阅源码进行学习。
- **扩容机制**:若插入元素时列表容量已满,则需要进行扩容。首先根据扩容倍数创建一个更大的数组,再将当前数组的所有元素依次移动至新数组。在本示例中,我们规定每次将数组扩容至之前的 2 倍。
=== "Java"

View file

@ -4,29 +4,10 @@ comments: true
# 4.4. &nbsp; 小结
- 数组和链表是两种基本数据结构,分别代表数据在计算机内存中的连续空间存储和离散空间存储方式。两者的优缺点呈现出互补的特性。
- 数组和链表是两种基本数据结构,分别代表数据在计算机内存中的两种存储方式:连续空间存储和离散空间存储。两者的特点呈现出互补的特性。
- 数组支持随机访问、占用内存较少;但插入和删除元素效率低,且初始化后长度不可变。
- 链表通过更改引用(指针)实现高效的节点插入与删除,且可以灵活调整长度;但节点访问效率低、占用内存较多。常见的链表类型包括单向链表、循环链表、双向链表。
- 动态数组,又称列表,是基于数组实现的一种数据结构。它保留了数组的优势,同时可以灵活调整长度。列表的出现极大地提高了数组的易用性,但可能导致部分内存空间浪费。
- 下表总结并对比了数组与链表的各项特性与操作效率。
<div class="center-table" markdown>
| | 数组 | 链表 |
| ------------ | ------------------------ | ------------ |
| 存储方式 | 连续内存空间 | 离散内存空间 |
| 数据结构长度 | 长度不可变 | 长度可变 |
| 内存使用率 | 占用内存少、缓存局部性好 | 占用内存多 |
| 优势操作 | 随机访问 | 插入、删除 |
| 访问元素 | $O(1)$ | $O(N)$ |
| 添加元素 | $O(N)$ | $O(1)$ |
| 删除元素 | $O(N)$ | $O(1)$ |
</div>
!!! note "缓存局部性"
在计算机中,数据读写速度排序是“硬盘 < 内存 < CPU 缓存当我们访问数组元素时计算机不仅会加载它还会缓存其周围的其他数据从而借助高速缓存来提升后续操作的执行速度链表则不然计算机只能挨个地缓存各个节点这样的多次搬运降低了整体效率
## 4.4.1. &nbsp; Q & A
@ -38,7 +19,7 @@ comments: true
2. 栈是一块比较小的内存,容易出现内存不足;堆内存很大,但是由于是动态分配,容易碎片化,管理堆内存的难度更大、成本更高。
3. 访问栈比访问堆更快,因为栈内存较小、对缓存友好,堆帧分散在很大的空间内,会出现更多的缓存未命中。
!!! question "为什么数组会强调要求相同类型的元素,而在链表中却没有强调同类型呢?"
!!! question "为什么数组要求相同类型的元素,而在链表中却没有强调同类型呢?"
链表由结点组成,结点之间通过引用(指针)连接,各个结点可以存储不同类型的数据,例如 int, double, string, object 等。

View file

@ -197,7 +197,7 @@ comments: true
![在前序遍历中搜索节点](backtracking_algorithm.assets/preorder_find_nodes.png)
<p align="center"> Fig. 在前序遍历中搜索节点 </p>
<p align="center"> 图:在前序遍历中搜索节点 </p>
## 13.1.1. &nbsp; 尝试与回退
@ -501,6 +501,8 @@ comments: true
=== "<11>"
![preorder_find_paths_step11](backtracking_algorithm.assets/preorder_find_paths_step11.png)
<p align="center"> 图:尝试与回退 </p>
## 13.1.2. &nbsp; 剪枝
复杂的回溯问题通常包含一个或多个约束条件,**约束条件通常可用于“剪枝”**。
@ -797,7 +799,7 @@ comments: true
![根据约束条件剪枝](backtracking_algorithm.assets/preorder_find_constrained_paths.png)
<p align="center"> Fig. 根据约束条件剪枝 </p>
<p align="center"> 图:根据约束条件剪枝 </p>
## 13.1.3. &nbsp; 框架代码
@ -1657,7 +1659,7 @@ comments: true
![保留与删除 return 的搜索过程对比](backtracking_algorithm.assets/backtrack_remove_return_or_not.png)
<p align="center"> Fig. 保留与删除 return 的搜索过程对比 </p>
<p align="center"> 图:保留与删除 return 的搜索过程对比 </p>
相比基于前序遍历的代码实现,基于回溯算法框架的代码实现虽然显得啰嗦,但通用性更好。实际上,**许多回溯问题都可以在该框架下解决**。我们只需根据具体问题来定义 `state``choices` ,并实现框架中的各个方法即可。

View file

@ -12,13 +12,13 @@ comments: true
![4 皇后问题的解](n_queens_problem.assets/solution_4_queens.png)
<p align="center"> Fig. 4 皇后问题的解 </p>
<p align="center"> 图:4 皇后问题的解 </p>
本题共包含三个约束条件:**多个皇后不能在同一行、同一列、同一对角线**。值得注意的是,对角线分为主对角线 `\` 和次对角线 `/` 两种。
![n 皇后问题的约束条件](n_queens_problem.assets/n_queens_constraints.png)
<p align="center"> Fig. n 皇后问题的约束条件 </p>
<p align="center"> 图:n 皇后问题的约束条件 </p>
### 逐行放置策略
@ -30,7 +30,7 @@ comments: true
![逐行放置策略](n_queens_problem.assets/n_queens_placing.png)
<p align="center"> Fig. 逐行放置策略 </p>
<p align="center"> 图:逐行放置策略 </p>
本质上看,**逐行放置策略起到了剪枝的作用**,它避免了同一行出现多个皇后的所有搜索分支。
@ -46,7 +46,7 @@ comments: true
![处理列约束和对角线约束](n_queens_problem.assets/n_queens_cols_diagonals.png)
<p align="center"> Fig. 处理列约束和对角线约束 </p>
<p align="center"> 图:处理列约束和对角线约束 </p>
### 代码实现

View file

@ -32,7 +32,7 @@ comments: true
![全排列的递归树](permutations_problem.assets/permutations_i.png)
<p align="center"> Fig. 全排列的递归树 </p>
<p align="center"> 图:全排列的递归树 </p>
### 重复选择剪枝
@ -45,7 +45,7 @@ comments: true
![全排列剪枝示例](permutations_problem.assets/permutations_i_pruning.png)
<p align="center"> Fig. 全排列剪枝示例 </p>
<p align="center"> 图:全排列剪枝示例 </p>
观察上图发现,该剪枝操作将搜索空间大小从 $O(n^n)$ 降低至 $O(n!)$ 。
@ -483,7 +483,7 @@ comments: true
![重复排列](permutations_problem.assets/permutations_ii.png)
<p align="center"> Fig. 重复排列 </p>
<p align="center"> 图:重复排列 </p>
那么如何去除重复的排列呢?最直接地,考虑借助一个哈希表,直接对排列结果进行去重。然而这样做不够优雅,**因为生成重复排列的搜索分支是没有必要的,应当被提前识别并剪枝**,这样可以进一步提升算法效率。
@ -497,7 +497,7 @@ comments: true
![重复排列剪枝](permutations_problem.assets/permutations_ii_pruning.png)
<p align="center"> Fig. 重复排列剪枝 </p>
<p align="center"> 图:重复排列剪枝 </p>
### 代码实现
@ -914,4 +914,4 @@ comments: true
![两种剪枝条件的作用范围](permutations_problem.assets/permutations_ii_pruning_summary.png)
<p align="center"> Fig. 两种剪枝条件的作用范围 </p>
<p align="center"> 图:两种剪枝条件的作用范围 </p>

View file

@ -438,7 +438,7 @@ comments: true
![子集搜索与越界剪枝](subset_sum_problem.assets/subset_sum_i_naive.png)
<p align="center"> Fig. 子集搜索与越界剪枝 </p>
<p align="center"> 图:子集搜索与越界剪枝 </p>
为了去除重复子集,**一种直接的思路是对结果列表进行去重**。但这个方法效率很低,因为:
@ -460,7 +460,7 @@ comments: true
![不同选择顺序导致的重复子集](subset_sum_problem.assets/subset_sum_i_pruning.png)
<p align="center"> Fig. 不同选择顺序导致的重复子集 </p>
<p align="center"> 图:不同选择顺序导致的重复子集 </p>
总结来看,给定输入数组 $[x_1, x_2, \cdots, x_n]$ ,设搜索过程中的选择序列为 $[x_{i_1}, x_{i_2}, \cdots , x_{i_m}]$ ,则该选择序列需要满足 $i_1 \leq i_2 \leq \cdots \leq i_m$ **不满足该条件的选择序列都会造成重复,应当剪枝**。
@ -916,7 +916,7 @@ comments: true
![子集和 I 回溯过程](subset_sum_problem.assets/subset_sum_i.png)
<p align="center"> Fig. 子集和 I 回溯过程 </p>
<p align="center"> 图:子集和 I 回溯过程 </p>
## 13.3.2. &nbsp; 考虑重复元素的情况
@ -930,7 +930,7 @@ comments: true
![相等元素导致的重复子集](subset_sum_problem.assets/subset_sum_ii_repeat.png)
<p align="center"> Fig. 相等元素导致的重复子集 </p>
<p align="center"> 图:相等元素导致的重复子集 </p>
### 相等元素剪枝
@ -1438,4 +1438,4 @@ comments: true
![子集和 II 回溯过程](subset_sum_problem.assets/subset_sum_ii.png)
<p align="center"> Fig. 子集和 II 回溯过程 </p>
<p align="center"> 图:子集和 II 回溯过程 </p>

View file

@ -11,10 +11,10 @@ comments: true
因此在能够解决问题的前提下,算法效率成为主要的评价维度,包括:
- **时间效率**,即算法运行速度的快慢。
- **空间效率**,即算法占用内存空间的大小。
- **时间效率**算法运行速度的快慢。
- **空间效率**算法占用内存空间的大小。
简而言之,**我们的目标是设计“既快又省”的数据结构与算法**。而有效地评估算法效率至关重要,因为只有了解评价标准,我们才能对比分析各种算法,从而指导算法设计与优化过程。
简而言之,**我们的目标是设计“既快又省”的数据结构与算法**。而有效地评估算法效率至关重要,因为只有这样我们才能将各种算法进行对比,从而指导算法设计与优化过程。
效率评估方法主要分为两种:实际测试和理论估算。
@ -32,11 +32,11 @@ comments: true
**复杂度分析评估的是算法运行效率随着输入数据量增多时的增长趋势**。这个定义有些拗口,我们可以将其分为三个重点来理解:
- “算法运行效率”可分为运行时间和占用空间两部分,与之对应地,复杂度可分为「时间复杂度 Time Complexity」和「空间复杂度 Space Complexity」。
- “随着输入数据量增多时”表示复杂度与输入数据量有关,反映了算法运行效率与输入数据量之间的关系。
- “增长趋势”表示复杂度分析关注的是算法时间与空间的增长趋势,而非具体的运行时间或占用空间。
1. “算法运行效率”可分为运行时间和占用空间两部分,与之对应地,复杂度可分为「时间复杂度 Time Complexity」和「空间复杂度 Space Complexity」。
2. “随着输入数据量增多时”意味着复杂度反映了算法运行效率与输入数据量之间的关系。
3. “增长趋势”表示复杂度分析关注的是算法时间与空间的增长趋势,而非具体的运行时间或占用空间。
**复杂度分析克服了实际测试方法的弊端**。首先,它独立于测试环境,因此分析结果适用于所有运行平台。其次,它可以体现不同数据量下的算法效率,尤其是在大数据量下的算法性能。
**复杂度分析克服了实际测试方法的弊端**。首先,它独立于测试环境,分析结果适用于所有运行平台。其次,它可以体现不同数据量下的算法效率,尤其是在大数据量下的算法性能。
如果你对复杂度分析的概念仍感到困惑,无需担心,我们会在后续章节详细介绍。

View file

@ -26,7 +26,7 @@ comments: true
![算法使用的相关空间](space_complexity.assets/space_types.png)
<p align="center"> Fig. 算法使用的相关空间 </p>
<p align="center"> 图:算法使用的相关空间 </p>
=== "Java"
@ -85,7 +85,7 @@ comments: true
"""类"""
def __init__(self, x: int):
self.val: int = x # 节点值
self.next: Optional[Node] = None # 指向下一节点的指针(引用
self.next: Optional[Node] = None # 指向下一节点的引用
def function() -> int:
"""函数"""
@ -669,7 +669,7 @@ $$
![空间复杂度的常见类型](space_complexity.assets/space_complexity_common_types.png)
<p align="center"> Fig. 空间复杂度的常见类型 </p>
<p align="center"> 图:空间复杂度的常见类型 </p>
!!! tip
@ -967,8 +967,8 @@ $$
// 常量、变量、对象占用 O(1) 空间
final int a = 0;
int b = 0;
List<int> nums = List.filled(10000, 0);
ListNode node = ListNode(0);
// 循环中的变量占用 O(1) 空间
for (var i = 0; i < n; i++) {
int c = 0;
@ -1417,7 +1417,7 @@ $$
![递归函数产生的线性阶空间复杂度](space_complexity.assets/space_complexity_recursive_linear.png)
<p align="center"> Fig. 递归函数产生的线性阶空间复杂度 </p>
<p align="center"> 图:递归函数产生的线性阶空间复杂度 </p>
### 平方阶 $O(n^2)$
@ -1605,7 +1605,6 @@ $$
List<List<int>> numMatrix = List.generate(n, (_) => List.filled(n, 0));
// 二维列表占用 O(n^2) 空间
List<List<int>> numList = [];
for (var i = 0; i < n; i++) {
List<int> tmp = [];
for (int j = 0; j < n; j++) {
@ -1797,7 +1796,7 @@ $$
![递归函数产生的平方阶空间复杂度](space_complexity.assets/space_complexity_recursive_quadratic.png)
<p align="center"> Fig. 递归函数产生的平方阶空间复杂度 </p>
<p align="center"> 图:递归函数产生的平方阶空间复杂度 </p>
### 指数阶 $O(2^n)$
@ -1969,7 +1968,7 @@ $$
![满二叉树产生的指数阶空间复杂度](space_complexity.assets/space_complexity_exponential.png)
<p align="center"> Fig. 满二叉树产生的指数阶空间复杂度 </p>
<p align="center"> 图:满二叉树产生的指数阶空间复杂度 </p>
### 对数阶 $O(\log n)$

View file

@ -436,7 +436,7 @@ $$
![算法 A, B, C 的时间增长趋势](time_complexity.assets/time_complexity_simple_example.png)
<p align="center"> Fig. 算法 A, B, C 的时间增长趋势 </p>
<p align="center"> 图:算法 A, B, C 的时间增长趋势 </p>
相较于直接统计算法运行时间,时间复杂度分析有哪些特点呢?
@ -634,7 +634,7 @@ $T(n)$ 是一次函数,说明时间的增长趋势是线性的,因此其时
![函数的渐近上界](time_complexity.assets/asymptotic_upper_bound.png)
<p align="center"> Fig. 函数的渐近上界 </p>
<p align="center"> 图:函数的渐近上界 </p>
也就是说,计算渐近上界就是寻找一个函数 $f(n)$ ,使得当 $n$ 趋向于无穷大时,$T(n)$ 和 $f(n)$ 处于相同的增长级别,仅相差一个常数项 $c$ 的倍数。
@ -906,7 +906,7 @@ $$
![时间复杂度的常见类型](time_complexity.assets/time_complexity_common_types.png)
<p align="center"> Fig. 时间复杂度的常见类型 </p>
<p align="center"> 图:时间复杂度的常见类型 </p>
!!! tip
@ -1600,7 +1600,7 @@ $$
![常数阶、线性阶、平方阶的时间复杂度](time_complexity.assets/time_complexity_constant_linear_quadratic.png)
<p align="center"> Fig. 常数阶、线性阶、平方阶的时间复杂度 </p>
<p align="center"> 图:常数阶、线性阶、平方阶的时间复杂度 </p>
以「冒泡排序」为例,外层循环执行 $n - 1$ 次,内层循环执行 $n-1, n-2, \cdots, 2, 1$ 次,平均为 $\frac{n}{2}$ 次,因此时间复杂度为 $O(n^2)$ 。
@ -2110,7 +2110,7 @@ $$
![指数阶的时间复杂度](time_complexity.assets/time_complexity_exponential.png)
<p align="center"> Fig. 指数阶的时间复杂度 </p>
<p align="center"> 图:指数阶的时间复杂度 </p>
在实际算法中,指数阶常出现于递归函数。例如以下代码,其递归地一分为二,经过 $n$ 次分裂后停止。
@ -2420,7 +2420,7 @@ $$
![对数阶的时间复杂度](time_complexity.assets/time_complexity_logarithmic.png)
<p align="center"> Fig. 对数阶的时间复杂度 </p>
<p align="center"> 图:对数阶的时间复杂度 </p>
与指数阶类似,对数阶也常出现于递归函数。以下代码形成了一个高度为 $\log_2 n$ 的递归树。
@ -2745,7 +2745,7 @@ $$
![线性对数阶的时间复杂度](time_complexity.assets/time_complexity_logarithmic_linear.png)
<p align="center"> Fig. 线性对数阶的时间复杂度 </p>
<p align="center"> 图:线性对数阶的时间复杂度 </p>
### 阶乘阶 $O(n!)$
@ -2947,7 +2947,7 @@ $$
![阶乘阶的时间复杂度](time_complexity.assets/time_complexity_factorial.png)
<p align="center"> Fig. 阶乘阶的时间复杂度 </p>
<p align="center"> 图:阶乘阶的时间复杂度 </p>
请注意,因为 $n! > 2^n$ ,所以阶乘阶比指数阶增长地更快,在 $n$ 较大时也是不可接受的。

View file

@ -12,7 +12,7 @@ comments: true
![ASCII 码](character_encoding.assets/ascii_table.png)
<p align="center"> Fig. ASCII 码 </p>
<p align="center"> 图:ASCII 码 </p>
然而,**ASCII 码仅能够表示英文**。随着计算机的全球化诞生了一种能够表示更多语言的字符集「EASCII」。它在 ASCII 的 7 位基础上扩展到 8 位,能够表示 256 个不同的字符。
@ -40,7 +40,7 @@ Unicode 是一种字符集标准,本质上是给每个字符分配一个编号
![Unicode 编码示例](character_encoding.assets/unicode_hello_algo.png)
<p align="center"> Fig. Unicode 编码示例 </p>
<p align="center"> 图:Unicode 编码示例 </p>
然而 ASCII 码已经向我们证明,编码英文只需要 1 字节。若采用上述方案,英文文本占用空间的大小将会是 ASCII 编码下大小的两倍,非常浪费内存空间。因此,我们需要一种更加高效的 Unicode 编码方法。
@ -61,7 +61,7 @@ UTF-8 的编码规则并不复杂,分为两种情况:
![UTF-8 编码示例](character_encoding.assets/utf-8_hello_algo.png)
<p align="center"> Fig. UTF-8 编码示例 </p>
<p align="center"> 图:UTF-8 编码示例 </p>
除了 UTF-8 之外,常见的编码方式还包括:

View file

@ -17,7 +17,7 @@ comments: true
![线性与非线性数据结构](classification_of_data_structure.assets/classification_logic_structure.png)
<p align="center"> Fig. 线性与非线性数据结构 </p>
<p align="center"> 图:线性与非线性数据结构 </p>
非线性数据结构可以进一步被划分为树形结构和网状结构。
@ -35,7 +35,7 @@ comments: true
![内存条、内存空间、内存地址](classification_of_data_structure.assets/computer_memory_location.png)
<p align="center"> Fig. 内存条、内存空间、内存地址 </p>
<p align="center"> 图:内存条、内存空间、内存地址 </p>
内存是所有程序的共享资源,当某块内存被某个程序占用时,则无法被其他程序同时使用了。**因此在数据结构与算法的设计中,内存资源是一个重要的考虑因素**。比如,算法所占用的内存峰值不应超过系统剩余空闲内存;如果缺少连续大块的内存空间,那么所选用的数据结构必须能够存储在离散的内存空间内。
@ -43,7 +43,7 @@ comments: true
![连续空间存储与离散空间存储](classification_of_data_structure.assets/classification_phisical_structure.png)
<p align="center"> Fig. 连续空间存储与离散空间存储 </p>
<p align="center"> 图:连续空间存储与离散空间存储 </p>
值得说明的是,**所有数据结构都是基于数组、链表或二者的组合实现的**。例如,栈和队列既可以使用数组实现,也可以使用链表实现;而哈希表的实现可能同时包含数组和链表。

View file

@ -20,7 +20,7 @@ comments: true
![原码、反码与补码之间的相互转换](number_encoding.assets/1s_2s_complement.png)
<p align="center"> Fig. 原码、反码与补码之间的相互转换 </p>
<p align="center"> 图:原码、反码与补码之间的相互转换 </p>
显然「原码」最为直观。但实际上,**数字是以「补码」的形式存储在计算机中的**。这是因为原码存在一些局限性。
@ -131,7 +131,7 @@ $$
![IEEE 754 标准下的 float 表示方式](number_encoding.assets/ieee_754_float.png)
<p align="center"> Fig. IEEE 754 标准下的 float 表示方式 </p>
<p align="center"> 图:IEEE 754 标准下的 float 表示方式 </p>
给定一个示例数据 $\mathrm{S} = 0$ $\mathrm{E} = 124$ $\mathrm{N} = 2^{-2} + 2^{-3} = 0.375$ ,则有:

View file

@ -43,7 +43,7 @@ status: new
![二分查找的分治过程](binary_search_recur.assets/binary_search_recur.png)
<p align="center"> Fig. 二分查找的分治过程 </p>
<p align="center"> 图:二分查找的分治过程 </p>
在实现代码中,我们声明一个递归函数 `dfs()` 来求解问题 $f(i, j)$ 。

View file

@ -11,7 +11,7 @@ status: new
![构建二叉树的示例数据](build_binary_tree_problem.assets/build_tree_example.png)
<p align="center"> Fig. 构建二叉树的示例数据 </p>
<p align="center"> 图:构建二叉树的示例数据 </p>
### 判断是否为分治问题
@ -38,7 +38,7 @@ status: new
![在前序和中序遍历中划分子树](build_binary_tree_problem.assets/build_tree_preorder_inorder_division.png)
<p align="center"> Fig. 在前序和中序遍历中划分子树 </p>
<p align="center"> 图:在前序和中序遍历中划分子树 </p>
### 基于变量描述子树区间
@ -64,7 +64,7 @@ status: new
![根节点和左右子树的索引区间表示](build_binary_tree_problem.assets/build_tree_division_pointers.png)
<p align="center"> Fig. 根节点和左右子树的索引区间表示 </p>
<p align="center"> 图:根节点和左右子树的索引区间表示 </p>
### 代码实现
@ -430,6 +430,8 @@ status: new
=== "<10>"
![built_tree_step10](build_binary_tree_problem.assets/built_tree_step10.png)
<p align="center"> 图:构建二叉树的递归过程 </p>
设树的节点数量为 $n$ ,初始化每一个节点(执行一个递归函数 `dfs()` )使用 $O(1)$ 时间。**因此总体时间复杂度为 $O(n)$** 。
哈希表存储 `inorder` 元素到索引的映射,空间复杂度为 $O(n)$ 。最差情况下,即二叉树退化为链表时,递归深度达到 $n$ ,使用 $O(n)$ 的栈帧空间。**因此总体空间复杂度为 $O(n)$** 。

View file

@ -17,7 +17,7 @@ status: new
![归并排序的分治策略](divide_and_conquer.assets/divide_and_conquer_merge_sort.png)
<p align="center"> Fig. 归并排序的分治策略 </p>
<p align="center"> 图:归并排序的分治策略 </p>
## 12.1.1. &nbsp; 如何判断分治问题
@ -49,7 +49,7 @@ $$
![划分数组前后的冒泡排序](divide_and_conquer.assets/divide_and_conquer_bubble_sort.png)
<p align="center"> Fig. 划分数组前后的冒泡排序 </p>
<p align="center"> 图:划分数组前后的冒泡排序 </p>
接下来,我们计算以下不等式,其左边和右边分别为划分前和划分后的操作总数:
@ -77,7 +77,7 @@ $$
![桶排序的并行计算](divide_and_conquer.assets/divide_and_conquer_parallel_computing.png)
<p align="center"> Fig. 桶排序的并行计算 </p>
<p align="center"> 图:桶排序的并行计算 </p>
## 12.1.3. &nbsp; 分治常见应用

View file

@ -17,7 +17,7 @@ status: new
![汉诺塔问题示例](hanota_problem.assets/hanota_example.png)
<p align="center"> Fig. 汉诺塔问题示例 </p>
<p align="center"> 图:汉诺塔问题示例 </p>
**我们将规模为 $i$ 的汉诺塔问题记做 $f(i)$** 。例如 $f(3)$ 代表将 $3$ 个圆盘从 `A` 移动至 `C` 的汉诺塔问题。
@ -31,6 +31,8 @@ status: new
=== "<2>"
![hanota_f1_step2](hanota_problem.assets/hanota_f1_step2.png)
<p align="center"> 图:规模为 1 问题的解 </p>
对于问题 $f(2)$ ,即当有两个圆盘时,**由于要时刻满足小圆盘在大圆盘之上,因此需要借助 `B` 来完成移动**,包括三步:
1. 先将上面的小圆盘从 `A` 移至 `B`
@ -51,6 +53,8 @@ status: new
=== "<4>"
![hanota_f2_step4](hanota_problem.assets/hanota_f2_step4.png)
<p align="center"> 图:规模为 2 问题的解 </p>
### 子问题分解
对于问题 $f(3)$ ,即当有三个圆盘时,情况变得稍微复杂了一些。由于已知 $f(1)$ 和 $f(2)$ 的解,因此可从分治角度思考,**将 `A` 顶部的两个圆盘看做一个整体**,执行以下步骤:
@ -73,6 +77,8 @@ status: new
=== "<4>"
![hanota_f3_step4](hanota_problem.assets/hanota_f3_step4.png)
<p align="center"> 图:规模为 3 问题的解 </p>
本质上看,**我们将问题 $f(3)$ 划分为两个子问题 $f(2)$ 和子问题 $f(1)$** 。按顺序解决这三个子问题之后,原问题随之得到解决。这说明子问题是独立的,而且解是可以合并的。
至此,我们可总结出汉诺塔问题的分治策略:将原问题 $f(n)$ 划分为两个子问题 $f(n-1)$ 和一个子问题 $f(1)$ 。子问题的解决顺序为:
@ -85,7 +91,7 @@ status: new
![汉诺塔问题的分治策略](hanota_problem.assets/hanota_divide_and_conquer.png)
<p align="center"> Fig. 汉诺塔问题的分治策略 </p>
<p align="center"> 图:汉诺塔问题的分治策略 </p>
### 代码实现
@ -431,7 +437,7 @@ status: new
![汉诺塔问题的递归树](hanota_problem.assets/hanota_recursive_tree.png)
<p align="center"> Fig. 汉诺塔问题的递归树 </p>
<p align="center"> 图:汉诺塔问题的递归树 </p>
!!! quote

View file

@ -25,7 +25,7 @@ status: new
![爬到第 3 阶的最小代价](dp_problem_features.assets/min_cost_cs_example.png)
<p align="center"> Fig. 爬到第 3 阶的最小代价 </p>
<p align="center"> 图:爬到第 3 阶的最小代价 </p>
设 $dp[i]$ 为爬到第 $i$ 阶累计付出的代价,由于第 $i$ 阶只可能从 $i - 1$ 阶或 $i - 2$ 阶走来,因此 $dp[i]$ 只可能等于 $dp[i - 1] + cost[i]$ 或 $dp[i - 2] + cost[i]$ 。为了尽可能减少代价,我们应该选择两者中较小的那一个,即:
@ -248,7 +248,7 @@ $$
![爬楼梯最小代价的动态规划过程](dp_problem_features.assets/min_cost_cs_dp.png)
<p align="center"> Fig. 爬楼梯最小代价的动态规划过程 </p>
<p align="center"> 图:爬楼梯最小代价的动态规划过程 </p>
本题也可以进行状态压缩,将一维压缩至零维,使得空间复杂度从 $O(n)$ 降低至 $O(1)$ 。
@ -447,7 +447,7 @@ $$
![带约束爬到第 3 阶的方案数量](dp_problem_features.assets/climbing_stairs_constraint_example.png)
<p align="center"> Fig. 带约束爬到第 3 阶的方案数量 </p>
<p align="center"> 图:带约束爬到第 3 阶的方案数量 </p>
在该问题中,如果上一轮是跳 $1$ 阶上来的,那么下一轮就必须跳 $2$ 阶。这意味着,**下一步选择不能由当前状态(当前楼梯阶数)独立决定,还和前一个状态(上轮楼梯阶数)有关**。
@ -469,7 +469,7 @@ $$
![考虑约束下的递推关系](dp_problem_features.assets/climbing_stairs_constraint_state_transfer.png)
<p align="center"> Fig. 考虑约束下的递推关系 </p>
<p align="center"> 图:考虑约束下的递推关系 </p>
最终,返回 $dp[n, 1] + dp[n, 2]$ 即可,两者之和代表爬到第 $n$ 阶的方案总数。

View file

@ -44,7 +44,7 @@ status: new
![最小路径和示例数据](dp_solution_pipeline.assets/min_path_sum_example.png)
<p align="center"> Fig. 最小路径和示例数据 </p>
<p align="center"> 图:最小路径和示例数据 </p>
**第一步:思考每轮的决策,定义状态,从而得到 $dp$ 表**
@ -56,7 +56,7 @@ status: new
![状态定义与 dp 表](dp_solution_pipeline.assets/min_path_sum_solution_step1.png)
<p align="center"> Fig. 状态定义与 dp 表 </p>
<p align="center"> 图:状态定义与 dp 表 </p>
!!! note
@ -76,7 +76,7 @@ $$
![最优子结构与状态转移方程](dp_solution_pipeline.assets/min_path_sum_solution_step2.png)
<p align="center"> Fig. 最优子结构与状态转移方程 </p>
<p align="center"> 图:最优子结构与状态转移方程 </p>
!!! note
@ -92,7 +92,7 @@ $$
![边界条件与状态转移顺序](dp_solution_pipeline.assets/min_path_sum_solution_step3.png)
<p align="center"> Fig. 边界条件与状态转移顺序 </p>
<p align="center"> 图:边界条件与状态转移顺序 </p>
!!! note
@ -322,7 +322,7 @@ $$
![暴力搜索递归树](dp_solution_pipeline.assets/min_path_sum_dfs.png)
<p align="center"> Fig. 暴力搜索递归树 </p>
<p align="center"> 图:暴力搜索递归树 </p>
每个状态都有向下和向右两种选择,从左上角走到右下角总共需要 $m + n - 2$ 步,所以最差时间复杂度为 $O(2^{m + n})$ 。请注意,这种计算方式未考虑临近网格边界的情况,当到达网络边界时只剩下一种选择。因此实际的路径数量会少一些。
@ -586,7 +586,7 @@ $$
![记忆化搜索递归树](dp_solution_pipeline.assets/min_path_sum_dfs_mem.png)
<p align="center"> Fig. 记忆化搜索递归树 </p>
<p align="center"> 图:记忆化搜索递归树 </p>
### 方法三:动态规划
@ -893,6 +893,8 @@ $$
=== "<12>"
![min_path_sum_dp_step12](dp_solution_pipeline.assets/min_path_sum_dp_step12.png)
<p align="center"> 图:最小路径和的动态规划过程 </p>
### 状态压缩
由于每个格子只与其左边和上边的格子有关,因此我们可以只用一个单行数组来实现 $dp$ 表。

View file

@ -17,7 +17,7 @@ status: new
![编辑距离的示例数据](edit_distance_problem.assets/edit_distance_example.png)
<p align="center"> Fig. 编辑距离的示例数据 </p>
<p align="center"> 图:编辑距离的示例数据 </p>
**编辑距离问题可以很自然地用决策树模型来解释**。字符串对应树节点,一轮决策(一次编辑操作)对应树的一条边。
@ -27,7 +27,7 @@ status: new
![基于决策树模型表示编辑距离问题](edit_distance_problem.assets/edit_distance_decision_tree.png)
<p align="center"> Fig. 基于决策树模型表示编辑距离问题 </p>
<p align="center"> 图:基于决策树模型表示编辑距离问题 </p>
**第一步:思考每轮的决策,定义状态,从而得到 $dp$ 表**
@ -54,7 +54,7 @@ status: new
![编辑距离的状态转移](edit_distance_problem.assets/edit_distance_state_transfer.png)
<p align="center"> Fig. 编辑距离的状态转移 </p>
<p align="center"> 图:编辑距离的状态转移 </p>
根据以上分析,可得最优子结构:$dp[i, j]$ 的最少编辑步数等于 $dp[i, j-1]$ , $dp[i-1, j]$ , $dp[i-1, j-1]$ 三者中的最少编辑步数,再加上本次的编辑步数 $1$ 。对应的状态转移方程为:
@ -411,6 +411,8 @@ $$
=== "<15>"
![edit_distance_dp_step15](edit_distance_problem.assets/edit_distance_dp_step15.png)
<p align="center"> 图:编辑距离的动态规划过程 </p>
### 状态压缩
由于 $dp[i,j]$ 是由上方 $dp[i-1, j]$ 、左方 $dp[i, j-1]$ 、左上方状态 $dp[i-1, j-1]$ 转移而来,而正序遍历会丢失左上方 $dp[i-1, j-1]$ ,倒序遍历无法提前构建 $dp[i, j-1]$ ,因此两种遍历顺序都不可取。

View file

@ -17,7 +17,7 @@ status: new
![爬到第 3 阶的方案数量](intro_to_dynamic_programming.assets/climbing_stairs_example.png)
<p align="center"> Fig. 爬到第 3 阶的方案数量 </p>
<p align="center"> 图:爬到第 3 阶的方案数量 </p>
本题的目标是求解方案数量,**我们可以考虑通过回溯来穷举所有可能性**。具体来说,将爬楼梯想象为一个多轮选择的过程:从地面出发,每轮选择上 $1$ 阶或 $2$ 阶,每当到达楼梯顶部时就将方案数量加 $1$ ,当越过楼梯顶部时就将其剪枝。
@ -149,7 +149,7 @@ status: new
// 当爬到第 n 阶时,方案数量加 1
if (state === n) res.set(0, res.get(0) + 1);
// 遍历所有选择
for (choice of choices) {
for (const choice of choices) {
// 剪枝:不允许越过第 n 阶
if (state + choice > n) break;
// 尝试:做出选择,更新状态
@ -182,7 +182,7 @@ status: new
// 当爬到第 n 阶时,方案数量加 1
if (state === n) res.set(0, res.get(0) + 1);
// 遍历所有选择
for (let choice of choices) {
for (const choice of choices) {
// 剪枝:不允许越过第 n 阶
if (state + choice > n) break;
// 尝试:做出选择,更新状态
@ -382,7 +382,7 @@ $$
![方案数量递推关系](intro_to_dynamic_programming.assets/climbing_stairs_state_transfer.png)
<p align="center"> Fig. 方案数量递推关系 </p>
<p align="center"> 图:方案数量递推关系 </p>
我们可以根据递推公式得到暴力搜索解法:
@ -609,7 +609,7 @@ $$
![爬楼梯对应递归树](intro_to_dynamic_programming.assets/climbing_stairs_dfs_tree.png)
<p align="center"> Fig. 爬楼梯对应递归树 </p>
<p align="center"> 图:爬楼梯对应递归树 </p>
观察上图发现,**指数阶的时间复杂度是由于「重叠子问题」导致的**。例如:$dp[9]$ 被分解为 $dp[8]$ 和 $dp[7]$ $dp[8]$ 被分解为 $dp[7]$ 和 $dp[6]$ ,两者都包含子问题 $dp[7]$ 。
@ -921,7 +921,7 @@ $$
![记忆化搜索对应递归树](intro_to_dynamic_programming.assets/climbing_stairs_dfs_memo_tree.png)
<p align="center"> Fig. 记忆化搜索对应递归树 </p>
<p align="center"> 图:记忆化搜索对应递归树 </p>
## 14.1.3. &nbsp; 方法三:动态规划
@ -1165,7 +1165,7 @@ $$
![爬楼梯的动态规划过程](intro_to_dynamic_programming.assets/climbing_stairs_dp.png)
<p align="center"> Fig. 爬楼梯的动态规划过程 </p>
<p align="center"> 图:爬楼梯的动态规划过程 </p>
## 14.1.4. &nbsp; 状态压缩

View file

@ -17,7 +17,7 @@ status: new
![0-1 背包的示例数据](knapsack_problem.assets/knapsack_example.png)
<p align="center"> Fig. 0-1 背包的示例数据 </p>
<p align="center"> 图:0-1 背包的示例数据 </p>
我们可以将 0-1 背包问题看作是一个由 $n$ 轮决策组成的过程,每个物体都有不放入和放入两种决策,因此该问题是满足决策树模型的。
@ -273,7 +273,7 @@ $$
![0-1 背包的暴力搜索递归树](knapsack_problem.assets/knapsack_dfs.png)
<p align="center"> Fig. 0-1 背包的暴力搜索递归树 </p>
<p align="center"> 图:0-1 背包的暴力搜索递归树 </p>
### 方法二:记忆化搜索
@ -539,7 +539,7 @@ $$
![0-1 背包的记忆化搜索递归树](knapsack_problem.assets/knapsack_dfs_mem.png)
<p align="center"> Fig. 0-1 背包的记忆化搜索递归树 </p>
<p align="center"> 图:0-1 背包的记忆化搜索递归树 </p>
### 方法三:动态规划
@ -822,6 +822,8 @@ $$
=== "<14>"
![knapsack_dp_step14](knapsack_problem.assets/knapsack_dp_step14.png)
<p align="center">0-1 背包的动态规划过程 </p>
### 状态压缩
由于每个状态都只与其上一行的状态有关,因此我们可以使用两个数组滚动前进,将空间复杂度从 $O(n^2)$ 将低至 $O(n)$ 。
@ -851,6 +853,8 @@ $$
=== "<6>"
![knapsack_dp_comp_step6](knapsack_problem.assets/knapsack_dp_comp_step6.png)
<p align="center">0-1 背包的状态压缩后的动态规划过程 </p>
在代码实现中,我们仅需将数组 `dp` 的第一维 $i$ 直接删除,并且把内循环更改为倒序遍历即可。
=== "Java"

View file

@ -15,7 +15,7 @@ status: new
![完全背包问题的示例数据](unbounded_knapsack_problem.assets/unbounded_knapsack_example.png)
<p align="center"> Fig. 完全背包问题的示例数据 </p>
<p align="center"> 图:完全背包问题的示例数据 </p>
完全背包和 0-1 背包问题非常相似,**区别仅在于不限制物品的选择次数**。
@ -294,6 +294,8 @@ $$
=== "<6>"
![unbounded_knapsack_dp_comp_step6](unbounded_knapsack_problem.assets/unbounded_knapsack_dp_comp_step6.png)
<p align="center"> 图:完全背包的状态压缩后的动态规划过程 </p>
代码实现比较简单,仅需将数组 `dp` 的第一维删除。
=== "Java"
@ -537,7 +539,7 @@ $$
![零钱兑换问题的示例数据](unbounded_knapsack_problem.assets/coin_change_example.png)
<p align="center"> Fig. 零钱兑换问题的示例数据 </p>
<p align="center"> 图:零钱兑换问题的示例数据 </p>
**零钱兑换可以看作是完全背包的一种特殊情况**,两者具有以下联系与不同点:
@ -907,6 +909,8 @@ $$
=== "<15>"
![coin_change_dp_step15](unbounded_knapsack_problem.assets/coin_change_dp_step15.png)
<p align="center"> 图:零钱兑换问题的动态规划过程 </p>
### 状态压缩
零钱兑换的状态压缩的处理方式和完全背包一致。
@ -1182,7 +1186,7 @@ $$
![零钱兑换问题 II 的示例数据](unbounded_knapsack_problem.assets/coin_change_ii_example.png)
<p align="center"> Fig. 零钱兑换问题 II 的示例数据 </p>
<p align="center"> 图:零钱兑换问题 II 的示例数据 </p>
相比于上一题,本题目标是组合数量,因此子问题变为:**前 $i$ 种硬币能够凑出金额 $a$ 的组合数量**。而 $dp$ 表仍然是尺寸为 $(n+1) \times (amt + 1)$ 的二维矩阵。

View file

@ -16,7 +16,7 @@ $$
![链表、树、图之间的关系](graph.assets/linkedlist_tree_graph.png)
<p align="center"> Fig. 链表、树、图之间的关系 </p>
<p align="center"> 图:链表、树、图之间的关系 </p>
那么,图与其他数据结构的关系是什么?如果我们把「顶点」看作节点,把「边」看作连接各个节点的指针,则可将「图」看作是一种从「链表」拓展而来的数据结构。**相较于线性关系(链表)和分治关系(树),网络关系(图)的自由度更高,从而更为复杂**。
@ -29,7 +29,7 @@ $$
![有向图与无向图](graph.assets/directed_graph.png)
<p align="center"> Fig. 有向图与无向图 </p>
<p align="center"> 图:有向图与无向图 </p>
根据所有顶点是否连通,可分为「连通图 Connected Graph」和「非连通图 Disconnected Graph」。
@ -38,13 +38,13 @@ $$
![连通图与非连通图](graph.assets/connected_graph.png)
<p align="center"> Fig. 连通图与非连通图 </p>
<p align="center"> 图:连通图与非连通图 </p>
我们还可以为边添加“权重”变量,从而得到「有权图 Weighted Graph」。例如在王者荣耀等手游中系统会根据共同游戏时间来计算玩家之间的“亲密度”这种亲密度网络就可以用有权图来表示。
![有权图与无权图](graph.assets/weighted_graph.png)
<p align="center"> Fig. 有权图与无权图 </p>
<p align="center"> 图:有权图与无权图 </p>
## 9.1.2. &nbsp; 图常用术语
@ -64,7 +64,7 @@ $$
![图的邻接矩阵表示](graph.assets/adjacency_matrix.png)
<p align="center"> Fig. 图的邻接矩阵表示 </p>
<p align="center"> 图:图的邻接矩阵表示 </p>
邻接矩阵具有以下特性:
@ -80,7 +80,7 @@ $$
![图的邻接表表示](graph.assets/adjacency_list.png)
<p align="center"> Fig. 图的邻接表表示 </p>
<p align="center"> 图:图的邻接表表示 </p>
邻接表仅存储实际存在的边,而边的总数通常远小于 $n^2$ ,因此它更加节省空间。然而,在邻接表中需要通过遍历链表来查找边,因此其时间效率不如邻接矩阵。

View file

@ -30,6 +30,8 @@ comments: true
=== "删除顶点"
![adjacency_matrix_remove_vertex](graph_operations.assets/adjacency_matrix_remove_vertex.png)
<p align="center"> 图:邻接矩阵的初始化、增删边、增删顶点 </p>
以下是基于邻接矩阵表示图的实现代码。
=== "Java"
@ -1147,6 +1149,8 @@ comments: true
=== "删除顶点"
![adjacency_list_remove_vertex](graph_operations.assets/adjacency_list_remove_vertex.png)
<p align="center"> 图:邻接表的初始化、增删边、增删顶点 </p>
以下是基于邻接表实现图的代码示例。细心的同学可能注意到,**我们在邻接表中使用 `Vertex` 节点类来表示顶点**,这样做的原因有:
- 如果我们选择通过顶点值来区分不同顶点,那么值重复的顶点将无法被区分。
@ -1533,7 +1537,7 @@ comments: true
// 在邻接表中删除顶点 vet 对应的链表
this.adjList.delete(vet);
// 遍历其他顶点的链表,删除所有包含 vet 的边
for (let set of this.adjList.values()) {
for (const set of this.adjList.values()) {
const index = set.indexOf(vet);
if (index > -1) {
set.splice(index, 1);
@ -1622,7 +1626,7 @@ comments: true
// 在邻接表中删除顶点 vet 对应的链表
this.adjList.delete(vet);
// 遍历其他顶点的链表,删除所有包含 vet 的边
for (let set of this.adjList.values()) {
for (const set of this.adjList.values()) {
const index: number = set.indexOf(vet);
if (index > -1) {
set.splice(index, 1);

View file

@ -18,7 +18,7 @@ comments: true
![图的广度优先遍历](graph_traversal.assets/graph_bfs.png)
<p align="center"> Fig. 图的广度优先遍历 </p>
<p align="center"> 图:图的广度优先遍历 </p>
### 算法实现
@ -427,6 +427,8 @@ BFS 通常借助「队列」来实现。队列具有“先入先出”的性质
=== "<11>"
![graph_bfs_step11](graph_traversal.assets/graph_bfs_step11.png)
<p align="center"> 图:图的广度优先遍历步骤 </p>
!!! question "广度优先遍历的序列是否唯一?"
不唯一。广度优先遍历只要求按“由近及远”的顺序遍历,**而多个相同距离的顶点的遍历顺序是允许被任意打乱的**。以上图为例,顶点 $1$ , $3$ 的访问顺序可以交换、顶点 $2$ , $4$ , $6$ 的访问顺序也可以任意交换。
@ -443,7 +445,7 @@ BFS 通常借助「队列」来实现。队列具有“先入先出”的性质
![图的深度优先遍历](graph_traversal.assets/graph_dfs.png)
<p align="center"> Fig. 图的深度优先遍历 </p>
<p align="center"> 图:图的深度优先遍历 </p>
### 算法实现
@ -835,6 +837,8 @@ BFS 通常借助「队列」来实现。队列具有“先入先出”的性质
=== "<11>"
![graph_dfs_step11](graph_traversal.assets/graph_dfs_step11.png)
<p align="center"> 图:图的深度优先遍历步骤 </p>
!!! question "深度优先遍历的序列是否唯一?"
与广度优先遍历类似,深度优先遍历序列的顺序也不是唯一的。给定某顶点,先往哪个方向探索都可以,即邻接顶点的顺序可以任意打乱,都是深度优先遍历。

View file

@ -13,7 +13,7 @@ status: new
![分数背包问题的示例数据](fractional_knapsack_problem.assets/fractional_knapsack_example.png)
<p align="center"> Fig. 分数背包问题的示例数据 </p>
<p align="center"> 图:分数背包问题的示例数据 </p>
本题和 0-1 背包整体上非常相似,状态包含当前物品 $i$ 和容量 $c$ ,目标是求不超过背包容量下的最大价值。
@ -24,7 +24,7 @@ status: new
![物品在单位重量下的价值](fractional_knapsack_problem.assets/fractional_knapsack_unit_value.png)
<p align="center"> Fig. 物品在单位重量下的价值 </p>
<p align="center"> 图:物品在单位重量下的价值 </p>
### 贪心策略确定
@ -36,7 +36,7 @@ status: new
![分数背包的贪心策略](fractional_knapsack_problem.assets/fractional_knapsack_greedy_strategy.png)
<p align="center"> Fig. 分数背包的贪心策略 </p>
<p align="center"> 图:分数背包的贪心策略 </p>
### 代码实现
@ -373,4 +373,4 @@ status: new
![分数背包问题的几何表示](fractional_knapsack_problem.assets/fractional_knapsack_area_chart.png)
<p align="center"> Fig. 分数背包问题的几何表示 </p>
<p align="center"> 图:分数背包问题的几何表示 </p>

View file

@ -22,7 +22,7 @@ status: new
![零钱兑换的贪心策略](greedy_algorithm.assets/coin_change_greedy_strategy.png)
<p align="center"> Fig. 零钱兑换的贪心策略 </p>
<p align="center"> 图:零钱兑换的贪心策略 </p>
实现代码如下所示。你可能会不由地发出感叹So Clean !贪心算法仅用十行代码就解决了零钱兑换问题。
@ -233,7 +233,7 @@ status: new
![贪心无法找出最优解的示例](greedy_algorithm.assets/coin_change_greedy_vs_dp.png)
<p align="center"> Fig. 贪心无法找出最优解的示例 </p>
<p align="center"> 图:贪心无法找出最优解的示例 </p>
也就是说,对于零钱兑换问题,贪心算法无法保证找到全局最优解,并且有可能找到非常差的解。它更适合用动态规划解决。

View file

@ -15,7 +15,7 @@ status: new
![最大容量问题的示例数据](max_capacity_problem.assets/max_capacity_example.png)
<p align="center"> Fig. 最大容量问题的示例数据 </p>
<p align="center"> 图:最大容量问题的示例数据 </p>
容器由任意两个隔板围成,**因此本题的状态为两个隔板的索引,记为 $[i, j]$** 。
@ -33,7 +33,7 @@ $$
![初始状态](max_capacity_problem.assets/max_capacity_initial_state.png)
<p align="center"> Fig. 初始状态 </p>
<p align="center"> 图:初始状态 </p>
我们发现,**如果此时将长板 $j$ 向短板 $i$ 靠近,则容量一定变小**。这是因为在移动长板 $j$ 后:
@ -42,13 +42,13 @@ $$
![向内移动长板后的状态](max_capacity_problem.assets/max_capacity_moving_long_board.png)
<p align="center"> Fig. 向内移动长板后的状态 </p>
<p align="center"> 图:向内移动长板后的状态 </p>
反向思考,**我们只有向内收缩短板 $i$ ,才有可能使容量变大**。因为虽然宽度一定变小,**但高度可能会变大**(移动后的短板 $i$ 可能会变长)。
![向内移动长板后的状态](max_capacity_problem.assets/max_capacity_moving_short_board.png)
<p align="center"> Fig. 向内移动长板后的状态 </p>
<p align="center"> 图:向内移动长板后的状态 </p>
由此便可推出本题的贪心策略:
@ -84,6 +84,8 @@ $$
=== "<9>"
![max_capacity_greedy_step9](max_capacity_problem.assets/max_capacity_greedy_step9.png)
<p align="center"> 图:最大容量问题的贪心过程 </p>
### 代码实现
代码循环最多 $n$ 轮,**因此时间复杂度为 $O(n)$** 。
@ -305,7 +307,7 @@ $$
![移动短板导致被跳过的状态](max_capacity_problem.assets/max_capacity_skipped_states.png)
<p align="center"> Fig. 移动短板导致被跳过的状态 </p>
<p align="center"> 图:移动短板导致被跳过的状态 </p>
观察发现,**这些被跳过的状态实际上就是将长板 $j$ 向内移动的所有状态**。而在第二步中,我们已经证明内移长板一定会导致容量变小。也就是说,被跳过的状态都不可能是最优解,**跳过它们不会导致错过最优解**。

View file

@ -11,7 +11,7 @@ status: new
![最大切分乘积的问题定义](max_product_cutting_problem.assets/max_product_cutting_definition.png)
<p align="center"> Fig. 最大切分乘积的问题定义 </p>
<p align="center"> 图:最大切分乘积的问题定义 </p>
假设我们将 $n$ 切分为 $m$ 个整数因子,其中第 $i$ 个因子记为 $n_i$ ,即
@ -45,7 +45,7 @@ $$
![切分导致乘积变大](max_product_cutting_problem.assets/max_product_cutting_greedy_infer1.png)
<p align="center"> Fig. 切分导致乘积变大 </p>
<p align="center"> 图:切分导致乘积变大 </p>
接下来思考哪个因子是最优的。在 $1$ , $2$ , $3$ 这三个因子中,显然 $1$ 是最差的,因为 $1 \times (n-1) < n$ 恒成立即切分出 $1$ 反而会导致乘积减小
@ -55,7 +55,7 @@ $$
![最优切分因子](max_product_cutting_problem.assets/max_product_cutting_greedy_infer3.png)
<p align="center"> Fig. 最优切分因子 </p>
<p align="center"> 图:最优切分因子 </p>
总结以上,可推出贪心策略:
@ -276,7 +276,7 @@ $$
![最大切分乘积的计算方法](max_product_cutting_problem.assets/max_product_cutting_greedy_calculation.png)
<p align="center"> Fig. 最大切分乘积的计算方法 </p>
<p align="center"> 图:最大切分乘积的计算方法 </p>
**时间复杂度取决于编程语言的幂运算的实现方法**。以 Python 为例,常用的幂计算函数有三种:

View file

@ -10,7 +10,7 @@ comments: true
![哈希冲突的最佳与最差情况](hash_algorithm.assets/hash_collision_best_worst_condition.png)
<p align="center"> Fig. 哈希冲突的最佳与最差情况 </p>
<p align="center"> 图:哈希冲突的最佳与最差情况 </p>
**键值对的分布情况由哈希函数决定**。回忆哈希函数的计算步骤,先计算哈希值,再对数组长度取模:
@ -439,13 +439,45 @@ index = hash(key) % capacity
=== "Dart"
```dart title="simple_hash.dart"
[class]{}-[func]{add_hash}
/* 加法哈希 */
int addHash(String key) {
int hash = 0;
final int MODULUS = 1000000007;
for (int i = 0; i < key.length; i++) {
hash = (hash + key.codeUnitAt(i)) % MODULUS;
}
return hash;
}
[class]{}-[func]{mul_hash}
/* 乘法哈希 */
int mulHash(String key) {
int hash = 0;
final int MODULUS = 1000000007;
for (int i = 0; i < key.length; i++) {
hash = (31 * hash + key.codeUnitAt(i)) % MODULUS;
}
return hash;
}
[class]{}-[func]{xor_hash}
/* 异或哈希 */
int xorHash(String key) {
int hash = 0;
final int MODULUS = 1000000007;
for (int i = 0; i < key.length; i++) {
hash ^= key.codeUnitAt(i);
}
return hash & MODULUS;
}
[class]{}-[func]{rot_hash}
/* 旋转哈希 */
int rotHash(String key) {
int hash = 0;
final int MODULUS = 1000000007;
for (int i = 0; i < key.length; i++) {
hash = ((hash << 4) ^ (hash >> 28) ^ key.codeUnitAt(i)) % MODULUS;
}
return hash;
}
```
=== "Rust"
@ -584,7 +616,7 @@ $$
# 布尔量 True 的哈希值为 1
dec = 3.14159
hash_dec = hash(dec)
hash_dec = hash(dec)
# 小数 3.14159 的哈希值为 326484311674566659
str = "Hello 算法"
@ -692,23 +724,23 @@ $$
int num = 3;
int hashNum = num.hashCode;
// 整数 3 的哈希值为 34803
bool bol = true;
int hashBol = bol.hashCode;
// 布尔值 true 的哈希值为 1231
double dec = 3.14159;
int hashDec = dec.hashCode;
// 小数 3.14159 的哈希值为 2570631074981783
String str = "Hello 算法";
int hashStr = str.hashCode;
// 字符串 Hello 算法 的哈希值为 468167534
List arr = [12836, "小哈"];
int hashArr = arr.hashCode;
// 数组 [12836, 小哈] 的哈希值为 976512528
ListNode obj = new ListNode(0);
int hashObj = obj.hashCode;
// 节点对象 Instance of 'ListNode' 的哈希值为 1033450432

View file

@ -19,7 +19,7 @@ comments: true
![链式地址哈希表](hash_collision.assets/hash_table_chaining.png)
<p align="center"> Fig. 链式地址哈希表 </p>
<p align="center"> 图:链式地址哈希表 </p>
链式地址下,哈希表的操作方法包括:
@ -542,7 +542,7 @@ comments: true
for (let i = 0; i < bucket.length; i++) {
if (bucket[i].key === key) {
bucket.splice(i, 1);
this.size--;
this.#size--;
break;
}
}
@ -1165,7 +1165,7 @@ comments: true
![线性探测](hash_collision.assets/hash_table_linear_probing.png)
<p align="center"> Fig. 线性探测 </p>
<p align="center"> 图:线性探测 </p>
然而,线性探测存在以下缺陷:

View file

@ -10,7 +10,7 @@ comments: true
![哈希表的抽象表示](hash_map.assets/hash_table_lookup.png)
<p align="center"> Fig. 哈希表的抽象表示 </p>
<p align="center"> 图:哈希表的抽象表示 </p>
除哈希表外,我们还可以使用数组或链表实现查询功能。若将学生数据看作数组(链表)元素,则有:
@ -464,7 +464,7 @@ index = hash(key) % capacity
![哈希函数工作原理](hash_map.assets/hash_function.png)
<p align="center"> Fig. 哈希函数工作原理 </p>
<p align="center"> 图:哈希函数工作原理 </p>
以下代码实现了一个简单哈希表。其中,我们将 `key``value` 封装成一个类 `Pair` ,以表示键值对。
@ -1501,13 +1501,13 @@ index = hash(key) % capacity
![哈希冲突示例](hash_map.assets/hash_collision.png)
<p align="center"> Fig. 哈希冲突示例 </p>
<p align="center"> 图:哈希冲突示例 </p>
容易想到,哈希表容量 $n$ 越大,多个 `key` 被分配到同一个桶中的概率就越低,冲突就越少。因此,**我们可以通过扩容哈希表来减少哈希冲突**。如下图所示,扩容前键值对 `(136, A)``(236, D)` 发生冲突,扩容后冲突消失。
![哈希表扩容](hash_map.assets/hash_table_reshash.png)
<p align="center"> Fig. 哈希表扩容 </p>
<p align="center"> 图:哈希表扩容 </p>
类似于数组扩容,哈希表扩容需将所有键值对从原哈希表迁移至新哈希表,非常耗时。并且由于哈希表容量 `capacity` 改变,我们需要通过哈希函数来重新计算所有键值对的存储位置,这进一步提高了扩容过程的计算开销。为此,编程语言通常会预留足够大的哈希表容量,防止频繁扩容。

View file

@ -171,7 +171,7 @@ comments: true
_maxHeap = nums;
// 堆化除叶节点以外的其他所有节点
for (int i = _parent(size() - 1); i >= 0; i--) {
_siftDown(i);
siftDown(i);
}
}
```
@ -204,7 +204,7 @@ comments: true
![完美二叉树的各层节点数量](build_heap.assets/heapify_operations_count.png)
<p align="center"> Fig. 完美二叉树的各层节点数量 </p>
<p align="center"> 图:完美二叉树的各层节点数量 </p>
因此,我们可以将各层的“节点数量 $\times$ 节点高度”求和,**从而得到所有节点的堆化迭代次数的总和**。

View file

@ -11,7 +11,7 @@ comments: true
![小顶堆与大顶堆](heap.assets/min_heap_and_max_heap.png)
<p align="center"> Fig. 小顶堆与大顶堆 </p>
<p align="center"> 图:小顶堆与大顶堆 </p>
堆作为完全二叉树的一个特例,具有以下特性:
@ -333,7 +333,7 @@ comments: true
![堆的表示与存储](heap.assets/representation_of_heap.png)
<p align="center"> Fig. 堆的表示与存储 </p>
<p align="center"> 图:堆的表示与存储 </p>
我们可以将索引映射公式封装成函数,方便后续使用。
@ -708,6 +708,8 @@ comments: true
=== "<9>"
![heap_push_step9](heap.assets/heap_push_step9.png)
<p align="center"> 图:元素入堆步骤 </p>
设节点总数为 $n$ ,则树的高度为 $O(\log n)$ 。由此可知,堆化操作的循环轮数最多为 $O(\log n)$ **元素入堆操作的时间复杂度为 $O(\log n)$** 。
=== "Java"
@ -994,10 +996,24 @@ comments: true
// 添加节点
_maxHeap.add(val);
// 从底至顶堆化
_siftUp(size() - 1);
siftUp(size() - 1);
}
[class]{MaxHeap}-[func]{siftUp}
/* 从节点 i 开始,从底至顶堆化 */
void siftUp(int i) {
while (true) {
// 获取节点 i 的父节点
int p = _parent(i);
// 当“越过根节点”或“节点无需修复”时,结束堆化
if (p < 0 || _maxHeap[i] <= _maxHeap[p]) {
break;
}
// 交换两节点
_swap(i, p);
// 循环向上堆化
i = p;
}
}
```
=== "Rust"
@ -1072,6 +1088,8 @@ comments: true
=== "<10>"
![heap_pop_step10](heap.assets/heap_pop_step10.png)
<p align="center"> 图:堆顶元素出堆步骤 </p>
与元素入堆操作相似,堆顶元素出堆操作的时间复杂度也为 $O(\log n)$ 。
=== "Java"
@ -1480,12 +1498,28 @@ comments: true
// 删除节点
int val = _maxHeap.removeLast();
// 从顶至底堆化
_siftDown(0);
siftDown(0);
// 返回堆顶元素
return val;
}
[class]{MaxHeap}-[func]{siftDown}
/* 从节点 i 开始,从顶至底堆化 */
void siftDown(int i) {
while (true) {
// 判断节点 i, l, r 中值最大的节点,记为 ma
int l = _left(i);
int r = _right(i);
int ma = i;
if (l < size() && _maxHeap[l] > _maxHeap[ma]) ma = l;
if (r < size() && _maxHeap[r] > _maxHeap[ma]) ma = r;
// 若节点 i 最大或索引 l, r 越界,则无需继续堆化,跳出
if (ma == i) break;
// 交换两节点
_swap(i, ma);
// 循环向下堆化
i = ma;
}
}
```
=== "Rust"

View file

@ -18,11 +18,11 @@ comments: true
![遍历寻找最大的 k 个元素](top_k.assets/top_k_traversal.png)
<p align="center"> Fig. 遍历寻找最大的 k 个元素 </p>
<p align="center"> 图:遍历寻找最大的 k 个元素 </p>
!!! tip
当 $k = n$ 时,我们可以得到从大到小的序列,等价于「选择排序」算法。
当 $k = n$ 时,我们可以得到从大到小的序列,等价于「选择排序」算法。
## 8.3.2. &nbsp; 方法二:排序
@ -32,7 +32,7 @@ comments: true
![排序寻找最大的 k 个元素](top_k.assets/top_k_sorting.png)
<p align="center"> Fig. 排序寻找最大的 k 个元素 </p>
<p align="center"> 图:排序寻找最大的 k 个元素 </p>
## 8.3.3. &nbsp; 方法三:堆
@ -70,6 +70,8 @@ comments: true
=== "<9>"
![top_k_heap_step9](top_k.assets/top_k_heap_step9.png)
<p align="center"> 图:基于堆寻找最大的 k 个元素 </p>
总共执行了 $n$ 轮入堆和出堆,堆的最大长度为 $k$ ,因此时间复杂度为 $O(n \log k)$ 。该方法的效率很高,当 $k$ 较小时,时间复杂度趋向 $O(n)$ ;当 $k$ 较大时,时间复杂度不会超过 $O(n \log n)$ 。
另外,该方法适用于动态数据流的使用场景。在不断加入数据时,我们可以持续维护堆内的元素,从而实现最大 $k$ 个元素的动态更新。
@ -227,7 +229,20 @@ comments: true
=== "Dart"
```dart title="top_k.dart"
[class]{}-[func]{top_k_heap}
/* 基于堆查找数组中最大的 k 个元素 */
MinHeap topKHeap(List<int> nums, int k) {
// 将数组的前 k 个元素入堆
MinHeap heap = MinHeap(nums.sublist(0, k));
// 从第 k+1 个元素开始,保持堆的长度为 k
for (int i = k; i < nums.length; i++) {
// 若当前元素大于堆顶元素,则将堆顶元素出堆、当前元素入堆
if (nums[i] > heap.peek()) {
heap.pop();
heap.push(nums[i]);
}
}
return heap;
}
```
=== "Rust"

View file

@ -29,6 +29,8 @@ comments: true
=== "<5>"
![binary_search_dictionary_step_5](algorithms_are_everywhere.assets/binary_search_dictionary_step_5.png)
<p align="center"> 图:查字典步骤 </p>
查阅字典这个小学生必备技能,实际上就是著名的「二分查找」。从数据结构的角度,我们可以把字典视为一个已排序的「数组」;从算法的角度,我们可以将上述查字典的一系列操作看作是「二分查找」算法。
**例二:整理扑克**。我们在打牌时,每局都需要整理扑克牌,使其从小到大排列,实现流程如下:
@ -39,7 +41,7 @@ comments: true
![扑克排序步骤](algorithms_are_everywhere.assets/playing_cards_sorting.png)
<p align="center"> Fig. 扑克排序步骤 </p>
<p align="center"> 图:扑克排序步骤 </p>
上述整理扑克牌的方法本质上是「插入排序」算法,它在处理小型数据集时非常高效。许多编程语言的排序库函数中都存在插入排序的身影。
@ -53,7 +55,7 @@ comments: true
![货币找零过程](algorithms_are_everywhere.assets/greedy_change.png)
<p align="center"> Fig. 货币找零过程 </p>
<p align="center"> 图:货币找零过程 </p>
在以上步骤中,我们每一步都采取当前看来最好的选择(尽可能用大面额的货币),最终得到了可行的找零方案。从数据结构与算法的角度看,这种方法本质上是「贪心算法」。

View file

@ -35,13 +35,13 @@ comments: true
![数据结构与算法的关系](what_is_dsa.assets/relationship_between_data_structure_and_algorithm.png)
<p align="center"> Fig. 数据结构与算法的关系 </p>
<p align="center"> 图:数据结构与算法的关系 </p>
数据结构与算法犹如拼装积木。一套积木,除了包含许多零件之外,还附有详细的组装说明书。我们按照说明书一步步操作,就能组装出精美的积木模型。
![拼装积木](what_is_dsa.assets/assembling_blocks.jpg)
<p align="center"> Fig. 拼装积木 </p>
<p align="center"> 图:拼装积木 </p>
两者的详细对应关系如下表所示。

View file

@ -32,7 +32,7 @@ comments: true
![Hello 算法内容结构](about_the_book.assets/hello_algo_mindmap.png)
<p align="center"> Fig. Hello 算法内容结构 </p>
<p align="center"> 图:Hello 算法内容结构 </p>
## 0.1.3. &nbsp; 致谢

View file

@ -172,7 +172,7 @@ comments: true
![动画图解示例](../index.assets/animation.gif)
<p align="center"> Fig. 动画图解示例 </p>
<p align="center"> 图:动画图解示例 </p>
## 0.2.3. &nbsp; 在代码实践中加深理解
@ -184,7 +184,7 @@ comments: true
![运行代码示例](../index.assets/running_code.gif)
<p align="center"> Fig. 运行代码示例 </p>
<p align="center"> 图:运行代码示例 </p>
**第一步:安装本地编程环境**。请参照[附录教程](https://www.hello-algo.com/chapter_appendix/installation/)进行安装,如果已安装则可跳过此步骤。
@ -198,13 +198,13 @@ git clone https://github.com/krahets/hello-algo.git
![克隆仓库与下载代码](suggestions.assets/download_code.png)
<p align="center"> Fig. 克隆仓库与下载代码 </p>
<p align="center"> 图:克隆仓库与下载代码 </p>
**第三步:运行源代码**。如果代码块顶部标有文件名称,则可以在仓库的 `codes` 文件夹中找到相应的源代码文件。源代码文件将帮助你节省不必要的调试时间,让你能够专注于学习内容。
![代码块与对应的源代码文件](suggestions.assets/code_md_to_repo.png)
<p align="center"> Fig. 代码块与对应的源代码文件 </p>
<p align="center"> 图:代码块与对应的源代码文件 </p>
## 0.2.4. &nbsp; 在提问讨论中共同成长
@ -214,7 +214,7 @@ git clone https://github.com/krahets/hello-algo.git
![评论区示例](../index.assets/comment.gif)
<p align="center"> Fig. 评论区示例 </p>
<p align="center"> 图:评论区示例 </p>
## 0.2.5. &nbsp; 算法学习路线
@ -228,4 +228,4 @@ git clone https://github.com/krahets/hello-algo.git
![算法学习路线](suggestions.assets/learning_route.png)
<p align="center"> Fig. 算法学习路线 </p>
<p align="center"> 图:算法学习路线 </p>

View file

@ -12,7 +12,7 @@ comments: true
![二分查找示例数据](binary_search.assets/binary_search_example.png)
<p align="center"> Fig. 二分查找示例数据 </p>
<p align="center"> 图:二分查找示例数据 </p>
对于上述问题,我们先初始化指针 $i = 0$ 和 $j = n - 1$ ,分别指向数组首元素和尾元素,代表搜索区间 $[0, n - 1]$ 。请注意,中括号表示闭区间,其包含边界值本身。
@ -47,6 +47,8 @@ comments: true
=== "<7>"
![binary_search_step7](binary_search.assets/binary_search_step7.png)
<p align="center">binary_search_step1 </p>
值得注意的是,由于 $i$ 和 $j$ 都是 `int` 类型,**因此 $i + j$ 可能会超出 `int` 类型的取值范围**。为了避免大数越界,我们通常采用公式 $m = \lfloor {i + (j - i) / 2} \rfloor$ 来计算中点。
=== "Java"
@ -627,7 +629,7 @@ comments: true
![两种区间定义](binary_search.assets/binary_search_ranges.png)
<p align="center"> Fig. 两种区间定义 </p>
<p align="center"> 图:两种区间定义 </p>
## 10.1.2. &nbsp; 优点与局限性

View file

@ -164,7 +164,7 @@ status: new
![将查找右边界转化为查找左边界](binary_search_edge.assets/binary_search_right_edge_by_left_edge.png)
<p align="center"> Fig. 将查找右边界转化为查找左边界 </p>
<p align="center"> 图:将查找右边界转化为查找左边界 </p>
请注意,返回的插入点是 $i$ ,因此需要将其减 $1$ ,从而获得 $j$ 。
@ -321,7 +321,7 @@ status: new
![将查找边界转化为查找元素](binary_search_edge.assets/binary_search_edge_by_element.png)
<p align="center"> Fig. 将查找边界转化为查找元素 </p>
<p align="center"> 图:将查找边界转化为查找元素 </p>
代码在此省略,值得注意的有:

View file

@ -15,7 +15,7 @@ status: new
![二分查找插入点示例数据](binary_search_insertion.assets/binary_search_insertion_example.png)
<p align="center"> Fig. 二分查找插入点示例数据 </p>
<p align="center"> 图:二分查找插入点示例数据 </p>
如果想要复用上节的二分查找代码,则需要回答以下两个问题。
@ -203,7 +203,7 @@ status: new
![线性查找重复元素的插入点](binary_search_insertion.assets/binary_search_insertion_naive.png)
<p align="center"> Fig. 线性查找重复元素的插入点 </p>
<p align="center"> 图:线性查找重复元素的插入点 </p>
此方法虽然可用,但其包含线性查找,因此时间复杂度为 $O(n)$ 。当数组中存在很多重复的 `target` 时,该方法效率很低。
@ -238,6 +238,8 @@ status: new
=== "<8>"
![binary_search_insertion_step8](binary_search_insertion.assets/binary_search_insertion_step8.png)
<p align="center"> 图:二分查找重复元素的插入点的步骤 </p>
观察以下代码,判断分支 `nums[m] > target``nums[m] == target` 的操作相同,因此两者可以合并。
即便如此,我们仍然可以将判断条件保持展开,因为其逻辑更加清晰、可读性更好。

View file

@ -16,7 +16,7 @@ comments: true
![线性查找求解两数之和](replace_linear_by_hashing.assets/two_sum_brute_force.png)
<p align="center"> Fig. 线性查找求解两数之和 </p>
<p align="center"> 图:线性查找求解两数之和 </p>
=== "Java"
@ -199,6 +199,7 @@ comments: true
/* 方法一: 暴力枚举 */
List<int> twoSumBruteForce(List<int> nums, int target) {
int size = nums.length;
// 两层循环,时间复杂度 O(n^2)
for (var i = 0; i < size - 1; i++) {
for (var j = i + 1; j < size; j++) {
if (nums[i] + nums[j] == target) return [i, j];
@ -244,6 +245,8 @@ comments: true
=== "<3>"
![two_sum_hashtable_step3](replace_linear_by_hashing.assets/two_sum_hashtable_step3.png)
<p align="center"> 图:辅助哈希表求解两数之和 </p>
实现代码如下所示,仅需单层循环即可。
=== "Java"
@ -469,7 +472,9 @@ comments: true
/* 方法二: 辅助哈希表 */
List<int> twoSumHashTable(List<int> nums, int target) {
int size = nums.length;
// 辅助哈希表,空间复杂度 O(n)
Map<int, int> dic = HashMap();
// 单层循环,时间复杂度 O(n)
for (var i = 0; i < size; i++) {
if (dic.containsKey(target - nums[i])) {
return [dic[target - nums[i]]!, i];

View file

@ -46,7 +46,7 @@ comments: true
![多种搜索策略](searching_algorithm_revisited.assets/searching_algorithms.png)
<p align="center"> Fig. 多种搜索策略 </p>
<p align="center"> 图:多种搜索策略 </p>
上述几种方法的操作效率与特性如下表所示。

View file

@ -29,6 +29,8 @@ comments: true
=== "<7>"
![bubble_operation_step7](bubble_sort.assets/bubble_operation_step7.png)
<p align="center"> 图:利用元素交换操作模拟冒泡 </p>
## 11.3.1. &nbsp; 算法流程
设数组的长度为 $n$ ,冒泡排序的步骤为:
@ -40,7 +42,7 @@ comments: true
![冒泡排序流程](bubble_sort.assets/bubble_sort_overview.png)
<p align="center"> Fig. 冒泡排序流程 </p>
<p align="center"> 图:冒泡排序流程 </p>
=== "Java"

View file

@ -18,7 +18,7 @@ comments: true
![桶排序算法流程](bucket_sort.assets/bucket_sort_overview.png)
<p align="center"> Fig. 桶排序算法流程 </p>
<p align="center"> 图:桶排序算法流程 </p>
=== "Java"
@ -411,10 +411,10 @@ comments: true
![递归划分桶](bucket_sort.assets/scatter_in_buckets_recursively.png)
<p align="center"> Fig. 递归划分桶 </p>
<p align="center"> 图:递归划分桶 </p>
如果我们提前知道商品价格的概率分布,**则可以根据数据概率分布设置每个桶的价格分界线**。值得注意的是,数据分布并不一定需要特意统计,也可以根据数据特点采用某种概率模型进行近似。如下图所示,我们假设商品价格服从正态分布,这样就可以合理地设定价格区间,从而将商品平均分配到各个桶中。
![根据概率分布划分桶](bucket_sort.assets/scatter_in_buckets_distribution.png)
<p align="center"> Fig. 根据概率分布划分桶 </p>
<p align="center"> 图:根据概率分布划分桶 </p>

View file

@ -16,7 +16,7 @@ comments: true
![计数排序流程](counting_sort.assets/counting_sort_overview.png)
<p align="center"> Fig. 计数排序流程 </p>
<p align="center"> 图:计数排序流程 </p>
=== "Java"
@ -362,6 +362,8 @@ $$
=== "<8>"
![counting_sort_step8](counting_sort.assets/counting_sort_step8.png)
<p align="center"> 图:计数排序步骤 </p>
计数排序的实现代码如下所示。
=== "Java"

View file

@ -62,6 +62,8 @@ comments: true
=== "<12>"
![heap_sort_step12](heap_sort.assets/heap_sort_step12.png)
<p align="center"> 图:堆排序步骤 </p>
在代码实现中我们使用了与堆章节相同的从顶至底堆化Sift Down的函数。值得注意的是由于堆的长度会随着提取最大元素而减小因此我们需要给 Sift Down 函数添加一个长度参数 $n$ ,用于指定堆的当前有效长度。
=== "Java"

View file

@ -12,7 +12,7 @@ comments: true
![单次插入操作](insertion_sort.assets/insertion_operation.png)
<p align="center"> Fig. 单次插入操作 </p>
<p align="center"> 图:单次插入操作 </p>
## 11.4.1. &nbsp; 算法流程
@ -25,7 +25,7 @@ comments: true
![插入排序流程](insertion_sort.assets/insertion_sort_overview.png)
<p align="center"> Fig. 插入排序流程 </p>
<p align="center"> 图:插入排序流程 </p>
=== "Java"

View file

@ -11,7 +11,7 @@ comments: true
![归并排序的划分与合并阶段](merge_sort.assets/merge_sort_overview.png)
<p align="center"> Fig. 归并排序的划分与合并阶段 </p>
<p align="center"> 图:归并排序的划分与合并阶段 </p>
## 11.6.1. &nbsp; 算法流程
@ -52,6 +52,8 @@ comments: true
=== "<10>"
![merge_sort_step10](merge_sort.assets/merge_sort_step10.png)
<p align="center"> 图:归并排序步骤 </p>
观察发现,归并排序的递归顺序与二叉树的后序遍历相同,具体来看:
- **后序遍历**:先递归左子树,再递归右子树,最后处理根节点。

View file

@ -41,6 +41,8 @@ comments: true
=== "<9>"
![pivot_division_step9](quick_sort.assets/pivot_division_step9.png)
<p align="center"> 图:哨兵划分步骤 </p>
!!! note "快速排序的分治思想"
哨兵划分的实质是将一个较长数组的排序问题简化为两个较短数组的排序问题。
@ -366,7 +368,7 @@ comments: true
![快速排序流程](quick_sort.assets/quick_sort_overview.png)
<p align="center"> Fig. 快速排序流程 </p>
<p align="center"> 图:快速排序流程 </p>
=== "Java"

View file

@ -18,7 +18,7 @@ comments: true
![基数排序算法流程](radix_sort.assets/radix_sort_overview.png)
<p align="center"> Fig. 基数排序算法流程 </p>
<p align="center"> 图:基数排序算法流程 </p>
下面来剖析代码实现。对于一个 $d$ 进制的数字 $x$ ,要获取其第 $k$ 位 $x_k$ ,可以使用以下计算公式:

View file

@ -47,6 +47,8 @@ comments: true
=== "<11>"
![selection_sort_step11](selection_sort.assets/selection_sort_step11.png)
<p align="center"> 图:选择排序步骤 </p>
在代码中,我们用 $k$ 来记录未排序区间内的最小元素。
=== "Java"
@ -290,4 +292,4 @@ comments: true
![选择排序非稳定示例](selection_sort.assets/selection_sort_instability.png)
<p align="center"> Fig. 选择排序非稳定示例 </p>
<p align="center"> 图:选择排序非稳定示例 </p>

View file

@ -10,7 +10,7 @@ comments: true
![数据类型和判断规则示例](sorting_algorithm.assets/sorting_examples.png)
<p align="center"> Fig. 数据类型和判断规则示例 </p>
<p align="center"> 图:数据类型和判断规则示例 </p>
## 11.1.1. &nbsp; 评价维度

View file

@ -15,7 +15,7 @@ comments: true
![排序算法对比](summary.assets/sorting_algorithms_comparison.png)
<p align="center"> Fig. 排序算法对比 </p>
<p align="center"> 图:排序算法对比 </p>
## 11.11.1. &nbsp; Q & A

View file

@ -8,7 +8,7 @@ comments: true
![双向队列的操作](deque.assets/deque_operations.png)
<p align="center"> Fig. 双向队列的操作 </p>
<p align="center"> 图:双向队列的操作 </p>
## 5.3.1. &nbsp; 双向队列常用操作
@ -351,6 +351,8 @@ comments: true
=== "popFirst()"
![linkedlist_deque_pop_first](deque.assets/linkedlist_deque_pop_first.png)
<p align="center"> 图:基于链表实现双向队列的入队出队操作 </p>
以下是具体实现代码。
=== "Java"
@ -359,8 +361,8 @@ comments: true
/* 双向链表节点 */
class ListNode {
int val; // 节点值
ListNode next; // 后继节点引用(指针)
ListNode prev; // 前驱节点引用(指针)
ListNode next; // 后继节点引用
ListNode prev; // 前驱节点引用
ListNode(int val) {
this.val = val;
@ -634,8 +636,8 @@ comments: true
def __init__(self, val: int):
"""构造方法"""
self.val: int = val
self.next: ListNode | None = None # 后继节点引用(指针)
self.prev: ListNode | None = None # 前驱节点引用(指针)
self.next: ListNode | None = None # 后继节点引用
self.prev: ListNode | None = None # 前驱节点引用
class LinkedListDeque:
"""基于双向链表实现的双向队列"""
@ -1240,8 +1242,8 @@ comments: true
/* 双向链表节点 */
class ListNode {
public int val; // 节点值
public ListNode? next; // 后继节点引用(指针)
public ListNode? prev; // 前驱节点引用(指针)
public ListNode? next; // 后继节点引用
public ListNode? prev; // 前驱节点引用
public ListNode(int val) {
this.val = val;
@ -1383,8 +1385,8 @@ comments: true
/* 双向链表节点 */
class ListNode {
var val: Int // 节点值
var next: ListNode? // 后继节点引用(指针)
weak var prev: ListNode? // 前驱节点引用(指针)
var next: ListNode? // 后继节点引用
weak var prev: ListNode? // 前驱节点引用
init(val: Int) {
self.val = val
@ -1520,8 +1522,8 @@ comments: true
const Self = @This();
val: T = undefined, // 节点值
next: ?*Self = null, // 后继节点引用(指针
prev: ?*Self = null, // 前驱节点引用(指针
next: ?*Self = null, // 后继节点指针
prev: ?*Self = null, // 前驱节点指针
// Initialize a list node with specific value
pub fn init(self: *Self, x: i32) void {
@ -1677,8 +1679,8 @@ comments: true
/* 双向链表节点 */
class ListNode {
int val; // 节点值
ListNode? next; // 后继节点引用(指针)
ListNode? prev; // 前驱节点引用(指针)
ListNode? next; // 后继节点引用
ListNode? prev; // 前驱节点引用
ListNode(this.val, {this.next, this.prev});
}
@ -1807,8 +1809,8 @@ comments: true
/* 双向链表节点 */
pub struct ListNode<T> {
pub val: T, // 节点值
pub next: Option<Rc<RefCell<ListNode<T>>>>, // 后继节点引用(指针
pub prev: Option<Rc<RefCell<ListNode<T>>>>, // 前驱节点引用(指针
pub next: Option<Rc<RefCell<ListNode<T>>>>, // 后继节点指针
pub prev: Option<Rc<RefCell<ListNode<T>>>>, // 前驱节点指针
}
impl<T> ListNode<T> {
@ -1988,6 +1990,8 @@ comments: true
=== "popFirst()"
![array_deque_pop_first](deque.assets/array_deque_pop_first.png)
<p align="center"> 图:基于数组实现双向队列的入队出队操作 </p>
以下是具体实现代码。
=== "Java"

View file

@ -10,7 +10,7 @@ comments: true
![队列的先入先出规则](queue.assets/queue_operations.png)
<p align="center"> Fig. 队列的先入先出规则 </p>
<p align="center"> 图:队列的先入先出规则 </p>
## 5.2.1. &nbsp; 队列常用操作
@ -308,6 +308,8 @@ comments: true
=== "pop()"
![linkedlist_queue_pop](queue.assets/linkedlist_queue_pop.png)
<p align="center"> 图:基于链表实现队列的入队出队操作 </p>
以下是用链表实现队列的示例代码。
=== "Java"
@ -1200,6 +1202,8 @@ comments: true
=== "pop()"
![array_queue_pop](queue.assets/array_queue_pop.png)
<p align="center"> 图:基于数组实现队列的入队出队操作 </p>
你可能会发现一个问题:在不断进行入队和出队的过程中,`front` 和 `rear` 都在向右移动,**当它们到达数组尾部时就无法继续移动了**。为解决此问题,我们可以将数组视为首尾相接的「环形数组」。
对于环形数组,我们需要让 `front``rear` 在越过数组尾部时,直接回到数组头部继续遍历。这种周期性规律可以通过“取余操作”来实现,代码如下所示。

View file

@ -12,7 +12,7 @@ comments: true
![栈的先入后出规则](stack.assets/stack_operations.png)
<p align="center"> Fig. 栈的先入后出规则 </p>
<p align="center"> 图:栈的先入后出规则 </p>
## 5.1.1. &nbsp; 栈常用操作
@ -310,6 +310,8 @@ comments: true
=== "pop()"
![linkedlist_stack_pop](stack.assets/linkedlist_stack_pop.png)
<p align="center"> 图:基于链表实现栈的入栈出栈操作 </p>
以下是基于链表实现栈的示例代码。
=== "Java"
@ -1077,6 +1079,8 @@ comments: true
=== "pop()"
![array_stack_pop](stack.assets/array_stack_pop.png)
<p align="center"> 图:基于数组实现栈的入栈出栈操作 </p>
由于入栈的元素可能会源源不断地增加,因此我们可以使用动态数组,这样就无需自行处理数组扩容问题。以下为示例代码。
=== "Java"

View file

@ -16,7 +16,7 @@ comments: true
![完美二叉树的数组表示](array_representation_of_tree.assets/array_representation_binary_tree.png)
<p align="center"> Fig. 完美二叉树的数组表示 </p>
<p align="center"> 图:完美二叉树的数组表示 </p>
**映射公式的角色相当于链表中的指针**。给定数组中的任意一个节点,我们都可以通过映射公式来访问它的左(右)子节点。
@ -26,7 +26,7 @@ comments: true
![层序遍历序列对应多种二叉树可能性](array_representation_of_tree.assets/array_representation_without_empty.png)
<p align="center"> Fig. 层序遍历序列对应多种二叉树可能性 </p>
<p align="center"> 图:层序遍历序列对应多种二叉树可能性 </p>
为了解决此问题,**我们可以考虑在层序遍历序列中显式地写出所有 $\text{None}$** 。如下图所示,这样处理后,层序遍历序列就可以唯一表示二叉树了。
@ -124,13 +124,13 @@ comments: true
![任意类型二叉树的数组表示](array_representation_of_tree.assets/array_representation_with_empty.png)
<p align="center"> Fig. 任意类型二叉树的数组表示 </p>
<p align="center"> 图:任意类型二叉树的数组表示 </p>
值得说明的是,**完全二叉树非常适合使用数组来表示**。回顾完全二叉树的定义,$\text{None}$ 只出现在最底层且靠右的位置,**因此所有 $\text{None}$ 一定出现在层序遍历序列的末尾**。这意味着使用数组表示完全二叉树时,可以省略存储所有 $\text{None}$ ,非常方便。
![完全二叉树的数组表示](array_representation_of_tree.assets/array_representation_complete_binary_tree.png)
<p align="center"> Fig. 完全二叉树的数组表示 </p>
<p align="center"> 图:完全二叉树的数组表示 </p>
如下代码给出了数组表示下的二叉树的简单实现,包括以下操作:
@ -960,7 +960,96 @@ comments: true
=== "Dart"
```dart title="array_binary_tree.dart"
[class]{ArrayBinaryTree}-[func]{}
/* 数组表示下的二叉树类 */
class ArrayBinaryTree {
late List<int?> _tree;
/* 构造方法 */
ArrayBinaryTree(this._tree);
/* 节点数量 */
int size() {
return _tree.length;
}
/* 获取索引为 i 节点的值 */
int? val(int i) {
// 若索引越界,则返回 null ,代表空位
if (i < 0 || i >= size()) {
return null;
}
return _tree[i];
}
/* 获取索引为 i 节点的左子节点的索引 */
int? left(int i) {
return 2 * i + 1;
}
/* 获取索引为 i 节点的右子节点的索引 */
int? right(int i) {
return 2 * i + 2;
}
/* 获取索引为 i 节点的父节点的索引 */
int? parent(int i) {
return (i - 1) ~/ 2;
}
/* 层序遍历 */
List<int> levelOrder() {
List<int> res = [];
for (int i = 0; i < size(); i++) {
if (val(i) != null) {
res.add(val(i)!);
}
}
return res;
}
/* 深度优先遍历 */
void dfs(int i, String order, List<int?> res) {
// 若为空位,则返回
if (val(i) == null) {
return;
}
// 前序遍历
if (order == 'pre') {
res.add(val(i));
}
dfs(left(i)!, order, res);
// 中序遍历
if (order == 'in') {
res.add(val(i));
}
dfs(right(i)!, order, res);
// 后序遍历
if (order == 'post') {
res.add(val(i));
}
}
/* 前序遍历 */
List<int?> preOrder() {
List<int?> res = [];
dfs(0, 'pre', res);
return res;
}
/* 中序遍历 */
List<int?> inOrder() {
List<int?> res = [];
dfs(0, 'in', res);
return res;
}
/* 后序遍历 */
List<int?> postOrder() {
List<int?> res = [];
dfs(0, 'post', res);
return res;
}
}
```
=== "Rust"

View file

@ -10,13 +10,13 @@ comments: true
![AVL 树在删除节点后发生退化](avl_tree.assets/avltree_degradation_from_removing_node.png)
<p align="center"> Fig. AVL 树在删除节点后发生退化 </p>
<p align="center"> 图:AVL 树在删除节点后发生退化 </p>
再例如,在以下完美二叉树中插入两个节点后,树将严重向左倾斜,查找操作的时间复杂度也随之恶化。
![AVL 树在插入节点后发生退化](avl_tree.assets/avltree_degradation_from_inserting_node.png)
<p align="center"> Fig. AVL 树在插入节点后发生退化 </p>
<p align="center"> 图:AVL 树在插入节点后发生退化 </p>
G. M. Adelson-Velsky 和 E. M. Landis 在其 1962 年发表的论文 "An algorithm for the organization of information" 中提出了「AVL 树」。论文中详细描述了一系列操作确保在持续添加和删除节点后AVL 树不会退化,从而使得各种操作的时间复杂度保持在 $O(\log n)$ 级别。换句话说在需要频繁进行增删查改操作的场景中AVL 树能始终保持高效的数据操作性能,具有很好的应用价值。
@ -384,6 +384,7 @@ G. M. Adelson-Velsky 和 E. M. Landis 在其 1962 年发表的论文 "An algorit
```dart title="avl_tree.dart"
/* 获取节点高度 */
int height(TreeNode? node) {
// 空节点高度为 -1 ,叶节点高度为 0
return node == null ? -1 : node.height;
}
@ -601,11 +602,13 @@ AVL 树的特点在于「旋转 Rotation」操作它能够在不影响二叉
=== "<4>"
![avltree_right_rotate_step4](avl_tree.assets/avltree_right_rotate_step4.png)
<p align="center"> 图:右旋操作步骤 </p>
此外,如果节点 `child` 本身有右子节点(记为 `grandChild` ),则需要在「右旋」中添加一步:将 `grandChild` 作为 `node` 的左子节点。
![有 grandChild 的右旋操作](avl_tree.assets/avltree_right_rotate_with_grandchild.png)
<p align="center"> Fig. 有 grandChild 的右旋操作 </p>
<p align="center"> 图:有 grandChild 的右旋操作 </p>
“向右旋转”是一种形象化的说法,实际上需要通过修改节点指针来实现,代码如下所示。
@ -836,13 +839,13 @@ AVL 树的特点在于「旋转 Rotation」操作它能够在不影响二叉
![左旋操作](avl_tree.assets/avltree_left_rotate.png)
<p align="center"> Fig. 左旋操作 </p>
<p align="center"> 图:左旋操作 </p>
同理,若节点 `child` 本身有左子节点(记为 `grandChild` ),则需要在「左旋」中添加一步:将 `grandChild` 作为 `node` 的右子节点。
![有 grandChild 的左旋操作](avl_tree.assets/avltree_left_rotate_with_grandchild.png)
<p align="center"> Fig. 有 grandChild 的左旋操作 </p>
<p align="center"> 图:有 grandChild 的左旋操作 </p>
可以观察到,**右旋和左旋操作在逻辑上是镜像对称的,它们分别解决的两种失衡情况也是对称的**。基于对称性,我们可以轻松地从右旋的代码推导出左旋的代码。具体地,只需将「右旋」代码中的把所有的 `left` 替换为 `right` ,将所有的 `right` 替换为 `left` ,即可得到「左旋」代码。
@ -1073,7 +1076,7 @@ AVL 树的特点在于「旋转 Rotation」操作它能够在不影响二叉
![先左旋后右旋](avl_tree.assets/avltree_left_right_rotate.png)
<p align="center"> Fig. 先左旋后右旋 </p>
<p align="center"> 图:先左旋后右旋 </p>
### 先右旋后左旋
@ -1081,7 +1084,7 @@ AVL 树的特点在于「旋转 Rotation」操作它能够在不影响二叉
![先右旋后左旋](avl_tree.assets/avltree_right_left_rotate.png)
<p align="center"> Fig. 先右旋后左旋 </p>
<p align="center"> 图:先右旋后左旋 </p>
### 旋转的选择
@ -1089,7 +1092,7 @@ AVL 树的特点在于「旋转 Rotation」操作它能够在不影响二叉
![AVL 树的四种旋转情况](avl_tree.assets/avltree_rotation_cases.png)
<p align="center"> Fig. AVL 树的四种旋转情况 </p>
<p align="center"> 图:AVL 树的四种旋转情况 </p>
在代码中,我们通过判断失衡节点的平衡因子以及较高一侧子节点的平衡因子的正负号,来确定失衡节点属于上图中的哪种情况。

View file

@ -11,7 +11,7 @@ comments: true
![二叉搜索树](binary_search_tree.assets/binary_search_tree.png)
<p align="center"> Fig. 二叉搜索树 </p>
<p align="center"> 图:二叉搜索树 </p>
## 7.4.1. &nbsp; 二叉搜索树的操作
@ -37,6 +37,8 @@ comments: true
=== "<4>"
![bst_search_step4](binary_search_tree.assets/bst_search_step4.png)
<p align="center"> 图:二叉搜索树查找节点示例 </p>
二叉搜索树的查找操作与二分查找算法的工作原理一致,都是每轮排除一半情况。循环次数最多为二叉树的高度,当二叉树平衡时,使用 $O(\log n)$ 时间。
=== "Java"
@ -270,7 +272,24 @@ comments: true
=== "Dart"
```dart title="binary_search_tree.dart"
[class]{BinarySearchTree}-[func]{search}
/* 查找节点 */
TreeNode? search(int num) {
TreeNode? cur = _root;
// 循环查找,越过叶节点后跳出
while (cur != null) {
// 目标节点在 cur 的右子树中
if (cur.val < num)
cur = cur.right;
// 目标节点在 cur 的左子树中
else if (cur.val > num)
cur = cur.left;
// 找到目标节点,跳出循环
else
break;
}
// 返回目标节点
return cur;
}
```
=== "Rust"
@ -311,7 +330,7 @@ comments: true
![在二叉搜索树中插入节点](binary_search_tree.assets/bst_insert.png)
<p align="center"> Fig. 在二叉搜索树中插入节点 </p>
<p align="center"> 图:在二叉搜索树中插入节点 </p>
=== "Java"
@ -640,7 +659,31 @@ comments: true
=== "Dart"
```dart title="binary_search_tree.dart"
[class]{BinarySearchTree}-[func]{insert}
/* 插入节点 */
void insert(int num) {
// 若树为空,直接提前返回
if (_root == null) return;
TreeNode? cur = _root;
TreeNode? pre = null;
// 循环查找,越过叶节点后跳出
while (cur != null) {
// 找到重复节点,直接返回
if (cur.val == num) return;
pre = cur;
// 插入位置在 cur 的右子树中
if (cur.val < num)
cur = cur.right;
// 插入位置在 cur 的左子树中
else
cur = cur.left;
}
// 插入节点
TreeNode? node = TreeNode(num);
if (pre!.val < num)
pre.right = node;
else
pre.left = node;
}
```
=== "Rust"
@ -693,13 +736,13 @@ comments: true
![在二叉搜索树中删除节点(度为 0](binary_search_tree.assets/bst_remove_case1.png)
<p align="center"> Fig. 在二叉搜索树中删除节点(度为 0 </p>
<p align="center"> 图:在二叉搜索树中删除节点(度为 0 </p>
当待删除节点的度为 $1$ 时,将待删除节点替换为其子节点即可。
![在二叉搜索树中删除节点(度为 1](binary_search_tree.assets/bst_remove_case2.png)
<p align="center"> Fig. 在二叉搜索树中删除节点(度为 1 </p>
<p align="center"> 图:在二叉搜索树中删除节点(度为 1 </p>
当待删除节点的度为 $2$ 时,我们无法直接删除它,而需要使用一个节点替换该节点。由于要保持二叉搜索树“左 $<$ 根 $<$ 右”的性质,因此这个节点可以是右子树的最小节点或左子树的最大节点。
@ -720,6 +763,8 @@ comments: true
=== "<4>"
![bst_remove_case3_step4](binary_search_tree.assets/bst_remove_case3_step4.png)
<p align="center"> 图:二叉搜索树删除节点示例 </p>
删除节点操作同样使用 $O(\log n)$ 时间,其中查找待删除节点需要 $O(\log n)$ 时间,获取中序遍历后继节点需要 $O(\log n)$ 时间。
=== "Java"
@ -1283,7 +1328,80 @@ comments: true
=== "Dart"
```dart title="binary_search_tree.dart"
[class]{BinarySearchTree}-[func]{remove}
/* 插入节点 */
void insert(int num) {
// 若树为空,直接提前返回
if (_root == null) return;
TreeNode? cur = _root;
TreeNode? pre = null;
// 循环查找,越过叶节点后跳出
while (cur != null) {
// 找到重复节点,直接返回
if (cur.val == num) return;
pre = cur;
// 插入位置在 cur 的右子树中
if (cur.val < num)
cur = cur.right;
// 插入位置在 cur 的左子树中
else
cur = cur.left;
}
// 插入节点
TreeNode? node = TreeNode(num);
if (pre!.val < num)
pre.right = node;
else
pre.left = node;
}
/* 删除节点 */
void remove(int num) {
// 若树为空,直接提前返回
if (_root == null) return;
TreeNode? cur = _root;
TreeNode? pre = null;
// 循环查找,越过叶节点后跳出
while (cur != null) {
// 找到待删除节点,跳出循环
if (cur.val == num) break;
pre = cur;
// 待删除节点在 cur 的右子树中
if (cur.val < num)
cur = cur.right;
// 待删除节点在 cur 的左子树中
else
cur = cur.left;
}
// 若无待删除节点,直接返回
if (cur == null) return;
// 子节点数量 = 0 or 1
if (cur.left == null || cur.right == null) {
// 当子节点数量 = 0 / 1 时, child = null / 该子节点
TreeNode? child = cur.left ?? cur.right;
// 删除节点 cur
if (cur != _root) {
if (pre!.left == cur)
pre.left = child;
else
pre.right = child;
} else {
// 若删除节点为根节点,则重新指定根节点
_root = child;
}
} else {
// 子节点数量 = 2
// 获取中序遍历中 cur 的下一个节点
TreeNode? tmp = cur.right;
while (tmp!.left != null) {
tmp = tmp.left;
}
// 递归删除节点 tmp
remove(tmp.val);
// 用 tmp 覆盖 cur
cur.val = tmp.val;
}
}
```
=== "Rust"
@ -1364,7 +1482,7 @@ comments: true
![二叉搜索树的中序遍历序列](binary_search_tree.assets/bst_inorder_traversal.png)
<p align="center"> Fig. 二叉搜索树的中序遍历序列 </p>
<p align="center"> 图:二叉搜索树的中序遍历序列 </p>
## 7.4.2. &nbsp; 二叉搜索树的效率
@ -1388,7 +1506,7 @@ comments: true
![二叉搜索树的平衡与退化](binary_search_tree.assets/bst_degradation.png)
<p align="center"> Fig. 二叉搜索树的平衡与退化 </p>
<p align="center"> 图:二叉搜索树的平衡与退化 </p>
## 7.4.3. &nbsp; 二叉搜索树常见应用

View file

@ -4,7 +4,7 @@ comments: true
# 7.1. &nbsp; 二叉树
「二叉树 Binary Tree」是一种非线性数据结构代表着祖先与后代之间的派生关系体现着“一分为二”的分治逻辑。与链表类似二叉树的基本单元是节点每个节点包含一个「值」和两个「指针」
「二叉树 Binary Tree」是一种非线性数据结构代表着祖先与后代之间的派生关系体现着“一分为二”的分治逻辑。与链表类似二叉树的基本单元是节点每个节点包含:值、左子节点引用、右子节点引用
=== "Java"
@ -12,8 +12,8 @@ comments: true
/* 二叉树节点类 */
class TreeNode {
int val; // 节点值
TreeNode left; // 左子节点指针
TreeNode right; // 右子节点指针
TreeNode left; // 左子节点引用
TreeNode right; // 右子节点引用
TreeNode(int x) { val = x; }
}
```
@ -37,8 +37,8 @@ comments: true
"""二叉树节点类"""
def __init__(self, val: int):
self.val: int = val # 节点值
self.left: Optional[TreeNode] = None # 左子节点指针
self.right: Optional[TreeNode] = None # 右子节点指针
self.left: Optional[TreeNode] = None # 左子节点引用
self.right: Optional[TreeNode] = None # 右子节点引用
```
=== "Go"
@ -53,9 +53,9 @@ comments: true
/* 节点初始化方法 */
func NewTreeNode(v int) *TreeNode {
return &TreeNode{
Left: nil,
Right: nil,
Val: v,
Left: nil, // 左子节点指针
Right: nil, // 右子节点指针
Val: v, // 节点值
}
}
```
@ -66,8 +66,8 @@ comments: true
/* 二叉树节点类 */
function TreeNode(val, left, right) {
this.val = (val === undefined ? 0 : val); // 节点值
this.left = (left === undefined ? null : left); // 左子节点指针
this.right = (right === undefined ? null : right); // 右子节点指针
this.left = (left === undefined ? null : left); // 左子节点引用
this.right = (right === undefined ? null : right); // 右子节点引用
}
```
@ -82,8 +82,8 @@ comments: true
constructor(val?: number, left?: TreeNode | null, right?: TreeNode | null) {
this.val = val === undefined ? 0 : val; // 节点值
this.left = left === undefined ? null : left; // 左子节点指针
this.right = right === undefined ? null : right; // 右子节点指针
this.left = left === undefined ? null : left; // 左子节点引用
this.right = right === undefined ? null : right; // 右子节点引用
}
}
```
@ -120,8 +120,8 @@ comments: true
/* 二叉树节点类 */
class TreeNode {
int val; // 节点值
TreeNode? left; // 左子节点指针
TreeNode? right; // 右子节点指针
TreeNode? left; // 左子节点引用
TreeNode? right; // 右子节点引用
TreeNode(int x) { val = x; }
}
```
@ -132,8 +132,8 @@ comments: true
/* 二叉树节点类 */
class TreeNode {
var val: Int // 节点值
var left: TreeNode? // 左子节点指针
var right: TreeNode? // 右子节点指针
var left: TreeNode? // 左子节点引用
var right: TreeNode? // 右子节点引用
init(x: Int) {
val = x
@ -153,8 +153,8 @@ comments: true
/* 二叉树节点类 */
class TreeNode {
int val; // 节点值
TreeNode? left; // 左子节点指针
TreeNode? right; // 右子节点指针
TreeNode? left; // 左子节点引用
TreeNode? right; // 右子节点引用
TreeNode(this.val, [this.left, this.right]);
}
```
@ -171,7 +171,7 @@ comments: true
![父节点、子节点、子树](binary_tree.assets/binary_tree_definition.png)
<p align="center"> Fig. 父节点、子节点、子树 </p>
<p align="center"> 图:父节点、子节点、子树 </p>
## 7.1.1. &nbsp; 二叉树常见术语
@ -188,7 +188,7 @@ comments: true
![二叉树的常用术语](binary_tree.assets/binary_tree_terminology.png)
<p align="center"> Fig. 二叉树的常用术语 </p>
<p align="center"> 图:二叉树的常用术语 </p>
!!! tip "高度与深度的定义"
@ -382,7 +382,7 @@ comments: true
![在二叉树中插入与删除节点](binary_tree.assets/binary_tree_add_remove.png)
<p align="center"> Fig. 在二叉树中插入与删除节点 </p>
<p align="center"> 图:在二叉树中插入与删除节点 </p>
=== "Java"
@ -530,7 +530,7 @@ comments: true
![完美二叉树](binary_tree.assets/perfect_binary_tree.png)
<p align="center"> Fig. 完美二叉树 </p>
<p align="center"> 图:完美二叉树 </p>
### 完全二叉树
@ -538,7 +538,7 @@ comments: true
![完全二叉树](binary_tree.assets/complete_binary_tree.png)
<p align="center"> Fig. 完全二叉树 </p>
<p align="center"> 图:完全二叉树 </p>
### 完满二叉树
@ -546,7 +546,7 @@ comments: true
![完满二叉树](binary_tree.assets/full_binary_tree.png)
<p align="center"> Fig. 完满二叉树 </p>
<p align="center"> 图:完满二叉树 </p>
### 平衡二叉树
@ -554,7 +554,7 @@ comments: true
![平衡二叉树](binary_tree.assets/balanced_binary_tree.png)
<p align="center"> Fig. 平衡二叉树 </p>
<p align="center"> 图:平衡二叉树 </p>
## 7.1.4. &nbsp; 二叉树的退化
@ -565,7 +565,7 @@ comments: true
![二叉树的最佳与最差结构](binary_tree.assets/binary_tree_best_worst_cases.png)
<p align="center"> Fig. 二叉树的最佳与最差结构 </p>
<p align="center"> 图:二叉树的最佳与最差结构 </p>
如下表所示,在最佳和最差结构下,二叉树的叶节点数量、节点总数、高度等达到极大或极小值。

View file

@ -16,7 +16,7 @@ comments: true
![二叉树的层序遍历](binary_tree_traversal.assets/binary_tree_bfs.png)
<p align="center"> Fig. 二叉树的层序遍历 </p>
<p align="center"> 图:二叉树的层序遍历 </p>
广度优先遍历通常借助「队列」来实现。队列遵循“先进先出”的规则,而广度优先遍历则遵循“逐层推进”的规则,两者背后的思想是一致的。
@ -334,7 +334,7 @@ comments: true
![二叉搜索树的前、中、后序遍历](binary_tree_traversal.assets/binary_tree_dfs.png)
<p align="center"> Fig. 二叉搜索树的前、中、后序遍历 </p>
<p align="center"> 图:二叉搜索树的前、中、后序遍历 </p>
以下给出了实现代码,请配合上图理解深度优先遍历的递归过程。
@ -794,3 +794,5 @@ comments: true
=== "<11>"
![preorder_step11](binary_tree_traversal.assets/preorder_step11.png)
<p align="center"> 图:前序遍历的递归过程 </p>