Skip to content

算法之旅2.0-复杂度分析

约 3985 字大约 13 分钟

数据结构复杂度分析

2023-03-12

在算法中,重复执行某个任务是很常见的,它与复杂度分析息息相关。因此,在介绍时间复杂度和空间复杂度之前,我们先来了解如何在程序中实现重复执行任务,即两种基本的程序控制结构:迭代、递归。

迭代

迭代(iteration)是一种重复执行某个任务的控制结构。在迭代中,程序会在满足一定的条件下重复执行某段代码,直到这个条件不再满足。

for循环

for 循环是最常见的迭代形式之一,适合在预先知道迭代次数时使用。

以下函数基于 for 循环实现了求和1+2+...+n1+2+...+n,求和结果使用变量 res 记录。

func forLoop(n int) int{
  res:=0
  for i:=1;i<=n;i++{
    res+=i
  }
  return res
}

下图是该求和函数的流程框图。

此求和函数的操作数量与输入数据大小nn成正比,或者说成“线性关系”。实际上,时间复杂度描述的就是这个“线性关系”。

嵌套循环

可以在一个循环结构内嵌套另一个循环结构,下面以 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
}

在这种情况下,函数的操作数量与n2n2成正比,或者说算法运行时间和输入数据大小nn成“平方关系”。

我们可以继续添加嵌套循环,每一次嵌套都是一次“升维”,将会使时间复杂度提高至“立方关系”“四次方关系”,以此类推。

递归

递归(recursion)是一种算法策略,通过函数调用自身来解决问题。它主要包含两个阶段。

  • 递:程序不断深入地调用自身,通常传入更小或更简化的参数,直到达到“终止条件”。
  • 归:触发“终止条件”后,程序从最深层的递归函数开始逐层返回,汇聚每一层的结果。

而从实现的角度看,递归代码主要包含三个要素。

  • 1.终止条件:用于决定什么时候由“递”转“归”。
  • 2.递归调用:对应“递”,函数调用自身,通常输入更小或更简化的参数。
  • 3.返回结果:对应“归”,将当前递归层级的结果返回至上一层。

观察以下代码,我们只需调用函数 recur(n) ,就可以完成1+2+...+n1+2+...+n的计算:

// 递归
func recur(n int) int{
  // 终止条件
  if n==1{
    return 1
  }
  // 递: 递归调用
  res:= recur(n-1)
  // 归: 返回结果
  return n+res
}

下图展示了该函数的递归过程。

虽然从计算角度看,迭代与递归可以得到相同的结果,但它们代表了两种完全不同的思考和解决问题的范式

  • 迭代:“自下而上”地解决问题。从最基础的步骤开始,然后不断重复或累加这些步骤,直到任务完成。
  • 递归:“自上而下”地解决问题。将原问题分解为更小的子问题,这些子问题和原问题具有相同的形式。接下来将子问题继续分解为更小的子问题,直到基本情况时停止(基本情况的解是已知的)。

以上述求和函数为例,设问题 f(n)=1+2+...+nf(n)=1+2+...+n

  • 迭代:在循环中模拟求和过程,从 11 遍历到 nn,每轮执行求和操作,即可求得 f(n)f(n)
  • 递归:将问题分解为子问题 f(n)=n+f(n1)f(n)=n+f(n-1),不断(递归地)分解下去,直至基本情况 f(1)=1f(1)=1 时终止。

调用栈

递归函数每次调用自身时,系统都会为新开启的函数分配内存,以存储局部变量、调用地址和其他信息等。这将导致两方面的结果。

  • 函数的上下文数据都存储在称为“栈帧空间”的内存区域中,直至函数返回后才会被释放。因此,递归通常比迭代更加耗费内存空间
  • 递归调用函数会产生额外的开销。因此递归通常比循环的时间效率更低

如图所示,在触发终止条件前,同时存在 nn 个未返回的递归函数,递归深度为n递归深度为n

在实际中,编程语言允许的递归深度通常是有限的,过深的递归可能导致栈溢出错误。

尾递归

有趣的是,如果函数在返回前的最后一步才进行递归调用,则该函数可以被编译器或解释器优化,使其在空间效率上与迭代相当。这种情况被称为尾递归(tail recursion)。

  • 普通递归:当函数返回到上一层级的函数后,需要继续执行代码,因此系统需要保存上一层调用的上下文。
  • 尾递归:递归调用是函数返回前的最后一个操作,这意味着函数返回到上一层级后,无须继续执行其他操作,因此系统无须保存上一层函数的上下文。

以计算1+2+...+n1+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,...0,1,1,2,3,5,8,13,...,求该数列的第 nn 个数字。

设斐波那契数列的第nn个数字为f(n)f(n),易得两个结论。

  • 数列的前两个数字为f(1)=0f(1)=0f(2)=1f(2)=1
  • 数列中的每个数字是前两个数字的和,即f(n)=f(n1)+f(n2)f(n)=f(n-1)+f(n-2)

按照递推关系进行递归调用,将前两个数字作为终止条件,便可写出递归代码。调用 fib(n) 即可得到斐波那契数列的第nn个数字:

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
}

观察以上代码,我们在函数内递归调用了两个函数,这意味着从一个调用产生了两个调用分支。如图所示,这样不断递归调用下去,最终将产生一棵层数为nn递归树(recursion tree)

从本质上看,递归体现了“将问题分解为更小子问题”的思维范式,这种分治策略至关重要。

  • 从算法角度看,搜索、排序、回溯、分治、动态规划等许多重要算法策略直接或间接地应用了这种思维方式。
  • 从数据结构角度看,递归天然适合处理链表、树和图的相关问题,因为它们非常适合用分治思想进行分析。

时间复杂度

推算方法

第一步:统计操作数量

针对代码,逐行从上到下计算即可。然而,由于上述cf(n)c*f(n)中的常数项cc可以取任意大小,因此操作数量T(n)T(n)中的各种系数、常数项都可以忽略。根据此原则,可以总结出以下计数简化技巧。

  • 1.忽略T(n)T(n)中的常数项。因为它们都与nn无关,所以对时间复杂度不产生影响。
  • 2.省略所有系数。例如,循环2n2n次、5n+15n+1次等,都可以简化记为nn次,因为nn前面的系数对时间复杂度没有影响。
  • 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)O(n^2)

第二步:判断渐近上界

时间复杂度由T(n)T(n)中最高阶的项来决定。这是因为在nn趋于无穷大时,最高阶的项将发挥主导作用,其他项的影响都可以忽略。

展示了一些例子,其中一些夸张的值是为了强调“系数无法撼动阶数”这一结论。当nn趋于无穷大时,这些常数变得无足轻重。

常见类型

设输入数据大小为nn,常见的时间复杂度类型如图所示(按照从低到高的顺序排列)。

常数阶O(1)O(1)

常数阶的操作数量与输入数据大小nn无关,即不随着nn的变化而变化。

在以下函数中,尽管操作数量 size 可能很大,但由于其与输入数据大小nn无关,因此时间复杂度仍为O(1)O(1)

// 常数阶
func constant(n int) int{
  count:=0
  size:=10000
  for i:=0;i<size;i++{
    count++
  }
  return count
}

线性阶O(n)O(n)

线性阶的操作数量相对于输入数据大小nn以线性级别增长。线性阶通常出现在单层循环中:

// 线性阶
func linear(n int) int{
  count := 0
  for i:=0;i<n;i++{
    count++
  }
  return count
}

遍历数组和遍历链表等操作的时间复杂度均为O(n)O(n),其中nn为数组或链表的长度:

// 线性阶 遍历数组
func arrayTraversal(nums []int) int{
  count:=0
  // 循环次数与数组长度成正比
  for range nums{
    count++
  }
  return count
}

