mirror of
https://github.com/krahets/hello-algo.git
synced 2024-12-25 00:16:28 +08:00
Polish the chapter of array and linkedlist
This commit is contained in:
parent
0858ab91c0
commit
c310edb672
26 changed files with 287 additions and 236 deletions
|
@ -6,7 +6,7 @@
|
|||
|
||||
#include "../utils/common.h"
|
||||
|
||||
/* 随机返回一个数组元素 */
|
||||
/* 随机访问元素 */
|
||||
int randomAccess(int *nums, int size) {
|
||||
// 在区间 [0, size) 中随机抽取一个数字
|
||||
int randomIndex = rand() % size;
|
||||
|
|
|
@ -14,7 +14,7 @@ extern "C" {
|
|||
/* 链表节点结构体 */
|
||||
struct ListNode {
|
||||
int val; // 节点值
|
||||
struct ListNode *next; // 指向下一节点的指针(引用)
|
||||
struct ListNode *next; // 指向下一节点的引用
|
||||
};
|
||||
|
||||
// typedef 作用是为一种数据类型定义一个新名字
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
#include "../utils/common.hpp"
|
||||
|
||||
/* 随机返回一个数组元素 */
|
||||
/* 随机访问元素 */
|
||||
int randomAccess(int *nums, int size) {
|
||||
// 在区间 [0, size) 中随机抽取一个数字
|
||||
int randomIndex = rand() % size;
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
namespace hello_algo.chapter_array_and_linkedlist;
|
||||
|
||||
public class array {
|
||||
/* 随机返回一个数组元素 */
|
||||
/* 随机访问元素 */
|
||||
public static int randomAccess(int[] nums) {
|
||||
Random random = new();
|
||||
// 在区间 [0, nums.Length) 中随机抽取一个数字
|
||||
|
|
|
@ -9,8 +9,8 @@ namespace hello_algo.chapter_stack_and_queue;
|
|||
/* 双向链表节点 */
|
||||
public class ListNode {
|
||||
public int val; // 节点值
|
||||
public ListNode? next; // 后继节点引用(指针)
|
||||
public ListNode? prev; // 前驱节点引用(指针)
|
||||
public ListNode? next; // 后继节点引用
|
||||
public ListNode? prev; // 前驱节点引用
|
||||
|
||||
public ListNode(int val) {
|
||||
this.val = val;
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
import 'dart:math';
|
||||
|
||||
/* 随机返回一个数组元素 */
|
||||
/* 随机访问元素 */
|
||||
int randomAccess(List nums) {
|
||||
// 在区间 [0, nums.length) 中随机抽取一个数字
|
||||
int randomIndex = Random().nextInt(nums.length);
|
||||
|
|
|
@ -7,8 +7,8 @@
|
|||
/* 双向链表节点 */
|
||||
class ListNode {
|
||||
int val; // 节点值
|
||||
ListNode? next; // 后继节点引用(指针)
|
||||
ListNode? prev; // 前驱节点引用(指针)
|
||||
ListNode? next; // 后继节点引用
|
||||
ListNode? prev; // 前驱节点引用
|
||||
|
||||
ListNode(this.val, {this.next, this.prev});
|
||||
}
|
||||
|
|
|
@ -8,7 +8,7 @@ import (
|
|||
"math/rand"
|
||||
)
|
||||
|
||||
/* 随机返回一个数组元素 */
|
||||
/* 随机访问元素 */
|
||||
func randomAccess(nums []int) (randomNum int) {
|
||||
// 在区间 [0, nums.length) 中随机抽取一个数字
|
||||
randomIndex := rand.Intn(len(nums))
|
||||
|
|
|
@ -10,7 +10,7 @@ import java.util.*;
|
|||
import java.util.concurrent.ThreadLocalRandom;
|
||||
|
||||
public class array {
|
||||
/* 随机返回一个数组元素 */
|
||||
/* 随机访问元素 */
|
||||
static int randomAccess(int[] nums) {
|
||||
// 在区间 [0, nums.length) 中随机抽取一个数字
|
||||
int randomIndex = ThreadLocalRandom.current().nextInt(0, nums.length);
|
||||
|
|
|
@ -11,8 +11,8 @@ import java.util.*;
|
|||
/* 双向链表节点 */
|
||||
class ListNode {
|
||||
int val; // 节点值
|
||||
ListNode next; // 后继节点引用(指针)
|
||||
ListNode prev; // 前驱节点引用(指针)
|
||||
ListNode next; // 后继节点引用
|
||||
ListNode prev; // 前驱节点引用
|
||||
|
||||
ListNode(int val) {
|
||||
this.val = val;
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
* Author: IsChristina (christinaxia77@foxmail.com)
|
||||
*/
|
||||
|
||||
/* 随机返回一个数组元素 */
|
||||
/* 随机访问元素 */
|
||||
function randomAccess(nums) {
|
||||
// 在区间 [0, nums.length) 中随机抽取一个数字
|
||||
const random_index = Math.floor(Math.random() * nums.length);
|
||||
|
|
|
@ -11,8 +11,8 @@ class ListNode:
|
|||
def __init__(self, val: int):
|
||||
"""构造方法"""
|
||||
self.val: int = val
|
||||
self.next: ListNode | None = None # 后继节点引用(指针)
|
||||
self.prev: ListNode | None = None # 前驱节点引用(指针)
|
||||
self.next: ListNode | None = None # 后继节点引用
|
||||
self.prev: ListNode | None = None # 前驱节点引用
|
||||
|
||||
|
||||
class LinkedListDeque:
|
||||
|
|
|
@ -8,7 +8,7 @@ include!("../include/include.rs");
|
|||
|
||||
use rand::Rng;
|
||||
|
||||
/* 随机返回一个数组元素 */
|
||||
/* 随机访问元素 */
|
||||
fn random_access(nums: &[i32]) -> i32 {
|
||||
// 在区间 [0, nums.len()) 中随机抽取一个数字
|
||||
let random_index = rand::thread_rng().gen_range(0..nums.len());
|
||||
|
|
|
@ -12,8 +12,8 @@ use std::cell::RefCell;
|
|||
/* 双向链表节点 */
|
||||
pub struct ListNode<T> {
|
||||
pub val: T, // 节点值
|
||||
pub next: Option<Rc<RefCell<ListNode<T>>>>, // 后继节点引用(指针)
|
||||
pub prev: Option<Rc<RefCell<ListNode<T>>>>, // 前驱节点引用(指针)
|
||||
pub next: Option<Rc<RefCell<ListNode<T>>>>, // 后继节点指针
|
||||
pub prev: Option<Rc<RefCell<ListNode<T>>>>, // 前驱节点指针
|
||||
}
|
||||
|
||||
impl<T> ListNode<T> {
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
* Author: nuomi1 (nuomi1@qq.com)
|
||||
*/
|
||||
|
||||
/* 随机返回一个数组元素 */
|
||||
/* 随机访问元素 */
|
||||
func randomAccess(nums: [Int]) -> Int {
|
||||
// 在区间 [0, nums.count) 中随机抽取一个数字
|
||||
let randomIndex = nums.indices.randomElement()!
|
||||
|
|
|
@ -7,8 +7,8 @@
|
|||
/* 双向链表节点 */
|
||||
class ListNode {
|
||||
var val: Int // 节点值
|
||||
var next: ListNode? // 后继节点引用(指针)
|
||||
weak var prev: ListNode? // 前驱节点引用(指针)
|
||||
var next: ListNode? // 后继节点引用
|
||||
weak var prev: ListNode? // 前驱节点引用
|
||||
|
||||
init(val: Int) {
|
||||
self.val = val
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
* Author: Justin (xiefahit@gmail.com)
|
||||
*/
|
||||
|
||||
/* 随机返回一个数组元素 */
|
||||
/* 随机访问元素 */
|
||||
function randomAccess(nums: number[]): number {
|
||||
// 在区间 [0, nums.length) 中随机抽取一个数字
|
||||
const random_index = Math.floor(Math.random() * nums.length);
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
const std = @import("std");
|
||||
const inc = @import("include");
|
||||
|
||||
// 随机返回一个数组元素
|
||||
// 随机访问元素
|
||||
pub fn randomAccess(nums: []i32) i32 {
|
||||
// 在区间 [0, nums.len) 中随机抽取一个整数
|
||||
var randomIndex = std.crypto.random.intRangeLessThan(usize, 0, nums.len);
|
||||
|
|
|
@ -11,8 +11,8 @@ pub fn ListNode(comptime T: type) type {
|
|||
const Self = @This();
|
||||
|
||||
val: T = undefined, // 节点值
|
||||
next: ?*Self = null, // 后继节点引用(指针)
|
||||
prev: ?*Self = null, // 前驱节点引用(指针)
|
||||
next: ?*Self = null, // 后继节点指针
|
||||
prev: ?*Self = null, // 前驱节点指针
|
||||
|
||||
// Initialize a list node with specific value
|
||||
pub fn init(self: *Self, x: i32) void {
|
||||
|
|
|
@ -1,10 +1,14 @@
|
|||
# 数组
|
||||
|
||||
「数组 Array」是一种线性数据结构,其将相同类型元素存储在连续的内存空间中。我们将元素在数组中的位置称为元素的「索引 Index」。
|
||||
「数组 Array」是一种线性数据结构,其将相同类型元素存储在连续的内存空间中。我们将某个元素在数组中的位置称为该元素的「索引 Index」。
|
||||
|
||||
![数组定义与存储方式](array.assets/array_definition.png)
|
||||
|
||||
**数组初始化**。通常有无初始值和给定初始值两种方式,我们可根据需求选择合适的方法。在大多数编程语言中,若未指定初始值,数组的所有元素通常会被默认初始化为 $0$ 。
|
||||
## 数组常用操作
|
||||
|
||||
### 初始化数组
|
||||
|
||||
我们可以根据需求选用数组的两种初始化方式:无初始值、给定初始值。在未指定初始值的情况下,大多数编程语言会将数组元素初始化为 $0$ 。
|
||||
|
||||
=== "Java"
|
||||
|
||||
|
@ -21,7 +25,7 @@
|
|||
// 存储在栈上
|
||||
int arr[5];
|
||||
int nums[5] { 1, 3, 2, 5, 4 };
|
||||
// 存储在堆上
|
||||
// 存储在堆上(需要手动释放空间)
|
||||
int* arr1 = new int[5];
|
||||
int* nums1 = new int[5] { 1, 3, 2, 5, 4 };
|
||||
```
|
||||
|
@ -108,24 +112,20 @@
|
|||
let nums: Vec<i32> = vec![1, 3, 2, 5, 4];
|
||||
```
|
||||
|
||||
## 数组优点
|
||||
### 访问元素
|
||||
|
||||
**在数组中访问元素非常高效**。由于数组元素被存储在连续的内存空间中,因此计算数组元素的内存地址非常容易。给定数组首个元素的地址和某个元素的索引,我们可以使用以下公式计算得到该元素的内存地址,从而直接访问此元素。
|
||||
|
||||
![数组元素的内存地址计算](array.assets/array_memory_location_calculation.png)
|
||||
数组元素被存储在连续的内存空间中,这意味着计算数组元素的内存地址非常容易。给定数组内存地址(即首元素内存地址)和某个元素的索引,我们可以使用以下公式计算得到该元素的内存地址,从而直接访问此元素。
|
||||
|
||||
```shell
|
||||
# 元素内存地址 = 数组内存地址 + 元素长度 * 元素索引
|
||||
# 元素内存地址 = 数组内存地址(首元素内存地址) + 元素长度 * 元素索引
|
||||
elementAddr = firtstElementAddr + elementLength * elementIndex
|
||||
```
|
||||
|
||||
!!! question "为什么数组元素的索引要从 $0$ 开始编号呢?"
|
||||
![数组元素的内存地址计算](array.assets/array_memory_location_calculation.png)
|
||||
|
||||
观察上图,我们发现数组首个元素的索引为 $0$ ,这似乎有些反直觉,因为从 $1$ 开始计数会更自然。
|
||||
观察上图,我们发现数组首个元素的索引为 $0$ ,这似乎有些反直觉,因为从 $1$ 开始计数会更自然。但从地址计算公式的角度看,**索引的含义本质上是内存地址的偏移量**。首个元素的地址偏移量是 $0$ ,因此它的索引为 $0$ 也是合理的。
|
||||
|
||||
然而从地址计算公式的角度看,**索引本质上表示的是内存地址的偏移量**。首个元素的地址偏移量是 $0$ ,因此索引为 $0$ 也是合理的。
|
||||
|
||||
访问元素的高效性带来了诸多便利。例如,我们可以在 $O(1)$ 时间内随机获取数组中的任意一个元素。
|
||||
在数组中访问元素是非常高效的,我们可以在 $O(1)$ 时间内随机访问数组中的任意一个元素。
|
||||
|
||||
=== "Java"
|
||||
|
||||
|
@ -199,83 +199,11 @@ elementAddr = firtstElementAddr + elementLength * elementIndex
|
|||
[class]{}-[func]{random_access}
|
||||
```
|
||||
|
||||
## 数组缺点
|
||||
### 插入元素
|
||||
|
||||
**数组在初始化后长度不可变**。系统无法保证数组之后的内存空间是可用的,因此数组长度无法扩展。而若希望扩容数组,则需新建一个数组,然后把原数组元素依次拷贝到新数组。在数组很大的情况下,这是非常耗时的。
|
||||
数组元素在内存中是“紧挨着的”,它们之间没有空间再存放任何数据。这意味着如果想要在数组中间插入一个元素,则需要将该元素之后的所有元素都向后移动一位,之后再把元素赋值给该索引。
|
||||
|
||||
=== "Java"
|
||||
|
||||
```java title="array.java"
|
||||
[class]{array}-[func]{extend}
|
||||
```
|
||||
|
||||
=== "C++"
|
||||
|
||||
```cpp title="array.cpp"
|
||||
[class]{}-[func]{extend}
|
||||
```
|
||||
|
||||
=== "Python"
|
||||
|
||||
```python title="array.py"
|
||||
[class]{}-[func]{extend}
|
||||
```
|
||||
|
||||
=== "Go"
|
||||
|
||||
```go title="array.go"
|
||||
[class]{}-[func]{extend}
|
||||
```
|
||||
|
||||
=== "JS"
|
||||
|
||||
```javascript title="array.js"
|
||||
[class]{}-[func]{extend}
|
||||
```
|
||||
|
||||
=== "TS"
|
||||
|
||||
```typescript title="array.ts"
|
||||
[class]{}-[func]{extend}
|
||||
```
|
||||
|
||||
=== "C"
|
||||
|
||||
```c title="array.c"
|
||||
[class]{}-[func]{extend}
|
||||
```
|
||||
|
||||
=== "C#"
|
||||
|
||||
```csharp title="array.cs"
|
||||
[class]{array}-[func]{extend}
|
||||
```
|
||||
|
||||
=== "Swift"
|
||||
|
||||
```swift title="array.swift"
|
||||
[class]{}-[func]{extend}
|
||||
```
|
||||
|
||||
=== "Zig"
|
||||
|
||||
```zig title="array.zig"
|
||||
[class]{}-[func]{extend}
|
||||
```
|
||||
|
||||
=== "Dart"
|
||||
|
||||
```dart title="array.dart"
|
||||
[class]{}-[func]{extend}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="array.rs"
|
||||
[class]{}-[func]{extend}
|
||||
```
|
||||
|
||||
**数组中插入或删除元素效率低下**。数组元素在内存中是“紧挨着的”,它们之间没有空间再放任何数据。这意味着如果我们想要在数组中间插入一个元素,就不得不将此索引之后的所有元素都向后移动一位,然后再把元素赋值给该索引。
|
||||
值得注意的是,由于数组的长度是固定的,因此插入一个元素必定会导致数组尾部元素的“丢失”。我们将这个问题的解决方案留在列表章节中讨论。
|
||||
|
||||
![数组插入元素](array.assets/array_insert_element.png)
|
||||
|
||||
|
@ -351,7 +279,11 @@ elementAddr = firtstElementAddr + elementLength * elementIndex
|
|||
[class]{}-[func]{insert}
|
||||
```
|
||||
|
||||
删除元素也类似,如果我们想要删除索引 $i$ 处的元素,则需要把索引 $i$ 之后的元素都向前移动一位。值得注意的是,删除元素后,原先末尾的元素变得“无意义”了,所以我们无需特意去修改它。
|
||||
### 删除元素
|
||||
|
||||
同理,如果我们想要删除索引 $i$ 处的元素,则需要把索引 $i$ 之后的元素都向前移动一位。
|
||||
|
||||
请注意,删除元素完成后,原先末尾的元素变得“无意义”了,所以我们无需特意去修改它。
|
||||
|
||||
![数组删除元素](array.assets/array_remove_element.png)
|
||||
|
||||
|
@ -427,15 +359,15 @@ elementAddr = firtstElementAddr + elementLength * elementIndex
|
|||
[class]{}-[func]{remove}
|
||||
```
|
||||
|
||||
总结来看,数组的插入与删除操作有以下缺点:
|
||||
总的来看,数组的插入与删除操作有以下缺点:
|
||||
|
||||
- **时间复杂度高**:数组的插入和删除的平均时间复杂度均为 $O(n)$ ,其中 $n$ 为数组长度。
|
||||
- **丢失元素**:由于数组的长度不可变,因此在插入元素后,超出数组长度范围的元素会丢失。
|
||||
- **内存浪费**:我们可以初始化一个比较长的数组,只用前面一部分,这样在插入数据时,丢失的末尾元素都是我们不关心的,但这样做同时也会造成内存空间的浪费。
|
||||
- **内存浪费**:我们可以初始化一个比较长的数组,只用前面一部分,这样在插入数据时,丢失的末尾元素都是“无意义”的,但这样做也会造成部分内存空间的浪费。
|
||||
|
||||
## 数组常用操作
|
||||
### 遍历数组
|
||||
|
||||
**数组遍历**。以下介绍两种常用的遍历方法。
|
||||
在大多数编程语言中,我们既可以通过索引遍历数组,也可以直接遍历获取数组中的每个元素。
|
||||
|
||||
=== "Java"
|
||||
|
||||
|
@ -509,7 +441,11 @@ elementAddr = firtstElementAddr + elementLength * elementIndex
|
|||
[class]{}-[func]{traverse}
|
||||
```
|
||||
|
||||
**数组查找**。通过遍历数组,查找数组内的指定元素,并输出对应索引。
|
||||
### 查找元素
|
||||
|
||||
在数组中查找指定元素需要遍历数组,每轮判断元素值是否匹配,若匹配则输出对应索引。
|
||||
|
||||
因为数组是线性数据结构,所以上述查找操作被称为「线性查找」。
|
||||
|
||||
=== "Java"
|
||||
|
||||
|
@ -583,12 +519,104 @@ elementAddr = firtstElementAddr + elementLength * elementIndex
|
|||
[class]{}-[func]{find}
|
||||
```
|
||||
|
||||
### 扩容数组
|
||||
|
||||
在复杂的系统环境中,程序难以保证数组之后的内存空间是可用的,从而无法安全地扩展数组容量。因此在大多数编程语言中,**数组的长度是不可变的**。
|
||||
|
||||
如果我们希望扩容数组,则需重新建立一个更大的数组,然后把原数组元素依次拷贝到新数组。这是一个 $O(n)$ 的操作,在数组很大的情况下是非常耗时的。
|
||||
|
||||
=== "Java"
|
||||
|
||||
```java title="array.java"
|
||||
[class]{array}-[func]{extend}
|
||||
```
|
||||
|
||||
=== "C++"
|
||||
|
||||
```cpp title="array.cpp"
|
||||
[class]{}-[func]{extend}
|
||||
```
|
||||
|
||||
=== "Python"
|
||||
|
||||
```python title="array.py"
|
||||
[class]{}-[func]{extend}
|
||||
```
|
||||
|
||||
=== "Go"
|
||||
|
||||
```go title="array.go"
|
||||
[class]{}-[func]{extend}
|
||||
```
|
||||
|
||||
=== "JS"
|
||||
|
||||
```javascript title="array.js"
|
||||
[class]{}-[func]{extend}
|
||||
```
|
||||
|
||||
=== "TS"
|
||||
|
||||
```typescript title="array.ts"
|
||||
[class]{}-[func]{extend}
|
||||
```
|
||||
|
||||
=== "C"
|
||||
|
||||
```c title="array.c"
|
||||
[class]{}-[func]{extend}
|
||||
```
|
||||
|
||||
=== "C#"
|
||||
|
||||
```csharp title="array.cs"
|
||||
[class]{array}-[func]{extend}
|
||||
```
|
||||
|
||||
=== "Swift"
|
||||
|
||||
```swift title="array.swift"
|
||||
[class]{}-[func]{extend}
|
||||
```
|
||||
|
||||
=== "Zig"
|
||||
|
||||
```zig title="array.zig"
|
||||
[class]{}-[func]{extend}
|
||||
```
|
||||
|
||||
=== "Dart"
|
||||
|
||||
```dart title="array.dart"
|
||||
[class]{}-[func]{extend}
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="array.rs"
|
||||
[class]{}-[func]{extend}
|
||||
```
|
||||
|
||||
## 数组优点与局限性
|
||||
|
||||
数组存储在连续的内存空间内,且元素类型相同。这包含丰富的先验信息,系统可以利用这些信息来优化操作和运行效率,包括:
|
||||
|
||||
- **空间效率高**: 数组为数据分配了连续的内存块,无需额外的结构开销。
|
||||
- **支持随机访问**: 数组允许在 $O(1)$ 时间内访问任何元素。
|
||||
- **缓存局部性**: 当访问数组元素时,计算机不仅会加载它,还会缓存其周围的其他数据,从而借助高速缓存来提升后续操作的执行速度。
|
||||
|
||||
连续空间存储是一把双刃剑,它导致的缺点有:
|
||||
|
||||
- **插入与删除效率低**:当数组中元素较多时,插入与删除操作需要移动大量的元素。
|
||||
- **长度不可变**: 数组在初始化后长度就固定了,扩容数组需要将所有数据复制到新数组,开销很大。
|
||||
- **空间浪费**: 如果数组分配的大小超过了实际所需,那么多余的空间就被浪费了。
|
||||
|
||||
## 数组典型应用
|
||||
|
||||
数组是最基础的数据结构,在各类数据结构和算法中都有广泛应用。
|
||||
数组是一种基础且常见的数据结构,既频繁应用在各类算法之中,也可用于实现各种复杂数据结构,主要包括:
|
||||
|
||||
- **随机访问**:如果我们想要随机抽取一些样本,那么可以用数组存储,并生成一个随机序列,根据索引实现样本的随机抽取。
|
||||
- **排序和搜索**:数组是排序和搜索算法最常用的数据结构。例如,快速排序、归并排序、二分查找等都需要在数组上进行。
|
||||
- **排序和搜索**:数组是排序和搜索算法最常用的数据结构。快速排序、归并排序、二分查找等都主要在数组上进行。
|
||||
- **查找表**:当我们需要快速查找一个元素或者需要查找一个元素的对应关系时,可以使用数组作为查找表。假如我们想要实现字符到 ASCII 码的映射,则可以将字符的 ASCII 码值作为索引,对应的元素存放在数组中的对应位置。
|
||||
- **机器学习**:神经网络中大量使用了向量、矩阵、张量之间的线性代数运算,这些数据都是以数组的形式构建的。数组是神经网络编程中最常使用的数据结构。
|
||||
- **数据结构实现**:数组可以用于实现栈、队列、哈希表、堆、图等数据结构。例如,图的邻接矩阵表示实际上是一个二维数组。
|
||||
|
|
|
@ -1,20 +1,26 @@
|
|||
# 链表
|
||||
|
||||
内存空间是所有程序的公共资源,排除已被占用的内存空间,空闲内存空间通常散落在内存各处。在上一节中,我们提到存储数组的内存空间必须是连续的,而当需要申请一个非常大的数组时,空闲内存中可能没有这么大的连续空间。与数组相比,链表更具灵活性,它可以被存储在非连续的内存空间中。
|
||||
内存空间是所有程序的公共资源,在一个复杂的系统运行环境下,空闲的内存空间可能散落在内存各处。我们知道,存储数组的内存空间必须是连续的,而当数组非常大时,内存可能无法提供如此大的连续空间。此时链表的灵活性优势就体现出来了。
|
||||
|
||||
「链表 Linked List」是一种线性数据结构,其每个元素都是一个节点对象,各个节点之间通过指针连接,从当前节点通过指针可以访问到下一个节点。**由于指针记录了下个节点的内存地址,因此无需保证内存地址的连续性**,从而可以将各个节点分散存储在内存各处。
|
||||
|
||||
链表中的「节点 Node」包含两项数据,一是节点「值 Value」,二是指向下一节点的「引用 Reference」,或称「指针 Pointer」。
|
||||
「链表 Linked List」是一种线性数据结构,其中的每个元素都是一个节点对象,各个节点通过“引用”相连接。引用记录了下一个节点的内存地址,我们可以通过它从当前节点访问到下一个节点。这意味着链表的各个节点可以被分散存储在内存各处,它们的内存地址是无需连续的。
|
||||
|
||||
![链表定义与存储方式](linked_list.assets/linkedlist_definition.png)
|
||||
|
||||
观察上图,链表中的每个「节点 Node」对象都包含两项数据:节点的“值”、指向下一节点的“引用”。
|
||||
|
||||
- 链表的首个节点被称为“头节点”,最后一个节点被称为“尾节点”。
|
||||
- 尾节点指向的是“空”,它在 Java, C++, Python 中分别被记为 $\text{null}$ , $\text{nullptr}$ , $\text{None}$ 。
|
||||
- 在 C, C++, Go, Rust 等支持指针的语言中,上述的“引用”应被替换为“指针”。
|
||||
|
||||
如以下代码所示,链表以节点对象 `ListNode` 为单位,每个节点除了包含值,还需额外保存下一节点的引用(指针)。因此在相同数据量下,**链表通常比数组占用更多的内存空间**。
|
||||
|
||||
=== "Java"
|
||||
|
||||
```java title=""
|
||||
/* 链表节点类 */
|
||||
class ListNode {
|
||||
int val; // 节点值
|
||||
ListNode next; // 指向下一节点的指针(引用)
|
||||
ListNode next; // 指向下一节点的引用
|
||||
ListNode(int x) { val = x; } // 构造函数
|
||||
}
|
||||
```
|
||||
|
@ -25,7 +31,7 @@
|
|||
/* 链表节点结构体 */
|
||||
struct ListNode {
|
||||
int val; // 节点值
|
||||
ListNode *next; // 指向下一节点的指针(引用)
|
||||
ListNode *next; // 指向下一节点的指针
|
||||
ListNode(int x) : val(x), next(nullptr) {} // 构造函数
|
||||
};
|
||||
```
|
||||
|
@ -37,7 +43,7 @@
|
|||
"""链表节点类"""
|
||||
def __init__(self, val: int):
|
||||
self.val: int = val # 节点值
|
||||
self.next: Optional[ListNode] = None # 指向下一节点的指针(引用)
|
||||
self.next: Optional[ListNode] = None # 指向下一节点的引用
|
||||
```
|
||||
|
||||
=== "Go"
|
||||
|
@ -46,7 +52,7 @@
|
|||
/* 链表节点结构体 */
|
||||
type ListNode struct {
|
||||
Val int // 节点值
|
||||
Next *ListNode // 指向下一节点的指针(引用)
|
||||
Next *ListNode // 指向下一节点的指针
|
||||
}
|
||||
|
||||
// NewListNode 构造函数,创建一个新的链表
|
||||
|
@ -92,7 +98,7 @@
|
|||
/* 链表节点结构体 */
|
||||
struct ListNode {
|
||||
int val; // 节点值
|
||||
struct ListNode *next; // 指向下一节点的指针(引用)
|
||||
struct ListNode *next; // 指向下一节点的指针
|
||||
};
|
||||
|
||||
typedef struct ListNode ListNode;
|
||||
|
@ -124,7 +130,7 @@
|
|||
/* 链表节点类 */
|
||||
class ListNode {
|
||||
var val: Int // 节点值
|
||||
var next: ListNode? // 指向下一节点的指针(引用)
|
||||
var next: ListNode? // 指向下一节点的引用
|
||||
|
||||
init(x: Int) { // 构造函数
|
||||
val = x
|
||||
|
@ -141,7 +147,7 @@
|
|||
const Self = @This();
|
||||
|
||||
val: T = 0, // 节点值
|
||||
next: ?*Self = null, // 指向下一节点的指针(引用)
|
||||
next: ?*Self = null, // 指向下一节点的指针
|
||||
|
||||
// 构造函数
|
||||
pub fn init(self: *Self, x: i32) void {
|
||||
|
@ -158,7 +164,7 @@
|
|||
/* 链表节点类 */
|
||||
class ListNode {
|
||||
int val; // 节点值
|
||||
ListNode? next; // 指向下一节点的指针(引用)
|
||||
ListNode? next; // 指向下一节点的引用
|
||||
ListNode(this.val, [this.next]); // 构造函数
|
||||
}
|
||||
```
|
||||
|
@ -172,13 +178,15 @@
|
|||
#[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}$ 来表示空。
|
||||
## 链表常用操作
|
||||
|
||||
**链表初始化方法**。建立链表分为两步,第一步是初始化各个节点对象,第二步是构建引用指向关系。完成后,即可以从链表的头节点(即首个节点)出发,通过指针 `next` 依次访问所有节点。
|
||||
### 初始化链表
|
||||
|
||||
建立链表分为两步,第一步是初始化各个节点对象,第二步是构建引用指向关系。初始化完成后,我们就可以从链表的头节点出发,通过引用指向 `next` 依次访问所有节点。
|
||||
|
||||
=== "Java"
|
||||
|
||||
|
@ -385,11 +393,13 @@
|
|||
n3.borrow_mut().next = Some(n4.clone());
|
||||
```
|
||||
|
||||
在编程语言中,数组整体是一个变量,比如数组 `nums` 包含元素 `nums[0]` , `nums[1]` 等。而链表是由多个分散的节点对象组成,**我们通常将头节点当作链表的代称**,比如以上代码中的链表可被记做链表 `n0` 。
|
||||
数组整体是一个变量,比如数组 `nums` 包含元素 `nums[0]` , `nums[1]` 等,而链表是由多个独立的节点对象组成的。**我们通常将头节点当作链表的代称**,比如以上代码中的链表可被记做链表 `n0` 。
|
||||
|
||||
## 链表优点
|
||||
### 插入节点
|
||||
|
||||
**链表中插入与删除节点的操作效率高**。如果我们想在链表中间的两个节点 `A` , `B` 之间插入一个新节点 `P` ,我们只需要改变两个节点指针即可,时间复杂度为 $O(1)$ ;相比之下,数组的插入操作效率要低得多。
|
||||
**在链表中插入节点非常容易**。假设我们想在相邻的两个节点 `n0` , `n1` 之间插入一个新节点 `P` ,则只需要改变两个节点引用(指针)即可,时间复杂度为 $O(1)$ 。
|
||||
|
||||
相比之下,在数组中插入元素的时间复杂度为 $O(n)$ ,在大数据量下的效率较低。
|
||||
|
||||
![链表插入节点](linked_list.assets/linkedlist_insert_node.png)
|
||||
|
||||
|
@ -465,7 +475,11 @@
|
|||
[class]{}-[func]{insert}
|
||||
```
|
||||
|
||||
在链表中删除节点也非常方便,只需改变一个节点的指针即可。如下图所示,尽管在删除操作完成后,节点 `P` 仍然指向 `n1` ,但实际上 `P` 已经不再属于此链表,因为遍历此链表时无法访问到 `P` 。
|
||||
### 删除节点
|
||||
|
||||
在链表中删除节点也非常简便,只需改变一个节点的引用(指针)即可。
|
||||
|
||||
请注意,尽管在删除操作完成后节点 `P` 仍然指向 `n1` ,但实际上遍历此链表已经无法访问到 `P` ,这意味着 `P` 已经不再属于该链表了。
|
||||
|
||||
![链表删除节点](linked_list.assets/linkedlist_remove_node.png)
|
||||
|
||||
|
@ -541,9 +555,9 @@
|
|||
[class]{}-[func]{remove}
|
||||
```
|
||||
|
||||
## 链表缺点
|
||||
### 访问节点
|
||||
|
||||
**链表访问节点效率较低**。如上节所述,数组可以在 $O(1)$ 时间下访问任意元素。然而链表无法直接访问任意节点,因为程序需要从头节点出发,逐个向后遍历,直至找到目标节点。也就是说,如果想要访问链表中第 $i$ 个节点,则需要向后遍历 $i - 1$ 轮。
|
||||
**在链表访问节点的效率较低**。如上节所述,我们可以在 $O(1)$ 时间下访问数组中的任意元素。链表则不然,程序需要从头节点出发,逐个向后遍历,直至找到目标节点。也就是说,访问链表的第 $i$ 个节点需要循环 $i - 1$ 轮,时间复杂度为 $O(n)$ 。
|
||||
|
||||
=== "Java"
|
||||
|
||||
|
@ -617,11 +631,9 @@
|
|||
[class]{}-[func]{access}
|
||||
```
|
||||
|
||||
**链表的内存占用较大**。链表以节点为单位,每个节点除了包含值,还需额外保存下一节点的引用(指针)。这意味着在相同数据量的情况下,链表比数组需要占用更多的内存空间。
|
||||
### 查找节点
|
||||
|
||||
## 链表常用操作
|
||||
|
||||
**遍历链表查找**。遍历链表,查找链表内值为 `target` 的节点,输出节点在链表中的索引。
|
||||
遍历链表,查找链表内值为 `target` 的节点,输出节点在链表中的索引。此过程也属于「线性查找」。
|
||||
|
||||
=== "Java"
|
||||
|
||||
|
@ -695,13 +707,31 @@
|
|||
[class]{}-[func]{find}
|
||||
```
|
||||
|
||||
## 数组 VS 链表
|
||||
|
||||
下表总结对比了数组和链表的各项特点与操作效率。由于它们采用两种相反的存储策略,因此各种性质和操作效率也呈现对立的特点。
|
||||
|
||||
<div class="center-table" markdown>
|
||||
|
||||
| | 数组 | 链表 |
|
||||
| ---------- | ------------------------ | ------------ |
|
||||
| 存储方式 | 连续内存空间 | 离散内存空间 |
|
||||
| 缓存局部性 | 友好 | 不友好 |
|
||||
| 容量扩展 | 长度不可变 | 可灵活扩展 |
|
||||
| 内存效率 | 占用内存少、浪费部分空间 | 占用内存多 |
|
||||
| 访问元素 | $O(1)$ | $O(n)$ |
|
||||
| 添加元素 | $O(n)$ | $O(1)$ |
|
||||
| 删除元素 | $O(n)$ | $O(1)$ |
|
||||
|
||||
</div>
|
||||
|
||||
## 常见链表类型
|
||||
|
||||
**单向链表**。即上述介绍的普通链表。单向链表的节点包含值和指向下一节点的指针(引用)两项数据。我们将首个节点称为头节点,将最后一个节点成为尾节点,尾节点指向空 $\text{None}$ 。
|
||||
**单向链表**。即上述介绍的普通链表。单向链表的节点包含值和指向下一节点的引用两项数据。我们将首个节点称为头节点,将最后一个节点成为尾节点,尾节点指向空 $\text{None}$ 。
|
||||
|
||||
**环形链表**。如果我们令单向链表的尾节点指向头节点(即首尾相接),则得到一个环形链表。在环形链表中,任意节点都可以视作头节点。
|
||||
|
||||
**双向链表**。与单向链表相比,双向链表记录了两个方向的指针(引用)。双向链表的节点定义同时包含指向后继节点(下一节点)和前驱节点(上一节点)的指针。相较于单向链表,双向链表更具灵活性,可以朝两个方向遍历链表,但相应地也需要占用更多的内存空间。
|
||||
**双向链表**。与单向链表相比,双向链表记录了两个方向的引用。双向链表的节点定义同时包含指向后继节点(下一个节点)和前驱节点(上一个节点)的引用(指针)。相较于单向链表,双向链表更具灵活性,可以朝两个方向遍历链表,但相应地也需要占用更多的内存空间。
|
||||
|
||||
=== "Java"
|
||||
|
||||
|
@ -709,8 +739,8 @@
|
|||
/* 双向链表节点类 */
|
||||
class ListNode {
|
||||
int val; // 节点值
|
||||
ListNode next; // 指向后继节点的指针(引用)
|
||||
ListNode prev; // 指向前驱节点的指针(引用)
|
||||
ListNode next; // 指向后继节点的引用
|
||||
ListNode prev; // 指向前驱节点的引用
|
||||
ListNode(int x) { val = x; } // 构造函数
|
||||
}
|
||||
```
|
||||
|
@ -721,8 +751,8 @@
|
|||
/* 双向链表节点结构体 */
|
||||
struct ListNode {
|
||||
int val; // 节点值
|
||||
ListNode *next; // 指向后继节点的指针(引用)
|
||||
ListNode *prev; // 指向前驱节点的指针(引用)
|
||||
ListNode *next; // 指向后继节点的指针
|
||||
ListNode *prev; // 指向前驱节点的指针
|
||||
ListNode(int x) : val(x), next(nullptr), prev(nullptr) {} // 构造函数
|
||||
};
|
||||
```
|
||||
|
@ -734,8 +764,8 @@
|
|||
"""双向链表节点类"""
|
||||
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"
|
||||
|
@ -744,8 +774,8 @@
|
|||
/* 双向链表节点结构体 */
|
||||
type DoublyListNode struct {
|
||||
Val int // 节点值
|
||||
Next *DoublyListNode // 指向后继节点的指针(引用)
|
||||
Prev *DoublyListNode // 指向前驱节点的指针(引用)
|
||||
Next *DoublyListNode // 指向后继节点的指针
|
||||
Prev *DoublyListNode // 指向前驱节点的指针
|
||||
}
|
||||
|
||||
// NewDoublyListNode 初始化
|
||||
|
@ -768,8 +798,8 @@
|
|||
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; // 指向前驱节点的引用
|
||||
}
|
||||
}
|
||||
```
|
||||
|
@ -784,8 +814,8 @@
|
|||
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; // 指向前驱节点的引用
|
||||
}
|
||||
}
|
||||
```
|
||||
|
@ -796,8 +826,8 @@
|
|||
/* 双向链表节点结构体 */
|
||||
struct ListNode {
|
||||
int val; // 节点值
|
||||
struct ListNode *next; // 指向后继节点的指针(引用)
|
||||
struct ListNode *prev; // 指向前驱节点的指针(引用)
|
||||
struct ListNode *next; // 指向后继节点的指针
|
||||
struct ListNode *prev; // 指向前驱节点的指针
|
||||
};
|
||||
|
||||
typedef struct ListNode ListNode;
|
||||
|
@ -819,8 +849,8 @@
|
|||
/* 双向链表节点类 */
|
||||
class ListNode {
|
||||
int val; // 节点值
|
||||
ListNode next; // 指向后继节点的指针(引用)
|
||||
ListNode prev; // 指向前驱节点的指针(引用)
|
||||
ListNode next; // 指向后继节点的引用
|
||||
ListNode prev; // 指向前驱节点的引用
|
||||
ListNode(int x) => val = x; // 构造函数
|
||||
}
|
||||
```
|
||||
|
@ -831,8 +861,8 @@
|
|||
/* 双向链表节点类 */
|
||||
class ListNode {
|
||||
var val: Int // 节点值
|
||||
var next: ListNode? // 指向后继节点的指针(引用)
|
||||
var prev: ListNode? // 指向前驱节点的指针(引用)
|
||||
var next: ListNode? // 指向后继节点的引用
|
||||
var prev: ListNode? // 指向前驱节点的引用
|
||||
|
||||
init(x: Int) { // 构造函数
|
||||
val = x
|
||||
|
@ -849,8 +879,8 @@
|
|||
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 {
|
||||
|
@ -868,8 +898,8 @@
|
|||
/* 双向链表节点类 */
|
||||
class ListNode {
|
||||
int val; // 节点值
|
||||
ListNode next; // 指向后继节点的指针(引用)
|
||||
ListNode prev; // 指向前驱节点的指针(引用)
|
||||
ListNode next; // 指向后继节点的引用
|
||||
ListNode prev; // 指向前驱节点的引用
|
||||
ListNode(this.val, [this.next, this.prev]); // 构造函数
|
||||
}
|
||||
```
|
||||
|
@ -884,8 +914,8 @@
|
|||
#[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>>>, // 指向前驱节点的指针
|
||||
}
|
||||
|
||||
/* 构造函数 */
|
||||
|
@ -912,7 +942,7 @@
|
|||
|
||||
双向链表常被用于需要快速查找前一个和下一个元素的场景。
|
||||
|
||||
- **高级数据结构**:比如在红黑树、B 树中,我们需要知道一个节点的父节点,这可以通过在节点中保存一个指向父节点的指针来实现,类似于双向链表。
|
||||
- **高级数据结构**:比如在红黑树、B 树中,我们需要访问节点的父节点,这可以通过在节点中保存一个指向父节点的引用来实现,类似于双向链表。
|
||||
- **浏览器历史**:在网页浏览器中,当用户点击前进或后退按钮时,浏览器需要知道用户访问过的前一个和后一个网页。双向链表的特性使得这种操作变得简单。
|
||||
- **LRU 算法**:在缓存淘汰算法(LRU)中,我们需要快速找到最近最少使用的数据,以及支持快速地添加和删除节点。这时候使用双向链表就非常合适。
|
||||
|
||||
|
|
|
@ -1,12 +1,14 @@
|
|||
# 列表
|
||||
|
||||
**数组长度不可变导致实用性降低**。在许多情况下,我们事先无法确定需要存储多少数据,这使数组长度的选择变得困难。若长度过小,需要在持续添加数据时频繁扩容数组;若长度过大,则会造成内存空间的浪费。
|
||||
**数组长度不可变导致实用性降低**。在实际中,我们可能事先无法确定需要存储多少数据,这使数组长度的选择变得困难。若长度过小,需要在持续添加数据时频繁扩容数组;若长度过大,则会造成内存空间的浪费。
|
||||
|
||||
为解决此问题,出现了一种被称为「动态数组 Dynamic Array」的数据结构,即长度可变的数组,也常被称为「列表 List」。列表基于数组实现,继承了数组的优点,并且可以在程序运行过程中动态扩容。在列表中,我们可以自由添加元素,而无需担心超过容量限制。
|
||||
为解决此问题,出现了一种被称为「动态数组 Dynamic Array」的数据结构,即长度可变的数组,也常被称为「列表 List」。列表基于数组实现,继承了数组的优点,并且可以在程序运行过程中动态扩容。我们可以在列表中自由地添加元素,而无需担心超过容量限制。
|
||||
|
||||
## 列表常用操作
|
||||
|
||||
**初始化列表**。通常我们会使用“无初始值”和“有初始值”的两种初始化方法。
|
||||
### 初始化列表
|
||||
|
||||
我们通常使用“无初始值”和“有初始值”这两种初始化方法。
|
||||
|
||||
=== "Java"
|
||||
|
||||
|
@ -126,7 +128,9 @@
|
|||
let list2: Vec<i32> = vec![1, 3, 2, 5, 4];
|
||||
```
|
||||
|
||||
**访问与更新元素**。由于列表的底层数据结构是数组,因此可以在 $O(1)$ 时间内访问和更新元素,效率很高。
|
||||
### 访问元素
|
||||
|
||||
列表本质上是数组,因此可以在 $O(1)$ 时间内访问和更新元素,效率很高。
|
||||
|
||||
=== "Java"
|
||||
|
||||
|
@ -243,7 +247,9 @@
|
|||
list[1] = 0; // 将索引 1 处的元素更新为 0
|
||||
```
|
||||
|
||||
**在列表中添加、插入、删除元素**。相较于数组,列表可以自由地添加与删除元素。在列表尾部添加元素的时间复杂度为 $O(1)$ ,但插入和删除元素的效率仍与数组相同,时间复杂度为 $O(N)$ 。
|
||||
### 插入与删除元素
|
||||
|
||||
相较于数组,列表可以自由地添加与删除元素。在列表尾部添加元素的时间复杂度为 $O(1)$ ,但插入和删除元素的效率仍与数组相同,时间复杂度为 $O(n)$ 。
|
||||
|
||||
=== "Java"
|
||||
|
||||
|
@ -471,7 +477,9 @@
|
|||
list.remove(3); // 删除索引 3 处的元素
|
||||
```
|
||||
|
||||
**遍历列表**。与数组一样,列表可以根据索引遍历,也可以直接遍历各元素。
|
||||
### 遍历列表
|
||||
|
||||
与数组一样,列表可以根据索引遍历,也可以直接遍历各元素。
|
||||
|
||||
=== "Java"
|
||||
|
||||
|
@ -654,7 +662,9 @@
|
|||
}
|
||||
```
|
||||
|
||||
**拼接两个列表**。给定一个新列表 `list1` ,我们可以将该列表拼接到原列表的尾部。
|
||||
### 拼接列表
|
||||
|
||||
给定一个新列表 `list1` ,我们可以将该列表拼接到原列表的尾部。
|
||||
|
||||
=== "Java"
|
||||
|
||||
|
@ -753,7 +763,9 @@
|
|||
list.extend(list1);
|
||||
```
|
||||
|
||||
**排序列表**。排序也是常用的方法之一。完成列表排序后,我们便可以使用在数组类算法题中经常考察的「二分查找」和「双指针」算法。
|
||||
### 排序列表
|
||||
|
||||
完成列表排序后,我们便可以使用在数组类算法题中经常考察的“二分查找”和“双指针”算法。
|
||||
|
||||
=== "Java"
|
||||
|
||||
|
@ -838,15 +850,15 @@
|
|||
list.sort(); // 排序后,列表元素从小到大排列
|
||||
```
|
||||
|
||||
## 列表实现 *
|
||||
## 列表实现
|
||||
|
||||
为了帮助加深对列表的理解,我们在此提供一个简易版列表实现。需要关注三个核心点:
|
||||
许多编程语言都提供内置的列表,例如 Java, C++, Python 等。它们的实现比较复杂,各个参数的设定也非常有考究,例如初始容量、扩容倍数等。感兴趣的读者可以查阅源码进行学习。
|
||||
|
||||
为了帮助你理解列表的工作原理,我们在此提供一个简易版列表实现,重点包括:
|
||||
|
||||
- **初始容量**:选取一个合理的数组初始容量。在本示例中,我们选择 10 作为初始容量。
|
||||
- **数量记录**:声明一个变量 size,用于记录列表当前元素数量,并随着元素插入和删除实时更新。根据此变量,我们可以定位列表尾部,以及判断是否需要扩容。
|
||||
- **扩容机制**:插入元素时可能超出列表容量,此时需要扩容列表。扩容方法是根据扩容倍数创建一个更大的数组,并将当前数组的所有元素依次移动至新数组。在本示例中,我们规定每次将数组扩容至之前的 2 倍。
|
||||
|
||||
本示例旨在帮助读者直观理解列表的工作机制。实际编程语言中,列表实现更加标准和复杂,各个参数的设定也非常有考究,例如初始容量、扩容倍数等。感兴趣的读者可以查阅源码进行学习。
|
||||
- **扩容机制**:若插入元素时列表容量已满,则需要进行扩容。首先根据扩容倍数创建一个更大的数组,再将当前数组的所有元素依次移动至新数组。在本示例中,我们规定每次将数组扩容至之前的 2 倍。
|
||||
|
||||
=== "Java"
|
||||
|
||||
|
|
|
@ -1,28 +1,9 @@
|
|||
# 小结
|
||||
|
||||
- 数组和链表是两种基本数据结构,分别代表数据在计算机内存中的连续空间存储和离散空间存储方式。两者的优缺点呈现出互补的特性。
|
||||
- 数组和链表是两种基本的数据结构,分别代表数据在计算机内存中的两种存储方式:连续空间存储和离散空间存储。两者的特点呈现出互补的特性。
|
||||
- 数组支持随机访问、占用内存较少;但插入和删除元素效率低,且初始化后长度不可变。
|
||||
- 链表通过更改引用(指针)实现高效的节点插入与删除,且可以灵活调整长度;但节点访问效率低、占用内存较多。常见的链表类型包括单向链表、循环链表、双向链表。
|
||||
- 动态数组,又称列表,是基于数组实现的一种数据结构。它保留了数组的优势,同时可以灵活调整长度。列表的出现极大地提高了数组的易用性,但可能导致部分内存空间浪费。
|
||||
- 下表总结并对比了数组与链表的各项特性与操作效率。
|
||||
|
||||
<div class="center-table" markdown>
|
||||
|
||||
| | 数组 | 链表 |
|
||||
| ------------ | ------------------------ | ------------ |
|
||||
| 存储方式 | 连续内存空间 | 离散内存空间 |
|
||||
| 数据结构长度 | 长度不可变 | 长度可变 |
|
||||
| 内存使用率 | 占用内存少、缓存局部性好 | 占用内存多 |
|
||||
| 优势操作 | 随机访问 | 插入、删除 |
|
||||
| 访问元素 | $O(1)$ | $O(N)$ |
|
||||
| 添加元素 | $O(N)$ | $O(1)$ |
|
||||
| 删除元素 | $O(N)$ | $O(1)$ |
|
||||
|
||||
</div>
|
||||
|
||||
!!! note "缓存局部性"
|
||||
|
||||
在计算机中,数据读写速度排序是“硬盘 < 内存 < CPU 缓存”。当我们访问数组元素时,计算机不仅会加载它,还会缓存其周围的其他数据,从而借助高速缓存来提升后续操作的执行速度。链表则不然,计算机只能挨个地缓存各个节点,这样的多次“搬运”降低了整体效率。
|
||||
|
||||
## Q & A
|
||||
|
||||
|
@ -34,7 +15,7 @@
|
|||
2. 栈是一块比较小的内存,容易出现内存不足;堆内存很大,但是由于是动态分配,容易碎片化,管理堆内存的难度更大、成本更高。
|
||||
3. 访问栈比访问堆更快,因为栈内存较小、对缓存友好,堆帧分散在很大的空间内,会出现更多的缓存未命中。
|
||||
|
||||
!!! question "为什么数组会强调要求相同类型的元素,而在链表中却没有强调同类型呢?"
|
||||
!!! question "为什么数组要求相同类型的元素,而在链表中却没有强调同类型呢?"
|
||||
|
||||
链表由结点组成,结点之间通过引用(指针)连接,各个结点可以存储不同类型的数据,例如 int, double, string, object 等。
|
||||
|
||||
|
|
|
@ -7,10 +7,10 @@
|
|||
|
||||
因此在能够解决问题的前提下,算法效率成为主要的评价维度,包括:
|
||||
|
||||
- **时间效率**,即算法运行速度的快慢。
|
||||
- **空间效率**,即算法占用内存空间的大小。
|
||||
- **时间效率**:算法运行速度的快慢。
|
||||
- **空间效率**:算法占用内存空间的大小。
|
||||
|
||||
简而言之,**我们的目标是设计“既快又省”的数据结构与算法**。而有效地评估算法效率至关重要,因为只有了解评价标准,我们才能对比分析各种算法,从而指导算法设计与优化过程。
|
||||
简而言之,**我们的目标是设计“既快又省”的数据结构与算法**。而有效地评估算法效率至关重要,因为只有这样我们才能将各种算法进行对比,从而指导算法设计与优化过程。
|
||||
|
||||
效率评估方法主要分为两种:实际测试和理论估算。
|
||||
|
||||
|
@ -28,11 +28,11 @@
|
|||
|
||||
**复杂度分析评估的是算法运行效率随着输入数据量增多时的增长趋势**。这个定义有些拗口,我们可以将其分为三个重点来理解:
|
||||
|
||||
- “算法运行效率”可分为运行时间和占用空间两部分,与之对应地,复杂度可分为「时间复杂度 Time Complexity」和「空间复杂度 Space Complexity」。
|
||||
- “随着输入数据量增多时”表示复杂度与输入数据量有关,反映了算法运行效率与输入数据量之间的关系。
|
||||
- “增长趋势”表示复杂度分析关注的是算法时间与空间的增长趋势,而非具体的运行时间或占用空间。
|
||||
1. “算法运行效率”可分为运行时间和占用空间两部分,与之对应地,复杂度可分为「时间复杂度 Time Complexity」和「空间复杂度 Space Complexity」。
|
||||
2. “随着输入数据量增多时”意味着复杂度反映了算法运行效率与输入数据量之间的关系。
|
||||
3. “增长趋势”表示复杂度分析关注的是算法时间与空间的增长趋势,而非具体的运行时间或占用空间。
|
||||
|
||||
**复杂度分析克服了实际测试方法的弊端**。首先,它独立于测试环境,因此分析结果适用于所有运行平台。其次,它可以体现不同数据量下的算法效率,尤其是在大数据量下的算法性能。
|
||||
**复杂度分析克服了实际测试方法的弊端**。首先,它独立于测试环境,分析结果适用于所有运行平台。其次,它可以体现不同数据量下的算法效率,尤其是在大数据量下的算法性能。
|
||||
|
||||
如果你对复杂度分析的概念仍感到困惑,无需担心,我们会在后续章节详细介绍。
|
||||
|
||||
|
|
|
@ -79,7 +79,7 @@
|
|||
"""类"""
|
||||
def __init__(self, x: int):
|
||||
self.val: int = x # 节点值
|
||||
self.next: Optional[Node] = None # 指向下一节点的指针(引用)
|
||||
self.next: Optional[Node] = None # 指向下一节点的引用
|
||||
|
||||
def function() -> int:
|
||||
"""函数"""
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
# 二叉树
|
||||
|
||||
「二叉树 Binary Tree」是一种非线性数据结构,代表着祖先与后代之间的派生关系,体现着“一分为二”的分治逻辑。与链表类似,二叉树的基本单元是节点,每个节点包含一个「值」和两个「指针」。
|
||||
「二叉树 Binary Tree」是一种非线性数据结构,代表着祖先与后代之间的派生关系,体现着“一分为二”的分治逻辑。与链表类似,二叉树的基本单元是节点,每个节点包含:值、左子节点引用、右子节点引用。
|
||||
|
||||
=== "Java"
|
||||
|
||||
|
@ -8,8 +8,8 @@
|
|||
/* 二叉树节点类 */
|
||||
class TreeNode {
|
||||
int val; // 节点值
|
||||
TreeNode left; // 左子节点指针
|
||||
TreeNode right; // 右子节点指针
|
||||
TreeNode left; // 左子节点引用
|
||||
TreeNode right; // 右子节点引用
|
||||
TreeNode(int x) { val = x; }
|
||||
}
|
||||
```
|
||||
|
@ -33,8 +33,8 @@
|
|||
"""二叉树节点类"""
|
||||
def __init__(self, val: int):
|
||||
self.val: int = val # 节点值
|
||||
self.left: Optional[TreeNode] = None # 左子节点指针
|
||||
self.right: Optional[TreeNode] = None # 右子节点指针
|
||||
self.left: Optional[TreeNode] = None # 左子节点引用
|
||||
self.right: Optional[TreeNode] = None # 右子节点引用
|
||||
```
|
||||
|
||||
=== "Go"
|
||||
|
@ -49,9 +49,9 @@
|
|||
/* 节点初始化方法 */
|
||||
func NewTreeNode(v int) *TreeNode {
|
||||
return &TreeNode{
|
||||
Left: nil,
|
||||
Right: nil,
|
||||
Val: v,
|
||||
Left: nil, // 左子节点指针
|
||||
Right: nil, // 右子节点指针
|
||||
Val: v, // 节点值
|
||||
}
|
||||
}
|
||||
```
|
||||
|
@ -62,8 +62,8 @@
|
|||
/* 二叉树节点类 */
|
||||
function TreeNode(val, left, right) {
|
||||
this.val = (val === undefined ? 0 : val); // 节点值
|
||||
this.left = (left === undefined ? null : left); // 左子节点指针
|
||||
this.right = (right === undefined ? null : right); // 右子节点指针
|
||||
this.left = (left === undefined ? null : left); // 左子节点引用
|
||||
this.right = (right === undefined ? null : right); // 右子节点引用
|
||||
}
|
||||
```
|
||||
|
||||
|
@ -78,8 +78,8 @@
|
|||
|
||||
constructor(val?: number, left?: TreeNode | null, right?: TreeNode | null) {
|
||||
this.val = val === undefined ? 0 : val; // 节点值
|
||||
this.left = left === undefined ? null : left; // 左子节点指针
|
||||
this.right = right === undefined ? null : right; // 右子节点指针
|
||||
this.left = left === undefined ? null : left; // 左子节点引用
|
||||
this.right = right === undefined ? null : right; // 右子节点引用
|
||||
}
|
||||
}
|
||||
```
|
||||
|
@ -116,8 +116,8 @@
|
|||
/* 二叉树节点类 */
|
||||
class TreeNode {
|
||||
int val; // 节点值
|
||||
TreeNode? left; // 左子节点指针
|
||||
TreeNode? right; // 右子节点指针
|
||||
TreeNode? left; // 左子节点引用
|
||||
TreeNode? right; // 右子节点引用
|
||||
TreeNode(int x) { val = x; }
|
||||
}
|
||||
```
|
||||
|
@ -128,8 +128,8 @@
|
|||
/* 二叉树节点类 */
|
||||
class TreeNode {
|
||||
var val: Int // 节点值
|
||||
var left: TreeNode? // 左子节点指针
|
||||
var right: TreeNode? // 右子节点指针
|
||||
var left: TreeNode? // 左子节点引用
|
||||
var right: TreeNode? // 右子节点引用
|
||||
|
||||
init(x: Int) {
|
||||
val = x
|
||||
|
@ -149,8 +149,8 @@
|
|||
/* 二叉树节点类 */
|
||||
class TreeNode {
|
||||
int val; // 节点值
|
||||
TreeNode? left; // 左子节点指针
|
||||
TreeNode? right; // 右子节点指针
|
||||
TreeNode? left; // 左子节点引用
|
||||
TreeNode? right; // 右子节点引用
|
||||
TreeNode(this.val, [this.left, this.right]);
|
||||
}
|
||||
```
|
||||
|
|
Loading…
Reference in a new issue