顺序表详解:从数组到动态扩容的实现与调试实战
2026/9/7 20:35:50 网站建设 项目流程

1. 顺序表到底是什么:从数组到数据结构的跨越

很多人第一眼看到“顺序表”这三个字,第一反应是:这不就是数组吗?我大一就会了,有什么可学的?

说实话,这个反应很真实。顺序表和数组在底层确实是同一件事——一段连续的内存空间,里面按顺序存着一组同类型的数据。但如果你真的只用“数组”来理解它,到了写增删改查的时候就会懵:为什么明明只是“在中间插一个数”,代码写起来这么麻烦?为什么我明明声明了一个很大的数组,程序还是动不动就崩?

这就是顺序表教材存在的意义:把“数组”这个语言层面的东西,抽象成“数据结构”层面的东西。数据结构关心的不是“怎么声明一个数组”,而是“数据怎么组织、怎么访问、怎么维护”。数组本身是静态的、刚性的,而顺序表是在数组之上建立起一套完整的操作规则,让它成为一个能自我管理的结构。

拿我自己当年学《数据结构(C语言版)》的体会来说,严蔚敏那本教材里顺序表那一章,代码看着不算特别长,但里面的每个函数都有一种“套路感”:初始化要分配内存、插入要移动元素、删除要移动元素、查找要遍历、销毁要释放内存。学完这一章,你才算真正开始理解什么叫“对数据的操作不是凭空发生的,而是围绕着某个组织方式展开的”。

还有一个很重要的点:顺序表是后面所有线性结构的地基。链表、栈、队列,甚至后面的字符串匹配、图论里的邻接表,很多思路都是在顺序表基础上演化出来的。如果顺序表的插入删除逻辑你吃透了,后面学链表的时候,你会发现它们的逻辑框架几乎一样,只是物理存储方式变了而已。

在动手写代码之前,先想清楚一件事:顺序表存在的价值是什么?答案是它的存储结构带来的两大优势——随机访问缓存友好

随机访问指的是:只要知道下标,就能在 O(1) 时间内取到元素。因为底层是连续内存,内存地址可以直接算出来:address = base + index * sizeof(T),这个计算只是乘法和加法,CPU 几纳秒就能完成。这也是为什么排序算法、二分查找、堆这种结构都愿意用数组来实现的原因。

缓存友好指的是:连续内存访问时,CPU 缓存命中率极高。现代 CPU 在读取一个内存地址时,会把相邻的一块数据一起加载进 L1/L2 缓存,如果接下来访问的是相邻的数据,直接命中缓存,速度非常快。相比之下,链表这种每个节点都“撒”在不同位置的结构,每次访问都可能触发一次缓存失效,数据规模大了以后性能差距非常明显。

所以顺序表天然适合“查得多、改得少”的场景。反过来,如果你需要在中间频繁插入删除,顺序表会因为挪数据而付出代价,这时候才需要考虑链表。

2. 完整实现一个顺序表:从结构体定义到动态扩容

光讲概念不行,得拿代码说话。我直接给出一个我认为最适合初学阶段的实现版本,然后在后面逐个说明关键设计。

这里我选的是动态顺序表,也就是底层用malloc按需分配的版本。静态版本(SeqList arr[MAX_SIZE])虽然代码更短,但实际工程中没人用固定上限的写法,而且考研和面试题目里问动态扩容的概率极高。

先看结构体定义:

#include <stdio.h> #include <stdlib.h> #define INIT_CAPACITY 4 typedef struct { int* data; // 指向底层数组的指针 int size; // 当前元素个数(线性表的长度) int capacity; // 当前容量(最多能存多少元素) } SeqList;

这里有几个细节需要解释。

第一,data为什么是int*而不是int arr[]?因为我们需要在运行期动态分配内存,malloc返回的就是指针。你用int arr[100]这种写法,容量就固定死了,没法扩容。

