Outline — 點擊展開各節
4.1 指標
4.2 單向鏈結串列
4.3 動態鏈結的堆疊與佇列
4.4 多項式
4.5 更多的串列運算
4.6 等價關係
4.7 稀疏矩陣
4.8 雙向鏈結串列
4.9 / 4.10 文獻與綜合習題
Chapter 4 · Linked Lists

第 4 章 串列

前三章反覆撞上同一堵牆:連續配置的插入與刪除必須搬資料。本章用一個指標欄位換掉那堵牆 —— 代價是每個節點多幾個位元組,收穫是第 5 章以後所有結構的地基。

本章要解決的,是前三章累積下來的同一個問題

回顧一下這條線索:

  • 第 2 章 2.3 節:「循序映射對多數的運算都可以有良好的表現…… 只有插入和刪除被困住了。」
  • 第 2 章 2.4 節:「我們傾向於將所有的稀疏矩陣以一個陣列來表示…… 但是,這樣做使得我們在從陣列中配置各別的矩陣所需的空間上遭遇困難。
  • 第 3 章 3.5 節:多元堆疊的 stack_full 最差是 $O(\texttt{MEMORY\_SIZE})$ —— 元素要在整塊記憶體裡搬來搬去。

本章的答案只有一句話:不要求連續。把「下一個元素在哪裡」明白地存成一個指標,資料就再也不必移動。整章其餘的內容 —— 多項式、稀疏矩陣、等價關係 —— 都是把前面做過的題目用串列重做一遍,好讓你直接對照兩種表示法的差別。

4.1 指標

在前面各章中我們已學習使用陣列和循序映射的簡易資料結構表示法。這些表示法將資料物件的連續元素以一定的距離來儲存。因而:

循序表示法昂貴在哪裡 —— 一個三字母的例子

考慮下列以 at 為結尾長度為三個字元之字母串列:

$$(\texttt{bat},\ \texttt{cat},\ \texttt{sat},\ \texttt{vat})$$

我們想將 mat 加入串列中。如果此串列儲存在陣列中,則在將 mat 插入之前,我們必須將 satvat 向右移動一個位置。同樣地,如果要將 cat 自串列中刪除,我們必須將 satvat 向左移動一個位置以維持循序表示法。

一般而言,在陣列中插入或刪除任意元素是很費時的工作。當我們使用數個不同大小的有序串列時,我們會遭遇到循序表示法更多的困難:若將每一個串列分別儲存在不同的最大陣列中,則可能浪費空間;若將串列全部儲存在一個陣列中,則可能需要經常地移動資料。

鏈結表示法 (linked representation)

「不同於循序表示法中串列的連續項目以一定的距離儲存,在一個鏈結串列中的項目可以存放在記憶中任一個位置。換言之,在循序表示法中元素的順序和它們在有序串列中的順序是相同。而在鏈結表示法中,這兩種順序不一定相同。為了以每一元素在串列中的正確順序來存取元素,我們儲存串列中下一個元素的位址(address),或稱儲位(location)。因此,和串列的各個元素相關連的是一個節點(node),它包含了資料部份和一個指向串列中下一個元素的指標(pointer)。指標通常稱為鏈結(link)。」

C 的指標運算符號

& 位址運算符號

若宣告 int i, *pi;pi = &i;&i 傳回 i 的位址,並指定其為 pi 之值。

* 完全參考(間接)運算符號

i = 10;*pi = 10; 兩種情況下 i 值均為 10。第二種情況中,* 號使 pi 被完全參考,10 被存入由指標 pi 所指的儲位,而不是 10 存入指標

在指標上還有其他的運算。因為指標是一個非負的整數,C 允許我們在指標上進行算術運算,如加、減、乘、除。我們也可以判斷一個指標是否大於、小於或等於另一個指標,以及可將指標轉換成整數。

虛指標 NULL

「指標的大小在不同的電腦上可能是不同的。有時候,同一電腦上指標的大小也可能不同。舉例而言,指向 char 的指標之大小可能大於指向 float 的指標之大小。」C 以一個特別值當作虛指標(null)。虛指標沒有指向任何的物件或函數,一般而言以 0 表示。巨集 NULL 定義在 ANSI C 的 stddef.h 或 K&R C 的 stdio.h 中。虛指標可用於關係運算式中作為一個 FALSE 值,所以測試是否為虛指標可寫 if (pi == NULL) 或簡單地寫成 if (!pi)

4.1.1 指標可能是危險的

三條保命規則
  1. 把沒有實際指向物件的所有指標設定為 NULL「這樣做可以使得當你企圖存取某一區域的記憶體時,這個記憶體區域超出你的程式記憶體範圍,或者,未包含指向一個有效的物件之指標參考的機會減到最少。在有些電腦上,可以完全參考虛指標,而其結果是 NULL,以允許程式繼續地執行。在其他的電腦上,其結果是位址 0 上的位元內容,經常會造成嚴重的錯誤。
  2. 在指標型態之間轉換時,使用明確的 type cast。例如:
    pi = malloc(sizeof(int));  *assign to pi a pointer to int*/
    pf = (float *) pi; /*casts an int pointer to a float pointer*/
  3. 永遠為函數定義明確的傳回型態。「在許多的系統中,指標的大小和型態 int 的大小相同。因為 int 為預設的型態名稱,有些程式設計人員在定義一個函數時省略了其傳回型態。預設為 int 的傳回型態稍後可以被解釋為一個指標。在有些電腦上,這已經證實是危險的作法。

4.1.2 使用動態配置的記憶體

累堆 heap

「在你的程式中,你也許希望取得空間以儲存資訊。在編寫程式呼叫時,你也許無法預知需要多少的空間,也不知道你所要的空間也許大得無法取得。要解決這個問題,C 提供了一種構造,稱為累堆(heap),以便在執行期間配置記憶體。」

int i, *pi;
float f, *pf;
pi = (int *) malloc(sizeof(int));
pf = (float *) malloc(sizeof(float));
*pi = 1024;
*pf = 3.14;
printf("an integer = %d, a float = %f\n", *pi, *pf);
free(pi);
free(pf);

程式 4.1:指標的配置和釋回

malloc 的呼叫包含一個參數,它決定了用以保存一個 intfloat 所需的記憶體大小。其結果是一個指標,指向適當大小之記憶區間的起始位置。結果的型態可以是不同的。在有些系統中,malloc 的結果是 char* 即指向 char 的指標。但是,對使用 ANSI C 的人而言,將會發現結果是 void*

懸浮參考 dangling reference

在程式 4.1 中,如果在 printf 指令之後加入了一行:

pf = (float *) malloc(sizeof(float));

則指向儲存數值 3.14 的儲位之指標即消失。現在,沒有任何方法可以存取這個儲位。這是懸浮參考(dangling reference)的一個例子。當所有指向動態配置記憶區間的指標都消失時,對程式而言,此記憶空間即消失。當我們檢查使用指標和動態記憶體的程式時,應當要記住永遠要將不需要的記憶體釋回

4.2 單向鏈結串列

鏈結串列畫出時如一連串節點,其中箭頭表示指標鏈結。指向串列第一個節點的指標之名稱也是串列的名稱。注意,我們並未明確地寫出指標的值,僅畫出一個箭頭表示它們。這樣做的目的在加強下列的事實:

  1. 節點沒有按照循序位置儲存
  2. 節點的位置在每次執行程式時可以改變
batcat satvat NULL ptr
圖 4.1:畫出鏈結串列常用的方法 —— 指標的實際值不重要,重要的是「誰指向誰」。
在 cat 與 sat 之間插入 mat 的四個步驟
  1. 取得一個目前未使用的節點;令其位址為 paddr
  2. 將此節點的資料欄位設定為 mat
  3. paddr 的鏈結欄位設定為包含 cat 的節點之鏈結欄位中找到的位址。
  4. 設定包含 cat 的節點之鏈結欄位,使其指向 paddr
batcat satvat NULL mat ptr 灰虛線 = 舊鏈結 紅實線 = 新鏈結
圖 4.2:在 cat 之後插入 mat —— 並未移動串列中原有的任何元素。代價只是每個節點多一個鏈結欄位,「這並不是很嚴重的付出」。

現在假設我們想要自串列中刪除 mat。我們只要找到在 mat 之前的元素,即 cat,設定其鏈結欄位,使其指向 mat 的鏈結欄位。我們沒有移動任何資料,而且,雖然 mat 的鏈結欄位仍指向 satmat 已不再存於串列中。

