数据结构C++版

数据结构C++版

标签(空格分隔): 数据结构


第一章 绪论

一. 复杂度度量

  1. 时间复杂度:T(n)
    (1)算法执行时间的变化趋势,可以表现为输入规模的一个函数,记为T(n),其中n是问题规模
    (2)其实,T(n)的表示并不严谨,因为即使输入规模大小相同,输入的内容不同也会造成算法计算时间不同。例如:由n个元素组成的序列由\(n!\)种,有时所有元素都需交换,有时无需任何交换。所以,我们做一次简化,选择执行时间最长的输入规模作为T(n),并以T(n)作为度量该算法的复杂度。

  2. 渐进复杂度:O(f(n))
    (1)对于同一问题规模的两个算法A和B,通过比较其时间复杂度\(T_A(n)和T_B(n)\),即可判定二者优劣 (带入n计算两个函数值比较大小)。但这并不是说明,对于所有问题规模,二者优劣总是一定的,因为有的算法适合小规模输入,有些则恰恰相反。
    (2)我们总是关注大规模输入下的算法执行时间,因为小规模输入本身执行时间就很短。为了描述这种随着问题规模不断扩大带来的算法执行时间变化趋势,基于保守估计的原则,我们首先关注\(T(n)\)的上界,引入符号\(O\)
    (3)具体的,若存在正常数c和\(f(n)\),使得任意 \(n>>2\) 都有:$$T(n) <= c*f(n)$$        则可认为n足够大之后\(f(n)\)给出了\(T(n)\)的渐进上界,记为$$T(n) = O(f(n))$$
    (4)所以对于 \(O\),有如下性质:
            对任意 \(c>0\),有 \(O(f(n)) = O(c*f(n))\)
            对任意常数 \(a>b>0\),有 \(O(n^a+n^b) = O(n^a)\)

  3. 最好复杂度估计
    (1)对算法执行时间的乐观估计,我们引入符号 \(\Omega\)
    (2)若对于 \(n>>2\),都有 \(T(n) >= c*g(n)\),则可认为,在n足够大后,\(g(n)\) 给出了 \(T(n)\)的一个渐进下界,记为 $$T(n) = \Omega(g(n))$$

  4. 精确估计
    (1)从渐进趋势来看,\(T(n)\)介于 \(\Omega(g(n))\)\(O(f(n))\)之间,若恰巧出现 \(g(n)=f(n)\),则可用 \(\Theta\) 符号来表示
    (2)若对于正常数\(c_1,c_2\) 和函数 \(h(n)\),对于所有n>>2,都有$$c_1h(n) <= T(n) <= c_2h(n)$$
    则可认为,n足够大之后,\(h(n)\) 给出了 \(T(n)\) 的一个确界,我们记为:$$T(n) = \Theta(h(n))$$

二. 递归

  1. 线性递归
    (1)数组求和的线性递归版本:
    int sum(int A[],int n){
        if (n<1)      // 平凡情况,递归基
            return 0;
        else
            return A[n-1] + sum(A,n-1); // 一般情况
    }
    
    int main(){
        int A[] = {1,2,34};
        int res = sum(A,3);
        cout << "sum:" << res;     // 37
    }
    

(2)线性递归
     a ) 算法sum()总是朝着更深层次进行自我调用,且每个实例对自身调用至多一次,使得这些调用形成线性关系。
     b ) 线性递归的解分为两步:第一对应于单独的某个元素,可直接求解。另一个对应于剩余部分,其结构与原问题相同(eg: A[n-1]),子问题的解经过简单合并后即可得到原问题的解。


(3)减而治之:线性递归每深入一层,带求解问题的规模都缩减一个常数,直至最终化为平凡小问题。

  1. 递归分析sum函数
    (1)递归跟踪:用分析递归算法的总体运行时间与空间,将递归算法执行过程整理为图的形式。
    digui1.png-24.9kB


             每一个递归实例中所涉及的非递归部分分为三类(判断n是否为0,累加sum(n-1)+A[n-1],返回当前总和),共需计算时间为常数\(O\)(3)。
             对于长度为n的输入数组,递归深度应为n+1,故整个sum算法的运行时间为 $ (n+1) * O$(3) = \(O\)(n)


    (2)递归方程

  2. 递归模式
    (1)多递归基:
    为保证有穷性,递归算法都应设置递归基,且应针对可能出现的平凡情况,都需设置递归基,故同一个递归基可能不止一个。
    线性递归实现数组反转:

    void reverse(int* A,int low, int high){
        if(low < high){
            swap(A[low],A[high]);
            reverse(A,low+1,high-1);
        }
    }
    int main(){
        int A[] = {23,45,12,57,28};
        reverse(A,0,4);
        for(int i=0;i<sizeof(A)/sizeof(A[0]);i++)
            cout << A[i] << "," ;
    
    }
    

