当前位置: 首页 > news >正文

排序与查找算法(C语言实现)

文章目录

  • 排序与查找算法
    • 1. 排序
      • 1.1 如何评估一个排序算法
      • 1.2 选择排序
        • 代码实现
        • 分析
      • 1.3 冒泡排序
        • 代码实现
        • 分析
      • 1.4 插入排序
        • 代码实现
        • 分析
      • 1.5 希尔排序
        • 代码实现
        • 分析
      • 1.6 归并排序
        • 代码实现
        • 分析
      • 1.7 快速排序
        • 代码实现
        • 分析
        • 改进策略
      • 1.8 堆排序
        • 代码实现
        • 分析
    • 2. 查找
      • 2.1 二分查找
        • 代码实现
        • 二分查找的变种

排序与查找算法

排序的前提是"比较",排序的目的往往是"查找"。这一章,我们主要给大家介绍一些经典的基于比较的排序算法,以及经常被人低估的二分查找算法。

1. 排序

1.1 如何评估一个排序算法

我们可以从三个维度去分析一个排序算法:时间复杂度、空间复杂度和稳定性。

  1. 时间复杂度

    • 最好情况
    • 最坏情况
    • 平均情况
    • 系数和低阶项
  2. 空间复杂度

    我们需要重点关注原地排序(store in place),也就是空间复杂度为 O(1) 的排序。

  3. 稳定性

    数据集中"相等"的元素,如果排序前和排序后的相对次序不变,那么这个排序算法就是稳定的。稳定性是排序算法一个很重要的指标。

    我们通过一个例子来说明这个问题:在电商系统中,经常需要查看订单数据,以做数据统计。现在有这样一个需求,先对订单按金额从大到小排序,如果金额相同,再按下单时间从大到小排序。 解决思路:先按下单时间从大到小排序,再用稳定的排序算法,按金额从大到小排序。(why?)

1.2 选择排序

选择排序(Selection sort)是一种简单直观的排序算法。

其基本思想是:首先在未排序的数列中找到最小(或最大)元素,然后将其存放到数列的起始位置;接着,再从剩余未排序的元素中继续寻找最小(或最大)元素,然后放到已排序序列的末尾。以此类推,直到所有元素均排序完毕。

以数列[20,40,30,10,60,50]为例,其选择排序过程如下:

在这里插入图片描述

代码实现
// main.c
#include <stdio.h>
#define SIZE(arr) (sizeof(arr) / sizeof(arr[0]))

void selection_sort(int arr[], int n);

int main(void) {
    int arr[] = {3, 1, 4, 1, 5, 9, 2, 6};

    // 选择排序
    selection_sort(arr, SIZE(arr));

    printf("排序后数组顺序为:");
    for (int i = 0; i < SIZE(arr); i++) {
        printf(" %d", arr[i]);
    }

    return 0;
}
// selection_sort.c
void selection_sort(int arr[], int n) {
    // 从小到大
    int min = arr[0];
    int minIdx = 0;
    for (int i = 0; i < n - 1; i++) {
        min = arr[i];
        minIdx = i;
        for (int j = i + 1; j < n; j++) {
            // 找出min
            if (min > arr[j]) {
                min = arr[j];
                minIdx = j;
            }
        }
        // 交换
        int temp = arr[i];
        arr[i] = arr[minIdx];
        arr[minIdx] = temp;
    }
}
分析
  1. 时间复杂度:O(n^2)
  2. 空间复杂度:O(1)
  3. 稳定性:稳定

1.3 冒泡排序

冒泡排序(Bubble sort)也是一种简单直观的排序算法。

其基本思想是:从左到右,依次比较相邻的两个元素,如果它们逆序,则交换这两个元素。每比较一轮(冒泡一次),最大的元素就会"沉"到数列的末尾,而较小的元素会慢慢"浮"到数列的前面。因此,经过 N-1 (假设有N个元素) 次冒泡,数组就排好序了。

以数列 [3, 6, 4, 2, 11, 10, 5] 为例,其冒泡排序过程如下:

在这里插入图片描述

代码实现
void bubble_sort(int arr[], int n) {
    for (int i = n - 1; i > 0; i--) {
        for (int j = 0; j < i; j++) {
            if (arr[j] > arr[j+1]) {
                // 交换
                int temp = arr[j];
                arr[j] = arr[j+1];
                arr[j+1] = temp;
            }
        }
    }
}
分析
  1. 时间复杂度:O(n^2)
  2. 空间复杂度:O(1)
  3. 稳定性:稳定

1.4 插入排序

插入排序(Insertion sort)也是一种简单直观的排序算法。

其基本思想是:构建有序序列。对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入。

插入排序类似于摸牌并将其从小到大排列。每次摸到一张牌后,根据其点数插入到确切位置。

以数列 [3, 6, 4, 2 ,11, 10, 5] 为例,插入排序过程如下:

在这里插入图片描述

代码实现
// 自己的代码思路 循环和选择结构嵌套得比较深,代码可读性变差
void insert_sort(int arr[], int n) {
    for (int i = 1; i < n; i++) {
        int insertNum = arr[i];
        for (int j = i - 1; j >= 0; j--) {
            // 找到合适的插入位置
            if (insertNum >= arr[j]) {
                arr[j + 1] = insertNum;
                break;
            } else {
                arr[j + 1] = arr[j]; // 比待插入数大的数往后移一位
                if (j == 0) {
                    arr[j] = insertNum; // 待插入数是最小的情况
                }
            }
        }
    }
}
// 参考代码
void insertion_sort(int arr[], int n) {
    for (int i = 1; i < n; i++) { // i:待插入元素的索引
        // 保存待插入的元素
        int value = arr[i];
        int j = i - 1;
        while (j >= 0 && arr[j] > value) {
            arr[j + 1] = arr[j]; // 逻辑上的交换操作
            j--;
        }
        // j == -1 || arr[j] <= value
        arr[j + 1] = value;
    }
}
分析
  1. 时间复杂度:O(n^2)
  2. 空间复杂度:O(1)
  3. 稳定性:稳定

1.5 希尔排序

希尔排序(Shell sort)是插入排序的一种改进版本,它是基于插入排序下面两点性质而提出的改进方法:

  • 插入排序在对几乎已经排好序的数据排序时,效率很高,可以达到线性排序的效率。
  • 但插入排序每次只能将数据移动一位,因此一般情况下比较低效。

希尔排序,又叫缩小增量排序。它通过将元素在组间交换,从而可以将小的元素快速地移动到数列的前面。前面几次组间插入排序的目的,是使数列基本有序。最后一次,增量(gap)为 1,也就是简单的插入排序了。

希尔排序的效率与 gap 序列相关,希尔本人推荐的 gap 序列为:n/2, n/4, …, 1。但这个序列并不是最佳的。

以数列 [9, 1, 2, 5, 7, 4, 8, 6, 3, 5] 为例,希尔排序过程如下:

在这里插入图片描述

代码实现
void shell_sort(int arr[], int n) {
    int gap = n / 2;
    while (gap != 0) {
        for (int i = gap; i < n; i++) {
            int value = arr[i];
            int j = i - gap;

            while (j >= 0 && value < arr[j]) {
                arr[j + gap] = arr[j];
                j -= gap;
            } // j < 0 || value >= arr[j]

            arr[j + gap] = value;

        }
        gap /= 2;
    }
}
分析
  1. 时间复杂度:O(n^2),一般来说小于O(n^2)
  2. 空间复杂度:O(1)
  3. 稳定性:不稳定。因为会长距离的交换元素,因此希尔排序是不稳定的。一般来说,希尔排序的性能优于简单的插入排序,但它牺牲了稳定性。

1.6 归并排序

归并排序(Merge sort)是建立在归并操作上的一种高效的排序算法。该算法是分治思想的一种典型应用。

归并(merge)操作:是指将两个已经有序的序列合并成一个有序序列的操作。

