堆栈(栈)Stack

堆栈数据结构

在计算机科学中,堆栈是一种抽象数据类型,用作元素的集合,具有两个主要的操作;

  • PUSH:将元素添加到集合
  • POP:删除最近添加但尚未删除的元素

堆栈是一种 LIFO(后进先出)的线性的数据结构,或者更抽象说是一种顺序集合,push 和 pop 操作只发生在结构的一端,称为栈顶。这种结构可以很容易地从堆栈顶部取出一个项目,而要到达堆栈更深处的一个项目可能需要先取出多个其他项目。例如;我们经常看到的浏览器访问记录,总是把最近记录展示给你。还包括:一摞书、一叠盘子、一脑瓜子生活琐事。

实现堆栈结构

当你真的有场景需要使用后进先出堆栈时,一定是不能使用 Java 提供的 Stack 的。因为这个工具类是在 JDK 1.0 阶段开发的,实现的特别粗糙,包括像 synchronized 锁也是直接加到方法上。同时 JDK Stack 类的注解也提醒,使用 ArrayDeque 替代;

  • Deque 接口及其实现提供了一组更完整和一致的 LIFO 堆栈操作,应优先使用此类。所以我们本章也是以 ArrayDeque 为原型做代码实现。

1. ArrayDeque 介绍

ArrayDeque 是一个基于数组实现的堆栈数据结构,在数据存放时元素通过二进制与运算获取对应的索引存放元素。当数组长度超过初始空间后,进行2的n次幂左移一位扩容,并将数组内容的元素按照分半分别进行迁移。

  • 堆栈的数据结构是以2的次幂进行初始化,扩容时候为2的倍数。它之所这样是因为保证了在后续计算元素索引位置时,可以进行与运算。也就说 2的n次幂-1 得到的值是一个011111的范围,在与元素索引位置计算时候,找到两个值之间1的位置即可。
  • 数据的压栈,压栈是一个在数组中倒放的方式,通过与运算得到索引值。当发生空间不足时扩容迁移数据,会有2次操作。一次是空间的前半段复制,另外一次是后半段复制。
  • 最后在数据弹出时,按照空间的元素数量总数开始,同样通过与运算计算索引值。分为弹出队列中未发生迁移的数据,和已经完全迁移好的数据。凡是迁移的数据,都是保证了一个顺序。

添加元素

public void addFirst(E e) {
    if (e == null)
        throw new NullPointerException();
    elements[head = (head - 1) & (elements.length - 1)] = e;
    logger.info("push.idx head:{}", head);
    if (head == tail)
        doubleCapacity();
}
  • 首先,代码检查传入的元素 e 是否为 null。如果是 null,则抛出 NullPointerException,这是合理的,因为大多数栈结构不允许 null 元素。
  • 然后,通过 head = (head - 1) & (elements.length - 1) 来计算新的 head 位置。这里使用了位运算,(head - 1) & (elements.length - 1) 实际上是在实现一个循环数组的索引计算。假设 elements.length 是 2 的幂次方(比如 4、8、16 等),这种位运算等同于 (head - 1) % elements.length,但位运算效率更高。它把 head 指针向前移动一位(循环移动),并将元素 e 放入新的 head 位置。
  • 接着,代码记录新的 head 索引值到日志中。
  • 最后,如果 head 等于 tail,说明栈满了,调用 doubleCapacity 方法将栈的容量翻倍。

扩容空间

private void doubleCapacity() {
    assert head == tail;
    int p = head;
    int n = elements.length;
    int r = n - p;
    int newCapacity = n << 1;
    if (newCapacity < 0)
        throw new IllegalStateException("Sorry, deque too big");
    Object[] a = new Object[newCapacity];
    /*
     * src      - 源数组
     * srcPos   – 源数组中的起始位置
     * dest     - 目标数组
     * destPos  – 目标数据中的起始位置
     * length   – 要复制的数组元素的数量
     */
    // 第一次拷贝元素:[2、1、4、3] 将数组中的扩容后一半元素拷贝到新数组0开始往后的位置。拷贝4、3
    System.arraycopy(elements, p, a, 0, r);
    // 第二次拷贝元素:[2、1、4、3] 将数组中的前面一半数量的元素,拷贝到新数组后一半开始的位置往后。拷贝2、1
    System.arraycopy(elements, 0, a, r, p);
    elements = a;
    head = 0;
    tail = n;
}
  • assert head == tail;:这是一个断言语句,用于确保调用这个方法时,headtail 确实相等,意味着当前存储结构已满。在生产环境中,如果断言被禁用,这行代码不会执行,所以它主要用于开发和调试阶段来验证逻辑。
  • int p = head;int n = elements.length;:分别获取当前头指针的位置 p 和数组当前的长度 n
  • int r = n - p;:计算头指针 p 右边元素的数量。这里的 “右边” 是基于循环数组的逻辑,因为是循环结构,所以头指针右边的元素数量对于重新分配数组很重要。
  • int newCapacity = n << 1;:将数组的容量翻倍,使用左移运算符 << 相当于乘以 2,这是一种高效的翻倍操作。
  • if (newCapacity < 0):检查新的容量是否溢出。如果 newCapacity < 0,说明发生了溢出,抛出 IllegalStateException 异常。
  • Object[] a = new Object[newCapacity];:创建一个新的更大容量的数组 a
  • System.arraycopy(elements, p, a, 0, r);:将原数组中从 p 位置开始到数组末尾的元素复制到新数组 a 的起始位置。
  • System.arraycopy(elements, 0, a, r, p);:将原数组中从起始位置到 p 位置的元素复制到新数组 a 中紧接在前面复制元素的后面。
  • elements = a;:将新数组 a 赋值给原来存储数据的数组 elements
  • head = 0;tail = n;:重新设置头指针 head 为 0,尾指针 tail 为原数组的长度 n,这样就完成了数组的扩容和指针的调整。

