pull/944/head
krahets 1 year ago
parent 874e75d92d
commit 2a85d796e6

@ -98,6 +98,12 @@ comments: true
var nums = [_]i32{ 1, 3, 2, 5, 4 }; var nums = [_]i32{ 1, 3, 2, 5, 4 };
``` ```
=== "Dart"
```dart title="array.dart"
```
## 4.1.1.   数组优点 ## 4.1.1.   数组优点
**在数组中访问元素非常高效**。由于数组元素被存储在连续的内存空间中,因此计算数组元素的内存地址非常容易。给定数组首个元素的地址和某个元素的索引,我们可以使用以下公式计算得到该元素的内存地址,从而直接访问此元素。 **在数组中访问元素非常高效**。由于数组元素被存储在连续的内存空间中,因此计算数组元素的内存地址非常容易。给定数组首个元素的地址和某个元素的索引,我们可以使用以下公式计算得到该元素的内存地址,从而直接访问此元素。
@ -249,6 +255,19 @@ elementAddr = firtstElementAddr + elementLength * elementIndex
} }
``` ```
=== "Dart"
```dart title="array.dart"
/* 随机返回一个 数组元素 */
int randomAccess(List nums) {
// 在区间[0,size) 中随机抽取一个数字
int randomIndex = Random().nextInt(nums.length);
// 获取并返回随机元素
int randomNum = nums[randomIndex];
return randomNum;
}
```
## 4.1.2.   数组缺点 ## 4.1.2.   数组缺点
**数组在初始化后长度不可变**。由于系统无法保证数组之后的内存空间是可用的,因此数组长度无法扩展。而若希望扩容数组,则需新建一个数组,然后把原数组元素依次拷贝到新数组,在数组很大的情况下,这是非常耗时的。 **数组在初始化后长度不可变**。由于系统无法保证数组之后的内存空间是可用的,因此数组长度无法扩展。而若希望扩容数组,则需新建一个数组,然后把原数组元素依次拷贝到新数组,在数组很大的情况下,这是非常耗时的。
@ -420,6 +439,23 @@ elementAddr = firtstElementAddr + elementLength * elementIndex
} }
``` ```
=== "Dart"
```dart title="array.dart"
/* 扩展数组长度 */
List extend(List nums, int enlarge) {
// 初始化一个扩展长度后的数组元素初始值为0
List<int> res = List.filled(nums.length + enlarge, 0);
// 将原数组中的所有元素复制到新数组
for (var i = 0; i < nums.length; i++) {
res[i] = nums[i];
}
// 返回扩展后的新数组
return res;
}
```
**数组中插入或删除元素效率低下**。如果我们想要在数组中间插入一个元素,由于数组元素在内存中是“紧挨着的”,它们之间没有空间再放任何数据。因此,我们不得不将此索引之后的所有元素都向后移动一位,然后再把元素赋值给该索引。 **数组中插入或删除元素效率低下**。如果我们想要在数组中间插入一个元素,由于数组元素在内存中是“紧挨着的”,它们之间没有空间再放任何数据。因此,我们不得不将此索引之后的所有元素都向后移动一位,然后再把元素赋值给该索引。
![数组插入元素](array.assets/array_insert_element.png) ![数组插入元素](array.assets/array_insert_element.png)
@ -550,6 +586,35 @@ elementAddr = firtstElementAddr + elementLength * elementIndex
} }
``` ```
=== "Zig"
```zig title="array.zig"
// 在数组的索引 index 处插入元素 num
fn insert(nums: []i32, num: i32, index: usize) void {
// 把索引 index 以及之后的所有元素向后移动一位
var i = nums.len - 1;
while (i > index) : (i -= 1) {
nums[i] = nums[i - 1];
}
// 将 num 赋给 index 处元素
nums[index] = num;
}
```
=== "Dart"
```dart title="array.dart"
/* 在数组的索引 index 处插入元素 num */
void insert(List nums, int num, int index) {
// 把索引index以及之后的所有元素向后移动一位
for (var i = nums.length - 1; i > index; i--) {
nums[i] = nums[i - 1];
}
// 将 num 赋给 index 处元素
nums[index] = num;
}
```
删除元素也类似,如果我们想要删除索引 $i$ 处的元素,则需要把索引 $i$ 之后的元素都向前移动一位。值得注意的是,删除元素后,原先末尾的元素变得“无意义”了,我们无需特意去修改它。 删除元素也类似,如果我们想要删除索引 $i$ 处的元素,则需要把索引 $i$ 之后的元素都向前移动一位。值得注意的是,删除元素后,原先末尾的元素变得“无意义”了,我们无需特意去修改它。
![数组删除元素](array.assets/array_remove_element.png) ![数组删除元素](array.assets/array_remove_element.png)
@ -677,6 +742,17 @@ elementAddr = firtstElementAddr + elementLength * elementIndex
} }
``` ```
=== "Dart"
```dart title="array.dart"
/* 删除索引 index 处元素 */
void remove(List nums, int index) {
for (var i = index; i < nums.length - 1; i++) {
nums[i] = nums[i + 1];
}
}
```
总结来看,数组的插入与删除操作有以下缺点: 总结来看,数组的插入与删除操作有以下缺点:
- **时间复杂度高**:数组的插入和删除的平均时间复杂度均为 $O(n)$ ,其中 $n$ 为数组长度。 - **时间复杂度高**:数组的插入和删除的平均时间复杂度均为 $O(n)$ ,其中 $n$ 为数组长度。
@ -852,6 +928,27 @@ elementAddr = firtstElementAddr + elementLength * elementIndex
} }
``` ```
=== "Dart"
```dart title="array.dart"
/* 遍历数组元素 */
void traverse(List nums) {
var count = 0;
// 通过索引遍历数组
for (var i = 0; i < nums.length; i++) {
count++;
}
// 直接遍历数组
for (var num in nums) {
count++;
}
// 通过forEach方法遍历数组
nums.forEach((element) {
count++;
});
}
```
**数组查找**。通过遍历数组,查找数组内的指定元素,并输出对应索引。 **数组查找**。通过遍历数组,查找数组内的指定元素,并输出对应索引。
=== "Java" === "Java"
@ -985,6 +1082,18 @@ elementAddr = firtstElementAddr + elementLength * elementIndex
} }
``` ```
=== "Dart"
```dart title="array.dart"
/* 在数组中查找指定元素 */
int find(List nums, int target) {
for (var i = 0; i < nums.length; i++) {
if (nums[i] == target) return i;
}
return -1;
}
```
## 4.1.4. &nbsp; 数组典型应用 ## 4.1.4. &nbsp; 数组典型应用
**随机访问**。如果我们想要随机抽取一些样本,那么可以用数组存储,并生成一个随机序列,根据索引实现样本的随机抽取。 **随机访问**。如果我们想要随机抽取一些样本,那么可以用数组存储,并生成一个随机序列,根据索引实现样本的随机抽取。

@ -159,6 +159,12 @@ comments: true
} }
``` ```
=== "Dart"
```dart title=""
```
!!! question "尾节点指向什么?" !!! question "尾节点指向什么?"
我们将链表的最后一个节点称为「尾节点」,其指向的是“空”,在 Java, C++, Python 中分别记为 $\text{null}$ , $\text{nullptr}$ , $\text{None}$ 。在不引起歧义的前提下,本书都使用 $\text{null}$ 来表示空。 我们将链表的最后一个节点称为「尾节点」,其指向的是“空”,在 Java, C++, Python 中分别记为 $\text{null}$ , $\text{nullptr}$ , $\text{None}$ 。在不引起歧义的前提下,本书都使用 $\text{null}$ 来表示空。
@ -339,6 +345,12 @@ comments: true
n3.next = &n4; n3.next = &n4;
``` ```
=== "Dart"
```dart title="linked_list.dart"
```
## 4.2.1. &nbsp; 链表优点 ## 4.2.1. &nbsp; 链表优点
**链表中插入与删除节点的操作效率高**。例如,如果我们想在链表中间的两个节点 `A` , `B` 之间插入一个新节点 `P` ,我们只需要改变两个节点指针即可,时间复杂度为 $O(1)$ ;相比之下,数组的插入操作效率要低得多。 **链表中插入与删除节点的操作效率高**。例如,如果我们想在链表中间的两个节点 `A` , `B` 之间插入一个新节点 `P` ,我们只需要改变两个节点指针即可,时间复杂度为 $O(1)$ ;相比之下,数组的插入操作效率要低得多。
@ -456,6 +468,17 @@ comments: true
} }
``` ```
=== "Dart"
```dart title="linked_list.dart"
/* 在链表的节点 n0 之后插入节点 P */
void insert(ListNode n0, ListNode P) {
ListNode? n1 = n0.next;
P.next = n1;
n0.next = P;
}
```
在链表中删除节点也非常方便,只需改变一个节点的指针即可。如下图所示,尽管在删除操作完成后,节点 `P` 仍然指向 `n1` ,但实际上 `P` 已经不再属于此链表,因为遍历此链表时无法访问到 `P` 在链表中删除节点也非常方便,只需改变一个节点的指针即可。如下图所示,尽管在删除操作完成后,节点 `P` 仍然指向 `n1` ,但实际上 `P` 已经不再属于此链表,因为遍历此链表时无法访问到 `P`
![链表删除节点](linked_list.assets/linkedlist_remove_node.png) ![链表删除节点](linked_list.assets/linkedlist_remove_node.png)
@ -608,6 +631,18 @@ comments: true
} }
``` ```
=== "Dart"
```dart title="linked_list.dart"
/* 删除链表的节点 n0 之后的首个节点 */
void remove(ListNode n0) {
if (n0.next == null) return;
ListNode P = n0.next!;
ListNode? n1 = P.next;
n0.next = n1;
}
```
## 4.2.2. &nbsp; 链表缺点 ## 4.2.2. &nbsp; 链表缺点
**链表访问节点效率较低**。如上节所述,数组可以在 $O(1)$ 时间下访问任意元素。然而,链表无法直接访问任意节点,这是因为系统需要从头节点出发,逐个向后遍历直至找到目标节点。例如,若要访问链表索引为 `index`(即第 `index + 1` 个)的节点,则需要向后遍历 `index` 轮。 **链表访问节点效率较低**。如上节所述,数组可以在 $O(1)$ 时间下访问任意元素。然而,链表无法直接访问任意节点,这是因为系统需要从头节点出发,逐个向后遍历直至找到目标节点。例如,若要访问链表索引为 `index`(即第 `index + 1` 个)的节点,则需要向后遍历 `index` 轮。
@ -755,6 +790,19 @@ comments: true
} }
``` ```
=== "Dart"
```dart title="linked_list.dart"
/* 访问链表中索引为 index 的节点 */
ListNode? access(ListNode? head, int index) {
for (var i = 0; i < index; i++) {
if (head == null) return null;
head = head.next;
}
return head;
}
```
**链表的内存占用较大**。链表以节点为单位,每个节点除了保存值之外,还需额外保存指针(引用)。这意味着在相同数据量的情况下,链表比数组需要占用更多的内存空间。 **链表的内存占用较大**。链表以节点为单位,每个节点除了保存值之外,还需额外保存指针(引用)。这意味着在相同数据量的情况下,链表比数组需要占用更多的内存空间。
## 4.2.3. &nbsp; 链表常用操作 ## 4.2.3. &nbsp; 链表常用操作
@ -924,6 +972,23 @@ comments: true
} }
``` ```
=== "Dart"
```dart title="linked_list.dart"
/* 在链表中查找值为 target 的首个节点 */
int find(ListNode? head, int target) {
int index = 0;
while (head != null) {
if (head.val == target) {
return index;
}
head = head.next;
index++;
}
return -1;
}
```
## 4.2.4. &nbsp; 常见链表类型 ## 4.2.4. &nbsp; 常见链表类型
**单向链表**。即上述介绍的普通链表。单向链表的节点包含值和指向下一节点的指针(引用)两项数据。我们将首个节点称为头节点,将最后一个节点成为尾节点,尾节点指向 $\text{null}$ 。 **单向链表**。即上述介绍的普通链表。单向链表的节点包含值和指向下一节点的指针(引用)两项数据。我们将首个节点称为头节点,将最后一个节点成为尾节点,尾节点指向 $\text{null}$ 。
@ -1091,6 +1156,12 @@ comments: true
} }
``` ```
=== "Dart"
```dart title=""
```
![常见链表种类](linked_list.assets/linkedlist_common_types.png) ![常见链表种类](linked_list.assets/linkedlist_common_types.png)
<p align="center"> Fig. 常见链表种类 </p> <p align="center"> Fig. 常见链表种类 </p>

