1. 鏈表

1.1. 單鏈表

圖 23.6 “鏈表”所示的鏈表即單鏈表(Single Linked List),本節我們學習如何創建和操作這種鏈表。每個鏈表有一個頭指針,通過頭指針可以找到第一個節點,每個節點都可以通過指針域找到它的後繼,最後一個節點的指針域為NULL,表示沒有後繼。數組在內存中是連續存放的,而鏈表在內存中的佈局是不規則的,我們知道訪問某個數組元素b[n]時可以通過基地址+n×每個元素的位元組數得到它地址,或者說數組支持隨機訪問,而鏈表是不支持隨機訪問的,只能通過前一個元素的指針域得知後一個元素的地址,因此只能從頭指針開始順序訪問各節點。以下代碼實現了單鏈表的基本操作。

例 26.1. 單鏈表

/* linkedlist.h */
#ifndef LINKEDLIST_H
#define LINKEDLIST_H

typedef struct node *link;
struct node {
	unsigned char item;
	link next;
};

link make_node(unsigned char item);
void free_node(link p);
link search(unsigned char key);
void insert(link p);
void delete(link p);
void traverse(void (*visit)(link));
void destroy(void);
void push(link p);
link pop(void);

#endif
/* linkedlist.c */
#include <stdlib.h>
#include "linkedlist.h"

static link head = NULL;

link make_node(unsigned char item)
{
	link p = malloc(sizeof *p);
	p->item = item;
	p->next = NULL;
	return p;
}

void free_node(link p)
{
	free(p);
}

link search(unsigned char key)
{
	link p;
	for (p = head; p; p = p->next)
		if (p->item == key)
			return p;
	return NULL;
}

void insert(link p)
{
	p->next = head;
	head = p;
}

void delete(link p)
{
	link pre;
	if (p == head) {
		head = p->next;
		return;
	}
	for (pre = head; pre; pre = pre->next)
		if (pre->next == p) {
			pre->next = p->next;
			return;
		}
}

void traverse(void (*visit)(link))
{
	link p;
	for (p = head; p; p = p->next)
		visit(p);
}

void destroy(void)
{
	link q, p = head;
	head = NULL;
	while (p) {
		q = p;
		p = p->next;
		free_node(q);
	}
}

void push(link p)
{
	insert(p);
}

link pop(void)
{
	if (head == NULL)
		return NULL;
	else {
		link p = head;
		head = head->next;
		return p;
	}
}
/* main.c */
#include <stdio.h>
#include "linkedlist.h"

void print_item(link p)
{
	printf("%d\n", p->item); 
}

int main(void)
{
	link p = make_node(10);
	insert(p);
	p = make_node(5);
	insert(p);
	p = make_node(90);
	insert(p);
	p = search(5);
	delete(p);
	free_node(p);
	traverse(print_item);
	destroy();

	p = make_node(100);
	push(p);
	p = make_node(200);
	push(p);
	p = make_node(250);
	push(p);
	while (p = pop()) {
		print_item(p);
		free_node(p);
	}

	return 0;
}

在初始化時把頭指針head初始化為NULL,表示空鏈表。然後main函數調用make_node創建幾個節點,分別調用insert插入到鏈表中。

void insert(link p)
{
	p->next = head;
	head = p;
}

圖 26.1. 鏈表的插入操作

鏈表的插入操作

正如上圖所示,insert函數雖然簡單,其中也隱含了一種特殊情況(Special Case)的處理,當headNULL時,執行insert操作插入第一個節點之後,head指向第一個節點,而第一個節點的next指針域成為NULL,這很合理,因為它也是最後一個節點。所以空鏈表雖然是一種特殊情況,卻不需要特殊的代碼來處理,和一般情況用同樣的代碼處理即可,這樣寫出來的代碼更簡潔,但是在讀代碼時要想到可能存在的特殊情況。當然,insert函數傳進來的參數p也可能有特殊情況,傳進來的p可能是NULL,甚至是野指針,本章的函數代碼都假定調用者的傳進來的參數是合法的,不對參數做特別檢查。事實上,對指針參數做檢查是不現實的,如果傳進來的是NULL還可以檢查一下,如果傳進來的是野指針,根本無法檢查它指向的內存單元是不是合法的,C標準庫的函數通常也不做這種檢查,例如strcpy(p, NULL)就會引起段錯誤。

接下來main函數調用search在鏈表中查找某個節點,如果找到就返回指向該節點的指針,找不到就返回NULL

link search(unsigned char key)
{
	link p;
	for (p = head; p; p = p->next)
		if (p->item == key)
			return p;
	return NULL;
}

search函數其實也隱含了對於空鏈表這種特殊情況的處理,如果是空鏈表則循環體一次都不執行,直接返回NULL

然後main函數調用delete從鏈表中摘除用search找到的節點,最後調用free_node釋放它的存儲空間。

void delete(link p)
{
	link pre;
	if (p == head) {
		head = p->next;
		return;
	}
	for (pre = head; pre; pre = pre->next)
		if (pre->next == p) {
			pre->next = p->next;
			return;
		}
}

