p12(7 分钟)、p13(15 分钟)
先明确一个术语
说「链表」的时候,默认指的是那种也可以称为单链表的列表。前面所有实现都是单链表,提到双向链表时要显式说明。
head 到底是什么
叫 head 的这个变量只是指向头节点的指针。理想情况下本该给它取个像 HEADPOINTER 这样的名字——它仅仅指向头节点,并非头节点本身;头节点就是链表中的第一个节点。
head 是指针变量,头节点是第一个节点本身。这是两个东西。
结构
每个节点有两个链接:一个指向下一节点,一个指向前一节点。
struct node { int data; struct node *next; struct node *prev;};逻辑表示:节点画成三个格子。
+--------+--------+--------+| prev | data | next |+--------+--------+--------+ 前一个 数据 下一个示例:3 个节点在地址 400 / 600 / 800。
400 → next 600,prev 0600 → next 800,prev 400800 → next 0, prev 600对于第一个节点没有前一个节点,所以最左边单元格将会是 0 或者为空。
优点
单个指针就能看到前、当前、后三个节点
temp 指向某节点(值为 600)temp->next 是 800temp->prev 是 400对比单链表:仅凭一个指针无法查看前一个节点,必须额外用另一个指针来跟踪前一个。
删除容易得多
删除一个节点,单链表需要两个指针(待删节点 + 前驱),双向链表只要一个(指向待删的那个节点)。
能在链表中进行反向查找,就能沿两个方向遍历链表。
缺点
prev 指针要额外内存
典型架构(int 4 字节、指针 4 字节):
单链表 4 数据 + 4 链接 = 8 字节双向链表 4 数据 + 8 链接 = 12 字节对于整数列表,用于链接的空间将是数据所用空间的两倍。
插入或删除时要重置更多链接
比起单向链表,需要重置更多几个链接,因此更容易出错。
实现
局部变量和全局变量
定义位置 局部变量在函数内,全局变量在函数外生存期 局部变量只管函数调用的期间,全局变量管应用程序的整个生命周期函数结束 局部变量从内存中清除,全局变量仍然存在可访问性 局部变量除非通过指针访问否则并非到处可访问;全局变量所有函数的任何地方都能访问前面的实现中,大多时候都把 head 声明为全局变量。
要写的函数
insert_at_head(x):在链表起始位置插入,参数是一个整数insert_at_tail:在链表尾端插入(最终没写)- 正向打印:从链表头至尾遍历打印
- 反向打印:从链表尾到头遍历,按逆序打印
反向打印还有个额外作用:反向打印函数能验证每个节点的反向链接(prev)是否正确创建。它是 prev 指针的验证手段,不只是个演示。
节点必须建在堆上
错误做法
如果像声明普通变量那样写:
struct node newnode; /* ← 错!这是栈上的局部变量 */它是作为局部变量创建的,函数调用结束时就会从内存中被清除。
局部变量存于应用程序内存的栈区,生存期不受我们控制。要求是:除非明确地移除一个节点,否则它应当一直在内存中。
正确做法
必须在动态内存(堆区)创建节点:C 用 malloc,C++ 用 new。
堆里的任何东西,除非我们显式释放,否则不会被清除。
一个真实的 bug
/* 错误版本 */struct node *get_new_node(int x) { struct node newnode; /* 栈上 */ newnode.data = x; return &newnode; /* 返回栈上变量的地址 */}会发生什么:
假设返回地址 50→ 一旦该函数结束,用于 get_new_node 的栈帧就会被回收 所以现在即便你有地址 50,但那里已经没有节点了而且类型也不同:newnode 是结构体节点类型;接收方要的是指向结构体节点的指针类型。
正确的 get_new_node
struct node *get_new_node(int x) { struct node *new_node = malloc(sizeof(struct node)); /* 堆上创建 */ new_node->data = x; new_node->next = NULL; new_node->prev = NULL; return new_node;}我们没法直接命名堆中的一段内存,访问堆中某物的唯一方式是通过指针;倘若丢失了这个指针,就会失去这个节点。
为什么单独写一个函数:为了避免重复代码——既要在 insert_at_head 中创建节点,过段时间也要在 insert_at_tail 中创建节点。
变量命名建议:用 new_node / new_node_pointer 之类,temp 并不是一个非常有意义的名字。
insert_at_head 实现
链表为空
head = new_node;return;示例:插入数字 2,get_new_node 在地址 400 创建新节点;head = new_node 把地址 400 存入 head。
函数执行完毕后变量 new_node 从内存中被清除,但节点本身不会被清除。
链表不为空
示例再插入数字 4,节点在地址 600:
head->prev = new_node; /* ① 把现有头节点的 prev 域设为新节点地址 */new_node->next = head; /* ② 把新节点的 next 域设为当前头节点地址 */head = new_node; /* ③ 头指针指向新节点 */顺序的解释:首先把现有头节点的前一域设置为这个新节点的地址,也就是建立这条链接;然后把新节点的下一域设为当前头节点的地址;现在可以断开这条链路并构建这条链路,因此把头节点设置为新节点的地址。
和单链表的区别:多了一行 head->prev = new_node。
内存分区
代码 / 文本段 所有待执行的指令全局变量段 全局变量栈 所有局部变量 + 函数调用执行的所有信息堆 / 动态内存 malloc / new 分配的内存栈就如同便签簿或白板之于函数调用。
栈内存的分配与释放我们无法控制,它自动发生。
打印函数
正向打印与单链表的打印函数相同,用 temp = temp->next。
反向打印要分两步:首先利用 next 指针到达列表的尾节点,然后再逆向遍历,用 temp = temp->prev。
没实现的
insert_at_tail- 删除函数(尽管开头说会写删除)
单链表 vs 双向链表
| 单链表 | 双向链表 | |
|---|---|---|
| 节点大小(int) | 8 字节 | 12 字节 |
| 前向遍历 | ✓ | ✓ |
| 反向遍历 | ✗ | ✓ |
| 单指针看前后 | ✗ | ✓ |
| 删除所需指针数 | 2 个 | 1 个 |
| 链接维护复杂度 | 低 | 高(更容易出错) |
| 插入顺序 | 先建新链再断旧链 | 多维护一条 prev |
坑
- head 是指针,不是头节点。
- 节点必须 malloc/new 到堆上,栈上的局部变量在函数返回后就没了。
- 返回栈上变量的地址是经典 bug,而且类型也不对。
- 双向链表每节点 12 字节(int 场景),链接空间是数据空间的两倍。
insert_at_head的三行顺序:先改head->prev,再改new_node->next,最后动head。
请输入编辑凭据,只有站点所有者可以修改文章。