向量vector背景下的二分查找、冒泡排序和归并排序

211 阅读14分钟

零、vector库中的常用函数

函数名函数作用
empty()判断容器是否为空
capacity()容器的容量
size()返回容器中元素的个数
resize(int num)重新指定容器的长度为num。 若容器变长,则以默认值填充新位置;如果容器变短,则末尾超出容器长度的元素被删除
resize(int num , elem)重新指定容器的长度为num。 若容器变长,则以elem值填充新位置;如果容器变短,则末尾超出容器长度的元素被删除
push_back(elem)在尾部插入元素elem
pop_back()删除最后一个元素
insert(const_iterator pos , elem)迭代器指向位置pos插入元素elem
insert(const_iterator pos , int num , elem)迭代器指向位置pos插入count个元素elem
erase(const_iterator pos)删除迭代器指向的元素
erase(const_iterator start , const_iterator end)删除迭代器从start到end之间的元素
clear()删除容器中所有的元素
at(int idx)返回索引idx所指的数据
front()返回容器中第一个数据元素
back()返回容器中最后一个数据元素

一、元素访问

1.插入

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

注意在插入操作中采用了从后向前的策略,这是为了避免数据被覆盖。

2.区间删除

template <typename T> int Vector<T>::remove( Rank lo, Rank hi ) 
{ //0<=lo<=hi<=n
    if ( lo == hi ) 
        return 0; //出于效率考虑,单独处理退化情况
    while ( hi < _size ) 
        _elem[ lo ++ ] = _elem[ hi ++ ]; //O(n-hi):[hi,n)顺次前移
    _size=lo;
    shrink();//更新规模,有必要则缩容
    return hi - lo; //返回被删除元素的数目
}

区间删除.png

注意在区间删除中采用了从前向后的策略,这是为了避免当n-hi-1>hi-lo-1时,数据被覆盖。

3.单元素删除

template <typename T> T Vector<T>::remove( Rank r ) 
{
    T e = _elem[r]; //
    remove( r, r+1 ); //“区间”删除
    return e; //返回被删除元素
} //O(n-r), 0 <= r < size

其实也就是将单元素删除视作区间删除的特例。

二、向量唯一化

1.无序向量唯一化

template <typename T> Rank Vector<T>::deduplicate() 
{
    Rank oldSize = _size;
    for ( Rank i = 1; i < _size; ) //在进行了下面判断之后再增加
    {   
        if ( find( _elem[i], 0, i ) < 0 )//查找函数,试图在索引范围 [0, i) 内查找元素 _elem[i]
            i++;
        else
            remove(i);
    }
    return oldSize - _size;
}

find函数的一个参考:

    int array[n]; // 定义一个大小为 n 的数组
    for (int i = 0; i < n; i++) // 循环输入数组的元素
    {
        cin >> array[i]; // 输入数组的每个元素
    }
​
    // 双重循环用于去除数组中的重复元素
    for (int i = 0; i < n - 1; i++) // 外层循环,从第一个元素开始
    {
        for (int j = 1 + i; j < n; j++) // 内层循环,比较第 i 个元素和第 j 个元素
        {
            if (array[j] == array[i]) // 如果发现重复元素
            {
                // 将 j 之后的元素左移一位
                for (int k = j + 1; k < n; k++) 
                {
                    array[k - 1] = array[k]; // 把后面的元素覆盖当前元素
                }
​
                n--; // 数组大小减少 1,因为去掉了一个重复元素
                j--; // 重新检查当前索引 j,避免漏检
            }
​
            else
                continue; // 如果没有重复,继续检查下一个元素
        }
    }

2.有序向量唯一化(低效)

原理:

有序向量低效算法.png

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;
}//注意:其中_size的减小,由remove()隐式地完成

运行时间主要取决于while循环,次数共计:_size - 1 = n - 1

最坏情况下每次都要调用remove(),耗时O(n-1)~O(1)。

累计共O(n^2)。

——发现尽管省去find(),总体耗时与无序向量的deduplicate()相同。

3.有序向量唯一化(高效)

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;
    shrink(); //直接截取尾部多余元素
    return j-i; //向量规模变化量
}//注意:通过remove(lo,hi)批量删除,仍然不能达到高效率

三、二分查找(有序向量)

1.版本A

template <typename T> static Rank binSearch( T * S, T const & e, Rank lo, Rank hi ) //在有序向量区间[lo,hi)内查找元素e
{
    while ( lo < hi ) 
    { //每部迭代可能要做两次比较判断,有三个分支
        Rank mi = ( lo + hi ) >> 1; //轴点居中(区间宽度折半,等效于其数值右移一位)
        if ( e < S[mi] ) 
            hi = mi; //深入前半段[lo, mi)
        else if ( S[mi] < e ) 
            lo = mi + 1; //深入后半段(mi, hi)
        else return mi; //命中
    }
    return -1; //查找失败
}

