Skip to content

第 12 章:分治与回溯


元数据卡

属性
难度(巅峰型)
前置递归、数组操作、第 9-10 章(排序)、第 11 章(搜索)
关键词分治三步法·最大子数组·逆序对·回溯三要素·选择·约束·目标·N皇后·全排列·子集·子集和·剪枝·状态树
预计阅读120 分钟
代码行~200 行(三语言合计)

你在哪

你学到的大部分方法都有一个共同模式——把大问题拆成小问题,解决小问题,组合起来。但当拆到最小发现走不通时,你需要"回到上一个岔路口重新选"——这就是回溯。

前 11 章你一直在处理确定性的问题——排序、搜索、哈希、树。这些问题有一个共同的特点:你可以用算法推导出唯一的正确答案。

现在我们要面对更复杂的局面:

在一个数组中找一个连续子数组,使其和最大。你可以枚举所有 O(n²) 个子数组,但能不能更快?

在一个 8×8 的棋盘上放 8 个皇后,使它们互不攻击。有多少种放法?怎么用代码找到所有放法?

给定一个集合 [1,2,3],列出它的所有子集。一共 8 个,但你怎么确保不遗漏、不重复?

这些问题没有「一个算法 → 刷一下出结果」那么简单。它们需要你系统地生成所有可能性,或者巧妙地分而治之

分治和回溯是两种最基础也最强大的算法设计范式。分治是「把大问题变成小问题,小规模解决了再组合」。回溯是「试错——走不通就回头」。理解它们,不仅是通往动态规划(下一章)的桥梁,更是你从「算法的使用者」变成「算法的设计者」的关键一步。


你的任务

本章分层

  • 必读:分治三步法(分-治-合)——用 1-2 个例子(如最大子数组)说明模板;回溯三要素(选择-约束-目标)——用 N 皇后或全排列一个例子说清楚
  • 选读:逆序对统计(归并排序的扩展应用);子集生成的 start 指针技巧
  • 深水区:子集和的剪枝优化(排序+break+同层去重);分治、回溯、动态规划的边界区分

本章不会要求你掌握

  • 所有回溯题型的完整代码(用 2 个例子讲懂模板即可,不要堆题)
  • DP 对比——只需保留一句"有重叠子问题时再考虑 DP"

读完本章,你应当能:

  1. 用分治三步法分析问题(分-治-合)
  2. 实现最大子数组问题的分治解法
  3. 画出回溯算法的决策树,套用模板
  4. 用回溯模板解决 N 皇后或全排列
  5. 区分分治、回溯、动态规划的基本区别

遭遇战→获得技能

① 分治(Divide & Conquer)—— 三步解决复杂问题

核心直觉:如果原问题很难,把它切成更小的相同问题。小到能直接解决时再组合回去。

这就是分治的法典:

  1. 分(Divide):将问题分解为若干子问题
  2. 治(Conquer):递归地解决每个子问题(当问题足够小时直接求解)
  3. 合(Combine):将子问题的解组合成原问题的解

你其实已经见过分治了——第 9 章的归并排序就是它的完美范例。现在我们用这个框架解决两个经典问题。

1.1 最大子数组问题(Maximum Subarray)

问题:给定整数数组 nums,找到一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。

暴力法 O(n²)——枚举每个起点终点。分治法把这个复杂度拉到 O(n log n)。

分治分析

分:从中点切开 → 左边问题 + 右边问题
治:递归求左右的最大子数组
合:还有第三种可能——跨越中点的子数组
    答案 = max(左最大, 右最大, 跨中点最大)
python
def max_subarray_divide_conquer(nums):
    """分治求最大子数组和"""
    def helper(left, right):
        if left == right:
            return nums[left]

        mid = (left + right) // 2

        # 治:递归求左右
        left_max = helper(left, mid)
        right_max = helper(mid + 1, right)

        # 合:计算跨中点的最大和
        # 从中点向左扩展
        left_sum = float('-inf')
        cur_sum = 0
        for i in range(mid, left - 1, -1):
            cur_sum += nums[i]
            left_sum = max(left_sum, cur_sum)

        # 从中点向右扩展
        right_sum = float('-inf')
        cur_sum = 0
        for i in range(mid + 1, right + 1):
            cur_sum += nums[i]
            right_sum = max(right_sum, cur_sum)

        cross_sum = left_sum + right_sum

        return max(left_max, right_max, cross_sum)

    return helper(0, len(nums) - 1)


nums = [-2, 1, -3, 4, -1, 2, 1, -5, 4]
print(max_subarray_divide_conquer(nums))  # → 6 (子数组 [4, -1, 2, 1])

为什么跨中点一定包含 mid 和 mid+1?

因为「最大子数组」是连续的。如果它在左半没有延伸到 mid,它就完全属于「左子问题」的范围,会被递归解决。跨中点的定义就是「至少包含 mid 和 mid+1 各一个元素」的连续数组。所以跨中点方案只需要从中点左右扩展,不需要枚举所有可能。

但是等等——最大子数组明明有 O(n) 的 Kadane 算法,为什么还要学分治?

你说得对。Kadane 算法(动态规划)O(n) 就能解决最大子数组问题。学这个分治版本不是为了解决这个问题,而是为了理解分治思维。最大子数组的分治解法让你看到:即使是一个看似「必须连续扫描」的问题,分治法也能给出一个完全不同的视角——而同一个结构在「逆序对」问题上威力更大。

** Java 差异:** 注意 Java 没有内置的 float('-inf'),你需要 Integer.MIN_VALUE。而且递归深度可能造成栈溢出(StackOverflowError),所以真正的工业代码要么用迭代,要么显式增大栈大小。

1.2 逆序对(Count Inversions)—— 归并排序的探针

问题:给定数组 [2, 4, 1, 3, 5],计算有多少对 (i, j) 满足 i < jnums[i] > nums[j]

上例中:(2,1), (4,1), (4,3) → 共 3 对。

暴力法是 O(n²),分治能到 O(n log n)。关键洞察:在归并排序的合并过程中,当左半的元素大于右半的元素时,左半剩余的所有元素都和这个右半元素构成逆序对。

python
def count_inversions(nums):
    """归并排序同时统计逆序对"""
    def merge_sort_and_count(arr, temp, left, right):
        if left >= right:
            return 0

        mid = (left + right) // 2
        count = 0

        # 左右递归统计
        count += merge_sort_and_count(arr, temp, left, mid)
        count += merge_sort_and_count(arr, temp, mid + 1, right)

        # 合并并统计跨中点的逆序对
        i = left          # 左半指针
        j = mid + 1       # 右半指针
        k = left          # temp 数组指针

        while i <= mid and j <= right:
            if arr[i] <= arr[j]:
                temp[k] = arr[i]
                i += 1
            else:
                # arr[i..mid] 所有元素都 > arr[j]
                # 它们都和 arr[j] 构成逆序对
                count += (mid - i + 1)
                temp[k] = arr[j]
                j += 1
            k += 1

        # 收尾
        while i <= mid:
            temp[k] = arr[i]
            i += 1
            k += 1
        while j <= right:
            temp[k] = arr[j]
            j += 1
            k += 1

        # 拷贝回原数组
        for i in range(left, right + 1):
            arr[i] = temp[i]

        return count

    n = len(nums)
    temp = [0] * n
    return merge_sort_and_count(nums, temp, 0, n - 1)


nums = [2, 4, 1, 3, 5]
print(count_inversions(nums))  # → 3

# 原始数组已被修改为排序后的版本
print(nums)  # → [1, 2, 3, 4, 5]

关键行讲解

python
count += (mid - i + 1)

这里的神奇之处:当 arr[j] < arr[i] 时,因为左右两半各自有序,arr[i..mid] 全部大于 arr[j]。这意味着 arr[j] 和左半所有未处理的元素都构成逆序对,一共 mid - i + 1 个。

