数据结构:数组与链表
这一篇博客将介绍最基础的数据结构——数组与链表
数组
按逻辑结构来说,数组是一种线性数据结构,将相同类型元素存储在连续的内存空间中,元素在数组中的位置叫做元素的索引。
常用操作
初始化数组
访问元素
索引的含义本质上是数组内存地址(数组首地址)的偏移量。访问数组元素十分高效,可在
时间内随机访问数组中的任意一个元素 插入元素
要在数组中间插入元素,需要将插入位置之后的所有元素都向后移动,再把元素赋值给该位置,这样的后移操作会导致数组的最后一个元素丢失
1
2
3
4
5
6def insert(arr, item, index):
"""在arr的index处插入item"""
# 把索引index及之后的所有元素向后移动一位
for i in range(len(arr) - 1, index, -1): # range(len(arr) - 1, index, -1)获得(index, len(num) - ]范围
arr[i] = arr[i - 1]
num[index] = item1
2
3
4
5
6void insert(int[] arr, int item, int index){
for(int i = arr.length - 1; i > index; i--){
arr[i] = arr[i-1];
}
num[index] = item;
}1
2
3
4
5
6void insert(int* arr, int size, int item, int index){
for(int i = size - 1; i > index; i--){
arr[i] = arr[i - 1];
}
arr[index] = item;
}删除元素
要删除索引index处的元素,要将index之后的所有元素都向前移动
1
2
3def remove(arr, index):
for i in range(index, len(arr) - 1):
arr[i] = arr[i + 1]1
2
3
4
5
6void remove(int[] arr, int index){
for(int i = index; i < arr.length - 1; i++){
//注意:数组的索引最多只能达到arr.length - 1,因此是i < arr.length - 1
arr[i] = arr[i + 1];
}
}1
2
3
4
5void remove(int* arr, int size, int index){
for(int i = index; i < size - 1; i++){
arr[i] = arr[i + 1];
}
}可以看到,数组的插入与删除操作有以下缺点:
- 时间复杂度高:数组插入和删除的平均时间复杂度均为(n为数组长度)
- 元素丢失:插入元素时,数组最后一个元素将丢失**(也有可能最后一个元素是无效元素)
- 内存浪费:数组长度是固定的,可能有部分空间未被使用,造成内存浪费遍历数组
查找元素
在数组中查找元素需要遍历数组,进行线性查找,直到找到目标元素(返回目标元素索引)或者遍历结束仍未找到(通常返回-1)
扩容数组
无法保证数组末尾之后的内存空间可用,大多数编程语言中,数组长度是不可变的。想要扩容数组,需要重新建立一个更大的数组,再将原数组元素一一拷贝,复杂度为
1
2
3
4
5def extend(arr, enlarge):
res = [0] * (len(arr) + enlarge)
for i in range(len(num)):
res[i] = arr[i]
return res1
2
3
4
5
6
7int* extend(int* arr, int size, int enlarge){
int* res = new int[size + enlarge];
for(int i = 0; i < size; i++){
res[i] = arr[i];
}
return res;
}1
2
3
4
5
6
7
8
9
10
11int* extend(int*arr, int size, int enlarge){
int* res = (int*)malloc(sizeof(int) * (size + enlarge));
for(int i = 0; i < size; i++){
res[i] = arr[i];
}
// 初始化扩展后的空间
for(int i = size; i < size + enlarge; i++){
res[i] = 0;
}
return res;
}
数组优点与局限性总结
数组的特点:存储空间连续、元素类型相同
优点:
- 空间效率高:连续的内存空间,不需要除数据空间外的额外空间用以维护结构信息。
- 可随机访问:访问任一元素的时间复杂度为
- 缓存局部性良好:访问数组元素时,缓存会加载周围的局部性信息,通过局部性原理加快后续执行速度
缺点:
- 插入和删除效率低:特别是数组中元素较多时,需要移动大量的元素
- 长度不可变:数组内存空间初始固定分配,扩容数组开销很大
- 空间浪费:数组空间往往未被完全运用,造成空间浪费
典型应用
- 随机访问
- 排序和搜索:快速排序、归并排序、二分查找都主要在数组上进行
- 查找表
- 机器学习:存储向量、矩阵、张量
- 实现复杂数据结构
链表
按逻辑结构来说,链表是一种线性数据结构,节点(包含数据和引用或指针)存储在分散的内存空间中,节点引用或指针指向下一个节点,因此比数组多了一部分结构开销。
节点类实现方法
1 | class ListNode: |
1 | struct ListNode{ |
1 | typedef struct ListNode{ |
链表常用操作
初始化链表
- 初始化各个节点对象
- 构建引用指向关系(头节点常用于代称链表)
插入节点
- 插入节点只需改变节点引用(指针)即可,时间复杂度为
1
2
3def insert(pre_node, new_node):
new_node.next = pre_node.next
pre_node.next = new_nodeor C 1
2
3
4void insert(ListNode* pre_node, ListNode* new_node){
new_node->next = pre_node->next;
pre_node->next = new_node;
}- 插入节点只需改变节点引用(指针)即可,时间复杂度为
删除节点
- 删除节点只需改变一个(被删除节点的前驱节点)节点的引用(指针)
1
2
3
4
5
6def remove(n):
# 删除n节点后的首个节点
if not n.next:
# 若n节点为尾节点
return
n.next = n.next.nextor C 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16void remove(ListNode* n){
// 删除n节点后的首个节点
if(n->next == nullptr){
return;
}
ListNode* n0 = n->next; // 待删除节点
ListNode* n1 = n0->next; // 删除后n节点的后继节点
n->next = n1;
delete n0; //free(n0);
/*
n->next = n->next->next;
注意:不能直接这样写,C++需要手动释放内存,必须要存储带删除节点的指针值
*/
}访问节点
链表仅支持顺序访问,需要从头节点开始向后遍历,效率较低,时间复杂度为
1
2
3
4
5
6
7
8def access(head, index) -> ListNode | None:
for _ in range(index):
# 沿着head向后走index次,到达(0(head) + index处)
if not head:
# 访问到空节点
return None
head = head.next
return head1
2
3
4
5
6
7
8
9ListNode* access(ListNode* head, int index){
for(int i = 0; i < index; i++){
if(head == nullptr){
return nullptr;
}
head = head->next;
}
return head;
}1
2
3
4
5
6
7
8
9ListNode* access(ListNode* head, int index){
for(int i = 0; i < index; i++){
if(!head){
return NULL;
}
head = head->next;
}
return head;
}查找节点
- 遍历链表,线性查找
1
2
3
4
5
6
7
8def find(head, target):
index = 0
while head:
if head.val == target:
return index
head = head.next
index += 1
return -11
2
3
4
5
6
7
8
9
10
11int find(ListNode* head, int target){
int index = 0;
while(head != nullptr){
if(head->val == target){
return index;
}
head = head->next;
index++;
}
return -1;
}1
2
3
4
5
6
7
8
9
10
11int find(ListNode* head, int target){
int index = 0;
while(head){
if(head->val == target){
return index;
}
head = head->next;
index++;
}
return -1;
}
常见链表类型
- 单向链表
- 环形链表:令单向链表的尾节点指向头节点即可得到,任一节点都可视作头节点
- 双向链表
典型应用
- 单向链表:实现栈、队列、哈希表和图等数据结构
- 双向链表:高级数据结构(红黑树、B树)、浏览器历史、LRU算法(一种缓存淘汰算法)
- 循环链表:时间片轮转调度算法、数据缓冲区
列表
一个抽象的数据结构概念,表示元素的有序集合,能够访问、修改、添加、删除和遍历元素等,不必考虑容量限制的问题,可基于链表或动态数组(动态扩容)实现。许多编程语言中的标准库提供的列表都是基于动态数组实现的,如Python中的list
、Java中的ArrayList
、C++中的vector
和C#中的List
等(C未提供内置动态数组)。
常用操作
初始化列表
访问元素
- 列表本质上是数组,因此访问和更新元素的时间复杂度为
- 列表本质上是数组,因此访问和更新元素的时间复杂度为
插入和删除元素
- 在列表尾部进行插入或删除操作的时间复杂度为
,在列表中间进行插入或删除操作的时间复杂度为 。下面是使用Python和C++内置的列表进行插入删除操作的示例
1
2
3
4
5
6
7
8
9
10
11
12
13
14# 有初始值初始化列表
nums: nums[int] = [1, 3, 2, 5, 4]
# 尾部添加元素
nums.append(1)
# 在中间插入元素
nums.insert(3, 6) # 索引3插入6
# 删除元素
nums.pop(3) # 删除索引3处元素
# 清空列表
nums.clear()1
2
3
4
5
6
7
8
9
10// 有初始值初始化
vector<int> nums = { 1, 3, 2, 5, 4 };
nums.push_back(1);
nums.insert(nums.begin() + 3, 6);
nums.erase(nums.begin() + 3);
nums.clear();- 在列表尾部进行插入或删除操作的时间复杂度为
遍历列表
- 可用索引遍历,Python中用
len()
函数获取列表长度,C++中用list.size()
获取列表长度 - 可直接迭代遍历,Python中用
for num in nums
语句,C++中用for(int num : nums)
语句
- 可用索引遍历,Python中用
拼接列表
1
2nums1 = [6, 8, 7, 10, 9]
nums += nums11
2
3
4
5// 有初始值初始化
vector<int> nums1 = { 6, 8, 7, 10, 9 };
// nums.end()指定插入位置,nums1.begin()指定插入元素起始位置,nums1.end()指定插入元素结束位置
nums.insert(nums.end(), nums1.begin(), nums1.end());排序列表
1
nums.sort() # 增序
1
sort(nums.begin(), nums.end()) // 增序
(整数)列表简单实现
三个设计重点:初始容量、数量记录和扩容机制
1 | # 单下划线前缀:一种约定,表示一个类的内部使用变量或方法,但不会被Python解释器认为是私有的,只是提示开发人员不要直接访问或修改这些变量 |
1 | class MyList{ |
1 | typedef struct{ |
- 标题: 数据结构:数组与链表
- 作者: 敖炜
- 创建于 : 2023-10-23 13:59:21
- 更新于 : 2024-04-19 09:30:09
- 链接: https://ao-wei.github.io/2023/10/23/数据结构:数组与链表/
- 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。