mirror of
https://github.com/krahets/hello-algo.git
synced 2026-07-12 15:36:05 +00:00
build
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -4,15 +4,21 @@ comments: true
|
||||
|
||||
# 4.2. 链表
|
||||
|
||||
内存空间是所有程序的公共资源,排除已被占用的内存空间,空闲内存空间通常散落在内存各处。在上一节中,我们提到存储数组的内存空间必须是连续的,而当需要申请一个非常大的数组时,空闲内存中可能没有这么大的连续空间。与数组相比,链表更具灵活性,它可以被存储在非连续的内存空间中。
|
||||
内存空间是所有程序的公共资源,在一个复杂的系统运行环境下,空闲的内存空间可能散落在内存各处。我们知道,存储数组的内存空间必须是连续的,而当数组非常大时,内存可能无法提供如此大的连续空间。此时链表的灵活性优势就体现出来了。
|
||||
|
||||
「链表 Linked List」是一种线性数据结构,其每个元素都是一个节点对象,各个节点之间通过指针连接,从当前节点通过指针可以访问到下一个节点。**由于指针记录了下个节点的内存地址,因此无需保证内存地址的连续性**,从而可以将各个节点分散存储在内存各处。
|
||||
|
||||
链表中的「节点 Node」包含两项数据,一是节点「值 Value」,二是指向下一节点的「引用 Reference」,或称「指针 Pointer」。
|
||||
「链表 Linked List」是一种线性数据结构,其中的每个元素都是一个节点对象,各个节点通过“引用”相连接。引用记录了下一个节点的内存地址,我们可以通过它从当前节点访问到下一个节点。这意味着链表的各个节点可以被分散存储在内存各处,它们的内存地址是无需连续的。
|
||||
|
||||

|
||||
|
||||
<p align="center"> Fig. 链表定义与存储方式 </p>
|
||||
<p align="center"> 图:链表定义与存储方式 </p>
|
||||
|
||||
观察上图,链表中的每个「节点 Node」对象都包含两项数据:节点的“值”、指向下一节点的“引用”。
|
||||
|
||||
- 链表的首个节点被称为“头节点”,最后一个节点被称为“尾节点”。
|
||||
- 尾节点指向的是“空”,它在 Java, C++, Python 中分别被记为 $\text{null}$ , $\text{nullptr}$ , $\text{None}$ 。
|
||||
- 在 C, C++, Go, Rust 等支持指针的语言中,上述的“引用”应被替换为“指针”。
|
||||
|
||||
如以下代码所示,链表以节点对象 `ListNode` 为单位,每个节点除了包含值,还需额外保存下一节点的引用(指针)。因此在相同数据量下,**链表通常比数组占用更多的内存空间**。
|
||||
|
||||
=== "Java"
|
||||
|
||||
@@ -20,7 +26,7 @@ comments: true
|
||||
/* 链表节点类 */
|
||||
class ListNode {
|
||||
int val; // 节点值
|
||||
ListNode next; // 指向下一节点的指针(引用)
|
||||
ListNode next; // 指向下一节点的引用
|
||||
ListNode(int x) { val = x; } // 构造函数
|
||||
}
|
||||
```
|
||||
@@ -31,7 +37,7 @@ comments: true
|
||||
/* 链表节点结构体 */
|
||||
struct ListNode {
|
||||
int val; // 节点值
|
||||
ListNode *next; // 指向下一节点的指针(引用)
|
||||
ListNode *next; // 指向下一节点的指针
|
||||
ListNode(int x) : val(x), next(nullptr) {} // 构造函数
|
||||
};
|
||||
```
|
||||
@@ -43,7 +49,7 @@ comments: true
|
||||
"""链表节点类"""
|
||||
def __init__(self, val: int):
|
||||
self.val: int = val # 节点值
|
||||
self.next: Optional[ListNode] = None # 指向下一节点的指针(引用)
|
||||
self.next: Optional[ListNode] = None # 指向下一节点的引用
|
||||
```
|
||||
|
||||
=== "Go"
|
||||
@@ -52,9 +58,9 @@ comments: true
|
||||
/* 链表节点结构体 */
|
||||
type ListNode struct {
|
||||
Val int // 节点值
|
||||
Next *ListNode // 指向下一节点的指针(引用)
|
||||
Next *ListNode // 指向下一节点的指针
|
||||
}
|
||||
|
||||
|
||||
// NewListNode 构造函数,创建一个新的链表
|
||||
func NewListNode(val int) *ListNode {
|
||||
return &ListNode{
|
||||
@@ -98,7 +104,7 @@ comments: true
|
||||
/* 链表节点结构体 */
|
||||
struct ListNode {
|
||||
int val; // 节点值
|
||||
struct ListNode *next; // 指向下一节点的指针(引用)
|
||||
struct ListNode *next; // 指向下一节点的指针
|
||||
};
|
||||
|
||||
typedef struct ListNode ListNode;
|
||||
@@ -130,7 +136,7 @@ comments: true
|
||||
/* 链表节点类 */
|
||||
class ListNode {
|
||||
var val: Int // 节点值
|
||||
var next: ListNode? // 指向下一节点的指针(引用)
|
||||
var next: ListNode? // 指向下一节点的引用
|
||||
|
||||
init(x: Int) { // 构造函数
|
||||
val = x
|
||||
@@ -145,9 +151,9 @@ comments: true
|
||||
pub fn ListNode(comptime T: type) type {
|
||||
return struct {
|
||||
const Self = @This();
|
||||
|
||||
|
||||
val: T = 0, // 节点值
|
||||
next: ?*Self = null, // 指向下一节点的指针(引用)
|
||||
next: ?*Self = null, // 指向下一节点的指针
|
||||
|
||||
// 构造函数
|
||||
pub fn init(self: *Self, x: i32) void {
|
||||
@@ -164,7 +170,7 @@ comments: true
|
||||
/* 链表节点类 */
|
||||
class ListNode {
|
||||
int val; // 节点值
|
||||
ListNode? next; // 指向下一节点的指针(引用)
|
||||
ListNode? next; // 指向下一节点的引用
|
||||
ListNode(this.val, [this.next]); // 构造函数
|
||||
}
|
||||
```
|
||||
@@ -178,19 +184,21 @@ comments: true
|
||||
#[derive(Debug)]
|
||||
struct ListNode {
|
||||
val: i32, // 节点值
|
||||
next: Option<Rc<RefCell<ListNode>>>, // 指向下一节点的指针(引用)
|
||||
next: Option<Rc<RefCell<ListNode>>>, // 指向下一节点的指针
|
||||
}
|
||||
```
|
||||
|
||||
我们将链表的首个节点称为「头节点」,最后一个节点称为「尾节点」。尾节点指向的是“空”,在 Java, C++, Python 中分别记为 $\text{null}$ , $\text{nullptr}$ , $\text{None}$ 。在不引起歧义的前提下,本书都使用 $\text{None}$ 来表示空。
|
||||
## 4.2.1. 链表常用操作
|
||||
|
||||
**链表初始化方法**。建立链表分为两步,第一步是初始化各个节点对象,第二步是构建引用指向关系。完成后,即可以从链表的头节点(即首个节点)出发,通过指针 `next` 依次访问所有节点。
|
||||
### 初始化链表
|
||||
|
||||
建立链表分为两步,第一步是初始化各个节点对象,第二步是构建引用指向关系。初始化完成后,我们就可以从链表的头节点出发,通过引用指向 `next` 依次访问所有节点。
|
||||
|
||||
=== "Java"
|
||||
|
||||
```java title="linked_list.java"
|
||||
/* 初始化链表 1 -> 3 -> 2 -> 5 -> 4 */
|
||||
// 初始化各个节点
|
||||
// 初始化各个节点
|
||||
ListNode n0 = new ListNode(1);
|
||||
ListNode n1 = new ListNode(3);
|
||||
ListNode n2 = new ListNode(2);
|
||||
@@ -207,7 +215,7 @@ comments: true
|
||||
|
||||
```cpp title="linked_list.cpp"
|
||||
/* 初始化链表 1 -> 3 -> 2 -> 5 -> 4 */
|
||||
// 初始化各个节点
|
||||
// 初始化各个节点
|
||||
ListNode* n0 = new ListNode(1);
|
||||
ListNode* n1 = new ListNode(3);
|
||||
ListNode* n2 = new ListNode(2);
|
||||
@@ -224,7 +232,7 @@ comments: true
|
||||
|
||||
```python title="linked_list.py"
|
||||
# 初始化链表 1 -> 3 -> 2 -> 5 -> 4
|
||||
# 初始化各个节点
|
||||
# 初始化各个节点
|
||||
n0 = ListNode(1)
|
||||
n1 = ListNode(3)
|
||||
n2 = ListNode(2)
|
||||
@@ -292,7 +300,7 @@ comments: true
|
||||
|
||||
```c title="linked_list.c"
|
||||
/* 初始化链表 1 -> 3 -> 2 -> 5 -> 4 */
|
||||
// 初始化各个节点
|
||||
// 初始化各个节点
|
||||
ListNode* n0 = newListNode(1);
|
||||
ListNode* n1 = newListNode(3);
|
||||
ListNode* n2 = newListNode(2);
|
||||
@@ -309,7 +317,7 @@ comments: true
|
||||
|
||||
```csharp title="linked_list.cs"
|
||||
/* 初始化链表 1 -> 3 -> 2 -> 5 -> 4 */
|
||||
// 初始化各个节点
|
||||
// 初始化各个节点
|
||||
ListNode n0 = new ListNode(1);
|
||||
ListNode n1 = new ListNode(3);
|
||||
ListNode n2 = new ListNode(2);
|
||||
@@ -343,7 +351,7 @@ comments: true
|
||||
|
||||
```zig title="linked_list.zig"
|
||||
// 初始化链表
|
||||
// 初始化各个节点
|
||||
// 初始化各个节点
|
||||
var n0 = inc.ListNode(i32){.val = 1};
|
||||
var n1 = inc.ListNode(i32){.val = 3};
|
||||
var n2 = inc.ListNode(i32){.val = 2};
|
||||
@@ -391,15 +399,17 @@ comments: true
|
||||
n3.borrow_mut().next = Some(n4.clone());
|
||||
```
|
||||
|
||||
在编程语言中,数组整体是一个变量,比如数组 `nums` 包含元素 `nums[0]` , `nums[1]` 等。而链表是由多个分散的节点对象组成,**我们通常将头节点当作链表的代称**,比如以上代码中的链表可被记做链表 `n0` 。
|
||||
数组整体是一个变量,比如数组 `nums` 包含元素 `nums[0]` , `nums[1]` 等,而链表是由多个独立的节点对象组成的。**我们通常将头节点当作链表的代称**,比如以上代码中的链表可被记做链表 `n0` 。
|
||||
|
||||
## 4.2.1. 链表优点
|
||||
### 插入节点
|
||||
|
||||
**链表中插入与删除节点的操作效率高**。如果我们想在链表中间的两个节点 `A` , `B` 之间插入一个新节点 `P` ,我们只需要改变两个节点指针即可,时间复杂度为 $O(1)$ ;相比之下,数组的插入操作效率要低得多。
|
||||
**在链表中插入节点非常容易**。假设我们想在相邻的两个节点 `n0` , `n1` 之间插入一个新节点 `P` ,则只需要改变两个节点引用(指针)即可,时间复杂度为 $O(1)$ 。
|
||||
|
||||
相比之下,在数组中插入元素的时间复杂度为 $O(n)$ ,在大数据量下的效率较低。
|
||||
|
||||

|
||||
|
||||
<p align="center"> Fig. 链表插入节点 </p>
|
||||
<p align="center"> 图:链表插入节点 </p>
|
||||
|
||||
=== "Java"
|
||||
|
||||
@@ -533,11 +543,15 @@ comments: true
|
||||
}
|
||||
```
|
||||
|
||||
在链表中删除节点也非常方便,只需改变一个节点的指针即可。如下图所示,尽管在删除操作完成后,节点 `P` 仍然指向 `n1` ,但实际上 `P` 已经不再属于此链表,因为遍历此链表时无法访问到 `P` 。
|
||||
### 删除节点
|
||||
|
||||
在链表中删除节点也非常简便,只需改变一个节点的引用(指针)即可。
|
||||
|
||||
请注意,尽管在删除操作完成后节点 `P` 仍然指向 `n1` ,但实际上遍历此链表已经无法访问到 `P` ,这意味着 `P` 已经不再属于该链表了。
|
||||
|
||||

|
||||
|
||||
<p align="center"> Fig. 链表删除节点 </p>
|
||||
<p align="center"> 图:链表删除节点 </p>
|
||||
|
||||
=== "Java"
|
||||
|
||||
@@ -714,9 +728,9 @@ comments: true
|
||||
}
|
||||
```
|
||||
|
||||
## 4.2.2. 链表缺点
|
||||
### 访问节点
|
||||
|
||||
**链表访问节点效率较低**。如上节所述,数组可以在 $O(1)$ 时间下访问任意元素。然而链表无法直接访问任意节点,因为程序需要从头节点出发,逐个向后遍历,直至找到目标节点。也就是说,如果想要访问链表中第 $i$ 个节点,则需要向后遍历 $i - 1$ 轮。
|
||||
**在链表访问节点的效率较低**。如上节所述,我们可以在 $O(1)$ 时间下访问数组中的任意元素。链表则不然,程序需要从头节点出发,逐个向后遍历,直至找到目标节点。也就是说,访问链表的第 $i$ 个节点需要循环 $i - 1$ 轮,时间复杂度为 $O(n)$ 。
|
||||
|
||||
=== "Java"
|
||||
|
||||
@@ -887,11 +901,9 @@ comments: true
|
||||
}
|
||||
```
|
||||
|
||||
**链表的内存占用较大**。链表以节点为单位,每个节点除了包含值,还需额外保存下一节点的引用(指针)。这意味着在相同数据量的情况下,链表比数组需要占用更多的内存空间。
|
||||
### 查找节点
|
||||
|
||||
## 4.2.3. 链表常用操作
|
||||
|
||||
**遍历链表查找**。遍历链表,查找链表内值为 `target` 的节点,输出节点在链表中的索引。
|
||||
遍历链表,查找链表内值为 `target` 的节点,输出节点在链表中的索引。此过程也属于「线性查找」。
|
||||
|
||||
=== "Java"
|
||||
|
||||
@@ -1086,13 +1098,31 @@ comments: true
|
||||
}
|
||||
```
|
||||
|
||||
## 4.2.4. 常见链表类型
|
||||
## 4.2.2. 数组 VS 链表
|
||||
|
||||
**单向链表**。即上述介绍的普通链表。单向链表的节点包含值和指向下一节点的指针(引用)两项数据。我们将首个节点称为头节点,将最后一个节点成为尾节点,尾节点指向空 $\text{None}$ 。
|
||||
下表总结对比了数组和链表的各项特点与操作效率。由于它们采用两种相反的存储策略,因此各种性质和操作效率也呈现对立的特点。
|
||||
|
||||
<div class="center-table" markdown>
|
||||
|
||||
| | 数组 | 链表 |
|
||||
| ---------- | ------------------------ | ------------ |
|
||||
| 存储方式 | 连续内存空间 | 离散内存空间 |
|
||||
| 缓存局部性 | 友好 | 不友好 |
|
||||
| 容量扩展 | 长度不可变 | 可灵活扩展 |
|
||||
| 内存效率 | 占用内存少、浪费部分空间 | 占用内存多 |
|
||||
| 访问元素 | $O(1)$ | $O(n)$ |
|
||||
| 添加元素 | $O(n)$ | $O(1)$ |
|
||||
| 删除元素 | $O(n)$ | $O(1)$ |
|
||||
|
||||
</div>
|
||||
|
||||
## 4.2.3. 常见链表类型
|
||||
|
||||
**单向链表**。即上述介绍的普通链表。单向链表的节点包含值和指向下一节点的引用两项数据。我们将首个节点称为头节点,将最后一个节点成为尾节点,尾节点指向空 $\text{None}$ 。
|
||||
|
||||
**环形链表**。如果我们令单向链表的尾节点指向头节点(即首尾相接),则得到一个环形链表。在环形链表中,任意节点都可以视作头节点。
|
||||
|
||||
**双向链表**。与单向链表相比,双向链表记录了两个方向的指针(引用)。双向链表的节点定义同时包含指向后继节点(下一节点)和前驱节点(上一节点)的指针。相较于单向链表,双向链表更具灵活性,可以朝两个方向遍历链表,但相应地也需要占用更多的内存空间。
|
||||
**双向链表**。与单向链表相比,双向链表记录了两个方向的引用。双向链表的节点定义同时包含指向后继节点(下一个节点)和前驱节点(上一个节点)的引用(指针)。相较于单向链表,双向链表更具灵活性,可以朝两个方向遍历链表,但相应地也需要占用更多的内存空间。
|
||||
|
||||
=== "Java"
|
||||
|
||||
@@ -1100,8 +1130,8 @@ comments: true
|
||||
/* 双向链表节点类 */
|
||||
class ListNode {
|
||||
int val; // 节点值
|
||||
ListNode next; // 指向后继节点的指针(引用)
|
||||
ListNode prev; // 指向前驱节点的指针(引用)
|
||||
ListNode next; // 指向后继节点的引用
|
||||
ListNode prev; // 指向前驱节点的引用
|
||||
ListNode(int x) { val = x; } // 构造函数
|
||||
}
|
||||
```
|
||||
@@ -1112,8 +1142,8 @@ comments: true
|
||||
/* 双向链表节点结构体 */
|
||||
struct ListNode {
|
||||
int val; // 节点值
|
||||
ListNode *next; // 指向后继节点的指针(引用)
|
||||
ListNode *prev; // 指向前驱节点的指针(引用)
|
||||
ListNode *next; // 指向后继节点的指针
|
||||
ListNode *prev; // 指向前驱节点的指针
|
||||
ListNode(int x) : val(x), next(nullptr), prev(nullptr) {} // 构造函数
|
||||
};
|
||||
```
|
||||
@@ -1125,8 +1155,8 @@ comments: true
|
||||
"""双向链表节点类"""
|
||||
def __init__(self, val: int):
|
||||
self.val: int = val # 节点值
|
||||
self.next: Optional[ListNode] = None # 指向后继节点的指针(引用)
|
||||
self.prev: Optional[ListNode] = None # 指向前驱节点的指针(引用)
|
||||
self.next: Optional[ListNode] = None # 指向后继节点的引用
|
||||
self.prev: Optional[ListNode] = None # 指向前驱节点的引用
|
||||
```
|
||||
|
||||
=== "Go"
|
||||
@@ -1135,10 +1165,10 @@ comments: true
|
||||
/* 双向链表节点结构体 */
|
||||
type DoublyListNode struct {
|
||||
Val int // 节点值
|
||||
Next *DoublyListNode // 指向后继节点的指针(引用)
|
||||
Prev *DoublyListNode // 指向前驱节点的指针(引用)
|
||||
Next *DoublyListNode // 指向后继节点的指针
|
||||
Prev *DoublyListNode // 指向前驱节点的指针
|
||||
}
|
||||
|
||||
|
||||
// NewDoublyListNode 初始化
|
||||
func NewDoublyListNode(val int) *DoublyListNode {
|
||||
return &DoublyListNode{
|
||||
@@ -1159,8 +1189,8 @@ comments: true
|
||||
prev;
|
||||
constructor(val, next, prev) {
|
||||
this.val = val === undefined ? 0 : val; // 节点值
|
||||
this.next = next === undefined ? null : next; // 指向后继节点的指针(引用)
|
||||
this.prev = prev === undefined ? null : prev; // 指向前驱节点的指针(引用)
|
||||
this.next = next === undefined ? null : next; // 指向后继节点的引用
|
||||
this.prev = prev === undefined ? null : prev; // 指向前驱节点的引用
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -1175,8 +1205,8 @@ comments: true
|
||||
prev: ListNode | null;
|
||||
constructor(val?: number, next?: ListNode | null, prev?: ListNode | null) {
|
||||
this.val = val === undefined ? 0 : val; // 节点值
|
||||
this.next = next === undefined ? null : next; // 指向后继节点的指针(引用)
|
||||
this.prev = prev === undefined ? null : prev; // 指向前驱节点的指针(引用)
|
||||
this.next = next === undefined ? null : next; // 指向后继节点的引用
|
||||
this.prev = prev === undefined ? null : prev; // 指向前驱节点的引用
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -1187,8 +1217,8 @@ comments: true
|
||||
/* 双向链表节点结构体 */
|
||||
struct ListNode {
|
||||
int val; // 节点值
|
||||
struct ListNode *next; // 指向后继节点的指针(引用)
|
||||
struct ListNode *prev; // 指向前驱节点的指针(引用)
|
||||
struct ListNode *next; // 指向后继节点的指针
|
||||
struct ListNode *prev; // 指向前驱节点的指针
|
||||
};
|
||||
|
||||
typedef struct ListNode ListNode;
|
||||
@@ -1210,8 +1240,8 @@ comments: true
|
||||
/* 双向链表节点类 */
|
||||
class ListNode {
|
||||
int val; // 节点值
|
||||
ListNode next; // 指向后继节点的指针(引用)
|
||||
ListNode prev; // 指向前驱节点的指针(引用)
|
||||
ListNode next; // 指向后继节点的引用
|
||||
ListNode prev; // 指向前驱节点的引用
|
||||
ListNode(int x) => val = x; // 构造函数
|
||||
}
|
||||
```
|
||||
@@ -1222,8 +1252,8 @@ comments: true
|
||||
/* 双向链表节点类 */
|
||||
class ListNode {
|
||||
var val: Int // 节点值
|
||||
var next: ListNode? // 指向后继节点的指针(引用)
|
||||
var prev: ListNode? // 指向前驱节点的指针(引用)
|
||||
var next: ListNode? // 指向后继节点的引用
|
||||
var prev: ListNode? // 指向前驱节点的引用
|
||||
|
||||
init(x: Int) { // 构造函数
|
||||
val = x
|
||||
@@ -1238,10 +1268,10 @@ comments: true
|
||||
pub fn ListNode(comptime T: type) type {
|
||||
return struct {
|
||||
const Self = @This();
|
||||
|
||||
|
||||
val: T = 0, // 节点值
|
||||
next: ?*Self = null, // 指向后继节点的指针(引用)
|
||||
prev: ?*Self = null, // 指向前驱节点的指针(引用)
|
||||
next: ?*Self = null, // 指向后继节点的指针
|
||||
prev: ?*Self = null, // 指向前驱节点的指针
|
||||
|
||||
// 构造函数
|
||||
pub fn init(self: *Self, x: i32) void {
|
||||
@@ -1259,8 +1289,8 @@ comments: true
|
||||
/* 双向链表节点类 */
|
||||
class ListNode {
|
||||
int val; // 节点值
|
||||
ListNode next; // 指向后继节点的指针(引用)
|
||||
ListNode prev; // 指向前驱节点的指针(引用)
|
||||
ListNode next; // 指向后继节点的引用
|
||||
ListNode prev; // 指向前驱节点的引用
|
||||
ListNode(this.val, [this.next, this.prev]); // 构造函数
|
||||
}
|
||||
```
|
||||
@@ -1275,10 +1305,10 @@ comments: true
|
||||
#[derive(Debug)]
|
||||
struct ListNode {
|
||||
val: i32, // 节点值
|
||||
next: Option<Rc<RefCell<ListNode>>>, // 指向后继节点的指针(引用)
|
||||
prev: Option<Rc<RefCell<ListNode>>>, // 指向前驱节点的指针(引用)
|
||||
next: Option<Rc<RefCell<ListNode>>>, // 指向后继节点的指针
|
||||
prev: Option<Rc<RefCell<ListNode>>>, // 指向前驱节点的指针
|
||||
}
|
||||
|
||||
|
||||
/* 构造函数 */
|
||||
impl ListNode {
|
||||
fn new(val: i32) -> Self {
|
||||
@@ -1293,9 +1323,9 @@ comments: true
|
||||
|
||||

|
||||
|
||||
<p align="center"> Fig. 常见链表种类 </p>
|
||||
<p align="center"> 图:常见链表种类 </p>
|
||||
|
||||
## 4.2.5. 链表典型应用
|
||||
## 4.2.4. 链表典型应用
|
||||
|
||||
单向链表通常用于实现栈、队列、散列表和图等数据结构。
|
||||
|
||||
@@ -1305,7 +1335,7 @@ comments: true
|
||||
|
||||
双向链表常被用于需要快速查找前一个和下一个元素的场景。
|
||||
|
||||
- **高级数据结构**:比如在红黑树、B 树中,我们需要知道一个节点的父节点,这可以通过在节点中保存一个指向父节点的指针来实现,类似于双向链表。
|
||||
- **高级数据结构**:比如在红黑树、B 树中,我们需要访问节点的父节点,这可以通过在节点中保存一个指向父节点的引用来实现,类似于双向链表。
|
||||
- **浏览器历史**:在网页浏览器中,当用户点击前进或后退按钮时,浏览器需要知道用户访问过的前一个和后一个网页。双向链表的特性使得这种操作变得简单。
|
||||
- **LRU 算法**:在缓存淘汰算法(LRU)中,我们需要快速找到最近最少使用的数据,以及支持快速地添加和删除节点。这时候使用双向链表就非常合适。
|
||||
|
||||
|
||||
@@ -4,13 +4,15 @@ comments: true
|
||||
|
||||
# 4.3. 列表
|
||||
|
||||
**数组长度不可变导致实用性降低**。在许多情况下,我们事先无法确定需要存储多少数据,这使数组长度的选择变得困难。若长度过小,需要在持续添加数据时频繁扩容数组;若长度过大,则会造成内存空间的浪费。
|
||||
**数组长度不可变导致实用性降低**。在实际中,我们可能事先无法确定需要存储多少数据,这使数组长度的选择变得困难。若长度过小,需要在持续添加数据时频繁扩容数组;若长度过大,则会造成内存空间的浪费。
|
||||
|
||||
为解决此问题,出现了一种被称为「动态数组 Dynamic Array」的数据结构,即长度可变的数组,也常被称为「列表 List」。列表基于数组实现,继承了数组的优点,并且可以在程序运行过程中动态扩容。在列表中,我们可以自由添加元素,而无需担心超过容量限制。
|
||||
为解决此问题,出现了一种被称为「动态数组 Dynamic Array」的数据结构,即长度可变的数组,也常被称为「列表 List」。列表基于数组实现,继承了数组的优点,并且可以在程序运行过程中动态扩容。我们可以在列表中自由地添加元素,而无需担心超过容量限制。
|
||||
|
||||
## 4.3.1. 列表常用操作
|
||||
|
||||
**初始化列表**。通常我们会使用“无初始值”和“有初始值”的两种初始化方法。
|
||||
### 初始化列表
|
||||
|
||||
我们通常使用“无初始值”和“有初始值”这两种初始化方法。
|
||||
|
||||
=== "Java"
|
||||
|
||||
@@ -130,7 +132,9 @@ comments: true
|
||||
let list2: Vec<i32> = vec![1, 3, 2, 5, 4];
|
||||
```
|
||||
|
||||
**访问与更新元素**。由于列表的底层数据结构是数组,因此可以在 $O(1)$ 时间内访问和更新元素,效率很高。
|
||||
### 访问元素
|
||||
|
||||
列表本质上是数组,因此可以在 $O(1)$ 时间内访问和更新元素,效率很高。
|
||||
|
||||
=== "Java"
|
||||
|
||||
@@ -247,7 +251,9 @@ comments: true
|
||||
list[1] = 0; // 将索引 1 处的元素更新为 0
|
||||
```
|
||||
|
||||
**在列表中添加、插入、删除元素**。相较于数组,列表可以自由地添加与删除元素。在列表尾部添加元素的时间复杂度为 $O(1)$ ,但插入和删除元素的效率仍与数组相同,时间复杂度为 $O(N)$ 。
|
||||
### 插入与删除元素
|
||||
|
||||
相较于数组,列表可以自由地添加与删除元素。在列表尾部添加元素的时间复杂度为 $O(1)$ ,但插入和删除元素的效率仍与数组相同,时间复杂度为 $O(n)$ 。
|
||||
|
||||
=== "Java"
|
||||
|
||||
@@ -475,7 +481,9 @@ comments: true
|
||||
list.remove(3); // 删除索引 3 处的元素
|
||||
```
|
||||
|
||||
**遍历列表**。与数组一样,列表可以根据索引遍历,也可以直接遍历各元素。
|
||||
### 遍历列表
|
||||
|
||||
与数组一样,列表可以根据索引遍历,也可以直接遍历各元素。
|
||||
|
||||
=== "Java"
|
||||
|
||||
@@ -658,7 +666,9 @@ comments: true
|
||||
}
|
||||
```
|
||||
|
||||
**拼接两个列表**。给定一个新列表 `list1` ,我们可以将该列表拼接到原列表的尾部。
|
||||
### 拼接列表
|
||||
|
||||
给定一个新列表 `list1` ,我们可以将该列表拼接到原列表的尾部。
|
||||
|
||||
=== "Java"
|
||||
|
||||
@@ -757,7 +767,9 @@ comments: true
|
||||
list.extend(list1);
|
||||
```
|
||||
|
||||
**排序列表**。排序也是常用的方法之一。完成列表排序后,我们便可以使用在数组类算法题中经常考察的「二分查找」和「双指针」算法。
|
||||
### 排序列表
|
||||
|
||||
完成列表排序后,我们便可以使用在数组类算法题中经常考察的“二分查找”和“双指针”算法。
|
||||
|
||||
=== "Java"
|
||||
|
||||
@@ -842,15 +854,15 @@ comments: true
|
||||
list.sort(); // 排序后,列表元素从小到大排列
|
||||
```
|
||||
|
||||
## 4.3.2. 列表实现 *
|
||||
## 4.3.2. 列表实现
|
||||
|
||||
为了帮助加深对列表的理解,我们在此提供一个简易版列表实现。需要关注三个核心点:
|
||||
许多编程语言都提供内置的列表,例如 Java, C++, Python 等。它们的实现比较复杂,各个参数的设定也非常有考究,例如初始容量、扩容倍数等。感兴趣的读者可以查阅源码进行学习。
|
||||
|
||||
为了帮助你理解列表的工作原理,我们在此提供一个简易版列表实现,重点包括:
|
||||
|
||||
- **初始容量**:选取一个合理的数组初始容量。在本示例中,我们选择 10 作为初始容量。
|
||||
- **数量记录**:声明一个变量 size,用于记录列表当前元素数量,并随着元素插入和删除实时更新。根据此变量,我们可以定位列表尾部,以及判断是否需要扩容。
|
||||
- **扩容机制**:插入元素时可能超出列表容量,此时需要扩容列表。扩容方法是根据扩容倍数创建一个更大的数组,并将当前数组的所有元素依次移动至新数组。在本示例中,我们规定每次将数组扩容至之前的 2 倍。
|
||||
|
||||
本示例旨在帮助读者直观理解列表的工作机制。实际编程语言中,列表实现更加标准和复杂,各个参数的设定也非常有考究,例如初始容量、扩容倍数等。感兴趣的读者可以查阅源码进行学习。
|
||||
- **扩容机制**:若插入元素时列表容量已满,则需要进行扩容。首先根据扩容倍数创建一个更大的数组,再将当前数组的所有元素依次移动至新数组。在本示例中,我们规定每次将数组扩容至之前的 2 倍。
|
||||
|
||||
=== "Java"
|
||||
|
||||
|
||||
@@ -4,29 +4,10 @@ comments: true
|
||||
|
||||
# 4.4. 小结
|
||||
|
||||
- 数组和链表是两种基本数据结构,分别代表数据在计算机内存中的连续空间存储和离散空间存储方式。两者的优缺点呈现出互补的特性。
|
||||
- 数组和链表是两种基本的数据结构,分别代表数据在计算机内存中的两种存储方式:连续空间存储和离散空间存储。两者的特点呈现出互补的特性。
|
||||
- 数组支持随机访问、占用内存较少;但插入和删除元素效率低,且初始化后长度不可变。
|
||||
- 链表通过更改引用(指针)实现高效的节点插入与删除,且可以灵活调整长度;但节点访问效率低、占用内存较多。常见的链表类型包括单向链表、循环链表、双向链表。
|
||||
- 动态数组,又称列表,是基于数组实现的一种数据结构。它保留了数组的优势,同时可以灵活调整长度。列表的出现极大地提高了数组的易用性,但可能导致部分内存空间浪费。
|
||||
- 下表总结并对比了数组与链表的各项特性与操作效率。
|
||||
|
||||
<div class="center-table" markdown>
|
||||
|
||||
| | 数组 | 链表 |
|
||||
| ------------ | ------------------------ | ------------ |
|
||||
| 存储方式 | 连续内存空间 | 离散内存空间 |
|
||||
| 数据结构长度 | 长度不可变 | 长度可变 |
|
||||
| 内存使用率 | 占用内存少、缓存局部性好 | 占用内存多 |
|
||||
| 优势操作 | 随机访问 | 插入、删除 |
|
||||
| 访问元素 | $O(1)$ | $O(N)$ |
|
||||
| 添加元素 | $O(N)$ | $O(1)$ |
|
||||
| 删除元素 | $O(N)$ | $O(1)$ |
|
||||
|
||||
</div>
|
||||
|
||||
!!! note "缓存局部性"
|
||||
|
||||
在计算机中,数据读写速度排序是“硬盘 < 内存 < CPU 缓存”。当我们访问数组元素时,计算机不仅会加载它,还会缓存其周围的其他数据,从而借助高速缓存来提升后续操作的执行速度。链表则不然,计算机只能挨个地缓存各个节点,这样的多次“搬运”降低了整体效率。
|
||||
|
||||
## 4.4.1. Q & A
|
||||
|
||||
@@ -38,7 +19,7 @@ comments: true
|
||||
2. 栈是一块比较小的内存,容易出现内存不足;堆内存很大,但是由于是动态分配,容易碎片化,管理堆内存的难度更大、成本更高。
|
||||
3. 访问栈比访问堆更快,因为栈内存较小、对缓存友好,堆帧分散在很大的空间内,会出现更多的缓存未命中。
|
||||
|
||||
!!! question "为什么数组会强调要求相同类型的元素,而在链表中却没有强调同类型呢?"
|
||||
!!! question "为什么数组要求相同类型的元素,而在链表中却没有强调同类型呢?"
|
||||
|
||||
链表由结点组成,结点之间通过引用(指针)连接,各个结点可以存储不同类型的数据,例如 int, double, string, object 等。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user