数据结构-栈与队列

一、栈

1.概念

「栈stack」是一种遵循先入后出的逻辑的线性数据结构。
我们可以将栈类比为桌面上的一摞盘子,如果需要拿出底部的盘子,则需要先将上面的盘子依次取出。我们将盘子替换为各种类型的元素(如整数、字符、对象等),就得到了栈数据结构。
如图所示,我们把堆叠元素的顶部称为“栈顶”,底部称为“栈底”。将把元素添加到栈顶的操作叫做“入栈”,删除栈顶元素的操作叫做“出栈”。

2.常用方法

方法描述时间复杂度
push()元素入栈(添加到栈顶)O(1)
pop()栈顶元素出站O(1)
peek()访问栈顶元素O(1)
通常情况下,我们可以直接使用编程语言内置的栈类。然而,某些语言可能没有专门提供栈类,这时我们可 以将该语言的“数组”或“链表”视作栈来使用,并在程序逻辑上忽略与栈无关的操作。
        //初始化栈
        Stack<Integer> stack = new Stack<>();
        //元素入栈
        stack.push(1);
        stack.push(2);
        stack.push(3);

//        访问栈顶元素  访问并不会取出
        System.out.println(stack.peek());//3
//        取出栈顶元素
        System.out.println(stack.pop());//3
//        获取栈的长度、取出后这个元素就出栈了
        System.out.println(stack.size());//2
        //判断栈是否为空
        System.out.println(stack.isEmpty());//false

3.自定义栈的实现

栈遵循先入后出的原则,因此我们只能在栈顶添加或删除元素。然而,数组和链表都可以在任意位置添加和 删除元素,因此栈可以被视为一种受限制的数组或链表 。换句话说,我们可以“屏蔽”数组或链表的部分无 关操作,使其对外表现的逻辑符合栈的特性。

3.1基于链表实现

1)自定义单向链表

@Data
@AllArgsConstructor
@NoArgsConstructor
public class ListNode {
    int val;//节点值
    ListNode nextNode;//指向下一个节点的引用
}

2)自定义栈


//基于链表实现栈
public class LinkedListStack {
    private ListNode headNode; // 将头节点作为栈顶
    private int stkSize = 0; // 栈的长度

    public LinkedListStack() {
        headNode = null;
    }
    /* 获取栈的长度 */
    public int size() {
        return stkSize;
    }
    /* 判断栈是否为空 */
    public boolean isEmpty() {
        return size() == 0;
    }

//    入栈
    public void push(int num){
        ListNode listNode = new ListNode(num,null);
        listNode.nextNode=headNode;
        headNode=listNode;

        stkSize++;
    }
    //访问栈顶元素
    public int peek(){
        if(isEmpty()) throw new IndexOutOfBoundsException();
        return headNode.getVal();
    }

    //出栈
    public int pop(){
        int val = peek();
        headNode=headNode.nextNode;
        stkSize--;
        return val;

    }

}

3)测试


        LinkedListStack linkedListStack = new LinkedListStack();
        linkedListStack.push(1);
        linkedListStack.push(4);
        linkedListStack.push(5);
        linkedListStack.push(3);
        System.out.println(linkedListStack.peek());//3
        System.out.println(linkedListStack.pop());//3
        System.out.println(linkedListStack.size());//3

3.2基于数组实现

使用数组实现栈时,我们可以将数组的尾部作为栈顶。如图所示,入栈与出栈操作分别对应在数组尾部
添加元素与删除元素,时间复杂度都为 𝑂(1)

由于入栈的元素可能会源源不断地增加,因此我们可以使用动态数组,这样就无须自行处理数组扩容问题

代码:


public class ArrayStack {
    private ArrayList<Integer> stack;

    public ArrayStack() {
        // 初始化列表(动态数组)
        stack = new ArrayList<>();
    }

    //入栈
    public void push(Integer num){
        stack.add(num);
    }
    //求栈长
    public Integer size(){
        return stack.size();
    }
    //获取栈顶元素
    public Integer peek(){
        if (size()==0)
            throw new IndexOutOfBoundsException();
        return stack.get(size()-1);

    }
    //出栈
    public Integer pop(){
        if (size()==0)
            throw new IndexOutOfBoundsException();
        return stack.remove(size()-1);
    }
}

此处就不演示了。

3.3两种实现对比

