究竟是怎么打破二次时间屏障的?浅谈希尔排序的思想和复杂度证明
作为最早突破 O(N^{2}) 复杂度的排序算法之一,希尔排序(Shell Sort)一直是闪耀着编程艺术之美的存在。
本文尽可能通俗的解释了希尔排序为什么快,以及打破二次时间屏障的关键一击是什么。
复杂度证明放最后了,有点烧脑,但不影响上文阅读。
希尔排序的思想:
希尔排序是插入排序的改进版,通过比较一定间隔的元素进行插入排序,并且不断缩小间隔,直到比较相邻元素。我们管这个间隔叫增量,增量为h的排序我们成为h-排序。
如果我们使用插入排序的话,最后一步将元素1从最后一个位置移动到第一个位置,需要把其余元素依次往后挪动一位,这种开销十分巨大。
而使用希尔排序时,我们通过较大的间隔(增量),将元素1快速送到第一个位置。
第一趟排序:
根据图2,增量为4时,我们只对间隔为4的元素进行插入排序。将[8,4]进行插入排序,得到[4,8]。将[7,3]进行插入排序,得到[3,7]。将[6,2]进行插入排序,得到[2,6]。将[5,1]进行插入排序,得到[1,5]。
第一趟排序后,通过每隔四个元素进行插入排序,花费较少的比较次数,算法将小元素快速的送到前面,同理大元素被挤到后面。
第二趟排序:
根据图3,增量为2时,我们对间隔为2的元素进行插入排序。将[4,2,8,6]进行插入排序,得到[2,4,6,8]。将[3,1,7,5]进行插入排序,得到[1,3,5,7]。
第二趟排序后,通过每隔二个元素进行插入排序,小元素继续被快速的送到前面,同理大元素被挤到后面。
经过两趟大增量的排序,数据在宏观上是比较有序的。此时只需要最后执行一次增量为1的排序,也就是普通的插入排序,将现在的数据进行微调,就能得到有序的结果。
对[2, 1, 4, 3, 6, 5, 8, 7]这个半成品进行插入排序,显然比对[8, 7, 6, 5, 4, 3, 2, 1]进行插入排序容易得多。
/*希尔排序c代码,来自《数据结构与算法分析》*/
void ShellSort(ElementType A[], int N) {
int i, j, increment;
ElementType tmp;
// 每一趟排序增量折半,当然也可以使用其他增量
/* 1*/ for (increment = N / 2; increment > 0; increment /= 2) {
/* 2*/ for (i = increment; i < N; ++i) {
/* 3*/ tmp = A[i];
// 对A[i],A[i-increment],A[i-2*increment]...进行插入排序
/* 4*/ for (j = i; j >= increment; j -= increment) {
/* 5*/ if (tmp < A[j - increment]) {
/* 6*/ A[j] = A[j - increment];
/* 7*/ }
/* 8*/ else {
/* 9*/ break;
/*10*/ }
/*11*/ }
/*12*/ A[j] = tmp;
/*13*/ }
/*14*/ }
}
通俗的讲,希尔排序能够以较大的步伐将小元素往前送,这样大大的减少了需要比较的次数,从而提高了速度。
使用1, 2, 4, ..., \lfloor \frac{N}{2} \rfloor 这种不断减半的增量大多数情况下能够提速,但是遇到最坏的情况仍然是 \Theta(N^{2}) 。
比如图4:
因此Hibbard提出了著名的Hibbard增量:1, 3, 7, ..., 2^{k}-1 。使用这个增量的希尔排序最坏运行时间是 O(N^{3/2}) 。
通俗来说,能打破二次时间界的核心原因是:
在执行 h_{k } 排序之前,我们已经执行了 h_{k+1} 和 h_{k+2} 排序。而这两个排序使得序列在宏观上更有序,并且严格地保证了对于某个位置的元素E,在这个元素左侧,且到E一定距离以上的元素一定小于E。
所以4-11行代码循环次数并没有想象中的那么多。
证明:使用Hibbard增量的希尔排序最坏运行时间是 O(N^{3/2}) 。
假设我们已经进行到 h_{k} -排序,在此之前序列一定满足 h_{k+1} -排序和 h_{k+2} -排序。
所以对于位置 P和位置 P-d上的两个元素,如果d是 h_{k+1} 或者 h_{k+2} 的整数倍,显然A[P-d]≤A[P]。
同样的,当d是 h_{k+1} 和 h_{k+2} 的线性组合时,一样可以证明A[P-d]≤A[P]。
比如d=1*3+2*7,那么根据7-排序结果,A[17]≥A[10]≥A[3],根据3-排序结果,A[3]≥A[0],所以A[17]≥A[0]。
因为 h_{k+2}=2*h_{k+1}+1 ,所以h_{k+1} 和 h_{k+2} 没有公因数,因此大于等于 (h_{k+1}-1)(h_{k+2}-1) 的所有整数都能表示成 h_{k+1} 和 h_{k+2} 的线性组合(参数非负),证明略。
所以4-11行for循环代码只需要检查和当前tmp左侧且距离不超过 (h_{k+1}-1)(h_{k+2}-1)=8h_{k}^{2}+4h_{k} 的元素即可,其余的能表示成线性组合,所以一定不大于tmp。
又因为增量是 h_{k+1} ,所以4-11行的for循环实际运行的次数不超过 \frac{8h_{k}^{2}+4h_{k}}{h_{k}}=8h_{k}+4=O(h_k) 。
因为代码第二行的for循环执行 N-h_{k} 次,所以h_{k} 排序运行的上界是 O((N-h_k)*h_{k})=O(Nh_{k}) 。
但是当 h_{k} 较大时, (h_{k+1}-1)(h_{k+2}-1)=8h_{k}^{2}+4h_{k} 会过大,导致上述分析失效。因此我们不能使用该上界,而是使用另一个上界,也就是将 h_{k} -排序看做是h_{k} 个 大小为\frac{N}{h_{k}}的序列的插入排序,得到一个更大一点的上界: O(h_{k}*(\frac{N}{h_{k}})^{2})=O(\frac{N^{2}}{h_{k}}) 。
所以我们约定,当 h_{k}<\sqrt{N} 时,我们用较小一点的上界 O(Nh_{k}) ,当 h_{k}>\sqrt{N} 时,我们用较大一点的上界 O(\frac{N^{2}}{h_{k}}) 。
所以假设增量个数为t。总复杂度为:
O(\sum_{k=1}^{t/2}{Nh_{k}}+\sum_{k=t/2+1}^{t}{N^{2}/h_{k}})
=O(N\sum_{k=1}^{t/2}{h_{k}}+N^{2}\sum_{k=t/2+1}^{t}{1/h_{k}})
=O(N*{h_{t/2}}+N^{2}*1/h_{t/2}) (两个求和都是几何级数,所以结果都是最大的一项)
=O(N*\sqrt{N}+\frac{N^{2}}{\sqrt{N}}) (利用了 h_{t/2}=\Theta(\sqrt{N}) ,因为h_{t}=O(N) 且 h_{t/2}=\Theta(\sqrt{h_{t}}) )
=O(N^{3/2}) 。
有了这个上界,排序的速度就得到了极大的保障。举个例子,对10000个元素进行排序,如果普通插入排序最坏情形不超过100秒,那么Hibbard增量的希尔排序最坏情况不会超过1秒。
ps:最后证明过程来自Weiss的《数据结构与算法分析》,但是总复杂度证明那里把 \sum_{k=1}^{t/2}{Nh_{k}} 错写成了 \sum_{k+1}^{t/2}{Nh_{k}} ,不知是我买了盗版还是作者真的写错了。