ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

算法效率的尺子:一文搞懂时间复杂度和空间复杂度

算法效率的尺子:一文搞懂时间复杂度和空间复杂度 1. 为什么“算法快不快”不能靠感觉1.1 先抛一个问题两段代码谁更快如果你写过一段循环肯定听过一个说法算法快慢不看秒表看时间复杂度和空间复杂度。这句话说起来容易真让你解释什么是 O(n)、什么是 O(n^2)、两者差多少很多人又卡住了。假设你要从一个长度为 n 的数组里查一个数。第一段是线性循环第二段是二分查找。很多初学者会说“线性循环简单一定更快”可一旦 n 变成 1000 万线性循环可能要跑几秒钟二分查找几乎瞬间返回。问题出在哪出在我们把“快到肉眼没感觉”当成了“快”而没有用一个随数据规模变化的标准去衡量。这就是时间复杂度和空间复杂度存在的意义。它们不是面试八股文里用来背的术语而是描述一段算法“消耗资源如何随着数据规模增长”的语言。说得再直白一点不讨论数据规模就聊快慢等于不讨论距离就聊油耗。O(n)、O(logn) 这样的记号就是给你写出的每段循环、每个递归、每层缓存做一次“体检”告诉你在数据变大时代码是跑成一条平线还是跑成一条陡峭的上坡路。1.2 复杂度到底“度量”了什么时间复杂度的度量对象不是秒也不是毫秒。真实运行时间受到机器 CPU、内存、语言、编译器的影响同一个递归在 Python 里慢到爆换 C 语言后又快得惊人。复杂度分析划掉了这些噪声只保留最核心的关系计算步骤的数量 T(n) 和输入规模 n 之间是什么关系。比如一个简单循环要执行 n 次T(n) 就是 n 的一次方双重循环要执行 n^2 次T(n) 就是 n 的二次方。空间复杂度同理度量的是算法在运行过程中额外申请的内存单元数量和输入规模之间的关系。这个“额外”很关键通常我不把输入本身占用的空间算进去只算为了完成任务额外开辟的那部分内存比如辅助数组、递归调用栈、中间变量。明白这一点后续才不会把空间复杂度算错。我在实际工作中还发现很多人学复杂度时只记住了“O(n)”“O(n^2)”的写法却不知道它背后是一套“渐进分析”的思想。等你真正理解了这套思想看一段代码脑子里就能浮现出它在大数据量下的表现而不是靠实验去猜。1.3 我在刷题和项目里为什么反复强调这个概念我自己的体会是很多代码在样例数据上运行没问题一上生产环境就超时或者内存告警绝大多数不是某个语法写错了而是复杂度选错了。举个很常见的例子字符串拼接。如果在一个循环里用 Python 的字符串加法做累积比如 result s[i]表面上是 O(n) 的循环但字符串不可变每次加法都要重新申请内存并复制旧内容整体退化成 O(n^2)。改成列表收集再 join复杂度就回到 O(n)。这种问题靠肉眼 debug 根本看不出来只有心里时刻带着复杂度分析才能在生产环境“爆雷”之前把它拦下来。所以这篇文章我想带着你把时间复杂度和空间复杂度从头捋一遍包括它们怎么定义、怎么计算、常见误区是什么以及一些可以拿进项目里直接用的判断技巧。适合刚开始学数据结构的同学也适合正在复习算法、准备面试或想系统地给存量代码做一次体检的开发者。2. 时间复杂度别再看秒表看增长趋势2.1 渐近分析为什么只看最高阶项时间复杂度用的是“渐进分析”英文叫 asymptotic analysis。意思是当 n 足够大时T(n) 中影响最大的部分就代表了整个算法的趋势。比如 T(n) 3n 10n 从 10 涨到 1000常数项 10 基本无感n 再涨到 100 万3n 才是决定资源消耗的主角。于是我们记作 O(n)。严格的数学定义涉及极限和常数倍数存在正常数 c 和 n0使得当 n n0 时f(n) c*g(n)那么 f(n) O(g(n))。听起来像数学分析但你可以把它理解成一个“上界”算法的实际消耗最多不会超过某个数量级的若干倍。工程里我们常说某个算法是 O(n)其实就是在表达它的运行时间大致随 n 线性增长。为什么不纠结常数因为当 n 足够大时常数项和低阶项对增长的“形状”几乎没有影响。但这里有个前提n 足够大。如果数据集永远只有几百条O(n^2) 甚至可能比 O(n) 跑得更快因为常数更小。这一点后面第 5 章会展开它是很多人误用复杂度的地方。2.2 一张表看懂常见复杂度复杂度增长趋势典型场景O(1)不随 n 变化数组按下标访问、哈希表读写O(logn)缓慢增长二分查找、平衡二叉搜索树O(n)线性增长单层循环遍历数组O(nlogn)略超线性归并排序、快排平均情况O(n^2)平方增长两层嵌套循环、冒泡排序O(2^n)爆炸式增长递归枚举子集O(n!)极难承受全排列枚举这张表建议记在脑海里。遇到一段代码先估计它属于哪一档再判断能不能接受。我面试时常看到有人把 O(nlogn) 说成 O(logn)虽然只是一字之差但 n 到 10 万时两者差了约 10 万倍的计算量。这个错不只是笔试扣分在系统设计里真会造成灾难。2.3 手把手推导循环、嵌套、顺序结构算代码的时间复杂度我有三个固定套路。第一只看最深层那个循环的基本操作。如果循环体是 O(1)那整层循环就是 O(循环次数)。例如for i in range(n): print(i)print 是 O(1)循环 n 次结果是 O(n)。第二嵌套循环要把层数相乘。外层 n 次内层 n 次那就是 n*n n^2for i in range(n): for j in range(n): print(i j)如果内层循环次数和外层变量有关比如 for j in range(i)总次数是 01...n-1 n(n-1)/2取最高阶还是 O(n^2)。这一点很多人算错他们以为“内层不是 n 次所以不是 O(n^2)”实际等差求和之后仍然是二次方。第三顺序结构取最大的那部分。如果一段代码先做 O(n) 的循环再做 O(n^2) 的嵌套循环总的复杂度是 O(n n^2) O(n^2)。低阶的那一部分被高阶“吞并”没必要单独写出来。递归的情况稍微特殊它要写出递推式。比如斐波那契数列的朴素递归T(n) T(n-1) T(n-2) O(1)这个递推式的解是 O(2^n)。如果用了记忆化每个子问题只算一次变成 O(n)。这也能看出来数据结构记不记中间结果往往是“能用”和“根本跑不完”的区别。2.4 最坏情况、平均情况和均摊情况O 记号可以用于最坏、平均、最好任何一种情况只是工程里默认说“复杂度”时一般指最坏情况。因为最坏情况是你能承诺的上限系统设计必须保障最坏情况下也不崩。比如哈希表查找平均是 O(1)但在大量冲突时最坏可能退化成 O(n)。如果只按平均复杂度设计接口有一天数据被恶意制造冲突整个服务就挂了。所以很多哈希表的实现会在冲突过多时触发 rehash这就是用均摊复杂度来保证整体性能。均摊分析指的是在一系列连续操作中把偶尔的高成本操作平摊到每次操作上典型例子是动态数组 append大部分时候 O(1)触发扩容时 O(n)但均摊下来每次追加还是 O(1)。这三个“情况”要分清面试里十有八九会被问。3. 空间复杂度内存不是让你随便造的3.1 空间复杂度到底在统计什么很多初学者以为空间复杂度就是“变量个数”这是片面的。空间复杂度统计的是算法运行过程中额外需要的内存单元增长趋势和输入规模 n 的关系。这里要抓住两个词“额外”和“趋势”。输入数据本身占的内存不计入或者更准确地说如果输入是数组数组本身确实占用 O(n) 内存但那是题目给你的不是你算法消耗的额外资源。分析算法的时候我们只关心你为了完成计算额外创建了哪些结构比如辅助数组、哈希表、递归栈。如果一份代码里输入数组是 O(n)你新建了一个同样大小的 result 数组那额外空间就是 O(n)总空间是 O(n)如果你只用了几个临时变量哪怕输入数组本身是 O(n)额外空间也是 O(1)。我见过很多人在算法题解析里把这两者混在一起导致明明空间 O(1) 的算法被当成 O(n)或者反过来把输入数组自带的 O(n) 算成算法额外开销。搞清楚口径再评判代码。3.2 常见空间复杂度档位附典型例子空间复杂度典型情况O(1)只使用有限几个临时变量交换、计数器O(logn)递归深度为 logn二分查找递归O(n)新建一个长度 n 的辅助数组、哈希表存 n 个元素O(n^2)二维矩阵、邻接矩阵存图最需要留意的是递归调用栈。递归每调用一层系统栈帧就要保存当前参数、局部变量和返回地址。递归深度是 d那调用栈空间就是 O(d)。和循环不同循环不额外占用调用栈。比如快速排序如果写成递归平均递归深度是 O(logn)空间复杂度 O(logn)如果每次选的基准都很差递归深度退化成 O(n)空间可能变 O(n)。同样一个算法实现方式不同空间复杂度可能完全不同。3.3 递归的调用栈空间为什么是隐形成本递归的空间成本往往被忽视因为你看不到那个“栈”它不像数组一样直观。但一旦递归深度变大它是最容易导致内存溢出的部分。举个例子计算 1 加到 n循环写法只用一个累加变量空间 O(1)递归写法每层保存一个中间状态深度 n空间 O(n)。函数功能一样但底层资源占用完全不同。我踩过一个坑用递归遍历一颗深度很大的 JSON 树Python 默认递归深度限制是 1000 左右数据一深就直接 RecursionError。后来我改成显式栈的迭代写法虽然代码看起来啰嗦一点但再也不用担心栈溢出。这就是空间复杂度分析在真实问题里的价值它能提前告诉你某个写法撑不撑得住。3.4 空间换时间到底值不值复杂度分析里最常用的策略就是空间换时间。比如把递归改成迭代并显式使用栈或者用哈希表预计算能够减少时间但多占了内存。一个非常经典的例子是“两数之和”问题暴力解双重循环是 O(n^2) 时间、O(1) 空间先用哈希表遍历一次时间复杂度降到 O(n)但空间变成 O(n)。实际业务中我也常这么干比如接口里需要频繁查询某个映射关系我会提前把数据加载到一个字典而不是每次循环扫描。这多出来的几十 MB 内存换来的是低延迟接口值不值取决于业务并发量和数据规模。分析能力在这儿就派上用场了你要能清晰地算出到底换来了多少时间代价又是多少空间才好做这个取舍。4. 复杂度分析实操四个常见代码模板逐行拆解4.1 从一个数组求和开始确认 O(n) 时间、O(1) 空间def sum_array(arr): total 0 for x in arr: total x return total对于输入数组长度 n这个代码只做两件事一个变量 total 初始化和累加。每次累加是 O(1)循环 n 次时间 T(n) n取最高阶是 O(n)。额外空间只有一个 total 变量不随 n 变化空间复杂度 O(1)。如果有人说数组本身占了 O(n)所以要算 O(n) 空间那要看口径。题目给你 arrarr 本身不算算法额外空间。但如果你在函数里复制了一份 arr比如 arr2 arr[:]那额外空间就是 O(n)因为这份复制是你自己开的。这道题想考的就是你能不能在遍历时不开辅助结构。4.2 嵌套循环但内层次数减半为什么还是 O(n^2)for i in range(n): for j in range(i 1, n): process(i, j)内层循环平均次数是 n/2所以总次数约为 n*(n - 1)/2也就是二分之一 n 平方再减去二分之一 n。渐进复杂度只保留最高阶常数二分之一不影响所以仍然是 O(n^2)。很多人在这里会犯迷糊既然内层不是满的 n 次是不是就算 O(nlogn)不对要想到等差求和。1 2 ... n-1 n(n-1)/2求和之后最高项是 n^2。同样的逻辑也适用于很多三角形遍历。真正能从 O(n^2) 降下来的关键是让内层循环的规模不是 n而是 log n比如二分搜索的嵌套。4.3 二分查找时间和空间怎么同时算def binary_search(nums, target): left, right 0, len(nums) - 1 while left right: mid (left right) // 2 if nums[mid] target: return mid elif nums[mid] target: left mid 1 else: right mid - 1 return -1每循环一次搜索区间缩小一半最多需要 log2(n) 次循环所以时间复杂度 O(logn)。空间上只用了 left、right、mid 三个变量都是常数个额外存储空间复杂度 O(1)。要注意这个 O(logn) 的对数底数并不重要因为换底公式只差一个常数O(log2 n) 和 O(log10 n) 是同一个复杂度。工程里如果你想在每次循环时打印当前区间所有值那多占的空间就不再是 O(1) 了。所以“只改一行”也可能改变复杂度。4.4 递归求和调用栈是隐形空间def sum_recursive(arr, idx): if idx len(arr): return 0 return arr[idx] sum_recursive(arr, idx 1)这段递归的时间复杂度是 O(n)因为每个元素被访问一次。空间复杂度是多少递归深度是 n系统调用栈要保存 n 层帧所以额外空间是 O(n)而不是 O(1)。同一个求和逻辑用循环写只要 O(1) 空间用递归写会多占 O(n) 栈空间。这就是为什么在一些嵌入式或内存受限环境里不推荐无脑递归。如果你用尾递归且编译器支持优化可能栈空间降到 O(1)但 Python 默认不支持尾递归优化所以别把理论当实现。5. 常见误区与踩坑实录为什么你分析的和实际不符5.1 误区一忽略常数直接在项目里套公式理论复杂度告诉我们 O(n) 一定比 O(n^2) 好吗不一定。如果 O(n) 的常数是 1000而 O(n^2) 的常数是 1那么 n 小于 1000 时O(n^2) 可能反而更快。举个例子单个循环里做了很重的正则匹配另一个只有双层循环但做的都是简单位运算小数据集上可能是后者赢。复杂度分析判断的是“渐近行为”不是“绝对速度”。所以做性能优化时要结合 n 的实际数量级。我一般在搜索引擎耗时、数据库连接这类场景里先估算 n 的量级再决定要不要动代码结构。5.2 误区二把 O(logn) 记成 O(1)二分查找确实很快但它是 O(logn)不是 O(1)。当 n 从 10 亿变成 20 亿二分查找大约多跑一次。这仍然是随 n 变化的。在很多实时系统里log n 的增长往往可以接受但你不能把它当成常数一次次随意叠加。如果循环里套了二分复杂度是 O(nlogn)而不是 O(n)。面试时你如果把 O(nlogn) 说成 O(n)那后面系统设计题基本就崩了。5.3 误区三空间复杂度只数变量前面说过空间复杂度要统计“额外占用的内存”随 n 变化的量级。递归调用栈、动态数组扩容、字符串拼接中间结果、日志数组等都会产生额外空间但很多人统计时只想着“我定义了三个变量”于是把 O(n) 写成 O(1)。我调试过一段日志系统它把每条日志先存入内存列表再批量写库n 涨上去之后内存先撑不住。这个内存消耗就是列表的 O(n)。所以分析空间时要把所有临时对象过一遍尤其是那些在循环里不断增长的容器。5.4 误区四把平均复杂度和最坏复杂度混为一谈哈希表、快排的平均复杂度都很漂亮但实际可能被输入数据“打爆”。快排如果选第一个元素当基准而输入恰好是近乎有序的数组递归深度会退化到 O(n)时间复杂度退到 O(n^2)。解决方式包括引入随机化基准或三数取中。这类问题在面试中经常被问“快排什么时候最坏”但到了真实项目里很多线上问题就是“数据分布刚好命中最坏情况”导致的。因此复杂度分析不能只报平均还要把最坏情况作为兜底。5.5 排查性能问题时我是这么定位的实际排查线上超时我不会一上来就用性能分析工具。先在大脑里过一遍核心路径数据量级多大循环层数多少有没有隐藏的字符串复制、哈希冲突、递归深度。比如一段 Python 代码莫名其妙慢我会先怀疑 list 的 insert(0, x)因为它要把后面所有元素后移单次 O(n)循环 n 次就成了 O(n^2)。改成 collections.deque 的 appendleft 后就变成 O(1)。这种问题通过复杂度分析就能立刻定位再上 cProfile 只是验证。所以把复杂度分析练成“肌肉记忆”其实是排查和优化代码的第一道防线。6. 复杂度分析在真实项目里的使用手册6.1 先估量级再选算法一份可用清单n 大概多少100、1 万、100 万、还是 10 亿不同量级策略完全不同。单次操作重不重如果循环体里有 IO、网络请求、正则、加解密即使复杂度是 O(n)也未必比一个复杂度 O(n^2) 但循环体极轻的算法快。有没有隐藏的复制或容量问题字符串拼接、列表头部插入、slice 复制、正则回溯都会把表面复杂度放大。能不能用哈希表、排序、双指针、前缀和等技巧来降维度降复杂度之前先想清楚数据是否满足这些技巧的使用前提。有没有必要优化如果数据量只有几百与其调算法不如先把逻辑写清楚。先做能工作的版本再按复杂度分析决定要不要优化。这份清单可以贴在工位旁边。遇到性能问题按顺序过一遍比乱试优化方式更高效。6.2 用复杂度思维做架构取舍空间换时间的实际案例我做过一个活动推荐接口原来每次请求都要在内存里遍历几千个候选商品再逐个算分取 Top K。因为候选数量不大单次是 O(nlogn)问题不大。后来候选涨到几十万接口毛刺越来越多。我直接改成在 Redis 里维护一个有序集合每次写入或更新商品时就把分数更新进去查询时直接取 Top K把接口耗时的复杂度从 O(nlogn) 变成了接近 O(k)。代价是写入链路多一次 Redis 操作还要定期清理冷数据。这就是一个非常典型的空间换时间把计算提前到写入阶段把数据结构多占的那一点存储视为折旧成本。如果你不会算复杂度就很难评估这样的改造到底划不划算。6.3 我在实际工作中最常用到的三个小技巧第一个技巧是“只看最深的循环和最大的空间申请”。分析时间先看内层循环体是否是 O(1)再看总次数分析空间先看有没有长度随 n 增长的容器或递归深度。这样能快速抓住主要矛盾。第二个技巧是“用双指针代替嵌套循环”。很多数组类问题比如有序数组求两数之和一个外层循环加一个内层循环是 O(n^2)用双指针可以把内层扫描摊掉变成 O(n)。第三个技巧是“不要迷信大 O要配合常数和实际数据分布”。我在项目里经常写注释简单记录“这里 O(nlogn)n 约 10000当前方案可以接受”这能让后来接手的人快速理解你的取舍而不是盲目把算法换成更炫但常数大的版本。最后再分享一个我个人的体会复杂度分析的真正价值不在于考试满分而在于它给了你一把尺子让你面对任何一段代码、任何一个方案都能快速判断“它能不能撑住下一个数量级”。在真实业务里数据量从一万变成一百万往往就是一次市场活动的事代码还是那套代码能不能撑住区别往往就是你是不是在写第一版时就做好了复杂度上的判断。只要你愿意在平时刷题、写项目时多问一句“这段代码的时间复杂度和空间复杂度各是多少为什么”这门技能就会越来越熟练。
返回列表