1. 从C语言到数据结构:先搞清楚这门课到底在解决什么问题
很多自学者都会经历这样一个时刻:C语言的语法学得差不多了,指针虽然绕但能用,结构体也会写了,文件操作、内存管理这些虽然不熟,但至少知道是怎么回事。然后翻开数据结构教材,看到第一章的"顺序表"——心里冒出的第一个念头往往是:这不就是数组吗?C语言不是早就会数组了吗?为什么还要专门学一遍?
这个疑问非常正常,也恰恰点中了数据结构这门课的核心价值。C语言教的是"怎么写代码",数据结构教的是"怎么组织数据"。同样是存一百个学生的成绩,用普通数组能存,用结构体数组也能存,但当你要往里插入一条记录、删掉一条记录、按学号查找某个人的信息时,数据摆放方式不同,代码的复杂度和运行速度可能差出几个数量级。
数据结构解决的就是这类问题的通用方案。它不关心你的数据是学生成绩还是商品价格,只关心数据之间的逻辑关系和存储方式。顺序表是所有数据结构里最基础的一种,因为它最贴近我们已有的认知——它就是在连续的内存空间里顺序存放一组数据元素。换句话说,你学会C语言数组的那一刻,其实已经掌握了顺序表的存储形态,但离真正掌握顺序表还差一层:把数组和它的操作封装成一套完整、可复用、边界安全的机制。
所以我建议所有C语言学习者,在学数据结构的时候,把心态从"我会不会写代码"切换成"我这套设计能不能经得起推敲"。顺序表这个入门项目,表面上代码量不大,但它涵盖了数据结构的三种核心能力:怎么定义结构、怎么设计操作、怎么分析效率。把这三件事理顺了,后面的链表、栈、队列、树学起来都是顺水推舟的事。
这篇文章会完整拆解顺序表的每一个核心操作,从结构体定义到初始化、插入、删除、查找、销毁,代码是完整的C语言实现,每一步都会解释设计理由。也会把我在实际调试中遇到的崩溃现场、边界条件问题讲清楚,这些是教材和很多教程里不会细写的东西。
2. 顺序表的本质:为什么"数组加几个变量"就能被称为一种数据结构
2.1 从硬币收纳盒理解顺序表的三个核心字段
如果我们把顺序表拆到不能再拆,它其实就是一块连续的内存 + 记录已经存了几个元素的变量 + 记录这块内存能存几个元素的变量。这三样东西,一个都不能少。
打个比方。你有一个长条形的硬币收纳盒,盒子有十个槽位——这十个槽位就是"内存空间"。你往里放了四个硬币——这个数量就是"当前长度"。收纳盒总共能装十个硬币——这个上限就是"容量"。少了"当前长度"这个变量,你就不知道里面到底有几个硬币,只能靠数;少了"容量"这个变量,你往里塞第十一个硬币的时候,就不知道盒子已经满了。顺序表的设计就是把这个简单的物理模型抽象成C语言里的结构体。
典型的结构体定义是这样的:
#define INIT_CAPACITY 8 // 初始容量 typedef struct { int *data; // 指向动态分配的内存空间 int length; // 当前元素个数 int capacity; // 当前分配的空间能容纳的元素个数 } SeqList;为什么要用int *data而不是直接定义一个定长数组int data[100]?这个设计选择后面会展开讲,核心原因是为了让顺序表的大小可以根据需要动态调整,而不是一上来就锁死成某个固定值。
术语上需要先统一一下。length在数据结构里叫表长,就是当前实际存了多少个元素。capacity叫容量,是最多能存多少个元素。很多初学会把这两个概念搞混,写循环的时候用错了,要么访问越界,要么漏掉最后一个元素。这两个字段的含义和区别,是顺序表所有操作的基础,务必记牢。
2.2 逻辑结构和物理结构:顺序表为什么叫"顺序"表
数据结构这门课里,"顺序"这个词特指物理存储上的连续。也就是说,第0个元素和第1个元素在内存里是紧挨着的,第1个和第2个也是紧挨着的,以此类推。这种"一个挨一个"的存储关系,在C语言里天然对应数组。
但这里要区分两个概念:逻辑结构和物理结构。逻辑结构是指数据之间的抽象关系——在顺序表里,元素之间有"前驱"和"后继"的关系,比如第3个元素的前驱是第2个,后继是第4个。物理结构是指数据在内存里的实际摆放方式——顺序表的物理摆放就是连续的数组。
有的同学会问:连续摆放有什么好处?最大的好处是随机访问。你要访问第5个元素,只要知道起始地址,加上5乘每个元素的大小,马上就能算出第5个元素的内存地址,跳到那里取值。这个操作的时间消耗是固定的,跟表里有多少个元素没关系。我们用大O记号表示,就是O(1)复杂度。
这个特性在后面的章节会反复用到。插入和删除为什么慢?也是因为"连续"这个要求——往中间插一个元素,后面的所有元素都得往后挪,给新元素腾地方。要维持"连续"这个性质,就必须付出挪动的代价。顺序表的快和慢,本质上都来自"连续"这两个字。
2.3 静态分配与动态分配:数据结构教材里的路线分岔
我见过很多教材和网课在顺序表这一节会给出两种实现方式:一种是定长数组,一种是动态分配内存。初学者往往觉得两种都行,直接跳过了这个差别。实际上这背后是对"数据结构应该具备什么能力"的不同理解。
静态分配版本是这样写的:
#define MAX_SIZE 100 typedef struct { int data[MAX_SIZE]; int length; } SeqList;问题在于:MAX_SIZE定多少合适?定小了,往里插数据时满了,怎么办?没空间了,程序要么报错,要么只能忽略新数据。定大了,比如定10000,但实际只用了10个,多余的空间白白占着,浪费内存。静态分配最大的毛病就是不够灵活,它把一个应该根据实际情况动态调整的需求,提前锁死在了编译期。
动态分配版本则允许我们运行时申请内存、空间不够了重新申请一块更大的、用完后释放。这更贴近真实项目的需要,因为真实场景下你往往不知道数据量会涨到多大。我在写C语言项目时,几乎不会为了存放数据而使用固定大小的数组,动态分配是基本操作。
既然要讲透顺序表,那必须用动态分配版本。这也是从C语言"存储期"概念过渡到数据结构"存储结构"概念的一个自然衔接——C语言的内存管理能力,在这里第一次有了明确的应用场景。
3. 顺序表核心操作逐行拆解:从初始化到销毁的完整实现
3.1 初始化:malloc之后一定要做的事情
初始化是顺序表的第一道生命周期,也是最容易出错的地方。很多同学的代码在初始化这一步就埋下了隐患:malloc了内存但没检查是否成功,分配完没给capacity赋值,结果后面length明明可以增长,但程序自己都不知道上限在哪。
标准做法分三步:分配内存、检查分配结果、设置初始状态。
#include <stdio.h> #include <stdlib.h> #define INIT_CAPACITY 8 typedef struct { int *data; int length; int capacity; } SeqList; // 初始化顺序表 void SeqList_Init(SeqList *list) { list->data = (int *)malloc(sizeof(int) * INIT_CAPACITY); if (list->data == NULL) { printf("内存分配失败\n"); exit(1); } list->length = 0; list->capacity = INIT_CAPACITY; }为什么把list设计成指针而不是直接传值?因为C语言是值传递,如果直接传一个SeqList list进去,函数内部修改的是那份拷贝,出了函数就丢了。所以这里必须传地址SeqList *list,才能让初始化真正作用到外部的顺序表上。
malloc返回的是void *,我们在前面加了(int *)强制转换。在C语言里这个转换可以省略,但写上更明确——省得读到代码时还要想一下这块内存是按什么类型来用的。检查返回值那个if是必须的,malloc不是每次都能成功的,内存不足时会返回NULL,如果不检查直接往下用,等于对空指针解引用,运行时会直接崩溃。
3.2 插入操作:为什么必须从最后一个元素开始往后挪
顺序表的插入是初学阶段理解"挪动元素"这个概念的第一个窗口。在位置pos(我们约定用下标表示,从0开始)插入一个新元素val,标准的操作序列是:先判断参数是否合法、检查容量是否够、从最后一个元素开始逐个往后复制、腾出位置后放入新元素、更新长度。
没有经验的代码往往会写成这样:找到位置后直接从前向后挪,结果把后面的元素都覆盖了。比如data[i+1] = data[i],第一次执行就把第i+1位的值覆盖成了第i位的,后面的数据全乱了。正确做法是从后往前挪:先把最后一个往后移,再把倒数第二个移到倒数第一个的位置——后移的过程中始终不会覆盖还没处理的值。
// 在指定下标位置插入元素 val void SeqList_Insert(SeqList *list, int pos, int val) { // 1. 判断插入位置是否合法 if (pos < 0 || pos > list->length) { printf("插入位置非法\n"); return; } // 2. 如果空间不够,先扩容 if (list->length == list->capacity) { SeqList_Expand(list); } // 3. 从后往前依次后移元素 for (int i = list->length - 1; i >= pos; i--) { list->data[i + 1] = list->data[i]; } // 4. 放入新值,更新长度 list->data[pos] = val; list->length++; }关于位置合法性的判断,有一个细节值得多说两句。pos允许等于list->length吗?允许。因为这意味着在表的末尾追加元素。所以合法范围是0到length,是一个闭开区间。很多同学会把边界写错,要么漏了pos == length,导致没办法用同一个函数在尾部追加;要么写了pos >= list->length,导致尾部追加直接报错。
3.3 删除操作:前移的覆盖逻辑与"先用再减"的陷阱
删除操作和插入是镜像的。删除位置pos的元素,需要把pos之后的元素全部往前移一位,然后长度减一。这个逻辑看起来很简单,但里面藏着初学者最容易犯错的一个点:移动方向。
插入要从后往前移,删除要从前往后移。为什么?因为删除是把后面的值往前覆盖,你从pos开始执行data[i] = data[i+1],等于用后面一个值覆盖当前的值,下一轮循环再用再后面的值覆盖刚才那个位置——这个链条是安全的,不会丢数据。如果反过来从后往前移,后面的值会先覆盖掉前面的,数据丢失。
完整代码:
// 删除指定下标的元素 void SeqList_Delete(SeqList *list, int pos) { // 1. 检查位置是否合法 if (pos < 0 || pos >= list->length) { printf("删除位置非法\n"); return; } // 2. 从前往后前移元素 for (int i = pos; i < list->length - 1; i++) { list->data[i] = list->data[i + 1]; } // 3. 长度减一 list->length--; }我在初学的时候,最喜欢在删除操作里犯的错是:循环结束后没有list->length--。这会导致一个问题——逻辑上你删了一个元素,但表里实际记录的还是原来的长度。后面遍历的时候,最后一个元素会"幽灵复现",因为它的值还在内存里躺着,长度又没减少,所以会被打印出来。这种bug不算崩溃,但数据完全不对,排查起来也很费劲。
这里还有一个值得养成的习惯:删除末尾元素后,data[length]这个位置的值其实还留在内存里,但逻辑上它已经不属于这个表了。我们通过length来界定"哪些位置有效",凡是index >= length的位置都视为无效数据。这个思维方式在后面的栈、队列里会反复用到。
3.4 按值查找与按位访问:顺序表最引以为傲的O(1)能力
顺序表有两种最常见的查询:按下标访问,以及按值查找。
按下标访问很简单,list->data[i]就是。因为数组是连续存储的,地址计算是起始地址 + i * sizeof(int),这个计算和i的大小无关,所以无论表里有一万个元素还是一亿个元素,随机访问某个元素的时间都是一样的,也就是O(1)复杂度。这是顺序表相对于链表的绝对优势。
按值查找,就是给定一个值,找到它第一次出现的位置:
// 查找第一个值为 val 的元素,返回下标,找不到返回 -1 int SeqList_Find(SeqList *list, int val) { for (int i = 0; i < list->length; i++) { if (list->data[i] == val) { return i; } } return -1; }这个代码很直白,但有两个点值得注意。第一,返回值设计。我用-1表示"没有找到"。为什么不用0?因为0是合法下标,第0个位置是可以存数据的,如果用0表示"没找到",那么第0个位置恰好是目标值时,你无法区分是"找到了第0个"还是"没找到"。所以负数作为失败标志是合理的选择。这个"返回值设计要能区分成功与失败"的思路,在之后写栈、队列的Pop操作时会反复出现。
第二,这个操作的时间复杂度是O(n)。因为最坏情况下你要扫描完整个表才知道值存不存在。这也是"顺序表"这个名字的另一层含义的代价——顺序访问。你在写程序时,要能判断出某段代码是被高频调用的;如果在一个大表上频繁做按值查找,O(n)的代价可能成为性能瓶颈,那时候可能就需要考虑哈希表之类的方案了。
3.5 扩容机制:倍增法为什么是"够用且不浪费"的折中方案
前面在插入操作里提到了SeqList_Expand,这是动态内存管理最关键的部分,需要单独展开讲。扩容的思路是:当length == capacity时,说明当前内存已经满了,此时重新申请一块更大的内存,把旧数据搬过去,然后释放旧内存并使用新内存。
直接上代码:
void SeqList_Expand(SeqList *list) { // 容量翻倍 int new_capacity = list->capacity * 2; int *new_data = (int *)malloc(sizeof(int) * new_capacity); if (new_data == NULL) { printf("扩容失败\n"); exit(1); } // 拷贝旧数据 for (int i = 0; i < list->length; i++) { new_data[i] = list->data[i]; } // 释放旧内存,更新指针和容量 free(list->data); list->data = new_data; list->capacity = new_capacity; }扩容倍数为什么是2而不是"每次加10个"或者"每次加100个"?这里有个"摊还分析"的思想——虽然单次扩容要拷贝全部元素、开销不小,但如果每次都翻倍,那么分摊到每一次插入上的平均成本非常低,近似O(1)。反过来,如果固定加10个容量,那每插入10次就要扩容一次,扩容时要把前面所有元素都拷贝一遍,累积成本会越来越高,整体插入复杂度会退化到O(n)。
倍增法也有边界问题:如果初始容量是1,每次翻倍,那么从1到2、到4、到8……一旦元素数量接近2的某次幂,下一次扩容就是一次"全部拷贝"。但这个拷贝是有"摊销"的,平均下来依然是可接受的。实际工程里,很多动态数组也是采用类似的倍增策略,只是倍率不同,有的是1.5倍、有的是2倍。
扩容的时候还有几个细节需要养成习惯:realloc可以合并"分配新内存、拷贝旧数据、释放旧内存"三步,但很多教材不用它,原因是realloc在扩容失败时会返还NULL,这个时候原本的内存可能已经被释放或者处于不可用状态,处理起来要格外小心。用"malloc + 手动拷贝 + free"的方式虽然代码长一点,但每一步都在掌控之中,对初学者更友好。
释放旧内存那行free(list->data)不要漏。经典的内存泄漏案例就是这样来的:不断扩容,但不释放旧的内存块,程序跑着跑着内存越占越多,最后被系统杀掉。写完扩容函数后,配合调试器或者valgrind工具跑一遍,确认没有泄漏,这是一个很重要的习惯。
3.6 遍历打印与销毁:一个表完整的生命周期管理
有了上面的基础操作,打印和销毁就顺理成章了。打印函数的主要价值在于:每次操作后打印一下,能肉眼确认数据是否符合预期,这是初学阶段最直观的调试手段。
// 打印顺序表所有元素 void SeqList_Print(SeqList *list) { printf("["); for (int i = 0; i < list->length; i++) { printf("%d", list->data[i]); if (i < list->length - 1) { printf(", "); } } printf("]\n"); } // 销毁顺序表 void SeqList_Destroy(SeqList *list) { free(list->data); list->data = NULL; list->length = 0; list->capacity = 0; }销毁函数的价值容易被忽略。初学写小程序时确实无所谓,程序退出后操作系统会回收所有内存。但一旦进入真实项目和后续的课程设计,长生命周期程序里反复创建、销毁数据结构,不写销毁函数的后果就是内存泄漏。free之后把指针置为NULL也是一个重要习惯——防止后续代码误用一个已经释放的指针,也就是悬空指针问题。
这里顺带提一下data指针在free后置NULL的另一个好处:如果后面不小心再次free(list->data),对NULL指针执行free是安全的,程序不会崩。但如果你忘记置NULL,又碰巧再次free,C标准说是"未定义行为",实际运行中往往会在内存管理环节崩溃,而且崩溃的位置和原因相隔很远,排错非常痛苦。
4. 教科书不会明说的崩溃现场:这些坑我都替你踩过了
4.1 那个让我调试了两个小时的非法地址访问
顺序表入门阶段遇到最多的运行时错误就是在终端里蹦出来一句Segmentation fault或者Process returned -1073741819(Windows上常见),前者是段错误,后者在Windows下通常对应0xC0000005访问冲突。两个都指向同一类问题:访问了不属于你的内存。
最常见的场景是:初始化顺序表之后,没有调用SeqList_Init,直接调用SeqList_Insert。这时候list->data是一个未初始化的垃圾值——可能指向某个随机的内存地址,也可能为NULL。程序毫不知情地往这个地址写入数据,于是直接崩溃。很多同学第一次遇到时会懵,反复检查插入代码逻辑,却忘了问题出在更早的初始化步骤。
排查这类问题的思路应该是"由近及远":先看崩溃发生在哪个函数,再逐层追溯谁调用了它。如果你用的IDE有调试器,在调用栈里能看到main函数里哪一行调用了SeqList_Insert,顺着步骤检查——那个list的data字段有没有被正确初始化。
4.2 插入位置判定出错:边界条件是数据结构最重要的细节
还有一个特别容易犯的错更隐蔽:插入时判断合法位置用的是if (pos < 0 || pos > list->length),但有人在写的时候把>写成了>=。测试的时候如果只插到中间某个位置,看不出问题;一旦在尾部追加元素,程序就报"插入位置非法"。这种bug是典型的"边界条件"问题,平时跑正常用例一切正常,一到边界情况就露馅。
数据结构的学习,很大一部分就是训练"边界敏感性"。写插入操作的时候,把pos的合法区间画出来:最小是0,最大是length(尾部追加)。把所有可能取值逐个在脑海里过一遍,确认合法区间。删除操作的合法区间则是0到length-1,注意这里和插入不一样——删除位置不能等于length,因为那个位置根本没有元素。
4.3 移动方向错误的连锁反应:为什么会复制出一堆重复元素
移动方向错误引起的现象也很有意思。插入时如果从前往后挪,结果不是崩溃,而是数据错乱——某个元素值被重复了一遍,另一个元素值消失了。比如数组是[1,2,3,4,5],往下标2的位置插入99,如果从前往后执行data[i+1]=data[i],会得到[1,2,2,2,2,5]这种乱七八糟的结果。
这类逻辑错误不会崩溃,但输出的结果是完全错误的——而这恰恰是新手觉得最难排查的:程序能跑,但结果不对。排查手段就是打印输出每一个中间态。我给自己的建议是:学数据结构的时候别嫌慢,每一步操作后都把整个表打印一遍,盯着看数据是怎么流动的。几次下来,对"从后往前"和"从前往后"的理解就不再是死记硬背,而是真的知道为什么了。
4.4 内存泄漏与重复释放:动态内存管理必须养成的三个习惯
用malloc系列函数的时候,有三个习惯越早养成越好。第一,谁分配谁释放——在哪个函数里malloc的,就要负责在合适的时机free。第二,free之后把指针置NULL,防止重复释放和悬空指针。第三,所有动态分配的地方都要检查返回值,也就是判断是否为NULL。
重复释放的例子很常见:第一个循环里删除了某个节点顺便free了,第二个循环里又用了这个节点,结果就double free,程序崩溃。再比如,你按值删除了一个元素,但这个值对应的内存已经被别的地方释放了,你没有同步置NULL,后面又free了一次。C语言不会帮你检测这类错误,跑起来时崩溃的位置和崩溃原因经常隔了十万八千里。
valgrind --leak-check=full ./your_program这个工具在Linux环境里是排查内存问题的神器,能定位出具体的泄漏位置和非法访问行号。Windows上也可以用Visual Studio的调试模式配合CRT内存泄漏检测。我推荐初学者从第一份数据结构作业开始就养成跑内存检测的习惯,这会帮你避开无数后面的痛苦。
5. 为什么顺序表是"快慢兼备"的结构:时间复杂度的真实含义
5.1 O(1)与O(n):随机访问和插入删除的天然差异
顺序表最核心的复杂度特征就两句话:按下标访问是O(1),插入删除是O(n)。这两句话如果只当作结论背下来,过两天就忘了;如果理解了背后"连续存储"这个根本原因,就永远不会忘。
访问是O(1),是因为地址计算只需要一次加法和一次乘法,跟表有多大无关。你要取第50万个元素和取第5个元素,计算的时间是相同的。
插入删除是O(n),是因为"连续"这个性质要求你必须给新元素腾出连续的空间,或者填补删除后留下的空洞。平均来看,你要移动一半的元素。如果表里有10万个元素,插入一次就要移动5万个,这个代价是肉眼可见的。
这也是面试中经常遇到的题目:为什么数组比链表更适合随机访问,但链表更适合频繁插入删除?答案的根源就在"连续"和"非连续"的存储方式上。顺序表用连续存储换来了O(1)的随机访问,代价是插入删除要挪元素;链表用指针连接换来了O(1)的插入删除(假设已经定位到位置),代价是随机访问必须从头遍历。
5.2 存储密度:顺序表为什么比链表更省内存
还有一个经常被忽略但面试常提的概念是存储密度。存储密度 = 数据本身占用的空间 / 结点总共占用的空间。顺序表每个位置都只存元素本身,没有额外的指针开销,所以存储密度接近1。而链表每个节点除了存数据,还要存一个指向下一个节点的指针,如果数据是int(4字节),指针在64位系统下是8字节,那存储密度就只有4/(4+8)≈33%——三分之二的内存花在了"指路"上。
这个差异在实际项目中是有意义的。如果你处理的是上百万条记录,每条记录是一个结构体,顺序表比链表省下的内存可能就非常可观。当然,链表也有它不可替代的场景,比如频繁在中间插入删除、或者数据块大小不固定的时候。但从"入门第一课"的角度看,顺序表让你先理解"数据密度""连续存储"这些基础概念,后面学链表的时候再对比,理解链路就顺了。
5.3 扩容的摊还分析:倍增法告诉你"平均时间"是怎么回事
前面提到的扩容采用倍增法,这里把背后的"摊还分析"说透。假设初始容量为1,要连续插入n个元素。在插入第1个时,扩容到2,拷贝1个元素;插入第2个时,扩容到4,拷贝2个;插入第4个时,扩容到8,拷贝4个。依此类推,扩容的总拷贝次数是1+2+4+8+...+n/2,等比数列求和约等于n。这意味着插入n个元素的总开销大约是n次拷贝加上n次直接插入,平摊下来,每次插入的开销是一个常数,也就是摊还O(1)。
反过来,如果每次都只增加固定数量的容量,比如一次加10个,那么每插入10次就要扩容一次,每次扩容要拷贝之前所有元素,总开销是1+11+21+31+...,是O(n²)级别的。这在数据量大了以后会非常明显地卡顿。
所以"倍增"不是一个随意的选择,它保证了动态数组在随机插到尾部时,平均性能依然接近O(1)。这一点在理解vector、ArrayList这类语言内置动态数组的实现原理时同样适用。虽然这篇文章用的是C语言,但很多语言里都有类似的数据结构,原理是通的。
6. 把顺序表用起来:两个综合练习和一个面试常考题
6.1 综合分析:顺序表实现去重操作
学完基本操作,我强烈建议做两个综合练习,这两个练习能检验自己对顺序表操作的掌握程度。第一个是去重:给定一个顺序表,把重复的元素删掉,只保留第一次出现的那个。
思路并不复杂:遍历所有元素,对每个元素,看它前面的部分是否已经出现过这个值。如果没有,保留;如果有,删掉它。由于删除操作本身会导致后面的元素前移,所以用"倒着遍历"的方式删除会更方便——从后往前检查,删除某个元素不影响前面还没检查到的元素的下标。
void SeqList_RemoveDuplicates(SeqList *list) { for (int i = 0; i < list->length; i++) { int val = list->data[i]; // 从 i+1 开始找,删除所有等于 val 的元素 for (int j = i + 1; j < list->length; ) { if (list->data[j] == val) { SeqList_Delete(list, j); // 删除后 j 不增加,因为后面的元素补上来了 } else { j++; } } } }代码里有一个关键点:删除后j不能自增,因为原来的j+1位置的元素已经移到j位置了,如果不检查,就会漏掉这个新移到当前位置的元素。这类细节,就是"边界思维"和"移动思维"的实际应用,多做几道题就能形成肌肉记忆。
6.2 递增有序表的合并:双指针法的启蒙
第二个练习是合并两个递增有序的顺序表,输出一个新的递增有序顺序表。这个问题是很多算法题的原型,也是"双指针"技巧的启蒙。
思路是:用两个下标分别指向两个表的起始位置,每次比较两个当前元素,把较小的那个放入新表,相应下标前进一位。任何一个表遍历完,把另一个表的剩余部分全部追加到新表尾部。
void SeqList_Merge(SeqList *a, SeqList *b, SeqList *result) { int i = 0, j = 0, k = 0; while (i < a->length && j < b->length) { if (a->data[i] <= b->data[j]) { result->data[k++] = a->data[i++]; } else { result->data[k++] = b->data[j++]; } result->length = k; } // 处理剩余部分 while (i < a->length) { result->data[k++] = a->data[i++]; result->length = k; } while (j < b->length) { result->data[k++] = b->data[j++]; result->length = k; } }这个合并操作的时间复杂度是O(m+n),也就是只扫描一遍两个表就完成了合并。这个"双指针"思路在后续的归并排序、链表合并、字符串处理里会反复出现。从顺序表开始接触它,性价比非常高。
6.3 C语言指针的"再认识":顺序表是理解指针最好的实战场景
有个容易忽略但特别重要的副产品:学会顺序表之后,你对C语言指针的理解会上一个台阶。因为顺序表的所有操作都绕不开结构体指针、通过指针访问成员、动态内存分配这些概念。之前学C语言时觉得抽象的"指针",在这里变成了你每天都在用的工具。
比如list->length这个写法,list是指向SeqList结构体的指针,->是"解引用并取成员"的语法糖。你写了无数次之后,对"指针指向某个结构体""通过指针操作结构体成员"这些事情就有了实感。再回头看C语言教材里的指针章节,会比原来清晰得多。这也是我经常对初学者说的一句话:先学C语言,再用数据结构这门课来"复习"C语言,效果比单纯刷C语言题好得多。
7. 学习路线建议与常见问题答疑
7.1 一道经典面试题:两个递增顺序表求交集
每次我辅导新手学到这里,都会给他们加一道经典题:给定两个递增有序的顺序表,求它们的交集(公共元素)。思路同样用双指针,但因为两个表都是有序的,所以不需要暴力双重循环。
具体做法是:两个下标同时走,谁小谁往前走;相等,记录这个值,同时两个下标一起走。因为有序,所以这个策略不会漏掉任何公共元素。时间复杂度是O(m+n),比O(m*n)的暴力解法高效很多。
void SeqList_Intersection(SeqList *a, SeqList *b, SeqList *result) { int i = 0, j = 0, k = 0; while (i < a->length && j < b->length) { if (a->data[i] < b->data[j]) { i++; } else if (a->data[i] > b->data[j]) { j++; } else { // 相等 result->data[k++] = a->data[i]; result->length = k; i++; j++; } } }这道题在面试中出现频率很高,核心考点不是代码量,而是你有没有意识到"利用有序性来减少比较次数"。刷题的时候多做几道这种题,对算法思维的形成很有帮助。
7.2 一个经常被问到的困惑:顺序表初始容量设多大?
很多同学会纠结INIT_CAPACITY这个值到底该取8、16还是100。答案是:没有标准答案,取决于你的应用场景。如果数据量通常很小,8或16就够了;如果可能很大,设一个更大的初始值可以减少扩容次数。真实的项目中,这个值往往是基于历史数据统计来定的。
重点不是初始值,而是扩容机制要正确。即使初始值设错了,只要扩容逻辑没问题,表最终也能正常工作,只是效率上会有微小的差别。所以初学阶段,设8还是16都可以,不用过度纠结。
7.3 学习顺序表的正确姿势:动手路线图
最后梳理一下我觉得最有效的学习路线。第一步,手写一遍完整的顺序表代码,包括初始化、插入、删除、查找、扩容、销毁,一个都不能少,不能只抄书上的,要自己敲出来。第二步,想办法"弄坏"它:试着插入越界位置、在空表上删除、连续插入触发多次扩容,观察会发生什么,为什么会这样。第三步,做几道综合练习,就是前面提到的去重、合并、求交集这类题。第四步,找一个数据结构可视化的网站,把顺序表的插入删除过程放慢看一遍,把"从后往前挪"变成脑子里的一幅画面。
整个过程下来,你对顺序表就不仅仅是"知道",而是"会用、会调、能说清楚"。这也是数据结构这门课第一个内容该有的掌握程度。这一步踩实了,后面的链表、栈、队列就是水到渠成的事了。