代码来源:《数据结构(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);
通过这样一种方法,可以保证插入进来的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版代码中有三个地方需要注意: