四.数组与链表
www.hello-algo.com/chapter_arr…
1. 数组
1.1. 定义:
一种线性数据结构,将相同类型的元素存储在连续的内存空间中,元素在数组(array)中的位置称为元素的索引。
1.2. 基本操作
1.2.1. 初始化数组:
根据需求选用数组的两种初始化方式:无初始值、给定初始值。在未指定初始值的情况下,大多数编程语言会将数组元素初始化为 0 。
1.2.2. 访问数组:
计算数组元素的内存地址非常容易。给定数组内存地址(首元素内存地址)和某个元素的索引,不过一般知道是哪个数组。这里的访问的意思是,计算机通过内存区访问这个数组的元素。在我们访问数组中的元素时,直接使用元素对应的索引值即可。需要注意的是从地址计算公式的角度看,索引本质上是内存地址的偏移量。
1.2.3. 插入元素:
数组元素在内存中是“紧挨着的”,它们之间没有空间再存放任何数据。如果想在数组中间插入一个元素,则需要将该元素之后的所有元素都向后移动一位,之后再把元素赋值给该索引。由于数组的长度是固定的,因此插入一个元素必定会导致数组尾部元素“丢失”
1.2.4. 删除元素:
若想删除索引 i 处的元素,则需要把索引 i 之后的元素都向前移动一位。并且删除一个元素后,数组最后一位会空出一个位置。
1.2.5. 遍历元素:
可以利用索引来进行遍历,也可直接遍历。
1.2.6. 查找元素:
在数组中查找指定元素需要遍历数组,时间复杂度为 O(n),每轮判断元素值是否匹配,若匹配则输出对应索引。因为数组是线性数据结构,所以上述查找操作被称为“线性查找”。
1.2.7. 扩充元素:
数组的长度是不可变的。对于某一个数组自己是无法进行变化的,除非再初始化一个更大的数组然后将原数组复制过去
1.3. 优缺点
1.3.1. 优点
- 空间效率高
- 支持随机访问
- 缓存局部性:当访问数组元素时,计算机不仅会加载它,还会缓存其周围的其他数据,从而借助高速缓存来提升后续操作的执行速度。
1.3.2. 缺点
- 插入与删除效率低:
- 时间复杂度高:平均时间复杂度为 O(n)
- 容易丢失元素
- 内存浪费
- 长度不可变:初始化后长度固定,再扩容需要进行复制操作
- 空间浪费:有大量无法用到的内存空间
1.4. 应用
- 随机访问
- 排序与搜索:如再快速排序,归并排序和二分查找等上面
- 查找表:如 ASCll 表
- 机器学习:村长线性代数运算的数据
- 数据结构实现:用于实现栈,队列,哈希表,堆,图等。
2. 链表
链表
定义:一种线性数据结构,,其中的每个元素都是一个节点对象,各个节点通过“引用”相连接。每个元素的内存地址不连续。每个节点包括“值”和“引用”两项数据。
2.1. 链表操作
2.1.1. 初始化链表
初始化分为两步: 1.初始化各个节点对象:
# 初始化链表 1 -> 3 -> 2 -> 5 -> 4
ListNode* n0 = new ListNode(1);// 将 节点值 初始化为 1,节点指针为n0,下一节点为空
ListNode* n1 = new ListNode(3);
ListNode* n2 = new ListNode(2);
ListNode* n3 = new ListNode(5);
ListNode* n4 = new ListNode(4);
# 初始化链表 1 -> 3 -> 2 -> 5 -> 4
n0 = ListNode(1)
n1 = ListNode(3)
n2 = ListNode(2)
n3 = ListNode(5)
n4 = ListNode(4)
2.构建节点之间的引用关系。
n0->next = n1;
n1->next = n2;
n2->next = n3;
n3->next = n4;
n0.next = n1
n1.next = n2
n2.next = n3
n3.next = n4
由于链表是由多个独立的节点对象组成的。我们通常将头节点当作链表的代称,比如以上代码中的链表可记作链表 n0 。
2.1.2. 插入节点
插入节点只需要改变两个节点之间的引用(指针)即可,[[#1. 时间复杂度]]为 O(1)
/* 在链表的节点 n0 之后插入节点 P */
void insert(ListNode *n0, ListNode *P) {
ListNode *n1 = n0->next;
P->next = n1;
n0->next = P;
}
def insert(n0: ListNode, P: ListNode):
"""在链表的节点 n0 之后插入节点 P"""
n1 = n0.next
P.next = n1
n0.next = P
2.1.3. 删除节点
改变前置节点的引用(指针)即可。
/* 删除链表的节点 n0 之后的首个节点 */
void remove(ListNode *n0) {
if (n0->next == nullptr)
return;
// n0 -> P -> n1
ListNode *P = n0->next;
ListNode *n1 = P->next;
n0->next = n1;
// 释放内存
delete P;
}
def remove(n0: ListNode):
"""删除链表的节点 n0 之后的首个节点"""
if not n0.next:
return
# n0 -> P -> n1
P = n0.next
n1 = P.next
n0.next = n1
2.1.4. 访问节点
在链表中访问节点的效率较低,访问链表的第 i 个节点需要循环 i−1 轮,时间复杂度为 O(n) 。
/* 访问链表中索引为 index 的节点 */
ListNode *access(ListNode *head, int index) {
for (int i = 0; i < index; i++) {
if (head == nullptr)
return nullptr;
head = head->next;
}
return head;
}
def access(head: ListNode, index: int) -> ListNode | None:
"""访问链表中索引为 index 的节点"""
for _ in range(index):
if not head:
return None
head = head.next
return head
2.1.5. 查找节点
遍历链表,查找其中值为 target 的节点,输出该节点在链表中的索引。此过程也属于线性查找。其时间复杂度为 O(n)
/* 在链表中查找值为 target 的首个节点 */
int find(ListNode *head, int target) {
int index = 0;
while (head != nullptr) {
if (head->val == target)
return index;
head = head->next;
index++;
}
return -1;
}
def find(head: ListNode, target: int) -> int:
"""在链表中查找值为 target 的首个节点"""
index = 0
while head:
if head.val == target:
return index
head = head.next
index += 1
return -1
2.2. 常见列表类型
- 单向列表:即普通列表,首个节点称为头节点,将最后一个节点称为尾节点,尾节点指向空
None(节点) - 环形链表 :单向链表尾节点指向头节点,任意节点都为头节点
- 双向链表: 双向链表记录了两个方向的引用,同时包含指向后继节点(下一个节点)和前驱节点(上一个节点)的引用(指针),可以朝两个方向遍历链表,但相应地也需要占用更多的内存空间。
/* 双向链表节点结构体 */
struct ListNode {
int val; // 节点值
ListNode *next; // 指向后继节点的指针
ListNode *prev; // 指向前驱节点的指针
ListNode(int x) : val(x), next(nullptr), prev(nullptr) {} // 构造函数
};
class ListNode:
"""双向链表节点类"""
def __init__(self, val: int):
self.val: int = val # 节点值
self.next: ListNode | None = None # 指向后继节点的引用
self.prev: ListNode | None = None # 指向前驱节点的引用
2.3. 应用
- 单向链表通常用于实现栈、队列、哈希表和图等数据结构。
- 双向链表常用于需要快速查找前一个和后一个元素的场景:红黑树,
- 环形链表常用于需要周期性操作的场景,比如操作系统的资源调度。时间片轮转调度算法:
3. 列表
3.1. 定义
列表(list)表示元素的有序集合,支持元素的访问、修改、添加、删除和遍历等操作,无需考虑容量限制问题,主要基于链表和数组实现,其中链表天然可以看作是列表,而数组则是一个具有长度限制的列表。
为了解决使用数组实现列表时长度不可变造成的实用性降低问题,主要采用动态数组来实现列表。实际上,许多编程语言中的标准库提供的列表是基于动态数组实现的,动态数组可以进行扩容,例如 Python 中的 list 、Java 中的 ArrayList 、C++ 中的 vector 和 C# 中的 List 等。后续讨论的列表与动态列表视为等同的概念。
3.2. 列表实现
一般编程语言中内置了列表的实现
class MyList:
"""列表类"""
def __init__(self):
"""构造方法"""
self._capacity: int = 10 # 列表容量
self._arr: list[int] = [0] * self._capacity # 数组(存储列表元素)
self._size: int = 0 # 列表长度(当前元素数量)
self._extend_ratio: int = 2 # 若插入元素时列表容量已满,则需要进行扩容。先根据扩容倍数创建一个更大的数组,再将当前数组的所有元素依次移动至新数组。本示例中每次列表扩容的倍数
def size(self) -> int:
"""获取列表长度(当前元素数量)"""
return self._size
def capacity(self) -> int:
"""获取列表容量"""
return self._capacity
def get(self, index: int) -> int:
"""访问元素"""
# 索引如果越界,则抛出异常,下同
if index < 0 or index >= self._size:
raise IndexError("索引越界")
return self._arr[index]
def set(self, num: int, index: int):
"""更新元素"""
if index < 0 or index >= self._size:
raise IndexError("索引越界")
self._arr[index] = num
def add(self, num: int):
"""在尾部添加元素"""
# 元素数量超出容量时,触发扩容机制
if self.size() == self.capacity():
self.extend_capacity()
self._arr[self._size] = num
self._size += 1
def insert(self, num: int, index: int):
"""在中间插入元素"""
if index < 0 or index >= self._size:
raise IndexError("索引越界")
# 元素数量超出容量时,触发扩容机制
if self._size == self.capacity():
self.extend_capacity()
# 将索引 index 以及之后的元素都向后移动一位
for j in range(self._size - 1, index - 1, -1):
self._arr[j + 1] = self._arr[j]
self._arr[index] = num
# 更新元素数量
self._size += 1
def remove(self, index: int) -> int:
"""删除元素"""
if index < 0 or index >= self._size:
raise IndexError("索引越界")
num = self._arr[index]
# 将索引 index 之后的元素都向前移动一位
for j in range(index, self._size - 1):
self._arr[j] = self._arr[j + 1]
# 更新元素数量
self._size -= 1
# 返回被删除的元素
return num
def extend_capacity(self):
"""列表扩容"""
# 新建一个长度为原数组 _extend_ratio 倍的新数组,并将原数组复制到新数组
self._arr = self._arr + [0] * self.capacity() * (self._extend_ratio - 1)
# 更新列表容量
self._capacity = len(self._arr)
def to_array(self) -> list[int]:
"""返回有效长度的列表"""
return self._arr[: self._size]
3.3. 基本操作
3.3.1. 列表初始化
# 初始化列表
# 无初始值
nums1: list[int] = []
n = []#二者均可
# 有初始值
nums: list[int] = [1, 3, 2, 5, 4]
nums = [1, 3, 2, 5, 4]
/* 初始化列表 */
// 需注意,C++ 中 vector 即是本文描述的 nums
// 无初始值
vector<int> nums1;
// 有初始值
vector<int> nums = { 1, 3, 2, 5, 4 };
3.3.2. 访问元素
访问列表的时间复杂度为 O(1)
# 访问元素
num: int = nums[1] # 访问索引 1 处的元素
num = nums[1]
# 更新元素
nums[1] = 0 # 将索引 1 处的元素更新为 0
/* 访问元素 */
int num = nums[1]; // 访问索引 1 处的元素
/* 更新元素 */
nums[1] = 0; // 将索引 1 处的元素更新为 0
3.3.3. 插入和删除元素
列表进行插入和删除元素的操作时,在列表尾部添加元素的时间复杂度为 O(1) ,但插入和删除元素的效率仍与数组相同,时间复杂度为 O(n) 。
# 清空列表
nums.clear()
# 在尾部添加元素
nums.append(1)
nums.append(3)
nums.append(2)
nums.append(5)
nums.append(4)
# 在中间插入元素
nums.insert(3, 6) # 在索引 3 处插入数字 6
# 删除元素
nums.pop(3) # 删除索引 3 处的元素
/* 清空列表 */
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
/* 删除元素 */
nums.erase(nums.begin() + 3); // 删除索引 3 处的元素
3.3.4. 遍历数组
遍历时时间复杂度为 O(n)
# 通过索引遍历列表并进行逐个元素的相加
count = 0
for i in range(len(nums)):
count += nums[i]
# 直接遍历列表元素并进行逐个元素的相加
for num in nums:
count += num
/* 通过索引遍历列表 */
int count = 0;
for (int i = 0; i < nums.size(); i++) {
count += nums[i];
}
/* 直接遍历列表元素 */
count = 0;
for (int num : nums) {
count += num;
}
3.3.5. 拼接列表
# 拼接两个列表
nums1: list[int] = [6, 8, 7, 10, 9]
nums += nums1 # 将列表 nums1 拼接到 nums 之后
/* 拼接两个列表 */
vector<int> nums1 = { 6, 8, 7, 10, 9 };
// 将列表 nums1 拼接到 nums 之后
nums.insert(nums.end(), nums1.begin(), nums1.end());
3.3.6. 数组的排列
# 排序列表
nums.sort() # 排序后,列表元素从小到大排列
/* 排序列表 */
sort(nums.begin(), nums.end()); // 排序后,列表元素从小到大排列
进行列表的排列后即可进行二分查找,双指针的算法。
3.4. 小结
| 结构 | 长度是否可变 | 访问效率 | 插入和删除元素效率 | 是否内存浪费 |
|---|---|---|---|---|
| 数组 | 否 | 快 O(1) | 慢 O(n) | 可能会有 |
| 链表 | 是 | 慢 O(n) | 快 O(1) | 否 |
| 列表 | 否 | 快 O(1) | 慢 O(n) | 有 |
- 数组访问时通过访问元素地址来实现:元素内存地址 = 数组内存地址(首元素内存地址) + 元素长度 * 元素索引
- 链表重点在于更改引用(指针),链表共有单向链表、环形链表、双向链表三种类型
- 列表基于动态数组实现,大大提高数组的实用性
- 程序运行时,数据主要存储在内存中。数组可提供更高的内存空间效率,而链表则在内存使用上更加灵活。
- 数组相对于链表具有更高的缓存命中率
- 数组要求相同类型的元素,而在链表中不要求数据相同类型,因为链表的各个节点可以存储不同类型的数据
- 删除节点
P后,可以不把P.next设为None - 在实现嵌套列表时,需要使用循环实现,否则每一个元素都不是独立的