Update docs for deployment on Vercel.

This commit is contained in:
krahets 2022-11-22 17:47:26 +08:00
parent eec011d595
commit 33d79ea6da
124 changed files with 3964 additions and 4 deletions

3
.gitignore vendored
View file

@ -7,8 +7,5 @@
# mkdocs files
site/
.cache/
overrides/
codes/python
codes/cpp
docs/chapter_*

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

View file

@ -0,0 +1,222 @@
---
comments: true
---
# 数组
「数组 Array」是一种将 **相同类型元素** 存储在 **连续内存空间** 的数据结构,将元素在数组中的位置称为元素的「索引 Index」。
![array_definition](array.assets/array_definition.png)
<p style="text-align:center"> Fig. 数组定义与存储方式 </p>
!!! 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)
<p style="text-align:center"> Fig. 数组元素的内存地址计算 </p>
```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)
<p style="text-align:center"> Fig. 在数组中插入与删除元素 </p>
=== "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"
```
## 数组典型应用
**随机访问。** 如果我们想要随机抽取一些样本,那么可以用数组存储,并生成一个随机序列,根据索引实现样本的随机抽取。
**二分查找。** 例如前文查字典的例子,我们可以将字典中的所有字按照拼音顺序存储在数组中,然后使用与日常查纸质字典相同的 “翻开中间,排除一半” 的方式,来实现一个查电子字典的算法。
**深度学习。** 神经网络中大量使用了向量、矩阵、张量之间的线性代数运算,这些数据都是以数组的形式构建的。数组是神经网络编程中最常使用的数据结构。

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

View file

@ -0,0 +1,222 @@
---
comments: true
---
# 链表
!!! note "引言"
内存空间是所有程序的公共资源,排除已占用的内存,空闲内存往往是散落在内存各处的。我们知道,存储数组需要内存空间连续,当我们需要申请一个很大的数组时,系统不一定存在这么大的连续内存空间。而链表则更加灵活,不需要内存是连续的,只要剩余内存空间大小够用即可。
「链表 Linked List」是一种线性数据结构其中每个元素都是单独的对象各个元素一般称为结点之间通过指针连接。由于结点中记录了连接关系因此链表的存储方式相比于数组更加灵活系统不必保证内存地址的连续性。
链表的「结点 Node」包含两项数据一是结点「值 Value」二是指向下一结点的「指针 Pointer」或称「引用 Reference」
![linkedlist_definition](linked_list.assets/linkedlist_definition.png)
<p style="text-align:center"> Fig. 链表定义与存储方式 </p>
=== "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)
<p style="text-align:center"> Fig. 在链表中插入与删除结点 </p>
=== "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)
<p style="text-align:center"> Fig. 常见链表类型 </p>

View file

@ -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 };
List<Integer> list = new ArrayList<>(Arrays.asList(numbers));
```
=== "C++"
```cpp title="list.cpp"
```
=== "Python"
```python title="list.py"
```
**访问与更新元素。** 列表的底层数据结构是数组,因此可以在 $O(1)$ 时间内访问与更新元素,效率很高。
=== "Java"
```java title="list.java"
/* 访问元素 */
int num = list.get(1); // 访问索引 1 处的元素
/* 更新元素 */
list.set(1, 0); // 将索引 1 处的元素更新为 0
```
=== "C++"
```cpp title="list.cpp"
```
=== "Python"
```python title="list.py"
```
**在列表中添加、插入、删除元素。** 相对于数组,列表可以自由地添加与删除元素。在列表尾部添加元素的时间复杂度为 $O(1)$ ,但是插入与删除元素的效率仍与数组一样低,时间复杂度为 $O(N)$ 。
=== "Java"
```java title="list.java"
/* 清空列表 */
list.clear();
/* 尾部添加元素 */
list.add(1);
list.add(3);
list.add(2);
list.add(5);
list.add(4);
/* 中间插入元素 */
list.add(3, 6); // 在索引 3 处插入数字 6
/* 删除元素 */
list.remove(3); // 删除索引 3 处的元素
```
=== "C++"
```cpp title="list.cpp"
```
=== "Python"
```python title="list.py"
```
**遍历列表。** 与数组一样,列表可以使用索引遍历,也可以使用 `for-each` 直接遍历。
=== "Java"
```java title="list.java"
/* 通过索引遍历列表 */
int count = 0;
for (int i = 0; i < list.size(); i++) {
count++;
}
/* 直接遍历列表元素 */
count = 0;
for (int n : list) {
count++;
}
```
=== "C++"
```cpp title="list.cpp"
```
=== "Python"
```python title="list.py"
```
**拼接两个列表。** 再创建一个新列表 `list1` ,我们可以将其中一个列表拼接到另一个的尾部。
=== "Java"
```java title="list.java"
/* 拼接两个列表 */
List<Integer> list1 = new ArrayList<>(Arrays.asList(new Integer[] { 6, 8, 7, 10, 9 }));
list.addAll(list1); // 将列表 list1 拼接到 list 之后
```
=== "C++"
```cpp title="list.cpp"
```
=== "Python"
```python title="list.py"
```
**排序列表。** 排序也是常用的方法之一,完成列表排序后,我们就可以使用在数组类算法题中经常考察的「二分查找」和「双指针」算法了。
=== "Java"
```java title="list.java"
/* 排序列表 */
Collections.sort(list); // 排序后,列表元素从小到大排列
```
=== "C++"
```cpp title="list.cpp"
```
=== "Python"
```python title="list.py"
```
## 列表简易实现 *
为了帮助加深对列表的理解,我们在此提供一个列表的简易版本的实现。需要关注三个核心点:
- **初始容量:** 选取一个合理的数组的初始容量 `initialCapacity` 。在本示例中,我们选择 10 作为初始容量。
- **数量记录:** 需要声明一个变量 `size` ,用来记录列表当前有多少个元素,并随着元素插入与删除实时更新。根据此变量,可以定位列表的尾部,以及判断是否需要扩容。
- **扩容机制:** 插入元素有可能导致超出列表容量,此时需要扩容列表,方法是建立一个更大的数组来替换当前数组。需要给定一个扩容倍数 `extendRatio` ,在本示例中,我们规定每次将数组扩容至之前的 2 倍。
本示例是为了帮助读者对如何实现列表产生直观的认识。实际编程语言中,列表的实现远比以下代码复杂且标准,感兴趣的读者可以查阅源码学习。
=== "Java"
```java title="my_list.java"
/* 列表类简易实现 */
class MyList {
int[] nums; // 数组(存储列表元素)
int initialCapacity = 10; // 列表初始容量
int size = 0; // 列表长度(即当前元素数量)
int extendRatio = 2; // 每次列表扩容的倍数
/* 构造函数 */
public MyList() {
nums = new int[initialCapacity];
}
/* 获取列表容量 */
public int size() {
return size;
}
/* 获取列表长度(即当前元素数量) */
public int capacity() {
return nums.length;
}
/* 访问元素 */
public int get(int index) {
// 索引如果越界则抛出异常,下同
if (index >= size)
throw new IndexOutOfBoundsException("索引越界");
return nums[index];
}
/* 更新元素 */
public void set(int index, int num) {
if (index >= size)
throw new IndexOutOfBoundsException("索引越界");
nums[index] = num;
}
/* 尾部添加元素 */
public void add(int num) {
// 元素数量超出容量时,触发扩容机制
if (size == nums.length)
extendCapacity();
nums[size] = num;
// 更新元素数量
size++;
}
/* 中间插入元素 */
public void add(int index, int num) {
if (index >= size)
throw new IndexOutOfBoundsException("索引越界");
// 元素数量超出容量时,触发扩容机制
if (size == nums.length)
extendCapacity();
// 索引 i 以及之后的元素都向后移动一位
for (int j = size - 1; j >= index; j--) {
nums[j + 1] = nums[j];
}
nums[index] = num;
// 更新元素数量
size++;
}
/* 删除元素 */
public void remove(int index) {
if (index >= size)
throw new IndexOutOfBoundsException("索引越界");
// 索引 i 之后的元素都向前移动一位
for (int j = index; j < size - 1; j++) {
nums[j] = nums[j + 1];
}
// 更新元素数量
size--;
}
/* 列表扩容 */
public void extendCapacity() {
// 新建一个长度为 size 的数组,并将原数组拷贝到新数组
nums = Arrays.copyOf(nums, nums.length * extendRatio);
}
}
```
=== "C++"
```cpp title="my_list.cpp"
```
=== "Python"
```python title="my_list.py"
```

View file

@ -0,0 +1,41 @@
---
comments: true
---
# 小结
- 数组和链表是两种基本数据结构,代表了数据在计算机内存中的两种存储方式,即连续空间存储和离散空间存储。两者的优缺点呈现出此消彼长的关系。
- 数组支持随机访问、内存空间占用小;但插入与删除元素效率低,且初始化后长度不可变。
- 链表可通过更改指针实现高效的结点插入与删除,并且可以灵活地修改长度;但结点访问效率低、占用内存多。常见的链表类型有单向链表、循环链表、双向链表。
- 列表又称动态数组,是基于数组实现的一种数据结构,其保存了数组的优势,且可以灵活改变长度。列表的出现大大提升了数组的实用性,但副作用是会造成部分内存空间浪费。
## 数组 VS 链表
<p style="text-align:center"> Table. 数组与链表特点对比 </p>
<div class="center-table" markdown>
| | 数组 | 链表 |
| ------------ | ------------------------ | ------------ |
| 存储方式 | 连续内存空间 | 离散内存空间 |
| 数据结构长度 | 长度不可变 | 长度可变 |
| 内存使用率 | 占用内存少、缓存局部性好 | 占用内存多 |
| 优势操作 | 随机访问 | 插入、删除 |
</div>
!!! tip
「缓存局部性Cache locality」涉及到了计算机操作系统在本书不做展开介绍建议有兴趣的同学 Google / Baidu 一下。
<p style="text-align:center"> Table. 数组与链表操作时间复杂度 </p>
<div class="center-table" markdown>
| 操作 | 数组 | 链表 |
| ------- | ------ | ------ |
| 访问元素 | $O(1)$ | $O(N)$ |
| 添加元素 | $O(N)$ | $O(1)$ |
| 删除元素 | $O(N)$ | $O(1)$ |
</div>

View file

@ -0,0 +1,43 @@
---
comments: true
---
# 算法效率评估
## 算法评价维度
在开始学习算法之前,我们首先要想清楚算法的设计目标是什么,或者说,如何来评判算法的好与坏。整体上看,我们设计算法时追求两个层面的目标。
1. **找到问题解法。** 算法需要能够在规定的输入范围下,可靠地求得问题的正确解。
2. **寻求最优解法。** 同一个问题可能存在多种解法,而我们希望算法效率尽可能的高。
换言之,在可以解决问题的前提下,算法效率则是主要评价维度,包括:
- **时间效率** ,即算法的运行速度的快慢。
- **空间效率** ,即算法占用的内存空间大小。
数据结构与算法追求 “运行地快、内存占用少” ,而如何去评价算法效率则是非常重要的问题。
## 效率评估方法
### 实际测试
假设我们现在有算法 A 和 算法 B ,都能够解决同一问题,现在需要对比两个算法之间的效率。我们能够想到的最直接的方式,就是找一台计算机,把两个算法都完整跑一遍,并监控记录运行时间和内存占用情况。这种评估方式能够反映真实情况,但是也存在很大的硬伤。
**难以排除测试环境的干扰因素。** 硬件配置会影响到算法的性能表现。例如,在某台计算机中,算法 A 比算法 B 运行时间更短;但换到另一台配置不同的计算机中,可能会得到相反的测试结果。这意味着我们需要在各种机器上展开测试,而这是不现实的。
**展开完整测试非常耗费资源。** 随着输入数据量的大小变化,算法会呈现出不同的效率表现。比如,有可能输入数据量较小时,算法 A 运行时间短于算法 B ,而在输入数据量较大时,测试结果截然相反。因此,若想要达到具有说服力的对比结果,那么需要输入各种体量数据,这样的测试需要占用大量计算资源。
### 理论估算
既然实际测试具有很大的局限性,那么我们是否可以仅通过一些计算,就获知算法的效率水平呢?答案是肯定的,我们将此估算方法称为「复杂度分析 Complexity Analysis」或「渐进复杂度分析 Asymptotic Complexity Analysis」。
**复杂度分析评估随着输入数据量的增长,算法的运行时间和占用空间的增长趋势** 。根据时间和空间两方面,复杂度可分为「时间复杂度 Time Complexity」和「空间复杂度 Space Complexity」。
**复杂度分析克服了实际测试方法的弊端。** 一是独立于测试环境,分析结果适用于所有运行平台。二是可以体现不同数据量下的算法效率,尤其是可以反映大数据量下的算法性能。
## 复杂度分析的重要性
复杂度分析给出一把评价算法效率的 “标尺” ,告诉我们执行某个算法需要多少时间和空间资源,也让我们可以开展不同算法之间的效率对比。
计算复杂度是个数学概念,对于初学者可能比较抽象,学习难度相对较高。从这个角度出发,其并不适合作为第一章内容。但是,当我们讨论某个数据结构或者算法的特点时,难以避免需要分析它的运行速度和空间使用情况。**因此,在展开学习数据结构与算法之前,建议读者先对计算复杂度建立起初步的了解,并且能够完成简单案例的复杂度分析**。

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

View file

@ -0,0 +1,361 @@
---
comments: true
---
# 空间复杂度
「空间复杂度 Space Complexity」统计 **算法使用内存空间随着数据量变大时的增长趋势** 。这个概念与时间复杂度很类似。
## 算法相关空间
算法运行中,使用的内存空间主要有以下几种:
- 「输入空间」用于存储算法的输入数据;
- 「暂存空间」用于存储算法运行中的变量、对象、函数上下文等数据;
- 「输出空间」用于存储算法的输出数据;
!!! tip
通常情况下,空间复杂度统计范围是「暂存空间」+「输出空间」。
暂存空间可分为三个部分:
- 「暂存数据」用于保存算法运行中的各种 **常量、变量、对象** 等。
- 「栈帧空间」用于保存调用函数的上下文数据。系统每次调用函数都会在栈的顶部创建一个栈帧,函数返回时,栈帧空间会被释放。
- 「指令空间」用于保存编译后的程序指令,**在实际统计中一般忽略不计**。
![space_types](space_complexity.assets/space_types.png)
<p style="text-align:center"> Fig. 算法使用的相关空间 </p>
=== "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)
<p style="text-align:center"> Fig. 空间复杂度的常见类型 </p>
!!! 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) 空间
List<ListNode> nodes = new ArrayList<>();
for (int i = 0; i < n; i++) {
nodes.add(new ListNode(i));
}
// 长度为 n 的哈希表占用 O(n) 空间
Map<Integer, String> map = new HashMap<>();
for (int i = 0; i < n; i++) {
map.put(i, String.valueOf(i));
}
}
```
=== "C++"
```cpp title="space_complexity_types.cpp"
```
=== "Python"
```python title="space_complexity_types.py"
```
以下递归函数会同时存在 $n$ 个未返回的 `algorithm()` 函数,使用 $O(n)$ 大小的栈帧空间。
=== "Java"
```java title="space_complexity_types.java"
/* 线性阶(递归实现) */
void linearRecur(int n) {
System.out.println("递归 n = " + n);
if (n == 1) return;
linearRecur(n - 1);
}
```
=== "C++"
```cpp title="space_complexity_types.cpp"
```
=== "Python"
```python title="space_complexity_types.py"
```
![space_complexity_recursive_linear](space_complexity.assets/space_complexity_recursive_linear.png)
<p style="text-align:center"> Fig. 递归函数产生的线性阶空间复杂度 </p>
### 平方阶 $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) 空间
List<List<Integer>> numList = new ArrayList<>();
for (int i = 0; i < n; i++) {
List<Integer> tmp = new ArrayList<>();
for (int j = 0; j < n; j++) {
tmp.add(0);
}
numList.add(tmp);
}
}
```
=== "C++"
```cpp title="space_complexity_types.cpp"
```
=== "Python"
```python title="space_complexity_types.py"
```
在以下递归函数中,同时存在 $n$ 个未返回的 `algorihtm()` ,并且每个函数中都初始化了一个数组,长度分别为 $n, n-1, n-2, ..., 2, 1$ ,平均长度为 $\frac{n}{2}$ ,因此总体使用 $O(n^2)$ 空间。
=== "Java"
```java title="space_complexity_types.java"
/* 平方阶(递归实现) */
int quadraticRecur(int n) {
if (n <= 0) return 0;
int[] nums = new int[n];
System.out.println("递归 n = " + n + " 中的 nums 长度 = " + nums.length);
return quadraticRecur(n - 1);
}
```
=== "C++"
```cpp title="space_complexity_types.cpp"
```
=== "Python"
```python title="space_complexity_types.py"
```
![space_complexity_recursive_quadratic](space_complexity.assets/space_complexity_recursive_quadratic.png)
<p style="text-align:center"> Fig. 递归函数产生的平方阶空间复杂度 </p>
### 指数阶 $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)
<p style="text-align:center"> Fig. 满二叉树下的指数阶空间复杂度 </p>
### 对数阶 $O(\log n)$
对数阶常见于分治算法、数据类型转换等。
例如「归并排序」,长度为 $n$ 的数组可以形成高度为 $\log n$ 的递归树,因此空间复杂度为 $O(\log n)$ 。
再例如「数字转化为字符串」,输入任意正整数 $n$ ,它的位数为 $\log_{10} n$ ,即对应字符串长度为 $\log_{10} n$ ,因此空间复杂度为 $O(\log_{10} n) = O(\log n)$ 。

View file

@ -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)
Map<Integer, Integer> dic = new HashMap<>();
// 单层循环,时间复杂度 O(n)
for (int i = 0; i < size; i++) {
if (dic.containsKey(target - nums[i])) {
return new int[] { dic.get(target - nums[i]), i };
}
dic.put(nums[i], i);
}
return new int[0];
}
```
=== "C++"
```cpp title="leetcode_two_sum.cpp"
```
=== "Python"
```python title="leetcode_two_sum.py"
```

View file

@ -0,0 +1,28 @@
---
comments: true
---
# 小结
### 算法效率评估
- 「时间效率」和「空间效率」是算法性能的两个重要的评价维度。
- 我们可以通过「实际测试」来评估算法效率,但难以排除测试环境的干扰,并且非常耗费计算资源。
- 「复杂度分析」克服了实际测试的弊端,分析结果适用于所有运行平台,并且可以体现不同数据大小下的算法效率。
### 时间复杂度
- 「时间复杂度」统计算法运行时间随着数据量变大时的增长趋势,可以有效评估算法效率,但在某些情况下可能失效,比如在输入数据量较小或时间复杂度相同时,无法精确对比算法效率的优劣性。
- 「最差时间复杂度」使用大 $O$ 符号表示,即函数渐进上界,其反映当 $n$ 趋于正无穷时,$T(n)$ 处于何种增长级别。
- 推算时间复杂度分为两步,首先统计计算操作数量,再判断渐进上界。
- 常见时间复杂度从小到大排列有 $O(1)$ , $O(\log n)$ , $O(n)$ , $O(n \log n)$ , $O(n^2)$ , $O(2^n)$ , $O(n!)$ 。
- 某些算法的时间复杂度不是恒定的,而是与输入数据的分布有关。时间复杂度分为「最差时间复杂度」和「最佳时间复杂度」,后者几乎不用,因为输入数据需要满足苛刻的条件才能达到最佳情况。
- 「平均时间复杂度」可以反映在随机数据输入下的算法效率,最贴合实际使用情况下的算法性能。计算平均时间复杂度需要统计输入数据的分布,以及综合后的数学期望。
### 空间复杂度
- 与时间复杂度的定义类似,「空间复杂度」统计算法占用空间随着数据量变大时的增长趋势。
- 算法运行中相关内存空间可分为输入空间、暂存空间、输出空间。通常情况下,空间复杂度不计入输入空间。暂存空间可分为指令空间、数据空间、栈帧空间,其中栈帧空间一般在递归函数中才会影响到空间复杂度。
- 我们一般只关心「最差空间复杂度」,即统计算法在「最差输入数据」和「最差运行时间点」下的空间复杂度。
- 常见空间复杂度从小到大排列有 $O(1)$ , $O(\log n)$ , $O(n)$ , $O(n^2)$ , $O(2^n)$ 。

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

View file

