一個七步方法
初學者和老練的解題者之間最大的差別,不是天生的聰明——而是在答案並不顯然時,有一套可以退守的流程。一張白紙令人僵住;一份清單不會。下面是一個你可以按順序、不慌不忙地套用到幾乎任何演算法問題上的方法。
- 釐清。用自己的話複述問題。輸入和輸出究竟是什麼?規模與取值範圍如何?會有重複、負數、空輸入嗎?動手寫程式碼之前先問清楚。
- 手算小例子。取一個極小的輸入,在紙上解出來。親手求解這個動作,幾乎總能揭示出真正演算法的結構。
- 先寫暴力解。找出任何一個正確的解法,無論多慢。一個能跑的 O(n^2),勝過一個有 bug 的「聰明」想法——而且它給了你一個可以去優化的起點。
- 找瓶頸。分析暴力解的大 O,問自己:哪一步是昂貴的那一步?正是這一步,會帶來你全部的改進空間。
- 選一個能攻克瓶頸的模式或結構。反覆查找?用雜湊表。重複的子問題?用動態規劃。在有序陣列上掃描?用雙指標。(對應見下。)
- 分析新的大 O。這個模式真的降低了代價嗎?把時間與空間複雜度像前面台階教你的那樣大聲說出來。若沒有改進,換一個模式再試。
- 測邊界情形。空輸入、單個元素、全部相等、最大規模、負數。讓你的程式碼逐一走過它們。絕大多數 bug 就藏在這裡。
把問題對應到模式
第 5 步——選模式——看起來是難處,但它主要靠「認出來」。你在這條階梯上學過的每個模式,都有一句標誌性的提示語。當你在題目裡聽到那句話,對應的模式就亮起來。訓練自己把題目的語言翻譯成模式的語言。
Problem says ... Reach for ... ------------------------------------- ------------------------------ "have I seen this value before?" hash table / set (avg O(1)) "find a value in sorted data" binary search (O(log n)) "smallest / largest so far, repeatedly" heap / priority queue "all paths / all combinations" backtracking (with pruning) "overlapping subproblems" dynamic programming "contiguous window over an array" sliding window (O(n)) "pair in a sorted array" two pointers (O(n)) "shortest path / levels in a graph" BFS "reachability / cycles / components" DFS or union-find "split, solve halves, combine" divide and conquer
一個完整例子把這些串起來。「給定一個陣列,是否存在兩個數之和等於目標值?」暴力解:檢查每一對,O(n^2)。瓶頸:對每個數,你都重新在整個陣列裡找它的補數。「我之前見過這個值嗎?」這句話亮了——於是用[[hash-table|雜湊表]]:邊掃描邊存下每個數,對當前這個數,檢查 (目標 − 當前) 是否已被存過。這把內層搜索變成平均 O(1),於是整體是 O(n)。一個模式,施於瓶頸之上,就把 n^2 壓成了 n。
bool twoSum(const vector<int>& a, int target) {
unordered_set<int> seen;
for (int x : a) {
if (seen.count(target - x)) return true; // complement already seen
seen.insert(x);
}
return false;
}
// Brute force was O(n^2). With the hash set, this is O(n) time, O(n) space.保持冷靜與誠實
還有兩個習慣,區分了不斷成長的人與原地踏步的人。第一是把你的思路大聲講出來——邊做邊說出你的假設、你的暴力解、你的瓶頸。它讓你保持誠實;在面試中,也讓別人能幫到你。第二是願意說「我的第一個想法是 O(n^2),讓我看看能不能做得更好」,而不是假裝那個快速答案是憑空、完整地降臨的。
並非每個問題都需要花俏的模式。有時暴力解*就是*答案,因為 n 很小,去搬出[[dynamic-programming|動態規劃]]反而是過度設計。這套心法的一部分是判斷力:讓投入匹配輸入規模與需求。目標是一個正確、清晰、且*足夠*快的解——而非可能存在的最聰明的那個。