数据结构 | 第2章 向量

132 阅读9分钟

“开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 5 天,点击查看活动详情

第2章 向量

2.1 从数组到向量

前驱(predecessor)

后继(successor)

向量就是线性数组的一种抽象与泛化,它也是由具有线性次序的一组元素构成的集合V = {v0, v1, ..., vn-1},其中的元素分别由(rank)相互区分。

各元素的秩互异,且均为[0, n)内的整数。若元素 e 的前驱元素共计 r 个,则其秩就是 r 。

2.2 接口

ADT接口:

image-20220619203859571.png

注:find和search都是返回元素的秩!

image-20220619204923898.png

Vector模板类:

 0001 using Rank = int; //秩
 0002 #define DEFAULT_CAPACITY  3 //默认的初始容量(实际应用中可设置为更大)
 0003 
 0004 template <typename T> class Vector { //向量模板类(代码2.1)
 0005 protected:
 0006    Rank _size; Rank _capacity;  T* _elem; //规模、容量、数据区
 0007    void copyFrom ( T const* A, Rank lo, Rank hi ); //复制数组区间A[lo, hi)
 0008    void expand(); //空间不足时扩容
 0009    void shrink(); //装填因子过小时压缩
 0010    bool bubble ( Rank lo, Rank hi ); //扫描交换
 0011    void bubbleSort ( Rank lo, Rank hi ); //起泡排序算法
 0012    Rank maxItem ( Rank lo, Rank hi ); //选取最大元素
 0013    void selectionSort ( Rank lo, Rank hi ); //选择排序算法
 0014    void merge ( Rank lo, Rank mi, Rank hi ); //归并算法
 0015    void mergeSort ( Rank lo, Rank hi ); //归并排序算法
 0016    void heapSort ( Rank lo, Rank hi ); //堆排序(稍后结合完全堆讲解)
 0017    Rank partition ( Rank lo, Rank hi ); //轴点构造算法
 0018    void quickSort ( Rank lo, Rank hi ); //快速排序算法
 0019    void shellSort ( Rank lo, Rank hi ); //希尔排序算法
 0020 public:
 0021 // 构造函数
 0022    Vector ( int c = DEFAULT_CAPACITY, Rank s = 0, T v = 0 ) //容量为c、规模为s、所有元素初始为v
 0023    { _elem = new T[_capacity = c]; for ( _size = 0; _size < s; _elem[_size++] = v ); } //s<=c
 0024    Vector ( T const* A, Rank n ) { copyFrom ( A, 0, n ); } //数组整体复制
 0025    Vector ( T const* A, Rank lo, Rank hi ) { copyFrom ( A, lo, hi ); } //区间
 0026    Vector ( Vector<T> const& V ) { copyFrom ( V._elem, 0, V._size ); } //向量整体复制
 0027    Vector ( Vector<T> const& V, Rank lo, Rank hi ) { copyFrom ( V._elem, lo, hi ); } //区间
 0028 // 析构函数
 0029    ~Vector() { delete [] _elem; } //释放内部空间
 0030 // 只读访问接口
 0031    Rank size() const { return _size; } //规模
 0032    bool empty() const { return !_size; } //判空
 0033    Rank find ( T const& e ) const { return find ( e, 0, _size ); } //无序向量整体查找
 0034    Rank find ( T const& e, Rank lo, Rank hi ) const; //无序向量区间查找
 0035    Rank search ( T const& e ) const //有序向量整体查找
 0036    { return ( 0 >= _size ) ? -1 : search ( e, 0, _size ); }
 0037    Rank search ( T const& e, Rank lo, Rank hi ) const; //有序向量区间查找
 0038 // 可写访问接口
 0039    T& operator[] ( Rank r ); //重载下标操作符,可以类似于数组形式引用各元素
 0040    const T& operator[] ( Rank r ) const; //仅限于做右值的重载版本
 0041    Vector<T> & operator= ( Vector<T> const& ); //重载赋值操作符,以便直接克隆向量
 0042    T remove ( Rank r ); //删除秩为r的元素
 0043    int remove ( Rank lo, Rank hi ); //删除秩在区间[lo, hi)之内的元素
 0044    Rank insert ( Rank r, T const& e ); //插入元素
 0045    Rank insert ( T const& e ) { return insert ( _size, e ); } //默认作为末元素插入
 0046    void sort ( Rank lo, Rank hi ); //对[lo, hi)排序
 0047    void sort() { sort ( 0, _size ); } //整体排序
 0048    void unsort ( Rank lo, Rank hi ); //对[lo, hi)置乱
 0049    void unsort() { unsort ( 0, _size ); } //整体置乱
 0050    Rank deduplicate(); //无序去重
 0051    Rank uniquify(); //有序去重
 0052 // 遍历
 0053    void traverse ( void (* ) ( T& ) ); //遍历(使用函数指针,只读或局部性修改)
 0054    template <typename VST> void traverse ( VST& ); //遍历(使用函数对象,可全局性修改)
 0055 }; //Vector