不用排序,只计数也能做

如果你的需求是只统计逆序对数量而不修改原数组,可以在递归时拷贝一份:

python
def count_inversions_pure(nums):
    """纯计数版——不改变原数组"""
    n = len(nums)
    if n <= 1:
        return 0

    mid = n // 2
    left = nums[:mid]
    right = nums[mid:]

    count = count_inversions_pure(left) + count_inversions_pure(right)

    # 合并计数
    i = j = k = 0
    while i < len(left) and j < len(right):
        if left[i] <= right[j]:
            nums[k] = left[i]
            i += 1
        else:
            nums[k] = right[j]
            j += 1
            count += len(left) - i  # 这里和上面一样
        k += 1

    nums[k:] = left[i:] + right[j:]
    return count

② 回溯(Backtracking)—— 系统的试错哲学

核心直觉:有路就走,走不通就回头。

回溯是暴力搜索的「优雅」版本。它的工作量本质上和暴力没区别,但它有两件事做得更好:

  1. 不生成无效的中间状态(剪枝)
  2. 系统性地遍历所有可能性(不会漏也不会重复)

回溯的代码框架极其固定,因为所有回溯问题共享同一个灵魂:

python
def backtrack(路径, 选择列表):
    if 满足终止条件:     # 目标
        result.append(路径)
        return

    for 选择 in 选择列表:
        if 不满足约束条件:  # 约束/剪枝
            continue

        做选择               # 路径.append(选择)
        backtrack(新路径, 新选择列表)  # 递归
        撤销选择             # 路径.pop()

这就是回溯三要素:选择、约束、目标。

要素含义在 N 皇后中的例子
选择每步有几种选项当前行在哪一列放皇后
约束什么选择是非法的不能和已有皇后冲突
目标什么时候找到了解8 行都放了皇后

2.1 N 皇后 —— 回溯的「hello world」

问题:在 N×N 的棋盘上放 N 个皇后,任何两个皇后不能在同一行、同一列、同一对角线。

核心思路:一行一行放。放第 i 行时,检查第 i 行的每列是否是合法位置(约束检查),如果是就放下去然后递归下一行。

python
def solve_n_queens(n):
    """N 皇后问题——返回所有合法棋盘"""
    result = []
    # 用一维数组 board[row] = col 表示棋盘
    board = [-1] * n

    def is_safe(row, col):
        """检查 (row, col) 是否安全"""
        for r in range(row):
            c = board[r]
            # 同列冲突 或 对角冲突
            if c == col or abs(r - row) == abs(c - col):
                return False
        return True

    def backtrack(row):
        if row == n:  # 目标:所有行都放了皇后
            result.append(
                ["." * col + "Q" + "." * (n - col - 1) for col in board]
            )
            return

        for col in range(n):  # 选择:尝试当前行每一列
            if is_safe(row, col):  # 约束剪枝
                board[row] = col   # 做选择
                backtrack(row + 1) # 递归
                board[row] = -1    # 撤销选择

    backtrack(0)
    return result


solutions = solve_n_queens(4)
print(f"4 皇后的解数量: {len(solutions)}")  # → 2
for sol in solutions:
    for row in sol:
        print(row)
    print()
# .Q..
# ...Q
# Q...
# ..Q.
#
# ..Q.
# Q...
# ...Q
# .Q..

为什么用一维数组? 因为每行只有一个皇后,这是天然的约束约束。用 board[row] = col 比二维布尔数组更简洁、更高效。

对角线冲突检测 abs(r - row) == abs(c - col)

两条对角线:主对角线(左上-右下)满足 row - col 为常数;副对角线(左下-右上)满足 row + col 为常数。用 abs(r - row) == abs(c - col) 可以同时检查两种对角线——两个点在同一条对角线上当且仅当行差等于列差。