第二,sizecapacity为什么要分开?这是初学最容易混淆的一组概念。size是“当前实际存了多少个元素”,capacity是“当前能存多少个元素”。判断表是否已满,看的是size == capacity;判断表是否为空,看的是size == 0。这两个概念不分开,后面扩容逻辑根本写不清楚。

第三,INIT_CAPACITY为什么设成 4?这是灵活性考虑。设大了浪费内存,设小了频繁扩容影响性能。实际工程中根据业务规模定,但作为教学版本,4 足够让你观察到“扩容”这件事的发生。

2.1 初始化与销毁:内存管理的两个端点

void seqlist_init(SeqList* list) { list->data = (int*)malloc(INIT_CAPACITY * sizeof(int)); if (list->data == NULL) { printf("内存分配失败\n"); exit(1); } list->size = 0; list->capacity = INIT_CAPACITY; } void seqlist_destroy(SeqList* list) { free(list->data); list->data = NULL; list->size = 0; list->capacity = 0; }

初始化做的事:分配一块初始内存,把 size 置 0,capacity 置 4。这里有一个很多教材都不太强调的点:分配完内存一定要判断返回值是否为 NULL。虽然在小程序里malloc基本不会失败,但这是一个职业习惯问题。真实项目里内存不足是常见故障,不检查返回值的结果就是程序在后续使用空指针时出现段错误,而且错误位置和错误原因离得很远,极难排查。

销毁函数里的free(list->data)之后,为什么要手动把指针置 NULL?因为free只是释放内存,并不会把指针本身清零。如果后面代码误用了这个指针,就会访问一块已经释放的内存,造成“悬空指针”问题。这个 bug 极其隐蔽,不像段错误那么明显,但会产生随机性的诡异行为,可能今天正常、明天崩溃、换个编译器又正常。在free之后置 NULL,是把一个潜在的定时炸弹拆掉。

2.2 扩容机制:为什么 2 倍扩容是“最佳实践”

插入元素前需要先检查容量是否已满,满了就扩容。这段代码是顺序表里最需要仔细理解的部分:

void seqlist_expand(SeqList* list) { int new_capacity = list->capacity * 2; int* new_data = (int*)realloc(list->data, new_capacity * sizeof(int)); if (new_data == NULL) { printf("扩容失败\n"); exit(1); } list->data = new_data; list->capacity = new_capacity; }

使用realloc而不是malloc+memcpy+free三连操作,是因为realloc是标准库专门为“调整已有内存块大小”设计的。如果当前内存块后面还有足够的连续空间,realloc会直接在原地扩大,这种情况下效率最高,不需要搬动数据。

但你要知道一个关键细节:realloc原地扩容失败(或者后面空间不够)时,它会重新找一块更大的内存,把旧数据复制过去,然后释放旧内存——这个动作由realloc内部完成。所以这里绝对不能写成:

list->data = (int*)realloc(list->data, new_capacity * sizeof(int));

这行代码有个隐患:如果realloc失败返回 NULL,它会直接覆盖掉list->data原来的值,导致旧内存指针丢失,后续无法释放,内存泄漏。正确做法是像上面那样,先用临时变量接收返回值,验证非空后再赋值回list->data

为什么扩容倍数是 2,而不是 1.5、3、10?这是一个关于均摊复杂度的问题。每次扩容,你需要把旧数据全部复制到新内存,成本是 O(n)。如果每次只多扩一个位置,那么插入 n 个元素的总时间复杂度是 1 + 2 + 3 + ... + n = O(n²),这不可接受。如果每次扩容翻倍,那么扩容操作发生的次数大约是 log(n) 次,每次复制成本从 1 到 n 不等,总成本大约是 2n,均摊到每次插入上就是 O(1)。这是典型的均摊分析思想,和 vector 的底层实现原理完全一致。选 2 倍而不是 10 倍,是为了在“扩容次数少”和“空间利用率高”之间取平衡——10 倍扩容会导致大量内存闲置。

提示:这里有个反直觉的点——扩容时realloc返回的地址可能与旧地址相同,也可能不同。你的代码不能依赖扩容后原指针仍然有效,一定要用返回值更新。

