排序(sorting)是算法家族里比较重要也比较基础的一类,内容也是五花八门了:
一、有“基于比较”的,也有“不基于比较”的;
二、*有迭代的(iterative)也有递归的(recursive);
三、有利用分治法(divide and conquer)思路解决的;(除了显而易见的“二路归并”算法,*“代入法(substitution method)”也是分治的一种,如快速排序/插入排序)python
再进入正文以前,我想推荐你们一个很好的能够可视化学习算法的网站VisuALgo算法
判断算法的“好坏”,咱们通常借助时间(空间)复杂度为依据,包括最好状况/最坏状况/和平均状况的复杂度。api
排序方法 | 平均状况 | 最好状况 | 最坏状况 | 辅助空间 | 稳定性 |
---|---|---|---|---|---|
冒泡排序 | O(n²) | O(n) | O(n²) | O(1) | 稳定 |
简单选择排序 | O(n²) | O(n²) | O(n²) | O(1) | 不稳定 |
直接插入排序 | O(n²) | O(n) | O(n²) | O(1) | 稳定 |
希尔排序 | O(nlogn)~O(n²) | O(nlogn) | O(n²) | O(1) | 稳定 |
堆排序 | O(nlogn) | O(nlogn) | O(nlogn) | O(1) | 不稳定 |
归并排序 | O(nlogn) | O(nlogn) | O(nlogn) | O(n) | 稳定 |
快速排序 | O(nlogn) | O(nlogn) | O(n²) | O(logn)~O(n) | 不稳定 |
1*、迭代的(iterative)与递归的(recursive)的区别
迭代(iterative)指循环反复执行某操做,由旧值递推出新值,每一次对过程的重复称为一次“迭代”,而每一次迭代获得的结果会做为下一次迭代的初始值;
递归(recursive)指程序在运行过程当中直接或间接调用本身。递归算法要求有边界条件、递归前段和递归返回段。当边界条件不知足时,递归前进;当边界条件知足时,递归返回。
咱们以阶乘(factorial)为例,看看两类算法是如何操做的:数组
#迭代iterative def factorial(number): product = 1 for i in range(number): #主体是循环 product = product * (i+1) return product m = factorial(5) print(m) #递归recursive def factorial(number): if number <= 1: #递归的边界条件(出口) return 1 else: return number * factorial(number-1) #调用自身 n = factorial(5) print(n)
咱们来看一下两个算法运行过程:dom
对于这个问题来讲,迭代比递归的时间效率更高。
不过在真正使用的时候,还须要根据状况讨论两类算法的优劣。ide
2*、分治法(devide and conquer)中的代入法(substitution method)
分治法,简而言之就是把大问题拆分红小问题,经过递归的求解小问题,最终获得大问题的解。学习
- 这里插上一小句,分治法与动态规划(Dynamic Programming)看上去很像,都是拆大问题为小问题求解,不过动态规划“更聪明”也更灵活,已求解的子问题会被保存起来,避免重复子问题的反复求解。
另外,动态规划与数学的递归分析法有着很深的渊源,算法的思路每每可以被表示成数组递归的等式。
代入法具体的作法与“数学概括法”的思路不谋而合:
给出递推过程当中第n+1项与第n项的关系;
从第0项开始将每一项的参数带入这个递推公式求解。
重点就在于这个适用于任一个阶段的“递推关系”的肯定,这也是神仙算法“快速排序”的精髓。
经过两两条目进行比较,决定是否将两个条目进行交换(swap)。
这类算法是最容易理解和应用的,但同时并不那么高效,它们的时间复杂度一般为O(N²)。网站
算法过程:
一、比较相邻的两个条目(a,b);
二、若是两个条目的大小关系与排序目标不符,则将两个条目交换;(假设咱们想要创建升序序列)
三、重复以上两个步骤直到队尾;
四、这时,队尾条目就为队列中的最大值;这时咱们再从步骤1开始重复,交换至倒数第二位;直到队列中全部条目都有序。
时间复杂度:
有内外两个循环,时间复杂度为O(N²)。ui
改进:提早终止的冒泡排序
若是在内层循环中没有进行交换,那么就意味着该队列已经有序,即可以终止排序操做。
所以对于一个已经有序的序列,其最好状况的时间复杂度为O(n)。
不过这一点改进并不能改变冒泡排序的阶级属性平均时间复杂度。.net
算法过程:
一、在[i,N-1]范围内寻找最小的条目的位置X(初始时i=0);
二、将条目x与条目i交换;
三、将i加1,重复步骤一、2,直到全部条目有序。
void selectionSort(int a[], int N) { for (int i = 0; i <= N-2; i++) { 外层循环 O(N) int X = min_element(a+i, a+N) - a; //内层循环 O(N),找到最小条目的位置 swap(a[X], a[L]); // O(1) 而知也可能相等(并不真正交换) } }
时间复杂度:
一样是内外两层循环,时间复杂度为O(N²)。
算法过程:
插入排序的算法思路很像咱们在打牌时调整牌序的作法:
一、开始的时候手里只有一张牌;
二、拿到下一张牌,将牌放到手中牌组的合适位置;
三、每张牌都重复上面的步骤。
void insertionSort(int a[], int N) { for (int i = 1; i < N; i++) { // 外层循环 O(N) X = a[i]; // X 是将插入的对象 for (j = i-1; j >= 0 && a[j] > X; j--) //从后往前在已经有序的前i-1个条目中找到应当插入的位置 a[j+1] = a[j]; // 为X的插入腾出位置 a[j+1] = X; // 将X插入j+1位 } }
时间复杂度:
显然,外层循环的时间复杂度为O(N)
而内层循环的时间复杂度则与待排序序列的有序情况有关:
算法过程:
一、将两个条目分为一组,合并成为有序的长度为2的序列;
二、将两个已排序的长度为2的序列分为一组,合并成为有序的长度为4的序列;
重复该步骤...
三、最终,将两个已排序的长度为(N/2)的序列合并成为有序的长度为N的序列,排序完成。
以上只是大致的思路,去进一步了解归并排序,咱们先从“合并”(merge)这个操做谈起:
从两个待合并序列的首部开始,边比较边向后移(取出两边指针所指的较小条目的到辅助队列中去,并将指针向后移一位)
void merge(int a[], int low, int mid, int high) { // 子序列1 = a[low..mid], 子序列2 = a[mid+1..high], 都是有序的 int N = high-low+1; int b[N]; // 一个辅助数组 int left = low, right = mid+1, bIdx = 0; //初始化子序列和辅助序列的指针 while (left <= mid && right <= high) // 合并过程 b[bIdx++] = (a[left] <= a[right]) ? a[left++] : a[right++]; while (left <= mid) b[bIdx++] = a[left++]; // 处理余下的部分 while (right <= high) b[bIdx++] = a[right++]; // 处理余下的部分 for (int k = 0; k < N; k++) a[low+k] = b[k]; // 将辅助数组中的内容粘贴回去 }
以上就是归并排序算法的灵魂核心所在了。
还记得以前提到过的“分治法”(Divide and Conquer)吗?
将大问题拆分正小问题,经过解决小问题递归的解决大问题。
归并排序就是一个典型的利用“分治法”思路的算法:
“分”的过程很容易:将待排序的序列一分为二,一直分到不能再分(单个条目),再经过迭代的思路回溯着求解;
“治”的部分就是咱们刚刚介绍的合并(merge)的过程。
完整算法过程:
void mergeSort(int a[], int low, int high) { // 待排序的序列是a[low..high] if (low < high) { // 迭代的出口是单个条目或空(low>=high) int mid = (low+high) / 2; mergeSort(a, low , mid ); // 将序列一分为二,迭代求解(recursive) mergeSort(a, mid+1, high); merge(a, low, mid, high); // “治”的部分,合并子序列 } }
时间复杂度:
对于每一次长度为k的序列的合并(merge)操做来讲,它的时间复杂度是O(k)。(最多有k-1次比较,当两个待合并的序列正好“镶嵌”时)
由上图可知,在第k层,每个待合并的序列长度为n/(2^(k-1)),须要执行合并的次数为2^(k-1)。
因此能够获得,在第k层,合并的总的时间复杂度为O[N/(2^(k-1))]*O[2^(k-1)] = O(N);
易知该归并树一共有logN层,因此可得归并排序总的时间复杂度为O(NlogN)。
归并排序的一个很大优势就是,不管待排序的序列状况如何,其时间复杂度都是O(NlogN)。
这种性质使得其适用于大规模的排序。(NlogN的增加速度远小于N²)
不过,归并排序也有一些弱势的部分:
一、算法稍显复杂;(不过咱们也不须要从底层写起(from scratch))
二、须要O(N)的空间复杂度(一个辅助队列),使得这个算法不是就地算法。
快速排序也是一个使用“分治法”思路的算法。
算法过程:
咱们用“分治法”的思路来分析算法:
“分”的部分:
选择一个条目p(至关于一个中央标杆)
而后将待排序序列a[i...j]分为三部分:a[i...m-1],a[m],a[m+1...j]
“治”的部分:
...什么都不作。
是否是感受和以前讨论的“归并排序”彻底相反呢?
咱们先从重要的“分”的部分(经典版本)开始讨论:
为了分隔a[i...j],咱们先选择a[i]做为中央标杆p。
余下的元素被分到到三个区域:
① S1 = a[i+1...m] 其中元素都 < p;
② S2 = a[m+1...k-1] 其中元素 ≥ p;
③ 未知区域 = a[k...j] 还没有分配至S1/S2。
初始时,S1区和S2区都是空的;即除了p自身,全部的元素都在“未知区域”中。
对于每个在未知区域中的元素a[k],咱们将其与p比较,决定其分到S1仍是S2。
先经过图片来对“分组”的操做有一个直观的认识:
状况一:a[i] ≥ p
状况二:a[i] < p
算法实现:
int partition(int a[], int i, int j) { int p = a[i]; // 选择a[i]做为中心轴 int m = i; // S1和S2初始状况下都是空的 for (int k = i+1; k <= j; k++) { // 遍历未知区域 if (a[k] < p) { // 状况2 m++; swap(a[k], a[m]); } // 对于状况1: a[k] >= p,仅仅k++,无额外操做 } swap(a[i], a[m]); // 最后一步,将a[m]与a[i]交换,将中心轴放在最终位置 return m; // 返回p最终位置的下标 } void quickSort(int a[], int low, int high) { if (low < high) { int m = partition(a, low, high); // 时间复杂度 O(N) // m为low最终的位置 quickSort(a, low, m-1); // 迭代求解左边分组 quickSort(a, m+1, high); // 迭代求解右边分组 } }
复杂度分析:
首先,分析每一次“分组”(partition)的复杂度:
对于partition(a,i,j),只须要递归执行(j-i)次(将未分组的条目一一分组),因此它的时间复杂度是O(N)。
最坏的状况下,即若是序列原本就是有序的,那么每次都选择第一个条目做为“中心轴”的结果就是,分组的左半边只有p(x≤p),而余下的条目都在右半边(x>p)。
这种状况下一共须要执行n-1次“分组”的操做。总的时间复杂度为O(N²)。
而最好的状况下,每一次选择的p都可以将序列分为相等大小的两部分。
这种状况下,递归的深度只有O(logN)(与归并排序相相似),每一层的时间复杂度为O(N),获得总的时间复杂度为O(NlogN)。
随机快速排序与快速排序不一样的一点就是,相对于从“固定”的位置选择p(好比一直选择起始部分的元素做为p),p的选择是随机的。
为何这个随机快速排序的时间复杂度为O(NlogN)呢?解释起来可能稍显繁琐,不过咱们能够创建一种直观的感觉:
若是是随机选择p的话,咱们遇到极端状况的几率(彻底正序)就会很小,(能够把它想象成符合一种温和的正态式的随机分布)那么这种“较好状况”和“较差状况”碰撞叠加相平均,结果便会获得O(NlogN)的时间复杂度。
基于比较的排序算法时间复杂度的下限为O(NlogN),也就是说,可以作到最坏状况的时间复杂度也为O(NlogN)的算法就能够被视做最优算法了。
然而,若是使用不基于比较的排序方法,咱们能够“变得更快”,甚至达到O(N)的时间复杂度。(不过待排序列须要知足一些前提条件)
前提条件:若是待排序的序列为小范围内的整型数(Integer),咱们只需记下每一个整型数出现的频次,再按序输出就好了。
例如,待排序序列的范围是[1,9],只须要记录下“1”出现了多少次,“2”出现了多少次……再按从1到9的顺序输出就好了。
前提条件:待排序的序列能够是较大范围的整型数,可是位数不能太大。
基数排序又被称为“桶子法”(Bucket Sort)。在基数排序中,咱们将每一个待排序的数视做一个 w 长的字符串(若是长度不够能够在前面添零)
① 先从最右位(最小位)开始,将待排序的数根据最小位的数值分到(0~9)这十个“桶子”中去,再从“0号桶”开始,依次将每一个桶子中的数取出来,排成一个最小位有序的序列。
② 接着,根据倒数第二位的数值,“依序”将各数再次分到十个“桶子”中去,而后将每一个桶子中的数取出排列成新的序列。(注意取出的时候要维持放入桶中的顺序)这个时候获得就是后两位有序的序列了。
③ 重复这个操做,直到最左位,即可获得有序的数列了。
不难看出,这个排序方法是“稳定”的。其时间复杂度为O(w*(N+k))
“放”的时间复杂度为O(N),“取”的时间复杂度为O(k)(这里指有k个“桶”),一共须要操做w次(共有w位)。
背景知识
堆有如下两个性质:
- 是一棵彻底二叉树(就是只有最下一层的右侧可为空的满二叉树)
- 堆中某个节点的值老是不大于(大根堆)或不小于(小根堆)其父节点的值
一个彻底二叉树可以被存储成为一个数列A(从根节点开始,层序遍历入队),由此一来,咱们可以很容易获得节点之间的关系:
一、父节点 parent(i) = i>>1 (1/2)
二、左子节点 left(i) = i<<1 (i2)
三、右子节点 right(i) = i<<1 + 1 (i2+1)
一、初始建成一个大根堆;
二、将堆顶元素取出,并将堆末尾(对应的数列的末尾元素)移至堆顶处;
三、调整堆中的元素位置,再次构成大根堆,回到第一步,直到全部元素被取出。
为了保证堆的彻底二叉树的特性,添加元素只能在末尾添加。
添加元素以后,可能会破坏堆的顺序,所以要进行相应的交换调整。
时间复杂度为O(logN)
有两种时间复杂度不一样的初始方式:
从直观上看一下这两种初始化方式的时间复杂度区别:
在元素数为K时,可得其调整的时间复杂度为O(h) = O(logK) 由于底层的元素较多,因此咱们能够认为总体的时间复杂度向下靠拢(O(logN)) 所以能够获得堆排序的总的时间复杂度为O(N)[初始堆]+O(NlogN)[调整] = O(NlogN)