要讓鏈結表示法行得通,需要三種能力
  1. 一種架構,用以定義節點的構造 —— 採用 2.2 節的自身參考結構
  2. 在必要時用來建立新節點的一種方法 —— malloc
  3. 當節點不再需要時可以刪除它們的一種方法 —— free
範例 4.1 [List of Words ending in at]
typedef struct list_node *list_pointer;
typedef struct list_node {
        char data[4];
        list_pointer link;
        };
list_pointer ptr = NULL;
為什麼指標的 typedef 要寫在 struct 之前

「應注意,我們在定義 struct (list_node) 之前已定義了指標 (list_pointer) 指向此 structC 允許我們建立指向尚未存在的型態之指標,因為若不如此,我們將面對一個矛盾的用法:我們不能定義指向不存在的型態之指標,但是,要定義新的型態,我們必須將指標包含在此型態之內。

建立一個新的、空的串列:list_pointer ptr = NULL;。因新的串列初值設定為空的,其起始位址為 0。可用巨集測試空串列:

#define IS_EMPTY(ptr) (!(ptr))

產生新的節點:

ptr = (list_pointer) malloc(sizeof(list_node));

指派欄位值時引用新的運算符號 ->如果 e 為指標,指向包含欄位 name 的結構,則 e->name 是運算式 (*e).name 的一種簡寫。運算符號 -> 稱為結構成份(structure member)運算符號,適用於使用指標指向一個 struct,而不適用於 *. 表示法。

strcpy(ptr->data,"bat");
ptr->link = NULL;
範例 4.2 [Two_node linked list]
typedef struct list_node *list_pointer;
typedef struct list_node {
        int data;
        list_pointer link;
        };
list_pointer ptr = NULL;
list_pointer create2()
{
/* create a linked list with two nodes */
   list_pointer first, second;
   first = (list_pointer)malloc(sizeof(list_node));
   second = (list_pointer)malloc(sizeof(list_node));
   second->link = NULL;
   second->data = 20;
   first->data = 10;
   first->link = second;
   return first;
}

程式 4.2:建立有兩個節點的串列

insert 與 delete

範例 4.3 [List insertion]
void insert(list_pointer *ptr, list_pointer node)
{
/* insert a new node with data = 50 into the list
ptr after node */
   list_pointer temp;
   temp = (list_pointer)malloc(sizeof(list_node));
   if (IS_FULL(temp)){
      fprintf(stderr, "The memory is full\n");
      exit(1);
   }
   temp->data = 50;
   if (*ptr) {
      temp->link = node->link;
      node->link = temp;
   }
   else {
      temp->link = NULL;
      *ptr = temp;
   }
}

程式 4.3:在串列之前的簡易插入

為什麼第一個參數是 list_pointer *ptr(雙重指標)

「第一個變數為 ptr,指向串列第一個節點。如果此變數包含一個虛位址(即,串列中沒有節點),我們要改變 ptr,使其指向資料欄為 50 的節點。這表示必須傳送 ptr 的位址。這是為何要宣告 list_pointer *ptr 的原因。因為第二個指標 node 的位址不會改變,我們不需要將它的位址當成參數來傳送。」典型的函數呼叫為 insert(&ptr,node);

另外注意新巨集 #define IS_FULL(ptr) (!((ptr))) —— 它配合 malloc 使用,如果沒有記憶體可用時,malloc 傳回 NULL

範例 4.4 [List deletion]

「自串列中刪除任意一個節點比插入稍為複雜一些,因刪除與節點的位置相關。」假設有三個指標:ptr 指向串列的開始,node 指向想要刪除的節點,而 trail 指向它前面的一個節點。

情形一:刪的是第一個節點

