世界上最誠實的演算法
假設你弄丟了一把小掛鎖的三位數密碼。你能做的最不聰明的事,就是試 000、再試 001、再試 002,一路試到 999,直到有一個打得開為止。沒有洞見、沒有捷徑——只有耐心與記帳。這就是暴力法的想法,濃縮成一幅畫面:要解一個問題,就把這個問題所允許的每一個候選答案都試一遍,留下行得通的那個(或那些)。我們刻意把整個學習階段從這裡開始,因為暴力法是其他一切所立足的地板——是你用來衡量「聰明」的那條基準線。
為什麼要拿最笨的方法開場?因為它有一個無價的優點:它顯然是正確的,而且它之所以正確,理由你一口氣就能說完。前面幾個階段,我們學會了把問題和演算法分開,也學會問「一個執行時間指的是哪一種情況」。暴力法正是這些習慣第一次得到回報的地方。對大型輸入它會太慢——這正是這一整個階段的戲劇張力所在——但它是你永遠不會懷疑的那個方法,這讓它成為學習「把每件事都試一遍」到底要求什麼的最佳起點。
兩個半邊:一個生成器與一個測試器
仔細看那場掛鎖大掃描,你會看到兩件不同的工作在輪流進行。一件工作負責產生「下一個要試的東西」(000、然後 001……);另一件工作負責檢查它(鎖打開了嗎?)。把這兩件事拆開,你就得到了幾乎每個暴力法內部的那具引擎:生成與測試。生成器是一個程序,一次交給你一個候選解;測試器則是一個判定式,接收一個候選解,回答「是」或「否」——這個候選解滿足問題的要求嗎?
while generator has more candidates:
x = generator.next() # generate
if test(x): # test
report x # (or stop here for the first hit)把這兩個半邊分開是一個實實在在的設計優勢,而不只是整潔而已。你可以換上一個更聰明的生成器——一個產生較少「註定失敗」候選解的生成器——而完全不必動測試器;你也可以把測試器磨利,而不必重寫生成器。這個迴圈的總代價,從這個拆分一眼就能讀出來:它大約等於(生成的候選解數量)乘上(一次測試的代價)。這個乘積就是我們接下來整個階段都在攻擊的那個數字,而我們幾乎總是從縮小第一個因子下手。
為什麼它總是對的:完全列舉
暴力法的正確性,靠的是對生成器的一個有紀律的承諾,叫做完全列舉:把每一個候選解都列出來,各一次,一個不漏、一個不重。有兩個條件必須同時成立。不遺漏意思是:問題所允許的每一個候選解,都確實出現在這趟掃描裡——這正是讓你能信任一個「否」答案的依據,因為唯有真的把它們全看過,你才能宣稱「不存在解」。不重複意思是:每個候選解最多出現一次——這讓計數保持誠實,也讓你不會白做工。
生成器要怎麼同時保證這兩件事?標準做法是替候選解強加一個「順序」,然後從頭走到尾。把掛鎖密碼從 0 編號到 999,往上數:數值順序剛好把每一個各拜訪一次,於是用一個小小的歸納就能說明:沒有跳過、也沒有重複。同樣的想法可以放大。要列出 n 個項目的所有子集,就把整數從 0 數到 2^n - 1,把每個整數讀成一個位元遮罩(列舉子集);要列出 n 個項目的所有排列,就用字典序(列舉排列)。在每一種情況裡,一個固定的順序,正是把「把每件事都試一遍」從一句美好的願望,變成一個你能證明「真的拜訪了每一個」的程序的關鍵。
計算代價:這座草堆有多大?
在你尋找那根針之前,你應該先知道這座草堆有多大。所有候選解構成的集合,就是搜尋空間——真正的答案可能藏身的每一個位置。它的大小,決定了暴力法到底是不是一個可行選項;而壞消息是,它通常會隨著輸入而爆炸。對 n 個項目各做一次「要/不要」的選擇,會得到 2^n 個候選解(冪集)。把 n 個項目排序,會得到 n! 種順序。把 k 種標籤之一指派給 n 個項目中的每一個,會得到 k^n 種。這些計數並非偶然——它們正是純暴力法為什麼是指數時間的原因。
把數字代進去,你就能感受到那道斷崖。生成一個子集大約花 n 的工夫(讀出它的位元),所以掃過所有子集是 Theta(n * 2^n):n 在 20 到 25 之內還算舒服,過了 30 就沒救了。排列更殘忍——n! 超越每一個 2^n,所以 n = 13 就已經越過六十億種順序,n = 20 更是徹底搆不著。這正是前面幾個階段教我們讀出的那個誠實判決:指數增長不是「慢」,它是一堵牆。一個在 n = 10 時無人能敵的方法,到了 n = 60 可能在物理上根本辦不到。
純粹的生成與測試是盲目的——而這正是突破口
這裡有一個值得我們執著的缺陷。在「純粹的」生成與測試裡,生成器是盲目的:它產生候選解時完全不顧測試器,於是它樂呵呵地大量吐出一望即知註定失敗的候選解。試著替八位有衝突要求的晚宴賓客安排座位,一個盲目的生成器會把八個人全排好,才注意到第一位和第二位賓客——明明在最前面兩張椅子上就定下來了——根本不被允許坐在一起。它做完了一整套安排,才發現一個在兩個決定後就看得見的矛盾。
最有威力的單一改良,就是把知識從測試器「反推回生成器」:拒絕生成任何已知會失敗的候選解。當這種回饋發生在「建構一個候選解的途中」——前兩張椅子一旦就已破壞規則,就立刻放棄它們的所有完成方式——純粹的生成就變成了回溯法,而省下來的工夫可以大到驚人。這是整個階段的樞紐:我們不是把暴力法丟掉,而是教它的生成器別再浪費時間。這種「提早拒絕」本身有個我們會倚靠的名字,叫做可行性剪枝。
從這裡開始,這個階段以清晰的步伐往上爬。第 3 篇《回溯法》會把「做—遞迴—還原」的迴圈講精確,並把它呈現成「對一棵由部分解構成的樹做深度優先走訪」,以八皇后與子集和作為示範模板。第 4 篇《分支定界法》把剪枝升級到「最佳化」——砍掉那些「最好的可能結果都贏不過目前已找到的最佳答案」的子樹。第 5 篇《中間相遇法》則直接攻擊指數本身,常常把 2^n 變成大約 2^(n/2)。它們每一個都是同一招:從這條誠實、正確的基準線出發,然後拒絕去看那些不可能有意義的候選解。