☰
用C语言入门数据结构:从指针、结构体到顺序表实现
2026/10/1 13:57:14 网站建设 项目流程

1. 为什么学数据结构,我仍然推荐从C语言开始

每年带新手入门的时候,总会有人问我同一个问题:现在Python、Java都这么方便,为什么还要用C语言学数据结构?我的回答通常只有一句:因为C语言能让你看到数据结构的“内部构造”,而其他语言帮你把这些构造藏起来了。

先说一个很现实的情况。打开各大招聘网站和考研大纲,数据结构这门课的考察语言十有八九是C或C++。浙大、清华这些学校的慕课,翁恺老师的C语言课,PTA平台上的题目,默认环境都是C语言。你可能会说“我用Python也能写出链表”,这话没错,但面试官让你手写一个链表反转的时候,考的是你对指针、内存、节点关系的理解,而不是Python里那行node.next = prev表面上的魔法。

更重要的是,数据结构这门课研究的本质是“数据在内存里怎么组织、怎么访问、怎么增删改查”。C语言提供的指针、结构体、动态内存分配,恰好就是这些操作的最小工具集。我用一个直白的类比:学数据结构就像学做饭,Python和Java是给你一个已经切好菜、配好料的半成品包,你按说明书下锅就行;C语言是直接给你一把刀、一块生肉,你得自己切、自己掌握火候。前者学会的是“会做这道菜”,后者学会的是“为什么这道菜要这么切、这么炒”。等你把C语言的数据结构吃透了,再去看Python里的列表、字典、Java里的ArrayList、HashMap,你会一眼看穿它们的底层实现,那种感觉完全不一样。

当然,C语言的缺点也很明显:没有自动内存管理、没有泛型、字符串处理麻烦、数组越界不报错。但恰恰因为这些“缺点”,你被迫去思考每一个节点在哪里分配、什么时候释放、下标从头到尾是怎么走的。这些思考才是数据结构课真正的考点——不只是“链表是什么”,而是“链表从哪来、怎么维护、怎么释放”。

这篇是系列的第一篇,我打算先聊聊学数据结构前需要补的C语言基础,然后带着你完整实现第一个数据结构——顺序表。后面几篇会依次覆盖链表、栈、队列、二叉树、图、常见排序和查找算法。每一篇都会给完整代码、逐段解释、踩坑记录,尽量做到你照着敲一遍就能跑、能改、能应付考试和面试。

2. 学数据结构前,C语言的三块关键拼图:指针、结构体、动态内存

很多同学学数据结构卡住,不是数据结构本身难,而是C语言基础还差“最后一公里”。你在数组里用a[i]用得很熟,但看到p->next就懵了,看到(SeqList*)malloc(sizeof(SeqList))就不知道在干什么。这很正常,因为我当年也是这么过来的。缺的其实就是三样东西:指针、结构体、动态内存。补上这三块拼图,数据结构的大门基本就打开了。

2.1 指针:看穿一切地址操作

指针的本质就是“存地址的变量”。听起来简单,但实际使用中有三个层次:声明指针、指针指向目标、通过指针操作目标。

int x = 10; int *p = &x; // p 存了 x 的地址 *p = 20; // 通过 p 修改 x 的值 printf("%d\n", x); // 输出 20

数据结构的代码里,指针最常出现在两个地方。一是“指向结构体的指针”,二是“指向指针的指针”。前者比如指向链表节点的指针,后者比如在函数里要修改头节点指针本身时使用的二级指针。

这里有个初学者最容易绕晕的点:p->next到底是什么意思?它等价于(*p).next,也就是“p 所指向的那个结构体里面的 next 成员”。你只要把p想成“一条绳”,把->想成“顺着绳子摸到那个盒子,再从盒子里拿出某个东西”,就不会乱了。考试的时候也经常会出这类题,我见过最多的错误就是把p->next写成p.next,因为没有搞清楚 p 是指针还是结构体变量。

2.2 结构体:数据类型的封装

结构体解决的是“把多个相关的数据绑在一起”的问题。比如描述一个学生,你需要学号、姓名、成绩,如果分开用三个数组,维护它们之间的关系就很痛苦。结构体可以直接打包:

typedef struct Student { int id; char name[20]; double score; } Student;

注意这里的typedef是很多教材容易略过但实战必须掌握的东西。没有typedef,你声明变量就要写成struct Student stu,有了typedef可以直接写Student stu,简洁很多。数据结构教材里的typedef struct { ... } SeqList;也是同理。

更关键的是,结构体里可以放指向自身类型的指针,这就构成了“自引用结构体”,链表节点的定义就是典型:

typedef struct Node { int data; struct Node *next; } Node;

这段定义你要能读懂:struct Node类型里面有一个next成员,它是指向另一个struct Node的指针。这样一个个节点通过next串起来,就形成了一条链。理解了这一点,你再看链表代码就不会有障碍。

2.3 动态内存:数据结构会生长的根源

数组一旦声明,大小就固定了。但实际场景里你不知道会有多少数据,所以需要运行时向系统申请内存,这就是malloc、calloc、realloc和free的用武之地。

int *arr = (int*)malloc(sizeof(int) * 10); if (arr == NULL) { printf("内存分配失败\n"); return -1; } // 使用 arr free(arr); // 用完必须释放

这里有两个考试和实际开发都高频出现的问题。第一个是分配之后一定要判断是否成功,malloc失败会返回NULL,不判断直接使用,程序会崩溃。第二个是free之后要把指针置为NULL,否则会变成野指针,后续如果误用它,就是一个特别难排查的 bug。数据结构里好多段错误(segment fault)都是这么来的。

2.4 三块拼图的合练:先写一个动态数组雏形

在正式写顺序表之前,我建议你先亲手把下面这个小程序敲一遍。它把指针、结构体、动态内存三样东西都串起来了:

#include <stdio.h> #include <stdlib.h> typedef struct { int *base; // 指针,指向动态分配的数组空间 int length; // 当前元素个数 int capacity; // 总容量 } DynamicArray; void init(DynamicArray *arr, int cap) { arr->base = (int*)malloc(sizeof(int) * cap); if (arr->base == NULL) { printf("分配失败\n"); exit(1); } arr->length = 0; arr->capacity = cap; } void add(DynamicArray *arr, int value) { if (arr->length >= arr->capacity) { // 容量不足就扩容,这里先用简单的两倍扩容 int newCap = arr->capacity * 2; int *newBase = (int*)realloc(arr->base, sizeof(int) * newCap); if (newBase == NULL) { printf("扩容失败\n"); return; } arr->base = newBase; arr->capacity = newCap; } arr->base[arr->length++] = value; } void destroy(DynamicArray *arr) { free(arr->base); arr->base = NULL; arr->length = 0; arr->capacity = 0; } int main() { DynamicArray arr; init(&arr, 4); for (int i = 1; i <= 10; i++) { add(&arr, i * i); } for (int i = 0; i < arr.length; i++) { printf("%d ", arr.base[i]); } printf("\n"); destroy(&arr); return 0; }

这段代码虽然没有封装成完整的数据结构,但你已经能感受到动态管理数据的味道了。接下来要实现的顺序表,就是在这个雏形上做更完整的封装。

3. 第一个数据结构就做顺序表:完整代码与逐段拆解

顺序表是线性表的一种存储方式,本质上就是“用数组存线性表”。为什么第一个数据结构选它?因为它的代码量小、逻辑直观、但涵盖了数据结构最核心的思维:逻辑结构、物理结构、操作的封装、边界条件的处理。学好了它,后面理解链表会轻松很多。

3.1 先搞清楚复杂度再动手

表结构涉及的几个基本操作,在顺序表里的时间复杂度如下:

操作顺序表时间复杂度说明
按下标访问O(1)内存连续,直接偏移计算
按值查找O(n)需要遍历比较
在末尾插入O(1)如果容量充足,直接写入
在中间/头部插入O(n)需要移动后续元素
删除末尾元素O(1)直接长度减一
删除中间/头部元素O(n)需要前移后续元素

