纯数据结构Java实现(2/11)(栈与队列)

栈和队列的应用非常多,但其起实现嘛,其实很少人关心。

虽然苹果一直宣传什么最小年龄的编程者,它试图把编程大众化,弱智化,但真正的复杂问题,需要抽丝剥茧的时候,还是要 PRO 人士出场,所以知根知底,实在是必要之举(而非无奈之举)。

大门敞开,越往里走越窄,竞争会越激烈。

基本特性

就一条,FILO。但是用在其他复杂数据结构,比如树,或者用在其他应用场景的时候,比如记录调用过程中的变量及其状态等,超有用。

应用举例

比如 撤销操作:

用户每次的录入都会入栈,被系统记录,然后写入文件;但是用户撤销,则是当前的操作出栈,此时上一次操作位于栈顶,也就相当于本次操作被取消了。

这里始终 操作栈顶 即可。

比如 程序调用栈:

函数一调用就会入栈(因为这个函数可能内部还要调用别的函数),函数调用返回时,出栈。

每次返回时不知道下一步执行谁?不会的,它会参考栈里面记录的调用链。

比如 括号匹配 问题:

遇到左括号(只要是左边括号)就入栈,碰到右边括号就比较,如果匹配,那么就出栈。(不匹配直接返回false)

(抱歉,Python代码写多了,老是忘记加上 ; 分号)

顺序栈实现

定义好接口,然后内部封装一个动态数组,实现接口的方法即可。

大概的接口,通用的方法就五个:

public interface Stack<E> {
    //接口中声明相关方法即可
    boolean isEmpty();
    int getSize();

    E pop();
    E peek();
    void push(E e);
}

然后实现代码如下:


// 真正的实现
import array.AdvanceDynamicArray;

public class ArrayStack<E> implements Stack<E> {
    //底层实现是动态数组,所以内部直接引用动态数组就好了
    AdvanceDynamicArray<E> array;

    public ArrayStack(int capacity) {
        array = new AdvanceDynamicArray<>(capacity);
    }

    public ArrayStack() {
        array = new AdvanceDynamicArray<>();
    }

    @Override
    public boolean isEmpty() {
        return array.isEmpty();
    }

    @Override
    public int getSize() {
        return array.getSize();
    }

    @Override
    public E pop() {
        return array.pop();
    }

    @Override
    public E peek() {
        return array.getLast();
    }

    @Override
    public void push(E e) {
        array.append(e);
    }

    @Override
    public String toString() {
        StringBuilder res = new StringBuilder();
        res.append("Stack [");
        for (int i = 0; i < array.getSize(); i++) {
            res.append(array.get(i));
            if (i != array.getSize() - 1) {
                res.append(", ");
            }
        }
        res.append("],top right");
        return res.toString();
    }
}

没事儿简单测试一下看看:

//测试一下栈
private static void test_stack_1() {
  ArrayStack<Integer> stack = new ArrayStack<>(); //默认内部动态数组容量 10
  //推入 5 个元素
  for(int i=0; i< 5; i++){
    stack.push(i);
    System.out.println(stack); //每次入栈,打印一次
  }
  System.out.println("---------");
  stack.pop();
  System.out.println(stack);
}

// 打印输入结果如下:
Stack [0],top right
Stack [0, 1],top right
Stack [0, 1, 2],top right
Stack [0, 1, 2, 3],top right
Stack [0, 1, 2, 3, 4],top right
---------
Stack [0, 1, 2, 3],top right

复杂度分析

基本都在末尾操作,所以基本都是 O(1)。

(push 和 pop 由于涉及到扩容和缩容,所以上面的 O(1) 其实是均摊的)

普通队列

同样是一个操作受限的容器

基本特性

感觉就一条 FILO 。

应用举例

我接触的用到的队列,要么是支持并发操作的并发队列,由于加锁,所以并发性并不是很好。

另外一种就是异步任务队列,即把工作加入队列,由外部 IO 接口读取(可能是多个线程,也可能是多路复用的读)

哦,个人在广度优先遍历时用过。(需要统计相关目录及其子目录的各种各样语言的代码量,此时把子目录加入到队列尾部,然后不断出队检查当前目录的文件)

顺序队列实现

  • 定义好接口,底层实现用 动态数组 完成接口中的方法
  • 入队 enqueue,出队 dequeue 为核心 (不必担心满和空,因为一个在数组尾部操作,一个在数组头部操作,空间够不够底层数组负责)

接口及其实现 如下:

//接口
public interface Queue<E> {
    boolean isEmpty();
    int getSize();

    E dequeue();
    E getFront();

    void enqueue(E e);
}

public class ArrayQueue<E> implements Queue<E> {

    private AdvanceDynamicArray<E> array;

    public ArrayQueue(int capacity) {
        array = new AdvanceDynamicArray<>(capacity);
    }

    public ArrayQueue() {
        array = new AdvanceDynamicArray<>();
    }

    @Override
    public boolean isEmpty() {
        return array.isEmpty();
    }

    @Override
    public int getSize() {
        return array.getSize();
    }

    @Override
    public E dequeue() {
        return array.popLeft();
    }

    @Override
    public E getFront() {
        return array.getFirst();
    }

    @Override
    public void enqueue(E e) {
        array.append(e);
    }

    @Override
    public String toString() {
        StringBuilder res = new StringBuilder();
        res.append("Queue: [");
        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();
    }

    //测试看看
    public static void main(String[] args) {
        ArrayQueue<Integer> queue = new ArrayQueue<>();
        //放入元素
        for(int i = 0; i< 10; i++) {
            queue.enqueue(i);
            System.out.println(queue); //放入一个元素,查看一次队列
        }

        //出栈试试
        System.out.println("--------");
        queue.dequeue();
        System.out.println(queue);
    }
}

输出结果:

Queue: [0],tail
Queue: [0, 1],tail
Queue: [0, 1, 2],tail
Queue: [0, 1, 2, 3],tail
Queue: [0, 1, 2, 3, 4],tail
Queue: [0, 1, 2, 3, 4, 5],tail
Queue: [0, 1, 2, 3, 4, 5, 6],tail
Queue: [0, 1, 2, 3, 4, 5, 6, 7],tail
Queue: [0, 1, 2, 3, 4, 5, 6, 7, 8],tail
Queue: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],tail
--------
Queue: [1, 2, 3, 4, 5, 6, 7, 8, 9],tail

复杂度分析

其实就是出队的时候,从头部出,涉及到移动(覆盖元素),所以为 O(n)

其他操作都只在尾部进行,所以都是 O(1),其中尾部 enqueue 是均摊。总体来说,这个出队的消耗时间太大了。如果要 底层实现不变,可以实现其他队列,减少移动次数

循环队列

循环队列是如何减少移动操作?

出队一定要移动元素么? 如果只移动记录队首的标记,这样会不好好一点?

  • 尝试移动标记队首、队尾的标志(索引)

循环队列有一个非常重要的点,区分队列满、队列空的条件:

  • 队列满 (tail+1)%capacity == front
  • 队列空 tail == front

人为的浪费一个空间,不存元素,和队列空条件区分开。(否则队列满和空都能用 tail == front 来判断,无法区分)

基本原理

其实也就是相对于普通队列而言,支持其优化的理由在哪里。

首先老规矩,front 肯定指向的是第一个元素,tail 肯定执行的是最后一个元素的后一个位置,大致如下图:

也即是说,还是基于顺序存储的结构,新增两个变量记录队首和队尾的索引。

然后看一下 tail 和 front 都是怎么变? 一句话总结:

  • 添加元素 tail++ ((tail+1)%capacity)
  • 删除元素 front++ ((front+1)%capacity)

在队列中移动索引,front 或者 tail, 都要取模,以免越界。

细说,初始状态,没有元素,两者都指向索引为 0 的位置,然后添加元素 tail++,不断添加不断++;当且仅在队首出元素的时候,front++。

此时出队就不需要移动元素覆盖前面的了,直接移动索引 front 即可。然后就出现这样的状况:

发现前面有可用的空间,然后也还会出现这样的状态:

然后再往里面扔一个元素试试,结果就循环了:

那再放一个呢?队列满了。

(因为前面说过认为的空出一个空间,让队列满和队列空区分开来)

这里的扩容怎么设计?需要修改底层动态数组么?

原始的动态数组方式,即使它扩容,也无法改变 front 和 tail 关系,所以不适用。
(且扩容拷贝的时候,也要考虑偏移,即取模问题)

具体实现

先把基于 Queue 接口把框架写出来,然后填补 enqueue 和 dequeue 方法。

