栈的特点
栈是一种线性结构
相比数组,栈对应操作的是数组的子集
只能从一端添加数据,也只能在一端取出数据
这一端被称之为栈顶
先进后出的特点(Last In First Out)
压栈(入栈)和弹栈(出栈)
元素放入栈的过程就叫做入栈,也可以叫压栈。元素取出来的过程就叫做出栈,也可以叫弹栈。 可以理解成机枪的弹夹,弹夹就是一个栈,子弹一颗一颗压进去,就是压栈,把子弹打出来的过程就是弹栈,当然每次打出的子弹一定是弹夹最上面那颗。这里说的元素可以是任意类型,比如方法的出入栈,普通数据类型的出入栈。数据结构是数据的存储方式,并不限定具体的数据是什么。
虽然这种数据结构看起来很简单,但应用非常广泛和强大,比如程序的撤销功能,把每次操作都进行压栈,每次撤销就取栈顶的操作进行弹栈。在比如程序中的递归,这点应该都不陌生了。比如A方法里面调用B,B方法里面调用C,那么过程就是这样的,不断加载方法进栈,等到加载完成后,栈顶的方法先执行,执行后弹栈,紧接着执行最新栈顶的方法。如果使用递归,是要注意递归条件的,不断的加载方法进栈,内存很快就会撑爆。
!
栈的实现
首先根据栈的特点,大概总结出5个方法,把他们定义成接口
public interface Stack<E> {
//获取栈容量
int getSize();
//栈是否为空
boolean isEmpty();
//压栈
void push(E e);
//弹栈
E pop();
//查看一下栈顶的值
E peek();
}
接着定义一个类,具体实现栈的方法,如果看过上一篇关于数组数据结构的文章,那就很轻松了,这个栈的设计也是基于动态数组的。但是数组操作元素可以很随意,怎么玩都行。但是由于栈先进后出的特点,栈对外暴漏的接口只能操作栈顶,对应的方法就是压栈和弹栈以及查看一下栈顶的数据。
public class ArrayStack implements Stack{
MyArray myArray;
public ArrayStack(){
myArray = new MyArray();
}
public ArrayStack(int capacity)
{
myArray = new MyArray(capacity);
}
@Override
public int getSize() {
return myArray.getSize();
}
@Override
public boolean isEmpty() {
return myArray.isEmpty();
}
@Override
public void push(Object o) {
myArray.addLast(o);
}
@Override
public Object pop() {
return myArray.removeLast();
}
@Override
public Object peek() {
return myArray.getLast();
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append("Stack: bottom[");
for(int x = 0; x < myArray.getSize(); x++)
{
sb.append(myArray.getByIndex(x));
if(x != myArray.getSize() - 1)
{
sb.append(",");
}
}
sb.append("]top");
return sb.toString();
}
}
栈的另一个应用
下面看一下LeetCode上面的一道题
这道题的截图解题思路其实也是利用了栈数据结构的特点
1.遍历整个字符串,将左括号的字符压入栈
2.如果是右括号,取出栈顶的字符,判断当前右括号和栈顶的左括号能匹配不,匹配不了,代表括号格式错误。
如果能匹配,就继续下次循环,以此类推,如果全部能匹配,栈内的数据也应该会为空。
private static boolean isValid(String s)
{
//1.遍历整个字符串
//2.如果是{[(这种左括号就存入到栈中
//3.如果是右括号,就从栈顶取出,看一下栈顶和括号和当前右括号是否能匹配上
//4.循环结束后,如果都能正确匹配上,那么代表栈顶被取空
//使用Java提供的栈,用法和自己定义的没啥区别
Stack<Character> stack = new Stack();
for(int x = 0; x < s.length(); x++)
{
char c = s.charAt(x);
if(c == '{' || c == '[' || c == '(') {
stack.push(c);
}
else
{
//栈顶为空直接返回false
if(stack.isEmpty())
return false;
//从栈顶取数据,匹配一下
char topChar = stack.pop();
if(topChar == '{' && c != '}')
return false;
if(topChar == '[' && c != ']')
return false;
if(topChar == '(' && c != ')')
return false;
}
}
return stack.isEmpty();
}
队列的实现
队列和栈的区别就是栈是先进后出,而队列是先进先出。方法名字发生了变化,栈的push = 队列的enqueue,栈的pop = 队列的dequeue,栈的peek = 队列的getFront,不过大同小异。在入列的时候是往队列的队尾插入元素,取的时候是取队首的元素。就像在食堂打饭一样,后来的要排在队尾,给队首的人先打饭。按照来的先后顺序打饭。代码也像打饭一样简单啦!
@Override
public void enqueue(Object o) {
myArray.addLast(o);
}
@Override
public Object dequeue() {
return myArray.removeFirst();
}
@Override
public Object getFront() {
return myArray.getFirst();
}
循环队列
由于队列中dequeue的操作是O(n)级别的,原因就是每次从队列中取出元素时,都要把之后的元素全部往前挪。下面使用循环队列来优化,将复杂度提升到O(1)级别。
如上图所示,如果进行了dequeue操作后,可不可以不移动元素呢?其实是可以的,但是我需要定义两个指针来表示当前元素的范围。front代表开始位置,tail代表结束位置,也可以说是元素待插入的位置。在初始化的时候,front和tail是在一起的,那么如果说front == tail的话,就代表这个队列为空。
因为是循环队列嘛,后面的位置用完了就可以用前面的了,不用怕破坏队列先进先出的特点,因为这期间一直维护着front和tail的索引位置。下面这个图代表即将有一个元素插入到索引1的位置,插入之后,tail ++,tail指向了索引2的位置,此时front == tail了。是不是有些熟悉?没错,初始化的时候,front == tail代表队列为空,现在队列满了也出现了这种情况(front == tail),这不是我们想要的。可以用tail + 1 = front 来表示队列已经满了,但这就意味着使用这个表达式得空出来一个位置。
还有一个问题,就是队列已经使用到索引为7的位置时,怎么进行循环使用的问题,这个问题可以使用(tail + 1)% capacity 来解决,换句话说,不大于7你就直接用,大于7之后和7取模,余数当作索引。 那么tail + 1 = front表达式就需要修改成 (tail + 1)% capacity == front。
public class LoopQueue<E> implements Queue<E> {
private E[] arr;
private int front; //定义队列的开始指针
private int tail;//定义队列的结尾指针
private int size;//队列中存在的元素个数
public LoopQueue() {
this(10);
}
public LoopQueue(int capacity) {
arr = (E[]) new Object[capacity];
front = 0;
tail = 0;
size = 0;
}
public int getCapacity()
{
return arr.length - 1; //有一个空间不可以使用
}
@Override
public void enqueue(E e) {
//判断容器有没有装满,装满则需要扩容操作,如果(结尾指针 + 1)%数组长度 == 开始指针,代表容器满了。
if((tail + 1) % arr.length == front)
{
resize(getCapacity() * 2);//使用getCapacity是因为获取可用元素的容量
}
//arr[tail]的位置存储该元素,记录tail指向,记录size数量
arr[tail] = e;
tail = (tail + 1) % arr.length;
size++;
}
private void resize(int newCapaticy) {
E[] newArr = (E[]) new Object[newCapaticy + 1];//加一是有一个空间要浪费,真正可以使用的是newCapacity的个数
for(int x = 0; x < size; x++)//一共有size的个数要存入新数组
{
newArr[x] = arr[(front + x) % arr.length];
}
arr = newArr;
front = 0;
tail = size;
}
@Override
public E dequeue() {
if(isEmpty()) {
throw new IllegalArgumentException("The queue is empty.");
}
//取出元素,维护front的索引位置,维护size个数以及判断缩容
E ele = arr[front];
arr[front] = null;
front = (front + 1) % arr.length;
size--;
if(size == getCapacity() / 4 && getCapacity() / 2 != 0)
{
resize(getCapacity() / 2);
}
return ele;
}
@Override
public E getFront() {
if(isEmpty()) {
throw new IllegalArgumentException("The queue is empty.");
}
return arr[front];
}
@Override
public int getSize() {
return size;
}
@Override
public boolean isEmpty() {
return front == tail;
}
//展示数组元素的方法
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append(String.format("Queue size = %d,capacity=%d\n",size,arr.length));
sb.append("front [");
for(int x = front; x != tail; x = (x + 1) % arr.length)
{
sb.append(arr[x]);
if((x + 1) % arr.length != tail)
{
sb.append(",");
}
}
sb.append("] tail");
return sb.toString();
}
}
最后测一下性能,入队由原来的O(n)级别变成了O(1)级别,可以看出来,循环队列的性能用的时间高出优化前的200多倍。
public static double getTimeConsume(Queue<Integer> queue,int count)
{
//记录队列的存取时间
double startTime = System.nanoTime();
Random random = new Random();
for(int x = 0; x < count; x++)
{
queue.enqueue(random.nextInt(Integer.MAX_VALUE));
}
for(int x = 0; x < count; x++)
{
queue.dequeue();
}
double endTime = System.nanoTime();
return (endTime - startTime) / 1000000000.0;
}
public static void main(String[] args)
{
//测试10W条记录两种队列所用的时间
int count = 100000;
double arrayQueue = getTimeConsume(new ArrayQueue(),count);
double loopQueue = getTimeConsume(new LoopQueue(),count);
System.out.println(arrayQueue);
System.out.println(loopQueue);
}
Console:4.4064827
0.016629
栈和队列就到这啦,其实循环队列其实最重要的就是搞清楚怎样维护front和tail的索引位置,只要理解了这个就比较简单了。下一篇文章说说链表。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。