你不需要背这个表,而是要理解“为什么会这样”。数组是连续内存,所以按下标访问是address = base + index * size,一步到位,所以是 O(1);插入要腾位置,后面的元素全部要往后挪,挪几个就取决于数据量,所以是 O(n)。理解了这个“为什么”,考试遇到“顺序表在第 i 个位置插入元素的平均移动次数”这类题,你也能推导出来。

3.2 完整可运行的代码

下面我给出一个完整的顺序表实现,实现了初始化、销毁、插入、删除、按值查找、按下标访问、修改、遍历打印几个基本操作。代码里加了必要的注释,你自己敲的时候可以去掉注释,强迫自己回忆每一步在干什么。

#include <stdio.h> #include <stdlib.h> #define LIST_INIT_SIZE 8 #define INCREMENT 4 typedef struct { int *data; // 动态数组指针 int length; // 当前长度 int capacity; // 当前容量 } SeqList; // 1. 初始化 void InitList(SeqList *L) { L->data = (int*)malloc(sizeof(int) * LIST_INIT_SIZE); if (L->data == NULL) { printf("内存分配失败\n"); exit(1); } L->length = 0; L->capacity = LIST_INIT_SIZE; } // 2. 销毁 void DestroyList(SeqList *L) { free(L->data); L->data = NULL; L->length = 0; L->capacity = 0; } // 3. 扩容(内部使用) void ExpandList(SeqList *L) { int newCapacity = L->capacity + INCREMENT; int *newData = (int*)realloc(L->data, sizeof(int) * newCapacity); if (newData == NULL) { printf("扩容失败\n"); return; } L->data = newData; L->capacity = newCapacity; } // 4. 在指定位置插入元素 // pos 从 1 开始计数,插入后元素位于第 pos 位 int ListInsert(SeqList *L, int pos, int value) { if (pos < 1 || pos > L->length + 1) { printf("插入位置不合法\n"); return 0; } if (L->length >= L->capacity) { ExpandList(L); } // 从最后一个元素开始,逐个后移 for (int i = L->length; i >= pos; i--) { L->data[i] = L->data[i - 1]; } L->data[pos - 1] = value; L->length++; return 1; } // 5. 删除指定位置的元素,并通过 target 返回被删除的值 int ListDelete(SeqList *L, int pos, int *target) { if (pos < 1 || pos > L->length) { printf("删除位置不合法\n"); return 0; } *target = L->data[pos - 1]; for (int i = pos; i < L->length; i++) { L->data[i - 1] = L->data[i]; } L->length--; return 1; } // 6. 按值查找,返回第一个匹配的位置,找不到返回 0 int LocateElem(SeqList *L, int value) { for (int i = 0; i < L->length; i++) { if (L->data[i] == value) { return i + 1; } } return 0; } // 7. 按下标访问,pos 从 1 开始 int GetElem(SeqList *L, int pos, int *target) { if (pos < 1 || pos > L->length) { printf("访问位置不合法\n"); return 0; } *target = L->data[pos - 1]; return 1; } // 8. 修改指定位置元素 int SetElem(SeqList *L, int pos, int value) { if (pos < 1 || pos > L->length) { printf("修改位置不合法\n"); return 0; } L->data[pos - 1] = value; return 1; } // 9. 打印所有元素 void PrintList(SeqList *L) { printf("当前顺序表:"); for (int i = 0; i < L->length; i++) { printf("%d ", L->data[i]); } printf("\n"); } int main() { SeqList L; InitList(&L); // 插入测试 ListInsert(&L, 1, 10); ListInsert(&L, 2, 20); ListInsert(&L, 2, 15); // 插到第二个位置,结果是 10 15 20 ListInsert(&L, 4, 30); PrintList(&L); // 删除测试 int delVal; ListDelete(&L, 2, &delVal); printf("删除了 %d\n", delVal); PrintList(&L); // 查找测试 int pos = LocateElem(&L, 20); if (pos) { printf("元素 20 在第 %d 位\n", pos); } else { printf("元素 20 不存在\n"); } // 修改测试 SetElem(&L, 1, 100); PrintList(&L); DestroyList(&L); return 0; }

这段代码编译运行后,输出结果应该是:

当前顺序表:10 15 20 30 删除了 15 当前顺序表:10 20 30 元素 20 在第 2 位 当前顺序表:100 20 30

3.3 逐函数拆解:为什么这样写

这里的每个函数设计都有讲究,我挑几个重点说。

插入操作的核心是一个反向遍历的循环:for (int i = L->length; i >= pos; i--) { L->data[i] = L->data[i - 1]; }。为什么从后往前移动?如果你从前往后移动,前面的元素一被覆盖就丢了。从后往前,先把最后一个元素挪到它后面的空闲位置,再逐个前移,数据就不会丢。这个思想在数组相关的所有算法里都会用到。

删除操作正好相反,是从前往后移动:for (int i = pos; i < L->length; i++) { L->data[i - 1] = L->data[i]; }。注意这里下标,删除第 pos 个元素,它的数组下标是 pos-1,把后面元素依次往前挪一格就行。末尾的元素会残留,但没关系,因为 length 已经减一,多出去的值不会参与后续操作。不少初学者会纠结“要不要把最后一个位置清零”,其实不需要,保持 length 的正确性就够了。

为什么删除要带回一个target指针?因为你删除元素的场景里,往往还需要知道被删的是什么。直接用指针参数把被删的值传出来,调用方就多了一条信息渠道。这也是 C 语言函数传参的一个典型模式——返回值表示操作是否成功,指针参数携带结果。这个模式在后面的二叉树、图、排序算法里会反复出现。

关于realloc扩容,我在这里用的是容量加固定增量INCREMENT。市面上教材里常见的是倍增策略,两倍扩容。两种方式各有优劣:倍增策略扩容次数少,均摊时间复杂度低;固定增量空间利用率高,但扩容频繁。考试一般问的是“每次扩容增加固定大小,n 次插入的均摊复杂度是多少”这类题,答案是 O(n)。如果你用倍增,均摊是 O(1)。但对于初学阶段,不用纠结到这个程度,用固定增量还能帮你理解“扩容是要付出代价的”这个事实。

4. 顺序表实现里的四个关键细节与常见坑

代码能跑通只是第一步,真正拉开差距的是对细节的理解。下面这四点是我在教学和看别人代码时最常见的坑和考点,单独拿出来说一下。

4.1 下标从 1 开始还是从 0 开始

这是一个让很多人精神分裂的问题。数组下标从 0 开始,这是 C 语言的规定;但逻辑上的“第 1 个元素”,考试题目里通常从 1 开始计数。于是插入位置 pos 是逻辑位置,访问数组时要用pos - 1。

我建议你养成一个固定习惯:写代码时,凡是在“逻辑位置”和“数组下标”之间转换的地方,单独写一行注释,例如// 第 pos 个元素在数组下标 pos-1。这个习惯能帮你少掉很多头发。PTA 和考研题里,也经常考查“在顺序表第 i 个位置插入元素,需要移动多少个元素”“第 i 个位置删除,需要移动多少个元素”这类推导题,你只要把逻辑位置和数组下标的关系理清楚,这些题就是纯数学。

4.2 realloc 扩容失败的后果与内存碎片

我见过不少同学的代码里,realloc返回值直接覆盖原指针:L->data = realloc(L->data, newSize);。这其实是个隐患。如果realloc失败,它返回NULL,同时原来分配的内存块仍然存在。如果你直接把这个NULL赋给L->data,原来的内存块就丢了——既访问不到,也没办法释放,内存泄漏就直接发生了。

所以正确写法一定是先赋给临时变量,判断成功后再更新原指针。我在上面的代码里就是这么写的。另外提一个概念叫“内存碎片”,频繁的小块扩容会导致堆内存产生很多不连续的空洞,虽然不影响编译,但程序长时间跑下来,性能会劣化。实际项目中如果要在顺序表和链表之间做选型,考虑内存碎片也是理由之一。

4.3 删除为什么要有返回值

可能有人觉得,删除操作直接L->length--不就行了,搞那么麻烦干什么。但你思考一个场景:你想删除顺序表里第一个值为 20 的元素,并且删除之后要做点别的处理。如果没有返回值,你删完之后还得再查一遍“被删的到底是不是 20”,这很蠢。有了指针参数带回删除值,一次操作同时得到了“位置”“是否成功”“被删的值”三份信息。

