目录
1.引言
日常生活中,排序作为必要工具反复出现。有时它为了筛选出我们所描述的东西,有时它也反应了某些物品在数据层面的属性,如排名,销量等等...当打开某软件,打开排名显示,第一第二进入眼帘,仿佛整个世界的划分已经从数据开始
介绍:
排序按操作可分为两大类;内部排序和外部排序。内部排序指的是只能在内存中进行操作的排序;外部排序在内存和磁盘中都可以排序,若想向计算机磁盘进行操作,不能进行直接访问,需要使用文件指针,而文件指针是反直觉的,它不能及时的往前返回,那么为了解决不能指针不能回去如果用多个指针,海量文件的遍历会使得排序算法耗费的时间成本巨大,所以外部排序比较难实现。
本博客介绍的常见排序按照思路分可分为如图所示:
2.排序思路及其代码展示
下面会介绍几大基础排序的思路与代码呈现,都以从小到大排序为最终实现
需要注意的是:在实现代码时,是先将一次的排序完成,然后套循环实现整体的排序,所以,重要的是排序思想以及单趟排序实现
插入排序
1).插入排序
模拟思路:好比打扑克,发牌阶段需要对自己的牌组进行排序,当我们拿到第一张时,将它放到手里,第二张,嗯,需要跟第一张对比以下,到底是放到它的前面呢还是放到它的后面,拿到第三张,看看它应该插在哪张的前面...以此类推,最终将自己的牌堆变成一副有顺序的排。
代码实现:
单趟排序:假设前有end项并且前n已经有序,这时我们要插入第end+1个数,在实现插入之前我们需要明白要移动的数据是咋移动的,很明显我们要把比end+1位置对应的数大的都往后面移动,移动结束把end+1原本那个数带入要插入的位置
准备工作:把end+1这个数用临时变量tmp保存住
1.情况一,插中间
2.情况二,插头
int end;
//[0,end]已经有序,end+1需要插入
int tmp = a[end + 1];
while (end >= 0)
{
if (a[end] > tmp)//如果大于tmp,则这个数要往后移动一位,并且end要向前
{
a[end + 1] = a[end];
end--;
}
else//如果小于,说明比tmp大的都移完了,接下来不用动跳出循环
break;
}
//end为插入数的前一位,所以end+1才是tmp的位置
a[end + 1] = tmp;
整体排序:加循环,这里要注意边界,到底i是小于n还是n-1,我们这样思考,end指向哪里,哪里就已经排完了,那么最后一个数是n-1,我们要实现n-1,说明n-2已经排完了,说明i到n-2就行了对不对!那么最后的边界不就是小于n-1吗~ 轻轻松松(bushi
InsertSort(int* a, int n)
{
for (int i = 0; i < n-1; i++)
{
int end = i;
//[0,end]已经有序,end+1需要插入
int tmp = a[end + 1];
while (end >= 0)
{
if (a[end] > tmp)//如果大于tmp,则这个数要往后移动一位,并且end要向前
{
a[end + 1] = a[end];
end--;
}
else//如果小于,说明比tmp大的都移完了,接下来不用动跳出循环
break;
}
//end为插入数的前一位,所以end+1才是tmp的位置
a[end + 1] = tmp;
}
}
浅浅分析一下复杂度:
时间复杂度:
最复杂,就是逆序全需要排序,全部往后排——O(N^2)
最简单,就是顺序,不需要往后排,只要遍历一遍看看大小即可——O(N)
空间复杂度:O(1)
稳定性:稳定,因为我们可以通过先后顺序来决定哪个在前
2).希尔排序
模拟思路:通过上面对于复杂度的分析,我们知道这样一个事实:如果被排列的数据与要变成的顺序差距过大,这样排序的时间成本会增加,那么有没有可能通过某些办法能够将本来序列很逆的数据变得不那么逆?类似的思路是这样的:就像我以前上体育课,第一节课需要对队伍进行排序,50来个人男女分两排,123123的进行报数,喊到1的向前,喊到3的向后,这样成了6组,这6组进行比较,高往后低往前,最后变队回成两组就是3个组靠进去,一个一个排,这样就方便了。类似的,希尔算法有分两布走:1.预处理2.插入排序;预处理就是把组尽可能划分开,然后在这些小组里进行插入排序,这样做的目的是使得越大的数往后的越快,越小的往前越快。
代码实现:
单趟排序:其实跟插入排序一样,只是这次的间隔是自己拟定的gap
int gap;
for (int i = 0; i < n - gap; i += gap)
{
int end = i;
int tmp = a[end + gap];
while (end >= 0)
{
if (a[end] > a[end+gap])
{
a[end + gap] = a[end];
end -= gap;
}
else
break;
}
a[end + gap] = tmp;
}
整体排序:我们需要讨论这么一个问题:到底gap选什么呢?之前我们说过,其实希尔算法的预处理就是为了尽快把大的小的数往外边靠,如果我们是10个数,那gap=3挺好,但是如果我们的数有10000个,那gap=3好像又跟直接插入排序没什么区别了,几乎没有速度的调整,所以gap的大小取决于数组的大小。当然gap如果越小其作用和插入排序越靠近,也就意味着数组越有序。那么我们根据数组提供n个数进行变化,先向数据分成几大组,然后不断减小gap
void ShellSort(int* a, int n)
{
int gap = n;
while (gap > 1)
{
//gap = gap / 2; //用gap/2意味着每次都能除到1进行插入排序
gap = gap / 3 + 1; //用gap/3+1也意味着每次都能除到1进行插入排序,但是/3不一定行
for (int i = 0; i < n - gap; i += gap)
{
int end = i;
int tmp = a[end + gap];
while (end >= 0)
{
if (a[end] > a[end+gap])
{
a[end + gap] = a[end];
end -= gap;
}
else
break;
}
a[end + gap] = tmp;
}
}
}
分析一下复杂度:太难分析了,我直接写结论吧呵呵,
时间复杂度:O(N^1.25)~O(1.6*N^1.25),可以取O(1.3*N),如果N很大时,排序的时间仅次于O(N*logN).
空间复杂度:O(1)
稳定性:不稳定,因为隔着好几个排序,如果刚好跳过了跟自己一样的数,那么就不稳了。
选择排序
1).直接选择排序
模拟思路:在一大堆数据中先找到最小的,然后放在第一个,第二小的放到第二个...依次进行,最后我们得到一个顺序的序列。
当然,优化一下,我们在遍历一次时能同时识别最大和最小,这样就确定了两个数的位置
代码实现:
先确定范围,双指针指向最左和最右。每遍历一次选出最大值和最小值的位置,然后和边缘两边互换。将两个指针向里面移动,判断结束时双指针碰头。
void SelectSort(int* a, int n)
{
int begin = 0, end = n - 1;
while (begin < end)
{
int min = begin, max = begin;
for (int i = begin + 1; i <= end; i++)
{
if (a[i] > a[max])
max = i;
if (a[i] < a[min])
min = i;
}
Swap(&a[begin], &a[min]);
Swap(&a[end], &a[max]);
begin++; end--;
}
}
这个代码存在bug,在我们以为它们四个数交换时,其实有可能是三个数的交换,此时是错误的
void SelectSort(int* a, int n)
{
int begin = 0, end = n - 1;
while (begin < end)
{
int min = begin, max = begin;
for (int i = begin + 1; i <= end; i++)
{
if (a[i] > a[max])
max = i;
if (a[i] < a[min])
min = i;
}
Swap(&a[begin], &a[min]);
if(max==begin)
max=min;
Swap(&a[end], &a[max]);
begin++; end--;
}
}
复杂度分析:
时间复杂度:O(N^2)
空间复杂度:O(1)
稳定性:数组里不稳定,如{3,3,1,1,1,2}这样1,3都不稳定
2).堆排序
之前在二叉树部分讲过思想,这里不赘诉了。
void HeapDataDown(HPDataType* a, int n, int parent)
{
int minchild = parent * 2 + 1;
while (minchild < n)
{
if (minchild + 1 < n && a[parent * 2 + 1] > a[parent * 2 + 2])
{
minchild = parent * 2 + 2;
}
if (a[minchild] < a[parent])
{
Swap(&a[minchild], &a[parent]);
parent = minchild;
minchild = parent * 2 + 1;
}
else
{
break;
}
}
}
void HeapSort(int* a, int n)
{
int i = 0;
for (i = (n - 1 - 1) / 2; i >= 0; i--)
{
HeapDataDown(a, n, i);
}
// 升序排列靠大堆
for (i = n - 1; i >= 0; i--)
{
Swap(&a[0], &a[i]);
HeapDataDown(a, i, 0);
}
}
复杂度分析:
时间复杂度:O(N*logN)
空间复杂度:O(1)
稳定性:不稳定,因为树的向下调动不能指定数落的位置
交换排序
1).冒泡排序
模拟思路:对比相邻的两个数,大的往后小的往前,这样将大的往后冒泡到最后。
代码实现:
单趟排序:就是将最大的数冒泡到最后面
for (int j = 0; j < n - 1; j++)
{
if (a[j] > a[j + 1])
{
Swap(&a[j], &a[j + 1]);
}
}
整体排序:每次冒泡,都会排序完成一个数,最大的范围可以减一。
第一次冒泡n-1次,第二次冒泡n-2......第n-1次冒泡1次
void BubbleSort(int* a, int n)
{
for (int i = 0; i < n - 1; i++)
{
for (int j = 0; j < n - 1 - i; j++)
{
if (a[j] > a[j + 1])
{
Swap(&a[j], &a[j + 1]);
}
}
}
}
复杂度分析:
时间复杂度:O(N^2)
空间复杂度:O(1)
稳定性:稳定,只要前一个跟后一个一样大时不向后冒泡即可
2).快速排序
介绍:快排有三种思路写出,有如下:1.hoare创造的方法 2.挖坑法 3.前后指针法
这三种方法其实都是一个思想:把设定的key放到本应该排序的地点,这个数的左边右边分别小于,大于这个数。
1.hoare法
模拟思路:如图
描述一下:标记最左边位为key,先让right走,比key大就向前,找到比key小的停下,然后left走,比key小向前,比key大则停下,left跟right对应的数交换,不断反复,直到left==right时,把key与left位置的数交换。
注意事项:key在哪边,那么哪边的定位员不动,如图,key在左边,那么先让小R往前走。因为是这样的:拿上面举例子,如果我们让L先走,其逻辑是只要不比key大的数L都向前走,我们会发现走到最后key应该放的位置不是L和R相聚的位置,而是跟相聚位置的前一个。返回值传回key,这样内容到整体代码时再解释。
单趟排序实现:
特别注意:该代码的第6,10行,在判断与key位置的数比较时也判断了left跟right的大小,这是因为如果不加判断条件,有可能left比right大了还在循环里头没跑完,但是我们循环结束的条件没有能够判断。
int PartSort1(int* a, int left, int right)
{
int key = left;
while (left < right)
{
while (left < right && a[right] >= a[key])
{
right--;
}
while (left < right && a[left] <= a[key])
{
left++;
}
Swap(&a[left], &a[right]);
}
Swap(&a[left], &a[key]);
return left;
}
2.挖坑法
模拟思路:如图
描述一下:key标记为left位置的数保存,将其left的位置视为空hole,right向前走,找到比key小的,然后把这个数给坑填上,然后把right位置挖坑,走left找到比key大的填坑挖坑。一直到left==right时,把key填到坑上结束。
单趟排序实现:
int PartSort2(int* a, int left, int right)
{
int key = a[left];
int hole = left;
while (left<right)
{
while (hole < right && key <= a[right])
{
right--;
}
a[hole] = a[right];
hole = right;
while (hole > left && key >= a[left])
{
left++;
}
a[hole] = a[left];
hole = left;
}
a[left] = key;
return left;
}
3.前后指针
模拟思路:如图
描述一下:prev在第一这个位置,cur在第二位。cur一直在往前走,如果cur对应的数比ket小,prev向前走。当cur找到比key大的值时,prev停住但是cur还在往前走,直到找到直到cur找到比key小的值,prev向前走停在比key大的数上,然后prev与cur位置上的数交换。cur走到最后,prev这个位置对应的数用key带入。相当于:cur一直往前走并且判断是否小于key对应的值,如果小于对应的值,那么prev向前,然后prev对应的值和cur对应的值交换(当prev跟cur不指向同一个数据时)
单趟排序实现:
int PartSort3(int* a, int left, int right)
{
int key = left;
int prev = left;
int cur = left + 1;
while (cur<=right)
{
if (a[cur] < a[key] && ++prev!=cur)
Swap(&a[prev], &a[cur]);
cur++;
}
Swap(&a[key], &a[prev]);
return prev;
}
整体代码
递归代码:可以发现上面的代码返回值返回了key的位置。每一次的单趟排序得到一个已经排序好的数和排序好的数对应的位置。那么我们能通过递归的思路,把一个大的数组排序定下一个数的位置后排序其余的数
递归分三步走,1.确定递归函数的参数,这里的参数设定为两边的范围,返回值是key,那么分开就变成[begin,key-1][key+1,end]。 2.确定什么时候结束,因为两边的范围不断缩小,所以这里的结束范围是传入的left大于等于right时结束。 3.递推关系如图上,先排序后拆开,前序。
void QuickSort(int* a, int begin, int end)
{
if (begin >= end)
{
return;
}
int key = PartSort(a, begin, end); //选择用哪个方法单趟排
QuickSort(a, begin, key-1);
QuickSort(a, key+1, end);
}
此时有一个疑问了,快排的复杂度是什么呢?让我们看一下通过上面递归代码演绎出以下两种会出现的极端情况。
情况一
这个情况好讨厌啊,我可以说它基本上没有发挥快排跟递归的真正用处,N层遍历N次...依次到1层遍历1次,相加发现时间复杂度为O(N^2),有需要这么多时间。而且我们能发现,如果N真的特别特别大时,这个函数递归调用也是N次非常的大,我们知道递归这个方法虽然好写,但是时间成本很高,并且需要开辟大量函数栈帧来递归,所以这个情况很有可能就会出现栈溢出!
情况二
这个情况属于最完美的递归调用了,把二分发挥到了极致,每层N,N-1...个,递归的复杂度为log(N),所以全部这个的时间复杂度为O(N*log(N)),我们发现这个情况最有利于快排的使用。
解决方法
我们可以引入一个函数,这个函数是用来调换原数组的数据,取最左边和最右边的坐标,这样也可以取得中间的数。我们要避免的是:取key的值为最小的数或者接近最小的数。那么我们得到了left,right,mid对应的数,其实可以通过调换三个位置的数的达到避免key为最小的情况。key取得的应该是三个数里中间的数。
int GetMidIndex(int* a, int left, int right)
{
int mid = left + (left - right) / 2;
if (a[left] < a[mid])
{
if (a[mid] < a[right])
return mid;
else //a[mid]>=a[right],a[mid]>a[left]
{
if (a[left] > a[right])
return right;
else
return left;
}
}
else //a[left] >= a[mid]
{
if (a[mid] > a[right])
return mid;
else //a[left] >= a[mid],a[right] > a[mid]
{
if (a[left] > a[right])
return right;
else
return left;
}
}
}
所以我们可以通过调用这个函数,在每次单趟排序都调用一次。这样使得避免递归函数栈溢出的情况了。
下面以part1即hoare法来进行演示,其他的方法依次类推。
int PartSort1(int* a, int left, int right)
{
// 三数取中
int mid = GetMidIndex(a, left, right);
Swap(&a[left], &a[mid]);
int key = left;
while (left < right)
{
while (left < right && a[right] >= a[key])
{
right--;
}
while (left < right && a[left] <= a[key])
{
left++;
}
Swap(&a[left], &a[right]);
}
Swap(&a[key], &a[left]);
return left;
}
那么是否有不用递归的方法使得我们也可以避免栈的溢出呢?答案是肯定的。
非递归代码:通过对递归代码的分析,我们能将递归函数变为非递归。递归函数传入的参数是左右两边的范围,所以非递归则是在每一次循环中实现快排的单趟排序。
模拟思路:
递归函数为非就是通过判断传入的坐标来进行递归。那么我们能清楚一点,就是非递归的实现也需要通过坐标来实现。那么如何实现呢?哪来的空间让我们存储坐标呢?递归函数消耗的是栈帧的空间,栈帧的大小也不过几Mb,那么我们可以用数据结构的栈来存储坐标,而我们知道数据结构的栈存放在堆里,堆的大小就够用了。
首先,为了模拟递归的过程,即类似于二叉树的前序思想。1.我们传入坐标为最左边和最右边的两个数,顺序是先左边后右边,这样出栈的顺序就是先右后左。第一次对快排进行执行,传出一个mid来分割两个区域。2.因为分割开了两个区域,分别是[begin,mid-1] [mid+1,end]。因为先开始快排的是左半部分,栈是先入后出,所以我们把右半部分的坐标先存入栈中,当然存入坐标的顺序是先左后右。(这一块容易混淆,我总结一下,先递归哪半部分,那么先把另一个部分的坐标存入栈,存入栈的坐标要遵循先左坐标后右坐标)。包装成一个循环,当然循环判断结束的条件是这个栈是否为空
下面是代码展示:
void QuickSortNonR(int* a, int begin, int end)
{
Stack QS_Stack;
StackInit(&QS_Stack);
StackPush(&QS_Stack, begin);
StackPush(&QS_Stack, end);
while (!StackEmpty(&QS_Stack))
{
int right = StackTop(&QS_Stack);
StackPop(&QS_Stack);
int left = StackTop(&QS_Stack);
StackPop(&QS_Stack);
int mid = PartSort(a, left, right);//选择用哪个方法单趟排
//先右半部分坐标存入
if (mid + 1 < right)
{
StackPush(&QS_Stack, mid + 1);
StackPush(&QS_Stack, right);
}
//再左半部分坐标存入
if (left < mid - 1)
{
StackPush(&QS_Stack, left);
StackPush(&QS_Stack, mid - 1);
}
}
}
此时,我们再也不会出现栈溢出这个情况了。当然三种单趟排序也不一定需要加入三数取中。。。
最后的优化
void QuickSort(int* a, int begin, int end)
{
if (begin >= end)
{
return;
}
if (begin - end <= 8)
{
InsertSort(a + begin, begin - end + 1);
}
int key = PartSort3(a, begin, end);
QuickSort(a, begin, key-1);
QuickSort(a, key+1, end);
}
复杂度分析:
时间复杂度:O(N*log(N))
空间复杂度:O(N)
稳定性:不稳定
归并排序
思路模拟:通过上面的快排,是否可以给我们一些启发,通过二分递归来实现数组的有序。由此,我们能想出一个思路,我们二分一次,一个两部分有序,那么整个就有序,如果不有序,继续划分,两个部分有序则返回有序依次...如果知道最后分为了一个数,那这个数就已经有序了。
原数组递归展开——相当于递归时二叉树的前序,先检查是不是到停止条件了,如果不是则继续
这里的判断很简单,首先我们确定传入的参数是左右两边的范围(左右都是闭区间),当左范围的参数要大于等于右范围的参数时,递归返回,其他条件继续递归。
展开后重新按照顺序合并——相当于二叉树后序,等到递归到最底层再开始顺序排列
这里我们要选择怎么把两部分合并,这里我们采用的跟上面一致使用数组。1.如果直接在传入的数组里面进行操作会出现很尴尬的事情。如果是用覆盖的话,那么被覆盖的数会直接消失。那如果是交换,会出现交换前可能本身数组有序,交换后,数组反而不有序了,毕竟两个部分的最小值不代表这两个部分合在一起也是最小和第二小,所以使用交换不可以。必须开辟一个数组来转载数组的顺序。
递归排序实现:
void _MergeSort(int* a, int* tmp,int begin,int end)
{
if (begin >= end)
return;
int mid = begin + (end - begin) / 2;
_MergeSort(a, tmp, begin, mid);
_MergeSort(a, tmp, mid + 1, end);
//归并
int begin1 = begin, end1 = mid;
int begin2 = mid + 1, end2 = end;
int i = begin;
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, (end - begin + 1) * sizeof(int));
}
整体递归形式:无非就是开辟一个临时数组跟原数组以及左右范围一起进入递归中。
void MergeSort(int* a, int n)
{
int* tmp = (int*)malloc(sizeof(int) * n);
if (tmp == NULL)
{
perror("malloc fail");
return;
}
_MergeSort(a, tmp, 0, n - 1);
free(tmp);
}
非递归形式
思路模拟:通过上面的gif,我们可以知道,好像归并排序好像2的n次幂,那么我们是否可以通过手动调取这些2的n次幂值来实现非递归呢?当然,只要用一个gap==1;先是一个跟一个归并,如何gap*=2;反复到最后。(当然,这样设计是有瑕疵的,后面说)
void MergeSortNonr(int* a, int n)
{
int* tmp = (int*)malloc(sizeof(int) * n);
if (tmp == NULL)
{
perror("malloc fail");
return;
}
int gap = 1;
while (gap < n)
{
for (int j = 0; j < n; j += 2 * gap)
{
int begin1 = j, end1 = j + gap - 1;
int begin2 = j + gap, end2 = j + 2 * gap - 1;
int i = j;
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 + j, tmp + j, (end2 - j + 1) * sizeof(int));
gap *= 2;
}
}
free(tmp);
}
聪明如你,我们就有这样一个疑问,如果存在一个奇数的数组,那怎么办?更遗憾的是,如果前面好好的,都能对应上,后面单独一个没有归并,那不是跟没归并一样。所以只要不是2的n次幂个元素的数组就是越界!
讨论一下会出现什么情况
1.end1越界,跳过循环
2.begin2越界,跳出循环
3.end2越界,调整范围,继续归并
void MergeSortNonr(int* a, int n)
{
int* tmp = (int*)malloc(sizeof(int) * n);
if (tmp == NULL)
{
perror("malloc fail");
return;
}
int gap = 1;
while (gap < n)
{
for (int j = 0; j < n; j += 2 * gap)
{
int begin1 = j, end1 = j + gap - 1;
int begin2 = j + gap, end2 = j + 2 * gap - 1;
int i = j;
if (end1 >= n)
break;
if (begin2 >= n)
break;
if (end2 >= n)
end2 = n - 1;
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 + j, tmp + j, (end2 - j + 1) * sizeof(int));
gap *= 2;
}
}
free(tmp);
}
3.总结
排序类型 | 最好时间 | 最坏时间 | 平均时间 | 空间复杂度 | 稳定 |
插入排序 | O(N) | O(2^N) | O(2^N) | O(1) | 是 |
希尔排序 | O(1.3^N) | O(2^N) | O(2^N) | O(1) | 否 |
选择排序 | O(2^N) | O(2^N) | O(2^N) | O(1) | 否 |
堆排序 | O(N*log(N)) | O(N*log(N)) | O(N*log(N)) | O(1) | 否 |
冒泡排序 | O(N) | O(2^N) | O(2^N) | O(1) | 是 |
快速排序 | O(N*log(N)) | O(2^N) | O(N*log(N)) | O(log(N)) | 否 |
归并排序 | O(N*log(N)) | O(N*log(N)) | O(N*log(N)) | O(N) | 是 |