归并排序和堆排序都能保证O(nlogn),为什么谁也没法把稳定和原地两个优点占全

「算法与数据结构」系列 Day03

写在前面

上一篇讲快速排序,平均O(nlogn)听着很美,但最坏情况会退化成O(n²),还得靠随机化基准这种"打补丁"的方式去防。归并排序和堆排序不一样,不管输入长什么样,最坏情况都稳稳地卡在O(nlogn)------这才是真正意义上"靠谱"的O(nlogn)排序。但这两个"优等生"之间也不是对等的:一个要多花一倍空间才能换来稳定性,另一个能原地排却怎么都稳定不了。这篇就讲清楚这个取舍到底是怎么产生的。


一、是什么:一个靠合并,一个靠堆结构

归并排序还是分治的路子:把数组从中间切两半,各自递归排好序,再把两个已经有序的子数组合并成一个有序数组。它的关键动作全在"合并"这一步------两个指针分别指向两个子数组的开头,谁小就先取谁,取完一个指针后面的元素全部原样搬过去。

堆排序走的是完全不同的思路:先把整个数组原地构建成一个大顶堆(父节点永远比子节点大),这样堆顶(数组下标0的位置)就是当前最大值。把堆顶和数组末尾元素交换,堆顶的最大值就归位了,再把剩下的n-1个元素重新调整成大顶堆,重复这个"取堆顶+调整"的过程,数组就从后往前一个个确定下来。

堆排序能原地排的关键在于:大顶堆这种逻辑上的完全二叉树,可以直接映射到数组的下标关系上 ,不需要额外的指针结构------下标i的左子节点是2i+1,右子节点是2i+2,反过来,下标i的父节点是(i-1)/2

flowchart LR subgraph 数组下标视角的完全二叉树 A0[下标0] --> A1[下标1] A0 --> A2[下标2] A1 --> A3[下标3] A1 --> A4[下标4] A2 --> A5[下标5] end

二、为什么:稳定要靠合并规则,原地要靠交换,两者天然冲突

归并排序和堆排序都能保证最坏O(nlogn),是因为它们的递归/循环结构不依赖输入数据的原始顺序:归并排序不管数组长什么样,每次都严格二分,递归深度恒定是O(logn)层,每一层所有的合并操作加起来要遍历当前层的全部元素,也就是O(n),两者相乘恒为O(nlogn);堆排序建堆是O(n),之后n次"取堆顶+调整堆",每次调整的代价是树高O(logn),总共也是O(nlogn)。这一点和快速排序完全不同------快排的复杂度会因为分区不均衡而漂移,这两个不会。

但代价出现在别的维度上。归并排序的稳定性来自合并这一步的规则:两个子数组里遇到相等的元素,只要固定优先取左边(前面)子数组的那个,两个相等元素原本谁在前,合并后还是谁在前,顺序不会乱。可这个规则要求合并时必须把两个子数组的内容先复制出来再逐个比较写回,这就需要一个和原数组等长的临时数组,这也是归并排序空间复杂度是O(n)、不是原地排序的根本原因。

堆排序的原地 能力则来自它可以直接在原数组上通过下标运算完成堆的调整,不需要任何额外的数组。可它的核心动作"取堆顶后把末尾元素换上来"是一次跨越任意距离的交换------数组末尾的元素可能被直接换到最前面,一路跳过若干个和它相等的元素,这些相等元素原本的相对顺序就被打乱了。想保住稳定性,就得知道每个相等元素原始的先后关系并在交换时保留下来,但堆结构本身只维护"父节点比子节点大"这个偏序关系,不记录任何"相等元素谁先谁后"的信息,稳定性也就无从谈起。

说到底,两者的取舍不是巧合,而是同一个根源的两个方向:"稳定"需要额外空间去记录/保留原始顺序,"原地"需要靠直接交换来省空间,交换恰好又是破坏顺序的动作------鱼和熊掌,选一个就必须放弃另一个。


三、怎么用:代码实现 + 常见坑

归并排序:合并时<=<一字之差决定稳不稳定

java 复制代码
public static void mergeSort(int[] arr, int left, int right) {
    if (left < right) {
        int mid = (left + right) / 2;
        mergeSort(arr, left, mid);
        mergeSort(arr, mid + 1, right);
        merge(arr, left, mid, right);
    }
}

private static void merge(int[] arr, int left, int mid, int right) {
    int[] leftArr = Arrays.copyOfRange(arr, left, mid + 1);
    int[] rightArr = Arrays.copyOfRange(arr, mid + 1, right + 1);

    int i = 0, j = 0, k = left;
    while (i < leftArr.length && j < rightArr.length) {
        // 相等时优先取左边,这一条是稳定性的全部来源
        if (leftArr[i] <= rightArr[j]) {
            arr[k++] = leftArr[i++];
        } else {
            arr[k++] = rightArr[j++];
        }
    }
    while (i < leftArr.length) arr[k++] = leftArr[i++];
    while (j < rightArr.length) arr[k++] = rightArr[j++];
}

第一个坑就是把leftArr[i] <= rightArr[j]误写成leftArr[i] < rightArr[j]------两边相等时会先取右边子数组的元素,稳定性直接丢掉,这种bug编译器和大部分单测都发现不了,因为排序结果本身是对的,只是相等元素的相对顺序变了。

第二个坑是空间开销容易被低估 :虽然总的空间复杂度写作O(n),但朴素实现在每一层递归的每一次merge里都新建临时数组,递归树一共O(logn)层,某一时刻同时存活的临时数组总长度不会超过n,但频繁的小数组分配会带来不小的GC压力,工程实现通常会提前分配一个和原数组等长的辅助数组复用,而不是每次merge都new。

