1. 为什么单向链表值得认真学——从存储原理说起
1.1 链表到底解决了什么问题
很多同学第一次接触数据结构,第一个被反复要求手写的结构就是单向链表。说实话,链表在工程里的出场率远低于数组和哈希表,但它依然是数据结构课程里绕不开的基石,也是面试和考研笔试的高频考点。为什么?因为链表用最直白的方式讲清楚了“指针”“内存布局”“增删改查的时间复杂度”这几个核心概念,而这些概念是所有复杂数据结构的地基。
先回到本质:计算机里存数据,无非两种组织方式——连续存储和离散存储。数组是前者,链表是后者。数组在内存里是一块连续空间,下标访问是O(1),但插入和删除往往要搬移元素,是O(n)。链表不一样,每个节点只存自己的数据和一个指向下一个节点的指针,节点散落在内存各处,靠指针串起来。所以链表的插入和删除在已知位置的前提下是O(1),但查找只能从头遍历,是O(n)。
这个时间复杂度差异,是理解所有后续数据结构的基础。你后面学树、图、跳表,本质上都是在“链”的基础上加约束、加跳转、加索引。如果链表这块的指针逻辑没理顺,后面看二叉树的左右孩子指针、图的邻接表,都会感觉像在雾里看花。
另外从实际教学场景看,链表也是练习C语言指针最好的项目。热词里有“数据结构c语言版”“数据结构实验报告”“数据结构与算法课程设计”,说明大量同学正在用C语言写链表实验。C语言里的指针操作,如果不亲手调几次链表,很难真正理解“值传递”和“引用传递”的区别。
1.2 单向链表的基本形态与节点设计
单向链表的核心就两个东西:节点结构体和头指针。节点结构体一般长这样:
c复制typedef struct Node {
int data;
struct Node *next;
} Node;
这是一个最简形态,data存的是int。实际工程中data可以是一个结构体、一个字符串、一个对象指针,但链表操作的逻辑完全不依赖data的具体类型,它只关心next指针。
理解这个结构要抓住一个关键点:struct Node里面有一个指向自己类型结构的指针。很多初学者第一次看到这种“自引用结构”会愣一下,其实它就是递归定义——一个节点知道下一个节点在哪里,下一个节点又知道下下个节点在哪里,这样就串成了一条线。
头指针是链表的入口,用一个Node *head变量保存第一个节点的地址。如果链表为空,head就是NULL。这个NULL判断是链表操作里最基础也最重要的边界条件,后面几乎所有函数都要先处理它。
链表节点定义好坏直接影响后面的代码复杂度。我在实际教学和写代码时,见过几种常见的节点设计变体,各有适用场景:
| 设计方式 | 特点 | 适用场景 |
|---|---|---|
| 普通节点指针 | 最常用,头指针即第一个节点 | 学习、面试手写 |
| 带头节点(哨兵节点) | 第一个节点只存指针不存有效数据 | 简化头插和删除逻辑 |
| 双向链表节点 | 增加prev指针 | 需要反向遍历 |
| 结构体包含长度字段 | 额外维护size | 频繁查询长度 |
其中“带头节点”这个设计值得多说一句。它的好处是让“空链表”和“非空链表”的操作逻辑统一起来,头插法和中间插入不用特判头指针是不是NULL。代价是多了一个不含数据的节点,浪费一点空间。很多教材默认用不带头节点的写法,因为更贴近链表定义本身,但做实验报告或工程代码时,带头节点往往更省心。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 单向链表的核心操作细节拆解
2.1 创建节点与初始化链表
链表操作的第一步永远是创建节点。一个规范的创建函数应该把“分配内存”和“设置初值”打包在一起,避免漏初始化:
c复制Node *createNode(int data) {
Node *newNode = (Node *)malloc(sizeof(Node));
if (newNode == NULL) {
printf("内存分配失败\n");
return NULL;
}
newNode->data = data;
newNode->next = NULL;
return newNode;
}
这里有两个容易踩的坑。
第一个是malloc之后没有检查返回值。虽然在小程序里malloc失败的概率很低,但在嵌入式或大内存申请场景下确实可能发生。养成检查NULL的习惯,是专业代码和课程作业的区别。当然,在初学阶段,很多老师写的示例代码不检查,但这不意味着你应该省略。
第二个坑是newNode->next忘记置NULL。如果创建一个节点后马上把它当尾节点用,而它的next没有被初始化,那后面遍历时就会越界,产生未定义行为。malloc返回的内存内容是随机的,不是默认为0。所以每次创建节点,必须显式把next置成NULL,这是链表所有操作的纪律。
初始化一个空链表,其实就是让头指针指向NULL:
c复制Node *head = NULL;
就这一行,没有别的。但这一行后面衍生出的所有麻烦,几乎都来自“忘了判断head是否为NULL”或者“在链表为空时误操作了head”。
2.2 头插法、尾插法与中间插入的实现要点
插入操作是链表里最核心的逻辑,也是最能体现“指针操作基本功”的地方。三种插入方式,我一个个说。
头插法:
c复制void insertAtHead(Node **head, int data) {
Node *newNode = createNode(data);
newNode->next = *head;
*head = newNode;
}
这里用了二级指针Node **head,原因是需要修改头指针本身。如果只传Node *head,你在函数里修改的只是形参副本,函数结束后外面的head并不会改变,链表就丢了。这是C语言新手最容易掉进去的陷阱:传指针并不能让函数改变指针本身的值,只能改变指针指向的内容。所以凡是需要修改头指针的场合,都要用二级指针,或者让函数返回新头指针。
头插法的好处是O(1)且实现简单。坏处是插入顺序和链表顺序相反。如果你依次头插1、2、3,最终链表是3、2、1。很多初学者第一次用头插法建链表,打印出来发现顺序反了,还以为自己写错了,其实这正是头插法的特性。
尾插法:
c复制void insertAtTail(Node **head, int data) {
Node *newNode = createNode(data);
if (*head == NULL) {
*head = newNode;
return;
}
Node *temp = *head;
while (temp->next != NULL) {
temp = temp->next;
}
temp->next = newNode;
}
尾插法保证了顺序和插入顺序一致,是日常使用最多的方式。缺点是每插入一次就要遍历到尾部,整体建链的时间复杂度是O(n²),节点多的时候性能很差。工程上的优化办法是维护一个尾指针tail,每次插入直接接在tail后面,再更新tail,这样把尾部插入降成O(1)。这个优化思路在后面的很多数据结构里都能看到——用额外空间换时间,维护一个“捷径”指针。
中间插入(在第pos个位置后面插入):
c复制int insertAfter(Node *node, int data) {
if (node == NULL) {
return -1;
}
Node *newNode = createNode(data);
newNode->next = node->next;
node->next = newNode;
return 0;
}
中间插入的关键是两行赋值顺序:先让新节点的next指向原节点的下一个节点,再让原节点的next指向新节点。顺序不能反,如果先执行node->next = newNode,原链表从node往后的部分就丢了,因为旧的下一个节点地址没有提前保存。
我用一个生活类比解释这个操作:像两个人手拉手排成一列,你要插到中间,必须先把你的手搭到后面那个人的手上,再让前面那个人放开他的手来牵你的另一只手。顺序错了,后面那个人就掉队找不到前一个人了。
2.3 删除操作:被很多人忽略的前驱指针
删除操作比插入更容易出错,因为它不仅要修改指针,还要释放内存。单链表删除的基本思路是:找到目标节点的前驱节点,让前驱的next指向目标节点的next,然后释放目标节点。
c复制int deleteNode(Node **head, int data) {
if (*head == NULL) {
return -1;
}
Node *toDelete = NULL;
if ((*head)->data == data) {
toDelete = *head;
*head = (*head)->next;
free(toDelete);
return 1;
}
Node *prev = *head;
Node *cur = (*head)->next;
while (cur != NULL) {
if (cur->data == data) {
prev->next = cur->next;
free(cur);
return 1;
}
prev = cur;
cur = cur->next;
}
return 0;
}
这段代码里有个特别容易忽略的点:要删除的是头节点的情况必须单独处理,因为此时没有“前驱节点”,修改的是头指针本身。如果统一按“找前驱”的逻辑写,头节点就删不掉,或者误用空指针。
另一个常见错误是free之后还在用那个指针。虽然C语言里调用free后指针变量仍然是原来那个地址,但它指向的内存已经被释放,任何访问都是未定义行为。严谨的写法是在free之后把指针置NULL,至少能避免后续误用时的“幽灵指针”问题。
还有一种删除思路是“只改数据不改指针”,也就是说删除节点node时,把node->next的data复制到node,然后删除node->next。这种trick在某些场景(比如只知道当前节点指针、不知道前驱的经典题)下有用,它把“删当前节点”变成了“删后继节点”,适用于“只给节点指针,不从头遍历”的限制。但工程上不推荐,因为它会让链表里实际存储的数据发生位移,可能破坏外部对节点地址的依赖。
2.4 查找、修改与遍历:别小看边界条件
查找是最简单的操作,但也最容易“想当然”:
c复制Node *findNode(Node *head, int data) {
Node *cur = head;
while (cur != NULL) {
if (cur->data == data) {
return cur;
}
cur = cur->next;
}
return NULL;
}
边界条件是:链表为NULL时不会崩溃;找不到目标时返回NULL。这两点看起来容易,但很多新手写着写着就写成while(cur->next != NULL),导致最后一个节点永远查不到。
遍历打印:
c复制void printList(Node *head) {
Node *cur = head;
while (cur != NULL) {
printf("%d ", cur->data);
cur = cur->next;
}
printf("\n");
}
遍历的时候千万不要在循环体里修改cur->next之外的东西,也不要误用head去做移动,否则你会把链表头指针弄丢。很多人为了省一个变量,直接用head遍历,打印完之后head变成了NULL,链表直接没了。所以遍历一定用临时变量,head作为唯一入口要始终保真。
查找和遍历的时间复杂度都是O(n),没什么好讲的。真正容易出问题的场景是“在查找的同时做删除或修改”,这时如果你只拿到了目标节点而没拿到它的前驱,单链表就束手无策了。这也是为什么实际代码里经常看到“快慢指针”“双指针”的变体——它们往往就是为了规避单链表无法回头的问题。
3. 手把手写一个可用的单向链表(C语言实现)
3.1 完整代码与关键函数
把上面的函数串起来,写一个可以实际运行的完整示例。这段代码覆盖了创建、插入、删除、查找、打印、销毁,可以在编译环境里直接跑:
c复制#include <stdio.h>
#include <stdlib.h>
typedef struct Node {
int data;
struct Node *next;
} Node;
Node *createNode(int data) {
Node *newNode = (Node *)malloc(sizeof(Node));
if (newNode == NULL) {
printf("内存分配失败\n");
return NULL;
}
newNode->data = data;
newNode->next = NULL;
return newNode;
}
void insertAtHead(Node **head, int data) {
Node *newNode = createNode(data);
if (newNode == NULL) return;
newNode->next = *head;
*head = newNode;
}
void insertAtTail(Node **head, int data) {
Node *newNode = createNode(data);
if (newNode == NULL) return;
if (*head == NULL) {
*head = newNode;
return;
}
Node *tail = *head;
while (tail->next != NULL) {
tail = tail->next;
}
tail->next = newNode;
}
void printList(Node *head) {
Node *cur = head;
while (cur != NULL) {
printf("%d -> ", cur->data);
cur = cur->next;
}
printf("NULL\n");
}
void freeList(Node *head) {
Node *cur = head;
while (cur != NULL) {
Node *next = cur->next;
free(cur);
cur = next;
}
}
int main() {
Node *head = NULL;
insertAtTail(&head, 1);
insertAtTail(&head, 2);
insertAtTail(&head, 3);
insertAtHead(&head, 0);
printList(head);
freeList(head);
return 0;
}
运行结果:
code复制0 -> 1 -> 2 -> 3 -> NULL
这段代码既是实验报告可以直接用的模板,也是理解链表操作的最小闭环。我特别想强调freeList的实现:它先用next保存cur->next,再free(cur),然后用next继续循环。如果直接free(cur)然后cur = cur->next,那cur->next在free之后已经不可访问了,属于典型的“悬垂指针”错误。这个细节写实验报告时经常被老师圈出来,务必重视。
3.2 需要注意的内存管理细节
C语言链表的内存管理是“成也free,败也free”。我总结几个项目里真正踩过的坑:
第一,不要重复释放。比如两个指针指向同一个节点,先后free两次,第二次是未定义行为,大概率会直接崩溃或者污染堆管理信息。解决办法是统一用freeList接口释放整条链,不要在主函数里手动再free一遍。
第二,释放节点的顺序要从头到尾,不能跳着释放。很多同学删掉一个节点后想“顺便把整条链都释放掉”,结果free了头节点,后面的节点找不到了,内存就泄漏了。
第三,不要忘记每个malloc都要配对free。写实验或小项目时内存泄漏很难被察觉,因为程序结束系统会回收,但如果你是在一个长期运行的进程里做链表操作(比如在服务端代码里),泄漏就非常致命。可以用valgrind这类工具检测,C语言内存错误的排查工具链里,valgrind是必学的。
第四,节点里的data如果是指针类型(比如指向字符串的char *),释放节点前要先释放data,否则字符串内存泄漏。这是很多“半项目”写挂的地方——节点结构体改了,但释放逻辑没有跟着改。
3.3 与Java/Python/Go实现的对比思考
现在学数据结构不一定只用C语言,很多学校也允许Java或Python交实验报告。从热词里能看到“java 数据结构详解”“go语言数据结构”“c++数据结构”,说明多语言对比是很多读者的实际需求。
Java里的链表思路和C差不多,但引用类型天然扮演了指针角色。定义一个内部节点类,next就是Node类型的引用。Java不用手动管理内存,但也要注意“对象引用”的持有问题,如果删除节点后还有其他引用指向它,GC不会立刻回收。链表操作的核心逻辑和边界条件是一样的。
Python实现链表时,很多人会直接用列表list代替——因为Python的list其实是动态数组,默认支持O(1)尾部插入,中间插入虽然也是O(n),但内置实现比自己写的链效率更高,代码也更简洁。所以学Python链表的现实意义更多是“理解概念”,而不是“优化性能”。下面是Python的简单实现:
python复制class Node:
def __init__(self, data):
self.data = data
self.next = None
class LinkedList:
def __init__(self):
self.head = None
def append(self, data):
new_node = Node(data)
if not self.head:
self.head = new_node
return
cur = self.head
while cur.next:
cur = cur.next
cur.next = new_node
def print_list(self):
cur = self.head
while cur:
print(cur.data, end=" -> ")
cur = cur.next
print("None")
Go语言里的链表更接近C语言的血统,因为Go有显式指针(*Node)。但Go的GC会自动回收不可达节点,不需要手动free,内存安全压力小很多。Go标准库container/list就提供了一套现成的双向链表实现,直接用就好。
对不同语言的选择,我的建议是:如果你在准备考研、复试或面试手写代码,请务必用C语言练熟。因为C语言能暴露所有指针细节,面试官考察的就是你对内存和指针的掌控力。如果只是做课程设计、小工具,Python或Go的可用性更高,写起来也更快。数据结构是思维层面的东西,语言只是载体,但载体选对了,学习效率完全不同。
4. 常见问题与排查技巧实录
4.1 段错误/空指针问题
链表代码最常见的崩溃就是段错误,十有八九是访问了NULL指针或游离的野指针。比如下面这段错误代码:
c复制Node *cur = head;
while (cur->next != NULL) {
printf("%d\n", cur->data);
cur = cur->next;
}
如果head本身是NULL,cur->next这一行直接就崩了。就算head不是NULL,这个循环也漏掉了最后一个节点,因为最后一个节点的next是NULL,循环就不会打印它。
排查段错误的思路是有套路的:
第一步,先确认崩溃位置。在关键函数前后加printf,或者用gdb打断点,定位是哪一行崩的。
第二步,检查所有解引用操作是不是都判定了NULL。尤其是遍历、插入、删除这几个函数。
第三步,检查malloc返回是否检查。极少见但确实有。
第四步,检查是不是重复释放了内存。这个用gdb可能看不出来,用valgrind立刻就能报出来。
4.2 死循环与指针丢失
死循环常见于构建环形链表。明明写的是单链表,打印时却永远不停,那说明链表里某个节点的next指回了前面的节点,形成了一个环。最常见的原因有两种:
一种是在尾插时忘了先判断head为NULL,导致第一个节点插入时把自己引用了自己。代码如果是newNode->next = head,而此时head还是NULL,那没问题。但如果之前的操作让head指向了一个未初始化next的节点,就可能恰好让next指向了自身,这属于典型的未定义行为经过“巧妙运气”造成的bug。
另一种是插入位置算错了offset,比如把新节点的next设为node,而不是node->next。这种错误在写“约瑟夫环”一类题目时非常容易发生,因为约瑟夫环本身就是循环链表,一旦指针串错,打印就会无限循环。
排查方法是:在遍历循环里加一个计数器,超过链表长度+10就break,先打断死循环,再观察是哪个节点的next指向了不该指的位置。
4.3 内存泄漏
内存泄漏不像崩溃那么直观,它不会马上报错,而是在反复执行插入、删除后,程序占用内存越来越大。C语言里最常见的泄漏就是“删节点只移指针不free”。
我自己带过很多学生做实验,最常见的写法是:
c复制cur = cur->next; // 直接把要删除的节点跳过,但没有free
跳过了,回头这个节点就永远找不到了,内存就泄了。排查方法就是valgrind:
bash复制valgrind --leak-check=full ./a.out
看到non-zero bytes allocated且没有被free,就能定位到是哪一行malloc出来的。valgrind的报错信息一开始看不懂很正常,关键是快速找到“block”和“by”行,那两行会提示是哪个函数申请的内存。经验值是:链表程序80%的valgrind报错都指向freeList漏了节点或者deleteNode没free。
4.4 常见面试/考试考点整理
从热词中的“数据结构考研”“王道数据结构”“数据结构期末复习”“数据结构题目”可以看出,链表是考试和面试的重灾区。整理几个高频考点,每个都是我能确定会反复看到的:
- 链表反转。这是面试手写题里出现频率最高的,没有之一。有递归和迭代两种写法,递归写法代码短但不好想,迭代写法利用prev、cur、next三个指针,逻辑清晰,建议优先掌握。
c复制Node *reverseList(Node *head) {
Node *prev = NULL;
Node *cur = head;
while (cur != NULL) {
Node *next = cur->next;
cur->next = prev;
prev = cur;
cur = next;
}
return prev;
}
-
找到链表中间节点。用快慢指针,快指针每次走两步,慢指针每次走一步,快指针到尾时慢指针正好在中间。这是链表题里最经典的“双指针”应用。
-
检测链表是否有环。也是快慢指针,如果快指针追上了慢指针,说明有环。确认有环后进一步找环的入口节点,思路是让一个指针从头走,另一个从相遇点走,二者再次相遇处就是环入口。这里涉及一个数学推导,面试官很吃这一套。
-
合并两个有序链表。可以用递归实现,也可以用迭代。核心是维护一个尾指针,每次取两者中较小的节点接上去。
-
删除倒数第N个节点。两种办法,一是先遍历算长度再删正数第len-N个,另一种是快指针先走N步,然后快慢一起走,快指针到尾时慢指针正好指向倒数第N个节点的前驱。后者更“优雅”也更常被问到。
这些题目都不难,但每道都能在核心细节上卡人。比如反转链表时如果不保存next指针,链表就从中间断了;检测环时如果不把快慢指针初始化好,一开始就相等,会误判成有环。我建议每个准备面试或考研的人,把这些题都用C语言手写三遍以上,直到闭着眼睛能写出来,再换到纸笔环境里默写一遍,那才是真掌握。
5. 单向链表在实际项目和笔试面试中的定位
5.1 工程中为什么要少用链表
这里说点学校老师不太会讲的实话:真实工程项目里,链表被用的频率远低于数组、哈希表和高阶数据结构。原因有几个。
第一,缓存局部性差。数组在内存里是连续的,CPU缓存加载一个字节会顺带加载附近的字节,数组遍历时命中率极高。链表节点散落各处,每次访问都可能要等内存加载,性能开销大。在追求吞吐量的服务端代码里,这个差异能被放大到几倍甚至几十倍。
第二,链表节点本身有额外内存开销。每个节点都要存一个next指针,在64位系统里就是8字节。如果数据本身只有4字节,链表比数组多了一倍内存开销。
第三,链表的随机访问能力差。数组直接用下标取第k个元素是O(1),链表要走到第k个是O(n)。虽然增删是优势,但很多实际需求是“读多写少”,数组明显更合适。
那链表在工程里什么时候是不可替代的?最典型的场景是:需要频繁在中间位置插入删除,且元素数量动态变化。比如操作系统的进程队列、内存管理里的空闲块链表、音频编辑软件的撤销历史,这些场景下链表确实比数组方便。另外Redis底层就大量使用了链表和跳跃表,所以热词里会出现“redis数据结构”。
5.2 面试中的经典变体题
面试官知道工程里单链表不常用,为什么还爱考?因为链表是考察“指针操作”和“边界思维”的最好载体。一道链表反转题,就能看出你对指针赋值顺序、边界条件、内存安全是否敏感。所以面试题的套路往往是:从一道基础链表面试题出发,逐步增加限制条件。
比如“反转链表”的升级版是“反转链表的一部分”,要求只反转从m到n之间的节点。这个题需要先找到第m-1个节点,再在中间做局部反转,最后把三段接起来。不仅考基本功,还考“不乱”的能力。
再比如“K个一组反转链表”,是LeetCode第25题,难度偏高。它的核心是递归分组:先反转前K个,递归处理剩余链表。实现里要用一个计数器找到第K个节点,如果不足K个就原样返回。这个题能很好地同时考察链表操作和递归思维。
还有“判断回文链表”,面试官会要求O(n)时间、O(1)空间。做法是先用快慢指针找中点,反转后半段,然后从两端交替比较。这又是快慢指针和反转的组合拳。
我比较推荐的刷题路线是:先死磕基础操作(建链、插入、删除、反转),再把双指针、递归、虚拟头节点这三个技巧练熟,最后再刷上面的变体题。千万不要直接刷难题目,链表的基础不扎实,刷一百道题也没什么效果,因为错误模型是同一个——指针串接的边界条件没掌握。
也可以聊聊虚拟头节点,也就是dummy node。比如“删除倒数第N个节点”这个题,用哑节点可以统一处理删除头节点的情况。我在面试手写时几乎每次都会先放一个dummy节点,它能让你少写至少一个if分支,减少出错概率。
6. 我自己的一些体会
6.1 学习链表最好的方式:画图
我教过的学生里,链表学得最吃力的人,往往是最不愿意画图的人。链表操作的本质是指针重连,纯靠脑补太容易出错。我自己的习惯是,在写代码之前先在纸上画出前后状态图:三个节点,标出next的指向前后变化,一步一画,再对着图写代码,一次就能写出没有低级错误的实现。
这里推荐一种画法:每个节点画成小方框,里面写data,下面写next的箭头。画插入操作时,用黄色标注新增的箭头,用删除线划掉旧的箭头。画完两步再对照代码,很难出错。
6.2 保持简单的一种写法规范
链表代码写多了以后,我总结了几个让代码更不容易错的习惯:
不管为不为空,都先创建新节点并且把next置NULL;
删除、插入时优先处理头节点/空链表的分支,不要让主逻辑去兼容特殊情况;
遍历时永远用临时cur变量,绝对不动head;
释放时先保存next,再free当前节点;
所有函数返回前,再想想是否有边界条件没覆盖。
这些习惯看起来极其基础,但它们能让你在面试高压环境下少出低级错误。面试手写链表时,最大的敌人不是思路,而是紧张状态下忘了处理某个NULL判断。
6.3 下一步往哪里延伸
如果你已经把单链表练得很熟了,下一步自然的方向是双向链表和循环链表。双向链表多了一个prev指针,让删除任意节点变得简单,代价是插入和删除时要多维护一个指针。循环链表让首尾相连,在“约瑟夫环”“轮转调度”等问题里特别有用。再往后就是跳表,它在链表上加了多级索引,把查找从O(n)优化到O(log n),是Redis有序集合的底层实现之一。
从一个最基础的单向链表出发,其实可以延伸到整个数据结构的半壁江山。这也是为什么我反复和学生强调:别嫌链表“太简单”“过时了”,把它吃透,你对指针、内存、边界条件的理解会上一个台阶,后面学什么都顺畅得多。
最后分享一个小技巧:如果你常写得一手链表bug,可以把常用操作封装成一组固定的函数模板,每次需要时直接复用,而不是现场重写。尤其在做大作业或课程设计时,一套稳定的链表操作库能帮你省下大量调bug的时间,把精力放到业务逻辑上。我自己写代码有个习惯,任何项目里用到链表,都先写好createNode、insertAtTail、deleteNode、freeList这四个函数,再加一个打印函数帮助调试,后面所有功能都在这套地基上搭建。这个习惯让我在写树、图乃至LRU缓存时都受益。