@ -0,0 +1,695 @@
---
comments: true
---
# 时间复杂度
## 统计算法运行时间
运行时间能够直观且准确地体现出算法的效率水平。如果我们想要 **准确预估一段代码的运行时间** ,该如何做呢?
1. 首先需要 **确定运行平台** ,包括硬件配置、编程语言、系统环境等,这些都会影响到代码的运行效率。
2. 评估 **各种计算操作的所需运行时间** ,例如加法操作 `+` 需要 1 ns ,乘法操作 `*` 需要 10 ns ,打印操作需要 5 ns 等。
3. 根据代码 **统计所有计算操作的数量** ,并将所有操作的执行时间求和,即可得到运行时间。
例如以下代码,输入数据大小为 $n$ ,根据以上方法,可以得到算法运行时间为 $6n + 12$ ns 。
$$
1 + 1 + 10 + (1 + 5) \times n = 6n + 12
$$
=== "Java"
```java title=""
// 在某运行平台下
void algorithm(int n) {
int a = 2; // 1 ns
a = a + 1; // 1 ns
a = a * 2; // 10 ns
// 循环 n 次
for (int i = 0; i < n; i++) { // 1 ns 每轮都要执行 i++
System.out.println(0); // 5 ns
}
}
```
=== "C++"
```cpp title=""
```
=== "Python"
```python title=""
```
但实际上, **统计算法的运行时间既不合理也不现实。** 首先,我们不希望预估时间和运行平台绑定,毕竟算法需要跑在各式各样的平台之上。其次,我们很难获知每一种操作的运行时间,这为预估过程带来了极大的难度。
## 统计时间增长趋势
「时间复杂度分析」采取了不同的做法,其统计的不是算法运行时间,而是 **算法运行时间随着数据量变大时的增长趋势**
“时间增长趋势” 这个概念比较抽象,我们借助一个例子来理解。设输入数据大小为 $n$ ,给定三个算法 `A` , `B` , `C`
- 算法 `A` 只有 $1$ 个打印操作,算法运行时间不随着 $n$ 增大而增长。我们称此算法的时间复杂度为「常数阶」。
- 算法 `B` 中的打印操作需要循环 $n$ 次,算法运行时间随着 $n$ 增大成线性增长。此算法的时间复杂度被称为「线性阶」。
- 算法 `C` 中的打印操作需要循环 $1000000$ 次,但运行时间仍与输入数据大小 $n$ 无关。因此 `C` 的时间复杂度和 `A` 相同,仍为「常数阶」。
=== "Java"
```java title=""
// 算法 A 时间复杂度:常数阶
void algorithm_A(int n) {
System.out.println(0);
}
// 算法 B 时间复杂度:线性阶
void algorithm_B(int n) {
for (int i = 0; i < n; i++) {
System.out.println(0);
}
}
// 算法 C 时间复杂度:常数阶
void algorithm_C(int n) {
for (int i = 0; i < 1000000; i++) {
System.out.println(0);
}
}
```
=== "C++"
```cpp title=""
```
=== "Python"
```python title=""
```
![time_complexity_first_example](time_complexity.assets/time_complexity_first_example.png)
<p style="text-align:center"> Fig. 算法 A, B, C 的时间增长趋势 </p>
相比直接统计算法运行时间,时间复杂度分析的做法有什么好处呢?以及有什么不足?
**时间复杂度可以有效评估算法效率。** 算法 `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)
<p style="text-align:center"> Fig. 函数的渐进上界 </p>
本质上看,计算渐进上界就是在找一个函数 $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$ 趋于无穷大时,这些常数都是 “浮云” 。
<div class="center-table" markdown>
| 操作数量 $T(n)$ | 时间复杂度 $O(f(n))$ |
| ---------------------- | -------------------- |
| $100000$ | $O(1)$ |
| $3n + 2$ | $O(n)$ |
| $2n^2 + 3n + 2$ | $O(n^2)$ |
| $n^3 + 10000n^2$ | $O(n^3)$ |
| $2^n + 10000n^{10000}$ | $O(2^n)$ |
</div>
## 常见类型
设输入数据大小为 $n$ ,常见的时间复杂度类型有(从低到高排列)
$$
\begin{aligned}
O(1) < O(\log n) < O(n) < O(n \log n) < O(n^2) < O(2^n) < O(n!) \newline
\text{常数阶} < \text{对数阶} < \text{线性阶} < \text{线性对数阶} < \text{平方阶} < \text{指数阶} < \text{阶乘阶}
\end{aligned}
$$
![time_complexity_common_types](time_complexity.assets/time_complexity_common_types.png)
<p style="text-align:center"> Fig. 时间复杂度的常见类型 </p>
!!! 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)
<p style="text-align:center"> Fig. 常数阶、线性阶、平方阶的时间复杂度 </p>
以「冒泡排序」为例,外层循环 $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)
<p style="text-align:center"> Fig. 指数阶的时间复杂度 </p>
在实际算法中,指数阶常出现于递归函数。例如以下代码,不断地一分为二,分裂 $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)
<p style="text-align:center"> Fig. 对数阶的时间复杂度 </p>
与指数阶类似,对数阶也常出现于递归函数。以下代码形成了一个高度为 $\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)
<p style="text-align:center"> Fig. 线性对数阶的时间复杂度 </p>
### 阶乘阶 $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)
<p style="text-align:center"> Fig. 阶乘阶的时间复杂度 </p>
## 最差、最佳、平均时间复杂度
**某些算法的时间复杂度不是恒定的,而是与输入数据的分布有关。** 举一个例子,输入一个长度为 $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)$ 。
但在实际应用中,尤其是较为复杂的算法,计算平均时间复杂度比较困难,因为很难简便地分析出在数据分布下的整体数学期望。这种情况下,我们一般使用最差时间复杂度来作为算法效率的评判标准。

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

View file

@ -0,0 +1,43 @@
---
comments: true
---
# 数据结构分类
数据结构主要可根据「逻辑结构」和「物理结构」两种角度进行分类。
## 逻辑结构:线性与非线性
**「逻辑结构」反映了数据之间的逻辑关系。** 数组和链表的数据按照顺序依次排列,反映了数据间的线性关系;树从顶至底按层级排列,反映了祖先与后代之间的派生关系;图由结点和边组成,反映了复杂网络关系。
我们一般将逻辑结构分为「线性」和「非线性」两种。“线性” 这个概念很直观,即表明数据在逻辑关系上是排成一条线的;而如果数据之间的逻辑关系是非线形的(例如是网状或树状的),那么就是非线性数据结构。
- **线性数据结构:** 数组、链表、栈、队列、哈希表;
- **非线性数据结构:** 树、图、堆、哈希表;
![classification_logic_structure](classification_of_data_strcuture.assets/classification_logic_structure.png)
<p style="text-align:center"> Fig. 线性与非线性数据结构 </p>
## 物理结构:连续与离散
!!! note
若感到阅读困难,建议先看完下个章节「数组与链表」,再回过头来理解物理结构的含义。
**「物理结构」反映了数据在计算机内存中的存储方式。** 从本质上看,分别是 **数组的连续空间存储****链表的离散空间存储** 。物理结构从底层上决定了数据的访问、更新、增删等操作方法,在时间效率和空间效率方面呈现出此消彼长的特性。
![classification_phisical_structure](classification_of_data_strcuture.assets/classification_phisical_structure.png)
<p style="text-align:center"> Fig. 连续空间存储与离散空间存储 </p>
**所有数据结构都是基于数组、或链表、或两者组合实现的。** 例如栈和队列,既可以使用数组实现、也可以使用链表实现,而例如哈希表,其实现同时包含了数组和链表。
- **基于数组可实现:** 栈、队列、堆、哈希表、矩阵、张量(维度 $\geq 3$ 的数组)等;
- **基于链表可实现:** 栈、队列、堆、哈希表、树、图等;
基于数组实现的数据结构也被称为「静态数据结构」,这意味着该数据结构在在被初始化后,长度不可变。相反地,基于链表实现的数据结构被称为「动态数据结构」,该数据结构在被初始化后,我们也可以在程序运行中修改其长度。
!!! tip
数组与链表是其他所有数据结构的 “底层积木”,建议读者一定要多花些时间了解。

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

View file

@ -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 个二进制位
<p style="text-align:center"> Table. Java 的基本数据类型 </p>
<div class="center-table" markdown>
| 类别 | 符号 | 占用空间 | 取值范围 | 默认值 |
| ------ | ----------- | ----------------- | ---------------------------------------------- | -------------- |
| 整数 | byte | 1 byte | $-2^7$ ~ $2^7 - 1$ ( $-128$ ~ $127$ ) | $0$ |
| | short | 2 bytes | $-2^{15}$ ~ $2^{15} - 1$ | $0$ |
| | **int** | 4 bytes | $-2^{31}$ ~ $2^{31} - 1$ | $0$ |
| | long | 8 bytes | $-2^{63}$ ~ $2^{63} - 1$ | $0$ |
| 浮点数 | **float** | 4 bytes | $-3.4 \times 10^{38}$ ~ $3.4 \times 10^{38}$ | $0.0$ f |
| | double | 8 bytes | $-1.7 \times 10^{308}$ ~ $1.7 \times 10^{308}$ | $0.0$ |
| 字符 | **char** | 2 bytes | $0$ ~ $2^{16} - 1$ | $0$ |
| 布尔 | **boolean(bool)** | 1 byte / 1 bit | $\text{true}$ 或 $\text{false}$ | $\text{false}$ |
</div>
!!! tip
以上表格中,加粗项在「算法题」中最为常用。此表格无需硬背,大致理解即可,需要时可以通过查表来回忆。
**「基本数据类型」与「数据结构」之间的联系与区别**
我们知道,数据结构是在计算机中 **组织与存储数据的方式** ,它的主语是 “结构” ,而不是 “数据” 。比如,我们想要表示 “一排数字” ,自然应该使用「数组」这个数据结构。数组的存储方式使之可以表示数字的相邻关系、先后关系等一系列我们需要的信息,但至于其中存储的是整数 int ,还是小数 float ,或是字符 char **则与所谓的数据的结构无关了**。
=== "Java"
```java
/* 使用多种「基本数据类型」来初始化「数组」 */
int[] numbers = new int[5];
float[] decimals = new float[5];
char[] characters = new char[5];
boolean[] booleans = new boolean[5];
```
=== "C++"
```cpp title=""
```
=== "Python"
```python title=""
```
## 计算机内存
在计算机中,内存和硬盘是两种主要的存储硬件设备。「硬盘」主要用于长期存储数据,容量较大(通常可达到 TB 级别)、速度较慢。「内存」用于运行程序时暂存数据,速度更快,但容量较小(通常为 GB 级别)。
**算法运行中,相关数据都被存储在内存中。** 下图展示了一个计算机内存条,其中每个黑色方块都包含一块内存空间。我们可以将内存想象成一个巨大的 Excel 表格,其中每个单元格都可以存储 1 byte 的数据,在算法运行时,所有数据都被存储在这些单元格中。
**系统通过「内存地址 Memory Location」来访问目标内存位置的数据。** 计算机根据特定规则给表格中每个单元格编号,保证每块内存空间都有独立的内存地址。自此,程序便通过这些地址,访问内存中的数据。
![computer_memory_location](data_and_memory.assets/computer_memory_location.png)
<p style="text-align:center"> Fig. 内存条、内存空间、内存地址 </p>
**内存资源是设计数据结构与算法的重要考虑因素。** 内存是所有程序的公共资源,当内存被某程序占用时,不能被其它程序同时使用。我们需要根据剩余内存资源的情况来设计算法。例如,若剩余内存空间有限,则要求算法占用的峰值内存不能超过系统剩余内存;若运行的程序很多、缺少大块连续的内存空间,则要求选取的数据结构必须能够存储在离散的内存空间内。

View file

@ -0,0 +1,11 @@
---
comments: true
---
# 小结
- 整数 byte, short, int, long 、浮点数 float, double 、字符 char 、布尔 boolean 是计算机中的基本数据类型,占用空间的大小决定了它们的取值范围。
- 在程序运行时,数据存储在计算机的内存中。内存中每块空间都有独立的内存地址,程序是通过内存地址来访问数据的。
- 数据结构主要可以从逻辑结构和物理结构两个角度进行分类。逻辑结构反映了数据中元素之间的逻辑关系,物理结构反映了数据在计算机内存中的存储形式。
- 常见的逻辑结构有线性、树状、网状等。我们一般根据逻辑结构将数据结构分为线性(数组、链表、栈、队列)和非线性(树、图、堆)两种。根据实现方式的不同,哈希表可能是线性或非线性。
- 物理结构主要有两种,分别是连续空间存储(数组)和离散空间存储(链表),所有的数据结构都是由数组、或链表、或两者组合实现的。

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

View file

@ -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)
<p style="text-align:center"> Fig. 数据结构与算法的关系 </p>
!!! tip "约定俗成的习惯"
在实际讨论中,我们通常会将「数据结构与算法」简称为「算法」。例如,我们熟称的 LeetCode 算法题目,实际上同时考察了数据结构和算法两部分知识。

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

View file

@ -0,0 +1,46 @@
---
comments: true
---
# 一起参与创作
由于作者水平有限,书中内容难免疏漏谬误,请您谅解。此外,希望您可以一同参与到本书的内容创作中来。如果你发现笔误、无效链接、内容缺失、文字歧义、解释不清晰、行文结构不合理等问题,烦请您帮忙修正内容,以帮助其他读者获取更优质的学习内容。
!!! quote ""
纸质书籍的两次印刷的间隔时间往往需要数年,内容更新非常不方便。</br>但在本开源 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 [<u>本仓库</u>](https://github.com/krahets/hello-algo) 至个人账号;
2. 使用 Git 克隆 Fork 的仓库至本地Git 安装教程见上节 “编程环境安装”
3. 在本地修改 `.java` , `.cpp` , `.py` 文件中的代码,并运行测试;测试完毕后,请同步修改 Markdown 文件中的对应代码;
5. 将本地更新 Commit ,并 Push 至远程仓库;
6. 刷新仓库网页,点击 “Create pull request” 按钮发起拉取请求即可;
TODO教学视频
## 创作新内容
「修改代码」的流程是完整的,还可以用来 **重写某章节、新增章节、翻译代码至其他编程语言** 等。非常欢迎您和我一同来创作本书!

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 MiB

View file

@ -0,0 +1,109 @@
---
comments: true
---
# 关于本书
在 2018 年 10 月发生了一件事,成为了我职业生涯的重要转折点。当时的我在交大读研,对互联网求职一无所知,但仍然硬着头皮申请了 Microsoft 的软件工程师实习。在二面中,面试官让我在白板上写出 “快速排序” 代码,我摇了摇头,畏畏缩缩地写了一个 “冒泡排序” ,并且还写错了。从面试官的表情上,我看到了一个大大的 Game Over 。
从那次失利开始,找工作的压力就倒逼我开始刷算法题。我采用 “扫雷游戏” 式的学习方法,两眼一抹黑刷题,扫到不会的 “雷” 就通过查资料把它解决掉,配合周期性的总结,逐渐形成了数据结构与算法的知识图景。幸运地,我在 2020 年秋招斩获了多家大厂的 Offer 。
之后分享题解成为了我的爱好。经常刷题的同学可能遇见过一个顶着路飞笑脸头像名为「Krahets」的力扣 ID 那便是我。截至目前我已在力扣LeetCode上分享了近 100 道题目解析,累积了 1700 万阅读量,回复了数千条读者的评论和问题,并编写了 LeetBook《图解算法数据结构》已免费售出 21 万多本。
回想自己当初在 “扫雷式” 刷题中被炸的满头包的痛苦,我意识到有一本 “刷题前必看” 的读物可以使算法小白少走许多弯路,而这正与我的擅长点契合。强烈的写作意愿席卷而来,那就来吧:
<h4 style="text-align:center"> Hello算法 </h4>
## 读者对象
- 数据结构与算法零基础的同学,但需具备 Java, C++, Python 任一语言的编程基础
- 具有一定算法刷题基础,想要系统地学习或复习数据结构与算法的同学
- 本书以代码实践为导向,尤其适合在职或即将成为工程师(程序员)的同学
!!! quote ""
<p style="text-align:center"> 追风赶月莫停留,平芜尽处是春山 </p>
<p style="text-align:center"> 一起加油! </p>
## 内容结构
本书主要内容分为复杂度分析、数据结构、算法三个部分。
![mindmap](index.assets/mindmap.png)
<p style="text-align:center"> Fig. 知识点思维导图 </p>
### 复杂度分析
首先介绍数据结构与算法的评价维度,以及算法效率评估方法,引出了计算复杂度概念。
接下来,介绍了函数渐进上界的含义,并分别介绍了时间复杂度和空间复杂度的定义、推算方法、常见类型等,以及最差、最佳、平均时间复杂度的区别。
### 数据结构
首先介绍了物理结构和逻辑结构两种数据结构的分类方法,随后介绍了各个数据结构,包括数组、链表、栈、队列、树、堆、图、散列表等,内容包括:
- 基本定义:数据结构的设计来源、存在意义;
- 主要特点:在各项数据操作中的优势、劣势;
- 常用操作:例如访问、更新、插入、删除、遍历、搜索等;
- 常见类型:在算法题或工程实际中,经常碰到的数据结构类型;
- 典型应用:此数据结构经常搭配哪些算法使用;
- 实现方法:对于重要的数据结构,将给出完整的实现示例;
### 算法
介绍了常见的算法类型,包括查找算法、排序算法、搜索与回溯算法、动态规划、分治算法等,主要关心以下内容:
- 基本定义:算法的设计思想;
- 主要特点:使用前置要求、优势和劣势;
- 算法效率:最差和平均时间复杂度、空间复杂度;
- 应用场景:结合例题讲述算法应用;
## 配套代码
完整可运行的代码在 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 750 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 271 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 285 KiB

View file

@ -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
前往 [<u>VSCode 官网</u>](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 。

Binary file not shown.

After

Width:  |  Height:  |  Size: 503 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 490 KiB

View file

@ -0,0 +1,47 @@
---
comments: true
---
# 算法学习建议
## 算法学习 “三步走”
**第一阶段,算法入门,也正是本书的定位。** 熟悉各种数据结构的特点、用法,学习各种算法的工作原理、用途、效率等。
**第二阶段,刷算法题。** 可以先从热门题单开刷,推荐 [<u>剑指 Offer</u>](https://leetcode.cn/problem-list/xb9nqhhg/)、[<u>LeetCode 热题 HOT 100</u>](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)

View file

@ -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).

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

View file

@ -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;
}
```
### 两种表示对比
对比下来,两种表示的代码写法有以下不同点:
<div class="center-table" markdown>
| 表示方法 | 初始化指针 | 缩小区间 | 循环终止条件 |
| ------------------- | ------------------- | ------------------------- | ------------ |
| 双闭区间 $[0, n-1]$ | $i = 0$ , $j = n-1$ | $i = m + 1$ , $j = m - 1$ | $i > j$ |
| 左闭右开 $[0, n)$ | $i = 0$ , $j = n$ | $i = m + 1$ , $j = m$ | $i = j$ |
</div>
观察发现,在 “双闭区间” 表示中,由于对左右两边界的定义是相同的,因此缩小区间的 $i$ , $j$ 处理方法也是对称的,这样更不容易出错。综上所述,**建议你采用 “双闭区间” 的写法。**
### 大数越界处理
当数组长度很大时,加法 $i + j$ 的结果有可能会超出 `int` 类型的取值范围。在此情况下,我们需要换一种计算中点的写法。
```java
// (i + j) 有可能超出 int 的取值范围
int m = (i + j) / 2;
// 更换为此写法则不会越界
int m = i + (j - i) / 2;
```
## 复杂度分析
**时间复杂度 $O(\log n)$ ** 其中 $n$ 为数组或链表长度;每轮排除一半的区间,因此循环轮数为 $\log_2 n$ ,使用 $O(\log n)$ 时间。
**空间复杂度 $O(1)$ ** 指针 `i` , `j` 使用常数大小空间。
## 优缺点
二分查找效率很高,体现在:
- **二分查找时间复杂度低。** 对数阶在数据量很大时具有巨大优势,例如,当数据大小 $n = 2^{20}$ 时,线性查找需要 $2^{20} = 1048576$ 轮循环,而二分查找仅需要 $\log_2 2^{20} = 20$ 轮循环。
- **二分查找不需要额外空间。** 相对于借助额外数据结构来实现查找的算法来说,其更加节约空间使用。
但并不意味着所有情况下都应使用二分查找,这是因为:
- **二分查找仅适用于有序数据。** 如果输入数据是乱序的,为了使用二分查找而专门执行数据排序,那么是得不偿失的,因为排序算法的时间复杂度一般为 $O(n \log n)$ ,比线性查找和二分查找都更差。再例如,对于频繁插入元素的场景,为了保持数组的有序性,需要将元素插入到特定位置,时间复杂度为 $O(n)$ ,也是非常昂贵的。
- **二分查找仅适用于数组。** 由于在二分查找中,访问索引是 ”非连续“ 的,因此链表或者基于链表实现的数据结构都无法使用。
- **在小数据量下,线性查找的性能更好。** 在线性查找中,每轮只需要 1 次判断操作;而在二分查找中,需要 1 次加法、1 次除法、1 ~ 3 次判断操作、1 次加法(减法),共 4 ~ 6 个单元操作;因此,在数据量 $n$ 较小时,线性查找反而比二分查找更快。

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

