【软考算法】软件设计师下午第四题之分治法:归并、快排与“拆、治、合“

上一篇总纲里我说,第四题的目标不是拿满分,而是拿稳分。今天我们从分治法开始,用两个最经典的案例,把"拆、治、合"这套路焊死在脑子里。

一、分治法就三句话,别把它想复杂

分治法(Divide and Conquer),软考下午题里出现频率最高的算法策略之一。它的核心就三步:

  1. 拆(Divide):把大问题拆成若干个规模更小的相同子问题;

  2. 治(Conquer):递归地解决这些子问题,如果子问题够小,直接求解;

  3. 合(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] = MAXright[n2] = MAX 是哨兵值。因为 MAX 足够大,当某一侧的临时数组取完后,另一侧剩下的元素会自然地被全部复制回原数组,不需要额外写判断 i < n1j < 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 <= rk < 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 的核心——状态转移方程到底在转移什么,以及怎么在考场上把那张表填对。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值