当前位置: 首页 > news >正文

数据结构:快速排序 (Quick Sort)

目录

从“一分为二”的思想开始

核心操作 Partition (分割)

现在我们开始扫描:

代码的逐步完善

第一阶段:实现 partition 函数

第二阶段:实现递归的 quickSort 函数

复杂度与特性分析

时间复杂度 (Time Complexity)

空间复杂度 (Space Complexity)

稳定性 (Stability)


接下来我们将进入一个重量级的、也是工业界应用最广泛的排序算法之一:快速排序 (Quick Sort)。它的思想与我们之前讨论的 O(n^2) 算法有着本质的区别。


从“一分为二”的思想开始

我们先回顾一下之前几个排序算法的共性:

  • 冒泡排序:每轮把一个最大的元素“冒泡”到队尾。

  • 选择排序:每轮选一个最小的元素放到队首。

  • 插入排序:每轮把一个新元素插入到有序的队伍中。

它们的共同点是,每一轮操作,都只让一个元素到达了它“近乎”或“最终”的正确位置。为了让所有 n 个元素都归位,就需要大约 n 轮操作。

而每轮操作又需要大约 n 次比较或移动,这就导致了它们的复杂度基本都是 O(n^2)。

要想实现突破,我们就必须提出一个颠覆性的问题:

有没有一种方法,我们只操作一轮,就能产生比“一个元素归位”更大的进展?

✅ 答案是肯定的。想象一下,如果我们随便在数组里选一个元素,比如 arr[k],我们叫它基准 (pivot)。然后我们进行一次操作,使得:

  1. pivot 这个元素本身被放到了一个位置 p 上,这个位置就是它排序后最终应该在的位置。

  2. 所有比 pivot 小的元素,都被移动到了它的左边。

  3. 所有比 pivot 大的元素,都被移动到了它的右边。

[...小于pivot的元素...] pivot [...大于pivot的元素...]

这样一轮操作下来,我们不仅让 pivot 一个元素归位了,还顺便把整个数组分成了两个独立的子区域

接下来,我们只需要对左右两个子区域分别重复这个过程,直到每个子区域都只剩一个元素或为空,整个排序就完成了!

这种“先找一个基准将问题分解成两个子问题,再对子问题递归求解”的思路,就是大名鼎鼎的分而治之 (Divide and Conquer) 思想。


核心操作 Partition (分割)

“分治”思想听起来很棒,但关键在于,我们如何实现上面说的那个核心操作?这个操作通常被称为 Partition (分割)

📌 目标:给定一个数组(或子数组)和一个 pivot,将数组重排。

我们来设计一个具体的 Partition 方案。为了简单,我们总是选择当前处理的子数组的最后一个元素作为 pivot

假设数组是 arr = [2, 8, 7, 1, 3, 5, 6, 4],我们处理 low=0high=7 的范围。 pivot 就是 arr[7] = 4

我们的目标是把所有 <4 的放左边,>4 的放右边。 我们可以用两个“指针”来做这件事:

  • 一个指针 i,它代表“小于等于 pivot 区域”的右边界。初始时,这个区域不存在,我们可以让 i 指向 low - 1 的位置。

  • 另一个指针 j,它作为扫描指针,从 low 开始遍历到 high - 1

arr = [2, 8, 7, 1, 3, 5, 6, 4]    ↑ ↑i j

现在我们开始扫描:

1️⃣j=0, arr[0]=22 < pivot(4)。它应该属于“小于区”。

我们把 i 向右移动一位(i现在是0),然后交换 arr[i]arr[j] 的值。(这里因为 ij 刚好相等,交换了自己)。

i=0, arr 变为 [**2**, 8, 7, 1, 3, 5, 6, 4] 。小于区是 [2]

arr = [2, 8, 7, 1, 3, 5, 6, 4]    ↑i,j

2️⃣ j=1, arr[1]=88 > pivot(4)。它不属于“小于区”,我们什么都不做,i 不动。

arr = [2, 8, 7, 1, 3, 5, 6, 4]    ↑  ↑i  j

3️⃣ j=2, arr[2]=77 > pivot(4)i 仍然不动。

arr = [2, 8, 7, 1, 3, 5, 6, 4]    ↑     ↑i     j

4️⃣ j=3, arr[3]=11 < pivot(4)。它应该属于“小于区”。

