究竟是怎么打破二次时间屏障的?浅谈希尔排序的思想和复杂度证明

究竟是怎么打破二次时间屏障的?浅谈希尔排序的思想和复杂度证明

作为最早突破 O(N^{2}) 复杂度的排序算法之一,希尔排序(Shell Sort)一直是闪耀着编程艺术之美的存在。

本文尽可能通俗的解释了希尔排序为什么快,以及打破二次时间屏障的关键一击是什么。

复杂度证明放最后了,有点烧脑,但不影响上文阅读。

希尔排序的思想:

希尔排序是插入排序的改进版,通过比较一定间隔的元素进行插入排序,并且不断缩小间隔,直到比较相邻元素。我们管这个间隔叫增量,增量为h的排序我们成为h-排序。

图1:待排序数据

如果我们使用插入排序的话,最后一步将元素1从最后一个位置移动到第一个位置,需要把其余元素依次往后挪动一位,这种开销十分巨大。

而使用希尔排序时,我们通过较大的间隔(增量),将元素1快速送到第一个位置。

第一趟排序:

图2:第一趟,4-排序结果

根据图2,增量为4时,我们只对间隔为4的元素进行插入排序。将[8,4]进行插入排序,得到[4,8]。将[7,3]进行插入排序,得到[3,7]。将[6,2]进行插入排序,得到[2,6]。将[5,1]进行插入排序,得到[1,5]。

第一趟排序后,通过每隔四个元素进行插入排序,花费较少的比较次数,算法将小元素快速的送到前面,同理大元素被挤到后面。


第二趟排序:

图3:第二趟,2-排序结果

根据图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:

图4:4-排序和2-排序没有改变任何元素位置,只能通过1-排序完成排序任务。此时算法退化成插入排序。


因此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}} ,不知是我买了盗版还是作者真的写错了。

编辑于 2021-05-09 09:43