(圖 4.7,trail == NULL必須確實地改變起始位址 ptr

情形二:刪的不是第一個

(圖 4.8)只要改變 trail 的鏈結欄,使其指向 node 之鏈結欄所指的節點即可。

void delete(list_pointer *ptr, list_pointer trail,
                                 list_pointer node)
{
/* delete node from the list, trail is the preceding node
ptr is the head of the list */
   if (trail)
      trail->link = node->link;
   else
      *ptr = (*ptr)->link;
   free(node);
}

程式 4.4:自串列中刪除 —— 除了改變鏈結欄或 *ptr 之值以外,delete 也會將配置給所刪除的節點之空間還給系統記憶體

範例 4.5 [Printing out a list]
void print_list(list_pointer ptr)
{
   printf("The list contains: ");
   for (; ptr; ptr = ptr->link)
      printf("%4d",ptr->data);
   printf("\n");
}

程式 4.5:印出一個串列

「首先印出 ptr 的資料欄之內容,而後將 ptr 以其 link 欄內的位址取代。我們繼續地印出 data 欄,移向下一個節點,在到達串列的結尾才停止。」注意 for 迴圈的條件就是 ptr 本身 —— 到達 NULL 自然結束。

習題 1 — 串列基本運算(4.2 節習題)
  1. 重寫 delete(程式 4.4),使其只用兩個指標 ptrtrail
  2. 假設有一個如範例 4.2 中的整數串列。建立一個函數來搜尋串列的一個整數值 num。如果 num 在串列中,函數傳回一個指標,指向包含 num 的節點。否則傳回 NULL。
  3. 設計一個函數,將包含數值 num 的節點自串列中刪除。使用第 2 題中的搜尋函數來判斷 num 是否在串列中。
  4. 設計一個函數 length,計算在串列中的元素個數。
  5. p 為指向單向鏈結串列的第一個節點之指標。設計一個程序式,從 p 開始,每隔一個節點刪除一個(即第一個,第三個,第五個節點等)。你的演算法之時間複雜度為何?
  6. 令 $x=(x_1,x_2,\ldots,x_n)$ 和 $y=(y_1,y_2,\ldots,y_m)$ 為兩個鏈結串列。假設在每一個串列中,節點以其資料欄內之值升冪排列。設計一個演算法將這兩個串列合併為新的串列 $z$,使其中的節點仍為升冪排列。在合併以後,$x$ 和 $y$ 不以個別的串列存在。你不可以使用額外的節點。你的演算法之時間複雜度為何?
  7. 令 $\text{list}_1=(x_1,\ldots,x_n)$ 和 $\text{list}_2=(y_1,\ldots,y_m)$。設計一個函數,將此二串列合併以產生鏈結串列 $\text{list}_3=(x_1,y_1,x_2,y_2,\ldots,x_my_m,x_{m+1},\ldots,x_n)$,若 $m \le n$。
  8. § 藉著在由左而右追蹤的過程中,將鏈結反向,就可能以兩種方向追蹤一個鏈結串列。(a) 設計一個函數將 ptr 從任一指定位置 (left,ptr) 向右移動 $n$ 個節點。(b) 向左移動 $n$ 個節點。
點擊展開解題要點

第 1 題:node 消掉 —— 讓 trail 指向要刪的節點的前一個node 就是 trail->linktrail == NULL 時代表要刪第一個。

第 5 題:用一個指標往前走,每次做 tmp = p->link; p->link = tmp->link; free(tmp); p = p->link;,注意每一步都要檢查 NULL(因為要碰兩格)。時間 $\theta(n)$,額外空間 $O(1)$。

第 6 題(最重要的一題):不可以使用額外的節點」是關鍵條件 —— 這代表必須重接鏈結而不是複製資料。標準的三指標合併:

z = NULL; tail = NULL;
while (x && y) {
  if (x->data <= y->data) { t = x; x = x->link; }
  else                    { t = y; y = y->link; }
  if (!z) z = tail = t; else { tail->link = t; tail = t; }
}
tail->link = (x ? x : y);       /* 接上剩下的那一條 */

時間 $\theta(n+m)$,額外空間 $O(1)$。這正是第 7 章合併排序的核心步驟 —— 現在先熟悉它。

第 8 題:這是本節最精巧的一題。維護一對指標 (left, ptr)ptr 左邊所有節點的鏈結都已反向(指回左邊)。向右走一步:把 ptr->link 記下來、讓 ptr->link 指向 left、然後 left = ptr; ptr = 記下來的。向左走就是把這個操作反過來做。這讓你用單向串列達成雙向追蹤,代價是串列在追蹤過程中是「壞的」—— 其他程式不能同時看它。

4.3 動態鏈結的堆疊與佇列

先前我們曾以循序的方式表示堆疊和佇列。這種表示法已證實在僅有一個堆疊或一個佇列時是有效率的。然而,如果有多個堆疊和佇列同時存在,則沒有任何一種方法可以有效率地將它們以循序方式儲存。

elementlink NULL top (a) 鏈結堆疊 elementlink NULL front rear (b) 鏈結佇列
圖 4.10:鏈結的堆疊和佇列。注意鏈結的方向 —— 堆疊由 top 往下鏈;佇列由 front 往 rear 鏈,這使得堆疊頂端的加入/刪除,以及佇列後端的加入、前端的刪除,都能容易地進行。

n 個堆疊

#define MAX_STACKS 10 /*maximum number of stacks*/
typedef struct {
        int key;
        /* other fields */
        } element;
typedef struct stack *stack_pointer;
typedef struct stack {
        element item;
        stack_pointer link;
        };
stack_pointer top[MAX_STACKS];

啟始狀況:top[i] = NULL,$0 \le i \lt \texttt{MAX\_STACKS}$。邊界條件:top[i] == NULL 若且唯若第 $i$ 個堆疊是空的;IS_FULL(temp) 若且唯若記憶體已滿載。

void add(stack_pointer *top, element item)
{
/* add an element to the top of the stack */
   stack_pointer temp =
                 (stack_pointer) malloc(sizeof (stack));
   if (IS_FULL(temp)) {
      fprintf(stderr, "The memory is full\n");
      exit(1);
   }
   temp->item = item;
   temp->link = *top;
   *top = temp;
}

程式 4.6:在鏈結的堆疊中加入元素 —— 典型呼叫 add(&top[stack_no],item)

element delete(stack_pointer *top) {
/* delete an element from the stack */
   stack_pointer temp = *top;
   element item;
   if (IS_EMPTY(temp)) {
      fprintf(stderr, "The stack is empty\n");
      exit(1);
   }
   item = temp->item;
   *top = temp->link;
   free(temp);
   return item;
}

程式 4.7:自鏈結的堆疊中刪除元素

m 個佇列

#define MAX_QUEUES 10 /* maximum number of queues */
typedef struct queue *queue_pointer;
typedef struct queue {
        element item;
        queue_pointer link;
        };
queue_pointer front[MAX_QUEUES], rear[MAX_QUEUES];
void addq(queue_pointer *front, queue_pointer *rear,
                                       element item)
{
/* add an element to the rear of the queue */
   queue_pointer temp =
                 (queue_pointer) malloc(sizeof(queue));
   if (IS_FULL(temp)) {
      fprintf(stderr, "The memory is full\n");
      exit(1);
   }
   temp->item = item;
   temp->link = NULL;
   if (*front) (*rear)->link = temp;
   else *front = temp;
   *rear = temp;
}

程式 4.8:在鏈結佇列的後端加入元素 —— addqadd 複雜,因為它必須檢查空的佇列

element deleteq(queue_pointer *front)
{
/* delete an element from the queue */
   queue_pointer temp = *front;
   element item;
   if (IS_EMPTY(*front)) {
      fprintf(stderr, "The queue is empty\n");
      exit(1);
   }
   item = temp->item;
   *front= temp->link;
   free(temp);
   return item;
}

程式 4.9:自鏈結佇列的前端刪除元素

為什麼這個「額外的空間」是划算的

「上述有關 $n$ 個堆疊和 $m$ 個佇列問題的解法在計算上和觀念上都很簡單。我們不再需要將堆疊或佇列移位以產生空間。只要還有可用的記憶空間存在,計算就可能繼續進行。

「雖然我們需要鏈結欄的額外空間,使用鏈結串列仍是有用的,因為由鏈結的儲位所造成的負擔(overhead)可以被下列兩個因素抵消:(1) 以簡單的方式表示串列的能力,和 (2) 鏈結表示法所減少的計算時間。

習題 — 堆疊與佇列的應用(4.3 節習題)
  1. 迴文是一個字彙或片語由前或由後拼字時都是相同的。例如,"reviver" 和 "Able was I ere I saw Elba" 兩者均為迴文。使用堆疊,我們可以判斷一個字彙或片語是否為迴文。設計一個 C 函數,當一個字彙或片語為迴文時傳回 TRUE,否則傳回 FALSE。
  2. 我們可以用堆疊來判斷一個運算式中的括號的層次是否正確。設計一個 C 函數來完成這個工作。
  3. 考慮一種假想的資料型態 X2。X2 是一種線性串列,限制其插入可以在串列的兩端進行,而刪除只能在一端進行。為 X2 設計一種鏈結串列表示法。編寫 X2 的插入和刪除函數。對於你的表示法,指出其啟始狀況和邊界條件。
點擊展開解題要點

第 1 題:把字串前半推入堆疊,再一邊彈出一邊與後半比對。注意處理奇數長度(中間字元跳過)與忽略空白與大小寫("Able was I ere I saw Elba" 需要這個)。時間 $\theta(n)$、空間 $\theta(n)$。

第 2 題:遇左括號推入、遇右括號彈出並檢查是否配對;結束時堆疊必須為空。三種錯誤要分開報:多了右括號(彈空堆疊)、多了左括號(結束時非空)、括號型別不配對。

第 3 題:X2 = 兩端可插入、一端可刪除,這是輸出限制的雙向佇列(output-restricted deque)。用「指向最後一個節點的環狀串列」最漂亮(4.5.2 節的技巧):insert_frontinsert_rear 都是 $O(1)$,從 front 刪除也是 $O(1)$。啟始狀況 ptr = NULL;邊界條件 IS_EMPTY(ptr)

4.4 多項式

4.4.1 以單向鏈結串列表示多項式

正如在第 2 章討論的,只要記憶體夠用,我們希望表示任何數目的、不同的多項式。一般而言,我們要表示的多項式為:

$$A(x) = a_{m-1}x^{e_{m-1}} + \cdots + a_0 x^{e_0}$$

其中 $a_i$ 為非零的係數,$e_i$ 為非零的整數之指數,且 $e_{m-1} \gt e_{m-2} \gt \cdots \gt e_1 \gt e_0 \ge 0$。

typedef struct poly_node *poly_pointer;
typedef struct poly_node {
        int coef;
        int expon;
        poly_pointer link;
        };
poly_pointer a,b,d;
314 28 10NULL a (a) a = 3x¹⁴ + 2x⁸ + 1 814 −310 106NULL b (b) b = 8x¹⁴ − 3x¹⁰ + 10x⁶
圖 4.11:多項式表示法 —— 每個節點三個欄位:coefexponlink

4.4.2 多項式相加

要將兩個多項式相加,我們從 ab 所指的節點開始檢查它們的項次。三種情況:

情況動作
a->expon == b->expon係數相加,若和不為 0 則為結果產生一個新項目;ab 都前進
a->expon < b->expon複製 b 中的項目附加到 d指標移到 b 的下一項
a->expon > b->expona 上採類似的運算
兩個工程技巧

(1) rear 指標:「為了避免在每次加入新的節點時必須找尋 d 的最後一個節點,我們使用一個指標 rear 來指向目前 d 中最後的一個節點。」
(2) 假的初始節點:「為了使工作靈活地進行,一開始我們將 d 初設為不具任何值的單一節點,而在函數結束前將其刪除。雖然這樣做比較不巧妙,它可避免更多的計算。」—— 少了它,每次 attach 都要判斷「d 是不是還空的」。

poly_pointer padd(poly_pointer a, poly_pointer b)
{
/* return a polynomial which is the sum of a and b */
   poly_pointer front, rear, temp;
   int sum;
   rear = (poly_pointer)malloc(sizeof(poly_node));
   if (IS_FULL(rear)) {
      fprintf(stderr, "The memory is full\n");
      exit(1);
   }
   front = rear;
   while (a && b)
      switch (COMPARE(a->expon,b->expon)) {
         case -1: /* a->expon < b->expon */
                  attach(b->coef,b->expon,&rear);
                  b = b->link;
                  break;
         case 0: /* a->expon = b->expon */
                  sum = a->coef + b->coef;
                  if (sum) attach(sum,a->expon,&rear);
                  a = a->link;  b = b->link; break;
         case 1: /* a->expon > b->expon */
                  attach(a->coef,a->expon,&rear);
                  a = a->link;
      }
   /* copy rest of list a and then list b */
   for (; a; a = a->link) attach(a->coef,a->expon,&rear);
   for (; b; b = b->link) attach(b->coef,b->expon,&rear);
   rear->link = NULL;
   /* delete extra initial node */
   temp = front; front = front->link;  free(temp);
   return front;
}

程式 4.10:兩個多項式相加 ——「這是我們的第一個串列處理的完整範例,你應該仔細地研習」

void attach(float coefficient, int exponent, poly_pointer
                                                  *ptr)
{
/* create a new node with coef = coefficient and expon =
exponent, attach it to the node pointed to by ptr.  ptr is
updated to point to this new node */
   poly_pointer temp;
   temp = (poly_pointer)malloc(sizeof(poly_node));
   if (IS_FULL(temp)) {
      fprintf(stderr, "The memory is full\n");
      exit(1);
   }
   temp->coef = coefficient;
   temp->expon = exponent;
   (*ptr)->link = temp;
   *ptr = temp;
}

程式 4.11:將一個節點附加在串列的結尾

分析 padd

三種成本估計:(1) 係數相加,(2) 指數比較,(3) 為 d 產生新的項目。假設 $a$ 和 $b$ 分別有 $n$ 和 $m$ 項:

$$0 \le \text{係數相加的次數} \le \min\{m,n\}$$

「下限發生在沒有任何指數是相同的之時,而上限則發生在當一個多項式的指數為另一個多項式的指數之部份集合時。」

至於指數比較,每一次執行 while 迴圈時比較一次。在每一次的迴路中,或者 $a$,或者 $b$,或者 $a$ 和 $b$ 兩者會移到下一項。因為全部的項數為 $m+n$,迴路執行的次數以及指數比較的次數受限於 $m+n$。很容易就可建構一個例子使其需要 $m+n-1$ 次的比較,例如 $m=n$,且

$$e_{m-1} \gt f_{m-1} \gt e_{m-2} \gt f_{m-2} \gt \cdots \gt e_1 \gt f_1 \gt e_0 \gt f_0$$

在 $d$ 中最多的項數 $m+n$,所以最多需要產生 $m+n$ 個新的項目。

$$\boxed{T_{\text{padd}} = O(m+n)}$$

「這表示如果我們在一部電腦上實作執行此演算法,所需的時間為 $c_1m + c_2n + c_3$,其中 $c_1,c_2,c_3$ 為常數。因為任何將兩個多項式相加的演算法都必須檢查非零的項目至少一次,padd 在一個常數因子之內已是最佳的了。

4.4.3 刪除多項式

一個假想的使用者希望讀取多項式 $a(x)$、$b(x)$ 和 $d(x)$,而後計算 $e(x) = a(x) \cdot b(x) + d(x)$:

poly_pointer a, b, d, e
a = read_poly();
b = read_poly();
d = read_poly();
temp = pmult(a,b);
e = padd(temp,d);
print_poly(e);

如果我們希望計算更多的多項式,回收用來儲存 temp(x) 的節點是很有幫助的,因 temp(x) 是為保存 d(x) 的部份結果而建立的。

void erase(poly_pointer *ptr)
{
/* erase the polynomial pointed to by ptr */
   poly_pointer temp;
   while (*ptr) {
      temp = *ptr;
      *ptr = (*ptr)->link;
      free(temp);
   }
}

程式 4.12:刪除一個多項式 —— 逐一將節點釋回,時間 $O(\text{節點數})$

4.4.4 以環狀鏈結串列表示法多項法

環狀串列 circular list 與鏈 chain

「如果我們修改串列的結構,使得最後一個節點的鏈結欄指向串列的第一個節點,我們即可更有效率地釋回一個多項式的所有節點。我們稱其為環狀串列(circular list)單向鏈結串列的最後一個節點有虛鏈結時稱為鏈(chain)。

314 28 10 ptr
圖 4.13:$\texttt{ptr} = 3x^{14}+2x^8+1$ 的環狀表示法 —— 最後一個節點指回第一個。

avail 可用空間串列

「藉著將釋回的節點視為一個串列(鏈),我們既可達成這個目標,又可獲得一個有效率的環形串列之刪除演算法。當需要一個新的節點時,檢查此串列。如果串列不為空的,則可以使用其中的一個節點。只有在這個串列是空的時,我們才需要利用 malloc 來建立新的節點。

poly_pointer get_node(void)
/* provide a node for use */
{
   poly_pointer node;
   if (avail) {
      node = avail;
      avail = avail->link;
   }
   else {
      node = (poly_pointer) malloc(sizeof(poly_node));
      if (IS_FULL(node)) {
         fprintf(stderr, "The memory is full\n");
         exit(1);
      }
   }
   return node;
}

程式 4.13:get_node 函數

void ret_node(poly_pointer ptr)
{
/* return a node to the available list */
   ptr->link = avail;
   avail = ptr;
}

程式 4.14:ret_node 函數 —— 注意它就是「推入一個堆疊」

void cerase(poly_pointer *ptr)
{
/* erase the circular list ptr */
   poly_pointer temp;
   if (*ptr) {
      temp = (*ptr)->link;
      (*ptr)->link = avail;
      avail = temp;
      *ptr = NULL;
   }
}

程式 4.15:刪除一個環形串列

cerase 為什麼是常數時間 —— 本節最漂亮的一招

「利用函數 cerase我們可以在一定的時間內刪除一個環狀串列,且和串列的節點個數無關。」對照 erase(程式 4.12)要走過每個節點逐一 free,$O(n)$。

原理:環狀串列只要把兩條環接起來就好 —— 記下第一個節點 temp,把整條環的尾巴(即 *ptr)接到 avail,再讓 avail 指向 temp三個指標指派,$O(1)$。

標頭節點 head node

環狀串列帶來的新麻煩,以及它的解法

「當我們想要實作多項式其他的運算時,在圖 4.13 的結構中所做的改變會造成問題,因為空的多項式必須以特殊情況來處理。為了避免這種特殊狀況,在每一個多項式中加入一個標頭節點(head node),亦即,每一多項式,空的或非空的,包含一個額外的節點。此節點中的 exponcoef 欄是無關緊要的。

習題 2 — 把 padd 改成 cpadd 的七項修改

對於具有標頭節點表示法的環形串列,在 cerase 中可以不必測試 *ptr。在 padd 中我們必須做的改變只有:

  1. 增加兩個變數,starta = astartb = b
  2. while 迴圈之前,指派 a = a->linkb = b->link跳過標頭節點)。
  3. while 迴圈改為 while (a != starta && b != startb)
  4. 將第一個 for 迴圈改為 for (; a != starta; a = a->link)
  5. 將第二個 for 迴圈改為 for (; b != startb; b = b->link)
  6. 刪除 rear->link = NULL; 和「/* delete extra initial node */」各行。
  7. temp = front; front = front->link; free(temp); 改成 rear->link = front;把環接起來)。
點擊展開:expon = −1 這個更進一步的化簡

如果將標頭節點的 expon 欄設定為 −1,則可更進一步地簡化相加演算法。

理由:在我們檢查了 a 所有的節點以後,starta == astarta->expon == -1因 $-1 \le$ b->expon(任何合法指數都 $\ge 0$),我們可以利用接下來執行的 switch 指令來複製 b 中剩餘的各項。如果我們在 a 之前檢查完 b 中所有的節點,這種情況也是成立的。

換句話說:標頭節點的 $-1$ 當成一個哨兵(sentinel),讓「其中一條走完了」這個特殊情況自動落進 case -1case 1 去處理 —— 那兩個收尾用的 for 迴圈就不需要了。這和第 2 章 2.6 節 seqsearchsearchnum 寫進 list[n] 當哨兵,是同一個手法。

poly_pointer cpadd(poly_pointer a, poly_pointer b)
{
/* polynomials a and b are singly linked circular lists
with a head node. Return a polynomial which is the sum
of a and b */
   poly_pointer starta, d, lastd;
   int sum, done = FALSE;
   starta = a;              /* record start of a */
   a = a->link;             /* skip head node for a and b*/
   b = b->link;
   d = get_node();          /* get a head node for sum */
   d->expon = -1; lastd = d;
   do {
      switch (COMPARE(a->expon, b->expon)) {
         case -1: /* a->expon < b->expon */
               attach(b->coef,b->expon,&lastd);
               b = b->link;
               break;
         case 0:  /* a->expon = b->expon */
               if (starta == a)  done = TRUE;
               else {
                  sum = a->coef + b->coef;
                  if (sum) attach(sum,a->expon,&lastd);
                  a = a->link; b = b->link;
               }
               break;
         case 1:  /* a->expon > b->expon */
               attach(a->coef,a->expon,&lastd);
               a = a->link;
      }
   } while (!done);
   lastd->link = d;
   return d;
}

