重温:数据结构与算法 - 07队列

xwzz.jpg

重温:数据结构与算法 - 开篇
重温:数据结构与算法 - 复杂度分析(一)
重温:数据结构与算法 - 复杂度分析(二)
重温:数据结构与算法 - 数组
重温:数据结构与算法 - 链表(一)
重温:数据结构与算法 - 链表(二)
重温:数据结构与算法 - 栈

叨叨两句

最近提了离职,给公司找交接人也面试了不少人,上次作为面试官还是两年前事情;
最大感受大环境还是太过浮躁,五年前如此,今亦是如此,福报996,中年危机,卷王争霸,各种负面信息缠绕在周围;
作为个人,没资格去评论他人的职业规划,给他人灌输鸡汤,更是害人不浅。仅告诫自己:静心、学习、自省、前行。

什么是队列

言归正传,本章介绍另一种“操作受限”的线性表数据结构:队列

结构相同,它仅支持添加和删除操作:

  • 添加操作(入队 - enqueue)
  • 删除操作(出队 - dequeue)

不同点在于与栈的先进后出相反,它具有"先进先出"特性:

排队.png

图中,排队买票就是典型的队列结构,先排队的人优先买到票。

队列结构.png

我们把入队操作称之"enqueue()",出队操作则为"dequeue()",一个简单的队列操作约束大致如下:

// 队列基本操作约束接口
public interface Queue {

    // 入队
    boolean enqueue(T item);

    // 出队
    T dequeue();

    // 是否为空
    boolean isEmpty();

    // 队中数据的数量
    int size();

    // 清空
    void clear();
}

一个满足先进先出的队列容器,其关键在于enqueue()dequeue()函数的实现上,而基础容器的选择同上篇的栈结构一样,使用数组或者链表都可以,使用数组实现队列称之:顺序队列,使用链表实现则为:链式队列
对于资源限制严谨的场景下推荐使用顺序队列,而对资源限制没有要求,仅需先进后出特性的场景可使用链式队列。

顺序队列(数组)

以下是以数组实现顺序队列的思路,大家不妨看看:

顺序队列:

  • 1、定义一个items[]数组来存储数据;

  • 2、定义一个head指针和tail指针,分别指向当前队列的首下标和尾下标;

  • 3、入队操作enqueue()中,校验队列是否已满:

    • 已满 ,返回false,入队失败;
    • 未满,将数据存储到队尾(tail指针位置),且tail指针后移一位 ;
  • 4、出队操作dequeue()中,校验队列是否已空:

    • 已空:返回null
    • 非空:返回队首数据(head指针位置),且将head指针后移一位。

按照这个思路,我把实现代码贴在了下方:

public class ArrayQueue implements Queue {

    private static final int DEFAULT_SIZE = 10;

    // 队列中数据数量
    private int count;

    private Object[] items;

    // 当前队列能存储的最大容量
    private int maxSize;

    // head指向队首下标 , tail指向队尾下标
    private int head = 0, tail = 0;

    public ArrayQueue() {
        this(DEFAULT_SIZE);
    }

    public ArrayQueue(int intSize) {
        items = new Object[intSize];
        this.maxSize = intSize;
    }

    @Override
    public boolean enqueue(T item) {
        // 队列满了,存储失败
        if (tail == maxSize) return false;

        // 存储,队尾下标后移一位
        items[tail] = item;
        ++tail;
        ++count;
        return true;
    }

    @Override
    public T dequeue() {
        // 队空,返回null
        if (isEmpty()) return null;

        // 取出数据,队首下标后移一位
        Object item = items[head];
        ++head;
        --count;
        return (T) item;
    }

    @Override
    public boolean isEmpty() {
        return count == 0;
    }

    @Override
    public int size() {
        return count;
    }

    @Override
    public void clear() {
        if (isEmpty()) return;
        for (int i = 0; i < size(); i++) {
            items[i] = null;
        }
        count = 0;
    }


    public void print() {
        for (int i = head; i < tail; i++) {
            System.out.print(items[i] + "\t");
        }
        System.out.println();
    }
}

测试一下:

