【有营养的算法笔记】基础算法 —— 快速排序思路梳理和常见错误拔毛

简介: 【有营养的算法笔记】基础算法 —— 快速排序思路梳理和常见错误拔毛

一、思路


快速排序,简称快排,是一个常用的算法。


但是对于快排来说,边界问题是比较难处理的,所以写快排时,背出算法模板,可以帮助我们快速的解决问题。通过板子我们也不需要处理很繁琐的bug。


今天的模板不仅简洁,并且可以完美的解决边界问题。


接下来说一下 快排的主要思想


快排的思想为 分治 ,说白了就是递归,按照区间,通过递归的方式将序列排成有序。


我们将快排的步骤分为三步:

c4154febb59980ab7ed9fc5feac71b8a.png

确定分界点:左边界点 q[l] 或 右边界点 q[r] 或 中间点 q[l + r >> 1],其中任意一个位置的值为 key

调整区间:小于等于 key 的值在左边,大于等于 key 的值在右边


递归处理左右区间,主要方法为使用双指针法分别在左边找不符合条件的值和右边不符合条件的值,然后对它们进行交换,递归处理从而使序列有序。




二、模板讲解


通用模板:

void quick_sort(int q[], int l, int r)
{
    if (l >= r) return;
    int key = q[l + r >> 1], i = l - 1, j = r + 1;
    while (i < j)
    {
        while (q[++i] < key) ;
        while (q[--j] > key) ;
        if (i < j) swap(q[i], q[j]);
    }
    quick_sort(q, l, j);
    quick_sort(q, j + 1, r);
}


时间复杂度:O(N * logN) 最差O(N^2) 空间复杂度:O(N)


看完板子,我们提出几个问题:


   快排递归的截止条件是什么?


   key 为什么取中间值,这样可以规避什么问题?


   为什么 i 和 j 初始化的值在 l - 1 和 r + 1,如果不这么初始化会有什么问题?


   处理左右区间的主体思路是什么?为什么要 ++i,不这样写有什么问题,能不能这么写:while (q[i] < key) i++;?


   如果取的分界点不同时,quick_sort 处理的区间分别可以是什么?一些区间划分为什么不对?

我们接下来带着这些问题来剖析这个板子:


   问题1:快排递归的截止条件是什么?


当递归处理区间,截止条件那么就是无需递归,常见情况就是只有一个数,所以理论上是 l == r 就可以截止,但是板子中为了更加严谨,写成了 l >= r也是完全没有问题的。


   问题2:key 为什么取中间值,这样可以规避什么问题?


我们设想一下如果 key 取左边界点 q[l],那么当 序列的数全部相同 或 有序 的时候,那么时间复杂度就退化到了 O(N^2) ,当进行排序时,就可能超时。


比如:1 2 3 4 5 6 7 8 9

                  1    2    3    4    5    6    7    8    9
                     key
                l                       r
                        对于这种情况,每一次 key 都会取在左边界点,第一次处理左右边界的时候,就会不断的++i,--j,交换值
                        对于当前情况就是一直在 --j
                        那么最后循环停止后,递归处理左右区间时,也是相同情况
                        就相当于把所有情况都走了一遍,这时 时间复杂度为 O(N^2)
                        当数据量足够大时,就会超时,我们简单画一下图,就拿这个序列来说


5611efb89e1c9edce1bd68dd8d4eb6f4.png



所以当我们 边界点取中 时就可以尽可能规避掉这个问题。


   问题3:为什么 i 和 j 初始化的值在 l - 1 和 r + 1,如果不这么初始化会有什么问题?


初始化 l - 1 和 r + 1 就是让 i 和 j 在序列 最左边的前一个位置 和 序列最右边的后一个位置 。


设想一下,如果初始化为 i 和 j 再套用这个模板,会出现什么情况?第一个数必定会错过。就算加以改进,可能还会有很多潜在的问题。


另外初始化为 l 和 r 的某个弊端在下个问题就会提及。


   问题4:处理左右区间的主体思路是什么?为什么要 ++i,不这样写有什么问题,能不能这么写:while (q[i] < key) i++;?


