首页 Algorithms-DengNo4
文章
取消

Algorithms-DengNo4

第四章 栈与队列

栈与队列与列表和向量一样,都是线性序列结构。其基础性是其他结构无法比拟的。

着重介绍如何利用栈结构,实现基于试探回溯策略的高效搜索算法。 在队列方面,将介绍如何实现基于轮值策略的通用循环分配器,并以银行窗口为例实现基础的调度算法

4.1 栈

4.1.1 ADT接口

栈中可以操作的一端更多的称为栈顶,另一方无法操作的称为栈底。

操作接口功能
size()报告栈的规模
empty()判断栈是否为空
push(e)将e插至栈顶
pop()删除栈顶对象
top()引用栈顶对象

后出先进

4.1.3 Stack模板类

利用C++继承,按向量模板实现栈结构

1
2
3
4
5
6
7
8
9
#include "../Vector/Vector.h" //以向量为基类,派生出栈模板类
template <typename T>
class Stack : public Vector<T>
{                                                // 将向量的首/末端作为栈底/顶
public:                                          // size()、empty()以及其它开放接口,均可直接沿用
    void push(T const &e) { insert(size(), e); } // 入栈:等效于将新元素作为向量的末元素揑入
    T pop() { return remove(size() - 1); }       // 出栈:等效亍初除向量的末元素
    T &top() { return (*this)[size() - 1]; }     // 取顶:直接返回向量的末元素
};

4.2 栈与递归

4.2.1 函数调用栈

图04-03 函数调用栈实例:主函数main()调用funcA(),funcA()调用funcB(),funcB()再自我调用

如图4.3所示,调用栈的基本单位是帧(frame)。每次函数调用时,都会相应地创建一帧,记录该函数实例在二进制程序中的返回地址(return address),以及局部变量、传入参数等,并将该帧压入调用栈。若在该函数返回之前又发生新的调用,则同样地要将与新函数对应的一帧压入栈中,成为新的栈顶。函数一旦运行完毕,对应的帧随即弹出,运行控制权将被交还给该函数的上层调用函数,并按照该帧中记录的返回地址确定在二进制程序中继续执行的位置。

在任一时刻,调用栈中的各帧,依次对应于那些尚未返回的调用实例,亦即当时的活跃函数实例(active function instance)。特别地,位于栈底的那帧必然对应于入口主函数main(),若它从调用栈中弹出,则意味着整个程序的运行结束,此后控制权将交还给操作系统。仿照递归跟踪法,程序执行过程出现过的函数实例及其调用关系,也可构成一棵树,称作该程序的运行树。任一时刻的所有活跃函数实例,在调用栈中自底到顶,对应于运行树中从根节点到最新活跃函数实例的一条调用路径。

4.2.2 避免递归

追求高效率的场合,尽量避免递归,尤其是过度的递归

4.3 栈的典型应用

4.3.1 逆序输出

栈所擅长的典型问题中,共同特征:首先,解答以线性序列的形式给出。其次,无论递归还是迭代实现,该序列都是依逆序计算输出的。

进制转换问题

递归式算法

1
2
3
4
5
6
7
8
9
10
11
12
13
void convert(Stack<char> &S, __int64 n, int base)
{                       // 十进制n到base进制的转换(迭代版)
    static char digit[] // 0 < n, 1 < base <= 16,新进制下的数位符号,可视base取值范围适当扩充。
        = {'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F'};
    while (n > 0)
    { // 由低到高,逐一计算出新进制下的各数位
        int remainder = (int)(n % base);
        S.push(digit[remainder]); // 余数(当前位)入栈
        n /= base;                // n更新为其对base的除商
    }
} // 新进制下,由高到低的各数位,自顶而下保存到栈中。

4.3.2 递归嵌套

具有自相似性的问题多可以嵌套地递归描述,但因分支位置和嵌套深度并与固定,其递归算法的复杂度与容易控制。 栈结构及其操作天然地具有递归嵌套性。

栈混洗

图04-06 栈混洗实例:从{1, 2, 3, 4}到{3, 2, 4, 1}(上方左侧为栈A,右侧为栈B;下方为栈S)

由n次push和n次pop构成的任何操作序列,只要满足“任一前缀中的push与少于pop”这一限制,则该序列也必然对应于某个栈混洗(习题[4-4])。

括号匹配 递归实现

只要将push、pop操作分别与左、右括号相对应,则长度为n的栈混洗,必然与由n对括号组成的合法表达式彼此对应(习题[4-4])。比如,栈混洗{ 3, 2, 4, 1 }对应于表达”( ( ( ) ) ( ) )”。按照这一理解,借助栈结构,只需扫描一趟表达式,即可在线性时间内,判定其中的括号是否匹配。

