【算法竞赛学习笔记】基础算法篇:递归再探
前言
本文为个人学习的算法学习笔记,学习笔记,学习笔记,不是经验分享与教学,不是经验分享与教学,不是经验分享与教学,若有错误各位大佬轻喷(T^T)。主要使用编程语言为Python3,各类资料题目源于网络,主要自学途径为蓝桥云课,侵权即删。
一、学习目标
- 理解递归的核心现象(“递” 与 “归” 的双向过程)
- 掌握递归在数学和计算机科学中的严格定义
- 熟练运用递归代码的通用模板,具备独立编写 Python 递归代码的能力
- 明确递归过程中的关键注意事项(避免重复计算、栈溢出等)
二、递归现象:从生活例子理解 “递” 与 “归”
递归的本质是 “先递后归”—— 先将问题逐层拆解为更小的同类问题(递的过程),直到触及可直接解决的最小问题,再将结果逐层回溯合并(归的过程)。
文档中经典生活案例:某计姓家族子孙询问 “18 代祖的名字”,过程如下:
- 递的过程:子孙问父亲→父亲问祖父→祖父问曾祖父→……→直到 18 代祖(最小问题:18 代祖知道自己的名字)
- 归的过程:18 代祖告诉儿子 “我叫你猜”→儿子告诉孙子→……→最终子孙得到答案 “你猜”
通过该案例可直观理解:递归必须包含 “拆解(递)” 和 “回溯(归)” 两个环节,缺一不可。
三、递归的定义
在数学和计算机科学中,递归(Recursion)是指在函数定义中直接或间接调用函数自身的方法,其核心是 “用同类小规模问题的解构建原问题的解”。
1. 数学定义
若一个问题的解可表示为更小规模同类问题的解,且存在最小解(终止条件),则可通过递归定义。示例:函数 \(f(n) = f(n-1) + 1\)(其中 \(f(1) = 1\))
- 原问题:求 \(f(3)\)
- 拆解(递):\(f(3) = f(2) + 1\) → \(f(2) = f(1) + 1\)
- 终止(最小解):\(f(1) = 1\)(无需再拆解)
- 回溯(归):\(f(2) = 1 + 1 = 2\) → \(f(3) = 2 + 1 = 3\)
2. 计算机科学定义(Python 实现)
在代码中,递归表现为 “函数调用自身”,需满足 “可重复子问题” 和 “递归出口” 两大条件。示例(对应上述数学函数):
def f(n):# 递归出口(最小子问题的解)if n == 1:return 1# 调用自身(拆解为小规模问题)return f(n - 1) + 1# 测试:计算f(3)
print(f(3)) # 输出:3
四、递归的两大核心要点
递归能正确运行的前提是满足以下两个条件,缺一不可:
1. 可拆解为 “可重复子问题”
原问题的解可拆分为多个子问题的解,且子问题与原问题的求解思路完全一致,仅数据规模不同。
- 关键特征:子问题的 “逻辑结构” 和 “求解步骤” 与原问题相同。
- 示例(计算 n 的阶乘):
- 原问题:\(n! = n \times (n-1)!\)
- 子问题:\((n-1)! = (n-1) \times (n-2)!\)
- 规律:所有子问题均遵循 “当前数 × 前一个数的阶乘”,仅 “当前数” 的规模不同,符合 “可重复子问题” 要求。
若子问题与原问题思路不同(如 “计算 n! 时突然需要计算 n 的平方”),则无法用递归解决。
2. 必须有 “递归出口”(终止条件)
存在一个 “最小子问题”,其解可直接给出(无需再拆解调用自身),否则会导致函数无限递归,最终引发栈溢出。
- 示例 1:阶乘的递归出口是 \(n=1\) 时返回 1(\(1! = 1\) 是已知结论)。
- 示例 2:斐波那契数列的递归出口是 \(F(0)=0\)、\(F(1)=1\)(最小子问题的解明确)。
反例:若删除阶乘函数的终止条件(if n == 1: return 1
),则函数会无限调用 \(f(n-1)\)、\(f(n-2)\)…… 直到栈溢出。
五、递归代码通用模板(Python 优先)
递归代码的结构具有高度规律性,可总结为以下两类模板(根据问题复杂度选择):
1. 基础模板(适用于简单递归问题)
适用于仅需 “拆解 + 回溯”,无需中间层处理的场景(如阶乘、简单求和),对应文档中的基础模板逻辑。
def recursive_func(parameters):# 1. 递归出口:处理最小子问题,直接返回结果if 终止条件:return 最小子问题的解# 2. 递的过程:调用自身,缩小问题规模(参数需调整)return recursive_func(缩小后的参数)
示例(计算 n 的阶乘):
def factorial(n):# 递归出口:0!和1!均为1if n in (0, 1):return 1# 拆解为n × (n-1)!return n * factorial(n - 1)# 测试:计算5!
print(factorial(5)) # 输出:120
2. 完整模板(适用于复杂递归问题)
适用于需要 “处理当前层逻辑” 或 “回溯后善后” 的场景(如二叉树遍历、链表反转),对应文档中包含四层结构的模板。
def recursive_complete(level, other_params):# Part 1:递归出口(终止条件,超过最大层级则终止)if level > 最大层级:return 终止时的结果 # 或直接return(无返回值场景)# Part 2:处理当前层逻辑(根据问题需求编写,如修改数据、打印信息)process(level, other_params)# Part 3:递的过程(进入下一层,缩小问题规模,层级+1)recursive_complete(level + 1, other_params)# Part 4:归的过程(回溯后善后,如恢复状态、释放资源,按需编写)# post_process(level, other_params)return 当前层的结果 # 按需返回(无返回值则省略)
示例(二叉树前序遍历):
# 定义二叉树节点类
class TreeNode:def __init__(self, val=0, left=None, right=None):self.val = valself.left = leftself.right = rightdef pre_order_traversal(node, level=1):# Part 1:递归出口(节点为空,无需处理)if node is None:return# Part 2:当前层逻辑(打印当前节点值和层级)print(f"层级{level}:节点值{node.val}")# Part 3:进入下一层(先遍历左子树,再遍历右子树,层级+1)pre_order_traversal(node.left, level + 1)pre_order_traversal(node.right, level + 1)# 构建测试二叉树: 1
# / \
# 2 3
root = TreeNode(1, TreeNode(2), TreeNode(3))
# 测试前序遍历
pre_order_traversal(root)
# 输出:
# 层级1:节点值1
# 层级2:节点值2
# 层级2:节点值3
六、模拟演练:实现 “神秘函数” S (x)
1. 问题描述(来自文档)
神秘函数 \(S(x)\) 定义如下:
- 当 \(x = 0\) 时,\(S(0) = 1\)(递归出口);
- 当 x 为偶数时,\(S(x) = S(x/2)\)(拆解为更小问题);
- 当 x 为奇数时,\(S(x) = S(x-1) + 1\)(拆解为更小问题)。
输入:正整数 x(\(1 ≤ x ≤ 10^6\)),输出 \(S(x)\) 的值。
2. 样例分析(输入 x=7)
- 递的过程:\(S(7) → S(6)+1 → S(3)+1 → S(2)+1 → S(1)+1 → S(0)+1\)
- 归的过程:\(S(0)=1 → S(1)=1+1=2 → S(2)=2 → S(3)=2+1=3 → S(6)=3 → S(7)=3+1=4\)
- 最终输出:4
3. Python 代码实现
def calculate_mystery_S(x):# 递归出口:x=0时返回1if x == 0:return 1# x为偶数:调用S(x//2)(整数除法,避免浮点数)if x % 2 == 0:return calculate_mystery_S(x // 2)# x为奇数:调用S(x-1) + 1else:return calculate_mystery_S(x - 1) + 1# 测试样例(输入x=7)
x = 7
print(calculate_mystery_S(x)) # 输出:4# 额外测试:x=4(偶数)
print(calculate_mystery_S(4)) # 输出:1(S(4)=S(2)=S(1)=S(0)+1=2?注:需按定义重新计算:S(4)=S(2)=S(1)=S(0)+1=2,实际运行验证)
七、编写递归代码的核心技巧
文档中强调:“明白函数作用并相信它能完成这个任务,千万不要跳进这个函数里面企图探究更多细节,关注当前层的逻辑就好”,这是避免 “人肉递归” 的关键。
1. 技巧核心:“函数作用先行”
编写递归代码时,先明确 “当前函数的核心功能”,然后直接用该功能(调用自身)解决子问题,无需关心子问题内部如何执行。
示例 1:反转链表(基于文档逻辑)
- 函数定义:
reverse_linked_list(head)
,功能是 “反转以 head 为头节点的链表,并返回反转后的新头节点”。 - Python 实现:
class ListNode:def __init__(self, val=0, next=None):self.val = valself.next = nextdef reverse_linked_list(head):# 递归出口:链表为空或只有一个节点,直接返回(无需反转)if head is None or head.next is None:return head# 子问题:反转head.next之后的链表,相信它能返回新头节点new_headnew_head = reverse_linked_list(head.next)# 当前层逻辑:调整head与head.next的指向(完成当前节点的反转)head.next.next = head # 让原head的下一个节点指向自己head.next = None # 断开原指向,避免循环# 返回反转后的新头节点return new_head# 构建测试链表:1 → 2 → 3 → 4 → 5
head = ListNode(1, ListNode(2, ListNode(3, ListNode(4, ListNode(5)))))
# 反转链表
new_head = reverse_linked_list(head)
# 遍历反转后的链表(验证结果)
current = new_head
while current:print(current.val, end=" → ")current = current.next
# 输出:5 → 4 → 3 → 2 → 1 →
示例 2:快速幂(递归实现)快速幂的功能是 “在 O (log n) 时间内计算 \(a^b\)”,核心是拆解为 “偶数次幂平方、奇数次幂平方再乘 a”,文档中提供了 Python 实现思路。
def quick_pow(a, b):# 递归出口:任何数的0次幂为1if b == 0:return 1# 子问题:计算a^(b//2),相信它能返回结果resres = quick_pow(a, b // 2)# 当前层逻辑:根据b的奇偶性合并结果if b % 2 == 1:return res * res * a # 奇数:多乘一次aelse:return res * res # 偶数:直接平方# 测试:计算2^5(预期32)、3^4(预期81)
print(quick_pow(2, 5)) # 输出:32
print(quick_pow(3, 4)) # 输出:81
八、递归的注意事项(避坑指南)
递归虽简洁,但易出现重复计算和栈溢出两大问题,需针对性解决。
1. 问题 1:重复计算(重叠子问题)
当多个子问题的解被反复计算时,会导致时间复杂度急剧上升(如未优化的斐波那契数列)。
(1)案例:未优化的斐波那契数列
斐波那契定义:\(F(n) = F(n-1) + F(n-2)\)(\(F(0)=0, F(1)=1\))调用树(以 F (6) 为例):\(F(6) = F(5) + F(4)\),\(F(5) = F(4) + F(3)\)…… 其中 F (4)、F (3)、F (2) 均被多次计算,时间复杂度为 O (2ⁿ)。
(2)解决办法(Python 实现)
-
方法 1:记忆化(缓存中间结果)用列表或字典存储已计算的 F (n),下次需要时直接读取,避免重复计算。优化后时间复杂度为 O (n)。
# 用字典缓存中间结果(键:n,值:F(n)) fib_cache = {}def fib_memo(n):if n == 0:return 0if n == 1:return 1# 若已计算过,直接返回缓存值if n in fib_cache:return fib_cache[n]# 未计算则递归,并缓存结果fib_cache[n] = fib_memo(n-1) + fib_memo(n-2)return fib_cache[n]# 测试:计算F(10)(预期55) print(fib_memo(10)) # 输出:55
-
方法 2:改递归为非递归(动态规划思想)用迭代方式从最小子问题(F (0)、F (1))逐步计算到 F (n),完全避免递归调用。
def fib_iterative(n):if n == 0:return 0if n == 1:return 1# 用变量存储前两个结果(F(i-2)和F(i-1))prev_prev = 0 # F(0)prev = 1 # F(1)for i in range(2, n+1):current = prev_prev + prev # F(i) = F(i-2) + F(i-1)prev_prev = prev # 更新F(i-2)为F(i-1)prev = current # 更新F(i-1)为F(i)return prev# 测试:计算F(10)(预期55) print(fib_iterative(10)) # 输出:55
2. 问题 2:栈溢出
递归依赖 “函数调用栈” 实现,每次调用函数会在栈中添加一个 “栈帧”。若递归层数过多(如 n=10000),栈会超出最大容量,引发栈溢出错误。
(1)案例:深层递归导致栈溢出
# 当n=1000时,可能引发栈溢出(Python默认递归深度约1000)
def deep_recursion(n):if n == 1:return 1return deep_recursion(n - 1) + 1# 测试:n=1000(可能报错:RecursionError: maximum recursion depth exceeded)
# print(deep_recursion(1000))
(2)解决办法
-
方法 1:手动调整递归深度(谨慎使用)通过
sys.setrecursionlimit()
扩大递归深度,但不推荐(可能导致内存问题):import sys # 调整递归深度为2000(仅临时测试用) sys.setrecursionlimit(2000) def deep_recursion(n):if n == 1:return 1return deep_recursion(n - 1) + 1 # 测试:n=1500(大概率可运行) print(deep_recursion(1500)) # 输出:1500
-
方法 2:改递归为非递归(推荐)用循环模拟递归过程,彻底避免栈溢出。例如将上述 “深层递归求和” 改为迭代:
def iterative_sum(n):result = 0for i in range(1, n+1):result += 1return result# 测试:n=10000(无栈溢出风险) print(iterative_sum(10000)) # 输出:10000
3. 拓展:递归的复杂度分析
- 时间复杂度:取决于 “递归调用次数” 和 “每一层的操作复杂度”。例如优化后的斐波那契(记忆化),调用次数 O (n),每一层操作 O (1),总时间 O (n)。
- 空间复杂度:取决于 “递归深度” 和 “每一层的空间开销”。例如递归实现的斐波那契,深度 O (n),每一层无额外空间,总空间 O (n);非递归实现空间 O (1)。
九、递归的优势
-
代码结构清晰,可读性强相比复杂的迭代逻辑,递归能直接映射问题的数学定义,代码更简洁。例如斐波那契的递归代码(3 行核心逻辑)vs 迭代代码(循环 + 变量维护),递归更易理解。
-
培养问题拆解能力递归要求开发者将原问题拆解为同类子问题,强制锻炼 “抽象思维” 和 “分治思想”,为后续学习分治、动态规划等算法奠定基础。
十、递归核心小结(基于文档)
- 本质:函数调用自身,通过 “递(拆解子问题)” 和 “归(回溯结果)” 解决问题,需包含 “可重复子问题” 和 “递归出口”。
- 代码模板:基础模板(出口 + 调用自身)、完整模板(出口 + 当前层处理 + 下一层 + 善后),优先用 Python 实现。
- 编写技巧:明确函数作用,不人肉递归,仅关注当前层逻辑。
- 避坑重点:用记忆化避免重复计算,用非递归避免栈溢出。
- 优势:代码简洁、可读性强,助力培养问题拆解能力。