912. 排序数组 超级通俗易懂、全面的快速排序教程(优化重复元素、实例有序问题)

🎯 本文档详细介绍了快速排序算法及其优化方案,包括Lomuto分区与Hoare分区方法。快速排序是一种高效的排序算法,其核心在于通过选择一个"基准"来将数组划分为两个子数组,递归地对这些子数组进行排序。文档中不仅解释了这两种分区的基本思想和实现细节,还讨论了如何通过随机选择基准值来避免最坏情况的发生,并对比了它们的效率差异。此外,文中提到了JDK中采用的双轴快速排序及对小数组使用插入排序的优化策略,展示了高效排序算法在实际应用中的重要性。

快速排序

题目

leetcode.cn/problems/so...

Lomuto分区方案:传统快速排序

**基本思想:**选一个基准数,把小的扔到基准的左边,大的扔到基准的右边,递归重复这个过程。

  1. 选基准,可以选择数组的任意一个数,例如第一个数
  2. 分区操作
    1. 左指针向右找比基准大的
    2. 右指针向左找比基准小的
    3. 交换这两个数
    4. 重复步骤 a、b、c,直到左、右指针相遇
    5. 最后把基准和左右指针所在位置的元素交换
  3. 递归 对基准的左右两边的子数组重复上述操作。
java 复制代码
private void quickSort(int[] nums, int left, int right) {
    if (left >= right) return;
    // 基准
    int pivot = nums[left];
    int i = left, j = right;
    while (i < j) {
        // 找到比基准小的数字
        while (i < j && nums[j] >= pivot) {
            j--;
        }
        // 找到比基准大的数字
        while (i < j && nums[i] <= pivot) {
            i++;
        }
        // 交换元素
        if (i < j) swap(nums, i, j);
    }
    // 最后把基准和左右指针所在位置的元素交换
    swap(nums, left, i);
    // 对基准的左右两边重复上述操作
    quickSort(nums, left, i - 1);
    quickSort(nums, i + 1, right);
}

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

使用方式

java 复制代码
quickSort(nums, 0, nums.length - 1);

注意点

如果选择是第一个数为基准,需要先移动右指针,再移动左指针。

如果先移动左指针,再移动右指针会怎么样?

例如数组是[3, 1, 2, 4]

  • 基准为3
  • 先移动 left,找到比 3 大的数,即 4,此时 left=3
  • 此时 left==right,交换基准和left的位置,交换后为 [4, 1, 2, 3]

此时,基准左边的部分元素大于3,违反了规则。原因是如果先找到比基准大的元素,再和基准交换,就会出现基准左边的数比基准大的情况

如果先移动右指针,再移动左指针呢?

  • 基准为3
  • 先移动 right,找到比 3 小的数,即 2,此时 right=2
  • 移动 left,知道 left==right,交换基准和 left 的位置,交换后为 [2, 1, 3, 4]

满足基准左边的元素都小于基准,基准右边的元素都大于基准。先移动右指针能保证当左右指针相遇时,相遇点的元素一定 ≤ 基准

时间复杂度分析

  • 最好情况 O (n logn ):每次划分都能将数组均匀分成两部分(即分区点接近中位数)
  • 最坏情况 O(n²):每次划分极度不平衡(如分区点是最小或最大值,且输入已有序或逆序)

缺点

传统的快速排序是有明显缺点的

  1. 出现最坏时间复杂度,当输入数组已经有序或近乎有序时,每次选择的基准(pivot)都是最小或最大值,导致分区极度不平衡。此时时间复杂度退化为O(n²)
  1. 总是选择最左边的元素作为基准(pivot = nums[left]),在特定输入(如已排序数组)下性能较差。
  2. 重复元素处理:当数组中存在大量重复元素时,当前的分区方式可能导致不平衡的分区,因为nums[j] >= pivotnums[i] <= pivot会让重复元素分布在两侧,可能退化为O(n²)。
  1. 递归深度:最坏情况下递归深度为O(n),可能导致栈溢出。

Hoare分区方案:优化大量元素

Hoare分区方案的关键特点

  • 基准值选择:如选择数组的第一个元素作为基准值(pivot)
  • 双指针法:
    • 左指针i从数组起始位置向右移动
    • 右指针j从数组末尾向左移动
    • i找到≥pivot的元素,j找到≤pivot的元素时,交换它们
  • 终止条件:当两个指针相遇或交叉时停止
  • 分区结果:
    • 返回的j是分区点的位置
    • 分区后,左侧子数组所有元素≤pivot
    • 右侧子数组所有元素≥pivot
  • 递归排序:分区后对左右两个子数组分别递归调用快速排序

do-while循环确保ij至少移动一次,防止死循环

java 复制代码
class Solution {
    public int[] sortArray(int[] nums) {
        quickSort(nums, 0, nums.length - 1);
        return nums;
    }
    
    // 快速排序主函数
    private void quickSort(int[] nums, int low, int high) {
        if (low < high) {
            // 获取分区点
            int partitionIndex = hoarePartition(nums, low, high);
            // 递归排序左半部分
            quickSort(nums, low, partitionIndex);
            // 递归排序右半部分
            quickSort(nums, partitionIndex + 1, high);
        }
    }
    
