递归、分治与主定理 — 从零精通算法与数据结构——Google 面试系统备战 第8篇

第8章:递归、分治与主定理

本章目标

读完本章你会:

  • 用三种方法(递归树、代入法、主定理)分析任何分治算法
  • 精确应用主定理的三个情况,不混淆、不误用
  • 手写最大子数组、矩阵乘法的分治解法
  • 理解 Strassen 算法为什么能降到 O(n^{log₂ 7}) ≈ O(n^{2.81})
  • 解决最近点对问题——分治在计算几何中的经典应用

知识讲解

从一个生活例子开始

你有一份 1000 人的名单,需要找出年龄最大的人。

方法 A(逐个比较): 一个接一个比,记录当前最大值。需要 999 次比较。

方法 B(分组赛): 分成两半,各自找出半区冠军,然后两个冠军对决。半区内的冠军又是用同样的方法找的——递归。还是需要 999 次比较——没减少。

但如果是找两个最接近生日的人呢? 方法 A 需要两两比较——约 500000 次。方法 B 分组找,可以跳过很多不必要的比较——远少于 500000 次。

这就是分治法的核心:问题有某种"结构"使得分治能比暴力更快。 没有这种结构,分治和暴力一样差;有这种结构,分治能降到 O(n log n) 甚至更低。

工作原理

8.1 主定理:分析递归的瑞士军刀

大多数分治递归满足:T(n) = a·T(n/b) + f(n)

  • a: 子问题个数
  • b: 每个子问题的规模缩小比例
  • f(n): 划分 + 合并的代价

主定理三情况:

比较 f(n) 和 n^{log_b a}(递归产生的"叶片代价")

情况 1:叶子主导(f(n) 增长慢于 n^{log_b a})

如果 f(n) = O(n^{log_b a - ε}),那么 T(n) = Θ(n^{log_b a})

直觉:合并代价被大量子问题淹没——瓶颈在递归树的叶子层

情况 2:各层均衡(f(n) 和 n^{log_b a} 同阶)

如果 f(n) = Θ(n^{log_b a}·logᵏ n),那么 T(n) = Θ(n^{log_b a}·log^{k+1} n)

直觉:每层工作量相同,乘上层数 log n —— 归并排序就是 a=2, b=2, f(n)=Θ(n), log_b a=1

情况 3:根主导(f(n) 增长快于 n^{log_b a})

如果 f(n) = Ω(n^{log_b a + ε}),且满足正则条件 af(n/b) ≤ cf(n),那么 T(n) = Θ(f(n))

直觉:合并代价太大,递归优势消失——瓶颈在顶层

8.2 经典分治问题

最大子数组问题: 在一个数组中找到和最大的连续子数组。

分治思路:

1. 分成左右两半
2. 最大子数组要么全在左边、全在右边、或跨越中点
3. 前两种递归,第三种 O(n) 扫描
→ T(n) = 2T(n/2) + O(n) = O(n log n)

Kadane 算法能做到 O(n),但分治解法展现了如何把"看似必须全局扫描"的问题拆成子问题。

矩阵乘法与 Strassen 算法:

标准矩阵乘法 C = A × B,C[i][j] = Σ A[i][k]·B[k][j] → O(n³)。

Strassen 的惊人发现(1969):矩阵乘法可以分成 7 次 n/2 大小的乘法(而不是 8 次),代价是一些额外的加减法:

标准分治: T(n) = 8T(n/2) + O(n²) → 情况 1, n^{log₂ 8} = n³ → O(n³),没改进
Strassen:  T(n) = 7T(n/2) + O(n²) → 情况 1, n^{log₂ 7} ≈ n^{2.807} → 更快!

Strassen 的核心是用巧妙的线性组合把 8 次乘法压缩为 7 次,多做了几个 O(n²) 的加法(在大 n 面前微不足道)。这直接推动了"矩阵乘法下界是多少"的理论研究。

最近点对问题:

给定平面上 n 个点,找到距离最近的两个点。

暴力:O(n²)。分治:O(n log n)。

1. 按 x 坐标排序,分成左右两半
2. 递归找左右最近对,取 δ = min(左最近, 右最近)
3. 关键:检查跨越中线的点对——但不是所有点!
   只需检查距中线 ≤ δ 的点,按 y 坐标排序后,每个点最多检查后面 7 个点
→ T(n) = 2T(n/2) + O(n log n) → O(n log² n)

8.3 递归树法:当主定理不够用

主定理的三种情况不覆盖所有递归式。当主定理不适用时,画递归树:

T(n) = 2T(n/2) + n log n

层 0:           n log n
层 1:      n/2 log(n/2) × 2 = n log n - n log 2
层 2:  n/4 log(n/4) × 4 = n log n - 2n log 2
...
层 i: n/2ⁱ log(n/2ⁱ) × 2ⁱ = n(log n - i log 2)

层数: log n
总和: n Σ(log n - i) = Θ(n log² n)