** Java 差异:** Java 的 List<List<String>> 类型声明又臭又长。同样逻辑的 Java 代码会比 Python 长两倍。但核心逻辑一模一样——只是语法噪音多了。

2.2 全排列 —— 排列 vs 组合的分水岭

问题:给定 nums = [1, 2, 3],返回所有可能的排列。

全排列的特点是顺序重要——[1, 2, 3][1, 3, 2] 是两个不同的排列。因此每步选择的约束是:当前路径中没出现过的元素。

python
def permute(nums):
    """全排列——回溯标准模板"""
    result = []
    path = []
    used = [False] * len(nums)

    def backtrack():
        if len(path) == len(nums):  # 目标
            result.append(path[:])   # path[:] 拷贝,防止后续修改
            return

        for i in range(len(nums)):
            if used[i]:   # 约束:不能重复使用
                continue

            used[i] = True    # 做选择
            path.append(nums[i])
            backtrack()       # 递归
            path.pop()        # 撤销选择
            used[i] = False

    backtrack()
    return result


print(permute([1, 2, 3]))
# → [[1,2,3], [1,3,2], [2,1,3], [2,3,1], [3,1,2], [3,2,1]]

状态树可视化(n=3)

                    []
          /          |          \
        [1]         [2]         [3]
       /    \      /    \      /    \
    [1,2] [1,3] [2,1] [2,3] [3,1] [3,2]
     |      |     |      |     |      |
  [1,2,3] [1,3,2] ... 等 6 个叶子节点

全排列的去重版本(nums 包含重复元素时)

python
def permute_unique(nums):
    """含重复元素的全排列——去重剪枝"""
    nums.sort()  # 先排序,让相同元素相邻
    result = []
    path = []
    used = [False] * len(nums)

    def backtrack():
        if len(path) == len(nums):
            result.append(path[:])
            return

        for i in range(len(nums)):
            if used[i]:
                continue
            # 剪枝:当前元素和前一个相同,且前一个还没用过
            # → 跳过。这保证相同元素的相对位置固定
            if i > 0 and nums[i] == nums[i - 1] and not used[i - 1]:
                continue

            used[i] = True
            path.append(nums[i])
            backtrack()
            path.pop()
            used[i] = False

    backtrack()
    return result

print(permute_unique([1, 1, 2]))
# → [[1,1,2], [1,2,1], [2,1,1]]

去重剪枝的灵魂一笔if i > 0 and nums[i] == nums[i - 1] and not used[i - 1]。这条规则的意思是:「当相同元素出现时,只有前一个被用了,后一个才能用。」这样就避免了相同元素在不同顺序下的重复排列。

2.3 子集(Subsets)—— 每个元素「选或不选」

问题:给定 nums = [1, 2, 3],返回所有子集(共 2^n 个)。

全排列和子集的最大区别:子集关心「有没有」不关心「顺序」。

全排列: [1,2] 和 [2,1] 都算
子集:   [1,2] 和 [2,1] 算同一个——所以只产生一个

解决方法:每次只从当前元素后面选择,不走回头路。

python
def subsets(nums):
    """子集生成——每个元素选或不选"""
    result = []
    path = []

    def backtrack(start):
        # 每个路径都是合法子集(不设长度约束)
        result.append(path[:])

        for i in range(start, len(nums)):
            path.append(nums[i])
            backtrack(i + 1)  # 关键:只从后面选
            path.pop()

    backtrack(0)
    return result


print(subsets([1, 2, 3]))
# → [[], [1], [1,2], [1,2,3], [1,3], [2], [2,3], [3]]

对比:子集 vs 全排列的回溯模板

全排列:used[] 数组 → 选过的不能再选(顺序重要)
子集:  start 指针 → 只从当前后面选(顺序不重要)

这个 start 指针是回溯中极其重要的一个模式。它意味着「不考虑之前已经处理过的元素」,是「组合类」问题(子集、组合、子集和)的标准写法。