程式 4.16:以環狀串列表示的多項式之加法。注意終止條件 starta == a 出現在 case 0 —— 因為兩條都走回標頭節點時,兩個 expon 都是 −1,恰好相等。

習題 — 多項式(4.4 節習題 7,Programming project)

設計並建立一種鏈結配置系統來表示和處理多項式。你必須使用具有標頭節點的環形串列。為了有效率地刪除多項式,採用本節所討論可用空間串列及相關的函數。設計並測試下列的函數:

函數規格
pread讀取一個多項式,並將它轉換成環形串列表示法。傳回一個指向此多項式的指標。
pwrite以一種可以明白表示多項式的格式輸出多項式。
padd計算 $d = a+b$。不可改變 $a$ 或 $b$。
psub計算 $d = a-b$。不可改變 $a$ 或 $b$。
pmult計算 $d = a \cdot b$。不可改變 $a$ 或 $b$。
eval評估多項式在任一點 $a$ 之值,其中 $a$ 為一個浮點常數。將結果以浮點數傳回。
perase將一個以環形串列表示的多項式還給可用空間串列。

4.5 更多的串列運算

4.5.1 有關鏈的運算

typedef struct list_node *list_pointer;
typedef struct list_node {
        char data;
        list_pointer link;
        };
list_pointer invert(list_pointer lead)
{
/* invert the list pointed to by lead */
   list_pointer middle,trail;
   middle = NULL;
   while (lead) {
      trail = middle;
      middle = lead;
      lead = lead->link;
      middle->link = trail;
   }
   return middle;
}

程式 4.17:將單向鏈結串列反向

三個指標的舞步,以及該怎麼驗證它

「我們對這個常式特別有興趣是因為如果使用三個指標,它可以在同一串列上直接進行。」trail(已反向的部分)、middle(正在處理的)、lead(還沒處理的)。

課本直接給了測試計畫:「至少用三個實例測試這個函數 —— 空串列,只有一個節點的串列,和有兩個節點的串列,以便瞭解它是如何進行的。」對於一個 $\text{length} \ge 1$ 節點的串列,其中的 while 迴圈會執行 length 次,所以它的計算時間是線性的,或 $O(\text{length})$

list_pointer concatenate(list_pointer ptr1,
                                  list_pointer ptr2)
{
/* produce a new list that contains the list ptr1 followed
by the list ptr2. The list pointed to by ptr1 is changed
permanently */
   list_pointer temp;
   if (IS_EMPTY(ptr1)) return ptr2;
   else {
      if (!IS_EMPTY(ptr2)) {
         for (temp = ptr1; temp->link; temp = temp->link)
            ;
         temp->link = ptr2;
      }
      return ptr1;
   }
}

程式 4.18:合併單向鏈結串列 —— 複雜度 $O(\text{ptr1 的長度})$

concatenate 有副作用

註解已經警告了:「The list pointed to by ptr1 is changed permanently.」因這個函數不必為新的串列配置更多的儲存空間,所以 ptr1 也包含了合併以後的串列。習題中將討論不會改變 ptr1 的合併函數(那就必須複製節點,代價是 $O(n)$ 的額外空間)。

4.5.2 有關環狀鏈結串列的運算

一個微小但關鍵的設計選擇

「假設我們要在此串列之前加入一個新的節點。我們必須改變包含 $x_3$ 的節點之鏈結欄。這表示我們必須順著整個串列 a 向下移動,直到找到最後一個節點。」—— $O(n)$。

「如果我們以指向最後一個節點的指標來為環形串列命名,那會更為方便。」這樣一來,ptr 是最後一個節點,ptr->link 就是第一個節點 —— 前端和後端都能在常數時間內取得

x₁x₂x₃ a
圖 4.17:指向環形串列的最後一個節點 —— 這樣就可以在常數時間內從兩端插入。
void insert_front(list_pointer *ptr, list_pointer node)
/* insert node at the front of the circular list ptr,
where ptr is the last node in the list */
{
   if (IS_EMPTY(*ptr)) {
   /* list is empty, change ptr to point to new entry */
      *ptr = node;
      node->link = node;
   }
   else {
   /* list is not empty, add new entry at front */
      node->link = (*ptr)->link;
      (*ptr)->link = node;
   }
}

程式 4.19:在串列之前插入節點

從後端插入,只要多一行

要將 node 加在後端,我們只需加入另一個指令 *ptr = node;insert_frontelse 子句即可。」—— 因為「最後一個節點」的定義就是 ptr 指的那個;把新節點插到前面後,再宣告它是最後一個,環的內容不變、起點變了。

int length(list_pointer ptr)
{
/* find the length of the circular list ptr */
   list_pointer temp;
   int count = 0;
   if (ptr) {
      temp = ptr;
      do {
         count++;
         temp = temp->link;
      } while (temp != ptr);
   }
   return count;
}

程式 4.20:找出一個環形串列的長度 —— 注意用 do...while 而非 while,因為起點本身也要數進去

習題 — 環狀串列(4.5 節習題)
  1. 建立一個函數在環狀鏈結串列中找尋一個整數 num。如果 num 在串列中,函數傳回指向包含 num 的節點之指標,否則傳回 NULL。
  2. 設計一個函數從一個環狀鏈結串列刪除包含數值 num 的節點。你的函數首先要找到此數值 num
  3. 設計一個函數將兩個環形串列合併。假設每一個串列的指標指向最後一個節點。函數應傳回一個指標,它指向合併以後的環形串列之最後一個節點。在合併以後,輸入的串列的即不復獨自存在。你的函數之時間複雜度為何?
  4. 設計一個函數將環形串列中的指標反向。
點擊展開解題要點

第 3 題(考點):兩個環形串列 p1p2 都指向各自的最後一個節點。合併只需要交換兩條環的「第一個節點」指標

list_pointer cconcat(list_pointer p1, list_pointer p2)
{
   list_pointer temp;
   if (!p1) return p2;
   if (!p2) return p1;
   temp = p1->link;        /* p1 的第一個節點 */
   p1->link = p2->link;    /* p1 的尾接到 p2 的頭 */
   p2->link = temp;        /* p2 的尾接回 p1 的頭 */
   return p2;              /* p2 現在是新環的最後一個節點 */
}

時間複雜度 $O(1)$ —— 這正是「指向最後一個節點」這個約定的回報。對照單向鏈結的 concatenate(程式 4.18)需要 $O(\text{ptr1 的長度})$ 走到尾巴。

第 1、2 題的陷阱:環狀串列不能while (temp) 當終止條件(永遠不是 NULL,會無限迴圈)。必須像 length 一樣用 do { ... } while (temp != ptr);

4.6 等價關係

讓我們將一些鏈結和循序表示法的觀念結合在一起,以便解決在設計和製造超大型積體電路(VLSI)時會遭遇的問題。在製造 VLSI 線路的過程中的一個步驟是將矽晶的簿層以一系列的罩遮來曝光。每一個罩遮由數個多邊形構成。電氣重疊的多邊形是等價的(equivalent),而電氣等價表示罩遮多邊形之間的一種關係。

定義 — 等價關係 equivalence relation

在集合 $S$ 上的一個關係 $\equiv$ 稱為在 $S$ 上的等價關係,若且唯若它在 $S$ 上具有下列三個性質:

性質定義在 VLSI 的意義
反身性 reflexive$x \equiv x$$x$ 和其自身電氣等價
對稱性 symmetric若 $x \equiv y$,則 $y \equiv x$重疊是雙向的
遞移性 transitive若 $x \equiv y$ 且 $y \equiv z$,則 $x \equiv z$$x$ 和 $z$ 也是電氣等價

「等價關係的例子有很多。例如,『等於』($=$)關係就是一種等價關係。」

一個具體的例子

如果有 12 個多邊形,編號為 0 到 11,且下列各組重疊:

$$0 \equiv 4,\ 3 \equiv 1,\ 6 \equiv 10,\ 8 \equiv 9,\ 7 \equiv 4,\ 6 \equiv 8,\ 3 \equiv 5,\ 2 \equiv 11,\ 11 \equiv 0$$

則因為 $\equiv$ 關係具有反身性,對稱性,遞移性,我們可將這 12 個多邊形分割成下列的等價類(equivalence class)

$$\{0,2,4,7,11\};\quad \{1,3,5\};\quad \{6,8,9,10\}$$

「這些等價類是重要的,因為它們定義了一個可用來驗證罩遮的正確性之訊號網路。」注意 $0 \equiv 4$、$7 \equiv 4$、$2 \equiv 11$、$11 \equiv 0$ 這四條,靠遞移性把 $\{0,2,4,7,11\}$ 串成一類。

兩階段演算法

void equivalence()
{
   initialize;
   while (there are more pairs) {
      read the next pair <i,j>;
      process this pair;
   }
   initialize the output;
   do
      output a new equivalence class;
   while (not done);
}

程式 4.21:等價演算法第一階段

為什麼不用二維陣列 pairs[n][m]

「序對 $\langle i,j \rangle$ 基本上是兩個在範圍 0 到 $n-1$ 的隨機整數。要有簡易的隨機存取暗示應使用一個陣列,如 pairs[n][m]。第 $i$ 列包含了和輸入值 $i$ 有直接關係的序對元素 $j$。但這種方法可能浪費大量的空間,因只會用到少數的陣列元素。而且這可能需要使用大量的時間將新的序對 $\langle i,k \rangle$ 插入列 $i$,因為我們必須掃描該列以便找到下一個閒置位置或配置更多的記憶體。

最終的資料結構 —— 鏈結與循序的混合

「這些考慮因素使我們對每一列採用一個鏈結串列表示法。節點的結構僅需要一個資料欄及一個鏈結欄。但是,因為仍需隨機存取第 $i$ 列,我們採用一個一維陣列 seq[n] 來儲存 $n$ 個串列的標頭節點。至於演算法的第二階段,則需要一種方法來指示物件 $i$ 是否已被印出。我們採用陣列 out[n] 以及常數 TRUE 和 FALSE 來達成此目的。」

void equivalence()
{
   initialize seq to NULL and out to TRUE;
   while (there are more pairs) {
      read the next pair, <i,j>;
      put j on the seq[i] list;
      put i on the seq[j] list;
   }
   for (i = 0; i < n; i++)
      if (out[i]) {
         out[i] = FALSE;
         output this equivalence class;
      }
}

程式 4.22:等價演算法更詳細的版本 —— 注意每一個序對 放進兩個串列(對稱性)

第二階段的「堆疊」從哪裡來

「在第二階段,我們掃描 seq 陣列以找出第一個 $i$ 值,$0 \le i \lt n$,且 out[i] == TRUE。在串列 seq[i] 中的每一個元素均被印出。根據遞移性,要處理和 $i$ 同一類的其餘串列,我們為其節點建立一個堆疊。要這樣做,改變鏈結欄,使它們指向相反的方向。」—— 又是 invert 的技巧:重用既有節點當堆疊,不配置任何新空間。程式 4.23 中的 y = x->link; x->link = top; top = x; x = y; 正是這一步。

#include <stdio.h>
#include <alloc.h>
#define MAX_SIZE 24
#define IS_FULL(ptr) (!(ptr))
#define FALSE 0
#define TRUE 1

typedef struct node *node_pointer;
typedef struct node {
        int data;
        node_pointer link;
        };
void main(void)
{
   short int out[MAX_SIZE];
   node_pointer seq[MAX_SIZE];
   node_pointer x,y,top;
   int i,j,n;

   printf("Enter the size (<= %d) ",MAX_SIZE);
   scanf("%d",&n);
   for (i = 0; i < n; i++) {
   /* initialize seq and out */
      out[i] = TRUE;   seq[i] = NULL;
   }

   /* Phase 1: Input the equivalence pairs: */
   printf("Enter a pair of numbers (-1 -1 to quit): ");
   scanf("%d%d",&i,&j);
   while (i >= 0) {
      x = (node_pointer)malloc(sizeof(node));
      if (IS_FULL(x)) {
         fprintf(stderr,"The memory is full\n");
         exit(1);
      }
      x->data = j;   x->link = seq[i];   seq[i] = x;
      x = (node_pointer)malloc(sizeof(node));
      if (IS_FULL(x)) {
         fprintf(stderr, "The memory is full\n");
         exit(1);
      }
      x->data = i;   x->link = seq[j];   seq[j] = x;
      printf("Enter a pair of numbers (-1 -1 to quit): ");
      scanf("%d%d",&i,&j);
   }

   /* Phase 2: output the equivalence classes */
   for (i = 0; i < n; i++)
      if (out[i]) {
         printf("\nNew class: %5d",i);
         out[i] = FALSE;   /* set class to false */
         x = seq[i];  top = NULL; /* initialize stack */
         for (;;) {        /* find rest of class */
            while (x) {   /* process list */
               j = x->data;
               if (out[j]) {
                  printf("%5d",j);   out[j] = FALSE;
                  y = x->link; x->link = top; top = x; x = y;
               }
               else x = x->link;
            }
            if (!top) break;
            x = seq[top->data]; top = top->link; /*unstack*/
         }
      }
}

程式 4.23:用來找出等價類的程式

分析等價程式
  • seqout 的設置需時 $O(n)$。
  • 第一階段輸入等價序對時,每一序對需要一個常數時間。因此,這個階段一共需要 $O(m+n)$ 時間,其中 $m$ 為輸入的序對個數。
  • 第二階段,我們最多將每一個節點放入鏈結堆疊中一次。因為只有 $2m$ 個節點,且 for 迴圈執行 $n$ 次,這個階段需要 $O(m+n)$ 時間。
$$T_{\text{equivalence}} = O(m+n), \qquad S_{\text{equivalence}} = O(m+n)$$

任何處理等價關係的演算法必須檢查所有的 $m$ 個等價序對,以及所有的 $n$ 個多邊形至少一次。因此,沒有一個演算法所需的時間少於 $O(m+n)$。這表示在一個常數因子之內,此等價演算法是最佳的。不幸地,演算法所需的空間也是 $O(m+n)$。在第 5 章,對這個問題我們有另一種解法,只需要 $O(n)$ 空間。

第 5 章會怎麼改進

第 5 章的 union-find(集合的合併與尋找)只維護一個大小為 $n$ 的父節點陣列,完全不儲存序對 —— 讀進一對就立刻合併兩棵樹,讀完就丟。空間因此降到 $O(n)$。這是本書中「換一個資料結構,空間就掉一個數量級」最乾淨的一個例子,值得讀完第 5 章再回來對照。

4.7 稀疏矩陣

在第 2 章,我們看到只要儲存稀疏矩陣中不為零的元素,即可節省空間和計算時間。然而,我們發現當執行加,減,乘等矩陣運算時,非零項個數會改變。就像多項式的情況一樣,稀疏矩陣表示法局部的運算會先建立,而後加以清除以便提供空間給其他的矩陣使用。所以,稀疏矩陣的循序表示法和多項式的循序表示法受困於相同的缺點。

十字鏈結 (orthogonal list) 表示法

「在我們的資料表示法中,稀疏矩陣的每一行以具有標頭節點的環狀鏈結串列表示。稀疏矩陣的每一列也有類似的表示法。每一個節點有一個標示欄位,它用來區別標頭節點與資料項節點。」

標頭節點:三個欄位

downrightnext(加上 tag)

down 用來鏈結成一行串列,right 用來鏈結成一個列串列。next 將標頭節點鏈結在一起。列 $i$ 的標頭節點亦就是行 $i$ 的標頭節點,且標頭節點之個數為 $\max\{\text{列數},\ \text{行數}\}$。

資料節點:五個欄位

rowcoldownrightvalue(加上 tag)

down 用來鏈結同一行中下一個非零項,right 用來鏈結同一列中下一個非零項。因此,如果 $a_{ij} \ne 0$,就有一個節點的標示欄為 entryvalue$= a_{ij}$、row$=i$、col$=j$。將這個節點加入列 $i$ 和行 $j$ 的環狀鏈結串列中 —— 所以它是同時被加入兩個不同的串列中。

