Outline — 點擊展開各節
2.1 陣列視同一種 ADT
2.2 結構和聯結
2.3 多項式 ADT
2.4 稀疏矩陣 ADT
2.5 多維陣列的表示法
2.6 字串 ADT
2.7 / 2.8 文獻與綜合習題
Chapter 2 · Arrays and Structures

第 2 章 陣列與結構

把陣列從「一連串記憶位址」提升成抽象資料型態,再用它蓋出多項式、稀疏矩陣與字串 —— 本章是「同一個 ADT 可以有很多種表示法,而表示法決定效率」的第一次完整示範。

本章的主線

第 1 章給了評比的工具,第 2 章開始真正做資料結構。整章反覆出現同一個循環:

  1. 先寫 ADT —— 只說「有哪些運算、各做什麼」,不碰實作。
  2. 再選表示法 —— 同一個 ADT 可以有好幾種,各有空間/時間的取捨。
  3. 最後分析 —— 用第 1 章的漸近式表示法算出每個運算的複雜度,回頭檢討表示法選得對不對。

多項式(2.3 節)、稀疏矩陣(2.4 節)、字串(2.6 節)都是這個循環的實例。最戲劇性的一段在 2.4.2:同一個「轉置」運算,換一種寫法就從 $O(\textit{columns}\cdot\textit{elements})$ 掉到 $O(\textit{columns}+\textit{elements})$。

2.1 陣列視同一種抽象資料型態

一個不平常的觀點

「我們將陣列視為一種 ADT 以開始我們的討論。這是不平常的觀點,因多數的程式人員僅以『一連串的記憶位址』來看待陣列。這是不恰當的看法,因它明顯地只著重在實作問題。雖然陣列通常以一連串的記憶位址實作,但並非恆是如此的。」

直觀上而言,陣列是一組序對 $\langle \textit{index}, \textit{value}\rangle$,其中每一個索引(index)定義了一個與其關連的值(value)。在數學上而言,我們稱此為對應映射。然而,當我們視其為一種 ADT 時,我們更關心的是可以在一個陣列上執行的運算

除了建立一個新的陣列以外,多數的程式語言只為陣列提供兩種標準運算:一個是擷取(retrieve)一個值,另一個是儲存(store)一個值。

structure Array is
  objects: A set of pairs <index, value> where for each value of index
    there is a value from the set item. Index is a finite ordered set of
    one or more dimensions, for example, {0, ... , n-1} for one dimension,
    {(0,0), (0,1), (0,2), (1,0), (1,1), (1,2), (2,0), (2,1), (2,2)} for
    two dimensions, etc.
  functions:
    for all A in Array, i in index, x in item, j, size in integer

    Array Create(j, list)  ::=  return an array of j dimensions where list
                                is a j-tuple whose ith element is the size
                                of the ith dimension. Items are undefined.
    Item  Retrieve(A, i)   ::=  if (i in index) return the item associated
                                with index value i in array A
                                else return error
    Array Store(A, i, x)   ::=  if (i in index)
                                return an array that is identical to array
                                A except the new pair <i, x> has been
                                inserted else return error.

end Array

結構 2.1:抽象資料型態 Array

這樣定義的好處

「此種 ADT 定義的優點是可以清楚地指出陣列是比『一連串的記憶位址』更具有一般性的結構。」注意 Create(j, list)建構函數Store轉換函數Retrieve觀察函數 —— 正好對應第 1 章 ADT 的三類函數。

基底位址與定址

在 C 語言中的一個一維陣列以變數名稱後面加上中括號來定義,例如:

int list[5], *plist[5];

宣告了兩個陣列,各包含了五個元素。第一個陣列定義了五個整數,而第二個陣列定義了五個指向整數的指標(pointer)。在 C 中,所有的陣列索引自 0 開始

當編譯程式遇到了一個陣列宣告,它會配置五個連續的記憶位置。第一個元素 list[0] 的位址稱為基底位址(base address)。如果一個整數的大小在你的機器上可用 sizeof(int) 表示,可得到 list[] 的五個元素之記憶位址如下:

變數記憶位址
list[0]base address = $\alpha$
list[1]$\alpha + \texttt{sizeof(int)}$
list[2]$\alpha + 2\cdot\texttt{sizeof(int)}$
list[3]$\alpha + 3\cdot\texttt{sizeof(int)}$
list[4]$\alpha + 4\cdot\texttt{sizeof(int)}$
兩個宣告的差別 —— 以及一個很容易記錯的等式

下列的宣告是有差別的:

int *list1;      /* 只是一個指標,沒有預留空間  */
int list2[5];    /* 預留了五個記憶位置          */

變數 list1list2 都是指向一個 int 的指標,但在第二個宣告中預留了五個記憶位置以儲存整數值。list2 為指向 list2[0] 的指標,而 list2+i 為指向 list2[i] 的指標。

C 的指標算術 $$\texttt{(list2+i)} \equiv \texttt{\&list2[i]}, \qquad \texttt{*(list2+i)} \equiv \texttt{list2[i]}$$

應注意,在 C 中我們並未將位移 $i$ 乘上型態的大小來取得陣列中適當的元素。也就是說,不論陣列 list2 的型態為何,(list2+i) 永遠等於 &list2[i]

陣列作為函數參數
#define MAX_SIZE 100
float sum(float [], int);
float input[MAX_SIZE], answer;
int i;
void main(void)
{
   for (i = 0; i < MAX_SIZE; i++)
     input[i] = i;
   answer = sum(input, MAX_SIZE);
   printf("The sum is: %f\n", answer);
}
float sum(float list[], int n)
{
   int i;
   float tempsum = 0;
   for (i = 0; i < n; i++)
     tempsum += list[i];
   return tempsum;
}

程式 2.1:陣列程式範例

C 函數的所有參數必須在函數中宣告。但是,一個一維陣列的範圍只有在主程式中定義,因為在函數中並未配置陣列的新位置。如果需要一維陣列的大小,它或是當成一個參數傳入函數,或是當成一個總體變數來存取。

這段話解釋了第 1 章的 $S_{sum}(n)=0$

當呼叫 suminput = &input[0] 被複製到一個暫時儲位,並將其關連到型式參數 list因此,在 C 語言中,不管數參數傳遞是以傳值(call-by-value)方式傳送的事實,陣列參數可讓其值被改變。複製的是「位址」,不是整個陣列 —— 這正是第 1 章範例 1.7 說 C 的 sum 可變空間需求為 0 的原因。

範例 2.1 [一維陣列定址]

假設已作了如下的宣告:

int one[] = {0, 1, 2, 3, 4};

我們想要編寫一個函數以印出此陣列第 $i$ 個元素的位址,以及在此位址找到的整數值。函數以 print1(&one[0], 5) 呼叫。

void print1(int *ptr, int rows)
{
/* print out a one-dimensional array using a pointer */
   int i;
   printf("Address Contents\n");
   for (i = 0; i < rows; i++)
     printf("%8u%5d\n", ptr + i, *(ptr + i));
   printf("\n");
}

程式 2.2:以位址存取一維陣列

第 $i$ 個元素的位址僅以 ptr+i 來表示。要獲得第 $i$ 個元素之值,我們使用完全參考運算符號 *(dereferencing,即參考一個記憶位址並傳回其值)。

位址內容
12280
12301
12322
12343
12364

圖 2.1:一維陣列定址

應注意每一個元素的位址遞增 2 個位元組。這是在 Intel 386 機器上會看到的結果。

2.2 結構和聯結

2.2.1 結構 (structure)

陣列是相同型態的資料之集合。在 C 語言中,有不同的方法可以將不同型態的資料群集在一起 —— 此種方法稱為 struct,為結構(structure)的縮寫。一個結構(在許多其他的語言中稱為記錄,record)是資料項的集合,每個資料項分別以其名稱和型態定義。

struct {
        char name[10];
        int age;
        float salary;
        } person;

產生一個命名為 person 的變數,它有三個欄位:姓名(字元陣列)、年齡(整數)、薪水(float)。

我們可用如下方式指定這些欄位值。注意,點號 . 當成結構成份運算符號

strcpy(person.name,"james");
person.age = 10;
person.salary = 35000;

我們也可以使用 typedef 指令來建立自設的結構資料型態:

typedef struct human_being {          typedef struct {
        char name[10];                        char name[10];
        int age;              或              int age;
        float salary;                        float salary;
        };                                   } human_being;

在此 human_being 是型態名稱,可以在此定義之後以它來宣告變數:human_being person1, person2;

結構不能直接比較 —— ANSI C 的重要差別

如果我們可以寫下 if (person1 == person2) 而令這兩個結構被檢查是否相等,或者寫下 person1 = person2 來代表指定運算,將是很美好的事。

ANSI C 允許結構的指定運算,但多數早期的 C 語言則不允許。對於較早的 C 版本,我們被迫以更詳細的格式編寫:

strcpy(person1.name, person2.name);
person1.age = person2.age;
person1.salary = person2.salary;

雖然結構不能直接檢查相等或不相等,我們可以編寫函數來做這件事。假設 TRUEFALSE 定義為 #define FALSE 0#define TRUE 1

int humans_equal(human_being person1,
                          human_being person2)
{
/* return TRUE if person1 and person2 are the same human
being otherwise return FALSE */
   if (strcmp(person1.name, person2.name))
     return FALSE;
   if (person1.age != person2.age)
     return FALSE;
   if (person1.salary != person2.salary)
     return FALSE;
   return TRUE;
}

程式 2.3:檢查結構是否相等的函數

巢狀結構

我們也可以將一個結構內含在另一個結構之內。例如以前述的 human_being 結構而言,我們也許會想在其中包含個人的出生日期:

typedef struct {
        int month;
        int day;
        int year;
        } date;

typedef struct human_being {
        char name[10];
        int age;
        float salary;
        date dob;
        };

一個在 1944 年 2 月 11 日出生的人,其 date struct 之值可以設定為:

person1.dob.month = 2;
person1.dob.day = 11;
person1.dob.year = 1944;

2.2.2 聯結 (union)

繼續以 human_being 為例,如果可以辨別男性和女性將是很美好的事。如果是男性,我們也許想知道他是否有留鬍子;如果是女性,我們也許想知道她有幾個小孩。這型成 C 的另一種特色,稱為 union

union 的定義

union 的宣告與結構類似,但 union 中的欄位必須共用記憶空間。這表示在任何時候只有一個 union 欄位是「有效的」。

typedef struct sex_type {
  enum tag_field {female, male} sex;
  union {
     int children;
     int beard;
     } u;
  };
typedef struct human_being {
        char name[10];
        int age;
        float salary;
        date dob;
        sex_type sex_info;
        };
human_being person1, person2;

我們可以指定欄位值給 person1person2,如:

person1.sex_info.sex = male;
person1.sex_info.u.beard = FALSE;

person2.sex_info.sex = female;
person2.sex_info.u.children = 4;
C 不會替你檢查 —— 標識欄位是你自己的責任

應注意,我們首先設定標識欄位(tag field)之值。這使我們可以決定 union 中那一個欄位是有效的。然後將一個值放置在 union 中適當的欄位內。

C 並不會檢查我們是否用了恰當的欄位。例如,我們也許在 sex_info.sex 中存入 female 值,而後直接將 TRUE 值存入 sex_info.u.beard 欄位中。雖然我們知道這樣不恰當,C 並未檢查以確定我們使用了正確的 union 欄位。

2.2.3 結構的內部實作

多數情況下,我們不需要關心 C 編譯程式如何將結構中的欄位確實地存入記憶體中。一般而言,如果結構定義如下:

struct {int i,j; float a, b;};
或
struct {int i; int j; float a; float b; };

各欄位值將根據欄位在結構定義中的先後順序,以位址漸增的方式儲存在記憶體中。

空洞與壓縮 (holes and padding)

「瞭解在一個結構中實際上會有空洞或壓縮以便將兩個相鄰的欄位適當地安排在記憶體中是很重要的。」

一個 structunion 型態的物件之大小是表示最大的組成元素所需要的記憶之數量,包括任何必要的壓縮在內。結構必須在相同型式的記憶位址界限開始或結束,例如,偶數位元組邊界,或是 4、8、16 的倍數之位址。

2.2.4 自我參考結構

定義 — 自我參考結構 self-referential structure

自我參考結構是一種結構,其中的一個或多個組成元素是指向自身的指標。自我參考結構通常需要動態記憶管理常式(mallocfree)來明確地取得或釋回記憶體。

typedef struct list {
        char data;
        list *link ;
        } ;

結構 list 的每一個實體有兩個成份,datalinkdata 為單一字元,而 link 為指向 list 結構的指標。link 之值或者是 list 的一個實體在記憶體中的位址,或者是虛指標(null pointer)

list item1, item2, item3;
item1.data = 'a';
item2.data = 'b';
item3.data = 'c';
item1.link = item2.link = item3.link = NULL;

結構 item1item2item3 分別包含資料項 a、b 和 c,以及虛指標。藉著將 item2 中的虛指標欄位 link 以指向 item3 的指標取代之,將 item1 中的虛指標欄位 link 以指向 item2 的指標取代之,我們可將此三個結構連接在一起:

item1.link = &item2;
item2.link = &item3;
abc datalink datalink datalink item1item2item3 NULL
三個自我參考結構串成一條鏈。斜線代表虛指標 —— 這正是第 4 章「串列」的起點。

有關這種鏈結將在第 4 章詳細說明。

習題 1 — 設計結構(2.2 節習題)
  1. 設計一種結構來表示太陽系中的行星。每一個行星的欄位有行星名稱,它和太陽的距離(單位 mile),及它所有的衛星之數目。針對行星 Earth 和 Venus,分別在它們各別的欄位中儲入適當的值。
  2. 修改 human_being 結構,使我們可以根據不同的婚姻狀態加入不同的資訊。婚姻狀態可以是列舉資料型態(enumerated type),其值為 singlemarriedwidoweddivorced。使用 union,以婚姻狀態為依據,包含下列資訊:
    Single:不需其他資訊。
    Married:包含一個結婚日期欄位。
    Widowed:包含結婚日期欄位,以及配偶死亡的日期欄位。
    Divorced:包含離婚日期欄位,以及離婚次數欄位。
  3. 設計一種結構來代表下列各個幾何物件:rectangle(長方形)、triangle(三角形)、circle(圓形)。
點擊展開解題要點

第 1 題直接用巢狀 struct 即可,重點是欄位型態選對:距離用 double(數量級到 $10^9$ mile),衛星數用 int,名稱用 char name[12]

第 2 題是本節的核心練習,骨架與 sex_type 完全平行:

typedef struct {
  enum marital_tag {single, married, widowed, divorced} status;
  union {
    date  marriage_date;             /* married  */
    struct { date marriage, death; } w;   /* widowed  */
    struct { date divorce; int times; } d; /* divorced */
  } u;                                /* single 不需欄位 */
} marital_type;

注意 single 這一支不佔 union 的任何欄位 —— 這正是 union 的用意:整個 marital_type 只需要最大那一支(widowed,兩個 date)的空間。

第 3 題同樣用 tag + union:rectangle 存長寬、triangle 存三邊或底與高、circle 存半徑。這是「用一個型態表示多種形狀」的標準寫法,也是往後圖形/樹的節點常見的做法。

2.3 多項式抽象資料型態

陣列不僅只是陣列資料結構而已,我們也可利用它來製作其他的抽象資料型態。例如,一種簡單而常見的資料結構:有序串列(ordered list)線性串列(linear list)。我們可以找到此種資料結構的許多實例:

應留意瑞士的年份有些不同,因為它不包含任何項目。它是空串列的例子,空串列以 () 表示。其他的串列都有資料項,以 $(\textit{item}_0, \textit{item}_1, \ldots, \textit{item}_{n-1})$ 的格式寫出。

在串列上可以執行許多的運算,包括:找出串列的長度 $n$;由左而右(或由右而左)讀取串列中的項目;擷取/替換第 $i$ 個項目($0 \le i \lt n$);在第 $i$ 個位置插入一個新項目(原編號 $i, i+1, \ldots, n-1$ 變成 $i+1, i+2, \ldots, n$);自第 $i$ 個位置刪除一個項目(原編號 $i+1, \ldots, n-1$ 變成 $i, \ldots, n-2$)。

循序映射 (sequential mapping) —— 以及它的死穴

最常用的實作法是以陣列來表示一個有序串列,其中串列的元素 $\textit{item}_i$ 關連到陣列的索引 $i$。我們稱此為循序映射,因如果採用陣列標準的實作法,$\textit{item}_i$、$\textit{item}_{i+1}$ 將會儲存在陣列中連續的 $i$ 和 $i+1$ 位置內。

循序映射對上列多數的運算都可以有良好的表現:可以在常數時間內擷取一個項目、替換一項項目,或找出一個串列的長度;也可以從任一個方向讀取串列中的項目。

只有插入和刪除被困住了,因為循序配置法迫使我們移動串列中的項目以維持循序映射。就因為此種負擔,使得我們在第 4 章中考慮有序串列的非循序映射方法。

多項式:一個需要有序串列的問題

從數學觀點來看,多項式為各項的加總,各項的格式為 $ax^e$,其中 $x$ 為變數,$a$ 為係數,而 $e$ 為指數。下面是兩個多項式的例子:

$$A(x) = 3x^{20} + 2x^5 + 4 \qquad\text{與}\qquad B(x) = x^4 + 10x^3 + 3x^2 + 1$$

多項式最大的(或第一個)指數稱為次方(degree)。等於 0 的係數不必寫出;指數為 0 的項目不必寫出變數,因為 $x$ 的 0 次方為 1。假設有兩個多項式 $A(x)=\sum a_ix^i$ 和 $B(x)=\sum b_ix^i$,則:

$$A(x)+B(x) = \sum (a_i+b_i)x^i$$ $$A(x)\cdot B(x) = \sum\Big(a_i x^i \cdot \sum (b_j x^j)\Big)$$

結構 2.2 抽象資料型態 Polynomial

structure Polynomial is
  objects: p(x) = a1*x^e1 + ... + an*x^en; a set of ordered pairs of
    <ei, ai> where ai in Coefficients and ei in Exponents, ei are
    integers >= 0
  functions:
    for all poly, poly1, poly2 in Polynomial, coef in Coefficients,
    expon in Exponents

    Polynomial   Zero()                  ::= return the polynomial, p(x) = 0
    Boolean      IsZero(poly)            ::= if (poly) return FALSE
                                             else return TRUE
    Coefficient  Coef(poly, expon)       ::= if (expon in poly) return its
                                             coefficient else return zero
    Exponent     Lead_Exp(poly)          ::= return the largest exponent in poly
    Polynomial   Attach(poly,coef,expon) ::= if (expon in poly) return error
                                             else return the polynomial poly
                                             with the term <coef, expon> inserted
    Polynomial   Remove(poly, expon)     ::= if (expon in poly)
                                             return the polynomial poly with
                                             the term whose exponent is expon
                                             deleted else return error
    Polynomial   SingleMult(poly,coef,expon) ::= return the polynomial
                                             poly * coef * x^expon
    Polynomial   Add(poly1, poly2)       ::= return the polynomial poly1 + poly2
    Polynomial   Mult(poly1, poly2)      ::= return the polynomial poly1 * poly2

end Polynomial

結構 2.2:抽象資料型態 polynomial

先寫演算法,再決定表示法

現在我們要對表示法做一些決定。非常合理的,第一個決定要求不同的指數以降冪順序安排。這個要求可以簡化許多的運算。使用我們的定義和此規定,我們可以寫出和 C 函數非常接近的 Add 函數(程式 2.4),但它仍然與表示法無關 —— 這就是 ADT 的價值。

/* d = a + b, where a, b, and d are polynomials */
d = Zero()
while (! IsZero(a) && ! IsZero(b)) do {
  switch COMPARE(Lead_Exp(a), Lead_Exp(b)) {
    case -1: d =
       Attach(d, Coef(b,Lead_Exp(b)), Lead_Exp(b));
       b = Remove(b, Lead_Exp(b));
       break;
    case  0: sum = Coef(a, Lead_Exp(a)) + Coef(b, Lead_Exp(b));
       if (sum) {
          Attach(d, sum, Lead_Exp(a));
          a = Remove(a, Lead_Exp(a));
          b = Remove(b, Lead_Exp(b));
          }
       break;
    case 1: d =
       Attach(d, Coef(a,Lead_Exp(a)), Lead_Exp(a));
       a = Remove(a, Lead_Exp(a));
  }
}
insert any remaining terms of a or b into d

程式 2.4:padd 函數的第一個版本

兩種表示法的取捨

表示法一:係數陣列
#define MAX_DEGREE 101
typedef struct {
        int degree;
        float coef[MAX_DEGREE];
        } polynomial;

a 的型態是 polynomial,且 $n \lt$ MAX_DEGREE,則 $A(x)=\sum_{i=0}^{n} a_ix^i$ 表示為 a.degree = na.coef[i] = $a_{n-i}$。

缺點:「雖然此種表示法對多數的運算可產生非常簡單的演算法,它浪費了許多的空間。」若 a.degree 遠小於 MAX_DEGREE,或多項式是稀疏的(係數不為 0 的項次個數相對於次方很小),就有大量位置閒置。

表示法二:整體性 terms 陣列
MAX_TERMS 100 /*size of terms array*/
typedef struct {
        float coef;
        int expon;
        } polynomial;
polynomial terms[MAX_TERMS];
int avail = 0;

只儲存非零項,所有多項式共用同一個陣列。每個多項式以一對 <start, finish> 索引界定;avail 指向下一個可用位置。

startafinishastartbfinishbavail
coef2111031
exp100004320
0123456

圖 2.2:兩個多項式的陣列表示法 —— $A(x)=2x^{1000}+1$、$B(x)=x^4+10x^3+3x^2+1$。此例中 starta=0、finisha=1、startb=2、finishb=5、avail=6。

哪一種比較好? —— 課本自己給的評估

「它是否比每一個多項式各使用一個係數陣列的表示法還好?」

  • 明顯地,它解決了有太多零項的問題,因 $A(x)=2x^{1000}+1$ 僅使用了 6 個儲存單位:starta 用 1 個、finisha 用 1 個、係數 2 個、指數 2 個。
  • 然而,當所有的項次均不為 0,目前的表示法所需的空間將是第一種表示法的兩倍(每項要存係數 指數)。

結論:「除非我們可以事先知道每一個多項式只有少數的零項次,目前的表示法可能是比較好的一種。」任何一個具有 $n$ 個非零項的多項式 $A$ 之 starta 和 finisha 值,使得 finisha = starta + $n$ − 1 成立。

padd 及其分析

void padd(int starta,int finisha,int startb, int finishb,
                             int *startd,int *finishd)
{
/* add A(x) and B(x) to obtain D(x) */
   float coefficient;
   *startd = avail;
   while (starta <= finisha && startb <= finishb)
     switch(COMPARE(terms[starta].expon,
                    terms[startb].expon)) {
       case -1: /* a expon < b expon */
                attach(terms[startb].coef,terms[startb].expon);
                startb++;
                break;
       case 0: /* equal exponents */
                coefficient = terms[starta].coef +
                              terms[startb].coef;
                if (coefficient)
                   attach(coefficient,terms[starta].expon);
                starta++;
                startb++;
                break;
       case 1: /* a expon > b expon */
                attach(terms[starta].coef,terms[starta].expon);
                starta++;
   }
   /* add in remaining terms of A(x) */
   for(; starta <= finisha; starta++)
     attach(terms[starta].coef,terms[starta].expon);
   /* add in remaining terms of B(x) */
   for( ; startb <= finishb; startb++)
     attach(terms[startb].coef, terms[startb].expon);
   *finishd = avail-1;
}

程式 2.5:將兩個多項式相加的函數

void attach(float coefficient, int exponent)
{
/* add a new term to the polynomial */
   if (avail >= MAX_TERMS) {
     fprintf(stderr,"Too many terms in the polynomial\n");
     exit(1);
   }
   terms[avail].coef = coefficient;
   terms[avail++].expon = exponent;
}

程式 2.6:加入新項次的函數

分析 padd

因為在多項式 $A$ 和 $B$ 中的非零項之個數是時間複雜度最重要的因素,我們將以它們來進行分析。令 $m$ 和 $n$ 分別為多項式 $A$ 和 $B$ 中非零項的個數。

  • 若 $m \gt 0$ 且 $n \gt 0$,將會進入 while 迴圈。此迴圈每次執行需時 $O(1)$。
  • 在每次迴圈執行中,startastartb 之值遞增,或兩者均遞增。因迴圈在 starta 大於 finishastartb 大於 finishb 時停止,迴圈執行次數的上限為 $m+n-1$
  • 其最差狀況發生在 $\displaystyle A(x)=\sum_{i=0}^{n} x^{2i}$ 與 $\displaystyle B(x)=\sum_{i=0}^{n} x^{2i+1}$(指數完全交錯,永遠不會同時遞增兩個)。
  • 其餘兩個迴路的時間受限於 $O(n+m)$。
$$T_{\text{padd}} = O(n+m)$$
這個表示法留下的問題

當建立多項式時,avail 之值會遞增,直到它等於 MAX_TERMS。當這種情況發生時,我們必須結束程式嗎?以目前的表示法而言,我們必須結束,除非有一些多項式將不再使用。我們可以編寫一個壓縮函數,它會刪除不再需要的多項式並在陣列之尾端產生一個大的、連續的可用空間。但是,它需要費時的資料移動工作;此外,對於每一個有移動的多項式,我們必須改變其開始和結束的索引。在第 3 章中會經驗一些「簡易」的壓縮常式。

2.4 稀疏矩陣抽象資料型態

2.4.1 簡介

在數學上,一個矩陣包含 $m$ 列和 $n$ 行的元素。通常寫為 $m \times n$(讀作 "m by n")。在這樣的矩陣中,所有的元素個數為 $mn$。如果 $m$ 等於 $n$,矩陣為一個方陣

計算機科學上,一個矩陣的標準表示法為二維陣列,如定義 a[MAX_ROWS][MAX_COLS]。以這種表示法,我們可以用 a[i][j] 快速地找到一個元素。但是,仍有一些問題存在此標準表示法中:

什麼是稀疏矩陣

「雖然要明確地判斷一個矩陣是否為稀疏矩陣是困難的,當我們看到它時,會直覺地辨識它是稀疏矩陣。」課本的例子是一個 $6\times 6$ 的矩陣,36 個元素中只有 8 個不為 0,「所以一定是稀疏矩陣」。

為什麼要在意:考慮儲存一個 $1000 \times 1000$ 的矩陣所需要的空間需求。如果此矩陣包含大多數是 0 的元素,我們會浪費相當大量的空間。因此,稀疏矩陣表示法應該只儲存不為 0 的元素

structure Sparse_Matrix is
  objects: a set of triples, <row, column, value>, where row and column
    are integers and form a unique combination, and value comes from the
    set item.
  functions:
    for all a, b in Sparse_Matrix, x in item, i, j, max_col, max_row in index

    Sparse_Matrix Create(max_row, max_col) ::=
        return a Sparse_Matrix that can hold up to max_items =
        max_row x max_col and whose maximum row size is max_row and
        whose maximum column size is max_col.

    Sparse_Matrix Transpose(a) ::=
        return the matrix produced by interchanging the row and column
        value of every triple.

    Sparse_Matrix Add(a, b) ::=
        if the dimensions of a and b are the same
        return the matrix produced by adding corresponding items, namely
        those with identical row and column values.
        else return error

    Sparse_Matrix Multiply(a, b) ::=
        if number of columns in a equals number of rows in b
        return the matrix d produced by multiplying a by b according to
        the formula: d[i][j] = sum(a[i][k] * b[k][j]) where d(i, j) is
        the (i, j)th element
        else return error.

end Sparse_Matrix

結構 2.3:抽象資料型態 Sparse_Matrix

三項式表示法的三個設計決定

查看矩陣,我們知道可以使用三項式 <row, col, value> 來唯一決定矩陣中的任一個元素。因為我們想要轉置運算可以有效率地執行:

  1. 必須將三項式按照列索引的升冪順序來排列;
  2. 更進一步要求,任一列的三項式都依其行索引的升冪順序儲存;
  3. 為了保証運算會結束,必須知道列數、行數,以及矩陣中非零元素之個數

