自学《算法导论》之10-1例题及习题

这一节涉及到栈,队列,双端队列及一些组合形式,为了突出重点,简化问题,令元素类型一律为int,物理存储结构一律采用定长数组。

  • 例题1 :栈
    下面的代码就实现了一个简单的栈,栈内可容纳元素个数有上限。在实现每个类型的时候都应该问问自己,为什么需要维护这些数据成员,多一个会不会更好?少一个可行吗?
    • 因为栈是一种逻辑数据结构,而每种逻辑数据结构的实现都需要依赖一种物理存储结构的支持,这里我选择了数组,所以我需要维护一个数组及其总长度(m_array和m_totalLength)。
    • 由于Push、Pop、Top方法中待处理元素的位置信息,不是由参数给定的,而是由栈自身维护的,所以我还需要记录当前待处理元素(这里指的是栈顶元素)的下标(m_top),其取值范围是[-1, m_totalLength-1]。
      • m_top的作用之一是用来计算栈顶元素所对应的数组元素的下标f(m_top),从而将逻辑层操作转化成存储层的操作,这里我将映射法则定义为f(m_top) = m_top。
      • m_top的作用之二是计算:当前栈内元素个数 = m_top + 1,从而判断栈是否为空或已满。
      • 这已经是数据成员最精简的版本,一个也不能少,多一个也没必要。
class Stack
{
public:
    Stack(int len);         //len表示栈的最大长度

    void Push(int val);     //压栈,若栈已满,报错”Overflow“
    void Pop();             //出栈,若栈为空,报错”Underflow“
    int  Top() const;           //读取栈顶,若栈为空,报错”Empty“

    bool IsEmpty() const;   //栈是否为空
    bool IsFull()  const;       //栈是否已满

private:
    int* m_array;   //数组
    const int m_totalLength;    //栈的最大长度,也是数组的总长度
    int m_top;  //栈顶元素下标 
};

Stack::Stack(int len)
    :m_totalLength(len),
      m_array(new int[len]),
      m_top(-1)
{}

void Stack::Push(int val)
{
    if(IsFull())
        cerr << "Overflow" << endl;
    else
        m_array[++m_top] = val;
}

void Stack::Pop()
{
    if(IsEmpty())
        cerr << "Underflow" << endl;
    else
        --m_top;
}

int Stack::Top() const
{
    if(IsEmpty())
    {
        cerr << "Empty" << endl;
        return -1;
    }
    else
        return m_array[m_top];
}

bool Stack::IsEmpty() const
{
    return m_top == -1;
}

bool Stack::IsFull()  const
{
    return m_top == m_totalLength - 1;
}
  • 例题2:队列
    同样的,队列可同时容纳元素个数有上限。我都需要保存哪些数据成员呢?
    • 数组及其总长度(m_array, m_totalLength)
    • 入队,出队方法中待处理元素位置信息,这里指的是队头和队尾元素的下标,同样需要由方法内部维护(m_begin, m_end)。
    • m_begin, m_end的作用之一是计算队头、队尾所对应数组元素下标,依据逻辑含义应是只增不减的,而依据环形队列的映射法则计算出的数组元素下标是在[0, m_totalLength)区间内循环取值的。
    • m_begin, m_end的作用之二是计算当前容纳元素个数,作为判断操作合法性的边界条件。
    • 具体实现中,在不影响上述两作用的前提下,必须对m_begin,m_end的值加以限制。(见方法Dequeue)
    • 由于入队,出队方法的被调用频率不同且无关,必须被记录至两个变量中,所以数据成员不能更少了。
class Queue
{
public:
    Queue(int len);
    void Enqueue(int val);
    void Dequeue();
    int Front() const;
    int Back() const;
    bool IsEmpty() const;
    bool IsFull() const;
private:
    int IndexInArray(indexInQueue) const;
private:
    int* m_array;
    const int  m_totalLength;
    int  m_begin;   //index of front element
    int  m_end;     //index of the one next to back element
};

Queue::Queue(int len)
    : m_totalLength(len),
      m_array(new int[len]),
      m_begin(0),
      m_end(0)
{
}

void Queue::Enqueue(int val)
{
    if(IsFull())
        cerr << "Overflow" << endl;
    else
    {
        m_array[IndexInArray(m_end)] = val;
        ++m_end;
    }
}

void Queue::Dequeue()
{
    if(IsEmpty())
        cerr << "Underflow" << endl;
    else
    {
        ++m_begin;
        //限制m_begin,m_end取值范围
        if(m_begin >= m_totalLength)
        {
            m_begin -= m_totalLength;
            m_end -= m_totalLength;
        }
    }
}

int Queue::Front() const
{
    if(IsEmpty())
    {
        cerr << "Empty" << endl;
        return -1;
    }
    return m_array[IndexInArray(m_begin)];
}

int Queue::Back() const
{
    if(IsEmpty())
    {
        cerr << "Empty" << endl;
        return -1;
    }
    return m_array[IndexInArray(m_end - 1)];
}

bool Queue::IsEmpty() const
{
    return m_begin == m_end;
}

bool Queue::IsFull() const
{
    return m_end - m_begin == m_totalLength;
}

int Queue::IndexInArray(indexInQueue) const
{
    assert(indexInQueue >= 0);
    return indexInQueue % m_totalLength;
}
  • 习题10.1-2
    不失一般性,把数组视为环形数组。
    因为需要把一个数组分给两个栈使用,所以我们需要设置一个分界线,两个栈分别以分界线处两个相邻元素为起点,分别向左右两个方向生长,直到二者总长度达到数组总长度为止。
    如此说来,除了数组和总长度外,还需要保存一个常量(分界线的位置m_divide)和两个变量(两个栈顶元素下标m_top[A],m_top[B])。
    m_top[A]和m_top[B]保存的是逻辑层的栈内下标,它的作用和Stack::m_top以及Queue::m_begin,Queue::m_end都是一样的,一是计算对应的数组元素下标,二是计算边界条件,判断调用合法性。
class DoubleStack
{
public:
    enum StackID
    {
        A = 0,
        B = 1
    };

    DoubleStack(int len);

    void Push(StackID id, int val);

    void Pop(StackID id);

    int  Top(StackID id) const;

    bool IsEmpty(StackID id) const;

    bool IsFull() const;
private:
    int TopIndexInArray(StackID id) const;
private:
    int* m_array;
    const int m_totalLength;
    int  m_top[2];
    const int m_divide;
};

DoubleStack::DoubleStack(int len)
    : m_array(new int[len]),
      m_totalLength(len),
      m_divide(len/2) //any value within [0, m_totalLength) is ok
{
    m_top[A] = -1;
    m_top[B] = -1;
}

void DoubleStack::Push(StackID id, int val)
{
    if(IsFull())
    {
        cout << "Overflow" << endl;
        return;
    }
    ++ m_top[id];
    m_array[TopIndexInArray(id)] = val;
}

void DoubleStack::Pop(StackID id)
{
    if(IsEmpty(id))
    {
        cerr << "Underflow" << endl;
        return;
    }
    -- m_top[id];
}

int  DoubleStack::Top(StackID id) const
{
    if(IsEmpty(id))
    {
        cerr << "Empty" << endl;
        return -1;
    }
    return m_array[TopIndexInArray(id)];
}

bool DoubleStack::IsEmpty(StackID id) const
{
    return m_top[id] == -1;
}

bool DoubleStack::IsFull() const
{
    return m_top[A] + m_top[B] + 2 == m_totalLength;
}

int DoubleStack::TopIndexInArray(DoubleStack::StackID id) const
{
    //let m_array[m_divide] belongs to stack B
    if(id == A)
        return (m_divide - (m_top[A] + 1) + m_totalLength) % m_totalLength;
    else
        return (m_divide + m_top[B]) % m_totalLength;
}
  • 以上三个类型的实现有一些共同的逻辑。是时候来总结一下了。
    1. 需要保存哪些信息?和普通线性表不同,调用栈的Push,Pop方法时,被操作的元素所处的位置是调用方和被调用方之间的一种约定,这里约定为栈顶的元素。类似的,双方约定调用队列的Enqueue、Dequeue所操作的元素分别为队尾和队头。既然这一信息不是由参数传入,就需要类型自行维护。总结一下,需要保存的是待操作元素的位置信息。每个栈有一个,每个队列有两个。
    2. 逻辑层信息和存储层信息,选择保存哪一个?假设你有一个菜谱,如果你想照着它炒出一盘菜,你还需要存放和操作食材的厨房。每个逻辑数据结构就好像一个菜谱,如果你想用程序实现它并运行起来,你还需要一个存储和操作它的介质,那就是物理存储结构,比如数组。数组是存储层的结构,而栈是逻辑层的结构,随之而产生的是每个元素都有两个位置信息,我叫它们逻辑层下标和存储层下标。两者构成一对映射,通常是满射。通过映射法则,两者可以互相求得。所以我们只需要在数据成员中保存一方,就可以在需要时计算出另一方。我在上面的实现中,都选择了保存逻辑下标,并将计算存储下标的工作封装在一个函数中,这样做的好处是:1. 几乎每个接口都有逻辑层的处理,但不是每个都需要动用存储层逻辑,所以保存逻辑层信息可以使得接口在逻辑层的处理更直接高效。例如,IsEmpty接口就无需计算存储层下标;2. 当你想改换一种物理存储结构时,例如从数组改为链表,你只需要修改从逻辑层下标到物理层下标的映射过程,灵活性较好。
    3. 映射法则可以很灵活。通常,考虑效率和易读性,栈下标x到数组下标f(x)的映射法则往往定义为:f(x) = x。如果你很任性,就想玩些花样,其实你完全可以把数组当做环形数组,将数组的任意位置作为起始点,比如f(x) = x + 2,就是用数组的第三个元素存储第一个入栈的元素,满栈前最后两个入栈的元素放在数组第一个,第二个元素上。或者你还可以倒过来存储,f(x) = 数组总长度 - 1 - x;甚至你可以毫无规律地将逻辑下标{0, 1, 2, 3}映射成存储下标{3, 1, 2, 0},只要它是满射,只要你开心。为什么我要这样折腾这个映射法则呢?因为有时候,它可以帮助我们灵活地解决问题。例如下面的习题10.1-2,如何将两个栈的逻辑下标映射到一个数组的存储下标上去,既要彼此不干扰,又可以最大限度利用数组,这便是映射法则的用武之地了。具体实现见函数DoubleStack::TopIndexInArray。
  • 10.1-4 同例题2
  • 10.1-5 双端队列
    按照前面总结的规律,需要保存的是待操作元素的位置信息,这里有两个待操作元素的位置会移动,所以保存它们在逻辑层的下标,m_begin, m_end,分别表示队头和队尾元素的下一个元素的下标。未完待续...

猜你喜欢

转载自www.cnblogs.com/meixiaogua/p/9670744.html