mirror of
https://github.com/krahets/hello-algo.git
synced 2024-12-24 09:26:28 +08:00
Add Dart codes to the documents. (#529)
This commit is contained in:
parent
041a989d33
commit
025051c81b
38 changed files with 849 additions and 96 deletions
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
```
|
||||
|
||||
## 数组典型应用
|
||||
|
||||
**随机访问**。如果我们想要随机抽取一些样本,那么可以用数组存储,并生成一个随机序列,根据索引实现样本的随机抽取。
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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]{}
|
||||
```
|
||||
|
|
|
@ -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` ,并实现框架中的各个方法。
|
||||
|
||||
## 典型例题
|
||||
|
|
|
@ -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!)$** 。实际上,根据对角线约束的剪枝也能够大幅地缩小搜索空间,因而搜索效率往往优于以上时间复杂度。
|
||||
|
|
|
@ -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` 中重复出现。
|
||||
|
|
|
@ -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)$
|
||||
|
|
|
@ -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
|
||||
|
||||
实际应用中我们很少使用「最佳时间复杂度」,因为通常只有在很小概率下才能达到,可能会带来一定的误导性。相反,「最差时间复杂度」更为实用,因为它给出了一个“效率安全值”,让我们可以放心地使用算法。
|
||||
|
|
|
@ -129,3 +129,9 @@
|
|||
```zig title=""
|
||||
|
||||
```
|
||||
|
||||
=== "Dart"
|
||||
|
||||
```dart title=""
|
||||
|
||||
```
|
||||
|
|
|
@ -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$ 条边,下表为邻接矩阵和邻接表的时间和空间效率对比。
|
||||
|
|
|
@ -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}
|
||||
```
|
||||
|
||||
深度优先遍历的算法流程如下图所示,其中:
|
||||
|
||||
- **直虚线代表向下递推**,表示开启了一个新的递归方法来访问新顶点;
|
||||
|
|
|
@ -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$ 的两个学生时,我们得到:
|
||||
|
|
|
@ -72,6 +72,12 @@
|
|||
[class]{MaxHeap}-[func]{init}
|
||||
```
|
||||
|
||||
=== "Dart"
|
||||
|
||||
```dart title="my_heap.dart"
|
||||
[class]{MaxHeap}-[func]{MaxHeap}
|
||||
```
|
||||
|
||||
## 复杂度分析
|
||||
|
||||
为什么第二种建堆方法的时间复杂度是 $O(n)$ ?我们来展开推算一下。
|
||||
|
|
|
@ -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)$ ,这些操作都非常高效。
|
||||
|
|
|
@ -144,6 +144,17 @@
|
|||
// 注释
|
||||
```
|
||||
|
||||
=== "Dart"
|
||||
|
||||
```dart title=""
|
||||
// 标题注释,用于标注函数、类、测试样例等
|
||||
|
||||
// 内容注释,用于详解代码
|
||||
|
||||
// 多行
|
||||
// 注释
|
||||
```
|
||||
|
||||
## 在动画图解中高效学习
|
||||
|
||||
相较于文字,视频和图片具有更高的信息密度和结构化程度,因此更易于理解。在本书中,**重点和难点知识将主要通过动画和图解形式展示**,而文字则作为动画和图片的解释与补充。
|
||||
|
|
|
@ -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$ 缩小区间操作也是对称的。这样更不容易出错。因此,**我们通常采用“双闭区间”的写法**。
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)$ 。**尽管如此,该方法的整体时空效率更为均衡,因此它是本题的最优解法**。
|
||||
|
|
|
@ -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)$ 。
|
||||
|
|
|
@ -74,6 +74,12 @@
|
|||
[class]{}-[func]{bucketSort}
|
||||
```
|
||||
|
||||
=== "Dart"
|
||||
|
||||
```dart title="bucket_sort.dart"
|
||||
[class]{}-[func]{bucketSort}
|
||||
```
|
||||
|
||||
!!! question "桶排序的适用场景是什么?"
|
||||
|
||||
桶排序适用于处理体量很大的数据。例如,输入数据包含 100 万个元素,由于空间限制,系统内存无法一次性加载所有数据。此时,可以将数据分成 1000 个桶,然后分别对每个桶进行排序,最后将结果合并。
|
||||
|
|
|
@ -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)$ 。
|
||||
|
|
|
@ -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$ 轮。
|
||||
|
|
|
@ -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)$ 。
|
||||
|
|
|
@ -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]` 。
|
||||
|
|
|
@ -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}
|
||||
```
|
||||
|
|
|
@ -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$ ,那么第二轮的结果将取代第一轮的结果。由于数字的高位优先级高于低位,我们应该先排序低位再排序高位。
|
||||
|
|
|
@ -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}$ 。
|
||||
|
|
|
@ -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]{}
|
||||
```
|
||||
|
||||
## 双向队列应用
|
||||
|
||||
双向队列兼具栈与队列的逻辑,**因此它可以实现这两者的所有应用场景,同时提供更高的自由度**。
|
||||
|
|
|
@ -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]{}
|
||||
```
|
||||
|
||||
以上实现的队列仍然具有局限性,即其长度不可变。然而,这个问题不难解决,我们可以将数组替换为动态数组,从而引入扩容机制。有兴趣的同学可以尝试自行实现。
|
||||
|
||||
两种实现的对比结论与栈一致,在此不再赘述。
|
||||
|
|
|
@ -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]{}
|
||||
```
|
||||
|
||||
## 两种实现对比
|
||||
|
||||
### 支持操作
|
||||
|
|
|
@ -100,6 +100,12 @@
|
|||
|
||||
```
|
||||
|
||||
=== "Dart"
|
||||
|
||||
```dart title=""
|
||||
|
||||
```
|
||||
|
||||
![任意类型二叉树的数组表示](binary_tree.assets/array_representation_with_empty.png)
|
||||
|
||||
## 优势与局限性
|
||||
|
|
|
@ -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 树的节点查找操作与二叉搜索树一致,在此不再赘述。
|
||||
|
|
|
@ -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$ 右”的遍历顺序,而二叉搜索树满足“左子节点 $<$ 根节点 $<$ 右子节点”的大小关系。因此,在二叉搜索树中进行中序遍历时,总是会优先遍历下一个最小节点,从而得出一个重要性质:**二叉搜索树的中序遍历序列是升序的**。
|
||||
|
|
|
@ -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
|
||||
|
||||
需要注意的是,插入节点可能会改变二叉树的原有逻辑结构,而删除节点通常意味着删除该节点及其所有子树。因此,在二叉树中,插入与删除操作通常是由一套操作配合完成的,以实现有实际意义的操作。
|
||||
|
|
|
@ -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
|
||||
|
||||
我们也可以仅基于循环实现前、中、后序遍历,有兴趣的同学可以自行实现。
|
||||
|
|
Loading…
Reference in a new issue