如何清晰的理解算法中的时间复杂度?

学过算法的朋友,对大 O(Big O)这个概念应该不陌生,它是用于计算算法复杂度的方法。

这个方法好像是伴随着算法而生的,在其他地方很少能够看到它。你有没有思考过大 O 的本质到底是什么意思? 反正我自己从来都没有完全弄清楚过。

最近正好在看一些介绍算法的书籍,自然也是绕不过这个概念的。这次我抱着试试心态,想搞明白大 O 到底是什么,最终还真有一些收获,所以决定写下来,希望和我有同样困惑的朋友能少走点弯路。

大 O 其实是一个数学上的概念,是由数学家巴赫曼在 1892 年引入,并由另外一名数学家兰道推广的。

这篇文章我们就从数学上的大 O 开始,看看它是怎么成为算法复杂度分析方法的。

⛓️ 无穷大

这一切都要从"无穷大"这个概念说起。

关于无穷大的介绍,《从一到无穷大》这本书里的一个例子最为生动,我们不妨来看看:

设想有一家旅店,内设有限个房间,而且房间都已客满。这时候来了位新客,想订房间。店主说:"对不起,所有客房都住满了。"

现在再设想另一家旅店,内设无限多个房间,所有房间也都客满。这时也来了为新客,想订房间。店主说:"没有问题!" 接着,他就把 1 号房间里的旅客移到 2 号房间,2 号房间的旅客移到 3 号房间,3 号房间的旅客移到 4 号房间,以此类推,这一来,新客就可以住进了被腾空的 1 号房间。

这个例子是不是有些颠覆我们对无穷大的理解?

之前我们可能就觉得"无穷大"就是一个很大很大的数,没有哪个数比它更大了。从这个例子中我们能感受到无穷大不是一个静态的数,而是一个动态的趋势。

🎢 趋势的变化

既然无限大是一种无限增加的趋势,那么如何判断一个无穷大比另一个无穷大增长的更快呢?

我们用几个无穷大的数组做为示例。

自然数:

1,2,3,4,5,6,7,8,9,10...

偶数:

2,4,6,8,10,12,14,16,18,20...

自然数的平方:

1,4,9,16,25,36,49,64,81,100...

当这些数组趋于无穷大的时候,我们不太方便用平铺的方式列出数值,这时候函数可以很好的解决这个麻烦。

自然数:y1 = x (x为正整数)

偶数: y2 = 2x (x为正整数)

平方: y3 = x² (x为正整数)

我们将函数画成图像就可以清晰地看出它们增大的趋势,y1 < y2 < y3

上面的函数,正好可以用对应初中物理中的速度和加速度的内容(x为时间,单位s,y为距离,单位m)。

y1 = x ,对应着初速度为 1(m/s) 的匀速运动。

y2 = 2x,对应着初速度为 2(m/s) 的匀速运动。

y3 = x²,对应着初速度为 0,加速度为 2(m/s²) 的加速运动。

三个函数都在增大,增大的趋势归根结底是速度变化的趋势 ,所以 y1y2 可以是一类趋势(匀速运动),y3 是另一类趋势(加速运动)。

如何才能更好的区分这些趋势类型呢?

数学上有个方法:用函数相比之后的结果来区分。

比如 y1y2,当 y1 / y2 时,不管 x 变成多大,结果都是 1/2因此这种 y1 / y2 两个函数相比的结果是大于 0 的常数时,我们称这两个函数为同阶。

如果用 y3y1 相比,y3 / y1 结果为 x,当 x 趋于无穷大的时候,结果就是无穷大。 因此这种 y3 / y1两个函数相比的结果是无穷大时,我们称 y3y1 的高阶。

🚀 大 O 的数学意义

数学上,对于同阶的函数最好能用一个统一的符号表示,这样我们就可以快速地看出一个函数处于哪个阶,这时候大 O 这个符号就可以登场了。

假设函数 f(n)g(n) 属于同阶,那么肯定存在一个大于 0 的常量 c,使得 f(n) / g(n) <= c。这时候我们称 f∈O(g) 也可以写成 f = O(g)

这有意义吗?表面上看这满足了 f(n) <= c * g(n) 这个公式的成立。

同时,这也意味着当 n 趋于无穷大时,对于函数 f(n),任意的同阶函数 g(n) 乘以一个常量 c 都会满足 f(n) <= c * g(n)。此时函数 c * g(n) 就是 f(n) 的上界(也称渐进上界,因为 n 是逐渐地趋于无穷大的)。

通常情况下,我们在写 O(g)时,g 函数会选择同阶函数中最简单的那个。

比如,之前介绍的 y1=xy2=2x,它们的大 O 写成 O(x) 或者 O(2x) 都是正确,但是通常会选择最简单的那个 O(x),所以这个阶中所有函数的大 O 都可以表示成 O(x)

结合上面所说,大 O 意义,就是用最简单的方式,快速标记一个函数的同阶上界。

