快速排序是一种常用的排序算法,它基于分治的思想,通过将数组划分为较小的子数组,然后递归地排序这些子数组来达到排序整个数组的目的。

快速排序的基本思想是从数组中选择一个基准元素,然后通过一趟排序将数组分成两部分,以基准元素为轴,使得左边的元素都小于等于基准元素,右边的元素都大于基准元素。然后对这两部分分别进行递归排序,最终将整个数组排序。

快速排序步骤

快速排序具体步骤如下:

  1. 选择一个基准元素 pivot,可以是数组的第一个元素或最后一个元素;
  2. 设置两个指针,一个指向数组的起始位置 left,一个指向数组的结束位置 right
  3. left 指针开始向右遍历,找到下一个大于 pivot 的元素,记其索引为 i
  4. right 指针开始向左遍历,找到下一个小于等于 pivot 的元素,记其索引为 j
  5. 如果 i<j,交换 ij 处的元素;
  6. 重复步骤 3-5,直到 left 指针和 right 指针相遇;
  7. 将基准元素 pivot 与指针相遇处的元素交换。
  8. 递归地对基准元素左边的子数组和右边的子数组进行快速排序。

快速排序复杂度

快速排序的时间复杂度为 O(n×logn)O(n \times logn),其中 nn 为数组的长度。它是一种 原地排序算法,不需要额外的存储空间,但是在最坏情况下可能会出现时间复杂度为 O(n2)O(n^2) 的情况,需要进行优化。

快速排序实现

交换两个元素

1
2
3
4
5
void swap(int* a, int* b) {
int temp = *a;
*a = *b;
*b = temp;
}

划分数组为两部分

通过一趟排序将数组分成两部分,以基准元素为轴,使得左边的元素都小于等于基准元素,右边的元素都大于基准元素,最后返回基准元素在数组中的索引。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
int partition(int arr[], int left, int right) {
int pivot = arr[left];
int i = left + 1;
int j = right;

while (true) {
// 找到下一个大于 pivot 的元素
while (i <= j && arr[i] <= pivot) {
i++;
}
// 找到下一个小于等于 pivot 的元素
while (j >= i && arr[j] > pivot) {
j--;
}
// 不是在对方遍历过的区间找到的, 就交换并自增自减指针
if (i < j) {
swap(&arr[i++], &arr[j--]);
} else {
break;
}
}
swap(&arr[left], &arr[j]);

return j;
}

上面的倒数第二行代码中,为什么基准元素是与索引为 j 的元素进行交换呢?

这是因为,我们选择了区间 [left,right][left, right] 左边界 的元素作为了基准元素。划分后,左边都是小于等于基准元素的元素,而索引 j 右边都是大于基准元素的元素,所以基准元素是与索引为 j 的元素进行交换。

同样地,如果我们选择区间 [left,right][left, right] 右边界的元素作为了基准元素,那么代码就该这样写了:

1
2
3
4
5
6
7
int pivot = arr[right];
int i = left;
int j = right - 1;
// ...
swap(&arr[right], &arr[i]);

return i;

递归实现快速排序

递归地对基准元素左边的子数组和右边的子数组进行快速排序。快速排序接口的入参为 left=0right=n-1

1
2
3
4
5
6
7
void quickSort(int arr[], int left, int right) {
if (left < right) {
int pivot = partition(arr, left, right);
quickSort(arr, left, pivot - 1);
quickSort(arr, pivot + 1, right);
}
}

快速排序测试

快速排序代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
void swap(int* a, int* b) {
int temp = *a;
*a = *b;
*b = temp;
}

int partition(int arr[], int left, int right) {
int pivot = arr[left];
int i = left + 1;
int j = right;

while (true) {
// 找到下一个大于 pivot 的元素
while (i <= j && arr[i] <= pivot) {
i++;
}
// 找到下一个小于等于 pivot 的元素
while (j >= i && arr[j] > pivot) {
j--;
}
// 不是在对方遍历过的区间找到的, 就交换并自增自减指针
if (i < j) {
swap(&arr[i++], &arr[j--]);
} else {
break;
}
}
swap(&arr[left], &arr[j]);

return j;
}

void quickSort(int arr[], int left, int right) {
if (left < right) {
int pivot = partition(arr, left, right);
quickSort(arr, left, pivot - 1);
quickSort(arr, pivot + 1, right);
}
}

快速排序测试代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <stdio.h>
#include <stdbool.h>

void swap(int* a, int* b);
int partition(int arr[], int left, int right);
void quickSort(int arr[], int left, int right);

void printArray(int arr[], int n) {
for (int i = 0; i < n; i++) {
printf("%d ", arr[i]);
}
printf("\n");
}

int main(int argc, char *argv[]) {
int arr[] = {3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5};
int n = sizeof(arr) / sizeof(arr[0]);

quickSort(arr, 0, n - 1);

printf("After quickSort: ");
printArray(arr, n);
}

