ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

十大基础算法:从排序到启发式优化的工程实践指南

十大基础算法:从排序到启发式优化的工程实践指南 1. 十大基础算法到底该排谁每当聊起基础算法总有人问网上有人列十大机器学习算法有人列十大排序算法还有人把动态规划、贪心、回溯全塞进去到底哪十个才算真正的基础我在一线写了十几年代码面试过几百人也带过不少新人。我想给的答案很直接所谓十大基础算法不是某个权威机构钦定的名单而是你解决实际问题时几乎绕不开的十类算法范式。它们不是知识树上的果实而是树根。你学得越深越会发现后面那些花里胡哨的神经网络、SLAM、渲染引擎、编译器优化底层到处是这些基础算法的影子。这份榜单是我个人根据出现频率 迁移价值 面试考频排出来的不追求绝对正确但求实用排名算法类型解决的核心问题典型代表1排序算法让数据有序快排、归并、堆排序2二分查找在有序空间里快速定位标准二分、lower_bound3动态规划多阶段决策最优化背包、LIS、编辑距离4贪心算法局部最优解全局最优区间调度、哈夫曼编码5分治算法大问题拆小问题归并排序、快速幂6回溯算法穷举所有可能的解空间八皇后、数独、组合7图论遍历与最短路节点关系的最优路径BFS、DFS、Dijkstra8字符串匹配文本中的模式查找KMP、BM9数据结构内建算法数据组织与高效增删查栈、队列、堆、哈希10启发式与迭代优化无解析解时的逼近求解模拟退火、粒子群、PID这篇文章适合谁看准备校招社招的候选人、刚入门想建立算法体系的初学者、以及写了几年 CRUD 想补内功的工程师。我会尽量把每个算法讲透它解决什么问题、核心思路是什么、代码长什么样、实战里有哪些坑。2. 排序算法不只是把数字排整齐2.1 排序为什么是算法之首有面试官喜欢问排序算法都学烂了有什么好问的但其实排序是理解算法复杂度、递归、分治、堆结构的最佳载体。你写一个冒泡排序和写一个 TimSort中间差着整个算法思维进阶史。基础排序里我建议你至少能手写三种冒泡排序理解思想、快速排序理解分治与退化、堆排序理解堆这种数据结构。C 里直接用std::sort当然快但如果你不清楚它底层是快排 插入排序 堆排序的混合体一旦遇到极端数据就不会排查性能问题。冒泡排序核心代码长这样适合教学但实战里很少用void bubbleSort(vectorint arr) { int n arr.size(); for (int i 0; i n - 1; i) { bool swapped false; for (int j 0; j n - i - 1; j) { if (arr[j] arr[j 1]) { swap(arr[j], arr[j 1]); swapped true; } } if (!swapped) break; // 已有序提前退出 } }那个swapped标记很多人会漏掉但它恰恰是工程化的关键——面对接近有序的数据加了这行能把最好复杂度降到 O(n)。2.2 快排的退化陷阱快速排序平均 O(n log n)但如果你每次选的 pivot 都是最大值或最小值它就会退化成 O(n²)。很多初学者不知道这一点直到在有序数组上跑出了超时。规避方法有两种一是随机选 pivot二是三数取中取首、中、尾三个数的中位数做 pivot。工程上经常两者结合。我见过一个线上服务线上问题就是有人手写快排没做随机化在用户ID接近有序的数据上直接卡死后来换成std::sort就好了。2.3 堆排序的隐蔽优势堆排序最容易被忽视的价值是不稳定但省内存。它在 O(1) 额外空间里完成排序更适合内存受限的嵌入式场景。热搜词里有9个值排序算法RTL实现说的就是硬件描述语言里做排序——那种资源紧张的环境下堆排和插入排序反而比快排更常用因为快排是递归的硬件里实现递归非常痛苦。实战心得工程中绝大多数排序直接调库但你要能看懂std::sort的行为——数据少用插入排序、递归深用堆排序兜底。这正是三种基础排序思想的合体。3. 二分查找比你想的难得多3.1 二分不只是数组里找数热搜词里二分算法单独霸榜不是没道理。二分思想远不止在有序数组里找一个数它本质是在一个单调的决策空间里快速收敛到边界。比如求平方根、查找旋转数组的最小值、在有序矩阵中搜索、Linux 磁盘寻道算法的电梯调度变种都是二分思路的延展。基础模板我建议背这个C 版本它找的是第一个满足条件的位置int lowerBound(vectorint nums, int target) { int left 0, right nums.size(); // 左闭右开 while (left right) { int mid left (right - left) / 2; if (nums[mid] target) { right mid; } else { left mid 1; } } return left; }注意left (right - left) / 2而不是(left right) / 2后者在 left right 溢出时直接崩。这个溢出坑在 C 和 Java 里真实存在LeetCode 早期版本的题目都踩过。3.2 二分的三个致命细节细节一边界定义。左闭右开[left, right)还是左闭右闭[left, right]两种写法都可以但你必须写之前就定死。我见过太多人写着写着混了结果while (left right)和right mid组合在一起直接死循环。细节二mid 的取整方向。查找左边界时 mid 向下取整没问题但查找右边界时最好向上取整否则当left 1 right时会死循环。细节三二分的应用前提是单调性不一定是有序数组。比如每个版本是否崩溃是一个 false 序列到 true 序列的切换虽然数组没有大小关系但答案具备单调性就可以二分。很多人学了二分只会做排序数组遇到最大值最小化问题就懵了其实那是二分的经典战场。3.3 二分之外的查找热搜词里有除了二分法还有什么算法这个问题很实在。有序数组查找可以插值查找类似查字典翻页和斐波那契查找但实际收益有限。真正值得关注的是哈希查找——O(1) 平均复杂度但它不要求有序也无法做范围查询。所以工程实践里的结论是要范围查询选 B 树索引要等值查询选哈希索引二分在两者之间承上启下。你把这个逻辑理清了数据库索引的原理也顺带懂了。4. 动态规划状态转移是灵魂4.1 动态规划到底在干什么动态规划DP是新手最头疼的算法之一因为没有固定模板每一道题都不一样。但如果你从暴力递归 备忘录切入会好理解很多。动态规划本质上是对暴力搜索的优化——把重复计算的子问题结果存下来用空间换时间。经典的斐波那契数列就是最简单的 DP但它太简单体现不出状态转移的威力。我用爬楼梯问题来演示从递归到 DP 的演进// 暴力递归O(2^n) int climbStairs(int n) { if (n 2) return n; return climbStairs(n - 1) climbStairs(n - 2); } // 备忘录递归O(n) int climbStairsMemo(int n, vectorint memo) { if (n 2) return n; if (memo[n] ! 0) return memo[n]; return memo[n] climbStairsMemo(n - 1, memo) climbStairsMemo(n - 2, memo); } // 自底向上 DPO(n) int climbStairsDP(int n) { if (n 2) return n; vectorint dp(n 1, 0); dp[1] 1; dp[2] 2; for (int i 3; i n; i) { dp[i] dp[i - 1] dp[i - 2]; } return dp[n]; } // 滚动数组优化O(1) 空间 int climbStairsOpt(int n) { if (n 2) return n; int prev 1, curr 2; for (int i 3; i n; i) { int next prev curr; prev curr; curr next; } return curr; }4.2 状态定义是最难的环节DP 的难点从来不是写代码而是定义状态。同样是背包问题前 i 个物品中选若干件容量为 j 时的最大价值这个状态定义新手想不出来很正常——这是经验问题。我分享一个实操经验拿到 DP 题先暴力递归递归函数的参数就是状态然后看递归里哪些重复计算再用 memo 数组或者 DP 表去重。这个暴力递归 - 备忘录 - 自底向上的三段法是通用的。我做面试官时最怕听到我会背包九讲因为背诵和真正理解是两回事。如果你能把状态定义、转移方程、初始化条件、遍历顺序四个要素讲清楚比背下十道题有用得多。4.3 遍历顺序里的小坑背包问题的遍历顺序直接决定算法正确性。01背包要求逆序遍历容量完全背包要求正序遍历容量。很多文章只是说记住就行我解释一下原因01背包的状态转移依赖dp[i][j]从dp[i-1][j-w]转移如果正序遍历容量dp[j]可能已经被本轮更新过相当于同一个物品被重复选入这就变成了完全背包。所以逆序遍历用的还是上一轮上一个物品的旧值。这道题我当年第一次写就栽了debug 到怀疑人生后来才搞明白。这类细节教科书里往往一笔带过但实战就是会被卡住。5. 贪心算法简单背后是证明5.1 贪心的直觉与陷阱贪心算法的代码往往比 DP 短得多但难在证明局部最优能推出全局最优。比如区间调度问题给你一堆区间选出尽量多的互不重叠区间。策略很简单——每次选最早结束的区间。int intervalSchedule(vectorvectorint intervals) { sort(intervals.begin(), intervals.end(), [](auto a, auto b) { return a[1] b[1]; }); int count 1; int end intervals[0][1]; for (int i 1; i intervals.size(); i) { if (intervals[i][0] end) { count; end intervals[i][1]; } } return count; }代码写了不到十行但能证明这个策略正确的人并不多。实际工作中那些看起来对但证明不了的贪心方案往往会在边界条件上翻车。5.2 贪心与 DP 的边界贪心和 DP 很容易被搞混。我习惯用一个简单标准来判断当前决策会不会影响后续决策如果不会大概率是贪心如果会就要考虑 DP。比如找零钱问题在人民币面额体系100、50、20、10、5、1下贪心是对的但在自定义面额如 1、5、11下贪心可能失败——凑 15 块贪心先拿 11结果要 4 张11111而正确解是 3 张555。这个例子特别适合跟新手讲清楚贪心的前提是局部最优就是你当前能做的唯一正确选择而这个前提必须靠数学证明或题目条件保障。5.3 实战里的贪心应用贪心算法在工程里极其常见。哈夫曼编码是贪心最小生成树的 Kruskal 和 Prim 是贪心Dijkstra 最短路本质也是贪心每次选距离最近的点扩展。热词里有个高级算法设计与分析如果你参加过这类课程就知道贪心章节作业的难度从来不在写代码而在证明你的贪心策略是安全的。我的经验是工作中遇到一个优化问题先试试贪心能不能解解不了再上 DP 或者搜索。因为贪心的代码量最小、bug 最少、可维护性最高哪怕它只能达到理论最优的 90%在工程里也比一个完美但复杂的 DP 要好。6. 分治与回溯递归的两副面孔6.1 分治分而治之再合并分治算法本质是把原问题拆成几个规模更小的子问题分别解决后合并结果。最经典的例子是归并排序和快速幂。快速幂这个例子特别漂亮把计算x^n从 O(n) 降到 O(log n)long long fastPow(long long x, long long n, long long mod) { long long res 1; while (n 0) { if (n 1) res (res * x) % mod; x (x * x) % mod; n 1; } return res; }这段代码的核心思想是x^10 x^8 * x^2用二进制的位运算把指数拆开。你有没有发现这和二分查找其实是同一个思想的不同面孔——都是利用信息复用来减少计算量。6.2 回溯暴力搜索的空间管理回溯算法是决策树的深度优先遍历典型场景是八皇后、数独、全排列、组合总和。回溯的核心是做选择 - 递归 - 撤销选择三步曲。以全排列为例vectorvectorint permute(vectorint nums) { vectorvectorint res; vectorint path; vectorbool used(nums.size(), false); backtrack(nums, used, path, res); return res; } void backtrack(vectorint nums, vectorbool used, vectorint path, vectorvectorint res) { if (path.size() nums.size()) { res.push_back(path); return; } for (int i 0; i nums.size(); i) { if (used[i]) continue; used[i] true; path.push_back(nums[i]); backtrack(nums, used, path, res); path.pop_back(); used[i] false; } }6.3 剪枝才是回溯的性能钥匙没有剪枝的回溯和暴力枚举没区别。我拿组合总和举例如果已知当前和已经超过 target后面更大的数显然也不可能了这时直接 return。再比如 N 皇后如果当前列、主对角线、副对角线都做了哈希标记判断冲突就是 O(1)而不是每次扫描整个棋盘。剪枝优化后的回溯经常能跑出远超预期的性能。LeetCode Hot100 里那些回溯题很多时候不是回溯本身慢而是你剪枝剪得不够狠。7. 图论BFS、DFS 与 Dijkstra 的取舍7.1 图的遍历先从 BFS/DFS 说起图的遍历是图论的地基。DFS 走递归或栈BFS 走队列。BFS 天然具备最短路径的特性——第一次扫描到目标节点时的步数一定是最短步数这在无权图中尤其好用。我做一个简单的模板对比// BFS 模板 void bfs(vectorvectorint graph, int start) { queueint q; vectorbool visited(graph.size(), false); q.push(start); visited[start] true; while (!q.empty()) { int node q.front(); q.pop(); for (int neighbor : graph[node]) { if (!visited[neighbor]) { visited[neighbor] true; q.push(neighbor); } } } }BFS 的层次遍历有一种很常见的变体就是记录当前层大小int step 0; while (!q.empty()) { int size q.size(); for (int i 0; i size; i) { // 处理当前层节点 } step; }这个变体在走迷宫最少多少步单词接龙最短路径里都是核心结构。7.2 Dijkstra 与 A* 的取舍热词里专门有A*算法与BFS算法的优缺点和Dijkstra算法我把三者放在一起说BFS适合无权图找最短路径无法处理边权重Dijkstra适合带权图但所有边权非负。核心是贪心——每次从未访问节点中挑距离最小的用优先队列优化后复杂度为 O(E log V)A*在 Dijkstra 的基础上加了启发式函数估算当前节点到目标的距离搜索方向性更强适合地图导航这类场景。A* 的缺点是启发式函数设计不好会退化——过度估计的话找到的路径可能不是最短路径我有个实际经验工程里做路径规划如果地图规模不大直接上 Dijkstra 最稳如果地图很大且对实时性有要求再考虑 A并仔细设计启发式函数。* 别为了炫技一上来就上 A*它的正确性边界比 Dijkstra 苛刻得多。7.3 图论在 AI 算法里的身影你可能觉得图论离 AI 很远其实不然。热词里的3DGS算法最经典的论文3D Gaussian Splatting涉及空间关系组织底层有大量图论和空间数据结构的思想。强化学习里的 PPO 算法训练智能体在环境中探索环境本身的关系网络也需要图算法做表示。更别提数据结构与算法本来就是 AI 工程师的必修基础。8. 字符串匹配KMP 到底优化了什么8.1 暴力匹配的问题字符串匹配看起来简单从文本串第一个字符开始逐个和模式串比较不匹配就右移一位重新比较。最坏复杂度 O(m * n)在文本很长、模式串也有很多重复前缀时会非常慢。KMP 算法优化的核心是当匹配失败时利用已经匹配的部分信息把模式串尽量多地右移而不是只移动一位。这个尽量多由 next 数组也叫部分匹配表决定。求 next 数组是 KMP 的难点vectorint buildNext(const string pattern) { int m pattern.size(); vectorint next(m, 0); int j 0; for (int i 1; i m; i) { while (j 0 pattern[i] ! pattern[j]) { j next[j - 1]; } if (pattern[i] pattern[j]) { j; } next[i] j; } return next; } int kmpSearch(const string text, const string pattern) { vectorint next buildNext(pattern); int j 0; for (int i 0; i text.size(); i) { while (j 0 text[i] ! pattern[j]) { j next[j - 1]; } if (text[i] pattern[j]) { j; } if (j pattern.size()) { return i - pattern.size() 1; // 匹配起点 } } return -1; }8.2 next 数组的直觉理解很多教程讲 next 数组讲得很晦涩。我用大白话说next[i] 表示模式串前 i 个字符中最长相等前后缀的长度。当第 i 个位置匹配失败时我们已经知道前 i-1 个字符是匹配的所以可以直接把模式串跳到前缀等于后缀的位置从而避免重复比较。举个例子模式串 ABCABX在 X 处匹配失败前面的 ABCAB 中最长相等前后缀是 AB长度 2所以直接跳到下标 2 继续比而不是回到 0。我当年学 KMP 的时候死记硬背看了一天看不懂后来自己把 next 数组的构建过程用纸笔一步步模拟了一遍然后才真正开窍。这类算法必须手推光看代码不行。8.3 KMP 与工程现实的差距说实话如果你用的是 Python、Java、C 这种高级语言直接调find或strstr就够了底层实现可能比手写 KMP 更高效。但 KMP 的思想仍然值得掌握因为在正则表达式引擎、编辑器高亮、基因序列比对这些场景里KMP 的思想会以各种变体出现。热词里的CDCL算法SAT求解器核心算法其实也借鉴了类似的思想——通过记录冲突和学习子句来跳过大量无效搜索。可见**利用已获取的信息减少重复计算**是贯穿所有算法的一条主线。9. 数据结构中的内建算法真正的高频武器9.1 为什么数据结构也算算法你可能会问栈、队列、哈希表不是数据结构吗怎么跑进算法榜单了我说个实在的数据结构本身就是操作序列的规则它们的访问方式、增删查策略、扩容策略每一个细节都是算法。比如栈它的算法思想在于后进先出这个访问策略——函数调用栈、括号匹配、表达式求值、撤销操作全是靠这个策略工作的。队列则是先进先出适用于 BFS、消息队列、任务调度。堆优先队列更典型插入 O(log n)、取最值 O(1)、删除 O(log n)C 里priority_queue底层就是堆。热词里堆排序算法和pid算法蹭在一起不是偶然很多实时控制场景里堆就是用来维护最值的。9.2 哈希表的扩容与冲突哈希表是工程中出现频率最高的数据结构但很多人只是会用没想过它的实现细节。哈希表的两个核心问题是哈希函数设计和冲突处理。Cunordered_map用的是链地址法拉链法当某个桶链过长时会触发 rehash。我踩过一个真实的坑用unordered_map存大量自定义对象但没重写哈希函数导致所有对象都映射到同一个桶上查询复杂度退化到 O(n)一个离线任务直接跑了几小时。后来换成了合适的哈希函数几十秒就完了。哈希函数选得好不好直接决定哈希表是 O(1) 还是 O(n)。9.3 手写数据结构在面试中的地位面试时经常考手写 LRU Cache或手写最小栈。LRU 的标准解法是哈希表 双向链表哈希表提供 O(1) 查找双向链表提供 O(1) 的插入和删除。这个组合看起来简单但把两个结构间的指针维护理顺比想象中容易出错。热词里有数据结构排序算法这种组合词说明大家也意识到单独背排序或单独背数据结构都不够关键要理解数据结构如何支撑算法的运行效率。比如图算法依赖邻接表链表/数组Dijkstra 依赖优先队列堆BFS 依赖队列这些依赖关系才是真实项目中做技术选型的依据。10. 启发式与迭代优化传统算法与现代AI的桥梁10.1 粒子群与模拟退火热词里有粒子群算法原理也有模拟退火算法还有基于混合SPSS-PSO-SVM模型烟气软测量算法软件。这些词看着高大上本质上都干同一件事在解空间巨大且没有解析解时用一种启发式策略逼近最优解。模拟退火的直觉很讨喜把求解过程类比成金属退火温度高时接受差解的概率大允许跳出局部最优温度逐渐降低接受差解的概率变小最终收敛。粒子群算法的直觉则是一群粒子在解空间里飞行每个粒子记住自己的历史最优位置同时群体共享全局最优位置通过个体认知 社会认知两个分量更新速度。import random import math def particle_swarm_optimization(objective, bounds, n_particles30, iterations100): dim len(bounds) particles [] velocities [] pbest [] for _ in range(n_particles): pos [random.uniform(b[0], b[1]) for b in bounds] particles.append(pos) velocities.append([0.0] * dim) pbest.append(pos[:]) gbest min(pbest, keyobjective) w, c1, c2 0.7, 1.5, 1.5 for _ in range(iterations): for i in range(n_particles): for d in range(dim): r1, r2 random.random(), random.random() v w * velocities[i][d] \ c1 * r1 * (pbest[i][d] - particles[i][d]) \ c2 * r2 * (gbest[d] - particles[i][d]) velocities[i][d] v particles[i][d] v if objective(particles[i]) objective(pbest[i]): pbest[i] particles[i][:] if objective(pbest[i]) objective(gbest): gbest pbest[i][:] return gbest这类算法的调参是一门玄学w、c1、c2 的数值会影响收敛速度和探索能力。我的建议是先用默认参数跑通再观察收敛曲线决定调参方向不要一上来就死磕参数。10.2 PID 与卡尔曼滤波工业界的常青树热词里PID算法、增量式PID、卡尔曼滤波单独成词说明它们在实际工程中的需求非常大。PID比例-积分-微分控制是工业控制领域最基础的闭环算法。你别看它公式只有三行整定参数Kp、Ki、Kd却能让工程师秃头。增量式PID在电机控制里很常见它输出的不是绝对量而是增量避免大幅跳变。卡尔曼滤波则是传感器融合领域的王者算法。NTC温度传感器、IMU姿态解算、GPS惯导融合底层都是卡尔曼滤波或它的变体扩展卡尔曼、无迹卡尔曼。它的核心是预测 更新两个步骤用协方差矩阵描述不确定性把多个带噪声的观测融合成一个更准确的估计。10.3 机器学习算法也是算法热词里有机器学习算法回归算法Kmeans聚类强化学习算法PPOYOLO算法这些超出了传统基础算法的范畴但它们的根基仍然是基础算法。Kmeans 的 EM 思想与贪心/迭代优化有千丝万缕的联系YOLO 的 anchor 设计背后是枚举与剪枝PPO 的策略梯度更新也离不开梯度下降这个优化算法。所以我不是把十大基础算法当成一个封闭名单而是说你如果把十大基础算法吃透了往后学任何新算法都有抓手。它们解决的是如何设计计算过程的元问题而新算法只是把不同的元问题组合方式套在了具体场景上。11. 算法怎么学才算真学会11.1 学习的路径建议结合我带新人的经验我建议的学习路径是第一步把排序和二分练到闭眼能写的程度第二步用递归专题打通分治和回溯递归是这两类算法的共同根基第三步把 DP 的暴力递归 - 备忘录 - 自底向上三步法练熟第四步刷图论模板题BFS/DFS/Dijkstra 各手写一遍第五步学数据结构底层的实现细节尤其是堆、哈希、双向链表第六步有余力再看启发式算法和 AI 算法的入门教程热词里的信奥算法和算法设计与分析其实对应的就是这个路径。信奥信息学奥赛非常强调基础算法的手写能力和数学功底而大学里的算法设计与分析则更侧重复杂度分析和正确性证明两者都要重视。11.2 刷题的正确姿势很多新手刷题有个误区一道题只看题解看懂了就觉得会了结果三天后再做仍然写不出来。我推荐一个笨办法拿到题先想 15 分钟想不出来看题解但要关掉题解自己写写完提交AC 了也别急着下一题花 5 分钟总结这题属于哪类范式状态转移方程/贪心策略/剪枝条件是什么一周后再重新做一遍如果还能独立写出来才算真正掌握LeetCode Hot100 这个刷题清单是公认的高质量题库里面覆盖了本篇文章提到的绝大多数算法类型。但说实话刷题数量不是关键你能不能用一句话讲清楚每道题的核心套路才是关键。11.3 避免会做但不会用我见过不少候选人算法题刷了三百道但到了实际项目里遇到一个计算两个地理位置之间的距离并找最近的点的问题还是直接用双重循环。明明用分治思想类似最近点对能把复杂度从 O(n²) 降到 O(n log n)他却想不到。问题出在哪刷题时题目已经帮你贴好了标签这题是动态规划但真实世界里没人会给你贴标签。所以平时练习时我建议你多做标签模糊的练习题或者干脆从业务问题反推这个问题的数据规模是多少操作类型是什么对实时性要求有多高这些问题的答案会自然指向某个算法范式。最后的提醒写到这里我想再啰嗦一点。十大基础算法看起来是基础知识但正因为它们太基础反而容易被低估。热词里有算法研发过程代码管理这个词说明大家的关注点已经在从会不会写算法转向怎么在团队里规范地做算法研发——这是好现象说明你开始从会用走向工程化。我个人在实际项目里最大的体会是算法能力不是靠背模板获得的而是在反复调试中把每个细节揉碎了理解之后才能真正内化的。比如你真正调试过一次二分查找的死循环就再也不会在边界条件上翻车真正在线上环境排查过一次哈希碰撞导致的性能劣化就不会再轻视哈希函数的设计。所以别怕慢别怕 debug别怕把一道题反复做三遍。基础算法的价值不在于让你在面试里默写代码而在于当你面对一个全新的、复杂的问题时能下意识地用排序、二分、DP、贪心、分治、回溯、图论这些标准件去拆解它、组合它最终拼出一个可靠方案。这份十大名单虽然是旧的但算法思想永远是新的入场券。
返回列表