太好啦 發表於 2025-8-4 08:34:00

ArrayDeque双端队列--底层原理可视化

<blockquote>
<p>主要学习双端队列 ArrayDeque ,通过对其栈功能的使用,掌握循环数组底层原理</p>
<p>觉得文章枯燥的可以结合ArrayDeque 底层原理可视化视频:https://www.bilibili.com/video/BV1zChGz8EVL/</p>
</blockquote>
<p>有环形的<strong>数组</strong>?同时具备<strong>栈功能</strong>和<strong>队列功能</strong>?</p>
<img src="https://img2024.cnblogs.com/blog/1209017/202508/1209017-20250804082021624-173738934.jpg" alt="" width="60%">
<h2 id="1-java-中的栈实现">1. Java 中的栈实现</h2>
<p>在Java中,栈类你可以直接找到的是<code>Stack</code>类。<code>Stack</code>类实在JDK 1.0 的时候就有了,但你会发现在<code>Stack</code>类头注释写着:建议优先使用<code>Deque</code>接口及其实现类,例如:<code>ArrayDeque</code>。</p>
<h3 id="11-stack">1.1. Stack</h3>
<p><code>Stack</code> 继承自 <code>Vector&lt;E&gt;</code>,线程安全,但因每次操作都要加锁,性能较差。<code>Vector</code> 集合基本也不会用到的。</p>
<p>示例:</p>
<pre><code class="language-java">Stack&lt;Integer&gt; stack = new Stack&lt;&gt;();
stack.push(1);
int top = stack.peek();
int popped = stack.pop();
</code></pre>
<h3 id="12-linkedlist">1.2. LinkedList</h3>
<p><code>LinkedList</code>实现了<code>Deque</code>双端队列接口,具备了队列功能和栈功能,也就是说<code>LinkedList</code> 可以当做普通List集合来用,同时还可以当做栈或队列来使用。</p>
<p>以下是通过LinkedList 来实现入栈、出栈操作:</p>
<pre><code class="language-java">LinkedList&lt;String&gt; linkedList = new LinkedList&lt;&gt;();
// 入栈
linkedList.push("渊");
linkedList.push("渟");
linkedList.push("岳");
linkedList.push("Why?");
System.out.println(linkedList); //
// 获取栈顶元素
String peek = linkedList.peek();//不会出栈
System.out.println(peek); // Why?
// 出栈
String pop = linkedList.pop();//出栈一个
System.out.println(pop);// Why?
linkedList.pop();   //再出栈一个,不获取结果
System.out.println(linkedList);// 剩下的元素:[渟, 渊]
</code></pre>
<p>使用双端队列功能时,如果你想将<strong>引用改为接口名</strong>,<br>
❌这样写是错的:<code>List&lt;String&gt; linkedList = new LinkedList&lt;&gt;();</code><br>
✔得这样写才行:<code>Deque&lt;String&gt; linkedList = new LinkedList&lt;&gt;();</code></p>
<h3 id="13-arraydeque">1.3. ArrayDeque</h3>
<p><code>Deque</code>接口为双端队列接口,<code>ArrayDeque</code>实现了该接口。</p>
<p><code>ArrayDeque</code> 看命名就知道是双端队列,并且底层数据结构为数组。<code>ArrayDeque</code>除了具有<strong>队列(FIFO)</strong>功能,同时它还具备<strong>栈(LIFO)</strong>功能,所以它可以当做栈来使用。</p>
<p><strong>栈功能</strong>示例:</p>
<pre><code class="language-java">Deque&lt;Integer&gt; deque = new ArrayDeque&lt;&gt;();
deque.push(1);
deque.push(2);
deque.push(3);
System.out.println(deque); //
int top = deque.peek();
System.out.println(top); // 3
int popped = deque.pop();
System.out.println(popped); // 3
System.out.println(deque); //
</code></pre>
<p><code>LinkedList</code>在集合学习时已经学过了,它的双端队列功能并不会影响底层数据结构,仅仅是操作逻辑不同而已。</p>
<p>在双端队列功能上,<code>LinkedList</code>没有<code>ArrayDeque</code>性能高,通常使用<code>ArrayDeque</code>更多,所以我们详细来学学<code>ArrayDeque</code>有什么独特之处?</p>
<h2 id="2-arraydeque底层原理">2. ArrayDeque底层原理</h2>
<p>在Java中,可以通过双端队列<code>Deque</code>的实现类来实现<strong>栈功能</strong>,常用的有两个:<code>ArrayDeque</code> 和 <code>LinkedList</code>。</p>
<p>两个类继承与实现:</p>
<pre><code class="language-java">// ArrayDeque
public class ArrayDeque&lt;E&gt; extends AbstractCollection&lt;E&gt;
                           implements Deque&lt;E&gt;, Cloneable, Serializable
