书山有路之学习算法导论(二)--排序和顺序统计量

今天是大年初一,祝大家新年快乐啊!

这些天看了《算法导论》的第二部分,这一部分我也写了挺久的,接下来以排序为核心来讨论一下。

书山有路勤为径,加油!

二、排序和顺序统计量

排序问题的结构如下:

输入:一个n个数的序列<a1,a2,...,an>

输出:输入序列的一个排列<a1',a2',...,an'>,满足a1'<=a2'<=...<=an'

讨论具体算法之前再回顾一个概念--原址排序,如果输入数组中仅有常数个元素需要在排序过程中存储在数组之外,则称排序算法是原址的。

6.堆排序

6.1 堆

要了解堆排序首先要引入堆的概念。

堆是一个数组,可以被看成一个近似的完全二叉树,树上的每一个结点对应数组中的一个元素。表示堆的数组A包括两个属性:A.length给出数组元素的个数,A.heap-size表示有多少个堆元素存储在该数组中,即A[1...A.length]可能都存放数据,但只有A[1...A.heap-size]中存放的是堆的有效元素,可以知道0<=A.heap-size<=A.length。下图以二叉树的形式展现了一个最大堆。


树的根节点就是A[1],当给定一个结点的下标i,很容易知道它的父节点、左孩子和右孩子的下标:

PARENT(i):return ⌊i/2⌋

LEFT(i):return 2*i

RIGHT(i):return 2*i+1

堆可以分为两种:最大堆和最小堆。

最大堆中除了根结点以外的所有结点都满足:A[PARENT[i]]>=A[i],即某个结点的值至多与其父结点一样大。

最小堆中除了根结点以外的所有结点都满足:A[PARENT[i]]<=A[i],即某个结点的值至少与其父结点一样大。

当把堆看成是一棵树时,堆中一个结点的高度就为该结点到叶结点最长简单路径上边的数目,于是可以定义堆的高度即为根结点的高度。可以证明含n个元素的堆的高度为Θ(lg n)。在堆排序过程中我们使用最大堆,接下来详细说明对堆的基本操作与堆排序过程。

6.2 维护堆的性质

MAX-HEAPIFY通过让A[i]的值在最大堆中逐级下降,使得当下标为i的结点违反堆的性质时以下标i为根结点的子树重新遵循最大堆的性质。在调用MAX-HEAPIFY时假定根结点为LEFT(i)和RIGHT(i)的二叉树都是最大堆。其时间复杂度是O(lg n)。

MAX-HEAPIFY(A,i)   //使以标号i为根结点的树满足最大堆的性质
    l=LEFT(i)    //左孩子标号
    r=RIGHT(i)   //右孩子标号
    if(l<=A.heap-size && A[l]>A[i])   //如果左孩子存在且左孩子值大于当前结点值就标记左孩子为largest
        largest=l
    else
        largest=i
    if(r<=A.heap-size && A[r]>A[largest]) //如果右孩子存在且右孩子值大于当前结点与左孩子中的较大值就标记右孩子为largest
        largest=r
    if(largest!=i){
        exchange A[i] with A[largest]   //当largest不是i的时候将A[i]的值与A[largest]的值交换
        MAX-HEAPIFY(A,largest)   //递归调用,维护以largest结点为根结点的最大堆的性质
    }
        

6.3 建堆

为了使用堆排序算法,我们首先需要建立一个最大堆。我们利用过程MAX-HEAPIFY把一个大小为n=A.length的数组A[1...n]转换为最大堆。子数组A[n/2+1...n]中的元素都是树的叶结点,每个叶结点都可以看成是只包含一个元素的堆。过程BUILD-MAX-HEAP对树中的其他结点都调用一次MAX-HEAPIFY,最终可以使数组满足最大堆的性质,它具有线性的时间复杂度。