堆排序:子节点下标算错、建堆起点选错是两个高频坑

java 复制代码
public static void heapSort(int[] arr) {
    int n = arr.length;
    // 从最后一个非叶子节点开始建堆,而不是从下标0开始
    for (int i = n / 2 - 1; i >= 0; i--) {
        heapify(arr, n, i);
    }
    for (int i = n - 1; i > 0; i--) {
        swap(arr, 0, i);        // 堆顶(最大值)换到末尾,归位
        heapify(arr, i, 0);     // 堆大小减1,重新调整堆
    }
}

private static void heapify(int[] arr, int n, int i) {
    int largest = i;
    int left = 2 * i + 1;
    int right = 2 * i + 2;
    if (left < n && arr[left] > arr[largest]) largest = left;
    if (right < n && arr[right] > arr[largest]) largest = right;
    if (largest != i) {
        swap(arr, i, largest);
        heapify(arr, n, largest); // largest变了才需要继续往下调整
    }
}

private static void swap(int[] arr, int i, int j) {
    int temp = arr[i];
    arr[i] = arr[j];
    arr[j] = temp;
}

第一个坑是左右子节点的下标公式记反或算错 :左子节点是2*i+1,右子节点是2*i+2,很容易手滑写成2*i2*i+1,这样整棵树的父子关系全部错位,调整出来的根本不是堆。

第二个坑是建堆的起点选错 :必须从最后一个非叶子节点n/2 - 1开始倒着往前调用heapify,而不是从下标0开始正着调用------如果从0开始,调整下标0时它的子树可能还没被调整成堆,heapify的"子树已经是堆"这个前提就不成立了,调整结果是错的。

排序 最坏复杂度 空间复杂度 是否稳定 是否原地
归并排序 O(nlogn) O(n) 稳定
堆排序 O(nlogn) O(1) 不稳定

这个取舍在Java标准库里也能对上号:Arrays.sort(Object[])(以及Collections.sort)背后用的是TimSort,本质是归并排序的优化版,因为对象排序经常需要保证稳定(比如先按时间排序、再按类型排序,两次排序的结果不能互相破坏);而Arrays.sort(int[])这类基本类型数组排序用的是双轴快速排序,因为基本类型没有"相同值但是不同对象"的区别,稳定性无意义,快排更小的常数因子更划算。堆排序反而很少直接用来做通用排序,它真正的舞台是PriorityQueue------用堆结构维护"每次都能O(logn)取出当前最大/最小值",而不需要把整个集合排出全序。


四、面试追问

Q1:为什么归并排序和堆排序的最坏复杂度都能稳定在O(nlogn),而快速排序做不到?

因为两者的递归/循环结构都不依赖输入数据的原始顺序。归并排序不管输入长什么样都严格二分,递归深度恒为O(logn),每层合并遍历全部元素O(n);堆排序建堆是O(n),之后n次取堆顶加调整堆,每次都是树高O(logn)。而快速排序的复杂度取决于每次分区分得均不均匀,均衡程度又和基准选择、输入顺序绑定,遇到不利输入就会退化到O(n²)。

Q2:归并排序为什么天生稳定?

稳定性来自合并阶段的规则:合并两个有序子数组时,只要两边元素相等就固定优先取左(前)边子数组的那个写进结果数组,两个相等元素原本谁在前,合并后依然谁在前。只要这条"相等取左"的规则被严格遵守,稳定性就有保证。

Q3:堆排序为什么不稳定?

堆排序的核心动作是把堆顶元素和数组末尾元素交换,这是一次跨越任意距离的直接交换,可能让一个元素跳过若干个和它相等的元素被换到另一端,原有的相对顺序就被打乱了。堆结构本身只维护"父节点比子节点大"的偏序关系,不记录相等元素的原始先后顺序,所以没法保证稳定。

Q4:建堆的时间复杂度为什么是O(n),而不是直觉上的O(nlogn)?

建堆调用了大约n/2次heapify,每次heapify最坏是O(logn),直觉上容易以为总复杂度是O(nlogn)。但严格计算会发现:完全二叉树里越靠下层的节点数量越多,但这些节点到叶子的下沉高度越小;越靠近根的节点虽然下沉高度大,但数量很少。把每层的节点数乘以该层的下沉高度逐层相加,是一个收敛的等比数列,总和是O(n),比直觉的O(nlogn)更紧。


下一篇预告

Day04 非比较排序:计数排序 / 桶排序 / 基数排序为什么能突破O(nlogn)下界。

相关推荐
天天喝旺仔1 小时前
Go 泛型实战:从类型参数、约束到可复用泛型容器与函数
数据结构·算法·容器·go
可爱的小小小狼1 小时前
【无标题】
java·算法
和裕2 小时前
平口开槽箱 vs 飞机盒 vs 扣底盒:自动化、展示效果与成本核心区别
大数据·运维·网络·人工智能·算法·自动化
AgentMaster2 小时前
从售前到售后全链路覆盖:智能客服在企业 5 大场景的落地实践与工具选型
大数据·人工智能·算法
Cccp.1232 小时前
【leetcode】(六) 图和贪心算法
数据结构·算法·leetcode
y1su2 小时前
【Leetcode】1477. 找两个和为目标值且不重叠的子数组
数据结构·后端·算法·leetcode·职场和发展
不会就选b10 小时前
算法日常・每日刷题--<贪心>14
算法
mmmmath_313 小时前
LeetCode.541.反转字符串II
数据结构·算法·leetcode
Navigator_Z13 小时前
LeetCode //MySQL - 1251. Average Selling Price
c语言·算法·leetcode