资讯详情

算法入门:从生活场景理解时间复杂度与常见算法范式

📅 2026/10/10 19:55:33 | 华诺云谱 👁 阅读
算法入门:从生活场景理解时间复杂度与常见算法范式
经常有朋友问我“算法到底是什么是不是只有数学天才或者程序员才需要学”我通常不急着下定义而是先反问一句你早上出门前是先穿袜子还是先穿裤子如果你有一套自己固定的顺序而且每次都能顺利出门、不会穿反那你其实已经在使用一个“算法”了。这篇长文不搞数学劝退也不堆术语我想用做菜、排队、找东西这些日常场景把算法是什么、怎么衡量好坏、有哪些常见套路、怎么变成能跑的代码、以及怎么系统学好一次性讲清楚。可能有人觉得“算法”这个词离自己很远但你在手机上点外卖背后有排序算法帮你按距离排序你在地图上查路线背后有路径规划算法你用搜索框找内容背后有检索匹配的机制。算法不是漂浮在课本里的抽象概念而是人类把经验整理成可重复执行步骤的产物。接下来的内容我从零开始拆解。1. 算法不是高深公式而是一份“怎么把事情做对”的操作说明书很多人一听到“算法”就联想到复杂的数学公式和天书般的代码这其实是最大的误解。算法的本质非常朴素它就是一套为了解决问题而设计的有穷、明确、可执行的步骤序列。只要符合这几个条件哪怕你不用电脑、不用代码在纸上画一画也算是在设计算法。1.1 从“番茄炒蛋”理解算法的三个关键零件我们可以把做番茄炒蛋当成一个算法来看。它天然包含三个关键零件输入、处理步骤、输出。输入番茄、鸡蛋、食用油、盐、葱花可能还有一把锅铲。处理步骤打蛋 → 切番茄 → 热锅倒油 → 先炒蛋并盛出 → 再炒番茄 → 把蛋倒回去混合 → 加盐调味 → 出锅装盘。输出一盘配着米饭吃的番茄炒蛋。如果你把步骤顺序换一下比如先放盐再打蛋或者番茄还没切就倒进油锅最后成品大概率不对劲。如果漏掉某一步比如忘了打蛋那端上桌的就是一碗炒番茄。这和算法很像步骤的次序和完整性直接决定结果的正确性。为什么我要从做菜讲起因为做菜这件事每天都在告诉我们同一件事一个复杂任务完全可以靠明确步骤被稳定复现。算法做的就是这个事只不过它不局限于厨房而是可以套用在数学问题、数据处理、路径搜索等无数场景中。1.2 算法随身带的五个“证件”有穷、确定、可行、输入、输出计算机科学里对算法有个经典描述一个合格的算法必须满足几个基本特性。我不打算把教材定义背给你听但你可以把这些特性理解为算法随身携带的五张证件。有穷性算法必须在有限步骤内结束。如果设计出来的规则永远跑不完那就是死循环不是算法。比如“走出迷宫直到出不去为止”听起来没问题但如果迷宫真的没有出口这个规则就无法保证结束。确定性每一个步骤都必须含义清晰、没有歧义。菜谱里写“加适量盐”就不是一个确定步骤因为不同的人对“适量”理解不同。算法要求同样的输入任何时候执行都能得到相同的结果。可行性每一步都得是执行者真正能做到的。“把水加热到一万度”对普通锅来说不可行不能算有效步骤。输入算法可以有零个或多个输入。比如一个“生成随机数”的算法输入可能只是一个种子值。输出算法至少要有一个输出结果。哪怕这个结果只是“成功”或“失败”也必须明确告诉使用者发生了什么。有两点值得注意第一“确定步骤”不等于“固定步骤”算法里可以有“如果条件成立做A否则做B”这种分支但条件本身必须能被确定地判断第二算法不要求每一步都机械简单但必须能被严格理解。1.3 算法是思想代码只是它的“译文”新手最容易混淆的两个概念就是“算法”和“程序”。我的理解是算法是解决问题的思想程序是这种思想在计算机上的具体表达。同一个“按身高从矮到高排队”的算法你可以用 C 写也可以用 Java 写还可以用 Python 写甚至可以不带任何电脑带着全班同学在体育课上用两两比较换位置的方式完成。我经常用一个类比来说明菜谱是算法厨师是执行者最后端出来的那道菜是程序的运行结果。只要菜谱写得足够清晰中餐厨师和西餐厨师都能做出同一道菜只是手法和工具可能不同。同样算法写好后可以由不同的程序员翻译成不同语言运行结果应该是一致的。分清这两个概念之后你的心态会好很多你不需要先精通一门编程语言才能学算法你完全可以先用文字或示意图把思路画明白再去考虑怎么用代码实现。反过来如果代码跑出来的结果不对也不一定是算法本身有问题可能是翻译过程中出了错。这种“先思想后实现”的思维是很多人没意识到的学习捷径。2. 算法好坏怎么比两把尺子叫时间复杂度和空间复杂度既然算法是一套解决问题的步骤那自然要问这套步骤好不好怎么判断它好不好最朴素的方法是掐着秒表让两个算法分别跑一遍看谁更快。但这里面有个问题同一个算法在不同电脑、不同数据规模下表现可能完全不同。所以我们需要两把更抽象的尺子时间复杂度和空间复杂度。2.1 为什么不直接掐秒表假设你有一个“把一万个数字从小到大排好”的算法。在最新款电脑上跑可能是 0.1 秒在一个老旧的设备上跑可能要 10 秒。难道说明算法变差了吗没有。硬件不同而已。再换一批数据如果这一万个数本来就已经排好序有的算法会跑得特别快如果它们是完全乱序有的算法又会特别慢。我们需要的不是一个会受环境干扰的“秒数”而是一个能描述算法本身趋势的指标。所以计算机科学家们约定不去数具体的执行秒数而是数这个算法在最坏情况下大概要执行多少次基本操作。这里的基本操作可以是“比较一次大小”“做一次加法”“移动一次数据”。于是我们得到一个关于数据规模 n 的函数。比如一个算法要执行 3n 2 次操作另一个算法要执行 5n² 2n 次操作后者在 n 变大时会迅速变慢。当然实际分析时不需要精确到每一项。我们更关心的是“随着 n 不断变大操作次数朝什么方向增长”。这个“增长方向”就是时间复杂度。2.2 大O表示法别被吓到它只是“增长速度”大O表示法是描述算法时间消耗最常见的方式。它不关心系数也不关心低阶项只看当 n 趋向无穷大时操作次数的主导增长项。O(1)常数时间。不管数据规模是一百还是一百万操作次数都固定。就像你直接打开冰箱门拿自己知道放在哪的那瓶牛奶不需要翻遍整个冰箱。O(log n)对数时间。每次都能排除掉一大半数据典型例子是二分查找。n 越大增长速度越慢非常理想。O(n)线性时间。操作次数和数据规模成正比。就像你在一排抽屉里找钥匙每次只能看一个抽屉最坏情况要把所有抽屉翻一遍。O(n log n)线性对数时间。很多优秀排序算法处于这个级别比如归并排序。O(n²)平方时间。双重循环两两比较往往属于这类。数据规模翻一倍时间变成原来的四倍。为了更直观我列一张表记号通俗含义典型场景O(1)与数据规模无关固定几步搞定数组按下标取元素O(log n)每走一步问题规模少一半在有序数组中查找O(n)从头到尾扫一遍在无序数组中找最大值O(n log n)分半处理再线性合并归并排序、快速排序平均情况O(n²)双层循环两两组合冒泡排序、朴素嵌套循环这里有个新手常犯的错误总觉得 O(1) 一定比 O(log n) 好O(log n) 一定比 O(n) 好。实际上大O描述的是规模趋近无穷时的趋势在小规模数据下一个常数很大的 O(1) 算法完全可能比 O(n) 算法更慢。就好比“打电话问朋友要密码”是 O(1)但如果每次都要打一通 10 分钟的电话还不如自己花 30 秒翻一下本子。2.3 同一个问题不同算法差距能有多大只讲理论太虚了我们来算一笔账。假设一个有序数组里有一百万个元素线性查找最坏情况要检查一百万个元素也就是 O(n)。二分查找每次比较把范围减半从 100 万减到 50 万、25 万、12.5 万……大约只需要 20 次比较。20 次和 100 万次这就是选对算法的力量。如果再放大到十亿个元素二分查找也只需要大约 30 次线性查找却可能要查十亿次。这种差距不是靠“电脑速度快一点”能弥补的因为增长速度完全不同。再来看排序。冒泡排序的平均复杂度是 O(n²)归并排序是 O(n log n)。当 n 等于十万时O(n²) 意味着约 100 亿次操作O(n log n) 大约只有 170 万次。虽然这些数字不是严格的真实时间但足以说明在数据规模上去之后算法的选择决定了你是秒开还是等半小时。当然二分查找有一个前提数组必须有序。如果你面临的是一个无序数组且排序代价太高线性查找反而是合理的选择。算法选择从来不是“贵的就一定好”而是“在给定条件和约束下找到最合适的方案”。2.4 时间不够空间来凑经典的空间换时间时间很重要但并不是唯一指标。算法还有一个维度叫空间复杂度也就是跑起来需要额外占用多少内存。很多时候为了把时间从“慢得无法接受”降到“快得流畅”我们愿意多花一部分内存这就是“空间换时间”。举一个最典型的例子哈希表。如果不加任何结构你要在一个数组里找一个指定值只能从头线性扫O(n)。但如果你在插入数据的同时再维护一张“值 → 位置”的哈希表查找时直接按索引去取时间就变成 O(1)。代价是什么是额外的存储空间。另一个例子是“记住已经算过的结果”。计算斐波那契数列时如果按最直接的递归方式F(5) 会重复计算 F(3) 多次规模一大就爆炸。但如果用一个数组把每个 F(i) 记录下来每次直接取之前的结果时间能从指数级降到线性级代价只是多存 n 个数字。这种思路再往前一步就是后面说的动态规划。理解了这两把尺子你已经比很多只会写代码的人高出一个层次。因为你能解释清楚一个算法为什么快、快在哪里一个算法为什么慢、慢在哪里。3. 六种常见算法范式解题套路比想象中少有一类问题初学者会特别困惑我连题都读完了却完全不知道从哪里下手。其实算法世界里真正被反复使用的思维模式没有想象中那么多。我把最常见的几种范式拿出来说它们就像解题的“套路”学一个就能解一批题。3.1 穷举法最笨也最可靠穷举法的思路是把所有可能的情况都试一遍从中挑出答案。比如找出一组数字中的最大值你可以从第一个数字开始挨个和当前最大值比较最后留下最大的。道理直白代码也好写唯一的缺点是慢。你可能觉得“穷举”太低级了。但在算法设计里穷举永远是最重要的兜底方案。很多复杂问题没有现成巧法时先暴力跑一遍能帮你确认问题的正确答案长什么样然后再想办法优化。而且穷举也不是必须蠢蠢地全试可以通过提前判断去掉明显不可能的情况这个操作叫“剪枝”。哪怕写的还是穷举效率也能提升不少。3.2 贪心算法每一步都选当下最好的贪心的中文名字很形象在每一步决策时都选择当前看起来最优的选项期望最终结果也最优。它的思维是“走一步看一步做眼前最好的选择”。但贪心很容易翻车。举个例子有 1 元、3 元、4 元三种面额的硬币要凑出 6 元。按贪心思路先取最大的 4 元剩下 2 元只能用两个 1 元一共 3 枚。可是最优解明明是 3 3只需要 2 枚。这说明局部最优不一定能得到全局最优。贪心能用的场景必须满足某种特殊性质比如“每一步的最优选择不会影响后面步骤的选择”。那贪心什么时候能用经典例子是活动安排你有一整天时间面对很多场讲座每场有固定的开始和结束时间最多能完整参加几场贪心策略是“每次选结束时间最早的讲座”这种情况下它是可行的。所以不要一看到“最优”字样就无脑贪心先问自己局部最优能不能推导出全局最优如果证明不了就要考虑更稳的算法。3.3 分治算法大事化小小事化了分治法的思想有三步把大问题拆成几个小问题分别解决小问题再合并结果。最有名的应用是归并排序。假设你有一摞乱序扑克牌。最简单的分治做法是把这摞牌从中间分成两半分别把两半排好序最后再把两个有序序列合并成一个有序序列。递归做下去直到每份只剩一张牌时它天然有序不需要再排。合并两个有序序列也容易每次从两个序列的头部挑一个更小的放入新序列。归并排序的时间复杂度是稳定的 O(n log n)无论输入是正序还是逆序表现都不差。它的核心价值在于很多时候“把整体排序”难“把两个已经有序的部分合并”却很简单所以分治能把复杂问题拆到不可再分再逐层合并回来。你只要记住“分”要能拆成同样类型的子问题“治”要能把子问题的解顺畅合并成原问题的解。3.4 动态规划记住结果别重复劳动动态规划是对“重复计算”的正面反击。它把一个问题拆成重叠的子问题然后把每个子问题的结果存起来后面不再重复算。还是用斐波那契数列举例。递归公式是 F(n) F(n−1) F(n−2)。如果你按 F(6) 递归去展开会发现 F(3) 被反复算了多次。数据一肥大这种重复计算会让程序慢到怀疑人生。动态规划的做法是从底部开始用一张表记录 F(0)、F(1)、F(2)……直到 F(n)每个值只算一次取表里的结果拼接。再举一个更生活化的例子上楼梯。假设每次可以走 1 阶或 2 阶想到达第 n 阶共有多少种走法实际上到达第 n 阶的最后一步要么来自第 n−1 阶跨 1 阶要么来自第 n−2 阶跨 2 阶。所以方案数 F(n) F(n−1) F(n−2)。这也是动态规划。动态规划通常有三个关键点重叠子问题问题可以被拆成重复出现的小问题、最优子结构整体最优解包含局部最优解、状态转移方程描述子问题之间怎么递推。很多人卡在“状态转移方程”上我的建议是先别急着写公式用小例子在纸上推一遍从 F(1)、F(2)、F(3) 一个个往后列规律自己会冒出来。3.5 回溯算法此路不通就退回重新走回溯算法的思想特别像走迷宫你从入口出发沿着一条路一直走走不通就退回上一个岔路口换一条路再试。它本质上是一种“带后悔功能的深度优先搜索”。经典问题是八皇后在 8×8 棋盘上放 8 个皇后要求它们互相不能攻击。一个皇后的攻击范围为同行、同列、同斜线。回溯的做法是逐行尝试在当前行从左到右试每一列如果这个位置和已放的皇后不冲突就放下去然后继续下一行如果放到某一行发现所有列都被冲突就返回上一行把上一行皇后的位置往右挪一位再重新试。这个过程会不断“剪枝”也就是一旦发现当前局面已经没有希望立刻放弃该分支。回溯算法非常适合解约束满足问题数独、N皇后、图的着色、括号生成等。初学回溯时最难理解的是“状态回退”递归进入下一层之前修改了某个状态等递归返回后要记得恢复。这个顺序一旦搞混结果就全乱。我会建议在纸上画出递归树用“前进、返回、改状态、再前进”的方式跟着走一遍比光看代码有效得多。4. 从生活问题到一段能跑的代码完整实现一个算法的真实过程前面聊了那么多思想和理论可能你还是觉得“我还是不知道怎么动手”。这一节我带你完完整整走一遍从一个实际需求开始到写出能运行的代码再测试边界情况。这个过程才是日常工作中真正每天都在发生的“算法落地”。4.1 把需求想清楚最大连续子数组和我选一个经典问题但它足够小适合展示完整思路给定一个整数数组请你找出和最大的“连续子数组”并返回这个最大和。所谓连续子数组就是原数组中连续的一段不能跳着取。举个例子数组是 [-2, 1, -3, 4, -1, 2, 1, -5, 4]最终答案是 6因为连续段 [4, -1, 2, 1] 的和是 6。这个问题在现实中有很多变体比如分析股票价格变化找出连续几日涨幅最大的时间窗口比如分析传感器数据流找出异常累积最明显的区间。在写代码之前先约定输入输出输入是一个整数数组输出是最大连续子数组的和。那空数组怎么办我下面先实现一个默认数组非空的情况遇到空数组时抛出异常或返回约定的 0关键是一开始就要讲清楚规则。这个约定过程就是算法的“确定性”要求。4.2 先写伪代码不要急着打开编辑器很多人一拿到题就开始敲代码我的习惯是先写“伪代码”用普通人能看懂的自然语言描述步骤。伪代码可以帮你把“思路”和“实现细节”分开。最直接的暴力思路是枚举所有可能的起点 i 和终点 j计算从 i 到 j 的和然后不断更新最大值。best 负无穷 for i in 0..n-1: for j in i..n-1: sum 0 for k in i..j: sum sum arr[k] best max(best, sum) return best这个三层循环的逻辑完全正确但它有三层嵌套时间复杂度是 O(n³)。如果数组有 1000 个元素就大约要执行 10 亿次运算明显不理想。但它的好处是容易想、容易写、不容易错。在实际工作中如果数据量不大先跑通一个暴力解完全没毛病。优化要建立在“正确”的基础上而不是一上来就追求高端方法。4.3 线性扫描实现从 O(n³) 到 O(n)上面暴力解慢是因为每次算 sum 都从零开始加一遍。仔细观察可以发现当 j 往后移动时新的连续和完全可以由上一个连续和接着加一个元素得到而不是重新算。于是我们得到一个更聪明的做法叫线性扫描。核心思路是遍历数组时维护两个变量current以当前元素为结尾的连续子数组的最大和。best到目前为止见过的最大和。每次遇到一个新元素x我们面临两种选择要么把x接到前面的子数组后面即current x要么从x重新开始一段新的子数组。取二者中较大者更新current再把它和best比较。写成 Python 就是def max_sub_array(nums): best float(-inf) current 0 for x in nums: current max(x, current x) best max(best, current) return best这个代码短得惊人但它背后是一个典型的动态规划思路。如果你用 dp[i] 表示以第 i 个元素结尾的最大子数组和那么状态转移方程就是dp[i] max(nums[i], dp[i-1] nums[i])每次只要记录上一轮的current就能递推全数组。这正好印证了前面的第 3 节重叠子问题存在于“前一段的最大和”中我们把它记下来避免重复计算。4.4 用边界条件检验算法是否正确写出代码只是第一步真正考验算法的是边界。我用几个测试用例跑一遍print(max_sub_array([-2, 1, -3, 4, -1, 2, 1, -5, 4])) # 预期 6 print(max_sub_array([-1, -2, -3])) # 预期 -1 print(max_sub_array([5])) # 预期 5 print(max_sub_array([1, 2, 3])) # 预期 6第一个例子对应 [4, -1, 2, 1] 的和 6。第二个全是负数可能有人会误以为答案是 0但按照“必须选非空连续子数组”的约定正确答案是所有负数里最大的那个也就是 -1。算法里current max(x, current x)保证了它不会把更小的负数累加下去best会在遍历中抓到最大的负数。第三个和第四个是退化情况只有一个元素和全正数线性扫描都能正确覆盖。注意如果数组可能为空best会保持负无穷这显然不是好结果。实际工程里应该提前约定空数组返回 0或者抛出一个明确异常。算法输入输出的确定性一定要在实现之前想好。4.5 从暴力到线性优化不是炫技我经常和一些初学者强调能想出 O(n) 解当然好但不要因此看不起暴力解。从三层循环到线性扫描不是靠灵光一现而是通过观察“重复计算”然后想办法消除。暴力解让你理解问题优化解让你理解结构。如果数组长度是 10 万O(n³) 的暴力解基本不可能跑完而线性扫描只需要遍历 10 万次几乎是瞬间完成。这个差距就是复杂度分析在实际问题中的价值。以后你遇到一个陌生问题先不管美观先写一个正确的笨解法再一点点优化这条路是走得很踏实的。5. 想真正学会算法三个常见误区加一条稳妥路线讲到这里你已经把算法的核心概念过了一遍。但我知道很多人真正关心的问题是那我该怎么继续学下去说实话算法这个领域劝退过很多人其中相当一部分不是智商不够而是走错了路。我总结三个最常见的误区再给一条我自己验证过很多次的学习路线。5.1 误区一一上来就啃数学证明把自己劝退算法教材里写满了正确性证明、复杂度推导这些东西当然重要但它们不该是初学者第一眼看到的内容。我见过太多人翻开一本经典算法书第一周就被归纳证明和渐近分析吓退了。我的建议是第一遍学算法以“能看懂思路、能写出代码、能跑通用例”为目标。比如你刚接触二分查找先用一个有序数组在纸上模拟中间的元素比目标大就砍掉右半比目标小就砍掉左半。你完全可以在不懂数学证明的情况下体会到“每次排除一半”的快感。等后续你有经验了再回头补证明那时候你会发现原来那些符号都是对过程的精确化表达一点都不吓人。5.2 误区二只刷题不归纳做了几百道还是不会很多同学喜欢用“刷题数量”衡量算法能力结果刷了几百道遇到新题还是懵。这背后的原因不是题目做少了而是没有把题目变成自己的套路。我建议每做一道题都问自己三个问题这道题属于什么类型用到了哪种算法范式还有没有其他解法当你做完一定数量的题目后试着按类型归档看到“数组里找两个数满足某种关系”先想哈希表看到“有序数组查找”先想二分看到“求最优解且有重叠子问题”先想动态规划。这种归纳能力比多刷十道题有用得多。例如最大子数组和这道题如果只看答案你记住了一个线性扫描代码但如果你把它归档到“动态规划状态转移方程是 dp[i] max(nums[i], dp[i-1] nums[i])”你以后遇到“最长递增子序列”“打家劫舍”这类问题时就会下意识寻找转移关系而不是束手无策。5.3 误区三只看不写觉得看懂了就是会了算法是动手技能不是阅读技能。看视频、看文章时你觉得自己全都懂了但只要一合上页面让你从头写一遍很可能卡在第一行。这个现象太正常了因为“看懂”是别人替你把逻辑走通了你的大脑还没有建立自己的路径。所以每次学完一个算法一定要亲手把它写出来最好用笔在纸上先模拟一遍过程再打开编辑器敲一遍代码。我还有一个土办法把一个算法讲给身边的人听。如果你能让他听懂说明你真的理解了如果你讲着讲着发现自己都说不圆那就说明还有漏洞需要补。这种输出式学习方法比反复看书管用得多。5.4 一条可行的学习顺序我给零基础学习者推荐这条路线按顺序来不容易崩溃先学基础数据结构数组、链表、栈、队列、哈希表、树、图。算法是长在数据结构上的不知道数组怎么按下标取元素、不知道链表怎么遍历后面很多算法根本无从谈起。再学排序与检索冒泡排序、插入排序、选择排序、快速排序、归并排序、二分查找。不要觉得“现在编程语言都有现成排序函数”就不需要学排序。排序是理解时间复杂度、递归、分治思想的最好土壤。再学算法范式穷举、贪心、分治、动态规划、回溯。前面我讲的那些套路值得每一个都亲手实现一遍并且找对应的练习巩固。最后培养复杂度直觉看到一段代码能大概说出它是 O(n)、O(log n) 还是 O(n²)。一开始不需要严格推导能区分量级就够了。5.5 每周吃透一个算法比一天刷十道更有用最后分享一点我的个人经验。我见过很多同学给自己定“每天刷五道题”的目标坚持两周就放弃了因为目标太粗反馈也来得太慢。后来我改用另一种方式每周只研究一个主题比如“本周主角是二分查找”。我会先找一个实际问题比如在一份已经按时间排序的温度记录中找到第一次超过某个阈值的日期然后用纸笔写出二分思路再用代码实现最后再找两三个变体题目比如“在旋转过的有序数组里找目标值”。周末的时候我会把自己对这一周算法的理解写成笔记确保下次再见到这类问题能立刻反应过来。坚持三个月左右算法的“套路感”就慢慢长出来了。算法不是一门靠死记硬背的学科它更像一套思维工具。当你带着“解决实际问题”的眼光去学而不是抱着“应付面试”的心态去背你会发现那些看似抽象的概念其实每一条都是从真实需求里长出来的。希望这个长文能把你的第一块基石放稳剩下的路咱们边写边学。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