將這些資訊集中起來,即成為 a[0] 這一列:a[0].row 包含列數,a[0].col 包含行數,a[0].value 包含全部不為 0 之元素個數。

Sparse_Matrix Create(max_row, max_col) ::=

     #define MAX_TERMS 101 /* maximum number of terms +1*/
     typedef struct {
             int col;
             int row;
             int value;
             } term;
     term a[MAX_TERMS];
rowcolvalue
a[0]668
[1]0015
[2]0322
[3]05−15
[4]1111
[5]123
[6]23−6
[7]4091
[8]5228
rowcolvalue
b[0]668
[1]0015
[2]0491
[3]1111
[4]213
[5]2528
[6]3022
[7]32−6
[8]50−15

圖 2.4:(a) 以三項式儲存的稀疏矩陣 (b) 其轉置矩陣

2.4.2 轉置矩陣

要將矩陣轉置,我們必須將行和列互換。就是說,在原始矩陣中的每一個元素 a[i][j] 變成轉置矩陣中的元素 b[j][i]。因為我們已將原始的矩陣依列序排列,我們或許會以為下列的演算法是將矩陣轉置的一個好方法:

for each row i
  take element <i, j, value> and store it
  as element <j, i, value> of the transpose;
為什麼這個「好方法」不行

「很不幸地,如果我們以列索引來處理原始的矩陣,除非我們已經處理了在元素 <j,i,value> 之前的所有元素,我們將不能確知該將它放在轉置矩陣的那一個位置。」例如在圖 2.4 中可得到:

(0,0,15) 變成 (0,0,15)
(0,3,22) 變成 (3,0,22)
(0,5,-15) 變成 (5,0,-15)

如果我們將三項式連續地放在轉置矩陣中,當我們插入新的三項式時,就必須移動元素以保持正確的順序。

藉著使用行索引來決定元素放在轉置矩陣中的位置,我們可避免這種資料移動。這建議下列的演算法:

for all elements in column j
  place element <i, j, value> in
  element <j, i, value>

這個演算法暗示了我們應該「找出行 0 所有的元素,而後將它們存放在轉置矩陣的列 0,找出行 1 所有的元素,而後將它們存放在轉置矩陣的列 1,等」。因為原始的矩陣以列序排列,轉置矩陣中每一列的各行也會以升冪順序排列。

void transpose(term a[], term b[])
/* b is set to the transpose of a */
{
   int n,i,j, currentb;
   n = a[0].value;          /* total number of elements */
   b[0].row = a[0].col;     /* rows in b = columns in a */
   b[0].col = a[0].row;     /* columns in b = rows in a */
   b[0].value = n;
   if (n > 0 ) { /* non zero matrix */
     currentb = 1;
     for (i = 0; i < a[0].col; i++)
     /* transpose by the columns in a */
       for (j = 1; j <= n; j++)
       /* find elements from the current column */
         if (a[j].col == i) {
         /* element is in current column, add it to b */
           b[currentb].row = a[j].col;
           b[currentb].col = a[j].row;
           b[currentb].value = a[j].value;
           currentb++;
         }
   }
}

程式 2.7:轉置一個稀疏矩陣

分析 transpose

所使用的巢狀 for 迴路是決定性因素。其餘的指令(兩個 if 指令和數個指派指令)僅需要常數時間。

  • 外層的迴圈執行 a[0].col 次,其中 a[0].col 存放原始矩陣的行數。
  • 內層迴路每做一次需時 a[0].value,即原始矩陣中元素的個數。
$$T_{\text{transpose}} = O(\textit{columns} \cdot \textit{elements})$$
這個時間「有一點困擾」

如果以大小為 $\textit{rows} \times \textit{columns}$ 的二維陣列來表示矩陣,轉置只要:

for (j = 0; j < columns; j++)
  for (i = 0; i < rows; i++)
    b[j][i] = a[i][j];

時間為 $O(\textit{rows}\cdot\textit{columns})$。當元素個數的次方為 $\textit{columns}\cdot\textit{rows}$ 時,我們的轉置函數之時間從 $O(\textit{columns}\cdot\textit{elements})$ 變成 $O(\textit{columns}^2 \cdot \textit{rows})$。為了節省空間,我們反而浪費了許多時間。

fast_transpose:用空間換回時間

實際上,藉著多使用一點儲存空間,我們可以建立一個更好的演算法,在 $O(\textit{columns}+\textit{elements})$ 時間內轉置完成。它首先決定在原始矩陣中每一行的元素個數(這可以決定轉置矩陣中每一列的元素個數),根據這個資訊決定轉置矩陣中每一列的起始位置,然後將原始矩陣中的元素逐一移到轉置矩陣中正確的位置上。

#define MAX_COL 50 /*maximum number of columns + 1*/

void fast_transpose(term a[], term b[])
{
/* the transpose of a is placed in b */
   int row_terms[MAX_COL], starting_pos[MAX_COL];
   int i,j, num_cols = a[0].col, num_terms = a[0].value;
   b[0].row = num_cols;  b[0].col = a[0].row;
   b[0].value = num_terms;
   if (num_terms > 0) { /* nonzero matrix */
      for (i = 0; i < num_cols; i++)
        row_terms[i] = 0;
      for (i = 1; i <= num_terms; i++)
        row_terms[a[i].col]++;
      starting_pos[0] = 1;
      for (i = 1; i < num_cols; i++)
        starting_pos[i] =
                   starting_pos[i-1] + row_terms[i-1];
      for (i = 1; i <= num_terms; i++) {
        j = starting_pos[a[i].col]++;
        b[j].row = a[i].col;   b[j].col = a[i].row;
        b[j].value = a[i].value;
      }
   }
}

程式 2.8:一個稀疏矩陣的快速轉置

如果我們以圖 2.4(a) 之稀疏矩陣來試驗演算法,在執行第三個 for 迴圈以後,row_termsstarting_pos 之值為:

[0][1][2][3][4][5]
row_terms =122201
starting_pos =124688
分析 fast_transpose

這四個迴圈決定了所需的計算時間。迴圈的主體分別執行 num_colsnum_termsnum_cols-1num_terms 次。因迴圈內的指令僅需常數時間:

$$T_{\text{fast\_transpose}} = O(\textit{columns} + \textit{elements})$$

當元素個數的次方為 $\textit{columns}\cdot\textit{rows}$,時間變為 $O(\textit{columns}\cdot\textit{rows})$ —— 這時間和二維陣列表示法所需時間相等,雖然 fast_transpose 有較大的常數因子。然而,當元素個數相對於 $\textit{columns}\cdot\textit{rows}$ 是十分的小時,fast_transpose 是比較快的。

代價:transpose 所需的空間較 fast_transpose 為少,因為後者必須為 row_termsstarting_pos 兩個陣列配置空間。(習題會問你如何縮減成一個陣列。)

2.4.3 矩陣相乘

定義 — 矩陣相乘

對於 $A$ 和 $B$,其中 $A$ 為 $m \times n$,$B$ 為 $n \times p$,則矩陣相乘所得的矩陣 $D$ 的維度為 $m \times p$。其元素 $\langle i,j \rangle$ 為:

$$d_{ij} = \sum_{k=0}^{n-1} a_{ik}\,b_{kj}, \qquad 0 \le i \lt m,\ 0 \le j \lt p$$
兩個稀疏矩陣相乘,結果不一定稀疏
$$\begin{bmatrix}1&0&0\\1&0&0\\1&0&0\end{bmatrix}\begin{bmatrix}1&1&1\\0&0&0\\0&0&0\end{bmatrix}=\begin{bmatrix}1&1&1\\1&1&1\\1&1&1\end{bmatrix}$$

圖 2.5:兩個稀疏矩陣相乘 —— 左右都只有 3 個非零元素,乘積卻是滿的 9 個。

要相乘兩個以有序串列表示的稀疏矩陣,我們必須計算 $D$ 各列的元素,以便將元素儲存在適當的位置上而不必移動先前已計算的元素。做這件事,我們選擇 $A$ 中的一列並找出 $B$ 中行 $j$ 的所有元素,$j=0,1,\ldots,\texttt{cols\_b}-1$。

先轉置 B —— 這就是 fast_transpose 的用武之地

一般而言,我們必須將 $B$ 全部掃描一次以找出在行 $j$ 上的元素。然而,藉著先計算 $B$ 的轉置矩陣,我們即可免除掃描 $B$。這可將各行的元素放置在連續的位置上。一旦我們找到了 $A$ 的列 $i$ 和 $B$ 的行 $j$ 之元素,我們只要做一個類似於 2.3 節多項式加法中所使用的合併運算即可。

void mmult(term a[], term b[], term d[])
/* multiply two sparse matrices */
{
   int i, j, column, totalb = b[0].value, totald = 0;
   int rows_a = a[0].row, cols_a = a[0].col,
   totala = a[0].value; int  cols_b = b[0].col;
   int row_begin = 1, row = a[1].row, sum = 0;
   int new_b[MAX_TERMS][3];
   if (cols_a != b[0].row) {
     fprintf(stderr,"Incompatible matrices\n");
     exit(1);
   }
   fast_transpose(b,new_b);
   /* set boundary condition */
   a[totala+1].row = rows_a;
   new_b[totalb+1].row = cols_b;
   new_b[totalb+1].col = 0;
   for (i = 1; i <= totala; ) {
     column = new_b[1].row;
     for (j = 1; j <= totalb+1;) {
     /* multiply row of a by column of b */
       if (a[i].row != row) {
          storesum(d,&totald,row,column,&sum);
          i = row_begin;
          for (; new_b[j].row == column; j++)
            ;
          column = new_b[j].row;
       }
       else if (new_b[j].row != column) {
          storesum(d, &totald, row, column, &sum);
          i = row_begin;
          column = new_b[j].row;
       }
       else switch (COMPARE(a[i].col, new_b[j].col)) {
          case -1: /* go to next term in a */
             i++;  break;
          case 0: /* add terms, go to next term in a and b*/
             sum += ( a[i++].value * new_b[j++].value);
             break;
          case 1 : /* advance to next term in b */
             j++;
       }
     }  /* end of for j <= totalb+1 */
     for (; a[i].row == row; i++)
       ;
     row_begin = i; row = a[i].row;
   } /* end of for i<=totala */
   d[0].row = rows_a;
   d[0].col = cols_b; d[0].value =  totald;
}

