分治算法——大问题拆成小问题
中等2分治算法:把大问题拆成小问题,轻松搞定!
你有没有遇到过这样的难题——桌上一堆乱糟糟的乐高零件,要拼出一个大城堡,不知道从哪下手?聪明的方法是:先按颜色分成几堆,每堆拼一个小部分,最后再把小部分组合起来。这就是分治(Divide and Conquer)的核心思想:“分而治之”。在编程里,分治算法能把一个复杂的大问题,拆成若干个结构相同的小问题,分别解决后,再把小问题的答案合并起来,得到最终结果。
比如,你想知道全班有多少人。老师可以先把班级分成4个小组,每组数完人数,再把4个数字加起来,就是全班总人数。分治就是这种“先分、再算、最后合”的套路。
分治的三步走
所有分治算法都遵循三个步骤:
- 分解(Divide):把原问题拆成若干个规模较小的、与原问题形式相同的子问题。
- 解决(Conquer):递归地解决每个子问题。如果子问题小到可以直接算出答案(比如只剩一个数),就不再递归。
- 合并(Combine):把子问题的结果组合成原问题的答案。
这三个步骤就像收拾书包:
- 分解:先把书、作业本、文具分别拿出来。
- 解决:把书按大小叠好,作业本按科目放好,文具装进笔袋。
- 合并:最后把整理好的所有东西放回书包。
例子1:用分治法求数组最大值(入门必看)
虽然用循环求最大值更简单,但分治的框架能让你看清“分解—解决—合并”的过程。下面这段代码就是典型的分治写法:
#include <iostream>
#include <vector>
using namespace std;
// 求数组arr在区间[left, right]内的最大值
int findMax(const vector<int>& arr, int left, int right) {
if (left == right) { // 只剩一个数,直接返回
return arr[left];
}
int mid = left + (right - left) / 2; // 找到中间位置
int leftMax = findMax(arr, left, mid); // 递归求左半部分最大值
int rightMax = findMax(arr, mid + 1, right);// 递归求右半部分最大值
return max(leftMax, rightMax); // 合并:取左右中较大的
}
int main() {
vector<int> arr = {3, 7, 2, 9, 5}; // 待求数组
int maxVal = findMax(arr, 0, arr.size() - 1);
cout << "最大值 = " << maxVal << endl; // 输出 9
return 0;
}
代码解释:
left和right是当前要检查的数组区间下标。- 当
left == right时,区间里只有一个数,这个数就是最大值(递归的“终止条件”)。 - 否则,把区间从中间切开,分别处理左边和右边。
mid = left + (right - left) / 2可以避免 (left+right) 可能出现的溢出问题,是推荐的写法。 - 左右两边的最大值都算出来后,用
max()选出更大的那个作为整个区间的最大值。
递归过程(以数组 {3,7,2,9,5} 为例):
findMax(0,4)
├─ findMax(0,2)
│ ├─ findMax(0,1)
│ │ ├─ findMax(0,0) → 3
│ │ └─ findMax(1,1) → 7
│ │ → max(3,7) = 7
│ └─ findMax(2,2) → 2
│ → max(7,2) = 7
└─ findMax(3,4)
├─ findMax(3,3) → 9
└─ findMax(4,4) → 5
→ max(9,5) = 9
→ max(7,9) = 9
最终得到最大值9。
例子2:归并排序——分治的明星应用
归并排序是分治算法最经典的案例。它的思想很简单:要把一个无序数组排好序,先把它切成两半,分别排序,再把两个有序的子数组合并成一个整体有序的数组。
生活中的类比:老师手里有两堆按身高排好的队伍(左队和右队),现在要把它们合并成一支整体按身高排好的队伍。做法是:每次从两队的最前面各拉一个人出来,比较身高,矮的先进入新队伍,直到两队都完成。
下面是用C++实现的归并排序完整代码:
#include <iostream>
#include <vector>
using namespace std;
// 合并两个有序区间 [left, mid] 和 [mid+1, right]
void merge(vector<int>& arr, int left, int mid, int right) {
vector<int> temp(right - left + 1); // 临时数组,用于存放合并结果
int i = left, j = mid + 1, k = 0; // i指向左半部分开头,j指向右半部分开头
// 把两个区间中较小的元素依次放入temp
while (i <= mid && j <= right) {
if (arr[i] <= arr[j]) {
temp[k++] = arr[i++];
} else {
temp[k++] = arr[j++];
}
}
// 处理左半部分剩余的元素
while (i <= mid) {
temp[k++] = arr[i++];
}
// 处理右半部分剩余的元素
while (j <= right) {
temp[k++] = arr[j++];
}
// 把临时数组中的结果拷回原数组
for (int p = 0; p < k; ++p) {
arr[left + p] = temp[p];
}
}
// 归并排序:对数组arr的区间[left, right]进行排序
void mergeSort(vector<int>& arr, int left, int right) {
if (left >= right) { // 区间为空或只有一个元素,已经有序
return;
}
int mid = left + (right - left) / 2; // 分解:找到中间位置
mergeSort(arr, left, mid); // 解决:递归排序左半部分
mergeSort(arr, mid + 1, right); // 解决:递归排序右半部分
merge(arr, left, mid, right); // 合并:把两个有序子数组合并
}
int main() {
vector<int> arr = {38, 27, 43, 3, 9, 82, 10};
mergeSort(arr, 0, arr.size() - 1);
cout << "排序结果:";
for (int num : arr) {
cout << num << " ";
}
cout << endl;
return 0;
}
合并过程详解(以两个有序子数组 {27,38,43} 和 {3,9,82} 为例):
- 比较
27和3:3小 → 放入temp - 比较
27和9:9小 → 放入temp - 比较
27和82:27小 → 放入temp - 比较
38和82:38小 → 放入temp - 比较
43和82:43小 → 放入temp - 右半部分只剩
82,直接放入temp 得到{3,9,27,38,43,82}。
例子3:最大子段和——分治也能做
你在操场上有一排同学的零花钱数额(有正有负),要选出一段连续的同学,使他们的零花钱之和最大,这就是最大子段和问题。分治可以这样处理:
- 分解:把数组从中点切成左右两半。
- 解决:递归求出左半部分的最大子段和、右半部分的最大子段和。
- 合并:最大子段可能还有一种情况——跨越了中点。这时需要从中点向左右两侧扩展,分别找到中点左边能取到的最大后缀和、中点右边能取到的最大前缀和,相加就是跨越中点的最大子段和。最后取三者最大值。
虽然这个问题也有更快的动态规划解法,但分治思路同样清晰,且能延伸到“平面最近点对”等更复杂的题目。
新手常犯的四个错误
-
忘记写递归终止条件
如果没有if (left == right) return ...,递归会一直分下去,直到数组越界或栈溢出。哪怕只有一层也要检查。 -
切分点mid计算溢出
老式写法(left + right) / 2当 left 和 right 都很大时可能整数溢出。用left + (right - left) / 2更安全。 -
合并时索引搞混乱
比如归并排序中,temp数组的下标和原数组的对应关系容易写错。建议用临时数组从0开始存,拷回时用arr[left + p] = temp[p]。 -
假设子问题一定能合并
有些分治问题(比如快速排序)不需要显式合并,但必须保证子问题的解能组合成原问题的解。如果合并方法不对,结果就是错的。
完整可运行示例(归并排序)
上面已经给出了归并排序的完整代码。你可以直接复制到编译器里运行,看看数组 {38,27,43,3,9,82,10} 排序后的结果。如果想测试更多数据,可以把 arr 替换成自己喜欢的数字。
相关指引
学完分治的基本框架,你还可以去了解:
- 快速排序:也是分治思想,但它不需要额外数组,原地排序,更高效。
- 二分查找:最简单的分治——每次把查找范围砍掉一半。
- 棋盘覆盖:用L型骨牌覆盖棋盘的经典分治问题。
- 平面最近点对:像我们开头提到的,分治配合排序能高效找出平面上距离最近的两个点。
- CDQ分治:一种高级分治技巧,常用于处理三维偏序等问题。
分治算法的魅力在于:它把复杂问题层层简化,直到变成一眼能看出的答案。多练几道题,你就能体会到“大事化小,小事化了”的编程智慧。试着用分治去解决“求数组逆序对”或“最大子段和”吧,它们会是你提高算法能力的绝佳伙伴!
例题精讲
归并排序利用分治思想将数组划分为左右子数组分别排序再合并。其时间复杂度的递推式为T(n)=2T(n/2)+O(n),该时间复杂度为?
分治算法必须将原问题划分成两个相同规模的子问题,才能保证效率。
以下代码实现归并排序中的合并操作,将两个有序区间[L,mid]和[mid+1,R]合并到全局数组tmp后再拷回原数组。请在横线处填入正确代码。
void merge(int a[], int L, int mid, int R) {
int i = L, j = mid+1, k = L;
while (i <= mid && j <= R) {
if (a[i] <= a[j]) tmp[k++] = a[i++];
else tmp[k++] = a[j++];
}
while (i <= mid) tmp[k++] = a[i++];
while (j <= R) tmp[k++] = a[j++];
for (int p = L; p <= R; p++) ___
}
在有序数组中查找某元素,二分查找每次将问题规模减半。对一个长度为n的有序数组,最坏情况下需要比较的次数是?
棋盘覆盖问题:给定一个2^k×2^k大小的棋盘,有一个特殊方格,需要用L型骨牌(3个方格组成)覆盖所有棋盘,分治算法将棋盘分成四个2^(k-1)×2^(k-1)的子棋盘,其中包含特殊格的子棋盘继续递归,其余三个子棋盘在中心位置放置一个L型骨牌作为新特殊格。以下为递归函数的部分实现,请填空。
void chessBoard(int tr, int tc, int dr, int dc, int size) {
if (size == 1) return;
int t = tile++; // L型骨牌编号
int s = size/2;
// 覆盖左上角子棋盘
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;
___
}
}