CC++ & Algorithm

数组与链表的比较与应用

困难4
语言版本:通用
概述:数组和链表各有所长,选择哪种取决于你更看重查找速度还是插入删除效率,以及数据集的大小和变化模式。

数组 vs 链表:你的数据该用什么容器?

想象一下,你有一个盒子用来放你的零食。如果每次想拿中间的薯片,你希望直接伸手就能拿到(像数组),还是必须从盒子顶部一个个翻过去(像链表)?如果经常要往盒子中间塞新零食,你希望只挪动一小部分包装,还是得把后面所有零食都重新排一遍?这就是数组和链表的选择问题。

数组和链表是最基础的两种数据结构,用来组织一串数据。数组像一排连续的房间,每个房间有门牌号(下标);链表像一列火车,每节车厢拉着下一节车厢的把手(指针)。它们各有长短,没有绝对的好坏,关键看你最常做什么操作:是快速找到某个人,还是频繁增删中间的人?

生活中的对比(扩充版)

1. 座位表 vs 纸条链

  • 数组:就像教室里的固定座位。老师想叫“第3排第2个同学”,直接就能点名(快速随机访问)。但要是让两个同学换座位,就得把他们的名字都擦掉重写(修改慢)。如果想在中间插入一个新同学,后面所有人的座位都要往后移(插入慢)。

  • 链表:想象每个同学手里拿着一张纸条,上面写着“下一个同学是XXX”。老师要找第15个同学,只能从第1个开始问:“你后面是谁?”然后依次传话(慢查找)。但要是让一个新同学插队,只需要修改两张纸条——前面同学的“下一个”改成新同学,新同学的“下一个”改成原来那个(插入快)。

2. 衣架与钉子

  • 数组:像墙上的一排钉子,每个钉子上挂一件衣服。你能直接走到第5个钉子(快速找位置)。但想在第3和第4个钉子之间加一个新钉子?需要把第4个及以后的钉子全部拔掉重新钉(移动元素)。

  • 链表:像一串用绳子连起来的衣架。每个衣架上还挂着一个箭头,指向下一个衣架。要插入一个新衣架,只需解开前后两个箭头,把新衣架串进去(只改两个箭头)。但要找第三个衣架?只能从第一个开始顺着箭头数。

数组与链表的“体检报告”——每个维度都给你讲明白

我们先看一张对比表,然后每个维度都用生活例子和代码片段解释。

对比维度数组(包括动态数组)链表
随机访问O(1)O(n)
插入/删除头部O(n)(需移动全部)O(1)
插入/删除尾部均摊 O(1)O(1)(如有尾指针)、否则O(n)
插入/删除中间O(n)(移动元素)O(1)(如果已知位置指针)
内存效率连续,无额外指针,但可能浪费预留空间每个节点额外指针开销(16-32字节),分散存储
缓存友好性连续内存,缓存命中高离散内存,缓存命中低
分配方式一次性分配大块内存每次新节点动态分配,容易产生内存碎片
扩容成本需要复制所有元素无整体扩容,只需分配新节点

? 随机访问:找第几个有多快?

  • 数组:想找第5个元素,直接用下标 arr[4](因为下标从0开始),一步到位。就像你记得第5本漫画书在书架的第5格,伸手就拿。复杂度是 O(1),常数时间。

  • 链表:想找第5个节点,必须从头结点开始,一步一步顺着 next 指针走4步。就像你只知道第一本书,然后问“下一本是什么?”问4次才拿到。复杂度是 O(n),数据越多,找得越慢。

代码示意(伪代码,理解意思即可):

# 数组:直接通过下标访问
arr = [10, 20, 30, 40, 50]  # 创建包含5个数的数组
third = arr[2]              # 直接拿到30,一次操作

# 链表:需要从头遍历
class Node:
    def __init__(self, value):
        self.value = value   # 存的数据
        self.next = None     # 指向下一个节点的指针

head = Node(10)
head.next = Node(20)
head.next.next = Node(30)    # 构建一个3个节点的链表

# 要拿到第三个节点(30)
current = head               # 从第一个开始
count = 0
while count < 2:             # 需要走两步
    current = current.next
    count += 1
third_value = current.value  # 经过两次next才拿到30

生活类比:数组就像一排有号码的储物柜,你直接走到对应号码;链表就像寻宝游戏,每个线索只告诉你下一个线索在哪。


➕ 插入/删除:改数据时有多麻烦?

