前言:排序算法是最经典的算法知识,也是每个合格程序员应该需要掌握的知识点。考虑到排序算法通常代码简短,逻辑思维强和应用范围广等特性,排序算法题目便成为了面试中的常客。在面试中最常考的是快速排序和归并排序等基本的排序算法,并且经常要求现场手写基本的排序算法。作为嵌入式工程师,排序算法常常会出现在滤波算法和数据处理方面。故此,工程师需要熟练掌握其思想与代码。
下面介绍几种常见的排序算法:冒泡排序、选择排序、插入排序、归并排序、快速排序、希尔排序、堆排序、计数排序、桶排序、基数排序的思想,其代码均采用C语言实现。
算法效率
算法效率分析分为两种:第一种是时间效率,第二种是空间效率。时间效率被称为时间复杂度,而空间效率被称作空间复杂度。
时间复杂度的定义:在计算机科学中,算法的时间复杂度是一个函数,它定量描述了该算法的运行时间。常规情况下,算法的时间复杂度是需要在计算机上跑一下才能得到。可是上机测试是非常繁琐的,所以人们定义了时间复杂度这个分析方式。规定:算法所花费的时间与其中语句的执行次数成正比例,算法中的基本操作的执行次数,为算法的时间复杂度。
空间复杂度的定义:空间复杂度是对一个算法在运行过程中临时占用存储空间大小的量度。空间复杂度不是程序占用了多少字节的空间,因为这个也没太大意义,所以空间复杂度算的是变量的个数。空间复杂度计算规则基本跟时间复杂度类似,也使用大O渐进表示法。
排序算法中的时间复杂度和空间复杂度一般都使用大O的渐进表示法进行表示,大O的渐进表示法规则如下:
1、所有常数都用常数1表示。
2、只保留最高阶项。
3、如果最高阶项存在且不是1,则去除与这个项的系数,得到的结果就是大O阶。
算法效率详解博客:http://t.csdn.cn/DdQCx
1、冒泡排序
冒泡排序是一种简单的排序算法。它重复地走访过要排序的数列,一次比较两个元素,如果它们的顺序错误就把它们交换过来。走访数列的工作是重复地进行直到没有再需要交换,也就是说该数列已经排序完成。该排序算法之所以叫冒泡算法就是因为算法会每次把数值中最大的数值“浮动”到数组的最顶端,过程类似水中气泡的浮动。
1.1 算法描述
1、比较相邻的元素。如果第一个比第二个大,就交换它们两个;
2、对每一对相邻元素作同样的工作,从开始第一对到结尾的最后一对,这样在最后的元素应该会是最大的数;
3、针对所有的元素重复以上的步骤,除了最后一个;
4、重复步骤1~3,直到排序完成。
1.2 动图演示
1.3 算法实现
//冒泡排序算法
void BubbleSort(int arr[],int numSize)
{
int temp = 0; //交换的临时变量
for(int i = numSize-1; i > 0 ; i--) // 每次需要排序的长度
{
for(int j = 0; j < i; j++)
{
if(arr[j] > arr[j+1]) //保证较大的数值放在后面
{
temp = arr[j];
arr[j] = arr[j+1];
arr[j+1] = temp;
}
}
}
}
算法结果:
平均时间复杂度:O(n^2);空间复杂度:O(1);稳定性:稳定
适用场景:冒泡排序思路简单,代码也简单,特别适合小数据的排序。但是,由于算法复杂度较高,在数据量大的时候不适合使用。
2、选择排序
选择排序是一种简单直观的排序算法,它也是一种交换排序算法,和冒泡排序有一定的相似度,可以认为选择排序是冒泡排序的一种改进。核心本质:就是选择出数组中最小/大的数值进行归位。
2.1 算法描述
1、在未排序序列中找到最小(大)元素,存放到排序序列的起始位置
2、从剩余未排序元素中继续寻找最小(大)元素,然后放到已排序序列的末尾。
3、重复第二步,直到所有元素均排序完毕。
2.2 动图演示
2.3 算法实现
//选择排序算法
void SelectSort(int arr[],int numSize)
{
printf("选择排序算法\r\n");
int temp,min = 0;
for(int i = 0; i < numSize; i++)
{
min = i;
//寻找数组最小值
for(int j = i+1; j < numSize; j++)
{
if(arr[min] > arr[j])
{
min = j;
}
}
if(min != i)
{
temp = arr[i];
arr[i] = arr[min];
arr[min] = temp;
}
}
}
算法结果 :
平均时间复杂度:O(n^2);空间复杂度:O(1);稳定性:不稳定
适用场景:选择排序实现也比较简单,并且由于在各种情况下复杂度波动小,因此一般是优于冒泡排序的。在所有的完全交换排序中,选择排序也是比较不错的一种算法。但是,由于固有的O(n^2)复杂度,选择排序在海量数据面前显得力不从心。因此,它适用于简单数据排序。
3、插入排序
插入排序是一种简单直观的排序算法。它的工作原理是通过构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入。
3.1 算法描述
1、把待排序的数组分成已排序和未排序两部分,初始的时候把第一个元素认为是已排好序的。
2、从第二个元素开始,在已排好序的子数组中寻找到该元素合适的位置并插入该位置。
3、重复上述过程直到最后一个元素被插入有序子数组中。
3.2 动图演示
3.3 算法实现
//插入排序算法
void InsertSort(int arr[],int numSize)
{
for(int i=1; i < numSize; i++)
{
int value = arr[i];
int position=i;
while(position > 0 && arr[position-1] > value)
{
arr[position] = arr[position-1];
position--;
}
arr[position] = value;
}
}
算法结果:
平均时间复杂度:O(n^2);空间复杂度:O(1);稳定性:稳定
适用场景:插入排序由于O(n^2)的复杂度,在数组较大的时候不适用。但是,在数据比较少的时候,是一个不错的选择,一般做为快速排序的扩充。
4、归并排序
归并排序是建立在归并操作上的一种有效的排序算法。该算法是采用分治法的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为2-路归并。
4.1 算法描述
1、将序列每相邻两个数字进行归并操作,形成ceil(n/2)个序列,排序后每个序列包含两/一个元素
2、若此时序列数不是1个则将上述序列再次归并,形成ceil(n/4)个序列,每个序列包含四/三个元素
3、重复步骤2,直到所有元素排序完毕,即序列数为1
4.2 动图演示
特别注意:归并算法的核心是划分和归并,下方GIF仅为一种归并排序的措施(与本人写得代码有出入)。
代码的划分与归并流程图;
4.3 算法实现
//归并排序算法
void MergeSort(int arr[],int numSize)
{
//分配一个辅助数组
int *tempArr = (int *)malloc(numSize * sizeof(int));
if(tempArr)
{
msort(arr, tempArr, 0, numSize-1);
free(tempArr);
}
else
{
printf("ERROR:Failed to allocate memory\r\n");
}
}
//归并排序算法:1、数组划分
void msort(int arr[],int tempArr[], int left, int right)
{
//如果只有一个元素,那么就不需要继续划分
//只有一个元素的区域,本身就是有序的,只需要归并即可
if(left < right)
{
//寻找中间点
int mid = (left + right)/2;
//递归划分左半区域
msort(arr, tempArr, left, mid);
//递归划分右半区域
msort(arr, tempArr, mid+1, right);
//合并已经排序的部分
merge(arr, tempArr, left, mid, right);
}
}
//归并排序算法:2、数组归并
void merge(int arr[],int tempArr[],int left,int mid,int right)
{
//标记左半区第一个未排序的元素
int l_pos = left;
//标记右半区第一个未排序的元素
int r_pos = mid+1;
//临时数组元素的下标
int pos = left;
//合并
while(l_pos <= mid && r_pos <= right)
{
if(arr[l_pos] < arr[r_pos])
{
tempArr[pos++] = arr[l_pos++];
}
else
{
tempArr[pos++] = arr[r_pos++];
}
}
//合并左半区剩余的元素
while(l_pos <= mid)
{
tempArr[pos++] = arr[l_pos++];
}
//合并右半区剩余的元素
while(r_pos <= right)
{
tempArr[pos++] = arr[r_pos++];
}
//把临时数组中合并后的元素复制到原来的数组
while(left <= right)
{
arr[left] = tempArr[left];
left++;
}
}
算法结果:
平均时间复杂度:O(nlogn);空间复杂度:O(n);稳定性:稳定
适用场景:归并排序在数据量比较大的时候也有较为出色的表现(效率上),但是,其空间复杂度O(n)使得在数据量特别大的时候(例如,1千万数据)几乎不可接受。而且,考虑到有的机器内存本身就比较小,因此,采用归并排序一定要注意。
5、快速排序
快速排序是一个知名度极高的排序算法,其对于大数据的优秀排序性能和相同复杂度算法中相对简单的实现使它注定得到比其他算法更多的宠爱(面试高频的排序算法)。
5.1 算法描述
1、从数列中挑出一个元素,称为"基准"(pivot),
2、重新排序数列,所有比基准值小的元素摆放在基准前面,所有比基准值大的元素摆在基准后面(相同的数可以到任何一边)。在这个分区结束之后,该基准就处于数列的中间位置。这个称为分区(partition)操作。
3、递归地(recursively)把小于基准值元素的子数列和大于基准值元素的子数列排序。
5.2 动图演示
5.3 算法实现
//快速排序算法
void QuickSort(int arr[],int numSize)
{
qsort(arr,0,numSize);
}
void qsort(int arr[], int low, int high)
{
if(low >= high) //两个索引标号相碰
return;
int pivot = partition(arr, low, high); //将数组分为两部分
qsort(arr, low, pivot-1); //递归排序左子数组
qsort(arr, pivot+1, high); //递归排序右子数值
}
int partition(int arr[], int low, int high)
{
int pivot = arr[low]; //基准
while (low < high){
while (low < high && arr[high] >= pivot) --high;
arr[low]=arr[high]; //交换比基准大的数值到左端
while (low < high && arr[low] <= pivot) ++low;
arr[high] = arr[low]; //交换比基准小的数值到右端
}
//扫描完成,基准到位
arr[low] = pivot;
//返回的是基准的位置
return low;
}
算法结果:
平均时间复杂度:O(nlogn);空间复杂度:O(logn);稳定性:不稳定
适用场景:快速排序在大多数情况下都是适用的,尤其在数据量大的时候性能优越性更加明显。但是在必要的时候,需要考虑下优化以提高其在最坏情况下的性能。
6、希尔排序
在希尔排序出现之前,计算机界普遍存在“排序算法不可能突破O(N^2)”的观点。希尔排序是第一个突破O(n2)的排序算法,它是简单插入排序的改进版。希尔排序的提出,主要基于以下两点:
1、插入排序算法在数组基本有序的情况下,可以近似达到O(n)复杂度,效率极高。
2、但插入排序每次只能将数据移动一位,在数组较大且基本无序的情况下性能会迅速恶化。
希尔排序核心:插入算法的升级,通过分组迭代的插入排序缩短排序时间
6.1 算法描述
先将整个待排序的记录序列分割成为若干子序列分别进行直接插入排序,具体算法描述:
1、选择一个增量序列t1,t2,…,tk,其中ti>tj,tk=1;
2、按增量序列个数k,对序列进行 k 趟排序;
3、每趟排序,根据对应的增量ti,将待排序列分割成若干长度为m 的子序列,分别对各子表进行直接插入排序。仅增量因子为1 时,整个序列作为一个表来处理,表长度即为整个序列的长度。
6.2 动图演示
6.3 算法实现
//希尔排序
void HillSort(int arr[],int numSize)
{
//定义增量和步长
int gap,i,j,k,temp;
//通过改变组距持续进行插入排序
for(gap = numSize/2; gap >= 1; gap = gap/2)
{
//每个组别进行插入排序
for(i = 0; i < gap; i++)
{
for(j = i + gap; j < numSize; j += gap) //每个组别中的数据
{
//标记需要插入排序的数值
temp = arr[j];
for(k = j -gap; arr[k] > temp && k >=0 ; k = k-gap) //当前gap组别下,前一个数值大于现在这个数值
{
arr[k+gap] = arr[k]; //将数值后移一位
}
//将插入的数值放入正确位置
arr[k+gap] = temp;
}
}
}
}
算法结果:
平均时间复杂度:O(nlogn);空间复杂度:O(1);稳定性:不稳定
适用场景:希尔排序虽然快,但是毕竟是插入排序,其数量级并没有后起之秀——快速排序O(nlogn)快。在大量数据面前,希尔排序不是一个好的算法。但是,中小型规模的数据完全可以使用它。
7、堆排序
堆排序(Heapsort)是指利用堆积树(堆)这种数据结构所设计的一种排序算法,它是选择排序的一种。可以利用数组的特点快速定位指定索引的元素。堆排序就是把最大堆堆顶的最大数取出,将剩余的堆继续调整为最大堆,再次将堆顶的最大数取出,这个过程持续到剩余数只有一个时结束。
7.1 算法描述
7.1.1 什么是堆
堆是具有以下性质的完全二叉树:每个结点的值都大于或等于其左右孩子结点的值,称为大顶堆;或者每个结点的值都小于或等于其左右孩子结点的值,称为小顶堆。如下图:
同时,我们对堆中的结点按层进行编号,将这种逻辑结构映射到数组中就是下面这个样子
该数组从逻辑上讲就是一个堆结构,我们用简单的公式来描述一下堆的定义就是:
大顶堆:arr[i] >= arr[2i+1] && arr[i] >= arr[2i+2]
小顶堆:arr[i] <= arr[2i+1] && arr[i] <= arr[2i+2]
7.1.2 堆排序基本思想及步骤
堆排序的基本思想是:将待排序序列构造成一个大顶堆,此时,整个序列的最大值就是堆顶的根节点。将其与末尾元素进行交换,此时末尾就为最大值。然后将剩余n-1个元素重新构造成一个堆,这样会得到n个元素的次小值。如此反复执行,便能得到一个有序序列了。
步骤一 构造初始堆。将给定无序序列构造成一个大顶堆(一般升序采用大顶堆,降序采用小顶堆)。
1.假设给定无序序列结构如下
2.此时我们从最后一个非叶子结点开始(叶结点自然不用调整,第一个非叶子结点 arr.length/2-1=5/2-1=1,也就是下面的6结点),从左至右,从下至上进行调整。
4.找到第二个非叶节点4,由于[4,9,8]中9元素最大,4和9交换。
这时,交换导致了子根[4,5,6]结构混乱,继续调整,[4,5,6]中6最大,交换4和6。
此时,我们就将一个无需序列构造成了一个大顶堆。
步骤二 将堆顶元素与末尾元素进行交换,使末尾元素最大。然后继续调整堆,再将堆顶元素与末尾元素交换,得到第二大元素。如此反复进行交换、重建、交换。
a.将堆顶元素9和末尾元素4进行交换
b.重新调整结构,使其继续满足堆定义
c.再将堆顶元素8与末尾元素5进行交换,得到第二大元素8.
后续过程,继续进行调整,交换,如此反复进行,最终使得整个序列有序
1、将无需序列构建成一个堆,根据升序降序需求选择大顶堆或小顶堆;
2、将堆顶元素与末尾元素交换,将最大元素"沉"到数组末端;
3、重新调整结构(每次长度递减1),使其满足堆定义,然后继续交换堆顶元素与当前末尾元素,反复执行调整+交换步骤,直到整个序列有序。
7.2 动图演示
7.3 算法实现
//堆排序
void swap(int *a, int *b)
{
int temp = *b;
*b = *a;
*a = temp;
}
void max_heapify(int arr[], int start, int end)
{
// 建立父节点指针和子节点指针
int dad = start;
int son = dad * 2 + 1;
while (son <= end) { // 若子节点指针在范围内才做比较
if (son + 1 <= end && arr[son] < arr[son + 1]) // 先比较子节点的大小,选择大的
son++;
if (arr[dad] > arr[son]) //如果父节点大于子节点代表调整结束,直接跳出函数
return;
else { // 否则交换父子节点内容,继续比较
swap(&arr[dad], &arr[son]);
dad = son;
son = dad * 2 + 1;
}
}
}
void HeapSort(int arr[], int len)
{
int i;
// 初始化,i从最后一个父节点开始调整
for (i = len / 2 - 1; i >= 0; i--)
max_heapify(arr, i, len - 1);
// 将第一个元素与最后一个元素进行交换,再重新调整,直到排序完毕
for (i = len - 1; i > 0; i--) {
swap(&arr[0], &arr[i]);
max_heapify(arr, 0, i - 1);
}
}
算法结果:
平均时间复杂度:O(nlogn);空间复杂度:O(1);稳定性:不稳定
适用场景:堆排序在建立堆和调整堆的过程中会产生比较大的开销,在元素少的时候并不适用。但是,在元素比较多的情况下,还是不错的一个选择。尤其是在解决诸如“前n大的数”一类问题时,几乎是首选算法。
8、计数排序
计数排序不是基于比较的排序算法,其核心在于将输入的数据值转化为键存储在额外开辟的数组空间中。 作为一种线性时间复杂度的排序,计数排序要求输入的数据必须是有确定范围的整数。
8.1 算法描述
1、找出待排序的数组中最大和最小的元素;
2、统计数组中每个值为i的元素出现的次数,存入数组C的第i项;
3、对所有的计数累加(从C中的第一个元素开始,每一项和前一项相加);
4、反向填充目标数组:将每个元素i放在新数组的第C(i)项,每放一个元素就将C(i)减去1。
8.2 动图演示
8.3 算法实现
//计数排序
void CountSort(int arr[], int numSize)
{
if (numSize < 1) return;
// 寻找最大的元素
int max = arr[0];
for (size_t i = 1; i < numSize; i++)
if (arr[i] > max) max = arr[i];
// 分配一个长度为max+1的数组存储计数,并初始化为0
int *count = (int *)malloc(sizeof(int) * (max + 1));
memset(count, 0, sizeof(int) * (max + 1));
// 计数
for (size_t i = 0; i < numSize; i++)
count[arr[i]]++;
// 统计计数的累计值
for (size_t i = 1; i < max + 1; i++)
count[i] += count[i - 1];
// 创建一个临时数组保存结果
int *output = (int *)malloc(sizeof(int) * numSize);
// 将元素放到正确的位置上
for (size_t i = 0; i < numSize; i++)
{
output[count[arr[i]] - 1] = arr[i];
count[arr[i]]--;
}
// 将结果复制回原数组
for (size_t i = 0; i < numSize; i++)
arr[i] = output[i];
}
算法结果:
注意事项: 受限于计数排序自身限制,排序数组的最大值和最小值不要差距过大,否则计算时间很长。
平均时间复杂度:O(n+k);空间复杂度:O(k);稳定性:稳定
适用场景:排序目标要能够映射到整数域,其最大值最小值应当容易辨别。例如高中生考试的总分数,显然用0-750就OK啦;又比如一群人的年龄,用个0-150应该就可以了,再不济就用0-200喽。另外,计数排序需要占用大量空间,它比较适用于数据比较集中的情况(正常情况下,仅适用于整数排序)。
9、桶排序
桶排序又叫箱排序,是计数排序的升级版,它的工作原理是将数组分到有限数量的桶子里,然后对每个桶子再分别排序(有可能再使用别的排序算法或是以递归方式继续使用桶排序进行排序),最后将各个桶中的数据有序的合并起来。
9.1 算法描述
1、找出待排序数组中的最大值max、最小值min
2、我们使用 动态数组ArrayList 作为桶,桶里放的元素也用 ArrayList 存储。桶的数量为(max-min)/arr.length+1
3、遍历数组 arr,计算每个元素 arr[i] 放的桶
4、每个桶各自排序
5、遍历桶数组,把排序好的元素放进输出数组
9.2 图片演示
元素分布在桶中:
然后,元素在每个桶中排序:
桶排序流程:
9.3 算法实现
桶排序需要自己规定每个桶的大小;
//桶排序
void BucketSort(int arr[], int numSize)
{
int i, j, k;
int bucket[1000] = {0};
for (i = 0; i < n; i++) {
bucket[arr[i]]++;
}
for (i = 0, j = 0; i < MAX_VALUE; i++) {
for (k = bucket[i]; k > 0; k--) {
arr[j++] = i;
}
}
}
算法结果:
平均时间复杂度:O(n+k);空间复杂度:O(n+k);稳定性:稳定
适用场景:桶排序可用于最大最小值相差较大的数据情况,但桶排序要求数据的分布必须均匀,否则可能导致数据都集中到一个桶中。比如[104,150,123,132,20000], 这种数据会导致前4个数都集中到同一个桶中。导致桶排序失效。
10、基数排序
基数排序(BaseSort)是桶排序的扩展,它的基本思想是:将整数按位数切割成不同的数字,然后按每个位数分别比较。
排序过程:将所有待比较数值(正整数)统一为同样的数位长度,数位较短的数前面补零。然后,从最低位开始,依次进行一次排序。这样从最低位排序一直到最高位排序完成以后, 数列就变成一个有序序列。
10.1 算法描述
1、取得数组中的最大数,并取得位数;
2、arr为原始数组,从最低位开始取每个位组成radix数组;
3、对radix进行计数排序(利用计数排序适用于小范围数的特点);
10.2 动图演示
10.3 算法实现
//基数排序
void BaseSort(int arr[],int numSize)
{
int max = 0;
int k = 0;
int count = 0;
int bucket[10][10]; //定义数组桶(默认每个桶最多放10个,读者朋友可以根据实际数组情况写大一点)
int num = 0;
int Y = 1;
// 确定数组最大值
for(int i = 0; i < numSize; i++)
{
if(arr[max] < arr[i])
{
max = i;
}
}
printf("%d\r\n",arr[max]);
k = arr[max];
// 通过最大数确定需要几层基数排序
do{
k = k/10;
count++;
}while(k != 0);
printf("%d\r\n",count);
for(int i =1; i <= count; i++) //排序的次数
{
//每次基数排序前先清空桶内数据
memset(bucket, 0, sizeof(bucket));
for(int j=0; j < numSize; j++)
{
num = arr[j]/Y%10;
//根据基数将数值放到对应的桶中
bucket[num][++bucket[num][0]] = arr[j];
}
//从桶中将数组拿出放入数组中
int arr_len = 0;
for(int n = 0; n <= 9; n++)
{
for(int m = 1; m <= bucket[n][0]; m++)
{
arr[arr_len++] = bucket[n][m];
}
}
Y*=10;
printf("%d\r\n",Y);
}
}
算法结果:
平均时间复杂度:O(n*k);空间复杂度:O(n+k);稳定性:稳定
适用场景:基数排序要求较高,元素必须是整数,整数时长度10W以上,最大值100W以下效率较好,但是基数排序比其他排序好在可以适用字符串,或者其他需要根据多个条件进行排序的场景,例如日期,先排序日,再排序月,最后排序年 ,其它排序算法可是做不了的。(局限:含小数或负数的数组不太好操作)
排序算法总结
参考地址:
菜鸟教程:1.0 十大经典排序算法 | 菜鸟教程 (runoob.com)
GitHub:GitHub - hustcc/JS-Sorting-Algorithm: 一本关于排序算法的 GitBook 在线书籍 《十大经典排序算法》,多语言实现。