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

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

快速排序

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

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

  1. 选基准,可以选择数组的任意一个数,例如第一个数
  2. 分区操作
    1. 左指针向右找比基准大的
    2. 右指针向左找比基准小的
    3. 交换这两个数
    4. 重复步骤 a、b、c,直到左、右指针相遇
    5. 最后把基准和左右指针所在位置的元素交换
  3. 递归 对基准的左右两边的子数组重复上述操作。

alt

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;
}

使用方式

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

缺点

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

  1. 出现最坏时间复杂度,当输入数组已经有序或近乎有序时,每次选择的基准(pivot)都是最小或最大值,导致分区极度不平衡。此时时间复杂度退化为O(n²)

alt

  1. 总是选择最左边的元素作为基准(pivot = nums[left]),在特定输入(如已排序数组)下性能较差。
  2. 重复元素处理:当数组中存在大量重复元素时,当前的分区方式可能导致不平衡的分区,因为nums[j] >= pivotnums[i] <= pivot会让重复元素分布在两侧,可能退化为O(n²)。

alt

  1. 递归深度:最坏情况下递归深度为O(n),可能导致栈溢出。

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

Hoare分区方案的关键特点

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

alt

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

class Solution {

    public int[] sortArray(int[] nums) {
        quickSort(nums, 0, nums.length - 1);
        return nums;
    }

    private void quickSort(int[] nums, int left, int right) {
        if (left >= right) return;
        int pivot = nums[left];
        // 初始化左右指针
        int i = left - 1, j = right + 1;
        while (true) {
            // 从左向右找第一个大于等于pivot的元素
            do {
                i++;
            } while (i < right && nums[i] < pivot);
            // 从右向左找第一个小于等于pivot的元素
            do {
                j--;
            } while (j > left && nums[j] > pivot);
            // 如果指针相遇或交叉,退出循环
            if (i >= j) {
                break;
            }
            swap(nums, i, j);
        }
        // 递归处理左、右分区
        quickSort(nums, left, j);
        quickSort(nums, j + 1, right);
    }

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

}

alt

与 Lomuto 分区方案的比较

  • 效率更高:Hoare分区通常比Lomuto分区少做3倍的交换操作
  • 分区更均衡:在处理包含多个相同元素的数组时表现更好,如下图所示:Lomuto 分出来的右数组很大,左数组大小为0。Lomuto 分出来的左数组和右数组一样大

alt

  • 分区点:分区后基准值的位置是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²)。
[1, 2, 3, 4, 5] → pivot=1 → 左边[],右边[2,3,4,5]
[2, 3, 4, 5] → pivot=2 → 左边[],右边[3,4,5]
...

随机选择一个元素作为 pivot

  • 降低 最坏情况(已排序数组)出现的概率,即使输入是有序的,由于 pivot 随机选择,相当于输入无序,分区仍然较均衡。
class Solution {
    Random r = new Random();

    public int[] sortArray(int[] nums) {
        quickSort(nums, 0, nums.length - 1);
        return nums;
    }

    private void quickSort(int[] nums, int left, int right) {
        if (left >= right) return;
        // 随机位置获取一个pivot
        int pivot = nums[left + r.nextInt(right - left)];
        // 初始化左右指针
        int i = left - 1, j = right + 1;
        while (true) {
            // 从左向右找第一个大于等于pivot的元素
            do {
                i++;
            } while (i < right && nums[i] < pivot);
            // 从右向左找第一个小于等于pivot的元素
            do {
                j--;
            } while (j > left && nums[j] > pivot);
            // 如果指针相遇或交叉,退出循环
            if (i >= j) {
                break;
            }
            swap(nums, i, j);
        }
        // 递归处理左、右分区
        quickSort(nums, left, j);
        quickSort(nums, j + 1, right);
    }

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

}

alt

JDK 排序算法

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

class Solution {
    public int[] sortArray(int[] nums) {
        Arrays.sort(nums);
        return nums;
    }
}

alt

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

alt

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

alt

#算法##笔试##排序算法##快速排序#
全部评论

相关推荐

点赞 评论 收藏
分享
评论
1
收藏
分享

创作者周榜

更多
牛客网
牛客企业服务