数据一大就卡:初学者看懂 O(n²) 如何一步步降到 O(n)

0 阅读14分钟

很多初学者第一次碰到性能问题,直觉是两件事:要么怀疑电脑不行,要么怀疑 for 循环写得不够优雅。真到工作里,最常见的情况其实更朴素:代码本来没坏,小数据时也跑得挺顺,一旦数据量从几百、几千涨到几十万、几百万,程序就突然慢得像在高峰期堵车。

一句话先说结论:当程序慢在“反复比较、反复查找”上时,别只盯着语法细节,先想能不能把结构重写掉,让算法从 O(n²) 降到 O(n log n),再看有没有机会降到 O(n)

这篇文章不打算把你扔进公式堆里,而是先讲人话:什么叫“用实现复杂度换算法复杂度”?为什么数据一大,算法就会变成瓶颈?同一个需求,怎么从双重循环一路改到排序,再改到哈希表?最后还会讲清一个很重要的现实:性能不是白捡的,你省下运行时间,通常要多付开发难度、代码复杂度和测试成本。

先把这句话翻成人话

“实现复杂度换算法复杂度”,可以直接翻成一句大白话:代码写法更绕一点,换程序跑得更快一点,而且数据越大,这个收益越明显。

先别急着背术语,我们拿一个生活画面来想。

假设你在快递站帮忙找重复包裹上的手机号:

  • 最笨但最直接的办法,是拿起一个包裹,就和剩下所有包裹比一遍。这很像 O(n²)

  • 稍微聪明一点的办法,是先按手机号排好序,再看相邻两个是不是一样。这很像 O(n log n)

  • 再进一步,你拿一个登记本,见到一个手机号就记下来;下次再见到同一个号,立刻知道它重复了。这很像 O(n)

现在再说术语就不吓人了:

  • 算法复杂度:数据越来越多时,程序工作量增长得有多快。

  • 实现复杂度:代码会不会更难写、更难读、更难测、更难交接。

所以这类优化的本质,不是“把同一段代码抠得更紧”,而是换一种组织数据和处理数据的方式

一个很常见的小案例是:夜里跑一份用户去重任务。刚开始一天只有 5000 条数据,双重循环照样跑完,谁也没意见。三个月后数据涨到 200 万,任务从 3 秒变成 30 分钟,这时候你就不能再装作它只是“电脑今天心情不好”了。

为什么昨天还好好的,今天突然卡成 PPT

初学者最容易困惑的一点是:同一份代码,昨天还能跑,为什么数据一多就不行了?

因为 O(n²) 的麻烦,不在“它慢一点”,而在“它会越长越夸张”。你把数据量翻 10 倍,工作量可能不是翻 10 倍,而是翻 100 倍。

下面这张表,不求你死记,只求你看出量级差距:

| 数据量 n | O(n²) 级别的比较次数 | O(n log n) 级别的操作量 | O(n) 级别的操作量 |

|---|---:|---:|---:|

| 1,000 | 约 1,000,000 | 约 10,000 | 1,000 |

| 100,000 | 约 10,000,000,000 | 约 1,700,000 | 100,000 |

| 1,000,000 | 约 1,000,000,000,000 | 约 20,000,000 | 1,000,000 |

行动建议:先把你自己程序里的真实数据量代进去看一眼,很多“要不要优化”的问题,看完这张量级表就不纠结了。

这里的 log n 你不用现在死抠数学证明,初学阶段记住一个感觉就够:它涨得比 n 慢得多,比 更是温柔得多。很多时候,O(n log n) 已经足够把“跑不动”变成“能接受”。

也就是说,性能瓶颈往往不是突然冒出来的,而是原来被小数据掩盖住了。小数据时,简单写法最省心;大数据时,简单写法可能就成了主战场上的拖后腿选手。

从双重循环到哈希表,中间到底换了什么

很多人以为,复杂度优化像某种神秘武功。其实没那么玄,它通常就是下面三步思路:

  1. 先承认你现在在反复扫描。

  2. 再想能不能用顺序性减少扫描。

  3. 最后想能不能用索引或哈希直接定位。

你可以把它理解成一次“结构重写”路线图:

开始

-> 这个需求里是不是有大量“比较、查找、判断是否存在”?

-> 如果没有,先别急着优化算法

-> 如果有,看看是不是在反复全表扫描

-> 能先排序让数据变得有规律吗?能的话,先试 O(n log n)

-> 还需要大量快速查找吗?能的话,再考虑哈希表、集合、索引,把它压到 O(n)

-> 如果内存很紧、顺序不能乱、团队维护能力有限,停在 O(n log n) 也可能已经是正确答案

行动建议:不要一上来就喊“我要写成 O(n)”,先顺着这条路线判断,你到底是在解决重复扫描,还是在给自己增加无谓的复杂度。

