笔记来源:hello 算法!
在算法中,重复执行某个任务是很常见的,它与复杂度分析息息相关。
2.1 迭代¶
迭代(iteration)是一种重复执行某个任务的控制结构。在迭代中,程序会在满足一定的条件下重复执行某段代码,直到这个条件不再满足。
1. for 循环
以下函数基于 for
循环实现了求和 1+2+⋯+n ,求和结果使用变量 res
记录。
/* for 循环 */
int forLoop(int n) {
int res = 0;
// 循环求和 1, 2, ..., n-1, n
for (int i = 1; i <= n; i++) {
res += i;
}
return res;
}
2. while 循环
与 for
循环类似,while
循环也是一种实现迭代的方法。在 while
循环中,程序每轮都会先检查条件,如果条件为真,则继续执行,否则就结束循环。
下面我们用 while
循环来实现求和 1+2+⋯+n :
/* while 循环 */
int whileLoop(int n) {
int res = 0;
int i = 1; // 初始化条件变量
// 循环求和 1, 2, ..., n-1, n
while (i <= n) {
res += i;
i++; // 更新条件变量
}
return res;
}
while
循环比 for
循环的自由度更高。在 while
循环中,我们可以自由地设计条件变量的初始化和更新步骤。
例如在以下代码中,条件变量 i 每轮进行两次更新,这种情况就不太方便用 for
循环实现:
/* while 循环(两次更新) */
int whileLoopII(int n) {
int res = 0;
int i = 1; // 初始化条件变量
// 循环求和 1, 4, 10, ...
while (i <= n) {
res += i;
// 更新条件变量
i++;
i *= 2;
}
return res;
}
总的来说,for
循环的代码更加紧凑,while
循环更加灵活,两者都可以实现迭代结构。
3. 嵌套循环
/* 双层 for 循环 */
char *nestedForLoop(int n) {
// n * n 为对应点数量,"(i, j), " 对应字符串长最大为 6+10*2,加上最后一个空字符 \0 的额外空间
// n * n 表示总共有 n² 个坐标点
//每个坐标点字符串 "(i, j), " 最多需要 26 字节(考虑数字可能很大)
//+1 是为了字符串结尾的 \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];
// 将当前 (i, j) 坐标格式化为字符串,如 "(1, 1), "
// snprintf 确保不会超出 tmp 的缓冲区大小
snprintf(tmp, sizeof(tmp), "(%d, %d), ", i, j);
//将临时字符串 tmp 追加到结果字符串 res 末尾
//strncat 的第三个参数限制追加的字符数,防止缓冲区溢出
strncat(res, tmp, size - strlen(res) - 1);
}
}
return res;
}
2.2 递归
递归(recursion)是一种算法策略,通过函数调用自身来解决问题。它主要包含两个阶段。
- 递:程序不断深入地调用自身,通常传入更小或更简化的参数,直到达到“终止条件”。
- 归:触发“终止条件”后,程序从最深层的递归函数开始逐层返回,汇聚每一层的结果。
- 我们只需调用函数
recur(n)
,就可以完成 1+2+⋯+n 的计算
/* 递归 */
int recur(int n) {
// 终止条件
if (n == 1)
return 1;
// 递:递归调用
int 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 时终止。
1.调用栈
递归函数每次调用自身时,系统都会为新开启的函数分配内存,以存储局部变量、调用地址和其他信息等。这将导致两方面的结果。
- 函数的上下文数据都存储在称为“栈帧空间”的内存区域中,直至函数返回后才会被释放。因此,递归通常比迭代更加耗费内存空间。
- 递归调用函数会产生额外的开销。因此递归通常比循环的时间效率更低。
如图 2-4 所示,在触发终止条件前,同时存在 n 个未返回的递归函数,递归深度为 n 。
图 2-4 递归调用深度
在实际中,编程语言允许的递归深度通常是有限的,过深的递归可能导致栈溢出错误。
2. 尾递归¶
有趣的是,如果函数在返回前的最后一步才进行递归调用,则该函数可以被编译器或解释器优化,使其在空间效率上与迭代相当。这种情况被称为尾递归(tail recursion)。
- 普通递归:当函数返回到上一层级的函数后,需要继续执行代码,因此系统需要保存上一层调用的上下文。
- 尾递归:递归调用是函数返回前的最后一个操作,这意味着函数返回到上一层级后,无须继续执行其他操作,因此系统无须保存上一层函数的上下文。
以计算 1+2+⋯+n 为例,我们可以将结果变量 res
设为函数参数,从而实现尾递归:
/* 尾递归 */
int tailRecur(int n, int res) {
// 终止条件
if (n == 0)
return res;
// 尾递归调用
return tailRecur(n - 1, res + n);
}
尾递归的执行过程如图 2-5 所示。对比普通递归和尾递归,两者的求和操作的执行点是不同的。
- 普通递归:求和操作是在“归”的过程中执行的,每层返回后都要再执行一次求和操作。
- 尾递归:求和操作是在“递”的过程中执行的,“归”的过程只需层层返回。
3. 递归树
当处理与“分治”相关的算法问题时,递归往往比迭代的思路更加直观、代码更加易读。以“斐波那契数列”为例。
Question
给定一个斐波那契数列 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 个数字:
/* 斐波那契数列:递归 */
int fib(int n) {
// 终止条件 f(1) = 0, f(2) = 1
if (n == 1 || n == 2)
return n - 1;
// 递归调用 f(n) = f(n-1) + f(n-2)
int res = fib(n - 1) + fib(n - 2);
// 返回结果 f(n)
return res;
}
可视化运行
观察以上代码,我们在函数内递归调用了两个函数,这意味着从一个调用产生了两个调用分支。如图 2-6 所示,这样不断递归调用下去,最终将产生一棵层数为 n 的递归树(recursion tree)。
图 2-6 斐波那契数列的递归树
从本质上看,递归体现了“将问题分解为更小子问题”的思维范式,这种分治策略至关重要。
- 从算法角度看,搜索、排序、回溯、分治、动态规划等许多重要算法策略直接或间接地应用了这种思维方式。
- 从数据结构角度看,递归天然适合处理链表、树和图的相关问题,因为它们非常适合用分治思想进行分析。