今天我们来介绍几种常见的排序方法:
插入排序
直接插入排序
直接插入排序的思路比较简单,我们将数组分为两部分:有序区和无需区。
我们不断将无序区的第一个元素插入到有序区中,同时保证有序区的元素继续有序。
我们最开始的有序区只有一个元素(首元素):
接下来我们把无序区的第一个元素插入有序区:
我们继续重复这个步骤即可,当无序区中没有元素,排序完成
分析直接插入的排序过程,我们可以发现:
- 元素越接近有序,直接出入排序算法的时间效率越高
- 时间复杂度:O(N^2)
- 空间复杂度:O(1),它是一种稳定的排序算法
- 稳定性:稳定
void InsertSort(int* a, int n)
{
for (int i = 0; i < n - 1; i++)
{
int end = i;
int tmp = a[i + 1];
while (end >= 0)
{
if (tmp < a[end])
{
a[end + 1] = a[end];
--end;
}
else
{
break;
}
}
a[end + 1] = tmp;
}
}
希尔排序
希尔排序,我们又叫 缩小增量排序法。
对于这种排序法的基本思路,选定一个整数k,把选定一个 整数,把待排序文件中所有记录分组,所有距离为k的记录在同一组内,并对每一组中的记录进行排序。当k=1的时候,所有记录在统一组内排好序。
上面的描述可能会比骄傲抽象,我们来拆开讲解一下:
1. 分组
选定一个整数gap,我们把间隔为gap的元素分为一组:
2. 分组排序
当我们将数组分好组后,我们对每一组进行排序。
我们以gap为3 为例子,进行演示:
很显然我们可以看到,此时每一组都已经达到有序。
但是,显然每组的有序不代表整体的有序,不急,我们先来看一下,gap的取值对于整体有序程度的影响:
我们取值gap=5,gap=3,gap=1,观察他们分组排序后的整体序列:
观察上图,我们可以总结出几点:
- gap越小,结果越接近于有序,当gap=1,等价于直接插入排序
- gap越小,分的组越多,每一组需要排序的元素越多,时间越长
3. 希尔排序
讲完上面的问题,我们可以来介绍一下真正的希尔排序,
我们知道希尔排序又叫缩小增量法,也就是我们通过多次分组排序来使数组趋近于有序。
这里我们们要引入 预排序 这个说法,当gap>1的时候,我们都称为预排序,此后不断减小gap,目的是不断让数组接近有序。
4. 希尔排序的时间复杂度
实际上希尔排序就是多次的小范围直接插入排序,尤其是最后gap降为1的时候,就是直接插入排序,但在最后一次排序之前,数组已经很接近有序了,所以只需要移动小部分元素就可以达到有序了。
但是,希尔排序的时间复杂度并不好计算,因为一个很重要的影响因素就是我们如何取gap的值,有很多方法,因此在许多资料中给出的希尔排序的时间复杂度并不是固定的。
我们截取了两段:
在这里,我使用的是Knuth提出的方法他利用大量的样本进行试验,得出按
gap=gap/3+1的规则不断减小gap,可以达到较优的时间复杂度
5. 代码实现
了解了运行的原理之后,关于代码的实践,会有一些不一样;
按照原理,我们定义好gap并且分组之后,依次对每一组进行排序,如果这样写,代码会这样写:
while(gap>1)
{
gap=gap/3+1
for(int i=1;i<=gap;i++) //组数等于gap
j+=1;
for(int j=0;j<n;j+=gap)
{
直接插入排序
}
}
我们可以看到,这样写有一个缺点:我们在排序之外还需要套两层循环。
这里,我们可不可以优化一下?答案是可以的:
这里我们采用 多组同排 的方法,而不是之前使用的 把一组排完再排下一组,我们先看一下代码:
while(gap>1)
{
gap=gap/3+1i
for(int i=0;i<n-gap;i++)
{
直接插入排序
}
}
分析这段代码,我们就可以理解什么是多组同排了,我们每次不把一组全部排完,只是排一个数据,然后接着下一组,再在那组中排一个元素,并按照这个1,2,3,1,2,3…组数顺序知道n-gap-1的位置。
这样我们就只需要一个循环就够了,时间复杂度大大减小。
完整代码:
void Shell_1(int* a, int n)
{
int gap = n;
while (gap > 1)
{
gap = gap / 3 + 1;
for (int i = 0; i < n - gap; i++)
{
int end = i;
int tmp = a[end + gap];
while (end>=0)
{
if (a[end] > tmp)
{
a[end + gap] = a[end];
end -= gap;
}
else
{
break;
}
}
a[end + gap] = tmp;
}
}
}
选择排序
选择排序
对于选择排序,我们很熟悉。
所谓选择,选的是最小值/最大值:
我们不断选出最小值,放在最前面,再去剩余的数中再找出最小值,放在第二个位置,按此步骤即可使数组有序。
void Select_Sort_1(int* a, int n)
{
for (int i = 0; i < n; i++)
{
int min = a[i];
for (int j = i+1; j < n; j++)
{
if (min > a[i])
{
swop(&min, &a[i]);
}
}
}
}
我们是否可以对直接选择排序进行一定程度的优化?
既然我们可以选出最小值/最大值,那么我们是否可以在一次遍历中同时找到最小值和最大值,并且把它们分别放在数组首和数组尾。
那么具体我们如何设计我们的代码呢?
1. 两个遍历边界
每次我们经过一次遍历找到最小值和最大值,并把他们放在数组前面和后面之后,就需要将我们下次遍历的范围减小,在剩下的数中找最小和最大。所以我们分别设置begin和end两个遍历边界下标
2. 记录最大值下标,最小值下标
我们在遍历过程中需要通过比较找到最大值和最小值,所以需要设置两个下标mini和maxi记录两个值的下标,再将对应下标元素与遍历边界位置的元素分别互换
3. 何时停止遍历
当我们的两个遍历边界begin和end没有相遇的时候,就说明我们还没结束,所以我们以begin<end为判断条件
下面我们来演示一下运转过程,以便理解:
这里展示了前两次的遍历,相信还是不难看懂的,我们再来看下接近有序的时候:
4. 可能会出现的bug情况
虽然我们这个方法思路十分简单,但它存在一个漏洞:
我们在每次遍历结束后将对应下标元素与遍历边界位置的元素分别互换时:
swop(&a[begin], &a[mini]);
swop(&a[end], &a[maxi]);
这样看上去没有错误,但是如果begin与maxi在每次遍历时就是重叠的,即最大值就在遍历的左边界,这个时候我们的程序会出错。为什么呢,我们看图说话:
我们发现我们的排序失效了,这就是因为9作为最大值与begin的位置重合了,所以为什么会出现这种情况?
我们发现,从这里开始,就开始乱套了,我们的第一次排序后 数组第一个值和最后一个值并不是我们想要的最小值和最大值。所以在不改变大思路的情况下,我们如何改进?
其实很简单,只要单独判断一下这个情况:
swop(&a[begin], &a[mini]);
if (begin == maxi)
{
maxi = mini;
}
swop(&a[end], &a[maxi]);
下面是完整代码:
void Select_Sort_2(int* a, int n)
{
int begin = 0;
int end = n - 1;
while (begin < end)
{
int maxi = begin;
int mini = begin;
for (int i =begin; i <= end; i++)
{
if (a[i] > a[maxi])
maxi = i;
if (a[i] < a[mini])
mini = i;
}
swop(&a[begin], &a[mini]);
if (begin == maxi)
{
maxi = mini;
}
swop(&a[end], &a[maxi]);
++begin;
--end;
}
}
5. 时间复杂度
- 时间复杂度:O(n^2)
- 空间复杂度:O(1)
- 稳定性:不稳定
直接排序的效率并不是很好。实际情况中很少用到
堆排序`
在之前有关于堆的博客中,有介绍过了:
【C语言】堆
这里只贴出代码(升序):
//堆(选择)排序 默认升序
void AdjustDown(int* a,int n,int parent)
{
int child = 2 * parent + 1;
while (child < n)
{
if (child + 1 < n && a[child + 1] > a[child])
{
++child;
}
if (a[child] > a[parent])
{
swop(&a[child], &a[parent]);
parent = child;
child = 2 * child + 1;
}
else
{
break;
}
}
}
void Heap_Sort(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 )
{
swop(&a[0], &a[end]);
AdjustDown(a, end, 0);
end--;
}
}
交换排序
冒泡排序
冒泡排序也是我们十分熟悉的排序:
我们通过相邻数据的比较和交换不断将较大值的记录向序列的尾部移动,而较小的值像序列的前部移动。
数值较大的值就像泡泡一样,逐渐浮出水中。。。。到达水面,也就是数组的末尾。
这里我不再做过多的表述,直接上代码:
void BubbleSort(int* a, int n)
{
for (int i = 0; i < n; i++)
{
int exchange = 0;
for (int j = 1; j < n - i; j++)
{
if (a[j] < a[j - 1])
{
swop(&a[j], &a[j - 1]);
exchange = 1;
}
if (exchange == 0)
{
break;
}
}
}
}
快速排序
快速排序时各种排序中很重要的一种,内容也比较多,所以为了不使文章过于冗长,我另外单独写一篇博客介绍它.
链接如下:【图解C语言】快速排序,真的好快捏
归并排序
归并排序(MERGE-SORT)是建立在归并操作上的一种有效的排序算法,该算法是采用分治法(Divide andConquer)的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为二路归并。
我们将数组分割再组合的过程中,由于数组是一段连续的空间,我们不能直接像链表那样断开,再链接。
所以我们需要先开辟一段动态的空间,以便我们操作。
之后的步骤比较明确:
1. 分解(递归)
我们按照 二分法 的方式,不断递归,将区间不断分解,直到每个小区间达到有序 。
2. 合并
当小区间都有有序了,我们就可以开始逐层合并。这个时候我们就需要使用到我们生成的临时数组了:
- 我们将两个区间并时,将选取的数放到临时数组中,当合并完成后,我们再把临时数组中的数据再拷贝回元素组中。
- 当我们使排序完成后,释放临时数组空间。
由于再把合并过程画在上图中,会十分拥挤,所以我在下面单独举个例子:
由此,我们可以得出代码:
void _MergeSort(int* a, int left,int right,int*tmp)
{
if(left>=right)
{
return;
}
int mid = (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 index = left;
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++];
}
//归并后的结果,拷贝回到原数组
for (int i = left; i <= right; ++i)
{
a[i] = tmp[i];
}
}
void MergeSort(int* a, int n)
{
//创建临时数组
int* tmp = (int*)malloc(sizeof(int) * n);
_MergeSort(a, 0, n - 1, tmp);
free(tmp);
}
和快速排序类似,我们也可以通过非递归的方式来实现 归并排序。
不过这种写法有些难度,需要注意的小细节很多。
所以我们单独开设一篇博客来讲解它,想了解的同学可以看一看:
【图解C语言】非递归 实现 归并排序
总结与比较
1. 排序的各个性能对比