public class LoopQueue<E> implements Queue<E> {
    //内部自己维护一个数组
    private E[] data;
    private int front, tail; //front 指向头,tail 指向队尾的下一个元素
    private int size; //其实可以用通过 front, tail 实现,但复杂,容易出错

    public LoopQueue(int capacity){
        data = (E[]) new Object[capacity+1]; //因为要故意浪费一个空间
        front = tail = 0;
        size = 0;
    }

    public LoopQueue(){
        data = (E[]) new Object[10+1]; //因为要故意浪费一个空间,默认存储10个元素
        front = tail = 0;
        size = 0;
    }

    //外部能感知的实际能存储的 capacity
    public int getCapacity() {
        return data.length -1; //注意是 data.length 少一个
    }

    //快捷方法,判断队列满 -- 用户不用关心,client始终可以放入 (因为会动态扩容)
    private boolean isFull() {
        //return (tail+1)%getCapacity() == front;
        return (tail+1)%data.length == front; //判断队列满,用实际的 data.length 判断
    }

    @Override
    public boolean isEmpty() {
        //return size == 0;
        return front == tail; //特别注意队列为空的条件
    }

    @Override
    public int getSize() {
        return size; //专门有一个变量维护
    }

    @Override
    public E getFront() {
        //但凡要取元素,都要看看是否为空
        if(isEmpty()){
            throw new IllegalArgumentException("队列为空,不能出队");
        }
        return data[front];
    }

    @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 = 0; i < size; i++) {
            res.append(data[i]);
            if (i != size - 1) {
                res.append(", ");
            }
        } */
       //相对于 front 偏移的方式也是可以的 data[(i+front)%data.length]
        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();
    }  

  // ---------------------- TODO
    @Override
    public E dequeue() {
        //TODO
        return null;
    }

    @Override
    public void enqueue(E e) {
        //TODO

    }
}

上面的遍历方式也可以用取模偏移来写。

然后实现遗留下来的两个 TODO:

入队,先看队列是否为满。

  • 如果满,则重新分配空间,此时新空间自然应该从 0 开始放元素:
    @Override
    public void enqueue(E e) {
        //添加之前,先要看看队列是否是满的
        if (isFull()) {
            //抛出异常 or 动态扩容(包括移动元素)
            resize(2 * getCapacity()); //当前实际占用空间*2
        }

        //入队列
        //data[tail++] = e; // tail++ 可能超过了 data.length
        data[tail] = e;
        tail = (tail + 1) % data.length;
        size++;
    }

    private void resize(int newCapacity) {
        //改变容量,然后移动元素,重置索引
        E[] newData = (E[]) new Object[newCapacity + 1];

        //复制: 把旧的元素,放入新的数组
        //新数组的索引是从 0 -> size 的
        for (int i = 0; i < size; i++) {
            //newData[i] = data[?];
            newData[i] = data[(front + i) % data.length]; //索引移动,用的是data.length 判断
        }

        //重置索引
        front = 0;
        tail = size; //实际个数是不变的
        data = newData; //data.length 变化了,所以 getCapacity() 自然也变了
    }

出队 操作,先看队列是否为空:

  • 如果为空,不返回或者抛出异常
    @Override
    public E dequeue() {
        //先看看是否为空
        if(isEmpty()){
            throw new IllegalArgumentException("队列为空,不能出队");
        }

        E ret = data[front];
        //最好还是把 data[front]  处理一下
        data[front] = null;
        front = (front+1)%data.length;
        size--;
        // 是否需要缩减容量

        return ret;
    }

其实还没有完,如果一直出列,size 比 data.length 小太多,则有必要缩减容量。

即在出队 dequeue 的代码中有必要添加 是否需要缩减容量 这一段:

    @Override
    public E dequeue() {
        //先看看是否为空
        if(isEmpty()){
            throw new IllegalArgumentException("队列为空,不能出队");
        }

        E ret = data[front];
        //最好还是把 data[front]  处理一下
        data[front] = null;
        front = (front+1)%data.length;
        size--;

        //缩减容量(lazy 缩减),当实际存储为 1/4 capacity时,capacity缩减为一半
        if(size == getCapacity()/4 && getCapacity()/2 != 0) {
            resize(getCapacity()/2); //缩减后的容量不能为0
        }

        return ret;
    }

测试看看:

    public static void main(String[] args) {
        LoopQueue<Integer> queue = new LoopQueue<>(); //默认实际存储 10 个元素
        //存储  11 个元素看看
        for(int i=0; i<11; i++){
            queue.enqueue(i);
            System.out.println(queue); // 在 10 个元素满的时候回扩容
        }
        //出队试试
        System.out.println("------");
        queue.dequeue();
        System.out.println(queue);
        //出队到只剩 5 个元素,即 20/4 时,缩减容量
        queue.dequeue();
        queue.dequeue();
        queue.dequeue();
        queue.dequeue();
        queue.dequeue();
        // 6, 7, 8, 9 10
        System.out.println(queue); //此时容量变为 10 了
    }

运行结果:

Queue: size=1, capacity=10
front [0] tail
Queue: size=2, capacity=10
front [0, 1] tail
Queue: size=3, capacity=10
front [0, 1, 2] tail
Queue: size=4, capacity=10
front [0, 1, 2, 3] tail
Queue: size=5, capacity=10
front [0, 1, 2, 3, 4] tail
Queue: size=6, capacity=10
front [0, 1, 2, 3, 4, 5] tail
Queue: size=7, capacity=10
front [0, 1, 2, 3, 4, 5, 6] tail
Queue: size=8, capacity=10
front [0, 1, 2, 3, 4, 5, 6, 7] tail
Queue: size=9, capacity=10
front [0, 1, 2, 3, 4, 5, 6, 7, 8] tail
Queue: size=10, capacity=10
front [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] tail
Queue: size=11, capacity=20
front [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10] tail
------
Queue: size=10, capacity=20
front [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] tail
Queue: size=5, capacity=10
front [6, 7, 8, 9, 10] tail

实现总结

  • 开辟内部数组时,data.length 始终要比指定的 capacity 多一个

    • 即便是 resize,也是 new Object[newCapacity + 1]
  • 队列空 front == tail,队列满 (tail+1)%capacity == tail
  • 满、空的判断都要放在前面(enqueue, dequeue)
  • 索引的移动都要取模,包括 tail 和 front

此时,出队的复杂度也变为 O(1) 了(因为根本没有移动元素)。

复杂度分析

还分析啥?因为普通队列 dequeue 时要移动元素,O(n),所以这里才会拉扯一个循环队列。

所以除了 dequeue 是均摊的 O(1) 以及 enqueue 均摊O(1),其他操作都是 O(1)。



(链式存储的部分,后续再补充进来)

如果有不正确的地方,欢迎批评指正。

以防万一,我这里还是把相关代码上传到 gayhub 上了。

原文地址:https://www.cnblogs.com/bluechip/p/self-pureds-stack-queue.html

时间: 2024-08-14 04:09:05

纯数据结构Java实现(2/11)(栈与队列)的相关文章

纯数据结构Java实现(4/11)(BST)

个人感觉,BST(二叉查找树)应该是众多常见树的爸爸,而不是弟弟,尽管相比较而言,它比较简单. 二叉树基础 理论定义,代码定义,满,完全等定义 不同于线性结构,树结构用于存储的话,通常操作效率更高.就拿现在说的二叉搜索树(排序树)来说,如果每次操作之后会让剩余的数据集减少一半,这不是太美妙了么?(当然不当的运用树结构存储,也会导致从 O(logN) 退化到 O(n)). 值得说明,O(logN) 其实并不准确,准确来说应该说 O(树的高度) 定义&性质&行话 树里面常见的二叉树: BST,

纯数据结构Java实现(5/11)(Set&amp;Map)

纯数据结构Java实现(5/11)(Set&Map) Set 和 Map 都是抽象或者高级数据结构,至于底层是采用树还是散列则根据需要而定. 可以细想一下 TreeMap/HashMap, TreeSet/HashSet 的区别即可 只定义操作接口(操作一致),不管具体的实现,所以即便底层是 BST 亦可(只是效率不高) (我还是直说了吧,如果不要求有序,尽量用 Hash 实现的吧) 集合(Set) 二分搜索树不存放重复元素,所以 BST 就是一个很好的用于实现集合的底层结构 常见应用 其实主要

纯数据结构Java实现(6/11)(二叉堆&amp;优先队列)

堆其实也是树结构(或者说基于树结构),一般可以用堆实现优先队列. 二叉堆 堆可以用于实现其他高层数据结构,比如优先队列 而要实现一个堆,可以借助二叉树,其实现称为: 二叉堆 (使用二叉树表示的堆). 但是二叉堆,需要满足一些特殊性质: 其一.二叉堆一定是一棵完全二叉树 (完全二叉树可以用数组表示,见下面) 完全二叉树缺失的部分一定是在右下方.(每层一定是从左到右的顺序优先存放) 完全二叉树的结构,可以简单理解成按层安放元素的.(所以数组是不错的底层实现) 其二.父节点一定比子节点大 (针对大顶堆

数据结构Java实现07----队列:顺序队列&amp;顺序循环队列、链式队列、顺序优先队列

数据结构Java实现07----队列:顺序队列&顺序循环队列.链式队列.顺序优先队列 一.队列的概念: 队列(简称作队,Queue)也是一种特殊的线性表,队列的数据元素以及数据元素间的逻辑关系和线性表完全相同,其差别是线性表允许在任意位置插入和删除,而队列只允许在其一端进行插入操作在其另一端进行删除操作. 队列中允许进行插入操作的一端称为队尾,允许进行删除操作的一端称为队头.队列的插入操作通常称作入队列,队列的删除操作通常称作出队列. 下图是一个依次向队列中插入数据元素a0,a1,...,an-

浅谈算法和数据结构(1):栈和队列

浅谈算法和数据结构(1):栈和队列 2014/11/03 ·  IT技术                                         · 2 评论                                      ·  数据结构, 栈, 算法, 队列 分享到: 60 SegmentFault D-Day 2015 北京:iOS 站 JDBC之“对岸的女孩走过来” CSS深入理解之relative HTML5+CSS3实现春节贺卡 原文出处: 寒江独钓   欢迎分享原创

小猪的数据结构辅助教程——3.1 栈与队列中的顺序栈

小猪的数据结构辅助教程--3.1 栈与队列中的顺序栈 标签(空格分隔): 数据结构 本节学习路线图与学习要点 学习要点 1.栈与队列的介绍,栈顶,栈底,入栈,出栈的概念 2.熟悉顺序栈的特点以及存储结构 3.掌握顺序栈的基本操作的实现逻辑 4.掌握顺序栈的经典例子:进制变换的实现逻辑 1.栈与队列的概念: 嗯,本节要进行讲解的就是栈 + 顺序结构 = 顺序栈! 可能大家对栈的概念还是很模糊,我们找个常见的东西来拟物化~ 不知道大家喜欢吃零食不--"桶装薯片"就可以用来演示栈! 生产的时

小猪的数据结构辅助教程——3.2 栈与队列中的链栈

小猪的数据结构辅助教程--3.2 栈与队列中的链栈 标签(空格分隔): 数据结构 1.本节引言: 嗯,本节没有学习路线图哈,因为栈我们一般都用的是顺序栈,链栈还是顺带提一提吧, 栈因为只是栈顶来做插入和删除操作,所以较好的方法是将栈顶放在单链表的头部,栈顶 指针与单链表的头指针合二为一~所以本节只是讲下链栈的存储结构和基本操作! 2.链栈的存储结构与示意图 存储结构: typedef struct StackNode { SElemType data; //存放的数据 struct StackN

Java:基于LinkedList实现栈和队列

1.提供一组栈的接口,其底层关联到一个LinkedList(双端队列)实例.由于只暴露部分基于栈实现的接口,所以可以提供安全的栈实现. package junit; import java.util.LinkedList; /** * 用LinkedList实现栈 * * 队列和栈区别:队列先进先出,栈先进后出. * * @author 林计钦 * @version 1.0 Sep 5, 2013 11:24:34 PM */ public class Stack<T> { private L

《数据结构》第3章-栈与队列的学习总结

我之前有接触过栈和队列,当时就觉得很好奇,那是以怎样的存储结构存储数据的呢?拨开重重迷雾,终于学到基础知识了. 学习<栈和队列>有两个星期了,有了前面两个章节的思维基础,我觉得栈和队列学习起来还是很好理解的,通过一些实际应用例子,让我有了更进一步的理解.现在我梳理一下知识,下面总结这一章我所学习到的东西. 一.栈(后进先出:LIFO) 1.顺序栈 这是顺序栈的存储结构: typedef struct { int *base;//栈底指针 int *top; //栈顶指针 int size; /