// LinkedList
public class LinkedList&lt;E&gt;
    extends AbstractSequentialList&lt;E&gt;
    implements List&lt;E&gt;, Deque&lt;E&gt;, Cloneable, java.io.Serializable
</code></pre>
<p><code>LinkedList</code> 实现类既是<code>List</code>集合、同时又是<code>Deque</code>双端队列,也就是说,这个类具备多种功能:链式集合、链式队列和链式栈三种功能。</p>
<p><code>ArrayDeque</code> 实现类具备两种功能:队列和栈。</p>
<p>上一篇栈的文章也讲述过使用<strong>单链表实现自定义栈</strong>,并使用自定义栈完成了<strong>有效括号匹配</strong>实战,在此,主要完成<code>ArrayDeque</code>栈功能的学习。</p>
<h3 id="21-arraydeque的数据结构">2.1. ArrayDeque的数据结构</h3>
<p>看命名就知道是双端队列,并且底层数据结构为<strong>数组</strong>。<code>ArrayDeque</code>类主要字段如下:</p>
<pre><code class="language-java">public class ArrayDeque&lt;E&gt; extends AbstractCollection&lt;E&gt;
                           implements Deque&lt;E&gt;, Cloneable, Serializable{
    // 数据就保存在这个数组,大小总是2的幂
    transient Object[] elements;
        // 头索引
    transient int head;
        // 尾索引
    transient int tail;
    // 允许最小容量
    private static final int MIN_INITIAL_CAPACITY = 8;

        ...
}
</code></pre>
<p>特别说明下<code>MIN_INITIAL_CAPACITY=8</code>,这个最小容量是在你指定<code>ArrayDeque</code>大小时才会用到。比如你指定大小为7,那它创建出来的大小为8,它的计算逻辑和HashMap的一模一样。<code>ArrayDeque</code>的默认大小为<strong>16</strong>。相关代码如下:</p>
<pre><code class="language-java">// 默认构造方法
public ArrayDeque() {
        elements = new Object;
}

// 指定大小构造方法
public ArrayDeque(int numElements) {
        allocateElements(numElements);
}

private void allocateElements(int numElements) {
        elements = new Object;
}

// 计算结果为2的幂次方,跟HashMap的计算逻辑一样
private static int calculateSize(int numElements) {
        int initialCapacity = MIN_INITIAL_CAPACITY;
       
        if (numElements &gt;= initialCapacity) {
                initialCapacity = numElements;
                initialCapacity |= (initialCapacity &gt;&gt;&gt;1);
                initialCapacity |= (initialCapacity &gt;&gt;&gt;2);
                initialCapacity |= (initialCapacity &gt;&gt;&gt;4);
                initialCapacity |= (initialCapacity &gt;&gt;&gt;8);
                initialCapacity |= (initialCapacity &gt;&gt;&gt; 16);
                initialCapacity++;

                if (initialCapacity &lt; 0)
                        initialCapacity &gt;&gt;&gt;= 1;
        }
        return initialCapacity;
}
</code></pre>
<h3 id="22-为什么大小设置为2的幂次方">2.2. 为什么大小设置为2的幂次方?</h3>
<p>如果学过HashMap底层实现逻辑,那就非常容易理解,之前的HashMap文章还专门讲了这个。</p>
<p><strong>它是<code>HashMap</code>的哈希值映射数组下标和<code>ArrayDeque</code>循环数组得以实现的基石。</strong></p>
<p>使得通过<strong>与(&amp;)运算</strong>高效完成<strong>数组下标映射</strong>,非常方便哈希值映射计算和循环索引计算。为得就是方便计算元素索引位置、提高计算效率,特别是扩容后需要做的调整,也变得简单高效。</p>
<p>通过添加元素(入栈)、动态扩容和移除元素(出栈)这些操作感受<strong>与(&amp;)运算</strong>的巧妙之处。</p>
<h3 id="23-添加元素--入栈">2.3. 添加元素--入栈</h3>
<p>从添加元素开始,直到元素达到阈值后触发动态扩容,再接着学习动态扩容。</p>
<p>元素入栈操作:</p>
<pre><code class="language-java">Deque&lt;Integer&gt; stack = new ArrayDeque&lt;&gt;();
stack.push(1);// 相当于 addFirst
stack.push(2);
stack.push(3);
stack.push(4);
stack.push(5);
stack.push(6);
stack.push(7);
stack.push(8);
System.out.println(stack);
</code></pre>
<p>执行结果:</p>
<blockquote>
<p></p>
</blockquote>
<p><strong>结果怎么反过来的??</strong></p>
<p>我们看看源码怎么写的</p>
<pre><code class="language-java">public void push(E e) {
        addFirst(e);
}