值得注意的:

基于size()直接实现的判空接口empty()。

区间删除remove(lo, hi),区间查找find(e, lo, hi)。

sort()接口将向量转化为有序向量,为此可有多种排序算法供选择。

2.3 构造与析构

image-20220619210842492.png

构造方法:

与所有的对象一样,向量在使用之前也需首先被系统创建——借助构造函数(constructor) 做初始化(initialization)。

  • 默认构造方法:

    首先根据创建者指定的初始容量,向系统申请空间,以创建内部私有数组 _elem[];若容量未明确指定,则使用默认值DEFAULT_CAPACITY。接下来,鉴于初生的向量尚不包含任何元素,故将指示规模的变量 _size初始化为0。

  • 基于复制的构造方法:

    向量的另一典型创建方式,是以某个已有的向量或数组为蓝本,进行(局部或整体的)克隆。

析构方法:

与所有对象一样,不再需要的向量,应借助析构函数(destructor)及时清理(cleanup),以释放其占用的系统资源。与构造函数不同,同一对象只能有一个析构函数,不得重载。

向量对象的析构过程,如代码2.1中的方法~Vector()所示:只需释放用于存放元素的内部数组 _elem[],将其占用的空间交还操作系统。 _capacity和 _size之类的内部变量无需做任何处理,它们将作为向量对象自身的一部分被系统回收,此后既无需也无法被引用。

若不计系统用于空间回收的时间,整个析构过程只需O(1)时间。

2.4 动态空间管理

2.4.1 静态空间管理

内部数组所占物理空间的容量,若在向量的生命期内不允许调整,则称作静态空间管理策略。

向量实际规模与其内部数组容量的比值(即_size / _capacity),亦称作装填因子(load factor),它是衡量空间利用率的重要指标。从这一角度,现有的难题是:

如何才能保证向量的装填因子既不致于超过1(上溢问题(overflow)),也不致于太接近于0(下溢问题(underflow))?

为此,需要改用动态空间管理策略。其中一种有效的方法,即使用所谓的可扩充向量。

2.4.2 可扩充向量

若内部数组仍有空余,则操作可照常执行。每经一次插入(删除),可用空间都会减少(增加)一个单元。

这里的难点及关键在于:

如何实现扩容?新的容量取作多少才算适宜?

image-20220620104945623.png

2.4.3 扩容

 template <typename T> void Vector<T>::expand() { //向量空间不足时扩容
    if ( _size < _capacity ) return; //尚未满员时,不必扩容
    if ( _capacity < DEFAULT_CAPACITY ) _capacity = DEFAULT_CAPACITY; //不低于最小容量
    T* oldElem = _elem;  _elem = new T[_capacity <<= 1]; //容量加倍
    for ( Rank i = 0; i < _size; i++ )
       _elem[i] = oldElem[i]; //复制原向量内容(T为基本类型,或已重载赋值操作符'=')
    delete [] oldElem; //释放原空间
 }

请注意,新数组的地址由操作系统分配,与原数据区没有直接的关系。这种情况下,若直接引用数组,往往会导致共同指向原数组的其它指针失效,成为野指针(wild pointer);而经封装为向量之后,即可继续准确地引用各元素,从而有效地避免野指针的风险。

这里的关键在于,新数组的容量总是取作原数组的两倍——这正是上述后一问题的答案。

2.4.4 分摊分析

