Chapter2 向量Vector与冒泡排序、归并排序

357 阅读17分钟
  • 数据结构是数据项的结构化集合

  • 结构性表现为数据项之间的相互联系及作用,即定义于数据项之间的某种逻辑次序

  • 根据这种逻辑次序,将数据结构划分为三类

    • 线性结构,最为基本的线性结构统称为序列sequence,根据其中数据项的逻辑次序与其物理存储地址的对应关系不同,将其分为

      1. 向量vector 所有数据项的物理存放位置与其逻辑次序完全吻合,此时的逻辑次序也称作秩(rank)

      2. 列表list 逻辑上相邻的数据项在物理上未必相邻,而是采用间接定址的方式通过封装后的位置(position)相互引用

    • 半线性结构

    • 非线性结构

2.1 从数组到向量

2.1.1 数组

数组元素与编号一一对应,A[0], A[1], A[2], A[3], ... , A[n-1],它们存放于起始于地址A、物理位置连续的一段存储空间,并统称作数组(array),若数组A[ ]存放空间的起始地址为A,且每个元素占用s个单位的空间,则元素A[i]对应的物理地址为:A + i * s

对于任何0 <= i < j < n,A[ i ]都是A[ j ]的前驱(predecessor),A[ j ]都是A[ i ]的后继(successor),特别的,A[ i ]与A[ j ]紧邻时,称作直接前驱intermediate predecessor直接后继intermediate successor

任一元素的所有前驱构成其前缀prefix,所有后继构成其后缀suffix

2.1.2 向量

向量是线性数组的一种抽象和泛化,它也是由具有线性次序的一组元素构成的集合V = {v0, v1, ..., vn-1},各元素与[0, n)内的秩Rank一一对应,这种访问方式称作循秩访问

typedef int Rank;

向量的优点是:操作,管理维护更加简化统一和安全;元素类型可以灵活选取,便于定做复杂的数据结构。

2.2 接口

2.2.1 ADT接口

操作接口功能适用对象
size()报告向量当前的规模(元素总数)有返回值向量
get(r)获取秩为r的元素 有返回值向量
put(r, e)用e替换秩为r的元素 无返回值向量
insert(r, e)e作为秩为r的元素插入,原后继元素依次后移 无返回值向量
remove(r)删除秩为r的元素,返回该元素中原存放的对象 有返回值向量
disordered()判断所有元素是否已按非降序排列 ,返回逆序数(为0时则已排好) 有返回值向量
sort()调整各元素的位置,使之按非降序排列 无返回值向量
find(e)查找等于e且秩最大的元素,找不到等于e的元素时返回-1 有返回值向量
search(e)查找目标元素e,返回不大于e且秩最大的元素 有返回值有序向量
deduplicate()剔除重复元素向量
uniquify()剔除重复元素有序向量
traverse()遍历向量并统一处理所有元素,处理方法由函数对象指定向量

2.2.2 vector模板类

//代码2-1
typedef int Rank;
template <typename T>
class Vector
{
private:
    Rank _size;    //规模
    int _capacity; //容量
    T *_elem;      //数据存储区域
protected:
    /*内部函数*/
public:
    /*构造函数*/
    /*析构函数*/
    /*只读接口*/
    /*可写接口*/
    /*遍历接口*/
};

image-20200809175858300

2.3 构造和析构

由代码2-1可知,向量结构在内部维护一个元素类型为T的私有数组_elem[]:其容量由私有变量_capacity指示;有效元素的数量(即向量当前的实际规模),则由_size指示,我们进一步对向量元素的rank数组单元的逻辑编号以及物理地址之间的关系做以下约定:

==向量中秩为r的元素,对应于内部数组中的_elem[r],其物理地址为_elem + r==

这里的r前为何没有×单个元素的大小,都按1看待?数组_elem的首元素地址加r代表_elem[r]的地址

2.3.1 默认构造方法

//代码2-2
#define DEFAULT_CAPACITY 3	//默认初始容量(根据实际应用调整)
Vector(int c = DEFAULT_CAPACITY) //默认
{
    _elem = new T[_capacity = c];
    _size = 0;
}

image-20200809175826359根据创建者指定的初始容量,向系统申请空间(如果未指定,则采用默认容量DEFAULT_CAPACITY),以创建内部私有数组_elem[],因为初生的向量不含任何元素,将表示规模(有效元素个数)的_size初始化为0

复杂度分析

整个过程顺序进行,没有任何迭代,故若忽略用于分配数组空间的时间,共需常数时间O(1)

2.3.2 基于复制的构造方法

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