View file

@ -0,0 +1,60 @@
---
comments: true
---
# 哈希查找
!!! question
在数据量很大时,「线性查找」太慢;而「二分查找」要求数据必须是有序的,并且只能在数组中应用。那么是否有方法可以同时避免上述缺点呢?答案是肯定的,此方法被称为「哈希查找」。
「哈希查找 Hash Searching」借助一个哈希表来存储需要的「键值对 Key Value Pair」我们可以在 $O(1)$ 时间下实现 “键 $\rightarrow$ 值” 映射查找,体现着 “以空间换时间” 的算法思想。
## 算法实现
如果我们想要给定数组中的一个目标元素 `target` ,获取该元素的索引,那么可以借助一个哈希表实现查找。
![hash_search_index](hashing_search.assets/hash_search_index.png)
=== "Java"
```java title="hashing_search.java"
/* 哈希查找(数组) */
int hashingSearch(Map<Integer, Integer> map, int target) {
// 哈希表的 key: 目标元素value: 索引
// 若哈希表中无此 key ,返回 -1
return map.getOrDefault(target, -1);
}
```
再比如,如果我们想要给定一个目标结点值 `target` ,获取对应的链表结点对象,那么也可以使用哈希查找实现。
![hash_search_listnode](hashing_search.assets/hash_search_listnode.png)
=== "Java"
```java title="hashing_search.java"
/* 哈希查找(链表) */
ListNode hashingSearch1(Map<Integer, ListNode> map, int target) {
// 哈希表的 key: 目标结点值value: 结点对象
// 若哈希表中无此 key ,返回 -1
return map.getOrDefault(target, null);
}
```
## 复杂度分析
**时间复杂度:** $O(1)$ ,哈希表的查找操作使用 $O(1)$ 时间。
**空间复杂度:** $O(n)$ ,其中 $n$ 为数组或链表长度。
## 优缺点
在哈希表中,**查找、插入、删除操作的平均时间复杂度都为 $O(1)$** ,这意味着无论是高频增删还是高频查找场景,哈希查找的性能表现都非常好。当然,一切的前提是保证哈希表未退化。
即使如此,哈希查找仍存在一些问题,在实际应用中,需要根据情况灵活选择方法。
- 辅助哈希表 **需要使用 $O(n)$ 的额外空间**,意味着需要预留更多的计算机内存;
- 建立和维护哈希表需要时间,因此哈希查找 **不适合高频增删、低频查找的使用场景**
- 当哈希冲突严重时,哈希表会退化为链表,**时间复杂度劣化至 $O(n)$**
- **当数据量很小时,线性查找比哈希查找更快**。这是因为计算哈希映射函数可能比遍历一个小型数组更慢;

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

