前面我们通过2篇文章,一起了解了 递归,以及使用递归来解决汉诺塔问题。今天我们在这个基础上,进一步地熟悉和学习递归。
这篇学习笔记将涵盖递归的基本概念、应用、优化技巧、陷阱及与迭代的对比,并通过具体的 Python 代码示例和大家一起来深入理解递归的使用。
一、 巩固基础
1. 递归的概念
递归,简单来说就是函数自己调用自己。听起来有点绕,但其实就像俄罗斯套娃,一层套一层,直到遇到最小的那个娃娃(基线条件)才停止。
递归是指一个函数直接或间接地调用自身。它通常由两个关键要素构成:
- 基线条件(Base Case):递归结束的条件,防止无限递归。
- 递归条件(Recursive Case):将问题分解成更小的子问题,并递归调用自身来解决这些子问题。
2. 递归的调用栈
每次递归调用都会把当前的局部变量、返回地址等压入调用栈,直到达到基线条件并开始回溯。理解调用栈有助于调试递归程序。
3. 示例:阶乘与斐波那契数列
- 阶乘:
阶乘是一个经典的递归例子,定义为 n! = n * (n-1)!
,直到 1! = 1
。
def factorial(n):if n == 0: # 基线条件return 1else:return n * factorial(n - 1) # 递归条件
调用 factorial(5)
会通过递归逐步调用直到 factorial(0)
,然后逐步返回结果。
想象一下,计算5的阶乘:
-
factorial(5)
调用factorial(4)
-
factorial(4)
调用factorial(3)
-
factorial(3)
调用factorial(2)
-
factorial(2)
调用factorial(1)
-
factorial(1)
调用factorial(0)
-
factorial(0)
遇到基线条件,返回1 -
然后逐层返回,最终得到
5 * 4 * 3 * 2 * 1 * 1 = 120
- 斐波那契数列:
斐波那契数列的递归定义为 F(n) = F(n-1) + F(n-2)
,直到 F(0) = 0
和 F(1) = 1
。
def fibonacci(n):if n <= 1:return nelse:return fibonacci(n - 1) + fibonacci(n - 2)
4. 使用调试工具可视化递归调用栈
可以通过调试工具(例如 PyCharm 或 VS Code 的调试器)逐步观察递归调用栈。每进入一次递归,都会看到栈中增加一个新的调用帧,直到基线条件触发。
也使用调试工具例如Python的pdb,可以一步步跟踪递归函数的执行过程,观察每次调用时变量的变化,帮助你更直观地理解递归。
二、 递归的应用场景
递归在算法设计中就像一把瑞士军刀,可以解决各种问题。递归在很多经典算法中都有广泛应用:
1. 分治法
分治法的精髓在于“分而治之”,把大问题拆解成小问题,分别解决后再合并结果。
- 归并排序:
归并排序是一种典型的分治算法,通过递归地将数组分成两半进行排序,然后合并已排序的两部分。
def merge_sort(arr):if len(arr) <= 1:return arrmid = len(arr) // 2left = merge_sort(arr[:mid])right = merge_sort(arr[mid:])return merge(left, right)def merge(left, right):result = []while left and right:if left[0] < right[0]:result.append(left.pop(0))else:result.append(right.pop(0))result.extend(left)result.extend(right)return result
- 快速排序:
快速排序通过递归地选择一个基准元素,将数组分为比基准小和比基准大的两部分,然后对这两部分分别进行排序。
def quick_sort(arr):if len(arr) <= 1:return arrpivot = arr[len(arr) // 2]left = [x for x in arr if x < pivot]middle = [x for x in arr if x == pivot]right = [x for x in arr if x > pivot]return quick_sort(left) + middle + quick_sort(right)
2. 回溯法
回溯法是通过递归尝试所有可能的解,并在遇到错误时回退并继续尝试其他解。
- 八皇后问题:
通过递归摆放皇后,并判断每一层是否满足规则。如果满足规则就进入下一层,否则回退。
八皇后问题是一个经典的回溯算法问题,目标是在一个8x8的棋盘上放置8个皇后,使得它们互不攻击(即任意两个皇后不能在同一行、同一列或同一对角线上)。
def solve_n_queens(n):# 初始化棋盘,-1表示未放置皇后board = [-1] * n# 存储所有合法的棋盘布局solutions = []# 回溯函数def backtrack(row):# 如果已经放置了n个皇后,保存当前棋盘布局if row == n:solutions.append(board[:])return# 尝试在当前行的每一列放置皇后for col in range(n):# 检查当前位置是否合法if is_valid(board, row, col):# 放置皇后board[row] = col# 递归处理下一行backtrack(row + 1)# 回溯:撤销当前行的皇后放置board[row] = -1# 检查在第row行第col列放置皇后是否合法def is_valid(board, row, col):# 遍历之前的所有行for i in range(row):# 检查是否在同一列或同一对角线上if board[i] == col or abs(board[i] - col) == row - i:return Falsereturn True# 从第0行开始回溯backtrack(0)# 返回所有合法的棋盘布局return solutions
代码解析
2.1. 数据结构
-
board
: 一个长度为n
的列表,表示棋盘。board[i] = j
表示在第i
行第j
列放置了一个皇后。 -
solutions
: 存储所有合法的棋盘布局。
2.2. 核心函数
-
backtrack(row)
: 递归回溯函数,尝试在第row
行放置皇后。-
如果
row == n
,说明已经成功放置了n
个皇后,将当前棋盘布局保存到solutions
中。 -
否则,遍历当前行的每一列,尝试放置皇后:
-
如果当前位置合法(通过
is_valid
检查),则放置皇后,并递归处理下一行。 -
递归结束后,撤销当前行的皇后放置(回溯),尝试其他可能性。
-
-
-
is_valid(board, row, col)
: 检查在第row
行第col
列放置皇后是否合法。-
遍历之前的所有行,检查是否有皇后在同一列或同一对角线上。
-
2.3. 运行流程
-
初始化一个大小为
n
的棋盘board
,所有值初始化为-1
(表示未放置皇后)。 -
调用
backtrack(0)
,从第0行开始尝试放置皇后。 -
在
backtrack
函数中:-
如果当前行
row == n
,说明找到一种合法布局,将其保存到solutions
中。 -
否则,遍历当前行的每一列,尝试放置皇后:
-
如果当前位置合法,则放置皇后,并递归处理下一行。
-
递归结束后,撤销当前行的皇后放置,尝试其他列。
-
-
-
最终返回所有合法的棋盘布局
solutions
。
3. 树的遍历
树形结构本身就是递归的天然应用。二叉树的前序、中序、后序遍历都可以通过递归实现。
class TreeNode:def __init__(self, val=0, left=None, right=None):self.val = valself.left = leftself.right = rightdef preorder(root):if root:print(root.val)preorder(root.left)preorder(root.right)
三、 递归的优化
1. 记忆化搜索
记忆化搜索是一种优化递归的技术,目的是避免重复计算相同的子问题。常用于斐波那契数列等问题。
def fibonacci_memo(n, memo={}):if n in memo:return memo[n]if n <= 1:return nmemo[n] = fibonacci_memo(n - 1, memo) + fibonacci_memo(n - 2, memo)return memo[n]
2. 尾递归优化
尾递归是指递归调用出现在函数的最后一步,可以将递归转化为循环,从而避免栈溢出问题。
def factorial_tail(n, acc=1):if n == 0:return accreturn factorial_tail(n - 1, n * acc)
各位需要注意的是:Python 并不支持尾递归优化,因此对于深度较大的递归,仍然要小心栈溢出。
四、 递归的陷阱
1. 栈溢出
递归深度过大时,调用栈会导致栈溢出。可以通过增加基线条件或者将递归转化为迭代来避免。
2. 重复计算
递归可能会多次计算相同的子问题,造成效率低下。例如,斐波那契数列的递归实现会计算许多重复的子问题。
3. 效率低下
递归相较于循环通常会带来额外的函数调用开销,导致效率较低。
五、 递归与迭代
递归和迭代各有优缺点:
- 递归在处理分治问题、树的遍历等复杂问题时非常直观。
- 迭代相对效率更高,特别是对于简单问题,如阶乘、斐波那契数列。
有些递归问题可以转化为迭代解决,特别是尾递归问题。
六、 实践项目
递归在实际问题中有广泛应用。以下是几个经典递归问题:
- 全排列:给定一组数字,输出所有可能的排列。
- 组合问题:从一个集合中选取若干元素的所有组合。
- 子集问题:从一个集合中生成所有的子集。
# 组合问题示例
def combine(n, k):res = []def backtrack(start, path):if len(path) == k:res.append(path)returnfor i in range(start, n + 1):backtrack(i + 1, path + [i])backtrack(1, [])return res
继续扩展递归应用,我们将通过实际问题来进一步理解递归的强大功能。
以下是我们一起来使用递归解决的三个实际问题:文件目录遍历、JSON 数据解析 和 HTML 文档解析。
6.1. 文件目录遍历
文件目录遍历是一个常见的递归问题,因为目录可以包含文件和子目录,而子目录又可能包含更多的文件和子目录。因此,我们可以通过递归来遍历整个文件树。
代码示例:递归遍历文件夹
import osdef traverse_directory(path):# 获取路径下的所有文件和子目录for item in os.listdir(path):full_path = os.path.join(path, item)if os.path.isdir(full_path): # 如果是目录,递归遍历print(f"Directory: {full_path}")traverse_directory(full_path) # 递归调用else: # 如果是文件,打印文件路径print(f"File: {full_path}")# 示例调用
traverse_directory('/path/to/directory')
解释:
os.listdir(path)
返回指定目录下的所有文件和子目录的名称。os.path.isdir(full_path)
判断路径是否是一个目录。- 如果是目录,则递归调用
traverse_directory
,遍历该目录。 - 如果是文件,则直接打印文件路径。
通过这种方式,可以遍历整个文件树,不管目录有多深,这个非常实用!
6.2. JSON 数据解析
递归常常用于处理具有嵌套结构的数据,像 JSON 这样的格式通常包含字典、列表等复杂嵌套结构。使用递归可以帮助我们解析和提取其中的数据。
代码示例:递归解析 JSON 数据
假设我们有一个复杂的 JSON 数据,其中包含嵌套的字典和列表。
import json# 假设的 JSON 数据
data = '''
{"name": "Alice","age": 25,"address": {"city": "Wonderland","zipcode": "12345"},"hobbies": ["reading", "painting", {"name": "sports", "types": ["soccer", "basketball"]}]
}
'''def parse_json(obj):if isinstance(obj, dict):for key, value in obj.items():print(f"{key}:")parse_json(value) # 递归调用,处理字典的每个值elif isinstance(obj, list):for item in obj:parse_json(item) # 递归调用,处理列表中的每个元素else:print(f"Value: {obj}") # 打印最终的值# 解析 JSON 数据
parse_json(json.loads(data))
解释:
parse_json
函数检查对象类型:- 如果是字典,就递归地遍历字典的键值对。
- 如果是列表,就递归地遍历列表中的元素。
- 如果既不是字典也不是列表,那么就是基本数据类型,直接打印值。
- 使用
json.loads
将字符串转换为 Python 对象后传递给parse_json
进行解析。
这种递归解析方法适用于处理结构复杂的 JSON 数据,能够处理不确定的深度和层级。
6.3. HTML 文档解析
HTML 文档通常是由标签嵌套构成的树形结构,因此解析 HTML 时递归是非常自然的选择。我们可以使用递归遍历 HTML 元素及其子元素。
代码示例:递归解析 HTML 文档
假设我们需要解析一个简单的 HTML 文件,提取其中的所有 a
标签(超链接)。
from html.parser import HTMLParserclass MyHTMLParser(HTMLParser):def handle_starttag(self, tag, attrs):if tag == 'a': # 如果是 <a> 标签for attr in attrs:if attr[0] == 'href':print(f"Link: {attr[1]}") # 打印链接地址# 示例 HTML 文档
html_data = '''
<html><head><title>Test Page</title></head><body><h1>Welcome to My Webpage</h1><p>Here are some links:</p><a href="https://example.com">Example</a><a href="https://another-example.com">Another Example</a></body>
</html>
'''# 创建并使用 HTMLParser 对象
parser = MyHTMLParser()
parser.feed(html_data)
解释:
HTMLParser
是 Python 的标准库,用于解析 HTML 内容。handle_starttag
方法在每次遇到开始标签时被调用。当标签是a
时,我们从其属性中提取href
,即超链接地址。feed
方法将 HTML 字符串传递给解析器,递归解析 HTML 内容并提取超链接。
在实际的 HTML 解析中,递归处理各个标签及其子标签是非常常见的,尤其是当文档结构复杂时。
七、 拓展学习
拓展学习可以让我们在理解和掌握递归的基础上,进行融汇贯通,将递归运用在实际需要的地方。
一点建议:
- 函数式编程:学习函数式编程语言,如 Haskell 或 Lisp,有助于深入理解递归的思想。
- 数学归纳法:递归和数学归纳法密切相关,理解递归关系式和数学归纳法的原理有助于深入理解递归的本质。
-
学习递归相关的数学知识,例如:
-
递归关系式
-
数学归纳法
-
-
学习资源:
-
书籍:
-
《算法导论》
-
《编程珠玑》
-
-
网站:
-
LeetCode
-
LintCode
-
-
视频:
-
MIT OpenCourseware: Introduction to Algorithms
-
-
从简单的例子开始,逐步深入理解递归。
-
多动手实践,尝试用递归解决不同的问题。
-
不要害怕犯错,调试是学习递归的重要部分。
通过以上内容,我们已经将递归的基础知识和应用场景进行了充分阐述,后续我们就可以通过实际练习不断提升递归的能力。祝你好运!