This commit is contained in:
krahets
2023-08-17 05:12:05 +08:00
parent f0826da7f6
commit 97c532b228
67 changed files with 1481 additions and 1066 deletions
File diff suppressed because it is too large Load Diff
+99 -69
View File
@@ -4,15 +4,21 @@ comments: true
# 4.2.   链表
内存空间是所有程序的公共资源,排除已被占用的内存空间,空闲内存空间通常散落在内存各处。在上一节中,我们提到存储数组的内存空间必须是连续的,而当需要申请一个非常大的数组时,空闲内存可能没有这么大的连续空间。与数组相比,链表更具灵活性,它可以被存储在非连续的内存空间中
内存空间是所有程序的公共资源,在一个复杂的系统运行环境下,空闲内存空间可能散落在内存各处。我们知道,存储数组的内存空间必须是连续的,而当数组非常大时,内存可能无法提供如此大的连续空间。此时链表的灵活性优势就体现出来了
「链表 Linked List」是一种线性数据结构,其每个元素都是一个节点对象,各个节点之间通过指针连接,从当前节点通过指针可以访问到下一个节点。**由于指针记录了下个节点的内存地址,因此无需保证内存地址的连续性**,从而可以将各个节点分散存储在内存各处
链表中的「节点 Node」包含两项数据,一是节点「值 Value」,二是指向下一节点的「引用 Reference」,或称「指针 Pointer」。
「链表 Linked List」是一种线性数据结构,其中的每个元素都是一个节点对象,各个节点通过“引用”相连接。引用记录了下一个节点的内存地址,我们可以通过它从当前节点访问到下一个节点。这意味着链表的各个节点可以被分散存储在内存各处,它们的内存地址是无需连续的
![链表定义与存储方式](linked_list.assets/linkedlist_definition.png)
<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. &nbsp; 链表常用操作
**链表初始化方法**。建立链表分为两步,第一步是初始化各个节点对象,第二步是构建引用指向关系。完成后,即可以从链表的头节点(即首个节点)出发,通过指针 `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. &nbsp; 链表优
### 插入节
**链表中插入与删除节点的操作效率高**。如果我们想在链表中间的两个节点 `A` , `B` 之间插入一个新节点 `P` 我们只需要改变两个节点指针即可,时间复杂度为 $O(1)$ ;相比之下,数组的插入操作效率要低得多
**链表中插入节点非常容易**。假设我们想在相邻的两个节点 `n0` , `n1` 之间插入一个新节点 `P` 只需要改变两个节点引用(指针即可,时间复杂度为 $O(1)$ 。
相比之下,在数组中插入元素的时间复杂度为 $O(n)$ ,在大数据量下的效率较低。
![链表插入节点](linked_list.assets/linkedlist_insert_node.png)
<p align="center"> Fig. 链表插入节点 </p>
<p align="center"> 图:链表插入节点 </p>
=== "Java"
@@ -533,11 +543,15 @@ comments: true
}
```
在链表中删除节点也非常方便,只需改变一个节点的指针即可。如下图所示,尽管在删除操作完成后,节点 `P` 仍然指向 `n1` ,但实际上 `P` 已经不再属于此链表,因为遍历此链表时无法访问到 `P` 。
### 删除节点
在链表中删除节点也非常简便,只需改变一个节点的引用(指针)即可。
请注意,尽管在删除操作完成后节点 `P` 仍然指向 `n1` ,但实际上遍历此链表已经无法访问到 `P` ,这意味着 `P` 已经不再属于该链表了。
![链表删除节点](linked_list.assets/linkedlist_remove_node.png)
<p align="center"> Fig. 链表删除节点 </p>
<p align="center"> 图:链表删除节点 </p>
=== "Java"
@@ -714,9 +728,9 @@ comments: true
}
```
## 4.2.2. &nbsp; 链表缺
### 访问节
**链表访问节点效率较低**。如上节所述,数组可以在 $O(1)$ 时间下访问任意元素。然而链表无法直接访问任意节点,因为程序需要从头节点出发,逐个向后遍历,直至找到目标节点。也就是说,如果想要访问链表第 $i$ 个节点,则需要向后遍历 $i - 1$ 轮。
**链表访问节点效率较低**。如上节所述,我们可以在 $O(1)$ 时间下访问数组中的任意元素。链表则不然,程序需要从头节点出发,逐个向后遍历,直至找到目标节点。也就是说,访问链表第 $i$ 个节点需要循环 $i - 1$ 轮,时间复杂度为 $O(n)$
=== "Java"
@@ -887,11 +901,9 @@ comments: true
}
```
**链表的内存占用较大**。链表以节点为单位,每个节点除了包含值,还需额外保存下一节点的引用(指针)。这意味着在相同数据量的情况下,链表比数组需要占用更多的内存空间。
### 查找节点
## 4.2.3. &nbsp; 链表常用操作
**遍历链表查找**。遍历链表,查找链表内值为 `target` 的节点,输出节点在链表中的索引。
遍历链表,查找链表内值为 `target` 的节点,输出节点在链表中的索引。此过程也属于「线性查找」。
=== "Java"
@@ -1086,13 +1098,31 @@ comments: true
}
```
## 4.2.4. &nbsp; 常见链表类型
## 4.2.2. &nbsp; 数组 VS 链表
**单向链表**。即上述介绍的普通链表。单向链表的节点包含值和指向下一节点的指针(引用)两项数据。我们将首个节点称为头节点,将最后一个节点成为尾节点,尾节点指向空 $\text{None}$
下表总结对比了数组和链表的各项特点与操作效率。由于它们采用两种相反的存储策略,因此各种性质和操作效率也呈现对立的特点
<div class="center-table" markdown>
| | 数组 | 链表 |
| ---------- | ------------------------ | ------------ |
| 存储方式 | 连续内存空间 | 离散内存空间 |
| 缓存局部性 | 友好 | 不友好 |
| 容量扩展 | 长度不可变 | 可灵活扩展 |
| 内存效率 | 占用内存少、浪费部分空间 | 占用内存多 |
| 访问元素 | $O(1)$ | $O(n)$ |
| 添加元素 | $O(n)$ | $O(1)$ |
| 删除元素 | $O(n)$ | $O(1)$ |
</div>
## 4.2.3. &nbsp; 常见链表类型
**单向链表**。即上述介绍的普通链表。单向链表的节点包含值和指向下一节点的引用两项数据。我们将首个节点称为头节点,将最后一个节点成为尾节点,尾节点指向空 $\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
![常见链表种类](linked_list.assets/linkedlist_common_types.png)
<p align="center"> Fig. 常见链表种类 </p>
<p align="center"> 图:常见链表种类 </p>
## 4.2.5. &nbsp; 链表典型应用
## 4.2.4. &nbsp; 链表典型应用
单向链表通常用于实现栈、队列、散列表和图等数据结构。
@@ -1305,7 +1335,7 @@ comments: true
双向链表常被用于需要快速查找前一个和下一个元素的场景。
- **高级数据结构**:比如在红黑树、B 树中,我们需要知道一个节点的父节点,这可以通过在节点中保存一个指向父节点的指针来实现,类似于双向链表。
- **高级数据结构**:比如在红黑树、B 树中,我们需要访问节点的父节点,这可以通过在节点中保存一个指向父节点的引用来实现,类似于双向链表。
- **浏览器历史**:在网页浏览器中,当用户点击前进或后退按钮时,浏览器需要知道用户访问过的前一个和后一个网页。双向链表的特性使得这种操作变得简单。
- **LRU 算法**:在缓存淘汰算法(LRU)中,我们需要快速找到最近最少使用的数据,以及支持快速地添加和删除节点。这时候使用双向链表就非常合适。
+25 -13
View File
@@ -4,13 +4,15 @@ comments: true
# 4.3. &nbsp; 列表
**数组长度不可变导致实用性降低**。在许多情况下,我们事先无法确定需要存储多少数据,这使数组长度的选择变得困难。若长度过小,需要在持续添加数据时频繁扩容数组;若长度过大,则会造成内存空间的浪费。
**数组长度不可变导致实用性降低**。在实际中,我们可能事先无法确定需要存储多少数据,这使数组长度的选择变得困难。若长度过小,需要在持续添加数据时频繁扩容数组;若长度过大,则会造成内存空间的浪费。
为解决此问题,出现了一种被称为「动态数组 Dynamic Array」的数据结构,即长度可变的数组,也常被称为「列表 List」。列表基于数组实现,继承了数组的优点,并且可以在程序运行过程中动态扩容。在列表中,我们可以自由添加元素,而无需担心超过容量限制。
为解决此问题,出现了一种被称为「动态数组 Dynamic Array」的数据结构,即长度可变的数组,也常被称为「列表 List」。列表基于数组实现,继承了数组的优点,并且可以在程序运行过程中动态扩容。我们可以在列表中自由添加元素,而无需担心超过容量限制。
## 4.3.1. &nbsp; 列表常用操作
**初始化列表**。通常我们会使用“无初始值”和“有初始值”的两种初始化方法。
### 初始化列表
我们通常使用“无初始值”和“有初始值”这两种初始化方法。
=== "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. &nbsp; 列表实现 *
## 4.3.2. &nbsp; 列表实现
为了帮助加深对列表的理解,我们在此提供一个简易版列表实现。需要关注三个核心点:
许多编程语言都提供内置的列表,例如 Java, C++, Python 等。它们的实现比较复杂,各个参数的设定也非常有考究,例如初始容量、扩容倍数等。感兴趣的读者可以查阅源码进行学习。
为了帮助你理解列表的工作原理,我们在此提供一个简易版列表实现,重点包括:
- **初始容量**:选取一个合理的数组初始容量。在本示例中,我们选择 10 作为初始容量。
- **数量记录**:声明一个变量 size,用于记录列表当前元素数量,并随着元素插入和删除实时更新。根据此变量,我们可以定位列表尾部,以及判断是否需要扩容。
- **扩容机制**:插入元素时可能超出列表容量,此时需要扩容列表。扩容方法是根据扩容倍数创建一个更大的数组,将当前数组的所有元素依次移动至新数组。在本示例中,我们规定每次将数组扩容至之前的 2 倍。
本示例旨在帮助读者直观理解列表的工作机制。实际编程语言中,列表实现更加标准和复杂,各个参数的设定也非常有考究,例如初始容量、扩容倍数等。感兴趣的读者可以查阅源码进行学习。
- **扩容机制**插入元素时列表容量已满,则需要进行扩容。首先根据扩容倍数创建一个更大的数组,将当前数组的所有元素依次移动至新数组。在本示例中,我们规定每次将数组扩容至之前的 2 倍。
=== "Java"
+2 -21
View File
@@ -4,29 +4,10 @@ comments: true
# 4.4. &nbsp; 小结
- 数组和链表是两种基本数据结构,分别代表数据在计算机内存中的连续空间存储和离散空间存储方式。两者的优缺点呈现出互补的特性。
- 数组和链表是两种基本数据结构,分别代表数据在计算机内存中的两种存储方式:连续空间存储和离散空间存储。两者的点呈现出互补的特性。
- 数组支持随机访问、占用内存较少;但插入和删除元素效率低,且初始化后长度不可变。
- 链表通过更改引用(指针)实现高效的节点插入与删除,且可以灵活调整长度;但节点访问效率低、占用内存较多。常见的链表类型包括单向链表、循环链表、双向链表。
- 动态数组,又称列表,是基于数组实现的一种数据结构。它保留了数组的优势,同时可以灵活调整长度。列表的出现极大地提高了数组的易用性,但可能导致部分内存空间浪费。
- 下表总结并对比了数组与链表的各项特性与操作效率。
<div class="center-table" markdown>
| | 数组 | 链表 |
| ------------ | ------------------------ | ------------ |
| 存储方式 | 连续内存空间 | 离散内存空间 |
| 数据结构长度 | 长度不可变 | 长度可变 |
| 内存使用率 | 占用内存少、缓存局部性好 | 占用内存多 |
| 优势操作 | 随机访问 | 插入、删除 |
| 访问元素 | $O(1)$ | $O(N)$ |
| 添加元素 | $O(N)$ | $O(1)$ |
| 删除元素 | $O(N)$ | $O(1)$ |
</div>
!!! note "缓存局部性"
在计算机中,数据读写速度排序是“硬盘 < 内存 < CPU 缓存”。当我们访问数组元素时,计算机不仅会加载它,还会缓存其周围的其他数据,从而借助高速缓存来提升后续操作的执行速度。链表则不然,计算机只能挨个地缓存各个节点,这样的多次“搬运”降低了整体效率。
## 4.4.1. &nbsp; Q & A
@@ -38,7 +19,7 @@ comments: true
2. 栈是一块比较小的内存,容易出现内存不足;堆内存很大,但是由于是动态分配,容易碎片化,管理堆内存的难度更大、成本更高。
3. 访问栈比访问堆更快,因为栈内存较小、对缓存友好,堆帧分散在很大的空间内,会出现更多的缓存未命中。
!!! question "为什么数组会强调要求相同类型的元素,而在链表中却没有强调同类型呢?"
!!! question "为什么数组要求相同类型的元素,而在链表中却没有强调同类型呢?"
链表由结点组成,结点之间通过引用(指针)连接,各个结点可以存储不同类型的数据,例如 int, double, string, object 等。