View file

@ -0,0 +1,60 @@
---
comments: true
---
# 线性查找
「线性查找 Linear Search」是一种最基础的查找方法其从数据结构的一端开始依次访问每个元素直到另一端后停止。
## 算法实现
线性查找实质上就是遍历数据结构 + 判断条件。比如,我们想要在数组 `nums` 中查找目标元素 `target` 的对应索引,那么可以在数组中进行线性查找。
![linear_search](linear_search.assets/linear_search.png)
=== "Java"
```java title="linear_search.java"
/* 线性查找(数组) */
int linearSearch(int[] nums, int target) {
// 遍历数组
for (int i = 0; i < nums.length; i++) {
// 找到目标元素,返回其索引
if (nums[i] == target)
return i;
}
// 未找到目标元素,返回 -1
return -1;
}
```
再比如,我们想要在给定一个目标结点值 `target` ,返回此结点对象,也可以在链表中进行线性查找。
=== "Java"
```java title="linear_search.java"
/* 线性查找(链表) */
ListNode linearSearch(ListNode head, int target) {
// 遍历链表
while (head != null) {
// 找到目标结点,返回之
if (head.val == target)
return head;
head = head.next;
}
// 未找到目标结点,返回 null
return null;
}
```
## 复杂度分析
**时间复杂度 $O(n)$ ** 其中 $n$ 为数组或链表长度。
**空间复杂度 $O(1)$ ** 无需使用额外空间。
## 优缺点
**线性查找的通用性极佳。** 由于线性查找是依次访问元素的,即没有跳跃访问元素,因此数组或链表皆适用。
**线性查找的时间复杂度太高。** 在数据量 $n$ 很大时,查找效率很低。

View file

@ -0,0 +1,19 @@
# 小结
- 线性查找是一种最基础的查找方法,通过遍历数据结构 + 判断条件实现查找。
- 二分查找利用数据的有序性,通过循环不断缩小一半搜索区间来实现查找,其要求输入数据是有序的,并且仅适用于数组或基于数组实现的数据结构。
- 哈希查找借助哈希表来实现常数阶时间复杂度的查找操作,体现以空间换时间的算法思想。
<p style="text-align:center"> Table. 三种查找方法对比 </p>
<div class="center-table" markdown>
| | 线性查找 | 二分查找 | 哈希查找 |
| ------------------------------------- | ------------------------ | ----------------------------- | ------------------------ |
| 适用数据结构 | 数组、链表 | 数组 | 数组、链表 |
| 输入数据要求 | 无 | 有序 | 无 |
| 平均时间复杂度</br>查找 / 插入 / 删除 | $O(n)$ / $O(1)$ / $O(n)$ | $O(\log n)$ / $O(n)$ / $O(n)$ | $O(1)$ / $O(1)$ / $O(1)$ |
| 最差时间复杂度</br>查找 / 插入 / 删除 | $O(n)$ / $O(1)$ / $O(n)$ | $O(\log n)$ / $O(n)$ / $O(n)$ | $O(n)$ / $O(n)$ / $O(n)$ |
| 空间复杂度 | $O(1)$ | $O(1)$ | $O(n)$ |
</div>

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

View file

