万字细啄常见排序算法
前言:各位老铁好,今天笔者来分享数据结构中的排序,排序在我们实际生活中应用非常广泛,例如我们逛各种购物软件时,都会对商品进行排序,按销量,按价格等等,由此可见排序算法的重要性,所以笔者今天来分享我们在常用的排序算法,再讲有关排序算法的内容之前,笔者会先讲几个相关的概念,为后面理解排序算法做铺垫。
1.排序有关概念铺垫
(1)排序:何为排序,通俗点讲就是一串记录按照某个关键字大小进行递增或递减排序。
(2)稳定性:如果一个排序算法能够保证,相同的数字的顺序在排序前后都一样,那么这个排序算法就是稳定的。
(3)内部排序;数据元素全部在内存中的排序
(4)外部排序:数据元素太多不能同时放入内存中,根据排序过程之间的要求不能在内外存之间移动数据的排序。
2.常见排序算法
插入排序:
(1)直接插入排序算法的思路很简单,就是维护一个有序的序列,将每一个元素插入到这个维护好的有序序列中,直到数据元素插完。像下面这种场景


到这里我们已经懂得了插入排序算法的思路,那么我们如何实现它呢???
#include <iostream>
#include <vector>
using namespace std;//默认[0,end]区间内是有序的
void InsertSort(vector<int>& v)
{int end = 0;for (int i = 0; i<v.size()-1; i++)//只需要排序v.size()-2个元素{end = i;int tmp = v[end+1];//把这个元素保存起来,防止被覆盖while (end>=0){if (tmp < v[end]){v[end + 1] = v[end];//将该元素完后移动,空出位置end--;}else{break;//如果比有序序列中最后一个元素大,直接放在有序序列末尾}}v[end + 1] = tmp;//将目标元素插入到有序序列中}
}int main()
{vector<int> v{5, 3, 4, 1, 2};InsertSort(v);for (int i = 0; i < v.size(); i++){cout << v[i] << " ";}cout << endl;return 0;
}
我们来运行一下代码吧,看看能否正确排序

由于插入排序算法遇到相等元素并不会移动元素,所以相等元素在有序序列中保持着和原来一样的顺序,那么这个插入排序算法就是稳定的。
直接插入排序特性总结

(2)希尔排序:假设有n个数,一般是通过n/2得到一个gap值,通过gap值来划分出若干个组,每一个组都进行排序,不断重复以上步骤,当gap==1时,所有数据就可以有序了。


希尔排序特性总结:
希尔排序是在直接插入排序的基础上优化的,我们知道当数据越接近有序,那么直接插入排序的效率越高,所以希尔排序在gap>1之前都是预排序,目的就是为了让数据接近有序,从而提高效率。
通过过程图我们可以看出,希尔排序不是稳定的了。
这里提一嘴,希尔排序的时间复杂度差不多在o(n*1.3)
到这里,我们就懂得希尔排序的思路了,那么接下来就是如何实现了。
//希尔排序是在直接插入排序基础上进化而来的
//直接插入排序的gap==1
void ShellSort(vector<int>& v)
{int gap = v.size();while (gap > 1)//防止gap==0情况出现{gap = gap / 2;for (int i = 0; i < v.size() - gap; i++){int end = i;int tmp = v[end + gap];while (end >= 0){if (tmp < v[end]){v[end + gap] = v[end];end -= gap;}else{break;}}v[end + gap] = tmp;}}
}int main()
{vector<int> v{5, 3, 4, 1, 2};ShellSort(v);for (int i = 0; i < v.size(); i++){cout << v[i] << " ";}cout << endl;return 0;
}
看看是否能正确将数据进行排序

选择排序
选择排序的思想也很简单,就是每一次从待排的数据元素中找出最小(或最大)的元素,插入到有序序列的起始位置,直到所有元素排完才停止。
选择排序的算法又分为两种,一种是直接选择排序,一种是堆排序。
(1)直接选择排序
直接选择排序的思路很简单,直接看下面画图就理解了。

由于第一个元素需要检查n次,第二个元素需要检查n-1次,由此递推下去,直接选择排序的时间复杂度是o(n^2),空间复杂度是o(1),由于直接选择排序的时间复杂度较高,所以在实际中并不常用,
由于直接选择选择排序会交换数据元素的位置,可能会出现导致和未排序之前的元素顺序一样,所以直接选择排序是不稳定的!!!
由于实际中不常用,那么笔者也不实现该算法的代码了,感兴趣的老铁可以自己实现一下。
(2)堆排序
堆排序顾名思义就是使用堆这种数据结构所设计的一种排序,堆排序也是一种悬着排序,但是需要注意当我们要进行升序排序时,要建大堆,降序排序时,要建小堆(这里不懂得,可以去看堆那篇文章)

