1. 先把"线性表"这个词拆开:它描述的是一份契约,不是一种内存布局
很多人第一次接触线性表,脑子里浮现的是数组。这个联想不算错,但它只对了一半,而且是最容易让人在后面学链表时卡壳的那一半。线性表真正描述的东西,是一组元素的逻辑关系:除了第一个元素没有前驱、最后一个元素没有后继,中间每个元素有且只有一个直接前驱和一个直接后继。它讲的是"谁挨着谁",跟这些元素在内存里是不是挨着,完全是两码事。
这个区分看着像文字游戏,实际影响很大。你后面写代码时遇到的绝大多数崩溃和逻辑错误,恰恰来自"逻辑上相邻"和"物理上相邻"这两件事被混为一谈。顺序表里它们恰好重合,链表里它们彻底分离,而初学者往往在写完顺序表之后形成了"相邻就是挨着"的肌肉记忆,切到链表就开始写出p[1]这种表达。所以我在带人入门时,一定会先把这层窗户纸捅破再往下讲。
1.1 ADT 定义里真正起作用的只有那几条操作语义
教科书写线性表的抽象数据类型,会列出一长串操作:初始化、销毁、判空、求长度、按位取值、按值查找、插入、删除、遍历。看着琐碎,但真正决定一个实现好坏的核心只有四条:按位取值、按位插入、按位删除、按值查找。剩下的基本都是这四条的组合或者包装。
关键在于,每一条操作除了"能干什么",还有一层隐含的"承诺"。比如按位取值承诺不改变表的内容,插入承诺原有元素的相对次序不变,删除承诺被删元素之后的元素整体前移一位。这些承诺才是契约的本质。你用数组实现,用链表实现,甚至用一块 mmap 出来的共享内存实现,只要承诺兑现,上层代码就不该察觉到差别。
提示:判断一个线性表实现是否合格,别看它提供了多少接口,看它在这四条核心操作的边界情况上是否符合契约。空表插入、末尾删除、下标等于长度,这三种情况能过,基本就及格了。
我见过不少人写顺序表,把length定义成"最后一个元素的下标",于是空表时length = -1。这在 C 里意味着你得用一个有符号类型,而且每次循环条件都要写成i <= length而不是i < length,一不小心就多访问一个位置。用"元素个数"来定义length,空表就是 0,循环统一写成i < length,能省掉后面一大半的下标推敲。
1.2 顺序表、单链表、双链表的分工,不是一张复杂度表格能说完的
几乎所有资料都会给你一张表:顺序表随机访问 O(1)、插入删除 O(n);链表随机访问 O(n)、已知位置插入删除 O(1)。这张表是对的,但它漏掉了一个决定性的前提——链表的 O(1) 插入,前提是你已经拿到了那个位置的指针。如果你只知道下标 i,那对不起,你还是得先走 i 步,整体依然是 O(n)。
这个前提在实际工程里极其致命。很多人看表以为链表插入快,兴冲冲把业务里的列表换成链表,结果发现按索引修改反而更慢了,因为每次都要从头遍历。链表真正擅长的是"手里已经攥着节点引用"的场景,比如遍历过程中顺手删除当前节点、LRU 链表里把节点挪到头部、事件循环里维护待处理队列。
| 场景 | 顺序表 | 单链表 | 判断依据 |
|---|---|---|---|
| 频繁按下标读写 | 明显更优 | 差 | 地址算术 vs 逐节点跳转 |
| 末尾追加 | 优(摊还 O(1)) | 优(维护尾指针) | 都需要 O(1) |
| 头部插入删除 | 差 | 优 | 整体搬移 vs 改两个指针 |
| 遍历中删除当前元素 | 差 | 优 | 删除会导致后续下标失效 |
| 内存局部性敏感 | 明显更优 | 差 | 连续内存命中缓存行 |
| 元素体积很大 | 差 | 优(存指针) | 搬移成本按元素大小放大 |
这张表里最后两行是我额外加的,也是教科书通常不强调的。内存局部性这一条在现代机器上的权重远超很多人的想象。顺序表遍历时,一次缓存行加载能带进十几个元素;链表每跳一个节点基本就是一次缓存未命中,实测下来同样的遍历,链表慢三五倍是常态,元素结构一复杂还会更夸张。这就是为什么很多标准库的默认容器是动态数组而不是链表。
1.3 教学顺序总是先讲顺序表,理由不只是"简单"
先讲顺序表,表面理由是它基于数组,大家熟悉。更深一层的原因是:顺序表把所有"麻烦"都暴露在明面上——你得自己管理容量、自己算下标、自己处理搬移、自己判断越界。这些麻烦在链表里并没有消失,只是换了一种形式(变成指针操作和内存管理),而在顺序表阶段把它们摸熟,切到链表时你就只需要专注"指针怎么接"这一件事。
反过来,如果一上来就讲链表,初学者很容易把"指针画图"当成数据结构的主体,画出漂亮的图,写出的代码却在插入第一个元素、删除最后一个元素、释放整条链这类边界上崩掉。我个人的经验是,顺序表写顺手了,链表的两小时就能过;顺序表靠背代码过关的,链表能卡一周。
2. 顺序表的内存布局:下标寻址为什么能做到常数时间
顺序表的全部秘密就一句话:元素存放在一段连续的内存里,并且每个元素占用的字节数相同。有了这两个条件,第 i 个元素的地址就是一个纯算术表达式,跟 i 的大小毫无关系。这也是它敢承诺 O(1) 随机访问的全部底气。
这段连续内存从哪来?两种方式。静态方式是编译期就定好大小的数组,int a[100];,简单粗暴,代价是上限焊死。动态方式是在堆上申请一块空间,用一个指针指向它,运行时按需扩容。工程里几乎只用后者,因为没人愿意给自己的列表设一个硬上限。但要注意,动态方式拿到的仍然是一段连续内存,malloc保证这一点,这是整套地址算术成立的前提。
2.1 从 base + i * sizeof(T) 说起
假设起始地址是base,每个元素占sizeof(int) = 4字节,那么第 i 个元素的地址就是base + 4 * i。编译器把data[i]翻译成这个表达式,CPU 执行一次乘法加一次加法,然后直接访存。这中间没有任何循环、没有任何跳转,所以是常数时间。
链表就没这个待遇了。你想拿第 i 个节点,必须先读第一个节点的next指针,顺着它读到第二个,再读第三个……读第 i 个节点需要 i 次访存,而且这 i 次访存之间存在数据依赖,CPU 的乱序执行和预取器都帮不上忙,只能老老实实等。所以在实际硬件上,顺序表的随机访问和链表的随机访问差距,比复杂度记号里 O(1) 和 O(n) 的差距还要大。
一个常见的误解是"反正 n 很小,无所谓"。n = 100 的时候链表访问末尾确实也不算慢,但如果你在一个循环里反复访问,比如双重循环里内层按下标取值,那 100 倍的差距就出来了。我调过一个把二维网格拍平成一维链表的代码,改成顺序表之后整体耗时从 800ms 掉到 60ms,逻辑一行没动。
2.2 capacity 和 length 必须分开存的两个理由
初学者写顺序表最常见的设计是只存一个数,用它同时表示"用了多少"和"有多大"。这个设计在静态数组上勉强能用,一旦换成动态扩容就彻底行不通。原因有两个。
第一个理由是成本。如果每次插入都要重新申请内存,那把 n 个元素依次插进去就是 n 次分配,每次分配还可能触发一次全量拷贝,总代价直接飙到 O(n²)。分开存之后,你只在length撞到capacity时才扩一次,平摊到每次插入上的成本就降下来了。
第二个理由是语义。length描述的是逻辑状态——表里现在有几个有效元素;capacity描述的是物理状态——这块内存能装几个。这两个值在绝大多数时刻是不相等的,而且它们的变化时机也完全不同:length每次插入删除都变,capacity只在扩容时跳变。把它们强行合并成一个变量,等于放弃了"预留空间"这个优化手段。
注意:
capacity一旦扩大就不要轻易缩小。频繁的"扩一下缩一下"会让内存分配器疲于奔命,而且在扩容瞬间会出现新旧两块内存同时存在的峰值占用。缩容留给显式的shrink_to_fit之类接口,由调用方决定。
2.3 一段最小可用的结构体定义
我习惯用下面这个骨架,字段顺序和类型都是踩过坑之后定下来的:
#include <stddef.h> typedef struct { int *data; /* 堆上的连续内存,空表时为 NULL */ size_t length; /* 有效元素个数 */ size_t capacity; /* 当前可容纳的元素个数 */ } SeqList;length和capacity都用size_t,不是int。这一条很关键:用有符号整型做下标,你会不停地在i >= 0和i < length之间纠结,而且length - 1在空表时等于 -1,配合无符号比较会立刻变成一个巨大的正数。统一用无符号类型,把"下标不可能为负"这件事写进类型里,能消灭掉一大类边界 bug。
data初始化为NULL、capacity初始化为 0,这两个约定让"空表"这个状态变得唯一且可判断。不要用malloc(0)返回的那个指针当空表标志,标准对它的行为规定得很宽松,不同实现可能返回NULL也可能返回一个不可解引用的非空指针,拿它做判空会出问题。
3. 插入与删除的真实成本:教科书隐藏掉的那半个 O(n)
复杂度记号把插入说成 O(n),这个结论没错,但它掩盖了一个重要事实:插入和删除的成本几乎全部花在搬移元素上,而不是花在"找到位置"上。这个认知差距直接决定了你后面优化代码时的方向——如果瓶颈在搬移,那优化方向就是减少搬移量或者减小单个元素的体积,而不是去优化查找。
而且 O(n) 这个记号还有个副作用,它让人以为"n 小的时候随便用"。实际上搬移的常数因子可能非常大,一次memmove搬 1000 个 4 字节整数和搬 1000 个 256 字节的结构体,耗时差两个数量级。这个差别在纸面上完全看不出来。
3.1 移动元素次数的精确计算
在长度为 n 的表里,往第 i 个位置(i 从 0 开始,允许 i = n 表示追加到末尾)插入一个元素,需要把原来下标 i 到 n-1 的元素整体后移一位,涉及的元素个数是n - i。插入到末尾时 i = n,移动 0 个;插入到头部时 i = 0,移动 n 个。
删除第 i 个元素,需要把下标 i+1 到 n-1 的元素整体前移一位,涉及的元素个数是n - 1 - i。删除末尾元素时移动 0 个,删除头部元素时移动 n - 1 个。
这两个公式值得抄在手边。用它算一下就知道,往一个十万元素的表头部插一个元素,要搬十万个元素;而追加到末尾是零搬移。同样是"插入",成本差了五个数量级。
3.2 平均移动次数:为什么说删除比插入"便宜一点点"
假设插入位置在 0 到 n 之间等概率取值(共 n+1 种可能),平均移动次数是:
(1/(n+1)) * Σ(n - i) for i = 0..n = (1/(n+1)) * (n + (n-1) + ... + 0) = (1/(n+1)) * n(n+1)/2 = n/2删除位置在 0 到 n-1 之间等概率(共 n 种可能),平均移动次数是(n-1)/2。两者相差半个元素,平时可以忽略,但在 n 很小的极端情况下(比如 n = 1),这个差别就是"搬 0 次"和"搬 1 次"的区别,测试用例写不写对就看这里。
需要强调的是,这个"等概率"假设在真实业务里经常不成立。日志追加场景下插入几乎全在末尾,平均成本接近 0;而如果业务是"最新消息插到最前面展示",那插入几乎全在头部,平均成本就是 n,此时顺序表是最差的选择,应该换链表或者干脆用环形缓冲。
/* 在 pos 处插入 v,pos 取值范围 [0, length] */ int seq_insert(SeqList *L, size_t pos, int v) { if (pos > L->length) return -1; /* 下标越界 */ if (seq_reserve(L, L->length + 1) != 0) /* 先保证容量 */ return -1; for (size_t i = L->length; i > pos; --i) /* 从后往前搬 */ L->data[i] = L->data[i - 1]; L->data[pos] = v; L->length++; return 0; }这段代码有两个地方容易写错。一是循环方向必须从后往前,从前往后搬会把还没搬的元素直接覆盖掉,结果是插入位置之后全是同一个值。二是循环条件i > pos,当pos == length(追加)时循环一次都不执行,正好符合"零搬移"的预期;如果用i >= pos,追加时会往data[length]写一次data[length-1],虽然不影响正确性但多做了一次无用功,而当length == 0时还会访问data[-1],直接踩越界。
3.3 memmove 与手写循环:编译器能帮你的和帮不了你的
单个元素很小的时候,现代编译器基本能把上面那个循环向量化成 SIMD 指令,手写循环和memmove的性能差距不大。但如果元素是几十上百字节的结构体,memmove通常会明显更快,因为它在内部会走宽字长拷贝甚至非临时存储指令,而编译器对结构体数组的循环向量化能力要保守得多。
有个前提必须记住:重叠区间只能用memmove,绝对不能用memcpy。插入操作里源区间和目标区间是重叠的(目标整体后移一位),用memcpy属于未定义行为,某些优化等级下它会假设区间不重叠而做反向拷贝,结果就是数据被莫名其妙地破坏。这个 bug 极其难查,因为它可能只在开了-O2的发布版本上出现,调试版本一切正常。
/* 用 memmove 替代循环,注意目标地址在前一位 */ memmove(&L->data[pos + 1], &L->data[pos], (L->length - pos) * sizeof(int));计算字节数时那个sizeof(int)不能漏,漏了就只搬四分之一个数组。这类错误编译器不会报,运行时也不一定崩,数据静静地错着,等你在别处发现异常的时候早就找不到源头了。
4. 动态扩容:1.5 倍、2 倍与固定增量,选错了会埋大雷
扩容策略是顺序表里唯一一个"看起来随意,实际上影响巨大"的决策。选错了不会让程序立刻出错,但会在数据量涨到某个量级时让性能突然塌方,而且这种塌方往往出现在压测或者线上高峰,排查起来非常痛苦。
通常有三种策略:每次扩容增加固定数量(比如 +10)、每次乘以固定的倍数(1.5 或 2)、先按倍数再设上限。第一种是最容易想到的,也是最不该用的。
4.1 固定增量为什么必然导致平方级复杂度
假设每次满了就加 10 个槽位,从 0 涨到 n 需要扩容约 n/10 次。每次扩容都要把当前所有元素拷贝一遍,第 k 次扩容的拷贝量约为 10k,累加起来是10 * (1 + 2 + ... + n/10),约等于n²/20。也就是说,光是把 n 个元素塞进去,开销就是平方级的,插 10 万个元素要搬 5 亿次。
这个结论反直觉的地方在于:每次扩容看起来只搬了"当前这么多"元素,怎么会累加成平方?关键在于扩容次数本身随 n 线性增长,而每次的搬移量也随 n 线性增长,两个线性相乘就是平方。几何增长打破的正是这一点——扩容次数变成对数量级,把总搬移量压回了线性。
4.2 几何增长的摊还成本,手算一遍比看公式清楚
取 1.5 倍增长,初始容量 8。依次插入时,扩容发生在插入第 9、13、19、28、42、63、94…… 个元素的时候,对应的拷贝量是 8、12、18、27、40、60、90……
到容量涨到 C 为止,总拷贝量约为8 + 12 + 18 + ... + 2C/3,这是一个等比数列,公比 1.5,求和结果是3C - 24左右。也就是说,插入 n 个元素的总拷贝次数不到 3n,均摊到每次插入上是常数。2 倍增长对应的是不到 2n。这就是"摊还 O(1)"的来历。
但摊还分析只描述长期平均,不描述单次延迟。第 n 次插入如果正好撞上扩容,那一次就是实打实的 O(n),如果这个插入发生在交互式界面的事件响应里,用户就会感觉到一次明显的卡顿。对延迟敏感的场景,正确做法是提前预留容量(启动时reserve一个预估上限),把扩容挪到初始化阶段,而不是指望摊还分析救场。
4.3 realloc 能不能直接用,以及那个溢出的坑
扩容时用realloc比"申请新块 + 拷贝 + 释放旧块"省事,而且有机会在原地扩展(如果堆上紧邻的块恰好空闲),能减少一次全量拷贝。但这里有个流传很广的说法需要澄清:"1.5 倍扩容能让 realloc 原地扩展,2 倍不行"。这个说法有它的道理——1.5 倍时释放出来的旧块占新块的三分之二,理论上够新块使用;2 倍时旧块只占一半,不够。但实际能不能原地扩展取决于内存分配器的实现和当前堆布局,不能当成保证。把它当经验参考可以,当设计依据就过头了。
真正必须处理的是乘法溢出。capacity * 1.5在size_t上如果接近上限,会回绕成一个很小的值,然后realloc成功返回一个远小于预期的块,后续写入就是堆溢出。这个坑在 32 位程序上不是理论问题,是真实会踩的。
static int seq_reserve(SeqList *L, size_t need) { if (need <= L->capacity) return 0; size_t cap = L->capacity ? L->capacity : 8; while (cap < need) { size_t next = cap + cap / 2; /* 1.5 倍 */ if (next <= cap) { /* 溢出保护 */ cap = need; break; } cap = next; } /* size_t 乘元素大小同样可能溢出,先判一下 */ if (cap > (size_t)-1 / sizeof(int)) return -1; int *p = realloc(L->data, cap * sizeof(int)); if (!p) return -1; L->data = p; L->capacity = cap; return 0; }这里有个细节:realloc失败时返回NULL,但原来的指针依然有效。所以必须用一个临时变量接返回值,成功了再赋给L->data。直接写L->data = realloc(L->data, ...)是经典泄漏写法——一旦失败,旧指针丢了,新块也没拿到,那段内存就永远回不来了。这个错误在教科书示例里出现的频率高得离谱。
5. 我踩过的四个坑,以及当时完整的排查链路
这一段写的是真实卡过我的问题,不是从书上抄来的。写它们的价值不在于给出答案,而在于把"从一个模糊现象追到根因"的过程摊开——因为实际工作中你面对的基本都是模糊现象,能复现出稳定错误码已经算运气好。
5.1 从"偶尔崩在 printf"到定位到下标越界的两小时
现象是程序跑几千次里会有一次在打印列表内容时崩溃,崩溃位置在内核的字符串处理函数里,看起来跟我写的逻辑毫无关系。第一反应是库有问题,查了半天没有任何发现。
排查链路是这样走的:先把打印语句改成逐个元素输出,发现崩溃前打印出来的最后一个数字是个负数,明显不对;再看看它前面那个数字,是对的;于是怀疑length被写大了。加了一段断言,在每次操作后校验length <= capacity,运行了几百次终于触发——断言失败发生在一次删除之后。
顺着那次删除往回看,问题出在删除函数的下标检查写成了if (pos > L->length),而正确写法是>=。当pos == length时,函数读到了那个从未初始化过的槽位,把里面的垃圾值当成元素删掉,length减一,整个表从这一刻起就少了一个元素,后面所有下标全部错位。而那个垃圾值恰好是个负数,打印时触发了格式化异常,于是崩溃现场看起来跟删除代码完全无关。
这个坑给我的教训有两个。一是越界检查必须写成区间形式,把合法区间[0, length)直接翻译成pos >= length就报错,不要用>。二是崩溃现场不等于错误现场,越界写坏的数据可能要过很久才被读到,看到崩溃位置的第一反应应该是"哪块内存被写坏了",而不是"这行为什么错"。
5.2 释放之后仍被持有的指针,在一台机器上灵一台机器上崩
有一段代码,销毁列表后把指针置空了,但别处还存着一份data字段的副本用于遍历。构造函数在别的机器上跑得好好的,换到另一台机器上就偶发段错误。这类问题最烦的地方是它不可复现,看起来像"环境问题"。
定位方法是打开地址清理工具跑一遍,它会精确指出访问发生在一次free之后,并打印出分配和释放的完整调用栈,一眼就能看到问题所在。这里的关键认知是:释放内存之后立刻置空结构体里的指针,只能保护通过这个字段访问的路径,保护不了任何已经流出去的副本。要根治,必须让"谁能持有这段内存"这件事有明确的约定,通常做法是让销毁函数成为唯一出口,并且明确文档化"销毁后所有派生指针全部失效"。
提示:这类问题在开发机上往往不能稳定复现,因为堆的布局、分配器的行为、甚至 ASLR 都会影响"释放后的内存是否还能读到旧值"。不要因为"我这跑着没事"就放过它。
5.3 结构体里存指针还是存值,一次内存泄漏的复盘
顺序表的元素类型如果是结构体,你会面临一个选择:表里直接存结构体本身,还是存指向结构体的指针。这两种选择在内存管理上的负担完全不同。
存值的话,data就是一块n * sizeof(Struct)的连续内存,扩容时realloc搬的是整个结构体,深拷贝语义天然成立。存指针的话,表本身只管理指针数组,那些被指向的对象得单独管,销毁表的时候必须逐个释放,漏一个就是一次泄漏。
我当时选了存指针,理由是结构体大,搬移便宜。结果销毁函数里只写了free(L->data),忘了遍历释放每个元素,跑一天下来泄漏掉几百兆。复盘时发现根本原因不是忘了写,而是没有把"表拥有元素所有权"这件事写进接口文档,所以调用方以为表只是个容器,自己也去释放了一遍,双重释放的隐患同时埋下了。
后来的做法是:如果元素体积小(几十字节以内)且没有内部指针,一律存值,省心;如果必须存指针,就在结构体名字和函数名前缀里明确表达所有权语义,销毁函数必须显式遍历释放,并且允许传入一个回调来处理元素内部的资源。
5.4 边界条件三件套:空表、满表、只有一个元素
我现在写任何线性结构,第一版测试用例只有三个:空表上做所有操作、容量恰好满时插入、表里只有一个元素时删除。这三个场景能覆盖掉绝大部分的越界和空指针问题。
空表最容易出的错是"求最后一个元素"。写成L->data[L->length - 1],length为 0 时下标变成SIZE_MAX,访问到的地址离数组十万八千里。满表最容易出的错是扩容判断写成>而不是>=,正好装满时不再扩容,下一次插入直接踩坏堆上的下一个块。单元素最容易出的错是删除后忘记处理"表变空"这个状态,有些实现会在这里残留一个仍然指向已释放内存的尾指针。
int seq_erase(SeqList *L, size_t pos, int *out) { if (pos >= L->length) return -1; /* 注意是 >= */ if (out) *out = L->data[pos]; memmove(&L->data[pos], &L->data[pos + 1], (L->length - pos - 1) * sizeof(int)); L->length--; return 0; }注意这里memmove的字节数用的是length - pos - 1,不是length - pos。差一个元素看着无所谓,但删除最后一个元素时length - pos等于 1,会从data[length]读一个未初始化的值搬进来,虽然马上length--让它失效了,但在开了内存检查工具的环境里这就是一次非法读取告警。这类"读一下但不用"的越界,是静态检查工具报得最多、人工最容易忽略的一类。
6. 顺序表的工程边界:什么时候该果断换掉它
顺序表在工程里的实际出场率远高于链表,原因前面说过:缓存友好、实现简单、没有额外指针开销、内存是连续的一块好管理。但它有明确的失效边界,踩过界就会很难受。判断方法很简单:看你的操作分布里,非末尾位置的插入删除占多大比例。
6.1 频繁头部插入的场景,顺序表是最差解
只要业务里有"新数据总是插到最前面"这种模式,顺序表每次插入都要搬全表。数据量上千之后,单次操作就是微秒级甚至毫秒级,放在高频路径上直接拖垮整体吞吐。
这种情况下有几个替代方案,按实现复杂度从低到高:用链表,头部插入 O(1),代价是失去随机访问和缓存优势;用双端队列式的环形缓冲,头尾都是 O(1),按位访问也是 O(1),但中间插入依然不行;倒着存,把"最新"放在数组末尾,逻辑上反转一下,问题直接消失。最后这个方案改动最小,很多场景下最实用,只是需要调用方接受"遍历顺序是反的"这个约定。
6.2 元素体积放大之后,搬移成本不是线性增长的
前面算平均移动次数时按"个数"算,但真实耗时按"字节数"算。元素是 4 字节整数时,搬 1000 个是 4KB;元素是 512 字节的结构体时,搬 1000 个就是 512KB。后者已经远超 L2 缓存容量,每次搬移都要走内存,速度差一个量级。
所以元素体积大的时候,正确做法是表里存指针,元素放在堆上单独管理。这样搬移的就是 8 字节的指针,扩容也便宜。代价是内存从连续变成离散,遍历时的缓存命中率下降,以及必须处理所有权和释放时机。这是一个明确的权衡,没有免费选项,看你更在意哪一头。
| 元素特征 | 推荐存法 | 主要理由 |
|---|---|---|
| 小于 64 字节且无内部指针 | 直接存值 | 少一次间接寻址,缓存友好 |
| 大于 128 字节 | 存指针 | 搬移成本与容量开销都可控 |
| 含内部动态资源 | 存指针 | 需要显式的构造与析构钩子 |
| 需要跨模块共享 | 存指针(或引用计数) | 避免拷贝语义引发的不一致 |
6.3 一份可以直接拿去用的骨架
把前面几节的东西拼起来,就是下面这份代码。它不长,但每一行都有对应的理由:类型用size_t消除负下标,扩容带溢出保护,插入用memmove处理重叠,删除的检查用>=。
#include <stdlib.h> #include <string.h> #include <stddef.h> typedef struct { int *data; size_t length; size_t capacity; } SeqList; static int seq_reserve(SeqList *L, size_t need) { if (need <= L->capacity) return 0; size_t cap = L->capacity ? L->capacity : 8; while (cap < need) { size_t next = cap + cap / 2; if (next <= cap) { cap = need; break; } cap = next; } if (cap > (size_t)-1 / sizeof(int)) return -1; int *p = realloc(L->data, cap * sizeof(int)); if (!p) return -1; L->data = p; L->capacity = cap; return 0; } int seq_init(SeqList *L) { L->data = NULL; L->length = 0; L->capacity = 0; return 0; } int seq_push(SeqList *L, int v) { return seq_insert(L, L->length, v); } int seq_insert(SeqList *L, size_t pos, int v) { if (pos > L->length) return -1; if (seq_reserve(L, L->length + 1) != 0) return -1; memmove(&L->data[pos + 1], &L->data[pos], (L->length - pos) * sizeof(int)); L->data[pos] = v; L->length++; return 0; } int seq_erase(SeqList *L, size_t pos, int *out) { if (pos >= L->length) return -1; if (out) *out = L->data[pos]; memmove(&L->data[pos], &L->data[pos + 1], (L->length - pos - 1) * sizeof(int)); L->length--; return 0; } void seq_destroy(SeqList *L) { free(L->data); L->data = NULL; L->length = 0; L->capacity = 0; }有两个接口是我特意加的:seq_push只是对seq_insert(L, L->length, v)的一层薄封装,但它把"末尾追加"这个高频操作的意图显式写了出来,调用处一眼能看懂,而且以后如果想给追加做特殊优化(比如批量预留),只需要改这一处。
还有一个常被忽略的点是seq_destroy里把三个字段全清了,而不是只free一下。清空的意义在于:销毁之后再被误用,程序会访问一个长度为 0 的表,insert和erase都会直接返回错误码,而不会去碰那块已经释放的内存;如果不清长度,误用时会以为表里还有元素,读的就是野指针。用一个确定的空状态去接住错误,比一个不确定的半死状态好得多。
一个补充建议:非末尾插入的调用点,最好在性能敏感的地方加一条日志或者注释,标明"此处插入位置靠前,列表长度预期不超过 X"。因为这类调用一旦碰上数据量增长,就是最典型的性能悬崖,而代码本身不会报错,只有监控曲线会突然抬头。留下这行注释,半年后接手的人能少走很多弯路。