栈的最小值
该问题是“实现一个空栈存储数值,其支持一般的push,pop,以及用于求当前栈中最小值的min运算,要求3个运算的时间复杂度都是O(1)”。问题要求找最小值的代价是O(1),那只能在push,pop的过程中用额外空间维护一些关于最小值的信息。假设栈只有push运算,那只需1个额外变量就能记录栈中的最小值,但当存在pop运算时,如果最小值元素被pop了,那就只能遍历寻找新的最小值,代价就不再是O(1)。这里就需要注意到栈的一个性质,即“每次pop后栈会回到最后一次push前的状态”。这说明如果用一个结构顺序记录每次push后的“历史最小值”,那么pop后的最小值就是这个结构中的倒数第2条记录,然而倒数第一条记录因为pop结束已经没意义了,应从中删除,这又说明这个结构也很适合用栈实现。
下面给出具体程序,其通过和原栈“同步”的另一个栈记录“历史最小值”。由于这两个栈是同步push,pop的,所以这个程序push,pop也是O(1)代价的,而对于min运算只需返回“历史最小值”栈的栈顶即可。
下一个更大的数
该问题是“给定无重复的乱序整数数组,求每个数右边离其最近的更大的数。用程序语言内置map/dict存储“元素”到“结果”的映射,元素没有对应结果时无需存储。要求程序的代价为O(n),前提是假定程序语言内置的map/dict执行写入操作的代价是O(1),对于大多数语言也确实是这样”。利用栈解决该问题的核心步骤如下:
1) 读取数组下一个数并计为n;
2) 如果栈为空或栈顶元素小于n,就将n入栈。然后回到(1);
3) 循环出栈直到栈空或栈顶元素大于n,这些出栈元素对应的解都是n。然后回到(1);
具体程序如下,可以发现这是个嵌套两层的循环,外层的代价是固定的O(n),栈至多执行O(n)次入栈,内层虽然有while但由于栈至多执行O(n)次出栈,所以while的循环次数至多为O(n),所以程序的代价就是O(n)。
合法的括号匹配
该问题可描述为“四则运算中常用括号表明运算次序,虽然小括号足够用,但为阅读方便也会用中括号和大括号。这些括号必须成对出现和“左开右闭”,每对括号内都不能有未闭合的其他括号。括号匹配问题要求检查字符串中的小中大括号是否合法,为了简化问题,字符串中不会包含数字和运算符号,仅由“( [ { } ] )”这6种字符组成”。问题的关键在于归纳适合程序实现的判断逻辑,假设有张表T记录哪些括号未闭合、以及这些括号的打开顺序,然后分情况讨论:
1) 读字符串的下一位字符,如果没有字符则认为匹配成功;
2) 当读到表示打开的“( [ {”时,就在T中记录一下;
3) 当读到表示关闭的“} ] )”时,就要去T查看最后一次打开的括号是什么,这里分情况讨论:
3.1) 没有最后一次打开的括号或不匹配:该括号没成对出现或内部有未闭合的括号,可判定为不合法;
3.2) 能匹配最后一次打开的括号:这时这对括号已经成功匹配,对问题不再有影响所以删掉T中这个括号打开的记录,这时T中最后一次打开的括号就是倒数第二次打开的括号,回到步骤(1);
上面的讨论很容易变成程序,而且非常适合用栈来提供T的“功能”,具体程序如下:
删除不匹配的小括号
该问题可描述为“给定一个字符串,其仅含字符、'(‘、’)’,字符可在任意位置,当小括号不匹配时,至少删掉共多少个左括号或右括号,才能使剩下的所有小括号匹配,并输出其中一种删完后的字符串”。因为字符串中有字符,所以不方便直接把字符串装入栈中,这里用栈存放左右括号的下标。具体流程如下:
1) 初始化left,right两个数组,分别存储左括号和右括号,left会作为栈使用;
2) 读取下一个字符并计为x;
3) 如果x为左括号,则将x的下标入栈left,然后回到(2);
4) 如果x为右括号,分两种情况讨论:
4.1) 如果left非空,说明成功“关闭”一个左括号,left出栈一次,然后回到(2);
4.2) 如果left为空,说明右括号前没有“待关闭”的左括号,将当前x的下标存入right,然后回到(2);
程序结束时若left,right都为空,则说明能够括号本来就是匹配的。否则在原字符串中删掉left,right剩余的下标后能正确匹配。这里简单讨论下匹配失败的原因,其可分成3类情况:
1) 左括号多了。对应于left非空,right空的情形;
2) 右括号多了。对应于left空,right非空的情形;
3) 存在一个纵轴,轴左边右括号多了,轴右边左括号多了。对应于left,right都非空的情形;
根据这3类情况可确定,至少删掉|left|个左括号和|right|个右括号才能匹配,而left,right记录的下标刚好提供了种删除方案。下面是程序,其时间复杂度是O(n),判断元素是否在Python的set数据类型中的代价是O(1)。
最长有效小括号
该问题是“给定字符串,其仅由'(‘、’)’组成,求该字符串能正确匹配的最长子串长度”。这个问题是比前面更难一点的匹配问题,这里回到前面讨论的“删除不匹配的小括号”问题,程序结束时若left,right中都有记录,那么left,right中的下标都是升序的,根据前面讨论过的第3种匹配失败情况,right中的所有下标都一定小于left中的。如果按right在左left在右边拼接这两个数组得到新数组indexs,那么能正确匹配的最长子串的长度是“字符串最左端到index[0]下标”、“index[-1]下标到字符串最右端”、“index相邻两元素”这3类中的最大值,具体程序如下。
小括号的得分
该问题是“给定字符串,其仅由'(‘、’)’组成,其中所有括号都是匹配的。然后定义记分规则,(XXX)的得分为XXX的得分乘2,XXX为空时得1分,比如()得1分,(()()())得6分,(((())))得8分。求给定字符串的得分”。解决这个问题的思路是把“部分得分”也丢到栈中,最后栈中剩下的“部分得分”之和就是总得分,具体步骤如下:
1) 读下一个字符并计为i;
2) 如果i为”(“,则入栈i,回到步骤(1);
3) 如果i为”)”,此时分情况讨论如下:
3.1) 如果栈顶为”(“,此时出栈一次,然后入栈数字1,回到步骤(1);
3.2) 如果栈顶为数字,则循环出栈直到出栈”(“,前面出栈的元素都是数字,求和乘2后入栈,最后回到步骤(1);
读完所有字符后程序退出,栈中剩下的元素的和就是得分。这个程序的正确性在于能够让非“直接关闭”的括号之间有数字,关闭这种括号时要先把括号里面的分加起来,最后乘2,这个计算分数的过程和栈内左括号的关闭过程同步。
具体程序如下,虽然内层有while循环,但由于入栈次数为n,所以代价是O(n)。
检查和计算布尔表达式
布尔表达式是数字电路中的基本数学工具。该问题为“给定布尔表达式字符串,计算表达式的值。输入是多个true、false、 and、or组合成的字符串,单词间用一个空格作分隔,字符串首尾无空格。输入不保证合法(比如true true and),合法时返回字符串的true或false,否则返回字符串error。注意and运算的优先级大于or”。
判定是否合法很简单,能按3个一组读到的是“布尔,运算,布尔”即合法。如果运算优先级一样,可以把单词全部放入栈,按3个一组出栈计算结果,再把结果入栈,循环该过程直到栈只有1个元素,该元素就是计算结果。但由于and优先级更高,这样计算会导致错误结果,考虑到没有括号,可直接把所有and计算出来,最后只剩or就很好办了。具体流程如下:
1) 初始化栈s_1,s_2,把所有单词从右到左入栈s_1;
2) 若s_1有大于等于3个元素,取出3个判断其是否是“布尔,运算,布尔”,不是就返回error,否则分情况讨论:
2.1) 若这3个元素包含and,则进行计算并把计算结果入栈s_1;
2.2) 若这3个元素包含or,则将前2个入栈s_2,最后1个入栈s_1;
3) 若s_1仅有1个元素,判断这个元素是否为“布尔”,如果不是就返回error,否则其出栈并入栈s_2,这时s_2是计算完所有and的布尔表达式,其只会有or,可以用前面的方法直接计算出表达式的值;
程序实现如下,其相当于遍历两次,所以时间复杂度为O(n)。
计算逆波兰式
逆波兰式又叫后缀表达式,是把二元运算写成“操作数,操作数,运算符”的表达式。人们更习惯使用“操作数,运算符,操作数”形式的中缀表达式,比如算术表达式。如无特殊说明,一般直接把算术表达式的逆波兰记法直接称为逆波兰式。例如算术表达式“(2+10)*3”对应的逆波兰式是“2 10 + 3 *”,可发现括号在逆波兰式中无意义。手算逆波兰式时可从左到右读,读到算符就将其和前两个数运算,并把结果放回。该过程容易用栈模拟,事实上逆波兰式比算术表达式更“适合计算机”。程序如下,输入是字符串数组形式的合法逆波兰式,只含+-*/和整数,比如[’10’,’-30′,’-‘]。
中缀表达式转换为逆波兰式(调度场算法)
广义上中缀表达式可包含任意多种二元运算和优先级,只要运算能写作“<操作数, 运算符, 操作数>”的,比如布尔表达式就是中缀表达式。但本文为方便还是用“小括号整数四则运算表达式”讨论,为了有2个以上的优先级,再加上幂算符x^y表示x的y次方,优先级高于乘除。这里规定输入为形如[(, 2,-,-3, ), /, 11,^,2]的字符串数组,需注意这里唯一的运算规则是“括号内最优先,高优先级优先,同优先级左边优先”,不能下意识的默认使用算术表达式中的交换律、分配律等,因为一般的二元运算不一定满足。换句话说,计算结果一样的逆波兰式不一定算“原式的逆波兰式”。
调度场算法(Shunting Yard Algo)是该问题的标准算法,由著名荷兰计算机科学家Dijkstra提出,其得名于火车站用轨道作“栈”管理车厢次序的方法。上图是该算法流程的一个具体例子,有输入、符号栈、输出3个结构,为了方便描述把它们都当成栈,并分别计为s_1,s_2,s_3,栈顶分别在左端,上端,右端。并且把逆波兰式递归定义成“1个逆波兰式是空、1个数、1个<子逆波兰式1, 子逆波兰式2, 算符>”。这里具体分析下步骤,就用上图的例子:
1) s_1出栈A,此时s_2为空,s_1栈顶为+,说明之前没有运算会作用于A,能确定这个新逆波兰式1是“<A, 待定子式2, +>”的结构,子式2无法确定,因为不知道后面+的是什么(后面如果是X*Y就是+X*Y,如果是X-Y才是+X)。可以把A入栈s_3作为确定的结果,但把+从s_1出栈并入栈s_2,为了等子式1确定方可入栈s_3;
2) s_1出栈B,此时s_2栈顶是+,s_1栈顶为*,说明之前没有比*更高优先级的运算会作用于B,子式2也只能确定是“<B,待定子式3,*>”的结构,式1就是“<A,B,待定子式3,*,+>”,子式3无法确定,因为不知道后面*的是什么。可以把B入栈s_3作为确定的结果,但把*从s_1出栈并入栈s_2,为了等子式1确定方可入栈s_3;
3) s_1出栈C,此时s_2栈顶是*,s_1栈顶为-,说明*要优先计算,子式2确定是“<B,C,*>”,把C入栈s_3,再把*从s_2出栈并入栈s_3。此时不代表式1也确定了,此时s_2栈顶是+,由于-+优先级一样且+在左,所以+要优先计算,子式1确定是“<A,B,C,*,+>”,把+从s_2出栈并入栈s_3。然后开启新逆波兰式4为“<子式1,待定子式5,->”,把-入栈s_2,为了等子式5确定方入栈s_3;
4) s_1出栈D,此时s_2栈顶是-,s_1为空,说明这步只能给-计算,子式5确定为D,所以把D入栈s_3,把-从s_2出栈并入栈s_3,最后得到“<子式1,D,->”即“<A,B,C,*,+,D,->”;
算法之所以设立专门存符号的栈s_2,是因为符号在逆波兰式末端,当“递归”解析完“子逆波兰式”后,可直接把符号出栈,把“子逆波兰式”夹在中间即可。这个逻辑显然能推广到任意多个优先级的情况。
最后分析怎么处理括号,最简单的方法是直接把括号当成“子中缀表达式”并对其递归上述步骤,但这里还是考虑修改一些上述逻辑使其直接支持括号,当读到左括号“(”时首先要避免括号左边的运算符(s_2栈顶)对括号的影响,一种方法是把”(“当成一个优先级极高的算符压入栈s_2,这样做之后,括号内第一个数会按上述(2)的步骤走下去,就跟解析一个新的中缀表达式一样。之后读到右括号“)”时,要当成解析完一整个中缀表达式那样,把s_2循环的出栈并入栈s_3,直到遇到之前放入的左括号“(”后将其出栈丢弃。这样就等效于把一个括号中的内容变成一个逆波兰式并放在s_1正确的位置上。
具体程序如下,由于s_1中每个元素至多有2次出栈、2次入栈,所以时间复杂度是O(n)。