递归树法的步骤:

  1. 画树,标注每层的问题大小和个数
  2. 计算每层总工作量
  3. 对层求和(层数通常 O(log n))
  4. 得出渐进界

代码实战

include/algo/divide_conquer.h

#ifndef ALGO_DIVIDE_CONQUER_H_
#define ALGO_DIVIDE_CONQUER_H_

#include <algorithm>
#include <cstdint>
#include <limits>
#include <vector>

namespace algo {

// ========== 最大子数组 ==========

struct SubArrayResult {
  int64_t sum;
  int left;   // 起始索引
  int right;  // 结束索引(包含)
};

// 分治法:O(n log n)——展示分治思想
SubArrayResult MaxSubArrayDivideConquer(const std::vector<int>& data);

// Kadane 算法:O(n)——面试中的最优解
SubArrayResult MaxSubArrayKadane(const std::vector<int>& data);

// ========== 最近点对 ==========

struct Point {
  double x;
  double y;
};

// 返回最近点对的距离,O(n log n)
double ClosestPair(std::vector<Point>& points);

}  // namespace algo

#endif  // ALGO_DIVIDE_CONQUER_H_

include/algo/divide_conquer_impl.h(核心实现)

#ifndef ALGO_DIVIDE_CONQUER_IMPL_H_
#define ALGO_DIVIDE_CONQUER_IMPL_H_

namespace algo {

namespace detail {

SubArrayResult MaxCrossingSubArray(const std::vector<int>& data,
                                    int left, int mid, int right) {
  // 从中点向左扩展,找最大和
  int64_t left_sum = std::numeric_limits<int64_t>::min();
  int64_t sum = 0;
  int max_left = mid;
  for (int i = mid; i >= left; --i) {
    sum += data[i];
    if (sum > left_sum) {
      left_sum = sum;
      max_left = i;
    }
  }

  // 从中点+1向右扩展
  int64_t right_sum = std::numeric_limits<int64_t>::min();
  sum = 0;
  int max_right = mid + 1;
  for (int i = mid + 1; i <= right; ++i) {
    sum += data[i];
    if (sum > right_sum) {
      right_sum = sum;
      max_right = i;
    }
  }

  return {left_sum + right_sum, max_left, max_right};
}

SubArrayResult MaxSubArrayDC(const std::vector<int>& data,
                              int left, int right) {
  if (left == right) return {data[left], left, right};

  int mid = left + (right - left) / 2;
  auto left_result = MaxSubArrayDC(data, left, mid);
  auto right_result = MaxSubArrayDC(data, mid + 1, right);
  auto cross_result = MaxCrossingSubArray(data, left, mid, right);

  if (left_result.sum >= right_result.sum &&
      left_result.sum >= cross_result.sum)
    return left_result;
  if (right_result.sum >= left_result.sum &&
      right_result.sum >= cross_result.sum)
    return right_result;
  return cross_result;
}

}  // namespace detail

inline SubArrayResult MaxSubArrayDivideConquer(const std::vector<int>& data) {
  return detail::MaxSubArrayDC(data, 0, static_cast<int>(data.size()) - 1);
}

inline SubArrayResult MaxSubArrayKadane(const std::vector<int>& data) {
  int64_t max_ending_here = data[0];
  int64_t max_so_far = data[0];
  int start = 0, end = 0, temp_start = 0;

  for (int i = 1; i < static_cast<int>(data.size()); ++i) {
    if (max_ending_here + data[i] < data[i]) {
      max_ending_here = data[i];
      temp_start = i;
    } else {
      max_ending_here += data[i];
    }
    if (max_ending_here > max_so_far) {
      max_so_far = max_ending_here;
      start = temp_start;
      end = i;
    }
  }
  return {max_so_far, start, end};
}

}  // namespace algo

#endif  // ALGO_DIVIDE_CONQUER_IMPL_H_

本章小结

  1. 通用递归式:T(n) = a·T(n/b) + f(n)。a 个子问题,缩小至 n/b,划分+合并 O(f(n))
  2. 主定理:比较 f(n) 和 n^{log_b a}——叶子主导(情况1)、均衡(情况2)、根主导(情况3)
  3. 递归树法:画树 → 每层求和 → 对层数求和。当主定理不适用时的通用方法
  4. 最大子数组分治 O(n log n) 展示如何将"跨越边界"问题拆解;Kadane O(n) 是面试最优解
  5. Strassen O(n^{2.807}) 证明矩阵乘法可以低于 O(n³),常数大但理论意义深远

关键术语

术语 释义
主定理 给出一大类递归式渐进解的工具,基于比较 f(n) 和 n^
递归树 将递归调用结构可视化的方法,每层标注问题规模和合并代价
Strassen 算法 用 7 次 n/2 乘法(而非 8 次)实现矩阵乘法的分治算法
posted @ 2026-06-22 01:09  Yobeeo  阅读(13)  评论(0)    收藏  举报