处理左右区间的主题思路就是 双指针 。


i 用来找 >= x 的值,遇到就停止;j 用来找 <= x 的值,遇到就停止;然后用 swap 库函数交换它们的值,就这么处理达到在每一层函数中左右区间都有序。


那么我们为什么要 ++i ,能不能写成这样?


i = l, j = r;
while (i < j)
{
    while (q[i] < key) i++; // 1
    while (q[j] > key) j--; // 2
    if (i < j) swap(q[i], q[j]);
}


这种情况是可能会导致死循环的,比如序列:3 1 3 6 3


   l = 0,q = 4

   key = q[l + r >> 1] = 3

   i = 0, j = 4

   q[i] = 3, q[j] = 3, key = 3


那么在循环处就会卡死,1处走不了,跳到2,但是2也走不了,也无法交换,总的循环条件又满足,所以就会造成 死循环 。这样写是 典型的错误 。


为了规避这个问题,和让 i 和 j 落到相应的位置,于是就有了我们板子里的方案:

i = l - 1, j = r + 1;
while (i < j)
{
    while (q[++i] < key) ;
    while (q[--j] > key) ;
    if (i < j) swap(q[i], q[j]);
}



另外其实 do...while 循环其实更好理解,就是先让 i 和 j 走一步。所以这种模板也可以:

i = l - 1, j = r + 1;
while (i < j)
{
    do i++; while (q[i] < key) ;
    do j--; while (q[j] > key) ;
    if (i < j) swap(q[i], q[j]);
}



   问题5:如果取的分界点不同时,quick_sort 处理的区间分别可以是什么?一些区间划分为什么不对?

常见情况 :


   当 分界点为右边界点 q[r] 或 中间点 q[l + r + 1 >> 1] 时,区间为 [l ~ i - 1] 和 [i ~ r]。


   当 分界点为左边界点 q[l] 或 中间点 q[l + r >> 1] 时,区间为 [l ~ j] 和 [j + 1 ~ r]。


上面两种为常见的区间的划分,由于博主能力有限,就不深入证明了。下面就举一个简单的例子,说明为什么要这么划分:


我们拿 情况1 举例:


假设我们当前分界点为 中界点 q[l + r >> 1] ,序列为:1 2

   l = 0, r = 1

   l + r >> 1 = 0 key = q[l + r >> 1] = 0



第一次快排
  1 2
i  key     j


while (q[++i] < key) ; i 往后走一步,不满足循环条件,i 在 0 下标处停住;

while (q[--j] > key) ; j 往前走一步,满足循环条件,继续循环;

while (q[--j] > key) ; j 往前走一步,不满足循环条件,j 在 0 下标处停住;


此刻下标位置情况

1 2
   key     
  ij



i == j 不进行 swap 交换,接下来就要开始划分区间递归;


接下来再看区间的划分:


   quick_sort(q, l, i - 1); 划分区间为 [0 ~ -1] 区间不存在,这边递归不用进行;


   quick_sort(q, i, r); 划分区间为 [0 ~ 1],又是第一次快排开始时的区间,这就是 死递归 ,如果测试会 内存超限 ,就是典型的 Memory Limit Exceeded 错误。


所以我们要加以改进 key = q[l + r + 1 >> 1] ,让其进行 上取整 ,防止下取整到错误情况。


(其他边界情况如果大家有兴趣的话可以自己下去证明一下,博主比较菜…就不献丑了,目前只要背住我们当前这个板子,对大多数情况都是没有问题的)




三、模板测试


我们用一道OJ测试我们的模板是否正确:


描述:


给定你一个长度为 n 的整数数列。


请你使用快速排序对这个数列按照从小到大进行排序。


并将排好序的数列按顺序输出。


输入格式:


输入共两行,第一行包含整数 n 。


第二行包含 n 个整数(所有整数均在 1∼10^9 范围内),表示整个数列。


输出格式:


输出共一行,包含 nn 个整数,表示排好序的数列。


数据范围:


1≤ n ≤ 100000


输入样例

5
3 1 2 4 5


输出样例

1 2 3 4 5


代码

0528785f494f56a1d67c8e19b1d5aba7.png



AC了,那么说明是没有问题的




四、加练 —— 第 K 个数


描述


给定一个长度为 n 的整数数列,以及一个整数 k,请用 快速选择算法 求出数列从小到大排序后的第 k 个数。


输入格式:

第一行包含两个整数 n 和 k。


第二行包含 n 个整数(所有整数均在 1∼10^9 范围内),表示整数数列。


输出格式:


输出一个整数,表示数列的第 k 小数。


数据范围:


   1 ≤ n ≤ 100000

   1 ≤ k ≤ n


输入样例

5 3
2 4 1 5 3

输出样例

3




思路:


对于 快速选择算法 ,其实就是 快排的一个变形 。


对于 快速选择算法 我们这里主要分三步:


   确定分界点:左边界点 q[l] 或 右边界点 q[r] 或 中间点 q[l + r >> 1],其中任意一个位置的值为 key

   调整区间:小于等于 key 的值在左边,大于等于 key 的值在右边

   根据左右区间元素个数,确定 k 所在区间,对单个区间进行递归


d311f6bc4a15f1bcf86204cc50355ba9.png


这里分情况讨论:


   k ≤ sl,则递归 左区间 ,由于 k 在 左半区间 ,所以依然是找的 第 k 个数

   k > sl,则递归 右区间 ,由于 k 在 右半区间 ,所以找的是 第 k - sl 个数


那么到这里,我们也可以计算出它的时间复杂度:由于我们只需要一边递归,那么我们的总执行次数大约为:n + n/2 + n/4.... < 2n ,时间复杂度为 O(N)。

接下来我们看一下代码怎么写:


#include <iostream>
using namespace std;
const int N = 100010;
int q[N], n, k;
int quick_sort(int l, int r, int k)
{
    if (l >= r)
        return q[l];
    int key = q[l + r >> 1], i = l - 1, j = r + 1;
    while (i < j)
    {
        while (q[++i] < key) ;
        while (q[--j] > key) ;
        if (i < j)
            swap(q[i], q[j]);
    }
    int sl = j - l + 1; // 左半区间的元素个数
    if (k <= sl)
        return quick_sort(l, j, k); // 递归左半区间
    else
        return quick_sort(j + 1, r, k - sl); // 递归右半区间
}
int main()
{
    cin >> n >> k;
    for (int i = 0; i < n; i++)
    {
        cin >> q[i];
    }
    cout << quick_sort(0, n - 1, k) << endl;
    return 0;
}



这里递归到最后会只剩下一个数的,所以结果是一定会找到的,不用担心找不到,不太理解的话可以画一下图。


   这里还有一个更加容易理解的版本,我比较推荐:


我们求 第 k 个数,其实就是求 k - 1 下标的值。


那么的主体思路的第三步就可以改为:


每次判断 k 是在 左半区间 还是 右半区间 ,递归查找 k 所在区间,递归到最后只剩一个数时,q[k]就是答案。


#include <iostream>
using namespace std;
const int N = 100010;
int q[N], n, k;
int quick_sort(int l, int r, int k)
{
    if (l >= r)
        return q[k];
    int key = q[l + r >> 1], i = l - 1, j = r + 1;
    while (i < j)
    {
        while (q[++i] < key) ;
        while (q[--j] > key) ;
        if (i < j)
            swap(q[i], q[j]);
    }
    if (k <= j) // k 在左半区间
        return quick_sort(l, j, k);
    else
        return quick_sort(j + 1, r, k);
}
int main()
{
    cin >> n >> k;
    for (int i = 0; i < n; i++)
    {
        cin >> q[i];
    }
    // k - 1 就是 第 k 个数 的下标
    cout << quick_sort(0, n - 1, k - 1) << endl;
    return 0;
}







