算法(一·一):概论——算法的复杂度介绍
写在前面的话
为什么要有时间、空间复杂度
算法是问题求解的指南,它告诉我们如何解决特定问题,就像一本详细的操作手册。然而,不仅要知道如何解决问题,还要知道解决问题所需的时间和资源。就像计划旅行一样,你需要知道旅行路线和所需的时间、金钱和资源,以确保你的旅行是实际可行的。同样,算法的时间复杂度是确定算法是否在合理时间内完成任务的关键因素,而空间复杂度则是确保算法能够在可用内存范围内运行的关键因素。换言之分析时间、空间复杂度的目的是保障你的算法是可行的,换个角度看也是你评价办成一件事儿的不同种方案的指标。因此,时间复杂度和空间复杂度是评估算法性能和可行性的关键工具。
时间、空间复杂度可以理解成什么
- 时间复杂度是指运行这个算法所需要计算的工作量
- 空间复杂度是指运行这个算法所需要占用的内存空间
算法的时间复杂度
时间复杂度概念
前面我们提到,评估一个算法的性能与可行性需要时间复杂度这个指标,但究竟什么是时间复杂度?再此之前我先引入一个时间频度(语句频度)的概念。
- 时间频度T(n):一个算法运行所需要的时间,从理论上是无法计算的,必须在实践中经过上机运行测试下,才能知道它究竟跑了多久。然而对于我们来说,对每个算法都上机进行测试跑一下是不切合实际的。为此我们需要想一个办法,不运行代码就能比较出各个算法的运行时间差异性。同时我们知道一个常识一个算法运行的时间与算法中代码(语句)的执行次数成正比,因此哪个算法中代码(语句)执行的次数越多,它运行的时间就越多。而时间频度(语句频度)就是解决这个问题的有利工具,它是指一个算法中的代码(语句)执行次数记为T(n)。
- 问题规模n:T(n)中的n是什么?它叫做问题规模,问题规模通常是一个与特定计算问题相关的参数,表示算法要处理的问题的大小。问题规模通常与输入数据的规模相关,例如数组的长度、数据集的大小、图的节点数等。
- 算法的运行时间函数f(n):通常与问题规模n相关。它描述了算法在不同问题规模下的实际性能,通常以基本操作(执行次数最多的那条语句)重复执行的次数来表示。
- 渐进时间复杂度O(f(n)) 表示算法的渐进时间复杂度,即算法在输入规模 n 趋近无穷大时的性能上界。它用于描述算法的增长率,并忽略了常数因子和低阶项,只用高阶项表示。
有了这些概念,现在我们来说算法的时间复杂度究竟是什么。
算法的时间复杂度是用来衡量算法运行时间f(n)随输入规模n增加而增长的速度O(f(n))。
简单的说直白点,它就是对于执行次数最多的那条语句——执行次数的高阶表示,然后外面放个O()。
加粗样式>想具体了解为什么用大O表示、为什么与输入问题规模等等有关问题的同学可以看
“算法(一):概论”这一章节,里面有具体的介绍。
核心思想
算法的代码运行时间与代码执行次数成正比,故有公式:T(n) = O(f(n))。
分析时间复杂度过程
原则
- 原则1:对于一些简单的语句,如输入、输出、声明、赋值、条件判断等语句,近似认为时间复杂度为O(1)。(注意:在这里,常数项忽略不计)
- 原则2:对于顺序结构的时间复杂度可采用大O下"求和法则"。
- 求和法则:是算法时间复杂度分析中的一个重要原则,它适用于顺序结构中一系列语句的执行时间估计。具体来说,如果一段代码包含多个顺序执行的语句,你可以将它们的时间复杂度相加以获得整段代码的总时间复杂度。
- 举个简单的例子,假设有一个算法包含以下三个顺序执行的语句:
①一个循环,时间复杂度是 O(n)。
②一个条件判断,时间复杂度是 O(1)。
③另一个循环,时间复杂度是 O(m)。
要计算整个算法的时间复杂度,可以使用"求和法则"将这些时间复杂度相加:O(n) + O(1) + O(m)
根据大 O 记号的性质,我们只关注最高阶的项,而忽略常数因子。因此,整个算法的时间复杂度是:O(n + m)
这意味着整个算法的运行时间与 n 和 m 的规模成正比,可以简单地表示为 O(n + m)。
- 原则3:由于加法原则,我们关注循环次数最多的代码。
- 原则4:对于选择结构,如if语句,它的主要时间耗费是在执行then字句或else字句所用的时间,需注意的是检验条件也需要O(1)时间。
- 原则5:对于循环结构,循环语句的运行时间主要体现在多次迭代中执行循环体以及检验循环条件的时间耗费,一般可用大O下"乘法法则"。
- 乘法原则:时间复杂度的乘法原则通常用于分析嵌套循环的时间复杂度。当算法包含多个嵌套的循环时,可以使用乘法原则来计算总时间复杂度。
- 举例说明:在这个示例中,有三个嵌套的循环,分别具有不同的时间复杂度。根据乘法原则,总时间复杂度是这些时间复杂度的乘积,即 O(nmp)。
for i in range(n): # 第一个循环,时间复杂度是 O(n)
for j in range(m): # 第二个循环,时间复杂度是 O(m)
for k in range(p): # 第三个循环,时间复杂度是 O(p)
# 做一些常数时间的工作
# 总时间复杂度 = O(n) * O(m) * O(p) = O(nmp)
- 原则6:对于复杂的算法,可以将它分成几个容易估算的部分,然后利用求和法则和乘法法则技术整个算法的时间复杂度
计算时间复杂度步骤
- 步骤一:找出算法中的基本语句(执行次数最多的语句),通常是最内层循环的循环体。
- 步骤二:计算基本语句的执行次数的数量级,这一步通常利用加和原则与乘法原则。
- 步骤三:将基本语句执行次数的数量级放入大Ο记号中。
常见时间复杂度
类型
时间复杂度类型 | 时间复杂度大小 | 介绍 |
---|---|---|
常数时间复杂度 | O(1) | 无论输入规模的大小,算法的执行时间都是常数级别的,即执行时间固定。 |
对数时间复杂度 | O(logn) | 算法的执行时间随着输入规模的增加而以对数方式增长,常见于二分查找等算法。 |
线性时间复杂度 | O(n) | 算法的执行时间与输入规模成线性关系,随着输入规模的增加,执行时间也线性增长。 |
线性对数时间复杂度 | O(nlogn) | 算法的执行时间与输入规模成 n 与 logn 的乘积关系,常见于快速排序、归并排序等。 |
平方时间复杂度 | O(n^2) | 算法的执行时间与输入规模的平方成正比,通常用于描述嵌套循环的算法。 |
多项式时间复杂度 | O(n^k) | 算法的执行时间与输入规模的 k 次方成正比,其中 k 是一个固定的常数。 |
指数时间复杂度 | O(2^n) | 算法的执行时间与输入规模的指数关系,通常用于描述指数增长的问题,效率非常低。 |
阶乘时间复杂度 | O(n!) | 算法的执行时间与输入规模的阶乘成正比,通常用于描述组合问题,效率非常低。 |
指数级时间复杂度 | O(n^n) | 是一种非常高的时间复杂度,表示算法的运行时间随着输入规模 n 的指数级增长。这意味着算法的执行时间会急剧增加,特别是在 n 变大的情况下,这种复杂度通常不适用于实际问题中。 |
举例
-
常数时间复杂度 O(1)
- 代码
int a=1; int b=2; int s=a+b; int t = a*b print(a)
- 说明:只要代码的执行时间不随问题规模n的增大而增大,这样的时间复杂度都是O(1)。并且一般情况下,只要代码中不存在循环语句、递归语句,纵使代码语句非常多,一般都是O(1)。
- 代码
-
对数时间复杂度 O(logn)
- 代码
int i = 1; while(i <= n) { i *= 2; }
- 说明:令x是循环次数,当 x个2相乘大于n时,退出循环,即2^x=n ,x=log2(n) ,所以时间复杂度是O(log2(n) )。
- 代码
-
线性时间复杂度 O(n)
- 代码
for(i=0;i<n;i++){ print(i); }
- 说明:循环了问题规模n次,所以时间复杂度是 O(n)。
- 代码
-
线性对数时间复杂度 O(nlogn)
- 代码
for(int i = 0 ; i < n ; i++){ function (i); } void fun(int n){ while(i<=n) { i*=2; } }
- 说明:外层循环调用的function()函数时间复杂度为O(n),内层是O(log2(n) )。利用乘法原则,代码的时间复杂度为O(nlog2(n))。
- 代码
-
平方时间复杂度 O(n^2)
- 代码
int i; for(i = 0 ; i < n ; i++){ for(j = 0 ; j < n ; j++){ print("hello world"); } }
- 说明:双层循环时间复杂度就是O(n^2)。
- 代码
-
多项式时间复杂度 O(n^k)
- 说明:k层嵌套,其中k是常数 。
-
指数时间复杂度 O(n^2)
- 代码
def fibonacci_recursive(n): if n <= 1: return n return fibonacci_recursive(n - 1) + fibonacci_recursive(n - 2)
- 说明:递归计算斐波那契数列的时间复杂度是指数级别的,因为它对于每个 n 都会递归调用两次,导致指数级的调用次数。
- 代码
-
阶乘时间复杂度 O(n!)
- 代码
def generate_permutations(arr): if len(arr) <= 1: return [arr] permutations = [] for i in range(len(arr)): first_element = arr[i] rest_elements = arr[:i] + arr[i+1:] for perm in generate_permutations(rest_elements): permutations.append([first_element] + perm) return permutations
- 说明:这个算法会递归地生成所有可能的排列。它的时间复杂度是 O(n!),因为有 n 个元素的全排列共有 n! 种可能。
- 代码
-
指数级时间复杂度 O(n^n)
- 代码
def exponential_recursive(n): if n == 0: return 1 result = 0 for i in range(n): result += exponential_recursive(n - 1) return result
- 说明:这个递归算法计算了指数级的子问题,每次调用都会生成 n 个子问题。算法的运行时间是 O(n^n)。对于较小的 n,它可能仍然可以在合理的时间内完成,但对于任何较大的 n,运行时间会急剧增加,因此不适用于大规模问题。
- 代码
常见时间复杂度比较
O(1) < O(logn) < O(n) < O(nlogn) < O(n^2) < O(n^3) < O(2^n) < O(n!) < O(n^n)
注意: O(2^n) O(n!) O(n^n) 这三项都是非多项式类型的时间复杂度,算法效率极低,平常算法设计时一定要避免此类复杂度算法设计。
算法的空间复杂度
算法空间复杂度概念
算法的空间复杂度是评估算法在执行过程中所需的额外内存空间的度量。它表示随着输入规模的增加,算法所需的额外内存空间会如何增长。空间复杂度通常以大O符号(O-notation)表示,类似于时间复杂度。
分析原则
- 程序本身的空间: 这是指算法执行所需的代码空间,包括程序的指令、变量、数据结构等。这是算法的固定空间开销。
- 递归调用的空间: 如果算法使用递归,每次递归调用都会创建新的函数调用堆栈,其中存储了函数的局部变量、参数和返回地址。这会占用额外的内存空间。递归算法的空间复杂度通常与递归深度相关。
- 数据结构的空间: 如果算法使用数据结构(如数组、链表、树、图等),需要考虑这些数据结构所占用的内存空间。例如,一个包含n个元素的数组需要O(n)的空间。
- 辅助空间: 有时算法需要额外的辅助空间来存储临时数据或辅助数据结构。这些空间通常与输入规模和算法的复杂性有关。
计算方式
计算空间复杂度时,通常考虑最坏情况下的空间需求。空间复杂度可以帮助我们评估算法在内存资源方面的要求,特别是在处理大规模数据时。与时间复杂度一样,我们通常关注的是算法的渐进空间复杂度,即随着输入规模的增加,空间需求的增长趋势。例如,如果一个算法的空间复杂度为O(n),这意味着它的额外空间需求与输入规模成线性关系。如果空间复杂度为O(1),则表示算法的空间需求是常数,与输入规模无关。
常见空间复杂度
常见空间复杂度: O(1) O(n)
总结
对于一个算法,其时间复杂度和空间复杂度往往是相互影响的。它们是一体两面,互为表里,类似于“阴阳”。因此当我们追求一个好的时间复杂度时,可能会使空间复杂度的性能变差,这也叫牺牲空间换取时间,有很多经典算法就是这样设计的;当追求一个较好空间复杂度时,可能会使时间复杂度的性能变差,这也叫牺牲时间换取空间。具体采用何种策略,要看我们具体的实际要求,比如时间相对重要时,我们要“保时”;空间相对重要时我们要“保空”。