复杂度分析
算法效率评估
在算法设计中,我们追求以下两个层面的目标。
- 找到问题解法:算法需要再规定的输入范围内可靠地求得问题的正确解
- 寻求最优解法:同一个问题可能存在多种解法,我们希望找到尽可能高效的算法。
也就是说,在能够解决问题的前提下,算法效率已经成为衡量算法优劣的主要评价指标,它包括以下两个度。
- 时间效率:算法运行速度的快慢。
- 空间效率:算法占用内存空间的大小。
复杂度分析能够体现算法运行所需要的时间和空间资源与输入数据大小之间的关系。它描述了随着输入数据的增加,算法执行所需要的时间和空间的增长趋势。
迭代与递归
在算法中,重复执行某个任务是很常见的,它与复杂度分析息息相关。因此,在介绍时间复杂度和空间复杂度之前,先了解如何在程序中实现重复执行任务,即来那个黄总基本的程序控制结构:迭代、递归
迭代
迭代(iteration)是一种重复执行某任务的控制结构。在迭代中,程序会在满足一定的条件下重复执行某代码,知道这个条件不再满足。
1.for循环
for
循环是最常见的迭代形式之一,适合在预先知道迭代次数时使用。
以下函数基于for循环实现了求和1+2+3+...+n,求和结果使用res记录。
int forLoop(int n){int res = 0;// 循环求和 1,2,3,4...,nfor (int i = 1; i <= n; i++){res += i;}return res;
}
流程图:
此求和函数的操作与输入数据大小n成正比,或者说成“线性关系”。实际上,时间复杂度描述的就是这个“线性关系”。
2.while循环
与for循环类似,while
循环也是一种实现迭代的方法。在while循环中,每轮都会先检查条件,如果条件为真,则继续执行否则就结束循环。
/*while 循环*/
int whileLoop(int n){int res = 0;int i = 1;while (i <= n){res += i;i++;}return res;
}
while循环比for循环自由度更高。在while循环中,我们可以自由地设计条件变量的初始化和更新步骤。
3.嵌套循环
我们可以在一个循环结构内嵌套另一个循环结构,下面以for循环为例:
/* 双层 for 循环 */
string nestedForLoop(int n) {ostringstream res;// 循环 i = 1, 2, ..., n-1, nfor (int i = 1; i <= n; ++i) {// 循环 j = 1, 2, ..., n-1, nfor (int j = 1; j <= n; ++j) {res << "(" << i << ", " << j << "), ";}}return res.str();
}
嵌套循环流程框图:
在这种情况下,函数的操作数量与n2成正比,或者说算法运行时间和输入数据大小n成“平方关系”。我们可以继续添加嵌套循环,每一次嵌套都是一次“升维”,将会使其时间复杂度提高至“立方关系”“四次关系”,以此类推。
递归
递归(recursion)是一种算法策略,通过函数调用自身来解决问题。它主要包含两个阶段。
- 递:程序不断深入地调用自身,通常传入更小或更简化的参数,知道达到“终止条件”。
- 归:触发“终止条件”后,程序从最深层的递归函数开始逐层返回,汇聚每一层的结果。
而从实现的角度看,递归代码主要包含三个要素。
- 终止条件:用于决定什么时候由“递”转为“归”。
- 递归调用:对应“递”,函数调用自身,通常输入更小后更简化的参数。
- 返回结果:对应“归”,将当前递归层级的结果返回至上一层。
观察以下代码,我们只需要调用函数recur(n)
就可以完成1+2+3+...+n的计算
/*递归调用*/
int recur(int n){if (n == 1){return 1;}return n + recur(n - 1);
}
递归过程:
虽然从计算角度看,迭代与递归可以得到相同的结果,但他们代表了两种完全不同的思考和解决问题的范式。
- 迭代:“自下而上”地解决问题。从最基础的步骤开始,然后不断重复或累加这些步骤,直到任务完成。
- 递归:“自上而下”地解决问题。将原问题分解为更小的子问题,这些子问题和原问题具有相同的形式。接下来将子问题继续分解为更小的子问题,直到基本情况时停止(基本情况的解是已知的)
上述求和函数为例,设问题 𝑓(𝑛) = 1 + 2 + ⋯ + 𝑛 。
- 迭代:在循环中模拟求和过程,从1遍历到n,每轮执行求和操作,即可求得 𝑓(𝑛) 。
- 递归:将问题分解为子问题 𝑓(𝑛) = n + 𝑓(𝑛 - 1),不断(递归地)分解下去,直至基本情况 𝑓(1) = 1 时 终止。
1.调用栈
递归函数每次调用自身时,系统都会为新开启的函数分配内存,以存储局部变量、调用地址和其他信息等。这将导致两方面的结果。
- 函数的上下文数据都存储在称为“栈帧空间”的内存区域中,直至函数返回后才会被释放。因此递归通常比迭代更加耗费内存空间。
- 递归调用函数会产生额外的开销。因此递归通常比循环的时间效率更低。
在上述递归过程中,在触发终止条件前,同时存在n个未返回的递归函数,递归深度为n。
2.尾递归
如果函数在返回前的最后一步才进行递归调用,则该函数可以被编译器或解释器优化,使其在空间效率上与迭代相当。这种情况被称为尾递归(tail recursion)。
- 普通递归:当函数返回到上一层级的函数后,需要继续执行代码,因此系统需要保存上一层调用的上下文
- 尾递归:递归调用时函数返回前的最后一个操作,这意味着函数返回到上一层级后,无需继续执行其他操作,因此系统无需保存上一层函数的上下文。
以计算1+2+...+n为例,我们将结果设为函数参数res,从而实现尾递归:
/*尾递归*/
int tailRecur(int n, int res){// 终止条件if (n == 0)return res;// 尾递归调用return tailRecur(n - 1, res + n);
}
普通递归:求和操作是在“归”的过程中执行的,每层返回后都要执行一次求和操作。
尾递归:求和操作是在“递”的过程中执行的,“归”的过程只需层层返回。
尾递归过程:
注意:许多编译器或解释器并不支持尾递归优化。例如Python、Java、C++等默认不支持尾递归优化。
3.递归树
当处理与“分治”相关的算法问题时,递归往往比迭代的思路更加直观、代码更加易读。以“斐波那契数列”为例:
给定一个斐波那契数列 0,1,1,2,3,5,8,13,...,求该数列的第n个数字。
设斐波那契数列的第n个数字为 𝑓(𝑛) ,易得两个结论。
- 数列的前两个数字为 𝑓(1) = 0 和 𝑓(2) = 1。
- 数列中的每个数字是前两个数字的和,即 𝑓(𝑛) = 𝑓(𝑛 - 1) + 𝑓(𝑛 - 2)。
按照地推关系进行递归调用,将前两个数字作为终止条件,便可写出递归代码。调用fib(n)即可得到斐波那契数列的第n个数字:
/*斐波那契数列的第n个数字 0,1,1,2,3,5,8...... */
int fib(int n){if (n == 2 || n == 1)return n - 1;return fib(n - 1) + fib(n - 2);
}
观察以上代码,我们在函数内递归调用了两个函数,这意味着从一个调用产生了两个调用分支。如图所示,这样不断递归调用下去,最终将产生一棵层数为n的递归树(recursion tree)。
从本质上看,递归体现了“将问题分解为更小的子问题”的思维范式,这种分治策略至关重要。
- 从算法角度看,搜索、排序、回溯、分治、动态规划等许多重要算法策略直接或间接地应用了这种思维方式。
- 从数据结构看,递归天然适合处理链表、树和图的相关问题,因为它们非常适合用分治思想进行分析
两者对比
迭代 | 递归 | |
实现方式 | 循环结构 | 函数调用自身 |
时间效率 | 效率通常较高,无函数调用开销 | 每次函数调用都会产生开销 |
内存使用 | 通常使用固定大小的内存空间 | 累计函数调用可能使用大量的栈帧空间 |
适用问题 | 适用于简单循环任务,代码直观、可读性好 | 适用于子问题分解,如树、图、分治、回溯等,代码结构简洁、清晰。 |
以上述递归函数为例,求和操作在递归的“归”阶段进行,这意味着最初被调用的函数实际上是最后完成其求和操作的,这种工作机制与栈的“先入后出”原则异曲同工。
事实上,“调用栈”和“栈帧空间”这类递归术语已经暗示了递归与栈之间的密切关系。
- 递:当函数被调用时,系统会在“调用栈”上为该函数分配新的栈帧,用于存储函数的局部变量、参数、返回地址等数据
- 归:当函数完成执行并返回时,对应的栈帧会被从“调用栈”上移除,恢复之前函数的执行环境。
总之,选择迭代还是递归取决于特定问题的性质。在编程实践中,权衡两者的优劣并根据情景选择合适的方法至关重要。