以数列 [38, 27, 43, 3, 9, 82, 10] 为例,归并排序过程如下:

在这里插入图片描述

代码实现
// 归并排序
#define N 10
int tempArr[N];

void merge(int arr[], int left, int mid, int right) {
    int i = left, j = left, k = mid + 1;
    while (j <= mid && k <= right) {
        if (arr[j] <= arr[k]) {
            tempArr[i++] = arr[j++];
        } else {
            tempArr[i++] = arr[k++];
        }
    }

    // j > mid || k > right
    while (j <= mid) {
        tempArr[i++] = arr[j++];
    }

    while (k <= right) {
        tempArr[i++] = arr[k++];
    }

    // 复制数组
    for (int idx = left; idx <= right; idx++) {
        arr[idx] = tempArr[idx];
    }
}

void merge_sort_helper(int arr[], int left, int right) { // 递归分解数组
    // 递归终止条件
    if (left >= right)
        return;
    
    int mid = left + ((right - left) >> 1);
    // 将未排序的大数组分为左右子数组
    merge_sort_helper(arr, left, mid); // 左
    merge_sort_helper(arr, mid + 1, right); // 右

    // 将左右子数组合并为有序大数组
    merge(arr, left, mid, right);
}

void merge_sort(int arr[], int n) {
    merge_sort_helper(arr, 0, n - 1);
}
分析
  1. 时间复杂度 O(nlogn)

    T(n) = 2T(n/2) + O(n)

    如下图所示,每一层的所用的时间为 cn (归并操作所用的时间,其中 c 为常数项),总共有 log2n 层。

在这里插入图片描述

  1. 空间复杂度:O(n)

    所用空间:tmp数组需要O(n)的空间,栈的深度为O(lgn)。因此总的空间复杂度为 O(n) + O(lgn) = O(n)。

  2. 稳定性:稳定

    当左边区间最前面的元素和右边区间最前面的元素相等时,我们放入的是左边区间的元素,因此归并排序是稳定的。

1.7 快速排序

快速排序(Quick sort)是建立在分区操作上的一种高效的排序算法。快速排序也是分治思想的一种典型应用。

快速排序的步骤如下:

  1. 挑选基准值:从数列中挑选一个元素作为"基准值"。
  2. 分区:将比基准值小的元素排列在数列的前端,将比基准值大的元素排列到数列的尾端,与基准值相等的元素可以放到任意一段。分区操作之后,基准值就已经就位了。
  3. 递归排序子序列:递归地将小于基准值元素的子序列和大于基准值元素的子序列排序。

分区算法有多种,其中一种如下图所示(基准值为 5):

在这里插入图片描述

代码实现
#include <stdlib.h>
#include <time.h>

// 快速排序
void SWAP(int arr[], int idx1, int idx2) {
    int temp = arr[idx1];
    arr[idx1] = arr[idx2];
    arr[idx2] = temp;
}

int partition(int arr[], int left, int right) {
    // 选择基准值
    srand(time(NULL));
    int idx = rand() % (right - left + 1) + left;
    int pivot = arr[idx];
    SWAP(arr, idx, right); // 将基准值放到最右边

    // 分区
    int storgeIdx = left;
    for (int i = left; i < right; i++) {
        if (arr[i] < pivot) {
            SWAP(arr, i, storgeIdx);
            storgeIdx++;
        }
    }

    SWAP(arr, storgeIdx, right); // 将位于最右边的基准值换回数组中间
    return storgeIdx;
}

void quick_sort_helper(int arr[], int left, int right) {
    if (left >= right) return;

    int mid = partition(arr, left, right);
    quick_sort_helper(arr, left, mid - 1);
    quick_sort_helper(arr, mid + 1, right);
}

