我刚带完一个团队面试,候选人简历上写着“熟悉常见算法”,我问他“给你一个有序数组,找第一个大于等于 target 的位置”,他背出了模板,但当我追问“为什么 mid 计算要用 left + (right - left) / 2,为什么返回 left 而不是 mid”时,他沉默了。这不是个例。
很多程序员对算法思想的理解停留在“背题”层面。真正在工作中、在面试白板前能稳定发挥的,靠的不是题海战术,而是脑子里的算法思想网络:你知道这道题属于什么类型、应该往哪个方向试探、复杂度上限在哪里、边界条件怎么抠。本文我从一个一线开发者的视角,把程序员最该吃的几种核心算法思想——二分、双指针、分治、贪心、动态规划、回溯、搜索——逐个拆开讲清楚。每个思想都会给到适用场景、套路总结和可直接上手的代码示例,最后聊聊算法思想在 AI 时代对程序员的意义。
先说一句:这篇文章不是给竞赛选手看的,而是给那些“想稳扎稳打过面试、写出更好代码”的后端、前端、测试、运维同学看的。你可以把它当作一份索引,先建立整体框架,再对着题目逐个击破。
1. 算法思想的本质:不是知识,是“套路库”
1.1 什么是算法思想?
算法思想是解决问题时的高层策略,和具体语言、具体数据结构无关。比如“二分”是一种思想,它可以作用在数组、链表、答案区间、函数值域上;而“二分查找”只是二分思想在有序数组上的一个具体落地。
这个区分很重要。我接触过大量工程师,他能写出二分查找,但遇到“求 x 的平方根”这种表面上没有数组的题就懵了。原因就是他把算法当成了知识点去背,而没有把思想抽象出来。
1.2 两类常见的思维误区
第一类:把所有问题都暴力求解。比如两数之和,见过太多人上来就两层 for 循环,O(n²) 在 n = 10⁶ 时直接超时。暴力不是错,但暴力之后必须有一个“我能不能把复杂度降一个量级”的自我质问过程。
第二类:迷信高级数据结构,忽视基础思想。平衡树、线段树、并查集确实强大,但实际面试中 80% 的题目靠的还是二分、双指针、动态规划、BFS/DFS 这几板斧。先把低频高难的数据结构放下,把高频思想吃透,性价比更高。
1.3 一张图看懂高频算法思想的适用场景
| 思想 | 核心特征 | 典型场景 | 复杂度特征 |
|---|---|---|---|
| 二分 | 有明确单调性 | 有序数组查找、答案二分 | O(log n) |
| 双指针 | 左右收敛、窗口移动 | 有序数组配对、子串问题 | O(n) |
| 分治 | 拆分子问题再合并 | 归并排序、表达式求值 | O(n log n) |
| 贪心 | 每一步做局部最优 | 区间问题、任务调度 | 通常 O(n log n) |
| 动态规划 | 有重叠子问题和最优子结构 | 背包、路径、序列问题 | O(n²) 常见 |
| 回溯 | 搜索全部可行解 | 排列、组合、棋盘 | 指数级,需剪枝 |
| BFS/DFS | 遍历状态空间 | 图、树、迷宫、拓扑 | O(V+E) |
注意:上面的复杂度是“在常规实现下的复杂度”,具体问题会有变化,但架构感先建立起来,解题方向就不会跑太偏。
接下来,我按“从简单到复杂、从单点思维到全局思维”的顺序,逐个讲透每个思想的核心逻辑、关键代码和实战套路。
2. 二分与双指针:把遍历次数降下来的两个基本盘
2.1 二分查找:真正的精髓是边界分析
很多人以为二分查找就是“三个变量+while 循环”,但下笔写的时候,边界总是乱。核心问题就一句话:left和right到底指向什么?
我习惯用的是“闭区间”写法,逻辑最不容易出错:
def lower_bound(nums: list[int], target: int) -> int: left, right = 0, len(nums) # 左闭右开区间 [left, right) while left < right: mid = left + (right - left) // 2 if nums[mid] < target: left = mid + 1 # mid 排除,移动到右侧 else: right = mid # mid 可能是答案,保留 return left这个函数返回第一个大于等于target的下标。核心理解点是:
- 区间是左闭右开
[left, right),所以right初始是len(nums)而不是len(nums)-1; - 当
nums[mid] < target时,mid一定是左侧的一部分,所以left = mid + 1; - 当
nums[mid] >= target时,mid可能是答案,不能直接排除,所以right = mid; - 计算
mid用left + (right - left) // 2,避免left + right溢出,这是 C++/Java 里常见坑,Python 虽不怕,但习惯要养成。
二分思想之所以被称为“思想”而不是“公式”,在于它适用的场景远比“有序数组查找”广。比如:
- 在 1 到 n 范围内猜数,这就是二分答案;
- 寻找旋转排序数组中的最小值,这是对“断点性质”的二分手写;
- 在一组“非递减函数”上求满足条件的临界点,同样二分。
我后来面试别人,最看重的就是候选人能不能把二分从“数组”抽象到“任意单调序列”。一旦能做到这一步,很多看似棘手的题都会变得非常简单。
2.2 双指针与滑动窗口:把 O(n²) 降成 O(n)
双指针不是一个独立思想,而是一种“利用指针的相对位置关系来减少重复遍历”的策略。最常见的两种形态:
- 相向双指针:一个从左往右、一个从右往左,典型场景是“有序数组的两数之和”;
- 滑动窗口:两个指针同向移动,维护一个窗口,典型场景是“最长无重复子串”。
相向双指针示例:两数之和(有序数组)
def two_sum(nums: list[int], target: int) -> list[int]: left, right = 0, len(nums) - 1 while left < right: cur = nums[left] + nums[right] if cur == target: return [left, right] elif cur < target: left += 1 else: right -= 1 return []这里的关键是:因为数组有序,nums[left] + nums[right]如果小于target,说明left位置的值太小,和任何更左边的值相加都不可能等于target,所以left可以放心右移。如果大于target,说明right位置的值太大,right可以放心左移。每次都排除一个位置,整体 O(n)。
滑动窗口示例:最长无重复子串
def length_of_longest_substring(s: str) -> int: window = {} left = 0 max_len = 0 for right, ch in enumerate(s): if ch in window and window[ch] >= left: left = window[ch] + 1 window[ch] = right max_len = max(max_len, right - left + 1) return max_len滑动窗口的通用套路是:
- 右指针不断向右扩大窗口;
- 当窗口内条件不再满足时,移动左指针收缩窗口;
- 每次窗口变化时,尝试更新答案。
这套路看着简单,但真正容易翻车的是第二步的收缩策略——为什么这个题要收缩到不满足为止?因为窗口性质的不可逆。比如“无重复字符”这个条件,一旦窗口出现重复字符,你必须把重复字符上一次出现的位置及之前的字符全部移出窗口,否则窗口永远不合法。
我在实际复盘中发现,很多刷题者栽在滑动窗口上,不是因为不熟练,而是没有意识到:滑动窗口适合的题目,窗口状态必须满足“随着右指针右移,左指针只能右移,不能回头”。一旦需要左指针回退的题目(比如某些区间 RMQ 问题),滑动窗口就不合适了,得换单调栈或线段树。
2.3 二分与双指针的本质联系
很多人没意识到,二分和双指针是一对“孪生兄弟”:它们都在利用“单调性”来压缩搜索空间。二分的单调性是“值域/区间上的单调函数”,双指针的单调性是“指针移动方向上的单调关系”。
如果面试时你能主动说出这句话,会比只写出答案加分不少。能答出这种抽象层面的概括,说明你是真的理解了,而不是背题。
3. 分治与贪心:两个“看起来简单”的思想
3.1 分治:拆、解、合三步走
分治思想的精髓是三个字:拆、解、合。
- 拆:把大问题分解成若干个规模更小的子问题;
- 解:递归求解子问题;
- 合:把子问题的解合并成大问题的解。
最经典的分治就是归并排序。它的时间复杂度是稳定的 O(n log n),而且它有非常漂亮的工程性质:稳定、适合链表、可以作为外部排序的基础。
def merge_sort(arr: list[int]) -> list[int]: if len(arr) <= 1: return arr mid = len(arr) // 2 left = merge_sort(arr[:mid]) right = merge_sort(arr[mid:]) return merge(left, right) def merge(left: list[int], right: list[int]) -> list[int]: res = [] i = j = 0 while i < len(left) and j < len(right): if left[i] <= right[j]: res.append(left[i]) i += 1 else: res.append(right[j]) j += 1 res.extend(left[i:]) res.extend(right[j:]) return res归并排序的“合”这一步,顺带可以解决很多衍生题:求逆序对、求“每个数右边比它小的数个数”等。这是因为归并排序每次合并时,天然就在比较左右两个有序子数组的元素大小关系。换句话说,分治不只是排序手段,它还提供了一种“计算跨边界贡献”的框架。
这个洞察是我在工作中真正用上分治思想时才有的:不是每个题都叫“归并排序”,但很多问题——比如“区间统计”“分块计算”——本质上都是先把区间拆小,再合并统计。写代码的时候你甚至不需要显式递归,只是脑子里有“拆-解-合”的框架,自然就会想到用前缀和、树状数组之类的手段。
3.2 贪心:局部最优不一定全局最优
贪心是几个思想里最容易让人迷惑的:它没有固定模板,每一步都做眼前看起来最优的选择,最后居然能得到全局最优解,或者至少是一个不错的近似解。
我常用的判断标准是三个字:换不换。如果你能证明“任何最优解都可以通过交换方式变成我们的贪心解,且不劣于原最优解”,那贪心就是对的。最常见的反例是“找零钱”:如果用面额 [1, 3, 4] 找 6 块钱,贪心先拿 4 再拿 1 和 1,需要 3 枚;但最优解是 3 + 3,只需要 2 枚。这个例子说明贪心不是万能的。
经典的贪心题有哪些呢?区间调度(按结束时间排序,选最早结束的)、分发饼干(胃口从小到大,饼干从小到大)、跳跃游戏(每次维护最远可达距离)等等。
以“分发饼干”为例,思路特别典型:
def find_content_children(g: list[int], s: list[int]) -> int: g.sort() s.sort() i = j = 0 while i < len(g) and j < len(s): if s[j] >= g[i]: i += 1 j += 1 return i为什么贪心在这里是对的?因为每个孩子只需要一块饼干,且胃口小的孩子比胃口大的孩子更容易满足。我们把小饼干优先塞给胃口小的孩子,能给后面的孩子留出更大的饼干。这就是“局部最优能导向全局最优”的典型:它需要“单调性+无后效性”两个条件。
实战心得:判断一道题能不能用贪心,不要先想怎么证,先问自己一个问题——“如果我知道当前这一步怎么选,后面所有结果是否都可以基于这个选择递推,而不用回退?”如果可以,大概率是贪心;如果不行,可能是动态规划或回溯。
4. 动态规划:程序员最需要攻克的思维关卡
4.1 动态规划的五个步骤
动态规划是面试里区分度最大的一个思想,也是我花最多时间跟同事讲的思想。它本质上是一种“记忆化暴力”:把大问题拆成子问题,用一张表记录子问题的答案,避免重复计算。
我总结的动态规划五步法:
- 确定状态:思考“这个问题的答案,取决于哪些变量”;
- 定义 dp 数组:明确
dp[i]或dp[i][j]代表什么含义; - 找状态转移方程:思考怎么从较小规模的子问题递推到大问题;
- 确定初始化和边界:想想
dp[0]、dp[1]什么的已知答案; - 确定遍历顺序:从上到下、从左到右还是反向。
以最简单的“斐波那契数列”为例:
def fib(n: int) -> int: if n <= 1: return n dp = [0] * (n + 1) dp[1] = 1 for i in range(2, n + 1): dp[i] = dp[i-1] + dp[i-2] return dp[n]这个题太简单,它真正的价值在于让你看到动态规划和递归的本质区别:递归反复计算fib(3)无数次,而 dp 表只算一次。
4.2 从一维到二维:背包问题代表动态规划的经典形态
再往上一层,背包问题是理解二维 dp 的最佳入门。如果你能做到“0-1 背包”完全靠自己写出来,动态规划就算入门了。
def knapsack(weights: list[int], values: list[int], capacity: int) -> int: n = len(weights) dp = [[0] * (capacity + 1) for _ in range(n + 1)] for i in range(1, n + 1): for w in range(1, capacity + 1): if weights[i-1] > w: dp[i][w] = dp[i-1][w] else: dp[i][w] = max(dp[i-1][w], dp[i-1][w - weights[i-1]] + values[i-1]) return dp[n][capacity]这里dp[i][w]表示“前 i 件物品,容量为 w 的背包能装下的最大价值”。状态转移方程的核心是:装或不装第 i 件物品。
二维 dp 的代码看起来不复杂,真正难的是空间优化:把二维数组压成一维。压成一维后,内层循环必须从capacity倒序遍历,否则会出现“同一件物品被多次装入”的问题。这个细节我在面试中问过很多人,十个人里能答清楚的不到一半。为什么倒序遍历?因为正序遍历会让dp[w - weight]已经被当前 i 更新过,相当于第 i 件物品被重复使用了;倒序则保证dp[w - weight]还是上一轮的旧值,符合 0-1 背包“每件物品只能选一次”的约束。
动态规划还有一个容易踩的坑:状态定义不能有后效性。什么叫有后效性?就是“当前状态会影响未来状态,但你在定义 dp 时没有把这个影响记录下来”。最典型的是股票买卖问题:如果你只记录“当前持有现金数”,但不知道手里是否已经持有股票,就无法做出正确的买卖决策。所以需要定义两个状态:hold[i](持有股票)和cash[i](不持有股票)。这一点是最多人卡住的。
4.3 动态规划的“看得见与看不见”
很多人问我怎么快速提升动态规划能力,我的建议是:不要一开始就做难题,先把以下三类题吃透:
- 线性 DP:爬楼梯、打家劫舍、最长上升子序列;
- 区间 DP:回文子串、合并石子(注意先枚举区间长度);
- 背包类及变种:0-1 背包、完全背包(内层正序遍历)、多重背包。
把这三类做熟,你对“如何定义状态”会有很强的直觉。之后遇到新题目,第一步就会条件反射地去想“影响答案的变量是什么”——这是动态规划最核心的能力。到了这一步,你已经不是在做题,而是在搭状态机。
5. 回溯与搜索:在状态空间里“暴力美学”
5.1 回溯算法:决策树的深度优先遍历
回溯算法和暴力枚举的区别在于,它在遍历决策树的同时,能“撤销”选择,回到上一层继续尝试其他分支。它适合所有“找所有解”的问题:全排列、组合、子集、数独、八皇后。
回溯的模板非常固定,我写代码时通常三步走:
path记录当前路径;- 递归:尝试所有候选元素;
- 回溯:撤销选择,恢复现场。
以“全排列”为例:
def permute(nums: list[int]) -> list[list[int]]: res = [] used = [False] * len(nums) def backtrack(path: list[int]): if len(path) == len(nums): res.append(path.copy()) return for i, num in enumerate(nums): if used[i]: continue used[i] = True path.append(num) backtrack(path) path.pop() used[i] = False backtrack([]) return res回溯的复杂度很高,通常是指数级,但很多题目要求“返回所有结果”,所以算法复杂度没有优化空间,能做的就是剪枝。剪枝不是在模板上加奇怪的判断,而是在画决策树的时候提前想清楚“哪些分支一定不成立”。
我记得刚接触回溯时有段时间非常痛苦,因为经常写出“时间超时”。后来我养成了一个习惯:拿到回溯题先画决策树,第二步再写代码。先画出树,你自然能看见哪些子树不需要递归,剪枝条件也就呼之欲出了。
5.2 BFS 与 DFS:两种搜索顺序的区别
BFS(广度优先搜索)和 DFS(深度优先搜索)是遍历图和树的两大基本方法。它们不是单独的思想,而是空间换时间和时间换空间的经典代表。
DFS 用栈(递归天然就是栈),代码简洁,适合找路径、判断连通性;BFS 用队列,逐层扩展,天然适合找“最短步数”的问题。
以“二叉树层序遍历”为例:
from collections import deque def level_order(root): if not root: return [] res = [] q = deque([root]) while q: level_size = len(q) level = [] for _ in range(level_size): node = q.popleft() level.append(node.val) if node.left: q.append(node.left) if node.right: q.append(node.right) res.append(level) return res这里有个细节经常被忽略:level_size = len(q)必须在处理每一层之前取,因为你后面popleft会让队列长度动态变化。如果写成了for _ in range(len(q)),Python 的range参数是进入循环时一次性算好的,所以其实也可以;但如果你顺手改成了q的实时长度相关的条件,就会出 bug。稳妥起见,先取快照。
BFS 还有一个变种叫“双向 BFS”,用在起点和终点都明确的最短路径问题上,可以把搜索空间从 2^k 降到大概 2^(k/2) 的量级。实际手写难度略大,但面试时如果能提到这个优化思路,会很加分。
DFS 则要注意递归深度。Python 默认递归深度只有 1000 左右,如果树的深度可能很大,要么手动改写非递归栈,要么提前设置sys.setrecursionlimit()。这个坑我在实际写深度优先遍历时踩过一次,很痛。
6. 算法思想在工作中的落地:AI 时代程序员怎么用
6.1 面试的本质是“考察思想,而不是考察答案”
现在很多面试题都来自力扣原题,但这不代表你把题背下来就能过。面试官往往会在你写出答案后,追加类似这些问题:
- “你这个解法的时间复杂度是多少?为什么?”
- “如果数组变成海量数据无法一次性载入内存,怎么办?”
- “如果输入有重复元素,你的解法还能成立吗?”
这些问题背后考察的就是对算法思想的变通能力。二分能不能改成三分?双指针能不能从两端改成快慢?动态规划能不能滚动数组优化空间?这些追问,所有答案都建立在你对思想的深度理解上。
6.2 日常开发里算法思想藏在哪儿
很多人觉得“工作不用算法”,这是一种错觉。算法思想藏在你看不见的地方:
- 后端做分页查询时,直接在 SQL 里加
LIMIT/OFFSET是 O(n) 的行为,在数据量达到千万级时就会抖动,明白这个道理的人会考虑用游标或范围二分来优化; - 前端做长列表虚拟滚动时,本质上是二分查找“当前滚动位置附近的可见节点”;
- 日志系统的对账任务里,两个有序事件流做合并,本质上是双指针归并;
- 推荐系统的 CTR 预估排序,从候选集中找到 TopK,本质上是堆或快选的应用。
我特别想说的是:很多程序员用 Map、用字典仅仅是为了“去重”,但其实字典的意义远超于此——它是空间换时间的极致体现,这个思路和“动态规划用表记录子问题”是同一件事。
6.3 AI 时代,算法思想还要学吗
现在 AI 编程助手越来越强,你写一句“用二分查找实现 lower_bound”,它直接给你整个函数。那还要不要学算法思想?我的观点是:要,而且比以前更不能丢。
原因是:AI 能写代码,但它不知道你为什么写这样的代码。当你要 review AI 生成的代码时,你就需要判断它用的这个算法到底能不能满足当前数据规模的性能要求。如果你连“这个函数的时间复杂度是多少”都看不出来,你怎么信得过 AI 交给你的东西?
而且,AI 时代的岗位需求正在从“能写代码的人”转向“能定义问题和约束的人”。定义问题的能力,恰恰就是算法思想的核心——你把现实问题抽象成哪种数据结构问题?用哪种算法策略去逼近最优解?这些能力,AI 短时间内还替不了你。
现在市面上也看到很多黑马程序员这类机构在推“AI 大模型应用开发”课,里面把大模型 Prompt、Agent 设计讲得很细,但底层逻辑还是没变:任何 Agent 都是在一个状态空间里做决策,是搜索问题,也是策略问题。算法思想骨子里的东西,放在 AI 时代一样适用。
7. 常见问题与避坑纠错实录
7.1 为什么我题刷了很多,面试还是不会?
大概率是陷入了“记忆型刷题”的误区。刷完一道题,不看标签就做下一道,你的大脑只是在匹配“题号-解法”,而不是在建立“类型-思想”的映射。
我推荐的做法是:刷题时给自己写一个“思想标签”,比如“这道题用了二分,它和之前某道有序数组查找的题目有什么异同?”当你积累了二十道以上带标签的题,你会惊讶地发现,很多看起来不相干的题目,底层思想是相通的。到面试时,你面对新题的第一步不是回忆,而是归类——“这题有点像滑动窗口,但多了一个约束;有点像贪心,但带有后效性”——把这个归类的过程练熟,面试就稳了。
7.2 背模板有用吗?
短期的用,长期的有害。短期快速过面试,模板可以帮你保底;但一旦面试官追问细节,或者题目稍作变形,模板就会失效。说到底,模板只是思想的外壳,你需要的不是外壳,是内核。
拿二分来说,网上流传着十几种模板,什么“左闭右闭”“左闭右开”“找左边界”“找右边界”。我不建议背那么多,只建议盯死一个版本(比如本文的左闭右开版本),把它反复练到成为肌肉记忆。到面试时,你只需在心里问自己一句话:我返回的到底是 left 还是 left-1?这个问题的答案取决于你对“不变式”的理解,而不取决于背了多少模板。
7.3 常见问题速查表
| 现象 | 原因 | 解决方案 |
|---|---|---|
| 二分死循环或返回错误下标 | 边界区间定义不清楚 | 统一用左闭右开,明确 mid 排除还是保留 |
| 滑动窗口收缩后窗口仍不满足条件 | 收缩逻辑没想清“什么条件触发收缩” | 先画窗口示意图,确定左指针移动的停止条件 |
| 动态规划递推结果不对 | 状态定义有后效性或遗漏变量 | 回到五步法,重新列举“影响答案的变量” |
| 回溯结果重复 | 忘记使用 visited 数组或没有跳过同层重复元素 | 画决策树定位重复来源,加 used 或排序去重剪枝 |
| 递归深度超限 | Python 默认限制约 1000 层 | 设置更高递归限制或改迭代式 DFS |
| BFS 结果不是最短路径 | 没有按层计数,所有节点混在一个队列里处理 | 每轮先取 len(q),然后只处理当前层的节点 |
7.4 想补算法基础,看什么资料
市面上的资料非常多,但如果只看三样,我的建议是:
- 一本系统讲算法思想的教材(比如《算法图解》适合入门,语言轻松;《算法导论》适合系统体系,但别从头啃);
- 一个能按标签刷题的在线平台,刷题时按“类型”而不是按“难度”来刷;
- 一套“讲思想”的视频课程,安利风格偏向以“某一思想串多种题型”的方式讲的课程,比单纯讲代码有用的多。
我自己在带实习生的时候,还会推荐他们准备一个“错题笔记”,不写题解,只写“当初为什么没想到这一点”。这个做法听起来很笨,但坚持下来收益巨大。因为刷题能力的提升,本质就是“把踩过的坑变成模式识别的自动化过程”,写笔记正是把这个过程显性化。
最后再分享一个小技巧:每次拿到算法题,不要立刻写代码,先在白纸上把“输入规模”写出来。n = 10⁵、n = 10⁶、n = 10⁹ 对应的可接受复杂度是完全不同的层次。能接受 O(n²) 还是只能接受 O(n log n) 或更低的 O(log n)?先想清楚这个问题,再用算法思想做匹配,你的解题路径会清晰很多。这个习惯,我从第一份工作用到现在,实战下来的确很稳。