前言
排序,可以说是数据结构中必不可缺的一环。我们创造数据存储它,要想知道数据之间的联系,比较是必不可少的。不然,费劲心思得来的数据若是不能有更多的意义,那么拿到了又有什么用?
排序是计算机内经常进行的一种操作,其目的是将一组“无序”的记录序列调整为“有序”的记录序列。分为内部排序和外部排序,若整个排序过程不需要访问外存便能完成,则称此类排序问题为内部排序。反之,若参加排序的记录数量很大,整个序列的排序过程不可能在内存中完成,则称此类排序问题为外部排序。内部排序的过程是一个逐步扩大记录的有序序列长度的过程。
一. 排序的种类
快速排序、希尔排序、堆排序、直接选择排序不是稳定的排序算法,而基数排序、冒泡排序、直接插入排序、折半插入排序、归并排序是稳定的排序算法。
稳定排序:假设在待排序的文件中,存在两个或两个以上的记录具有相同的关键字,在用某种排序法排序后,若这些相同关键字的元素的相对次序仍然不变,则这种排序方法是稳定的。其中冒泡,插入,基数,归并属于稳定排序,选择,快速,希尔,归属于不稳定排序。
具体为什么这些排序是稳定排序,或者是不稳定排序,会在之后进行图片演示。
根据上述介绍可以知道,总共的排序分为8种,接下来我会挑出重要的和大家讲解。
二. 排序的实现
一下排序都是按照排升序的方式进行的,请读者注意。如果想要让排序的功能更加丰富,推荐读者像qsort这样的标准函数一样,传入比较的函数指针,由于只是讲解比较原理,故简化了。
1. 插入排序
1.1. 原理
图1-1 插入排序原理图
如图1-1所示,开始的时候数据的储存入第A行所示,我们需要将从第二个数据的位置开始,向前一次插入数据,将小的放在前面。从目标位置向前遍历的时候,如果目标数据小于比较数据,就将比较的数据向后移动一格。因此我们需要提前记录目标位置的值。
从A行的第二列开始,2小于5,就将5向后移动一位,到顶了,就将2赋给第一列。同理,到了第三列的4,4小于5,就将5向后挪动一列,再继续比较,4大于2,就不需要挪动2,将4赋给第二列。按照上述规律,依次插入,直到到第E行,将第六列的3插入到第三列为止。
1.2. 代码
// 插入排序
void InsertSort(int* a, int n)
{
// a, 不能为空
assert(a);
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;
}
}
a[end + 1] = tmp;
}
}
2. 希尔排序
2.1. 介绍
希尔希尔(Shell's Sort)是插入排序的一种又称“缩小增量排序”(Diminishing Increment Sort),是直接插入排序算法的一种更高效的改进版本。希尔排序是非稳定排序算法。该方法因 D.L.Shell 于 1959 年提出而得名。
希尔排序是一种比较厉害的排序,虽然没有稳定性,但是排序的时间复杂度是小于O(N^2)的。所以,希尔排序是一种很厉害的排序。快的表现和快速排序、堆排序、归并排序是一个等级的。
2.2. 原理
图2-1 希尔排序原理图
如果插入排序是希尔排序的一种特殊情况,插入排序相当于希尔排序增量为1的时候的排序。那么为什么诞生的希尔排序有它的意义呢?从第一趟增量为5的时候,相当于给数据分成了5个部分,大大减小了排序的范围。在第二趟的排序中,会造成的效果就是,交换的时候每次增量不超过上一次排序的增量也就是5。因此提高了效率。
在性能上,希尔排序的时间复杂度区间在O(N^(3/2))到O(N*logN)之间,不需要大量的辅助空间,因此数据排序在中等规模中表现良好。但是对于规模非常大的数据时不是最佳选择,数据量大的时候仍然推荐快速排序。至于时间复杂度和降低时间复杂度的方法是需要非常复杂的数学模型的,专家们正在研究,如今仍然是数学难题。
2.3. 代码
// 希尔排序
void ShellSort(int* a, int n)
{
// a, 不能为空
assert(a);
// 希尔排序相当于插入排序中有多次预排序
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(tmp < a[end]) // 升序中, 小于插入的值就挪动数据
{
a[end + gap] = a[end];
end -= gap;
}
else // 反之跳出循环赋值
{
break;
}
}
a[end + gap] = tmp;
}
}
}
3. 选择排序
3.1. 原理
直接选择排序(Straight Select Sorting) 也是一种简单的排序方法,它的基本思想是:第一次从R[0]~R[n-1]中选取最小值和最大值,与R[0]、R[n-1]交换,第二次从R[1]~R[n-2]中选取最小值和最大值,与R[1]、R[n-2]交换,....,第i次从R[i-1]~R[n-1-i]中选取最小值和最大值,与R[i-1]、R[n-1-i]交换,.....,重复n/2次,得到一个按排序码从小到大排列的有序序列。
因为非常的简单,就是反复遍历数组将最小值最大值分别放到数组首和数组尾,所以时间复杂度很稳定,但也很大,一直都是O(N^2)。
3.2. 代码
void swap(int* a, int p1, int p2)
{
int tmp = a[p1];
a[p1] = a[p2];
a[p2] = tmp;
}
// 选择排序
void SelectSort(int* a, int n)
{
assert(a);
int begin = 0, end = n - 1;
while(begin < end) // 一次循环找到最大值和最小值,
{
int mini = begin, maxi = end;
for(int i = begin; i <= end; ++i) // 一次循环找到最大值和最小值
{
if(a[i] < a[mini]) // 找最小
{
mini = i;
}
if(a[i] > a[maxi]) // 找最大
{
maxi = i;
}
}
if(maxi == begin) // 如果最大值在第一位,就需要改变交换的位置
{
maxi = mini;
}
swap(a, begin, mini); // 交换数据
swap(a, end, maxi);
++begin; // 控制结束循环的条件
--end;
}
}
4. 堆排序
4.1. 原理
原理相关的可以参考之前在二叉树部分推出的堆排序,是一样的。利用二叉树的原理建堆,将最大的数放在堆顶,然后将堆顶的数据放到末尾之后重新从堆头调整数据,这样堆的数据每次都会减少一个,直到全部完成排序。
建堆的方式选择向下建堆,完成排序的时间复杂度是O(N*logN)。
4.2. 代码
// 堆排序
void AdjustDown(int* a, int n, int parent)
{
assert(a);
// 假设左孩子更大, 公式:child = parent * 2 + 1;
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, child);
parent = child;
child = parent * 2 + 1;
}
else// 反之,跳出循环(无需继续调整)
{
break;
}
}
}
void HeapSort(int* a, int n)
{
assert(a);
//向下调整建堆(大堆)
int i = n / 2 - 1;
while(i >= 0)
{
AdjustDown(a, n, i);
--i;
}
// 得到降序数组
while(--n)
{
swap(a, 0, n);
AdjustDown(a, n, 0);
}
}
5. 冒泡排序
5.1. 原理
冒泡排序(Bubble Sort)是最简单和最通用的排序方法,其基本思想是:在待排序的一组数中,将相邻的两个数进行比较,若前面的数比后面的数大就交换两数,否则不交换;如此下去,直至最终完成排序。由此可得,在排序过程中,大的数据往下沉,小的数据往上浮,就像气泡一样,于是将这种排序算法形象地称为冒泡排序。
图5-1 冒泡排序原理图
算法的原理如图5-1所示,在待排序的一组数中,将相邻的两个数进行比较,若前面的数比后面的数大就交换两数,否则不交换。比如第一趟排序,比较26和28,26小于28故不做改变,到了28和24,28大于24,所以就交换数据位置,再向下比较28和11,仍然需要交换,这样就完成了一轮,28不在需要比较。然后依次排序。
5.2. 代码
// 冒泡排序
void BubbleSort(int* a, int n)
{
for(int i = 0; i < n - 1; ++i) // 循环趟数
{
for(int j = 1; j < n - i; ++j) // 循环一趟
{
if(a[j] < a[j - 1]) // 把大的元素往后放,使数据提增
{
swap(a, j, j - 1);
}
}
}
}
6. 快速排序
6.1. 原理
快速排序算法通过多次比较和交换来实现排序,其排序流程如下:
(1)首先设定一个分界值,通过该分界值将数组分成左右两部分。
(2)将大于或等于分界值的数据集中到数组右边,小于分界值的数据集中到数组的左边。此时,左边部分中各元素都小于分界值,而右边部分中各元素都大于或等于分界值。
(3)然后,左边和右边的数据可以独立排序。对于左侧的数组数据,又可以取一个分界值,将该部分数据分成左右两部分,同样在左边放置较小值,右边放置较大值。右侧的数组数据也可以做类似处理。
(4)重复上述过程,可以看出,这是一个递归定义。通过递归将左侧部分排好序后,再递归排好右侧部分的顺序。当左、右两个部分各数据排序完成后,整个数组的排序也就完成了。
图6-1 快速排序举例图
快速排序在分部中分为三种方法:最标准的霍尔法、挖坑法以及前后指针法。如图6-1所示,快速排序的方法是挖坑法。首先在数组中记录分界值(这里是49),设置头指针和尾指针。如果要将数据排列为升序,那么尾指针从后向前遍历找小于分界值的数(找到了27)停下来,将这个放到之前记录分界值位置的部分,也可以说是头指针部分,然后将27填充到49处,表示填坑。这样27的位置相当于挖了一个坑。然后就然头指针找大于分界值的数(这里找到的是65),然后将65填充到尾指针的位置处(27)。之后移动尾指针,重复这个循环,直到头指针和尾指针相遇,将记录的分界值填入。结果如图6-1中的一次划分后所示。每次划分都能够确定一个值的具体位置,这里确定的是49。之后将49前后的数据分为新的空间继续进行划分即可完成排序。
霍尔法,是使用头指针从左找大,尾指针从后找小,然后交换位置。相遇之后交换分界值的位置,和挖坑法最大的不同之处在于49的位置是最后移动的。
前后指针法,前指针找到小于分界值的数就和后指针位置的数交换,然后后指针也向前推进。与前两者最大的不同是,指针的移动是按照同方向进行的。
6.2. 代码
int Midi(int* a, int left, int right)
{
int midi = (left + right) / 2;
if(a[left] > a[midi])
{
if(a[midi] > a[right])
{
return midi;
}
else if(a[left] > a[right])
{
return right;
}
else
{
return left;
}
}
else // a[left] < a[midi]
{
if(a[midi] < a[right])
{
return midi;
}
else if(a[left] < a[right])
{
return right;
}
else
{
return left;
}
}
}
// 快速排序递归实现
// 快速排序hoare版本
int PartSort1(int* a, int left, int right)
{
int key = left;
int begin = left + 1, end = right;
while(begin < end)
{
while(begin < end && a[end] >= a[key])
{
--end;
}
while(begin < end && a[begin] <= a[key])
{
++begin;
}
swap(a, begin, end);
}
swap(a, key, end);
return end;
}
// 快速排序挖坑法
int PartSort2(int* a, int left, int right)
{
int key = a[left];
int begin = left, end = right;
while(begin < end)
{
while(begin < end && a[end] >= key)
{
--end;
}
a[begin] = a[end];
while(begin < end && a[begin] <= key)
{
++begin;
}
a[end] = a[begin];
}
a[end] = key;
return end;
}
// 快速排序前后指针法
int PartSort3(int* a, int left, int right)
{
int key = a[left];
int prev = left, cur = left + 1;
while(cur <= right)
{
if(a[cur] < key)
{
++prev;
swap(a, prev, cur);
}
++cur;
}
swap(a, left, prev);
return prev;
}
void QuickSort(int* a, int left, int right)
{
assert(a);
//区间过小直接返回
if(left >= right)
{
return;
}
//如果区间过小使用插入排序
// if(right - left < 10)
// {
// SelectSort(a, right - left + 1);
// }
// 三数取中确定key
int key = Midi(a, left, right);
swap(a, left, key);
key = PartSort3(a, left, right);
QuickSort(a, left, key - 1);
QuickSort(a, key + 1, right);
}
6.3. 非递归的实现
非递归的实现就需要解决区间的问题,所以我们需要向之前数据结构中,队列和栈中借过来存储区间位置即可。代码如下:
// 快速排序 非递归实现
void QuickSortNonR(int* a, int left, int right)
{
assert(a);
Stack st;
StackInit(&st);
StackPush(&st, right);
StackPush(&st, left);
while(!StackEmpty(&st))
{
int begin = StackTop(&st);
StackPop(&st);
int end = StackTop(&st);
StackPop(&st);
// 三数取中确定key
int keyi = Midi(a, begin, end);
swap(a, begin, keyi);
keyi = PartSort2(a, begin, end);
//区间过小就不入栈
if(begin < keyi - 1)
{
StackPush(&st, keyi - 1);
StackPush(&st, begin);
}
if(keyi < end)
{
StackPush(&st, end);
StackPush(&st, keyi + 1);
}
}
StackDestroy(&st);
}
7. 归并排序
7.1. 原理
图7-1 归并排序原理图
归并排序,就是将两组都是有序的数据合成一组的排序。所以对于原来的数组如图7-1所示,先拆分为不可继续分割的区间,然后分别将其合并,排列成有序数组,为下一次归并做准备。例如这里的10和4,组合之后成为[4,10],3和6组合之后成为[3,6]。然后再将这两个区间组合成[3,4,6,10]。直到组合完成所有数据。
ps:实际在代码中比较需要建立额外的位置存放排序的数据,另外还需要每次排列完成数据后将数据赋值给原来的数组。相互比较的时候也需要注意,会有一个区间的数据没完全存入,需要分出一步完成该过程。
7.2. 代码
// 归并排序,子函数
void _MergeSort(int* a, int* tmp, int left, int right)
{
// 区间过小,直接返回
if(left >= right)
{
return;
}
// 分区间
int midi = (left + right) / 2;
// [left, midi] [mide + 1, right]
_MergeSort(a, tmp, left, midi);
_MergeSort(a, tmp, midi + 1, right);
// 归并
int begin1 = left, end1 = midi;
int begin2 = midi + 1, end2 = right;
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 + left, tmp + left, sizeof(int) * (right - left + 1));
}
// 归并排序
void MergeSort(int* a, int n)
{
// 检查数组存在
assert(a);
// 开辟额外的空间暂存排序后数据
int* tmp = (int*)malloc(sizeof(int) * n);
if(tmp == NULL)
{
perror("malloc fail");
return;
}
// 分装子函数,实现归并功能
_MergeSort(a, tmp, 0, n - 1);
free(tmp);
}
7.3. 非递归实现
和快速排序一样,需要解决的是区域划分的问题。那么如何划分区间呢?
方法是设置区间的大小值,每次排序都按照这个大小划分区间,没完成一次归并就将这个区间的值翻倍,如图7-1所示,分解区间,开始区间的大小为1,之后是2/4/8。当然划分区间需要调整,因为数组的大小可能并不是分区间的倍数。如果数组剩下的元素不满于一个区间,就不需要继续排序,如果有一个区间但是不足第二个,就需要修剪第二个区间的范围。
代码如下:
// 归并排序, 非递归
void MergeSortNonR(int* a, int n)
{
assert(a);
// 开辟额外的空间暂存排序后数据
int* tmp = (int*)malloc(sizeof(int) * n);
if(tmp == NULL)
{
perror("malloc fail");
return;
}
int gap = 1; // 表示区间间隔
while(gap < n)
{
// 将所有区间分为n / gap + 1 份, 每次比较两个区间
for(int i = 0; i < n; i += 2 * gap)
{
int begin1 = i, end1 = begin1 + gap - 1;
int begin2 = end1 + 1, end2 = begin2 + gap - 1;
int j = begin1;
if(n - 1 < begin2)// 剩余区间过小,不比较
{
break;
}
else if(n - 1 < end2) // 如果区间2在数组结束之前,end2就是数组尾
{
end2 = n - 1;
}
//归并
// 控制比较次数
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) * (2 * gap));
}
gap *= 2; // 循环的递增
}
free(tmp);
}
作者结语
说到底,排序并不是需要用论文的方式记录的东西,这些排序的存在都已经有很长时间了,可以说计算机行业家喻户晓,所以只能算是整理了自己的学习过程。
写的也比较简单,原理然后接代码,从文本的角度来讲还是较难理解的,但是接触过的都能回到意思。这也是这篇博客的不足之处,本来是给小白学习的,但是小白却可能看不懂。
无论如何,博客已经出炉了,希望各大高手指点指点。