有序向量

代码来源:《数据结构(c++语言版)(第三版)》,邓俊辉编著,ISBN: 978-7-302-33064-6

有序向量中的元素不仅按线性次序排放,而且数值大小也按次序单调分布。这给后续的操作带来很大的便利。虽然对向量排序sort()会花费一定的时间,但是从总体上看,转化成有序向量后,操作会简化很多。这一时间的花费是值得的。

1.有序性甄别

向量有序的比较原理是:对向量中任意两个相邻元素进行比较,如果其未按要求排列,则称其为逆序对。如果向量中没有类似的逆序对,则向量有序。

template <typename T> int Vector::disordered() const {   //返回向量中逆序相邻元素对的总数
    int n = 0;  //计数器
    for (int i = 1; i < _size; ++i)     //逐一检查_size - 1对相邻元素
        if(_elem[i-1] > _elem[i]) ++n;  //逆序则计数
    return n;   //向量有序当且仅当n = 0
}

2.唯一化

为了实现唯一化,最容易想到的方法就是从第一项元素开始,逐个扫描后继元素,若相同则删除后继元素。

template <typename T> int Vector::uniquify(){    //有序向量重复元素剔除算法(低效版)
    int oldSize = _size; int i = 1; //当前比对元素的秩,起始于首元素
    while (i < _size)   //从前向后,逐一比对各对相邻元素
        _elem[i - 1] == _elem[i] ? remove(i) : ++i; //若雷同,则删除后者;否则,转至后一元素
    return oldSize - _size; //向量规模变化量,即被删除的元素总数
}

但是这一代码中,每个元素都移动了n - i次,时间复杂度O(n) = O(n -2) + O(n - 3) + … + O(2) + O(1) = O(n^2),与无序向量复杂度相同,没有起到有序的作用。

注意到在代码中,引起复杂度增加的主要就是while,每个元素每次只移动一个位置,降低了效率。为此我们对其进行修改。

template <typename T> int Vector::uniquify(){    //有序向量重复元素剔除算法(高效版)
    Rank i = 0, j = 0;  //各对互异“相邻”元素的秩
    while (++j < _size) //逐一扫描,直至末元素
        if (_elem[i] != _elem[j])   //跳过雷同者
            _elem[++i] = _elem[j];  //发现不同元素时,向前移至紧邻前者右侧
    _size = ++i; shrink();  //直接截除尾部多余元素
    return j - i;   //向量规模变化量,即被删除的元素总数
}   

修改后的代码中,先是对向量第一项进行记录,对后继项进行扫描,如果有相同项,不做任何处理;如果有不同项,将其复制到第二项;再有不同项复制到第三项……以此类推,直到把所有不同项顺序排列在前j项中,最后删除j + 1项以后的空间。

3.插入元素

在有序向量中插入元素,使用类似语句 V.insert(V.search(e) + 1, e);

  1. search(e)返回的是不大于e元素的秩最大的值。也就是说,如果有序向量里有e元素存在,且有多个e元素,那么其返回的就会是e元素所在的最大的位置;如果e不存在,那么返回一个小于e的最大位置;
  2. 找到返回值后,对其+ 1,目的是将新元素插入在刚刚找到的那个位置的后一个位置,将元素e插入,后继元素后移。
  3. 如果e比向量中所有元素都小,那么按照定义,返回值会是-1位置,加一之后会是0位置,即首地址;如果e比向量中所有元素都大,那么返回值会是旧向量的末尾,加一后是尾地址之后插入一个元素。

通过这样一种方法,可以保证插入进来的e元素停留在一个恰好的位置,使得新向量仍然是一个有序向量,而且如果有多个e在旧向量中,每次插入的位置都相对确定,是在最末尾处添加。

4.查找元素

由上可知,S.search(e, lo, hi)可以帮助查找元素。而为了更快捷查找元素,二分查找是个更好的办法。

// 二分查找算法(版本A):在有序向量的区间[lo, hi)内查找元素e,0 <= lo <= hi <= _size
template <typename T> static Rank binSearch ( T* A, T const& e, Rank lo, Rank hi ) {
    while ( lo < hi ) { //每步迭代可能要做两次比较判断,有三个分支
       Rank mi = ( lo + hi ) >> 1; //以中点为轴点
       if      ( e < A[mi] ) hi = mi; //深入前半段[lo, mi)继续查找
       else if ( A[mi] < e ) lo = mi + 1; //深入后半段(mi, hi)继续查找
       else                return mi; //在mi处命中
    } //成功查找可以提前终止
    return -1; //查找失败
} //有多个命中元素时,不能保证返回秩最大者;查找失败时,简单地返回-1,而不能指示失败的位置

将区间分为三部分:S[lo, mi), S[mi], S(mi, hi)。

将元素e进行比较,观察其属于那个部分,如果在左右两个区间,则继续进行二分查找,直到找到对应元素。

可以证明,二分查找的平均查找长度是O(1.5logn)

