今天是我坚持算法的第3天。先说说这个“算法DAY3”的来历:前两天把数组、字符串、模拟题扫了一轮,昨天刚写完冒泡排序,今天本来想直接冲KMP和贪心,结果发现基础不牢,写个区间合并卡了半小时。所以DAY3的核心不是学新东西,是把暴力枚举、排序、二分和递归这些“地基”打稳。这篇就当我的刷题复盘,也是给同样刚起步的人一份可参考的路径,内容会包含具体题目思路、完整代码和踩过的坑。
这篇适合两类人:一类是刚开始刷题、想系统入门算法的同学,另一类是学了半天但遇到新题还是没思路、想补基本功的人。我会尽量把“为什么这么做”讲明白,而不只是贴代码。
1. DAY3的学习路线:为什么第三天还在补基础
1.1 前两天的进度:从输入输出到简单模拟
第一天一般是从输入输出、变量、数组开始,第二天做几道模拟题,比如“给定一个数组,输出每个元素右边第一个比它大的数”。这类题目对逻辑要求不高,真正考验的是能不能把题目描述转换成代码。我当时第二天的感受是:暴力解法基本都能写,但一旦数据范围变大,程序就会超时。
所以在规划DAY3时,我做了一个很实际的决定:先不碰字符串匹配和动态规划,集中攻克三件事——暴力枚举、手写归并排序、理解时间复杂度。如果这三个东西没有形成肌肉记忆,后面学贪心和搜索基本是空中楼阁。
1.2 为什么“暴力枚举”反而是最重要的基础
很多人觉得暴力枚举没什么技术含量,其实不是。暴力枚举的本质是“把问题空间完整地遍历一遍”,这是所有算法正确性的底线。贪心可能选错贪心策略,动态规划可能漏掉状态,但暴力只要遍历完全,结果就是对的。后续学剪枝、记忆化搜索、甚至A*算法,都是在“暴力遍历”的基础上减少搜索空间。
所以DAY3的第一道题,我建议就是纯暴力枚举。别急着优化,先用最朴素的方式做出来,然后分析复杂度,再去想办法降低复杂度。这个顺序非常重要。
1.3 第三天内容安排:枚举、排序、复杂度、一个贪心
给自己定的DAY3任务清单如下:
- 一道暴力枚举题(两数之和)
- 一道区间调度贪心题
- 手写一次归并排序,不要用sort()偷懒
- 用一道二分查找题来理解O(log n)
- 把“时间复杂度什么时候用O、什么时候用Θ”搞清楚
实际做下来,这个量刚刚好,既没有太轻松也没有过量。很多人DAY3就急着学KMP和动态规划,我也动过这个念头,但后来发现,贪心里最简单的“区间选择”都能错好几次,KMP的next数组更是看完就忘。不如先把基本功磨扎实。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心细节拆解:暴力、贪心、归并与KMP初探
2.1 暴力枚举:两数之和的三种写法
题目很经典:给定一个整数数组和一个目标值,找出数组中和为目标值的两个数,返回下标。
最暴力的写法就是两层循环,第一层固定i,第二层找j,判断nums[i] + nums[j] == target。代码很短:
cpp复制#include <vector>
using namespace std;
vector<int> twoSum(vector<int>& nums, int target) {
int n = nums.size();
for (int i = 0; i < n; ++i) {
for (int j = i + 1; j < n; ++j) {
if (nums[i] + nums[j] == target) {
return {i, j};
}
}
}
return {};
}
时间复杂度是O(n^2),空间复杂度O(1)。这个写法提交给力扣没问题,但你要清楚它慢在哪:每到一个i,都要遍历后面所有的j。如果数组长度为10万,n的平方就是100亿次操作,肯定超时。
于是自然想到用哈希表优化。遍历一次数组,每访问一个元素x,就查一下target - x是否已经出现过。这里有个细节:应该先查再插入,避免同一元素自己跟自己匹配。代码不复杂,我直接贴核心逻辑:
cpp复制#include <unordered_map>
#include <vector>
using namespace std;
vector<int> twoSum(vector<int>& nums, int target) {
unordered_map<int, int> mp;
for (int i = 0; i < (int)nums.size(); ++i) {
int need = target - nums[i];
if (mp.find(need) != mp.end()) {
return {mp[need], i};
}
mp[nums[i]] = i;
}
return {};
}
这个优化过程本身就是“暴力枚举 → 哈希换时间”的典型例子。DAY3千万别只满足于把题做出来,要能清楚说出一句话:为什么哈希表查找是O(1)平均复杂度。
2.2 贪心算法入门:区间调度问题
贪心的核心是“局部最优能推出全局最优”,但这个性质不是白来的,需要证明。我DAY3选了经典的“选择最多不重叠区间”问题:给定一些区间,选出尽量多的区间,使得它们互不重叠。
贪心策略是:按区间右端点排序,每次都选当前右端点最小且不与已选区间重叠的区间。这一步的直觉是,右端点越小,留给后面的空间越大。
cpp复制#include <vector>
#include <algorithm>
using namespace std;
int eraseOverlapIntervals(vector<vector<int>>& intervals) {
if (intervals.empty()) return 0;
sort(intervals.begin(), intervals.end(),
[](vector<int>& a, vector<int>& b) {
return a[1] < b[1];
});
int count = 1;
int preEnd = intervals[0][1];
for (int i = 1; i < intervals.size(); ++i) {
if (intervals[i][0] >= preEnd) {
++count;
preEnd = intervals[i][1];
}
}
return intervals.size() - count;
}
这里我踩过一个坑:比较区间重叠时,条件到底是>=还是>要看题目定义,如果区间端点重合不算重叠就写>=,否则写>。很多人可能就是在这里错了一个边界条件,导致提交不过。
2.3 归并排序:从分治到代码落地
DAY3必须手写一次归并排序,不是为了面试默写,而是为了理解“分治”这个思维模型。归并排序的思路是:把数组从中间分成两半,分别排序,再合并两个有序数组,合并过程需要临时空间。
看代码:
cpp复制#include <vector>
using namespace std;
void merge(vector<int>& arr, int left, int mid, int right) {
vector<int> temp(right - left + 1);
int i = left, j = mid + 1, k = 0;
while (i <= mid && j <= right) {
if (arr[i] <= arr[j]) {
temp[k++] = arr[i++];
} else {
temp[k++] = arr[j++];
}
}
while (i <= mid) temp[k++] = arr[i++];
while (j <= right) temp[k++] = arr[j++];
for (int p = 0; p < temp.size(); ++p) {
arr[left + p] = temp[p];
}
}
void mergeSort(vector<int>& arr, int left, int right) {
if (left >= right) return;
int mid = left + (right - left) / 2;
mergeSort(arr, left, mid);
mergeSort(arr, mid + 1, right);
merge(arr, left, mid, right);
}
需要注意三点。第一,mid的计算不要写(left + right) / 2,因为极端情况下可能溢出,写成left + (right - left) / 2更安全。第二,merge时一定要用临时数组存好再覆盖回去,否则会丢失数据。第三,递归终止条件是left >= right,不是left == right,原因是有可能传入空区间。
归并排序的时间复杂度是O(n log n),这个复杂度怎么来的?每一层递归都把区间对半拆分,一共有log n层,每层合并过程总复杂度是O(n),所以总复杂度是n乘log n。
2.4 KMP算法:DAY3要不要碰
KMP确实刷屏,热搜词里也有它。但我个人建议,第三天的阶段不要急着啃KMP。可以先用半小时理解它的思想,然后放下。KMP的核心是next数组,它表示“模式串中每个位置之前的最长相同前后缀长度”。这个东西如果只记住代码,过几天再看依然不懂。
我DAY3只做了一件事:手写了一个求next数组的小函数,并理解了它的递推过程。代码如下:
cpp复制#include <vector>
#include <string>
using namespace std;
vector<int> getNext(string p) {
int n = p.size();
vector<int> next(n, 0);
int j = 0;
for (int i = 1; i < n; ++i) {
while (j > 0 && p[i] != p[j]) {
j = next[j - 1];
}
if (p[i] == p[j]) {
++j;
}
next[i] = j;
}
return next;
}
理解这个代码的关键是:next[i]表示前缀p[0..i]中,最长相等前后缀的长度。而j = next[j-1]这一步就是“往回跳”,相当于是暴力匹配失败后,利用已经算出的信息避免从头再来。如果能把这个逻辑想明白,就可以去做KMP主串匹配了;想不明白也没关系,先标记一下,等把DFS和递归练熟再回头。
3. 实操记录:从暴力到优化的完整流程
3.1 一道题从超时到AC的完整记录
我DAY3专门找了一道需要优化的题来练手:给定一个整数数组,求所有连续子数组中,和为负数的子数组数量。暴力枚举所有子数组是O(n^2),如果n是10万,必然超时。这道题优化时用前缀和:前缀和数组pre[i]表示前i个元素的和,子数组[i, j]的和就是pre[j] - pre[i-1],要小于0,等价于pre[j] < pre[i-1]。
于是问题变成“统计pre[j]前面有多少个pre[i-1]大于pre[j]”,也就是逆序对的计数。逆序对正好可以用归并排序来统计,代码我就不贴完整版了,思路是在merge两个有序数组时,如果右半边的某个数小于左半边的某个数,那么左半边从当前位置到mid的所有数都大于右半边这个数,逆序对数量加上mid - i + 1。
这个题做完我有一种“通了”的感觉:暴力枚举是起点,前缀和是工具,归并排序是统计手段,全部串起来了。
3.2 贪心正确性的证明笔记
DAY3我犯过一个典型的错误:看到类似“每次选最大的”就以为是贪心。当时有一道题是“用最少的硬币凑出某个金额”,我直接每次选面值最大的硬币,结果在普通面值时没问题,换成自定义面值就错了。原因很简单:这个策略不具有贪心选择性质。
所以在做区间调度时,我在笔记里写了三段话:
- 我们的贪心策略是“每次选结束时间最早的区间”。
- 假设最优解的第一个区间不是结束最早的,那么把它替换成结束最早的区间,依然不会与后面的区间冲突。
- 替换之后最优解不会变差,所以“每次选结束最早的”是正确的。
这就是一个最简单的贪心正确性证明。每次做贪心题,都要求自己写出这样的论证过程,哪怕写得很幼稚,也比“感觉对”要强。
3.3 复杂度分析实战:什么时候用O,什么时候用Θ
热词里有个问题很值得回答:计算算法复杂度时,什么时候用O、什么时候用Θ?
简单说,O是渐近上界,表示“不会比某个复杂度更差”;Θ是渐近紧界,表示“这个算法就是这个复杂度,不会更好也不会更差”。例如,一个排序算法,你说它是O(n^2),这句话没错,因为n^2是它的上界;但如果你说它是Θ(n^2),就表示它确实稳定在n^2这个量级。冒泡排序就有这种情况:最坏情况是O(n^2),最好情况是O(n),但整体不是Θ(n^2),因为它有早停优化后可能跑得快。
用的时候,如果只是表达“最坏不会超过”,用O;如果能确定算法在所有情况下的复杂度都一致,用Θ。归并排序一般说O(n log n),但严格来说它对所有输入都是这个复杂度,所以也可以说Θ(n log n)。有些教材为了表达方便,在不引起歧义时混用,但面试或做题时最好区分清楚。
3.4 实测进度总结
DAY3结束时,我的AC情况是:
- 暴力枚举题,第一版O(n^2),第二版哈希O(n),AC
- 区间调度贪心题,思路一次对,AC
- 手写归并排序,本地测试通过
- 逆序对计数,中间有个边界条件写错,改了两次才AC
还有一个很重要的产出:整理了一张自己的笔记模板,每道题记录四样东西:题意摘要、暴力复杂度、优化思路、边界条件。这个模板后面刷题特别有用。
4. 常见问题与排查技巧实录
4.1 递归爆栈:测试用例一大就崩溃
这个我DAY3踩得很深刻。写归并排序时,我在本地测试一百万个数没崩,但上在线评测系统跑一千万就栈溢出了。原因是递归深度不是log n吗?其实归并排序递归深度确实是log n,理论上不会爆栈,但我当时测试时用的不是这个代码,而是写了一个快速排序的递归版本,最坏情况下递归深度达到n,所以爆栈了。
解决方法是:第一,确认当前算法的递归深度;第二,实在不行改成非递归版本;第三,不要默认编译器栈空间很大,尤其是嵌入式或在线评测环境。
4.2 区间合并的边界条件反复错
区间相关的题,比如“合并区间”,我几乎每次都在区间端点比较上卡住。调试了半天,最后发现问题在于“区间是否闭合”的定义。有的题说[1, 4]和[4, 5]能合并,有的题说不能,关键在于题目有没有规定区间是左闭右闭还是左闭右开。规范做法是在读题时把区间类型圈出来,然后根据类型写严格的比较符号,不要靠猜。
4.3 暴力枚举去重困难
暴力枚举的进阶是“不重复地枚举”。比如三个数之和等于0,经典做法是固定一个数,然后双指针找另外两个。这个算法的难点不是双指针,而是去重:如果数组里有重复元素,很容易出现重复三元组。
我的经验是:外层循环固定i时,如果当前元素和前一个元素相同,就跳过;找到一组答案后,移动左右指针也要跳过重复值。这个细节我当时漏了,导致提交后出现大量重复结果。写成代码就是:
cpp复制if (i > 0 && nums[i] == nums[i - 1]) continue;
双指针内部类似:
cpp复制while (left < right && nums[left] == nums[left + 1]) ++left;
while (left < right && nums[right] == nums[right - 1]) --right;
4.4 复杂度判断错误:没有统计所有操作
我DAY3最初以为“两数之和”用vector的find扫描也能过,于是写了if (find(nums.begin(), nums.end(), target - nums[i]) != end),结果超时。原因是我忘了find本身就是O(n),外层再加循环就是O(n^2),和暴力没区别。后来才改成哈希表。
这个教训说明:判断复杂度时,要把每个接口的真实开销算进去。很多人觉得STL自带操作都是O(1),其实find是O(n),sort是O(n log n),lower_bound是O(log n),各有各的复杂度。建议记一张表,见下面的速查。
| 常用操作 | 平均复杂度 | 注意事项 |
|---|---|---|
| 数组随机访问 | O(1) | 直接下标访问 |
| vector插入末尾 | O(1) | 扩容时是O(n) |
| vector查找find | O(n) | 线性查找 |
| unordered_map查找 | O(1) | 哈希冲突时退化 |
| map插入/查找 | O(log n) | 红黑树实现 |
| sort | O(n log n) | 快排+插入排序混合 |
| lower_bound | O(log n) | 有序序列二分 |
4.5 问题排查速查表
再整理一张DAY3高频错误速查表,平时卡住可以直接对号入座。
| 现象 | 可能原因 | 排查思路 |
|---|---|---|
| 程序超时 | 复杂度估计错误 | 看数据范围,算最内层循环次数是否超过1e8 |
| 递归爆栈 | 递归深度过大 | 打印中间变量看递归到第几层,考虑改迭代 |
| 运行但答案错误 | 边界条件写错 | 检查循环边界、数组越界、空数组、单元素 |
| 输出重复 | 枚举去重失败 | 查找跳过重复元素的逻辑 |
| 内存超限 | 临时数组过大 | 尽量复用vector,避免频繁分配 |
5. 热词盘点:这些算法关键词如何对应学习路径
5.1 排序算法家族:从冒泡到堆排序
DAY3在刷题过程中,会遇到大量排序算法热词。冒泡排序是基础,但实际场景中用得很少,它的意义在于理解“相邻交换”的模型。堆排序的核心是“用完全二叉树维护最大堆或最小堆”,它的时间复杂度稳定为O(n log n),而且不需要额外的数组空间,是真正的原地排序。建堆过程看起来是O(n log n),但严格分析是O(n),细节可以留到后面研究。
我给DAY3的排序优先级是:手写归并>手写快排>理解堆排序>会写冒泡。前两个必须能背、能写、能讲复杂度,堆排序至少得知道建堆和调整的代码结构。
5.2 搜索算法热词:从二分到A*再到DFS
搜索算法里,二分查找是DAY3就能完全掌握的。写二分查找有一个通用模板:用左闭右开区间,当left < right时循环,收缩边界时保持区间不变量。这个模板一旦掌握,能直接套到“第一个大于等于x的位置”“最后一个小于等于x的位置”等变体中。
A算法很热门,但它是带启发式的搜索,应用在路径规划、地图导航、游戏中。学习它之前,至少要熟练BFS和优先队列。DAY3可以先了解它的核心公式f = g + h,g是起点到当前点的代价,h是当前点到终点的估计代价,A就是选择f最小的节点扩展。刚入门不要深究。
5.3 机器学习与强化学习热词:属于另一个知识体系
热词里的深度学习算法、随机森林回归、DQN、PPO、MADDPG这些,本质是“基于数据的算法”,和传统数据结构算法不太一样。我觉得DAY3阶段不用碰,等把基础算法练熟再去学,效率更高。随机森林属于机器学习集成方法,核心思想是训练多棵决策树再投票;DQN和PPO属于强化学习,核心问题是“代理如何通过试错学习策略”,需要线性代数、概率论、神经网络基础。盲目追热词容易挫败。
如果你已经有编程基础,想提前了解,我建议的做法是:跑一个现成的强化学习示例,比如CartPole环境里用随机策略尝试几百次,看看分数变化,先建立直觉,再补理论。
5.4 算法工程师面试视角:DAY3阶段应该如何积累
算法工程师面试一般不直接考框架,而是考代码能力和算法思维。面试流程通常涉及算法题、机器学习基础、项目经历。DAY3这个阶段,最重要的面试准备不是刷难题,而是踏实整理一套属于自己的题解模板。
我建议从DAY3开始维护一个知识清单,每学一个算法就更新一行:算法名称、核心思想、时间复杂度、典型例题、易错点。等到面试前复习,这份清单会比任何网上的刷题路线都有效。
text复制算法名称:归并排序
核心思想:分治
时间复杂度:O(n log n)
典型例题:逆序对、链表排序
易错点:临时数组回写
这个表格看起来简单,但坚持写一百条,你就会发现所有算法之间有清晰的交叉连接。
最后分享一个实际心得
DAY3真正让我收获的,不是写了多少个AC,而是学会了克制。看到“KMP算法”上热搜,看到群里有人讨论“动态规划”,我确实心痒。但当天我忍住没有盲目追进度,而是把归并排序写得能闭眼就写出来,把复杂度分析搞到能给别人讲明白。
所以如果你也在自己打卡算法学习,我给一个小建议:每学一个算法,别只在心里说“懂了”,找个朋友或者自己对着空气讲一遍,三分钟内能讲清楚就算过关。讲不清楚的地方,就是下一个DAY要补的地方。刷题这条路没有什么捷径,但如果你能把“暴力解法”和“复杂度优化”这两个基本功刻进脑子里,后面对抗KMP、动态规划,甚至机器学习里的各种优化算法,都会轻松很多。