3. 增删改查:每个操作背后都有一个边界条件陷阱

顺序表的增删改查,表面上就是数组操作,但真正写起来,每个操作都有一堆“差一”问题(off-by-one)。我按照实际编码顺序,一个一个讲。

3.1 尾插:最简单也最容易忘检查

void seqlist_push_back(SeqList* list, int value) { if (list->size == list->capacity) { seqlist_expand(list); } list->data[list->size] = value; list->size++; }

尾插的逻辑核心是:size同时扮演了两个角色——它既是当前元素个数,也是下一个空闲位置的数组下标。比如当前 size = 3,说明有 3 个元素,下标 0、1、2 已占用,新元素应该放到下标 3 的位置,正好就是list->data[list->size]。写完后 size 自增,这一行代码是很多初学最容易忽略的——忘记自增的话,插入多少次 size 都是 0,表永远像空的一样。

这个函数还有一个隐藏约束:list指针不能为 NULL。所有函数都默认调用者传入的是已经初始化过的合法指针,这个约定要写清楚。如果代码后面出现野指针,大概率不是这里的问题,而是调用方初始化遗漏。

3.2 指定位置插入:倒序移动的来龙去脉

这是顺序表里最核心、也最容易写错的一个操作。

void seqlist_insert(SeqList* list, int index, int value) { if (index < 0 || index > list->size) { printf("插入位置非法\n"); return; } if (list->size == list->capacity) { seqlist_expand(list); } for (int i = list->size; i > index; i--) { list->data[i] = list->data[i - 1]; } list->data[index] = value; list->size++; }

先讲边界检查:index的合法范围是[0, size]。注意,index == size是合法的,这本质上就是尾插;index == 0是头插,合法但需要移动全部元素。非法的情况是负数或超过 size,此时应该直接返回,不做任何操作。

这里容易出问题的是“返回后怎么通知调用方”。这段代码用的是printf打印错误信息,这是教学向的写法。但从软件工程角度看,函数的设计原则应该是:能返回错误就返回错误,让调用方决定怎么处理,而不是在函数内部打印。更合理的签名是返回bool或错误码。我在项目里一般这样写:

bool seqlist_insert(SeqList* list, int index, int value);

返回false表示插入失败,调用方根据返回值决定是打印日志、抛异常还是忽略。

再说循环移动的逻辑。为什么是从后往前移动,而不是从前往后?我们来推演一下:要在 index = 2 的位置插入一个新元素,假设原来数组是[10, 20, 30, 40],size = 4。

如果从前往后移动:先移动 data[2] 到 data[3],此时数组变成[10, 20, 30, 30];接着移动 data[3] 到 data[4],但 data[3] 已经被覆盖成 30 了,原来 data[3] 的 40 已经丢失。结果就是[10, 20, 30, 30, 40?],完全错误。

如果从后往前移动:先把 data[3] 的 40 移到 data[4],数组变成[10, 20, 30, 40, 40];再把 data[2] 的 30 移到 data[3],数组变成[10, 20, 30, 30, 40];然后把 25 写入 data[2],得到[10, 20, 25, 30, 40]。正确。

核心原因:从前往后移动时,目标位置的数据会被源位置的后续数据覆盖,造成丢失;从后往前移动则可以保证每个数据在移动前,它的目标位置还是空闲的。这个“倒序移动”的思路,后面学链表节点的插入时也会用到。

3.3 按位置删除:正序覆盖的正向思维

bool seqlist_remove(SeqList* list, int index) { if (index < 0 || index >= list->size) { return false; } for (int i = index; i < list->size - 1; i++) { list->data[i] = list->data[i + 1]; } list->size--; return true; }

删除的逻辑是插入的反向操作,但这里有一个重要的区别:删除时是从前往后覆盖,因为我们要把后面的元素往前搬。

比如删除 index = 1 的元素,数组是[10, 20, 30, 40]

  • 先把 data[2] 的 30 覆盖到 data[1]:[10, 30, 30, 40]
  • 再把 data[3] 的 40 覆盖到 data[2]:[10, 30, 40, 40]
  • size-- 变成 3,逻辑上的数组是[10, 30, 40]

