时间复杂度与空间复杂度详解

时间复杂度与空间复杂度详解

时间复杂度和空间复杂度,是衡量算法效率的两个核心度量。下面从"数一数"这种最直观的方式讲起,给出大 O 的形式化定义,配上 C++ / Python 双版本代码、公式推导和三张图,中间点几个常见误区,也说说这篇讲不到的地方。

这两天在教一个小孩子学习算法,用一种简单的思维讲解时间复杂度和空间复杂度。


一、先别管名字,来数一数

假设你妹妹让你帮她在书包里找一本作业本。

书包里有 3 本 本子的时候,你抓两下就找到了,用了 2 秒。

书包里有 30 本 本子的时候,你要翻一会儿,用了 20 秒。

书包里有 300 本本子的时候,你翻了两个小时。

现在我问你一个问题,"找本子"这件事,到底"有多快"?

你可能会说:"2 秒。"

错。2 秒是书包里有 3 本时的答案。 本子变成 300 本,答案就变成了两个小时。

所以真正要问的,是下面这句话。

本子每多一倍,我要多用多少时间?

这就是"复杂度"这个词真正在问的东西。它不关心你跑得多快,关心的是东西变多了以后,你变慢的速度有多快。

我觉得这是理解复杂度的唯一 入口。如果你记住了 O(n²) 这四个字符,但不知道它在问这个,那你只是在背咒语------飞机不会因为机场修得像飞机场就降落。


二、大 O:一种"只留主干"的写法

我妹妹真正找本子的动作是这样的:

|------------------------------------------------------------------|
| 翻开第 1 本 → 不是 翻开第 2 本 → 不是 翻开第 3 本 → 不是 ... 翻开第 n 本 → 找到了 |

一共翻了 n 次。(n 就是本子的数量。)

如果每翻一本要花 0.1 秒 ,还要先花 2 秒拉开书包拉链,那总时间是:

𝑇(𝑛)=0.1𝑛+2

翻本子的力气(0.1n)是主角,拉开书包拉链那两秒(+2)是个跑龙套的。

现在把 n 取几个数:

n(本子数) 0.1n +2 总时间
10 1 2 3
100 10 2 12
1000 100 2 102
10000 1000 2 1002

看最后一列,n 从 100 涨到 10000(100 倍),总时间从 12 涨到 1002,涨了大约 83 倍。

再看 0.1n 这一项,12 里它占 10,1002 里它占 1000。它一直是主角。 而那个 +2(拉链的时间)在 n 很小的时候还有点存在感,n 一大就完全不算数了。

这就是大 O 的全部秘密。

当 n 变得很大很大时,只留下涨得最凶的那一项,把前面的系数和后面加的小尾巴都扔掉。

写成数学语言,就是:

0.1𝑛+2⟶𝑂(𝑛)

这就像报身高,1.83 米报成 1.8 米,没人跟你较真那 3 厘米;报成 2 米,那就要吵一架了。

更严谨一点,大 O 的定义是:存在 两个正常数 𝑐 和 𝑛0,使得对所有 𝑛 ≥𝑛0 都有

𝑇(𝑛)≤𝑐⋅𝑓(𝑛)

就是「不管你怎么跑,我这条线永远压在你头上」。大 O 是一根天花板 ,它不保证贴得多近,只保证你撞不穿。

这时我们就写 𝑇(𝑛) =𝑂(𝑓(𝑛))。翻译成人话就是,

"从某个 n 开始,它的增长速度不会超过 f(n) 的某个倍数。"

注意这里我用了"增长"和"不超过",大 O 说的是一根上限,不是一个精确值。 它天生就是个偷懒的写法,而且这个偷懒是第一流的。因为当 n 足够大时,你根本不需要知道精确值,你只需要知道它在怎么涨。


三、五种涨法(图一)

把最常见的那几种"涨法"画出来,你会一眼看懂为什么计算机老师总在念叨它们:

图一:复杂度阶梯

图一 ,横轴是数据量 n,纵轴是要做的操作次数。注意红色那条 O(n²),它一开始很谦虚,到后面直接冲上天。

我用找本子来翻译这五条:

