🧠 别背题,背套路

吴师兄算法心法

带了这么多年学生,我发现一个扎心的事实:很多人刷了三百道题还是怕面试,原因不是题做少了,而是每道题都当新题做,做完就忘。其实成百上千道题,翻来覆去就那么几个骨架。把下面这六套心法刻进脑子,再看题你会有种「噢,这不就是那个套路嘛」的踏实感。

1

双指针

两根指针,省一层循环

双指针的本质,是用两个一前一后(或一快一慢)的指针,把本来要两层循环干的事压成一层。它有四个常见长相,认清了就不会乱:

对撞指针:数组有序时,一头一尾往中间夹。和大了就缩右、小了就进左——两数之和 II、盛水容器都是它。
快慢指针:一个走两步、一个走一步。在链表里找中点、判环;在数组里做原地去重。
滑动窗口:右指针扩、左指针缩,维护一段「连续区间」满足某条件,求最长 / 最短。
读写分离:一个指针负责读、一个负责写,原地移除 / 搬运元素。

# 对撞:有序数组里找一对
l, r = 0, n - 1
while l < r:
    if 满足(l, r): return ...
    elif 偏大: r -= 1        # 缩右变小
    else: l += 1            # 进左变大

我常跟学生说:看到「有序 + 找一对」,手就该先把 l、r 摆上去;看到「链表中点 / 判环」,脑子里先冒出 slow、fast。这种条件反射,就是熟练。

2

动态规划

大问题=小问题的最优拼起来

DP 之所以难,是因为它「抽象」。但只要每次都老老实实问自己这五个问题,再难的 DP 也能拆开:

状态是什么——dp[i] 到底代表什么?(如「以 i 结尾的最长…」「前 i 个能否…」)
怎么转移——dp[i] 由哪些更小的状态推来?这是核心,也是动画最该看清的一步。
边界——最小的子问题答案是多少(dp[0]、空串、单个元素)。
遍历顺序——0-1 背包内层倒着、完全背包内层正着,方向错了答案就错。
能不能压空间——只依赖前一两项时,用滚动变量把二维压成一维。

# 选 / 不选:一类一维 DP 的通用骨架
prev2, prev1 = 0, 0
for x in nums:
    cur = max(prev1,            # 不选当前
              prev2 + x)        # 选当前(接更前面的)
    prev2, prev1 = prev1, cur

很多人卡在「想不出转移方程」。我的笨办法是:先别想公式,先用动画把一个小例子的表格一格一格填出来,填着填着,方程自己就浮出来了。这也是我把每道 DP 都做成填表动画的原因。

3

回溯

选 → 递归 → 撤销

回溯说穿了就是在一棵「决策树」上做深度优先搜索:进入一个分支前做选择,退出来时把选择撤销,换下一个分支再试。三件套永远是这三步:

path.append(选项)        # ① 选
backtrack(下一步)        # ② 递归
path.pop()              # ③ 撤销

剩下的区别只在两个旋钮:用 start 还是 used——组合 / 子集用 start 索引去重(只往后选),排列用 used 数组标记谁用过;能不能复用——递归传 i 表示同一个数能再选,传 i+1 表示用过就不能再用。再加上「剩余目标 < 0 就剪枝」,组合总和这类带和的题也拿下了。

一个最容易翻车的细节:收集结果时一定要存 path[:] 这个副本,直接存 path 会被后面的回溯改掉。这个坑我见过太多人踩。

4

树形递归

空返回基准,拿左右拼自己

二叉树几乎所有题都长一个样:递归到空节点返回一个基准值,否则先拿到左、右子树的答案,再合并成当前节点的答案。换的只是「合并」这一步:

def dfs(node):
    if not node: return 基准值      # 空节点的答案
    L = dfs(node.left)
    R = dfs(node.right)
    return 合并(L, R, node)         # 拼成当前节点的答案

求深度,合并就是 1 + max(L, R);求节点数就是 1 + L + R。还有一类更妙的——返回值给父亲、全局变量记答案:算直径、最大路径和时,递归返回「能向上延伸的深度」,同时用一个全局量记下「横跨当前节点的路径」。把这两者分清,是树形题不出错的关键。

至于遍历,前 / 中 / 后序根本不用单独记——就是同一个递归骨架,把「处理当前节点」那行代码放在递归左、右之间的不同位置而已。

5

二分

不止数组,凡单调皆可二分

二分查找人人会写,但真正用好它的人不多。两个进阶认知能让你打开新世界:

① 大多数二分题,本质是「找第一个满足某条件的位置」(下界二分)。把模板写成左闭右开,满足就收右、不满足就进左,最后返回 l,边界问题一次理清。
不一定要在数组里二分——只要「答案越大越满足(或越不满足)」具有单调性,就能直接在答案的取值范围上二分。求最小速度、x 的平方根、能否在 D 天送达,都是这个套路。

# 下界二分:找第一个满足 cond 的位置
l, r = 0, n
while l < r:
    m = (l + r) // 2
    if cond(m): r = m       # 满足,答案在 m 或更左
    else: l = m + 1
return l

写二分老是差一、死循环?记死一套模板,别每次现想边界。我自己十年只用这一个左闭右开模板,再没出过错。

6

后进先出,专治嵌套与回退

遇到「嵌套结构」和「需要回头看最近一个」的问题,栈就是天选之子。它有两个高频面孔:

配对 / 嵌套:括号匹配、表达式求值、路径化简——进入一层就压栈、退出一层就弹栈合并。栈顶永远是「离我最近、还没处理完」的那个。
单调栈:求「下一个更大 / 更小的元素」的利器。维护一个单调的栈,新元素打破单调性时就弹栈结算。每日温度、接雨水都靠它把 O(n²) 降到 O(n)。

# 单调栈:为每个元素找右边第一个更大的
stack = []                   # 存还没找到答案的下标
for i, x in enumerate(arr):
    while stack and x > arr[stack[-1]]:
        j = stack.pop(); ans[j] = i - j   # 结算 j
    stack.append(i)

单调栈最反直觉的一点:栈里存的是下标不是值。因为你往往需要「位置差」来算答案。这点想通了,单调栈就不神秘了。

这六套心法,覆盖了八九成的面试题。把它们当成积木,难题往往就是几块积木的拼装——比如回文链表 = 找中点 + 反转 + 双指针比较。

⛳ 按学习路径开始练 →