栈也是一种表,只不过这种表的插入和删除操作只能在一个同一个位置,即表的末尾处(栈顶)。对栈的操作最普遍的是入栈和出栈,当然还包括判断栈空、判断栈满、取栈顶等操作。
栈是后进先出的(LIFO
),栈的可见元素只存在于栈顶。
顺序栈可以看做是顺序表的简单实现,但是需要确定把顺序表的表头还是表尾作为栈顶。
如果把表头作为栈顶,入栈和出栈操作都需要在第0
个位置上做插入和删除操作,这就导致每次出入栈都需要移动顺序表中剩余的所有元素,时间复杂度为O(n)
;如果把顺序表的最后一个元素作为栈顶,每次出入栈操作都是在顺序表末尾做删除或添加操作,那么很明显时间复杂度为O(1)
。
顺序栈的实现可以参考:ArrayStack.java
链式栈使用链表实现的,和顺序实现其实没什么特别大的区别,只是用了不同的数据结构,并且链式栈的栈顶不再需要规定是尾节点,因为链表的头尾插入和删除的时间复杂度都是O(1)
。
简单的顺序栈需要预先分配空间(简单数组实现),如果栈中数据太多就可能导致溢出,而依附于ArrayList
实现的话没有溢出的可能,但是在扩容和收缩的时候有复制操作,特别是数据量大的时候,而且扩展的一半没有完全使用会造成空间浪费,不过存储的都是数据本身,数据占用空间会少一些。
链式栈没有顺序栈的大部分烦恼,除了会占用额外的空间,然而实际应用中的栈基本都是顺序表实现的(包括Java
),因为栈不适合存储大量的数据,一般都是用栈实现一种上下文相关的算法,因此不会有太多的数据,使用顺序栈已经足够。
比如检查一个表达式{(a+b)/c-d+[(e-f)/(g+h)+i]}/100
中的{[()]}
是否成对且是否匹配:
public class SymbolPairChecker {
public static boolean check(String pattern) {
if (StringUtils.isNoneBlank(pattern)) {
ArrayStack stack = new ArrayStack<>();
for (int i = 0; i < pattern.length(); i++) {
Character character = pattern.charAt(i);
switch (character) {
case '{':
case '[':
case '(':
if (!stack.isEmpty()) {
Character preCharacter = stack.peek();
if (!order(character, preCharacter)) {
throw new RuntimeException(formatError(pattern, i));
}
}
stack.push(character);
break;
case ')':
case ']':
case '}':
if (stack.isEmpty()) {
throw new RuntimeException(formatError(pattern, i));
}
Character preCharacter = stack.pop();
if (!match(character, preCharacter)) {
throw new RuntimeException(formatError(pattern, i));
}
}
}
if(!stack.isEmpty()){
throw new RuntimeException(formatError(pattern, pattern.length() - 1));
}
}
return true;
}
private static boolean match(Character character, Character preCharacter) {
switch (character) {
case '}':
if(preCharacter != '{'){
return false;
}
break;
case ']':
if(preCharacter != '['){
return false;
}
break;
case ')':
if(preCharacter != '('){
return false;
}
break;
}
return true;
}
private static boolean order(Character character, Character preCharacter) {
return character <= preCharacter;
}
private static String formatError(String pattern, int i) {
return "Symbol not match!\n" + pattern + "\n" + pointer(i);
}
private static String pointer(int i) {
StringBuilder result = new StringBuilder();
for (int j = 0; j < i; j++) {
result.append(" ");
}
return result + "^";
}
public static void main(String[] args) {
check("{(a+b)/c-d+[(e-f)/(g+h)+i]}/100");
//check("(a+b)/c-d+[(e-f)/(g+h)+i]}/100");
//check("((a+b)/c-d+[(e-f)/(g+h)+i]}/100");
//check("([])");
//check("({})");
check("[");
}
}
简单计算器直接边读边解释,不遵守四则运算法则,这肯定是不对的;如果要遵守四则运算,就必须使用后缀表达(逆波兰表达式)式压栈的方式;也可以用栈实现中缀表达式到逆波兰表达式的转换:
public class StackCalculator {
private final static Map CHARACTER_WEIGHT_MAP = new HashMap<>();
static {
CHARACTER_WEIGHT_MAP.put('+', 1);
CHARACTER_WEIGHT_MAP.put('-', 1);
CHARACTER_WEIGHT_MAP.put('*', 2);
CHARACTER_WEIGHT_MAP.put('/', 2);
CHARACTER_WEIGHT_MAP.put('(', 3);
CHARACTER_WEIGHT_MAP.put(')', 4);
}
public static Double eval(String exp) {
exp = exp.replaceAll(" ", "")//干掉空格
.replaceAll("\\(-", "(0-");//转换负数(-2) -> (0-2),其中负数必须用括号括起来
SymbolPairChecker.check(exp);
List reversePolishExp = reversePolishExp(exp);
ArrayStack numStack = new ArrayStack<>();
for (String s : reversePolishExp) {
try {
Double d = Double.parseDouble(s);
numStack.push(d);
} catch (NumberFormatException e){
Double b = numStack.pop();
Double a = numStack.pop();
switch (s){
case "+":
numStack.push(a + b);
break;
case "-":
numStack.push(a - b);
break;
case "*":
numStack.push(a * b);
break;
case "/":
numStack.push(a / b);
break;
}
}
}
return numStack.pop();
}
private static List reversePolishExp(String exp) {
List list = new ArrayList<>();
ArrayStack stack = new ArrayStack<>();
StringBuilder num = new StringBuilder();
for (int i = 0; i < exp.length(); i++) {
Character character = exp.charAt(i);
String type = typeOfCharacter(character);
if (type.equals("num")) {
num.append(character);
} else if (type.equals("op")) {
if(num.length() > 0){
list.add(num.toString());
num = new StringBuilder();
}
if (stack.isEmpty()) {//空栈则直接将操作符入栈
stack.push(character);
} else {
if (character == ')') {//如果是),依次打印栈中所有操作符,直到出现(,其中(需要弹出但不输出
Character toAppend = stack.pop();
while (toAppend != '(') {
list.add(String.valueOf(toAppend));
toAppend = stack.pop();
}
} else {//其他情况时,如果此次操作符权重高于或等于栈中操作符,本次操作符压栈;否则输出栈顶操作符
Character preCharacter = stack.peek();
while (preCharacter != '(' && CHARACTER_WEIGHT_MAP.get(character) <= CHARACTER_WEIGHT_MAP.get(preCharacter)) {
list.add(String.valueOf(stack.pop()));
if(stack.isEmpty()){
break;
}
preCharacter = stack.peek();
}
}
//本次操作符入栈
if(character != ')'){
stack.push(character);
}
}
}
}
if(num.length() > 0){
list.add(num.toString());
}
if(!stack.isEmpty()){
while (!stack.isEmpty()){
list.add(String.valueOf(stack.pop()));
}
}
return list;
}
private static String typeOfCharacter(Character character) {
switch (character) {
case '+':
case '-':
case '*':
case '/':
case '(':
case ')':
return "op";
case '0':
case '1':
case '2':
case '3':
case '4':
case '5':
case '6':
case '7':
case '8':
case '9':
case '.':
return "num";
default:
throw new RuntimeException("Character not allowed here : " + character);
}
}
public static void main(String[] args) {
System.out.println(eval("((12+40)*2-4+((5-6/2)/(1+2)+100))/100"));
System.out.println(eval("11+22*33+(44*55+66)*77"));
System.out.println(eval("1/2-3*(4+5)"));
}
}
上面的方法有点笨,其实可以直接使用两个栈:一个操作数栈,一个操作符栈,一边解析一边计算,不过先转换成逆波兰表达式再计算,思路是非常清晰的,两个栈的方式效率更高。
Java
中任何一个程序片段几乎都是方法的嵌套调用:进入一个方法A()
时,这个方法有各种参数、变量名称和对应的值;然后开始调用另外一个方法A1()
,此时A()
方法的现场推入一个栈中(push()
),然后开始进入A1()
方法,A1()
方法结束后,再把A()
方法的现场恢复出来(pop()
),继续执行;如果A1()
方法中还嵌套有其他方法,可以一样推到同一个栈中。
递归调的实现也很类似,即
f(x)=xf(x−1) f ( x ) = x f ( x − 1 )
一个问题可以被划分成很多个相同的子问题,问题的难度是递减的,最简单的问题是一个常量,并且问题的个数是有限的。也就是说想要解决f(x)
,只需要解决f(x-1)
即可,依次类推直到f(1)
为固定解,最后回退回去依次解决f(2) f(3) ... f(x)
。一个典型的递归——阶乘的非递归实现:
public static Long factorialStack(int n){
Stack stack = new ArrayStack<>();
while(n > 0){
stack.push(n--);
}
Long result = 1L;
while(!stack.isEmpty()){
result *= stack.pop();
}
return result;
}
队列也是一种简单的表实现,只能在头部删除、尾部插入,先进先出。队列用于缓冲,都有容量,当队列满载是,新的入队请求将被阻止,直到有元素出队。
队列的操作一般有:入队、出队、判空、置空、判满、获取队首元素等。
队列可以用顺序表实现,维护两个指针front
和rear
,前者指向队首,后者指向队尾,如果每次入队出队操作只是简单地在尾节点天加或者头节点删除,只需要移动front
和rear
,时间复杂度为O(1)
并不需要复制元素,但是front
指针一直在向后移动,如果某个时刻front
指针移动到了队尾,这是再入队肯定会得到一个数组越界的异常(假溢出);但是如果让front
指针都保持在数组第一个元素,但每次出队都要复制元素,这并不是非常完美的实现方法(ArrayQueue.java)。
有一种解决假溢出办法是将数组的最后一个节点和第一个节点在逻辑上相连,front
和rear
的维护不再是简单的++
,而是如果两个指针任何一个到达数组末尾,直接跳到数组起始位置,每次入队时rear = (rear + 1) / capacity
,每次出队时front = (fron + 1) / capacity
,这样就可以保证是在逻辑上首尾相连的(SimpleArrayQueue.java)。
一个解决假溢出非常有效的方法是使用链表来实现队列,因为本身链表就有首尾节点,删除和添加元素的时间复杂度都是O(1)
,不需要指针来维护,但是需要维护队列当前容量,以保证队列不溢出(LinkedQueue.java)。
因为队列是有固定容量的,基于ADT
定义,没有其他查询操作,因此直接使用顺序栈可以节省空间,Java
中的ArrayBlokingQueue
就是顺序队列。
队列一般都用来做缓存,用来匹配生产和消费速度的不匹配。
比如打印机作业,很多人使用同一台打印机,如果有10
个人同时提交打印请求,和明显打印机是忙不过来的,但是打印请求却可以提交成功的,这些请求被放在一个队列里,按照先到先服务的原则,一个个打印。
再比如redis
的队列,消息的生产速度是很快的,比如批量发送优惠券,但是消息的消费速度却是很慢的,因为一条信息的发送取决于用户的状态、网络等,这时就可以把消息放到一个队列,让发送服务器慢慢消费。