今天是学习数据结构的 Day1。早上翻完教材前二十页,第一反应是“这些东西我以后写业务代码真的能用上吗”,后来跟着网课手写了一遍链表增删改查,才明白所谓数据结构,本质就是搞清楚“数据怎么摆、怎么取、怎么改”,而链表正好是入门最直观的模型。这篇笔记不是教科书复读,是我自己踩过坑之后整理出来的完整记录:从基础概念慢慢过渡到单链表、双向链表、循环链表,重点放链表增删改查的代码实现和边界处理,顺带加了不少调试心得。适合刚学 C 语言、指针基础还行的同学,如果你是零基础,先把指针和结构体过一遍再来看会舒服很多。
1. 数据结构到底是什么,以及为什么从链表开始
1.1 数据结构的定义和我最初的误解
我一开始听到“数据结构”这四个字,下意识以为是某种高级数据库。后来才知道,数据结构研究的是数据在计算机中如何组织、存储和处理。往小了说,一个班级的学生信息用数组存还是用链表存,是结构问题;往大了说,一个网页的菜单层级、一个排队系统、一个撤销操作栈,背后都是数据结构在撑腰。
很多教材一上来就甩定义:“数据结构是相互之间存在一种或多种特定关系的数据元素的集合。”这话严谨但劝退。我自己更喜欢的说法是:数据结构就是给数据定规矩、定关系——谁在前谁在后,谁包含谁,谁能找到谁。规矩定好了,增删改查才有稳定的路子可走。
那为什么先从链表开始?因为链表几乎把所有核心概念都串在了一起:结构体、指针、动态内存分配、NULL 的处理、边界条件。学会单链表的增删改查,后面学栈、队列、树都会顺畅很多。这些结构本质上就是在链表或数组上套了一层约束规则。
1.2 逻辑结构与物理结构
这是第一天里最容易被忽略、但对理解链表至关重要的一节。
逻辑结构描述的是数据之间的抽象关系,跟计算机怎么存没关系。常见的逻辑结构包括:
- 集合:数据元素之间没有明确关系,就像一堆散装零件。
- 线性结构:一对一的关系,就像排队打饭,每个人只有一个前驱和一个后继。
- 树形结构:一对多的关系,就像公司组织架构,一个领导管很多下属。
- 图形结构:多对多的关系,就像社交网络,每个人都有很多朋友。
物理结构指的是数据在内存里真实存在的方式,更严谨的叫法是存储结构。常用的两种:
- 顺序存储:把数据一个接一个放在连续的内存单元格里,数组就是典型。
- 链式存储:数据分散存放,靠指针把各个节点串起来,链表就是典型。
理解了这两个概念,你会立刻发现一个关键点:同一个逻辑结构,可以用不同的物理结构实现。比如栈这个逻辑结构,用数组实现叫顺序栈,用链表实现叫链式栈,行为都一样(后进先出),只是内部存储方式不同。链表学习的价值,很大程度上就是让你体会“逻辑”和“物理”之间的这种解耦。
1.3 抽象数据类型:给数据和操作打包
抽象数据类型,英文叫 ADT(Abstract Data Type)。听起来吓人,其实理解起来很简单:它就是把数据和对数据的操作打包在一起,形成一个不可分割的整体。
我举个例子。你办了一张健身房的会员卡,卡上存了你的姓名、剩余次数、过期时间,这是“数据”。前台能帮你查剩余次数、扣次、延期,这些是“操作”。那你拿着这张卡去任何一家分店,只要报卡号,店员都能按照标准流程帮你处理,你不需要关心他们的数据库长什么样。这个“卡+操作”的完整定义,就是一个 ADT。
对应到 C 语言里,我们通常用一个结构体存放数据,再用一组操作这个结构体的函数来表达 ADT。链表的学习就是从定义节点的结构体开始,逐步补充创建、插入、删除、查找、修改这些操作函数。这也是为什么链表题目几乎都围绕“结构体定义 + 几个函数”来展开,因为它天然契合 ADT 的思想。
2. 链表核心概念:节点、头结点、哨兵位
2.1 节点的本质:数据域 + 指针域
链表的基本单位是节点,每个节点在 C 语言里通常用一个结构体来定义:
typedef struct Node { int data; // 数据域,这里假设存整数,实际可以是任意类型 struct Node *next; // 指针域,指向下一个节点 } Node;理解这段代码,关键是理解next到底是什么:它是一个指针,存的是下一个节点的内存地址,不是下一个节点本身。如果用一句话描述链表,那就是“通过一系列分散的节点,用指针把它们按顺序拴在一起”。你顺着一个节点的next跳到下一个节点,再顺着跳下去,就完成了一次完整的遍历。
那链表最后一个节点怎么办?把next设为NULL。这个NULL是链表里最重要的东西之一,既是终止标志,也是很多越界问题的根源。你写遍历循环的时候,条件几乎都是while (p != NULL);但如果你粗心把某个节点的next忘了置空,程序就会一直往下走,直到访问到非法地址,直接段错误。
2.2 头指针、头结点和哨兵位的关系
很多新手会被“头指针”和“头结点”这两个词绕晕。我分清楚他们之后,代码逻辑一下子清晰了。
- 头指针指向链表的第一个节点。它本身是一个指针变量,可能是一个普通的局部变量。
- 头结点是链表第一个节点之前额外加的一个节点,它的
data通常不用,next指向真正的首元节点。
为什么要加一个不存数据的头结点?这是链表设计里非常巧妙的一笔。有了头结点,空表的判断从头指针是否等于 NULL,变成了头结点的 next 是否为 NULL;插入和删除首元节点时,也不需要单独写一份特殊逻辑。也就是说,头结点的存在让“第一个位置”和“中间位置”的操作统一起来了,代码写起来少了一堆 if。
哨兵位的概念在有些算法题里也会出现,比如虚拟头节点dummy。其实和头结点是一个套路的变形:在操作过程中临时加一个辅助节点,避免对边界做特殊处理。做题时如果发现插入删除需要大量判断首尾,先想想能不能用哨兵位简化。
2.3 链表和数组,谁强谁弱不能只看一面
学链表最怕的就是变成“逢数组就踩、逢链表就吹”。实际上,链表和数组各有各的适用场景,Day1 就应该把它们的优缺点摆在一张表里看清楚。
| 对比维度 | 数组 | 链表 |
|---|---|---|
| 内存空间 | 连续,大小固定 | 分散,大小动态 |
| 随机访问 | O(1),直接按下标取 | O(n),必须从头遍历 |
| 头部插入/删除 | O(n),要搬动元素 | O(1),改指针即可 |
| 尾部插入(不扩容时) | O(1) | O(n),要遍历到尾部 |
| 空间额外开销 | 小 | 每个节点多一个指针开销 |
| 缓存友好性 | 高,连续内存有利于局部性 | 低,节点分散导致访存跳变 |
我自己的体会是:频繁按下标访问、数据规模稳定的场景,数组更合适;频繁在中间插入删除、数据规模动态变化的场景,链表更合适。这俩不是替代关系,而是互补关系。很多开源项目的实现里,数组和链表还会一起出现,比如哈希表解决冲突用的就是数组加链表。
3. 链表增删改查的完整实现与边界处理
3.1 初始化和创建:头结点怎么搭起来
先说初始化。有头结点的链表,初始化就是给头结点分配内存,并把它的next指向NULL:
Node *initList() { Node *head = (Node *)malloc(sizeof(Node)); if (head == NULL) { printf("内存分配失败\n"); exit(1); } head->next = NULL; return head; }我第一次写的时候,忘了检查malloc的返回值。后面复杂度高了才发现,一旦内存分配失败,head是 NULL,下一行直接操作head->next就会段错误。现在我的习惯是:只要用 malloc,立刻检查是否为 NULL,宁多三行代码,不省一次判断。
创建链表有三种常用姿势:头插法、尾插法、按数组批量建表。头插法建出来的链表顺序是反的,适合需要逆序处理的场景;尾插法需要额外维护一个尾指针,否则每次都要遍历到底部,复杂度会变成 O(n²)。
我最常用的写法是尾插,用两个指针配合:
void insertTail(Node *head, int val) { Node *newNode = (Node *)malloc(sizeof(Node)); newNode->data = val; newNode->next = NULL; Node *p = head; while (p->next != NULL) { p = p->next; } p->next = newNode; }注意循环条件是p->next != NULL,不是p != NULL。如果循环到p == NULL,说明已经跑过头了,这时候你回不到前驱节点上。这个细节在插入删除操作里反复出现,一定要养成“要找前驱、不能超过前驱”的思维。
3.2 插入操作:头插、尾插和任意位置插入
插入操作的核心原则是:先接新节点,再断开旧链接。这个顺序反了,链表就断了,后面的节点全部漏掉。
头插的写法:
void insertHead(Node *head, int val) { Node *newNode = (Node *)malloc(sizeof(Node)); newNode->data = val; newNode->next = head->next; head->next = newNode; }先让新节点的next指向旧的第一个节点,再把头结点的next指向新节点。两行代码不能换顺序。
任意位置插入比较考验边界处理。假设我们想在第pos个位置(从 0 开始计数)插入节点,需要先找到待插入位置的前一个节点:
int insertAt(Node *head, int pos, int val) { if (pos < 0) return -1; Node *p = head; int i = 0; while (p != NULL && i < pos) { p = p->next; i++; } if (p == NULL) { printf("插入位置无效\n"); return -1; } Node *newNode = (Node *)malloc(sizeof(Node)); newNode->data = val; newNode->next = p->next; p->next = newNode; return 0; }这里有个容易翻车的点:循环里判断的是p != NULL还是p->next != NULL。因为我们要找的是“前驱节点”,所以循环结束后p应当停在待插入位置的前一个节点上,如果循环条件用错了,边界位置就会访问空指针。
还有一个很多人都会问的问题:为什么插入函数里有时候传二级指针,有时候只用一级指针?答案是:只要不修改头指针本身,只修改头指针指向的节点的域,一级指针就够用。我们的头结点是独立分配的,插入和删除改的是head->next这个成员,不是改head自己,所以一级指针就行。删除首元节点时也是一样。只有需要“把整个链表清空并让外部头指针变成 NULL”这种场景,才需要二级指针。
3.3 删除操作:按值删、按位置删、防内存泄漏
按位置删除的逻辑:先找到前驱节点,再让它跨过被删节点,最后释放被删节点的内存,并把被删节点的next置为 NULL,防止出现野指针。
int deleteAt(Node *head, int pos) { if (pos < 0 || head == NULL || head->next == NULL) { return -1; } Node *p = head; int i = 0; while (p->next != NULL && i < pos) { p = p->next; i++; } if (p->next == NULL) { printf("删除位置无效\n"); return -1; } Node *temp = p->next; p->next = temp->next; free(temp); temp = NULL; return 0; }注意到这个函数里循环条件是p->next != NULL。因为我们需要保证p之后至少还有一个节点可以被删除,如果p->next已经是 NULL,说明位置越界。
删除函数里的free(temp); temp = NULL;是必须的。free只释放空间,不会自动把指针变量变成 NULL,那个指针变量在释放之后仍然保存着原地址。如果后续代码不小心对它解引用,程序就直接崩了。我自己的惯例是:free 之后立刻把指针置 NULL。这个习惯救过我很多次。
按值删除比按位置删除多一层循环判断,本质上是先遍历找到 data 匹配的节点,记录它的前驱,然后执行同样的“跨越 + 释放”操作。如果链表里存在多个相同值的节点,还要想清楚需求是删第一个、删所有,还是只删某一处。答题和写项目前先确认语义,否则很容易写一半跑偏。
3.4 查找和修改:遍历是万能基本功
查找最典型的场景就是判断某个值是否存在,或者返回某个值对应的节点。实现起来就是一趟遍历:
Node *findNode(Node *head, int val) { Node *p = head->next; while (p != NULL) { if (p->data == val) { return p; } p = p->next; } return NULL; }返回值用 NULL 表示没找到,这个约定要统一。调用方拿到返回值后,先判断是不是 NULL,再访问节点的数据域,这个顺序不能乱。
修改操作和查找操作基本是孪生兄弟。你要改某个节点的值,先找到它,再改数据域。所以没有单独的“改一个值的函数”,而是把“查找 + 更新”组合起来:
int updateNode(Node *head, int oldVal, int newVal) { Node *target = findNode(head, oldVal); if (target == NULL) { return -1; } target->data = newVal; return 0; }查找和修改看起来简单,但它们其实是后面很多复杂算法的基础。比如反转链表、合并有序链表、找中间节点等,本质上都是“遍历 + 指针操作”的组合。把遍历写稳,后面会顺利非常多。
链表的遍历还有一个常用的变体,就是“快慢指针”。比如判断链表是否有环、找链表中点,都用到了两个指针一快一慢的走法。Day1 不展开,但建议你在练熟遍历之后再回头看,会有一种“原来如此”的感觉。
4. 我再补充一个变体:双向链表和循环链表
如果只有单链表,你会很快发现一个麻烦:想删除某个节点时,总需要从头遍历找到它的前驱节点,效率很低。双向链表就是为了解决这个问题而存在的。
双向链表的每个节点多了一个prev指针,指向前一个节点:
typedef struct DNode { int data; struct DNode *prev; struct DNode *next; } DNode;多了这个prev,删除某个已知节点时就可以直接通过p->prev找到前驱,不需要从头遍历。代价是每个节点多占一个指针的空间,插入删除时也需要同时维护两个方向的指针。
新手写双向链表最容易出的错就是:只改了后向链,忘了改前向链。每次操作都要问自己三遍:前驱的 next 改了吗?后继的 prev 改了吗?被操作节点自己的指针改了吗?三个问题缺一个,链表就会在某个角落悄悄坏掉。
循环链表则是把最后一个节点的next指回头结点或第一个节点,形成闭环。它在“约瑟夫环”“循环队列”这类场景里非常常见。循环链表最大的特点是遍历终止条件变成了“走回到出发点”,而不是遇到 NULL。这个变化会让很多刚接触的人不适,但多写几次就能习惯。
实际上 Day1 如果觉得单链表已经够呛,双向链表和循环链表可以先放一放,把单链表的增删改查练熟,再花一天去补这两个变体。我自己的学习节奏是:周一单链表,周二双向链表,周三再把循环链表、链表的题刷几道。这样更不容易产生挫败感。
5. 调试经验:段错误排查与常见翻车现场
5.1 段错误的主要来源和预防
学链表第一天,你几乎一定会遇到段错误。段错误本质上是程序访问了没有权限访问的内存,在链表场景下,常见的来源就这几类:
- 对 NULL 指针解引用。节点遍历过头,或者访问了已经被释放的节点的成员。
malloc分配失败后没有检查,继续往下操作。free之后没有把指针置 NULL,又尝试通过它访问数据。- 插入和删除时修改指针顺序错误,导致链表从中间断开,遍历时走到非法地址。
很多新手一出现段错误就慌,其实你只需要记住一个口诀:“先看空指针,再看释放顺序,最后看循环边界。”这三类问题占了链表段错误的九成以上。
5.2 用 gdb 快速定位崩溃位置
如果代码突然段错误,我会最快速度做两件事:一是加打印日志看程序跑到哪一步崩的,二是用调试器直接看栈回溯。
比如用 gdb 调试:
gcc -g linkedlist.c -o linkedlist gdb ./linkedlist进入 gdb 后直接运行,一旦崩溃,输入bt查看调用栈,就能看到崩溃发生在哪个函数、哪一行。然后print关键变量,看看它是不是 NULL,或者指向了奇怪的地址。这个排查闭环在链表调试里非常有效。
还有一个取巧的办法:把可疑节点地址打印出来,人工检查它是否落在合理区间。比如p的地址是 0x0,肯定有问题;如果是 0x7fffxxxx,通常是栈区地址,不该出现在链表节点里;如果是一个之前已经 free 过的地址,说明你碰了悬空指针。
5.3 纸笔演算比冥想有用
有段时间我写链表老错,后来发现一个笨办法特别管用:在纸上画链表,每个节点画两个格子,一个写 data,一个写 next。每执行一步代码,就把格子里的内容更新一遍。代码改一行,图就重画一遍。
这个方法听起来慢,但真的能救很多命。很多指针问题在脑子里绕不过弯的时候,落到纸上就一目了然了。我现在做链表面试算法题,也保持“先画图、再写码”的习惯。画图画明白了,代码基本就是一气呵成。
5.4 推荐的学习顺序和练习题
如果你刚开始学链表,我的建议顺序是:先单链表初始化 -> 遍历打印 -> 尾插法 -> 头插法 -> 任意位置插入 -> 按位置删除 -> 按值删除 -> 查找 -> 修改。每写完一个函数,都去 main 函数里完整测一遍,把空表、表尾、中间位置、越界位置都测一遍。很多人只测正常路径,结果村到面试官一测空表就崩,原因就在这里。
练完基础的增删改查,再去做几道经典题巩固:反转单链表、合并两个有序链表、链表中环的检测、寻找链表倒数第 k 个节点。这些题几乎都是 Day1 概念的组合应用,做完一遍,你会发现自己对指针的掌控感完全不一样。
我个人实际学习过程中最大的坎是“插入和删除的顺序”,因为它很难靠背代码解决,必须靠画图和反复练习形成肌肉记忆。现在我写链表插入已经可以做到不假思索先动新节点的 next、再动前驱的 next,但最开始那一周里,我几乎每一个用例都能崩溃一次。所以如果你今天也在为链表头疼,不用怀疑自己的智商,你只是在经历每个学数据结构的人都走过的路。这在数据结构里是最值得花一段时间慢慢磨的一块基础,它值得你为它犯错。