← 返回 Day 6 正文

Day 6 · 图解

Day 006 — 列表(List)图解

用图理解列表的底层机制和操作原理


1. 列表的内存结构

Python 列表 = 动态数组(不是链表!)
═════════════════════════════════════

列表对象 lst = [10, 20, 30, 40, 50]

┌────────────────────────────────────────┐
│            PyListObject                │
├────────────────────────────────────────┤
│  ob_refcnt: 1                          │
│  ob_type: &PyList_Type                 │
│                                        │
│  ob_size: 5         ← 实际元素个数      │
│  allocated: 8       ← 已分配容量        │
│                                        │
│  ob_item: ──────→ ┌─────┬─────┐       │
│                    │ [0] │  ·──┼──→ int(10) │
│                    ├─────┼─────┤       │
│                    │ [1] │  ·──┼──→ int(20) │
│                    ├─────┼─────┤       │
│                    │ [2] │  ·──┼──→ int(30) │
│                    ├─────┼─────┤       │
│                    │ [3] │  ·──┼──→ int(40) │
│                    ├─────┼─────┤       │
│                    │ [4] │  ·──┼──→ int(50) │
│                    ├─────┼─────┤       │
│                    │ [5] │     │ (未使用)    │
│                    ├─────┼─────┤       │
│                    │ [6] │     │ (未使用)    │
│                    ├─────┼─────┤       │
│                    │ [7] │     │ (未使用)    │
│                    └─────┴─────┘       │
└────────────────────────────────────────┘

关键特点:
1. ob_item 指向一个连续的 PyObject* 指针数组
2. allocated > ob_size  → 预留空间,减少 realloc
3. 每个元素存储的是 "指向实际对象的指针",而非值本身

2. 索引与寻址原理

随机访问 O(1) 的原理
══════════════════════

列表内存地址 = 连续数组

假设 ob_item 指向的指针数组起始地址为 0x1000
每个指针占 8 字节(64位系统)

元素地址计算公式:
    element_addr = base_addr + index * element_size

访问 lst[3]:
    base_addr = 0x1000
    index = 3
    element_size = 8

    0x1000 + 3 * 8 = 0x1018

    → 直接读取地址 0x1018 处的指针值
    → 得到 int(40) 对象的地址

这就是 O(1) 的原因:
CPU 直接计算地址 → 一次内存读取 → 搞定!
不需要遍历任何东西。

3. Append 扩容流程

flowchart TD A[调用 lst.append\(x\)] --> B{容量够吗?<br/>ob_size < allocated?} B -->|是| C[在 ob_item[ob_size] 处<br/>存储 x 的指针] C --> D[ob_size += 1] D --> E[✅ 真正的 O\(1\)] B -->|否| F[申请新内存<br/>new_alloc = ob_size * 1.125 + α] F --> G[复制旧指针数组<br/>到新内存] G --> H[释放旧内存] H --> I[更新 ob_item<br/>指向新内存] I --> J[更新 allocated] J --> C
flowchart TD
    A[调用 lst.append\(x\)] --> B{容量够吗?<br/>ob_size < allocated?}
    B -->|是| C[在 ob_item[ob_size] 处<br/>存储 x 的指针]
    C --> D[ob_size += 1]
    D --> E[✅ 真正的 O\(1\)]

    B -->|否| F[申请新内存<br/>new_alloc = ob_size * 1.125 + α]
    F --> G[复制旧指针数组<br/>到新内存]
    G --> H[释放旧内存]
    H --> I[更新 ob_item<br/>指向新内存]
    I --> J[更新 allocated]
    J --> C

扩容示例

逐步扩容演示(从空列表开始)
════════════════════════════

初始: ob_size=0, allocated=0

append(1):
  → 扩容到 4 个位置
  → 写入索引 0
  → ob_size=1, allocated=4
  ┌───┬───┬───┬───┐
  │ 1 │   │   │   │
  └───┴───┴───┴───┘

append(2), append(3), append(4):
  → 直接写入,不需要扩容
  → ob_size=4, allocated=4
  ┌───┬───┬───┬───┐
  │ 1 │ 2 │ 3 │ 4 │
  └───┴───┴───┴───┘

append(5):
  → 容量满了,扩容!
  → new_alloc = 4 + (4>>3) + 3 = 8
  → 复制旧数据,写入索引 4
  → ob_size=5, allocated=8
  ┌───┬───┬───┬───┬───┬───┬───┬───┐
  │ 1 │ 2 │ 3 │ 4 │ 5 │   │   │   │
  └───┴───┴───┴───┴───┴───┴───┴───┘

结论:大部分 append 是 O(1),
只有少数需要扩容时才是 O(n),
均摊下来 ≈ O(1) 🎉

4. 切片操作的内存视角

lst[2:5] 的内存操作
════════════════════

lst = [10, 20, 30, 40, 50, 60, 70]
索引:   0   1   2   3   4   5   6

切片: lst[2:5]
        ↓   ↓   ↓
       30  40  50

                      原始列表
                 ┌──────────────┐
                 │ 指针数组       │
                 │ [0] → int(10) │
                 │ [1] → int(20) │
    ┌─────────── │ [2] → int(30) │ ←─┐
    │            │ [3] → int(40) │   │
    │            │ [4] → int(50) │ ←─┤
    │  新列表    │ [5] → int(60) │   │
    │ ┌──────┐   │ [6] → int(70) │   │
    │ │ [0] ─┼───┘               │   │
    │ │ [1] ─┼───────────────────┘   │
    │ │ [2] ─┼───────────────────────┘
    │ └──────┘
    └── 新列表的指针数组
        存的是指向同一个 int 对象的指针!
        (浅拷贝:复制指针,不复制对象)

重要:切片创建新列表,但元素指向相同的对象。
如果元素是可变对象(如嵌套列表),修改会影响原列表!

5. 列表推导式执行流程

[x**2 for x in range(5) if x % 2 == 0]
═════════════════════════════════════════

           ┌─────────────────────┐
           │  1. 创建空列表 result │
           └─────────┬───────────┘
                     │
                     ▼
           ┌─────────────────────┐
           │  2. for x in        │ ← 迭代器:0, 1, 2, 3, 4
           │     range(5)        │
           └─────────┬───────────┘
                     │
                     ▼
           ┌─────────────────────┐
           │  3. if x % 2 == 0   │ ← 筛选条件
           └──────┬──────┬──────┘
                  │真    │假
                  ▼     └──→ 回到步骤 2
           ┌─────────────────────┐
           │  4. x**2           │ ← 表达式求值
           └─────────┬───────────┘
                     │
                     ▼
           ┌─────────────────────┐
           │  5. result.append(  │ ← C 层面 append
           │     expression)     │
           └─────────┬───────────┘
                     │
                     ▼
                 回到步骤 2
                     │
                     ▼
           ┌─────────────────────┐
           │  6. 返回 result      │
           └─────────────────────┘

6. 列表 vs 其他数据结构的决策树

flowchart TD Q1["你需要什么操作?"] Q1 -->|"按索引随机访问"| Q2 Q1 -->|"频繁在开头插入/删除"| Q3 Q1 -->|"只需要快速入队出队"| Q4 Q1 -->|"需要去重和集合运算"| SET[使用 set] Q1 -->|"键值对映射"| DICT[使用 dict] Q2 -->|"长度固定"| TUPLE[使用 tuple<br/>更轻量] Q2 -->|"长度变化"| LIST[使用 list<br/>动态数组 ✓] Q3 -->|"偶尔插入"| LIST[list 也还行] Q3 -->|"频繁插入"| DEQUE[使用 collections.deque<br/>双向链表 ✓] Q4 -->|"先进先出"| DEQUE2[使用 collections.deque<br/>queue.Queue] Q4 -->|"后进先出"| LIST_STACK[list 就可以<br/>append + pop O(1)]
flowchart TD
    Q1["你需要什么操作?"]
    
    Q1 -->|"按索引随机访问"| Q2
    Q1 -->|"频繁在开头插入/删除"| Q3
    Q1 -->|"只需要快速入队出队"| Q4
    Q1 -->|"需要去重和集合运算"| SET[使用 set]
    Q1 -->|"键值对映射"| DICT[使用 dict]
    
    Q2 -->|"长度固定"| TUPLE[使用 tuple<br/>更轻量]
    Q2 -->|"长度变化"| LIST[使用 list<br/>动态数组 ✓]
    
    Q3 -->|"偶尔插入"| LIST[list 也还行]
    Q3 -->|"频繁插入"| DEQUE[使用 collections.deque<br/>双向链表 ✓]
    
    Q4 -->|"先进先出"| DEQUE2[使用 collections.deque<br/>queue.Queue]
    Q4 -->|"后进先出"| LIST_STACK[list 就可以<br/>append + pop O(1)]

7. 列表的常见操作复杂度

操作                │ 时间复杂度 │ 说明
───────────────────┼─────────┼──────────────────
lst[i]             │  O(1)   │ 随机访问
lst[i] = x         │  O(1)   │ 赋值(索引必须存在)
lst.append(x)      │  O(1)*  │ *均摊 O(1),偶尔扩容
lst.pop()          │  O(1)   │ 尾部弹出
lst.pop(i)         │  O(n)   │ 弹出后元素前移
lst.insert(i, x)   │  O(n)   │ 插入后元素后移
lst.remove(x)      │  O(n)   │ 先查找 (O(n)) 再删除 (O(n))
x in lst           │  O(n)   │ 线性搜索
lst[i:j]           │  O(k)   │ k = 切片长度
lst.sort()         │  O(n log n) │ Timsort
len(lst)           │  O(1)   │ 长度缓存为 ob_size
lst.copy()         │  O(n)   │ 复制所有指针

启示:在列表开头操作(insert(0, x)、pop(0))都是 O(n) 的。如果经常做这种操作,考虑 collections.deque。