排序与查找算法(C语言实现)
文章目录
- 排序与查找算法
- 1. 排序
- 1.1 如何评估一个排序算法
- 1.2 选择排序
- 代码实现
- 分析
- 1.3 冒泡排序
- 代码实现
- 分析
- 1.4 插入排序
- 代码实现
- 分析
- 1.5 希尔排序
- 代码实现
- 分析
- 1.6 归并排序
- 代码实现
- 分析
- 1.7 快速排序
- 代码实现
- 分析
- 改进策略
- 1.8 堆排序
- 代码实现
- 分析
- 2. 查找
- 2.1 二分查找
- 代码实现
- 二分查找的变种
排序与查找算法
排序的前提是"比较",排序的目的往往是"查找"。这一章,我们主要给大家介绍一些经典的基于比较的排序算法,以及经常被人低估的二分查找算法。
1. 排序
1.1 如何评估一个排序算法
我们可以从三个维度去分析一个排序算法:时间复杂度、空间复杂度和稳定性。
-
时间复杂度
- 最好情况
- 最坏情况
- 平均情况
- 系数和低阶项
-
空间复杂度
我们需要重点关注原地排序(store in place),也就是空间复杂度为 O(1) 的排序。
-
稳定性
数据集中"相等"的元素,如果排序前和排序后的相对次序不变,那么这个排序算法就是稳定的。稳定性是排序算法一个很重要的指标。
我们通过一个例子来说明这个问题:在电商系统中,经常需要查看订单数据,以做数据统计。现在有这样一个需求,先对订单按金额从大到小排序,如果金额相同,再按下单时间从大到小排序。 解决思路:先按下单时间从大到小排序,再用稳定的排序算法,按金额从大到小排序。(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;
}
}
分析
- 时间复杂度:O(n^2)
- 空间复杂度:O(1)
- 稳定性:稳定
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;
}
}
}
}
分析
- 时间复杂度:O(n^2)
- 空间复杂度:O(1)
- 稳定性:稳定
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;
}
}
分析
- 时间复杂度:O(n^2)
- 空间复杂度:O(1)
- 稳定性:稳定
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;
}
}
分析
- 时间复杂度:O(n^2),一般来说小于O(n^2)
- 空间复杂度:O(1)
- 稳定性:不稳定。因为会长距离的交换元素,因此希尔排序是不稳定的。一般来说,希尔排序的性能优于简单的插入排序,但它牺牲了稳定性。
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);
}
分析
-
时间复杂度 O(nlogn)
T(n) = 2T(n/2) + O(n)
如下图所示,每一层的所用的时间为 cn (归并操作所用的时间,其中 c 为常数项),总共有 log2n 层。
-
空间复杂度:O(n)
所用空间:tmp数组需要O(n)的空间,栈的深度为O(lgn)。因此总的空间复杂度为 O(n) + O(lgn) = O(n)。
-
稳定性:稳定
当左边区间最前面的元素和右边区间最前面的元素相等时,我们放入的是左边区间的元素,因此归并排序是稳定的。
1.7 快速排序
快速排序(Quick sort)是建立在分区操作上的一种高效的排序算法。快速排序也是分治思想的一种典型应用。
快速排序的步骤如下:
- 挑选基准值:从数列中挑选一个元素作为"基准值"。
- 分区:将比基准值小的元素排列在数列的前端,将比基准值大的元素排列到数列的尾端,与基准值相等的元素可以放到任意一段。分区操作之后,基准值就已经就位了。
- 递归排序子序列:递归地将小于基准值元素的子序列和大于基准值元素的子序列排序。
分区算法有多种,其中一种如下图所示(基准值为 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);
}
分析
- 时间复杂度:
- 最好情况:每次分区都可以分为大小相等的两份。O(nlogn) T(n) = 2T(n/2) + O(n) 分析方法和归并排序一致,这里不再赘述。
- 最坏情况:每次分区,基准值最终都位于数列的两端。O(n^2)
- 空间复杂度:O(logn) 平均情况下,栈的深度为 O(logn)。
- 稳定性:在分区操作中,选取基准值后,我们会将其和最右边的元素进行交换。这可能是一次长距离的交换,因此快速排序是不稳定的。
改进策略
快速排序算法虽然最坏情况下的时间复杂度是 O(n2),但是平均情况下时间复杂度是 O(nlogn)。不仅如此,快速排序退化到O(n2)的概率极小,我们还通过合理地选择基准值来避免这种情况发生。
- 选取基准值
- 随机选取
- 选择 3 个或 5 个元素,然后选取其中位数作为基准值。
- 当元素个数比较少时(比如小于等于32个元素),采用插入排序。
- 每次分区分为三个区域:pivot。当相等的元素很多时,这种分区方法可以极大地提高快速排序的效率。
- 。。。
1.8 堆排序
参考文章:堆排序算法C++实现(以及子节点下标是父节点两倍的证明) - 知乎
堆排序(Heapsort)是指利用堆这种数据结构所设计的一种排序算法。
堆是一个近似完全二叉树的结构,并同时满足堆的性质:即子结点的 key 值总是小于或大于它的父结点的 key 值。
堆排序算法的步骤如下:
-
构建大顶堆
找到第一个非叶子结点,从后往前构建大顶堆。
-
将堆顶元素和无序区的最后一个元素交换,并将无序区的长度减 1。
-
将无序区重新调整成大顶堆。
-
重复步骤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);
}
}
分析
-
时间复杂度:O(nlogn)
建堆操作的时间复杂度为O(n)(why? 见下图),
heapify
操作的时间复杂度为O(logn),循环的次数为 n-1。T(n) = O(n) + (n-1)*O(logn) = O(nlogn)
-
空间复杂度:O(1)
-
稳定性:不稳定
父子结点交换元素,可能会导致长距离的交换。
2. 查找
2.1 二分查找
二分查找(Binary search),也称为折半查找,是一种在有序数组中查找某一特定元素的算法。
二分查找的步骤如下:
- 查找过程从数组的中间元素开始,如果中间元素正好是要查找的元素,则查找过程结束。
- 如果查找的元素小于中间元素,则在左半区间查找;否则在右半区间查找。
- 这种查找算法每一次比较都会使区间缩小一半,直至找到元素,或者区间为空。
很显然,二分查找的时间复杂度为 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 的代码。
-
条件表达式是
left <= right
。left = right
时,说明区间内还有一个元素,该元素也要和 key 值进行比较。 -
计算 mid 时,注意避免发生溢出。
mid = (left + right) / 2
可能会发生溢出,应该写成mid = left + ((right - left) >> 1)
-
left 和 right 的更新。
注意不能写成 left = mid ; right = mid ;
,这样可能会导致无限循环
(why? while 循环的条件left <= right
永远满足,永远不会退出循环。如果数组中没有目标元素,那么就会陷入无限循环)
应该写成 left = mid + 1; right = mid - 1;
,中间元素已经和 key 值比较过了。
二分查找的变种
- 查找第一个与 key 相等的元素
- 查找最后一个与 key 相等的元素
- 查找第一个大于等于 key 值的元素
- 查找最后一个小于等于 key 值的元素
应用:IP归属地查询
假设有 1000 万条这样的数据,给定一个 IP 地址,如何快速地找到它的归属地?