//代码2-3
template <typename T> //元素类型
//以数组区间A[lo, hi)为蓝本复制向量,注意左闭右开,hi作为一个哨兵
void Vector<T>::copyFrom(T const *A, Rank lo, Rank hi)
{
    _elem = new T[_capacity = 2 * (hi - lo)]; //分配空间,为何是两倍?见2.4
    _size = 0;                                //规模清零
    while (lo < hi)                           //A[lo, hi)中的元素逐一
        _elem[_size++] = A[lo++];             //复制到_elem[0, hi - lo)
}

copyFrom()首先根据待复制区间的边界,换算出新向量的初始规模;再以双倍的容量,为内部数组_elem[]申请空间。最后通过一趟迭代,完成区间**A[lo, hi)**内各元素的顺次复制。

复杂度分析

若忽略开辟新空间所需的时间,运行时间应正比于区间宽度,即O(hi - lo) = O(_size)


下面是具体的构造函数:

//代码2-4
public:
    /*构造函数*/
    Vector(int c = DEFAULT_CAPACITY) //默认
    {
        _elem = new T[_capacity = c];
        _size = 0;
    }

    Vector(T const *A, Rank lo, Rank hi) //数组区间复制
    {
        copyFrom(A, lo, hi);
    }

    Vector(Vector<T> const &V, Rank lo, Rank hi) //向量区间复制
    {
        copyFrom(V._elem, lo, hi);
    }

    Vector(Vector<T> const &V) //向量整体复制
    {
        copyFrom(V._elem, 0, V._size);
    }

需要强调一点:由于向量内部含有动态分配的空间,默认的运算符“=”不足以支持向量之间的直接赋值。为了实现这个目的,我们重载向量的赋值运算符。

//代码2-5
template <typename T>
Vector<T>& Vector<T>::operator=(Vector<T> const &V) //重载赋值运算符
{
    if (_elem)
        delete[] _elem;             //释放原内容
    copyFrom(V._elem, 0, V.size()); //整体复制
    return *this;                   //返回当前对象的引用,以便链式赋值
}

2.3.3 析构方法

//代码2-6 
~Vector() //释放内部空间
{
	delete[] _elem;
}

不再需要使用的向量对象应该借助析构函数及时清理释放系统资源,同一个对象只能有一个析构函数且不能重载。

如代码所示,我们只需释放存放元素的内部数组_elem[],而_capacity_size这类内部变量无需做任何处理,将作为向量对象的一部分被系统回收。

复杂度分析

不计系统用于空间回收的时间,这个析构过程需要的时间为O(1)

2.4 动态内存管理

2.4.1 静态空间管理

内部数组所占物理空间的容量在向量生命期内不允许调整,这样的管理策略难以保证空间效率。

向量实际规模与其内部数组容量的比值 = _size / _capacity,称之为装填因子,它是衡量空间利用率的重要指标。

我们下面讨论的目的就是找到方法==保证向量的装填因子<1,而又不接近于0==

2.4.2 可扩充向量

若内部数组仍有空余,则操作可照常执行。每经一次插入(删除),可用空间都会减少(增加)一个单元。一旦可用空间耗尽,就动态地扩大内部数组的容量(一种实现方法是,申请一个更大容量的数组,将原数组中的成员复制到新数组,然后将原数组释放)。

image-20200809181440905

2.4.3 扩容

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

每次调用insert()接口插入新元素时,都有先调用该算法,检查内部数组的可用容量,一旦当前数据区已满(_size = _capacity),则将原数组替换为一个更大的数组。

这里的关键问题是:为何容量要加倍而不是以其他数目增加?

2.4.4 分摊分析

时间代价

这种扩容方式的灵活性不是没有代价的,每次扩容发生的元素搬迁都需要额外的时间,即每次n➡2n的扩容都需要花费**O(2n) = O(n)**时间,这也是最坏情况下,单次插入操作所需的时间。

随着向量规模的不断扩大,在执行插入操作之前需要进行扩容的概率,也将迅速降低。故就某种平均意义而言,用于扩容的时间成本不至很高。以下不妨就此做一严格的分析。

分摊复杂度

不妨考查对可扩充向量的足够多次连续操作,并将其间所消耗的时间,分摊至所有的操作。如此分摊平均至单次操作的时间成本,称作分摊运行时间(amortized running time)。

以可扩充向量为例,可以考查对该结构的连续n次(查询、插入或删除等)操作,将所有操作中用于内部数组扩容的时间累计起来,然后除以n。只要n足够大,这一平均时间就是用于扩容处理的分摊时间成本。

下面我们来推导可扩充向量单次操作中,用于扩容处理的分摊时间成本:

最坏情况:在初始容量1的满向量中,连续插入n = 2m >> 2 个元素,共扩容了m(即log2n)次

image-20200809225609339

每次扩容所需要的时间成本:1, 2, 4, 8,... , 2m理解一点,插入第2m个元素时恰好发生扩容,复制这2m个元素消耗的时间为2m,扩容后的容量为2m+1在图中未标出,但2m这个时间成本不能忽略。

作为几何级数,各次扩容的时间成本相加后与末项同阶,总体耗时为T(n) = O(2m) = O(n)

由代码2-7可知,每次插入操作我们都要调用expend(),故每次操作的分摊成本为T(n) / n = O(1)

其他扩容策略

假如我们采用递增的扩容策略,下面简要推算这种方式的分摊运行时间

最坏情况:在初始容量0的空向量中,连续插入n = m*i >> 2 个元素,每次扩容 i 个元素共扩容了m次

image-20200809232117948

每次扩容复制原向量所需要的时间成本:0, i, 2i, ... , (m-1) × i,m × i

作为算术级数,各次扩容的时间成本相加后是末项的平方,总体耗时为T(n) = O((m × i)2) = O(n2),每次操作的分摊成本为T(n) / n = O(n)。

由此可见,在时间成本上,倍增策略优于递增策略。

2.4.5 缩容(见代码shrink函数)

2.5 常规(无序)向量

无序向量:T为可判等的基本类型,或已重载操作符“==”或“!=

有序向量:T为可比较的基本类型,或已重载操作符“<”或“>

2.5.1 使用[]直接引用元素

与数组通过下标访问元素的方式相比(A[i]),向量ADT设置的get()put()接口显得不那么直观自然,通过重载操作符[]来解决这个问题。

调用下面的函数,则V[r]只能作右值,引用的vector<T>对象不能修改(后一个const),返回的值也不能立即修改(前一个const)。

template <typename T>
const Vector<T> &Vector<T>::operator[](Rank r) const //重载下标运算符
{
    return _elem[r];
    // assert: 0 <= r < _size
}

当然,重载下面函数,V[r]可以作为左值被修改。

template <typename T>
T &Vector<T>::operator[](Rank r) //重载下标运算符
{
    return _elem[r];
    // assert: 0 <= r < _size
}

注意,我们这里(及后面的各种算法)没有对r的范围进行判断而是断言0 <= r < _size,实际应用中不能这样。

2.5.2 置乱器(见代码unsort函数)

2.5.3 判等器与比较器

2.5.4 无序查找

实现

//无序向量的顺序查找
template <typename T>
Rank Vector<T>::find(T const &e, Rank lo, Rank hi) const
{
    while ((lo < hi--) && (e != _elem(hi))) //从后向前顺序查找
        return hi;                          // 如果hi < lo,则说明查找失败;否则hi就是命中元素的Rank
}

复杂度分析

最坏情况下,O(hi - lo) = O(n);最好情况下,O(1)

对于规模相同、内部组成不同的输入,渐进运行时间却有本质区别,故此类算法也称作输入敏感input sensitive算法

2.5.5 插入insert(r, e)

实现

template <typename T>
Rank Vector<T>::insert(Rank r, T const &e)
{
    expand();                       //如有必要则扩容
    for (int i = _size; i > r; i--) //自后向前
        _elem[i] = _elem[i - 1];    //将后继元素顺次后移一个单元
    _elem[r] = e;                   //置入新元素
    _size++;                        //更新规模
    return r;                       //返回秩
}

记得判断是否扩容☑

记得使元素按由后至前的顺序向后移位(由前至后会发生覆盖)☑

复杂度分析

时间成本主要来自后继元素的后移,线性正比于后缀长度,O(_size - r + 1)

最好的情况,r = _size, O(1)的时间;最坏的情况,r = 0,O(_size)的时间。

对于每个插入位置而言,对应的移动操作次数恰好等于其后继元素(包含自身)的数目。不难看出它们也构成一个等差数列,故在等概率的假设条件下,其均值(数学期望)应渐进地与其中的最高项同阶,为O(n),n为向量规模

2.5.6 删除

实现

//区间删除
template <typename T>
int Vector<T>::remove(Rank lo, Rank hi)
{
    if (lo == hi) //出于对效率的考虑,单独处理退化情况
        return 0;
    while (hi < _size)
        _elem[lo++] = _elem[hi++]; //[hi,_size)顺次前移hi-lo个单位
    _size = lo;                    //更新规模,这里的lo经过循环更新后就等于当前规模
    shrink();                      //如有必要,进行缩容
    return hi - lo;                //返回被删除元素数目
}

//单元素删除
template <typename T>
T Vector<T>::remove(Rank r)
{
    T e = _elem[r];   //备份被删除元素
    remove(r, r + 1); //等效对区间[r, r+1)的删除
    return e;         //返回被删除元素
}

image-20200812094231805

复杂度分析

计算成本主要来自于后缀元素的前移O(m+1) = O(_size - hi + 1),与要删除的元素的规模无关

最好的情况,hi在最后一个,O(1);最坏的情况,hi在第一个,O(n) = O(_size)

还有一种实现方法是通过反复调用remove(r)实现remove(lo, hi),但是这么做后发生多次后缀元素的前移,每次循环耗时正比于后缀长度,循环次数等于区间宽度hi - lo,复杂度可能高至O(n2)

2.5.7 唯一化

鳯兮鳯兮,故是一鳯

实现

template <typename T>
int Vector<T>::deduplicate()
{
    int oldSize = _size;                        //记录原规模
    Rank i = 1;                                 //从_elem[1]开始
    while (i < _size)                           //自前向后逐一考察各元素
        (find(_elem[i], 0, i) < 0) ?            //前缀中是否存在雷同者
            i++                                 //不存在-继续查找下一个元素;
                                   : remove(i); //存在-删除该元素
    return oldSize - _size;                     //返回向量规模变化量,即删除元素数
}

正确性分析

不变性:在while循环中,在当前元素_elem[i]的前缀_elem[0, i)内,所有元素彼此互异

单调性:随着反复while的迭代,当前前缀的长度单调非降,直到达到size;对称的后缀长度单调下降,直到0

复杂度分析

由程序可知,经过n -2 步迭代后,算法必然终止,每步迭代主要的计算成本来自find()remove()两个接口。

_elem[i],对于find(),查找范围是[0, i),对于remove()(不一定调用),后缀规模是(i, size);两者相加,每轮迭代的时间复杂度最大为O(n)

故总体复杂度为O(n - 2) * O(n) = O(n2)

2.5.8 遍历

实现

template <typename T>
void Vector<T>::traverse(void (*visit)(T &)) //函数指针,只读或局部性修改
{
    for (int i = 0; i < _size; i++)
        visit(_elem[i]);
}

template <typename T>
template <typename VST>
void Vector<T>::traverse(VST &visit) //函数对象,全局性修改更便捷
{
    for (int i = 0; i < _size; i++)
        visit(_elem[i]);
}

传入的参数是函数指针或函数对象,遍历时,循环调用这个函数,一般建议使用函数对象

使用函数对象时,必须其中重载操作符(),即operator()(){...}

实例

template <typename T>
struct Increase //函数对象,递增一个T类函数,通过重载操作符()实现
{
    virtual void operator()(T &e)
    {
        e++; //假设T可以直接递增或已重载++
    }
};

template <typename T>
void increase(Vector<T> &V) //统一递增向量中的各元素
{
    V.traverse(Increase<T>());
}

复杂度分析

遍历操作本身只包含一层线性的循环迭代,故除了向量规模的因素之外,遍历所需时间应线性正比于所统一指定的基本操作所需的时间。比如在上例中,统一的基本操作Increase<T>()只需常数时间,故这一遍历的总体时间复杂度为O(n)。

2.6 有序向量

若向量S[0, n)中的所有元素不仅按线性次序存放,而且其数值大小也按此次序单调分布,则称作有序向量(sorted vector)

与通常的向量一样,有序向量依然不要求元素互异,故通常约定其中的元素自前(左)向后(右)构成一个非降序列,即对任意0 <= i < j < n都有S[i] <= S[j]

2.6.1比较器

与无序向量相比,有序向量隐含先决条件:元素之间必须可以比较大小。我们假定,复杂数据对象都已经重载<<=等操作符

2.6.2 有序性甄别

有序向量作为无序向量的特例,自然可以沿用无序向量的查找算法。但是,得益于元素之间的有序性,有序向量的查找、唯一化等操作可以更快地完成。

因此,在实施这些操作之前,有必要判断当前向量是否有序,以便确定是否采用更高效的接口。

template <typename T>
int Vector<T>::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.6.3 唯一化

image-20200812175618805

有序向量中,重复的元素必然相互紧邻构成一个区间,因此,去重就是在每个区间只保存单个元素即可。

低效版

实现
template <typename T>
int Vector<T>::uniquify()
{
    int oldSize = _size;
    int i = 1;
    while (i < _size)
        _elem[i - 1] == _elem[i] ? remove(i) : i++; //雷同则删除,不雷同就下一个
    return oldSize - _size;                         //向量规模变化量,即删除元素个数
}
复杂度分析

运算时间的消耗主要来自while循环,共迭代了_size -1 = n -1步;在最坏情况下,每次循环执行一次remove()操作,复杂度正比于被删除元素后继的个数,(n - 2)+(n - 3)+(n - 4)+ ... +2+1 = O(n2)(算术级数与末项的平方同阶),这个复杂度和无序向量下的唯一化算法相同,说明未充分使用向量的有序性。

改进方法

低效版复杂度过高的根源在于:各次调用remove()时,同一元素作为后继元素可能向前移动多次,而且每次只移动一个单元。

既然每组重复元素必然相邻集中分布,那么不妨以区间为单位分批对各组重复元素进行删除,再使其后继元素向前跨一大步。

实现

template <typename T>
int Vector<T>::uniquify()
{
    Rank i = 0, j = 0;             //各对互异相邻元素的秩
    while (++j < _size)            //逐一扫描,直到末元素
        if (_elem[i] != _elem[j])  //跳过雷同
            _elem[++i] = _elem[j]; //发现不同元素,向前移至紧邻前者右侧
    _size = i + 1;                 //截除尾部多余元素
    shrink();
    return j - i; //j是原规模-1,i是现规模-1,返回删除元素个数
}

实例

image-20200812191102807

复杂度分析

通过循环,j1增加到_size - 1,复杂度仅为O(n)

2.6.4 查找

接口

有序向量查找的各种算法统一接口

image-20200813152835474

template <typename T>
Rank Vector<T>::search(T const &e, Rank lo, Rank hi) const
{
    return (rand() % 2) ? binSearch(_elem, e, lo, hi)  //二分查找
                        : fibSearch(_elem, e, lo, hi); //或Fibonacci查找
}

语义

约定:在有序向量区间V[lo, hi)中,确定不大于e的最后一个元素。

特殊地,若**-∞ < e < V[lo]**,则返回左侧哨兵lo - 1

V[hi - 1] < e < +∞,则返回末元素

下面的算法只有二分查找(版本C)满足这个语义。

2.6.5 二分查找(版本A)

原理:减而治之

image-20200813153833158

以任一元素S[mi] = x为界,都可将区间分为三部分,且根据此时的有序性必有:

S[lo, mi) <= S[mi] <= S(mi, hi)

将目标元素e与x做一比较,分三种情况进行处理:

  1. e < x,则目标元素如果存在,必属于左侧子区间S[lo, mi),故可深入其中继续查找;
  2. x < e,则目标元素如果存在,必属于右侧子区间S(mi, hi),故也可深入其中继续查找;
  3. e = x,则意味着已经在此处命中,故查找随即终止。

实现

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;
        else if (A[mi] < e) // 深入查找后半段
            lo = mi + 1;
        else // 已命中直接返回
            return mi;
    }
    return -1;
    //有多个命中元素时,不能保证返回秩最大者;查找失败时,简单地返回-1,而不能指示失败的位置
}

代码中建议统一采用小于号,因为在有序向量中,A<B即A在B的左侧,直观且便于理解。

复杂度分析

设定hi - lo = n,每个T(n)都可以分解为一次比较O(1)和新的查找区间T(n/2),n经过logn次分解到0,所以有

T(n) = O(1) + T(n/2) = O(log2(hi - lo)) = O(logn)

因为查找算法的类型较多,我们想采用比复杂度更为细微的方式来评定各个方法的效率,具体而言就是logn前面的常系数的大小。

查找长度

上述代码中涉及的计算有元素大小比较,秩的算术运算及其赋值。而通常情况下向量元素的类型更为复杂,因此,元素大小的比较在时间复杂度的常系数中的权重远高于其他运算,我们也更关注元素大小比较操作的次数(即if语句),称之为查找长度

对下图的理解:程序是先试图向左再试图向右。如果判断结果是e < A[mi],那么只需一次判断即可;反之A[mi] < e和A[mi] = e,都需要两次判断。体现在图中就是,向左+1,向右和直接命中都+2。

image-20200813171857318

成功时有七种情况,平均查找长度(ASL) = (4+3+5+2+5+4+6)/7 = 4+

失败时有八种情况,平均查找长度 = (3+4+4+5+4+5+5+6)/8 = 4.5 = 1.5log28(实际长度是7,这里是大致的表示一下)

经过推算,成功和失败时的平均查找长度均大致为O(1.5logn)

2.6.6 Fibonacci查找

构思

对于二分查找,在递归深度(代码是将递归改写成了迭代)相同的情况下,左(+1)右(+2)转向的不同导致对应的比较次数不同。由此,我们更希望多做一下成本低的转向(左),通过不均衡的递归深度,来补偿转向成本的不均衡,这样平均长度还有进一步缩短的可能。

在任何区间[0, n)内,总是选取[λ·n]作为轴点,0≤λ<1,比如binarySearch中我们就是令λ=0.5,下面计算λ为多大时复杂度达到最小。

image-20200813222859580

这类查找算法的渐进复杂度为α(λ)·log2n = O(logn),当复杂度最小时,即α(λ)取最小值

递推式:

α(λ)log2n=λ[1+α(λ)log2(λn)]+(1λ)[2+α(λ)log2((1λ)n)]\alpha(\lambda) \cdot \log _{2} n=\lambda \cdot\left[1+\alpha(\lambda) \cdot \log _{2}(\lambda n)\right]+(1-\lambda) \cdot\left[2+\alpha(\lambda) \cdot \log _{2}((1-\lambda) n)\right]

整理后:

ln2α(λ)=λlnλ+(1λ)ln(1λ)2λ\frac{-\ln 2}{\alpha(\lambda)}=\frac{\lambda \cdot \ln \lambda+(1-\lambda) \cdot \ln (1-\lambda)}{2-\lambda}

结果是

λ=ϕ=(51)/2α(λ)=1.440420\lambda=\phi=(\sqrt{5}-1) / 2 \\ \alpha(\lambda)=1.440420 \ldots

我们发现,λ等于黄金分割数时算法复杂度达到最低,而Fibonacci数列中相邻两项的比值随数字增大不断逼近黄金分割数。

实现

template <typename T>
static Rank fibSearch(T *A, T const &e, Rank lo, Rank hi)
{
    Fib fib(hi - lo); //获取不小于查找规模的最小项Fibonacci数
    while (lo < hi)
    {
        while (hi - lo < fib.get()) //同理,获取不小于当前查找规模的最小项Fibonacci数
            fib.prev();
        Rank mi = lo + fib.get() - 1; //确定形如Fib(k) - 1的轴点

        if (e < A[mi]) // 深入查找前半段
            hi = mi;
        else if (e > A[mi]) // 深入查找后半段
            lo = mi + 1;
        else // 已命中直接返回
            return mi;
    }
    return -1//查找失败
}

实例

image-20200813230257991

2.6.7 二分查找(版本B)

构思

Fibonacci查找方法是通过不均衡的递归深度来补偿不同的转向成本以期达到某种平衡,还有一种方法是,我们直接解决转向代价不平衡的问题。

每次迭代只进行一次比较,轴点mi同样选择中点:

当 e < A[mi] 时,x若存在则必属于左侧子区间A[lo, mi==)==,递归深入;

否则,e若存在则必属于右侧子区间A==[==mi, hi),递归深入

直到hi - lo = 1时,才判断是否命中

我们可以看出,这种方法在好的情况(在mi处直接命中)比版本A的计算成本高,在最坏情况下,比版本A的计算成本低,无论查找成功还是失败,版本B各分支的查找长度更接近,这是一种更加平衡的算法。

实现

template <typename T>
static Rank binSearch(T *A, T const &e, Rank lo, Rank hi)
{
    while (1 < hi - lo) //直至有效查找区间的宽度缩短至1时算法终止
    {
        Rank mi = (lo - hi) >> 1;      // 取中点为轴点
        e < A[mi] ? hi = mi : lo = mi; // 子区间为[lo, mi)或[mi, hi)
    }
    return e == A[lo] ? lo : -1;
    //缩短至一个元素时,如果相等,返回该元素Rank;不等,说明查找失败,返回-1
}

2.6.8 二分查找(版本C)

实现

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),注意和B版本的区别
    }                                      //出口处必有A[lo = hi] = M
    return --lo;                           //  A[lo - 1]
}

正确性

先观察一下版本B和版本C的区别

  • 算法结束时的区间的长度由1➡0

  • 转入右侧子区间时左边界的取值由mi➡mi+1

  • 版本C的返回值符合了语义的要求

从while循环中很容易发现单调性是可以保证的,我们着重观察不变性: ==A[0, lo)≤ e < A[hi, n)==。

  1. 初始时,lo = 0,hi = n,A[0, lo)= A[hi, n)= 空集,和空集比较均为true

  2. 在算法执行的任意时刻

  • A[lo - 1]总是(截至当前已确认的)不大于e的最大者M

  • A[hi]总是(截至当前已确认的)大于e的最小者m

    ![image-20200814163223159](D:\学习资料处\2020Summer\DS\DS02向量\第2章 向量B 摘要与心得.assets\image-20200814163223159.png)

  1. 算法结束时,lo = hi,A[lo - 1] = A[hi - 1]是全局不大于e的最大者

    ![image-20200814160702975](D:\学习资料处\2020Summer\DS\DS02向量\第2章 向量B 摘要与心得.assets\image-20200814160702975.png)

实例

binSearch(A, 9, 0, 12)

Rank0(lo)123456(mi)789101112(hi)
A5568899121414141520

第一次循环

mi = 6; (e < A[mi]) = False; lo = mi + 1 = 7

Rank7(lo)89(mi)101112(hi)
A121414141520

第二次循环

mi = 9; (e < A[mi]) = True; hi = mi = 9;

Rank7(lo)8(mi)9(hi)
A121414

第三次循环

mi = 8; (e < A[mi]) = True; hi = mi = 8;

Rank7(lo)8(hi)
A1214

第四次循环

mi = 7; (e < A[mi]) = True; hi = mi = 7;

Rank7(lo = hi)
A12

跳出循环

(lo < hi) = False; return --lo = 6;

2.7 排序与下界

2.8 排序器

通过以上分析,我们知道,对于常规向量,有序向量的查找会节省不少时间成本,下面,我们讨论如何使常规向量🔀有序向量:排序

统一入口

template <typename T>
void Vector<T>::sort(Rank lo, Rank hi)
{
    swith(rand() % 6)
    {
    case 1:
        bubbleSort(lo, hi); //冒泡排序
        break;
    case 2:
        selectionSort(lo, hi); // 选择排序
    case 3:
        mergeSort(lo, hi); //归并排序
    case 4:
        heapSort(lo, hi); //堆排序
    case 5:
        quickSort(lo, hi); //快速排序
    default:
        shellSort(lo, hi); //希尔排序
    }
}

各个排序算法可视化:visualgo.net/zh/sorting

冒泡排序(Bubble Sort)

原始版本

原始版本通过完整的扫描交换,每次确定一个最大值使扫描范围减1,各趟扫描成本呈算术级数,时间复杂度可比作下面三角形的面积

n+(n1)+(n2)+...+3+2+1=O(n2)n + (n - 1) + (n - 2) + ... + 3 + 2 + 1 = O(n^{2})

image-20200815175036015

改进

上图红色部分必然是有序的,但是绿色的部分并不一定是无序的,改进方法是,每趟扫描都记录一下是否存在逆序元素,如果没有逆序元素了,自然就可以直接结束扫描了。时间复杂度可比作图一中绿色梯形面积。

template <typename T>
void Vector<T>::bubbleSort(Rank lo, Rank hi)
{
    while (!bubble(lo, hi--))
        ;
}

template <typename T>
bool Vector<T>::bubble(Rank lo, Rank hi)
{
    bool sorted = true; //假设整体有序
    while (++lo < hi)
        if (_elem[lo - 1] > _elem[lo]) //若发现逆序
        {
            sorted = false;                 //  则整体未达到有序
            swap(_elem[lo - 1], _elem[lo]); // 交换逆序对使之有序
        }
    return sorted; //返回有序标志
}

image-20200815222508634

再改进

上一步改进中我们考虑了在一部分前缀如果已经有序的情况下降低时间复杂度;而实际情况可能更复杂,如果只有前面几个元素是乱序,上面的改进和原始算法比并没有什么优势。

我们可以把单纯返回bool表示这趟扫描有没有发现逆序,改为返回Rank表示这趟扫描最后一次发现逆序是在last位置(取这对逆序中右者的Rank),然后下一趟扫描只需扫描[lo, last)即可。这时的时间复杂度可以优化为图三所示的各个绿色部分面积之和。

template <typename T>
void Vector<T>::bubbleSort(Rank lo, Rank hi)
{
    while (lo < (hi = bubble2(lo, hi)))
        ;
}

template <typename T>
Rank Vector<T>::bubble2(Rank lo, Rank hi)
{
    Rank last = lo; //最右侧逆序对初始化为[lo - 1, lo]
    while (++lo < hi)
        if (_elem[lo - 1] > _elem[lo]) //若发现逆序
        {
            last = lo; //更新最右侧逆序位置记录
            swap(_elem[lo - 1], _elem[lo])
        }
    return last;
}

综合评价

稳定算法的特征是,重复元素之间的相对次序在排序前后保持一致,而Bubble Sort中某两个元素交换的条件是严格的后者大于前者,重复元素会不断靠拢但不会相互跨越,所以,Bubble Sort属于稳定算法

但是,在最坏情况下,各个版本的Bubble Sort的时间复杂度都将达到O(n2);最好情况下是O(n)


归并排序(Merge Sort)

构思

事实:CBA(Comparsion Based Algorithm基于比较的算法)求解排序问题都存在下界Ω(nlogn),由上面的内容我们知道Bubble Sort的上界是O(n2),归并排序就可以实现最坏情况下复杂度为O(nlogn)

  1. 序列一分为二 O(1)
  2. 子序列递归排序 2·T(n/2)
  3. 合并有序子序列 O(n)

image-20200819175315433

主算法实现

template <typename T>
void Vector<T>::mergeSort(Rank lo, Rank hi)
{
    if (hi - lo < 2) // 单元素区间自然有序
        return;
	//否则继续向下执行
    int mi = (lo + hi) >> 1; //以中点为界
    mergeSort(lo, mi);       //对前半段排序
    mergeSort(mi, hi);       //对后半段排序
    merge(lo, mi, hi);       //归并
}

二路归并-merge()函数的实现

将两个有序序列B,C合并成一个有序向量A

S[lo, hi) = S[lo, mi) + S[mi, hi)

image-20200819221407734

template <typename T>
void Vector<T>::merge(Rank lo, Rank mi, Rank 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++])
        ; //复制前子向量B
    int lc = hi - mi;
    T *C = _elem + mi; //后子向量C[0, lc) = _elem[mi, hi)

    //归并:反复从B和C首元素中取出更小者归入A
    for (Rank i = 0, j = 0, k = 0; (j < lb) || (k < lc);)	//j, k同时越界时算法结束
    {
        //每次接纳一个元素后,A通过自加指向下一个位置
        if ((j < lb) && (lc <= k || (B[j] <= C[k])))
            A[i++] = B[j++];
        if ((k < lc) && (lb <= j || (C[k] < B[j])))
            A[i++] = C[k++];
    }

    delete[] B; //释放临时空间B
}

函数首先分别定义了三个向量A,B,C

  • A定义在原输入向量存储空间上,从_elem + lo开始,即A指向原向量要排序的起点
  • B定义在一段新申请的空间上,宽度为mi - lo,并将A(等同于原输入的向量)的左半部分复制给B
  • C也定义在原输入向量存储空间上,但C从_elem + mi开始,即C指向原向量的中点

if中的逻辑

  • 逻辑与左侧:j < lbk < lc限定B[j]C[k]指向各自范围内实际存在的元素
  • 逻辑与右侧:
    • (lc <= k || (B[j] <= C[k])):两种情况使B[j]可以加入到A的末尾,一是C中的元素已经全部加入到A中了,这种情况直接把B中元素依次加入到A末尾即可。注意,这里利用了C++的短路求值特性,在判断lc <= k为真后,程序不会再对(B[j] <= C[k])进行判断(C[k]不存在,判断将引发错误);二是(B[j] <= C[k])为真,两者取其更小者加入A末尾。
    • (lb <= j || (C[k] < B[j]):同理。

在这门课中,一个常用技巧是,我们可以将越界看作是向量的尾后是正无穷,例如我们视C[hi]的值为**+∞**,那么B中的元素的值不可能有比正无穷还大的,自然将B中元素依次加入到A的末尾即可。

正确性分析

Case1:i∈[lo, mi)

B和C中元素都有可能加入到A中,但j <= i,这是不可能发生覆盖

Case2:i∈[mi, hi)

这是i已经越过了mi,但是不用担心A会覆盖C的存储内容,因为C中被A覆盖的那些元素必然已经被归入到A中

Case3:C的右侧残存个别元素即B提前耗尽(这是的B[j]等效无穷大)

在这种情况下,因为C残存的元素其实就在它需要归位的位置,所以它们没有发生赋值等,也就是说我们可以在B中元素提前耗尽的情况直接结束程序(因为这种情况下我们什么都不用做)

精简:(j < lb) || (k < lc)j < lb

(k < lc) && (lb <= j || (C[k] < B[j]))(k < lc) && C[k] < B[j]

(j < lb) && (lc <= k || (B[j] <= C[k]))(lc <= k) || (B[j] <= C[k])

image-20200819230925557

Case4:B的右侧残存个别元素即C提前耗尽(这是的C[k]等效无穷大)

而B中的值仍然需要赋值给A中元素

image-20200819231226084

复杂度分析

算法的运行时间主要消耗于for循环,共有两个控制条件

    //归并:反复从B和C首元素中取出更小者归入A
    for (Rank i = 0, j = 0, k = 0; (j < lb) || (k < lc);)	//j, k同时越界时算法结束
    {
        //每次接纳一个元素后,A通过自加指向下一个位置
        if ((j < lb) && (lc <= k || (B[j] <= C[k])))
            A[i++] = B[j++];
        if ((k < lc) && (lb <= j || (C[k] < B[j])))
            A[i++] = C[k++];
    }

初始:j = 0, k = 0

最终:j = lb, k = lc

即:j + k = lb +lc = hi - lo = n

观察:经过每次迭代,j和k至少有一个加1,即j + k至少加1,那么总体迭代次数就不会大于O(n)次

merge()的时间复杂度为O(n),但是前提是两个子向量均有序

若按上面的分析实现:T(n) = 2·T(n/2) + O(n) = T(1) + log2n·O(n) = O(nlogn)