@ -110,6 +110,12 @@ comments: true
try list.appendSlice(&[_]i32{ 1, 3, 2, 5, 4 }); try list.appendSlice(&[_]i32{ 1, 3, 2, 5, 4 });
``` ```
=== "Dart"
```dart title="list.dart"
```
**访问与更新元素**。由于列表的底层数据结构是数组,因此可以在 $O(1)$ 时间内访问和更新元素,效率很高。 **访问与更新元素**。由于列表的底层数据结构是数组,因此可以在 $O(1)$ 时间内访问和更新元素,效率很高。
=== "Java" === "Java"
@ -208,6 +214,12 @@ comments: true
list.items[1] = 0; // 将索引 1 处的元素更新为 0 list.items[1] = 0; // 将索引 1 处的元素更新为 0
``` ```
=== "Dart"
```dart title="list.dart"
```
**在列表中添加、插入、删除元素**。相较于数组,列表可以自由地添加与删除元素。在列表尾部添加元素的时间复杂度为 $O(1)$ ,但插入和删除元素的效率仍与数组相同,时间复杂度为 $O(N)$ 。 **在列表中添加、插入、删除元素**。相较于数组,列表可以自由地添加与删除元素。在列表尾部添加元素的时间复杂度为 $O(1)$ ,但插入和删除元素的效率仍与数组相同,时间复杂度为 $O(N)$ 。
=== "Java" === "Java"
@ -396,6 +408,12 @@ comments: true
_ = list.orderedRemove(3); // 删除索引 3 处的元素 _ = list.orderedRemove(3); // 删除索引 3 处的元素
``` ```
=== "Dart"
```dart title="list.dart"
```
**遍历列表**。与数组一样,列表可以根据索引遍历,也可以直接遍历各元素。 **遍历列表**。与数组一样,列表可以根据索引遍历,也可以直接遍历各元素。
=== "Java" === "Java"
@ -549,6 +567,12 @@ comments: true
} }
``` ```
=== "Dart"
```dart title="list.dart"
```
**拼接两个列表**。给定一个新列表 `list1` ,我们可以将该列表拼接到原列表的尾部。 **拼接两个列表**。给定一个新列表 `list1` ,我们可以将该列表拼接到原列表的尾部。
=== "Java" === "Java"
@ -632,6 +656,12 @@ comments: true
try list.insertSlice(list.items.len, list1.items); // 将列表 list1 拼接到 list 之后 try list.insertSlice(list.items.len, list1.items); // 将列表 list1 拼接到 list 之后
``` ```
=== "Dart"
```dart title="list.dart"
```
**排序列表**。排序也是常用的方法之一。完成列表排序后,我们便可以使用在数组类算法题中经常考察的「二分查找」和「双指针」算法。 **排序列表**。排序也是常用的方法之一。完成列表排序后,我们便可以使用在数组类算法题中经常考察的「二分查找」和「双指针」算法。
=== "Java" === "Java"
@ -703,6 +733,12 @@ comments: true
std.sort.sort(i32, list.items, {}, comptime std.sort.asc(i32)); std.sort.sort(i32, list.items, {}, comptime std.sort.asc(i32));
``` ```
=== "Dart"
```dart title="list.dart"
```
## 4.3.2. &nbsp; 列表实现 * ## 4.3.2. &nbsp; 列表实现 *
为了帮助加深对列表的理解,我们在此提供一个简易版列表实现。需要关注三个核心点: 为了帮助加深对列表的理解,我们在此提供一个简易版列表实现。需要关注三个核心点:
@ -1756,3 +1792,96 @@ comments: true
}; };
} }
``` ```
=== "Dart"
```dart title="my_list.dart"
/* 列表类简易实现 */
class MyList {
late List<int> _nums; // 数组(存储列表元素)
int _capacity = 10; // 列表容量
int _size = 0; // 列表长度(即当前元素数量)
int _extendRatio = 2; // 每次列表扩容的倍数
/* 构造方法 */
MyList() {
_nums = List.filled(_capacity, 0);
}
/* 获取列表长度(即当前元素数量)*/
int size() => _size;
/* 获取列表容量 */
int capacity() => _capacity;
/* 访问元素 */
int get(int index) {
if (index >= _size) throw RangeError('索引越界');
return _nums[index];
}
/* 更新元素 */
void set(int index, int num) {
if (index >= _size) throw RangeError('索引越界');
_nums[index] = num;
}
/* 尾部添加元素 */
void add(int num) {
// 元素数量超出容量时,触发扩容机制
if (_size == _capacity) extendCapacity();
_nums[_size] = num;
// 更新元素数量
_size++;
}
/* 中间插入元素 */
void insert(int index, int num) {
if (index >= _size) throw RangeError('索引越界');
// 元素数量超出容量时,触发扩容机制
if (_size == _capacity) extendCapacity();
// 将索引 index 以及之后的元素都向后移动一位
for (var j = _size - 1; j >= index; j--) {
_nums[j + 1] = _nums[j];
}
_nums[index] = num;
// 更新元素数量
_size++;
}
/* 删除元素 */
int remove(int index) {
if (index >= _size) throw RangeError('索引越界');
int num = _nums[index];
// 将索引 index 之后的元素都向前移动一位
for (var j = index; j < _size - 1; j++) {
_nums[j] = _nums[j + 1];
}
// 更新元素数量
_size--;
// 返回被删除元素
return num;
}
/* 列表扩容 */
void extendCapacity() {
// 新建一个长度为 _capacity * _extendRatio 的数组
final _newNums = List.filled(_capacity * _extendRatio, 0);
// 将原数组拷贝到新数组
List.copyRange(_newNums, 0, _nums);
// 更新 _nums 的引用
_nums = _newNums;
// 更新列表容量
_capacity = _nums.length;
}
/* 将列表转换为数组 */
List<int> toArray() {
List<int> nums = [];
for (var i = 0; i < _size; i++) {
nums.add(get(i));
}
return nums;
}
}
```

@ -159,6 +159,12 @@ comments: true
[class]{}-[func]{preOrder} [class]{}-[func]{preOrder}
``` ```
=== "Dart"
```dart title="preorder_traversal_i_compact.dart"
[class]{}-[func]{preOrder}
```
![在前序遍历中搜索节点](backtracking_algorithm.assets/preorder_find_nodes.png) ![在前序遍历中搜索节点](backtracking_algorithm.assets/preorder_find_nodes.png)
<p align="center"> Fig. 在前序遍历中搜索节点 </p> <p align="center"> Fig. 在前序遍历中搜索节点 </p>
@ -358,6 +364,12 @@ comments: true
[class]{}-[func]{preOrder} [class]{}-[func]{preOrder}
``` ```
=== "Dart"
```dart title="preorder_traversal_ii_compact.dart"
[class]{}-[func]{preOrder}
```
在每次“尝试”中,我们通过将当前节点添加进 `path` 来记录路径;而在“回退”前,我们需要将该节点从 `path` 中弹出,**以恢复本次尝试之前的状态**。换句话说,**我们可以将尝试和回退理解为“前进”与“撤销”**,两个操作是互为相反的。 在每次“尝试”中,我们通过将当前节点添加进 `path` 来记录路径;而在“回退”前,我们需要将该节点从 `path` 中弹出,**以恢复本次尝试之前的状态**。换句话说,**我们可以将尝试和回退理解为“前进”与“撤销”**,两个操作是互为相反的。
=== "<1>" === "<1>"
@ -592,6 +604,12 @@ comments: true
[class]{}-[func]{preOrder} [class]{}-[func]{preOrder}
``` ```
=== "Dart"
```dart title="preorder_traversal_iii_compact.dart"
[class]{}-[func]{preOrder}
```
剪枝是一个非常形象的名词。在搜索过程中,**我们利用约束条件“剪掉”了不满足约束条件的搜索分支**,避免许多无意义的尝试,从而提升搜索效率。 剪枝是一个非常形象的名词。在搜索过程中,**我们利用约束条件“剪掉”了不满足约束条件的搜索分支**,避免许多无意义的尝试,从而提升搜索效率。
![根据约束条件剪枝](backtracking_algorithm.assets/preorder_find_constrained_paths.png) ![根据约束条件剪枝](backtracking_algorithm.assets/preorder_find_constrained_paths.png)
@ -848,6 +866,12 @@ comments: true
``` ```
=== "Dart"
```dart title=""
```
下面,我们尝试基于此框架来解决例题三。在例题三中,状态 `state` 是节点遍历路径,选择 `choices` 是当前节点的左子节点和右子节点,结果 `res` 是路径列表,实现代码如下所示。 下面,我们尝试基于此框架来解决例题三。在例题三中,状态 `state` 是节点遍历路径,选择 `choices` 是当前节点的左子节点和右子节点,结果 `res` 是路径列表,实现代码如下所示。
=== "Java" === "Java"
@ -1289,6 +1313,22 @@ comments: true
[class]{}-[func]{backtrack} [class]{}-[func]{backtrack}
``` ```
=== "Dart"
```dart title="preorder_traversal_iii_template.dart"
[class]{}-[func]{isSolution}
[class]{}-[func]{recordSolution}
[class]{}-[func]{isValid}
[class]{}-[func]{makeChoice}
[class]{}-[func]{undoChoice}
[class]{}-[func]{backtrack}
```
相较于基于前序遍历的实现代码,基于回溯算法框架的实现代码虽然显得啰嗦,但通用性更好。实际上,**所有回溯问题都可以在该框架下解决**。我们需要根据具体问题来定义 `state``choices` ,并实现框架中的各个方法。 相较于基于前序遍历的实现代码,基于回溯算法框架的实现代码虽然显得啰嗦,但通用性更好。实际上,**所有回溯问题都可以在该框架下解决**。我们需要根据具体问题来定义 `state``choices` ,并实现框架中的各个方法。
## 12.1.5. &nbsp; 典型例题 ## 12.1.5. &nbsp; 典型例题

@ -488,6 +488,14 @@ comments: true
[class]{}-[func]{nQueens} [class]{}-[func]{nQueens}
``` ```
=== "Dart"
```dart title="n_queens.dart"
[class]{}-[func]{backtrack}
[class]{}-[func]{nQueens}
```
## 12.3.1. &nbsp; 复杂度分析 ## 12.3.1. &nbsp; 复杂度分析
逐行放置 $n$ 次,考虑列约束,则从第一行到最后一行分别有 $n, n-1, \cdots, 2, 1$ 个选择,**因此时间复杂度为 $O(n!)$** 。实际上,根据对角线约束的剪枝也能够大幅地缩小搜索空间,因而搜索效率往往优于以上时间复杂度。 逐行放置 $n$ 次,考虑列约束,则从第一行到最后一行分别有 $n, n-1, \cdots, 2, 1$ 个选择,**因此时间复杂度为 $O(n!)$** 。实际上,根据对角线约束的剪枝也能够大幅地缩小搜索空间,因而搜索效率往往优于以上时间复杂度。

@ -336,6 +336,14 @@ comments: true
[class]{}-[func]{permutationsI} [class]{}-[func]{permutationsI}
``` ```
=== "Dart"
```dart title="permutations_i.dart"
[class]{}-[func]{backtrack}
[class]{}-[func]{permutationsI}
```
需要重点关注的是,我们引入了一个布尔型数组 `selected` ,它的长度与输入数组长度相等,其中 `selected[i]` 表示 `choices[i]` 是否已被选择。我们利用 `selected` 避免某个元素被重复选择,从而实现剪枝。 需要重点关注的是,我们引入了一个布尔型数组 `selected` ,它的长度与输入数组长度相等,其中 `selected[i]` 表示 `choices[i]` 是否已被选择。我们利用 `selected` 避免某个元素被重复选择,从而实现剪枝。
如下图所示,假设我们第一轮选择 1 ,第二轮选择 3 ,第三轮选择 2 ,则需要在第二轮剪掉元素 1 的分支,在第三轮剪掉元素 1, 3 的分支。**从本质上理解,此剪枝操作可将搜索空间大小从 $O(n^n)$ 降低至 $O(n!)$** 。 如下图所示,假设我们第一轮选择 1 ,第二轮选择 3 ,第三轮选择 2 ,则需要在第二轮剪掉元素 1 的分支,在第三轮剪掉元素 1, 3 的分支。**从本质上理解,此剪枝操作可将搜索空间大小从 $O(n^n)$ 降低至 $O(n!)$** 。
@ -683,6 +691,14 @@ comments: true
[class]{}-[func]{permutationsII} [class]{}-[func]{permutationsII}
``` ```
=== "Dart"
```dart title="permutations_ii.dart"
[class]{}-[func]{backtrack}
[class]{}-[func]{permutationsII}
```
注意,虽然 `selected``duplicated` 都起到剪枝的作用,但他们剪掉的是不同的分支: 注意,虽然 `selected``duplicated` 都起到剪枝的作用,但他们剪掉的是不同的分支:
- **剪枝条件一**:整个搜索过程中只有一个 `selected` 。它记录的是当前状态中包含哪些元素,作用是避免某个元素在 `state` 中重复出现。 - **剪枝条件一**:整个搜索过程中只有一个 `selected` 。它记录的是当前状态中包含哪些元素,作用是避免某个元素在 `state` 中重复出现。

@ -264,6 +264,12 @@ comments: true
``` ```
=== "Dart"
```dart title=""
```
## 2.3.2. &nbsp; 推算方法 ## 2.3.2. &nbsp; 推算方法
空间复杂度的推算方法与时间复杂度大致相同,只是将统计对象从“计算操作数量”转为“使用空间大小”。与时间复杂度不同的是,**我们通常只关注「最差空间复杂度」**,这是因为内存空间是一项硬性要求,我们必须确保在所有输入数据下都有足够的内存空间预留。 空间复杂度的推算方法与时间复杂度大致相同,只是将统计对象从“计算操作数量”转为“使用空间大小”。与时间复杂度不同的是,**我们通常只关注「最差空间复杂度」**,这是因为内存空间是一项硬性要求,我们必须确保在所有输入数据下都有足够的内存空间预留。
@ -386,6 +392,12 @@ comments: true
``` ```
=== "Dart"
```dart title=""
```
**在递归函数中,需要注意统计栈帧空间**。例如,函数 `loop()` 在循环中调用了 $n$ 次 `function()` ,每轮中的 `function()` 都返回并释放了栈帧空间,因此空间复杂度仍为 $O(1)$ 。而递归函数 `recur()` 在运行过程中会同时存在 $n$ 个未返回的 `recur()` ,从而占用 $O(n)$ 的栈帧空间。 **在递归函数中,需要注意统计栈帧空间**。例如,函数 `loop()` 在循环中调用了 $n$ 次 `function()` ,每轮中的 `function()` 都返回并释放了栈帧空间,因此空间复杂度仍为 $O(1)$ 。而递归函数 `recur()` 在运行过程中会同时存在 $n$ 个未返回的 `recur()` ,从而占用 $O(n)$ 的栈帧空间。
=== "Java" === "Java"
@ -585,6 +597,12 @@ comments: true
``` ```
=== "Dart"
```dart title=""
```
## 2.3.3. &nbsp; 常见类型 ## 2.3.3. &nbsp; 常见类型
设输入数据大小为 $n$ ,常见的空间复杂度类型有(从低到高排列) 设输入数据大小为 $n$ ,常见的空间复杂度类型有(从低到高排列)
@ -826,6 +844,27 @@ $$
} }
``` ```
=== "Dart"
```dart title="space_complexity.dart"
/* 常数阶 */
void constant(int n) {
// 常量、变量、对象占用 O(1) 空间
final int a = 0;
int b = 0;
List<int> nums = List.filled(10000, 0);
// 循环中的变量占用 O(1) 空间
for (var i = 0; i < n; i++) {
int c = 0;
}
// 循环中的函数占用 O(1) 空间
for (var i = 0; i < n; i++) {
function();
}
}
```
### 线性阶 $O(n)$ ### 线性阶 $O(n)$
线性阶常见于元素数量与 $n$ 成正比的数组、链表、栈、队列等。 线性阶常见于元素数量与 $n$ 成正比的数组、链表、栈、队列等。
@ -1051,6 +1090,26 @@ $$
} }
``` ```
=== "Dart"
```dart title="space_complexity.dart"
/* 线性阶 */
void linear(int n) {
// 长度为 n 的数组占用 O(n) 空间
List<int> nums = List.filled(n, 0);
// 长度为 n 的列表占用 O(n) 空间
List<ListNode> nodes = [];
for (var i = 0; i < n; i++) {
nodes.add(ListNode(i));
}
// 长度为 n 的哈希表占用 O(n) 空间
Map<int, String> map = HashMap();
for (var i = 0; i < n; i++) {
map.putIfAbsent(i, () => i.toString());
}
}
```
以下递归函数会同时存在 $n$ 个未返回的 `algorithm()` 函数,使用 $O(n)$ 大小的栈帧空间。 以下递归函数会同时存在 $n$ 个未返回的 `algorithm()` 函数,使用 $O(n)$ 大小的栈帧空间。
=== "Java" === "Java"
@ -1170,6 +1229,17 @@ $$
} }
``` ```
=== "Dart"
```dart title="space_complexity.dart"
/* 线性阶(递归实现) */
void linearRecur(int n) {
print('递归 n = $n');
if (n == 1) return;
linearRecur(n - 1);
}
```
![递归函数产生的线性阶空间复杂度](space_complexity.assets/space_complexity_recursive_linear.png) ![递归函数产生的线性阶空间复杂度](space_complexity.assets/space_complexity_recursive_linear.png)
<p align="center"> Fig. 递归函数产生的线性阶空间复杂度 </p> <p align="center"> Fig. 递归函数产生的线性阶空间复杂度 </p>
@ -1351,6 +1421,26 @@ $$
} }
``` ```
=== "Dart"
```dart title="space_complexity.dart"
/* 平方阶 */
void quadratic(int n) {
// 矩阵占用 O(n^2) 空间
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++) {
tmp.add(0);
}
numList.add(tmp);
}
}
```
在以下递归函数中,同时存在 $n$ 个未返回的 `algorithm()` ,并且每个函数中都初始化了一个数组,长度分别为 $n, n-1, n-2, ..., 2, 1$ ,平均长度为 $\frac{n}{2}$ ,因此总体占用 $O(n^2)$ 空间。 在以下递归函数中,同时存在 $n$ 个未返回的 `algorithm()` ,并且每个函数中都初始化了一个数组,长度分别为 $n, n-1, n-2, ..., 2, 1$ ,平均长度为 $\frac{n}{2}$ ,因此总体占用 $O(n^2)$ 空间。
=== "Java" === "Java"
@ -1484,6 +1574,18 @@ $$
} }
``` ```
=== "Dart"
```dart title="space_complexity.dart"
/* 平方阶(递归实现) */
int quadraticRecur(int n) {
if (n <= 0) return 0;
List<int> nums = List.filled(n, 0);
print('递归 n = $n 中的 nums 长度 = ${nums.length}');
return quadraticRecur(n - 1);
}
```
![递归函数产生的平方阶空间复杂度](space_complexity.assets/space_complexity_recursive_quadratic.png) ![递归函数产生的平方阶空间复杂度](space_complexity.assets/space_complexity_recursive_quadratic.png)
<p align="center"> Fig. 递归函数产生的平方阶空间复杂度 </p> <p align="center"> Fig. 递归函数产生的平方阶空间复杂度 </p>
@ -1630,6 +1732,19 @@ $$
} }
``` ```
=== "Dart"
```dart title="space_complexity.dart"
/* 指数阶(建立满二叉树) */
TreeNode? buildTree(int n) {
if (n == 0) return null;
TreeNode root = TreeNode(0);
root.left = buildTree(n - 1);
root.right = buildTree(n - 1);
return root;
}
```
![满二叉树产生的指数阶空间复杂度](space_complexity.assets/space_complexity_exponential.png) ![满二叉树产生的指数阶空间复杂度](space_complexity.assets/space_complexity_exponential.png)
<p align="center"> Fig. 满二叉树产生的指数阶空间复杂度 </p> <p align="center"> Fig. 满二叉树产生的指数阶空间复杂度 </p>