程式 2.9:稀疏矩陣相乘

void storesum(term d[], int *totald, int row, int column,
                                     int *sum)
{
/* if *sum != 0, then it along with its row and column
position is stored as the *totald+1 entry in d */
   if (*sum)
     if (*totald < MAX_TERMS) {
       d[++*totald].row = row;
       d[*totald].col = column;
       d[*totald].value = *sum;
       *sum = 0;
     }
     else {
       fprintf(stderr,"Numbers of terms in product
                       exceeds %d\n",MAX_TERMS);
       exit(1);
     }
}

程式 2.10:storesum 函數

兩個「虛擬表示法」當監看點

注意程式中引用了兩個新的表示法:a[totala+1].row = rows_anew_b[totalb+1].row = cols_b。「這兩個虛擬表示法當做監看點,使我們可以建立一個精緻的演算法」—— 它們讓內層迴圈不必額外寫「已經走到結尾了嗎」的判斷。

分析 mmult

除了 abd 和少數幾個變數所需的空間外,還需要儲存轉置矩陣 new_b 的空間,以及 fast_transpose 所需要的額外空間。

  • 在第一個 for 迴圈以前的指令只需 $O(\texttt{cols\_b}+\texttt{totalb})$ 之時間(將 $b$ 轉置所需的時間)。
  • 外層 for 迴圈執行 totala 次。在整個迴路中 $j$ 最大的增值為 totalb+1
  • termsrow 為 $A$ 中目前所處理的列之所有元素個數,則在 $i$ 移到 $A$ 的下一列之前,$i$ 最多可增加 termsrow 次;此重設最費時 col_b,$i$ 全部的增值最多為 $\texttt{cols\_b} \times \texttt{termsrow}$。
  • 所以外層 for 迴圈最多的執行次數為 $\texttt{cols\_b}+\texttt{cols\_b}\times\texttt{termsrow}+\texttt{totalb}$。
$$O\Big(\sum_{\textit{row}} (\texttt{cols\_b}\cdot\texttt{termsrow}+\texttt{totalb})\Big) = O(\texttt{cols\_b}\cdot\texttt{totala} + \texttt{rows\_a}\cdot\texttt{totalb})$$

因為 $\texttt{totala} \le \texttt{cols\_a}\cdot\texttt{rows\_a}$ 且 $\texttt{totalb} \le \texttt{cols\_a}\cdot\texttt{cols\_b}$,mmult 所需之時間最多為:

$$O(\texttt{rows\_a}\cdot\texttt{cols\_a}\cdot\texttt{cols\_b})$$

傳統的三層迴圈演算法也是 $O(\texttt{rows\_a}\cdot\texttt{cols\_a}\cdot\texttt{cols\_b})$。然而,其常數因子大於傳統的演算法。在最差狀況下 mmult 較慢一個常數因子;但是,當 totala 和 totalb 相對於其最大值是相當的小,即 $A$ 和 $B$ 為稀疏矩陣時,mmult 則比傳統的演算法優異。

這種表示法留下的難題 —— 接到第 3、4 章

「因為稀疏矩陣中元素的個數是變動的,我們傾向於將所有的稀疏矩陣以一個陣列來表示,就像我們在 2.3 節多項式中的作法一樣。這樣可讓我們有效率地使用空間。但是,這樣做使得我們在從陣列中配置各別的矩陣所需的空間上遭遇困難。這個困難在多項式表示法中也會遇到,而且在 3.4 節當我們學習多重堆疊和列的一種類似的表示法時,將變得更為明顯。」

習題 2 — 轉置與相乘(2.4 節習題)
  1. 寫出 C 函數 read_matrixprint_matrixsearch,它們分別讀取三項式到一個新的稀疏矩陣、印出稀疏矩陣中的元素,以及在稀疏矩陣中找尋一個值。分析每一個函數的計算時間。
  2. 重寫 fast_transpose 使其只用一個陣列而非兩個陣列來儲存 row_termsstarting_pos
  3. 針對 mmult 函數,提出一個正確性證明。
  4. 分析 fast_transpose 所需的時間和空間需求。對於是否存在更快的演算法,你有何意見?
  5. 使用在 fast_transpose 中所用的陣列起始位置之觀念來重寫 mmult,使其不必將 $B$ 轉置即可將稀疏矩陣 $A$、$B$ 相乘。你的函數所需的計算時間為何?
點擊展開解題要點

第 2 題(最常考):關鍵觀察是 starting_pos 可以就地row_terms 算出來。先用一個陣列 t[] 統計各行元素個數,再做一次前綴和把 t[i] 原地改寫成起始位置:

int t[MAX_COL], i, j, sum = 1, tmp;
for (i = 0; i < num_cols; i++) t[i] = 0;
for (i = 1; i <= num_terms; i++) t[a[i].col]++;
for (i = 0; i < num_cols; i++) {   /* prefix sum in place */
  tmp = t[i]; t[i] = sum; sum += tmp;
}

之後的搬移迴圈原封不動用 t[a[i].col]++ 即可。空間從 $2\cdot\texttt{MAX\_COL}$ 降為 $\texttt{MAX\_COL}$,時間不變,仍是 $O(\textit{columns}+\textit{elements})$。

第 4 題:時間 $O(\textit{columns}+\textit{elements})$、額外空間 $O(\textit{columns})$。不存在漸近上更快的演算法 —— 因為任何轉置都必須至少讀一次每個非零元素($\Omega(\textit{elements})$),而且輸出的每一列起點都要定出來($\Omega(\textit{columns})$)。所以這個界限是緊的。

第 5 題:不轉置 $B$,改為對 $B$ 也建一份「每一行的起始位置」索引。時間仍是 $O(\texttt{cols\_b}\cdot\texttt{totala}+\texttt{rows\_a}\cdot\texttt{totalb})$,但省下了 $O(\texttt{cols\_b}+\texttt{totalb})$ 的轉置時間與 new_b 的空間

2.5 多維陣列的表示法

多維陣列的內部表示法需要更複雜的定址公式。如果陣列宣告為 $a[\textit{upper}_0][\textit{upper}_1]\cdots[\textit{upper}_{n-1}]$,則陣列中的元素個數可以很清楚地表示為:

元素個數 $$\prod_{i=0}^{n-1} \textit{upper}_i$$

舉例而言,如果宣告 aa[10][10][10],則需要 $10\cdot10\cdot10=1000$ 個儲存單位來保存此陣列。

兩種常用的表示法

列序(row major order)行序(column major order)。在此我們僅討論列序,行序則留作習題。正如其名稱所暗示的,列序根據列的順序儲存多維陣列

二維

$A[\textit{upper}_0][\textit{upper}_1]$ 可以解釋為有 $\textit{upper}_0$ 列的二維陣列,即 $\textit{row}_0, \textit{row}_1, \ldots, \textit{row}_{\textit{upper}_0-1}$,每一列有 $\textit{upper}_1$ 個元素。

假設 $\alpha$ 為 $A[0][0]$ 的位址,則 $A[i][0]$ 的位址將是 $\alpha + i \cdot \textit{upper}_1$,因為在第 $i$ 列的第 1 個元素之前一共有 $i$ 列,每一列有 $\textit{upper}_1$ 個元素。

二維列序定址 $$\text{location}(a[i][j]) = \alpha + i\cdot\textit{upper}_1 + j$$
為什麼公式裡沒有「元素大小」

應注意,我們並未乘上元素的大小。這是遵照 C 的慣用法,元素的大小會自動地計算。」這與 2.1 節 (list2+i) == &list2[i] 是同一件事。

三維

$A[\textit{upper}_0][\textit{upper}_1][\textit{upper}_2]$ 可以解釋為有 $\textit{upper}_0$ 個二維陣列,每一個大小為 $\textit{upper}_1 \times \textit{upper}_2$。$a[i][0][0]$ 的位址為 $\alpha + i\cdot\textit{upper}_1\cdot\textit{upper}_2$:

三維列序定址 $$\text{location}(a[i][j][k]) = \alpha + i\cdot\textit{upper}_1\cdot\textit{upper}_2 + j\cdot\textit{upper}_2 + k$$

n 維的一般式

將上述的討論一般化,一個 $n$ 維的陣列宣告為 $A[\textit{upper}_0][\textit{upper}_1]\ldots[\textit{upper}_{n-1}]$,則對於其中任何一個元素 $A[i_0][i_1]\ldots[i_{n-1}]$ 的定址公式為:

n 維列序定址 $$\text{location} = \alpha + \sum_{j=0}^{n-1} i_j\,a_j, \qquad \begin{cases} a_j = \displaystyle\prod_{k=j+1}^{n-1}\textit{upper}_k, & 0 \le j \lt n-1\\[6pt] a_{n-1} = 1 \end{cases}$$
編譯程式怎麼用這條公式

應注意,$a_j$ 可以從 $a_{j+1}$($0 \le j \lt n-1$)計算而來,只要以一個乘法計算 $a_j = \textit{upper}_{j+1}\cdot a_{j+1}$ 即可。

因此,編譯程式一開始只取用宣告的上限 $\textit{upper}_0, \ldots, \textit{upper}_{n-1}$,而後以 $n-2$ 個乘法來計算常數值 $a_0, \ldots, a_{n-2}$。$a[i_0], \ldots, a[i_{n-1}]$ 的位址可以利用此公式計算,只需要多 $n-1$ 個乘法和 $n$ 個加法與 $n$ 個減法即可。

習題 — 定址(2.5 節習題)
  1. 假設有一個一維陣列 a[MAX_SIZE]。通常此陣列的註標,是 0 到 MAX_SIZE-1。然而,利用指標運算,我們可以建立一個有任意註標範圍的陣列。說明如何建立此種陣列並找出其註標,使其範圍介於 −10 到 10 之間。
  2. 將第 1 題擴充到二維陣列,使其列和行之註標範圍均為 −10 到 10。
  3. 對於一個宣告為 $a[\textit{upper}_0]\ldots[\textit{upper}_{n-1}]$ 的陣列,找出元素 $a[i_0][i_1]\ldots[i_{n-1}]$ 的定址公式。假設此陣列採用行序表示法,每一個元素之大小為 1 word,$a[0][0]\ldots[0]$ 之位址為 $\alpha$。
點擊展開解題要點

第 1 題:配置 int raw[21],再取 int *a = raw + 10;。之後 a[-10]a[10] 都合法,因為 a[-10] 就是 raw[0]。一般式:若註標範圍是 $[\ell, u]$,取 a = raw - l,大小為 $u-\ell+1$。

第 3 題:行序是列序的「反轉」—— 索引的權重由邊累乘而非右邊:

$$\text{location} = \alpha + \sum_{j=0}^{n-1} i_j\,b_j, \qquad \begin{cases} b_j = \displaystyle\prod_{k=0}^{j-1}\textit{upper}_k, & 0 \lt j \le n-1\\[6pt] b_0 = 1 \end{cases}$$

檢查一下課本給的 $3\times3$ 例子:行序的儲存順序是 a[0][0], a[1][0], a[2][0], a[0][1], a[1][1], a[2][1], a[0][2], a[1][2], a[2][2] —— 第一個索引變化最快,正好對應 $b_0=1$。

2.6 字串抽象資料型態

2.6.1 簡介

到目前為止,我們僅討論了成組元素為數值的 ADT。在本節中,我們將注意力轉到另一種資料型態,字串,其組成元素為字元。我們定義字串的形式為 $S = s_0, \ldots, s_{n-1}$,其中 $s_i$ 為程式語言字元集中的字元。如果 $n=0$,則 $S$ 為空字串或虛字串(null string)。

structure String is
  objects: a finite set of zero or more characters.
  functions:
    for all s, t in String, i, j, m in non-negative integers

    String  Null(m)        ::= return a string whose maximum length is
                               m characters, but is initially set to NULL.
                               We write NULL as "".
    Integer Compare(s, t)  ::= if s equals t return 0
                               else if s precedes t return -1
                               else return +1
    Boolean IsNull(s)      ::= if (Compare(s, NULL)) return FALSE
                               else return TRUE
    Integer Length(s)      ::= if (Compare(s, NULL))
                               return the number of characters in s
                               else return 0.
    String  Concat(s, t)   ::= if (Compare(t, NULL))
                               return a string whose elements are those
                               of s followed by those of t
                               else return s.
    String  Substr(s, i, j)::= if ((j > 0) && (i+j-1) < Length(s))
                               return the string containing the characters
                               of s at positions i, i+1, ..., i+j-1.
                               else return NULL.

end String

結構 2.4:抽象資料型態 string

在 C 語言中,字串以字元陣列表示,並以虛字元 \0 作為結束。舉例而言,如果定義了字串:

#define MAX_SIZE 100 /*maximum size of string */
char s[MAX_SIZE] = {"dog"};
char t[MAX_SIZE] = {"house"};
dog\0 hou se\0 s[0]s[1]s[2]s[3] t[0]t[1]t[2] t[3]t[4]t[5]
圖 2.7:在 C 語言中字串表示法 —— 注意兩個字串中均包含了陣列的上限(結束的 \0)。
strcat 的陷阱 —— 課本親自示範的一個真實 bug

假設我們想將兩個字串合併在一起,形成新的字串「doghouse」。我們可使用 strcat(s,t),結果儲存在 s 中。

雖然 s 的長度增加了 5 個字元,在 s 中並沒有多餘的空間來儲存額外的 5 個字元。我們的編譯程式處理這個問題的方法並不高明:它僅是重新寫入記憶體以便調適 5 個額外的字元。因為在 s 之後立即宣告了 t,這表示字彙「house」將會消失。

C 的字串函數

C 提供了其他數個字串函數,可透過指令 #include <string.h> 來存取它們。

函數說明
char *strcat(char *dest, char *src)合併 dest 和 src 字串;結果存在 dest
char *strncat(char *dest, char *src, int n)合併 dest 與 src 中的 n 個字元;結果存在 dest
char *strcmp(char *str1, char *str2)比較兩個字串;若 str1<str2,結果 <0;若 str1=str2,結果 =0;若 str1>str2,結果 >0
char *strncmp(char *str1, char *str2, int n)比較前面的 n 個字元;判定規則同上
char *strcpy(char *dest, char *src)將 src 複製到 dest;傳回 dest
char *strncpy(char *dest, char *src, int n)由字串 src 中複製 n 個字元到 dest;傳回 dest
size_t strlen(char *s)傳回字串 s 的長度
char *strchr(char *s, int c)傳回指向字串 s 中的第一個 c 字元的指標;若找不到,則傳 NULL
char *strrchr(char *s, int c)傳回指向字串 s 中最後一個 c 字元的指標;若找不到,則傳 NULL
char *strtok(char *s, char *delimiters)傳回 s 中的一個符號;此符號包含在分隔字元 delimiters 中
char *strstr(char *s, char *pat)傳回在 s 中的子串列 pat 的起始指標
size_t strspn(char *s, char *spanset)在 s 中找尋包含於 spanset 中的字元;傳回跨距的長度
size_t strcspn(char *s, char *spanset)在 s 中找尋不包含於 spanset 中的字元;傳回跨距的長度
char *strpbrk(char *s, char *spanset)在 s 中找尋包含於 spanset 中的字元;傳回指向第一個屬於 spanset 字元的指標

圖 2.8:C 的字串函數

範例 2.2 [String Insertion] 字串插入

假設我們有兩個字串 string1string2,並想要從 string1 的第 $i$ 個位置起,將 string2 插入 string1 中。設第 1 個字串為「amobile」,第 2 個字串為「uto」,要從位置 1 開始插入「uto」,所以產生了字彙「automobile」。

amob ile\0 uto\0 \0 a\0 auto\0 auto mobi le\0 st temptemp temptemp 起始 (a) strncpy(temp, s, i) 之後 (b) strcat(temp, t) 之後 (c) strcat(temp, (s+i)) 之後
圖 2.9:字串插入範例 —— 只用三個函數呼叫就完成。因為 strncpy 複製前 $i$ 個字元,s 餘留下來的部份在位址 (s+i)
void strnins(char *s, char *t, int i)
{
/* insert string t into string s at position i */
  char string[MAX_SIZE], *temp = string;

  if (i < 0 && i > strlen(s)) {
    fprintf(stderr,"Position is out of bounds \n");
    exit(1);
  }
  if (!strlen(s))
    strcpy(s,t);
  else if (strlen(t)) {
    strncpy(temp, s,i);
    strcat(temp,t);
    strcat(temp, (s+i));
    strcpy(s, temp);
  }
}

程式 2.11:字串插入函數

課本自己說:這個程式不該用

「此特別的函數一般在 <string.h> 中並不能找到。因為這兩個字串中任何一個都可以是空的,我們在程式中也使用了檢查此種情況的指令。值得注意的是函數呼叫 strnins(s,t,0)strcat(t,s) 是對等的。程式 2.11 供作字串處理的一個範例。因為它很浪費空間和時間,在實際上不應使用它。試著重新改寫它,使得字串 temp 是非必要的。

2.6.2 樣式比對 (pattern matching)

假設有兩個字串 stringpat,其中 pat 是要在 string 中找尋的樣式。判斷 pat 是否在 string 中最簡單的方式是使用內建函數 strstr

為何還要自己寫樣式比對函數?課本給了兩個理由
  1. 函數 strstr 對 ANSI C 是新的。因此,對我們所使用的編譯程式可能不適用。
  2. 實作樣式比對函數有許多不同的方法。最簡單但最沒有效率的方法是循序地檢查字串中的每一個字元,直到找到所要的樣式,或到達字串結尾。如果 pat 不在 string 中,此種方法的計算時間為 $O(n\cdot m)$,其中 $n$ 為 pat 之長度,$m$ 為 string 的長度。

改良一:nfind —— 先比對結尾

藉著在 strlen(pat)string 中剩下的字元之個數還多時結束比對工作,我們可以大幅地改良完全地樣式比對技術。在我們繼續檢查剩餘的字元之前檢查 patstring 的第一個與最後一個字元是第二個改良點。

int nfind(char *string, char *pat)
{
/* match the last character of pattern first, and
then match from the beginning */
   int i,j,start = 0;
   int lasts = strlen(string)-1;
   int lastp = strlen(pat)-1;
   int endmatch = lastp;

   for (i = 0; endmatch <= lasts; endmatch++, start++) {
     if (string[endmatch] == pat[lastp])
       for (j = 0, i = start; j < lastp &&
                   string[i] == pat[j]; i++,j++)
         ;
     if (j == lastp)
       return start; /* successful */
     }
     return -1;
}

程式 2.12:首先檢查結尾索引的樣式比對程式

分析 nfind

如果將 nfind 應用在 string = "aa...a"pat = "a...ab" 時,則這些字串所需的計算時間為 $O(m)$,與字串的長度線性相關,這顯然比循序比對方法好很多。雖然我們在循序比對方法上所做的改良對平均時間有所改善,但最差狀況的計算時間仍為 $O(n\cdot m)$。

Knuth–Morris–Pratt 演算法

理想目標

在理想上,我們當然希望演算法的工作時間為 $O(\texttt{strlen(string)}+\texttt{strlen(pat)})$。這對此問題是最佳的時間複雜度,而且在最差狀況時,它必須比對樣式中和字串的所有字元至少一次。我們希望在字串中找尋樣式時不需在字串中向後移動 —— 就是說,當比對不成功時,我們要利用對樣式中的字元以及比對不成功發生在樣式中那一個位置的認知,來決定應當從那一個位置繼續搜尋。

使用 Knuth、Morris 和 Pratt 的範例,假設 pat = 'abcabcacab'。令 $s = s_0s_1\ldots s_{m-1}$ 為字串,並且假設我們正在判斷從 $s_i$ 開始的位置是否有一個比對成功存在。

假設 pat 的前四個字元比對成功,其後有一個比對不成功,即 $s_{i+4} \ne b$:

s   = '-  a  b  c  a  ?  ?  .  .  .  .  ?'
pat = 'a  b  c  a  b  c  a  c  a  b'

經過觀察,我們發現搜尋匹配的樣式可以從比較 $s_{i+4}$ 和 pat 的第二個字元 b 再繼續。這是將樣式 pat 向右滑動可以發生部份配的第一個位置。

定義 — 失敗函數 failure function

若 $P = p_0p_1\ldots p_{n-1}$ 為一個樣式,則其失敗函數 $f$ 定義如下:

$$f(j)=\begin{cases} \max\{\,i \mid i \lt j \text{ 且 } p_0p_1\cdots p_i = p_{j-i}p_{j-i+1}\cdots p_j\,\}, & \text{若存在一個這樣的 } i \ge 0\\[4pt] -1, & \text{若不存在時} \end{cases}$$

對於範例樣式 pat = "abcabcacab",我們可得:

j0123456789
patabcabcacab
f−1−1−10123−101
樣式比對規則

根據失敗函數的定義,我們可獲得下列的樣式比對規則:如果找到部份的匹配使得 $s_{i-j}\ldots s_{i-1} = p_0p_1\ldots p_{j-1}$ 且 $s_i \ne p_j$,若 $j \ne 0$,則比對工作可以從比較 $s_i$ 和 $p_{f(j-1)+1}$ 繼續。若 $j=0$,則從比較 $s_{i+1}$ 和 $p_0$ 繼續。

#include <stdio.h>
#include <string.h>
#define max_string_size 100
#define max_pattern_size 100
int pmatch();
void fail();
int failure[max_pattern_size];
char string[max_string_size];
char pat[max_pattern_size];

int pmatch(char *string, char *pat)
{
/* Knuth, Morris, Pratt string matching algorithm */
  int i = 0, j = 0;
  int lens = strlen(string);
  int lenp = strlen(pat);
  while ( i < lens && j < lenp ) {
    if (string[i] == pat[j]) {
      i++; j++; }
    else if (j == 0) i++;
        else j = failure[j-1]+1;
  }
  return ( (j == lenp) ? (i-lenp) : -1);
}

程式 2.13:Knuth、Morris、Pratt 的樣式比對演算法

分析 pmatch

while 迴圈持續執行,直到字串或樣式之一到達其結尾。因為 $i$ 值不會減少,則用來將 $i$ 值增加的指令最多執行 $m = \texttt{strlen(string)}$ 次。將 $j$ 重設為 failure[j-i]+1 使得 $j$ 值減少。故此一指令所執行的次數不會多於 $j$ 在比對失敗時被指令 j++ 增值的次數。每當 j++ 指令執行時,$i$ 值也會增加。故 $j$ 值最多增加 $m$ 次。

$$T_{\text{pmatch}} = O(m) = O(\texttt{strlen(string)})$$

快速計算失敗函數

根據 pmatch 的分析,如果我們可以在 $O(\texttt{strlen(pat)})$ 時間內計算失敗函數,則整個樣式比對過程的計算時間將和字串和樣式的長度和成正比。幸運地,有一個快速的方法可計算失敗函數。它以下列重新定義的失敗函數為依據:

$$f(j)=\begin{cases} -1 & \text{若 } j=0\\[4pt] f^{\,m}(j-1)+1 & \text{其中 } m \text{ 為使得} p_{f^{\,m}(j-1)+1}=p_j \text{ 的最小整數值 } k\\[4pt] -1 & \text{若沒有 } k \text{ 值滿足上式} \end{cases}$$

(注意,$f^{\,1}(j)=f(j)$ 且 $f^{\,m}(j)=f(f^{\,m-1}(j))$。)

void fail(char *pat)
{
/* compute the pattern's failure function */
  int n = strlen(pat);
  failure[0] = -1;
  for (j=1; j < n; j++) {
    i = failure[j-1];
    while ((pat[j] != pat[i+1]) && (i >= 0))
      i = failure[i];
    if (pat[j] == pat[i+1])
      failure[j] = i+1;
    else failure[j] = -1;
  }
}

程式 2.14:計算失敗函數

分析 fail

在每次的 while 迴路中,$i$ 值會減少(根據 $f$ 的定義)。變數 $i$ 值在每一次 for 迴路執行時之開始被重設。然而,其值或者重設為 −1,或者重設為比前一次執行的終止值多 1。因為 for 迴圈僅執行 $n-1$ 次($n$ 為樣式的長度),則 $i$ 值最多增加到 $n-1$。所以其值最多被減少 $n-1$ 次。總之,在整個演算法中 while 迴圈最多執行 $n-1$ 次。

$$T_{\text{fail}} = O(n) = O(\texttt{strlen(pat)})$$

注意,當失敗函數無法預知時,則先計算失敗函數,然後再執行樣式比對所需的時間為

$$O(\texttt{strlen(pat)} + \texttt{strlen(string)})$$
習題 3 — 失敗函數與字串函數(2.6 節習題)

(a) 對於下列的各個樣式,找出其失敗函數:aaaabababaaabaabaab

(b) 證明 nfind 所需的計算時間為 $O(n\cdot m)$,其中 $n$ 和 $m$ 分別為字串和樣式的長度。找出一個使這成立的字串和樣式的實例。

(c) 證明失敗函數的兩個定義是對等的。

點擊展開解題要點

(a) 逐位套定義「最長的真前綴同時也是到此為止的後綴」:

j01234567
aaaab−1012−1
ababaa−1−10120
abaabaab−1−1001234

驗算 abaabaab 的 $f(7)=4$:前綴 abaab(長 5,索引 0..4)與後綴 abaab(索引 3..7)相同,所以 $i=4$。

(b)string = "aaaa...a"($m$ 個 a)、pat = "aa...ab"($n-1$ 個 a 再加一個 b)。nfind 的外層迴圈跑 $m-n+1$ 次,每次因為 string[endmatch] == pat[lastp] 這個快速檢查不會幫上忙(結尾字元是 b,永遠不等於 a)…… 反過來取 pat = "baa...a" 使結尾檢查每次都通過,內層迴圈就每次都跑到 $n-1$ 才失敗,總共 $O(n\cdot m)$。

(c) 要證兩個定義給出同一個 $f(j)$。關鍵是:所有「$p_0\ldots p_i$ 同時是 $p_0\ldots p_j$ 的後綴」的 $i$,恰好就是序列 $f(j-1),\ f^2(j-1),\ f^3(j-1),\ldots$ 這一條失敗鏈上的元素加 1。第一個定義取其中最大的,第二個定義從鏈頭往下找第一個使 $p_{f^m(j-1)+1}=p_j$ 的 —— 因為鏈是遞減的,「第一個滿足的」就是「最大的」。

2.7 精選參考文獻

主題參考書目
C 中陣列表示法 T. Plum, Reliable Data Structures in C, Plum Hall, Cardiff, N.J., 1985(第 3 章)
R. Jaesche, Solutions in C, Addison-Wesley, Reading, Mass., 1986(第 2 章)—— 包含了陣列界限的詳細討論,如改變陣列的界限和指標位址
字串 B. Kernighan and D. Ritchie, The C Programming Language, ANSI C, 2nd ed., Prentice-Hall, Englewood Clifts, N.J., 1988
S. Harbison and G. Steele, C: A Reference Manual, 3rd ed., Prentice-Hall, Englewood Clifts, N.J., 1991(第 13 章)
字串指標 R. Traister, Mastering C Pointers, Academic Press, San Diego, Calif., 1990
KMP 樣式比對 Knuth, Morris, Pratt, "Fast pattern matching in string", SIAM Journal on Computing, vol. 6, no. 2, 1977

2.8 綜合習題

習題 1、2 — 陣列與鞍點

1. 給予陣列 a[n] 並產生陣列 z[n],使得 z[0] = a[n-1]z[1] = a[n-2]、…、z[n-2] = a[1]z[n-1] = a[0]使用最少量的記憶體。

2. 一個 $m \times n$ 的矩陣中若有一個項目 a[i][j] 為列 $i$ 中之最小值且為行 $j$ 中之最大值時,稱此矩陣有鞍點(saddle point)。設計一個 C 函數來決定若有鞍點存在時,它在矩陣中的位置。你的方法所需的計算時間為何?

點擊展開解題要點

第 1 題:「使用最少量的記憶體」是關鍵 —— 不要另外開陣列,用就地對調for (i = 0; i < n/2; i++) SWAP(a[i], a[n-1-i], t);。額外空間 $O(1)$,時間 $\theta(n)$。若題目堅持要產生另一個陣列 z,那 $O(n)$ 是無可避免的。

第 2 題:直觀作法是對每個元素都檢查一次它的列和行 → $O(m\cdot n\cdot(m+n))$。較好的作法:先用一趟 $O(mn)$ 算出每列的最小值 rmin[i] 與每行的最大值 cmax[j],再用一趟 $O(mn)$ 找出同時滿足 a[i][j]==rmin[i] && a[i][j]==cmax[j] 的位置。總時間 $\theta(mn)$、額外空間 $\theta(m+n)$ —— 因為每個元素至少要讀一次,$\Omega(mn)$ 是下限,所以這個作法是最佳的。

習題 3 到 8 — 特殊矩陣的壓縮儲存

課本特別標注:「習題 3 到 8 探討各種不同的矩陣表示法,這些矩陣常用於自然科學的問題之解法中。」這一組題目的共同主題是:當非零元素的位置有規律時,可以用一個公式把二維索引壓進一維陣列,完全不必儲存 0。

三角矩陣 (triangular matrix)

三角矩陣是指一個方陣的主對角線以上的所有元素或主對角線以下的所有元素均為 0。在一個有 $n$ 列的下三角矩陣 $a$ 中,在列 $i$ 上不為 0 的元素個數最多 $i+1$ 個。因此,全部不為 0 的元素個數為:

$$d = \sum_{i=0}^{n-1}(i+1) = \frac{n(n+1)}{2}$$
x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x 非零 非零 下三角上三角
圖 2.11:下三角與上三角矩陣
習題 3–8 — 定址公式

3. 找出元素 $a_{ij}$ 的定址公式,使它們可以依列序儲存在陣列 b[n(n+1)/2] 中,且 a[0][0] 儲存在 b[0]

5. 一個三對角線矩陣(tridiagonal matrix)為一個方陣,其中在主對角線及其相鄰的兩個對角線以外的元素均為 0。找出一個演算法由陣列 b 中決定 a[i][j] 之值。

6. 一個方形帶狀矩陣(square band matrix) $D_{n,a}$ 是一個 $n \times n$ 的矩陣,其中所有的非零元素均集中在以主對角線為中心的帶狀區域上。(a) 在此帶狀 $D_{n,a}$ 中有多少個元素?(b) 對於帶狀中的元素 $d_{ij}$ 而言,$i$ 與 $j$ 之間的關係為何?(c) 找出定址公式。

8. 一個元素值為複數的矩陣 $X$ 以一組矩陣 $\langle a,b \rangle$ 來表示,其中 $a$ 和 $b$ 包含了實數值。設計一個函數來計算兩個複數矩陣 $\langle a,b\rangle$ 和 $\langle d,e\rangle$ 的乘積,其中 $\langle a,b\rangle * \langle d,e\rangle = (a+ib)*(d+ie) = (ab-be)+i(ae+bd)$。若矩陣均為 $n\times n$,決定所需要的加法和乘法之個數。

點擊展開解題要點

第 3 題(下三角,列序):列 $i$ 之前已經放了 $0+1+2+\cdots+i = \frac{i(i+1)}{2}$ 個元素,所以

$$\text{location}(a_{ij}) = \frac{i(i+1)}{2} + j, \qquad j \le i$$

檢查:$a_{00}\to 0$、$a_{10}\to 1$、$a_{11}\to 2$、$a_{20}\to 3$ ✓。上三角只要把 $i,j$ 的角色對調。

第 5 題(三對角線):非零元素滿足 $|i-j| \le 1$,共 $3n-2$ 個。若依列序存:列 0 有 2 個,其餘每列 3 個(最後一列 2 個),所以

$$\text{location}(a_{ij}) = 2 + 3(i-1) + (j-i+1) = 2i + j, \qquad i \ge 1$$

再加上 $i=0$ 的特例($a_{00}\to0$、$a_{01}\to1$)—— 其實 $2i+j$ 在 $i=0$ 也對。存取前務必先檢查 $|i-j|\le1$,否則直接回傳 0。

第 6 題(方形帶狀 $D_{n,a}$):(a) 帶狀包含主對角線與其上下各 $a-1$ 個對角線,共 $2a-1$ 條;扣掉四角缺的部分,元素個數為 $n(2a-1) - a(a-1)$。(b) 條件是 $|i-j| \le a-1$。

第 8 題:直接照式子做要 4 次 $n\times n$ 矩陣乘法($ad$、$be$、$ae$、$bd$)。但用 Karatsuba 技巧只要 3 次:令 $P_1=ad$、$P_2=be$、$P_3=(a+b)(d+e)$,則實部 $=P_1-P_2$、虛部 $=P_3-P_1-P_2$。矩陣乘法次數 $4\to3$,代價是多幾次 $O(n^2)$ 的加法 —— 對大的 $n$ 非常划算。

習題 9、10 — 兩個 Programming project

9.「隨機走向」問題:一隻(喝醉的)蟑螂被放在一間鋪設磁磚地板的長方形房間中的一塊磁磚上,房間的大小為 $n \times m$ 磁磚。假設它可以從目前所在的磁磚位置移到周圍八塊磁磚中的任一個位置上(除非它碰到了牆壁),而且每一塊磁磚被走到的機會是均等的。則此蟲子接觸地板上的每一塊磁磚至少一次需要多少時間?

八個方向以 imove[k]jmove[k] 表示($0 \le k \le 7$),每次產生一個介於 0 到 7 之間的亂數值 $k$ 來模擬。程式必須:(a) 處理 $2 \lt n \le 40$、$2 \le m \le 20$;(b) 以 $n=15,m=15$ 從 $(10,10)$ 開始、$n=39,m=19$ 從 $(1,1)$ 開始兩組資料實驗;(c) 設一個重複執行次數之上限(本題 50,000 恰當),這可保証程式一定會停止。

10.「騎士的旅程」:從棋盤的任一個方格開始,連續地走 64 步,經過所有的方格,且每個方格只能走過一次。J. C. Warnsdorff 在 1823 年提出的規則:騎士永遠必須移到具有最少的出路可到達尚未走過的方塊之所有方塊之一。

本章重點回顧

一定要記住的清單
  1. 陣列是 ADT(結構 2.1:Create / Retrieve / Store),不是「一連串記憶位址」。
  2. C 的指標算術(list2+i) == &list2[i]不乘元素大小。傳陣列給函數傳的是位址,所以陣列參數的值可以被改變。
  3. union 的欄位共用空間,任何時候只有一個有效;標識欄位由你自己維護,C 不檢查
  4. 循序映射擷取/替換/求長度都是 $O(1)$,只有插入和刪除被困住 —— 這是第 4 章改用串列的動機。
  5. 多項式 padd:$O(n+m)$,最差狀況是指數完全交錯。
  6. 轉置:transpose 是 $O(\textit{cols}\cdot\textit{elements})$,fast_transpose 是 $O(\textit{cols}+\textit{elements})$ —— 用 $O(\textit{cols})$ 的額外空間換來的。
  7. 多維列序定址:$\text{location} = \alpha + \sum_j i_j a_j$,其中 $a_j = \prod_{k=j+1}^{n-1}\textit{upper}_k$、$a_{n-1}=1$。
  8. KMP:失敗函數 $O(\texttt{strlen(pat)})$、比對 $O(\texttt{strlen(string)})$,合計 $O(n+m)$;樸素法與 nfind 最差都是 $O(n\cdot m)$。
本章各函數的複雜度一覽
函數時間
padd$O(n+m)$
transpose$O(\textit{cols}\cdot\textit{elements})$
fast_transpose$O(\textit{cols}+\textit{elements})$
mmult$O(\texttt{cols\_b}\cdot\texttt{totala}$
$+\ \texttt{rows\_a}\cdot\texttt{totalb})$
傳統矩陣相乘$O(\texttt{rows\_a}\cdot\texttt{cols\_a}\cdot\texttt{cols\_b})$
樸素樣式比對$O(n\cdot m)$
nfind最差 $O(n\cdot m)$
fail$O(n)$
pmatch$O(m)$
最容易答錯的五個點
  1. (list2+i) 不要再乘 sizeof
  2. 兩個稀疏矩陣的乘積不一定稀疏(圖 2.5)。
  3. fast_transpose 快,但多用了兩個陣列transpose 慢,但空間省。
  4. ANSI C 允許結構指定,早期 C 不允許;但兩者都不允許== 比較結構。
  5. 失敗函數的 $f(j)$ 是索引不是長度,所以「從 $p_{f(j-1)+1}$ 繼續」那個 +1 不能漏。
往後各章如何用到本章

2.3 節留下的「多個多項式共用一個陣列、壓縮很痛苦」的問題,在 3.4 節(多重堆疊和佇列)會變得更明顯,並在 第 4 章用串列徹底解決。2.4 節的三項式表示法在 4.7 節會改寫成十字鏈結串列。2.2.4 節的自我參考結構就是第 4 章的節點。KMP 的失敗函數觀念則在 第 10 章的數位搜尋結構再度出現。