这里不需要真的清空最后一个位置的旧值(那个 40 脏数据),因为 size 已经告诉外部“只有前 3 个数据是有效的”。当然,如果你想更严谨,可以在 size-- 之后把 data[size] 设为 0,避免残留数据在调试时造成误解。

边界条件是index >= list->size而不是> size,因为删除操作中 index 最大只能是 size - 1。如果你的表是空的,size = 0,那么任何 index >= 0 都不合法,第一个判断就拦住了。

3.4 按值查找与按位置取值:一个返回下标,一个返回元素

int seqlist_find(SeqList* list, int value) { for (int i = 0; i < list->size; i++) { if (list->data[i] == value) { return i; } } return -1; } int seqlist_get(SeqList* list, int index) { if (index < 0 || index >= list->size) { printf("下标越界\n"); return -1; } return list->data[index]; }

查找的逻辑很简单,就是线性扫描。顺序表本身无序,所以只能从头到尾遍历,时间复杂度 O(n)。这里涉及一个设计选择:查找返回的是下标而不是元素本身。为什么这么设计?因为拿到下标之后,你可以做的事情更多:你可以根据下标做修改,可以删除,可以接着做其他操作。如果直接返回值,你还需要再查一轮才能拿到下标,浪费一次遍历。

再说一个很多人犯过的错:seqlist_get里如果下标越界,返回 -1 是否合理?如果表里恰好存了一个 -1,那么这个函数就会产生歧义——到底是取值成功了,还是越界失败了?更严谨的 API 设计是用指针参数带出结果:

bool seqlist_get(SeqList* list, int index, int* result) { if (index < 0 || index >= list->size) { return false; } *result = list->data[index]; return true; }

调用方式变成:

int val; if (seqlist_get(&list, 3, &val)) { printf("取到的值是 %d\n", val); } else { printf("取值失败\n"); }

这也是 C 语言里很常见的一种模式:函数返回值表示成功/失败,结果通过出参带出。养成这种习惯之后,你会发现代码的可读性和健壮性明显提升。

3.5 打印与遍历:为什么我建议你写一个 dump 函数

void seqlist_print(SeqList* list) { printf("size=%d, capacity=%d, [", list->size, list->capacity); for (int i = 0; i < list->size; i++) { printf("%d", list->data[i]); if (i != list->size - 1) { printf(", "); } } printf("]\n"); }

这个函数看起来没什么技术含量,但实际调试时极其有用。我见过太多初学者在调试顺序表时,直接在 main 里手动循环打印,每次遇到 bug 都要临时写循环。更好的做法是一开始就实现seqlist_print,任何操作之后打印一眼,立刻能看出 size 是否异常、元素顺序是否正确、capacity 是否意外变化。

这里我特意把 size 和 capacity 也打印出来,因为很多“操作结果看起来正确,但程序却有问题”的场景,根源就是 size 和 capacity 维护错了。比如插入后忘记 size++,打印出来你会看到 size 没有变化,一下子就能定位到问题。

4. 顺序表 vs 链表:别背结论,看场景

学完顺序表之后,很多人立刻会学链表。于是出现了一个经典问题:顺序表和链表到底哪个好?

这个问题去问不同的人,会得到不同版本的回答。有人说链表好,插入删除快;有人说顺序表好,访问快。如果你只是背诵“顺序表适合读多写少,链表适合写多读少”,那是知其然不知其所以然。我建议你从下面四个维度去理解。

内存布局:顺序表是一整块连续内存,链表是分散的内存节点,节点之间靠指针相连。这个差异决定了前者支持随机访问(O(1)),后者只能从头遍历(O(n))。

