数据结构与算法(2)——栈和队列

 


前言:题图无关,只是好看,接下来就来复习一下栈和队列的相关知识

前序文章:

1|0


1|1什么是栈


栈是一种用于存储数据的简单数据结构(与链表类似)。数据入栈的次序是栈的关键。可以把一桶桶装的薯片看作是一个栈的例子,当薯片做好之后,它们会依次被添加到桶里,每一片都会是当前的最上面一片,而每次我们取的时候也是取的最上面的那一片,规定你不能破坏桶也不能把底部捅穿,所以第一个放入桶的薯片只能最后一个从桶里取出;

定义:栈(Stack)是一个有序线性表,只能在表的一端(称为栈顶,top)执行插入和删除操作。最后插入的元素将第一个被删除,所以栈也称为后进先出(Last In First Out,LIFO)或先进后出(First In Last Out)线性表;

两个改变栈的操作都有专用名称。一个称为入栈(push),表示在栈中插入一个元素;另一个称为出栈(pop),表示从栈中删除一个元素。试图对一个空栈执行栈操作称为下溢(underflow);试图对一个满栈执行栈操作称为溢出(overflow)。通常,溢出和下溢均认为是异常;

1|2栈的应用


  • 无处不在的Undo操作(撤销);
  • 程序调用的系统栈;
  • 括号/符号匹配;
  • 等等等等....

1|3栈抽象数据类型


下面给出栈抽象数据类型中的操作,为了简单起见,假设数据类型为整型;

栈的主要操作

  • void push(int data):将data(数据)插入栈;
  • int pop():删除并返回最后一个插入栈的元素;

栈的辅助操作

  • int top():返回最后一个插入栈的元素,但不删除;
  • int size():返回存储在栈中元素的个数;
  • int isEmpty():判断栈中是否有元素;
  • int isStackFull():判断栈中是否存满元素;

1|4动态数组简单实现栈结构


我们结合之前创建的Array类,我们能够很好的创建属于我们自己的动态数组实现的栈结构,对于用户来说,我们只需要完成我们的相关操作,并且知道我能够不断地往里添加元素而不出错就行了,所以我们先来定义一个通用的栈接口:

public interface Stack<E> { int getSize(); boolean isEmepty(); void push(E e); E pop(); E top(); }

然后我们往之前的动态数组中添加两个用户友好的方法:

public E getLast() { return get(size - 1); } public E getFirst() { return get(0); }

然后实现自己的动态数组为底层的栈结构就轻松多了:

public class ArrayStack<E> implements Stack<E> { Array<E> array; public ArrayStack(int capacity) { array = new Array<>(capacity); } public ArrayStack() { array = new Array<>(); } @Override public int getSize() { return array.getSize(); } @Override public boolean isEmepty() { return array.isEmpty(); } public int getCapacity() { return array.getCapacity(); } @Override public void push(E e) { array.addLast(e); } @Override public E pop() { return array.removeLast(); } @Override public E top() { return array.getLast(); } @Override public String toString() { StringBuilder res = new StringBuilder(); res.append("Stack:"); res.append("["); for (int i = 0; i < array.getSize(); i++) { res.append(array.get(i)); if (i != array.getSize() - 1) { res.append(","); } } res.append("]"); return res.toString(); } }

简单复杂度分析

从代码中可以看出,几乎所有的时间复杂度都为O(1)级别,比较特别的是push()pop()操作可能涉及到底层数组的扩容或缩容的操作,所以是均摊下来的复杂度;


2|0队列


2|1什么是队列


队列是一种用于存储数据的数据结构(与链表和栈类似),数据到达的次序是队列的关键;在日常生活中队列是指从序列的开始按照顺序等待服务的一队人或物;

定义:队列是一种只能在一端插入(队尾),在另一端删除(队首)的有序线性表。队列中第一个插入的元素也是第一个被删除的元素,所以队列是一种先进先出(FIFO,First In First Out)或后进后出(LiLO,Last In Last Out)线性表;

与栈类似,两个改变队列的操作各有专用名称;在队列中插入一个元素,称为入队(EnQueue),从队列中删除一个元素,称为出队(DeQueue);试图对一个空队列执行出队操作称为下溢(underflow),试图对一个满队列执行入队操作称为溢出(overflow);通常认为溢出和下溢是异常。