头部操作(在最前面加/删)

  • 数组:要在最前面插一个数,必须把后面所有数往后挪一位。比如 [1,2,3] 头部插入0,变成 [0,1,2,3],1、2、3都往后挪。如果数组有100万个数,就要挪100万次,慢得要命。
  • 链表:只要新节点指向原来的头结点,然后让链表头指针指向新节点。两步搞定,不管后面有多少节点。

中间操作(在中间加/删)

  • 数组:同样需要移动后面的元素。比如在位置5插入,位置5及之后的所有元素都要向右挪。
  • 链表:如果已经知道要插入的位置的前一个节点(比如通过遍历找到了),只需要修改两条 next 指针,不需要动其他元素。注意:查找这个前一个节点本身需要 O(n) 时间,但插入动作本身是 O(1)。如果你频繁插入且位置已知(比如你正在遍历时插入),链表就非常快。

尾部操作

  • 数组:如果尾部还有空位(比如动态数组预留了空间),直接加在最后,O(1)。如果空间满了,需要扩容(复制所有元素到新数组),但平均下来每次追加是 O(1)。
  • 链表:如果有一个 tail 指针一直指向最后一个节点,那么尾部插入也是 O(1)。否则要遍历到末尾,O(n)。

代码示例(Python 分别演示数组和链表的中间插入,并比较手动操作):

# ---------- 数组:中间插入 ----------
arr = [1, 2, 3, 4, 5]      # 初始数组
pos = 2                    # 想在索引2(第3个位置)插入99
arr.insert(pos, 99)        # 底层会移动索引2及之后的元素
print("数组插入后:", arr)  # 输出 [1, 2, 99, 3, 4, 5]

# ---------- 链表:中间插入 ----------
class Node:
    def __init__(self, data):
        self.data = data   # 节点数据
        self.next = None   # 下一节点指针

# 手动构建链表 1 -> 2 -> 3 -> 4 -> 5
head = Node(1)
head.next = Node(2)
head.next.next = Node(3)
head.next.next.next = Node(4)
head.next.next.next.next = Node(5)

# 想在节点2之后插入99(已知节点2的位置)
prev = head.next          # 节点2(值为2)
new_node = Node(99)       # 新节点存99
new_node.next = prev.next # 新节点指向原来节点2的下一个(节点3)
prev.next = new_node      # 节点2指向新节点
# 现在链表为 1 -> 2 -> 99 -> 3 -> 4 -> 5

? 内存与缓存:谁更省空间、更快?

  • 数组:所有元素紧挨着放在连续的内存里。就像一排连在一起的房间,每个房间大小一样。没有额外指针,所以内存利用率高。但如果你申请了100个房间,只用了10个,剩下90个就浪费了(预留空间)。另外,连续内存让CPU缓存更容易把整块数据加载进来,遍历时几乎每次都能在缓存中命中,速度极快。

  • 链表:每个节点是单独分配的内存,它们可能分散在内存各处。每个节点除了存数据,还要存一个或两个指针(单向链表一个,双向链表两个),每个指针占8字节(64位系统),额外开销不小。比如存一个整数(4字节),却要额外8字节的指针,内存浪费率 > 60%。而且指针跳跃导致缓存不命中,遍历速度可能比数组慢几十倍。

生活类比:数组像一栋公寓楼,邻居挨着住,串门很快(缓存友好);链表像分散在城市各处的房子,每串门一次就要开车去另一个地方(不友好)。


? 扩容:装满后怎么办?

  • 数组(动态数组):当空间满了,会申请一块更大的新内存(通常是当前大小的1.5倍或2倍),然后把所有老元素复制过去,再释放旧内存。这个过程比较耗时,但好在不是每次插入都会发生(均摊后O(1))。就像你有一个小书包,装满了就换一个大书包,把书都倒进去。
  • 链表:不需要整体扩容。每次插入新节点,只分配一个节点所需的内存。就像你每拿一本新书,就临时编一个篮子挂上去,篮子之间用绳子连起来,永远不会因为篮子太大而烦恼。但频繁小内存分配会导致内存碎片,就像很多小纸片散落一地。

? 实际应用场景(扩充版)

适合用数组的地方(包括动态数组)

  1. 排行榜:显示游戏分数排名,经常要按名次直接查第几名的分数。数组随机访问 O(1) 最合适。
  2. 游戏地图网格:比如扫雷、象棋,每个格子有固定坐标(行和列),用二维数组直接用下标定位。
  3. 存储一年12个月的名称:数据量固定,不需要插入删除。
  4. 做统计时先收集数据再分析:比如先读入所有学生的成绩,再计算平均分、最高分,中途不修改。
  5. CPU密集的科学计算:比如图像处理,像素数据是连续内存,用数组能让CPU缓存发挥最大作用。