插入删除:两者在“已知位置”的前提下,顺序表需要移动大量元素,链表只需要修改几个指针。但这里有个隐含前提——链表修改指针的前提是你已经找到了那个节点。如果插入操作本身要先按值查找,链表查找就 O(n) 了,加上插入的 O(1),整体还是 O(n)。顺序表查找 O(n),插入的移动也是 O(n),同样是 O(n)。所以在中间位置插入的场景下,如果你有位置的指针/下标,链表优势明显;如果只有值,两者差距没那么大。

缓存命中:这个维度是很多人忽略的。顺序表因为有很好的空间局部性,CPU 缓存命中率远高于链表。在数据量很大且频繁遍历时,顺序表的实际速度往往比链表快很多,即使理论上两者的遍历复杂度都是 O(n)。

空间开销:顺序表除了存储数据本身几乎没有额外开销;链表每个节点需要额外存一个指针,64 位系统下是 8 字节。假设存 int,链表的内存开销至少是顺序表的 3 倍(4 字节数据 + 8 字节指针)。

作为一个工程结论:凡是能预估数据规模、读操作偏多的场景,优先用顺序表;双向队列这种两端删除频繁、中间操作多的场景,链表更合适。绝大多数情况下,默认选择顺序表(或者说动态数组)都不会错。C++ 的std::vector、Java 的ArrayList、Python 的list,底层全是动态顺序表思想,这是所有语言工程师用脚投票的结果。

5. 内存bug排查与调试实战:那些教科书不讲的“坑”

初学顺序表的时候,最容易把大量时间花在调试诡异 bug 上。这里我把常见问题拿出来逐个拆解,每个都是我亲自踩过或者大量读者反复问过的。

5.1 段错误:越界的真实后果是什么

C 语言的数组越界不会在编译时报错,只会在运行时产生不可预知的行为。很多人以为“越界访问就会立刻崩溃”,但真实情况是:你访问data[size]这种“差一个”的位置,往往访问到的是一块还没被使用的堆内存,值可能是任意垃圾数据,程序不会崩溃,但输出完全不对。更糟的是,如果你越界写入,会破坏堆管理器的元数据,可能暂时平安无事,等下一次free或者malloc时才爆发崩溃。

排查这类问题有一个可靠的工具:AddressSanitizer(ASan)。在 GCC/Clang 编译时加-fsanitize=address,再运行程序,就能精确定位是哪一行发生了越界访问:

gcc -g -fsanitize=address seqlist.c -o seqlist

运行后如果发生越界,程序会打印出错的位置、访问的地址、以及附近合法的内存范围,定位效率比看日志猜高几个数量级。我见过很多初学者在全无头绪时靠打印语句盲猜,那真的是浪费时间。工具是解决问题的捷径。

5.2 内存泄漏:动态扩容后忘了释放

动态顺序表需要在seqlist_destroy里释放底层数组,如果漏了这步,程序每次用到这个顺序表就会泄漏一块内存。短期跑一次看不出问题,但如果是服务器程序,每处理一个请求就泄漏一次,几天后内存耗尽,进程直接 OOM 被杀。排查内存泄漏用 Valgrind:

valgrind --leak-check=full ./seqlist

输出里会显示分配和释放的调用栈,一眼就能看出哪块内存没被释放。

5.3 常见的逻辑错误:size 和 capacity 混用

我把这个单独拎出来说,因为它出现频率太高了。在遍历、插入、删除的循环条件里,新手经常写错:

// 错误写法:遍历用 capacity 而不是 size for (int i = 0; i < list->capacity; i++) { printf("%d ", list->data[i]); }

当 size = 4、capacity = 8 时,这个循环会打印出后半段未初始化的垃圾数据。遍历、查找、打印、修改,循环条件永远用 size,只有扩容和判断满没满时才用 capacity。

5.4 重复 free:析构两次引发的崩溃

如果你在程序中调用两次seqlist_destroy,第二次free(list->data)时,list->data已经是悬空指针(或者被我们在 destroy 里置成了 NULL)。这里体现出手动把指针置 NULL 的价值:free(NULL)是安全的空操作,不会崩溃。如果没有置空,free一块已经释放的内存属于未定义行为,可能会崩溃,也可能鬼畜地正常运行,完全看运气。

