排序算法对你们来讲确定都不陌生吧,做为最基础且最重要的算法之一,在面试中经典排序算法也常常被要求手撕代码。但是排序算法实在是太多了(见下图),有些名字听起来都莫名其妙的,好比鸡尾酒排序,侏儒排序,煎饼排序等。固然,这篇文章会为你们讲解众多排序算法中最经典的部分,也是你们最熟悉的几种算法,包括
冒泡排序
、插入排序
、选择排序
、归并排序
、计数排序
、基数排序
、桶排序
、希尔排序
、堆排序
。同时也会利用一些手绘图来帮助你们更好地理解,但愿你们在阅读完本文章后都可以有所收获。ios
声明: 在下面讲解的全部算法中,咱们默认须要对待处理的数组进行升序排序。即排序好的数组中,元素的大小从左到右递增排序。c++
在正式开始讲解各类排序算法以前,我还但愿你们思考一个问题。什么样的排序算法才是一个好的算法,各类各样的排序算法它们的应用场景又有什么不一样?但愿你们在读完这篇文章以后可以有一个答案。git
其实,要想真正学好排序算法,咱们要作的不只仅是了解它的算法原理,而后背下代码就完事。更重要的是,咱们要学会去分析和评价一个排序算法。那么对于这么多的排序算法,咱们应该关注它们的哪些方面呢?面试
分析一个算法的好坏,第一个固然就是应该分析该算法的时间复杂度
。排序算法须要对一组数据进行排序,在实际的工程中,数据的规模多是10个、100个,也多是成千上万个。同时,对于要进行排序处理的数据,多是接近有序的,也多是彻底无序的。所以,在分析其时间复杂度时,咱们不只要考虑平均状况下的时间复杂度,还要分析它在最好状况
以及最坏状况
下代码的执行效率有何差别。算法
对于一个常见的排序算法来讲,执行过程当中每每会涉及两个操做步骤,一个是进行元素的比较
,二是对元素进行交换
或者移动
。因此在分析排序算法的时间复杂度时,也要特别注意算法实现过程当中不一样的元素比较
和交换
(或移动
)的次数。shell
这里须要引入一个新的概念,原地排序
。原地排序就是指在排序过程当中没必要申请额外的存储空间,只利用原来存储待排数据的存储空间进行比较和排序的排序算法。换句话说,原地排序不会产生多余的内存消耗。api
对于通常的算法,咱们通常只须要分析它的时间复杂度
和空间复杂度
,可是对于排序算法来讲,咱们还有一个很是重要的分析指标,那就是排序算法的稳定性
。数组
稳定性
是指,在须要进行排序操做的数据中,若是存在值相等的元素,在排序先后,相等元素之间的排列顺序不发生改变。数据结构
你们可能会想,反正都是相等的元素,经过排序后谁在前谁在后有什么不同呢?对排序算法进行稳定性分析又有什么实际意义呢?ide
其实,在学习数据结构与算法的过程当中,咱们解决的问题基本上都是对简单的数字进行排序。这时,咱们考虑其是否稳定彷佛并无什么意义。
可是在实际应用中,咱们面对的数据对象每每都是复杂的,每一个对象可能具备多个数字属性且每一个数字属性的排序都是有意义的。因此在排列时,咱们须要关注每一个数字属性的排序是否会对其余属性进行干扰。
举个例子,假如咱们要给大学中的学生进行一个排序。每一个学生都有两个数字属性,一个是学生所在年级,另外一个是学生的年龄,最终咱们但愿按照学生年龄大小进行排序。而对于年龄相同的同窗,咱们但愿按照年级从低到高的顺序排序。那么要知足这样的需求,咱们应该怎么作呢?
第一个想到的,固然就是先对学生的年龄进行排序,而后再在相同年龄的区间里对年级进行排序。这种办法很直观且彷佛没什么问题,可是仔细一想,会发现若是咱们要进行一次完整的排序,咱们须要采用5次排序算法(按年龄排序1次,四个年级分别排序4次)。那么咱们有没有更好地解决办法呢?
若是咱们利用具备稳定性
的排序算法,这个问题就会更好地解决了。咱们先按照年级对学生进行排序,而后利用稳定的排序算法,按年龄进行排序。这样,只须要运用两次排序,咱们就完成了咱们的目的。
这是由于,稳定的排序算法可以保证在排序过程当中,相同年龄的同窗,在排序以后,他们的顺序不发生改变。因为第一次咱们已经将学生按年级排序好了,因而在第二次排序时,咱们运用稳定的排序算法,相同年龄的学生依旧按年级保持顺序。
了解如何分析排序算法后,接下来就能够开始下面各类排序算法的学习了。
在讲解插入排序
以前,咱们先来回顾一下,在一个有序数组中,咱们是如何插入一个新的元素并使数组保持有序的呢?
咱们须要遍历整个数组,直到找到该元素应该插入的位置,而后将后面相应的元素日后移动,最后插入咱们的目标元素。(插入过程以下图)
插入排序其实就是借助这样的思想,首先咱们将数组中的数据分为两个区间,一个是已排序区间
,另外一个是未排序区间
,同时这两个区间都是动态
的。开始时,假设最左侧的元素已被排序,即为已排序区间,每一次将未排序区间的首个数据放入排序好的区间中,直达未排序空间为空。
#include<iostream> #include<vector> using namespace std; void InsertionSort(vector<int>&, int); int main() { vector<int> test = { 3, 7, 6, 4, 5, 1, 2, 8 }; InsertionSort(test, test.size()); for (auto x : test) cout << x << " "; return 0; } void InsertionSort(vector<int>& arr, int len) { for (int i = 1; i < len; ++i) { //注意i从1开始 int key = arr[i]; //须要插入的元素 int j = i - 1; //已排序区间 while ((j >= 0) && (arr[j] > key)) { arr[j + 1] = arr[j]; //元素向后移动 j--; } arr[j + 1] = key; } }
插入排序的时间复杂度?
最好状况
: 即该数据已经有序,咱们不须要移动任何元素。因而咱们须要从头至尾遍历整个数组中的元素O(n).
最坏状况
: 即数组中的元素恰好是倒序的,每次插入时都须要和已排序区间中全部元素进行比较,并移动元素。所以最坏状况下的时间复杂度是O(n^2).
平均时间复杂度
:相似咱们在一个数组中插入一个元素那样,该算法的平均时间复杂度为O(n^2).
插入排序是原地排序吗?
从插入排序的原理中能够看出,在排序过程当中并不须要额外的内存消耗,也就是说,插入排序是一个原地排序算法
。
插入排序是稳定的排序算法吗?
其实,咱们在插入的过程当中,若是遇到相同的元素,咱们能够选择将其插入到以前元素的前面也能够选择插入到后面。因此,插入排序能够是稳定
的也多是不稳定的。
选择排序和插入排序相似,也将数组分为已排序
和未排序
两个区间。可是在选择排序的实现过程当中,不会发生元素的移动
,而是直接进行元素的交换
。
选择排序的实现过程: 在不断未排序
的区间中找到最小
的元素,将其放入已排序
区间的尾部
。
#include<iostream> #include<vector> using namespace std; void SelectionSort(vector<int>&); int main() { vector<int> test = { 3, 7, 6, 4, 5, 1, 2, 8 }; SelectionSort(test); for (auto x : test) cout << x << " "; return 0; } void SelectionSort(vector<int>& 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; swap(arr[i], arr[min]); } }
选择排序的时间复杂度?
最好状况
,最坏状况
:都须要遍历未排序区间,找到最小元素。因此都为O(n^2).所以,平均复杂度也为O(n^2).
选择排序是原地排序吗?
与插入排序同样,选择排序没有额外的内存消耗,为原地排序算法
。
插入排序是稳定的排序算法吗?
答案是否认
的,由于每次都要在未排序区间找到最小的值和前面的元素进行交换,这样若是遇到相同的元素,会使他们的顺序发生交换
。
好比下图的这组数据,使用选择排序算法来排序的话,第一次找到最小元素1,与第一个2交换位置,那前面的2和后面的2顺序就变了,因此就不稳定
了。
冒泡排序和插入排序和选择排序不太同样。冒泡排序每次只对相邻
两个元素进行操做。每次冒泡操做,都会比较
相邻两个元素的大小,若不知足排序要求,就将它俩交换
。每一次冒泡,会将一个元素
移动到它相应的位置,该元素就是未排序元素中最大
的元素。
#include<iostream> #include<vector> using namespace std; void BubbleSort(vector<int>&); int main() { vector<int> test = { 3, 7, 6, 4, 5, 1, 2, 8 }; BubbleSort(test); for (auto x : test) cout << x << " "; return 0; } void BubbleSort(vector<int>& arr) { for (int i = 0; i < arr.size() - 1; i++) for (int j = 0; j < arr.size() - i - 1; j++) if (arr[j] > arr[j+1]) swap(arr[j], arr[j+1]); }
若是咱们仔细观察冒泡排序算法,咱们会注意到在第一次冒泡中,咱们已经将最大
的元素移到末尾。在第二次冒泡中,咱们将第二大
元素移至倒数第二个位置,而后以此类推,因此很容易想到利用递归
来实现冒泡排序。
递归思路:
#include<iostream> #include<vector> using namespace std; void Recursive_BubbleSort(vector<int>&, int); int main() { vector<int> test = { 3, 7, 6, 4, 5, 1, 2, 8 }; Recursive_BubbleSort(test,test.size()); for (auto x : test) cout << x << " "; return 0; } void Recursive_BubbleSort(vector<int>& arr, int n) { if (n == 1) return; for (int i = 0; i < arr.size() - 1; i++) { if (arr[i] > arr[i + 1]) swap(arr[i], arr[i + 1]); } Recursive_BubbleSort(arr, n - 1); }
冒泡排序的时间复杂度?
最好状况
:咱们只须要进行一次冒泡操做,没有任何元素发生交换,此时就能够结束程序,因此最好状况时间复杂度是O(n).
最坏状况
: 要排序的数据彻底倒序
排列的,咱们须要进行n次冒泡操做,每次冒泡时间复杂度为O(n),因此最坏状况时间复杂度为O(n^2)。
平均复杂度
:O(n^2)
冒泡排序是原地排序吗?
冒泡的过程只涉及相邻数据之间的交换
操做而没有额外的内存消耗,故冒泡排序为原地排序算法
。
冒泡排序是稳定的排序算法吗?
在冒泡排序的过程当中,只有每一次冒泡操做才会交换
两个元素的顺序。因此咱们为了冒泡排序的稳定性,在元素相等的状况下,咱们不予交换,此时冒泡排序即为稳定的排序算法
。
接下来将为你们介绍两种
最重要同时也
最经常使用的排序算法,你们必定要提起精神认真看了,这但是10次面试9次都会问到的排序算法。可是在介绍这两种排序算法以前还须要给你们讲一讲什么是
分治思想
。
在计算机科学中,分治法
是基于多项分支递归
的一种重要的算法思想。从名字能够看出,“分治”也就是“分而治之”的意思,就是把一个复杂的问题分红两个或多个相同或相似的子问题,直到子问题能够简单直接地解决,原问题的解即为子问题的合并
。
分治算法
通常都是用递归
来实现的,具体的分治算法能够按照下面三个步骤来解决问题:
该算法是利用分治思想
解决问题的一个很是典型的应用,归并排序的基本思路就是先把数组一分为二,而后分别把左右数组排好序,再将排好序的左右两个数组合并成一个新的数组,最后整个数组就是有序的了。
运用递归法实现归并操做的主要步骤:
- 申请空间,使其大小为两个已经排序序列之和,该空间用来存放
合并
后的序列。- 设定
两个指针
,最初位置分别为两个已经排序序列的起始
位置。- 比较两个指针所指向的元素,选择
较小
的元素放入到合并空间,并将指针移动到下一位置
。- 重复步骤3直到
某一指针
到达序列尾,而后将另外一序列剩下的全部元素直接复制到合并
序列尾
#include<iostream> #include<vector> using namespace std; void Merge(vector<int>& , int , int , int ); void MergeSort(vector<int>& , int , int ); int main() { vector<int> test = { 3, 7, 6, 4, 5, 1, 2, 8 }; MergeSort(test,0,test.size()-1); for (auto x : test) cout << x << " "; return 0; } void Merge(vector<int>& arr, int left, int mid, int right) { int i = left; int j = mid + 1; int k = 0; vector<int> temp(right - left + 1); while (i <= mid && j <= right) temp[k++] = arr[i] <= arr[j] ? arr[i++] : arr[j++]; while (i <= mid) temp[k++] = arr[i++]; while (j <= right) temp[k++] = arr[j++]; for (int m = 0; m < temp.size(); m++) arr[left + m] = temp[m]; } void MergeSort(vector<int>& arr,int left, int right) { if (left >= right) return; int mid = left + (right - left) / 2; MergeSort(arr, left, mid); MergeSort(arr, mid + 1, right); Merge(arr, left, mid, right); }
归并排序中须要用到两个函数,一个是MergeSort
函数,一个是Merge
函数。MergeSort
函数的做用是把数组中left至right的元素所有排列好。而Merge
函数的做用是把左右两个已经排序好的数组合并
成一个数组。
Merge
函数的编写很是重要,首先咱们须要建立一个新的数组temp,数组大小为right-left+1.而后定义两个下标i和j, 其中i=left, j=mid+1,i表示第一个数组的起始位置,j表示第二个数组的起始位置。同时还须要一个下标k来标记temp数组中填入元素的位置。
接下来开始遍历两个数组,比较i和j所指元素的大小,将较小者放入temp数组中,同时该较小者下标和k向后移动。当其中一个子数组循环完后,将剩下数组中的元素依次放入temp数组中。
最终,将temp中已排序好的数组拷贝回原数组array,再返回通过归并排序好的数组。
MeergeSort
函数主要是用于递归调用。当right >= left时,就直接return。不然,找到数组的中间下标,将数组一分为二,分别两边两边数组进行归并排序,最后将两个数组用Merge
函数合并起来。
归并排序的时间复杂度?
归并排序的递推公式为T(n)=2*T(n/2)+n
该递归式代表,对n个元素递归排序所需时间复杂度,等于左右子区间n/2个元素分别递归排序的时间,加上将两个已排好的子区间合并起来的时间O(n)
当递归循环至最后一层时,即n=1时,T(1)=1,因而能够推导出归并排序的时间复杂度为O(nlongn)
归并排序是原地排序吗?
从原理中能够看出,在归并排序过程当中咱们须要分配临时数组temp,因此不是
原地排序算法,空间复杂度为O(n).
归并排序是稳定的排序算法吗?
当咱们遇到左右数组中的元素相同时,咱们能够先把左边的元素放入temp数组中,再放入右边数组的元素,这样就保证了相同元素的先后顺序不发生改变。因此,归并排序是一个稳定
的排序算法。
快速排序
,也就是咱们常说的“快排”
。其实,快排也是利用的分治思想
。它具体的作法是在数组中取一个基准pivot,pivot位置能够随机选择(通常咱们选择数组中的最后一个元素)。选择完pivot以后,将小于pivot的全部元素放在pivot左边,将大于pivot的全部元素放在右边。最终,pivot左侧元素都将小于右侧元素。接下来咱们递归将左侧的子数组和右侧子数组进行快速排序。若是左右两侧的数组都是有序的话,那么咱们的整个数组就处于有序的状态了。
快速排序的主要步骤为:
基准值
:从数组中挑出一个元素,称为“基准”(pivot)分割
:从新排序数组,全部比pivot小的元素摆放在pivot前面,全部比pivot值大的元素放在pivot后面(与pivot值相等的数能够到任何一边)。递归
排序子数组:递归地将小于pivot元素的子序列和大于pivot元素的子序列进行快速排序。底部
的判断条件是数列的大小是零或一,此时该数列显然已经有序。#include<iostream> #include<vector> using namespace std; int partition(vector<int>& , int , int ); void QuickSort(vector<int>& , int , int ); int main() { vector<int> test = { 3, 7, 6, 4, 5, 1, 2, 8 }; QuickSort(test,0,test.size()-1); for (auto x : test) cout << x << " "; return 0; } int partition(vector<int>& arr, int left, int right) { int pivot = right; int location = left; for (int i = left; i < right; i++) { if (arr[i] < arr[pivot]) { int temp = arr[i]; arr[i] = arr[location]; arr[location] = temp; location++; } } int temp = arr[pivot]; arr[pivot] = arr[location]; arr[location] = temp; return location; } void QuickSort(vector<int>& arr,int left, int right) { if (left >= right) return; int pivot = partition(arr, left, right); QuickSort(arr, left, pivot-1); QuickSort(arr, pivot + 1, right); }
快速排序算法中有两个函数,QuickSort
函数和partition
函数。partition
函数的做用返回pivot下标,意思是此时,全部在pivot左侧的元素都比pivot的值小,在右侧的值比pivot大。接下来对左右两侧的数组递归调用QuickSort
函数进行快排。
咱们每次指定pivot指向最后一个元素,同时定义一个变量location,用来标记pivot最后应该置于的位置。在location左侧的全部元素都是比pivot值小的,从location开始,右侧全部元素都比pivot大。
只要遍历到的元素比pivot的值小,就与location所指的元素进行交换,同时location++,更新pivot应该在的位置。
数组遍历结束,最后将元素pivot与location所指元素进行交换,这样,pivot左侧的元素就所有比pivot小,右侧元素所有比pivot大了。
快速排序的时间复杂度?
快排的时间复杂度也能够像归并排序那样用递推公式计算出来。若是每次分区都恰好把数组分红两个大小同样的区间,那么它的时间复杂度也为O(nlogn).可是若是遇到最坏状况下,该算法可能退化成O(n^2).
快速排序是原地排序吗?
根据上述原理能够知道,快速排序也没有额外的内存消耗,故也是一种原地排序算法
。
快速排序是稳定的排序算法吗?
由于分区操做涉及元素之间的交换(以下图),当遍历到第一个小于2的元素1时,会交换1与前面的3,所以两个相等3的顺序就发生了改变。因此快速排序不是
一个稳定的排序算法。
上面讲的5种排序算法过程都是基于元素之间的比较和交换,因此咱们经常把这种排序算法称为比较类排序
。同时上面介绍的5种排序算法的时间复杂度最快也只能达到 O(nlogn),因此咱们也称这类排序算法称为非线性时间比较类排序
。接下将介绍另外几种
线性时间非比较类排序
,虽然这几种排序算法彷佛效率更高,可是通过下面的介绍你就会发现,它们也并非万能的。
计数排序不是基于比较的排序算法,其核心在于将输入的数据值
转化为键
存储在额外开辟
的数组空间中。简单点说,就是用额外的数组记录输入数据中各数据出现的次数
,而后将数据按出现频数
取出。
其中array为原数组,count数组用于计算每一个元素出现次数。
计数排序实现步骤:
计数排序思路比较简单,先找到数组中元素最大值max,额外分配一个大小为max+1的数组用于计算元素出现次数。最后从小到大按元素个数更新原数组。
#include<iostream> #include<vector> using namespace std; void CountSort(vector<int>&); int main() { vector<int> test = { 3, 3, 6, 2, 5, 1, 2, 8 }; CountSort(test); for (auto x : test) cout << x << " "; return 0; } int FindMax(vector<int> arr) { int max = 0; for (auto x : arr) if (x > max) max = x; return max; } void CountSort(vector<int>& arr) { int max = FindMax(arr); vector<int> count(max+1,0); //须要分配数组大小,节省了数组空间 for (int i = 0; i < arr.size(); i++) { //开始计数 count[arr[i]]++; // } int index = 0; for (int k = 0; k <count.size(); k++) { for (int cnt = 0; cnt < count[k]; cnt++) arr[index++] = k; } }
你们从上面的图中能够看到,咱们在计数时分配了计数数组count,可是count数组的两个位置根本没有计入任何数字。那是由于该数组中最小的元素为2,不可能存在0和1了。所以咱们能够对咱们以前的代码进行改进,找到数组中的最大元素和最小元素,而后分配max-min+1空间的数组便可。
#include<iostream> #include<vector> using namespace std; void CountSort(vector<int>&); int main() { vector<int> test = { 3, 3, 6, 2, 5, 1, 2, 8 }; CountSort(test); for (auto x : test) cout << x << " "; return 0; } int FindMax(vector<int> arr) { int max = arr[0]; for (auto x : arr) if (x > max) max = x; return max; } int FindMin(vector<int> arr) { int min = arr[0]; for (auto x : arr) if (x < min) min = x; return min; } void CountSort(vector<int>& arr) { int max = FindMax(arr); int min = FindMin(arr); vector<int> count(max - min + 1,0); //须要分配数组大小,节省了数组空间 for (int i = 0; i < arr.size(); i++) { //开始计数 count[arr[i]-min]++; // } int index = 0; for (int k = 0; k <count.size(); k++) { for (int cnt = 0; cnt < count[k]; cnt++) arr[index++] = k+min; } }
注意count数组脚标与原数组脚标之间的转换
通过改进以后的算法节省了必定的空间,可是细心的朋友会发现,每次排序的结果是根据count数组中元素出现次数直接对原数组进行更新。这样的话,咱们原数组中的元素被覆盖,算法的稳定性
也就得不到保障。那么咱们应该怎么保证计数排序先后数组数据的先后一致性
和稳定性
呢?
接下的讲解会有一些繁琐,但愿你们结合文字和图片慢慢理解。
首先,咱们须要对咱们以前的count数组进行变形,咱们将数组中的每个元素进行累加。换句话说,就是从第二个元素开始,每个元素都加上前面元素之和。
所以咱们count数组进行以下变形:
那么这样累加的意义是什么呢?
其实,这时count数组中的元素值表示相应整数最终的排序位置。
例如咱们的数组[5,2,3,6,2,5],count数组中count[4]=6
,(count数组中下标为4,可是表示存储元素的实际大小为6),因此元素6最终会位于数组中第6的位置。的确,array一共有6个元素,最大值为6,故排序后6处于数组的最后位,即第6位。
因为咱们要保证原数组数据的先后一致性
,因此接下来咱们须要分配一个新的数组
,用于拷贝array中的元素,最终达到有序。
可是还有一个问题,咱们要如何保证计数排序的稳定性
呢?
这里很巧妙地用到了一个办法——反向填充数组
创建好count数组后,咱们用k遍历原数组array.
例如当k=5
时,咱们在count数组中找到对应下标(5-2=3)
的位置,count[3]=5
.也就是说,咱们的元素5最终会在有序数组中的第5位。因为数组下标从0开始,因此咱们须要将5保存至Sorted_array下标为4的地方,即Sorted_array[4]=array[5]
。同时存入一个数据后,咱们须要将count数组中该元素对应值减1,表示下一个相等元素位置向前移动,直到咱们遍历完整个数组array.
上面的文字描述能够简单表述为:
k=5 -> array[k]=5 -> array[k]-min=3
count[3]=5 -> sorted_array[4] = array[5]
因为最后咱们将原数组反向填充
到新数组中,同时指向位置的指针不断向前移动
,这样,咱们就保证了咱们计数排序算法的稳定性
。
#include<iostream> #include<vector> using namespace std; vector<int> CountSort(vector<int>&); int main() { vector<int> test = { 3, 3, 6, 2, 5, 1, 2, 8 }; vector<int> newarray = CountSort(test); for (auto x : newarray) cout << x << " "; return 0; } int FindMax(vector<int> arr) { int max = arr[0]; for (auto x : arr) if (x > max) max = x; return max; } int FindMin(vector<int> arr) { int min = arr[0]; for (auto x : arr) if (x < min) min = x; return min; } vector<int> CountSort(vector<int>& arr) { int max = FindMax(arr); int min = FindMin(arr); vector<int> count(max - min + 1,0); //须要分配数组大小,节省了数组空间 vector<int> sortedarray(arr.size(), 0); for (int i = 0; i < arr.size(); i++) { //开始计数 count[arr[i]-min]++; // } for (int j = 1; j < count.size(); j++) { count[j] = count[j - 1] + count[j]; } for (int k = arr.size()-1; k >=0; k--) { sortedarray[count[arr[k]-min]-1] = arr[k]; count[arr[k] - min]--; } return sortedarray; }
若是对整个过程仍是不太明白,建议你们结合手绘图解,以及代码再仔细看一下。下面总结一下基数排序算法的整个过程。
计数排序算法的基本步骤:
count[i]
项,每放一个元素,就将count[i]-1
.计数算法的时间复杂度为O(n+k),因为咱们须要分配额外的数组空间,空间复杂度也为O(n+k),即不是
原地排序算法.同时咱们经过反向填充数组的办法保证了计数排序算法的稳定性
。
因为用来计数的数组count的长度取决于待排序数组中数据的范围,这使得对于数据范围很大的数组,计数排序须要消耗大量额外的内存。也就是说计数排序具备必定的局限性,虽然做为一种线性时间复杂度的排序,计数排序要求输入必须是肯定范围的整数。若是数据范围太大,意味着咱们须要额外消耗的内存也就更大,因此计数排序也不是那么万能的。
桶排序中的桶其实有点相似于计数排序中的“键”,不过这里的桶表明的是一个区间范围。桶排序算法的实现就是将数组分配到有限量的桶里,而后对每一个桶分别进行排序(有可能用到其余排序算法),排序完后再将桶里的数据依次拿出,便可获得排序后的数列。
桶排序的步骤:
4.从不为空的桶中按顺序拿出元素放入本来的数组中。
#include<iostream> #include<vector> #include<algorithm> #include<queue> using namespace std; void bucketsort(vector<int>&); int main() { vector<int> test = { 40, 8, 2, 15, 37, 42, 11, 29, 24, 7 }; bucketsort(test); for (auto x : test) cout << x << " "; return 0; } void bucketsort(vector<int>& arr) { queue<int> buckets[10]; for (int digit = 1; digit <= 1e9; digit *= 10) { for (int elem : arr) { buckets[(elem / digit) % 10].push(elem); } int idx = 0; for (queue<int>& bucket : buckets) { while (!bucket.empty()) { arr[idx++] = bucket.front(); bucket.pop(); } } } }
桶排序思路比较简单,若是桶的数量等于数组元素的数量,那么桶排序就变成了计数排序。因此在代码中能够看到与计数排序类似的地方。
假设咱们须要排序的数组元素有n个,同时用m个桶来存储咱们的数据。那么平均每一个桶的元素个数为k = n/m个.若是在桶内咱们使用快速排序,那么时间复杂度为klogk,总的时间复杂度即为nlog(n/m).若是桶的数量接近元素的数量,桶排序的时间复杂度就是O(n) 了。可是若是运气很差,全部的元素都到了一个桶了,那么它的时间复杂度就退化成 O(nlogn) 了。
基数排序其实也是一个非比较型的整数排序算法,其原理是将整数按位切割成不一样的数字,而后按每一个位数分别比较。可是在计算机中字符串和浮点数也能够用整数表示,因此也能够用基数排序。
因而可知,基数排序是基于位数的比较,因此再处理一些位数较多的数字时基数排序就有明显的优点了。例如在给手机号排序,或者给一些较长的英语专业名词排序等。
基数排序的主要步骤:
#include<iostream> #include<vector> using namespace std; void radixsort(vector<int>&); int maxbit(vector<int>); int main() { vector<int> test = { 77, 15, 31, 50, 8, 100, 24, 3, 43, 65 }; radixsort(test); for (auto x : test) cout << x << " "; return 0; } int maxbit(vector<int> arr) //求数据的最大位数 { int max = arr[0]; for (auto x : arr) if (x > max) max = x; int bit = 1; while (max >= 10) { max /= 10; ++bit; } return bit; } void radixsort(vector<int>& arr) //基数排序 { int bit = maxbit(arr); vector<int> tmp(arr.size()); vector<int> count(10); //0-9计数器 int i, j, k; int radix = 1; for (i = 1; i <= bit; i++) //进行bit次排序 { for (j = 0; j < 10; j++) count[j] = 0; //每次分配前清空计数器 for (j = 0; j < arr.size(); j++) { k = (arr[j] / radix) % 10; count[k]++; } for (j = 1; j < 10; j++) count[j] = count[j - 1] + count[j]; for (j = arr.size() - 1; j >= 0; j--) { k = (arr[j] / radix) % 10; tmp[count[k] - 1] = arr[j]; count[k]--; } for (j = 0; j < arr.size(); j++) arr[j] = tmp[j]; radix = radix * 10; } }
基数排序算法中,基于位数0-9的排序有点相似计数排序,若是你们对代码有所疑惑,能够多看几回计数排序的思路和代码。
根据上面的讲解你们也能够很容易地看出,基数排序的时间复杂度是O(k*n),其中n是排序的元素个数,k是元素中最大元素的位数。所以,基数算法也是线性的时间复杂度,可是因为k取决于数字的位数,因此在某些状况下该算法不必定优于O(nlogn).
到目前为止,咱们已经介绍了8种排序算法,其实这8种排序算法已经包括了全部的基本实现思想。可是“革命还没有成功,同志仍需努力”。接下来还会继续为你们介绍另外两种比较特殊的排序算法——希尔排序
和堆排序
。
希尔排序
,也称递减增量排序算法
,是插入排序
的一种更高效的改进
版本。
因此在具体讲解希尔排序以前,咱们仍是先来回顾一下插入排序的整个实现过程:
首先咱们将数组中的数据分为两个区间,一个是已排序区间,另外一个是未排序区间,同时这两个区间都是动态的,须要添加和移动元素。开始时,假设最左侧的一个元素已被排序,即为已排序区间,每一次将未排序区间的首个数据插入排序好的区间中,直达未排序空间为空。
那么插入排序有哪些不足的地方呢?
在插入排序中,咱们每次只交换两个相邻
元素,当一个元素须要向前移动至它的正确位置时,只能一步一步地移动。所以插入排序的平均时间复杂度为O(n^2).
而希尔排序
的想法是实现具备必定间隔
元素之间的交换,即首先排序有必定间隔的元素,同时按顺序依次减少间隔
,这样就可让一个元素一次性地朝最终位置前进一大步,当间隔为1时就是插入排序了。
希尔排序算法步骤:
#include<iostream> #include<vector> #include<algorithm> #include<queue> using namespace std; void shell_sort(vector<int>&); int main() { vector<int> test = { 40, 8, 2, 15, 37, 42, 11, 29, 24, 7 }; shell_sort(test); for (auto x : test) cout << x << " "; return 0; } void shell_sort(vector<int>& arr) { for (int gap = arr.size() / 2; gap > 0; gap /= 2) { for (int i = gap; i < arr.size(); i++) { int temp = arr[i]; int j; for (j = i; j >= gap && arr[j - gap] > temp; j -= gap) { arr[j] = arr[j - gap]; } arr[j] = temp; } } }
在希尔算法中,步长的选择尤为重要。在上面的代码中,最初的步长选择为n/2,而且不断对步长取半,直到最后为1.虽然这样能够比普通的插入排序O(n^2)更好,可是根据步长的选择不一样,希尔排序的平均时间复杂度还能够更优。下图为不一样步长序列选择下的最坏时间复杂度。
下面是几种优秀的步长序列:
- `Shell’s original sequence: N/2 , N/4 , …, 1
- Knuth’s increments: 1, 4, 13, …, (3k – 1) / 2
- Sedgewick’s increments: 1, 8, 23, 77, 281, 1073, 4193, 16577…4j+1+ 3·2j+ 1.
- Hibbard’s increments: 1, 3, 7, 15, 31, 63, 127, 255, 511…
- Papernov & Stasevich increment: 1, 3, 5, 9, 17, 33, 65,…
- Pratt: 1, 2, 3, 4, 6, 9, 8, 12, 18, 27, 16, 24, 36, 54, 81….
由于希尔排序在实现过程当中没有分配额外的内存空间,因此是一种原地排序算法
。可是因为希尔排序将数组分组并存在元素的交换,因此并不是
一个稳定的排序算法。
堆排序是利用“堆”这种数据结构实现的一种排序算法。那么什么是堆呢?
先给你们简单介绍一下堆,堆通常具备下面两个性质:
堆老是一棵彻底二叉树。(彻底二叉树是指即除了最底层,其余层的节点都必须被元素填满,同时最底层的叶子节点必须所有。)
堆中的任意节点的值都必须大于等于或小于等于它的子节点
其中,将每一个节点的值都大于等于其子节点值的堆称为“大顶堆”(max heap),将每一个节点的值小于等于子节点值的堆称为“小顶堆”(min heap).
以下图:
由于咱们经常使用数组来存储彻底二叉树,因此咱们也能够用数组来存储堆。
堆在数组中的存储图以下:
由上图咱们能够看出对于堆中元素下标为i的点:
2*i+1
为它的左子节点2*i+2
为它的右子节点i/2
是它的前继节点简单介绍完了堆,接下来就让咱们看看这些对于堆有哪些具体的操做吧:
(咱们经常用“大顶堆”用于堆排序,因此下面将要实现的堆都默认为“大顶堆”。)
对于“大顶堆”,它的每一个节点的值都必须大于等于它的子节点的值。因此说为了维护一个“大顶堆”,咱们须要设计一个算法将不知足“大顶堆”性质的节点进行修改。修改思路是将其与子节点相比较,若是它的值小于它的子节点,就将其与子节点中最大值发生交换,而后继续对该点进行判断,直到到达合适的地方。
咱们已经知道怎么维护一个堆了,那么咱们怎么将用数组建一个“大顶堆”呢?
当咱们须要维护一个大小为n的堆时,通常从下标n/2的位置不断向前移动。以下图,咱们首先判断下标为4的元素,其知足堆的定义。而后咱们判断下标为3的元素,使其与值为29的子节点发生交换。依次循环,当下标为1时,值为2的节点须要不断与子节点发生交换,直到叶子节点的位置。具体实现以下图:
根据“大顶堆”的定义,堆顶元素即为整个数据中的最大值。因而当咱们建好堆后,咱们将堆顶元素与堆中最后一个元素交换,即将堆中最大元素放在了数组中的最后一个位置。此时,由于咱们将较小的元素放在了堆顶,因此咱们须要对其进行堆维护(heapify)
.维护完成后,堆顶元素为此时堆中的最大元素,而后继续重复上面的操做,反向填充数组
,直到最后堆中剩下一个元素,即在数组的首位置。
#include <iostream> #include<vector> using namespace std; void heapSort(vector<int>&, int); int main() { vector<int> arr = { 40, 2, 8, 29, 37, 24, 11, 15, 7, 36 }; heapSort(arr, arr.size()); for (auto x : arr) cout << x << " "; } void heapify(vector<int>& arr, int n, int i) { int largest = i; int l = 2 * i + 1; int r = 2 * i + 2; if (l < n && arr[l] > arr[largest]) largest = l; if (r < n && arr[r] > arr[largest]) largest = r; if (largest != i) { swap(arr[i], arr[largest]); heapify(arr, n, largest); } } void heapSort(vector<int>& arr, int n) { for (int i = n / 2 - 1; i >= 0; i--) heapify(arr, n, i); for (int i = n - 1; i >= 0; i--) { swap(arr[0], arr[i]); heapify(arr, i, 0); //堆的数量减一 } }
上面堆排序算法代码中用到了两个函数,heapify
和 heapSort
.其中heapify
函数的做用是对堆中下标为i的点进行堆维护,使其知足堆的性质。在heapSort
函数中咱们先将堆从下标n/2-1
的位置进行维护,直到成为“大顶堆”,而后对堆顶元素进行交换和维护循环。最后,数组中的元素即为有序保存的了。
须要注意的是算法中的heapify
函数须要传入除数组外的两个整数,第一个整数是指须要维护堆的大小。在最开始进行堆维护时,咱们须要对整个数组进行维护,n
即为数组的大小。可是在对堆顶元素和最后位置的元素进行交换后,此时最后一个元素已经在它正确的位置,因此咱们须要维护堆的大小将逐渐减少
,即传入代码中的i
.
咱们已经知道,包含n个元素的完整二叉树的高度为logn.
而当咱们使用heapify
函数,对某个元素进行维护时,咱们须要继续将元素与其左,右子元素进行比较,并将其向下推移,直到其两个子元素均小于其大小。在最坏的状况下,咱们须要将元素从根移动到叶子节点,进行屡次logn
的比较和交换。在build_max_heap
阶段,咱们对n/2
元素执行此操做,所以build_heap
步骤的最坏状况复杂度为n/2*log(n) ~ nlogn
。
在排序步骤中,咱们将根元素与最后一个元素交换,并堆放根元素。对于每一个元素,这又须要花费logn
最长时间,由于咱们可能须要将元素从根一直带到最远的叶子上。因为咱们重复了n
次,所以heap_sort
步骤也是nlogn
。
因为build_max_heap
和heap_sort
步骤是一个接一个地执行的,所以算法复杂度不会增长,而且保持为nlogn
。
所以,堆排序在全部状况下,即最好最坏以及平均状况下的时间复杂度均为O(nlogn).
到目前为止,已经为你们介绍完了十种基本算法。感谢你们的认真阅读,最后对十种算法的总结以下,但愿你们有所收获!
若是你喜欢个人文章,欢迎关注个人公众号【Coderoger】了解更多LeetCode题解思路以及算法知识!