BUILD-MAX-HEAP(A)  //用数组A的元素建立一个最大堆
    A.heap-size=A.length
    for(i=A.length/2;i>=1;i--){  //每一次循环开始,结点i+1,i+2,...,n都是一个最大堆的根结点
        MAX-HEAPIFY(A,i)
    }

6.4 堆排序算法

堆排序算法利用BUILD-MAX-HEAP将输入数组A[1...n]建成最大堆,其中n=A.length。因为数组中的最大元素总是存放在A[1]中,通过它与A[n]进行互换,互换之后再利用MAX-HEAPIFY来维护最大堆的性质,不断重复这一过程,可以实现对数组元素的排序。堆排序算法的时间复杂度是O(nlgn)。

HEAPSORT(A)   //使用堆排序算法将数组A中的元素从小到大排序
    BUILD-MAX-HEAP(A)   //首先用数组A的元素建立一个最大堆
    for(i=A.length;i>=2;i--){    //每次循环的过程就是将在堆中的最大值取出并排好至相应的位置
        exchange A[1] with A[i]         //i代表堆中元素的个数,将A[1]与A[i]交换也就是找到第i小的元素并排在第i位上
        A.heap-size=A.heap-size-1      //排好一个元素,堆中元素的个数减1
        MAX-HEAPIFY(A,1)     //元素交换后,A[1]的值发生了变化,于是要从1号结点出发来维护堆的性质
    }

6.5 优先队列

优先队列是一种用来维护由一组元素构成的集合S的数据结构,其中每一个元素都有一个相关的值,称为关键字(key)。一个最大优先队列可以支持如下操作:插入一个新元素、返回具有最大关键字的元素、去掉并返回具有最大关键字的元素、改变某个元素关键字的值。最小优先队列的操作与之相似,在返回队首元素时有差别,最小优先队列可以返回具有最小关键字的元素。接下来,以最大优先队列为例讨论如何实现优先队列的相关操作。

①返回具有最大关键字的元素。因为以堆为背景,堆对应的二叉树的根就是具有最大关键字的元素,所以该操作只需要在Θ(1)的时间内就可以实现。

HEAP-MAXIMUM(A)  //返回优先队列A中具有最大关键字的元素
    return A[1]    //A[1]就是具有最大关键字的元素

②去掉并返回具有最大关键字的元素。这个操作也就是先取出具有最大关键字的元素,再从根结点出发维护堆的性质。它的时间复杂度是O(lgn),主要的时间开支在维护堆的性质中。

HEAP-EXTRACT-MAX(A)
    if(A.heap-size<1)    //检查优先队列是否为空
        error "heap underflow"
    max=A[1]    //取出具有最大关键字的元素
    A[1]=A[heap-size]   //与堆排序的操作相似,将A[heap-size]置于根结点
    A.heap-size=A.heap-size-1   //堆元素的个数减一
    MAX-HEAPIFY(A,1)   //从根结点出发维护堆的性质
    return max   //返回具有最大关键字的元素

③将元素x的关键字增加至k,假设k的值不小于x的关键字值。因为增大关键字可能会违反最大堆的性质,需要在当前结点到根结点的路径上为新增的关键字寻找恰当的插入位置。在该操作实现的过程中,当前元素会不断地与其父结点进行比较,如果当前元素地关键字较大,则当前元素与其父结点进行交换。不断重复这一过程,直到当前元素的关键字小于其父结点时终止,因为此时就可以符合最大堆的性质了。在包含n个元素的堆上,此操作的时间复杂度是O(lgn)

HEAP-INCREASE-KEY(A,i,key)  //将下标为i元素的关键字的值增加到key,假设key的值不小于原关键字的值
    if(key<A[i])   //判断新关键字是否比原关键字的值小
        error "new key is smaller than current key"
    A[i]=key    //修改关键字的值
    while(i>1 && A[PARENT(i)]<A[i]){    //寻找合适的插入位置,判断父结点是否存在且父结点的关键字的值是否小于该关键字
        exchange A[i] with A[PARENT(i)]  //当父结点关键字的值小于该关键字那么就将其与父结点进行交换
        i=PARENT(i)   //修改i的值为父结点的下标
    }

