算法温故知新之排序

各种常用排序算法回顾, 有新的认识会次序更新。

  1. insertion sort
    小规模的数据,初始数据几乎是有序的。 如何定义这个“小规模”,这根机器及语言还有比较的类型都有关系。
    稳定的排序

    扩展:
            Shell sort — 把数组分以h为单位的递减的子数组,然后对各个子数组进行insertion sort。即使对很大的数据,也有比较好的性能,且实现简单,排序时不需要额外的空间。甚至在一些类库及嵌入式设备中,使用它来代替quick sort.
            它是unstable的。关键是如何取的的这个h数组。 不同的选取间隔,会影响算法的性能。 详细参见wiki上关于shellsort的表.
            在uclibc中有方便使用的实现。(uClibc/libc/stdlib/stdlib.c中的qsort_r)
            

  2. merge sort
    稳定的排序
    与通常的折半查找类似, 运行时间是NlogN。 简单的实现需要额外的大小为N的辅助空间,如果要达到不使用额外空间的in-place排序,则会比较复杂。

    Java中的Arrays.sort()依据数组中的元素个数,来使用merge sort或者更改过的quicksort。而当元素个数少于7个时,就会使用insertion sort。 Python和Java SE7使用timsort,它是一个在merge sort和insertion sort之间做调节的算法。详细情况可参照openjdk中的实现来权衡如何找到适用的阀值。

    优化: 与quick sort的优化手段一样, 可以使用insert sort来处理小的数组。选取的这个临界点,通常要与计算机中的cache结构一致。参考merge sort

    扩展:
               已经存在应用与并行计算中的merge sort版本,parallel merge
                merge sort更适合以link list的组织的数据,同时,被使用来对慢速顺序访问的数据进行排序(如磁带机)

  3. BST sort, binary search tree sort
    通过随机选取一个数来开始建立bst, 它的高度是O(lgn).
    它的运行时间通过self-balancing binary search tree的优化可以达到是O(nlog(n)), 而如果不进行 self-balancing binary search tree最坏情况是O(n(2)). 
    使用场景: 
        数据以链表方式组织
        需要快速处理查找
        inorder tree walk

    优化:
           self-balancing binary search tree
           这里需要注意有些不能随机的选取数据的场合(即,带排序的数据不是一次性获得时, 比如通过网络一部分一部分获得), 就不能使用上述优化, 容易影响最后的性能.
           利用上述技巧,实现的tree是AVL tree,它可以实现在平均情况及最坏情况下的查找,插入,删除操作都在O(logn)完成.

  4. quicksort
    非稳定排序
    它的最优/最坏/平均情况都是 O(nlogn)
    它是分治法的典型代表。与中值排序一样都依赖于选取一个好的pivot值。

    这里只列出典型的选取pivot的伪代码

    // left is the index of the leftmost element of the array
       // right is the index of the rightmost element of the array (inclusive)
       //   number of elements in subarray = right-left+1
       function partition(array, 'left', 'right', 'pivotIndex')
          'pivotValue' := array['pivotIndex']
          swap array['pivotIndex'] and array['right']  // Move pivot to end
          'storeIndex' := 'left'
          for 'i' from 'left' to 'right' - 1  // left ≤ i < right
              if array['i'] < 'pivotValue'
                  swap array['i'] and array['storeIndex']
                  'storeIndex' := 'storeIndex' + 1
          swap array['storeIndex'] and array['right']  // Move pivot to its final place
          return 'storeIndex'

    优化:
        所有的优化其实都集中在如何选取更有效的pivot (selection algorithm专门处理它)
        每次随机选取
        每次折中选取, 但是在大数据量时,需要注意整数溢出,这是需要使用left + (right – left) / 2代替(left+right) /2
        为了优化使用的空间, 每次从划分之后的较小的一个数组开始递归sort
        在划分的规模达到一定的时候,采用insertion sort代替(从gnu libc的qsort的实现中可以看到它依赖于计算机的硬件,cache,处理数据的大小之类). 而在uclibc的实现中(参见http://git.uclibc.org/uClibc/tree/libc/stdlib/stdlib.c)qsort是用一个改进的shell sort代替的
        通过并行化来优化

    扩展:
        Balanced quicksort
       External quicksort
       Three-way radix quicksort 
       Quick radix sort

  5. mediansort
    非稳定排序
    它最多用于找出中值。相关描述参考资料2中的描述比较及MIT的算法导论视频中lecture 6的讲解
    通常来说,用于找出某个集合中排第几位的数
                                    k <= |SL|                        , selection(SL, k)
    selection(s,k) =    |SL| < k <= |SL| + |SV| , v
                                    k > |SL| + |SV|               , selection(SR, k-|SL|-|SV|
    这里SL表示小于k的元素,SV表示等于k的元素, SR表示大于k的元素
  6. heapsort
    非稳定排序
    它比较适合处理形如priority queues的问题,比如,在很大数据流的大量字符串中,找出最长或者最短的字符串。再比如,NCAA中冠军队的产生方法。(它不是跟所有球队(64-1)都进行比赛,而是各自跟一部分进行比赛,从而取最终胜利6场的球队)
    它的最优/最坏/平均情况都是 O(nlogn)
    pseudo code
    sort(A)
        buildHeap(A)
        for i = n-1 down to 1 do
            swap A[0] with A[i]
            heapify(A, 0, i)
    
    buildHeap(A)
        for (i = [n/2] - 1)  downto 0 do
            heapify(A, i, n)
    
    heapify(A, idx, max)
        left = 2*idx + 1
        right = 2*idx + 2
        if (left < max and A[left] > A[idx] then
            largest = left
        else
            largest = idx
        if (right < max and A[right] > A[largest] then
            largest = right
        if (largest != idx) then
            swap A[i] and A[largest]
            heapify (A, largest, max)

重要知识点:

  1. 注意算法所使用的数据集, 一般来说,在小数据集上,插入排序表现得比快速排序要好。虽然,从执行时间的增长率来看,快速排序更好些。
  2. 分析算法的三种情况:worst case, average case, best case
  3. 性能指标的分类:Constant O(n), Logarithmic O(log n), Sublinear O(n ^ d) d < 1, Linear O(n), nlog(n), Quadratic O(n^2), Exponential O(2 ^ n)
    摘自<Algorithms 4th> chapter 1
    《算法温故知新之排序》
  4. 无论是平均还是最坏情况下,一个基于比较的排序算法不可能得到比 O(nlog(n))更快的性能。比如, 最常用的quicksort, BST-sort. 虽然也有一些高级复杂算法能够得到一些理论上比这两个更好的性能,但是提高的也是很有限的类似nlog(n) + C这样的常数C的大小.
    不是基于比较的,比如桶排序,在有些情况下会得到更好的性能)
    这里提一下关于logN,它可表示为:
        采用二分法,从N到1所要用的次数, 精确说是logN的上界
        使用二进制来表示数N所需要的bit数,精确说是log(N+1)的上界
        一个有N个节点的完全二叉树的深度,精确说是logN的下界
        也是1 + 1/2 + 1/3 + … + 1/n的和
  5. 对于主定理(Master theorem)的应用
    用来给出使用分治法得出的时间复杂度
    如果一个分治算法的时间复杂度能够表示为:T(n) = aT([n/b]) + O(n^d),其中a >0, b>1,d>=0, 那么可以得到以下集中情况:
        d > log(b)(a), T(n) = O(n^d)
        d = log(b)(a), T(n) = O(n^d logn)
        d < log(b)(a), T(n) = O(n^log(b)(a))
     这里b的取值通常是2,比如二分查找或merge sort。 当然也可以是其它的分治个数。
     比如,mergesort的复杂度是T(n) = 2T(n/2) + O(n), 此时从上诉的主定理可知,a = 2, b =2, d = 1, 则 1 = log(2)(2),那么T(n) = O(n^1logn) = O(nlogn)
    提到了分治法,这里再提一下,矩阵乘法及傅里叶变化都需要用到分治的思想。
  6. 如何选择排序算法(摘自《Algorithms In a NutShell》Chapter 4)
    这里具体的一些测算的性能数据, 可以参照书中的描述。
    同时,它也提供了一套实现好的测算程序共大家使用。(O‘REILLY的官网上能够下载到)
    标准                                     排序算法
    很少的元素                           插入排序
    几乎有序的元素                    插入排序
    关注最坏情况                       堆排序
    希望能够得到一个好             快速排序
    的平均情况下性能
    元素是从一个密集集             桶排序
    合中抽取
    希望尽可能少的写代码          插入排序
  7. 下面是一张很有用的图标, 摘自sort algorithm的wiki

参考资料:

    Introduction to Algorithms

    Algorithms In a Nutshell

    Algorithms 4th
    Algorithms (加州大学伯克利分校和加州大学圣迭戈分销本科生的算法课讲义。中文名称叫:算法概论。 与上一本不是同一本书!)

    …

点赞