八大排序的时间复杂度、空间复杂度和稳定性
口诀
插帽龟(直接插入,冒泡排序,归并排序),它很稳(稳定)
插帽龟喜欢选帽插(直接选择,冒泡排序,直接插入),插完它就慌了(“方”——时间复杂 度o(n^2) )
快归堆(快速排序,归并排序,堆排序)->n老(时间复杂度o(n log2 n) )
希尔排序和基数排序单独稍微记忆即可(两个稳定性相反,基你太稳?)
八大排序
测试题目
一组无序数组:49 38 65 97 76 13 27 49
主函数和函数声明及运行结果
头文件、函数声明和主函数
#include<stdlib.h>
void InsertSort(int arr[], int n);
void ShellSort_1(int arr[], int n);
void ShellSort_2(int arr[], int n);
void BubbleSort(int arr[], int n);
void QuickSort(int arr[], int low, int high);
void SelectSort(int arr[], int n);
void HeapSort(int arr[], int n);
void MergeSort(int arr[], int left, int right);
void RadixSort(int arr[], int n);
int main() {
int n, i, arr[255];
for (i = 0; scanf_s("%d", &arr[i]) == 1; i++) {
if ((getchar()) == '\n')break;
//录入不知个数的数组,以回车键结束
}
n = i + 1;//记录数组中的数字个数
InsertSort(arr, n);//直接插入排序
ShellSort_1(arr, n);//希尔排序(交换)
ShellSort_2(arr, n);//希尔排序(插入)
BubbleSort(arr, n);//冒泡排序
QuickSort(arr, 0, n - 1);//快速排序
SelectSort(arr, n);//简单选择排序
HeapSort(arr, n);//堆排序
MergeSort(arr, 0, n - 1);//归并排序
RadixSort(arr, n);//基数排序
for (i = 0; i < n; i++) {
printf("%d ", arr[i]);
//输出排列后数组
}
return 0;
}
运行结果
直接插入排序:和前面的比,找到对应位置插入。
基本原理
指针从第一个开始依次往后移动,指针指向第几个数,就使前几个数成为有序数组。(和前面的数作比较,插入到相应位置)
e.g: 阅到第五个数76时,前面数组已经排序危38 49 65 97,此时76插入到65和97之间。
注意点:阅到最后一个数49时,前面的数组中已经存在49,那么该49插入到前面的 49后面。(原因是进行循环时是从数组最末端往前比较交换,减少循环次数)(稳定)
代码实现
void InsertSort(int arr[], int n) {
//直接插入排序
int t;
for (int i = 1; i < n; i++) {
//从第二个数开始与前面的数作比较
t = arr[i];//将历到的数的值取出
int j;
for (j = i - 1; arr[j] > t && j >= 0; j--) {
//从历到的数的前一个数开始,依次向前查找
//直到该值小于等于历到的数或全部查找一遍(没有小于历到的数)
arr[j + 1] = arr[j];
//大于历到的数全部后移
//最后一轮循环中的arr[j]空出留给历到的数插入
}
arr[j + 1] = t;//将历到的数插入(j+1是由于最后一轮循环中j被--)
}
}
希尔排序(shell排序):对每一个子表进行直接插入排序。
基本原理
概念:
步长(d)、子表
e.g: 步长d=4,相隔4的数字形成一个子表。(步长一般定义为数组长度的一半(取整))
元素进行排序可以使用交换或者插入——在以下代码中均会体现。子表建立之后,子表中的数字是连续存储的。(不再相隔四个数字)(但实际上不是真的连续存储:子表不是物理关系,而只是一个逻辑关系,在逻辑上它们是一个线性表,但在物理上它们还在原来的位置)
进行完第一趟后,再缩小步长进行第二趟 ··· ···
如果使用插入对元素进行排序,最后一次大循环中,d=1,其实最后一次大循环执行了一个完整的插入排序,只不过经过上面几轮排序以后,数组已经处于一个高度有序状态,最后一次“插入排序”仅仅进行了微调,整个数组就整齐有序了。(比直接插入排序效率更高)
代码实现
一、交换
void ShellSort_1(int arr[], int n) {
//希尔排序(交换)
int t;//用于交换的变量定义
for (int d = n / 2; d >= 1; d /= 2) {
//定义初始步长,每轮大循环步长减半
for (int i = d; i < n; i++) {
//遍历每个子表(i与i-d之间的比较,因此初始i为d,在d之前的元素都无法取到i-d)
for (int j = i; j >= d; j -= d) {
//遍历每个子表中的每个元素
if (arr[j] < arr[j - d]) {
t = arr[j]; arr[j] = arr[j - d]; arr[j - d] = t;
//两两之间比较交换(插入排序的取法,冒泡排序的做法)
//其实重复度有点高,前面排好的又重新查一遍
}
}
}
}
}
二、插入
void ShellSort_2(int arr[], int n) {
//希尔排序(插入)
int t, j;
for (int d = n / 2; d >= 1; d /= 2) {
//每轮大循环步长减半
for (int i = d; i < n; i++) {
//从第一个子表的第二个数开始取
t = arr[i];//将历到的数取出
for (j = i; j >= d && arr[j - d] > t; j -= d) {
arr[j] = arr[j - d];
//从历到的数往前比较,大于历到的数全部后移
}
arr[j] = t;//将历到的数插入
}
}
}
冒泡排序:从后两两对比,更小的往前放。
基本原理
每进行一轮循环后,最小的数都会出现在进行循环的片段中的首位。
注意点:同理,为减少循环次数,两个相同的数字相遇,保证前后顺序不发生变化。(稳定)
代码实现
void BubbleSort(int arr[],int n) {
//冒泡排序
//按照上述基本原理,从小到大排序,这里采用从后往前冒泡,把最小数冒到最前面
int t;//用于交换的变量定义
for (int i = 0; i < n - 1; i++) {
//n-1是因为最后一次循环是最后两个数进行排序(不和自己比较)
//因此循环次数比数字个数少一次
for (int j = n - 1; j > 0; j--) {
//从后往前(倒一个数和倒二个进行排序,倒二个和倒三个进行排序......
if (arr[j] < arr[j - 1]) {
//“<”不是“<=”,两个相同数字相遇,前后顺序不变
t = arr[j]; arr[j] = arr[j - 1]; arr[j - 1] = t;
//如果后面比前面小,交换
}
}
}
}
快速排序:小放枢轴左,大放枢细右。
高低所指换,换针向枢轴。
高低所遇处,枢轴所落入。
递归再排至,左右仅一头。
基本原理
枢轴:快速排序中规定枢轴从数组的第一个元素开始选(即pivot=49)
49被单独存储,相当于“出去了”。
高低:指high和low两个指针(其实是下标)
初始状态下,low和high分别指向最左边和最右边的数字。
此时low和high指向的数字(以下简称L和H)进行比较,两个49进行比较,大小相等,不变换。->枢轴仍在low的位置,high--;
此时L和H进行比较有27<49,L和H互换。->枢轴在high的位置,low++;
依次如此 ··· ···( 枢轴所在位置找数填充(挖坑法),枢轴在左,从右端找第一个小于枢轴的数来填坑,枢轴在右,从左端找第一个大于枢轴的数来填坑——代码实现的重要点 )
最终low和high重合,将枢轴49落入重合处。(高低所遇处,枢轴所落入。)
此时比49小的数在枢轴左侧,大于等于49的数在枢轴右侧。(小放枢轴左,大放枢细右。)
(以下76误写成26,就换作26进行讲解)
很巧妙的是:枢轴49此时放在它应该在的顺序位置。->递归:49的左右两侧分别再进行相同操作。
以左边为例,进行一次递归后呈现26 13 27 38的顺序,此时在27的右侧只有38一个数字,则38的位置被确定,27右侧不再进行递归。(跳出)(递归再排至,左右仅一头。)
代码实现
void QuickSort(int arr[], int low, int high) {
//快速排序
if (low >= high)return;//递归结束条件
int pivot = arr[low], count1 = 0, count2 = 0;
//定义枢轴、枢轴左右两边的数组长度。
//下面的这个代码一点也不漂亮
//使用了count去解决左右区间长度的问题
//但实际上low、high不变,直接定义i和j来进行循环的位置变换
//这样不用在每次变换里面再对count进行数值变换,缩短代码也会好看些
//最后递归也只要使用low,i-1/i+1,high即可(下面的这个是初学的时候自己试着敲的)
//大家可以直接用注释里面的想法去代换
while (low != high) {
//以下一整个while和if目的:从右端找到第一个小于枢轴的数
while (low < high && pivot <= arr[high]) {
high--;
count2++;
}
if (low < high) {
arr[low++] = arr[high];
count1++;
}
//以下一整个while和if目的:从左端找到第一个大于枢轴的数
while (low < high && arr[low] <= pivot) {
low++;
count1++;
}
if (low < high) {
arr[high--] = arr[low];
count2++;
}
}
arr[low] = pivot;//low和high交汇处放入枢轴
QuickSort(arr, low - count1, low - 1);
//枢轴左半部分递归
QuickSort(arr, low + 1, low + count2);
//枢轴右半部分递归
}
简单选择排序:先扫,再找,放入最前。
基本原理
数组从头到尾遍历一遍找到最小值,将最小值放入最前面的位置(该数字和第一个数进行交换)。
以此类推。
代码实现
void SelectSort(int arr[], int n) {
//简单选择排序
for (int i = 0; i < n; i++) {
int min = arr[i], minn = i, j;
//把查找数组的首位作为最小值以便和后面作比较
for (j = i; j < n; j++) {
if (arr[j] < min) {
min = arr[j]; minn = j;
}
}
//在循环中不断更新查找数组中的最小值,循环结束找到最小值
int t = arr[i]; arr[i] = arr[minn]; arr[minn] = t;
//最小值和查找数组中的首位交换
}
}
堆排序:先建堆,再找数。
找一次,建一次。
找到数,就输出。
输出完,排序完。
基本原理
度:结点的孩子结点个数即为该结点的度。
结点:二叉树中的每个数都是一个结点。
叶子结点&非叶子结点:度为0的结点叫叶子结点,度为2为非叶子结点。
根节点:处在树的最顶端(没有双亲)的结点叫根结点。
堆:大根堆/小根堆(一般默认建立大根堆)
大根堆:结点>左、右孩子——排列从小到大(升序)
小根堆与大根堆相反
联想:二叉排序树:左<根<右
使用到的逻辑结构:树(实际上物理结构,即存储结构还是一个顺序表)
原来是按顺序存储的结构来存储,在使用堆排序,实现逻辑结构时,使用了“堆”(完全二叉树)——一个非线性的数据结构来完成,但最终的输出结果仍然保留在一个连续的存储空间的线性的顺序表中。(思想——为了实现算法而运用数据结构)
先建堆:将顺序表中的数字建无序堆(从上往下,从左往右),找出非叶子结点(图中画圈的结点),判断每个结点是否满足大于左右孩子——子结点(若不满足,则结点数字与左/右孩子数字进行交换)(从左至右,从下至上一个一个查结点),发生一次交换,则往下检查交换后的左/右孩子作为父亲是否满足条件(循环直到不需要交换),若没有发生交换则按从左至右从下至上的顺序继续查找,直到所有结点都满足条件,则大根堆建立成功。
49 38 65 97 76 13 27 49
再找数:可以观察到:大根堆中最大的数->根结点处
就输出:把根结点和堆底换位置(38所在位置)(最后一行的最右端数字位置)并输出。
找一次,建一次:把剩下的数再建成一个大根堆(直接从交换后的根节点开始向下调整)。
如是循环 ··· ···
对于完全二叉树的数学角度观察:
举个简单的例子
在每个结点都有完整的左右孩子且倒二层全是叶子结点的情况下(上图中的3增有左右孩子时就是该情况),每一层的元素个数是上一层的二倍,根节点数量是1,所以某层的节点数量,一定是之前所有层节点总数+1( 即2^(n-1)=(1+2+4+...+2^n)+1 ),(可以得到:非叶子结点数为正好比叶子结点少一个)所以,我们能找到最后一层的第一个节点的索引(数组下标),即结点总数/2(根节点索引为0),这也就是第一个叶子节点,所以最后一个非叶子节点的索引就是第一个叶子结点的索引-1。
那么对于左右孩子不完整的二叉树呢?这个计算方式仍然适用,第一个叶子节点的索引,一定是序列长度/2(区别于上方完整情况下的节点总数/2,在完整情况下两个值相等),所以第最后一个非叶子节点的索引就是 序列长度 / 2 -1,对于此图数组长度为5,最后一个非叶子节点为5/2-1=1,即为6这个节点。
代码实现
void HeapAdjust(int arr[],int i,int n) {
//堆排序
int key = i;//定义比较时的父亲
for (int j = 2 * i + 1; j < n; j = j * 2 + 1) {
//2*1+1是下标为i的结点的左孩子下标(循环条件为存在左孩子)
//条件3(j=j*2+1)在else处解释
if (j < n - 1 && arr[j] < arr[j + 1])j++;
//如果右孩子存在且右孩子大于左孩子——j改为右孩子下标
//目的:找到左右孩子的最大值以便和父亲作比较
if (arr[key] < arr[j]) {
//父亲小于孩子进行交换
int t = arr[key]; arr[key] = arr[j]; arr[j] = t;
key = j;//若交换,则要查找的父亲编程上一轮交换后的孩子
}
else break;
//如果不发生交换,则不再往下检查(检查交换后的的孩子的左右孩子)
//减少循环次数
}
}
void HeapSort(int arr[], int n) {
for (int i = n / 2 - 1; i >= 0; i--) {
//从最后一个非叶子结点(下标为n/2-1)开始检查并调整
HeapAdjust(arr, i, n);//从查找到的非叶子结点开始向下调整
}
for (int i = 0; i < n - 1; i++) {
//一次输出一个数,直到二叉树中只剩下根节点,故一共要进行n-1循环
int t = arr[0]; arr[0] = arr[n - 1 - i]; arr[n - 1 - i] = t;
//根节点和堆底进行交换
HeapAdjust(arr, 0, n - i - 1);
//直接从被交换的根节点向下调整
//减少循环次数,因为除了根节点,其他的节点都满足条件
}
}
归并排序:两有序并为一有序。
另建表,分别从两序列头依次对比。
基本原理
将整个数列不断折半拆分成只有一个元素的多个有序序列,再进行合并排序。
e.g: 49和38是两个只有一个元素的有序数列,先将两个数列并在一起成为表A:|49|38|
另新建表B:|49|38|(将表A抄下)。
分别从两个有序数列的头取数进行对比,将小的数往表A的首个位置放。
显然有49>38,因而表A:|38|49|。(两个有序数列合并成了一个有序数列)
对所给题目有:
e.g: 以后两个有序数列13 76和27 49为例。
建立表A:|13|76|27|49|
新建表B:|13|76|27|49|(将表A 抄下)
分别从两个序列头取数进行比较,(这里不妨设指向有序数列1的序列头的指针为a,指向2头的 指针为b)此时a指向13,b指向27。
显然有13<27,此时a指向的数字13抄入表A的首个位置,a++;
(A:|13|76|27|49|)
a和b指向的数字再次进行比较,a指向76,b指向27,27更小,排在13后面,b++;(A:|13|27|27|49|)
··· ···
若a和b其中一个指针指向了末位置并且已被超入表A(遍历),那么另外一个序列直接将剩余 抄入表A即可(已经排序好了)
最终:A:|13|27|49|76|
当题目中如上遇到相等的数时,由于归并排序具有稳定性->我们可以设置当元素大小相等时,先将前 半部分的数据放入临时数组,这样就可以保证相等元素在排序后依然保持原来的顺序。
代码实现
void MergeSort(int arr[], int left, int right) {
//归并排序
if (left >= right)return;//拆分到每个序列只有一个元素
int middle = (left + right) / 2;//对半折
MergeSort(arr, left, middle);//左半边继续对半
MergeSort(arr, middle + 1, right);//右半边继续对半
//递归到return以后才运行以下代码,
// 即实现最初的一个元素的有序数列的归并排序,
//再到多个元素的有序序列之间的归并排序。
int* aux = (int*)malloc((right - left + 1) * sizeof(int*));
//为新建表提供充足的存储空间
for (int i = left; i <= right; i++) {
*(aux + i - left) = arr[i];
}//将表A复制到表B(arr复制给aux)
int a1 = left, a2 = middle + 1;
//定义序列1的下标和序列2的下标
for (int i = left; i <= right; i++) {
//将两个有序序列所在的arr的片段替换为总体有序的片段
if (a1 > middle) {
arr[i] = *(aux + a2 - left);
a2++;
}//序列1最后一个数值已经读入arr中,序列2按顺序抄入(通过循环)
else if (a2 > right){
*(arr + i) = *(aux + a1 - left);
a1++;
}//序列2最后一个数值已经读入arr中,序列1按顺序抄入(通过循环)
else if (*(aux + a1 - left) >= *(aux + a2 - left)){
*(arr + i) = *(aux + a2 - left);
a2++;
}//序列1中历到的数小于等于序列2,序列1历到的数抄入
else{
*(arr + i) = *(aux + a1 - left);
a1++;
}//序列2中历到的数小于序列1,序列2历到的数抄入
}
}
/*
* 注意事项:不能合并起来写!且对于a1、a2的大小判断要写在前面!
* cause:不能合并起来写的原因:
如果按照下方这么写,就相当于正确代码中的条件1和条件3(2和4)能同时成立
但实际上这是题设不允许的,这导致对aux的取值会超出left和right的范围并读入arr中。
下标大小判断写在前面的原因:
下标不超出范围的条件优先级要在序列中数值大小判断之前
因为下标不超出范围是前提
否则会出现上面的情况。
if (a2 > right || *(aux + a1 - left) <= *(aux + a2 - left)) {
arr[i] = *(aux + a1 - left);
a1++;
}
else if (a1 > middle || *(aux + a1 - left) > *(aux + a2 - left)) {
arr[i] = *(aux + a2 - left);
a2++;
}
*/
基数排序:看个位,看十位 ··· ···
基本原理
1.求出数组中的最大值,并求其位数(确定大循环次数)。
2.建立一个长度为10的数组(10个桶),统计某个位数上数值为0~9的数字个数到相应的桶中。
3.将每个桶的数值改为该桶内元素个数累加前面所有桶内的元素个数。(为建立数学关系)
4.根据桶(数组下标)和累加后桶内元素个数(数组大小)以及桶内数字(如个位桶8中放入38)的关系确立数学等式,并将桶内数字按顺序放入临时数组中,排好该位数的排序后再复制给原数组,然后再进入下一个位数的大循环 ··· ···
代码实现
void RadixSort(int arr[], int n) {
//基数排序
int max = arr[0];
int radix = 1;
for (int i = 1; i < n; i++) {
if (arr[i] > max)max = arr[i];
}//找出数组中的最大值
int d;
for (d = 1; max >= 10; d++) {
max /= 10;
}//算出最大值的位数
for (int k = 1; k <= d; k++) {
//k=1时桶下标表示个位数字,k=2时桶下标表示十位数字...
int bucket[10];//建立十个桶
for (int i = 0; i < 10; i++) {
bucket[i] = 0;
}//将每个桶的元素个数初始化为0
for (int i = 0; i < n; i++) {
int j = (arr[i] / radix) % 10;
bucket[j]++;
}//统计每个桶中的元素个数
for (int i = 1; i < 10; i++) {
bucket[i] = bucket[i - 1] + bucket[i];
}//每个桶内元素个数累加上前面所有桶内的元素个数作为新的桶数值
int temp[255];//建立临时储存数据的临时数组
for (int i = 0; i < n; i++) {
int j = (arr[i] / radix) % 10;//找到数字对应的桶的下标
temp[bucket[j] - 1] = arr[i];
//-1的原因是数组下标从0开始
//而对应桶数值代表该数值排在第几位(从1开始计数)
bucket[j]--;//很重要的一步!更新bucket的值,防止临时数组下标重复被覆盖。
}
for (int i = 0; i < n; i++) {
arr[i] = temp[i];
}//将临时数组中的数字复制到原数组中
radix *= 10;//用pow效率低
}
}
终于告一段落了,看到这里都辛苦了~
有错误欢迎指出~