④插入一个新元素到优先队列中。这个插入操作的思想是首先增加一个关键字为-∞的叶结点来扩展最大堆,然后通过调用HEAP-INCREASE-KEY为新结点设置对应的关键字,同时保持最大堆的性质。在包含n个元素的堆上,该操作的时间复杂度是O(lgn)。

MAX-HEAP-INSERT(A,key)     //插入一个关键字为key的新元素
    A.heap-size=A.heap-size+1    //堆元素的个数加1
    A[A.heap-size]=-∞    //先用一个极小的关键字来扩展最大堆
    HEAP-INCREASE-KEY(A,A.heap-size,key)   //为新结点设置对应的关键字
可以看出,在一个包含n个元素的堆中,所有优先队列的操作都可以在O(lgn)时间内完成。


7.快速排序

7.1 快速排序的描述

对于包含n个数的输入数组来说,快速排序是一种最坏情况时间复杂度为Θ(n^2)的排序算法。虽然最坏情况时间复杂度很差,但是快速排序通常是实际排序应用中最好的选择,因为它的平均性能非常好:它的期望时间复杂度为Θ(nlgn),而且其中隐含的常数因子很小,同时快速排序能够进行原址排序(输入数组中仅有常数个元素需要在排序过程中存储在数组之外)。

快速排序的主要思想也是分治思想。对子数组A[p...r]进行快速排序主要有以下三步:

分解:数组A[p...r]被划分成两个(可能为空)子数组A[p...q-1]和A[q+1...r],使得A[p...q-1]中的每一个元素都小于等于A[q],而A[q]也小于等于A[q+1...r]中的每个元素。其中计算下标q也是划分过程的一部分。

解决:通过递归调用快速排序,对子数组A[p...q-1]和A[q+1...r]进行排序。

合并:因为子数组都是原址排序的,所以不需要合并操作,进行分解和解决之后,数组A[p...r]已经有序。

QUICKSORT(A,p,r)  //对数组元素A[p...r]进行快速排序
    if(p<r){    //当需要排序的元素至少有两个时才进行下列排序操作,否则已经有序
        q=PARTITION(A,p,r)   //划分数组,找到划分点q
        QUICKSORT(A,p,q-1)   //对数组元素A[p...q-1]进行快速排序
        QUICKSORT(A,q+1,r)  //对数组元素A[q+1...r]进行快速排序
    }   //递归调用结束以后A[p...r]有序

算法的关键步骤就是对数组的划分过程PARTITION,它实现了对子数组A[p...r]的原址排序。

PARTITION(A,p,r)    //对A[p...r]进行划分
    x=A[r]    //选择一个主元,以A[r]为中心对数组进行划分
    i=p-1     //划分之后标号<=i的元素的值都小于等于x
    for(j=p;j<=r-1;j++)   //循环检查以划分数组
        if(A[j]<=x){   //将当前元素与主元进行比较,如果小于等于主元进行如下操作
            i=i+1   //将i加1
            exchange A[i] with A[j]  //把A[i]的值与A[j]交换使得标号<=i的元素的值保证<=x
        }
    exchange A[i+1] with A[r]  //把主元置于合适的位置上
    return i+1   //返回主元现在的标号
可以证明PARTITION在子数组A[p...r]上的时间复杂度是Θ(n),其中n=r-p+1。

7.2 快速排序的性能

快速排序的运行时间依赖于划分是否平衡。如果划分是平衡的,那么快速排序算法性能与归并排序一样。如果划分是不平衡的,那么快速排序的性能就接近于插入排序。对于快速排序来说,当其划分都是最大程度不平衡的,算法的时间复杂度是Θ(n^2),当其好的划分和差的划分交替出现时,快速排序的时间复杂度与全是好的划分时一样,都是O(nlgn)。

7.3 快速排序的随机化版本

