二分不是「背下来的模板」,而是「每次砍掉一半候选区间」这个动作。
在一百万个数里找一个,最多问 20 次——因为 2²⁰ ≈ 104 万。每问一次,候选区间就减半,这就是全部的秘密。
三个边界最容易写错,记住这三句就够了:
lo <= hi,因为区间是闭区间 [lo, hi]。lo = mid + 1 / hi = mid - 1,因为 mid 已经看过了。lo + (hi - lo) / 2,避免 lo+hi 溢出。// C++ int lower_bound(const vector<int>& a, int x) { int lo = 0, hi = (int)a.size() - 1; while (lo <= hi) { int mid = lo + (hi - lo) / 2; if (a[mid] < x) lo = mid + 1; else hi = mid - 1; } return lo; // 第一个 >= x 的位置 }
hi = mid 和 lo = mid 混着用,且循环条件是 lo < hi——区间不缩小,mid 永远不动。写之前先想清楚「区间是闭的还是左闭右开」。| 进出规则 | 典型用途 | |
|---|---|---|
| 栈 | 后进先出 | 括号匹配、撤销、递归展开、单调栈找下一个更大 |
| 队列 | 先进先出 | BFS、任务调度、滑动窗口 |
| 双端队列 | 两头都能进出 | 滑动窗口最大值(单调队列) |
删除头结点是一个特例:没有前驱。加一个不存数据的哑结点放在最前面,头结点就有了前驱,特例消失——这是链表题少写一半 if 的诀窍。
// Python:删除值等于 x 的结点 dummy = ListNode(0, head) prev = dummy while prev.next: if prev.next.val == x: prev.next = prev.next.next else: prev = prev.next return dummy.next
排序算法值得学的只有三件事:怎么分区、怎么合并、什么时候别排序。
选最后一个元素做基准。用一个指针 i 表示「小于基准的区域到哪了」,扫一遍,比基准小的都换到前面去。扫完之后把基准换到 i 的位置——它左边的都小,右边的都大,且位置已经确定。
// Lomuto 分区 int partition(vector<int>& a, int lo, int hi) { int pivot = a[hi], i = lo; for (int j = lo; j < hi; ++j) if (a[j] < pivot) swap(a[i++], a[j]); swap(a[i], a[hi]); return i; }
分到只剩一个元素(天然有序),再两两合并。合并时如果右边先出,说明左边剩下的都比它大——这一步顺手就能统计逆序对数量,这是快排做不到的。
求「最大的 k 个」用大小为 k 的小顶堆:新元素比堆顶大就换掉堆顶。复杂度 O(n log k),比全排的 O(n log n) 省——数据量大而 k 小的时候差别明显。
| 算法 | 平均 | 最坏 | 稳定 | 什么时候用 |
|---|---|---|---|---|
| 快排 | O(n log n) | O(n²) | 否 | 通用、原地、常数小 |
| 归并 | O(n log n) | O(n log n) | 是 | 要稳定 / 链表 / 外部排序 |
| 堆排 | O(n log n) | O(n log n) | 否 | 内存紧、只要最坏可控 |
| 计数/桶 | O(n+k) | O(n+k) | 是 | 值域小且已知 |
动态规划不是「套公式」,是「把重复的子问题答案存下来」。想清楚三件事:状态是什么、怎么转移、边界在哪。
dp[i][c] = 只考虑前 i 件物品、容量 c 时能装的最大价值。每件物品只有两个选择:不拿(继承上一行)或拿(上一行容量减重 + 价值)。
// 一维滚动数组,注意容量要倒着遍历 vector<int> dp(C + 1, 0); for (int i = 0; i < n; ++i) for (int c = C; c >= w[i]; --c) dp[c] = max(dp[c], dp[c - w[i]] + v[i]); return dp[C];
贪心只做当前最优选择,不回头。它对的前提是最优子结构 + 贪心选择性质:局部最优能推出全局最优。区间调度(按结束时间排)满足;0/1 背包按「性价比」贪心就不满足——反例:容量 10,物品 (重 6 值 10) 和 (重 5 值 9) 加 (重 5 值 9)。
图的题只有两种:要么「走一遍」(BFS/DFS),要么「算最短路」(Dijkstra/Floyd)。
队列里放「待访问的节点」。每次取出一个,把它的邻居里没访问过的加进队列。因为先进先出,所以先被加进来的(离起点更近的)先被处理——这就是「最短步数」的由来。
// 多源 BFS:把所有源点先入队 queue<int> q; for (int s : sources) { dist[s] = 0; q.push(s); } while (!q.empty()) { int u = q.front(); q.pop(); for (int v : g[u]) if (dist[v] == -1) { dist[v] = dist[u] + 1; q.push(v); } }
DFS 用递归或栈,适合「问是否存在」「枚举所有方案」。回溯 = DFS + 撤销选择,模板只有四步:做选择 → 递归 → 撤销 → 剪枝。
两个操作:find(找根)、union(合并)。路径压缩 + 按秩合并后,单次操作接近 O(1)。连通性问题、Kruskal 求最小生成树都用它。
parent[i] = i; int find(int x){ return parent[x]==x ? x : parent[x]=find(parent[x]); } void uni(int a,int b){ parent[find(a)] = find(b); }
Dijkstra 的核心假设是「一旦一个点被取出,它的最短路就定了」。有负权时,后面可能通过一条负边把已经确定的距离改小——假设崩塌。所以它只适用于非负权图。
| 算法 | 适用 | 复杂度 |
|---|---|---|
| BFS | 无权图最短路 | O(V+E) |
| Dijkstra(堆) | 非负权单源 | O((V+E)logV) |
| Bellman-Ford | 有负权 / 判负环 | O(VE) |
| Floyd | 全源,V ≤ 400 | O(V³) |
| Kruskal | 最小生成树 | O(E log E) |
字符串匹配的关键不是「比对」,而是「失配时别从头再来」。
暴力匹配在失配时把模式串整体右移一位,前面的比较全部浪费。KMP 观察到:失配位置之前的那段文本我们已经知道内容了,如果模式串自己有个前缀等于后缀,就可以直接跳到那里继续比。
// 前缀表:next[i] = 前 i 个字符里最长相等前后缀长度 vector<int> build(const string& p) { vector<int> nxt(p.size() + 1, 0); for (int i = 1, k = 0; i < (int)p.size(); ++i) { while (k && p[i] != p[k]) k = nxt[k]; if (p[i] == p[k]) ++k; nxt[i + 1] = k; } return nxt; }
哈希表把键映射到桶。桶少了冲突多,桶多了浪费内存——负载因子(元素数 / 桶数)就是那个旋钮,一般超过 0.75 就扩容。
两种冲突解法: