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 使用了双轴快速排序,有兴趣的朋友可以去学习一下源码

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

相关推荐
千金裘换酒7 小时前
LeetCode 移动零元素 快慢指针
算法·leetcode·职场和发展
wm10438 小时前
机器学习第二讲 KNN算法
人工智能·算法·机器学习
NAGNIP8 小时前
一文搞懂机器学习线性代数基础知识!
算法
NAGNIP8 小时前
机器学习入门概述一览
算法
奋进的芋圆8 小时前
DataSyncManager 详解与 Spring Boot 迁移指南
java·spring boot·后端
iuu_star8 小时前
C语言数据结构-顺序查找、折半查找
c语言·数据结构·算法
Yzzz-F8 小时前
P1558 色板游戏 [线段树 + 二进制状态压缩 + 懒标记区间重置]
算法
计算机程序设计小李同学8 小时前
个人数据管理系统
java·vue.js·spring boot·后端·web安全
漫随流水9 小时前
leetcode算法(515.在每个树行中找最大值)
数据结构·算法·leetcode·二叉树
Echo娴9 小时前
Spring的开发步骤
java·后端·spring