@ -0,0 +1,114 @@
---
comments: true
---
# 冒泡排序
「冒泡排序 Bubble Sort」是一种最基础的排序算法非常适合作为第一个学习的排序算法。顾名思义「冒泡」是该算法的核心操作。
!!! tip "为什么叫 “冒泡”"
在水中,越大的泡泡浮力越大,所以最大的泡泡会最先浮到水面。
「冒泡」操作则是在模拟上述过程,具体做法为:从数组最左端开始向右遍历,依次对比相邻元素大小,若 **左元素 > 右元素** 则将它俩交换,最终可将最大元素移动至数组最右端。
完成此次冒泡操作后,**数组最大元素已在正确位置,接下来只需排序剩余 $n - 1$ 个元素**。
=== "Step 1"
![bubble_operation_step1](bubble_sort.assets/bubble_operation_step1.png)
=== "Step 2"
![bubble_operation_step2](bubble_sort.assets/bubble_operation_step2.png)
=== "Step 3"
![bubble_operation_step3](bubble_sort.assets/bubble_operation_step3.png)
=== "Step 4"
![bubble_operation_step4](bubble_sort.assets/bubble_operation_step4.png)
=== "Step 5"
![bubble_operation_step5](bubble_sort.assets/bubble_operation_step5.png)
=== "Step 6"
![bubble_operation_step6](bubble_sort.assets/bubble_operation_step6.png)
=== "Step 7"
![bubble_operation_step7](bubble_sort.assets/bubble_operation_step7.png)
## 算法流程
设数组长度为 $n$ ,完成第一轮「冒泡」后,数组最大元素已在正确位置,接下来只需排序剩余 $n - 1$ 个元素。
同理,对剩余 $n - 1$ 个元素执行「冒泡」,可将第二大元素交换至正确位置,因而待排序元素只剩 $n - 2$ 个。
以此类推…… **循环 $n - 1$ 轮「冒泡」,即可完成整个数组的排序**
![bubble_sort](bubble_sort.assets/bubble_sort.png)
=== "Java"
```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;
}
}
}
}
```
## 算法分析
**时间复杂度 $O(n^2)$ ** 各轮「冒泡」遍历的数组长度为 $n - 1$ , $n - 2$ , $\cdots$ , $2$ , $1$ 次,求和为 $\frac{(n - 1) n}{2}$ ,因此使用 $O(n^2)$ 时间。
**空间复杂度 $O(1)$ ** 指针 $i$ , $j$ 使用常数大小的额外空间。
**原地性:** 指针变量仅使用常数大小额外空间,因此是 **原地排序**
**稳定性:** 不交换相等元素,因此是 **稳定排序**
**自适应:** 引入 `flag` 优化后(见下文),可在输入数组已排序下达到最优时间复杂度 $O(N)$ ,因此是 **自适应排序**
## 效率优化
我们发现,若在某轮「冒泡」中未执行任何交换操作,则说明数组已经完成排序,可直接返回结果。考虑可以增加一个标志位 `flag` 来监听该情况,若出现则直接返回。
优化后,冒泡排序的最差和平均时间复杂度仍为 $O(n^2)$ ;而在输入数组 **已排序** 时,达到 **最佳时间复杂度** $O(n)$ 。
=== "Java"
```java
/* 冒泡排序(标志优化)*/
void bubbleSortWithFlag(int[] nums) {
// 外循环:待排序元素数量为 n-1, n-2, ..., 1
for (int i = nums.length - 1; i > 0; i--) {
boolean flag = false; // 初始化标志位
// 内循环:冒泡操作
for (int j = 0; j < i; j++) {
if (nums[j] > nums[j + 1]) {
// 交换 nums[j] 与 nums[j + 1]
int tmp = nums[j];
nums[j] = nums[j + 1];
nums[j + 1] = tmp;
flag = true; // 记录交换元素
}
}
if (!flag) break; // 此轮冒泡未交换任何元素,直接跳出
}
}
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

View file

@ -0,0 +1,71 @@
---
comments: true
---
# 插入排序
顾名思义,「插入排序 Insertion Sort」是一种基于 **数组插入操作** 的排序算法。
「插入操作」思想:选定数组的某个元素 `base` ,将 `base` 与其左边的元素依次对比大小,并 “插入” 到正确位置。
然而,由于数组元素是连续的,因此我们无法直接把 `base` 插入到目标位置,而是需要把从正确位置到 `base` 之间的所有元素向右移动一位。
![insertion_operation](insertion_sort.assets/insertion_operation.png)
## 算法流程
第 1 轮先选取数组的 **第 2 个元素**`base` ,执行「插入操作」后, **数组前 2 个元素已完成排序**
第 2 轮选取 **第 3 个元素**`base` ,执行「插入操作」后, **数组前 3 个元素已完成排序**
以此类推……最后一轮选取 **数组尾元素**`base` ,执行「插入操作」后 **所有元素已完成排序**
![insertion_sort](insertion_sort.assets/insertion_sort.png)
=== "Java"
```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 赋值到正确位置
}
}
```
## 算法分析
**时间复杂度 $O(n^2)$ ** 各轮插入操作最多循环 $n - 1$ , $n-2$ , $\cdots$ , $2$ , $1$ 次,求和为 $\frac{(n - 1) n}{2}$ ,使用 $O(n^2)$ 时间。
**空间复杂度 $O(1)$ ** 指针 $i$ , $j$ 使用常数大小的额外空间。
**原地性:** 指针变量仅使用常数大小额外空间,因此是 **原地排序**
**稳定性:** 不交换相等元素,因此是 **稳定排序**
**自适应:** 当输入数组完全有序时,每次插入操作(即内循环)仅循环一次,此时时间复杂度为 $O(n)$ 。
## 插入排序 vs 冒泡排序
!!! question
虽然「插入排序」和「冒泡排序」的时间复杂度皆为 $O(n^2)$ ,但实际运行速度却有很大差别,这是为什么呢?
回顾复杂度分析,两个方法的循环次数都是 $\frac{(n - 1) n}{2}$ 。但不同的是,「冒泡操作」是在做 **元素交换** ,需要借助一个临时变量实现,共 3 个单元操作;而「插入操作」是在做 **赋值** ,只需 1 个单元操作;因此,可以粗略估计出冒泡排序的计算开销约为插入排序的 3 倍。
插入排序运行速度快,并且具有原地、稳定、自适应的优点,因此很受欢迎。实际上,包括 Java 在内的许多编程语言的排序库函数的实现都用到了插入排序。库函数的大致思路:
- 对于 **长数组**,采用基于分治的排序算法,例如「快速排序」,时间复杂度为 $O(n \log n)$
- 对于 **短数组**,直接使用「插入排序」,时间复杂度为 $O(n^2)$
在数组较短时,复杂度中的常数项(即每轮中的单元操作数量)占主导作用,此时插入排序运行地更快。这个现象与「线性查找」和「二分查找」的情况类似。

View file

@ -0,0 +1,6 @@
---
comments: true
---
# 归并排序

View file

@ -0,0 +1,6 @@
---
comments: true
---
# 快速排序

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

View file

@ -0,0 +1,78 @@
# 双向队列
对于队列,我们只能在头部删除或在尾部添加元素,而「双向队列 Deque」更加灵活在其头部和尾部都能执行元素添加或删除操作。
![deque_operations](deque.assets/deque_operations.png)
<p style="text-align:center"> Fig. 双向队列的操作 </p>
## 双向队列常用操作
双向队列的常用操作见下表,方法名需根据编程语言设定来具体确定。
<p style="text-align:center"> Table. 双向队列的常用操作 </p>
<div class="center-table" markdown>
| 方法 | 描述 |
| ------------ | ---------------- |
| offerFirst() | 将元素添加至队首 |
| offerLast() | 将元素添加至队尾 |
| pollFirst() | 删除队首元素 |
| pollLast() | 删除队尾元素 |
| peekFirst() | 访问队首元素 |
| peekLast() | 访问队尾元素 |
| size() | 获取队列的长度 |
| isEmpty() | 判断队列是否为空 |
</div>
相同地,我们可以直接使用编程语言实现好的双向队列类。
=== "Java"
```java title="deque.java"
/* 初始化双向队列 */
Deque<Integer> deque = new LinkedList<>();
/* 元素入队 */
deque.offerLast(2);
deque.offerLast(5);
deque.offerLast(4);
deque.offerFirst(3);
deque.offerFirst(1);
System.out.println("队列 deque = " + deque);
/* 访问队首元素 */
int peekFirst = deque.peekFirst();
System.out.println("队首元素 peekFirst = " + peekFirst);
int peekLast = deque.peekLast();
System.out.println("队尾元素 peekLast = " + peekLast);
/* 元素出队 */
int pollFirst = deque.pollFirst();
System.out.println("队首出队元素 pollFirst = " + pollFirst +
",队首出队后 deque = " + deque);
int pollLast = deque.pollLast();
System.out.println("队尾出队元素 pollLast = " + pollLast +
",队尾出队后 deque = " + deque);
/* 获取队列的长度 */
int size = deque.size();
System.out.println("队列长度 size = " + size);
/* 判断队列是否为空 */
boolean isEmpty = deque.isEmpty();
```
=== "C++"
```cpp title="deque.cpp"
```
=== "Python"
```python title="deque.py"
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

View file

@ -0,0 +1,218 @@
---
comments: true
---
# 队列
「队列 Queue」是一种遵循「先入先出 first in, first out」数据操作规则的线性数据结构。顾名思义队列模拟的是排队现象即外面的人不断加入队列尾部而处于队列头部的人不断地离开。
我们将队列头部称为「队首」,队列尾部称为「队尾」,将把元素加入队尾的操作称为「入队」,删除队首元素的操作称为「出队」。
![queue_operations](queue.assets/queue_operations.png)
<p style="text-align:center"> Fig. 队列的先入先出特性 </p>
## 队列常用操作
队列的常用操作见下表,方法命名需根据编程语言的设定来具体确定。
<p style="text-align:center"> Table. 队列的常用操作 </p>
<div class="center-table" markdown>
| 方法 | 描述 |
| --------- | ---------------------------- |
| offer() | 元素入队,即将元素添加至队尾 |
| poll() | 队首元素出队 |
| front() | 访问队首元素 |
| size() | 获取队列的长度 |
| isEmpty() | 判断队列是否为空 |
</div>
我们可以直接使用编程语言实现好的队列类。
=== "Java"
```java title="queue.java"
/* 初始化队列 */
Queue<Integer> queue = new LinkedList<>();
/* 元素入队 */
queue.offer(1);
queue.offer(3);
queue.offer(2);
queue.offer(5);
queue.offer(4);
System.out.println("队列 queue = " + queue);
/* 访问队首元素 */
int peek = queue.peek();
System.out.println("队首元素 peek = " + peek);
/* 元素出队 */
int poll = queue.poll();
System.out.println("出队元素 poll = " + poll + ",出队后 queue = " + queue);
/* 获取队列的长度 */
int size = queue.size();
System.out.println("队列长度 size = " + size);
/* 判断队列是否为空 */
boolean isEmpty = queue.isEmpty();
```
=== "C++"
```cpp title="queue.cpp"
```
=== "Python"
```python title="queue.py"
```
## 队列实现
队列需要一种可以在一端添加,并在另一端删除的数据结构,也可以使用链表或数组来实现。
### 基于链表的实现
我们将链表的「头结点」和「尾结点」分别看作是队首和队尾,并规定队尾只可添加结点,队首只可删除结点。
=== "Java"
```java title="linkedlist_queue.java"
/* 基于链表实现的队列 */
class LinkedListQueue {
LinkedList<Integer> list;
public LinkedListQueue() {
// 初始化链表
list = new LinkedList<>();
}
/* 获取队列的长度 */
public int size() {
return list.size();
}
/* 判断队列是否为空 */
public boolean isEmpty() {
return list.size() == 0;
}
/* 入队 */
public void offer(int num) {
// 尾结点后添加 num
list.addLast(num);
}
/* 出队 */
public int poll() {
// 删除头结点
return list.removeFirst();
}
/* 访问队首元素 */
public int peek() {
return list.getFirst();
}
}
```
=== "C++"
```cpp title="linkedlist_queue.cpp"
```
=== "Python"
```python title="linkedlist_queue.py"
```
### 基于数组的实现
数组的删除首元素的时间复杂度为 $O(n)$ ,因此不适合直接用来实现队列。然而,我们可以借助两个指针 `front` , `rear` 来分别记录队首和队尾的索引位置,在入队 / 出队时分别将 `front` / `rear` 向后移动一位即可,这样每次仅需操作一个元素,时间复杂度降至 $O(1)$ 。
还有一个问题,在入队与出队的过程中,两个指针都在向后移动,而到达尾部后则无法继续移动了。为了解决此问题,我们可以采取一个取巧方案,即将数组看作是 “环形” 的。具体做法是规定指针越过数组尾部后,再次回到头部接续遍历,这样相当于使数组 “首尾相连” 了。
为了适应环形数组的设定,获取长度 `size()` 、入队 `offer()` 、出队 `poll()` 方法都需要做相应的取余操作处理,使得当尾指针绕回数组头部时,仍然可以正确处理操作。
基于数组实现的队列有一个缺点,即长度不可变。但这点我们可以通过动态数组来解决,有兴趣的同学可以自行实现。
=== "Java"
```java title="array_queue.java"
/* 基于环形数组实现的队列 */
class ArrayQueue {
int[] nums; // 用于存储队列元素的数组
int size = 0; // 队列长度(即元素个数)
int front = 0; // 头指针,指向队首
int rear = 0; // 尾指针,指向队尾 + 1
public ArrayQueue(int capacity) {
// 初始化数组
nums = new int[capacity];
}
/* 获取队列的容量 */
public int capacity() {
return nums.length;
}
/* 获取队列的长度 */
public int size() {
int capacity = capacity();
// 由于将数组看作为环形,可能 rear < front 因此需要取余数
return (capacity + rear - front) % capacity;
}
/* 判断队列是否为空 */
public boolean isEmpty() {
return rear - front == 0;
}
/* 入队 */
public void offer(int num) {
if (size() == capacity()) {
System.out.println("队列已满");
return;
}
// 尾结点后添加 num
nums[rear] = num;
// 尾指针向后移动一位,越过尾部后返回到数组头部
rear = (rear + 1) % capacity();
}
/* 出队 */
public int poll() {
// 删除头结点
if (isEmpty())
throw new EmptyStackException();
int num = nums[front];
// 队头指针向后移动,越过尾部后返回到数组头部
front = (front + 1) % capacity();
return num;
}
/* 访问队首元素 */
public int peek() {
// 删除头结点
if (isEmpty())
throw new EmptyStackException();
return nums[front];
}
}
```
=== "C++"
```cpp title="array_queue.cpp"
```
=== "Python"
```python title="array_queue.py"
```
## 队列典型应用
- **淘宝订单。** 购物者下单后,订单就被加入到队列之中,随后系统再根据顺序依次处理队列中的订单。在双十一时,在短时间内会产生海量的订单,如何处理「高并发」则是工程师们需要重点思考的问题。
- **各种待办事项。** 例如打印机的任务队列、餐厅的出餐队列等等。

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

View file

@ -0,0 +1,197 @@
---
comments: true
---
# 栈
「栈 Stack」是一种遵循「先入后出 first in, last out」数据操作规则的线性数据结构。我们可以将栈类比为放在桌面上的一摞盘子如果需要拿出底部的盘子则需要先将上面的盘子依次取出。
我们将顶部盘子称为「栈顶」,底部盘子称为「栈底」,将把元素添加到栈顶的操作称为「入栈」,将删除栈顶元素的操作称为「出栈」。
![stack_operations](stack.assets/stack_operations.png)
<p style="text-align:center"> Fig. 栈的先入后出特性 </p>
## 栈常用操作
栈的常用操作见下表,方法名需根据编程语言设定来具体确定。
<p style="text-align:center"> Table. 栈的常用操作 </p>
<div class="center-table" markdown>
| 方法 | 描述 |
| --------- | ---------------------- |
| push() | 元素入栈(添加至栈顶) |
| pop() | 栈顶元素出栈 |
| peek() | 访问栈顶元素 |
| size() | 获取栈的长度 |
| isEmpty() | 判断栈是否为空 |
</div>
我们可以直接使用编程语言实现好的栈类。
=== "Java"
```java title="stack.java"
/* 初始化栈 */
Stack<Integer> stack = new Stack<>();
/* 元素入栈 */
stack.push(1);
stack.push(3);
stack.push(2);
stack.push(5);
stack.push(4);
System.out.println("栈 stack = " + stack);
/* 访问栈顶元素 */
int peek = stack.peek();
System.out.println("栈顶元素 peek = " + peek);
/* 元素出栈 */
int pop = stack.pop();
System.out.println("出栈元素 pop = " + pop + ",出栈后 stack = " + stack);
/* 获取栈的长度 */
int size = stack.size();
System.out.println("栈的长度 size = " + size);
/* 判断是否为空 */
boolean isEmpty = stack.isEmpty();
```
=== "C++"
```cpp title="stack.cpp"
```
=== "Python"
```python title="stack.py"
```
## 栈的实现
为了更加清晰地了解栈的运行机制,接下来我们来自己动手实现一个栈类。
栈规定元素是先入后出的,因此我们只能在栈顶添加或删除元素。然而,数组或链表都可以在任意位置添加删除元素,因此 **栈可被看作是一种受约束的数组或链表**。换言之,我们可以 “屏蔽” 数组或链表的部分无关操作,使之对外的表现逻辑符合栈的规定即可。
### 基于链表的实现
使用「链表」实现栈时,将链表的尾结点看作栈顶即可。
受益于链表的离散存储方式,栈的扩容更加灵活,删除元素的内存也会被系统自动回收;缺点是无法像数组一样高效地随机访问,并且由于链表结点需存储指针,导致单个元素占用空间更大。
=== "Java"
```java title="linkedlist_stack.java"
/* 基于链表实现的栈 */
class LinkedListStack {
LinkedList<Integer> list;
public LinkedListStack() {
// 初始化链表
list = new LinkedList<>();
}
/* 获取栈的长度 */
public int size() {
return list.size();
}
/* 判断栈是否为空 */
public boolean isEmpty() {
return size() == 0;
}
/* 入栈 */
public void push(int num) {
list.addLast(num);
}
/* 出栈 */
public int pop() {
return list.removeLast();
}
/* 访问栈顶元素 */
public int peek() {
return list.getLast();
}
}
```
=== "C++"
```cpp title="linkedlist_stack.cpp"
```
=== "Python"
```python title="linkedlist_stack.py"
```
### 基于数组的实现
使用「数组」实现栈时,将数组的尾部当作栈顶。准确地说,我们需要使用「列表」,因为入栈的元素可能是源源不断的,因此使用动态数组可以方便扩容。
基于数组实现的栈,优点是支持随机访问,缺点是会造成一定的空间浪费,因为列表的容量始终 $\geq$ 元素数量。
=== "Java"
```java title="array_stack.java"
/* 基于数组实现的栈 */
class ArrayStack {
List<Integer> list;
public ArrayStack() {
// 初始化列表(动态数组)
list = new ArrayList<>();
}
/* 获取栈的长度 */
public int size() {
return list.size();
}
/* 判断栈是否为空 */
public boolean isEmpty() {
return size() == 0;
}
/* 入栈 */
public void push(int num) {
list.add(num);
}
/* 出栈 */
public int pop() {
return list.remove(size() - 1);
}
/* 访问栈顶元素 */
public int peek() {
return list.get(size() - 1);
}
/* 访问索引 index 处元素 */
public int get(int index) {
return list.get(index);
}
}
```
=== "C++"
```cpp title="array_stack.cpp"
```
=== "Python"
```python title="array_stack.py"
```
!!! tip
实际编程中,我们一般直接将 `ArrayList``LinkedList` 当作「栈」来使用。我们仅需通过脑补来屏蔽无关操作,而不用专门去包装它。
## 栈典型应用
- **浏览器中的后退与前进、软件中的撤销与反撤销。** 每当我们打开新的网页,浏览器就讲上一个网页执行入栈,这样我们就可以通过「后退」操作来回到上一页面,后退操作实际上是在执行出栈。如果要同时支持后退和前进,那么则需要两个栈来配合实现。
- **程序内存管理。** 每当调用函数时,系统就会站栈顶添加一个栈帧,用来记录函数的上下文信息。在递归函数中,向下递推会不断执行入栈,向上回溯阶段时出栈。

View file

@ -0,0 +1,9 @@
---
comments: true
---
# 小结
- 栈是一种遵循先入后出的数据结构,可以使用数组或链表实现。
- 队列是一种遵循先入先出的数据结构,可以使用数组或链表实现。
- 双向队列的两端都可以添加与删除元素。

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Some files were not shown because too many files have changed in this diff Show more