归并排序与基数排序:二路归并排序(递归与非递归实现)、基数排序(LSD与MSD)

排序算法这块,前面我们聊了不少基于比较的排序。今天要讲的这两个,路子不太一样。归并排序是分治思想的典型代表,基数排序则另辟蹊径,用“分配”代替“比较”。我个人觉得,理解这两种排序,能帮你打开排序的另一扇窗。

一、二路归并排序:分而治之的典范

归并排序的核心思想,说白了就八个字:先拆后合,两两合并

你想想看,一个无序数组,怎么排?归并排序的做法是:先把数组从中间一分为二,左边排好,右边排好,然后合并两个有序数组。那左边怎么排?继续拆。一直拆到每个子数组只有一个元素——单个元素天然有序。然后再一层层合并回去。

嗯,这里要注意:合并两个有序数组,需要借助一个临时数组。这是归并排序空间复杂度的来源。

1. 递归实现:最直观的写法

递归版本最容易理解,代码也最简洁。我刚开始学归并排序时,就是从这个版本入手的。

// 合并两个有序区间 [left, mid] 和 [mid+1, right]
void merge(int arr[], int left, int mid, int right, int temp[]) {
    int i = left, j = mid + 1, k = 0;

    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 (i = 0; i < k; i++)
        arr[left + i] = temp[i];
}

// 递归版归并排序
void mergeSortRecursive(int arr[], int left, int right, int temp[]) {
    if (left >= right) return;

    int mid = left + (right - left) / 2;
    mergeSortRecursive(arr, left, mid, temp);
    mergeSortRecursive(arr, mid + 1, right, temp);
    merge(arr, left, mid, right, temp);
}

这段代码的逻辑很清晰:先拆,再合。递归深度是 log₂n,每一层合并的总代价是 O(n),所以总时间复杂度 O(n log n)。

小技巧:mid 的计算用 left + (right - left) / 2 而不是 (left + right) / 2,可以避免 left+right 溢出。虽然现在 32 位 int 很少溢出,但养成好习惯总没错。

2. 非递归实现:迭代版归并

递归版本虽然好理解,但函数调用有开销,而且递归深度太深可能栈溢出。非递归版本用迭代代替递归,性能更好。

非递归的思路是:从长度为 1 的子数组开始合并,然后长度翻倍,再合并,直到整个数组有序。

void mergeSortIterative(int arr[], int n) {
    int *temp = (int *)malloc(n * sizeof(int));
    if (!temp) return;

    // size 表示当前子数组的长度,从 1 开始,每次翻倍
    for (int size = 1; size < n; size *= 2) {
        // 每次合并两个长度为 size 的子数组
        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);
}

这段代码里,外层循环控制子数组长度,内层循环遍历数组,两两合并。注意边界处理:最后一个子数组可能长度不足 size,要单独处理。

避坑指南:我曾经在非递归实现中犯过一个错误——内层循环的步长写成了 left += size,结果合并时出现了重叠。正确的步长应该是 left += 2 * size,因为每次合并两个子数组,跨度是 2 倍 size。

二、基数排序:用分配代替比较

基数排序的思路很有意思——它不比较元素大小,而是根据元素的“位”来分配。比如排序整数,先按个位分桶,再按十位分桶,最后按百位分桶。分完就排好了。

基数排序有两种实现方式:LSD(Least Significant Digit,从低位开始)和 MSD(Most Significant Digit,从高位开始)。

1. LSD 基数排序:从低位到高位

LSD 是最常用的版本。它从最低位开始,逐位进行“分配-收集”操作。每次分配时,把元素放到对应数字的桶里,然后按桶的顺序收集回来。

// 获取数字的第 digit 位(从 0 开始,0 表示个位)
int getDigit(int num, int digit) {
    for (int i = 0; i < digit; i++)
        num /= 10;
    return num % 10;
}

// LSD 基数排序
void radixSortLSD(int arr[], int n) {
    // 先找到最大值,确定位数
    int maxVal = arr[0];
    for (int i = 1; i < n; i++)
        if (arr[i] > maxVal) maxVal = arr[i];

    int maxDigit = 0;
    while (maxVal > 0) {
        maxVal /= 10;
        maxDigit++;
    }

    int *temp = (int *)malloc(n * sizeof(int));
    int count[10] = {0};

    for (int d = 0; d < maxDigit; d++) {
        // 统计每个桶的元素个数
        memset(count, 0, sizeof(count));
        for (int i = 0; i < n; i++) {
            int digit = getDigit(arr[i], d);
            count[digit]++;
        }

        // 计算每个桶的起始位置(前缀和)
        for (int i = 1; i < 10; i++)
            count[i] += count[i - 1];

        // 从后往前遍历,保证稳定性
        for (int i = n - 1; i >= 0; i--) {
            int digit = getDigit(arr[i], d);
            temp[--count[digit]] = arr[i];
        }

        // 拷贝回原数组
        for (int i = 0; i < n; i++)
            arr[i] = temp[i];
    }

    free(temp);
}

