(快速排序)
导读大家好,很高兴又和大家见面啦!!! 在上一篇内容中,我们介绍了 交换排序 的基本思想,以及第一种 交换排序算法——冒泡排序。
交换 指的是:根据序列中两个元素关键字的比较结果,来对换这两个记录在序列中的位置。 交换排序思想 则是通过 比较 与 交换 这两步 核心操作 完成排序。
冒泡排序 正是基于 交换排序思想,最直观、也最简单的实现方式。 然而,正是因为它在排序过程中,需要频繁执行比较与交换来完成每一次 冒泡 ,使得其算法效率相对低下。 在上一篇末尾,我们留下了一个问题:
这是否意味着交换排序思想整体上不如插入排序思想?现在我可以明确告诉你:不是的。 冒泡排序只是 交换排序家族 的 先行者,它揭示了该思想的 核心操作,却远未展现其真正的效率巅峰。 我们可以这样比喻:
交换排序思想 如同一匹能够日行千里的 千里马,但要发挥它的全部潜力,还需要一位懂它的 伯乐。
那么,这位伯乐究竟是谁呢? 不卖关子了——正是 分治策略。 当 交换 这匹 千里马,遇上 分治 这位伯乐,两者融合,便诞生了今天的主角:
在排序算法领域享有盛名,被誉为 20世纪十大算法之一 的——快速排序。那么,快速排序究竟如何将 分治 与 交换 精妙结合,实现效率的飞跃?它又有哪些独到的魅力与智慧? 让我们带着这些期待,一同走进 快速排序 的精彩世界。
一、基本思想快速排序(quick sort)是一种 高效的排序算法,它采用 分治策略,通过 递归 地将数据分割成较小的部分来实现排序。也就是说 快排 是一个结合了 交换排序思想 与 分治策略 的 递归算法。 快速排序 的 基本思想 是:
在待排序表 L[1\cdots n]中任取一个元素 pivot 作为 枢轴(或基准,通常取首元素)通过一趟排序将待排序表划分为独立的两部分L[1 \cdots k-1] 和 L[k+1 \cdots n] L[1 \cdots k-1] 中的所有元素小于 pivot L[k+1……n] 中的所有元素大于等于 pivot pivot 放在了其最终位置 L(k) 上,这个过程称为 一次划分。分别 递归 地对两个子表重复上述过程,直至每部分内只有一个元素或空为止,即所有元素放在了其最终位置上。快排 的 核心思想 可以概括为:选取基准、分区、递归。
二、排序过程这里我们具体的实例更好的来说明其排序过程:
下标
1
2
3
4
初始状态:
4
3
2
1
就比如上表这个具有 7 个元素的 降序序列 ,我们希望通过 快排 实现 升序排列,那么我们会将 关键字序列 逐步进行 拆分 。那它究竟是如何通过 拆分 完成最终排序的呢?下面就让我们一起来感受一下其内在的魅力;
2.1 第一次拆分2.1.1 基准选择快排 在每一次进行 拆分 前都需要选择一个 基准,通常选取 首元素 ,这里我们就直接简单点,以 首元素 作为 基准;
确定好 基准 后,接下来我们就需要以该 基准 将序列拆分为两部分:
代码语言:javascript复制flowchart LR
a[左侧小元素] ---> b[基准元素] ---> c[右侧大元素]2.1.2 序列拆分这里我们所说的拆分,实际上指的是 逻辑拆分 而不是真正意义上的拆分。具体的拆分过程我们是借助的 折半思想 ,通过 左右指针 来实现 逻辑拆分:
左指针 指向 左侧小元素部分右指针 指向 右侧大元素部分下标
1
2
3
4
初始状态:
$\textcolor{red}{4}$
$\textcolor{black}{3}$
$\textcolor{black}{2}$
$\textcolor{black}{1}$
$\textcolor{black}{0}$
左右指针:
基准、L
R
2.1.3 交换排序完成了 逻辑拆分 后,接下来我们就需要开始通过 交换排序思想 进行初步的 交换排序,具体过程如下:
移动右指针,当右指针找到 小于基准值的元素 时停止查找移动左指针,当左指针找到 大于基准值的元素 时停止查找交换左右指针所指向的元素,使其继续保持:左指针指向小元素,右指针指向大元素重复上述过程,直到左右指针相遇交换指针所指向的元素与基准元素下面我们就来开始实操:
第一次查找:0 < 4R 指针找到小元素,再移动 L 指针下标
1
2
3
4
初始状态:
$\textcolor{red}{4}$
$\textcolor{black}{3}$
$\textcolor{black}{2}$
$\textcolor{black}{1}$
$\textcolor{blue}{0}$
左右指针:
基准、 L
R
第二次查找:4 == 4,L 指针右移下标
1
2
3
4
初始状态:
$\textcolor{red}{4}$
$\textcolor{black}{3}$
$\textcolor{black}{2}$
$\textcolor{black}{1}$
$\textcolor{black}{0}$
左右指针:
基准
L
R
第三次查找:3 < 4L 指针右移下标
1
2
3
4
初始状态:
$\textcolor{red}{4}$
$\textcolor{blue}{3}$
$\textcolor{black}{2}$
$\textcolor{black}{1}$
$\textcolor{black}{0}$
左右指针:
基准
L
R
第四次查找:2 < 4L 指针右移下标
1
2
3
4
初始状态:
$\textcolor{red}{4}$
$\textcolor{black}{3}$
$\textcolor{blue}{2}$
$\textcolor{black}{1}$
$\textcolor{black}{0}$
左右指针:
基准
L
R
第五次查找:1 < 4L 指针右移下标
1
2
3
4
初始状态:
$\textcolor{red}{4}$
$\textcolor{black}{3}$
$\textcolor{black}{2}$
$\textcolor{blue}{1}$
$\textcolor{black}{0}$
左右指针:
基准
L
R
第六次查找:0 < 4L、R 指针相遇,结束查找并完成交换下标
1
2
3
4
交换前:
$\textcolor{red}{4}$
$\textcolor{black}{3}$
$\textcolor{black}{2}$
$\textcolor{black}{1}$
$\textcolor{blue}{0}$
左右指针:
基准
L、R
交换后:
$\textcolor{blue}{0}$
$\textcolor{black}{3}$
$\textcolor{black}{2}$
$\textcolor{black}{1}$
$\textcolor{red}{4}$
左右指针:
基准、L、R
此时第一次的拆分就已经完成,这时的 关键字序列 就被我们以 基准元素 4 为分界线拆分成了两部分:
代码语言:javascript复制flowchart LR
a[左侧小于4] ---> b[基准元素4] ---> c[右侧大于4]完成了第一次的拆分后,此时 左侧元素 的个数 n_l \geq 1 ,右侧元素 的个数 n_r == 0,这就表示 右侧元素 已经不需要继续拆分,左侧元素 还需要进一步拆分;
2.2 第二次拆分在进行第二次拆分时,根据 分治策略 的思想,我们是需要分别对 左侧元素 以及 右侧元素 完成 拆分操作,但是由于此时 基准 4 的右侧不存在任何元素,因此,我们只需要完成对 左侧的拆分;
2.2.1 基准选择同样的,由于第一次选择的 基准 是 首元素 ,那么本次的 基准 我们同样要选择 首元素:
下标
1
2
3
初始状态:
$\textcolor{red}{0}$
$\textcolor{black}{3}$
$\textcolor{black}{2}$
$\textcolor{black}{1}$
左右指针:
基准
2.2.2 序列拆分同样我们还是以 左右指针 来实现 逻辑拆分:
下标
1
2
3
初始状态:
$\textcolor{red}{0}$
$\textcolor{black}{3}$
$\textcolor{black}{2}$
$\textcolor{black}{1}$
左右指针:
基准、L
R
2.2.3 交换排序第一次查找:1 > 0R 指针左移下标
1
2
3
初始状态:
$\textcolor{red}{0}$
$\textcolor{black}{3}$
$\textcolor{black}{2}$
$\textcolor{blue}{1}$
左右指针:
基准、L
R
第二次查找:2 > 0R 指针左移下标
1
2
3
初始状态:
$\textcolor{red}{0}$
$\textcolor{black}{3}$
$\textcolor{blue}{2}$
$\textcolor{black}{1}$
左右指针:
基准、L
R
第三次查找:3 > 0R 指针左移下标
1
2
3
初始状态:
$\textcolor{red}{0}$
$\textcolor{blue}{3}$
$\textcolor{black}{2}$
$\textcolor{black}{1}$
左右指针:
基准、L
R
第四次查找:0 == 0,R、L 指针相遇,结束查找并完成交换下标
1
2
3
交换前:
$\textcolor{red}{0}$
$\textcolor{black}{3}$
$\textcolor{black}{2}$
$\textcolor{black}{1}$
左右指针:
基准、L、R
交换后:
$\textcolor{red}{0}$
$\textcolor{black}{3}$
$\textcolor{black}{2}$
$\textcolor{black}{1}$
左右指针:
基准、L、R
此时序列就以 0 作为分界线分成了两部分:
代码语言:javascript复制flowchart LR
a[左侧小于0] ---> b[基准元素0] ---> c[右侧大于0]由于 0 的左侧不存在任何元素,因此不需要继续 拆分;而 0 的右侧还存在 3 个元素,因此还要继续对右侧进行 拆分;
2.3 第三次拆分上一轮拆分中,基准元素 0 的右侧序列为:
下标
1
2
3
初始状态:
$\textcolor{black}{3}$
$\textcolor{black}{2}$
$\textcolor{black}{1}$
2.3.1 基准选择这里我们同样还是选择 首元素 作为基准:
下标
1
2
3
初始状态:
$\textcolor{red}{3}$
$\textcolor{black}{2}$
$\textcolor{black}{1}$
左右指针:
基准
2.3.2 序列拆分我们同样还是以 左右指针 来实现 逻辑拆分:
下标
1
2
3
初始状态:
$\textcolor{red}{3}$
$\textcolor{black}{2}$
$\textcolor{black}{1}$
左右指针:
基准、L
R
2.3.3 交换排序第一次查找:1 < 3R 指针找到了小元素,再移动 L 指针下标
1
2
3
初始状态:
$\textcolor{red}{3}$
$\textcolor{black}{2}$
$\textcolor{blue}{1}$
左右指针:
基准、L
R
第二次查找:3 == 3 ,L 指针右移下标
1
2
3
初始状态:
$\textcolor{red}{3}$
$\textcolor{black}{2}$
$\textcolor{black}{1}$
左右指针:
基准、L
R
第三次查找:2 < 3L 指针右移下标
1
2
3
初始状态:
$\textcolor{red}{3}$
$\textcolor{blue}{2}$
$\textcolor{black}{1}$
左右指针:
基准
L
R
第四次查找:1 < 3L、R 指针相遇,结束查找并完成交换下标
1
2
3
交换前:
$\textcolor{red}{3}$
$\textcolor{black}{2}$
$\textcolor{blue}{1}$
左右指针:
基准
L 、R
交换后:
$\textcolor{blue}{1}$
$\textcolor{black}{2}$
$\textcolor{red}{3}$
左右指针:
基准、 L 、R
可以看到,这一次之后,元素已经有序,但是,因为 3 的左侧还是有两个元素,因此还是会进行第四次拆分;
2.4 第四次拆分这一次拆分也是和前面的步骤一样,这里我就不再赘述,直接看过程;
2.4.1 基准选择下标
1
2
交换后:
$\textcolor{red}{1}$
$\textcolor{black}{2}$
左右指针:
基准
2.4.2 序列拆分下标
1
2
初始状态:
$\textcolor{red}{1}$
$\textcolor{black}{2}$
左右指针:
基准、L
R
2.4.3 交换排序第一次查找:2 > 1R 指针左移下标
1
2
初始状态:
$\textcolor{red}{1}$
$\textcolor{blue}{2}$
左右指针:
基准、L
R
第二次查找:1 == 1 ,L、R 指针相遇,结束查找并完成交换下标
1
2
交换前:
$\textcolor{red}{1}$
$\textcolor{black}{2}$
左右指针:
基准、L、R
交换后:
$\textcolor{red}{1}$
$\textcolor{black}{2}$
左右指针:
基准、L、R
此时由于 基准元素 1 的左侧不存在任何元素,右侧只有一个元素,因此不再继续拆分,并且完成了最终的排序;
2.5 小结整个排序过程实际上就是一个 递归 的过程,每一次的 拆分 就是在执行一轮 递进,当完成第四轮 拆分 后,算法就会开始回归; 正是因为 快排 是一个 递归算法 ,所以我们在整个排序过程中才能看到 快排 在每一次的 递进 中就做了三件事:
基准选择:选择一个用于分割序列的 基准元素 作为序列的 分界线序列拆分:通过 左右指针 来完成 逻辑拆分交换排序:通过 左右指针 的 交替比较 与 交换,将元素调整到其正确的分区中。 具体来说,在每一步中,指针通过比较 基准元素 与 当前元素 的大小关系,决定是否进行交换,这种 交替扫描 的方式是提高查找效率的关键。通过每一次递归中的这三步操作,快速排序 成功地 将原始问题分解为规模更小的子问题,并 通过解决这些子问题最终合并(由于是原地排序,合并是自动完成的)得到有序序列。
结语通过今天的学习,我们深入探讨了 快速排序 这一高效算法的核心思想 与 排序过程。快速排序 凭借其 分而治之 的策略,通过 选取基准、分区操作 和 递归排序,将复杂问题层层分解,展现出了简洁而强大的排序逻辑。 回顾全文,我们揭示了快速排序的三大核心步骤:
基准选择:通常选取首元素作为基准,将序列逻辑分割为左右两部分。分区操作:通过左右指针的交替扫描与元素交换,将小于基准的元素调整至其左侧,大于基准的元素调整至其右侧。递归排序:将分区后的左右子序列作为新的待排序序列,重复上述过程,直至序列完全有序。值得注意的是,分区操作中指针的交替移动与条件交换是保证算法正确性的关键。 在下一篇内容中,我们将告别理论描述,亲手使用 C语言 来实现经典的 Hoare快速排序算法。我们将不仅实现代码,还会在此基础上深入分析其性能,探讨其平均情况下卓越效率的原因。具体内容包括:
详细解析如何用代码控制左右指针的精准移动。深入探讨元素交换的具体实现与边界条件处理。分析算法的时间与空间复杂度,理解其高效背后的原理。讨论递归调用的优化以避免栈溢出风险。通过代码的实现与性能分析,我们将更加深刻地体会快速排序的巧妙之处。敬请期待,我们下一篇实战篇再见! 互动与分享
点赞👍 - 您的认可是我持续创作的最大动力
收藏⭐ - 方便随时回顾这些重要的基础概念
转发↗️ - 分享给更多可能需要的朋友
评论💬 - 欢迎留下您的宝贵意见或想讨论的话题
感谢您的耐心阅读! 关注博主,不错过更多技术干货。我们下一篇再见!