void quick_sort(int arr[], int n) {
    quick_sort_helper(arr, 0, n - 1);
}
分析
  1. 时间复杂度:
    • 最好情况:每次分区都可以分为大小相等的两份。O(nlogn) T(n) = 2T(n/2) + O(n) 分析方法和归并排序一致,这里不再赘述。
    • 最坏情况:每次分区,基准值最终都位于数列的两端。O(n^2)
  2. 空间复杂度:O(logn) 平均情况下,栈的深度为 O(logn)。
  3. 稳定性:在分区操作中,选取基准值后,我们会将其和最右边的元素进行交换。这可能是一次长距离的交换,因此快速排序是不稳定的。
改进策略

快速排序算法虽然最坏情况下的时间复杂度是 O(n2),但是平均情况下时间复杂度是 O(nlogn)。不仅如此,快速排序退化到O(n2)的概率极小,我们还通过合理地选择基准值来避免这种情况发生。

  1. 选取基准值
    • 随机选取
    • 选择 3 个或 5 个元素,然后选取其中位数作为基准值。
  2. 当元素个数比较少时(比如小于等于32个元素),采用插入排序。
  3. 每次分区分为三个区域:pivot。当相等的元素很多时,这种分区方法可以极大地提高快速排序的效率。
  4. 。。。

1.8 堆排序

参考文章:堆排序算法C++实现(以及子节点下标是父节点两倍的证明) - 知乎

堆排序(Heapsort)是指利用堆这种数据结构所设计的一种排序算法。

堆是一个近似完全二叉树的结构,并同时满足堆的性质:即子结点的 key 值总是小于或大于它的父结点的 key 值。

堆排序算法的步骤如下:

  1. 构建大顶堆

    找到第一个非叶子结点,从后往前构建大顶堆。

  2. 将堆顶元素和无序区的最后一个元素交换,并将无序区的长度减 1。

  3. 将无序区重新调整成大顶堆。

  4. 重复步骤2和步骤3,直到无序区的长度为 1。

堆通常是用一维数组实现的,父子结点的索引满足如下关系(证明见本节参考文章):

lchild(i) = 2i + 1
rchild(i) = 2i + 2
parent(i) = (i - 1)/2
代码实现
// 堆排序
void heapify(int arr[], int i, int n) {
    while (i < n) {
        // 如果某个结点下标为i 那么它的左子结点下标为 2i+1 右子节点下标为2i+2
        int lchild = 2 * i + 1;
        int rchild = 2 * i + 2;
        int maxIdx = i; // 该结点与子节点中数值最大的节点的下标

        if (lchild < n && arr[lchild] > arr[maxIdx]) {
            maxIdx = lchild;
        }

        if (rchild < n && arr[rchild] > arr[maxIdx]) {
            maxIdx = rchild;
        }

        // 到这里,maxIdx是结点i与子结点中最大结点的下标
        if (i == maxIdx) break; // 该结点的大顶堆构建完成

        // 运行到这里,说明需要移动元素才能构大顶堆
        SWAP(arr, i, maxIdx); // 堆顶元素与最大的元素交换

        i = maxIdx; // 交换后,往下遍历检查子结点的堆是否符合大顶堆结构
    }
}

static void build_heap(int arr[], int n) {
    // 按照堆底到堆顶的顺序,找到第一个非叶子结点
    // 第一个非叶子结点的下标 i = (n - 2) / 2
    // 从该结点开始往前遍历到根节点,建立大顶堆
    for (int i = (n - 2) >> 1; i >= 0; i--) {
        heapify(arr, i, n);
    }
}

void heap_sort(int arr[], int n) {
    // step1 先建立大顶堆
    build_heap(arr, n);

    // step2 根据大顶堆构建有序数组
    // 大顶堆直接对应的数组并不是有序的,但可以确定大顶堆的堆顶元素一定是最大的
    // ① 所以构建大顶堆后,将根节点(最大元素)与最后一个结点交换,这就得到了有序数组最末尾的元素,② 然后调整堆(除了最后的结点)的结构使其重新变为大顶堆。
    // 重复步骤一和步骤二,直至有序数组构建完成
    int len = n; // 无序数组的长度
    while (len > 1) {
        SWAP(arr, 0, len - 1);
        len--;
        heapify(arr, 0, len);
    }
}
分析
  1. 时间复杂度:O(nlogn)

    建堆操作的时间复杂度为O(n)(why? 见下图), heapify 操作的时间复杂度为O(logn),循环的次数为 n-1。 T(n) = O(n) + (n-1)*O(logn) = O(nlogn)

