diff --git a/.gitignore b/.gitignore index b51d9e5b4..261c907b6 100644 --- a/.gitignore +++ b/.gitignore @@ -7,8 +7,5 @@ # mkdocs files site/ .cache/ -overrides/ codes/python codes/cpp - -docs/chapter_* \ No newline at end of file diff --git a/docs/chapter_array_and_linkedlist/array.assets/array_definition.png b/docs/chapter_array_and_linkedlist/array.assets/array_definition.png new file mode 100644 index 000000000..401b06691 Binary files /dev/null and b/docs/chapter_array_and_linkedlist/array.assets/array_definition.png differ diff --git a/docs/chapter_array_and_linkedlist/array.assets/array_insert_remove_element.png b/docs/chapter_array_and_linkedlist/array.assets/array_insert_remove_element.png new file mode 100644 index 000000000..a35f52076 Binary files /dev/null and b/docs/chapter_array_and_linkedlist/array.assets/array_insert_remove_element.png differ diff --git a/docs/chapter_array_and_linkedlist/array.assets/array_memory_location_calculation.png b/docs/chapter_array_and_linkedlist/array.assets/array_memory_location_calculation.png new file mode 100644 index 000000000..3e1a08d75 Binary files /dev/null and b/docs/chapter_array_and_linkedlist/array.assets/array_memory_location_calculation.png differ diff --git a/docs/chapter_array_and_linkedlist/array.md b/docs/chapter_array_and_linkedlist/array.md new file mode 100644 index 000000000..a7760f524 --- /dev/null +++ b/docs/chapter_array_and_linkedlist/array.md @@ -0,0 +1,222 @@ +--- +comments: true +--- + +# 数组 + +「数组 Array」是一种将 **相同类型元素** 存储在 **连续内存空间** 的数据结构,将元素在数组中的位置称为元素的「索引 Index」。 + +![array_definition](array.assets/array_definition.png) + +
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" + + ``` + +=== "Python" + + ```python title="array.py" + + ``` + +## 数组优点 + +**在数组中访问元素非常高效。** 这是因为在数组中,计算元素的内存地址非常容易。给定数组首个元素的地址、和一个元素的索引,利用以下公式可以直接计算得到该元素的内存地址,从而直接访问此元素。 + +![array_memory_location_calculation](array.assets/array_memory_location_calculation.png) + +Fig. 数组元素的内存地址计算
+ +```java title="" +// 元素内存地址 = 数组内存地址 + 元素长度 * 元素索引 +elementAddr = firtstElementAddr + elementLength * elementIndex +``` + +**为什么数组元素索引从 0 开始编号?** 根据地址计算公式,**索引本质上表示的是内存地址偏移量**,首个元素的地址偏移量是 $0$ ,那么索引是 $0$ 也就很自然了。 + +访问元素的高效性带来了许多便利。例如,我们可以在 $O(1)$ 时间内随机获取一个数组中的元素。 + +=== "Java" + + ```java title="array.java" + /* 随机返回一个数组元素 */ + int randomAccess(int[] nums) { + int randomIndex = ThreadLocalRandom.current(). + nextInt(0, nums.length); + int randomNum = nums[randomIndex]; + return randomNum; + } + ``` + +=== "C++" + + ```cpp title="array.cpp" + + ``` + +=== "Python" + + ```python title="array.py" + + ``` + +## 数组缺点 + +**数组在初始化后长度不可变。** 由于系统无法保证数组之后的内存空间是可用的,因此数组长度无法扩展。而若希望扩容数组,则需新建一个数组,然后把原数组元素依次拷贝到新数组,在数组很大的情况下,这是非常耗时的。 + +=== "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" + + ``` + +=== "Python" + + ```python title="array.py" + + ``` + +**数组中插入或删除元素效率低下。** 假设我们想要在数组中间某位置插入一个元素,由于数组元素在内存中是 “紧挨着的” ,它们之间没有空间再放任何数据。因此,我们不得不将此索引之后的所有元素都向后移动一位,然后再把元素赋值给该索引。删除元素也是类似,需要把此索引之后的元素都向前移动一位。总体看有以下缺点: + +- **时间复杂度高:** 数组的插入和删除的平均时间复杂度均为 $O(N)$ ,其中 $N$ 为数组长度。 +- **丢失元素或:** 由于数组的长度不可变,因此在插入元素后,数组原来的末尾元素会丢失。 +- **内存浪费:** 我们一般会初始化一个比较长的数组,只用前面一部分,这样在插入数据时,丢失的末尾元素都是我们不关心的,但这样做同时也会造成内存空间的浪费。 + +![array_insert_remove_element](array.assets/array_insert_remove_element.png) + +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" + + ``` + +=== "Python" + + ```python title="array.py" + + ``` + +## 数组常用操作 + +**数组遍历。** 以下介绍两种常用的遍历方法。 + +=== "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" + + ``` + +=== "Python" + + ```python title="array.py" + + ``` + +**数组查找。** 通过遍历数组,查找数组内的指定元素,并输出对应索引。 + +=== "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" + + ``` + +=== "Python" + + ```python title="array.py" + + ``` + +## 数组典型应用 + +**随机访问。** 如果我们想要随机抽取一些样本,那么可以用数组存储,并生成一个随机序列,根据索引实现样本的随机抽取。 + +**二分查找。** 例如前文查字典的例子,我们可以将字典中的所有字按照拼音顺序存储在数组中,然后使用与日常查纸质字典相同的 “翻开中间,排除一半” 的方式,来实现一个查电子字典的算法。 + +**深度学习。** 神经网络中大量使用了向量、矩阵、张量之间的线性代数运算,这些数据都是以数组的形式构建的。数组是神经网络编程中最常使用的数据结构。 diff --git a/docs/chapter_array_and_linkedlist/linked_list.assets/linkedlist_common_types.png b/docs/chapter_array_and_linkedlist/linked_list.assets/linkedlist_common_types.png new file mode 100644 index 000000000..3ced93c65 Binary files /dev/null and b/docs/chapter_array_and_linkedlist/linked_list.assets/linkedlist_common_types.png differ diff --git a/docs/chapter_array_and_linkedlist/linked_list.assets/linkedlist_definition.png b/docs/chapter_array_and_linkedlist/linked_list.assets/linkedlist_definition.png new file mode 100644 index 000000000..73120e956 Binary files /dev/null and b/docs/chapter_array_and_linkedlist/linked_list.assets/linkedlist_definition.png differ diff --git a/docs/chapter_array_and_linkedlist/linked_list.assets/linkedlist_insert_remove_node.png b/docs/chapter_array_and_linkedlist/linked_list.assets/linkedlist_insert_remove_node.png new file mode 100644 index 000000000..3fcda1f63 Binary files /dev/null and b/docs/chapter_array_and_linkedlist/linked_list.assets/linkedlist_insert_remove_node.png differ diff --git a/docs/chapter_array_and_linkedlist/linked_list.md b/docs/chapter_array_and_linkedlist/linked_list.md new file mode 100644 index 000000000..361e8c28f --- /dev/null +++ b/docs/chapter_array_and_linkedlist/linked_list.md @@ -0,0 +1,222 @@ +--- +comments: true +--- + +# 链表 + +!!! note "引言" + + 内存空间是所有程序的公共资源,排除已占用的内存,空闲内存往往是散落在内存各处的。我们知道,存储数组需要内存空间连续,当我们需要申请一个很大的数组时,系统不一定存在这么大的连续内存空间。而链表则更加灵活,不需要内存是连续的,只要剩余内存空间大小够用即可。 + +「链表 Linked List」是一种线性数据结构,其中每个元素都是单独的对象,各个元素(一般称为结点)之间通过指针连接。由于结点中记录了连接关系,因此链表的存储方式相比于数组更加灵活,系统不必保证内存地址的连续性。 + +链表的「结点 Node」包含两项数据,一是结点「值 Value」,二是指向下一结点的「指针 Pointer」(或称「引用 Reference」)。 + +![linkedlist_definition](linked_list.assets/linkedlist_definition.png) + +Fig. 链表定义与存储方式
+ +=== "Java" + + ```java title="" + /* 链表结点类 */ + class ListNode { + int val; // 结点值 + ListNode next; // 指向下一结点的指针(引用) + ListNode(int x) { val = x; } // 构造函数 + } + ``` + +=== "C++" + + ```cpp title="" + + ``` + +=== "Python" + + ```python title="" + """ 链表结点类 """ + class ListNode: + def __init__(self, x): + self.val = x # 结点值 + self.next = None # 指向下一结点的指针(引用) + ``` + +**尾结点指向什么?** 我们一般将链表的最后一个结点称为「尾结点」,其指向的是「空」,在 Java / C++ / Python 中分别记为 `null` / `nullptr` / `None` 。在不引起歧义下,本书都使用 `null` 来表示空。 + +**链表初始化方法。** 建立链表分为两步,第一步是初始化各个结点对象,第二步是构建引用指向关系。完成后,即可以从链表的首个结点(即头结点)出发,访问其余所有的结点。 + +!!! tip + + 我们通常将头结点当作链表的代称,例如头结点 `head` 和链表 `head` 实际上是同义的。 + +=== "Java" + + ```java title="" + /* 初始化链表 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="" + + ``` + +=== "Python" + + ```python title="" + + ``` + +## 链表优点 + +**在链表中,插入与删除结点的操作效率高。** 例如,如果想在链表中间的两个结点 `A` , `B` 之间插入一个新结点 `P` ,我们只需要改变两个结点指针即可,时间复杂度为 $O(1)$ ,相比数组的插入操作高效很多。在链表中删除某个结点也很方便,只需要改变一个结点指针即可。 + +![linkedlist_insert_remove_node](linked_list.assets/linkedlist_insert_remove_node.png) + +Fig. 在链表中插入与删除结点
+ +=== "Java" + + ```java title="" + /* 在链表的结点 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="" + + ``` + +=== "Python" + + ```python title="" + + ``` + +## 链表缺点 + +**链表访问结点效率低。** 上节提到,数组可以在 $O(1)$ 时间下访问任意元素,但链表无法直接访问任意结点。这是因为计算机需要从头结点出发,一个一个地向后遍历到目标结点。例如,倘若想要访问链表索引为 `index` (即第 `index + 1` 个)的结点,那么需要 `index` 次访问操作。 + +=== "Java" + + ```java title="" + /* 访问链表中索引为 index 的结点 */ + ListNode access(ListNode head, int index) { + for (int i = 0; i < index; i++) { + head = head.next; + if (head == null) + return null; + } + return head; + } + ``` + +=== "C++" + + ```cpp title="" + + ``` + +=== "Python" + + ```python title="" + + ``` + +**链表的内存占用多。** 链表以结点为单位,每个结点除了保存值外,还需额外保存指针(引用)。这意味着同样数据量下,链表比数组需要占用更多内存空间。 + +## 链表常用操作 + +**遍历链表查找。** 遍历链表,查找链表内值为 `target` 的结点,输出结点在链表中的索引。 + +=== "Java" + + ```java title="" + /* 在链表中查找值为 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="" + + ``` + +=== "Python" + + ```python title="" + + ``` + +## 常见链表类型 + +**单向链表。** 即上述介绍的普通链表。单向链表的结点有「值」和指向下一结点的「指针(引用)」两项数据。我们将首个结点称为头结点,尾结点指向 `null` 。 + +**环形链表。** 如果我们令单向链表的尾结点指向头结点(即首尾相接),则得到一个环形链表。在环形链表中,我们可以将任意结点看作是头结点。 + +**双向链表。** 单向链表仅记录了一个方向的指针(引用),在双向链表的结点定义中,同时有指向下一结点(后继结点)和上一结点(前驱结点)的「指针(引用)」。双向链表相对于单向链表更加灵活,即可以朝两个方向遍历链表,但也需要占用更多的内存空间。 + +=== "Java" + + ```java title="" + /* 双向链表结点类 */ + class ListNode { + int val; // 结点值 + ListNode next; // 指向后继结点的指针(引用) + ListNode prev; // 指向前驱结点的指针(引用) + ListNode(int x) { val = x; } // 构造函数 + } + ``` + +=== "C++" + + ```cpp title="" + + ``` + +=== "Python" + + ```python title="" + + ``` + +![linkedlist_common_types](linked_list.assets/linkedlist_common_types.png) + +Fig. 常见链表类型
diff --git a/docs/chapter_array_and_linkedlist/list.md b/docs/chapter_array_and_linkedlist/list.md new file mode 100644 index 000000000..4f584dba2 --- /dev/null +++ b/docs/chapter_array_and_linkedlist/list.md @@ -0,0 +1,273 @@ +--- +comments: true +--- + +# 列表 + +**由于长度不可变,数组的实用性大大降低。** 在很多情况下,我们事先并不知道会输入多少数据,这就为数组长度的选择带来了很大困难。长度选小了,需要在添加数据中频繁地扩容数组;长度选大了,又造成内存空间的浪费。 + +为了解决此问题,诞生了一种被称为「列表 List」的数据结构。列表可以被理解为长度可变的数组,因此也常被称为「动态数组 Dynamic Array」。列表基于数组实现,继承了数组的优点,同时还可以在程序运行中实时扩容。在列表中,我们可以自由地添加元素,而不用担心超过容量限制。 + +## 列表常用操作 + +**初始化列表。** 我们通常使用 `Integer[]` 包装类和 `Arrays.asList()` 作为中转,来初始化一个带有初始值的列表。 + +=== "Java" + + ```java title="list.java" + /* 初始化列表 */ + // 注意数组的元素类型是 int[] 的包装类 Integer[] + Integer[] numbers = new Integer[] { 1, 3, 2, 5, 4 }; + 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="" + + ``` + +=== "Python" + + ```python title="" + + ``` + +## 推算方法 + +空间复杂度的推算方法和时间复杂度总体类似,只是从统计 “计算操作数量” 变为统计 “使用空间大小” 。与时间复杂度不同的是,**我们一般只关注「最差空间复杂度」**。这是因为内存空间是一个硬性要求,我们必须保证在所有输入数据下都有足够的内存空间预留。 + +**最差空间复杂度中的 “最差” 有两层含义**,分别为输入数据的最差分布、算法运行中的最差时间点。 + +- **以最差输入数据为准。** 当 $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="" + + ``` + +=== "Python" + + ```python title="" + + ``` + +**在递归函数中,需要注意统计栈帧空间。** 例如函数 `loop()`,在循环中调用了 $n$ 次 `function()` ,每轮中的 `function()` 都返回并释放了栈帧空间,因此空间复杂度仍为 $O(1)$ 。而递归函数 `recur()` 在运行中会同时存在 $n$ 个未返回的 `recur()` ,从而使用 $O(n)$ 的栈帧空间。 + +=== "Java" + + ```java title="" + int function() { + // do something + return 0; + } + /* 循环 */ + void loop(int n) { + for (int i = 0; i < n; i++) { + function(); + } + } + /* 递归 */ + void recur(int n) { + if (n == 1) return; + return recur(n - 1); + } + ``` + +=== "C++" + + ```cpp title="" + + ``` + +=== "Python" + + ```python title="" + + ``` + +## 常见类型 + +设输入数据大小为 $n$ ,常见的空间复杂度类型有(从低到高排列) + +$$ +\begin{aligned} +O(1) < O(\log n) < O(n) < O(n^2) < O(2^n) \newline +\text{常数阶} < \text{对数阶} < \text{线性阶} < \text{平方阶} < \text{指数阶} +\end{aligned} +$$ + +![space_complexity_common_types](space_complexity.assets/space_complexity_common_types.png) + +Fig. 空间复杂度的常见类型
+ +!!! tip + + 部分示例代码需要一些前置知识,包括数组、链表、二叉树、递归算法等。如果遇到看不懂的地方无需担心,可以在学习完后面章节后再来复习,现阶段先聚焦在理解时间复杂度含义和推算方法上。 + +### 常数阶 $O(1)$ + +常数阶常见于数量与输入数据大小 $n$ 无关的常量、变量、对象。 + +需要注意的是,在循环中初始化变量或调用函数而占用的内存,在进入下一循环后就会被释放,即不会累积占用空间,空间复杂度仍为 $O(1)$ 。 + +=== "Java" + + ```java title="space_complexity_types.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_types.cpp" + + ``` + +=== "Python" + + ```python title="space_complexity_types.py" + + ``` + +### 线性阶 $O(n)$ + +线性阶常见于元素数量与 $n$ 成正比的数组、链表、栈、队列等。 + +=== "Java" + + ```java title="space_complexity_types.java" + /* 线性阶 */ + void linear(int n) { + // 长度为 n 的数组占用 O(n) 空间 + int[] nums = new int[n]; + // 长度为 n 的列表占用 O(n) 空间 + ListFig. 递归函数产生的线性阶空间复杂度
+ +### 平方阶 $O(n^2)$ + +平方阶常见于元素数量与 $n$ 成平方关系的矩阵、图。 + +=== "Java" + + ```java title="space_complexity_types.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_types.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_types.cpp" + + ``` + +=== "Python" + + ```python title="space_complexity_types.py" + + ``` + +![space_complexity_exponential](space_complexity.assets/space_complexity_exponential.png) + +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/docs/chapter_computational_complexity/space_time_tradeoff.md b/docs/chapter_computational_complexity/space_time_tradeoff.md new file mode 100644 index 000000000..3753f050c --- /dev/null +++ b/docs/chapter_computational_complexity/space_time_tradeoff.md @@ -0,0 +1,79 @@ +# 权衡时间与空间 + +理想情况下,我们希望算法的时间复杂度和空间复杂度都能够达到最优,而实际上,同时优化时间复杂度和空间复杂度是非常困难的。 + +**降低时间复杂度,往往是以提升空间复杂度为代价的,反之亦然。** 我们把牺牲内存空间来提升算法运行速度的思路称为「以空间换时间」;反之,称之为「以时间换空间」。选择哪种思路取决于我们更看重哪个方面。大多数情况下,内存空间不会成为算法瓶颈,因此以空间换时间更加常用。 + +## 示例题目 * + +以 LeetCode 全站第一题 [两数之和](https://leetcode.cn/problems/two-sum/) 为例,「暴力枚举」和「辅助哈希表」分别为 **空间最优** 和 **时间最优** 的两种解法。本着时间比空间更宝贵的原则,后者是本题的最佳解法。 + +### 方法一:暴力枚举 + +时间复杂度 $O(N^2)$ ,空间复杂度 $O(1)$ ,属于「时间换空间」。 + +虽然仅使用常数大小的额外空间,但运行速度过慢。 + +=== "Java" + + ```java title="" title="leetcode_two_sum.java" + public int[] twoSum(int[] nums, int target) { + int size = nums.length; + // 外层 * 内层循环,时间复杂度为 O(n) + 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" + + ``` + +=== "Python" + + ```python title="leetcode_two_sum.py" + + ``` + +### 方法二:辅助哈希表 + +时间复杂度 $O(N)$ ,空间复杂度 $O(N)$ ,属于「空间换时间」。 + +借助辅助哈希表 dic ,通过保存数组元素与索引的映射来提升算法运行速度。 + +=== "Java" + + ```java title="" title="leetcode_two_sum.java" + public int[] twoSum(int[] nums, int target) { + int size = nums.length; + // 辅助哈希表,空间复杂度 O(n) + MapFig. 算法 A, B, C 的时间增长趋势
+ +相比直接统计算法运行时间,时间复杂度分析的做法有什么好处呢?以及有什么不足? + +**时间复杂度可以有效评估算法效率。** 算法 `B` 运行时间的增长是线性的,在 $n > 1$ 时慢于算法 `A` ,在 $n > 1000000$ 时慢于算法 `C` 。实质上,只要输入数据大小 $n$ 足够大,复杂度为「常数阶」的算法一定优于「线性阶」的算法,这也正是时间增长趋势的含义。 + +**时间复杂度分析将统计「计算操作的运行时间」简化为统计「计算操作的数量」。** 这是因为,无论是运行平台、还是计算操作类型,都与算法运行时间的增长趋势无关。因此,我们可以简单地将所有计算操作的执行时间统一看作是相同的 “单位时间” 。 + +**时间复杂度也存在一定的局限性。** 比如,虽然算法 `A` 和 `C` 的时间复杂度相同,但是实际的运行时间有非常大的差别。再比如,虽然算法 `B` 比 `C` 的时间复杂度要更高,但在输入数据大小 $n$ 比较小时,算法 `B` 是要明显优于算法 `C` 的。即使存在这些问题,计算复杂度仍然是评判算法效率的最有效、最常用方法。 + +## 函数渐进上界 + +设算法「计算操作数量」为 $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="" + + ``` + +=== "Python" + + ```python 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)) + $$ + +![asymptotic_upper_bound](time_complexity.assets/asymptotic_upper_bound.png) + +Fig. 函数的渐进上界
+ +本质上看,计算渐进上界就是在找一个函数 $f(n)$ ,**使得在 $n$ 趋向于无穷大时,$T(n)$ 和 $f(n)$ 处于相同的增长级别(仅相差一个常数项 $c$ 的倍数)**。 + +!!! tip + + 渐进上界的数学味儿有点重,如果你感觉没有完全理解,无需担心,因为在实际使用中我们只需要会推算即可,数学意义可以慢慢领悟。 + +## 推算方法 + +推算出 $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="" + + ``` + +=== "Python" + + ```python title="" + + ``` + +### 2. 判断渐进上界 + +**时间复杂度由多项式 $T(n)$ 中最高阶的项来决定**。这是因为在 $n$ 趋于无穷大时,最高阶的项将处于主导作用,其它项的影响都可以被忽略。 + +以下表格给出了一些例子,其中有一些夸张的值,是想要向大家强调 **系数无法撼动阶数** 这一结论。在 $n$ 趋于无穷大时,这些常数都是 “浮云” 。 + +Fig. 时间复杂度的常见类型
+ +!!! tip + + 部分示例代码需要一些前置知识,包括数组、递归算法等。如果遇到看不懂的地方无需担心,可以在学习完后面章节后再来复习,现阶段先聚焦在理解时间复杂度含义和推算方法上。 + +### 常数阶 $O(1)$ + +常数阶的操作数量与输入数据大小 $n$ 无关,即不随着 $n$ 的变化而变化。 + +对于以下算法,无论操作数量 `size` 有多大,只要与数据大小 $n$ 无关,时间复杂度就仍为 $O(1)$ 。 + +=== "Java" + + ```java title="" title="time_complexity_types.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_types.cpp" + + ``` + +=== "Python" + + ```python title="time_complexity_types.py" + + ``` + +### 线性阶 $O(n)$ + +线性阶的操作数量相对输入数据大小成线性级别增长。线性阶常出现于单层循环。 + +=== "Java" + + ```java title="" title="time_complexity_types.java" + /* 线性阶 */ + int linear(int n) { + int count = 0; + for (int i = 0; i < n; i++) + count++; + return count; + } + ``` + +=== "C++" + + ```cpp title="time_complexity_types.cpp" + + ``` + +=== "Python" + + ```python title="time_complexity_types.py" + + ``` + +「遍历数组」和「遍历链表」等操作,时间复杂度都为 $O(n)$ ,其中 $n$ 为数组或链表的长度。 + +!!! tip + + **数据大小 $n$ 是根据输入数据的类型来确定的。** 比如,在上述示例中,我们直接将 $n$ 看作输入数据大小;以下遍历数组示例中,数据大小 $n$ 为数组的长度。 + +=== "Java" + + ```java title="" title="time_complexity_types.java" + /* 线性阶(遍历数组) */ + int arrayTraversal(int[] nums) { + int count = 0; + // 循环次数与数组长度成正比 + for (int num : nums) { + count++; + } + return count; + } + ``` + +=== "C++" + + ```cpp title="time_complexity_types.cpp" + + ``` + +=== "Python" + + ```python title="time_complexity_types.py" + + ``` + +### 平方阶 $O(n^2)$ + +平方阶的操作数量相对输入数据大小成平方级别增长。平方阶常出现于嵌套循环,外层循环和内层循环都为 $O(n)$ ,总体为 $O(n^2)$ 。 + +=== "Java" + + ```java title="" title="time_complexity_types.java" + /* 平方阶 */ + int quadratic(int n) { + int count = 0; + for (int i = 0; i < n; i++) { + for (int j = 0; j < n; j++) { + count++; + } + } + return count; + } + ``` + +=== "C++" + + ```cpp title="time_complexity_types.cpp" + + ``` + +=== "Python" + + ```python title="time_complexity_types.py" + + ``` + +![time_complexity_constant_linear_quadratic](time_complexity.assets/time_complexity_constant_linear_quadratic.png) + +Fig. 常数阶、线性阶、平方阶的时间复杂度
+ +以「冒泡排序」为例,外层循环 $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="" title="time_complexity_types.java" + /* 平方阶(冒泡排序) */ + void bubbleSort(int[] nums) { + int n = nums.length; + for (int i = 0; i < n - 1; i++) { + for (int j = 0; j < n - 1 - 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="time_complexity_types.cpp" + + ``` + +=== "Python" + + ```python title="time_complexity_types.py" + + ``` + +### 指数阶 $O(2^n)$ + +!!! note + + 生物学科中的 “细胞分裂” 即是指数阶增长:初始状态为 $1$ 个细胞,分裂一轮后为 $2$ 个,分裂两轮后为 $4$ 个,……,分裂 $n$ 轮后有 $2^n$ 个细胞。 + +指数阶增长地非常快,在实际应用中一般是不能被接受的。若一个问题使用「暴力枚举」求解的时间复杂度是 $O(2^n)$ ,那么一般都需要使用「动态规划」或「贪心算法」等算法来求解。 + +=== "Java" + + ```java title="" title="time_complexity_types.java" + /* 指数阶(遍历实现) */ + int exponential(int n) { + int count = 0, base = 1; + // cell 每轮一分为二,形成数列 1, 2, 4, 8, ..., 2^(n-1) + for (int i = 0; i < n; i++) { + for (int j = 0; j < base; j++) { + count++; + } + base *= 2; + } + // count = 1 + 2 + 4 + 8 + .. + 2^(n-1) = 2^n - 1 + return count; + } + ``` + +=== "C++" + + ```cpp title="time_complexity_types.cpp" + + ``` + +=== "Python" + + ```python title="time_complexity_types.py" + + ``` + +![time_complexity_exponential](time_complexity.assets/time_complexity_exponential.png) + +Fig. 指数阶的时间复杂度
+ +在实际算法中,指数阶常出现于递归函数。例如以下代码,不断地一分为二,分裂 $n$ 次后停止。 + +=== "Java" + + ```java title="" title="time_complexity_types.java" + /* 指数阶(递归实现) */ + int expRecur(int n) { + if (n == 1) return 1; + return expRecur(n - 1) + expRecur(n - 1) + 1; + } + ``` + +=== "C++" + + ```cpp title="time_complexity_types.cpp" + + ``` + +=== "Python" + + ```python title="time_complexity_types.py" + + ``` + +### 对数阶 $O(\log n)$ + +对数阶与指数阶正好相反,后者反映 “每轮增加到两倍的情况” ,而前者反映 “每轮缩减到一半的情况” 。对数阶仅次于常数阶,时间增长的很慢,是理想的时间复杂度。 + +对数阶常出现于「二分查找」和「分治算法」中,体现 “一分为多” 、“化繁为简” 的算法思想。 + +设输入数据大小为 $n$ ,由于每轮缩减到一半,因此循环次数是 $\log_2 n$ ,即 $2^n$ 的反函数。 + +=== "Java" + + ```java title="" title="time_complexity_types.java" + /* 对数阶(循环实现) */ + int logarithmic(float n) { + int count = 0; + while (n > 1) { + n = n / 2; + count++; + } + return count; + } + ``` + +=== "C++" + + ```cpp title="time_complexity_types.cpp" + + ``` + +=== "Python" + + ```python title="time_complexity_types.py" + + ``` + +![time_complexity_logarithmic](time_complexity.assets/time_complexity_logarithmic.png) + +Fig. 对数阶的时间复杂度
+ +与指数阶类似,对数阶也常出现于递归函数。以下代码形成了一个高度为 $\log_2 n$ 的递归树。 + +=== "Java" + + ```java title="" title="time_complexity_types.java" + /* 对数阶(递归实现) */ + int logRecur(float n) { + if (n <= 1) return 0; + return logRecur(n / 2) + 1; + } + ``` + +=== "C++" + + ```cpp title="time_complexity_types.cpp" + + ``` + +=== "Python" + + ```python title="time_complexity_types.py" + + ``` + +### 线性对数阶 $O(n \log n)$ + +线性对数阶常出现于嵌套循环中,两层循环的时间复杂度分别为 $O(\log n)$ 和 $O(n)$ 。 + +主流排序算法的时间复杂度都是 $O(n \log n )$ ,例如快速排序、归并排序、堆排序等。 + +=== "Java" + + ```java title="" title="time_complexity_types.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_types.cpp" + + ``` + +=== "Python" + + ```python title="time_complexity_types.py" + + ``` + +![time_complexity_logarithmic_linear](time_complexity.assets/time_complexity_logarithmic_linear.png) + +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="" title="time_complexity_types.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_types.cpp" + + ``` + +=== "Python" + + ```python title="time_complexity_types.py" + + ``` + +![time_complexity_factorial](time_complexity.assets/time_complexity_factorial.png) + +Fig. 阶乘阶的时间复杂度
+ +## 最差、最佳、平均时间复杂度 + +**某些算法的时间复杂度不是恒定的,而是与输入数据的分布有关。** 举一个例子,输入一个长度为 $n$ 数组 `nums` ,其中 `nums` 由从 $1$ 至 $n$ 的数字组成,但元素顺序是随机打乱的;算法的任务是返回元素 $1$ 的索引。我们可以得出以下结论: + +- 当 `nums = [?, ?, ..., 1]`,即当末尾元素是 $1$ 时,则需完整遍历数组,此时达到 **最差时间复杂度 $O(n)$** ; +- 当 `nums = [1, ?, ?, ...]` ,即当首个数字为 $1$ 时,无论数组多长都不需要继续遍历,此时达到 **最佳时间复杂度 $\Omega(1)$** ; + +「函数渐进上界」使用大 $O$ 记号表示,代表「最差时间复杂度」。与之对应,「函数渐进下界」用 $\Omega$ 记号(Omega Notation)来表示,代表「最佳时间复杂度」。 + +=== "Java" + + ```java title="" title="worst_best_time_complexity.java" + public class worst_best_time_complexity { + /* 生成一个数组,元素为 { 1, 2, ..., n },顺序被打乱 */ + static 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 所在索引 */ + static int findOne(int[] nums) { + for (int i = 0; i < nums.length; i++) { + if (nums[i] == 1) + return i; + } + return -1; + } + + /* Driver Code */ + public static void main(String[] args) { + for (int i = 0; i < 10; i++) { + int n = 100; + int[] nums = randomNumbers(n); + int index = findOne(nums); + System.out.println("打乱后的数组为 " + Arrays.toString(nums)); + System.out.println("数字 1 的索引为 " + index); + } + } + } + ``` + +=== "C++" + + ```cpp title="worst_best_time_complexity.cpp" + + ``` + +=== "Python" + + ```python title="worst_best_time_complexity.py" + + ``` + +!!! tip + + 我们在实际应用中很少使用「最佳时间复杂度」,因为往往只有很小概率下才能达到,会带来一定的误导性。反之,「最差时间复杂度」最为实用,因为它给出了一个 “效率安全值” ,让我们可以放心地使用算法。 + +从上述示例可以看出,最差或最佳时间复杂度只出现在 “特殊分布的数据” 中,这些情况的出现概率往往很小,因此并不能最真实地反映算法运行效率。**相对地,「平均时间复杂度」可以体现算法在随机输入数据下的运行效率,用 $\Theta$ 记号(Theta Notation)来表示**。 + +对于部分算法,我们可以简单地推算出随机数据分布下的平均情况。比如上述示例,由于输入数组是被打乱的,因此元素 $1$ 出现在任意索引的概率都是相等的,那么算法的平均循环次数则是数组长度的一半 $\frac{n}{2}$ ,平均时间复杂度为 $\Theta(\frac{n}{2}) = \Theta(n)$ 。 + +但在实际应用中,尤其是较为复杂的算法,计算平均时间复杂度比较困难,因为很难简便地分析出在数据分布下的整体数学期望。这种情况下,我们一般使用最差时间复杂度来作为算法效率的评判标准。 diff --git a/docs/chapter_data_structure/classification_of_data_strcuture.assets/classification_logic_structure.png b/docs/chapter_data_structure/classification_of_data_strcuture.assets/classification_logic_structure.png new file mode 100644 index 000000000..3158e6a79 Binary files /dev/null and b/docs/chapter_data_structure/classification_of_data_strcuture.assets/classification_logic_structure.png differ diff --git a/docs/chapter_data_structure/classification_of_data_strcuture.assets/classification_phisical_structure.png b/docs/chapter_data_structure/classification_of_data_strcuture.assets/classification_phisical_structure.png new file mode 100644 index 000000000..697627b41 Binary files /dev/null and b/docs/chapter_data_structure/classification_of_data_strcuture.assets/classification_phisical_structure.png differ diff --git a/docs/chapter_data_structure/classification_of_data_strcuture.md b/docs/chapter_data_structure/classification_of_data_strcuture.md new file mode 100644 index 000000000..832c3a8a2 --- /dev/null +++ b/docs/chapter_data_structure/classification_of_data_strcuture.md @@ -0,0 +1,43 @@ +--- +comments: true +--- + +# 数据结构分类 + +数据结构主要可根据「逻辑结构」和「物理结构」两种角度进行分类。 + +## 逻辑结构:线性与非线性 + +**「逻辑结构」反映了数据之间的逻辑关系。** 数组和链表的数据按照顺序依次排列,反映了数据间的线性关系;树从顶至底按层级排列,反映了祖先与后代之间的派生关系;图由结点和边组成,反映了复杂网络关系。 + +我们一般将逻辑结构分为「线性」和「非线性」两种。“线性” 这个概念很直观,即表明数据在逻辑关系上是排成一条线的;而如果数据之间的逻辑关系是非线形的(例如是网状或树状的),那么就是非线性数据结构。 + +- **线性数据结构:** 数组、链表、栈、队列、哈希表; +- **非线性数据结构:** 树、图、堆、哈希表; + +![classification_logic_structure](classification_of_data_strcuture.assets/classification_logic_structure.png) + +Fig. 线性与非线性数据结构
+ +## 物理结构:连续与离散 + +!!! note + + 若感到阅读困难,建议先看完下个章节「数组与链表」,再回过头来理解物理结构的含义。 + +**「物理结构」反映了数据在计算机内存中的存储方式。** 从本质上看,分别是 **数组的连续空间存储** 和 **链表的离散空间存储** 。物理结构从底层上决定了数据的访问、更新、增删等操作方法,在时间效率和空间效率方面呈现出此消彼长的特性。 + +![classification_phisical_structure](classification_of_data_strcuture.assets/classification_phisical_structure.png) + +Fig. 连续空间存储与离散空间存储
+ +**所有数据结构都是基于数组、或链表、或两者组合实现的。** 例如栈和队列,既可以使用数组实现、也可以使用链表实现,而例如哈希表,其实现同时包含了数组和链表。 + +- **基于数组可实现:** 栈、队列、堆、哈希表、矩阵、张量(维度 $\geq 3$ 的数组)等; +- **基于链表可实现:** 栈、队列、堆、哈希表、树、图等; + +基于数组实现的数据结构也被称为「静态数据结构」,这意味着该数据结构在在被初始化后,长度不可变。相反地,基于链表实现的数据结构被称为「动态数据结构」,该数据结构在被初始化后,我们也可以在程序运行中修改其长度。 + +!!! tip + + 数组与链表是其他所有数据结构的 “底层积木”,建议读者一定要多花些时间了解。 diff --git a/docs/chapter_data_structure/data_and_memory.assets/computer_memory_location.png b/docs/chapter_data_structure/data_and_memory.assets/computer_memory_location.png new file mode 100644 index 000000000..2111f4903 Binary files /dev/null and b/docs/chapter_data_structure/data_and_memory.assets/computer_memory_location.png differ diff --git a/docs/chapter_data_structure/data_and_memory.md b/docs/chapter_data_structure/data_and_memory.md new file mode 100644 index 000000000..1282e899b --- /dev/null +++ b/docs/chapter_data_structure/data_and_memory.md @@ -0,0 +1,81 @@ +--- +comments: true +--- + +# 数据与内存 + +## 基本数据类型 + +谈到计算机中的数据,我们能够想到文本、图片、视频、语音、3D 模型等等,这些数据虽然组织形式不同,但是有一个共同点,即都是由各种基本数据类型构成的。 + +**「基本数据类型」是 CPU 可以直接进行运算的类型,在算法中直接被使用。** + +- 「整数」根据不同的长度分为 byte, short, int, long ,根据算法需求选用,即在满足取值范围的情况下尽量减小内存空间占用。 +- 「浮点数」代表小数,根据长度分为 float, double ,同样根据算法的实际需求选用。 +- 「字符」在计算机中是以字符集的形式保存的,char 的值实际上是数字,代表字符集中的编号,计算机通过字符集查表来完成编号到字符的转换。 +- 「布尔」代表逻辑中的 ”是“ 与 ”否“ ,其占用空间需要具体根据编程语言确定,通常为 1 byte 或 1 bit 。 + +!!! note "字节与比特" + + 1 字节 (byte) = 8 比特 (bit) , 1 比特即最基本的 1 个二进制位 + +Table. Java 的基本数据类型
+ +Fig. 内存条、内存空间、内存地址
+ +**内存资源是设计数据结构与算法的重要考虑因素。** 内存是所有程序的公共资源,当内存被某程序占用时,不能被其它程序同时使用。我们需要根据剩余内存资源的情况来设计算法。例如,若剩余内存空间有限,则要求算法占用的峰值内存不能超过系统剩余内存;若运行的程序很多、缺少大块连续的内存空间,则要求选取的数据结构必须能够存储在离散的内存空间内。 diff --git a/docs/chapter_data_structure/summary.md b/docs/chapter_data_structure/summary.md new file mode 100644 index 000000000..e51e4dc5b --- /dev/null +++ b/docs/chapter_data_structure/summary.md @@ -0,0 +1,11 @@ +--- +comments: true +--- + +# 小结 + +- 整数 byte, short, int, long 、浮点数 float, double 、字符 char 、布尔 boolean 是计算机中的基本数据类型,占用空间的大小决定了它们的取值范围。 +- 在程序运行时,数据存储在计算机的内存中。内存中每块空间都有独立的内存地址,程序是通过内存地址来访问数据的。 +- 数据结构主要可以从逻辑结构和物理结构两个角度进行分类。逻辑结构反映了数据中元素之间的逻辑关系,物理结构反映了数据在计算机内存中的存储形式。 +- 常见的逻辑结构有线性、树状、网状等。我们一般根据逻辑结构将数据结构分为线性(数组、链表、栈、队列)和非线性(树、图、堆)两种。根据实现方式的不同,哈希表可能是线性或非线性。 +- 物理结构主要有两种,分别是连续空间存储(数组)和离散空间存储(链表),所有的数据结构都是由数组、或链表、或两者组合实现的。 diff --git a/docs/chapter_dsa_introduction/index.assets/look_up_dictionary.png b/docs/chapter_dsa_introduction/index.assets/look_up_dictionary.png new file mode 100644 index 000000000..0a24f6153 Binary files /dev/null and b/docs/chapter_dsa_introduction/index.assets/look_up_dictionary.png differ diff --git a/docs/chapter_dsa_introduction/index.assets/look_up_dictionary_step_1.png b/docs/chapter_dsa_introduction/index.assets/look_up_dictionary_step_1.png new file mode 100644 index 000000000..b76e3db0c Binary files /dev/null and b/docs/chapter_dsa_introduction/index.assets/look_up_dictionary_step_1.png differ diff --git a/docs/chapter_dsa_introduction/index.assets/look_up_dictionary_step_2.png b/docs/chapter_dsa_introduction/index.assets/look_up_dictionary_step_2.png new file mode 100644 index 000000000..08f0bf2d2 Binary files /dev/null and b/docs/chapter_dsa_introduction/index.assets/look_up_dictionary_step_2.png differ diff --git a/docs/chapter_dsa_introduction/index.assets/look_up_dictionary_step_3.png b/docs/chapter_dsa_introduction/index.assets/look_up_dictionary_step_3.png new file mode 100644 index 000000000..6b91df4e5 Binary files /dev/null and b/docs/chapter_dsa_introduction/index.assets/look_up_dictionary_step_3.png differ diff --git a/docs/chapter_dsa_introduction/index.assets/look_up_dictionary_step_4.png b/docs/chapter_dsa_introduction/index.assets/look_up_dictionary_step_4.png new file mode 100644 index 000000000..fd3f43333 Binary files /dev/null and b/docs/chapter_dsa_introduction/index.assets/look_up_dictionary_step_4.png differ diff --git a/docs/chapter_dsa_introduction/index.assets/look_up_dictionary_step_5.png b/docs/chapter_dsa_introduction/index.assets/look_up_dictionary_step_5.png new file mode 100644 index 000000000..4eff15e80 Binary files /dev/null and b/docs/chapter_dsa_introduction/index.assets/look_up_dictionary_step_5.png differ diff --git a/docs/chapter_dsa_introduction/index.assets/relationship_between_data_structure_and_algorithm.png b/docs/chapter_dsa_introduction/index.assets/relationship_between_data_structure_and_algorithm.png new file mode 100644 index 000000000..82e610a96 Binary files /dev/null and b/docs/chapter_dsa_introduction/index.assets/relationship_between_data_structure_and_algorithm.png differ diff --git a/docs/chapter_dsa_introduction/index.md b/docs/chapter_dsa_introduction/index.md new file mode 100644 index 000000000..aeff024a2 --- /dev/null +++ b/docs/chapter_dsa_introduction/index.md @@ -0,0 +1,84 @@ +--- +comments: true +--- + +# 算法是什么 + +听到 “算法” 这个词,我们一般会联想到数学。但实际上,大多数算法并不包含复杂的数学,而更像是在考察基本逻辑,而这些逻辑在我们日常生活中处处可见。 + +在正式介绍算法之前,我想告诉你一件有趣的事:**其实,你在过去已经学会了很多算法,并且已经习惯将它们应用到日常生活中。** 接下来,我将介绍两个具体例子来佐证。 + +**例一:拼积木。** 一套积木,除了有许多部件之外,还会附送详细的拼装说明书。我们按照说明书上一步步操作,即可拼出复杂的积木模型。 + +如果从数据结构与算法的角度看,大大小小的「积木」就是数据结构,而「拼装说明书」上的一系列步骤就是算法。 + +**例二:查字典。** 在字典中,每个汉字都有一个对应的拼音,而字典是按照拼音的英文字母表顺序排列的。假设需要在字典中查询任意一个拼音首字母为 $r$ 的字,一般我们会这样做: + +1. 打开字典大致一半页数的位置,查看此页的首字母是什么(假设为 $m$ ); +2. 由于在英文字母表中 $r$ 在 $m$ 的后面,因此应排除字典前半部分,查找范围仅剩后半部分; +3. 循环执行步骤 1-2 ,直到找到拼音首字母为 $r$ 的页码时终止。 + +=== "Step 1" + + ![look_up_dictionary_step_1](index.assets/look_up_dictionary_step_1.png) + +=== "Step 2" + + ![look_up_dictionary_step_2](index.assets/look_up_dictionary_step_2.png) + +=== "Step 3" + + ![look_up_dictionary_step_3](index.assets/look_up_dictionary_step_3.png) + +=== "Step 4" + + ![look_up_dictionary_step_4](index.assets/look_up_dictionary_step_4.png) + +=== "Step 5" + + ![look_up_dictionary_step_5](index.assets/look_up_dictionary_step_5.png) + +查字典这个小学生的标配技能,实际上就是大名鼎鼎的「二分查找」。从数据结构角度,我们可以将字典看作是一个已排序的「数组」;而从算法角度,我们可将上述查字典的一系列指令看作是「二分查找」算法。 + +小到烹饪一道菜、大到星际航行,几乎所有问题的解决都离不开算法。计算机的出现,使我们可以通过编程将数据结构存储在内存中,也可以编写代码来调用 CPU, GPU 执行算法,从而将生活中的问题搬运到计算机中,更加高效地解决各式各样的复杂问题。 + +!!! tip + + 读到这里,如果你感到对数据结构、算法、数组、二分查找等此类概念一知半解,那么就太好了!因为这正是本书存在的价值,接下来,本书将会一步步地引导你进入数据结构与算法的知识殿堂。 + +## 算法是什么? + +「算法 Algorithm」是在有限时间内解决问题的一组指令或操作步骤。算法具有以下特性: + +- 问题是明确的,需要拥有明确的输入和输出定义。 +- 解具有确定性,即给定相同输入时,输出一定相同。 +- 具有可行性,可在有限步骤、有限时间、有限内存空间下完成。 +- 独立于编程语言,即可用多种语言实现。 + +## 数据结构是什么? + +「数据结构 Data Structure」是在计算机中组织与存储数据的方式。为了提高数据存储和操作性能,数据结构的设计原则有: + +- 空间占用尽可能小,节省计算机内存。 +- 数据操作尽量快,包括数据访问、添加、删除、更新等。 +- 提供简洁的数据表示和逻辑信息,以便算法高效运行。 + +数据结构的设计是一个充满权衡的过程,这意味着如果获得某方面的优势,则往往需要在另一方面做出妥协。例如,链表相对于数组,数据添加删除操作更加方便,但牺牲了数据的访问速度;图相对于链表,提供了更多的逻辑信息,但需要占用更多的内存空间。 + +## 数据结构与算法的关系 + +「数据结构」与「算法」是高度相关、紧密嵌合的,体现在: + +- 数据结构是算法的底座。数据结构为算法提供结构化存储的数据,以及操作数据的对应方法。 +- 算法是发挥数据结构优势的舞台。数据结构仅存储数据信息,结合算法才可解决特定问题。 +- 算法有对应最优的数据结构。给定算法,一般可基于不同的数据结构实现,而最终执行效率往往相差很大。 + +如果将数据结构与算法比作「LEGO 乐高」,数据结构就是乐高「积木」,而算法就是把积木拼成目标形态的一系列「操作步骤」。 + +![relationship_between_data_structure_and_algorithm](index.assets/relationship_between_data_structure_and_algorithm.png) + +Fig. 数据结构与算法的关系
+ +!!! tip "约定俗成的习惯" + + 在实际讨论中,我们通常会将「数据结构与算法」简称为「算法」。例如,我们熟称的 LeetCode 算法题目,实际上同时考察了数据结构和算法两部分知识。 diff --git a/docs/chapter_introduction/contribution.assets/edit_markdown.png b/docs/chapter_introduction/contribution.assets/edit_markdown.png new file mode 100644 index 000000000..02fc6318d Binary files /dev/null and b/docs/chapter_introduction/contribution.assets/edit_markdown.png differ diff --git a/docs/chapter_introduction/contribution.md b/docs/chapter_introduction/contribution.md new file mode 100644 index 000000000..d54963aa9 --- /dev/null +++ b/docs/chapter_introduction/contribution.md @@ -0,0 +1,46 @@ +--- +comments: true +--- + +# 一起参与创作 + +由于作者水平有限,书中内容难免疏漏谬误,请您谅解。此外,希望您可以一同参与到本书的内容创作中来。如果你发现笔误、无效链接、内容缺失、文字歧义、解释不清晰、行文结构不合理等问题,烦请您帮忙修正内容,以帮助其他读者获取更优质的学习内容。 + +!!! quote "" + + 纸质书籍的两次印刷的间隔时间往往需要数年,内容更新非常不方便。但在本开源 HTML 书中,内容更迭的时间被缩短至数日甚至几个小时。 + +## 修改文字 + +每个页面的右上角都有一个「编辑」按钮,你可以按照以下步骤修改文章: + +1. 点击编辑按钮,如果遇到提示 “需要 Fork 此仓库” ,请通过; +2. 修改 Markdown 源文件内容; +3. 在页面底部填写更改说明,然后单击 “Propose file change” 按钮; +4. 页面跳转后,点击 “Create pull request” 按钮发起拉取请求即可,我会第一时间查看处理并及时更新内容。 + +![edit_markdown](contribution.assets/edit_markdown.png) + +## 修改图片 + +书中的配图无法直接修改,需要通过以下途径提出修改意见: + +1. 新建一个 Issue ,将需要修改的图片复制或截图,粘贴在面板中; +2. 描述图片问题,应如何修改; +3. 提交 Issue 即可,我会第一时间重新画图并替换图片。 + +## 修改代码 + +若发现代码源文件有错误,可以本地修改并提交 Pull Request : + +1. 登录 GitHub ,并 Fork [本仓库](https://github.com/krahets/hello-algo) 至个人账号; +2. 使用 Git 克隆 Fork 的仓库至本地(Git 安装教程见上节 “编程环境安装” ); +3. 在本地修改 `.java` , `.cpp` , `.py` 文件中的代码,并运行测试;测试完毕后,请同步修改 Markdown 文件中的对应代码; +5. 将本地更新 Commit ,并 Push 至远程仓库; +6. 刷新仓库网页,点击 “Create pull request” 按钮发起拉取请求即可; + +(TODO:教学视频) + +## 创作新内容 + +「修改代码」的流程是完整的,还可以用来 **重写某章节、新增章节、翻译代码至其他编程语言** 等。非常欢迎您和我一同来创作本书! diff --git a/docs/chapter_introduction/index.assets/learning_route.png b/docs/chapter_introduction/index.assets/learning_route.png new file mode 100644 index 000000000..7423808d6 Binary files /dev/null and b/docs/chapter_introduction/index.assets/learning_route.png differ diff --git a/docs/chapter_introduction/index.assets/mindmap.png b/docs/chapter_introduction/index.assets/mindmap.png new file mode 100644 index 000000000..a1c83838f Binary files /dev/null and b/docs/chapter_introduction/index.assets/mindmap.png differ diff --git a/docs/chapter_introduction/index.md b/docs/chapter_introduction/index.md new file mode 100644 index 000000000..6355dfe51 --- /dev/null +++ b/docs/chapter_introduction/index.md @@ -0,0 +1,109 @@ +--- +comments: true +--- + +# 关于本书 + +在 2018 年 10 月发生了一件事,成为了我职业生涯的重要转折点。当时的我在交大读研,对互联网求职一无所知,但仍然硬着头皮申请了 Microsoft 的软件工程师实习。在二面中,面试官让我在白板上写出 “快速排序” 代码,我摇了摇头,畏畏缩缩地写了一个 “冒泡排序” ,并且还写错了。从面试官的表情上,我看到了一个大大的 Game Over 。 + +从那次失利开始,找工作的压力就倒逼我开始刷算法题。我采用 “扫雷游戏” 式的学习方法,两眼一抹黑刷题,扫到不会的 “雷” 就通过查资料把它解决掉,配合周期性的总结,逐渐形成了数据结构与算法的知识图景。幸运地,我在 2020 年秋招斩获了多家大厂的 Offer 。 + +之后,分享题解成为了我的爱好。经常刷题的同学可能遇见过一个顶着路飞笑脸头像,名为「Krahets」的力扣 ID ,那便是我。截至目前,我已在力扣(LeetCode)上分享了近 100 道题目解析,累积了 1700 万阅读量,回复了数千条读者的评论和问题,并编写了 LeetBook《图解算法数据结构》,已免费售出 21 万多本。 + +回想自己当初在 “扫雷式” 刷题中被炸的满头包的痛苦,我意识到有一本 “刷题前必看” 的读物可以使算法小白少走许多弯路,而这正与我的擅长点契合。强烈的写作意愿席卷而来,那就来吧: + +追风赶月莫停留,平芜尽处是春山
+一起加油!
+ +## 内容结构 + +本书主要内容分为复杂度分析、数据结构、算法三个部分。 + +![mindmap](index.assets/mindmap.png) + +Fig. 知识点思维导图
+ +### 复杂度分析 + +首先介绍数据结构与算法的评价维度,以及算法效率评估方法,引出了计算复杂度概念。 + +接下来,介绍了函数渐进上界的含义,并分别介绍了时间复杂度和空间复杂度的定义、推算方法、常见类型等,以及最差、最佳、平均时间复杂度的区别。 + +### 数据结构 + +首先介绍了物理结构和逻辑结构两种数据结构的分类方法,随后介绍了各个数据结构,包括数组、链表、栈、队列、树、堆、图、散列表等,内容包括: + +- 基本定义:数据结构的设计来源、存在意义; +- 主要特点:在各项数据操作中的优势、劣势; +- 常用操作:例如访问、更新、插入、删除、遍历、搜索等; +- 常见类型:在算法题或工程实际中,经常碰到的数据结构类型; +- 典型应用:此数据结构经常搭配哪些算法使用; +- 实现方法:对于重要的数据结构,将给出完整的实现示例; + +### 算法 + +介绍了常见的算法类型,包括查找算法、排序算法、搜索与回溯算法、动态规划、分治算法等,主要关心以下内容: + +- 基本定义:算法的设计思想; +- 主要特点:使用前置要求、优势和劣势; +- 算法效率:最差和平均时间复杂度、空间复杂度; +- 应用场景:结合例题讲述算法应用; + +## 配套代码 + +完整可运行的代码在 GitHub 仓库 [![github-stars](https://img.shields.io/github/stars/krahets/hello-algo?style=social)](https://github.com/krahets/hello-algo) ,你可以使用 Git 将仓库 Clone 至本地,也可以下载仓库压缩包使用。 + +编程环境部署和代码使用方法请见下章 “编程环境安装” 和 “算法学习建议” 。 + +## 风格约定 + +- 文章中的重要名词会用「」符号标注,例如「数组 Array」。这类名词应该被牢记,英文翻译也建议记住,以便后续查阅资料时使用。 +- 重点内容、总起句、总结句会被 **加粗** ,此类文字值得更多关注。 +- 专有名词和有特指含义的词句会使用 “ ” 标注,以避免歧义。 +- 标题后标注 * 符号的是选读章节,如果你的时间有限,可以先跳过这些章节。 + +## 本书特点 + +**以实践为主。** 我们知道,学习英语期间光啃书本是远远不够的,需要多听、多说、多写,在实践中培养语感、积累经验。编程语言也是一门语言,因此学习方法也应是类似的,需要多看优秀代码、多敲键盘、多思考代码逻辑。 + +本书的理论部分占少量篇幅,主要分为两类:一是基础且必要的概念知识,以培养读者对于算法的感性认识;二是重要的分类、对比或总结,这是为了帮助你站在更高视角俯瞰各个知识点,形成连点成面的效果。 + +实践部分主要由示例和代码组成。代码配有简要注释,复杂示例会尽可能地使用视觉化的形式呈现。我强烈建议读者对照着代码自己敲一遍,如果时间有限,也至少逐行读、复制并运行一遍,配合着讲解将代码吃透。 + +!!! quote + + “Talk is cheap. Show me the code.” ― Linus Torvalds + + “少吹牛,看代码” + +**视觉化学习。** 信息时代以来,视觉化的脚步从未停止。媒体形式经历了文字短信、图文 Email 、动图、短(长)视频、交互式 Web 、3D 游戏等演变过程,信息的视觉化程度越来越高、愈加符合人类感官、信息传播效率大大提升。科技界也在向视觉化迈进,iPhone 就是一个典型例子,其相对于传统手机是高度视觉化的,包含精心设计的字体、主题配色、交互动画等。 + +近两年,短视频成为最受欢迎的信息媒介,可以在短时间内将高密度的信息 “灌” 给我们,有着极其舒适的观看体验。阅读则不然,读者与书本之间天然存在一种 “疏离感”,我们看书会累、会走神、会停下来想其他事、会划下喜欢的句子、会思考某一片段的含义,这种疏离感给了读者与书本之间对话的可能,拓宽了想象空间。 + +本书作为一本入门教材,希望可以保有书本的 “慢节奏” ,但也会避免与读者产生过多 “疏离感” ,而是努力将知识完整清晰地推送到你聪明的小脑袋瓜中。我将采用视觉化的方式(例如配图、动画),尽我可能清晰易懂地讲解复杂概念和抽象示例。 + +!!! quote + + “A picture is worth a thousand words.” + + “一图胜千言” + +**内容精简化。** 大多数的经典教科书,会把每个主题都讲的很透彻。虽然透彻性正是其获得读者青睐的原因,但对于想要快速入门的初学者来说,这些教材的实用性不足。本书会避免引入非必要的概念、名词、定义等,也避免展开不必要的理论分析,毕竟这不是一本真正意义上的教材,主要任务是尽快地带领读者入门。 + +引入一些生活案例或趣味内容,非常适合作为知识点的引子或者解释的补充,但当融入过多额外元素时,内容会稍显冗长,也许反而使读者容易迷失、抓不住重点,这也是本书需要避免的。 + +敲代码如同写字,“美” 是统一的追求。本书力求美观的代码,保证规范的变量命名、统一的空格与换行、对齐的缩进、整齐的注释等。 + +## 致谢 + +(TODO) diff --git a/docs/chapter_introduction/installation.assets/image-20221117201957848.png b/docs/chapter_introduction/installation.assets/image-20221117201957848.png new file mode 100644 index 000000000..fdb2c2f62 Binary files /dev/null and b/docs/chapter_introduction/installation.assets/image-20221117201957848.png differ diff --git a/docs/chapter_introduction/installation.assets/image-20221118013006841.png b/docs/chapter_introduction/installation.assets/image-20221118013006841.png new file mode 100644 index 000000000..ae6f9aa29 Binary files /dev/null and b/docs/chapter_introduction/installation.assets/image-20221118013006841.png differ diff --git a/docs/chapter_introduction/installation.assets/image-20221118013751773.png b/docs/chapter_introduction/installation.assets/image-20221118013751773.png new file mode 100644 index 000000000..3bd4cf97a Binary files /dev/null and b/docs/chapter_introduction/installation.assets/image-20221118013751773.png differ diff --git a/docs/chapter_introduction/installation.assets/vscode_installation.png b/docs/chapter_introduction/installation.assets/vscode_installation.png new file mode 100644 index 000000000..77bd06d7f Binary files /dev/null and b/docs/chapter_introduction/installation.assets/vscode_installation.png differ diff --git a/docs/chapter_introduction/installation.md b/docs/chapter_introduction/installation.md new file mode 100644 index 000000000..a55703259 --- /dev/null +++ b/docs/chapter_introduction/installation.md @@ -0,0 +1,40 @@ +--- +comments: true +--- + +# 编程环境安装 + +## 安装 Git + +前往 [Git 官网](https://git-scm.com/downloads) 下载对应系统安装包,本地安装即可。 + +## 下载代码仓 + +如果已经安装 Git ,可以打开一个命令行,输入以下命令克隆代码仓。 + +```shell +git clone https://github.com/krahets/hello-algo.git +``` + +当然,你也可以不使用 Git ,直接点击 “Download ZIP” 下载压缩包并解压即可。 + +![image-20221118013006841](installation.assets/image-20221118013006841.png) + +## 安装 VSCode + +前往 [VSCode 官网](https://code.visualstudio.com/) 下载对应系统安装包,本地安装即可。 + +## Python 环境 + +1. 前往 [Miniconda3](https://docs.conda.io/en/latest/miniconda.html) ,选择对应系统安装包,下载并安装。 +2. 在 VSCode 中的插件市场中搜索 Python ,安装 Python Extension Pack 。 + +## Java 环境 + +1. 前往 [OpenJDK](https://jdk.java.net/18/) ,选择对应系统安装包,下载并安装。 +2. 在 VSCode 中的插件市场中搜索 Java ,安装 Java Extension Pack 。 + +## C++ 环境 + +1. Windows 系统需要安装 MinGW 。 +2. 在 VSCode 中的插件市场中搜索 c++ ,安装 C/C++ Extension Pack 。 diff --git a/docs/chapter_introduction/suggestions.assets/algorithm_animation.gif b/docs/chapter_introduction/suggestions.assets/algorithm_animation.gif new file mode 100644 index 000000000..5db93eeee Binary files /dev/null and b/docs/chapter_introduction/suggestions.assets/algorithm_animation.gif differ diff --git a/docs/chapter_introduction/suggestions.assets/comment.gif b/docs/chapter_introduction/suggestions.assets/comment.gif new file mode 100644 index 000000000..d64787ac3 Binary files /dev/null and b/docs/chapter_introduction/suggestions.assets/comment.gif differ diff --git a/docs/chapter_introduction/suggestions.assets/learning_route.png b/docs/chapter_introduction/suggestions.assets/learning_route.png new file mode 100644 index 000000000..7423808d6 Binary files /dev/null and b/docs/chapter_introduction/suggestions.assets/learning_route.png differ diff --git a/docs/chapter_introduction/suggestions.assets/md_code.png b/docs/chapter_introduction/suggestions.assets/md_code.png new file mode 100644 index 000000000..688f4a715 Binary files /dev/null and b/docs/chapter_introduction/suggestions.assets/md_code.png differ diff --git a/docs/chapter_introduction/suggestions.assets/running_code.gif b/docs/chapter_introduction/suggestions.assets/running_code.gif new file mode 100644 index 000000000..0d01ffbf8 Binary files /dev/null and b/docs/chapter_introduction/suggestions.assets/running_code.gif differ diff --git a/docs/chapter_introduction/suggestions.md b/docs/chapter_introduction/suggestions.md new file mode 100644 index 000000000..53963aaa4 --- /dev/null +++ b/docs/chapter_introduction/suggestions.md @@ -0,0 +1,47 @@ +--- +comments: true +--- + +# 算法学习建议 + +## 算法学习 “三步走” + +**第一阶段,算法入门,也正是本书的定位。** 熟悉各种数据结构的特点、用法,学习各种算法的工作原理、用途、效率等。 + +**第二阶段,刷算法题。** 可以先从热门题单开刷,推荐 [剑指 Offer](https://leetcode.cn/problem-list/xb9nqhhg/)、[LeetCode 热题 HOT 100](https://leetcode.cn/problem-list/2cktkvj/) ,先积累至少 100 道题量,熟悉大多数的算法问题。刚开始刷题时,“遗忘” 是最大的困扰点,但这是很正常的,请不要担心。学习中有一种概念叫 “周期性回顾” ,同一道题隔段时间做一次,当做了三遍以上,往往就能牢记于心了。 + +**第三阶段,搭建知识体系。** 在学习方面,可以阅读算法专栏文章、解题框架、算法教材,不断地丰富知识体系。在刷题方面,可以开始采用进阶刷题方案,例如按专题分类、一题多解、一解多题等,刷题方案在社区中可以找到一些讲解,在此不做赘述。 + +![learning_route](suggestions.assets/learning_route.png) + +## 图文搭配学 + +对比大部分教材,本书更倾向于以结构化的方式介绍知识。视频和图片相比于文字的信息密度和结构化程度更高,更容易让人理解。在本书中,重点和难点知识会主要以动画、图解的形式呈现,而文字的作用则是作为动画和图的解释与补充。 + +在阅读本书的过程中,若发现某段内容提供了动画或图解,**建议以图为主线,将文字内容(一般在图的上方)对齐到图中内容,综合来理解**。 + +![algorithm_animation](suggestions.assets/algorithm_animation.gif) + +## 运行源代码 + +编程是门实践技术,知识内容最终都会落地到一行一行的代码上。本书提供配套 Java, C++, Python 代码仓。**若学习时间紧张,请至少将所有代码通读并运行一遍;在时间允许下,强力建议你对照着代码自己敲一遍,逐渐锻炼肌肉记忆,相比于读代码,写的过程也会带来新的收获。** + +代码栏上若标有 `*.java` , `*.cpp` , `*.py` ,则可在仓库 codes 文件夹中找到对应的 **代码源文件** 。 + +![md_code](suggestions.assets/md_code.png) + +这些源文件中包含测试样例,可以直接运行,帮助你省去不必要的调试时间,可以将精力集中在学习内容上。 + +![running_code](suggestions.assets/running_code.gif) + +!!! tip + + 若你的 PC 仍没有编程环境,可以参照下节「编程环境安装」的内容进行安装。 + +## 参与讨论区 + +阅读本书时,请不要 “惯着” 那些弄不明白的知识点。如果有任何疑惑,可以在评论区留下你的问题,小伙伴们和我都会给予解答(我一般 2 ~ 3 天看一次评论区)。 + +同时,也希望你可以多花时间逛逛评论区。一方面,可以看看大家遇到了什么问题,反过来查漏补缺,这往往可以引起更加深度的思考。另一方面,也希望你可以慷慨地解答小伙伴们的问题、分享自己的见解,大家一起加油与进步! + +![comment](suggestions.assets/comment.gif) diff --git a/docs/chapter_reference/index.md b/docs/chapter_reference/index.md new file mode 100644 index 000000000..fcc5da56f --- /dev/null +++ b/docs/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/docs/chapter_searching/binary_search.assets/binary_search_step1.png b/docs/chapter_searching/binary_search.assets/binary_search_step1.png new file mode 100644 index 000000000..6c26916e7 Binary files /dev/null and b/docs/chapter_searching/binary_search.assets/binary_search_step1.png differ diff --git a/docs/chapter_searching/binary_search.assets/binary_search_step2.png b/docs/chapter_searching/binary_search.assets/binary_search_step2.png new file mode 100644 index 000000000..6f100faf9 Binary files /dev/null and b/docs/chapter_searching/binary_search.assets/binary_search_step2.png differ diff --git a/docs/chapter_searching/binary_search.assets/binary_search_step3.png b/docs/chapter_searching/binary_search.assets/binary_search_step3.png new file mode 100644 index 000000000..afc4b2ad3 Binary files /dev/null and b/docs/chapter_searching/binary_search.assets/binary_search_step3.png differ diff --git a/docs/chapter_searching/binary_search.assets/binary_search_step4.png b/docs/chapter_searching/binary_search.assets/binary_search_step4.png new file mode 100644 index 000000000..71988301b Binary files /dev/null and b/docs/chapter_searching/binary_search.assets/binary_search_step4.png differ diff --git a/docs/chapter_searching/binary_search.assets/binary_search_step5.png b/docs/chapter_searching/binary_search.assets/binary_search_step5.png new file mode 100644 index 000000000..8cd4c64b7 Binary files /dev/null and b/docs/chapter_searching/binary_search.assets/binary_search_step5.png differ diff --git a/docs/chapter_searching/binary_search.assets/binary_search_step6.png b/docs/chapter_searching/binary_search.assets/binary_search_step6.png new file mode 100644 index 000000000..a82323c25 Binary files /dev/null and b/docs/chapter_searching/binary_search.assets/binary_search_step6.png differ diff --git a/docs/chapter_searching/binary_search.assets/binary_search_step7.png b/docs/chapter_searching/binary_search.assets/binary_search_step7.png new file mode 100644 index 000000000..f34257e38 Binary files /dev/null and b/docs/chapter_searching/binary_search.assets/binary_search_step7.png differ diff --git a/docs/chapter_searching/binary_search.md b/docs/chapter_searching/binary_search.md new file mode 100644 index 000000000..58b7bcb53 --- /dev/null +++ b/docs/chapter_searching/binary_search.md @@ -0,0 +1,154 @@ +--- +comments: true +--- + +# 二分查找 + +「二分查找 Binary Search」利用数据的有序性,通过每轮缩小一半搜索区间来查找目标元素。 + +使用二分查找有两个前置条件: + +- **要求输入数据是有序的**,这样才能通过判断大小关系来排除一半的搜索区间; +- **二分查找仅适用于数组** ,而在链表中使用效率很低,因为其在循环中需要跳跃式(非连续地)访问元素。 + +## 算法实现 + +给定一个长度为 $n$ 的排序数组 `nums` ,元素从小到大排列。数组的索引取值范围为 + +$$ +0, 1, 2, \cdots, n-1 +$$ + +使用「区间」来表示这个取值范围的方法主要有两种: + +1. **双闭区间 $[0, n-1]$** ,即两个边界都包含自身;此方法下,区间 $[0, 0]$ 仍包含一个元素; +2. **左闭右开 $[0, n)$** ,即左边界包含自身、右边界不包含自身;此方法下,区间 $[0, 0)$ 为空; + +### “双闭区间” 实现 + +首先,我们先采用 “双闭区间” 的表示,在数组 `nums` 中查找目标元素 `target` 的对应索引。 + +=== "Step 1" + + ![binary_search_step1](binary_search.assets/binary_search_step1.png) + +=== "Step 2" + + ![binary_search_step2](binary_search.assets/binary_search_step2.png) + +=== "Step 3" + + ![binary_search_step3](binary_search.assets/binary_search_step3.png) + +=== "Step 4" + + ![binary_search_step4](binary_search.assets/binary_search_step4.png) + +=== "Step 5" + + ![binary_search_step5](binary_search.assets/binary_search_step5.png) + +=== "Step 6" + + ![binary_search_step6](binary_search.assets/binary_search_step6.png) + +=== "Step 7" + + ![binary_search_step7](binary_search.assets/binary_search_step7.png) + +二分查找 “双闭区间” 表示下的代码如下所示。 + +=== "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; + } + ``` + +### “左闭右开” 实现 + +当然,我们也可以使用 “左闭右开” 的表示方法,写出相同功能的二分查找代码。 + +=== "Java" + + ```java title="binary_search.java" + /* 二分查找(左闭右开) */ + int binarySearch1(int[] nums, int target) { + // 初始化左闭右开 [0, n) ,即 i, j 分别指向数组首元素、尾元素+1 + int i = 0, j = nums.length; + // 循环,当搜索区间为空时跳出(当 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) 中 + j = m; + else // 找到目标元素,返回其索引 + return m; + } + // 未找到目标元素,返回 -1 + return -1; + } + ``` + +### 两种表示对比 + +对比下来,两种表示的代码写法有以下不同点: + +Table. 三种查找方法对比
+ +Fig. 双向队列的操作
+ +## 双向队列常用操作 + +双向队列的常用操作见下表,方法名需根据编程语言设定来具体确定。 + +Table. 双向队列的常用操作
+ +Fig. 队列的先入先出特性
+ +## 队列常用操作 + +队列的常用操作见下表,方法命名需根据编程语言的设定来具体确定。 + +Table. 队列的常用操作
+ +Fig. 栈的先入后出特性
+ +## 栈常用操作 + +栈的常用操作见下表,方法名需根据编程语言设定来具体确定。 + +Table. 栈的常用操作
+ +Fig. 子结点与子树
+ +需要注意,父结点、子结点、子树是可以向下递推的。例如,如果将上图的「结点 2」看作父结点,那么其左子节点和右子结点分别为「结点 4」和「结点 5」,左子树和右子树分别为「结点 4 以下的树」和「结点 5 以下的树」。 + +## 二叉树常见术语 + +「根结点 Root Node」:二叉树最顶层的结点,其没有父结点; + +「叶结点 Leaf Node」:没有子结点的结点,其两个指针都指向 $\text{null}$ ; + +结点「度 Degree」:结点的子结点数量,二叉树中度的范围是 0, 1, 2 ; + +结点「深度 Depth」 :根结点到该结点的层数; + +结点「高度 Height」:最远叶结点到该结点的层数; + +二叉树「高度」:二叉树中根结点到最远叶结点的层数; + +![binary_tree_terminology](binary_tree.assets/binary_tree_terminology.png) + +Fig. 二叉树的常见术语
+ +## 二叉树最佳和最差结构 + +当二叉树的每层的结点都被填满时,达到「完美二叉树」;而当所有结点都偏向一边时,二叉树退化为「链表」。 + +![binary_tree_corner_cases](binary_tree.assets/binary_tree_corner_cases.png) + +Fig. 二叉树的最佳和最差结构
+ +在最佳和最差结构下,二叉树的结点数量和高度等性质达到最大(最小)值。 + +Fig. 在二叉树中插入与删除结点
+ +```java title="binary_tree.java" +TreeNode P = new TreeNode(0); +// 在 n1 -> n2 中间插入结点 P +n1.left = P; +P.left = n2; +// 删除结点 P +n1.left = n2; +``` + +!!! note + + 插入结点会改变二叉树的原有逻辑结构,删除结点往往意味着删除了该结点的所有子树。因此,二叉树中的插入与删除一般都是由一套操作配合完成的,这样才能实现有意义的操作。 + +## 二叉树遍历 + +非线性数据结构的遍历操作比线性数据结构更加复杂,往往需要使用搜索算法来实现。常见的二叉树遍历方式有层序遍历、前序遍历、中序遍历、后序遍历。 + +### 层序遍历 + +「层序遍历 Hierarchical-Order Traversal」从顶至底、一层一层地遍历二叉树,并在每层中按照从左到右的顺序访问结点。 + +层序遍历本质上是「广度优先搜索 Breadth-First Traversal」,其体现着一种 “一圈一圈向外” 的层进遍历方式。 + +![binary_tree_bfs](binary_tree.assets/binary_tree_bfs.png) + +Fig. 二叉树的层序遍历
+ +广度优先遍历一般借助「队列」来实现。队列的规则是 “先进先出” ,广度优先遍历的规则是 ”一层层平推“ ,两者背后的思想是一致的。 + +=== "Java" + + ```java title="binary_tree_bfs.java" + /* 层序遍历 */ + ListFig. 二叉树的前 / 中 / 后序遍历
+ +