上一篇总纲里我说,第四题的目标不是拿满分,而是拿稳分。今天我们从分治法开始,用两个最经典的案例,把"拆、治、合"这套路焊死在脑子里。
一、分治法就三句话,别把它想复杂
分治法(Divide and Conquer),软考下午题里出现频率最高的算法策略之一。它的核心就三步:
-
拆(Divide):把大问题拆成若干个规模更小的相同子问题;
-
治(Conquer):递归地解决这些子问题,如果子问题够小,直接求解;
-
合(Merge):把子问题的解合并成原问题的解。
在考场上,看到题目里有"分成两半"、"递归处理"、"合并结果"这类关键词,基本就可以往分治法上靠了。经典的应用场景包括:归并排序、快速排序、二分查找、最大子数组、汉诺塔等等。
下面直接上两个在软考里最常见的完整案例,代码都是 C 语言,跟真题风格保持一致。
二、案例一:归并排序(Merge Sort)
归并排序是分治法的教科书级案例,2019 年下午第四题就考过它的代码填空。它的思路特别清晰:
-
拆:把数组从中间劈成左右两半;
-
治:对左右两半分别递归调用归并排序;
-
合:把两个已经排好序的半合并成一个有序数组。
完整代码
#include <stdio.h>
#include <stdlib.h>
#define MAX 65536
/* 合并两个有序子数组 */
void merge(int arr[], int p, int q, int r) {
int n1 = q - p + 1; // 左半部分长度
int n2 = r - q; // 右半部分长度
int left[n1 + 1], right[n2 + 1];
int i, j, k;
/* 复制数据到临时数组 */
for (i = 0; i < n1; i++)
left[i] = arr[p + i];
for (j = 0; j < n2; j++)
right[j] = arr[q + 1 + j];
left[n1] = MAX; // 哨兵,简化循环判断
right[n2] = MAX;
i = 0; j = 0;
for (k = p; k <= r; k++) {
if (left[i] <= right[j]) {
arr[k] = left[i];
i++;
} else {
arr[k] = right[j];
j++;
}
}
}
/* 归并排序主函数 */
void mergeSort(int arr[], int begin, int end) {
int mid;
if (begin < end) {
mid = (begin + end) / 2; // 取中点,拆分
mergeSort(arr, begin, mid); // 递归排序左半
mergeSort(arr, mid + 1, end); // 递归排序右半
merge(arr, begin, mid, end); // 合并两个有序半
}
}
int main() {
int arr[] = {49, 38, 65, 97, 76, 13, 27};
int n = sizeof(arr) / sizeof(arr[0]);
mergeSort(arr, 0, n - 1);
for (int i = 0; i < n; i++)
printf("%d ", arr[i]);
return 0;
}
关键点解析
1. 递归终止条件 if (begin < end) 这行是递归的出口。当 begin == end 时,子数组只剩一个元素,天然有序,直接返回。
2. 合并时的哨兵 left[n1] = MAX 和 right[n2] = MAX 是哨兵值。因为 MAX 足够大,当某一侧的临时数组取完后,另一侧剩下的元素会自然地被全部复制回原数组,不需要额外写判断 i < n1 或 j < n2 的循环。当然真题里也可能用 while (i < n1 && j < n2) 的方式,两种写法都要能看懂。
3. 时间复杂度 归并排序的递归式是 T(n) = 2T(n/2) + n,根据主定理,时间复杂度为 O(n log n)。空间复杂度是 O(n),因为合并时需要临时数组。
三、案例二:快速排序(Quick Sort)
快速排序也是分治法,但它和归并排序的"拆法"不一样。归并排序是"无脑对半劈",快排是"按基准值划分":
-
拆:选一个基准值(pivot),把数组分成"左边都小于 pivot,右边都大于 pivot"的两部分;
-
治:递归地对左右两部分进行快速排序;
-
合:不需要显式合并,因为基准值归位后,左右各自有序,整个数组就有序了。
完整代码(挖坑法)
#include <stdio.h>
/* 划分函数:一趟排序后,pivot 到达最终位置 */
int partition(int arr[], int low, int high) {
int pivot = arr[low]; // 选第一个元素为基准
int i = low, j = high;
while (i < j) {
/* 从右向左找第一个小于 pivot 的 */
while (i < j && arr[j] >= pivot) j--;
if (i < j) arr[i++] = arr[j];
/* 从左向右找第一个大于 pivot 的 */
while (i < j && arr[i] <= pivot) i++;
if (i < j) arr[j--] = arr[i];
}
arr[i] = pivot; // pivot 归位
return i; // 返回基准最终位置
}
/* 快速排序主函数 */
void quickSort(int arr[], int low, int high) {
int pivotPos;
if (low < high) {
pivotPos = partition(arr, low, high); // 划分
quickSort(arr, low, pivotPos - 1); // 递归左半
quickSort(arr, pivotPos + 1, high); // 递归右半
}
}
int main() {
int arr[] = {49, 38, 65, 97, 76, 13, 27};
int n = sizeof(arr) / sizeof(arr[0]);
quickSort(arr, 0, n - 1);
for (int i = 0; i < n; i++)
printf("%d ", arr[i]);
return 0;
}
关键点解析
1. 划分是核心 快排的灵魂在 partition 函数。一趟划分结束后,基准值左边的都不大于它,右边的都不小于它。这个"基准归位"的过程,本质上就是在做"拆"。
2. 与归并排序的区别
-
归并排序:拆分简单(对半劈),合并复杂(要逐个比较合并);
-
快速排序:拆分复杂(要划分),合并简单(不需要合并)。
3. 复杂度
-
最好/平均时间复杂度:O(n log n);
-
最坏时间复杂度(数组已有序,每次选到最大/最小值做基准):O(n²);
-
空间复杂度:主要是递归栈,平均 O(log n),最坏 O(n)。
四、模拟题一(归并排序)
【说明】 采用归并排序对 n 个元素进行递增排序。首先将 n 个元素的数组分成各含 n/2 个元素的两个子数组,然后递归地对两个子数组进行排序,最后合并两个已排好序的子数组。
【C代码】
#include <stdio.h>
#include <stdlib.h>
#define MAX 65536
void merge(int arr[], int p, int q, int r) {
int n1 = q - p + 1;
int n2 = r - q;
int *left = (int*)malloc((n1 + 1) * sizeof(int));
int *right = (int*)malloc((n2 + 1) * sizeof(int));
int i, j, k;
for (i = 0; i < n1; i++)
left[i] = arr[p + i];
for (j = 0; j < n2; j++)
right[j] = arr[q + 1 + j];
left[n1] = MAX;
right[n2] = MAX;
i = 0; j = 0;
for (k = p; (1); k++) {
if (left[i] <= right[j]) {
arr[k] = left[i];
i++;
} else {
arr[k] = right[j];
j++;
}
}
free(left);
free(right);
}
void mergeSort(int arr[], int begin, int end) {
int mid;
if ((2)) {
mid = (begin + end) / 2;
mergeSort(arr, begin, mid);
(3);
merge(arr, begin, mid, end);
}
}
int main() {
int arr[] = {38, 27, 43, 3, 9, 82, 10};
int n = sizeof(arr) / sizeof(arr[0]);
mergeSort(arr, 0, n - 1);
for (int i = 0; i < n; i++)
printf("%d ", arr[i]);
return 0;
}
【问题1】(8分) 根据说明和 C 代码,补齐 (1)~(3) 处的空缺。
【问题2】(4分) 根据以上 C 代码,算法采用了(4)算法设计策略。分析时间复杂度时,列出其递归式为(5),解出渐进时间复杂度为(6)(用 O 符号表示)。
【问题3】(3分) 对于两个长度分别为 n1 和 n2 的已排好序的子数组进行归并,根据上述 C 代码,元素之间的比较次数最多为(7)。
五、模拟题二(快速排序)
【说明】 快速排序采用分治策略对数组进行排序。其基本思想是:选择一个基准元素,通过一趟排序将待排序列分为两部分,其中一部分元素均不大于基准元素,另一部分元素均不小于基准元素,然后递归地对这两部分继续进行排序。
【C代码】
#include <stdio.h>
int partition(int arr[], int low, int high) {
int pivot = arr[low];
int i = low, j = high;
while (i < j) {
while (i < j && arr[j] >= pivot) (1);
if (i < j) arr[i++] = arr[j];
while (i < j && arr[i] <= pivot) (2);
if (i < j) arr[j--] = arr[i];
}
(3) = pivot;
return i;
}
void quickSort(int arr[], int low, int high) {
int pivotPos;
if ((4)) {
pivotPos = partition(arr, low, high);
quickSort(arr, low, pivotPos - 1);
quickSort(arr, pivotPos + 1, high);
}
}
int main() {
int arr[] = {72, 6, 57, 88, 60, 42, 83, 73, 48, 85};
int n = sizeof(arr) / sizeof(arr[0]);
quickSort(arr, 0, n - 1);
for (int i = 0; i < n; i++)
printf("%d ", arr[i]);
return 0;
}
【问题1】(8分) 根据说明和 C 代码,补齐 (1)~(4) 处的空缺。
【问题2】(4分) 该算法采用了(5)算法设计策略。若每次划分都能将序列均匀地分为两部分,则其时间复杂度为(6)(用 O 符号表示);若待排序列已经有序,则时间复杂度退化为(7)(用 O 符号表示)。
【问题3】(3分) 对于初始序列 {72, 6, 57, 88, 60, 42, 83, 73, 48, 85},以第一个元素为基准,经过第一趟划分后,序列变为(8)。
六、参考答案
模拟题一
【问题1】
-
(1)
k <= r或k < r + 1 -
(2)
begin < end -
(3)
mergeSort(arr, mid + 1, end)
【问题2】
-
(4) 分治(或分治法 / Divide and Conquer)
-
(5) T(n) = 2T(n/2) + n
-
(6) O(n log n) 或 O(nlgn)
【问题3】
-
(7) n1 + n2(每次比较都会消耗一个元素,两个子数组共 n1+n2 个元素,哨兵不算)
模拟题二
【问题1】
-
(1)
j-- -
(2)
i++ -
(3)
arr[i] -
(4)
low < high
【问题2】
-
(5) 分治(或分治法 / Divide and Conquer)
-
(6) O(n log n) 或 O(nlgn)
-
(7) O(n²)
【问题3】
-
(8) {48, 6, 57, 42, 60, 72, 83, 73, 88, 85}
推演过程:以 72 为基准,从右往左找第一个小于 72 的数(48),放到左边;从左往右找第一个大于 72 的数(88),放到右边;i 和 j 相遇在 72 的位置,填入基准值。最终 72 左边都不大于 72,右边都不小于 72。
七、小结
分治法在软考第四题里,最常出现的就是归并排序和快速排序这两个排序算法。记住它们的核心区别:
| 特性 | 归并排序 | 快速排序 |
|---|---|---|
| 拆分方式 | 对半劈(mid = (begin+end)/2) | 按基准值划分(partition) |
| 合并方式 | 需要显式合并(merge) | 不需要显式合并 |
| 最好时间复杂度 | O(n log n) | O(n log n) |
| 最坏时间复杂度 | O(n log n) | O(n²) |
| 空间复杂度 | O(n) | O(log n) ~ O(n) |
| 稳定性 | 稳定 | 不稳定 |
另外,分治法的递归式 T(n) = 2T(n/2) + n 一定要会写,这是拿分的关键。考场上如果代码填空没把握,先把"分治策略"和复杂度这两分拿到手,不亏。
八、下一篇预告
【软考算法】软件设计师下午第四题之动态规划:0-1 背包与最长公共子序列的"填表艺术"
如果说分治法是"拆完再合",那动态规划就是"记住过往,避免重复"。下一篇我们聊聊 DP 的核心——状态转移方程到底在转移什么,以及怎么在考场上把那张表填对。

530

被折叠的 条评论
为什么被折叠?