2.版本B

与版本A相比的改进之处:

二分查找版本B.png

template <typename T> static Rank binSearch( T * S, T const & e, Rank lo, Rank hi ) 
{
    while ( 1 < hi - lo ) 
    { //有效查找区间的宽度缩短至1时,算法才终止
        Rank mi = (lo + hi) >> 1; //以中点为轴点,经比较后确定深入[lo,mi)或[mi,hi)
        e < S[mi] ? hi = mi : lo = mi;
    } 
    // 退出循环时,lo 和 hi 相邻,满足 hi = lo + 1
    return e == S[lo] ? lo : -1 ;
}

3.版本C(对返回值语义的扩充)

template <typename T> static Rank binSearch( T * S, T const & e, Rank lo, Rank hi ) 
{
    while ( lo < hi ) 
    { 
        Rank mi = (lo + hi) >> 1;
        e < S[mi] ? hi = mi : lo = mi + 1; //[lo, mi)或(mi, hi)
    } //出口时,必有S[lo = hi] = M
    return lo - 1; //故,S[lo-1] = m
}
特点版本B版本C
终止条件当区间宽度为 1 时终止lo == hi 时终止
调整方式直接将 lohi 设置为中点 mi使用 lo = mi + 1 来缩小右区间范围
返回值意义返回目标值的索引或 -1返回小于或等于目标值的最大索引
灵活性适用于简单查找更灵活,可扩展为插入位置查找等场景
额外判断需要在循环结束后额外判断无需额外判断

这段代码之所以被称为对 二分查找函数返回值语义的扩充,主要是因为它不仅仅满足传统二分查找的功能(找到目标值的索引或者返回失败标志),还扩展了返回值的意义,使其能够应用于更多场景,例如 查找插入位置范围界定

版本A,B的返回值语义

传统二分查找的返回值有以下特点:

  1. 功能目标:

    • 找到目标值在数组中的位置。
    • 如果目标值不存在,返回一个失败的标志(通常是 -1 或类似值)。
  2. 适用场景:

    • 仅限于查找目标值是否存在以及其索引位置。
    • 不适合处理其他需求,例如插入位置查找或范围问题。

版本C的扩展语义

扩展点 1:插入位置查找
  • 代码在循环退出时满足以下性质:

    • lo == hi,即搜索区间为空。
    • lo 指向第一个 大于目标值 e 的位置
    • lo - 1 指向数组中最后一个 小于或等于目标值 e 的位置
  • 意义:

    • 即使目标值 e 不存在,返回的 lo - 1 仍然有实际意义,表示目标值可以插入的位置。
    • 例如,插入位置是 lo,保证数组在插入后仍然有序。
扩展点 2:支持范围查找
  • 通过返回的 lo - 1,可以轻松找到:

    • 最后一个小于或等于目标值的位置。
    • 第一个大于目标值的位置(即 lo 本身)。
  • 意义

    • 可以在有序数组中进行区间操作(如寻找小于某值的元素范围)。

适用场景

相比传统二分查找,版本C代码适合更多应用场景:

  1. 单点查找:查找目标值的索引,若不存在,返回接近位置的索引。

  2. 插入位置查找:确定目标值应插入的位置,保持数组有序。

  3. 范围查找

    • 找到小于或等于目标值的最大索引。
    • 找到大于目标值的最小索引。

示例分析

假设数组 S = {1, 3, 5, 7, 9},目标值为 4,代码执行流程如下:

  1. 初始:lo = 0, hi = 5
  2. 第一次循环:mi = 2, S[mi] = 5, e < S[mi]hi = 2
  3. 第二次循环:mi = 1, S[mi] = 3, e > S[mi]lo = 2
  4. 循环结束:lo = hi = 2
  5. 返回值:lo - 1 = 1,表示 4 应插入到索引 2 位置,索引 1 是最后一个小于或等于 4 的值。

返回值语义:

  • 小于或等于 4 的最大索引:1
  • 插入位置:2

四、动态扩容的两种策略

1.容量加倍策略

(1)模板代码:

template <typename T> void Vector<T>::expand() 
{//向量空间不足时扩容
    if ( _size < _capacity ) 
        return; //尚未满员时,不用扩容
    _capacity = max( _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; //释放原空间
}// 得益于向量的封装,尽管扩容之后数据区的物理地址有所改变,却不致出现野指针

(2)分摊复杂度分析:

1. 动态扩容策略
  • 每次扩容时,容量 _capacity 加倍,即新的容量是原来的 2 倍。
  • 假设最初容量为 1(或某个常数值),并有 n 次插入操作。

扩容的次数是以对数增长的,例如:

  • 容量变化:1 -> 2 -> 4 -> 8 -> ... -> n
  • 总共扩容的次数为 \log_2(n)。

2. 分摊复杂度计算

普通插入操作

  • 如果当前容量未满(_size < _capacity),插入元素仅需要 O(1) 时间,因为只需将元素写入下一个空位置。

扩容操作

  • 当容量满时,需要扩容:

    1. 分配新内存(时间复杂度与新容量大小成比例,记为 O(k),其中 k 是扩容后的容量)。
    2. 将旧数据复制到新内存(时间复杂度为 O(k/2),即上一次的容量大小)。
    3. 释放旧内存(操作时间为 O(1),可以忽略不计)。

因此,扩容的时间复杂度为 O(k)。


总体分摊分析

假设最终插入了 n 个元素:

  1. 每次扩容涉及的数据复制量为:

    • 第 1 次扩容:复制 1 个元素。
    • 第 2 次扩容:复制 2 个元素。
    • 第 3 次扩容:复制 4 个元素。
    • ...
    • 第 i 次扩容:复制 2^{i-1} 个元素。
  2. 总的复制次数是一个等比数列的和:

    S=1+2+4+8++n/2S = 1 + 2 + 4 + 8 + \dots + n/2
    • 这是一个等比数列,其和为: S = 2^0 + 2^1 + 2^2 + \dots + 2^{\log_2(n)-1} = n - 1
  3. 分摊到每次插入的开销:

    • 总插入次数为 n。

    • 平均每次插入需要的时间为:

      Sn=n1nO(1)\frac{S}{n} = \frac{n - 1}{n} \approx O(1)

2.容量递增策略

(1)模板代码:

template <typename T> void Vector<T>::expand() 
{//向量空间不足时扩容
    if ( _size < _capacity ) 
        return; //尚未满员时,不用扩容
    _capacity = max( _capacity, DEFAULT_CAPACITY ); //不低于最小容量
    T* oldElem = _elem; 
    _elem = new T[ _capacity += INCRMENT0 ];//每次增加固定容量INCREMENT
    for ( Rank i = 0; i < _size; i++ ) //复制原向量内容
        _elem[i] = oldElem[i]; //T为基本类型,或已重载赋值运算符'='
    delete [] oldElem; //释放原空间
}// 得益于向量的封装,尽管扩容之后数据区的物理地址有所改变,却不致出现野指针

(2)分摊复杂度分析:

假设需要插入 N 个元素,初始容量为 C_0,每次扩容增加容量为 INCRMENT0

扩容次数
  • 每次扩容增加的容量是固定的,因此扩容的次数为: k = \lceil \frac{N - C_0}{\text{INCRMENT0}} \rceil ,即扩容次数随 N 增长呈线性比例。
每次扩容的时间开销
  • 扩容时,需要将当前数组的所有元素复制到新空间中。
  • 第 i 次扩容涉及的元素复制数为size = C_0 + i \times \text{INCRMENT0}。
总复制次数
  • 总的元素复制次数为:

    S=C0+(C0+INCRMENT0)+(C0+2×INCRMENT0)++(C0+(k1)×INCRMENT0)S = C_0 + (C_0 + \text{INCRMENT0}) + (C_0 + 2 \times \text{INCRMENT0}) + \dots + (C_0 + (k-1) \times \text{INCRMENT0})

    这是一个等差数列,其和为:

    S=kC0+INCRMENT0k(k1)2S = k \cdot C_0 + \text{INCRMENT0} \cdot \frac{k \cdot (k-1)}{2}
分摊复杂度
  • 平均每次插入的开销为: \text{Amortized Cost} = \frac{S}{N}
  • 代入 k \approx \frac{N}{\text{INCRMENT0}} 和 C_0 \ll N 的近似值: S \approx \frac{N^2}{2 \cdot \text{INCRMENT0}}
  • 则分摊开销为:\text{Amortized Cost} \approx O(N)

3.两种策略的装填因子(空间利用率)

特性第一段代码(加倍扩容)第二段代码(固定增量扩容)
装填因子范围[0.5,1.0][0.5,1.0]
扩容后装填因子0.5接近 1.0
扩容频率较低(对数级扩容次数)较高(线性扩容次数)
内存效率较低(可能过度分配)较高(更紧凑的使用)
适用场景适合大规模数据适合小规模数据

五、冒泡排序

冒泡排序是一种简单的交换排序算法,通过反复比较相邻的元素并交换它们的位置,使较大的元素逐渐“冒泡”到序列的末尾。

1.基础版

template <typename T> Vector<T>::bubbleSort( Rank lo, Rank hi ) 
{
	while( lo < --hi ) //逐趟冒泡排序(输入保证0 <= lo < hi <= size)
		for( Rank i = lo; i < hi; i++ ) //若相邻元素
			if( _elem[i] > _elem[i + 1] ) //逆序
				swap( _elem[i], _elem[i + 1] ); //则交换
}

经过k趟扫描完成后,最大的k个元素必然就位;经k趟扫描交换后,问题规模必然缩减至n-k;经至多n趟扫描后,算法必然终止,且能够给出正确答案。

2.提前终止版

template <typename T> void Vector<T>::bubbleSort( Rank lo, Rank hi ) 
{
    // 外层循环控制每一轮的排序范围 [lo, hi)
    // 初始假设序列已排序,设置 sorted 为 false
    for ( bool sorted = false; sorted = !sorted; hi-- ) 
        // 内层循环逐步“冒泡”,比较相邻元素,范围为 [lo + 1, hi)
        sorted=ture;//每次内层循环开始时,将 sorted 设为 true。
        for ( Rank i = lo + 1; i < hi; i++ )
            // 如果当前元素比相邻的后一个元素大,则交换它们的位置
            if ( _elem[i-1] > _elem[i] ) 
                swap( _elem[i-1], _elem[i] ), sorted = false; 
                // 标记 sorted 为 false,表示本轮排序发生了交换,序列尚未完全有序
}

3.跳跃版

template <typename T>
void Vector<T>::bubbleSort( Rank lo, Rank hi ) 
{
    // 外层循环控制排序范围 [lo, hi),每一轮将范围缩小到最后发生交换的位置
    for (Rank last; lo < hi; hi = last) {
        // 初始化 last 为 lo,表示当前尚未发生任何交换
        for (Rank i = (last = lo) + 1; i < hi; i++) { 
            // 比较相邻两个元素,如果顺序不正确,交换它们的位置
            if (_elem[i-1] > _elem[i]) { 
                swap(_elem[i-1], _elem[i]); 
                // 更新 last 为当前元素位置 i,表示最后发生交换的位置
                last = i; 
            }
        }
    }
}

//A[lo, last) <= A[last, hi)
//A[last - 1] < A[last, hi)

关键优化点

  • 动态调整排序范围 每轮内层循环会记录最后一次发生交换的位置 (last)。外层循环通过 hi = last 将下一轮的排序范围限制为 [lo, last),无需继续比较 [last, hi) 部分,因为它已经是有序的。
  • 跳过无意义的比较 如果在某一轮中 last 的值没有被更新(即没有任何元素交换),那么下一轮的 hi 会直接等于 lo,从而退出外层循环。

4.综合评价

  • 时间效率:

    • 最好:O(n)
    • 最坏:O(n^2)
  • 稳定性:

    冒泡排序算法是稳定的。

六、归并排序

1.分而治之策略

template <typename T>
void Vector<T>::mergeSort( Rank lo, Rank hi ) 
{
    // 如果子序列的长度小于 2,则直接返回,无需排序
    if ( hi - lo < 2 ) return; 

    // 计算子序列的中点
    int mi = (lo + hi) >> 1; 
    // 递归地对左半部分排序
    mergeSort( lo, mi ); 
    // 递归地对右半部分排序
    mergeSort( mi, hi ); 
    // 合并左右两部分
    merge( lo, mi, hi ); 
}
template <typename T> 
void Vector<T>::merge( Rank lo, Rank mi, Rank hi ) { 
    // 合并两个有序区间 [lo, mi) 和 [mi, hi),其中 lo < mi < hi

    Rank i = 0;                 // 指针 i 用于结果数组 A 的索引
    T* A = _elem + lo;          // A 指向待合并的区间 [_elem[lo], _elem[hi))
    
    // 左半部分 [lo, mi)
    Rank j = 0, lb = mi - lo;   // j 是左区间的索引,lb 是左区间的长度
    T* B = new T[lb];           // 创建临时数组 B,用于保存左区间的数据
    for ( Rank i = 0; i < lb; i++ ) 
        B[i] = A[i];            // 将左区间的内容复制到临时数组 B 中

    // 右半部分 [mi, hi)
    Rank k = 0, lc = hi - mi;   // k 是右区间的索引,lc 是右区间的长度
    T* C = _elem + mi;          // C 指向右区间的起始位置 [_elem[mi], _elem[hi))

    // 比较 B 和 C 的当前元素,按顺序插入到 A 中
    while ( ( j < lb ) && ( k < lc ) ) 
        A[i++] = ( B[j] <= C[k] ) ? B[j++] : C[k++]; 

    // 如果右区间 C 的元素全部合并完成,而左区间 B 还有剩余
    while ( j < lb ) 
        A[i++] = B[j++];        // 将左区间 B 的剩余元素复制到 A 中

    delete [] B;                // 删除临时数组 B,释放内存
}

2.复杂度

归并排序的时间复杂度为O(n logn)。