6. 顺序表的扩展应用与进阶练习:从课内走向实战

知道基础操作之后,要真正掌握顺序表,还需要通过一些典型算法题来加深理解。顺序表本质上是数组,因此大量基于数组的算法题都可以视为顺序表的扩展应用。

6.1 基本变形:逆置、去重、删除指定值

这些是笔试和数据结构课程设计的高频题。

顺序表逆置:双指针从两端向中间交换,时间复杂度 O(n),空间复杂度 O(1)。

void seqlist_reverse(SeqList* list) { int i = 0, j = list->size - 1; while (i < j) { int temp = list->data[i]; list->data[i] = list->data[j]; list->data[j] = temp; i++; j--; } }

删除顺序表中所有等于某个值的元素。最直观的思路是每删除一个就把后面元素整体前移,这样的时间复杂度是 O(n²)。更好的做法是双指针:一个指针遍历原数组,一个指针指向“下一个可写入位置”。

void seqlist_remove_all(SeqList* list, int value) { int write = 0; for (int read = 0; read < list->size; read++) { if (list->data[read] != value) { list->data[write] = list->data[read]; write++; } } list->size = write; }

这个题的精髓在于“原地操作”:用一个循环解决,不需要额外的数组或反复调用删除函数。这种思路在后面做题时非常常见,属于 O(n) 单循环技巧。

有序顺序表去重:同样的双指针思路,但判断条件变成“当前元素是否与上一个保留元素相同”。

void seqlist_unique(SeqList* list) { if (list->size <= 1) return; int write = 1; for (int read = 1; read < list->size; read++) { if (list->data[read] != list->data[write - 1]) { list->data[write] = list->data[read]; write++; } } list->size = write; }

这种“保留式覆盖”的技巧,本质上是把“删除”从“搬动剩余所有元素”优化成了“只搬需要保留的元素”,也是很多算法题里常见的“原地修改”思想。

6.2 顺序表在高频算法题里扮演的角色

洛谷上很多入门级题目,虽然题目描述是“统计数字”“数据分布”之类的场景,但核心操作就是开一个大数组做索引统计。比如“梦中的统计”这类题目,先开一个足够大的数组,每读入一个数字就让对应下标的计数加一,最后按顺序输出。为什么这样做?因为数组天然支持 O(1) 的随机访问,非常契合“以值为索引”的统计需求。

同样地,很多排序算法的题解里,如果让手写排序,大部分人会选择对数组做冒泡、插入、选择或快排。这些操作表面上是在处理数组,本质上就是在操作一个静态版本的顺序表。理解了顺序表的数据移动规律(插入时倒序移动、删除时正序覆盖),你再看排序代码里的交换和搬移,会觉得异常亲切。

6.3 进阶思考:顺序表还能扩展出哪些东西

顺序表的基本形态完成后,建议自己去拓展实现这几个版本:

  • 泛型顺序表:把存储类型从int改成用void*存储任意类型指针,让同一个结构体既能存 int 也能存结构体。C 语言没有模板,void*是模拟泛型的唯一办法。这个练习对理解 C 语言的类型系统帮助极大。
  • 有序顺序表:在插入时自动找到合适位置,让表始终保持有序。这样查找操作就可以用二分查找 O(log n),而不是线性扫描 O(n)。这个版本是考研数据结构里经常考的“有序表插入”。
  • 顺序表作为其他数据结构的底层:用两个顺序表实现队列,或者用一个顺序表实现栈。这个练习能帮你理解“数据结构是可以组合的”这件事。

7. 一个完整的可运行示例:把上面的代码串起来

光讲不练等于白学。我把核心操作整合成一个完整的示例程序,你可以直接复制编译运行,验证效果。