圖 26.2. 鏈表的刪除操作

鏈表的刪除操作

從上圖可以看出,要摘除一個節點需要首先找到它的前趨然後才能做摘除操作,而在單鏈表中通過某個節點只能找到它的後繼而不能找到它的前趨,所以刪除操作要麻煩一些,需要從第一個節點開始依次查找要摘除的節點的前趨。delete操作也要處理一種特殊情況,如果要摘除的節點是鏈表的第一個節點,它是沒有前趨的,這種情況要用特殊的代碼處理,而不能和一般情況用同樣的代碼處理。這樣很不爽,能不能把這種特殊情況轉化為一般情況呢?可以把delete函數改成這樣:

void delete(link p)
{
	link *pnext;
	for (pnext = &head; *pnext; pnext = &(*pnext)->next)
		if (*pnext == p) {
			*pnext = p->next;
			return;
		}
}

圖 26.3. 消除特殊情況的鏈表刪除操作

消除特殊情況的鏈表刪除操作

定義一個指向指針的指針pnext,在for循環中pnext遍歷的是指向鏈表中各節點的指針域,這樣就把head指針和各節點的next指針統一起來了,可以在一個循環中處理。

然後main函數調用traverse函數遍歷整個鏈表,調用destroy函數銷毀整個鏈表。請讀者自己閲讀這兩個函數的代碼。

如果限定每次只在鏈表的頭部插入和刪除元素,就形成一個LIFO的訪問序列,所以在鏈表頭部插入和刪除元素的操作實現了堆棧的pushpop操作,main函數的最後幾步把鏈表當成堆棧來操作,從打印的結果可以看到出棧的順序和入棧是相反的。想一想,用鏈表實現的堆棧和第 2 節 “堆棧”中用數組實現的堆棧相比有什麼優點和缺點?

習題

1、修改insert函數實現插入排序的功能,鏈表中的數據按從小到大排列,每次插入數據都要在鏈表中找到合適的位置再插入。在第 6 節 “折半查找”中我們看到,如果數組中的元素是有序排列的,可以用折半查找算法更快地找到某個元素,想一想如果鏈表中的節點是有序排列的,是否適用折半查找算法?為什麼?

2、基于單鏈表實現隊列的enqueuedequeue操作。在鏈表的末尾再維護一個指針tail,在tailenqueue,在headdequeue。想一想能不能反過來,在headenqueue而在taildequeue

3、實現函數void reverse(void);將單鏈表反轉。如下圖所示。

圖 26.4. 單鏈表的反轉

單鏈表的反轉

1.2. 雙向鏈表

鏈表的delete操作需要首先找到要摘除的節點的前趨,而在單鏈表中找某個節點的前趨需要從表頭開始依次查找,對於n個節點的鏈表,刪除操作的時間複雜度為O(n)。可以想像得到,如果每個節點再維護一個指向前趨的指針,刪除操作就像插入操作一樣容易了,時間複雜度為O(1),這稱為雙向鏈表(Doubly Linked List)。要實現雙向鏈表只需在上一節代碼的基礎上改動兩個地方。

linkedlist.h中修改鏈表節點的結構體定義:

struct node {
	unsigned char item;
	link prev, next;
};

linkedlist.c中修改insertdelete函數:

void insert(link p)
{
	p->next = head;
	if (head)
		head->prev = p;
	head = p;
	p->prev = NULL;
}

void delete(link p)
{
	if (p->prev)
		p->prev->next = p->next;
	else
		head = p->next;
	if (p->next)
		p->next->prev = p->prev;
}

圖 26.5. 雙向鏈表

雙向鏈表

由於引入了prev指針,insertdelete函數中都有一些特殊情況需要用特殊的代碼處理,不能和一般情況用同樣的代碼處理,這非常不爽,如果在表頭和表尾各添加一個Sentinel節點(這兩個節點只用於界定表頭和表尾,不保存數據),就可以把這些特殊情況都轉化為一般情況了。

例 26.2. 帶Sentinel的雙向鏈表

/* doublylinkedlist.h */
#ifndef DOUBLYLINKEDLIST_H
#define DOUBLYLINKEDLIST_H

typedef struct node *link;
struct node {
	unsigned char item;
	link prev, next;
};

link make_node(unsigned char item);
void free_node(link p);
link search(unsigned char key);
void insert(link p);
void delete(link p);
void traverse(void (*visit)(link));
void destroy(void);
void enqueue(link p);
link dequeue(void);

#endif
/* doublylinkedlist.c */
#include <stdlib.h>
#include "doublylinkedlist.h"

struct node tailsentinel;
struct node headsentinel = {0, NULL, &tailsentinel};
struct node tailsentinel = {0, &headsentinel, NULL};

static link head = &headsentinel;
static link tail = &tailsentinel;

link make_node(unsigned char item)
{
	link p = malloc(sizeof *p);
	p->item = item;
	p->prev = p->next = NULL;
	return p;
}

void free_node(link p)
{
	free(p);
}

