八大排序
排序是一种非常重要的基础算法,在校招和工作中都非常的实用,它在日常生活中无处不再。本章将介绍八大基本排序。
1 排序的概念
所谓排序,就是将一串记录按照某种递增递减的关系,使该记录成为一个有序的序列。常见并实用的排序有如下八种。
//直接插排
void InsertSort(int* a, int n);
//希尔排序
void ShellSort(int* a, int n);
//直接选排
void SelectSort(int* a, int n);
//堆排序
void HeapSort(int* a, int n);
//冒泡排序
void BubbleSort(int* a, int n)
//快速排序
void QuickSort(int* a, int left, int right)
//归并排序
void MergeSort(int* a, int n)
//计数排序
void CountSort(int* a, int n)
2 插入排序
2.1 直接插入排序
基本思想
直接插入排序是最简单的插入排序,例如摸扑克牌时,需要将摸到手的牌按照一定的顺序插入到已有的牌中,这里便是运用了直接插排的思想。
插入排序的定义:把待排的元素按规则逐个插入到一个有序序列中,直到所有元素插完为止,得到一个新的有序序列。
代码实现
首先有一个有序数组,此外有一个元素待插入到该数组中,这样只要不满足比较条件就将下标所指元素向后移动一位,直到满足比较条件或下标越界出数组时退出循环,将待插元素插入下标的下一个位置。
//1. 单趟排序 -- 针对一个元素的排序
void InsertSort(int* a, int n) {
int end;
int x;
while (end >= 0) {
if (a[end] > x) {
a[end + 1] = a[end];
end--;
}
else {
break;
}
}
a[end + 1] = x;
}
排序是要将所有元素都按顺序排序,而想实现这样的排序,只能一个一个元素排。所以排序一般分为内外两层循环,内循环调整元素未知,而外循环决定调整哪个元素。
- 首先将数组看成仅有一个元素的数组也就是第一个元素,然后将这样一个数组的尾部元素 e n d end end 的下一个元素看成待调整的元素 x x x ,再将待调整的元素运用上述代码“插入”到数组中,这样边排完了头两个元素。
- 将数组尾部的下标 e n d end end 向后挪一位,仍将其后的一个元素视为待调整的元素 x x x 再插入一次,这样就完成了第二趟排序。
- 以此类推,直到标识数组尾部的下标 e n d end end 指向倒数第1个元素,也就完成了整个数组的排序。
故只要加一个外循环,控制标识数组尾部的下标 e n d end end 遍历区间为 [ 0 , n − 1 ) [0,n-1) [0,n−1),这样便能完成对整个数组的排序。
void InsertSort(int* a, int n) {
assert(a);
//外循环 - 多趟排序
for (int i = 0; i < n - 1; i++) {
int end = i;
int x = a[end + 1];
//内循环 - 单趟排序
while (end >= 0) {
if (a[end] > x) {
a[end + 1] = a[end];
end--;
}
else {
break;
}
}
a[end + 1] = x;
}
}
所以插入排序即每次将 x x x 插入数组头到标识数组尾部下标 e n d end end 所在区间 [ 0 , e n d ] [0,end] [0,end] 并完成排序。
复杂度分析
外循环循环 n n n 次,内循环次数从1开始逐次增加,则循环次数为等差数列之和 T ( n ) = 1 + 2 + 3 + … + n − 1 T(n)=1+2+3+…+n-1 T(n)=1+2+3+…+n−1,故时间复杂度为 O ( n 2 ) O(n^2) O(n2) 。
开辟空间常数次,故空间复杂度为 O ( 1 ) O(1) O(1) 。
2.2 希尔排序
直接插排在接近有序是时效率极高,但其他情况下的表现却不尽人意,故Shell想出希尔排序来优化直接插排。在直接插排的前面进行预排序使其性能得到质的飞跃。Shell在计算机中和Linus一样是个流芳百世的名字。
分组预排序
思想上对直接插排进行优化,既然直接插入排序在数组接近有序的情况下效率极高,那就让数组先预排序,使其变得接近有序再去直接插排。这就有点递归分治的思想,这预排序里面就包含了希尔伟大的思想。
- 按
gap
分组,对分组值进行插入排序;
假设gap为3,则每三个元素分成一组,将一组的元素看出一个数组,忽略原本数组中的其他元素。
- 对分出的gap组进行插入排序。
将每个所分的组看出一个数组,对每一个这样的数组进行直接插排,过程如图所示:
预排序所得结果虽不是完全有序,但相对于原数组确实是更加有序的。
代码实现
void ShellSort(int* a, int n) {
assert(a);
int gap = n;
//多次预排一次插排
while (gap > 1) {
gap /= 2;
//排多组
for (int j = 0; j < gap; j++) {
//多趟排序
for (int i = j; i < n - gap; i += gap) {
int end = i;
int x = a[end + gap];
//单趟排序
while (end >= 0) {
if (a[end] > x) {
a[end + gap] = a[end];
end -= gap;
}
else {
break;
}
}
a[end + gap] = x;
}
}
}
}
- 首先完成一组的排序,此时就是将直接插排中所谓的下一个元素改成下“gap”个元素,对于边界的控制仍然满足 e n d end end最大为倒数第二个元素的条件。
- 外围再套一层循环,控制排序的起始位置
i
,也就是控制了多组的排序。 - 外围再套一层循环,控制
gap
不断减小到1,执行多次预排序,最后gap=1时执行直接插排。
代码简化
void ShellSort1(int* a, int n) {
assert(a);
int gap = n;
while (gap > 1) {
gap = gap / 3 + 1;
for (int i = 0; i < n - gap; i++) {
int end = i;
int x = a[end + gap];
while (end >= 0) {
if (a[end] > x) {
a[end + gap] = a[end];
end -= gap;
}
else {
break;
}
}
a[end + gap] = x;
}
}
}
复杂度分析
组内插排的复杂度
最坏情况下的复杂度分析较为复杂,简单推理一下:假设数组有
n
n
n 个元素分成
g
a
p
gap
gap 组,则每组大概有
n
/
g
a
p
n/gap
n/gap 个元素。每组的插入排序的复杂度大概为
T
(
n
,
g
a
p
)
=
1
+
2
+
3
+
.
.
.
+
n
/
g
a
p
T(n,gap)=1+2+3+...+n/gap
T(n,gap)=1+2+3+...+n/gap ,总共gap组复杂度大概为
F
(
n
,
g
a
p
)
=
(
1
+
2
+
3
+
.
.
.
+
n
/
g
a
p
)
∗
g
a
p
F(n,gap)=(1+2+3+...+n/gap)*gap
F(n,gap)=(1+2+3+...+n/gap)∗gap 。
M
i
n
:
lim
g
a
p
→
n
F
(
n
,
g
a
p
)
=
O
(
n
)
M
a
x
:
lim
g
a
p
→
1
F
(
n
,
g
a
p
)
=
O
(
n
2
)
Min:\lim_{gap→n}F(n,gap)=O(n)\\Max:\lim_{gap→1}F(n,gap)=O(n^2)
Min:gap→nlimF(n,gap)=O(n)Max:gap→1limF(n,gap)=O(n2)
对于预排序来说,gap越大,效果越差,但时间消耗越小;而gap越小,效果越好,但时间消耗就越大,所以执行多次预排更合理。
多组预排的复杂度
在该循环中gap从n开始不停的除2最终到1,这个过程最外层控制预排次数的循环,大概要走
l
o
g
2
n
log_2n
log2n 次,每次gap/=3
时,大概预排
l
o
g
3
n
log_3n
log3n 次。
而每次预排中的插入排序部分的gap不停的变化,导致复杂度较难计算,只能说约等于 O ( n l o g n ) O(nlogn) O(nlogn),大概为 O ( n 1.3 ) O(n^{1.3}) O(n1.3)。
 