@ -159,6 +159,12 @@ $$
``` ```
=== "Dart"
```dart title=""
```
然而实际上,**统计算法的运行时间既不合理也不现实**。首先,我们不希望预估时间和运行平台绑定,因为算法需要在各种不同的平台上运行。其次,我们很难获知每种操作的运行时间,这给预估过程带来了极大的难度。 然而实际上,**统计算法的运行时间既不合理也不现实**。首先,我们不希望预估时间和运行平台绑定,因为算法需要在各种不同的平台上运行。其次,我们很难获知每种操作的运行时间,这给预估过程带来了极大的难度。
## 2.2.2. &nbsp; 统计时间增长趋势 ## 2.2.2. &nbsp; 统计时间增长趋势
@ -369,6 +375,12 @@ $$
``` ```
=== "Dart"
```dart title=""
```
![算法 A, B, C 的时间增长趋势](time_complexity.assets/time_complexity_simple_example.png) ![算法 A, B, C 的时间增长趋势](time_complexity.assets/time_complexity_simple_example.png)
<p align="center"> Fig. 算法 A, B, C 的时间增长趋势 </p> <p align="center"> Fig. 算法 A, B, C 的时间增长趋势 </p>
@ -521,6 +533,12 @@ $$
``` ```
=== "Dart"
```dart title=""
```
$T(n)$ 是一次函数,说明时间增长趋势是线性的,因此可以得出时间复杂度是线性阶。 $T(n)$ 是一次函数,说明时间增长趋势是线性的,因此可以得出时间复杂度是线性阶。
我们将线性阶的时间复杂度记为 $O(n)$ ,这个数学符号称为「大 $O$ 记号 Big-$O$ Notation」表示函数 $T(n)$ 的「渐近上界 Asymptotic Upper Bound」。 我们将线性阶的时间复杂度记为 $O(n)$ ,这个数学符号称为「大 $O$ 记号 Big-$O$ Notation」表示函数 $T(n)$ 的「渐近上界 Asymptotic Upper Bound」。
@ -747,6 +765,12 @@ $$
``` ```
=== "Dart"
```dart title=""
```
### 2) 判断渐近上界 ### 2) 判断渐近上界
**时间复杂度由多项式 $T(n)$ 中最高阶的项来决定**。这是因为在 $n$ 趋于无穷大时,最高阶的项将发挥主导作用,其他项的影响都可以被忽略。 **时间复杂度由多项式 $T(n)$ 中最高阶的项来决定**。这是因为在 $n$ 趋于无穷大时,最高阶的项将发挥主导作用,其他项的影响都可以被忽略。
@ -924,6 +948,20 @@ $$
} }
``` ```
=== "Dart"
```dart title="time_complexity.dart"
/* 常数阶 */
int constant(int n) {
int count = 0;
int size = 100000;
for (var i = 0; i < size; i++) {
count++;
}
return count;
}
```
### 线性阶 $O(n)$ ### 线性阶 $O(n)$
线性阶的操作数量相对于输入数据大小以线性级别增长。线性阶通常出现在单层循环中。 线性阶的操作数量相对于输入数据大小以线性级别增长。线性阶通常出现在单层循环中。
@ -1050,6 +1088,19 @@ $$
} }
``` ```
=== "Dart"
```dart title="time_complexity.dart"
/* 线性阶 */
int linear(int n) {
int count = 0;
for (var i = 0; i < n; i++) {
count++;
}
return count;
}
```
遍历数组和遍历链表等操作的时间复杂度均为 $O(n)$ ,其中 $n$ 为数组或链表的长度。 遍历数组和遍历链表等操作的时间复杂度均为 $O(n)$ ,其中 $n$ 为数组或链表的长度。
!!! question "如何确定输入数据大小 $n$ " !!! question "如何确定输入数据大小 $n$ "
@ -1194,6 +1245,20 @@ $$
} }
``` ```
=== "Dart"
```dart title="time_complexity.dart"
/* 线性阶(遍历数组) */
int arrayTraversal(List<int> nums) {
int count = 0;
// 循环次数与数组长度成正比
for (var num in nums) {
count++;
}
return count;
}
```
### 平方阶 $O(n^2)$ ### 平方阶 $O(n^2)$
平方阶的操作数量相对于输入数据大小以平方级别增长。平方阶通常出现在嵌套循环中,外层循环和内层循环都为 $O(n)$ ,因此总体为 $O(n^2)$ 。 平方阶的操作数量相对于输入数据大小以平方级别增长。平方阶通常出现在嵌套循环中,外层循环和内层循环都为 $O(n)$ ,因此总体为 $O(n^2)$ 。
@ -1357,6 +1422,22 @@ $$
} }
``` ```
=== "Dart"
```dart title="time_complexity.dart"
/* 平方阶 */
int quadratic(int n) {
int count = 0;
// 循环次数与数组长度成平方关系
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
count++;
}
}
return count;
}
```
![常数阶、线性阶、平方阶的时间复杂度](time_complexity.assets/time_complexity_constant_linear_quadratic.png) ![常数阶、线性阶、平方阶的时间复杂度](time_complexity.assets/time_complexity_constant_linear_quadratic.png)
<p align="center"> Fig. 常数阶、线性阶、平方阶的时间复杂度 </p> <p align="center"> Fig. 常数阶、线性阶、平方阶的时间复杂度 </p>
@ -1595,6 +1676,29 @@ $$
} }
``` ```
=== "Dart"
```dart title="time_complexity.dart"
/* 平方阶(冒泡排序) */
int bubbleSort(List<int> nums) {
int count = 0; // 计数器
// 外循环:未排序区间为 [0, i]
for (var i = nums.length - 1; i > 0; i--) {
// 内循环:将未排序区间 [0, i] 中的最大元素交换至该区间的最右端
for (var j = 0; j < i; j++) {
if (nums[j] > nums[j + 1]) {
// 交换 nums[j] 与 nums[j + 1]
int tmp = nums[j];
nums[j] = nums[j + 1];
nums[j + 1] = tmp;
count += 3; // 元素交换包含 3 个单元操作
}
}
}
return count;
}
```
### 指数阶 $O(2^n)$ ### 指数阶 $O(2^n)$
!!! note !!! note
@ -1788,6 +1892,24 @@ $$
} }
``` ```
=== "Dart"
```dart title="time_complexity.dart"
/* 指数阶(循环实现) */
int exponential(int n) {
int count = 0, base = 1;
// cell 每轮一分为二,形成数列 1, 2, 4, 8, ..., 2^(n-1)
for (var i = 0; i < n; i++) {
for (var j = 0; j < base; j++) {
count++;
}
base *= 2;
}
// count = 1 + 2 + 4 + 8 + .. + 2^(n-1) = 2^n - 1
return count;
}
```
![指数阶的时间复杂度](time_complexity.assets/time_complexity_exponential.png) ![指数阶的时间复杂度](time_complexity.assets/time_complexity_exponential.png)
<p align="center"> Fig. 指数阶的时间复杂度 </p> <p align="center"> Fig. 指数阶的时间复杂度 </p>
@ -1901,6 +2023,16 @@ $$
} }
``` ```
=== "Dart"
```dart title="time_complexity.dart"
/* 指数阶(递归实现) */
int expRecur(int n) {
if (n == 1) return 1;
return expRecur(n - 1) + expRecur(n - 1) + 1;
}
```
### 对数阶 $O(\log n)$ ### 对数阶 $O(\log n)$
与指数阶相反,对数阶反映了“每轮缩减到一半的情况”。对数阶仅次于常数阶,时间增长缓慢,是理想的时间复杂度。 与指数阶相反,对数阶反映了“每轮缩减到一半的情况”。对数阶仅次于常数阶,时间增长缓慢,是理想的时间复杂度。
@ -2050,6 +2182,20 @@ $$
} }
``` ```
=== "Dart"
```dart title="time_complexity.dart"
/* 对数阶(循环实现) */
int logarithmic(num n) {
int count = 0;
while (n > 1) {
n = n / 2;
count++;
}
return count;
}
```
![对数阶的时间复杂度](time_complexity.assets/time_complexity_logarithmic.png) ![对数阶的时间复杂度](time_complexity.assets/time_complexity_logarithmic.png)
<p align="center"> Fig. 对数阶的时间复杂度 </p> <p align="center"> Fig. 对数阶的时间复杂度 </p>
@ -2163,6 +2309,16 @@ $$
} }
``` ```
=== "Dart"
```dart title="time_complexity.dart"
/* 对数阶(递归实现) */
int logRecur(num n) {
if (n <= 1) return 0;
return logRecur(n / 2) + 1;
}
```
### 线性对数阶 $O(n \log n)$ ### 线性对数阶 $O(n \log n)$
线性对数阶常出现于嵌套循环中,两层循环的时间复杂度分别为 $O(\log n)$ 和 $O(n)$ 。 线性对数阶常出现于嵌套循环中,两层循环的时间复杂度分别为 $O(\log n)$ 和 $O(n)$ 。
@ -2320,6 +2476,20 @@ $$
} }
``` ```
=== "Dart"
```dart title="time_complexity.dart"
/* 线性对数阶 */
int linearLogRecur(num n) {
if (n <= 1) return 1;
int count = linearLogRecur(n / 2) + linearLogRecur(n / 2);
for (var i = 0; i < n; i++) {
count++;
}
return count;
}
```
![线性对数阶的时间复杂度](time_complexity.assets/time_complexity_logarithmic_linear.png) ![线性对数阶的时间复杂度](time_complexity.assets/time_complexity_logarithmic_linear.png)
<p align="center"> Fig. 线性对数阶的时间复杂度 </p> <p align="center"> Fig. 线性对数阶的时间复杂度 </p>
@ -2490,6 +2660,21 @@ $$
} }
``` ```
=== "Dart"
```dart title="time_complexity.dart"
/* 阶乘阶(递归实现) */
int factorialRecur(int n) {
if (n == 0) return 1;
int count = 0;
// 从 1 个分裂出 n 个
for (var i = 0; i < n; i++) {
count += factorialRecur(n - 1);
}
return count;
}
```
![阶乘阶的时间复杂度](time_complexity.assets/time_complexity_factorial.png) ![阶乘阶的时间复杂度](time_complexity.assets/time_complexity_factorial.png)
<p align="center"> Fig. 阶乘阶的时间复杂度 </p> <p align="center"> Fig. 阶乘阶的时间复杂度 </p>
@ -2800,6 +2985,33 @@ $$
} }
``` ```
=== "Dart"
```dart title="worst_best_time_complexity.dart"
/* 生成一个数组,元素为 { 1, 2, ..., n },顺序被打乱 */
List<int> randomNumbers(int n) {
final nums = List.filled(n, 0);
// 生成数组 nums = { 1, 2, 3, ..., n }
for (var i = 0; i < n; i++) {
nums[i] = i + 1;
}
// 随机打乱数组元素
nums.shuffle();
return nums;
}
/* 查找数组 nums 中数字 1 所在索引 */
int findOne(List<int> nums) {
for (var i = 0; i < nums.length; i++) {
// 当元素 1 在数组头部时,达到最佳时间复杂度 O(1)
// 当元素 1 在数组尾部时,达到最差时间复杂度 O(n)
if (nums[i] == 1) return i;
}
return -1;
}
```
!!! tip !!! tip
实际应用中我们很少使用「最佳时间复杂度」,因为通常只有在很小概率下才能达到,可能会带来一定的误导性。相反,「最差时间复杂度」更为实用,因为它给出了一个“效率安全值”,让我们可以放心地使用算法。 实际应用中我们很少使用「最佳时间复杂度」,因为通常只有在很小概率下才能达到,可能会带来一定的误导性。相反,「最差时间复杂度」更为实用,因为它给出了一个“效率安全值”,让我们可以放心地使用算法。

@ -133,3 +133,9 @@ comments: true
```zig title="" ```zig title=""
``` ```
=== "Dart"
```dart title=""
```

@ -762,6 +762,95 @@ comments: true
``` ```
=== "Dart"
```dart title="graph_adjacency_matrix.dart"
/* 基于邻接矩阵实现的无向图类 */
class GraphAdjMat {
List<int> vertices = []; // 顶点元素,元素代表“顶点值”,索引代表“顶点索引”
List<List<int>> adjMat = []; //邻接矩阵,行列索引对应“顶点索引”
/* 构造方法 */
GraphAdjMat(List<int> vertices, List<List<int>> edges) {
this.vertices = [];
this.adjMat = [];
// 添加顶点
for (int val in vertices) {
addVertex(val);
}
// 添加边
// 请注意edges 元素代表顶点索引,即对应 vertices 元素索引
for (List<int> e in edges) {
addEdge(e[0], e[1]);
}
}
/* 获取顶点数量 */
int size() {
return vertices.length;
}
/* 添加顶点 */
void addVertex(int val) {
int n = size();
// 向顶点列表中添加新顶点的值
vertices.add(val);
// 在邻接矩阵中添加一行
List<int> newRow = List.filled(n, 0, growable: true);
adjMat.add(newRow);
// 在邻接矩阵中添加一列
for (List<int> row in adjMat) {
row.add(0);
}
}
/* 删除顶点 */
void removeVertex(int index) {
if (index >= size()) {
throw IndexError;
}
// 在顶点列表中移除索引 index 的顶点
vertices.removeAt(index);
// 在邻接矩阵中删除索引 index 的行
adjMat.removeAt(index);
// 在邻接矩阵中删除索引 index 的列
for (List<int> row in adjMat) {
row.removeAt(index);
}
}
/* 添加边 */
// 参数 i, j 对应 vertices 元素索引
void addEdge(int i, int j) {
// 索引越界与相等处理
if (i < 0 || j < 0 || i >= size() || j >= size() || i == j) {
throw IndexError;
}
// 在无向图中,邻接矩阵沿主对角线对称,即满足 (i, j) == (j, i)
adjMat[i][j] = 1;
adjMat[j][i] = 1;
}
/* 删除边 */
// 参数 i, j 对应 vertices 元素索引
void removeEdge(int i, int j) {
// 索引越界与相等处理
if (i < 0 || j < 0 || i >= size() || j >= size() || i == j) {
throw IndexError;
}
adjMat[i][j] = 0;
adjMat[j][i] = 0;
}
/* 打印邻接矩阵 */
void printAdjMat() {
print("顶点列表 = $vertices");
print("邻接矩阵 = ");
printMatrix(adjMat);
}
}
```
## 9.2.2. &nbsp; 基于邻接表的实现 ## 9.2.2. &nbsp; 基于邻接表的实现
设无向图的顶点总数为 $n$ 、边总数为 $m$ ,则有: 设无向图的顶点总数为 $n$ 、边总数为 $m$ ,则有:
@ -1451,6 +1540,86 @@ comments: true
[class]{GraphAdjList}-[func]{} [class]{GraphAdjList}-[func]{}
``` ```
=== "Dart"
```dart title="graph_adjacency_list.dart"
/* 基于邻接表实现的无向图类 */
class GraphAdjList {
// 邻接表key: 顶点value该顶点的所有邻接顶点
Map<Vertex, List<Vertex>> adjList = {};
/* 构造方法 */
GraphAdjList(List<List<Vertex>> edges) {
for (List<Vertex> edge in edges) {
addVertex(edge[0]);
addVertex(edge[1]);
addEdge(edge[0], edge[1]);
}
}
/* 获取顶点数量 */
int size() {
return adjList.length;
}
/* 添加边 */
void addEdge(Vertex vet1, Vertex vet2) {
if (!adjList.containsKey(vet1) ||
!adjList.containsKey(vet2) ||
vet1 == vet2) {
throw ArgumentError;
}
// 添加边 vet1 - vet2
adjList[vet1]!.add(vet2);
adjList[vet2]!.add(vet1);
}
/* 删除边 */
void removeEdge(Vertex vet1, Vertex vet2) {
if (!adjList.containsKey(vet1) ||
!adjList.containsKey(vet2) ||
vet1 == vet2) {
throw ArgumentError;
}
// 删除边 vet1 - vet2
adjList[vet1]!.remove(vet2);
adjList[vet2]!.remove(vet1);
}
/* 添加顶点 */
void addVertex(Vertex vet) {
if (adjList.containsKey(vet)) return;
// 在邻接表中添加一个新链表
adjList[vet] = [];
}
/* 删除顶点 */
void removeVertex(Vertex vet) {
if (!adjList.containsKey(vet)) {
throw ArgumentError;
}
// 在邻接表中删除顶点 vet 对应的链表
adjList.remove(vet);
// 遍历其他顶点的链表,删除所有包含 vet 的边
adjList.forEach((key, value) {
value.remove(vet);
});
}
/* 打印邻接表 */
void printAdjList() {
print("邻接表 =");
adjList.forEach((key, value) {
List<int> tmp = [];
for (Vertex vertex in value) {
tmp.add(vertex.val);
}
print("${key.val}: $tmp,");
});
}
}
```
## 9.2.3. &nbsp; 效率对比 ## 9.2.3. &nbsp; 效率对比
设图中共有 $n$ 个顶点和 $m$ 条边,下表为邻接矩阵和邻接表的时间和空间效率对比。 设图中共有 $n$ 个顶点和 $m$ 条边,下表为邻接矩阵和邻接表的时间和空间效率对比。

@ -287,6 +287,38 @@ BFS 通常借助「队列」来实现。队列具有“先入先出”的性质
[class]{}-[func]{graphBFS} [class]{}-[func]{graphBFS}
``` ```
=== "Dart"
```dart title="graph_bfs.dart"
/* 广度优先遍历 BFS */
List<Vertex> graphBFS(GraphAdjList graph, Vertex startVet) {
// 使用邻接表来表示图,以便获取指定顶点的所有邻接顶点
// 顶点遍历序列
List<Vertex> res = [];
// 哈希表,用于记录已被访问过的顶点
Set<Vertex> visited = {};
visited.add(startVet);
// 队列用于实现 BFS
Queue<Vertex> que = Queue();
que.add(startVet);
// 以顶点 vet 为起点,循环直至访问完所有顶点
while (que.isNotEmpty) {
Vertex vet = que.removeFirst(); // 队首顶点出队
res.add(vet); // 记录访问顶点
// 遍历该顶点的所有邻接顶点
for (Vertex adjVet in graph.adjList[vet]!) {
if (visited.contains(adjVet)) {
continue; // 跳过已被访问过的顶点
}
que.add(adjVet); // 只入队未访问的顶点
visited.add(adjVet); // 标记该顶点已被访问
}
}
// 返回顶点遍历序列
return res;
}
```
代码相对抽象,建议对照以下动画图示来加深理解。 代码相对抽象,建议对照以下动画图示来加深理解。
=== "<1>" === "<1>"
@ -593,6 +625,39 @@ BFS 通常借助「队列」来实现。队列具有“先入先出”的性质
[class]{}-[func]{graphDFS} [class]{}-[func]{graphDFS}
``` ```
=== "Dart"
```dart title="graph_dfs.dart"
/* 深度优先遍历 DFS 辅助函数 */
void dfs(
GraphAdjList graph,
Set<Vertex> visited,
List<Vertex> res,
Vertex vet,
) {
res.add(vet); // 记录访问顶点
visited.add(vet); // 标记该顶点已被访问
// 遍历该顶点的所有邻接顶点
for (Vertex adjVet in graph.adjList[vet]!) {
if (visited.contains(adjVet)) {
continue; // 跳过已被访问过的顶点
}
// 递归访问邻接顶点
dfs(graph, visited, res, adjVet);
}
}
/* 深度优先遍历 DFS */
List<Vertex> graphDFS(GraphAdjList graph, Vertex startVet) {
// 顶点遍历序列
List<Vertex> res = [];
// 哈希表,用于记录已被访问过的顶点
Set<Vertex> visited = {};
dfs(graph, visited, res, startVet);
return res;
}
```
深度优先遍历的算法流程如下图所示,其中: 深度优先遍历的算法流程如下图所示,其中:
- **直虚线代表向下递推**,表示开启了一个新的递归方法来访问新顶点; - **直虚线代表向下递推**,表示开启了一个新的递归方法来访问新顶点;

@ -229,6 +229,12 @@ comments: true
``` ```
=== "Dart"
```dart title="hash_map.dart"
```
遍历哈希表有三种方式,即 **遍历键值对、遍历键、遍历值**。 遍历哈希表有三种方式,即 **遍历键值对、遍历键、遍历值**。
=== "Java" === "Java"
@ -387,6 +393,12 @@ comments: true
``` ```
=== "Dart"
```dart title="hash_map.dart"
```
## 6.1.2. &nbsp; 哈希函数 ## 6.1.2. &nbsp; 哈希函数
哈希表的底层实现为数组,同时可能包含链表、二叉树(红黑树)等数据结构,以提高查询性能(将在下节讨论)。 哈希表的底层实现为数组,同时可能包含链表、二叉树(红黑树)等数据结构,以提高查询性能(将在下节讨论)。
@ -1263,6 +1275,96 @@ $$
} }
``` ```
=== "Dart"
```dart title="array_hash_map.dart"
/* 键值对 int -> String */
class Entry {
int key;
String val;
Entry(this.key, this.val);
}
/* 基于数组简易实现的哈希表 */
class ArrayHashMap {
late List<Entry?> _buckets;
ArrayHashMap() {
// 初始化数组,包含 100 个桶
_buckets = List.filled(100, null);
}
/* 哈希函数 */
int _hashFunc(int key) {
final int index = key % 100;
return index;
}
/* 查询操作 */
String? get(int key) {
final int index = _hashFunc(key);
final Entry? pair = _buckets[index];
if (pair == null) {
return null;
}
return pair.val;
}
/* 添加操作 */
void put(int key, String val) {
final Entry pair = Entry(key, val);
final int index = _hashFunc(key);
_buckets[index] = pair;
}
/* 删除操作 */
void remove(int key) {
final int index = _hashFunc(key);
_buckets[index] = null;
}
/* 获取所有键值对 */
List<Entry> entrySet() {
List<Entry> entrySet = [];
for (final Entry? pair in _buckets) {
if (pair != null) {
entrySet.add(pair);
}
}
return entrySet;
}
/* 获取所有键 */
List<int> keySet() {
List<int> keySet = [];
for (final Entry? pair in _buckets) {
if (pair != null) {
keySet.add(pair.key);
}
}
return keySet;
}
/* 获取所有值 */
List<String> values() {
List<String> valueSet = [];
for (final Entry? pair in _buckets) {
if (pair != null) {
valueSet.add(pair.val);
}
}
return valueSet;
}
/* 打印哈希表 */
void printHashMap() {
for (final Entry kv in entrySet()) {
print("${kv.key} -> ${kv.val}");
}
}
}
```
## 6.1.3. &nbsp; 哈希冲突 ## 6.1.3. &nbsp; 哈希冲突
细心的你可能已经注意到,**在某些情况下,哈希函数 $f(x) = x \bmod 100$ 可能无法正常工作**。具体来说,当输入的 key 后两位相同时,哈希函数的计算结果也会相同,从而指向同一个 value 。例如,查询学号为 $12836$ 和 $20336$ 的两个学生时,我们得到: 细心的你可能已经注意到,**在某些情况下,哈希函数 $f(x) = x \bmod 100$ 可能无法正常工作**。具体来说,当输入的 key 后两位相同时,哈希函数的计算结果也会相同,从而指向同一个 value 。例如,查询学号为 $12836$ 和 $20336$ 的两个学生时,我们得到:

@ -162,6 +162,20 @@ comments: true
} }
``` ```
=== "Dart"
```dart title="my_heap.dart"
/* 构造方法,根据输入列表建堆 */
MaxHeap(List<int> nums) {
// 将列表元素原封不动添加进堆
_maxHeap = nums;
// 堆化除叶节点以外的其他所有节点
for (int i = _parent(size() - 1); i >= 0; i--) {
_siftDown(i);
}
}
```
## 8.2.3. &nbsp; 复杂度分析 ## 8.2.3. &nbsp; 复杂度分析
为什么第二种建堆方法的时间复杂度是 $O(n)$ ?我们来展开推算一下。 为什么第二种建堆方法的时间复杂度是 $O(n)$ ?我们来展开推算一下。

@ -307,6 +307,12 @@ comments: true
``` ```
=== "Dart"
```dart title="heap.dart"
```
## 8.1.2. &nbsp; 堆的实现 ## 8.1.2. &nbsp; 堆的实现
下文实现的是大顶堆。若要将其转换为小顶堆,只需将所有大小逻辑判断取逆(例如,将 $\geq$ 替换为 $\leq$ )。感兴趣的读者可以自行实现。 下文实现的是大顶堆。若要将其转换为小顶堆,只需将所有大小逻辑判断取逆(例如,将 $\geq$ 替换为 $\leq$ )。感兴趣的读者可以自行实现。
@ -514,6 +520,25 @@ comments: true
} }
``` ```
=== "Dart"
```dart title="my_heap.dart"
/* 获取左子节点索引 */
int _left(int i) {
return 2 * i + 1;
}
/* 获取右子节点索引 */
int _right(int i) {
return 2 * i + 2;
}
/* 获取父节点索引 */
int _parent(int i) {
return (i - 1) ~/ 2; // 向下整除
}
```
### 访问堆顶元素 ### 访问堆顶元素
堆顶元素即为二叉树的根节点,也就是列表的首个元素。 堆顶元素即为二叉树的根节点,也就是列表的首个元素。
@ -607,6 +632,15 @@ comments: true
} }
``` ```
=== "Dart"
```dart title="my_heap.dart"
/* 访问堆顶元素 */
int peek() {
return _maxHeap[0];
}
```
### 元素入堆 ### 元素入堆
给定元素 `val` ,我们首先将其添加到堆底。添加之后,由于 val 可能大于堆中其他元素,堆的成立条件可能已被破坏。因此,**需要修复从插入节点到根节点的路径上的各个节点**,这个操作被称为「堆化 Heapify」。 给定元素 `val` ,我们首先将其添加到堆底。添加之后,由于 val 可能大于堆中其他元素,堆的成立条件可能已被破坏。因此,**需要修复从插入节点到根节点的路径上的各个节点**,这个操作被称为「堆化 Heapify」。
@ -918,6 +952,20 @@ comments: true
} }
``` ```
=== "Dart"
```dart title="my_heap.dart"
/* 元素入堆 */
void push(int val) {
// 添加节点
_maxHeap.add(val);
// 从底至顶堆化
_siftUp(size() - 1);
}
[class]{MaxHeap}-[func]{siftUp}
```
### 堆顶元素出堆 ### 堆顶元素出堆
堆顶元素是二叉树的根节点,即列表首元素。如果我们直接从列表中删除首元素,那么二叉树中所有节点的索引都会发生变化,这将使得后续使用堆化修复变得困难。为了尽量减少元素索引的变动,我们采取以下操作步骤: 堆顶元素是二叉树的根节点,即列表首元素。如果我们直接从列表中删除首元素,那么二叉树中所有节点的索引都会发生变化,这将使得后续使用堆化修复变得困难。为了尽量减少元素索引的变动,我们采取以下操作步骤:
@ -1354,6 +1402,26 @@ comments: true
} }
``` ```
=== "Dart"
```dart title="my_heap.dart"
/* 元素出堆 */
int pop() {
// 判空处理
if (isEmpty()) throw Exception('堆为空');
// 交换根节点与最右叶节点(即交换首元素与尾元素)
_swap(0, size() - 1);
// 删除节点
int val = _maxHeap.removeLast();
// 从顶至底堆化
_siftDown(0);
// 返回堆顶元素
return val;
}
[class]{MaxHeap}-[func]{siftDown}
```
## 8.1.3. &nbsp; 堆常见应用 ## 8.1.3. &nbsp; 堆常见应用
- **优先队列**:堆通常作为实现优先队列的首选数据结构,其入队和出队操作的时间复杂度均为 $O(\log n)$ ,而建队操作为 $O(n)$ ,这些操作都非常高效。 - **优先队列**:堆通常作为实现优先队列的首选数据结构,其入队和出队操作的时间复杂度均为 $O(\log n)$ ,而建队操作为 $O(n)$ ,这些操作都非常高效。