很多人都选择随机化版本的快速排序作为大数据输入情况下的排序算法。

随机化版本与始终采用A[r]作为主元的方法不同,是从子数组A[p...r]中随机选择一个元素作为主元,为了达到这一目的,需要将A[r]与从A[p...r]中随机选出的一个元素交换。因为主元元素是随机选择的,我们期望在平均情况下,对输入数组的划分是比较均衡的。新的划分程序中,我们只是在真正进行划分前进行一次交换,随机化划分的伪码如下:

RANDOMIZED-PARTITION(A,p,r)   //对A[p..r]进行随机划分
    i=RANDOM(p,r)     //随机选择在[p,r]中的以一个整数
    exchange A[r] with A[i]   //将随机选中的A[i]与A[r]进行交换
    return PARTITION(A,p,r)   //随机选定主元后,再对A[p..r]进行划分

新的随机化版本的快速排序程序如下:

RANDOMIZED-QUICKSORT(A,p,r)  //对A[p..r]进行快速排序的随机化版本
    if(p<r){   //检查子数组A[p..r]中元素的个数是否多于1个
        q=RANDOMIZED-PARTITION(A,p,r)  //随机化划分
        RANDOMIZED-QUICKSORT(A,p,q-1)  //递归调用快速排序
        RANDOMIZED-QUICKSORT(A,q+1,r)
    }

我们通过证明可以得出结论:使用RANDOMIZED-QUICKSORT,在输入元素互异的情况下,快速排序算法的期望运行时间为O(nlgn)。


8.线性时间排序

8.1 排序算法的下界

先介绍一个概念--比较排序,比较排序是指在排序的结果中,各元素的次序依赖于它们之间比较的排序方法。可以证明,任何比较排序在最坏情况下都要经过Ω(nlgn)次比较。同时,堆排序和归并排序都是渐进最优的比较排序算法,因为堆排序和归并排序的运行时间上界为O(nlgn)。

8.2 计数排序

计数排序假设n个输入元素中的每一个都是在0到k区间内的一个整数,k为某个整数。当k=O(n)时,排序的运行时间为Θ(n)。

计数排序的基本思想是:对每一个输入元素x,确定小于x的元素个数。利用这一信息,就可以直接把x放到它在输出数组中的位置上了。在伪代码中,假设输入是一个数组A[1...n],A.length=n。同时还需要两个数组B[1...n]存放排序的输出,C[0...k]提供临时存储空间。

COUNTING-SORT(A,B,k)    //对数组A进行计数排序,结果存在数组B中,A中元素的值均小于k
    let C[0..k] be a new array
    for(i=0;i<=k;i++)   //初始化数组C
        C[i]=0
    for(j=1;j<=A.length;j++)  //这个循环结束后,C[i]中存放A数组中值为i元素的个数
        C[A[j]]=C[A[j]]+1
    for(i=1;i<=k;i++)   //这个循环结束后,C[i]中存放A数组中值小于等于i元素的个数
        C[i]=C[i]+C[i-1]
    for(j=A.length;j>=1;j--){    //让j从A.length降到1可以保证计数排序稳定
        B[C[A[j]]]=A[j]    //在输出数组的相应位置写入A[j]
        C[A[j]]=C[A[j]]-1   //修改相应C[A[j]]的值
    }

计数排序的一个重要性质就是它是稳定的:具有相同值得元素在输出数组中得相对次序与它们在输入数组中得相对次序相同。

8.3 基数排序

基数排序是先按最低有效位来进行排序的算法。伪码比较直观,如下:

RADIX-SORT(A,d)    //对数组A中的元素进行基数排序,其中A中的元素均为n位数,第1位是最低位,第n位是最高位
    for(i=1;i<=d;i++)    //对每一位进行排序
        use a stable sort to sort array A on digit i   //使用一种稳定的排序算法(比如计数排序)对第i位进行排序

8.4 桶排序

