前言
一、插入排序
1、直接插入排序
(1)基本思想
基本思想: 把待排序的记录按其关键码值的大小逐个插入到一个已经排好序得有序序列中,直到所有的记录插完为止,得到一个新的有序序列。
(2)代码实现
问题1: 在实现排序时我们是直接完整的将其写出吗?
问题2: 直接插入排序是怎样将一个元素插入到一个有序区间,并保证插入后仍然是一个有序区间。
单趟直接插入排序:
//升序
//实现排序先局部在整体
//单趟——将一个元素插入到一个有序区间,并保证插入后仍然是一个有序区间。
void InsertSort(int* a, int n)
{
int end;//插入元素的前一个元素下标
int tmp;//插入元素
// 将tmp插入到[0,end]区间中,保持有序
//1、end向后挪动
while (end >= 0)
{
if (tmp < a[end])
{
//往后挪动
a[end + 1] = a[end];
//迭代
end--;
}
else
{
break;
}
}
//2、插入tmp
a[end + 1] = tmp;
}
问题3: 如何将一个无序数组按照直接插入排序排成升序。
整体直接插入排序
:
//升序
//实现排序先局部在整体
//整体——将一个无序数组按照直接插入排序排成升序。
void InsertSort(int* a, int n)
{
//整体:把第一个元素看成升序,从第二个元素开始看成插入tmp,直到插入到最后一个元素停止。
int i = 1;
for (i = 1; i < n; i++)
{
//单趟
int end = i - 1;//插入元素的前一个元素下标
int tmp = a[i];//插入元素
// 将tmp插入到[0,end]区间中,保持有序
//1、end向后挪动
while (end >= 0)
{
if (tmp < a[end])
{
//往后挪动
a[end + 1] = a[end];
//迭代
end--;
}
else
{
break;
}
}
//2、插入tmp
a[end + 1] = tmp;
//打印观察每一次排序后的结果
PrintArray(a, n);
}
}
tip:以升序为例总结直接插入排序
- 时间复杂度
- 最好情况:升序排升序——O(N)
- 最坏情况:降序排升序——O(N^2)
- 元素集合越接近有序,直接插入排序算法的时间效率越高
- 空间复杂度:O(1),它是一种稳定的排序算法
- 稳定性:稳定
2、希尔排序(缩小增量排序)
(1)基本思想
基本思想:
- 预排序:分组插排,目的是使数组接近有序
- 直接插入排序
(2)代码实现
问题1: 预排序为什么会使数组接近有序?
代码示例1:预排序——一组排完再排另一组
//希尔排序
// 1、预排序
// 2、直接插入排序
// 先局部再整体
//单趟的预排序
void ShellSort(int* a, int n)
{
//预排序
//①分组
int gap = 3;//假设分为三组
//方式一:一组排完再排另外一组
for (int j = 0; j < gap; j++)
{
//②对每组数据直接插入排序
for (int i = j + gap; i < n ; i += gap)
{
//单趟
int end = i - gap;
int tmp = a[i];
//1、end向后挪动
while (end >= 0)
{
if (tmp < a[end])
{
a[end + gap] = a[end];
end -= gap;
}
else
{
break;
}
}
//2、插入tmp
a[end + gap] = tmp;
}
//观察每一组排完序后的序列
PrintArray(a, n);
}
}
tip: 预排序我们套了三段循环,看着有点复杂了,我们可以对其优化——》语句合并:把第一层循环和第二层循环合并,注合并之后效率并没有改变。
代码示例2:预排序——多组并排
void ShellSort(int* a, int n)
{
//预排序
//①分组
int gap = 3;//假设分为三组
//方式二:多组并排
//②对每组数据直接插入排序
for (int i = gap; i < n; i++)
{
//单趟
int end = i - gap;
int tmp = a[i];
//1、end向后挪动
while (end >= 0)
{
if (tmp < a[end])
{
a[end + gap] = a[end];
end -= gap;
}
else
{
break;
}
}
//2、插入tmp
a[end + gap] = tmp;
}
//观察一次预排序后的序列
PrintArray(a, n);
}
问题2: gap是多少合适?
代码示例3:整体的希尔排序
//整体的希尔排序
void ShellSort(int* a, int n)
{
int gap = n;
while (gap > 1)
{
//预排序
//①分组
gap = gap / 2;//逐渐接近1,最后等于1
//方式二:多组并排
//②对每组数据直接插入排序
for (int i = gap; i < n; i++)
{
//单趟
int end = i - gap;
int tmp = a[i];
//1、end向后挪动
while (end >= 0)
{
if (tmp < a[end])
{
a[end + gap] = a[end];
end -= gap;
}
else
{
break;
}
}
//2、插入tmp
a[end + gap] = tmp;
}
//观察一次预排序后的序列
PrintArray(a, n);
}
}
希尔排序特性总结:
- 希尔排序是对直接插入排序的优化。
- 当gap > 1时都是预排序,目的是让数组更接近有序。当gap == 1时就是直接插入排序,这时数组已经接近有序了,效率高,所以说希尔是对直接插入的优化。
- 稳定性:不稳定。
- 时间复杂度:O(N^1.3)(tip:量级略大于O(N*logN))
二、选择排序
1、直接选择排序
(1)基本思想
基本思想:每一次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始位置,直到全部待排序的数据元素排完。
(2)代码实现
问题1: 怎样选出最小的数据元素?
代码示例:直接选择排序——遍历一次只选一个数
//交换
void Swap(int* e1, int* e2)
{
int temp = *e1;
*e1 = *e2;
*e2 = temp;
}
//直接选择排序
void SelectSort(int* a, int n)
{
//直接选择排序——n个数据,需要选n-1次
for (int i = 0; i < n - 1; i++)
{
//在[i , n - 1]区间,选择最小的数据元素
int minPos = i;
for (int j = i + 1; j < n; j++)
{
if (a[j] < a[minPos])
{
minPos = j;
}
}
//交换——最小元素与第一个元素交换
Swap(&a[i], &a[minPos]);
}
}
tip: 直接选择排序遍历一次只能选一个最小(或最大)的数,对其优化——》①遍历一次区间[left,right],选出最大的数和最小的数;②最小的与这组元素的第一个交换,最大的与这组元素的最后一个交换;③缩小区间[left+1,right-1],在这个区间重复上述操作,直到left >= right结束。
代码示例:优化的直接选择排序——遍历一次选两个数
//优化的直接选择排序——遍历一次选两个数
void SelectSort(int* a, int n)
{
int left = 0;
int right = n - 1;
while (left < right)
{
//在[left , right]区间,选出最小和最大的两个数
int minPos = left, maxPos = left;
for (int i = left + 1; i <= right; i++)
{
//选出最小
if (a[i] < a[minPos])
{
minPos = i;
}
//选出最大
if (a[i] > a[maxPos])
{
maxPos = i;
}
}
//排升序:小左大右
Swap(&a[left], &a[minPos]);
//如果left和maxPos重叠,需要修正maxPos
if (left == maxPos)
{
maxPos = minPos;
}
Swap(&a[right], &a[maxPos]);
//迭代
left++;
right--;
}
}
注意: 如果left和maxPos重叠,需要修正maxPos的位置,因为通过第一次Swap交换之后maxPos的位置可能改变到minPos。
tip:直接选择排序的特性总结
- 时间复杂度
- 最坏时间复杂度:O(N^2)
- 最好时间复杂度:O(N^2)
- 直接选择排序非常好理解,但是效率不好,实际中很少使用
- 稳定性:不稳定
2、堆排序
(1)基本思想
堆排序是利用堆的思想所设计的一种排序,它是选择排序的一种。
基本思想:
- 建堆
- 升序:建大堆
- 降序:建小堆
- 利用堆删除思想来进行排序
(2)代码实现
因为堆排序我在堆应用那篇博客已经详细讲解了,所以这里我们直接实现堆排序。
堆排序详细讲解链接
代码实现:堆排序
//向下调整——大堆
void AdjustDown(int* a, int n, int parent)
{
int child = parent * 2 + 1;//保存较大孩子的下标
//向下调整——调整到叶子结束
while (child < n)
{
//注意:要先判断右孩子是否为有效数据
if (child + 1 < n && a[child] < a[child + 1])
{
++child;
}
//当父亲小于孩子时才向下调整
if (a[parent] < a[child])
{
Swap(&a[parent], &a[child]);
//迭代
parent = child;
child = parent * 2 + 1;
}
else
{
break;
}
}
}
//堆排序
void HeapSort(int* a, int n)
{
//向下调整建堆——从倒数第一个非叶子结点开始向下调整,然后向前迭代,直到根才结束。
int i = (n - 2) / 2;
for (i = (n - 2) / 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等于数组前面的数据个数
//迭代
--end;
}
}
tip:堆排序特性总结
- 时间复杂度:O(N*logN)——》堆排序使用堆来选数,效率就高了很多。
- 空间复杂度:O(1)
- 稳定性:不稳定
三、交换排序
1、冒泡排序
(1)基本思想
百度百科:
tip:
- 相邻元素两两比较,大的就往后交换,即一趟冒泡解决一个数字
- 确定趟数:因为一次冒泡解决一个数字,所以趟数 = 元素个数 - 已经排过的趟数 - 1
(2)代码实现
代码示例:冒泡排序
//冒泡排序
void BubbleSort(int* a, int n)
{
//整体:n个元素,需要n - 1趟冒泡
for (int j = 0; j < n - 1; j++)
{
//单趟:相邻两两比较,大的往后交换
for (int i = 1; i < n - j; i++)
{
if (a[i - 1] > a[i])
{
Swap(&a[i - 1], &a[i]);
}
}
}
}
tip: 根据分析:上述代码的时间复杂度,最好情况和最坏情况都是O(N^2),即不管数组是否有序,我们都需要n-1趟冒泡,所以我们对其优化——如果一趟冒泡之后如果没有交换,说明序列已经有序了,就结束排序。(可以定义一个变量exchange来判断是否发生交换——exchange初始化为false,如果发生交换exchange=true)
代码示例:冒泡排序——优化:有序了,就结束排序
//冒泡排序——优化:当冒泡排序没有发生交换时,结束排序
void BubbleSort(int* a, int n)
{
//整体:n个元素,需要n - 1趟冒泡
for (int j = 0; j < n - 1; j++)
{
bool exchange = false;
//单趟:相邻两两比较,大的往后交换
for (int i = 1; i < n - j; i++)
{
if (a[i - 1] > a[i])
{
exchange = true;
Swap(&a[i - 1], &a[i]);
}
}
//判断是否发生交换,没有发生交换,结束排序
if (exchange == false)
{
break;
}
}
}
2、快速排序
(1)基本思想
快速排序是Hoare于1962年提出的一种二叉树结构的交换排序方法,其基本思想为:任取待排序元素序列中的某个元素作为基准值,按照该排序码将待排序集合分割成两个子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止。
(2)代码实现
- 快排递归实现的框架
//快排递归实现的框架
void QuickSort(int* a, int left, int right)
{
//递归出口——当区间只有一个值or区间不存在就递推结束,开始回归
if (left >= right)
{
return;
}
//调用函数将区间[left,right]中的元素分割成两个部分,并接收基准值key的下标
int keyi = PartSort1(a, left, right);
//根据keyi继续划分左右两个子区间
//左区间[left,key-1]
QuickSort(a, left, keyi - 1);
//右区间[key+1,right]
QuickSort(a, keyi + 1, right);
}
- hoare法
问题1: hoare分割左右区间的方式?
hoare单趟1
:
//hoare版本:将区间按照基准值划分为左右两个部分
int PartSort1(int* a, int left, int right)
{
//选左边的为基准值
int keyi = left;
//直到L与R相遇结束
while (left < right)
{
//左边为基准值,右边先走
//右边选小
while (a[right] > a[keyi])
--right;
//左边选大
while (a[left] < a[keyi])
++left;
//交换R与L
Swap(&a[left], &a[right]);
}
//交换L与keyi
Swap(&a[left], &a[keyi]);
return left;
}
调试发现left不会走,如下图:
既然left为基准值不会往后走,那我们一开始就left++可以解决该问题吗?
不可以,它的本质问题是当left/right与key相等时,left/right就不会走,所以left++不可以解决该问题,如下面两个场景:
- 场景1:当left/right都遇到与基准值key相等的情况时,死循环!
- 场景2:当基准值key右边的值都大于基准值时,排完序后并没有满足单趟排序的目的
解决该问题的方式是,相等时left/right也可以继续向后走,因为相等的在左边右边都可以,所以没必要交换。
hoare单趟2
:
//hoare版本:将区间按照基准值划分为左右两个部分
int PartSort1(int* a, int left, int right)
{
//选左边的为基准值
int keyi = left;
//直到L与R相遇结束
while (left < right)
{
//左边为基准值,右边先走
//右边选小
while (a[right] >= a[keyi])
--right;
//左边选大
while (a[left] <= a[keyi])
++left;
//交换R与L
Swap(&a[left], &a[right]);
}
//交换L与keyi
Swap(&a[left], &a[keyi]);
return left;
}
调试之后发现,如果基准值key右边的值都大于或等于key时,right一直走,会发生越界(left也可能越界)
解决方案:加一个结束条件,当left>=right时,left/right不再走。
hoare单趟3
:
//hoare版本:将区间按照基准值划分为左右两个部分
int PartSort1(int* a, int left, int right)
{
//选左边的为基准值
//注意:快排的操作是在原数组上操作的,所以我们使用指针标识基准值,不使用临时变量保存key
//使用临时变量保存基准值,最后交换时,交换的只是临时变量
int keyi = left;
//直到L与R相遇结束
while (left < right)
{
//左边为基准值,右边先走
//右边选小,
//注意:特殊情况key右边的值都大于或等于key时,right越界
while (left < right && a[right] >= a[keyi])
--right;
//左边选大
while (left < right && a[left] <= a[keyi])
++left;
//交换R与L
Swap(&a[left], &a[right]);
}
//交换L与keyi
Swap(&a[left], &a[keyi]);
//返回基准值最终的位置
return left;
}
问题2: 为什么相遇点一定比key小?
- 挖坑法
问题1: 挖坑法分割左右区间的方式?
//挖坑法:将区间按照基准值划分为左右两个部分
int PartSort2(int* a, int left, int right)
{
//选左边的为基准值,将基准值保存到一个临时变量中,这个时候该位置形成一个坑位
int key = a[left];
int hole = left;
//直到L与R相遇结束
while (left < right)
{
//左边为坑位,右边先走
//右边选小,
//注意:特殊情况key右边的值都大于或等于key时,right越界
while (left < right && a[right] >= key)
--right;
//找到比key小的,将小的值放到坑位,更新坑位
a[hole] = a[right];
hole = right;
//左边选大
while (left < right && a[left] <= key)
++left;
//找到比key大的,将大的值放到坑位,更新坑位
a[hole] = a[left];
hole = left;
}
//把基准值放到相遇点的坑位
a[hole] = key;
//返回基准值最终的位置
return hole;
}
- 前后指针法
问题1: 前后指针法分割左右区间的方式?
//前后指针法:将区间按照基准值划分为左右两个部分
int PartSort3(int* a, int left, int right)
{
//选左边为基准值
int keyi = left;
//初始化前后指针
int prev = left;
int cur = left + 1;
//当cur越界时结束
while (cur <= right)
{
//当cur找到的值小于key,++prev,cur和prev位置的值交换
//注意:避免自己交换自己
if (a[cur] < a[keyi] && a[++prev] != a[cur])
{
Swap(&a[cur], &a[prev]);
}
//迭代
++cur;
}
//cur越界时,交换prev和key的值
Swap(&a[prev], &a[keyi]);
//返回基准值最终的位置
return prev;
}
- 快排的时间复杂度
随机选key
:
//前后指针法:将区间按照基准值划分为左右两个部分
int PartSort3(int* a, int left, int right)
{
//随机选key——针对快排最坏情况的优化
//注意:可能区间的起始点不是0,所以需要加上left
int randi = left + rand() % (right - left);
Swap(&a[left], &a[randi]);
//仍选左边为基准值
int keyi = left;
//初始化前后指针
int prev = left;
int cur = left + 1;
//当cur越界时结束
while (cur <= right)
{
//当cur找到的值小于key,++prev,cur和prev位置的值交换
//注意:避免自己交换自己
if (a[cur] < a[keyi] && a[++prev] != a[cur])
{
Swap(&a[cur], &a[prev]);
}
//迭代
++cur;
}
//cur越界时,交换prev和key的值
Swap(&a[prev], &a[keyi]);
//返回基准值最终的位置
return prev;
}
三数取中
:
//从区间开始、中间、结束三个值,选出中间值的下标
int GetMidNumi(int* a, int begin, int end)
{
int mid = (begin + end) / 2;
if (a[begin] < a[mid])
{
if (a[mid] < a[end])// a[begin] < a[mid] < a[end]
{
return mid;
}
else if (a[begin] > a[end])// a[end] < a[begin] < a[mid]
{
return begin;
}
else// a[begin] < a[end] < a[mid]
{
return end;
}
}
else// a[begin] > a[mid]
{
if (a[mid] > a[end])// a[begin] > a[mid] > a[end]
{
return mid;
}
else if (a[begin] < a[end])// a[mid] < a[begin] < a[end]
{
return begin;
}
else// a[begin] > a[end] > a[mid]
{
return end;
}
}
}
//hoare版本:将区间按照基准值划分为左右两个部分
int PartSort1(int* a, int left, int right)
{
//三数取中
int midi = GetMidNumi(a, left, right);
if (midi != left)
Swap(&a[midi], &a[left]);
//选左边的为基准值
//注意:快排的操作是在原数组上操作的,所以我们使用指针标识基准值,不使用临时变量保存key
//使用临时变量保存基准值,最后交换时,交换的只是临时变量
int keyi = left;
//直到L与R相遇结束
while (left < right)
{
//左边为基准值,右边先走
//右边选小,
//注意:特殊情况key右边的值都大于或等于key时,right越界
while (left < right && a[right] >= a[keyi])
--right;
//左边选大
while (left < right && a[left] <= a[keyi])
++left;
//交换R与L
Swap(&a[left], &a[right]);
}
//交换L与keyi
Swap(&a[left], &a[keyi]);
//返回基准值最终的位置
return left;
}
- 快排的优化:小区间使用直接插入排序,减少递归
//快排递归实现的框架
void QuickSort(int* a, int left, int right)
{
//递归出口——当区间只有一个值or区间不存在就递推结束,开始回归
if (left >= right)
{
return;
}
//小区间优化:减少递归次数
if (right - left + 1 <= 10)
{
//直接插入排序
InsertSort(a + left, right - left + 1);//注意:小区间不一定是从头开始的,可能在中间,所以需要+left
}
else
{
//调用函数将区间[left,right]中的元素分割成两个部分,并接收基准值key的下标
int keyi = PartSort1(a, left, right);
//根据keyi继续划分左右两个子区间
//左区间[left,key-1]
QuickSort(a, left, keyi - 1);
//右区间[key+1,right]
QuickSort(a, keyi + 1, right);
}
}
tip:快排一般不看最坏,因为快排加了优化,几乎不会出现最坏,时间复杂度的量级还是在O(N*logN)
- 快排的非递归
//快排非递归
void QuickSortNonR(int* a, int left, int right)
{
ST st;
STInit(&st);
//初始状态
STPush(&st, left);
STPush(&st, right);
//判断栈是否为空
while (!STEmpty(&st))
{
//不为空,区间出栈
int end = STTop(&st);
STPop(&st);
int begin = STTop(&st);
STPop(&st);
//调用函数单趟排序分割区间
int keyi = PartSort3(a, begin, end);
//区间入栈
//注意:当区间只有一个值或不存在时就不入栈了
//[begin,keyi-1] keyi [keyi+1,end]
if (keyi + 1 < end)
{
STPush(&st, keyi + 1);
STPush(&st, end);
}
if (begin < keyi - 1)
{
STPush(&st, begin);
STPush(&st, keyi - 1);
}
}
//排完序,销毁栈
STDestroy(&st);
}
tip:快排特性总结
- 快排一般不看最坏,因为快排加了优化,几乎不会出现最坏,时间复杂度的量级还是在O(N*logN)
- 空间复杂度:O(logN)
- 稳定性:不稳定
四、归并排序
1、基本思想
归并排序是建立在归并操作上的一种有效,稳定的排序算法,该算法是采用分治法(Divide and Conquer)的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为二路归并。
2、归并排序的步骤
在总结归并排序的步骤之前,我们先来清楚以下几个问题:
- 问题1:两个有序区间是怎样归并成一个有序区间的?
- 问题2:归并的前提是左右区间有序,那我们怎么让它有序?
- 问题3:归并是直接递归调用自己吗?
归并排序的步骤:
- 开辟一个临时空间,存放归并后的序列,空间大小与排序数组一样大
- 调用核心子函数来实现归并排序
3、归并的递归写法
//完成归并排序
void _MergeSort(int* a, int left, int right, int* tmp)
{
//递归出口:区间分解到一个值或不存在就不分解了
if (left >= right)
{
return;
}
//分解——使左右区间有序
int mid = (left + right) / 2;
//[left,mid] [mid+1,right] 子区间递归分解
_MergeSort(a, left, mid, tmp);
_MergeSort(a, mid + 1, right, tmp);
//合并——左右区间有序了就归并
int begin1 = left, end1 = mid;
int begin2 = mid + 1, 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++];
}
//将归并后的结果拷贝回原数组
//注意:区间并不一定从下标0开始,可能为在中间,所以需要加上left
memcpy(a + left, tmp + left, sizeof(int) * (right - left + 1));
}
//归并排序的递归写法
void MergeSort(int* a, int n)
{
//创建一个临时数组存放每一次归并后的序列
int* tmp = (int*)malloc(sizeof(int) * n);
assert(tmp);
//调用核心子函数完成归并排序
_MergeSort(a, 0, n - 1, tmp);
//使用完堆空间,记得释放
free(tmp);
}
4、归并的非递归写法
- 问题1: 我们需要借用栈来辅助改循环吗?
错误代码示例
:
//归并排序的非递归
void MergeSortNonR(int* a, int n)
{
//创建一个临时数组存放每一次归并后的序列
int* tmp = (int*)malloc(sizeof(int) * n);
assert(tmp);
//gap存放每组区间的元素个数
int gap = 1;
while (gap < n)
{
//合并左右区间
for (int i = 0; i < n; i += 2 * gap)
{
//每个区间有gap个元素
//[i,i+gap-1] [i+gap,i+2*gap-1]
int begin1 = i, end1 = i + gap - 1;
int begin2 = i + gap, end2 = i + 2 * gap - 1;
int j = i;
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++];
}
}
//间隔为gap的多组数据,归并完以后一次性将归并后的结果拷贝回原数组
memcpy(a, tmp, sizeof(int) * n);
gap *= 2;
}
//使用完堆空间,记得释放
free(tmp);
}
- 问题2:上述代码错误在哪?
- 问题3: 当数组长度不是2的次方倍时,我们怎样解决数组越界问题呢?
- 问题4:归并之后需要将排好序的结果拷贝回原数组,是归并一部分就拷贝回一部分好呢,还是间隔为gap的多组数据,归并完以后,一把拷贝原数组好呢?
正确代码1
:一次性拷贝,不能直接break,需要修正边界
//间隔为gap的多组数据,归并完以后一次性将归并后的结果拷贝回原数组
void MergeSortNonR(int* a, int n)
{
//创建一个临时数组存放每一次归并后的序列
int* tmp = (int*)malloc(sizeof(int) * n);
assert(tmp);
//gap存放每组区间的元素个数
int gap = 1;
while (gap < n)
{
//合并左右区间
for (int i = 0; i < n; i += 2 * gap)
{
//每个区间有gap个元素
//[i,i+gap-1] [i+gap,i+2*gap-1]
int begin1 = i, end1 = i + gap - 1;
int begin2 = i + gap, end2 = i + 2 * gap - 1;
//一次性拷贝不能直接break,修正越界
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 = i;
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++];
}
}
//间隔为gap的多组数据,归并完以后一次性将归并后的结果拷贝回原数组
memcpy(a, tmp, sizeof(int) * n);
gap *= 2;
}
//使用完堆空间,记得释放
free(tmp);
}
正确代码
:归并一部分拷贝一部分
void MergeSortNonR(int* a, int n)
{
//创建一个临时数组存放每一次归并后的序列
int* tmp = (int*)malloc(sizeof(int) * n);
assert(tmp);
//gap存放每组区间的元素个数
int gap = 1;
while (gap < n)
{
//合并左右区间
for (int i = 0; i < n; i += 2 * gap)
{
//每个区间有gap个元素
//[i,i+gap-1] [i+gap,i+2*gap-1]
int begin1 = i, end1 = i + gap - 1;
int begin2 = i + gap, end2 = i + 2 * gap - 1;
//归并一部分拷贝一部分
if (end1 >= n || begin2 >= n)
{
break;
}
if (end2 >= n)
{
end2 = n - 1;
}
int j = i;
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 + i, tmp + i, sizeof(int) * (end2 - i + 1));
}
gap *= 2;
}
//使用完堆空间,记得释放
free(tmp);
}
补充:外排序
tip:归并排序特性总结
- 归并的缺点在于需要O(N)的空间复杂度,归并排序的思想更多是解决磁盘中的外排序问题
- 时间复杂度:O(N*logN)
- 空间复杂度:O(N)
- 稳定性:稳定
五、非比较排序
1、常见的非比较排序
- 计数排序:计数排序适合范围集中,且范围不大的整形数组排序,不适合范围分散或非整形的排序,例如字符串、浮点数、结构体等
- 基数排序:实际中没有什么应用价值,校招也不考,可学不可学
- 桶排序:设计太差了,不推荐学习
- 综上非比较排序我们这里只详细讲解计数排序
2、计数排序
(1)基本思想
计数排序又称为鸽巢原理,是对哈希直接定址法的变形应用。用到了哈希映射的思想。
(2)操作步骤
- 统计每个数据出现的次数
- 排序:根据countA数组统计的结果,将数据读会原数组
//计数排序
void CountSort(int* a, int n)
{
//相对映射,先找出max、min算范围
int max = a[0], min = a[0];
for (int i = 0; i < n; ++i)
{
if (max < a[i])
{
max = a[i];
}
if (min > a[i])
{
min = a[i];
}
}
int range = max - min + 1;//左闭右闭区间个数需要+1
int* countA = (int*)malloc(sizeof(int) * range);
assert(countA);
//malloc不会初始化,使用memset初始化数组
memset(countA, 0, sizeof(int) * range);
//计数
for (int i = 0; i < n; ++i)
{
//相对位置映射:下标对应数据的范围位置,即为a[i]-min
countA[a[i] - min]++;
}
//排序
int j = 0;//原数组下标
for (int i = 0; i < range; ++i)
{
while (countA[i]--)
{
a[j++] = i + min;
}
}
free(countA);
}
tip:计数排序特性总结
- 计数排序在数据范围集中时,效率很高,但是使用范围及场景有限
- 时间复杂度:O(N+范围)
- 空间复杂度:O(范围)
- 稳定性:稳定
五、总结
-
稳定性:假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中r[i]=r[j],且r[i]在r[j]之前,而在排序后的序列中,r[i]仍在r[j]之前,则称这种排序算法是稳定的;否则称为不稳定的。如图:
-
稳定性的意义:例如在高考中的排名就需要稳定性。
-
排序稳定性的分析,如图: