使用二级指针操作链表
2015-11-19
现在的编程过程中,我们很少需要亲自动手实现链表这类数据结构,因为标准库(甚至语言本身)往往已经提供了相关的功能。
然而,对于底层的编程——尤其是系统编程而言,手工实现并优化一些底层的数据结构仍然是必要的。要知道,我们可能会面对一个无法使用动态语言、无法使用C标准库、即使是“hello, world”也要通过IO指令输出的环境。
本文将探讨手工实现的链表中,关于指针的一个小技巧。
通常的链表操作
(会看这篇博客的)大部分读者对链表都很熟悉了,基本概念略过。
我们定义一个简单的单向链表,包含一个整数作为内容:
1 struct node {
2 struct node *next;
3 int content;
4 };
就像教科书上写的那样,我们可以用一个指针,从头到尾访问整个链表:
1 void scan(struct node *head) {
2 struct node *current = head;
3
4 while (current) {
5 // do something
6
7 current = current->next;
8 }
9 }
然后,我们实现一个简单的链表插入:
1 void insert(struct node *head, int index, struct node *node) {
2 struct node *current = head;
3
4 while (current) {
5 --index;
6 if (index == 0) {
7 node->next = current->next;
8 current->next = node;
9 return;
10 }
11
12 current = current->next;
13 }
14
15 // error
16 }
Duang!不出所料,insert(head, 0, node)
挂了。
我们发现,链表的第一个节点是特殊的。其它节点都是上一个节点的->next
,唯独第一个节点是通过head
指针传入的。而且需要注意的是,当index
为0
时,head
指针本身就应该指向新插入的节点。
于是我们秉持着打那指哪的精神,应付了一下:
1 struct node *insert(struct node *head, int index, struct node *node) {
2 // head
3 if (index == 0) {
4 node->next = head;
5
6 return node;
7 }
8
9 // others
10 struct node *current = head;
11
12 while (current) {
13 --index;
14 if (index == 0) {
15 node->next = current->next;
16 current->next = node;
17 return head;
18 }
19
20 current = current->next;
21 }
22
23 // error
24 }
这个方法也适用于在链表中删除节点。需要注意,如果要插入或删除多个节点,第一个if
需要相应地改为循环。
但是,把代码写两遍,分开处理第一个节点和其它节点。这显然是一种坏味道。如果操作本身更复杂一些,例如根据content
值来决定是否要删除,或者对链表节点进行排序,代码会变得更加复杂。
有几种可能的应对方式,一种是再写一个函数,把重复的代码包装起来;另一种是使用一个指针存放上一个node
,有经验的“教科书式程序员”会选择这样做。
我们来看一个删除节点的例子:
1 struct node *remove(struct node *head, int value) {
2 struct node *last = 0;
3 struct node *current = head;
4
5 while (current) {
6 if (current->content == value) {
7 struct node *next = current->next;
8
9 free(current);
10 current = next;
11
12 if (last) {
13 last->next = current;
14 } else {
15 head = current;
16 }
17 } else {
18 last = current;
19 current = current->next;
20 }
21 }
22
23 return head;
24 }
这种实现对于通常的使用来说,已经足够。但把last
和head
割裂开的做法,对于追求完美的我们来说,还是不够优雅。于是,请出本文的主角——二级指针。
使用二级指针
先不急着解决问题。我们观察remove
函数的接口:
1 struct node *remove(struct node *head, int value);
在使用这个函数时,需要先传入head
,然后再把返回值重新赋给head
。换句话说,head
在这里应该是一个可以被改写的参数。
这种可改写的参数,在Delphi里叫var参数,在C++里是传引用的参数。
在C里我们可以怎么做呢?对,二级指针:
1 void remove(struct node **head, int value) {
2 struct node *last = 0;
3 struct node *current = *head;
4
5 while (current) {
6 if (current->content == value) {
7 struct node *next = current->next;
8
9 free(current);
10 current = next;
11
12 if (last) {
13 last->next = current;
14 } else {
15 *head = current;
16 }
17 } else {
18 last = current;
19 current = current->next;
20 }
21 }
22 }
仔细想想,remove
函数对链表中指针的修改,无非是改head
和改last->next
这两种。next
指针和*head
指针其实是对称的。
这种对称关系可以用递归来展现:
1 void remove(struct node **head, int value) {
2 struct node *current = *head;
3
4 if (current) {
5 if (current->content == value) {
6 struct node *next = current->next;
7
8 free(current);
9 current = next;
10
11 *head = current;
12
13 remove(head, value);
14 } else {
15 remove(¤t->next, value);
16 }
17 }
18 }
我们之所以可以用递归来处理链表,正是因为next
和*head
具有这种特殊的关系。一个链表可以看成另一个链表的头上插入一个元素。而用于表示那“另一个链表”的指针正好就是第一个元素的next
指针。
递归中的current
其实是多余的,可以直接用*head
来代替:
1 void remove(struct node **head, int value) {
2 if (*head) {
3 if ((*head)->content == value) {
4 struct node *next = (*head)->next;
5
6 free(*head);
7 *head = next;
8
9 remove(head, value);
10 } else {
11 remove(&(*head)->next, value);
12 }
13 }
14 }
然后,我们把递归化成循环,就得到了简洁而一致的实现:
1 void remove(struct node **head, int value) {
2 while (*head) {
3 if ((*head)->content == value) {
4 struct node *next = (*head)->next;
5
6 free(*head);
7 *head = next;
8 } else {
9 head = &(*head)->next;
10 }
11 }
12 }
当删除链表第一个节点的时候,remove
函数会改写*head
,使它指向第二个元素(即,第一个元素的next
指向的节点);而删除之后的节点的时候,被改写的则是上一个节点的next
指针。
我们再来看看如何实现插入:
1 void insert(struct node **head, int index, struct node *node) {
2 while (*head) {
3 if (index == 0) {
4 node->next = *head;
5 *head = node;
6 return;
7 }
8 --index;
9
10 head = &(*head)->next;
11 }
12 }
实现方法和remove
大抵类似。
不用二级指针
二级指针还是有些抽象的,有没有一种几乎等价、但是更容易理解的实现方式呢?
答案是肯定的。
我们换一个角度思考——既然可以用head
代替last->next
,那我们也可以使用last->next
来代替head
。
方法很简单——建立一个空节点,作为虚拟的last
节点,然后让last->next
指向链表的第一个节点:
1 struct node last;
2 last->next = /* head of the linked list */;
用last->next
代替之前的*head
,来操作链表就可以了。代码差别不大:
1 void remove(struct node *last, int value) {
2 while (last->next) {
3 if (last->next->content == value) {
4 struct node *next = last->next->next;
5
6 free(last->next);
7 last->next = next;
8 } else {
9 last = last->next;
10 }
11 }
12 }
这种虚拟节点对于双向链表来说非常方便。我们可以直接使用一个节点entry
来保存头尾指针,即entry->next
指向第一个元素,entry->last
指向最后一个元素。
《操作系统》课的JOS lab里,就出现了二级指针。这是本文的来由。因此我们用lab的方式结束本文:
This completes the blog.