前言
本文整理了十大经典排序算法的代码以及复杂度分析。所有代码都是以升序为排序规则
冒泡排序
算法思想:每趟排序依次比较两个元素,如果第二个数小于第一个数,就把两个数做交换,当遍历完时,这趟排序的最大值就到数组最后的位置上了,接着对这趟排序的最大值左边的部分再进行下一趟排序,第 i 趟排序得到整个序列中的第 i 大数,做完 n - 1 趟排序后整个序列就是有序的了
void bubbleSort(int num[],int n){
int t;
//排序n - 1次
for(int i = 2;i <= n;i++){
int tmp = n - i;
//第一次排序时j最大要到n - 2,第二次时j要到n - 3...以此类推,最大要到n - i
//如果 i 是从1开始,到n-1,那tmp就应该等于n - i - 1
for(int j = 0;j <= tmp;j++){
if(num[j] > num[j + 1]){
t = num[j];num[j] = num[j + 1]; num[j + 1] = t;
}
}
}
}
优化
如果在一次循环中没有出现元素的交换,即某一次i = k(2 <= k <= n)时,在j的循环中都是num[j] < num[j + 1],那么说明数组中元素已经是升序,不需要再进行后续的循环操作了
void bubbleSort(int num[],int n){
int flag;
int t;
//排序n - 1次
for(int i = 2;i <= n;i++){
flag = 1;
int tmp = n - i;
for(int j = 0;j <= tmp;j++){
if(num[j] > num[j + 1]){
t = num[j];num[j] = num[j + 1]; num[j + 1] = t;
flag = 0;
}
}
//这一趟排序没有交换元素,说明数组已有序,直接结束算法
if(flag == 1) return;
}
}
时间复杂度:平均 O(n²);最好是遍历第一遍就发现是有序的,无需多次遍历,复杂度为 O(n)
空间复杂度:O(1),只需要两个整型变量
选择排序
void selectSort(int num[],int n){
//每次寻找i往后中最小的元素,然后将其与i下标对应元素交换位置
for(int i = 0;i < n - 1;i++){
//tmp维护单次查找到的最小值
int tmp = num[i];
//minIndex维护单次查找到的最小值的下标,用于最后交换元素
int minIndex = i;
for(int j = i + 1;j < n;j++){
if(num[j] < tmp){
minIndex = j;
tmp = num[j];
}
}
int t = num[i];
num[i] = num[minIndex];
num[minIndex] = t;
}
}
时间复杂度:平均 O(n²);由于不管当前的 nums[i] 是多少,每次都会遍历 num[i,…,n - 1],所以最坏复杂度跟最好复杂度也是 O(n2)
空间复杂度:只需要两个整型变量
不稳定性:考虑待排序序列 21 3 22 1 4,在第一轮选择时会把 1 与第一个 2 交换,得到1 3 22 21 4,后续两个 2 的相对位置都是保持这样,可见是不稳定的
桶排序
桶排序的第一种简单的实现是,根据待排序数组中的元素的数值范围,构建一个足够对应各个值的数组,比如数值范围是 [1,100],那就构建一个大小为 100 的临时数组,或称 100 个桶,第一个桶中存放数值为 1 的元素,第 100 个桶中存放数值为 100 的元素,从而每个桶中只会存放一种数值的所有元素
第一次遍历把待排序数组中所有元素按照数值大小放到对应的桶里。第二次只需从小到大遍历所有桶,按顺序把每个桶的元素取出来得到的就是排序好的序列了:
void bucketSort(int num[],int n){
int tmp[100] = {0}; //排序0到99数字
//相当于遍历数组,把每个数放进其对应的桶中,相当于计数
for(int i = 0;i < n;i++){
tmp[num[i]] += 1;
}
int index = -1;
//以数值从小到大遍历每个桶,桶内只要有元素就放入num数组
for(int i = 0;i < 100;i++){
while(tmp[i] > 0){
num[++index] = i;
tmp[i]--;
}
}
}
这种实现下,数组中的每个值都对应不一样的桶,每个桶中只会存储同个值的元素,所以我们无需对单个桶内的元素再去排序。但是如果待排序的数组中的 数值范围较大 且 比较离散 的话,就会需要创建很大的数组,且会出现很多桶中没有存放元素的空间浪费的情况,甚至范围太大的话直接就超出了编程语言所能创建的最大数组大小。
所以更具扩展性更健壮的做法是,根据待排序数组中的元素的数值范围,划分为多个区间,每个区间对应一个桶即可,我们可以根据数值范围进行灵活地拆分,使需要的桶数跟所需耗费的时间都处于合适的范围。这样的话,单个桶内就可能会存储不同数值的元素,那么在第一次把元素放进对应的桶时,还需要对这个桶进行排序,使桶内存放的元素保持有序,才能方便第二次对所有桶进行遍历时进行归并。这样的话,每次排序的复杂度为 O(n),那么最终的复杂度为O(n2)
时间复杂度:O(n),第一个for循环执行了n次,第二个for循环中的while语句中的 “tmp[i] > 0” 语句也是执行了n次(不包含当每次tmp[i]减为0时再执行的那一次,因为这与数组中的数有关,当数值大小越离散,如1,2,3,4,5…(集中,如1,1,1,1,1…),就会出现更多次 tmp[i] 减为0的时候)。所以是O(n + n),即O(n);最坏情况下是 O(n2)
空间复杂度:O(k),k为数组中数的大小范围,所以可以看出来,如果数组中的数的大小差距过大(如1到1000),那么就需要的空间就更大,越浪费
快速排序
void quickSortt(int num[],int l,int r){
if(l >= r) return;
int ll = l,rr = r;
//选择最左端元素为枢轴数 pivot
int tmp = num[l];
while(ll < rr){
//选择最左端元素为枢轴时,要先移动右指针
//因为先移动右指针的话,可以保证,如果此时ll跟rr之间都是大于tmp的数,那么rr经过循环后
//就会跟ll重叠,最终跳出最外层的大循环,那么由于是rr找到了ll,所以此时他们两个所在的数一定是小于tmp的,那么就可以顺理成章地将num[ll]/num[rr]跟num[l]交换
//反过来,如果是ll先移动,那么他可能会移动到与rr重叠,那么此时rr跟ll所在的数就是大于tmp的了,也就无法将tmp放到其所该在的位置了
while(ll < rr && num[rr] > tmp) rr--;
while(ll < rr && num[ll] < tmp) ll++;
if(ll < rr){
int t = num[ll];
num[ll] = num[rr];
num[rr] = t;
}
}
//单次排序的结果是基准数来到他在最终排序结果中应该处于的位置,然后基准数往左的数都小于它,往右的数都大于他
//注意,这意味着什么?意味着此时的 tmp 就是数组中第 ll 大的数
num[l] = num[ll];
num[ll] = tmp;
//对排序后的枢轴数左右两边递归进行同样的排序操作
quickSortt(num,l,ll - 1);
quickSortt(num,ll + 1,r);
}
void quickSort(int num[],int n){
quickSortt(num,0,n - 1);
}
时间复杂度:O(nlogn)
优化:枢轴随机化
上面的代码中每次排序都是盲目地选最左侧的数为单次排序的枢轴,如果枢轴的选取是随机的能提高整个算法的整体性能,是一个优化。具体做法就是在 [l,r] 中随机选取一个下标,然后将这个下标对应的数与 l 下标对应的数交换即可,这样接下来从形式上看还是以最左侧的数为枢轴进行排序,还是一样的代码:
void quickSortt(int num[],int l,int r){
if(l >= r) return;
int ll = l,rr = r;
int pivotIndex = randomIn(l,r); //不存在的函数,只是模拟生成[l,r]之间的一个随机数而已
int tmp = nums[l];
num[l] = num[pivotIndex];
num[pivotIndex] = tmp;
int pivot = nums[l];
while(ll < rr){
while(ll < rr && num[rr] > pivot) rr--;
while(ll < rr && num[ll] < pivot) ll++;
if(ll < rr){
int t = num[ll];
num[ll] = num[rr];
num[rr] = t;
}
}
num[l] = num[ll];
num[ll] = pivot;
quickSortt(num,l,ll - 1);
quickSortt(num,ll + 1,r);
}
void quickSort(int num[],int n){
quickSortt(num,0,n - 1);
}
处理含重复元素的快速排序
void QuickSort_Three(int a[],int low,int high){
if(low >= high){
return;
}
int lo = low,hi = high,i = lo + 1;
//以最左边的元素为基准数(枢轴)
int k = a[lo];
while(i <= hi){
while(i <= hi && a[i] > k){
int temp = a[i];
a[i] = a[hi];
a[hi] = temp;
//保证hi右边的所有数都大于枢轴
hi--;
if(i == hi){
break;
}
}
while(i <= hi && a[i] < k){
int temp = a[i];
a[i] = a[lo];
a[lo] = temp;
//保证lo左边的数都小于枢轴
lo++;
}
//当i移动到与hi相等的时候本次排序完毕,即
//lo左边的数都小于枢轴
//hi右边的所有数都大于枢轴
//[lo,hi]的数都等于枢轴
while(i <= hi && a[i] == k){
i++;
}
}
//类似于快速排序,对所有枢轴数所在区域的左右两部分递归处理
QuickSort_Three(a,low,lo - 1);
QuickSort_Three(a,hi + 1,high);
}
最坏情况
快排最坏的复杂度为 O(n2),对应的情况是整个数组本身就已经是有序的,那么每次右指针都会移动到最左边的枢轴处,即每次排序的复杂度是 O(n),那么 n 个枢轴,总的复杂度就是 O(n2)
堆排序
升序排序,用大顶堆
//堆的调整方法,n为堆的大小,由于下标从1开始,所以n也是最后一个元素的下标
//调整以pos为根的子树
void shift(int num[],int n,int pos){
while(pos <= n / 2){
int l = pos * 2;
int r = pos * 2 + 1;
if(r <= n && num[r] > num[l]) l = r;
//如果pos节点比左右子树都优先,那么不用调整,直接返回 (大顶堆
if(num[pos] > num[l]) return;
//否则就把pos节点跟较优先的子节点交换
int t = num[pos];
num[pos] = num[l];
num[l] = t;
//然后对被交换的节点所在的子堆进行筛选
pos = l;
}
}
/*堆排序,n为数组的大小。在堆中使用的是顺序存储结构,
所以节点下标从1开始更好,这样当前节点下标乘以2以及乘以2加一
就是左右儿子的下标,所以正式开始排序之前创建一个比待排序数组大1的数组作为堆,
0号下标空置,对其排序完成后再搬回到函数接收的数组中,返回*/
void heapSort(int a[],int n){
//创建堆所在数组
int num[n + 1];
num[0] = 0;
for(int i = 1;i <= n;i++){
num[i] = a[i - 1];
}
//建堆,即从 n / 2 开始往上遍历然后调整以每个节点为根的堆。因为对某个节点为根的子堆进行筛选要求左右子树均为筛选到的子堆,所以要从下网上筛选
for(int i = n / 2;i > 0;i--){
shift(num,n,i);
}
//排序,每次把堆顶元素放到堆最后一个位置上,然后堆大小减一,再对堆重新筛选
for(int i = 1;i <= n - 1;i++){
int t = num[1];
num[1] = num[n - i + 1];
num[n - i + 1] = t;
shift(num,n - i,1);
}
//排序后结果放回原数组
for(int i = 1;i <= n;i++){
a[i - 1] = num[i];
}
}
时间复杂度:O(nlogn),堆的高度为logn,每次筛选也是从底往上进行,即每次筛选复杂度为O(logn),加上外层循环那么总的复杂度为O(nlogn)
归并排序
//二路合并操作
void merge(int a[],int left,int mid,int right){
int tmp[right - left + 1];
//左部分的起始下标为left,右半部分起始下标为mid + 1
int i = left,j = mid + 1,k = -1;
while(i <= mid && j <= right){
if(a[i] <= a[j]) tmp[++k] = a[i++];
else tmp[++k] = a[j++];
}
//如果是左半部分合并完了就把右半部分剩下的全部合并到tmp数组中;右半部分同理
while(i <= mid) tmp[++k] = a[i++];
while(j <= right) tmp[++k] = a[j++];
//合并完把tmp数组拷贝回a数组。注意tmp数组不是malloc()函数分配空间的,不用free()
for(int i = 0;i <= k;i++){
a[left + i] = tmp[i];
}
}
//递归地数组拆分为两个子数组,直到数组只有一个元素;将两个子数组进行二路归并
void mergeSortDivide(int a[],int left,int right){
if(left >= right) return;
int mid = (left + right) / 2;
//[left,mid]分到左部分,[mid + 1,right]分到右部分
mergeSortDivide(a,left,mid);
mergeSortDivide(a,mid + 1,right);
//把两部分合并起来
merge(a,left,mid,right);
}
//==========调用函数=========================//
void mergeSort(int num[],int n){
mergeSortDivide(num,0,n - 1);
}
时间复杂度:O(nlogn)
空间复杂度:O(n),需要一个临时用于合并的数组
优化
merge 的时候是把排好序的 [left,mid] 以及 [mid + 1,right] 合并成新的 [left,right] 区间,那么如果 a[mid] <= a[mid + 1] 就说明 [left,right] 已经是排序好的了,不用再进行合并了。对 mergeSortDivide 做如下修改:
void mergeSortDivide(int a[],int left,int right){
if(left >= right) return;
int mid = (left + right) / 2;
mergeSortDivide(a,left,mid);
mergeSortDivide(a,mid + 1,right);
//增加的代码
if(a[mid] <= a[mid + 1]) return;
merge(a,left,mid,right);
}
插入排序
void insertSort(int num[],int n){
int tmp,j;
//每次循环将num[i]插入到它前面的序列中应该插入的位置
for(int i = 1;i < n;i++){
tmp = num[i];
for(j = i;j > 0 && num[j - 1] > tmp;j--){
num[j] = num[j - 1];
}
num[j] = tmp;
}
}
最好情况是原数组就是有序数组,这样在遍历每个元素时,它们一开始所处的位置就是正确的位置,不需要对其左边的元素进行任何移动,所以整个过程只需要遍历一遍数组即可,复杂度为 O(n)
最坏情况是原数组是完全逆序的,这样遍历每个元素时需要对其左边的所有元素都进行移动,每次移动的复杂度是 O(n),n 个移动,所以复杂度是 O(n2)
希尔排序
//使用增量dk进行直接插入排序(插入排序其实就是增量为1的时候的排序)
void shellInsert(int num[],int n,int dk){
int tmp,j;
for(int i = dk;i < n;i++){
tmp = num[i];
for(j = i;j - dk >= 0 && num[j - dk] > tmp;j -= dk){
num[j] = num[j - dk];
}
num[j] = tmp;
}
}
//=============调用函数==========//
//dk为增量序列,m为序列中的数的个数
void shellSort(int num[],int n,int dk[],int m){
//遍历使用增量序列中的每个增量进行插入排序
for(int i = 0;i < m;i++){
shellInsert(num,n,dk[i]);
}
}
计数排序
计数:计算数组中每一个数的个数,然后根据计数结果得出每个元素应该放的位置。如排序0到99的整数,计数得到0的个数为5,1的个数为6,那么1的存放位置下标应该是5到10
void countSort(int num[],int n){
int tmp[n];
//复制原数组
for(int i = 0;i < n;i++){
tmp[i] = num[i];
}
//计数数组
int count[100] = {0};
//计数
for(int i = 0;i < n;i++){
count[tmp[i]] += 1;
}
for(int i = 1;i < 100;i++){
count[i] = count[i] + count[i-1];
}
//根据计数结果将数放回到原数组
for(int i = 0;i < n;i++){
num[--count[tmp[i]]] = tmp[i];
}
}
时间复杂度:O(n + k),k为数组中数的大小范围
空间复杂度:O(n + k),一个用于复制的数组和一个用于计数的数组
基数排序
基数排序看起来很像是对每个数的每一位进行计数排序,最后合并起来
int radix = 10;//基数为10(0到9)
int digitNum = 2;//最多有2位数字
//获取number第i位上的数
int getDigitNum(int number,int i){
while(i > 1){
number /= 10;
i--;
}
return number % 10;
}
//完成对第i位的排序,从num1数组排序到num2数组中
void radixSortt(int num1[],int num2[],int n,int i){
int count[radix];
for(int j = 0;j < radix;j++) count[j] = 0;
for(int j = 0;j < n;j++){
count[getDigitNum(num1[j],i)] += 1;
}
for(int j = 1;j < radix;j++){
count[j] += count[j - 1];
}
//必须从右到左放置被排序数组的元素,因为对上一位的排序已经完成,不能破坏,而count[index]也是从大到小的
for(int j = n - 1;j >= 0;j--){
int index = getDigitNum(num1[j],i);
num2[--count[index]] = num1[j];
}
}
void radixSort(int num[],int n){
//用于放置排序后数据的临时数组,这里num2数组不是使用malloc()函数分配空间的
//最后不能用free()函数释放,包括上面的count数组
int num2[n];
int i = 1;
//对奇数位排序时从num数组排序到num2数组中
//对偶数位排序时从num2数组排序到num数组中
while(i <= digitNum){
if(i % 2 == 1) radixSortt(num,num2,n,i);
else radixSortt(num2,num,n,i);
i++;
}
//最大位数为奇数时,说明最后一次排序后数据是放在num2数组中,要拷贝回num数组
if(digitNum % 2 == 1){
for(int j = 0;j < n;j++) num[j] = num2[j];
}
}
时间复杂度:O(n * m),其中m为数组中的数的最大位数
空间复杂度:O(n + k)