ArrayQueue queue = new ArrayQueue<>(3);
queue.enqueue("1");
queue.enqueue("2");
queue.enqueue("3");
queue.enqueue("4");
queue.enqueue("5");
queue.print();
String data1 = queue.dequeue();
System.out.println("出队:" + data1);
String data2 = queue.dequeue();
System.out.println("出队:" + data2);
queue.print();
System.out.println("大小:" + queue.size());

// 问题:队列有空闲空间却无法添加数据
System.out.println(queue.enqueue("6"));
queue.print();
System.out.println("大小:" + queue.size());

输出:

1   2   3   
出队:1
出队:2
3   
大小:1

false
3   
大小:1

通过测试,我们创建了一个大小为3的队列,并一口气增加了5个元素,因队列只能容纳3个元素,后续添加的4、5元素都失败了;紧接出队两个元素1、2,队列最终只剩下元素3,满足队列的先进先出原则。

然而,当我想尝试继续添加元素6时,却失败了!队列当前大小依旧是1,但我们知道队列总大小明明是3,却不能继续向队列添加数据,显然这并不是我想要的结果,通过下图我们可以看到原因:

入队失败.png

当我们在第一步入队操作后,tail指针就已到队尾,无论第二步出队多少个元素,tail指针始终没有被重置,显然这是导致此问题的根本原因。

如何解决?
数据搬移!没错,在数组章节提到过插入一个数据进数组中,需要将插入位置及后面所有的数据都向后搬移一位;同理,当队列中队首元素出队时,将队首之后的所有元素向前搬移一位,并将tail--,这样就可以保证tail指针始终指向的是队内实际尾元素下标,而不是队列实际大小的尾下标。

按照上面思路,的确解决了问题,不过每次出队都需要进行一次数据搬移,这使得dequeue()函数时间复杂度由原先的 O(1) --》O(n).

有没有更好的方案?
换个角度,来看看enqueue()入队函数,还是上面问题,当我们执行第3步,入队元素6时,由于tail指针指向了队尾,入队失败。但通过上图可以看到head指针前是存在空闲空间的,此时我们进行数据搬移可否?

已上图为例,当执行第三步时,tail指针指向队尾,但head指针并不在队首,那么将3搬移至下标为0的位置,重置head、tail指针位置,再入队元素6,按照这个思路修改enqueue()函数如下:

    public boolean enqueue(T item) {
        // 队列到达末尾
        if (tail == maxSize) {
            // 队列满了,存储失败
            if (head == 0) return false;

            // 队列头部有空闲空间,进行数据搬移
            for (int i = head; i < tail; i++) {
                items[i - head] = items[i];
            }
            // 修正head、tail下标
            tail = tail - head;
            head = 0;
        }

        // 存储,队尾下标向后移动一位
        items[tail] = item;
        ++tail;
        ++count;
        return true;
    }

修改后的enqueue()函数时间复杂度,又是多少呢?分析如下:

  • 当tail未达尾部时,可直接入队,时间复杂度:O(1)
  • 当tail达到尾部时,队首有空余空间,进行数据搬移,时间复杂度:O(n)
  • 每次入队操作,tail可能在0n任意位置,0n-1位置的时间复杂度为O(1),在n位置的时间复杂度为O(n),可将这n次搬移操作平摊给前n-1次操作,所以最终平均时间复杂度为:O(1)

链式队列(链表)

OK,接下来学习链式队列:
链式队列相较顺序队列简单很多,不需关心存储空间不够,或数据搬移问题。

链式队列:

  • 1、定义一个head指针和tail指针,用于指向当前队列的首结点和尾结点;
  • 2、入队操作enqueue()时,只需tail.next -> newNode , tail -> tail.next
  • 3、出队操作dequeue()时,head -> head.next
public class LinkedQueue implements Queue {

    private Node head, tail;
    private int count = 0;

    @Override
    public boolean enqueue(T item) {
        Node newNode = new Node<>(item);
        if (head == null) {
            head = tail = newNode;
        } else {
            tail.next = newNode;
            tail = tail.next;
        }
        count++;
        return true;
    }

    @Override
    public T dequeue() {
        if (head == null) return null;
        T item = head.data;
        head = head.next;
        count--;
        return item;
    }

    @Override
    public boolean isEmpty() {
        return count == 0;
    }

    @Override
    public int size() {
        return count;
    }

    @Override
    public void clear() {
        head = null;
    }

    public void print() {
        Node temp = head;
        while (temp != null) {
            System.out.print(temp.data + "\t");
            temp = temp.next;
        }
        System.out.println();
    }

    static class Node {

        public T data;          // 存储数据
        public Node next;    // 下一个结点

        public Node(T data) {
            this.data = data;
        }

    }
}

测试:

        LinkedQueue queue = new LinkedQueue<>();
        queue.enqueue("1");
        queue.enqueue("2");
        queue.enqueue("3");
        queue.enqueue("4");
        queue.enqueue("5");

        queue.print();

        System.out.println("出队:" + queue.dequeue());
        System.out.println("出队:" + queue.dequeue());
        System.out.println("出队:" + queue.dequeue());

        queue.print();

        queue.enqueue("6");

        queue.print();

输出:

出队:1
出队:2
出队:3
4   5   
4   5   6   

可以看到链式队列,无论是入队enqueue()还是出队dequeue(),都可通过指针直接访问到对应结点,所以链式队列的出队入队时间复杂度都是:O(1)

进阶

循环队列

前面提到,在内存资源有限的场景下,推荐使用顺序队列,反之可使用链式队列。如果在有限资源下,又希望能拥有像链式队列一样高效的入队操作,这样的队列能否实现?

循环队列.png

如图所示,我们将数组的首尾相连:

  • 入队时,当数据填充到尾部且继续填充时,重置tail指针指向下标为0的内存空间,诺该空间处在空闲状态则入队成功,否则已满入队失败;
  • 出队时,通过公式head = ( head + 1 ) % maxSize 来计算实际的head指针位置。
/**
 * 循环队列
 */
public class CircularQueue implements Queue {

    private static final int DEFAULT_SIZE = 10;

    // 队列中数据数量
    private int count;

    private Object[] items;

    // 当前队列能存储的最大容量
    private int maxSize;

    // head指向队列头部下标 , tail指向尾部下标
    private int head = 0, tail = 0;

    public CircularQueue() {
        this(DEFAULT_SIZE);
    }

    public CircularQueue(int intSize) {
        items = new Object[intSize];
        this.maxSize = intSize;
    }

    @Override
    public boolean enqueue(T item) {
        // 队满
        if (count == maxSize) return false;
        // tail到达队尾,
        if (tail == maxSize) tail = 0;
        // 存储,队尾下标向后移动一位
        items[tail] = item;
        ++tail;
        ++count;
        return true;
    }

    @Override
    public T dequeue() {
        // 队空,返回null
        if (isEmpty()) return null;
        // 取出数据,队头下标向后移动一位
        Object item = items[head];
        head = (head + 1) % maxSize;
        --count;
        return (T) item;
    }

    @Override
    public boolean isEmpty() {
        return count == 0;
    }

    @Override
    public int size() {
        return count;
    }

    @Override
    public void clear() {
        if (isEmpty()) return;
        for (int i = 0; i < size(); i++) {
            items[i] = null;
        }
        count = 0;
    }


    public void print() {
        for (int i = head, j = 0; j < count; i = (i + 1) % maxSize, j++) {
            System.out.print(items[i] + "\t");
        }
        System.out.println();
    }
}

测试:

        CircularQueue queue = new CircularQueue<>(5);
        queue.enqueue("1");
        queue.enqueue("2");
        queue.enqueue("3");
        queue.enqueue("4");
        queue.enqueue("5");
        queue.enqueue("6");

        queue.print();

        System.out.println("出队:" + queue.dequeue());
        System.out.println("出队:" + queue.dequeue());

        queue.print();

        queue.enqueue("7");

        queue.print();

输出:

1   2   3   4   5   
出队:1
出队:2
3   4   5   
3   4   5   7   

由于入队enqueue()、出队dequeue()分别对tail指针和head指针进行了纠正,不再会出现数据搬移的情况,其时间复杂度都为:O(1).

你可能感兴趣的:(重温:数据结构与算法 - 07队列)