系統生命週期、演算法定義、資料抽象化、效率分析與效率估計 —— 全書其餘各章的共同語彙都在這裡奠定。
第 1 章不介紹任何具體的資料結構,它建立的是描述與評比資料結構所需要的三套工具:
讀完本章之後,「這個演算法是 $O(n\log n)$」這句話對你應該是一個有精確定義、可以證明的敘述,而不只是一個口號。
好的程式師會把大型的電腦程式看成一個包含了許多複雜的、相關的部份之系統。對系統而言,這些程式在一個稱為系統生命週期(system life cycle)的開發過程下進行。此週期由五個階段構成。這些步驟雖然可看成獨立的,但它們是高度相關的,而且遵循一種不嚴格的時段順序。
由下而上是一種老式的、沒有結構化的策略,對早期強調編寫好的程式是一種適用的方法。因為程式師對整個專案沒有良好的規劃,所產生的程式經常會有許多關連不緊密、有錯誤的程式段。
課本的比喻很傳神:由下而上的方法就像以一張通用的藍圖來建造房子 —— 所有的房子都看成相同的,都必須有牆壁、屋頂、管線和暖氣等,建築物特殊的用途為何是不相干的。相反地,由上而下以程式最後的目標為開端,並利用這個最後結果將程式分割成數個可管理的分段;通常在這個階段會發展出多個用來解決問題的方法,而且會以這些方法相互比較。
「做這件事情的順序是關鍵性的」—— 因為一種資料物件的表示法可以決定和它有關的演算法之效率。所以我們通常會先寫出和資料物件無關的演算法,把表示法的決定儘可能延緩。這樣不但可以產生一個可用數種語言編寫的系統,還可以有足夠的時間在所選擇的語言中找到最有效率的實作方法。這正是本書「先 ADT、後實作」寫法的理由。
使用一些基於數學的技術證明程式是正確的。不幸的是,這些證明不但耗時,而且對大型的專案不易達成。但是,選擇一些演算法並證明它們是正確的可以減少錯誤的數目。
需要工作程式碼和一組測試資料。初學者通常以為只要沒有語法錯誤程式就一定正確 —— 這是錯的。好的測試資料應該檢驗程式碼的每一段落都正確地執行;例如程式中有 switch,測試資料就應涵蓋每一個 case。
是否能夠很容易地消除錯誤,和先前所做的設計與編碼策略有關。以冗長的程式碼所編製的大型且沒有文件說明的程式為程式設計師的惡夢 —— 一個修正過的錯誤可能產生數個新的錯誤。
基本的系統測試重點在證明程式可以正確地執行。雖然這是關鍵性的考慮因素,程式的執行時間也是重要的因素:沒有錯誤但執行速度慢的程式是沒有用的。這正是 1.4、1.5 兩節的主題。
五個階段的順序與名稱(需求 → 分析 → 設計 → 編碼 → 驗証),以及「驗証」底下的三個子項(正確性驗証/測試/除錯)是最常被單獨命題的部分。注意課本強調這五步「看似獨立、實則高度相關,且只遵循不嚴格的時段順序」。
演算法是一組有限的指令,根據這些指令,可以完成一項特定的工作。
An algorithm is a finite set of instructions that, if followed, accomplishes a particular task.
此外,所有的演算法必須符合下列五項條件:
| 條件 | 意義 |
|---|---|
| (1) 輸入 input | 可以沒有,或從外部提供多個資料。 |
| (2) 輸出 output | 至少產生一個結果。 |
| (3) 明確的 definiteness | 每一個指令都很明白且沒有混淆不清。 |
| (4) 有限的 finiteness | 如果追蹤一個演算法的指令,則在所有的情況下,演算法會在有限的步驟之後停止。 |
| (5) 有效的 effectiveness | 每一個指令都很基本,即使一個人僅使用紙和筆也可以完成它。每個運算即使如 (3) 所規定的一般明確仍是不夠的;它必須是一個可行的運算。 |
在計算理論中,演算法和程式的差別之一是程式可以不符合第 4 個條件(有限性)。例如,作業系統可以看成是一個等待迴路,直到有更多的工作進入系統中 —— 這樣的程式可以不停止,除非是系統損毀。
因我們的應用程式總是會終止,在本書中將程式和演算法看成是相同的。
這兩條容易混淆。「明確」要求的是敘述無歧義;「有效」要求的是該運算真的做得出來。課本的習題正是用這個對比命題:
•「存在三個正整數 $x,y,z$ 使得 $x^n+y^n=z^n$ 有一解的最大 $n$ 值是否為 $n=2$?」—— 敘述明確,但它不是一個可行的運算(違反有效性)。
•「將 5 除以 0 以後的值儲存到 $x$,並且分歧到指令 10」—— 違反有效性,因為除以 0 不是可行的運算。
我們可以用自然語言(如英文)來描述演算法,但必須確認最後所使用的指令是很明確的。另一種可能的方法是流程圖,但它只適用於小型而簡易的演算法。在本書中,多數的演算法將以 C 語言表示,偶而會採用英文和 C 語言的混合表示法。
假設我們要發展一個可以將一組 $n$ 個整數排序的程式,$n \ge 1$。下列是一個簡單的解法:
From those integers that are currently unsorted, find the smallest and place it next in the sorted list.
雖然這個敘述足以描述排序問題,它並不是一個演算法,因它還留有數個沒有回答的問題:它沒有告知原始的整數儲存在何處、以何種方式儲存,以及結果應儲存在何處。我們可假設整數儲存在一個陣列 list 中,使得第 $i$ 個整數存放在 list[i] 內,且 $0 \le i \lt n$。
for (i = 0; i < n; i++) {
Examine list[i] to list[n-1] and suppose that the
smallest integer is at list[min];
Interchange list[i] and list[min];
}
程式 1.1:Selection sort 演算法(部份以 C 語言,部份以英文編寫)
要將程式 1.1 轉變成真正的 C 程式,仍然需要清楚地定義兩個子任務:找到最小的整數與將它和 list[i] 交換。後者可用一個函數(程式 1.2)或一個巨集來解決。
void swap(int *x, int *y)
/* both parameters are pointers to ints */
{
int temp = *x; /* declares temp as an int and assigns
to it the contents of what x points to */
*x = *y; /* stores what y points to into the location
where x points */
*y = temp; /* places the contents of temp in location
pointed to by y */
}
程式 1.2:swap 函數
#define SWAP(x,y,t) ((t) = (x), (x) = (y), (y) = (t))
巨集版本 —— 函數的程式碼比巨集容易閱讀,但巨集可用於任何資料型態。
#include <stdio.h>
#include <math.h>
#define MAX_SIZE 101
#define SWAP(x,y,t) ((t) = (x), (x) = (y), (y) = (t))
void sort(int [],int); /* selection sort */
void main(void)
{
int i,n;
int list[MAX_SIZE];
printf("Enter the number of numbers to generate: ");
scanf("%d",&n);
if( n < 1 || n > MAX_SIZE) {
fprintf(stderr, "Improper value of n\n");
exit(1);
}
for (i = 0; i < n; i++) {/*randomly generate numbers*/
list[i] = rand() % 1000;
printf("%d ",list[i]);
}
sort(list,n);
printf("\n Sorted array:\n ");
for (i = 0; i < n; i++) /* print out sorted numbers */
printf("%d ",list[i]);
printf("\n");
}
void sort(int list[],int n)
{
int i, j, min, temp;
for (i = 0; i < n-1; i++) {
min = i;
for (j = i+1; j < n; j++)
if (list[j] < list[min])
min = j;
SWAP(list[i],list[min],temp);
}
}
程式 1.3:Selection sort 完整程式
函數 sort(list,n) 可正確將一組 $n$ 個整數排序,$n \ge 1$。其結果儲存在 list[0], …, list[n-1],使得
證明:當外層的 for 迴圈完成了 $i=q$ 的迴路時,則 $\texttt{list}[q] \le \texttt{list}[r]$,$q \lt r \lt n$。再者,在其後的迴圈中,$i \gt q$ 且 list[0] 到 list[q] 維持不變。因此,在外層的 for 迴圈完成最後一次執行時(即 $i=n-2$),可以獲得 $\texttt{list[0]} \le \texttt{list[1]} \le \cdots \le \texttt{list[n-1]}$。□
假設有 $n$ 個不同的整數,$n \ge 1$,已經排序並儲存在陣列 list 中,亦即 $\texttt{list[0]} \le \texttt{list[1]} \le \cdots \le \texttt{list[n-1]}$。我們必須指出整數 searchnum 是否在此串列中;如果它在,傳回一個索引 $i$ 使得 list[i] = searchnum;如果不在,傳回 $-1$。
令 left 和 right 分別代表所要搜尋的串列之左端和右端。一開始設定 left = 0、right = n-1。令 middle = (left+right)/2 為串列的中央位置。將 list[middle] 和 searchnum 相互比較,可得下列三種結果之一:
searchnum < list[middle]:若 searchnum 在串列中,它必在 $0$ 與 middle-1 之間。因此將 right 設定為 middle-1。searchnum == list[middle]:傳回 middle。searchnum > list[middle]:若 searchnum 在串列中,它必在 middle+1 與 n-1 之間。因此將 left 設定為 middle+1。while (there are more integers to check ) {
middle = (left + right) / 2;
if (searchnum < list[middle])
right = middle - 1;
else if (searchnum == list[middle])
return middle;
else left = middle + 1;
}
程式 1.4:搜尋一個已排序的串列
比較的工作可藉一個函數或一個巨集來達成。我們採用 C 函數庫的規則:第一個數值小於第二個 → 傳回負值 $(-1)$;相等 → 傳回 $0$;大於 → 傳回正值 $(+1)$。
int compare(int x, int y)
{
/* compare x and y, return -1 for less than, 0 for equal,
1 for greater */
if (x < y) return -1;
else if (x == y) return 0;
else return 1;
}
程式 1.5:比較兩個整數值
#define COMPARE(x,y) (((x) < (y)) ? -1: ((x) == (y))? 0: 1)
巨集版本 —— 本書採用巨集,因為它適用於任何資料型態。
int binsearch(int list[], int searchnum, int left,
int right)
{
/* search list[0] <= list[1] <= ... <= list[n-1] for
searchnum. Return its position if found. Otherwise
return -1 */
int middle;
while (left <= right) {
middle = (left + right)/2;
switch (COMPARE(list[middle], searchnum)) {
case -1: left = middle + 1;
break;
case 0: return middle;
case 1: right = middle - 1;
}
}
return -1;
}
程式 1.6:搜尋一個有序串列 —— 迴路式二分搜尋法
才入門的程式設計者通常認為函數是一種可被其他的函數引用(呼叫)的程式單元 —— 它執行其程式碼而後將控制傳回其呼叫函數。這種觀點忽略了函數可以呼叫自身(直接遞迴,direct recursion),或者,函數可以再度引用其呼叫函數(間接遞迴,indirect recursion)。
學生經常認為遞迴是一種令人迷惑的技術,僅適用於少數的特殊問題,例如階乘值計算或 Ackermann 函數。這是不正確的想法 —— 因為任何使用指派指令、if-else 和 while 指令編寫的函數,均可以用遞迴的方式編寫。通常,遞迴的函數比其對等的重覆性函數容易瞭解。
我們如何決定何時應將一個演算法以遞迴的方式表示?一個例子是當問題本身以遞迴的方式定義時。階乘、費氏值(Fibonacci number)以及二項式係數均屬於此種類型。二項式係數如下:
可以用如下公式遞迴地計算:
課本把它講得很清楚,這是本節最實用的一條方法論:
(1) 建立終止遞迴呼叫的臨界條件;
(2) 建立遞迴呼叫,使得每一次的呼叫更進一步地接近解答。
仔細檢查程式 1.6 可以發現有兩種方法可以終止搜尋:一個是搜尋成功的訊號(list[middle] == searchnum),另一個是搜尋失敗的訊號(左指標和右指標交叉而過)。
程式因搜尋成功而終止的程式碼不必改變。但是,用來觸發搜尋失敗的 while 指令必須以一個對等的 if 指令取代它,此指令的 then 子句遞迴地引用函數。
int binsearch(int list[], int searchnum, int left,
int right)
{
/* search list[0] <= list[1] <= ... <= list[n-1] for
searchnum. Return its position if found. Otherwise
return -1 */
int middle;
if (left <= right) {
middle = (left + right)/2;
switch (COMPARE(list[middle], searchnum)) {
case -1: return
binsearch(list, searchnum, middle + 1, right);
case 0: return middle;
case 1: return
binsearch(list, searchnum, left, middle - 1);
}
}
return -1;
}
程式 1.7:二分搜尋法的遞迴實作法
應注意,雖然程式碼已改變,但遞迴的函數呼叫與迴路式的函數呼叫是相同的。
對於一個有 $n$ 個元素的集合,$n \ge 1$,印出此集合所有可能的排列組合。例如集合為 $\{a,b,c\}$,則排列組合為
$$\{(a,b,c),\ (a,c,b),\ (b,a,c),\ (b,c,a),\ (c,a,b),\ (c,b,a)\}$$有 $n$ 個元素就有 $n!$ 個排列組合。再探討集合 $\{a,b,c,d\}$,可以發現一個用來產生排列組合的簡易演算法:
遞迴解法的線索是「接著所有的排列組合」這句話。它暗示如果有一個可以在 $n-1$ 個元素的集合上有效的演算法,就可以解決具有 $n$ 個元素的集合上的問題。
void perm(char *list, int i, int n)
/* generate all the permutations of list[i] to list[n] */
{
int j, temp;
if (i == n) {
for (j = 0; j <= n; j++)
printf("%c", list[j]);
printf(" ");
}
else {
/* list[i] to list[n] has more than one permutation,
generate these recursively */
for (j = i; j <= n; j++) {
SWAP(list[i],list[j],temp);
perm(list,i+1,n);
SWAP(list[i],list[j],temp);
}
}
}
程式 1.8:遞迴的排列產生器 —— 啟始呼叫為 perm(list,0,n-1);
注意那對成雙的 SWAP:第二個 SWAP 把陣列還原,否則下一輪迴圈的交換基準就被破壞了。i 的值會隨著每一次的呼叫而改變,但 n 則不會;參數 list 為一個陣列的指標,其值也不會隨著呼叫而改變。
(a) 階乘函數 $n!$:當 $n \le 1$ 時其值為 $1$,當 $n \gt 1$ 時其值為 $n\cdot(n-1)!$。分別編寫遞迴式與迴路式的 C 函數來計算 $n!$。
(b) 費氏數值定義為 $f_0=0$、$f_1=1$,且 $f_i=f_{i-1}+f_{i-2}$ 當 $i \gt 1$。分別編寫遞迴式和迴路式的 C 函數計算 $f_i$。
(c) [Towers of Hanoi] 一共有 3 根套桿且在第一根套桿上有 64 個直徑大小不同的圓盤,由下往上看時呈現由大而小的順序。規則:一次只能移動一個圓盤;任何一個圓盤都不能放在直徑比它小的圓盤之上。編寫一個遞迴的函數將移動順序印出。
(d) Ackermann 函數 $A(m,n)$ 定義如下 —— 編寫此函數的遞迴式和迴路式版本。
(a) 遞迴版直接照定義寫:if (n <= 1) return 1; else return n * fact(n-1);。迴路版用一個累乘器由 $2$ 乘到 $n$。兩者步驟計數都是 $\theta(n)$,但遞迴版有 $\theta(n)$ 的堆疊空間需求,迴路版是 $\theta(1)$ —— 這正是 1.4.1 節範例 1.8 的主題。
(b) 注意陷阱:照定義直接寫的遞迴費氏函數是指數時間的($\theta(\phi^{\,n})$),因為 $f_{i-1}$ 和 $f_{i-2}$ 的子樹被重複計算。迴路版只要兩個變數滾動即可,是 $\theta(n)$。這是「遞迴比較好懂,但不一定比較快」最經典的例子。
(c) 河內塔的遞迴骨架:把上面 $n-1$ 個盤子搬到輔助桿 → 把第 $n$ 個盤子搬到目標桿 → 把那 $n-1$ 個盤子從輔助桿搬到目標桿。移動次數 $T(n)=2T(n-1)+1=2^n-1$,$n=64$ 時是 $1.8\times10^{19}$ 次。
(d) Ackermann 函數之所以被研究,正是因為「它對於較小的 $m$ 和 $n$ 值,其函數值增加地非常快」。它是一個可計算但不是原始遞迴的函數,因此迴路式版本必須自行以堆疊模擬遞迴,不能只用巢狀的固定迴圈。
C 語言的基本資料型態包括 char、int、float 和 double,其中有些可用關鍵字 short、long 和 unsigned 來修飾。除了這些基本資料型態以外,C 提供兩種方法幫助我們將資料群集在一起:陣列和結構。
具有相同基本資料型態的元素之集合,以隱涵的方式定義。例如 int list[5] 定義一個有五個元素的整數陣列,其有效的註標範圍是 0..4。
元素的集合,其元素的資料型態不一定相同,以明顯地方式定義。(第 2 章詳細說明。)
struct student {
char last_name;
int student_id;
char grade;
}
一個有 3 個欄位的結構,其中兩個是字元型態,一個是整數。
C 語言也提供指標資料型態。對每一種基本資料型態都有一個對應的指標資料型態,以放在變數名稱前面的星號 * 來表示:
int i, *pi;
宣告 i 為一個整數,pi 為指向一個整數的指標。
資料型態是物件(object)和可以在這個物件之上作用的一組運算(operations)之集合。
不論程式處理預設的資料型態或使用者自定資料型態,都必須考慮兩個觀點:物件和運算。例如 int 型態包含了物件 $\{0, +1, -1, +2, -2, \ldots, \texttt{INT\_MAX}, \texttt{INT\_MIN}\}$,其中 INT_MAX 和 INT_MIN 為電腦所能表示的最大和最小的整數(定義在 limits.h 中)。整數的運算包含算術運算 + - * / %、測試相等/不相等的運算,以及指派運算。
瞭解內部表示法我們即可編寫演算法來使用它。但是,如果想要改變這些物件的內部表示法,我們就一定要修改使用它的程式。
由許多軟體設計者的觀察發現,隱藏一種資料型態之物件的表示法,讓使用者對其無所知是一種良好的設計策略。在此種情況下,使用者僅能透過物件所提供的函數來處理物件;設計者仍然可以改變內部表示法,只要運算之新的實作方法沒有變更使用者介面即可 —— 也就是說,使用者不必重新編寫他們的演算法。
一個抽象資料型態是一種資料型態,它的組織方式使得物件的規格與物件上的運算之規格和該物件的內部表示法與運算的實作法是獨立的。
有些程式語言有明確的方法來支援定義和實作之間的差別 —— Ada 中有封包(package)的觀念,C++ 中有類別(Class)的觀念。雖然 C 沒有實作 ADT 的明確方法,仍可使用相同的概念來設計自有的資料型態。
ADT 的運算之定義與其實作有何不同?定義中包含了每一個函數的名稱、其參數之型態、與其結果之型態,另外還有函數功能之描述,但不必訴諸內部表示法或實作細節。此外,可以將一種資料型態的函數分成三種類型:
| 類型 | 說明 |
|---|---|
| (1) 產生/建構 creator / constructor | 對指定的型態產生一個新的實體(instance)。 |
| (2) 轉換 transformer | 也用來產生指定的型態之實體,通常是利用一個或多個其他的實體來產生。 |
| (3) 觀察/彙報 reporter | 提供有關指定的型態的一個實體之資訊,但不會改變實體。 |
「一般而言,一個 ADT 的定義中至少會包含三個函數,它們分別屬於這三類函數中各別的類型。」這是判斷一個 ADT 定義是否完整的快速檢查。
結構的定義以結構名稱和其名稱縮寫為首。定義中有兩大部份:物件(objects)和函數(functions)。符號「::=」應讀成「定義為」。
structure Natural_Number is
objects: an ordered subrange of the integers starting at zero
and ending at the maximum integer (INT_MAX) on the computer
functions:
for all x, y in Nat_Number; TRUE, FALSE in Boolean
and where +, -, <, and == are the usual integer operations
Nat_No Zero( ) ::= 0
Boolean Is_Zero(x) ::= if (x) return FALSE
else return TRUE
Nat_No Add(x, y) ::= if ((x + y) <= INT_MAX) return x + y
else return INT_MAX
Boolean Equal(x, y) ::= if (x == y) return TRUE
else return FALSE
Nat_No Successor(x) ::= if (x == INT_MAX) return x
else return x + 1
Nat_No Subtract(x, y) ::= if (x < y) return 0
else return x - y
end Natural_Number
結構 1.1:抽象資料型態 Natural_Number
Zero() —— 沒有參數,傳回自然數 $0$。這是一個建構函數。Successor(x) —— 傳回數序中的下一個自然數,是轉換函數的一個例子。應注意:如果 $x$ 已經是 INT_MAX,則定義 Successor 傳回 INT_MAX。這種狀況下,有些程式設計者也許會比較喜歡 Successor 傳回一個錯誤旗號 —— 這也是一個很好的作法。Add、Subtract —— 其他的轉換函數,同樣做了飽和處理(上限 INT_MAX、下限 $0$)。Is_Zero、Equal —— 觀察/彙報函數,傳回 Boolean,不改變實體。注意定義中用到了其他資料型態(Boolean、整數集合上的 $+,-,\lt,==$)。這暗示了「為了要定義一種資料型態,我們也許需要使用其他資料型態中的運算」。
對於下列的習題,以結構 1.1 所用的格式提供各抽象資料型態的定義。
Predecessor、Is_Greater、Multiply、Divide。Create、Insert、Remove、Is_In、Union、Intersection、Difference。Create、Insert、Remove 和 Is_In。And、Or、Not、Xor(Exclusive or)、Equivalent 和 Implies。共同格式:每一題都必須寫出 structure … is / objects: / functions: / end … 四個部份,且至少要涵蓋建構、轉換、觀察三類各一個函數。
第 1 題的陷阱:Predecessor(0) 與 Divide(x,0) 都是邊界,必須像 Subtract 一樣明確定義(傳回 $0$ 或錯誤旗號),不能留白 —— 否則違反演算法的「明確性」。
第 2、3 題的差別:Set 的 Insert 對已存在的元素不改變集合;Bag 的 Insert 則增加一份計數。這一句就是兩者 ADT 定義唯一的實質差異,也是最常被考的一點。
第 4 題:objects 是 $\{\texttt{TRUE}, \texttt{FALSE}\}$;建構函數可用 Create() 傳回 FALSE;Implies(x,y) 定義為 Or(Not(x), y),Equivalent(x,y) 定義為 Not(Xor(x,y)) —— 用已定義的運算去定義後面的運算,是 ADT 寫法的標準風格。
本書的目標之一在建立讀者的程式評估能力。我們可以由數個因素來評估一個程式,包括:
| 效率分析 performance analysis | 效率估計 performance measurement | |
|---|---|---|
| 取得什麼 | 和機器無關的估計時間和空間 | 和機器相關的執行時間 |
| 用途 | 複雜度理論(complexity theory)的核心 | 找出沒有效率的程式片段 |
| 本章節次 | 1.4 節 | 1.5 節 |
一個程式的空間複雜度是完全地執行程式所需要的記憶體。
一個程式的時間複雜度是完全地執行程式所需要的計算機時間。
程式所需要的空間為下列各項之和:
代表和程式的輸入與輸出之數量與大小無關的空間需求。包括指令空間(用以儲存程式碼的空間)、用來存簡單變數、固定大小的結構變數(例如 struct),和常數的空間。
包含了大小與所要解決的問題中特定實體 $I$ 相關的結構化變數所需要的空間。此外,還包含了當函數採用遞迴方式呼叫時所需要的空間。
一個程式 $P$ 對一個實體 $I$ 運作所需要的可變空間以 $S_P(I)$ 表示。$S_P(I)$ 通常以該實體 $I$ 的一些特性之函數來代表;最常用的特性包括數量、大小,以及和 $I$ 關連的輸入與輸出之值。如果輸入為一個包含 $n$ 個數值的陣列,則 $n$ 為一個實體的特性,且可用 $S_P(n)$ 來代表 $S_P(I)$。
其中 $c$ 為一個常數,代表固定的空間需求。
在分析一個程式的空間複雜度時,通常僅考慮可變的空間需求。特別是在我們想要比較數個程式的空間複雜度時更應如此。
float abc(float a, float b, float c)
{
return a+b*b*c +(a+b-c)/(a+b)+4.00;
}
程式 1.9:簡單算術函數
此函數接受三個簡單變數當成輸入,以傳回一個簡單值當成輸出。根據上述類別,此函數只有固定空間需求,因此 $S_{abc}(I) = 0$。
float sum(float list[], int n)
{
float tempsum = 0;
int i;
for (i = 0; i < n; i++)
tempsum += list[i];
return tempsum;
}
程式 1.10:計算數值加總的迴路式函數
雖然輸出為單一數值,輸入卻包含了一個陣列。因此,可變的空間需求視陣列如何傳遞到函數而定。
有些程式語言(如 Pascal)可用傳值方式傳遞陣列 —— 這種方法在函數執行之前將整個陣列複製到一個暫時性的工作空間,所以 $S_{sum}(I) = S_{sum}(n) = n$。
C 以傳值方式傳遞所有的參數。當一個陣列當成參數來傳遞給一個函數時,C 將傳送陣列的第一個元素之位址。C 不會複製陣列。因此 $S_{sum}(n) = 0$。
float rsum(float list[], int n)
{
if (n) return rsum(list,n-1) + list[n-1];
return 0;
}
程式 1.11:計算數值加總的遞迴式函數
用此種方法時,編譯程式必須為每一次的遞迴呼叫保存參數、區域性變數,以及返回位址。在 80386 電腦上,整數和指標都需要 2 byte 儲存空間,浮點數需要 4 byte。
| 型態 | 名稱 | 位元組數量 |
|---|---|---|
| parameter: float | list[] | 2 |
| parameter: integer | n | 2 |
| return address: (used internally) | 2 (unless a far address) | |
| TOTAL per recursive call | 6 |
圖 1.1:程式 1.11 每次遞迴呼叫所需的空間
如果 MAX_SIZE = 1000,遞迴式程式所需要的可變空間為 $6 \times 1000 = 6000$ byte。迴路式程式則沒有可變空間需求。正如所見的,遞迴式程式比其相對的迴路式程式有較多的負擔(overhead)。
針對 1.2 節習題所建立的下列函數,決定它們的空間複雜度:
統一的作法:先數出每次遞迴呼叫要保存的東西(參數 + 區域變數 + 返回位址),再乘上遞迴的最大深度。
記住 C 的傳陣列規則:只要是以 list[] 形式傳陣列,每一層只多 2 個位元組的指標,不是整個陣列。
一個程式 $P$ 所使用的時間 $T(P)$ 為其編譯時間和執行時間之和。編譯時間類似固定的空間項目,因為它與實體的特性無關;此外,一旦證明了程式可以正確地執行,我們可能多次地執行它而不必重新編譯。總之,我們真正關心的是程式的執行時間 $T_P$。
其中 $c_a, c_s, c_l, c_{st}$ 為常數值,代表執行各項運算所需之時間,而 ADD、SUB、LDA、STA 代表程式以實體特性為 $n$ 執行時,所要進行的加、減、載入和儲存的次數。
要取得這樣詳細的執行時間之估計值的確要下一番功夫,因為它需要對編譯程式的屬性有深入的瞭解 —— 我們必須知道編譯程式如何將原始程式翻譯成目的程式碼。如果我們必須要知道執行時間,最好的方法是利用系統時計來計算程式時間(1.5 節)。此外,我們可以計算程式所進行的運算之次數,這給予我們與機器無關的估計值 —— 但必須先瞭解如何將程式分割成不同的步驟(step)。
程式步驟是語法上或語意上有特別意義的程式段落,它的執行時間和實體的特性是無關的。
應注意,一個程式步驟中所代表的運算次數可能和另一個程式步驟所代表的運算次數不相同。舉例而言,對 a=2 這樣簡單的指派指令,我們可能算它為一個步驟;對於像 a = 2*b + 3*c/d - e + f/g/a/b/c 一般較複雜的指派指令,我們也可能算它為一個步驟。執行一個指令所需的時間要能夠當成一個步驟來計算的唯一要求是:與實體的特性無關。
float sum(float list[], int n)
{
float tempsum = 0; count++; /* for assignment */
int i;
for (i = 0; i < n; i++) {
count++; /* for the for loop */
tempsum += list[i]; count++; /* for assignment */
}
count++; /* last execution of for */
count++; /* for return */ return tempsum;
}
程式 1.12:程式 1.10 加入計數指令
注意,我們只關心可執行的指令,所以函數頭、第二個變數宣告都自動地不列入考慮。因為主要的目的在決定最後的計數,可從程式 1.12 中將大多數的程式指令消除,得到化簡後的程式 1.13:
float sum(float list[], int n)
{
float tempsum = 0;
int i;
for (i = 0; i < n; i++)
count += 2;
count +=3;
return 0;
}
程式 1.13:程式 1.12 簡化版
因此,每次呼叫 sum,一共執行 $2n+3$ 步驟。
float rsum(float list[], int n)
{
count++; /* for if conditional */
if (n) {
count++; /* for return and rsum invocation */
return rsum(list,n-1) + list[n-1];
}
count++;
return list[0];
}
程式 1.14:程式 1.11 加入計數指令
首先必須指出臨界條件 $n=0$ 時的步驟計數:當 $n=0$ 時只有 if 條件指令和第二個 return 指令會執行,因此全部的步驟計數為 $2$。對於 $n \gt 0$,if 條件指令與第一個 return 指令會執行,所以每一個 $n \gt 0$ 的遞迴呼叫的步驟計數加 $2$。因為一共有 $n$ 次的函數呼叫,其後再執行一次 $n=0$ 的函數呼叫:
令人奇怪的是遞迴式函數比其相對的迴路式函數有較少的步驟計數($2n+2 \lt 2n+3$)。然而必須提醒:步驟計數只告訴我們有多少步驟被執行,它不能告訴我們每一個步驟用了多少時間。因此,雖然遞迴式函數的步驟較少,它通常比有相同步驟的迴路式的版本的執行速度慢,一般而言,比迴路式的版本需要更多的時間。
void add(int a[][MAX_SIZE], int b[][MAX_SIZE],
int c[][MAX_SIZE], int rows, int cols)
{
int i, j;
for (i = 0; i < rows; i++)
for (j = 0; j < cols; j++)
c[i][j] = a[i][j] + b[i][j];
}
程式 1.15:矩陣加法
void add(int a[][MAX_SIZE], int b[][MAX_SIZE],
int c[][MAX_SIZE], int rows, int cols)
{
int i, j;
for (i = 0; i < rows; i++) {
count++; /* for i for loop */
for (j = 0; j < cols; j++) {
count++; /* for j for loop */
c[i][j] = a[i][j] + b[i][j];
count++; /* for assignment statement */
}
count++; /* last time of j for loop */
}
count++; /* last time of i for loop */
}
程式 1.16:矩陣加法加入計數指令
void add(int a[][MAX_SIZE], int b[][MAX_SIZE],
int c[][MAX_SIZE], int rows, int cols)
{
int i, j;
for (i = 0; i < rows; i++) {
for (j = 0; j < cols; j++)
count += 2;
count += 2;
}
count++;
}
程式 1.17:簡化的程式 1.16
觀察 $2\cdot\textit{rows}\cdot\textit{cols} + 2\cdot\textit{rows} + 1$:與 rows 相關的額外項是 $2\cdot\textit{rows}$,與 cols 沒有對應的額外項。所以課本說:當矩陣的列數比行數大得多時,應將此矩陣行列互換。
另一種獲得步驟計數的方法是使用表列法。步驟:(1) 決定每一個指令的步驟計數,稱為 step/execution(s/e);(2) 找出每一指令被執行的次數,稱為頻率。不可執行的指令的頻率為 $0$。將 s/e 乘以頻率可以得到每一指令全部步驟;將各指令全部的步驟相加,即可獲得整個函數所需的步驟計數。
| 敘述 | s/e | 頻率 | 步驟計數 |
|---|---|---|---|
float sum(float list[], int n) | 0 | 0 | 0 |
{ | 0 | 0 | 0 |
float tempsum = 0; | 1 | 1 | 1 |
int i; | 0 | 0 | 0 |
for (i = 0; i < n; i++) | 1 | n+1 | n+1 |
tempsum += list[i]; | 1 | n | n |
return tempsum; | 1 | 1 | 1 |
} | 0 | 0 | 0 |
| Total | 2n+3 |
圖 1.2:程式 1.10 步驟計數表
「在第 5 行的 for 迴路有些複雜。但是,迴路從 0 開始到 n 時結束,它的頻率為 n+1。」—— 因為最後一次的條件測試($i=n$,測試失敗而離開迴圈)也要算一次。而迴路主體(第 6 行)只執行 n 次,因 $i=n$ 時它不執行。這一格是表列法最常見的失分點。
| 敘述 | s/e | 頻率 | 步驟計數 |
|---|---|---|---|
float rsum(float list[], int n) | 0 | 0 | 0 |
{ | 0 | 0 | 0 |
if (n) | 1 | n+1 | n+1 |
return rsum(list,n-1) + list[n-1]; | 1 | n | n |
return list[0]; | 1 | 1 | 1 |
} | 0 | 0 | 0 |
| Total | 2n+2 |
圖 1.3:遞迴式加總函數的步驟計數表
| 敘述 | s/e | 頻率 | 步驟計數 |
|---|---|---|---|
void add(int a[][MAX_SIZE] ··· ) | 0 | 0 | 0 |
{ | 0 | 0 | 0 |
int i, j; | 0 | 0 | 0 |
for (i=0; i<rows; i++) | 1 | rows+1 | rows+1 |
for (j = 0; j < cols; j++) | 1 | rows·(cols+1) | rows·cols + rows |
c[i][j] = a[i][j] + b[i][j]; | 1 | rows·cols | rows·cols |
} | 0 | 0 | 0 |
| Total | 2rows·cols + 2rows + 1 |
圖 1.4:矩陣加法的步驟計數表
對大多數程式而言,時間複雜度並不是完全依賴輸入或輸出之個數,或其他容易定義的特性而定。考慮 binsearch(程式 1.6):用來決定步驟計數最自然的參數是串列中的元素個數 $n$。但只有參數 $n$ 是不夠的 —— 對於相同的 $n$ 值,步驟計數會隨著所要找尋的元素 searchnum 在陣列中的位置而改變。
對指定的參數所能執行的最少步驟數。
對指定的參數所能執行最多步驟數。
以指定的參數在實體上執行的平均步驟。
「藉著定義三種步驟計數,我們可以從所選擇的參數不足以決定唯一的步驟計數之困境中解脫。」
我們所以要決定步驟計數的動機是為了能夠比較計算相同函數的兩個程式之時間複雜度,以及能夠預測當實體特性改變時,執行時間增長的情形。
要決定一個程式確實的步驟計數證實是一件相當困難的工作。當步驟本身的概念是不確定的時(指令 x=y 和 x=y+z+(x/y)+(x*y*z-x/z) 都算成一個步驟),耗費大量的精神來決定確實的步驟計數是不值得去做的。因為步驟所代表的涵意之不確定性,當以比較兩個程式為目標時,確實的步驟計數不是非常有用的。
在兩個式子的步驟計數相差很大時,如 $3n+3$ 比 $100n+10$,則是一種例外狀況。但即使在這種情況下,知道確實的步驟計數為 $100n+10$ 並非必要的 —— 別的表示法,如「大約 $80n$,或 $85n$,或 $75n$」就足以得到相同的結論。
假設兩個程式的複雜度分別為 $c_1n^2+c_2n$ 與 $c_3n$。對於夠大的 $n$ 值而言,複雜度為 $c_3n$ 的程式將比複雜度為 $c_1n^2+c_2n$ 的程式快速。對於小的 $n$ 值,任一個程式都可能比另一個快(視 $c_1,c_2,c_3$ 而定)。
不論 $c_1,c_2,c_3$ 之值為何,總會有一個 $n$ 值使得複雜度為 $c_3n$ 的程式比複雜度為 $c_1n^2+c_2n$ 的程式快速。這個 $n$ 值就稱為損益點。確實的損益點無法用分析的方法來決定 —— 程式必須在電腦上執行以便決定損益點。但知道有損益點的存在就足以明白兩者的等級差異。
若且唯若 $f(n)=O(g(n))$(讀作 "$f$ of $n$ is big oh of $g$ of $n$")則存在大於 0 的常數值 $c$ 和 $n_0$,使得對所有的 $n$ 值,$n \ge n_0$ 時,$f(n) \le c\,g(n)$ 均成立。
| 敘述 | 成立的理由 |
|---|---|
| $3n+2=O(n)$ | 對所有 $n\ge 2$,$3n+2\le 4n$ |
| $3n+3=O(n)$ | 對所有 $n\ge 3$,$3n+3\le 4n$ |
| $100n+6=O(n)$ | 對所有 $n\ge 10$,$100n+6\le 101n$ |
| $10n^2+4n+2=O(n^2)$ | 對所有 $n\ge 5$,$10n^2+4n+2\le 11n^2$ |
| $1000n^2+100n-6=O(n^2)$ | 對所有 $n\ge 100$,$1000n^2+100n-6\le 1001n^2$ |
| $6\cdot 2^n+n^2=O(2^n)$ | 對所有 $n\ge 4$,$6\cdot 2^n+n^2\le 7\cdot 2^n$ |
| $3n+3=O(n^2)$ | 對所有 $n\ge 2$,$3n+3\le 3n^2$(成立,但不夠緊) |
| $10n^2+4n+2=O(n^4)$ | 對所有 $n\ge 2$,成立但不夠緊 |
| $3n+2 \ne O(1)$ | 對任何常數 $c$ 及所有 $n\ge n_0$,$3n+2$ 並不會小於或等於 $c$ |
| $10n^2+4n+2 \ne O(n)$ | 二次項無法被線性函數壓住 |
敘述 $f(n)=O(g(n))$ 僅陳述 $g(n)$ 是當 $n \ge n_0$ 時 $f(n)$ 之值的上限。它並未說出這個上限好到何種程度。請注意 $n=O(n^2)$、$n=O(n^{2.5})$、$n=O(n^3)$、$n=O(2^n)$ 等函數都對。
為了使敘述 $f(n)=O(g(n))$ 更有意義,當我們可以下決論說 $f(n)=O(g(n))$ 時,$g(n)$ 必須是 $n$ 最小的函數。所以當我們可以說 $3n+3=O(n)$ 時,通常不會說 $3n+3=O(n^2)$,即使它是對的。
另一個陷阱:根據 Big $O$ 的定義,明顯地 $f(n)=O(g(n))$ 與 $O(g(n))=f(n)$ 是不相同的;事實上 $O(g(n))=f(n)$ 是無意義的。不巧的是所使用的符號「$=$」通常是用來代表「對等的」關係。這個困擾可以藉著將「$=$」讀成「是」而不要讀成「等於」來避免。
若 $f(n)=a_mn^m+\cdots+a_1n+a_0$,則 $f(n)=O(n^m)$。
證明:
$$\begin{aligned} f(n) &\le \sum_{i=0}^{m}|a_i|\,n^i\\[2pt] &\le n^m\sum_{0}^{m}|a_i|\,n^{\,i-m}\\[2pt] &\le n^m\sum_{0}^{m}|a_i|,\qquad \text{for } n\ge 1 \end{aligned}$$所以 $f(n)=O(n^m)$。□
若且唯若 $f(n)=\Omega(g(n))$(讀作 "$f$ of $n$ is omega of $g$ of $n$"),則存在大於 0 的常數 $c$ 和 $n_0$,使得對所有的 $n$ 值,$n \ge n_0$ 時,$f(n) \ge c\,g(n)$ 均成立。
| 敘述 | 理由 |
|---|---|
| $3n+2=\Omega(n)$ | 當 $n\ge 1$ 時 $3n+2\ge 3n$(實際上當 $n\ge 0$ 時此不等式即成立,但 $\Omega$ 的定義中要求 $n_0\gt 0$) |
| $3n+3=\Omega(n)$ | 當 $n\ge 1$ 時 $3n+3\ge 3n$ |
| $100n+6=\Omega(n)$ | 當 $n\ge 1$ 時 $100n+6\ge 100n$ |
| $10n^2+4n+2=\Omega(n^2)$ | 當 $n\ge 1$ 時 $10n^2+4n+2\ge n^2$ |
| $6\cdot 2^n+n^2=\Omega(2^n)$ | 當 $n\ge 1$ 時 $6\cdot 2^n+n^2\ge 2^n$ |
也請注意這些成立但不夠緊的敘述:$3n+3=\Omega(1)$;$10n^2+4n+2=\Omega(n)$;$6\cdot 2^n+n^2=\Omega(n^{100})$、$\Omega(n^{50.2})$、$\Omega(n^2)$、$\Omega(n)$、$\Omega(1)$。
就像 big oh 的例子,對於 $f(n)=\Omega(g(n))$ 有數個 $g(n)$ 函數存在;$g(n)$ 僅是 $f(n)$ 的下限。它暗示了當敘述 $f(n)=\Omega(g(n))$ 成立時,$g(n)$ 應是 $n$ 最大的函數(與 $O$ 要取最小的剛好相反)。因此當我們可以說 $3n+3=\Omega(n)$ 或 $6\cdot2^n+n^2=\Omega(2^n)$ 時,通常不會說 $3n+3=\Omega(1)$ 或 $6\cdot2^n+n^2=\Omega(1)$,即使它們是對的。
若 $f(n)=a_mn^m+\cdots+a_1n+a_0$ 且 $a_m \gt 0$,則 $f(n)=\Omega(n^m)$。
(證明:留作習題。)
若且唯若 $f(n)=\theta(g(n))$(讀作 "$f$ of $n$ is theta of $g$ of $n$"),則存在正的常數 $c_1$、$c_2$ 和 $n_0$,使得所有的 $n$ 值,$n \ge n_0$ 時,$c_1g(n) \le f(n) \le c_2g(n)$ 均成立。
$3n+2 \ge 3n$ 當 $n\ge 2$,且 $3n+2 \le 4n$ 當 $n\ge 2$,所以 $c_1=3$、$c_2=4$、$n_0=2$,則 $3n+2=\theta(n)$ 成立。
其他成立的:$10n^2+4n+2=\theta(n^2)$;$6\cdot2^n+n^2=\theta(2^n)$;$10\log n+4=\theta(\log n)$。
不成立的:$3n+2\ne\theta(1)$;$3n+3\ne\theta(n^2)$;$10n^2+4n+2\ne\theta(n)$;$10n^2+4n+2\ne\theta(1)$;$6\cdot2^n+n^2\ne\theta(n^2)$、$\ne\theta(n^{100})$、$\ne\theta(1)$。
Theta 表示法比 big oh 和 Omega 表示法兩者都精確。若且唯若 $f(n)=\theta(g(n))$,則 $g(n)$ 同時是 $f(n)$ 的上限和下限。
定理 1.4:若 $f(n)=a_mn^m+\cdots+a_1n+a_0$ 且 $a_m \gt 0$,則 $f(n)=\theta(n^m)$。(證明:留作習題。)
係數必須是 1:在前面三個範例中 $g(n)$ 所用的係數都是 1。我們幾乎不會使用 $3n+3=O(3n)$,或 $10=O(100)$,或 $10n^2+4n+2=\Omega(4n^2)$,或 $6\cdot2^n+n^2=\theta(4\cdot2^n)$ 等表示法,即使它們都是正確的。
$O(1)$ 表示計算時間為常數值。$O(n)$ 為線性函數,$O(n^2)$ 稱為二次函數,$O(n^3)$ 稱為三次函數,$O(2^n)$ 則稱為指數函數。對於夠大的 $n$ 值而言,一個演算法所需時間為 $O(\log n)$ 時,它比需要時間為 $O(n)$ 的演算法還快;同樣地,$O(n\log n)$ 比 $O(n^2)$ 還快,但不如 $O(n)$。
「如果不想要決定確實的步驟計數,則應使用這些表示法中的那一個?」對這個問題的答案是漸近式複雜度不必決定確實的步驟計數就可以很容易地算出。通常的作法是先決定程式中每一個指令(或一組指令)的漸近式複雜度,而後再將它們加總即得。
| 敘述 | 漸近式複雜度 |
|---|---|
void add(int a[][MAX_SIZE] ··· ) | 0 |
{ | 0 |
int i, j; | 0 |
for (i=0; i<rows; i++) | $\Theta(\textit{rows})$ |
for (j = 0; j < cols; j++) | $\Theta(\textit{rows}\cdot\textit{cols})$ |
c[i][j] = a[i][j] + b[i][j]; | $\Theta(\textit{rows}\cdot\textit{cols})$ |
} | 0 |
| Total | $\Theta(\textit{rows}\cdot\textit{cols})$ |
圖 1.5:矩陣相加之時間複雜度
建立圖 1.5 的表格實際上比建立圖 1.4 的表格容易。舉例而言,要獲得第 5 行的確實步驟計數 $\textit{rows}\cdot(\textit{cols}+1)$ 比將第 5 行看成一個漸近式複雜度 $\theta(\textit{rows}\cdot\textit{cols})$ 要困難得多了。另一種方法:因程式的行數是固定的(即與實體的特性無關),我們只要取得最大的指令行複雜度即可。
採用的實體特性是串列中的元素個數 $n$。while 迴圈每一次需時 $\theta(1)$。
因為使用漸近式分析,我們不需要如此精確的最差狀況下迴路執行次數之計算。除了最後一次,每一次迴路執行時會將必須搜尋的串列範圍減半 —— 也就是說,right - left + 1 的值在每一次迴路中以 2 為因數遞減一半。
最佳狀況複雜度為 $\theta(1)$,即在最好的狀況下 searchnum 於第一次執行 while 迴路時就被找到。(可以證明 while 迴圈最多重作 $\lceil\log_2(n+1)\rceil$ 次。)
重新考慮函數 perm(程式 1.8)。當 $i=n$,需時 $\theta(n)$。當 $i \lt n$,則執行 else 子句。在此子句中的 for 迴路進入的次數為 $n-i+1$ 次。此迴路每次執行需時 $\theta(n+T_{\text{perm}}(i+1,n))$。因此,當 $i \lt n$:
又因為當 $i+1 \le n$ 時 $T_{\text{perm}}(i+1,n)$ 最少為 $n$,我們可獲得對 $i \lt n$:
解此遞迴運算式,可得:
魔術方陣是一 $n \times n$ 的矩陣,其中填入 $1$ 到 $n^2$ 之間的整數,使得各行、各列,以及兩個主對角線上的元素和均相等。下圖為一個 $n=5$ 的魔術方陣,加總同為 65。
| 15 | 8 | 1 | 24 | 17 |
| 16 | 14 | 7 | 5 | 23 |
| 22 | 20 | 13 | 6 | 4 |
| 3 | 21 | 19 | 12 | 10 |
| 9 | 2 | 25 | 18 | 11 |
圖 1.6:$n=5$ 之魔術方陣
將 $1$ 放在第一列最中間的方格內。向上且向左移動並以遞增之順序將數值放在空的方格內。如果移動跳出了方陣(亦即超出了方陣的界限),找出如果你在方陣的另一邊登陸時會在那一個方格,以這個方格繼續填入數值。如果方格已填入數值,則向下移動取代向上移動,再繼續。
#include <stdio.h>
#define MAX_SIZE 15 /* maximum size of square */
void main(void)
/* construct a magic square, iteratively */
{
static int square[MAX_SIZE][MAX_SIZE];
int i, j, row, column; /* indices */
int count ; /* counter */
int size; /* Square size */
printf("Enter the size of the square: ");
scanf("%d", &size);
/* check for input errors */
if (size < 1 || size > MAX_SIZE + 1) {
fprintf(stderr, "Error! Size is out of range\n");
exit(1);
}
if (!(size % 2)) {
fprintf(stderr, "Error! Size is even\n");
exit(1);
}
for (i = 0; i < size; i++)
for (j = 0; j < size; j++)
square[i][j] = 0;
square[0][(size-1) / 2] = 1; /* middle of first row */
/* i and j are current position */
i = 0;
j = (size - 1) / 2;
for (count = 2; count <= size * size; count++) {
row = (i-1 < 0) ? (size - 1) : (i - 1); /*up*/
column = (j-1 < 0) ? (size - 1) : (j - 1); /*left*/
if (square[row][column]) /*down*/
i = (++i) % size;
else { /* square is unoccupied */
i = row;
j = (j-1 < 0) ? (size - 1) : --j;
}
square[i][j] = count;
}
/* output the magic square */
printf(" Magic Square of size %d : \n\n",size);
for (i = 0; i < size; i++) {
for (j = 0; j < size; j++)
printf("%5d", square[i][j]);
printf("\n");
}
printf("\n\n");
}
程式 1.22:魔術方陣程式
令 $n$ 代表魔術方陣的大小(即程式 1.22 中變數 size 之值):
if 指令需時 $\theta(1)$。for 迴圈(初始化)複雜度為 $\theta(n^2)$。for 迴圈每次執行需時 $\theta(1)$,此迴圈執行 $\theta(n^2)$ 次,所以其複雜度為 $\theta(n^2)$。for 迴圈也是需時 $\theta(n^2)$。「在以後各章中當我們分析程式時,通常將我們局限在提出程式複雜度的上限。所以,我們通常只用 big oh 表示法。我們這樣做的原因是它是實際上的一個趨勢。在許多的分析中,當程式的複雜度界限計算是以其上限和下限來分析時,theta 表示法也許會用來代替 big oh 表示法。」
(a) $5n^2-6n=\theta(n^2)$
(b) $n!=O(n^n)$
(c) $2n^2+n\log n=\theta(n^2)$
(d) $\sum_{i=0}^{n} i^2=\theta(n^3)$
(e) $\sum_{i=0}^{n} i^3=\theta(n^4)$
(f) $n2^n+6\cdot 2^n=\theta(n2^n)$
(g) $n^3+10^6n^2=\theta(n^3)$
(h) $6n^3/(\log n+1)=O(n^3)$
(i) $n^{1.001}+n\log n=\theta(n^{1.001})$
(j) $n^k+n+n^k\log n=\theta(n^k\log n)$, $k\ge 1$
(k) $10n^3+15n^4+100n^22^n=O(n^22^n)$
通用作法:要證 $\theta$,必須同時給出 $c_1, c_2, n_0$ 並驗證 $c_1g(n)\le f(n)\le c_2g(n)$;要證 $O$ 只需給 $c, n_0$ 驗證上界。
(a) 取 $c_1=4$(當 $n\ge 6$ 時 $5n^2-6n\ge 4n^2$)、$c_2=5$、$n_0=6$。
(b) $n!=n(n-1)\cdots 1 \le n\cdot n\cdots n=n^n$,取 $c=1,n_0=1$。
(d)(e) 用標準和公式 $\sum i^2=\frac{n(n+1)(2n+1)}{6}$、$\sum i^3=\left[\frac{n(n+1)}{2}\right]^2$,再套定理 1.4。
(h) 只要求 $O$,不是 $\theta$ —— 因為 $\log n+1 \ge 1$,所以 $6n^3/(\log n+1)\le 6n^3$。它不是 $\theta(n^3)$,這就是本題只寫 $O$ 的原因。
(i) 關鍵是 $n\log n = o(n^{1.001})$,因為對任意 $\varepsilon\gt0$,$\log n$ 的增長慢於 $n^{\varepsilon}$。
(j) 三項中 $n^k\log n$ 為主項($k\ge1$ 時 $n^k\log n$ 壓過 $n^k$ 與 $n$)。(k) $n^22^n$ 為主項,指數壓過任何多項式。
證偽的標準手法:反設存在 $c,n_0$,然後找出一個使不等式破裂的 $n$,或證明兩函數的比值趨於 $\infty$(或 $0$)。
1. $\dfrac{10n^2+9}{n}=10n+\frac{9}{n}\to\infty$,不可能被任何常數 $c$ 壓住。
2. $\dfrac{n^2\log n}{n^2}=\log n\to\infty$,所以上界不成立(但 $\Omega(n^2)$ 成立)。
3. $\dfrac{n^2/\log n}{n^2}=\dfrac{1}{\log n}\to 0$,所以下界不成立(但 $O(n^2)$ 成立)。
4. $\dfrac{6n^23^n}{n^32^n}=\dfrac{6}{n}\left(\dfrac{3}{2}\right)^n\to\infty$,$3^n$ 項壓過了 $2^n$ 項。
5. $\dfrac{3^n}{2^n}=(1.5)^n\to\infty$。這題最常見的錯誤是以為「指數就是指數,都同一級」—— 底數不同的指數函數不同級。
複雜度函數可以用來比較執行相同工作的兩個程式 $P$ 和 $Q$。假設程式 $P$ 的複雜度為 $\theta(n)$,程式 $Q$ 的複雜度為 $\theta(n^2)$。我們可以斷定當「$n$ 足夠大」時,程式 $P$ 比程式 $Q$ 為快。
當想要決定應使用兩個程式中的那一個時,我們必須要知道 $n$ 是否「足夠大」。如果程式 $P$ 實際的執行時間為 $10^6 n$ 個百萬分之一秒,而程式 $Q$ 為 $n^2$ 個萬分之一秒,而 $n \le 10^6$,且其他的考慮因素都相同時,則應選擇程式 $Q$。
在一部每秒執行 1 百萬個步驟的電腦上,如果程式執行需要 $2^n$ 步驟:
• $n=40$ → 約 $1.1\times10^{12}$ 步驟 → 18.3 分鐘
• $n=50$ → 13 天
• $n=60$ → 310.56 年
• $n=100$ → 約 $4\times10^{13}$ 年
結論:當程式的複雜度為指數函數時,它限用於小的 $n$ 值(通常 $n \le 40$)。
複雜度為高次的多項式之程式,其使用也受到限制。例如程式需要 $n^{10}$ 步驟:$n=10$ 時需 10 秒;$n=100$ 需 3.171 年;$n=1000$ 則需 $3.17\times10^{13}$ 年。如果換成 $n^3$ 步驟:$n=1000$ 僅需 1 秒;$n=10{,}000$ 需 110.67 分;$n=100{,}000$ 需 11.57 天。
| 時間 | 函數名稱 | 實體特性 n | |||||
|---|---|---|---|---|---|---|---|
| 1 | 2 | 4 | 8 | 16 | 32 | ||
| $1$ | Constant | 1 | 1 | 1 | 1 | 1 | 1 |
| $\log n$ | Logarithmic | 0 | 1 | 2 | 3 | 4 | 5 |
| $n$ | Linear | 1 | 2 | 4 | 8 | 16 | 32 |
| $n\log n$ | Log linear | 0 | 2 | 8 | 24 | 64 | 160 |
| $n^2$ | Quadratic | 1 | 4 | 16 | 64 | 256 | 1024 |
| $n^3$ | Cubic | 1 | 8 | 64 | 512 | 4096 | 32768 |
| $2^n$ | Exponential | 2 | 4 | 16 | 256 | 65536 | 4294967296 |
| $n!$ | Factorial | 1 | 2 | 24 | 40326 | 20922789888000 | 26313 × 1033 |
圖 1.7:函數值
| Time for $f(n)$ instructions on a $10^9$ instr/sec computer | |||||||
|---|---|---|---|---|---|---|---|
| n | $f(n)=n$ | $f(n)=\log_2 n$ | $f(n)=n^2$ | $f(n)=n^3$ | $f(n)=n^4$ | $f(n)=n^{10}$ | $f(n)=2^n$ |
| 10 | .01µs | .03µs | .1µs | 1µs | 10µs | 10sec | 1µs |
| 20 | .02µs | .09µs | .4µs | 8µs | 160µs | 2.84hr | 1ms |
| 30 | .03µs | .15µs | .9µs | 27µs | 810µs | 6.83d | 1sec |
| 40 | .04µs | .21µs | 1.6µs | 64µs | 2.56ms | 121.36d | 18.3min |
| 50 | .05µs | .28µs | 2.5µs | 125µs | 6.25ms | 3.1yr | 13d |
| 100 | .10µs | .66µs | 10µs | 1ms | 100ms | 3171yr | 4*1013yr |
| 1,000 | 1.00µs | 9.96µs | 1ms | 1sec | 16.67min | 3.17*1013yr | 32*10283yr |
| 10,000 | 10.00µs | 130.03µs | 100ms | 16.67min | 115.7d | 3.17*1023yr | |
| 100,000 | 100.00µs | 1.66ms | 10sec | 11.57d | 3171yr | 3.17*1033yr | |
| 1,000,000 | 1.00ms | 19.92ms | 16.67min | 31.71yr | 3.17*107yr | 3.17*1043yr | |
圖 1.9:在每秒百萬指令電腦上的執行時間(µs = $10^{-6}$ 秒,ms = $10^{-3}$ 秒,d = 天,yr = 年)
從實際觀點而言,對於相當大的 $n$ 值($n \gt 100$),很明顯的,具有小的複雜度(如 $n$、$n\log n$、$n^2$、$n^3$)之程式才是可行的。此外,即使有人可以製造每秒 $10^{12}$ 指令的電腦,這現象還是存在的 —— 在此情況下,圖 1.9 將以 1000 的倍率縮減;當 $n=100$,它將需要 3.17 年來執行 $n^{10}$ 個指令;$4\times10^{10}$ 年來執行 $2^n$ 個指令。把電腦加快 1000 倍,並沒有讓指數演算法變得可用。
雖然效率分析在確認一個演算法的空間和時間複雜度上提供我們一個強大的工具,有些時候,我們仍然要考慮演算法如何在我們的機器上執行。這個考慮因素使我們從分析的領域走向估計的領域。我們將討論的重點放在時間的估計上。
用來計算時間的函數為 C 標準函數庫的一部份,可透過 #include <time.h> 來存取。在 C 中有兩種方法來計算事件的時間。
| 方法 1(clock) | 方法 2(time) | |
|---|---|---|
| 開始計時 | start = clock(); | start = time(NULL); |
| 結束計時 | stop = clock(); | stop = time(NULL); |
| 傳回型態 | clock_t | time_t |
| 所需秒數 | duration = | duration = |
圖 1.10:在 C 語言中事件的計時方法
提供自程式開始執行後所經過的處理機時間。要為一個事件計時,要使用二次 clock,一次在事件開始,另一次在事件結束。因為結果可以是任何合法的數值型態,我們將其 type cast 為 double。此外,因為這個結果以內部處理機時間來測量,我們必須將它除以每秒的時計滴答數(ticks)以獲得實際的秒數 —— 每秒時計滴答數儲存在內建的常數 CLK_TCK 中。課本說這個方法在他們所用的機器上比較精確。
以秒為單位傳回時間,其內建型態為 time_t。與 clock 不同的是 time 有一個參數,它定義用來儲存時間的位址;因為我們不需要保留這個時間,對此參數則傳一個 NULL 值給它。然後將這兩個時間傳送到 difftime,它以秒為單位傳回這兩個時間的差值。優點:無需知道每秒時計滴答數。
選取排序(selection sort)的最差狀況發生在元素以相反的順序排列時 —— 亦即,要將目前為降冪排列的一個陣列以升冪排序。為進行時間測試,將陣列的大小改變,由 $0, 10, 20, \ldots, 90, 100, 200, \ldots, 1600$。
#include <stdio.h>
#include <time.h>
#define MAX_SIZE 1601
#define ITERATIONS 26
#define SWAP(x, y, t) ((t) = (x), (x) = (y), (y) = (t))
void main(void)
{
int i,j,position;
int list[MAX_SIZE];
int sizelist[] = {0, 10, 20, 30, 40, 50, 60, 70, 80, 90,
100, 200, 300, 400, 500, 600, 700, 800, 900, 1000, 1100,
1200, 1300, 1400, 1500, 1600};
clock_t start, stop;
double duration;
printf(" n time\n");
for (i = 0; i < ITERATIONS; i++) {
for (j = 0; j < sizelist[i]; j++)
list[j] = sizelist[i] - j;
start = clock();
sort(list,sizelist[i]);
stop = clock();
/* CLK_TCK = number of clock ticks per second */
duration = ((double) (stop-start)) / CLK_TCK;
printf("%6d %f\n",sizelist[i], duration);
}
}
程式 1.23:Selection sort 函數的呼叫時間計算程式
測試在 IBM 相容的 PC 上進行(80386 CPU、80387 數值協同處理器、turbo 加速器,Borland Turbo C)。當 $n \le 100$ 時,測得的時間為 0 —— 這當然不精確。對所有的 $n$ 而言,排序時間應大於 0,因為有一些工作量已完成。再者,在所用的電腦上 CLK_TCK 之值為 18,當 $n \lt 500$ 時,所測得的時計滴答數小於 10。因為測量誤差為 $\pm1$ 滴答,當 $n \lt 500$ 時,所測得的時計滴答數之精確度小於 10%。
| n | 時間 | n | 時間 |
|---|---|---|---|
| 30 ··· 100 | .00 | 900 | 1.86 |
| 200 | .11 | 1000 | 2.31 |
| 300 | .22 | 1100 | 2.80 |
| 400 | .38 | 1200 | 3.35 |
| 500 | .60 | 1300 | 3.90 |
| 600 | .82 | 1400 | 4.54 |
| 700 | 1.15 | 1500 | 5.22 |
| 800 | 1.48 | 1600 | 5.93 |
圖 1.11:Selection Sort 最差狀況之效率(以秒為單位)
當 $n \gt 500$ 時,本實驗的精確度在 10% 以內,可視其為可接受的。所獲得的時間給我們的建議是:雖然選取排序法對於小的陣列是好的方法,但對於大的陣列而言,它是很差的方法。
當我們想計算一個只需要很少的時間即可完成的函數之時間時,用來計算選取排序之直接的計時方法是不足的。本例要考慮一種更為精確的計時方法。
int seqsearch(int list[], int searchnum, int n)
{
/*search an array, list, that has n numbers. Return i,
if list[i] = searchnum. Return -1, if searchnum is not in
the list */
int i;
list[n] = searchnum;
for (i = 0; list[i] != searchnum; i++)
;
return ((i < n ) ? i : -1);
}
程式 1.24:Sequential Search 函數(注意 list[n] = searchnum; 這個哨兵寫法,讓迴圈裡不必再檢查 i < n)
這程式從陣列的第一個元素開始,以所要搜尋的數值 searchnum 和陣列中的每一個元素比較,直到 searchnum 被找到或是到達陣列的結尾才停止。如果要找的數不在陣列中,則出現此種搜尋的最差狀況 —— 陣列中所有的元素都經過比較,而迴圈反覆執行了 $n+1$ 次。
因為搜尋所需時間比排序少,即使對小的陣列,程式 1.23 所有的計時策略在此是不夠的。解法:對每一種陣列的大小,多次呼叫搜尋函數。因為在陣列的大小比較小時,函數執行速度較快,我們對小的陣列重覆搜尋之次數比對大的陣列還多。
#include <stdio.h>
#include <time.h>
#define MAX_SIZE 1001
#define ITERATIONS 16
int seqsearch(int [], int, int);
void main(void)
{
int i, j, position;
int list[MAX_SIZE];
int sizelist[] = {0, 10, 20, 30, 40, 50, 60, 70, 80, 90,
100, 200, 400, 600, 800, 1000};
int numtimes[] = {30000, 12000, 6000, 5000, 4000, 4000,
4000, 3000, 3000, 2000, 2000,
1000, 500, 500, 500, 200};
clock_t start, stop;
double duration,total;
for (i = 0; i < MAX_SIZE; i++)
list[i] = i;
for (i = 0; i < ITERATIONS; i++) {
start = clock();
for (j = 0; j < numtimes[i]; j++)
position = seqsearch(list, -1, sizelist[i]);
stop = clock();
total = ((double)(stop-start))/CLK_TCK;
duration = total/numtimes[i];
printf("%5d %d %d %f %f\n", sizelist[i], numtimes[i],
(int)(stop-start), total,
duration);
list[sizelist[i]] = sizelist[i]; /* reset value */
}
}
程式 1.25:Sequential Search 的計時程式
(1) 在每次呼叫 seqsearch 以後,必須重設元素 list[sizelist[i]] —— 因為函數把哨兵寫進去了。
(2) 重複的次數必須足夠大,使得所經過的時計滴答數至少為 10(如果想要精確度至少為 10%)。然而,如果我們重複的次數太多,則所需的全部計算時間將變得非常大。選取一個適當的重複次數包括了嘗試錯誤的過程。
| n | 重複次數 | 滴答數 | 總時間 (sec) | 每次執行時間 |
|---|---|---|---|---|
| 0 | 30000 | 16 | 0.879121 | 0.000029 |
| 10 | 12000 | 16 | 0.879121 | 0.000073 |
| 20 | 6000 | 14 | 0.769231 | 0.000128 |
| 30 | 5000 | 16 | 0.879121 | 0.000176 |
| 40 | 4000 | 16 | 0.879121 | 0.000220 |
| 50 | 4000 | 20 | 1.098901 | 0.000275 |
| 60 | 4000 | 23 | 1.263736 | 0.000316 |
| 70 | 3000 | 20 | 1.098901 | 0.000366 |
| 80 | 3000 | 23 | 1.263736 | 0.000421 |
| 90 | 2000 | 17 | 0.934066 | 0.000467 |
| 100 | 2000 | 18 | 0.989011 | 0.000495 |
| 200 | 1000 | 18 | 0.989011 | 0.000989 |
| 400 | 500 | 18 | 0.989011 | 0.001978 |
| 600 | 500 | 27 | 1.483516 | 0.002967 |
| 800 | 500 | 35 | 1.923077 | 0.003846 |
| 1000 | 300 | 27 | 1.483516 | 0.004945 |
圖 1.13:Sequential Search 之最差狀況效率(IBM PS/2 Model 50,Turbo C)
對於較大的 $n$ 值,時間和陣列大小之間的線性相關即變得更清楚。這是因為常數的加成因素,對小的 $n$ 值是有較大的優勢。(看最右欄:$n$ 從 100 到 1000 增加 10 倍,每次執行時間從 0.000495 到 0.004945 也大約增加 10 倍 —— 線性。)
產生一組會造成程式的最差狀況效率的資料並不是永遠都很容易。有些時候,必須以一個程式來產生最差狀況的資料。在其他時候,即使是這樣做也很困難。此時,必須以另一種方法來估計最差狀況的效率:
對於我們有興趣的各個實體特性的每一組資料,產生一個相當多數的隨機測試資料,取得各組測試資料的執行時間,採用這些時間中最大者當成此實體特徵這一組資料的最差狀況時間。
雖然我們可以對循序搜尋和二分搜尋這樣做,對排序程式則不可以。如果我們假設所有的鍵值都是不同的,則對任一個 $n$ 值,必須使用 $n!$ 組不同的排列以取得平均時間。
要取得平均狀況的資料通常比取得最差狀況的資料困難。所以,通常我們會採用上面所說明的策略,而僅取得平均時間的估計值。
不論我們以隨機資料來估計最差狀況或平均時間,我們可以嘗試的實體個數通常比這樣的實體總個數少很多。因此,先決定應該為實驗產生何種類型的資料以便分析所要測試的演算法是必要的。這個工作隨演算法不同而有很大的差別。
| 主題 | 參考書目 |
|---|---|
| 程式設計技巧與程式發展 |
D. Gries, The Science of Programming, Springer Verlag, NY, 1981 E. Dijkstra, A Discipline of Programming, Prentice-Hall, Englewood Cliffs, NJ, 1976 B. W. Kernighan and P. J. Plauger, The Elements of Programming Style, 2nd ed., McGraw Hill, NY, 1978 |
| 大型軟體系統的開發 |
E. Horowitz, Practical Strategies for Developing Very Large Software Systems, Addison-Wesley, Reading, Mass., 1975 I. Sommerville, Software Engineering, 3rd ed., Addison-Wesley, Workingham, England, 1989 F. Brooks, The Mythical Man-Month, Addison-Wesley, Reading, Mass., 1979 |
| 效率分析與估計 | S. Sahni, Software Development in Pascal, 2nd ed., Camelot Publishing, 1989 |
| 抽象資料型態 |
B. Liskov and J. Guttag, Abstraction and Specification in Program Development, MIT Press, Cambridge, Mass., 1988 J. Kingston, Algorithms and Data Structures |
課本在 1.1 節「改良和編碼」裡提到:「如果必須完全地抹去已完成的工作,我們也會慶幸能夠很快速而且較少錯誤地重新編製新的系統。一本討論這種『第二個系統』事項的書籍是列於本章結尾的參考文獻中的 Frederick Brooks 所著的 The Mythical Man-Month。」—— 這就是 1.1 節與 1.6 節之間的連結。
count 變數法與 s/e × 頻率表列法。for 那一行的頻率是 $n+1$。| 函數 | 時間 | 空間(可變) |
|---|---|---|
sum(迴路) | $2n+3=\theta(n)$ | $0$ |
rsum(遞迴) | $2n+2=\theta(n)$ | $6n$ bytes |
add(矩陣) | $\theta(\textit{rows}\cdot\textit{cols})$ | $0$ |
binsearch | 最差 $\theta(\log n)$ 最佳 $\theta(1)$ | 迴路 $0$ |
perm | $\theta(n\cdot n!)$ | $\theta(n)$ |
| magic square | $\theta(n^2)$ | $\theta(n^2)$ |
seqsearch | 最差 $\theta(n)$ | $0$ |
| selection sort | $\theta(n^2)$ | $0$ |
rsum 步驟計數比較少,但執行比較慢。for 的頻率是 $n+1$,主體才是 $n$。第 2 章起,每一個資料結構都會先給 ADT 定義(1.3 節的格式),再給實作,最後用 1.4 節的漸近式表示法標出每個運算的複雜度。第 7 章(排序)與第 10 章(搜尋結構)幾乎整章都在做 1.4.3 節的分析,第 9 章(累堆結構)的「成本分攤(amortized cost)」更是本章步驟計數觀念的直接延伸。