downheadrightnext downheadrowcolright value entryij a (a) 標頭節點(b) 資料節點(c) 設定 a ij ij
圖 4.19:稀疏矩陣節點構造 —— 兩種節點大小不同,所以用 union 把它們包在一個型態裡。
#define MAX_SIZE 50 /*size of largest matrix*/
typedef enum {head,entry} tagfield;
typedef struct matrix_node *matrix_pointer;
typedef struct entry_node {
        int row;
        int col;
        int value;
        };
typedef struct matrix_node {
        matrix_pointer down;
        matrix_pointer right;
        tagfield tag;
        union {
            matrix_pointer next;
            entry_node entry;
            } u;
        };
matrix_pointer hdnode[MAX_SIZE];

「因為我們的表示法中有兩種不同的節點形式,我們使用 union 來建立適當的資料結構。也就是說,這個資料結構比以前曾建立的任一種資料結構複雜。」—— 這是第 2 章 2.2.2 節 union 的第一個真正重要的應用。

00110 12000 0−400 000−15
圖 4.20:$4\times4$ 稀疏矩陣 $a$ —— 16 個元素中只有 4 個非零。
空間需求

「如果要表示一個具有 num_terms 非零項的 $\texttt{num\_rows} \times \texttt{num\_cols}$ 的矩陣,則需要

$$\max\{\texttt{num\_rows},\ \texttt{num\_cols}\} + \texttt{num\_terms} + 1 \quad\text{個節點}$$

因每一個節點需要數個 word 的記憶體,num_terms 相當小時全部的記憶體將少於 $\texttt{num\_rows} \cdot \texttt{num\_cols}$。」(那個 $+1$ 是整個矩陣的標頭節點,它的 rowcol 欄用來儲存矩陣的維度。)

三個運算及其分析

函數作用複雜度
mread讀入稀疏矩陣並轉換成鏈結表示法。輸入第一行是 num_rowsnum_colsnum_terms,其後 num_terms 行為 row,col,value以列序輸入,每一列中以行序輸入$O(\texttt{num\_rows} + \texttt{num\_cols} + \texttt{num\_terms})$
mwrite將稀疏矩陣的內容印出。外層 for 執行 num_rows 次;對任一列 $i$,內層 for 執行的次數等於列 $i$ 上不為零的項目之個數。$O(\texttt{num\_rows} + \texttt{num\_terms})$
merase清除一個稀疏矩陣,將所有節點還給系統記憶體。逐列釋放資料節點與標頭節點,最後再釋放剩下的標頭節點。$O(\texttt{num\_rows} + \texttt{num\_cols} + \texttt{num\_terms})$
matrix_pointer new_node(void)
{
   matrix_pointer temp;
   temp = (matrix_pointer) malloc(sizeof(matrix_node));
   if (IS_FULL(temp)) {
      fprintf(stderr, "The memory is full\n");
      exit(1);
   }
   return temp;
}

程式 4.25:取得一個新的矩陣節點

void merase(matrix_pointer *node)
{
/* erase the matrix, return the nodes to the heap */
   matrix_pointer x,y, head = (*node)->right;
   int i, num_heads;
   /* free the entry and head nodes by row */
   for (i = 0; i < (*node)->u.entry.row; i++) {
      y = head->right;
      while (y != head) {
         x = y; y = y->right; free(x);
      }
      x = head; head = head->u.next; free(x);
   }
   /* free remaining head nodes*/
   y = head;
   while (y != *node) {
      x = y; y = y->u.next; free(x);
   }
   free(*node);  *node = NULL;
}

程式 4.27:清除一個稀疏矩陣

分析 mread —— 一個誠實的比較

「因為採用 last 來追蹤目前工作的列,行及採用 next 追蹤目前工作的行,我們也可以在一個常數時間之內設立每一個非零項。因此,輸入和鏈結每一個項目節點的 for 迴圈僅需要 $O(\texttt{num\_terms})$ 時間。函數其餘的工作需時 $O(\max\{\texttt{num\_rows},\texttt{num\_cols}\})$。」

$$T_{\text{mread}} = O(\max\{\texttt{num\_rows},\texttt{num\_cols}\} + \texttt{num\_terms}) = O(\texttt{num\_rows} + \texttt{num\_cols} + \texttt{num\_terms})$$

「值得注意的是,這個時間比起採用一個二維陣列來輸入 $\texttt{num\_rows} \times \texttt{num\_cols}$ 矩陣所需的 $O(\texttt{num\_rows} \cdot \texttt{num\_cols})$ 已經是相當好的了。但是,它仍然比 2.4 節中所有的循序方法來得差些。」—— 課本沒有粉飾:鏈結表示法買到的是「大小可變」的彈性,不是全面的速度優勢。

習題 — 稀疏矩陣(4.7 節習題)
  1. 令 $a$ 和 $b$ 為兩個稀疏矩陣。設計一個函數 madd 以產生矩陣 $d = a+b$。你的函數必須令矩陣 $a$ 和 $b$ 維持不變,並設立 $d$ 為新的矩陣。說明我們可以在 $O(\texttt{num\_rows}+\texttt{num\_cols}+\texttt{num\_terms}_a+\texttt{num\_terms}_b)$ 時間內完成此加法之運算。
  2. 設計一個函數 mmult 產生矩陣 $d = a \cdot b$。說明可以在 $O(\texttt{num\_cols} \cdot \texttt{num\_terms}_b + \texttt{num\_rows}_a \cdot \texttt{num\_terms}_b)$ 時間完成。你可否想出一個方法,在 $O(\min\{\texttt{num\_cols}_b \cdot \texttt{num\_terms}_a,\ \texttt{num\_rows}_a \cdot \texttt{num\_terms}_b\})$ 時間內完成?
  3. (a) 重新設計 merase 使得它將被清除的串列放置在可用空間串列而不是將其還給系統記憶體。(b) 重新設計 mread,使得它先試圖從可用空間串列中取得一個新節點。
  4. 設計一個函數 mtranspose,計算矩陣 $b = a^T$。你的函數所需的計算時間為何?
  5. 設計一個函數來複製一個稀疏矩陣。你的函數所需的計算時間為何?
點擊展開解題要點

第 4 題(最值得做的一題):十字鏈結表示法的轉置幾乎是免費的 —— 因為每個節點已經同時掛在列串列和行串列上。轉置只要:交換每個節點的 downright、交換 rowcol、並交換標頭節點的角色。時間 $O(\texttt{num\_rows}+\texttt{num\_cols}+\texttt{num\_terms})$。

對照第 2 章:循序表示法的 transpose 是 $O(\textit{cols}\cdot\textit{elements})$,fast_transpose 是 $O(\textit{cols}+\textit{elements})$。十字鏈結的轉置和 fast_transpose 同級,但不需要那兩個輔助陣列。

第 3 題:free() 換成 ret_node()(4.4.4 節的可用空間串列),new_node() 改成先看 avail對於反覆建立/清除中間結果矩陣的計算,這能大幅減少 mallocfree 的呼叫次數。

第 1 題的關鍵限制:「令矩陣 $a$ 和 $b$ 維持不變」代表不能重接 $a$、$b$ 的鏈結,必須為 $d$ 配置全新的節點。這和 4.4 節 padd 的約定一致。

4.8 雙向鏈結串列

單向鏈結串列的兩個痛點

「單向鏈結串列可能引起一些問題,因為我們只能在其中沿著鏈結的方向來移動。舉例而言,如果指標指向一個特定點的節點 ptr,而我們想要找到在 ptr 之前的節點。要達成這件事唯一的方法是從串列的開頭找起,直找到一個節點,其鏈結欄位指向 ptr 為止。對於刪除運算而言,因為我們必須知到前一個節點的位置,很顯然地,這個工作不能以單向鏈結串列有效地完成。每當我們需要在任一個方向移動而有困難時,最有用的方法是改用雙向鏈結串列。」

typedef struct node *node_pointer;
typedef struct node {
        node_pointer llink;
        element item;
        node_pointer rlink;
        };
雙向鏈結串列的基本恆等式

假設 ptr 指著雙向鏈結串列任一個節點,則:

$$\texttt{ptr} = \texttt{ptr->llink->rlink} = \texttt{ptr->rlink->llink}$$

「這個公式反應出此種構造基本的行為,就是說,我們可以很容易地向前或向後移動。」這個式子也是檢查你的插入/刪除程式是否正確的最快方法 —— 改完之後,每個受影響的節點都應該仍滿足它。