记号 名字 找本子的版本
𝑂(1) 常数 本子就在你手上,不用找
𝑂(log⁡𝑛) 对数 每次翻到中间,直接砍掉一半
𝑂(𝑛) 线性 一本一本翻
𝑂(𝑛log⁡𝑛) 线性对数 先分成小堆再整理(好排序的做法)
𝑂(𝑛2) 平方 每两本都要互相比一遍

这里有个特别容易搞混的点。你一定会听到有人说"𝑂(𝑛) 比 𝑂(𝑛2) 快",这句话不完全对。

它准确的说法是,当 n 足够大的时候,𝑂(𝑛) 才一定比 𝑂(𝑛2) 快。 n 小的时候,一条 O(n²) 的代码完全可能跑赢一条 O(n) 的代码,只要它每条指令写得更精简。

看图二你就明白了。同一份代码,n=8 的时候两条线几乎贴在一起,你根本看不出谁好谁坏;n=60 的时候,一个上一个冲天。

图二:n 小时看不出来

这是我见过的、最值得记住的一件事:如果数据不够大,你测出来的快慢可能是假的。

我是做工程的人,对这种事特别敏感------实验做得不对,数据就会骗你,而且它骗得特别自然,让你觉得你懂了。


四、例子一:找人(𝑂(𝑛) 对上 𝑂(log⁡𝑛))

现在上代码。同一件事,我们写两种做法。

做法 A:一本一本翻(𝑂(𝑛))

|-------------------------------------------------------------------------------------------------------|
| # Python def 线性查找(名单, 目标): for 名字 in 名单: # 从头看到尾 if 名字 == 目标: return True return False |

|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| // C++ —— 同一件事 bool linear_search(const vector<string>& names, const string& target) { for (const auto& n : names) // 从头看到尾 if (n == target) return true; return false; } |

最坏情况下要看 n 次。所以:

𝑇(𝑛)=𝑂(𝑛)

做法 B:每次砍掉一半(𝑂(log⁡𝑛))

但是 ,如果名单是排好序的(比如按拼音排好),你就能干一件聪明得离谱的事。

先翻中间那本。

  • 如果中间这本的拼音在你找的名字前面 ,那你要找的肯定在后半边,前边一半整块扔掉。
  • 如果在后面,后边一半扔掉。

每翻一次,名单就少一半。

|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| # Python def 二分查找(有序名单, 目标): 左, 右 = 0, len(有序名单) - 1 while 左 <= 右: 中 = (左 + 右) // 2 if 有序名单[中] == 目标: return True elif 有序名单[中] < 目标: 左 = 中 + 1 # 扔掉左半边 else: 右 = 中 - 1 # 扔掉右半边 return False |

|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| // C++ —— 同一件事 bool binary_search_sorted(const vector<string>& a, const string& target) { int lo = 0, hi = (int)a.size() - 1; while (lo <= hi) { int mid = lo + (hi - lo) / 2; // 这么写是为了防止 lo+hi 溢出 if (a[mid] == target) return true; else if (a[mid] < target) lo = mid + 1; // 扔掉左半边 else hi = mid - 1; // 扔掉右半边 } return false; } |

为什么是 log?我们来推一下。n 本本子,每次砍一半:

𝑛→𝑛2→𝑛4→⋯→1

像剥洋葱,每次把剩下的对半撕。只不过这个洋葱剥 20 层就没了------哪怕它有 100 万片。

砍了几次?设砍了 k 次:

𝑛2𝑘=1⟹2𝑘=𝑛⟹𝑘=log2⁡𝑛

这就是 log 的来历。「我要撕几次才能把这一摞纸撕成一张?」答案给出来就是 log2⁡𝑛。所以撕 100 万张纸只要 20 下,因为纸是成摞撕的。

所以:

𝑇(𝑛)=𝑂(log⁡𝑛)

log 是复杂度里的「温柔型选手」,数据翻一倍,它只多干一趟活。

这两个差多远?我给你算个实在数。一本 100 万条的名单:

  • 做法 A:最坏要找 1,000,000 次。
  • 做法 B:log2⁡1000000 ≈20 次。

20 次。

我第一次算这个的时候是有点恼怒的。你想,你妹妹找本子找两个小时,你只消翻 20 下。就这么个"每次都从中间下手"的笨办法,把人家的两个小时变成了 20 下。这不是什么高深技巧,无非是换了个问法,不问"下一个在哪儿",问"它可能在左半边还是右半边"。

这就是我一直在说的事。不懂的东西你没法简单地说出来,等你真能简单地说出来了,你会发现它本来就很朴素。

(顺便:C++ 标准库的 std::binary_search、Python 的 bisect 模块,干的就是这件事。别人已经写好了,你直接叫即可。)


五、例子二:两两都比较(𝑂(𝑛2)),以及它为什么可怕

给你一个名单,问你:谁和谁同名?

最笨的做法,是让每个人跟其他每个人都比一遍:

|----------------------------------------------------------------------------------------------------------------------------------------------------------------|
| # Python def 找重名(名单): for i in range(len(名单)): for j in range(len(名单)): # 注意这个内层循环 if i != j and 名单[i] == 名单[j]: return (名单[i], 名单[j]) return None |

|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| // C++ —— 同一件事 pair<string,string> find_duplicate(const vector<string>& a) { for (size_t i = 0; i < a.size(); ++i) for (size_t j = 0; j < a.size(); ++j) if (i != j && a[i] == a[j]) return {a[i], a[j]}; return {}; } |

为什么会变成 n²?因为这是两层循环:外层跑 n 次,每跑一次内层又跑 n 次。乘起来:

𝑇(𝑛)=𝑛×𝑛=𝑛2=𝑂(𝑛2)

两层循环不神秘,就是「每个人挨个跟所有人握手」,握手次数 = 人数 × 人数。人一多,握到手软。

看图二右边那条红色曲线,那就是它。

为什么我说它可怕?拿数字说话:

n(人数) n²(比较次数) 假设每次比较 1 微秒
100 10,000 0.01 秒(没感觉)
1,000 1,000,000 1 秒(还行)
10,000 100,000,000 100 秒(你开始眨眼)
100,000 10,000,000,000 2.8 小时(你可以睡一觉)

注意这个感觉 ,n 变 1000 倍,时间变 1,000,000 倍 。这就是平方的恐怖之处,它慢得越来越离谱。

所以我让你记住一句话:

一个 𝑂(𝑛2) 的算法能不能用,不取决于你的电脑有多好,取决于 n 会不会长大。

电脑快 4 倍,你只能扛住 n 翻一倍。而现实里的数据,往往不止翻一倍。


六、例子三:排序,以及为什么好的排序是 𝑛log⁡𝑛

排序的朴素做法是:每次从剩下的里面挑一个最小的放前面(选择排序)。挑一个要扫一遍,扫 n 次:

𝑇(𝑛)=𝑂(𝑛2)

挑出最小的放前面,每挑一次都得把剩下的人全体扫一遍 。你这不是在排序,你是在点名,而且每挑一个都重新点一次名。

而聪明做法(归并排序、快速排序)的核心思想是:先把问题切一半,解决两个小问题,再拼起来。

𝑇(𝑛)=2𝑇(𝑛2)+𝑂(𝑛)

「把活儿劈成两半干(2𝑇(𝑛/2)),干完再花点力气粘回去(𝑂(𝑛))」,分治的哲学就这一句,会分活儿的人不吃亏。

这个式子的意思是一行一行读出来:

  • 𝑇(𝑛):排 n 个数要花的时间;
  • 右边:分成两个 n/2 的问题(两个 𝑇(𝑛/2)),
  • 再花 O(n) 把它们拼回一起。

解这个式子(一层层展开,得到 log⁡𝑛 层,每层总共 O(n)):

𝑇(𝑛)=𝑂(𝑛log⁡𝑛)

听起来很唬人,其实只是「n 个东西,每个花 log⁡𝑛 步就找到自己的位置」。这是排序界的天花板。

对比一下(还是 100 万条):

算法 操作次数 大致倍数
𝑂(𝑛2) 选择排序 1012 1
𝑂(𝑛log⁡𝑛) 归并排序 2 ×107 约 1/50000

这就是为什么全世界的库函数里,排序都不用"挑最小"那种笨办法。 这跟学术洁癖没关系,差的是 5 万倍。


