13.经典 O(nlogn) 复杂度算法之快排

简介: 13.经典 O(nlogn) 复杂度算法之快排

经典 O(nlogn) 复杂度算法之快排


快速排序是由东尼·霍尔所发展的一种排序算法。在平均状况下,排序 n 个项目要 Ο(nlogn) 次比较。在最坏状况下则需要 Ο(n2) 次比较,但这种状况并不常见。事实上,快速排序通常明显比其他 Ο(nlogn) 算法更快,因为它的内部循环(inner loop)可以在大部分的架构上很有效率地被实现出来。

快速排序原理

快速排序使用分治法(Divide and conquer)策略来把一个串行(list)分为两个子串行(sub-lists)。

快速排序又是一种分而治之思想在排序算法上的典型应用。本质上来看,快速排序应该算是在冒泡排序基础上的递归分治法。

快速排序的名字起的是简单粗暴,因为一听到这个名字你就知道它存在的意义,就是快,而且效率高!它是处理大数据最快的排序算法之一了。虽然 Worst Case 的时间复杂度达到了 O(n²),但是人家就是优秀,在大多数情况下都比平均时间复杂度为 O(n logn) 的排序算法表现要更好,可是这是为什么呢,我也不知道。好在我的强迫症又犯了,查了 N 多资料终于在《算法艺术与信息学竞赛》上找到了满意的答案:

快速排序的最坏运行情况是 O(n²),比如说顺序数列的快排。但它的平摊期望时间是 O(nlogn),且 O(nlogn) 记号中隐含的常数因子很小,比复杂度稳定等于 O(nlogn) 的归并排序要小很多。所以,对绝大多数顺序性较弱的随机数列而言,快速排序总是优于归并排序。

算法步骤

  1. 从数列中挑出一个元素,称为 “基准”(pivot);
  2. 重新排序数列,所有元素比基准值小的摆放在基准前面,所有元素比基准值大的摆在基准的后面(相同的数可以到任一边)。在这个分区退出之后,该基准就处于数列的中间位置。这个称为分区(partition)操作;
  3. 递归地(recursive)把小于基准值元素的子数列和大于基准值元素的子数列排序;

遍历 p 到 r 之间的数据,将小于 pivot 的放在左边,大于 pivot 的放在右边,pivot 则在中间。数组 p 到 r 之间的数据就被分成了三个部分,前面 p 到 q-1 之间都是小于 pivot 的,中间是 pivot,后面的 q+1 到 r 之间是大于 pivot 的。

动图展示

快速排序

静态原理图

快速排序

根据分治、递归的处理思想,我们可以用递归排序下标从 p 到 q-1 之间的数据和下标从 q+1 到 r 之间的数据,直到区间缩小为 1,就说明所有的数据都有序了。

选择好基准元素以后,我们要做的就是把小于基准元素的交换到基准元素的左边,大于的交换到基准元素的右边。

从基准元素

递推公式与代码实现

公式如下所示:

递推公式:
quick_sort(p…r) = quick_sort(p…q-1) + quick_sort(q+1, r)
终止条件:
p >= r

转化成代码的大致思路如下伪代码,主要就是利用了递归技巧实现分区并对每个区排序,关键就是在于分区函数对数据的交换:

// 快速排序,A 是数组,n 表示数组的大小
quickSort(A, n) {
  quickSortC(A, 0, n-1)
}
// 快速排序递归函数,p,r 为下标
quickSortC(A, p, r) {
  if p >= r then return
  q = partition(A, p, r) // 获取分区点
  quickSortC(A, p, q-1)
  quickSortC(A, q+1, r)
}

归并排序中有一个 merge() 合并函数,我们这里有一个 partition() 分区函数。partition() 分区函数实际上我们前面已经讲过了,就是随机选择一个元素作为 pivot(一般情况下,可以选择 p 到 r 区间的第一个元素),然后对 A[p…r] 分区,函数返回 pivot 的下标。

因为分区的过程涉及交换操作,如果数组中有两个相同的元素,比如序列 6,8,7,6,3,5,9,4,在经过第一次分区操作之后,两个 6 的相对先后顺序就会改变。所以,快速排序并不是一个稳定的排序算法。

完整代码

package com.zero.algorithms.linear.sort;
import java.util.Arrays;
/**
 * 快速排序
 */
public class QuickSort implements ComparisonSort {
    @Override
    public int[] sort(int[] sourceArray) {
        // 对 arr 进行拷贝,不改变参数内容
        int[] arr = Arrays.copyOf(sourceArray, sourceArray.length);
        quickSortInternal(arr, 0, arr.length - 1);
        return arr;
    }
    /**
     * 快速排序
     * @param arr 数据
     * @param p 开始下标
     * @param r 结束下标
     * @return
     */
    private void quickSortInternal(int[] arr, int p, int r) {
        if (p >= r) {
            return;
        }
        // 获取分区点,也就是 pivot 下标
        int q = partition(arr, p, r);
        // 递归调用直到有序
        quickSortInternal(arr, p, q - 1);
        quickSortInternal(arr, q + 1, r);
    }
    /**
     * 分区函数:挖坑填洞,先把基准元素的坑挖出来,然后遍历坑后面的数组,比坑的位置小则交换数据
     * 。并标记最新的坑的位置
     * @param arr 待分区数据
     * @param p 开始下标
     * @param r 结束喜爱奥
     * @return
     */
    private int partition(int[] arr, int p, int r) {
        // 设置基准值,选择第一个,也可以随机,
        int pivot = arr[p];
        //从基准元素的下一个位置开始遍历数组,记录当前坑的位置,最后把 pivot 值放到这个坑中
        int index = p;
        for (int i = p + 1; i <= r; i++) {
            // 比基准数据小
            if (arr[i] < pivot) {
                // 坑位移动
                index++;
                // 交换数据 i 位置的 与 index 位置数据交换
                int indexValue = arr[index];
                arr[index] = arr[i];
                arr[i] = indexValue;
            }
        }
        // 最后把 pivot位置与 与 最后的坑 index 交换
        arr[p] = arr[index];
        arr[index] = pivot;
        return index;
    }
}

