快速排序的缺陷及优化
原理
快速排序(Quick Sort) 是一种基于分治法的排序算法,由Tony Hoare于1960年提出。其基本思想是通过多次划分操作将一个数组分成较小的子数组,每个子数组中的元素比基准值更接近其最终位置。
操作步骤
- 选择基准值(Pivot):从数组中选择一个元素作为基准值。
- 分区操作(Partitioning):将数组分为两部分,一部分包含小于基准值的元素,另一部分包含大于基准值的元素。所有相等的元素可以放在任一部分或中间。
- 初始化两个指针,左指针在数组开头,右指针在数组末尾。
- 移动左指针直到找到一个大于基准值的元素。
- 移动右指针直到找到一个小于基准值的元素。
- 交换这两个元素的位置。
- 继续上述步骤直到左指针和右指针相遇或交叉,此时将基准值放置在合适的位置。
- 递归排序:对基准值左右两边的子数组分别进行快速排序。
实现
排序过程
如下图所示,使用快速排序法对指定序列arr [ 3 8 5 1 2 6 9 7 ]进行降序排序。
- 选定基准值,arr[0] = 3 (第 1 个元素)作为基准值。
- 从右向左移动 right 指针,找到大于基准值(3)的数(7)停止。
- 从左向右移动 left 指针,找到小于基准值(3)的数(1)停止。
- 交换 left 和 right 指向的数据。
- 继续从右向左移动 right 指针,找到大于基准值(3)的数(9)停止。
- 从左向右移动 left 指针,找到小于基准值(3)的数(2)停止。
- 交换 left 和 right 指向的数据。
- 继续从右向左移动 right 指针,找到大于基准值(3)的数(6)停止。
- 从左向右移动 left 指针,未找到小于基准值(3)的数,与 right 相遇。此时 left 指向位置即为基准值(key)应该在的位置。
- 交换 left 指向位置 和 key(基准值) 指向的数据。
- 以基准值 (key) 位置为中心,将数据左右拆开,拆分成两个序列。
- 使用同样的方法,分别对左右两个分区进行上述排序操作。
- 当待处理序列只有 1 个元素时,此序列一定为有序序列。结束处理。
代码实现
根据以上推导过程编写代码
/**
* @ 快速排序 hoare 版本
* @ arr - 待排序的数组
* @ start_pos - 待处理分区元素起始位置 end_pos - 待处理分区元素结束位置
* @ 要求从大到小排序数据,选取首元素作为基准值
* */
void quick_hoare_sort_start(int *arr, int start_pos, int end_pos)
{
int key = arr[start_pos]; /* 选取分区首元素作为基准值 */
int right = end_pos; /* 右指针,从右向左遍历数据 */
int left = start_pos; /* 左指针,从左向右遍历数据 */
if (left >= right)
return;
while (left < right) {
/* 1. 从右向左遍历,找到大于基准值的元素位置 */
while ((left < right) && (arr[right] <= key))
right--;
/* 2. 从左向右遍历,找到小于基准值的元素位置 */
while ((left < right) && (arr[left] >= key))
left++;
/* 3. 成功找到元素后,交换位置 */
if (left < right) {
swap_data(&arr[left], &arr[right]);
/* 此处网上很多代码包含以下两句,实际是有bug */
// left++;
// right--;
}
}
/* 4. left和right相遇,left指向位置为基准值应该所在位置 */
swap_data(&arr[left], &arr[start_pos]);
/* 5. 分别对左右分区,进行快速排序处理 */
quick_hoare_sort_start(arr, start_pos, left-1);
quick_hoare_sort_start(arr, left+1, end_pos);
}
运行结果
思考
- 移动 left 指针和 right 指针的先后顺序有讲究吗?什么情况下先移动 left 指针,什么情况下又先移动 right 指针呢?
- 若是基准值 key 不选取的第 1 个元素,而选取最后 1 个元素又如何处理呢?
带着以上两个疑问画出如下排序过程图。
- 选定基准值,arr[len-1] = 7 (最后 1 个元素)作为基准值。
- 从左向右移动 left 指针,找到小于基准值(7)的数(3)停止。
- 从右向左移动 right 指针,找到大于基准值(7)的数(9)停止。
- 交换 left 和 right 指向的数据。
- 继续从左向右移动 left 指针,找到小于基准值(7)的数(5)停止。
- 从右向左移动 right 指针,未找到大于基准值(7)的数,与 left 相遇。此时 right 指向位置即为基准值(key)应该在的位置。
- 交换 right 指向位置 和 key(基准值) 指向的数据。
- 以基准值 (key) 位置为中心,将数据左右拆开,拆分成两个序列。
- 使用同样的方法,分别对左右两个分区进行上述排序操作。
- 当待处理序列只有 1 个元素时,此序列一定为有序序列。结束处理。
代码实现
根据以上推导过程编写代码
/**
* @ 快速排序 hoare 版本
* @ arr - 待排序的数组
* @ start_pos - 待处理分区元素起始位置 end_pos - 待处理分区元素结束位置
* @ 要求从大到小排序数据,选取末元素作为基准值
* */
void quick_hoare_sort_end(int *arr, int start_pos, int end_pos)
{
int key = arr[end_pos]; /* 选取分区末元素作为基准值 */
int right = end_pos; /* 右指针,从右向左遍历数据 */
int left = start_pos; /* 左指针,从左向右遍历数据 */
if (left >= right)
return;
while (left < right) {
/* 1. 从左向右遍历,找到小于基准值的元素位置 */
while ((left < right) && (arr[left] >= key))
left++;
/* 2. 从右向左遍历,找到大于基准值的元素位置 */
while ((left < right) && (arr[right] <= key))
right--;
/* 3. 成功找到元素后,交换位置 */
if (left < right) {
swap_data(&arr[left], &arr[right]);
}
}
/* 4. left和right相遇,left指向位置为基准值应该所在位置 */
swap_data(&arr[right], &arr[end_pos]);
/* 5. 分别对左右分区,进行快速排序处理 */
quick_hoare_sort_end(arr, start_pos, left-1);
quick_hoare_sort_end(arr, left+1, end_pos);
}
运行结果
结论
先移动 left 指针还是 先移动 right 指针由基准值选择决定。
- 当选择第 1 个元素作为基准值时,因为基准值的位置在前面,所以要保证与 left 位置进行交换,此时需要先移动 right 指针,这样就能保证 left 和 right 最后相遇时候指向的值一定该在基准值左边。包含以下情况。
- 假设 right 指针找到数据,left 指针未找到,此时 left 移动到 right 指针位置结束遍历,指向数据本身应该交换到基准值左边,所以交换基准值和 left 指向值是合理的。
- 假设 right 指针未找到数据,此时 right 指针移动到 left 指针的位置结束遍历。此时的left指向数据本身就存在于基准值左边。将 left 指向数据和基准值位置交换亦是合理的。
- 当选择最后 1 个元素作为基准值时,因为基准值的位置在后面,所以要保证与 right 位置进行交换,此时需要先移动 left 指针,这样就能保证 left 和 right 最后相遇时候指向的值一定该在基准值右边。包含以下情况。
3. 假设 left 指针找到数据,right 指针未找到,此时 right 移动到 left 指针位置结束遍历,指向数据本身应该交换到基准值右边,所以交换基准值和 right 指向值是合理的。
4. 假设 left 指针未找到数据,此时 left 指针移动到 right 指针的位置结束遍历。此时的 right 指向数据本身就存在于基准值右边。将 right 指向数据和基准值位置交换亦是合理的。
优化
优化1. 网上代码 bug
小编在网上查阅相关资料过程中,发现很多示例在找到元素时交换过程中编写代码如下
/* 3. 成功找到元素后,交换位置 */
if (left < right) {
swap_data(&arr[left], &arr[right]);
/* 此处网上很多代码包含以下两句,实际是有bug */
// left++;
// right--;
}
交换元素位置后,将 left 指针和 right 指针 均移动了 1 次,看似合理,实则若遇到下 1 次 left 和 right 均未找到合适的元素时,最后交换基准值时会发生错误。
假设此时数组序列变为 [ 3, 8, 5, 1, 6, 2, 9, 7 ] 运行结果如下
可以看出档交换 09 和 02 后,right 指针前移指向 09,而 left 指针后移指向 02,left > right 此时交换 left 和 基准值 key 明显可以看出发生错误。
基于此问题,正确代码如下
/* 3. 成功找到元素后,交换位置 */
if (left < right) {
swap_data(&arr[left], &arr[right]);
/* 此处网上很多代码包含以下两句,实际是有bug */
//left++; /* 若选取末元素作为基准,移动 left,不移动 right */
right--; /* 若选取首元素作为基准,移动 right,不移动 left */
}
修复后运行结果如下
优化2. 减少基准值的移动
每 1 次当分区排序遍历结束的时候,此时若 left 指向数据与基准值相等,可以不用移动数据,从而减少数据交换次数。
/* 4. left和right相遇,left指向位置为基准值应该所在位置, 若此时left指向值等于基准值,无需移动 */
if (arr[left] != arr[start_pos]) {
swap_data(&arr[left], &arr[start_pos]);
}
优化3. 有序序列优化
在序列本身是有序的情况下,排序运行结果如下
可以看出在序列本身有序情况下,每次分割时都会导致一个子分区为空,此时快速排序的时间复杂度退化到O(n²),导致性能严重下降,我们可以使用以下两种方式规避这种情况。
有序检查
在快速排序前,先对序列进行有序检查,来规避此种情况。代码设计如下
/**
* @ 检查数组是否有序(降序)
* @ arr - 序列 len - 元素个数
* @ 有序返回0,无序返回1
* */
int check_arr_sort(int *arr,