在以前的文章当中,咱们经过海盗分金币问题详细讲解了递归方法。python
咱们能够认为在递归的过程中,咱们经过函数本身调用本身,将大问题转化成了小问题,所以简化了编码以及建模。今天这篇文章呢,就正式和你们聊一聊将大问题简化成小问题的分治算法的经典使用场景——排序。面试
排序算法有不少,不少博文都有总结,号称有十大经典的排序算法。咱们信手拈来就能够说上来不少,好比插入排序、选择排序、桶排序、希尔排序、快速排序、归并排序等等。老实讲这么多排序算法,但咱们实际工做中并不会用到那么多,凡是高级语言都有自带的排序工具,咱们直接调用就好。为了应付面试以及提高本身算法能力呢,用到的也就那么几种。今天咱们来介绍一下利用分治思想实现的两种经典排序算法——归并排序与快速排序。算法
咱们先来说归并排序,归并排序的思路其实很简单,说白了只有一句话:两个有序数组归并的复杂度是\(O(n)\)。数组
咱们举个例子:数据结构
a = [1, 4, 6] b = [2, 4, 5] c = []
咱们用i和j分别表示a和b两个数组的下标,c表示归并以后的数组,显然一开始的时候i, j = 0, 0。咱们不停地比较a和b数组i和j位置大小关系,将小的那个数填入c。app
填入一个数以后:less
i = 1 j = 0 a = [1, 4, 6] b = [2, 4, 5] c = [1]
填入两个数以后:函数
i = 1 j = 1 a = [1, 4, 6] b = [2, 4, 5] c = [1, 2]
咱们重复以上步骤,直到a和b数组当中全部的数都填入c数组为止,咱们能够很方便地写出以上操做的代码:工具
def merge(a, b): i, j = 0, 0 c = [] while i < len(a) or j < len(b): # 判断a数组是否已经所有放入 if i == len(a): c.append(b[j]) j += 1 continue elif j == len(b): c.append(a[i]) i += 1 continue # 判断大小 if a[i] <= b[j]: c.append(a[i]) i += 1 else: c.append(b[j]) j += 1 return c
从上面的代码咱们也能看出来,这个过程虽然简单,可是写成代码很是麻烦,由于咱们须要判断数组是否已经所有填入的状况。这里有一个简化代码的优化,就是在a和b两个数组当中插入一个”标兵“,这个标兵设置成正无穷大的数,这样当a数组当中其余元素都弹出以后。因为标兵大于b数组当中除了标兵以外其余全部的数,就能够保证a数组永远不会越界,如此就能够简化不少代码了(前提,a和b数组当中不存在和标兵同样大的数)。优化
咱们来看代码:
def merge(a, b): i, j = 0, 0 # 插入标兵 a.append(MAXINT) b.append(MAXINT) c = [] # 因为插入了标兵,因此长度判断的时候须要-1 while i < len(a)-1 or j < len(b)-1: if a[i] <= b[j]: c.append(a[i]) i += 1 else: c.append(b[j]) j += 1 return c
这里应该都没有问题,接下来的问题是咱们怎么利用归并数组的操做来排序呢?
其实很简单,这也是归并排序的精髓。
咱们每次将一个数组一分为二,显然,这个划分出来的数组不必定是有序的。但若是咱们继续切分呢?直到数组当中只有一个元素的时候,是否是就自然有序了呢?
咱们举个例子:
[4, 1, 3, 2] / \ [4, 1] [3, 2] / \ / \ [4] [1] [3] [2] \ / \ / [1, 4] [2, 3] \ / [1, 2, 3, 4]
经过上面的这个过程咱们能够发现,在归并排序的时候,咱们先一直往下递归切分数组,直到全部的切片当中只有一个元素自然有序。接着一层一层地归并回来,当全部元素归并结束的时候,数组就完成了排序。这也就是归并排序的所有过程。
若是还不理解,还能够参考一下下面的动图。
咱们来试着用代码来实现。以前我曾经在面试的时候被要求在白板上写过归并排序,当时我用的C++以为编码还有必定的难度。如今,当我用习惯了Python以后,我感受编码难度下降了不少。由于Python支持许多数组相关的高级操做,好比切片,变长等等。整个归并排序的代码不超过20行,咱们一块儿来看下代码:
def merge_sort(arr): n = len(arr) # 当长度小于等于1,说明自然有序 if n <= 1: return arr mid = n // 2 # 经过切片将数组一分为二,递归排序左边以及右边部分 L, R = merge_sort(arr[: mid]), merge_sort(arr[mid: ]) n_l, n_r = len(L), len(R) # 数组当中插入标兵 L.append(sys.maxsize) R.append(sys.maxsize) new_arr = [] i, j = 0, 0 # 归并已经排好序的L和R while i < n_l or j < n_r: if L[i] <= R[j]: new_arr.append(L[i]) i += 1 else: new_arr.append(R[j]) j += 1 return new_arr
你看,不管是思想仍是代码实现,归并排序并不难,就算一开始不熟悉,写个两遍也必定没问题了。
理解了归并排序以后,再来学快速排序就不难了,咱们一块儿来看快速排序的算法原理。
快速排序一样利用了分治的思想,咱们每次作一个小的操做,让数组的一部分变得有序,以后咱们经过递归,将这些有序的部分组合在一块儿,达到总体有序。
在归并排序当中,咱们划分问题的方法是横向切分,咱们直接将数组一分为二,针对这两个部分分别排序。快排稍稍不一样,它并非针对数组的横向切分,而是从问题自己出发的”纵向“切分。在快速排序当中,咱们解决的子问题不是对数组的一部分排序,而是提高数组的有序程度。怎么提高呢?咱们在数组当中寻找一个数,做为标杆,咱们利用这个标杆调整数组当中元素的顺序。将小于它的放到它的左侧,大于它的放到它的右侧。这么一个操做结束以后,能够确定的是,这个标杆所在的位置就是排序完成以后,它应该在的位置。
咱们来看个例子:
a = [8, 4, 3, 9, 10, 2, 7]
咱们选择7做为标杆,一轮操做以后能够获得:
a = [2, 4, 3, 7, 9, 10, 8]
接着咱们怎么作呢?很简单,咱们只须要针对标杆前面以及标杆后面的部分重复上述操做便可。若是还不明白的同窗能够看一下下面这张动图:
若是用C++写过快排的同窗确定对于快排的代码印象深入,它是属于典型的原理不难,可是写起来很麻烦的算法。由于快速排序须要用到两个下标,写的时候一不当心很容易写出bug。一样,因为Python当中动态数组的支持很是好,咱们能够避免使用下标来实现快排,这样代码的可读性以及编码难度都要下降不少。
多说无益,咱们来看代码:
def quick_sort(arr): n = len(arr) # 长度小于等于1说明自然有序 if n <= 1: return arr # pop出最后一个元素做为标杆 mark = arr.pop() # 用less和greater分别存储比mark小或者大的数 less, greater = [], [] for x in arr: if x <= mark: less.append(x) else: greater.append(x) arr.append(mark) return quick_sort(less) + [mark] + quick_sort(greater)
整个代码出去注释,不到15行,我想你们应该都很是容易理解。
最后,咱们来分析一下这两个算法的复杂度,为何说这两个算法都是\(nlogn\)的算法呢?(不考虑快速排序最差状况)这个证实很是简单,咱们放一张图你们一看就明白了:
咱们在递归的过程中,咱们只遍历了一遍数组,虽然咱们每一层都会讲数组拆分。可是在递归树上同一层的递归函数遍历的总数加起来应该是等于数组的总长也就是n的。
并且递归的层数是有限制的,由于咱们每次都将数组一分为二。而一个数组的最小长度是1,也就是说极端状况下咱们一共能有\(\log_2^n\)层,每一层的复杂度总和是n,因此总体的复杂度是\(nlogn\)。
固然对于快速排序算法来讲,若是数组是倒序的,咱们默认取最后一个元素做为标杆的话,咱们是没法切分数组的,由于除它以外全部的元素都比它大。在这种状况下算法的复杂度会退化到\(n^2\)。因此咱们说快速排序算法最差复杂度是\(O(n^2)\)。
到这里,关于归并排序与快速排序的算法就讲完了。这两个算法并不难,我想学过算法和数据结构的同窗应该都有印象,可是在实际面试当中,真正能把代码写出来而且没有明显bug的实在是很少。我想,不论以前是否已经学会了,回顾一下都是颇有必要的吧。
今天的文章就到这里,但愿你们有所收获。若是喜欢本文,请顺手点个关注吧。