@ -148,6 +148,17 @@ comments: true
// 注释 // 注释
``` ```
=== "Dart"
```dart title=""
// 标题注释,用于标注函数、类、测试样例等
// 内容注释,用于详解代码
// 多行
// 注释
```
## 0.2.2. &nbsp; 在动画图解中高效学习 ## 0.2.2. &nbsp; 在动画图解中高效学习
相较于文字,视频和图片具有更高的信息密度和结构化程度,因此更易于理解。在本书中,**重点和难点知识将主要通过动画和图解形式展示**,而文字则作为动画和图片的解释与补充。 相较于文字,视频和图片具有更高的信息密度和结构化程度,因此更易于理解。在本书中,**重点和难点知识将主要通过动画和图解形式展示**,而文字则作为动画和图片的解释与补充。

@ -279,6 +279,32 @@ comments: true
} }
``` ```
=== "Dart"
```dart title="binary_search.dart"
/* 二分查找(双闭区间) */
int binarySearch(List<int> nums, int target) {
// 初始化双闭区间 [0, n-1] ,即 i, j 分别指向数组首元素、尾元素
int i = 0, j = nums.length - 1;
// 循环,当搜索区间为空时跳出(当 i > j 时为空)
while (i <= j) {
int m = i + (j - i) ~/ 2; // 计算中点索引 m
if (nums[m] < target) {
// 此情况说明 target 在区间 [m+1, j] 中
i = m + 1;
} else if (nums[m] > target) {
// 此情况说明 target 在区间 [i, m-1] 中
j = m - 1;
} else {
// 找到目标元素,返回其索引
return m;
}
}
// 未找到目标元素,返回 -1
return -1;
}
```
时间复杂度为 $O(\log n)$ 。每轮缩小一半区间,因此二分循环次数为 $\log_2 n$ 。 时间复杂度为 $O(\log n)$ 。每轮缩小一半区间,因此二分循环次数为 $\log_2 n$ 。
空间复杂度为 $O(1)$ 。指针 `i` , `j` 使用常数大小空间。 空间复杂度为 $O(1)$ 。指针 `i` , `j` 使用常数大小空间。
@ -520,6 +546,32 @@ comments: true
} }
``` ```
=== "Dart"
```dart title="binary_search.dart"
/* 二分查找(左闭右开区间) */
int binarySearchLCRO(List<int> nums, int target) {
// 初始化左闭右开 [0, n) ,即 i, j 分别指向数组首元素、尾元素+1
int i = 0, j = nums.length;
// 循环,当搜索区间为空时跳出(当 i = j 时为空)
while (i < j) {
int m = i + (j - i) ~/ 2; // 计算中点索引 m
if (nums[m] < target) {
// 此情况说明 target 在区间 [m+1, j) 中
i = m + 1;
} else if (nums[m] > target) {
// 此情况说明 target 在区间 [i, m) 中
j = m;
} else {
// 找到目标元素,返回其索引
return m;
}
}
// 未找到目标元素,返回 -1
return -1;
}
```
如下图所示,在两种区间表示下,二分查找算法的初始化、循环条件和缩小区间操作皆有所不同。 如下图所示,在两种区间表示下,二分查找算法的初始化、循环条件和缩小区间操作皆有所不同。
在“双闭区间”表示法中,由于左右边界都被定义为闭区间,因此指针 $i$ 和 $j$ 缩小区间操作也是对称的。这样更不容易出错。因此,**我们通常采用“双闭区间”的写法**。 在“双闭区间”表示法中,由于左右边界都被定义为闭区间,因此指针 $i$ 和 $j$ 缩小区间操作也是对称的。这样更不容易出错。因此,**我们通常采用“双闭区间”的写法**。

@ -218,6 +218,12 @@ comments: true
[class]{}-[func]{binarySearchLeftEdge} [class]{}-[func]{binarySearchLeftEdge}
``` ```
=== "Dart"
```dart title="binary_search_edge.dart"
[class]{}-[func]{binarySearchLeftEdge}
```
## 10.2.3. &nbsp; 查找右边界 ## 10.2.3. &nbsp; 查找右边界
类似地,我们也可以二分查找最右边的 `target` 。当 `nums[m] == target` 时,说明大于 `target` 的元素在区间 $[m + 1, j]$ 中,因此执行 `i = m + 1` **使得指针 $i$ 向大于 `target` 的元素靠近**。 类似地,我们也可以二分查找最右边的 `target` 。当 `nums[m] == target` 时,说明大于 `target` 的元素在区间 $[m + 1, j]$ 中,因此执行 `i = m + 1` **使得指针 $i$ 向大于 `target` 的元素靠近**。
@ -384,6 +390,12 @@ comments: true
[class]{}-[func]{binarySearchRightEdge} [class]{}-[func]{binarySearchRightEdge}
``` ```
=== "Dart"
```dart title="binary_search_edge.dart"
[class]{}-[func]{binarySearchRightEdge}
```
观察下图,搜索最右边元素时指针 $j$ 的作用与搜索最左边元素时指针 $i$ 的作用一致,反之亦然。也就是说,**搜索最左边元素和最右边元素的实现是镜像对称的**。 观察下图,搜索最右边元素时指针 $j$ 的作用与搜索最左边元素时指针 $i$ 的作用一致,反之亦然。也就是说,**搜索最左边元素和最右边元素的实现是镜像对称的**。
![查找最左边和最右边元素的对称性](binary_search_edge.assets/binary_search_left_right_edge.png) ![查找最左边和最右边元素的对称性](binary_search_edge.assets/binary_search_left_right_edge.png)

@ -193,6 +193,21 @@ comments: true
} }
``` ```
=== "Dart"
```dart title="two_sum.dart"
/* 方法一: 暴力枚举 */
List<int> twoSumBruteForce(List<int> nums, int target) {
int size = nums.length;
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];
}
}
return [0];
}
```
此方法的时间复杂度为 $O(n^2)$ ,空间复杂度为 $O(1)$ ,在大数据量下非常耗时。 此方法的时间复杂度为 $O(n^2)$ ,空间复杂度为 $O(1)$ ,在大数据量下非常耗时。
## 10.3.2. &nbsp; 哈希查找:以空间换时间 ## 10.3.2. &nbsp; 哈希查找:以空间换时间
@ -430,6 +445,23 @@ comments: true
} }
``` ```
=== "Dart"
```dart title="two_sum.dart"
/* 方法二: 辅助哈希表 */
List<int> twoSumHashTable(List<int> nums, int target) {
int size = nums.length;
Map<int, int> dic = HashMap();
for (var i = 0; i < size; i++) {
if (dic.containsKey(target - nums[i])) {
return [dic[target - nums[i]]!, i];
}
dic.putIfAbsent(nums[i], () => i);
}
return [0];
}
```
此方法通过哈希查找将时间复杂度从 $O(n^2)$ 降低至 $O(n)$ ,大幅提升运行效率。 此方法通过哈希查找将时间复杂度从 $O(n^2)$ 降低至 $O(n)$ ,大幅提升运行效率。
由于需要维护一个额外的哈希表,因此空间复杂度为 $O(n)$ 。**尽管如此,该方法的整体时空效率更为均衡,因此它是本题的最优解法**。 由于需要维护一个额外的哈希表,因此空间复杂度为 $O(n)$ 。**尽管如此,该方法的整体时空效率更为均衡,因此它是本题的最优解法**。

@ -235,6 +235,26 @@ comments: true
} }
``` ```
=== "Dart"
```dart title="bubble_sort.dart"
/* 冒泡排序 */
void bubbleSort(List<int> nums) {
// 外循环:未排序区间为 [0, i]
for (int i = nums.length - 1; i > 0; i--) {
// 内循环:将未排序区间 [0, i] 中的最大元素交换至该区间的最右端
for (int j = 0; j < i; j++) {
if (nums[j] > nums[j + 1]) {
// 交换 nums[j] 与 nums[j + 1]
int tmp = nums[j];
nums[j] = nums[j + 1];
nums[j + 1] = tmp;
}
}
}
}
```
## 11.3.2. &nbsp; 效率优化 ## 11.3.2. &nbsp; 效率优化
我们发现,如果某轮“冒泡”中没有执行任何交换操作,说明数组已经完成排序,可直接返回结果。因此,可以增加一个标志位 `flag` 来监测这种情况,一旦出现就立即返回。 我们发现,如果某轮“冒泡”中没有执行任何交换操作,说明数组已经完成排序,可直接返回结果。因此,可以增加一个标志位 `flag` 来监测这种情况,一旦出现就立即返回。
@ -471,6 +491,29 @@ comments: true
} }
``` ```
=== "Dart"
```dart title="bubble_sort.dart"
/* 冒泡排序(标志优化)*/
void bubbleSortWithFlag(List<int> nums) {
// 外循环:未排序区间为 [0, i]
for (int i = nums.length - 1; i > 0; i--) {
bool flag = false; // 初始化标志位
// 内循环:将未排序区间 [0, i] 中的最大元素交换至该区间的最右端
for (int j = 0; j < i; j++) {
if (nums[j] > nums[j + 1]) {
// 交换 nums[j] 与 nums[j + 1]
int tmp = nums[j];
nums[j] = nums[j + 1];
nums[j + 1] = tmp;
flag = true; // 记录交换元素
}
}
if (!flag) break; // 此轮冒泡未交换任何元素,直接跳出
}
}
```
## 11.3.3. &nbsp; 算法特性 ## 11.3.3. &nbsp; 算法特性
- **时间复杂度为 $O(n^2)$ 、自适应排序** :各轮“冒泡”遍历的数组长度依次为 $n - 1$ , $n - 2$ , $\cdots$ , $2$ , $1$ ,总和为 $\frac{(n - 1) n}{2}$ 。在引入 `flag` 优化后,最佳时间复杂度可达到 $O(n)$ 。 - **时间复杂度为 $O(n^2)$ 、自适应排序** :各轮“冒泡”遍历的数组长度依次为 $n - 1$ , $n - 2$ , $\cdots$ , $2$ , $1$ ,总和为 $\frac{(n - 1) n}{2}$ 。在引入 `flag` 优化后,最佳时间复杂度可达到 $O(n)$ 。

@ -285,6 +285,36 @@ comments: true
[class]{}-[func]{bucketSort} [class]{}-[func]{bucketSort}
``` ```
=== "Dart"
```dart title="bucket_sort.dart"
/* 桶排序 */
void bucketSort(List<double> nums) {
// 初始化 k = n/2 个桶,预期向每个桶分配 2 个元素
int k = nums.length ~/ 2;
List<List<double>> buckets = List.generate(k, (index) => []);
// 1. 将数组元素分配到各个桶中
for (double num in nums) {
// 输入数据范围 [0, 1),使用 num * k 映射到索引范围 [0, k-1]
int i = (num * k).toInt();
// 将 num 添加进桶 bucket_idx
buckets[i].add(num);
}
// 2. 对各个桶执行排序
for (List<double> bucket in buckets) {
bucket.sort();
}
// 3. 遍历桶合并结果
int i = 0;
for (List<double> bucket in buckets) {
for (double num in bucket) {
nums[i++] = num;
}
}
}
```
!!! question "桶排序的适用场景是什么?" !!! question "桶排序的适用场景是什么?"
桶排序适用于处理体量很大的数据。例如,输入数据包含 100 万个元素,由于空间限制,系统内存无法一次性加载所有数据。此时,可以将数据分成 1000 个桶,然后分别对每个桶进行排序,最后将结果合并。 桶排序适用于处理体量很大的数据。例如,输入数据包含 100 万个元素,由于空间限制,系统内存无法一次性加载所有数据。此时,可以将数据分成 1000 个桶,然后分别对每个桶进行排序,最后将结果合并。

@ -265,6 +265,33 @@ comments: true
[class]{}-[func]{countingSortNaive} [class]{}-[func]{countingSortNaive}
``` ```
=== "Dart"
```dart title="counting_sort.dart"
/* 计数排序 */
// 简单实现,无法用于排序对象
void countingSortNaive(List<int> nums) {
// 1. 统计数组最大元素 m
int m = 0;
for (int num in nums) {
m = max(m, num);
}
// 2. 统计各数字的出现次数
// counter[num] 代表 num 的出现次数
List<int> counter = List.filled(m + 1, 0);
for (int num in nums) {
counter[num]++;
}
// 3. 遍历 counter ,将各元素填入原数组 nums
int i = 0;
for (int num = 0; num < m + 1; num++) {
for (int j = 0; j < counter[num]; j++, i++) {
nums[i] = num;
}
}
}
```
!!! note "计数排序与桶排序的联系" !!! note "计数排序与桶排序的联系"
从桶排序的角度看,我们可以将计数排序中的计数数组 `counter` 的每个索引视为一个桶,将统计数量的过程看作是将各个元素分配到对应的桶中。本质上,计数排序是桶排序在整型数据下的一个特例。 从桶排序的角度看,我们可以将计数排序中的计数数组 `counter` 的每个索引视为一个桶,将统计数量的过程看作是将各个元素分配到对应的桶中。本质上,计数排序是桶排序在整型数据下的一个特例。
@ -647,6 +674,42 @@ $$
[class]{}-[func]{countingSort} [class]{}-[func]{countingSort}
``` ```
=== "Dart"
```dart title="counting_sort.dart"
/* 计数排序 */
// 完整实现,可排序对象,并且是稳定排序
void countingSort(List<int> nums) {
// 1. 统计数组最大元素 m
int m = 0;
for (int num in nums) {
m = max(m, num);
}
// 2. 统计各数字的出现次数
// counter[num] 代表 num 的出现次数
List<int> counter = List.filled(m + 1, 0);
for (int num in nums) {
counter[num]++;
}
// 3. 求 counter 的前缀和,将“出现次数”转换为“尾索引”
// 即 counter[num]-1 是 num 在 res 中最后一次出现的索引
for (int i = 0; i < m; i++) {
counter[i + 1] += counter[i];
}
// 4. 倒序遍历 nums ,将各元素填入结果数组 res
// 初始化数组 res 用于记录结果
int n = nums.length;
List<int> res = List.filled(n, 0);
for (int i = n - 1; i >= 0; i--) {
int num = nums[i];
res[counter[num] - 1] = num; // 将 num 放置到对应索引处
counter[num]--; // 令前缀和自减 1 ,得到下次放置 num 的索引
}
// 使用结果数组 res 覆盖原数组 nums
nums.setAll(0, res);
}
```
## 11.9.3. &nbsp; 算法特性 ## 11.9.3. &nbsp; 算法特性
- **时间复杂度 $O(n + m)$** :涉及遍历 `nums` 和遍历 `counter` ,都使用线性时间。一般情况下 $n \gg m$ ,时间复杂度趋于 $O(n)$ 。 - **时间复杂度 $O(n + m)$** :涉及遍历 `nums` 和遍历 `counter` ,都使用线性时间。一般情况下 $n \gg m$ ,时间复杂度趋于 $O(n)$ 。

