Chapter 10

贪心算法与回溯

局部最优与穷举之道——N 皇后、全排列与区间调度完全指南

贪心算法原理

贪心算法在每一步选择当前看起来最优的选项(局部最优),希望由此导致全局最优。

⚠️

贪心不总是正确的贪心策略不是万能的。需要证明局部最优选择不会影响未来决策,才能保证全局最优。常用的证明方法:贪心选择性质(交换论证)、归纳法。

贪心 vs 动态规划

方面贪心动态规划
决策方式每步做一个不可回头的选择枚举所有可能,取最优
正确性需要证明贪心选择性质只要子结构最优,就正确
效率通常更高效(O(n log n) 或 O(n))通常需要 O(n²) 或更高
适用活动选择、霍夫曼编码背包问题、编辑距离

经典贪心题目

活动选择问题(区间调度)

给定 n 个活动,每个活动有开始时间和结束时间。选择尽可能多的互不重叠的活动。

def activity_selection(intervals):
    # 贪心策略:每次选结束时间最早的活动(为后续活动留最多时间)
    intervals.sort(key=lambda x: x[1])  # 按结束时间排序
    selected = []
    last_end = -float('inf')

    for start, end in intervals:
        if start >= last_end:   # 不与上一个活动重叠
            selected.append((start, end))
            last_end = end
    return selected

# 示例
acts = [(1,4),(3,5),(0,6),(5,7),(3,9),(5,9),(6,10),(8,11)]
print(activity_selection(acts))
# [(1,4), (5,7), (8,11)] — 3 个活动

跳跃游戏

def can_jump(nums: list) -> bool:
    # 贪心:维护当前能到达的最远位置
    max_reach = 0
    for i, jump in enumerate(nums):
        if i > max_reach:   # 当前位置不可达
            return False
        max_reach = max(max_reach, i + jump)
    return True

def jump_min_steps(nums: list) -> int:
    """跳跃游戏 II:最少跳几次到达末尾"""
    jumps = curr_end = farthest = 0
    for i in range(len(nums) - 1):
        farthest = max(farthest, i + nums[i])
        if i == curr_end:   # 到达当前跳的边界,必须跳
            jumps += 1
            curr_end = farthest
    return jumps

分发饼干

def find_content_children(g: list, s: list) -> int:
    # g[i]: 第 i 个孩子的胃口;s[j]: 第 j 块饼干的尺寸
    # 贪心:用最小的够吃的饼干满足胃口最小的孩子
    g.sort(); s.sort()
    i = j = 0
    while i < len(g) and j < len(s):
        if s[j] >= g[i]:   # 这块饼干能满足这个孩子
            i += 1
        j += 1
    return i   # 满足的孩子数

Huffman 编码思路

Huffman 编码是一种贪心算法,给高频字符分配短编码,低频字符分配长编码,实现无损压缩。

import heapq

def build_huffman(freq_dict):
    """构建 Huffman 树,返回各字符的编码"""
    # 每次取频率最小的两棵树合并(贪心选择:最小代价先合并)
    heap = [(freq, char) for char, freq in freq_dict.items()]
    heapq.heapify(heap)

    while len(heap) > 1:
        f1, c1 = heapq.heappop(heap)   # 最小频率
        f2, c2 = heapq.heappop(heap)   # 次小频率
        merged = (f1 + f2, (c1, c2))   # 合并为新节点
        heapq.heappush(heap, merged)

    return heap[0]   # 返回 Huffman 树根

回溯算法框架

回溯是一种系统化穷举的算法:在所有可能的选择中尝试每一条路径,遇到不满足条件的情况就"回溯"撤销选择,尝试下一条路径。

def backtrack(路径, 选择列表):
    if 满足结束条件:
        result.append(路径.copy())   # 记录结果
        return
    for 选择 in 选择列表:
        if 不满足约束条件:           # 剪枝
            continue
        做选择                       # 将选择加入路径
        backtrack(路径, 新的选择列表)
        撤销选择                     # 恢复现场(关键!)

排列问题:全排列

# 无重复元素的全排列
def permute(nums: list) -> list:
    result = []

    def backtrack(path, remaining):
        if not remaining:
            result.append(path[:])   # 注意要复制!
            return
        for i, x in enumerate(remaining):
            path.append(x)
            backtrack(path, remaining[:i] + remaining[i+1:])
            path.pop()               # 撤销选择

    backtrack([], nums)
    return result

# 含重复元素的全排列(去重)
def permute_unique(nums: list) -> list:
    result = []
    nums.sort()   # 排序,让相同元素相邻

    def backtrack(path, used):
        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, used)
            path.pop()
            used[i] = False

    backtrack([], [False] * len(nums))
    return result

组合问题

# 组合总和(元素可重复使用)
def combination_sum(candidates, target):
    result = []

    def backtrack(start, path, remaining):
        if remaining == 0:
            result.append(path[:])
            return
        for i in range(start, len(candidates)):
            if candidates[i] > remaining:  # 剪枝:超出目标
                break
            path.append(candidates[i])
            backtrack(i, path, remaining - candidates[i])   # i 而非 i+1(可重复)
            path.pop()

    candidates.sort()
    backtrack(0, [], target)
    return result

# 电话号码字母组合
def letter_combinations(digits: str) -> list:
    if not digits: return []
    phone = {'2':'abc','3':'def','4':'ghi','5':'jkl',
             '6':'mno','7':'pqrs','8':'tuv','9':'wxyz'}
    result = []

    def backtrack(idx, path):
        if idx == len(digits):
            result.append(''.join(path))
            return
        for ch in phone[digits[idx]]:
            path.append(ch)
            backtrack(idx + 1, path)
            path.pop()

    backtrack(0, [])
    return result

子集问题

def subsets(nums: list) -> list:
    result = []

    def backtrack(start, path):
        result.append(path[:])   # 每个状态都是一个子集
        for i in range(start, len(nums)):
            path.append(nums[i])
            backtrack(i + 1, path)
            path.pop()

    backtrack(0, [])
    return result

# n 个元素共有 2^n 个子集
# 示例:subsets([1,2,3]) → [[], [1], [1,2], [1,2,3], [1,3], [2], [2,3], [3]]

N 皇后

在 n×n 的棋盘上放置 n 个皇后,使得任意两个皇后不在同一行、同一列、同一对角线。

def solve_n_queens(n: int) -> list:
    result = []
    board = [['.'] * n for _ in range(n)]
    cols = set()           # 已被占用的列
    diag1 = set()          # 已被占用的左上-右下对角线(row - col 相同)
    diag2 = set()          # 已被占用的右上-左下对角线(row + col 相同)

    def backtrack(row):
        if row == n:
            # 找到一个合法解
            result.append([''.join(r) for r in board])
            return
        for col in range(n):
            # 检查冲突(剪枝)
            if col in cols or (row - col) in diag1 or (row + col) in diag2:
                continue
            # 做选择
            board[row][col] = 'Q'
            cols.add(col)
            diag1.add(row - col)
            diag2.add(row + col)
            # 递归下一行
            backtrack(row + 1)
            # 撤销选择
            board[row][col] = '.'
            cols.discard(col)
            diag1.discard(row - col)
            diag2.discard(row + col)

    backtrack(0)
    return result

# n=4 有 2 个解,n=8 有 92 个解

数独求解器

def solve_sudoku(board) -> None:
    def is_valid(row, col, num):
        char = str(num)
        # 检查行
        if char in board[row]: return False
        # 检查列
        if any(board[r][col] == char for r in range(9)): return False
        # 检查 3×3 方块
        br, bc = 3 * (row // 3), 3 * (col // 3)
        for r in range(br, br+3):
            for c in range(bc, bc+3):
                if board[r][c] == char: return False
        return True

    def backtrack() -> bool:
        for row in range(9):
            for col in range(9):
                if board[row][col] != '.': continue  # 跳过已填
                for num in range(1, 10):
                    if is_valid(row, col, num):
                        board[row][col] = str(num)
                        if backtrack():
                            return True   # 找到解
                        board[row][col] = '.'  # 撤销
                return False   # 1-9 都不行,回溯
        return True   # 所有格子填满,成功

    backtrack()

回溯剪枝技巧
1. 约束传播:提前排除无效选项(如 N 皇后用 set 记录冲突)
2. 排序优先:先尝试最有可能成功的选择(fail-first 原则)
3. 可行性剪枝:当剩余元素不够组成答案时提前退出(如组合总和检查超出)
4. 最优性剪枝:当当前路径不可能比已知最优解更好时剪枝

🎓

恭喜完成全部10章!
你已经学习了算法与数据结构的全貌:
复杂度分析 → 数组/字符串 → 链表 → 栈/队列 → 哈希表 → 树 → 图 → 排序 → 动态规划 → 贪心/回溯
下一步:在 LeetCode 上按专题练习,每道题都先尝试独立思考,再对照这里的框架分析。