在这里插入图片描述

  1. 空间复杂度:O(1)

  2. 稳定性:不稳定

    父子结点交换元素,可能会导致长距离的交换。

2. 查找

2.1 二分查找

二分查找(Binary search),也称为折半查找,是一种在有序数组中查找某一特定元素的算法。

二分查找的步骤如下:

  1. 查找过程从数组的中间元素开始,如果中间元素正好是要查找的元素,则查找过程结束。
  2. 如果查找的元素小于中间元素,则在左半区间查找;否则在右半区间查找。
  3. 这种查找算法每一次比较都会使区间缩小一半,直至找到元素,或者区间为空。

很显然,二分查找的时间复杂度为 O(logn)。

代码实现
// 二分查找(循环实现)
int binary_search(int arr[], int n, int key) {
    int left = 0, right = n - 1;
    int mid = 0;

    while (left <= right) {
        mid = left + ((right - left) >> 1);
        if (arr[mid] == key) {
            return mid;
        }

        else if (arr[mid] < key) {
            left = mid + 1;
        }

        else {
            right = mid - 1;
        }
    }

    return -1; // 没找到
}

二分查找一般都用循环的方式实现。写二分查找时注意以下几点,就能轻松写出 bug free 的代码。

  1. 条件表达式是 left <= right

    left = right 时,说明区间内还有一个元素,该元素也要和 key 值进行比较。

  2. 计算 mid 时,注意避免发生溢出。

    mid = (left + right) / 2 可能会发生溢出,应该写成 mid = left + ((right - left) >> 1)

  3. left 和 right 的更新。

注意不能写成 left = mid ; right = mid ; ,这样可能会导致无限循环

(why? while 循环的条件left <= right 永远满足,永远不会退出循环。如果数组中没有目标元素,那么就会陷入无限循环)

应该写成 left = mid + 1; right = mid - 1; ,中间元素已经和 key 值比较过了。

二分查找的变种
  1. 查找第一个与 key 相等的元素
  2. 查找最后一个与 key 相等的元素
  3. 查找第一个大于等于 key 值的元素
  4. 查找最后一个小于等于 key 值的元素

应用:IP归属地查询

假设有 1000 万条这样的数据,给定一个 IP 地址,如何快速地找到它的归属地?

相关文章:

  • 【Linux开发工具】调试器-gdb
  • 【动态路由】系统Web URL资源整合系列(后端技术实现)【nodejs实现】
  • 代码随想录算法【Day46】
  • PHP处理大文件上传
  • 搜广推校招面经十六
  • es和kibana安装
  • WEB安全--SQL注入--堆叠注入
  • 53倍性能提升!TiDB 全局索引如何优化分区表查询?
  • 关系数据库标准语言SQL
  • SQL语句语法
  • 【Java】xxl-job
  • print(f“Random number below 100: {random_number}“)的其他写法
  • 【Linux】:网络协议
  • c++--变量内存分配
  • C语言进阶习题【3】5 枚举——找单身狗2
  • Pytest快速入门
  • 【MySQL】第五弹---数据类型全解析:从基础到高级应用
  • Linux 上安装 PostgreSQL
  • AI时代:架构师的困境与救赎
  • 计时器任务实现(保存视频和图像)
  • “复旦源”一源六馆焕新启幕,设立文化发展基金首期1亿元
  • 大学2025丨北大教授陈平原:当卷不过AI时,何处是归途
  • 家国万里·时光故事会|构筑中国船舰钢筋铁骨,她在焊花里展现工匠风范
  • 一旅客因上错车阻挡车门关闭 ,株洲西高铁站发布通报
  • 张巍任中共河南省委副书记
  • 中国社联成立95周年,《中国社联期刊汇编》等研究丛书出版