link search(unsigned char key)
{
	link p;
	for (p = head->next; p != tail; p = p->next)
		if (p->item == key)
			return p;
	return NULL;
}

void insert(link p)
{
	p->next = head->next;
	head->next->prev = p;
	head->next = p;
	p->prev = head;
}

void delete(link p)
{
	p->prev->next = p->next;
	p->next->prev = p->prev;
}

void traverse(void (*visit)(link))
{
	link p;
	for (p = head->next; p != tail; p = p->next)
		visit(p);
}

void destroy(void)
{
	link q, p = head->next;
	head->next = tail;
	tail->prev = head;
	while (p != tail) {
		q = p;
		p = p->next;
		free_node(q);
	}
}

void enqueue(link p)
{
	insert(p);
}

link dequeue(void)
{
	if (tail->prev == head)
		return NULL;
	else {
		link p = tail->prev;
		delete(p);
		return p;
	}
}
/* main.c */
#include <stdio.h>
#include "doublylinkedlist.h"

void print_item(link p)
{
	printf("%d\n", p->item); 
}

int main(void)
{
	link p = make_node(10);
	insert(p);
	p = make_node(5);
	insert(p);
	p = make_node(90);
	insert(p);
	p = search(5);
	delete(p);
	free_node(p);
	traverse(print_item);
	destroy();

	p = make_node(100);
	enqueue(p);
	p = make_node(200);
	enqueue(p);
	p = make_node(250);
	enqueue(p);
	while (p = dequeue()) {
		print_item(p);
		free_node(p);
	}

	return 0;
}

圖 26.6. 帶Sentinel的雙向鏈表

帶Sentinel的雙向鏈表

這個例子也實現了隊列的enqueuedequeue操作,現在每個節點有了prev指針,可以反過來在headenqueue而在taildequeue了。

現在結合第 5 節 “環形隊列”想一想,其實用鏈表實現環形隊列是最自然的,以前基于數組實現環形隊列,我們還需要“假想”它是首尾相接的,而如果基于鏈表實現環形隊列,我們本來就可以用指針串成首尾相接的。把上面的程序改成環形鏈表(Circular Linked List)也非常簡單,只需要把doublylinkedlist.c中的

struct node tailsentinel;
struct node headsentinel = {0, NULL, &tailsentinel};
struct node tailsentinel = {0, &headsentinel, NULL};

static link head = &headsentinel;
static link tail = &tailsentinel;

改成:

struct node sentinel = {0, &sentinel, &sentinel};
static link head = &sentinel;

再把doublylinkedlist.c中所有的tail替換成head即可,相當於把headtail合二為一了。

圖 26.7. 環形鏈表

環形鏈表

1.3. 靜態鏈表

回想一下我們在例 12.4 “用廣度優先搜索解迷宮問題”中使用的資料結構,我把圖重新畫在下面。

圖 26.8. 廣度優先搜索的隊列資料結構

廣度優先搜索的隊列資料結構

這是一個靜態分配的數組,每個數組元素都有rowcolpredecessor三個成員,predecessor成員保存一個數組下標,指向數組中的另一個元素,這其實也是鏈表的一種形式,稱為靜態鏈表,例如上圖中的第6、4、2、1、0個元素串成一條鏈表。

1.4. 本節綜合練習

1、Josephus是公元1世紀的著名歷史學家,相傳在一次戰役中他和另外幾個人被圍困在山洞裡,他們寧死不屈,決定站成一圈,每次數到三個人就殺一個,直到全部死光為止。Josephus和他的一個朋友不想死,於是串通好了站在適當的位置上,最後只剩下他們倆的時候這個遊戲就停止了。如果一開始的人數為N,每次數到M個人就殺一個,那麼要想不死應該站在什麼位置呢?這個問題比較複雜,[具體數學]的1.3節研究了Josephus問題的解,有興趣的讀者可以參考。現在我們做個比較簡單的練習,用鏈表模擬Josephus他們玩的這個遊戲,NM作為命令行參數傳入,每個人的編號依次是1~N,打印每次被殺者的編號,打印最後一個倖存者的編號。

2、在第 2.11 節 “本節綜合練習”的習題1中規定了一種日誌檔案的格式,每行是一條記錄,由行號、日期、時間三個欄位組成,由於記錄是按時間先後順序寫入的,可以看作所有記錄是按日期排序的,對於日期相同的記錄再按時間排序。現在要求從這樣的一個日誌檔案中讀出所有記錄組成一個鏈表,在鏈表中首先按時間排序,對於時間相同的記錄再按日期排序,最後寫回檔案中。比如原檔案的內容是:

1 2009-7-30 15:16:42
2 2009-7-30 15:16:43
3 2009-7-31 15:16:41
4 2009-7-31 15:16:42
5 2009-7-31 15:16:43
6 2009-7-31 15:16:44

重新排序輸出的檔案內容是:

1 2009-7-31 15:16:41
2 2009-7-30 15:16:42
3 2009-7-31 15:16:42
4 2009-7-30 15:16:43
5 2009-7-31 15:16:43
6 2009-7-31 15:16:44