mirror of
https://github.com/krahets/hello-algo.git
synced 2026-04-01 17:53:18 +08:00
feat: Revised the book (#978)
* Sync recent changes to the revised Word. * Revised the preface chapter * Revised the introduction chapter * Revised the computation complexity chapter * Revised the chapter data structure * Revised the chapter array and linked list * Revised the chapter stack and queue * Revised the chapter hashing * Revised the chapter tree * Revised the chapter heap * Revised the chapter graph * Revised the chapter searching * Reivised the sorting chapter * Revised the divide and conquer chapter * Revised the chapter backtacking * Revised the DP chapter * Revised the greedy chapter * Revised the appendix chapter * Revised the preface chapter doubly * Revised the figures
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 27 KiB |
@@ -1,6 +1,6 @@
|
||||
# 数组
|
||||
|
||||
「数组 array」是一种线性数据结构,其将相同类型元素存储在连续的内存空间中。我们将元素在数组中的位置称为该元素的「索引 index」。下图展示了数组的主要术语和概念。
|
||||
「数组 array」是一种线性数据结构,其将相同类型的元素存储在连续的内存空间中。我们将元素在数组中的位置称为该元素的「索引 index」。下图展示了数组的主要术语和概念。
|
||||
|
||||

|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
### 初始化数组
|
||||
|
||||
我们可以根据需求选用数组的两种初始化方式:无初始值、给定初始值。在未指定初始值的情况下,大多数编程语言会将数组元素初始化为 $0$ 。
|
||||
我们可以根据需求选用数组的两种初始化方式:无初始值、给定初始值。在未指定初始值的情况下,大多数编程语言会将数组元素初始化为 $0$ :
|
||||
|
||||
=== "Python"
|
||||
|
||||
@@ -115,13 +115,13 @@
|
||||
|
||||
### 访问元素
|
||||
|
||||
数组元素被存储在连续的内存空间中,这意味着计算数组元素的内存地址非常容易。给定数组内存地址(即首元素内存地址)和某个元素的索引,我们可以使用下图所示的公式计算得到该元素的内存地址,从而直接访问此元素。
|
||||
数组元素被存储在连续的内存空间中,这意味着计算数组元素的内存地址非常容易。给定数组内存地址(首元素内存地址)和某个元素的索引,我们可以使用下图所示的公式计算得到该元素的内存地址,从而直接访问该元素。
|
||||
|
||||

|
||||
|
||||
观察上图,我们发现数组首个元素的索引为 $0$ ,这似乎有些反直觉,因为从 $1$ 开始计数会更自然。但从地址计算公式的角度看,**索引的含义本质上是内存地址的偏移量**。首个元素的地址偏移量是 $0$ ,因此它的索引为 $0$ 也是合理的。
|
||||
观察上图,我们发现数组首个元素的索引为 $0$ ,这似乎有些反直觉,因为从 $1$ 开始计数会更自然。但从地址计算公式的角度看,**索引本质上是内存地址的偏移量**。首个元素的地址偏移量是 $0$ ,因此它的索引为 $0$ 是合理的。
|
||||
|
||||
在数组中访问元素是非常高效的,我们可以在 $O(1)$ 时间内随机访问数组中的任意一个元素。
|
||||
在数组中访问元素非常高效,我们可以在 $O(1)$ 时间内随机访问数组中的任意一个元素。
|
||||
|
||||
```src
|
||||
[file]{array}-[class]{}-[func]{random_access}
|
||||
@@ -129,11 +129,11 @@
|
||||
|
||||
### 插入元素
|
||||
|
||||
数组元素在内存中是“紧挨着的”,它们之间没有空间再存放任何数据。如下图所示,如果想要在数组中间插入一个元素,则需要将该元素之后的所有元素都向后移动一位,之后再把元素赋值给该索引。
|
||||
数组元素在内存中是“紧挨着的”,它们之间没有空间再存放任何数据。如下图所示,如果想在数组中间插入一个元素,则需要将该元素之后的所有元素都向后移动一位,之后再把元素赋值给该索引。
|
||||
|
||||

|
||||
|
||||
值得注意的是,由于数组的长度是固定的,因此插入一个元素必定会导致数组尾部元素的“丢失”。我们将这个问题的解决方案留在列表章节中讨论。
|
||||
值得注意的是,由于数组的长度是固定的,因此插入一个元素必定会导致数组尾部元素“丢失”。我们将这个问题的解决方案留在“列表”章节中讨论。
|
||||
|
||||
```src
|
||||
[file]{array}-[class]{}-[func]{insert}
|
||||
@@ -141,7 +141,7 @@
|
||||
|
||||
### 删除元素
|
||||
|
||||
同理,如下图所示,若想要删除索引 $i$ 处的元素,则需要把索引 $i$ 之后的元素都向前移动一位。
|
||||
同理,如下图所示,若想删除索引 $i$ 处的元素,则需要把索引 $i$ 之后的元素都向前移动一位。
|
||||
|
||||

|
||||
|
||||
@@ -155,11 +155,11 @@
|
||||
|
||||
- **时间复杂度高**:数组的插入和删除的平均时间复杂度均为 $O(n)$ ,其中 $n$ 为数组长度。
|
||||
- **丢失元素**:由于数组的长度不可变,因此在插入元素后,超出数组长度范围的元素会丢失。
|
||||
- **内存浪费**:我们可以初始化一个比较长的数组,只用前面一部分,这样在插入数据时,丢失的末尾元素都是“无意义”的,但这样做也会造成部分内存空间的浪费。
|
||||
- **内存浪费**:我们可以初始化一个比较长的数组,只用前面一部分,这样在插入数据时,丢失的末尾元素都是“无意义”的,但这样做会造成部分内存空间浪费。
|
||||
|
||||
### 遍历数组
|
||||
|
||||
在大多数编程语言中,我们既可以通过索引遍历数组,也可以直接遍历获取数组中的每个元素。
|
||||
在大多数编程语言中,我们既可以通过索引遍历数组,也可以直接遍历获取数组中的每个元素:
|
||||
|
||||
```src
|
||||
[file]{array}-[class]{}-[func]{traverse}
|
||||
@@ -179,32 +179,32 @@
|
||||
|
||||
在复杂的系统环境中,程序难以保证数组之后的内存空间是可用的,从而无法安全地扩展数组容量。因此在大多数编程语言中,**数组的长度是不可变的**。
|
||||
|
||||
如果我们希望扩容数组,则需重新建立一个更大的数组,然后把原数组元素依次拷贝到新数组。这是一个 $O(n)$ 的操作,在数组很大的情况下是非常耗时的。
|
||||
如果我们希望扩容数组,则需重新建立一个更大的数组,然后把原数组元素依次复制到新数组。这是一个 $O(n)$ 的操作,在数组很大的情况下非常耗时。代码如下所示:
|
||||
|
||||
```src
|
||||
[file]{array}-[class]{}-[func]{extend}
|
||||
```
|
||||
|
||||
## 数组优点与局限性
|
||||
## 数组的优点与局限性
|
||||
|
||||
数组存储在连续的内存空间内,且元素类型相同。这种做法包含丰富的先验信息,系统可以利用这些信息来优化数据结构的操作效率。
|
||||
|
||||
- **空间效率高**: 数组为数据分配了连续的内存块,无须额外的结构开销。
|
||||
- **支持随机访问**: 数组允许在 $O(1)$ 时间内访问任何元素。
|
||||
- **缓存局部性**: 当访问数组元素时,计算机不仅会加载它,还会缓存其周围的其他数据,从而借助高速缓存来提升后续操作的执行速度。
|
||||
- **空间效率高**:数组为数据分配了连续的内存块,无须额外的结构开销。
|
||||
- **支持随机访问**:数组允许在 $O(1)$ 时间内访问任何元素。
|
||||
- **缓存局部性**:当访问数组元素时,计算机不仅会加载它,还会缓存其周围的其他数据,从而借助高速缓存来提升后续操作的执行速度。
|
||||
|
||||
连续空间存储是一把双刃剑,其存在以下缺点。
|
||||
连续空间存储是一把双刃剑,其存在以下局限性。
|
||||
|
||||
- **插入与删除效率低**:当数组中元素较多时,插入与删除操作需要移动大量的元素。
|
||||
- **长度不可变**: 数组在初始化后长度就固定了,扩容数组需要将所有数据复制到新数组,开销很大。
|
||||
- **空间浪费**: 如果数组分配的大小超过了实际所需,那么多余的空间就被浪费了。
|
||||
- **插入与删除效率低**:当数组中元素较多时,插入与删除操作需要移动大量的元素。
|
||||
- **长度不可变**:数组在初始化后长度就固定了,扩容数组需要将所有数据复制到新数组,开销很大。
|
||||
- **空间浪费**:如果数组分配的大小超过实际所需,那么多余的空间就被浪费了。
|
||||
|
||||
## 数组典型应用
|
||||
|
||||
数组是一种基础且常见的数据结构,既频繁应用在各类算法之中,也可用于实现各种复杂数据结构。
|
||||
|
||||
- **随机访问**:如果我们想要随机抽取一些样本,那么可以用数组存储,并生成一个随机序列,根据索引实现样本的随机抽取。
|
||||
- **随机访问**:如果我们想随机抽取一些样本,那么可以用数组存储,并生成一个随机序列,根据索引实现随机抽样。
|
||||
- **排序和搜索**:数组是排序和搜索算法最常用的数据结构。快速排序、归并排序、二分查找等都主要在数组上进行。
|
||||
- **查找表**:当我们需要快速查找一个元素或者需要查找一个元素的对应关系时,可以使用数组作为查找表。假如我们想要实现字符到 ASCII 码的映射,则可以将字符的 ASCII 码值作为索引,对应的元素存放在数组中的对应位置。
|
||||
- **查找表**:当需要快速查找一个元素或其对应关系时,可以使用数组作为查找表。假如我们想实现字符到 ASCII 码的映射,则可以将字符的 ASCII 码值作为索引,对应的元素存放在数组中的对应位置。
|
||||
- **机器学习**:神经网络中大量使用了向量、矩阵、张量之间的线性代数运算,这些数据都是以数组的形式构建的。数组是神经网络编程中最常使用的数据结构。
|
||||
- **数据结构实现**:数组可以用于实现栈、队列、哈希表、堆、图等数据结构。例如,图的邻接矩阵表示实际上是一个二维数组。
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
「链表 linked list」是一种线性数据结构,其中的每个元素都是一个节点对象,各个节点通过“引用”相连接。引用记录了下一个节点的内存地址,通过它可以从当前节点访问到下一个节点。
|
||||
|
||||
链表的设计使得各个节点可以被分散存储在内存各处,它们的内存地址是无须连续的。
|
||||
链表的设计使得各个节点可以分散存储在内存各处,它们的内存地址无须连续。
|
||||
|
||||

|
||||
|
||||
@@ -183,7 +183,7 @@
|
||||
|
||||
### 初始化链表
|
||||
|
||||
建立链表分为两步,第一步是初始化各个节点对象,第二步是构建引用指向关系。初始化完成后,我们就可以从链表的头节点出发,通过引用指向 `next` 依次访问所有节点。
|
||||
建立链表分为两步,第一步是初始化各个节点对象,第二步是构建节点之间的引用关系。初始化完成后,我们就可以从链表的头节点出发,通过引用指向 `next` 依次访问所有节点。
|
||||
|
||||
=== "Python"
|
||||
|
||||
@@ -195,7 +195,7 @@
|
||||
n2 = ListNode(2)
|
||||
n3 = ListNode(5)
|
||||
n4 = ListNode(4)
|
||||
# 构建引用指向
|
||||
# 构建节点之间的引用
|
||||
n0.next = n1
|
||||
n1.next = n2
|
||||
n2.next = n3
|
||||
@@ -212,7 +212,7 @@
|
||||
ListNode* n2 = new ListNode(2);
|
||||
ListNode* n3 = new ListNode(5);
|
||||
ListNode* n4 = new ListNode(4);
|
||||
// 构建引用指向
|
||||
// 构建节点之间的引用
|
||||
n0->next = n1;
|
||||
n1->next = n2;
|
||||
n2->next = n3;
|
||||
@@ -229,7 +229,7 @@
|
||||
ListNode n2 = new ListNode(2);
|
||||
ListNode n3 = new ListNode(5);
|
||||
ListNode n4 = new ListNode(4);
|
||||
// 构建引用指向
|
||||
// 构建节点之间的引用
|
||||
n0.next = n1;
|
||||
n1.next = n2;
|
||||
n2.next = n3;
|
||||
@@ -246,7 +246,7 @@
|
||||
ListNode n2 = new(2);
|
||||
ListNode n3 = new(5);
|
||||
ListNode n4 = new(4);
|
||||
// 构建引用指向
|
||||
// 构建节点之间的引用
|
||||
n0.next = n1;
|
||||
n1.next = n2;
|
||||
n2.next = n3;
|
||||
@@ -263,7 +263,7 @@
|
||||
n2 := NewListNode(2)
|
||||
n3 := NewListNode(5)
|
||||
n4 := NewListNode(4)
|
||||
// 构建引用指向
|
||||
// 构建节点之间的引用
|
||||
n0.Next = n1
|
||||
n1.Next = n2
|
||||
n2.Next = n3
|
||||
@@ -280,7 +280,7 @@
|
||||
let n2 = ListNode(x: 2)
|
||||
let n3 = ListNode(x: 5)
|
||||
let n4 = ListNode(x: 4)
|
||||
// 构建引用指向
|
||||
// 构建节点之间的引用
|
||||
n0.next = n1
|
||||
n1.next = n2
|
||||
n2.next = n3
|
||||
@@ -297,7 +297,7 @@
|
||||
const n2 = new ListNode(2);
|
||||
const n3 = new ListNode(5);
|
||||
const n4 = new ListNode(4);
|
||||
// 构建引用指向
|
||||
// 构建节点之间的引用
|
||||
n0.next = n1;
|
||||
n1.next = n2;
|
||||
n2.next = n3;
|
||||
@@ -314,7 +314,7 @@
|
||||
const n2 = new ListNode(2);
|
||||
const n3 = new ListNode(5);
|
||||
const n4 = new ListNode(4);
|
||||
// 构建引用指向
|
||||
// 构建节点之间的引用
|
||||
n0.next = n1;
|
||||
n1.next = n2;
|
||||
n2.next = n3;
|
||||
@@ -331,7 +331,7 @@
|
||||
ListNode n2 = ListNode(2);
|
||||
ListNode n3 = ListNode(5);
|
||||
ListNode n4 = ListNode(4);
|
||||
// 构建引用指向
|
||||
// 构建节点之间的引用
|
||||
n0.next = n1;
|
||||
n1.next = n2;
|
||||
n2.next = n3;
|
||||
@@ -349,7 +349,7 @@
|
||||
let n3 = Rc::new(RefCell::new(ListNode { val: 5, next: None }));
|
||||
let n4 = Rc::new(RefCell::new(ListNode { val: 4, next: None }));
|
||||
|
||||
// 构建引用指向
|
||||
// 构建节点之间的引用
|
||||
n0.borrow_mut().next = Some(n1.clone());
|
||||
n1.borrow_mut().next = Some(n2.clone());
|
||||
n2.borrow_mut().next = Some(n3.clone());
|
||||
@@ -366,7 +366,7 @@
|
||||
ListNode* n2 = newListNode(2);
|
||||
ListNode* n3 = newListNode(5);
|
||||
ListNode* n4 = newListNode(4);
|
||||
// 构建引用指向
|
||||
// 构建节点之间的引用
|
||||
n0->next = n1;
|
||||
n1->next = n2;
|
||||
n2->next = n3;
|
||||
@@ -383,18 +383,18 @@
|
||||
var n2 = inc.ListNode(i32){.val = 2};
|
||||
var n3 = inc.ListNode(i32){.val = 5};
|
||||
var n4 = inc.ListNode(i32){.val = 4};
|
||||
// 构建引用指向
|
||||
// 构建节点之间的引用
|
||||
n0.next = &n1;
|
||||
n1.next = &n2;
|
||||
n2.next = &n3;
|
||||
n3.next = &n4;
|
||||
```
|
||||
|
||||
数组整体是一个变量,比如数组 `nums` 包含元素 `nums[0]` 和 `nums[1]` 等,而链表是由多个独立的节点对象组成的。**我们通常将头节点当作链表的代称**,比如以上代码中的链表可被记做链表 `n0` 。
|
||||
数组整体是一个变量,比如数组 `nums` 包含元素 `nums[0]` 和 `nums[1]` 等,而链表是由多个独立的节点对象组成的。**我们通常将头节点当作链表的代称**,比如以上代码中的链表可记作链表 `n0` 。
|
||||
|
||||
### 插入节点
|
||||
|
||||
在链表中插入节点非常容易。如下图所示,假设我们想在相邻的两个节点 `n0` 和 `n1` 之间插入一个新节点 `P` ,**则只需要改变两个节点引用(指针)即可**,时间复杂度为 $O(1)$ 。
|
||||
在链表中插入节点非常容易。如下图所示,假设我们想在相邻的两个节点 `n0` 和 `n1` 之间插入一个新节点 `P` ,**则只需改变两个节点引用(指针)即可**,时间复杂度为 $O(1)$ 。
|
||||
|
||||
相比之下,在数组中插入元素的时间复杂度为 $O(n)$ ,在大数据量下的效率较低。
|
||||
|
||||
@@ -418,7 +418,7 @@
|
||||
|
||||
### 访问节点
|
||||
|
||||
**在链表访问节点的效率较低**。如上节所述,我们可以在 $O(1)$ 时间下访问数组中的任意元素。链表则不然,程序需要从头节点出发,逐个向后遍历,直至找到目标节点。也就是说,访问链表的第 $i$ 个节点需要循环 $i - 1$ 轮,时间复杂度为 $O(n)$ 。
|
||||
**在链表中访问节点的效率较低**。如上一节所述,我们可以在 $O(1)$ 时间下访问数组中的任意元素。链表则不然,程序需要从头节点出发,逐个向后遍历,直至找到目标节点。也就是说,访问链表的第 $i$ 个节点需要循环 $i - 1$ 轮,时间复杂度为 $O(n)$ 。
|
||||
|
||||
```src
|
||||
[file]{linked_list}-[class]{}-[func]{access}
|
||||
@@ -426,15 +426,15 @@
|
||||
|
||||
### 查找节点
|
||||
|
||||
遍历链表,查找链表内值为 `target` 的节点,输出节点在链表中的索引。此过程也属于线性查找。
|
||||
遍历链表,查找其中值为 `target` 的节点,输出该节点在链表中的索引。此过程也属于线性查找。代码如下所示:
|
||||
|
||||
```src
|
||||
[file]{linked_list}-[class]{}-[func]{find}
|
||||
```
|
||||
|
||||
## 数组 VS 链表
|
||||
## 数组 vs. 链表
|
||||
|
||||
下表总结对比了数组和链表的各项特点与操作效率。由于它们采用两种相反的存储策略,因此各种性质和操作效率也呈现对立的特点。
|
||||
下表总结了数组和链表的各项特点并对比了操作效率。由于它们采用两种相反的存储策略,因此各种性质和操作效率也呈现对立的特点。
|
||||
|
||||
<p align="center"> 表 <id> 数组与链表的效率对比 </p>
|
||||
|
||||
@@ -451,8 +451,8 @@
|
||||
|
||||
如下图所示,常见的链表类型包括三种。
|
||||
|
||||
- **单向链表**:即上述介绍的普通链表。单向链表的节点包含值和指向下一节点的引用两项数据。我们将首个节点称为头节点,将最后一个节点称为尾节点,尾节点指向空 $\text{None}$ 。
|
||||
- **环形链表**:如果我们令单向链表的尾节点指向头节点(即首尾相接),则得到一个环形链表。在环形链表中,任意节点都可以视作头节点。
|
||||
- **单向链表**:即前面介绍的普通链表。单向链表的节点包含值和指向下一节点的引用两项数据。我们将首个节点称为头节点,将最后一个节点称为尾节点,尾节点指向空 $\text{None}$ 。
|
||||
- **环形链表**:如果我们令单向链表的尾节点指向头节点(首尾相接),则得到一个环形链表。在环形链表中,任意节点都可以视作头节点。
|
||||
- **双向链表**:与单向链表相比,双向链表记录了两个方向的引用。双向链表的节点定义同时包含指向后继节点(下一个节点)和前驱节点(上一个节点)的引用(指针)。相较于单向链表,双向链表更具灵活性,可以朝两个方向遍历链表,但相应地也需要占用更多的内存空间。
|
||||
|
||||
=== "Python"
|
||||
@@ -652,17 +652,17 @@
|
||||
|
||||
单向链表通常用于实现栈、队列、哈希表和图等数据结构。
|
||||
|
||||
- **栈与队列**:当插入和删除操作都在链表的一端进行时,它表现出先进后出的的特性,对应栈;当插入操作在链表的一端进行,删除操作在链表的另一端进行,它表现出先进先出的特性,对应队列。
|
||||
- **哈希表**:链地址法是解决哈希冲突的主流方案之一,在该方案中,所有冲突的元素都会被放到一个链表中。
|
||||
- **图**:邻接表是表示图的一种常用方式,在其中,图的每个顶点都与一个链表相关联,链表中的每个元素都代表与该顶点相连的其他顶点。
|
||||
- **栈与队列**:当插入和删除操作都在链表的一端进行时,它表现出先进后出的特性,对应栈;当插入操作在链表的一端进行,删除操作在链表的另一端进行,它表现出先进先出的特性,对应队列。
|
||||
- **哈希表**:链式地址是解决哈希冲突的主流方案之一,在该方案中,所有冲突的元素都会被放到一个链表中。
|
||||
- **图**:邻接表是表示图的一种常用方式,其中图的每个顶点都与一个链表相关联,链表中的每个元素都代表与该顶点相连的其他顶点。
|
||||
|
||||
双向链表常被用于需要快速查找前一个和下一个元素的场景。
|
||||
双向链表常用于需要快速查找前一个和后一个元素的场景。
|
||||
|
||||
- **高级数据结构**:比如在红黑树、B 树中,我们需要访问节点的父节点,这可以通过在节点中保存一个指向父节点的引用来实现,类似于双向链表。
|
||||
- **浏览器历史**:在网页浏览器中,当用户点击前进或后退按钮时,浏览器需要知道用户访问过的前一个和后一个网页。双向链表的特性使得这种操作变得简单。
|
||||
- **LRU 算法**:在缓存淘汰算法(LRU)中,我们需要快速找到最近最少使用的数据,以及支持快速地添加和删除节点。这时候使用双向链表就非常合适。
|
||||
- **LRU 算法**:在缓存淘汰(LRU)算法中,我们需要快速找到最近最少使用的数据,以及支持快速添加和删除节点。这时候使用双向链表就非常合适。
|
||||
|
||||
循环链表常被用于需要周期性操作的场景,比如操作系统的资源调度。
|
||||
环形链表常用于需要周期性操作的场景,比如操作系统的资源调度。
|
||||
|
||||
- **时间片轮转调度算法**:在操作系统中,时间片轮转调度算法是一种常见的 CPU 调度算法,它需要对一组进程进行循环。每个进程被赋予一个时间片,当时间片用完时,CPU 将切换到下一个进程。这种循环的操作就可以通过循环链表来实现。
|
||||
- **数据缓冲区**:在某些数据缓冲区的实现中,也可能会使用到循环链表。比如在音频、视频播放器中,数据流可能会被分成多个缓冲块并放入一个循环链表,以便实现无缝播放。
|
||||
- **时间片轮转调度算法**:在操作系统中,时间片轮转调度算法是一种常见的 CPU 调度算法,它需要对一组进程进行循环。每个进程被赋予一个时间片,当时间片用完时,CPU 将切换到下一个进程。这种循环操作可以通过环形链表来实现。
|
||||
- **数据缓冲区**:在某些数据缓冲区的实现中,也可能会使用环形链表。比如在音频、视频播放器中,数据流可能会被分成多个缓冲块并放入一个环形链表,以便实现无缝播放。
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# 列表
|
||||
|
||||
「列表 list」是一个抽象的数据结构概念,它表示元素的有序集合,支持元素访问、修改、添加、删除和遍历等操作,无需使用者考虑容量限制的问题。列表可以基于链表或数组实现。
|
||||
「列表 list」是一个抽象的数据结构概念,它表示元素的有序集合,支持元素访问、修改、添加、删除和遍历等操作,无须使用者考虑容量限制的问题。列表可以基于链表或数组实现。
|
||||
|
||||
- 链表天然可以被看作是一个列表,其支持元素增删查改操作,并且可以灵活动态扩容。
|
||||
- 数组也支持元素增删查改,但由于其长度不可变,因此只能被看作是一个具有长度限制的列表。
|
||||
- 链表天然可以被看作一个列表,其支持元素增删查改操作,并且可以灵活动态扩容。
|
||||
- 数组也支持元素增删查改,但由于其长度不可变,因此只能被看作一个具有长度限制的列表。
|
||||
|
||||
当使用数组实现列表时,**长度不可变的性质会导致列表的实用性降低**。这是因为我们通常无法事先确定需要存储多少数据,从而难以选择合适的列表长度。若长度过小,则很可能无法满足使用需求;若长度过大,则会造成内存空间的浪费。
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
### 初始化列表
|
||||
|
||||
我们通常使用“无初始值”和“有初始值”这两种初始化方法。
|
||||
我们通常使用“无初始值”和“有初始值”这两种初始化方法:
|
||||
|
||||
=== "Python"
|
||||
|
||||
@@ -264,14 +264,14 @@
|
||||
# 清空列表
|
||||
nums.clear()
|
||||
|
||||
# 尾部添加元素
|
||||
# 在尾部添加元素
|
||||
nums.append(1)
|
||||
nums.append(3)
|
||||
nums.append(2)
|
||||
nums.append(5)
|
||||
nums.append(4)
|
||||
|
||||
# 中间插入元素
|
||||
# 在中间插入元素
|
||||
nums.insert(3, 6) # 在索引 3 处插入数字 6
|
||||
|
||||
# 删除元素
|
||||
@@ -284,14 +284,14 @@
|
||||
/* 清空列表 */
|
||||
nums.clear();
|
||||
|
||||
/* 尾部添加元素 */
|
||||
/* 在尾部添加元素 */
|
||||
nums.push_back(1);
|
||||
nums.push_back(3);
|
||||
nums.push_back(2);
|
||||
nums.push_back(5);
|
||||
nums.push_back(4);
|
||||
|
||||
/* 中间插入元素 */
|
||||
/* 在中间插入元素 */
|
||||
nums.insert(nums.begin() + 3, 6); // 在索引 3 处插入数字 6
|
||||
|
||||
/* 删除元素 */
|
||||
@@ -304,14 +304,14 @@
|
||||
/* 清空列表 */
|
||||
nums.clear();
|
||||
|
||||
/* 尾部添加元素 */
|
||||
/* 在尾部添加元素 */
|
||||
nums.add(1);
|
||||
nums.add(3);
|
||||
nums.add(2);
|
||||
nums.add(5);
|
||||
nums.add(4);
|
||||
|
||||
/* 中间插入元素 */
|
||||
/* 在中间插入元素 */
|
||||
nums.add(3, 6); // 在索引 3 处插入数字 6
|
||||
|
||||
/* 删除元素 */
|
||||
@@ -324,14 +324,14 @@
|
||||
/* 清空列表 */
|
||||
nums.Clear();
|
||||
|
||||
/* 尾部添加元素 */
|
||||
/* 在尾部添加元素 */
|
||||
nums.Add(1);
|
||||
nums.Add(3);
|
||||
nums.Add(2);
|
||||
nums.Add(5);
|
||||
nums.Add(4);
|
||||
|
||||
/* 中间插入元素 */
|
||||
/* 在中间插入元素 */
|
||||
nums.Insert(3, 6);
|
||||
|
||||
/* 删除元素 */
|
||||
@@ -344,14 +344,14 @@
|
||||
/* 清空列表 */
|
||||
nums = nil
|
||||
|
||||
/* 尾部添加元素 */
|
||||
/* 在尾部添加元素 */
|
||||
nums = append(nums, 1)
|
||||
nums = append(nums, 3)
|
||||
nums = append(nums, 2)
|
||||
nums = append(nums, 5)
|
||||
nums = append(nums, 4)
|
||||
|
||||
/* 中间插入元素 */
|
||||
/* 在中间插入元素 */
|
||||
nums = append(nums[:3], append([]int{6}, nums[3:]...)...) // 在索引 3 处插入数字 6
|
||||
|
||||
/* 删除元素 */
|
||||
@@ -364,14 +364,14 @@
|
||||
/* 清空列表 */
|
||||
nums.removeAll()
|
||||
|
||||
/* 尾部添加元素 */
|
||||
/* 在尾部添加元素 */
|
||||
nums.append(1)
|
||||
nums.append(3)
|
||||
nums.append(2)
|
||||
nums.append(5)
|
||||
nums.append(4)
|
||||
|
||||
/* 中间插入元素 */
|
||||
/* 在中间插入元素 */
|
||||
nums.insert(6, at: 3) // 在索引 3 处插入数字 6
|
||||
|
||||
/* 删除元素 */
|
||||
@@ -384,14 +384,14 @@
|
||||
/* 清空列表 */
|
||||
nums.length = 0;
|
||||
|
||||
/* 尾部添加元素 */
|
||||
/* 在尾部添加元素 */
|
||||
nums.push(1);
|
||||
nums.push(3);
|
||||
nums.push(2);
|
||||
nums.push(5);
|
||||
nums.push(4);
|
||||
|
||||
/* 中间插入元素 */
|
||||
/* 在中间插入元素 */
|
||||
nums.splice(3, 0, 6);
|
||||
|
||||
/* 删除元素 */
|
||||
@@ -404,14 +404,14 @@
|
||||
/* 清空列表 */
|
||||
nums.length = 0;
|
||||
|
||||
/* 尾部添加元素 */
|
||||
/* 在尾部添加元素 */
|
||||
nums.push(1);
|
||||
nums.push(3);
|
||||
nums.push(2);
|
||||
nums.push(5);
|
||||
nums.push(4);
|
||||
|
||||
/* 中间插入元素 */
|
||||
/* 在中间插入元素 */
|
||||
nums.splice(3, 0, 6);
|
||||
|
||||
/* 删除元素 */
|
||||
@@ -424,14 +424,14 @@
|
||||
/* 清空列表 */
|
||||
nums.clear();
|
||||
|
||||
/* 尾部添加元素 */
|
||||
/* 在尾部添加元素 */
|
||||
nums.add(1);
|
||||
nums.add(3);
|
||||
nums.add(2);
|
||||
nums.add(5);
|
||||
nums.add(4);
|
||||
|
||||
/* 中间插入元素 */
|
||||
/* 在中间插入元素 */
|
||||
nums.insert(3, 6); // 在索引 3 处插入数字 6
|
||||
|
||||
/* 删除元素 */
|
||||
@@ -444,14 +444,14 @@
|
||||
/* 清空列表 */
|
||||
nums.clear();
|
||||
|
||||
/* 尾部添加元素 */
|
||||
/* 在尾部添加元素 */
|
||||
nums.push(1);
|
||||
nums.push(3);
|
||||
nums.push(2);
|
||||
nums.push(5);
|
||||
nums.push(4);
|
||||
|
||||
/* 中间插入元素 */
|
||||
/* 在中间插入元素 */
|
||||
nums.insert(3, 6); // 在索引 3 处插入数字 6
|
||||
|
||||
/* 删除元素 */
|
||||
@@ -470,14 +470,14 @@
|
||||
// 清空列表
|
||||
nums.clearRetainingCapacity();
|
||||
|
||||
// 尾部添加元素
|
||||
// 在尾部添加元素
|
||||
try nums.append(1);
|
||||
try nums.append(3);
|
||||
try nums.append(2);
|
||||
try nums.append(5);
|
||||
try nums.append(4);
|
||||
|
||||
// 中间插入元素
|
||||
// 在中间插入元素
|
||||
try nums.insert(3, 6); // 在索引 3 处插入数字 6
|
||||
|
||||
// 删除元素
|
||||
@@ -669,7 +669,7 @@
|
||||
|
||||
### 拼接列表
|
||||
|
||||
给定一个新列表 `nums1` ,我们可以将该列表拼接到原列表的尾部。
|
||||
给定一个新列表 `nums1` ,我们可以将其拼接到原列表的尾部。
|
||||
|
||||
=== "Python"
|
||||
|
||||
@@ -770,7 +770,7 @@
|
||||
|
||||
### 排序列表
|
||||
|
||||
完成列表排序后,我们便可以使用在数组类算法题中经常考察的“二分查找”和“双指针”算法。
|
||||
完成列表排序后,我们便可以使用在数组类算法题中经常考查的“二分查找”和“双指针”算法。
|
||||
|
||||
=== "Python"
|
||||
|
||||
@@ -857,13 +857,13 @@
|
||||
|
||||
## 列表实现
|
||||
|
||||
许多编程语言都提供内置的列表,例如 Java、C++、Python 等。它们的实现比较复杂,各个参数的设定也非常有考究,例如初始容量、扩容倍数等。感兴趣的读者可以查阅源码进行学习。
|
||||
许多编程语言内置了列表,例如 Java、C++、Python 等。它们的实现比较复杂,各个参数的设定也非常考究,例如初始容量、扩容倍数等。感兴趣的读者可以查阅源码进行学习。
|
||||
|
||||
为了加深对列表工作原理的理解,我们尝试实现一个简易版列表,包括以下三个重点设计。
|
||||
|
||||
- **初始容量**:选取一个合理的数组初始容量。在本示例中,我们选择 10 作为初始容量。
|
||||
- **数量记录**:声明一个变量 `size` ,用于记录列表当前元素数量,并随着元素插入和删除实时更新。根据此变量,我们可以定位列表尾部,以及判断是否需要扩容。
|
||||
- **扩容机制**:若插入元素时列表容量已满,则需要进行扩容。首先根据扩容倍数创建一个更大的数组,再将当前数组的所有元素依次移动至新数组。在本示例中,我们规定每次将数组扩容至之前的 2 倍。
|
||||
- **扩容机制**:若插入元素时列表容量已满,则需要进行扩容。先根据扩容倍数创建一个更大的数组,再将当前数组的所有元素依次移动至新数组。在本示例中,我们规定每次将数组扩容至之前的 2 倍。
|
||||
|
||||
```src
|
||||
[file]{my_list}-[class]{my_list}-[func]{}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 13 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 19 KiB |
@@ -6,11 +6,11 @@
|
||||
|
||||
## 计算机存储设备
|
||||
|
||||
计算机中包括三种不同类型的存储设备:硬盘、内存、缓存。下表展示了它们在计算机系统中的不同角色和性能特点。
|
||||
计算机中包括三种不同类型的存储设备:「硬盘 hard disk」、「内存 random-access memory, RAM」、「缓存 cache memory」。下表展示了它们在计算机系统中的不同角色和性能特点。
|
||||
|
||||
<p align="center"> 表 <id> 计算机的存储设备 </p>
|
||||
|
||||
| | 硬盘 Hard Disk | 内存 RAM | 缓存 Cache |
|
||||
| | 硬盘 | 内存 | 缓存 |
|
||||
| ------ | ---------------------------------------- | -------------------------------------- | ------------------------------------------------- |
|
||||
| 用途 | 长期存储数据,包括操作系统、程序、文件等 | 临时存储当前运行的程序和正在处理的数据 | 存储经常访问的数据和指令,减少 CPU 访问内存的次数 |
|
||||
| 易失性 | 断电后数据不会丢失 | 断电后数据会丢失 | 断电后数据会丢失 |
|
||||
@@ -31,7 +31,7 @@
|
||||
|
||||
总的来说,**硬盘用于长期存储大量数据,内存用于临时存储程序运行中正在处理的数据,而缓存则用于存储经常访问的数据和指令**,以提高程序运行效率。这三者共同协作,确保计算机系统的高效运行。
|
||||
|
||||
如下图所示,在程序运行时,数据会从硬盘中被读取到内存中,供给 CPU 计算使用。缓存可以看作是 CPU 的一部分,**它通过智能地从内存加载数据**,给 CPU 提供高速的数据读取,从而显著提升程序的执行效率,减少对较慢的内存的依赖。
|
||||
如下图所示,在程序运行时,数据会从硬盘中被读取到内存中,供给 CPU 计算使用。缓存可以看作 CPU 的一部分,**它通过智能地从内存加载数据**,给 CPU 提供高速的数据读取,从而显著提升程序的执行效率,减少对较慢的内存的依赖。
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
- 数组和链表是两种基本的数据结构,分别代表数据在计算机内存中的两种存储方式:连续空间存储和分散空间存储。两者的特点呈现出互补的特性。
|
||||
- 数组支持随机访问、占用内存较少;但插入和删除元素效率低,且初始化后长度不可变。
|
||||
- 链表通过更改引用(指针)实现高效的节点插入与删除,且可以灵活调整长度;但节点访问效率低、占用内存较多。
|
||||
- 常见的链表类型包括单向链表、循环链表、双向链表,它们分别具有各自的应用场景。
|
||||
- 常见的链表类型包括单向链表、环形链表、双向链表,它们分别具有各自的应用场景。
|
||||
- 列表是一种支持增删查改的元素有序集合,通常基于动态数组实现,其保留了数组的优势,同时可以灵活调整长度。
|
||||
- 列表的出现大幅地提高了数组的实用性,但可能导致部分内存空间浪费。
|
||||
- 程序运行时,数据主要存储在内存中。数组提供更高的内存空间效率,而链表则在内存使用上更加灵活。
|
||||
@@ -26,7 +26,7 @@
|
||||
|
||||
链表由节点组成,节点之间通过引用(指针)连接,各个节点可以存储不同类型的数据,例如 int、double、string、object 等。
|
||||
|
||||
相对地,数组元素则必须是相同类型的,这样才能通过计算偏移量来获取对应元素位置。例如,如果数组同时包含 int 和 long 两种类型,单个元素分别占用 4 bytes 和 8 bytes ,那么此时就不能用以下公式计算偏移量了,因为数组中包含了两种长度的元素。
|
||||
相对地,数组元素则必须是相同类型的,这样才能通过计算偏移量来获取对应元素位置。例如,数组同时包含 int 和 long 两种类型,单个元素分别占用 4 bytes 和 8 bytes ,此时就不能用以下公式计算偏移量了,因为数组中包含了两种长度的元素。
|
||||
|
||||
```shell
|
||||
# 元素内存地址 = 数组内存地址 + 元素长度 * 元素索引
|
||||
@@ -34,17 +34,17 @@
|
||||
|
||||
!!! question "删除节点后,是否需要把 `P.next` 设为 $\text{None}$ 呢?"
|
||||
|
||||
不修改 `P.next` 也可以。从该链表的角度看,从头节点遍历到尾节点已经遇不到 `P` 了。这意味着节点 `P` 已经从链表中删除了,此时节点 `P` 指向哪里都不会对这条链表产生影响了。
|
||||
不修改 `P.next` 也可以。从该链表的角度看,从头节点遍历到尾节点已经不会遇到 `P` 了。这意味着节点 `P` 已经从链表中删除了,此时节点 `P` 指向哪里都不会对该链表产生影响。
|
||||
|
||||
从垃圾回收的角度看,对于 Java、Python、Go 等拥有自动垃圾回收的语言来说,节点 `P` 是否被回收取决于是否仍存在指向它的引用,而不是 `P.next` 的值。在 C 和 C++ 等语言中,我们需要手动释放节点内存。
|
||||
从垃圾回收的角度看,对于 Java、Python、Go 等拥有自动垃圾回收机制的语言来说,节点 `P` 是否被回收取决于是否仍存在指向它的引用,而不是 `P.next` 的值。在 C 和 C++ 等语言中,我们需要手动释放节点内存。
|
||||
|
||||
!!! question "在链表中插入和删除操作的时间复杂度是 $O(1)$ 。但是增删之前都需要 $O(n)$ 查找元素,那为什么时间复杂度不是 $O(n)$ 呢?"
|
||||
!!! question "在链表中插入和删除操作的时间复杂度是 $O(1)$ 。但是增删之前都需要 $O(n)$ 的时间查找元素,那为什么时间复杂度不是 $O(n)$ 呢?"
|
||||
|
||||
如果是先查找元素、再删除元素,确实是 $O(n)$ 。然而,链表的 $O(1)$ 增删的优势可以在其他应用上得到体现。例如,双向队列适合使用链表实现,我们维护一个指针变量始终指向头节点、尾节点,每次插入与删除操作都是 $O(1)$ 。
|
||||
如果是先查找元素、再删除元素,时间复杂度确实是 $O(n)$ 。然而,链表的 $O(1)$ 增删的优势可以在其他应用上得到体现。例如,双向队列适合使用链表实现,我们维护一个指针变量始终指向头节点、尾节点,每次插入与删除操作都是 $O(1)$ 。
|
||||
|
||||
!!! question "图片“链表定义与存储方式”中,浅蓝色的存储节点指针是占用一块内存地址吗?还是和节点值各占一半呢?"
|
||||
!!! question "图“链表定义与存储方式”中,浅蓝色的存储节点指针是占用一块内存地址吗?还是和节点值各占一半呢?"
|
||||
|
||||
文中的示意图只是定性表示,定量表示需要根据具体情况进行分析。
|
||||
该示意图只是定性表示,定量表示需要根据具体情况进行分析。
|
||||
|
||||
- 不同类型的节点值占用的空间是不同的,比如 int、long、double 和实例对象等。
|
||||
- 指针变量占用的内存空间大小根据所使用的操作系统及编译环境而定,大多为 8 字节或 4 字节。
|
||||
@@ -53,22 +53,22 @@
|
||||
|
||||
如果添加元素时超出列表长度,则需要先扩容列表再添加。系统会申请一块新的内存,并将原列表的所有元素搬运过去,这时候时间复杂度就会是 $O(n)$ 。
|
||||
|
||||
!!! question "“列表的出现大大提升了数组的实用性,但副作用是会造成部分内存空间浪费”,这里的空间浪费是指额外增加的变量如容量、长度、扩容倍数所占的内存吗?"
|
||||
!!! question "“列表的出现极大地提高了数组的实用性,但可能导致部分内存空间浪费”,这里的空间浪费是指额外增加的变量如容量、长度、扩容倍数所占的内存吗?"
|
||||
|
||||
这里的空间浪费主要有两方面含义:一方面,列表都会设定一个初始长度,我们不一定需要用这么多。另一方面,为了防止频繁扩容,扩容一般都会乘以一个系数,比如 $\times 1.5$ 。这样一来,也会出现很多空位,我们通常不能完全填满它们。
|
||||
这里的空间浪费主要有两方面含义:一方面,列表都会设定一个初始长度,我们不一定需要用这么多;另一方面,为了防止频繁扩容,扩容一般会乘以一个系数,比如 $\times 1.5$ 。这样一来,也会出现很多空位,我们通常不能完全填满它们。
|
||||
|
||||
!!! question "在 Python 中初始化 `n = [1, 2, 3]` 后,这 3 个元素的地址是相连的,但是初始化 `m = [2, 1, 3]` 会发现它们每个元素的 id 并不是连续的,而是分别跟 `n` 中的相同。这些元素地址不连续,那么 `m` 还是数组吗?"
|
||||
!!! question "在 Python 中初始化 `n = [1, 2, 3]` 后,这 3 个元素的地址是相连的,但是初始化 `m = [2, 1, 3]` 会发现它们每个元素的 id 并不是连续的,而是分别跟 `n` 中的相同。这些元素的地址不连续,那么 `m` 还是数组吗?"
|
||||
|
||||
假如把列表元素换成链表节点 `n = [n1, n2, n3, n4, n5]` ,通常情况下这五个节点对象也是被分散存储在内存各处的。然而,给定一个列表索引,我们仍然可以在 $O(1)$ 时间内获取到节点内存地址,从而访问到对应的节点。这是因为数组中存储的是节点的引用,而非节点本身。
|
||||
假如把列表元素换成链表节点 `n = [n1, n2, n3, n4, n5]` ,通常情况下这 5 个节点对象也分散存储在内存各处。然而,给定一个列表索引,我们仍然可以在 $O(1)$ 时间内获取节点内存地址,从而访问到对应的节点。这是因为数组中存储的是节点的引用,而非节点本身。
|
||||
|
||||
与许多语言不同的是,在 Python 中数字也被包装为对象,列表中存储的不是数字本身,而是对数字的引用。因此,我们会发现两个数组中的相同数字拥有同一个 id ,并且这些数字的内存地址是无须连续的。
|
||||
与许多语言不同,Python 中的数字也被包装为对象,列表中存储的不是数字本身,而是对数字的引用。因此,我们会发现两个数组中的相同数字拥有同一个 id ,并且这些数字的内存地址无须连续。
|
||||
|
||||
!!! question "C++ STL 里面的 `std::list` 已经实现了双向链表,但好像一些算法的书上都不怎么直接用这个,是不是有什么局限性呢?"
|
||||
!!! question "C++ STL 里面的 `std::list` 已经实现了双向链表,但好像一些算法书上不怎么直接使用它,是不是因为有什么局限性呢?"
|
||||
|
||||
一方面,我们往往更青睐使用数组实现算法,而只有在必要时才使用链表,主要有两个原因。
|
||||
一方面,我们往往更青睐使用数组实现算法,而只在必要时才使用链表,主要有两个原因。
|
||||
|
||||
- 空间开销:由于每个元素需要两个额外的指针(一个用于前一个元素,一个用于后一个元素),所以 `std::list` 通常比 `std::vector` 更占用空间。
|
||||
- 缓存不友好:由于数据不是连续存放的,`std::list` 对缓存的利用率较低。一般情况下,`std::vector` 的性能会更好。
|
||||
- 缓存不友好:由于数据不是连续存放的,因此 `std::list` 对缓存的利用率较低。一般情况下,`std::vector` 的性能会更好。
|
||||
|
||||
另一方面,必要使用链表的情况主要是二叉树和图。栈和队列往往会使用编程语言提供的 `stack` 和 `queue` ,而非链表。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user