知方号

知方号

2.2   迭代与递归

2.2   迭代与递归¶

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

2.2.1   迭代¶

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

1.   for 循环¶

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

以下函数基于 for 循环实现了求和 (1 + 2 + dots + n) ,求和结果使用变量 res 记录。需要注意的是,Python 中 range(a, b) 对应的区间是“左闭右开”的,对应的遍历范围为 (a, a + 1, dots, b-1) :

iteration.pydef for_loop(n: int) -> int: """for 循环""" res = 0 # 循环求和 1, 2, ..., n-1, n for i in range(1, n + 1): res += i return resiteration.cpp/* for 循环 */int forLoop(int n) { int res = 0; // 循环求和 1, 2, ..., n-1, n for (int i = 1; i

图 2-2 是该嵌套循环的流程框图。

图 2-2   嵌套循环的流程框图

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

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

2.2.2   递归¶

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

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

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

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

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

recursion.pydef recur(n: int) -> int: """递归""" # 终止条件 if n == 1: return 1 # 递:递归调用 res = recur(n - 1) # 归:返回结果 return n + resrecursion.cpp/* 递归 */int recur(int n) { // 终止条件 if (n == 1) return 1; // 递:递归调用 int res = recur(n - 1); // 归:返回结果 return n + res;}recursion.java/* 递归 */int recur(int n) { // 终止条件 if (n == 1) return 1; // 递:递归调用 int res = recur(n - 1); // 归:返回结果 return n + res;}recursion.cs/* 递归 */int Recur(int n) { // 终止条件 if (n == 1) return 1; // 递:递归调用 int res = Recur(n - 1); // 归:返回结果 return n + res;}recursion.go/* 递归 */func recur(n int) int { // 终止条件 if n == 1 { return 1 } // 递:递归调用 res := recur(n - 1) // 归:返回结果 return n + res}recursion.swift/* 递归 */func recur(n: Int) -> Int { // 终止条件 if n == 1 { return 1 } // 递:递归调用 let res = recur(n: n - 1) // 归:返回结果 return n + res}recursion.js/* 递归 */function recur(n) { // 终止条件 if (n === 1) return 1; // 递:递归调用 const res = recur(n - 1); // 归:返回结果 return n + res;}recursion.ts/* 递归 */function recur(n: number): number { // 终止条件 if (n === 1) return 1; // 递:递归调用 const res = recur(n - 1); // 归:返回结果 return n + res;}recursion.dart/* 递归 */int recur(int n) { // 终止条件 if (n == 1) return 1; // 递:递归调用 int res = recur(n - 1); // 归:返回结果 return n + res;}recursion.rs/* 递归 */fn recur(n: i32) -> i32 { // 终止条件 if n == 1 { return 1; } // 递:递归调用 let res = recur(n - 1); // 归:返回结果 n + res}recursion.c/* 递归 */int recur(int n) { // 终止条件 if (n == 1) return 1; // 递:递归调用 int res = recur(n - 1); // 归:返回结果 return n + res;}recursion.kt/* 递归 */fun recur(n: Int): Int { // 终止条件 if (n == 1) return 1 // 递: 递归调用 val res = recur(n - 1) // 归: 返回结果 return n + res}recursion.rb### 递归 ###def recur(n) # 终止条件 return 1 if n == 1 # 递:递归调用 res = recur(n - 1) # 归:返回结果 n + resendrecursion.zig// 递归函数fn recur(n: i32) i32 { // 终止条件 if (n == 1) { return 1; } // 递:递归调用 var res: i32 = recur(n - 1); // 归:返回结果 return n + res;}可视化运行

全屏观看 >

图 2-3 展示了该函数的递归过程。

图 2-3   求和函数的递归过程

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

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

以上述求和函数为例,设问题 (f(n) = 1 + 2 + dots + 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 + dots + n) 为例,我们可以将结果变量 res 设为函数参数,从而实现尾递归:

recursion.pydef tail_recur(n, res): """尾递归""" # 终止条件 if n == 0: return res # 尾递归调用 return tail_recur(n - 1, res + n)recursion.cpp/* 尾递归 */int tailRecur(int n, int res) { // 终止条件 if (n == 0) return res; // 尾递归调用 return tailRecur(n - 1, res + n);}recursion.java/* 尾递归 */int tailRecur(int n, int res) { // 终止条件 if (n == 0) return res; // 尾递归调用 return tailRecur(n - 1, res + n);}recursion.cs/* 尾递归 */int TailRecur(int n, int res) { // 终止条件 if (n == 0) return res; // 尾递归调用 return TailRecur(n - 1, res + n);}recursion.go/* 尾递归 */func tailRecur(n int, res int) int { // 终止条件 if n == 0 { return res } // 尾递归调用 return tailRecur(n-1, res+n)}recursion.swift/* 尾递归 */func tailRecur(n: Int, res: Int) -> Int { // 终止条件 if n == 0 { return res } // 尾递归调用 return tailRecur(n: n - 1, res: res + n)}recursion.js/* 尾递归 */function tailRecur(n, res) { // 终止条件 if (n === 0) return res; // 尾递归调用 return tailRecur(n - 1, res + n);}recursion.ts/* 尾递归 */function tailRecur(n: number, res: number): number { // 终止条件 if (n === 0) return res; // 尾递归调用 return tailRecur(n - 1, res + n);}recursion.dart/* 尾递归 */int tailRecur(int n, int res) { // 终止条件 if (n == 0) return res; // 尾递归调用 return tailRecur(n - 1, res + n);}recursion.rs/* 尾递归 */fn tail_recur(n: i32, res: i32) -> i32 { // 终止条件 if n == 0 { return res; } // 尾递归调用 tail_recur(n - 1, res + n)}recursion.c/* 尾递归 */int tailRecur(int n, int res) { // 终止条件 if (n == 0) return res; // 尾递归调用 return tailRecur(n - 1, res + n);}recursion.kt/* 尾递归 */tailrec fun tailRecur(n: Int, res: Int): Int { // 添加 tailrec 关键词,以开启尾递归优化 // 终止条件 if (n == 0) return res // 尾递归调用 return tailRecur(n - 1, res + n)}recursion.rb### 尾递归 ###def tail_recur(n, res) # 终止条件 return res if n == 0 # 尾递归调用 tail_recur(n - 1, res + n)endrecursion.zig// 尾递归函数fn tailRecur(n: i32, res: i32) i32 { // 终止条件 if (n == 0) { return res; } // 尾递归调用 return tailRecur(n - 1, res + n);}可视化运行

全屏观看 >

尾递归的执行过程如图 2-5 所示。对比普通递归和尾递归,两者的求和操作的执行点是不同的。

普通递归:求和操作是在“归”的过程中执行的,每层返回后都要再执行一次求和操作。尾递归:求和操作是在“递”的过程中执行的,“归”的过程只需层层返回。

图 2-5   尾递归过程

Tip

请注意,许多编译器或解释器并不支持尾递归优化。例如,Python 默认不支持尾递归优化,因此即使函数是尾递归形式,仍然可能会遇到栈溢出问题。

3.   递归树¶

当处理与“分治”相关的算法问题时,递归往往比迭代的思路更加直观、代码更加易读。以“斐波那契数列”为例。

Question

给定一个斐波那契数列 (0, 1, 1, 2, 3, 5, 8, 13, dots) ,求该数列的第 (n) 个数字。

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

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

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

recursion.pydef fib(n: int) -> int: """斐波那契数列:递归""" # 终止条件 f(1) = 0, f(2) = 1 if n == 1 or n == 2: return n - 1 # 递归调用 f(n) = f(n-1) + f(n-2) res = fib(n - 1) + fib(n - 2) # 返回结果 f(n) return resrecursion.cpp/* 斐波那契数列:递归 */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;}recursion.java/* 斐波那契数列:递归 */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;}recursion.cs/* 斐波那契数列:递归 */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;}recursion.go/* 斐波那契数列:递归 */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) // 返回结果 f(n) return res}recursion.swift/* 斐波那契数列:递归 */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) let res = fib(n: n - 1) + fib(n: n - 2) // 返回结果 f(n) return res}recursion.js/* 斐波那契数列:递归 */function fib(n) { // 终止条件 f(1) = 0, f(2) = 1 if (n === 1 || n === 2) return n - 1; // 递归调用 f(n) = f(n-1) + f(n-2) const res = fib(n - 1) + fib(n - 2); // 返回结果 f(n) return res;}recursion.ts/* 斐波那契数列:递归 */function fib(n: number): number { // 终止条件 f(1) = 0, f(2) = 1 if (n === 1 || n === 2) return n - 1; // 递归调用 f(n) = f(n-1) + f(n-2) const res = fib(n - 1) + fib(n - 2); // 返回结果 f(n) return res;}recursion.dart/* 斐波那契数列:递归 */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;}recursion.rs/* 斐波那契数列:递归 */fn fib(n: i32) -> i32 { // 终止条件 f(1) = 0, f(2) = 1 if n == 1 || n == 2 { return n - 1; } // 递归调用 f(n) = f(n-1) + f(n-2) let res = fib(n - 1) + fib(n - 2); // 返回结果 res}recursion.c/* 斐波那契数列:递归 */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;}recursion.kt/* 斐波那契数列:递归 */fun 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) val res = fib(n - 1) + fib(n - 2) // 返回结果 f(n) return res}recursion.rb### 斐波那契数列:递归 ###def fib(n) # 终止条件 f(1) = 0, f(2) = 1 return n - 1 if n == 1 || n == 2 # 递归调用 f(n) = f(n-1) + f(n-2) res = fib(n - 1) + fib(n - 2) # 返回结果 f(n) resendrecursion.zig// 斐波那契数列fn fib(n: i32) i32 { // 终止条件 f(1) = 0, f(2) = 1 if (n == 1 or n == 2) { return n - 1; } // 递归调用 f(n) = f(n-1) + f(n-2) var res: i32 = fib(n - 1) + fib(n - 2); // 返回结果 f(n) return res;}可视化运行

