赞
踩
文章转载自:https://blog.fiteen.top/2019/sorting-algorithm, 仅作学习使用~
由于待排序的元素数量不同,使得排序过程中涉及的存储器不同,可将排序方法分为两类:
我们可以将常见的内部排序算法可以分成两类:
名词解释:
时间/空间复杂度:描述一个算法执行时间/占用空间与数据规模的增长关系
n:待排序列的个数
r:“桶”的个数(上面的三种非比较类排序都是基于“桶”的思想实现的)
d:待排序列的最高位数
In-place:原地算法,指的是占用常用内存,不占用额外内存。空间复杂度为 O(1) 的都可以认为是原地算法
Out-place:非原地算法,占用额外内存
稳定性:假设待排序列中两元素相等,排序前后这两个相等元素的相对位置不变,则认为是稳定的。
交换函数(后面排序会用到):
// 交换函数
void swap(int *arr, int i, int j){
int tmp = 0;
tmp = arr[i];
arr[i] = arr[j];
arr[j] = tmp;
}
冒泡排序(Bubble Sort),顾名思义,就是指越小的元素会经由交换慢慢“浮”到数列的顶端。
算法原理
动图演示
代码实现
void bubble_sort(int *arr, int n)
{
int Cnt, Idx = 0;
//10个数排9次
for (Cnt = 0; Cnt < n - 1; Cnt++)
{ //每次排序之前排过的数在最后就不用比了
for (Idx = 0; Idx < n - Cnt - 1; Idx++)
{
if (arr[Idx] > arr[Idx + 1])
{
swap(arr, Idx, Idx + 1);
}
}
}
}
算法分析
冒泡排序属于交换排序,是稳定排序,
但是我们常看到冒泡排序的最优时间复杂度是 O(n),那要如何优化呢?
我们可以用一个 flag 参数记录新一轮的排序中元素是否做过交换,如果没有,说明前面参与比较过的元素已经是正序,那就没必要再从头比较了。代码实现如下:
//一句话总结:从左至右,两两比较,每趟排序后 剩下里面最大的数都会跑到末尾 void bubble_sort(int *arr, int len){ int i, j; int flag = 1;//标志是否发生交换 for (i = len - 1; i > 0; i--){//控制遍历次数 和 每趟排序的结尾 for (j = 0; j < i; j++){ if(arr[j] > arr[j+1]) { swap(arr, j, j + 1); flag = 0; } if (flag) return; } } }
快速排序(Quick Sort),是冒泡排序的改进版,之所以“快速”,是因为使用了分治法。它也属于交换排序,通过元素之间的位置交换来达到排序的目的。
算法原理
在序列中随机挑选一个元素作基准,将小于基准的元素放在基准之前,大于基准的元素放在基准之后,再分别对小数区与大数区进行排序。
一趟快速排序的具体做法是:
仔细研究一下上述算法我们会发现,在排序过程中,对基准的移动其实是多余的,因为只有一趟排序结束时,也就是 i = j 的位置才是基准的最终位置。
由此可以优化一下算法:
动图演示
代码实现
这里取序列的第一个元素为基准。
//递归 原地in-place int select_pivot(int *arr, int low){ return arr[low];//直接以起始点为基准 } void quick_sort(int *arr, int low, int high){//low,high代表区间两端 int i = low, j = high, pivot; //递归终止条件 if (low >= high) return; pivot = select_pivot(arr, low); while(i != j) { //因为基准是区间左起点,所以先找小的值,这样i==j时,一定停在小的值上,而可直接和基准交换 while (arr[j] >= pivot && i < j) j--; while(arr[i] <= pivot && i < j) i++; if (i < j) swap(arr, i, j);//交换 } arr[low] = arr[i]; arr[i] = pivot; //递归排序左右基准两个子区间 quick_sort(arr, low, i - 1); quick_sort(arr, i + 1, high); }
算法分析
快速排序是不稳定排序,
快速排序中,基准的选取非常重要,它将影响排序的效率。举个例子,假如序列本身顺序随机,快速排序是所有同数量级时间复杂度的排序算法中平均性能最好的,但如果序列本身已经有序或基本有序,直接选取固定位置,例如第一个元素作为基准,会使快速排序就会沦为冒泡排序,时间复杂度为 O(n²)。为了避免发生这种情况,引入下面两种获取基准的方法:
随机选取
就是选取序列中的任意一个数为基准的值。
int select_pivot_radom(int *arr, int low, int high)
{
srand((unsigned)time(NULL));
int pivot = rand() % (high - low) + low;
swap(arr, low, pivot);
return arr[low];
}
三者取中
就是取起始位置、中间位置、末尾位置指向的元素,对这三个元素排序后取中间数作为基准。
int select_pivot_midian_of_three(int *arr, int low, int high)
{
// 计算数组中间的元素的下标
int mid = low + ((high - low) >> 1);
// 排序,使 arr[mid] <= arr[low] <= arr[high]
if (arr[mid] > arr[high]) swap(arr, mid, high);
if (arr[low] > arr[high]) swap(arr, low, high);
if (arr[mid] > arr[low]) swap(arr, low, mid);
// 使用 low 位置的元素作为基准
return arr[low];
}
经验证明,三者取中的规则可以大大改善快速排序在最坏情况下的性能。
递归实现(python)
# 快速排序:递归 写法(需要额外空间out-place)
def quick_sort(array):
# 终止/基线条件
if len(array) < 2:
return array
else:
#递归条件
pivot = array[0] #基准数
less = [i for i in array[1:] if i <= pivot] #额外空间存储比pivot小的子区间
greater = [i for i in array[1:] if i > pivot]
return quick_sort1(less) + [pivot] + quick_sort1(greater)
直接插入排序(Straight Insertion Sort),是一种简单直观的排序算法,它的基本操作是不断地将尚未排好序的数插入到已经排好序的部分,好比打扑克牌时一张张抓牌的动作。在冒泡排序中,经过每一轮的排序处理后,序列后端的数是排好序的;而对于插入排序来说,经过每一轮的排序处理后,序列前端的数都是排好序的。
算法原理
先将第一个元素视为一个有序子序列,然后从第二个元素起逐个进行插入,直至整个序列变成元素非递减有序序列为止。如果待插入的元素与有序序列中的某个元素相等,则将待插入元素插入大相等元素的后面。整个排序过程进行 n-1 趟插入。
动图演示
代码实现
//========================写法1======================= void insert_sort(int *arr, int len){ int i, j; for (i = 1; i < len; i++){ int tmp = arr[i];//待插入数 for (j = i; j > 0 && arr[j - 1] > tmp; j--){ arr[j] = arr[j - 1];//大的数依次右移 } arr[j] = tmp; } } //==========================写法2====================== void insert_sort_1(int *arr, int n) { int i = 0, j = 0; for (i = 1; i < n; i++) { j = i; //一直拿当前数与前面数比,有<=它的就停止,没有接着交换位置并往前比 while (j >= 1) { if (arr[j] < arr[j - 1]) { swap(arr, j, j - 1); } j--; } } }
算法分析
插入排序是稳定排序,
希尔排序(Shell’s Sort)是第一个突破 O(n²) 的排序算法,是直接插入排序的改进版,又称“缩小增量排序”(Diminishing Increment Sort)。它与直接插入排序不同之处在于,它会优先比较距离较远的元素。
算法原理
先将整个待排序列分割成若干个子序列分别进行直接插入排序,待整个序列中的记录“基本有序”时,再对全体记录进行一次直接插入排序。
子序列的构成不是简单地“逐段分割”,将相隔某个增量的记录组成一个子序列,让增量逐趟缩短,直到增量为 1 为止。
动图演示
代码实现
增量序列可以有各种取法,例如上面动图所示,增量序列满足 [n / 2, n / 2 / 2, …, 1],n 是序列本身的长度,这也是一种比较流行的增量序列定义方式。这时希尔排序的算法可以通过下面的代码实现:
void shell_sort(int *arr, int len){
int i, j, gap, tmp;
for (gap = len >> 1; gap > 0; gap = gap >> 1){//gap依次减小
for (i = gap; i < len; i++){//每次插排右边待插入的数
tmp = arr[i];
//这段就是插排,只是每次和前面相距gap长的数比,不是和前面每一个比
for (j = i; j >= gap && arr[j - gap] > tmp; j -= gap)//注意边界条件的判断
{
arr[j] = arr[j - gap];
}
arr[j] = tmp;
}
}
}
增量序列也可以有其它的定义方式,那么希尔排序的实现可以归纳成这样:
void shell_insert(int arr[], int n, int dk) { int i, j, temp; for (i = dk; i < n; i += dk) { temp = arr[i]; j = i - dk; while (j >= 0 && temp < arr[j]) { arr[j + dk] = arr[j]; j -= dk; } arr[j + dk] = temp; } } void shell_sort(int arr[], int n, int dlta[], int t) { int k; for (k = 0; k < t; ++k) { // 一趟增量为 dlta[k] 的插入排序 shell_insert(arr, n, dlta[k]); } }
算法分析
希尔排序是不稳定排序,它的分析是一个复杂的问题,因为它的运行时间依赖于增量序列的选择,它的平均时间复杂度为 O(n^1.3),最好情况是 O(n),最差情况是 O(n²)。空间复杂度为 O(1)。
选择排序(Selection Sort)是一种简单直观的排序算法。它的基本思想就是,每一趟 n-i+1(i=1,2,…,n-1) 个记录中选取关键字最小的记录作为有序序列的第 i 个记录。
算法原理
简单选择排序:
动图演示
代码实现
void select_sort(int *arr, int n) { int i, j; for (i = 0; i < n - 1; i++) //排n-1趟即可,前面排完最后一个数必为有序 { int min = i; //min为当前趟最小值下标,初始为第i个,依次往后比,找到小的就交换 for (j = i + 1; j < n; j++) { if(arr[min] > arr[j]) { min = j; //找到当前趟最小值的下标 } } swap(arr, min, i); } }
算法分析
选择排序是不稳定排序,
堆排序(Heap Sort)是指利用堆这种数据结构所设计的一种排序算法。堆的特点:
算法原理
实现堆排序需要解决两个问题:
以升序为例,算法实现的思路为:
动图演示
代码实现
//将数组转化成大顶堆/小顶堆的形式,然后每次把堆顶元素放在数组最后,再堆化,循环至数组完全有序 //已知父节点下标i,则左子节点下标2*i+1,右子节点下标2*i+2; //已知孩子节点下标i,则其父节点下标为(i-1)/2 结果直接取整(小数位丢弃) //调整剩余元素,构成大顶堆(全是根据遍历父节点来调整每一个二叉部分) void heapify(int tree[], int n, int parent){ //n 表示序列长度,i 表示父节点下标 if (parent >= n) return; //左侧子节点下标 int left = 2 * parent + 1; //右侧子节点下标 int right = 2 * parent + 2; int max = parent; if (left < n && tree[left] > tree[max]) max = left; if (right < n && tree[right] > tree[max]) max = right; if (max != parent){//如果最大值不是当前父节点,则交换最大值到上面,并继续递归调用调整下面相连的子二叉树 swap(tree, max, parent); heapify(tree, n, max); //递归调整被交换的下一个父亲节点 } } //将数组下标当作完全二叉树每个节点下标来处理(从左至右,从上至下排序号) void build_heap(int tree[], int n){ //树最后一个节点下标 int last_node = n - 1; //确定最后一个节点 的 父节点下标 int parent = (last_node - 1) / 2; //开始从最后一个父节点开始,往前遍历每个父节点,并调整值的顺序,最终当前最大值会到堆顶 int i; //第一次构建大顶堆需要遍历所有父节点,后面每次调整只要传入根节点即可(里面会递归调整发生值改变的所在子二叉树) for (i = parent; i >= 0; i--){ heapify(tree, n, i); } } void heap_sort(int tree[], int n){ //构建堆 build_heap(tree, n); int i; //i代表最后一个元素下标,也是当前未调整的数组长度 //每次将堆化好的堆顶元素放到最后,并调整剩下的元素 for (i = n - 1; i >= 0; i--){ // 将堆顶元素与最后一个元素交换 swap(tree, i, 0); //调整大顶堆(剩下的元素),永远保证大顶堆有序 heapify(tree, i, 0); //构建好堆以后 每次调整,只要传入根节点0即可 } }
算法分析
堆排序是不稳定排序,适合数据量较大的序列,它的
堆排序仅需一个记录大小供交换用的辅助存储空间。
归并排序(Merge Sort)是建立在归并操作上的一种排序算法。它和快速排序一样,采用了分治法。
算法原理
归并的含义是将两个或两个以上的有序表组合成一个新的有序表。也就是说,从几个数据段中逐个选出最小的元素移入新数据段的末尾,使之有序。
那么归并排序的算法我们可以这样理解:
假如初始序列含有 n 个记录,则可以看成是 n 个有序的子序列,每个子序列的长度为 1。然后两两归并,得到 n/2 个长度为 2 或 1 的有序子序列;再两两归并,……,如此重复,直到得到一个长度为 n 的有序序列为止,这种排序方法称为 二路归并排序,下文介绍的也是这种排序方式。
动图演示
代码实现
void merge(int *arr, int L, int M, int R){ int LEFT_SIZE = M - L + 1; int RIGHT_SIZE = R - M; int *left = (int *)malloc(LEFT_SIZE * sizeof(int));//开辟左子数组 memset(left, 0, LEFT_SIZE * sizeof(int)); int *right = (int *)malloc(RIGHT_SIZE * sizeof(int));//开辟右子数组 memset(right, 0, RIGHT_SIZE * sizeof(int)); int i, j, k; // 以 M 为分割线,把原数组分成左右子数组 for (i = L; i <= M; i++) left[i - L] = arr[i]; for (j = M + 1; j <= R; j++) right[j - M - 1] = arr[j]; // 再合并成一个有序数组(从两个序列中选出最小值依次插入) i = 0; j = 0; k = L; while (i < LEFT_SIZE && j < RIGHT_SIZE) arr[k++] = left[i] < right[j] ? left[i++] : right[j++]; while (i < LEFT_SIZE) arr[k++] = left[i++]; while (j < RIGHT_SIZE) arr[k++] = right[j++]; //释放内存 free(left); left = NULL; free(right); right = NULL; } void merge_sort(int *arr, int L, int R){ if (L == R) return; //递归终止条件,分无可分 // 将 arr[L..R] 平分为 arr[L..M] 和 arr[M+1..R] int M = (L + R) >> 1; //(分别递归地将子序列排序为有序数列)先分割到最小子区间,再反过来一步步合并成一个完整有序数组 merge_sort(arr, L, M);//左边 merge_sort(arr, M + 1, R);//右边 // 将两个排序后的子序列再归并到 arr merge(arr, L, M, R); }
算法分析
归并排序是稳定排序,它和选择排序一样,性能不受输入数据的影响,但表现比选择排序更好,它的
桶排序(Bucket sort)是计数排序的升级版。它利用了函数的映射关系,高效与否的关键就在于这个映射函数的确定。
桶排序的工作的原理:假设输入数据服从均匀分布,将数据分到有限数量的桶里,每个桶再分别排序(也有可能是使用别的排序算法或是以递归方式继续用桶排序进行排序)。
算法原理
动图演示
代码实现
void bucket_sort(int arr[], int n, int r) { if (arr == NULL || r < 1) return; // 根据最大/最小元素和桶数量,计算出每个桶对应的元素范围 int max = arr[0], min = arr[0]; int i, j; for (i = 1; i < n; i++) { if (max < arr[i]) max = arr[i]; if (min > arr[i]) min = arr[i]; } int range = (max - min + 1) / r + 1; // 建立桶对应的二维数组,一个桶里最多可能出现 n 个元素 int buckets[r][n]; memset(buckets, 0, sizeof(buckets)); int counts[r]; memset(counts, 0, sizeof(counts)); for (i = 0; i < n; i++) { int k = (arr[i] - min) / range; buckets[k][counts[k]++] = arr[i]; } int index = 0; for (i = 0; i < r; i++) { // 分别对每个非空桶内数据进行排序,比如计数排序 if (counts[i] == 0) continue; counting_sort(buckets[i], counts[i]); // 拼接非空的桶内数据,得到最终的结果 for (j = 0; j < counts[i]; j++) { arr[index++] = buckets[i][j]; } } }
算法分析
桶排序是稳定排序,但仅限于桶排序本身,假如桶内排序采用了快速排序之类的非稳定排序,那么就是不稳定的。
桶排序的时间复杂度可以这样看:
n 次循环,每个数据装入桶
r 次循环,每个桶中的数据进行排序(每个桶中平均有 n/r 个数据)
假如桶内排序用的是选择排序这类时间复杂度较高的排序,整个桶排序的时间复杂度就是 O(n)+O(n²),视作 O(n²),这是最差的情况;
假如桶内排序用的是比较先进的排序算法,时间复杂度为 O(nlogn),那么整个桶排序的时间复杂度为 O(n)+O(r*(n/r)*log(n/r))=O(n+nlog(n/r))。k=nlog(n/r),桶排序的平均时间复杂度为 O(n+k)。当 r 接近于 n 时,k 趋近于 0,这时桶排序的时间复杂度是最优的,就可以认为是 O(n)。也就是说如果数据被分配到同一个桶中,排序效率最低;但如果数据可以均匀分配到每一个桶中,时间效率最高,可以线性时间运行。但同样地,桶越多,空间就越大。
占用额外内存,需要创建 r 个桶的额外空间,以及 n 个元素的额外空间,所以桶排序的空间复杂度为 O(n+r)。
计数排序(Counting Sort)是一种非比较性质的排序算法,利用了桶的思想。它的核心在于将输入的数据值转化为键存储在额外开辟的辅助空间中,也就是说这个辅助空间的长度取决于待排序列中的数据范围。
如何转化成桶思想来理解呢?我们设立 r 个桶,桶的键值分别对应从序列最小值升序到最大值的所有数值。接着,按照键值,依次把元素放进对应的桶中,然后统计出每个桶中分别有多少元素,再通过对桶内数据的计算,即可确定每一个元素最终的位置。
算法原理
动图演示
代码实现
// 计数排序,a是数组,n是数组大小。假设数组中存储的都是非负整数。 void countingSort(int* a, int n){ if (n < 1) return; //查找数组中数据范围 int max = a[0]; for (int i = 1; i < n; i++) { if(max < a[i]) max = a[i]; } //存值出现次数的数组 int* c = (int*)malloc(max + 1); memset(c, 0,sizeof(int)*(max+1)); //计算每个元素个数,存入数组c中,下标是待排序值,内容是出现次数 for (int i = 0; i < n; i++) { c[a[i]]++; } //依次累加,那c中 当前内容-1 就为排序的数组下标 for (int i = 1; i < n; i++) { c[i] = c[i-1] + c[i]; } // 临时数组r,存储排序之后的结果 int* r = (int*)malloc(n); //从后向前在c中 取值-1 当作下标存入排序数组r中 for (int i = n - 1; i >= 0; --i) { int index = c[a[i]] - 1; r[index] = a[i]; c[a[i]]--;//取出来一个就个数少一个 } //结果拷贝到原始a数组 for (int i = 0; i < n; i++){ a[i] = r[i]; } }
算法分析
计数排序属于非交换排序,是稳定排序,适合数据范围不显著大于数据数量的序列。
它的时间复杂度是线性的,为 O(n+r),r 表示待排序列中的数据范围,也就是桶的个数。可以这样理解:将 n 个数据依次放进对应的桶中,再从 r 个桶中把数据按顺序取出来。
占用额外内存,还需要 r 个桶,因此空间复杂度是 O(n+r),计数排序快于任何比较排序算法,但这是通过牺牲空间换取时间来实现的。
基数排序(Radix Sort)是非比较型排序算法,它和计数排序、桶排序一样,利用了“桶”的概念。基数排序不需要进行记录关键字间的比较,是一种借助多关键字排序的思想对单逻辑关键字进行排序的方法。比如数字 100,它的个位、十位、百位就是不同的关键字。
那么,对于一组乱序的数字,基数排序的实现原理就是将整数按位数(关键字)切割成不同的数字,然后按每个位数分别比较。对于关键字的选择,有最高位优先法(MSD 法)和最低位优先法(LSD 法)两种方式。MSD 必须将序列先逐层分割成若干子序列,然后再对各子序列进行排序;而 LSD 进行排序时,不必分成子序列,对每个关键字都是整个序列参加排序。
算法原理
以 LSD 法为例:
如果要支持负数参加排序,可以将序列中所有的值加上一个常数,使这些值都成为非负数,排好序后,所有的值再减去这个常数。
动图演示
代码实现
// 基数,范围0~9 #define RADIX 10 void radix_sort(int arr[], int n) { // 获取最大值和最小值 int max = arr[0], min = arr[0]; int i, j, l; for (i = 1; i < n; i++) { if (max < arr[i]) max = arr[i]; if (min > arr[i]) min = arr[i]; } // 假如序列中有负数,所有数加上一个常数,使序列中所有值变成正数 if (min < 0) { for (i = 0; i < n; i++) arr[i] -= min; max -= min; } // 获取最大值位数 int d = 0; while (max > 0) { max /= RADIX; d ++; } int queue[RADIX][n]; memset(queue, 0, sizeof(queue)); int count[RADIX] = {0}; for (i = 0; i < d; i++) { // 分配数据 for (j = 0; j < n; j++) { int key = arr[j] % (int)pow(RADIX, i + 1) / (int)pow(RADIX, i); queue[key][count[key]++] = arr[j]; } // 收集数据 int c = 0; for (j = 0; j < RADIX; j++) { for (l = 0; l < count[j]; l++) { arr[c++] = queue[j][l]; queue[j][l] = 0; } count[j] = 0; } } // 假如序列中有负数,收集排序结果时再减去前面加上的常数 if (min < 0) { for (i = 0; i < n; i++) arr[i] += min; } }
算法分析
基数排序是稳定排序,适用于关键字取值范围固定的排序。
基数排序可以看作是若干次“分配”和“收集”的过程。假设给定 n 个数,它的最高位数是 d,基数(也就是桶的个数)为 r,那么可以这样理解:共进行 d 趟排序,每趟排序都要对 n 个数据进行分配,再从 r 个桶中收集回来。所以算法的时间复杂度为 O(d(n+r)),在整数的排序中,r = 10,因此可以简化成 O(dn),是线性阶的排序。
占用额外内存,需要创建 r 个桶的额外空间,以及 n 个元素的额外空间,所以基数排序的空间复杂度为 O(n+r)。
这三种排序算法都利用了桶的概念,但对桶的使用方法上有明显差异:
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。