三、栈和队列
栈
栈(stack): 栈(stack)是一种遵循先入后出逻辑的线性数据结构。
1.栈的基本操作
| 方法名 | 描述 |
|---|---|
| push() | 入栈 |
| pop() | 出栈 |
| peek() | 访问栈顶元素 |
2.栈的实现
栈遵循先入后出的原则,因此我们只能在栈顶添加或删除元素。然而,数组和链表都可以在任意位置添加和删除元素,因此栈可以视为一种受限制的数组或链表。
基于数组的实现
使用数组实现栈时,我们可以将数组的尾部作为栈顶。
由于入栈的元素可能会源源不断地增加,因此我们可以使用动态数组,这样就无须自行处理数组扩容问题。
基于链表的实现
使用链表实现栈时,我们可以将链表的头节点视为栈顶,尾节点视为栈底。
尾插法可以达到和头插法一样的效率,只是代码实现会稍冗长。
3.两种实现对比
支持操作
时间效率
空间效率
我们不能简单地确定哪种实现更加节省内存,需要针对具体情况进行分析。
4.栈的应用
括号匹配
算法步骤:
- 遇到左括号就入栈。
- 遇到右括号,就消耗一个左括号。
代码实现
public static boolean bracketCheck(char str[]){
//初始化栈
Stack<Character> stack = new Stack<>();
for (int i = 0; i < str.length; i++) {
//遇到左括号,就入栈。
if (str[i] == '(' || str[i] == '[' || str[i] == '{'){
stack.push(str[i]);
}else { //遇到右括号,就消耗一个左括号
if (stack.isEmpty()){ //扫描到右括号,但栈为空
return false;
}
//当前扫描到的元素和栈顶元素匹配
char topElem = stack.pop();
if (str[i] == ')' && topElem != '('){
return false;
}
if (str[i] == ']' && topElem != '['){
return false;
}
if (str[i] == '}' && topElem != '{'){
return false;
}
}
}
//扫描完所有元素,栈不为空,说明匹配失败,还有左括号
return stack.isEmpty();
}
表达式
中缀表达式: 中缀表达式是人们日常书写数学公式或表达式时最常用的一种表示方法。在这种表示法中,运算符位于操作数之间。中缀表达式的阅读习惯符合人们的自然语言逻辑,但直接对其进行计算处理时较为复杂,特别是在涉及到优先级和括号的情况,因此在计算机科学中,常会将其转换为前缀(波兰表示法)或后缀(逆波兰表示法)形式进行处理。
逆波兰表达式/后缀表达式(Reverse Polish notation): 操作符位于它们的操作数之后。
波兰表达式/前缀表达式(Polish notation): 操作符位于它们的操作数之前。
表达式组成: 由三个部分组成,操作数、运算符、界限符。界限符是必不可少的,反映了计算的先后顺序。
一个中缀表达式可以对应多个后缀、前缀表达式。一个中缀表达式只对应一个后缀表达式(确保算法的“确定性”)
中缀表达式转换为后缀表达式
思想: 先转后算。
中缀表达式转换为后缀表达式步骤(手算方法)
- 确定中缀表达式中各个运算符的运算顺序。
- 选择下一个运算符,按照【左操作数 右操作数 运算符】的方式组合成一个新的操作数。
- 如果还有运算符没被处理,就继续2。
保证表达式运算顺序唯一
- 算法要求确定性:各个运算符的运算顺序不唯一,因此对应的后缀表达式也不唯一。
- “左优先”原则(可保证运算顺序唯一):只要左边的运算符能先计算,就优先算左边的。可保证手算和机算结果相同。
后缀表达式的计算(手算方法)
从左往右扫描,每遇到一个运算符,就让运算符前面最近的两个操作数执行对应运算,合体为一个操作数。
注意:两个操作数的左右顺序。
用栈实现后缀表达式的计算(机算方法)
从左往右扫描下一个元素,直到处理完所有元素:
- 若扫描到操作数则压入栈。
- 若扫描到运算符,则弹出两个栈顶元素,执行相应运算,运算结果压回栈顶。
注意:先出栈的是“右操作数“。
代码实现
public static void main(String[] args) {
//计算结果:5
String expression = "15 7 1 1 + - / 3 * 2 1 1 + + -";
String[] tokens = expression.split(" ");
System.out.println(expressionCount(tokens));
}
public static Integer expressionCount(String[] expression){
Stack expressionStack = new Stack();
for (int i = 0; i < expression.length; i++) {
if (isNumeric(expression[i])){
//如果是数字,入栈
int element = Integer.parseInt(expression[i]);
expressionStack.push(element);
}else { //否则,就是运算符,弹出两个栈顶元素,计算,再把结果入栈,直至表达式扫描完成。
int elementRigth = Integer.parseInt(expressionStack.pop().toString()); //先出栈的是右操作数
int elementLeft = Integer.parseInt(expressionStack.pop().toString());
if (expression[i].equals("+")){
expressionStack.push(elementLeft + elementRigth);
}else if(expression[i].equals("-")){
expressionStack.push(elementLeft - elementRigth);
}else if(expression[i].equals("*")){
expressionStack.push(elementLeft * elementRigth);
}else if(expression[i].equals("/")){
expressionStack.push(elementLeft / elementRigth);
}
}
}
return Integer.parseInt(expressionStack.pop().toString());
}
public static boolean isNumeric(String str) {
return str.matches("-?\d+(\.\d+)?");
}
后缀表达式怎么转中缀
中缀表达式转换为前缀表达式
中缀表达式转换为前缀表达式步骤(手算方法)
- 确定中缀表达式中各个运算符的运算顺序。
- 选择下一个运算符,按照「运算符 左操作数 右操数」的方式组合成一个新的操作数。
- 如果还有运算符没被处理,就继续②。
“右优先”原则: 只要右边的运算符能先计算,就优先算右边的。
用栈实现前缀表达式的计算(机算方法)
从右往左扫描下一个元素,直到处理完所有元素:
- 若扫描到操作数则压入栈。
- 若扫描到运算符,则弹出两个栈顶元素,执行相应运算,运算结果压回栈顶。
注意:先出栈的是“左操作数”。
结合
中缀表达式转换为后缀表达式步骤(机算方法)
-
初始化一个栈,用于保存暂时还不能确定运算顺序的运算符。
-
从左到右处理各个元素,直到末尾。可能遇到三种情况:
①遇到操作数。直接加入后缀表达式。
②遇到界限符。遇到“(”直接入栈;遇到“)”则依次弹出栈内运算符并加入后缀表达式,直到弹出“(”为止。注意:“(”不加入后缀表达式。
③遇到运算符。依次弹出栈中优先级高于或等于当前运算符的所有运算符,并加入后缀表达式。若碰到“(”或栈空则停止,之后再把当前运算符入栈。
-
按上述方法处理完所有字符后,将栈中剩余运算符依次弹出,并加入后缀表达式。
代码实现
( ( 15 / ( 7 - ( 1 + 1 ) ) ) * 3 ) - ( 2 + ( 1 + 1 ) )
15 7 1 1 + - / 3 * 2 1 1 + + -
/**
* 用栈将前缀表达式转换为后缀表达式
*
* @param expression
* @return
*/
public static StringBuilder rpnexpressionConvert(String[] expression) {
Stack operatorStack = new Stack();
StringBuilder reversePolishnotation = new StringBuilder();
for (int i = 0; i < expression.length; i++) {
String nowElement = expression[i];
if (isNumeric(nowElement)) {
//如果是数字,加入后缀表达式
reversePolishnotation.append(nowElement).append(" ");
} else if (nowElement.equals("(")) {
operatorStack.push(nowElement);
} else if (nowElement.equals(")")) {
while (!operatorStack.isEmpty()) {
if (operatorStack.peek().equals("(")) { //遇到左括号,停止出栈
operatorStack.pop();
break;
}
reversePolishnotation.append(operatorStack.pop()).append(" ");
}
} else if (nowElement.equals("+") || nowElement.equals("-") || nowElement.equals("*")
|| nowElement.equals("/")){
if (nowElement.equals("+") || nowElement.equals("-")){
while (!operatorStack.isEmpty()){
if (operatorStack.peek().equals("(")){
operatorStack.pop();
operatorStack.push(nowElement);
break;
}
reversePolishnotation.append(operatorStack.pop()).append(" ");
}
if (operatorStack.isEmpty()){
operatorStack.push(nowElement); //当前元素入栈
}
}else {
while (!operatorStack.isEmpty()){
if (operatorStack.peek().equals("(")){
operatorStack.push(nowElement);
break;
} else if (operatorStack.peek().equals("+") || operatorStack.peek().equals("-")) {
operatorStack.push(nowElement);
break;
}
reversePolishnotation.append(operatorStack.pop()).append(" ");
}
if (operatorStack.isEmpty()){
operatorStack.push(nowElement); //当前元素入栈
}
}
}
}
while (!operatorStack.isEmpty()){
reversePolishnotation.append(operatorStack.pop()).append(" ");
}
return reversePolishnotation;
}
两个算法的结合:中缀转后缀+后缀表达式求值
用栈实现中缀表达式的计算:
-
初始化两个栈,操作数栈和运算符栈。
-
从左往右扫描下一个元素,直到处理完所有元素:
- 若扫描到操作数,压入操作数栈。
- 若扫描到运算符或界限符,则按照“中缀转后缀”相同的逻辑压入运算符栈(期间也会弹出运算符,每当弹出一个运算符时,就需要再弹出两个操作数栈的栈顶元素并执行相应运算,运算结果再压回操作数栈)
代码实现
public static Integer nifixExpressionCount(String[] symbol){
Stack operandStack = new Stack();
Stack operatorStack = new Stack();
for (int i = 0; i < symbol.length; i++) {
String nowElement = symbol[i];
if (isNumeric(nowElement)){
operandStack.push(nowElement);
} else{//运算符和界限符
if (nowElement.equals("(")) {
operatorStack.push(nowElement);
} else if (nowElement.equals(")")) {
while (!operatorStack.isEmpty()) {
if (operatorStack.peek().equals("(")) { //遇到左括号,停止出栈
operatorStack.pop();
break;
}
//运算符栈弹出运算符,操作数栈要弹出两个操作数运算,结果再入操作数栈。
int elementRigth = Integer.parseInt(operandStack.pop().toString()); //先出栈的是右操作数
int elementLeft = Integer.parseInt(operandStack.pop().toString());
if (operatorStack.peek().equals("+")){
operatorStack.pop();
operandStack.push(elementLeft + elementRigth);
}else if(operatorStack.peek().equals("-")){
operatorStack.pop();
operandStack.push(elementLeft - elementRigth);
}else if(operatorStack.peek().equals("*")){
operatorStack.pop();
operandStack.push(elementLeft * elementRigth);
}else if(operatorStack.peek().equals("/")){
operatorStack.pop();
operandStack.push(elementLeft / elementRigth);
}
}
}else { // +- */
if (nowElement.equals("+") || nowElement.equals("-")){
while (!operatorStack.isEmpty()){
if (operatorStack.peek().equals("(")){
operatorStack.pop();
operatorStack.push(nowElement);
break;
}
//运算符栈弹出运算符,操作数栈要弹出两个操作数运算,结果再入操作数栈。
int elementRigth = Integer.parseInt(operandStack.pop().toString()); //先出栈的是右操作数
int elementLeft = Integer.parseInt(operandStack.pop().toString());
if (operatorStack.peek().equals("+")){
operatorStack.pop();
operandStack.push(elementLeft + elementRigth);
}else if(operatorStack.peek().equals("-")){
operatorStack.pop();
operandStack.push(elementLeft - elementRigth);
}else if(operatorStack.peek().equals("*")){
operatorStack.pop();
operandStack.push(elementLeft * elementRigth);
}else if(operatorStack.peek().equals("/")){
operatorStack.pop();
operandStack.push(elementLeft / elementRigth);
}
}
if (operatorStack.isEmpty()){
operatorStack.push(nowElement); //当前元素入栈
}
}else {// * /
while (!operatorStack.isEmpty()){
if (operatorStack.peek().equals("(")){
operatorStack.pop();
operatorStack.push(nowElement);
break;
} else if (operatorStack.peek().equals("+") || operatorStack.peek().equals("-")) {
operatorStack.push(nowElement);
break;
}
//运算符栈弹出运算符,操作数栈要弹出两个操作数运算,结果再入操作数栈。
int elementRigth = Integer.parseInt(operandStack.pop().toString()); //先出栈的是右操作数
int elementLeft = Integer.parseInt(operandStack.pop().toString());
if (operatorStack.peek().equals("+")){
operatorStack.pop();
operandStack.push(elementLeft + elementRigth);
}else if(operatorStack.peek().equals("-")){
operatorStack.pop();
operandStack.push(elementLeft - elementRigth);
}else if(operatorStack.peek().equals("*")){
operatorStack.pop();
operandStack.push(elementLeft * elementRigth);
}else if(operatorStack.peek().equals("/")){
operatorStack.pop();
operandStack.push(elementLeft / elementRigth);
}
}
if (operatorStack.isEmpty()){
operatorStack.push(nowElement); //当前元素入栈
}
}
}
}
}
while (!operatorStack.isEmpty()){
//运算符栈弹出运算符,操作数栈要弹出两个操作数运算,结果再入操作数栈。
int elementRigth = Integer.parseInt(operandStack.pop().toString()); //先出栈的是右操作数
int elementLeft = Integer.parseInt(operandStack.pop().toString());
if (operatorStack.peek().equals("+")){
operatorStack.pop();
operandStack.push(elementLeft + elementRigth);
}else if(operatorStack.peek().equals("-")){
operatorStack.pop();
operandStack.push(elementLeft - elementRigth);
}else if(operatorStack.peek().equals("*")){
operatorStack.pop();
operandStack.push(elementLeft * elementRigth);
}else if(operatorStack.peek().equals("/")){
operatorStack.pop();
operandStack.push(elementLeft / elementRigth);
}
}
return Integer.parseInt(operandStack.pop().toString());
}
栈在递归中的应用
递归(recursion)
从实现的角度看,递归代码主要包含三个要素
递:函数调用自身,通常输入更小或更简化的参数。
终止条件:决定什么时候由“递”转“归”。
归:将当前递归层级的结果返回至上一层。
“递归”算法适合解决: 将原问题分解为更小的子问题,这些子问题和原问题具有相同的形式。
递归的局限性:
- 太多层递归可能会导致栈溢出。
- 递归算法的时间和空间复杂度高。
尾递归(tail recursion):如果函数在返回前的最后一步才进行递归调用,则该函数可以被编译器或解释器优化,使其在空间效率上与迭代相当。
计算正整数的阶乘
//计算正整数 n!
int factorial(int n){
if(n==0|| n==1)
return 1;
return n*factorial(n-1);
}
斐波那契数列
递归树(recursion tree):函数内递归调用了两个函数,这意味着从一个调用产生了两个调用分支。如图 2-6 所示,这样不断递归调用下去,最终将产生一棵层数为 𝑛 的递归树
调用 fib(n) 即可得到斐波那契数列的第 𝑛 个数字。
/* 斐波那契数列:递归 */
int fib(int n) {
// 终止条件 f(1) = 0, f(2) = 1
if (n == 1 || n == 2)
return n - 1;
// 递归调用 f(n) = f(n-1) + f(n-2)
int res = fib(n - 1) + fib(n - 2);
// 返回结果 f(n)
return res;
}
队列
队列(queue): 队列是一种遵循先入先出规则的线性数据结构。
1.队列基本操作
元素加入队尾的操作称为“入队”,删除队首元素的操作称为“出队”。
| 方法名 | 描述 |
|---|---|
| offer() | 入队 |
| poll() | 出队 |
| peek() | 访问队首元素 |
2.队列实现
基于数组的实现
环形数组
双指针
rear = front + size,这个公式计算出的 rear 指向队尾元素之后的下一个位置。
需要让 front 或 rear 在越过数组尾部时,直接回到数组头部继续遍历。
循环索引的作用通常是为了在固定大小的容器中实现索引访问,避免出现越界问题。
- 0%3=0
- 1%3=1
- 2%3=2
- 3%3=0
- 4%3=1
- 5%3=2
- 6%3=0
这显示了取模运算如何形成了一个周期性的模式,对于3而言,这个周期是每3个数重复一次(0, 1, 2, 然后再次是0)。
基于链表的实现
将链表的“头节点”和“尾节点”分别视为“队首”和“队尾”,规定队尾仅可添加节点,队首仅可删除节点。
3.两种实现对比
两种实现的对比结论与栈一致。
4.队列典型应用
双向队列
双向队列(double-ended queue): 双向队列提供了更高的灵活性,允许在头部和尾部执行元素的添加或删除操作。
双向队列就像是栈和队列的组合,或者是两个栈拼在了一起;它表现的是栈 + 队列的逻辑,因此可以实现栈与队列的所有应用,并且更加灵活。
1.双向队列常用操作
| 方法名 | 描述 |
|---|---|
| offerFirst() | 将元素添加至队首 |
| offerLast() | 将元素添加至队尾 |
| pollFirst(); | 队首元素出队 |
| pollLast() | 队尾元素出队 |
| peekFirst() | 访问队首元素 |
| peekLast() | 访问队尾元素 |
2.双向队列实现
基于数组的实现
与基于数组实现队列类似,我们也可以使用环形数组来实现双向队列。
在队列的实现基础上,仅需增加“队首入队”和“队尾出队”的方法。
基于双向链表的实现
3.双向队列应用
双向队列兼具栈与队列的逻辑,因此它可以实现这两者的所有应用场景,同时提供更高的自由度。
习题
1.栈
习题1
n个不同元素进栈,出栈元素不同排列的个数为,上述公式称为卡特兰(Catalan)数,可采用数学归纳法证明(不要求掌握)。
习题2
利用栈对算术表达式 10 * (40 – 30 / 5) + 20 求值时,存放操作数的栈(初始为空)的容量至少为 (4 ) ,才能满足暂存该表达式中的运算数或运算结果的要求。