兩種「限制存取端點」的有序串列。限制帶來的不是損失而是能力 —— 遞迴、回溯搜尋與運算式求值,全都靠這個限制才寫得出來。
堆疊和佇列是第 2 章討論的另一種資料型態 —— 有序串列的特例。差別只在「插入和刪除可以發生在哪裡」:
| 堆疊 stack | 佇列 queue | |
|---|---|---|
| 插入 | 頂端 top | 後端 rear |
| 刪除 | 頂端 top(同一端) | 前端 front(另一端) |
| 別名 | 後進先出 LIFO Last-In-First-Out | 先進先出 FIFO First-In-First-Out |
後半章是這個限制的三個應用:迷宮回溯(3.3)、運算式求值與轉換(3.4),以及當多個堆疊要擠進同一個陣列時的空間管理難題(3.5)—— 最後這一題正是第 4 章改用串列的直接動機。
回想一下,$A = a_0, a_1, \ldots, a_{n-1}$ 為一個有 $n \ge 0$ 元素的有序串列。$a_i$ 稱為分子或元素,它自某些集合中取出。虛串列或空串列以 () 表示,具有 $n=0$ 個元素。
堆疊是一種有序串列,其插入和刪除僅在串列的一端進行,稱此為頂端(top)。給予一個堆疊 $S = (a_0, \ldots, a_{n-1})$,$a_0$ 為底端元素,$a_{n-1}$ 為頂端元素,且元素 $a_i$ 在元素 $a_{i-1}$ 之上,$0 \lt i \lt n$。
堆疊運算的限制暗示了如果我們依序將元素 A、B、C、D、E 加入堆疊中,則當我們要從堆疊刪除資料時,E 為第一個元素。因為最後插入堆疊的元素為第一個被刪除的元素,所以堆疊又稱為後進先出(Last-In-First-Out,LIFO)串列。
在討論堆疊 ADT 之前,來看一種特別的堆疊,稱為系統堆疊,它在程式執行期間用來處理函數呼叫。
當一個函數被呼叫時,程式會產生一種結構,稱為活動錄(activation record)或堆疊框(stack frame),並將它放在系統堆疊的頂端。起初,被呼叫的函數之活動錄只包含一個指向前一個堆疊框的指標和返回位址。
因為在任何時候只會有一個函數在執行,系統即選擇了其堆疊框在系統堆疊最頂端的函數來執行。如果此函數又呼叫其他的函數,則呼叫函數中除了靜態宣告以外的區域變數和參數的均被加到它的堆疊框中。而後,產生被呼叫的函數之新的堆疊框,並放在系統堆疊的頂端。當此函數結束,它的堆疊框被刪除,而後,堆疊框再度成為堆疊的頂端之呼叫函數繼續執行。
「因為所有的函數同時儲存在系統堆疊中,如果一個函數呼叫其自身時並沒有任何的差別。亦即,遞迴程式呼叫不需要特殊的處理方法;執行中的程式只是對每一個遞迴呼叫產生一個新的堆疊框。然而,遞迴呼叫會耗用大部份配置給系統堆疊的記憶體;它可能消耗整個可用的記憶空間。」—— 這正是第 1 章範例 1.8 算出 $S_{rsum} = 6 \times n$ 位元組的機制。
structure Stack is
objects: a finite ordered list with zero or more elements.
functions:
for all stack in Stack, item in element,
max_stack_size in positive integer
Stack CreateS(max_stack_size) ::=
create an empty stack whose maximum size is max_stack_size
Boolean IsFull(stack, max_stack_size) ::=
if (number of elements in stack == max_stack_size)
return TRUE
else return FALSE
Stack Add(stack, item) ::=
if (IsFull(stack)) stack_full
else insert item into top of stack and return
Boolean IsEmpty(stack) ::=
if (stack == CreateS(max_stack_size))
return TRUE
else return FALSE
Element Delete(stack) ::=
if (IsEmpty(stack)) return
else remove and return the item on the top of the stack.
end Stack
結構 3.1:抽象資料型態 stack
實作這個 ADT 最簡單的方法是使用一維陣列 stack[MAX_STACK_SIZE]。堆疊第一個(或底端的)元素儲存於 stack[0],第二個在 stack[1],而第 $i$ 個在 stack[i-1]。與此陣列相關的變數為 top,它指向堆疊頂端的元素。起初,top 設定為 −1,表示一個空的堆疊。
Stack CreateS(max_stack_size) ::=
#define MAX_STACK_SIZE 100 /*maximum stack size*/
typedef struct {
int key;
/* other fields */
} element;
element stack[MAX_STACK_SIZE];
int top = -1;
Boolean IsEmpty(Stack) ::= top < 0;
Boolean IsFull(Stack) ::= top >= MAX_STACK_SIZE-1;
「應注意,element 定義成一個僅包含 key 欄位的結構。通常,我們不會建立一種只有一個欄位的結構。但是,我們在這裡和以後的各章中均使用 element 為一個樣版,其中的欄位可以增加或修改,以符合應用程式的需求。」—— 3.3 節的迷宮就是把 element 改成 {row, col, dir}。
void add(int *top, element item)
{
/* add an item to the global stack */
if (*top >= MAX_STACK_SIZE-1) {
stack_full();
return;
}
stack[++*top] = item;
}
程式 3.1:加入元素到堆疊中
element delete(int *top)
{
/* return the top element from the stack */
if (*top == -1)
return stack_empty(); /* returns an error key */
return stack[(*top)--];
}
程式 3.2:自堆疊中刪除元素
典型的函數呼叫應為 add(&top, item); 以及 item = delete(&top);。應注意,這兩個函數呼叫均傳送 top 的位址。如果不是傳入它的位址,則在 add 或 delete 中對 top 所作的改變將不會回傳至主程式。(C 是傳值呼叫 —— 這與第 2 章 2.1 節說的是同一件事。)
堆疊是總體變數,而且被「隱藏」,因為我們想要強調只有透過頂端指標才能存取堆疊的觀念。
考慮如圖 3.3 所示的鐵道交換網路。在圖右邊為編號 $0, 1, \ldots, n-1$ 的火車廂。每一車廂被推入堆疊,並可在任何時候將它拖出。舉例而言,如果 $n=3$,我們可以推入 0,推入 1,推入 2,而後再將車廂拖出,產生新的車廂順序 2, 1, 0。
對於 $n=3$ 和 $n=4$,可以得到的車廂編號之可能的排列為何?那些排列是不可能發生的?
$n=3$:可得到 5 種排列(不是 $3!=6$)——
012, 021, 102, 120, 210。
唯一做不到的是 201。理由:要先輸出 2,表示 0 和 1 都還在堆疊裡,且 1 壓在 0 上面;所以 1 一定比 0 早出來,不可能是 0 先於 1。
$n=4$:可得到 14 種。做不到的有 $4!-14=10$ 種。
一般規律(這才是本題真正的重點):可達到的排列個數是第 $n$ 個卡塔蘭數(Catalan number):
$C_1=1,\ C_2=2,\ C_3=5,\ C_4=14,\ C_5=42,\ \ldots$ ✓
判定法則:一個排列做得到,若且唯若它不含「312 樣式」 —— 即不存在索引 $i \lt j \lt k$ 使得 $p_k \lt p_i \lt p_j$。201 就是 $n=3$ 唯一的 312 樣式(2, 0 之間夾不住…… 更直觀地說:若某個較大的數先出,被它壓住的那些數就必須以遞減順序出來)。
stack_empty 和 stack_full 函數。fibon(n),傳回第 $n$ 個費氏數值。針對函數呼叫 fibon(4),畫出其系統堆疊之狀態。關於此函數的效率,你有何意見?第 1 題:stack_full() 至少要印出錯誤訊息到 stderr;stack_empty() 要傳回一個 key 欄位含錯誤代碼的 element,讓呼叫端能檢查。
第 3 題的「你有何意見」:這是本章與第 1 章的接點。fibon(4) 會呼叫 fibon(3) 和 fibon(2),而 fibon(3) 又呼叫 fibon(2) —— fibon(2) 被算了兩次。呼叫次數是指數的 $\theta(\phi^{\,n})$,$\phi=(1+\sqrt5)/2$。但系統堆疊在任一瞬間只展開一條路徑,所以最大深度只有 $\theta(n)$。時間指數、空間線性 —— 這正是第 1 章習題 3 最容易答錯的一格。
佇列是一種有序串列,其中所有的插入發生在串列的一端,所有的刪除發生在串列的另一端。給予一個佇列 $Q = (a_0, a_1, \ldots, a_{n-1})$,$a_0$ 為前端元素,$a_{n-1}$ 為後端元素,而 $a_{i+1}$ 在 $a_i$ 之後,$0 \le i \lt n$。
佇列運算上的限制暗示了如果我們依序將 A、B、C、D 插入佇列中,則 A 是第一個可以自佇列刪除的元素。因為第一個加入佇列的元素也是第一個刪除的元素,佇列又稱先進先出(First-In-First-Out,FIFO)串列。
structure Queue is
objects: a finite ordered list with zero or more elements.
functions:
for all queue in Queue, item in element,
max_queue_size in positive integer
Queue CreateQ(max_queue_size) ::=
create an empty queue whose maximum size is max_queue_size
Boolean IsFullQ(queue, max_queue_size) ::=
if (number of elements in queue == max_queue_size)
return TRUE
else return FALSE
Queue AddQ(queue, item) ::=
if (IsFullQ(queue)) queue_full
else insert item at rear of queue and return queue
Boolean IsEmptyQ(queue) ::=
if (queue == CreateQ(max_queue_size))
return TRUE
else return FALSE
Element DeleteQ(queue) ::=
if (IsEmptyQ(queue)) return
else remove and return the item at front of queue.
end Queue
結構 3.2:抽象資料型態 Queue
「佇列在循序儲位上的表示法比堆疊的循序表示法困難。」最簡易的方法是採用一維陣列和兩個變數 front 和 rear(堆疊只需要一個 top)。
Queue CreateQ(max_queue_size) ::=
#define MAX_QUEUE_SIZE 100 /*Maximum queue size*/
typedef struct {
int key;
/* other fields */
} element;
element queue[MAX_QUEUE_SIZE];
int rear = -1;
int front = -1;
Boolean IsEmptyQ(queue) ::= front == rear
Boolean IsFullQ(queue) ::= rear == MAX_QUEUE_SIZE-1
void addq(int *rear, element item)
{
/* add an item to the queue */
if (*rear == MAX_QUEUE_SIZE-1) {
queue_full();
return;
}
queue[++*rear] = item;
}
程式 3.3:插入元素到佇列中
element deleteq(int *front, int rear)
{
/* remove element at the front of the queue */
if (*front == rear)
return queue_empty(); /*return an error key */
return queue[++*front];
}
程式 3.4:自佇列中刪除元素
「同樣地,在呼叫 deleteq 時傳送 front 的位址,使 front 的修改是永久性的。因為 deleteq 不會改變 rear,我們沒有傳送 rear 的位址;但它使用 rear 來檢查是否為一個空佇列。」這是個很乾淨的介面設計示範:只把需要修改的東西以位址傳入。
佇列經常用於電腦的程式設計中,一種典型的例子是由作業系統所建立的工作佇列(job queue)。如果作業系統未採用工作優先權,則工作依它們進入系統的順序被處理。
| 前端 | 末端 | Q[0] | Q[1] | Q[2] | Q[3] | 說明 |
|---|---|---|---|---|---|---|
| −1 | −1 | 空佇列 | ||||
| −1 | 0 | J1 | 加入 Job 1 | |||
| −1 | 1 | J1 | J2 | 加入 Job 2 | ||
| −1 | 2 | J1 | J2 | J3 | 加入 Job 3 | |
| 0 | 2 | J2 | J3 | 刪除 Job 1 | ||
| 1 | 2 | J3 | 刪除 Job 2 |
圖 3.5:在一個循序佇列中的插入和刪除
「我們明顯地可看到,當工作進入和離開系統時,佇列逐漸地向右移動。這代表當後端索引等於 MAX_QUEUE_SIZE-1 時,暗示佇列是滿載的。在此狀況下,queue_full 應將整個佇列向右移動,使第一個元素再度放在 queue[0],且 front 為 −1。它也應該重新計算 rear,使它指出正確的位置。將陣列移位是一種耗時的工作,特別是當它有很多的元素時。事實上,queue_full 最差狀況的複雜度為 $O(\texttt{MAX\_QUEUE\_SIZE})$。」
如果將陣列 queue[MAX_QUEUE_SIZE] 看成一個環狀,則可以得到一種更有效率的佇列表示法。在這種表示法中:
front 和 rear 的初值設定為 0 而不是 −1。front 索引永遠以逆時鐘方向指著佇列中第一個元素的前一個位置。rear 索引指向佇列目前的最後位置。若且唯若 front == rear,佇列是空的。MAX_QUEUE_SIZE = 6。右圖 front=0, rear=3,佇列中有 J1、J2、J3。圖 3.7 說明 MAX_QUEUE_SIZE=6 時的兩個滿載佇列。雖然它們仍有一個元素的空位,將一個元素加入時會造成 front == rear,使得我們無法辨別它是一個空佇列或是一個滿載佇列。
因此,我們改用大小為 MAX_QUEUE_SIZE 的環形佇列,最多只能儲存 MAX_QUEUE_SIZE - 1 個元素。
要為環形佇列製作 addq 和 deleteq 有一些困難,因為我們必須保證它以環形轉動。這可以用模數運算來達成。於 addq 中,後端索引的環形轉動發生在指令 *rear = (*rear+1) % MAX_QUEUE_SIZE;
void addq(int front, int *rear, element item)
{
/* add an item to the queue */
*rear = (*rear+1) % MAX_QUEUE_SIZE;
if (front == *rear) {
queue_full(rear); /* reset rear and print error*/
return;
}
queue[*rear] = item;
}
程式 3.5:插入元素到環形佇列 —— 注意先轉動後端索引,再放入元素
element deleteq(int *front, int rear)
{
element item;
/* remove front element from the queue and put it
in item */
if (*front == rear)
return queue_empty(); /* queue_empty returns an
error key */
*front = (*front+1) % MAX_QUEUE_SIZE;
return queue[*front];
}
程式 3.6:自環形佇列中刪除元素 —— 同樣先轉動 front,再刪除元素
「請留意,addq 中測試滿載佇列的條件和在 deleteq 中測試空佇列的條件是相同的。在 addq 中,當 front == *rear 被檢查,而且發現它成立,則實際上仍有一個位置未使用(queue[rear]),因佇列的第一元素並不是 queue[front],而是其順時鐘方向的前一個位置。正如先前提到的,如果在此插入一個元素,我們將無法區別佇列是空的或是滿的之狀況,因插入會使得 front 等於 rear。」
「它們的實作視特定的應用程式而定。如果目的在繼續處理和刪除一個元素,則 queue_full 應將後端指標復原為先前的值。在我們呼叫的 queue_full 中建議採用這個方法。同樣地,queue_empty 應傳回一個具有錯誤關鍵的 item,以便在程式中檢查它。」
queue_full 和 queue_empty 函數。queue_full 和 queue_empty 函數。circle[MAX_SIZE] 中,以環狀來處理一個線性串列。(a) 找出一個以 front、rear 和 MAX_SIZE 表示的公式,來代表串列的元素個數。(b) 設計一個函數以刪除串列中第 $K$ 個元素。(c) 設計一個函數,在第 $K$ 個元素之後插入一個元素 item。(d) 對於 (b) 和 (c) 之函數,它們的時間複雜度為何?第 3 題(很好的考題):從滿載佇列開始,反覆做「刪除一個 → 加入一個」。每次加入時 rear 都已在 MAX_QUEUE_SIZE-1,觸發 queue_full,整個陣列要左移一格 —— 每次加入都是 $O(\texttt{MAX\_QUEUE\_SIZE})$。做 $k$ 次就是 $O(k \cdot \texttt{MAX\_QUEUE\_SIZE})$。這正是環形佇列存在的理由。
第 5(a) 題:元素個數的公式(環形,且 front 指向第一個元素的前一格):
加上 MAX_SIZE 再取模,是為了處理 rear < front(已繞圈)的情況。
第 5(d) 題:兩者都是 $O(n)$ —— 因為循序儲存的插入/刪除必須搬移元素以維持循序映射。這又回到第 2 章 2.3 節的那句話:「只有插入和刪除被困住了」,也正是第 4 章的動機。
長久以來,迷宮問題一直是個令人感到有興趣的題目。實驗心理學者訓練老鼠在迷宮中找尋食物,許多偵探小說家使用英國式的花園迷宮來安排謀殺案。我們對迷宮也有興趣,因為它可供很好的一種堆疊應用。
「雖然這個程式在找到正確的路徑之前經過了許多錯誤的路徑,但在找到了正確路徑以後,可以重新走迷宮而不經過錯誤的路徑。」—— 堆疊裡留下的正是那條正確的路徑。
二維陣列,0 代表可通行的路徑,1 代表障礙。為避免檢查邊界的狀況,將迷宮的邊界以 1 圍起來。所以一個 $m \times p$ 的迷宮需要一個 $(m+2) \times (p+2)$ 的陣列。進入點在 [1][1],出口位在 [m][p]。
以方位代表:北、東北、東、東南、南、西南、西和西北,以 N, NE, E, SE, S, SW, W, NW 表示,並以數值 0 到 7 編號。預先定義在陣列 move 中。
因為不想回到已經走過的路徑,使用另一個二維陣列 mark 來記錄已經檢查過的迷宮位置。元素初值全設為 0;當走到一個位置 maze[row][col] 時,將 mark[row][col] 改成 1。
把 3.1 節的 element 重新定義為 {short int row; short int col; short int dir;} —— 堆疊函數 add/delete 完全不必改就能正常運作。這就是 3.1 節說「element 是一個樣版」的用意。
「在此必須很小心,因為並非每一個位置都有八個相鄰的點。如果 [row, col] 在邊界上,則有少於八個,甚至只有三個鄰點存在。」用一圈 1 把迷宮包起來,就把「邊界檢查」變成「反正那格是牆」,程式裡一個 if 都不用寫。
typedef struct {
short int vert;
short int horiz;
} offsets;
offsets move[8]; /*array of moves for each direction*/
如果目前位在 maze[row][col] 而想要找到下一個走到的位置 maze[next_row][next_col],我們可設定:
next_row = row + move[dir].vert;
next_col = col + move[dir].horiz;
| 方位 | 方向數值 | move[dir].vert | move[dir].horiz |
|---|---|---|---|
| N | 0 | −1 | 0 |
| NE | 1 | −1 | 1 |
| E | 2 | 0 | 1 |
| SE | 3 | 1 | 1 |
| S | 4 | 1 | 0 |
| SW | 5 | 1 | −1 |
| W | 6 | 0 | −1 |
| NW | 7 | −1 | −1 |
圖 3.10:移動的表格 —— 這張表把「八個方向」從一串 if 變成一次陣列查表。
「當我們在迷宮中走動時,我們可能會有多個移動方向的選擇。因為不曉得那一個移動方向最好,我們將目前的位置保留下來,而任意選擇一個移動方向。藉著將目前的位置保留下來,當我們選擇了一條沒有希望的路徑時,我們可以回到原來的位置並選擇另一條路徑。我們從北方開始順時鐘選取可能的移動。」—— 這就是回溯(backtracking)。
initialize a stack to the maze's entrance coordinates and
direction to north;
while (stack is not empty) {
/* move to position at top of stack */
<row,col,dir> = delete from top of stack;
while (there are more moves from current position) {
<next_row, next_col> = coordinates of next move;
dir = direction of move;
if ((next_row == EXIT_ROW) && (next_col == EXIT_COL))
success;
if (maze[next_row][next_col] == 0 &&
mark[next_row][next_col] == 0) {
/* legal move and haven't been there */
mark[next_row][next_col] = 1;
/* save current position and direction */
add <row,col,dir> to the top of the stack;
row = next_row;
col = next_col;
dir = north;
}
}
}
printf("No path found\n");
程式 3.7:迷宮演算法初版
「因迷宮中的每一個位置只能走過一次,堆疊所需之空間最多和迷宮中的 0 元素之個數一樣多。圖 3.11 中的迷宮從入口到出口只有一條路徑。在這個迷宮中找尋入口到出口的路徑時,當抵達出口時所有 0 的位置(出口除外)將會保留在陣列上。因為在一個 $m \times p$ 的迷宮中最多有 $mp$ 個元素不為 0,設定堆疊的容量有 $mp$ 個元素就足夠了。」
void path(void)
{
/* output a path through the maze if such a path exists */
int i, row, col, next_row, next_col, dir, found = FALSE;
element position;
mark[1][1] = 1; top = 0;
stack[0].row = 1; stack[0].col = 1; stack[0].dir = 1;
while (top > -1 && !found) {
position = delete(&top);
row = position.row; col = position.col;
dir = position.dir;
while (dir < 8 && !found) {
/* move in direction dir */
next_row = row + move[dir].vert;
next_col = col + move[dir].horiz;
if (next_row == EXIT_ROW && next_col == EXIT_COL)
found = TRUE;
else if ( !maze[next_row][next_col] &&
! mark[next_row][next_col]) {
mark[next_row][next_col] = 1;
position.row = row; position.col = col;
position.dir = ++dir;
add(&top, position);
row = next_row; col = next_col; dir = 0;
}
else ++dir;
}
}
if (found) {
printf("The path is:\n");
printf("row col\n");
for (i = 0; i <= top; i++)
printf("%2d%5d",stack[i].row, stack[i].col);
printf("%2d%5d\n",row,col);
printf("%2d%5d\n",EXIT_ROW,EXIT_COL);
}
else printf("The maze does not have a path\n");
}
程式 3.8:迷宮搜尋函數
「迷宮大小決定了 path 的計算時間。因迷宮中每一個位置最多走訪一次,此演算法最差狀況的複雜度為 $O(mp)$,其中 $m$ 和 $p$ 分別是迷宮的列數與行數。」
position.dir = ++dir; —— 存回堆疊的不是目前的方向,而是下一個要試的方向。所以當回溯回到這個位置時,它會直接從沒試過的方向繼續,不會重試已經失敗的那些。少了這個 ++,程式會無限迴圈。
path 函數的動作。將它和你在 (a) 中的作法相互比較。第 3 題:最大路徑長度是 $\textit{rows} \times \textit{columns}$ —— 因為 mark 保證每格最多進入一次,路徑不可能比格子總數還長。圖 3.11 正是課本給的「一條長路徑」範例:蛇行走過幾乎所有格子。
第 1 題的重點:若牆只有水平和垂直,且牆畫在格子上(而非格子邊界上),則八個方向中的對角移動要小心 —— 兩個正交方向都是牆時,斜著穿過去在物理上是不合理的(會穿牆角)。課本的模式是把牆當成「不能站的格子」,所以對角移動只要目標格是 0 就允許。
運算式的表示法與計算法在計算機科學上是極有興趣的。對程式設計者而言,當寫下複雜的運算式,如:
或者複雜的指派指令,如:
假設在指令 (3.2) 中 $a=4$、$b=c=2$、$d=e=3$。運算順序是:
| 照優先權(除、乘先於加、減) | 照從左到右 |
|---|---|
| $((4/2)-2)+(3*3)-(4*2)$ $= 0 + 9 - 8$ $= \mathbf{1}$ | $(4/(2-2+3))*(3-4)*2$ $= (4/3)*(-1)*2$ $= \mathbf{-2.66666\cdots}$ |
「大多數的人會選擇第一個答案,因為我們知道除法在減法之前計算,乘法在加法之前計算。如果我們要的是第二個答案,我們必須將 (3.2) 式改寫成不一樣的寫法,以括號來改變計算的先後順序,如 $x = ((a/(b-c+d))*(e-a))*c$ (3.3)。」
在任何程式語言中,都有一種用來決定計算運算符號先後順序的優先權等級。運算符號由高優先權到低優先權排列,具有相同優先權的符號放在同一個方格中。
| 符號 | 運算 | 優先順序 | 關連性 |
|---|---|---|---|
() [] -> . | function call, array element, struct or union member | 17 | left-to-right |
-- ++ | increment, decrement(後序) | 16 | left-to-right |
-- ++ ! ~ - + & * sizeof | decrement, increment(先序), logical not, one's complement, unary minus or plus, address or indirection, size (in bytes) | 15 | right-to-left |
(type) | type cast | 14 | right-to-left |
* / % | multiplicative | 13 | left-to-right |
+ - | binary add or subtract | 12 | left-to-right |
<< >> | shift | 11 | left-to-right |
> >= < <= | relational | 10 | left-to-right |
== != | equality | 9 | left-to-right |
& | bitwise and | 8 | left-to-right |
^ | bitwise exclusive or | 7 | left-to-right |
| | bitwise or | 6 | left-to-right |
&& | logical and | 5 | left-to-right |
|| | logical or | 4 | left-to-right |
?: | conditional | 3 | right-to-left |
= += -= /= *= %= <<= >>= &= ^= |= | assignment | 2 | right-to-left |
, | comma | 1 | left-to-right |
圖 3.12:C 語言優先權等級(優先順序由 Harbison 和 Steele 提供)
「例如,乘運算符號的關連為由左而右。這表示運算 a*b/c%d/e 相當於 ((((a*b)/c)%d)/e)。換言之,我們先計算最左邊的運算符號。對於具有相同優先權,由右到左關連性的運算符號,我們先計算最右邊符號。括號用來否決優先權,而且永遠從最裡面的括號中之運算式先算。」
運算式標準的寫法稱中序表示法(infix notation),因為其中的二元運算符號(binary operator)被寫在它的兩個運算元之間。雖然中序法為編寫運算式最常用的方法,它並不是編譯程式用來評估運算式的方法。編譯程式通常使用一種沒有括號的表示法,稱為後序表示法(postfix notation)。這種表示法中,每一個運算符號出現在其運算元之後。
| 中序 | 後序 |
|---|---|
2+3*4 | 2 3 4*+ |
a*b+5 | ab*5+ |
(1+2)*7 | 1 2+7* |
a*b/c | ab*c/ |
((a/(b-c+d))*(e-a))*c | abc-d+/ea-*c* |
a/b-c+d*e-a*c | ab/c-de*+ac*- |
圖 3.13:中序和後序表示法
「這種計算的過程比中序運算式的計算容易得多,因為不需要考慮括號的處理。要計算一個運算式,我們只要由左而右掃描一次即可。在找到一個運算符號以前,我們先將運算元放入堆疊中。而後我們從堆疊取出該運算符號所需要的正確個數之運算元,執行運算,並將結果存回堆疊中。在到達運算式的結尾之前,我們持續地以這種方式來處理。最後再將結果自堆疊的頂端取出。」
| 符號 | [0] | [1] | [2] | 頂端 |
|---|---|---|---|---|
| 6 | 6 | 0 | ||
| 2 | 6 | 2 | 1 | |
| / | 6/2 | 0 | ||
| 3 | 6/2 | 3 | 1 | |
| − | 6/2−3 | 0 | ||
| 4 | 6/2−3 | 4 | 1 | |
| 2 | 6/2−3 | 4 | 2 | 2 |
| * | 6/2−3 | 4*2 | 1 | |
| + | 6/2−3+4*2 | 0 |
圖 3.14:後序運算式計算 —— 輸入為九個字元的字串 62/3-42*+
#define MAX_STACK_SIZE 100 /*maximum stack size*/
#define MAX_EXPR_SIZE 100 /*max size of expression*/
typedef enum {lparen, rparen, plus, minus, times, divide,
mod, eos, operand} precedence;
int stack[MAX_STACK_SIZE]; /* global stack */
char expr[MAX_EXPR_SIZE]; /* input string */
為了簡化工作,假設運算式中僅包含二元運算符號 + - * / %,且運算元均為一位整數。
「因為運算元 (symbol) 起初是一個字元,我們必須將它轉換成一位整數值。我們以指令 symbol-'0' 來完成這件工作。這個指令將 symbol 的 ASCII 值減去 '0' 的 ASCII 值 (48)。舉例而言,如果 symbol='1',則字元 '1' 的 ASCII 值為 49,因此,指令 symbol-'0' 產生的數值為 1。」
int eval(void)
{
/* evaluate a postfix expression, expr, maintained as a
global variable. '\0' is the the end of the expression.
The stack and top of the stack are global variables.
get_token is used to return the tokentype and
the character symbol. Operands are assumed to be single
character digits */
precedence token;
char symbol;
int op1, op2;
int n = 0; /* counter for the expression string */
int top = -1;
token = get_token(&symbol, &n);
while (token != eos) {
if (token == operand)
add(&top, symbol-'0'); /* stack insert */
else {
/* remove two operands, perform operation, and
return result to the stack */
op2 = delete(&top); /*stack delete */
op1 = delete(&top);
switch(token) {
case plus: add(&top,op1+op2);
break;
case minus: add(&top, op1-op2);
break;
case times: add(&top, op1*op2);
break;
case divide: add(&top,op1/op2);
break;
case mod: add(&top, op1%op2);
}
}
token = get_token(&symbol, &n);
}
return delete(&top); /* return result */
}
程式 3.9:用以計算後序運算式的函數
注意 op2 = delete(&top); op1 = delete(&top);。因為堆疊是後進先出,後被推入的是右運算元。若把順序寫反,加法和乘法看不出問題,但減法和除法會算錯($a-b$ 變成 $b-a$)。
precedence get_token(char *symbol, int *n)
{
/* get the next token, symbol is the character
representation, which is returned, the token is
represented by its enumerated value, which
is returned in the function name */
*symbol = expr[(*n)++];
switch (*symbol) {
case '(' : return lparen;
case ')' : return rparen;
case '+' : return plus;
case '-' : return minus;
case '/' : return divide;
case '*' : return times;
case '%' : return mod;
case ' ' : return eos;
default : return operand; /* no error checking,
default is operand */
}
}
程式 3.10:從輸入字串中取得一個符號的函數
例如,a/b-c+d*e-a*c 完全地加上括號以後變成 ((((a/b)-c)+(d*e))-(a*c)),執行步驟 2 和 3 以後得到 ab/c-de*+ac*-。
「雖然這個方法以手算時效果很好。但是對電腦是沒有效率的,因為它需要掃描兩次。第一回讀取運算式並加上括號,第二回移動運算符號。」
因為中序和後序運算式中的運算元是相同的,我們可以藉著由左而右掃描中序運算式來產生對等的後序運算式。在這次的掃描中,每遇到運算元就將它們傳送到輸出運算式中。但是,運算符號的輸出順序則根據它們的優先權來決定。
假設有一個簡易式 a+b*c,它產生的後序運算式為 abc*+。
| 符號 | [0] | [1] | [2] | 頂端 | 輸出 |
|---|---|---|---|---|---|
| a | −1 | a | |||
| + | + | 0 | a | ||
| b | + | 0 | ab | ||
| * | + | * | 1 | ab | |
| c | + | * | 1 | abc | |
| eos | −1 | abc*+ |
圖 3.15:將 a+b*c 轉換成後序運算式
「運算元立即輸出,但兩個運算符號必須反置。一般而言,具有較高優先權的運算符號必須在低優先運算符號之前輸出。因此,只要堆疊頂端的運算符號之優先權小於輸入運算符號的優先權,我們就將輸入運算元推入堆疊中。」
括號使得轉換過程更為困難,因為對等的後序運算式沒有括號。以運算式 a*(b+c)*d 為例,產生後序運算式 abc+*d*。
| 符號 | [0] | [1] | [2] | 頂端 | 輸出 |
|---|---|---|---|---|---|
| a | −1 | a | |||
| * | * | 0 | a | ||
| ( | * | ( | 1 | a | |
| b | * | ( | 1 | ab | |
| + | * | ( | + | 2 | ab |
| c | * | ( | + | 2 | abc |
| ) | * | 0 | abc+ | ||
| * | * | 0 | abc+* | ||
| d | * | 0 | abc+*d | ||
| eos | −1 | abc+*d* |
圖 3.16:將 a*(b+c)*d 轉換成後序運算式
「應注意,在到達右括號以前,將運算符號推入堆疊中。到達時,將運算符號自堆疊推出,直到對應的左括號為止,而後將堆疊中的左括號刪除。(右括號絕不會存入堆疊。)」
「左括號使問題複雜,因為它在堆疊裡面時,當成一個低優先權運算符號,而它不在堆疊裡面時,當成一個高優先權運算符號。當它在運算式中被找到時即被推入堆疊,但只有在找到其對應的右括號時才會被推出堆疊。所以,我們有兩種優先權,一種是堆疊內優先權(in-stack precedence,isp),另一種是輸入優先權(incoming precedence,icp)。」
precedence stack[MAX_STACK_SIZE];
/* isp and icp arrays -- index is value of precedence
lparen, rparen, plus, minus, times, divide, mod, eos */
static int isp[] = {0,19,12,12,13,13,13,0};
static int icp[] = {20,19,12,12,13,13,13,0};
| 符號 | isp | icp | 說明 |
|---|---|---|---|
( lparen | 0 | 20 | 進來時最高(一定被推入),在堆疊裡最低(不會被別人擠出來) |
) rparen | 19 | 19 | 高於任何運算符號,所以會把括號內的全部擠出來 |
+ - | 12 | 12 | 取自圖 3.12 |
* / % | 13 | 13 | 取自圖 3.12 |
eos | 0 | 0 | 最低,到字串結尾時把堆疊全部清空 |
用列舉型態當索引很漂亮:「因列舉型態的變數值只是以該值在列舉型態宣告的位置對應數值表示,我們可以使用名稱當成陣列的索引。例如,isp[plus] 轉譯成 isp[2],它給予我們一個堆疊內優先權 12。」
void postfix(void)
{
/* output the postfix of the expression. The expression
string, the stack, and top are global */
char symbol;
precedence token;
int n = 0;
int top = 0; /* place eos on stack */
stack[0] = eos;
for (token = get_token(&symbol, &n); token != eos;
token = get_token(&symbol,&n)) {
if (token == operand)
printf("%c",symbol);
else if (token == rparen) {
/* unstack tokens until left parenthesis */
while (stack[top] != lparen)
print_token(delete(&top));
delete(&top); /* discard the left parenthesis */
}
else {
/* remove and print symbols whose isp is greater
than or equal to the current token's icp */
while(isp[stack[top]] >= icp[token])
print_token(delete(&top));
add(&top, token);
}
}
while ( (token=delete(&top)) != eos)
print_token(token);
printf("\n");
}
程式 3.11:將中序運算式轉成後序運算的函數
「令 $n$ 為運算式中符號的個數。$O(n)$ 時間用於讀取和輸出符號。此外,兩個 while 迴圈也會耗掉一些時間。因為推入和推出堆疊的符號個數與 $n$ 成正比,在此用掉的全部時間為 $O(n)$。所以函數 postfix 的複雜度為 $O(n)$。」
1. 寫出下列運算式的後序表示法:
(a) a*b*c
(b) -a+b-c+d
(c) a*-b+c
(d) (a+b)*d+e/(f+a*d)+c
(e) a&&b||c||!(e>f)
(f) !(a&&!((b<c)||(c>d)))||(c<d)
3. 使用圖 3.12 的優先權和 '('、')' 和 \0 的優先權來回答:(a) 在 postfix 函數中,如果輸入運算式 expr 有 $n$ 個運算符號以及無限層次的括號,則在任何時候堆疊中的元素個數最大為多少?(b) 如果 expr 有 $n$ 個運算符號且括號的層次最多為 6,則 (a) 的答案為何?
6. 另一種易於計算,沒有括號運算式形式稱為先序表示法(prefix notation)。在先序表示法中,運算符號在其運算元之前。請留意,在中序運算式和先序運算式中,它們的運算元的順序是相同的。
| 中序 | 先序 |
|---|---|
a*b/c | /*abc |
a/b-c+d*e-a*c | -+-/abc*de*ac |
a*(b+c)/d-g | -/*a+bcdg |
圖 3.17:中序和先序運算式
第 1 題答案:
| 中序 | 後序 |
|---|---|
a*b*c | ab*c* |
-a+b-c+d | a-b+c-d+(一元負號寫成 a@ 較嚴謹) |
a*-b+c | ab-*c+ |
(a+b)*d+e/(f+a*d)+c | ab+d*efad*+/+c+ |
a&&b||c||!(e>f) | ab&&c||ef>!|| |
第 3 題(最值得想的一題):
(a) 無限層次括號時,堆疊最大元素個數為 $n+1$。理由:每個左括號和每個運算符號都可能同時在堆疊上,但左括號的數量受限於運算符號 —— 對 $n$ 個二元運算符號,完全加括號最多有 $n$ 個左括號,再加上底部的 eos,得 $n+1$。
(b) 括號層次最多 6 時,堆疊中最多同時有 6 個左括號,加上運算符號堆疊的部分。答案為 $\min(n+1,\ ?)$ —— 實際上仍受 $n$ 限制,但上界降為約 $n+1$ 與「$6$ 個左括號 + 每層至多幾個運算符號」取小者。關鍵洞察:括號層次限制了左括號的數量,但運算符號本身仍可能連續堆積(例如 a+b+c+d+... 因為 isp = icp 會立刻被彈出,所以實際上同優先權不堆積)。
第 6 題的關鍵:先序運算式要由右而左掃描(提示裡就這麼說)。遇到運算元推入堆疊;遇到運算符號則取出兩個運算元,先取出的是左運算元(與後序相反)。時間和空間都是 $O(n)$。
直到目前為止,我們僅討論單一堆疊或單一佇列的表示法。在這兩種情況下,我們已明白它們可獲得有效率的循序表示法。現在,我們想要討論多元堆疊之狀況。(多元佇列的表示法留待習題討論。)
「如果只要表示兩個堆疊,其解法是很容易的。我們以 memory[0] 當作第一個堆疊的底端元素,memory[MEMORY_SIZE-1] 當作第二個堆疊的底端元素。第一個堆疊向 memory[MEMORY_SIZE-1] 方向增長,而第二個堆疊向 memory[0] 方向增長。使用這種表示法,我們可以有效率地使用所有的空間。」—— 兩個堆疊從兩端往中間長,只有在它們撞在一起時才是真的滿了。
「在相同的陣列表示兩個以上的堆疊時會有問題,因為我們不再有一個明確的點當作每一個堆疊的底端元素。假設有 $n$ 個堆疊,我們將可用的空間分成 $n$ 段。如果堆疊的大小可知,則最初的分割可依據不同的堆疊大小比率分配。否則,將記憶體分成大小相同的段落。」
#define MEMORY_SIZE 100 /* size of memory */
#define MAX_STACKS 10 /* max number of stacks plus 1 */
/* global memory declaration */
element memory[MEMORY_SIZE];
int top[MAX_STACKS];
int boundary[MAX_STACKS];
int n; /* number of stacks entered by the user */
要將陣列分割成大約相等的段落,使用下列的程式碼:
top[0] = boundary[0] = -1;
for (i = 1; i < n;i++)
top[i] = boundary[i] = (MEMORY_SIZE/n)*i;
boundary[n] = MEMORY_SIZE-1;
boundary[stack_no]($0 \le \texttt{stack\_no} \lt \texttt{MAX\_STACKS}$)永遠指向在底端元素之前的一個位置;top[stack_no] 則指向頂端元素。
若且唯若 boundary[stack_no] == top[stack_no],則此堆疊是空的。
編號為 stack_no 的堆疊在它滿載之前可以從 boundary[stack_no]+1 向 boundary[stack_no+1] 增長。因為最後一個堆疊需要一個邊界,我們設定 boundary[n] 為 MEMORY_SIZE-1。
void add(int i, element item)
{
/* add an item to the ith stack */
if (top[i] == boundary[i+1])
stack_full(i);
memory[++top[i]] = item;
}
程式 3.12:將一個 item 加入堆疊 stack_no 中
element delete(int i)
{
/* remove top element from the ith stack */
if (top[i] == boundary[i])
return stack_empty(i);
return memory[top[i]--];
}
程式 3.13:從堆疊 stack_no 中刪除一個 item
「多元堆疊的 add 和 delete 函數看起來和單一堆疊循序表示法中的函數一樣簡單。然而事實並非如此,因為在 add 中,測試條件 top[i] == boundary[i+1] 僅暗示其中的一個堆疊空間已用完,而非整個記憶空間均滿載。事實上,於陣列 memory 中的其他堆疊之間,仍有許多未使用空間。」
因此,我們建立一個錯誤修復函數 stack_full,來決定在記憶體中是否仍有閒置空間。如果仍有空間可取用,應將堆疊移位以配置空間給滿載的堆疊。只要在陣列 memory 中仍有閒置空間,我們即可保證 stack_full 可加入元素,如果:
memory[0] 當作最左邊,memory[MEMORY_SIZE-1] 當作最右邊)。這可以在堆疊 stack_no 和 stack_no+1 之間產生一個空位。stack_no 的左邊找尋。找出最大的 $j$,$0 \le j \lt \texttt{stack\_no}$,而且在堆疊 $j$ 和 $j+1$ 之間仍有閒置空間。亦即,$\texttt{top}[j] \lt \texttt{boundary}[j+1]$。如果有這樣的 $j$ 值存在,則堆疊 $j+1, j+2, \ldots, \texttt{stack\_no}$ 向左移動一個位置。這也可以在堆疊 stack_no 和 stack_no+1 之間產生一個空位。MEMORY_SIZE 空間已用盡,其中已沒有閒置空間。在此狀況下,stack_full 傳回一個錯誤訊息並結束。「stack_full 的實作留作習題。明顯地,以這樣的表示法來儲存 $n$ 個堆疊時,其最差狀況的效率是很不好的。實際上,在最差狀況時,此函數的時間複雜度為 $O(\texttt{MEMORY\_SIZE})$。」
第 2 章 2.4 節結尾說:「因為稀疏矩陣中元素的個數是變動的,我們傾向於將所有的稀疏矩陣以一個陣列來表示…… 但是,這樣做使得我們在從陣列中配置各別的矩陣所需的空間上遭遇困難。這個困難在多項式表示法中也會遇到,而且在 3.4 節當我們學習多重堆疊和列的一種類似的表示法時,將變得更為明顯。」—— 現在你看到它了。多個變動大小的結構共用一塊連續記憶體,就會不斷需要搬移。第 4 章的串列用指標徹底解決這件事。
memory[MEMORY_SIZE] 中。設計兩個 C 函數,使它們可以在堆疊 stack_no 中($0 \le \texttt{stack\_no} \le 1$)加入或刪除元素。只要在這兩個堆疊內全部的元素個數小於 MEMORY_SIZE-1,你的函數就必須能夠將元素加入堆疊。memory[MEMORY_SIZE] 內。設計 C 函數,使它們可以分別在這兩個資料物件加入或刪除元素。對於你的資料表示法之適用性,你有何意見?stack_full 策略,試用一個 C 函數實作之。add 和 delete 函數與習題 3 的 stack_full 函數,建立一連串的加入/刪除運算,使得每一次的加入運算需時 $O(\texttt{MEMORY\_SIZE})$。假設你有兩個堆疊,並從一個代表 memory[MEMORY_SIZE] 完全用盡的配置開始作業。add 和 stack_full 函數,使得 add 在記憶體中的閒置空間小於 $c_1$ 時即停止。$c_1$ 根據經驗來選擇,它表示於記憶體中移動元素何時是徒然無益的。以一個小的常數值作為你的選擇。memory[MEMORY_SIZE] 中。將每一個佇列當成一個環形佇列。針對這種表示法,設計 addq、deleteq 和 queue_full 函數。第 1 題:這就是課文開頭說的「兩個堆疊從兩端往中間長」。top[0] 從 −1 往上增,top[1] 從 MEMORY_SIZE 往下減。滿的條件是 top[0] + 1 == top[1] —— 不需要任何搬移,這是 $n=2$ 的特例才有的好處。
第 4 題(和 3.2 節習題 3 是同一個把戲):讓兩個堆疊交錯地加入和刪除,使得每次加入時本堆疊都滿、而閒置空間永遠在另一端。於是每次 stack_full 都必須把中間所有元素搬過去,$O(\texttt{MEMORY\_SIZE})$。
第 5 題的用意:如果只剩 1 格閒置空間,搬移整個陣列只為了塞一個元素,攤提下來每次加入都是 $O(\texttt{MEMORY\_SIZE})$。設一個門檻 $c_1$,閒置空間低於它就直接宣告失敗,可以避免這種病態行為 —— 這是工程上的常見權衡:提早放棄,好過無止盡地做無用功。
| 主題 | 參考書目 |
|---|---|
| 系統堆疊和活動錄 |
A. Holub, Compiler Design in C, Prentice-Hall, Englewood Cliffs, N.J., 1990 本書所用的活動錄(圖 3.2)即以 Holub 的討論為基礎。 |
| C 的優先權等級 |
S. Harbison and G. Steele, C: A Reference Manual, 3rd ed., Prentice-Hall, Englewood Cliffs, N.J., 1991 B. Kernighan and D. Ritchie, The C Programming Language, 2nd ed., Prentice-Hall, Englewood Cliffs, N.J., 1988 |
「人們曾花了許多的時間玩接龍,而今賭場更以人類的這項弱點來獲取大量資金。」
移動規則依序嘗試 (a)–(e),做完一個移動後重新由 (a) 開始:
只有在遊戲牌堆或廢棄牌堆最上面的紙牌才可移到輸出牌堆上。一旦紙牌放到輸出牌堆,就不可以再將它拿回來。當所有的牌全部被放到輸出牌堆,或者,餘牌全部用盡而且沒有任何紙牌可被移動時,遊戲結束。計分:一開始參加者先付 52 元給莊家,而每放一張牌到輸出堆時贏得 5 元。設計一個程式可以多次地玩遊戲,並計算淨輸贏。利用一個隨機亂數產生器來洗牌。
「我們想要模擬機場飛機起、降的模式。機場有三個跑道,跑道 0,跑道 1,和跑道 2。一共有四種降落預備模式,前面兩個跑道各兩種。」
對於每一個時段,同時進入降落佇列的飛機不能多於三架,同時進入起飛佇列的也有類似的限制。
接龍是堆疊的練習(每一個牌堆都是 LIFO,而且「放到輸出堆就拿不回來」正是 pop 的不可逆性);機場模擬是多個佇列的練習(FIFO,而且「佇列儘可能維持相同大小」正好用上 3.5 節多元結構共用空間的觀念)。兩題合起來剛好把本章的兩個資料型態都用過一遍。
top = -1;環形佇列起始 front = rear = 0。不要記混。MAX_QUEUE_SIZE - 1 個,否則無法區分空與滿。空:front == rear;滿:(rear+1) % size == front。queue_full 要搬整個陣列,$O(\texttt{MAX\_QUEUE\_SIZE})$ —— 這就是環形佇列存在的理由。mark 記已走、堆疊存 <row,col,dir>、存回堆疊的是 ++dir。複雜度 $O(mp)$。op2 先取、op1 後取。中序轉後序:只有 isp[top] >= icp[token] 時才推出。stack_full 最差是 $O(\texttt{MEMORY\_SIZE})$ —— 連續配置的根本限制。| 函數 | 時間 |
|---|---|
add / delete(堆疊) | $O(1)$ |
addq / deleteq(環形) | $O(1)$ |
queue_full(非環形) | $O(\texttt{MAX\_QUEUE\_SIZE})$ |
path(迷宮) | $O(mp)$ |
eval(後序求值) | $O(n)$ |
postfix(中序轉後序) | $O(n)$ |
stack_full(多元) | $O(\texttt{MEMORY\_SIZE})$ |
add/delete 一定要傳 &top,不是 top。op2 先 pop;寫反了減法除法就錯。++dir),不是目前的。3.5 節的空間管理難題,第 4 章用串列解決(4.4 節有連結堆疊與連結佇列)。堆疊的回溯觀念在第 5 章的樹狀尋訪(非遞迴中序尋訪)與第 6 章的深度優先搜尋(DFS)再度出現;佇列則是第 6 章廣度優先搜尋(BFS)與拓樸排序的核心。後序運算式的觀念在第 5 章會以「二元運算式樹的後序尋訪」重新出現 —— 到時你會發現後序表示法其實就是運算式樹的後序走訪結果。