算法复杂度分析:O、θ、Ω、小o符号到底怎么用?
聊程序性能分析复杂度是绕不开的第一道坎。我每年要看不少代码评审经常有人在群里贴一段代码问“这个双层循环复杂度是不是O(n)”底下回帖的老哥往往会追问一句“你确定是O不是θ吗”结果很多时候对方一脸茫然——O、θ、Ω、小o不都是表示复杂度的吗有什么区别其实这里面的门道不少而且一旦用错你给出的复杂度结论在严谨的人眼里就是站不住脚的。今天这篇专门把复杂度分析这件事讲透重点回答一个被反复搜索的问题计算算法复杂度时什么时候用O什么时候用θ什么时候那个不起眼的小写o又会冒出来。内容从复杂度分析的底层思路讲起配合真实代码逐步拆解最后还会把我在面试、代码评审和性能优化中踩过的坑一并整理出来。不管你是准备校招面试、要写技术文档还是想把自己程序的性能说清楚读完应该都能把复杂度表达得稳稳当当。1. 复杂度分析的底层逻辑先看增长率再谈快慢1.1 为什么不能直接用“跑了几秒钟”来评价程序很多人第一次接触复杂度时都有一个疑问判断一个程序快不快直接跑一下用计时器测不就行了吗为什么要搞出一堆数学符号这个问题的答案等你真的在项目里优化过一段代码就会明白绝对时间根本不是一个可靠的度量。同样一个排序算法在你的笔记本上可能只跑几十毫秒放到一台十年前的老服务器上可能就要几百毫秒用Python写和用C写差距可能是几十倍哪怕同一台机器CPU负载高的时候和刚开机的时候跑出来的时间也完全不一样。所以“跑了几秒钟”只能说明某一次特定环境下的表现没法回答一个更本质的问题当输入规模从1万变成10万、再变成100万时你这个程序的时间增长是线性的、平方的还是对数的复杂度的核心就是找到程序运行时间随输入规模变化的“增长趋势”。它刻意忽略机器差异、语言差异、常数系数只保留最关键的部分当n足够大时时间量级是n、n²、log n还是n log n。这样你才能在不同算法之间做公平比较。1.2 从T(n)到渐近符号三步走要把一个程序的时间表达成复杂度我一般习惯走三步。第一步确定输入规模n。这个n必须是问题的核心尺度比如数组长度、字符串长度、图的顶点数和边数。选错了n后面全盘皆输。第二步找出代码里的基本操作并且统计它在最坏情况下的执行次数。所谓基本操作通常指执行频率最高的那一个操作比如排序里的比较、搜索里的“判断是否相等”。不需要把每条语句都数一遍抓住主要的那个就够了。第三步把统计出来的次数T(n)做化简去掉低阶项去掉常数系数保留增长最快的主项。比如T(n)3n²5n10去掉低阶项和系数后就是n²。然后再用合适的渐近符号把这个主项“包装”起来就得到了我们平时写的那种复杂度表达式。这套流程看起来简单实际做起来有不少容易出错的地方。最典型的一个错误是第二步只数了循环头的执行次数却忘记数循环体里的操作。还有一个错误是把每条语句的常数工作量都当作n来算结果把θ(n)硬生生推成了θ(n²)。这些都是我亲眼见过别人犯过的错。1.3 输入规模n怎么定输入规模的选取并不总是千篇一律的“数组长度”。比如分析一个字符串匹配算法n通常是主串长度m是模式串长度复杂度就要写成关于n和m的二维形式。分析图算法时n是顶点数e是边数常见的写法是O(ne)。分析矩阵乘法时如果矩阵是n×n输入规模其实是n²个元素但习惯上直接用矩阵维度n来表示复杂度比如普通矩阵乘法是θ(n³)这里的n是矩阵边长。同一个n在不同问题里代表的含义可能完全不同所以你在写复杂度表达式之前一定要先声明“这里的n到底是什么”。2. 符号家族梳一遍O、Ω、θ、o、ω都在表达什么2.1 用“跑分阈值”理解五种符号既然要分清什么时候用O、什么时候用θ就得先把符号的定义搞清楚。很多人记不清这几个符号其实只要用“阈值”的角度去理解一下子就通了。假设f(n)是算法的时间函数g(n)是我们用来比较的参照函数比如n、n²、log n。那么大O符号O说f(n)O(g(n))意思是存在一个常数c和一个足够大的n₀当n≥n₀时f(n)最多不超过c·g(n)。换句话说g是f的一个上界但不要求这个上界有多紧。Ω符号Ω说f(n)Ω(g(n))意思是存在常数c和n₀当n≥n₀时f(n)至少不低于c·g(n)。g是f的一个下界。θ符号θ说f(n)θ(g(n))意思是f(n)正好在c₁·g(n)和c₂·g(n)之间上下界同时成立。这是渐近紧界表达的信息量最大。小o符号o说f(n)o(g(n))意思是无论你取一个多小的正数c当n足够大时f(n)都严格小于c·g(n)。它表示f的增长严格慢于g。ω符号ω反过来表示f的增长严格快于g。光看定义有点抽象打个比方。大O就像你跟你妈说“我晚上最迟12点回家”至于你到底十点回还是十一点半回这个说法都没错但它只是给你妈一个安全上限。θ就像你实际观察下来发现“我每天就是十点到十一点之间到家”这个上下界非常稳定。小o则像你拍胸脯说“我肯定比午夜之前早得多而且无论你怎么放宽标准我都到不了半夜那个点”。2.2 一个大到离谱的常见误区把“O”理解成“等于”这是我要重点提醒的一个误区O不是“等于”也不是“大约”它是“不大于”。很多人写“算法复杂度是O(n²)”心里想的是“这就是n²”但对数学严谨的人来说他只知道你证明了“不会超过n²这个量级”。举个例子。假设某个算法的时间函数真实是T(n)3n5那么我们知道T(n)O(n)。但严格说T(n)O(n²)也对因为n²也是它一个非常宽松的上界。可你要是跟面试官说“这个算法复杂度是O(n²)”可能没有错但完全没有展示出你对算法的理解。所以当你能证明T(n)同时有匹配的上界和下界时应该优先使用θ。θ才是真正的“等于”意义上的紧界表达。用集合的语言说θ(g)就是O(g)和Ω(g)的交集。注意这里的“等号”其实是“属于集合”的意思但日常使用中大家已经习惯写成“”。2.3 小o和θ的差异一个说“严格更小”一个说“正好这么大”回到热搜问题里那个小写的o。很多人会把小o和大O混在一起甚至打字的时候懒得区分全写成了o结果本来想表达θ输出却成了严格弱上界意思差远了。小o的准确定义是f(n)o(g(n))表示对任意正数c都存在足够大的n₀使得f(n) c·g(n)。它比大O更严格强调的是“永远达不到g的量级”。比如3n5O(n)同时3n5o(n²)但你绝对不能说3n5o(n)因为无论c怎么放大3n5都不会小于c·n的某个固定比例。那么小o什么时候用在我见过的算法论文里小o一般用来表达“某个界虽然成立但可证明它不紧”。例如一个算法的时间可以保证为o(n²)说明它比n²严格地慢但我们暂时说不出它到底是n log n还是n^1.5。在日常工程文档里小o几乎不出现面试也基本不考。真正需要你每天在代码评审里做决策的是大O和θ。3. 实操决策一个函数到底该选O、θ还是o3.1 一张决策表回答“什么时候用O什么时候用θ”聊了这么多定义终于要正面回答那个最常见的问题了我计算复杂度时到底什么时候写O什么时候写θ我的经验是先问自己三个问题第一我分析的是最坏情况、平均情况、最好情况还是均摊情况第二我只证明了上界还是同时证明了匹配下界第三我想表达的是“不超过某个量级”的安全承诺还是“就是这个量级”的精确判断答案不同符号选择完全不同。把这三个问题落到行动上可以整理成下面这张决策表。你手上掌握的信息建议使用的符号一句话理由只证明了上界下界不确定O只承诺不超过不说死只证明了上界且已证明上界严格弱于参照o强调即使上界也不紧还差得远只证明了下降上升不确定Ω只承诺至少这么慢上界和下界都证明增长率相同θ量级已经钉死用最紧的表述下界严格强于某个参照ω和o相反强调严格更快只想要一个性能安全上限不想扯证明O最简单、最保守已经做了最坏和平均的完整推导θ或分别写O/θ信息量最大也最经得起追问这张表我建议你直接收藏。以后不管是写代码注释还是回答面试题先想清楚你是“能证明上下界”还是“只想给个安全上界”再决定用什么符号。3.2 面试、技术文档、论文里的不同习惯不同场景下符号的默认用法其实不太一样。面试场景里如果面试官问“这个算法的时间复杂度是多少”大多数人期待的是一个紧界或至少最坏上界的回答。你回答“二分查找是O(log n)”不算错但如果能补一句“实际上最坏和平均都是θ(log n)因为每次比较都会把搜索区间减半最坏也需要约log n次”这个回答就明显更有水平。反过来如果你对某个算法只能证明一个上界千万不要强行说θ否则面试官追问一句“你能证明下界吗”你就尴尬了。技术文档和代码注释里我见到的习惯是性能承诺用O因为读者想知道的往往是“这段代码在极端情况下到底会不会爆”O(n²)意味着“最坏不会超过n²量级”。但如果这个函数是我自己要长期维护的核心模块我会额外写清楚最好θ(1)、平均θ(n log n)、最坏θ(n²)这样后人调试性能问题时可以立刻对上号。算法研究论文则更苛刻凡是能推出紧界的地方审稿人默认你该用θ如果做不到就必须退而求其次用O并且明确说明“这里只给出上界紧界仍然开放”。小o在这种情况下也偶尔出现通常用于描述“这个算法的时间消耗严格低于某项任务的下界”这类结论普通开发可以暂时不用太纠结。3.3 经典算法里符号到底应该怎么标我把几个常见算法的复杂度符号选择列成一个表方便你对照理解。算法最坏情况平均情况工程文档常见写法冒泡排序经典无优化θ(n²)θ(n²)O(n²)冒泡排序带标志位优化θ(n²)θ(n²)最好θ(n)O(n²)最好O(n)归并排序θ(n log n)θ(n log n)O(n log n)快速排序θ(n²)θ(n log n)O(n²)平均O(n log n)线性搜索θ(n)θ(n)O(n)二分查找θ(log n)θ(log n)O(log n)哈希表查找θ(n)最坏冲突时θ(1)理想散列O(1) 平均最坏O(n)注意我在这里写了很多θ。原因很简单这些算法的最坏情况或者平均情况的上下界都是可以严密证明的所以按数学严格的写法确实可以写θ。但工程文档里仍然常写O因为工程师要的是“性能安全承诺”而且很多人没有意识到自己其实已经证明了θ。我的建议是面试和论文里能证明紧就写θ工程注释里为了可读性写O也没有大毛病但你心里要清楚两者差着“半个证明”的距离。4. 案例分析从读代码到选符号完整走一遍4.1 双层循环例找数组里的逆序对假设有个函数要统计数组里所有满足ij且A[i]A[j]的逆序对数量最直接的实现是下面这样。def count_inversions(A): n len(A) count 0 for i in range(n): for j in range(i 1, n): if A[i] A[j]: count 1 return count我们来走一遍分析流程。输入规模n是数组长度。基本操作选择最内层的比较A[i] A[j]因为它执行次数最多。内层循环j从i1跑到n-1对每个i执行n-1-i次。所以比较总次数是T(n) Σ(i0 到 n-2) (n-1-i) (n-1) (n-2) ... 1 n(n-1)/2这个T(n)展开是0.5n² - 0.5n。保留主项后是n²量级。关键问题是它能写θ(n²)吗可以因为我们已经精确计算出了执行次数它不仅有一个O(n²)的上界而且至少也是n(n-1)/2次比较下限同样是n²量级。所以这里最严谨的写法是“最坏和平均均为θ(n²)”如果你只想给个上界写O(n²)也对但没那么有信息量。4.2 含提前退出的循环大多数“O”背后是口径不一致再看一个更有意思的例子。在数组里查找目标值target一旦找到就返回下标。def find_first(A, target): for i in range(len(A)): if A[i] target: return i return -1这个函数的最坏情况是目标不在数组里或者目标在最后一个位置循环要跑完整个数组所以最坏是θ(n)。最好情况是目标恰好在下标0一次命中θ(1)。平均情况呢如果假设目标等概率出现在任意位置那么平均要找(n1)/2次仍然是θ(n)。那为什么很多人面试时只回答“O(n)”因为他们默认问的是最坏情况上界O(n)可以不用解释下界就答出来。但如果你具备更强的分析能力应该补一句“最坏是θ(n)平均也是θ(n)最好θ(1)”这样面试官就知道你是真的吃透了这段代码。如果你更进一步这个查找函数用在一个“目标基本都在数组开头”的业务场景里那平均复杂度可能接近θ(1)。但这不再是纯算法问题了它依赖于输入分布。所以你会发现只要口径一变符号和值都要跟着变。这恰恰解释了为什么工程里大量出现O而不是θ——因为很多时候我们并不清楚真实输入分布只能给一个最坏上界的保守承诺。4.3 递归与主定理θ最常出现的地方递归算法的复杂度分析经常会得出θ因为分治过程的上下界都比较清晰。以归并排序为例递归式是T(n) 2T(n/2) θ(n)这里θ(n)表示每次合并两个有序子数组的代价上下界都是线性的。用主定理套一下a2b2log_b(a)1f(n)θ(n)正好落在情况二所以结果是T(n)θ(n log n)。为什么这里写θ而不是O因为递归树每一层的工作量至少是c·n、至多是C·n两层夹在一起最终总和的上下界都是n log n量级。主定理的三种情况给出的结论本来就是紧界所以用θ是最自然的选择。如果某个递归式你只能轻轻松松证明一个上界、却没有办法证明下界比如f(n)上界是O(n)但下界不确定那就不能贸然用θ只能老实写O。这个例子也说明一个规律能用θ的时候通常意味着你把整个算法的结构看清了知道每一层到底在做什么、最少要做多少、最多要做多少。只写O很多时候不是“谦虚”而是“还没完全看清”。4.4 用实验验证你推出来的复杂度复杂度分析是理论推演但推完之后最好还是拿实验数据验证一下。我自己在写一些不熟悉的算法时会做一个简单的时间测试。比如我推出来某段代码复杂度是θ(n²)那就准备n1000、2000、4000、8000四组数据分别运行并记录耗时。如果n翻倍n²的耗时应该大约翻4倍。如果实际只翻了2倍多一点说明我可能把复杂度推大了实际更可能是θ(n log n)。如果翻了快8倍那可能不是n²而是n³。这种“翻倍观察法”不用任何统计工具在本地跑一遍就能快速暴露分析错误。不过要注意数据规模要足够大否则常数部分会干扰观察。比如你的n从10变到20连CPU缓存都还没跑满翻倍规律根本看不出来。我一般建议至少从n1000起步最好能跑出0.1秒以上的耗时再做对比那样的数据才可信。5. 常见问题与排查技巧实录5.1 复杂度分析最常见的三个翻车现场第一个翻车现场是混淆最好、最坏和平均。有人分析快速排序跑了一组随机数据发现时间接近n log n就兴冲冲写“快排复杂度是θ(n log n)”完全不管最坏情况下它确实是θ(n²)。正确做法是明确说明“平均情况θ(n log n)最坏情况θ(n²)”。第二个翻车现场是忽略递归里的拷贝开销。很多人分析归并排序时只统计比较次数把merge阶段复制数组到临时数组的线性开销给忘了。虽然最后结论可能还是n log n但如果分析的是那些对常数特别敏感的场景你可能会得出完全相反的结论。第三个翻车现场是把所有常数都当成可忽略。复杂度分析里常数确实可以忽略但那是“渐近意义”上。如果你的两个候选方案一个是O(n)但常数极大一个是O(n²)但常数极小在n不大的业务场景里后者可能反而更快。复杂度结论只能告诉你在数据足够大的趋势下谁赢不能替代基准测试。5.2 面试时如何回答复杂度问题面试中我总结了一个比较稳的模板。先声明口径再说上界再补紧界“我假设输入规模是n。这个算法在最坏情况下每次循环都需要完整扫描一遍数组所以总次数大概在n的量级同时我可以构造一个输入让它的确会跑满这么多次因此最坏情况是θ(n)。如果只需要上界也可以说O(n)。”这套话术的好处是它明确告诉面试官你回答了“最坏情况”同时用“能构造输入达到这个界”证明了下界紧最后又解释了大O和θ的关系。哪怕面试官对符号特别敏感也挑不出毛病。反过来如果我对一个算法只能证明上界、没构造出达到上界的输入我会坦白说“这里我只证明了O(n log n)下界还需要进一步分析”。诚实比硬凑θ重要得多。5.3 工程中我实际怎么用复杂度分析最后聊聊工作里的真实使用方式。我不可能对每段代码都做完整复杂度推导一般只对热点函数这么做。流程是先用profiler找出整个程序执行时间占比最高的函数然后针对这个函数做复杂度分析写明它是O还是θ、最好平均最坏如何。如果分析完发现它处于一个高复杂度循环里再考虑优化。给代码写注释时我习惯用一句话说明口径比如“最坏O(n²)平时跑随机数据约θ(n log n)”。这样后续维护的人一看就知道上界和期望分别是什么。很多人写注释只写“复杂度O(n²)”既不说明是平均还是最坏也不说明是否紧三个月后回头看自己都搞不懂当时在什么条件下算出来的。我个人在写代码评审意见时还有一个小习惯如果你确认了上下界就大胆用θ它会拉高整段分析的可信度如果你只验证了一次运行时间那就老老实实写“当前数据规模下约O(n log n)”不要硬吹。选O还是选θ本质上是看你掌握的证据够不够。复杂度的符号不是拿来装门面的而是表达你对自己程序理解深度的一把尺子。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →