C语言数据结构与算法(五)

220 阅读16分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 29 天,点击查看活动详情

排序算法篇

恭喜来到最后一部分:排序算法篇,数据结构与算法的学习也接近尾声了,我们的故事也要结束了。记忆里你的眼睛很好看,好像里面藏满了星星✨✨

基础排序

冒泡排序

冒泡排序的核心就是交换,通过不断地进行交换,一点一点将大的元素推向一端,每一轮都会有一个最大的元素排到对应的位置上,最后形成有序。

算法演示网站:visualgo.net/zh/sorting

设数组长度为N:

  • 共进行N轮排序。
  • 每一轮排序从数组的最左边开始,两两元素进行比较,如果左边元素大于右边的元素,那么就交换两个元素的位置,否则不变。
  • 每轮排序都会将剩余元素中最大的一个推到最右边,下次排序就不再考虑这些已经在对应位置的元素。

比如下面的数组:

那么在第一轮排序时,首先比较前两个元素:

我们发现前者更大,那么此时就需要交换,交换之后,继续向后比较后面的两个元素:

我们发现后者更大,不变,继续看后两个:

此时前者更大,交换,继续比较后续元素:

还是前者更大,继续交换,然后向后比较:

依然是前者更大,只要是最大的元素,它会在每次比较中被一直往后丢:

最后,当前数组中最大的元素就被丢到最前面去了,这一轮排序结束,因为最大的已经排到对应的位置上了,所以说第二轮我们只需要考虑其之前的这些元素即可:

这样,我们就可以不断将最大的丢到最右边了,最后N轮排序之后,就是一个有序的数组了。

  1. 实际上排序并不需要N轮,而是N-1轮即可,因为最后一轮只有一个元素未排序了,相当于已经排序了,所以说不需要再考虑了。
  2. 如果整轮排序中都没有出现任何的交换,那么说明数组已经是有序的了,不存在前一个比后一个大的情况。

程序代码如下:

#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]);
    }
}

希尔排序

希尔排序是插入排序的进阶版本(又叫缩小增量排序)插入排序虽然很好理解,但是在极端情况下会出现让所有已排序元素后移的情况(比如刚好要插入的是一个特别小的元素)为了解决这种问题,希尔排序对插入排序进行改进,它会对整个数组按照步长进行分组,优先比较距离较远的元素。

这个步长是由一个增量序列来定的,这个增量序列很关键,我们一般使用 ![img](g.yuque.com/gr/latex?\f… {n} {2})、![img](g.yuque.com/gr/latex?\f… {n} {4})、![img](g.yuque.com/gr/latex?\f… {n} {8})、...、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

然后是十位数:

最后再次按顺序取出来:

成功得到有序数组。

结语