我们把 i 向右移动一位(i现在是1),然后交换 arr[i]arr[j] 的值:交换 arr[1](8) 和 arr[3](1)。

i=1, arr 变为 [2, **1**, 7, 8, 3, 5, 6, 4] 小于区是 [2, 1]

arr = [2, 1, 7, 8, 3, 5, 6, 4]    ↑     ↑i     j

5️⃣ j=4, arr[4]=33 < pivot(4)

i 右移到2,交换 arr[2](7) 和 arr[4](3)。

i=2, arr 变为 [2, 1, **3**, 8, 7, 5, 6, 4] 小于区是 [2, 1, 3]

arr = [2, 1, 3, 8, 7, 5, 6, 4]    ↑     ↑i     j

6️⃣ j=5, arr[5]=55 > pivot(4)i 不动。

arr = [2, 1, 3, 8, 7, 5, 6, 4]    ↑        ↑i        j

7️⃣ j=6, arr[6]=66 > pivot(4)i 不动。

arr = [2, 1, 3, 8, 7, 5, 6, 4]    ↑           ↑i           j

扫描指针 j 走到了尽头。现在数组是 [2, 1, 3, | 8, 7, 5, 6, | 4]i=2 指向了小于区的最后一个元素 3

❓ pivot=4 还在数组末尾。它的最终位置应该在哪里?

就在小于区的后面!也就是 i+1 的位置。 我们最后一步,就是将 pivotarr[i+1](当前是8)交换。

交换 arr[3](8) 和 arr[7](4)。 最终数组变为:[2, 1, 3, **4**, 7, 5, 6, 8]

arr = [2, 1, 3, 4, 7, 5, 6, 8]    ↑           ↑i           j

看!👉4 已经在他最终的位置上了。所有比它小的都在左边,所有比它大的都在右边。Partition 操作成功!这个函数需要返回 pivot 的新索引 i+1,也就是 3


代码的逐步完善

第一阶段:实现 partition 函数

这个函数是快速排序的核心,我们先把它写出来。

#include <iostream>// 我们一直需要的 swap 函数
void swap(int* a, int* b) {int temp = *a;*a = *b;*b = temp;
}// Partition 函数
// arr[] --> 数组
// low   --> 子数组的起始索引
// high  --> 子数组的结束索引
int partition(int arr[], int low, int high) {// 1. 选择最后一个元素作为 pivotint pivot = arr[high];// 2. 'i' 是小于等于 pivot 区域的右边界int i = (low - 1); // 3. 'j' 遍历从 low 到 high-1for (int j = low; j <= high - 1; j++) {// 如果当前元素小于或等于 pivotif (arr[j] <= pivot) {i++; // 扩展小于区的边界swap(&arr[i], &arr[j]);}}// 4. 将 pivot 放到它的最终位置 (i+1)swap(&arr[i + 1], &arr[high]);// 5. 返回 pivot 的索引return (i + 1);
}

第二阶段:实现递归的 quickSort 函数

有了 partition 这个强大的工具,quickSort 函数本身就变得非常简洁和优雅了。它只需要做三件事:

  1. 调用 partition 来分割数组。

  2. pivot 左边的子数组,递归调用 quickSort

  3. pivot 右边的子数组,递归调用 quickSort

当然,递归必须有一个终止条件:

当子数组的 low >= high 时,说明这个子数组只剩一个或零个元素,它天然就是有序的,不需要再处理。

// 快速排序的主函数
void quickSort(int arr[], int low, int high) {// 递归的终止条件if (low < high) {// 1. 调用 partition,找到 pivot 的正确位置 piint pi = partition(arr, low, high);// 2. 递归地对 pivot 左边的子数组进行排序quickSort(arr, low, pi - 1);// 3. 递归地对 pivot 右边的子数组进行排序quickSort(arr, pi + 1, high);}
}

现在我们把所有部分组合起来,写一个主函数来启动排序。

void printArray(int arr[], int size) {for (int i = 0; i < size; i++)std::cout << arr[i] << " ";std::cout << std::endl;
}int main() {int arr[] = {2, 8, 7, 1, 3, 5, 6, 4};int n = sizeof(arr) / sizeof(arr[0]);std::cout << "Original array: \n";printArray(arr, n);// 启动快速排序,初始范围是整个数组 0 到 n-1quickSort(arr, 0, n - 1);std::cout << "Sorted array: \n";printArray(arr, n);return 0;
}

复杂度与特性分析

