第 12 章 · 内存视角

回到最底层:每个结构在栈/堆上长什么样,指针怎么串起来(见 C 指针第 1、2、9 章)。

12.1 顺序表:一段连续堆内存

typedef struct { int *data; int len, cap; } SeqList;

SeqList 本身若定义在函数里是上的结构体(约 12 字节),但它的 data 指向 malloc 来的块。a[i] 编译成 *(data + i)——基址 + 偏移,一次地址计算 + 一次解引用(C 指针第 4 章)。

栈                   堆
┌────────────┐       ┌──────────────────────┐
│ data ──────┼──────►│ 0 │ 1 │ 2 │ ... │n-1│  ← 连续 int
│ len  cap   │       └──────────────────────┘
└────────────┘

12.2 链表:节点离散在堆,靠 next 串联

typedef struct Node { int val; struct Node *next; } Node;

每个 Node 各自 malloc,地址无规律地散布在堆里,next 存下一个节点的地址。

栈        堆
┌──────┐  ┌──────┬───┐   ┌──────┬───┐   ┌──────┬────┐
│ head─┼─►│ val1 │ ──┼──►│ val2 │ ──┼──►│ val3 │NULL│
└──────┘  └──────┴───┘   └──────┴───┘   └──────┴────┘
          地址A            地址B(≠A+16)   地址C

head->next->val 是两次解引用:先顺着 next 拿到 B,再读 B 的 val树、图、跳表、Trie 都是这个模型的推广——多几个指针字段而已(第 5、6、11 章)。

12.3 栈 vs 堆:生命周期决定结构选型

内存分配方式生命周期适合放
编译器自动函数返回即失效局部变量、小数组
malloc/free手动控制链表节点、树节点、动态数组

关键推论:链表节点必须放堆(函数返回后仍存活);放栈的局部节点返回即悬空——「返回局部变量地址」是经典陷阱(见 C 指针第 2 章)。

12.4 缓存局部性:数组快、链表慢的本质

CPU 从内存读数据以**缓存行(cache line,64 字节)**为单位。顺序表元素连续,读 a[i] 时把邻居一起搬进缓存,下次访问 a[i+1] 命中;链表节点离散,每次跳 next 大概率缓存未命中(cache miss),要重新访内存,慢一个数量级。

// 同样遍历 n 个元素:数组远快于链表(缓存局部性)
for (int i = 0; i < n; i++) sum += arr[i];          // 顺序访问,缓存命中率高
for (Node *p = head; p; p = p->next) sum += p->val; // 随机跳转,频繁 miss

12.5 各结构内存开销

结构每元素额外开销来源
顺序表0(或预留 cap 浪费)纯数据
单链表8 字节一个 next 指针
双向链表16 字节prev + next
二叉树16 字节left + right
哈希表(链地址)桶数组 + 节点指针桶 + next
Trie每字符一个指针数组26 或 256 个指针
跳表多层 next 指针期望 2×指针
printf("%zu %zu\n", sizeof(int), sizeof(Node));  // 8 vs 16:一个指针就翻倍

对比:数组连续、顺序访问命中缓存;链表离散、指针跳转打破局部性。故大量数据下数组遍历/排序常反超链表。

对比:顺序表近零开销但需连续块;链表/树付指针税换灵活性;Trie、跳表多指针换速度(空间换时间)。

12.6 从复杂度回到内存

第 1 章只数操作次数,本章补上每次操作的常数代价(一次解引用、一次 cache miss、一次 malloc)。数据结构 = 内存布局 + 指针关系 + 操作算法