排序算法总结
参考 Poll的笔记 http://www.cnblogs.com/maybe2030/p/4715042.html
直接插入排序
基本思想:将待排序的无序数列看成是一个仅含有一个元素的有序数列和一个无序数列,将无序数列中的元素逐次插入到有序数列中,从而获得最终的有序数列。
示意图:
时间复杂度:O(n^2)
是否稳定: 稳定
C++代码
#include <iostream> using namespace std; void insertSort(int a[], int n){ for (size_t i = 1; i < n; i++) { for (int j = i-1; j >= 0 && a[j]>a[j+1]; j--) { int temp = a[j + 1]; a[j + 1] = a[j]; a[j] = temp; } } } int main(void) { int a[] = { 23, 56, 11, 2, 5, 8, 9, 55, 100, 67 }; insertSort(a, 10); for (auto i : a){ cout << i << " "; } }
直接选择排序
基本思想: 在要排序的一组数中,选出最小(或者最大)的一个数与第1个位置的数交换;然后在剩下的数当中再找最小(或者最大)的与第2个位置的数交换,依次类推,直到第n-1个元素(倒数第二个数)和第n个元素(最后一个数)比较为止。
示意图:
时间复杂度:O(n^2)
是否稳定:不稳定
C++代码
#include <iostream> using namespace std; void selectSort(int a[], int n){ for (size_t i = 0; i < n; i++) { int minIndex = i; for (size_t j = i + 1; j < n; j++){ minIndex = a[j] < a[minIndex] ? j : minIndex; } int t = a[i]; a[i] = a[minIndex]; a[minIndex] = t; } } int main(void){ int a[] = { 23, 56, 11, 2, 5, 8, 9, 55, 100, 67 }; selectSort(a, 10); for (auto i : a){ cout << i << " "; } }
堆排序
基本思想:初始时把要排序的n个数的序列看作是一棵顺序存储的二叉树(一维数组存储二叉树),调整它们的存储序,使之成为一个堆,将堆顶元素输出,得到n 个元素中最小(或最大)的元素,这时堆的根节点的数最小(或者最大)。然后对前面(n-1)个元素重新调整使之成为堆,输出堆顶元素,得到n 个元素中次小(或次大)的元素。依此类推,直到只有两个节点的堆,并对它们作交换,最后得到有n个节点的有序序列。称这个过程为堆排序。
时间复杂度分析:O(nlog(n)),堆排序是一种不稳定的排序算法。
因此,实现堆排序需解决两个问题:
1. 如何将n 个待排序的数建成堆?
2. 输出堆顶元素后,怎样调整剩余n-1 个元素,使其成为一个新堆?
首先讨论第二个问题:输出堆顶元素后,怎样对剩余n-1元素重新建成堆?
调整小顶堆的方法:
1)设有m 个元素的堆,输出堆顶元素后,剩下m-1 个元素。将堆底元素送入堆顶((最后一个元素与堆顶进行交换),堆被破坏,其原因仅是根结点不满足堆的性质。
2)将根结点与左、右子树中较小元素的进行交换。
3)若与左子树交换:如果左子树堆被破坏,即左子树的根结点不满足堆的性质,则重复方法 (2).
4)若与右子树交换,如果右子树堆被破坏,即右子树的根结点不满足堆的性质。则重复方法 (2).
5)继续对不满足堆性质的子树进行上述交换操作,直到叶子结点,堆被建成。
称这个自根结点到叶子结点的调整过程为筛选。如图:
再讨论第一个问题,如何将n 个待排序元素初始建堆?
建堆方法:对初始序列建堆的过程,就是一个反复进行筛选的过程。
1)n 个结点的完全二叉树,则最后一个结点是第n/2个结点的子树。
2)筛选从第n/2个结点为根的子树开始,该子树成为堆。
3)之后向前依次对各结点为根的子树进行筛选,使之成为堆,直到根结点。
如图建堆初始过程:无序序列:(49,38,65,97,76,13,27,49)
C++代码
#include <iostream> using namespace std; void heapAdjust(int a[], int root, int n){ int temp = a[root]; int child = 2 * root + 1; while (child < n){ if (child + 1 < n && a[child] > a[child + 1]) child++; if (a[root] > a[child]){ a[root] = a[child]; root = child; child = 2 * root + 1; } else break; a[root] = temp; } } void heapBuilding(int a[], int n){ for (int i = (n-1)/2; i >=0; i--) heapAdjust(a, i, n); } void heapSort(int a[], int n){ heapBuilding(a, n); for (int i = n-1; i >=0; i--) { int min = a[0]; a[0] = a[i]; a[i] = min; heapAdjust(a, 0, i); } } int main() { int a[] = { 23, 56, 11, 2, 5, 8, 9, 55, 100, 67 }; heapSort(a, 10); for (auto i : a){ cout << i << " "; } }
冒泡排序
基本思想: 在要排序的一组数中,对当前还未排好序的范围内的全部数,自上而下对相邻的两个数依次进行比较和调整,让较大的数往下沉,较小的往上冒。即:每当两相邻的数比较后发现它们的排序与排序要求相反时,就将它们互换。每一趟排序后的效果都是讲没有沉下去的元素给沉下去。
时间复杂度分析:O(n^2),冒泡排序是一种不稳定排序算法。
C++代码
#include <iostream> using namespace std; void bubbleSort(int a[], int n) { for (size_t i = 0; i < n; i++) { for (size_t j = 0; j < n-1-i; j++) { if (a[j] >a[j + 1]) { int t = a[j]; a[j] = a[j + 1]; a[j + 1] = t; } } } } int main(){ int a[] = { 23, 56, 11, 2, 5, 8, 9, 55, 100, 67 }; bubbleSort(a, 10); for (auto i : a){ cout << i << " " ; } }
快速排序
基本思想:快速排序算法的基本思想为分治思想。
1)先从数列中取出一个数作为基准数;
2)根据基准数将数列进行分区,小于基准数的放左边,大于基准数的放右边;
3)重复分区操作,知道各区间只有一个数为止。
示例:
(a)一趟排序的过程:
(b)排序的全过程
时间复杂度:O(nlog(n)),但若初始数列基本有序时,快排序反而退化为冒泡排序。
C++代码
#include <iostream> using namespace std; void quickSort(int a[],int l, int r) { if (l < r){ int i = l, j = r; int x = a[i]; while (i < j){ while (i < j && a[j] >= x) j--; if (i < j) a[i] = a[j]; while (i < j && a[i] <= x) i++; if (i < j) a[j] = a[i]; } a[i] = x; quickSort(a, l, i - 1); quickSort(a, i + 1, r); } } int main(void) { int a[] = { 23, 56, 11, 2, 5, 8, 9, 11, 100, 67 }; quickSort(a, 0, 9); for (auto i : a){ cout << i << " "; } }
归并排序
基本思想:归并(Merge)排序法是将两个(或两个以上)有序表合并成一个新的有序表,即把待排序序列分为若干个子序列,每个子序列是有序的。然后再把有序子序列合并为整体有序序列。
时间复杂度:O(nlog(n)),归并算法是一种稳定排序算法。
C++代码
#include <iostream> #include <vector> using namespace std; void merge(int a[], int l, int mid, int r) { vector<int> temp; int i = l, j = mid + 1; while (i <= mid && j <= r) { if (a[i] <= a[j]){ temp.push_back(a[i++]); } else temp.push_back(a[j++]); } while (i <= mid) temp.push_back(a[i++]); while (j <= r) temp.push_back(a[j++]); for (size_t i = 0; i < temp.size(); i++) { a[l+i] = temp[i]; } } void mergeSort(int a[], int l, int r) { if (l < r){ int mid = (l + r) / 2; mergeSort(a, l, mid); mergeSort(a, mid + 1, r); merge(a, l, mid, r); } } int main(void) { int a[] = { 23, 56, 11, 2, 5, 8, 9, 55, 100, 67 }; int n = sizeof(a) / sizeof(int); mergeSort(a, 0, 9); for (auto i : a) cout << i << " "; }
桶排序/基数排序
说基数排序之前,我们先说桶排序:
基本思想:是将数列分到有限数量的桶里。每个桶再个别排序(有可能再使用别的排序算法或是以递回方式继续使用桶排序进行排序)。桶排序是鸽巢排序的一种归纳结果。当要被排序的阵列内的数值是均匀分配的时候,桶排序使用线性时间O(n)。但桶排序并不是比较排序,不受到O(n*log n)下限的影响。
简单来说,就是把数据分组,放在一个个的桶中,然后对每个桶里面的在进行排序。
例如要对大小为[1..1000]范围内的n个整数A[1..n]排序
首先,可以把桶设为大小为10的范围,具体而言,设集合B[1]存储[1..10]的整数,集合B[2]存储(10..20]的整数,…… ,集合B[i]存储((i-1)*10, i*10]的整数,i=1,2,..100,总共有100个桶。
然后,对A[1, ... , n]从头到尾扫描一遍,把每个A[i]放入对应的桶B[j]中。 再对这100个桶中每个桶里的数字排序,这时可用冒泡,选择,乃至快排,一般来说任何排序法都可以。
最后,依次输出每个桶里面的数字,且每个桶中的数字从小到大输出,这样就得到所有数字排好序的一个序列了。
假设有n个数字,有m个桶,如果数字是平均分布的,则每个桶里面平均有n/m个数字。如果对每个桶中的数字采用快速排序,那么整个算法的复杂度是 O(n+m*n/m*log(n/m))=O(n+n*logn-n*logm)。
从上式看出,当m接近n的时候,桶排序复杂度接近O(n)
当然,以上复杂度的计算是基于输入的n个数字是平均分布这个假设的。这个假设是很强的 ,实际应用中效果并没有这么好。如果所有的数字都落在同一个桶中,那就退化成一般的排序了。
一个有关桶排序的图文讲解,强力推荐:阿哈磊的《最快最简单的排序—桶排序》。
桶排序的一个重要的应用场景:Bit-map:
所谓的Bit-map就是用一个bit位来标记某个元素对应的Value,而Key即是该元素。由于采用了Bit为单位来存储数据,因此在存储空间方面,可以大大节省。
如果说了这么多还没明白什么是Bit-map,那么我们来看一个具体的例子,假设我们要对0-7内的5个元素(4,7,2,5,3)排序(这里假设这些元素没有重复)。那么我们就可以采用Bit-map的方法来达到排序的目的。要表示8个数,我们就只需要8个Bit(1Bytes),首先我们开辟1Byte的空间,将这些空间的所有Bit位都置为0(如下图):
然后遍历这5个元素,首先第一个元素是4,那么就把4对应的位置为1(可以这样操作 p+(i/8)|(0×01<<(i%8)) 当然了这里的操作涉及到Big-ending和Little-ending的情况,这里默认为Big-ending),因为是从零开始的,所以要把第五位置为一(如下图):
然后再处理第二个元素7,将第八位置为1,,接着再处理第三个元素,一直到最后处理完所有的元素,将相应的位置为1,这时候的内存的Bit位的状态如下:
然后我们现在遍历一遍Bit区域,将该位是一的位的编号输出(2,3,4,5,7),这样就达到了排序的目的。
其实Bit-map还有很多用途,这里只是用排序进行了Bit-map的介绍,Bit-map可以进行查重的操作,尤其是在大数据上应用更为广泛,它可以将存储空间降低10倍左右。
前面说的几大排序算法 ,大部分时间复杂度都是O(n2),也有部分排序算法时间复杂度是O(nlogn)。而桶式排序却能实现O(n)的时间复杂度。但桶排序的缺点是:
1)首先是空间复杂度比较高,需要的额外开销大。排序有两个数组的空间开销,一个存放待排序数组,一个就是所谓的桶,比如待排序值是从0到m-1,那就需要m个桶,这个桶数组就要至少m个空间。
2)其次待排序的元素都要在一定的范围内等等。
桶式排序是一种分配排序。分配排序的特定是不需要进行关键码的比较,但前提是要知道待排序列的一些具体情况。
各种排序算法性能比较
1)各种排序的稳定性,时间复杂度和空间复杂度总结:
改错:上述快速排序算法的空间复杂度应改为O(log2n)。
为什么快速排序算法的空间复杂度为O(log2n)~O(n)?
快速排序算法的实现需要栈的辅助,栈的递归深度为O(log2n);当整个数列均有序时,栈的深度会达到O(n)。
我们比较时间复杂度函数的情况:
2)时间复杂度来说:
(1)平方阶(O(n2))排序
各类简单排序:直接插入、直接选择和冒泡排序;
(2)线性对数阶(O(n*logn))排序
快速排序、堆排序和归并排序;
(3)O(n1+§))排序,§是介于0和1之间的常数
希尔排序
(4)线性阶(O(n))排序
基数排序,此外还有桶、箱排序。
说明:
(1)当原表有序或基本有序时,直接插入排序和冒泡排序将大大减少比较次数和移动记录的次数,时间复杂度可降至O(n);
(2)而快速排序则相反,当原表基本有序时,将蜕化为冒泡排序,时间复杂度提高为O(n^2);
(3)原表是否有序,对简单选择排序、堆排序、归并排序和基数排序的时间复杂度影响不大。
3)稳定性:排序算法的稳定性:若待排序的序列中,存在多个具有相同关键字的记录,经过排序, 这些记录的相对次序保持不变,则称该算法是稳定的;若经排序后,记录的相对次序发生了改变,则称该算法是不稳定的。
稳定性的好处:排序算法如果是稳定的,那么从一个键上排序,然后再从另一个键上排序,第一个键排序的结果可以为第二个键排序所用。基数排序就是这样,先按低位排序,逐次按高位排序,低位相同的元素其顺序再高位也相同时是不会改变的。另外,如果排序算法稳定,可以避免多余的比较。
稳定的排序算法:冒泡排序、插入排序、归并排序和基数排序。
不是稳定的排序算法:选择排序、快速排序、希尔排序、堆排序。
4)选择排序算法准则:
每种排序算法都各有优缺点。因此,在实用时需根据不同情况适当选用,甚至可以将多种方法结合起来使用。
选择排序算法的依据:
影响排序的因素有很多,平均时间复杂度低的算法并不一定就是最优的。相反,有时平均时间复杂度高的算法可能更适合某些特殊情况。同时,选择算法时还得考虑它的可读性,以利于软件的维护。一般而言,需要考虑的因素有以下四点:
(1)待排序的记录数目n的大小;
(2)记录本身数据量的大小,也就是记录中除关键字外的其他信息量的大小;
(3)关键字的结构及其分布情况;
(4)对排序稳定性的要求。
设待排序元素的个数为n.
(1)当n较大,则应采用时间复杂度为O(n*logn)的排序方法:快速排序、堆排序或归并排序。
快速排序:是目前基于比较的内部排序中被认为是最好的方法,当待排序的关键字是随机分布时,快速排序的平均时间最短;
堆排序:如果内存空间允许且要求稳定性的;
归并排序:它有一定数量的数据移动,所以我们可能过与插入排序组合,先获得一定长度的序列,然后再合并,在效率上将有所提高。
(2)当n较大,内存空间允许,且要求稳定性:归并排序
(3)当n较小,可采用直接插入或直接选择排序。
直接插入排序:当元素分布有序,直接插入排序将大大减少比较次数和移动记录的次数。
直接选择排序:当元素分布有序,如果不要求稳定性,选择直接选择排序。
(4)一般不使用或不直接使用传统的冒泡排序。
(5)基数排序
它是一种稳定的排序算法,但有一定的局限性:
1、关键字可分解;
2、记录的关键字位数较少,如果密集更好;
3、如果是数字时,最好是无符号的,否则将增加相应的映射复杂度,可先将其正负分开排序。