Files
912-notes/thu_dsa/chp4/chp4.md
2019-10-23 15:47:50 +08:00

210 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
Conclusion on Chapter Four: Stack & Queue
=========================================
## 知识脉络梳理
本章主要是讨论了栈(和队列)。借助前面的`Vector`或者`List`,都可以比较轻易地实现两种数据结构,只不过在一些细节上需要在意。栈和队列的基本操作都只需要`O(1)`的时间,它们更多是作为算法设计的基本出发点,来构造更为复杂的算法。因此本章主要是讨论两种数据结构的应用。
为了用栈或者队列构造其他算法,关键在于把握它们的`LIFO`(栈)或者`FIFO`(队列)的特性。利用栈的特性,可以实现`逆序输出``递归嵌套``延迟缓冲`以及`试探回溯`的算法。而队列在模拟现实生活中的排队现象中比较有用武之地。
针对`递归嵌套`问题,本章描述了两个具体的应用,即括号匹配问题与栈混洗问题,这里的关键在于把握两个问题内在的联系,对于两个问题都需要有比较深刻的认识。而`试探回溯`问题则代表了一类算法设计思想:尽量利用问题自身的特性进行剪枝,从而更加高效地找到算法的解,这里的关键在于把握`准绳``粉笔`两个工具。
## 栈的实现
栈的实现用`Vector`或者用`List`都是无所谓的,直接一个`public`的继承就可以了。想说的是如果用`Vector`实现的话注意一下栈顶和栈尾的选择,把栈尾选到`Vector`的起始处,这样压栈和弹栈操作就都是在`Vector`的末端操作,时间复杂度都是$O(1)$。
## 栈的应用
我觉得栈的核心就在于它的后入先出(LIFO)特性。栈的应用也都是基于这个特性而展开。
### 逆序输出
一个典型的应用就是逆序输出。所谓逆序输出,其实就是先得到的结果不能立即输出,需要等到后面的结果都输出完毕以后,才能输出当前的值,这和栈后入先出的特性完全一致,所以栈在逆序输出方面颇有应用。
> 逆序输出的实例。
比如说十进制整数转字符串的算法。对于整数`(int)123`,要把它转化成字符的`(char*)"123"`,由于每次只能获取该整数的最低位,但低位只有在高位输出以后才能输出,因此使用一个栈会比较方便。
另一个应用是进制转换问题。比如说将十进制整数转换成k任意进制整数。算法采用<除k取余法>,但是首先得到的余数要在后面的余数输出以后才能输出,所以也采用栈结构实现。进制转换的代码如下,该代码中转换的结果用字符串表示。
```cpp
char* convert(__int64 n, int base){
Stack<char> charStack;
while(n != 0){
charStack.push(digits[n % base]);
n /= base;
}
char* res = new char[charStack.getSize() + 1];
int pos = 0;
while (!charStack.empty()) res[pos++] = charStack.pop();
res[pos] = '\0';
return res;
}
```
### 递归嵌套
递归嵌套的问题是指某个问题的局部和整体具有某种相似性,而该问题作为整体问题的分支其位置与递归的深度又不可确定。例如下面要说的括号匹配和栈混洗就是这样一个例子。按照邓公所说,栈天生具有递归嵌套性(反正我是不懂什么意思),因此可以用来解决这类问题。
> 括号匹配问题。
首先考虑一下括号匹配问题。我们可以轻易地概括出括号匹配问题的一些性质:
+ 若`P`匹配,则`(P)`也匹配
+ 若`P1``P2`均匹配,则`P1 P2`也匹配
其中,第一条对应了减而治之策略,第二条对应了分而治之策略。
然而,这两条性质都不容易直接应用。究其原因,这两条都是必要条件,并不能直接判断原表达式`P`是否匹配。例如针对第一条,可以找到`()()`匹配,但是`)(`不匹配的反例。针对第二条,可以找到`(()())`匹配,但是`(()``())`均不匹配的反例。
所以,必须找到一条充分条件。
> 括号匹配充分条件。
设原表达式`P`由两部分`L``R`组成,`L () R`匹配可以推出`L R`匹配。
利用这种性质就可以设计出算法了,关键在于找到原表达式最内部那对括号,然后删除之,进而减而治之地将算法进行下去。为了找到最内部的括号,就需要优先遍历外部的括号,从这个角度来看,我觉得这里的栈是起到延迟缓冲的作用。
代码非常简单,放在下面,凑点字数。
```cpp
bool paren(const char exp[]){
Stack<char> parenStack;
char currParen;
for(int ix = 0; (currParen = exp[ix]) != '\0'; ++ix){
switch(currParen){
case '(':
case '[':
case '{':
parenStack.push(currParen);
break;
case ')':
if (parenStack.empty()) return false;
if (parenStack.pop() != '(') return false;
break;
case ']':
if (parenStack.empty()) return false;
if (parenStack.pop() != '[') return false;
break;
case '}':
if (parenStack.empty()) return false;
if (parenStack.pop() != '{') return false;
break;
}
}
return parenStack.empty()? true : false;
}
```
可以注意到,对于只有一种类型括号(`()`, `[]`, `{}`, `<head></head>`)的情况,其实只需要满足左括号数量总是大于右括号就可以了。但是在多种括号的情况下,这个性质就不成立了,例如`[(])`并不是一个匹配的表达式。
> 栈混洗(`stack permutation`)问题。
说的是有三个栈`A`, `B`, `S`,其中`B``S`为空,`A`中含有n个元素。可以写作$A = < a_1, a_2, ..., a_n]$,这里`<`表示栈顶,`]`表示栈底。
现在要通过一系列的出栈、入栈操作,把`A`中的所有元素通过`S`,转移到`B`中。将B写作$< a_{k_1}, a_{k_2}, ..., a_{k_n}]$。把这样的一个序列就称作对原输入序列的一个栈混洗。
> 合法栈混洗的数量。
根据上面的描述可以看出,栈混洗只需要满足唯一一个条件:出栈时栈不得为空。只要满足该条件的一个序列都是一个合法的栈混洗。这个和上面描述的括号匹配问题是不是感觉有一点相似啊?其实它们之间本质就是一样的,一个入栈操作可以看做是一个左括号,一个出栈操作可以看做是一个右括号,合法的栈混洗要求入栈操作总是大于等于出栈操作,匹配的括号表达式要求左括号数量总是大于等于右括号。
可以通过递推表达式或者排列组合的方法来得到栈混洗的数量。关于这个可以参考[栈混洗的数量](https://www.cnblogs.com/jiayouwyhit/p/3222973.html)。从结论上来说,所有的操作数量有$C_{2n}^{n}$个,其中合法的操作数有$C_{2n} - C_{2n}^{n-1}$个。
> 合法栈混洗的快速判断。
经常会有这种问题,给你一个出栈序列,需要你判断它是否是合法的。慢慢模拟吧来的太慢了,没有耐心。所以需要开发出一些快速的方法。
一种方法是出栈序列中元素i之后比其小的元素必须是降序排列的。这是比较容易理解的因为在出栈序列中排列在元素i之后且比i小的元素在元素i出栈时必然已经入栈了并且在元素i出栈时仍然滞留在栈中他们之后的出栈次序当然是递减的。具体可以看[这篇](https://blog.csdn.net/Xiao__Tian__/article/details/51111632)。
另一种方法是我邓公的方法。合法栈混洗的充要条件是它的出栈序列不含`312`模式。这个条件的必要性是显然的,因为`312`是三个元素栈混洗中唯一不合法的那个。但是充分性的证明好像有点复杂,至今不会......
要是利用代码解决这个问题的话,最好还是采用利用栈模拟它的整个过程,这样每个元素入栈一次出栈一次,时间复杂度只有$O(n)$。
### 延迟缓冲
> 中缀表达式的求值问题
这个问题主要是有一个运算符之间的优先级问题,低优先级的运算符只能在相邻高优先级运算法运算完毕之后才能进行,即使它排在前面。那么这里就体现出一个明显的后入先出的特性,栈在这里起到延迟缓冲的作用。
问题的实现方面,首先肯定需要一个运算符栈,因此就是它需要延迟缓冲。为了确定各个运算符之间的优先级,还需要建立一个优先级表。这里还要考虑一些特殊的情况:
+ 括号的处理。括号可以强行改变运算符之间的优先级。例如左括号`(`作为新来的运算符时,其优先级高于所有运算符。但作为栈顶的运算符时,其优先级小于所有运算符。而右括号`)`永远不会入栈,其优先级也小于所有运算符,但是等于左括号。
+ 原表达式扫描完毕的处理。往往会有这种情况,即原表达式扫描完毕了,但是栈中还有没有完成计算的运算符。为了处理这种情况,可以将结束符`\0`也添加到优先级表中,它的优先级小于所有其他运算符,直到它遇到人为添加到栈底的`\0`,栈空后,计算结束。
此外,对于操作数,也需要建立一个操作数栈。因为相关的操作数也需要其对应的运算符获得了运算的权限才能计算,这个栈也是起到延迟缓冲的作用。
根据上面的描述,给出中缀表达式计算的代码:
```cpp
//pri[stack top optr][curr optr]
const char pri[NOPTR][NOPTR] = {
/* + - * / ^ ! ( ) \0 */
/* + */ '>', '>', '<', '<', '<', '<', '<', '>', '>',
/* - */ '>', '>', '<', '<', '<', '<', '<', '>', '>',
/* * */ '>', '>', '>', '>', '<', '<', '<', '>', '>',
/* / */ '>', '>', '>', '>', '<', '<', '<', '>', '>',
/* ^ */ '>', '>', '>', '>', '>', '<', '<', '>', '>',
/* ! */ '>', '>', '>', '>', '>', '>', '<', '>', '>',
/* ( */ '<', '<', '<', '<', '<', '<', '<', '=', ' ',
/* ) */ ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ',
/* \0 */ '<' ,'<', '<', '<', '<', '<', '<', ' ', '=' };
/*
* @brief : to compute the value of an infix expression
* @args : infixExpr
* @return: return the value in double
* @others: only consider valid infix expression
*/
double evaluate(char* infixExpr){
Stack<double> opnd;
Stack<char> optr;
double opnd1, opnd2;
optr.push('\0');
while(!optr.empty()){
if (isdigit(*infixExpr)) opnd.push(readNumber(infixExpr));
else{
if (*infixExpr == ' ' || *infixExpr == '\t') { // skip white spaces
++infixExpr;
continue;
}
switch(orderBetween(optr.top(), *infixExpr)){
case '>':
if (optr.top() == '!') opnd.push(cal(optr.pop(), opnd.pop()));
else{
opnd2 = opnd.pop();
opnd1 = opnd.pop();
opnd.push(cal(opnd1, optr.pop(), opnd2));
}
break;
case '<':
optr.push(*infixExpr++);
break;
case '=':
infixExpr++;
optr.pop();
break;
}
}
}
return opnd.pop();
}
```
> 中缀表达式转后缀表达式
其实和中缀求值是一个问题,都需要考虑运算符之间的优先级。但是由于这里不用运算,就只需要一个操作符栈就可以了。得到的后缀表达式自身可以蕴含运算符的优先级信息,或者说没有优先级,直接按顺序计算就可以了。
因此后缀表达式的计算只需要一个操作数栈,因为操作符已经没有优先级了。
## 队列的实现
其实和栈的实现是一样的,只是由于需要双边操作,最好还是用一个`List`作为底层数据结构,才能保证所有操作都是$O(1)$的时间复杂度。
队列在后面的其他数据结构中有一些应用,比如树的层序遍历。