再探排序
记得之前写过一篇关于快排的博客,当时只是写了一个快排的代码片段,加上简单的优化意见——当递归到一定规模的序列时使用插排提升效率、随机选取比较的元。那篇博客是在准备面试时看到网上一些资料而写的简单的笔记。
最近在重新读《数据结构与算法分析》,这本书是大二时数据结构课的教材。记得当初上课没有用心学习,最后的成绩也算是勉勉强强八十几分。到现在,对于排序也就记得大体的思想,真正上手写代码却是漏洞百出。现在重新回顾,得到了很多新的感悟,原来像团团迷雾的算法细节也终于清晰起来。本篇博客会对插入排序、希尔排序、快速排序、归并排序的读书体会与收获进行记录。
插入排序
基本思想
插入排序的思想是:对于一个已经排序(不妨设从小到大排序)的序列,如果要新增元素并且保持序列的大小顺序,我只要从最后(即最大)的数字开始寻找,找到第一个比自己小的数字,然后插入到这个位置就好了。下表展示了插入元素的具体过程:
步骤 | 序列 |
---|---|
初始序列 | 12 3 67 45 4 92 |
插入3 | 3 12 67 45 4 92 |
插入67(不变) | 3 12 67 45 4 92 |
插入45 | 3 12 45 67 4 92 |
插入4 | 3 4 12 45 67 92 |
插入92(不变) | 3 4 12 45 67 92 |
复杂度分析
在进行排序算法的复杂度分析之前,我们先了解一下逆序的概念。我们称具有$i < j$但$A[i] > A[j]$性质的数偶$< A[i], A[j]>$为逆序。
很容易可以证明,N个元素组成的序列平均有$\frac{N*(N-1)}{4}$个逆序:N个元素中一共有$\mathbf{C}_n^2$个数偶。平均情况下,则一般是逆序一般是正序,$L_r+L = \mathbf{C}_n^2$,则逆序数$L_r=\frac{\mathbf{C}_n^2}{2}=\frac{N*(N-1)}{4}$。
为什么要证明逆序的数量呢?因为排序的本质是消除逆序:如果一个序列没有逆序,那么它就是已经有序的了。在插入排序中,如果已排序的序列中有$M$个比$A[i]$大的元素,那么这个$A[i]$和这$M$个元素就形成了$M$个逆序。由于插入对于后面的元素和$A[i]$的位置关系没有影响,所以一次插入恰巧可以消除$M$个逆序。但是,一次插入的代价则是M个元素都向后移动一位,同时$A[i]$存放在相应的位置,所以消除M个逆序的代价是$M+1$。平均下来,每逆序的消除代价是$O(1)$。不算上其他的附加操作,消除$\frac{N*(N-1)}{4}$个逆序就需要$O(N^2)$的开销了。
事实上,对于所有每次都只交换相邻元素的排序(说的就是冒泡排序…),他们每次都只能消除一个逆序,$O(N^2)$就是他们的牢笼了。
代码实现
在代码实现时,有两种比较常见的比较方案——从前向后比较和从后向前比较。
在从前向后比较的实现中,一旦发现比自己大的元素,则从这个元素开始,所有的元素都向后移动一个单位;如果所有的元素都小于等于自己,则需要判断是否已经到最后。
在从后向前比较的实现中,只要元素比自己大就向后移动一个单位,直到遇到比自己小的元素,并放入这个位置。如果所有的元素都小于等于自己,则只需要比较最后一个元素,发现比自己小就停止比较了。
我们可以看出,从后向前进行比较的方式比较简单,而且不用遍历比自己小的元素,且截止条件很明确:遇到比自己小的元素或者到达位置0处。而从前向后比较的截止位置还要根据当前插入元素的位置来调整,不那么直观;同时所有的元素都会被遍历到,效率比前者低下。如果所有的元素相等,前者只需要$O(N)$,而后者则需要$O(N^2)$的时间。
class InsertSort
# 实现1:从0开始寻找比list[i]大的值,这样如果所有的值都小于等于list[i],依然要遍历i个元素
def self.insert_sort_1(list)
size = list.size
(1...size).each do |i|
(0...i).each do |j|
insert(list, i, j) and break if list[i] < list[j]
end
end
end
# 实现2:从末端开始寻找比list[i]小的值,并且在寻找的同时移动元素,这样如果所有的值都小于等于list[i],则只需要找第一个值就可以,所以这种方式更优
def self.insert_sort_2(list)
size = list.size
(1...size).each do |i|
j = i
tmp = list[i]
# 依次移动比list[i]大的元素,直到遇到比list[i]小的元素或者到达顶端
while j>0 && tmp < list[j-1]
list[j] = list[j-1]
j -= 1
end
list[j] = tmp
end
end
private
def self.insert(list, index, pos)
tmp = list[index]
i= index
while i > pos do
list[i] = list[i-1]
i -= 1
end
list[pos] = tmp
end
end
希尔排序
基本思想
希尔排序的想法很简单,既然相邻元素交换一次只能消除一个逆序,那么隔着多个元素交换不就能解除$O(N^2)$的限制了吗?希尔排序先以一个较大距离进行一趟排序,再以小一些的距离进行第二趟排序……最后到只比较相邻元素时,大多数逆序已经被消除了:
步骤 | 序列 |
---|---|
初始序列 | 12 3 67 45 4 92 54 23 78 11 8 |
距离为4 | 4 3 8 23 12 11 54 45 78 92 67 |
距离为2 | 4 3 8 11 12 23 54 45 67 92 78 |
距离为1 | 3 4 8 11 12 23 45 54 67 78 92 |
记某次排序的距离为$h_k$,那么一次$h_k$排序的过程就是对$h_k$个子数组进行插入排序。
复杂度分析
希尔排序最坏的结果就是相隔的排序没有成功消除多个逆序,最后的复杂度就成了插入排序的$O(N^2)$。
为了提高希尔排序的效率,增量序列$h_1,h_2,……,h_n$必须慎重地进行选择。如果增量序列不是互素的,增量缩小时,可以消除的逆序数量也许会因为它的倍数们的关系而大大减少。流行的一种选择是初始增量为$N/2$,且每次增量都是上一次的$1/2$。这就是在上一小节表格中使用的方案,可以看到在距离为2时消除的逆序数量并不理想。Hibbard 提出了一个比$2^n$更好的增量方案:$1,3,7,……,2^n-1$,它的相邻增量没有公因子,虽然其中还是包含很多公因子,但已经比$2^n$具有更好的表现,有证明Hibbard增量的希尔排序最坏情形运行时间是$\Theta(N^{3/2})$。
在实践中,Sedgewick还提出了集中比Hibbard好得多的增量序列,最坏运行实践是$\Theta(N^{4/3})$,平均运行时间猜测(没有被证明)是$O(N^{7/6})$,其中最好的序列是${1,5,19,41,109,……}$,序列的项是$94^i-92^i+1$或$4^i-3*2^i+1$。
总的来说,希尔排序的算法非常简单,但是算法复杂度的分析又非常复杂。在这里只是给出了比较好的序列供选择。希尔排序的性能在实践中完全可以接受,即使是数以万计的N仍然如此,简单的算法使它成为处理较大输入数据的常用算法。
代码实现
希尔排序本质就是不断进行增量为$h_k$的插入排序,从$h_k$处的元素开始。所以希尔排序的代码只要在插入排序的基础上进行修改即可:
def shell_sort(list,increments)
# 从大到小的顺序使用增量进行排序
increments.reverse.each do |increment|
i = increment
while i < N
j = i
# 增量为increment的插入排序,此时待插入的元素是list[i]
tmp = list[i]
while j>=increment && list[j-increment] > tmp
list[j] = list[j-increment]
j -= increment
end
list[j] = tmp
i += 1
end
end
end
乍一看希尔排序是很多遍的插入排序,但是由于每趟希尔排序可以一次消除多个逆序,所以每个小插入排序的效率较高,选对增量序列的话,自然比插入排序快很多。
快速排序
基本思想
作为一种递归的算法,快速排序的想法也很简单:把各个元素看成书架上的一排书,比当前书小的在左边,比当前书大的在右边。对于左右两边的子序列,继续使用这样的做法,直到左右子序列都不超过一本书,那么所有的书就是有序的了。在快排算法中,称“当前书”为枢纽元。枢纽元的选取方式有很多,在下面的表格中,取最后一个元素作为枢纽元:
步骤 | 序列 |
---|---|
初始序列 | 12 3 67 45 4 92 54 23 78 11 8 |
取8作为枢纽元 | 4 3 8 45 12 92 54 23 78 11 67 |
8左右序列分别取3和67 | 3 4 8 45 12 11 54 23 67 92 78 |
67左右序列分别取23和78 | 3 4 8 11 12 23 54 45 67 78 92 |
23左右序列分别取12和45 | 3 4 8 11 12 23 45 54 67 78 92 |
复杂度分析
快排的平均运行时间是$O(N\log N)$,最坏情况是$O(N^2)$。作为一个递归的算法,平均状况下每次将序列平分成两份,则一共进行$\log N$趟划分,每次划分遍历一遍序列,耗时$O(N)$,所以平均耗时为$O(N \log N)$。在最坏情况下,作为枢纽元的元素是最大/最小的元素,则序列被划分为$0$元素的序列和$N-1$的序列。如果每个序列都是这样的情况,则需要进行$N-1$趟划分,共耗时为$O(N^2)$。
算法细节
三数中值分割法
快速排序可能会耗时$O(N^2)$的根本原因在于选取枢纽元的时候不能保证子序列的大小是相对平均的。
划分的具体过程是怎么样的呢?首先将枢纽元放在最后,并且最左边为指针$i$,枢纽元前是指针$j$,$i$和$j$分别在遇到大于等于/小于等于枢纽元的时候停止,如果此时$i$处的元素依然小于$j$处的元素,那么两者进行交换,并且指针继续前进。直到$list[i]>list[j]$,则将枢纽元与$list[i]$进行对调,完成划分。
为了让选取的枢纽元不至于太极端,随机选择是个好主意吗?且不说随机数生成相当耗时,随机选择依然可能会选中最大/最小的元素。甚至,在输入数据是随机的情况下,就算每次都取第一个数也跟随机选择的效果是一样的,因为选到的数都是随机的,这时生成随机数就是"literally"的徒劳了。
那么有什么更好的办法吗?三数中值分割法是个不错的办法。三数中值分割法的思想是:取当前序列的第一、最末和中间三个数字,将他们从小到大排列,最小的元素放在序列最左,中间的元素放在序列最右,最大的元素放在倒数第二个位置。这样最左边的元素和倒数第二个元素分别小于/大于枢纽元,可以保证在游标移动的过程中一定会遇到可以停止的元素而不会越界。这两个元素分别是两个警戒标记。
三数中值分割法尽管可以避免产生极端的划分结果且不像随机数生成那么耗时,但是也是相当费时间的。因此很多情况下,使用三数中值分割法并不能使快速排序的运行时间更短。但是在理论上,三数中值分割法确实是一个比较好的优化方法。
现在划分的具体代码如下所示:
list = [21,324,54,12,65,2]
def quick_sort(left,right)
i=left
j = right-1
item = list[right]
# 当i和j都停止时交换,直到i>=j
while true
# 递增i直到遇到大于等于枢纽元的元素。
# 这里的++i不能变成 if list[i]<item i++
# 因为如果不是++i的话,如果i和j都遇到了和枢纽元相同大小的元素,那么他们会永远停留在这个位置进入死循环。++i确保交换之后就前进。
while list[++i] < item
end
while list[++j] > item
end
if i<j
swap(list[i],list[j]) if i<j
else
break
end
end
swap(list[i],list[right-1])
quick_sort(left,i-1)
quick_sort(i+1,right)
end
相等元素
另一个细节是关于相等元素的:如果元素和枢纽元的大小相等,那么需要交换吗?直觉上好像不交换可以节省一次操作,但是更细致地分析来看,如果相等的元素不交换,那么当所有的元素都等于枢纽元时,会发生什么——$i$不停递增到$j$处,序列被分为空序列和$N-1$个元素的序列。对于具有$N$个相同元素的序列来说,时间复杂度成为了$O(N^2)$。而直觉上比较慢的每次都交换的时间复杂度则为$O(N\log N)$。因此,结论是与枢纽元相等的元素也停止并进行交换的处理方式比较好。
最后一个细节:对于很小的数组$(N\leq20$)来说,插入排序的效率比快速排序更高。通常的解决办法是对于小于等于一定尺寸(通常是$10$)的序列采用插入排序。这种措施还有一种附加的好处:在使用三数中值分割法时不会出现只有一两个元素的情况。根据《数据结构与算法分析》的描述,这样的处理可以节省$15%$的快速排序运行时间。
归并排序
基本思想
归并排序也是一个递归的算法,它的思想是合并两个已经排序的序列A和B,从他们的第一个元素开始,取较小者拷贝到第三个空序列C中,一直到某个序列已经取到末尾,则把另一个序列全部拷贝到C中。
$1,13,24,26 \ \uparrow \ Aptr$
$2,15,27,38 \ \uparrow \ Bptr$
$\square \square \square \square \square \square \square \square \ \uparrow \ Cptr$
$……$
指针移动
$……$
$1,13,24,26 \ ………\uparrow \ ………Aptr$
$2,15,27,38 \ ……\uparrow \ ……Bptr$
$1,2,13,15,24,\square \square \square \ ……………\uparrow \ ……………Cptr$
下一步$26$放入$Cptr$处,接着讲$27,38$直接放在最后,排好序的$C$序列就诞生了。
复杂度
归并排序的复杂度也是$O(N\log N)$。它将序列逐层分治,对$N$个数归并的时间等于对两个$\frac{N}{2}$大小的序列归并,再加上合并时间$N$。
$$T(1)=1 \ T(N)=2T(\frac{N}{2})+N$$ 两边各除以N可得 $$\frac{T(N)}{N} = \frac{T(\frac{N}{2})}{\frac{N}{2}}+1$$ $$\frac{T(\frac{N}{2})}{\frac{N}{2}} = \frac{T(\frac{N}{4})}{\frac{N}{4}}+1$$ $$……$$ $$\frac{T(2)}{2} = T(1)+1$$
逐层累加,得到 $$T(N)=N\log N+N$$ 所以,归并排序的复杂度是$O(N\log N)$。
算法细节
临时数组
归并排序中一个很大的开销就是临时数组的创建。如果每一趟合并都新创建一次临时数组的话,就需要$\log N$个临时数组。不但十分耗时,还占用大量内存。
事实上,同一时刻归并排序只需要一个临时数组。所以可以使用一个和原数组相同大小的临时数组,每一趟归并都使用此临时数组,不再每次创建。
尽管如此,归并排序还是需要额外的内存和数据拷贝的额外时间,所以对于重要的内部排序应用而言,人们还是倾向于使用快速排序。
代码实现
下面是简单的归并排序代码实现:
def merge_sort(list, sec_list, left, right)
unless left >= right
middle = (left+right)/2
merge_sort(list, sec_list, left, middle)
merge_sort(list, sec_list, middle+1,right)
pos1 = left
pos2 = middle+1
curr = left
while(pos1 <= middle && pos2<=right)
if list[pos1] > list[pos2]
sec_list[curr] = list[pos2]
pos2 += 1
else
sec_list[curr] = list[pos1]
pos1 += 1
end
curr += 1
end
while(pos1 <= middle)
sec_list[curr] = list[pos1]
pos1 += 1
curr += 1
end
while(pos2 <= right)
sec_list[curr] = list[pos2]
pos2 += 1
curr += 1
end
(left..right).each do |index|
list[index] = sec_list[index]
end
end
end
归并排序$\rightarrow$外部排序
在无法全部装入内存的外部排序中,归并排序的思想被广泛地用到。下面是使用三盘额外磁带对一盘磁带的大量数组进行排序的过程:
磁带 | 序列 |
---|---|
$T_{a1}$ | 81 94 11 96 12 35 17 99 28 58 41 75 15 |
$T_{a2}$ | |
$T_{b1}$ | |
$T_{b2}$ |
如果每次从磁带读入M=3条记录,并且交替地排序后写到$T_{b1}$和$T_{b2}$中,那么结果就是这样的:
磁带 | 序列 | 序列 | 序列 |
---|---|---|---|
$T_{a1}$ | |||
$T_{a2}$ | |||
$T_{b1}$ | 11 81 94 | 17 28 99 | 15 |
$T_{b2}$ | 12 35 96 | 41 58 75 |
然后将$T_{b1}$和$T_{b2}$中的两个长度为M的顺串通过归并排序写入到$T_{a2}$中,结果就是一个长度为$2M$的顺串。然后再从$T_{b1}$和$T_{b2}$中取出后两个顺串,将结果写到$T_{a1}$中,重复交替使用$T_{a1}$和$T_{a2}$,直到$T_{b1}$和$T_{b2}$为空。这时$T_{a1}$和$T_{a2}$中都是长度为$2M$的顺串,再重复上一次的操作到$T_{b1}$和$T_{b2}$上,这次的结果顺串是$4M$。重复操作直到得到长度为$N$的顺串。
这样的算法,需要进行$\log(N/M)$趟操作(每次序列都是上一次的两倍,$2^n*M=N$)。
多路合并
如果有额外的磁带,可以使用$2k$盘磁带,进行$k$-路合并的算法。基本过程与上一小节的$2$路合并相似,只是从$k$路中取最小的元素需要构造一个最小堆才较为高效。这样的合并方式需要进行$\log_k(N/M)$趟操作。
多相合并
多相合并是一中相对复杂一些的合并方式,可以达到只用$k+1$盘磁带完成$k$路合并的效果。
多相合并的基本思想是把所有顺串不均衡地划分成两份,使得不停地合并部分之后,最后只剩下两个顺串可以合并成一个顺串。
假设一共有34个顺串,一个可行的分配方案如下:
初始分配(顺串长度) | T2+T3 | T1+T2 | T1+T3 | T2+T3 | T1+T2 | T1+T3 | T2+T3 | |
---|---|---|---|---|---|---|---|---|
$T_{1}$ | 0 | 13(2M) | 5(2M) | 3(8M) | 1(8M) | 1(34M) | ||
$T_{2}$ | 21(M) | 8(M) | 5(5M) | 2(5M) | 1(21M) | |||
$T_{3}$ | 13(M) | 8(3M) | 3(3M) | 2(13M) | 1(13M) |
表格从左向右看是层层分解,那么从右向左看就是层层相加——什么经典的模型是层层相加的呢?斐波那契!分配这些顺串的最好方式就是把它分配成两个斐波那契数,如果不足就使用哑顺串来填充。
替换选择
如果想要边读入数据边输入顺串呢?可以通过使用一个最小堆,当读入的数据比当前的最小数据还小的时候,将此数据存入死区(因为这个数据如果放在当前顺串,可能导致顺串的顺序性质遭到破坏);否则可以读入最小堆。与此同此,不断从最小堆中取出当前最小的元素存入顺串中。当M个元素存入之后,继续存下一个顺串,直到死区的元素个数达到最小堆的大小,用死区的元素构建一个新的最小堆,继续存入顺串。
这样的处理方式可以避免等待磁盘读完数据之后再进行顺串的创建,而是边读入边创建顺串。在读入时间很长的情况下很有用。
总结
本文记录了插入排序、希尔排序、快速排序、归并排序的读书笔记。
其实在日常工作中真的会经常用到这些排序算法,甚至算法书中的算法吗?未必。但是为什么还要学习算法、抠细节呢?因为在探究算法细节的时候,我们能够踩到许多看来不会成为问题的坑,也会找到意想不到的性能瓶颈。算法的思想和考虑细节的过程能够锻炼我们的思维。
在学习其他东西时何尝不需要深入细节的能力呢?会使用数据库就是学会数据库了吗?各种索引、锁、引擎、优化……探究地越多,才会越游刃有余,才不会成为样样“会用”的“面向stackoverflow”编程的码农。
共勉。