开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 29 天,点击查看活动详情
排序算法篇
恭喜来到最后一部分:排序算法篇,数据结构与算法的学习也接近尾声了,我们的故事也要结束了。记忆里你的眼睛很好看,好像里面藏满了星星✨✨
基础排序
冒泡排序
冒泡排序的核心就是交换,通过不断地进行交换,一点一点将大的元素推向一端,每一轮都会有一个最大的元素排到对应的位置上,最后形成有序。
算法演示网站:visualgo.net/zh/sorting
设数组长度为N:
- 共进行N轮排序。
- 每一轮排序从数组的最左边开始,两两元素进行比较,如果左边元素大于右边的元素,那么就交换两个元素的位置,否则不变。
- 每轮排序都会将剩余元素中最大的一个推到最右边,下次排序就不再考虑这些已经在对应位置的元素。
比如下面的数组:
那么在第一轮排序时,首先比较前两个元素:
我们发现前者更大,那么此时就需要交换,交换之后,继续向后比较后面的两个元素:
我们发现后者更大,不变,继续看后两个:
此时前者更大,交换,继续比较后续元素:
还是前者更大,继续交换,然后向后比较:
依然是前者更大,只要是最大的元素,它会在每次比较中被一直往后丢:
最后,当前数组中最大的元素就被丢到最前面去了,这一轮排序结束,因为最大的已经排到对应的位置上了,所以说第二轮我们只需要考虑其之前的这些元素即可:
这样,我们就可以不断将最大的丢到最右边了,最后N轮排序之后,就是一个有序的数组了。
- 实际上排序并不需要N轮,而是N-1轮即可,因为最后一轮只有一个元素未排序了,相当于已经排序了,所以说不需要再考虑了。
- 如果整轮排序中都没有出现任何的交换,那么说明数组已经是有序的了,不存在前一个比后一个大的情况。
程序代码如下:
#include <stdio.h>
// 正常人版
void bubbleSort(int arr[], int size){
for (int i = 0; i < size; ++i) {
for (int j = 0; j < size - i - 1; ++j) {
//注意需要到N-1的位置就停止,因为要比较j和j+1
//这里减去的i也就是已经排好的不需要考虑了
if(arr[j] > arr[j + 1]) { //如果后面比前面的小,那么就交换
int tmp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = tmp;
}
}
}
}
// 优化版
void bubbleSort(int arr[], int size) {
for (int i = 0; i < size - 1; ++i) { //只需要size-1次即可
_Bool flag = 1; //这里使用一个标记,默认为1表示数组是有序的
for (int j = 0; j < size - i - 1; ++j) {
if (arr[j] > arr[j + 1]) {
flag = 0; //如果发生交换,说明不是有序的,把标记变成0
int tmp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = tmp;
}
}
if (flag) break; //如果没有发生任何交换,flag一定是1,数组已经有序,所以说直接结束排序
}
}
int main() {
int arr[] = {1, 5, 2, 8, 16, 7, 11, 12};
bubbleSort(arr, sizeof(arr) / sizeof(arr[0]));
for (int i = 0; i < sizeof(arr) / sizeof(arr[0]); ++i) {
printf("%d -> ", arr[i]);
}
}
插入排序
插入排序,准确地说应该叫直接插入排序,它的核心思想就像打牌一样,你肯定很熟悉了。从牌堆去摸牌,那么摸到牌之后,在我们手中的牌顺序是乱的,这样肯定不行啊,牌都没理顺怎么知道哪些牌有多少呢?为了使得其有序,我们就会根据牌的顺序,将新摸过来的牌插入到对应的位置上,这样我们后面就不用再整理手里的牌了。而插入排序实际上也是一样的原理,我们默认前面的牌都是已经排好序的(一开始就只有第一张牌是有序状态),剩余的部分我们会挨着遍历,然后将其插到前面对应的位置上去。
算法演示网站:visualgo.net/zh/sorting
设数组长度为N,详细过程为:
● 共进行N轮排序。
● 每轮排序会从后面依次选择一个元素,与前面已经处于有序的元素,从后往前进行比较,直到遇到一个不大于当前元素的的元素,将当前元素插入到此元素的前面。
● 插入元素后,后续元素则全部后移一位。
● 当后面的所有元素全部遍历完成,全部插入到对应的位置之后,排序完成。
比如下面的数组:
此时我们默认第一个元素已经是处于有序状态,我们从第二个元素开始看:
将其取出,从后往前,与前面的有序序列依次进行比较,首先比较的是4,发现比4小,继续向前,发现已经到头了,所以说直接放到最前面即可,注意在放到最前面之前,先将后续元素后移,腾出空间:
接着插入即可:
目前前面两个元素都是有序的状态了,我们继续来看第三个元素:
依然是从后往前看,我们发现上来就遇到了7小的4,所以说直接放到这个位置:
现在前面三个元素都是有序状态了,同样的,我们继续来看第四个元素:
依次向前比较,发现到头了都没找到比1还小的元素,所以说将前面三个元素全部后移:
将1插入到对应的位置上去:
现在前四个元素都是有序的状态了,我们只需要按照同样的方式完成后续元素的遍历,最后得到的就是有序的数组了。
我们来尝试编写一下代码:
#include <stdio.h>
void insertSort(int arr[], int size){
for (int i = 1; i < size; ++i) { //从第二个元素开始看
int j = i, tmp = arr[i]; //j直接变成i,因为前面的都是有序的了,tmp相当于是抽出来的牌暂存一下
while (j > 0 && arr[j - 1] > tmp) { //只要j>0并且前一个还大于当前待插入元素,就一直往前找
arr[j] = arr[j - 1]; //找的过程中需要不断进行后移操作,把位置腾出来
j--;
}
arr[j] = tmp; //j最后在哪个位置,就是是哪个位置插入
}
}
int main() {
int arr[] = {1, 5, 2, 8, 16, 7, 11, 12};
insertSort(arr, sizeof(arr) / sizeof(arr[0]));
for (int i = 0; i < sizeof(arr) / sizeof(arr[0]); ++i) {
printf("%d -> ", arr[i]);
}
}
选择排序
这种排序比较好理解,我们每次都去后面找一个最小的放到前面即可。
算法演示网站:visualgo.net/zh/sorting
设数组长度为N,详细过程为:
- 共进行N轮排序。
- 每轮排序会从后面的所有元素中寻找一个最小的元素出来,然后与已经排序好的下一个位置进行交换。
- 进行N轮交换之后,得到有序数组。
比如下面的数组:
第一次排序需要从整个数组中寻找一个最小的元素,并将其与第一个元素进行交换:
交换之后,第一个元素已经是有序状态了,我们继续从剩下的元素中寻找一个最小的:
此时2正好在第二个位置,假装交换一下,这样前面两个元素都已经是有序的状态了,我们接着来看剩余的:
此时发现3是最小的,所以说直接将其交换到第三个元素位置上:
这样,前三个元素都是有序的了,通过不断这样交换,最后我们得到的数组就是一个有序的了,我们来尝试编写一下代码:
#include <stdio.h>
void selectSort(int arr[], int size){
for (int i = 0; i < size - 1; ++i) { //因为最后一个元素一定是在对应位置上的,所以只需要进行N - 1轮排序
int min = i; //记录一下当前最小的元素,默认是剩余元素中的第一个元素
for (int j = i + 1; j < size; ++j) //挨个遍历剩余的元素,如果遇到比当前记录的最小元素还小的元素,就更新
if(arr[min] > arr[j])
min = j;
int tmp = arr[i]; //找出最小的元素之后,开始交换
arr[i] = arr[min];
arr[min] = tmp;
}
}
int main() {
int arr[] = {1, 5, 2, 8, 16, 7, 11, 12};
selectSort(arr, sizeof(arr) / sizeof(arr[0]));
for (int i = 0; i < sizeof(arr) / sizeof(arr[0]); ++i) {
printf("%d -> ", arr[i]);
}
}
进阶排序
快速排序是冒泡排序的进阶版本,在冒泡排序中,进行元素的比较和交换是在相邻元素之间进行的,元素每次交换只能移动一个位置,所以比较次数和移动次数较多,效率相对较低。而在快速排序中,元素的比较和交换是从两端向中间进行的,较大的元素一轮就能够交换到后面的位置,而较小的元素一轮就能交换到前面的位置,元素每次移动的距离较远,所以比较次数和移动次数较少,速度更快。
快速排序每一轮的目的就是将大的丢到基准右边去,小的丢到基准左边去。
设数组长度为N,详细过程为:
- 在一开始,排序范围是整个数组
- 排序之前,我们选择整个排序范围内的第一个元素作为基准,对排序范围内的元素进行快速排序
- 先从最右边向左看,依次将每一个元素与基准元素进行比较,如果发现比基准元素小,那么就与左边遍历位置上的元素(一开始是基准元素的位置)进行交换,此时保留右边当前遍历的位置。
- 交换后,转为从左往右开始遍历元素,如果发现比基准元素大,那么就与之前保留的右边遍历的位置上的元素进行交换,同样保留左边当前的位置,循环执行上一个步骤。
- 当左右遍历撞到一起时,本轮快速排序完成,最后在最中间的位置就是基准元素的位置了。
- 以基准位置为中心,划分左右两边,以同样的方式执行快速排序。
比如下面的数组:
首先我们选择第一个元素4作为基准元素,一开始左右指针位于两端:
此时从右往左开始看,直到遇到一个比4小的元素,首先是6,肯定不是,将指针往后移动:
此时继续让3和4进行比较,发现比4小,那么此时直接将3直接覆盖过去,到左边指针所指向的元素位置:
此时我们转为从左往右看,如果遇到比4大的元素,就交换到右边指针处,3肯定不是了,因为刚刚才换过来,接着就是2:
2也没有4大,所以说继续往后看,此时7比4要大,那么继续交换:
接着,又开始从右往左看:
此时5是比4要大的,继续向前,发现1比4要小,所以说继续交换:
接着又转为从左往右看,此时两个指针撞到一起了,排序结束,最后两个指针所指向的位置就是给基准元素的位置了:
本轮快速排序结束后,左边不一定都是有序的,但是一定比基准元素要小,右边一定比基准元素大。接着我们以基准为中心,分成两个部分再次进行快速排序:
这样,我们最后就可以使得整个数组有序了。
接下来尝试编码实现一下快速排序:
#include <stdio.h>
void quickSort(int arr[], int start, int end){
if(start >= end) return; //范围不可能无限制的划分下去
int left = start, right = end, pivot = arr[left]; //定义两个指向左右两个端点的指针,以及取出基准
while (left < right) { //只要两个指针没相遇,就一直循环进行下面的操作
while (left < right && arr[right] >= pivot) right--; //从右向左看,直到遇到比基准小的
arr[left] = arr[right]; //遇到比基准小的,就丢到左边去
while (left < right && arr[left] <= pivot) left++; //从左往右看,直到遇到比基准大的
arr[right] = arr[left]; //遇到比基准大的,就丢到右边去
}
arr[left] = pivot; //最后相遇的位置就是基准存放的位置了
quickSort(arr, start, left - 1); //不包含基准,划分左右两边,再次进行快速排序
quickSort(arr, left + 1, end);
}
int main() {
int arr[] = {1, 5, 2, 8, 16, 7, 11, 12};
quickSort(arr,0, sizeof(arr) / sizeof(arr[0]));
for (int i = 0; i < sizeof(arr) / sizeof(arr[0]); ++i) {
printf("%d -> ", arr[i]);
}
}
希尔排序
希尔排序是插入排序的进阶版本(又叫缩小增量排序)插入排序虽然很好理解,但是在极端情况下会出现让所有已排序元素后移的情况(比如刚好要插入的是一个特别小的元素)为了解决这种问题,希尔排序对插入排序进行改进,它会对整个数组按照步长进行分组,优先比较距离较远的元素。
这个步长是由一个增量序列来定的,这个增量序列很关键,我们一般使用 、、、...、1 这样的增量序列。
设数组长度为N,详细过程为:
- 首先求出最初的步长,n/2即可。
- 我们将整个数组按照步长进行分组,也就是两两一组(如果n为奇数的话,第一组会有三个元素)
- 我们分别在这些分组内进行插入排序。
- 排序完成后,我们将步长/2,重新分组,重复上述步骤,直到步长为1时,插入排序最后一遍结束。
我们以下面的数组为例:
首先数组长度为8,直接整除2,得到4,那么步长就是4了,我们按照4的步长进行分组:
其中,4、8为第一组,2、5 为第二组,7、3为第三组,1、6为第四组,我们分别在这四组内进行插入排序,组内排序之后的结果为:
可以看到目前小的元素尽可能地在往前面走,虽然还不是有序的,接着我们缩小步长,4/2=2,此时按照这个步长划分:
此时4、3、8、7为一组,2、1、5、6为一组,我们继续在这两个组内进行排序,得到:
最后我们继续将步长/2,得到2/2=1,此时步长变为1,也就相当于整个数组为一组,再次进行一次插入排序,此时我们会发现,小的元素都靠到左边来了,此时再进行插入排序就不会出现集体后移的情况。
来尝试编写一下代码:
#include <stdio.h>
void shellSort(int arr[], int size){
int delta = size / 2;
while (delta >= 1) {
//插入排序,此时需要考虑分组了
for (int i = delta; i < size; ++i) { //从delta开始,前delta个组的第一个元素默认是有序状态
int j = i, tmp = arr[i]; //待插入的先抽出来
while (j >= delta && arr[j - delta] > tmp) {
//注意这里比较需要按步长往回走,所以说是j - delta,此时j必须大于等于delta才可以,如果j - delta小于0说明前面没有元素了
arr[j] = arr[j - delta];
j -= delta;
}
arr[j] = tmp;
}
delta /= 2; //分组插排完事之后,重新计算步长
}
}
int main() {
int arr[] = {1, 5, 2, 8, 16, 7, 11, 12};
shellSort(arr, sizeof(arr) / sizeof(arr[0]));
for (int i = 0; i < sizeof(arr) / sizeof(arr[0]); ++i) {
printf("%d -> ", arr[i]);
}
}
堆排序
堆排序也是选择排序的一种,但是它能够比直接选择排序更快。回忆一下什么是顶堆:
对于一棵完全二叉树,树中父亲结点都比孩子结点小的我们称为小根堆(小顶堆),树中父亲结点都比孩子结点大则是大根堆,堆是一棵完全二叉树,我们可以使用数组来进行表示:
我们通过构建一个堆,就可以将一个无序的数组依次输入,最后存放的是一个按顺序排放的数组,利用这种性质,我们可以很轻松地利用堆进行排序,先来写一个小顶堆:
typedef int E;
typedef struct MinHeap {
E * arr;
int size;
int capacity;
} * Heap;
_Bool initHeap(Heap heap){
heap->size = 0;
heap->capacity = 10;
heap->arr = malloc(sizeof (E) * heap->capacity);
return heap->arr != NULL;
}
_Bool insert(Heap heap, E element){
if(heap->size == heap->capacity) return 0;
int index = ++heap->size;
while (index > 1 && element < heap->arr[index / 2]) {
heap->arr[index] = heap->arr[index / 2];
index /= 2;
}
heap->arr[index] = element;
return 1;
}
E delete(Heap heap){
E max = heap->arr[1], e = heap->arr[heap->size--];
int index = 1;
while (index * 2 <= heap->size) {
int child = index * 2;
if(child < heap->size && heap->arr[child] > heap->arr[child + 1])
child += 1;
if(e <= heap->arr[child]) break;
else heap->arr[index] = heap->arr[child];
index = child;
}
heap->arr[index] = e;
return max;
}
将这些元素挨个插入到堆中,然后再挨个拿出来,得到的就是一个有序的顺序了:
int main(){
int arr[] = {3, 5, 7, 2, 9, 0, 6, 1, 8, 4};
struct MinHeap heap; //先创建堆
initHeap(&heap);
for (int i = 0; i < 10; ++i)
insert(&heap, arr[i]); //直接把乱序的数组元素挨个插入
for (int i = 0; i < 10; ++i)
arr[i] = delete(&heap); //然后再一个一个拿出来,就是按顺序的了
for (int i = 0; i < 10; ++i)
printf("%d ", arr[i]);
}
最后得到的结果为:
其他排序方案
归并排序
归并排序利用递归分治的思想,将原本的数组进行划分,然后首先对划分出来的小数组进行排序,然后最后在合并为一个有序的大数组,还是很好理解的:
我们以下面的数组为例:
在一开始先一半一半地进行划分:
继续进行划分:
最后会变成这样的一个一个的元素:
此时我们就可以开始归并排序了,注意这里的合并并不是简简单单地合并,我们需要按照从小到大的顺序,依次对每个元素进行合并,第一组树4和2,此时我们需要从这两个数组中先选择小的排到前面去:
排序完成后,我们继续向上合并:
最后我们再将这两个数组合并到原有的大小:
最后就能得到一个有序的数组了。
代码如下:
#include <stdio.h>
#include <stdlib.h>
void merge(int arr[], int tmp[], int left, int leftEnd, int right, int rightEnd){
int i = left, size = rightEnd - left + 1; //保存一下当前范围长度,后面使用
while (left <= leftEnd && right <= rightEnd) { //如果两边都还有,那么就看哪边小,下一个就存哪一边的
if(arr[left] <= arr[right]) //如果左边的小,那么就将左边的存到下一个位置(这里i是从left开始的)
tmp[i++] = arr[left++];
else
tmp[i++] = arr[right++];
}
while (left <= leftEnd) //如果右边看完了,只剩左边,直接把左边的存进去
tmp[i++] = arr[left++];
while (right <= rightEnd)
tmp[i++] = arr[right++];
for (int j = 0; j < size; ++j, rightEnd--) //全部存到暂存空间中之后,暂存空间中的内容都是有序的了,此时挨个回原数组中
arr[rightEnd] = tmp[rightEnd];
}
void mergeSort(int arr[], int tmp[], int start, int end){
if(start >= end) return; //依然是使用递归,所以说如果范围太小,就不用看了
int mid = (start + end) / 2; //先找到中心位置,一会分两半
mergeSort(arr, tmp, start, mid); //对左半和右半分别进行归并排序
mergeSort(arr, tmp, mid + 1, end);
merge(arr, tmp, start, mid, mid + 1, end);
//上面完事之后,左边和右边都是有序状态了,此时再对整个范围进行一次归并排序即可
}
int main() {
int arr[] = {3, 5, 7, 2, 9, 0, 6, 1, 8, 4};
int tmp[10];
mergeSort(arr, tmp, 0, 9);
for (int i = 0; i < sizeof(arr) / sizeof(arr[0]); ++i) {
printf("%d -> ", arr[i]);
}
}
桶排序
我们先来看看计数排序,它要求是数组长度为N,且数组内的元素取值范围是0 - M-1 之间(M小于等于N)
算法演示网站:visualgo.net/zh/sorting?…
比如下面的数组:
我们先对其进行一次遍历,统计每个元素的出现次数,统计完成之后,我们就能够明确在排序之后哪个位置可以存放值为多少的元素了:
我们来分析一下,首先1只有一个,那么只会占用一个位置,2也只有一个,所以说也只会占用一个位置:
所以说我们直接根据统计的结果,把这些值挨个填进去就行了:
很简单,只需要遍历一次进行统计就行了。
接着来看桶排序,它要求是数组长度为N,且数组内的元素取值范围是0-M-N 之间(M小于等于N),比如现在有1000个学生,现在需要对这些学生按照成绩进行排序,因为成绩的范围是0-100,我们可以建立101个桶来分类存放。
比如下面的数组:
此数组中包含1-6的元素,所以说我们可以建立 6个桶来进行统计:
我们只需要遍历一次,就可以将所有的元素分类丢到这些桶中,最后我们只需要依次遍历这些桶,然后把里面的元素拿出来依次存放回去得到的就是有序的数组了:
基数排序
基数排序是一种依靠统计来进行的排序算法。它的思路是,分出10个基数出来(从0 - 9)我们依然是只需要遍历一次,我们根据每一个元素的个位上的数字,进行分类,因为现在有10个基数,也就是10个桶。个位完事之后再看十位、百位...
算法演示网站:visualgo.net/zh/sorting
先按照个位数进行统计,然后排序,再按照十位进行统计,然后排序,最后得到的结果就是最终的结果了:
73, 104, 186
然后是十位数:
最后再次按顺序取出来:
成功得到有序数组。