入门 #algorithm#data-structure

数组与链表

数组(Array)和链表(Linked List)是最基础的数据结构——数组在内存中连续存储,支持随机访问;链表通过指针串联,支持灵活插入删除

📍 开学排座位——两种不同的占座方式

新学期开学,你们班要安排座位。老师有两种方案:

方案一(数组式): 教室里有 50 个座位排成一排,每个座位固定编号 1 到 50。你只要说出座位号,就能直接走到那个座位——“第 37 号座位”,走过去坐下。

方案二(链表式): 老师说:“张三,你坐第一排第一个。李四,你坐在张三后面。王五,你坐在李四后面……”每个人只知道自己后面是谁。你想找第 37 个人——得从第一个人开始,一个一个往后数。

这就是数组和链表最本质的区别:

  • 数组(Array) ——连续排列,知道编号就能直接定位
  • 链表(Linked List) ——链条串联,要找人必须顺着链走

这两种存储方式是几乎所有数据结构的”乐高积木”——理解了它们,你就理解了后续一切数据结构的基础。


🏢 数组——连续内存,随机访问

什么是数组?

数组是 一段连续的内存空间,每个元素大小相同,按顺序排列。

arr = [10, 20, 30, 40, 50]
# 内存中:| 10 | 20 | 30 | 40 | 50 |
# 地址:   100  104  108  112  116  (假设每个 int 占 4 字节)

随机访问——数组的”超能力”

数组最强大的能力是 随机访问(Random Access):给定下标,直接定位到对应元素。

arr[3]  # 直接取到 40

原理是下面这个公式:

arr[i] 的地址 = 数组起始地址 + i × 每个元素的大小

🧮 计算过程:arr 的起始地址是 100,每个 int 占 4 字节。arr[3] = 100 + 3 × 4 = 112 → 直接从地址 112 读取数据。一次乘法 + 一次加法,就是全部成本。不管数组是一万个元素还是一百万个。

这就是为什么数组访问是 O(1) 的——常数时间,和数组大小无关。

数组的插入和删除——代价高昂

arr = [10, 20, 30, 40, 50]

# 在位置 1 插入 15
# 需要把 20, 30, 40, 50 全部往后移一位
arr.insert(1, 15)
# arr → [10, 15, 20, 30, 40, 50]

插入/删除的”昂贵”在于:为了保持连续性和顺序,后面的元素必须全部移动。

  • 在末尾插入/删除:O(1)——不需要移动
  • 在开头插入/删除:O(n)——全部要移动
  • 平均情况:O(n)

适用场景

