接上篇继续汇总排序算法。
五、归并排序
5.1 原地归并
算法描述:实现归并最直接的方法就是将两个不同的有序数组归并到第三个数组中,常用的方法是创建适当大小的数组,将两个输入数组中的元素一个个从小到大放入这个数组当中。但随着归并次数的增加,需要创建的数组很多,空间复杂度很高。我们希望能有一种原地归并的方法,先将前半部分排序,再将后半部分排序,然后在数组中移动而不需要使用额外的空间。
原地归并的核心就是覆盖原数组,从而不需要每次返回新的数组。这里介绍两种方法,算法第四版中的方法,以及手摇算法,并进行适当的分析。
A. 它将涉及的所有元素复制到一个辅助数组中,再把归并的结果放回原数组中。
复杂度分析:由于返回的是原数组,空间复杂度 O(1),时间复杂度和归并排序相同
示例:
import copy
def merge(nums,lo,mid,hi):
n= len(nums)
i,j = lo,mid+1
aux = copy.deepcopy(nums)
k = lo
while k<=hi:
if j<=hi and (i>mid or aux[j]<aux[i]):
nums[k]=aux[j]
j+=1
else:
nums[k]=aux[i]
i+=1
k += 1
return nums
B.手摇算法。(参考 https://blog.csdn.net/qq_36771269/article/details/80397186 )。通过三次逆序,实现位置的变换。A算法其实不能算是严格原地归并算法,它仍旧借助了辅助数组。手摇算法真正实现了“原地”“这个概念。首先举例说明一下什么是手摇算法:想要将 EFABCD转换为ABCDEF。分为三步
E F A B C D
第一步: F E A B C D
第二步: F E D C B A
第三步: A B C D E F
首先将左边逆序,再将右边逆序,最后全部逆序。
理解手摇算法之后,如何将其与merge函数融合?用 i,j 指代起点和中点。i指针不断向后移动,直到找到第一个比j指向的元素大的元素或者到达中点, index指针先代替j指向右端的第一个元素 ,j指针不断向后移动,直道找到第一个比i指向元素大的元素或者直到遇到数组的末尾,最后将i~index-1段和index~j-1段进行手摇,之后将 i 移动j-index+1空位,然后继续上述操作。
复杂度分析: 整体空间复杂度仅为交换操作,为O(1)。但是时间复杂度在最好的情况,左子段和右子段直接全部交换,复杂度还是O(n*logn),但最坏的情况,一段一段的缓慢前进的情况;此时算法的时间复杂度就是n*n,原地归并的复杂度就是O(n*n*logn)。 综合起来原地归并的时间复杂度在O(n*logn)–O(n*n*logn)之间。
示例:
def merge(nums,lo,mid,hi):
i,j = lo,mid+1
while i<=mid and j<=hi:
if nums[i]<=nums[j]:
i+= 1
else:
index=j
j += 1
while j<=hi and nums[j]<nums[i]:
j += 1
nums[i:index]=nums[i:index][::-1]
nums[index:j]=nums[index:j][::-1]
nums[i:j] = nums[i:j][::-1]
return nums
5.2 自顶向下的归并排序(递归方法)
自顶向下实际上就是递归的一个过程,分解(sort)到底部之后再进行合并(merge),合并主要使用前述merge。可见树状图(下图,N=16)来理解整个问题。每个结点都表示一个sort() 方法通过merge() 方法归并而成的子数组。这棵树有n层,0~n-1之间的任意k有 2^k 个子数组,每个数组长度2^(n-k),每次归并需要2^(n-k)次比较,因此每层比较次数2^n,总共n2^n, 由于2^n记为N,总共为NlgN。
def sort(nums,lo,hi):
if lo>=hi:
return
mid = (hi+lo)//2
sort(nums,lo,mid)
sort(nums,mid+1,hi)
return merge(nums,lo,mid,hi)
拓展思考:
1. 用不同的方法处理小规模问题能改进大多数递归算法的性能,递归会使小规模问题中方法的调用过于频繁,因而改进可以改善性能。如果在小数组(一般长度小于15)上使用插入排序,会比归并排序更快,可以将归并排序的运行时间缩短10%到15%。
def sort(nums,lo,hi):
if lo >= hi:
return
if hi-lo<=15:
for i in range(lo+1,hi+1):
for j in range(i,lo,-1):
if nums[j] < nums[j-1]:
nums[j],nums[j-1]=nums[j-1],nums[j]
return nums
else:
mid = (hi+lo)//2
sort(nums,lo,mid)
sort(nums,mid+1,hi)
return merge(nums, lo, mid, hi)
2.如果添加一个判断条件,如果a[mid]小于a[mid+1],我们就认为数组已经是有序的,就可以跳过merge()方法,这个过程不影响排序的递归调用,但是任意有序的子数组算法的运行时间就变成线性的了。
def sort(nums,lo,hi):
# print lo,hi
if lo>=hi:
return
mid = (hi+lo)//2
sort(nums,lo,mid)
sort(nums,mid+1,hi)
# return merge(nums, lo, mid, hi)
if nums[mid]<=nums[mid+1]:
return nums
else:
return merge(nums,lo,mid,hi)
3. 通过切换辅助数组和输入数组,缩短数组元素的复制时间。通过标签,切换两种排序方法,一种将数据从输入数组排序到辅助数组;一种将数据从辅助数组排序到输入数组。
5.3 自底向上的归并排序(循环)
自顶向下是算法设计中”分治“的思想。将大问题分割成小问题分别解决,然后用所有小问题的答案来解决整个大问题。在这里,自底向上刚好相反。先归并微型数组,然后再成对归并并得到子数组。直至将所有数组归并在一起。这种方法,代码量更小,两两归并,四四归并,八八归并,一直下去。
def sort(nums):
n = len(nums)
sz = 1
while sz<n:
j = 0
while j<n:
nums = merge(nums,j,j+sz-1,min(j+sz*2-1,n-1))
j += sz*2
sz *= 2
return nums
5.4 小结与分析
自底向上的归并排序比较适合用链表组织的数据(leetcode上有一题,可以去实践一下)。这种方法只需要重新组织链表链接就能将链表原地排序,不需要新的链接节点。
归并排序是一种渐近最优的基于比较排序的算法。归并排序在最坏情况下的比较次数和任意基于比较的排序算法所需的最少比较次数都是~NlgN。
六、快速排序
快速排序也是一种分治的排序算法,它将一个数组分成两个子数组,将两部分独立地排序。中心思想是:当两个子数组都有序时,整个数组就有序了。和归并排序排序不同,归并排序的递归调用发生在处理整个数组之前,找到最小子元素,但对于快速排序来说,递归调用发生在处理整个数组之后,切分的位置取决于数组的内容。
整个算法的关键在partition 切分部分。数组需要满足以下三个条件:
1)对于某个j,a[j] 已经确定
2)a[lo] 到 a[j-1] 中的所有元素不大于 a[j]
3)a[j+1] 到 a[hi] 中的所有元素不小于 a[j]
复杂度分析:将长度为N的无重复数组排序,平均需要 ~2NlgN次比较;最多需要N^2/2次比较,但是随机打乱数组能够预防这种情况。快速排序具有两个明显的优势:第一、快速排序切分方法的内循环会使用一个递增的索引将数组元素和一个定值比较,十分简洁;第二,它需要的比较次数很少
def partition(nums,lo,hi):
k = nums[lo]
i,j=lo+1,hi
while True:
while nums[i]<=k:
if i == hi:
break
i += 1
while nums[j]>k:
if j == lo:
break
j -= 1
if i >= j:
break
nums[i],nums[j]=nums[j],nums[i]
nums[lo],nums[j]=nums[j],nums[lo]
return j,nums
def sort(nums,lo,hi):
if hi<=lo:
return
j,nums = partition(nums,lo,hi)
sort(nums,lo,j-1)
sort(nums,j+1,hi)
return nums
快速排序的改进:
1. 切换到插入排序。和前面类似,对于小数组,快速排序比插入排序要慢,因此最简单的改动就是当长度小于例如5~15时,切换成插入排序:
if (hi <= lo + M):
return insertion(nums,lo,hi)
2. 三取样切分。使用子数组的一小部分元素的中位数来切分数组。这样使得切分更好。将取样大小设为3, 用大小居中的元素切分效果最好。同时可以将取样元素放在数组末尾作为“哨兵”,去掉partition()中的数组边界测试。
3. 熵最优排序。适用于有大量重复元素的情况。介绍三向切分的快速排序。
维护一个指针 lt 使得 a[lo..lt-1] 中的元素都小于v,一个指针 gt 使得 a[gt+1..hi] 中的元素都大于v,一个指针 i,使得 a[lt...i-1]中的元素都等于v,a[i..gt]中的元素还未确定,按照快排方法进行交换,直至所有元素均被处理。
def sort(nums,lo,hi):
if hi<=lo:
return
lt,i,gt=lo,lo+1,hi
v = nums[lo]
while i <=gt:
if nums[i]<v:
nums[lt],nums[i]=nums[i],nums[lt]
i += 1
lt += 1
elif nums[i]>v:
nums[gt],nums[i] = nums[i],nums[gt]
gt -= 1
else:
i +=1
sort(nums,lo,lt-1)
sort(nums,gt+1,hi)
return nums
补充一下几个结论,三向切分的最坏情况是所有的主键均不相同,当存在主键相同时,就会比归并排序的性能好很多。通过香农定理有以下两个结论(证明过程并没有看懂):不存在任何基于比较的排序算法能够保证在NH-N次比较之内将N个元素排序,其中H为由主键值出现的频率定义的香农信息量;对于大小为N的数组,三向切分的快速排序需要~(2ln2)NH次比较。
反正记住,三向切分的快速排序的运行时间和输入的信息量的N倍是成正比的。对于包含大量重复元素的数组,它可以将排序时间从对数降低到线性级别。记得在排序前将数组打乱以避免最坏情况。
七、堆排序
7.1 堆的定义
当一颗二叉树的每个结点都大于等于它的两个子结点时,它被称为堆有序。其中,根结点是堆有序的二叉树中的最大结点。接下来,对于堆的表示,具有特别好性能的就是完全二叉树,它只需要数组而不需要指针就可以表示。它将二叉树的结点按层序放入数组,位置 k 的结点的父节点位置为 ,它的两个子结点的位置分别为 2k 和 2k+1。这个关系非常非常重要!
另外,值得注意的是,这里堆的数组从序号1 开始, nums[0]不放任何数据,可取“/”。
7.2 重要操作
基本操作--上浮(swim):当某个结点的优先级上升(或是在堆底加入一个新的元素)时,我们需要由下至上恢复堆的顺序。具体来说,如果堆的有序状态因为某个结点比它的父节点更大而被打破,我们需要通过交换它和它的父结点来修复堆。同样可以向上依次恢复秩序。这个过程,实现起来也比较简单:
def swim(nums,k):
while k>1 and nums[k]>nums[k/2]:
nums[k],nums[k/2]=nums[k/2],nums[k]
k = k/2
return nums
基本操作--下沉(sink):如果堆的有序状态因为某个结点变得比其他两个结点或者其中之一个结点更小而被打破,那么可以通过与子结点中较大的进行交换来恢复,同样不断重复至完全修复。
def sink(nums,k):
while 2*k <len(nums):
j = 2*k
if j+1<len(nums) and nums[j]<nums[j+1]:
j += 1
if nums[k]<nums[j]:
nums[j],nums[k]= nums[k],nums[j]
k = j
插入:将新元素加入到数组末尾,增加堆的大小,并让这个新元素上浮到合适位置。
def insert(nums,v):
nums.append(v)
return swim(nums,len(nums)-1)
删除最大元素:从数组顶端删去最大的元素,并将数组的最后一个元素放到顶端,减小堆的大小并让这个元素下沉到合适的位置。
def delmax(nums):
nums[-1],nums[1]=nums[1],nums[-1]
maxnum = nums.pop(-1)
return maxnum,sink(nums, 1)
对于一个含有N个元素的基于堆的优先队列,插入元素操作只需要不超过 lgN+1 次比较,删除最大元素的操作需要不超过 2lgN 次比较。
7.3 堆排序
思路很简单,将所有元素插入一个查找最小元素的优先队列,然后再重复调用删除最小元素的操作来将它们按顺序删去。分为两个阶段,第一,在堆的构造中,将原始数据重新组织安排进一个堆。第二下沉排序,从堆中按递减顺序取出所有元素并得到排序结果。
堆的构造:从右至左扫描数组,跳过大小为1的堆。从上往下下沉。(和我们常规思维,从左往右扫描数组不同,从右往左下沉效率更高。因为我们的目标是构造一个堆有序的数组并使最大元素位于数组的开头,次大元素在附近,而非构造函数结束的末尾。下沉操作由 N 个元素构造堆只需少于2N次比较以及少于N次交换。
下沉排序:将堆中最大元素删除,然后放入堆缩小后空出的位置。
有区别的是,这里对sink函数进行了改动,引入了变量 n, 这样将最大元素放至末尾,不需要额外的空间。写代码时,需要注意一下边界条件。
def sink(nums,n,k):
while 2*k < n:
j = 2*k
if j+1<n and nums[j]<nums[j+1]:
j = j+1
if nums[k]<nums[j]:
nums[j],nums[k]= nums[k],nums[j]
k = j
return nums
def sort(nums):
nums = ["/"]+ nums
n = len(nums)
for i in range(n/2,0,-1):
nums = sink(nums,n,i)
while n>1:
n -= 1
nums[1],nums[n] = nums[n],nums[1]
nums = sink(nums,n,1)
return nums[1:]
最后简单说一下三种其他的排序。分别是基数排序、桶排序和计数排序。
八、基数排序
直接用一个例子理解一下:分别从个位、十位、百位依次对数组进行排序。
复杂度分析:设待排序列为n个记录,d为执行回合数,基数为 r,则进行链式基数排序的时间复杂度为O(d(n+r)),最好、最坏、平均均为此,其中,一趟分配时间复杂度为O(n),一趟收集时间复杂度为O(r),共进行d趟分配和收集。
空间复杂度来说:使用二维矩阵来当桶子:Ο(n × r),需要r个桶子,每个桶子需可放n个资料 ⇒ Ο( n × r);使用链表来当桶子,需要O(n).
import math
def sort(nums,r=10):
k = int(math.ceil(math.log(max(nums),r)))
for i in range(1,k+1):
buckets = [[] for _ in range(r)]
for item in nums:
buckets[item%(r**i)/(r**(i-1))].append(item)
nums=[]
for each in buckets:
nums +=each
return nums
九、桶排序
桶排序的基本思想是将一个数据表分割成许多buckets,然后每个bucket各自排序,或用不同的排序算法,或者递归的使用bucket sort算法。也是典型的divide-and-conquer分而治之的策略。
每个桶内的排序算法,根据情况限定。
图来源:https://blog.csdn.net/developer1024/article/details/79770240
复杂度分析:对N个关键字进行桶排序的时间复杂度分为:循环计算每个关键字的桶映射函数 O(N); 利用其他排序算法对每个桶内的所有数据进行排序,为 ∑ O(Ni*logNi) 。其中Ni 为第i个桶的数据量。
第(2)部分是桶排序性能好坏的决定因素。所以我们要尽量减少桶内数据的数量。有以下两点:(1) 映射函数f(k)能够将N个数据平均的分配到M个桶中,这样每个桶就有[N/M]个数据量。(2) 尽量的增大桶的数量。对于N个待排数据,M个桶,平均每个桶[N/M]个数据的桶排序平均时间复杂度为:O(N)+O(M*(N/M)*log(N/M))=O(N+N*(logN-logM))=O(N+N*logN-N*logM) 当N=M时,即极限情况下每个桶只有一个数据时。桶排序的最好效率能够达到O(N)。
桶排序的空间复杂度 为O(N+M)。
十、计数排序
总体概括来说,主要思想是根据获得的数据表的范围,分割成不同的buckets,然后直接统计数据在buckets上的频次,然后顺序遍历buckets就可以得到已经排好序的数据表。如下图例子,
算法的步骤如下:
- 找出待排序的数组中最大和最小的元素
- 统计数组中每个值为i的元素出现的次数,存入数组C的第i项
- 对所有的计数累加(从C中的第一个元素开始,每一项和前一项相加)
- 反向填充目标数组:将每个元素i放在新数组的第C(i)项,每放一个元素就将C(i)减去1
计数排序算法没有用到元素间的比较,它利用元素的实际值来确定它们在输出数组中的位置。因此,计数排序算法不是一个基于比较的排序算法,从而它的计算时间下界不再是O(nlogn)。算法中的循环时间代价都是线性的,还有一个常数k,因此时间复杂度是O(n+k)。另一方面,计数排序算法之所以能取得线性计算时间的上界是因为对元素的取值范围作了一定限制,即k=O(n)。当k=O(n)时,我们采用计数排序就很好,总的时间复杂度为O(n)。
总体是一个空间换时间的方法。比较稳定。
最后的最后, 一定记住 排序算法小结(上) 的那张小结的图。然后对于较为常见的算法要做到熟悉。