相关文章
|
1月前
|
搜索推荐 算法 Java
现有一个接口DataOperation定义了排序方法sort(int[])和查找方法search(int[],int),已知类QuickSort的quickSort(int[])方法实现了快速排序算法
该博客文章通过UML类图和Java源码示例,展示了如何使用适配器模式将QuickSort类和BinarySearch类的排序和查找功能适配到DataOperation接口中,实现算法的解耦和复用。
17 1
现有一个接口DataOperation定义了排序方法sort(int[])和查找方法search(int[],int),已知类QuickSort的quickSort(int[])方法实现了快速排序算法
|
1月前
|
算法 搜索推荐
算法设计 (分治法应用实验报告)基于分治法的合并排序、快速排序、最近对问题
这篇文章是关于分治法应用的实验报告,详细介绍了如何利用分治法实现合并排序和快速排序算法,并探讨了使用分治法解决二维平面上的最近对问题的方法,包括伪代码、源代码实现及时间效率分析,并附有运行结果和小结。
|
2月前
|
算法 搜索推荐 编译器
算法高手养成记:Python快速排序的深度优化与实战案例分析
【7月更文挑战第11天】快速排序是编程基础,以O(n log n)时间复杂度和原址排序著称。其核心是“分而治之”,通过选择基准元素分割数组并递归排序两部分。优化包括:选择中位数作基准、尾递归优化、小数组用简单排序。以下是一个考虑优化的Python实现片段,展示了随机基准选择。通过实践和优化,能提升算法技能。**
47 3
|
3月前
|
存储 算法 Java
技术笔记:JVM的垃圾回收机制总结(垃圾收集、回收算法、垃圾回收器)
技术笔记:JVM的垃圾回收机制总结(垃圾收集、回收算法、垃圾回收器)
39 1
|
2月前
|
存储 算法 C语言
软考中级之数据库系统工程师笔记总结(二)数据结构与算法
软考中级之数据库系统工程师笔记总结(二)数据结构与算法
23 0
|
2月前
|
算法 搜索推荐 C#
|
2月前
|
存储 算法 程序员
|
15天前
|
算法 BI Serverless
基于鱼群算法的散热片形状优化matlab仿真
本研究利用浴盆曲线模拟空隙外形,并通过鱼群算法(FSA)优化浴盆曲线参数,以获得最佳孔隙度值及对应的R值。FSA通过模拟鱼群的聚群、避障和觅食行为,实现高效全局搜索。具体步骤包括初始化鱼群、计算适应度值、更新位置及判断终止条件。最终确定散热片的最佳形状参数。仿真结果显示该方法能显著提高优化效率。相关代码使用MATLAB 2022a实现。
|
15天前
|
算法 数据可视化
基于SSA奇异谱分析算法的时间序列趋势线提取matlab仿真
奇异谱分析(SSA)是一种基于奇异值分解(SVD)和轨迹矩阵的非线性、非参数时间序列分析方法,适用于提取趋势、周期性和噪声成分。本项目使用MATLAB 2022a版本实现从强干扰序列中提取趋势线,并通过可视化展示了原时间序列与提取的趋势分量。代码实现了滑动窗口下的奇异值分解和分组重构,适用于非线性和非平稳时间序列分析。此方法在气候变化、金融市场和生物医学信号处理等领域有广泛应用。
|
16天前
|
资源调度 算法
基于迭代扩展卡尔曼滤波算法的倒立摆控制系统matlab仿真
本课题研究基于迭代扩展卡尔曼滤波算法的倒立摆控制系统,并对比UKF、EKF、迭代UKF和迭代EKF的控制效果。倒立摆作为典型的非线性系统,适用于评估不同滤波方法的性能。UKF采用无迹变换逼近非线性函数,避免了EKF中的截断误差;EKF则通过泰勒级数展开近似非线性函数;迭代EKF和迭代UKF通过多次迭代提高状态估计精度。系统使用MATLAB 2022a进行仿真和分析,结果显示UKF和迭代UKF在非线性强的系统中表现更佳,但计算复杂度较高;EKF和迭代EKF则更适合维数较高或计算受限的场景。