排序是一种非常重要的基础算法,在校招和工作中都非常的实用,它在日常生活中无处不再。本章将介绍八大基本排序。
所谓排序,就是将一串记录按照某种递增递减的关系,使该记录成为一个有序的序列。常见并实用的排序有如下八种。
//直接插排
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);
直接插入排序是最简单的插入排序,例如摸扑克牌时,需要将摸到手的牌按照一定的顺序插入到已有的牌中,这里便是运用了直接插排的思想。
插入排序的定义:把待排的元素按规则逐个插入到一个有序序列中,直到所有元素插完为止,得到一个新的有序序列。
首先有一个有序数组,此外有一个元素待插入到该数组中,这样只要不满足比较条件就将下标所指元素向后移动一位,直到满足比较条件或下标越界出数组时退出循环,将待插元素插入下标的下一个位置。
//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;
}
单趟排序“只能将一个元素有序”,即将 x x x 插入到 [ 0 , e n d ] [0,end] [0,end] 的有序区间。
排序是要将所有元素都按顺序排序,而想实现这样的排序,只能一个一个元素排。所以排序一般分为内外两层循环,内循环调整元素未知,而外循环决定调整哪个元素。
故只要加一个外循环,控制标识数组尾部的下标 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] 并完成排序。
将问题分解,可以更简单快速的解决问题。
最好情况即数组顺序有序或接近有序但仍需遍历一边,时间复杂度为 O ( n ) O(n) O(n),最坏情况为数组逆序所有元素都要调整,时间复杂度为 O ( n 2 ) O(n^2) O(n2) 。
外循环循环 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) 。
直接插排对于数组接近有序的情况显得非常的快,而在数组完全逆序的情况下显得非常慢。
直接插排在接近有序是时效率极高,但其他情况下的表现却不尽人意,故Shell想出希尔排序来优化直接插排。在直接插排的前面进行预排序使其性能得到质的飞跃。Shell在计算机中和Linus一样是个流芳百世的名字。
思想上对直接插排进行优化,既然直接插入排序在数组接近有序的情况下效率极高,那就让数组先预排序,使其变得接近有序再去直接插排。这就有点递归分治的思想,这预排序里面就包含了希尔伟大的思想。
gap
分组,对分组值进行插入排序;假设gap为3,则每三个元素分成一组,将一组的元素看出一个数组,忽略原本数组中的其他元素。
4,5,2,0为一组,3,1,8为一组,6,7,9为一组。gap的值同时也是组的个数。
将每个所分的组看出一个数组,对每一个这样的数组进行直接插排,过程如图所示:
预排序所得结果虽不是完全有序,但相对于原数组确实是更加有序的。
- 由于直接插入排序对逆序数组的效果差,但若加上预排序的话效果相较于直接插排会非常好。
- 对于预排序来说,
gap
越大,效果越差,但时间消耗越小;而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;
}
}
}
}
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;
}
}
}
简化了控制多组的循环,仅用
i
控制下标end。从原来的一组一组排,变成了多组一起排,先排第一组的第一对元素,再排第二组的第一对,第三组的第一对;第一组的第二对,第二组的第二对,第三组的第二对,……,直到排完。
希排的时间复杂度分析较为复杂,下面简单推理一下。实现间距为gap的多组预排,最好情况下仅遍历一遍数组,时间复杂度为 O ( n ) O(n) O(n)。
最坏情况下的复杂度分析较为复杂,简单推理一下:假设数组有 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)。
虽然希尔排序用的都是直接插入排序,但希尔排序的精髓在于其思想非常的难得,这使得排序的复杂度得到质的优化。
 
选择排序是所有排序中最简单的排序之一。
再重复上述步骤直到选完为止,当然遍历数组选最值时虽可只选一个但为降低时间消耗最好选两个。
简单版:一次找一个极值。
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),所以直接选择排序的效果比较差。
堆排序要先把数组建成堆再进行堆排序,建堆用向下调整算法,时间复杂度为 O ( n ) O(n) O(n)。建堆最好选择排升序建大堆,排降序建小堆。
数组从逻辑结构上看是完全二叉树,但并不一定是堆。所以我们需要先将数组建成堆,然后才能再进行堆排序。若将数组建成小堆,取堆顶就是最小的数,选出次小的数就要删除首元素从第二个元素位置开始破坏堆的结构重新建堆。
将数组首尾元素互换,堆顶最大数就被放在了数组末尾,尾指针减1,再向下调整把次大的数调整到堆顶,再首尾互换,次大的数就放在了后面。依次类推一直循环,直到尾指针指向数组首元素,就排完了整个数组。
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),可见堆排和希尔排序属于同一级别的排序。
相邻两数进行比较,满足交换条件就交换。假设最坏情况下逆序数组有 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 个位置。
共排序 n − 1 n-1 n−1 趟,每趟排序比上一趟少比较 1 1 1 个数,共将 n − 1 n-1 n−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),所以应对比一下二者的一般情况下的表现:
冒泡排序需要两趟排序,第一趟从1到5比较 n − 1 n-1 n−1 次并交换了5和6,第二趟从1到5比较 n − 2 n-2 n−2 次,共比较了 n − 1 + n − 2 n-1+n-2 n−1+n−2 次。插入排序共排序5趟,前4趟每趟比较1次共4次,第5趟时6和5比较一次并移动然后4和5比较一次,一共比较了 n − 2 + 2 = n n-2+2=n n−2+2=n 次。
每个方格表示一个数组元素,当呈现橘红色表示该元素已经排到了正确的位置上,也就是该趟排序已结束。
- 从箭头的长度可以看出,冒泡排序每趟排序都是从头遍历到后才完成一趟排序,就算前半部分呈有序状之后每趟同样会再遍历一遍。
- 插入排序时从前向后排序,当数组接近有序时每趟排序只会比较2次,而且之后每趟排序不会重复遍历之前已排完的有序部分。
当存在一个很长的前半部分有序后半部分无序的数组时二者的区别就体现出来了。