我自己刚学算法那会儿,干过一件特别丢人的事:把网上几道热门题的题解背了下来,以为能应付面试,结果面试官把题目改了一个条件,我当场就卡住了。后来带过不少刚起步的同事,也回答过很多“算法小白”的问题,发现大家问得最多的其实不是“这道题怎么解”,而是“为什么我看了半天题解还是不会做”“明明照着写了,一跑就超时”“这个算法到底什么时候该用”。这些问题的根源,往往不是天赋不够,也不是刷题量不达标,而是对算法的理解方式,从一开始就跑偏了。
这篇文章我想从新手最常见的困惑出发,把“简单算法”这个概念拆开揉碎讲清楚。排序、查找、递归、复杂度分析都会聊到,也会把实际调试过程中踩过的坑一并列出来。内容不追求面面俱到,但保证每一个点都能直接落地,适合正在学数据结构与算法、准备笔试面试,或者工作中突然发现自己欠了一屁股“基础债”的朋友。
1. 新手学算法的几个典型误区
1.1 背代码而不是理解思路
我见过太多人拿着冒泡排序的代码,能一字不差默写出来,但问他“内层循环为什么是 n - i - 1,而不是 n - i”,就答不上来了。这就是典型的“会背不会用”。面试官或者实际项目里,几乎不会有题目让你“原样默写某个算法”,他们更关心的是:你能不能根据具体的输入、需求、数据规模和约束条件,从你的知识库里挑出合适的算法,然后改造成能解决当前问题的代码。
那怎么避免背代码呢?我的办法比较笨,但特别有效:每学完一个算法,不看参考代码,自己拿一个具体的小数组,比如 [5, 1, 4, 2, 8],在纸上把每一轮结束后的数组状态画出来。先把过程走明白,再谈代码实现。甚至可以在代码里临时加一个打印语句,把每次交换后的数组打出来,亲眼看着数据一点点“动起来”。这一步的反馈非常直接,比你盯着代码看十遍都有用。
1.2 只刷题不看复杂度
“不管三七二十一,先暴力解出来再说”——这是新手的通病。暴力解法本身没什么问题,很多题目暴力解能过,是因为数据范围小。但你要是拿同样的思路去处理真实项目里的百万级、千万级数据,程序估计要跑到天荒地老。
我举个例子。给你一个长度为 n 的数组,要判断里面有没有重复元素。最简单粗暴的方式是两层循环,时间复杂度是 O(n²)。当 n=10 时,这点计算量无所谓;但当 n=10^6 时,就意味着大约 10^12 次基本操作,现代 CPU 每秒能执行的操作量级大概是 10^9 次左右,你自己算算要跑多久。而用哈希表(unordered_set)或者先排序再比较相邻元素,时间复杂度立刻降到 O(n) 或 O(n log n),可能只需要几毫秒。
所以,每写完一段代码,养成一个习惯:看一眼输入规模,估算一下自己的解法在最坏情况下会跑多少次循环、开多大内存。这比你会了多少个冷门算法重要得多。
1.3 忽视边界条件和输入输出细节
很多算法代码核心逻辑写得没问题,但一提交就“答案错误”或者“运行时错误”,问题通常出在边界条件上:数组为空、只有一个元素、元素全是负数、输入里混着重复值、整型溢出……这些情况虽然看起来“极端”,但实际工程和面试题里反而最常见。
我常用的一个笨办法是:写完代码之后,先别急着提交,自己构造几组边界用例。比如排序算法就测一测空数组、只有一个元素、已经有序的数组、完全逆序的数组、全部元素相等的数组。如果这些极端情况都能跑通,代码的可靠性会高很多。注意,测试不是“做样子”,你要真的把代码跑起来看结果,而不是在脑子里过一遍就觉得没问题。脑内模拟往往会有盲区。
1.4 盲目追求高深算法,忽略基础
网上看到“3DCNN和C3D算法对比”“粒子群算法原理”“SVPWM算法详解”,觉得高级,转头就去啃,结果被公式劝退,连带着基础也没学好。这个心态我特别理解,谁不想一步到位呢?但这里我想说一句可能不太中听的话:算法学习是有顺序的。
像 3D 卷积、粒子群、SVPWM、FOC 这些,有它们各自的应用场景,比如视频理解、优化问题、电机控制。但它们的底层,都建立在数组、链表、树、图、排序、查找、递归、概率统计这些基础上。你现在连二叉树的遍历都没搞明白,就去看多模态融合、强化学习、深度学习的文章,除了增加焦虑,不会带来任何真正的提升。我的建议是:先把基础算法的地基打好,再去接触领域算法。地基不牢,楼盖得越高越危险。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 先把地基打好:时间复杂度和空间复杂度
2.1 时间复杂度:先从“数循环次数”开始
时间复杂度这个概念,大学课本里喜欢用渐近记号来定义,但新手初学没必要太纠结数学推导。你只要抓住一点:把算法执行的基本操作次数,表达成关于输入规模 n 的函数,然后保留增长最快的项,去掉系数和低阶项,就是时间复杂度。
比如一段两层循环,外层跑 n 次,内层也跑 n 次,那么基本操作次数大约是 n * n = n²,复杂度就是 O(n²)。如果你只有一个循环,那就是 O(n)。如果你不管输入多大,都只做一次操作,那就是 O(1)。
这里有一个很多新手会混淆的点:时间复杂度描述的是“增长趋势”,不是“精确运行时间”。一个 O(n) 的算法在数据量小时,可能跑得不如一个 O(n²) 的算法快,因为 O(n²) 的系数可能更小。但数据量一旦上去,O(n²) 会迅速失控。所以实际工程里,我们经常说“在数据规模可预估的情况下,选择合适的算法”,而不是“最好的算法一定是复杂度最低的”。
我自己的经验是,当内存足够时,优先考虑时间复杂度更低的算法;当内存紧张时,再做空间换时间的权衡。这个思路在实际项目中是反复出现的。
2.2 空间复杂度:从“额外开了多少内存”去想
空间复杂度往往被新手忽略,因为现在电脑内存动不动就是 8G、16G,大家觉得“反正内存够大,随便开数组”。但真实场景下,嵌入式开发、移动端开发、高并发服务端,内存都是要斤斤计较的。而且不光是内存大小的问题,频繁申请内存也会带来额外的性能开销。
空间复杂度看的是:除了输入数据本身占用的空间之外,你的算法额外申请了多少空间,这个额外空间随输入规模 n 怎么增长。比如你写排序算法时,额外开了一个和原数组等大的临时数组,那空间复杂度就是 O(n)。如果你只在循环里用了几个临时变量,那空间复杂度就是 O(1)。递归也是消耗空间的,每递归一层,会有一个函数调用栈帧,所以递归深度有多深,空间开销就有多大。
建议新手在学习每个算法时,别只把时间复杂度和空间复杂度当成两个需要背的数字,而是要试着从代码里“找到”它们是怎么来的。打个比方,时间复杂度是“做这件事要花多少时间”,空间复杂度是“为了做这件事,你要腾出多大一张桌子”。这两个指标往往是互相制约的,你很难同时做到又快又省空间。
2.3 为什么 O(log n) 看起来不高,但很舒服?
O(log n) 是新手最容易“没感觉”的复杂度。因为对数函数图像的增长速度肉眼可见地缓慢,你甚至会怀疑它是不是“约等于O(1)”。
举一个最直觉的例子:在一个有序数组里找一个目标值,最“笨”的办法是从头到尾遍历,O(n)。但用二分查找,每比较一次,搜索范围就缩小一半。10 个元素,大约比较 4 次;1000 个元素,大约比较 10 次;1,000,000 个元素,大约比较 20 次。你看,数据量从 1000 涨到 1,000,000,需要的比较次数只增加了 10 次。这就是 O(log n) 的威力。
我经常跟新手说,如果你发现某个算法的复杂度里出现了 log n,那通常意味着它用了某种“分而治之”或者“每次排除一部分”的策略。这种策略在算法设计里特别常考,也是很多高效算法的核心思想。
2.4 常见复杂度速查表
| 复杂度 | 典型场景 | 例子 |
|---|---|---|
| O(1) | 哈希表查找、数组按下标访问 | unordered_map 的查找(平均情况) |
| O(log n) | 二分查找、平衡二叉搜索树 | 有序数组中查找元素 |
| O(n) | 单层循环遍历 | 从头到尾找最大值 |
| O(n log n) | 高效排序、分治算法 | 归并排序、堆排序 |
| O(n²) | 双层循环、朴素排序 | 冒泡排序、选择排序 |
| O(2ⁿ) | 递归枚举子集 | 暴力解决组合问题 |
| O(n!) | 全排列枚举 | 暴力搜索旅行商问题(小规模) |
这张表不用背,但你每写一个算法,最好能对照一下,心里大概有个数。尤其是在面试的时候,面试官问你“这个解法复杂度是多少”,如果你答不上来,基本等于告诉对方你只是“背了代码”。
3. 入门必学的几个简单算法,动手写一遍
3.1 冒泡排序:从“交换邻居”理解两层循环
冒泡排序是很多人的算法启蒙。它的思路特别朴素:重复地遍历数组,比较相邻两个元素,如果顺序错了,就把它们交换过来。每一轮遍历之后,最大的元素就像泡泡一样“冒”到数组末尾。
先看一段最常见的 C++ 实现:
cpp复制#include <iostream>
#include <vector>
using namespace std;
void bubbleSort(vector<int>& arr) {
int n = arr.size();
for (int i = 0; i < n - 1; i++) {
// 内层循环:把未排序部分中的最大值移动到末尾
for (int j = 0; j < n - 1 - i; j++) {
if (arr[j] > arr[j + 1]) {
swap(arr[j], arr[j + 1]);
}
}
}
}
int main() {
vector<int> arr = {5, 1, 4, 2, 8};
bubbleSort(arr);
for (int x : arr) {
cout << x << " ";
}
return 0;
}
新手最容易问的一个问题就是:内层循环为什么是 j < n - 1 - i,而不是 j < n - 1?原因很简单:经过第 i 轮冒泡之后,数组末尾的 i 个元素已经是排好序的了,已经“沉底”的元素不需要再参与比较。如果内层循环不受 i 限制,每一轮都把整个数组从头到尾扫一遍,当然也能排序,但会有一部分比较是完全多余的。这个差别在数据量小的时候看不出来,数据量大了之后,运行时间的差距就很明显了。
冒泡排序还可以加一个小优化:如果某一轮遍历过程中一次交换都没有发生,说明数组已经有序,直接结束。这里我就不贴代码了,你可以自己动手加这个“提前退出”的逻辑。这个小优化是面试常考的点,也考验你对“代码执行状态”的理解。
3.2 归并排序:理解“分治”思想的敲门砖
如果说冒泡排序是让你练手“循环和交换”,那归并排序就是让你第一次接触“分治”思想:把大问题拆成小问题,递归解决小问题,再把结果合并起来。
归并排序的核心过程分两步:第一步,把数组从中间切成两份,分别递归排序;第二步,把两个已经有序的子数组合并成一个有序数组。合并的过程很像是手里有两摞已经排好序的扑克牌,每次比较两摞最上面的那张,把更小的那张拿下来放到结果里。
cpp复制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 idx = 0; idx < temp.size(); idx++) {
arr[left + idx] = temp[idx];
}
}
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);
}
归并排序的时间复杂度是稳定的 O(n log n),不管你输入的数据已经有序还是完全逆序,它都不会退化。代价是空间复杂度是 O(n),因为合并过程需要一个临时数组。这一点和堆排序、快速排序不同,是面试里经常被问到的对比点。
3.3 堆排序:利用二叉堆的“选择排序”
堆排序很多人觉得难,其实它的核心思想很简单:先建一个最大堆,然后反复把堆顶元素(也就是最大值)和当前堆的最后一个元素交换,再缩小堆的范围,调整堆结构。这个过程本质上是“不断找出最大值放到后面”,很像选择排序,只不过选择最大值的时候,用到了一个高效的数据结构——堆。
堆排序的时间复杂度是 O(n log n),而且空间复杂度是 O(1),因为它在原地排序。但它有一个小缺点:不稳定。什么叫不稳定?简单说,就是如果数组里有两个相等的元素,排序之后它们的相对顺序可能会发生变化。比如有两个都是 3 的元素,原本前一个 3 在后一个 3 前面,堆排序之后可能就跑到后面去了。
新手不用一开始就把堆排序的实现背得滚瓜烂熟,但一定要理解“堆”这个数据结构本身:父节点和子节点的下标关系、上浮和下沉操作、建堆过程。理解了这些,堆排序、优先队列、Top K 问题这些后面都会变得很容易。
3.4 图的最短路径:从 Dijkstra 到“负权值为什么不行”
热搜词里有“迪杰斯特拉算法负权值”,这确实是新手很容易绕进去的问题。Dijkstra 算法的思路是贪心:每次从当前未确定最短路径的节点中,选一个距离起点最近的节点,认为它的最短路径已经确定了,然后再用这个节点去松弛它的邻居。
那为什么 Dijkstra 不能处理负权边?我经常用一个例子来讲:假设起点是 A,有两条路到终点 C,一条是 A → C,长度 5;另一条是 A → B → C,长度分别是 6 和 -4,总长是 2。按 Dijkstra 的贪心策略,第一步会选中 A → C 这条长度为 5 的直接边,认为“C 的最短路径已经确定为 5 了”。可实际上,你从 A 先去 B,再走负权边到 C,总长度才 2。如果存在负权边,先被确定的最短路可能并不是真正的全局最短路,所以这个算法的前提就不成立了。
处理带负权边的图,经典算法是 Bellman-Ford,它用多次松弛来逐步逼近最短路径,时间复杂度相对高一些。如果是不存在负权回路的图,也可以用 SPFA。这里面细节很多,新手阶段不用一上来就全学会,但至少要把“Dijkstra 不能处理负权边”背后的道理想明白,这比背结论要强得多。
排序算法、最短路径这些内容,单独拎出来都够写好几篇文章。对于新手来说,最忌讳的是“听过等于会了”。我的建议是:每个算法都亲手实现一遍,再拿着各种特殊输入去测,直到你能自信地说“这个算法我彻底理解了”。
4. 从文字到代码:递归、分治与调试小技巧
4.1 递归的万能套路:终止条件、递归调用、合并结果
递归是新手从“看懂代码”到“写得出代码”之间的一道坎。但递归其实是有套路可循的。写递归时,你脑子里只需要三件事:第一,递归的终止条件是什么;第二,如何把当前问题拆成一个更小的子问题,然后调用递归;第三,拿到子问题的结果之后,如何把它和当前步骤合并成最终结果。
以“计算数组前 n 个元素的和”为例,可以这么写:
cpp复制int sum(vector<int>& arr, int n) {
if (n == 0) return 0; // 终止条件
return arr[n - 1] + sum(arr, n - 1); // 递归调用 + 合并
}
这里递归调用稍微“绕”一点,但逻辑是清晰的。不过我要提醒一句:实际工程中,这种简单的求和千万不要用递归写,因为深度会随着 n 增长,容易爆栈。递归更适合用在“问题天然具有递归结构”的场景,比如二叉树遍历、归并排序、快速排序、图的深度优先搜索。
那怎么判断一个问题适不适合用递归呢?一个很实用的标准是:你能把这个问题拆成一个更小但结构一模一样的子问题,并且这个拆分可以一直进行下去。如果你发现递归深度可能达到几万甚至几十万,那就要小心栈溢出了。
4.2 递归改迭代:用显式栈模拟
面试中有一个高频题:能不能把递归改成非递归?比如二叉树的非递归遍历。这里面的通用思路是:你用一个自己定义的栈,来代替系统函数调用栈。系统帮你保存“当前函数执行到哪一步”的状态,现在你自己把这个状态压入栈中,然后手动弹出处理。
我建议新手一开始不要直接去学那些看起来很“精妙”的非递归写法,而是先用最笨的方式:用栈结构模拟递归过程。比如中序遍历二叉树,先把根节点入栈,然后不断把左子树入栈,直到没有左子树为止;弹出栈顶节点时访问它,再把右子树入栈。这个过程多画几遍图,比死背代码有用得多。
不过我这里更想强调的是:在真实业务代码里,递归改迭代往往不是为了提高速度,而是为了避免调用栈溢出。尤其是处理大数据量的任务时,如果递归深度不可控,你要提前评估是否该改成迭代版本。很多新手写着写着就忘了这一层,上线之后在极端输入下崩溃,这种问题排查起来特别费劲。
4.3 调试算法代码的实用“土办法”
说到调试,很多新手只会用一行行打印,或者干脆盯着代码发呆。这里我分享几个对我帮助很大的土办法:
第一招,小数据手算。在纸上把算法的每一步走一遍,把关键变量的变化写下来,然后用代码跑一遍,看看输出是否和你手算的一致。如果中间某一步不一致,说明那一行的逻辑有问题。
第二招,加“日志”。在循环体或者递归函数入口,打印当前的变量状态。比如排序时打印每一轮的数组内容,一眼就会发现问题是出在交换条件上,还是出在循环边界上。
第三招,构造随机输入测试。写一个小的测试函数,生成大量随机数组,用你的算法排序,再用系统自带的 std::sort 结果进行比较。如果有不一致,说明算法有 bug。这个方法特别适合检验排序类算法的正确性。
我踩过一个很典型的坑:有一个排序算法,我写 while (i <= j) 时把边界多了一个等号,导致在特定输入下数组越界,运行时报出“segmentation fault”。当时我盯着代码看了很久都没发现,后来用随机输入测试才定位到是边界问题。从此我就养成了写“边界测试用例”的习惯,空输入、单元素、相等元素、逆序排列,统统跑一遍再提交。
5. 新手常见报错与排查技巧实录
5.1 数组越界和“段错误”
“段错误”(Segmentation Fault)应该是 C/C++ 新手最头疼的报错之一。它的常见原因之一就是数组访问越界。比如你在循环里用了 arr[i + 1],但循环结束条件没有给最后一个元素留出余量;或者用了 arr[n],但数组下标范围是 0 到 n-1。
排查段错误的方法:先定位到“崩”的那一行,可以加打印语句输出当前循环变量,缩小崩溃范围;再看这一行里访问的数组下标和当前数组的 size 是否匹配。不要一看到段错误就去问别人,自己动手定位的过程是你对代码结构理解加深的过程。
5.2 死循环:程序跑不完,一直卡住
死循环最容易出现在 while 循环的更新语句被跳过,或者二分查找的边界更新条件写错。比如用二分查找时,如果 left 和 right 的更新方式不对,可能出现 left 永远小于 right,循环就永远出不去。
我的经验是:任何含有循环的代码,都要问自己一句——“每一轮循环,变量是不是一定朝着终止条件的方向前进?”如果某一轮循环里变量没有任何变化,那这个循环大概率是死循环。遇到程序卡住时,最直接的办法是在循环体内打印关键变量,看看变量的变化趋势。
5.3 超时(TLE)和整型溢出
超时不一定只是算法复杂度过高,还可能是某个细节导致多做了很多次无用操作。比如频繁在循环里申请内存、使用过深的递归、或者在 unordered_map 里存了不该存的大对象。排查超时问题时,除了看复杂度,还要看代码里有没有“可优化的细节”。
整型溢出也是个容易被新手忽略的问题。比如两个 int 相加,结果超过了 int 的表示范围,就会得到错误的负数。int 能表示的最大值大约是 21 亿,很多算法题里的数值稍微一累加就会超。解决办法是把变量类型改成 long long,或者在计算前用更长的类型承接。我见过有人因为溢出问题 debug 了好几个小时的,所以在写累加、累乘逻辑时,养成先估算最大值的习惯非常必要。
5.4 常见问题排查速查表
| 现象 | 可能原因 | 排查建议 |
|---|---|---|
| 程序崩溃/段错误 | 数组越界、空指针、递归爆栈 | 加打印,缩小崩溃行;检查边界下标 |
| 输出结果错但逻辑“看起来对” | 边界条件处理错误、初始化问题 | 构造空/单元素/重复元素等边界测试 |
| 大输入下跑得很慢 | 算法复杂度过高、内部有重复计算 | 估算复杂度,考虑用哈希表/排序/分治优化 |
| 死循环 | 循环变量没更新、边界条件错误 | 在循环内打印变量,确认每轮都在向终止变化 |
| 数值结果神秘出错 | 整型溢出 | 把类型换成 long long,或避免大数直接相加 |
| 递归深层崩溃 | 递归深度过大 | 改迭代,或检查递归终止条件是否足够早 |
这张表我建议新手直接存下来。遇到问题的时候先对照一遍,比自己瞎猜效率高得多。
6. 把算法用到真实场景:一个从理论到实践的路线参考
6.1 用简单算法解决一个小问题:给“购物车”里的商品排序
很多新手学排序的时候都会问:学了冒泡排序、归并排序、堆排序,到底在项目里有什么用?我给你一个非常接地气的例子:假设你在做一个购物车功能,需要根据商品价格从低到高展示商品。如果商品数量不多,比如几十件,你用冒泡排序都能做,因为数据量小,性能差异根本看不出来。但如果你的商品列表有几十万条,需要加上分页,这时候你用 O(n²) 的排序就会明显变慢,这时 O(n log n) 的归并排序或者标准库的 std::sort 才是合理的选择。
这个例子的重点不在于“用哪种排序”,而在于让你意识到:算法不是孤立存在的知识,它是跟你对业务数据规模的理解绑在一起的。当你写业务代码时,先问自己“数据量大概多大,最坏情况是多少”,这个习惯会直接影响你对算法选型的判断。
6.2 从算法题到真实业务代码,差在哪里
算法题和真实业务代码之间的差异,主要就差在“约束条件”上。算法题通常有一个清晰的目标函数,输入输出也都定义得很明确;而业务代码要处理的是各种各样的输入异常、并发访问、内存限制、可维护性、可读性。比如你在算法题里用 unordered_map 就完事了,但在业务代码里你可能要考虑它的线程安全性、内存占用、哈希冲突被恶意利用的风险等等。
我见过一些同事,算法题刷得很溜,但写起业务代码来,毫无结构化意识,把所有逻辑塞进一个函数里,变量名全是 a、b、c。这里我想多说一句:算法思维训练的是你“拆解问题、找到最优路径”的能力,但它不能替代工程素养。学习算法的同时,也要刻意练习代码规范、模块化和可测试性。这两者不冲突,而是相辅相成。
6.3 后续进阶方向:基础打牢后再奔“领域算法”
如果你已经能熟练写排序、二分、递归、图遍历,对时间复杂度和空间复杂度也有手感了,那就可以考虑往更具体的领域方向走。比如热搜里的“深度学习算法”“粒子群算法原理”“PID算法”“A* 算法”这些,每一个背后都对应一个非常具体的应用领域。A* 算法在路径规划里常见,PID 在自动控制里常见,粒子群在优化问题里常见。它们都是“基础算法+领域知识”的复合体。
我给新手的建议是:不要太早分散注意力。先把最核心的基础算法吃透,然后根据你的兴趣方向,选择一两个领域深挖。比如你对机器人感兴趣,可以学 A*、Dijkstra、RRT 这些路径规划算法;你对人工智能感兴趣,可以从线性回归、逻辑回归入手,再慢慢接触树模型、神经网络;你对嵌入式控制感兴趣,可以学 PID、卡尔曼滤波。这样你的知识结构会形成一个“坚实的底座+清晰的方向”,而不是一堆零散名词的堆砌。
写到最后,分享一个我自己的体会:算法学习真的没有捷径,但绝对有方法。方法的核心,就是“慢”。学一个算法,不要急着刷下一道题,先把它的原理、边界、复杂度、代码、调试全过程走一遍,你会发现积累的速度反而是最快的。我刚开始学的时候,一天恨不得刷十道题,后来发现什么都没留下。现在带人,我更建议他们一周只吃透两三个算法,但每个算法都敢说自己“彻底想明白了”。这种扎实的感觉,会在你面试和工作中给你最大的底气。