归并排序与快速排序对比

快排和归并用的都是分治思想,递推公式和递归代码也非常相似,那它们的区别在哪里呢?

image

可以发现,归并排序的处理过程是由下到上的,先处理子问题,然后再合并。而快排正好相反,它的处理过程是由上到下的,先分区,然后再处理子问题。归并排序虽然是稳定的、时间复杂度为 O(nlogn) 的排序算法,但是它是非原地排序算法。我们前面讲过,归并之所以是非原地排序算法,主要原因是合并函数无法在原地执行。快速排序通过设计巧妙的原地分区函数,可以实现原地排序,解决了归并排序占用太多内存的问题。

总结

归并排序和快速排序是两种稍微复杂的排序算法,它们用的都是分治的思想,代码都通过递归来实现,过程非常相似。理解归并排序的重点是理解递推公式和 merge() 合并函数。同理,理解快排的重点也是理解递推公式,还有 partition() 分区函数。

归并排序算法是一种在任何情况下时间复杂度都比较稳定的排序算法,这也使它存在致命的缺点,即归并排序不是原地排序算法,空间复杂度比较高,是 O(n)。正因为此,它也没有快排应用广泛。

快速排序算法虽然最坏情况下的时间复杂度是 O(n2),但是平均情况下时间复杂度都是 O(nlogn)。不仅如此,快速排序算法时间复杂度退化到 O(n2) 的概率非常小,我们可以通过合理地选择 pivot 来避免这种情况。

详细代码我提交在 GitHub,戳此即可查看。



码哥字节

相关文章
|
22天前
|
机器学习/深度学习 存储 算法
1 .算法的复杂度(超全)
1 .算法的复杂度(超全)
|
1月前
|
存储 算法 C语言
数据结构与算法②(复杂度相关OJ)(六道数组OJ题)(上)
数据结构与算法②(复杂度相关OJ)(六道数组OJ题)
38 2
|
19天前
|
算法 搜索推荐
数据结构和算法——表排序(算法概述、物理排序、复杂度分析,包含详细清晰图示过程)
数据结构和算法——表排序(算法概述、物理排序、复杂度分析,包含详细清晰图示过程)
13 0
|
19天前
|
存储 机器学习/深度学习 算法
数据结构和算法学习记录——空间复杂度的计算(冒泡排序、阶乘递归、斐波那契数列递归、常见复杂度对比、栈帧、栈溢出)
数据结构和算法学习记录——空间复杂度的计算(冒泡排序、阶乘递归、斐波那契数列递归、常见复杂度对比、栈帧、栈溢出)
9 0
|
1月前
|
存储 算法
数据结构与算法②(复杂度相关OJ)(六道数组OJ题)(下)
数据结构与算法②(复杂度相关OJ)(六道数组OJ题)
25 0
|
1月前
|
机器学习/深度学习 存储 算法
数据结构与算法①(第一章复杂度知识点)(大O渐进表示法)(下)
数据结构与算法①(第一章复杂度知识点)(大O渐进表示法)
21 0
|
1月前
|
存储 算法
数据结构与算法①(第一章复杂度知识点)(大O渐进表示法)(上)
数据结构与算法①(第一章复杂度知识点)(大O渐进表示法)
28 0
|
2天前
|
算法
基于GA遗传优化的混合发电系统优化配置算法matlab仿真
**摘要:** 该研究利用遗传算法(GA)对混合发电系统进行优化配置,旨在最小化风能、太阳能及电池储能的成本并提升系统性能。MATLAB 2022a用于实现这一算法。仿真结果展示了一系列图表,包括总成本随代数变化、最佳适应度随代数变化,以及不同数据的分布情况,如负荷、风速、太阳辐射、弃电、缺电和电池状态等。此外,代码示例展示了如何运用GA求解,并绘制了发电单元的功率输出和年变化。该系统原理基于GA的自然选择和遗传原理,通过染色体编码、初始种群生成、适应度函数、选择、交叉和变异操作来寻找最优容量配置,以平衡成本、效率和可靠性。
|
3天前
|
机器学习/深度学习 算法
基于鲸鱼优化的knn分类特征选择算法matlab仿真
**基于WOA的KNN特征选择算法摘要** 该研究提出了一种融合鲸鱼优化算法(WOA)与K近邻(KNN)分类器的特征选择方法,旨在提升KNN的分类精度。在MATLAB2022a中实现,WOA负责优化特征子集,通过模拟鲸鱼捕食行为的螺旋式和包围策略搜索最佳特征。KNN则用于评估特征子集的性能。算法流程包括WOA参数初始化、特征二进制编码、适应度函数定义(以分类准确率为基准)、WOA迭代搜索及最优解输出。该方法有效地结合了启发式搜索与机器学习,优化特征选择,提高分类性能。
|
3天前
|
机器学习/深度学习 算法 数据可视化
基于BP神经网络的64QAM解调算法matlab性能仿真
**算法预览图省略** MATLAB 2022A版中,运用BP神经网络进行64QAM解调。64QAM通过6比特映射至64复数符号,提高数据速率。BP网络作为非线性解调器,学习失真信号到比特的映射,对抗信道噪声和多径效应。网络在处理非线性失真和复杂情况时展现高适应性和鲁棒性。核心代码部分未显示。