1. 顺序表到底是什么:先把这个基础概念吃透
1.1 从“数据怎么存放”说起
如果你刚开始学数据结构,第一个真正需要动手写的代码通常就是顺序表。别看它基础,几乎所有后续内容——栈、队列、哈希表甚至数据库的底层存储——都会遇到顺序存储的影子。顺序表的核心定义很简单:用一段地址连续的存储单元,依次存放线性表中的数据元素。换句话说,逻辑上相邻的数据,在物理内存里也挨在一起。
打个比方:顺序表就像电影院里的连排座位,观众入场后按票号依次坐下,座位号连续,找人也特别方便——只要知道第几排第几座,直接走过去就行。链表则更像“寻宝游戏”,每个座位上的人告诉你下一位坐在哪里,你只能一个一个找。这个差异决定了顺序表的核心特性:随机访问快,插入删除慢。
很多人学顺序表时只记住了“数组就是一种顺序表”,这个理解方向没错,但不够完整。数组确实是顺序表最直观的实现方式,但顺序表真正的价值在于:它把“数据的逻辑结构”和“物理存储方式”绑在了一起,让你能在程序设计层面理解为什么不同操作会有不同的时间复杂度。吃透顺序表,等于打牢了整个数据结构的根基。
1.2 一个顺序表里到底装了什么
从代码视角看,一个经典的顺序表包含两样东西:一块连续的内存区域(通常用数组表示),以及一个记录当前元素数量的长度值。有的实现还会带一个“容量”字段,用于区分数组总大小和实际使用大小,这是后面实现动态扩容的前提。
#define MAX_SIZE 100 typedef struct { int data[MAX_SIZE]; // 用数组存储数据 int length; // 当前元素个数 } SeqList;这段结构体定义几乎是所有顺序表实现的起点。MAX_SIZE规定了表的最大容量,length则永远指向“下一个可以放数据的位置”,同时也是当前元素的总数。这里有一个新手特别容易出现的错误:把length当作数组下标来用。length的含义是“已有多少个元素”,而最后一个元素的合法下标是length - 1。弄混了,马上就会越界。
从这个基础结构出发,你会慢慢发现顺序表的所有操作(插入、删除、查找、遍历)都围绕这两个核心变量展开:操作数组存数据,操作length控制有效范围。理解了这个,后面的代码就顺理成章了。
2. 顺序表的核心操作:初始化、插入、删除、查找的完整拆解
2.1 初始化与判空:越是简单越要写对
顺序表的第一步永远是初始化。很多人觉得初始化不就是把length设为0吗?话是没错,但关键在于:你必须在逻辑上“清空”整张表,而不只是修改一个变量。如果表中有残留的旧数据,初始化之后length为0,程序后续操作不会读取这些残留数据,所以逻辑上它们就不存在了。但如果你的代码不小心用到了未初始化的数组,读到的就是内存里的随机值,这种Bug极难排查。
void InitList(SeqList *L) { L->length = 0; // 表中没有任何元素 // 这里不用把 data 数组全部清零,因为 length 已经限制了有效范围 }为什么要强调“清零不是必须”?因为性能。当表特别大时,逐个清零的时间成本是可以省掉的。数据结构追求的核心之一就是效率,能用O(1)完成的就不要花O(n)。当然,如果你为了调试方便想清零,也不是不行,但要意识到这里有一个工程上的取舍。
与初始化配套的还有两个几乎“白送”的操作:判空和求表长。
int IsEmpty(SeqList *L) { return L->length == 0; } int ListLength(SeqList *L) { return L->length; }它们的复杂度都是O(1),因为根本不需要遍历数组,直接读取字段值就行。这是顺序表“结构简单”带来的直接红利,也是初学者第一次直观感受到“不同操作复杂度不同”的绝佳案例。
2.2 插入操作:为什么数据移动才是关键
顺序表的插入操作,逻辑上分为两步:第一步,把插入位置及其后面的所有元素往后挪一位;第二步,把新元素放进空出来的位置。这么说可能有点抽象,想象一排人站在队伍里,你硬要插到第三位,那么原先的第三位到最后一位,所有人都得往后退一步。
代码实现如下:
int ListInsert(SeqList *L, int pos, int elem) { int i; // 1. 检查pos是否合法:1 <= pos <= length + 1 if (pos < 1 || pos > L->length + 1) { return 0; // 插入失败 } // 2. 检查表是否已满 if (L->length >= MAX_SIZE) { return 0; } // 3. 从最后一个元素开始,依次向后移动一位 for (i = L->length - 1; i >= pos - 1; i--) { L->data[i + 1] = L->data[i]; } // 4. 将新元素放入pos位置 L->data[pos - 1] = elem; // 5. 表长加1 L->length++; return 1; // 插入成功 }这段代码有三个细节值得画重点。
第一,pos的下标语义。用户传入的pos是“第几个位置”,从1开始数;而数组下标从0开始。所以新元素最终的存放位置是data[pos - 1]。这个转换做不好,后面全乱。
第二,移动元素的顺序。循环从最后一个元素data[length - 1]开始,往右挪到data[length],然后依次往左处理。为什么要从后往前挪?因为如果从前往后挪,前面的元素会先覆盖后面的元素,还没轮到后面移动,数据就已经丢了。
第三,移动的终止条件。循环条件是i >= pos - 1,意味着从pos - 1这个位置开始,它本身也要往后挪,把位置让给新元素。
插入操作的时间复杂度也很好算。最坏情况是插入到表头,所有元素都要挪,O(n);最好情况是插入到表尾,一个都不用挪,O(1);平均下来是O(n)。顺序表的插入天生慢,这就是“连续存储”的代价。
2.3 删除操作:反方向的移动,同样要小心边界
删除操作是插入的逆过程:把某个位置的元素取走,然后把它后面的所有元素往前挪一位,最后把length减1。
int ListDelete(SeqList *L, int pos, int *elem) { int i; if (pos < 1 || pos > L->length) { return 0; } *elem = L->data[pos - 1]; // 先把被删元素的值取出来 for (i = pos; i < L->length; i++) { L->data[i - 1] = L->data[i]; } L->length--; return 1; }删除操作里容易被忽略的是:被删除元素的值要通过elem指针带出去。这是因为C语言的函数只能返回一个值,而删除操作既需要告诉调用者“成功还是失败”,也需要把删掉的那个数据交还给调用者。用一个指针参数来“输出”数据,是C语言里非常经典的做法。
数据移动方向的差异也要注意。插入是从后往前挪,因为要避免覆盖;删除是从前往后挪,因为要把后面的数据往前面“补位”。方向反了,结果一定错。另外,删除之后length减1,最后一个位置残留的数据已经不在有效范围内了,不用特意清理。这和初始化的道理一样——length管理着“有效区”。
如果你用Python之类的高级语言写顺序表,pop方法帮你把数据搬移的工作全做了,你根本意识不到底层发生了什么。这正是我建议初学者用C语言实现一遍顺序表的原因:只有自己手动挪过数据,才能真正理解数组下标、元素移动和边界条件之间的微妙关系。
2.4 查找操作:按值查找 vs 按位查找
查找有两种:按值查找和按位查找,它们背后的复杂度完全不同。
按位查找就是给定下标,返回对应的数据。因为顺序表的物理存储是连续的,数组支持随机访问,所以这一步的复杂度是O(1):
int GetElem(SeqList *L, int pos, int *elem) { if (pos < 1 || pos > L->length) { return 0; } *elem = L->data[pos - 1]; return 1; }按值查找则是另一回事。你要在表里找一个“值等于目标”的元素,只能从头到尾一个一个比较,没有捷径。如果有多个相同的元素,通常返回第一个匹配的位置:
int LocateElem(SeqList *L, int target) { int i; for (i = 0; i < L->length; i++) { if (L->data[i] == target) { return i + 1; // 返回逻辑位置,从1开始 } } return 0; // 表示不存在 }按值查找的时间复杂度,最坏情况O(n),平均情况O(n)。这正是“连续存储”给顺序表带来的固有限制:随机访问快,但查找只能顺序遍历。这个“不对称”的特性,在后序学习哈希表、二叉搜索树时会不断被拿出来对比,顺序表作为参照系的角色无可替代。
2.5 一个能直接运行的完整例子
把上面的操作串起来,写一个能跑的完整程序,比零散看代码片段有用得多。我建议你亲手敲一遍,然后分别测试边界情况:插入到表头、表尾、中间,删除第一个元素、最后一个元素,查找不存在的值。
#include <stdio.h> #define MAX_SIZE 100 typedef struct { int data[MAX_SIZE]; int length; } SeqList; void InitList(SeqList *L) { L->length = 0; } int ListInsert(SeqList *L, int pos, int elem) { int i; if (pos < 1 || pos > L->length + 1 || L->length >= MAX_SIZE) { return 0; } for (i = L->length - 1; i >= pos - 1; i--) { L->data[i + 1] = L->data[i]; } L->data[pos - 1] = elem; L->length++; return 1; } int ListDelete(SeqList *L, int pos, int *elem) { int i; if (pos < 1 || pos > L->length) { return 0; } *elem = L->data[pos - 1]; for (i = pos; i < L->length; i++) { L->data[i - 1] = L->data[i]; } L->length--; return 1; } void PrintList(SeqList *L) { int i; for (i = 0; i < L->length; i++) { printf("%d ", L->data[i]); } printf("\n"); } int main() { SeqList list; int deleted; InitList(&list); ListInsert(&list, 1, 10); ListInsert(&list, 2, 20); ListInsert(&list, 2, 15); // 现在表:10, 15, 20 PrintList(&list); ListDelete(&list, 2, &deleted); printf("删除了: %d\n", deleted); PrintList(&list); return 0; }这个例程把前面讲的所有关键操作都整合在了一起,输出结果是:
10 15 20 删除了: 15 10 20拿到完整结果以后,我强烈建议你用调试器一步一步跟踪,重点看每次插入和删除时数组里元素的移动过程。看一眼内存里的数据变化,比听我讲十遍都管用。
3. 动态扩容与设计权衡:从固定数组到一个像样的容器
3.1 静态数组的死穴:容量写死之后怎么办
上面的实现有一个致命问题:MAX_SIZE是写死的100。如果实际数据超过100个元素,插入操作直接失败。这个限制在某些场景下可以接受,但作为一个像样的数据结构,我们希望它能在需要时“长大”。
这就是动态顺序表的来源:不再用固定数组,而是用一个指向堆内存的指针,加上容量capacity和当前长度length两个字段。
typedef struct { int *data; // 指向动态分配的数组 int length; // 当前元素个数 int capacity; // 当前分配的容量 } SeqList;这里最核心的思想是:内存不是一次给够,而是“按需分配,不够就换更大的”。换内存的过程看似简单,实际上包含一个非常关键的步骤——把旧数据搬到新内存里。少了这一步,数据就全丢了。
3.2 扩容的完整流程:申请、搬移、释放
动态扩容通常发生在插入操作发现length == capacity时。此时我们执行以下步骤:
- 申请一块更大的内存,比如新容量是旧容量的2倍。
- 把旧数组里的所有元素逐个复制到新内存。
- 释放旧内存,让
data指向新内存。 - 更新
capacity。
int ExpandCapacity(SeqList *L) { int newCapacity = L->capacity * 2; int *newData = (int *)malloc(newCapacity * sizeof(int)); int i; if (newData == NULL) { return 0; // 内存分配失败 } for (i = 0; i < L->length; i++) { newData[i] = L->data[i]; } free(L->data); L->data = newData; L->capacity = newCapacity; return 1; }这里有个初学者特别容易踩的坑:忘了释放旧内存。在C语言里,堆内存不会自动回收,你free的必须是原来分配的那块内存。如果你直接把L->data = (int *)malloc(...)覆盖掉旧指针,旧内存就变成了内存泄漏——程序占用的内存越来越多,但那些空间永远找不回来了。
我见过不少人写的顺序表实际运行时内存越用越多,就是这个原因。
3.3 扩容倍数到底怎么选:2倍还是1.5倍
扩容的倍数看起来是个小选择,其实背后有数学原因。常用的策略是扩容为原来的2倍,或者1.5倍,很少看到用固定增量(比如每次多100个)的方案。
固定增量的代价是:如果你反复插入很多元素,每次扩容都要搬移一次数据,搬移的总次数太多,效率非常低。什么意思?假设初始容量是100,固定每次增加100,那么插入第101个元素时要搬到容量200,插入第201个时要搬200个数据到容量300,插入第301个时要搬300个数据到容量400……你很快就会意识到,搬移数据的开销越来越大,而且扩容次数呈线性增长。
如果采用倍增策略,初始容量100,插入到101时搬到200,插入到201时搬到400,插入到401时搬到800,扩容次数大幅减少。虽然每次搬移的数据量变大了,但总体搬移次数呈对数增长。
关于2倍和1.5倍的取舍,业界有过不少讨论。2倍策略实现简单,均摊时间复杂度O(1)。1.5倍策略在内存回收机制上往往更友好——因为重新分配时更可能复用之前的空闲内存块。不过对初学者而言,用2倍就足够清晰,先把倍增思想吃透,再去研究工程上的微调。
关键心得:动态扩容的整体代价是均摊O(1)。意思是虽然某次插入可能因为扩容而变慢,但把它摊到所有插入操作上,平均每次的成本仍然是常数。这也是为什么动态数组在实际工程中非常受欢迎。
3.4 缩容:听起来很美好,但别频繁触发
既然能扩容,是不是应该对称地实现缩容?逻辑上确实应该:当删除元素导致length远小于capacity时,可以考虑把容量缩小,节省内存。但有一个陷阱叫“扩容缩容抖动”:频繁插入删除导致反复扩容缩容,每次都要搬移数据,性能反而爆炸。
常见的工程做法有两个。第一,缩容触发条件设得保守一些,比如让length < capacity / 4时才缩小为原来的一半,这样扩容和缩容之间会有一段缓冲区,不会在临界点附近来回横跳。第二,干脆不缩容,在确保使用场景下内存可接受,牺牲空间换性能。很多高要求的库导出的动态数组实现也不做自动缩容,正是为了防止抖动。
初学者容易忽视这个细节,但设计一个“不会抖动”的动态顺序表,是区分“会写代码”和“懂代码”的分水岭。
4. 应用场景与选型判断:顺序表在真实的工程中用在哪儿
4.1 典型场景一:需要频繁随机访问的地方
顺序表最舒服的应用场景,是“读多写少”且需要快速按下标访问数据的地方。比如一个通讯录,如果用户经常按序号查看联系人、按编号查找第几个联系人,顺序表是完美的选择。数组本身就是最自然的存储结构,按下标访问是O(1),没有任何多余开销。
再比如程序里的日志缓冲区,追加日志只需要在尾部插入,偶尔需要翻看某一段日志,顺序表同样合适。尾部插入是顺序表“唯一不心疼”的插入操作,因为不需要移动任何元素。
4.2 典型场景二:实现栈和队列的底层
栈(后进先出)和队列(先进先出)在频繁操作的数据方向上非常固定:栈只在栈顶操作,队列只在头部出、尾部进。如果用顺序表实现这两种结构,你几乎不会碰到“在中间插入删除”这种费劲的操作,全部都是在两端操作。
尤其是栈,用顺序表实现简直是量身定做:
typedef struct { int *data; int top; // 栈顶下标,-1表示空栈 int capacity; } SeqStack; // 入栈 int Push(SeqStack *s, int elem) { if (s->top + 1 >= s->capacity) { // 扩容 } s->data[++s->top] = elem; return 1; } // 出栈 int Pop(SeqStack *s, int *elem) { if (s->top == -1) return 0; *elem = s->data[s->top--]; return 1; }栈顶的入栈出栈都是O(1),不会触发数据移动,顺序表的价值在这里发挥到极致。这也是为什么很多教材不约而同地先讲顺序表,再讲栈就用顺序表实现。
4.3 什么时候该用链表:插入删除特别频繁时
任何一个数据结构都不是万能的。如果场景是“频繁在中间位置插入和删除”,顺序表就会暴露它最大的劣势:每次操作都要移动一半甚至更多的元素。这个时候,链表就更有优势了,因为链表插入删除只需要修改前后节点的指针,不需要搬移数据。
举个现实中的例子:正在编辑一份很长的文档,你要不停地在一句话中间插入文字、删除段落。这种操作如果底层用顺序表,每输入一个字符,后面所有人都要往后退一步,代价太高;但用链表,每个字符就是一个节点,插入删除只需要断开和接上指针,代价非常低。
所以选型时记住一句话:看操作的重心在哪里。高频随机访问→顺序表;高频中间插入删除→链表;高频尾部追加→顺序表已经够好。所谓“数据结构选型”,本质就是分析场景,挑出代价最低的方案。
5. 常见问题与排查技巧实录:我踩过的坑都在这里
5.1 扩容之后,指针怎么“莫名其妙”失效了
这个坑几乎每个用C写顺序表的人都会踩到。你写了下面这样的代码,明明逻辑没问题,可运行起来数据全乱了:
int *p = &list.data[3]; // 保存一个指向元素的指针 ListInsert(&list, 5, 99); // 触发扩容 printf("%d\n", *p); // 读出来的值完全不对原因在于:扩容时,数据被复制到了一个新的内存区域,旧内存被free了。你手里那个指针还在指向旧内存——它已经是“悬挂指针”,指向了一块已经释放的空间。访问它,轻则读到脏数据,重则程序崩溃。
怎么避开?记住一条铁律:不要长期保存指向顺序表内部元素的指针,尤其是在可能触发扩容的操作之后。如果你确实需要保存某个元素的位置,保存它的下标,而不是指针。下标配合data数组依然可以O(1)访问,但不会因为扩容失效。
5.2 越界访问:为何是内存破坏,而不是直接报错
C语言的数组不检查越界。list.data[200]写进去,编译器根本不理你,运行时也不报错——但那个位置的内存可能存放着别的变量,甚至别的重要数据。你写进去,就悄无声息地把别人的数据破坏了。
最恶心的部分就是,越界写坏的往往是别处的内存,程序可能很久之后才崩溃,而且崩溃位置和越界操作的代码完全没有关系,查起来特别痛苦。
我给你的排查方法很简单:如果程序随机崩溃、数据被神秘修改,优先检查所有数组访问的下标计算是否正确。尤其是插入和删除操作,pos - 1、length - 1这些表达式极易出错。还有一种高效手段:使用内存检测工具编译运行(常用的内存检测工具有很多),它会帮你抓出越界的具体位置。
5.3 长度为0却还在读取元素:边界条件漏判
删到最后一个元素后,length变成0。如果此时你仍然调用GetElem(&list, 1, &elem),函数里的pos < 1 || pos > length会拦住你,返回失败。怕的是你在实现的时候把判断写漏了一条:
if (pos < 1 || pos > L->length + 1) { ... } // 插入时是 length+1,因为可以在末尾插入 if (pos < 1 || pos > L->length) { ... } // 删除、查找时是 length,不能越界插入和删除的下界检查很容易被写成一模一样的,结果就是删除操作多了一个“可以删除末尾之后”的错误分支,读到了根本不存在的数据。我建议你在写完每个操作后,立刻测试这几个用例:操作第一个元素、操作最后一个元素、操作length+1位置、操作0位置。把这些边界全部跑一遍,代码的可靠性立刻上一个台阶。
5.4 一个实用的测试思路:断言驱动的数据验证
如果你是初学者,写完顺序表之后不知道怎么验证,我来分享一个我一直在用的方法:在每次插入、删除操作之后,手动检查一个关键不变量——length等于有效元素个数,同时从data[0]到data[length-1]每个位置的值都是一次插入或移动落进去的、不该有“空洞”。
更具体地说,你可以写一个ValidateList函数:
int ValidateList(SeqList *L) { int i; // 基本约束:length 不能为负,不能超过 capacity if (L->length < 0 || L->length > L->capacity) { return 0; } // 实际数据范围的检查:如果表是空的,不应该尝试读取 // 此处可扩展具体业务规则 return 1; }然后在每个操作之后调用它。一旦ValidateList返回0,程序立即停下来,错误定位到具体的操作,比你事后翻日志高效得多。这个思路其实就是测试驱动开发在数据结构上的朴素实践,强烈建议养成习惯。
6. 动手写一个完整课设性质的顺序表:从需求到验收
6.1 需求定义:做一个“学生成绩管理系统”的存储核心
只讲零散操作,终究不够过瘾。来一个综合实践:用顺序表实现一个学生成绩管理系统的数据层。需求是:
- 支持添加学生记录(姓名、学号、成绩)
- 支持按学号查找学生
- 支持按位置删除学生
- 支持打印所有学生信息
- 学号唯一,重复学号不允许添加
这个需求非常贴近真实应用,同时能覆盖顺序表的基本操作之外,还多了一个“按学号找再删除”的复合操作。
6.2 数据结构设计与实现
定义一个学生结构体,再定义一个顺序表结构体:
#define MAX_NAME_LEN 32 typedef struct { int id; // 学号 char name[MAX_NAME_LEN]; // 姓名 float score; // 成绩 } Student; typedef struct { Student *data; int length; int capacity; } StudentList;添加学生的逻辑比普通插入多一步:先遍历一遍,确认学号不重复。这本质上就是一次按值查找的O(n)操作。
int AddStudent(StudentList *list, Student stu) { int i; for (i = 0; i < list->length; i++) { if (list->data[i].id == stu.id) { return 0; // 学号重复,添加失败 } } if (list->length >= list->capacity) { if (ExpandCapacity(list) == 0) { return 0; } } list->data[list->length++] = stu; return 1; }按学号删除,也是先查找,再删除。有了前面LocateElem的经验,这个操作自然能写出来。当你把一个本来很抽象的顺序表落成“学生成绩管理系统”的存储层时,你会突然理解顺序表的真实定位:它不是终点,而是无数实用系统的底层基石。
6.3 验收标准与自测清单
一个合格的课程设计级别顺序表,至少要满足以下自测清单:
- 插入到空表第一个位置,成功,
length变1。 - 插入到末尾位置,成功。
- 插入非法位置(0、负值、超出
length+1),失败,表状态不变。 - 删除合法位置,成功,后续元素前移,
length减1。 - 删除非法位置,失败。
- 查找存在的元素,返回正确位置。
- 查找不存在的元素,返回失败。
- 连续插入超过原容量后,所有数据保持完整。
- 一个元素都不删的情况下反复插入,内存没有泄漏。
- 全部删除后,表回到
length = 0状态,还能继续插入。
第10条很多人会忽略:删除所有元素之后,表应该是一个“空的但依然完全可用”的状态,而不是一个废掉的表。一个合格的数据结构实现,必须能被反复使用,而不是用一次就报废。
7. 顺序表的扩展方向:学完之后往哪儿走
7.1 从静态到动态,从C到更高级的语言
当你彻底掌握了C语言版本的动态顺序表,你会突然发现高级语言里的vector、ArrayList、list这些容器的底层设计一下就通了。以C++的vector为例,它本质上就是一个封装好的动态顺序表:连续内存、随机访问、自动扩容。但vector还多了一个关键设计——扩容时不是简单复制对象,而是区分“移动构造”和“复制构造”,以减少大对象拷贝的代价。
理解了顺序表的精髓,再去看这些高级容器的设计,你看到的就不只是“用法”,而是“为什么这么设计”的底层逻辑。
7.2 从顺序表到其他数据结构
顺序表学完,你的下一步通常是链表。两条路线一对比,你会彻底理解“连续存储”和“链式存储”的本质区别。再往后学栈和队列时,你可以尝试分别用顺序表和链表各实现一遍,体会同样的逻辑结构如何适配不同的物理存储。
更进阶的方向包括:循环顺序表(解决队列的假溢出问题)、多级索引顺序表(跳表的核心思想)、以及顺序表和哈希表结合形成的缓存友好型数据结构。每一条路都从顺序表出发,但每一条路都会带你走得更远。
7.3 面对面试题:顺序表的高频考点
面试中顺序表的题目往往看似简单,却最能考察基本功是否扎实。常考的点包括:手写一个动态数组实现、分析扩容操作的均摊复杂度、原地删除重复元素、合并两个有序顺序表。尤其是“合并两个有序顺序表”,虽然思路简单,但边界条件很多,能一遍写对的人比例其实不高。
我的建议是,面试前把顺序表的每个操作都以“白纸手写”的方式练上三遍。手写一遍、调试一遍、复盘一遍,比默背十遍原理有用得多。顺序表是所有数据结构里最好手写、也最容易露怯的一个。
8. 写在最后:顺序表靠不靠谱,亲测才有发言权
我个人在实际操作中的体会是,顺序表是最适合练手的数据结构第一课,因为它把“数据在内存里到底怎么摆”这件事讲透了。你在调试器里盯着数组里的元素移动,看它一步一步腾位置、补位,就会真正理解“连续存储”这四个字的分量。
再分享一个小技巧:如果你在实现过程中觉得某个操作总是出错,先别急着调试,把这个操作对应的数组状态画下来,把下标标注在旁边,然后手写一遍循环过程。慢就是快,顺序表这个层次把基本功打扎实了,后面学链表、树、图,都会顺畅得多。用一次完整的实现,把数组下标、边界条件、动态内存这三个基本功一次补齐,这门课的收益就翻倍了。