一个七步方法
初学者和老练的解题者之间最大的差别,不是天生的聪明——而是在答案并不显然时,有一套可以退守的流程。一张白纸令人僵住;一份清单不会。下面是一个你可以按顺序、不慌不忙地套用到几乎任何算法问题上的方法。
- 厘清。用自己的话复述问题。输入和输出究竟是什么?规模与取值范围如何?会有重复、负数、空输入吗?动手写代码之前先问清楚。
- 手算小例子。取一个极小的输入,在纸上解出来。亲手求解这个动作,几乎总能揭示出真正算法的结构。
- 先写暴力解。找出任何一个正确的解法,无论多慢。一个能跑的 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|动态规划]]反而是过度设计。这套心法的一部分是判断力:让投入匹配输入规模与需求。目标是一个正确、清晰、且*足够*快的解——而非可能存在的最聪明的那个。