零、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; //返回被删除元素的数目
}
注意在区间删除中采用了从前向后的策略,这是为了避免当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.有序向量唯一化(低效)
原理:
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相比的改进之处:
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 时终止 |
| 调整方式 | 直接将 lo 或 hi 设置为中点 mi | 使用 lo = mi + 1 来缩小右区间范围 |
| 返回值意义 | 返回目标值的索引或 -1 | 返回小于或等于目标值的最大索引 |
| 灵活性 | 适用于简单查找 | 更灵活,可扩展为插入位置查找等场景 |
| 额外判断 | 需要在循环结束后额外判断 | 无需额外判断 |
这段代码之所以被称为对 二分查找函数返回值语义的扩充,主要是因为它不仅仅满足传统二分查找的功能(找到目标值的索引或者返回失败标志),还扩展了返回值的意义,使其能够应用于更多场景,例如 查找插入位置 或 范围界定。
版本A,B的返回值语义
传统二分查找的返回值有以下特点:
-
功能目标:
- 找到目标值在数组中的位置。
- 如果目标值不存在,返回一个失败的标志(通常是
-1或类似值)。
-
适用场景:
- 仅限于查找目标值是否存在以及其索引位置。
- 不适合处理其他需求,例如插入位置查找或范围问题。
版本C的扩展语义
扩展点 1:插入位置查找
-
代码在循环退出时满足以下性质:
lo == hi,即搜索区间为空。lo指向第一个 大于目标值e的位置。lo - 1指向数组中最后一个 小于或等于目标值e的位置。
-
意义:
- 即使目标值
e不存在,返回的lo - 1仍然有实际意义,表示目标值可以插入的位置。 - 例如,插入位置是
lo,保证数组在插入后仍然有序。
- 即使目标值
扩展点 2:支持范围查找
-
通过返回的
lo - 1,可以轻松找到:- 最后一个小于或等于目标值的位置。
- 第一个大于目标值的位置(即
lo本身)。
-
意义:
- 可以在有序数组中进行区间操作(如寻找小于某值的元素范围)。
适用场景
相比传统二分查找,版本C代码适合更多应用场景:
-
单点查找:查找目标值的索引,若不存在,返回接近位置的索引。
-
插入位置查找:确定目标值应插入的位置,保持数组有序。
-
范围查找:
- 找到小于或等于目标值的最大索引。
- 找到大于目标值的最小索引。
示例分析
假设数组 S = {1, 3, 5, 7, 9},目标值为 4,代码执行流程如下:
- 初始:
lo = 0, hi = 5。 - 第一次循环:
mi = 2, S[mi] = 5, e < S[mi]→hi = 2。 - 第二次循环:
mi = 1, S[mi] = 3, e > S[mi]→lo = 2。 - 循环结束:
lo = hi = 2。 - 返回值:
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) 时间,因为只需将元素写入下一个空位置。
扩容操作
-
当容量满时,需要扩容:
- 分配新内存(时间复杂度与新容量大小成比例,记为 O(k),其中 k 是扩容后的容量)。
- 将旧数据复制到新内存(时间复杂度为 O(k/2),即上一次的容量大小)。
- 释放旧内存(操作时间为 O(1),可以忽略不计)。
因此,扩容的时间复杂度为 O(k)。
总体分摊分析
假设最终插入了 n 个元素:
-
每次扩容涉及的数据复制量为:
- 第 1 次扩容:复制 1 个元素。
- 第 2 次扩容:复制 2 个元素。
- 第 3 次扩容:复制 4 个元素。
- ...
- 第 i 次扩容:复制 2^{i-1} 个元素。
-
总的复制次数是一个等比数列的和:
- 这是一个等比数列,其和为: S = 2^0 + 2^1 + 2^2 + \dots + 2^{\log_2(n)-1} = n - 1
-
分摊到每次插入的开销:
-
总插入次数为 n。
-
平均每次插入需要的时间为:
-
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}。
总复制次数
-
总的元素复制次数为:
这是一个等差数列,其和为:
分摊复杂度
- 平均每次插入的开销为: \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)。