上篇发布之后,后台留言里出现最多的不是"更新下篇",而是"我照着敲完,为什么插入第四个元素就乱码了"以及"容量不够的时候,我是不是只能重新定义数组"。说实话,这些反馈比我预想中更有价值——因为它说明很多人已经跳过了"看懂"阶段,进入了"亲手写崩"阶段。而"写崩"恰恰是学会顺序表的开始。
这篇下篇,我打算继续接着上篇的话题往下走:上篇我们把顺序表的基本骨架搭起来了,也就是结构体定义、初始化、插入、删除、查找这些"必备动作";这篇要把顺序表真正打磨成一个能应对真实场景的工具。包括动态扩容怎么做、几种高频操作如何写才能不出错、为什么数组越界在C语言里这么阴魂不散、以及顺序表和链表到底怎么选。全程依然是"胎教级",代码从头写到尾,一行一行解释。
1. 上篇的账先算清:基础版顺序表到底差在哪
1.1 上篇交付的"基础款"长什么样
先回顾一下上篇最终给出的核心代码。为了照顾没看过上篇、或者看到一半关掉的朋友,我把骨架再贴一遍:
c复制#include <stdio.h>
#include <stdlib.h>
#define MAX_SIZE 10 // 固定最大容量
typedef struct {
int data[MAX_SIZE];
int length;
} SeqList;
// 初始化
void InitList(SeqList *L) {
L->length = 0;
}
// 尾插
int Append(SeqList *L, int val) {
if (L->length >= MAX_SIZE) {
return 0; // 表满
}
L->data[L->length] = val;
L->length++;
return 1;
}
// 打印
void PrintList(SeqList *L) {
for (int i = 0; i < L->length; i++) {
printf("%d ", L->data[i]);
}
printf("\n");
}
这段代码的核心逻辑就是"数组存数据 + 一个变量记长度",插入和删除都是把元素往后挪或者往前挪。它作为入门没有任何问题,但如果你把它当成一个能用的"成品",很快就会碰壁。
1.2 基础款的三个硬伤
第一个硬伤:容量是写死的。MAX_SIZE = 10,一旦插入第11个元素,Append函数直接返回0,数据进不去。你说把它改成100?那前面99个空位就全浪费了。在真实场景里,数据量往往不是事先能预估的。
第二个硬伤:函数没有错误反馈。Append返回0表示失败,但调用方如果不检查这个返回值,程序还是会继续跑,数据却已经丢了。C语言不像别的语言会给你抛异常,返回值和errno就是你唯一的沟通手段,不养成检查返回值的习惯,后面写大点的程序迟早出事。
第三个硬伤:所有操作都假设data数组本身是安全可用的。一旦出现越界写、或者把结构体当成普通变量直接赋值拷贝,很容易写出"能跑但结果全错"的代码。这些问题在基础版里就被掩盖了。
所以这篇下篇我们要做的,不是推翻重来,而是把这套基础款逐步升级成工程款。核心思路有两条:容量要能自动增长,操作要经得起边界情况考验。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 动态扩容:顺序表从"定容"到"自适应"的关键一跃
2.1 为什么固定容量是"假省心"
顺序表底层是连续内存,想让容量变大,唯一的办法就是"重新找一块更大的连续内存,把旧数据搬过去,再释放旧内存"。这件事在C语言里有一个现成函数叫realloc,但很多人听到"重新分配内存"就害怕,其实拆开看只有两步:申请新空间、搬数据。
动态扩容的设计难点不是realloc怎么用,而是"什么时候扩"和"一次扩多少"。
2.2 扩容策略:固定增量、倍增、衰减因子
扩容策略常见的有三种,我直接用一个表格说明:
| 策略 | 做法 | 均摊插入成本 | 适用场景 |
|---|---|---|---|
| 固定增量 | 每次容量固定加5或10 | O(n),且频繁搬移 | 小规模,数据量波动小 |
| 倍增扩容 | 每次容量翻倍(2倍或1.5倍) | O(1) | 通用场景,绝大多数该用这个 |
| 惰性扩容 | 用到快满才扩1个,配合缩容 | O(n),频繁分配 | 特殊内存受限场景 |
倍增为什么是主流?因为翻倍之后,每个元素平均下来只被搬运常数次。你可以这么理解:如果容量从1涨到1024,中间经历了1、2、4、8、16、32、64、128、256、512、1024这些扩容点,累计搬运的元素总数是1+2+4+...+512=1023,刚好约等于最终容量。也就是说,插入n个元素,总搬移量大约是2n,平摊到每次插入就是常数时间。这是数据结构里"均摊分析"最经典的一个例子,考试和面试都爱考。
2.3 realloc 的正确打开方式:临时指针的救赎
很多初学C语言的同学第一次写扩容都会这么写:
c复制L->data = (int *)realloc(L->data, new_capacity * sizeof(int));
这行代码有问题,而且问题可能很隐蔽:realloc如果失败,会返回NULL,同时原来的内存块仍然有效。但你这里直接把返回值赋给L->data,一旦返回NULL,原来的data指针就丢了,既没法继续用旧数据,也没法free,内存泄漏加数据丢失一次性凑齐。
正确的写法是先用一个临时指针接住realloc的返回值,判断成功后再赋值:
c复制int *tmp = (int *)realloc(L->data, new_capacity * sizeof(int));
if (tmp == NULL) {
// 扩容失败,原数据还在,可以做错误处理
return 0;
}
L->data = tmp;
L->capacity = new_capacity;
这个"临时指针接住返回值"的习惯,不只是realloc,包括malloc、calloc都应该这样。它的本质是:不要因为操作失败把原有的有效状态破坏掉。
2.4 完整的扩容实现:把"容量"字段加进结构体
上篇的结构体里只有data和length,要想支持扩容,必须再加一个capacity字段:
c复制typedef struct {
int *data; // 指向动态数组
int length; // 当前元素个数
int capacity; // 当前容量
} SeqList;
注意,data从直接定义的数组变成了指针,因为我们要让它能指向不同大小的内存块。对应的初始化函数也要改:
c复制void InitList(SeqList *L, int init_capacity) {
L->data = (int *)malloc(init_capacity * sizeof(int));
if (L->data == NULL) {
printf("内存分配失败\n");
exit(1);
}
L->length = 0;
L->capacity = init_capacity;
}
然后写一个核心函数EnsureCapacity,在每次插入前调用:
c复制int EnsureCapacity(SeqList *L, int need) {
if (need <= L->capacity) {
return 1; // 容量够,不用扩
}
// 扩容到 max(当前容量的2倍, 所需容量)
int new_capacity = L->capacity * 2;
if (new_capacity < need) {
new_capacity = need;
}
int *tmp = (int *)realloc(L->data, new_capacity * sizeof(int));
if (tmp == NULL) {
return 0;
}
L->data = tmp;
L->capacity = new_capacity;
return 1;
}
这里有一个细节值得拿出来说:为什么new_capacity要在"翻倍"和"实际所需"里取较大值?因为如果一次插入大量数据(比如insert 100个元素),只翻倍可能反复扩容好多次。取max(2倍, need)可以保证"一次扩容满足本次需求",同时不丢失倍增的均摊优势。
有了EnsureCapacity之后,上篇的Append函数就可以改造成真正的"无上限"了:
c复制int Append(SeqList *L, int val) {
if (!EnsureCapacity(L, L->length + 1)) {
return 0;
}
L->data[L->length] = val;
L->length++;
return 1;
}
就改动这几行,固定容量那个硬伤就解决了。后面你插入第11、第100、第1000个元素都不会有问题,容量不够时它会自动找更大块的内存。
2.5 扩容之后记得释放:别把内存管理当可有可无
动态扩容引入了动态内存分配,那"释放"就是必须补上的操作。上篇的固定数组不用管,但动态版本必须写一个销毁函数:
c复制void DestroyList(SeqList *L) {
if (L->data != NULL) {
free(L->data);
L->data = NULL;
}
L->length = 0;
L->capacity = 0;
}
什么时候调用?用完顺序表之后,比如main函数末尾、或者不再需要这个表的时候。C语言不会帮你自动回收堆内存,一次malloc或者realloc不配一个free,程序跑得越久,内存泄漏积累得越严重。顺序表这种可长可短的容器,最容易漏。
3. 高频操作四件套:合并、反转、去重、有序插入
3.1 有序插入:先找位置再平移
很多图书管理系统、成绩管理系统的需求都是"保持数据有序地插入",顺序表上实现这个操作,逻辑非常直观:
- 从后往前找到第一个比
val小的位置; - 把该位置之后的所有元素整体后移一位;
- 把
val放进去。
为什么从后往前找?因为从后往前移动元素时,后面的位置先空出来,不会覆盖还没移动的旧数据。代码长这样:
c复制int SortedInsert(SeqList *L, int val) {
// 首先保证容量
if (!EnsureCapacity(L, L->length + 1)) {
return 0;
}
int i = L->length - 1;
// 从最后一个元素开始,比 val 大的都往后挪
while (i >= 0 && L->data[i] > val) {
L->data[i + 1] = L->data[i];
i--;
}
L->data[i + 1] = val;
L->length++;
return 1;
}
这里有个边界条件:如果插入的值比所有元素都小,循环会一直跑到i == -1才停,此时i + 1 == 0,正好是头部,逻辑没有问题。如果插入的值比所有元素都大,循环一次都不跑,直接放末尾,也没问题。
这个操作的复杂度和普通插入一样,最坏情况是O(n)——因为要移动元素。如果你需要频繁在有序序列里插入,顺序表其实不是最优解,这点后面对比链表时再说。
3.2 反转:双指针从两端往中间换
这个题目在各种算法题和期末实验里几乎必考,解法也很固定:
c复制void ReverseList(SeqList *L) {
int i = 0, j = L->length - 1;
while (i < j) {
int tmp = L->data[i];
L->data[i] = L->data[j];
L->data[j] = tmp;
i++;
j--;
}
}
时间复杂度O(n),空间复杂度O(1)。它和链表的反转思路完全不同——链表要改指针指向,顺序表直接交换元素。很多刚开始学数据结构的人会把两种反转搞混,其实只要记住一点:顺序表是"连续内存上的交换",链表是"节点指针的扭转"。
3.3 去重:快慢双指针原地搞定
去重有个前提:数据最好有序。如果无序,先去重就要用哈希表之类的额外结构;但如果是"有序表去重",那就有非常优雅的原地解法。考试实验里最常见的也是这种:
c复制int RemoveDuplicates(SeqList *L) {
if (L->length <= 1) {
return L->length;
}
int slow = 0; // 慢指针指向新数组的最后一个保留位置
for (int fast = 1; fast < L->length; fast++) {
if (L->data[fast] != L->data[slow]) {
slow++;
L->data[slow] = L->data[fast];
}
}
// 现在新长度是 slow + 1
L->length = slow + 1;
return L->length;
}
这个思路建议背下来,不只是顺序表,很多"原地去重"的题都用它。它的原理是:快指针负责遍历,慢指针负责维护"新表的尾巴"。遇到不同的元素,就把慢指针往前推进一格,并把新元素填过来。因为慢指针永远追不上快指针,所以新元素覆盖旧元素是安全的,不会丢数据。
我见过很多同学自己写去重会额外开一个临时数组,然后再拷贝回去,复杂度高且麻烦。快慢指针在这里的价值就是"原地完成",空间占用O(1)。
3.4 合并两个有序顺序表:归并排序的雏形
合并两个有序顺序表,输出一个合并后的有序顺序表,也是常考题目。思路就是"归并":两个表各一个指针,谁小取谁,取完往后走:
c复制SeqList MergeList(SeqList *A, SeqList *B) {
SeqList C;
InitList(&C, A->length + B->length);
int i = 0, j = 0;
while (i < A->length && j < B->length) {
if (A->data[i] <= B->data[j]) {
Append(&C, A->data[i]);
i++;
} else {
Append(&C, B->data[j]);
j++;
}
}
// 把剩余元素全部拷进去
while (i < A->length) {
Append(&C, A->data[i]);
i++;
}
while (j < B->length) {
Append(&C, B->data[j]);
j++;
}
return C;
}
时间复杂度和归并排序的merge部分一样,O(A.length + B.length)。这里你也可以感受到:顺序表这种随机访问能力让"合并两个有序序列"变得非常自然,而如果用链表实现,指针操作会更琐碎。
4. 内存错误与边界条件:顺序表调试的典型翻车现场
4.1 越界访问:为什么"当时没报错,交作业才崩"
C语言的数组越界不会像Java那样抛ArrayIndexOutOfBoundsException,它只是往你不知道的内存地址里写数据。更麻烦的是,很多时候越界写不会立即崩溃,而是悄悄破坏相邻内存里的数据,等你用到那块数据时才发现全乱了。
顺序表最常见的越界场景有三种:
- 插入时忘记判断容量,直接把
data[length] = val,而length已经大于等于capacity; - 删除时
i传入非法位置(比如负数或大于等于length),导致移动元素时把data[i+1]读到越界; - 循环遍历时
i <= length而不是i < length,多读了一个元素。
举个例子,假如你的容量是10,但插入了11个元素,前10个可能都好好的,第11个数据写到了data[10]。这个位置在内存里可能是另一个变量、可能是堆管理结构,程序可能没崩,但你的数据可能在其他看不见的地方开始变质。这种bug最难排查,因为表面现象和根因之间隔了一层。
所以我建议你在写顺序表操作时,每个函数开头都做"参数合法性检查":
c复制int ListInsert(SeqList *L, int pos, int val) {
if (pos < 0 || pos > L->length) {
return 0; // 位置非法
}
if (!EnsureCapacity(L, L->length + 1)) {
return 0;
}
for (int i = L->length; i > pos; i--) {
L->data[i] = L->data[i - 1];
}
L->data[pos] = val;
L->length++;
return 1;
}
这行if (pos < 0 || pos > L->length)就是"防守型编程"。C语言不会替你挡错误,你必须在入口处自己挡。
4.2 悬垂指针与重复释放:free 之后的那块内存碰不得
动态扩容以后,free就进入你的日常了。两个最常见的灾难:
free(L->data)之后没有把L->data置为NULL,之后再调用其他函数时可能误以为data有效,继续读写已经释放的内存;- 销毁函数被调用两次,第二次
free同一块内存,直接抛double free错误。
第一个问题用我们上面的DestroyList写法就能避免——free之后立刻置NULL。第二个问题可以通过在销毁时判断L->data != NULL来规避,但更可靠的是设计层面保证"销毁之后不再对这个表做任何操作"。
4.3 边界条件清单:空表、单元素、尾部操作
我自己的习惯是,每写完一个顺序表的函数,就用下面这组边界条件测一遍:
| 边界条件 | 测试动作 | 期望结果 |
|---|---|---|
| 空表 | 调用删除、查找、反转 | 不崩溃,返回明确的错误码或不改变状态 |
| 单元素表 | 删除唯一元素、反转 | 删除后变为空表,反转后元素不变 |
| 满表插入 | 在容量刚好满时插入 | 触发扩容,而不是拒绝或越界 |
| 尾插尾删 | 在length-1位置插入/删除 |
插入能成功,删除后长度减一 |
| 越界位置 | 传入pos = -1或pos = length+1 |
返回0,程序不崩溃 |
把这些场景在纸上画一遍,再对照代码跑一遍,顺序表这块的基础就算打牢了。数据结构考试里的"程序填空"和"找出错误"题,考的基本上就是这些边界条件。
4.4 用打印和断言辅助调试:别一上来就上调试器
很多同学一遇到程序崩了就开gdb,但顺序表这种简单结构,其实用几个打印函数就能快速定位。我在开发顺序表相关代码时最常干的事是:
c复制void PrintListDebug(SeqList *L) {
printf("[length=%d, capacity=%d] ", L->length, L->capacity);
for (int i = 0; i < L->length; i++) {
printf("%d ", L->data[i]);
}
printf("\n");
}
注意我在前面打印了length和capacity,很多问题一看这两个数就明白了——比如你发现length变成了负数,那八成是删除的时候把length--写成了length++;如果capacity突然变成了一个巨大的数,可能就是越界写把capacity字段覆盖了。
另外可以善用assert宏。比如在函数入口处写上:
c复制#include <assert.h>
void ListInsert(SeqList *L, int pos, int val) {
assert(L != NULL);
assert(pos >= 0 && pos <= L->length);
...
}
断言的作用是在Debug版本里帮你拦截不可能发生的错误。如果条件不成立,程序立刻终止并告诉你文件名和行号,比你自己盯着输出猜快得多。
5. 顺序表和链表的选型博弈:考试、面试、实战如何不踩坑
5.1 存储密度与随机访问
很多初学者纠结的"到底学顺序表还是链表",其实在真实工程里根本不是二选一,而是看在什么场景下哪个更合适。你看Redis里既有简单动态字符串,也有双向链表、快速列表,不同数据结构各有使用场景。
存储密度方面,顺序表是纯数据,没有额外的指针开销;链表每个节点至少多一个指针(单链表)或两个指针(双向链表),内存利用率更低。随机访问方面,顺序表按下标访问是O(1),链表要一个个跳,是O(n)。这是考试里最常考的对比点,两个区别必须背熟。
5.2 插入删除的真实成本:别只看"O(1)"
链表的插入删除,如果已经给你了目标节点的指针,确实是O(1);但问题是,你要先找到那个节点,查找通常是O(n)。顺序表的插入删除是O(n),但它移动的是连续内存,而且现代CPU对连续内存有缓存友好性加成。
所以"链表插入删除比顺序表快"这个说法是有条件的——它说的是"已知位置的情况下"。如果你是在尾部插入,顺序表甚至比链表快,因为尾插是O(1),链表还要遍历到尾部才能找到最后一个节点的指针(除非你有尾指针)。
5.3 缓存局部性:顺序表在实战中常胜的原因
这个点几乎所有的教科书都不讲,但它对你的程序性能影响很大。CPU从内存读数据不是一次读一个字节,而是把一整块叫"缓存行"的数据加载到高速缓存。顺序表的元素在内存里是连续的,所以遍历一个顺序表时,CPU加载一次缓存行,后面好几个元素都已经在缓存里了。
链表则不然,每个节点在内存里的位置是散落的,遍历一个链表会频繁触发缓存未命中,每次都要重新从主存加载数据。数据量一大,链表遍历的耗时可能是顺序表的数倍。
我见过一个测试,100万个整数,顺序表遍历只需要几毫秒,而链表遍历要几十毫秒甚至上百毫秒。这差距不是语言的差距,就是内存访问模式造成的。
5.4 一张选型决策表
| 需求场景 | 推荐 | 原因 |
|---|---|---|
| 频繁按下标访问、随机读 | 顺序表 | O(1)随机访问 |
| 数据规模基本固定、增长少 | 顺序表 | 内存连续、缓存友好、无额外指针 |
| 频繁在头部/中间插入删除 | 链表 | 顺序表移动元素成本高 |
| 无法预估数据量、频繁增删 | 链表或动态顺序表 | 链表天然动态;顺序表需扩容 |
| 内存极度受限、数据量小 | 顺序表 | 没有指针开销 |
| 大文件流式处理、节点分散 | 链表 | 避免大块连续内存分配 |
注意,这个表里"无法预估数据量"我写了两个选择。实际上动态顺序表在扩容方面已经可以应对大多数场景,链表的优势主要在于"插入删除频率极高且位置很分散"的情况。考试和面试中,如果题目没有特别说明,用顺序表当默认选择通常更稳。
6. 可直接抄作业的完整代码:从结构体到测试用例
最后把上面所有内容整合成一个完整的、可直接运行的C程序。代码风格采用工程上推荐的写法,并且包含充分的错误处理和边界测试。你可以把这套代码当作自己实现顺序表的模板,也可以直接用来做数据结构实验报告的基础。
6.1 完整代码
c复制#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
typedef struct {
int *data;
int length;
int capacity;
} SeqList;
// 初始化
void InitList(SeqList *L, int init_capacity) {
L->data = (int *)malloc(init_capacity * sizeof(int));
if (L->data == NULL) {
printf("内存分配失败\n");
exit(1);
}
L->length = 0;
L->capacity = init_capacity;
}
// 销毁
void DestroyList(SeqList *L) {
if (L->data != NULL) {
free(L->data);
L->data = NULL;
}
L->length = 0;
L->capacity = 0;
}
// 确保容量足够,不足则扩容
int EnsureCapacity(SeqList *L, int need) {
if (need <= L->capacity) {
return 1;
}
int new_capacity = L->capacity * 2;
if (new_capacity < need) {
new_capacity = need;
}
int *tmp = (int *)realloc(L->data, new_capacity * sizeof(int));
if (tmp == NULL) {
return 0;
}
L->data = tmp;
L->capacity = new_capacity;
return 1;
}
// 尾插
int Append(SeqList *L, int val) {
if (!EnsureCapacity(L, L->length + 1)) {
return 0;
}
L->data[L->length] = val;
L->length++;
return 1;
}
// 指定位置插入
int ListInsert(SeqList *L, int pos, int val) {
assert(L != NULL);
if (pos < 0 || pos > L->length) {
return 0;
}
if (!EnsureCapacity(L, L->length + 1)) {
return 0;
}
for (int i = L->length; i > pos; i--) {
L->data[i] = L->data[i - 1];
}
L->data[pos] = val;
L->length++;
return 1;
}
// 指定位置删除
int ListDelete(SeqList *L, int pos) {
if (pos < 0 || pos >= L->length) {
return 0;
}
for (int i = pos; i < L->length - 1; i++) {
L->data[i] = L->data[i + 1];
}
L->length--;
return 1;
}
// 有序插入(保持升序)
int SortedInsert(SeqList *L, int val) {
if (!EnsureCapacity(L, L->length + 1)) {
return 0;
}
int i = L->length - 1;
while (i >= 0 && L->data[i] > val) {
L->data[i + 1] = L->data[i];
i--;
}
L->data[i + 1] = val;
L->length++;
return 1;
}
// 反转
void ReverseList(SeqList *L) {
int i = 0, j = L->length - 1;
while (i < j) {
int tmp = L->data[i];
L->data[i] = L->data[j];
L->data[j] = tmp;
i++;
j--;
}
}
// 有序表去重
int RemoveDuplicates(SeqList *L) {
if (L->length <= 1) {
return L->length;
}
int slow = 0;
for (int fast = 1; fast < L->length; fast++) {
if (L->data[fast] != L->data[slow]) {
slow++;
L->data[slow] = L->data[fast];
}
}
L->length = slow + 1;
return L->length;
}
// 合并两个有序表(调用方记得销毁返回的表)
SeqList MergeList(SeqList *A, SeqList *B) {
SeqList C;
InitList(&C, A->length + B->length);
int i = 0, j = 0;
while (i < A->length && j < B->length) {
if (A->data[i] <= B->data[j]) {
Append(&C, A->data[i]);
i++;
} else {
Append(&C, B->data[j]);
j++;
}
}
while (i < A->length) {
Append(&C, A->data[i]);
i++;
}
while (j < B->length) {
Append(&C, B->data[j]);
j++;
}
return C;
}
// 打印
void PrintList(SeqList *L) {
printf("[length=%d, capacity=%d] ", L->length, L->capacity);
for (int i = 0; i < L->length; i++) {
printf("%d ", L->data[i]);
}
printf("\n");
}
6.2 main 函数:把每一步都测一遍
c复制int main() {
SeqList L;
InitList(&L, 2); // 故意给很小的初始容量,测试扩容
// 测试尾插和扩容
for (int i = 1; i <= 10; i++) {
Append(&L, i);
}
printf("尾插1~10: ");
PrintList(&L);
// 测试指定位置插入
ListInsert(&L, 3, 99);
printf("在位置3插入99: ");
PrintList(&L);
// 测试删除
ListDelete(&L, 3);
printf("删除位置3: ");
PrintList(&L);
// 测试反转
ReverseList(&L);
printf("反转: ");
PrintList(&L);
// 测试有序插入
SeqList S;
InitList(&S, 5);
SortedInsert(&S, 5);
SortedInsert(&S, 3);
SortedInsert(&S, 8);
SortedInsert(&S, 1);
printf("有序插入 5,3,8,1: ");
PrintList(&S);
// 测试去重(先构造有重复的有序表)
SeqList D;
InitList(&D, 5);
int arr[] = {1, 1, 2, 3, 3, 3, 5};
for (int i = 0; i < 7; i++) {
Append(&D, arr[i]);
}
printf("去重前: ");
PrintList(&D);
RemoveDuplicates(&D);
printf("去重后: ");
PrintList(&D);
// 测试合并
SeqList A, B;
InitList(&A, 3);
InitList(&B, 3);
Append(&A, 1);
Append(&A, 3);
Append(&A, 5);
Append(&B, 2);
Append(&B, 4);
Append(&B, 6);
SeqList C = MergeList(&A, &B);
printf("合并A和B: ");
PrintList(&C);
// 释放所有动态内存
DestroyList(&L);
DestroyList(&S);
DestroyList(&D);
DestroyList(&A);
DestroyList(&B);
DestroyList(&C);
return 0;
}
这段代码在Windows的Visual Studio、macOS的clang、Linux的gcc下都能直接编译运行。如果用的是gcc,命令行直接:
bash复制gcc seqlist.c -o seqlist && ./seqlist
6.3 亲手敲一遍的建议
我强烈不建议直接复制粘贴跑通就完事,那不叫学会了。你可以先把上面这20多个函数自己实现一遍,然后对比我的版本,重点看三个地方:
第一,EnsureCapacity里的临时指针判断。你写的版本是不是直接realloc然后赋值?如果是,去体会一下为什么那样写有隐患。第二,SortedInsert里为什么要从后往前遍历?如果从前往后,会发生什么?试试看。第三,RemoveDuplicates里,如果你把slow变量换成length - 1再实现一遍,哪里会出问题?
做数据结构的学习,代码跑通只是最底层的目标。真正的检验是:把这个表从"初始容量2"一路插入到"1000个元素",再随机删除、插入、查找,程序能不能一直保持稳定、不崩溃、不越界。能做到这一点,顺序表这一关才是真的过了。
我在带新手和数据结构的项目里,见过太多人背下了"逻辑结构=集合+线性+树+图"这些概念,但一写代码就露馅。顺序表最难的从来不是概念,而是把概念落成C语言代码时,那种对内存、对边界条件的敏感度。这份敏感度没有捷径,只能靠一行一行写、一次一次崩、一遍一遍改,慢慢磨出来。希望这篇下篇,能让你少摔几个跟头。
