文章目录
向量的ADT及其模板
size(); // 向量规模
get(r); //获取秩为r的向量 (秩是指该元素前面含有r个元素)
put(r,e); //替换秩为r的元素
insert(r,e); //将e作为秩为r的元素插入
remove(r); //删除秩为r的元素
disordered(); //判断元素是否有序(非降序)
sort(); //将向量排序
find(e); //查找等于e且秩最大者,用于无序向量
search(e); //同上,但用于有序向量
deduplicate(); //剔除重复元素,用于无序向量
uniquify(); //提出重复元素,用于有序向量
tranverse(); //遍历并处理所有元素
一个基本的向量必须能够实现上述功能,我们在拓展的基础上,建立了向量的模板类
typedef int rank; //秩
#define DEFAULT_CAPACITY 3 // 默认容量
template <typename T> class vector {
protected:
rank _size; int _capacity; T* elem;
void copyForm(T const*, rank lo, rank hi);
void expand();
void shrik();
bool bubble(rank lo, rank hi);
void bubbleSort(rank lo, rank hi);
rank max(rank lo, rank hi);
void selectionSort(rank lo, rank hi);
void merge(rank lo, rank mi, rank hi);
void mergeSort(rank lo, rank hi);
rank partition(rank lo, rank hi);
void quickSort(rank lo, rank hi);
public:
//构造函数
vector(int c = DEFAULT_CAPACITY, int s = 0, T v = 0)
{_elem = new T[capacity = c]; for (_size =0; -size < s; _elem[_size] = v)}
vector(T const* A, rank n) {copyForm(A, 0, n);}
vector(T const* A, rank lo, rank hi) {copyForm(A, lo, hi);}
vector(vector<T>& V) {copyForm(V._elem, 0, V._size);}
vector(vector<T>& V, rank lo, rank hi) {copyForm(V._elem, lo, hi);}
//析构函数
~vector() {delete[] _elem;}
//只读访问接口
Rank size() const {return _size;}
bool empty() const {return !_size;}
int disordered() const;
rank find(T const& e) const{return find(e, 0, _size);}
rank find(T const& e, rank lo, rank hi) const;
rank search(T const& e) const;
{return 0 >= _size? -1: search(e, 0, _size);};
rank search(T const& e) const {return s;};
rank search(T const& e, rank lo, rank hi) const;
//可写访问接口
T& operator[](rank r) {return elem[r];};
vector<T> & operator= (vector<T> & V);
T remove(rank r);
int remove(rank lo, rank hi);
rank insert(rank r, T const& e);
rank insert(T const& e) {insert(_size, e);};
void sort(rank lo, rank hi);
void sort() {sort(0, _size);};
void unsort(rank lo, rank hi);
void unsort() {unsort(0, _size);};
void deduplicate();
void uniquify();
//遍历
void traverse (void(*)(T&));
template <typename VST> void tranverse(VST&);
}
思考一:构造函数声明的位置
前面的代码告诉我我们,构造函数声明在public部分,那么这是为什么?这里我们要说明的一个前提时:当我们创建一个实例对象时,实际上是在调用类的构造函数。
那么通过这个前提我们便可以得出以下结论:
- 如果将类的构造函数声明为private或者protected,我们是无法创建该类实例的。但如果我们在类的成员函数中创建类呢,那么这是确实可以进行的,但没有实例是无法调用成员函数的
我们还可以思考这样一个问题是否可以通过类方法来进行创建类对象的呢?这个问题留待检查 - 如果我们不想让用户创建实例,同时我们希望它的子类可以对其进行调用,那么只需要把构造函数声明在protected中就可。
思考二:关于类模板编写的收获
- 首先我们需要将所有方法按照内部方法和外部方法进行分类,内部方法只是用来方便外部函数或者一些辅助功能。不需要用户进行调用和了解。
- 此外我们可以将方法按照构造,析构,只读,可写进行分类,这样使得模板的可读性更强。
- 最后我们可以对方法进行一般化拓展,比如,向量的指定位置查找,与全部查找,完全可用两个接口一段代码进行实现 ,这样的拓展将能够使得类的设计对使用者更加便利。
思考三:函数指针与函数对象
这是一个很深入的问题,这部分会在单独部分进行展示
各成员方法的实现与思考
1. 构造与析构
这里需要强调的是,前面模板类中声明的其中四种构造函数,都调用了copyForm()成员方法,这充分体现了代码重用的思想,与此同时,重载=运算符也可以使用这个方法。因此copyForm()一组代码基本将构造函数所需要的辅助功能全部实现,故这里不讨论如何实现构造函数,只展示copyForm()的代码:
template <typename T>
void copyForm(T const* A, rank lo, rank hi) {
_elme = new T[(hi - lo) * 2]; _size = 0;
while (lo < hi)
_elem[_size++] = A[lo++];
}
对于析构函数,只需要删除动态创建的数组即可,故这里不予讨论
2.动态空间管理
如果只是使用静态空间的话,那么如果vector不断插入新的元素,最终数组将会溢出,这里引入装填因子的概念,即_size/_capacity,我们希望能够使装填因子足够接近并不至于超过1
向量扩容
这里我们希望建立一个方法,每当数组填满的时候便将数组扩容,即动态创建一个新的数组并赋值。
根据这个思路我们建立的expand()函数来对‘满’向量进行扩充
template <typename T> void expand() {
if (_size < _capacity) return;
if (_capacity < DEFAULT_CAPACITY) _capacity = DEFALUT_CAPACITY;
T* old_elem = elem; _elme = new T[_capacity<<=1];
for (int i = 0; i < _size; i++)
elem[i] = old_elem[i];
delete old_elem;
}
通过这种手段我们成功的将向量的装填因子保持在0.5~1之间,但值得困惑的是:为何将向量容量进行翻倍,如果是加上一个定值或者变为原来的几倍难道不行么?
在进行讨论之前,我们首先引入分摊复杂度的概念:
分摊复杂度
在足够多且连续的操作之下,平均每次操作的时间成本被称为分摊复杂度,下面介绍平均复杂度的概念将两者进行区分。
平均复杂度:如果多种运行情况存在某种概率分布,那么我们将其状况对应的运行时间加权得到的时间成本,我们将其称为平均复杂度。
为何容量翻倍
下面先分析,在足够多次运行时(假设全为插入操作),用于扩充向量的分摊复杂度。
不失一般性,对于刚刚扩充结束的向量(_size = n),其装填因子为0.5,那么需要执行n次插入操作才会进行扩容,忽略 new 和 delete 所使用的常数时间,需要进行2n次复制才能完成扩充,那么分摊下来每次操作分摊两次赋值才做,我们将其记为 2c, 对应复杂度为O(1)。
我们考虑扩充3倍的情况,同样的算法,平均每次操作分摊3/2次赋值操作,我们将其记为1.5*c。
分析两种情况下的对比
倍数 | 分摊操作 | 状态因子范围 |
---|---|---|
2倍 | 2*c | 0.5~1 |
3倍 | 1.5*c | 0.33~1 |
可以看出这是一个巧妙地关系,时间与空间的对立,如果我们想让分摊的时间复杂度尽可能低,从无穷角度来看,实际上是使向量长度足够长,此时几乎不需要扩容操作,但这便意味着极大的空间浪费,于是在两者的权衡下我们选择加倍向量长度。除此之外我们还可以考虑另一个重要的观点:
操作符<<,加倍操作可以通过该方法很快捷地实现,其耗费的时间大大低于乘除,近似于加减操作。
缩容操作
与扩容类似,这里不在介绍其原理,直接给出其代码实现:
template <typename T> void shrink() {
if (DEFALUT_CAPACITY < capacity << 1) return;
if (_size << 2 > capacity) return;
T* old_elem = elem; elem = new T[capacity >>= 1];
for (int i = 0; i < _size; i++)
_elem[i] = old_elem[i];
delete[] old_elem;
}
进一步讨论
我们首先要知道我们现在无论是缩容还是扩容操作。都是为了提高空间利用率,从根本上理解实际上使利用时间换取空间。那么我们需要思考:我们面对的问题是否对这种操作有需求,是否会造成空间浪费,是否是单次操作敏感地应用,如果我们并不会进行大量的插入删除操作(向量在这方面并不高效),那么便没有必要引入这两个操作;如果我们想要实现的功能对单次操作较为敏感,对最坏结果较为重视,那么这便要求我们要舍弃掉这两个操作。
向量的基本操作实现(public 部分)
运算符重载
需要重载的运算符主要有[],==,<,三个操作,下面我们给出分析:
- [],是一个很重要的方法,没什么原理可讲,直接给代码
template <typename T> T& Vector:: operator[] (rank r) const
{return _elem[r];}
注:这里一定要返回引用,因为数组元素的访问支持修改!!!!
- ==,<,这里主要是为了能够实现后续的查找功能实现的,因为我们担心T可能不支持这两种操作,故而进行重载,但更重要的是,我们这里要提出一种新的思想,不说了,直接看代码吧:
template <typename T> static bool lt (T* a, T* b) {return *a < *b};
template <typename T> static bool eq (T* a, T* b) {return *a = *b}
这里只展示部分代码,这里的代码有很重要的作用,那就是我们可以直接通过指针进行大小判断,但为什么不直接利用*操作符呢?可能以后学的多一点便能够理解这一点了……
置乱算法
这是一个很有趣的部分,我们先看看下面代码如何实现将数组全部元素置乱的吧。
template <typename T> void vector<T>: unsort (rank lo, rank hi) {
T* V = _elem + lo;
for ( rank i = hi - lo; i > 0; i--)
swap(V[i], V[rand()%i]);
}
这个算法有两个很有趣的性质,一个是向量元素所有排列组合都可以通过该算法实现,另一个是所有排列组合的概率都是相同的。
下面给出证明:
- 对于任意排列组合,我们每次让最后一个元素归位,那么一定能够将该排列实现。
- 下面证明对于每个位置每个元素的概率都是相同的,该开始最后一个位置每个元素对应的概率为1/n,倒数第二个位置剩余元素对应的概率为(n-1)/n * 1/(n-1)= 1 / n, 同理可证对于任意位置,每个元素被选取的概率都是相同的,那么必然每个排列组合的概率都是相同的。
无序查找
无序查找没啥可说的,就是挨个判等,然后返回秩,这里唯一值得提的是,返回最大的秩,这样你返回值便有着更强的规律性,能够应用于较复杂的算法。比如对于有序向量的查找与插入,如果能返回不大于该元素的最大的秩,那么该位置便对应了插入位置。
复杂度分析:这个的最坏情况为O(n),最好为O(1),这类算法被称为输入敏感的算法。
插入与删除操作
先展示代码,在进行分析:
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;
return r;
}
int vector<T>:: remove(rank lo, rank hi) {
if (lo == hi) return 0;
while(hi < _size) _elem[_lo++] = elem[hi++]; //将hi后面的元素全部前移
_size = lo;
shrink();
return hi-lo;//返回被删除的元素个数
}
首先上面两组代码主体部分没啥大不了,我想学过c++的基本能掌握数组的插入与删除的技巧,但我想让大家注意的是,expand()和shrink() 的位置,思考一下如果把expand(),放在末尾或者把shrink()放在开头,会造成什么样的后果。
- 考虑第一种情况,如果expand(),放在末尾,那么便不存在“满”的向量,同时,如果一次插入之后,向量满了,如果后执行expand(),便会是向量容量加倍,但如果此后不再进行插入操作,可能会造成空间的浪费。
- 第二种情况,如果删掉元素之后填充因子小于临界值了,前置的shrink(),便会导致空间的浪费。
总之,可能上述两个小技巧在很多人看来没有必要,但我认为有必要在代码的每个细节进行精益求精,往往bug的产生或者运算速度的下降都与这些因素密切相关。
复杂度
上述两种操作都是输入敏感操作,最好的情况恰好是最后的元素,复杂度为O(1),最坏的是O(n)。
唯一化以及遍历
唯一化在非排序下只有复杂度为O(n^2)的算法,无论是查找前驱还是后继都没有很大区别,唯一值得注意的是这里有对find()函数和remove()函数的调用,一定程度上实现了代码的重用。代码如下(找前驱):
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; //返回删除元素个数
}
遍历这里主要是利用了函数指针与函数对象机制,这里代码不在展示,关于函数指针与函数对象的相关知识将于另一篇文章讲解。
有序向量
在前面的讨论中,我们了解,无序向量无论是find()操作,还是delete()操作,复杂度都是O(n)的,特别是唯一化操作,复杂度达到了(n^2),这是我们不希望看到的,而有序向量在以上操作中有着极大的优势,甚至可以快上一个线性程度,我们在对问题进行分析时,一般都会先将数据先行排序进行预处理,这样大大方便了其他操作。
有序判断
这里引入逆序对的概念,这个概念对于快排的理解很重要,逆序对的数目决定了冒泡排序进行交换的次数。我们定义对于一个元素大于其直接后继,则称其为一个逆序对,当然,如果逆序对不存在,数组必然有序,反之,数组必然无序。下面给出判断数组有序性的代码,即**disordered()**函数,它的返回值为逆序对的个数。
template <typename T> int vector::disordered() const {
int n = 0;
for (int i = 1; i < _size; i++)
if (_elem[i-1] < _elem[i]) n++;
return n;
}
唯一化
在讨论排序函数之前,关于有序数组的唯一化是一个有趣且值得讨论的话题。这里我们探讨两种实现的思路。
1.思路一:不断判断后续元素与当前元素是否相等,相等则删除。基本代码如下
template <typename T> int vector::uniquify() {
int Oldsize = _size; int i = 1;
while (i < _size)
_elem[i-1] == _elem[i]? remove(i) : i++;
return Oldsize - _size;
}
从这个思路可以看出这是一个非常低效的算法,试想一下,如果所有元素都是相同的话,那么这个算法的复杂度竟然达到了O(n^2),的层次,这几乎于无序算法没有区别,我们试想:是不是直接删除成段的元素,或者干脆直接不删除元素来达到我们的目的呢?
2. 思路二:新建一个数组,把不重复的元素存到数组中,然后更新数组以及规模。这个算法看似高明,但对空间也造成了不必要的损耗,我们试想,这个功能能不能在原数组的基础上实现呢?
这个问题的答案推敲之后很容易得到,我们完全可以直接覆盖原变量,利用一个地标变量来记录数组规模,这样数组只需要遍历一遍便可以实现唯一化,下面给出代码:
template <typename T> int vector::uniquify() {
int i = j = 1;
while (i < _size)
_elem[i-1] == _elem[i]? i++: _elem[j++] = _elem[i];
_size = j;
shrink();
return i - j;
}
查找
有序向量在查找方面具有极大的优势,这主要是由于其有序性给了我们极大的遍历,我们这里主要介绍二分查找和Fibonacci查找,这里体现我们对代码内部细节的分析,如何改进判断标准来减小复杂度的系数。(注:判断的时间损耗远远大于移位操作)
三判断以及Fibonacci算法
这里为了更好讲解,先展示代码:
template <typename T> static binSearch(T* A, T const& e, rank lo, rank hi) {
while (lo < hi) {
rank mi = (hi + lo) >> 1;
if (A[mi] > e) hi = mi;
else if (A[mi] < e) lo = mi;
else return mi;
}
return lo;
}
从上述代码可以看出,在进行三重判断时,左右的判断次数时不平衡的,那么我们二分的分法便显得不合理,我们可以利用数学知识求出收敛时的判断次数为:1.5log(n).
下面时推导过程:
对于规模为2^k - 1 的数组,记C(k)为判断次数,则可已得到推导公式:
C(k) = (2^(k - 1) -1 + C(2^k - 1))) + 2 + (22^(k - 1) -1 + C(2^k - 1)))
最终可以推得:C(k) = 1.5k, 即 1.5log(k).
为此我们希望能够通过改进两者的比例来提高一定效率,于是利用Fibonacci数字的规律来对数组进行分割,发现恰好能够达到最好的分割效果,最终判断次数为 1.44*log(n), 一定程度上提高了效率,这里只展示代码以供查阅:
#include"...\finbonacci\Fib.h"
template <typename T> static rank fibsearch(T* A, T const& e, rank lo, rank hi) {
Fib fib(hi - lo);
while (lo < hi) {
while (hi - lo < fib.get()) fib.pre();
rank mi = lo + fib.get() - 1;
if (e < A[mi]) hi = mi;
else if (A[mi] < e) lo = mi;
else return mi;
}
return lo;
}
两判断以及算法改进
二分查找的优点在于,无论时向左还是向右,都只需要一次判断,而这同时意味着,不存在最佳情况,任意元素的查找都要一直到最底层,但这一改变使得二分查找的比例更加适用(Finbonacci算法不再适用),同时使得各类情况的性能较为平均。下面展示升级后的最终代码:
template <typename T> static Binseach(T* A, T const& e, rank lo, rank hi) {
while (1 < hi - lo) {
rank mi = (hi + lo) >> 1;
(e < _elem[mi]) ? hi = mi: lo = mi + 1;
}
return --lo;
}
这涉及到算法正确性证明过程中,不变性的讨论,我们可以从代码中看出,当i >= hi 时, e < _elem[i],而当i < lo时,e >= _elem[i],通过这种不变性我们可以看出,当lo == hi时,lo-1 就是不大于e的最大秩,这是我们通过不变性得出的结论,从这里我们可以看出算法设计不变性的一个重要应用。通过这个算法,我们不仅实现了两判断算法的实现,同时返回不大于e的最大秩,便于我们在不影响查找操作下对有序数组进行插入操作。
排序
数组排序是非常非常基础,也是非常重要的一部分,是很多算法设计的基础,为此这里我们要对多种排序算法的原理,复杂度进行深入的探讨。
算法复杂度下界
这是一个很有趣的概念,这里给出了一种方法:问题最坏情况下的最小复杂度的求解!当然,这一最小复杂度可能无法实现,但这给了我们算法改进的指标与要求,即是否达到最大优化,是否还有改进空间。
比较树
介绍复杂度下界,我们不得不介绍一下,比较树的概念,以三个苹果为例,有一个苹果与其他两个重量不同,我们需要进行比较
我们知道一共有三种可能的结果,尽管最终答案只有1个。在上述的二叉树结构中,我们令叶节点为最终结果,内部节点为当前状态,连线(图中没画出)代表比较结果,从上述图中我们可以得出以下结论:
- 最好状况可能只需要一次判断。我们完全可以单独一个分支来实现我们的结果。
- 最坏状况不可能低于log(n)(上取整),n为结果数目,底数为树的分支数目。这是显然的,因为如果树的深度少于log(n),那么树的容量便是不够的。
- 通过上述结论我们可以判断,排序算法的最坏情况下的最优算法复杂度至少是:log(n!),化简为n*log(n),这表明我们当前的算法是相当快捷的。
冒泡排序
我要嘲弄一下这个算法,尽管这个算法是最容易理解的排序算法,但是它忽略了很多东西!
这个算法声如其名,就是每次找出最大或者最小元素,即每次让一个元素归位。我们可以对其优化,加上对数组有序性的判断,同时对无序结束位置进行判断,这样一定程度提高了算法效率,但这不能从根本上改变它复杂度为O(n^2)的事实。
它的算法缓慢的最主要的原因是,他忽略了数组元素中的相会制约关系,对数组中元素的排列关系关注很少,反而更多的只关注最大的元素,每次只能减少一个逆序对的数目。
由于对算法的学习甚少,这里不再添加评论,来日再说……
归并排序
这是一个很好用的算法,实现了平均情况的复杂度为n*log(n)。这里体现了分治的思想,通过二分不断将问题简化,在问题最小集下,实质上只是减少了一个逆序对,这样看来并没有多少优点,他的优势在于,两个排好序的数组进行排序,这样组合时,每次操作减少的便不是只有一个逆序对,而是一次减少多个逆序对!!!!这里考虑了一定程度的数据内部关系。下面来看这个算法是如何实现的吧:
template <typename T> void vector<T>:: mergesort(rank lo, rank hi) {
if (hi - lo < 2) return;
int mi = (hi + lo) >> 1;
mergeSort(lo, mi); mergeSort(mi, hi);
merge(lo, mi, hi);
}
void vector<T>::merge(rank lo, rank mi, rank hi) {
T* A = _elem + lo;
int lb = mi - lo; T* B = new T[lb];
for (rank i = 0; i < lb; B[i] = A[i++])
int lc = hi - mi; T* C = _elem + mi;
int i = 0, j = 0;
for(rank k = 0; (j < lb)&&(k < lc);) {
if (B[j] > C[k]) A[i++] = C[k++];
else A[i++] = B[j++];
}
while(j < lb) {
A[i++] = B[j++];
}
delete B;
}
这个算法虽然达到了nlog(n)的规模,然而在每次递归实现时,总是要创建一个新的数组实现数组暂存,我曾经思考过能否在不使用临时数组的形式来进行排序,发现无法实现,复杂度几乎达到了O(n2),有人提出了一个显得改进方法,手摇算法,其复杂度却也是在nlog(n)~n2之间波动。
为了减低归并算法的空间复杂度,借鉴于可用向量表(Freelists)的启示,这里提出,我们完全在调用函数之前new出一个足够大的数组来供算法使用,这样便大大减少了new和delete操作所耗费的时间。
排序小结
定量评价数组排序算法的效率,这里理解了逆序对可以作为评价标准的重要作用,我们可以做下对比:
- 对于冒泡排序,每次交换必然意味着减少一个逆序对,然而如果一个数都比它后面的大,完全可以一次交换减少多个逆序对,而这个操作是冒泡没有做到的,因此这是最低效的排序算法。
- 对于归并排序,虽然在最底层进行的排序也不过是减少了一个逆序对,然而随着递归深度的减少,对于两个已排序的数组进行重组,这时便可以实现单次交换减少多个逆序对的情形,因而这个算法要比冒泡算法高效得多。
- 对于快速排序,我们可以树立一个坐标来对元素进行划分,这时每次交换都必然意味着至少减少一个逆序对的个数,这也是为什么快速排序比冒泡排序快速的原因。在一般情况下,快排取值不会过于极端,一般都可以在n*log(n)左右,快排比归并排序一般情况下都要快,因为归并尽管可以尽量减少储存空间,但数组的复制确实无法避免的,而快排不需要这种数组元素的复制与转移。