进阶 #algorithm#recursion#divide-conquer

递归与分治

分治(Divide and Conquer)把大问题分解成小问题分别解决再合并——递归是实现分治的自然方式。归并排序、快速排序都是分治思想的应用

🪆 俄罗斯套娃——自己里面还装着自己

你打开一个俄罗斯套娃。里面露出一个小一点儿的娃娃。你再打开它——里面又有一个更小的。再打开……

最小的那个娃娃已经不能打开了。但它也是套娃——只是它是最小、不能拆分的那一个。

这就是递归(Recursion)一个东西的定义里包含了它自己

在编程中,递归就是”函数调用自身”。但这不应该是无限循环——必须有一个终止条件(就像最小的俄罗斯套娃,不能再打开)。

分治(Divide and Conquer) 则是一类利用递归的算法设计思想:把一个大问题分解成若干个小问题,分别解决小问题,再合并结果。

🎯 类比:军训喊口令

连长说”各排报数!”。

一排长喊:“二排长,你们排多少人?“(分解) 二排长喊:“三排长,你们排多少人?” ……(继续分解) 最后一个排长喊:“我们排 30 人!“(最小子问题——基线条件) 倒数第二个排长算了算:“我们排 25 人,加上最后一排 30 人,一共 55 人”(合并) ……(继续合并) 一排长:“全连 200 人!”

这就是分治:把”算全连人数”分解成”算每个排的人数”,排再分解成班,最小的班直接知道人数。然后逐层合并结果。


🔄 递归——两个要素

任何一个正确的递归函数,必须有两个要素

要素 1:基线条件(Base Case)

递归的”出口”——问题足够小,可以直接解决,不再需要递归。

要素 2:递归步骤(Recursive Step)

把问题转化成”更小的同类问题”,然后调用自身。

# 阶乘 n! = n × (n-1) × (n-2) × ... × 1
def factorial(n):
    # 基线条件
    if n <= 1:
        return 1
    
    # 递归步骤:n! = n × (n-1)!
    return n * factorial(n - 1)

# 执行过程:
# factorial(4) = 4 * factorial(3)
#             = 4 * (3 * factorial(2))
#             = 4 * (3 * (2 * factorial(1)))
#             = 4 * (3 * (2 * 1))
#             = 24
调用栈的变化:
factorial(4)  → 调用 factorial(3) → 调用 factorial(2) → 调用 factorial(1)
                                                          ↓ 返回 1
                                              ← 返回 2 * 1 = 2
                             ← 返回 3 * 2 = 6
             ← 返回 4 * 6 = 24

💡 递归的本质是”栈”——每次函数调用把当前状态压入调用栈,基线条件触发后开始逐层”弹出”。这也意味着递归深度过大时会导致”栈溢出”。


🧩 分治——三步法

分治(Divide and Conquer)是递归最重要的应用。它把问题按三个步骤解决:

1. 分解(Divide):把原问题分解成若干个子问题
                  子问题是原问题的"缩小版"
2. 解决(Conquer):递归地解决子问题
                   当子问题足够小时,直接解决
3. 合并(Combine):把子问题的解合并成原问题的解

例子:用分治找最大值

def find_max(arr):
    # 基线:只有一个元素,最大值就是它自己
    if len(arr) == 1:
        return arr[0]
    
    # 分解:分成两半
    mid = len(arr) // 2
    left_max = find_max(arr[:mid])
    right_max = find_max(arr[mid:])
    
    # 合并:取两个最大值中更大的
    return max(left_max, right_max)

# 执行过程:
# find_max([3, 8, 2, 5])
# ├─ find_max([3, 8])
# │  ├─ find_max([3]) → 3
# │  └─ find_max([8]) → 8
# │  └─ max(3, 8) → 8
# ├─ find_max([2, 5])
# │  ├─ find_max([2]) → 2
# │  └─ find_max([5]) → 5
# │  └─ max(2, 5) → 5
# └─ max(8, 5) → 8

🏢 递归 vs 迭代

维度递归迭代
代码简洁性✅ 简洁,和数学定义一致❌ 通常需要更多代码
可读性✅ 分治思想自然表达❌ 需要追踪循环变量
性能❌ 函数调用有开销✅ 更高效
栈溢出风险❌ 深度太大会栈溢出✅ 没有这个风险
调试难度❌ 调用链长时难追踪✅ 更容易单步调试

什么时候用递归?

# ✅ 适合递归
# 树和图遍历
def traverse_tree(node):
    if not node:
        return
    print(node.val)
    traverse_tree(node.left)
    traverse_tree(node.right)

# 分治算法(归并排序、快排)
def merge_sort(arr):
    if len(arr) <= 1:
        return arr
    # ...
# ❌ 不适合递归
# 简单的循环——迭代更高效
def sum_arr(arr):
    total = 0
    for x in arr:
        total += x
    return total
# 用递归写就是"为了递归而递归"——没必要

🎯 分治的经典应用

你已经学过的一些算法,其实都是分治思想的应用:

算法分治过程
归并排序分解:分成两半。解决:递归排序。合并:合并两个有序数组
快速排序分解:选 pivot 分区。解决:递归排序两边。合并:不需要!
二分搜索分解:取中间值。解决:在左边或右边继续找。合并:不需要
二叉树遍历分解:左子树和右子树。解决:递归遍历。合并:访问根节点(的位置不同)

主定理(Master Theorem)——分析分治算法的时间复杂度

分治算法的时间复杂度通常满足一个公式:

$$ T(n) = aT(n/b) + O(n^d) $$

其中:

  • a = 递归中分解成几个子问题
  • b = 每个子问题的规模是原问题的 1/b
  • O(n^d) = 分解和合并的代价

主定理直接给出结果:

a < b^d  → T(n) = O(n^d)         (例:二分搜索 a=1,b=2,d=0 → O(log n))
a = b^d  → T(n) = O(n^d log n)   (例:归并排序 a=2,b=2,d=1 → O(n log n))
a > b^d  → T(n) = O(n^log_b(a))  (例:暴力递归 a=3,b=2,d=0 → O(n^1.585))

📝 小结

概念一句话
递归函数调用自身——自己里面又包含自己
基线条件递归的出口——问题足够小直接解决
分治分解→解决→合并——把大问题拆成小问题
调用栈递归的底层机制——每次调用压栈,返回时出栈
主定理分析分治算法时间复杂度的公式
适用场景树/图遍历、分治排序、搜索问题

🎯 小练习:用递归实现”反转字符串”函数。提示:把字符串分解成”第一个字符”和”剩余部分”。

为什么先学这个? 分治是算法设计的第一种核心思想。接下来学习它的”升级版”——动态规划,通过”记住子问题的答案”来避免重复计算。