✅ 适合❌ 不适合
频繁按下标访问(arr[i]频繁在中间插入/删除
数据大小已知、变化不大数据规模经常变化
需要缓存友好的场景(连续内存)需要灵活分配内存

🔗 链表——指针串联,灵活插拔

什么是链表?

链表的每个元素是一个 节点(Node) ,每个节点包含数据和指向下一个节点的 指针(Pointer)

class ListNode:
    def __init__(self, val):
        self.val = val      # 数据
        self.next = None     # 指向下一个节点

# 创建链表:10 → 20 → 30 → 40 → 50
head = ListNode(10)
head.next = ListNode(20)
head.next.next = ListNode(30)
head.next.next.next = ListNode(40)
head.next.next.next.next = ListNode(50)
内存布局(分散的):
地址 200: [10 | 指向 350]  →  地址 350: [20 | 指向 180]  →  地址 180: [30 | None]

关键区别:数组在内存里是挨着坐的,链表在内存里分散坐,通过指针串联。

链表的插入和删除——“拆链重接”

# 在 20 和 30 之间插入 25
# 1. 创建新节点 25
# 2. 让 25 指向 30
# 3. 让 20 指向 25
new_node = ListNode(25)
new_node.next = node20.next   # 25 → 30
node20.next = new_node        # 20 → 25

插入/删除只需要修改相邻节点的指针——不需要移动任何数据。这就是链表插入/删除 O(1) 的原因。

🧩 类比:寻宝游戏

数组 = 电影院座位——你知道 15 排 7 座在哪,直接走过去。但要插一个新座位?你得把后面所有人的座位往后挪。

链表 = 寻宝游戏——你拿到一张纸条写着”宝藏埋在第三棵大树下”。到了第三棵大树,树下有张纸条写着”往前走 50 步的大石头下”。你要找第 37 个地点——得一张纸条一张纸条地看。

但埋新宝藏时很方便——在任意两个地点之间加一个点,只需要改前后两张纸条上的指引就行。

链表的种类

类型结构特点
单向链表A→B→C→D只能从头往后走
双向链表A↔B↔C↔D可前可后,更灵活
循环链表A→B→C→A尾节点指向头,适合环形场景

Python 的 list 是动态数组,而 collections.deque 是双向链表的实现。

链表的访问——慢

要访问链表的第 i 个元素,必须从 head 开始,沿着指针走 i 步:

def get_node(head, index):
    current = head
    for _ in range(index):
        if not current:
            raise IndexError("越界")
        current = current.next
    return current

这就是 O(n)——随着链表变长,访问时间线性增长。

适用场景

✅ 适合❌ 不适合
频繁在中间插入/删除频繁按下标访问
不知道数据总量缓存敏感的场景
实现队列、LRU 缓存等需要频繁随机读取

⚔️ 数组 vs 链表——全面对比

# 数组 vs 链表的代码对比
# 场景:做一个"通讯录",频繁添加联系人和按序号查找

# 数组方案
contacts_arr = []
contacts_arr.append("张三")  # 末尾添加 O(1)
contacts_arr.insert(0, "李四")  # 开头插入 O(n)
print(contacts_arr[2])  # 按序号查找 O(1)

# 链表方案
# 需要自己实现链表——Python 没有内置的单链表
# 末尾添加 O(n)——除非维护尾指针
# 开头插入 O(1)
# 按序号查找 O(n)
维度数组链表
内存连续,一次性分配分散,动态分配
随机访问O(1) ⚡O(n) 🐢
开头插入/删除O(n) 🐢O(1) ⚡
末尾插入/删除O(1) ⚡O(1)(有尾指针)
中间插入/删除O(n) 🐢O(1) ⚡(找到位置后)
额外内存几乎没有每个节点多一个指针(8 字节)
缓存利用好(连续内存,CPU 缓存预读)差(分散内存,缓存命中率低)

💡 内存层面的差异:数组的连续内存让 CPU 的缓存(Cache)可以”预读”相邻元素——当你访问 arr[0] 时,CPU 自动把附近的元素也加载到缓存。链表分散存储,每次访问都要从主存加载,慢很多。

这就是为什么算法复杂度相同的情况下,数组实现的算法往往比链表快——Cache Friendly(缓存友好)


🎯 如何选择?

操作模式 → 推荐
──────────────────────
频繁按索引访问      → 数组
大量在中间插入删除  → 链表
实现栈(LIFO)      → 数组(末尾操作)
实现队列(FIFO)    → 链表(两端操作)
数据量小且稳定      → 数组
数据量大且变化多    → 链表

实际经验:在绝大多数情况下,数组(动态数组)是默认选择。现代编程语言的动态数组(Python 的 list、Java 的 ArrayList、C++ 的 vector)在末尾追加时性能非常好,只有在确定需要大量在中间/开头插入时才考虑链表。


📝 小结

概念一句话
数组连续内存,随机访问 O(1) ——看电影找座位
链表节点+指针串联,插入删除 O(1) ——寻宝游戏
随机访问直接通过下标定位到元素
指针链表中指向下一个节点的引用
时间复杂度O(1)=常数时间,O(n)=和规模成正比

🎯 小练习:如果你要设计一个”音乐播放器的播放列表”——用户经常添加/删除歌曲,也需要按顺序播放。你会用数组还是链表?如果用户还需要”跳到指定的第 N 首歌”呢?

为什么先学这个? 数组和链表是所有数据结构的构建基石。理解了它们,下一步看最简单的两种”操作受限”的结构——栈与队列