数据结构与算法(四)——栈和队列
数据结构与算法(四)——栈和队列
iwehdio的博客园:https://www.cnblogs.com/iwehdio/
Github:https://github.com/iwehdio/DSA_THU_DJH_asJava
1、栈
-
栈(stack)是由一组元素组成的受限的线性序列。
-
在一个时刻只有一个元素可以访问,而且这个元素是在栈的顶端(top),另一端成为盲端(bottom)。
-
对元素的插入(push)和取出(pop)只能在栈的顶部进行。
-
由于栈的特性,后入栈的先出栈。
-
-
栈属于序列的特例,可以直接基于向量或列表派生。实现:
template <typemname T> class stack public Vector<T>{ //继承Vector,可直接使用Vector的方法 public: void push(T const &e){ //入栈 insert(size(), e); } T pop(){ //出栈 return remove(size() - 1); } T top(){ //取栈顶元素 return (*this)[size() - 1]; } }
2、队列
-
队列(queue)是由一组元素组成的受限的线性序列。
- 在一个时刻,只能在队尾插入(入队):enqueue() 。
- 同时,也只能在队头删除(出队):dequeue() 。
- 查询队头 rear() 和查询队尾 front() 。
- 队列的特性是,先进先出,后进后出。
-
实现:可以基于向量或列表派生。
template <typename T> class Queue: public List<T>{ //继承自列表 public: void enqueue(T const &e){ //入队 insertAsLast(e); } T dequeue(){ //出队 return remove(first()); } T &front(){ //队首 return first()->data; } }
3、栈的应用
-
典型应用场合:逆序输出、递归嵌套、延迟缓冲和栈式计算。
-
进制转换 可作为逆序输出的实例:
-
对所要转换的数字做短除法,将结果由底而上的拼接起来。
-
实现:
void convert(Stack<char> &s, _int64 n, int base){ static char digit[] = {'0','1','2','3','4','5','6','7','8','9','A','B','C','D','E','F'}; //十六进制以内的符号 while(n > 0){ s.push(digit[n % base]); //余数入栈 n /= base; //n更新为商 } } main(){ Stack<char> S; convert(S, n, base); while(!S.empty()){ //逆序输出 printf("%c", S.pop()); } }
-
-
括号匹配 可作为递归嵌套的实例:
-
简化忽略除了括号外的其他字符。
-
平凡:无括号的表达式是匹配的。
-
减而治之:如果表达式 E 匹配,那么表达式 ( E ) 匹配。分而治之:如果表达式 E 和 F 匹配,那么 EF 匹配。但是减而治之和分而治之只提供了必要性,而问题的有效简化需要充分性。
-
如果颠倒思路,如果表达式 L()R 匹配,那么表达式 LR 匹配。(注意推理方向和字符串长度与减而治之和分而治之的区别)
-
顺序扫描表达式,用栈记录已经扫描的部分。如果遇到左括号 ( 则入栈,如果遇到右括号 ) 则将弹出一个栈顶的左括号(栈中只存储左括号)。如果字符串扫描完后栈不为空,或字符串未扫描完却向空栈请求弹出,则括号不匹配。
-
实现:
bool paren(const char exp[], int lo, int hi){ Stack<char> S; for(int i=li; i<hi; i++){ if(exp[i] == '('){ S.push(exp[i]); } else if(!S.empty()){ //遇右括号先判断栈是否已空 S.pop(); } else { return false; } } return S.empty(); //字符串匹配完毕时栈是否为空 }
-
如果只有一种括号,借用栈的思想,我们可以用一个计数器 n (初始化为0,实际上记录的是栈的规模)。从左到右扫描,遇到左括号加一,遇到右括号减一,如果过程中为负或者最后不为0则不匹配。
-
如果有多种括号,仍然可以用栈的思想(匹配相同类型的栈顶左括号),但是多个计数器无法完成实现。
-
-
栈混洗 :对栈中的元素进行重新排列的一种方式。
- 限制条件:将栈 A 中的元素混洗至栈 B 中。设置一个中转站栈 S 。只允许两种操作,将 A 的顶元素弹出并压入 S ,或将 S 的顶元素弹出并压入 B 。这样将 A 中的元素全部转入 B 中,B 成为 A的一个栈混洗。
- 对于长度为 n 的栈的输入序列,可能得到的栈混洗种类数 SP(n) 为卡特兰数
SP(n)=catalan(n)=(2n)!/(n+1)!/n!
。- 对于栈 A 中的初始顶元素 m,其可能是第 k 个压入栈 B 的元素,而且此时栈 S 为空。
- 此时栈 B 中除 m 外有 k - 1 个元素,栈 A 中有 n - k 个元素。两者的栈混洗种类数相互独立,且当 k 为定值时可得到
SP(k-1)*SP(n-k)
种栈混洗。 - 则对所有 k 的取值求和,得到递推式:
SP(n)=sigma k(1<=k<=n)(SP(k-1)*SP(n-k))
。
- 如何甄别输入序列的任一序列是否为栈混洗:
- 对于任意三个元素是否能按某相对次序出现在混洗中,与其他元素无关。
- 对于栈 A 中任意位置的三个元素 < ... i, j ,k ... ] ,存在顺序 [ ... k, i, j ... > 的,一定不是栈混洗。时间复杂度 O(n^3)。
- 可以证明这个条件是一个充要条件。
- 事实上可以得到一个 O(n) 的算法。即模拟混洗过程。即每次将栈 B 中所要压入的下一个元素,必须将其置为 S 中的顶元素,否则无法混洗为目标序列。
- 每次 S 出栈之前,检测 S 是否为空,或者所要弹出的元素在 S 中但不是顶元素。
- 每一栈混洗都对应于栈 S 的 n 次 push 和 n 次 pop 操作。这与括号匹配的算法是一致的。也就是说,规模为 n 的栈每个栈混洗操作序列都对应于 n 个括号的一种匹配方式。规模为 n 的栈的栈混洗种类数 SP(n) 就等于 n 个括号的匹配方式数。
-
中缀表达式 可作为延迟缓冲的实例:
-
是一个通用的算术或逻辑公式表示方法, 操作符是以中缀形式处于操作数的中间。
-
使用运算数和运算符栈分别存储表达式中的运算数与运算符。
-
使用一个数组存储不同运算符的优先级。
-
实现:
float evaluate(char* S){ //指针S指向所要进行运算的中缀表达式 Stack<char> opnd; //运算数栈 Stack<char> optr; //运算符栈 optr.push('\0'); //运算符的头尾都有哨兵'\0' while(!optr.empty()){ //运算我栈为空时结束,只有到达尾哨兵'\0'后才会清空 if(isdigit(*S)){ //如果指向的是数字,则压入运算数栈 /*readNumber()是将运算数压入运算数栈,同时要处理多位数字的情况,此时需要先将前一位数字弹栈然后乘十后加上后一位数字再入栈*/ readNumber(S, opnd); } else { //如果指向的是运算符,则需要判断当前运算符与运算符栈栈顶元素的优先级 //orderBetween()是利用制好的表比较运算符的优先级 switch(orderBetween(optr.top(), *S)){ case '<': //如果栈顶的运算符优先级低,则将新运算符入栈,指针后移 optr.push(*S); S++; break; case '=': /*如果优先级相等(在目前定义的运算符中只会出现在左右括号和左右哨兵的情况下),左右括号的功能已经完成,将左括号弹栈并将指针后移跨过右括号*/ optr.pop(); S++; break; case '>': /*如果栈顶的运算符优先级高,则先将指针停在此时指向的运算符不动。注意:对于栈顶的运算符优先级高的情况,指针指向的位置不变。*/ char op = optr.pop(); //首先将运算符栈顶弹出 if('!' == op){ /*根据是一元还是二元运算符,从运算数栈中弹出一个或两个运算数,通过calca()的重载方法进行计算*/ opnd.push(calcu(op, opnd.pop())); } else { float pOpnd2 = opnd.pop(), Popnd1 = opnd.pop(); opnd.push(calcu(pOpnd1, op, pOpnd2)); } break; } } } return opnd.pop(); //最后运算数栈中的唯一一个元素就是结果 }
-
-
逆波兰表达式(RPN):
-
在由运算符和操作数组成的表达式中,不使用括号也不需要使用约定的优先级关系,即可表示带优先级的运算关系。使用运算符的位置表征优先级。
-
对于运算数,直接入栈。不分运算符和运算数,只需要一个栈。
-
对于运算符,需要几个操作数,则从栈中取出几个操作数进行运算,并将结果入栈。
-
手动从中缀表达式转换为逆波兰表达式:
- 用括号显式的表达所有运算符的优先级。
- 将运算符移到所对应的右括号之后。
- 抹去所有括号并整理。
- 运算符的相对位置可能会发生变化,但运算数的位置不会发生变化。
-
从中缀表达式转换为逆波兰表达式的实现:可以通过之前中缀表达式的实现。
float infix2postfix(char* S, char* &RPN){ //输入S转换为RPN Stack<char> opnd; Stack<char> optr; optr.push('\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 '=': optr.pop(); S++; break; case '>': char op = optr.pop(); append(RPN, op); //运算符接入RPN if('!' == op){ opnd.push(calcu(op, opnd.pop())); } else { float pOpnd2 = opnd.pop(), Popnd1 = opnd.pop(); opnd.push(calcu(pOpnd1, op, pOpnd2)); } break; } } } return opnd.pop(); //最后运算数栈中的唯一一个元素就是结果 }
因为 RPN 的思想就是,把运算符放置在可以直接用之前的运算数进行运算时的位置。所以在原本中缀表达式的算法中,在满足运算符运算条件时,就是该运算符在 RPN 中的位置。
-
参考:数据结构与算法(清华大学C++描述):https://www.bilibili.com/video/av49361421
iwehdio的博客园:https://www.cnblogs.com/iwehdio/