■ 快速排序面试
我的感受快速排序相对还好理解一些。大的框架上来讲,快速排序使用的是递归的思想。算法
具体描述: 首先获取数组的第0元素做为一个基准(pivot),而后从第1元素开始向右遍历,将全部小于基准的值都尽可能往左摆放。具体来讲那就是在遍历过程当中创建两个游标,一个游标i用来作全遍历,另外一个游标d_index用来标明到那个元素为止,前面的全部元素都已是被断定为小于基准所以被放到左边的边界。比较第i元素和基准大小,对于每比较到一次第i元素小于基准的状况,就交换当前i和d_index两个元素,而且将d_index自增1。遍历完一趟后此时应该交换d_index-1(此时的第d_index元素是属于大于基准的哦),这样基准元素就到了它正确的位置,剩余的事情就是再快速排序一下整个序列中[0:d_index-1]以及[d_index:]这两个子序列便可。shell
另外因为用了递归,因此得斟酌下递归返回的条件。能够想象,当递归不断深刻,对于右半区的序列而言返回的d_index是愈来愈大的,总有一天+1后会大于right,同时左半区愈来愈小,会在某一天等于0。这二者都是跳出递归的条件。总的来讲,能够设置一个left<right的条件,控制跳出。api
用代码来讲:数组
def quick_sort(lst,left=0,right=None): if right is None: right = len(lst) - 1 if left < right: partitionIndex = partition(lst,left,right) # 递归处理左半区和右半区(虽然说是半区,但并不必定是对半开,根据partitionIndex大小不一样可能会有偏颇 quick_sort(lst,left,partitionIndex-1) quick_sort(lst,partitionIndex+1,right) def partition(lst,left,right): ''' 对lst的[left:right+1]序列进行分区,最终返回分区元素的下标d_index-1 函数返回时数组所处的状态应该是d_index-1左边的都比它小,右边都比它大 :param lst: :param left: :param right: :return: ''' pivot = left i = d_index = pivot + 1 while i <= right: if lst[i] < lst[pivot]: lst[d_index],lst[i] = lst[i],lst[d_index] d_index += 1 i += 1 lst[d_index-1],lst[pivot] = lst[pivot],lst[d_index-1] return d_index - 1
(上面的"将小于基准的元素尽可能放到左边"的逻辑是从左到右依次循环遍历,碰到小于基准的放到当前未遍历区的最左端。另外还可能有多种实现逻辑,好比两个游标分别从左右开始,右边游标遇到小于基准的值就将其值赋到左边,左边游标则是遇到大雨基准值的放到右边。须要注意要灵活一些。)数据结构
若是要求quick_sort只能接受一个lst参数,那么能够考虑将递归放在partition中进行:app
def partition(lst,left,right): if left >= right: return pivot = left d = i = left + 1 while i <= right: if lst[pivot] < lst[i]: lst[d],lst[i] = lst[i],lst[d] d += 1 i += 1 lst[d-1],lst[pivot] = lst[pivot],lst[d-1] pivot = d - 1 # 别忘了这步,不然pivot是0 partition(lst,left,pivot-1) partition(lst,pivot+1,right) def quick_sort(lst): left = 0 right = len(lst) - 1 partition(lst,left,right)
● 分析框架
快速排序的时间复杂度,关键在于看要进行多少次比较和换位。能够想象,最坏的状况下,每次选出的基准都是当前待排序内容中最大值,这样会致使整个递归过程当中的右半区始终是空,全部排序负担都在左半区进行。至关于就是作了一个普通的冒泡排序,因此是O(n^2)的。在理想状况下,每次选择的基准都差很少是待排序区域的中间值,这样的话比如是二叉树的搜索,最终的时间复杂度是O(nlogn)的。整体而言,平均状况下更接近O(nlogn),是众多基于关键字排序算法中比较快的,因此被叫作了快速排序。ide
空间上,因为使用了递归,系统自动开辟了一个栈空间用来记录所有出现过的partitionIndex。易知这个栈最坏状况下将是O(n),平均的是O(logn)函数
■ 归并排序
归并的思想其实也不难,其核心想法是将待排序的数组当作先后两个(差很少)等长的数组的组合,只要保证两个数组各自都有序,而后经过必定方法将两个有序的数组再有序地合并成一个大数组就完成了排序。至于两个子数组怎么排序,那么可使用递归的归并排序便可。这个递归在子数组的长度只有1的时候中止并返回子数组自己用于上一级的归并。
代码:
拜Python中方便的数组切片所赐,归并的代码能够写得很清晰
def merge_sort(lst): leng = len(lst) if leng <= 1: return lst # 这里记得要返回子数组,若是返回None那么这个子数组的信息就丢失了! lc = lst[:leng//2] rc = lst[leng//2:] return list(merge(merge_sort(lc),merge_sort(rc))) def merge(left,right): # res = [] 借用了Python的生成器机制,没有单独开一个数组来保存结果 i,j = 0,0 m,n = len(left),len(right) while i < m and j < n: if left[i] <= right[j]: # res.append(left[i]) yield left[i] i += 1 else: # res.append(right[j]) yield right[j] j += 1 # 一个遍历完了另外一个还没完时的补处理 while i < m: # res.append(left[i]) yield left[i] i += 1 while j < n: # res.append(right[j]) yield right[j] j += 1 # return res
归并排序是一种稳定的排序方法,当多个元素同值时原先就放在左边的元素最终排序出来以后也放在左边。时间复杂度上来讲归并排序是O(nlogn)的,而且不论原数组是否处于比较理想的状态都差很少是这个。空间上来讲,由于须要额外维护一个数组用来进行子数组的合并(虽然Python用了yield,空间上可能复杂度更小一些),这个数组最大的时候也就是n个元素,所以是O(n)的空间复杂度。
■ 希尔排序
希尔排序能够看作是一个插入排序的改进型。相比于插入排序不断地遍历数组中的全部位置,希尔排序经过必定间隔将数组分红若干组,优先排列距离较远的元素,这样万一较远的元素是可能须要交互位置的元素时能够比较快的完成操做。希尔排序又叫减少增量排序。
希尔排序的想法是这样的,首先指定一个gap值,一般能够指定为数组长度//2。将数组中全部0,0+gap,0+2*gap...这些下标的元素做为一个组进行简单的插入排序,保证位于这些隔着gap格的位置上的各个元素处于有序的状态。处理完这一组后继续处理1,1+gap...这一组,以此类推。将全部组都处理完成后,将gap减少1,重复上述操做。最终当gap==1的时候,在进行逐个元素之间的微调便可,最终得到有序序列。
代码:
def shell_sort(lst): gap = len(lst) // 2 # 初始指定gap是长度的一半 while gap > 0: # 不断缩小gap i = gap while i < len(lst): # 通过这个循环能够保证以当前gap为间隔造成的各个组都有序了 j = i while j-gap >= 0: # 每趟遍历保证最小的放在最左边 if lst[j-gap] > lst[j]: lst[j-gap],lst[j] = lst[j],lst[j-gap] j -= gap i += 1 gap /= 2
希尔排序的复杂度取决于增量gap的具体设置。像上面这个例子中使用了gap = len(lst) // 2开始计算,这样整个排序的时间复杂度可能在O(n^2)左右。通常希尔排序最低的时间复杂度能够下降到O(nlog2n),比直接插入排序要快一点,可是仍是慢于快速排序的O(nlogn)。
至于空间复杂度,因为不涉及任何递归以及额外的数据结构,因此是O(1)的。
● 通常模式说明
通常说到希尔排序,算法结构就是如上面代码那样的。可是在本身实现的时候,常常会把第二层循环写成for(i=0; i<gap; i++)。而后把第三层循环写成for(j=i; j+gap<len(lst); j+=gap)。这样写其实从功能实现的角度来讲没什么毛病,无非是将最里层的循环不变式改为了最大的放在最右边。
可是须要注意的是公认的希尔排序,仍是以上面代码中的模式为准。为了考试面试等,仍是须要把本身的思惟扭转过来。
■ 堆排序
在写二叉树和堆的数据结构的时候已经提到过了堆排序如何构建以及使用。就再也不写了。
不过世面上面经常使用的堆排序的“模板”在细节上和我看的那本书里描述的堆排序还有些不一样(好比约定俗成的函数名等等),下面特意写一下符合世间通常标准的堆排序代码:
def buildMaxHeap(lst): for i in range((len(lst)-1)//2,-1,-1): heapify(lst,i) def heapify(lst,i): # heapify至关于那边书上的shiftdown,不过参数要少一些 lc = 2 * i + 1 rc = 2 * i + 2 while rc < len(lst) or lc < len(lst): if rc < len(lst) and lst[rc] > lst[lc]: t = rc else: t = lc if lst[t] > lst[i]: lst[i],lst[t] = lst[t],lst[i] i = t lc,rc = 2*i+1,2*i+2 def heapSort(lst): buildMaxHeap(lst) i = len(lst)-1 res = [] # 其实若是经过堆自己结构来存储已排序的部分,能够不用这个额外的数据结构,只是heapify中没有end参数,致使Python中很难指定某一次向下筛选的下边界 while lst: # 条件也能够写是while i >= 0之类的 lst[0],lst[i] = lst[i],lst[0] res.insert(0,lst.pop()) heapify(lst,0) i -= 1 return res
无非就是把向下筛选的shiftdown叫作了heapify,而且是经过大顶堆来排序的。
● 分析
正如书上所说,构造堆的过程是一个O(n)的过程(详细证实目前我还作不出来…,凭感受看应该是O(nlogn)的,但并非),排序时每一个元素都会去进行一次向下筛选,因此总的时间复杂度是O(nlogn)。通常来讲若是充分利用堆结构自己的空间那么能够不用外部数据结构记录结果,因此空间复杂度能够作到O(1)。
■ 计数排序
* 基数排序和计数排序是两个不一样的东西…
上述全部排序算法,通常状况下时间复杂度是O(nlgn)的,对于某些算法如快速排序,若是状况很糟糕的话反而会更差。
另外一方面,从总体的角度出发看待排序操做,咱们必然是要访问数组中每一个元素的值的,否则没办法作出完整正确的排序。所以排序算法通常复杂度的下界就是O(n)了。那么有没有O(n)的排序算法实现呢?答案是确定的。并且为了让时间复杂度下降到O(n),咱们必然要采起的策略就是以空间换时间,计数排序就是这样一种线性时间复杂度,可是空间消耗可能比较大的排序算法。
● 描述与实现
对于数组中的某个数,排序完成(假设是升序排序)以后这个数在有序数组中的位置如何肯定?针对这个问题,显然,答案是其下标是i-1,i是数组中全部小于等于它值的数的个数(因为是小于等于,包括其自身因此要减去1,暂时不考虑有相同值的元素)。
基于这个显而易见的事实,计数排序的基本想法就是,针对待排序数组lst,额外维护一个数组B。B的长度是max(lst)+1,而B[i]元素的值是lst中全部小于等于i的元素的个数(注意是i,下标值)。
示例: 若是lst是[2,1,5,3,2,3],那么维护的这个B是[0, 1, 3, 5, 5, 6]。其中B中值的意义就是,lst中小于等于0的值是0个,小于等于1的值的个数是1个,小于等于2的值的个数是3个……
得出B以后,接下来要作的是逆序遍历原数组lst,对于元素lst[j],访问B[lst[j]],得到到的数字 - 1(减去1是为了调和下标和个数之间相差1的问题)就是这个lst[j]值该在排序后数组安排的下标的位置。安排完成后,别忘了B[lst[j]] -= 1,代表若是后面又遇到和lst[j]等值的元素,那么这些元素应该往前一格安排。因为排序直接针对下标进行赋值,咱们不能直接用原lst做为结果的容器,所以能够另新开一个和lst等长的数组C,用C做为结果容器来记录排序结果。
计数排序的一个特色就是它是稳定的,而稳定的依据在于咱们遍历原数组的时候使用的是逆序遍历。因为B中的值是逐渐递减的,因此等值元素安排入新数组的顺序是从后往前的,为了保证稳定性,遍历的时候也从后往前才行。
实现代码也不难:
def count_sort(lst): leng = len(lst) B = [0] * (max(lst)+1) # 这里也务必要+1,若不加1的话max(lst)没法在B中获得维护 C = [None] * leng for i in lst: # 首先计数每种元素的出现次数 B[i] += 1 tmp = 0 for i,count in enumerate(B): # 将B[i]从新赋值成sum(B[:i]) B[i] = count + tmp tmp += count for j in range(leng-1, -1, -1): # 逆序遍历 val = lst[j] C[B[val]-1] = val # B[val] - 1的减去1别忘了 B[val] -= 1 return C
该说的上面基本都说了,略微值得一提的是求B的过程,分红两步走。第一步是统计每一个值在lst中出现的次数,第二步是将某个下标为i的值B[i]从新赋值成B[0]到B[i-1]全部值的和,如此递归到B最后一个值。这样获得的B就是符合要求的,“小于等于个人元素有多少个”的列表了。
● 分析
回到算法总体上来,上面说了计数算法是个稳定的排序算法。再来看看时间复杂度,组成程序主体的是三个循环,第一个和第三个循环分别是O(n)的,而第二个循环是O(k)的。n,k分别表示原数组lst长度和数组中最大数值。因此整个算法的复杂度是O(n + k)。当k << n的时候,这个算法的排序基本上属于O(n)的。而空间复杂度,咱们建立了一个长度为n的结果数组和一个长度为k的辅助数组,所以总空间复杂度也是O(n+k)。
技术排序的弱点也很明显,若是n不大可是k很大的话,其复杂度仍是比较使人恐惧的。
另外上述具体代码适用的具体条件还有一个就是默认lst中元素都含有正整数的接口供排序使用。若是元素的数值类型不是正整数,或者没有这个接口的话,恐怕要另寻他路。我一开始尝试想使用HashMap来记录元素出现次数,可是发现为了要统计“小于等于个人元素个数”,HashMap自己又要作一次排序…