CC++ & Algorithm

分治算法:把大问题切成小块吃掉的智慧

困难16
语言版本:C++Python
概述:分治算法是一种“分而治之”的思想,把复杂问题拆成简单小问题,解决后再合并,就像收拾散落的拼图一样。

用分治算法,把大问题切成小块吃掉的秘诀

你有没有遇到过这样的场景:妈妈让你整理一箱子杂乱无章的玩具,你直接动手肯定累得不行。但如果先把玩具分成几堆——乐高一堆、小汽车一堆、布娃娃一堆,每堆再分别摆好,最后合到一起就整整齐齐了。这就是“分治”思想的日常版本。

在计算机程序里,分治算法(Divide and Conquer)就是做同样的事:把一个大问题拆成若干个规模更小、结构相似的子问题,分别解决它们,再把结果合并起来,得到最终答案。听起来很简单?没错!很多复杂的问题,比如排序、搜索、求最大值,都能用这种方法轻松搞定。

分治的三大步骤:拆、解、合

分治算法的核心只有三步,我们挨个来看:

  1. 分解:把一个大问题分成若干个相同类型的子问题。就像把50个同学的名单分成两组,每组25人。
  2. 解决:分别解决这些子问题。如果子问题仍然很大,可以继续递归分解,直到子问题小到可以直接解决(比如只剩1个人,他的分数就是他自己)。
  3. 合并:把子问题的解组合起来,得到原问题的答案。比如把两组的总分加起来,就是全班总分。

生活例子:你想知道全班同学的身高总和。可以先把名单平均分成两组,每组25人;再每组分成两小组,直到只剩1个人——那他的身高就是他自己。然后从最小的组开始,把两个身高加起来,一步步往上合并,最后得到全班总和。整个过程就像搭积木,一层一层往上堆。

代码实战:用分治求数组的最大值

我们来看一段具体的C++代码。它不断把数组从中间切开,直到只剩一个数,然后比较左右两边的最大值,取较大的那个。

#include <iostream>
using namespace std;

// 函数:找到数组 arr[left...right] 中的最大值
// left: 左边界索引,right: 右边界索引
int findMax(int arr[], int left, int right) {
    // 如果只剩一个元素,它就是最大值(递归终止条件)
    if (left == right) {
        return arr[left];
    }
    // 从中间分开
    int mid = (left + right) / 2;
    // 递归求左半部分的最大值
    int leftMax = findMax(arr, left, mid);
    // 递归求右半部分的最大值
    int rightMax = findMax(arr, mid + 1, right);
    // 合并:取两者中较大的那个
    return (leftMax > rightMax) ? leftMax : rightMax;
}

int main() {
    int arr[] = {3, 8, 1, 6, 9, 2, 7};  // 待查找的数组
    int n = sizeof(arr) / sizeof(arr[0]); // 数组元素个数
    cout << "数组中的最大值是: " << findMax(arr, 0, n - 1) << endl;
    return 0;
}

运行这段代码,它会输出9。你可能会想:这个代码用了递归,执行时到底怎么工作的?我们来模拟一下。假设数组是 {3, 8, 1, 6, 9, 2, 7},索引从0到6。

  • 第一次调用 findMax(arr, 0, 6),mid=3,分成左半 [0..3] 和右半 [4..6]。
  • 左半继续分:[0..1] 和 [2..3];右半分:[4..5] 和 [6..6](因为只剩一个元素,直接返回7)。
  • 再继续分,直到每个子数组只有一个元素。比如 [0..0] 返回3,[1..1] 返回8,然后比较得到 leftMax=8;[2..2] 返回1,[3..3] 返回6,得到 leftMax=6(注意这里左右名字有重名,但理解过程就好)。
  • 然后向上合并:[0..1]得到8,[2..3]得到6,比较得8;[4..5]中[4..4]=9, [5..5]=2,得9;[6..6]=7。最后合并 [0..3]=8 和 [4..6]=9,得到全局最大值9。

你看,分治算法把复杂问题变得像切蛋糕一样简单。

更多生活中的分治例子

  • 切蛋糕:你有一块大蛋糕,要分给全班同学。先把蛋糕切成两半,每半再切成两半,直到每个人得到一小块。最后你不需要关心整块蛋糕有多大,只要每块分对就行——这就是分治。
  • 计算班级平均分:把全班按学号分成两组,每组算出平均分,然后取两个平均分的平均值?等等,这不对!平均分不能直接这样合并,因为两组人数可能不同。所以分治思想不是万能的,它要求子问题结构相同、可独立求解,且合并方式正确。正确的做法是用分治算总分和总人数,最后再除。
  • 找围棋棋盘上最大的一块黑子区域:把棋盘分成四块,分别找每块中的最大区域,再考虑跨边界的情况——这就要用到更高级的分治了。

新手最容易犯的四个错误

  1. 忘记写递归终止条件
    比如上面代码中,if (left == right) 就是终止条件。如果漏掉,递归会无限循环下去,直到栈溢出(程序崩溃)。你可以试着删掉那两行,运行一下——会报错“Segmentation fault”或“Stack overflow”。

  2. 边界划分出错
    分界点 mid = (left + right) / 2 后,左半部分是 [left, mid],右半部分必须是 [mid+1, right]。有些人会写成 [left, mid-1][mid, right],导致数组元素丢失或重复计算。比如用 [left, mid-1] 就会漏掉 mid 位置的那个元素。

  3. 合并逻辑错误
    拿求最大值来说,合并时应该取 leftMaxrightMax 的最大值,但如果写成 leftMax + rightMax 就会得到和——那就全错了。分治的合并步骤一定要根据问题来,不能一概而论。

  4. 递归层数太深导致栈溢出
    如果数组非常大(比如100万个元素),递归会调用很多层(约log2(100万) ≈ 20层,其实还好)。但如果你写的是线性分割(比如每次只减1),递归层数就可能等于数组长度,那就容易爆栈。所以分治通常采用对半分割,让递归深度为O(log n),很安全。

完整示例:用分治求数组的和

学会了找最大值,我们换个例子:求所有数的和。代码非常类似,只是合并时改成加法。

#include <iostream>
using namespace std;

// 函数:计算数组 arr[left...right] 的和
int arrSum(int arr[], int left, int right) {
    if (left == right) {            // 只剩一个元素,直接返回它
        return arr[left];
    }
    int mid = (left + right) / 2;   // 从中间切开
    int leftSum = arrSum(arr, left, mid);       // 左半部分的和
    int rightSum = arrSum(arr, mid + 1, right); // 右半部分的和
    return leftSum + rightSum;      // 合并:两边相加
}

int main() {
    int arr[] = {3, 8, 1, 6, 9, 2, 7}; // 待求和数组
    int n = sizeof(arr) / sizeof(arr[0]);
    int total = arrSum(arr, 0, n - 1);
    cout << "数组所有元素之和是: " << total << endl;   // 输出 36
    return 0;
}

运行它会输出 36(3+8+1+6+9+2+7=36)。这个例子和求最大值几乎一样,只是合并时把“取较大”改成“相加”。说明分治算法的框架是可以复用的,你只需要改合并的逻辑,就能解决不同问题。

为什么学完分治,还要学其他的?

分治思想是很多经典算法的基础。比如:

  • 归并排序:把数组拆成两半,分别排序,然后合并成有序数组。
  • 快速排序:选一个基准值,把小于基准的放左边,大于的放右边,再分别递归。
  • 二分查找:在有序数组中找目标值,每次把范围缩小一半——这也是分治(但没有合并步骤)。
  • 棋盘覆盖问题最近点对问题等。

掌握了分治,你就拥有了“大事化小”的思维。遇到任何复杂问题,先想想能不能拆成更简单的小问题,解决后再合起来。这种能力不仅在编程中有用,在生活中也同样管用哦!


相关指引
如果你已经理解了分治的基本思想,可以继续学习:

试着用分治思想写一个“求数组中最大两个数的和”的程序吧!

例题精讲

1单选题

在分治算法中,将问题分解为子问题后,通常需要对子问题的解进行合并。以下哪个排序算法使用了分治策略?

A冒泡排序
B插入排序
C归并排序
D选择排序
2判断题

二分查找算法属于分治算法,因为它将查找范围每次缩小一半。

3填空题
以下是用分治思想求数组最大子段和的C++代码片段,请填空。\nint maxCrossingSum(int arr[], int l, int m, int h) {\n    int sum = 0, left_sum = INT_MIN;\n    for (int i = m; i >= l; i--) {\n        sum += arr[i];\n        if (sum > left_sum) left_sum = sum;\n    }\n    sum = 0; int right_sum = INT_MIN;\n    for (int i = m+1; i <= h; i++) {\n        sum += arr[i];\n        if (sum > right_sum) right_sum = sum;\n    }\n    return left_sum + right_sum;\n}\nint maxSubArraySum(int arr[], int l, int h) {\n    if (l == h) return arr[l];\n    int m = (l + h) / 2;\n    return max(___(1)___, max(___(2)___, ___(3)___));\n}
4单选题

在快速排序的分治过程中,partition函数返回一个索引,使得该索引左边的元素都小于等于pivot,右边的元素都大于等于pivot。以下关于快速排序的描述错误的是:

A快速排序的平均时间复杂度为O(n log n)
B快速排序的最坏情况发生在每次划分极不平衡时
C快速排序是稳定的排序算法
D快速排序通过递归对子数组进行排序
5判断题

分治算法总是将问题分解成两个规模相等的子问题。