Add Dart codes to the documents. (#529)

This commit is contained in:
Yudong Jin 2023-06-02 02:40:26 +08:00 committed by GitHub
parent 041a989d33
commit 025051c81b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
38 changed files with 849 additions and 96 deletions

View file

@ -6,70 +6,68 @@
import 'dart:math';
class Array {
/* 随机返回一个 数组元素 */
int randomAccess(List nums) {
// [0,size)
int randomIndex = Random().nextInt(nums.length);
//
int randomNum = nums[randomIndex];
return randomNum;
}
/* 随机返回一个 数组元素 */
int randomAccess(List nums) {
// [0,size)
int randomIndex = Random().nextInt(nums.length);
//
int randomNum = nums[randomIndex];
return randomNum;
}
/* 扩展数组长度 */
List extend(List nums, int enlarge) {
// 0
List<int> res = List.filled(nums.length + enlarge, 0);
/* 扩展数组长度 */
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;
//
for (var i = 0; i < nums.length; i++) {
res[i] = nums[i];
}
//
return res;
}
/* 在数组的索引 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;
/* 在数组的索引 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;
}
/* 删除索引 index 处元素 */
void remove(List nums, int index) {
for (var i = index; i < nums.length - 1; i++) {
nums[i] = nums[i + 1];
}
/* 删除索引 index 处元素 */
void remove(List nums, int index) {
for (var i = index; i < nums.length - 1; i++) {
nums[i] = nums[i + 1];
}
}
/* 遍历数组元素 */
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++;
});
/* 遍历数组元素 */
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++;
});
}
/* 在数组中查找指定元素 */
int find(List nums, int target) {
for (var i = 0; i < nums.length; i++) {
if (nums[i] == target) return i;
}
return -1;
/* 在数组中查找指定元素 */
int find(List nums, int target) {
for (var i = 0; i < nums.length; i++) {
if (nums[i] == target) return i;
}
return -1;
}
/* Driver Code */
@ -81,26 +79,26 @@ int main() {
print('数组 nums = $nums');
/* 随机访问 */
int randomNum = Array().randomAccess(nums);
int randomNum = randomAccess(nums);
print('在 nums 中获取随机元素 $randomNum');
/* 长度扩展 */
nums = Array().extend(nums, 3);
nums = extend(nums, 3);
print('将数组长度扩展至 8 ,得到 nums = $nums');
/* 插入元素 */
Array().insert(nums, 6, 3);
insert(nums, 6, 3);
print("在索引 3 处插入数字 6 ,得到 nums = $nums");
/* 删除元素 */
Array().remove(nums, 2);
remove(nums, 2);
print("删除索引 2 处的元素,得到 nums = $nums");
/* 遍历元素 */
Array().traverse(nums);
traverse(nums);
/* 查找元素 */
int index = Array().find(nums, 3);
int index = find(nums, 3);
print("在 nums 中查找元素 3 ,得到索引 = $index");
return 0;

View file

@ -7,43 +7,41 @@
import '../utils/list_node.dart';
import '../utils/print_util.dart';
class LinkedList {
/* 在链表的节点 n0 之后插入节点 P */
void insert(ListNode n0, ListNode P) {
ListNode? n1 = n0.next;
P.next = n1;
n0.next = P;
}
/* 在链表的节点 n0 之后插入节点 P */
void insert(ListNode n0, ListNode P) {
ListNode? n1 = n0.next;
P.next = n1;
n0.next = P;
}
/* 删除链表的节点 n0 之后的首个节点 */
void remove(ListNode n0) {
if (n0.next == null) return;
ListNode P = n0.next!;
ListNode? n1 = P.next;
n0.next = n1;
}
/* 删除链表的节点 n0 之后的首个节点 */
void remove(ListNode n0) {
if (n0.next == null) return;
ListNode P = n0.next!;
ListNode? n1 = P.next;
n0.next = n1;
}
/* 访问链表中索引为 index 的节点 */
ListNode? access(ListNode? head, int index) {
for (var i = 0; i < index; i++) {
if (head == null) return null;
head = head.next;
/* 访问链表中索引为 index 的节点 */
ListNode? access(ListNode? head, int index) {
for (var i = 0; i < index; i++) {
if (head == null) return null;
head = head.next;
}
return head;
}
/* 在链表中查找值为 target 的首个节点 */
int find(ListNode? head, int target) {
int index = 0;
while (head != null) {
if (head.val == target) {
return index;
}
return head;
}
/* 在链表中查找值为 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;
head = head.next;
index++;
}
return -1;
}
/* Driver Code */
@ -65,21 +63,21 @@ int main() {
printLinkedList(n0);
/* 插入节点 */
LinkedList().insert(n0, ListNode(0));
insert(n0, ListNode(0));
print('插入节点后的链表为');
printLinkedList(n0);
/* 删除节点 */
LinkedList().remove(n0);
remove(n0);
print('删除节点后的链表为');
printLinkedList(n0);
/* 访问节点 */
ListNode? node = LinkedList().access(n0, 3);
ListNode? node = access(n0, 3);
print('链表中索引 3 处的节点的值 = ${node!.val}');
/* 查找节点 */
int index = LinkedList().find(n0, 2);
int index = find(n0, 2);
print('链表中值为 2 的节点的索引 = $index');
return 0;

View file

@ -24,6 +24,7 @@ int findOne(List<int> nums) {
// 1 O(n)
if (nums[i] == 1) return i;
}
return -1;
}
@ -36,5 +37,6 @@ int main() {
print('\n数组 [ 1, 2, ..., n ] 被打乱后 = $nums');
print('数字 1 的索引为 + $index');
}
return 0;
}

View file

@ -92,6 +92,12 @@
var nums = [_]i32{ 1, 3, 2, 5, 4 };
```
=== "Dart"
```dart title="array.dart"
```
## 数组优点
**在数组中访问元素非常高效**。由于数组元素被存储在连续的内存空间中,因此计算数组元素的内存地址非常容易。给定数组首个元素的地址和某个元素的索引,我们可以使用以下公式计算得到该元素的内存地址,从而直接访问此元素。
@ -171,6 +177,12 @@ elementAddr = firtstElementAddr + elementLength * elementIndex
[class]{}-[func]{randomAccess}
```
=== "Dart"
```dart title="array.dart"
[class]{}-[func]{randomAccess}
```
## 数组缺点
**数组在初始化后长度不可变**。由于系统无法保证数组之后的内存空间是可用的,因此数组长度无法扩展。而若希望扩容数组,则需新建一个数组,然后把原数组元素依次拷贝到新数组,在数组很大的情况下,这是非常耗时的。
@ -235,6 +247,12 @@ elementAddr = firtstElementAddr + elementLength * elementIndex
[class]{}-[func]{extend}
```
=== "Dart"
```dart title="array.dart"
[class]{}-[func]{extend}
```
**数组中插入或删除元素效率低下**。如果我们想要在数组中间插入一个元素,由于数组元素在内存中是“紧挨着的”,它们之间没有空间再放任何数据。因此,我们不得不将此索引之后的所有元素都向后移动一位,然后再把元素赋值给该索引。
![数组插入元素](array.assets/array_insert_element.png)
@ -293,6 +311,18 @@ elementAddr = firtstElementAddr + elementLength * elementIndex
[class]{}-[func]{insert}
```
=== "Zig"
```zig title="array.zig"
[class]{}-[func]{insert}
```
=== "Dart"
```dart title="array.dart"
[class]{}-[func]{insert}
```
删除元素也类似,如果我们想要删除索引 $i$ 处的元素,则需要把索引 $i$ 之后的元素都向前移动一位。值得注意的是,删除元素后,原先末尾的元素变得“无意义”了,我们无需特意去修改它。
![数组删除元素](array.assets/array_remove_element.png)
@ -357,6 +387,12 @@ elementAddr = firtstElementAddr + elementLength * elementIndex
[class]{}-[func]{remove}
```
=== "Dart"
```dart title="array.dart"
[class]{}-[func]{remove}
```
总结来看,数组的插入与删除操作有以下缺点:
- **时间复杂度高**:数组的插入和删除的平均时间复杂度均为 $O(n)$ ,其中 $n$ 为数组长度。
@ -427,6 +463,12 @@ elementAddr = firtstElementAddr + elementLength * elementIndex
[class]{}-[func]{traverse}
```
=== "Dart"
```dart title="array.dart"
[class]{}-[func]{traverse}
```
**数组查找**。通过遍历数组,查找数组内的指定元素,并输出对应索引。
=== "Java"
@ -489,6 +531,12 @@ elementAddr = firtstElementAddr + elementLength * elementIndex
[class]{}-[func]{find}
```
=== "Dart"
```dart title="array.dart"
[class]{}-[func]{find}
```
## 数组典型应用
**随机访问**。如果我们想要随机抽取一些样本,那么可以用数组存储,并生成一个随机序列,根据索引实现样本的随机抽取。

View file

@ -153,6 +153,12 @@
}
```
=== "Dart"
```dart title=""
```
!!! question "尾节点指向什么?"
我们将链表的最后一个节点称为「尾节点」,其指向的是“空”,在 Java, C++, Python 中分别记为 $\text{null}$ , $\text{nullptr}$ , $\text{None}$ 。在不引起歧义的前提下,本书都使用 $\text{null}$ 来表示空。
@ -333,6 +339,12 @@
n3.next = &n4;
```
=== "Dart"
```dart title="linked_list.dart"
```
## 链表优点
**链表中插入与删除节点的操作效率高**。例如,如果我们想在链表中间的两个节点 `A` , `B` 之间插入一个新节点 `P` ,我们只需要改变两个节点指针即可,时间复杂度为 $O(1)$ ;相比之下,数组的插入操作效率要低得多。
@ -399,6 +411,12 @@
[class]{}-[func]{insert}
```
=== "Dart"
```dart title="linked_list.dart"
[class]{}-[func]{insert}
```
在链表中删除节点也非常方便,只需改变一个节点的指针即可。如下图所示,尽管在删除操作完成后,节点 `P` 仍然指向 `n1` ,但实际上 `P` 已经不再属于此链表,因为遍历此链表时无法访问到 `P`
![链表删除节点](linked_list.assets/linkedlist_remove_node.png)
@ -463,6 +481,12 @@
[class]{}-[func]{remove}
```
=== "Dart"
```dart title="linked_list.dart"
[class]{}-[func]{remove}
```
## 链表缺点
**链表访问节点效率较低**。如上节所述,数组可以在 $O(1)$ 时间下访问任意元素。然而,链表无法直接访问任意节点,这是因为系统需要从头节点出发,逐个向后遍历直至找到目标节点。例如,若要访问链表索引为 `index`(即第 `index + 1` 个)的节点,则需要向后遍历 `index` 轮。
@ -527,6 +551,12 @@
[class]{}-[func]{access}
```
=== "Dart"
```dart title="linked_list.dart"
[class]{}-[func]{access}
```
**链表的内存占用较大**。链表以节点为单位,每个节点除了保存值之外,还需额外保存指针(引用)。这意味着在相同数据量的情况下,链表比数组需要占用更多的内存空间。
## 链表常用操作
@ -593,6 +623,12 @@
[class]{}-[func]{find}
```
=== "Dart"
```dart title="linked_list.dart"
[class]{}-[func]{find}
```
## 常见链表类型
**单向链表**。即上述介绍的普通链表。单向链表的节点包含值和指向下一节点的指针(引用)两项数据。我们将首个节点称为头节点,将最后一个节点成为尾节点,尾节点指向 $\text{null}$ 。
@ -760,4 +796,10 @@
}
```
=== "Dart"
```dart title=""
```
![常见链表种类](linked_list.assets/linkedlist_common_types.png)

View file

@ -106,6 +106,12 @@
try list.appendSlice(&[_]i32{ 1, 3, 2, 5, 4 });
```
=== "Dart"
```dart title="list.dart"
```
**访问与更新元素**。由于列表的底层数据结构是数组,因此可以在 $O(1)$ 时间内访问和更新元素,效率很高。
=== "Java"
@ -204,6 +210,12 @@
list.items[1] = 0; // 将索引 1 处的元素更新为 0
```
=== "Dart"
```dart title="list.dart"
```
**在列表中添加、插入、删除元素**。相较于数组,列表可以自由地添加与删除元素。在列表尾部添加元素的时间复杂度为 $O(1)$ ,但插入和删除元素的效率仍与数组相同,时间复杂度为 $O(N)$ 。
=== "Java"
@ -392,6 +404,12 @@
_ = list.orderedRemove(3); // 删除索引 3 处的元素
```
=== "Dart"
```dart title="list.dart"
```
**遍历列表**。与数组一样,列表可以根据索引遍历,也可以直接遍历各元素。
=== "Java"
@ -545,6 +563,12 @@
}
```
=== "Dart"
```dart title="list.dart"
```
**拼接两个列表**。给定一个新列表 `list1` ,我们可以将该列表拼接到原列表的尾部。
=== "Java"
@ -628,6 +652,12 @@
try list.insertSlice(list.items.len, list1.items); // 将列表 list1 拼接到 list 之后
```
=== "Dart"
```dart title="list.dart"
```
**排序列表**。排序也是常用的方法之一。完成列表排序后,我们便可以使用在数组类算法题中经常考察的「二分查找」和「双指针」算法。
=== "Java"
@ -699,6 +729,12 @@
std.sort.sort(i32, list.items, {}, comptime std.sort.asc(i32));
```
=== "Dart"
```dart title="list.dart"
```
## 列表实现 *
为了帮助加深对列表的理解,我们在此提供一个简易版列表实现。需要关注三个核心点:
@ -768,3 +804,9 @@
```zig title="my_list.zig"
[class]{MyList}-[func]{}
```
=== "Dart"
```dart title="my_list.dart"
[class]{MyList}-[func]{}
```

View file

@ -70,6 +70,12 @@
[class]{}-[func]{preOrder}
```
=== "Dart"
```dart title="preorder_traversal_i_compact.dart"
[class]{}-[func]{preOrder}
```
![在前序遍历中搜索节点](backtracking_algorithm.assets/preorder_find_nodes.png)
## 尝试与回退
@ -146,6 +152,12 @@
[class]{}-[func]{preOrder}
```
=== "Dart"
```dart title="preorder_traversal_ii_compact.dart"
[class]{}-[func]{preOrder}
```
在每次“尝试”中,我们通过将当前节点添加进 `path` 来记录路径;而在“回退”前,我们需要将该节点从 `path` 中弹出,**以恢复本次尝试之前的状态**。换句话说,**我们可以将尝试和回退理解为“前进”与“撤销”**,两个操作是互为相反的。
=== "<1>"
@ -251,6 +263,12 @@
[class]{}-[func]{preOrder}
```
=== "Dart"
```dart title="preorder_traversal_iii_compact.dart"
[class]{}-[func]{preOrder}
```
剪枝是一个非常形象的名词。在搜索过程中,**我们利用约束条件“剪掉”了不满足约束条件的搜索分支**,避免许多无意义的尝试,从而提升搜索效率。
![根据约束条件剪枝](backtracking_algorithm.assets/preorder_find_constrained_paths.png)
@ -505,6 +523,12 @@
```
=== "Dart"
```dart title=""
```
下面,我们尝试基于此框架来解决例题三。在例题三中,状态 `state` 是节点遍历路径,选择 `choices` 是当前节点的左子节点和右子节点,结果 `res` 是路径列表,实现代码如下所示。
=== "Java"
@ -667,6 +691,22 @@
[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` ,并实现框架中的各个方法。
## 典型例题

View file

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

View file

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

View file

@ -258,6 +258,12 @@
```
=== "Dart"
```dart title=""
```
## 推算方法
空间复杂度的推算方法与时间复杂度大致相同,只是将统计对象从“计算操作数量”转为“使用空间大小”。与时间复杂度不同的是,**我们通常只关注「最差空间复杂度」**,这是因为内存空间是一项硬性要求,我们必须确保在所有输入数据下都有足够的内存空间预留。
@ -380,6 +386,12 @@
```
=== "Dart"
```dart title=""
```
**在递归函数中,需要注意统计栈帧空间**。例如,函数 `loop()` 在循环中调用了 $n$ 次 `function()` ,每轮中的 `function()` 都返回并释放了栈帧空间,因此空间复杂度仍为 $O(1)$ 。而递归函数 `recur()` 在运行过程中会同时存在 $n$ 个未返回的 `recur()` ,从而占用 $O(n)$ 的栈帧空间。
=== "Java"
@ -579,6 +591,12 @@
```
=== "Dart"
```dart title=""
```
## 常见类型
设输入数据大小为 $n$ ,常见的空间复杂度类型有(从低到高排列)
@ -662,6 +680,12 @@ $$
[class]{}-[func]{constant}
```
=== "Dart"
```dart title="space_complexity.dart"
[class]{}-[func]{constant}
```
### 线性阶 $O(n)$
线性阶常见于元素数量与 $n$ 成正比的数组、链表、栈、队列等。
@ -728,6 +752,12 @@ $$
[class]{}-[func]{linear}
```
=== "Dart"
```dart title="space_complexity.dart"
[class]{}-[func]{linear}
```
以下递归函数会同时存在 $n$ 个未返回的 `algorithm()` 函数,使用 $O(n)$ 大小的栈帧空间。
=== "Java"
@ -790,6 +820,12 @@ $$
[class]{}-[func]{linearRecur}
```
=== "Dart"
```dart title="space_complexity.dart"
[class]{}-[func]{linearRecur}
```
![递归函数产生的线性阶空间复杂度](space_complexity.assets/space_complexity_recursive_linear.png)
### 平方阶 $O(n^2)$
@ -856,6 +892,12 @@ $$
[class]{}-[func]{quadratic}
```
=== "Dart"
```dart title="space_complexity.dart"
[class]{}-[func]{quadratic}
```
在以下递归函数中,同时存在 $n$ 个未返回的 `algorithm()` ,并且每个函数中都初始化了一个数组,长度分别为 $n, n-1, n-2, ..., 2, 1$ ,平均长度为 $\frac{n}{2}$ ,因此总体占用 $O(n^2)$ 空间。
=== "Java"
@ -918,6 +960,12 @@ $$
[class]{}-[func]{quadraticRecur}
```
=== "Dart"
```dart title="space_complexity.dart"
[class]{}-[func]{quadraticRecur}
```
![递归函数产生的平方阶空间复杂度](space_complexity.assets/space_complexity_recursive_quadratic.png)
### 指数阶 $O(2^n)$
@ -984,6 +1032,12 @@ $$
[class]{}-[func]{buildTree}
```
=== "Dart"
```dart title="space_complexity.dart"
[class]{}-[func]{buildTree}
```
![满二叉树产生的指数阶空间复杂度](space_complexity.assets/space_complexity_exponential.png)
### 对数阶 $O(\log n)$

View file

@ -155,6 +155,12 @@ $$
```
=== "Dart"
```dart title=""
```
然而实际上,**统计算法的运行时间既不合理也不现实**。首先,我们不希望预估时间和运行平台绑定,因为算法需要在各种不同的平台上运行。其次,我们很难获知每种操作的运行时间,这给预估过程带来了极大的难度。
## 统计时间增长趋势
@ -365,6 +371,12 @@ $$
```
=== "Dart"
```dart title=""
```
![算法 A, B, C 的时间增长趋势](time_complexity.assets/time_complexity_simple_example.png)
相较于直接统计算法运行时间,时间复杂度分析有哪些优势和局限性呢?
@ -515,6 +527,12 @@ $$
```
=== "Dart"
```dart title=""
```
$T(n)$ 是一次函数,说明时间增长趋势是线性的,因此可以得出时间复杂度是线性阶。
我们将线性阶的时间复杂度记为 $O(n)$ ,这个数学符号称为「大 $O$ 记号 Big-$O$ Notation」表示函数 $T(n)$ 的「渐近上界 Asymptotic Upper Bound」。
@ -739,6 +757,12 @@ $$
```
=== "Dart"
```dart title=""
```
### 2) 判断渐近上界
**时间复杂度由多项式 $T(n)$ 中最高阶的项来决定**。这是因为在 $n$ 趋于无穷大时,最高阶的项将发挥主导作用,其他项的影响都可以被忽略。
@ -840,6 +864,12 @@ $$
[class]{}-[func]{constant}
```
=== "Dart"
```dart title="time_complexity.dart"
[class]{}-[func]{constant}
```
### 线性阶 $O(n)$
线性阶的操作数量相对于输入数据大小以线性级别增长。线性阶通常出现在单层循环中。
@ -904,6 +934,12 @@ $$
[class]{}-[func]{linear}
```
=== "Dart"
```dart title="time_complexity.dart"
[class]{}-[func]{linear}
```
遍历数组和遍历链表等操作的时间复杂度均为 $O(n)$ ,其中 $n$ 为数组或链表的长度。
!!! question "如何确定输入数据大小 $n$ "
@ -970,6 +1006,12 @@ $$
[class]{}-[func]{arrayTraversal}
```
=== "Dart"
```dart title="time_complexity.dart"
[class]{}-[func]{arrayTraversal}
```
### 平方阶 $O(n^2)$
平方阶的操作数量相对于输入数据大小以平方级别增长。平方阶通常出现在嵌套循环中,外层循环和内层循环都为 $O(n)$ ,因此总体为 $O(n^2)$ 。
@ -1034,6 +1076,12 @@ $$
[class]{}-[func]{quadratic}
```
=== "Dart"
```dart title="time_complexity.dart"
[class]{}-[func]{quadratic}
```
![常数阶、线性阶、平方阶的时间复杂度](time_complexity.assets/time_complexity_constant_linear_quadratic.png)
以「冒泡排序」为例,外层循环执行 $n - 1$ 次,内层循环执行 $n-1, n-2, \cdots, 2, 1$ 次,平均为 $\frac{n}{2}$ 次,因此时间复杂度为 $O(n^2)$ 。
@ -1102,6 +1150,12 @@ $$
[class]{}-[func]{bubbleSort}
```
=== "Dart"
```dart title="time_complexity.dart"
[class]{}-[func]{bubbleSort}
```
### 指数阶 $O(2^n)$
!!! note
@ -1170,6 +1224,12 @@ $$
[class]{}-[func]{exponential}
```
=== "Dart"
```dart title="time_complexity.dart"
[class]{}-[func]{exponential}
```
![指数阶的时间复杂度](time_complexity.assets/time_complexity_exponential.png)
在实际算法中,指数阶常出现于递归函数。例如以下代码,不断地一分为二,经过 $n$ 次分裂后停止。
@ -1234,6 +1294,12 @@ $$
[class]{}-[func]{expRecur}
```
=== "Dart"
```dart title="time_complexity.dart"
[class]{}-[func]{expRecur}
```
### 对数阶 $O(\log n)$
与指数阶相反,对数阶反映了“每轮缩减到一半的情况”。对数阶仅次于常数阶,时间增长缓慢,是理想的时间复杂度。
@ -1302,6 +1368,12 @@ $$
[class]{}-[func]{logarithmic}
```
=== "Dart"
```dart title="time_complexity.dart"
[class]{}-[func]{logarithmic}
```
![对数阶的时间复杂度](time_complexity.assets/time_complexity_logarithmic.png)
与指数阶类似,对数阶也常出现于递归函数。以下代码形成了一个高度为 $\log_2 n$ 的递归树。
@ -1366,6 +1438,12 @@ $$
[class]{}-[func]{logRecur}
```
=== "Dart"
```dart title="time_complexity.dart"
[class]{}-[func]{logRecur}
```
### 线性对数阶 $O(n \log n)$
线性对数阶常出现于嵌套循环中,两层循环的时间复杂度分别为 $O(\log n)$ 和 $O(n)$ 。
@ -1432,6 +1510,12 @@ $$
[class]{}-[func]{linearLogRecur}
```
=== "Dart"
```dart title="time_complexity.dart"
[class]{}-[func]{linearLogRecur}
```
![线性对数阶的时间复杂度](time_complexity.assets/time_complexity_logarithmic_linear.png)
### 阶乘阶 $O(n!)$
@ -1504,6 +1588,12 @@ $$
[class]{}-[func]{factorialRecur}
```
=== "Dart"
```dart title="time_complexity.dart"
[class]{}-[func]{factorialRecur}
```
![阶乘阶的时间复杂度](time_complexity.assets/time_complexity_factorial.png)
## 最差、最佳、平均时间复杂度
@ -1614,6 +1704,14 @@ $$
}
```
=== "Dart"
```dart title="worst_best_time_complexity.dart"
[class]{}-[func]{randomNumbers}
[class]{}-[func]{findOne}
```
!!! tip
实际应用中我们很少使用「最佳时间复杂度」,因为通常只有在很小概率下才能达到,可能会带来一定的误导性。相反,「最差时间复杂度」更为实用,因为它给出了一个“效率安全值”,让我们可以放心地使用算法。

View file

@ -129,3 +129,9 @@
```zig title=""
```
=== "Dart"
```dart title=""
```

View file

@ -88,6 +88,12 @@
```
=== "Dart"
```dart title="graph_adjacency_matrix.dart"
[class]{GraphAdjMat}-[func]{}
```
## 基于邻接表的实现
设无向图的顶点总数为 $n$ 、边总数为 $m$ ,则有:
@ -179,6 +185,12 @@
[class]{GraphAdjList}-[func]{}
```
=== "Dart"
```dart title="graph_adjacency_list.dart"
[class]{GraphAdjList}-[func]{}
```
## 效率对比
设图中共有 $n$ 个顶点和 $m$ 条边,下表为邻接矩阵和邻接表的时间和空间效率对比。

View file

@ -84,6 +84,12 @@ BFS 通常借助「队列」来实现。队列具有“先入先出”的性质
[class]{}-[func]{graphBFS}
```
=== "Dart"
```dart title="graph_bfs.dart"
[class]{}-[func]{graphBFS}
```
代码相对抽象,建议对照以下动画图示来加深理解。
=== "<1>"
@ -219,6 +225,14 @@ BFS 通常借助「队列」来实现。队列具有“先入先出”的性质
[class]{}-[func]{graphDFS}
```
=== "Dart"
```dart title="graph_dfs.dart"
[class]{}-[func]{dfs}
[class]{}-[func]{graphDFS}
```
深度优先遍历的算法流程如下图所示,其中:
- **直虚线代表向下递推**,表示开启了一个新的递归方法来访问新顶点;

View file

@ -223,6 +223,12 @@
```
=== "Dart"
```dart title="hash_map.dart"
```
遍历哈希表有三种方式,即 **遍历键值对、遍历键、遍历值**
=== "Java"
@ -381,6 +387,12 @@
```
=== "Dart"
```dart title="hash_map.dart"
```
## 哈希函数
哈希表的底层实现为数组,同时可能包含链表、二叉树(红黑树)等数据结构,以提高查询性能(将在下节讨论)。
@ -484,6 +496,14 @@ $$
[class]{ArrayHashMap}-[func]{}
```
=== "Dart"
```dart title="array_hash_map.dart"
[class]{Entry}-[func]{}
[class]{ArrayHashMap}-[func]{}
```
## 哈希冲突
细心的你可能已经注意到,**在某些情况下,哈希函数 $f(x) = x \bmod 100$ 可能无法正常工作**。具体来说,当输入的 key 后两位相同时,哈希函数的计算结果也会相同,从而指向同一个 value 。例如,查询学号为 $12836$ 和 $20336$ 的两个学生时,我们得到:

View file

@ -72,6 +72,12 @@
[class]{MaxHeap}-[func]{init}
```
=== "Dart"
```dart title="my_heap.dart"
[class]{MaxHeap}-[func]{MaxHeap}
```
## 复杂度分析
为什么第二种建堆方法的时间复杂度是 $O(n)$ ?我们来展开推算一下。

View file

@ -301,6 +301,12 @@
```
=== "Dart"
```dart title="heap.dart"
```
## 堆的实现
下文实现的是大顶堆。若要将其转换为小顶堆,只需将所有大小逻辑判断取逆(例如,将 $\geq$ 替换为 $\leq$ )。感兴趣的读者可以自行实现。
@ -417,6 +423,16 @@
[class]{MaxHeap}-[func]{parent}
```
=== "Dart"
```dart title="my_heap.dart"
[class]{MaxHeap}-[func]{_left}
[class]{MaxHeap}-[func]{_right}
[class]{MaxHeap}-[func]{_parent}
```
### 访问堆顶元素
堆顶元素即为二叉树的根节点,也就是列表的首个元素。
@ -481,6 +497,12 @@
[class]{MaxHeap}-[func]{peek}
```
=== "Dart"
```dart title="my_heap.dart"
[class]{MaxHeap}-[func]{peek}
```
### 元素入堆
给定元素 `val` ,我们首先将其添加到堆底。添加之后,由于 val 可能大于堆中其他元素,堆的成立条件可能已被破坏。因此,**需要修复从插入节点到根节点的路径上的各个节点**,这个操作被称为「堆化 Heapify」。
@ -596,6 +618,14 @@
[class]{MaxHeap}-[func]{siftUp}
```
=== "Dart"
```dart title="my_heap.dart"
[class]{MaxHeap}-[func]{push}
[class]{MaxHeap}-[func]{siftUp}
```
### 堆顶元素出堆
堆顶元素是二叉树的根节点,即列表首元素。如果我们直接从列表中删除首元素,那么二叉树中所有节点的索引都会发生变化,这将使得后续使用堆化修复变得困难。为了尽量减少元素索引的变动,我们采取以下操作步骤:
@ -718,6 +748,14 @@
[class]{MaxHeap}-[func]{siftDown}
```
=== "Dart"
```dart title="my_heap.dart"
[class]{MaxHeap}-[func]{pop}
[class]{MaxHeap}-[func]{siftDown}
```
## 堆常见应用
- **优先队列**:堆通常作为实现优先队列的首选数据结构,其入队和出队操作的时间复杂度均为 $O(\log n)$ ,而建队操作为 $O(n)$ ,这些操作都非常高效。

View file

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

View file

@ -104,6 +104,12 @@
[class]{}-[func]{binarySearch}
```
=== "Dart"
```dart title="binary_search.dart"
[class]{}-[func]{binarySearch}
```
时间复杂度为 $O(\log n)$ 。每轮缩小一半区间,因此二分循环次数为 $\log_2 n$ 。
空间复杂度为 $O(1)$ 。指针 `i` , `j` 使用常数大小空间。
@ -174,6 +180,12 @@
[class]{}-[func]{binarySearchLCRO}
```
=== "Dart"
```dart title="binary_search.dart"
[class]{}-[func]{binarySearchLCRO}
```
如下图所示,在两种区间表示下,二分查找算法的初始化、循环条件和缩小区间操作皆有所不同。
在“双闭区间”表示法中,由于左右边界都被定义为闭区间,因此指针 $i$ 和 $j$ 缩小区间操作也是对称的。这样更不容易出错。因此,**我们通常采用“双闭区间”的写法**。

View file

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

View file

@ -72,6 +72,12 @@
[class]{}-[func]{twoSumBruteForce}
```
=== "Dart"
```dart title="two_sum.dart"
[class]{}-[func]{twoSumBruteForce}
```
此方法的时间复杂度为 $O(n^2)$ ,空间复杂度为 $O(1)$ ,在大数据量下非常耗时。
## 哈希查找:以空间换时间
@ -154,6 +160,12 @@
[class]{}-[func]{twoSumHashTable}
```
=== "Dart"
```dart title="two_sum.dart"
[class]{}-[func]{twoSumHashTable}
```
此方法通过哈希查找将时间复杂度从 $O(n^2)$ 降低至 $O(n)$ ,大幅提升运行效率。
由于需要维护一个额外的哈希表,因此空间复杂度为 $O(n)$ 。**尽管如此,该方法的整体时空效率更为均衡,因此它是本题的最优解法**。

View file

@ -96,6 +96,12 @@
[class]{}-[func]{bubbleSort}
```
=== "Dart"
```dart title="bubble_sort.dart"
[class]{}-[func]{bubbleSort}
```
## 效率优化
我们发现,如果某轮“冒泡”中没有执行任何交换操作,说明数组已经完成排序,可直接返回结果。因此,可以增加一个标志位 `flag` 来监测这种情况,一旦出现就立即返回。
@ -162,6 +168,12 @@
[class]{}-[func]{bubbleSortWithFlag}
```
=== "Dart"
```dart title="bubble_sort.dart"
[class]{}-[func]{bubbleSortWithFlag}
```
## 算法特性
- **时间复杂度为 $O(n^2)$ 、自适应排序** :各轮“冒泡”遍历的数组长度依次为 $n - 1$ , $n - 2$ , $\cdots$ , $2$ , $1$ ,总和为 $\frac{(n - 1) n}{2}$ 。在引入 `flag` 优化后,最佳时间复杂度可达到 $O(n)$ 。

View file

@ -74,6 +74,12 @@
[class]{}-[func]{bucketSort}
```
=== "Dart"
```dart title="bucket_sort.dart"
[class]{}-[func]{bucketSort}
```
!!! question "桶排序的适用场景是什么?"
桶排序适用于处理体量很大的数据。例如,输入数据包含 100 万个元素,由于空间限制,系统内存无法一次性加载所有数据。此时,可以将数据分成 1000 个桶,然后分别对每个桶进行排序,最后将结果合并。

View file

@ -72,6 +72,12 @@
[class]{}-[func]{countingSortNaive}
```
=== "Dart"
```dart title="counting_sort.dart"
[class]{}-[func]{countingSortNaive}
```
!!! note "计数排序与桶排序的联系"
从桶排序的角度看,我们可以将计数排序中的计数数组 `counter` 的每个索引视为一个桶,将统计数量的过程看作是将各个元素分配到对应的桶中。本质上,计数排序是桶排序在整型数据下的一个特例。
@ -179,6 +185,12 @@ $$
[class]{}-[func]{countingSort}
```
=== "Dart"
```dart title="counting_sort.dart"
[class]{}-[func]{countingSort}
```
## 算法特性
- **时间复杂度 $O(n + m)$** :涉及遍历 `nums` 和遍历 `counter` ,都使用线性时间。一般情况下 $n \gg m$ ,时间复杂度趋于 $O(n)$ 。

View file

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

View file

@ -79,6 +79,12 @@
[class]{}-[func]{insertionSort}
```
=== "Dart"
```dart title="insertion_sort.dart"
[class]{}-[func]{insertionSort}
```
## 算法特性
- **时间复杂度 $O(n^2)$ 、自适应排序** :最差情况下,每次插入操作分别需要循环 $n - 1$ , $n-2$ , $\cdots$ , $2$ , $1$ 次,求和得到 $\frac{(n - 1) n}{2}$ ,因此时间复杂度为 $O(n^2)$ 。在遇到有序数据时,插入操作会提前终止。当输入数组完全有序时,插入排序达到最佳时间复杂度 $O(n)$ 。

View file

@ -131,6 +131,14 @@
[class]{}-[func]{mergeSort}
```
=== "Dart"
```dart title="merge_sort.dart"
[class]{}-[func]{merge}
[class]{}-[func]{mergeSort}
```
合并方法 `merge()` 代码中的难点包括:
- **在阅读代码时,需要特别注意各个变量的含义**。`nums` 的待合并区间为 `[left, right]` ,但由于 `tmp` 仅复制了 `nums` 该区间的元素,因此 `tmp` 对应区间为 `[0, right - left]`

View file

@ -117,6 +117,14 @@
[class]{QuickSort}-[func]{partition}
```
=== "Dart"
```dart title="quick_sort.dart"
[class]{QuickSort}-[func]{_swap}
[class]{QuickSort}-[func]{_partition}
```
## 算法流程
1. 首先,对原数组执行一次「哨兵划分」,得到未排序的左子数组和右子数组;
@ -185,6 +193,12 @@
[class]{QuickSort}-[func]{quickSort}
```
=== "Dart"
```dart title="quick_sort.dart"
[class]{QuickSort}-[func]{quickSort}
```
## 算法特性
- **时间复杂度 $O(n \log n)$ 、自适应排序** :在平均情况下,哨兵划分的递归层数为 $\log n$ ,每层中的总循环数为 $n$ ,总体使用 $O(n \log n)$ 时间。在最差情况下,每轮哨兵划分操作都将长度为 $n$ 的数组划分为长度为 $0$ 和 $n - 1$ 的两个子数组,此时递归层数达到 $n$ 层,每层中的循环数为 $n$ ,总体使用 $O(n^2)$ 时间。
@ -289,6 +303,14 @@
[class]{QuickSortMedian}-[func]{partition}
```
=== "Dart"
```dart title="quick_sort.dart"
[class]{QuickSortMedian}-[func]{_medianThree}
[class]{QuickSortMedian}-[func]{_partition}
```
## 尾递归优化
**在某些输入下,快速排序可能占用空间较多**。以完全倒序的输入数组为例,由于每轮哨兵划分后右子数组长度为 $0$ ,递归树的高度会达到 $n - 1$ ,此时需要占用 $O(n)$ 大小的栈帧空间。
@ -354,3 +376,9 @@
```zig title="quick_sort.zig"
[class]{QuickSortTailCall}-[func]{quickSort}
```
=== "Dart"
```dart title="quick_sort.dart"
[class]{QuickSortTailCall}-[func]{quickSort}
```

View file

@ -124,6 +124,16 @@ $$
[class]{}-[func]{radixSort}
```
=== "Dart"
```dart title="radix_sort.dart"
[class]{}-[func]{digit}
[class]{}-[func]{countingSortDigit}
[class]{}-[func]{radixSort}
```
!!! question "为什么从最低位开始排序?"
在连续的排序轮次中,后一轮排序会覆盖前一轮排序的结果。举例来说,如果第一轮排序结果 $a < b$ 而第二轮排序结果 $a > b$ ,那么第二轮的结果将取代第一轮的结果。由于数字的高位优先级高于低位,我们应该先排序低位再排序高位。

View file

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

View file

@ -283,6 +283,12 @@
```
=== "Dart"
```dart title="deque.dart"
```
## 双向队列实现 *
双向队列的实现与队列类似,可以选择链表或数组作为底层数据结构。
@ -390,6 +396,14 @@
[class]{LinkedListDeque}-[func]{}
```
=== "Dart"
```dart title="linkedlist_deque.dart"
[class]{ListNode}-[func]{}
[class]{LinkedListDeque}-[func]{}
```
### 基于数组的实现
与基于数组实现队列类似,我们也可以使用环形数组来实现双向队列。在队列的实现基础上,仅需增加“队首入队”和“队尾出队”的方法。
@ -471,6 +485,12 @@
[class]{ArrayDeque}-[func]{}
```
=== "Dart"
```dart title="array_deque.dart"
[class]{ArrayDeque}-[func]{}
```
## 双向队列应用
双向队列兼具栈与队列的逻辑,**因此它可以实现这两者的所有应用场景,同时提供更高的自由度**。

View file

@ -252,6 +252,12 @@
```
=== "Dart"
```dart title="queue.dart"
```
## 队列实现
为了实现队列,我们需要一种数据结构,可以在一端添加元素,并在另一端删除元素。因此,链表和数组都可以用来实现队列。
@ -331,6 +337,12 @@
[class]{LinkedListQueue}-[func]{}
```
=== "Dart"
```dart title="linkedlist_queue.dart"
[class]{LinkedListQueue}-[func]{}
```
### 基于数组的实现
由于数组删除首元素的时间复杂度为 $O(n)$ ,这会导致出队操作效率较低。然而,我们可以采用以下巧妙方法来避免这个问题。
@ -417,6 +429,12 @@
[class]{ArrayQueue}-[func]{}
```
=== "Dart"
```dart title="array_queue.dart"
[class]{ArrayQueue}-[func]{}
```
以上实现的队列仍然具有局限性,即其长度不可变。然而,这个问题不难解决,我们可以将数组替换为动态数组,从而引入扩容机制。有兴趣的同学可以尝试自行实现。
两种实现的对比结论与栈一致,在此不再赘述。

View file

@ -250,6 +250,12 @@
```
=== "Dart"
```dart title="stack.dart"
```
## 栈的实现
为了深入了解栈的运行机制,我们来尝试自己实现一个栈类。
@ -333,6 +339,12 @@
[class]{LinkedListStack}-[func]{}
```
=== "Dart"
```dart title="linkedlist_stack.dart"
[class]{LinkedListStack}-[func]{}
```
### 基于数组的实现
在基于「数组」实现栈时,我们可以将数组的尾部作为栈顶。在这样的设计下,入栈与出栈操作就分别对应在数组尾部添加元素与删除元素,时间复杂度都为 $O(1)$ 。
@ -408,6 +420,12 @@
[class]{ArrayStack}-[func]{}
```
=== "Dart"
```dart title="array_stack.dart"
[class]{ArrayStack}-[func]{}
```
## 两种实现对比
### 支持操作

View file

@ -100,6 +100,12 @@
```
=== "Dart"
```dart title=""
```
![任意类型二叉树的数组表示](binary_tree.assets/array_representation_with_empty.png)
## 优势与局限性

View file

@ -169,6 +169,12 @@ G. M. Adelson-Velsky 和 E. M. Landis 在其 1962 年发表的论文 "An algorit
```
=== "Dart"
```dart title=""
```
「节点高度」是指从该节点到最远叶节点的距离,即所经过的“边”的数量。需要特别注意的是,叶节点的高度为 0 ,而空节点的高度为 -1 。我们将创建两个工具函数,分别用于获取和更新节点的高度。
=== "Java"
@ -251,6 +257,14 @@ G. M. Adelson-Velsky 和 E. M. Landis 在其 1962 年发表的论文 "An algorit
[class]{AVLTree}-[func]{updateHeight}
```
=== "Dart"
```dart title="avl_tree.dart"
[class]{AVLTree}-[func]{height}
[class]{AVLTree}-[func]{updateHeight}
```
### 节点平衡因子
节点的「平衡因子 Balance Factor」定义为节点左子树的高度减去右子树的高度同时规定空节点的平衡因子为 0 。我们同样将获取节点平衡因子的功能封装成函数,方便后续使用。
@ -315,6 +329,12 @@ G. M. Adelson-Velsky 和 E. M. Landis 在其 1962 年发表的论文 "An algorit
[class]{AVLTree}-[func]{balanceFactor}
```
=== "Dart"
```dart title="avl_tree.dart"
[class]{AVLTree}-[func]{balanceFactor}
```
!!! note
设平衡因子为 $f$ ,则一棵 AVL 树的任意节点的平衡因子皆满足 $-1 \le f \le 1$ 。
@ -407,6 +427,12 @@ AVL 树的特点在于「旋转 Rotation」操作它能够在不影响二叉
[class]{AVLTree}-[func]{rightRotate}
```
=== "Dart"
```dart title="avl_tree.dart"
[class]{AVLTree}-[func]{rightRotate}
```
### 左旋
相应的,如果考虑上述失衡二叉树的“镜像”,则需要执行「左旋」操作。
@ -479,6 +505,12 @@ AVL 树的特点在于「旋转 Rotation」操作它能够在不影响二叉
[class]{AVLTree}-[func]{leftRotate}
```
=== "Dart"
```dart title="avl_tree.dart"
[class]{AVLTree}-[func]{leftRotate}
```
### 先左旋后右旋
对于下图中的失衡节点 3仅使用左旋或右旋都无法使子树恢复平衡。此时需要先左旋后右旋即先对 `child` 执行「左旋」,再对 `node` 执行「右旋」。
@ -572,6 +604,12 @@ AVL 树的特点在于「旋转 Rotation」操作它能够在不影响二叉
[class]{AVLTree}-[func]{rotate}
```
=== "Dart"
```dart title="avl_tree.dart"
[class]{AVLTree}-[func]{rotate}
```
## AVL 树常用操作
### 插入节点
@ -658,6 +696,14 @@ AVL 树的特点在于「旋转 Rotation」操作它能够在不影响二叉
[class]{AVLTree}-[func]{insertHelper}
```
=== "Dart"
```dart title="avl_tree.dart"
[class]{AVLTree}-[func]{insert}
[class]{AVLTree}-[func]{insertHelper}
```
### 删除节点
类似地,在二叉搜索树的删除节点方法的基础上,需要从底至顶地执行旋转操作,使所有失衡节点恢复平衡。
@ -742,6 +788,14 @@ AVL 树的特点在于「旋转 Rotation」操作它能够在不影响二叉
[class]{AVLTree}-[func]{removeHelper}
```
=== "Dart"
```dart title="avl_tree.dart"
[class]{AVLTree}-[func]{remove}
[class]{AVLTree}-[func]{removeHelper}
```
### 查找节点
AVL 树的节点查找操作与二叉搜索树一致,在此不再赘述。

View file

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

View file

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

View file

@ -76,6 +76,12 @@
[class]{}-[func]{levelOrder}
```
=== "Dart"
```dart title="binary_tree_bfs.dart"
[class]{}-[func]{levelOrder}
```
### 复杂度分析
**时间复杂度**:所有节点被访问一次,使用 $O(n)$ 时间,其中 $n$ 为节点数量。
@ -202,6 +208,16 @@
[class]{}-[func]{postOrder}
```
=== "Dart"
```dart title="binary_tree_dfs.dart"
[class]{}-[func]{preOrder}
[class]{}-[func]{inOrder}
[class]{}-[func]{postOrder}
```
!!! note
我们也可以仅基于循环实现前、中、后序遍历,有兴趣的同学可以自行实现。