4.2 Fibonacci查找

二分查找表面上看起来很均衡,实际上还有改进余地。因为在算法中,对于一个[mi],如果向左查找,需要查找1次,如果向右查找,则需要查找2次,而向左或向右并不决定最终的结果。换句话说,如果某元素随机出现在向量中的某一位置,那么向左或者向右的次数也随机变化。但是向左或者向右的需要的查找次数不同。基于这一现象,我们可以对二分查找再次进行改进。

既然向左成本更低,我们就希望尽可能多的向左进行查找。通过递归深度的不平衡,对转向成本的不平衡进行补偿。

考虑Fibonacci数列。

设向量长度n = fib(k) - 1,取[mi] = fib(k - 1) - 1为中间数,那么其前后子向量的长度分别是:
fib(k - 1) - 1
fib(k) - 1 - (fib(k - 1) - 1) - 1 = fib(k - 2) - 1
这样前后子向量仍然能保持某个Fibonacci数列。

#include "fibonacci/Fib.h" //引入Fib数列类
// Fibonacci查找算法(版本A):在有序向量的区间[lo, hi)内查找元素e,0 <= lo <= hi <= _size
template <typename T> static Rank fibSearch ( T* A, T const& e, Rank lo, Rank hi ) {
   Fib fib ( hi - lo ); //用O(log_phi(n = hi - lo)时间创建Fib数列
   while ( lo < hi ) { //每步迭代可能要做两次比较判断,有三个分支
      while ( hi - lo < fib.get() ) fib.prev(); //通过向前顺序查找(分摊O(1))——至多迭代几次?
      Rank mi = lo + fib.get() - 1; //确定形如Fib(k) - 1的轴点
      if      ( e < A[mi] ) hi = mi; //深入前半段[lo, mi)继续查找
      else if ( A[mi] < e ) lo = mi + 1; //深入后半段(mi, hi)继续查找
      else                return mi; //在mi处命中
    } //成功查找可以提前终止
      return -1; //查找失败
} //有多个命中元素时,不能保证返回秩最大者;失败时,简单地返回-1,而不能指示失败的位置

并且可以证明,Fibonacci查找算法的平均复杂度为O(1.44logn),这一数值在不改变算法结构的情况下,已经达到极限,无法继续优化。

4.3 二分查找的改进

上述的二分查找的复杂度关键在于,每一次向右查找总是比向左多一次。为了消除这种情况,我们可以把每次比较的分支改为两个,即[lo, mi), [mi, hi)。当然,[mi]如果恰好是需要找到那个点时,无法在第一次就返回,必须等到足够多次的查找之后才能返回。也就是说,最好情况变得更差,最差情况变得更好。

// 二分查找算法(版本B):在有序向量的区间[lo, hi)内查找元素e,0 <= lo <= hi <= _size
template <typename T> static Rank binSearch ( T* A, T const& e, Rank lo, Rank hi ) {
   while ( 1 < hi - lo ) { //每步迭代仅需做一次比较判断,有两个分支;成功查找不能提前终止
      Rank mi = ( lo + hi ) >> 1; //以中点为轴点
      ( e < A[mi] ) ? hi = mi : lo = mi; //经比较后确定深入[lo, mi)或[mi, hi)
   } //出口时hi = lo + 1,查找区间仅含一个元素A[lo]
   return ( e == A[lo] ) ? lo : -1 ; //查找成功时返回对应的秩;否则统一返回-1
} //有多个命中元素时,不能保证返回秩最大者;查找失败时,简单地返回-1,而不能指示失败的位置

事实上,不管是版本B还是Fibonacci,都没有严格执行要求,即返回秩最大的e,而是会根据向量长度,随机返回某个e的位置,故再次改进提出版本C。

// 二分查找算法(版本C):在有序向量的区间[lo, hi)内查找元素e,0 <= lo <= hi <= _size
template <typename T> static Rank binSearch ( T* A, T const& e, Rank lo, Rank hi ) {
   while ( lo < hi ) { //每步迭代仅需做一次比较判断,有两个分支
      Rank mi = ( lo + hi ) >> 1; //以中点为轴点
      ( e < A[mi] ) ? hi = mi : lo = mi + 1; //经比较后确定深入[lo, mi)或(mi, hi)
   } //成功查找不能提前终止
   return --lo; //循环结束时,lo为大于e的元素的最小秩,故lo - 1即不大于e的元素的最大秩
} //有多个命中元素时,总能保证返回秩最大者;查找失败时,能够返回失败的位置

C版代码中有三个地方需要注意:

  1. 有效长度减少至0时,而不是1,结束循环;
  2. 两个子向量的大小分别是[lo, mi)和(mi, hi);
  3. 这样看起来会忽略[mi],实际上,多次循环后,即使向量中有多个e,最后一次的lo总是指向大于e元素的最小秩,故返回的–lo可以严格表示e在向量里的位置。

你可能感兴趣的:(c++学习)