快速排序的分析比之前的算法要复杂一些,因为它依赖于 pivot 的选择。

时间复杂度 (Time Complexity)

最好与平均情况 (Best/Average Case):

  • 如果每次我们选择的 pivot 都恰好是中位数,那么 partition 操作会把数组均分成两个大小接近 n/2 的子数组。

  • 第一层递归,partitionn 个元素操作,成本是 O(n)。

  • 第二层递归,对两个 n/2 的子数组操作,总成本是 2timesO(n/2)=O(n)。

  • 第三层递归,对四个 n/4 的子数组操作,总成本是 4timesO(n/4)=O(n)。

  • 需要多少层才能把数组分解完?大约需要 log_2n 层。

  • 在数学上可以证明,即使 pivot 选择得不那么完美(比如每次都按1:9的比例分割),平均时间复杂度依然是 O(nlogn)。

最坏情况 (Worst Case):

  • 如果数组已经有序完全逆序,而我们每次都选择最后一个元素做 pivot

  • partition 会把数组分成一个包含 n-1 个元素的子数组和一个空的子数组。

  • 递归树会变成一条长链,深度为 n

  • 总的时间复杂度是 O(n)+O(n−1)+...+O(1)=O(n2)。

  • 这是一个致命的缺陷,但在实际应用中可以通过随机选择 pivot等方法来极大程度地避免。


空间复杂度 (Space Complexity)

  • 快速排序是原地排序,它没有使用额外的数组。但是,递归调用本身需要消耗栈空间 (stack space) 来存储函数的参数、返回地址等。

  • 最好与平均情况: 递归树的深度是 O(logn),所以空间复杂度是:O(logn)

  • 最坏情况: 递归树的深度是 O(n),所以空间复杂度是:O(n)


稳定性 (Stability)

  • 我们来看 partition 操作中的 swapswap(&arr[i], &arr[j]) 和最后的 swap(&arr[i + 1], &arr[high]) 都是长距离交换

  • 例如,对于数组 [5a, 2, 8, 5b],如果我们选择 pivot=5b5a 在扫描时会因为 <=pivot 而被交换到“小于区”,但它的新位置和 5b 的相对关系很难保证不变。很容易就能举出反例。

  • 因此,快速排序是❌不稳定排序 (Unstable Sort)


快速排序名副其实,其平均情况下的性能非常出色,是实践中最高效的排序算法之一。它的主要缺点是存在一个性能很差的最坏情况,但这可以通过优化 pivot 的选择策略来有效缓解。理解它的“分治”思想是学习更高级算法的重要一步。

http://www.dtcms.com/a/357119.html

相关文章:

  • 数据结构(C语言篇):(五)单链表算法题(上)
  • Linux笔记13——shell编程基础-7
  • More Effective C++ 条款16:牢记80-20准则(Remember the 80-20 Rule)
  • Java泛型使用常见报错
  • Stream API 讲解
  • 上传文件到本地
  • LeetCode Hot 100 第8天
  • 医疗 AI 的 “破圈” 时刻:辅助诊断、药物研发、慢病管理,哪些场景已落地见效?
  • 174. Java 注释 - 声明注释类型
  • 《AI智脉速递》2025 年 8 月22 日 - 29 日
  • VS2022+QT6.7+NetWork(TCP服务器多客户端助手)
  • Rust 登堂 之 深入Rust 类型(六)
  • 如何打造团队协作型 IP,而非单人依赖型?
  • BugKu Web渗透之file_get_contents
  • Kotlin中回调函数的使用示例
  • Git-Git和TortoiseGit的安装以及使用
  • 云渲染云推流助力WebGL应用网页端无负担推流,摆脱终端加载缓慢问题
  • 无恶意软件勒索:Storm-0501如何转向云原生攻击
  • Linux829 shell:expect interact “ “ set
  • 知识卡片html5动态网页源码
  • CRYPT32!CryptMsgUpdate函数分析之CRYPT32!PkiAsn1Decode函数的作用是得到pci
  • ros2--topic/话题--接口
  • tauri打包失败
  • 太阳光模拟器在卫星研发与测试中的应用
  • wav音频转C语言样点数组
  • 嵌入式Linux设备树驱动开发 - dtsof驱动
  • shell学习(二)
  • Sharding-JDBC 使用方法
  • 为什么不能创建泛型数组?
  • C++并发编程-17. 线程安全的链表