#include <stdio.h> #include <stdlib.h> #include <stdbool.h> #define INIT_CAPACITY 4 typedef struct { int* data; int size; int capacity; } SeqList; void seqlist_init(SeqList* list) { list->data = (int*)malloc(INIT_CAPACITY * sizeof(int)); if (list->data == NULL) { printf("内存分配失败\n"); exit(1); } list->size = 0; list->capacity = INIT_CAPACITY; } void seqlist_expand(SeqList* list) { int new_capacity = list->capacity * 2; int* new_data = (int*)realloc(list->data, new_capacity * sizeof(int)); if (new_data == NULL) { printf("扩容失败\n"); exit(1); } list->data = new_data; list->capacity = new_capacity; } bool seqlist_insert(SeqList* list, int index, int value) { if (index < 0 || index > list->size) { return false; } if (list->size == list->capacity) { seqlist_expand(list); } for (int i = list->size; i > index; i--) { list->data[i] = list->data[i - 1]; } list->data[index] = value; list->size++; return true; } bool seqlist_remove(SeqList* list, int index) { if (index < 0 || index >= list->size) { return false; } for (int i = index; i < list->size - 1; i++) { list->data[i] = list->data[i + 1]; } list->size--; return true; } int seqlist_find(SeqList* list, int value) { for (int i = 0; i < list->size; i++) { if (list->data[i] == value) { return i; } } return -1; } void seqlist_print(SeqList* list) { printf("size=%d, capacity=%d, [", list->size, list->capacity); for (int i = 0; i < list->size; i++) { printf("%d", list->data[i]); if (i != list->size - 1) { printf(", "); } } printf("]\n"); } void seqlist_destroy(SeqList* list) { free(list->data); list->data = NULL; list->size = 0; list->capacity = 0; } int main() { SeqList list; seqlist_init(&list); seqlist_insert(&list, 0, 10); seqlist_insert(&list, 1, 20); seqlist_insert(&list, 2, 30); seqlist_insert(&list, 3, 40); seqlist_print(&list); seqlist_insert(&list, 2, 25); seqlist_print(&list); seqlist_remove(&list, 1); seqlist_print(&list); int pos = seqlist_find(&list, 25); if (pos != -1) { printf("找到 25 在下标 %d\n", pos); } else { printf("未找到 25\n"); } seqlist_destroy(&list); return 0; }

编译运行:

gcc -g -Wall seqlist.c -o seqlist ./seqlist

输出如下:

size=4, capacity=4, [10, 20, 30, 40] size=5, capacity=8, [10, 20, 25, 30, 40] size=4, capacity=8, [10, 25, 30, 40] 找到 25 在下标 1

你可以观察第二次插入时 capacity 从 4 变成 8,这就是扩容函数生效了。如果你想知道数据搬移的具体过程,加一行打印,看循环移动时数组的变化。

8. 给数据结构和算法训练者的几个建议

作为过来人,我不打算在最后做长篇大论式的总结,分享三个我认为学习顺序表阶段最值得养成的习惯。

第一个习惯是:写数据结构的练习题时,不要只盯“答案对不对”,要盯“每个边界条件是否都处理了”。空表插入、满表插入、删除最后一个元素、查找不存在的值、连续多次扩容——每个边界都要自己主动构造测试用例去验证。说实话,数据结构的 bug 绝大多数出在边界,而不是主流程。你能把边界条件想全,后面的二叉平衡树、图算法都会轻松很多。

第二个习惯是:尽早开始用调试工具和内存检测工具。我见过太多人在初学阶段把大量时间耗在“盯着代码看哪里错了”上,其实用 gdb 打断点看变量、用 ASan 检测越界、用 Valgrind 检测内存泄漏,几分钟就能定位问题。工具的使用不是高年级才需要的能力,而是第一天写 C 语言就应该积累的技能。

第三个习惯是:学任何数据结构,先在纸上画出存储结构和操作过程,再动手写代码。顺序表插入时元素是怎么移动的、size 是怎么变化的,这些用笔画一画就清楚了。当年我学严蔚敏教材时,就是先把每个操作在草稿纸上推演一遍,再写代码,写出来的代码几乎一遍过,调试时间大幅减少。这个习惯在后序学树的遍历、图的搜索时更加重要——这些内容靠凭空想很难想清楚,但一画就明白了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询