06.
栈与队列的应用
大家好,我是小C,上期给大家分享—— 栈与队列的存储结构与实现
本期分享内容:栈与队列的应用
本期小C邀请的是春晨溅雨·4位算法工程师为我们分享《数据结构算法面试全解析》专栏。
数据结构算法面试
栈与队列的应用
1 栈的应用
栈在很多面试的问题中都会被问到,其实按照我的理解栈的应用就是递归的应用。所以接下来将会介绍一些有关栈的应用,当你看到这些应用,会有一种感觉:这特么不就是递归吗?
OK,话不多说,开始栈的应用
1.1 递归
栈在程序设计中具有非常广泛的应用,其中递归的应用是求职者必须知道的。在此之前我们先了解一下递归,然后再解释如何应用栈实现递归。
递归的概念
想必大家都看过《盗梦空间》,主人公在梦里不断陷入一个接一个的梦中,从最浅层的梦逐步深入到更深的梦中,直至自己也不知道自己是在梦中还是现实中了。如果主人公学过递归,应该就能成功退出梦幻,哈哈哈,开个玩笑。在这里,我讲一下个人理解的递归:通过不断调用函数自身进行压栈,直至终止条件,将其结果计算出来进行弹栈,并将结果返回给弹栈之后的栈顶,一直弹栈到栈空为止。
可能我这段说辞有点绕。那给大家搞点形象的。
看完上面这幅图,相信很多同学对栈的概念有了更深的理解。接下来我们将通过一些实际的案例进一步解释栈在递归方面的应用。
遍历文件
我相信很多同学看到递归的应用,第一时间肯定是浏览器。因为这个例子已经被用烂了,我们在平时使用浏览器的时候,当需要返回上一个页面的时候,可以使用返回键,然后之前的页面能够迅速地被刷新出来。其实这里面就是运用了栈,顺序点击查看的网页界面一个个都被压栈。当你按下返回按钮,当前页面就被弹栈出去了。
但是今天我们讲一个更具工程应用价值的例子-遍历文件。无论是我们在电脑上搜索某个文件,还是在 Linux 系统中,通过rm -f
删除某个文件夹下所有文件(当然这个命令要慎用,笑)。其实都会涉及到这个应用。
遍历文件的算法:定义遍历文件的函数以某个文件夹为入口,遇到子文件夹就递归调用遍历函数,直至遇到指定后缀的文件,然后再弹栈,进行下一轮的遍历工作。
import java.io.File;
import java.io.FileFilter;
public class FileText {
public static void main(String[] args) {
String path = "D:\\JAVA"; //要遍历的路径
File file = new File(path); //获取其 file 对象
func(file);
}
private static void func(File file){
File[] fs = file.listFiles();
for(File f:fs){
if(f.isDirectory()) //若是目录,则递归打印该目录下的文件
func(f);
if(f.isFile()) //若是文件,直接打印
System.out.println(f);
}
}
}
好了,看到上面这段代码的同学就能明白递归,最重要的就两点:调用自己和终止条件。只要设置合理的终止条件,递归实现相关功能将十分优美简洁。但是递归
1.2 符号成对出现
这是在找工作过程中常见的笔试题。是 LeetCode 上一道非常经典的题目。题目大体是这样的:
一段字符串里给定了包括"(",")","[","]","{,}",判断这个字符串是否有效。 有效的条件包括:
左括号必须和相同类型的右括号闭合
左括号必须按照正确的顺序闭合
比如"{[]}","()",这样都是有效字符串,但是"{]"、"[)]"就不是有效的字符串。
计算步骤
初始化栈 S。
一次处理表达式的每个括号。
如果遇到开括号,我们只需将其推到栈上即可。这意味着我们将稍后处理它,让我们简单地转到前面的 子表达式。
如果我们遇到一个闭括号,那么我们检查栈顶的元素。如果栈顶的元素是一个 相同类型的 左括号,那么我们将它从栈中弹出并继续处理。否则,这意味着表达式无效。
如果到最后我们剩下的栈中仍然有元素,那么这意味着表达式无效。
代码如下:
private boolean isParenthesesValid(String s) {
//字符串为空
if (s==null){
return false;
}
int length=s.length();
Stack<Character> stack=new Stack<>();
//存储遍历的字符
char ch;
//存储字符转换后的数字
int parentNum;
//记录下括号出现的次数
int count=0;
for (int i=0;i<length;i++){
ch=s.charAt(i);
parentNum=whatParent(ch);
if(parentNum<0){
count++;
// <0 表示这是个左括号
stack.push(ch);
}else if(parentNum>0){
count++;
// >0 表示这是个右括号
if (stack.isEmpty()){
//右括号左边没有左括号的特殊情况
return false;
}
if(parentNum+whatParent(stack.peek())==0){
//栈顶是对应的左括号
stack.pop();
}else{
return false;
}
}else{
// =0 这不是一个括号,不管
}
}
//字符串中有括号且栈最后被清空了,表示括号成对出现且有效
if (count > 0 && stack.isEmpty()){
return true;
}
return false;
}
1.3 四则运算
栈的应用非常多,这里我们举一个在企业笔试面试中非常常见的题目:四则运算表达式求值。对于没接触过这一题的同学来说,可能有些疑惑,四则表达式不是直接求解就可以了吗?但是计算机识别数学表达式,并将之计算出来,情况和我们自己算是非常不同的,这里的难点在于让计算机知道加减乘除的优先级以及括号带来的优先级改变。
在上个世纪 50 年代,一位波兰逻辑学家提出了逆波兰表示法(Reverse Polish Notation,RPN)成功解决了这个问题。所谓逆波兰表示法,在现在也叫作后缀表达式计算法。
后缀表达式计算法
后缀表达式计算法的规则是:从左到右遍历后缀表达式中的每个元素,遇到数字就进栈,遇到符号,就将位于栈顶的两个元素出栈,进行计算,计算得到的结果再进栈,最终得到结果。
这里就有同学要问了,这里忽然提到的后缀表达式是什么呢?
这是一个好问题!
中缀表达式、后缀表达式
我们平常书写出来的数学表达式就是中缀表达式。之所以称之为中缀表达式,是因为所有的运算符号都在两个数字之间。而按照后缀转化规则,就可以得到后缀表达式,具体规则是:从左到右遍历中缀表达式的每个数字和符号,如果是数字就输出,如果是符号就判断栈顶符号与其的优先级(乘除优先级高于加减),如果是右括号或者优先级较低,则栈顶元素出栈并输出,并将当前的符号入栈,一直进行这样的操作,直至后缀表达式输出为止。
OK,这样我们就讲解完了四则表达式的计算原理了。
接下来看一下该题的解题代码:
将中缀表达式转为后缀表达式
//转换成后缀表达式
public boolean toPostfix(){
if( !isCorrect() ){
System.out.println("您的表达式输入有误,请检查后再输入");
return false;
}
Stack opStack = new Stack(infix.length());
//由于本题前导部分的限制原因,现仅考虑个位整数加减法
for(int i=0; i<infix.length(); i++){
char ch = infix.charAt(i);
if ( ch >='0' && ch <='9'){
//如果是操作数
postfix+=ch;
}else if(prority(ch)==0){
//如果是左括号,入栈
opStack.push(ch);
} else if (ch == ')' || ch == ']' || ch == '}') {
// 如果是右括号,因为前面已经判断过算式中括号匹配问题,所以此时遇到右括号,栈中必定有数据项
char chf = opStack.pop();
if (prority(chf) == 0) {
// 如果栈顶是左括号,则退出循环,说明算式中不再有其他运算符
break;
} else {
// 如果栈顶不是左括号则为普通操作符,说明括号中的运算符都已遍历完
postfix += chf;
}
} else if (prority(ch) > 0) {
// 如果是普通操作符
if (opStack.isNull()) {
// 如果栈不为空,入栈
opStack.push(ch);
} else {
char chf = opStack.peek();
int prof = prority(chf);
int pro = prority(ch);
if (prof == 0 || (prof > pro)) {
// 如果栈顶是左括号或栈顶符号的优先级较低,则入栈
opStack.push(ch);
} else if (prof <= pro && prof > 0) {
// 如果栈顶操作符优先级更高,且不为左括号
postfix += ch;
}
}
} else {// 既不是操作符也不是操作数,算式输入有误
System.out.println("您的表达式输入有误,请检查后再输入");
return false;
} // end of outer if-else
} // end of for
while (!opStack.isNull()) {
char ch = opStack.pop();
if (prority(ch) > 0) {
postfix+=ch;
} else
break;
}
return false;
}
根据后缀表达式求值
public int calculate(){
int maxSize = postfix.length();
Stack theStack = new Stack(maxSize,1);
int op1;//操作数 1
int op2;//操作数 2
int tmp = 0;//中间结果
for(int index=0; index<maxSize; index++){
char ch = postfix.charAt(index);
if( ch >='0' && ch <='9' ){
//如果是操作数,则入栈
theStack.push(ch-'0');
}else{
//如果是操作符,则将操作数出栈,运算
op1 = theStack.pop(1);
op2 = theStack.pop(1);
switch(ch){
case '+':
tmp = op1+op2;
break;
case '-':
tmp = op2 - op1;
break;
case '*':
tmp = op2 * op1;
break;
case '/':
tmp = op2 / op1;
break;
}
theStack.push(tmp);
}//end of if-else
}//end of for
result = theStack.pop(1);
return result;
}//end of calculate()
1.4 汉诺塔
汉诺塔也是一道经典到不能再经典的题目了。相信各位同学在找工作的过程中,至少能遇到两次。这道题源自于印度一个古老益智游戏。传说印度的大梵天在创造世界的时候建造了三根巨柱,其中一根柱子从上至下按照从小到大的顺序堆叠了 64 块黄金盘,大梵天的要求是将所有的盘子都按顺序堆叠到另一根柱子上,并且要求移动过程中大盘不能放到小盘上,在一次移动过程中只能移动一个盘子。
其实我们仔细观察这个问题,可以发现盘子从一根柱子移动到另一根柱子不就是弹栈和压栈嘛,只不过压栈与弹栈有一定的要求,就是压栈之前需要判断栈顶元素与自身的大小关系。好,确定是一个栈分类的问题,因此我们就可以比较容易的想到,可以使用递归来实现。
解决这个问题可以简化为三个步骤:
将第
n-1
个盘子由 A 转移到 B将
n
个盘子由 A 转移到 C将第
n-1
个盘子由 B 转移到 C
因此可以给出实现的代码:
public class Hanoi {
//f(n,param1,param2,param3)
//param1:开始位置, param2:中间过度位置,param3:移动到的位置
public static void f(int n, String from, String buffer, String to) {
if(1 == n) {
System.out.println(from + "->" + to);
}
else{
f(n-1, from, to, buffer);
System.out.println(from + "->" + to);
f (n-1, buffer, from, to);
}
}
public static int h(int n) {
if( 1 == n) {
return 1;
}else {
return 2*h(n-1) + 1;
}
}
public static void main(String[] args) {
f(2,"A","B","C");
}
}
2 队列的应用
各位同学,我将在这个模块继续介绍一些在笔试面试中常见的问题。同时由于我们已经学习了栈的一些知识,因此在这个模块,部分题目我将结合栈。
2.1 两个栈实现一个队列
在栈的内容中,我们学习到了栈通过pop
操作从栈顶获取一个元素,而对于队列而言,是从队列头获取元素。这一道题,我们是不是可以这样翻译一下:通过两个栈定义一种最先放入的元素,最先被取出的数据结构。
这道题,我们需要注意到两点就可以了:
StackA
向StackB
压入元素,必须保证一次性全部压栈。从队列中取出元素,必须保证该元素是从
StackB
中弹出的,也就是决不能在StackB
非空的时候,对StackB
进行压栈。
public static class TwoStackQueue<E>{
private Stack<E> stackA;
private Stack<E> stackB;
public TwoStackQueue() {
stackA = new Stack<>();
stackB = new Stack<>();
}
/**
* 添加元素逻辑
* @param e 要添加的元素
* @return 这里只是遵循 Queue 的习惯,这里简单处理返回 true 即可
*/
public boolean add(E e){
stackA.push(e);
return true;
}
/**
* 去除元素的时候需要判断两个地方,StackA & StackB 是否都为空
* StackB 为空的时候讲 StackA 中的元素全部依次压入 StackB
* @return 返回队列中的元素 如果队列为空返回 null
*/
public E poll(){
//如果队列中没有元素则直接返回空,也可以选择抛出异常
if (stackB.isEmpty() && stackA.isEmpty()){
return null;
}
if (stackB.isEmpty()){
while (!stackA.isEmpty()){
stackB.add(stackA.pop());
}
}
return stackB.pop();
}
/**
* peek 操作不取出元素,只返回队列头部的元素值
* @return 队列头部的元素值
*/
public E peek(){
//如果队列中没有元素则直接返回空,也可以选择抛出异常
if (stackB.isEmpty() && stackA.isEmpty()){
return null;
}
if (stackB.isEmpty()){
while (!stackA.isEmpty()){
stackB.add(stackA.pop());
}
}
return stackB.peek();
}
}
2.2 两个队列实现一个栈
我们实现好上面这一题,可以开始下面这一题的实现了。首先给大家一段思考的时间,我们在实现的时候需要注意什么呢?
首先队列是一个先进先出的数据结构,通过两个队列实现栈的数据结构,无非就是两个队列入队出队的操作,那么我们需要做的唯一的事情就是判断目前出队的值是否是按照放入元素顺序。
这里需要注意的两点是:
出栈操作时:将
QueueA
出队到QueueB
,需要保留一个元素用来弹出。入栈操作时:需要保证
QueueA
为空,同时将新元素加入到非空队列中。
public static class TwoQueueStack<E> {
private Queue<E> queueA;
private Queue<E> queueB;
public TwoQueueStack() {
queueA = new LinkedList<>();
queueB = new LinkedList<>();
}
/**
* 选一个非空的队列入队
*
* @param e
* @return
*/
public E push(E e) {
if (queueA.size() != 0) {
System.out.println("从 queueA 入队 " + e);
queueA.add(e);
} else if (queueB.size() != 0) {
System.out.println("从 queueB 入队 " + e);
queueB.add(e);
} else {
System.out.println("从 queueA 入队 " + e);
queueA.add(e);
}
return e;
}
public E pop() {
if (queueA.size() == 0 && queueB.size() == 0) {
return null;
}
E result = null;
if (queueA.size() != 0) {
while (queueA.size() > 0) {
result = queueA.poll();
if (queueA.size() != 0) {
System.out.println("从 queueA 出队 并 queueB 入队 " + result);
queueB.add(result);
}
}
System.out.println("从 queueA 出队 " + result);
} else {
while (queueB.size() > 0) {
result = queueB.poll();
if (queueB.size() != 0) {
System.out.println("从 queueB 出队 并 queueA 入队 " + result);
queueA.add(result);
}
}
System.out.println("从 queueB 出队" + result);
}
return result;
}
}
好,完成了上述的代码,我们可以总结一下上述的操作要点:
通过两个队列实现一个栈的过程中,任何时候都要保证其中一个队列是空的。
添加元素总是向非空队列中添加新元素
弹栈的时候,需要将其中一个队列中非队列尾的所有元素导入到另一个空队列中,然后将队尾元素出栈、
2.3 杨辉三角
有同学会感觉特别奇怪,讲着队列,怎么忽然来了一个杨辉三角(小声比比,杨辉三角不是初中的知识嘛,有啥好讲的呢。)。同学们先别聒噪,这道题在大名鼎鼎的华为笔试中还曾经出现过呢。
杨辉三角形的样子大体就是这样:
1
1 1
1 2 1
1 3 3 1
1 4 6 4 1
1 5 10 10 5 1
1 6 15 20 15 6 1
杨辉三角的特点是两边的数字都是1
,其他位置是上面一行中与之相邻的两个整数之和。杨辉三角的本质是二项式(a+b)的 n 次方展开后各项的系数排成的三角形,它的特点是左右两边全是 1,从第二行起,中间的每一个数是上一行里相邻两个数之和。
使用队列的思想来实现杨辉三角的流程:
首先,需要初始化一个队列,即对头=队尾=0;
将第一行的元素 1 入队,接着操作第二行(一二行不需要求和操作,直接将元素入队即可);
从第三行开始,现在的对头指向 N-1 行,先将每行的固定元素 1 入队,然后循环操作求和过程:将队首元素出队,并保存它的值 temp;获取当前队首的元素 x,并进行 temp=temp+x,且将 temp 入队;
循环结束后,队首在 N-1 行的最后一个元素处,现将其出队,然后将每行最后的固定元素 1 入队;
循环 3、4 步就可以输出杨辉三角形了。
注意:杨辉三角的特点是第 N 行的中间值等于 N-1 行两值的和,队列采用的是单进单出。
import java.util.Scanner;
public class YanghuiTriangle {
public static void main(String[] args) {
Scanner sc = new Scanner(System.in);
System.out.println("请输入杨辉三角的行数:");
int n = sc.nextInt();
int[][] triangle = new int[n][];
for (int i = 0; i < triangle.length; i++) {
triangle[i] = new int[i + 1];
for (int j = 0; j < triangle[i].length; j++) {
if (i == 0 || j == 0 || i == j) {
triangle[i][j] = 1;
} else {
triangle[i][j] = triangle[i - 1][j] + triangle[i - 1][j - 1];
}
System.out.print(triangle[i][j] + " ");
}
System.out.println();
}
}
}
3 牛刀小试
3.1 百度 2016 年开发面试题
问:使用堆栈来模拟队列的功能,要求数据必须存储在堆栈内部。需要实现入队、出队、判断为空三个功能?
答:
假定输出序列为 Y ,对于 n 个元素的输入序列 X ,若按照如下过程进行操作:
X 中元素按序全部顺序进入 S1 中;
依次将 S1 中元素出栈并压入 S2 栈中,直到 S1 栈空为止;
依次将 S2 中元素出栈并送输出序列 Y,直到 S2 栈空为止。
则输出序列的顺序与输入序列 X 完全相同,符合队列先进先出的特点。进一步分析可知,若将 X 从左向右分成若干段,按从左到右的顺序对每个段作上述操作,亦可得到同样的结果。所以,用两个栈模拟队列三个功能的操作过程可描述如下:
enqueue(入 队):入队元素直接放入 S1 栈中(Push)。
dequeue(出队):若 S2 栈不为空,则取 S2 栈的栈顶元素(Pop)作为出队元素;否则将栈 S1 中元素全部搬到 S2 中,再取 S2 栈的栈顶元素(Pop)作为出队元素(这可视为将入队元素分段地通过 S1 和 S2 栈)。
isEmpty(判空):若 S1 和 S2 两个栈均为空,则队列为空;否则不为空。
今日内容有get吗,欢迎各位留言讨论!
下期预告:算法性能衡量的好坏
以上专栏均来自CSDN GitChat专栏《数据结构算法面试全解析》,作者春晨溅雨·4位算法工程师,专栏详情可识别下方二维码查看哦!
了解更多详情
可识别下方二维码