(2)多项递归
递归算法中,不止递归基有多个,递归调用也可能有多个不同的分支选择。虽然选择多,但是之中从中选择一个递归分支,因此,这种方式仍属于线性递归。

  1. 递归消除
    (1)空间成本
    (2)尾递归及其消除
    若递归调用在递归实例中恰好以最后一步的形式出现,则这个递归是尾递归。尾递归消除可以用goto语句和while循环消除

    // goto消除尾递归
    void reverse_goto(int* A,int low, int high){    
        next:
        if(low < high){
            swap(A[low++],A[high--]);
            goto next;
        }
    }
    // while消除尾递归
    void reverse_while(int* A,int low, int high){   
        while(low < high)
            swap(A[low++],A[high--]);
    }
    
  2. 二分递归
    (1)分而治之:
    分而治之就是将问题分解为若干规模更小的子问题,再通过递归机制分别求解。这种分解持续进行,直到子问题规模缩减至平凡情况。(divide-and-conquer)策略。
    通常把问题一分为二,故称做二分递归。无论是分解2个还是更大常数个子问题,对算法总体的渐进复杂度并无实质影响。
    直观来讲,二分递归就是算法的最后一句,2次递归调用的组合。
    (2)二分递归下的数组求和
    二分递归的每个递归实例都可向下递归两次,所以二分递归下的递归基出现的相当频繁,会有超过半数的递归实例都是递归基。

    int sum(int A[],int low,int high){
        if( low==high )
            return A[low];
        int mid = (low + high) >> 1;  // 除2向下取整
        return sum(A,low,mid) + sum(A,mid+1,high);  // 两次递归调用
    }
    
    int main() {
        int A[] = {1,2,3,4,5,6,7,8};
        int i = sum(A, 0, 7);
        cout << i;
    }
    

(3)效率
二分递归的计算消耗来自两方面:子问题划分和子问题解答的合并。为使分治策略真正有效,划分出的子问题需要可独立求解,而无需其他子问题的原始数据或中间结果。
(4)Fibnoacci数:二分递归
```c
int fib(int n){
if (n<2)
return n;
else
return fib(n-1) + fib(n-2);
}

int main() {
    int i = fib(3);  // 斐波那契数列:0,1,1,2,3,5
    cout << i;
}

```

(5)优化策略
上述二分递归计算斐波那契的做法,并没有充分体现分治的思想,因为子问题之间会相互引用各自的数据。eg:fib(n)=fib(n-1)+fib(n-2)的划分问题,但是fib(n-1)需要再递归一次fib(n-2)的结果。为了消除这种重复递归,一种自然的思路就是:借助一定的辅助空间,在各自问题求解后,及时记录下其对应的解答
这种方式就形成了两种做法:一种是从递归基出发,自底向上递推得出各子问题的解,这是所谓的动态规划。另一种是通过直接调阅记录获得解答,从而避免从新计算。这就是所谓的查表法。
(6)Fibonacci数:查表法 -> 线性递归
思路:递归基种要有保存上一项的参数.使得每一个字问题的解答只会调用一次。在该算法抵达递归基之后的逐层返回过程中,每向上返回一层,以下各层的解答均不需继续保留。
```c
int fib(int n,int& pre){ // pre:引用
if (n==0) {
pre = 1;
return n;
}
else{
int pre2 ;
pre = fib(n-1,pre2);
return pre2+pre;
}
}

int main() {
    int fak = 100;
    int i = fib(3,fak);  // 斐波那契数列:0,1,1,2,3,5
    cout << i;
}
```

(7)Fibonacci数:动态规划=>迭代
思路:动态规划先从最小的子问题开始,设f(0)=0,f(1)=1,开始逐个循环
```c
int fib(int n){
int a = 0; // f(0)
int b = 1; // f(1)
while(n>0){
int tmp = b;
b = a+b;
a = tmp;
n--;
}
}

int main() {
    int fak = 100;
    int i = fib(4);  // 斐波那契数列:0,1,1,2,3,5
    cout << i;
}
```
#include <cstdlib>
#include <iostream>
using namespace std;


typedef int Rank;
#define DEFAULT_CAPACITY 4
template <typename T> class Vector{
protected:
    Rank _size;    // 元素个数
    int _capacity; // 内部数组大小
    T* _elem;      // 数据区
    void copyFrom(T const* A,Rank lo,Rank high ){
        _elem = new T[_capacity=2*(high-lo)];
        while( lo<high ){
            _elem[_size++] = A[lo++] ; // 复制到 _elem[0,high-lo)
        }
    }

    /**
     * 内部数组动态扩容算法:基于数组赋值
     * (1)与原生数组相比,可扩充向量更加灵活,只要系统尚有空间可用,其规模不再受限于初始容量
     * (2)每一次由n扩容到2n,都需要话费O(2n)=O(n)时间,这也是最坏情况下插入操作所需时间。表面来看,这一策略的效率很低,
     *      但随着向量规模的不断扩大,在执行插入操作前需要进行扩容的概率也越来越低,所以,就平均意义而言,用于扩容的时间成本不至很高。、
     * (3)分摊时间:如果连续对数组扩容,将其所有时间累积起来,分摊至每次操作。此处,我们可以连续对结构进行n次查询,插入,删除操作,计算下平均下来的分摊时间。
     *                有意思的是,即便我们连续进行n次都是插入的操作,用于扩容的平均分摊时间也不过是O(1),定义如下函数:
     *                      size(n) = 连续插入n个元素后响亮的规模
     *                      capacity(n) = 连续插入n个元素后数组的容量
     *                      T(n) = 连续插入n个元素而花费在扩容的时间
     *                因为只有在数组装满的情况下才会扩容,所以数组的装填因子始终大于等于50%,所以会有size(n) <= capacity(n) <= 2*size(n),所以会有
     *                capacity(n) = O(size(n)) = O(n),又因为,每次扩容所需时间正比于当时的数组规模。同样以2倍的速度增长,
     *                所以用于扩容的时间累积为T(n) = 2n + 4n + 8n +...+ capacity(n) < 2*capacity(n) = O(n)。将其平均分摊到n次操作中,相应时间为O(1)。
     *       早期的数组扩容多采用追加固定数组单元,此类分摊时间高达O(n)
     */
    void expand(){
        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];
        delete [] oldElem;
    }

    /**
     * 缩容:装填因子不足25%时压缩所占空间
     *       缩容的平均分摊时间也为O(1)
     */
    void shrink(){
        if(_capacity<DEFAULT_CAPACITY <<1)   // 不至收缩到DEFAULT_CAPACITY以下
            return;
        if(_size <<2 > _capacity)              // 以25%为界
            return;
        T* oldElem = _elem;
        _elem = new T[_capacity >>= 1]; //  容量减半
        for (int i = 0; i < _size; i++) {
            _elem[i] = oldElem[i];
        }
        delete [] oldElem;
    }
    /**
     * 置乱器:在软件测试,仿真模拟中,随机向量生成都是至关重要的操作
     * @param V :可通过V[i]访问数据元素
     */
    void permute(Vector<T> &V){
        for (int i = 0; i < _size; i++) {
            int a=1,b=2;
            swap(V[i],V[rand()%(i+1)]);
        }
    }
public:
    /**
     * 构造函数
     * @param c :容量。内部数组大小
     * @param s :元素个数
     * @param v :数组中的初始值
     */
    Vector(int c=DEFAULT_CAPACITY,int s=0,int v=0){
        _elem = new T[_capacity=c];
        for(_size=0;_size<s;_size++){
            _elem[_size] = v;
        }
    }

    /**
     * 析构函数:只需释放内部数组_elem[],_size和_capacity这种内部变量无须作任何处理,他们会随着对象的销毁备操作系统回收
     */
    ~Vector(){
        delete[] _elem;
    }
    /**
     * 重载操作符"="
     * @param V : 赋值的vector
     * @return
     */
    Vector<T>& operator= (Vector<T> const& V){  // 默认运算符"="不足以支持向量间的直接赋值
        if(_elem)
            delete[] _elem;  //释放原有内容
        copyFrom(V._elem,0,V._size);
        return *this;
    }

    /**
     * 通过Vector[i]访问向量中的元素
     */
    T& operator[](Rank r) const {
        return _elem[r];
    }

    /**
     * 无序向量查找:
     *              自向量末元素起,向前找出元素e的位置。
     *              此算法最坏情况下O(high-lo)=O(n),最好情况为O(1),对于规模相同,内部组成不同的输入,渐进运行时间有所差别,故称输入敏感算法。
     * @param e :待查严肃
     * @param lo :查找范围为[lo,high)
     * @param high
     * @return
     */
    Rank find(T const& e,Rank lo,Rank high)const{ // assert: 0 <= lo < high <= _size
        while( (lo<high--) && e!=_elem[high] );  // while(lo<high--)的顺序:先判断,再自减,再while
        return high;  // 跳出循环时,high = lo-1
    }

    /**
     * 插入: 将e作为秩为r的元素插入
     *        新插入元素越靠后,所需时间越短。平均插入时间为O(n)
     * @param r :秩,前缀元素的个数,等于元素角标
     * @param e
     * @return
     */
    Rank 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; // 返回秩
    }

    /**
     * 删除:删除区间[lo,hi)的元素
     *       删除单个元素可用 remove(r,r+1).最好O(1),最坏O(n)
     * @param lo
     * @param hi
     * @return
     */
    int remove(Rank lo,Rank hi){
        if (lo == hi)             // 处于效率原因,退化情况单独考虑
            return 0;
        while( hi<_size )
            _elem[lo++] = _elem[hi++];           // 删除前,容器规模_size个
        _size = lo;               // 更新规模,丢弃尾部[lo,_size=hi]区间的元素
        shrink();                 // 如有必要,进行缩容
        return hi-lo;            // 返回被删除的元素数目
    }
};

posted @ 2017-06-28 00:01  moon_lord  阅读(1777)  评论(0编辑  收藏  举报