支持操作
两种实现都支持栈定义中的各项操作。数组实现额外支持随机访问,但这已超出了栈的定义范畴,因此一般
不会用到。
时间效率
在基于数组的实现中,入栈和出栈操作都是在预先分配好的连续内存中进行,具有很好的缓存本地性,因此
效率较高。然而,如果入栈时超出数组容量,会触发扩容机制,导致该次入栈操作的时间复杂度变为 𝑂(𝑛)
在链表实现中,链表的扩容非常灵活,不存在上述数组扩容时效率降低的问题。但是,入栈操作需要初始化
节点对象并修改指针,因此效率相对较低。不过,如果入栈元素本身就是节点对象,那么可以省去初始化步
骤,从而提高效率。
综上所述,当入栈与出栈操作的元素是基本数据类型时,例如 int double ,我们可以得出以下结论。
‧ 基于数组实现的栈在触发扩容时效率会降低,但由于扩容是低频操作,因此平均效率更高。
‧ 基于链表实现的栈可以提供更加稳定的效率表现。
空间效率
在初始化列表时,系统会为列表分配“初始容量”,该容量可能超过实际需求。并且,扩容机制通常是按照特
定倍率(例如 2 倍)进行扩容,扩容后的容量也可能超出实际需求。因此, 基于数组实现的栈可能造成一定
的空间浪费
然而,由于链表节点需要额外存储指针, 因此链表节点占用的空间相对较大
综上,我们不能简单地确定哪种实现更加节省内存,需要针对具体情况进行分析

4.栈的典型应用

浏览器中的后退与前进、软件中的撤销与反撤销 。每当我们打开新的网页,浏览器就会将上一个网页执 行入栈,这样我们就可以通过后退操作回到上一页面。后退操作实际上是在执行出栈。如果要同时支持
后退和前进,那么需要两个栈来配合实现。
程序内存管理 。每次调用函数时,系统都会在栈顶添加一个栈帧,用于记录函数的上下文信息。在递归
函数中,向下递推阶段会不断执行入栈操作,而向上回溯阶段则会执行出栈操作。

二、队列

1.概念

「队列 queue」是一种遵循先入先出规则的线性数据结构。顾名思义,队列模拟了排队现象,即新来的人不断 加入队列的尾部,而位于队列头部的人逐个离开。
如图所示,我们将队列的头部称为“队首”,尾部称为“队尾”,将把元素加入队尾的操作称为“入队”,
删除队首元素的操作称为“出队”。

2.常用方法

方法名描述时间复杂度
push()入队至队尾O(1)
pop()
队首元素出队O(1)
peek()
访问队首元素
𝑂(1)
 /* 初始化队列 */
        Queue<Integer> queue = new LinkedList<>();

        /* 元素入队 */
        queue.offer(1);
        queue.offer(3);
        queue.offer(2);
        queue.offer(5);
        queue.offer(4);
        /* 访问队首元素 */
        System.out.println(queue.peek());//1
        /* 元素出队 */
        System.out.println(queue.poll());//1
        System.out.println(queue.poll());//3
        System.out.println(queue.poll());//2
        System.out.println(queue.poll());//5
        /* 获取队列的长度 */
        System.out.println(queue.size());//1
        /* 判断队列是否为空 */
        System.out.println(queue.isEmpty());//false

3.自定义实现队列

为了实现队列,我们需要一种数据结构,可以在一端添加元素,并在另一端删除元素。因此,链表和数组都 可以用来实现队列。

3.1基于链表实现

我们可以将链表的“头节点”和“尾节点”分别视为“队首”和“队尾”,规定队尾仅可添加 节点,队首仅可删除节点

public class LinkedListQueue {
    private ListNode front, rear; // 头节点 front ,尾节点 rear
    private int queSize = 0;

    public LinkedListQueue() {
        front = null;
        rear = null;
    }
    /* 获取队列的长度 */
    public int size() {
        return queSize;
    }

    /* 判断队列是否为空 */
    public boolean isEmpty() {
        return size() == 0;
    }

    //入队
    public void push(int num ){
        ListNode listNode = new ListNode(num, null);
        if(isEmpty()){//如果队列为空,头尾都是这个节点
            front=listNode;
            rear=listNode;
        }else{// 如果队列不为空,则将该节点添加到尾节点后
            //这里很关键,因为第一次入队时头和尾都指向了第一个入队的元素,
            rear.nextNode = listNode;
            rear = listNode;
        }

        queSize++;
    }

    /* 出队 */
    public int pop() {
        int num = peek();
// 删除头节点
        front = front.nextNode;
        queSize--;
        return num;
    }
    /* 访问队首元素 */
    public int peek() {
        if (isEmpty())
            throw new IndexOutOfBoundsException();
        return front.val;
    }

}

3.2基于数组实现

