5

使用二级指针操作链表

 1 year ago
source link: http://hczhcz.github.io/2015/11/19/double-pointers-in-linked-list-modification.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

现在的编程过程中,我们很少需要亲自动手实现链表这类数据结构,因为标准库(甚至语言本身)往往已经提供了相关的功能。

然而,对于底层的编程——尤其是系统编程而言,手工实现并优化一些底层的数据结构仍然是必要的。要知道,我们可能会面对一个无法使用动态语言、无法使用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指针传入的。而且需要注意的是,当index0时,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 }

这种实现对于通常的使用来说,已经足够。但把lasthead割裂开的做法,对于追求完美的我们来说,还是不够优雅。于是,请出本文的主角——二级指针。

使用二级指针

先不急着解决问题。我们观察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(&current->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.


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK