算法筑基(六):分治算法------大事化小,小事化了
📖 前言
分治算法(Divide and Conquer)是一种非常经典的算法设计思想。它的核心很简单:将一个难以直接解决的大问题,分解成若干个规模较小的相同问题,递归求解,最后合并得到原问题的解。
这种思想在计算机科学中无处不在:归并排序、快速排序、二分查找、大整数乘法、最近点对......都依赖分治。分治不仅让问题变得可解,常常还能将指数级的复杂度降到多项式级。
本文将从分治的基本框架开始,深入讲解大整数乘法(Karatsuba算法)和最近点对问题 这两个经典的分治案例,每个案例都配有完整的C语言代码 、逐行注释 以及实际应用场景 。同时也会回顾之前已经学过的归并排序、快速排序和二分查找,从分治的视角重新审视它们。最后附上课后练习及答案,巩固所学知识。
📌 本文目录
- 分治思想的核心框架
- 分治经典回顾
- 归并排序
- 快速排序
- 二分查找
- 大整数乘法(Karatsuba算法)
- 最近点对问题
- 分治算法对比总结
- 课后练习与答案
1. 分治思想的核心框架
分治算法的三步曲:
- 分解(Divide):将原问题分解为若干个规模较小的相同问题。
- 解决(Conquer):递归地求解每个子问题。如果子问题足够小,直接求解。
- 合并(Combine):将子问题的解合并成原问题的解。
下面我们通过几个经典例子来体会分治的魅力。
2. 分治经典回顾
2.1 归并排序(Merge Sort)
核心:将数组分成两半,分别排序,再合并。
c
void mergeSort(int arr[], int l, int r) {
if (l < r) {
int m = l + (r - l) / 2; // 分解
mergeSort(arr, l, m); // 解决左半
mergeSort(arr, m + 1, r); // 解决右半
merge(arr, l, m, r); // 合并
}
}
复杂度:O(n log n) 稳定,空间 O(n)。
2.2 快速排序(Quick Sort)
核心:选取基准,将数组划分为左右两部分,递归排序。
c
void quickSort(int arr[], int low, int high) {
if (low < high) {
int pi = partition(arr, low, high); // 分解(划分)
quickSort(arr, low, pi - 1); // 解决左半
quickSort(arr, pi + 1, high); // 解决右半
}
}
复杂度:平均 O(n log n),最坏 O(n²),空间 O(log n)。
2.3 二分查找(Binary Search)
核心:在有序序列中,每次取中间元素比较,缩小一半范围。
c
int binarySearch(int arr[], int l, int r, int target) {
if (l > r) return -1;
int mid = l + (r - l) / 2;
if (arr[mid] == target) return mid;
if (arr[mid] < target)
return binarySearch(arr, mid + 1, r, target);
else
return binarySearch(arr, l, mid - 1, target);
}
复杂度:O(log n)。
以上三个经典算法我们已在前几篇文章中详细实现过,这里不再赘述。接下来重点介绍两个更"分治味"浓厚的算法。
3. 大整数乘法(Karatsuba算法)
3.1 问题背景
两个 n 位数相乘,普通乘法需要 O(n²) 次乘法。Karatsuba 算法通过分治将复杂度降至 O(n^log₂3) ≈ O(n¹·⁵⁸)。
3.2 算法思想
假设有两个 n 位数 x 和 y,将 x 和 y 拆分成高低两部分(n 为偶数时):
x = a * 10^{n/2} + b
y = c * 10^{n/2} + d
则
x * y = ac * 10^n + (ad + bc) * 10^{n/2} + bd
直接计算需要四次乘法(ac, ad, bc, bd)。Karatsuba 观察到:
ad + bc = (a+b)(c+d) - ac - bd
这样只需要三次乘法:ac, bd, (a+b)(c+d)。
3.3 C语言实现(使用字符串表示大整数)
c
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
// 辅助函数:去掉前导零
char* trimLeadingZeros(char* s) {
while (*s == '0' && *(s+1) != '\0') s++;
return s;
}
// 辅助函数:两个字符串数字相加,返回新字符串
char* addStrings(char* a, char* b) {
int lenA = strlen(a), lenB = strlen(b);
int maxLen = lenA > lenB ? lenA : lenB;
char* result = (char*)malloc(maxLen + 2);
int carry = 0, i = lenA - 1, j = lenB - 1, k = maxLen;
result[maxLen + 1] = '\0';
while (i >= 0 || j >= 0 || carry) {
int sum = carry;
if (i >= 0) sum += a[i--] - '0';
if (j >= 0) sum += b[j--] - '0';
result[k--] = (sum % 10) + '0';
carry = sum / 10;
}
if (k >= 0) {
result[k] = '0' + carry;
return trimLeadingZeros(result);
}
return trimLeadingZeros(result + 1);
}
// 辅助函数:两个字符串数字相减(假设a >= b)
char* subStrings(char* a, char* b) {
int lenA = strlen(a), lenB = strlen(b);
int maxLen = lenA > lenB ? lenA : lenB;
char* result = (char*)malloc(maxLen + 2);
int borrow = 0, i = lenA - 1, j = lenB - 1, k = maxLen;
result[maxLen + 1] = '\0';
while (i >= 0 || j >= 0) {
int diff = (i >= 0 ? a[i--] - '0' : 0) - borrow;
if (j >= 0) diff -= b[j--] - '0';
if (diff < 0) {
diff += 10;
borrow = 1;
} else {
borrow = 0;
}
result[k--] = diff + '0';
}
return trimLeadingZeros(result + (k + 1));
}
// 辅助函数:字符串数字乘以10的n次方(末尾加零)
char* multiplyByPowerOf10(char* s, int n) {
int len = strlen(s);
char* result = (char*)malloc(len + n + 1);
strcpy(result, s);
for (int i = 0; i < n; i++) {
result[len + i] = '0';
}
result[len + n] = '\0';
return result;
}
// Karatsuba 乘法
char* karatsuba(char* x, char* y) {
// 去除前导零
x = trimLeadingZeros(x);
y = trimLeadingZeros(y);
int lenX = strlen(x);
int lenY = strlen(y);
// 如果任意一个数为0,返回"0"
if ((lenX == 1 && x[0] == '0') || (lenY == 1 && y[0] == '0')) {
char* zero = (char*)malloc(2);
zero[0] = '0'; zero[1] = '\0';
return zero;
}
// 递归基:如果两个数都很小,直接计算
if (lenX <= 4 && lenY <= 4) {
int a = atoi(x);
int b = atoi(y);
int res = a * b;
char* result = (char*)malloc(20);
sprintf(result, "%d", res);
return result;
}
// 取最大长度,使两个数长度一致(前面补零)
int maxLen = lenX > lenY ? lenX : lenY;
if (maxLen % 2 != 0) maxLen++;
char* xPadded = (char*)calloc(maxLen + 1, 1);
char* yPadded = (char*)calloc(maxLen + 1, 1);
int padX = maxLen - lenX;
int padY = maxLen - lenY;
memset(xPadded, '0', padX);
strcpy(xPadded + padX, x);
memset(yPadded, '0', padY);
strcpy(yPadded + padY, y);
int half = maxLen / 2;
// 分割为 high 和 low
char* a = (char*)malloc(half + 1);
char* b = (char*)malloc(half + 1);
char* c = (char*)malloc(half + 1);
char* d = (char*)malloc(half + 1);
strncpy(a, xPadded, half); a[half] = '\0';
strncpy(b, xPadded + half, half); b[half] = '\0';
strncpy(c, yPadded, half); c[half] = '\0';
strncpy(d, yPadded + half, half); d[half] = '\0';
// 递归计算三个乘积
char* ac = karatsuba(a, c);
char* bd = karatsuba(b, d);
// 计算 a+b 和 c+d
char* a_plus_b = addStrings(a, b);
char* c_plus_d = addStrings(c, d);
char* sum_ab_cd = karatsuba(a_plus_b, c_plus_d);
// 计算 (a+b)(c+d) - ac - bd
char* sub1 = subStrings(sum_ab_cd, ac);
char* ad_bc = subStrings(sub1, bd);
// 合并结果: ac * 10^maxLen + (ad+bc) * 10^{half} + bd
char* part1 = multiplyByPowerOf10(ac, maxLen);
char* part2 = multiplyByPowerOf10(ad_bc, half);
char* temp = addStrings(part1, part2);
char* result = addStrings(temp, bd);
// 释放内存
free(a); free(b); free(c); free(d);
free(ac); free(bd); free(a_plus_b); free(c_plus_d);
free(sum_ab_cd); free(sub1); free(ad_bc);
free(part1); free(part2); free(temp);
free(xPadded); free(yPadded);
return result;
}
int main() {
char a[] = "123456789";
char b[] = "987654321";
char* product = karatsuba(a, b);
printf("%s * %s = %s\n", a, b, product);
free(product);
return 0;
}
3.4 案例:高精度计算
Karatsuba 算法常用于大数库(如 Python 的 int 乘法),在加密算法、科学计算中都有应用。上面的代码虽然简化,但展示了分治乘法的核心思路。
3.5 复杂度分析
- 传统乘法:O(n²)
- Karatsuba:O(n^log₂3) ≈ O(n¹·⁵⁸)
4. 最近点对问题
4.1 问题描述
给定平面上 n 个点,找出欧几里得距离最近的一对点。
4.2 分治策略
- 分解:按 x 坐标排序,分成左右两半,递归求左右半内的最近点对距离 d₁ 和 d₂。
- 合并:考虑跨越中线的点对。只有在中线左右距离 d = min(d₁, d₂) 范围内的点才可能产生更小距离。在带状区域内按 y 坐标排序,对每个点检查其后最多 7 个点即可。
- 基例:点数很少时直接枚举。
4.3 C语言实现
c
#include <stdio.h>
#include <stdlib.h>
#include <math.h>
typedef struct {
double x, y;
} Point;
// 按 x 排序
int cmpX(const void* a, const void* b) {
Point* p1 = (Point*)a;
Point* p2 = (Point*)b;
return (p1->x - p2->x) > 0 ? 1 : -1;
}
// 按 y 排序
int cmpY(const void* a, const void* b) {
Point* p1 = (Point*)a;
Point* p2 = (Point*)b;
return (p1->y - p2->y) > 0 ? 1 : -1;
}
// 计算两点距离
double dist(Point a, Point b) {
double dx = a.x - b.x;
double dy = a.y - b.y;
return sqrt(dx*dx + dy*dy);
}
// 暴力求解小规模
double bruteForce(Point points[], int n) {
double min = 1e20;
for (int i = 0; i < n; i++) {
for (int j = i+1; j < n; j++) {
double d = dist(points[i], points[j]);
if (d < min) min = d;
}
}
return min;
}
// 在带状区域中寻找最小距离
double stripClosest(Point strip[], int size, double d) {
double min = d;
// 按 y 排序
qsort(strip, size, sizeof(Point), cmpY);
for (int i = 0; i < size; i++) {
for (int j = i+1; j < size && (strip[j].y - strip[i].y) < min; j++) {
double d2 = dist(strip[i], strip[j]);
if (d2 < min) min = d2;
}
}
return min;
}
// 分治主函数
double closestUtil(Point points[], int left, int right) {
if (right - left + 1 <= 3) {
return bruteForce(points + left, right - left + 1);
}
int mid = left + (right - left) / 2;
Point midPoint = points[mid];
double dl = closestUtil(points, left, mid);
double dr = closestUtil(points, mid + 1, right);
double d = dl < dr ? dl : dr;
// 收集带状区域内的点
Point* strip = (Point*)malloc((right - left + 1) * sizeof(Point));
int stripSize = 0;
for (int i = left; i <= right; i++) {
if (fabs(points[i].x - midPoint.x) < d) {
strip[stripSize++] = points[i];
}
}
double dMin = stripClosest(strip, stripSize, d);
free(strip);
return dMin;
}
double closestPair(Point points[], int n) {
qsort(points, n, sizeof(Point), cmpX);
return closestUtil(points, 0, n-1);
}
int main() {
Point points[] = {{2, 3}, {12, 30}, {40, 50}, {5, 1}, {12, 10}, {3, 4}};
int n = sizeof(points) / sizeof(points[0]);
double minDist = closestPair(points, n);
printf("最近点对距离: %lf\n", minDist);
return 0;
}
4.4 案例:地理信息系统
在 GIS 中,需要快速找到距离最近的两个兴趣点(如加油站),分治法能高效处理数万个点。
4.5 复杂度分析
- 时间复杂度:O(n log n)(主要来自排序和递归)。
- 空间复杂度:O(n)。
📊 分治算法对比
| 算法 | 分解方式 | 合并复杂度 | 总复杂度 |
|---|---|---|---|
| 归并排序 | 按位置二分 | O(n) | O(n log n) |
| 快速排序 | 按基准划分 | O(n) | 平均 O(n log n) |
| 二分查找 | 按大小二分 | O(1) | O(log n) |
| Karatsuba | 按数位二分 | O(n) | O(n^1.585) |
| 最近点对 | 按坐标二分 | O(n log n) | O(n log n) |
🎯 如何设计分治算法?
- 找到合适的分解方式:尽量使子问题规模均衡。
- 递归基:当问题足够小时,直接求解。
- 合并策略:这是分治的难点,需要巧妙设计。
✍️ 课后练习与答案
练习题目
- 实现一个非递归版本的归并排序。
- 尝试用 Karatsuba 算法计算 9999 × 9999 的精确结果(字符串形式)。
- 在最近点对问题中,证明带状区域中每个点只需检查最多 7 个点。
- 实现一个分治算法求解"最大子数组和"(例如股票最大收益)。
参考答案
1. 非递归归并排序(迭代版)
c
void mergeSortIterative(int arr[], int n) {
int* temp = (int*)malloc(n * sizeof(int));
for (int size = 1; size < n; size *= 2) {
for (int left = 0; left < n - size; left += 2 * size) {
int mid = left + size - 1;
int right = (left + 2 * size - 1 < n - 1) ? left + 2 * size - 1 : n - 1;
merge(arr, left, mid, right, temp);
}
}
free(temp);
}
2. Karatsuba 计算 9999×9999
直接调用 karatsuba("9999", "9999"),结果应为 "99980001"。
3. 带状区域检查点数证明
在最近点对问题中,对任意点 p,在其右侧 d×2d 的矩形内最多有 6 个点(因为若超过 6 个,则必有两点的 y 坐标差小于 d,从而距离小于 d)。实际上,每个点只需检查其后最多 7 个点即可保证找到最小距离。
4. 最大子数组和(分治法)
c
#include <stdio.h>
#include <limits.h>
int maxCrossingSum(int arr[], int l, int m, int r) {
int sum = 0, leftSum = INT_MIN, rightSum = INT_MIN;
for (int i = m; i >= l; i--) {
sum += arr[i];
if (sum > leftSum) leftSum = sum;
}
sum = 0;
for (int i = m+1; i <= r; i++) {
sum += arr[i];
if (sum > rightSum) rightSum = sum;
}
return leftSum + rightSum;
}
int maxSubArraySum(int arr[], int l, int r) {
if (l == r) return arr[l];
int m = l + (r - l) / 2;
int leftSum = maxSubArraySum(arr, l, m);
int rightSum = maxSubArraySum(arr, m+1, r);
int crossSum = maxCrossingSum(arr, l, m, r);
if (leftSum >= rightSum && leftSum >= crossSum) return leftSum;
else if (rightSum >= leftSum && rightSum >= crossSum) return rightSum;
else return crossSum;
}
int main() {
int arr[] = {-2, 1, -3, 4, -1, 2, 1, -5, 4};
int n = sizeof(arr)/sizeof(arr[0]);
int maxSum = maxSubArraySum(arr, 0, n-1);
printf("最大子数组和: %d\n", maxSum);
return 0;
}
🌟 寄语
分治思想是人类解决复杂问题的本能------遇到难题,先拆成小块,逐个击破,再拼起来。这种思维方式不仅在算法中重要,在生活和工作中同样适用。
本篇文章我们重新温习了归并、快排、二分,又深入学习了 Karatsuba 乘法和最近点对。这些都是分治思想的杰出代表。希望你能够举一反三,遇到新问题时先想想能否用分治来解决。
下一篇文章我们将进入字符串算法,学习 KMP、Trie 树等文本处理利器。敬请期待!
如果你在阅读过程中有任何疑问,欢迎评论区留言。
如果觉得这篇文章对你有帮助,点个赞👍,支持一下!