半夜一点,室友已经睡了,你还在和链表逆置较劲。这种场景我太熟了——数据结构这门课,几乎是每个计算机学生的第一道坎。基础概念看起来都能看懂,时间复杂度也会算,线性表听明白了,一写到代码就翻车。更别说链表和栈,考试前三天才开始背定义,面试被问到"用两个栈实现队列"直接愣住。今天这篇,我就把数据结构里最核心的几块——基础概念、时间复杂度、线性表、链表、栈——一次性讲透。不是照搬教材,而是按实战思路拆解:为什么要这么设计、代码怎么写不崩、面试和考试会怎么考。适合正在备考期末、准备考研408、刷算法题,或者刚学完C语言想进阶的朋友。
1. 基础概念:数据结构到底在解决什么问题
1.1 数据结构是什么:一张表格就是一个数据结构的雏形
很多教材开门见山给定义:数据结构是计算机存储、组织数据的方式。听完了,还是不知道学它干嘛。我换个说法。假设你手头有一个班五十个学生的成绩,要存起来,你会想到什么?最朴素的方式就是开一个数组,按下标存第一个人、第二个人……这就是数据结构。再进一步,你要按学号快速查找,可以给数组排好序;如果学生名单经常插入新同学,数组插到中间就得挪动后面所有数据,太慢了。于是你想想能不能用另一种存法。这个"怎么存、怎么取、怎么增删"的问题,就是数据结构要解决的核心问题。
数据结构的本质,是为数据之间的关系建模,并提供一套操作这些数据的方法。比如线性表表达的是"一个挨一个"的先后关系,树表达的是"一对多"的层次关系,图表达的是"多对多"的网状关系。每种关系都有各自适合的物理存储方式和操作算法。学数据结构,不是背一堆名词,而是要建立起"遇到一个需求,能选出合适的数据结构并写出正确操作代码"的能力。
1.2 逻辑结构和物理存储:一对容易被忽略的兄弟
这个概念学的时候容易跳过,但它是理解后面所有内容的钥匙。逻辑结构描述的是数据元素之间的抽象关系,与计算机无关。线性表、栈、队列、树、图,都是逻辑结构。物理存储描述的是这些逻辑关系在内存里怎么落地,主要有两种:顺序存储和链式存储。
顺序存储,就是把元素放在一片连续的内存空间中,逻辑上的"相邻"直接对应物理地址上的相邻。比如数组 a[0]、a[1]、a[2] 在内存里挨着放。链式存储,则不一定挨着,而是在每个元素节点里额外存一个指针,指向下一个元素的位置,用指针把散落在内存各处的节点串起来。同一个线性表,既可以用顺序表实现,也可以用链表实现,逻辑结构一样,物理实现不同。很多人学链表学不懂,就是没分清"逻辑上相邻"和"物理上相邻"这两个概念。链表在逻辑上依然是前一个节点后面跟着后一个节点,只不过物理上它们可能隔着十万八千里,全靠 next 指针找到对方。
1.3 抽象数据类型(ADT):把接口和实现分开
ADT(Abstract Data Type)的概念其实非常实用,它约定的是"这个数据结构能做什么",而不是"怎么做"。比如栈,逻辑上要求后进先出,对外提供 push 和 pop 两个操作,至于底层是用数组还是链表实现,调用者完全不关心。这个抽象的好处,在工程里体现得淋漓尽致:今天你用链表实现了栈,发现性能不好,明天改成动态数组,使用栈的那部分代码一行都不用改。
考试里经常让你"基于某种存储结构实现栈",其实就是考察你对ADT和物理存储关系的理解。我建议学习任何一个数据结构时,先问自己三个问题:它的逻辑结构是什么?它支持哪些基本操作?它的常见物理实现有哪几种?把这三个问题回答清楚,比抄十遍定义都管用。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 时间复杂度:算法好不好的度量尺
2.1 为什么先学时间复杂度:比的不是跑多快,而是怎么变
在学链表之前先讲时间复杂度,是因为后面每种操作的优劣必须要有个统一度量。很多人以为复杂度是"程序跑了多少秒",其实不对。同一份代码在不同机器上跑,速度天差地别——连十年前的老电脑和现在的 ARM 板子都能差出几十倍。时间复杂度关心的是一个更本质的问题:当输入规模 n 增大的时候,算法的操作次数会怎么增长。
比如查找一个元素,顺序表是数组,你直接按下标 a[i] 访问,不管 n 是100还是100万,都只要一次寻址,操作次数固定,是常数。链表呢,你只能从头节点开始一个一个往后走,n 越大,平均要走 n/2 个节点,操作次数和 n 成正比。这个"和 n 的关系"才是关键。因为大规模数据才是算法比拼的主战场,一万个数据可能都感觉不到差距,一千万个数据就能差出数量级。
2.2 大O表示法:忽略细节,抓住增长趋势
大O就是用来描述这种增长趋势的符号。它表示最坏情况下运行时间随 n 增长的上界,并且忽略常数和低阶项。比如某个算法实际执行了 3n² + 5n + 100 次操作,当 n 很大时,5n + 100 相比 3n² 根本不值一提,所以记为 O(n²)。
常见复杂度排序,从小到大:
O(1) < O(log n) < O(n) < O(n log n) < O(n²)
我用实际例子给这些复杂度配个场景:
- O(1):数组按下标访问元素。
- O(log n):二分查找。每次都砍掉一半,所以操作次数是 log₂n。
- O(n):单链表遍历一次。
- O(n log n):快速排序、归并排序的平均复杂度。
- O(n²):冒泡排序、嵌套两重循环。
学习大O时最容易犯的错,是把"实际花费时间短"和"复杂度低"混为一谈。一个 O(n) 的算法在 n 很小时可能确实比 O(n²) 慢,但复杂度看的是 n 变大的趋势。比如同样是遍历,链表 O(n) 但每个节点要跳指针,数组 O(n) 但顺序访问缓存友好,实际跑起来数组快得多,可两者复杂度级别一样。
2.3 手算复杂度的三个实用技巧
技巧一:找循环嵌套。有个程序片段:
c复制for (int i = 0; i < n; i++) {
for (int j = 0; j < i; j++) {
// 常数操作
}
}
外层跑 n 次,内层跑 i 次,总次数是 0 + 1 + 2 + ... + (n-1) = n(n-1)/2,所以复杂度 O(n²)。判断标准:最内层基本操作的执行次数。
技巧二:看递归。递归的复杂度通常和递归深度有关。比如遍历二叉树的递归,每个节点访问一次,深度最大是树高,但如果每个节点都递归调用两个子节点,那就是 O(n) 的访问次数。一个最简单的递归例子是斐波那契数列,直接递归两个分支,指数级 O(2ⁿ),因为太多重复计算。
技巧三:看数据范围猜复杂度。面试刷题有个很实用的经验:n ≤ 20 可能允许指数级;n ≤ 1e4 大概 O(n²);n ≤ 1e5 通常要求 O(n log n);n ≤ 1e7 基本只能 O(n)。这个经验不是严格的数学定理,但能帮你快速判断思路是否可行。
补充一个容易被忽略的点:空间复杂度。递归如果每层都开一个数组,还要看空间如何释放。比如归并排序需要 O(n) 的辅助数组;递归函数调用本身会把每一层栈帧压栈,递归深度就是空间复杂度的一部分。尤其是后面学栈的时候会再次碰到这个问题。
2.4 最好、最坏和平均情况别混着用
大O默认指最坏情况,但实际分析时你得分清。比如在数组末尾追加一个元素,最好 O(1),最坏(需要扩容并搬运所有数据)O(n),平均 O(1)。刷题时如果只说"均摊 O(1)",意思是大多数时候 O(1),偶尔 O(n) 但摊到每次操作上还是常数。动态数组、哈希表扩容都是经典的均摊分析场景。
学习建议:每学一个新数据结构,就把每个基本操作的时间复杂度列一张表,写下来。后面学链表和栈时,我也会给出这样的表。这个习惯能让你在考试和面试中快速反应。
3. 线性表:顺序表与链表的双生兄弟
3.1 线性表的定义:先有逻辑,再有实现
线性表是一组元素的有限序列,元素之间是一对一的线性关系。除了第一个元素没有前驱、最后一个没有后继,每个元素都有唯一的前驱和后继。用大白话说,就是一串元素排成一行,像排队买奶茶的队伍。
线性表的基本操作包括:获取长度、按位置访问、按值查找、在指定位置插入、删除指定位置元素、清空。这些操作是线性表的公共接口。至于底层怎么存,有两种典型实现,一种叫顺序表,一种叫链表。很多教材把顺序表和链表放在"线性表"这一章里,就是这个原因——它们都是线性表的物理存储实现。
3.2 顺序表:用数组实现的线性表
顺序存储的线性表就是顺序表,核心是一个数组,加上记录当前表长的变量。C语言里可以用动态数组表示:
c复制typedef struct {
int *data; // 指向堆上连续内存
int length; // 当前元素个数
int capacity; // 容量
} SeqList;
顺序表最大的优势是随机访问。想拿第 k 个元素,直接 data[k-1],O(1)。因为数组的地址连续,通过起始地址加偏移量一步就能定位。这也是为什么数组按下标访问那么快。
代价是插入和删除。在中间位置插入一个元素,需要把该位置及后面的元素全部往后移一位;删除则往前移,时间复杂度 O(n)。所以顺序表适合"查多改少"的场景。写插入代码时有个经典细节:从后往前循环移动元素,而不是从前往后。如果从前往后移动,后面的元素会把还没移动的元素覆盖掉。
3.3 链表登场:节点的游戏
链式存储的线性表叫链表。它不再需要一整块连续内存,每个节点包含数据域和指针域。单链表节点的 C 语言定义:
c复制typedef struct Node {
int data;
struct Node *next;
} Node;
创建链表时,每创建一个节点就需要给它分配一块独立的内存,再把前一个节点的 next 指向新节点。这样从第一个节点开始,沿着 next 指针走,就能遍历完整个链表。在已知某个节点位置的情况下,插入和删除确实可以做到 O(1)——只需要修改指针指向,不需要搬动数据。但这里有个关键前提:你要先找到这个位置。如果只给你一个值,让你把它插到链表中间,你得从头开始遍历 O(n) 才能找到插入位置。所以教材里会说"链表插入删除快",前提是已经持有前驱节点的指针。
3.4 顺序表 vs 链表:到底该用谁
很多初学者纠结:到底哪个好?答案是看场景。
| 对比维度 | 顺序表(数组) | 链表 |
|---|---|---|
| 内存空间 | 连续,可能需要预估容量 | 离散,按需分配,单个节点有指针开销 |
| 随机访问 | O(1) | O(n) |
| 已知位置的插入删除 | O(n)(需移动元素) | O(1)(改指针) |
| 按值查找 | O(n) | O(n) |
| 缓存性能 | 好,顺序访问友好 | 差,节点可能分散在内存各处 |
| 适用场景 | 频繁查询、数据规模确定 | 频繁增删、数据量不确定、内存碎片化严重 |
我实际工作中见过一个特别典型的案例:一个缓存组件,需要在头部频繁添加新数据、在尾部频繁淘汰旧数据,于是用了双向链表加哈希表的结构,也就是经典 LRU 缓存。如果用数组实现,头部插入要整体后移,性能会非常难看。反过来,存储一个固定长度的配置列表,比如一周七天的营业时间,数组远比链表合适,没有节点指针开销,访问也快。
顺序表和链表不是"一个先进一个落后"的关系,它们各有不可替代的优势。我建议刚开始学习时,把同一套线性表操作(插入、删除、查找、逆置)分别用顺序表和链表实现,体会两种实现方式在代码层面的差异。
4. 链表深入:从单链表到循环、双端与逆序
4.1 单链表核心操作:插入、删除、遍历
先给出单链表的基本结构。假设我们要在节点 p 后面插入一个新节点 newNode。顺序必须是先让 newNode->next = p->next,再把 p->next = newNode。顺序反了的话,先把 p->next 改成 newNode,后面的链表就找不到了,这就叫"断链"。
c复制// 在 p 节点后插入新节点
Node *newNode = (Node*)malloc(sizeof(Node));
newNode->data = value;
newNode->next = p->next; // 先接后面的
p->next = newNode; // 再连前驱
删除 p 后面的那个节点,则需要一个临时指针保存被删节点,再跳过它:
c复制Node *tmp = p->next;
p->next = tmp->next;
free(tmp);
这段代码看似简单,但它是链表最核心的基本功。插入删除翻来覆去就这两句话,难点全在边界条件:头节点、尾节点、空链表。
遍历就更基础了:
c复制Node *cur = head;
while (cur != NULL) {
// 访问 cur->data
cur = cur->next;
}
遍历时有个坑:如果你在循环体内释放当前节点,必须先把下一个节点指针存下来,否则 cur->next 已经失效。这个错误我见过无数回。
4.2 踩坑点:头节点、哨兵节点、内存释放
我总结三个链表题最常踩的坑,简直是新手重灾区。
第一个坑是"头节点要不要用哨兵节点"。很多教材喜欢用一个不存数据的头节点 head,好处是插入删除操作不需要特殊处理空链表和头部情况。如果你不用哨兵节点,在头部插入时就要单独判断 if (head == NULL) 或者用二级指针 &head。考试时建议直接用哨兵节点,代码更统一,不容易出错。
第二个坑是"遍历条件 p != NULL 还是 p->next != NULL"。想找最后一个节点,用 while (p->next != NULL) p = p->next;,跳出循环时 p 指向最后一个节点。想遍历所有节点,用 while (p != NULL)。这两个条件混用,容易少处理一个节点或者空指针访问。
第三个坑是内存释放。C 语言手动管理链表,删除节点后必须 free,否则内存泄漏。反过来也别在 free 之后去访问 tmp->data,那是悬空指针。Java、Python 这类有自动回收的语言没这个问题,但逻辑错误照样会有。
4.3 进阶:循环单链表、双端队列与嵌入式链表
循环单链表就是把尾节点的 next 不再指向 NULL,而是指向头节点,整个链表形成一个环。它适合处理周期性的数据,比如轮询调度、约瑟夫环问题、操作系统的进程轮转调度。遍历时终止条件从 p != NULL 变成 p != head 或者固定计数一次。循环链表的好处是任何一个节点出发都能遍历全部节点,坏处是如果写错终止条件容易无限循环。
双端队列(deque)也是热词里反复出现的概念。它允许两端都能插入删除,可以用双向链表实现,每个节点除了 next 还有一个 prev。实现时每端操作都是 O(1),非常灵活。Java 里的 ArrayDeque 和 LinkedList 都可以当双端队列用,但前者基于数组,后者基于链表。
嵌入式领域还有一种特殊的链表用法,典型代表是 Linux 内核的 list_head。它的节点里不直接存数据域,而是把链表节点指针内嵌到结构体里,通过宏找到宿主结构体的地址。这种实现可以容纳任意数据类型的节点,属于非常黑魔法的高级用法。初学者不需要精通,但至少要知道链表不是只能写成 struct Node { int data; Node* next; } 这一种样子。面试时如果你能说出这种设计,往往会给面试官留下好印象。
4.4 链表逆序:面试必考的头号算法
单链表逆置是数据结构面试和考试出现频率最高的问题之一。它考察的就是你对指针操作的熟练程度。迭代思路很清晰:准备两个指针 prev 初始为 NULL,cur 初始为 head,每轮用一个临时指针保存 cur->next,然后把 cur->next 指向 prev,再整体往后移动。
c复制Node* reverseList(Node* head) {
Node *prev = NULL, *cur = head;
while (cur != NULL) {
Node *next = cur->next; // 先保存后继
cur->next = prev; // 反转当前指针
prev = cur;
cur = next;
}
return prev; // 新的头
}
这道题有几个经典变体:K 个一组反转链表、反转从第 m 到第 n 个节点、带头节点的链表逆置。核心思想都一样,就是理清楚每次反转哪几个指针。我也见过用递归写的版本,代码非常短,但递归深度就是链表的长度,链表过长可能栈溢出。所以工程上我更推荐迭代写法。
5. 栈:后进先出的世界
5.1 栈的机制与基本实现
栈是一种操作受限的线性表,只允许在一端进行插入和删除操作,这一端叫栈顶。后进去的元素先出来,这就是 LIFO(Last In First Out)。可以把它想象成一摞盘子,你总是只能从最上面取盘子和放盘子,想拿最底下的,必须先把上面所有盘子搬走。
栈的底层实现有两种,数组栈和链表栈。数组栈需要一个栈顶指针 top,入栈时 data[top++] = x,出栈时 x = data[--top]。链表栈则直接把头节点当栈顶,插入删除都是 O(1)。由于栈的操作都集中在栈顶,所以不管哪种实现,入栈出栈都是 O(1),这点比队列简单。
c复制typedef struct {
int data[MAXSIZE];
int top;
} Stack;
void push(Stack *s, int x) {
s->data[s->top++] = x;
}
int pop(Stack *s) {
return s->data[--s->top];
}
注意:入栈前检查栈是否已满,出栈前检查栈是否为空。这是栈代码最容易崩的两个地方。Java 里 Stack 类本身就是基于动态数组实现的,而 Python 的 list 直接可以当栈用,append 入栈,pop() 出栈。
5.2 经典应用:括号匹配、表达式求值
学栈最重要的是理解它为什么会存在。栈天生适合处理"嵌套结构"和"对称结构"。
括号匹配是初学者必须手写一遍的题目:遇到左括号就压栈,遇到右括号就弹出栈顶,检查是否匹配;最后如果栈正好为空,说明括号完全匹配。比如字符串 ([{}]),每个左括号入栈,对应的右括号弹出,全部匹配成功。要是出现 ([)] 这种交叉嵌套,就会在某个右括号检查时发现栈顶不是对应的左括号。用栈来处理这种嵌套结构,是因为它天然保留了"最近未匹配"的信息,弹出的永远是最新压进去的那个。
表达式求值是栈的另一个经典场景。中缀表达式转后缀表达式、后缀表达式求值,都需要栈。求后缀表达式时,遇到数字压栈,遇到运算符则弹出两个操作数计算,结果再压回栈。这个过程中栈里始终保存着"尚未参与运算的操作数",非常好用。编译器在解析表达式时,本质上就是在做类似的事。
5.3 栈回溯(backtrace)、函数调用栈与中断栈帧
热词里有"backtrace栈回溯"和"中断栈针",这两个词其实就是栈在实际系统中的两个体现。先看函数调用栈。程序执行时,每个函数调用都会创建一个栈帧,里面存放函数参数、局部变量、返回地址。函数执行完毕,栈帧出栈,回到调用处继续执行。这种递归调用一层套一层,正好利用了栈的 LIFO 特性。
"栈回溯"指的是通过读取调用栈上的栈帧信息,还原出当前代码的调用链。比如 gdb 里的 bt 命令,或者核心转储文件中打印的调用栈,都是栈回溯。你写 C 程序遇到段错误时,最希望看到的就是崩溃点在哪个函数、谁调用了它、一直追溯到 main。面试或考试中可能会问"函数调用为什么用栈",答案就是:函数调用天然具有"后调用先返回"的嵌套结构,栈这种后进先出的结构完美匹配。
"中断栈针"其实是指中断处理时的栈切换与栈帧保存。操作系统在处理中断时,需要保存当前任务的现场(寄存器、返回地址),然后切换到一个专门的中断栈去执行中断处理程序,处理完再恢复现场。这里的"现场保存"和"栈帧切换"也是一个典型的栈过程。学习时不必纠结细节,但要理解:数据结构里的栈是抽象的逻辑结构,函数调用栈和中断栈是这种抽象在硬件/系统软件中的具体落地。
5.4 单调栈:栈的一类高效算法应用
单调栈是栈在算法题中的"高难度应用",热词里"单调栈揭秘"就是这个。单调栈的核心是:始终保持栈内元素单调递增或递减。它常用来解决"找下一个更大/更小元素"的问题。
举个例子,给定数组 [2, 1, 5, 6, 2, 3],求每个元素右边第一个比它大的元素。暴力方法对每个元素向右扫描,时间复杂度 O(n²)。单调栈的做法是 O(n):从左到右遍历,维护一个单调递减栈(栈底最大,栈顶最小),当遇到一个新元素,把栈中所有比它小的元素弹出,弹出的这些元素右边第一个比它大的元素,就是当前这个新元素。
python复制def next_greater_element(nums):
n = len(nums)
result = [-1] * n
stack = []
for i in range(n):
while stack and nums[stack[-1]] < nums[i]:
idx = stack.pop()
result[idx] = nums[i]
stack.append(i)
return result
栈里存的是下标,不是值,因为最后要在结果数组里定位。当弹出栈顶元素时,说明它的"右边第一个更大值"出现了,直接填入当前值。整个过程每个元素最多进栈一次、出栈一次,所以 O(n)。理解了单调栈,你再去刷"柱状图中的最大矩形""接雨水"这些经典题,会顺手很多。
5.5 栈和堆:别被两个"栈"搞晕
很多人学到这里会问:数据结构里的栈,和内存里的堆栈,是不是一回事?不是,但它们有联系。数据结构里的栈是一种逻辑结构,强调后进先出。内存中的"栈区"是一种运行时内存区域,存放函数调用栈帧,也是按后进先出管理。内存中的"堆区"则是动态分配内存的地方,和数据结构里的"堆"(完全二叉树)根本没有关系,纯粹是历史叫法撞名了。
一句话总结:函数调用栈是数据结构栈的一个实际应用;内存堆则是完全不同的存储区域。C 语言里 malloc 分配在堆区,局部变量在栈区;Java 里 new 出来的对象在堆区,局部引用在栈区。面试时候把这两个概念分清楚,就不会闹笑话。
6. 常见问题与实战经验速查
6.1 初学者的六大误区
我自己教过不少学生,也带过几个实习生。常见的坑其实非常集中。
- 只背概念不写代码。数据结构是动手课,不是背诵课。看十遍链表插入不如亲手写一遍。
- 忽略边界条件。单链表只有一个节点怎么办?空链表插入怎么办?栈满栈空怎么办?程序崩不崩全看这些。
- 复杂度算错。比如把平均情况当成最坏情况,或者忽略了递归的空间开销。
- 混淆栈和堆。我在 5.5 里强调过,考试常考。
- 断链问题。链表插入时没先保存后继,代码一跑就丢数据。
- 不调试,靠猜。链表问题完全可以通过打印节点地址来排查,别直接删代码重写。
6.2 面试和考试的高频考点
结合我看到的题型,最常考的几个点:
- 单链表逆置(迭代和递归两种写法都要会)
- 判断单链表是否有环,找环入口
- 合并两个有序链表
- 用两个栈实现队列(或者反过来用队列实现栈)
- 栈的括号匹配
- 单调栈求柱子最大矩形面积
- 循环链表的遍历和插入操作
- 顺序表与链表的复杂度对比
刷题建议:LeetCode 上对应的题目按"链表专项"和"栈专项"做一遍。不用贪多,每个类型做 10 道左右,套路就熟了。纯手写代码的环境下,一定要先把单链表插入删除逆置这几个基本操作练到肌肉记忆。
6.3 我的学习路线与资源推荐
如果你是零基础,我的建议是按这个顺序走:
第一步,用 C 语言把顺序表的所有操作手写一遍。重点训练对数组下标和边界条件的掌控。第二步,把同样的操作改成单链表实现,特别注意指针处理和内存释放。第三步,写一个数组栈和一个链表栈,用栈做一次括号匹配。第四步,刷几道链表和栈的简单题,比如反转链表、有效的括号。第五步,再回头看书上关于时间和空间复杂度的分析,把刚才写过的每个操作对应到大O级别。
书的话,《大话数据结构》适合快速入门,语言生动,例子多;考研的话《王道考研数据结构》更应试,每个知识点都标注了考点。算法题用 LeetCode 的"探索"模块就够了,注意从简单到中等逐步推进。如果学校的在线评测平台有数据结构实验题,比如"单链表的基本操作实验""基于链表的两个集合的差集",也建议认真做一遍,这些实验题往往就是期末考试题。
最后再分享一个小技巧:学链表时,强烈建议在纸上画节点图。每个节点画成两个格子,一个放数据,一个放指针,然后模拟 p->next = q 之后箭头怎么变。画几次以后,指针操作基本就不会错了。这个办法听起来土,但比网上那些动图有效得多。我自己带过的学生,凡是动手画过的,链表正确率明显高一截。
根据我个人经验,数据结构入门最大的障碍不是智商,而是"看得多写得少"。每天写一个小时链表代码,坚持一周,效果比考前突击强十倍。等你把链表逆置和括号匹配写到闭着眼都能出代码,后面的树和图就顺理成章了。
