C++算法学习——经典的抽象设计——charstack(2)

来源:互联网 发布:网络英语翻译 编辑:程序博客网 时间:2024/06/06 04:36

虽然利用vector来实现charstack很简单。但是更重要的是,依靠Vector类使得更难分析CharStack类的性能,因为Vector类隐藏了这么多的复杂性。因为你还不知道Vector类的详细情况如何,你不知道在push和pop方法需要的情况下添加或删除元素涉及多少工作。接下来,我们要论的的主要目的是分析数据表示如何影响算法的效率。如果所有成本都可见,那么这种分析就更容易实现。所以我们以后要用底层的东西来表示。

利用基于数组的方式来实现charstack

确保没有隐藏成本的一种方法是限制实现,使其仅依赖于语言支持的最原始的操作。在charstack的情况下,使用内置数组类型来存储元素的优点是数组不隐藏。从数组中选择一个元素需要几个机器语言指令,因此在现代计算机上花费很少的时间。在堆上分配数组存储或在不再需要时回收该存储通常比选择更多的时间,但是这些操作仍然在恒定时间运行。在典型的内存管理系统中,需要相同的时间来分配一个1000字节的块,就像分配一个10的块一样。

同时,数组即使是从堆动态分配的数组也可以作为STL类的底层表示形式。如我们开头所指出的,用于将字符存储在栈中的任何代表都不得不扩展。因为对于数组来说,一旦为数组分配了空间,就无法改变它的大小。

然而,你可以做的是分配一些固定的初始大小的数组,然后只要旧的空间用尽,将其替换为全新的数组。在此过程中,你必须将所有元素从旧数组复制到新的数组,然后确保旧数组使用的内存被循环回到堆中。

要存储堆栈元素,新版本的charstackpriv.h文件需要跟踪几个变量。最重要的是,它需要一个指向包含CharStack中所有字符的动态数组的指针。它还需要跟踪已经为该数组分配了多少元素,以便实现可以判断何时耗尽空间,并需要重新分配其内部存储。由于该值表示数组在其当前配置中可以存储的字符数,因此通常称为动态数组的容量。最后,重要的是要记住,数组通常会包含比容量允许的更少的字符。因此,数据结构需要包括跟踪有效大小的变量。在私有部分中包含这三个实例变量,可以使用比数组操作更复杂的栈操作来实现。修改后的charstackpriv.h文件的内容如图所示:

private:    /* 实例化参数 */    char *array; /* 字符型的动态数组 */    int capacity; /* 为数组分配空间 */    int count; /* 当前被压入的栈中的元素个数 */    /* 私有函数声明 */    void expandCapacity();

虽然charstack.cpp中的大部分代码与你在本文前面已经看到的类似,但是其中的一些方法也是特别提到的。例如,构造函数必须初始化内部数据结构以表示空栈。计数变量必须为零,但没有理由不提供具有初始容量的堆栈。在此实现中,构造函数通过为10个字符分配空间开始,这是常量INITIAL_CAPACITY的值。选择较大值可以减少堆栈必须扩展的机会,从而节省执行时间; 决定用于INITIAL_CAPACITY的什么值是时间空间权衡(time-space tradeoff)的一个例子。以后还会介绍到。实现代码:

#include "charstack.h"#include "error.h"using namespace std;/*常量*/const int INITIAL_CAPACITY = 10;/*构造函数和析构函数*/ CharStack::CharStack() {    capacity = INITIAL_CAPACITY;    array = new char[capacity];    count  = 0;}CharStack::~CharStack() {    delete[] array;}int CharStack::size() {    return count;}int CharStack::getCapacity(){    return capacity;} bool CharStack::isEmpty() {    return count == 0;}void CharStack::clear() {    count = 0; /*注意 这个时候我们只是把count的值赋值为0,但是实际的元素                 *还在内存中,只是不在这个对象里面了,所以不是真正意义上的                *销毁                */ }void CharStack::push(char ch) {    if(count == capacity) expandCapacity();    array[count] = ch; //因为count是从0开始计算的,所以不是count+1     count++;}char CharStack::pop() {    if (isEmpty()) error("pop: Attempting to pop an empty stack");    return array[count - 1];    count--; }char CharStack::peek() {    if (isEmpty()) error("peek: Attempting to peek at an empty stack");    return array[count - 1];}void CharStack::expandCapacity(){    char *oldArray = array;    array = new char[capacity * 2];    for(int i = 0; i < count; i++){        array[i] = oldArray[i];    }    delete[] oldArray;}

在push方法中发生更有趣的变化,该方法在栈的顶部添加一个新的字符。该方法将字符存储在第一个未使用的数组位置,并增加计数字段。push方法将将动态数组扩展到私有方法expandCapacity的任务,如下所示:

void CharStack::expandCapacity(){    char *oldArray = array;    array = new char[capacity * 2];    for(int i = 0; i < count; i++){        array[i] = oldArray[i];    }    delete[] oldArray;}

从代码中可以看出,expandCapacity方法通过保存指向旧数组的指针开始。然后,它将分配一个新的数组,其容量是现有数组的两倍,将旧数组中的所有字符复制到新数组中,最后释放旧数组存储.

提到的另一种方法是析构函数,如果没有其他原因,而不是这是你看到的第一个析构函数。大多数析构函数的主要职责是释放该类分配的堆内存。 expandCapacity方法可确保每次数组扩展时都会释放旧存储。 当CharStack准备好回收时,唯一指向堆存储的指针是动态数组,其地址存储在实例变量数组中。 因此,析构函数的实现:

CharStack::~CharStack() {    delete[] array;}

在C ++中使用析构函数不是内存分配问题的神奇解决方案。虽然charstack.cpp实现清楚,你仍然必须密切关注内存管理,并确保实现释放其分配的任何堆内存。 使用析构函数的优点是内存管理的复杂性然后从客户端隐藏起来。 客户端可以声明一个CharStack,使用它一段时间,然后舍弃它。 CharStack类的实现一旦超出了范围,就会考虑释放字符堆使用的内存。

测试代码:

#include <iostream>#include <string>#include "charstack.h" using namespace std;int main(){    CharStack mystack;    for(int i = 0; i < 5; i++){        char ch;        cin >> ch;        mystack.push(ch);    }    if(!mystack.isEmpty()) cout << "栈中的元素个数为" << mystack.size();    cout << endl;    char getElement = mystack.pop();    cout << "被移除的元素为:" << getElement << endl;    mystack.clear();    cout << "清空栈后,元素个数为:" << mystack.size() << endl;    cout << "此时的栈的容量为:" << mystack.getCapacity();    return 0;}

测试结果:

这里写图片描述

charstack的算法复杂度分析

在分析此类的性能时,最重要的问题是每个操作的运行时间如何随着集合中的值数量的变化而变化。对于CharStack类,大多数方法运行在相同的时间。栈的大小完全不一样的唯一方法是push方法,因为这种方法有时不得不扩大数组的容量。

到目前为止,我们的复杂性分析集中在特殊算法在最坏情况下的表现。如果按push操作必须扩展堆栈的容量,则该调用它将以线性时间运行,因此在最坏的情况下,似乎推算的计算复杂度必须为O(N)。然而,有一个重要的特征使得push操作与传统复杂性分析中出现的其他操作不同:最差的情况不可能每次发生。特别是,如果在栈上push一个元素并触发expandCapacity,使得该特定调用在O(N)时间内运行(扩展容量时候是O(N)),push下一个元素的成本将保证为O(1),因为容量已经被扩展。因此,在这种情况下,将利益扩张的费用分配给所有受益的push操作。这种复杂度测量的风格被称为平摊分析(amortized analysis

为了使此过程更容易理解,如果N次重复N次,则计算push操作的总成本是有用的。无论栈是否扩展,每次push操作都会产生一些代价。如果你使用希腊字母α代表固定成本,则push N个项目的总固定成本为αN.然而,扩展内部数组的容量,这是一个线性时间操作,其花费了栈上字符数量的常数β倍。在总运行时间方面,最糟糕的情况是如果在最后一次使用时候发生扩张。在这种情况下,最终的push操作会产生βN的额外成本。由于扩展容量总是使数组的大小增加一倍,当栈为N的一半大到N的1/4时,容量也必须扩大, 等等。因此,push N个项目的总成本由以下公式给出:

这里写图片描述

平均时间只是这个总和除以N,如下所示:

这里写图片描述

虽然括号内的总和取决于N,但总数不能大于2,这意味着平均时间由常数值α+2β界定,因此为O(1)。