ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

动态顺序表通讯录:从固定数组到自动扩容的完整改造

动态顺序表通讯录:从固定数组到自动扩容的完整改造 1. 从静态表到动态表为什么续篇必须重写存储层1.1 静态顺序表通讯录的致命短板上一篇我们用固定数组实现了一个能跑起来的通讯录一条Person people[1000]把所有联系人装进去配合菜单循环做增删查改。程序能编译、能运行、考试也能过但它有一个非常尴尬的场景——某天你往里面录联系人录到第1001个人的时候程序直接提示通讯录已满。你盯着屏幕上那行字确认自己没记错明明现代手机里存几千个联系人都是正常事怎么自己写的程序连1000个都装不下这就是静态顺序表的本质限制容量在编译期就焊死了。想改就得改代码重新编译而且无论你开50还是开5000内存都会被完整占用哪怕里面只存了3个人。所以这个续篇要解决的核心问题只有一个让通讯录的容量跟着数据量走。数据少就少占内存数据多了就自动扩容这就是动态顺序表的价值所在。顺带把文件保存、排序显示这些能跑但不好用的功能一并补上让通讯录真正脱离演示代码的范畴变成一个日常能用的工具。1.2 动态顺序表的结构体设计与数据组织动态和静态最本质的区别是把数组声明改为指针声明再加上两个配套变量typedef struct Person { char name[20]; char phone[12]; char address[30]; } Person; typedef struct SeqList { Person* data; // 指向堆上的联系人数组 size_t size; // 当前有效元素个数 size_t capacity; // 当前容量能装多少人 } SeqList;注意看data只是Person*它不指向任何具体内存。真正的存储空间需要在程序运行时通过malloc或calloc从堆上申请。这就是动态两个字的核心空间不是在编译期分配好的而是程序跑起来之后按需申请的。这里有个设计细节值得说size和capacity我都用了size_t而没用int。size_t是无符号整型在64位系统下是8字节能表达更大的范围而且它天然适合做数组下标和容量值——容量不可能是负数用无符号类型语义上更准确。很多教程用int考试判分没问题但养成用size_t的习惯进入工程实践后会更顺手。1.3 初始化与销毁两个容易写错的小函数动态版本要先做初始化把指针置空、计数清零。别小看这几行代码初始化写不好后面所有操作都是访问野指针void SeqListInit(SeqList* list) { list-data NULL; list-size 0; list-capacity 0; }这里有个关键问题data初始化为NULL那第一次插入数据时怎么办总不能对空指针直接赋值。所以每次插入前都要检查一个条件——size capacity。如果相等说明数组满了需要扩容。第一次插入时capacity是0size也是0既然相等就会触发扩容逻辑这就把首次插入也要分配内存这个场景天然地覆盖到了。很多初学者在这里单独写一个if分支处理首次分配实际上让首次插入走统一的扩容路径更简洁。有分配就必然要有释放。销毁函数是为数不多的、你必须亲手写的垃圾回收void SeqListDestroy(SeqList* list) { free(list-data); list-data NULL; list-size 0; list-capacity 0; }注意销毁之后要把指针置空。原因很实际如果谁不小心对已经destroy过的表再次调插入或查找程序会因为空指针而崩溃。置空之后至少会有一个明确的段错误比用着用着数据突然错乱好定位得多——这是我在调试阶段总结出来的血泪经验。2. 扩容策略什么时候该翻倍什么时候该加固定值2.1 realloc的三个隐藏行为扩容离不开realloc这个函数看起来简单但有几个行为和直觉不太一样不搞清楚很容易写出有内存问题代码。realloc(ptr, newSize)的行为分三种情况原来的内存块后面有足够的连续空间直接在原地扩展返回原地址。原来的内存块后面空间不够系统会重新找一块更大的内存把旧数据完整搬过去然后释放旧内存返回新地址。如果newSize是0或者内存分配失败返回NULL。前两条决定了你不能直接把返回值赋给原来的指针。如果realloc返回了新的地址而你写成list-data (Person*)realloc(list-data, newSize)一旦分配失败返回的是NULL那原指针就被覆盖了——原来的内存既没被释放函数调用失败时不会释放旧块你又失去了唯一能找到它的地址这就构成了真正的内存泄漏。常见错误写法是list-data (Person*)realloc(list-data, newSize); // 不推荐推荐的写法是用临时指针接过返回值判断成功后再赋值Person* temp (Person*)realloc(list-data, newCapacity * sizeof(Person)); if (temp NULL) { // 扩容失败原数据仍然有效直接返回错误 return -1; } list-data temp; list-capacity newCapacity; return 0;这种写法即使在扩容失败时原数据也完好无损程序可以继续运行只是本次插入操作被拒绝而已。2.2 策略对比翻倍扩容 vs 线性增长扩容策略是个经典的算法权衡问题。两种主流做法翻倍扩容每次乘2优点是均摊成本低。总共插入n个元素扩容操作发生的次数是log n级别每次需要搬移的数据量加起来是O(n)量级均摊到每次插入就是O(1)。缺点是内存浪费明显比如容量已经到1000才需要扩容实际数据才501个会浪费近一半空间。线性增长每次固定加100优点是内存利用率高有多少增长多少。缺点是把均摊复杂度提高到了O(n)——因为每加100就要搬一次家插入n个元素总共要做n/100次搬移每次搬移量平均是n/2总成本是O(n^2)量级。实际工程里通常用翻倍策略尤其在不知道最终数据量级的前提下。但翻倍有翻倍的坑如果容量已经很大比如几十万还想翻倍就可能超过系统内存限制或者触发过度分配。所以我在实际项目里用的是翻倍加退避的混合策略——优先尝试翻倍如果失败回退到每次增加固定数量再不行就拒绝本次插入int SeqListReserve(SeqList* list, size_t newCapacity) { if (newCapacity list-capacity) return 0; Person* temp (Person*)realloc(list-data, newCapacity * sizeof(Person)); if (temp NULL) { // 第一次扩容失败尝试保守的线性增长每次加32 size_t fallbackCapacity list-capacity 32; if (newCapacity fallbackCapacity) { temp (Person*)realloc(list-data, fallbackCapacity * sizeof(Person)); if (temp NULL) return -1; newCapacity fallbackCapacity; } else { return -1; } } list-data temp; list-capacity newCapacity; return 0; }这套策略在我实际使用中表现很稳绝大多数情况下翻倍能满足需求翻倍失败时也不会让程序直接崩溃而是以较小的步长继续尝试。2.3 插入元素时的扩容判定与完整实现有了扩容函数插入逻辑就顺理成章了。这里以按位置插入为最通用的版本实现因为在中间插入比尾部插入更难写好了通用的尾部插入就是它的特例int SeqListInsert(SeqList* list, size_t pos, const Person* person) { if (pos list-size) return -1; // 禁止跳着插 if (list-size list-capacity) { size_t newCapacity list-capacity 0 ? 4 : list-capacity * 2; if (SeqListReserve(list, newCapacity) ! 0) { return -1; // 扩容失败本次插入不执行 } } // 从后往前搬移元素给pos位置腾出空间 for (size_t i list-size; i pos; i--) { list-data[i] list-data[i - 1]; } list-data[pos] *person; list-size; return 0; }这个函数有个容易被忽视的边界判断pos list-size返回-1。注意这里只拒绝大于size的情况等于size时意味着插到末尾是允许的。这正好对应了尾部追加的语义——你不需要单独再写一个SeqListPushBack直接调用SeqListInsert(list, list-size, newPerson)就可以了。搬移方向也是个容易写错的点。如果循环从前往后搬前面的数据会把后面的覆盖掉。必须从后往前先把最后一个元素搬到最后一个空位再搬倒数第二个这样每个元素都能安全挪动。这是顺序表插入操作最经典的易错点写代码时要时刻盯着循环方向。3. 通讯录核心操作的动态化改造3.1 删除元素数据搬移背后的成本逻辑删除联系人也是顺序表的经典操作。按位置删除的代码本身很短int SeqListErase(SeqList* list, size_t pos) { if (pos list-size) return -1; for (size_t i pos; i list-size - 1; i) { list-data[i] list-data[i 1]; } list-size--; return 0; }但短小不代表不重要。这段代码体现的是顺序表删除操作的时间复杂度——O(n)。你想删掉中间的一个人后面所有人都要往前挪一格。而链表删除是O(1)的只需要改一下前一个节点的指针。为什么通讯录这个场景依然可以用顺序表因为通讯录的删除频率并不高一天删几个人就算多的而查找、遍历才是高频操作。顺序表对遍历是O(n)但常数极小缓存命中率高在千级别数据量下实际操作速度非常快。换链表删除虽然快但查找第一个人要顺着指针跳缓存利用差空间还多存一个指针字段。所以选数据结构不是选理论最优而是选场景最合适。3.2 查找与修改避免产生重复代码按姓名查找的通讯录功能看起来只要一个循环加strcmp就能实现int FindByName(const SeqList* list, const char* name) { for (size_t i 0; i list-size; i) { if (strcmp(list-data[i].name, name) 0) { return (int)i; } } return -1; }返回的是下标找不到返回-1。这里我刻意用了int而不是size_t因为size_t是无符号类型没法用-1表达不存在。实际项目中很多函数签名都有这种用有符号类型表达无符号语义的需求是很常见的工程权衡。修改联系人信息时很多人会把查找逻辑再写一遍。更好的做法是复用查找函数int ModifyByName(SeqList* list, const char* name, const Person* newInfo) { int idx FindByName(list, name); if (idx -1) return -1; list-data[idx] *newInfo; return 0; }这看起来只是把查找调用抽出来但实际价值在于如果后续想把查找从线性查找换成哈希或二分只需要改FindByName一个函数所有调用方都不受影响。代码复用的意义在长期维护时才会显示出来刚开始写都觉得多写一遍也没事等到改逻辑时才发现要改三四处才叫痛苦。3.3 排序qsort函数指针的一个实战案例通讯录还有一个高频需求按照姓名排序显示。C语言标准库自带qsort不需要自己写快排。它的接口是这样的void qsort(void* base, size_t num, size_t size, int (*compar)(const void*, const void*));最后一个参数是函数指针你需要自己实现一个比较函数。这是很多初学者第一道函数指针门槛但其实用法很固定int CompareByName(const void* a, const void* b) { const Person* pa (const Person*)a; const Person* pb (const Person*)b; return strcmp(pa-name, pb-name); } void SortContacts(SeqList* list) { qsort(list-data, list-size, sizeof(Person), CompareByName); }注意比较函数必须用const void*作为参数类型——这是qsort的接口约定不能直接写const Person*。void指针可以接收任意类型的指针然后在函数内部把它强制转回实际类型。这个动作表面上有些多余实际上正是C语言泛型编程的基础形态用void*屏蔽类型差异让同一个排序函数能处理任何类型的数据。排序后通讯录就按字典序排好了。这里有个优化思路值得说说既然姓名有序了查找就能用二分查找把复杂度从O(n)降到O(log n)。但要注意前提——如果你插入新联系人后没有再次排序二分查找的结果就是错的。所以实际通讯录我的做法是查找仍然用线性排序只用于展示。如果你确实想让查找变快可以考虑每次插入后直接把新联系人插到有序位置找到位置再插入牺牲插入效率换取查找效率。哪种合算取决于你的通讯录使用模式。4. 文件保存与读取让通讯录不随程序退出而消失4.1 二进制读写 vs 文本读写到底选哪个把通讯录关掉再打开之前录入的联系人全没了。这个体验放在学习场景里可以接受但放到真实使用中是完全不可忍受的。所以儿歌文件持久化是通讯录从玩具变成工具的必经一步。文件持久化的代码量并不多关键是要决定用二进制还是文本方式读写。两种方案各有各的适用场景对比维度二进制读写文本读写存储效率高直接按结构体内存布局存储低要转换成字符串写入可读性差用记事本打开是乱码好可以直接查看编辑可移植性依赖结构体内存对齐、字节序不依赖实现难度简单fwrite/fread一把梭更繁琐需要格式化写入和解析通讯录这个场景我选择二进制。理由很实际数据量不大但每次读写都要处理大量字符串用文本方式需要逐个字段格式化写入再解析回来代码量翻倍不止而二进制直接用fwrite把整个结构体数组写进文件读出来直接恢复简单直接。潜在隐患是字节序——如果你把文件拷到不同CPU架构的机器上可能读乱码。但通讯录这种个人工具绝大多数环境下不会遇到这个问题。4.2 保存与加载的完整实现保存函数的设计思路文件里不光存联系人数据还要存取一个版本号和当前有效人数。版本号是为后续扩展预留的——假如以后你要给Person结构体加一个字段比如邮箱没有版本号老文件读进新程序时一堆数据错位程序就炸了。有了版本号读文件时先检查版本不匹配就提示用户重建文件。#define CONTACT_FILE_VERSION 1 int SaveContacts(const SeqList* list, const char* filename) { FILE* fp fopen(filename, wb); if (fp NULL) return -1; int version CONTACT_FILE_VERSION; fwrite(version, sizeof(int), 1, fp); fwrite(list-size, sizeof(size_t), 1, fp); fwrite(list-data, sizeof(Person), list-size, fp); fclose(fp); return 0; } int LoadContacts(SeqList* list, const char* filename) { FILE* fp fopen(filename, rb); if (fp NULL) return -1; int version; size_t count; fread(version, sizeof(int), 1, fp); if (version ! CONTACT_FILE_VERSION) { fclose(fp); return -2; // 版本不兼容 } fread(count, sizeof(size_t), 1, fp); if (count 1000000) { // 简单防御文件数据可疑 fclose(fp); return -3; } if (SeqListReserve(list, count) ! 0) { fclose(fp); return -4; } size_t readCount fread(list-data, sizeof(Person), count, fp); list-size readCount; fclose(fp); return 0; }保存时有几个细节值得注意。fwrite(list-data, sizeof(Person), list-size, fp)这一句实际上连续写入了size个完整的Person结构体写入量等于结构体大小的整数倍。读取时对应的fread也必须用相同的大小和数量去读否则错位是必然的。加载函数里我加了一个防御性检查如果读出来的count大得离谱比如文件损坏导致读到垃圾数据直接拒绝加载防止后面malloc申请一个巨大的内存块耗尽系统资源。这个检查在真实项目中极其重要——你对文件内容保持它随时可能损坏的信任度而不是假设文件永远正确。4.3 一个必须处理的疏漏程序启动时读取写好了加载函数你得在main函数的开头调用它SeqList contacts; SeqListInit(contacts); if (LoadContacts(contacts, contacts.dat) 0) { printf(成功加载 %zu 位联系人\n, contacts.size); } else { printf(没有找到已有数据文件已创建新通讯录\n); }程序退出前调用保存if (SaveContacts(contacts, contacts.dat) 0) { printf(通讯录已保存\n); } SeqListDestroy(contacts);这里顺序很重要先保存再销毁。有人写反了先SeqListDestroy把list-data释放了再调SaveContacts传递给保存函数的指针已经是悬垂指针函数内部fwrite(list-data, ...)读取的是一片已释放的内存结果要么是垃圾数据要么直接崩溃。这个错误看起来低级但实际踩过的人不少尤其在重构代码时很容易把清理动作挪错位置。我的习惯是保存和销毁之间隔一个打印提示用输出信息强制区分两个阶段。5. 代码能跑只是及格线内存管理、边界条件与健壮性5.1 动态内存管理的三个检查点动态顺序表比静态版本多了一大类bug来源——内存管理。我总结出三个必备检查点每次写完相关代码都按这个顺序自查检查点1realloc返回值必须用临时变量接住。前面说过直接赋给原指针会在分配失败时丢失原内存地址。这是内存泄漏的重灾区。检查点2free之后必须置空。通讯录销毁时释放了data但如果没置空其他函数误操作时会访问到一块看似还存在的随机地址。悬垂指针比空指针危险得多因为空指针至少能稳定崩溃悬垂指针可能随机崩溃或随机产生错乱数据。检查点3插入前必须检查size和capacity的关系。虽然SeqListInsert里面已经包含了扩容判定但如果你绕过这个函数直接写list-data[list-size] newPerson; list-size;这种看起来更省事的代码就会越界写入——capacity不够时你访问了一块不属于这个顺序表的内存。这种错误的特征是程序不一定马上崩溃可能在很久之后某个无关操作时才崩排查起来极其痛苦。如果使用Linux环境建议装一下valgrind每次运行完程序后用valgrind --leak-checkfull ./contacts检查内存泄漏。它会明确告诉你哪一行分配的内存没有被释放。这是我接触动态内存后养成的习惯可以说救过我无数次。5.2 边界条件自查清单写完通讯录后我有一份固定的边界测试用例清单。别小看测试很多人的通讯录能跑通主流程一遇到底部或空表就出问题考试和上机的时候就悲剧了。以下几个方面必须在测试时覆盖空表操作通讯录一个联系人都没有时删除、查找、排序分别执行。此时size是0删除函数应当直接返回-1排序不能访问data[0]查找循环次数为0。满表扩容持续添加联系人直到触发扩容然后继续添加检查是否还能正常插入。同时检查扩容后旧数据是否完整保留。删除最后一个元素删除后size变为0此时再插入应该能正常触发扩容并新增数据。插入到中间位置比如在第三个位置插入检查后面的元素是否依次后移且顺序正确。文件为空或损坏用一个记事本写入乱码再改扩展名为dat程序加载时应该能正常弹错而不是崩溃。有一个很常见的测试用例连续添加100个人然后用二分查找找第1个和最后1个联系人。这是验证查找和底层存储是否正常配合的最低标准。5.3 一个真实踩坑案例扩容后数据全部变乱码最后分享一个我实际调试过的问题过程很典型。现象通讯录录入到第50个联系人左右正好是首次扩容点后续的数据在界面上显示混乱一些联系人的名字跑到电话字段里去了。排查第一步我用printf打印SeqListInsert执行前后的size和capacity以及list-data的地址。发现扩容前后data的地址改变了——说明realloc搬了家。再打印搬移后的前几个元素发现完全正常。继续排查我把注意力放到插入逻辑本身。插入到中间位置时我先调用了SeqListReserve扩容扩容拿到新地址后紧接着用了list-data[i] list-data[i - 1]搬移数据。这本应没问题……但问题就出在我是先扩容后搬移没错可我在搬移循环里把目标下标写成了从list-size开始而扩容之后capacity变了size没变循环边界是对的。最后定位到真正的元凶插入函数的参数const Person* person指向的是外面传入的局部变量但我在某次调用时取地址取错了——我把contacts.data[SomeIndex]当作新联系人传了进来。扩容前data指向旧内存扩容后data指向新内存而传入指针还指向已释放的旧内存插入时读取的就是一块已被free的地。这种地址失效导致的错乱跟代码逻辑本身并没有关系纯粹是操作顺序的疏忽取地址、扩容、再次使用旧地址三者之间的时序没有管好。修复方案在SeqListInsert里先备份要插入的内容再扩容再执行搬移和赋值。这样即使扩容导致旧地址失效备份已经保存在栈上后续操作完全不受影响int SeqListInsert(SeqList* list, size_t pos, const Person* person) { if (pos list-size) return -1; Person backup *person; // 先备份防止person指针指向旧data if (list-size list-capacity) { size_t newCapacity list-capacity 0 ? 4 : list-capacity * 2; if (SeqListReserve(list, newCapacity) ! 0) return -1; } for (size_t i list-size; i pos; i--) { list-data[i] list-data[i - 1]; } list-data[pos] backup; list-size; return 0; }这个坑的核心教训是指针有生命周期它只在它所指的内存有效时可用。任何涉及扩容、销毁、重新分配的操作都可能让旧指针失效。凡是持有旧地址的变量在内存调整后都不应该再被使用。这也解释了为什么我这么强调先备份、后扩容、再操作这个顺序——顺序本身就是在管理指针的生命周期。续篇到这里通讯录的大学版本算是基本成型了。如果你把上一篇的静态实现和这篇的动态实现对照着看会发现核心差别其实不在代码量而在于思考方式静态版本关心的是怎么把功能写出来动态版本关心的是怎么让程序在数据规模变化时依然正确、高效地运行。后续你如果还有精力可以继续加两个扩展方向一个是把联系人改成按分组管理每个组一个独立顺序表另一个是把数据存储换成SQLite让通讯录还能支持模糊查询、排序索引这些更高级的功能。那些都是后话了。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进