循环数组是一种数据结构,它通过将数组看作是首尾相连的方式,充分利用数组空间,避免频繁的内存分配和释放。在这个场景下,elements 是原循环数组,a 是扩容后的新数组。

  1. 第一次 System.arraycopy
    • System.arraycopy(elements, p, a, 0, r);
    • elements 是原数组,p 是原数组中开始复制的位置(也就是 head 的位置)。
    • a 是目标新数组,0 是新数组中开始粘贴的位置。
    • r 是要复制的元素个数,它等于原数组长度 n 减去 p,也就是从 p 位置到原数组末尾的元素个数。
    • 逻辑作用:想象原数组是一个环形队列,从 headp)位置开始,把这部分元素按顺序复制到新数组的开头部分。例如,原数组 [1, 2, 3, 4]p = 2(对应元素 3),r = 2(元素 34),那么新数组开头部分就会变成 [3, 4]。这一步先把原数组中 head 右侧(循环意义上)的元素复制到新数组的起始位置。
  1. 第二次 System.arraycopy
    • System.arraycopy(elements, 0, a, r, p);
    • 这里 elements 还是原数组,0 表示从原数组的起始位置开始复制。
    • a 依旧是目标新数组,r 是新数组中开始粘贴的位置,这个位置刚好是第一次复制结束的位置。
    • p 是要复制的元素个数,它是从原数组起始位置到 headp)位置的元素个数。
    • 逻辑作用:接着第一次复制,这一步把原数组中从起始位置到 head 位置(不包含第一次已经复制的部分)的元素,复制到新数组中紧挨着第一次复制元素的后面。继续上面的例子,原数组剩下的元素是 [1, 2],现在把它们复制到新数组 [3, 4] 的后面,新数组就变成 [3, 4, 1, 2]。这样就完整地把原循环数组中的所有元素,按照它们在循环数组中的顺序,复制到了新的扩容数组中。

总结

    • 这两次 System.arraycopy 操作的逻辑是为了在扩容循环数组时,保持元素的原有顺序。先复制 head 右侧(循环意义)的元素到新数组开头,再复制原数组起始到 head 位置的元素到新数组第一次复制元素之后,从而完成整个循环数组的复制和扩容。

弹出元素

  • 按照索引的计算,以此是弹出索引为:6、7、0、1、2、3、4 对应的元素。head 的值从扩容的长度添加元素后逐步减小,所以当前最开始弹出的元素是6索引对应的值。

public E pollFirst() {
    int h = head;
    @SuppressWarnings("unchecked")
    E result = (E) elements[h];
    if (result == null) {
        return null;
    }
    elements[h] = null;
    head = (h + 1) & (elements.length - 1);
    logger.info("pop.idx {} = {} & {}", head, Integer.toBinaryString(h + 1), Integer.toBinaryString(elements.length - 1));
    return result;
}
  • 这段代码看起来是一个双端队列(Deque)实现中的 pollFirst 方法,用于从队列头部取出并移除一个元素。
  • int h = head;:首先获取当前队列头部的索引 h,这里 head 应该是类中的一个成员变量,表示队列头部的位置。
  • E result = (E) elements[h];:从 elements 数组(这应该是存储队列元素的数组)的头部位置 h 取出元素,并进行类型转换。
  • if (result == null) return null;:如果取出的元素为 null,说明队列为空,直接返回 null。这是合理的,因为空队列没有元素可取出。
  • elements[h] = null;:将数组中原来头部位置的元素设置为 null,这样做有助于垃圾回收,释放不再使用的对象所占用的内存。
  • head = (h + 1) & (elements.length - 1);:通过这行代码更新头部位置。(h + 1) & (elements.length - 1) 这种写法通常用于在循环数组中移动索引。如果 elements.length 是 2 的幂次方,这个操作等同于 (h + 1) % elements.length,但位运算效率更高。它将头部索引移动到下一个位置,实现了从队列头部移除元素的逻辑。
  • return result;:最后返回取出的元素。

head 位置变化描述

  1. 初始状态headtail 都为 0,数组容量为 4
  2. addFirst(1)
    • 计算新的 head 位置:(head - 1) & (elements.length - 1) = (0 - 1) & 3 = 3
    • head 变为 3,并将 1 放入 elements[3]
  1. addFirst(2)
    • 再次计算新的 head 位置:(3 - 1) & 3 = 2
    • head 变为 2,并将 2 放入 elements[2]
  1. addFirst(3)
    • 计算新的 head 位置:(2 - 1) & 3 = 1
    • head 变为 1,并将 3 放入 elements[1]
  1. addFirst(4)
    • 计算新的 head 位置:(1 - 1) & 3 = 0
    • head 变为 0,并将 4 放入 elements[0]
  1. addFirst(5)
    • 检查到 (head - 1) & (elements.length - 1) == tail,触发 doubleCapacity
    • doubleCapacity 方法中,head 被重置为 0,数组容量翻倍为 8
    • 然后新的 head 计算为 (0 - 1) & 7 = 7,并将 5 放入 elements[7]
  1. pollFirst()
    • 取出 elements[head](即 elements[7])的值 5
    • 更新 head(7 + 1) & 7 = 0head 变为 0
  1. 再次 pollFirst ()
    • 取出 elements[head](即 elements[0])的值 4
    • 更新 head(0 + 1) & 7 = 1head 变为 1
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值