標頭節點在這裡不是選配

「一個空的串列不是真正的空的,因它恆具有標頭節點。」標頭節點的 item 欄通常不包含任何資訊,但它讓「空串列」和「非空串列」的程式碼完全一樣 —— 空串列就是標頭節點自己指向自己。

dinsert 與 ddelete

void dinsert(node_pointer node, node_pointer newnode)
{
/* insert newnode to the right of node */
   newnode->llink = node;
   newnode->rlink = node->rlink;
   node->rlink->llink = newnode;
   node->rlink = newnode;
}

程式 4.28:插入節點到雙向鏈結環狀串列 —— 四行,常數時間,不需要區分空/非空

void ddelete(node_pointer node, node_pointer deleted)
{
/* delete from the doubly linked list */
   if (node == deleted)
      printf("Deletion of head node not permitted.\n");
   else {
      deleted->llink->rlink = deleted->rlink;
      deleted->rlink->llink = deleted->llink;
      free(deleted);
   }
}

程式 4.29:從一個雙向鏈結環形串列刪除節點

對照一下,這就是雙向串列的價值
運算單向鏈結雙向鏈結
已知節點,插入其後$O(1)$$O(1)$
已知節點,刪除它$O(n)$(要先找前一個)$O(1)$
已知節點,找前一個$O(n)$$O(1)$
每節點的額外空間1 個指標2 個指標

「刪除」那一列就是整節的理由。注意 ddelete 中的兩行 —— 「我們只要改變所要刪除的節點之前一個節點(deleted->llink->rlink)與後一個節點(deleted->rlink->llink)之鏈結欄位」。

習題 3 — 雙向串列(4.8 節習題)
  1. 假設有一個雙向鏈結串列,如圖 4.23 所示,而我們想要加入一個新的節點到串列的第二和三個節點之間。重畫此圖使其表示所做的插入運算。標示出有影響的節點之欄位,使其可以表示在 dinsert 函數中的每一個指令是如何執行的。舉例而言,標示出 newnode->llinknewnode->rlink,及 node->rlink->llink
  2. 重做習題 1,但改成由串列刪除第二個節點。
  3. § [Programming project] 假設某電腦公司的員工資料如圖 4.27 所示。對每一員工,除了員工姓名以外,我們有職銜,識別號碼,以及服務地點。對於任何的資訊類型,我們都希望能快速存取。例如,我們希望能快速地取得所有在 New York 工作的員工名單,或者,所有 Programmer 的名單。執行這工作的一個方法是利用多元串列(multilist)。這種資料結構包含了除了姓名以外的每一欄位的索引表。設計函數完成:(a) 在多元串列中插入一個新員工記錄 (b) 從多元串列刪除一個員工記錄 (c) 改變多元串列中任一個欄位的資訊,並重組鏈結,以維持員工記錄之正確性 (d) 以上述任一個欄位查詢多元串列。
點擊展開解題要點

第 1 題的正確執行順序(這才是題目的重點):dinsert 的四行順序不能隨意調換

newnode->llink = node;           /* 1. 新節點向左指 */
newnode->rlink = node->rlink;    /* 2. 新節點向右指(此時 node->rlink 還沒被改) */
node->rlink->llink = newnode;    /* 3. 右鄰居向左指回新節點 */
node->rlink = newnode;           /* 4. 最後才改 node 向右 */

若把第 4 行提前到第 2 行之前,node->rlink 就已經是 newnode,第 2、3 行會接錯,串列的右半段整個遺失。這是雙向串列最常見的 bug,也是題目要你「標示出每一個指令如何執行」的用意。

第 3 題(多元串列):每個員工節點同時掛在多條串列上 —— 一條職銜串列、一條地點串列等,每一條各需要一組 link 欄位。這正是 4.7 節稀疏矩陣「一個節點同時屬於列串列和行串列」的一般化。改變欄位時((c))必須先從舊的那條串列摘下、再掛到新的那條上,不能只改資料欄。

4.9 精選參考文獻

主題參考書目
C 中指標R. Traister, Mastering C Pointers, Academic Press, San Diego, Calif., 1990

4.10 綜合習題

習題 1 — 更簡易的稀疏矩陣表示法

如果將運算限制為加法,減法,和乘法,我們可以獲得一種更簡易且更有效率的稀疏矩陣表示法。」在此表示法中,節點有 downrightrowcolvalue 欄位。我們將每一個非零項以一個節點表示,並將它們鏈結形成兩個環形串列

  • 第一個串列是列串列,它依照列序將節點連接起來,各列依照行序連接。這以 right 欄位來完成。
  • 第二個串列是行串列,它依照行序將節點連接起來,各行中依照列序連接。這以 down 欄位來完成。

這兩個串列共用標頭節點。此外,我們增加一個包含矩陣維度的節點。使用和 mread 相同的假設,設計一個函數來讀入稀疏矩陣並建立其內部節點。你的函數需要多少計算時間?需要多少額外的空間?

這個「更簡易」的版本省掉了什麼

4.7 節的表示法每一列、每一行各有一個獨立的環狀串列與標頭節點,所以需要 $\max\{\text{列},\text{行}\}$ 個標頭節點。綜合習題 1 的版本把全部的列串成一條大環、全部的行串成另一條大環,標頭節點只需要一個。空間省下 $O(\max\{\text{列},\text{行}\})$。

代價是:不能在常數時間跳到第 $i$ 列的開頭(必須沿著大環走)。這就是為什麼題目限制「運算只有加、減、乘」—— 這三種運算都是從頭到尾掃過一遍,不需要隨機存取某一列。

本章重點回顧

一定要記住的清單
  1. 鏈結表示法的核心交易:用「每節點一個指標」的空間,換掉「插入/刪除要搬資料」的時間。
  2. 指標三戒:不用的指標設 NULL、轉型要明確 cast、函數要寫明傳回型態。永遠 free 不需要的記憶體,否則產生懸浮參考。
  3. insert 的第一個參數是 list_pointer *ptr(雙重指標),因為串列空時要改變 ptr 本身。
  4. padd 是 $O(m+n)$,且「在一個常數因子之內已是最佳的」。用 rear 指標 + 假的初始節點兩個技巧。
  5. 環狀串列 + availcerase 是 $O(1)$,對照鏈的 erase 是 $O(n)$。標頭節點消除空串列的特殊情況;expon = -1 當哨兵消除收尾迴圈。
  6. 等價演算法:$O(m+n)$ 時間與空間,時間是最佳的,空間不是 —— 第 5 章的 union-find 降到 $O(n)$。
  7. 稀疏矩陣十字鏈結:節點同時掛在列串列與行串列上,需要 $\max\{\text{列},\text{行}\}+\texttt{num\_terms}+1$ 個節點,用 union 區分兩種節點。
  8. 雙向串列:刪除從 $O(n)$ 降到 $O(1)$。dinsert 的四行順序不能調換。恆等式 ptr = ptr->llink->rlink = ptr->rlink->llink
本章各函數的複雜度
函數時間
insert / delete(已知位置)$O(1)$
print_list / length$O(n)$
padd$O(m+n)$
erase(鏈)$O(n)$
cerase(環狀)$O(1)$
invert$O(n)$
concatenate$O(|\texttt{ptr1}|)$
環狀串列合併$O(1)$
equivalence$O(m+n)$
mread / merase$O(r+c+t)$
mwrite$O(r+t)$
dinsert / ddelete$O(1)$
最容易答錯的五個點
  1. 環狀串列不能while (ptr) 終止,要用 do...while (temp != ptr)
  2. cerase$O(1)$erase 是 $O(n)$ —— 差別在環不必逐一走。
  3. 環狀串列命名要指向最後一個節點,兩端插入才都是 $O(1)$。
  4. 等價演算法時間最佳、空間不最佳
  5. dinsertnode->rlink = newnode; 必須放在最後
往後各章如何用到本章

本章的自我參考結構就是第 5 章樹節點的原型(把一個 link 換成 left_childright_child 兩個)。4.6 節的等價問題在第 5 章用 union-find 重做,空間降到 $O(n)$。4.8 節的雙向串列是第 9 章各種堆積與第 10 章平衡樹刪除運算的基礎。第 6 章的圖形相鄰串列(adjacency list)就是本章的鏈結表示法直接套用在圖上。習題 6 的「合併兩個已排序串列、不用額外節點」則是第 7 章合併排序的核心。