数据结构基础排序算法
选择排序
选择排序的基本思路:从待排序元素中选取最大(或最小)的一个元素加入到已完成排序的末尾。
#include <stdio.h>#define ARR_LEN(arr) (sizeof(arr) / sizeof(arr[0])) #define SWAP(arr, i, j ) { \ int tmp = arr[i]; \ arr[i] = arr[j]; \ arr[j] = tmp; \ }void print_arr(int arr[], int len) {for (int i = 0; i < len; i++) {printf("%d ", arr[i]);}printf("\n"); }// 选择排序 void selection_sort(int arr[], int len) {/* * i表示未排序序列的开头元素 * 最后一轮选择排序时, 未排序序列的开头元素是数组倒数第二个元素 * i的每个取值都表示一轮选择排序 * 也就是选择排序一共执行9趟 */for (int i = 0; i < len - 1; i++) {// 不妨直接假设未排序序列的开头i位置元素就是最小值int min_index = i;// 遍历未排序数组序列,找出真正的最小值下标,此时应遍历最后一个元素for (int j = i + 1; j < len; j++) {if (arr[j] < arr[min_index]) {min_index = j; // 记录较小值的下标}} // for循环结束时,未排序序列的最小值下标就是min_index// 交换min_index和下标i的元素SWAP(arr, min_index, i);// 选择排序一趟打印一次数组print_arr(arr, len);} }int main(void) {// 测试选择排序int arr[] = { 1,10,2,5,3,4,5,6,3,2 };int len = ARR_LEN(arr);selection_sort(arr, len);return 0; }
时间复杂度分析:选择排序的时间复杂度是O(n2),任何情况下都一样。
空间复杂度分析:选择排序是一种原地排序算法,不需要占用额外内存空间。空间复杂度是O(1)
稳定性分析:选择排序在每一轮中选择最小元素,并与未排序部分的第一个元素交换。如果存在相等的元素,选择排序可能会改变它们的相对顺序。所以选择排序不是一个稳定的排序算法。
冒泡排序
基本思路:将未排序算法中的元素,从头到尾两两比较,将最大的元素不断后移到已经排序元素的头部(按从小到大排序)。
工作原理如下(按照从小到大排序):
- 第一轮冒泡排序:从数组的第一个元素开始,比较相邻的元素。如果第一个元素比第二个元素大,则交换它们的位置。然后,移动到下一对相邻元素,重复这个过程,直到比较最后一对元素。每一轮冒泡排序都会使当前比较序列的最大值到达数组末尾,随后第二轮排序过程中,需要比较的元素就减1。(将尾部最大的元素减去不再排序)
- 第二轮冒泡排序:重复第一轮的过程,但这次只比较和交换直到倒数第二个元素(因为最后一个元素已经是最大的了)。在这一轮结束时,倒数第二大的元素会被“冒泡”到倒数第二的位置。
- ...
结束条件:
- 在不设置任何额外结束条件的前提下,冒泡排序每一轮都会将未排序序列的最大值"冒泡"到末尾。冒泡排序需要进行固定的(n - 1)轮!
- 但实际上在这(n - 1)轮冒泡排序的过程中,只要某一轮完全不存在元素的交换,就说明数组已经完全有序了,排序就可以结束了。
- 所以我们可以设定一个布尔值来标记此轮冒泡排序是否存在元素交换,如果没有元素交换,直接结束整个排序。这种做法可以优化冒泡排序的性能,尤其是当原数组已基本有序时。
#include <stdio.h> #include <stdbool.h>#define ARR_LEN(arr) (sizeof(arr) / sizeof(arr[0]))#define SWAP(arr, i, j ) { \int tmp = arr[i]; \arr[i] = arr[j]; \arr[j] = tmp; \ }void print_arr(int arr[], int len) {for (int i = 0; i < len; i++) {printf("%d ", arr[i]);}printf("\n"); }void bubble_sort(int arr[], int len) {// 外层for的i表示第几轮冒泡排序,最多需要进行len-1轮for (int i = 1; i < len; i++) {// 标记在这一次冒泡排序中有没有交换,false表示没有交换bool swapped = false;/** j表示两两比较元素中的第一个元素的下标* 第一轮j的最大取值是数组倒数第二个元素,并且逐步减小* 所以j的条件是小于 (len - i)*/for (int j = 0; j < len - i; j++) {if (arr[j] > arr[j + 1]) {SWAP(arr, j, j + 1);// 发生了交换改变标记swapped = true;}}// 在一轮冒泡排序中没有任何交换,则排序已经完成,终止循环if (!swapped) {break;}// 打印一轮冒泡排序后数组的元素排列print_arr(arr, len);} }int main(void) {// 测试冒泡排序int arr[] = { 16, 1, 45, 23, 99, 2, 18, 67, 42, 10 };int len = ARR_LEN(arr);bubble_sort(arr, len);return 0; }
需要注意的是:
在上述参考代码中使用了"swapped"标记来使得冒泡排序可以在"一轮冒泡没有任何交换时结束"。这在某些场景下,可以提升算法的效率,尤其是最佳情况——输入的数组已经是有序的情况下。
时间复杂度分析:最佳情况下的时间复杂度是 O(n)。最坏情况下的时间复杂度是 O(n2)。平均情况下,时间复杂度也是 O(n2)。
空间复杂度分析:
冒泡排序是一种原地排序算法,不需要占用额外内存空间。空间复杂度是O(1)
稳定性分析:
冒泡排序显然是一种稳定的排序算法,因为交换的过程中不会交换任何两个相同的元素。
插入排序
工作原理是(将数组从小到大排序):
- 以数组的首元素为初始状态:这个初始状态相当于抓到的第一张牌,它默认就是有序的。
- 从数组的第二个元素开始遍历:相当于抓一张牌,然后从小到大整理手牌。
- 比较与交换:将新插入的元素和前面的元素逐一比较,如果新插入元素较小,则交换两个元素,直到完全不可交换,则完成一轮排序。
- 重复步骤2和3,直到步骤2遍历到最后一个元素。
#include <stdio.h>#define SWAP(arr, i, j ) { \int tmp = arr[i]; \arr[i] = arr[j]; \arr[j] = tmp; \ } #define ARR_SIZE(arr) (sizeof(arr) / sizeof(arr[0]))void print_arr(int arr[], int len) {for (int i = 0; i < len; i++) {printf("%d ", arr[i]);}printf("\n"); }// 通过交换元素的方式实现插入排序 void insertion_sort(int arr[], int len) {// 外层for循环中的i表示每轮选择排序新插入元素的下标,也就是"新摸手牌"的下标// 从数组第二个元素开始,后面的每一个元素都相当于"你要摸的手牌"for (int i = 1; i < len; i++) {// 内层for循环的j表示新插入元素需要比较元素的下标,也就是每一张"老手牌"的下标// j从i的前一个位置开始,递减,但不可能成为负数for (int j = i - 1; j >= 0; j--) {if (arr[j] > arr[j + 1]) { // 注意:这里如果加等号就不是稳定的排序算法了// 前面的元素比后面的元素大,交换SWAP(arr, j, j + 1);}else {// 只需要在某一次比较中,发现前一个元素小于等于后一个元素// 那么前面的元素就一定都是排序好的,此时这一轮选择排序结束break;}}// 打印每一轮选择排序后,数组的元素序列print_arr(arr, len);} }int main(void) {int arr[] = { 1, 21, 45, 231, 99, 2, 18, 7, 4, 9 };int len = ARR_SIZE(arr);insertion_sort(arr, len);return 0; }
上面的实现是通过交换元素取值实现排序的,交换是一个比较"重量级"的操作,需要连续三次赋值来完成。所以我们可以用移动元素来取代交换元素,这样可以轻微提升一些性能。思路是:
先使用一个temp临时存储新插入元素,然后和前面的元素逐一比较,若前面的元素较大就向后移动一位,直到比较完前面所有元素或者比较到一个元素比新插入元素小。
#include <stdio.h>#define SWAP(arr, i, j ) { \int tmp = arr[i]; \arr[i] = arr[j]; \arr[j] = tmp; \ } #define ARR_SIZE(arr) (sizeof(arr) / sizeof(arr[0]))void print_arr(int arr[], int len) {for (int i = 0; i < len; i++) {printf("%d ", arr[i]);}printf("\n"); }// 插入排序 优化: 用向后移动腾出插入位置,然后插入实现插入排序 void insertion_sort(int arr[], int len) {// 现在第一个元素就是第一张手牌,从第二个元素开始就是每一次要摸的牌// 外层for循环代表每一轮摸到的新手牌, 也就是每一轮插入排序for (int i = 1; i < len; i++) {// 先记录一下新手牌的值, 便于后续的插入操作int tmp = arr[i];int j = i - 1;for (; j >= 0; j--) {if (arr[j] > tmp) { // 注意:不能加=,加了就不是稳定排序算法了arr[j + 1] = arr[j]; // 将旧手牌中大于新手牌的所有牌都向后移}else{break; // 只要发现一张旧手牌更小或相等, 就说明已经找到新手牌的插入位置了}}/*现在还有一件事情没做:新手牌要插入,需要确定插入位置分析: for循环什么时候结束?两种情况:1.j=-1时,循环结束,说明新手牌是最小的,所以插入到0这个位置,也就是j+12.arr[j] <= tmp 也就是旧手牌更小或相等,此时新手牌放在j+1的位置*/arr[j + 1] = tmp;print_arr(arr, len); // 每一轮摸牌后查看排序后的数组} }int main(void) {int arr[] = { 1, 21, 45, 231, 99, 2, 18, 7, 4, 9 };int len = ARR_SIZE(arr);insertion_sort(arr, len);return 0; }
时间复杂度分析:最佳情况下的时间复杂度是 O(n)。最坏情况下的时间复杂度是 O(n2)。平均情况下,时间复杂度也是 O(n2)。
空间复杂度分析:
插入排序是一种原地排序算法,不需要占用额外内存空间。空间复杂度是O(1)
稳定性分析:
插入排序显然也是一种稳定的排序算法,因为对于两个相同的元素,我们始终都不会交换它们的相对位置。
注意:如果判断的条件改成
arr[j] >= arr[j + 1]
,即在前后元素相等时也交换/移动元素,算法就会变成不稳定的。