⭐️本篇博客我要来和大家一起聊一聊数据结构初阶中的最后一篇博客——八大经典排序算法的总结,其中会介绍他们的原来,还有复杂度的分析以及各种优化。
⭐️博客代码已上传至gitee:https://gitee.com/byte-binxin/data-structure/tree/master/Sort2.0
目录
- 🌏排序总览
- 🍯什么是排序?
- 🍯为什么要排序?(作用)
- 🍯排序的分类
- 🌏插入排序
- 🌴直接插入排序
- 🌴希尔排序
- 🌏选择排序
- 🌲直接选择排序
- 🌲堆排序
- 🌏交换排序
- 🐚冒泡排序
- 🐚快速排序(递归版本)
- 🍍hoare版本
- 🍍挖坑法
- 🍍前后指针法
- 🍍小区间优化快速排序
- 🐚快速排序(非递归版本)
- 🌏归并排序
- 🍁递归实现
- 🍁非递归实现
- 🌏计数排序(非比较排序)
- 🌏排序性能测试代码
- 🌏排序比较
- 🌐总结
🌏排序总览
🍯什么是排序?
🍤 我们可以先了解一下两个概念:
🍤 排序:所谓排序,就是使一串记录,按照其中的某个或某些关键字的大小,递增或递减的排列起来的操作。
🍤 排序的稳定性:假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,r[i]=r[j],且r[i]在r[j]之前,而在排序后的序列中,r[i]仍在r[j]之前,则称这种排序算法是稳定的;否则称为不稳定的。
🍯为什么要排序?(作用)
💿排序的在生活中应用十分广泛,比如在我们刷抖音短视频的时候,大数据根据我们的喜好,会把我们喜欢的推送给我们,还有我们购物可以根据价格升降序之类的来选择商品等等。
💿所以说排序真的是十分的重要。
🍯排序的分类
🌏插入排序
🌴直接插入排序
🍇基本思想:把待排序的数逐个插入到一个已经排好序的有序序列中,直到所有的记录插入完为止,得到一个新的有序序列。
🍇一般地,我们把第一个看作是有序的,所以我们可以从第二个数开始往前插入,使得前两个数是有序的,然后将第三个数插入直到最后一个数插入。
我们可以先看一个动图演示来理解一下:
为了让大家更好地理解代码是怎么实现的,我们可以实现单趟的排序,代码如下:
int end = n-1;
// 先定义一个变量将要插入的数保存起来
int x = a[end + 1];
while (end >= 0)
{
// 直到后面的数比前一个数大时就不往前移动,就直接把这个数放在end的后面
if (a[end] > x)
{
a[end + 1] = a[end];
end--;
}
else
{
break;
}
}
a[end + 1] = x;
🍇前面我们也说了,是从第二个是开始往前插入,所以说第一趟的end应该为0,最后一趟的end应该是end = n - 2,根据end+1<n可以推出。
所以直接插入排序的整个过程的代码实现如下:
void InsertSort(int* a, int n)
{
int i = 0;
for (i = 0; i < n - 1; i++)
{
int end = i;
// 先定义一个变量将要插入的数保存起来
int x = a[end + 1];
// 直到后面的数比前一个数大时就不往前移动,就直接把这个数放在end的后面
while (end >= 0)
{
if (a[end] > x)
{
a[end + 1] = a[end];
end--;
}
else
{
break;
}
}
a[end + 1] = x;
}
}
🍇时间复杂度和空间复杂度的分析
时间复杂度: 第一趟end最多往前移动1次,第二趟是2次……第n-1趟是n-1次,所以总次数是1+2+3+……+n-1=n*(n-1)/2,所以说时间复杂度是O(n^2)
最好的情况: 顺序
最坏的情况: 逆序
:给大家看一下直接插入排序排100w个数据要跑多久
空间复杂度:由于没有额外开辟空间,所以空间复杂度为O(1)
🍇直接插入排序稳定性的分析
直接插入排序在遇到相同的数时,可以就放在这个数的后面,就可以保持稳定性了,所以说这个排序是稳定的。
🌴希尔排序
🍋基本思想:希尔排序是建立在直接插入排序之上的一种排序,希尔排序的思想上是把较大的数尽快的移动到后面,把较小的数尽快的移动到后面。先选定一个整数,把待排序文件中所有记录分成个组,所有距离为的记录分在同一组内,并对每一组内的记录进行排序。(直接插入排序的步长为1),这里的步长不为1,而是大于1,我们把步长这个量称为gap,当gap>1时,都是在进行预排序,当gap==1时,进行的是直接插入排序。
🍋可以先给大家看一个图解:
看一下下面动图演示的过程:
我们可以先写一个单趟的排序:
int end = 0;
int x = a[end + gap];
while (end >= 0)
{
if (a[end] > x)
{
a[end + gap] = a[end];
end -= gap;
}
else
{
break;
}
}
a[end + gap] = x;
这里的单趟排序的实现和直接插入排序差不多,只不过是原来是gap = 1,现在是gap了。
由于我们要对每一组都进行排序,所以我们可以一组一组地排,像这样:
// gap组
for (int j = 0; j < gap; j++)
{
int i = 0;
for (i = 0; i < n-gap; i+=gap)
{
int end = i;
int x = a[end + gap];
while (end >= 0)
{
if (a[end] > x)
{
a[end + gap] = a[end];
end -= gap;
}
else
{
break;
}
}
a[end + gap] = x;
}
}
也可以对代码进行一些优化,直接一起排序,不要一组一组地,代码如下:
int i = 0;
for (i = 0; i < n - gap; i++)// 一起预排序
{
int end = i;
int x = a[end + gap];
while (end >= 0)
{
if (a[end] > x)
{
a[end + gap] = a[end];
end -= gap;
}
else
{
break;
}
}
a[end + gap] = x;
}
🍋当gap>1时,都是在进行预排序,当gap==1时,进行的是直接插入排序。
🍋gap越大预排越快,预排后越不接近有序
🍋gap越小预排越慢,预排后越接近有序
🍋gap==1时,进行的是直接插入排序。
🍋所以接下来我们要控制gap,我们可以让最初gap为n,然后一直除以2直到gap变成1,也可以这样:gap = gap/3+1。只要最后一次gap为1就可以了。
所以最后的代码实现如下:
void ShellSort(int* a, int n)
{
int gap = n;
while (gap > 1)// 不要写等于,会导致死循环
{
// gap > 1 预排序
// gap == 1 插入排序
gap /= 2;
int i = 0;
for (i = 0; i < n - gap; i++)// 一起预排序
{
int end = i;
int x = a[end + gap];
while (end >= 0)
{
if (a[end] > x)
{
a[end + gap] = a[end];
end -= gap;
}
else
{
break;
}
}
a[end + gap] = x;
}
}
}
🍋时间复杂度和空间复杂度的分析
时间复杂度: 外层循环的次数前几篇博客我们算过很多次类似的,也就是O(logN),
里面是这样算的
:给大家看一下直接插入排序排100w个数据要跑多久
看这时间,比起直接插入排序真的是快了太多。
空间复杂度:由于没有额外开辟空间,所以空间复杂度为O(1)
🍋希尔排序稳定性的分析
我们可以这样想,相同的数被分到了不同的组,就不能保证原有的顺序了,所以说这个排序是不稳定的。
🌏选择排序
🌲直接选择排序
🍆基本思想每一次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始位置,直到全部待排序的数据元素排完 。
🍆我们先看一下直接选择排序的动图演示:
像上面一样,我们先来实现单趟排序:
int begin = 0;
int mini = begin;
int maxi = begin;
int i = 0;
for (i = begin; i <= end; i++)
{
if (a[i] > a[maxi])
{
maxi = i;
}
if (a[i] < a[mini])
{
mini = i;
}
}
// 如果maxi和begin相等的话,要对maxi进行修正
if (maxi == begin)
maxi = mini;
Swap(&a[begin], &a[mini]);
Swap(&a[end], &a[maxi]);
这里我要说明一下,其中加了一段修正maxi的代码,就是为了防止begin和maxi相等时,mini与begin交换会导致maxi的位置发生变化,最后排序逻辑就会乱了,所以加上一段修正maxi的值得代码。
if (maxi == begin)
maxi = mini;
整体排序就是begin往前走,end往后走,相遇就停下,所以整体代码实现如下:
void SelectSort(int* a, int n)
{
int begin = 0;
int end = n - 1;
while (begin < end)
{
int mini = begin;
int maxi = begin;
int i = 0;
for (i = begin; i <= end; i++)
{
if (a[i] > a[maxi])
{
maxi = i;
}
if (a[i] < a[mini])
{
mini = i;
}
}
// 如果maxi和begin相等的话,要对maxi进行修正
if (maxi == begin)
maxi = mini;
Swap(&a[begin], &a[mini]);
Swap(&a[end], &a[maxi]);
begin++;
end--;
}
}
🍆时间复杂度和空间复杂度的分析
时间复杂度: 第一趟遍历n-1个数,选出两个数,第二趟遍历n-3个数,选出两个数……最后一次遍历1个数(n为偶数)或2个数(n为奇数),所以总次数是n-1+n-3+……+2,所以说时间复杂度是O(n^2)
最好的情况: O(n^2)(顺序)
最坏的情况: O(n^2)(逆序)
直接选择排序任何情况下的时间复杂度都是 O(n^2),因为不管有序还是无序都要去选数。
🍆给大家看一下直接选择排序排100w个数据要跑多久
空间复杂度:由于没有额外开辟空间,所以空间复杂度为O(1)
🍆直接选择排序稳定性的分析
我们可以这样想
所以说直接选择排序是不稳定的。
🌲堆排序
🌽堆排序我在上上一篇博客已经详细介绍了,大家可以点击这里去看堆排序
🌏交换排序
🍅基本思想:所谓交换,就是根据序列中两个记录键值的比较结果来对换这两个记录在序列中的位置,交换排序的特点是:将键值较大的记录向序列的尾部移动,键值较小的记录向序列的前部移动。
🐚冒泡排序
🍅基本思想:它重复地走访过要排序的元素列,依次比较两个相邻的元素,如果顺序(如从大到小、首字母从Z到A)错误就把他们交换过来。走访元素的工作是重复地进行直到没有相邻元素需要交换,也就是说该元素列已经排序完成。
🍅图解如下:
🍅再看一个冒泡排序的动图:
先实现单趟冒泡排序:
int j = 0;
for (j = 0; j < n - 1; j++)
{
// 比后面的数大就交换
if (a[j] > a[j + 1])
{
exchange = 1;
Swap(&a[j], &a[j + 1]);
}
}
再实现整体的排序:
void BubbleSort(int* a, int n)
{
int i = 0;
for (i = 0; i < n - 1; i++)
{
int exchange = 0;
int j = 0;
for (j = 0; j < n - i - 1; j++)
{
if (a[j] > a[j + 1])
{
exchange = 1;
Swap(&a[j], &a[j + 1]);
}
}
}
}
🍅我们再考虑这样一个问题,假如当前的序列已经有序了,我们有什么办法让这个排序尽快结束吗?
这当然是有的,我们可以定义一个exchange的变量,如果这趟排序发生交换就把这个变量置为1,否则就不变,不发生交换的意思就是该序列已经有序了,利用这样一个变量我们就可以直接结束循环了。
优化后的代码如下:
void BubbleSort(int* a, int n)
{
int i = 0;
for (i = 0; i < n - 1; i++)
{
int exchange = 0;
int j = 0;
for (j = 0; j < n - i - 1; j++)
{
if (a[j] > a[j + 1])
{
exchange = 1;
Swap(&a[j], &a[j + 1]);
}
}
// 不发生交换
if (exchange == 0)
break;
}
}
🍅时间复杂度和空间复杂度的分析
时间复杂度: 第一趟最多比较n-1次,第二趟最多比较n-2次……最后一次最多比较1次,所以总次数是n-1+n-2+……+1,所以说时间复杂度是O(n^2)
最好的情况: O(n)(顺序)
最坏的情况: O(n^2)(逆序)
所以说冒泡排序在最好的情况下比直接选择排序更优。
🍅给大家看一下冒泡排序排10w个数据要跑多久,因为太慢了,所以这里只排10w
可以看出的是,10w个数冒泡排序都排的很久。
空间复杂度:由于没有额外开辟空间,所以空间复杂度为O(1)
🍅直接选择排序稳定性的分析
冒泡排序在比较遇到相同的数时,可以不进行交换,这样就保证了稳定性,所以说冒泡排序数稳定的。
🐚快速排序(递归版本)
🌰快速排序是Hoare于1962年提出的一种二叉树结构的交换排序方法。
🍍hoare版本
🌰基本思想:任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止。
🌰我们先看一个分割一次的动图:
🌰我们要遵循一个原则:关键词取左,右边先找小再左边找大;关键词取右,左边找先大再右边找小。
🌰一次过后,2也就来到了排序后的位置,接下来我们就是利用递归来把key左边区间和右边的区间递归排好就可以了,如下:
递归左区间:[left, key-1] key 递归右区间:[key+1, right]
hoare版本找key值代码实现如下:
int PartSort1(int* a, int left, int right)
{
int keyi = left;
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[keyi], &a[left]);
return left;
}
快排代码实现如下:
void QuickSort(int* a, int left, int right)
{
if (left > right)
return;
int div = PartSort1(a, left, right);
// 两个区间 [left, div-1] div [div+1, right]
QuickSort(a, left, div - 1);
QuickSort(a, div + 1, right);
}
🌰我们考虑这样一种情况,当第一个数是最小的时候,顺序的时候会很糟糕,因为每次递归right都要走到头,看下图:
此时会建立很多函数栈帧,递归的深度会很深,会导致栈溢出(stackover),看下图:
为了优化这里写了一个三数取中的代码,三数取中就是在序列的首、中和尾三个位置选择第二大的数,然后放在第一个位置,这样就防止了首位不是最小的,这样也就避免了有序情况下,情况也不会太糟糕。
下面是三数取中代码:
int GetMidIndex(int* a, int left, int right)
{
int mid = left + (right - left) / 2;
if (a[mid] > a[left])
{
if (a[right] > a[mid])
{
return mid;
}
// a[right] <= a[mid]
else if (a[left] > a[right])
{
return left;
}
else
{
return right;
}
}
// a[mid] <= a[left]
else
{
if (a[mid] > a[right])
{
return mid;
}
// a[mid] <= a[right]
else if (a[left] > a[right])
{
return right;
}
else
{
return left;
}
}
}
所以加上三数取中优化后的代码如下:
int PartSort1(int* a, int left, int right)
{
int index = GetMidIndex(a, left, right);
Swap(&a[index], &a[left]);
int keyi = left;
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[keyi], &a[left]);
return left;
}
🌰时间复杂度和空间复杂度的分析
🌰给大家看一下快速排序排100w个数据要跑多久
看这时间快排果然是大哥。
空间复杂度:由于没有额外开辟空间,所以空间复杂度为O(1)
🌰快速排序稳定性的分析
快速排序肯定不稳定的,我可大家看一个例子:
🍍挖坑法
🐟基本思想: 设定一个基准值(一般为序列的最左边元素,也可以是最右变的元素)此时最左边的是一个坑。 开辟两个指针,分别指向序列的头结点和尾结点(选取的基准值在左边,则先从右边出发。反之,选取的基准值在右边,则先从左边出发)。 从右指针出发依次遍历序列,如果找到一个值比所选的基准值要小,则将此指针所指的值放在坑里,左指针向前移。 后从左指针出发(选取的基准值在左边,则后从左边出发。反之,选取的基准值在右边,则后从右边出发),依次便利序列,如果找到一个值比所选的基准值要大,则将此指针所指的值放在坑里,右指针向前移。 依次循环步骤4,5,直到左指针和右指针重合时,我们吧基准值放入这连个指针重合的位置。
🐟我们先看一个动图来理解一下:
🐟挖坑法我们要遵循一个原则:坑在左,右边找小;坑在右,左边找大。
挖坑法代码实现如下(加了三数取中算法):
int PartSort2(int* a, int left, int right)
{
int index = GetMidIndex(a, left, right);
Swap(&a[index], &a[left]);
int pivot = left;
int key = a[pivot];
while (left < right)
{
// 坑在左边,右边找小
while (left < right && a[right] >= key)
{
right--;
}
Swap(&a[pivot], &a[right]);
pivot = right;
// 坑在右边边,右边找大
while (left < right && a[left] <= key)
{
left++;
}
Swap(&a[pivot], &a[left]);
pivot = left;
}
a[pivot] = key;
return pivot;
}
🐟上一个方法已经介绍了时间复杂度和空间复杂度,这里就不多介绍了。
🍍前后指针法
🐠基本思想: 前后指针法就是有两个指针prev和cur,cur个在前,prev在后,cur在前面找小,找到了,prev就往前走一步,然后交换prev和cur所在位置的值,然后cur继续找小,直到cur走到空指针的位置就结束,最后将prev的值与key交换就完成了一次分割区间的操作。
🐠 大家还可以看下面的动图来理解一下这个前后指针法是个什么样子的:
下面是代码实现:
int PartSort3(int* a, int left, int right)
{
int index = GetMidIndex(a, left, right);
Swap(&a[index], &a[left]);
int key = a[left];
int prev = left;
int cur = left + 1;
while (cur <= right)
{
if (a[cur] < key)
{
prev++;
if (prev != cur)
Swap(&a[cur], &a[prev]);
}
cur++;
}
Swap(&a[prev], &a[left]);
return prev;
}
🍍小区间优化快速排序
🐾小区间优化原理: 当快速排序在递归过程中一直切分区间时,最后会被分成很小的区间,当区间中的数据个数很小时,其实这是已经是没有必要进行再分割的,且最后一层基本上占据了快速排序一半的递归,这是我们可以选择其他的排序来解决这个小区间的排序。
🐾大家可以看一看下面这个图来理解一下:
🐾还有一个我们要思考的问题就是最后这段小区间用什么排序比较好?
希尔排序适应的是比较多的数据才有优势,堆排序也不行,需要建堆,有点杀鸡用牛刀的感觉,其他三个插入排序、选择排序和冒泡排序相比,还是插入排序比较优,所以我们小区间选择用插入排序进行排序。
void QuickSort(int* a, int left, int right)
{
if (left > right)
return;
int div = PartSort3(a, left, right);
// 两个区间 [left, div-1] div [div+1, right]
if (div - 1 - left > 10)
{
QuickSort(a, left, div - 1);
}
else
{
InsertSort(a + left, (div - 1) - left + 1);
}
if (right - div - 1 > 10)
{
QuickSort(a, div + 1, right);
}
else
{
InsertSort(a + div + 1, right - (div + 1) + 1);
}
}
🐾可以看一下小区间优化后的快排性能:
其实还是有一点优化的。
🐚快速排序(非递归版本)
💦基本思想: 利用栈来模拟实现递归调用的过程,利用压栈的顺序来实现排序的顺序。
给大家看一个利用栈模拟实现的动图:
下面是非递归代码实现:
void QuickSortNonR(int* a, int left, int right)
{
stack<int>s;
s.push(right);
s.push(left);
while (!s.empty())
{
int newLeft = s.top();
s.pop();
int newRight = s.top();
s.pop();
int div = PartSort2(a, newLeft, newRight);
// 两个区间 [left, div-1] div [div+1, right]
// 压右区间
if (div + 1 < newRight)
{
s.push(newRight);
s.push(div + 1);
}
// 压左区间
if (newLeft < div - 1)
{
s.push(div - 1);
s.push(newLeft);
}
}
}
🌏归并排序
🍁递归实现
🌴基本思想:(MERGE-SORT)是建立在归并操作上的一种有效的排序算法,该算法是采用分治法(Divide andConquer)的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为二路归并。
🌴归并条件: 左区间有序 右区间有序
🌴为了方便大家理解,这里放一个图:
这图演示的就是先分治,分成若干个小的区间,然后再合并。
这里我还给大家放一个归并排序的动图,让大家更好地理解:
下面是代码实现:
void _MergeSort(int* a, int left, int right, int* tmp)
{
if (left >= right)
return;
int mid = left + (right - left) / 2;
// 归并条件:左区间有序 右区间有序
// 如何做到?递归左右区间
// [left, mid] [mid + 1, right]
_MergeSort(a, left, mid, tmp);
_MergeSort(a, mid + 1, right, tmp);
//归并
int begin1 = left;
int end1 = mid;
int begin2 = mid + 1;
int end2 = right;
int i = left;
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++];
}
for (i = left; i <= right; i++)
{
a[i] = tmp[i];
}
}
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;
}
🌴时间复杂度和空间复杂度的分析
时间复杂度: O(N*logN)
:给大家看一下直接插入排序排100w个数据要跑多久
空间复杂度: O(N),要来一个临时空间存放归并好的区间的数据。
🍇归并排序稳定性的分析
*归并排序在遇到相同的数时,可以就先将放前一段区间的数,再放后一段区间的数就可以保持稳定性了,所以说这个排序是稳定的。
🍁非递归实现
🌾基本思想: 这里我们用循环来实现这个非递归的归并排序,我们可以先两两一组,在四个四个一组归并……
🌾来看一个图解:
根据上面这个图,我们可以很快的写出一个框架,例如下面的代码:
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)
{
int i = 0;
for (i = 0; i < n; i += 2 * gap)
{
// [i, i+gap-1] [i+gap, i+2*gap-1]
int begin1 = i;
int end1 = i + gap - 1;
int begin2 = i + gap;
int end2 = i + 2 * gap - 1;
int index = i;
while (begin1 <= end1 && begin2 <= end2)
{
if (a[begin1] < a[begin2])
{
tmp[index++] = a[begin1++];
}
else
{
tmp[index++] = a[begin2++];
}
}
while (begin1 <= end1)
{
tmp[index++] = a[begin1++];
}
while (begin2 <= end2)
{
tmp[index++] = a[begin2++];
}
int j = 0;
for (j = i; j <= end2; j++)
{
a[j] = tmp[j];
}
}
gap *= 2;
}
free(tmp);
tmp = NULL;
}
🌾看到这里,大家有没有发现我们还有什么问题没有解决?
认真观察,我们会发现,这里再加一个数程序就会崩了,
两种需要调整的情况:
1.右半边区间多了
2.正要归并的右区间不够
看一个图解:
🌾如何调整?
当右半区间不存在时,我们可以不进行这次归并,直接跳出循环,也就是begin2 >= n时,我们就break跳出这次循环,不进行归并;
当右半区间算多了时,也就是end2>=n时,此时我们只需要对end2进行调整,使得右区间范围缩小,不越界,可以继续归并。
所以调整后的代码如下:
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)
{
int i = 0;
for (i = 0; i < n; i += 2 * gap)
{
// [i, i+gap-1] [i+gap, i+2*gap-1]
// 两种需要调整的情况:
// 1.右半边区间多了
// 2.正要归并的右区间不够
int begin1 = i;
int end1 = i + gap - 1;
int begin2 = i + gap;
int end2 = i + 2 * gap - 1;
int index = i;
// 情况1
if (begin2 >= n)
break;
// 情况2
if (end2 >= n)
end2 = n - 1;
while (begin1 <= end1 && begin2 <= end2)
{
if (a[begin1] < a[begin2])
{
tmp[index++] = a[begin1++];
}
else
{
tmp[index++] = a[begin2++];
}
}
while (begin1 <= end1)
{
tmp[index++] = a[begin1++];
}
while (begin2 <= end2)
{
tmp[index++] = a[begin2++];
}
int j = 0;
for (j = i; j <= end2; j++)
{
a[j] = tmp[j];
}
}
gap *= 2;
}
free(tmp);
tmp = NULL;
}
🌾这样非递归的归并排序就这样被我们实现了。非递归归并排序的实现的难点不在框架,而在边界控制,我们要把
边界控制
的到位,这样就能够很好地实现这个非递归。
🌏计数排序(非比较排序)
🌰基本思想: 计数排序是一个非基于比较的排序算法,该算法于1954年由 Harold H. Seward 提出。它的优势在于在对一定范围内的整数排序时,它的复杂度为Ο(n+k)(其中k是整数的范围),快于任何比较排序算法。 当然这是一种牺牲空间换取时间的做法,而且当O(k)>O(n*log(n))的时候其效率反而不如基于比较的排序。(来源:百度百科)
🌰 操作步骤:
- 统计相同元素出现次数
- 根据统计的结果将序列回收到原来的序列中
🌰 看下面一个计数排序的动图:
我们可以先计数出这个序列数据的范围也就是range = max - min + 1,最大值和最小值都可以通过遍历一遍序列来选出这两个数。然后我们可以开一个大小为range的计数的空间count中,然后将序列中的每一个数都减去min,然后映射到count这个空间中,然后我们再一次取出并加上min依次放进原数组空间中,这样我们就孙俪地完成了排序。
具体代码实现如下:
void CountSort(int* a, int n)
{
int min = a[0];
int max = a[0];
int i = 0;
for (i = 1; i < n; i++)
{
if (a[i] > max)
{
max = a[i];
}
if (a[i] < min)
{
min = a[i];
}
}
int range = max - min + 1;
int* count = (int*)malloc(sizeof(int) * range);
if (count == NULL)
{
printf("malloc error\n");
exit(-1);
}
// 初始化开辟的空间
memset(count, 0, sizeof(int) * range);
for (i = 0; i < n; i++)
{
count[a[i] - min]++;
}
int index = 0;
for (int i = 0; i < range; i++)
{
while (count[i]--)
{
a[index++] = i + min;
}
}
free(count);
count = NULL;
}
🌴时间复杂度和空间复杂度的分析
时间复杂度: O(MAX(N,范围))(以空间换时间)
:给大家看一下直接插入排序排100w个数据要跑多久
看这时间,确实是非常的快。
空间复杂度: O(N),要来一个临时空间存放归并好的区间的数据。
🌴计数排序稳定性的分析
*计数排序在我们这个实现里是不稳定的。
🌏排序性能测试代码
🌴这里给大家贴一份测试排序性能的代码,大家可以拿去试一下
void TestOP()
{
srand((unsigned int)time(NULL));
int* a1 = (int*)malloc(sizeof(int) * N);
int* a2 = (int*)malloc(sizeof(int) * N);
int* a3 = (int*)malloc(sizeof(int) * N);
int* a4 = (int*)malloc(sizeof(int) * N);
int* a5 = (int*)malloc(sizeof(int) * N);
int* a6 = (int*)malloc(sizeof(int) * N);
int* a7 = (int*)malloc(sizeof(int) * N);
int* a8 = (int*)malloc(sizeof(int) * N);
int i = 0;
for (i = 0; i < N; i++)
{
a1[i] = rand();
a2[i] = a1[i];
a3[i] = a2[i];
a4[i] = a3[i];
a5[i] = a4[i];
a6[i] = a5[i];
a7[i] = a6[i];
a8[i] = a7[i];
}
int begin1 = clock();
InsertSort(a1, N);
int end1 = clock();
int begin2 = clock();
ShellSort(a2, N);
int end2 = clock();
int begin3 = clock();
SelectSort(a3, N);
int end3 = clock();
int begin4 = clock();
HeapSort(a4, N);
int end4 = clock();
int begin5 = clock();
QuickSort(a5, 0, N - 1);
int end5 = clock();
int begin6 = clock();
MergeSort(a6, N);
int end6 = clock();
int begin7 = clock();
BubbleSort(a7, N);
int end7 = clock();
int begin8 = clock();
CountSort(a7, N);
int end8 = clock();
printf("InsertSort:%dms\n", end1 - begin1);
printf("ShellSort:%dms\n", end2 - begin2);
printf("SelectSort:%dms\n", end3 - begin3);
printf("HeapSort:%dms\n", end4 - begin4);
printf("QuickSort:%dms\n", end5 - begin5);
printf("MergeSort:%dms\n", end6 - begin6);
printf("BubbleSort:%dms\n", end7 - begin7);
printf("CountSort:%dms\n", end8 - begin8);
free(a1);
free(a2);
free(a3);
free(a4);
free(a5);
free(a6);
free(a7);
free(a8);
}
🌏排序比较
🌿给大家一张对比的表,大家可以参考一下:
排序方法 | 平均情况 | 最好情况 | 最坏情况 | 辅助空间 | 稳定性 |
---|---|---|---|---|---|
直接插入排序 | O(n^2) | O(n) | O(n^2) | O(1) | 稳定 |
希尔排序 | O(nlogn~n^2) | O(n^1.3) | O(n^2) | O(1) | 不稳定 |
直接选择排序 | O(n^2) | O(n^2) | O(n^2) | O(1) | 不稳定 |
堆排序 | O(nlogn) | O(nlogn) | O(nlogn) | O(1) | 不稳定 |
冒泡排序 | O(n^2) | O(n) | O(n^2) | O(1) | 稳定 |
快速排序 | O(nlogn) | O(nlogn) | O(n^2) | O(1) | 不稳定 |
归并排序 | O(nlogn) | O(nlogn) | O(nlogn) | O(n) | 稳定 |
🌐总结
🍄今天主要是介绍了八大经典排序,当然排序肯定不止这几种,但这些都是经典的,用的比较多,数据结构初阶的内容就更新到这了,后面会更新进阶数据结构部分,欢迎大家持续关注。
喜欢的话,欢迎大家点赞支持和指正~