排序算法可以说是算法的基础,很多leetcode题目也会有涉及
为了篇幅不至于太长,排序算法原理和leetcode题目分成2篇笔记进行介绍
1.本篇:针对经典的十大排序算法进行介绍
2.下一篇:介绍排序相关的leetcode题目解法
(点击查看)
00.排序前准备
提示:为了方便介绍,下面默认都是按照升序进行排序的;为了节省篇幅,只列出了核心函数,且将实现一个功能的部分代码合并成一行处理了
根据是否利用额外空间?
排序算法可以分为内部排序(In-place)和外部排序(Out-place)
- 内部排序:数据记录在内存中进行排序
- 外部排序:因排序的数据很大,一次不能容纳全部的排序记录,在排序过程中需要访问外存
针对排序后2个相等键值的顺序和排序前顺序是否一致?
对排序算法的稳定性有要求,稳定表示的就是排序前后相对顺序一致
排序算法的简要总结
其中:
n
:数据规模;k
:"桶"的个数
扩展:公共函数
排序算法中有很多交换2个元素的内容的需求,因此,将几种常见的交换函数实现列举如下:
//指针版本 void swap(int *a, int *b){ int temp = *a; *a = *b; *b = temp; } /* 不用系统函数的话,swap有下面3种常见实现 */ //交换数值 //位运算 //指针 int tmp = s[i]; s[i] ^= s[j]; int temp = *a; s[i] = s[j]; s[j] ^= s[i]; *a = *b; s[j] = tmp; s[i] ^= s[j]; *b = temp;
01.冒泡排序
原理:比较相邻的元素,如果第一个比第二个大,就交换他们两个
- 1.对每一对相邻元素作同样操作,从开始第一对到结尾最后一对;
结束后,最后的元素是最大
- 2.针对所有的元素重复以上的步骤,
除了最后一个
- 3.持续每次对越来越少的元素重复上面的步骤,直到没有任何一对数字需要比较
注意:如果是比较相邻2个元素的排序算法,最外层的下标通常到只能取到
len - 2
,因为只有len - 2
对//C //简单理解:外层for用来规定有几对的数进行比较;内层for从右向左,跑完就会排序好一个数 void bubble_sort(int arr[], int len) { for (int i = 0; i < len - 1; i++) //i没有参加比较,只是用来规定次数 for (int j = 0; j < (len - 1) - i; j++) { //每次保证循环的最后一个元素有序 if (arr[j] > arr[j + 1]) swap(&arr[j], &arr[j + 1]); //swap } } //C++ template <typename T> void bubble_sort(T arr[], int len) { for (int i = 0; i < len - 1; i++) for (int j = 0; j < len - 1 - i; j++) { if (arr[j] > arr[j + 1]) swap(arr[j], arr[j + 1]); } }
02.选择排序
总结:遍历未排序部分找合适的元素,放在已排序部分末尾
原理:一句话,未排序中找最小值,放在已排序末尾
1.首先在
未排序序列中找到最小元素
,存放到排序序列的起始位置2.再从剩余未排序元素中继续寻找最小元素,
然后放到已排序序列的末尾
提示:无论什么数据都是 O(n^2) 的时间复杂度,所以用到它时,数据规模越小越好;这个算法时间复杂度很差,且不稳定,除了可以要求,基本不用
//C void selection_sort(int arr[], int len) { for (int i = 0 ; i < len - 1; i++) { //每次将最小元素放在i的位置上,注:i<len-1 int min = i; //***记录位置,暂时将当前置为最小*** for (int j = i + 1; j < len; j++){ //遍历未排序的元素,注:j 的范围 if (arr[j] < arr[min]) min = j; //找到目前最小值,记录下标 } swap(&arr[min], &arr[i]); //交换 } } //C++,当函数必须是static函数 template <typename T> //整数和浮点数皆可使用,若要使用仿函数时必须设定大于(>)的运算子 void selection_sort(std::vector<T>& arr) { for (int i = 0; i < arr.size() - 1; i++) { int min = i; for (int j = i + 1; j < arr.size(); j++) if (arr[j] < arr[min]) min = j; std::swap(arr[i], arr[min]); } }
03.插入排序
总结:遍历已排序部分找合适位置给新元素
原理:一句话,顺序遍历,遇到新元素,找到新元素在前面已排列的合适位置插入
通过构建有序序列,对于未排序数据,在已排序序列中
从后向前扫描
,找到相应位置并插入
步骤:
1.待排序序列第一个元素看做一个有序序列,把第二个元素到最后一个元素当成是未排序序列
2.从头到尾依次扫描未排序序列,将扫描到的每个元素插入有序序列的适当位置(如果待插入的元素与有序序列中的某个元素相等,则将待插入元素插入到相等元素的后面)
void insertion_sort(int arr[], int len) { int i, j, min; for (i = 1; i < len; i++) { //无序数组,从左到右,默认第一个元素有序 min = arr[i]; //无序数组中一个元素 for (j = i; j > 0 && arr[j-1] > min; j--) //有序数组,从右到左,数组里一定不是j-- arr[j] = arr[j-1]; //预留出空位j-1 arr[j] = min; } }
相关题目:LC315_计算右侧小于当前元素的个数(本题好的地方,逆向的插入 + 二分查找优化已排序部分查找)
题目:一个整数数组 nums ,按要求返回一个新数组 counts (
counts[i] 的值是 nums[i] 右侧小于 nums[i] 的元素的数量
)
插入排序思路(超时)
:从后向前
的插入排序,从右向左看是升序 + 已排序部分用二分法优化查找- 不考虑位置,常规思路:升序排序后,新数组的下标就表示有几个元素小于自己
- 只考虑自己右侧的元素:从后向前,不断将后面元素升序排列,间接的得到想要的结果
- 插入排序:顺序遍历,遇到新元素,找到新元素在后面已排列的合适位置插入
- 细节:用一个vector来维护后面已排序的升序列表,便于找下标
vector<int> countSmaller(vector<int>& nums) { vector<int> sorted, res(nums.size()); //插入排序中已排序部分用sorted维护 for (int i = nums.size() - 1; i >= 0; --i) { //从后向前,外层是插入排序 int left = 0, right = sorted.size(); //二分的双指针,左闭右开 while (left < right) { int mid = left + (right - left) / 2; if (sorted[mid] >= nums[i]) right = mid; else left = mid + 1; } res[i] = right; sorted.insert(sorted.begin() + right, nums[i]); //sort是升序 } return res; }
04.希尔排序
总结:分段的插入排序,不断缩小排序区间的过程,分-总 的思想
介绍:
非稳定排序算法,分段跳跃式的插入排序
,是基于插入排序的第2点优化的
- 1.插入排序在对几乎已经排好序的数据操作时,效率高,即可以达到线性排序的效率
- 2.但插入排序一般来说是低效的,因为插入排序每次只能将数据移动一位
原理:分割若干子序列进行插入排序(一次移动多个位置) + 不断缩小子序列个数(gap控制)
步骤:
1.对准备排序的数据,根据某一增量分为若干子序列,并对子序列分别进行插入排序
2.逐渐将增量减小,重复步骤1,直至增量为1,此时数据序列基本有序,最后进行插入排序
注意:子序列里的
元素不是挨着的
,而是间隔为gap大小,跳跃选取的总结:外层for控制gap缩小,内层for执行插入排序
//C void shell_sort(int arr[], int len) { int i, j, min, gap; //外层for是用来控制gap不断缩小的 //初始间隔gap为数组一半,每次循环gap减半,跳跃式的插入排序 for (gap = len >> 1; gap > 0; gap = gap >> 1) { //插入排序外面的一层壳 for (i = gap; i < len; i++) { //无序数组;i=gap, 默认第一个有序,从第二个开始 min = arr[i]; //无序数组中第一个元素 for (j = i; j >= gap && arr[j-gap] > min; j -= gap)//有序数组;从右向左 arr[j] = arr[j - gap]; //将j-gap这个位置空出来 arr[j] = min; //内层for将空出来的位置更新 } } } //C++(另一种实现) template<typename T> void shell_sort(T array[], int length) { int h = 1; while (h < length / 3) h = 3 * h + 1; while (h >= 1) { for (int i = h; i < length; i++) { for (int j = i; j >= h && array[j] < array[j - h]; j -= h) std::swap(array[j], array[j - h]); } h = h / 3; } }
05.快速排序
总结:类似partition思路,左小右大
思路:分治法 + 左小右大;每次的小区间都是第一个元素是pivot;
最坏运行情况是 O(n^2),比如说顺序数组的快排分治步骤:
- 1.先从数组中取出一个数作为key值,称为 “基准”(pivot)
- 2.将比这个数小的数全部放在它的左边
- 3.对左右两个小数列重复1和2,直至各区间只有1个数
总结:不断挖坑的过程,第1个坑是key位置,这个位置数据要提前保留好,便于后面回填
1.从右到左,找到符合条件的数据,填入第一个坑,而这个数据的位置变成新坑
2.从左到右,找到符合条件的数据(与1的条件相反),填入上面的新坑…
问题:为什么不能执行swap(&arr[i], &arr[j])?
下面体现了最后的元素放置,如果直接swap,导致永远都是i和j位置互换,pivot没有位置放
index start i j arr[j] pivot arr[i]
//递归,选取 左闭右闭 区间处理 void quick_sort(int arr[], int start, int end) { if (start >= end) return; int i = start, j = end - 1; //一定要减1,因为会取到arr[j],左闭右闭 int pivot = arr[start]; //基准 while (i < j) { //当前组内寻找一遍,即i和j相遇,只有1个元素结束 while (i < j && pivot < arr[j]) j--; //向前寻找 arr[i] = arr[j]; while (i < j && pivot >= arr[i]) i++; //向后寻找 arr[j] = arr[i]; //swap(&arr[i], &arr[j]);绝对不能这么用 } arr[i] = pivot; //当组内找完一遍以后就把中间数key回归 quick_sort(arr, start, i-1); //注意:不是所谓的mid,i位置空出来是因为它是pivot quick_sort(arr, i+1, end); }
06.归并排序(需要额外空间)
思路:分治法(Divide and Conquer);一句话,两个区间"交替"取最小元素放入新区间
有2种实现方法:
1.自上而下的递归(所有递归的方法都可以用迭代重写,所以就有了第 2 种方法)
2.自下而上的迭代
分治思想
:下面一定要体会好先递归后处理的逻辑顺序
,先递归体现的是分的思想
,递归开始一层层返回时,就会执行到处理逻辑的代码(两个区间排成有序区间),体现的是治的思想
细节:区间的封闭性一定要统一好
,下面采用左闭右闭
;使用了额外空间void merge_sort(int arr[], const int len) { int tmp[len]; merge_sort(arr, tmp, 0, len - 1); //左闭右闭 } void merge_sort(int arr[], int tmp[], int start, int end) { if (start >= end) return; //终止条件,只有一个元素 int mid = start + (end - start) / 2; //防止overflow int start1 = start, end1 = mid; //2段左闭右闭区间 int start2 = mid + 1, end2 = end; //分的思想 merge_sort(arr, tmp, start1, end1); merge_sort(arr, tmp, start2, end2); //合的思想:二合一 (3个while用于合并2个有序数组,用tmp存临时合并结果) int k = start; //注:start开始 while (start1 <= end1 && start2 <= end2) tmp[k++] = arr[start1] < arr[start2] ? arr[start1++] : arr[start2++]; while (start1 <= end1) tmp[k++] = arr[start1++]; while (start2 <= end2) tmp[k++] = arr[start2++]; for (k = start; k <= end; k++) arr[k] = tmp[k]; //注:start开始(临时->正式) }
07.堆排序
示例:设有一个无序序列 { 1, 3, 4, 5, 2, 6, 9, 7, 8, 0 }
堆排序:不严格的BST,不要求左右精确,只要求根大于结点即可
详细:子结点键值或索引总是 < 或 > 它的父节点;一种利用堆的概念来排序的选择排序,分为2种方法:
大根堆
:每个节点的值都>=
其子节点的值
小根堆
:每个节点的值都<=
其子节点的值步骤:
1.建堆 (
从第一个非叶子节点开始
)2.自动排序 (
把堆首(最大值)和堆尾互换 -> 把堆尺寸缩小1,重建堆 -> 直到堆的尺寸为 1
)
思想:从第一个非叶子节点向上,先建一个大根堆;然后每次取出大根堆的root就可以了
void heap_sort(int arr[], int len) { //1.create heap(自下向上),i从最后一个非叶子节点开始调整 for (int i = len / 2 - 1; i >= 0; i--) //注意:>=0 adjust_heap(arr, i, len - 1); //注意:len-1,闭区间,函数会自动调节成大根堆 //2.sort(自上向下) //先将第一个元素和已排好元素前一位做交换,再重新调整,直到排序完毕 for (int i = len - 1; i > 0; i--) { swap(&arr[0], &arr[i]); //大根堆,保证i位置存储的是相对的最大值 adjust_heap(arr, 0, i - 1); //把堆的尺寸缩小1,重新调整堆 } } void adjust_heap(int arr[], int start, int end){//大根堆,左闭右闭 //1.建立父子节点 int dad = start, son = dad * 2 + 1; //2.子节点在范围内才比较(注:<=) while(son <= end) { //子节点中最大的 if ((son + 1 <= end) && (arr[son] < arr[son + 1])) son++; //如果父节点 > 子节点代表调整完毕,直接跳出函数 if (arr[dad] > arr[son]) return; swap(&arr[dad], &arr[son]); //交换节点,向下调整 dad = son; son = dad * 2 + 1; } }
思考:大根堆的应用场景?
升序
:每次为了在大根堆中取出一个元素放在最末尾,因此用大根堆
topk小
:先让k个元素构成根堆,第k+1个元素要将前k个元素中最大的元素替换掉,因此采用大根堆
实现
:stl中大小根堆可以用优先级队列priority_quque(优先级高的在队列前面)实现前提:使用前要包含
#include <quque>
,不好记话,就直接记忆成里面的仿函数类与常规使用的sort里的cmp作用相反;
1.升序队列,小根堆 —简单记忆,root最小,后面逐渐增大,因此是升序
priority_queue <int, vector<int>, greater<int>> q;
2.
降序队列,大根堆,---默认是 <,简单记忆就是root最大,后面逐渐减小,因此是降序
priority_queue <int, vector<int>, less<int>> q;
08.计数排序
计数排序:将输入数据值转化为key存储在额外开辟空间,计数(
[元素]=出现次数
),回填数组;线性时间复杂度
要求:数据必须事先有确定范围
举例:比如 1 2 1 2 2 3,计数([元素] = 出现次数)[1] = 2, [2] = 3, [3] = 1,总次数是2 + 3 + 1 = 6,共6个位置,
从后向前先放大数
(反向操作也可以),出现次数就放几个元素
特征
:不是比较排序,需要额外空间放出现次数信息;速度快于任何比较排序算法//正常的计数排序局限性:负数不可以用;优点;不用比较 //count_sort修改了负数不可以的情况,下面使用C最原始的方法,C++直接用map更简单 void count_sort(int arr[], int len) { int min = arr[0], max = arr[0], i; //1.find the max and min element for (i = 1; i < len; i++){ min = arr[i] < min ? arr[i] : min; //如果允许用库函数,直接min()就可以 max = arr[i] < max ? max : arr[i]; } int num = max - min + 1; int *pcount = (int*)malloc(num * sizeof(int));//省略判断指针nullptr memset(count, 0, num * sizeof(int)); //2.count for (i = 0; i < len; i++) pcount[arr[i] - min]++; //注:len, -min ,可处理负数 //3.assemble int index = 0; for (i = 0; i < num; i++){ while(pcount[i]--) arr[index++] = min + i; //注:min+是base,回填 } free(pcount); pcount = NULL; }
09.桶排序
桶排序:计数排序的升级版;数据映射到不同的桶中,桶内排序,再合并桶
为了使桶排序更加高效,我们需要做到这2点:
1.在额外空间充足的情况下,
尽量增大桶的数量
2.使用的映射函数能够将输入的
N 个数据均匀的分配到 K 个桶中
同时,对于桶中元素的排序,选择何种比较排序算法对于性能的影响至关重要
//C++(桶是用单链表实现的,类似于解决哈希碰撞的链式法) class solution { public: void bucket_sort(int arr[], int len) { //1.得到BUCKET_NUM个桶,每个桶有一个val=0,next=NULL的节点 vector<ListNode*> buckets(BUCKET_NUM,(ListNode*)(0));//外层是数组 //2.元素放入对应桶中(桶内排序好) for(int i = 0; i < len; ++i) { int index = arr[i] / BUCKET_NUM; //桶编号 ListNode *head = buckets.at(index); //具体哪个桶 buckets.at(index) = insert(head, arr[i]); //元素放入桶 } //3.合并桶 ListNode *head = buckets.at(0); for(int i = 1; i < BUCKET_NUM; ++i) head = merge(head, buckets.at(i)); //4.将所有值全部取出来 for(int i = 0; i < len; ++i) { arr[i] = head->val;head = head->next; //i++ } } //insert(元素放入桶时要直接排序好),返回是头节点 ListNode* insert(ListNode* head, int val){ //1.新增加一个节点 ListNode dummy; dummy.next = head; //增加一个节点,指向头节点 //2.遍历 ListNode *pre, *cur; //链表:dummy -> head -> ... pre = &dummy; cur = head; //遍历链表,找到正确位置,判断的是cur while(NULL != cur && cur->val <= val){ pre = cur; cur = cur->next; } //3.插入 ListNode *newNode = new ListNode(val); //根据val构建一个节点 newNode->next = cur; pre->next = newNode; return dummy.next; } //merge(归并排序也可使用,合并2个链表),返回是头节点 ListNode* merge(ListNode *head1,ListNode *head2){ ListNode dummy, *pre = &dummy; while(NULL != head1 && NULL != head2) { if(head1->val <= head2->val){ pre->next = head1; head1 = head1->next; //i++ }else{ pre->next = head2; head2 = head2->next; //i++ } pre = pre->next; } if(NULL!=head1) pre->next = head1; //没有处理完的链表要单独处理 if(NULL!=head2) pre->next = head2; return dummy.next; } private: const int BUCKET_NUM = 10; //桶的个数,这个依情况而定 //单链表节点 struct ListNode{ int val; ListNode* next; explicit ListNode(int i=0):val(i), next(NULL){} }; }
10.基数排序
基数排序:非比较型算法,原理是将整数按位数切割成不同的数字,然后按每个位数分别比较
详细:一句话,根据bit分桶,然后组合桶,
一次排序一bit位
,这样一直bit0,bit1的下去;这个bit不是二进制bit(是个位、十位、百位...的意思)
,属于稳定的排序
基数排序 vs 计数排序 vs 桶排序
这三种排序算法都利用了桶的概念,但对桶的使用方法上有明显差异:
- 计数排序:每个桶只存储单一键值
- 桶排序 :每个桶存储一定范围的数值
- 基数排序:根据键值的每位数字来分配桶
简单举例:10个桶,此时处理个位数
1.遍历一遍,就知道每个桶中应该放几个元素了
- 求桶的index的除数,如798
个位
桶index=(798/1)%10=8十位
桶index=(798/10)%10=9百位桶
index=(798/100)%10=72.将桶按顺序排好:0号桶,1号桶,…,9号桶,这会形成一个连续的区间,每个桶长度是元素个数
3.重新遍历数组,填充每个桶,因为上一步已经给每个桶留出了合适的长度,填充就完了
//C++ class solution { public: //先处理个位,再处理十位... void radix_sort(int data[], int n) { int count_bit = maxbit(data, n); //最外层循环的个数 int *tmp = new int[n]; //合并桶的临时额外空间 int *buckets = new int[BUCKET_NUM]; //计数器,桶里存的是元素个数 int i, index; int radix = 1; //控制处理位数 while(count_bit--) { //进行count_bit次排序 for(i = 0; i < BUCKET_NUM; i++) buckets[i] = 0;//清空桶 //1.统计每个桶中的元素个数 for(i = 0; i < n; i++){ index = (data[i] / radix) % BUCKET_NUM; //桶的索引 buckets[index]++; //桶中是元素个数 } //2.合并后,桶的占用的空间 //每个桶顺序放置,buckets[i]表示第i个桶放完,元素的个数 //每个桶的数量 = 以前桶数量和 + 自己的数量 for(i = 1; i < BUCKET_NUM; i++) buckets[i] += buckets[i - 1]; //3.将所有桶中记录依次收集到tmp中(逆序放置) for(i = n - 1; i >= 0; i--){ index = (data[i] / radix) % BUCKET_NUM; //桶的编号 tmp[buckets[index]--] = data[i]; //放元素在相应区间 } //3.将临时数组tmp的内容复制到data中 for(i = 0; i < n; i++) data[i] = tmp[i]; radix = radix * BUCKET_NUM;//radix会是1, 10, 100, 1000,.... } delete []tmp; delete []buckets; } int maxbit(int data[], int n) { //辅助函数,求所有数据中最大数的位数 int maxData = data[0]; for (int i = 1; i < n; ++i) { if (maxData < data[i]) maxData = data[i]; } int count_bit = 1; while (maxData / 10) ++count_bit; return count_bit; } private: const int BUCKET_NUM = 10; //桶的个数,这个依情况而定 };
参考
- 1.图解排序算法(一)之3种简单排序(选择,冒泡,直接插入) - dreamcatcher-cx - 博客园 (cnblogs.com)
- 2.图解排序算法(二)之希尔排序 - dreamcatcher-cx - 博客园 (cnblogs.com),这个博主的其他几篇博客的图也不错
- 3.其他一些网上的博客,找不到出处了
- 4.菜鸟教程