** C++ 差异:** C++ 的 vector::push_back / pop_back 就是标准回溯操作。注意 result.push_back(path) 要做拷贝:result.push_back(path) 自动深拷贝,但如果你存的是指针就得手动 reserve 避免多次分配。

2.4 子集和(Subset Sum)—— 剪枝才是灵魂

问题:给定数组 nums 和目标值 target,找出所有和为 target 的子集(组合,不可重复使用同一元素)。

python
def subset_sum(nums, target):
    """子集和——组合求和,每个元素只用一次"""
    nums.sort()  # 排序:便于剪枝 + 去重
    result = []
    path = []

    def backtrack(start, remaining):
        if remaining == 0:  # 目标:和等于 target
            result.append(path[:])
            return

        for i in range(start, len(nums)):
            # 剪枝 1:当前元素太大 → 后续更大,直接 break
            if nums[i] > remaining:
                break

            # 剪枝 2:同层重复元素去重
            if i > start and nums[i] == nums[i - 1]:
                continue

            path.append(nums[i])
            backtrack(i + 1, remaining - nums[i])
            path.pop()

    backtrack(0, target)
    return result


print(subset_sum([10, 1, 2, 7, 6, 1, 5], 8))
# → [[1, 1, 6], [1, 2, 5], [1, 7], [2, 6]]

剪枝分析

  1. 排序 + break:因为数组已排序,当前 nums[i] > remaining 时,后面的元素更大,更不可能满足条件。直接 break 整层。
  2. 同层去重:同一递归层中,相同的元素如果已经用第一个试过了,跳过后面的。这避免了 [1, 2, 5] 因为有两个 1 而出现两次([1a,2,5][1b,2,5])。

子集和的可重用版本(每个元素可无限次使用)

python
def subset_sum_unlimited(nums, target):
    """元素可重复使用的子集和"""
    result = []
    path = []

    def backtrack(start, remaining):
        if remaining == 0:
            result.append(path[:])
            return

        for i in range(start, len(nums)):
            if nums[i] > remaining:
                break

            path.append(nums[i])
            backtrack(i, remaining - nums[i])  # 注意:i,不是 i+1
            path.pop()

    backtrack(0, target)
    return result


print(subset_sum_unlimited([2, 3, 6, 7], 7))
# → [[2, 2, 3], [7]]

可重用的版本传 i 而非 i+1,因为当前元素可以再用一次。


常见陷阱

分治的核心:子问题必须独立

分治不是「递归」的同义词。分治要求子问题之间不重叠。

归并排序的子问题是独立的——左右两半互不相干。最大子数组的子问题部分重叠——左右最大子数组与跨中点最大子数组有重叠,但我们在「合」的步骤中额外处理了这个重叠。

如果子问题高度重叠——比如斐波那契数列——分治会退化到指数级。这正是下一章动态规划出场的原因。

回溯的复杂度

问题状态数剪枝后
N 皇后(无剪枝)n!远小于 n!
全排列n!n!(无法剪,必须全生成)
子集2^n2^n
子集和2^n取决于数据

回溯的复杂度通常是指数级的。这不是 bug——这是回溯的本质:系统性地枚举所有可能。 剪枝能大幅度加速,但不改变最坏情况的复杂度。

分治 vs 回溯 vs 动态规划(一句话)

  • 分治:大问题切成小问题,子问题独立求解再合并
  • 回溯:系统性地试所有可能路径,走不通就回头
  • 有重叠子问题时再考虑动态规划:把子问题答案存起来避免重复计算

通关挑战

  1. 分治求多数元素:给定数组,找出出现次数超过一半的元素(LeetCode 169)。尝试分治解法——这也 O(n log n),但更巧妙
  2. 数独求解器:用回溯实现 9×9 数独求解器。剪枝的关键是:每填一个格子,只尝试当前行/列/宫格中可行的数字
  3. 排列的字典序下一个:不生成所有排列,直接给出当前排列的下一个字典序排列(LeetCode 31)
  4. 组合总和 IV:给你一个无重复的正整数数组和一个 target,求出元素和等于 target 的排列数(注意:不同顺序算不同结果)。这个题归回溯还是 DP?
  5. N 皇后 bitset 优化:用位运算(三个整数分别表示列、主对角线、副对角线的占用)把 is_safe 的 O(n) 检查降到 O(1)

