1. 排序的概念和应用
1.1、排序的概念
- 排序:什么是排序?排序就是使一串记录,按照其中的某个或某些关键字的大小,递增或递减的排列起来的操作
- 内部排序:数据元素全部放在内存中的排序
- 外部排序:数据元素太多不能同时放在内存中,根据排序过程的要求不能在内外存之间移动数据的排序
- 稳定性:假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,r[i]=r[j],且r[i]在r[j]之前,而在排序后的序列中,r[i]仍在r[j]之前,则称这种排序算法是稳定的;否则称为不稳定的
1.2、排序的应用
- 比如:京东上面的综合排序和价格排序
- 比如:高校之间热度的排序
- 还有我常见的"点外卖",我们一般都会点热度最高的店铺,还有就是我们在学校中考试成绩的排序等等…
1.3、常见的排序算法
2. 插入排序
2.1、直接插入排序
- 直接插入排序其基本思想是:
把待排序的记录按其关键码值的大小逐个插入到一个已经排好序的有序序列中,直到所有的记录插入完为止,得到一个新的有序序列
- 实际中我们玩扑克牌时,就用了插入排序的思想…
直接插入排序(单趟)过程分析:
- 当插入第(i>=1)个元素时,前面的end[0],end[1],end[2]…end[i-1]已经排好序
- 这时,我们将保存end[i]的值,跟end[i-1],end[i-2]…end[0]进行比较
- 当end[i]的值比待比较的值小时,将end[i-1]的值覆盖到end[i],然后–end
- 当比待比较的值小时,跳出循环,然后直接插入到end[i]位置.
代码实现:
void InsertSort(int* p, int n)
{
for (int i = 0; i < n - 1; ++i)
{
int end = i;
//保存end下标后面的数据
int tmp = p[end + 1];
//当end为
while (end >= 0)
{
//升序,判断tmp是否小于p[end]
if (tmp < p[end])
{
//小于则覆盖end后面的数据
p[end + 1] = p[end];
--end; //end往前移
}
else
{
break;
}
}
//当end走到-1时,放在循环里面处理会导致数组越界访问
p[end + 1] = tmp;
}
}
直接插入排序的特性总结:
- 元素集合越接近有序,直接插入排序算法的时间效率越高
- 时间复杂度:O(N^2)
- 空间复杂度:O(1),它是一种稳定的排序算法
- 稳定性:稳定
2.2、希尔排序
希尔排序:希尔排序又称"缩小增量法"。
希尔排序的基本思想是:
- 先选定一个整数的间隔gap,把待排序文件中所有的数据分成一个个组,所有距离为gap的数据分在同一组内,并对每一组内的数据进行排序。
- 随后,我们将重复上述的分组和排序。
- 当gap为1时,就是直接插入排序了,最后,所有数据都在统一组内排好序了。
- 希尔排序其实是插入排序的变形,希尔每次走gap步(预排序),而插入排序走1步。
预排序:每次间隔为gap,直到i < 0时,才进行下一轮的预排序。每次进行间隔gap / 3 + 1的预排。当gap为1时,说明数据已经接近有序,直接进行插入排序
代码实现:
void ShellSort(int* p, int n)
{
int gap = n;
while (gap > 1)
{
//比如有1000个数,gap之间的间隔为"334",第二次间隔为112次,第三次间隔为"38",第四次为"13",第五次为"5", 第六次为"2"(前面为"预排序"),最后为"1"进行(插入排序)
//当gap > 1时,进行预排序-----当gap等于1时,说明已经接近有序,进行"插入排序"
//一开始进行排序时,i必须小于n - gap,如果i < n时,会导致tmp赋值时,会导致越界
for (int i = 0; i < n - gap; ++i)
{
int end = i;
int tmp = p[end + gap];
while (end >= 0)
{
if (tmp < p[end])
{
p[end + gap] = p[end];
end -= gap;
}
else
{
break;
}
}
p[end + gap] = tmp;
}
}
}
注意:gap每次走几步是没有规定的,可以每次走gap/2步,甚至是每次走gap/5+1步,控制好最后gap为1就行
希尔排序的特性总结:
- 希尔排序是对直接插入排序的优化。
- 当gap > 1时都是预排序,目的是让数组更接近于有序。当gap == 1时,数组已经接近有序的了,这样就会很快。这样整体而言,可以达到优化的效果。我们实现后可以进行性能测试的对比。
- 希尔排序的时间复杂度不好计算,因为gap的取值方法很多,导致很难去计算,因此在好些书中给出的希尔排序的时间复杂度都不固定
- 时间复杂度:因为代码中的gap是按照"Knuth"提出的方式取值的,而且Knuth进行了大量的试验统计,所以时间复杂度就暂时按照:O(N1.25)到 O(1.6*N1.25)来算
- 空间复杂度:O(1)
- 稳定性:不稳定
3. 选择排序
3.1 选择排序
基本思想:每一次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始位置,直到全部待排序的数据元素排完
选择排序过程解析:
- 在元素集合array[i]-----array[n-1]中选择最大(小)的数据元素
- 若它不是这组元素中的最后一个(第一个)元素,则将它与这组元素中的最后一个(第一个)元素交换
- 在剩余的array[i]–array[n-2](array[i+1]–array[n-1])集合中,重复上述步骤,直到集合剩余1个元素
代码实现:
void SelectSort(int* p, int n)
{
//找数组中最大值和最小值,然后交换到最左边和最右边里面
int left = 0;
int right = n - 1;
while (left < right)
{
int Max = right, Min = left;
for (int i = left; i <= right; ++i)
{
//在左闭右闭[left, right]区间中找最大值和最小值
if (p[i] > p[Max])
Max = i;
if (p[i] < p[Min])
Min = i;
}
//交换数据
Swap(&p[left], &p[Min]);
//如果left和Max重叠,需修正Max
if (left == Max)
Max = Min;
Swap(&p[right], &p[Max]);
//更新left和right
++left;
--right;
}
}
Ps:上面代码是优化版的"选择排序",同时找最大值和最小值
直接选择排序的特性总结:
- 直接选择排序思考非常好理解,但是效率不是很好。实际中很少使用
- 时间复杂度:O(N^2)
- 空间复杂度:O(1)
- 稳定性:不稳定
3.2 堆排序
- 堆排序即利用堆的思想来进行排序,总共分为两个步骤:
1.1、建堆
- 升序,建大堆
- 降序,建小堆
- 利用堆删除思想来进行排序
- 升序,建大堆
ps:不管是建大堆还是小堆,最好使用向下调整算法(时间复杂度:O(N)),因为它比向上调整算法(时间复杂度(O(nlongN))建堆更优
代码实现:
void AdJustDown(HPDataType* a, size_t root, size_t size)
{
size_t parent = root;
size_t child = (parent * 2) + 1;
//孩子节点大于最大节点时,就说明向下调整完毕,退出循环
while (child < size)
{
//选出左右孩子中最小/大的那个->"小/大堆"
//如果右孩子(a[child + 1])小于左孩子(a[child])
//则最小的孩子是右孩子,自增一下左孩子下标就是右孩子了
if (a[child + 1] > a[child] && child + 1 < size) //大堆
//if (a[child + 1] < a[child] && child + 1 < size) //小堆
{
++child;
}
//当孩子节点小于/大于父节点时进行调整,说明这是一个"小堆/大堆"->根节点存储最小/最大的节点
//if (a[child] < a[parent]) //小堆
if (a[child] > a[parent]) //大堆
{
//交换节点数据,并且继续往下调整
Swap(&a[child], &a[parent]);
//更新父节点下标和孩子节点下标
parent = child;
child = (parent * 2) + 1;
}
else
{
break;
}
}
}
void HeapSort(int* a, size_t n)
{
//排升序,建"大堆"(调用向下调整函数接口)
for(int i = (n - 1 - 1) / 2; i >= 0; --i)
{
AdJustDown(a, i, n);
}
}
直接选择排序的特性总结:
- 堆排序使用堆来选数,效率就高了很多
- 时间复杂度:O(N*logN)
- 空间复杂度:O(1)
- 稳定性:不稳定
4. 交换排序
4.1 冒泡排序
基本思想:
- 所谓交换,就是根据序列中两个记录键值的比较结果来对换这两个记录在序列中的位置
- 交换排序的特点是:将键值较大的记录向序列的尾部移动,键值较小的记录向序列的前部移动
代码实现:
void BubbleSort(int* p, int n)
{
//冒泡排序:如果进行升序排序,p[i]大于p[i + 1]时进行交换,大的往后移...
for (int i = 0; i < n; ++i)
{
int exchage = 0;
//第一次走n趟,第二次走n - 1,第三次n - 2.....则判断可以写成n - i
for (int j = 1; j < n - i; ++j)
{
if (p[j - 1] > p[j])
{
exchage = 1;
Swap(&p[j - 1], &p[j]);
}
}
if (exchage == 0)
break;
}
}
冒泡排序的特性总结:
- 冒泡排序是一种非常容易理解的排序
- 时间复杂度:O(N^2)
- 空间复杂度:O(1)
- 稳定性:稳定
4.2 快速排序
快速排序:
- 快速排序是"Hoare"于1962年提出的一种二叉树结构的交换排序方法
- 基本思想:任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列
- 左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值
- 然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止
// 假设按照升序对array数组中[left, right)区间中的元素进行排序
void QuickSort(int array[], int left, int right)
{
if(right - left <= 1)
return;
// 按照基准值对array数组的 [left, right)区间中的元素进行划分
int key = Partion(array, left, right);
// 划分成功后以div为边界形成了左右两部分 [left, key) 和 key+1, right)
// 递归排[left, key)
QuickSort(array, left, key);
// 递归排[key+1, right)
QuickSort(array, key+1, right);
}
上述为快速排序递归实现的主框架,发现与二叉树前序遍历规则非常像,在写递归框架时可想想二叉树前序遍历规则即可快速写出来,后序只需分析如何按照基准值来对区间中数据进行划分的方式即可
将区间按照基准值划分为左右两半部分的常见方式有:
- hoare版本
思想:
- 定义一个key,key为Array[left],随后先走right,right找比key小的值
- 找到后,就开始走left,left找比key大的值,当left找到后,交换left下标和right下标的值
- 一直重复上诉操作,直到left和right相遇时,退出循环,将key放进它们相遇的下标中
代码实现:
int PartSort(int* p, int left, int right)
{
//"优化"后面会讲到
int mid = GetMidIndex(p, left, right);
Swap(&p[mid], &p[left]);
int key = left;
while (left < right)
{
//key在最左边,先走right,找比key小的值
while (p[key] <= p[right] && left < right)
--right;
//right向后走找到比key小的值后,left往前走找比key大的值
while (p[key] >= p[left] && left < right)
++left;
//当left走到大于key并且right走到小于key的值时,进行交换
Swap(&p[left], &p[right]);
}
//当left和right相遇时,交换key和它们其中一个的值
Swap(&p[key], &p[left]);
return left;
}
- 挖坑法
- 思想:定义一个key和pit,key保存left下标的值,pit保存left下标。
- 随后先走right,right要找比key小的值,找到后,将right下标的值覆盖到pit下标中,更新pit为right后开始走left
- left找比key大的值,当left找到后,将left下标的值覆盖到pit下标中,更新pit为left,重复上述的操作
- 当出了循环后,说明left和right相遇了,只有一个坑位把key的数据给到pit
代码实现:
int PartSort(int* p, int left, int right)
{
//"优化"后面会讲到
int mid = GetMidIndex(p, left, right);
Swap(&p[mid], &p[left]);
//首先保存最左边的值
int key = p[left];
//建坑位
int pit = left;
while (left < right)
{
//首先从右边开始走,找比key小的数据
while (key <= p[right] && left < right)
{
--right;
}
//覆盖坑位数据
p[pit] = p[right];
//更新新的坑位
pit = right;
//右边找完完后,走左边,左边找比key大的值的
while (key >= p[right] && left < right)
{
++left;
}
//覆盖坑位数据
p[pit] = p[left];
//更新新的坑位
pit = left;
}
//出了循环后说明left和right相遇了,只有一个坑位
//把key的数据给到pit
p[pit] = key;
return pit;
}
挖坑法的优势:
- 比较好理解,不用考虑left和right相遇时为什么比key小!
- 不用理解左边是key时,为什么要右边先走的问题!
- 前后指针法
- 思想:定义一个prev和cur,当cur比key小时,前置自增prev,交换cur与prev数据,然后自增cur,如果prev和cur数据相同时,则直接自增cur
- 当cur比key大时,自增cur,cur走到right(尾)时,说明prev小于key,交换prev与key(a[left])的内容
代码实现:
int PartSort(int* p, int left, int right)
{
int prev = left;
int cur = left + 1;
int key = p[left];
//当cur走到尾时,退出循环
while (cur <= right)
{
//当p[cur]小于key并且p[++prev]不等于p[cur]时,交换数据,等于就直接跳过
if (p[cur] < key && p[++prev] != p[cur])
Swap(&p[prev], &p[cur]);
//自增cur
++cur;
}
//cur走到尾时,prev位置的值一定比key小,交换key位置和prev的内容
Swap(&p[prev], &p[left]);
return prev;
}
非递归版本
思想:使用"栈"模拟快速排序中的递归
代码实现:
void QuickSort3(int* p, int begin, int end)
{
ST st; //声明栈
StackInit(&st); //初始化栈
//将begin和end入栈,待排序时需要使用
StackPush(&st, begin);
StackPush(&st, end);
while (!StackEmpty(&st))
{
//栈:"先进后出",栈顶的值是end,将它赋值给对应的right,然后出栈
int right = StackTop(&st);
StackPop(&st);
//将栈中的begin赋给left,然后出栈
int left = StackTop(&st);
StackPop(&st);
//使用"前后指针版本"进行排序
int key = PartSort3(p, left, right); //进行排序
//排序后:[left, key - 1] key [key + 1, right]
//key左区间:[left, key - 1]
if (left < key - 1)
{
StackPush(&st, left);
StackPush(&st, key - 1);
}
//key右区间:[key + 1, right]
//当key+1大于right时说明key不在区间中[0+1, 0]----当key+1等于right时说明只有一个值[0, 0]
if (key + 1 < right)
{
StackPush(&st, key + 1);
StackPush(&st, right);
}
//这里是先进行key右区间排序,因为栈的性质("先进后出")
}
StackDestory(&st);
}
Ps:这里省略了造轮子,栈的实现过于简单…
快速排序优化
当快速排序遇到"接近升序的元素集合"时,如果集合量很大,每次递归进行排序找的left都是最小的,通过等差数列(n-1+n-2+…+0)得出时间复杂度为:O(N2),当数据量越大时,会导致"栈溢出"(递归太深,无法释放),这里我们需要进行优化.
"三数取中"优化
- 思想:选的left不是最大的,也不是最小的,而是选出"中位数"
代码实现:
int GetMidIndex(int* p, int left, int right)
{
int mid = left + (right - left) / 2;
if (p[mid] > p[left])
{
if (p[mid] < p[right])
{
return mid;
}
else if (p[left] > p[right])
{
return left;
}
else
{
return right;
}
}
//p[mid] < p[left]
else
{
if (p[mid] > p[right])
{
return mid;
}
else if (p[left] < p[right])
{
return left;
}
else
{
return right;
}
}
}
“小区间优化”
当递归层数越来越深的时候,递归所消耗的次数会越来越高,效率也会有一定的消耗,这是在Debug下编译的情况,但是在"realese"下递归会被优化的很好,不使用"小区间优化"
思想:给定一个整数值,当递归层数大于这个值时,调用"直接插入排序算法"进行排序
代码实现:
void QuickSort(int array[], int left, int right)
{
if(left >= right)
return;
//"小区间优化"
if(right - left + 1 >= 10)
{
InsertSort(array + left, right - left + 1);
}
else
{
int key = Partion(array, left, right);
QuickSort(array, left, key);
QuickSort(array, key+1, right);
}
}
快速排序的特性总结:
- 快速排序整体的综合性能和使用场景都是比较好的
- c(qsort)和c++(sort)库的排序算法都是使用"快算排序"的
- 时间复杂度:O(N*logN)
5. 归并排序
思想:
- 归并排序(MERGE-SORT)是建立在归并操作上的一种有效的排序算法,该算法是采用分治法(Divide andConquer)的一个非常典型的应用。
- 将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为二路归并,归并排序核心步骤:
代码实现:
void _MergeSort(int* p, int begin, int end, int* tmp)
{
//begin等于end时说明区间为[0, 0]只有一个值,begin大于end时说明区间不存在[1, 0](mid为0时)
if (begin >= end)
return;
int mid = begin + (end - begin) / 2;
//分割数组--->分成二个区间 [begin, mid] [mid+1, end]
_MergeSort(p, begin, mid, tmp);
_MergeSort(p, mid + 1, end, tmp);
//归并分割后的数据
int begin1 = begin, end1 = mid;
int begin2 = mid + 1, end2 = end;
int index = begin;
//使用二叉树中“后序遍历的方法"进行排序并且"归并到原数组" --- 跟合并二个数组和链表相似-> 取小进行尾插(升序)
while (begin1 <= end1 && begin2 <= end2)
{
//取小放到辅助数组
if (p[begin1] < p[begin2])
{
tmp[index++] = p[begin1++];
}
else
{
tmp[index++] = p[begin2++];
}
}
//二个区间中可能还会存在数据没有入到辅助数组tmp的情况
while (begin1 <= end1)
tmp[index++] = p[begin1++];
while (begin2 <= end2)
tmp[index++] = p[begin2++];
//将排序好的辅数组(tmp)拷贝到原数组(p)中
memcpy(p + begin, tmp + begin, sizeof(int) * (end - begin + 1));
}
void MergeSort(int* p, int n)
{
int* tmp = (int*)malloc(sizeof(int) * n);
_MergeSort(p, 0, n - 1, tmp);
}
非递归版本
思想:使用"循环"模拟递归
void MergeSortNonR(int* p, int n)
{
int* tmp = (int*)malloc(sizeof(int) * n);
assert(tmp);
//一开始gap为1,两两进行归并
int gap = 1;
//gap等于或大于n说明已经合并完成
while (gap < n)
{
for (int i = 0; i < n; i += 2 * gap)
{
int begin1 = i, end1 = i + gap - 1;
int begin2 = i + gap, end2 = i + 2 * gap - 1;
int index = i;
//判断边界问题
if (end1 >= n)
end1 = n - 1;
//当"end2越界时,修正end2"
if (end2 >= n)
end2 = n - 1;
//当b"begin2未越界且end2越界时,修正end2"
if (begin2 < n && end2 >= n)
end2 = n - 1;
//当"begin2和end2都越界时",说明是一个不存在的区间,修正为不进循环的区间
if (begin2 >= n && end2 >= n)
{
begin2 = n;
end2 = n - 1;
}
while (begin1 <= end1 && begin2 <= end2)
{
if (p[begin1] < p[begin2])
{
tmp[index++] = p[begin1++];
}
else
{
tmp[index++] = p[begin2++];
}
}
while (begin1 <= end1)
tmp[index++] = p[begin1++];
while (begin2 <= end2)
tmp[index++] = p[begin2++];
}
//将归并好的辅助数组(tmp)拷贝回原数组中
memcpy(p, tmp, sizeof(int) * n);
//每次归并的间隔为2的倍数,第一次gap为1,第二次为2,第三次为4......
gap *= 2;
}
}
归并排序的特性总结:
- 归并的缺点在于需要O(N)的空间复杂度,归并排序的思考更多的是解决在磁盘中的外排序问题
- 时间复杂度:O(N*logN)
- 空间复杂度:O(N)
- 稳定性:稳定
6. 排序算法复杂度及稳定性分析
排序方法 | 平均情况 | 最好情况 | 最坏情况 | 辅助空间 | 稳定性 |
---|---|---|---|---|---|
冒泡排序 | O(N2) | O(N) | O(N2) | O(1) | 稳定 |
简单选择排序 | O(N2) | O(N2) | O(N2) | O(1) | 不稳定 |
直接插入排序 | O(N2) | O(N) | O(N2) | O(1) | 稳定 |
希尔排序 | O(NlongN— O(N2)) | O(N1.3) | O(N2) | O(1) | 不稳定 |
堆排序 | O(NlongN) | O(NlongN) | O(NlongN) | O(N) | 稳定 |
归并排序 | O(NlongN) | O(NlongN) | O(NlongN) | O(N) | 稳定 |
快速排序 | O(NlongN) | O(NlongN) | O(N2) | O(longN— O(N)) | 不稳定 |