这里最关键的一句话是:复杂度下降,往往来自“数据结构变了”,而不是“循环写法更花了”。

  • O(n²) 常见于:每来一个元素,都去全量扫描一遍。

  • O(n log n) 常见于:先排序,再用顺序性做线性扫描。

  • O(n) 常见于:用哈希表、集合、计数数组、预处理索引,把“找一遍”改成“直接拿”。

把同一个需求做三遍,你就真懂了

我们用一个特别常见、特别适合初学者理解的例子:找出重复的用户 ID

假设输入是:


ids = ['u3', 'u1', 'u5', 'u3', 'u2', 'u1']

我们希望得到重复项:u3u1

写法一:最直接的双重循环,O(n²)

先说人话:拿第 1 个 ID 去和后面的都比一遍,再拿第 2 个去和后面的都比一遍,直到比完。

这就像班里有 40 个同学,你想找重名的人,于是让每个同学都去问剩下所有同学一次。能做,但累。


duplicates = set()

for i in range(len(ids)):

for j in range(i + 1, len(ids)):

if ids[i] == ids[j]:

duplicates.add(ids[i])

这个写法的优点很明显:

  • 好懂。

  • 额外空间少。

  • 初学者最容易一次写对。

但问题也很明显:

  • 比较次数会爆炸。

  • 数据量一大,时间马上失控。

  • 如果它在高频接口里,用户就能亲手感受到你的 O(n²)

一个小场景就很好理解:后台页面一次只查 200 条数据,这样写可能完全够用;但如果它要处理一天 300 万条日志,这种写法就像用勺子给泳池放水,努力是努力了,进度很感人。

写法二:先排序,再线性扫描,O(n log n)

先说人话:既然乱序时不好找重复,那我先把它排整齐。排完以后,重复元素会挨在一起,你只要看相邻两个是不是一样。


duplicates = set()

sorted_ids = sorted(ids)

for i in range(1, len(sorted_ids)):

if sorted_ids[i] == sorted_ids[i - 1]:

duplicates.add(sorted_ids[i])

排序后的数据会变成:


['u1', 'u1', 'u2', 'u3', 'u3', 'u5']

现在你就不用“每个都跟所有人比”,只要一路往前看相邻项。

这背后的关键术语是:你用排序换来了顺序性。

生活类比也很直白:一堆乱七八糟的账单里找同名客户很累,但如果你先按姓名排好,重复的人就会站在一起,眼睛扫过去就行。

这种写法的优点是:

  • O(n²) 快很多。

  • 通常比哈希表方案更容易控制行为。

  • 在很多工程场景里,已经足够实用。

但代价也有:

  • 排序本身要花时间。

  • 可能改变原始顺序。

  • 如果业务要求“按原输入顺序输出”,你还得补额外处理。

写法三:用集合做已见索引,O(n)

先说人话:我不再每次都回头找,而是准备一本“见过名单”。看见一个 ID 时,先查名单;查到了,就是重复;查不到,就先记上。


seen = set()

duplicates = set()

for x in ids:

if x in seen:

duplicates.add(x)

else:

seen.add(x)

这段代码为什么通常能看成 O(n)?因为集合里的“是否存在”查询,平均情况下非常快,常常可以近似理解成一次就能找到。

我们按顺序手跑一遍:

  1. 看到 u3seen 里没有,记下它。

  2. 看到 u1,没有,记下它。

  3. 看到 u5,没有,记下它。

  4. 看到 u3,有了,说明重复。

  5. 看到 u2,没有,记下它。

  6. 看到 u1,有了,说明重复。

这就像门口保安拿着来访登记表。来一个人,不用把整个楼的人重新点一遍名,只要看登记表里有没有。

这个方案通常是三者里性能最好的,但它并不是“永远无脑最优”,因为你为它付出了这些代价:

  • 需要额外内存保存 seen

  • 新手更容易忽略边界条件,比如去重后是否还要保留原顺序。

  • 代码从“直给”变成“要理解数据结构行为”。

三种写法放在一起看,差别会更扎实

| 写法 | 核心手段 | 时间复杂度 | 额外空间 | 优点 | 常见代价 |

|---|---|---|---|---|---|

| 双重循环 | 反复全量比较 | O(n²) | 低 | 最直接、最好懂 | 数据一大就慢 |

| 排序 + 扫描 | 先排序拿顺序性 | O(n log n) | 低到中 | 性能提升明显,逻辑仍较稳定 | 可能打乱原顺序 |

| 集合 / 哈希表 | 建已见索引直接查 | O(n) | 中到高 | 查找最快,适合高频场景 | 额外内存,代码理解门槛更高 |

行动建议:如果你已经发现瓶颈来自“反复查找是否存在”,优先考虑哈希表;如果你更在意顺序稳定、内存控制或实现可读性,先试 O(n log n) 往往更稳。

不是所有问题都该硬压到 O(n)

很多初学者刚学完复杂度,会出现一个很自然但也很危险的念头:既然 O(n) 更快,那是不是应该见一个问题就冲 O(n)

不一定。