这一算法可以简明地实现如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
bool paren(const char exp[], int lo, int hi)
{                                // 表达式括号匹配检查,可以兼顾三种括号
    Stack<char> S;               // 使用栈记录已发现但尚未匹配的左括号
    for (int i = 0; exp[i]; i++) /* 逐一检查当前字符 */
        switch (exp[i])
        { // 左括号直接进栈;右括号若与栈顶失配,则表达式必与匹配
        case '(':
        case '[':
        case '{':
            S.push(exp[i]);
            break;
        case ')':
            if ((S.empty()) || ('(' != S.pop()))
                return false;
            break;
        case ']':
            if ((S.empty()) || ('[' != S.pop()))
                return false;
            break;
        case '}':
            if ((S.empty()) || ('{' != S.pop()))
                return false;
            break;
        default:
            break; // 非括号字符一律忽略
        }
    return S.empty(); // 整个表达式扫描过后,栈中若仍残留(左)括号,则与匹配;否则栈空(匹配)
}

4.3.3 延迟缓冲

表达式求值

优先级表

求值算法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
float evaluate(char *S, char *&RPN)
{ // 对(已经删除空白格的)表达式S求值,并转换为逆波兰式RPN
    Stack<float> opnd;
    Stack<char> optr; // 运算数栈、运算符栈
    optr.push('\0');  // 尾哨兵'\0'也作为头哨兵首先入栈
    while (!optr.empty())
    { // 在运算符栈非空之前,逐个处理表达式中各字符
        if (isdigit(*S))
        { // 若当前字符为操作数,则
            readNumber(S, opnd);
            append(RPN, opnd.top()); // 读入操作数,并将其接至RPN末尾
        }
        else // 若当前字符为运算符,则
            switch (orderBetween(optr.top(), *S))
            {         //视其与栈顶运算符之间优先级高低分别处理
            case '<': // 栈顶运算符优先级更低时
                optr.push(*S);
                S++; // 计算推迟,当前运算符进栈
                break;
            case '=': // 优先级相等(当前运算符为右括号戒者尾部哨兵'\0')时
                optr.pop();
                S++; // 脱括号并接收下一个字符
                break;
            case '>':
            { // 栈顶运算符优先级更高时,可实施相应的计算,并将结果重新入栈
                char op = optr.pop();
                append(RPN, op); // 栈顶运算符出栈并续接至RPN末尾
                if ('!' == op)
                {                                // 若属于一元运算符
                    float pOpnd = opnd.pop();    // 叧需叏出一个操作数,并
                    opnd.push(calcu(op, pOpnd)); // 实施一元计算,结果入栈
                }
                else
                {                                                   // 对于其它(二元)运算符
                    float pOpnd2 = opnd.pop(), pOpnd1 = opnd.pop(); // 取出后、前操作数
                    opnd.push(calcu(pOpnd1, op, pOpnd2));           // 实施二元计算,结果入栈
                }
                break;
            }
            default:
                exit(-1); // 逢语法错误,不做处理直接退出
            }             // switch
    }                     // while
    return opnd.pop();    // 弹出并返回最后的计算结果
}

4.3.4 逆波兰表达式

RPN

逆波兰表达式(RPN)是数学表达式的一种 其语法规则可以概况为:操作符紧邻于对应的(最后一个)操作数之后, 12+ 34^ * 对应于 (1+2) *3 ^4

RPN表达式亦称为后缀表达式,计算效率较高

前一算法在对表达式求值的同时,也顺便完成了从常规表达式到RPN表达式的转换。在求值过程中,该算法借助append()函数=将各操作数和运算符适时地追加至串rpn的末尾,直至得到完整的RPN表达式。 这里采用的规则十分简明:凡遇到操作数,即追加至rpn;而运算符只有在从栈中弹出并执行时,才被追加。这一过程,与上述手工转换的方法完全等效,其正确性也因此得以确立。将RPN自动转换过程与RPN求值过程做一对照,即不难看出,后者只不过是前者的忠实再现。

4.4 试探回溯

4.4.1 试探与回溯

剪枝

剪枝,试探。 从零开始,尝试逐步增加候选解的长度。更准确地,这一过程是在成批地考查具有特定前缀的所有候选解。这种从长度上逐步向目标解靠近的尝试,称作试探(probing)。作为解的局部特征,特征前缀在试探的过程中一旦被发现与目标解不合,则收缩到此前一步的长度,然后继续试探下一可能的组合。特征前缀长度缩减的这类操作, 称作回溯 (backtracking),其效果等同于剪枝。如此,只要目标解的确存在就迟早会被发现,而且只要剪枝所依据的特征设计得当,计算的效率就会大大提高。

4.4.2 八皇后

迷宫寻径

4.5 队列

Queue

4.6 队列应用

4.6.1 循环分配器

轮值算法

本文由作者按照 CC BY 4.0 进行授权
热门标签
文章内容
热门标签