一、抽象数据类型与数据结构的比较
- 抽象数据类型 = 数据模型 + 定义在该模型上的一组操作
- 是抽象定义,关注外部的逻辑特性,定义操作&语义
- 是一种定义,不考虑时间复杂度不涉及数据的存储方式
- 数据结构 = 基于某种特定语言,实现ADT的一整套算法
- 是具体实现,涉及内部的表示与实现,是完整的算法
- 可以有多种实现,与复杂度密切相关,要考虑数据的具体存储机制
- ADT类似于一种接口规范,数据接口是具体的接口,二者连接起用户和功能实现者。
二、从数组到向量
- 数组
- c/c++语言中,数组A[ ]中的元素与[0,n)内的编号一一对应
- 反之,每个元素均由(非负)编号唯一指代,并可直接访问
- A[i]的物理地址 = A + i * s, s为单个单元占用的空间量,故称为线性数组( linear array)
- 向量
- 向量是数组元素的抽象与泛化,由一组元素按线性次序封装而成
- 循秩访问(call-by-rank):各元素的秩(rank)一一对应
- 元素的类型不限于基本类型
- 操作、管理维护更加简化、统一与安全
- 可更为便捷地参与复杂数据结构的定制与实现
- 向量ADT接口
- 向量操作实例
三、可扩充向量
- 静态空间管理
- 开辟内部数组_elem[ ]并使用一段连续的物理空间
- _capacity:总容量
- _size:当前的实际规模n
- 若采用静态空间管理策略,容量_capacity固定,则有明显不足
- 上溢(overflow):_elem[ ]不足以存放所以元素,尽管此时系统仍有足够的空间
- 下溢(underflow):_elem[ ]中的元素寥寥无几,装填因子(load factor) = _size / _capacity << 50%
- 一般的应用环境中难以准确预测空间的需求量->动态空间管理
- 动态空间管理
- 法一:在即将发生上溢时,通过开辟一个新的数组将原数组内容复制的形式,适当扩大内部数组的容量
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(int i = 0; i < _size; i++)
_elem[i] = oldElem[i];
delete [] oldElem;
}
- 容量递增策略
- 最坏情况:在初始容量0的空间中,连续插入n = m*I个元素
- 于是,在第1、I+1、2I+1、…次插入时,都需扩容
- 即便不计申请空间操作,各次扩容过程中复制原向量的时间成本依次为0, I, 2I, …, (m-1)I
- 总体耗时 = I * (m-1)*m/2 = O(n^2), 每次扩容的分摊成本为O(n)
T* oldElem = _elem; _elem = new T[_capacity += INCREMENT];
- 容量加倍策略
- 最坏情况:在初始容量1的满向量中,连续插入n = 2^m个元素
- 于是,在第1、2、4、8、…次插入时都需要扩容
- 各次扩容过程中复制原向量的时间成本依次为1,2,4,8,…,2^m = n
- 总耗时 = O(n),每次扩容的分摊成本为O(1)
- 平均分析与分摊分析
- 平均复杂度或期望复杂度(average/expected complexity)
- 根据数据结构各种操作出现的概率分布,将对应的成本加权平均。各种可能的操作,作为独立事件分别考察。割裂了操作之间的相关性与连贯性。往往不能准确地评判数据结构和算法的真实性能。
- 分摊复杂度(amortized complexity)
- 对数据结构连续地实施足够多次操作,所需总体成本分摊至单次操作。从实际可行的角度,对一系列操作做整体的考量。更加忠实地刻画了可能出现的操作序列。可以更为精准地评判数据结构和算法的真实性能。
四、无序向量
- 元素的访问
- 通过V.get( r )和V.put(r, e)接口,已然可以读、写向量元素,但就便捷性而言,远不如数组元素通过下标访问的访问方式。
- 为使向量通过下标访问,需重载下标运算符" [ ] "
- 此后,对外的V[r]即对应于内部的V._elem[ r ]
trmplate <typename T>
T & Vector<T>::operator[](Rank r) const { return _elem[r];}
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;
}
template <typename T>
int Vector<T>::remove(Rank lo, Rank hi){
if(hi == lo) return 0;
while(hi < _size) _elem[lo++] = _elem[hi++];
_size = lo; shrink();
return hi - lo;
}
- 单元素删除
- 可以视作区间删除的特例:[r] = [r, r+1)
- 为什么不是反过来,基于remove( r )接口,通过反复调用,实现remove(lo, hi)?
- 每次循环耗时正比于删除区间的后缀长度 = n - hi = O(n)
- 而循环次数等于区间宽度 = hi - lo = O(n)
- 如此,将导致总体O(n^2)的复杂度
trmplate <typename T>
T Vector<T>::remove(Rank r){
T e = _elem[r];
remove( r, r+1);
return e;
}
- 查找元素
- 无序向量:T为可判等的基本类型,或已重载操作符==或!=
- 有序向量:T为可比较的基本类型,或已重载操作符<或>
- 输入敏感( input - sensitive):最好O(1),最差O(n)
template <typename T>
Rank Vector<T>::find(T const & e, Rank lo, Rank hi)const
{
while((lo < hi--)&&(e!=_elem[hi]));
return hi;
}
- 唯一化:算法
- 应用实例:网络搜索的局部结果经过去重操作,汇总为最终报告
- 不变性:在当前元素V[i]的前缀V[0, i)中,各元素彼此互异(采用数学归纳法证明)
- 初始i = 1时自然成立。在以后的操作中,新进入前缀的元素如果是被查找的元素,会被删除,如果不是则进入前缀。
- 单调性:随着反复的while迭代
- 当前元素前缀的长度单调非降,且迟早增至_size
- 当前元素后缀的长度单调下降,且迟早减至0
- 故算法必然终止,且至多迭代O(n)轮
- 复杂度
- 每轮迭代中find()和remove()累计消耗线性时间,故总体为O(n^2)。find针对向量中前缀部分而言,remove针对向量中的其它部分,二者在一次while循环中的时间为n,故时间复杂度为O(n ^2)
*可进一步优化,比如…
- 仿照uniquify()高效版的思路,元素移动的次数可降至O(n),但比较次数依然是O(n^2);而且,稳定性也会被破坏。
- 先对需删除的重复元素做标记,然后再统一删除。稳定性保持,但因查找长度更长,从而导致更多的对比操作
- V.sort().uniquify():简明实现最优的O(nlogn)
template <typename T>
int Vector<T>::deduplicate(){
int oldSize = _size;
Rank i = 1;
while( i<_size )
(find(_elem[i], 0, i) < 0) ?
i++
: remove(i);
return oldSize - _size;
}
- 遍历
- 遍历向量,统一对各元素分别实施visit操作。如何指定visit操作,如何将其传递到向量内部
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]);}
template <typename T>
struct Increase{
virtual void operator()(T &e){ e++; }
};
template <typename T> void Increment(Vector<T> & V){
V.traverse(Increase<T>());
}