桶排序假设输入数据服从均匀分布,即输入是由一个随机过程产生的,该过程将元素均匀、独立地分布在[0,1)区间上,平均情况下它的时间代价为O(n)。因为对输入数据作出了假设,桶排序的速度也很快。

桶排序将[0,1)区间划分为n个相同大小的子区间,或称为桶。然后将n个输入数分别放到各个桶中。为了得到输出结果,我们先对每个桶中的数进行排序,然后遍历每个桶,按照次序把各个桶中的元素列出来即可。

BUCKET-SORT(A)    //对数组A进行桶排序,数组A中的元素在区间[0,1)上均匀分布
    n=A.length    //n保存数组A 中元素的个数
    let B[0..n-1] be a new array  //临时数组B存放链表,即桶,有n个桶
    for(i=0;i<=n-1;i++)
        make B[i] an empty list   //初始化B[i]为空表
    for(i=1;i<=n;i++)
        insert A[i] into list B[⌊nA[i]⌋]   //根据A[i]值的大小将A[i]插入到对应的桶中
    for(i=0;i<=n-1;i++)
        sort list B[i] with insertion sort   //把每个桶中的元素进行插入排序
    concatenate the lists B[0],B[1],...,B[n-1] together in order   //把每个桶中的元素合并起来将可以得到有序的数组了

我们可以证明知道:计数排序、基数排序和桶排序都可以在线性时间内完成。


9.中位数和顺序统计量

9.1 最小值和最大值

在一个由n个元素组成的集合中,第i个顺序统计量是该集合中第i小的元素。所以,最小值是第1个顺序统计量,最大值是第n个顺序统计量。中位数是所属集合的“中点元素”。

在一个有n个元素的集合中,进行n-1次比较就可以得到其最小值,代码很简单:

MINIMUM(A)   //求数组A的最小值
    min=A[1]   //初始化最小值为A[1]
    for(i=2;i<=A.length;i++)  //遍历数组A进行比较
        if(min>A[i])   //如果当前元素值小于min就修改最小值
            min=A[i]
    return min   //返回最小值

当然最大值也可以通过这种比较得出来。如果用这种方法分别独立地找出最大值和最小值,那么共需要2n-2次比较。

如果需要同时找出最大值和最小值,还有一种更优的方法:对输入元素成对进行处理,首先,我们将一对输入元素相互进行比较,然后把较小的与当前最小值比较,把较大的与当前最大值比较,这样对每两个元素共需比较3次。用这种方法,找出最大值和最小值只需要最多3⌊n/2⌋次比较,比分别独立寻找最大最小值要优一些。

9.2 期望为线性时间的选择算法

本节介绍一种解决选择问题的分治算法,其渐进运行时间为Θ(n),是线性时间的。与快速排序一样,我们仍然将输入数组进行递归划分,但与快速排序不同的是,选择算法中只处理划分的一边。这里,假设输入的数据都是互异的。

RANDOMIZED-SELECT(A,p,r,i)   //寻找A[p..r]中的第i个顺序统计量
    if(p==r)   //如果只有一个元素则返回它
        return A[p]
    q=RANDOMIZED-PARTITION(A,p,r)  //随机版本的数组划分,A[q]称为主元,含义与快速排序中的相同   
    k=q-p+1   //A[p..q]中元素的个数
    if(i==k)   //如果k正好等于i,说明A[q]就是第i个顺序统计量
        return A[q]
    else if(i<k)   //如果i<k,说明第i个顺序统计量在A[p..q-1]中
        return RANDOMIZED-SELECT(A,p,q-1,i)   //递归选择
    else    //如果i>k,说明第i个顺序统计量在A[q+1,r]中
       return RANDOMIZED-SELECT(A,q+1,r,i-k)   //递归选择

我们可以得到以下结论:假设所有的元素互异,在期望线性时间内,我们可以找到任意顺序统计量,特别是中位数。

至于最坏情况为线性时间的选择算法我暂时不深入讨论,我也还需要花些功夫才可以搞懂-_-!

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值