3 选择排序
3.1 直接选择排序
基本思想
- 遍历一遍数组,选出规定范围内的最大值和最小值,
- 将最大值和最后一个位置的元素交换,将最小值和最前一个位置交换。
- 将最前最后的位置“排除”出数组,进一步缩小范围,
再重复上述步骤直到选完为止,当然遍历数组选最值时虽可只选一个但为降低时间消耗最好选两个。
代码实现
简单版:一次找一个极值。
void SelectSort(int* a, int n) {
assert(a);
while (n) {
int maxi = 0;
for (int i = 0; i < n; i++) {
if (a[i] > a[maxi]) {
maxi = i;
}
}
Swap(&a[maxi], &a[n - 1]);
n--;
}
}
提升版:一次找两个极值。
//1.
void SelectSort(int* a, int n) {
assert(a);
int begin = 0, end = n - 1;
while (begin < end) {
int mini = begin, maxi = begin;
//找最值
for (int i = begin; i <= end; i++) {
if (a[i] < a[mini]) {
mini = i;
}
if (a[i] > a[maxi]) {
maxi = i;
}
}
//交换
Swap(&a[mini], &a[begin]);
//修正
if (maxi == begin) {
maxi = mini;
}
Swap(&a[maxi], &a[end]);
++begin;
--end;
}
}
//2.
void SelectSort2(int* a, int n) {
assert(a);
int sz = n;
while (sz > n / 2) {
int maxi = mini = n - sz;
for (int i = n - sz; i < sz; i++) {
if (a[i] < a[mini])
mini = i;
if (a[i] > a[maxi])
maxi = i;
}
Swap(&a[maxi], &a[sz - 1]);
if (mini == sz - 1) {
mini = maxi;
Swap(&a[mini], &a[n - sz]);
sz--;
}
}
第一种方案,定义了头尾两个指针来标识排序的范围,第二种是定义变量sz
标识范围大小,但要注意控制排序趟数正好是元素个数的一半,相比来说第一种更加直观。
既然最小值与首元素交换,最大值与尾元素交换,可能在最大值与尾元素交换的时候,尾元素正好是最小值,或者最小值与首元素交换时,首元素正好时最大值。
本来maxi
,mini
已经标识好了最值的下标,上述情况下交换会将元素移走,但maxi
,mini
不会变,这就导致了下一次交换的并不是最值。要想解决这个问题,就必须在交换后检查并修正这两个下标,及时的更改下标的位置使其依旧和元素保持对应。
复杂度分析
每次找一个最值和每次找两个的方式的复杂度具体算式分别为 T 1 ( n ) = n + n − 1 + . . . + 0 T_1(n)=n+n-1+...+0 T1(n)=n+n−1+...+0 和 T 2 ( n ) = n + n − 2 + n − 4 + . . . + 0 T_2(n)=n+n-2+n-4+...+0 T2(n)=n+n−2+n−4+...+0 ,但时间复杂度都是 O ( n 2 ) O(n^2) O(n2)。所以每次找一个和两个的优化仅仅是量的提升,并没有质的提升。
上述是最坏情况,而最好情况仍然是一样的遍历步骤,所以最好情况的时间复杂度仍是 O ( n 2 ) O(n^2) O(n2)。无论什么情况直接选排的复杂度都是 O ( n 2 ) O(n^2) O(n2),所以直接选择排序的效果比较差。
3.2 堆排序
基本思想
堆排序要先把数组建成堆再进行堆排序,建堆用向下调整算法,时间复杂度为 O ( n ) O(n) O(n)。建堆最好选择排升序建大堆,排降序建小堆。
- 向下调整算法要求结点的左右子树都是堆,故从下往上走,从第一个非叶子结点开始向下调整建堆。
- 建堆完成后,数组首尾元素互换选出最大的数,再向下调整选出次大的数,循环直至调整完数组。
代码实现
void AdjustDown(int* a, int n, int parent) {
assert(a);
int child = parent * 2 + 1;
while (child < n) {
if (child + 1 < n && a[child + 1] > a[child]) {
child++;
}
if (a[child] > a[parent]) {
Swap(&a[child], &a[parent]);
parent = child;
child = parent * 2 + 1;
}
else {
break;
}
}
}
void HeapSort(int* a, int n) {
assert(a);
//建堆
for (int i = (n - 1 - 1) / 2; i >= 0; i--) {
AdjustDown(a, n, i);
}
//堆排序
int end = n - 1;
while (end) {
Swap(&a[0], &a[end]);
AdjustDown(a, end, 0);
end--;
}
}
复杂度分析
直接选择排序的复杂度太高,虽然堆排序也是选择排序,但堆排序利用了堆的性质大幅缩减了比较的次数,达到了 O ( n l o g n ) O(nlogn) O(nlogn),建堆用向下调整算法,时间复杂度为 O ( n ) O(n) O(n)。二者综合起来就是 O ( n l o g n ) O(nlogn) O(nlogn),可见堆排和希尔排序属于同一级别的排序。
4 交换排序
4.1 冒泡排序
基本思想
相邻两数进行比较,满足交换条件就交换。假设最坏情况下逆序数组有 n n n 个元素,每趟排序将一个最大值放到末尾。
-
第一趟排序比较前 n − 1 n-1 n−1 数,共比较 n − 1 n-1 n−1 次,将最大数放到第 n n n 个位置;
-
第二趟排序比较前 n − 2 n-2 n−2 数,共比较 n − 2 n-2 n−2 次,将次最大数放到第 n − 1 n-1 n−1 个位置;
-
第 i i i 趟排序比较前 n − i n-i n−i 数,共比较 n − i n-i n−i 次,将次最大数放到第 n − i + 1 n-i+1 n−i+1 个位置;
… …
-
最后一趟第 n − 1 n -1 n−1 趟排序比较前 1 1 1 数,共比较 1 1 1 次,将次最大数放到第 n − i + 1 n-i+1 n−i+1 个位置。
代码实现
void BubbleSort(int* a, int n) {
assert(a);
//1.
//多趟排序 - 控制次数
for (int i = 0; i < n - 1; i++) {
bool exchange = true;
//单趟排序 - 控制边界
for (int j = 0; j < n - 1 - i; j++) {
if (a[j] > a[j + 1]) {
exchange = false;
Swap(&a[j], &a[j + 1]);
}
}
if (exchange) {
break;
}
}
//2.
int end = n - 1;
while (end > 0) {
bool exchange = true;
for (int j = 0; j < end; j++) {
if (a[j] > a[j + 1]) {
exchange = false;
Swap(&a[j], &a[j + 1]);
}
}
if (exchange) {
break;
}
--end;
}
}
通过加上exchange
的判断条件优化,单趟排序结束后如果一次交换都没发生,就代表数组已经有序,就可以停止循环。
复杂度分析
冒泡排序的时间复杂度为 O ( n 2 ) O(n^2) O(n2) ,其最好情况即数组顺序有序的情况下,时间复杂度为 O ( n ) O(n) O(n)。
插排选择冒泡比较
首先,选择排序的时间复杂度无论在什么情况下都是 O ( n 2 ) O(n^2) O(n2),所以选择排序的效率是最差的。其次,直接插排和冒泡排序在最坏情况下也都是 O ( n 2 ) O(n^2) O(n2),所以应对比一下二者的一般情况下的表现:
- 当数组顺序有序时,二者都是遍历一遍数组, O ( n ) O(n) O(n)。
- 当数组接近有序时,假设为 $ 1 \quad2\quad3\quad4\quad6\quad5$ ,此时二者的排序原理不同就体现出优劣了:
- 冒泡排序每趟都要从头开始比较到最终位置,而插入排序只会从无序的部分开始向前比较。冒泡排序相当于从后向前排,插入排序相当于从前向后排。
当存在一个很长的前半部分有序后半部分无序的数组时二者的区别就体现出来了。