看完上面得图示,相信各位老铁对排序的原理已经懂得了,那么接下来我们就开始实现一个堆排序了,我们看到图中(以升序进行举例),每次当我们交换完节点时,总会和孩子节点进行比较,如果比较当前根节点<孩子节点,那么就需要向下调整,既然使用到了向下调整,那么我们就得自己实现一个向下调整算法。
//向下调整算法
//向下调整算法
void AdjustDown(vector<int>& v, int parent,int n)//加入n,是因为堆的大小回变化
{//1.先算出孩子节点下标int child = parent * 2 + 1;while (child < n){//2.让child指向孩子节点中大的那个if (child + 1 < n && v[child + 1] > v[child])++child;//3.如果孩子节点大于父节点,那么继续向下调整//	否则调整结束if (v[child] > v[parent]){swap(v[child], v[parent]);parent = child;child = parent * 2 + 1;}elsebreak;}
}
那么接下来就要实现堆排序了。
void HeapSort(vector<int>& v)
{//升序:建大堆(对这部分感到疑惑的可以去看堆那篇文章)int n = v.size();for (int i = (n - 1 - 1) / 2; i >= 0; i--){AdjustDown(v, i,n);}//堆排序int end = n - 1;while (end > 0)//最后的根节点会自动有序{swap(v[end], v[0]);AdjustDown(v, 0,end);end--;}
}
那么接下俩测试一下我们的堆排序写的是否正确
void PrintArray(vector<int>& v)
{for (int i = 0; i < v.size(); ++i){printf("%d ", v[i]);}printf("\n");
}void TestHeapSort()
{vector<int> v = { 3,5,1,6,2,3,7,9,0,8 };PrintArray(v);HeapSort(v);PrintArray(v);
}int main()
{TestHeapSort();return 0;
}

到这里我们的堆排序已经实现完了,那么我们来分析一下堆排序的时间复杂度和空间复杂度和稳定性。
时间复杂度:通过遍历每一个节点进行建堆o(n),又需要给堆中每一个节点进行交换和向下调整,那么时间复杂度是o(n*logn)
空间复杂度:o(1)
稳定性:不稳定(不理解的老铁,自己画一遍过程图就理解了)
交换排序:交换排序通俗点讲就是通过序列中的两个值的比较结果进行交换这两个值的位置
(1)冒泡排序
现在假设我们要进行升序排序

看完图应该就可以理解冒泡排序的思路了,冒泡排序相对比较简单,笔者就不过多讲解,直接进行实现了。
void BubbleSort(vector<int>& v)
{for (int i = 0; i < v.size(); i++)//控制比较的元素个数{bool flag = false;for (int j = 1; j < v.size() - i; j++)//控制比较趟数{if (v[j - 1] > v[j]){swap(v[j - 1], v[j]);flag = true;}}//当遍历完一圈没有进行交换时,证明数组已经有序if (!flag)break;}
}
老规矩,测试一下
void TestBubbleSort()
{vector<int> v = { 9, 1, 2, 5, 7, 4, 8, 6, 3, 5 };PrintArray(v);BubbleSort(v);PrintArray(v);
}int main()
{TestBubbleSort();return 0;
}

从代码中我们可以看到冒泡排序是一个时间复杂度很高的排序,它最坏的情况下可以达到o(n*n),最好的情况下是o(n),没有使用额外的空间,所以空间复杂度是o(1),由于冒泡排序没有相同元素的原有的顺序,所以冒泡排序是稳定的。
(2)快速排序:任选序列中某一个元素作为key值,用该key值将序列分为两个序列,左序列的所有的值均小于该key值,右序列的所有值均大于该key值,不断的重复,知道所有的元素都在相应的位置上。
那么接下来我们就需要明白如何去寻找这个key值,如何使用这个key去分隔序列,将序列变成有序
hoare版本(不是标准的hoare方法)
什么是hoare的分隔法(不是标准的hoare方法)呢?简单来讲就是将序列中的第一个元素作为key值进行划分区间,定义一个左指针**(使用下标进行模拟)和一个右指针(使用下标进行模拟)**,左指针去寻找序列中比key值大的元素,右指针去寻找序列中比key值小的元素,然后将两个元素进行交换,然后左指针不断往后去寻找,右指针不断往前去寻找,当左指针>=右指针时,再将key下标的元素和右指针下标的元素进行交换