基于加倍策略的动态扩充数组不仅可行,而且就分摊复杂度而言效率也足以令人满意。

2.4.5 缩容

尽管下溢不属于必须解决的问题,但在格外关注空间利用率的场合,发生下溢时也有必要适当缩减内部数组容量。

 template <typename T> void Vector<T>::shrink() { //装填因子过小时压缩向量所占空间
    if ( _capacity < DEFAULT_CAPACITY << 1 ) return; //不致收缩到DEFAULT_CAPACITY以下
    if ( _size << 2 > _capacity ) return; //以25%为界
    T* oldElem = _elem;  _elem = new T[_capacity >>= 1]; //容量减半
    for ( Rank i = 0; i < _size; i++ ) _elem[i] = oldElem[i]; //复制原向量内容
    delete [] oldElem; //释放原空间
 }

实际上shrink()过程等效于expand()的逆过程,这两个算法相互配合,在不致实质地增加接口操作复杂度的前提下,保证了向量内部空间的高效利用。

当然,就单次扩容或缩容操作而言,所需时间的确会高达Ω(n),因此在对单次操作的执行速度极其敏感的应用场合以上策略并不适用,其中缩容操作甚至可以完全不予考虑。

2.5 常规向量

  • 直接引用元素

    对向量元素的访问可否沿用数组的方式呢?可以——形如A[i]。

  • 置乱器(permute)

    自后向前,经过O(n)步迭代后,实现了整个向量的置乱。应用:生成随机向量。

    将permute()算法封装至向量ADT中,对外提供向量的区间置乱接口Vector::unsort()。

  • 判等器与比较器

    “比对”操作:判断两个对象是否相等

    “比较”操作:判断两个对象的相对大小

    本书主要采用的方法:在定义对应的数据类型时,通过重载 "<" 和 "==" 之类的操作符,给出大小和相等关系的具体定义和判别方法。

  • 无序查找(find)

    Vector::find(e)接口

    当有多个命中元素时,本书统一约定返回其中秩最大者,故采用自后向前的查找次序

    查找失败约定统一返回-1

  • 插入(insert)

    Vector::insert(r, e)

    时间主要消耗于后继元素的后移,线性正比于后缀的长度(自后向前,后继元素顺次后移一个单元)

  • 删除 单个:Vector::remove(r)

    区间:Vector::remove(lo, hi) 删除区间[lo, hi)内的元素

    实行思路:将单元素删除视作区间删除的特例,并基于后者来实现前者,如此可将移动操作的总次数控制在O(m)内,而与待删除区间的宽度无关。

     0001 template <typename T> int Vector<T>::remove ( Rank lo, Rank hi ) { //删除区间[lo, hi)
     0002    if ( lo == hi ) return 0; //出于效率考虑,单独处理退化情况,比如remove(0, 0)
     0003    while ( hi < _size ) //区间[hi, _size)
     0004       _elem[lo++] = _elem[hi++]; //顺次前移hi - lo个单元
     0005    _size = lo; //更新规模,直接丢弃尾部[lo, _size = hi)区间
     0006    shrink(); //若有必要,则缩容
     0007    return hi - lo; //返回被删除元素的数目
     0008 }
    

    image-20220620161607208.png

  • 唯一化(deduplicate)

    无序向量清除重复元素接口:

    Vector::deduplicate()

     template <typename T> int Vector<T>::deduplicate() { //删除无序向量中重复元素(高效版)
         int oldSize = _size; //记录原规模
         Rank i = 1; //从_elem[1]开始
         while (i < _size) //自前向后逐一考查各元素_elem[i]
             (find(_elem[i], 0, i) < 0) ? //在其前缀中寻找与之雷同者(至多一个)
             i++ : remove(i); //若无雷同则继续考查其后继,否则删除雷同者
         return oldSize - _size; //向量规模变化量,即被删除元素总数
     }
    

    这里所需的时间,主要消耗于find()和remove()两个接口。总体时间复杂度O(n2)O(n^2)

  • 遍历(traverse)

    Vector::traverse()

    实例:V.traverse(Increase()) —— 以Increase()为基本操作进行遍历

2.6 有序向量

  • 比较器

    这里沿用上节的约定,假设复杂数据对象已经重载了 "<" 和 "<=" 等操作符

  • 有序性甄别

  • 唯一化(uniquify)

    Vector::uniquify()

    高效版:

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

    image-20220620211730350.png

    uniquify()算法的时间复杂度应为O(n),较之有序低效版和无序deduplicate()的O(n2)O(n^2)效率整整提高了一个线性因子。

  • 查找

    尽管在最坏情况下,无序向量的查找操作需要线性时间,但我们很快就会看到,有序向量的这一效率可以提升至O(logn)。

    为区别于无序向量的查找接口find(),有序向量的查找接口将统一命名为search()。与find()一样,代码2.1也针对有序向量的整体或区间查找重载了两个search()接口,且前者作为特例可直接调用后者。因此,只需如下面代码所示实现其中的区间查找接口。

     0001 template <typename T> //在有序向量的区间[lo, hi)内,确定不大于e的最后一个节点的秩
     0002 Rank Vector<T>::search ( T const& e, Rank lo, Rank hi ) const { //assert: 0 <= lo < hi <= _size
     0003    return ( rand() % 2 ) ? //按各50%的概率随机使用二分查找或Fibonacci查找
     0004           binSearch ( _elem, e, lo, hi ) : fibSearch ( _elem, e, lo, hi );
     0005 }
    

    鉴于有序查找的算法多样且各具特点,为便于测试,这里的接口不妨随机选择查找算法。实际应用中可根据问题的特点具体确定,并做适当微调。

  • 二分查找(版本A)

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

    左移运算符 << 和右移运算符 >> :

    左(右)移运算符乘除法
    << 1* 2
    << 2* 4
    >> 1/ 2
    >> 2/ 4

    与顺序查找算法的O(n)复杂度相比,O(logn)几乎改进了一个线性因子

    平均查找长度:O(1.5 · log2n)

    不足:就其常系数 1.5 而言仍有改进余地。

  • Fibonacci查找

    对二分A优化思路:

    其一,调整前、后区域的宽度,适当地加长(缩短)前(后)子向量。

    其二,统一沿两个方向深入所需要执行的比较次数,比如都统一为一。

    Fibonacci查找思路:通过采用黄金分割点,一定程度降低时间复杂度常系数。

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

    平均查找长度:O(1.44 ∙ log2n),较二分版本A的O(1.5 · log2n)略有提高。

  • 二分查找(版本B)

    版本B优化思路:从三分支到两分支

     0001 // 二分查找算法(版本B):在有序向量的区间[lo, hi)内查找元素e,0 <= lo < hi <= _size
     0002 template <typename T> static Rank binSearch ( T* S, T const& e, Rank lo, Rank hi ) {
     0003    while ( 1 < hi - lo ) { //每步迭代仅需做一次比较判断,有两个分支;成功查找不能提前终止
     0004       Rank mi = ( lo + hi ) >> 1; //以中点为轴点(区间宽度的折半,等效于宽度之数值表示的右移)
     0005       ( e < S[mi] ) ? hi = mi : lo = mi; //经比较后确定深入[lo, mi)或[mi, hi)
     0006    } //出口时hi = lo + 1,查找区间仅含一个元素A[lo]
     0007    return e < S[lo] ? lo - 1 : lo; //返回位置,总是不超过e的最大者
     0008 } //有多个命中元素时,返回秩最大者;查找失败时,简单地返回-1,而不能指示失败的位置
    

    版本B与版本A的区别:

    首先,每一步迭代只需判断是否e < A[mi],即可相应地更新有效查找区间的右边界(hi = mi)或左边界(lo = mi)

    另外,只有等到区间的宽度已不足2个单元时迭代才会终止,最后再通过一次比对判断查找是否成功

    性能方面:

    相对于A,最好情况下的效率有所倒退,因为不能如A那样一旦命中就能返回

    相对于A,最坏情况下的效率相应有所提高

    故整体性能更趋稳定

  • 二分查找(版本C)

    更进一步的要求:

    有多个命中元素时,返回秩最大者;查找失败时,能够返回失败的位置

    这个要求的优点:

    以有序向量的插入操作为例,若通过查找操作不仅能够确定可行的插入位置,而且能够在同时存在多个可行位置时保证返回其中的秩最大者,则不仅可以尽可能低减少需移动的后继元素,更可保证重复的元素按其插入的相对次序排列。对于向量的插入排序等算法的稳定性而言,这一性质更是至关重要。

    同样地,若在插入新元素e之前通过查找确定适当的插入位置,则希望在查找失败时返回不大(小)于e的最后(前)一个元素,以便将e作为其后继(前驱)插入向量。同样地,此类约定也使得插入排序等算法的实现更为便捷和自然。

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

    注意几点:

    循环终止:lo = hi,有效区间宽度缩短至0

    每次转入后端分支时,子向量左边界取mi + 1而不是 mi

    版本C中的循环体具有以下不变性:

    A[0, lo)中的元素皆不大于e;A[hi, n)中的元素皆大于e

    循环终止时,lo = hi。考查此时的元素A[lo - 1]和A[lo]:作为A[0, lo)内的最后一个元素,A[lo - 1]必不大于e;作为A[lo, n) = A[hi, n)内的第一个元素,A[lo]必大于e。也就是说,A[lo - 1]即是原向量中不大于 e 的最后一个元素。因此在循环结束之后,无论成功与否,只需返回 lo - 1 即可——这也是版本C与版本B的第三点差异。

2.7 排序与下界

最坏情况下CBA式排序算法至少需要Ω(nlogn)时间,其中n为待排序元素数目。

2.8 排序器

统一入口:

 0001 template <typename T> void Vector<T>::sort ( Rank lo, Rank hi ) { //向量区间[lo, hi)排序
 0002    switch ( rand() % 5 ) {
 0003       case 1:  bubbleSort ( lo, hi ); break; //起泡排序
 0004       case 2:  selectionSort ( lo, hi ); break; //选择排序
 0005       case 3:  mergeSort ( lo, hi ); break; //归并排序
 0006       case 4:  heapSort ( lo, hi ); break; //堆排序
 0007       default:  quickSort ( lo, hi ); break; //快速排序
 0008    } //随机选择算法以充分测试。实用时可视具体问题的特点灵活确定或扩充
 0009 }

起泡排序属于稳定算法(重复元素之间的相对次序在排序前后保持一致

归并排序:O(nlogn)

image-20220623101447252.png

无序向量的递归分解:O(logn)

有序向量的逐层归并:O(n)( Θ(n) )

故T(n) = O(nlogn) (因二路归并算法的效率稳定在Θ(n),故更准确地讲,归并排序算法的时间复杂度应为Θ(nlogn))

分治策略:

 0001 template <typename T> //向量归并排序
 0002 void Vector<T>::mergeSort ( Rank lo, Rank hi ) { //0 <= lo < hi <= size
 0003    if ( hi - lo < 2 ) return; //单元素区间自然有序,否则...
 0004    Rank mi = ( lo + hi ) / 2; //以中点为界
 0005    mergeSort ( lo, mi ); mergeSort ( mi, hi ); //分别排序
 0006    merge ( lo, mi, hi ); //归并
 0007 }

重点是二路归并:

 template <typename T> //有序向量的归并
 2 void Vector<T>::merge(Rank lo, Rank mi, Rank hi) { //以mi为界、各自有序的子向量[lo, mi)和[mi, hi)    T* A = _elem + lo; //合幵后的向量A[0, hi - lo) = _elem[lo, hi)    int lb = mi - lo; T* B = new T[lb]; //前子向量B[0, lb) = _elem[lo, mi)    for (Rank i = 0; i < lb; B[i] = A[i++]); //复制前子向量
    int lc = hi - mi; T* C = _elem + mi; //后子向量C[0, lc) = _elem[mi, hi)    for (Rank i = 0, j = 0, k = 0; (j < lb) || (k < lc); ) { //将B[j]和C[k]中的小者续至A末尾
       if ( (j < lb) && ( !(k < lc) || (B[j] <= C[k]) ) ) A[i++] = B[j++];
       if ( (k < lc) && ( !(j < lb) || (C[k] < B[j]) ) ) A[i++] = C[k++];
    }
    delete [] B; //释放临时空间B
 } //归并后得到完整的有序向量[lo, hi)