适合用链表的地方

  1. 文本编辑器中的撤销/恢复操作:你频繁地插入字符、删除字符,而且位置不固定(比如在中间插入一个字),用链表(或更复杂的编辑树)效率高。
  2. 音乐播放列表:你可以随时在任意两首歌之间插入新歌,删除某首歌,而且通常只需要顺序播放(很少需要“跳到第50首歌”)。
  3. 实现队列和栈的底层:比如用链表实现队列,在头部删除、尾部插入都是 O(1),比用数组更灵活(数组实现队列需要循环数组或者移动元素)。
  4. 图的邻接表:每个顶点后面跟一个链表,存储它连接的所有顶点,插入新边就是链表头插,O(1)。

折中方案与多语言建议

实际工程中,标准库容器已经帮我们权衡好了:

  • C++vector 数组(默认首选),list 链表(插入删除多),deque 双端队列(两端操作快)
  • Pythonlist 就是动态数组,collections.deque 基于双向链表+数组分块(两端操作快)
  • JavaArrayList 动态数组,LinkedList 双向链表

简单选择流程(与原文一致):

  1. 需要频繁随机访问下标? → 选数组(vector/ArrayList)
  2. 仅在两端插入删除? → 双端队列(deque)或动态数组
  3. 需要在任意位置频繁插入删除? → 链表(list)
  4. 不确定? → 默认用动态数组,现代CPU对连续内存非常友好。

⚠️ 新手最容易犯的5个错误

  1. 数组下标越界:数组长度是 n,有效下标是 0 ~ n-1。写代码时容易写成 arr[n](比如访问第n个元素,以为从1开始),导致程序崩溃或奇怪结果。
    解决方法:牢记“下标从0开始”,动态获取长度 len(arr)arr.length

  2. 链表忘记更新指针:插入或删除节点时,忘记修改前后节点的 next 指针。比如删除节点时只改了前驱的 next,却没处理被删节点的 next(虽然不影响,但内存泄漏风险),或者更严重:只改了当前节点,没改前驱,导致链表断裂。
    解决方法:画图!用纸笔画清楚每一步要修改哪些指针,再写代码。

  3. 混淆“位置”与“值”:比如在链表中,你想删除“节点值等于10”的节点,但你把“第3个位置”和“值为10”搞混。链表一般需要先查找值,再删除。直接按位置删除通常要遍历。

  4. 动态数组频繁插入导致性能灾难:在动态数组中间插入,时间复杂度 O(n)。如果在一个大数组的头部反复插入,性能极差。新手可能以为动态数组万能,结果程序卡死。
    解决方法:如果确实要在头部频繁插入,考虑用双端队列或链表。

  5. 链表随机访问的O(n)误用:如果你需要“先找到第100个元素,再在第100个后面插入”,那么查找就要O(n),插入O(1),总时间还是O(n)。如果循环中反复按索引找,时间复杂度会升到O(n²)。
    解决方法:如果你既要随机访问又要频繁插入,可能要考虑其他数据结构(如跳表、树)。


? 完整代码示例:图书借阅系统 —— 数组 vs 链表

我们模拟一个迷你图书系统:书架上有5本书,需要添加新书(在中间插入)、删除某本书、查找第3本书。

用动态数组(Python list)实现

# book_system_array.py
books = ["百年孤独", "活着", "三体", "围城", "红楼梦"]  # 初始书单

print("初始书架(数组):")
print(books)

# 1. 查找第3本书(索引2)
print(f"\n第3本书是:{books[2]}")  # 直接下标,O(1)

# 2. 在索引1(第2本书)后面插入新书"小王子"
index = 1  # 表示要在第2本书之后插入
books.insert(index + 1, "小王子")  # 注意:insert是在指定索引前插入,所以用index+1
print(f"\n在'{books[index]}'后面插入'小王子'后:")
print(books)  # ['百年孤独', '活着', '小王子', '三体', '围城', '红楼梦']

# 3. 删除"围城"(值为"围城")
target = "围城"
if target in books:
    books.remove(target)  # 内部会移动元素
    print(f"\n删除'{target}'后:")
    print(books)  # ['百年孤独', '活着', '小王子', '三体', '红楼梦']
else:
    print(f"\n未找到'{target}'")

用链表(手动实现)完成相同操作

# book_system_linkedlist.py
class Node:
    def __init__(self, title):
        self.title = title   # 书名
        self.next = None     # 下一本书指针

