4.1 栈
陛下用群臣,如积薪耳,后来者居上。 ——《史记·汲郑列传》
栈(stack)是一种受限的线性序列,可以将其视为是向量和列表的特例,这章我们利用C++的继承特性简化内部实现
- 只能在**栈顶(top)**插入和删除
- 栈底(bottom)为盲端
栈的一些实际应用:
JVM基于栈结构
网络浏览器多会将用户最近访问过的地址组织为一个栈用户每访问一个新页面,其地址就会被存放至栈顶; 而用户每按下一次“后退”按钮,即可沿相反的次序返回此前刚访问过的页面
和浏览器类似的还有文本编辑器的撤销功能,基于用户的操作记录被依次记录在一个栈中
通过以上应用,我们发现栈是一个典型的**后进先出(LIFO)**的结构
4.1.1 ADT接口
| 操作接口 | 功能 |
|---|---|
size() | 报告栈的规模 |
empty() | 判断栈是否为空 |
push(e),称之为入栈 | 将e插至栈顶 |
pop(),称之为出栈 | 删除栈顶对象 |
top() | 引用栈顶对象 |
4.1.3 Stack模板类
template <typename T>
class Stack : public Vector<T>
{
public:
//入栈,等效于将新元素作为向量末元素插入
void push(T const &e) { this->insert(this->size(), e); } //O(1)
//出栈,等效于将向量末元素删除
T pop() { return this->remove(this->size() - 1); } //O(1)
//取项,直接返回向量末元素
T &top() { return (*this)[this->size() - 1]; } //O(1)
//size(),empty()等开放接口可直接沿用
};
我们知道,向量的remove()与insert()的时间复杂度和操作元素的后缀规模线性相关,而在栈结构中这些操作的时间复杂度显然都应该为O(1),下面给出的继承自list的栈结构的时间复杂度也是O(1)
template <typename T>
class Stack2 : public List<T>
{
public:
void push(T const &e) { this->insertAsLast(e); }
T pop() { return this->remove(this->last()); }
T &top() { return this->last()->data; }
};
由于模板的参数依赖,这里的函数都使用了
this->
4.2 栈与递归
递归可以理解为一种特殊的函数调用(自我调用),在大部分操作系统中,每个运行的二进制程序都配有一个调用栈(call stack)负责跟踪记录同一程序的所有函数之间的调用关系,调用栈的基本单位是帧(frame),每次函数调用都会相应创建一帧,记录该函数实例在二进制程序中的返回地址(return address),以及局部变量、传入参数等, 并将该帧压入调用栈。
若在该函数返回之前又发生新的调用,则同样地要将与新函数对应的一帧压入栈中,成为新的栈顶。函数一旦运行完毕,对应的帧随即弹出,运行控制权将被交还给该函数的上层调用函数,并按照该帧中记录的返回地址确定在二进制程序中继续执行的位置。
系统在后台隐式地维护调用栈的过程中,难以区分哪些参数和变量是对计算过程 有实质作用的,更无法以通用的方式对它们进行优化,因此不得不将描述调用现场的所有参数和 变量悉数入栈。再加上每一帧都必须保存的执行返回地址以及前一帧起始位置,往往导致程序的 空间效率不高甚至极低;同时,隐式的入栈和出栈操作也会令实际的运行时间增加不少。所以要尽量减少递归的大量使用。
4.3 栈的典型应用
- 逆序输出(conversion):输出次序与处理过程颠倒;递归深度和输出长度不易预知
- 递归嵌套:具有自相似性的问题可递归描述,但分支位置和嵌套深度不固定
- 延迟缓冲:线性扫描算法模式中,在预读足够长后,才能确定可处理的前缀
- 栈式计算(RPN):基于栈结构的特定计算模式
4.3.1 逆序输出(应用:进制转换)
这类问题有以下共同特征:
- 虽有明确的算法但是解答以线性序列的形式给出
- 无论递归还是迭代实现,该序列都是依逆序计算输出的,即后计算的先输出
- 输入和输出规模不确定,难以事先确定存放输出数据的容器大小
据以上特征,我们使用栈来解决这类问题恰到好处,例如进制转换
构思
进制转换的方法大致如图所示
最后得到的转换结果为d0d1d2...dm-1dm
实现
void convert(Stack<char> &S, int64_t n, int base)
{
char digit[] = "0123456789ABCDEF"; //数位符号,如有必要可继续扩充
while (0 < n) //由低到高,逐一计算出新进制下的各数位
{
S.push(digit[n % base]); //余数(即当前计算出的数位)入栈
n /= base; //更新n
}
//新进制下由高到低的各数位,在栈中自顶而下保存
}
下面是一个调用demo
main()
{
Stack<char> S;
int64_t n = 9999;
int base = 16;
convert(S, n, base);
while (!S.empty())
printf("%c", S.pop()); //逆序输出
}
复杂度分析
除了循环外的操作时间复杂度均为常数级。
4.3.2 递归嵌套(应用:括号匹配,栈混洗)
括号匹配
我们可以提前进行线性规模的扫描使整个表达式只剩下括号
尝试:由外而内
- 平凡:无括号的表达式是匹配的
- 减治:E匹配**=>**(E)匹配
- 分治:E,F均匹配**=>**E+F匹配
我们发现,(E)匹配和E+F匹配均是必要条件而非充分条件,而我们利用减治和分治来简化问题的方向应该是
- E匹配**<=**(E)匹配
- E,F均匹配**<=**E+F匹配
要使问题简化必须寻找充分性条件
构思:由内而外
颠倒上述思路,我们从消去一对紧邻的左右括号着手
即L()R匹配**=>**L+R匹配
如何找到这对括号?如何使这种问题的简化得以持续进行下去?
顺序扫描表达式,用栈记录已扫描的部分。
凡遇”(“,则进栈;凡遇”)“,则出栈
一趟扫描记录后,如果栈恰好为空,则说明该表达式匹配。
实现
#include "Stack.h"
bool paren(const char exp[], int lo, int hi)
{
Stack<char> S; //使用栈记录已发现但尚未匹配的左括号
for (int i = lo; i < hi; i++) //逐一检查当前字符
if (exp[i] == '(') //遇左括号
S.push(exp[i]); //则push左括号
else if (!S.empty()) //遇右括号且此时栈非空
S.pop(); //则pop刚刚push的左括号
else //遇右括号且栈以空
return false; //则必不匹配,原因是缺少左括号
return S.empty(); //最终,匹配=>栈空,栈非空则不匹配,原因是缺少右括号
}
反思:使用计数器
由上图我们可以发现,只需在扫描表达式过程中使用一个计数器,当遇左括号时加1,遇右括号时减1,扫描完成后计数器为0则该表达式匹配,否则不匹配。这里的计数器其实代表栈的规模,那么为什么要使用较复杂的栈结构呢?
仅使用计数器无法正确判断拓展情况,例如:[ ( ] ),实际不匹配,而计数器判定为匹配。
拓展:多种括号并存的情况
bool parenPro(const char exp[], int lo, int hi)
{
Stack<char> S;
for (int i = lo; i < hi; i++)
{
if (exp[i] == '(' || exp[i] == '[') //如果是左括号
S.push(exp[i]); //则入栈
else if (exp[i] == ')' || exp[i] == ']') //如果是右括号
if (S.top() == exp[i]) //则与栈顶对比
S.pop(); //相等则pop栈顶
else
return false; //不相等则必不匹配
else //其他字符
break; //跳过
return S.empty();
}
}
只需在第6行和第8行的条件中加入更多符号即可实现更多符号的匹配判定
下面是一个调用demo
#include <iostream>
using namespace ::std;
main()
{
char exp[100];
cout << "Enter the expression to be judged (within 100 characters):" << endl;
cin >> exp;
cout << "lo = ";
int lo;
cin >> lo;
cout << "hi = ";
int hi;
cin >> hi;
cout << paren(exp, lo, hi) << endl;
cout << parenPro(exp, hi, lo);
}
栈混洗
概念引入
初始状态:
- A = < a1, a2, a3, a4, ... , an ] (我们约定用
<表示栈顶,用]表示栈底) - B = S = 空集
被允许的操作:
S.push(A.pop)B.push(B.pop)
结束:经过一系列以上被允许的操作后,A中的元素全部转入B中(由图示可知,这里的B栈顶和栈底的方向和A是恰好相反),我们称之为栈混洗(stack permutation)
计数
同一输入序列可以有多种栈混洗,那么长度为n的序列可能的栈混洗总数是?
假定栈S在第k次pop()后再度变空,则k值总共有n种可能,即
通过一些数学知识,我们发现
SP(1) = 1 SP(2) = 2 SP(3) = 5 ... SP(6) = 132 ...
甄别
问题发现:例如 < 1, 2, 3 ] SP(3) = 5,但是全排列为3!= 6,哪种情况不是栈混洗呢?
观察可知,[ 3, 1, 2 > 是非栈混洗,推而广之,任意三个元素能否按某种次序出现在混洗中,与其他元素无关,即:
对于任何1 ≤ i < j < k ≤ n,[ ..., k, ..., i, ..., j, ... > <=> 一定不是栈混洗。
两者互为充要条件。
甄别实现
原理是直接借助A,B,S模拟一次栈混洗的过程,
template <class T>
bool stackWashJudge(Stack<T> A, Stack<T> B)
{
Stack<T> S, R;
//将栈B倒置为R,使R由栈顶到栈底的顺序等同于S->B的出栈顺序
while (!B.empty())
{
R.push(B.pop());
}
while (!A.empty()) //A非空
{
S.push(A.pop()); //将A栈顶删除并push到S
if (S.top() == R.top()) //S和R的栈顶相同
{
//相当于在真正栈混洗过程中,S的栈顶被push到B中
S.pop();
R.pop();
//再判断在真正栈混洗过程中,S的栈顶(在A的栈顶push入S之前)是否连续push入B中
while (!S.empty() && S.top() == R.top())
{
S.pop();
R.pop();
}
}
}
return S.empty(); //S为空则代表B是A的合法栈混洗
}
栈混洗和括号匹配
观察:每一栈混洗都对应于栈S的n次push()和n次pop()操作所构成的序列。
而且,我们发现n个元素的栈混洗其实就等价于n对括号的匹配,push = '(',pop = ')',每个合法的栈混洗序列和每个合法的括号匹配情况一一对应,也即有n个元素有多少种合法栈混洗序列那么n对括号就有多少种合法匹配的情况。
4.3.3 延迟缓冲 [应用:中缀表达式(infix)求值]
Decrease and Conquer
我们可以先执行优先级高的局部计算,并将其替代为数值,随着运算符的不断减少而直至得出最终结果,下图自顶而上举例描述了这一过程:
那么我们如何高效找到可以优先计算的运算符呢?
我们容易发现,要想寻找高优先级的运算符需要扫描,而且运算顺序未必与扫描顺序一致,对于这类问题,同样可以很好地使用栈结构求解。
延迟缓冲策略
仅根据表达式的前缀,不足以确定各运算符的计算次序,只有获得足够的后续信息,才能确定其中哪些运算符可以执行(即分析更长的前缀)
求值算法 = 栈 + 线性扫描
while( 栈顶存在可优先计算的子表达式 )
{
该表达式退栈;
计算其数值;
计算结果入栈;
}
表达式计算结果 = 栈内最终剩余的一个元素;
计算实例:
主算法实现
优先级表格
| 栈顶与当前对比 | + | - | * | / | ^ | ! | ( | ) | \0 |
|---|---|---|---|---|---|---|---|---|---|
+ | > | > | < | < | < | < | < | > | > |
- | > | > | < | < | < | < | < | > | > |
* | > | > | > | > | < | < | < | > | > |
/ | > | > | > | > | < | < | < | > | > |
^ | > | > | > | > | > | < | < | > | > |
! | > | > | > | > | > | > | > | > | |
( | < | < | < | < | < | < | < | = | |
) | |||||||||
\0 | < | < | < | < | < | < | < | = |
空白处解释:栈顶不可能存在
),读取到)后必被pop(),故)所在行为空;
(和)不可能和\0做对比,因为这样意味着缺失)或(,即语法错误其他解释:注意栈顶和当前对比中的
(和),这种优先级分配保证了括号内表达式的优先级最高。一旦(在栈顶,那么无论当前操作符是什么,都会直接入栈,作为回报,一旦(是当前运算符,那么无论栈顶操作符是什么,都会直接接纳(入栈。
#define N_OPTR 9 //运算符总数
const char pri[N_OPTR][N_OPTR] = {
//运算符优先等级 [栈顶] [当前]
/* |------------------- 当前运算符 ------------------| */
/* + - * / ^ ! ( ) \0 */
/* -- + */ '>', '>', '<', '<', '<', '<', '<', '>', '>',
/* | - */ '>', '>', '<', '<', '<', '<', '<', '>', '>',
/* 栈 * */ '>', '>', '>', '>', '<', '<', '<', '>', '>',
/* 顶 / */ '>', '>', '>', '>', '<', '<', '<', '>', '>',
/* 运 ^ */ '>', '>', '>', '>', '>', '<', '<', '>', '>',
/* 算 ! */ '>', '>', '>', '>', '>', '>', ' ', '>', '>',
/* 符 ( */ '<', '<', '<', '<', '<', '<', '<', '=', ' ',
/* | ) */ ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ',
/* - \0 */ '<', '<', '<', '<', '<', '<', '<', ' ', '='};
float evalute(char *S, char *&RPN)
{
Stack<float> opnd; //运算数栈
Stack<char> optr; //运算符栈
optr.push('\0'); //尾哨兵先入栈
while (!optr.empty()) //逐个处理各字符,直到运算符栈空
{
if (isdigit(*S)) //若是操作数
{
readNumber(S, opnd); //读入操作数,readNumber的作用是读入小数,多位数等
append(RPN, opnd.top()); //将其接至RPN末尾
}
else //若是运算符
switch (orderBetween(optr.top(), *S)) //orderBetween的作用是比较栈顶和当前运算符的优先级
{
//视其与栈顶运算符之间的优先级高低分别处理
case '<': //栈顶运算符优先级更低
optr.push(*S); //计算推迟,当前运算符进栈
S++; //将字符地址移至下一位
break;
case '=': //优先级相等
//(左右括号相遇,这时括号内已经计算完成,删去括号即可)
optr.pop(); //脱括号并接收下一个字符
S++;
break;
case '>': //栈顶运算符优先级更高
//实施相应运算并将结果重新入栈
{ //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)
Reverse Polish Notation
作为一种数学表达式,其语法规则可概括为:操作符紧邻与对应的(最后一个)操作数之后按此规则递归可得到复杂的表达式,根据其特点,也可称之为后缀表达式(postfix)。例如:
作为补偿,必须额外引入一个起分隔作用的元字符(这里采用了空格)
相对于中缀表达式,RPN表达式的可读性不强,但是在对运算符优先级的表示和计算效率方面有很大优势。
举个栗子
0 ! 1 + 2 3 ^ 4 ! - 5 ! 6 / - 7 * 8 * - 9 -
手动转换
因为RPN在表达式求值方面效率更高且兼具简洁性,因此,我们在求值时不妨将常见的infix转换到postfix在进行求值。
例如:( 0 ! + 1 ) ^ ( 2 * 3 ! + 4 - 5)
1.用括号显式地表示优先级
{ ( [ 0 ! ] + 1 ) ^ ( [ ( 2 * [ 3 ! ] ) + 4 ] - 5 ) }
2.将运算符移到对应的右括号后
{ ( [ 0 ] ! 1 ) + ( [ ( 2 [ 3 ] ! ) * 4 ] + 5 ) - } ^
注意:这里的^对应的括号是最外层的{},这个移位的过程中可能发生运算符位置的颠倒
3.去掉所有括号
0 ! 1 + 2 3 ! * 4 + 5 - ^
4.稍作整理
0 ! 1 + 2 3 ! * 4 + 5 - ^即为对应的RPN表达式
邓老师强调:注意这些操作数这这些过程中没有发生位置改变,次序和初始时一致!!!
算法实现
我们将RPN的转换和中缀表达式的求值写在了同一段代码中,现在是时候解释这些代码的含义了:
if (isdigit(*S)) //若是操作数
{
readNumber(S, opnd);
append(RPN, opnd.top()); //将其接至RPN末尾
}
这里我们容易发现,每读入一个操作数就立刻将其连接至RPN末尾,这保证了操作数的相对位置不会发生变化。
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;
}
这里仅仅在栈顶优先级高时才有将操作符连接着RPN末尾的行为,这实际对应第2步,即将运算符移到对应的右括号后。优先级越高的操作符往往被先连接在RPN末尾,这保证了运算优先级。
4.4 *试探回溯法
4.5 队列
特点与接口
队列(queue)也是受限的序列
- 只能在队尾插入,对应接口
enqueue() - 只能在队头删除(查询并返回引用),对应接口
dequeue(),front()
先进先出FIFO,后进后出LILO
实现
template <typename T>
class Queue : public List<T>
{
public: //size()和empty()直接沿用即可
void enqueue(T const &e) { insertAsLast(e); } //入队
T dequeue() { return remove(fist()); } //出队
T &front() { return first()->data; } //队首
};
我们容易发现以上接口的时间复杂度都为常数