由于数组删除首元素的时间复杂度为 𝑂(𝑛) ,这会导致出队操作效率较低。然而,我们可以采用以下巧妙方
法来避免这个问题。
我们可以使用一个变量 front 指向队首元素的索引,并维护一个变量 size 用于记录队列长度。定义
rear = front + size ,这个公式计算出的 rear 指向队尾元素之后的下一个位置。
基于此设计, 数组中包含元素的有效区间为 [front, rear - 1],各种操作的实现方法如图所示。
‧ 入队操作:将输入元素赋值给 rear 索引处,并将 size 增加 1 。
‧ 出队操作:只需将 front 增加 1 ,并将 size 减少 1 。
可以看到,入队和出队操作都只需进行一次操作,时间复杂度均为 𝑂(1)

你可能会发现一个问题:在不断进行入队和出队的过程中, front rear 都在向右移动, 当它们到达数组尾
部时就无法继续移动了 。为解决此问题,我们可以将数组视为首尾相接的“环形数组”。
对于环形数组,我们需要让 front rear 在越过数组尾部时,直接回到数组头部继续遍历。这种周期性规律
可以通过“取余操作”来实现,
package com.syctest.test1.test.model;
//基于环形数组实现队列
public class ArrayQueue {
    private int[] nums; // 用于存储队列元素的数组
    private int front; // 队首指针,指向队首元素
    private int queSize; // 队列长度

    public ArrayQueue(int capacity){
        nums = new int[capacity];
        front = queSize = 0;
    }

    /* 获取队列的容量 */
    public int capacity() {
        return nums.length;
    }
    /* 获取队列的长度 */
    public int size() {
        return queSize;
    }
    /* 判断队列是否为空 */
    public boolean isEmpty() {
        return queSize == 0;
    }

    /* 入队 */
    public void push(int num) {
        if(queSize==capacity()){
            System.out.println("队列已满");
            return;
        }
        // 计算尾指针,指向队尾索引 + 1
        // 通过取余操作,实现 rear 越过数组尾部后回到头部
        int rear = (front + queSize) % capacity();
        // 将 num 添加至队尾
        nums[rear] = num;
        queSize++;
    }

    /* 出队 */
    public int pop() {
        int num = peek();
        // 队首指针向后移动一位,若越过尾部则返回到数组头部
        front = (front + 1) % capacity();
        queSize--;
        return num;
    }
    /* 访问队首元素 */
    public int peek() {
        if (isEmpty())
            throw new IndexOutOfBoundsException();
        return nums[front];
    }

}
以上实现的队列仍然具有局限性,即其长度不可变。然而,这个问题不难解决,我们可以将数组替换为动态 数组,从而引入扩容机制

4.队列典型应用

淘宝订单 。购物者下单后,订单将加入队列中,系统随后会根据顺序依次处理队列中的订单。在双十一 期间,短时间内会产生海量订单,高并发成为工程师们需要重点攻克的问题。
各类待办事项 。任何需要实现“先来后到”功能的场景,例如打印机的任务队列、餐厅的出餐队列等。
队列在这些场景中可以有效地维护处理顺序。

5.双向队列

在队列中,我们仅能在头部删除或在尾部添加元素。如图 5‑7 所示,「双向队列 double‑ended queue」提供
了更高的灵活性,允许在头部和尾部执行元素的添加或删除操作。

5.1常用方法

方法名
描述
时间复杂度
pushFirst()
将元素添加至队首
𝑂(1)
pushLast()
将元素添加至队尾
𝑂(1)
popFirst()
删除队首元素
𝑂(1)
popLast()
删除队尾元素
𝑂(1)
peekFirst()
访问队首元素
𝑂(1)
peekLast()
访问队尾元素
𝑂(1)

使用java实现的:

public static void main(String[] args) {
        /* 初始化双向队列 */
        Deque<Integer> deque = new LinkedList<>();
        deque.offerLast(2);//与addLast几乎一致,区别是容量不足时这个方法返回false,addLast抛异常
        deque.addLast(3);
        deque.offerFirst(1); // 添加至队首
        deque.addFirst(0);

        //访问元素
        System.out.println(deque.peekFirst());//0
        System.out.println(deque.peekLast());//3

        //队列长度
        System.out.println(deque.size());//4

        //元素出队
        System.out.println(deque.pollFirst());//0

        //队列长度
        System.out.println(deque.size());//3

    }

5.2双向队列应用

双向队列兼具栈与队列的逻辑, 因此它可以实现这两者的所有应用场景,同时提供更高的自由度
我们知道,软件的“撤销”功能通常使用栈来实现:系统将每次更改操作 push 到栈中,然后通过 pop 实现撤销。然而,考虑到系统资源的限制,软件通常会限制撤销的步数(例如仅允许保存 50 步)。当栈的长度超过
50 时,软件需要在栈底(即队首)执行删除操作。 但栈无法实现该功能,此时就需要使用双向队列来替代栈
请注意,“撤销”的核心逻辑仍然遵循栈的先入后出原则,只是双向队列能够更加灵活地实现一些额外逻辑。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值