排序的意义:
排序是计算机科学中最基本的问题之一,它的目的是将无序的数据集合按照一定的规则进行排序,以便于查找、更新、删除等操作。排序通常被认为是将数据集合中的元素按照升序或降序排列。常见的排序规则包括字典序、自然排序和自定义排序等。排序具有以下几个方面的意义:
提高数据的查找效率。排序可以将无序的数据集合按照一定的规则进行排序,从而使得查找某个元素时可以使用二分查找等高效的算法。
提高数据的操作效率。排序可以使得数据集合按照一定的规则排列,从而使得对数据的插入、删除、更新等操作更加高效。
优化算法效率。排序算法的效率直接影响到算法的执行效率和时间复杂度。通过选择适当的排序算法,可以提高算法的执行效率和时间复杂度。
改善程序的用户体验。对于需要处理大量数据的应用场景,如果数据集合是无序的,那么可能会导致程序响应缓慢或者崩溃等问题。通过对数据进行排序,可以改善程序的用户体验。
综上所述,对数据进行排序是提高数据操作效率、优化算法效率和改善程序用户体验的有效手段。
我们再日常生活中也会遇到很多关于排序的情况如:
成绩的高低、价格的高低……
排序的特性:
1、内排序:在内存里面进行排序。
2、外排序:当数据量比较大的时候,需要借助外层来进行排序,一般指占用空间比较大的排序,也就是空间复杂度比较大的排序方法。
3、稳定排序:排序前后两个相等的数相对位置不变,则算法稳定。
4、非稳定排序:排序前后两个相等的数相对位置发生了变化,则算法不稳定。
5、时间复杂度和空间复杂度:一个排序算法基本操作的执行次数和是否使用额外的空间。
比较排序算法的区别,我们一般比较的都是时间复杂度和空间复杂度,但有的场景会需要看排序的算法是否稳定。
一、冒泡排序
动画图解:
算法思想:
它重复地走访过要排序的数列,一次比较两个元素,如果它们的顺序错误就交换它们的位置,直到没有元素再需要交换,排序完成。前一个与后一个比较,将大的放到后面(交换)
升序:每一轮都会将最大值放到最后,那么10个数需要几轮?
答案是9轮。
当9个数都已经排到的他对应次序的位置,那么剩最后一个数也自然的在他对应的位置。
那么每次找较大值放到后面排几次序?
当已经排好一次序了,已经将最大值放到了最后,那么下一次只要对最大值前面进行排序就好了,所以就是 n - (i-1) ,(i-1)为已经排好序的次数。
程序代码:
void BubberSort(int *a, int n) { for (int i = 0; i < n - 1; i++) { int flag = 0; //标志 for (int j = 0; j < n - i - 1; j++) { if (a[j] > a[j + 1]) { Swap(&a[j], &a[j + 1]); flag = 1; } } if (flag == 0)//已经有序就跳出 break; } }
可用一个flag作为一个标记进行优化,
如果一个序列已经有序了(或序列中某一片段已经是有序了)但是冒泡排序还是会执行,所以我们要进行优化
使用flag标记
在每次准备排(主排序)的时候flag = 0;赋值为0
因为只有无序的时候才需要排序,所以在他进行排序时(进行大小的位置交换时)
将flag = 1;
若是有序的则flag没有变化还是0,并设置跳出已经有序的序列跳出的操作,用一个if语句判断即可。
特性:
时间复杂度为O(n^2)
空间复杂度为O(1)
稳定排序——进行交换时相等是不进行交换。
二、选择排序
动画图解:
算法思想:
升序:每一次找较小值放在前面或找最大值放在后面。
每次从待排序的数据元素中选出最小(或最大)的一个元素,存放到序列的起始位置,直到全部待排序的数据元素排完。选择排序的具体步骤如下:
在未排序的数列中找到最小(或最大)的元素,将其存放到数列的起始位置。
从剩下未排序的元素中继续寻找最小(或最大)的元素,然后放到已排序序列的末尾。
重复以上步骤,直到所有元素均排序完毕。
程序代码:
//选择排序 void SelectSort(int* a, int n) { int start = 0; int end = n - 1; while (start < end) { int max = start,min = start; for (int i = start+1; i <= end; i++) { if (a[min] > a[i]) { min = i; } if (a[max] < a[i]) { max = i; } } Swap(&a[start], &a[min]); if (max == start)//防止最小值交换后,值的更换 max = min; Swap(&a[end], &a[max]); start++; end--; } }
算法优化:就是同时找最大值和最小值。
但会遇到一个特殊情况,我的最大值刚好到最小值要存放的下标那,当最小值交换后,我的最大值的的下标就成为了最小值而不是刚才的最大值,所以我们要在换回来。
例如:
已经排过一次选择排序 -> 2 7 5 6 4 3 8 min =5 max = 1 (这里是下标)
进行最小值的交换时 2 3 5 6 4 7 8 此时下标为min的值为7,max下标值为3
但最大值的下标是7的那个位置 即max = 1的下标位置 但其值为3 不是所需的最大值
所以进行最大值的交换时 2 7 5 6 4 3 8 错误了。
特性:
时间复杂度为O(n^2)
空间复杂度为O(1)
不稳定排序:当一个序列为2 3 2 1,找最小交换位置1 3 2 2,这里这个第一个2与1交换后,俩个2的前后关系发生改变了。不稳定。
三、直接插入排序
动画图解:
算法思想:
通过构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入。
以第一个元素为基准第二个元素开始与第一个比较,小的话放到其前面。第一、二个元素就构成了有序序列。依次到第三……四...n个元素与一、二...三..n-1元素组成的序列比较,将其插入到合适的位置。
程序代码:
void InsertSort(int *a, int n) { for (int i = 0; i < n - 1; i++) { int start = i; for (int j = i + 1; j > 0; j--)//231 { if (a[j] < a[start]) { Swap(&a[j], &a[start]); if (start > 0) start--; } else break; } } }
该代码也有许多可以改进的地方,但对我来讲这个更适合我理解插入排序。
start 为 前面已经排序的序列的最后一个元素的下标,j = i+1,开始排的元素的下标。
j不会 = 0,因为我这个要排的元素最坏情况要比的话就是与首元素相比。
防止与前面的序列比较的下标 start 越界
例子(结合上述代码)
2 3 1
这时start = i =1。j = 2.
a[j] = a[2] ==1 与 a[start] = a[1] == 3。比较a[ j ] < a [ start ] 就更换。
更换后 序列 2 1 3 ,j-- , start -- ,j=1, start = 0
a[ j ] = 1,a[start] = 2 比较 -> 交换
1 2 3 , j -- ,j = 0 不满足条件 结束循环。
如果待排元素比前面排好的序列最后元素都大的话,就不需要与前面的进行比较了。直接跳出该趟循环。
特性:
时间复杂度为O(n^2)
空间复杂度为O(1)
稳定排序。直接插入排序是依次从左到右进行排序,相同的话不进行交换,不会改变相同两个值的前后关系。
四、希尔排序
希尔排序(Shell Sort)是插入排序的一种高效改进版本,也称缩小增量排序(Diminishing Increment Sort)。
它基于一个简单的思想,如果序列局部有序,则对于插入排序来说,移动元素的次数和比较的次数都会减少,从而提高排序的速度。
它的具体实现是先将待排序序列分成若干个子序列,分别进行直接插入排序,待整个序列中的元素基本有序时,再对全体元素进行一次直接插入排序。
希尔排序的核心是增量序列,即如何划分子序列。常见的增量序列有希尔增量
图解:
增加一个希尔增量gap,gap步里面的数进行排序不影响其他。
如下述gap = 5 时,第一个元素 gap步对应的是第六个元素,比较进行排序。
gap会逐渐减小,最后会到1,当gap=1时就相当于时直接插入排序
程序代码:
void ShellSort1(int* a, int n) { int gap = n; while (gap > 1) { gap = gap / 3 + 1; //gap = gap / 2; for (int i = 0; i < n - gap; i++) { int start = i; for (int j = gap + i; j >= 0; j -= gap) { if (a[j] < a[start]) { Swap(&a[j], &a[start]); start -= gap; if (start < 0)//防止j = 0 时 start = -2 使数组访问越界 { break; } } else break; } } } }
关于希尔增量是选择 gap = gap/2,还是gap = gap/3 + 1。那个好其实没有说法。都大差不差,只要最后是 gap =1就行。
依据直接插入排序,增加了希尔增量,每次比的就是希尔增量步的数。
(增量步进行比较)。
特性:
时间复杂度为O(n*logn)。
空间复杂度为O(1)。
不稳定排序。因为相同的值不能在同一增量序列里进行排序的话就不能保证相等元素的前后关系。
五、堆排序
堆排序(Heap Sort):将待排序数组构造成一个堆(大根堆或小根堆),然后依次将每个堆顶元素与堆末尾元素交换位置,调整堆的结构,重复执行交换和调整的操作,直到整个序列有序。
堆排序是利用堆的特性,堆则是利用完全二叉树的特性。
关于堆可查看博主的一篇博客堆
堆是一种特殊的完全二叉树,通常用数组来存储。在堆中,每个节点都大于等于(或小于等于)它的两个子节点,这种性质称为堆的性质。
堆排序的基本思想是将待排序序列构建成一个大根堆(或小根堆),然后依次将堆顶元素和最后一个元素交换,再重新调整堆,直到所有元素有序。
算法思想:
堆排序即利用堆的思想来进行排序,总共分为两个步骤:
1、建堆
升序:建大堆
降序:建小堆
2、排序
利用堆删除思想来进行排序
什么是堆删除的思想:如大堆,先堆顶与最后一个元素交换,进行向下调整将次大的放到堆顶,然后将数组--(这样堆顶放到这里就不会在进行其他的动作),
这里要是先进行数组个数--,再进行向下调整的话就会少调整了一次,再最后end = 3后就会发生错误。
关于建堆:
向上调整算法建堆
向下调整算法建堆 两种方式都可以建堆,但是我们进行排序是要用到向下调整算法。
所以我们选择向下调整建堆便于复用。
向下调整建堆举例:
程序代码:
void Adjustdowm(int*a, int n, int parent) { int child = parent * 2 + 1;//假设最大的是左孩子 while (child < n) { if (child + 1 < n && a[child] < a[child + 1])//防止右孩子空数组越界 { ++child; } if (a[child] > a[parent]) { Swap(&a[child], &a[parent]); parent = child; child = parent * 2 + 1; //交换调整 } else break; } } void HeapSort(int* a, int n)//升序:建大堆,降序:建小堆 { for (int i = (n - 1 - 1) / 2; i >= 0; i--)//从最后一个节点的父亲节点开始建大堆 { Adjustdowm(a, n, i); } int end = n - 1; while (end > 0) { Swap(&a[0], &a[end]);//将最大值放到最后,并对前面的进行向下调整将大的往上放到顶, Adjustdowm(a, end, 0); end--; } }
进行向下调整建堆是找最大/最小的孩子是要注意数组越界。
关于排序:将最大值放到最后,并对前面的进行向下调整将大的往上放到顶,循环此操作。
特性:
时间复杂度为O(nlogn),空间复杂度为O(1)
不稳定排序,当堆的右子树与堆顶相等然后进行排序,两个相等的元素前后位置关系发生了改变。不稳定。
六、快速排序
快速排序(Quick Sort)是一种基于分治思想的排序算法,通过将待排序序列分成两个子序列,使得左边子序列的所有元素小于等于右边子序列的所有元素,然后再递归地对两个子序列进行排序,直到整个序列有序。
快速排序的基本思想是选择一个基准元素,然后将序列中所有元素分成两个部分,使得左边的元素都小于基准元素,右边的元素都大于等于基准元素。然后对左右子序列进行递归排序,直到所有的子序列只有一个元素为止。
1、递归实现
hoare版本
算法思想:
直接让第一个元素作为基准,右边先动,右边找比基准值小的数,找到就不动。然后左边找比基准值大的值,找到。右边找的与左边找的进行交换……,L和R相遇则将基准值与停的位置的值进行交换,这样基准就放到了他该放他的位置。
void QuickSort(int *a, int begin, int end) { //1.hoare 法 if (begin >= end) return; /*int midi = GetMidi(a, begin, end);三数取中优化 Swap(&a[begin], &a[midi]);*/ int keyi = begin; int left = begin; int right = end; while (left < right) { while (left < right && a[right] >= a[keyi]) { right--; } while (left < right && a[left] <= a[keyi]) { left++; } Swap(&a[left], &a[right]); } Swap(&a[keyi], &a[left]); keyi = left; QuickSort(a, begin, keyi-1); QuickSort(a, keyi+1, end); }
右边和左边在找时要注意防止数组越界。
从右边找,整个序列都是比基准值大的值,一直找越界了就。从左边也类似
所以我们要添加个条件 left < right。如果right一直找,然后找到了left前面了都没有比基准值小的值那就不找了。
因为快排是一个分治思想,一分为二后,又二分为多……。所以我们采用一个递归来分治子问题。
基准值到指定位置后,我们再以他的左边、右边分别递归展开,继续分治。
左边: 开始位置到keyi - 1 的位置。
右边: keyi + 1到end 的位置。
一直分治到单个。就返回上一层
挖坑法
算法思想:
先让首元素作为基准并为坑位,保存该基准的值。
先从右边找比该基准值小的填坑,然后自身位置作为坑位,
从左边找比基准值大的值放到该坑位……
依次类推,两个指针相遇后将基准值填入最后一个坑位。
void QuickSort(int*a int begin,int end) { //2.挖坑法 if (begin >= end) //递归跳出的条件 return; int left = begin; int right = end; int key = a[left];// int hole = left; // 先让left 做坑 while (left < right) { while (left < right && a[right] >= key)//先从右边找比key小的值填左边的坑 { --right; } a[hole] = a[right]; hole = right; while (left < right && a[left] <= key) { ++left; } a[hole] = a[left]; hole = left; } a[hole] = key; QuickSort(a, begin, hole - 1); QuickSort(a, hole + 1, end); }
然后又开始递归分治,坑的左边,坑的右边。
形似与hoare。
前后指针
算法思想:
开始首元素作为基准,prev 指向首元素,cur指向prev的下一个。
cur不管是啥情况都会一直走到序列尾部,而prev是当cur对应的值小于基准值时将prev++,与cur对应下标位置进行交换即 prev位置的值与cur对应位置的值进行交换
当cur结束了(循环结束),将prev对应的值与基准值进行交换。基准值就到了合适的位置。
void QuickSort(int* a,int begin, int end) { //3、前后指针 if (begin >= end) return; int prev = begin; int cur = prev + 1; int keyi = begin; while (cur <= end) { if (a[cur] <= a[keyi] && ++prev != cur) { Swap(&a[cur], &a[prev]); } cur++; } Swap(&a[keyi], &a[prev]); QuickSort(a, begin, prev - 1); QuickSort(a, prev + 1, end); }
基准值到了合适位置后对其左边,进行分治。对其右边进行分治。
优化
当一个序列是有序时,还是首元素作为基准的话那么所谓的分治思想就不存在了。
每次分都是 keyi+1 到 end。并未分治。
所以我们要有一个三数取中来打破这种不能分治的问题。
1、三数取中
int GetMidi(int* a, int left, int right) { int mid = (left + right) / 2; // left mid right if (a[left] < a[mid]) { if (a[mid] < a[right]) { return mid; } else if (a[left] > a[right]) // mid是最大值 { return left; } else { return right; } } else // a[left] > a[mid] { if (a[mid] > a[right]) { return mid; } else if (a[left] < a[right]) // mid是最小 { return left; } else { return right; } } }
三数取中是取首元素,最后一个元素,最后一个元素和首元素的一个中值,中的一个中间值。
2、小区间优化
当区间比较小的时候,就不再递归划分去排序这个小区间。可以考虑其他排序。
这里建议直接用插入排序。void QuickSort(int* a,int begin, int end) { //3、前后指针 if (begin >= end) return; if ((end - begin + 1) > 10) { int prev = begin; int cur = prev + 1; int keyi = begin; while (cur <= end) { if (a[cur] <= a[keyi] && ++prev != cur) { Swap(&a[cur], &a[prev]); } cur++; } Swap(&a[keyi], &a[prev]); QuickSort(a, begin, prev - 1); QuickSort(a, prev + 1, end); } else { InsertSort(a+begin, end - begin + 1); } }
这里要注意控制的是开区间还是闭区间。
当我们进行其他排序算法进行排序要注意是从哪个位置到所结束的位置。
开始的位置都是随时变化的,但是个数都是那么多个。
2、非递归实现
需要利用到栈,因为栈的后进先出比较符合递归。相当于深度优先遍历
利用队列也可以但是相比较之下,会更麻烦。
//快排非递归 void QuickSortNor(int *a,int begin,int end) { ST st; StackInit(&st); //先让最初的begin 和 end 进栈 StackPush(&st, end); StackPush(&st, begin); while (!StackEmpty(&st)) { //将begin 、 end 出栈计算 keyi 并对单组排序。 int left = StackGetTop(&st); StackPop(&st); int right = StackGetTop(&st); //这里的left right 是用于循环找出keyi和排序的 StackPop(&st); int left1 = left; int right1 = right;//这里的left1 right1 是用于记录出栈的两个数据的范围。 int keyi = left; while (left < right) { while (left < right && a[right] >= a[keyi]) { --right; } while (left < right&& a[left] <= a[keyi]) { ++left; } Swap(&a[right], &a[left]); } Swap(&a[keyi], &a[left]); keyi = left; if (keyi + 1 < right1)//除非写一个函数找出keyi,right 和 left 才不会改变,不然的话就 //要另一个值来保存这个范围 { StackPush(&st, right1); StackPush(&st, keyi + 1); } if (left1 < keyi - 1) { StackPush(&st, keyi - 1); StackPush(&st, left1); } } StackDestroy(&st); }
算法思想:
先让最初的begin 和 end 进栈,
然后出栈排序的得出基准值的位置,将基准值的左边和右边分别进栈。
注意控制基准值左右进栈的范围、栈的特性是后进先出,注意进栈和出栈的左右选择是否正确。
特性:
时间复杂度为O(n log n)
空间复杂度为O(log n),因为递归会消耗栈帧的资源。
不稳定排序。
七、归并排序
归并排序也是一种常用的排序算法,属于归并排序。它通过分治的思想将原始数组分成长度为1的子数组,然后合并相邻的两个子数组,得到长度为2的有序子数组;再将相邻的两个有序子数组合并,得到长度为4的有序子数组,以此类推,直到整个数组有序为止。下面是归并排序的基本思路:
将原始数组分成长度为1的子数组。
将相邻的两个子数组合并,得到长度为2的有序子数组。
不断地重复步骤2,将相邻的有序子数组合并,得到更长的有序子数组,直到整个数组有序为止。
需要开辟一个空间与原数组相等的额外的数组将,将原数组子数组进行整合排序然后复制回原来的数组。
1、递归实现
void _MergeSort(int* a, int* tmp, int begin, int end) { if (end <= begin) return; int mid = (end + begin) / 2; //分 // [begin, mid][mid+1, end] _MergeSort(a, tmp, begin, mid); _MergeSort(a, tmp, mid + 1, end); // 归并到tmp数据组,再拷贝回去 // a->[begin, mid][mid+1, end]->tmp int begin1 = begin, end1 = mid; int begin2 = mid + 1, end2 = end; int index = begin; 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); if (tmp == NULL) { perror("malloc fail"); return; } _MergeSort(a, tmp, 0, n - 1); free(tmp); }
先分再治
递归进行分,子数组在临时数组排序然后拷贝回原数组。
2、非递归实现
void MergeSortNonR(int* a, int n) { int* tmp = (int*)malloc(sizeof(int) * n); if (tmp == NULL) { perror("malloc fail"); return; } int gap = 1; 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; // [begin1,end1] [begin2,end2] 归并 // 如果第二组不存在,这一组不用归并了 if (begin2 >= n) { break; } // 如果第二组的右边界越界,修正一下 if (end2 >= n) { end2 = n - 1; } //printf("[%d,%d][%d,%d] ", begin1, end1, begin2, end2); int index = i; 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 + i, tmp + i, (end2-i+1) * sizeof(int)); } printf("\n"); gap *= 2; } free(tmp); }
非递归实现的话就要一个增量来对数组进行 ——分。
分的话要防止数组越界,当gap增量变大时要注意子数组是否越界,要对其进行修正。
治的话仍是原来的情况,不需要改变。
八、计数排序
它通过确定每个元素之前有多少个小于它的元素来进行排序。计数排序的时间复杂度为O(n+k),其中k是排序中最大元素的大小。计数排序适用于元素范围不大的场景,并且要求元素为非负整数。(只局限与整型)
下面是计数排序的基本思路:
找出待排序数组中最大元素和最小元素的值,计算出数组的范围。
根据待排序数组中每个元素的值,统计每个元素出现的次数。
计算统计数组中小于等于每个元素的元素个数。
反向遍历待排序数组,根据统计数组中小于等于该元素的元素个数,确定该元素的位置。
利用数组下标计算数组中每个数出现的次数,并对其进行排序处理。
所以适用于数据比较集中的序列,当数据差距过大则数组开辟的空间也会太大浪费了太多空间。
void CountSort(int* a, int n) { int max = a[0], min = a[0]; for (int i = 0; i < n; i++) { if (a[i] > max) max = a[i]; if (a[i] < min) min = a[i]; } int range = max - min + 1; //是范围不是个数 int* count = (int *)malloc(sizeof(int)*range); memset(count, 0, sizeof(int) * range);// 100 101 125 132 121 125 164 125 121 187 199 //统计数据出现的次数 for (int i = 0; i < n; i++) { count[a[i] - min]++; //100 - 100 = 0 } //排序 int j = 0; for (int i = 0; i < range; i++) { while (count[i]--) a[j++] = i + min;// 0 + 100 下标 + min等于原数组的值 } }
因为要使用下标,所以我们要求出数组中,最小值到最大值之间的范围(一共有多少个数)
1、先求出数组中的最大值和最小值,然后计算范围 并开辟该范围的一个数组并将数组的值都初始化为0
---这里要注意的是你所用的是数组的开区间还是闭区间--统计数据出现的次数放入所开辟的数组中,这个数组就是用于计数的一个数组。
排序,因为计数数组中的数据都是按其下标进行存储的,所以依次都是有序的,只是有的数据个数多有的没有而已。
当你算出一个数组中的最小值时,可以用原数组中的下标对应的值减去这个最小值,放入计数数组中,以使计数数组减少空间资源的开辟。
但是当进行数组的排序时,你就要+min 使其获得原来的值。
特性:
时间复杂度为O(n+range)
空间复杂度为O(range),range为计数数组所开辟的空间
稳定排序。
九、总结
八大排序对比
最后,大家如果觉得文章写得不错的话,希望可以来个三连支持一下博主,若文章有哪方面不足的话可以在评论区留言,一起努力一起学习!!!