数据结构应用 - 栈
1.栈与递归
函数调用:运行的二进制程序都有一个调用栈或执行栈,借助调用栈可以跟踪属于同一程序的所有函数,记录它们之间的相互调用关系。
调用栈:调用栈的基本单位是帧,每次函数调用时,就会创建相应的帧,该帧记录了该函数实例在二进制程序中的返回地址、局部变量、传入参数等,然后将该帧压入栈中。
函数运行完成时,将相应的帧弹出栈。控制权将被交给该调用该函数的上层函数,并按照该帧记录的返回地址(上层函数的地址)确定继续执行的位置。
当main函数被弹出栈时,程序运行结束。
栈与递归:同一函数可以同时拥有多个实例,当函数递归(自己调用自己),系统也会在调用栈中压入新的一帧,这些帧的结构相同,但其中的参数和变量虽然同名,值却各自独立。
再深入就将涉及到汇编和编译原理,不再做讨论。
2.逆序输出
栈后进先出的性质其实符合很多事物的规律。如果某个算法的实现需要后进先出的性质,就可以借助栈。
进制转换:十进制数转换到其他进制数最基本的思路是使用短除法,然后将每一步得到的余数从低位到高位排列起来即可。
从低位到高位,就是最后得到的余数写在最前面,这和栈后进先出的性质是符合的。
递归实现:
1 void convert(Stack<char> &S, __int64 n, int base) 2 { 3 static char digit[] //0 < n, 1 < base <= 16,新进制下的数位符号,可视base取值范围适当扩充 4 = {'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F'}; 5 if (0 < n)//直到没有余数为止 6 { 7 S.push(digits[n & base]);//逆向记录当前最低位 8 convert(S, n / base, base);//通过递归得到所有更高位 9 } 10 }
迭代实现:
1 void convert(Stack <char> &S, __int64 n, int base) 2 { //十进制数n到base进制的转换(迭代版) 3 static char digit[] //0 < n, 1 < base <= 16,新进制下的数位符号,可视base取值范围适当扩充 4 = {'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F'}; 5 while (n > 0) 6 { //由低到高,逐一计算出新进制下的各数位 7 int remainder = (int)(n % base); 8 S.push(digit[remainder]); //余数(当前位)入栈 9 /*DSA*/ printf("%20I64d =", n); 10 n /= base; //n更新为其对base的除商 11 /*DSA*/ printf("%20I64d * %d + %d\n", n, base, remainder); 12 /*DSA*/ print(S); 13 getchar(); 14 } 15 } //新进制下由高到低的各数位,自顶而下保存于栈S中
3.递归嵌套
栈混洗:给定三个栈A, B, S。其中B, S初始为空,A含有n个元素,自顶向底构成序列:A=<a1, a2, ..., an]。
现只允许做以下两种操作(保证pop时栈不空,即不发生下溢):
1.S.push(A.pop())
2.B.push(S.pop())
经过n次操作后,A, S均为空,A中元素转入B中,此时B中的n个元素自底向顶构成的序列B=[ak1, ak2, ..., akn> 称为原序列的一个栈混洗(stack permutation)。
简而言之,顾名思义,也就是将两个已有栈像洗牌一样打乱,形成的新的栈就叫栈混洗。
括号匹配:检查表达式的括号匹配是编写代码时的一个重要步骤。
例如,a / ( b [ i - 1 ] [ j + 1 ] + c [ i + 1 ] [ j - 1 ] ) * 2
a / ( b [ i - 1 ] [ j + 1 ] ) + c [ i + 1 ] [ j - 1 ] ) * 2
前者括号匹配,后者不匹配。
二分递归实现:
除去不含括号的前后缀,将剩余的部分划分为各自括号匹配的两部分,重复此过程。
1 void trim(const char exp[], int &lo, int &hi) //删除exp[lo,hi]不含括号的最长前缀和最长后缀 2 { 3 //查找第一个和最后一个后缀 4 while ((lo <= hi) && (exp[lo] != '(') && (exp[lo] != ')')) 5 lo++; 6 while ((lo <= hi) && (exp[hi] != '(') && (exp[hi] != ')')) 7 hi--; 8 } 9 10 int divide(const char exp[], int lo, int hi) 11 { 12 int mi = lo; 13 int crc = 1; //crc为[lo,hi]内的左右括号数目之差 14 while ((0 < crc) && (++mi < hi)) 15 { 16 //逐个检查字符,直到左右括号数目相等或越界 17 if (exp[mi] = ')') 18 crc--; 19 if (exp[mi] = '(') 20 crc++; 21 } 22 return mi; //若mi<=hi,则为合法切分点 23 } 24 25 bool paren(const char exp[], int lo, int hi) 26 { 27 trim(exp, lo, hi); 28 if (lo > hi) 29 return true; 30 if (exp[lo] != ')')//首字符非左括号,不匹配 31 return false; 32 if (exp[hi] != '(')//首字符非右括号,不匹配 33 return false; 34 int mi = divide(exp, lo, hi);//适当的分切点 35 if (mi > hi)//切点不合法,不匹配 36 return false; 37 return paren(exp, lo + 1, mi - 1) && paren(exp, mi + 1, hi);//递归 38 }
时间复杂度O(n²),不适用于含有多种括号的表达式。
迭代实现:
借用了栈混洗的思路,排除了所有非括号的字符,进出栈的只有括号。
遇到左括号,进栈。遇到右括号,将栈顶的左括号弹出,检查他们是否相匹配,若不匹配则局部乃至全局不匹配,若匹配,重复该操作。
当表达式扫描完成时,若栈空,则表达式括号匹配。
1 bool paren ( const char exp[], int lo, int hi ) { //表达式括号匹配检查,可兼顾三种括号 2 Stack<char> S; //使用栈记录已发现但尚未匹配的左括号 3 for ( int i = lo; i <= hi; i++ ) /* 逐一检查当前字符 */ /*DSA*/{ 4 switch ( exp[i] ) { //左括号直接进栈;右括号若与栈顶失配,则表达式必不匹配 5 case '(': case '[': case '{': S.push ( exp[i] ); break; 6 case ')': if ( ( S.empty() ) || ( '(' != S.pop() ) ) return false; break; 7 case ']': if ( ( S.empty() ) || ( '[' != S.pop() ) ) return false; break; 8 case '}': if ( ( S.empty() ) || ( '{' != S.pop() ) ) return false; break; 9 default: break; //非括号字符一律忽略 10 /*DSA*/} displayProgress ( exp, i, S ); 11 } 12 return S.empty(); //整个表达式扫描过后,栈中若仍残留(左)括号,则不匹配;否则(栈空)匹配 13 }
该算法只需要扫描一次表达式,故线性时间复杂度O(n)。
扩展题:HHUOJ 1321
4.延迟缓冲
很多情况下,程序运行时需要对数据进行扫描到一定程度时才开始对数据进行相关操作,这时栈可以充当缓冲区的角色。
后缀表达式( 逆波兰表达式,RPN):后缀表达式是指符号位于操作数后面的表达式,相对于中缀表达式更适合计算机理解。利用栈可以求后缀表达式的值。
基本思路是,遇到操作数(程序中体现为变量或常量)则将其压入栈顶,遇到运算符则弹出栈顶的两个元素(弹出几个元素取决于该运算符是几目运算符),进行运算得出结果,将其压入栈中成为新的栈顶。
重复此操作直到栈空。
中缀转后缀:将中缀表达式转化为后缀表达式也可以借助栈来实现。
基本思路是,遇到操作数则使其流向输出,遇到运算符则将其与栈顶的运算符进行比较,若该运算符优先级别较高,则使其流向输出,若栈顶的运算符优先级别较高,则将栈顶运算符弹出并使其流向输出,将当前运算符压入栈中成为新的栈顶。重复这一流程直到读到表达式末尾。
5.回溯算法
剪枝:根据候选解的某些局部特征,以候选子集为单位批量地排除搜索空间中的候选解。
试探:从零开始,尝试逐步增加候选解的长度,成批地考察具有特定前缀的所有候选解,从而逐步向目标解靠近的过程就是试探。
回溯:试探到某一长度失败时,收缩到满足目标解的前一步,继续试探下一种可能的操作就是回溯。
n皇后问题:在n×n格的国际象棋上摆放n个皇后,使其不能互相攻击,即任意两个皇后都不能处于同一行、同一列或同一斜线上,问有多少种摆法。
枚举是不可能的,这辈子都不可能枚举的,复杂度高达O(nn),可以排除这一思路。
其实首先可以确定的是,八个皇后肯定两两不在同一行,这样一来就可以排除掉很多没有前途的候选解。
根据棋盘建立坐标系,可以用行列坐标表示每个皇后的位置。由此可以先写出皇后类。
1 struct Queen { //皇后类 2 int x, y; //皇后在棋盘上的位置坐标 3 Queen ( int xx = 0, int yy = 0 ) : x ( xx ), y ( yy ) {}; 4 bool operator== ( Queen const& q ) const { //重载判等操作符,以检测不同皇后之间可能的冲突 5 return ( x == q.x ) //行冲突(这一情况其实并不会发生,可省略) 6 || ( y == q.y ) //列冲突 7 || ( x + y == q.x + q.y ) //沿正对角线冲突 8 || ( x - y == q.x - q.y ); //沿反对角线冲突 9 } 10 bool operator!= ( Queen const& q ) const { return ! ( *this == q ); } //重载不等操作符 11 };
前面说可以确定每一行必定有且仅有一个皇后,那么纵坐标就可以不用操心了,我们只需要关注皇后的横坐标,就能准确定位皇后在棋盘上的位置。
将这些横坐标的值逐行压入栈中,根据栈中已有的坐标就可以判定接下来的行中有哪些位置是不能再放置皇后的。
如果遇到某一行不能放置皇后,则向上回溯,检查是可以改变上一行的皇后的位置使得当前行的皇后有位置可放。
1 void placeQueens ( int N ) { //N皇后算法(迭代版):采用试探/回溯的策略,借助栈记录查找的结果 2 Stack<Queen> solu; //存放(部分)解的栈 3 Queen q ( 0, 0 ); //从原点位置出发 4 do { //反复试探、回溯 5 if ( N <= solu.size() || N <= q.y ) { //若已出界,则 6 q = solu.pop(); q.y++; //回溯一行,并继续试探下一列 7 } else { //否则,试探下一行 8 while ( ( q.y < N ) && ( 0 <= solu.find ( q ) ) ) //通过与已有皇后的比对 9 /*DSA*///while ((q.y < N) && (solu.find(q))) //(若基于List实现Stack,则find()返回值的语义有所不同) 10 { q.y++; nCheck++; } //尝试找到可摆放下一皇后的列 11 if ( N > q.y ) { //若存在可摆放的列,则 12 solu.push ( q ); //摆上当前皇后,并 13 if ( N <= solu.size() ) nSolu++; //若部分解已成为全局解,则通过全局变量nSolu计数 14 q.x++; q.y = 0; //转入下一行,从第0列开始,试探下一皇后 15 } 16 }/*DSA*/if ( Step == runMode ) displayProgress ( solu, N ); 17 } while ( ( 0 < q.x ) || ( q.y < N ) ); //所有分支均已或穷举或剪枝之后,算法结束 18 }
n皇后问题有多种解法,回溯法只是其中一种,挖坑待填。
参考资料【1】《数据结构(C++语言版)》 邓俊辉
【2】《数据结构与算法分析——C语言描述》 Mark Allen Weiss