全屏观看 >

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

图 2-6   斐波那契数列的递归树

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

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

总结以上内容,如表 2-1 所示,迭代和递归在实现、性能和适用性上有所不同。

表 2-1   迭代与递归特点对比

迭代递归实现方式循环结构函数调用自身时间效率效率通常较高,无函数调用开销每次函数调用都会产生开销内存使用通常使用固定大小的内存空间累积函数调用可能使用大量的栈帧空间适用问题适用于简单循环任务,代码直观、可读性好适用于子问题分解,如树、图、分治、回溯等,代码结构简洁、清晰

Tip

如果感觉以下内容理解困难,可以在读完“栈”章节后再来复习。

那么,迭代和递归具有什么内在联系呢?以上述递归函数为例,求和操作在递归的“归”阶段进行。这意味着最初被调用的函数实际上是最后完成其求和操作的,这种工作机制与栈的“先入后出”原则异曲同工。

事实上,“调用栈”和“栈帧空间”这类递归术语已经暗示了递归与栈之间的密切关系。

递:当函数被调用时,系统会在“调用栈”上为该函数分配新的栈帧,用于存储函数的局部变量、参数、返回地址等数据。归:当函数完成执行并返回时,对应的栈帧会被从“调用栈”上移除,恢复之前函数的执行环境。

因此,我们可以使用一个显式的栈来模拟调用栈的行为,从而将递归转化为迭代形式:

recursion.pydef for_loop_recur(n: int) -> int: """使用迭代模拟递归""" # 使用一个显式的栈来模拟系统调用栈 stack = [] res = 0 # 递:递归调用 for i in range(n, 0, -1): # 通过“入栈操作”模拟“递” stack.append(i) # 归:返回结果 while stack: # 通过“出栈操作”模拟“归” res += stack.pop() # res = 1+2+3+...+n return resrecursion.cpp/* 使用迭代模拟递归 */int forLoopRecur(int n) { // 使用一个显式的栈来模拟系统调用栈 stack stack; int res = 0; // 递:递归调用 for (int i = n; i > 0; i--) { // 通过“入栈操作”模拟“递” stack.push(i); } // 归:返回结果 while (!stack.empty()) { // 通过“出栈操作”模拟“归” res += stack.top(); stack.pop(); } // res = 1+2+3+...+n return res;}recursion.java/* 使用迭代模拟递归 */int forLoopRecur(int n) { // 使用一个显式的栈来模拟系统调用栈 Stack stack = new Stack(); int res = 0; // 递:递归调用 for (int i = n; i > 0; i--) { // 通过“入栈操作”模拟“递” stack.push(i); } // 归:返回结果 while (!stack.isEmpty()) { // 通过“出栈操作”模拟“归” res += stack.pop(); } // res = 1+2+3+...+n return res;}recursion.cs/* 使用迭代模拟递归 */int ForLoopRecur(int n) { // 使用一个显式的栈来模拟系统调用栈 Stack stack = new(); int res = 0; // 递:递归调用 for (int i = n; i > 0; i--) { // 通过“入栈操作”模拟“递” stack.Push(i); } // 归:返回结果 while (stack.Count > 0) { // 通过“出栈操作”模拟“归” res += stack.Pop(); } // res = 1+2+3+...+n return res;}recursion.go/* 使用迭代模拟递归 */func forLoopRecur(n int) int { // 使用一个显式的栈来模拟系统调用栈 stack := list.New() res := 0 // 递:递归调用 for i := n; i > 0; i-- { // 通过“入栈操作”模拟“递” stack.PushBack(i) } // 归:返回结果 for stack.Len() != 0 { // 通过“出栈操作”模拟“归” res += stack.Back().Value.(int) stack.Remove(stack.Back()) } // res = 1+2+3+...+n return res}recursion.swift/* 使用迭代模拟递归 */func forLoopRecur(n: Int) -> Int { // 使用一个显式的栈来模拟系统调用栈 var stack: [Int] = [] var res = 0 // 递:递归调用 for i in (1 ... n).reversed() { // 通过“入栈操作”模拟“递” stack.append(i) } // 归:返回结果 while !stack.isEmpty { // 通过“出栈操作”模拟“归” res += stack.removeLast() } // res = 1+2+3+...+n return res}recursion.js/* 使用迭代模拟递归 */function forLoopRecur(n) { // 使用一个显式的栈来模拟系统调用栈 const stack = []; let res = 0; // 递:递归调用 for (let i = n; i > 0; i--) { // 通过“入栈操作”模拟“递” stack.push(i); } // 归:返回结果 while (stack.length) { // 通过“出栈操作”模拟“归” res += stack.pop(); } // res = 1+2+3+...+n return res;}recursion.ts/* 使用迭代模拟递归 */function forLoopRecur(n: number): number { // 使用一个显式的栈来模拟系统调用栈 const stack: number[] = []; let res: number = 0; // 递:递归调用 for (let i = n; i > 0; i--) { // 通过“入栈操作”模拟“递” stack.push(i); } // 归:返回结果 while (stack.length) { // 通过“出栈操作”模拟“归” res += stack.pop(); } // res = 1+2+3+...+n return res;}recursion.dart/* 使用迭代模拟递归 */int forLoopRecur(int n) { // 使用一个显式的栈来模拟系统调用栈 List stack = []; int res = 0; // 递:递归调用 for (int i = n; i > 0; i--) { // 通过“入栈操作”模拟“递” stack.add(i); } // 归:返回结果 while (!stack.isEmpty) { // 通过“出栈操作”模拟“归” res += stack.removeLast(); } // res = 1+2+3+...+n return res;}recursion.rs/* 使用迭代模拟递归 */fn for_loop_recur(n: i32) -> i32 { // 使用一个显式的栈来模拟系统调用栈 let mut stack = Vec::new(); let mut res = 0; // 递:递归调用 for i in (1..=n).rev() { // 通过“入栈操作”模拟“递” stack.push(i); } // 归:返回结果 while !stack.is_empty() { // 通过“出栈操作”模拟“归” res += stack.pop().unwrap(); } // res = 1+2+3+...+n res}recursion.c/* 使用迭代模拟递归 */int forLoopRecur(int n) { int stack[1000]; // 借助一个大数组来模拟栈 int top = -1; // 栈顶索引 int res = 0; // 递:递归调用 for (int i = n; i > 0; i--) { // 通过“入栈操作”模拟“递” stack[1 + top++] = i; } // 归:返回结果 while (top >= 0) { // 通过“出栈操作”模拟“归” res += stack[top--]; } // res = 1+2+3+...+n return res;}recursion.kt/* 使用迭代模拟递归 */fun forLoopRecur(n: Int): Int { // 使用一个显式的栈来模拟系统调用栈 val stack = Stack() var res = 0 // 递: 递归调用 for (i in n downTo 0) { // 通过“入栈操作”模拟“递” stack.push(i) } // 归: 返回结果 while (stack.isNotEmpty()) { // 通过“出栈操作”模拟“归” res += stack.pop() } // res = 1+2+3+...+n return res}recursion.rb### 使用迭代模拟递归 ###def for_loop_recur(n) # 使用一个显式的栈来模拟系统调用栈 stack = [] res = 0 # 递:递归调用 for i in n.downto(0) # 通过“入栈操作”模拟“递” stack 0) { index -= 1; res += stack[index]; } // res = 1+2+3+...+n return res;}可视化运行

全屏观看 >

观察以上代码,当递归转化为迭代后,代码变得更加复杂了。尽管迭代和递归在很多情况下可以互相转化,但不一定值得这样做,有以下两点原因。

转化后的代码可能更加难以理解,可读性更差。对于某些复杂问题,模拟系统调用栈的行为可能非常困难。

总之,选择迭代还是递归取决于特定问题的性质。在编程实践中,权衡两者的优劣并根据情境选择合适的方法至关重要。

欢迎在评论区留下你的见解、问题或建议

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至lizi9903@foxmail.com举报,一经查实,本站将立刻删除。