验收标准

  • [ ] 能徒手写出分治三步法解决最大子数组问题
  • [ ] 能解释逆序对计数为什么在 else 分支做 mid - i + 1
  • [ ] 能给任意回溯问题套模板:选择、约束、目标三要素
  • [ ] 能手写 N 皇后并回答 why 一维数组
  • [ ] 能区分全排列(used[])和子集(start)的回溯写法差异
  • [ ] 能画出 n=3 的全排列回溯树

常见卡点

卡点破解
分治的「合」不知道怎么写「合」不是玄学——想想跨中点的方案怎么做就行了。最大子数组问题里是「从中点左右扩展」
逆序对的 mid - i + 1 为什么对因为左半已经有序了。arr[i] > arr[j] 意味着左半从 i 开始的每个元素都 > arr[j]——别忘了左半是递增的
回溯的撤销总是忘模板写熟练:「做选择 → 递归 → 撤销选择」三行紧贴,先背下来再理解
全排列的 used 数组什么时候恢复递归返回后立刻恢复。 used[i] = True + backtrack + path.pop + used[i] = False
子集的 path[:] 拷贝不拷贝的话最后 result 里全是同一个 []——因为 path 是引用,后续的 pop 会清空它
剪枝写得过头导致漏解先写无剪枝版本,再逐层优化。剪枝 = 仍保证正确性的优化

现在不需要理解

  • 分支限界法(Branch and Bound)—— 回溯 + 上下界估计,在组合优化问题上(TSP、背包)更高效。它比回溯多了「评估当前路径的理论最优值,如果不如已有解就放弃」这一步。
  • 分治的 Master Theorem—— 形如 T(n) = aT(n/b) + f(n) 的递归式有通用的复杂度计算公式。知道它但不用死记,因为实际分析时画递归树更直观。
  • Meet in the Middle—— 当 2^n 太大但又想用搜索时,把集合平分,分别搜索再合并。这是个比回溯更少的回路的技巧。

旅人笔记

  • 分治是「分而治之」思想的完整实践。 就算你忘了所有分治算法,记住「大问题切小问题,小问题独立解决再合并」这个思维框架也值了。
  • 逆序对问题是分治最漂亮的「附加价值」案例。 做排序的同时顺手统计了逆序数——如果你在面试中遇到「计算右侧小于当前元素的个数」,这就是逆序对的变种。
  • 回溯的撤销操作是大多数 bug 的来源。 每写一个 backtrack,立即写相应的撤销代码。不要先写十个节点再来补撤销——一定会漏。
  • N 皇后是全排列的约束版本。 理解了 N 皇后是「全排列 + 对角线约束」,你就看透了回溯的本质:选择 + 约束 + 目标。
  • 剪枝不是优化,是灵魂。 没有剪枝的回溯只是慢一点的暴力枚举。剪枝让回溯从一个「理论算法」变成一个「面对现实数据的可用方案」。

一句话记住本章:「分治切到单,回溯记三选,剪枝是关键。」


→ 下一站预告

准备好了吗?如果你觉得分治和回溯已经够复杂了——那下一章会让你真正紧张起来。

第 13 章:动态规划。

这是整个数据结构与算法卷的巅峰之章。你会学到:

  • 从递归 → 记忆化搜索 → DP 表格的完整推导过程
  • 0-1 背包、完全背包、多重背包
  • 最长上升子序列(LIS)、最长公共子序列(LCS)
  • 区间 DP、状态压缩 DP

分治和回溯只是热身。动态规划才是真正的 Boss。

← 回到第 11 章:搜索与字符串匹配 | → 进入第 13 章:动态规划

Built with VitePress | Software Systems Atlas