测试结果

1
After quickSort: 1 1 2 3 3 4 5 5 5 6 9

进阶 1:基准元素的选择

首尾基准元素

上文说,基准元素可以是「数组的第一个元素或最后一个元素」,这其实是非常不可取的,在某些情况下可能导致最差的性能。因为:

  1. 假如,初始数组就已经是一个升序数组:我们每次都选择第一元素作为基准元素,那么右指针 j 会向左遍历整个数组,但不会进行任何交换操作。这一轮划分操作的时间复杂度为 O(n),整个排序过程的时间复杂度为 O(n^2)
  2. 同样地,初始数组就已经是一个降序数组:我们每次都选择最后一个元素作为基准元素,那么左指针 i 会向右遍历整个数组,但不会进行任何交换操作。这一轮划分操作的时间复杂度为 O(n),整个排序过程的时间复杂度为 O(n^2)

随机基准元素

随机基准元素,避免了上述问题,可谓是一种再好不过的基准元素的选择办法。但是,随机数的产生是有时间成本的,不一定会使得快速排序的更快

三数中值基准元素

三数中值是一种常用的基准元素选择方式 。它通过比较数组的第一个、中间和最后一个元素, 选择它们的中位数作为基准元素。这种方式可以在一定程度上避免最差情况的发生,并提高算法的性能。

如何选出三数的中位数在数组中的对应索引呢?多轮比较!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
int getMedianIdx(int arr[], int left, int right) {
int mid = (left + right) / 2;

int min = (arr[left] < arr[mid]) ? left : mid;
min = (arr[min] < arr[right]) ? min : right;

int max = (arr[left] > arr[mid]) ? left : mid;
max = (arr[max] > arr[right]) ? max : right;

if (min == left) {
return max == right ? mid : right;
} else if (min == right) {
return max == left ? mid : left;
} else {
return max == right ? left : right;
}
}

进阶 2:何时停止进行交换

上文的划分代码中,当左指针等于基准元素的时候并没有停止(while (i <= j && arr[i] <= pivot) {i++;}),当右指针等于基准元素的时候却停止了(while (j >= i && arr[j] > pivot) {j--;})。那么左、右指针是否应该停止呢?

假设,未排序的初始数组的所有元素都相同,那么试想一下左、右指针指向的元素等于基准元素时的情况:

  1. 左、右指针都不停止:那么先执行 while 循环的那个指针会一直移动,直到与另一个指针相遇(另一个指针一步也没移动)。这一轮移动划分的时间复杂度为 O(n),最后基准元素的位置还是在某一侧。下一次移动还是同样的情况,每次划分的数组的长度都是减少 1(另一个数组长度为 0)。整个排序过程的时间复杂度为 O(n^2)
  2. 左、右指针都停止:那么两个指针会交换着各自指向的元素,来到数组中间,并将基准元素放在中间,结束本轮划分。下次划分时,左右子数组的长度都会减半。整个排序过程的时间复杂度为 O(nlogn)
  3. 一个指针不停止、一个指针停止:等价于「左、右指针都不停止」的情况,因为情况 1 中,另一个指针就没有机会移动(就相当于停止啊)。

综上,当指针指向的元素等于基准元素时,应该停止

优化后的快速排序代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
void swap(int* a, int* b) {
int temp = *a;
*a = *b;
*b = temp;
}

// 获取 arr[left], arr[mid], arr[right] 三数中值的索引
int getMedianIdx(int arr[], int left, int right) {
int mid = (left + right) / 2;

int min = (arr[left] < arr[mid]) ? left : mid;
min = (arr[min] < arr[right]) ? min : right;

int max = (arr[left] > arr[mid]) ? left : mid;
max = (arr[max] > arr[right]) ? max : right;

if (min == left) {
return max == right ? mid : right;
} else if (min == right) {
return max == left ? mid : left;
} else {
return max == right ? left : right;
}
}

int partition(int arr[], int left, int right) {
// 将选中的基准元素与数组末尾元素交换, 并指定基准元素为「数组末尾元素」
int pivotIdx = getMedianIdx(arr, left, right);
swap(&arr[right], &arr[pivotIdx]);
int pivot = arr[right];

int i = left, j = right - 1;
while (true) {
while (i <= j && arr[i] < pivot) {
i++;
}
while (j >= i && arr[j] > pivot) {
j--;
}
if (i < j) {
swap(&arr[i++], &arr[j--]);
} else {
break;
}
}
swap(&arr[right], &arr[i]);

return i;
}

void quickSort(int arr[], int left, int right) {
if (left < right) {
int pivot = partition(arr, left, right);
quickSort(arr, left, pivot - 1);
quickSort(arr, pivot + 1, right);
}
}

最后留一个问题:快速排序为什么叫「快速」排序?