先说个建议:不管你是正在上数据结构课,还是在准备考研、刷面试题,顺序表都值得你亲手做一个完整的项目,而不是只会在卷子上画图。很多人觉得顺序表不就是数组嘛,初始化、插入、删除、查找,背一遍就行了。但真等你自己动手写一个通讯录,把这些操作全部调通,你才会发现里面藏着大量细节:动态扩容怎么扩、删除之后要不要释放空间、为什么函数传参传的是二级指针而不是一级指针、插入一个元素为什么要从后往前挪。这篇文章就是围绕“数据结构 + 顺序表 + 通讯录项目实战”这条线,把顺序表的核心原理讲透,然后带你把通讯录管理系统的核心功能一步步写出来。本文按上篇定位,先把顺序表底层和增删查改做完,排序、文件持久化、联系人分组这些扩展放到下篇。
1. 项目整体设计与思路拆解
1.1 需求拆解:通讯录到底要哪些功能
我习惯在写代码前先列需求,哪怕是一个练手项目。通讯录的核心需求很直白:能存联系人、能添加联系人、能删除联系人、能按名字找联系人、能修改联系人信息、能看到当前所有联系人。如果再往下拆一点,联系人至少要包含姓名和电话号码,如果想让它更像真实管理系统,可以再加一个备注字段。
但这里有个很关键的问题:这些功能的核心逻辑,和顺序表有什么关系?我换个说法你就明白了。“存一批联系人”这件事,本质就是“在一个线性序列里维护若干元素”;添加联系人就是顺序表的插入操作,删除联系人就是顺序表的删除操作,按名字找联系人是按值查找,显示全部联系人就是遍历。所以通讯录不是一个独立项目,它只是顺序表的一层业务外衣。先想清楚这一点,你就知道做这个项目时,代码应该分成两层:底层是顺序表对数据的增删查改,上层是通讯录对联系人信息的业务处理。
我在准备这个项目时,功能清单大致是这样的:
| 功能 | 用户操作 | 底层对应 |
|---|---|---|
| 添加联系人 | 输入姓名、电话、备注,存到末尾 | 顺序表插入(尾插) |
| 删除联系人 | 按姓名删除指定联系人 | 按值查找 + 按下标删除 |
| 查找联系人 | 输入姓名,输出完整信息 | 按值查找 |
| 修改联系人 | 输入原姓名和新资料,覆盖原记录 | 按下标修改 |
| 显示全部 | 打印当前所有联系人 | 顺序表遍历 |
单看功能,确实没有涉及排序、分组、分页这些高级东西。但如果你是初学者,我建议先把这一版跑通,再考虑扩展。基础都不牢的时候,加一堆花里胡哨的菜单只会让你分心。
1.2 为什么选顺序表,而不是链表或直接裸写数组
很多初学者会问:顺序表不就是结构体包了层数组吗,干嘛不直接开一个 Contact contacts[100] 算了?这个想法我能理解,但通讯录里的人数是可变的,今天可能是20人,过一个月可能变成200人。如果你直接开固定数组,要么浪费空间,要么一旦超过上限,程序就崩给你看。顺序表的价值就在于它替你管理了“容量”这个概念,满了就扩容,删了也能通过 size 精确控制有效元素数量,而不是靠一个写死的数值硬撑。
那链表行不行?按原理说当然行,很多学校的通讯录作业也会用链表实现。但顺序表在这个场景里有一个非常明显的优势:通讯录操作的高频动作不是频繁插入和删除,而是查看、查找、修改。比如“我存了300个联系人,想看一眼第100个人是谁”,顺序表用下标一步就能定位,链表的随机访问要一个个往后找。而且顺序表底层是连续内存,CPU缓存友好,遍历访问速度比链表快得多。所以通讯录这个需求,选顺序表更贴近真实应用场景。
另外,从学习顺序上讲,顺序表是所有线性表里面最容易理解的一种。它先不讲指针怎么绕来绕去,核心就是一个动态数组。你把顺序表搞懂了,再去看链表里的 malloc、指针操作,会轻松很多。这也是为什么绝大多数教材,从严蔚敏老师的《数据结构》到考研用的王道资料,都把顺序表放在线性表的第一站。
1.3 代码组织:一个文件和一个关键前提
这个项目我用纯 C 语言写,选 C 是因为教科书的算法描述基本都是 C 语言的伪代码,你移植起来没有任何隔阂。当然,用 Java、Python 的同学也完全能看懂,顺序表的核心思路是通用的,只是语法层面有差异。
我建议你把它写成一个单文件工程,比如 contact_seqlist.c,里面分成几块:底层顺序表的 init、insert、delete、find、update 等函数,上层业务菜单的 addContact、deleteContact、findContact 等封装,最后是 main。项目不大,不用急着拆头文件和源文件,把数据流向理清楚比什么都重要。
这里有一个关键前提必须声明:为了简化代码逻辑,这个版本的通讯录我假设姓名是唯一的,查找、删除、修改都按姓名来。如果你要把功能做得更完善,可以自己给联系人加一个自增 ID,用 ID 做唯一键。这个扩展留到后面再说。
2. 顺序表核心原理解读:背概念前先搞懂地址不变量
2.1 顺序表的物理存储:连续内存是你的第一直觉
顺序表的定义很多人都会背:用一段地址连续的存储单元,依次存放线性表中的数据元素。用大白话说,它底层就是一个数组。数组这个名字大家太熟了,以至于很多人忽略了它最有价值的特征——随机访问。
数组能随机访问,前提是你知道元素的首地址和每个元素的大小。假设数组首地址是 base,每个元素占 sizeof(ElemType) 个字节,那么第 i 个元素的地址就是:
text复制base + i * sizeof(ElemType)
这个公式对应到 C 语言里就是 list->data[i]。编译器看到 data[i],做的其实就是一次地址运算,然后去那块内存取值。所以顺序表的“按下标访问是 O(1)”,不是数学上的描述,而是物理上的必然。
这就直接引出了顺序表第一个设计点:结构体里必须同时存三样东西——数据区首地址 data、当前有效元素个数 size、当前最大容量 capacity。只有 data 和 size 是不够的,因为数组本身不知道什么时候满;只有 data 和 capacity 也没有意义,因为你不知道现在存了几个有效元素。很多代码写崩,就是因为没有把 size 和 capacity 分清楚,插入的时候拿 capacity 当有效长度,一插就歪。
2.2 动态扩容机制:容量和大小必须分开看
如果顺序表内部是一块静态数组,那它跟裸写数组没有本质区别。真正让顺序表“活”起来的是动态扩容。下面这段代码是每次扩容时的核心思路:
c复制static int SeqListExpand(SeqList *list) {
if (list == NULL || list->capacity <= 0) {
return -1;
}
int newCapacity = list->capacity * 2;
Contact *newData = (Contact *)realloc(list->data, sizeof(Contact) * newCapacity);
if (newData == NULL) {
return -1;
}
list->data = newData;
list->capacity = newCapacity;
return 0;
}
策略很简单:当 size == capacity 时,容量翻倍。为什么是翻倍,而不是每次都只加1?我给你算一笔账:假如初始容量是 10,每插入一个元素就要多申请一个元素的空间,那插入 N 个元素,realloc 会被调用 N 次,每次都可能发生数据搬迁,总代价是 O(N²)。但如果容量满了翻倍,扩容次数只有约 logN 次,均摊下来每个插入操作的时间复杂度是 O(1)。
这就像搬仓库,你不可能每次多进一件货就搬一次家,而是等仓库真的满到堵不住门了,直接租一个比原来大一倍的仓库,一次性搬运,之后再慢慢往里放货。
关于上面的扩容代码,我必须强调一个新手最容易踩的坑:realloc 的返回值不要直接赋给 list->data。因为 realloc 在扩容失败时会返回 NULL,并且原来那块内存依然是有效的。如果你直接 list->data = (Contact *)realloc(list->data, ...),一旦失败,list->data 就变成了 NULL,原来的数据既没扩容成功,也没地方释放,就成了内存泄漏加数据丢失。正确做法是先用一个临时指针接住返回值,判空后再赋值。
2.3 插入和删除为什么要移动元素
顺序表插入元素,不是简单地把新值塞进 data[pos] 就完了。数组是一段连续内存,为了保证逻辑上相邻的元素在物理上也相邻,你必须给新元素腾位置。
假设当前有 size 个元素,要在下标 pos 处插入,那么从 pos 到 size - 1 的所有元素都要往后挪一个位置。挪的顺序必须是从最后一个元素开始、从后往前。如果你从前往后挪,第一个元素会把第二个元素的位置占了,第二个还没挪就已经被覆盖,数据就乱了。
删除则是反过来,从 pos + 1 开始,把每个元素往前挪一格,最后 size--。这里有个容易误会的点:删除后最后那个位置上的“旧数据”其实还在内存里,我们不需要主动把它清零。因为顺序表对外暴露的有效范围是 0 到 size-1,只要 size 减了,后面的数据就不会被访问到,留着反而方便下一次插入时直接覆盖。很多新手会纠结“要不要 free 掉被删元素的内存”,不需要,你 free 的是整个 data 数组,删除操作本身不涉及内存释放。
这个移动的代价是 O(n)。插入越靠前,需要移动的元素越多;删除也是同理。所以顺序表的插入、删除平均时间复杂度是 O(n),查找按下标是 O(1),按值查找是 O(n)。这四个复杂度结论,期末、考研、面试都会被反复考到。
2.4 为什么函数参数里传的是指针
这是很多 C 语言初学者的阴影区。通讯录项目里,初始化顺序表时我用的函数是 SeqListInit(SeqList *list, int capacity),函数内部要修改 list->size、list->capacity、list->data。如果你把参数写成 SeqList list,那函数内部修改的只是实参的一份拷贝,函数调用结束后,外面的结构体什么变化都没有。
所以在 C 语言里,想让函数真正改变一个结构体变量,必须传结构体指针。这个原则不仅初始化时成立,扩容时也成立。扩容改了 list->data 和 list->capacity,同样需要指针。但也正因为这个原因,很多人在 insert 函数里会犹豫:扩容后 data 指向了一块新地址,那外面调用者持有的那个结构体的 data 还会同步更新吗?
答案是会,只要你传的是 SeqList *list。因为指针指向的是同一个结构体对象,函数内通过 list->data = newData 修改的是那个对象内部的字段,调用者看到的自然也是新值。这跟你把指针本身传进去、在函数里修改“指针变量”的指向是两码事,不要混淆。
3. 通讯录项目实战:从结构体设计到核心操作
3.1 数据结构定义:联系人结构和顺序表结构
代码开头是数据结构的定义。联系人字段我用姓名、电话、备注三样。注意,这里的字符数组长度是我拍脑袋定的,姓名不超过31个字符、电话不超过19个字符、备注不超过63个字符,已经能满足大多数练手场景:
c复制#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define DEFAULT_CAPACITY 10
#define NAME_SIZE 32
#define PHONE_SIZE 20
#define REMARK_SIZE 64
typedef struct {
char name[NAME_SIZE];
char phone[PHONE_SIZE];
char remark[REMARK_SIZE];
} Contact;
typedef struct {
Contact *data;
int size;
int capacity;
} SeqList;
顺序表结构体里 data 指向的是 Contact 类型的数组。size 是当前有效联系人数量,capacity 是当前最多能存多少个联系人。可以这样理解:capacity 是这座仓库的物理库容,size 是仓库里现在已经堆了多少货。
这里有一个小细节建议你养成习惯:结构体里凡是“字符串的字符数组”,我都习惯用宏把长度定义出来,并且在 scanf 时写上宽度限制,比如 scanf("%31s", temp.name)。为什么不是 scanf("%s", temp.name)?因为不加宽度限制,用户手一抖输了50个字符,strcpy 会把 50 个字符全部塞进 32 字节的数组里,直接栈溢出,轻则数据错乱,重则程序崩溃。这种做法在真实项目里绝对不允许。
3.2 初始化与销毁:动态内存的生命周期管理
初始化函数会申请一块堆内存。因为 data 是指针,你不 malloc,它就是一个野指针,随便往里写数据必然出问题。下面是我常用的写法:
c复制int SeqListInit(SeqList *list, int capacity) {
if (list == NULL) {
return -1;
}
if (capacity <= 0) {
capacity = DEFAULT_CAPACITY;
}
list->data = (Contact *)malloc(sizeof(Contact) * capacity);
if (list->data == NULL) {
list->size = 0;
list->capacity = 0;
return -1;
}
list->size = 0;
list->capacity = capacity;
return 0;
}
初始化里有两个防御处理:第一,如果传入的 capacity 小于等于0,就给它一个默认初始容量;第二,如果 malloc 失败,立刻把 size 和 capacity 都置0,避免外部拿到一个“看起来还能用”的半成品结构体。malloc 失败虽然在实际练习中不常见,但你得知道怎么处理。
有 malloc 就必然有 free。销毁函数是容易被人忽略的一部分。很多练手项目跑完就关了,根本不关心内存释放。但如果你以后要在比较大的程序里集成这个通讯录,用完不释放,长时间运行就会内存泄漏。销毁顺序表:
c复制void SeqListDestroy(SeqList *list) {
if (list == NULL) {
return;
}
free(list->data);
list->data = NULL;
list->size = 0;
list->capacity = 0;
}
注意 free(list->data) 之后,把 list->data 置成 NULL。这是个好习惯,防止以后误用已经释放的野指针。
3.3 插入操作:位置校验、扩容触发和从后往前挪
插入接口我设计得比较底层:调用者传入目标位置 pos,新联系人插入到该位置。如果 pos == size,相当于尾插。底层插入函数必须自己做三件事:位置合法吗?容量够吗?数据怎么挪?
c复制int SeqListInsert(SeqList *list, int pos, const Contact *contact) {
if (list == NULL || contact == NULL) {
return -1;
}
if (pos < 0 || pos > list->size) {
return -1;
}
if (list->size == list->capacity) {
if (SeqListExpand(list) != 0) {
return -1;
}
}
for (int i = list->size; i > pos; i--) {
list->data[i] = list->data[i - 1];
}
list->data[pos] = *contact;
list->size++;
return 0;
}
位置校验这里有个细节:允许的插入范围是 [0, size],而不是 [0, size-1]。因为 pos = size 表示插入到当前最后一个元素的后面,这是合法的尾插操作。如果 pos = size + 1,中间会空出一个位置,顺序表的连续性就被破坏了,所以必须报错。
写循环时有人会不小心写成 for (int i = pos; i < list->size; i++),然后逐个往后赋值。这样循环第一轮就把 data[pos + 1] 的原值覆盖了,后面的数据全部错乱。必须从后往前倒着搬,把最后一个先挪到新位置,才能保证每个旧值在被覆盖前已经搬走。
直接在某个位置插入本身不难,但要避免上层用户乱插。业务层添加联系人时,通常只需要往末尾追加,所以我再封装一层:
c复制int SeqListPushBack(SeqList *list, const Contact *contact) {
return SeqListInsert(list, list->size, contact);
}
如果你对“姓名唯一”有要求,可以在上层添加联系人时先查一遍重名,查到就不插入。这样可以把顺序表底层操作保持通用,不用在底层做业务判断。
3.4 删除操作:按下标删除和按名称删除
按下标删除是删除操作的核心。逻辑就是把后面的元素往前覆盖,最后把 size 减一:
c复制int SeqListDeleteByPos(SeqList *list, int pos) {
if (list == NULL || pos < 0 || pos >= list->size) {
return -1;
}
for (int i = pos; i < list->size - 1; i++) {
list->data[i] = list->data[i + 1];
}
list->size--;
return 0;
}
注意删除时 pos 的范围是 [0, size-1],这一点跟插入不一样。如果你传入 pos = size,那是删除一个不存在的位置,直接报错。
但这个函数对通讯录用户来说不太友好。没人愿意记住联系人存在第几个下标,大家更习惯的是“我想删掉张三”。所以业务层要先用姓名找到下标,再调用底层删除,二合一封装成按名称删除:
c复制int SeqListDeleteByName(SeqList *list, const char *name) {
if (list == NULL || name == NULL) {
return -1;
}
int pos = -1;
for (int i = 0; i < list->size; i++) {
if (strcmp(list->data[i].name, name) == 0) {
pos = i;
break;
}
}
if (pos == -1) {
return -1;
}
return SeqListDeleteByPos(list, pos);
}
用 strcmp 比较字符串是否相等时,返回值是 0 表示相等,很多人会在这里写成 if (strcmp(...)) 然后逻辑反掉,最后怎么都删不掉联系人。记住:strcmp() == 0 才是相等。
3.5 查找与修改:按下标修改是顺序表最大的便利
查找函数我不直接返回查到的结构体值,而是把找到的下标通过指针参数传出去。这样上层拿到下标后,既可以打印内容,也可以继续做删除或者修改。接口可以这样写:
c复制int SeqListFindByName(const SeqList *list, const char *name, int *pos) {
if (list == NULL || name == NULL || pos == NULL) {
return -1;
}
for (int i = 0; i < list->size; i++) {
if (strcmp(list->data[i].name, name) == 0) {
*pos = i;
return 0;
}
}
return -1;
}
找到位置后,打印联系人信息是件很容易的事:
c复制void PrintContact(const Contact *c) {
if (c == NULL) {
return;
}
printf("姓名: %s\n", c->name);
printf("电话: %s\n", c->phone);
printf("备注: %s\n", c->remark);
}
修改联系人,最朴素的做法是要求用户把新的联系人信息完整输入一遍,然后整体覆盖到对应位置。因为姓名唯一,所以只有姓名不能改,电话和备注都能改:
c复制int SeqListModifyByName(SeqList *list, const char *name, const Contact *newContact) {
if (list == NULL || name == NULL || newContact == NULL) {
return -1;
}
int pos = -1;
if (SeqListFindByName(list, name, &pos) != 0) {
return -1;
}
list->data[pos] = *newContact;
return 0;
}
这种整结构体覆盖的写法在业务上最简单,也最能体现顺序表“按下标随机访问”的优势。如果用的是链表,你当然也能通过遍历找到节点再更新,但代码会绕很多。这其实就是顺序表在通讯录项目里的价值缩影。
3.6 通讯录业务菜单:把这些函数串起来
底层函数只是积木,菜单函数才是把积木搭成通讯录的脚手架。我把每个功能封装成一个 void 函数,这样主菜单只需要用 switch 分发就行。添加联系人的业务封装大概是这个画风:
c复制void AddContact(SeqList *list) {
if (list == NULL) return;
Contact temp;
printf("请输入姓名: ");
scanf("%31s", temp.name);
for (int i = 0; i < list->size; i++) {
if (strcmp(list->data[i].name, temp.name) == 0) {
printf("该姓名已存在,请勿重复添加。\n");
return;
}
}
printf("请输入电话: ");
scanf("%19s", temp.phone);
printf("请输入备注: ");
scanf("%63s", temp.remark);
if (SeqListPushBack(list, &temp) == 0) {
printf("添加成功,当前共 %d 位联系人。\n", list->size);
} else {
printf("添加失败,可能原因: 内存分配失败。\n");
}
}
你可能已经发现,我在 contact 结构体里使用 char[32],但 scanf 却写 %31s。这么做就是为了让 scanf 读进去的字符串最大长度不超过 31 个字符,剩下的位置留给结束符 '\0'。这是 C 语言里防止输入越界很实用的一招。
菜单函数里的删除、查找、修改,基本上就是先接收一个姓名,然后调用上面的底层函数,再根据返回值提示用户操作结果。这里我不把完整 main 全贴出来,因为菜单代码没有太多算法含量,但开头和菜单循环可以示意一下:
c复制int main() {
SeqList list;
if (SeqListInit(&list, DEFAULT_CAPACITY) != 0) {
printf("顺序表初始化失败,程序退出。\n");
return -1;
}
int choice;
while (1) {
printf("\n===== 通讯录管理系统 =====\n");
printf("1. 添加联系人\n");
printf("2. 删除联系人\n");
printf("3. 查找联系人\n");
printf("4. 修改联系人\n");
printf("5. 显示所有联系人\n");
printf("0. 退出\n");
printf("请输入功能编号: ");
scanf("%d", &choice);
switch (choice) {
case 1: AddContact(&list); break;
case 2: DeleteContact(&list); break;
case 3: FindContact(&list); break;
case 4: ModifyContact(&list); break;
case 5: PrintAllContacts(&list); break;
case 0: printf("程序结束。\n"); SeqListDestroy(&list); return 0;
default: printf("无效输入,请重新选择。\n"); break;
}
}
}
注意所有业务函数传给底层时,都要传 &list,因为底层需要修改结构体内容。这也是前面“为什么要传指针”的又一次体现。如果你忘了取地址,只传了一个 list 值拷贝进去,你会发现菜单上每次都提示成功,但再次查看时联系人一个都没有。
4. 实操测试:这几组用例帮你验证代码对不对
代码写出来不代表完事,必须测试。我在实际调试这个通讯录时,会按下面几组用例逐项去验证。你可以照着这个顺序去试,能帮你快速定位大部分 bug。
第一组:基础插入和显示。 启动程序,先连续添加 3 个联系人,然后选择显示所有联系人。这组能通过的标志是:3 个人都能正常显示,字段顺序不乱,中文也不会变成乱码。如果显示漏人,优先检查是不是 size 在插入时没有正确自增。
第二组:触发扩容。 初始容量我设的是 10,你可以连续添加 12 个联系人,观察第 11 个联系人添加时程序是否还能正常工作。如果添加第 11 个人的时候程序崩溃,大概率是扩容函数没写对,或者扩容后 size、capacity 更新错位了。为了方便测试,你也可以临时把 DEFAULT_CAPACITY 改成 2,这样插到第 3 个人就会触发扩容,调试效率更高。
第三组:删除边界。 分别尝试:删空表里的一个不存在的人、删除第 0 个元素(头部删除)、删除最后一个元素(尾部删除)、连续删除直到表空、表空之后再继续删除。头部删除是最容易出问题的,如果从后往前覆盖时下标边界没把握好,很可能把最后一个元素漏了或者越界访问。
第四组:修改与查找联动。 先添加“张三”,再添加“李四”,然后按“张三”查找,能正常打印;接着把“张三”的电话改成新号码,再查一次,应该看到新号码。这一步能同时验证查找、修改和字符串拷贝逻辑。如果打印出来的号码是乱的,多半是 strcpy 时源字符串没有以 \0 结尾,或者是 scanf 输入时越界把相邻内存破坏了。
第五组:重复姓名防守。 输入两个“张三”,程序应该拒绝第二个,并提示姓名重复。如果第二个张三被成功存进去了,说明你添加联系人时没有做查重,或者查重时 strcmp 的相等判断写反了。
这五组都通过,说明通讯录的“增删查改”主流程基本是稳的。我推荐的测试原则是:不要在真实操作里随机乱点,而是每次只针对一个边界条件去验证。测试越有章法,你定位 bug 的速度越快。
5. 常见问题与排查技巧实录
练手项目最常见的坑,我来整理成表,方便你直接对照排查。
| 错误现象 | 可能原因 | 排查与解决 |
|---|---|---|
| 插入后显示时只有一半数据 | 插入或删除时移动元素的方向写反,覆盖了未处理的数据 | 插入必须从后往前移动元素,删除必须从前往后移动元素 |
| 添加第 11 个联系人时程序崩溃 | size == capacity 时没有扩容,或扩容后直接写入不存在的下标 |
检查插入前是否有扩容判断,扩容函数是否正确更新 capacity |
| 删除联系人后,最后一个位置出现重复数据 | 删除函数里 size-- 写错位置,或者移动元素循环范围不对 |
删除后 size 必须减一,被删位置后面的元素都要前移一位 |
| 按姓名找不到联系人 | 用 if (strcmp(a, b)) 判断相等,逻辑反了 |
strcmp 返回 0 才表示字符串相等,要写 if (strcmp(a, b) == 0) |
| 菜单操作后联系人没变化 | 函数参数传的是结构体值拷贝,内存被修改后没有影响外部 | 所有需要修改数据的函数都传结构体指针,调用时用 &list |
| 输入超长姓名/电话导致程序卡死或乱码 | scanf 没有加宽度限制,输入溢出字符数组 | 使用 %31s、%19s 这类带长度的格式控制符 |
| 程序退出后内存泄漏 | 没有调用 destroy/free 释放 data |
程序结束前调用 SeqListDestroy(&list) 并释放动态数组 |
| 从空表删除不报错反而成功 | 删除位置边界判断漏了 pos >= size |
删除前检查位置是否在合法区间 [0, size-1] |
再补充几个我在调试时经常用的“土办法”。第一,在关键函数里打印调试信息,比如在 SeqListInsert 成功后打印 size 和 capacity。很多问题看一眼这两个值就清楚了,比如 size 已经大于 capacity,说明扩容逻辑压根没触发;capacity 变成 0 但 data 不为 NULL,说明初始化顺序有问题。第二,用调试器在 realloc 返回后加断点,观察 data 指向的地址有没有变化。如果变了但打印联系人时还是用旧地址,说明你的插入函数没有同步更新结构体里的 data 字段。第三,当你怀疑内存越界但找不到原因时,可以把 DEFAULT_CAPACITY 调大,比如 10000,再复现一次。如果问题消失,大概率是容量边界处理有问题;如果问题还在,那不是内存越界就是逻辑写错。
写到这里,说点我的真实体会
顺序表这个东西,看十遍概念不如亲手写一遍通讯录。你写的时候会发现自己原来对数组、指针、结构体的理解全是“好像懂了”。我在第一次实现这个项目时,最意外的是插入函数里的元素移动——我以为从前往后挪和从后往前挪都行,结果从前往后一跑,数据全被覆盖成了第一个元素。后来才发现自己根本就没理解“连续存储”这四个字意味着什么。所以如果你在写这个通讯录时也卡住了,别慌,这恰恰说明你正在把抽象的知识变成真正能用的工具。代码跑不通不是坏事,它是让你记住原理的最快方式。这篇先把顺序表和通讯录的增删查改讲完,下一篇再去补排序、文件保存、联系人分组这些进阶功能。手别停,打开编辑器,把代码敲一遍再说。
