本文主要是对于主流排序算法的总结
目录
重复从最大堆取出数值最大的结点(把根结点和最后一个结点交换,把交换后的最后一个结点移出堆),并让残余的堆维持最大堆性质。
一、插入排序
1、直接插入排序
插入排序(英语:Insertion Sort)是一种简单直观的排序算法。它的工作原理是通过构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入。插入排序在实现上,通常采用in-place排序(即只需用到O(1)的额外空间的排序),因而在从后向前扫描过程中,需要反复把已排序元素逐步向后挪位,为最新元素提供插入空间。
具体实现
void InsertSort(int* a, int n)
{
for (int i = 0; i < n - 1; i++)
{
int end = i;
int tmp = a[end + 1];
while (end >= 0)
{
if (tmp < a[end])
{
a[end + 1] = a[end];
end--;
}
else
{
break;
}
}
a[end + 1] = tmp;
}
}
直接插入排序的特性总结:1. 元素集合越接近有序,直接插入排序算法的时间效率越高2. 时间复杂度:O(N^2)3. 空间复杂度:O(1),它是一种稳定的排序算法4. 稳定性:稳定5.在有序或接近有序时:时间复杂度O(N)
2、希尔排序
希尔排序(Shellsort),也称递减增量排序算法,是插入排序的一种更高效的改进版本。希尔排序是非稳定排序算法。
希尔排序 | |
---|---|
以23, 10, 4, 1的步长序列进行希尔排序。 | |
概况 | |
类别 | 排序算法 |
数据结构 | 数组 |
复杂度 | |
平均时间复杂度 | 根据步长序列的不同而不同。 |
最坏时间复杂度 | 根据步长序列的不同而不同。已知最好的:{\displaystyle O(n\log ^{2}n)} |
最优时间复杂度 | O(n) |
空间复杂度 | O(1) |
最佳解 | 非最佳算法 |
相关变量的定义 |
希尔排序是基于插入排序的以下两点性质而提出改进方法的:
- 插入排序在对几乎已经排好序的数据操作时,效率高,即可以达到线性排序的效率
- 但插入排序一般来说是低效的,因为插入排序每次只能将数据移动一位
希尔排序主要分为两个步骤
1、预排序
使数据有序或接近有序
分组插入预排,间隔gap为一组,一次挪gap步
以升序为例:gap越大较大的数据越快到后面,较小的数据越快到前面
gap越小与之相反,较大的数据越慢到后面,较小的数据越慢到前面
gap越大越不容易排成有序,gap越小排序就越慢,gap为1时是直接插入排序
综合考虑在排序过程中让gap不断缩小,从而使预排序结束
2、直接插入排序
具体实现
void ShellSort(int* a, int n)
{
int gap = n - 1;
while (gap > 1)
{
gap = gap / 3 + 1;
for (int i = 0; i < n - gap; i++)
{
int end = i;
int tmp = a[end + gap];
while (end >= 0)
{
if (tmp < a[end])
{
a[end + gap] = a[end];
end -= gap;
}
else
{
break;
}
}
a[end + gap] = tmp;
}
}
}
改进:同时操作多个以gap为间隔的组,同时使多组预排序
时间复杂度:O(N^1.3)
稳定性:不稳定
二、选择排序
1、选择排序
选择排序(Selection sort)是一种简单直观的排序算法。它的工作原理如下。首先在未排序序列中找到最小(大)元素,存放到排序序列的起始位置,然后,再从剩余未排序元素中继续寻找最小(大)元素,然后放到已排序序列的末尾。以此类推,直到所有元素均排序完毕。
选择排序 | |
---|---|
概况 | |
类别 | 排序算法 |
数据结构 | 数组 |
复杂度 | |
平均时间复杂度 | О(n²) |
最坏时间复杂度 | О(n²) |
最优时间复杂度 | О(n²) |
空间复杂度 | O(1) |
最佳解 | 偶尔出现 |
选择排序的示例动画。红色表示当前最小值,黄色表示已排序序列,蓝色表示当前位置。
选择排序的主要优点与数据移动有关。如果某个元素位于正确的最终位置上,则它不会被移动。选择排序每次交换一对元素,它们当中至少有一个将被移到其最终位置上,因此对n个元素的表进行排序总共进行至多(n-1)次交换。在所有的完全依靠交换去移动元素的排序方法中,选择排序属于非常好的一种。
void SelectSort(int* a, int n)
{
int begin = 0;
int end = n - 1;
while (begin < end)
{
int maxi = begin;
int mini = begin;
for (int i = begin + 1; i <= end; i++)
{
if (a[i] < a[mini])
{
mini = i;
}
if (a[i] > a[maxi])
{
maxi = i;
}
}
Swap(&a[begin], &a[mini]);
if (begin == maxi)
{
maxi = mini;
}
Swap(&a[end], &a[maxi]);
begin++;
end--;
}
}
改进:遍历一遍同时选出最大和最小的数据
时间复杂度:O(N^2)
时间复杂度计算: 第一次遍历N个数据, 第二次遍历N-1个数据……依次类推
N N-1 N-2 ……1 是一个等差数列
根据等差数列前N项和公式计算出时间复杂度为O(N^2)
2、堆排序
堆排序(英语:Heapsort)是指利用堆这种数据结构所设计的一种排序算法。堆是一个近似完全二叉树的结构,并同时满足堆的性质:即子节点的键值或索引总是小于(或者大于)它的父节点。
堆排序 | |
---|---|
堆排序算法的演示。首先,将元素进行重排,以符合堆的条件。图中排序过程之前简单地绘出了堆树的结构。 | |
概况 | |
类别 | 排序算法 |
数据结构 | 数组 |
复杂度 | |
平均时间复杂度 | O(N*logN) |
最坏时间复杂度 | O(N*logN) |
最优时间复杂度 | O(N*logN) |
空间复杂度 | O(1) |
最佳解 | 不是 |
若以升序排序说明,把数组转换成最大堆(Max-Heap Heap),这是一种满足最大堆性质(Max-Heap Property)的二叉树:对于除了根之外的每个节点i, A[parent(i)] ≥ A[i]。
重复从最大堆取出数值最大的结点(把根结点和最后一个结点交换,把交换后的最后一个结点移出堆),并让残余的堆维持最大堆性质。
通常堆是通过一维数组来实现的。在数组起始位置为0的情形中:
父节点i的左子节点在位置(2i+1)
父节点i的右子节点在位置(2i+2)
子节点i的父节点在位置(i-1)/2
堆排序最总要的就是向下调整算法
以升序为例:建立大堆,选出数组中最大的元素,最大元素位于堆顶,将最大元素与数组中最后一个元素交换,让剩余的N-1个元素进行向下调整,依次进行调整
//建大堆,排升序
void AdJustDown(int* a, int size, int parent)
{
int child = parent * 2 + 1;
while (child < size)
{
if (child + 1 < size && a[child + 1] > a[child])
{
child++;
}
if (a[child] > a[parent])
{
Swap(&a[child], &a[parent]);
parent = child;
child = parent * 2 + 1;
}
else
{
break;
}
}
}
void HeapSort(int* a, int n)
{
//建堆
for (int i = (n - 1 - 1) / 2; i >= 0; i--)
{
AdJustDown(a, n, i);
}
//将堆顶的元素与数组最后一个元素交换
int end = n - 1;
while (end > 0)
{
Swap(&a[0], &a[end]);
AdJustDown(a, end, 0);
end--;
}
}
1. 堆排序使用堆来选数,效率就高了很多。2. 时间复杂度:O(N*logN)3. 空间复杂度:O(1)4. 稳定性:不稳定
三、交换排序
1、冒泡排序
冒泡排序(英语:Bubble Sort)又称为泡式排序,是一种简单的排序算法。它重复地走访过要排序的数列,一次比较两个元素,如果它们的顺序错误就把它们交换过来。走访数列的工作是重复地进行直到没有再需要交换,也就是说该数列已经排序完成。这个算法的名字由来是因为越小的元素会经由交换慢慢“浮”到数列的顶端。
冒泡排序 | |
---|---|
使用冒泡排序为一列数字进行排序的过程 | |
概况 | |
类别 | 排序算法 |
数据结构 | 数组 |
复杂度 | |
平均时间复杂度 | O(n^2) |
最坏时间复杂度 | O(n^2) |
最优时间复杂度 | O(n) |
空间复杂度 | O(1) |
最佳解 | No |
冒泡排序
冒泡排序对n个项目需要O(n^2)的比较次数,且可以原地排序。尽管这个算法是最简单了解和实现的排序算法之一,但它对于包含大量的元素的数列排序是很没有效率的。
冒泡排序是与插入排序拥有相等的渐近时间复杂度,但是两种算法在需要的交换次数却很大地不同。在最坏的情况,冒泡排序需要O(n^2)次交换.冒泡排序如果能在内部循环第一次执行时,使用一个旗标来表示有无需要交换的可能,也可以把最优情况下的复杂度降低到O(n)。在这个情况,已经排序好的数列就无交换的需要。若在每次走访数列时,把走访顺序反过来,也可以稍微地改进效率。有时候称为鸡尾酒排序,因为算法会从数列的一端到另一端之间穿梭往返。
冒泡排序算法的运作如下:
- 比较相邻的元素。如果第一个比第二个大,就交换它们两个。
- 对每一对相邻元素作同样的工作,从开始第一对到结尾的最后一对。这步做完后,最后的元素会是最大的数。
- 针对所有的元素重复以上的步骤,除了最后一个。
- 持续每次对越来越少的元素重复上面的步骤,直到没有任何一对数字需要比较。
2、快速排序
快速排序(英语:Quicksort),又称分区交换排序(partition-exchange sort),简称快排,一种排序算法,最早由东尼·霍尔提出。在平均状况下,排序N个项目要O(N*logN)次比较。在最坏状况下则需要O(n^2)次比较,但这种状况并不常见。事实上,快速排序通常明显比其他算法更快,因为它的内部循环(inner loop)可以在大部分的架构上很有效率地达成。
快速排序 | |
---|---|
使用快速排序法对一列数字进行排序的过程 | |
概况 | |
类别 | 排序算法 |
数据结构 | 不定 |
复杂度 | |
平均时间复杂度 | O(N*logN) |
最坏时间复杂度 | O(N^2) |
最优时间复杂度 | O(N*logN) |
空间复杂度 | 根据实现的方式不同而不同 (递归O(logN) 非递归O(1) |
最佳解 | 有时是 |
快速排序利用分支思想 主要为三大步骤
1、确定分界点(key)
(1)取左边界
(2)取右边界
(3)取中间
(4)随机
2、调整区间
划分成两部分,使左区间所有的值小于等于key,右区间所有的值大于等于key
3、处理单个区间
使左右两个区间都有序,整体就有序了
缺点:快速排序在有序或者接近有序的时间复杂度为O(N^2)
数据量较大出现栈溢出
在数据量较小时,效率较低
改进:加入三数取中,避免当数组有序或接近有序时出现key为数组最大或者最小情况
改为非递归版本
判断数据量更改为其它排序算法
区间比较小的时候就不再用递归去划分这个小区间,可以直接用其它排序,最后几层递归调用次数十分的多,用插入排序优化后几层可以减少80%以上的递归调用次数
快速排序的大体框架不变,快排的三个版本只是单趟排序的方式不同而已
void QuickSort(int* a, int begin, int end)
{
if (begin >= end)
{
return;
}
if (end - begin > 10)
{
int keyi = PartSort(a, begin, end);
QuickSort(a, begin, keyi - 1);
QuickSort(a, keyi + 1, end);
}
else
{
InsertSort(a+begin,end-begin+1);
}
}
int GetMidIndex(int* a, int begin, int end)
{
int midi = (begin + end) / 2;
if (a[begin] < a[midi])
{
if (a[begin] > a[end])
{
return begin;
}
else if (a[midi] < a[end])
{
return midi;
}
else
{
return end;
}
}
else //a[begin] >= a[midi]
{
if (a[begin] < a[end])
{
return begin;
}
else if (a[midi] > a[end])
{
return midi;
}
else
{
return end;
}
}
}
快速排序有三个版本
1、hoare版本
左边做key右边先走
右边做key左边先走
int PartSort1(int* a, int begin, int end)
{
int left = begin;
int right = end;
int keyi = begin;
int midi = GetMidIndex(a, begin, end);
Swap(&a[midi], &a[keyi]);
while (left < right)
{
while (left < right && a[right] >= a[keyi])
{
right--;
}
while (left < right && a[left] <= a[keyi])
{
left++;
}
Swap(&a[left], &a[right]);
}
Swap(&a[left], &a[keyi]);
keyi = left;
return keyi;
}
2、挖坑法
挖坑法与前面的版本没有本质的不同,仅仅改进了左右先走的次序,可以不用关注左右
同时保存key的值而不是保存key的下标,前面使用的是交换,这里使用的是覆盖
int PartSort2(int* a, int begin, int end)
{
int midi = GetMidIndex(a, begin, end);
Swap(&a[begin], &a[midi]);
int key = a[begin];
int piti = begin;
while (begin < end)
{
while (begin < end && a[end] >= key)
{
end--;
}
a[piti] = a[end];
piti = end;
while (begin < end && a[begin] <= key)
{
begin++;
}
a[piti] = a[begin];
piti = begin;
}
a[piti] = key;
return piti;
}
3、前后指针版本
该版本与前面有本质的不同:定义两个指针prev和cur,一个指向第一个元素,另一个指向第二个元素,cur向前找小(找比key小的)然后交换prev和cur所指向的值
int PartSort3(int* a, int begin, int end)
{
int prev = begin;
int cur = begin + 1;
int keyi = begin;
int midi = GetMidIndex(a, begin, end);
Swap(&a[midi], &a[keyi]);
while (cur <= end)
{
if (a[cur] < a[keyi] && ++prev != cur)
Swap(&a[prev], &a[cur]);
cur++;
}
Swap(&a[prev], &a[keyi]);
keyi = prev;
return keyi;
}
为了避免自身与自身交换判断prev和cur的关系
4、非递归版本
递归改为非递归主要有两种思路:利用其它数据结构更改或者改为循环
这里利用栈来模拟递归过程
void QuickSortNonR(int* a, int begin, int end)
{
ST st;
StackInit(&st);
StackPush(&st, end);
StackPush(&st, begin);
while (!StackEmpty(&st))
{
int left = StackTop(&st);
StackPop(&st);
int right = StackTop(&st);
StackPop(&st);
int keyi = PartSort3(a, left, right);
if (keyi + 1 < right)
{
StackPush(&st, right);
StackPush(&st, keyi + 1);
}
if (left < keyi - 1)
{
StackPush(&st, keyi - 1);
StackPush(&st, left);
}
}
StackDestroy(&st);
}
这里入栈和出栈的顺序完全按照递归过程中的顺序
四、归并排序
归并排序
归并排序(英语:Merge sort,或mergesort),是创建在归并操作上的一种有效的排序算法,效率为{\displaystyle O(n\log n)}(大O符号)。1945年由约翰·冯·诺伊曼首次提出。该算法是采用分治法(Divide and Conquer)的一个非常典型的应用,且各层分治递归可以同时进行。
归并排序 | |
---|---|
使用合并排序为一列数字进行排序的过程 | |
概况 | |
类别 | 排序算法 |
数据结构 | 数组 |
复杂度 | |
平均时间复杂度 | O(N^logN) |
最坏时间复杂度 | O(N^logN) |
最优时间复杂度 | O(N^logN) |
空间复杂度 | O(N) |
最佳解 | 有时是 |
归并排序是用分治思想,分治模式在每一层递归上有三个步骤:
- 分解(Divide):将n个元素分成个含n/2个元素的子序列。
- 解决(Conquer):用合并排序法对两个子序列递归的排序。
- 合并(Combine):合并两个已排序的子序列已得到排序结果。
时间复杂度计算:归并排序是一种接近二分的算法以第一次为例
要遍历N个数据,第二次虽然分成两个区间,但元素个数不变还是要遍历N个
又因为这种二分类似与完全二叉树,有N个元素的完全二叉树的高度是logN
有logN个N相加结果是O(N*logN)
归并排序需要用一个额外的数组来存储数据,与快速排序的递归用途不同,归并排序的递归用来划分小区间,不断的递归划分小区间直到区间就只有一个元素,然后合并数组,不断合并……
也就是说归并的递归:递的时候是划分小区间,归的时候是进行排序,这也就是改非递归的时候不能用其它数据结构来模拟的原因
用一个额外的tmp数组来存储小区间排好序的元素,然后将tmp数组的元素拷贝回原数组
代码实现
void _MergeSort(int* a, int begin, int end, int* tmp)
{
if (begin >= end)
{
return;
}
int mid = (begin + end) / 2;
//划分区间
_MergeSort(a, begin, mid, tmp);
_MergeSort(a, mid + 1, end, tmp);
//让每个区间有序
int begin1 = begin, end1 = mid;
int begin2 = mid + 1, end2 = end;
int i = begin1;
while (begin1 <= end1 && begin2 <= end2)
{
if (a[begin1] < a[begin2])
{
tmp[i++] = a[begin1++];
}
else
{
tmp[i++] = a[begin2++];
}
}
while (begin1 <= end1)
{
tmp[i++] = a[begin1++];
}
while (begin2 <= end2)
{
tmp[i++] = a[begin2++];
}
memcpy(a + begin, tmp + begin, (end - begin + 1) * sizeof(int));
}
void MergeSort(int* a, int n)
{
int* tmp = (int*)malloc(sizeof(int) * n);
if (tmp == NULL)
{
printf("malloc fail\n");
exit(-1);
}
_MergeSort(a, 0, n - 1, tmp);
free(tmp);
tmp = NULL;
}
第二个函数用来生成额外的tmp数组,按需申请内存不会造成内存浪费,又避免了将上述两个函数合成一个函数后每次递归就又重新创建数组的不必要的消耗
非递归版本
void MergeSortNonR(int* a, int n)
{
int* tmp = (int*)malloc(sizeof(int) * n);
if (tmp == NULL)
{
printf("malloc fail\n");
exit(-1);
}
int gap = 1;
while (gap < n)
{
for (int i = 0; i < n; i += 2 * gap)
{
int begin1 = i, end1 = i + gap - 1;
int begin2 = i + gap, end2 = i + 2 * gap - 1;
if (end1 >= n)
{
end1 = n - 1;
begin2 = n;
end2 = n - 1;
}
else if (begin2 >= n)
{
begin2 = n;
end2 = n - 1;
}
else if (end2 >= n)
{
end2 = n - 1;
}
int j = begin1;
while (begin1 <= end1 && begin2 <= end2)
{
if (a[begin1] < a[begin2])
{
tmp[j++] = a[begin1++];
}
else
{
tmp[j++] = a[begin2++];
}
}
while (begin1 <= end1)
{
tmp[j++] = a[begin1++];
}
while (begin2 <= end2)
{
tmp[j++] = a[begin2++];
}
}
memcpy(a, tmp, sizeof(int) * n);
gap *= 2;
}
free(tmp);
}
非递归的实现是利用循环来模拟的,gap是用来划分区间的,循环模拟是直接从递归的归的阶段来模拟的,gap=1也就是每个区间只有一个元素,gap每次扩大为原来的二倍,每个区间也就跟着扩大二倍,直到这个区间长度为原数组的长度
同时也要主要end1 begin2 end2会出现越界的情况,需要手动来修正,哪个元素越界就需要修正这个元素及其它后面的元素,修正也比较简单只需将其修改为不存在的区间,它就不会进入循环,
虽然它越界了,只要不访问就没问题了。
五、计数排序
计数排序(Counting sort)是一种稳定的线性时间排序算法。该算法于1954年由 Harold H. Seward 提出。计数排序使用一个额外的数组C,其中第i个元素是待排序数组A中值等于i的元素的个数。然后根据数组C来将A中的元素排到正确的位置。
计数排序 | |
---|---|
概况 | |
类别 | 排序算法 |
数据结构 | 数组 |
复杂度 | |
平均时间复杂度 | O(n+k) |
最坏时间复杂度 | O(n+k) |
最优时间复杂度 | O(n+k) |
空间复杂度 | O(n+k) |
当输入的元素是n个0到k之间的整数时,它的运行时间是(n+k)。计数排序不是比较排序,因此不被 O(n\log n)的下界限制。
由于用来计数的数组C的长度取决于待排序数组中数据的范围(等于待排序数组的最大值与最小值的差加上1),这使得计数排序对于数据范围很大的数组,需要大量时间和内存。例如:计数排序是用来排序0到100之间的数字的最好的算法,但是它不适合按字母顺序排序人名。但是,计数排序可以用在基数排序算法中,能够更有效的排序数据范围很大的数组。
通俗地理解,例如有10个年龄不同的人,统计出有8个人的年龄比A小,那A的年龄就排在第9位,用这个方法可以得到其他每个人的位置,也就排好了序。当然,年龄有重复时需要特殊处理(保证稳定性),这就是为什么最后要反向填充目标数组,以及将每个数字的统计减去1。算法的步骤如下:
- 找出待排序的数组中最大和最小的元素
- 统计数组中每个值为i的元素出现的次数,存入数组C的第i-minValue项
- 对所有的计数累加(从C中的第一个元素开始,每一项和前一项相加)
- 反向填充目标数组:将每个元素i放在新数组的第C[i]项,每放一个元素就将C[i]减去1
void CountSort(int* a, int n)
{
int max = a[0];
int min = a[0];
for (int i = 0; i < n; i++)
{
if (a[i] < a[min])
{
min = i;
}
if (a[i] > a[max])
{
max = i;
}
}
//统计次数的数组
int range = max - min + 1;
int* count = (int*)malloc(sizeof(int) * range);
if (range == NULL)
{
printf("malloc fail\n");
exit(-1);
}
memset(count, 0, sizeof(int) * range);
//统计次数
for (int i = 0; i < n; i++)
{
count[a[i] - min]++;
}
int j = 0;
for (int i = 0; i < range; i++)
{
while (count[i]--)
{
a[j++] = i + min;
}
}
}
计数排序的优势在于在对一定范围内的整数排序时,它的复杂度为Ο(n+k)(其中k是整数的范围),快于任何比较排序算法。 [1] 当然这是一种牺牲空间换取时间的做法,而且当O(k)>O(n*log(n))的时候其效率反而不如基于比较的排序(基于比较的排序的时间复杂度在理论上的下限是O(n*log(n)), 如归并排序,堆排序)
六、基数排序
基数排序(英语:Radix sort)是一种非比较型整数排序算法,其原理是将整数按位数切割成不同的数字,然后按每个位数分别比较。由于整数也可以表达字符串(比如名字或日期)和特定格式的浮点数,所以基数排序也不是只能使用于整数。基数排序的发明可以追溯到1887年赫尔曼·何乐礼在打孔卡片制表机(Tabulation Machine)上的贡献[1]。
基数排序 | |
---|---|
概况 | |
类别 | 排序算法 |
数据结构 | 数组 |
复杂度 | |
最坏时间复杂度 | O(kN) |
空间复杂度 | O(k+N) |
最佳解 | Yes |
它是这样实现的:将所有待比较数值(正整数)统一为同样的数位长度,数位较短的数前面补零。然后,从最低位开始,依次进行一次排序。这样从最低位排序一直到最高位排序完成以后,数列就变成一个有序序列。
基数排序的方式可以采用LSD(Least significant digital)或MSD(Most significant digital),LSD的排序方式由键值的最右边开始,而MSD则相反,由键值的最左边开始。
对于比较排序来说,改进排序主要在两个方面1、比较 2、移动
而对于基数排序来说并不需要比较和排序
核心思想
1、分发数据
2、回收数据
简单来说基数排序是按照每个元素的位数,一位一位的来进行比较,同时有一个基准0~9
先从低位进行划分,同时要遵循先进先出的原则,很容易想到利用队列来作为基准
#define Radix 10
#define K 3
Queue Q[Radix];
int GetK(int value, int k)
{
int ret = 0;
while (k >= 0)
{
ret = value % 10;
value /= 10;
k--;
}
return ret;
}
void Distribute(int* a, int left, int right, int k)
{
for (int i = left; i < right; i++)
{
int key = GetK(a[i], k);
QueuePush(&Q[key], a[i]);
}
}
void Collect(int* a)
{
int j = 0;
for (int i = 0; i < Radix; i++)
{
while (!QueueEmpty(&Q[i]))
{
a[j++] = QueueFront(&Q[i]);
QueuePop(&Q[i]);
}
}
}
void RadixSort(int* a, int begin, int end)
{
for (int i = 0; i < K; i++)
{
//分发数据
Distribute(a, begin, end, i);
//回收数据
Collect(a);
}
}
利用的是三位数的数据进行测试,所以定义K为3,即分发和回收数据三次即可排好序