以下是我对排序算法的理解和认识!!!
目录
前言
排序在我们日常生活中随处可见,就比如我们在网上进行网购,搜索我们想要买的东西,它就会按照一定的规则进行排序。实际上,在这些展现在我们眼前的现象,它的底层是一些排序算法。作为一名程序员,我们更要对这些底层算法有更深入的理解。
一 、排序概念及应用
1. 1 概念
排序:所谓排序,就是使⼀串记录,按照其中的某个或某些关键字的⼤⼩,递增或递减的排列起来的操作
1.2 运用
排序在我们日常生活中随处可见。
购物筛选排序:
院校排名:
1.3 常见的排序算法
接下来我会针对下面比较重要的排序算法进行讲述
有图上可知,我们常见的排序算法分成四类。插入排序,选择排序,交换排序,归并排序。
接下来我会按照顺序依次讲述。
二 、实现常见排序算法
在开始讲解之前,我们先定义一个整型数组
void test01()
{
int a[] = { 5, 3, 9, 6, 2, 4, 7, 1, 8 };
int n = sizeof(a) / sizeof(int);
}
2.1 插入排序
基本思想:直接插入排序是一种简单的插入排序法。其基本思想是把待排序的记录按其关键码值的⼤⼩逐个插⼊到⼀个已经排好序的有序序列中,直到所有的记录插⼊完为⽌,得到⼀个新的有序序列。我们可以类比为玩扑克牌。
2.1.1 直接插入排序
当插入第i(i>=1)个元素时,前面的元素已经排好序,此时用取到的第i个元素与排好序的的元素逐个进行比较,找到插入的位置,原来位置上的元素顺序后移
直接插入排序代码实现:
//直接插入排序--n ^ 2
void InsertSort(int* arr, int n)
{
for (int i = 0; i < n - 1; i++)
{
int end = i;
int tmp = arr[end + 1];
while (end >= 0)
{
if (arr[end] > tmp)
{
arr[end + 1] = arr[end];
end--;
}
else
{
break;
}
}
// 注意跳出这个循环有两种可能: 遇到break 或者 end < 0
arr[end + 1] = tmp;
}
}
直接插入排序的特性总结
1 . 元素集合越接近有序,直接插入排序的时间复杂度越接近O(n)
2 . 时间复杂度:O(n^2)
3 . 空间复杂度:O(1)
2.1.2 希尔排序
针对上面的直接插入排序,希尔排序其实是直接插入排序的优化版。
基本思想:希尔排序法又称缩小增量法。其基本思想就是先选定一个整数(一般都是gap = n / 3 + 1),把待排序文件所有记录分成gap组,所有的距离相等的记录分在同一组内,并且每组都有gap个元素,然后再对每一组分别进行排序,然后再gap = gap / 3 + 1得到下一个gap,再将数据分成各组,进行插入排序,知道gap = 1的时候,这时候就相当于直接插入排序。
为了方便理解,我们可以参考下图:
希尔排序本质上就是直接插入排序的基础上改进来的,综合来说它的效率肯定比直接插入排序算法好!!!
希尔排序代码实现:
//希尔排序--n ^ 1.3
void ShellSort(int* arr, int n)
{
int gap = n / 3 + 1;
while (gap > 1)
{
gap = gap / 3 + 1;
//下面的逻辑和直接插入排序的逻辑是一样的
for (int i = 0; i < n - gap; i++)
{
int end = i;
int tmp = arr[end + gap];
while (end >= 0)
{
if (arr[end] > tmp)
{
arr[end + gap] = arr[end];
end -= gap;
}
else
{
break;
}
}
arr[end + gap] = tmp;
}
}
}
希尔排序特性总结
1 . 希尔排序是对直接插⼊排序的优化
2 . 当gap > 1时都是预排序,目的是让数组更接近于有序,当gap = 1时,数组已经是接近于有序的了,这样就会能很快排好序,可以达到优化的效果。
3 . 希尔排序的时间复杂度为O(n^1.3)
2.2 选择排序
基本思想:选择排序的基本思想就是每一次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始位置,直到全部待排序的数据元素排完
2.2.1 直接选择排序
1 . 在元素合集中找到最大或者最小的数据元素
2 . 如果它不是在这组元素中的最后一个或者是第一个元素,则它与这组元素中的最后一个或者第一个元素进行交换
3 . 在剩余的元素集合中,重复上面的步骤,直到只剩下一个元素
直接选择排序代码实现:
//交换函数
void Swap(int* x, int* y)
{
int tmp = *x;
*x = *y;
*y = tmp;
}
//直接选择排序--n ^ 2
void SelectSort(int* arr, int n)
{
int begin = 0;
int end = n - 1;
//当begin == end时跳出循环
while (begin < end)
{
// 定义变量小标 找元素集合里的最大值和最小值
int mini = begin;
int maxi = begin;
// 循环元素集合找最大最小
for (int i = begin; i <= end; i++)
{
while (arr[i] < arr[mini])
mini = i;
while (arr[i] > arr[maxi])
maxi = i;
}
if (maxi == begin)
maxi = mini;
Swap(&arr[begin], &arr[mini]);
Swap(&arr[end], &arr[maxi]);
begin++;
end--;
}
}
对于上面的代码,我们要注意到一个点,就是要先判断maxi == begin,然后再进行交换。
直接选择排序特性总结:
1 . 直接选择排序思考⾮常好理解,但是效率不高,实际中很少使用
2 . 时间复杂度:O(n^2)
3 . 空间复杂度:O(1)
2.2.2 堆排序
基本思路:堆排序是利用堆实现排序,其基本思想是将元素集合建成大堆或者小堆,再循环取堆顶放入元素集合中。
堆排序代码实现:
//交换函数
void Swap(int* x, int* y)
{
int tmp = *x;
*x = *y;
*y = tmp;
}
/向上调整函数
void AdjustUp(int* arr, int child)
{
int parent = (child - 1) / 2;
while (parent >= 0)
{
//建大堆:<
//建小堆:>
if (arr[parent] < arr[child])
{
Swap(&arr[parent], &arr[child]);
child = parent;
parent = (child - 1) / 2;
}
else
{
break;
}
}
}
//向下调整函数
void AdjustDown(int* arr, int parent, int n)
{
int child = 2 * parent + 1;
while (child < n)
{
//建大堆:<
//建小堆:>
if (child + 1 < n && arr[child] < arr[child + 1])
child += 1;
//建大堆:<
//建小堆:>
if (arr[parent] < arr[child])
{
Swap(&arr[child], &arr[parent]);
parent = child;
child = 2 * parent + 1;
}
else
{
break;
}
}
}
//堆排序--nlogn
void HeapSort(int* arr, int n)
{
//向上调整建堆--时间复杂度为nlogn
/*for (int i = 0; i < n; i++)
{
AdjustUp(arr, i);
}*/
//向下调整建堆--时间复杂度为n
for (int i = (n - 1 - 1) / 2; i >= 0; i--)
{
AdjustDown(arr, i, n);
}
// 这里是排升序 故建大堆 到这里大堆已经建好了
// 这下面的代码相当于二叉树删除 我们每次让堆顶和堆尾进行交换 然后再忽略堆尾 以此类推
int end = n - 1;
while (end > 0)
{
Swap(&arr[end], &arr[0]);
AdjustDown(arr, 0, end);
end--;
}
}
堆排序特性总结:
1 . 堆排序的时间复杂度为O(nlogn)
2 . 堆排序的空间复杂度为O(1)
2 . 堆排序可以是向上调整建堆,也可以是向下调整建堆,但是推荐用向下调整建堆,因为向下调整建堆的时间复杂度为O(n)
3 . 我们要排升序,就要建大堆;排降序,就要建小堆
2.3 交换排序
基本思想:所谓交换,就是根据序列中两个记录键值的⽐较结果来对换这两个记录在序列中的位置 交换排序的特点是将键值较⼤的记录向序列的尾部移动,键值较⼩的记录向序列的前部移动
2.3.1 冒泡排序
基本思想:重复遍历待排序的数组,每次比较相邻两个元素,如果顺序错误则交换位置,直到没有元素需要交换为止
冒泡排序代码实现:
//冒泡排序
void BubbleSort(int* arr, int n)
{
// 注意这里是表示要比较几轮
for (int i = 0; i < n - 1; i++)
{
// 注意这里每完成一轮 待排序的元素就会减少一个
for (int j = 0; j < n - 1 - i; j++)
{
if (arr[j + 1] < arr[j])
{
Swap(&arr[j + 1], &arr[j]);
}
}
}
}
冒泡排序特性总结:
1 . 时间复杂度:O(n^2)
2 . 空间复杂度:O(1)
2.3.2 快速排序
快速排序是Hoare于1962年提出的⼀种⼆叉树结构的交换排序⽅法。
基本思路:任意取待排序元素序列中某个元素为基准值,按照这个基准值,把待排序序列分割成左右子序列,左子序列中的所有元素均小于基准值,右子序列中所有元素均大于基准值,然后左右子序列再重复上述操作,直到所有元素都排列在相应的位置为止
快速排序代码实现--主框架
//快速排序(递归版)--nlogn
void QuickSort(int* arr, int left, int right)
{
if (left >= right)
{
return;
}
// 这里是通过 _QuickSort函数找基准值的下标
int keyi = _QuickSort(arr, left, right);
// 找到基准值的位置 再根据基准值一分为二
QuickSort(arr, left, keyi - 1);
QuickSort(arr, keyi+1 , right);
}
上面的代码是快速排序的主框架,现在我们就还欠一个查找基准值下标函数_QuickSort 。
对与_QuickSort函数,我们有三种写法:
2.3.2.1 hoare版本
算法思路
1 . 创建左右指针,确定基准值(一般取最左边为基准值)
2 . 从右向左找比基准值小的数据,从左向右找比基准值大的数据,当左指针始终比右指针小时,左右指针的数据进行交换,进入下一次循环
3 . 当left > right 时,即右指针跑到了左指针的左侧时,因为left指针扫过的数据都是不大于基准值的,因此right指针此时指向的数据一定不大于基准值
hoare版本快速排序代码实现:
//hoare版本找基准值下标
int _QuickSort1(int* arr, int left, int right)
{
int keyi = left;
left++;
while (left <= right)
{
// 这里left找比基准值大的 right找比基准值小的
while (left <= right && arr[right] > arr[keyi])
{
right--;
}
while (left <= right && arr[left] < arr[keyi])
{
left++;
}
if (left <= right)
{
Swap(&arr[left++], &arr[right--]);
}
}
Swap(&arr[right], &arr[keyi]);
return right;
}
注意点:
1 . 当左右指针left和right相遇时,即left = right时我们不应该让它跳出外层while循环。以下图为例:
当left指针与right指针相等时,如果我们跳出while循环让基准值和right指针的数据交换,那么此时8就去到了key的位置,而6来到了right的位置,此时的基准值为6并且位置在right指针位置,但是基准值的左边不满足都小于或等于基准值。
故当left == right时,我们继续循环,直到right走到left的左侧,再跳出循环进行交换。
2 . 当right指针或者left指针指向的数据等于基准值的话,我们直接让left指针的数据和right指针的数据进行交换,以下图为例:
当right位置的数据和基准值相等时,我们直接跳出right的循环;当left位置的数据和基准值也相等时,直接跳出left的循环;当left<=right时,我们让它们两个交换,交换完之后再left++,right--。如果right位置的值和基准值相同而继续它的循环,那就会导致right的位置走到了left的左侧,即right<left,此时跳出循环,交换right和基准值,此时会发现我们相当于遍历了一遍数组,此时会使得快速排序的时间复杂度变为O(n^2)。
2.3.2.2 挖坑法
算法思路:创建左右指针,首先从右向左找出比基准值小的数据,找到后放入左边坑中,此时当前的位置变为新的坑,然后再从左向右找比基准值大的数据,找到后立即放入右边坑中,当前位置变为新的坑,结束循环后将最开始储存的分界值放入当前的坑中,返回当前坑的下标
挖坑法快速排序代码实现:
//挖坑法
int _QuickSort2(int* arr, int left, int right)
{
int hole = left;
int key = arr[hole];
while (left < right)
{
// 注意这里是 >=
while (left < right && arr[right] >= key)
{
right--;
}
arr[hole] = arr[right];
hole = right;
// 注意这里是 <=
while (left < right && arr[left] <= key)
{
left++;
}
arr[hole] = arr[left];
hole = left;
}
arr[hole] = key;
return hole;
}
2.3.2.3 lomuto前后指针法
算法思路:创建前后指针,从左往右找比基准值小的,然后进行交换,使得小的都排在基准值的最左边
lomuto前后指针法快速排序代码实现:
//lomuto前后指针法
int _QuickSort3(int* arr, int left, int right)
{
int prev = left;
int cur = prev+1;
int keyi = left;
while (cur <= right)
{
if (arr[cur] < arr[keyi] && ++prev != cur)
{
Swap(&arr[cur], &arr[prev]);
}
cur++;
}
Swap(&arr[prev], &arr[keyi]);
return prev;
}
快速排序特性总结:
1 . 时间复杂度:O(nlogn)
2 . 空间复杂度:O(logn)
2.3.3 非递归版本快速排序
上面讲的快速排序是递归版本的,接下来是非递归版本的快速排序,在实现非递归快速排序前,它要借助数据结构栈实现!
数据结构栈代码实现:
#define _CRT_SECURE_NO_WARNINGS
#include "Stack.h"
// 栈的初始化
void STInit(ST* st)
{
assert(st);
st->arr = NULL;
st->top = st->capacity = 0;
}
//栈的销毁
void STDestroy(ST* st)
{
assert(st);
if (st->arr)
free(st->arr);
st->arr = NULL;
st->top = st->capacity = 0;
st = NULL;
}
//栈的判空
bool STEmpty(ST* st)
{
assert(st);
return st->top == 0;
}
//入栈--栈顶
void STPush(ST* st, STDataType x)
{
assert(st);
if (st->capacity == st->top)
{
int newcapacity = st->capacity == 0 ? 4 : 2 * st->capacity;
STDataType* tmp = (STDataType*)malloc(sizeof(STDataType) * newcapacity);
if (tmp == NULL)
{
perror("malloc");
exit(1);
}
st->arr = tmp;
st->capacity = newcapacity;
}
st->arr[st->top++] = x;
}
//出栈--栈顶
void STPop(ST* st)
{
assert(!STEmpty(st));
st->top--;
}
//取栈顶
STDataType STTop(ST* st)
{
assert(!STEmpty(st));
return st->arr[st->top - 1];
}
// 栈的有效元素个数
int STSize(ST* st)
{
assert(st);
return st->top;
}
非递归版本快速排序代码实现:
//非递归快速排序--nlogn
void QuickSortNonR(int* arr, int left, int right)
{
ST st;
STInit(&st);
STPush(&st,right);
STPush(&st, left);
while (!STEmpty(&st))
{
int begin = STTop(&st);
STPop(&st);
int end = STTop(&st);
STPop(&st);
// 这里得到排序的范围后 接下来就是找基准值 这里使用lomuto前后指针法
int keyi = begin;
int prev = begin;
int cur = prev + 1;
while (cur <= end)
{
if (arr[cur] < arr[keyi] && ++prev != cur)
{
Swap(&arr[cur], &arr[prev]);
}
cur++;
}
Swap(&arr[prev], &arr[keyi]);
keyi = prev;
// 入栈时都是从右向左入 取出来的时候才能先取到左
if (keyi + 1 < end)
{
STPush(&st, end);
STPush(&st, keyi+1);
}
if (begin < keyi - 1)
{
STPush(&st, keyi - 1);
STPush(&st, left);
}
}
// 注意这里使用完栈要记得销毁
STDestroy(&st);
}
2.4 归并排序
算法思想:归并排序是采用分治法的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列。即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为二路归并。
归并排序核心步骤:
归并排序代码实现:
// 归并排序实现代码
void _MergeSort(int* arr, int left, int right, int* tmp)
{
if (left >= right)
{
return;
}
int mid = (left + right) / 2;
// 先分组 直到只有一个元素为止
_MergeSort(arr, left, mid, tmp);
_MergeSort(arr, mid+1, right, tmp);
// 两组有序的数组合并
int begin1 = left, end1 = mid;
int begin2 = mid + 1, end2 = right;
int index = begin1;
while (begin1 <= end1 && begin2 <= end2)
{
if (arr[begin1] < arr[begin2])
{
tmp[index++] = arr[begin1++];
}
else
{
tmp[index++] = arr[begin2++];
}
}
while (begin1 <= end1)
{
tmp[index++] = arr[begin1++];
}
while (begin2 <= end2)
{
tmp[index++] = arr[begin2++];
}
// tmp里的数据拷贝给arr
for (int i = left; i <= right; i++)
{
arr[i] = tmp[i];
}
}
//归并排序--nlogn
void MergeSort(int* arr, int n)
{
int* tmp = (int*)malloc(sizeof(int) * n);
if (tmp == NULL)
{
perror("malloc");
exit(1);
}
_MergeSort(arr, 0, n - 1, tmp);
free(tmp);
// 使用完tmp要记得销毁
tmp = NULL;
}
归并排序特性总结:
1 . 时间复杂度:O(nlogn)
2 . 空间复杂度:O(n)
2.5 非递归版本归并排序
基本思路:参照递归版归并排序,非递归版本的归并排序是自底向上拆分与合并,无需创建函数栈帧。
非递归版本归并排序代码实现:
//非递归版本归并排序--nlogn
void MergeSortNonR(int* arr, int n )
{
int* tmp = (int*)malloc(sizeof(int) * n);
if (tmp == NULL)
{
perror("malloc");
exit(1);
}
memset(tmp, 0, sizeof(int) * n);
int gap = 1;
while (gap < n)
{
// 从底开始 找数组下标 分组 每组含gap个元素
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 = begin1; // 注意点 index要从每组的begin1开始
if (begin2 >= n) // 这里说明没有右序列
{
break;
}
if (end2 >= n) // 这里说明右序列不满足gap个
{
end2 = n - 1;
}
while (begin1 <= end1 && begin2 <= end2)
{
if (arr[begin1] < arr[begin2])
{
tmp[index++] = arr[begin1++];
}
else
{
tmp[index++] = arr[begin2++];
}
}
while (begin1 <= end1)
{
tmp[index++] = arr[begin1++];
}
while (begin2 <= end2)
{
tmp[index++] = arr[begin2++];
}
// 每排序完两组 要对arr进行拷贝
memcpy(arr + i, tmp + i, sizeof(int) * (end2 - i + 1));
}
gap *= 2;
}
// 跳出循环 记得销毁tmp
free(tmp);
tmp = NULL;
}
非递归版本归并排序特性总结:
1 . 时间复杂度:O(nlogn)
2 . 空间复杂度:O(n)
2.6 计数排序
算法思路:计数排序⼜称为鸽巢原理,是对哈希直接定址法的变形应⽤。其算法思想是先统计相同元素出现的次数,根据统计的结果将序列回收到原来的序列中
这里的数据的范围是1-9,故我们数组开的空间为10个整形,但是对于{100,101,109,105,101,105}这种就要采取另一种方式!
计数排序代码实现:
//计数排序--非比较排序2--n + range(range为计数数组的大小)
void CountSort(int* arr, int n)
{
int min = arr[0], max = arr[0];
// 找这组数中最大和最小值
for (int i = 0; i < n; i++)
{
if (arr[i] < min)
min = arr[i];
if (arr[i] > max)
max = arr[i];
}
// 确定范围range
int range = max - min + 1;
// 创建计数数组
int* count = (int*)malloc(sizeof(int) * range);
if (count == NULL)
{
perror("malloc");
exit(1);
}
// 注意这里要对count函数初始化--很重要 不然后面遍历到while(count[i])可能会死循环
memset(count, 0, sizeof(int) * range);
// 这里遍历原数组进行计数
for (int i = 0; i < n; i++)
{
// 注意这里要减去最小值min
count[arr[i] - min]++;
}
// 这里遍历计数数组
int index = 0;
for (int i = 0; i < range; i++)
{
while (count[i]--)
{
// 注意这里是i+min才能得到原来的值
arr[index++] = i + min;
}
}
// 注意这里要对count进行销毁
free(count);
count = NULL;
}
计数排序特性总结:
1 . 计数排序在数据范围集中时,效率很⾼,但是适⽤范围及场景有限
2 . 时间复杂度:O(n+range)
3 . 空间复杂度:O(range)
4 . 稳定性:稳定(相对位置不发生改变)
三 、排序算法复杂度及稳定性分析
稳定性概念:假如在待排序的序列中,存在多个相同的元素,经过排序之后,这些相同元素的相对位置保持不变,则称这种排序算法是稳定的,否则称为不稳定
对于上面的稳定性验证案例,我们可以按照上面的代码进行排序,得出结论!