目录
[1. 算法包](#1. 算法包)
[2. 快速排序代码](#2. 快速排序代码)
[3. 模拟程序](#3. 模拟程序)
[4. 运行程序](#4. 运行程序)
[5. 从大到小排序](#5. 从大到小排序)
[1. 选择基准值 (Pivot)](#1. 选择基准值 (Pivot))
[2. 分区操作 (Partition)](#2. 分区操作 (Partition))
[3. 递归排序](#3. 递归排序)
[假如 10 条数据进行排序](#假如 10 条数据进行排序)
[假如 20 条数据进行排序](#假如 20 条数据进行排序)
[假如 30 条数据进行排序](#假如 30 条数据进行排序)
[假设 5000 条数据,对比 冒泡、选择、插入、堆、归并](#假设 5000 条数据,对比 冒泡、选择、插入、堆、归并)
[1. 大数据集](#1. 大数据集)
[2. 随机数据](#2. 随机数据)
[3. 缓存友好的](#3. 缓存友好的)
前言
在实际场景中,选择合适的排序算法对于提高程序的效率和性能至关重要,本节课主要讲解"快速排序"的适用场景及代码实现。
快速排序
快速排序(Quick Sort) 是一种非常高效的排序算法,采用分治法的策略来把一个序列分为较小和较大的两个子序列,然后递归地排序两个子序列。其基本思想是:选择一个基准值(pivot),通过一趟排序将待排序的数据分割成独立的两部分,其中一部分的所有数据都比另一部分的所有数据要小,然后再按此方法对这两部分数据分别进行快速排序,整个排序过程可以递归进行,以达到整个数据变成有序序列。
代码示例
下面我们使用Go语言实现一个快速排序
1. 算法包
创建一个 pkg/algorithm.go
bash
touch pkg/algorithm.go
(如果看过上节课的插入排序,则已存在该文件,我们就不需要再创建了)
2. 快速排序代码
打开 pkg/algorithm.go文件,代码如下
从小到大 排序
Go
package pkg
// BubbleSort 冒泡排序
...
// SelectionSort 选择排序
...
// InsertionSort 插入排序
...
// QuickSort 快速排序
func QuickSort(arr []int, low, high int) {
if low < high {
// partitionIndex 是分区操作后基准的索引
partitionIndex := partition(arr, low, high)
// 分别对基准左侧和右侧的子数组进行快速排序
QuickSort(arr, low, partitionIndex-1)
QuickSort(arr, partitionIndex+1, high)
}
}
// partition 分区操作
func partition(arr []int, low, high int) int {
pivot := arr[high] // 选择最后一个元素作为基数
i := low - 1
for j := low; j < high; j++ {
// 如果当前元素小于或等于基数
if arr[j] <= pivot {
i++
// 交换 arr[i] 和 arr[j]
arr[i], arr[j] = arr[j], arr[i]
}
}
// 交换 arr[i+1] 和 arr[high] (基准值)
arr[i+1], arr[high] = arr[high], arr[i+1]
return i + 1
}
3. 模拟程序
打开 main.go 文件,代码如下:
Go
package main
import (
"demo/pkg"
"fmt"
)
func main() {
// 定义一个切片,这里我们模拟 10 个元素
arr := []int{51, 224, 67, 322, 825, 103, 50, 965, 789, 601}
fmt.Println("Original data:", arr) // 先打印原始数据
pkg.QuickSort(arr, 0, len(arr)-1) // 调用快速排序
fmt.Println("New data: ", arr) // 后打印排序后的数据
}
4. 运行程序
bash
go run main.go
能发现, Original data 后打印的数据,正是我们代码中定义的切片数据,顺序也是一致的。
New Data 后打印的数据,则是经过快速排序后的数据,是从小到大的。
5. 从大到小排序
如果需要 从大到小 排序也是可以的,在代码里,将两个元素比较的 小于等于符号 改成 大于等于符号 即可。
修改 pkg/algorithm.go 文件:
Go
package pkg
// BubbleSort 冒泡排序
...
// SelectionSort 选择排序
...
// InsertionSort 插入排序
...
// QuickSort 快速排序
func QuickSort(arr []int, low, high int) {
if low < high {
// partitionIndex 是分区操作后基准的索引
partitionIndex := partition(arr, low, high)
// 分别对基准左侧和右侧的子数组进行快速排序
QuickSort(arr, low, partitionIndex-1)
QuickSort(arr, partitionIndex+1, high)
}
}
// partition 分区操作
func partition(arr []int, low, high int) int {
pivot := arr[high] // 选择最后一个元素作为基数
i := low - 1
for j := low; j < high; j++ {
// 如果当前元素大于或等于基数
if arr[j] >= pivot {
i++
// 交换 arr[i] 和 arr[j]
arr[i], arr[j] = arr[j], arr[i]
}
}
// 交换 arr[i+1] 和 arr[high] (基准值)
arr[i+1], arr[high] = arr[high], arr[i+1]
return i + 1
}
只需要一丁点的代码即可
从 package pkg 算第一行,上面示例中在第三十一行代码中,我们将 "<=" 改成了 ">=" ,这样就变成了 从大到小排序了
快速排序的思想
- 分而治之:将大问题分解为小问题,然后递归地解决小问题,最后将小问题的解合并成原问题的解
- 原地排序:快速排序是原地排序算法,它只需要一个很小的栈空间(用于递归)来进行排序,不需要额外的存储空间
- 不稳定性:快速排序在某些情况下可能不是稳定的排序算法,因为相同元素的相对位置可能会在排序过程中改变
快速排序的实现逻辑
1. 选择基准值 (Pivot)
- 在快速排序中,首先需要选择一个基准值(pivot)。基准值的选择对排序的效率有很大的影响,但在本文的示例代码中,我们简单地选择了数组的最后一个元素作为基准值
2. 分区操作 (Partition)
- 分区操作是快速排序的核心。它的目的是将数组重新排列,使得所有比基准值小的元素都移到基准值的左边,所有比基准值大的元素都移到右边。分区操作完成后,基准值就处于其最终排序位置
- 在 partition 函数中,我们使用两个指针 i 和 j,其中 i 指向小于基准值的最后一个元素的下一个位置(初始化为 low - 1), j 用于遍历数组 (从 low 开始到 high - 1)。当 arr[j] 小于或等于基准值时,我们将其与 arr[i+1] 交换,并将 i 增加 1。这样,所有小于或等于基准值的元素都被交换到了基准值的左边
- 最后,我们将基准值 (原本在 arr[high] ) 与 arr[i + 1] 交换,此时 i + 1 就是基准值的最终位置,也是分区操作的返回值
3. 递归排序
- 分区操作完成后,我们得到了基准值的正确位置,并且数组被分成了两部分:一部分是基准值左边的所有元素 (都比基准值小),另一部分是基准值右边的所有元素 (都比基准值大)
- 然后,我们递归地对这两部分分别进行快速排序。这是通过调用 QuickSort 函数,并传入适当的参数 (基准值左侧和右侧的子数组的范围) 来实现的
循环次数测试
按照上面示例进行测试
假如 10 条数据进行排序
Go
[]int{51, 224, 67, 322, 825, 103, 50, 965, 789, 601}
总计循环了 30 次
假如 20 条数据进行排序
Go
[]int{997, 387, 461, 530, 979, 502, 36, 459, 99, 60, 454, 37, 182, 273, 529, 130, 315, 351, 975, 497}
总计循环了 83次
假如 30 条数据进行排序
Go
[]int{755, 247, 642, 652, 38, 587, 387, 284, 476, 924, 339, 830, 614, 534, 832, 450, 8, 641, 768, 788, 472, 750, 169, 479, 386, 124, 868, 259, 550, 613}
总计循环了 138次
上面我们说到,"基准值的选择对排序的效率有很大的影响",我们修改一条,依旧使用 30 条数据,我们将其最后一位数据 613,改成其他数 120 (这个值可随便改,这里示例 120)
通过调试,循环了 151 次
如果将 120 改成 700
通过调试,循环了 131 次
假设 5000 条数据,对比 冒泡、选择、插入、堆、归并
- 冒泡排序:循环次数 12,502,499 次
- 选择排序:循环次数 12,502,499 次
- 插入排序:循环次数 6,323,958 次
- 快速排序:循环次数 74,236 次
- 堆排序:循环次数 59,589 次
- 归并排序:循环次数 60,288 次
快速排序的适用场景
快速排序在多种情况下都是非常高效的,特别是以下几种情况:
1. 大数据集
对于大数据集,快速排序通常比简单的排序算法 (如 冒泡排序、插入排序) 更快
2. 随机数据
当输入数组中的数据是随机分布的时,快速排序的平均时间复杂度是 O(n log n),这是非常高效的
3. 缓存友好的
快速排序通过递归地在数组的不同部分上工作,倾向于产生良好的缓存局部性,特别是在处理大数据集时
然后,快速排序在某些情况下可能不是最佳选择,例如:
- 小数据集:对于非常小的数据集,快速排序的递归开销可能使得它不如简单的排序算法 (如 插入排序) 快
- 几乎已经排序的数据:在这种情况下,快速排序的性能可能退化为 O(n^2),因为它依赖于分区操作来减少问题的规模。如果分区操作不能有效地减少数组的大小 (例如,基准值总是最大或最小的元素),则会导致性能下降。在这种情况下,可以考虑使用归并排序或堆排序等算法
- 不稳定的排序需求:虽然快速排序在大多数情况下是稳定的 (如果实现得当),但在某些特定实现中可能不是。如果稳定性是排序算法的一个关键要求,可能需要考虑其他算法