文章目录

本笔记多处采用 双鱼211 大佬的博客: https://blog.youkuaiyun.com/weixin_50886514/article/details/119045154
● 插入排序
1.直接插入排序
时间复杂度为 O(N^2) 的排序中最好的排序,最好的情况为 O(N)
摘自 双鱼211 大佬的图片,简单易懂(有现成的图用来记笔记就是爽啊):
void InsertSort(int* arr, int n)
{
for (int i = 0; i < n - 1; ++i)
{
//记录有序序列最后一个元素的下标
int end = i;
//待插入的元素
int tem = arr[end + 1];
while (end >= 0)
{
//比插入的数大就向后移
if (tem < arr[end])
{
arr[end + 1] = arr[end];
end--;
}
//比插入的数小,跳出循环
else
{
break;
}
}
//tem放到比插入的数小的数的后面
arr[end + 1] = tem;
}
}
插入排序 优于 冒泡排序 的原因:
从后向前插入时,找到插入点后进入下一个数据的插入;而冒泡排序注定要把所有步骤走完(一个只具有教学意义的排序)
2.希尔排序
原数据越接近有序,插入排序时间复杂度越低 ——通过 预排序 降低时间复杂度
● 预排序 – 接近有序
● 插入排序
//准确来说这还不是希尔排序
//以下,将数据分 3 组,
void ShellSort_1(int* a, int n) {
int gap = 3;
for (int j = 0; j < gap; j++) {
for (int i = j; i < n - gap; i += gap) {
int end = i;//向前比较的 end
int tem = a[end + gap];//需要插入的数
while (end >= 0) {
if (tem < a[end]) {
a[end + gap] = a[end];
end = end - gap;
}
else {
break;
}
}
a[end + gap] = tem;
}
}
}
希尔排序复杂度(计算超麻烦):
● 选择排序
1.直接选择排序
每一趟从待排序的数据元素中选出最小(或最大)的一个元素,顺序放在已排好序的数列的 最前 / 最后 ,直到全部待排序的数据元素排完。
完全没有用,但因为这是篇记录排序的博客,所以记上了
直接选择排序和冒泡排序的区别(个人感觉基本没什么区别——反正都没什么用):
● 选择排序是给定位置去找数,冒泡排序是通过数去找位置;
● 冒泡排序每轮交换的次数比较多,而选择排序每轮只交换一次;
● 比冒泡排序效率高那么一点
void SelectSort(int* a, int n) {
int end = 0; int min = 0;
for (int j = 0; j < n - 1; j++) {//j 负责作为下标给选出的最大/最小数留位置
min = j;
for (int i = j + 1; i < n; i++) {
if (a[i] < a[min]) min = i;
}
int tem = a[j]; a[j] = a[min]; a[min] = tem;
}
}
2.堆排序
//向上调整
void AdjustUp(HPDataType* a, int child) {
int parent = (child - 1) / 2;
while (child > 0) {
if (a[child] < a[parent]) Swap(a + child, a + parent);//建小(根)堆 —— 父节点比子节点数据小
else break;
child = parent;
parent = (parent - 1) / 2;
}
}
//向下调整
void AdjustDown(int* a, int n, int parent)
{
int child = parent * 2 + 1;
while (child < n)
{
// 选出左右孩子中小的那个(如果建大堆则选大的那个)
if (child + 1 < n && a[child + 1] < a[child])
{
++child;
}
if (a[child] < a[parent])
{
Swap(&a[parent], &a[child]);
parent = child;
child = parent * 2 + 1;
}
else
{
break;
}
}
}
void HeapSort(HPDataType* a, int sz) {
//向下调整算法建堆(当此位置以下的部分都是小堆时,在此位置调用向下调整算法,即可使得此位置及以下都是小堆)
//当然也可以用向上调整算法建堆(需要让当前位置以上都是小堆,在此位置调用向上调整算法即可使得此位置及以上都是小堆),但效率较低
for (int i = (sz - 1 - 1) / 2; i >= 0; --i) {
AdjustDown(a, sz, i);
}
//建堆完成,此时整个数组都是小堆的结构
while (sz) {
//将堆顶数据(小堆堆顶即为最小的数据)交换到末尾
Swap(&a[0], &a[sz - 1]);
sz--;
//向下调整,将刚刚交换上来的大的数调下去,并将第二小的数调至堆顶,下一次循环中第二小的数也将被交换到末尾(倒数第二个位置)
AdjustDown(a, sz, 0);
}
}
● 交换排序
1.冒泡排序
除了教学以外完全没用呢~
void BubbleSort(int* a, int n)
{
for (int j = 0; j < n; ++j)
{
bool exchange = false;
for (int i = 1; i < n - j; i++)
{
if (a[i - 1] > a[i])
{
int tmp = a[i];
a[i] = a[i - 1];
a[i - 1] = tmp;
exchange = true;
}
}
if (exchange == false)//当某一次从头开始到最后都没有发生交换,说明已经有序,也就没有继续循环的必要了
{
break;
}
}
}
2.快速排序
综合而言最优的算法
快速排序是Hoare于1962年提出的一种二叉树结构的交换排序方法,其基本思想为:任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过
程,直到所有元素都排列在相应位置上为止。
1.hoare版本(左右指针法)
单趟排序:
1、选出一个key,一般是最左边或是最右边的。
2、定义一个begin和一个end,begin从左向右走,end从右向左走。(需要注意的是:若选择最左边的数据作为key,则需要end先走;若选择最右边的数据作为key,则需要bengin先走)。
3、在走的过程中,若end遇到小于key的数,则停下,begin开始走,直到begin遇到一个大于key的数时,将begin和right的内容交换,end再次开始走,如此进行下去,直到begin和end最终相遇,此时将相遇点的内容与key交换即可。(选取最左边的值作为key)
4.此时key的左边都是小于key的数,key的右边都是大于key的数
5.将key的左序列和右序列再次进行这种单趟排序,如此反复操作下去,直到左右序列只有一个数据,或是左右序列不存在时,便停止操作,此时此部分已有序
(写的太好了)
仍摘自 双鱼211 大佬的图片
以上步骤中,(选左边作为 key ) end 先走保证了 key 和 end 相遇时所表示的数一定是比 key 小的数
int PartSort(int* a, int left, int right) {
int key = left;//因为起始left处的数据到最后才被交换,所以key可以存下标
while (left < right) {
while (right > left && a[right] <= a[key]) {// 没有 = 时可能死循环 eg. 6 6 4 6 3
right--; //没有right > left的比较会出现越界的情况 eg. 0 3 2
}
while (right > left && a[left] >= a[key]) {
left++;
}
Swap(&a[left], &a[right]);
}
Swap(&a[key], &a[right]);
return right;
}
每次单趟排序都会确定数组中一个数据的位置
//left right 两参数配合递归 //left通常为 0,right通常为 数组大小 - 1
void QuickSort(int* a, int left, int right) {
if (left >= right) return;
int key = PartSort(a, left, right);
//[left,key - 1] key [key + 1,right]
QuickSort(a, left, key - 1);
QuickSort(a, key + 1, right);
}
乍一看这里快排的实现似乎没什么,但细想都是难题:
要应用二叉树递归的原理;要想明白控制与 key 选取相反的一侧先走方便最后的交换;left 初始不能是 key + 1;
2.挖坑法
- 选出一个数据(一般是最左边或是最右边的)存放在key变量中,在该数据位置形成一个坑
- 还是定义一个L和一个R,L从左向右走,R从右向左走。(若在最左边挖坑,则需要R先走;若在最右边挖坑,则需要L先走)
(思路和 hoare 版本类似)
int PartSort(int* a, int left, int right) {
int key = a[left];//因为可能会覆盖起始left处的数据,所以key不能存下标
while (left < right) {
while (left < right && a[right] >= key) {//仍要 key 的另一侧先走
right--;
}
a[left] = a[right];
while (left < right && a[left] <= key) {//left初始仍为 key —— 当 right 找不到比 key 小的数时直接在 key 处相遇
left++;
}
a[right] = a[left];
}
a[right] = key;
return right;
}
3.前后指针法
关于我绞尽脑汁也找不到靠谱的动态讲解图这件事
步骤还挺简洁
- 两指针 prev cur 一前一后,key 记录首元素值,初始规定 prev 指向首元素,cur 指向 prev 的下一个
- ++cur 后若 cur 处值大于 key,什么也不做;若小于 key 则 ++prev,再交换 prev 和 cur 处的值
通过以上的规则将数据分成前后两份:prev 以前 & prev 后
● 因为是前后指针,prev 将走的格 cur 已经走过,所以截止到 prev 都是小于 key 的值;
● 当 cur 和 prev 相隔开时,分析可知它们之间都是大于 key 的值,cur 继续向后找到小于 key 的值后经交换,它们之间仍都是大于 key 的值,这样变换保证了 prev 和 cur 之间一直如此,直到 cur 到尾部停止,此时 prev 到 cur(结尾) 值均大于 key
● prev 一直在和小于 key 的数交换,即 prev 处的数定然小于等于 key ,结束时只需将其和 key 交换即可实现 key 分割数据
int PartSort(int* a, int left, int right) {
int prev = left; int cur = left + 1;//也没说前后指针一定要定义成指针
int key = left;
while (cur <= right) {
if (a[cur] < a[key] && ++prev != cur) {//有人认为自己和自己交换没什么意思,所以这么写
Swap(&a[cur], &a[prev]);
}
cur++;
}
Swap(&a[key], &a[prev]);
return prev;
}
分析如上快速排序的复杂度:
最好的情况:key 取值每次都是中间值,复杂度:O(N*logN);最坏的情况:有序,复杂度:O(N^2)
——有序时这样的快排甚至比直接插入排序还慢
于是就有以下方法:
三数取中法
int GetMidIndex(int* a, int left, int right)
{
//int mid = (left + right) / 2; //只是这么写的话容易被出题人找规律针对……
int mid = left + rand() % (right - left)
if (a[left] < a[mid])
{
if (a[mid] < a[right])
{
return mid;
}
else if (a[left] < a[right])
{
return right;
}
else
{
return left;
}
}
else // a[left] > a[mid]
{
if (a[mid] > a[right])
{
return mid;
}
else if (a[left] > a[right])
{
return right;
}
else
{
return left;
}
}
}
对单趟排序进行改写:
int PartSort(int* a, int left, int right) {
int mid = GetMidIndex(a, left, right);
Swap(&a[left], &a[mid]);
//…………………………………………………………………………………………
}
4.三路划分
在解决上述问题后,以下情况仍会针对快速排序,使其效率奇低:
重复:
因为这种情况下,快排无法发挥其二叉树处理问题思想的作用 —— 没法把数据分成两份处理
以 hoare 版本为例,right 先走,直至开头才停下,确定位置的是开头的数据,下一次从第二个数据继续重复上述过程,复杂度:n + (n-1) + (n-2) + ……
三路划分
思路:开始时将数据分为三部分
- 在数组 a 中设置 3 个下标 left cur right,选定 key
- a[c] < key,交换 l 和 c 处数据,++ l,++c
- a[c] > key,交换 r 和 c 处数据,- - r
- a[c] == key,++c
c 处的数据小于 key 则将其甩往左侧,分析( l 初始为 key,l 后移定是 a[c] < key,而 c 后移无外乎是因为 a[c] <= key,此时要么 c 还没移动过,要么 l 和 c 之间已全是 key,交换,++l,l 处的值仍是 key)知 l 处一定是 key 故交换回来的值是 key,++c;大于 key 则甩往右侧,因不确定 r 交换回来的值所以 c 不动,–r 继续下一次的判断
//改进 —— 仔细一看这就是又写了一个不需要单趟排序的新的快速排序……
void QuickSort(int* a, int left, int right) {
if (left >= right) return;
int _left = left;
int _right = right;
int _cur = _left + 1;
int mid = GetMidIndex(a,_left,_right);//三数取中
Swap(&a[_left], &a[mid]);
int key = a[_left];
while(_cur <= _right){
if(a[_cur] < key){
Swap(&a[_cur], &a[_left]);
++_cur; ++_left;
}
else if(a[_cur] > key){
Swap(&a[_cur], &a[_right]);
--_right;
}
else ++_cur;
}
//数据分为三部分[left, _left-1] [_left, _right] [_right+1, right]
QuickSort(a, left, _left - 1);
QuickSort(a, _right + 1, right);
}
快速排序(非递归)
到此为止的快速排序使用了递归的方法,但深度太深时会导致栈溢出,所以亦要掌握非递归的方法
——可以通过栈来实现
//非递归快排序,借助 Stack 实现 —— 实现起来后有种前序遍历的逻辑
void QuickSortNonR(int* a, int left, int right) {//non recurse 非递归
ST st;
STInit(&st);
STPush(&st, left); STPush(&st, right);
while (!STEmpty(&st)) {
int right = STTop(&st); STPop(&st);
int left = STTop(&st); STPop(&st);
int key = PartSort1(a, left, right);
if (left < key - 1) {
STPush(&st, left); STPush(&st, key - 1);
}
if (right > key + 1) {
STPush(&st, key + 1); STPush(&st, right);
}
}
STDestroy(&st);
}
//如果用 Queue 实现,则有种层序遍历的逻辑
其优势在于来来回回就是那几个变量和一个函数栈帧,大大减小了空间占用
● 归并排序
将已有序的子序列合并得到完全有序的序列
用到了后序的逻辑
归并排序(递归)
void _MergeSort(int* a, int* tmp, int left, int right) {
if (left >= right) return;//理论上说没有 > 也没有问题
//后序思维
int mid = (left + right) / 2;
//[left, mid] [mid + 1, right]
_MergeSort(a, tmp, left, mid);
_MergeSort(a, tmp, mid + 1, right);
int i = left;
int begin1 = left, end1 = mid;//在到达逻辑上的底部并返回后从这里开始归并
int begin2 = mid + 1, end2 = right;
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) {
int* tmp = (int*)malloc(sizeof(int) * n);//对于数组的归并要借助额外空间
_MergeSort(a, tmp, 0, n - 1);//你也不希望因为不设子函数导致不停地开辟 tmp 吧
free(tmp);
}
因不同于快速排序运用了前序思想,用先前 栈 / 堆 的思想不好实现非递归的归并排序(难以控制哪部分和哪部分归并)
但用循环能解决问题
归并排序(非递归)
void MergeSortNonR(int* a, int n) {
int* tmp = (int*)malloc(sizeof(int) * n);
for (int gap = 1; gap < n; gap *= 2) {
for (int i = 0; i < n; i += 2 * gap) {
int begin1 = i, end1 = i + gap - 1;// -1
int begin2 = i + gap, end2 = i + 2 * gap - 1;
// 好直接的修正 ——不修正全是越界
if (end1 >= n || begin2 >= n)// 没有 begin2 能用归并时
{
break;
}
if (end2 >= n)
{
end2 = n - 1;
}
int j = i;
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) * (end2 - i + 1));//如果这里 *n 直接整体拷贝,会导致……反正就是有问题
}
}
}
关于整体拷贝的边界修正我现在看来意义不大,就跳过了
小区间优化
针对快速排序和归并排序的递归版本
当区间很小时 eg.100000个数据,可以快速分小,但分到10个左右时,若还要继续分到最小,则要再递归多次,代价稍大,此时不妨用简单的排序解决这些问题
//以归并排序为例
void _MergeSort_improve(int* a, int* tmp, int left, int right) {
if (left >= right) return;//理论上说没有 > 也没有问题
//小区间优化
if (right - left + 1 < 10) {// 10 就可以,不需要增大,因为到 10 就已涵盖了大半递归过程
InsertSort(a + left, right - left + 1);
return;
}
//……………………………………………………………………………………………………
经测验,优化后比原来快了约 15% - 25%
内排序与外排序
内排序:在内存中对数据排序
外排序:在文件(硬盘)中对数据排序
● 计数排序
不通过比较大小来排序
- 统计每个数据出现的次数
- 根据统计次数进行排序
改进思路——相对映射:找出待排序数据中的最大最小值,确定所开空间范围,所有数减最小值用于统计次数
void CountSort(int* a, int n)
{
int min = a[0], max = a[0];
for (int i = 0; i < n; i++)
{
if (a[i] < min)
{
min = a[i];
}
if (a[i] > max)
{
max = a[i];
}
}
int range = max - min + 1;
int* countA = (int*)malloc(sizeof(int) * range);
memset(countA, 0, sizeof(int) * range);
// 统计次数
for (int i = 0; i < n; i++)
{
countA[a[i] - min]++;
}
// 排序
int k = 0;
for (int j = 0; j < range; j++)
{
while (countA[j]--)
{
a[k++] = j + min;
}
}
}
时间复杂度:O(N + 范围)
空间复杂度:O(范围)
缺陷:
1.很依赖数据的范围,适用于范围集中的数组
2.只能用于整形
总结
排序的稳定性:
相同的数据相对位置是否变化,若排序能保证以下数据排序后相对位置不变则稳定
使用实例:选排名前三的人送奖品,如果同分数则按交卷时间判断 —— 若排序不稳定则做不到