前言
什么是排序
-
排序 :所谓排序,就是使一串记录,按照其中的某个或某些关键字的大小,递增或递减的排列起来的操作。
-
内部排序:数据元素全部存在于内存中的排序
-
外部排序: 数据元素太多不能同时存放于内存中,根据排序过程的要求不能在内外存之间移动数据的排序
排序的作用
在现实生活中就存在着非常多的事和物以及人上有着明确的排序区分,例如:
- 学生集合列队的高矮顺序
- 每个省/市的GDP排名
- 中国富豪榜排名········等等各类排名顺序
在程序员的眼里,排序的意义就是对于一堆数据的管辖筛选排列,按照需求把它进行排序即可
但是在众多生活软件使用中,每一位程序员写的排序对于用户软件使用体验可是有着极大的便利性,例如
- 淘宝商品的各项筛选排名
- 直播平台热度排名
- 游戏区服玩家排名·······等等各类排名顺序
这些,就是排序在生活中的使用所带来的便利性
1.常见的排序及实现
-
插入排序
基本思想:
直接插入排序是一种简单的插入排序法,其基本思想是:把待排序的记录按其关键码值的大小逐个插入到一个已经排好序的有序序列中,直到所有的记录插入完为止,得到一个新的有序序列
类似于生活中玩扑克牌斗地主的时候,对于抽的新牌进行插入自己对应思想的其位置排序
-
直接插入排序
思想动图化
代码实现:
插入排序
void InsertSort(int* a, int n)
{
for (int i = 0; i < n-1; i++)
{
int end = i;
int tmp = a[end+1];
while (end >= 0)
{
if (a[end] > tmp)
{
a[end + 1] = a[end];
end--;
}
else
{
break;
}
}
a[end + 1] = tmp;
}
}
-
希尔排序(缩小增量排序)
代码实现:
希尔排序
void ShellSort(int* a, int n)
{
int gap = n;gap为预排序的组数以及每组中每个数据的间长
希尔排序写法1:
for (gap/=3+1; gap > 1; gap/=3+1)
{ 用于控制gap, gap越大 越不接近有序,gap越小越接近有序,gap如果是1就是直接插入排序
for (int j = 0; j < gap; j++)
{ 用j来控制每一组的预排序
for (int i = j; i < n - gap; i += gap)
{
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;
}
}
}
希尔排序写法2(简化版):
while (gap>1)
{
gap = gap / 3 + 1;
for (int i = 0; i < n - gap; i++)
{ i从0的位置开始,把它限制在小于size-gap的范围内,每次+1都是在对各组的不同数据进行预排
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;
}
}
}
-
选择排序
选择排序基本思想:
每一次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始位置,直到全部待排序的数据元素排完 。
-
直接选择排序
- 在元素集合array[i] 至 array[n-1]中选择关键码最大(小)的数据元素
- 若它不是这组元素中的最后一个(或第一个)元素,则将它与这组元素中的最后一个(或第一个)元素交换
- 在剩余的array[i] 至 array[n-2](array[i+1] 至 array[n-1])集合中,重复上述步骤,直到集合剩余1个元素
思想动图化
代码实现:
一次选出最大数和最小数的选择排序
void SelectSort(int* a, int n)
{
int left = 0, right = n - 1;
while (left < right)
{
int maxi = left, mini = left;
for (int i = left + 1; i <= right; i++)
{
if (a[i] < a[mini])
mini = i;
if (a[i] > a[maxi])
maxi = i;
}
Swap(&a[left], &a[mini]);
if (maxi == left)
maxi = mini;
Swap(&a[right], &a[maxi]);
left++;
right--;
}
}
-
堆排序
思想动图化
向下调整
void ADjustdown(int* ps, size_t parent, size_t size)
{
assert(ps);
默认孩子为左子节点
size_t child = parent * 2 + 1;
如果子节点下标不大于数组长度下标,则与父节点进行对比交换
while (child < size)
{
1.两个孩子节点选最(小/大)的那个,并且右孩子不能超过数组长度
if (child + 1 < size && ps[child] < ps[child + 1])
{
child++;
}
2.如果父亲满足比孩子(小 / 大), 进行交换, 交换后把孩子位置赋予父亲, 并以此为根
求出新的子节点进行新一轮比较
if (ps[parent] < ps[child])
{
Swap(&ps[parent], &ps[child]);
parent = child;
child = parent * 2 + 1;
}
else
{
break;
}
}
}
堆排序
void HeapSort(int* a, int size)
{
先建堆
int parent = size - 2 / 2;
while (parent-- >= 0)
{
ADjustdown(a, parent, size);
}
//Print(a, size);
排序
while (size > 0)
{
Swap(&a[0], &a[size - 1]);
--size;
ADjustdown(a, 0, size);
}
}
-
交换排序
基本思想:交换,就是根据序列中两个记录值的比较结果来对换这两个记录在序列中的位置交换排序的特点是:将值较大的记录向序列的尾部移动,值较小的记录向序列的前部移动。
-
冒泡排序
冒泡排序
void BubbleSort(int* a, int n)
{
for (int j = 0; j < n; j++)
{
int trn = 0;
for (int i = 0; i < n - 1 - j; i++)
{
if (a[i] > a[i + 1])
{
Swap(&a[i], &a[i + 1]);
trn = 1;
}
}
if (!trn)
break;
}
}
-
快速排序
代码实现:
包含三个版本的排序,主体代码都是一样,其中的辅助排序函数不一样
主体函数:
主体函数
快排1.0:
void QuickSort1(int* a, int begin, int end)
{
//4.如果begin大于或者等于end,则代表这段区间只有一个数值,则无需排序
if (begin >= end)
return;
//1.用key来接收每一次的中间值,并利用分治思想把key左边小于key的数值和右边大于key的数值进行排序
int key = PartSort3(a, begin, end);
//2.对key左边小于key的数值组进行排序
QuickSort1(a, begin, key - 1);
//3.对key右边大于key的数值组进行排序
QuickSort1(a, key + 1, end);
}
hoare版本排序函数:
快排的单趟排序方法1:hoare版
hoare版本快排每单次排序都要求key的左边比key小右边比key大(使用的是闭区间)
int PartSort1(int* a, int left, int right)
{
把key设置为左边,则右先走找小,后左走找大
如果key设为右边,则左先走找大右后走找小
int key = left;
while (left < right)
{
情况: 1.right和key的值可能相等 2.right的值小于key
解:如果大于或者相等,则不进行任何处理,right--, 如果小于则停住,让left走
while (left < right && a[right] >= a[key])
right--;
情况: 1.left和key的值可能相等 2.left的值大于key
解:如果相等,则不进行任何处理,left++, 如果大于则停住,把left和right进行交换
while (left < right && a[left] <= a[key])
left++;
Swap(&a[left], &a[right]);
}
走到这里代表left==right,走到这里相等时,这个数值一定比key小,和key进行交换
Swap(&a[key], &a[left]);
此时left和right一定相等,随便返回哪个都可以,都是中间值
return left;
}
hoare版本优化而来的挖坑法
快排的单趟排序方法2:挖坑法
int PartSort2(int* a, int left, int right)
{
1.设定key为left,用一个变量存住key的下标
int keyi = a[left];
while (left < right)
{
2.key的数值被存起来之后,left位置的数值就可以被覆盖,称其为坑位,right按照排序特性找小对left进行覆盖
while (left < right && a[right] >= keyi)
{
--right;
}
a[left] = a[right];
3.left的坑位被覆盖后,right随机就变成了另一个坑位,此时left要做的就是找比keyi大的数值并把其覆盖到right的坑位中
while (left < right && a[left] <= keyi)
{
++left;
}
a[right] = a[left];
}
4.left和right相遇后的位置就是最终坑位,把keyi放入坑位形成 [小于keyi] keyi [大于keyi]
a[left] = keyi;
return left;
}
前后指针法排序
快排的单趟排序方法3:前后指针法
int PartSort3(int* a, int left, int right)
{
int keyi = left;
int prev = left;
int cur = prev + 1;
while (cur <= right)
{
如果两者都小于begin位置的数值,则代表两个元素都小于begin,那么就往后继续走去找大
if (a[cur] < a[keyi] && a[++prev]>a[keyi])
{
Swap(&a[prev], &a[cur]);
}
走到这里代表prev位置数值小于begin,并且prev后一位的数值大于begin,
而cur位置数值也大于begin,这时候prev停住不动,cur往后继续走找小
++cur;
}
Swap(&a[prev], &a[keyi]);
return prev;
}
-
快排的延伸优化
-
三数取中
快排分两种情况
1.最好的情况: 每次选的key都是中位数 O(N*logN)
2.最坏的情况:每次选的key都是最大或者最小的数O(N^2)
如本身数据就有序的情况下,且数据体量较大的情况下会爆栈(栈溢出),这种情况下希尔排序的优势就比快排大很多那么快排就要做出一个动作,针对有序的情况下的解决方式,让快排依然稳坐钓鱼台
解:既然有序的情况下,递归选key 不管选最左边或者最右边都不好,那么就针对选key来进行处理
一个数组: { 1 , 2 , 3 , 4 , 5 , 6 , 7 , 8 , 9 }解决方法:
1.随机选key(达成一个随机数的条件,但是这种情况下就像掷骰子,把自己的命运交给天,不够稳妥)
2.三数取中
2.1取第一个数和最后一个数以及中间位置那个数,选出满足三数中非最大同时也非最小的数为keyi
2.2针对如果数组有序的情况下,直接选择中间数那么肯定就是二分处理
2.3针对随机数的情况选中间数那么这个keyi一定不是最大也不是最小,这时候yi避免掉最坏的O(N^2)的情况
-
实现代码
int GetMidIndex(int* a, int left, int right) //选中间数函数
{
//int mid = (left + right) / 2;
//防溢出写法
int mid = left + (right - left) / 2;
//int mid = (left + right) >> 1;
if (a[left] < a[mid])
{
if (a[mid] < a[right])
{
return mid;
}
else if (a[left] < a[right])
{
return left;
}
else
{
return right;
}
}
else
{
if (a[mid] > a[right])
{
return mid;
}
else if (a[left] < a[right])
{
return left;
}
else
{
return right;
}
}
//return a[left] < a[mid] ? (a[mid] < a[right] ? mid : (a[left] < a[right] ? left : right))
// : (a[mid] > a[right] ? mid : (a[left] < a[right] ? left : right));
}
-
三数取中的运用
三数取中法,这个方法三种单趟排序都可以使用,这里使用指针法做案例
int PartSort4(int* a, int left, int right)
{
先把中间数取出,再把这个中间数和要做key的位置数值进行交换,其它逻辑不变,这里采用left做key
int midi = GetMidIndex(a, left, right);
Swap(&a[midi], &a[left]);
int key = left;
int prev = left, cur = prev + 1;
while (cur <= right)
{
如果cur位置的值小于key位置的值,并且prev下一位的值不等于cur的情况下说明cur和prev中间
必然有一个区间,此时进行交换即可
if (a[cur] < a[key] && ++prev != cur)
Swap(&a[prev], &a[cur]);
++cur;
}
Swap(&a[prev], &a[key]);
return prev;
}
-
区间优化概念
其实快速排序,以逻辑性画图递归展开来看,其实就是一颗二叉树,把大问题层层分割成小问题进行处理直到问题彻底被分割完毕
在快排中使用了分割数据进行排序,缩小每次递归时数据体量进行逐一排序,那么假设一组数据使用快排,画图来看这个递归树展开了10层
第10层就是最终的不可分割子问题,第十层展开的栈都有2^9 = 512
第10层中每一个栈的数据体量都是1个,第9层每组栈数据体量都在3个,第8层的数据体量在7个,第七层15个数据体量..*2+1的体量计算
这种处理方法下引出一个问题:那么针对这个情况,对于这些小体量数据组有必要进行递归展开这么多栈空间对其进行处理计算吗?
答:没必要,如果处理的数据体量小,完全可以利用小区间优化对其进行处理以达到省空间以及防止栈溢出的情况出现小区间优化顾名思义,每次选出一个key要对其左边和右边的数值进行排序
如果排序的数据体量大一些还好,那么如果排序的体量小呢?
比如key的左右都是5个数值,对它们进行递归 假设每次key是中间值,那么5个要分成2组2个,2组2个再分成4组1个数值的递归再返还
总计7次递归排序调用,那么5个数值对它进行7次排序调用,是否有意义呢? 5个数据或者类似这种小体量的数据是否使用直接插入排序有更好的效率呢?
小区间优化的概念:
区间很小时,不再使用递归划分的思路让他有序,而是直接使用插入排序对小区间进行排序,从而减少递归调用
-
实现代码
小区间优化
if (end - begin + 1 <= 30)
{
传指针需要a+begin,因为如果不这样的话每次排序的只是从整个数组的a[0]开始,此时你排的是右区间起始位置就错了
所以要传当前区间的起始位置过去
InsertSort(a + begin, end - begin + 1);
}
else
{
1.用key来接收每一次的中间值,并利用分治思想把key左边小于key的数值和右边大于key的数值进行排序
int key = PartSort4(a, begin, end);
2.对key左边小于key的数值组进行排序
QuickSort2(a, begin, key - 1);
3.对key右边大于key的数值组进行排序
QuickSort2(a, key + 1, end);
}
-
小区间优化运用
void QuickSort2(int* a, int begin, int end)
{
4.如果begin大于或者等于end,则代表这段区间只有一个数值,则无需排序
if (begin >= end)
return;
小区间优化
if (end - begin + 1 <= 30)
{
传指针需要a+begin,因为如果不这样的话每次排序的只是从整个数组的a[0]开始,此时你排的是右区间起始位置就错了
所以要传当前区间的起始位置过去
InsertSort(a + begin, end - begin + 1);
}
else
{
1.用key来接收每一次的中间值,并利用分治思想把key左边小于key的数值和右边大于key的数值进行排序
int key = PartSort4(a, begin, end);
2.对key左边小于key的数值组进行排序
QuickSort2(a, begin, key - 1);
3.对key右边大于key的数值组进行排序
QuickSort2(a, key + 1, end);
}
}
-
非递归版本快排
以上版本快排虽然效率不错,但是致命缺点依然存在,那就是递归,过量数据可能会导致爆栈(栈溢出)
分析下递归版本 每次递归展开的每个栈帧中存的是什么?存的其实是排序过程当中每一步要控制处理的一个数据区间
那么栈帧中最重要的就是存储处理数据所需的区间,那么对症下药,进行模拟非递归时和递归展开存储的核心一样就可以了非递归快排实现需要用到 数据结构:栈
-
实现代码
void QuickSortNon(int* a, int begin, int end) { Stack ST; StackInit(&ST); StackPush(&ST, begin); StackPush(&ST, end); while (!StackEmpty(&ST)) { 利用栈后进先出的特性,实现右边先动 int right = StackFront(&ST); StackPop(&ST); int left = StackFront(&ST); StackPop(&ST); 每次选出一个基准数 int key = PartSort3(a, left, right); 判断左右区间是否已经排序完毕 if (left < key - 1) { StackPush(&ST, left); StackPush(&ST, key - 1); } if (right > key + 1) { StackPush(&ST, key + 1); StackPush(&ST, right); } } }
-
归并排序
1.利用分治思想,将其分解至不可分解的子问题后,每一个栈帧中的数据量为1,只有1个数据的情况下就认为在这个栈帧中它是有序的2.在有序的情况下,回到上一层中对本层的所有有序数据组进行排序处理,直至回到起点栈帧中将左右区间组进行合并排序,即可完成整体排序形象化一些如下思想图化
代码实现:
归并排序基本思想:
归并排序(MERGE-SORT)是建立在归并操作上的一种有效的排序算法,该算法是采用分治法(Divide and
Conquer)的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有
序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为二路归并。
这个思想和之前的链式二叉树分治递归思想概念一样,例如求二叉树的深度
先把每个区间分割,分割到不可分割的子问题后返回上一层时进行排序
例: 一个数组{ 1,6,8,7,5,4,3,9}
分割1:{1,6,8,7},{5,4,3,9}
分割2:{1,6},{8,7},{5,4},{3,9}
分割3:{1},{6},{8},{7},{5},{4},{3},{9}
到分割3时已经处于不可分割的子问题,并且分割3中每一个栈帧都有序
此时栈帧进行返回并对两个有序数据组进行有序数组合并
合并1:{1,6},{8,7},{4,5},{3,9}
合并2:{1,6,7,8},{3,4,5,9}
合并3:{ 1,3,4,5,6,7,8,9 };
结果:{ 1,3,4,5,6,7,8,9 };
void _MergeSort(int* a, int begin, int end, int* tmp)
{
如果begin==end,则代表到这一层,就只有一个元素,一个元素则有序,返还回去进行排序
if (begin >= end)
{
return;
}
规划区间
int keyi = (begin + end ) / 2;
_MergeSort(a, begin, keyi,tmp);
_MergeSort(a, keyi + 1, end, tmp);
合并排序
//printf("[%d %d],[%d %d]\n", begin, keyi, keyi + 1, end);
int begin1 = begin, end1 = keyi;
int begin2 = keyi + 1, end2 = end;
int index = begin;
对比两个区间,进行合并排序放入tmp数组直到有一边区间走干净为止
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++];
把临时数组里面排序后的数据按照区间放回
memcpy(a + begin, tmp + begin, (end - begin + 1) * sizeof(int));
}
归并排序递归版
void MergeSort(int* a, int n)
{
//设置一个临时数组用于合并
int* tmp = (int*)malloc(sizeof(int) * n);
assert(tmp);
_MergeSort(a, 0,n - 1,tmp);
free(tmp);
}
- 非递归版归并排序
归并排序非递归版 递归版的归并排序有一个很明显的规律 那就是每次比较都是两个元素进行比较,只不过对于区间有一个调整 利用希尔排序的预排序中的gap区间概念即可进行控制每次的区间 难点:在于控制数据边界,没控制好一下就会越界 void MergeSortNon(int* a, int n) { int* tmp = (int*)malloc(sizeof(int) * n); assert(tmp); int gap = 1; while (gap < n) { for (int i = 0; i < n; i += gap * 2) { 设两个要合并排序的区间 int begin1 = i, end1 = i + gap - 1;//区间1 int begin2 = i + gap, end2 = i + gap * 2 - 1;//区间2 边界设置要点: 区间1中的begin是肯定不会越界,但是end1和区间2都会有可能越界 end1 begin2 end2 这三者几乎是相关联的 end1越界了,那么begin2肯定也越界了,如果end1没越界,那么begin2会越界吗? 答:不一定,如果end1处于边界,那么begin2肯定会越界 begin2越界了,那么end2肯定也越界了,如果begin2没越界那么end2会越界吗? 答:不一定,如果begin2处于n/2+1的位置,那么end2一定会越界! 对于这种情况就要进行三次判断 1.如果end1越界了,那么代表区间1涵盖了数据的所有或者后半部分, 区间2肯定是不存在的 2.如果end1处于边界,则要对begin2进行单独判断,如果begin2越界了, 那么区间2也是不存在的 3.end1和begin都没越界,只有end2越界了 说明数据体的后半部分区间小于gap设定区间,只需要校准end2到数据体的边界即可 1.end1是否越界 if (end1 >= n) { end1 = n - 1; } 2.begin2是否越界 if (begin2 >= n) { //让它进不去下面的交换循环体即可 begin2 = n; end2 = n - 1; } 3.end2是否越界 if (end2 >= n) { end2 = n - 1; } int index = i; 对比两个区间,进行合并排序放入tmp数组直到有一边区间走干净为止 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++]; 把临时数组里面排序后的数据按照区间放回 } memcpy(a, tmp, n * sizeof(int)); gap *= 2; } }
非比较排序
-
计数排序
计数排序(相对映射法)
void CountSort(int* a, int n)
{
int max = a[0], min = a[0];
找出最大和最小的数
for (int i = 1; i < n; i++)
{
if (a[i] > max)
max = a[i];
if (a[i] < min)
min = a[i];
}
创建辅助数组并映射
int range = max - min + 1;
int* tmp = (int*)calloc(range, sizeof(int));
assert(tmp);
for (int i = 0; i < n; i++)
{
tmp[a[i] - min]++;
}
排序写回
int j = 0;
for (int i = 0; i < range; ++i)
{
while (tmp[i]--)
{
a[j++] = i + min;
}
}
}
2.排序之间的细节对比
- 直接插入排序和冒泡排序做对比
细化的来说,直接插入排序要优于冒泡排序
因为冒泡排序针对有序不交换的情况下才会停
它的条件相对于来说是非常苛刻的,它是针对整体数据的,整体达到几乎有序或接近有序才可以达到O(N)
而直接插入排序针对的是每一段有序或无序,适应性非常的强
- 选择排序和冒泡排序做对比
选择排序的优势在于数据量大时,我们实现的选择排序是一次选两个值,所以这里选择排序的数据增量缩小是以N-2->N-4->N-6...这种等差数列方式进行排序,而冒泡排序则是以固定的N-1进行缩小排序
但是冒泡排序在一种情况下会比选择排序要好,那就是数据有序的情况下,因为冒泡排序会进行判断数据是否有序,而选择排序不会,它是逐渐减小增量
得出结论,选择排序在数据量大且无序的情况下优于冒泡排序, 冒泡排序在数据有序前提下优于选择排序 但是在都是N^2的排序中,但是这两种排序的适应力都不如直接选择排序
- 一般情况下希尔排序的时间复杂度都优于插入排序
只有一种情况下直接插入排序和希尔排序会相差无几,那么就是数据原本接近有序或者有序的情况下,这种情况下希尔排序的预排序几乎是没有什么作用
3.排序的稳定性
假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,r[i]=r[j] ,且 r[i] 在 r[j] 之前,而在排序后的序列中, r[i] 仍在 r[j] 之前,则称这种排序算法是稳定的;否则称为不稳定的。