这段代码里,我用了计数排序的思想来实现每个位的排序。注意从后往前遍历——这是为了保证稳定性。基数排序的稳定性很重要,因为后续位的排序不能破坏前面位的顺序。

核心要点:LSD 基数排序的时间复杂度是 O(d × (n + k)),其中 d 是位数,k 是基数(这里是 10)。当 d 较小且 n 较大时,效率很高。我在项目中处理过几十万条手机号排序,用基数排序比快速排序快不少。

2. MSD 基数排序:从高位到低位

MSD 从最高位开始分桶。它的特点是:高位分完后,每个桶内的元素在高位上已经有序,然后递归地对每个桶按次高位排序。

MSD 的实现比 LSD 复杂一些,因为需要递归处理每个桶。而且 MSD 通常需要配合插入排序来优化——当桶内元素较少时,直接插入排序比继续递归更高效。

// MSD 基数排序的递归辅助函数
void msdRadixSort(int arr[], int left, int right, int digit) {
    if (left >= right || digit < 0) return;

    int count[10] = {0};
    int *temp = (int *)malloc((right - left + 1) * sizeof(int));

    // 统计当前位各数字的出现次数
    for (int i = left; i <= right; i++) {
        int d = getDigit(arr[i], digit);
        count[d]++;
    }

    // 计算每个桶的起始位置
    int start[10] = {0};
    for (int i = 1; i < 10; i++)
        start[i] = start[i - 1] + count[i - 1];

    // 分配元素到临时数组
    int pos[10];
    memcpy(pos, start, sizeof(pos));
    for (int i = left; i <= right; i++) {
        int d = getDigit(arr[i], digit);
        temp[pos[d]++] = arr[i];
    }

    // 拷贝回原数组
    for (int i = left; i <= right; i++)
        arr[i] = temp[i - left];

    free(temp);

    // 递归处理每个桶
    for (int i = 0; i < 10; i++) {
        int l = left + start[i];
        int r = left + start[i] + count[i] - 1;
        if (count[i] > 1)
            msdRadixSort(arr, l, r, digit - 1);
    }
}

void radixSortMSD(int arr[], int n) {
    // 找最大值确定位数
    int maxVal = arr[0];
    for (int i = 1; i < n; i++)
        if (arr[i] > maxVal) maxVal = arr[i];

    int maxDigit = 0;
    while (maxVal > 0) {
        maxVal /= 10;
        maxDigit++;
    }

    msdRadixSort(arr, 0, n - 1, maxDigit - 1);
}

MSD 的好处是:如果高位分布不均匀,某些桶可能很小,可以提前结束递归。但它的递归开销比 LSD 大,而且需要额外的空间来记录每个桶的边界。

三、两种排序的对比与选择

特性 归并排序 基数排序
时间复杂度 O(n log n) O(d × (n + k))
空间复杂度 O(n) O(n + k)
稳定性 稳定 稳定
适用场景 通用排序,数据量较大 整数或定长字符串,位数较少
是否基于比较

我个人在实际项目中的选择经验是:如果数据是整数且范围不大(比如 0~10⁶),基数排序往往更快。但如果数据是浮点数或自定义结构体,归并排序更通用。

四、知识体系总览

下面这张图总结了本章的核心知识点和它们之间的关系:

归并排序与基数排序知识体系 归并排序 递归实现 非递归实现 分治思想 迭代合并 基数排序 LSD(低位优先) MSD(高位优先) 逐位分配-收集 递归分桶 共同点:都是稳定排序,时间复杂度 O(n log n) 或更优 区别:归并基于比较,基数基于分配;归并通用,基数适合整数

这张图把两种排序的核心分支和特点都列出来了。你可以看到,归并排序的两个实现方式本质相同,只是写法不同;基数排序的 LSD 和 MSD 则代表了两种不同的处理顺序。

总结一下:归并排序和基数排序都是稳定的排序算法,时间复杂度都能达到 O(n log n) 甚至更好。归并排序胜在通用性,任何可比较的数据都能排;基数排序胜在效率,但只适用于整数或定长字符串。实际开发中,我建议你根据数据类型和规模来选择——没有银弹,只有最合适的工具。

专注资料整理