数组与链表

135 阅读11分钟

四.数组与链表

www.hello-algo.com/chapter_arr…

1. 数组

1.1. 定义:

一种线性数据结构,将相同类型的元素存储在连续的内存空间中,元素在数组(array)中的位置称为元素的索引。 image.png

1.2. 基本操作

1.2.1. 初始化数组

根据需求选用数组的两种初始化方式:无初始值、给定初始值。在未指定初始值的情况下,大多数编程语言会将数组元素初始化为 0 。

1.2.2. 访问数组

计算数组元素的内存地址非常容易。给定数组内存地址(首元素内存地址)和某个元素的索引,不过一般知道是哪个数组。这里的访问的意思是,计算机通过内存区访问这个数组的元素。在我们访问数组中的元素时,直接使用元素对应的索引值即可。需要注意的是从地址计算公式的角度看,索引本质上是内存地址的偏移量image.png

1.2.3. 插入元素

数组元素在内存中是“紧挨着的”,它们之间没有空间再存放任何数据。如果想在数组中间插入一个元素,则需要将该元素之后的所有元素都向后移动一位,之后再把元素赋值给该索引。由于数组的长度是固定的,因此插入一个元素必定会导致数组尾部元素“丢失”image.png

1.2.4. 删除元素

若想删除索引 i 处的元素,则需要把索引 i 之后的元素都向前移动一位。并且删除一个元素后,数组最后一位会空出一个位置。image.png

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. 链表

链表 定义:一种线性数据结构,,其中的每个元素都是一个节点对象,各个节点通过“引用”相连接。每个元素的内存地址不连续。每个节点包括“值”和“引用”两项数据。 image.png

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) image.png

/* 在链表的节点 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. 删除节点

改变前置节点的引用(指针)即可image.png

/* 删除链表的节点 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. 常见列表类型

  1. 单向列表:即普通列表,首个节点称为头节点,将最后一个节点称为尾节点,尾节点指向空 None(节点)
  2. 环形链表 :单向链表尾节点指向头节点,任意节点都为头节点
  3. 双向链表: 双向链表记录了两个方向的引用,同时包含指向后继节点(下一个节点)和前驱节点(上一个节点)的引用(指针),可以朝两个方向遍历链表,但相应地也需要占用更多的内存空间。 image.png
/* 双向链表节点结构体 */
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. 应用

  1. 单向链表通常用于实现栈、队列、哈希表和图等数据结构。
  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
  • 在实现嵌套列表时,需要使用循环实现,否则每一个元素都不是独立的