本文用C++尽可能简洁得实现了10大经典必知必会的排序算法,并对其中的一些算法特点比如稳定性、时间复杂度等进行了一定的说明。
本文总结表如下,“其他备注”的具体内容在文章下有写出来。

他们的简洁代码实现和测试脚本如下(从小到大排序):
#include <stdio.h>
#include <vector>
#include <algorithm>
#include <unistd.h>
using namespace std;
// 辅助函数,顺序打印vector v中的数据
void Print(vector<int>& v){
for(int i=0;i<v.size()-1;i++){
printf("%d-",v[i]);
}
printf("%dn",v[v.size()-1]);
}
void bubble_sort(vector<int>& v){
int n = v.size();
for(int i=0;i<n-1;i++){ // 对n-1个数据进行冒泡放置
bool flag = false; // 该轮中是否还存在逆序对
for(int j=0;j<n-i-1;j++){
if(v[j]>v[j+1]){
swap(v[j],v[j+1]);
if(!flag) flag = true;
}
}
if(!flag) break;
}
}
void insert_sort(vector<int>& v){
int n = v.size();
for(int i=1;i<n;i++){ // 要被取出来插入到别处的元素的下标位置:从1~n-1
int elem = v[i], j=i-1;
for(j=i-1;j>=0;j--){ // 要比较的元素的id
if(v[j]>elem) v[j+1] = v[j];
else {break;}
}
v[j+1]=elem;
}
}
void select_sort(vector<int>& v){
int n = v.size();
for(int i=0;i<n;i++){ // 选择最小后的数后要放置的位置
int idx=i;
for(int j=i+1;j<n;j++){
if(v[j]<v[idx]) idx=j;
}
swap(v[i],v[idx]);
}
}
void shell_sort(vector<int>& v){
int n = v.size();
int step = n/2;
while(step>0){
// 对每组元素进行插入排序;等价于对step及以后的元素进行插入排序
for(int i=step;i<n;i++){
int elem=v[i], j=i-step;
for(j=i-step;j>=0;j-=step){
if(v[j]>elem) v[j+step] = v[j];
else {break;}
}
v[j+step] = elem;
}
step /= 2;
}
}
void merge_fn(vector<int>& v, int st, int ed){
if(st>=ed) return;
int mid= st + ((ed-st)>>1);
merge_fn(v, st, mid);
merge_fn(v, mid+1, ed);
// 合并排序后的结果,先申请一个和v[st,ed]等大的临时数组
vector<int> tmp_v(ed-st+1);
int i=st, j=mid+1, k=0;
while(i<=mid || j<=ed){
tmp_v[k++] = ((i<=mid && v[i]<v[j])||(j>ed))? v[i++] : v[j++];
}
// 重新赋值
for(int i=st;i<=ed;i++) v[i]=tmp_v[i-st];
}
void merge_sort(vector<int>& v){
merge_fn(v, 0, v.size()-1);
}
void quick_sort_fn(vector<int>& v, int l, int r){
// 递归出口
if(l>=r) return;
// 先遍历一次让pivot就位
int pivot = v[r];
int i=l, j=l; // i指向第一个大于等于pivot元素的位置(其实就是最后pivot要放置的位置)
// j用于遍历
for(;j<r;j++){
if(v[j]<pivot){
swap(v[i],v[j]);
i++; // 从而保持i往右都是大于等于pivot的元素
}
}
swap(v[r],v[i]); // 最终pivot元素就位,根据pivot被分成了两部分
// 然后再递归解决两边
quick_sort_fn(v, l, i-1);
quick_sort_fn(v, i+1, r);
}
void quick_sort(vector<int>& v){
quick_sort_fn(v, 0, v.size()-1);
}
void count_sort(vector<int>& v){
// 1. 产生计数表
// 1.1 找出最大值
int max_num = v[0];
for(int i=0;i<v.size();i++) max_num = max(max_num, v[i]);
// 1.2 生成计数表
vector<int> count_table(max_num+1, 0);
for(int i=0;i<v.size();i++) count_table[v[i]]++;
for(int i=1;i<count_table.size();i++) count_table[i] += count_table[i-1];
// 2. 从后往前遍历v,就位在临时数组中
vector<int> tmp_v(v.size());
for(int i=v.size()-1;i>=0;i--) tmp_v[--count_table[v[i]]]=v[i];
// 3. 将tmp_v赋值给v
for(int i=0;i<tmp_v.size();i++) v[i]=tmp_v[i];
}
void down_adjust(vector<int>& v, int n, int idx){
int max_idx = idx;
while(true){
int tmp_max_idx = max_idx;
int lch_idx = 2*max_idx+1, rch_idx = 2*max_idx+2;
if(lch_idx<n) max_idx = v[lch_idx]>v[max_idx]? lch_idx:max_idx;
if(rch_idx<n) max_idx = v[rch_idx]>v[max_idx]? rch_idx:max_idx;
if(max_idx==tmp_max_idx) break;
}
swap(v[max_idx],v[idx]);
return;
}
void build_big_heap(vector<int>& v){
// 从下标是(n-1)/2的元素到0的元素进行下调
int n=v.size();
for(int i=(n-1)/2;i>=0;i--){
down_adjust(v,n,i);
}
}
void heap_sort(vector<int>& v){
// 首先是建堆过程,O(n);然后是排序过程
// 由于排序过程我们希望是原地排序,因此要从最后开始就位元素;因此建立大顶堆。
build_big_heap(v); // 采用从下往上的建堆方式
// 每次把堆顶元素换入最后,然后对堆顶元素进行下调
int n = v.size();
for(int i=n-1;i>0;--i){
swap(v[0],v[i]);
down_adjust(v,i,0); // 总个数要变成i,不能再是n
}
}
int main(){
int a[6]={5,3,6,2,1,4};
vector<int> v(a,a+6);
Print(v); // 打印排序前的算法
// 排序算法
// bubble_sort(v);
// insert_sort(v);
// select_sort(v);
// shell_sort(v);
// merge_sort(v);
// quick_sort(v);
// count_sort(v);
// heap_sort(v);
Print(v); // 打印排序后的算法
return 0;
}
备注内容如下:
(说明:以下表述中的“就位”是指某个元素放到了最终从小到大排序结束后的位置)
- 冒泡排序
- 设置提前退出flag时,best time才是O(1);
- 相等时不交换才能保证稳定性;
- 交换次数=逆序对数=n*(n-1)/2-顺序对数;因此平均有序度就是O(n^2)。由于计算步骤最多的就是比较和交换,也因此平均时间复杂度就是O(n^2)
- 插入排序
- 插入排序是最像抓牌时的排序算法。
- 当要插入的元素和被比较的元素值相同的时候,要保证插在后面那么是稳定的算法;
- 虽然插入排序和冒泡排序从时空复杂度看是一样的,但是插入排序相对更快一些;冒泡 排序的主要操作是比较和元素交换,其中交换次数=逆序对数;插入排序的主要操作是比较和 移动,其中移动次数=逆序对数;但是交换需要进行三次赋值,移动只需要一次赋值;并且 冒泡排序的比较次数会比交换次数多很多次,但是插入排序的比较次数最多比移动次数多n次。
- 选择排序
- 最好复杂度也是O(n^2)的原因是总是要不断重复遍历去找剩下的元素里最小的值;
- 稳定性:不能确保稳定性。加入5,8,5,2,9;在第一次交换后,前一个5就到了第二个5的后面。
- 希尔排序
- 最好的时间复杂度即原来就有序,如果在第一个step的时候,通过flag记录没有发生元素移动,那么就直接退出循环,因此最好的时间复杂的是O(n);
- 最坏的时间复杂度和步长的选择有关,已经被证明的步长选择方法最坏的时间复杂度是O(n(logn)^2);
- 由于分成了几个组,所以可能发生不稳定的情况。比如有一些相同的元素,其中位置在最后的元素被分在第一组,那么他们之间的顺序就破坏了,因此不具备稳定性。
- 归并排序
- 时间复杂度的分析,T(n) = 2*T(n/2) + O(n) ;具体的时间时间复杂度参考网页:https://blog.csdn.net/so_geili/article/details/53444816 ;可以使用递推公式、递归树和主方法分析;
- 稳定性是可以做到的,只要合并的时候顺序不乱就可以做到稳定性的;
- 快速排序
- 最好的情况是每次都对半分,这样的话递归树分析复杂度时的高度是最低的;T(n)=2*T(n/2)+n;最坏的情况是每次都有一边没有元素,这样的话高度是n,所以最坏情况的t(n)=O(n^2);
- 不稳定的原因是在于,假如最后pivot该放的位置上的元素和pivot正好相等,那么交换后就是不稳定的;
- 虽然从时间复杂度上看,归并排序相比快排更好些,但是归并排序不是原地排序的,当数据量很大的时候就会有很多额外的空间开销,因此更多用的还是快排;
- 拓展:根据快排的关键代码,我们可以在O(n)的平均时间复杂度,O(1)的空间复杂度下找到第K大的元素。即先选一个pivot然后得到最终位置i后,i和K比较后,递归选择平均一半的区域重复上述操作。
- 桶排序
- 桶排序要求数据能轻易分成多个桶,并且要尽量均匀;比如用户的年龄排序;
- 桶排序的一个桶内数据采用快速排序等方式进行;因此稳定性也由桶内选择的排序算法的稳定性有关;
- 桶排序非常适合外部排序,每个桶可以存储为一个文件,最后的合并只需要顺序读取文件即可得到所有排序的结果;
- 计数排序
- 时间复杂度是O(max{n,k}),其中k是最大的元素值;具体原因看后面的代码就清楚了;顺提,计数排序是特殊的桶排序,即一个桶只放相同的数;
- 是稳定的方法, 因为算法中从后往前遍历和就位,因此是稳定的;
- 确定就位的方法是:从后往前遍历,假如遍历到x,查表知有y个元素小于等于x,那么x就位的下标就是y-1;然后更新y为y-1;继续遍历。具体可以参照下面的代码。
- 基数排序
- 例如对很多用户的手机进行排序;由于数据范围很大,不好找合适的桶数量;但是每位的数据范围很小(0-9),因此可以按照位进行桶排序,一共进行11次桶排序即可;
- 由于桶排序本身是不稳定的,因此为了确保基数排序效果的稳定,必须要从低位开始往高位排。
- 堆排序
- 由于其中发生了远距离的交换,因此是非稳定的;
- 堆排序分为两步,建堆+排序;建堆如果采用从下至上下调的方法可以实现O(n)的时间复杂度建堆;排序中为实现原地排序,必须从后往前就位元素,是O(nlogn)复杂度;因此总算法的时间复杂度是O(nlogn);
- 看起来比快排时间复杂度更好,并且也是原地排序;但是由于以下的原因所以实际中依然还是采用快排更多:
- CPU缓存没有快排访问友好;因为堆排序中访问数据不是连续的;
- 堆排序的交换次数大于快排;在建堆的过程中数据如果原来有序那么建堆后无序对数增加;快排中可以先随机选择一个数,然后与最后一个数交换后开始快排,整体上依然是有序状态,最终的数据交换次数少。