一份食譜,但寫得精確到連機器都能照著做
演算法是一串有限、毫不含糊的步驟,能把輸入轉成正確的輸出。把它想成一份烹飪食譜——只不過這份食譜絕不會寫「鹽適量」或「煮到熟為止」。每一步都必須精確到:一位毫無判斷力、只會照做的店員,或一台沒有直覺的機器,都能照著執行並且每次都得到相同結果。「有限」這點也很重要:步驟終究要用完,這樣整套程序才會真的停下來、交給你一個答案,而不是永遠繞圈圈。
這短短一句話裡藏了兩項要求,而我們之後會各用整整一個學習階段去談它們。第一,這些步驟必須在每一個被允許的輸入上都產生「正確」的輸出——這就是正確性,而「證明」它(而不只是測試它)本身就是一門技藝。第二,步驟必須會用完——這就是終止性。一個答案完美卻永遠不結束的演算法,根本算不上演算法。現在先記住這幅畫面:精確的步驟,輸入進、輸出出,而且保證會停。
問題、演算法、程式:三件不同的東西
初學者常把這三者混為一談,而把它們拆開來,正是你在這一階段所能做出的最有用的一步。問題是一份規格——一份合約,說明「給定『這種』輸入,產生一個滿足『這些』條件的輸出」。「把一串數字排成非遞減順序」就是一個問題。它對「怎麼做」隻字未提。問題、演算法與程式之間的區別正是如此:「做什麼」對上「怎麼做」對上「在這台機器上怎麼做」。
演算法則是為解決那個問題所選定的「一種」方法——一套特定的步驟策略。排序就有很多種:插入排序、合併排序、快速排序等等,全都正確,但成本各不相同。程式則是用真實語言(Python、C,隨便哪種)寫出來的演算法,連電腦所要求的瑣碎細節都補齊。三者由上往下是一對多的關係:一個問題,對應多個正確的演算法;一個演算法,對應多份忠實的程式。我們刻意選在「演算法」這一層去設計與分析,因為點子住在這一層,而且這一層的結論不會因你用了哪個編譯器而改變。
實例與輸出:問題是一整個家族
問題不是單一一道題目;它是一整個無窮的家族,這些題目共享同一種形狀。每一道被具體填滿的題目,就是一個問題實例。對排序問題而言,串列 [3, 1, 2] 是一個實例,[9, 9, 1, 5] 是另一個。問題是那個樣式,而實例是你真的能交給演算法去跑的一個例子。一個演算法唯有在問題允許的「每一個」實例上都產生合格的輸出,才配得上「正確」二字——而不只是在你恰好試過的那三個上。
輸出就是合約承諾要回傳的東西。有時它是一串重新排好的串列;有時是單一一個數字;有時是「是/否」。這裡有個現在就值得認識的細微之處:對同一個實例,一個問題可以有不只一個合格的輸出。「找一條最短路徑」可能有兩條路徑並列最短——兩條都是正確的輸出。所以「那個答案」有時其實是「一個答案」。把問題、實例、輸出三者乾乾淨淨地分開,正是讓我們之後能夠講出像「這個方法在所有實例上都正確,但在某些實例上很慢」這種精準說法的關鍵。
輸入規模:我們用來衡量成本的那個旋鈕
沒有量尺,談成本就沒有意義,而那把量尺就是輸入規模,幾乎總是寫成 n。它是對「這個實例有多大」所做的一個誠實計數:串列裡有幾個元素、圖裡有幾個頂點與幾條邊、一個數字有幾位。整個分析的遊戲,就是把成本描述成「n 的函數」——「規模為 n 的實例要花這麼多步」——因為這告訴我們:當輸入長大時,方法如何隨之伸縮,而這正是真正決定一個東西在實際規模下能不能用的那個問題。
誠實地挑選 n 需要一點講究。對排序而言,n 是串列長度——夠清楚了。但對「這個數是質數嗎?」,正確的規模是「位數」,而不是數值本身,因為一個 20 位的數確實是比 2 位的數更大的輸入,儘管兩者都「只是一個數字」。挑錯 n 是個經典陷阱,會讓一個慢演算法在紙上看起來很快。等我們爬到階梯更高處時還會再碰到這個「位數對上數值」的細微之處,屆時它會悄悄地把「有效率」與「只是看起來有效率」的方法區分開來。
不用碼錶也能數步數
如果我們真用一支碼錶去替演算法計時,答案會隨著每一台筆電、每一種語言、每一個背景程式而改變。為了得到一個與機器無關的量度,我們改為計數基本步驟——像是「比較兩個數」「相加」「讀或寫一個記憶體格子」這類最原始的運算——並約定每一個都算一單位。這套抽象就是RAM 模型(隨機存取機):一台想像出來的簡單電腦,任何一個記憶體格子都能在一步內取用,而每個基本運算都花常數時間。它是一個刻意為之的簡化,卻出奇地好用。
也要誠實面對這個模型藏起了什麼。它假裝「把兩個巨大的數相加」和「把兩個小數相加」一樣貴,也假裝「取用快取」和「取用遠處記憶體」一樣貴——這兩件事在真實硬體上都不是字面上成立的。但對於「替策略排名」以及「看清成本如何隨 n 增長」來說,RAM 模型恰好是對的高度:高到足以無視雜訊,低到仍能數出真正要緊的東西。這一階段的第 4 篇指南會精確地釘住「什麼東西才算一步」。
同樣規模、運氣不同:最壞、最佳與平均
就算固定了 n,同樣規模的兩個實例,成本也可能天差地別。在一串串列裡搜尋某個值,可能在第一個位置就找到(一步),也可能要到最末端才找到(n 步)。所以成本不是「每個規模對應一個數字」——它是「該規模所有實例上的一片分布」,而我們透過三種觀察角度去概括這片分布,這正是最壞、最佳與平均情況這個概念所捕捉的。
- 最壞情況:在所有規模為 n 的實例中,最會逼出步數的那一個所花的步數。這是這個領域的招牌量度,因為它是一種保證——「絕不會比這更慢」——而保證正是你能拿來依靠的東西。
- 最佳情況:任何規模為 n 的實例所逼出的最少步數。通常是最沒用的角度——它拿演算法最幸運的輸入來替它臉上貼金——但它有時會揭示一個真正有幫助的提早結束。
- 平均情況:典型的成本,對眾多實例取平均。很有威力,但有個值得大聲說清楚的但書:「平均」唯有在你固定了一個對輸入的假設分布之後才有意義。改變你對「什麼算典型」的假設,平均值就可能跟著改變——所以一個平均情況的論斷,只能誠實到它背後那個輸入模型那麼誠實。
這三種角度,正是上面我們所有仔細區分的回報。正因為我們把問題、實例與輸入規模分得清清楚楚,現在才能講出真正精準的話——「在最壞的、規模為 n 的實例上,這最多花這麼多步」——而不是含糊的「它蠻快的」。這一階段第 5 篇指南會把這三者好好拆開來談;現在,只要養成一個習慣:每當有人報給你一個執行時間,就問一句「你說的到底是哪一種情況?」