@ -343,6 +343,14 @@ comments: true
[class]{}-[func]{heapSort} [class]{}-[func]{heapSort}
``` ```
=== "Dart"
```dart title="heap_sort.dart"
[class]{}-[func]{siftDown}
[class]{}-[func]{heapSort}
```
## 11.7.2. &nbsp; 算法特性 ## 11.7.2. &nbsp; 算法特性
- **时间复杂度 $O(n \log n)$ 、非自适应排序** :建堆操作使用 $O(n)$ 时间。从堆中提取最大元素的时间复杂度为 $O(\log n)$ ,共循环 $n - 1$ 轮。 - **时间复杂度 $O(n \log n)$ 、非自适应排序** :建堆操作使用 $O(n)$ 时间。从堆中提取最大元素的时间复杂度为 $O(\log n)$ ,共循环 $n - 1$ 轮。

@ -212,6 +212,24 @@ comments: true
} }
``` ```
=== "Dart"
```dart title="insertion_sort.dart"
/* 插入排序 */
void insertionSort(List<int> nums) {
// 外循环:已排序元素数量为 1, 2, ..., n
for (int i = 1; i < nums.length; i++) {
int base = nums[i], j = i - 1;
// 内循环:将 base 插入到已排序部分的正确位置
while (j >= 0 && nums[j] > base) {
nums[j + 1] = nums[j]; // 将 nums[j] 向右移动一位
j--;
}
nums[j + 1] = base; // 将 base 赋值到正确位置
}
}
```
## 11.4.2. &nbsp; 算法特性 ## 11.4.2. &nbsp; 算法特性
- **时间复杂度 $O(n^2)$ 、自适应排序** :最差情况下,每次插入操作分别需要循环 $n - 1$ , $n-2$ , $\cdots$ , $2$ , $1$ 次,求和得到 $\frac{(n - 1) n}{2}$ ,因此时间复杂度为 $O(n^2)$ 。在遇到有序数据时,插入操作会提前终止。当输入数组完全有序时,插入排序达到最佳时间复杂度 $O(n)$ 。 - **时间复杂度 $O(n^2)$ 、自适应排序** :最差情况下,每次插入操作分别需要循环 $n - 1$ , $n-2$ , $\cdots$ , $2$ , $1$ 次,求和得到 $\frac{(n - 1) n}{2}$ ,因此时间复杂度为 $O(n^2)$ 。在遇到有序数据时,插入操作会提前终止。当输入数组完全有序时,插入排序达到最佳时间复杂度 $O(n)$ 。

@ -528,6 +528,48 @@ comments: true
} }
``` ```
=== "Dart"
```dart title="merge_sort.dart"
/* 合并左子数组和右子数组 */
// 左子数组区间 [left, mid]
// 右子数组区间 [mid + 1, right]
void merge(List<int> nums, int left, int mid, int right) {
// 初始化辅助数组
List<int> tmp = nums.sublist(left, right + 1);
// 左子数组的起始索引和结束索引
int leftStart = left - left, leftEnd = mid - left;
// 右子数组的起始索引和结束索引
int rightStart = mid + 1 - left, rightEnd = right - left;
// i, j 分别指向左子数组、右子数组的首元素
int i = leftStart, j = rightStart;
// 通过覆盖原数组 nums 来合并左子数组和右子数组
for (int k = left; k <= right; k++) {
// 若“左子数组已全部合并完”,则选取右子数组元素,并且 j++
if (i > leftEnd)
nums[k] = tmp[j++];
// 否则,若“右子数组已全部合并完”或“左子数组元素 <= 右子数组元素”,则选取左子数组元素,并且 i++
else if (j > rightEnd || tmp[i] <= tmp[j])
nums[k] = tmp[i++];
// 否则,若“左右子数组都未全部合并完”且“左子数组元素 > 右子数组元素”,则选取右子数组元素,并且 j++
else
nums[k] = tmp[j++];
}
}
/* 归并排序 */
void mergeSort(List<int> nums, int left, int right) {
// 终止条件
if (left >= right) return; // 当子数组长度为 1 时终止递归
// 划分阶段
int mid = (left + right) ~/ 2; // 计算中点
mergeSort(nums, left, mid); // 递归左子数组
mergeSort(nums, mid + 1, right); // 递归右子数组
// 合并阶段
merge(nums, left, mid, right);
}
```
合并方法 `merge()` 代码中的难点包括: 合并方法 `merge()` 代码中的难点包括:
- **在阅读代码时,需要特别注意各个变量的含义**。`nums` 的待合并区间为 `[left, right]` ,但由于 `tmp` 仅复制了 `nums` 该区间的元素,因此 `tmp` 对应区间为 `[0, right - left]` - **在阅读代码时,需要特别注意各个变量的含义**。`nums` 的待合并区间为 `[left, right]` ,但由于 `tmp` 仅复制了 `nums` 该区间的元素,因此 `tmp` 对应区间为 `[0, right - left]`

@ -313,6 +313,30 @@ comments: true
} }
``` ```
=== "Dart"
```dart title="quick_sort.dart"
/* 元素交换 */
void _swap(List<int> nums, int i, int j) {
int tmp = nums[i];
nums[i] = nums[j];
nums[j] = tmp;
}
/* 哨兵划分 */
int _partition(List<int> nums, int left, int right) {
// 以 nums[left] 作为基准数
int i = left, j = right;
while (i < j) {
while (i < j && nums[j] >= nums[left]) j--; // 从右向左找首个小于基准数的元素
while (i < j && nums[i] <= nums[left]) i++; //
_swap(nums, i, j); // 交换这两个元素
}
_swap(nums, i, left); // 将基准数交换至两子数组的分界线
return i; // 返回基准数的索引
}
```
## 11.5.1. &nbsp; 算法流程 ## 11.5.1. &nbsp; 算法流程
1. 首先,对原数组执行一次「哨兵划分」,得到未排序的左子数组和右子数组; 1. 首先,对原数组执行一次「哨兵划分」,得到未排序的左子数组和右子数组;
@ -507,6 +531,21 @@ comments: true
} }
``` ```
=== "Dart"
```dart title="quick_sort.dart"
/* 快速排序 */
void quickSort(List<int> nums, int left, int right) {
// 子数组长度为 1 时终止递归
if (left >= right) return;
// 哨兵划分
int pivot = _partition(nums, left, right);
// 递归左子数组、右子数组
quickSort(nums, left, pivot - 1);
quickSort(nums, pivot + 1, right);
}
```
## 11.5.2. &nbsp; 算法特性 ## 11.5.2. &nbsp; 算法特性
- **时间复杂度 $O(n \log n)$ 、自适应排序** :在平均情况下,哨兵划分的递归层数为 $\log n$ ,每层中的总循环数为 $n$ ,总体使用 $O(n \log n)$ 时间。在最差情况下,每轮哨兵划分操作都将长度为 $n$ 的数组划分为长度为 $0$ 和 $n - 1$ 的两个子数组,此时递归层数达到 $n$ 层,每层中的循环数为 $n$ ,总体使用 $O(n^2)$ 时间。 - **时间复杂度 $O(n \log n)$ 、自适应排序** :在平均情况下,哨兵划分的递归层数为 $\log n$ ,每层中的总循环数为 $n$ ,总体使用 $O(n \log n)$ 时间。在最差情况下,每轮哨兵划分操作都将长度为 $n$ 的数组划分为长度为 $0$ 和 $n - 1$ 的两个子数组,此时递归层数达到 $n$ 层,每层中的循环数为 $n$ ,总体使用 $O(n^2)$ 时间。
@ -904,6 +943,39 @@ comments: true
} }
``` ```
=== "Dart"
```dart title="quick_sort.dart"
/* 选取三个元素的中位数 */
int _medianThree(List<int> nums, int left, int mid, int right) {
// 此处使用异或运算来简化代码
// 异或规则为 0 ^ 0 = 1 ^ 1 = 0, 0 ^ 1 = 1 ^ 0 = 1
if ((nums[left] < nums[mid]) ^ (nums[left] < nums[right]))
return left;
else if ((nums[mid] < nums[left]) ^ (nums[mid] < nums[right]))
return mid;
else
return right;
}
/* 哨兵划分(三数取中值) */
int _partition(List<int> nums, int left, int right) {
// 选取三个候选元素的中位数
int med = _medianThree(nums, left, (left + right) ~/ 2, right);
// 将中位数交换至数组最左端
_swap(nums, left, med);
// 以 nums[left] 作为基准数
int i = left, j = right;
while (i < j) {
while (i < j && nums[j] >= nums[left]) j--; // 从右向左找首个小于基准数的元素
while (i < j && nums[i] <= nums[left]) i++; //
_swap(nums, i, j); // 交换这两个元素
}
_swap(nums, i, left); // 将基准数交换至两子数组的分界线
return i; // 返回基准数的索引
}
```
## 11.5.5. &nbsp; 尾递归优化 ## 11.5.5. &nbsp; 尾递归优化
**在某些输入下,快速排序可能占用空间较多**。以完全倒序的输入数组为例,由于每轮哨兵划分后右子数组长度为 $0$ ,递归树的高度会达到 $n - 1$ ,此时需要占用 $O(n)$ 大小的栈帧空间。 **在某些输入下,快速排序可能占用空间较多**。以完全倒序的输入数组为例,由于每轮哨兵划分后右子数组长度为 $0$ ,递归树的高度会达到 $n - 1$ ,此时需要占用 $O(n)$ 大小的栈帧空间。
@ -1121,3 +1193,24 @@ comments: true
} }
} }
``` ```
=== "Dart"
```dart title="quick_sort.dart"
/* 快速排序(尾递归优化) */
void quickSort(List<int> nums, int left, int right) {
// 子数组长度为 1 时终止
while (left < right) {
// 哨兵划分操作
int pivot = _partition(nums, left, right);
// 对两个子数组中较短的那个执行快排
if (pivot - left < right - pivot) {
quickSort(nums, left, pivot - 1); // 递归排序左子数组
left = pivot + 1; // 剩余未排序区间为 [pivot + 1, right]
} else {
quickSort(nums, pivot + 1, right); // 递归排序右子数组
right = pivot - 1; // 剩余未排序区间为 [left, pivot - 1]
}
}
}
```

@ -583,6 +583,57 @@ $$
} }
``` ```
=== "Dart"
```dart title="radix_sort.dart"
/* 获取元素 num 的第 k 位,其中 exp = 10^(k-1) */
int digit(int num, int exp) {
// 传入 exp 而非 k 可以避免在此重复执行昂贵的次方计算
return (num ~/ exp) % 10;
}
/* 计数排序(根据 nums 第 k 位排序) */
void countingSortDigit(List<int> nums, int exp) {
// 十进制的位范围为 0~9 ,因此需要长度为 10 的桶
List<int> counter = List<int>.filled(10, 0);
int n = nums.length;
// 统计 0~9 各数字的出现次数
for (int i = 0; i < n; i++) {
int d = digit(nums[i], exp); // 获取 nums[i] 第 k 位,记为 d
counter[d]++; // 统计数字 d 的出现次数
}
// 求前缀和,将“出现个数”转换为“数组索引”
for (int i = 1; i < 10; i++) {
counter[i] += counter[i - 1];
}
// 倒序遍历,根据桶内统计结果,将各元素填入 res
List<int> res = List<int>.filled(n, 0);
for (int i = n - 1; i >= 0; i--) {
int d = digit(nums[i], exp);
int j = counter[d] - 1; // 获取 d 在数组中的索引 j
res[j] = nums[i]; // 将当前元素填入索引 j
counter[d]--; // 将 d 的数量减 1
}
// 使用结果覆盖原数组 nums
for (int i = 0; i < n; i++) nums[i] = res[i];
}
/* 基数排序 */
void radixSort(List<int> nums) {
// 获取数组的最大元素,用于判断最大位数
// dart 中 int 的长度是 64 位的
int m = -1 << 63;
for (int num in nums) if (num > m) m = num;
// 按照从低位到高位的顺序遍历
for (int exp = 1; exp <= m; exp *= 10)
// 对数组元素的第 k 位执行计数排序
// k = 1 -> exp = 1
// k = 2 -> exp = 10
// 即 exp = 10^(k-1)
countingSortDigit(nums, exp);
}
```
!!! question "为什么从最低位开始排序?" !!! question "为什么从最低位开始排序?"
在连续的排序轮次中,后一轮排序会覆盖前一轮排序的结果。举例来说,如果第一轮排序结果 $a < b$ $a > b$ ,那么第二轮的结果将取代第一轮的结果。由于数字的高位优先级高于低位,我们应该先排序低位再排序高位。 在连续的排序轮次中,后一轮排序会覆盖前一轮排序的结果。举例来说,如果第一轮排序结果 $a < b$ $a > b$ ,那么第二轮的结果将取代第一轮的结果。由于数字的高位优先级高于低位,我们应该先排序低位再排序高位。

