我做了好几年技术面试官,看过大量候选人在“顺序表和链表”这道基础题上翻车。说实话,这两个结构单独拿出来,每个初学者都能说两句:顺序表是数组,链表是节点串起来的。但只要往深里一问,比如“为什么顺序表插入是O(n),但链表插入也是O(n)”、“ArrayList和LinkedList到底该怎么选”,大部分人就开始含糊了。这篇文章我想从一线开发者的视角,把这两个最基础的数据结构讲透,包括它们的底层原理、操作细节、性能边界,以及面试笔试里最常见的那些坑,希望能帮正在学数据结构、准备考研或者准备面试的朋友把这块地基打牢。
1. 为什么顺序表和链表是数据结构的“第一道分水岭”
数据结构这门课,喊了半天“程序 = 数据结构 + 算法”,但很多人学到后面连最基础的两个结构都没吃透。顺序表和链表,其实代表了两种完全不同的存储组织思路,一种是连续空间,一种是离散空间通过指针串联。理解了这两种思路,后面学栈、队列、树、图都会顺很多。
1.1 从内存视角看二者的本质差异
顺序表本质上就是数组,它在内存里开辟的是一块连续的区域。你可以把它想象成一排连在一起的储物柜,每个柜子有固定的编号,只要知道第一个柜子的地址,就能通过“基地址 + 下标 × 元素大小”直接算出任意一个柜子的位置。这就是随机访问能力的来源,时间复杂度O(1)。
链表则完全反着来,每个节点在内存里是分散的,节点之间靠指针(C/C++里叫指针,Java/Python里叫引用)连接。每个节点除了存数据,还额外存一个指针域,指向下一个节点。所以链表不能直接按下标访问,要找到第k个节点,必须从头节点开始一个next一个next地跳,时间复杂度O(n)。
我见过不少同学把这两者的区别简单归纳为“数组快,链表慢”,这其实很片面。它们各自有不可替代的优势,顺序表在随机访问上碾压链表,链表在已知节点位置的前提下做插入删除,又比顺序表便宜得多。真实开发中到底选哪个,取决于你的核心操作是“读”还是“写”。
1.2 教材、考试和实际开发为何都绕不开它
如果你去翻严蔚敏老师的《数据结构(C语言版)》,第一章绪论之后紧接着就是线性表,线性表的第一批实现就是顺序表和链表。王道考研数据结构系列、李春葆老师的教材、各类期末复习资料,也都把这一章当成整本书的地基。软考、考研408、各类面试题库,“顺序表和链表的区别”几乎是必考题。
工作中就更不用说了。做Java的同学天天用ArrayList和LinkedList,做C++的同学接触std::vector和std::list,做Python的用list和自定义链表类。理解了底层实现,你才能解释为什么ArrayList扩容会卡顿,为什么LinkedList在遍历时性能极差,为什么Redis的列表底层用了快速链表(quicklist)。这些性能问题,追根溯源都要回到这两个基础结构。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 顺序表:连续内存下的高效随机访问与扩容代价
顺序表的实现思路简单,但细节非常多,尤其是扩容逻辑、插入删除时的元素搬移,这些既是考试重点,也是实际编码中影响性能的关键。
2.1 顺序表的核心操作与静态/动态之分
先看定义。C语言静态顺序表通常是这么写的:
c复制#define MAXSIZE 100
typedef struct {
int data[MAXSIZE];
int length;
} SqList;
静态数组的问题是容量写死,存满了就没办法了。动态顺序表则用指针加动态内存分配,容量不够时可以扩容。
动态顺序表的结构一般是这样:
c复制typedef struct {
int *data;
int capacity; // 当前容量
int length; // 当前元素个数
} SeqList;
初始化时先分配一小块内存,比如capacity初始为4。当length到达capacity时,执行扩容操作:申请一块更大的内存(通常是原容量的两倍),把旧数据拷贝过去,释放旧空间。这个“两倍扩容”的策略很关键,如果每次只多扩一个位置,插入n个元素的时间复杂度会退化成O(n²);而倍增扩容,虽然单次扩容需要O(n)拷贝,但均摊到每次插入上只有O(1),这就是均摊分析的思想。
2.2 为什么顺序表插入删除必须搬移元素
顺序表在第i个位置插入元素时,需要把第i个及其后面的所有元素都往后挪一位,腾出位置给新元素;删除时则反过来,把后面的元素往前挪一位填补空缺。这个“搬移”操作是顺序表性能的核心瓶颈。
插入的关键代码:
c复制// 在位置pos(0-based)插入元素val
int SeqListInsert(SeqList *list, int pos, int val) {
if (pos < 0 || pos > list->length) return -1; // 位置合法性检查
if (list->length >= list->capacity) {
// 扩容,capacity翻倍
list->data = realloc(list->data, list->capacity * 2 * sizeof(int));
list->capacity *= 2;
}
for (int i = list->length; i > pos; i--) {
list->data[i] = list->data[i - 1]; // 从后往前搬移
}
list->data[pos] = val;
list->length++;
return 0;
}
注意搬移必须从后往前,先把最后一个元素往后挪,再挪倒数第二个,否则会覆盖掉后面的数据。这个细节在考试里经常以“写出插入算法”的形式出现,很多人一紧张就从前往后搬,写出来就是错的。
平均情况下,在长度为n的顺序表中插入一个元素,需要移动约n/2个元素,删除也是n/2,所以插入删除的平均时间复杂度是O(n)。而在末尾追加操作不需要搬移,是O(1),这就是为什么ArrayList的add()方法在大多数情况下很快,只有触发扩容时才慢一下。
2.3 扩容机制中容易踩的性能坑
扩容这个动作看起来普通,实际暗藏好几个坑。
第一个坑是扩容倍数选太小。如果选1.5倍,虽然省内存,但扩容次数更频繁,拷贝总量更大。Java的ArrayList默认扩容到1.5倍,C++的vector通常是2倍,各有取舍。1.5倍更省内存但扩容更频繁,2倍扩容次数少但可能浪费空间。实际开发里,如果预先知道数据量,最好直接调用ensureCapacity之类的接口,一次性把容量开够,避开扩容。
第二个坑是扩容时旧数据拷贝引发的“假死”。如果你的顺序表存的是几百万个元素的数组,每次翻倍扩容都要重新分配内存并拷贝,这个过程中程序会明显卡顿。解决方案有几种:提前预估容量、分块扩容、或者干脆改用链表。
第三个坑是缩容。很多同学只关注扩容,不考虑缩容,导致删掉大量元素后,底层数组仍然占着很大的内存。严格来说,当元素个数降到容量的四分之一以下时,可以考虑缩容到一半,避免内存浪费。但这个策略要谨慎,频繁的缩容和扩容会互相抖,反而更糟。业界常见的做法是不缩容或缩容阈值设得非常保守。
3. 链表:指针串联下的灵活插入与遍历之痛
链表的内容比顺序表多,单链表、双链表、循环链表,还有一个经典的风韵犹存的“静态链表”。初学的时候最让人头大的就是指针操作,尤其是插入和删除时指针的调整顺序,错一步就链表断开。我在下面把每种链表的核心操作都拆开讲,包括那些容易写错的顺序。
3.1 单链表:最简结构中的插入删除细节
单链表的节点定义:
c复制typedef struct Node {
int data;
struct Node *next;
} Node;
结构很简单,每个节点存数据域和指针域。但就是这样一个结构,涉及指针的操作非常容易出错。
先看头插法创建链表。所谓头插法,就是每次把新节点插到头节点之后,这样最后得到的链表顺序和输入顺序相反。这也是“链表逆序”的一种实现思路。
头插法代码:
c复制Node *CreateListByHead(int arr[], int n) {
Node *head = (Node*)malloc(sizeof(Node));
head->next = NULL; // 带头节点的空链表
for (int i = 0; i < n; i++) {
Node *s = (Node*)malloc(sizeof(Node));
s->data = arr[i];
s->next = head->next; // 先连后继
head->next = s; // 再改头节点指向
}
return head;
}
这里有一个很重要的点:先改新节点的next,再改前驱节点的next。如果反过来,先把head->next给s,再把s->next指向原head->next,就会导致后面的节点丢失。这个“换指针时必须先处理新节点的next,再处理前驱的next”的原则,在链表的所有插入操作中通用。
再看单链表的删除,删除节点p的后继节点:
c复制Node *q = p->next;
p->next = q->next;
free(q);
这里注意,你必须拿到要删除节点的前驱节点,才能进行删除操作。所以单链表删除的时间复杂度是O(n),因为它要先从头遍历找到前驱。而双链表不需要,因为它有前驱指针,可以直接改。
3.2 双链表与循环链表的额外价值
双链表的节点里多了一个prior指针,指向前驱节点。这样既可以顺着next向后遍历,也可以顺着prior向前遍历。删除节点时,不需要找前驱了,直接操作当前节点两边的指针即可。
双链表插入节点的核心代码:
c复制// 在节点p之后插入节点s
s->next = p->next;
s->prior = p;
if (p->next != NULL) {
p->next->prior = s;
}
p->next = s;
注意在双链表的“中间节点”插入时,p->next != NULL的判断不是必须的(如果p是尾节点,p->next是NULL,NULL->prior会崩)。很多初学者在这里漏掉判空,导致链尾插入时程序直接段错误。这个细节是考试和面试里反复出现的考点。
循环链表则把尾节点的next指向头节点,形成一个环。遍历的终止条件从p == NULL变成p == head。循环链表的好处是,从任意一个节点出发都能遍历整个链表,对于约瑟夫环这类问题非常自然。
还有一个概念叫“静态链表”,它用数组模拟链表的指针关系,数组每个元素中存一个“游标”来表示这个节点的后继在数组中的下标。C语言里如果你不想用指针(比如某些嵌入式环境下不允许动态内存分配),就可以用静态链表。严蔚敏教材在讲链表时把它作为补充内容,面试问“链表除了用指针还能怎么实现”时,能说出静态链表是个不错的加分点。
3.3 链表的遍历、逆序与中间节点的实践套路
链表遍历是最基本的操作,代码虽然简单,但在面试里经常和快慢指针、逆序等问题结合起来。
链表逆序是高频中的高频。我见过的最经典的逆序实现是迭代法,用三指针prev、cur、next维护遍历状态,代码长这样:
c复制Node *ReverseList(Node *head) {
Node *prev = NULL;
Node *cur = head;
while (cur != NULL) {
Node *next = cur->next; // 先保存next,防止断链
cur->next = prev; // 反转当前节点指针
prev = cur; // 前驱后移
cur = next; // 当前节点后移
}
return prev; // 新的头节点
}
核心思想就是每轮迭代把当前节点的next指向前驱,然后三个指针整体后移。Python单链表逆序的题也很多,思路和C语言完全一致,只是节点改用类来定义:
python复制class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
def reverse_list(head: ListNode) -> ListNode:
prev = None
cur = head
while cur:
next_node = cur.next
cur.next = prev
prev = cur
cur = next_node
return prev
除了逆序,快慢指针在链表题里简直万能:判断链表是否有环、找环入口、找链表的中间节点、找倒数第k个节点,全都可以用快慢指针解决。两个指针,一个每次走两步(快指针),一个每次走一步(慢指针),如果有环,它们最终会在环里相遇;如果找中间节点,快指针到末尾时慢指针正好在中间。这个思路必须熟练掌握,笔试面试出现的频率极高。
4. 顺序表与链表的全方位对比及选型逻辑
很多同学背得出“顺序表随机访问快、链表插入删除快”,但真正遇到场景还是不知道怎么选。这一节我把两者的对比维度全部列出来,再给出一些实际开发中的选型建议。
4.1 性能核心指标对比
这里先给出一张整体对比表,后面再逐条解释。
| 维度 | 顺序表 | 链表 |
|---|---|---|
| 底层存储 | 连续内存区域 | 非连续内存,节点间指针连接 |
| 随机访问 | O(1),直接按下标 | O(n),必须遍历 |
| 头部插入 | O(n),整体搬移 | O(1),改头指针 |
| 尾部插入 | O(1)平摊,可能触发扩容 | O(1),需维护尾指针 |
| 指定位置插入 | O(n),搬移元素 | O(1)已知前驱,O(n)需查找 |
| 删除指定节点 | O(n),搬移元素 | O(1)已知前驱/双链表,O(n)需查找 |
| 空间占用 | 紧凑,但可能有空闲容量 | 每个节点多一个指针域 |
| 缓存友好性 | 高,连续内存利于缓存 | 低,节点分散,易缓存缺失 |
| 扩容/缩容 | 需要搬移,可能卡顿 | 动态分配,无需搬移 |
| 逻辑复杂度 | 编码简单,调试容易 | 指针操作复杂,易出错 |
判断一个操作的时间复杂度,不能只看结构本身,还要看前提条件。比如“链表插入是O(1)”这句话,只有在已经给定前驱节点的前提下才成立。如果你要插入的位置是某个值,你仍然需要从头遍历找位置,那整体还是O(n)。面试时把这一点说清楚,比背结论能拿高分。
4.2 缓存局部性是如何影响真实性能的
这一点是很多教材和资料容易忽略的,但实际开发中影响特别大。
顺序表的底层是连续内存,当一个元素被访问时,它周围的内存区块会被一起载入CPU缓存,所以遍历顺序表时,绝大部分访问都能命中缓存,速度极快。链表则大不一样,每个节点散落在堆的不同角落,每次访问下一个节点都可能发生缓存缺失,导致CPU要重新从内存加载数据。这就是为什么即使理论上链表的插入是O(1),实际跑起来在一些场景下并不比顺序表快的原因。
我在一个项目里就遇到过这个问题:代码里用了一个LinkedList来存频繁增删的订单数据,结果遍历一次要几十毫秒,换成ArrayList后降到几毫秒。操作复杂度看起来一样是O(n),但常数系数差了几十倍,缓存局部性就是背后最大的影响因素。
4.3 实战选型:什么时候该选哪个
实际项目中,选型不是背口诀,而是问自己几个问题:
- 你的核心操作是随机访问多,还是插入删除多?
- 你操作的位置通常在哪?头部、尾部,还是随机位置?
- 你需要按索引访问吗?需要频繁遍历吗?
- 数据量大概多少?对内存占用敏感吗?
如果核心场景是“读多写少”,比如读用户的个人信息、读配置列表,用顺序表/ArrayList。随机访问O(1)的优势太明显了,开发效率也高。
如果核心场景是“频繁在头部或中间插入删除”,而且你有明确的插入位置,用链表。比如实现LRU缓存时,维护一个双向链表,你需要在O(1)时间内把某个节点移到头部,这种场景链表无可替代。
如果数据规模很大、需要频繁扩容,但你又希望减少内存再分配,可以混合策略:预分配足够的容量,或者用链表。实践中很多容器库其实采用了折中方案,比如Java的LinkedList、C++的list,以及Redis的quicklist这种“分段链表”,本质都是为了在内存连续性和插入删除灵活性之间做平衡。
5. 单链表的基本操作实验:从零搭建完整链路
我看到热搜里有“单链表的基本操作实验”和“数据结构实验报告”,说明很多人正在做这类实验。这一节我就按实验报告的逻辑,把单链表的建表、插入、删除、查找、遍历、逆序这些基本操作完整串一遍,代码可以直接抄,但建议你抄之前先理解每一步。
5.1 建表、遍历、查找的标准实现
先定义一个完整可用的带头节点的单链表结构:
c复制typedef struct Node {
int data;
struct Node *next;
} Node, *LinkList;
带头节点是整个链表结构的标准化做法,头节点不存数据,好处是插入删除第一个数据节点时,不需要单独处理头指针的变更,逻辑统一。这是严蔚敏教材特别强调的一个设计,也是考试题里“带头节点”和“不带头节点”区别的考点。
建表(尾插法,保持输入顺序):
c复制LinkList CreateListByTail(int arr[], int n) {
LinkList head = (LinkList)malloc(sizeof(Node));
head->next = NULL;
Node *tail = head; // 尾指针
for (int i = 0; i < n; i++) {
Node *s = (Node*)malloc(sizeof(Node));
s->data = arr[i];
s->next = NULL;
tail->next = s;
tail = s;
}
return head;
}
尾插法需要维护一个尾指针,每次把新节点挂在tail后面,然后更新tail。如果不维护尾指针,每次都从头遍历到末尾再插入,建表复杂度就变成O(n²)。
按值查找节点:
c复制Node *GetNodeByValue(LinkList head, int val) {
Node *p = head->next;
while (p != NULL && p->data != val) {
p = p->next;
}
return p; // 找到返回节点指针,没找到返回NULL
}
这段代码很短,但很能考察对链表边界的理解。循环条件是p != NULL && p->data != val,注意先后顺序不能反,如果写成p->data != val && p != NULL,在p为NULL时会直接访问空指针,崩溃。在实验报告里,可以专门写一个“边界情况分析”小节来说明为什么循环条件要这样写,会加分不少。
5.2 按位序插入、删除与整表删除的边界处理
按位序插入,在第i个位置插入元素:
c复制// 在第i个位置(1-based)插入元素val
int InsertAtPos(LinkList head, int i, int val) {
Node *p = head;
int j = 0;
while (p != NULL && j < i - 1) { // 找到第i-1个节点
p = p->next;
j++;
}
if (p == NULL) return -1; // 位置非法
Node *s = (Node*)malloc(sizeof(Node));
s->data = val;
s->next = p->next;
p->next = s;
return 0;
}
跳出while循环后,必须判断p是否为NULL。因为如果i大于链表长度+1,p会走到链表末尾变成NULL,这时候如果继续执行p->next操作,就段错误了。这个位置合法性检查是链表操作里最容易被忽略的地方。
按位序删除,删除第i个节点:
c复制int DeleteAtPos(LinkList head, int i) {
Node *p = head;
int j = 0;
while (p->next != NULL && j < i - 1) { // 找到第i-1个节点
p = p->next;
j++;
}
if (p->next == NULL) return -1; // 第i个节点不存在
Node *q = p->next;
p->next = q->next;
free(q);
return 0;
}
注意这里的while条件和插入不同,用的是p->next != NULL,原因是删除需要p的后继节点存在,如果p本身就是最后一个节点,就不可能有后继可删。这两个循环条件的微妙差异,我在批改实验报告时经常看到有同学写混。
整表删除:
c复制void DestroyList(LinkList head) {
Node *p = head;
while (p != NULL) {
Node *q = p->next;
free(p);
p = q;
}
}
链表实验最容易出现的内存泄漏,就是在反复创建链表后没有释放内存。C语言里没有垃圾回收,每次malloc出来的节点必须由你自己free。写实验报告时,把内存管理单独作为一个注意事项列出来,是一个亮点。
5.3 经典变式:逆序输出、合并有序链表、约瑟夫环
逆序输出有两种思路:一是先逆序再遍历,二是递归天然地栈式输出。递归的实现非常简洁:
c复制void PrintReverse(LinkList head) {
if (head == NULL) return;
PrintReverse(head->next);
printf("%d ", head->data);
}
递归的实质是利用系统栈,先递进到链表末尾,再逐步回溯打印。这个写法在考试里经常用来考“递归的思想”,但生产环境不建议对长链表用递归,因为它会导致栈溢出。
合并两个有序链表:
c复制LinkList MergeList(LinkList L1, LinkList L2) {
LinkList head = (LinkList)malloc(sizeof(Node));
Node *tail = head;
Node *p = L1->next;
Node *q = L2->next;
while (p != NULL && q != NULL) {
if (p->data <= q->data) {
tail->next = p;
p = p->next;
} else {
tail->next = q;
q = q->next;
}
tail = tail->next;
}
tail->next = (p != NULL) ? p : q; // 接上剩余部分
return head;
}
这个合并的过程不新建节点,只改变指针指向,空间复杂度O(1)。它是归并排序在链表上实现的基础,也是面试高频题。很多人第一次写会下意识地新建节点、把数据拷贝一遍,这样虽然也能得到正确结果,但空间复杂度变成了O(n),面试时会被追问“能不能优化”。
约瑟夫环问题(N个人围成一圈,从第1个开始报数,每次报到M的人出局),用循环单链表实现非常直观。核心逻辑是:遍历到报数为M的前一个节点,删掉它的后继节点,然后从下一个节点继续报数。注意报数的起点处理,以及只剩一个节点时的终止条件。
6. 高频笔试面试题:这些隐藏考点决定你能不能过
顺序表和链表在笔试面试中的出现频率非常高,而且很多题目,本质上是“披着算法外衣的链表基础操作”。这一节我挑几个最经典的来拆解。
6.1 题目一:判断链表是否有环,并找出环入口
这题的经典解是快慢指针。快指针每次走两步,慢指针每次走一步。如果链表无环,快指针会先到达NULL,结束。如果链表有环,快慢指针一定会在环内相遇。
找环入口还有一个数学推导:假设头节点到环入口的距离是a,环入口到相遇点的距离是b,环一周长度是c。慢指针走的路程是a+b,快指针走的路程是a+b+kc(快指针可能在环内绕了多圈才追上)。因为快指针速度是慢指针的两倍,所以:
2(a+b) = a+b+kc
=> a+b = kc
=> a = kc - b = (k-1)c + (c-b)
这个推导告诉我们一个结论:从相遇点出发,一个指针从头节点出发,两者每次都走一步,它们会在环入口相遇。这就是快慢指针找环入口的核心依据。
6.2 题目二:合并K个有序链表
合并两个有序链表很简单,但合并K个就复杂了。常见解法有三种:逐一合并(每两个合并一次)、分治合并、优先队列(堆)合并。优先队列的方案是,先把K个链表的头节点放进大小为K的最小堆,然后不断取堆顶节点,再将该节点的后继节点入堆。这样每个节点入堆出堆各一次,总复杂度O(N log K)。这道题考察的是“如何用最合适的数据结构来优化算法复杂度”,顺序表和链表作为最基础的结构,在这里是搭建更复杂算法的地基。
6.3 题目三:从尾到头打印链表节点值
这题有三种思路。第一种是上面说的递归,代码量最少。第二种是显式用栈,手动模拟递归的过程,适合对栈深度敏感的场景。第三种是利用顺序表的反向遍历,先遍历链表把节点值存入数组,再从尾到头打印。面试时你可以先说递归,再说“递归可能导致栈溢出,所以也可以用栈”,体现思考的全面性。
6.4 笔试中常见的顺序表算法题
顺序表的题目比链表更偏向“数组操作”。常见的有:原地删除有序数组中的重复元素、旋转数组、合并两个有序数组、反转数组、移动零。这些题的核心技巧都是双指针(快慢指针/左右指针)。
以“原地删除重复元素”为例,思路是:用一个慢指针维护结果数组的末尾位置,用一个快指针遍历整个数组,当遇到新值时,把值写到慢指针位置,然后慢指针前移。时间复杂度O(n),空间复杂度O(1)。写出来很简单,但重点考察的是“你是否理解数组连续存储下,如何通过覆盖而不是删除来达到目的”。这一点理解了,顺序表的本质就理解了。
6.5 八股文类问题怎么答得有条理
面试里最常见的问题是“ArrayList和LinkedList有什么区别”。多数人能答上“ArrayList基于数组,LinkedList基于链表”,然后就完了。想拿高分,建议从五个维度说:
- 底层结构:数组 vs 节点+指针。
- 随机访问:O(1) vs O(n)。
- 插入删除:数组按位置插入需要搬移,链表在已知节点位置时插入O(1)。
- 内存代价:数组有连续空间浪费,链表每个节点有指针开销。
- 缓存局部性:数组更友好,链表较差。
如果面试官再追问“那实际开发里怎么选”,就可以把前面讲到的场景决策那段搬出来,告诉他如果一个操作是遍历为主、读多写少,优先ArrayList;如果删除插入位置明确且操作频繁,而且不需要随机访问,才考虑LinkedList。甚至可以说,日常开发中绝大多数场景ArrayList就是最优选择,LinkedList的使用场景非常有限。
7. 从期末复习到考研面试:我的学习路径建议
现在网上的学习资料很多,王卓老师的PPT课件、王道考研的系列教材、严蔚敏老师的C语言版电子书、李春葆老师的教程,再加上各大OJ平台上的刷题题库,资料从来不缺,但很多人还是觉得数据结构难学。我的体会是,资料太多反而成为负担,关键是要有一条清晰的复习主线。
7.1 适合新手的复习路线图
如果你是完全零基础或者基础很薄弱,建议按这个顺序推进:
- 先看一份通识性的教程,把顺序表和链表的核心概念过一次。B站上王卓老师的课适合入门,讲得细、例子多,配合她的PPT课件看效率更高。
- 然后拿严蔚敏的教材(C语言版)精读线性表那一章,她的代码风格很老派,但逻辑严谨,是考研指定的参考标准。
- 跟着教材在本地IDE里把顺序表和单链表的每个操作都实现一遍,不要只抄代码,要理解每个指针操作的顺序和原因。
- 做实验报告。实验报告不是简单地贴代码,而是要把“功能需求→设计思路→核心代码→测试用例→结果分析→注意事项”完整写出来。写报告的过程其实就是重新梳理思路的过程。
- 到OJ平台刷题。LeetCode 203(移除链表元素)、206(反转链表)、141(环形链表)、21(合并两个有序链表)、83(删除排序链表中的重复元素),这些题都是链表入门必刷。
期末复习的时候,不要从头到尾翻课本,而是先看目录,把每章的知识点用自己的话写出来,再对比课本。我认识一个考上985研究生的学弟,他的做法是把每一章的代码题都自己默写一遍,不看书,写不出来就回头再学,直到能流畅默写为止。这个过程很笨,但效果极好。
7.2 两道自测题:检验你真的掌握了吗
学完这一章后,可以拿下面两道题自测一下。在不看任何参考资料的情况下,先自己写一遍。
第一题:给定一个单链表的头节点,判断它是否回文结构(例如1->2->3->2->1)。要求时间O(n),空间O(1)的解法。提示:用快慢指针找到中间节点,然后把后半段逆序,再从两端向中间对比。
第二题:实现一个顺序表,支持在任意位置插入和删除,但在最坏情况下,单次操作时间复杂度不能超过O(√n)。提示:分段数组(分块)的思想,块内直接搬移,块间调整索引。这道题不是考试要求,但非常锻炼设计能力。
这两道题,第一题是面试高频,第二题是进阶设计题。能把第一题独立做出来,说明链表的基本操作练得差不多了;能做第二题,说明你对顺序表的本质理解得很深了。做不出第二题也不用气馁,先把基础夯实,后面学完树和图再回头看。
我在实际学习数据结构的过程中最大的感受是,顺序表和链表就像数据结构的“任督二脉”,这章如果只是背概念,后面学树、图、哈希表的时候会遇到无数障碍。反而是老老实实把每个操作都自己实现一遍,哪怕慢一点,后面反而越学越快。如果你正在准备期末考试或者考研复试,建议多花一点时间在这一章上,把每个函数的边界条件、指针变换顺序弄清楚,这笔时间一定花得值。