2|2队列的一些应用举例


  • 操作系统根据(具有相同优先级的)任务到达的顺序调度任务(例如打印队列);
  • 模拟现实世界中的队列,如售票柜台前的队伍,或者任何需要先来先服务的场景;
  • 多道程序设计;
  • 异步数据传输(文件输入输出、管道、套接字);
  • 等等等等...

2|3动态数组简单实现队列结构


我们仍然定义一个Queue接口来说明我们队列中常用的一些方法:

public interface Queue<E> { int getSize(); boolean isEmpty(); void enqueue(E e); E dequeue(); E getFront(); }

借由我们之前自己实现的动态数组,那么我们的队列就很简单了:

public class ArrayQueue<E> implements Queue<E> { private Array<E> array; public ArrayQueue(int capacity){ array = new Array<>(capacity); } public ArrayQueue(){ array = new Array<>(); } @Override public int getSize(){ return array.getSize(); } @Override public boolean isEmpty(){ return array.isEmpty(); } public int getCapacity(){ return array.getCapacity(); } @Override public void enqueue(E e){ array.addLast(e); } @Override public E dequeue(){ return array.removeFirst(); } @Override public E getFront(){ return array.getFirst(); } @Override public String toString(){ StringBuilder res = new StringBuilder(); res.append("Queue: "); res.append("front ["); for(int i = 0 ; i < array.getSize() ; i ++){ res.append(array.get(i)); if(i != array.getSize() - 1) res.append(", "); } res.append("] tail"); return res.toString(); } }

简单的复杂度分析

  • void enquque(E):O(1)(均摊)
  • E dequeue()O(n)
  • E front():O(1)
  • int getSize():O(1)
  • boolean isEmpty():O(1)

2|4实现自己的循环队列


循环队列的实现其实就是维护了一个front和一个tail分别指向头和尾,然后需要特别注意的呢是判定队满和队空的条件:

  • 队空:front == tail,这没啥好说的;
  • 队满:tail + 1 == front,这里其实是有意浪费了一个空间,不然就判定不了到底是队空还是队满了,因为条件都一样...
public class LoopQueue<E> implements Queue<E> { private E[] data; private int front, tail; private int size; public LoopQueue(int capacity){ data = (E[])new Object[capacity + 1]; front = 0; tail = 0; size = 0; } public LoopQueue(){ this(10); } public int getCapacity(){ return data.length - 1; } @Override public boolean isEmpty(){ return front == tail; } @Override public int getSize(){ return size; } @Override public void enqueue(E e){ if((tail + 1) % data.length == front) resize(getCapacity() * 2); data[tail] = e; tail = (tail + 1) % data.length; size ++; } @Override public E dequeue(){ if(isEmpty()) throw new IllegalArgumentException("Cannot dequeue from an empty queue."); E ret = data[front]; data[front] = null; front = (front + 1) % data.length; size --; if(size == getCapacity() / 4 && getCapacity() / 2 != 0) resize(getCapacity() / 2); return ret; } @Override public E getFront(){ if(isEmpty()) throw new IllegalArgumentException("Queue is empty."); return data[front]; } private void resize(int newCapacity){ E[] newData = (E[])new Object[newCapacity + 1]; for(int i = 0 ; i < size ; i ++) newData[i] = data[(i + front) % data.length]; data = newData; front = 0; tail = size; } @Override public String toString(){ StringBuilder res = new StringBuilder(); res.append(String.format("Queue: size = %d , capacity = %d\n", size, getCapacity())); res.append("front ["); for(int i = front ; i != tail ; i = (i + 1) % data.length){ res.append(data[i]); if((i + 1) % data.length != tail) res.append(", "); } res.append("] tail"); return res.toString(); } }

简单复杂度分析

  • void enquque(E):O(1)(均摊)
  • E dequeue()O(1)(均摊)
  • E front():O(1)
  • int getSize():O(1)
  • boolean isEmpty():O(1)

简单数组队列和循环队列的简单比较

我们来简单对比一下两个队列的性能吧,这里直接上代码:

// 测试使用q运行opCount个enqueueu和dequeue操作所需要的时间,单位:秒 private static double testQueue(Queue<Integer> q, int opCount){ long startTime = System.nanoTime(); Random random = new Random(); for(int i = 0 ; i < opCount ; i ++) q.enqueue(random.nextInt(Integer.MAX_VALUE)); for(int i = 0 ; i < opCount ; i ++) q.dequeue(); long endTime = System.nanoTime(); return (endTime - startTime) / 1000000000.0; } public static void main(String[] args) { int opCount = 100000; ArrayQueue<Integer> arrayQueue = new ArrayQueue<>(); double time1 = testQueue(arrayQueue, opCount); System.out.println("ArrayQueue, time: " + time1 + " s"); LoopQueue<Integer> loopQueue = new LoopQueue<>(); double time2 = testQueue(loopQueue, opCount); System.out.println("LoopQueue, time: " + time2 + " s"); }

我这里的测试结果是这样的,大家也就可见一斑啦:

其实ArrayQueue慢主要是因为出栈时每次都需要把整个结构往前挪一下


3|0LeetCode 相关题目整理


3|120.有效的括号


我的答案:(10ms)

public boolean isValid(String s) { // 正确性判断 if (null == s || s.length() == 1) { return false; } Stack<Character> stack = new Stack<>(); // 遍历输入的字符 for (int i = 0; i < s.length(); i++) { char c = s.charAt(i); // 如果为左括号则push进栈 if (c == '(' || c == '[' || c == '{') { stack.push(c); } else { if (stack.isEmpty()) { return false; } char topChar = stack.pop(); if (c == ')' && topChar != '(') { return false; } if (c == ']' && topChar != '[') { return false; } if (c == '}' && topChar != '{') { return false; } } } // 最后栈为空才能返回true return stack.isEmpty(); }

参考答案:(8ms)

public boolean isValid(String s) { // 正确性判断 if (0 == s.length()) { return true; } if (s.length() % 2 == 1) { return false; } Stack<Character> stack = new Stack(); char[] cs = s.toCharArray(); for (int i = 0; i < cs.length; i++) { if (cs[i] == '(' || cs[i] == '[' || cs[i] == '{') { stack.push(cs[i]); } else { if (stack.isEmpty()) { return false; } char c = stack.pop(); if ((cs[i] == ')' && c == '(') || (cs[i] == '}' && c == '{') || (cs[i] == ']' && c == '[')) { } else { return false; } } } return stack.isEmpty(); }

3|2155. 最小栈(剑指Offer面试题30)


参考答案(107ms)

class MinStack { // 数据栈,用于存放插入的数据 private Stack<Integer> dataStack; // 最小数位置栈,存放数据栈中最小的数的位置 private Stack<Integer> minStack; /** * initialize your data structure here. */ public MinStack() { this.dataStack = new Stack<>(); this.minStack = new Stack<>(); } /** * 元素入栈 * * @param x 入栈的元素 */ public void push(int x) { dataStack.push(x); // 如果最小栈是空的,只要将元素入栈 if (minStack.isEmpty()) { minStack.push(x); } // 如果最小栈中有数据 else { minStack.push(Math.min(x, minStack.peek())); } } /** * 出栈方法 */ public void pop() { // 如果栈已经为空,则返回(LeetCode不能抛异常...) if (dataStack.isEmpty()) { return; } // 如果有数据,最小数位置栈和数据栈必定是有相同的元素个数, // 两个栈同时出栈 minStack.pop(); dataStack.pop(); } /** * 返回栈顶元素 * * @return 栈顶元素 */ public int top() { return dataStack.peek(); } /** * 获取栈中的最小元素 * * @return 栈中的最小元素 */ public int getMin() { // 如果最小数公位置栈已经为空(数据栈中已经没有数据了),则抛出异常 if (minStack.isEmpty()) { return 0; } // 获取数据占中的最小元素,并且返回结果 return minStack.peek(); } }

改进答案:

上面求解方法的主要问题在于,每次push操作时,minStack也执行了一次push操作(新元素或当前的最小元素),也就是说,重复执行了最小值的入栈操作,所以现在我们来修改算法降低空间复杂度。仍然需要设置一个minStack,但是只有当从dataStack中出栈的元素等于minStack栈顶元素时,才对minStack执行出栈的操作;也只有当dataStack入栈的元素小于或等于当前最小值时,才对minStack执行入栈操作,下面就简单写一下了主要看一下出栈和入栈实现的逻辑就好了:

class MinStack { private Stack<Integer> dataStack; private Stack<Integer> minStack; public MinStack() { this.dataStack = new Stack<>(); this.minStack = new Stack<>(); } public void push(int x) { dataStack.push(x); if (minStack.isEmpty() || minStack.peek() >= (Integer) x) { minStack.push(x); } } public void pop() { if (dataStack.isEmpty()) { return; } Integer minTop = minStack.peek(); Integer dataTop = dataStack.peek(); if (minTop.intValue() == dataTop.intValue()) { minStack.pop(); } dataStack.pop(); } public int top() { return dataStack.peek(); } public int getMin() { return minStack.peek(); } }

3|3225. 用队列实现栈


我的答案:(118ms)

class MyStack { private Queue<Integer> queue1; private Queue<Integer> queue2; /** * Initialize your data structure here. */ public MyStack() { queue1 = new LinkedList<>(); queue2 = new LinkedList<>(); } /** * Push element x onto stack. */ public void push(int x) { if (queue1.isEmpty()) { queue2.offer(x); } else { queue1.offer(x); } } /** * Removes the element on top of the stack and returns that element. */ public int pop() { int size; if (!queue1.isEmpty()) { size = queue1.size(); for (int i = 0; i < size - 1; i++) { queue2.offer(queue1.poll()); } return queue1.poll(); } else { size = queue2.size(); for (int i = 0; i < size - 1; i++) { queue1.offer(queue2.poll()); } return queue2.poll(); } } /** * Get the top element. */ public int top() { int size; if (!queue1.isEmpty()) { size = queue1.size(); for (int i = 0; i < size - 1; i++) { queue2.offer(queue1.poll()); } int result = queue1.peek(); queue2.offer(queue1.poll()); return result; } else { size = queue2.size(); for (int i = 0; i < size - 1; i++) { queue1.offer(queue2.poll()); } int result = queue2.peek(); queue1.offer(queue2.poll()); return result; } } /** * Returns whether the stack is empty. */ public boolean empty() { return queue1.isEmpty() && queue2.isEmpty(); } }

参考答案:(121ms)

class MyStack { Queue<Integer> q; /** * Initialize your data structure here. */ public MyStack() { this.q = new LinkedList<Integer>(); } /** * Push element x onto stack. */ public void push(int x) { q.add(x); } /** * Removes the element on top of the stack and returns that element. */ public int pop() { int size = q.size(); for (int i = 0; i < size - 1; i++) { q.add(q.remove()); } return q.remove(); } /** * Get the top element. */ public int top() { int size = q.size(); for (int i = 0; i < size - 1; i++) { q.add(q.remove()); } int ret = q.remove(); q.add(ret); return ret; } /** * Returns whether the stack is empty. */ public boolean empty() { return q.isEmpty(); } }

确实写得简洁啊,这样一来我就使用一个队列和两个队列都掌握啦,开心~

3|4232.用栈实现队列(剑指Offer面试题9)


参考答案:(72ms)

class MyQueue { Stack<Integer> pushstack; Stack<Integer> popstack; /** * Initialize your data structure here. */ public MyQueue() { this.pushstack = new Stack(); this.popstack = new Stack(); } /** * Push element x to the back of queue. */ public void push(int x) { pushstack.push(x); } /** * Removes the element from in front of queue and returns that element. */ public int pop() { if (popstack.isEmpty()) { while (!pushstack.isEmpty()) { popstack.push(pushstack.pop()); } } return popstack.pop(); } /** * Get the front element. */ public int peek() { if (popstack.isEmpty()) { while (!pushstack.isEmpty()) { popstack.push(pushstack.pop()); } } return popstack.peek(); } /** * Returns whether the queue is empty. */ public boolean empty() { return pushstack.isEmpty() && popstack.isEmpty(); } }

4|0其他题目整理


4|1剑指Offer面试题31:栈的压入、弹出序列


题目:输入两个整数序列,第一个序列表示栈的压入顺序,请判断第二个序列是否为该栈的弹出顺序。假设压入栈的所有数字均不相等。例如,序列{1,2,3,4,5}是某栈的压栈序列,序列{4,5,3,2,1}是该压栈序列对应的一个弹出序列,但{4,3,5,1,2}就不可能是该压栈序列的弹出序列。

参考答案:(原文链接:https://blog.csdn.net/derrantcm/article/details/46691083)

public class Test22 { /** * 输入两个整数序列,第一个序列表示栈的压入顺序,请判断二个序列是否为该栈的弹出顺序。 * 假设压入栈的所有数字均不相等。例如序列1 、2、3 、4、5 是某栈压栈序列, * 序列4、5、3、2、1是该压栈序列对应的一个弹出序列, * 但4、3、5、1、2就不可能是该压棋序列的弹出序列。 * 【与书本的的方法不同】 * * @param push 入栈序列 * @param pop 出栈序列 * @return true:出栈序列是入栈序列的一个弹出顺序 */ public static boolean isPopOrder(int[] push, int[] pop) { // 输入校验,参数不能为空,并且两个数组中必须有数字,并且两个数组中的数字个数相同 // 否则返回false if (push == null || pop == null || pop.length == 0 || push.length == 0 || push.length != pop.length) { return false; } // 经过上面的参数校验,两个数组中一定有数据,且数据数目相等 // 用于存放入栈时的数据 Stack<Integer> stack = new Stack<>(); // 用于记录入栈数组元素的处理位置 int pushIndex = 0; // 用于记录出栈数组元素的处理位置 int popIndex = 0; // 如果还有出栈元素要处理 while (popIndex < pop.length) { // 入栈元素还未全部入栈的条件下,如果栈为空,或者栈顶的元素不与当前处理的相等,则一直进行栈操作, // 直到入栈元素全部入栈或者找到了一个与当出栈元素相等的元素 while (pushIndex < push.length && (stack.isEmpty() || stack.peek() != pop[popIndex])) { // 入栈数组中的元素入栈 stack.push(push[pushIndex]); // 指向下一个要处理的入栈元素 pushIndex++; } // 如果在上一步的入栈过程中找到了与出栈的元素相等的元素 if (stack.peek() == pop[popIndex]) { // 将元素出栈 stack.pop(); // 处理下一个出栈元素 popIndex++; } // 如果没有找到与出栈元素相等的元素,说明这个出栈顺序是不合法的 // 就返回false else { return false; } } // 下面的语句总是成立的 // return stack.isEmpty(); // 为什么可以直接返回true:对上面的外层while进行分析可知道,对每一个入栈的元素, // 在stack栈中,通过一些入栈操作,总可以在栈顶上找到与入栈元素值相同的元素, // 这就说明了这个出栈的顺序是入栈顺序的一个弹出队列,这也可以解释为什么stack.isEmpty() // 总是返回true,所有的入栈元素都可以进栈,并且可以被匹配到,之后就弹出,最后栈中就无元素。 return true; } /** * 输入两个整数序列,第一个序列表示栈的压入顺序,请判断二个序列是否为该栈的弹出顺序。 * 【按书本上的思路进行求解,两者相差不大】 * * @param push 入栈序列 * @param pop 出栈序列 * @return true:出栈序列是入栈序列的一个弹出顺序 */ public static boolean isPopOrder2(int[] push, int[] pop) { // 用于记录判断出栈顺序是不是入栈顺的一个出栈序列,默认false boolean isPossible = false; // 当入栈和出栈数组者都不为空,并且都有数据,并且数据个数都相等 if (push != null && pop != null && push.length > 0 && push.length == pop.length) { // 用于存放入栈时的数据 Stack<Integer> stack = new Stack<>(); // 记录下一个要处理的入栈元素的位置 int nextPush = 0; // 记录下一个要处理的出栈元素的位置 int nextPop = 0; // 如果出栈元素没有处理完就继续进行处理 while (nextPop < pop.length) { // 如果栈为空或者栈顶的元素与当前处理的出栈元素不相同,一直进行操作 while (stack.isEmpty() || stack.peek() != pop[nextPop]) { // 如果入栈的元素已经全部入栈了,就退出内层循环 if (nextPush >= push.length) { break; } // 执行到此处说明还有入栈元素可以入栈 // 即将元素入栈 stack.push(push[nextPush]); // 指向下一个要处理的入栈元素的位置 nextPush++; } // 执行到此处有两种情况: // 第一种:在栈顶上找到了一个与入栈元素相等的元素 // 第二种:在栈顶上没有找到一个与入栈元素相等的元素,而且输入栈的元素已经全部入栈了 // 对于第二种情况就说弹出栈的顺序是不符合要求的,退出外层循环 if (stack.peek() != pop[nextPop]) { break; } // 对应到第一种情况:需要要栈的栈顶元素弹出 stack.pop(); // 指向下一个要处理的出栈元素的位置 nextPop++; } // 执行到此处有两种情况 // 第一种:外层while循环的在第一种情况下退出, // 第二种:所有的出栈元素都被正确匹配 // 对于出现的第一种情况其stack.isEmpty()必不为空,原因为分析如下: // 所有的入栈元素一定会入栈,但是只有匹配的情况下才会出栈, // 匹配的次数最多与入栈元素个数元素相同(两个数组的长度相等),如果有不匹配的元素, // 必然会使出栈的次数比入栈的次数少,这样栈中至少会有一个元素 // 对于第二种情况其stack.isEmpty()一定为空 // 所以书本上的nextPop == pop.length(pNextPop-pPop==nLength)是多余的 if (stack.isEmpty()) { isPossible = true; } } return isPossible; } public static void main(String[] args) { int[] push = {1, 2, 3, 4, 5}; int[] pop1 = {4, 5, 3, 2, 1}; int[] pop2 = {3, 5, 4, 2, 1}; int[] pop3 = {4, 3, 5, 1, 2}; int[] pop4 = {3, 5, 4, 1, 2}; System.out.println("true: " + isPopOrder(push, pop1)); System.out.println("true: " + isPopOrder(push, pop2)); System.out.println("false: " + isPopOrder(push, pop3)); System.out.println("false: " + isPopOrder(push, pop4)); int[] push5 = {1}; int[] pop5 = {2}; System.out.println("false: " + isPopOrder(push5, pop5)); int[] push6 = {1}; int[] pop6 = {1}; System.out.println("true: " + isPopOrder(push6, pop6)); System.out.println("false: " + isPopOrder(null, null)); // 测试方法2 System.out.println(); System.out.println("true: " + isPopOrder2(push, pop1)); System.out.println("true: " + isPopOrder2(push, pop2)); System.out.println("false: " + isPopOrder2(push, pop3)); System.out.println("false: " + isPopOrder2(push, pop4)); System.out.println("false: " + isPopOrder2(push5, pop5)); System.out.println("true: " + isPopOrder2(push6, pop6)); System.out.println("false: " + isPopOrder2(null, null)); } }

简单总结

栈和队列的应用远不止上面学习到的那些,实现方式也有很多种,现在也只是暂时学到这里,通过刷LeetCode也加深了我对于这两种数据结构的认识,不过自己还需要去熟悉了解一下计算机系统关于栈的应用这方面的知识,因为栈这种结构本身就很适合用来保存CPU现场之类的工作,还是抓紧时间吧,过两天还考试,这两天就先复习啦...

欢迎转载,转载请注明出处!
简书ID:@我没有三颗心脏
github:wmyskxz
欢迎关注公众微信号:wmyskxz_javaweb
分享自己的Java Web学习之路以及各种Java学习资料


__EOF__

本文作者我没有三颗心脏
本文链接https://www.cnblogs.com/wmyskxz/p/9272295.html
关于博主:评论和私信会在第一时间回复。或者直接私信我。
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!
声援博主:如果您觉得文章对您有帮助,可以点击文章右下角推荐一下。您的鼓励是博主的最大动力!
posted @   我没有三颗心脏  阅读(1857)  评论(1编辑  收藏  举报
编辑推荐:
· go语言实现终端里的倒计时
· 如何编写易于单元测试的代码
· 10年+ .NET Coder 心语,封装的思维:从隐藏、稳定开始理解其本质意义
· .NET Core 中如何实现缓存的预热?
· 从 HTTP 原因短语缺失研究 HTTP/2 和 HTTP/3 的设计差异
阅读排行:
· 周边上新:园子的第一款马克杯温暖上架
· Open-Sora 2.0 重磅开源!
· 分享 3 个 .NET 开源的文件压缩处理库,助力快速实现文件压缩解压功能!
· Ollama——大语言模型本地部署的极速利器
· DeepSeek如何颠覆传统软件测试?测试工程师会被淘汰吗?
点击右上角即可分享
微信分享提示

喜欢请打赏

扫描二维码打赏

支付宝打赏