七、空间复杂度:不光要能跑,还要占得住

到这里我们一直在数时间 。但还有另一半故事:它要占多大地方。

这个不难理解。你去搬东西:

  • 时间=你跑了几趟;
  • 空间=你要多大的仓库。

空间复杂度问的是同一类问题:

数据量 n 变大,占用的内存会涨多快?

看这段代码:

|--------------------------------------------------------------------------------------------------------------------|
| # 空间 O(1):不管 n 多大,只多用一个格子 def 求和_循环(列表): 总数 = 0 # 一个变量 for x in 列表: 总数 += x return 总数 |

|---------------------------------------------------------------------------------------------------------------------------------------------------------|
| // C++ —— 同样是 O(1) long long sum_loop(const vector<int>& v) { long long total = 0; // 一个变量 for (int x : v) total += x; return total; } |

再看这个:

|--------------------------------------------------------------------------------------------------------------------|
| # 空间 O(n):每次递归都新开一份 def 求和_递归(列表): if not 列表: # 基线情形 return 0 return 列表[0] + 求和_递归(列表[1:]) |

|------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| // C++ —— 同样是 O(n) long long sum_rec(const vector<int>& v, size_t i = 0) { if (i == v.size()) return 0; return v[i] + sum_rec(v, i + 1); // 每一层都还没返回 } |

为什么递归是 O(n) 空间? 因为计算机执行递归,就像你打电话找人,每一层的人都在线上等着 ,谁都没挂。第 1 层调用第 2 层,第 2 层调用第 3 层......到第 n 层时,前面 n 层全都在内存里挂着 。看图三右边,那摞起来的柱子就是这个意思。

一个我必须说实话的地方 :这段 Python 里 列表[1:] 还会额外复制一份新列表 ,所以它其实更浪费。真正的工程写法会用下标 i 往前挪(像上面那段 C++ 那样),避免复制。这算个挺好的教训,代码怎么写,直接决定复杂度。 我一开始举这个例子就是为了让你看见它。

图三:空间与取舍


八、最有用的那一招,空间换时间

如果你今天只带走一件事,带走这个。

回到第五节那个"两两比较找重名"。有没有办法不找死?

有。开个登记本。

|-------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| # 空间 O(n),时间 O(n) def 找重名_快(名单): 登记本 = set() # 用一点额外空间 for 名字 in 名单: if 名字 in 登记本: # 查登记本几乎是瞬间 return 名字 登记本.add(名字) return None |

|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| // C++ —— 同一件事 optional<string> find_dup_fast(const vector<string>& a) { unordered_set<string> seen; // 用一点额外空间 for (const auto& n : a) { if (seen.count(n)) return n; seen.insert(n); } return nullopt; } |

我们干了什么?我们多花了一点内存(那个"登记本"),换来时间从 𝑂(𝑛2) 变成 𝑂(𝑛)。

n=100 时:从 10,000 次比较,变成 100 次。图三左边那两根柱子就是这个对比。

代价是:多占 O(n) 的内存来存登记本。

这是一笔交易。 时间和空间本来就是一对可以互换的东西,你几乎总是在拿一个换另一个。整个计算机科学里一半的工程技巧,说到底就是在做这笔买卖。


九、别骗自己:三个常见的自欺

这一段是我最想说的。因为复杂度这东西,特别容易让人假装懂了。

① "我测过了,它很快。"

看图二 。n=8 的时候,𝑂(𝑛2) 和 𝑂(𝑛) 的线是重合的。你用 8 个数据测,然后下结论说"我的代码没问题",这在实验设计上就是个错误。你没测错,你只是测了一个不说明任何问题的东西。

② "我用了最新的库,所以肯定快。"

用了复杂算法库里最快的函数,不代表你的那段代码 变快了。工具是形式,你的算法才是实质。去掉工具的名字,看你的代码在做什么,这才是真的。

③ "大 O 越大就越慢。"

不对,是"n 够大以后 越慢"。𝑂(𝑛2) 的 C++ 代码跑赢一个 𝑂(𝑛) 的 Python 代码,太正常了。大 O 比较的是自己和自己在不同 n 下的表现,不是两门语言打架。


十、我不知道的部分(以及它为什么不丢人)

