目录
一.经典的冒泡排序
实现原理:
代码实现:
//冒泡排序
void Bubble_Sort(Elemtype* arr, int size)
{
for (int i = 0; i < size; i++)
{
for (int j = 0; j < size - i - 1; j++)//每次都确定待排序区间内的最后一个元素,所以,每次的查找区间就可以缩小一个
{
if (arr[j] > arr[j + 1])//交换
{
Elemtype temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;
}
}
}
}
性能分析:
二.插入排序
实现原理:
//直接插入排序
void Insert_Sort(Elemtype* arr, int size)
{
for (int i = 0; i <size; i++)
{
int end = i - 1;
Elemtype temp = arr[i];
//[0,end]是有序的,我们插入一个数据后依然有序
while (end >= 0)
{
if (arr[end] > temp)//只要比要插入的数据大,那么就将较大的数据后移给要插到前面去数字腾出位置来
{
arr[end + 1] = arr[end];
end--;
}
else//找到了新插入的数该待的位置就退出
break;
}
arr[end + 1] = temp;//最后不要忘了把插入的数据插入到它该待的位置上去
}
}
性能分析
三.希尔排序
实现原理:
代码实现:
//希尔排序
void Shell_Sort(Elemtype* arr, int size)
{
//定义一个间隔,规定数组按这个间隔划分
int gap = size;
while (gap > 1)
{
gap = gap / 3 + 1;//加1的目的可以保证最后一个gap一定是1,从而能够执行最后的插入排序
for (int i = 0; i < size - gap; i++)
{
int end = i;
Elemtype temp = arr[end + gap];
while (end >= 0)
{
if (arr[end] > temp)
{
arr[end + gap] = temp;
end -= gap;
}
else
break;
}
arr[end + gap] = temp;
}
}
}
性能分析
四.选择排序
实现原理:
实现代码:
void SelectSort(Elemtype* a, int n)
{
int begin = 0, end = n - 1;
while (begin < end)
{
int maxi = begin, mini = begin;
for (int i = begin; i <= end; i++)
{
if (a[i] > a[maxi])
{
maxi = i;
}
if (a[i] < a[mini])
{
mini = i;
}
}
//找到了最大的和最小的,分别放在两端,缩小区间继续查询
//这里注意交换可能会出现问题,如果begin或者end处就是是最大值或者最小值,那么在同一个位置就会被交换两次值,导致排序发生错误,我们应当单独处理它们
//第一次交换是没有问题的
Elemtype temp = a[begin];
a[begin] = a[mini];
a[mini] = temp;
//如果原来begin处的位置是最大的数,那么它此时已经被交换到了mini下标处,
if (maxi == begin)
maxi = mini;//此时的最大值已经被交换到mini下标处
Elemtype temp2 = a[maxi];
a[maxi] = a[end];
a[end] = temp2;
//不要忘记向中间缩进
begin++;
end--;
}
}
性能分析
五.堆排序
实现原理
1.建堆
如何建堆,建大堆还是建小堆?
我们以上面的这个已经根据数组建好的一棵完全二叉树为例:
我们如何将上述的完全二叉树转换为一个最大堆或者最小堆呢?
向下调整算法
建堆的关键就是让完全二叉树具有堆的性质,也就是根节点是数组的最大或者最小元素,为此,我们需要建上面的二叉树进行调整使最大或者最小的元素放在根节点的位置上去,我们可以选择从最容易调整的部分开始,也就是选择最右下角的最末尾的那棵子树先进行调整,接着逐层往上,每次调整的原则是将父节点与儿子节点中的最小的或者最大的进行交换,这样可以保证最小的或者最大的那个元素一直在向上走,我们以上面的二叉树为例给出建一个最小堆的演示:
2.如何通过调整堆来进行排序?
代码实现
//堆排序->(排升序建大堆,排降序建小堆)
//向下调整算法
void Adjustdown(Elemtype* a,int parent, int n)
{
int child = 2 * parent + 1;//左孩子下标
while (child < n)
{
if (child + 1 < n && a[child + 1] > a[child])//降序排序找较大的则修改为a[child + 1] < a[child]
child++;
if (a[parent] < a[child])//降序排序建大堆则修改为a[parent] > a[child]
{
Elemtype temp = a[parent];
a[parent] = a[child];
a[child] = temp;
//第一次交换完成后,更新父节点和孩子节点,将后续的已经更新的堆再次更新
parent = child;
child = 2 * parent + 1;
}
else//如果父节点比儿子节点大了,说明其下方的已经满足大堆的条件了,不需要在调整了
break;
}
}
void HeapSort(Elemtype* a, int n)
{
//首先我们需要建堆,这里我们以排升序为例,建大堆
//对比于将for循环放在向下调整算法内部,下面的Floyed建堆算法能在o(n)时间内建好堆,虽然函数逻辑上没有发生变化,但是复杂度计算上可以优化很多,因为只有嵌套的复杂度才是相乘的,在向下调整算法中,一次只传入一个父节点进行比较,相比于向下调整算法中的for循环更好
for (int i = (n - 1 - 1) / 2; i >= 0; i--)
{
Adjustdown(a,i, n);
}
//printf("建大堆后:->\n");
//for (int i = 0; i < n; i++)
// printf("%d ", a[i]);
//printf("\n");
int end = n - 1;
//先将根节点和最后一个叶子节点交换,输出并删除最后一个元素,重新调整堆并继续输出
while (end > 0)//没必要等于0,end=1就已经有序了
{
//堆的根节点和最后一个节点交换
Elemtype temp = a[0];
a[0] = a[end];
a[end] = temp;
//printf("%d ", a[end]);
//由于堆结构被破坏,我们需要再次调用向下调整算法恢复大堆结构
Adjustdown(a,0, end);
end--;//删除最后一个元素
}
}
性能分析
堆排序的时间复杂度为O(n log n),其中n是数组的大小。对于堆的构建操作,时间复杂度为O(n)。它将无序的数组构建成一个堆。进行n次删除堆顶元素操作,每次Heapify的时间复杂度为O(log n)。这是因为堆的高度是log n。
堆排序是一种原地排序算法,不需要额外的存储空间。
- 堆排序具有稳定性,可以保持相同值的元素相对顺序。
- 堆排序是一种不稳定的原地排序算法,因为交换元素会破坏相同值的元素原有的相对顺序。
堆排序适用于以下情况:
- 对于大规模数据的排序,堆排序相对较快,并且不会导致额外的内存开销。
- 当需要对稳定性没有要求时,堆排序是一种高效的排序算法选择。
需要注意的是,堆排序虽然具有较好的时间复杂度和空间复杂度,但在实现上稍微复杂一些。相比于其他排序算法,堆排序的代码可能相对复杂。因此,在实践中,如果不考虑稳定性的情况下,快速排序和归并排序可能更常用,因为它们更简单直观。
六.快速排序
实现原理:
快排是比较重要的一个排序,很多编程语言的排序有关的接口,都是基于快排的思想所实现的,快排也是综合性能较好的一个排序算法,快排是基于递归分治的思想,通过选定基准元素,并且将数组中的元素按与基准元素的比较结果放在基准元素的两侧,其带来的结果就是使得数组以基准元素分成两部分,然后我们分别对这两部分再重复进行上述的过程,知道被分成的某一部分元素个数为1或者为0即可终止并返回。
代码实现:
递归函数
在递归函数中,我们需要实现按选定的基准元素来将数组分成两个部分,并且确定递归结束返回的条件,对于返回条件,易知是待排序部分只剩至多一个元素时即可停止,所以递归函数便可以得出。
void QuickSort(Elemtype* a, int n,int l,int r)
{
if (l < r)
{
int idx = partion2(a, l, r);//找到基准元素应在的位置
QuickSort(a,n, l, idx - 1);
QuickSort(a, n, idx + 1, r);
}
}
基准值位置寻找函数
1.hoare版本
原理1:如何保证每次找到的位置上的数一定比基准元素小?
这里我们先给出结论:
左边做基准元素,就让右边先走,保证了相应的位置比基准元素小;右边做基准元素,让左边先走,保证了相应的位置比基准元素大。
我们就拿其中一条来解释:左边做基准元素,就让右边先走,保证相应的位置比基准元素小。
左边做基准元素,我们期望找到的是一个能将整个数组按基准元素划分为大于基准元素和小于基准元素的两部分,所以我们需要找到中间的某个位置上的数,这个位置上的数比基准元素小,才能将基准元素与找到的位置进行交换,所以此时两个指针相遇的位置上的元素一定是要比基准元素小的,两个指针相遇无非就是两种情况:
1.最后一步是R遇到的L,在上一轮的交换中,由于L和R位置上的交换,所以当前L上的元素是小于基准元素,R上的位置是大于基准元素的,如果开始就让R先走,则在当前轮次中R先走,R去找到了L。也就是到了一个小于基准元素的位置,这样该位置上的元素才能够和基准元素进行交换以满足要求。
2.最后一步是L遇上的R,同样的,在上一轮交换中,由于L和R的交换,此时R上的元素是大于基准元素的数,让R先走,保证了最后一步L遇上R时相应的R的位置上是比基准元素小的元素。
综上,在根据上面的动图,我们不难根据该原理写出如下的初始逻辑代码:
void partion(Elemtype* a, int n, int l, int r)
{
int keyi = l;//选定最左侧为基准元素
while (l < r)
{
while (a[r] > a[keyi]) r--;
while (a[l] < a[keyi]) l++;
//交换
Elemtype temp = a[l];
a[l] = a[r];
a[r] = temp;
}
//两个指针相遇时即找到了基准元素的位置
Elemtype temp1 = a[keyi];
a[keyi] = a[l];
a[l] = temp1;
}
下面我们一起来修正上面代码中的细节问题,以修正我们的逻辑代码:
问题1:while带来的死循环问题
问题2:基准元素无限小导致数组访问越界
经过上述的修改,我们就可以得出正确的分割函数:
//法1,经典算法
int partion1(Elemtype* a, int l, int r)
{
int keyi = l;//选择最左侧为基准元素
while (l < r)
{
while (l < r && a[r] >= a[keyi])r--;//右侧先找到第一个比基准值小的数
while (l < r && a[l] <= a[keyi]) l++;//左侧在找到第一个比基准值大的数
//交换
Elemtype temp = a[l];
a[l] = a[r];
a[r] = temp;
}
//当r和l相遇,我们就找到了基准元素在数组中的位置,也就是r和l所在的位置
Elemtype temp1 = a[keyi];
a[keyi] = a[l];
a[l] = temp1;
return l;//返回基准元素应该在的位置
}
第n次排序后的结果问题
在快速排序中,每次排序都选择一个基准元素,并根据该基准元素将数组分割为两个子数组。根据基准的选择和分割操作,每次排序都会将数组中的一部分元素放置在正确的位置上。
在排序的过程中,通过不断地选择基准元素和分割数组,最终得到有序的数组。但是每次排序的具体结果取决于基准元素的选择以及分割操作的具体实现。
所以,要回答第n次排序后数组的问题,需要了解每次排序中基准元素的选择方式,并可跟踪每次排序后的分割操作。这也是应试中经常考察的问题。
比如,给定一个数组为{ 4,7,1,9,3,6,5,8,3,2,0 },假设以最左侧为基准元素,求解第n次排序后的数组,我们只需要在分割函数内部进行数组的打印即可。
2.挖坑法
挖坑法就是经典方法的变种,转变一种方式,我们将初始基准元素保存并将该位置空出,接着我们继续上面找数的方法,每次将左右两个指针找出来的数和坑的位置进行交换并更新坑的位置,这样一来,最终两个指针相遇的位置也就是最后一个坑的位置,此时就找到了基准元素的位置,以此位置进行分割即可。
//法二,挖坑法
int partion2(Elemtype* a,int n, int l, int r)
{
int key = a[l];//将基准值保存起来
int holei = l;//保存基准元素的位置
while (l < r)
{
//从右侧找到第一个小于基准元素的值,交换数据并更新坑的位置
while (l < r && a[r] >= key)
r--;
a[holei] = a[r];
holei = r;
//同上
while (l < r && a[l] <= key)
l++;
a[holei] = a[l];
holei = l;
}
//最终找到了基准元素的位置也即两个指针相遇的位置
a[holei] = key;
return holei;
}
3.前后指针法
通过设置前后两个指针,满指针追赶快指针的方式,如果两个指针所指的元素都满足比基准元素大或者比基准元素小的关系,则两个指针就会一直处于相邻的状态,倘若快指针遇到了不满足和慢指针相同的对基准元素的关系,那么就只让快指针继续向前走,慢指针留在原位置直到快指针再一次向前找到符合慢指针处的元素与基准元素的大小关系的元素,这样一来快慢指针中间就会产生间隔,我们将中间的间隔的比基准元素大的值用快指针指向的小的元素对其一一交换位置即可。
//法三,双指针法
int partion3(Elemtype* a,int n, int l,int r)
{
//前后指针初始化,将cur初始为prev指针的下一个
int keyi = l;
int prev = l;
int cur = l + 1;
while (cur <= r)
{
if (a[cur] < a[keyi]&&++prev<=cur)//一旦快慢指针有了间隔,我们就将快指针指向的元素逐渐与慢指针指向的元素交换
{
Elemtype temp = a[prev];
a[prev] = a[cur];
a[cur] = temp;
}
++cur;//让快指针指向下一个位置
}
//当cur走到结束,prev指针就是基准元素应该在的位置
Elemtype temp1 = a[prev];
a[prev] = a[keyi];
a[keyi] = temp1;
return prev;
}
性能分析
快速排序是一种常用的排序算法,其性能在平均情况下非常出色,但在最坏情况下会出现较差的性能。
性能分析:
- 最好情况下:当待排序的序列能够均匀划分时,快速排序的时间复杂度为O(nlogn)。
- 平均情况下:快速排序的时间复杂度为O(nlogn)。
- 最坏情况下:当待排序的序列已经有序或基本有序时,快速排序的时间复杂度为O(n^2)。这是因为在每一轮划分时,选择的基准元素可能导致划分非常不均匀,从而导致性能下降。
- 空间复杂度:快速排序的空间复杂度为O(logn),主要是由于递归调用栈的使用。
性能优化
通过上面的分析我们不难发现,快速排序在最坏的情况下复杂的能够达到O(n^2),反应到原理上就是每次的分割函数选择的位置都是偏向于头部或者末尾的位置,导致每次都要对将近数组长度的数据进行排序,从这里也可以看出,如果我们的基准元素每次能选到待排序部分的中位数,那么就会一直分成一半,效率也会大大提高,所以我们就照着这个方向进行优化。
三数取中法
因为我们只能大致的选择一个中间的数,而我们又不可能一个一个去找,所以我们大致认为选择最左侧,最右侧,中间位置,三个数中中等大小的那个数为较好的中间值,将选出来的值再与我们选择的基准元素的位置交换,这里的交换是为了保证原始的逻辑代码仍然成立,不至于基准元素的位置改变导致算法出错。当然,还有一种方法就是在每一轮排序开始前都随机选一个元素作为基准元素,代码与之类似,只是要注意将选出的元素与我们规定好的基准元素所在的位置上原本的值进行交换之后再继续就可以了。
代码实现就比较简单了,就是三个数比较取中间的数即可:
//优化:三数取中法
int GetMidIndex(Elemtype * a, int left, int right)
{
int mid = (left + right) / 2;
if (a[left] < a[mid])
{
if (a[mid] < a[right])
{
return mid;
}
else if (a[left] < a[right])
{
return right;
}
else
{
return left;
}
}
else // a[left] > a[mid]
{
if (a[mid] > a[right])
{
return mid;
}
else if (a[left] > a[right])
{
return right;
}
else
{
return left;
}
}
}
缺陷优化
快排的优良特性导致很多企业开始专门针对快排缺陷设计测试样例,比如数组全都是重复数字等样例,数据量一旦上涨,就会导致超时问题,所以,我们需要设计出一种可以防止针对的快排方法用来解决缺陷(有优就有劣,相对应的复杂度就不那么好了),这里我们给出一种hoare版本和前后指针版本的结合-三路划分法。
三路划分法-防针对
快速排序的三路划分法是对传统的快速排序算法的一个改进,主要用于解决在有大量重复元素的数组中排序时出现的性能问题。三路划分法将待排序的数组划分成三个部分:小于、等于和大于基准元素的部分。
具体步骤如下:
代码实现
//快排优化-三路划分法(优化重复元素问题)
void QuickSort2(Elemtype* a, int n, int l, int r)
{
if (l >= r)
return;
//选出合适的基准元素与左侧元素交换(以最左侧为基准元素)
int midi = rand() % (r - l + 1) + l;//取随机数防止针对
std::swap(a[midi], a[l]);
int key = a[l];
int cur = l+1, left = l, right = r;//三个指针
while (cur <= right)
{
if (a[cur] < key)//小于基准元素的放在左边
{
std::swap(a[cur], a[left]);
cur++;
left++;
}
else if (a[cur] > key)
{
std::swap(a[cur], a[right]);
right--;
}
else
cur++;
}
QuickSort2(a, n, l, left - 1);
QuickSort2(a, n, right+1,r);
}
非递归实现
原理介绍
快速排序是一种常用的、高效的排序算法,其递归实现通常基于分治的思想。然而,递归实现可能会导致调用栈溢出的问题,特别是在处理大规模数据时。为了解决这个问题,可以使用非递归的方式来实现快速排序,其中常用的数据结构是栈。
非递归实现快速排序的原理如下:
- 初始化栈,并将初始的左右边界(通常是数组的起始和结束索引)入栈。
- 当栈不为空时,执行以下操作:
- 出栈,获取当前的左右边界。
- 对当前边界进行划分,选择一个基准元素,并通过分区操作将所有小于基准元素的元素放在左边,所有大于基准元素的元素放在右边,并返回基准元素的索引。
- 如果左边的边界小于基准元素的索引减一,将左边界和基准元素索引减一入栈(表示对左边一段区间进行划分)。
- 如果右边的边界大于基准元素的索引加一,将基准元素索引加一和右边界入栈(表示对右边一段区间进行划分)。
- 重复步骤2,直到栈为空。
使用栈实现非递归的快速排序有以下意义:
- 避免了递归调用带来的额外开销。递归调用可能导致函数栈帧的创建和销毁,而使用栈可以手动控制每个子问题的边界,避免了频繁的函数调用开销。
- 解决了递归调用带来的可能的调用栈溢出问题。递归实现在处理大规模数据时,调用层次可能过深,导致调用栈溢出。非递归实现通过栈来保存每个子问题的边界,可以有效地避免这个问题。
- 降低了空间复杂度。非递归实现只需要一个栈来保存每个子问题的边界,相比递归实现,节省了额外的内存空间。
代码实现
//快速排序非递归实现
void QuickSort1(Elemtype* a, int n, int l, int r)
{
std::stack<std::pair<int, int>>st;//创建左右边界的数对元素构成的栈
st.push(std::make_pair( l,r ));
while (!st.empty())
{
std::pair<int, int> pa = st.top();
st.pop();
/*
if (pa.first < pa.second)
{
int idx = partion3(a, n, pa.first, pa.second);//找到基准元素应在的位置
//如果我们想要下一次还是按顺序遍历,栈先进后出,我们就需要将小的区间最后放入
st.push(std::make_pair( idx + 1,pa.second ));
st.push(std::make_pair( pa.first,idx - 1 ));
}
*/
//优化上述的注释部分,因为存在很多的无效区间被判断增加了复杂度,改为先判断区间再决定是否入栈
int idx = partion3(a, n, pa.first, pa.second);
if (idx + 1 < pa.second)
st.push(std::make_pair(idx + 1, pa.second));
if (idx - 1 > pa.first)
st.push(std::make_pair(pa.first, idx - 1));
}
}
快排完整代码
//快速排序
//优化:三数取中法选取较优的基准元素并返回位置
int GetMidIndex(Elemtype * a, int left, int right)
{
int mid = (left + right) / 2;
if (a[left] < a[mid])
{
if (a[mid] < a[right])
{
return mid;
}
else if (a[left] < a[right])
{
return right;
}
else
{
return left;
}
}
else // a[left] > a[mid]
{
if (a[mid] > a[right])
{
return mid;
}
else if (a[left] > a[right])
{
return right;
}
else
{
return left;
}
}
}
//法1,经典算法
int flag = 0;
int partion1(Elemtype* a,int n, int l, int r)
{
//优化,将三数中的中间大小的数与最左侧的基准元素交换
int midi = GetMidIndex(a, l, r);
//交换选择的元素与最左侧位置元素,以保证原逻辑正确性
Elemtype k = a[midi];
a[midi] = a[l];
a[l] = k;
int keyi = l;//选择最左侧为基准元素
while (l < r)
{
while (l < r && a[r] >= a[keyi])r--;//右侧先找到第一个比基准值小的数
while (l < r && a[l] <= a[keyi]) l++;//左侧在找到第一个比基准值大的数
//交换
Elemtype temp = a[l];
a[l] = a[r];
a[r] = temp;
}
//当r和l相遇,我们就找到了基准元素在数组中的位置,也就是r和l所在的位置
Elemtype temp1 = a[keyi];
a[keyi] = a[l];
a[l] = temp1;
printf("第%d次排序后的结果为:->\n",++flag);
for (int k = 0; k < n; k++)
printf("%d ", a[k]);
printf("\n");
return l;//返回基准元素应该在的位置
}
//法二,挖坑法
int partion2(Elemtype* a,int n, int l, int r)
{
//优化,将三数中的中间大小的数与最左侧的基准元素交换
int midi = GetMidIndex(a, l, r);
//交换选择的元素与最左侧位置元素,以保证原逻辑正确性
Elemtype k = a[midi];
a[midi] = a[l];
a[l] = k;
int key = a[l];//将基准值保存起来
int holei = l;//保存基准元素的位置
while (l < r)
{
//从右侧找到第一个小于基准元素的值,交换数据并更新坑的位置
while (l < r && a[r] >= key)
r--;
a[holei] = a[r];
holei = r;
//同上
while (l < r && a[l] <= key)
l++;
a[holei] = a[l];
holei = l;
}
//最终找到了基准元素的位置也即两个指针相遇的位置
a[holei] = key;
return holei;
}
//法三,双指针法
int partion3(Elemtype* a,int n, int l,int r)
{
//前后指针初始化,将cur初始为prev指针的下一个
//优化,将三数中的中间大小的数与最左侧的基准元素交换
int midi = GetMidIndex(a, l, r);
//交换选择的元素与最左侧位置元素,以保证原逻辑正确性
Elemtype k = a[midi];
a[midi] = a[l];
a[l] = k;
int keyi = l;
int prev = l;
int cur = l + 1;
while (cur <= r)
{
if (a[cur] < a[keyi]&&++prev<=cur)//一旦快慢指针有了间隔,我们就将快指针指向的元素逐渐与慢指针指向的元素交换
{
Elemtype temp = a[prev];
a[prev] = a[cur];
a[cur] = temp;
}
++cur;//让快指针指向下一个位置
}
//当cur走到结束,prev指针就是基准元素应该在的位置
Elemtype temp1 = a[prev];
a[prev] = a[keyi];
a[keyi] = temp1;
return prev;
}
void QuickSort(Elemtype* a,int n,int l,int r)
{
if (l < r)
{
int idx = partion1(a,n, l, r);//找到基准元素应在的位置
QuickSort(a,n, l, idx - 1);
QuickSort(a,n, idx + 1, r);
}
}
//快速排序非递归实现
void QuickSort1(Elemtype* a, int n, int l, int r)
{
std::stack<std::pair<int, int>>st;//创建左右边界的数对元素构成的栈
st.push(std::make_pair( l,r ));
while (!st.empty())
{
std::pair<int, int> pa = st.top();
st.pop();
/*
if (pa.first < pa.second)
{
int idx = partion3(a, n, pa.first, pa.second);//找到基准元素应在的位置
//如果我们想要下一次还是按顺序遍历,栈先进后出,我们就需要将小的区间最后放入
st.push(std::make_pair( idx + 1,pa.second ));
st.push(std::make_pair( pa.first,idx - 1 ));
}
*/
//优化上述的注释部分,因为存在很多的无效区间被判断增加了复杂度,改为先判断区间再决定是否入栈
int idx = partion3(a, n, pa.first, pa.second);
if (idx + 1 < pa.second)
st.push(std::make_pair(idx + 1, pa.second));
if (idx - 1 > pa.first)
st.push(std::make_pair(pa.first, idx - 1));
}
}
七.归并排序
实现原理
代码实现
递归版本
对于数组的分解过程,我们可以采用递归的分治方法解决,二分取中即可,而对于两个有序数组的合并不算是什么难题,但是我们需要注意将两个数组合并后还要更新到原数组的对应的区间里,以更新我们的排序结果,所以我们需要额外的O(n)空间开辟辅助数组来保存每次的对应区间的排序结果。
//归并排序的递归策略
void MergeFunc(Elemtype* a, int begin, int end, Elemtype* tmp)
{
if (begin >= end)
return;
int mid = (begin + end) / 2;
MergeFunc(a, begin, mid, tmp);
MergeFunc(a, mid + 1, end, tmp);
int i = begin, j = mid + 1;
int t = 0;
while (i <= mid && j <= end)
{
if (a[i] <= a[j])//如果i对应的位置比j对应的位置上的元素小,那么放入tmp数组后i++
tmp[t++] = a[i++];
else //反之j++
tmp[t++] = a[j++];
}
//当while循环结束后,[begin,mid]和[mid+1,end]两个区间必定有一个已经跑完,剩余的元素必定已经有序
//下述两个while只会选择走一个
while (i <= mid)
tmp[t++] = a[i++];
while (j <= end)
tmp[t++] = a[j++];
//再将tmp保存好的有序数组按位置更新到原数组对应的区间上去
for (int i = 0; i < t; i++)
a[begin + i] = tmp[i];
}
void MergeSort(Elemtype* a, int n)
{
//首先定义临时数组tmp,将排序后将结果更新到原数组使用
Elemtype* tmp = (Elemtype*)malloc(sizeof(Elemtype) * n);
MergeFunc(a, 0, n - 1, tmp);
free(tmp);
}
非递归策略
非递归策略作为了解,实际中一般不常用,因为归并排序是将对应的区间划分后再由一个元素逐层向上合并排序,不同的区间之间互不影响,所以,我们可以直接跳过分组过程,来到合并阶段,我们将数组直接分组到底,也就是每组只剩一个元素,这样每组的元素就是有序的,再以2,4,8......等符合合并二叉树的结构的2的次幂向上合并,每次都确定的一个分割数将整个数组合并并排序,直到分割数等于整个数组的元素个数即可合并完毕,但是,我们需要注意整个合并过程中的边界问题。
//归并排序的非递归策略
void MergeSort1(Elemtype* a, int n)
{
Elemtype* tmp = (Elemtype*)malloc(sizeof(Elemtype) * n);
int gap = 1;//初始间隔
while (gap <n)
{
printf("按每组%d个元素分割\n",gap);
int t = 0;
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>=n ? n-1 : i + 2 * gap - 1;//防止越界
//我们需要判断越界问题
//如果两个边界只有一个,那么我们不需要在对其排序,因为每个区间都已经是有序的状态
if (end1 >= n || begin2 >= n)
break;
printf("[%d,%d] [%d,%d]\n", begin1, end1, begin2, end2);
while (begin1 <= end1 && begin2 <= end2)
{
if (a[begin1] <= a[begin2])
tmp[t++] = a[begin1++];
else
tmp[t++] = a[begin2++];
}
while (begin1 <= end1)
tmp[t++] = a[begin1++];
while (begin2 <= end2)
tmp[t++] = a[begin2++];
for (int k = i; k <t; k++)//从对应的位置将tmp拷贝到原数组
a[k] = tmp[k];
}
PrintArray(a, n);
printf("\n");
gap*=2;
}
free(tmp);
}
逆序对问题
归并排序经常用于解决逆序对问题,这里不在详细展开,可以参考求解逆序对问题。
性能分析
归并排序的时间复杂度是 O(nlogn),其中 n 是待排序数组的长度。虽然归并排序在最坏情况下的复杂度也是 O(nlogn),但由于它始终都要使用额外的辅助数组,在空间复杂度上略高于其他排序算法,为 O(n)。因为归并排序是一种稳定且效率高的排序算法,所以它被广泛应用在各种排序场景中。
八.非比较排序--计数排序
实现原理
代码实现
// 时间复杂度:O(N+Range)
// 空间复杂度:O(Range)
// 缺陷1:依赖数据范围,适用于范围集中的数组
// 缺陷2:只能用于整形
void CountSort(int* a, int n)
{
int min = a[0], max = a[0];
for (int i = 0; i < n; i++)
{
if (a[i] < min)
{
min = a[i];
}
if (a[i] > max)
{
max = a[i];
}
}
int range = max - min + 1;
int* countA = (int*)malloc(sizeof(int) * range);
memset(countA, 0, sizeof(int) * range);
// 统计次数
for (int i = 0; i < n; i++)
{
countA[a[i] - min]++;
}
// 排序
int k = 0;
for (int j = 0; j < range; j++)
{
while (countA[j]--)
{
a[k++] = j + min;
}
}
}
九.几大排序总结