class BookLinkedList:
    def __init__(self):
        self.head = None     # 第一个节点的指针

    def append_all(self, titles):
        """从列表批量添加书到链表尾部"""
        for title in titles:
            self.append(title)

    def append(self, title):
        """在链表末尾添加一本书"""
        new_node = Node(title)
        if self.head is None:
            self.head = new_node
            return
        last = self.head
        while last.next:          # 找到最后一个节点
            last = last.next
        last.next = new_node

    def get_by_index(self, index):
        """按索引查找书(从0开始),返回节点"""
        current = self.head
        count = 0
        while current:
            if count == index:
                return current
            current = current.next
            count += 1
        return None  # 越界返回None

    def insert_after(self, index, title):
        """在指定索引节点之后插入新书"""
        node = self.get_by_index(index)
        if node is None:
            print(f"索引{index}不存在,无法插入")
            return
        new_node = Node(title)
        new_node.next = node.next
        node.next = new_node

    def remove_by_value(self, title):
        """删除指定书名的第一本书"""
        current = self.head
        prev = None
        while current:
            if current.title == title:
                if prev is None:          # 要删除的是头节点
                    self.head = current.next
                else:
                    prev.next = current.next
                print(f"已删除'{title}'")
                return
            prev = current
            current = current.next
        print(f"未找到'{title}'")

    def display(self):
        """打印整个书架顺序"""
        titles = []
        current = self.head
        while current:
            titles.append(current.title)
            current = current.next
        print("书架(链表形式):", " -> ".join(titles))

# ---------- 测试 ----------
books_list = BookLinkedList()
books_list.append_all(["百年孤独", "活着", "三体", "围城", "红楼梦"])

print("初始书架(链表):")
books_list.display()

# 1. 查找第3本书(索引2)
node = books_list.get_by_index(2)
print(f"\n第3本书是:{node.title if node else '不存在'}")

# 2. 在索引1(第2本书)后面插入新书"小王子"
books_list.insert_after(1, "小王子")
print(f"\n在'活着'后面插入'小王子'后:")
books_list.display()

# 3. 删除"围城"
books_list.remove_by_value("围城")
print(f"\n删除'围城'后:")
books_list.display()

运行链表代码你会看到,插入和删除操作只涉及少量指针修改,不需要像数组那样移动大量元素。但查找第3本书需要走两步,不如数组直接 books[2] 快。


? 相关指引

掌握了数组和链表,你就可以进一步学习:

  • 队列和栈:基于数组或链表实现,理解“先进先出”和“后进先出”。
  • 哈希表:用数组+链表(链地址法)解决冲突,是“数组随机访问 + 链表动态插入”的经典组合。
  • :邻接表用链表存储边,邻接矩阵用二维数组存储边。
  • 缓存与局部性原理:为什么数组遍历快,链表慢?深入了解CPU缓存工作原理。
  • 更高级的数据结构:跳表(结合链表和二分查找)、动态数组的改良(如间隙缓冲 Gap Buffer)等。

记住:数据结构不是孤立的,它们常常组合使用。甚至很多问题可以用一种结构为主,另一种辅助。例如“用数组存索引,用链表存数据”,或者“用哈希表建立索引,用双向链表维持顺序”(Java的LinkedHashMap就是这么干的)。

现在你已经知道了数组和链表的优缺点,下次写程序时,停下来想一想:我主要需要“快速找到”还是“快速插入/删除”? 答案会直接帮你选出最趁手的工具。

例题精讲

1单选题

在已知插入位置(例如已给出节点指针)的情况下,在链表中插入一个元素和在数组中插入一个元素(已知索引),平均时间复杂度分别是多少?

A链表O(1),数组O(n)
B链表O(n),数组O(1)
C两者都是O(1)
D两者都是O(n)
2单选题

频繁进行随机访问(按索引直接取值)的操作,应优先选择哪种数据结构?

A数组
B链表
C两者效率相同
D取决于元素个数
3判断题

链表的各个节点在内存中一定是连续存储的,因此遍历链表时能充分利用CPU缓存局部性,提高访问速度。

4填空题
以下函数用于在单向链表的头部插入一个新节点。请补全代码。

struct Node {
    int data;
    Node* next;
};
void insertHead(Node*& head, int value) {
    Node* newNode = new Node;
    newNode->data = value;
    newNode->next = ___;
    head = newNode;
}
5填空题
以下代码使用数组实现栈的push操作。数组大小为MAX,栈顶指针top初始为-1。请补全push函数。

int stack[MAX];
int top = -1;
void push(int value) {
    if (top == MAX-1) {
        printf("Stack overflow\n");
        return;
    }
    ___;
    stack[top] = value;
}