如果你刷过一段时间算法题,大概率遇到过这些争论:有人说 KMP 本质是动态规划,有人反驳说它是自动机;有人说背熟堆排序模板就能应付面试,有人写快排却连边界条件都要现场推半小时。算法板子这四个字,在这些讨论里要么被神化,要么被当成死记硬背的反面教材。作为一名靠算法竞赛入行、又做了多年面试官的人,我的态度很朴素:板子确实要背,但更要搞清楚该背哪些、背到什么程度、以及用什么方式去背才不会变成“搬砖式默写”。
这篇内容就是围绕“必背算法板子”来写的。我会结合近期的算法热词里大家最关心的排序、二分、KMP、Dijkstra、并查集、DP、贪心等方向,把真正值得背的模板梳理出来,同时把哪些只需要理解流程、哪些根本不该硬背也一并讲清楚。文章不会照着教科书念定义,更多是给你一份可以直接落地的学习清单和避坑指南。
1. 算法板子的边界:不是所有算法都值得“背”
很多人一开始就把方向搞反了。他们把所有算法统统当成板子去背,结果越背越乱,最后连“排序算法为什么这样写”都答不上来。实际上,算法知识至少可以分成三层,每层的投入策略完全不同。
1.1 第一层:模板化算法,值得反复默写
这一层的典型特征是:核心流程非常固定,输入输出格式比较统一,且边界条件容易出坑。比如归并排序、二分查找、快速幂、KMP、并查集、Dijkstra、二叉树遍历、拓扑排序等。这些算法在笔试、面试和竞赛题里都会被高频复用,几乎不存在“换一个业务场景就完全不用”的情况。
对这些算法,我会要求自己做到闭着眼睛能写出完整代码,并且能解释每一行为什么这么写。因为你一旦在考场上现推边界条件,时间根本不够用;更重要的是,这些模板是所有复杂问题的底座,底座不牢,后面做图论、树形 DP、字符串处理都会到处漏风。
1.2 第二层:算法思想,要能讲原理和流程
贪心、动态规划、回溯、分治这类东西,往往没有唯一的“标准模板”,因为同一思想在不同题目里长着完全不同的面貌。你背一个“区间调度贪心的代码”,换一道“求最多不相交区间”的变体题,代码几乎无法复用;你能复用的是“按结束时间排序”的思维方式。
所以对这种类型,我会把重点放在证明和方法上,而不是背代码。比如贪心算法,最核心的是证明贪心选择安全性:当前这一步取最优解,为什么不会影响后续步骤?很多人说贪心靠猜,其实猜完必须用归纳法或者反证法证明,证明能过,代码几乎都是顺理成章的。
1.3 第三层:工程化算法,要背的是设计原则而不是固定代码
热词里出现了 PID、模拟退火、粒子群、ISP Bayer2RGB、AES、国密 SM 系列等,这些在日常工作中经常被提到,但它们绝大多数并不适合当成“板子”来死记。PID 是控制框架,参数 Kp、Ki、Kd 都需要根据被控对象在线整定;模拟退火和粒子群是元启发式算法,写成代码时和具体问题耦合极深,没有统一范式;AES、SM4 这类密码算法更是有标准库和官方文档,生产环境里自己手写反而容易引入安全漏洞。
对于这层内容,正确的学习方式是理解输入输出、弄清适用条件和适用边界、掌握调用方式或调优思路。
| 算法示例 | 正确姿态 | 原因 |
|---|---|---|
| 归并排序、二分查找 | 背到肌肉记忆 | 流程固定,边界容易踩坑 |
| 贪心、动态规划 | 背套路,练推导 | 核心在模型识别和状态设计 |
| PID、模拟退火、深度学习 | 背原理和适用条件 | 高度依赖场景,没有通用代码 |
| AES、SM4 | 直接用成熟库 | 自己实现容易出错且安全风险高 |
1.4 只会默写,不理解原理,才是背板子的最大风险
我面试时遇到过一个候选人,快排代码写得非常流利,但问他“快排最坏情况下复杂度是多少、什么时候发生”时,他愣了一下,然后说没考虑过。这就是把板子背成了“死模板”。
为了不陷入这种状态,每一个需要背的模板我都会强迫自己回答三个问题:它解决什么问题?边界条件在哪里?为什么复杂度是这个量级?这三个问题的答案我用注释写在模板代码顶部,背板子的同时也就把原理捋了一遍。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 第一梯队模板:排序、二分与快速幂的代码骨架
如果说算法板子有一座金字塔,那最底层一定是这几个:排序、二分、快速幂。它们不仅在竞赛里出现频率极高,也是面试手写代码最常抽查的基础能力。下面逐个拆解我最常用的骨架和易错点。
2.1 归并排序:背的是分治框架,顺带解决逆序对
归并排序的代码模板比较长,但结构非常固定,核心就三步:拆半、递归、合并。我用 C++ 写一个最常用的版本,函数里附带统计逆序对数的功能,这是竞赛题里特别常见的用法。
cpp复制void mergeSort(vector<int>& a, int l, int r, long long& inv) {
if (l >= r) return;
int mid = ((l + r) >> 1);
mergeSort(a, l, mid, inv);
mergeSort(a, mid + 1, r, inv);
vector<int> tmp(r - l + 1);
int i = l, j = mid + 1, k = 0;
while (i <= mid && j <= r) {
if (a[i] <= a[j]) {
tmp[k++] = a[i++];
} else {
tmp[k++] = a[j++];
inv += mid - i + 1; // 左半段剩余的都比 a[j] 大
}
}
while (i <= mid) tmp[k++] = a[i++];
while (j <= r) tmp[k++] = a[j++];
for (int t = 0; t < k; ++t) a[l + t] = tmp[t];
}
这里有个细节值得多说一句:统计逆序对时必须在右半段元素准备放入临时数组的那一刻,把 mid - i + 1 累加进答案。原因在于左半段和右半段此时都已经有序,如果左半段当前的 a[i] 大于右半段的 a[j],那么左半段从 i 到 mid 的每一个数都大于这个 a[j],这就是逆序对的数量。
逆序对计数的返回类型建议直接写成 long long。数据量到十万级别时,逆序对数量可能轻松突破 int 上限,我用 int 被坑过不止一次。
2.2 快排:边界处理比算法思想更容易翻车
快速排序的思想几乎所有人都能说清,但能把边界收敛写成零 bug 的人并不多。我比较推荐一种“双指针挖坑”写法,它比教科书的单边扫描更容易记忆。
cpp复制void quickSort(vector<int>& a, int l, int r) {
while (l < r) {
int i = l, j = r, pivot = a[(l + r) >> 1];
while (i <= j) {
while (a[i] < pivot) ++i;
while (a[j] > pivot) --j;
if (i <= j) {
swap(a[i], a[j]);
++i;
--j;
}
}
// 递归处理短的一侧,另一侧用循环处理,防止爆栈
if (j - l < r - i) {
quickSort(a, l, j);
l = i;
} else {
quickSort(a, i, r);
r = j;
}
}
}
为什么选中间位置作为 pivot?因为在完全有序的极端输入下,如果总是选第一个元素,快排会退化成 O(n^2)。取中间点虽然不能保证每次都完美,但在面试和竞赛的常见数据分布下已经足够稳。
另一个容易被忽略的是递归深度问题。上面这段代码通过在循环里继续处理较长区间、只对较小区间递归,把最坏情况下栈深度限制在了 O(log n),这个处理在八百万数据量的排序场景里会救你一命。
2.3 二分查找:必须把一套写法练成“条件反射”
二分查找是所有算法板子里性价比最高的一个,代码短、使用频率高、边界却极其考验人。热词里反复出现“二分查找算法”“二分算法”,说明题库和面试官都在重点考察这个点。
我推荐的写法是基于左闭右开区间 [l, r) 的,它最大的优点是可以很自然地扩展出 lower_bound 和 upper_bound:
cpp复制// 找第一个 >= target 的位置,也就是 C++ 里 lower_bound 的逻辑
int lowerBound(const vector<int>& a, int target) {
int l = 0, r = (int)a.size();
while (l < r) {
int mid = l + ((r - l) >> 1); // 防溢出
if (a[mid] < target) l = mid + 1;
else r = mid;
}
return l;
}
使用左闭右开写法时,循环终止条件必须是 l < r,而不是 l <= r。因为一旦区间长度为 0,说明已经查无可查。很多人死记硬背“mid = l + r >> 1”,却忘了写成 l + (r - l) / 2 能防止两个大整数相加溢出,后者才是更稳的姿势。
二分的难点不在记忆代码,而在于判断“当前 mid 值到底该归入哪一段”。我的经验是:先把问题转化成“求第一个满足条件的下标”,再套用 lowerBound 的代码骨架,能减少大量思维混乱。遇到实数范围内的二分,比如热词里提到的“求方程根”或“最小化最大值”类问题,就用固定迭代次数,比如 100 次,比判断浮点误差更省心,也更稳定。
2.4 快速幂与快速乘:数论题的地基
快速幂的模板是所有数论题的基础,从模运算题到矩阵快速幂,到最后都会落到这个骨架。
cpp复制typedef long long ll;
ll powMod(ll a, ll b, ll mod) {
ll res = 1 % mod;
while (b > 0) {
if (b & 1) res = res * a % mod;
a = a * a % mod;
b >>= 1;
}
return res;
}
需要注意两个坑。第一,res 的初始值写成 1 % mod 是为了处理 mod = 1 时的边界情况,很多模板都忽略了这点。第二,如果 mod 接近 1e18,res * a 这一步可能溢出,这时候得配合“快速乘”:
cpp复制ll mulMod(ll a, ll b, ll mod) {
ll res = 0;
while (b > 0) {
if (b & 1) res = (res + a) % mod;
a = (a + a) % mod;
b >>= 1;
}
return res;
}
快速乘的写法和快速幂几乎一模一样,理解一个,另一个就会了。我通常在模板代码的开头注释写下复杂度和适用场景:快速幂 O(log b),适用所有“底数较大但乘法不溢出”的模运算场景;一旦乘法溢出,就把代码升级为快速乘版本。
3. 寻路、最短路径与图论板子:别让“负权值”毁掉你的模板
图论是刷题和面试里最让人头大的内容之一,热词里关于“迪杰斯特拉算法负权值”的讨论度很高,A* 算法、D* 算法、路径规划也一直有人问。这个章节我挑几个真正值得背的图论模板,重点讲“为什么这样设计”。
3.1 Dijkstra 模板:为什么它不能处理负权边?
Dijkstra 算法有很多写法,但我最推荐使用优先队列优化的版本。它的思路是用一个小顶堆不断弹出当前距离最小的点,访问过的节点就不再重复处理,所以在线性数据结构上实现非常简单。
cpp复制const ll INF = 1e18;
vector<ll> dijkstra(const vector<vector<pair<int,int>>>& g, int s) {
int n = (int)g.size();
vector<ll> dist(n, INF);
dist[s] = 0;
priority_queue<pair<ll,int>, vector<pair<ll,int>>, greater<pair<ll,int>>> pq;
pq.push({0, s});
while (!pq.empty()) {
auto [d, u] = pq.top();
pq.pop();
if (d != dist[u]) continue; // 跳过过期的堆中元素
for (auto [v, w] : g[u]) {
if (dist[v] > dist[u] + w) {
dist[v] = dist[u] + w;
pq.push({dist[v], v});
}
}
}
return dist;
}
关于迪杰斯特拉算法为什么不能处理负权边,核心原因在于它基于贪心思想:从堆顶弹出某个点 u 时,它默认已经找到了源点到 u 的最短路径。但一旦存在负权边,后面可能出现一条经由负权边到达 u 的更短路,使得之前对 u 的距离假设被推翻。也就是说,它少了一次“回头验证”的步骤。
如果面试中被问到“有负权边怎么办”,答案分两类:如果只是存在负权但不含负环,可以用 Bellman-Ford 或 SPFA;如果存在负环,那么最短路问题本身的定义就失效了,因为绕负环可以无限降低距离。此时要求的是判断负环是否存在,而不是求最短距离。
3.2 SPFA 不是银弹,但小规模图里很实用
SPFA 是 Bellman-Ford 的队列优化版本,代码更短,很多人在模板里会背一份。
cpp复制bool spfa(vector<vector<pair<int,int>>>& g, int s, vector<ll>& dist) {
int n = g.size();
dist.assign(n, INF);
vector<bool> inQueue(n, false);
vector<int> relaxCount(n, 0);
queue<int> q;
dist[s] = 0;
q.push(s);
inQueue[s] = true;
while (!q.empty()) {
int u = q.front();
q.pop();
inQueue[u] = false;
for (auto [v, w] : g[u]) {
if (dist[v] > dist[u] + w) {
dist[v] = dist[u] + w;
if (!inQueue[v]) {
q.push(v);
inQueue[v] = true;
if (++relaxCount[v] >= n) return false; // 说明存在负环
}
}
}
}
return true;
}
说实话,不到万不得已我不会在比赛里用 SPFA,因为它的最坏时间复杂度可以到 O(VE),在刻意构造的图里会被卡到超时。但在面试场景中,提到它可以展示你对“负权图最短路”这个知识点的掌握程度。关键是能说清楚它的本质就是 Bellman-Ford 用队列优化“只更新可能发生变化的点”。
3.3 A* 算法与 Dijkstra 的关系:启发函数决定一切
A* 算法在热词里也很热,尤其是结合 YOLO、路径规划、游戏开发等场景。很多人问 A* 和 Dijkstra 是不是一回事,其实可以这么理解:Dijkstra 是 A* 在启发函数恒等于 0 时的特例。
A* 的估价函数是 f(n) = g(n) + h(n),其中 g(n) 是从起点到当前点的实际代价,h(n) 是当前点到终点的启发估计。当 h 始终为 0,A* 就完全退化成 Dijkstra;当 h 是可采纳的(不会高估实际代价),A* 还能保证找到最短路径。这也是为什么它常用于地图导航类题目的优化。
A* 的板子长度并不长,难点在于选择适合题目的启发式函数。如果网格允许上下左右四方向移动,一般用曼哈顿距离;如果允许八方向,可以用切比雪夫距离或者欧氏距离。工程中 A* 还常常配合二叉堆维护 open list,此时可以复用 Dijkstra 的优先队列框架。
3.4 并查集:图论中无处不在的“附属小工具”
并查集不直接解决最短路问题,但它是 Kruskal 最小生成树、判断图连通性等场景的地基。代码极短,背一个就够用。
cpp复制struct DSU {
vector<int> parent, sz;
DSU(int n) : parent(n + 1), sz(n + 1, 1) {
iota(parent.begin(), parent.end(), 0);
}
int find(int x) {
while (x != parent[x]) {
parent[x] = parent[parent[x]]; // 路径压缩
x = parent[x];
}
return x;
// 递归写法也可以,下面这个是常被背的版本
// return parent[x] == x ? x : parent[x] = find(parent[x]);
}
bool unite(int a, int b) {
int ra = find(a), rb = find(b);
if (ra == rb) return false;
if (sz[ra] < sz[rb]) swap(ra, rb);
parent[rb] = ra;
sz[ra] += sz[rb];
return true;
}
};
路径压缩之后,并查集单次操作的均摊复杂度接近 O(1),这已经能应付绝大多数题目。按秩合并就是那个维护 sz 数组的操作,它不是必须的,但加上以后能消除构造数据导致树链过深的隐患,属于典型的“锦上添花”型优化。
4. 字符串匹配与基础数据结构:把 KMP 背后的逻辑彻底讲明白
字符串算法在面试中不像排序和 DP 那样高频,但只要一出现就是硬骨头。热词里“KMP算法属于动态规划吗”这个问题问得很有水平,我在这里展开说。
4.1 KMP 更像状态机,而不是动态规划
先说结论:KMP 通常不被归类为动态规划算法。因为动态规划的本质是通过子问题的最优解推得原问题的最优解,且不同子问题之间存在递归依赖关系;而 KMP 的核心是失败回退,通过预先计算的 lps 数组(最长相等前后缀)来决定匹配失败时模式串指针该跳到哪里。它的运行过程更像一个确定的有限状态自动机,每个状态代表“当前已匹配了模式串的多少个字符”,一旦当前字符不匹配,就沿失配边回退到下一个可能的前缀长度。
但为什么有人会从 KMP 里看到 DP 的影子?因为 lps 数组的计算过程确实有“利用前面位置的状态从左往右递推”的特点。比如构建 lps[i] 时,我们会利用 lps[i-1] 的值推导,这是一条明显的递推链,和 DP 的“记忆化”在形式上相似。可严格来说,KMP 构造的不是“到位置 i 为止的最优解”,而是“模式串在位置 i 的最长公共前后缀”,没有在每一步做最优决策,因此把它理解为自动机更准确。
KMP 模板建议背这一份:
cpp复制vector<int> buildLPS(const string& p) {
int m = (int)p.size();
vector<int> lps(m, 0);
for (int i = 1, j = 0; i < m; ++i) {
while (j > 0 && p[i] != p[j]) {
j = lps[j - 1];
}
if (p[i] == p[j]) ++j;
lps[i] = j;
}
return lps;
}
void kmpSearch(const string& s, const string& p) {
string text = s, pat = p;
int n = text.size(), m = pat.size();
vector<int> lps = buildLPS(pat);
for (int i = 0, j = 0; i < n; ++i) {
while (j > 0 && text[i] != pat[j]) j = lps[j - 1];
if (text[i] == pat[j]) {
++j;
if (j == m) {
cout << "找到匹配,起始下标 = " << i - m + 1 << endl;
j = lps[j - 1]; // 允许重叠匹配
}
}
}
}
这段代码里最容易写错的是 while (j > 0 && p[i] != p[j]) 中的回退条件。这里的 j 不是从头开始找,而是跳回最长相等前后缀,少了这一步,KMP 就和朴素匹配没有区别了。
4.2 Trie 树:前缀匹配的标准答案
Trie 的名字听着唬人,本质就是把一组字符串按公共前缀合并成一棵多叉树。热词里有大量词频统计、搜索建议、前缀查询的需求,这些方向基本都能用 Trie 解决。手写板子时我习惯用二维数组而不是指针,防止内存泄漏、也方便调试。
cpp复制const int MAXN = 1e6 + 5;
const int CHAR_NUM = 26;
int trie[MAXN][CHAR_NUM], cnt[MAXN], tot = 0;
void insert(const string& s) {
int u = 0;
for (char ch : s) {
int c = ch - 'a';
if (!trie[u][c]) trie[u][c] = ++tot;
u = trie[u][c];
}
cnt[u]++;
}
int queryPrefixCount(const string& s) {
int u = 0;
for (char ch : s) {
int c = ch - 'a';
if (!trie[u][c]) return 0;
u = trie[u][c];
}
return cnt[u]; // 如果查前缀数量就返回节点经过次数,或者另行统计子树权值和
}
数组版 Trie 的好处是一眼能看懂扩展逻辑,也方便后续加入额外的统计数组。竞赛题里经常用它配合 DFS 解决词汇联想、异或最大值等经典问题。
4.3 树状数组:比线段树更该优先掌握的轻量模板
很多新手一看到“区间查询 + 单点修改”就直接背线段树,但其实在大量题目里,树状数组不仅代码短、常数小,调试起来也简单得多。它的板子核心是理解 lowbit = x & (-x) 这个操作。
cpp复制int n;
int bit[MAXN];
void add(int idx, int delta) {
while (idx <= n) {
bit[idx] += delta;
idx += idx & (-idx);
}
}
int sumPreffix(int idx) {
int res = 0;
while (idx > 0) {
res += bit[idx];
idx -= idx & (-idx);
}
return res;
}
如果遇到区间翻转、区间异或的问题,树状数组也可以开两个实例来分别维护,不需要立刻就上懒标记线段树。写这篇文章之前我刚用树状数组做了一道逆序对计数题,思路就是把元素按值域插入,每插一个值就查询之前有多少大于它的数,非常顺滑。
5. 动态规划与贪心:要背的是状态设计套路,不是代码
动态规划和贪心是算法热词里最“危险”的两个词,因为它们几乎没有标准答案。但刷题和面试中确实存在一些反复出现的套路,把这些套路背下来,比硬背题解要高效得多。
5.1 贪心:先证明,再写代码
我在面试里问贪心题时,最怕听到候选人直接说“这题用贪心,先把数组排序”。排序是表象,问题是“为什么排序局部最优可以推导全局最优”。热词里搜“贪心算法”的人很多,但真正理解证明思路的人很少。
贪心算法两大核心要素是贪心选择性质和最优子结构。贪心选择性质指每一步做出局部最优选择后不会影响其他步骤选择的最优性;最优子结构指原问题的最优解包含了子问题的最优解。像区间调度问题,按结束时间排序可以保证选择最早结束的区间后,剩余空间对后续决策没有任何副作用,所以贪心成立。类似地,哈夫曼编码、最小生成树里的 Kruskal、找零钱类问题等,都能用这套分析框架去验证。
代码层面并没有完全通用的贪心模板,因为有的题靠排序,有的题靠优先队列。热词里搜索“购物车算法”,其实很多优惠凑单问题底层就是在做贪心或动态规划:先把商品或优惠券按某种规则排序,再结合单调性决定是否合并。应对这类题,我会背的板子是“排序 + 堆”的思路组合:当数据需要实时取当前最大值或最小值时,优先想 priority_queue。
5.2 背包 DP:滚动数组的遍历方向是命门
0/1 背包是所有 DP 题里的“新人第一课”,也是面试现场最容易考到的模板化 DP。它的状态转移方程写出来很简单:dp[j] = max(dp[j], dp[j - w[i]] + v[i])。关键是针对一维滚动数组做优化时,内层循环必须从大到小遍历背包容量。
为什么必须倒序?因为 dp[j - w[i]] 必须来自“还没放当前物品 i”时的状态。如果正序遍历,容量较小的 dp 可能已经是放过了当前物品的新值,那就等于允许同一个物品被重复选入,这反而成了完全背包的写法。
完全背包则反过来,内层循环正序遍历,因为允许重复选同一物品,就需要用更新后的容量状态继续叠加。我把这两个模板放到一起记忆,效果比单个记要好得多。
cpp复制// 0/1 背包
for (int i = 0; i < n; ++i)
for (int j = W; j >= w[i]; --j)
dp[j] = max(dp[j], dp[j - w[i]] + v[i]);
// 完全背包
for (int i = 0; i < n; ++i)
for (int j = w[i]; j <= W; ++j)
dp[j] = max(dp[j], dp[j - w[i]] + v[i]);
还有一类高频的“最长公共子序列(LCS)”,它的转移其实是双状态合并,可以作为区间型 DP 的入门模板。而经典的最长上升子序列(LIS)又可以用贪心 + 二分的 O(n log n) 解法,我发现把 LIS 和 lowerBound 模板联系起来记忆,效率特别高。
5.3 状态机 DP 和其他常见套路:按关键词匹配
如果说背包要背遍历顺序,那状态机 DP 就更依赖“状态拆分”的思维。最容易理解的例子是打家劫舍:不能偷连续两家,于是把状态拆成“当前位置偷或不偷”。很多热词里的行业算法,只要涉及序列决策问题,都会转成这种模式。
另一种高频是区间 DP,套路固定:第一层循环枚举区间长度,第二层循环枚举左端点,第三层根据题目枚举分割点。典型代码骨架如下:
cpp复制for (int len = 2; len <= n; ++len) {
for (int l = 1; l + len - 1 <= n; ++l) {
int r = l + len - 1;
dp[l][r] = INF;
for (int k = l; k < r; ++k) {
dp[l][r] = min(dp[l][r], dp[l][k] + dp[k + 1][r] + cost(l, r, k));
}
}
}
这类题目问法经常是“合并石子最小代价”“戳气球最大得分”,看到这些关键词就可以先想到区间 DP。
我对 DP 类板子的总结是:状态定义没有唯一答案,遍历顺序也只有固定几种,但如果你能把“背包”“LIS”“LCS”“区间 DP”“树形 DP”这五类经典模型的转移逻辑背扎实,绝大多数面试题都能从中找到熟悉的影子。
6. 面试、竞赛和业务中,板子应该当工具而不是负担
最后聊点实在的:面试和竞赛同样是考算法,但对于“板子”的期待完全不同;到了真实业务里,很多算法热词根本不适合用“背”来处理。
6.1 面试官眼里,手写板子最怕出现“默写式答题”
我面试时,候选人在黑板上快速写出并查集模板,我当然会加分,但如果接着问一句“这里路径压缩是递归好还是迭代好”,对方答不上来,这印象分会立刻打折扣。面试不是为了验证你能默写,而是为了验证你对自己的代码有控制权。
比较好的回答方式是这样:动手写之前,先用一两句话说明“这道题适合用并查集,因为需要不断合并连通块,并且查询两个点是否连通”,然后在写的过程中顺带解释关键的路径压缩。这样一来,即使中间出了小错,面试官也知道你思路是清晰的。
6.2 业务项目里的热词算法,别强行往“板子”上套
热词列表里有 PID 算法、ISP Bayer2RGB、粒子群、模拟退火、AES 算法 CTR 模式等,这些词背后都是工程问题。真实研发中你会调用成熟的算法库、视觉 SDK、密码库,而不是从零写一个 AES。核心原因是工程对正确性、安全性和维护成本要求远大于对“手写能力”的要求。
以 ISP 算法里的 Bayer2RGB 为例,它的核心是去马赛克插值,不同硬件和不同分辨率下最优插值策略差异极大,不存在一套万能模板。理解“相邻像素冗余”的原理远比背一段 OpenCV 的转换参数有用,因为真正的调优往往发生在厂商专属 pipeline 层,开源代码只提供基准版本。
6.3 推荐一个个人调试过的方法:为板子建“模板仓库”
从入行到现在,我自己维护了一个本地模板仓库,按主题分类:排序、二分、图论、字符串、DP 套路、数论、几何基础等。每个模板文件开头都有固定注释,记录复杂度、适用边界、曾经踩过的坑。比如 KMP 文件里我会写一句“构建 lps 时 j 跳回的目标是 lps[j-1],不是 j-1,也不是 lps[j]”。这比任何教程都更能提醒自己。
每过一个月,我会把所有代码重新默写一遍,然后和仓库里的模板对拍。这个过程能快速暴露出哪里掌握得还浅。测试时拿几道经典题跑数据,比如用并查集写并查集模板题、用树状数组解决逆序对、用 Dijkstra 处理负权图并观察它怎么失败。只要有一道测试出现边界问题,就说明对应的模板还没真正内化。
每个模板需要达到的状态是:不假思索能写出正确版本,同时又能解释清楚某个细节为什么不能换成另一种常见写法。到了这一步,板子才真正变成了你的思维工具,而不只是死记硬背的段落。我个人觉得,算法训练里最迷人的地方就在这里:表面的模板千篇一律,但同一个模板放在不同问题里,能像积木一样重新组合出千变万化的解法。
