diff --git a/build/.gitignore b/build/.gitignore new file mode 100644 index 000000000..fd1014369 --- /dev/null +++ b/build/.gitignore @@ -0,0 +1,6 @@ +assets +*.assets + +*.png +*.jpg +*.gif diff --git a/build/chapter_array_and_linkedlist/array.md b/build/chapter_array_and_linkedlist/array.md new file mode 100755 index 000000000..fa4418ee8 --- /dev/null +++ b/build/chapter_array_and_linkedlist/array.md @@ -0,0 +1,909 @@ +--- +comments: true +--- + +# 4.1. 数组 + +「数组 Array」是一种将 **相同类型元素** 存储在 **连续内存空间** 的数据结构,将元素在数组中的位置称为元素的「索引 Index」。 + + + +
Fig. 数组定义与存储方式
+ +!!! note + + 观察上图,我们发现 **数组首元素的索引为 $0$** 。你可能会想,这并不符合日常习惯,首个元素的索引为什么不是 $1$ 呢,这不是更加自然吗?我认同你的想法,但请先记住这个设定,后面讲内存地址计算时,我会尝试解答这个问题。 + +**数组有多种初始化写法**。根据实际需要,选代码最短的那一种就好。 + +=== "Java" + + ```java title="array.java" + /* 初始化数组 */ + int[] arr = new int[5]; // { 0, 0, 0, 0, 0 } + int[] nums = { 1, 3, 2, 5, 4 }; + ``` + +=== "C++" + + ```cpp title="array.cpp" + /* 初始化数组 */ + int* arr = new int[5]; + int* nums = new int[5] { 1, 3, 2, 5, 4 }; + ``` + +=== "Python" + + ```python title="array.py" + """ 初始化数组 """ + arr = [0] * 5 # [ 0, 0, 0, 0, 0 ] + nums = [1, 3, 2, 5, 4] + ``` + +=== "Go" + + ```go title="array.go" + /* 初始化数组 */ + var arr [5]int + // 在 Go 中,指定长度时([5]int)为数组,不指定长度时([]int)为切片 + // 由于 Go 的数组被设计为在编译期确定长度,因此只能使用常量来指定长度 + // 为了方便实现扩容 extend() 方法,以下将切片(Slice)看作数组(Array) + nums := []int{1, 3, 2, 5, 4} + ``` + +=== "JavaScript" + + ```javascript title="array.js" + /* 初始化数组 */ + var arr = new Array(5).fill(0); + var nums = [1, 3, 2, 5, 4]; + ``` + +=== "TypeScript" + + ```typescript title="array.ts" + /* 初始化数组 */ + let arr: number[] = new Array(5).fill(0); + let nums: number[] = [1, 3, 2, 5, 4]; + ``` + +=== "C" + + ```c title="array.c" + + ``` + +=== "C#" + + ```csharp title="array.cs" + /* 初始化数组 */ + int[] arr = new int[5]; // { 0, 0, 0, 0, 0 } + int[] nums = { 1, 3, 2, 5, 4 }; + ``` + +=== "Swift" + + ```swift title="array.swift" + /* 初始化数组 */ + let arr = Array(repeating: 0, count: 5) // [0, 0, 0, 0, 0] + let nums = [1, 3, 2, 5, 4] + ``` + +=== "Zig" + + ```zig title="array.zig" + // 初始化数组 + var arr = [_]i32{0} ** 5; // { 0, 0, 0, 0, 0 } + var nums = [_]i32{ 1, 3, 2, 5, 4 }; + ``` + +## 4.1.1. 数组优点 + +**在数组中访问元素非常高效**。这是因为在数组中,计算元素的内存地址非常容易。给定数组首个元素的地址、和一个元素的索引,利用以下公式可以直接计算得到该元素的内存地址,从而直接访问此元素。 + + + +Fig. 数组元素的内存地址计算
+ +```java title="" +// 元素内存地址 = 数组内存地址 + 元素长度 * 元素索引 +elementAddr = firtstElementAddr + elementLength * elementIndex +``` + +**为什么数组元素索引从 0 开始编号?** 根据地址计算公式,**索引本质上表示的是内存地址偏移量**,首个元素的地址偏移量是 $0$ ,那么索引是 $0$ 也就很自然了。 + +访问元素的高效性带来了许多便利。例如,我们可以在 $O(1)$ 时间内随机获取一个数组中的元素。 + +=== "Java" + + ```java title="array.java" + /* 随机返回一个数组元素 */ + int randomAccess(int[] nums) { + // 在区间 [0, nums.length) 中随机抽取一个数字 + int randomIndex = ThreadLocalRandom.current(). + nextInt(0, nums.length); + int randomNum = nums[randomIndex]; + return randomNum; + } + ``` + +=== "C++" + + ```cpp title="array.cpp" + /* 随机返回一个数组元素 */ + int randomAccess(int* nums, int size) { + // 在区间 [0, size) 中随机抽取一个数字 + int randomIndex = rand() % size; + // 获取并返回随机元素 + int randomNum = nums[randomIndex]; + return randomNum; + } + ``` + +=== "Python" + + ```python title="array.py" + """ 随机访问元素 """ + def random_access(nums): + # 在区间 [0, len(nums)-1] 中随机抽取一个数字 + random_index = random.randint(0, len(nums) - 1) + # 获取并返回随机元素 + random_num = nums[random_index] + return random_num + ``` + +=== "Go" + + ```go title="array.go" + /* 随机返回一个数组元素 */ + func randomAccess(nums []int) (randomNum int) { + // 在区间 [0, nums.length) 中随机抽取一个数字 + randomIndex := rand.Intn(len(nums)) + // 获取并返回随机元素 + randomNum = nums[randomIndex] + return + } + ``` + +=== "JavaScript" + + ```javascript title="array.js" + /* 随机返回一个数组元素 */ + function randomAccess(nums) { + // 在区间 [0, nums.length) 中随机抽取一个数字 + const random_index = Math.floor(Math.random() * nums.length); + // 获取并返回随机元素 + const random_num = nums[random_index]; + return random_num; + } + ``` + +=== "TypeScript" + + ```typescript title="array.ts" + /* 随机返回一个数组元素 */ + function randomAccess(nums: number[]): number { + // 在区间 [0, nums.length) 中随机抽取一个数字 + const random_index = Math.floor(Math.random() * nums.length); + // 获取并返回随机元素 + const random_num = nums[random_index]; + return random_num; + } + ``` + +=== "C" + + ```c title="array.c" + + ``` + +=== "C#" + + ```csharp title="array.cs" + /* 随机返回一个数组元素 */ + int RandomAccess(int[] nums) + { + Random random=new(); + // 在区间 [0, nums.Length) 中随机抽取一个数字 + int randomIndex = random.Next(nums.Length); + // 获取并返回随机元素 + int randomNum = nums[randomIndex]; + return randomNum; + } + ``` + +=== "Swift" + + ```swift title="array.swift" + /* 随机返回一个数组元素 */ + func randomAccess(nums: [Int]) -> Int { + // 在区间 [0, nums.count) 中随机抽取一个数字 + let randomIndex = nums.indices.randomElement()! + // 获取并返回随机元素 + let randomNum = nums[randomIndex] + return randomNum + } + ``` + +=== "Zig" + + ```zig title="array.zig" + // 随机返回一个数组元素 + pub fn randomAccess(nums: []i32) i32 { + // 在区间 [0, nums.len) 中随机抽取一个整数 + var randomIndex = std.crypto.random.intRangeLessThan(usize, 0, nums.len); + // 获取并返回随机元素 + var randomNum = nums[randomIndex]; + return randomNum; + } + ``` + +## 4.1.2. 数组缺点 + +**数组在初始化后长度不可变**。由于系统无法保证数组之后的内存空间是可用的,因此数组长度无法扩展。而若希望扩容数组,则需新建一个数组,然后把原数组元素依次拷贝到新数组,在数组很大的情况下,这是非常耗时的。 + +=== "Java" + + ```java title="array.java" + /* 扩展数组长度 */ + int[] extend(int[] nums, int enlarge) { + // 初始化一个扩展长度后的数组 + int[] res = new int[nums.length + enlarge]; + // 将原数组中的所有元素复制到新数组 + for (int i = 0; i < nums.length; i++) { + res[i] = nums[i]; + } + // 返回扩展后的新数组 + return res; + } + ``` + +=== "C++" + + ```cpp title="array.cpp" + /* 扩展数组长度 */ + int* extend(int* nums, int size, int enlarge) { + // 初始化一个扩展长度后的数组 + int* res = new int[size + enlarge]; + // 将原数组中的所有元素复制到新数组 + for (int i = 0; i < size; i++) { + res[i] = nums[i]; + } + // 释放内存 + delete[] nums; + // 返回扩展后的新数组 + return res; + } + ``` + +=== "Python" + + ```python title="array.py" + """ 扩展数组长度 """ + # 请注意,Python 的 list 是动态数组,可以直接扩展 + # 为了方便学习,本函数将 list 看作是长度不可变的数组 + def extend(nums, enlarge): + # 初始化一个扩展长度后的数组 + res = [0] * (len(nums) + enlarge) + # 将原数组中的所有元素复制到新数组 + for i in range(len(nums)): + res[i] = nums[i] + # 返回扩展后的新数组 + return res + ``` + +=== "Go" + + ```go title="array.go" + /* 扩展数组长度 */ + func extend(nums []int, enlarge int) []int { + // 初始化一个扩展长度后的数组 + res := make([]int, len(nums)+enlarge) + // 将原数组中的所有元素复制到新数组 + for i, num := range nums { + res[i] = num + } + // 返回扩展后的新数组 + return res + } + ``` + +=== "JavaScript" + + ```javascript title="array.js" + /* 扩展数组长度 */ + function extend(nums, enlarge) { + // 初始化一个扩展长度后的数组 + const res = new Array(nums.length + enlarge).fill(0); + // 将原数组中的所有元素复制到新数组 + for (let i = 0; i < nums.length; i++) { + res[i] = nums[i]; + } + // 返回扩展后的新数组 + return res; + } + ``` + +=== "TypeScript" + + ```typescript title="array.ts" + /* 扩展数组长度 */ + function extend(nums: number[], enlarge: number): number[] { + // 初始化一个扩展长度后的数组 + const res = new Array(nums.length + enlarge).fill(0); + // 将原数组中的所有元素复制到新数组 + for (let i = 0; i < nums.length; i++) { + res[i] = nums[i]; + } + // 返回扩展后的新数组 + return res; + } + ``` + +=== "C" + + ```c title="array.c" + + ``` + +=== "C#" + + ```csharp title="array.cs" + /* 扩展数组长度 */ + int[] Extend(int[] nums, int enlarge) + { + // 初始化一个扩展长度后的数组 + int[] res = new int[nums.Length + enlarge]; + // 将原数组中的所有元素复制到新数组 + for (int i = 0; i < nums.Length; i++) + { + res[i] = nums[i]; + } + // 返回扩展后的新数组 + return res; + } + ``` + +=== "Swift" + + ```swift title="array.swift" + /* 扩展数组长度 */ + func extend(nums: [Int], enlarge: Int) -> [Int] { + // 初始化一个扩展长度后的数组 + var res = Array(repeating: 0, count: nums.count + enlarge) + // 将原数组中的所有元素复制到新数组 + for i in nums.indices { + res[i] = nums[i] + } + // 返回扩展后的新数组 + return res + } + ``` + +=== "Zig" + + ```zig title="array.zig" + // 扩展数组长度 + pub fn extend(mem_allocator: std.mem.Allocator, nums: []i32, enlarge: usize) ![]i32 { + // 初始化一个扩展长度后的数组 + var res = try mem_allocator.alloc(i32, nums.len + enlarge); + std.mem.set(i32, res, 0); + // 将原数组中的所有元素复制到新数组 + std.mem.copy(i32, res, nums); + // 返回扩展后的新数组 + return res; + } + ``` + +**数组中插入或删除元素效率低下**。假设我们想要在数组中间某位置插入一个元素,由于数组元素在内存中是“紧挨着的”,它们之间没有空间再放任何数据。因此,我们不得不将此索引之后的所有元素都向后移动一位,然后再把元素赋值给该索引。删除元素也是类似,需要把此索引之后的元素都向前移动一位。总体看有以下缺点: + +- **时间复杂度高**:数组的插入和删除的平均时间复杂度均为 $O(N)$ ,其中 $N$ 为数组长度。 +- **丢失元素**:由于数组的长度不可变,因此在插入元素后,超出数组长度范围的元素会被丢失。 +- **内存浪费**:我们一般会初始化一个比较长的数组,只用前面一部分,这样在插入数据时,丢失的末尾元素都是我们不关心的,但这样做同时也会造成内存空间的浪费。 + + + +Fig. 在数组中插入与删除元素
+ +=== "Java" + + ```java title="array.java" + /* 在数组的索引 index 处插入元素 num */ + void insert(int[] nums, int num, int index) { + // 把索引 index 以及之后的所有元素向后移动一位 + for (int i = nums.length - 1; i > index; i--) { + nums[i] = nums[i - 1]; + } + // 将 num 赋给 index 处元素 + nums[index] = num; + } + + /* 删除索引 index 处元素 */ + void remove(int[] nums, int index) { + // 把索引 index 之后的所有元素向前移动一位 + for (int i = index; i < nums.length - 1; i++) { + nums[i] = nums[i + 1]; + } + } + ``` + +=== "C++" + + ```cpp title="array.cpp" + /* 在数组的索引 index 处插入元素 num */ + void insert(int* nums, int size, int num, int index) { + // 把索引 index 以及之后的所有元素向后移动一位 + for (int i = size - 1; i > index; i--) { + nums[i] = nums[i - 1]; + } + // 将 num 赋给 index 处元素 + nums[index] = num; + } + + /* 删除索引 index 处元素 */ + void remove(int* nums, int size, int index) { + // 把索引 index 之后的所有元素向前移动一位 + for (int i = index; i < size - 1; i++) { + nums[i] = nums[i + 1]; + } + } + ``` + +=== "Python" + + ```python title="array.py" + """ 在数组的索引 index 处插入元素 num """ + def insert(nums, num, index): + # 把索引 index 以及之后的所有元素向后移动一位 + for i in range(len(nums) - 1, index, -1): + nums[i] = nums[i - 1] + # 将 num 赋给 index 处元素 + nums[index] = num + + """ 删除索引 index 处元素 """ + def remove(nums, index): + # 把索引 index 之后的所有元素向前移动一位 + for i in range(index, len(nums) - 1): + nums[i] = nums[i + 1] + ``` + +=== "Go" + + ```go title="array.go" + /* 在数组的索引 index 处插入元素 num */ + func insert(nums []int, num int, index int) { + // 把索引 index 以及之后的所有元素向后移动一位 + for i := len(nums) - 1; i > index; i-- { + nums[i] = nums[i-1] + } + // 将 num 赋给 index 处元素 + nums[index] = num + } + + /* 删除索引 index 处元素 */ + func remove(nums []int, index int) { + // 把索引 index 之后的所有元素向前移动一位 + for i := index; i < len(nums)-1; i++ { + nums[i] = nums[i+1] + } + } + ``` + +=== "JavaScript" + + ```javascript title="array.js" + /* 在数组的索引 index 处插入元素 num */ + function insert(nums, num, index) { + // 把索引 index 以及之后的所有元素向后移动一位 + for (let i = nums.length - 1; i > index; i--) { + nums[i] = nums[i - 1]; + } + // 将 num 赋给 index 处元素 + nums[index] = num; + } + + /* 删除索引 index 处元素 */ + function remove(nums, index) { + // 把索引 index 之后的所有元素向前移动一位 + for (let i = index; i < nums.length - 1; i++) { + nums[i] = nums[i + 1]; + } + } + ``` + +=== "TypeScript" + + ```typescript title="array.ts" + /* 在数组的索引 index 处插入元素 num */ + function insert(nums: number[], num: number, index: number): void { + // 把索引 index 以及之后的所有元素向后移动一位 + for (let i = nums.length - 1; i > index; i--) { + nums[i] = nums[i - 1]; + } + // 将 num 赋给 index 处元素 + nums[index] = num; + } + + /* 删除索引 index 处元素 */ + function remove(nums: number[], index: number): void { + // 把索引 index 之后的所有元素向前移动一位 + for (let i = index; i < nums.length - 1; i++) { + nums[i] = nums[i + 1]; + } + } + ``` + +=== "C" + + ```c title="array.c" + + ``` + +=== "C#" + + ```csharp title="array.cs" + /* 在数组的索引 index 处插入元素 num */ + void Insert(int[] nums, int num, int index) + { + // 把索引 index 以及之后的所有元素向后移动一位 + for (int i = nums.Length - 1; i > index; i--) + { + nums[i] = nums[i - 1]; + } + // 将 num 赋给 index 处元素 + nums[index] = num; + } + /* 删除索引 index 处元素 */ + void Remove(int[] nums, int index) + { + // 把索引 index 之后的所有元素向前移动一位 + for (int i = index; i < nums.Length - 1; i++) + { + nums[i] = nums[i + 1]; + } + } + ``` + +=== "Swift" + + ```swift title="array.swift" + /* 在数组的索引 index 处插入元素 num */ + func insert(nums: inout [Int], num: Int, index: Int) { + // 把索引 index 以及之后的所有元素向后移动一位 + for i in sequence(first: nums.count - 1, next: { $0 > index + 1 ? $0 - 1 : nil }) { + nums[i] = nums[i - 1] + } + // 将 num 赋给 index 处元素 + nums[index] = num + } + + /* 删除索引 index 处元素 */ + func remove(nums: inout [Int], index: Int) { + let count = nums.count + // 把索引 index 之后的所有元素向前移动一位 + for i in sequence(first: index, next: { $0 < count - 1 - 1 ? $0 + 1 : nil }) { + nums[i] = nums[i + 1] + } + } + ``` + +=== "Zig" + + ```zig title="array.zig" + // 在数组的索引 index 处插入元素 num + pub 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; + } + + // 删除索引 index 处元素 + pub fn remove(nums: []i32, index: usize) void { + // 把索引 index 之后的所有元素向前移动一位 + var i = index; + while (i < nums.len - 1) : (i += 1) { + nums[i] = nums[i + 1]; + } + } + ``` + +## 4.1.3. 数组常用操作 + +**数组遍历**。以下介绍两种常用的遍历方法。 + +=== "Java" + + ```java title="array.java" + /* 遍历数组 */ + void traverse(int[] nums) { + int count = 0; + // 通过索引遍历数组 + for (int i = 0; i < nums.length; i++) { + count++; + } + // 直接遍历数组 + for (int num : nums) { + count++; + } + } + ``` + +=== "C++" + + ```cpp title="array.cpp" + /* 遍历数组 */ + void traverse(int* nums, int size) { + int count = 0; + // 通过索引遍历数组 + for (int i = 0; i < size; i++) { + count++; + } + } + ``` + +=== "Python" + + ```python title="array.py" + """ 遍历数组 """ + def traverse(nums): + count = 0 + # 通过索引遍历数组 + for i in range(len(nums)): + count += 1 + # 直接遍历数组 + for num in nums: + count += 1 + ``` + +=== "Go" + + ```go title="array.go" + /* 遍历数组 */ + func traverse(nums []int) { + count := 0 + // 通过索引遍历数组 + for i := 0; i < len(nums); i++ { + count++ + } + // 直接遍历数组 + for range nums { + count++ + } + } + ``` + +=== "JavaScript" + + ```javascript title="array.js" + /* 遍历数组 */ + function traverse(nums) { + let count = 0; + // 通过索引遍历数组 + for (let i = 0; i < nums.length; i++) { + count++; + } + // 直接遍历数组 + for (let num of nums) { + count += 1; + } + } + ``` + +=== "TypeScript" + + ```typescript title="array.ts" + /* 遍历数组 */ + function traverse(nums: number[]): void { + let count = 0; + // 通过索引遍历数组 + for (let i = 0; i < nums.length; i++) { + count++; + } + // 直接遍历数组 + for(let num of nums){ + count += 1; + } + } + ``` + +=== "C" + + ```c title="array.c" + + ``` + +=== "C#" + + ```csharp title="array.cs" + /* 遍历数组 */ + void Traverse(int[] nums) + { + int count = 0; + // 通过索引遍历数组 + for (int i = 0; i < nums.Length; i++) + { + count++; + } + // 直接遍历数组 + foreach (int num in nums) + { + count++; + } + } + ``` + +=== "Swift" + + ```swift title="array.swift" + /* 遍历数组 */ + func traverse(nums: [Int]) { + var count = 0 + // 通过索引遍历数组 + for _ in nums.indices { + count += 1 + } + // 直接遍历数组 + for _ in nums { + count += 1 + } + } + ``` + +=== "Zig" + + ```zig title="array.zig" + // 遍历数组 + pub fn traverse(nums: []i32) void { + var count: i32 = 0; + // 通过索引遍历数组 + var i: i32 = 0; + while (i < nums.len) : (i += 1) { + count += 1; + } + count = 0; + // 直接遍历数组 + for (nums) |_| { + count += 1; + } + } + ``` + +**数组查找**。通过遍历数组,查找数组内的指定元素,并输出对应索引。 + +=== "Java" + + ```java title="array.java" + /* 在数组中查找指定元素 */ + int find(int[] nums, int target) { + for (int i = 0; i < nums.length; i++) { + if (nums[i] == target) + return i; + } + return -1; + } + ``` + +=== "C++" + + ```cpp title="array.cpp" + /* 在数组中查找指定元素 */ + int find(int* nums, int size, int target) { + for (int i = 0; i < size; i++) { + if (nums[i] == target) + return i; + } + return -1; + } + ``` + +=== "Python" + + ```python title="array.py" + """ 在数组中查找指定元素 """ + def find(nums, target): + for i in range(len(nums)): + if nums[i] == target: + return i + return -1 + ``` + +=== "Go" + + ```go title="array.go" + /* 在数组中查找指定元素 */ + func find(nums []int, target int) (index int) { + index = -1 + for i := 0; i < len(nums); i++ { + if nums[i] == target { + index = i + break + } + } + return + } + ``` + +=== "JavaScript" + + ```javascript title="array.js" + /* 在数组中查找指定元素 */ + function find(nums, target) { + for (let i = 0; i < nums.length; i++) { + if (nums[i] == target) return i; + } + return -1; + } + ``` + +=== "TypeScript" + + ```typescript title="array.ts" + /* 在数组中查找指定元素 */ + function find(nums: number[], target: number): number { + for (let i = 0; i < nums.length; i++) { + if (nums[i] === target) { + return i; + } + } + return -1; + } + ``` + +=== "C" + + ```c title="array.c" + + ``` + +=== "C#" + + ```csharp title="array.cs" + /* 在数组中查找指定元素 */ + int Find(int[] nums, int target) + { + for (int i = 0; i < nums.Length; i++) + { + if (nums[i] == target) + return i; + } + return -1; + } + ``` + +=== "Swift" + + ```swift title="array.swift" + /* 在数组中查找指定元素 */ + func find(nums: [Int], target: Int) -> Int { + for i in nums.indices { + if nums[i] == target { + return i + } + } + return -1 + } + ``` + +=== "Zig" + + ```zig title="array.zig" + // 在数组中查找指定元素 + pub fn find(nums: []i32, target: i32) i32 { + for (nums) |num, i| { + if (num == target) return @intCast(i32, i); + } + return -1; + } + ``` + +## 4.1.4. 数组典型应用 + +**随机访问**。如果我们想要随机抽取一些样本,那么可以用数组存储,并生成一个随机序列,根据索引实现样本的随机抽取。 + +**二分查找**。例如前文查字典的例子,我们可以将字典中的所有字按照拼音顺序存储在数组中,然后使用与日常查纸质字典相同的“翻开中间,排除一半”的方式,来实现一个查电子字典的算法。 + +**深度学习**。神经网络中大量使用了向量、矩阵、张量之间的线性代数运算,这些数据都是以数组的形式构建的。数组是神经网络编程中最常使用的数据结构。 diff --git a/build/chapter_array_and_linkedlist/linked_list.md b/build/chapter_array_and_linkedlist/linked_list.md new file mode 100755 index 000000000..0e17954a5 --- /dev/null +++ b/build/chapter_array_and_linkedlist/linked_list.md @@ -0,0 +1,979 @@ +--- +comments: true +--- + +# 4.2. 链表 + +!!! note "引言" + + 内存空间是所有程序的公共资源,排除已占用的内存,空闲内存往往是散落在内存各处的。我们知道,存储数组需要内存空间连续,当我们需要申请一个很大的数组时,系统不一定存在这么大的连续内存空间。而链表则更加灵活,不需要内存是连续的,只要剩余内存空间大小够用即可。 + +「链表 Linked List」是一种线性数据结构,其中每个元素都是单独的对象,各个元素(一般称为结点)之间通过指针连接。由于结点中记录了连接关系,因此链表的存储方式相比于数组更加灵活,系统不必保证内存地址的连续性。 + +链表的「结点 Node」包含两项数据,一是结点「值 Value」,二是指向下一结点的「指针 Pointer」(或称「引用 Reference」)。 + + + +Fig. 链表定义与存储方式
+ +=== "Java" + + ```java title="" + /* 链表结点类 */ + class ListNode { + int val; // 结点值 + ListNode next; // 指向下一结点的指针(引用) + ListNode(int x) { val = x; } // 构造函数 + } + ``` + +=== "C++" + + ```cpp title="" + /* 链表结点结构体 */ + struct ListNode { + int val; // 结点值 + ListNode *next; // 指向下一结点的指针(引用) + ListNode(int x) : val(x), next(nullptr) {} // 构造函数 + }; + ``` + +=== "Python" + + ```python title="" + """ 链表结点类 """ + class ListNode: + def __init__(self, x): + self.val = x # 结点值 + self.next = None # 指向下一结点的指针(引用) + ``` + +=== "Go" + + ```go title="" + /* 链表结点结构体 */ + type ListNode struct { + Val int // 结点值 + Next *ListNode // 指向下一结点的指针(引用) + } + + // NewListNode 构造函数,创建一个新的链表 + func NewListNode(val int) *ListNode { + return &ListNode{ + Val: val, + Next: nil, + } + } + ``` + +=== "JavaScript" + + ```js title="" + /* 链表结点结构体 */ + class ListNode { + val; + next; + constructor(val, next) { + this.val = (val === undefined ? 0 : val); // 结点值 + this.next = (next === undefined ? null : next); // 指向下一结点的引用 + } + } + ``` + +=== "TypeScript" + + ```typescript title="" + /* 链表结点结构体 */ + class ListNode { + val: number; + next: ListNode | null; + constructor(val?: number, next?: ListNode | null) { + this.val = val === undefined ? 0 : val; // 结点值 + this.next = next === undefined ? null : next; // 指向下一结点的引用 + } + } + ``` + +=== "C" + + ```c title="" + + ``` + +=== "C#" + + ```csharp title="" + /* 链表结点类 */ + class ListNode + { + int val; // 结点值 + ListNode next; // 指向下一结点的引用 + ListNode(int x) => val = x; //构造函数 + } + ``` + +=== "Swift" + + ```swift title="" + /* 链表结点类 */ + class ListNode { + var val: Int // 结点值 + var next: ListNode? // 指向下一结点的指针(引用) + + init(x: Int) { // 构造函数 + val = x + } + } + ``` + +=== "Zig" + + ```zig title="" + // 链表结点类 + pub fn ListNode(comptime T: type) type { + return struct { + const Self = @This(); + + val: T = 0, // 结点值 + next: ?*Self = null, // 指向下一结点的指针(引用) + + // 构造函数 + pub fn init(self: *Self, x: i32) void { + self.val = x; + self.next = null; + } + }; + } + ``` + +**尾结点指向什么?** 我们一般将链表的最后一个结点称为「尾结点」,其指向的是「空」,在 Java / C++ / Python 中分别记为 `null` / `nullptr` / `None` 。在不引起歧义下,本书都使用 `null` 来表示空。 + +**链表初始化方法**。建立链表分为两步,第一步是初始化各个结点对象,第二步是构建引用指向关系。完成后,即可以从链表的首个结点(即头结点)出发,访问其余所有的结点。 + +!!! tip + + 我们通常将头结点当作链表的代称,例如头结点 `head` 和链表 `head` 实际上是同义的。 + +=== "Java" + + ```java title="linked_list.java" + /* 初始化链表 1 -> 3 -> 2 -> 5 -> 4 */ + // 初始化各个结点 + ListNode n0 = new ListNode(1); + ListNode n1 = new ListNode(3); + ListNode n2 = new ListNode(2); + ListNode n3 = new ListNode(5); + ListNode n4 = new ListNode(4); + // 构建引用指向 + n0.next = n1; + n1.next = n2; + n2.next = n3; + n3.next = n4; + ``` + +=== "C++" + + ```cpp title="linked_list.cpp" + /* 初始化链表 1 -> 3 -> 2 -> 5 -> 4 */ + // 初始化各个结点 + ListNode* n0 = new ListNode(1); + ListNode* n1 = new ListNode(3); + ListNode* n2 = new ListNode(2); + ListNode* n3 = new ListNode(5); + ListNode* n4 = new ListNode(4); + // 构建引用指向 + n0->next = n1; + n1->next = n2; + n2->next = n3; + n3->next = n4; + ``` + +=== "Python" + + ```python title="linked_list.py" + """ 初始化链表 1 -> 3 -> 2 -> 5 -> 4 """ + # 初始化各个结点 + n0 = ListNode(1) + n1 = ListNode(3) + n2 = ListNode(2) + n3 = ListNode(5) + n4 = ListNode(4) + # 构建引用指向 + n0.next = n1 + n1.next = n2 + n2.next = n3 + n3.next = n4 + ``` + +=== "Go" + + ```go title="linked_list.go" + /* 初始化链表 1 -> 3 -> 2 -> 5 -> 4 */ + // 初始化各个结点 + n0 := NewListNode(1) + n1 := NewListNode(3) + n2 := NewListNode(2) + n3 := NewListNode(5) + n4 := NewListNode(4) + + // 构建引用指向 + n0.Next = n1 + n1.Next = n2 + n2.Next = n3 + n3.Next = n4 + ``` + +=== "JavaScript" + + ```js title="linked_list.js" + /* 初始化链表 1 -> 3 -> 2 -> 5 -> 4 */ + // 初始化各个结点 + const n0 = new ListNode(1); + const n1 = new ListNode(3); + const n2 = new ListNode(2); + const n3 = new ListNode(5); + const n4 = new ListNode(4); + // 构建引用指向 + n0.next = n1; + n1.next = n2; + n2.next = n3; + n3.next = n4; + ``` + +=== "TypeScript" + + ```typescript title="linked_list.ts" + /* 初始化链表 1 -> 3 -> 2 -> 5 -> 4 */ + // 初始化各个结点 + const n0 = new ListNode(1); + const n1 = new ListNode(3); + const n2 = new ListNode(2); + const n3 = new ListNode(5); + const n4 = new ListNode(4); + // 构建引用指向 + n0.next = n1; + n1.next = n2; + n2.next = n3; + n3.next = n4; + ``` + +=== "C" + + ```c title="linked_list.c" + + ``` + +=== "C#" + + ```csharp title="linked_list.cs" + /* 初始化链表 1 -> 3 -> 2 -> 5 -> 4 */ + // 初始化各个结点 + ListNode n0 = new ListNode(1); + ListNode n1 = new ListNode(3); + ListNode n2 = new ListNode(2); + ListNode n3 = new ListNode(5); + ListNode n4 = new ListNode(4); + // 构建引用指向 + n0.next = n1; + n1.next = n2; + n2.next = n3; + n3.next = n4; + ``` + +=== "Swift" + + ```swift title="linked_list.swift" + /* 初始化链表 1 -> 3 -> 2 -> 5 -> 4 */ + // 初始化各个结点 + let n0 = ListNode(x: 1) + let n1 = ListNode(x: 3) + let n2 = ListNode(x: 2) + let n3 = ListNode(x: 5) + let n4 = ListNode(x: 4) + // 构建引用指向 + n0.next = n1 + n1.next = n2 + n2.next = n3 + n3.next = n4 + ``` + +=== "Zig" + + ```zig title="linked_list.zig" + // 初始化链表 + // 初始化各个结点 + var n0 = inc.ListNode(i32){.val = 1}; + var n1 = inc.ListNode(i32){.val = 3}; + var n2 = inc.ListNode(i32){.val = 2}; + var n3 = inc.ListNode(i32){.val = 5}; + var n4 = inc.ListNode(i32){.val = 4}; + // 构建引用指向 + n0.next = &n1; + n1.next = &n2; + n2.next = &n3; + n3.next = &n4; + ``` + +## 4.2.1. 链表优点 + +**在链表中,插入与删除结点的操作效率高**。例如,如果想在链表中间的两个结点 `A` , `B` 之间插入一个新结点 `P` ,我们只需要改变两个结点指针即可,时间复杂度为 $O(1)$ ,相比数组的插入操作高效很多。在链表中删除某个结点也很方便,只需要改变一个结点指针即可。 + + + +Fig. 在链表中插入与删除结点
+ +=== "Java" + + ```java title="linked_list.java" + /* 在链表的结点 n0 之后插入结点 P */ + void insert(ListNode n0, ListNode P) { + ListNode n1 = n0.next; + n0.next = P; + P.next = n1; + } + + /* 删除链表的结点 n0 之后的首个结点 */ + void remove(ListNode n0) { + if (n0.next == null) + return; + // n0 -> P -> n1 + ListNode P = n0.next; + ListNode n1 = P.next; + n0.next = n1; + } + ``` + +=== "C++" + + ```cpp title="linked_list.cpp" + /* 在链表的结点 n0 之后插入结点 P */ + void insert(ListNode* n0, ListNode* P) { + ListNode* n1 = n0->next; + n0->next = P; + P->next = n1; + } + + /* 删除链表的结点 n0 之后的首个结点 */ + void remove(ListNode* n0) { + if (n0->next == nullptr) + return; + // n0 -> P -> n1 + ListNode* P = n0->next; + ListNode* n1 = P->next; + n0->next = n1; + // 释放内存 + delete P; + } + ``` + +=== "Python" + + ```python title="linked_list.py" + """ 在链表的结点 n0 之后插入结点 P """ + def insert(n0, P): + n1 = n0.next + n0.next = P + P.next = n1 + + """ 删除链表的结点 n0 之后的首个结点 """ + def remove(n0): + if not n0.next: + return + # n0 -> P -> n1 + P = n0.next + n1 = P.next + n0.next = n1 + ``` + +=== "Go" + + ```go title="linked_list.go" + /* 在链表的结点 n0 之后插入结点 P */ + func insert(n0 *ListNode, P *ListNode) { + n1 := n0.Next + n0.Next = P + P.Next = n1 + } + + /* 删除链表的结点 n0 之后的首个结点 */ + func removeNode(n0 *ListNode) { + if n0.Next == nil { + return + } + // n0 -> P -> n1 + P := n0.Next + n1 := P.Next + n0.Next = n1 + } + ``` + +=== "JavaScript" + + ```js title="linked_list.js" + /* 在链表的结点 n0 之后插入结点 P */ + function insert(n0, P) { + let n1 = n0.next; + n0.next = P; + P.next = n1; + } + + /* 删除链表的结点 n0 之后的首个结点 */ + function remove(n0) { + if (!n0.next) + return; + // n0 -> P -> n1 + let P = n0.next; + let n1 = P.next; + n0.next = n1; + } + ``` + +=== "TypeScript" + + ```typescript title="linked_list.ts" + /* 在链表的结点 n0 之后插入结点 P */ + function insert(n0: ListNode, P: ListNode): void { + const n1 = n0.next; + n0.next = P; + P.next = n1; + } + + /* 删除链表的结点 n0 之后的首个结点 */ + function remove(n0: ListNode): void { + if (!n0.next) { + return; + } + // n0 -> P -> n1 + const P = n0.next; + const n1 = P.next; + n0.next = n1; + } + ``` + +=== "C" + + ```c title="linked_list.c" + + ``` + +=== "C#" + + ```csharp title="linked_list.cs" + // 在链表的结点 n0 之后插入结点 P + void Insert(ListNode n0, ListNode P) + { + ListNode n1 = n0.next; + n0.next = P; + P.next = n1; + } + + // 删除链表的结点 n0 之后的首个结点 + void Remove(ListNode n0) + { + if (n0.next == null) + return; + // n0 -> P -> n1 + ListNode P = n0.next; + ListNode n1 = P.next; + n0.next = n1; + } + ``` + +=== "Swift" + + ```swift title="linked_list.swift" + /* 在链表的结点 n0 之后插入结点 P */ + func insert(n0: ListNode, P: ListNode) { + let n1 = n0.next + n0.next = P + P.next = n1 + } + + /* 删除链表的结点 n0 之后的首个结点 */ + func remove(n0: ListNode) { + if n0.next == nil { + return + } + // n0 -> P -> n1 + let P = n0.next + let n1 = P?.next + n0.next = n1 + P?.next = nil + } + ``` + +=== "Zig" + + ```zig title="linked_list.zig" + // 在链表的结点 n0 之后插入结点 P + pub fn insert(n0: ?*inc.ListNode(i32), P: ?*inc.ListNode(i32)) void { + var n1 = n0.?.next; + n0.?.next = P; + P.?.next = n1; + } + + // 删除链表的结点 n0 之后的首个结点 + pub fn remove(n0: ?*inc.ListNode(i32)) void { + if (n0.?.next == null) return; + // n0 -> P -> n1 + var P = n0.?.next; + var n1 = P.?.next; + n0.?.next = n1; + } + ``` + +## 4.2.2. 链表缺点 + +**链表访问结点效率低**。上节提到,数组可以在 $O(1)$ 时间下访问任意元素,但链表无法直接访问任意结点。这是因为计算机需要从头结点出发,一个一个地向后遍历到目标结点。例如,倘若想要访问链表索引为 `index` (即第 `index + 1` 个)的结点,那么需要 `index` 次访问操作。 + +=== "Java" + + ```java title="linked_list.java" + /* 访问链表中索引为 index 的结点 */ + ListNode access(ListNode head, int index) { + for (int i = 0; i < index; i++) { + if (head == null) + return null; + head = head.next; + } + return head; + } + ``` + +=== "C++" + + ```cpp title="linked_list.cpp" + /* 访问链表中索引为 index 的结点 */ + ListNode* access(ListNode* head, int index) { + for (int i = 0; i < index; i++) { + if (head == nullptr) + return nullptr; + head = head->next; + } + return head; + } + ``` + +=== "Python" + + ```python title="linked_list.py" + """ 访问链表中索引为 index 的结点 """ + def access(head, index): + for _ in range(index): + if not head: + return None + head = head.next + return head + ``` + +=== "Go" + + ```go title="linked_list.go" + /* 访问链表中索引为 index 的结点 */ + func access(head *ListNode, index int) *ListNode { + for i := 0; i < index; i++ { + if head == nil { + return nil + } + head = head.Next + } + return head + } + ``` + +=== "JavaScript" + + ```js title="linked_list.js" + /* 访问链表中索引为 index 的结点 */ + function access(head, index) { + for (let i = 0; i < index; i++) { + if (!head) + return null; + head = head.next; + } + return head; + } + ``` + +=== "TypeScript" + + ```typescript title="linked_list.ts" + /* 访问链表中索引为 index 的结点 */ + function access(head: ListNode | null, index: number): ListNode | null { + for (let i = 0; i < index; i++) { + if (!head) { + return null; + } + head = head.next; + } + return head; + } + ``` + +=== "C" + + ```c title="linked_list.c" + + ``` + +=== "C#" + + ```csharp title="linked_list.cs" + // 访问链表中索引为 index 的结点 + ListNode Access(ListNode head, int index) + { + for (int i = 0; i < index; i++) + { + if (head == null) + return null; + head = head.next; + } + return head; + } + ``` + +=== "Swift" + + ```swift title="linked_list.swift" + /* 访问链表中索引为 index 的结点 */ + func access(head: ListNode, index: Int) -> ListNode? { + var head: ListNode? = head + for _ in 0 ..< index { + if head == nil { + return nil + } + head = head?.next + } + return head + } + ``` + +=== "Zig" + + ```zig title="linked_list.zig" + // 访问链表中索引为 index 的结点 + pub fn access(node: ?*inc.ListNode(i32), index: i32) ?*inc.ListNode(i32) { + var head = node; + var i: i32 = 0; + while (i < index) : (i += 1) { + head = head.?.next; + if (head == null) return null; + } + return head; + } + ``` + +**链表的内存占用多**。链表以结点为单位,每个结点除了保存值外,还需额外保存指针(引用)。这意味着同样数据量下,链表比数组需要占用更多内存空间。 + +## 4.2.3. 链表常用操作 + +**遍历链表查找**。遍历链表,查找链表内值为 `target` 的结点,输出结点在链表中的索引。 + +=== "Java" + + ```java title="linked_list.java" + /* 在链表中查找值为 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; + } + ``` + +=== "C++" + + ```cpp title="linked_list.cpp" + /* 在链表中查找值为 target 的首个结点 */ + int find(ListNode* head, int target) { + int index = 0; + while (head != nullptr) { + if (head->val == target) + return index; + head = head->next; + index++; + } + return -1; + } + ``` + +=== "Python" + + ```python title="linked_list.py" + """ 在链表中查找值为 target 的首个结点 """ + def find(head, target): + index = 0 + while head: + if head.val == target: + return index + head = head.next + index += 1 + return -1 + ``` + +=== "Go" + + ```go title="linked_list.go" + /* 在链表中查找值为 target 的首个结点 */ + func find(head *ListNode, target int) int { + index := 0 + for head != nil { + if head.Val == target { + return index + } + head = head.Next + index++ + } + return -1 + } + ``` + +=== "JavaScript" + + ```js title="linked_list.js" + /* 在链表中查找值为 target 的首个结点 */ + function find(head, target) { + let index = 0; + while (head !== null) { + if (head.val === target) { + return index; + } + head = head.next; + index += 1; + } + return -1; + } + ``` + +=== "TypeScript" + + ```typescript title="linked_list.ts" + /* 在链表中查找值为 target 的首个结点 */ + function find(head: ListNode | null, target: number): number { + let index = 0; + while (head !== null) { + if (head.val === target) { + return index; + } + head = head.next; + index += 1; + } + return -1; + } + ``` + +=== "C" + + ```c title="linked_list.c" + + ``` + +=== "C#" + + ```csharp title="linked_list.cs" + // 在链表中查找值为 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; + } + ``` + +=== "Swift" + + ```swift title="linked_list.swift" + /* 在链表中查找值为 target 的首个结点 */ + func find(head: ListNode, target: Int) -> Int { + var head: ListNode? = head + var index = 0 + while head != nil { + if head?.val == target { + return index + } + head = head?.next + index += 1 + } + return -1 + } + ``` + +=== "Zig" + + ```zig title="linked_list.zig" + // 在链表中查找值为 target 的首个结点 + pub fn find(node: ?*inc.ListNode(i32), target: i32) i32 { + var head = node; + var index: i32 = 0; + while (head != null) { + if (head.?.val == target) return index; + head = head.?.next; + index += 1; + } + return -1; + } + ``` + +## 4.2.4. 常见链表类型 + +**单向链表**。即上述介绍的普通链表。单向链表的结点有「值」和指向下一结点的「指针(引用)」两项数据。我们将首个结点称为头结点,尾结点指向 `null` 。 + +**环形链表**。如果我们令单向链表的尾结点指向头结点(即首尾相接),则得到一个环形链表。在环形链表中,我们可以将任意结点看作是头结点。 + +**双向链表**。单向链表仅记录了一个方向的指针(引用),在双向链表的结点定义中,同时有指向下一结点(后继结点)和上一结点(前驱结点)的「指针(引用)」。双向链表相对于单向链表更加灵活,即可以朝两个方向遍历链表,但也需要占用更多的内存空间。 + +=== "Java" + + ```java title="" + /* 双向链表结点类 */ + class ListNode { + int val; // 结点值 + ListNode next; // 指向后继结点的指针(引用) + ListNode prev; // 指向前驱结点的指针(引用) + ListNode(int x) { val = x; } // 构造函数 + } + ``` + +=== "C++" + + ```cpp title="" + /* 链表结点结构体 */ + struct ListNode { + int val; // 结点值 + ListNode *next; // 指向后继结点的指针(引用) + ListNode *prev; // 指向前驱结点的指针(引用) + ListNode(int x) : val(x), next(nullptr), prev(nullptr) {} // 构造函数 + }; + ``` + +=== "Python" + + ```python title="" + """ 双向链表结点类 """ + class ListNode: + def __init__(self, x): + self.val = x # 结点值 + self.next = None # 指向后继结点的指针(引用) + self.prev = None # 指向前驱结点的指针(引用) + ``` + +=== "Go" + + ```go title="" + /* 双向链表结点结构体 */ + type DoublyListNode struct { + Val int // 结点值 + Next *DoublyListNode // 指向后继结点的指针(引用) + Prev *DoublyListNode // 指向前驱结点的指针(引用) + } + + // NewDoublyListNode 初始化 + func NewDoublyListNode(val int) *DoublyListNode { + return &DoublyListNode{ + Val: val, + Next: nil, + Prev: nil, + } + } + ``` + +=== "JavaScript" + + ```js title="" + /* 双向链表结点类 */ + class ListNode { + val; + next; + prev; + constructor(val, next) { + this.val = val === undefined ? 0 : val; // 结点值 + this.next = next === undefined ? null : next; // 指向后继结点的指针(引用) + this.prev = prev === undefined ? null : prev; // 指向前驱结点的指针(引用) + } + } + ``` + +=== "TypeScript" + + ```typescript title="" + /* 双向链表结点类 */ + class ListNode { + val: number; + next: ListNode | null; + prev: ListNode | null; + constructor(val?: number, next?: ListNode | null, prev?: ListNode | null) { + this.val = val === undefined ? 0 : val; // 结点值 + this.next = next === undefined ? null : next; // 指向后继结点的指针(引用) + this.prev = prev === undefined ? null : prev; // 指向前驱结点的指针(引用) + } + } + ``` + +=== "C" + + ```c title="" + + ``` + +=== "C#" + + ```csharp title="" + /* 双向链表结点类 */ + class ListNode { + int val; // 结点值 + ListNode next; // 指向后继结点的指针(引用) + ListNode prev; // 指向前驱结点的指针(引用) + ListNode(int x) => val = x; // 构造函数 + } + ``` + +=== "Swift" + + ```swift title="" + /* 双向链表结点类 */ + class ListNode { + var val: Int // 结点值 + var next: ListNode? // 指向后继结点的指针(引用) + var prev: ListNode? // 指向前驱结点的指针(引用) + + init(x: Int) { // 构造函数 + val = x + } + } + ``` + +=== "Zig" + + ```zig title="" + // 双向链表结点类 + pub fn ListNode(comptime T: type) type { + return struct { + const Self = @This(); + + val: T = 0, // 结点值 + next: ?*Self = null, // 指向后继结点的指针(引用) + prev: ?*Self = null, // 指向前驱结点的指针(引用) + + // 构造函数 + pub fn init(self: *Self, x: i32) void { + self.val = x; + self.next = null; + self.prev = null; + } + }; + } + ``` + + + +Fig. 常见链表类型
diff --git a/build/chapter_array_and_linkedlist/list.md b/build/chapter_array_and_linkedlist/list.md new file mode 100755 index 000000000..5887e6f3f --- /dev/null +++ b/build/chapter_array_and_linkedlist/list.md @@ -0,0 +1,1599 @@ +--- +comments: true +--- + +# 4.3. 列表 + +**由于长度不可变,数组的实用性大大降低**。在很多情况下,我们事先并不知道会输入多少数据,这就为数组长度的选择带来了很大困难。长度选小了,需要在添加数据中频繁地扩容数组;长度选大了,又造成内存空间的浪费。 + +为了解决此问题,诞生了一种被称为「列表 List」的数据结构。列表可以被理解为长度可变的数组,因此也常被称为「动态数组 Dynamic Array」。列表基于数组实现,继承了数组的优点,同时还可以在程序运行中实时扩容。在列表中,我们可以自由地添加元素,而不用担心超过容量限制。 + +## 4.3.1. 列表常用操作 + +**初始化列表**。我们通常会使用到“无初始值”和“有初始值”的两种初始化方法。 + +=== "Java" + + ```java title="list.java" + /* 初始化列表 */ + // 无初始值 + ListTable. 数组与链表特点对比
+ +Table. 数组与链表操作时间复杂度
+ +Fig. 算法使用的相关空间
+ +=== "Java" + + ```java title="" + /* 类 */ + class Node { + int val; + Node next; + Node(int x) { val = x; } + } + + /* 函数 */ + int function() { + // do something... + return 0; + } + + int algorithm(int n) { // 输入数据 + final int a = 0; // 暂存数据(常量) + int b = 0; // 暂存数据(变量) + Node node = new Node(0); // 暂存数据(对象) + int c = function(); // 栈帧空间(调用函数) + return a + b + c; // 输出数据 + } + ``` + +=== "C++" + + ```cpp title="" + /* 结构体 */ + struct Node { + int val; + Node *next; + Node(int x) : val(x), next(nullptr) {} + }; + + /* 函数 */ + int func() { + // do something... + return 0; + } + + int algorithm(int n) { // 输入数据 + const int a = 0; // 暂存数据(常量) + int b = 0; // 暂存数据(变量) + Node* node = new Node(0); // 暂存数据(对象) + int c = func(); // 栈帧空间(调用函数) + return a + b + c; // 输出数据 + } + ``` + +=== "Python" + + ```python title="" + """ 类 """ + class Node: + def __init__(self, x): + self.val = x # 结点值 + self.next = None # 指向下一结点的指针(引用) + + """ 函数 """ + def function(): + # do something... + return 0 + + def algorithm(n): # 输入数据 + b = 0 # 暂存数据(变量) + node = Node(0) # 暂存数据(对象) + c = function() # 栈帧空间(调用函数) + return a + b + c # 输出数据 + ``` + +=== "Go" + + ```go title="" + /* 结构体 */ + type node struct { + val int + next *node + } + + /* 创建 node 结构体 */ + func newNode(val int) *node { + return &node{val: val} + } + + /* 函数 */ + func function() int { + // do something... + return 0 + } + + func algorithm(n int) int { // 输入数据 + const a = 0 // 暂存数据(常量) + b := 0 // 暂存数据(变量) + newNode(0) // 暂存数据(对象) + c := function() // 栈帧空间(调用函数) + return a + b + c // 输出数据 + } + ``` + +=== "JavaScript" + + ```js title="" + /* 类 */ + class Node { + val; + next; + constructor(val) { + this.val = val === undefined ? 0 : val; // 结点值 + this.next = null; // 指向下一结点的引用 + } + } + + /* 函数 */ + function constFunc() { + // do something + return 0; + } + + function algorithm(n) { // 输入数据 + const a = 0; // 暂存数据(常量) + const b = 0; // 暂存数据(变量) + const node = new Node(0); // 暂存数据(对象) + const c = constFunc(); // 栈帧空间(调用函数) + return a + b + c; // 输出数据 + } + ``` + +=== "TypeScript" + + ```typescript title="" + /* 类 */ + class Node { + val: number; + next: Node | null; + constructor(val?: number) { + this.val = val === undefined ? 0 : val; // 结点值 + this.next = null; // 指向下一结点的引用 + } + } + + /* 函数 */ + function constFunc(): number { + // do something + return 0; + } + + function algorithm(n: number): number { // 输入数据 + const a = 0; // 暂存数据(常量) + const b = 0; // 暂存数据(变量) + const node = new Node(0); // 暂存数据(对象) + const c = constFunc(); // 栈帧空间(调用函数) + return a + b + c; // 输出数据 + } + ``` + +=== "C" + + ```c title="" + + ``` + +=== "C#" + + ```csharp title="" + /* 类 */ + class Node + { + int val; + Node next; + Node(int x) { val = x; } + } + + /* 函数 */ + int function() + { + // do something... + return 0; + } + + int algorithm(int n) // 输入数据 + { + int a = 0; // 暂存数据(常量) + int b = 0; // 暂存数据(变量) + Node node = new Node(0); // 暂存数据(对象) + int c = function(); // 栈帧空间(调用函数) + return a + b + c; // 输出数据 + } + ``` + +=== "Swift" + + ```swift title="" + /* 类 */ + class Node { + var val: Int + var next: Node? + + init(x: Int) { + val = x + } + } + + /* 函数 */ + func function() -> Int { + // do something... + return 0 + } + + func algorithm(n: Int) -> Int { // 输入数据 + let a = 0 // 暂存数据(常量) + var b = 0 // 暂存数据(变量) + let node = Node(x: 0) // 暂存数据(对象) + let c = function() // 栈帧空间(调用函数) + return a + b + c // 输出数据 + } + ``` + +=== "Zig" + + ```zig title="" + + ``` + +## 2.3.2. 推算方法 + +空间复杂度的推算方法和时间复杂度总体类似,只是从统计“计算操作数量”变为统计“使用空间大小”。与时间复杂度不同的是,**我们一般只关注「最差空间复杂度」**。这是因为内存空间是一个硬性要求,我们必须保证在所有输入数据下都有足够的内存空间预留。 + +**最差空间复杂度中的“最差”有两层含义**,分别为输入数据的最差分布、算法运行中的最差时间点。 + +- **以最差输入数据为准**。当 $n < 10$ 时,空间复杂度为 $O(1)$ ;但是当 $n > 10$ 时,初始化的数组 `nums` 使用 $O(n)$ 空间;因此最差空间复杂度为 $O(n)$ ; +- **以算法运行过程中的峰值内存为准**。程序在执行最后一行之前,使用 $O(1)$ 空间;当初始化数组 `nums` 时,程序使用 $O(n)$ 空间;因此最差空间复杂度为 $O(n)$ ; + +=== "Java" + + ```java title="" + void algorithm(int n) { + int a = 0; // O(1) + int[] b = new int[10000]; // O(1) + if (n > 10) + int[] nums = new int[n]; // O(n) + } + ``` + +=== "C++" + + ```cpp title="" + void algorithm(int n) { + int a = 0; // O(1) + vectorFig. 空间复杂度的常见类型
+ +!!! tip + + 部分示例代码需要一些前置知识,包括数组、链表、二叉树、递归算法等。如果遇到看不懂的地方无需担心,可以在学习完后面章节后再来复习,现阶段先聚焦在理解空间复杂度含义和推算方法上。 + +### 常数阶 $O(1)$ + +常数阶常见于数量与输入数据大小 $n$ 无关的常量、变量、对象。 + +需要注意的是,在循环中初始化变量或调用函数而占用的内存,在进入下一循环后就会被释放,即不会累积占用空间,空间复杂度仍为 $O(1)$ 。 + +=== "Java" + + ```java title="space_complexity.java" + /* 常数阶 */ + void constant(int n) { + // 常量、变量、对象占用 O(1) 空间 + final int a = 0; + int b = 0; + int[] nums = new int[10000]; + ListNode node = new ListNode(0); + // 循环中的变量占用 O(1) 空间 + for (int i = 0; i < n; i++) { + int c = 0; + } + // 循环中的函数占用 O(1) 空间 + for (int i = 0; i < n; i++) { + function(); + } + } + ``` + +=== "C++" + + ```cpp title="space_complexity.cpp" + /* 常数阶 */ + void constant(int n) { + // 常量、变量、对象占用 O(1) 空间 + const int a = 0; + int b = 0; + vectorFig. 递归函数产生的线性阶空间复杂度
+ +### 平方阶 $O(n^2)$ + +平方阶常见于元素数量与 $n$ 成平方关系的矩阵、图。 + +=== "Java" + + ```java title="space_complexity.java" + /* 平方阶 */ + void quadratic(int n) { + // 矩阵占用 O(n^2) 空间 + int [][]numMatrix = new int[n][n]; + // 二维列表占用 O(n^2) 空间 + ListFig. 递归函数产生的平方阶空间复杂度
+ +### 指数阶 $O(2^n)$ + +指数阶常见于二叉树。高度为 $n$ 的「满二叉树」的结点数量为 $2^n - 1$ ,使用 $O(2^n)$ 空间。 + +=== "Java" + + ```java title="space_complexity.java" + /* 指数阶(建立满二叉树) */ + TreeNode buildTree(int n) { + if (n == 0) return null; + TreeNode root = new TreeNode(0); + root.left = buildTree(n - 1); + root.right = buildTree(n - 1); + return root; + } + ``` + +=== "C++" + + ```cpp title="space_complexity.cpp" + /* 指数阶(建立满二叉树) */ + TreeNode* buildTree(int n) { + if (n == 0) return nullptr; + TreeNode* root = new TreeNode(0); + root->left = buildTree(n - 1); + root->right = buildTree(n - 1); + return root; + } + ``` + +=== "Python" + + ```python title="space_complexity.py" + """ 指数阶(建立满二叉树) """ + def build_tree(n): + if n == 0: return None + root = TreeNode(0) + root.left = build_tree(n - 1) + root.right = build_tree(n - 1) + return root + ``` + +=== "Go" + + ```go title="space_complexity.go" + /* 指数阶(建立满二叉树) */ + func buildTree(n int) *treeNode { + if n == 0 { + return nil + } + root := newTreeNode(0) + root.left = buildTree(n - 1) + root.right = buildTree(n - 1) + return root + } + ``` + +=== "JavaScript" + + ```js title="space_complexity.js" + /* 指数阶(建立满二叉树) */ + function buildTree(n) { + if (n === 0) return null; + const root = new TreeNode(0); + root.left = buildTree(n - 1); + root.right = buildTree(n - 1); + return root; + } + ``` + +=== "TypeScript" + + ```typescript title="space_complexity.ts" + /* 指数阶(建立满二叉树) */ + function buildTree(n: number): TreeNode | null { + if (n === 0) return null; + const root = new TreeNode(0); + root.left = buildTree(n - 1); + root.right = buildTree(n - 1); + return root; + } + ``` + +=== "C" + + ```c title="space_complexity.c" + + ``` + +=== "C#" + + ```csharp title="space_complexity.cs" + /* 指数阶(建立满二叉树) */ + TreeNode? buildTree(int n) + { + if (n == 0) return null; + TreeNode root = new TreeNode(0); + root.left = buildTree(n - 1); + root.right = buildTree(n - 1); + return root; + } + ``` + +=== "Swift" + + ```swift title="space_complexity.swift" + /* 指数阶(建立满二叉树) */ + func buildTree(n: Int) -> TreeNode? { + if n == 0 { + return nil + } + let root = TreeNode(x: 0) + root.left = buildTree(n: n - 1) + root.right = buildTree(n: n - 1) + return root + } + ``` + +=== "Zig" + + ```zig title="space_complexity.zig" + // 指数阶(建立满二叉树) + fn buildTree(mem_allocator: std.mem.Allocator, n: i32) !?*inc.TreeNode(i32) { + if (n == 0) return null; + const root = try mem_allocator.create(inc.TreeNode(i32)); + root.init(0); + root.left = try buildTree(mem_allocator, n - 1); + root.right = try buildTree(mem_allocator, n - 1); + return root; + } + ``` + + + +Fig. 满二叉树下的指数阶空间复杂度
+ +### 对数阶 $O(\log n)$ + +对数阶常见于分治算法、数据类型转换等。 + +例如「归并排序」,长度为 $n$ 的数组可以形成高度为 $\log n$ 的递归树,因此空间复杂度为 $O(\log n)$ 。 + +再例如「数字转化为字符串」,输入任意正整数 $n$ ,它的位数为 $\log_{10} n$ ,即对应字符串长度为 $\log_{10} n$ ,因此空间复杂度为 $O(\log_{10} n) = O(\log n)$ 。 diff --git a/build/chapter_computational_complexity/space_time_tradeoff.md b/build/chapter_computational_complexity/space_time_tradeoff.md new file mode 100755 index 000000000..07eea2e1a --- /dev/null +++ b/build/chapter_computational_complexity/space_time_tradeoff.md @@ -0,0 +1,386 @@ +--- +comments: true +--- + +# 2.4. 权衡时间与空间 + +理想情况下,我们希望算法的时间复杂度和空间复杂度都能够达到最优,而实际上,同时优化时间复杂度和空间复杂度是非常困难的。 + +**降低时间复杂度,往往是以提升空间复杂度为代价的,反之亦然**。我们把牺牲内存空间来提升算法运行速度的思路称为「以空间换时间」;反之,称之为「以时间换空间」。选择哪种思路取决于我们更看重哪个方面。 + +大多数情况下,时间都是比空间更宝贵的,只要空间复杂度不要太离谱、能接受就行,**因此以空间换时间最为常用**。 + +## 2.4.1. 示例题目 * + +以 LeetCode 全站第一题 [两数之和](https://leetcode.cn/problems/two-sum/) 为例。 + +!!! question "两数之和" + + 给定一个整数数组 `nums` 和一个整数目标值 `target` ,请你在该数组中找出“和”为目标值 `target` 的那两个整数,并返回它们的数组下标。 + + 你可以假设每种输入只会对应一个答案。但是,数组中同一个元素在答案里不能重复出现。 + + 你可以按任意顺序返回答案。 + +「暴力枚举」和「辅助哈希表」分别为 **空间最优** 和 **时间最优** 的两种解法。本着时间比空间更宝贵的原则,后者是本题的最佳解法。 + +### 方法一:暴力枚举 + +时间复杂度 $O(N^2)$ ,空间复杂度 $O(1)$ ,属于「时间换空间」。 + +虽然仅使用常数大小的额外空间,但运行速度过慢。 + +=== "Java" + + ```java title="leetcode_two_sum.java" + class SolutionBruteForce { + public int[] twoSum(int[] nums, int target) { + int size = nums.length; + // 两层循环,时间复杂度 O(n^2) + for (int i = 0; i < size - 1; i++) { + for (int j = i + 1; j < size; j++) { + if (nums[i] + nums[j] == target) + return new int[] { i, j }; + } + } + return new int[0]; + } + } + ``` + +=== "C++" + + ```cpp title="leetcode_two_sum.cpp" + class SolutionBruteForce { + public: + vectorFig. 算法 A, B, C 的时间增长趋势
+ +相比直接统计算法运行时间,时间复杂度分析的做法有什么好处呢?以及有什么不足? + +**时间复杂度可以有效评估算法效率**。算法 `B` 运行时间的增长是线性的,在 $n > 1$ 时慢于算法 `A` ,在 $n > 1000000$ 时慢于算法 `C` 。实质上,只要输入数据大小 $n$ 足够大,复杂度为「常数阶」的算法一定优于「线性阶」的算法,这也正是时间增长趋势的含义。 + +**时间复杂度的推算方法更加简便**。在时间复杂度分析中,我们可以将统计「计算操作的运行时间」简化为统计「计算操作的数量」,这是因为,无论是运行平台还是计算操作类型,都与算法运行时间的增长趋势无关。因而,我们可以简单地将所有计算操作的执行时间统一看作是相同的“单位时间”,这样的简化做法大大降低了估算难度。 + +**时间复杂度也存在一定的局限性**。比如,虽然算法 `A` 和 `C` 的时间复杂度相同,但是实际的运行时间有非常大的差别。再比如,虽然算法 `B` 比 `C` 的时间复杂度要更高,但在输入数据大小 $n$ 比较小时,算法 `B` 是要明显优于算法 `C` 的。对于以上情况,我们很难仅凭时间复杂度来判定算法效率高低。然而,即使存在这些问题,计算复杂度仍然是评判算法效率的最有效且常用的方法。 + +## 2.2.3. 函数渐近上界 + +设算法「计算操作数量」为 $T(n)$ ,其是一个关于输入数据大小 $n$ 的函数。例如,以下算法的操作数量为 + +$$ +T(n) = 3 + 2n +$$ + +=== "Java" + + ```java title="" + void algorithm(int n) { + int a = 1; // +1 + a = a + 1; // +1 + a = a * 2; // +1 + // 循环 n 次 + for (int i = 0; i < n; i++) { // +1(每轮都执行 i ++) + System.out.println(0); // +1 + } + } + ``` + +=== "C++" + + ```cpp title="" + void algorithm(int n) { + int a = 1; // +1 + a = a + 1; // +1 + a = a * 2; // +1 + // 循环 n 次 + for (int i = 0; i < n; i++) { // +1(每轮都执行 i ++) + cout << 0 << endl; // +1 + } + } + ``` + +=== "Python" + + ```python title="" + def algorithm(n): + a = 1 # +1 + a = a + 1 # +1 + a = a * 2 # +1 + # 循环 n 次 + for i in range(n): # +1 + print(0) # +1 + ``` + +=== "Go" + + ```go title="" + func algorithm(n int) { + a := 1 // +1 + a = a + 1 // +1 + a = a * 2 // +1 + // 循环 n 次 + for i := 0; i < n; i++ { // +1 + fmt.Println(a) // +1 + } + } + ``` + +=== "JavaScript" + + ```js title="" + function algorithm(n){ + var a = 1; // +1 + a += 1; // +1 + a *= 2; // +1 + // 循环 n 次 + for(let i = 0; i < n; i++){ // +1(每轮都执行 i ++) + console.log(0); // +1 + } + + } + ``` + +=== "TypeScript" + + ```typescript title="" + function algorithm(n: number): void{ + var a: number = 1; // +1 + a += 1; // +1 + a *= 2; // +1 + // 循环 n 次 + for(let i = 0; i < n; i++){ // +1(每轮都执行 i ++) + console.log(0); // +1 + } + + } + ``` + +=== "C" + + ```c title="" + void algorithm(int n) { + int a = 1; // +1 + a = a + 1; // +1 + a = a * 2; // +1 + // 循环 n 次 + for (int i = 0; i < n; i++) { // +1(每轮都执行 i ++) + printf("%d", 0); // +1 + } + } + ``` + +=== "C#" + + ```csharp title="" + void algorithm(int n) { + int a = 1; // +1 + a = a + 1; // +1 + a = a * 2; // +1 + // 循环 n 次 + for (int i = 0; i < n; i++) { // +1(每轮都执行 i ++) + Console.WriteLine(0); // +1 + } + } + ``` + +=== "Swift" + + ```swift title="" + func algorithm(n: Int) { + var a = 1 // +1 + a = a + 1 // +1 + a = a * 2 // +1 + // 循环 n 次 + for _ in 0 ..< n { // +1 + print(0) // +1 + } + } + ``` + +=== "Zig" + + ```zig title="" + + ``` + +$T(n)$ 是个一次函数,说明时间增长趋势是线性的,因此易得时间复杂度是线性阶。 + +我们将线性阶的时间复杂度记为 $O(n)$ ,这个数学符号被称为「大 $O$ 记号 Big-$O$ Notation」,代表函数 $T(n)$ 的「渐近上界 asymptotic upper bound」。 + +我们要推算时间复杂度,本质上是在计算「操作数量函数 $T(n)$ 」的渐近上界。下面我们先来看看函数渐近上界的数学定义。 + +!!! abstract "函数渐近上界" + + 若存在正实数 $c$ 和实数 $n_0$ ,使得对于所有的 $n > n_0$ ,均有 + $$ + T(n) \leq c \cdot f(n) + $$ + 则可认为 $f(n)$ 给出了 $T(n)$ 的一个渐近上界,记为 + $$ + T(n) = O(f(n)) + $$ + + + +Fig. 函数的渐近上界
+ +本质上看,计算渐近上界就是在找一个函数 $f(n)$ ,**使得在 $n$ 趋向于无穷大时,$T(n)$ 和 $f(n)$ 处于相同的增长级别(仅相差一个常数项 $c$ 的倍数)**。 + +!!! tip + + 渐近上界的数学味儿有点重,如果你感觉没有完全理解,无需担心,因为在实际使用中我们只需要会推算即可,数学意义可以慢慢领悟。 + +## 2.2.4. 推算方法 + +推算出 $f(n)$ 后,我们就得到时间复杂度 $O(f(n))$ 。那么,如何来确定渐近上界 $f(n)$ 呢?总体分为两步,首先「统计操作数量」,然后「判断渐近上界」。 + +### 1) 统计操作数量 + +对着代码,从上到下一行一行地计数即可。然而,**由于上述 $c \cdot f(n)$ 中的常数项 $c$ 可以取任意大小,因此操作数量 $T(n)$ 中的各种系数、常数项都可以被忽略**。根据此原则,可以总结出以下计数偷懒技巧: + +1. **跳过数量与 $n$ 无关的操作**。因为他们都是 $T(n)$ 中的常数项,对时间复杂度不产生影响。 +2. **省略所有系数**。例如,循环 $2n$ 次、$5n + 1$ 次、……,都可以化简记为 $n$ 次,因为 $n$ 前面的系数对时间复杂度也不产生影响。 +3. **循环嵌套时使用乘法**。总操作数量等于外层循环和内层循环操作数量之积,每一层循环依然可以分别套用上述 `1.` 和 `2.` 技巧。 + +根据以下示例,使用上述技巧前、后的统计结果分别为 + +$$ +\begin{aligned} +T(n) & = 2n(n + 1) + (5n + 1) + 2 & \text{完整统计 (-.-|||)} \newline +& = 2n^2 + 7n + 3 \newline +T(n) & = n^2 + n & \text{偷懒统计 (o.O)} +\end{aligned} +$$ + +最终,两者都能推出相同的时间复杂度结果,即 $O(n^2)$ 。 + +=== "Java" + + ```java title="" + void algorithm(int n) { + int a = 1; // +0(技巧 1) + a = a + n; // +0(技巧 1) + // +n(技巧 2) + for (int i = 0; i < 5 * n + 1; i++) { + System.out.println(0); + } + // +n*n(技巧 3) + for (int i = 0; i < 2 * n; i++) { + for (int j = 0; j < n + 1; j++) { + System.out.println(0); + } + } + } + ``` + +=== "C++" + + ```cpp title="" + void algorithm(int n) { + int a = 1; // +0(技巧 1) + a = a + n; // +0(技巧 1) + // +n(技巧 2) + for (int i = 0; i < 5 * n + 1; i++) { + cout << 0 << endl; + } + // +n*n(技巧 3) + for (int i = 0; i < 2 * n; i++) { + for (int j = 0; j < n + 1; j++) { + cout << 0 << endl; + } + } + } + ``` + +=== "Python" + + ```python title="" + def algorithm(n): + a = 1 # +0(技巧 1) + a = a + n # +0(技巧 1) + # +n(技巧 2) + for i in range(5 * n + 1): + print(0) + # +n*n(技巧 3) + for i in range(2 * n): + for j in range(n + 1): + print(0) + ``` + +=== "Go" + + ```go title="" + func algorithm(n int) { + a := 1 // +0(技巧 1) + a = a + n // +0(技巧 1) + // +n(技巧 2) + for i := 0; i < 5 * n + 1; i++ { + fmt.Println(0) + } + // +n*n(技巧 3) + for i := 0; i < 2 * n; i++ { + for j := 0; j < n + 1; j++ { + fmt.Println(0) + } + } + } + ``` + +=== "JavaScript" + + ```js title="" + function algorithm(n) { + let a = 1; // +0(技巧 1) + a = a + n; // +0(技巧 1) + // +n(技巧 2) + for (let i = 0; i < 5 * n + 1; i++) { + console.log(0); + } + // +n*n(技巧 3) + for (let i = 0; i < 2 * n; i++) { + for (let j = 0; j < n + 1; j++) { + console.log(0); + } + } + } + ``` + +=== "TypeScript" + + ```typescript title="" + function algorithm(n: number): void { + let a = 1; // +0(技巧 1) + a = a + n; // +0(技巧 1) + // +n(技巧 2) + for (let i = 0; i < 5 * n + 1; i++) { + console.log(0); + } + // +n*n(技巧 3) + for (let i = 0; i < 2 * n; i++) { + for (let j = 0; j < n + 1; j++) { + console.log(0); + } + } + } + ``` + +=== "C" + + ```c title="" + void algorithm(int n) { + int a = 1; // +0(技巧 1) + a = a + n; // +0(技巧 1) + // +n(技巧 2) + for (int i = 0; i < 5 * n + 1; i++) { + printf("%d", 0); + } + // +n*n(技巧 3) + for (int i = 0; i < 2 * n; i++) { + for (int j = 0; j < n + 1; j++) { + printf("%d", 0); + } + } + } + ``` + +=== "C#" + + ```csharp title="" + void algorithm(int n) + { + int a = 1; // +0(技巧 1) + a = a + n; // +0(技巧 1) + // +n(技巧 2) + for (int i = 0; i < 5 * n + 1; i++) + { + Console.WriteLine(0); + } + // +n*n(技巧 3) + for (int i = 0; i < 2 * n; i++) + { + for (int j = 0; j < n + 1; j++) + { + Console.WriteLine(0); + } + } + } + ``` + +=== "Swift" + + ```swift title="" + func algorithm(n: Int) { + var a = 1 // +0(技巧 1) + a = a + n // +0(技巧 1) + // +n(技巧 2) + for _ in 0 ..< (5 * n + 1) { + print(0) + } + // +n*n(技巧 3) + for _ in 0 ..< (2 * n) { + for _ in 0 ..< (n + 1) { + print(0) + } + } + } + ``` + +=== "Zig" + + ```zig title="" + + ``` + +### 2) 判断渐近上界 + +**时间复杂度由多项式 $T(n)$ 中最高阶的项来决定**。这是因为在 $n$ 趋于无穷大时,最高阶的项将处于主导作用,其它项的影响都可以被忽略。 + +以下表格给出了一些例子,其中有一些夸张的值,是想要向大家强调 **系数无法撼动阶数** 这一结论。在 $n$ 趋于无穷大时,这些常数都是“浮云”。 + +Fig. 时间复杂度的常见类型
+ +!!! tip + + 部分示例代码需要一些前置知识,包括数组、递归算法等。如果遇到看不懂的地方无需担心,可以在学习完后面章节后再来复习,现阶段先聚焦在理解时间复杂度含义和推算方法上。 + +### 常数阶 $O(1)$ + +常数阶的操作数量与输入数据大小 $n$ 无关,即不随着 $n$ 的变化而变化。 + +对于以下算法,无论操作数量 `size` 有多大,只要与数据大小 $n$ 无关,时间复杂度就仍为 $O(1)$ 。 + +=== "Java" + + ```java title="time_complexity.java" + /* 常数阶 */ + int constant(int n) { + int count = 0; + int size = 100000; + for (int i = 0; i < size; i++) + count++; + return count; + } + ``` + +=== "C++" + + ```cpp title="time_complexity.cpp" + /* 常数阶 */ + int constant(int n) { + int count = 0; + int size = 100000; + for (int i = 0; i < size; i++) + count++; + return count; + } + ``` + +=== "Python" + + ```python title="time_complexity.py" + """ 常数阶 """ + def constant(n): + count = 0 + size = 100000 + for _ in range(size): + count += 1 + return count + ``` + +=== "Go" + + ```go title="time_complexity.go" + /* 常数阶 */ + func constant(n int) int { + count := 0 + size := 100000 + for i := 0; i < size; i++ { + count ++ + } + return count + } + ``` + +=== "JavaScript" + + ```js title="time_complexity.js" + /* 常数阶 */ + function constant(n) { + let count = 0; + const size = 100000; + for (let i = 0; i < size; i++) count++; + return count; + } + ``` + +=== "TypeScript" + + ```typescript title="time_complexity.ts" + /* 常数阶 */ + function constant(n: number): number { + let count = 0; + const size = 100000; + for (let i = 0; i < size; i++) count++; + return count; + } + ``` + +=== "C" + + ```c title="time_complexity.c" + /* 常数阶 */ + int constant(int n) { + int count = 0; + int size = 100000; + int i = 0; + for (int i = 0; i < size; i++) { + count ++; + } + return count; + } + ``` + +=== "C#" + + ```csharp title="time_complexity.cs" + /* 常数阶 */ + int constant(int n) + { + int count = 0; + int size = 100000; + for (int i = 0; i < size; i++) + count++; + return count; + } + ``` + +=== "Swift" + + ```swift title="time_complexity.swift" + /* 常数阶 */ + func constant(n: Int) -> Int { + var count = 0 + let size = 100000 + for _ in 0 ..< size { + count += 1 + } + return count + } + ``` + +=== "Zig" + + ```zig title="time_complexity.zig" + // 常数阶 + fn constant(n: i32) i32 { + _ = n; + var count: i32 = 0; + const size: i32 = 100_000; + var i: i32 = 0; + while(iFig. 常数阶、线性阶、平方阶的时间复杂度
+ +以「冒泡排序」为例,外层循环 $n - 1$ 次,内层循环 $n-1, n-2, \cdots, 2, 1$ 次,平均为 $\frac{n}{2}$ 次,因此时间复杂度为 $O(n^2)$ 。 + +$$ +O((n - 1) \frac{n}{2}) = O(n^2) +$$ + +=== "Java" + + ```java title="time_complexity.java" + /* 平方阶(冒泡排序) */ + int bubbleSort(int[] nums) { + int count = 0; // 计数器 + // 外循环:待排序元素数量为 n-1, n-2, ..., 1 + for (int i = nums.length - 1; 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; + count += 3; // 元素交换包含 3 个单元操作 + } + } + } + return count; + } + ``` + +=== "C++" + + ```cpp title="time_complexity.cpp" + /* 平方阶(冒泡排序) */ + int bubbleSort(vectorFig. 指数阶的时间复杂度
+ +在实际算法中,指数阶常出现于递归函数。例如以下代码,不断地一分为二,分裂 $n$ 次后停止。 + +=== "Java" + + ```java title="time_complexity.java" + /* 指数阶(递归实现) */ + int expRecur(int n) { + if (n == 1) return 1; + return expRecur(n - 1) + expRecur(n - 1) + 1; + } + ``` + +=== "C++" + + ```cpp title="time_complexity.cpp" + /* 指数阶(递归实现) */ + int expRecur(int n) { + if (n == 1) return 1; + return expRecur(n - 1) + expRecur(n - 1) + 1; + } + ``` + +=== "Python" + + ```python title="time_complexity.py" + """ 指数阶(递归实现)""" + def exp_recur(n): + if n == 1: return 1 + return exp_recur(n - 1) + exp_recur(n - 1) + 1 + ``` + +=== "Go" + + ```go title="time_complexity.go" + /* 指数阶(递归实现)*/ + func expRecur(n int) int { + if n == 1 { + return 1 + } + return expRecur(n-1) + expRecur(n-1) + 1 + } + ``` + +=== "JavaScript" + + ```js title="time_complexity.js" + /* 指数阶(递归实现) */ + function expRecur(n) { + if (n == 1) return 1; + return expRecur(n - 1) + expRecur(n - 1) + 1; + } + ``` + +=== "TypeScript" + + ```typescript title="time_complexity.ts" + /* 指数阶(递归实现) */ + function expRecur(n: number): number { + if (n == 1) return 1; + return expRecur(n - 1) + expRecur(n - 1) + 1; + } + + ``` + +=== "C" + + ```c title="time_complexity.c" + /* 指数阶(递归实现) */ + int expRecur(int n) { + if (n == 1) return 1; + return expRecur(n - 1) + expRecur(n - 1) + 1; + } + ``` + +=== "C#" + + ```csharp title="time_complexity.cs" + /* 指数阶(递归实现) */ + int expRecur(int n) + { + if (n == 1) return 1; + return expRecur(n - 1) + expRecur(n - 1) + 1; + } + ``` + +=== "Swift" + + ```swift title="time_complexity.swift" + /* 指数阶(递归实现) */ + func expRecur(n: Int) -> Int { + if n == 1 { + return 1 + } + return expRecur(n: n - 1) + expRecur(n: n - 1) + 1 + } + ``` + +=== "Zig" + + ```zig title="time_complexity.zig" + // 指数阶(递归实现) + fn expRecur(n: i32) i32{ + if (n == 1) return 1; + return expRecur(n - 1) + expRecur(n - 1) + 1; + } + ``` + +### 对数阶 $O(\log n)$ + +对数阶与指数阶正好相反,后者反映“每轮增加到两倍的情况”,而前者反映“每轮缩减到一半的情况”。对数阶仅次于常数阶,时间增长得很慢,是理想的时间复杂度。 + +对数阶常出现于「二分查找」和「分治算法」中,体现“一分为多”、“化繁为简”的算法思想。 + +设输入数据大小为 $n$ ,由于每轮缩减到一半,因此循环次数是 $\log_2 n$ ,即 $2^n$ 的反函数。 + +=== "Java" + + ```java title="time_complexity.java" + /* 对数阶(循环实现) */ + int logarithmic(float n) { + int count = 0; + while (n > 1) { + n = n / 2; + count++; + } + return count; + } + ``` + +=== "C++" + + ```cpp title="time_complexity.cpp" + /* 对数阶(循环实现) */ + int logarithmic(float n) { + int count = 0; + while (n > 1) { + n = n / 2; + count++; + } + return count; + } + ``` + +=== "Python" + + ```python title="time_complexity.py" + """ 对数阶(循环实现)""" + def logarithmic(n): + count = 0 + while n > 1: + n = n / 2 + count += 1 + return count + ``` + +=== "Go" + + ```go title="time_complexity.go" + /* 对数阶(循环实现)*/ + func logarithmic(n float64) int { + count := 0 + for n > 1 { + n = n / 2 + count++ + } + return count + } + ``` + +=== "JavaScript" + + ```js title="time_complexity.js" + /* 对数阶(循环实现) */ + function logarithmic(n) { + let count = 0; + while (n > 1) { + n = n / 2; + count++; + } + return count; + } + ``` + +=== "TypeScript" + + ```typescript title="time_complexity.ts" + /* 对数阶(循环实现) */ + function logarithmic(n: number): number { + let count = 0; + while (n > 1) { + n = n / 2; + count++; + } + return count; + } + ``` + +=== "C" + + ```c title="time_complexity.c" + /* 对数阶(循环实现) */ + int logarithmic(float n) { + int count = 0; + while (n > 1) { + n = n / 2; + count++; + } + return count; + } + ``` + +=== "C#" + + ```csharp title="time_complexity.cs" + /* 对数阶(循环实现) */ + int logarithmic(float n) + { + int count = 0; + while (n > 1) + { + n = n / 2; + count++; + } + return count; + } + ``` + +=== "Swift" + + ```swift title="time_complexity.swift" + /* 对数阶(循环实现) */ + func logarithmic(n: Int) -> Int { + var count = 0 + var n = n + while n > 1 { + n = n / 2 + count += 1 + } + return count + } + ``` + +=== "Zig" + + ```zig title="time_complexity.zig" + // 对数阶(循环实现) + fn logarithmic(n: f32) i32 + { + var count: i32 = 0; + var n_var = n; + while (n_var > 1) + { + n_var = n_var / 2; + count +=1; + } + return count; + } + ``` + + + +Fig. 对数阶的时间复杂度
+ +与指数阶类似,对数阶也常出现于递归函数。以下代码形成了一个高度为 $\log_2 n$ 的递归树。 + +=== "Java" + + ```java title="time_complexity.java" + /* 对数阶(递归实现) */ + int logRecur(float n) { + if (n <= 1) return 0; + return logRecur(n / 2) + 1; + } + ``` + +=== "C++" + + ```cpp title="time_complexity.cpp" + /* 对数阶(递归实现) */ + int logRecur(float n) { + if (n <= 1) return 0; + return logRecur(n / 2) + 1; + } + ``` + +=== "Python" + + ```python title="time_complexity.py" + """ 对数阶(递归实现)""" + def log_recur(n): + if n <= 1: return 0 + return log_recur(n / 2) + 1 + ``` + +=== "Go" + + ```go title="time_complexity.go" + /* 对数阶(递归实现)*/ + func logRecur(n float64) int { + if n <= 1 { + return 0 + } + return logRecur(n/2) + 1 + } + ``` + +=== "JavaScript" + + ```js title="time_complexity.js" + /* 对数阶(递归实现) */ + function logRecur(n) { + if (n <= 1) return 0; + return logRecur(n / 2) + 1; + } + ``` + +=== "TypeScript" + + ```typescript title="time_complexity.ts" + /* 对数阶(递归实现) */ + function logRecur(n: number): number { + if (n <= 1) return 0; + return logRecur(n / 2) + 1; + } + ``` + +=== "C" + + ```c title="time_complexity.c" + /* 对数阶(递归实现) */ + int logRecur(float n) { + if (n <= 1) return 0; + return logRecur(n / 2) + 1; + } + ``` + +=== "C#" + + ```csharp title="time_complexity.cs" + /* 对数阶(递归实现) */ + int logRecur(float n) + { + if (n <= 1) return 0; + return logRecur(n / 2) + 1; + } + ``` + +=== "Swift" + + ```swift title="time_complexity.swift" + /* 对数阶(递归实现) */ + func logRecur(n: Int) -> Int { + if n <= 1 { + return 0 + } + return logRecur(n: n / 2) + 1 + } + ``` + +=== "Zig" + + ```zig title="time_complexity.zig" + // 对数阶(递归实现) + fn logRecur(n: f32) i32 + { + if (n <= 1) return 0; + return logRecur(n / 2) + 1; + } + ``` + +### 线性对数阶 $O(n \log n)$ + +线性对数阶常出现于嵌套循环中,两层循环的时间复杂度分别为 $O(\log n)$ 和 $O(n)$ 。 + +主流排序算法的时间复杂度都是 $O(n \log n )$ ,例如快速排序、归并排序、堆排序等。 + +=== "Java" + + ```java title="time_complexity.java" + /* 线性对数阶 */ + int linearLogRecur(float n) { + if (n <= 1) return 1; + int count = linearLogRecur(n / 2) + + linearLogRecur(n / 2); + for (int i = 0; i < n; i++) { + count++; + } + return count; + } + ``` + +=== "C++" + + ```cpp title="time_complexity.cpp" + /* 线性对数阶 */ + int linearLogRecur(float n) { + if (n <= 1) return 1; + int count = linearLogRecur(n / 2) + + linearLogRecur(n / 2); + for (int i = 0; i < n; i++) { + count++; + } + return count; + } + ``` + +=== "Python" + + ```python title="time_complexity.py" + """ 线性对数阶 """ + def linear_log_recur(n): + if n <= 1: return 1 + count = linear_log_recur(n // 2) + \ + linear_log_recur(n // 2) + for _ in range(n): + count += 1 + return count + ``` + +=== "Go" + + ```go title="time_complexity.go" + /* 线性对数阶 */ + func linearLogRecur(n float64) int { + if n <= 1 { + return 1 + } + count := linearLogRecur(n/2) + + linearLogRecur(n/2) + for i := 0.0; i < n; i++ { + count++ + } + return count + } + ``` + +=== "JavaScript" + + ```js title="time_complexity.js" + /* 线性对数阶 */ + function linearLogRecur(n) { + if (n <= 1) return 1; + let count = linearLogRecur(n / 2) + linearLogRecur(n / 2); + for (let i = 0; i < n; i++) { + count++; + } + return count; + } + ``` + +=== "TypeScript" + + ```typescript title="time_complexity.ts" + /* 线性对数阶 */ + function linearLogRecur(n: number): number { + if (n <= 1) return 1; + let count = linearLogRecur(n / 2) + linearLogRecur(n / 2); + for (let i = 0; i < n; i++) { + count++; + } + return count; + } + ``` + +=== "C" + + ```c title="time_complexity.c" + /* 线性对数阶 */ + int linearLogRecur(float n) { + if (n <= 1) return 1; + int count = linearLogRecur(n / 2) + + linearLogRecur(n / 2); + for (int i = 0; i < n; i++) { + count ++; + } + return count; + } + ``` + +=== "C#" + + ```csharp title="time_complexity.cs" + /* 线性对数阶 */ + int linearLogRecur(float n) + { + if (n <= 1) return 1; + int count = linearLogRecur(n / 2) + + linearLogRecur(n / 2); + for (int i = 0; i < n; i++) + { + count++; + } + return count; + } + ``` + +=== "Swift" + + ```swift title="time_complexity.swift" + /* 线性对数阶 */ + func linearLogRecur(n: Double) -> Int { + if n <= 1 { + return 1 + } + var count = linearLogRecur(n: n / 2) + linearLogRecur(n: n / 2) + for _ in 0 ..< Int(n) { + count += 1 + } + return count + } + ``` + +=== "Zig" + + ```zig title="time_complexity.zig" + // 线性对数阶 + fn linearLogRecur(n: f32) i32 + { + if (n <= 1) return 1; + var count: i32 = linearLogRecur(n / 2) + + linearLogRecur(n / 2); + var i: f32 = 0; + while (i < n) : (i += 1) { + count += 1; + } + return count; + } + ``` + + + +Fig. 线性对数阶的时间复杂度
+ +### 阶乘阶 $O(n!)$ + +阶乘阶对应数学上的「全排列」。即给定 $n$ 个互不重复的元素,求其所有可能的排列方案,则方案数量为 + +$$ +n! = n \times (n - 1) \times (n - 2) \times \cdots \times 2 \times 1 +$$ + +阶乘常使用递归实现。例如以下代码,第一层分裂出 $n$ 个,第二层分裂出 $n - 1$ 个,…… ,直至到第 $n$ 层时终止分裂。 + +=== "Java" + + ```java title="time_complexity.java" + /* 阶乘阶(递归实现) */ + int factorialRecur(int n) { + if (n == 0) return 1; + int count = 0; + // 从 1 个分裂出 n 个 + for (int i = 0; i < n; i++) { + count += factorialRecur(n - 1); + } + return count; + } + ``` + +=== "C++" + + ```cpp title="time_complexity.cpp" + /* 阶乘阶(递归实现) */ + int factorialRecur(int n) { + if (n == 0) return 1; + int count = 0; + // 从 1 个分裂出 n 个 + for (int i = 0; i < n; i++) { + count += factorialRecur(n - 1); + } + return count; + } + ``` + +=== "Python" + + ```python title="time_complexity.py" + """ 阶乘阶(递归实现)""" + def factorial_recur(n): + if n == 0: return 1 + count = 0 + # 从 1 个分裂出 n 个 + for _ in range(n): + count += factorial_recur(n - 1) + return count + ``` + +=== "Go" + + ```go title="time_complexity.go" + /* 阶乘阶(递归实现) */ + func factorialRecur(n int) int { + if n == 0 { + return 1 + } + count := 0 + // 从 1 个分裂出 n 个 + for i := 0; i < n; i++ { + count += factorialRecur(n - 1) + } + return count + } + ``` + +=== "JavaScript" + + ```js title="time_complexity.js" + /* 阶乘阶(递归实现) */ + function factorialRecur(n) { + if (n == 0) return 1; + let count = 0; + // 从 1 个分裂出 n 个 + for (let i = 0; i < n; i++) { + count += factorialRecur(n - 1); + } + return count; + } + ``` + +=== "TypeScript" + + ```typescript title="time_complexity.ts" + /* 阶乘阶(递归实现) */ + function factorialRecur(n: number): number { + if (n == 0) return 1; + let count = 0; + // 从 1 个分裂出 n 个 + for (let i = 0; i < n; i++) { + count += factorialRecur(n - 1); + } + return count; + } + ``` + +=== "C" + + ```c title="time_complexity.c" + /* 阶乘阶(递归实现) */ + int factorialRecur(int n) { + if (n == 0) return 1; + int count = 0; + for (int i = 0; i < n; i++) { + count += factorialRecur(n - 1); + } + return count; + } + ``` + +=== "C#" + + ```csharp title="time_complexity.cs" + /* 阶乘阶(递归实现) */ + int factorialRecur(int n) + { + if (n == 0) return 1; + int count = 0; + // 从 1 个分裂出 n 个 + for (int i = 0; i < n; i++) + { + count += factorialRecur(n - 1); + } + return count; + } + ``` + +=== "Swift" + + ```swift title="time_complexity.swift" + /* 阶乘阶(递归实现) */ + func factorialRecur(n: Int) -> Int { + if n == 0 { + return 1 + } + var count = 0 + // 从 1 个分裂出 n 个 + for _ in 0 ..< n { + count += factorialRecur(n: n - 1) + } + return count + } + ``` + +=== "Zig" + + ```zig title="time_complexity.zig" + // 阶乘阶(递归实现) + fn factorialRecur(n: i32) i32 { + if (n == 0) return 1; + var count: i32 = 0; + var i: i32 = 0; + // 从 1 个分裂出 n 个 + while (i < n) : (i += 1) { + count += factorialRecur(n - 1); + } + return count; + } + ``` + + + +Fig. 阶乘阶的时间复杂度
+ +## 2.2.6. 最差、最佳、平均时间复杂度 + +**某些算法的时间复杂度不是恒定的,而是与输入数据的分布有关**。举一个例子,输入一个长度为 $n$ 数组 `nums` ,其中 `nums` 由从 $1$ 至 $n$ 的数字组成,但元素顺序是随机打乱的;算法的任务是返回元素 $1$ 的索引。我们可以得出以下结论: + +- 当 `nums = [?, ?, ..., 1]`,即当末尾元素是 $1$ 时,则需完整遍历数组,此时达到 **最差时间复杂度 $O(n)$** ; +- 当 `nums = [1, ?, ?, ...]` ,即当首个数字为 $1$ 时,无论数组多长都不需要继续遍历,此时达到 **最佳时间复杂度 $\Omega(1)$** ; + +「函数渐近上界」使用大 $O$ 记号表示,代表「最差时间复杂度」。与之对应,「函数渐近下界」用 $\Omega$ 记号(Omega Notation)来表示,代表「最佳时间复杂度」。 + +=== "Java" + + ```java title="worst_best_time_complexity.java" + public class worst_best_time_complexity { + /* 生成一个数组,元素为 { 1, 2, ..., n },顺序被打乱 */ + int[] randomNumbers(int n) { + Integer[] nums = new Integer[n]; + // 生成数组 nums = { 1, 2, 3, ..., n } + for (int i = 0; i < n; i++) { + nums[i] = i + 1; + } + // 随机打乱数组元素 + Collections.shuffle(Arrays.asList(nums)); + // Integer[] -> int[] + int[] res = new int[n]; + for (int i = 0; i < n; i++) { + res[i] = nums[i]; + } + return res; + } + + /* 查找数组 nums 中数字 1 所在索引 */ + int findOne(int[] nums) { + for (int i = 0; i < nums.length; i++) { + // 当元素 1 在数组头部时,达到最佳时间复杂度 O(1) + // 当元素 1 在数组尾部时,达到最差时间复杂度 O(n) + if (nums[i] == 1) + return i; + } + return -1; + } + } + ``` + +=== "C++" + + ```cpp title="worst_best_time_complexity.cpp" + /* 生成一个数组,元素为 { 1, 2, ..., n },顺序被打乱 */ + vectorFig. 线性与非线性数据结构
+ +## 3.2.2. 物理结构:连续与离散 + +!!! note + + 若感到阅读困难,建议先看完下个章节「数组与链表」,再回过头来理解物理结构的含义。 + +**「物理结构」反映了数据在计算机内存中的存储方式**。从本质上看,分别是 **数组的连续空间存储** 和 **链表的离散空间存储**。物理结构从底层上决定了数据的访问、更新、增删等操作方法,在时间效率和空间效率方面呈现出此消彼长的特性。 + + + +Fig. 连续空间存储与离散空间存储
+ +**所有数据结构都是基于数组、或链表、或两者组合实现的**。例如栈和队列,既可以使用数组实现、也可以使用链表实现,而例如哈希表,其实现同时包含了数组和链表。 + +- **基于数组可实现**:栈、队列、哈希表、树、堆、图、矩阵、张量(维度 $\geq 3$ 的数组)等; +- **基于链表可实现**:栈、队列、哈希表、树、堆、图等; + +基于数组实现的数据结构也被称为「静态数据结构」,这意味着该数据结构在在被初始化后,长度不可变。相反地,基于链表实现的数据结构被称为「动态数据结构」,该数据结构在被初始化后,我们也可以在程序运行中修改其长度。 + +!!! tip + + 数组与链表是其他所有数据结构的“底层积木”,建议读者一定要多花些时间了解。 diff --git a/build/chapter_data_structure/data_and_memory.md b/build/chapter_data_structure/data_and_memory.md new file mode 100644 index 000000000..c10980616 --- /dev/null +++ b/build/chapter_data_structure/data_and_memory.md @@ -0,0 +1,149 @@ +--- +comments: true +--- + +# 3.1. 数据与内存 + +## 3.1.1. 基本数据类型 + +谈到计算机中的数据,我们能够想到文本、图片、视频、语音、3D 模型等等,这些数据虽然组织形式不同,但是有一个共同点,即都是由各种基本数据类型构成的。 + +**「基本数据类型」是 CPU 可以直接进行运算的类型,在算法中直接被使用。** + +- 「整数」根据不同的长度分为 byte, short, int, long ,根据算法需求选用,即在满足取值范围的情况下尽量减小内存空间占用; +- 「浮点数」代表小数,根据长度分为 float, double ,同样根据算法的实际需求选用; +- 「字符」在计算机中是以字符集的形式保存的,char 的值实际上是数字,代表字符集中的编号,计算机通过字符集查表来完成编号到字符的转换。占用空间与具体编程语言有关,通常为 2 bytes 或 1 byte ; +- 「布尔」代表逻辑中的 “是” 与 “否” ,其占用空间需要具体根据编程语言确定,通常为 1 byte 或 1 bit ; + +!!! note "字节与比特" + + 1 字节 (byte) = 8 比特 (bit) , 1 比特即最基本的 1 个二进制位 + +Table. Java 的基本数据类型
+ +Fig. 内存条、内存空间、内存地址
+ +**内存资源是设计数据结构与算法的重要考虑因素**。内存是所有程序的公共资源,当内存被某程序占用时,不能被其它程序同时使用。我们需要根据剩余内存资源的情况来设计算法。例如,若剩余内存空间有限,则要求算法占用的峰值内存不能超过系统剩余内存;若运行的程序很多、缺少大块连续的内存空间,则要求选取的数据结构必须能够存储在离散的内存空间内。 diff --git a/build/chapter_data_structure/summary.md b/build/chapter_data_structure/summary.md new file mode 100644 index 000000000..5340746a3 --- /dev/null +++ b/build/chapter_data_structure/summary.md @@ -0,0 +1,11 @@ +--- +comments: true +--- + +# 3.3. 小结 + +- 整数 byte, short, int, long 、浮点数 float, double 、字符 char 、布尔 boolean 是计算机中的基本数据类型,占用空间的大小决定了它们的取值范围。 +- 在程序运行时,数据存储在计算机的内存中。内存中每块空间都有独立的内存地址,程序是通过内存地址来访问数据的。 +- 数据结构主要可以从逻辑结构和物理结构两个角度进行分类。逻辑结构反映了数据中元素之间的逻辑关系,物理结构反映了数据在计算机内存中的存储形式。 +- 常见的逻辑结构有线性、树状、网状等。我们一般根据逻辑结构将数据结构分为线性(数组、链表、栈、队列)和非线性(树、图、堆)两种。根据实现方式的不同,哈希表可能是线性或非线性。 +- 物理结构主要有两种,分别是连续空间存储(数组)和离散空间存储(链表),所有的数据结构都是由数组、或链表、或两者组合实现的。 diff --git a/build/chapter_graph/graph.md b/build/chapter_graph/graph.md new file mode 100644 index 000000000..5f8d861e5 --- /dev/null +++ b/build/chapter_graph/graph.md @@ -0,0 +1,87 @@ +--- +comments: true +--- + +# 9.1. 图 + +「图 Graph」是一种非线性数据结构,由「顶点 Vertex」和「边 Edge」组成。我们可将图 $G$ 抽象地表示为一组顶点 $V$ 和一组边 $E$ 的集合。例如,以下表示一个包含 5 个顶点和 7 条边的图 + +$$ +\begin{aligned} +V & = \{ 1, 2, 3, 4, 5 \} \newline +E & = \{ (1,2), (1,3), (1,5), (2,3), (2,4), (2,5), (4,5) \} \newline +G & = \{ V, E \} \newline +\end{aligned} +$$ + + + +那么,图与其他数据结构的关系是什么?如果我们把「顶点」看作结点,把「边」看作连接各个结点的指针,则可将「图」看成一种从「链表」拓展而来的数据结构。**相比线性关系(链表)和分治关系(树),网络关系(图)的自由度更高,也从而更为复杂**。 + +## 9.1.1. 图常见类型 + +根据边是否有方向,分为「无向图 Undirected Graph」和「有向图 Directed Graph」。 + +- 在无向图中,边表示两结点之间“双向”的连接关系,例如微信或 QQ 中的“好友关系”; +- 在有向图中,边是有方向的,即 $A \rightarrow B$ 和 $A \leftarrow B$ 两个方向的边是相互独立的,例如微博或抖音上的“关注”与“被关注”关系; + + + +根据所有顶点是否连通,分为「连通图 Connected Graph」和「非连通图 Disconnected Graph」。 + +- 对于连通图,从某个结点出发,可以到达其余任意结点; +- 对于非连通图,从某个结点出发,至少有一个结点无法到达; + + + +我们可以给边添加“权重”变量,得到「有权图 Weighted Graph」。例如,在王者荣耀等游戏中,系统会根据共同游戏时间来计算玩家之间的“亲密度”,这种亲密度网络就可以使用有权图来表示。 + + + +## 9.1.2. 图常用术语 + +- 「邻接 Adjacency」:当两顶点之间有边相连时,称此两顶点“邻接”。 +- 「路径 Path」:从顶点 A 到顶点 B 走过的边构成的序列,被称为从 A 到 B 的“路径”。 +- 「度 Degree」表示一个顶点具有多少条边。对于有向图,「入度 In-Degree」表示有多少条边指向该顶点,「出度 Out-Degree」表示有多少条边从该顶点指出。 + +## 9.1.3. 图的表示 + +图的常用表示方法有「邻接矩阵」和「邻接表」。以下使用「无向图」来举例。 + +### 邻接矩阵 + +设图的顶点数量为 $n$ ,「邻接矩阵 Adjacency Matrix」使用一个 $n \times n$ 大小的矩阵来表示图,每一行(列)代表一个顶点,矩阵元素代表边,使用 $1$ 或 $0$ 来表示两个顶点之间有边或无边。 + + + +邻接矩阵具有以下性质: + +- 顶点不能与自身相连,因而邻接矩阵主对角线元素没有意义。 +- 「无向图」两个方向的边等价,此时邻接矩阵关于主对角线对称。 +- 将邻接矩阵的元素从 $1$ , $0$ 替换为权重,则能够表示「有权图」。 + +使用邻接矩阵表示图时,我们可以直接通过访问矩阵元素来获取边,因此增删查操作的效率很高,时间复杂度均为 $O(1)$ 。然而,矩阵的空间复杂度为 $O(n^2)$ ,内存占用较大。 + +### 邻接表 + +「邻接表 Adjacency List」使用 $n$ 个链表来表示图,链表结点表示顶点。第 $i$ 条链表对应顶点 $i$ ,其中存储了所有与该顶点相连的顶点。 + + + +邻接表仅存储存在的边,而边的总数往往远小于 $n^2$ ,因此更加节省空间。但是,因为在邻接表中需要通过遍历链表来查找边,所以其时间效率不如邻接矩阵。 + +观察上图发现,**邻接表结构与哈希表「链地址法」非常相似,因此我们也可以用类似方法来优化效率**。比如,当链表较长时,可以把链表转化为「AVL 树」,从而将时间效率从 $O(n)$ 优化至 $O(\log n)$ ,还可以通过中序遍历获取有序序列;还可以将链表转化为 HashSet(即哈希表),将时间复杂度降低至 $O(1)$ ,。 + +## 9.1.4. 图常见应用 + +现实中的许多系统都可以使用图来建模,对应的待求解问题也可以被约化为图计算问题。 + +Fig. 哈希表抽象表示
+ +## 6.1.1. 哈希表效率 + +除了哈希表之外,还可以使用以下数据结构来实现上述查询功能: + +1. **无序数组**:每个元素为 `[学号, 姓名]` ; +2. **有序数组**:将 `1.` 中的数组按照学号从小到大排序; +3. **链表**:每个结点的值为 `[学号, 姓名]` ; +4. **二叉搜索树**:每个结点的值为 `[学号, 姓名]` ,根据学号大小来构建树; + +使用上述方法,各项操作的时间复杂度如下表所示(在此不做赘述,详解可见 [二叉搜索树章节](https://www.hello-algo.com/chapter_tree/binary_search_tree/#_6))。无论是查找元素、还是增删元素,哈希表的时间复杂度都是 $O(1)$ ,全面胜出! + +Fig. 哈希函数
+ +=== "Java" + + ```java title="array_hash_map.java" + /* 键值对 int->String */ + class Entry { + public int key; // 键 + public String val; // 值 + public Entry(int key, String val) { + this.key = key; + this.val = val; + } + } + + /* 基于数组简易实现的哈希表 */ + class ArrayHashMap { + private ListFig. 哈希冲突
+ +综上所述,一个优秀的「哈希函数」应该具备以下特性: + +- 尽量少地发生哈希冲突; +- 时间复杂度 $O(1)$ ,计算尽可能高效; +- 空间使用率高,即“键值对占用空间 / 哈希表总占用空间”尽可能大; diff --git a/build/chapter_hashing/summary.md b/build/chapter_hashing/summary.md new file mode 100644 index 000000000..a66b041a3 --- /dev/null +++ b/build/chapter_hashing/summary.md @@ -0,0 +1,5 @@ +--- +comments: true +--- + +# 6.3. 小结 diff --git a/build/chapter_heap/heap.md b/build/chapter_heap/heap.md new file mode 100644 index 000000000..79c1ded80 --- /dev/null +++ b/build/chapter_heap/heap.md @@ -0,0 +1,1040 @@ +--- +comments: true +--- + +# 8.1. 堆 + +「堆 Heap」是一棵限定条件下的「完全二叉树」。根据成立条件,堆主要分为两种类型: + +- 「大顶堆 Max Heap」,任意结点的值 $\geq$ 其子结点的值; +- 「小顶堆 Min Heap」,任意结点的值 $\leq$ 其子结点的值; + + + +## 8.1.1. 堆术语与性质 + +- 由于堆是完全二叉树,因此最底层结点靠左填充,其它层结点皆被填满。 +- 二叉树中的根结点对应「堆顶」,底层最靠右结点对应「堆底」。 +- 对于大顶堆 / 小顶堆,其堆顶元素(即根结点)的值最大 / 最小。 + +## 8.1.2. 堆常用操作 + +值得说明的是,多数编程语言提供的是「优先队列 Priority Queue」,其是一种抽象数据结构,**定义为具有出队优先级的队列**。 + +而恰好,**堆的定义与优先队列的操作逻辑完全吻合**,大顶堆就是一个元素从大到小出队的优先队列。从使用角度看,我们可以将「优先队列」和「堆」理解为等价的数据结构。因此,本文与代码对两者不做特别区分,统一使用「堆」来命名。 + +堆的常用操作见下表(方法命名以 Java 为例)。 + +Table. 堆的常用操作
+ +Fig. 数据结构与算法的关系
+ +如果将「LEGO 乐高」类比到「数据结构与算法」,那么可以得到下表所示的对应关系。 + +Fig. 知识点思维导图
+ +### 复杂度分析 + +首先介绍数据结构与算法的评价维度、算法效率的评估方法,引出了计算复杂度概念。 + +接下来,从 **函数渐近上界** 入手,分别介绍了 **时间复杂度** 和 **空间复杂度**,包括推算方法、常见类型、示例等。同时,剖析了 **最差、最佳、平均** 时间复杂度的联系与区别。 + +### 数据结构 + +首先介绍了常用的 **基本数据类型** 、以及它们是如何在内存中存储的。 + +接下来,介绍了两种 **数据结构分类方法**,包括逻辑结构与物理结构。 + +后续展开介绍了 **数组、链表、栈、队列、散列表、树、堆、图** 等数据结构,关心以下内容: + +- 基本定义:数据结构的设计来源、存在意义; +- 主要特点:在各项数据操作中的优势、劣势; +- 常用操作:例如访问、更新、插入、删除、遍历、搜索等; +- 常见类型:在算法题或工程实际中,经常碰到的数据结构类型; +- 典型应用:此数据结构经常搭配哪些算法使用; +- 实现方法:对于重要的数据结构,将给出完整的实现示例; + +### 算法 + +包括 **查找算法、排序算法、搜索与回溯、动态规划、分治算法**,内容包括: + +- 基本定义:算法的设计思想; +- 主要特点:使用前置条件、优势和劣势; +- 算法效率:最差和平均时间复杂度、空间复杂度; +- 实现方法:完整的算法实现,以及优化措施; +- 示例题目:结合例题加深理解; + +## 0.1.3. 配套代码 + +完整代码托管在 [GitHub 仓库](https://github.com/krahets/hello-algo) ,皆可一键运行。 + +!!! tip "前置工作" + + 1. [编程环境安装](https://www.hello-algo.com/chapter_preface/installation/) ,若有请跳过 + 2. 代码下载与使用方法请见 [如何使用本书](https://www.hello-algo.com/chapter_preface/suggestions/#_4) + +## 0.1.4. 风格约定 + +- 标题后标注 * 符号的是选读章节,如果你的时间有限,可以先跳过这些章节。 +- 文章中的重要名词会用「」符号标注,例如「数组 Array」。名词混淆会导致不必要的歧义,因此最好可以记住这类名词(包括中文和英文),以便后续阅读文献时使用。 +- 重点内容、总起句、总结句会被 **加粗**,此类文字值得特别关注。 +- 专有名词和有特指含义的词句会使用 “ ” 标注,以避免歧义。 +- 在工程应用中,每种语言都有注释规范;而本书放弃了一部分的注释规范性,以换取更加紧凑的内容排版。注释主要分为三种类型:标题注释、内容注释、多行注释。 + +=== "Java" + + ```java title="" + /* 标题注释,用于标注函数、类、测试样例等 */ + + // 内容注释,用于详解代码 + + /** + * 多行 + * 注释 + */ + ``` + +=== "C++" + + ```cpp title="" + /* 标题注释,用于标注函数、类、测试样例等 */ + + // 内容注释,用于详解代码 + + /** + * 多行 + * 注释 + */ + ``` + +=== "Python" + + ```python title="" + """ 标题注释,用于标注函数、类、测试样例等 """ + + # 内容注释,用于详解代码 + + """ + 多行 + 注释 + """ + ``` + +=== "Go" + + ```go title="" + /* 标题注释,用于标注函数、类、测试样例等 */ + + // 内容注释,用于详解代码 + + /** + * 多行 + * 注释 + */ + ``` + +=== "JavaScript" + + ```js title="" + /* 标题注释,用于标注函数、类、测试样例等 */ + + // 内容注释,用于详解代码 + + /** + * 多行 + * 注释 + */ + ``` + +=== "TypeScript" + + ```typescript title="" + /* 标题注释,用于标注函数、类、测试样例等 */ + + // 内容注释,用于详解代码 + + /** + * 多行 + * 注释 + */ + ``` + +=== "C" + + ```c title="" + /* 标题注释,用于标注函数、类、测试样例等 */ + + // 内容注释,用于详解代码 + + /** + * 多行 + * 注释 + */ + ``` + +=== "C#" + + ```csharp title="" + /* 标题注释,用于标注函数、类、测试样例等 */ + + // 内容注释,用于详解代码 + + /** + * 多行 + * 注释 + */ + ``` + +=== "Swift" + + ```swift title="" + /* 标题注释,用于标注函数、类、测试样例等 */ + + // 内容注释,用于详解代码 + + /** + * 多行 + * 注释 + */ + ``` + +=== "Zig" + + ```zig title="" + // 标题注释,用于标注函数、类、测试样例等 + + // 内容注释,用于详解代码 + + // 多行 + // 注释 + ``` + +## 0.1.5. 本书特点 * + +??? abstract "默认折叠,可以跳过" + + **以实践为主**。我们知道,学习英语期间光啃书本是远远不够的,需要多听、多说、多写,在实践中培养语感、积累经验。编程语言也是一门语言,因此学习方法也应是类似的,需要多看优秀代码、多敲键盘、多思考代码逻辑。 + + 本书的理论部分占少量篇幅,主要分为两类:一是基础且必要的概念知识,以培养读者对于算法的感性认识;二是重要的分类、对比或总结,这是为了帮助你站在更高视角俯瞰各个知识点,形成连点成面的效果。 + + 实践部分主要由示例和代码组成。代码配有简要注释,复杂示例会尽可能地使用视觉化的形式呈现。我强烈建议读者对照着代码自己敲一遍,如果时间有限,也至少逐行读、复制并运行一遍,配合着讲解将代码吃透。 + + **视觉化学习**。信息时代以来,视觉化的脚步从未停止。媒体形式经历了文字短信、图文 Email 、动图、短(长)视频、交互式 Web 、3D 游戏等演变过程,信息的视觉化程度越来越高、愈加符合人类感官、信息传播效率大大提升。科技界也在向视觉化迈进,iPhone 就是一个典型例子,其相对于传统手机是高度视觉化的,包含精心设计的字体、主题配色、交互动画等。 + + 近两年,短视频成为最受欢迎的信息媒介,可以在短时间内将高密度的信息“灌”给我们,有着极其舒适的观看体验。阅读则不然,读者与书本之间天然存在一种“疏离感”,我们看书会累、会走神、会停下来想其他事、会划下喜欢的句子、会思考某一片段的含义,这种疏离感给了读者与书本之间对话的可能,拓宽了想象空间。 + + 本书作为一本入门教材,希望可以保有书本的“慢节奏”,但也会避免与读者产生过多“疏离感”,而是努力将知识完整清晰地推送到你聪明的小脑袋瓜中。我将采用视觉化的方式(例如配图、动画),尽我可能清晰易懂地讲解复杂概念和抽象示例。 + + **内容精简化**。大多数的经典教科书,会把每个主题都讲的很透彻。虽然透彻性正是其获得读者青睐的原因,但对于想要快速入门的初学者来说,这些教材的实用性不足。本书会避免引入非必要的概念、名词、定义等,也避免展开不必要的理论分析,毕竟这不是一本真正意义上的教材,主要任务是尽快地带领读者入门。 + + 引入一些生活案例或趣味内容,非常适合作为知识点的引子或者解释的补充,但当融入过多额外元素时,内容会稍显冗长,也许反而使读者容易迷失、抓不住重点,这也是本书需要避免的。 + + 敲代码如同写字,“美”是统一的追求。本书力求美观的代码,保证规范的变量命名、统一的空格与换行、对齐的缩进、整齐的注释等。 + +## 0.1.6. 致谢 + +本书的成书过程中,我获得了许多人的帮助,包括但不限于: + +- 感谢我的女朋友泡泡担任本书的首位读者,从算法小白的视角为本书的写作提出了许多建议,使这本书更加适合算法初学者来阅读。 +- 感谢腾宝、琦宝、飞宝为本书起了个响当当的名字,好听又有梗,直接唤起我最初敲下第一行代码 "Hello, World!" 的回忆。 +- 感谢我的导师李博,在小酌畅谈时您告诉我“觉得适合、想做就去做”,坚定了我写这本书的决心。 +- 感谢苏潼为本书设计了封面和 LOGO ,我有些强迫症,前后多次修改,谢谢你的耐心。 +- 感谢 @squidfunk ,包括 [Material-for-MkDocs](https://github.com/squidfunk/mkdocs-material/tree/master) 顶级开源项目以及给出的写作排版建议。 + +在写作过程中,我阅读了许多与数据结构与算法的书籍材料,学习到了许多知识,感谢前辈们的精彩创作。 + +感谢父母,你们一贯的支持与鼓励给了我自由度来做这些有趣的事。 + +## 0.1.7. 作者简介 + +{: .center} + +力扣(LeetCode)全网阅读量最高博主
+分享近百道算法题解,累积回复数千读者的评论问题
+创作 LeetBook《图解算法数据结构》,已免费售出 22 万本
diff --git a/build/chapter_preface/contribution.md b/build/chapter_preface/contribution.md new file mode 100644 index 000000000..073c3f5f4 --- /dev/null +++ b/build/chapter_preface/contribution.md @@ -0,0 +1,65 @@ +--- +comments: true +--- + +# 0.4. 一起参与创作 + +!!! success "开源的魅力" + + 纸质书籍的两次印刷的间隔时间往往需要数年,内容更新非常不方便。但在本开源 HTML 书中,内容更迭的时间被缩短至数日甚至几个小时。 + +由于作者水平有限,书中内容难免疏漏谬误,请您谅解。此外,期待您可以一同参与本书的创作。如果发现笔误、无效链接、内容缺失、文字歧义、解释不清晰、行文结构不合理等问题,烦请您修正内容,以帮助其他读者获取更优质的学习内容。所有 [撰稿人](https://github.com/krahets/hello-algo/graphs/contributors) 将被展示在仓库主页,以感谢您对开源社区的无私奉献。 + +## 0.4.1. 修改文字与代码 + +每个页面的右上角都有一个「编辑」按钮,你可以按照以下步骤修改文章: + +1. 点击编辑按钮,如果遇到提示“需要 Fork 此仓库”,请通过; +2. 修改 Markdown 源文件内容; +3. 在页面底部填写更改说明,然后单击“Propose file change”按钮; +4. 页面跳转后,点击“Create pull request”按钮发起拉取请求即可,我会第一时间查看处理并及时更新内容。 + + + +## 0.4.2. 修改图片与动画 + +书中的配图无法直接修改,需要通过以下途径提出修改意见: + +1. 新建一个 Issue ,将需要修改的图片复制或截图,粘贴在面板中; +2. 描述图片问题,应如何修改; +3. 提交 Issue 即可,我会第一时间重新画图并替换图片。 + +## 0.4.3. 创作新内容 + +如果您想要创作新内容,例如 **重写章节、新增章节、修改代码、翻译代码至其他编程语言** 等,那么需要实施 Pull Request 工作流程: + +1. 登录 GitHub ,并 Fork [本仓库](https://github.com/krahets/hello-algo) 至个人账号; +2. 进入 Fork 仓库网页,使用 `git clone` 克隆该仓库至本地; +3. 在本地进行内容创作(建议通过运行测试来验证代码正确性); +4. 将本地更改 Commit ,并 Push 至远程仓库; +5. 刷新仓库网页,点击“Create pull request”按钮发起拉取请求(Pull Request)即可; + +非常欢迎您和我一同来创作本书! + +## 0.4.4. 本地部署 hello-algo + +### Docker + +请确保 Docker 已经安装并启动,并根据如下命令离线部署。 + +稍等片刻,即可使用浏览器打开 `http://localhost:8000` 访问本项目。 + +```bash +git clone https://github.com/krahets/hello-algo.git +cd hello-algo + +docker-compose up -d +``` + +使用如下命令即可删除部署。 + +```bash +docker-compose down +``` + +(TODO:教学视频) diff --git a/build/chapter_preface/installation.md b/build/chapter_preface/installation.md new file mode 100644 index 000000000..adab95b21 --- /dev/null +++ b/build/chapter_preface/installation.md @@ -0,0 +1,52 @@ +--- +comments: true +--- + +# 0.3. 编程环境安装 + +(TODO 视频教程) + +## 0.3.1. 安装 VSCode + +本书推荐使用开源轻量的 VSCode 作为本地 IDE ,下载并安装 [VSCode](https://code.visualstudio.com/) 。 + +## 0.3.2. Java 环境 + +1. 下载并安装 [OpenJDK](https://jdk.java.net/18/)(版本需满足 > JDK 9)。 +2. 在 VSCode 的插件市场中搜索 `java` ,安装 Java Extension Pack 。 + +## 0.3.3. C/C++ 环境 + +1. Windows 系统需要安装 [MinGW](https://sourceforge.net/projects/mingw-w64/files/) ([配置教程](https://glj0.netlify.app/d-%E8%BD%AF%E4%BB%B6%E6%8A%80%E8%83%BD/windows%20%E4%B8%8B%E4%BD%BF%E7%94%A8%20vscode%20+%20mingw%20%E5%AE%8C%E6%88%90%E7%AE%80%E5%8D%95%20c%20%E6%88%96%20cpp%20%E4%BB%A3%E7%A0%81%E7%9A%84%E8%BF%90%E8%A1%8C%E4%B8%8E%E8%B0%83%E8%AF%95/)),MacOS 自带 Clang 无需安装。 +2. 在 VSCode 的插件市场中搜索 `c++` ,安装 C/C++ Extension Pack 。 + +## 0.3.4. Python 环境 + +1. 下载并安装 [Miniconda3](https://docs.conda.io/en/latest/miniconda.html) 。 +2. 在 VSCode 的插件市场中搜索 `python` ,安装 Python Extension Pack 。 + +## 0.3.5. Go 环境 + +1. 下载并安装 [go](https://go.dev/dl/) 。 +2. 在 VSCode 的插件市场中搜索 `go` ,安装 Go 。 +3. 快捷键 `Ctrl + Shift + P` 呼出命令栏,输入 go ,选择 `Go: Install/Update Tools` ,全部勾选并安装即可。 + +## 0.3.6. JavaScript 环境 + +1. 下载并安装 [node.js](https://nodejs.org/en/) 。 +2. 在 VSCode 的插件市场中搜索 `javascript` ,安装 JavaScript (ES6) code snippets 。 + +## 0.3.7. C# 环境 + +1. 下载并安装 [.Net 6.0](https://dotnet.microsoft.com/en-us/download) ; +2. 在 VSCode 的插件市场中搜索 `c#` ,安装 c# 。 + +## 0.3.8. Swift 环境 + +1. 下载并安装 [Swift](https://www.swift.org/download/); +2. 在 VSCode 的插件市场中搜索 `swift`,安装 [Swift for Visual Studio Code](https://marketplace.visualstudio.com/items?itemName=sswg.swift-lang)。 + +## 0.3.9. Rust 环境 + +1. 下载并安装 [Rust](https://www.rust-lang.org/tools/install); +2. 在 VSCode 的插件市场中搜索 `rust`,安装 [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)。 diff --git a/build/chapter_preface/suggestions.md b/build/chapter_preface/suggestions.md new file mode 100644 index 000000000..7907db787 --- /dev/null +++ b/build/chapter_preface/suggestions.md @@ -0,0 +1,63 @@ +--- +comments: true +--- + +# 0.2. 如何使用本书 + +## 0.2.1. 图文搭配学 + +视频和图片相比于文字的信息密度和结构化程度更高,更容易让人理解。在本书中,重点和难点知识会主要以动画、图解的形式呈现,而文字的作用则是作为动画和图的解释与补充。 + +在阅读本书的过程中,若发现某段内容提供了动画或图解,**建议你以图为主线**,将文字内容(一般在图的上方)对齐到图中内容,综合来理解。 + + + +## 0.2.2. 代码实践学 + +!!! tip "前置工作" + + 如果没有本地编程环境,可以参照下节 [编程环境安装](https://www.hello-algo.com/chapter_preface/installation/) 。 + +### 下载代码仓 + +如果已经安装 [Git](https://git-scm.com/downloads) ,可以通过命令行来克隆代码仓。 + +```shell +git clone https://github.com/krahets/hello-algo.git +``` + +当然,你也可以点击“Download ZIP”直接下载代码压缩包,解压即可。 + + + +### 运行源代码 + +本书提供配套 Java, C++, Python 代码仓(后续可能拓展支持语言)。书中的代码栏上若标有 `*.java` , `*.cpp` , `*.py` ,则可在仓库 codes 文件夹中找到对应的 **代码源文件**。 + + + +这些源文件中包含详细注释,配有测试样例,可以直接运行,帮助你省去不必要的调试时间,可以将精力集中在学习内容上。 + + + +!!! tip "代码学习建议" + + 若学习时间紧张,**请至少将所有代码通读并运行一遍**。若时间允许,**强烈建议对照着代码自己敲一遍**,逐渐锻炼肌肉记忆。相比于读代码,写代码的过程往往能带来新的收获。 + +## 0.2.3. 提问讨论学 + +阅读本书时,请不要“惯着”那些弄不明白的知识点。如果有任何疑惑,**可以在评论区留下你的问题**,小伙伴们和我都会给予解答(您一般 3 天内会得到回复)。 + +同时,也希望你可以多花时间逛逛评论区。一方面,可以看看大家遇到了什么问题,反过来查漏补缺,这往往可以引起更加深度的思考。另一方面,也希望你可以慷慨地解答小伙伴们的问题、分享自己的见解,大家一起加油与进步! + + + +## 0.2.4. 算法学习“三步走” + +**第一阶段,算法入门,也正是本书的定位**。熟悉各种数据结构的特点、用法,学习各种算法的工作原理、用途、效率等。 + +**第二阶段,刷算法题**。可以先从热门题单开刷,推荐 [剑指 Offer](https://leetcode.cn/problem-list/xb9nqhhg/)、[LeetCode 热题 HOT 100](https://leetcode.cn/problem-list/2cktkvj/) ,先积累至少 100 道题量,熟悉大多数的算法问题。刚开始刷题时,“遗忘”是最大的困扰点,但这是很正常的,请不要担心。学习中有一种概念叫“周期性回顾”,同一道题隔段时间做一次,当做了三遍以上,往往就能牢记于心了。 + +**第三阶段,搭建知识体系**。在学习方面,可以阅读算法专栏文章、解题框架、算法教材,不断地丰富知识体系。在刷题方面,可以开始采用进阶刷题方案,例如按专题分类、一题多解、一解多题等,刷题方案在社区中可以找到一些讲解,在此不做赘述。 + + diff --git a/build/chapter_reference/index.md b/build/chapter_reference/index.md new file mode 100644 index 000000000..fcc5da56f --- /dev/null +++ b/build/chapter_reference/index.md @@ -0,0 +1,17 @@ +# 参考文献 + +[1] Thomas H. Cormen, et al. Introduction to Algorithms (3rd Edition). + +[2] Aditya Bhargava. Grokking Algorithms: An Illustrated Guide for Programmers and Other Curious People (1st Edition). + +[3] 程杰. 大话数据结构. + +[4] 王争. 数据结构与算法之美. + +[5] 严蔚敏. 数据结构( C 语言版). + +[6] 邓俊辉. 数据结构( C++ 语言版,第三版). + +[7] 马克·艾伦·维斯著,陈越译. 数据结构与算法分析:Java语言描述(第三版). + +[8] Gayle Laakmann McDowell. Cracking the Coding Interview: 189 Programming Questions and Solutions (6th Edition). diff --git a/build/chapter_searching/binary_search.md b/build/chapter_searching/binary_search.md new file mode 100755 index 000000000..9ac254cec --- /dev/null +++ b/build/chapter_searching/binary_search.md @@ -0,0 +1,555 @@ +--- +comments: true +--- + +# 10.2. 二分查找 + +「二分查找 Binary Search」利用数据的有序性,通过每轮缩小一半搜索区间来查找目标元素。 + +使用二分查找有两个前置条件: + +- **要求输入数据是有序的**,这样才能通过判断大小关系来排除一半的搜索区间; +- **二分查找仅适用于数组**,而在链表中使用效率很低,因为其在循环中需要跳跃式(非连续地)访问元素。 + +## 10.2.1. 算法实现 + +给定一个长度为 $n$ 的排序数组 `nums` ,元素从小到大排列。数组的索引取值范围为 + +$$ +0, 1, 2, \cdots, n-1 +$$ + +使用「区间」来表示这个取值范围的方法主要有两种: + +1. **双闭区间 $[0, n-1]$** ,即两个边界都包含自身;此方法下,区间 $[0, 0]$ 仍包含一个元素; +2. **左闭右开 $[0, n)$** ,即左边界包含自身、右边界不包含自身;此方法下,区间 $[0, 0)$ 为空; + +### “双闭区间”实现 + +首先,我们先采用“双闭区间”的表示,在数组 `nums` 中查找目标元素 `target` 的对应索引。 + +=== "Step 1" +  + +=== "Step 2" +  + +=== "Step 3" +  + +=== "Step 4" +  + +=== "Step 5" +  + +=== "Step 6" +  + +=== "Step 7" +  + +二分查找“双闭区间”表示下的代码如下所示。 + +=== "Java" + + ```java title="binary_search.java" + /* 二分查找(双闭区间) */ + int binarySearch(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) / 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; + } + ``` + +=== "C++" + + ```cpp title="binary_search.cpp" + /* 二分查找(双闭区间) */ + int binarySearch(vectorTable. 三种查找方法对比
+ +Fig. 冒泡操作
+ +## 11.2.1. 算法流程 + +1. 设数组长度为 $n$ ,完成第一轮「冒泡」后,数组最大元素已在正确位置,接下来只需排序剩余 $n - 1$ 个元素。 +2. 同理,对剩余 $n - 1$ 个元素执行「冒泡」,可将第二大元素交换至正确位置,因而待排序元素只剩 $n - 2$ 个。 +3. 以此类推…… **循环 $n - 1$ 轮「冒泡」,即可完成整个数组的排序**。 + + + +Fig. 冒泡排序流程
+ +=== "Java" + + ```java title="bubble_sort.java" + /* 冒泡排序 */ + void bubbleSort(int[] nums) { + // 外循环:待排序元素数量为 n-1, n-2, ..., 1 + for (int i = nums.length - 1; 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; + } + } + } + } + ``` + +=== "C++" + + ```cpp title="bubble_sort.cpp" + /* 冒泡排序 */ + void bubbleSort(vectorFig. 插入操作
+ +## 11.3.1. 算法流程 + +1. 第 1 轮先选取数组的 **第 2 个元素** 为 `base` ,执行「插入操作」后,**数组前 2 个元素已完成排序**。 +2. 第 2 轮选取 **第 3 个元素** 为 `base` ,执行「插入操作」后,**数组前 3 个元素已完成排序**。 +3. 以此类推……最后一轮选取 **数组尾元素** 为 `base` ,执行「插入操作」后,**所有元素已完成排序**。 + + + +Fig. 插入排序流程
+ +=== "Java" + + ```java title="insertion_sort.java" + /* 插入排序 */ + void insertionSort(int[] nums) { + // 外循环:base = nums[1], nums[2], ..., nums[n-1] + 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]; // 1. 将 nums[j] 向右移动一位 + j--; + } + nums[j + 1] = base; // 2. 将 base 赋值到正确位置 + } + } + ``` + +=== "C++" + + ```cpp title="insertion_sort.cpp" + /* 插入排序 */ + void insertionSort(vectorFig. 排序中的不同元素类型和判断规则
+ +## 11.1.1. 评价维度 + +排序算法主要可根据 **稳定性 、就地性 、自适应性 、比较类** 来分类。 + +### 稳定性 + +- 「稳定排序」在完成排序后,**不改变** 相等元素在数组中的相对顺序。 +- 「非稳定排序」在完成排序后,相等元素在数组中的相对位置 **可能被改变**。 + +假设我们有一个存储学生信息的表格,第 1, 2 列分别是姓名和年龄。那么在以下示例中,「非稳定排序」会导致输入数据的有序性丢失。因此「稳定排序」是很好的特性,**在多级排序中是必须的**。 + +```shell + # 输入数据是按照姓名排序好的 + # (name, age) + ('A', 19) + ('B', 18) + ('C', 21) + ('D', 19) + ('E', 23) + + # 假设使用非稳定排序算法按年龄排序列表, + # 结果中 ('D', 19) 和 ('A', 19) 的相对位置改变, + # 输入数据按姓名排序的性质丢失 + ('B', 18) + ('D', 19) + ('A', 19) + ('C', 21) + ('E', 23) +``` + +### 就地性 + +- 「原地排序」无需辅助数据,不使用额外空间; +- 「非原地排序」需要借助辅助数据,使用额外空间; + +「原地排序」不使用额外空间,可以节约内存;并且一般情况下,由于数据操作减少,原地排序的运行效率也更高。 + +### 自适应性 + +- 「自适应排序」的时间复杂度受输入数据影响,即最佳 / 最差 / 平均时间复杂度不相等。 +- 「非自适应排序」的时间复杂度恒定,与输入数据无关。 + +我们希望 **最差 = 平均**,即不希望排序算法的运行效率在某些输入数据下发生劣化。 + +### 比较类 + +- 「比较类排序」基于元素之间的比较算子(小于、相等、大于)来决定元素的相对顺序。 +- 「非比较类排序」不基于元素之间的比较算子来决定元素的相对顺序。 + +「比较类排序」的时间复杂度最优为 $O(n \log n)$ ;而「非比较类排序」可以达到 $O(n)$ 的时间复杂度,但通用性较差。 + +## 11.1.2. 理想排序算法 + +- **运行快**,即时间复杂度低; +- **稳定排序**,即排序后相等元素的相对位置不变化; +- **原地排序**,即运行中不使用额外的辅助空间; +- **正向自适应性**,即算法的运行效率不会在某些输入数据下发生劣化; + +然而,**没有排序算法同时具备以上所有特性**。排序算法的选型使用取决于具体的列表类型、列表长度、元素分布等因素。 diff --git a/build/chapter_sorting/merge_sort.md b/build/chapter_sorting/merge_sort.md new file mode 100755 index 000000000..cf2854917 --- /dev/null +++ b/build/chapter_sorting/merge_sort.md @@ -0,0 +1,475 @@ +--- +comments: true +--- + +# 11.5. 归并排序 + +「归并排序 Merge Sort」是算法中“分治思想”的典型体现,其有「划分」和「合并」两个阶段: + +1. **划分阶段**:通过递归不断 **将数组从中点位置划分开**,将长数组的排序问题转化为短数组的排序问题; +2. **合并阶段**:划分到子数组长度为 1 时,开始向上合并,不断将 **左、右两个短排序数组** 合并为 **一个长排序数组**,直至合并至原数组时完成排序; + + + +Fig. 归并排序两阶段:划分与合并
+ +## 11.5.1. 算法流程 + +**「递归划分」** 从顶至底递归地 **将数组从中点切为两个子数组**,直至长度为 1 ; + +1. 计算数组中点 `mid` ,递归划分左子数组(区间 `[left, mid]` )和右子数组(区间 `[mid + 1, right]` ); +2. 递归执行 `1.` 步骤,直至子数组区间长度为 1 时,终止递归划分; + +**「回溯合并」** 从底至顶地将左子数组和右子数组合并为一个 **有序数组** ; + +需要注意,由于从长度为 1 的子数组开始合并,所以 **每个子数组都是有序的**。因此,合并任务本质是要 **将两个有序子数组合并为一个有序数组**。 + +=== "Step1" +  + +=== "Step2" +  + +=== "Step3" +  + +=== "Step4" +  + +=== "Step5" +  + +=== "Step6" +  + +=== "Step7" +  + +=== "Step8" +  + +=== "Step9" +  + +=== "Step10" +  + +观察发现,归并排序的递归顺序就是二叉树的「后序遍历」。 + +- **后序遍历**:先递归左子树、再递归右子树、最后处理根结点。 +- **归并排序**:先递归左子树、再递归右子树、最后处理合并。 + +=== "Java" + + ```java title="merge_sort.java" + /** + * 合并左子数组和右子数组 + * 左子数组区间 [left, mid] + * 右子数组区间 [mid + 1, right] + */ + void merge(int[] nums, int left, int mid, int right) { + // 初始化辅助数组 + int[] tmp = Arrays.copyOfRange(nums, 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(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); + } + ``` + +=== "C++" + + ```cpp title="merge_sort.cpp" + /** + * 合并左子数组和右子数组 + * 左子数组区间 [left, mid] + * 右子数组区间 [mid + 1, right] + */ + void merge(vectorFig. 哨兵划分
+ +=== "Java" + + ``` java title="quick_sort.java" + /* 元素交换 */ + void swap(int[] nums, int i, int j) { + int tmp = nums[i]; + nums[i] = nums[j]; + nums[j] = tmp; + } + + /* 哨兵划分 */ + int partition(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; // 返回基准数的索引 + } + ``` + +=== "C++" + + ```cpp title="quick_sort.cpp" + /* 元素交换 */ + void swap(vectorFig. 快速排序流程
+ +=== "Java" + + ```java title="quick_sort.java" + /* 快速排序 */ + void quickSort(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); + } + ``` + +=== "C++" + + ```cpp title="quick_sort.cpp" + /* 快速排序 */ + void quickSort(vectorFig. 双向队列的操作
+ +## 5.3.1. 双向队列常用操作 + +双向队列的常用操作见下表,方法名需根据特定语言来确定。 + +Table. 双向队列的常用操作
+ +Fig. 队列的先入先出特性
+ +## 5.2.1. 队列常用操作 + +队列的常用操作见下表,方法名需根据特定语言来确定。 + +Table. 队列的常用操作
+ +Fig. 栈的先入后出特性
+ +## 5.1.1. 栈常用操作 + +栈的常用操作见下表(方法命名以 Java 为例)。 + +Table. 栈的常用操作
+ +Fig. 子结点与子树
+ +## 7.1.1. 二叉树常见术语 + +二叉树的术语较多,建议尽量理解并记住。后续可能遗忘,可以在需要使用时回来查看确认。 + +- 「根结点 Root Node」:二叉树最顶层的结点,其没有父结点; +- 「叶结点 Leaf Node」:没有子结点的结点,其两个指针都指向 $\text{null}$ ; +- 结点所处「层 Level」:从顶至底依次增加,根结点所处层为 1 ; +- 结点「度 Degree」:结点的子结点数量。二叉树中,度的范围是 0, 1, 2 ; +- 「边 Edge」:连接两个结点的边,即结点指针; +- 二叉树「高度」:二叉树中根结点到最远叶结点走过边的数量; +- 结点「深度 Depth」 :根结点到该结点走过边的数量; +- 结点「高度 Height」:最远叶结点到该结点走过边的数量; + + + +Fig. 二叉树的常见术语
+ +!!! tip "高度与深度的定义" + + 值得注意,我们通常将「高度」和「深度」定义为“走过边的数量”,而有些题目或教材会将其定义为“走过结点的数量”,此时高度或深度都需要 + 1 。 + +## 7.1.2. 二叉树基本操作 + +**初始化二叉树**。与链表类似,先初始化结点,再构建引用指向(即指针)。 + +=== "Java" + + ```java title="binary_tree.java" + // 初始化结点 + TreeNode n1 = new TreeNode(1); + TreeNode n2 = new TreeNode(2); + TreeNode n3 = new TreeNode(3); + TreeNode n4 = new TreeNode(4); + TreeNode n5 = new TreeNode(5); + // 构建引用指向(即指针) + n1.left = n2; + n1.right = n3; + n2.left = n4; + n2.right = n5; + ``` + +=== "C++" + + ```cpp title="binary_tree.cpp" + /* 初始化二叉树 */ + // 初始化结点 + TreeNode* n1 = new TreeNode(1); + TreeNode* n2 = new TreeNode(2); + TreeNode* n3 = new TreeNode(3); + TreeNode* n4 = new TreeNode(4); + TreeNode* n5 = new TreeNode(5); + // 构建引用指向(即指针) + n1->left = n2; + n1->right = n3; + n2->left = n4; + n2->right = n5; + ``` + +=== "Python" + + ```python title="binary_tree.py" + """ 初始化二叉树 """ + # 初始化节点 + n1 = TreeNode(val=1) + n2 = TreeNode(val=2) + n3 = TreeNode(val=3) + n4 = TreeNode(val=4) + n5 = TreeNode(val=5) + # 构建引用指向(即指针) + n1.left = n2 + n1.right = n3 + n2.left = n4 + n2.right = n5 + ``` + +=== "Go" + + ```go title="binary_tree.go" + /* 初始化二叉树 */ + // 初始化结点 + n1 := NewTreeNode(1) + n2 := NewTreeNode(2) + n3 := NewTreeNode(3) + n4 := NewTreeNode(4) + n5 := NewTreeNode(5) + // 构建引用指向(即指针) + n1.Left = n2 + n1.Right = n3 + n2.Left = n4 + n2.Right = n5 + ``` + +=== "JavaScript" + + ```js title="binary_tree.js" + /* 初始化二叉树 */ + // 初始化结点 + let n1 = new TreeNode(1), + n2 = new TreeNode(2), + n3 = new TreeNode(3), + n4 = new TreeNode(4), + n5 = new TreeNode(5); + // 构建引用指向(即指针) + n1.left = n2; + n1.right = n3; + n2.left = n4; + n2.right = n5; + ``` + +=== "TypeScript" + + ```typescript title="binary_tree.ts" + /* 初始化二叉树 */ + // 初始化结点 + let n1 = new TreeNode(1), + n2 = new TreeNode(2), + n3 = new TreeNode(3), + n4 = new TreeNode(4), + n5 = new TreeNode(5); + // 构建引用指向(即指针) + n1.left = n2; + n1.right = n3; + n2.left = n4; + n2.right = n5; + ``` + +=== "C" + + ```c title="binary_tree.c" + + ``` + +=== "C#" + + ```csharp title="binary_tree.cs" + /* 初始化二叉树 */ + // 初始化结点 + TreeNode n1 = new TreeNode(1); + TreeNode n2 = new TreeNode(2); + TreeNode n3 = new TreeNode(3); + TreeNode n4 = new TreeNode(4); + TreeNode n5 = new TreeNode(5); + // 构建引用指向(即指针) + n1.left = n2; + n1.right = n3; + n2.left = n4; + n2.right = n5; + ``` + +=== "Swift" + + ```swift title="binary_tree.swift" + // 初始化结点 + let n1 = TreeNode(x: 1) + let n2 = TreeNode(x: 2) + let n3 = TreeNode(x: 3) + let n4 = TreeNode(x: 4) + let n5 = TreeNode(x: 5) + // 构建引用指向(即指针) + n1.left = n2 + n1.right = n3 + n2.left = n4 + n2.right = n5 + ``` + +=== "Zig" + + ```zig title="binary_tree.zig" + + ``` + +**插入与删除结点**。与链表类似,插入与删除结点都可以通过修改指针实现。 + + + +Fig. 在二叉树中插入与删除结点
+ +=== "Java" + + ```java title="binary_tree.java" + TreeNode P = new TreeNode(0); + // 在 n1 -> n2 中间插入结点 P + n1.left = P; + P.left = n2; + // 删除结点 P + n1.left = n2; + ``` + +=== "C++" + + ```cpp title="binary_tree.cpp" + /* 插入与删除结点 */ + TreeNode* P = new TreeNode(0); + // 在 n1 -> n2 中间插入结点 P + n1->left = P; + P->left = n2; + // 删除结点 P + n1->left = n2; + ``` + +=== "Python" + + ```python title="binary_tree.py" + """ 插入与删除结点 """ + p = TreeNode(0) + # 在 n1 -> n2 中间插入结点 P + n1.left = p + p.left = n2 + # 删除节点 P + n1.left = n2 + ``` + +=== "Go" + + ```go title="binary_tree.go" + /* 插入与删除结点 */ + // 在 n1 -> n2 中间插入结点 P + p := NewTreeNode(0) + n1.Left = p + p.Left = n2 + // 删除结点 P + n1.Left = n2 + ``` + +=== "JavaScript" + + ```js title="binary_tree.js" + /* 插入与删除结点 */ + let P = new TreeNode(0); + // 在 n1 -> n2 中间插入结点 P + n1.left = P; + P.left = n2; + // 删除结点 P + n1.left = n2; + ``` + +=== "TypeScript" + + ```typescript title="binary_tree.ts" + /* 插入与删除结点 */ + const P = new TreeNode(0); + // 在 n1 -> n2 中间插入结点 P + n1.left = P; + P.left = n2; + // 删除结点 P + n1.left = n2; + ``` + +=== "C" + + ```c title="binary_tree.c" + + ``` + +=== "C#" + + ```csharp title="binary_tree.cs" + /* 插入与删除结点 */ + TreeNode P = new TreeNode(0); + // 在 n1 -> n2 中间插入结点 P + n1.left = P; + P.left = n2; + // 删除结点 P + n1.left = n2; + ``` + +=== "Swift" + + ```swift title="binary_tree.swift" + let P = TreeNode(x: 0) + // 在 n1 -> n2 中间插入结点 P + n1.left = P + P.left = n2 + // 删除结点 P + n1.left = n2 + ``` + +=== "Zig" + + ```zig title="binary_tree.zig" + + ``` + +!!! note + + 插入结点会改变二叉树的原有逻辑结构,删除结点往往意味着删除了该结点的所有子树。因此,二叉树中的插入与删除一般都是由一套操作配合完成的,这样才能实现有意义的操作。 + +## 7.1.3. 常见二叉树类型 + +### 完美二叉树 + +「完美二叉树 Perfect Binary Tree」的所有层的结点都被完全填满。在完美二叉树中,所有结点的度 = 2 ;若树高度 $= h$ ,则结点总数 $= 2^{h+1} - 1$ ,呈标准的指数级关系,反映着自然界中常见的细胞分裂。 + +!!! tip + + 在中文社区中,完美二叉树常被称为「满二叉树」,请注意与完满二叉树区分。 + + + +### 完全二叉树 + +「完全二叉树 Complete Binary Tree」只有最底层的结点未被填满,且最底层结点尽量靠左填充。 + +**完全二叉树非常适合用数组来表示**。如果按照层序遍历序列的顺序来存储,那么空结点 `null` 一定全部出现在序列的尾部,因此我们就可以不用存储这些 null 了。 + + + +### 完满二叉树 + +「完满二叉树 Full Binary Tree」除了叶结点之外,其余所有结点都有两个子结点。 + + + +### 平衡二叉树 + +「平衡二叉树 Balanced Binary Tree」中任意结点的左子树和右子树的高度之差的绝对值 $\leq 1$ 。 + + + +## 7.1.4. 二叉树的退化 + +当二叉树的每层的结点都被填满时,达到「完美二叉树」;而当所有结点都偏向一边时,二叉树退化为「链表」。 + +- 完美二叉树是一个二叉树的“最佳状态”,可以完全发挥出二叉树“分治”的优势; +- 链表则是另一个极端,各项操作都变为线性操作,时间复杂度退化至 $O(n)$ ; + + + +Fig. 二叉树的最佳和最差结构
+ +如下表所示,在最佳和最差结构下,二叉树的叶结点数量、结点总数、高度等达到极大或极小值。 + +Fig. 二叉树的层序遍历
+ +广度优先遍历一般借助「队列」来实现。队列的规则是“先进先出”,广度优先遍历的规则是 ”一层层平推“ ,两者背后的思想是一致的。 + +=== "Java" + + ```java title="binary_tree_bfs.java" + /* 层序遍历 */ + ListFig. 二叉树的前 / 中 / 后序遍历
+ +动画图解、能运行、可提问的数据结构与算法快速入门教程
+[](https://github.com/krahets/hello-algo)
+动画诠释重点,平滑学习曲线电脑、平板、手机全终端阅读
+ + + +!!! quote "" + +"A picture is worth a thousand words."
+“一图胜千言”
+ +--- + +提供经典算法的清晰实现与测试代码多种语言,详细注释,皆可一键运行
+ + + +!!! quote "" + +"Talk is cheap. Show me the code."
+“少吹牛,看代码”
+ +--- + +作者一般 72h 内回复评论问题与小伙伴们一起讨论学习进步
+ + + +!!! quote "" + +“追风赶月莫停留,平芜尽处是春山”
+一起加油!
+ +--- + +
+
+
+
+