我不想让这篇文章显得比它实际的样子更完整。有几个地方我要说清楚:

  • 大 O 只是"上限" ,它不管你平时的实际快慢。现实中还有"平均复杂度""最坏复杂度"的区别。快速排序最坏是 𝑂(𝑛2),但你天天在用它,因为它平均 是 𝑂(𝑛log⁡𝑛),而且常数很小。"理论最坏"和"实际天天"完全是两回事。
  • 常数真的会被忽略吗? 数学上会。工程上不会。一个常数是 1000 的 𝑂(𝑛) 算法,在 n 小于某个数之前,跑不过一个常数是 1 的 𝑂(𝑛2)。所以真实的程序里要"两个都写,小了用笨的、大了用聪明的",这个技巧叫混合策略,Python 和 C++ 的排序里都藏着它。
  • 内存的"快"不止一层。 你的 CPU 里有一层层的缓存(缓存比内存快几百倍)。两个复杂度一样的算法,谁把数据摆得更近,谁就真快。这部分(cache-oblivious、内存局部性)我不知道得足够深,就不装懂了。
  • 我这篇文章里的所有时间估算 (比如"1 微秒一次比较")都是为了让你有感觉的随手假设 ,不是实测数据。真要看真实数字,你得自己在你的机器上跑一遍,而且要用足够大的 n。

最后一条我不知道,但我认为它比前面所有我知道的都重要:

"我不知道"不是终点。它是你唯一能站上去的地板。


十一、一句话,带走就行

如果你明天全忘了,只记得一句话,我希望是这句:

「东西变多了,你会怎么变慢」,这才是复杂度真正在问的。

再用一句话配上一个动作:

𝑂(1) <𝑂(log⁡𝑛) <𝑂(𝑛) <𝑂(𝑛log⁡𝑛) <𝑂(𝑛2)

这条链背下来比背单词有用。但它有个前提,「n 足够大的时候」。不加这五个字,这条链就是耍流氓。

但顺序里那个"<"要加前缀:「n 足够大时」。


十二、自己试(别光看,看没用)

我最后想说:这篇文章你读完,你什么都没学会。

难不难先不说,光读不算学。要用上手,你得自己动手。给你三个练习,每个都只要几十行:

  1. 亲手做个实验 :把第四节的两个查找函数各写一遍,n 取 1000、10000、100000,真的计时 ,把结果画成表格。
    你会看到你的电脑"骗"了你多少次。
  2. 把 𝑂(𝑛2) 改成 𝑂(𝑛) :用第五节的"找重名",先用双重循环,再用哈希表,n 取 10000,比较真实耗时。
    你会第一次真切体会"空间换时间"不是一句口号。
  3. 把它讲给别人 :找一个完全不懂编程的人,用找本子 这个例子给他讲一遍二分查找,直到他问出"那为什么不一开始就翻中间",到那一刻你就懂了。

如果你讲不清楚,别急着说自己笨。说不清楚的地方,正是你还没懂的地方。 这就是我全部的方法,没什么神秘的。

相关推荐
AbandonForce1 小时前
递归、搜索与回溯:从板书到二叉树解题方法
c++·算法·决策树
打工仔折腾 AI1 小时前
让时序预测真正参与决策:TimechoAI 工程化落地与管线设计实践
人工智能·后端·python·算法·机器学习·性能优化
Light602 小时前
企业自建 AI 智能体,成本差 8 倍还是 46 倍?
java·人工智能·算法·知识图谱·rag·ai agent 上下文·token 成本
曹牧2 小时前
VS: Visual Studio 里把未包含的文件加进项目2
linux·运维·算法
重生之小比特2 小时前
【算法拾光】滑动窗口|长度最小的子数组 + 无重复字符的最长子串
算法
weixin_307779132 小时前
C++代码实现MATLAB中的fitnlm函数功能
开发语言·c++·算法·matlab
雪落漂泊2 小时前
C++算法:双指针篇(下)
数据结构·c++·算法
生态学者12 小时前
Global Change Biology | 森林越“步调一致”,反而越怕干旱?树木生长与叶片活动的同步之谜
大数据·人工智能·算法·r语言·微信公众平台