挖坑法
挖坑法和hoare方法很类似,挖坑法只是将第一个元素存到临时变量key中,然后也是定义一个左指针和一个右指针,先让右指针进行往前遍历寻找比key值小的元素,将该元素放入到坑位中,而该元素之前的位置就变成了新的坑位了,然后再让左指针往后进行遍历,寻找比key值大的元素,将该元素放入坑位中,该元素之前的位置也变成的新的坑位,不断的循环往复,直到左右指针相等,那么就将key值放到右指针指向的坑位中。

前后指针法
前后指针法也是key指向第一个元素,定义两个指针,一个指针是prev,一个指针是cur,prev指针初始为0,cur指向prev指针的下一个,然后讲cur指针指向的内容和key值进行比较,如果cur值<key值,那么先将prev++,再将cur指向的内容和prev指向的内容进行交换,cur不断的往后走,直到超过序列的长度,最后再将prev指向的内容和key指向的内容进行交换,那么就让key的左边序列都是小于key,key的右边序列都是大于key

到这里我们已经懂得了快速排序的思路了,那么接下来我们就需要用代码来实现快速排序了,笔者会将上面的方法都使用代码实现一遍。
hoare版本
#include <algorithm>
void QuickSort1(vector<int>& v, int left, int right)
{if (left >= right){return;}int index = left;int begin = left;int end = right;while (left < right){//不能先移动左指针,会导致左指针越过右指针,导致最后交换时,将key值和一个大于key值的元素进行交换while (left < right && v[right] >= v[index]){--right;}while (left < right && v[left] <= v[index]){++left;}swap(v[left], v[right]);}//将最后遍历的元素和key值进行交换swap(v[begin], v[left]);//递归分别处理左右子序列QuickSort1(v, begin, left-1);QuickSort1(v, left + 1, end);
}
测试一下,看看写的有没有问题
void PrintArray(vector<int>& v)
{for (int i = 0; i < v.size(); ++i){printf("%d ", v[i]);}printf("\n");
}void TestBubbleSort()
{vector<int> v = { 9, 1, 2, 5, 7, 4, 8, 6, 3, 5 };PrintArray(v);QuickSort1(v, 0, v.size() - 1);PrintArray(v);
}

挖坑法
void QuickSort2(vector<int>& v, int left, int right)
{if (left >= right){return;}int key = v[left];int begin = left;int end = right;while (left < right){while (left < right && v[right] >= key){--right;}//填充左坑,形成右坑v[left] = v[right];while(left<right&&v[left]<=key){++left;}//填充右坑,形成左坑v[right] = v[left];}//填充最后一个坑位v[right] = key;QuickSort2(v, begin, left - 1);QuickSort2(v, left + 1, end);
}
测试

前后指针法
void QuickSort3(vector<int>& v, int left, int right)
{if (left >= right){return;}int key = v[left];int begin = left;int end = right;int prev = left;int cur = left + 1;while (cur <= right){if (v[cur] < key){++prev;swap(v[prev], v[cur]);}cur++;}//交换prev指向的值和key值(key只是局部变量,使用key进行交换不能改变数组中的值)swap(v[left], v[prev]);//递归处理两个子区间QuickSort3(v, begin, prev - 1);QuickSort3(v, prev + 1, end);
}
看看运行结果是否正确

到这里快速排序的基本实现已经差不多了,那么接下来就分析一下快速排序的时间复杂度和空间复杂度吧。

快速排序是通过分治的思想将区间进行分开处理,那么我们可以在逻辑上给它抽象二叉树,但是快速排序在每一层的节点都有n个节点进行排序,那么快速排序的时间复杂度是每层的时间层级数,也就是lognn,所有快速排序的时间复杂度是o(logn*n),由于快速排序使用递归,调用了栈帧,那么快速排序的空间复杂度为o(n)
快速排序在排序过程中会改变元素在序列中的原始顺序,所有快速排序不是稳定的。
除了上面的几种选取key值的方法,快速排序还可以在上面方法的基础上进行优化,那么如何优化呢?
使用三数取中的方法进行优化
三数取中:三数取中简单来讲就是在待排数组中的 左端,右端和中见取三个元素,然后再取它们的中位数作为key值,这样可以避免了key值会取到最大值/最小值,降低最坏的情况出现的概率。
那么笔者就实现一下三数取中的代码吧
int GetMidNumi(vector<int>& v, int left, int right)
{int mid = left + (right - left) / 2;//防止溢出if (v[left] < v[mid]){if (v[mid] < v[right]){return mid;}else if (v[left] < v[right]){return left;}else{return right;}}else{if (v[right] < v[mid]){return mid;}else if (v[left] < v[right]){return left;}else{return right;}}
}然后我们就可以在快排函数中调用三数取中进行优化了。
int GetMidNumi(vector<int>& v, int left, int right)
{int mid = left + (right - left) / 2;//防止溢出if (v[left] < v[mid]){if (v[mid] < v[right]){return mid;}else if (v[left] < v[right]){return left;}else{return right;}}else{if (v[right] < v[mid]){return mid;}else if (v[left] < v[right]){return left;}else{return right;}}
}void QuickSort3(vector<int>& v, int left, int right)
{if (left >= right){return;}int midIndex = GetMidNumi(v, left, right);//三数取中if (midIndex!=left){swap(v[midIndex], v[left]);}int key = v[left];int begin = left;int end = right;int prev = left;int cur = left + 1;while (cur <= right){if (v[cur] < key){++prev;swap(v[prev], v[cur]);}cur++;}//交换prev指向的值和key值(key只是局部变量,使用key进行交换不能改变数组中的值)swap(v[left], v[prev]);//递归处理两个子区间QuickSort3(v, begin, prev - 1);QuickSort3(v, prev + 1, end);
}
快速排序当递归到小区间时,可以考虑直接使用插入排序,这个也是一个优化快速排序的方法。
归并排序
归并排序是利用分治的思想,将区间不断的进行分割,然后对每一个子数组进行排序,再将每一个有序的数组进行合并,最后合并成一个有序的序列

相信各位老铁已经懂得了归并排序的思路了,那么接下来就和笔者一起实现一个归并排序吧,那么如何实现归并排序呢?
我们先需要不断递归遍历数组,将数组不断切分成小区间,然后将小区间的小的元素先插入到新的数组中。
void MergeSort(vector<int>& v, int begin, int end, vector<int>& result)
{if (begin >= end){return;}int mid = (begin + end) / 2;//递归切分出子区间MergeSort(v, begin, mid, result);MergeSort(v, mid + 1, end, result);int begin1 = begin, end1 = mid;int begin2 = mid + 1, end2 = end;int index = begin;//指向新数组的下标//遍历子区间while (begin1 <= end1 && begin2 <= end2){if (v[begin1] < v[begin2]){result[index++] = v[begin1++];}else{result[index++] = v[begin2++];}}//将剩下的区间的元素插入到新数组的末尾while (begin1 <= end1){result[index++] = v[begin1++];}while (begin2 <= end2){result[index++] = v[begin2++];}//将临时数组result拷贝回原数组vfor (int i = begin; i <= end; i++){v[i] = result[i];}
}
看看代码运行结果是否正确

那么归并排序的时间复杂度和空间复杂度是多少呢?
由于归并排序使用了额外的数组,所以归并排序的空间复杂度是o(N)
由于归并排序也是利用了分治的思想,所以在逻辑上我们也可以将归并排序看成二叉树,那么归并排序的时间复杂度=层数每一个层的时间,也就是o(lognn)
归并排序在排序过程中并不会改变元素原有的顺序,所以归并排序是稳定的。
计数排序
计数排序的思路是先统计元素在数组中出现的次数,将元素按照出现的次数插入到新的数组中。
计数排序主要是用在数据范围集中的时,这样计数排序的效率才高
既然懂得了计数排序的思路了,那么就来实现一下计数排序吧
void CountSort(vector<int>& v)
{//先统计出最大值和最小值(需要清楚数组中的元素的映射范围)int max = v[0], min = v[0];for (int i = 1; i < v.size(); i++){if (v[i] > max){max = v[i];}if (v[i] < min){min = v[i];}}int Size = max - min + 1;//开辟统计元素出现次数的数组vector<int> count(Size, 0);//统计元素出现的次数for (int num : v){count[num - min]++;}//将统计的元素按照次数插回原数组中int index = 0;for (int i = 0; i < Size; i++){while (count[i]--){v[index++] = i + min;}}}
看看能否对数组进行正确排序

计数排序的空间复杂度是o(数组的范围)
时间复杂度为o(max(n,数组的范围))
计数排序是稳定的
总结