平方阶O(n2n^2)

平方阶的操作数量相对于输入数据大小nn以平方级别增长。平方阶通常出现在嵌套循环中,外层循环和内层循环的时间复杂度都为O(n)O(n),因此总体的时间复杂度为O(n2)O(n^2)

// 平方阶
func quadratic(n int) int{
  count:=0
  // 循环次数与数据大小n成平方关系
  for i:=0;i<n;i++{
    for j:=0;j<n;j++{
      count++
    }
  }
  return count
}

下图对比了常数阶、线性阶和平方阶三种时间复杂度。

以冒泡排序为例,外层循环执行n1n-1次,内层循环执行n1,n2,n3,...2,1n-1,n-2,n-3,...2,1次,平均n/2n/2次,因此复杂度为O(n1)n/2=O(n2)O(n-1)n/2=O(n^2)

// 平方阶 冒泡排序
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(2n2^n)

生物学的“细胞分裂”是指数阶增长的典型例子:初始状态为11个细胞,分裂一轮后变为22个,分裂两轮后变为44个,以此类推,分裂nn轮后有 2n2^n个细胞。

// 指数阶 循环实现
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
}

在实际算法中,指数阶常出现于递归函数中。例如在以下代码中,其递归地一分为二,经过nn次分裂后停止:

// 指数阶 递归实现
func expRecur(n int) int{
  if n==1{
    return 1
  }
  return expRecur(n-1) + expRecur(n-1)+1
}

指数阶增长非常迅速,在穷举法(暴力搜索、回溯等)中比较常见。对于数据规模较大的问题,指数阶是不可接受的,通常需要使用动态规划或贪心算法等来解决。

对数阶O(lognlogn)

与指数阶相反,对数阶反映了“每轮缩减到一半”的情况。设输入数据大小为nn,由于每轮缩减到一半,因此循环次数是log2nlog_2n,即2n2^n的反函数。

以下代码模拟了“每轮缩减到一半”的过程,时间复杂度为O(log2n)O(log_2n),简记为O(logn)O(logn)

// 对数阶 循环实现
func logarithmic(n int) int{
  count:=0
  for n>1{
    n=n/2
    count++
  }
  return count
}

与指数阶类似,对数阶也常出现于递归函数中。以下代码形成了一棵高度为log2nlog_2n的递归树:

func logRecur(n int) int{
  if n<=1{
    return 0
  }
  return logRecur(n/2)+1
}

对数阶常出现于基于分治策略的算法中,体现了“一分为多”和“化繁为简”的算法思想。它增长缓慢,是仅次于常数阶的理想的时间复杂度。

线性对数阶O(nlognnlogn)

线性对数阶常出现于嵌套循环中,两层循环的时间复杂度分别为O(logn)O(logn)O(n)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
}

下图展示了线性对数阶的生成方式。二叉树的每一层的操作总数都为nn,树共有 log2n+1log_2n+1层,因此时间复杂度为O(nlogn)O(nlogn)

主流排序算法的时间复杂度通常为O(nlogn)O(nlogn),例如快速排序、归并排序、堆排序等。

阶乘阶O(n!n!)

阶乘阶对应数学上的“全排列”问题。给定nn个互不重复的元素,求其所有可能的排列方案,方案数量为:

阶乘通常使用递归实现。如图 2-14 和以下代码所示,第一层分裂出nn个,第二层分裂出n1n-1个,以此类推,直至第nn层时停止分裂:

// 阶乘阶 递归实现
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
}

最差,最佳,平均时间复杂度

算法的时间效率往往不是固定的,而是与输入数据的分布有关

值得说明的是,我们在实际中很少使用最佳时间复杂度,因为通常只有在很小概率下才能达到,可能会带来一定的误导性。而最差时间复杂度更为实用,因为它给出了一个效率安全值,让我们可以放心地使用算法。