算法之旅2.0-复杂度分析
在算法中,重复执行某个任务是很常见的,它与复杂度分析息息相关。因此,在介绍时间复杂度和空间复杂度之前,我们先来了解如何在程序中实现重复执行任务,即两种基本的程序控制结构:迭代、递归。
迭代
迭代(iteration)是一种重复执行某个任务的控制结构。在迭代中,程序会在满足一定的条件下重复执行某段代码,直到这个条件不再满足。
for循环
for 循环是最常见的迭代形式之一,适合在预先知道迭代次数时使用。
以下函数基于 for 循环实现了求和1+2+...+n,求和结果使用变量 res 记录。
func forLoop(n int) int{
res:=0
for i:=1;i<=n;i++{
res+=i
}
return res
}
下图是该求和函数的流程框图。
此求和函数的操作数量与输入数据大小n成正比,或者说成“线性关系”。实际上,时间复杂度描述的就是这个“线性关系”。
嵌套循环
可以在一个循环结构内嵌套另一个循环结构,下面以 for 循环为例:
func nestedForLoop(n int) string{
res:=""
// 循环i= 1,2,...n-1,n
for i:=1;i<=n;i++{
for j:=1;j<=n;j++{
// 循环j=1,2,...,n-1,n
res+=fmt.Sprintf("(%d, %d), ",i,j)
}
}
return res
}
在这种情况下,函数的操作数量与n2成正比,或者说算法运行时间和输入数据大小n成“平方关系”。
我们可以继续添加嵌套循环,每一次嵌套都是一次“升维”,将会使时间复杂度提高至“立方关系”“四次方关系”,以此类推。
递归
递归(recursion)是一种算法策略,通过函数调用自身来解决问题。它主要包含两个阶段。
- 递:程序不断深入地调用自身,通常传入更小或更简化的参数,直到达到“终止条件”。
- 归:触发“终止条件”后,程序从最深层的递归函数开始逐层返回,汇聚每一层的结果。
而从实现的角度看,递归代码主要包含三个要素。
- 1.终止条件:用于决定什么时候由“递”转“归”。
- 2.递归调用:对应“递”,函数调用自身,通常输入更小或更简化的参数。
- 3.返回结果:对应“归”,将当前递归层级的结果返回至上一层。
观察以下代码,我们只需调用函数 recur(n)
,就可以完成1+2+...+n的计算:
// 递归
func recur(n int) int{
// 终止条件
if n==1{
return 1
}
// 递: 递归调用
res:= recur(n-1)
// 归: 返回结果
return n+res
}
下图展示了该函数的递归过程。
虽然从计算角度看,迭代与递归可以得到相同的结果,但它们代表了两种完全不同的思考和解决问题的范式
。
- 迭代:“自下而上”地解决问题。从最基础的步骤开始,然后不断重复或累加这些步骤,直到任务完成。
- 递归:“自上而下”地解决问题。将原问题分解为更小的子问题,这些子问题和原问题具有相同的形式。接下来将子问题继续分解为更小的子问题,直到基本情况时停止(基本情况的解是已知的)。
以上述求和函数为例,设问题 f(n)=1+2+...+n 。
- 迭代:在循环中模拟求和过程,从 1 遍历到 n,每轮执行求和操作,即可求得 f(n)。
- 递归:将问题分解为子问题 f(n)=n+f(n−1),不断(递归地)分解下去,直至基本情况 f(1)=1 时终止。
调用栈
递归函数每次调用自身时,系统都会为新开启的函数分配内存,以存储局部变量、调用地址和其他信息等。这将导致两方面的结果。
- 函数的上下文数据都存储在称为“栈帧空间”的内存区域中,直至函数返回后才会被释放。因此,
递归通常比迭代更加耗费内存空间
。 - 递归调用函数会产生额外的开销。
因此递归通常比循环的时间效率更低
。
如图所示,在触发终止条件前,同时存在 n 个未返回的递归函数,递归深度为n 。
在实际中,编程语言允许的递归深度通常是有限的,过深的递归可能导致栈溢出错误。
尾递归
有趣的是,如果函数在返回前的最后一步才进行递归调用
,则该函数可以被编译器或解释器优化,使其在空间效率上与迭代相当。这种情况被称为尾递归(tail recursion)。
- 普通递归:当函数返回到上一层级的函数后,需要继续执行代码,因此系统需要保存上一层调用的上下文。
- 尾递归:递归调用是函数返回前的最后一个操作,这意味着函数返回到上一层级后,无须继续执行其他操作,因此系统无须保存上一层函数的上下文。
以计算1+2+...+n为例,可以将结果变量 res
设为函数参数,从而实现尾递归:
// 尾递归
func tailRecur(n int,res int) int{
// 终止条件
if n==0{
return res
}
// 尾递归调用
return tailRecur(n-1, res+n)
}
尾递归的执行过程如图所示。对比普通递归和尾递归,两者的求和操作的执行点是不同的。
- 普通递归:求和操作是在“归”的过程中执行的,每层返回后都要再执行一次求和操作。
- 尾递归:求和操作是在“递”的过程中执行的,“归”的过程只需层层返回。
递归树
当处理与“分治”相关的算法问题时,递归往往比迭代的思路更加直观、代码更加易读。以“斐波那契数列”为例。
给定一个斐波那契数列 0,1,1,2,3,5,8,13,...,求该数列的第 n 个数字。
设斐波那契数列的第n个数字为f(n),易得两个结论。
- 数列的前两个数字为f(1)=0和f(2)=1。
- 数列中的每个数字是前两个数字的和,即f(n)=f(n−1)+f(n−2)。
按照递推关系进行递归调用,将前两个数字作为终止条件,便可写出递归代码。调用 fib(n)
即可得到斐波那契数列的第n个数字:
func fib(n int) int{
// 终止条件 f(1)=0,f(2)=1
if n==1||n==2{
return n-1
}
// 递归调用f(n)=f(n-1)+f(n-2)
res:=fib(n-1)+fib(n-2)
return res
}
观察以上代码,我们在函数内递归调用了两个函数,这意味着从一个调用产生了两个调用分支。如图所示,这样不断递归调用下去,最终将产生一棵层数为n的递归树(recursion tree)
。
从本质上看,递归体现了“将问题分解为更小子问题”的思维范式,这种分治策略至关重要。
- 从算法角度看,搜索、排序、回溯、分治、动态规划等许多重要算法策略直接或间接地应用了这种思维方式。
- 从数据结构角度看,递归天然适合处理链表、树和图的相关问题,因为它们非常适合用分治思想进行分析。
时间复杂度
推算方法
第一步:统计操作数量
针对代码,逐行从上到下计算即可。然而,由于上述c∗f(n)中的常数项c可以取任意大小,因此操作数量T(n)中的各种系数、常数项都可以忽略。根据此原则,可以总结出以下计数简化技巧。
- 1.忽略T(n)中的常数项。因为它们都与n无关,所以对时间复杂度不产生影响。
- 2.省略所有系数。例如,循环2n次、5n+1次等,都可以简化记为n次,因为n前面的系数对时间复杂度没有影响。
- 3.循环嵌套时使用乘法。总操作数量等于外层循环和内层循环操作数量之积,每一层循环依然可以分别套用第 1. 点和第 2. 点的技巧。
给定一个函数,我们可以用上述技巧来统计操作数量:
func algorithm(n int) {
a := 1 // +0(技巧 1)
a = a + n // +0(技巧 1)
// +n(技巧 2)
for i := 0; i < 5 * n + 1; i++ {
fmt.Println(0)
}
// +n*n(技巧 3)
for i := 0; i < 2 * n; i++ {
for j := 0; j < n + 1; j++ {
fmt.Println(0)
}
}
}
以下公式展示了使用上述技巧前后的统计结果,两者推算出的时间复杂度都为O(n2)。
第二步:判断渐近上界
时间复杂度由T(n)中最高阶的项来决定。这是因为在n趋于无穷大时,最高阶的项将发挥主导作用,其他项的影响都可以忽略。
展示了一些例子,其中一些夸张的值是为了强调“系数无法撼动阶数”这一结论。当n趋于无穷大时,这些常数变得无足轻重。
常见类型
设输入数据大小为n,常见的时间复杂度类型如图所示(按照从低到高的顺序排列)。
常数阶O(1)
常数阶的操作数量与输入数据大小n无关,即不随着n的变化而变化。
在以下函数中,尽管操作数量 size
可能很大,但由于其与输入数据大小n无关,因此时间复杂度仍为O(1):
// 常数阶
func constant(n int) int{
count:=0
size:=10000
for i:=0;i<size;i++{
count++
}
return count
}
线性阶O(n)
线性阶的操作数量相对于输入数据大小n以线性级别增长。线性阶通常出现在单层循环中:
// 线性阶
func linear(n int) int{
count := 0
for i:=0;i<n;i++{
count++
}
return count
}
遍历数组和遍历链表等操作的时间复杂度均为O(n),其中n为数组或链表的长度:
// 线性阶 遍历数组
func arrayTraversal(nums []int) int{
count:=0
// 循环次数与数组长度成正比
for range nums{
count++
}
return count
}
平方阶O(n2)
平方阶的操作数量相对于输入数据大小n以平方级别增长。平方阶通常出现在嵌套循环中,外层循环和内层循环的时间复杂度都为O(n),因此总体的时间复杂度为O(n2):
// 平方阶
func quadratic(n int) int{
count:=0
// 循环次数与数据大小n成平方关系
for i:=0;i<n;i++{
for j:=0;j<n;j++{
count++
}
}
return count
}
下图对比了常数阶、线性阶和平方阶三种时间复杂度。
以冒泡排序为例,外层循环执行n−1次,内层循环执行n−1,n−2,n−3,...2,1次,平均n/2次,因此复杂度为O(n−1)n/2=O(n2):
// 平方阶 冒泡排序
func bubbleSort(nums []int) int{
count := 0 // 计数器
// 外循环: 未排序区间为[0, i]
for i:=len(nums)-1; i>0;i--{
// 内循环:将未排序区间[0, i]中最大的元素交换至该区间最右端
for j:=0;j<i;j++{
if nums[j]>nums[j+1]{
// 交换nums[j]与nums[j+1]
tmp:=nums[j]
nums[j]=nums[j+1]
nums[j+1]=tmp
count+=3 // 元素交换包含3个单元操作
}
}
}
return count
}
指数阶O(2n)
生物学的“细胞分裂”是指数阶增长的典型例子:初始状态为1个细胞,分裂一轮后变为2个,分裂两轮后变为4个,以此类推,分裂n轮后有 2n个细胞。
// 指数阶 循环实现
func exponential(n int)int{
count,base:=0,1
// 细胞每轮一分为2,形成数列 1,2,4,8,...,2^(n-1)
for i:=0;i<n;i++{
for j:=0;j<base;j++{
count++
}
base *=2
}
return count
}
在实际算法中,指数阶常出现于递归函数中。例如在以下代码中,其递归地一分为二,经过n次分裂后停止:
// 指数阶 递归实现
func expRecur(n int) int{
if n==1{
return 1
}
return expRecur(n-1) + expRecur(n-1)+1
}
指数阶增长非常迅速,在穷举法(暴力搜索、回溯等)中比较常见。对于数据规模较大的问题,指数阶是不可接受的,通常需要使用动态规划或贪心算法等来解决。
对数阶O(logn)
与指数阶相反,对数阶反映了“每轮缩减到一半”的情况。设输入数据大小为n,由于每轮缩减到一半,因此循环次数是log2n,即2n的反函数。
以下代码模拟了“每轮缩减到一半”的过程,时间复杂度为O(log2n),简记为O(logn):
// 对数阶 循环实现
func logarithmic(n int) int{
count:=0
for n>1{
n=n/2
count++
}
return count
}
与指数阶类似,对数阶也常出现于递归函数中。以下代码形成了一棵高度为log2n的递归树:
func logRecur(n int) int{
if n<=1{
return 0
}
return logRecur(n/2)+1
}
对数阶常出现于基于分治策略的算法中,体现了“一分为多”和“化繁为简”的算法思想。它增长缓慢,是仅次于常数阶的理想的时间复杂度。
线性对数阶O(nlogn)
线性对数阶常出现于嵌套循环中,两层循环的时间复杂度分别为O(logn)和 O(n)。相关代码如下:
// 线性对数阶
func linearLogRecur(n int) int{
if n<=1{
return 1
}
count:= linearLogRecur(n/2)+linearLogRecur(n/2)
for i:=0;i<n;i++{
count++
}
return count
}
下图展示了线性对数阶的生成方式。二叉树的每一层的操作总数都为n,树共有 log2n+1层,因此时间复杂度为O(nlogn)。
主流排序算法的时间复杂度通常为O(nlogn),例如快速排序、归并排序、堆排序等。
阶乘阶O(n!)
阶乘阶对应数学上的“全排列”问题。给定n个互不重复的元素,求其所有可能的排列方案,方案数量为:
阶乘通常使用递归实现。如图 2-14 和以下代码所示,第一层分裂出n个,第二层分裂出n−1个,以此类推,直至第n层时停止分裂:
// 阶乘阶 递归实现
func factorialRecur(n int) int{
if n==0{
return 1
}
count:=0
// 从1分裂出n个
for i:=0;i<n;i++{
count+=factorialRecur(n-1)
}
return count
}
最差,最佳,平均时间复杂度
算法的时间效率往往不是固定的,而是与输入数据的分布有关。
值得说明的是,我们在实际中很少使用最佳时间复杂度,因为通常只有在很小概率下才能达到,可能会带来一定的误导性。而最差时间复杂度更为实用,因为它给出了一个效率安全值,让我们可以放心地使用算法。