public void addFirst(E e) {
        // 不允许存放 null 元素
        if (e == null)
                throw new NullPointerException();
        // 入栈元素位置计算
        elements = e;
        if (head == tail)
                doubleCapacity();
}
</code></pre>
<p><strong>计算新的 head 索引并插入元素</strong></p>
<pre><code class="language-java">elements[ head = (head - 1) &amp; (elements.length - 1) ] = e;
</code></pre>
<p><code>head</code>为<code>int</code>数据类型,成员变量默认为:0;</p>
<p><code>head - 1</code>:在当前 <code>head</code> 之前的那个<strong>槽位</strong>,也就是“<strong>往左移一格</strong>”,第一次插入时为:-1;</p>
<p><code>&amp; (elements.length - 1)</code>:<strong>取模运算</strong>,但因为 <code>elements.length</code> 总是 2 的幂,这里用位运算更高效;</p>
<p><code>head = …</code>:先更新 <code>head</code> 成新位置,再把 <code>e</code> 存入 <code>elements</code>;</p>
<p>这样无论 <code>head</code> 从 0 跑到 -1,按位与都会自动“<strong>回绕</strong>”到数组末尾,实现<strong>循环缓冲</strong>。</p>
<p>说那么多也没啥印象,来个计算过程图示:</p>
<p><img src="https://img2024.cnblogs.com/blog/1209017/202508/1209017-20250804081228208-259088855.jpg" alt="ArrayDeque循环数组计算过程" loading="lazy"></p>
<p>所以,<code>push</code>入栈是从数组的<strong>末端开始,往回入栈</strong>的,<code>ArrayDeque</code>的数据结构为<strong>循环数组</strong>。</p>
<p>循环数组数据结构如图:</p>
<p><img src="https://img2024.cnblogs.com/blog/1209017/202508/1209017-20250804081253439-1451542368.jpg" alt="ArrayDeque循环数组" loading="lazy"></p>
<h3 id="24-动态扩容">2.4. 动态扩容</h3>
<p>栈和队列就像List集合那样,使用时可能并不会知道集合大小为多少,所以<code>ArrayDeque</code>需要像<code>ArrayList</code>一样需要动态扩容。</p>
<p><code>ArrayDeque</code>的动态扩容像<code>HashMap</code>一样,扩容时候为2的倍数,确保大小一直为2的幂次方。</p>
<p>动态扩容只会在元素<strong>入栈的时候才会触发</strong>,<code>addFirst</code>触发扩容条件的源码</p>
<pre><code class="language-java">if (head == tail)
        doubleCapacity();
</code></pre>
<p>动态扩容关键源码为</p>
<pre><code class="language-java">private void doubleCapacity() {
        assert head == tail;
        int p = head;
        // 数组大小
        int n = elements.length;
        // p右边元素的个数
        int r = n - p;
        // 容量翻倍
        int newCapacity = n &lt;&lt; 1;
        if (newCapacity &lt; 0)
                throw new IllegalStateException("Sorry, deque too big");
        // 创建新数组
        Object[] a = new Object;
        // 元素第一次拷贝
        System.arraycopy(elements, p, a, 0, r);
        // 元素第二次拷贝
        System.arraycopy(elements, 0, a, r, p);
        // 更新数组为新对象
        elements = a;
        // 重置头尾索引下标
        head = 0;
        tail = n;
}
</code></pre>
<p>重点在于,<strong>为什么需要拷贝两次?</strong></p>
<pre><code class="language-java">/*
* src   源数组
* srcPos源数组的起始位置
* dest    目标数组
* destPos 目标数据的起始位置
* length需要复制数组的长度
*/
// 元素第一次拷贝
System.arraycopy(elements, p, a, 0, r);
// 元素第二次拷贝
System.arraycopy(elements, 0, a, r, p);
</code></pre>
<p><code>ArrayDeque</code>使用的是<strong>双端队列</strong>,是一种<strong>循环数组</strong>,头尾看做是相连的,做两次拷贝的目的是:<strong>确保新数组中的元素保持原来入栈的顺序</strong>。具体怎么个情况,继续往下看,可视化一步步的讲个明白。</p>
<h3 id="25-彻底搞懂循环数组">2.5. 彻底搞懂循环数组</h3>
<p><strong>举个例子</strong>:指定<code>ArrayDeque</code>的大小为8。先入栈1、2、3、4、5、6、7、8元素;再入栈A、B、C、D、E、F、G、H 元素。</p>
<p>源码如下:</p>
<pre><code class="language-java">public class ArrayDequeStudy {
    public static void main(String[] args) {
      Deque&lt;String&gt; stack = new ArrayDeque&lt;&gt;(6);// 不能等于8,等于8初始大小会变为16
      stack.push("1");
      stack.push("2");
      stack.push("3");
      stack.push("4");
      stack.push("5");
      stack.push("6");
      stack.push("7");
      stack.push("8");
      stack.push("A");
      stack.push("B");
      stack.push("C");
      stack.push("D");
      stack.push("E");
      stack.push("F");
      stack.push("G");
      stack.push("H");
      System.out.println(stack); //
      String top = stack.peek();
      System.out.println(top);   // H
      String pop = stack.pop();
      System.out.println(pop);   // H
      System.out.println(stack.pop());// G
    }
}
</code></pre>
<p>通过图形显示处理过程就很好理解了。</p>
<h4 id="1-第一次扩容的可视化">1. 第一次扩容的可视化</h4>
<p>第一次扩容很好理解,只需执行一次元素拷贝,第二次的拷贝是<strong>空拷贝</strong><code>System.arraycopy(elements, 0, a, 8, 0)</code></p>
<p><img src="https://img2024.cnblogs.com/blog/1209017/202508/1209017-20250804081320637-1200145868.jpg" alt="ArrayDeque第一次扩容" loading="lazy"></p>
<h4 id="2-扩容完成后继续插入元素重点">2. 扩容完成后,继续插入元素(重点):</h4>
<p>在这里开始会出现环绕的插入,就是数组中的<strong>元素拆分成了两段</strong>。</p>
<p><img src="https://img2024.cnblogs.com/blog/1209017/202508/1209017-20250804081341513-264369404.jpg" alt="ArrayDeque环绕插入" loading="lazy"></p>
<p>也许这样更好理解点,在逻辑处理上<strong><code>ArrayDeque</code>的数据结构</strong>是长这样的↓。</p>
<p>push 一个元素head<strong>逆时针走动一格</strong>,写入元素即可。</p>
<p><img src="https://img2024.cnblogs.com/blog/1209017/202508/1209017-20250804081352990-1418133635.jpg" alt="ArrayDeque环形数组插入" loading="lazy"></p>
<h4 id="3-第二次扩容的可视化">3. 第二次扩容的可视化</h4>
<p>出现两端环绕的情况时,两次拷贝是必不可少的,第一次拷贝的是head索引位置的<strong>后半段</strong>,第二次拷贝的是0至head的<strong>前半段</strong>,也就是剩下的那部分。不管是<code>ArrayDeque</code>的栈功能操作,还是双端队列操作,它们都会<strong>形成环绕形态的数组</strong>,需要<strong>进行两次拷贝</strong>,才能确保栈LIFO和队列FIFO元素的<strong>顺序正确</strong>。</p>
<p><img src="https://img2024.cnblogs.com/blog/1209017/202508/1209017-20250804081407239-151862122.jpg" alt="ArrayDeque第二次扩容" loading="lazy"></p>
<h3 id="26-移除元素--出栈">2.6. 移除元素--出栈</h3>
<p>出栈操作<code>pop</code>就是将<code>head</code>索引下的元素取出,将head右移一位。</p>
<p>主要源码如下:</p>
<pre><code class="language-java">public E pollFirst() {
        int h = head;
        // 取出头元素
        E result = (E) elements;
        if (result == null)
                return null;
        // 清空对应数组
        elements = null;
        // 将head右移一位
        head = (h + 1) &amp; (elements.length - 1);
        return result;
}
</code></pre>
<p>整个操作过程非常高效,关键源码还是head 右移一位。</p>
<p>比如:<strong>出栈元素D的过程</strong></p>
<p><img src="https://img2024.cnblogs.com/blog/1209017/202508/1209017-20250804081420508-1749922622.jpg" alt="ArrayDeque循环数组--移除元素1" loading="lazy"></p>
<p><code>ArrayDeque</code>是循环数组,在常规横向的数组结构上面展示并不直观。</p>
<p>在首尾相连的圆形数组上,<strong>右移一位</strong>就像是在圆形的数组上<strong>顺时针走动一格</strong>,没有首尾隔断的感觉。</p>
<p>这样看起来可能更符合循环数组的处理逻辑,<strong>出栈操作</strong>:</p>
<p><img src="https://img2024.cnblogs.com/blog/1209017/202508/1209017-20250804081430918-233175166.jpg" alt="ArrayDeque循环数组--移除元素2" loading="lazy"></p>
<h2 id="3-总结">3. 总结</h2>
<p><code>ArrayDeque</code> 是基于<strong>循环数组的双端队列</strong>实现,既可用作队列(FIFO)也可用作栈(LIFO)。通过两个索引 <code>head</code>/<code>tail</code> 和位运算自动在固定大小(2 的幂)的底层数组中“回绕”操作,当空间用尽时再将数组容量翻倍并平铺原有元素,所有入、删、取操作均为摊销 O(1),不支持存放 <code>null</code> 且非线程安全,但因无额外节点指针、缓存友好,通常比链表结构性能更优。</p>
<p>通过这篇文章,从栈功能使用到底层原理基本掌握,对<code>ArrayDeque</code>队列操作功能感兴趣的可以自行学习,底层和栈功能是共用的,相信你很快便可掌握双端队列。</p>
<blockquote>
<p>文章配套的视频也同步更新:https://www.bilibili.com/video/BV1zChGz8EVL/</p>
</blockquote>
<p>想要可视化对比<code>ArrayDeque</code>的栈功能和队列功能,可到视频最后部分查看。</p>
<h2 id="往期推荐">往期推荐</h2>
<table>
<thead>
<tr>
<th>分类</th>
<th>往期文章</th>
</tr>
</thead>
<tbody>
<tr>
<td>Java集合底层原理可视化</td>
<td>“子弹弹夹”装弹和出弹的抽象原理实战:掌握栈的原理与实战<br>TreeMap集合--底层原理、源码阅读及它在Java集合框架中扮演什么角色?<br>LinkedHashMap集合--原理可视化<br>HashMap集合--基本操作流程的源码可视化<br>Java集合--HashMap底层原理可视化,秒懂扩容、链化、树化<br>Java集合--从本质出发理解HashMap<br>Java集合--LinkedList源码可视化<br>Java集合源码--ArrayList的可视化操作过程</td>
</tr>
<tr>
<td>设计模式秘籍<br>(已全部开源)</td>
<td>掌握设计模式的两个秘籍<br>往期设计模式文章的:设计模式</td>
</tr>
<tr>
<td>软件设计师</td>
<td>软考中级--软件设计师毫无保留的备考分享<br>通过软考后却领取不到实体证书?<br>2023年下半年软考考试重磅消息</td>
</tr>
<tr>
<td>Java学习路线<br>和相应资源</td>
<td>Java全栈学习路线、学习资源和面试题一条龙</td>
</tr>
</tbody>
</table>
<p>原创不易,觉得还不错的,三连支持:点赞、分享、推荐↓</p><br><br>
来源:https://www.cnblogs.com/dennyLee2025/p/19020842
頁: [1]
查看完整版本: ArrayDeque双端队列--底层原理可视化