最近正在学清华大学的那门DSA,从现在开始就把学习笔记,以及过程中感悟、遇到的问题、解决方法都记录在博客里。就从今天学习的开始记录吧,之前用word记录的学习笔记后面有时间再慢慢往博客上面搬
栈,stack,线性序列,但只能访问栈中的特定元素(栈的一端),其中开放的一端称为栈顶(top),不开放的另一端称为栈底(bottom)。
入栈——将元素作为最顶部的元素插入:push
出栈——将栈顶元素取出:pop
查询但不删除栈顶元素数值:top
下图片摘自清华大学的《数据结构(C++语言版)》
栈中元素遵循着**后入先出(Last in first out,LIFO)**的规则。联想“汉诺塔问题”~~
操作接口 | 功能 |
---|---|
size() | 获取栈的规模 |
empty() | 判断栈是否为空 |
push(e) | 将e插入栈顶 |
pop() | 弹出并返回栈顶对象 |
top() | 引用栈顶对象 |
栈属于序列受限后的特例,故可直接基于向量或列表派生出栈。书中以向量为例派生了栈,并把以列表派生栈留为了课后作业。下面分别记录两种方法派生栈类。
向量派生栈:
将向量末端视为栈顶,向量首端视为栈底。可沿用向量模板类所拥有的插入和删除方法,从而只需另写栈所特有的方法即可。
将向量末端视为栈顶的好处是使得入栈和出栈的操作时间都是常数时间O(1)
教材代码:
template <typename T>
class Stack: public Vector<T>
{
//将向量的首/末端作为栈底/顶
public: //size()、empty()以及其它开放接口,均可直接沿用
void push ( T const& e ) {
insert ( e ); } //入栈:等效于将新元素作为向量的末元素插入
T pop() {
return remove ( size() - 1 ); } //出栈:等效于删除向量的末元素
T& top() {
return ( *this ) [size() - 1]; } //取顶:直接返回向量的末元素
};
列表派生栈
自己尝试着用列表来派生栈类。虽然列表的插入和删除的时间复杂度都是O(1),为了和向量统一,就也将列表末元素作为栈顶
自己对照向量派生栈写的列表派生栈代码:
template <typename T>
class Stack :public List<T>
{
public:
void push(const T & e) {
insertAsLast(e); }
T pop() {
return remove(last()); }
T& top() {
return last()->data; }
};
栈的结构让我想起了汉诺塔问题,后面尝试一下写汉诺塔的算法再贴上来。
栈的典型应用场合之一:逆序输出。输出次序与处理过程颠倒;递归深度和输出长度不易预知
短除法,倒序取余。计算过程自上而下,输出过程却自下而上,即后入先出。由此,栈的功能完美契合。
具体过程:引入一个栈,在计算过程中每得到一个数位遍push进栈中,一旦计算终止,就通过一系列的pop将计算结果输出出来,从而得到进制转换后的结果。
教材代码
void convert(Stack<char>& S, __int64 n, int base) {
//十进制整数n到base进制的转换(迭代版)
char digit[] = "0123456789ABCDEF"; //数位符号,如有必要可相应扩充
while (n > 0) {
//由低到高,逐一计算出新进制下的各数位
S.push(digit[n % base]); //余数(当前位)入栈
n /= base; //n更新为其对base的除商
}
} //新进制下由高到低的各数位,自顶而下保存于栈S中
int main()
{
Stack<char> S; //用栈记录转换得到的各数位
convert(S, n, base); //进制转换
while (!S.empty())
printf("%c", (S.pop())); //逆序输出栈内数位,即正确结果
reutrn 0;
}
以上代码中通过一个字符数组digit来标示新进制下的数位符号,之所以用char而不用int直接标示,是因为当进制扩充到10以上,如16进制时,新进制下的数位符号不止有数字还有字母,所以直接以字符数组来标识,更方便。
递归嵌套:局部和整体具有自相似性的问题可递归描述,但分支位置和嵌套深度不固定
括号匹配,即对源代码语法检查,判断其中括号在嵌套的意义下是否完全匹配。
消去一对紧邻的左右括号,不会影响全局的匹配判断。因此借助栈,顺序扫描表达式,若遇到左括号“(”则入栈,遇到右括号“)”则将一个紧邻(栈顶)左括号出栈(即实际只需记录左括号)。这样反复迭代,当且仅当最后一个括号(右括号)被处理之后整个栈恰好变空,则原表达式是匹配的;反之无论是栈提前变空或到最后仍为飞空,则说明表达式括号是不匹配的。
对于只有一种括号的匹配情形,使用栈或者计数器均可进行匹配检测。那么对于处理多种括号的匹配检测,可否使用多个计数器呢?不行,反例: [ ( ] )
故,通过栈处理多种括号并存时的情况时,只需多添加一条判定:即遇到右括号时弹出的栈顶左括号是否匹配,若不匹配则仍匹配失败。
进一步拓展:实际上只需约定“括号”的通用格式,而不必固定括号的类型和数目
教材代码:
bool paren(const char exp[], int lo, int hi) {
//表达式括号匹配检查,可兼顾三种括号
Stack<char> S; //使用栈记录已发现但尚未匹配的左括号
for (int i = lo; i <= hi; 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; //非括号字符一律忽略
}
displayProgress(exp, i, S);
}
return S.empty(); //整个表达式扫描过后,栈中若仍残留(左)括号,则不匹配;否则(栈空)匹配
}
栈混洗(stack permutation):按照某种约定的规则,对栈中的元素进行重新排列,转入另一个初始为空的栈中
对原始栈A中的元素,通过借助中间栈B,按照某种规则,进行唯二的两种操作,获得新排列的栈C:
将A的栈顶元素弹出并压入B:B.push(A.pop())
将B的栈顶元素弹出并压入C:C.push(B.pop())
不同的混洗规则(即不同的上述两种操作的组合),能够导致不同的栈混洗结果。显然,栈混洗的总数SP(n)<=n!
假设这样的过程:第1号元素先转移到B中后,后续k-1个元素通过B转移到C中,然后1号元素转移到C中,此时B为空,且C中最靠底的k-1个元素和A中剩余的n-k个元素它们的栈混洗实际上是相互独立的。因此,对于第1号元素被作为第k个元素推入C中的情况所对应的栈混洗总数就应该是这两个相互独立的子序列所各自对应的栈混洗总数的乘积,而对于1号元素来说,k所有可能的取值所对应的栈混洗的总和就是要计算的栈混洗总数,即:
S P ( n ) = ∑ i = 0 N S P ( k − 1 ) × S P ( n − k ) SP(n)=\sum_{i=0}^NSP(k-1)\times SP(n-k) SP(n)=∑i=0NSP(k−1)×SP(n−k)
考虑平凡情况: S P ( 1 ) = 1 SP(1)=1 SP(1)=1
则该递推式的解正好是著名的catalan数:
S P ( n ) = ∑ i = 0 N S P ( k − 1 ) × S P ( n − k ) = C a t a l a n ( n ) = ( 2 n ) ! ( n + 1 ) ! n ! SP(n)=\sum_{i=0}^NSP(k-1)\times SP(n-k)=Catalan(n)=\frac{(2n)!}{(n+1)!n!} SP(n)=∑i=0NSP(k−1)×SP(n−k)=Catalan(n)=(n+1)!n!(2n)!
补充: C a t a l a n ( n ) = C 2 n n − C 2 n n + 1 = C 2 n n n + 1 = ( 2 n ) ! ( n + 1 ) ! n ! Catalan(n)=C_{2n}^n-C_{2n}^{n+1}=\frac{C_{2n}^n}{n+1}=\frac{(2n)!}{(n+1)!n!} Catalan(n)=C2nn−C2nn+1=n+1C2nn=(n+1)!n!(2n)!
典型例子:3,1,2
任意三个元素能否按照某种相对次序出现于混洗中,与其他元素无关。
推而广之对于任何三个互异整数: 1 ≤ i ≤ j ≤ k ≤ k 1\leq i \leq j\leq k\leq k 1≤i≤j≤k≤k,如果在某个排列中出现了: . . . k . . . i . . . j . . . ...k...i...j... ...k...i...j...,则必不是栈混洗。这是栈混洗必须禁止的特征,称为禁形。且这种禁形是栈混洗的充要条件
直接借助栈A、B、C,模拟栈混洗过程,以一种验证的方式来判别某种排列是不是栈混洗。若能成功将所有的元素顺利地转移,则为栈混洗;若每次pop之前,B已空,或B非空所需元素并非栈顶元素,则该排列不是栈混洗。
书中和慕课中都没有给出具体的栈混洗算法代码,而是留为了课后作业。因此那稍后我自己写一个,不知道是否完全正确,路过的大佬们可以帮我看看
每一栈混洗,都对应于中间栈B的n次push和pop操作构成的序列
n个元素的栈混洗有多少种,n对括号所能构成的合法表达式也就有多少种。二者之间存在一一对应关系。