因为工程里真正要做的不是“追求最漂亮的复杂度”,而是在性能收益、开发成本、维护成本之间做平衡

下面这张表,更像是实际工作的选型提示:

| 场景 | 更推荐的做法 | 原因 |

|---|---|---|

| 数据量小,功能低频,代码很快就要交付 | 保持简单,O(n²) 也可能能接受 | 先把需求做对,比过早优化更重要 |

| 数据量开始变大,但还能接受一次排序 | 优先试 O(n log n) | 收益大,代价通常比 O(n) 小 |

| 高频接口、在线请求、重复查找很多 | 倾向 O(n) | 每次请求都省时间,累计收益很高 |

| 内存很紧,或者不能轻易引入辅助结构 | 可能停在 O(n log n) | O(n) 不一定划算,空间代价可能太高 |

| 团队里多数人对复杂数据结构不熟 | 优先更稳的方案 | 可维护性也是成本,不是装饰品 |

行动建议:别只问“最快的是哪个”,还要问“这个收益值不值得我和团队长期背这个复杂度”。

这里还要补一句很重要的话:不是所有 O(n²) 的问题都能优雅地改成 O(n)

很多题之所以能做到 O(n),是因为它们满足某些前提,比如:

  • 可以用哈希表快速查找。

  • 可以接受额外内存。

  • 数据范围有限,可以用桶或计数数组。

  • 可以先做预处理。

如果这些前提不成立,硬拗 O(n) 可能只会把代码改得像机关盒,最后性能没省多少,维护的人先崩溃。

你省下了运行时间,也会多背几种账单

说“实现复杂度换算法复杂度”,重点就在“换”这个字。你不是白捡性能,你是在付另一种账单。

账单一:代码不再一眼看懂

双重循环的问题,是慢;它的好处,是非常直白。排序、哈希、索引、预处理一上来,代码会开始出现“为什么要先这样存”“为什么这里要多维护一个结构”的问题。

对初学者来说,这不是坏事,但确实是门槛。你以后写的不只是“能跑的代码”,而是“带策略的代码”。

账单二:空间开销可能上升

很多 O(n) 方案,本质上是在用空间换时间。比如上面的 seen 集合,就是额外维护的一份数据。

如果数据量极大,内存本身也会成为成本。你把时间省下来了,可能转头就开始和内存打架。

账单三:边界条件更容易出 bug

举个很真实的坑:有人把列表去重从双重循环改成了集合,性能确实好了,结果下游逻辑突然错了。为什么?因为业务其实依赖“原始顺序”,而集合天生不是给你保序输出准备的。

也就是说,优化不仅是“跑更快”,还会改变程序行为的细节。你不把这些细节补进测试,后面翻车会很快。

账单四:团队维护成本会上来

如果一个方案只有你自己能读懂,那它的隐藏成本就已经埋下了。工程里的优秀,不是“我能写出多聪明的东西”,而是“这个东西快,而且别人也接得住”。

所以成熟的判断不是“永远追最快”,而是:收益够大时再增加复杂度,收益不大时让代码保持朴素。

最后给你一套不容易走偏的实战顺序

如果你以后在项目里真碰到类似问题,可以按这个顺序来,不容易乱:

  1. 先测量,不要靠感觉。

看真实数据量是多少,耗时最久的地方是不是这段算法。

  1. 先找重复扫描。

只要你看到“每个元素都要去全表找一次”,就要警觉 O(n²) 风险。

  1. 先试 O(n log n),再冲 O(n)

排序 + 扫描往往是收益和复杂度比较平衡的一步。

  1. 只有在查找非常频繁时,再引入哈希、索引、缓存。

不要为了理论上更优,提前把代码写得过于绕。

  1. 补测试,特别是顺序、重复值、空数据、极端数据。

优化以后最容易出问题的,不是主流程,而是边角料。

  1. 做前后对比。

至少拿一组小数据和一组大数据,比较优化前后的耗时和资源占用。

你也可以把它压缩成一句工作里很好用的话:先确认是不是算法瓶颈,再确认是不是值得重写,最后才决定要不要为更低复杂度承担更高实现复杂度。

收个尾:真正的优化,不是把原路走快一点,而是换一条路

看到这里,你应该能抓住这件事的核心了。

O(n²) -> O(n log n) -> O(n),不是一场语法比赛,也不是为了面试而面试。它真正代表的是:当数据规模上来后,你要不要继续让程序“反复扫来扫去”,还是愿意改写结构,让程序变成“先整理,再少看几次”,甚至“先建索引,直接命中”。

对初学者来说,最重要的不是一上来就写出最优解,而是先建立这个判断框架:

  • 小数据、低频任务,简单方案很可能就是好方案。

  • 数据量上涨、算法成瓶颈时,先从结构上动手,不要只在细节上打补丁。

  • 性能提升越大,通常意味着你要承担更多实现和维护成本。

记住一句最实用的话:数据小时,简单就是美;数据一大,结构才是速度。