分治算法:化整为零的智慧

分治算法,说白了就是「分而治之」。我刚开始学算法时,总觉得这名字挺唬人。后来做项目多了才发现,这其实就是我们日常解决问题的本能——遇到一个大问题,先拆成几个小问题,小问题还解决不了?那就再拆。直到拆成一眼就能看穿的小块,然后逐个击破,最后把结果拼起来。

你想想看,这跟管理一个团队是不是很像?CEO 不会亲自去写每一行代码,而是把任务拆解,交给各个部门去完成。分治算法也是这个道理。

分治的三步曲

分治算法有三个核心步骤,我习惯这么记:

  1. 分解(Divide):把原问题拆成若干个规模更小的子问题
  2. 解决(Conquer):递归地解决这些子问题。如果子问题足够小,就直接求解
  3. 合并(Combine):把子问题的解合并成原问题的解

嗯,这里要注意:并不是所有问题都适合分治。只有那些「可以拆、拆完能合」的问题,才值得用分治。我在项目中见过不少强行用分治的代码,结果拆完合不回来,反而更复杂了。

分治算法的适用条件:

  • 问题可以分解为结构相似的子问题
  • 子问题的解可以合并为原问题的解
  • 子问题之间相互独立(没有重叠)
分治算法核心流程 原问题 分解 (Divide) 子问题 1 子问题 2 子问题 3 解决 (Conquer) 解决 (Conquer) 解决 (Conquer) 合并 (Combine)

二分搜索:最经典的分治

说到分治,二分搜索是绕不开的例子。它解决的问题很简单:在一个有序数组中找某个数。但它的思想,影响了无数算法设计。

二分搜索的思路是这样的:每次取中间元素,跟目标值比较。如果相等,直接返回;如果目标值小于中间元素,说明目标在左半部分;否则在右半部分。每次都能把搜索范围缩小一半。

我的经验:二分搜索看似简单,但边界条件特别容易写错。我曾经在一个嵌入式项目中,因为二分搜索的边界写成了 while (left < right) 而不是 while (left <= right),导致数组越界,系统跑飞了。排查了整整一个下午才找到问题。

// 二分搜索的 C 语言实现
int binarySearch(int arr[], int left, int right, int target) {
    while (left <= right) {
        int mid = left + (right - left) / 2;  // 防止溢出
        
        if (arr[mid] == target) {
            return mid;  // 找到了
        } else if (arr[mid] < target) {
            left = mid + 1;  // 目标在右半部分
        } else {
            right = mid - 1; // 目标在左半部分
        }
    }
    return -1;  // 没找到
}

注意看 mid = left + (right - left) / 2 这个写法。为什么不用 (left + right) / 2?因为当 left 和 right 都很大时,left + right 可能会溢出。我在 32 位单片机上就踩过这个坑。

大整数乘法:化乘为加

大整数乘法是分治的另一个经典应用。你可能会问:乘法有什么好讲的?直接用 * 不就行了?

问题是,当整数大到几千位时,CPU 的乘法指令就无能为力了。我们需要自己实现乘法算法。传统的竖式乘法复杂度是 O(n²),而用分治思想可以优化到 O(n^1.585)。

这个算法叫 Karatsuba 乘法,思路是这样的:

  1. 把两个 n 位数各拆成两半:高位和低位
  2. 计算三个乘积:高位乘高位、低位乘低位、以及 (高位+低位) 乘 (高位+低位)
  3. 通过加减组合得到最终结果

Karatsuba 公式:

设 X = a * 10^(n/2) + b, Y = c * 10^(n/2) + d

则 X * Y = ac * 10^n + (ad + bc) * 10^(n/2) + bd

其中 ad + bc = (a+b)(c+d) - ac - bd

你看,原本需要 4 次乘法,现在只需要 3 次。递归下去,复杂度就降低了。我在做密码学相关项目时,处理 RSA 的大数运算就用到了这个思想。

棋盘覆盖问题:视觉化的分治

棋盘覆盖问题是我个人觉得最有趣的分治例子。问题描述是这样的:

有一个 2^k × 2^k 的棋盘,其中恰好有一个方格是残缺的。现在要用 L 型骨牌(覆盖 3 个方格)覆盖整个棋盘,要求不重叠、不遗漏。