这个设计思路在后续栈和队列里也有体现。栈的pop操作、队列的出队操作,通常也都会带回被弹出的元素。哪怕你用 Java 里现成的ArrayList.remove,它也有返回值。数据结构课程不是教 API,而是在教“为什么 API 要设计成这个样子”。

4.4 传参时要用 &L 吗

在 main 里调用InitList(&L),传的是结构体的地址,为什么?因为 Init 内部要修改 L 的成员(data、length、capacity)。C 语言函数传参是值传递,如果你直接传L,函数里改的是副本,外面的 L 一点不变,而且你给L.data分配的内存地址也传不回去,后续全部失效。

有同学可能会问:那为什么不传二级指针SeqList **L?这取决于修改的层次。如果函数要改变结构体本身(比如让 L 指向一个全新的结构体),就需要二级指针;如果只是修改结构体内部的成员,一级指针就够了。我见过有些教材喜欢统一用二级指针,你的代码风格可以自己定,但要清楚两种写法的差别。考试里的简答题偶尔也会问“为什么这里要用指针参数”,能把“值传递导致副本失效”这句话答出来,就是得分点。

5. 关于学习路线:接下来几篇打算讲什么,以及两个建议

顺序表只是开胃菜。这个系列我大致规划了一下,后面会按这个顺序推进:

篇目主题核心内容
1顺序表动态数组实现线性表,插入、删除、查找
2单链表节点自引用、头插法尾插法、反转、合并
3双链表与循环链表前驱指针、循环终止条件
4栈顺序栈与链栈、括号匹配、表达式求值
5队列循环队列、链队列、约瑟夫问题
6二叉树先中后层序遍历、重建二叉树、BST
7图邻接矩阵与邻接表、DFS/BFS、最小生成树
8排序冒泡、快排、归并、堆排序实现与比较
9查找顺序查找、折半查找、哈希表

这个路线基本对应国内高校《数据结构(C语言版)》的章节安排,也覆盖了考研和专业课程的常考范围。

然后给两点建议。

第一,资料在精不在多。网上确实能搜到各种“数据结构 PDF”、谭浩强的 C 语言书、李春葆的习题集,你下载十个 PDF,不如把一本教材从头到尾敲完。我给新人的标配是:一本教材(随便哪本都行,关键是你能读进去)+ 一个在线刷题平台(PTA 或洛谷)+ 一个能跑 C 语言的环境。环境不用纠结,你电脑上装 VS Code 或者直接用虚拟机里的 Ubuntu 都可以,重点是马上开始写,不是花一星期折腾编辑器主题。

第二,每学一个数据结构,至少写一个“应用题”。比如学完栈,去写一个括号匹配;学完队列,去写一个约瑟夫环;学完顺序表,可以回头看看一些基础题,例如用stdio.h和limits.h解决 5×5 鞍点问题。鞍点问题本质上就是“找每行最大、每列最小”的位置,它不直接用顺序表,但锻炼的遍历和边界判断能力,和顺序表插入删除是完全同构的。你不一定非要去刷难题,但一定要把每个结构和至少一个现实问题联系起来,这样记忆会非常牢固。

最后说一点个人体会。CS 领域变化很快,但数据结构和 C 语言是少数的“不变项”。你后面学 C++、学 Python、学 Java,甚至去搞嵌入式、搞网络、搞操作系统,都会反复用到今天这篇文章里的概念。序列化地去理解指针、结构体、动态分配这三板斧,再加上每天半小时的敲码练习,数据结构真的没有想象中那么可怕。

我在实际教学里发现一个规律:先动手把代码敲一遍的人,学得永远比只看书的人快。所以,这篇文章看到这里,你该做的第一件事不是收藏,而是打开编辑器,把上面的顺序表代码完整地敲一遍,跑出那四行输出。敲完之后,试着把LIST_INIT_SIZE改小一点,比如改成 2,看看扩容触发时程序是怎么走的。这个动作虽然小,但比你看十遍理论都有用。

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

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

立即咨询