1.直接插入排序
排序思想:在一个有序数组中插入一个新的值,使该数组依然有序。
咱们平时玩扑克牌,咱们摸牌的时候就是插入排序的思想。
代码:
void InsertSort(vector<int>& arr) {
int n = arr.size();
for (int i = 0; i < n - 1; i++) {
int end = i;
int tmp = arr[end + 1];
while (end >= 0) {
if (tmp < arr[end]) {
arr[end + 1] = arr[end];
end--;
}
else break;
}
arr[end + 1] = tmp;
}
}
1.1复杂度分析:
时间复杂度:O(n^2)。
不要觉得时间复杂度是n^2就瞧不起插入排序,其实它要比冒泡排序优秀得多。
上面是咱们实现升序得代码,它仅仅是在排降序的数组时,复杂度是n^2。其他情况都要优于n^2。
当排一个升序数组时,时间复杂度降为O(n)。
所以,当插入排序排一个十分接近有序的数组(只有个别数据是无序的,比如调换位置),时间复杂度趋于O(n),此时优于n*logn的排序算法!
1.2稳定性分析:
稳定性分析:
分析代码逻辑,我们发现:在排序前和排序后,数值相等的数据不会改变它们之间的前后顺序,所以插入排序是稳定的!
2.希尔排序
排序思想:在插入排序时说过,在插入排序排一个接近有序的数组时效率是非常高的,要优于n*logn的算法。那如何将一个乱序的数组变得接近有序呢?我们需要将该数组的数据等分成gap组,gap代表同组数据的下标之差。分好组后,用插入排序对每组数据排一遍序,这样数组就接近有序了(这个过程称为预排序),最后对整个数组再来一遍插入排序就OK了!这个排序过程就是希尔排序!
代码:
void ShellSort(vector<int>& arr) {
int n = arr.size();
int gap = n;
while (gap > 1) {
gap = gap / 3 + 1;//保证最后一次gap=1;
for (int i = 0; i < n - gap; i++) {
int end = i;
int tmp = arr[end + gap];
while (end >= 0) {
if (tmp < arr[end]) {
arr[end + gap] = arr[end];
end-=gap;
}
else break;
}
arr[end + gap] = tmp;
}
}
}
至于gap如何取值,官方给出了比较好的方式就是上述代码采用的方式,但不一定是最好的,最优的方式一定是随着数据量的变化而变化的。
因为gap越大,代表着预排序结束,数组越无序。gap越小,比如gap接近1,就直接接近插入排序了,那么优化的效果就越低。
2.1复杂度分析
时间复杂度:希尔排序的时间复杂度难以计算。比较权威的教材上给出:当n在特定范围内时间复杂度约为O(n^1.3),当n->无穷时,时间复杂度可以降低到n*(logn)^2。也就是说它的效率要比n*logn的排序算法稍低一些。
2.2稳定性分析
在希尔排序的预排序过程中,数值相等的数据可能会改变它们之间的前后顺序,所以希尔排序是不稳定的!
2.3希尔排序&插入排序性能测试
测试代码:
void TestOP() {
srand(time(nullptr));
vector<int> arr1(100000);
vector<int> arr2(100000);
vector<int> arr3(100000);
int n = 100000;
for (int i = 0; i < n; i++) {
int j = rand();
arr1[i] = j;
arr2[i] = j;
arr3[i] = j;
}
int begin1 = clock();
//BubbleSort(arr1);
int end1 = clock();
int begin2 = clock();
InsertSort(arr2);
int end2 = clock();
int begin3 = clock();
ShellSort(arr3);
int end3 = clock();
cout << "ShellSort:" << (end3 - begin3) << endl;
cout << "InsertSort:" << (end2 - begin2) << endl;
//cout << "BubbleSort:" << (end1 - begin1) << endl;
}
int main() {
TestOP();
return 0;
}
在release版本下:十万个随机数,希尔排序耗时:7毫秒。插入排序耗时:885毫秒。
虽然希尔排序的思路比较麻烦,但它的效率是非常高的!它和插入排序,冒泡排序已经不是一个量级的算法了,它已经可以和n*logn的算法一较高下了!
3.选择排序
排序思路:假如要升序排列。先遍历数组,找到最小的数,和下标为0的数交换,再次遍历数组,找到次小的数,和下标为1的数交换。依次类推。。。
代码:
void SelectSort(vector<int>& arr) {
int n = arr.size();
int begin = 0, end = n - 1;
while (begin < end) {
int min = begin, max = begin;
for (int i = begin + 1; i <= end; i++) {
if (arr[i] < arr[min]) min = i;
if (arr[i] > arr[max]) max = i;
}
swap(arr[begin], arr[min]);
if (max == begin) max = min;
swap(arr[end], arr[max]);
begin++;
end--;
}
}
3.1复杂度分析
时间复杂度:O(n^2),且不论排有序还是无序的数组,永远都是O(n^2)。
3.2稳定性分析
分析代码逻辑,我们发现:在排序前和排序后,数值相等的数据不会改变它们之间的前后顺序,所以选择排序是稳定的!
3.3选择排序&冒泡排序性能对比
release版本下:十万个随机数,选择排序耗时:4140毫秒。冒泡排序耗时:11673毫秒。
选择排序效率大概是冒泡排序的三倍。
看来这两中排序算法的效率差距不大,属于同一个量级的算法。
当然在实践中,这两种排序没什么应用场景。较多应用于面试和教学。
4.堆排序
首先需要搞清楚什么是堆,堆的分类有哪些,如何建堆。我之前有一篇文章是专门介绍堆的,大家可以去看一看。
堆分为大堆和小堆。大堆的根结点最大,且每个父节点均大于子节点。小堆的根结点最小,且每个结点均小于子结点。
排序思想:如果要升序排列,则需要建大堆。让根结点与最后一个子结点交换,那么最大的数就排好了,再让交换后的堆向下调整,此时根结点就是次大的数,和倒数第二个子节点交换,那么次小的数就排好了,依此类推。。。
有的同学想问:升序排列可以建小堆嘛?
答:理论上讲是可以的!但这样做排序效率会变得极慢,没有实际运用价值!
因为建小堆的话,数组第一个数就是最小的,而我要升序排列,那么第一个数直接就排好了,不需要调整。我们必须从第二个数开始调整堆,但此时数据之间的关系全都乱套了,无法向上或向下调整恢复堆的特性,只能重新建堆,而建堆的代价是很大的,时间复杂度是O(n*logn)。这样每选出来一个数就要重新建一次堆。等排完整个数组,时间复杂度就达到了O((logn)*n^2)。这比冒泡排序还要慢!
4.1如何建堆
建堆一共有两种方式:向上调整建堆和向下调整建堆。
向上调整建堆的时间复杂度约为:O(n*logn)。
向下调整建堆的时间复杂度约为:O(n)。
向上调整建堆:
代码:
void AdjustUp(vector<int>& arr,int child) {
int parent = (child - 1) / 2;
while (child > 0) {
if (arr[child] > arr[parent]) swap(arr[child], arr[parent]);
else break;
child = parent;
parent = (parent - 1) / 2;
}
}
向上调整时,从第二层开始往下的层数都是需要调整的,层数越大,数据越多,调整的次数也越多。
向下调整建堆:
代码:
void AdjustDown(vector<int>& arr,int parent,int size) {
int child = parent * 2 + 1;
while (child < size) {
if (child + 1 < size && arr[child + 1] > arr[child]) child++;
if (arr[parent] < arr[child]) swap(arr[parent], arr[child]);
else break;
parent = child;
child = child * 2 + 1;
}
}
最后一层不需要调整,从倒数第二次开始往上,都需要调整。且越靠近最后一层,数据越多,调整的次数越少。越往上,数据越少,调整的次数越多。
通多对比两种建堆方式,我们发现向上调整只舍掉了第一层的数据,且只有一个。而向下调整舍掉的是最后一层的数据,如果是完全二次数的话就舍掉了整棵树差不多一半的数据。所以粗略的分析得出:向下调整优于向上调整。
我们也可以通过数学计算得出,每一项是一个等差乘等比,要用到错位相减法。
结论:向上调整时间复杂度:O(n*logn)。向下调整时间复杂度:O(n)。
所以堆排序采用向下调整建堆。
4.2堆排序代码
void AdjustDown(vector<int>& arr,int parent,int size) {
int child = parent * 2 + 1;
while (child < size) {
if (child + 1 < size && arr[child + 1] > arr[child]) child++;
if (arr[parent] < arr[child]) swap(arr[parent], arr[child]);
else break;
parent = child;
child = child * 2 + 1;
}
}
void HeapSort(vector<int>& arr) {
int n = arr.size();
for (int i = (n - 2) / 2; i >= 0; i--) {//向下调整建堆
AdjustDown(arr,i,n);
}
int end = n - 1;
while (end>0) {
swap(arr[0], arr[end]);
AdjustDown(arr, 0, end);
end--;
}
}
4.3时间复杂度分析
建堆:O(n),排序:O(n*logn)。总体时间复杂度为:O(n*logn)。
4.4稳定性分析
分析代码逻辑,我们发现:在排序前和排序后,数值相等的数据会改变它们之间的前后顺序,所以堆排序是不稳定的!
4.5向上调整建堆与向下调整建堆的方式进行堆排性能对比
如图:X86平台,release 版本下,一千万个随机数,采用向下调整的方式耗时697毫秒,采用向上调整的方式耗时786毫秒。差距虽然不太大,但还是有的。所以我们以后都用向下调整的方式进行堆排。
4.6堆排与希尔排序性能对比
十万个随机数,堆排比希尔排序快3毫秒。
一百万个随机数,堆排比希尔排序慢8毫秒。
我们前面分析的结果:堆排的性能应该比希尔排序略高一些,为什么这里堆排要慢一点呢?
那是因为rand()只能产生三万多个随机数,而我们却用它生成了一百万个数据,那么肯定有很多很多重复的数据。有很多重复的数据,数组就比较接近有序,这样希尔排序中预排序和最后一步的插入排序效率就更高。
而对于堆排序而言,特别是建堆这个过程,向下调整建堆最起码要循环n/2次,更何况还要涉及到父子结点的交换。所以较多的重复数据对堆排效率的提升并不明显!
还是一百万个数据,我们用rand()+i的方式减少重复数据,这时堆排就比希尔排序快不少。
5.冒泡排序
排序思想:先将最大的数冒到数组的最后,再将次大的数冒到数组的倒数第二个位置,依次类推。
代码:
void BubbleSort(vector<int>& arr) {
int n=arr.size();
for (int i = 0; i < n; i++) {
bool exchange = false;
for (int j = 0; j < n - i - 1; j++) {
if (arr[j] > arr[j + 1]) {
swap(arr[j], arr[j + 1]);
exchange = true;
}
}
if (exchange) break;
}
}
5.1复杂度分析
这是经过优化后的冒泡排序。
时间复杂度:O(n^2)。
在排升序数组时,时间复杂度度降为O(n)。
5.2稳定性分析
分析代码逻辑,我们发现:在排序前和排序后,数值相等的数据不会改变它们之间的前后顺序,所以冒泡排序是稳定的!
5.3冒泡排序与插入排序性能对比
单从时间复杂度上看,这两个排序算法好像差不多。但时间复杂度只是一个大致的衡量,忽略了很多细节,下面我们通过代码来细致分析两者的优劣:
测试代码:
void TestOP() {
srand(time(nullptr));
vector<int> arr1(100000);
vector<int> arr2(100000);
int n = 100000;
for (int i = 0; i < n; i++) {
int j = rand();
arr1[i] = j;
arr2[i] = j;
}
int begin1 = clock();
BubbleSort(arr1);
int end1 = clock();
int begin2 = clock();
InsertSort(arr2);
int end2 = clock();
cout << "InsertSort:" << (end2 - begin2) << endl;
cout << "BubbleSort:" << (end1 - begin1) << endl;
}
int main() {
TestOP();
return 0;
}
release版本下:十万个随机数,插入排序用时:1184毫秒。冒泡排序用时:11215毫秒。
孰优孰劣想必在大家心中已经有答案了。