    // Hoare分区方案实现
    private int hoarePartition(int[] nums, int low, int high) {
        // 选择第一个元素作为基准值(pivot)
        int pivot = nums[low];
        // 初始化左右指针
        int i = low - 1;
        int j = high + 1;
        
        while (true) {
            // 从左向右找第一个大于等于pivot的元素
            do {
                i++;
            } while (nums[i] < pivot);
            
            // 从右向左找第一个小于等于pivot的元素
            do {
                j--;
            } while (nums[j] > pivot);
            
            // 如果指针相遇或交叉,返回j作为分区点
            if (i >= j) {
                return j;
            }
            
            // 交换这两个元素
            swap(nums, i, j);
        }
    }
    
    // 交换数组中两个元素的位置
    private void swap(int[] nums, int i, int j) {
        int temp = nums[i];
        nums[i] = nums[j];
        nums[j] = temp;
    }
}

与 Lomuto 分区方案的比较

  • 效率更高:Hoare分区通常比Lomuto分区少做3倍的交换操作
  • 分区更均衡:在处理包含多个相同元素的数组时表现更好,如下图所示:Lomuto 分出来的右数组很大,左数组大小为0。Lomuto 分出来的左数组和右数组一样大
  • 分区点:分区后基准值的位置是j(不是i),且左右子数组是[low, j][j+1, high]

随机 pivot 优化

快速排序的性能高度依赖于 分区(partition) 的效果。如果每次分区都能将数组分成大致相等的两部分,那么递归深度就是 O(log n),总时间复杂度是 O(n log n)。但如果分区不平衡(比如每次只能分成一个元素和剩余部分),递归深度会变成 O(n),导致时间复杂度退化到 O(n²)。

Hoare分区可以优化大量重复元素的案例,但是对于已经升序或降序排序的案例,效率还是很低

最坏情况分析:固定选择第一个元素作为 pivot(如 pivot = nums[low]

如果输入数组已经有序(升序或降序),每次分区都会产生 极不平衡 的分区,效率极低,可能导致栈溢出

  • 左边子数组长度为 0(或 1),右边子数组长度为 n-1(或 n-2)。
  • 递归树退化为 链表,递归深度为 O(n),时间复杂度 O(n²)。
Shell 复制代码
[1, 2, 3, 4, 5] → pivot=1 → 左边[],右边[2,3,4,5]
[2, 3, 4, 5] → pivot=2 → 左边[],右边[3,4,5]
...

随机选择一个元素作为 pivot

  • 降低 最坏情况(已排序数组)出现的概率,即使输入是有序的,由于 pivot 随机选择,相当于输入无序,分区仍然较均衡。
java 复制代码
class Solution {
    Random random = new Random();
    public int[] sortArray(int[] nums) {
        quickSort(nums, 0, nums.length - 1);
        return nums;
    }
    
    // 快速排序主函数
    private void quickSort(int[] nums, int low, int high) {
        if (low < high) {
            // 获取分区点
            int partitionIndex = hoarePartition(nums, low, high);
            // 递归排序左半部分
            quickSort(nums, low, partitionIndex);
            // 递归排序右半部分
            quickSort(nums, partitionIndex + 1, high);
        }
    }
    
    // Hoare分区方案实现
    private int hoarePartition(int[] nums, int low, int high) {
        // 选择第一个元素作为基准值(pivot)
        int pivot = nums[low + random.nextInt(high - low + 1)];
        // 初始化左右指针
        int i = low - 1;
        int j = high + 1;
        
        while (true) {
            // 从左向右找第一个大于等于pivot的元素
            do {
                i++;
            } while (nums[i] < pivot);
            
            // 从右向左找第一个小于等于pivot的元素
            do {
                j--;
            } while (nums[j] > pivot);
            
            // 如果指针相遇或交叉,返回j作为分区点
            if (i >= j) {
                return j;
            }
            
            // 交换这两个元素
            swap(nums, i, j);
        }
    }
    
    // 交换数组中两个元素的位置
    private void swap(int[] nums, int i, int j) {
        int temp = nums[i];
        nums[i] = nums[j];
        nums[j] = temp;
    }
}

JDK 排序算法

如果用 JDK 默认排序,会发现它的排序效率极高

java 复制代码
class Solution {
    public int[] sortArray(int[] nums) {
        Arrays.sort(nums);
        return nums;
    }
}

JDK 也使用了快排,但是做了极致的优化,通过源码可知,JDK 使用了双轴快速排序,有兴趣的朋友可以去学习一下源码

除此之外,还针对小数组使用插入排序

相关推荐
还听珊瑚海吗3 小时前
基于SpringBoot的抽奖系统测试报告
java·spring boot·后端
你怎么知道我是队长6 小时前
Go语言标识符
后端·golang
朱剑君6 小时前
第四天——贪心算法——种花
算法·贪心算法
TextIn智能文档云平台6 小时前
PDF文档解析新突破:图表识别、公式还原、手写字体处理,让AI真正读懂复杂文档!
图像处理·人工智能·算法·自然语言处理·pdf·ocr
Panesle7 小时前
HunyuanCustom:文生视频框架论文速读
人工智能·算法·音视频·文生视频
hie988947 小时前
matlab稳定求解高精度二维对流扩散方程
算法·机器学习·matlab
买了一束花7 小时前
MATLAB导出和导入Excel文件表格数据并处理
人工智能·算法·matlab
纪元A梦8 小时前
贪心算法应用:顶点覆盖问题详解
java·算法·贪心算法
爱补鱼的猫猫9 小时前
22、近端策略优化算法(PPO)论文笔记
论文阅读·算法
开心星人9 小时前
【论文阅读】Reconstructive Neuron Pruning for Backdoor Defense
论文阅读·算法·剪枝