排序算法是最基本的算法之一。
可划分为 内部排序与 外部排序
内部排序是数据记录在内存中进行排序
外部排序是因为排序的数据很大,一次不能容纳全部的排序记录,在排序过程中需要访问外存。
常见的排序有冒泡排序,选择排序,插入排序,希尔排序,归并排序,快速排序,堆排序,计数排序,桶排序,基数排序等。
本篇博客主要详细讲解一下 归并排序与 快速排序
![在这里插入图片描述](https://img-blog.csdnimg.cn/20210521120605912.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L20wXzU2MTk1MDY0,size_16,color_FFFFFF,t_70#pic_center)
归并排序
归并排序采用分而治之的思想,其基本思想是:将有序的子序合并,从而得到完全有序的序列。
动图演示:
如何进行归并排序呢,首先要得到有序子序,当序列分解到只有一个元素时就认为它是有序的,这时就可以进行合并了。
递归实现
void MergeSort_(int* a,int left,int right,int* tmp)
{//传入参数 需要排序的数组a,数组左下标,右下标的值,以及新创建的数组tmp
if(left >= right) //归并结束条件,当只有一个数据或数据不存在时,结束分解
{
return ;
}
int mid = left + (right - left)/2; //得到中间下标的
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)
{
//将小的数据优先放入tmp中
if(a[begin1] < a[begin2])
{
tmp[i++] = a[begin1++];
}
else
{
tmp[i++] = a[begin2++];
}
}
//遍历完一个区间后将剩下的一个直接放到tmp后面
while(begin1 <= end1)
{
tmp[i++] = a[begin1++];
}
while(begin2 <= end2)
{
tmp[i++] = a[begin2++];
}
//归并完成后,拷贝回原数组
int j = 0 ;
for(j = left;j <= right;j++)
{
a[j] = tmp[j];
}
}
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);//释放空间
}
使用递归实现需要创建一个新的数组用于排序,排序后将数据拷贝回原数组。
非递归实现
非递归实现我们只需要控制每次参与合并的元素个数gap即可,在函数内利用循环进行排序。
三种特殊情况:
情况一:
当最后一个小组进行排序时,第二个区间内存在元素,但个数不足gap个,此时我们需要控制第二个区间的边界。如下图,我们需要将第二个区间的边界-1。
情况二:
当最后一个小组进行合并时,第二个区间内不存在元素,此时不需要对该小组进行合并。
情况三:
当最后一个小组进行合并时,第二个小区间不存在,并且第一个小区间元素个数不足gap个,此时也不需要对该小组进行合并。(可与情况2视为一类)
将这三种情况特殊讨论,得到代码如下
//归并排序(子函数)
void _MergeSortNonR(int* a, int* tmp, int begin1, int end1, int begin2, int end2)
{
int j = begin1;
//将两段子区间进行归并,归并结果放在tmp中
int i = begin1;
while (begin1 <= end1&&begin2 <= end2)
{
//将较小的数据优先放入tmp
if (a[begin1] < a[begin2])
tmp[i++] = a[begin1++];
else
tmp[i++] = a[begin2++];
}
//当遍历完其中一个区间,将另一个区间剩余的数据直接放到tmp的后面
while (begin1 <= end1)
tmp[i++] = a[begin1++];
while (begin2 <= end2)
tmp[i++] = a[begin2++];
//归并完后,拷贝回原数组
for (; j <= end2; j++)
a[j] = tmp[j];
}
//归并排序(主体函数)
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)
{
int begin1 = i, end1 = i + gap - 1;
int begin2 = i + gap, end2 = i + 2 * gap - 1;
if (begin2 >= n)//最后一组的第二个小区间不存在或是第一个小区间不够gap个,此时不需要对该小组进行合并
break;
if (end2 >= n)//最后一组的第二个小区间不够gap个,则第二个小区间的后界变为数组的后界
end2 = n - 1;
_MergeSortNonR(a, tmp, begin1, end1, begin2, end2);//合并两个有序序列
}
gap = 2 * gap;//下一趟需合并的子序列中元素的个数翻倍
}
free(tmp);//释放空间
}
快速排序
快速排序是如今最常使用的排序,它的基本思想为:任取一个基准值,将所有小于基准值的元素放在基准值左边,将所有大于基准值的元素放在基准值右边。然后左右重复这一过程,直到所有元素排序完毕即可。
对于如何按照基准值将待排序列分为两个序列,常见有三种方式
- Hoare版本
- 挖坑法
- 前后指针法
Hoare版本
动图演示:
单趟排序的基本步骤如下:
- 选出一个key,一般为最左边或最右边的
- 定义一个L和一个R,L从左向右走,R从右向左走。(注意:若选择左边的数据作为key,则需要R先走;若选择右边的数据作为key,则需要L先走)
- 在走的过程中,若R遇到小于key的数则停止运动,L开始走,直到L遇到一个大于key的数,则R与L所在地内容交换。R继续往前走,反复进行,当L与R相遇,此时相遇点与key交换即可。(选取左边的值作为key)
经过一次单趟排序后,使得key左边的元素都小于key,右边的元素都大于key。
然后将key左边和右边的元素再次进行单趟排序,如此反复操作,直到左右序列只有一个数据或左右序列不存在时,停止操作,因为这种序列可以认为是有序的。
代码:
```c
//快速排序(Hoare版本)
void QuickSort1(int* a, int begin, int end)
{
if (begin >= end)//当只有一个数据或是序列不存在时,不需要进行操作
return;
int left = begin;//L
int right = end;//R
int keyi = left;//key的下标
while (left < right)
{
//right先走,找小
while (left < right&&a[right] >= a[keyi])
{
right--;
}
//left后走,找大
while (left < right&&a[left] <= a[keyi])
{
left++;
}
if (left < right)//交换left和right的值
{
Swap(&a[left], &a[right]);
}
}
int meeti = left;//L和R的相遇点
Swap(&a[keyi], &a[meeti]);//交换key和相遇点的值
QuickSort1(a, begin, meeti - 1);//key的左序列进行此操作
QuickSort1(a, meeti + 1, end);//key的右序列进行此操作
}
挖坑法
挖坑法的单趟排序基本步骤:
- 选出一个数据(一般是最左边的或最右边的)存放在key变量中,在该数据位置形成一个坑。
- 还是定义一个L和一个R,L从左向右走,R从右向左走。(若在最左边挖坑,则需要R先走;若在最右边挖坑,则需要L先走)。
- 在走的过程中,若R遇到小于key的数,则将该数抛入坑位,并在此处形成一个坑位,这时L在向右走,若遇到大于key的数,则将其抛入坑位,形成一个新的坑位。如此循环下去,直到R与L相遇,这时将key抛入坑位即可。(选取最左边的作为坑位)
经过一次单趟排序后,使得key左边的元素都小于key,右边的元素都大于key。
然后将key左边和右边的元素再次进行单趟排序,如此反复操作,直到左右序列只有一个数据或左右序列不存在时,停止操作,因为这种序列可以认为是有序的。
代码:
//快速排序(挖坑法)
void QuickSort2(int* a, int begin, int end)
{
if (begin >= end)//当只有一个数据或是序列不存在时,不需要进行操作
return;
int left = begin;//L
int right = end;//R
int key = a[left];//在最左边形成一个坑位
while (left < right)
{
//right向左,找小
while (left < right&&a[right] >= key)
{
right--;
}
//填坑
a[left] = a[right];
//left向右,找大
while (left < right&&a[left] <= key)
{
left++;
}
//填坑
a[right] = a[left];
}
int meeti = left;//L和R的相遇点
a[meeti] = key;//将key抛入坑位
QuickSort2(a, begin, meeti - 1);//key的左序列进行此操作
QuickSort2(a, meeti + 1, end);//key的右序列进行此操作
}
前后指针法
前后指针的单趟排序的基本步骤:
- 选出一个key,一般是最左边的或最右边的
- 起始时,prev指针指向序列开头,cur指针指向prev+1
- 若cur指向的内容小于key,则prev先向后移动一位,然后交换prev和cur指针指向的内容,然后cur指针++;若cur指向的内容大于key,则cur指针直接++。如此进行下去,直到cur指针越界,此时将key和prev指针指向的内容交换即可。
经过一次单趟排序后,使得key左边的元素都小于key,右边的元素都大于key。
然后将key左边和右边的元素再次进行单趟排序,如此反复操作,直到左右序列只有一个数据或左右序列不存在时,停止操作,因为这种序列可以认为是有序的。
代码:
//快速排序(前后指针法)
void QuickSort3(int* a, int begin, int end)
{
if (begin >= end)//当只有一个数据或是序列不存在时,不需要进行操作
return;
//三数取中
// int midIndex = GetMidIndex(a, begin, end);
// Swap(&a[begin], &a[midIndex]);
int prev = begin;
int cur = begin + 1;
int keyi = begin;
while (cur <= end)//当cur未越界时继续
{
if (a[cur] < a[keyi] && ++prev != cur)//cur指向的内容小于key
{
Swap(&a[prev], &a[cur]);
}
cur++;
}
int meeti = prev;//cur越界时,prev的位置
Swap(&a[keyi], &a[meeti]);//交换key和prev指针指向的内容
QuickSort3(a, begin, meeti - 1);//key的左序列进行此操作
QuickSort3(a, meeti + 1, end);//key的右序列进行此操作
}
快排的两个优化
三数取中
快排在理想状态下,每次单趟排序结束后,key左右的数长度应该相同
每次选取key,如果key刚好为中间值时,则效率会很高,如果key为最大值或最小值时,那么快排的效率将会很低。
其实,对快排效率影响最大的是key的选取,key越接近中间位置,效率越高。
为了避免极端事件的发生,我们可以采取三数取中的方法
三数取中:三数指的是最左边的数,最右边的数以及中间位置的数。三数取中就是获取三个数中大小居中的那个数作为key,这就确保了我们选取的key不是最大值或最小值。
小区间优化
我们可以看到,就算是上面理想状态下的快速排序,也不能避免随着递归的深入,每一层的递归次数会以2倍的形式快速增长。
为了减少递归树的最后几层递归,我们可以设置一个判断语句,当序列的长度小于某个数的时候就不再进行快速排序,转而使用其他种类的排序。小区间优化若是使用得当的话,会在一定程度上加快快速排序的效率,而且待排序列的长度越长,该效果越明显。
小结
- 此篇博客主要讲解了归并排序与快速排序
- 如有错误,欢迎指正!!
- 详细讲解或其它排序可见