@ -195,6 +195,12 @@ comments: true
[class]{}-[func]{selectionSort} [class]{}-[func]{selectionSort}
``` ```
=== "Dart"
```dart title="selection_sort.dart"
[class]{}-[func]{selectionSort}
```
## 11.2.1. &nbsp; 算法特性 ## 11.2.1. &nbsp; 算法特性
- **时间复杂度为 $O(n^2)$ 、非自适应排序**:外循环共 $n - 1$ 轮,第一轮的未排序区间长度为 $n$ ,最后一轮的未排序区间长度为 $2$ ,即各轮外循环分别包含 $n$ , $n - 1$ , $\cdots$ , $2$ 轮内循环,求和为 $\frac{(n - 1)(n + 2)}{2}$ 。 - **时间复杂度为 $O(n^2)$ 、非自适应排序**:外循环共 $n - 1$ 轮,第一轮的未排序区间长度为 $n$ ,最后一轮的未排序区间长度为 $2$ ,即各轮外循环分别包含 $n$ , $n - 1$ , $\cdots$ , $2$ 轮内循环,求和为 $\frac{(n - 1)(n + 2)}{2}$ 。

@ -289,6 +289,12 @@ comments: true
``` ```
=== "Dart"
```dart title="deque.dart"
```
## 5.3.2. &nbsp; 双向队列实现 * ## 5.3.2. &nbsp; 双向队列实现 *
双向队列的实现与队列类似,可以选择链表或数组作为底层数据结构。 双向队列的实现与队列类似,可以选择链表或数组作为底层数据结构。
@ -1636,6 +1642,136 @@ comments: true
} }
``` ```
=== "Dart"
```dart title="linkedlist_deque.dart"
/* 双向链表节点 */
class ListNode {
int val; // 节点值
ListNode? next; // 后继节点引用(指针)
ListNode? prev; // 前驱节点引用(指针)
ListNode(this.val, {this.next, this.prev});
}
/* 基于双向链表实现的双向对列 */
class LinkedListDeque {
late ListNode? _front; // 头节点 _front
late ListNode? _rear; // 尾节点 _rear
int _queSize = 0; // 双向队列的长度
LinkedListDeque() {
this._front = null;
this._rear = null;
}
/* 获取双向队列长度 */
int size() {
return this._queSize;
}
/* 判断双向队列是否为空 */
bool isEmpty() {
return size() == 0;
}
/* 入队操作 */
void push(int num, bool isFront) {
final ListNode node = ListNode(num);
if (isEmpty()) {
// 若链表为空,则令 _front_rear 都指向 node
_front = _rear = node;
} else if (isFront) {
// 队首入队操作
// 将 node 添加至链表头部
_front!.prev = node;
node.next = _front;
_front = node; // 更新头节点
} else {
// 队尾入队操作
// 将 node 添加至链表尾部
_rear!.next = node;
node.prev = _rear;
_rear = node; // 更新尾节点
}
_queSize++; // 更新队列长度
}
/* 队首入队 */
void pushFirst(int num) {
push(num, true);
}
/* 队尾入队 */
void pushLast(int num) {
push(num, false);
}
/* 出队操作 */
int? pop(bool isFront) {
// 若队列为空,直接返回 null
if (isEmpty()) {
return null;
}
final int val;
if (isFront) {
// 队首出队操作
val = _front!.val; // 暂存头节点值
// 删除头节点
ListNode? fNext = _front!.next;
if (fNext != null) {
fNext.prev = null;
_front!.next = null;
}
_front = fNext; // 更新头节点
} else {
// 队尾出队操作
val = _rear!.val; // 暂存尾节点值
// 删除尾节点
ListNode? rPrev = _rear!.prev;
if (rPrev != null) {
rPrev.next = null;
_rear!.prev = null;
}
_rear = rPrev; // 更新尾节点
}
_queSize--; // 更新队列长度
return val;
}
/* 队首出队 */
int? popFirst() {
return pop(true);
}
/* 队尾出队 */
int? popLast() {
return pop(false);
}
/* 访问队首元素 */
int? peekFirst() {
return _front?.val;
}
/* 访问队尾元素 */
int? peekLast() {
return _rear?.val;
}
/* 返回数组用于打印 */
List<int> toArray() {
ListNode? node = _front;
final List<int> res = [];
for (int i = 0; i < _queSize; i++) {
res.add(node!.val);
node = node.next;
}
return res;
}
}
```
### 基于数组的实现 ### 基于数组的实现
与基于数组实现队列类似,我们也可以使用环形数组来实现双向队列。在队列的实现基础上,仅需增加“队首入队”和“队尾出队”的方法。 与基于数组实现队列类似,我们也可以使用环形数组来实现双向队列。在队列的实现基础上,仅需增加“队首入队”和“队尾出队”的方法。
@ -2650,6 +2786,115 @@ comments: true
[class]{ArrayDeque}-[func]{} [class]{ArrayDeque}-[func]{}
``` ```
=== "Dart"
```dart title="array_deque.dart"
/* 基于环形数组实现的双向队列 */
class ArrayDeque {
late List<int> _nums; // 用于存储双向队列元素的数组
late int _front; // 队首指针,指向队首元素
late int _queSize; // 双向队列长度
/* 构造方法 */
ArrayDeque(int capacity) {
this._nums = List.filled(capacity, 0);
this._front = this._queSize = 0;
}
/* 获取双向队列的容量 */
int capacity() {
return _nums.length;
}
/* 获取双向队列的长度 */
int size() {
return _queSize;
}
/* 判断双向队列是否为空 */
bool isEmpty() {
return _queSize == 0;
}
/* 计算环形数组索引 */
int index(int i) {
// 通过取余操作实现数组首尾相连
// 当 i 越过数组尾部后,回到头部
// 当 i 越过数组头部后,回到尾部
return (i + capacity()) % capacity();
}
/* 队首入队 */
void pushFirst(int num) {
if (_queSize == capacity()) {
throw Exception("双向队列已满");
}
// 队首指针向左移动一位
// 通过取余操作,实现 _front 越过数组头部后回到尾部
_front = index(_front - 1);
// 将 num 添加至队首
_nums[_front] = num;
_queSize++;
}
/* 队尾入队 */
void pushLast(int num) {
if (_queSize == capacity()) {
throw Exception("双向队列已满");
}
// 计算尾指针,指向队尾索引 + 1
int rear = index(_front + _queSize);
// 将 num 添加至队尾
_nums[rear] = num;
_queSize++;
}
/* 队首出队 */
int popFirst() {
int num = peekFirst();
// 队首指针向右移动一位
_front = index(_front + 1);
_queSize--;
return num;
}
/* 队尾出队 */
int popLast() {
int num = peekLast();
_queSize--;
return num;
}
/* 访问队首元素 */
int peekFirst() {
if (isEmpty()) {
throw Exception("双向队列为空");
}
return _nums[_front];
}
/* 访问队尾元素 */
int peekLast() {
if (isEmpty()) {
throw Exception("双向队列为空");
}
// 计算尾元素索引
int last = index(_front + _queSize - 1);
return _nums[last];
}
/* 返回数组用于打印 */
List<int> toArray() {
// 仅转换有效长度范围内的列表元素
List<int> res = List.filled(_queSize, 0);
for (int i = 0, j = _front; i < _queSize; i++, j++) {
res[i] = _nums[index(j)];
}
return res;
}
}
```
## 5.3.3. &nbsp; 双向队列应用 ## 5.3.3. &nbsp; 双向队列应用
双向队列兼具栈与队列的逻辑,**因此它可以实现这两者的所有应用场景,同时提供更高的自由度**。 双向队列兼具栈与队列的逻辑,**因此它可以实现这两者的所有应用场景,同时提供更高的自由度**。

@ -258,6 +258,12 @@ comments: true
``` ```
=== "Dart"
```dart title="queue.dart"
```
## 5.2.2. &nbsp; 队列实现 ## 5.2.2. &nbsp; 队列实现
为了实现队列,我们需要一种数据结构,可以在一端添加元素,并在另一端删除元素。因此,链表和数组都可以用来实现队列。 为了实现队列,我们需要一种数据结构,可以在一端添加元素,并在另一端删除元素。因此,链表和数组都可以用来实现队列。
@ -993,6 +999,76 @@ comments: true
} }
``` ```
=== "Dart"
```dart title="linkedlist_queue.dart"
/* 基于链表实现的队列 */
class LinkedListQueue {
ListNode? _front; // 头节点 _front
ListNode? _rear; // 尾节点 _rear
int _queSize = 0; // 队列长度
LinkedListQueue() {
_front = null;
_rear = null;
}
/* 获取队列的长度 */
int size() {
return _queSize;
}
/* 判断队列是否为空 */
bool isEmpty() {
return _queSize == 0;
}
/* 入队 */
void push(int num) {
// 尾节点后添加 num
final node = ListNode(num);
// 如果队列为空,则令头、尾节点都指向该节点
if (_front == null) {
_front = node;
_rear = node;
} else {
// 如果队列不为空,则将该节点添加到尾节点后
_rear!.next = node;
_rear = node;
}
_queSize++;
}
/* 出队 */
int pop() {
final int num = peek();
// 删除头节点
_front = _front!.next;
_queSize--;
return num;
}
/* 访问队首元素 */
int peek() {
if (_queSize == 0) {
throw Exception('队列为空');
}
return _front!.val;
}
/* 将链表转化为 Array 并返回 */
List<int> toArray() {
ListNode? node = _front;
final List<int> queue = [];
while (node != null) {
queue.add(node.val);
node = node.next;
}
return queue;
}
}
```
### 基于数组的实现 ### 基于数组的实现
由于数组删除首元素的时间复杂度为 $O(n)$ ,这会导致出队操作效率较低。然而,我们可以采用以下巧妙方法来避免这个问题。 由于数组删除首元素的时间复杂度为 $O(n)$ ,这会导致出队操作效率较低。然而,我们可以采用以下巧妙方法来避免这个问题。
@ -1759,6 +1835,77 @@ comments: true
} }
``` ```
=== "Dart"
```dart title="array_queue.dart"
/* 基于环形数组实现的队列 */
class ArrayQueue {
late List<int> _nums; // 用于储存队列元素的数组
late int _front; // 队首指针,指向队首元素
late int _queSize; // 队列长度
ArrayQueue(int capacity) {
_nums = List.filled(capacity, 0);
_front = _queSize = 0;
}
/* 获取队列的容量 */
int capaCity() {
return _nums.length;
}
/* 获取队列的长度 */
int size() {
return _queSize;
}
/* 判断队列是否为空 */
bool isEmpty() {
return _queSize == 0;
}
/* 入队 */
void push(int num) {
if (_queSize == capaCity()) {
throw Exception("队列已满");
}
// 计算尾指针,指向队尾索引 + 1
// 通过取余操作,实现 rear 越过数组尾部后回到头部
int rear = (_front + _queSize) % capaCity();
// 将 num 添加至队尾
_nums[rear] = num;
_queSize++;
}
/* 出队 */
int pop() {
int num = peek();
// 队首指针向后移动一位,若越过尾部则返回到数组头部
_front = (_front + 1) % capaCity();
_queSize--;
return num;
}
/* 访问队首元素 */
int peek() {
if (isEmpty()) {
throw Exception("队列为空");
}
return _nums[_front];
}
/* 返回 Array */
List<int> toArray() {
// 仅转换有效长度范围内的列表元素
final List<int> res = List.filled(_queSize, 0);
for (int i = 0, j = _front; i < _queSize; i++, j++) {
res[i] = _nums[j % capaCity()];
}
return res;
}
}
```
以上实现的队列仍然具有局限性,即其长度不可变。然而,这个问题不难解决,我们可以将数组替换为动态数组,从而引入扩容机制。有兴趣的同学可以尝试自行实现。 以上实现的队列仍然具有局限性,即其长度不可变。然而,这个问题不难解决,我们可以将数组替换为动态数组,从而引入扩容机制。有兴趣的同学可以尝试自行实现。
两种实现的对比结论与栈一致,在此不再赘述。 两种实现的对比结论与栈一致,在此不再赘述。

@ -256,6 +256,12 @@ comments: true
``` ```
=== "Dart"
```dart title="stack.dart"
```
## 5.1.2. &nbsp; 栈的实现 ## 5.1.2. &nbsp; 栈的实现
为了深入了解栈的运行机制,我们来尝试自己实现一个栈类。 为了深入了解栈的运行机制,我们来尝试自己实现一个栈类。
@ -902,6 +908,66 @@ comments: true
} }
``` ```
=== "Dart"
```dart title="linkedlist_stack.dart"
/* 基于链表类实现的栈 */
class LinkedListStack {
ListNode? _stackPeek; // 将头节点作为栈顶
int _stkSize = 0; // 栈的长度
LinkedListStack() {
_stackPeek = null;
}
/* 获取栈的长度 */
int size() {
return _stkSize;
}
/* 判断栈是否为空 */
bool isEmpty() {
return _stkSize == 0;
}
/* 入栈 */
void push(int num) {
final ListNode node = ListNode(num);
node.next = _stackPeek;
_stackPeek = node;
_stkSize++;
}
/* 出栈 */
int pop() {
final int num = peek();
_stackPeek = _stackPeek!.next;
_stkSize--;
return num;
}
/* 访问栈顶元素 */
int peek() {
if (_stackPeek == null) {
throw Exception("栈为空");
}
return _stackPeek!.val;
}
/* 将链表转化为 List 并返回 */
List<int> toList() {
ListNode? node = _stackPeek;
List<int> list = [];
while (node != null) {
list.add(node.val);
node = node.next;
}
list = list.reversed.toList();
return list;
}
}
```
### 基于数组的实现 ### 基于数组的实现
在基于「数组」实现栈时,我们可以将数组的尾部作为栈顶。在这样的设计下,入栈与出栈操作就分别对应在数组尾部添加元素与删除元素,时间复杂度都为 $O(1)$ 。 在基于「数组」实现栈时,我们可以将数组的尾部作为栈顶。在这样的设计下,入栈与出栈操作就分别对应在数组尾部添加元素与删除元素,时间复杂度都为 $O(1)$ 。
@ -1409,6 +1475,52 @@ comments: true
} }
``` ```
=== "Dart"
```dart title="array_stack.dart"
/* 基于数组实现的栈 */
class ArrayStack {
late List<int> _stack;
ArrayStack() {
_stack = [];
}
/* 获取栈的长度 */
int size() {
return _stack.length;
}
/* 判断栈是否为空 */
bool isEmpty() {
return _stack.isEmpty;
}
/* 入栈 */
void push(int num) {
_stack.add(num);
}
/* 出栈 */
int pop() {
if (isEmpty()) {
throw Exception("栈为空");
}
return _stack.removeLast();
}
/* 访问栈顶元素 */
int peek() {
if (isEmpty()) {
throw Exception("栈为空");
}
return _stack.last;
}
/* 将栈转化为 Array 并返回 */
List<int> toArray() => _stack;
}
```
## 5.1.3. &nbsp; 两种实现对比 ## 5.1.3. &nbsp; 两种实现对比
### 支持操作 ### 支持操作

@ -108,6 +108,12 @@ comments: true
``` ```
=== "Dart"
```dart title=""
```
![任意类型二叉树的数组表示](binary_tree.assets/array_representation_with_empty.png) ![任意类型二叉树的数组表示](binary_tree.assets/array_representation_with_empty.png)
<p align="center"> Fig. 任意类型二叉树的数组表示 </p> <p align="center"> Fig. 任意类型二叉树的数组表示 </p>

@ -177,6 +177,12 @@ G. M. Adelson-Velsky 和 E. M. Landis 在其 1962 年发表的论文 "An algorit
``` ```
=== "Dart"
```dart title=""
```
「节点高度」是指从该节点到最远叶节点的距离,即所经过的“边”的数量。需要特别注意的是,叶节点的高度为 0 ,而空节点的高度为 -1 。我们将创建两个工具函数,分别用于获取和更新节点的高度。 「节点高度」是指从该节点到最远叶节点的距离,即所经过的“边”的数量。需要特别注意的是,叶节点的高度为 0 ,而空节点的高度为 -1 。我们将创建两个工具函数,分别用于获取和更新节点的高度。
=== "Java" === "Java"
@ -360,6 +366,21 @@ G. M. Adelson-Velsky 和 E. M. Landis 在其 1962 年发表的论文 "An algorit
} }
``` ```
=== "Dart"
```dart title="avl_tree.dart"
/* 获取节点高度 */
int height(TreeNode? node) {
return node == null ? -1 : node.height;
}
/* 更新节点高度 */
void updateHeight(TreeNode? node) {
// 节点高度等于最高子树高度 + 1
node!.height = max(height(node.left), height(node.right)) + 1;
}
```
### 节点平衡因子 ### 节点平衡因子
节点的「平衡因子 Balance Factor」定义为节点左子树的高度减去右子树的高度同时规定空节点的平衡因子为 0 。我们同样将获取节点平衡因子的功能封装成函数,方便后续使用。 节点的「平衡因子 Balance Factor」定义为节点左子树的高度减去右子树的高度同时规定空节点的平衡因子为 0 。我们同样将获取节点平衡因子的功能封装成函数,方便后续使用。
@ -490,6 +511,18 @@ G. M. Adelson-Velsky 和 E. M. Landis 在其 1962 年发表的论文 "An algorit
} }
``` ```
=== "Dart"
```dart title="avl_tree.dart"
/* 获取平衡因子 */
int balanceFactor(TreeNode? node) {
// 空节点平衡因子为 0
if (node == null) return 0;
// 节点平衡因子 = 左子树高度 - 右子树高度
return height(node.left) - height(node.right);
}
```
!!! note !!! note
设平衡因子为 $f$ ,则一棵 AVL 树的任意节点的平衡因子皆满足 $-1 \le f \le 1$ 。 设平衡因子为 $f$ ,则一棵 AVL 树的任意节点的平衡因子皆满足 $-1 \le f \le 1$ 。
@ -704,6 +737,24 @@ AVL 树的特点在于「旋转 Rotation」操作它能够在不影响二叉
} }
``` ```
=== "Dart"
```dart title="avl_tree.dart"
/* 右旋操作 */
TreeNode? rightRotate(TreeNode? node) {
TreeNode? child = node!.left;
TreeNode? grandChild = child!.right;
// 以 child 为原点,将 node 向右旋转
child.right = node;
node.left = grandChild;
// 更新节点高度
updateHeight(node);
updateHeight(child);
// 返回旋转后子树的根节点
return child;
}
```
### 左旋 ### 左旋
相应的,如果考虑上述失衡二叉树的“镜像”,则需要执行「左旋」操作。 相应的,如果考虑上述失衡二叉树的“镜像”,则需要执行「左旋」操作。
@ -900,6 +951,24 @@ AVL 树的特点在于「旋转 Rotation」操作它能够在不影响二叉
} }
``` ```
=== "Dart"
```dart title="avl_tree.dart"
/* 左旋操作 */
TreeNode? leftRotate(TreeNode? node) {
TreeNode? child = node!.right;
TreeNode? grandChild = child!.left;
// 以 child 为原点,将 node 向左旋转
child.left = node;
node.right = grandChild;
// 更新节点高度
updateHeight(node);
updateHeight(child);
// 返回旋转后子树的根节点
return child;
}
```
### 先左旋后右旋 ### 先左旋后右旋
对于下图中的失衡节点 3仅使用左旋或右旋都无法使子树恢复平衡。此时需要先左旋后右旋即先对 `child` 执行「左旋」,再对 `node` 执行「右旋」。 对于下图中的失衡节点 3仅使用左旋或右旋都无法使子树恢复平衡。此时需要先左旋后右旋即先对 `child` 执行「左旋」,再对 `node` 执行「右旋」。
@ -1275,6 +1344,40 @@ AVL 树的特点在于「旋转 Rotation」操作它能够在不影响二叉
} }
``` ```
=== "Dart"
```dart title="avl_tree.dart"
/* 执行旋转操作,使该子树重新恢复平衡 */
TreeNode? rotate(TreeNode? node) {
// 获取节点 node 的平衡因子
int factor = balanceFactor(node);
// 左偏树
if (factor > 1) {
if (balanceFactor(node!.left) >= 0) {
// 右旋
return rightRotate(node);
} else {
// 先左旋后右旋
node.left = leftRotate(node.left);
return rightRotate(node);
}
}
// 右偏树
if (factor < -1) {
if (balanceFactor(node!.right) <= 0) {
// 左旋
return leftRotate(node);
} else {
// 先右旋后左旋
node.right = rightRotate(node.right);
return leftRotate(node);
}
}
// 平衡树,无需旋转,直接返回
return node;
}
```
## 7.5.3. &nbsp; AVL 树常用操作 ## 7.5.3. &nbsp; AVL 树常用操作
### 插入节点 ### 插入节点
@ -1562,6 +1665,32 @@ AVL 树的特点在于「旋转 Rotation」操作它能够在不影响二叉
} }
``` ```
=== "Dart"
```dart title="avl_tree.dart"
/* 插入节点 */
void insert(int val) {
root = insertHelper(root, val);
}
/* 递归插入节点(辅助方法) */
TreeNode? insertHelper(TreeNode? node, int val) {
if (node == null) return TreeNode(val);
/* 1. 查找插入位置,并插入节点 */
if (val < node.val)
node.left = insertHelper(node.left, val);
else if (val > node.val)
node.right = insertHelper(node.right, val);
else
return node; // 重复节点不插入,直接返回
updateHeight(node); // 更新节点高度
/* 2. 执行旋转操作,使该子树重新恢复平衡 */
node = rotate(node);
// 返回子树的根节点
return node;
}
```
### 删除节点 ### 删除节点
类似地,在二叉搜索树的删除节点方法的基础上,需要从底至顶地执行旋转操作,使所有失衡节点恢复平衡。 类似地,在二叉搜索树的删除节点方法的基础上,需要从底至顶地执行旋转操作,使所有失衡节点恢复平衡。
@ -2021,6 +2150,49 @@ AVL 树的特点在于「旋转 Rotation」操作它能够在不影响二叉
} }
``` ```
=== "Dart"
```dart title="avl_tree.dart"
/* 删除节点 */
void remove(int val) {
root = removeHelper(root, val);
}
/* 递归删除节点(辅助方法) */
TreeNode? removeHelper(TreeNode? node, int val) {
if (node == null) return null;
/* 1. 查找节点,并删除之 */
if (val < node.val)
node.left = removeHelper(node.left, val);
else if (val > node.val)
node.right = removeHelper(node.right, val);
else {
if (node.left == null || node.right == null) {
TreeNode? child = node.left ?? node.right;
// 子节点数量 = 0 ,直接删除 node 并返回
if (child == null)
return null;
// 子节点数量 = 1 ,直接删除 node
else
node = child;
} else {
// 子节点数量 = 2 ,则将中序遍历的下个节点删除,并用该节点替换当前节点
TreeNode? temp = node.right;
while (temp!.left != null) {
temp = temp.left;
}
node.right = removeHelper(node.right, temp.val);
node.val = temp.val;
}
}
updateHeight(node); // 更新节点高度
/* 2. 执行旋转操作,使该子树重新恢复平衡 */
node = rotate(node);
// 返回子树的根节点
return node;
}
```
### 查找节点 ### 查找节点
AVL 树的节点查找操作与二叉搜索树一致,在此不再赘述。 AVL 树的节点查找操作与二叉搜索树一致,在此不再赘述。

@ -265,6 +265,12 @@ comments: true
} }
``` ```
=== "Dart"
```dart title="binary_search_tree.dart"
[class]{BinarySearchTree}-[func]{search}
```
### 插入节点 ### 插入节点
给定一个待插入元素 `num` ,为了保持二叉搜索树“左子树 < 根节点 < 右子树”的性质,插入操作分为两步: 给定一个待插入元素 `num` ,为了保持二叉搜索树“左子树 < 根节点 < 右子树”的性质,插入操作分为两步:
@ -602,6 +608,12 @@ comments: true
} }
``` ```
=== "Dart"
```dart title="binary_search_tree.dart"
[class]{BinarySearchTree}-[func]{insert}
```
为了插入节点,我们需要利用辅助节点 `pre` 保存上一轮循环的节点,这样在遍历至 $\text{null}$ 时,我们可以获取到其父节点,从而完成节点插入操作。 为了插入节点,我们需要利用辅助节点 `pre` 保存上一轮循环的节点,这样在遍历至 $\text{null}$ 时,我们可以获取到其父节点,从而完成节点插入操作。
与查找节点相同,插入节点使用 $O(\log n)$ 时间。 与查找节点相同,插入节点使用 $O(\log n)$ 时间。
@ -1201,6 +1213,12 @@ comments: true
} }
``` ```
=== "Dart"
```dart title="binary_search_tree.dart"
[class]{BinarySearchTree}-[func]{remove}
```
### 排序 ### 排序
我们知道,二叉树的中序遍历遵循“左 $\rightarrow$ 根 $\rightarrow$ 右”的遍历顺序,而二叉搜索树满足“左子节点 $<$ 根节点 $<$ 右子节点”的大小关系。因此,在二叉搜索树中进行中序遍历时,总是会优先遍历下一个最小节点,从而得出一个重要性质:**二叉搜索树的中序遍历序列是升序的**。 我们知道,二叉树的中序遍历遵循“左 $\rightarrow$ 根 $\rightarrow$ 右”的遍历顺序,而二叉搜索树满足“左子节点 $<$ 根节点 $<$ 右子节点”的大小关系。因此,在二叉搜索树中进行中序遍历时,总是会优先遍历下一个最小节点,从而得出一个重要性质:**二叉搜索树的中序遍历序列是升序的**。

@ -147,6 +147,12 @@ comments: true
``` ```
=== "Dart"
```dart title=""
```
节点的两个指针分别指向「左子节点」和「右子节点」,同时该节点被称为这两个子节点的「父节点」。当给定一个二叉树的节点时,我们将该节点的左子节点及其以下节点形成的树称为该节点的「左子树」,同理可得「右子树」。 节点的两个指针分别指向「左子节点」和「右子节点」,同时该节点被称为这两个子节点的「父节点」。当给定一个二叉树的节点时,我们将该节点的左子节点及其以下节点形成的树称为该节点的「左子树」,同理可得「右子树」。
**在二叉树中,除叶节点外,其他所有节点都包含子节点和非空子树**。例如,在以下示例中,若将“节点 2”视为父节点则其左子节点和右子节点分别是“节点 4”和“节点 5”左子树是“节点 4 及其以下节点形成的树”,右子树是“节点 5 及其以下节点形成的树”。 **在二叉树中,除叶节点外,其他所有节点都包含子节点和非空子树**。例如,在以下示例中,若将“节点 2”视为父节点则其左子节点和右子节点分别是“节点 4”和“节点 5”左子树是“节点 4 及其以下节点形成的树”,右子树是“节点 5 及其以下节点形成的树”。
@ -337,6 +343,12 @@ comments: true
``` ```
=== "Dart"
```dart title="binary_tree.dart"
```
**插入与删除节点**。与链表类似,通过修改指针来实现插入与删除节点。 **插入与删除节点**。与链表类似,通过修改指针来实现插入与删除节点。
![在二叉树中插入与删除节点](binary_tree.assets/binary_tree_add_remove.png) ![在二叉树中插入与删除节点](binary_tree.assets/binary_tree_add_remove.png)
@ -455,6 +467,12 @@ comments: true
``` ```
=== "Dart"
```dart title="binary_tree.dart"
```
!!! note !!! note
需要注意的是,插入节点可能会改变二叉树的原有逻辑结构,而删除节点通常意味着删除该节点及其所有子树。因此,在二叉树中,插入与删除操作通常是由一套操作配合完成的,以实现有实际意义的操作。 需要注意的是,插入节点可能会改变二叉树的原有逻辑结构,而删除节点通常意味着删除该节点及其所有子树。因此,在二叉树中,插入与删除操作通常是由一套操作配合完成的,以实现有实际意义的操作。

@ -279,6 +279,26 @@ comments: true
} }
``` ```
=== "Dart"
```dart title="binary_tree_bfs.dart"
/* 层序遍历 */
List<int> levelOrder(TreeNode? root) {
// 初始化队列,加入根节点
Queue<TreeNode?> queue = Queue();
queue.add(root);
// 初始化一个列表,用于保存遍历序列
List<int> res = [];
while (queue.isNotEmpty) {
TreeNode? node = queue.removeFirst(); // 队列出队
res.add(node!.val); // 保存节点值
if (node.left != null) queue.add(node.left); // 左子节点入队
if (node.right != null) queue.add(node.right); // 右子节点入队
}
return res;
}
```
### 复杂度分析 ### 复杂度分析
**时间复杂度**:所有节点被访问一次,使用 $O(n)$ 时间,其中 $n$ 为节点数量。 **时间复杂度**:所有节点被访问一次,使用 $O(n)$ 时间,其中 $n$ 为节点数量。
@ -644,6 +664,37 @@ comments: true
} }
``` ```
=== "Dart"
```dart title="binary_tree_dfs.dart"
/* 前序遍历 */
void preOrder(TreeNode? node) {
if (node == null) return;
// 访问优先级:根节点 -> 左子树 -> 右子树
list.add(node.val);
preOrder(node.left);
preOrder(node.right);
}
/* 中序遍历 */
void inOrder(TreeNode? node) {
if (node == null) return;
// 访问优先级:左子树 -> 根节点 -> 右子树
inOrder(node.left);
list.add(node.val);
inOrder(node.right);
}
/* 后序遍历 */
void postOrder(TreeNode? node) {
if (node == null) return;
// 访问优先级:左子树 -> 右子树 -> 根节点
postOrder(node.left);
postOrder(node.right);
list.add(node.val);
}
```
!!! note !!! note
我们也可以仅基于循环实现前、中、后序遍历,有兴趣的同学可以自行实现。 我们也可以仅基于循环实现前、中、后序遍历,有兴趣的同学可以自行实现。

Loading…
Cancel
Save