怎么解?分治!

把棋盘分成四个 2^(k-1) × 2^(k-1) 的小棋盘。残缺格在其中一个子棋盘里。我们在其他三个子棋盘的交界处放一个 L 型骨牌,这样每个子棋盘就都有一个「残缺格」了。然后递归处理每个子棋盘。

避坑指南:我曾经在实现棋盘覆盖时,递归的终止条件写错了。当棋盘大小为 1×1 时,应该直接返回,但我忘了处理,导致无限递归。调试时栈溢出,系统直接复位。从那以后,我写递归函数的第一件事就是先写终止条件。

// 棋盘覆盖的 C 语言框架
#define MAX_SIZE 1024
int board[MAX_SIZE][MAX_SIZE];  // 棋盘
int tile = 1;  // 骨牌编号

void chessboard(int tr, int tc, int dr, int dc, int size) {
    if (size == 1) return;  // 终止条件
    
    int s = size / 2;
    int t = tile++;
    
    // 覆盖左上角子棋盘
    if (dr < tr + s && dc < tc + s) {
        chessboard(tr, tc, dr, dc, s);
    } else {
        board[tr + s - 1][tc + s - 1] = t;
        chessboard(tr, tc, tr + s - 1, tc + s - 1, s);
    }
    
    // 覆盖右上角子棋盘
    if (dr < tr + s && dc >= tc + s) {
        chessboard(tr, tc + s, dr, dc, s);
    } else {
        board[tr + s - 1][tc + s] = t;
        chessboard(tr, tc + s, tr + s - 1, tc + s, s);
    }
    
    // 覆盖左下角子棋盘
    if (dr >= tr + s && dc < tc + s) {
        chessboard(tr + s, tc, dr, dc, s);
    } else {
        board[tr + s][tc + s - 1] = t;
        chessboard(tr + s, tc, tr + s, tc + s - 1, s);
    }
    
    // 覆盖右下角子棋盘
    if (dr >= tr + s && dc >= tc + s) {
        chessboard(tr + s, tc + s, dr, dc, s);
    } else {
        board[tr + s][tc + s] = t;
        chessboard(tr + s, tc + s, tr + s, tc + s, s);
    }
}

这段代码看起来有点长,但核心逻辑其实很简单:每次递归处理四个子棋盘,把「伪残缺格」放在交界处。递归深度是 k,所以对于 8×8 的棋盘,递归深度只有 3 层。

分治的时间复杂度分析

分治算法的时间复杂度,通常可以用主定理(Master Theorem)来分析。对于形如 T(n) = aT(n/b) + f(n) 的递归式:

条件 时间复杂度 典型例子
f(n) = O(n^c) 且 c < log_b(a) T(n) = Θ(n^log_b(a)) 二分搜索:T(n) = T(n/2) + O(1) → Θ(log n)
f(n) = Θ(n^c) 且 c = log_b(a) T(n) = Θ(n^c log n) 归并排序:T(n) = 2T(n/2) + O(n) → Θ(n log n)
f(n) = Ω(n^c) 且 c > log_b(a) T(n) = Θ(f(n)) 棋盘覆盖:T(n) = 4T(n/2) + O(1) → Θ(n²)

其实主定理不用死记硬背。你只要记住:分治的效率取决于「拆分的份数」和「合并的代价」之间的博弈。拆得越多,子问题越多;合并越复杂,总时间越长。

我的建议:在实际项目中,不要盲目追求理论上的最优分治。有时候递归带来的函数调用开销,反而比简单的 O(n²) 算法更慢。我在一个 ARM Cortex-M3 的嵌入式项目中,就遇到过递归深度过大导致栈溢出的问题。对于小规模数据,简单的循环往往更可靠。

分治算法的魅力在于,它把复杂问题变得有章可循。无论是二分搜索的简洁、大整数乘法的巧妙,还是棋盘覆盖的视觉美感,背后都是同一个思想:化整为零,逐个击破。掌握了这个思想,你会发现很多看似棘手的问题,其实都有迹可循。


公众号:蓝海资料掘金营,微信deep3321