目录
下面的排序方法都默认排升序
插入排序
直接插入排序
基本思想
将待插入的数据依次插入一个有序序列中,每次插入后都要保证序列仍是有序的
现实模型:扑克牌摸牌理牌的过程
一趟排序的过程:将待插入的数据k从序列的末尾元素ai逐个往前比较,若k>ai则将k放在ai后面,比较停止,一趟排序结束;若k<ai则继续和前一个数比较,直至遇到上一种情况,或者已经到序列头部前面没有元素了,此时直接插入在开头即可。
由于序列一般是数组,所以插入在序列的头部或中间,需要将后面的元素向后移
示例:
这里定义的end是待插入数据的前一个元素的下标。若待插入数据小于前一个元素,则继续和再前一个元素比较,同时end–;若大于,则直接插入在end+1的位置
代码实现
void InsertSort(int* a, int n)
{
//共n个元素,需要进行n-1趟排序
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;
}
}
//此时end+1所在位置即为数据应该插入的位置
a[end + 1] = tmp;
}
}
说明:
- 跳出while循环只有两种情况:要么是break,说明找到了插入元素大于当前比较元素;要么就是序列到头了,说明插入元素最小,应该放在序列开头
以一个数组来看:
现在要对这个数组排序。直接插入排序就是先将第一个元素看成一个有序序列,然后依次将第二个、第三个,第……元素插入进这个有序序列。
需要注意的是,因为这是原地排序,所以在每趟插入前需要将end+1这个位置的元素保存起来(也就是即将插入序列的数据),以防因为前面元素向后移而导致数据没了,在代码中具体体现在int tmp=a[end+1]
此外,整个循环(元素插入的次数)的执行次数为n-1次,因为第一个元素不用插入。
注意i的取值,千万不能写成i<n
否则会使a[end+1]
越界
时间复杂度
- 最坏:逆序 等差数列求和–O(N2)
- 最好:顺序有序或接近顺序有序–O(N)
希尔排序
基本思想
希尔排序分为两大步:
- 预排序:将序列按一定的间隔gap分为若干组,组内先进行直接插入排序。间隔gap由大到小,预排序多次
- 直接插入排序:当间隔小到等于1时,最后进行一次直接插入排序,即可将序列排为有序序列
希尔排序的内核还是直接插入排序,只不过排序思路上进阶了
关于预排序
**为什么gap要由大到小呢?**因为:
gap越大,大的数可以更快的到后面,小的数可以更快的到前面,但此时序列越不接近有序
gap越小,数据跳动越慢,但序列越接近有序
所以先大后下,使得序列更快的接近有序。当gap>1时都是预排序,当gap=1时,序列已经接近有序了,此时再直接插入排序就会很快排好序列了
gap的取值
gap的初始值为待排序数据个数n
通常gap有两种取值方式:
- gap=gap / 2
- gap=gap / 3 + 1
一般是第二种用的比较多,这样预排序的次数不会太多。+1是为了使gap最终为1
示例:
最后,gap=2/3+1=1,进行最后一次直接插入排序即可得到有序序列了
代码实现
void ShellSort(int* a, int n)
{
int gap = n; //先定义一下分组间隔
while (gap>1)
{
gap = gap / 3 + 1; //一开始组间距比较大,越往后的预排序组间距越小,到最后为1。当gap=1时,即为最后整个序列的直接插入排序
//对于预排序来说,这里是多组同时进行预排序
//就是说,第一组先排一个,然后第二组再排一个,每一组都排完一 个后,再回过头来排第一组
for (int i = 0; i < n - gap; i++)
{
//一趟排序
int end = i; //代表有序序列的最后一个元素
int tmp = a[end +gap];//将所要插入序列的数据保存一下
while (end >= 0)
{
//如果插入的数据小于当前位置的元素,则将当前位置的 元素往后移gap位,同时继续判断前gap个元素
if (tmp < a[end])
{
a[end + gap] = a[end];
end-=gap;
}
else
{
break;
}
}
//此时end+gap所在位置即为数据应该插入的位置
a[end + gap] = tmp;
}
}
}
说明:
- 上面代码的预排序是多组同时进行的。就是说,第一组先排一个,然后第二组再排一个,每一组都排完一个后,再回过头来排第一组
- 注意循环判断条件,一定是
gap>1
- for循环的结束条件是
i<n-gap
。我们知道三个数只需要直接插入排序2次就可以有序了。这样对比去理解
时间复杂度
希尔排序的时间复杂度不好计算,因为gap的取值方法很多,导致很难去计算
希尔排序的时间复杂度在最开始排序和快结束排序时,我们可以大致得出为O(N),但中间排序的复杂度很难得到,因此希尔排序的时间复杂度很难得到。这里给个大致的结论为O(N1.3)
最开始时,每组数据少,所以排起来简单
快结束时,序列已经接近有序,所以也好排
选择排序
直接选择排序
基本思想
原思想:遍历序列,每次找出最大的元素或者最小的元素放在序列的末尾或开头。
现实模型:体育课体育老师按高矮排队
这里我们讲一下进阶的思想:遍历序列,每次同时找出最大元素和最小元素放在序列的末尾和开头
代码实现
//交换数据
void Swap(int* i, int* j)
{
int tmp = *i;
*i = *j;
*j = tmp;
}
void SelectSort(int* a, int n)
{
int begin = 0; //表示序列的开头
int end = n - 1; //表示序列的末尾
while (begin < end)
{
//一趟排序
int mini = begin; //表示本趟排序中最小元素的下标
int maxi = begin;//表示本趟排序中最大元素的下标
for (int i = begin + 1;i<=end;i++)
{
//找出序列中最大的元素的下标
if (a[i] > a[maxi])
{
maxi=i;
}
//找出序列中最小的元素下标
if (a[i] < a[mini])
{
mini=i;
}
}
//将本趟最大的元素换到序列的末尾
Swap(&a[end], &a[maxi]);
//修正一下:防止序列末尾的元素恰巧是本趟最小的元素而影响下面的交换
if (mini == end)
{
mini = maxi;
}
//将本趟最小的元素换到序列的开头
Swap(&a[begin], &a[mini]);
begin++;
end--;
}
}
说明:
- 定义end和begin两个下标用来每趟排序后放元素,每趟放好后end–,begin++。当begin>=end时,说明排序完成
- 注意每趟排序保存的是最大元素和最小元素的下标!!!
- 注意代码末尾的修正
时间复杂度
普通的和进阶的直接选择排序任何情况下时间复杂度都是O(N2),包括有序情况下
但直接选择排序还是有区别的。两个都是等差数列求和,不同的是,普通的公差为1,进阶的公差为2
堆排序
基本思想
先将序列建为大堆,再重复下面的步骤:
- 将堆顶元素与序列末尾元素交换位置
- 序列长度-1,即将新末尾元素踢出待排序序列
- 将新堆顶元素向下调整,使序列重新成为大堆
代码实现
//交换数据
void Swap(int* i, int* j)
{
int tmp = *i;
*i = *j;
*j = tmp;
}
//堆的向下调整
//建大堆。就是将父亲和左右孩子中较大的孩子比较,若小于较大的则交换
void AdjustDown(int* a, int n, int parent)
{
//假设左孩子大
int child = parent * 2 + 1;
while (child<n)
{
//如果右孩子存在且大于左孩子
if (child + 1 < n && a[child + 1] > a[child])
{
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)
{
//建大堆
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]);
//序列长度减一
end--;
//对堆重新向下调整
AdjustDown(a, end + 1, 0);
}
}
时间复杂度
堆排序与直接选择排序同为选择排序。但区别在于选择方式不同,堆排序借助堆,使得效率提升到了O(logN),在加上对n个数排序,所以最终时间复杂度为O(N*logN)
交换排序
冒泡排序
基本思想
从序列的头部开始,第一个元素和第二个元素比较,若第一个元素比第二个元素大则交换,然后它变成第二个元素再和第三个元素比较;若小则不交换,但还是继续第二个元素和第三个元素比较……最终可以将最大的元素放在序列的最后。这就是一趟排序。一共要进行n-1趟
第一趟n个数要比较n-1次,第二趟n-1个数要比较n-2次……
想象序列开头是湖底,序列末尾是湖面,那么整个排序的过程就像泡泡上升的过程
当然冒泡排序也可以进行优化:原本的代码是必须要进行n-1趟冒泡。但有可能序列原本就是有序的,但也只能进行n-1趟冒泡。这时我们可以加个检验变量,判断这一趟冒泡,有没有发生过数据交换,如果没有说明已经排序好了,后面不需要再进行冒泡了,这时直接break就可以了
但这种优化意义不大。除非是序列开头的一段是有序的,才有点用。否则中间或者后面一段有序啥用没有
代码实现
void BubbleSort(int* a, int n)
{
//n个数据排序,需要n-1趟
for (int i = 0; i < n; i++)
{
//一趟排序
int exchange = 0; //用来检验此趟排序是否发生交换,若没发生说明序列已经有序,没必要再进行后续的排序
for (int j = 0; j < n - i-1; j++)
{
if (a[j] > a[j + 1])
{
Swap(&a[j], &a[j + 1]);
exchange = 1;
}
}
if (exchange == 0)
{
break;
}
}
}
时间复杂度
等差数列求和–O(N2)
快速排序
快速排序是由霍尔提出的一种二叉树结构的交换排序方法,是一个前序过程。
基本思想
任取待排序元素序列中的某元素作为基准值key,按照该基准值将待排序集合分割成两个子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后左右子序列分别重复上述分割过程得到新的左右子序列,直到所有元素都排列在相应位置上为止
这里提出两个问题:
- 取哪个元素作为基准值key
- 如何使左子序列全部小于key,右子序列全部大于key
思想解释
先针对上面的两个问题:
-
通常选取序列的第一个(也可以是最后一个)元素作为key
-
设置左右下标分别指向序列的开头和末尾。右下标先走,遇到比key小的元素停下;左下标再走,遇到比key大的停下,然后交换左右下标指向的元素。直至左右下标相遇,此时将当前指向的元素与key指向的交换即可。
需要注意的是:设第一个元素为key,则右下标先走;若设最后一个元素为key,则左下标先走
解释:以设第一个元素为key为例
为了保证相遇位置比就一定比key要小呢?
因为相遇就两种情况:
一是R停止,L遇到R,此时相遇位置就是R停止的位置,而R是停在比key小的上面的。
第二种就是L停止,R遇到L,此时相遇位置就是L停止的位置,而此时的L一定是与上一个R停止的位置换过元素了,此时L停止的位置也一定是小于key的。就算我L一直没动过,那最终也只是key自己与自己换
那我非要设第一个元素为key,还有左下标先走呢?其实也可以,只不过有很多细节需要限制,比较麻烦
此外单趟排序的作用:
- 分割出了左右区间,左区间比key小,右区间比key大
- key已经落到它的正确位置(即排序后的最终位置)
第一趟排序完,剩下的就是要将左右子序列变成有序即可。那么要使左右区间有序就是原问题的子问题了,然后左右区间还可以继续分子问题。所以这是一个二叉树递归
代码实现
void QuickSort(int* a, int begin, int end)
{
//当区间中只有一个数据或没有数据时,则不用排
if (begin >= end)
{
return;
}
//一趟快排
int left = begin, right = end; //本趟所排序列的首末下标
int keyi = left;
while (left < right)
{
//先右下标走。找比key小的
while (left < right && a[right] >= a[keyi])
{
right--; //进入循环中说明没找到,继续往前找
}
//再左下标走。找比key大的
while (left < right && a[left] <= a[keyi])
{
left++; //进入循环说明没找到,继续往后找
}
Swap(&a[right], &a[left]);
}
//此时left与right相遇
Swap(&a[left], &a[keyi]);
keyi = left; //交换后的关键字的新下标
//对关键字的左右区间分别快排
//左区间
QuickSort(a, begin, keyi - 1);
//右区间
QuickSort(a, keyi+1, end);
}
说明:
-
这里基准值没有选具体的某个元素,而是选取的某个元素的下标。这是为了在
Swap(&a[left], &a[keyi]);
交换时,可以实现数组的改变。假如基准值选的是某个具体的元素,即
int key = a[left]
,那么在交换时Swap(&a[left],&key)
交换的仅仅是局部变量,没有对数组改变 -
这里加left<right和加上=号的目的是:防止越界和序列中有与key一样的值而造成的死循环
-
在key交换后,一定要重置key的新下标。以此为基础才能划分左右子序列
keyi = left;
时间复杂度
快排的递归过程是一个二叉树
理想状态下(也就是个满二叉树,树两侧孩子数量差不多),快排差不多要进行logN趟,而每趟的时间复杂度为O(N),所以最终是O(N*logN)
为什么每趟的时间复杂度为O(N)呢?
虽然代码中是两层循环,但实际只遍历了序列一遍
但在最坏情况下(也就是二叉树只有一边有孩子,另一边没有。如序列原本就有序时)这时候快排要进行N趟,每趟O(N),所以最终是O(N2)
空间复杂度:O(logN)。因为快排需要建立LlogN层的函数栈帧,每层栈帧的空间消耗为1,所以最终空间复杂度为logN
快排的优化
优化一:三数取中
针对在计算时间复杂度时的最坏情况,可以通过“三数取中”的方法来优化
具体操作:
- 原本key要么第一个元素,要么最后一个元素。这样在有序的情况下会使效率低很多。若我们想在有序的情况下也变成理想状态的话,可以选取序列中间的元素作为基准值,这样得到的左右子序列长度就差不多了
- 所以我们分别用begin、mid、end三个下标指向序列的开头、中间、末尾,取出这三个下标所指元素的中间大小的元素作为基准值。
- 为了原代码不变,我们可以将选出来的基准值换到序列的开头即可
具体代码:
//三数取中
int GetMidIndex(int* a, int begin, int end)
{
int mid = (begin + end )/ 2;
if (a[begin] < a[mid])
{
if (a[mid] < a[end])
{
return mid;
}
else if (a[begin] > a[end])
{
return begin;
}
else
{
return end;
}
}
else // a[begin] > a[mid]
{
if (a[mid] > a[end])
{
return mid;
}
else if (a[begin] < a[end])
{
return begin;
}
else
{
return end;
}
}
}
void QuickSort(int* a, int begin, int end)
{
//当区间中只有一个数据或没有数据时,则不用排
if (begin >= end)
{
return;
}
//三数取中优化
int mid = GetMidIndex(a, begin, end);
Swap(&a[mid], &a[begin]);
//一趟快排
int left = begin, right = end; //本趟所排序列的首末下标
int keyi = left;
while (left < right)
{
//先右下标走。找比key小的
while (left < right && a[right] >= a[keyi])
{
right--; //进入循环中说明没找到,继续往前找
}
//再左下标走。找比key大的
while (left < right && a[left] <= a[keyi])
{
left++; //进入循环说明没找到,继续往后找
}
Swap(&a[right], &a[left]);
}
//此时left与right相遇
Swap(&a[left], &a[keyi]);
keyi = left; //交换后的关键字的新下标
//对关键字的左右区间分别快排
//左区间
QuickSort(a, begin, keyi - 1);
//右区间
QuickSort(a, keyi+1, end);
}
这样一来,快排几乎不会出现最坏情况了。所以快排的时间复杂度可以认为是O(N*logN)
优化二:小区间优化
当快排往下分序列时,越往下序列越来越短,短到序列中只有15个元素了(15只是个泛值,差不多都可以),此时不再用快排排序,而是用直接插入排序。
好处:减少百分之八十五左右的递归调用,从而减少建立栈帧所带来的消耗
解释:假设一个序列中有10个元素,对这10个元素进行快排,子序列也得分出个3、4层。而最后一层递归次数就是整个递归次数的一半,所以少最后的3、4层,可以减少很多递归次数
这个优化很有价值。官方库包括qsort都在用这种小区间优化
这种优化在debug版本下较明显,在release版本下不明显。因为release版本下对于建立栈帧的本身就已经优化许多
具体代码:
void QuickSort(int* a, int begin, int end)
{
//当区间中只有一个数据或没有数据时,则不用排
if (begin >= end)
{
return;
}
//小区间优化
if ((end-begin+1)<15)
{
//注意这里数组千万不能只写a。一定是当前序列的开头
InsertSort(a + begin, end - begin + 1);
}
else
{
//三数取中优化
int mid = GetMidIndex(a, begin, end);
Swap(&a[mid], &a[begin]);
//一趟快排
int left = begin, right = end; //本趟所排序列的首末下标
int keyi = left;
while (left < right)
{
//先右下标走。找比key小的
while (left < right && a[right] >= a[keyi])
{
right--; //进入循环中说明没找到,继续往前找
}
//再左下标走。找比key大的
while (left < right && a[left] <= a[keyi])
{
left++; //进入循环说明没找到,继续往后找
}
Swap(&a[right], &a[left]);
}
//此时left与right相遇
Swap(&a[left], &a[keyi]);
keyi = left; //交换后的关键字的新下标
//对关键字的左右区间分别快排
//左区间
QuickSort(a, begin, keyi - 1);
//右区间
QuickSort(a, keyi + 1, end);
}
}
单趟排序的不同方法
之前我们讲的快排的单趟的排法,是霍尔提出的。下面我们在讲两个别的方法
挖坑法
思路:
-
设第一个元素为基准值key,将这个基准值保存在变量中,同时将这个这一个位置挖个坑
-
设左右下标从序列开头和末尾相向而走。右下标先走,遇到比key小的就将其放到坑中,然后这个位置变成新的坑;再左下标走,遇到比key大的,就将其放入坑中,然后这个位置成为新的坑
-
重复上述操作,直至左右下标相遇。此时则将key放到坑中即可
左右下标相遇的地方一定也是坑。因为坑不是在左下标处,就是在右下标处
挖坑法其实是霍尔法的通俗版本,更容易理解
具体代码:
//挖坑法
int SinglePassSort2(int* a, int begin, int end)
{
//三数取中
int mid= GetMidIndex(a, begin, end);
Swap(&a[mid], &a[begin]);
int left = begin, right = end; //定义左右下标
int key = a[left]; //保存基准值
int hole = left; //挖坑
while (left < right)
{
//右下标先走,找比key小的
while (left < right && a[right] >= key)
{
right--;
}
//右下标找到了,则将该处元素移到坑里
a[hole] = a[right];
hole = right;
//左下标走,找比key大的
while (left<right && a[left]>key)
{
left++;
}
//左下标找到了
a[hole] = a[left];
hole = left;
}
//left和right相遇了。则将key放入坑中即可
a[hole] = key;
//返回本趟基准值的下标
return hole;
}
前后下标法
思路:
- 先设第一个元素为基准值key
- 定义两个下标,下标prev指向第一个元素,下标cur指向第二个元素
- cur先往后走,遇到比key小的,则cur停下
- 此时prev先++,再交换prev处和cur处的元素,然后cur再往后走
- 当cur走出序列后,则直接将key位置与prev位置的值交换
也就是cur是不管遇到比key大的还是小的,要一直往后走
而prev只有在cur遇到小的时候,才走一步
具体代码:
//前后指针版本
int SinglePassSort3(int* a, int begin, int end)
{
//三数取中
int mid = GetMidIndex(a, begin, end);
Swap(&a[begin], &a[mid]);
int prev = begin, cur = begin + 1; //定义前后指针
int keyi = begin; //定义关键字下标
while (cur <= end)
{
//当cur遇到比key小的元素则交换。如果prev和cur指向同一个元素则不用交换了
if (a[cur] < a[keyi] && ++prev != cur)
Swap(&a[cur], &a[prev]);
cur++;
}
//交换prev和key
Swap(&a[prev], &a[keyi]);
//返回本趟基准值的下标
return prev;
}
快速排序的非递归
凡是递归的程序,都存在一个缺陷:当递归深度太深的话,容易发生栈溢出
所以凡是递归的代码,我们都要能改成非递归的
简单的递归改非递归直接改成循环
复杂的就需要借助栈或队列实现
思路:利用栈实现非递归
-
第一次将整个序列的左右区间依次入栈。下面开始进行循环
-
将右左区间依次出栈,进行单趟排序。排序完得到左右子序列。当子序列的长度大于1时,则将其的左右区间入栈
因为栈后进先出,所以左右进,右左出。
若想模仿上面的递归写法的思路,先左序列后右序列的话,则左序列后进栈
-
当栈空时,则结束
具体代码:
//非递归版快速排序
void QuickSortNonR(int* a, int begin, int end)
{
//创建一个栈
ST st;
StackInit(&st);
//第一次将整个序列的左右区间依次入栈
StackPush(&st, begin);
StackPush(&st, end);
while (!StackEmpty(&st))
{
//因为右区间后入栈,所以右区间先出栈
int right = StackTop(&st);
StackPop(&st);
//左区间再出栈
int left = StackTop(&st);
StackPop(&st);
//进行单趟排序
int keyi = SinglePassSort3(a, left, right);
//如果右子序列的长度大于1则将右子序列的左右区间入栈
if (right - keyi > 1)
{
StackPush(&st, keyi+1);
StackPush(&st, right);
}
//如果左子序列的长度大于1则左子序列的左右区间入栈
if (keyi - left > 1)
{
StackPush(&st, left);
StackPush(&st, keyi-1);
}
}
StackDestroy(&st);
}
说明:
-
一次出栈两个数字,分别是序列的头尾
-
代码最后的两个if的顺序谁在前谁在后都无所谓。按照上述代码的话,右子序列的区间先入栈,左子序列的区间后入栈。那等后面出栈,单趟排序时就是左子序列的左右区间先出,也就是左子序列先排序
但其实用队列也能实现非递归
用栈的话,就是整个过程和递归是一样的,先处理左序列,再处理右序列
用队列的话,就是一层一层的处理
归并排序
基本思想
要想整个序列有序:可以将整个序列分成左右两个子序列,使这两个子序列分别有序,然后再将两个子序列合并为一个有序序列即可
使子序列有序可以继续套用这个思路,所以归并排序是一个递归排序,递归过程是一个二叉树结构,是一个后序过程。
步骤图:
注意:
- 虽然说是将整个序列分成左右两个子序列,且图上也是这么画的,但这只是一个理解过程,实际还是在原数组中操作的,借助左右区间就可以实现分解
- 两个有序子序列合并为一个有序序列是需要借助额外空间的,在额外空间中合并完后拷贝回去即可。
代码实现
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, sizeof(int) * (end - begin + 1));
}
void MergeSort(int* a, int n)
{
//开辟额外空间
int* tmp = (int*)malloc(sizeof(int) * n);
if (tmp == NULL)
{
perror("malloc fail");
exit(-1);
}
//进行归并排序
_MergeSort(a, 0, n - 1, tmp);
free(tmp);
tmp = NULL;
}
说明:
- 额外空间只需要开辟一次即可,所以不能放在递归函数中。额外空间的长度即为待排序列的长度
时间复杂度
这里的递归过程是一个二叉树结构,共logN层,每一层循环N趟,所以时间复杂度为O(N*logN)
空间复杂度是O(N)
其实原本空间复杂度为O(N+logN)
logN是递归建立栈帧的空间消耗。层栈帧的空间消耗为1,所以最终空间复杂度为logN。但由于logN比起N可以忽略,所以最终为O(N)
归并排序的非递归
思路:设定一个rangeN从1开始,表示归并时每个序列的数据个数,因为1个数可以认为是有序的。每归并一次,rangeN就×2
这是原数组的变化情况
这是原数组的子序列在额外空间中合并,然后再从额外空间中拷贝回来
代码实现:
void MergeSortNonR(int* a, int n)
{
//开辟额外空间
int* tmp = (int*)malloc(sizeof(int) * n);
if (tmp == NULL)
{
perror("malloc fail");
exit(-1);
}
int rangeN = 1; //设定一开始序列中数据个数
//当rangeN大于等于序列数据总数,则说明排好了
while (rangeN < n)
{
//进行当前rangeN下的子序列合并
//i表示进行合并的两个子序列中左子序列的左区间
//i+=2*rangeN表示一次跳过两个子序列,来到下一个子序列进行合并
for (int i = 0; i < n; i += 2 * rangeN)
{
//两个子序列进行合并
//定义两个子序列的左右区间
int begin1 = i, end1 = i + rangeN - 1;
int begin2 = i + rangeN, end2 = i + 2 * rangeN - 1;
//由于序列长度并不一定是2^n,所以会存在越界的情况
//下面对越界情况进行修正
//end1越界,说明只有1个左子序列,且序列长度小于rangeN,那么就没必要进行合并了
if (end1 >= n)
{
break;
}
else if (begin2 >= n)//begin2越界,说明只有一个左子序列,且序列长度恰好等于rangeN,那么也没必要进行合并了
{
break;
}
else if (end2 >= n)//end2越界,说明左右两个子序列都有,但右子序列的长度小于rangeN,所以要对end2进行修正后将两个序列合并
{
//将end2修正到序列末尾
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));
}
//也可以等所有子序列都合并完了再拷贝至原数组
//memcpy(a, tmp, sizeof(int) * n);
rangeN *= 2;
}
}
说明:
-
这里有两种拷贝方式:注意是同一个rangeN下,两种不同的拷贝方式
- 每次两个子序列合并完则拷贝至原数组,即归并一部分就拷贝一部分
- 等所有子序列都合并完了再拷贝至原数组
-
关于区间越界的修正
下面是三种越界情况:
关于越界的修正有两种方式:
-
对于情况1和情况2,由于只有一个序列,那么可以不用合并,直接break即可;对于情况3,由于有两个序列,是需要进行合并的,但需要对右子序列的右区间做一下修正
-
对于情况1和情况2不break,也是像情况3一样做修正,注意修正时需要让右子序列的end2大于begin2,即右子序列没有没有数据。然后到下面进行判断,然后自然结束本次序列归并
if(end1>=n) { end1=n-1; begin2=n+1; end2=n; //begin2和end2可以随便取,只要保证begin2大于end2即区间不存在即可 } else if(begin2>=n) { begin2=n+1; end2=n; } else if(end2>=n) { end2=n-1; }
注意:
当选用修正方法a时,拷贝时只能用拷贝方法a。
解释:
用拷贝方法a是你合并的哪些区间,就拷贝哪些区间;而拷贝方法b是将整个序列都拷贝回去。假如遇到情况1和2,那么拷贝方法b会将额外空间中没有进行合并的那块区间也拷贝回去,而那块区间放的是随机值
选用修正方法b时,两种拷贝方法都可以用
两种修正方法我更倾向于用修正方法a,既然只有左子序列,那就直接跳出即可,没必要在去下面进行判断然后正常结束循环
-
-
总结:更倾向于使用修正方法a和拷贝方法a
排序的稳定性
稳定性:保证相同的数在排完序后相对顺序不变,那么此排序就是稳定的
注意:所谓稳定,是你努努力就可以保证该排序算法是稳定的;所谓不稳定,就是你怎么努力都不能保证该排序算法是稳定的
如果你执意想让一个原本稳定的排序算法不稳定那是很轻松的,有时改个符号即可
-
直接插入排序:稳定
小于的数才到前面,大于等于的数就在后面,这样就可以保证稳定
-
希尔排序:不稳定
如果预排序时,相同的数据分到了不同的组,那么就不能保证稳定性了
-
直接选择排序:不稳定
我们看普通版本的直接选择排序
看这种情况下,当max和end交换了,那么4的稳定性就不能保证了
-
堆排序:不稳定
这种情况下,8的稳定性就不能保证了
-
冒泡排序:稳定
只有大于后面的数时才交换,这样就可以保证稳定性了
-
快速排序:不稳定
这种情况下,4的稳定性就不能保证了
-
归并排序:稳定的
只要两个子序列合并时,遇到相同的数,左子序列先合并,就可以保证稳定性了
总结比较
上述排序都是内排序(在内存中排序),但归并也可以是外排序(在磁盘中排序。当数据太大时,内存中放不下)
我们将同时间复杂度的进行比较。
直接插入排序、直接选择排序、冒泡排序
- 它们三者在最坏情况下的时间复杂度都是O(N2)
- 对于直接插入排序:它的适应性很强,最坏的情况下要全挪动;但有时候只要挪动一半的序列;最好的情况下,不用挪动序列。所以在序列有序或接近有序的情况下,它的时间复杂度为O(N)
- 对于直接选择排序:适应性差,始终O(N2)。但经过优化,一次选两个,还行
- 对于冒泡排序:癌症晚期了,优化也没用。除非是从头开始有序,否则优化也没用
总结:直接插入>直接选择>冒泡
希尔排序、堆排序、快速排序、归并排序
当快速排序进过三数取中的优化后,除了希尔排序外的排序的时间复杂度都是O(N*logN),希尔排序为O(N1.3)。时间复杂度上四种排序差不多
空间复杂度上,希尔和堆排是O(1),快排是O(logN),归并排序是O(N)
总的来说四种方法没有谁好谁坏,都可以。
快速排序和归并排序
两种都是递归排序(这里不谈两种的非递归),递归过程都是二叉树结构
不同的是:
- 快排的过程是一个前序二叉树,先确定key,然后分割出左子序列,右子序列,再解决左子树、右子树
- 归并的过程是一个后序二叉树,先搞定左子树、右子树,再合并搞定根
直接插入排序和其他排序
当序列有序或接近有序时,直接插入排序是最快的,时间复杂度为O(N)