算法复杂度分析
1. 迭代和递归
在算法中,两种基本的程序控制结构是迭代和递归。迭代是一种重复执行某个任务的控制结构,它在满足一定条件下重复执行代码,直到条件不再满足,就是我们所熟知的“直到型循环”。 思考一下,我们常用的迭代控制结构有哪些?
而递归是一种算法策略,通过函数调用自身来解决问题,包含“递”和“归”两个阶段。 如何理解递归?它和迭代有哪些不同?开销如何?
1.1 迭代
1.1.1 for 循环(forLoop)
int forLoop(int n) {
int res = 0; // 通常使用英文缩写res表示“结果”的英文“result”
// 循环求和 1, 2, ..., n-1, n
for (int i = 1; i <= n; i++) { // 循环体:初始化条件变量i、循环结束条件、更新i
res += i;
}
return res;
}
1.1.2 while 循环(whileLoop)
int whileLoop(int n){
int res = 0; // 初始化结果变量
int i = 1; // 初始化条件变量
while (i <= n){ // 循环结束条件:条件变量i 大于n
res += i; // 实现res自增
i++; // 更新条件变量
}
return res; // 返回结果
}
1.1.3 嵌套循环
//我们可以在一个循环结构内嵌套另一个循环结构,下面以 for 循环为例:
/* 双层 for 循环 */
char *nestedForLoop(int n) {
// n * n 为对应点数量,"(i, j), " 对应字符串长最大为 6+10*2,加上最后一个空字符 \0 的额外空间
int size = n * n * 26 + 1;
char *res = malloc(size * sizeof(char));
// 循环 i = 1, 2, ..., n-1, n
for (int i = 1; i <= n; i++) {
// 循环 j = 1, 2, ..., n-1, n
for (int j = 1; j <= n; j++) {
char tmp[26];
snprintf(tmp, sizeof(tmp), "(%d, %d), ", i, j);
strncat(res, tmp, size - strlen(res) - 1);
}
}
return res;
}
1.2 递归
//2. 递归:
//递归 recursion」是一种算法策略,通过函数调用自身来解决问题。它主要包含两个阶段:
//递:程序不断深入地调用自身,通常传入更小或更简化的参数,直到达到“终止条件”。
//归:触发“终止条件”后,程序从最深层的递归函数开始逐层返回,汇聚每一层的结果。
// 我将递归简单理解为:“调用、停止、返回”,就像闯关一样,不停闯关,直到胜利,然后返回将每一关的
// 战利品带回去。
//而从实现的角度看,递归代码主要包含三个要素:
//
//终止条件:用于决定什么时候由“递”转“归”。--理解为何时胜利
//递归调用:对应“递”,函数调用自身,通常输入更小或更简化的参数。--理解为每闯一关的战利品
//返回结果:对应“归”,将当前递归层级的结果返回至上一层。--理解为把战利品带走
//观察以下代码,我们只需调用函数 recur(n) ,就可以完成1+2+3+...+n的计算:
/* 递归 */
int recur(int n) {
// 终止条件
if (n == 1)
return 1;
// 递:递归调用
int res = recur(n - 1);
// 归:返回结果
return n + res;
}
2. 时间和空间效率比较
比较了迭代和递归的时间和空间效率,迭代通常更优于递归。为什么?
因为递归使用调用栈,每次调用都会分配内存,而迭代只需迭代变量和少量额外空间。
递归每次需要占用另外的存储空间来存储递归地址等信息,空间开销更大。
// 比较迭代和递归求和的时间和空间效率:
// 1.迭代:“从1遍历到n,每遍历一个元素就和上一个元素相加,最后可得到总和f(n)”
// 2.递归:”将求和问题分解为n个子问题:f(n)=n+f(n-1)、n+f(n-2)、直到f(1)=1“
// 迭代每遍历一个元素就求和一次即可,而递归需要将求和问题分解为n个子问题,而每一个子问题都需要
//额外的存储空间来存储调用变量、调用地址等信息,这会占用大量额外的存储空间。
// 因此,递归的时间和空间效率往往不如迭代。
//1.调用栈
//递归函数每次调用自身时,系统都会为新开启的函数分配内存,以存储局部变量、调用地址和其他信息等。
// 这将导致两方面的结果。
//函数的上下文数据都存储在称为“栈帧空间”的内存区域中,直至函数返回后才会被释放。
//因此,递归通常比迭代更加耗费内存空间。
//递归调用函数会产生额外的开销。因此递归通常比循环的时间效率更低。
//2.尾递归
//有趣的是,如果函数在返回前的最后一步才进行递归调用,则该函数可以被编译器或解释器优化,
// 使其在空间效率上与迭代相当。这种情况被称为「尾递归 tail recursion」。
//普通递归:当函数返回到上一层级的函数后,需要继续执行代码,因此系统需要保存上一层调用的上下文。
//尾递归:递归调用是函数返回前的最后一个操作,这意味着函数返回到上一层级后,
//无须继续执行其他操作,因此系统无须保存上一层函数的上下文。
2.1 调用栈
递归函数的调用栈会占用大量内存,导致内存效率低下。
2.2 尾递归
尾递归是一种特殊情况,最后一步进行递归调用,可以在空间效率上与迭代相当。 (部分解释器和编译器可能不会做出优化)
int forLoopRecur(int n) {
// 使用迭代模拟尾递归
// ...
return res;
}
3. 时间复杂度分析
时间复杂度分析关注算法运行时间随数据量增大的增长趋势。
3.1 常数阶
void algorithm_A(int n) {
// 只有一个打印操作:常数阶
printf("%d", 0);
}
3.2 线性阶
void algorithm_B(int n) {
// 遍历n个元素:线性阶
for (int i = 0; i < n; i++) {
printf("%d", 0);
}
}
3.3 常数阶
void algorithm_C(int n) {
// 遍历有限个元素:常数阶
for (int i = 0; i < 1000000; i++) {
printf("%d", 0);
}
}
算法 A 只有1个打印操作,算法运行时间不随着n增大而增长。我们称此算法的时间复杂度为“常数阶”。
算法 B 中的打印操作需要循环n 次,算法运行时间随着n增大呈线性增长。此算法的时间复杂度被称为“线性阶”。
算法 C 中的打印操作需要循环1000000次,虽然运行时间很长,但它与输入数据大小n无关。因此 C 的时间复杂度和 A 相同,仍为“常数阶”。