​你看,大 O 的数学概念是不是特别简单,简单到你翻开《高等数学》都看不到它的踪影。

🔬 函数的阶

之前例子中函数比较简单,我们来看一个复杂一点的函数:

y = 3x³ - 1000x² + 5x - 7

我们先来确认一下这个函数的阶,用这个函数除以

<math xmlns="http://www.w3.org/1998/Math/MathML"> 3 x 3 − 1000 x 2 + 5 x − 7 x 3 = 3 − 1000 x + 5 x 2 − 7 x 3 \frac{3x^3 - 1000x^2 + 5x - 7}{x^3} = 3 - \frac{1000}{x} + \frac{5}{x^2} - \frac{7}{x^3} </math>x33x3−1000x2+5x−7=3−x1000+x25−x37

x 趋于无穷大时,所有分母中包含 x 的数都趋于 0 ,因此计算结果为 3,所以函数 y = 3x³ - 1000x² + 5x - 7 的大 O 为 O(x³)

来看看随着 x 的增大,y 的变化趋势。

x 3x³ 1000x² 5x 7 y
100 三百万 一千万 500 7 ≈ 负700万
1百万 三百万万亿 一千万亿 五百万 7 ≈ 三百万万亿

可以看到,当 x 等于 100 的时候,对 y 结果影响最大的还是 1000x²

x 等于 1百万 时,3x³ 以外的其他结果都可以忽略了。

从这个变化趋势我们可以看出,x 趋于无穷大时,函数的结果只跟 x 最大次方的元素有关。

知道了这层关系,不需要通过计算,也可以直接写出函数 y = 3x³ - 1000x² + 5x - 7 的大 O ------ O(x³)

这也进一步验证了之前的结论,大 O 可以快速地找到同阶函数,并以此找出增长趋势的渐近上界。

🎉 高德纳算法复杂度分析

我们暂时把目光转回到编程,思考一个问题:有没有一种公平的、一致的评判方法来对比不同算法的性能?

这个问题在计算机科学发展的早期,一直没有答案。

你想想在下面这两个场景下,你如何判断算法的好坏?

场景一:使用 1 万条数据进行测试,算法 A 运行 1 毫秒,算法 B 运行 10 毫秒。

场景二:使用 100 万条数据进行测试,算法 A 运行 10000 毫秒,算法 B 运行 6000 毫秒。

数据量少的时候,算法 A 表现好;数据量大的时候,算法 B 表现好。算法 A 和算法 B 到底哪个更好?这个问题换一种问法就是:如何制定一个标准来判断算法的好坏?

1965 年 Juris Hartmanis 和 Richard Stearns 首先提出了算法复杂的的概念,科学家们开始思考这个问题。最终,高德纳找到了方法将算法复杂度严格量化衡量,他也因此被称为"算法分析之父"。

是的,就是那个写出《计算机程序设计艺术》的大牛------高德纳

高德纳算法复杂度分析的主要思想有两点:

  1. 在比较算法快慢的时候,只需要考虑数据量特别大,大到无穷大的情况。 为什么要考虑无穷大?因为计算机的发明就是为了处理大量数据的,而且数据会越来越多。

  2. 决定算法快慢的因素虽然有很多,但是所有的因素都可以被分为两类: 第一类是不随数据变化的因素,第二类是随数据变化的因素。衡量算法好坏时,只需要考虑随数据变化到近乎无穷大的情况。

看到无穷大、变化趋势这些概念你会联想到什么?没错------大 O。这个在数学上不太起眼的概念,通过高德纳的搭线,从此在计算机科学领域大放异彩。

看一段函数复杂度的计算的例子:

java 复制代码
int cal (int n) {
    int sum = 0;    //1 Unit_Time
    int i = 1;      //1 Unit_Time

    for (i = 1; i <= n; i++){   //1 Unit_Time
        sum = sum + i;          //1 Unit_Time
    }

    return sum;     //1 Unit_Time
}

假设每条语句的运行时间都是相同的 1 个 时间单位(Unit_Time),这时候就可以通过计算语句运行的次数来获取运行的时间:

  1. for 循环之外有 3 条语句,时间是 3 * Unit_Time
  2. for 循环本身算一条语句,运行 n 次,s时间是 n * Unit_time
  3. for 循环内部有一条语句,运行 n 次,时间是 n * Unit_time

最终,总的时间为 (3 + 2n) * Unit_Time

2n + 3 的大 O 是多少?通过之前数学概念的介绍,我们可以很快的得出 O(n)

如果你熟悉大 O 的数学概念,就会知道它只记录 n 所在的最高阶,所以不需要向上面这样考虑每条语句,直接从 n 趋于无穷大的循环中就可以得出结果。

🎨 常见的算法复杂度

常见的算法复杂度有 O(n)O(log(n))O(n²)O(nlog(n)) 等。

O(n)

java 复制代码
for (int i = 1; i <= n; i++)
{
    //some O(1) expressions
}

这跟之前的例子类似,循环会执行 n 次,算法的复杂度是 O(n)

O(n²)

java 复制代码
for(int i = 0; i < n; i++)
{
    for(int j = 0; j < n; j++)
    {
        //some O(1) expressions
    }
}

循环有两层,外面一层循环 n 次,其中的每次又都会在内层中循环 n 次,所以最终的算法复杂度是 o(n²)

O(log(n))

java 复制代码
for (int i = 1; i <= n; i = i*2)
{
    //some O(1) expressions
}

在上面的循环中,i 每次循环的值分别为:1, 2, 4, 8, 16,...., 2^k ,最终 n = 2^k 时停止,其中 k 就是循环的次数。这里使用对数 log 的概念, 求得 k = log₂n

无论 log 的底是多少,根据换底公式 log₃n = (log₃2 / log₂3) * log₂n,都可以转换成 c * log₂n, 所以算法复杂度去掉了底数,写出 O(logn)

O(nlog(n))

java 复制代码
for(int i = 0; i < n; i++)
{
    for(int j = 0; j < n; j = j*2)
    {
        //some O(1) expressions
    }
}

这次是 O(n)O(logn) 的结合,外层循环 n 次,其中每次又都会在内层中循环 logn 次,所以最终的算法复杂度是 O(nlog(n))

✍️ 总结

著名数学家弗里曼.戴森曾经有一个形象的比喻:有些科学家像鸟,翱翔在高高的天空,俯瞰延伸至遥远地平线的广袤远景。他们喜欢那些统一我们思想、并将不同领域的诸多问题整合起来的概念。有些科学家像青蛙,生活在泥土里,只看到周围生长的花草。他们乐于探索特定问题的细节,一次只解决一个问题。

计算机科学的发展离不开这两类科学家的共同努力,而高德纳在这个领域就是那只鸟一样的科学家。他从算法复杂度的需求延伸出去,将数学领域的大 O 概念巧妙地整合到了算法领域。

也许你会听到大 O 算法复杂度有些许不足的讨论,但这些声音无法改变大 O 在评估算法复杂度方面的统治地位。

最后再补充一点,文章中介绍的算法复杂度,叫做时间复杂度,还有一种复杂度叫做空间复杂度,用于统计 n 趋于无限大时,所需要的内存空间的大小。

关于算法复杂度具体的应用,我们会在之后介绍排序算法历史的文章中详细讲解,到时候我们一起来聊聊科学家们是如何探索特定问题的细节,并解决问题的。


参考资料:

[1] home.csulb.edu/~tebert/tea...

[2] tandy.cs.illinois.edu/big-oh.pdf


更多内容请关注我的公众号:dingtingli

WWH 系列文章列表:

[1] Why - 为什么 JS 更像一门编译型语言?

[2] What - 什么是依赖注入?

[3] What - 什么是 Big O?

[4] How - 不同的语言都如何处理错误?

[5] How - 面向对象语言如何处理异常?

[6] Why - 为什么排序算法复杂度上限是 O(NlogN)?

最近文章列表:

[1] 需要多少内存来运行100万个并发任务?

[2] 在 C 语言中实现简单的哈希表

[3] 成就卓越:事业成功的核心要素

[4] C++异常处理的底层机制

[5] .git 目录里到底包含了什么?

[6] 看图聊算法:一个游戏让你理解二分法的本质

[7] 看图聊算法:超越二分法,探索大厂经典面试题

[8] 看图聊算法:插入排序,使用频率最高的排序算法

[9] 看图聊算法:归并排序的原理与优化

[10] 看图聊算法:冯·诺依曼的第一个计算机程序

相关推荐
睡不着还睡不醒40 分钟前
【数据结构强化】应用题打卡
算法
sp_fyf_20241 小时前
计算机前沿技术-人工智能算法-大语言模型-最新研究进展-2024-10-05
人工智能·深度学习·神经网络·算法·机器学习·语言模型·自然语言处理
C++忠实粉丝2 小时前
前缀和(6)_和可被k整除的子数组_蓝桥杯
算法
木向2 小时前
leetcode42:接雨水
开发语言·c++·算法·leetcode
TU^2 小时前
C语言习题~day16
c语言·前端·算法
吃什么芹菜卷2 小时前
深度学习:词嵌入embedding和Word2Vec
人工智能·算法·机器学习
wclass-zhengge2 小时前
数据结构与算法篇(树 - 常见术语)
数据结构·算法
labuladuo5202 小时前
AtCoder Beginner Contest 372 F题(dp)
c++·算法·动态规划
夜雨翦春韭2 小时前
【代码随想录Day31】贪心算法Part05
java·数据结构·算法·leetcode·贪心算法
hsling松子7 小时前
使用PaddleHub智能生成,献上浓情国庆福
人工智能·算法·机器学习·语言模型·paddlepaddle