Java Deque与Stack

2023/06/07

1. 概述

Java Stack类实现了堆栈数据结构。Java 1.6引入了Deque接口,实现了一个“双端队列”,支持两端插入和移除元素。

现在,我们也可以将Deque接口用作LIFO(后进先出)堆栈。此外,如果我们查看Stack类的Javadoc,我们会看到:

Deque接口及其实现提供了一组更完整和一致的LIFO堆栈操作,应优先使用此类。

在本教程中,我们将比较Java Stack类和Deque接口。此外,我们将讨论为什么我们应该为LIFO堆栈使用Deque而不是Stack

2. 类与接口

Java的Stack是一个类:

public class Stack<E> extends Vector<E> { ... }

也就是说,如果我们要创建自己的Stack类型,就得继承java.util.Stack类。

由于Java不支持多重继承,如果我们的类已经是另一种类型的子类,有时很难扩展Stack类

public class UserActivityStack extends ActivityCollection { ... }

在上面的示例中,UserActivityStack类是ActivityCollection类的子类。因此,它不能同时扩展java.util.Stack类。要使用Java Stack类,我们可能需要重新设计我们的数据模型。

另一方面,Java的Deque是一个接口:

public interface Deque<E> extends Queue<E> { ... }

我们知道在Java中一个类可以实现多个接口。因此,实现接口比扩展类以进行继承更灵活。

例如,我们可以轻松地让我们的UserActivityStack实现Deque接口:

public class UserActivityStack extends ActivityCollection implements Deque<UserActivity> { ... }

因此,从面向对象设计的角度来看,Deque接口比Stack类给我们带来了更多的灵活性

3. 同步方法和性能

我们已经看到Stack类是java.util.Vector的子类。Vector类是同步的,它使用传统的方式来实现线程安全:使其方法“synchronized”。

作为其子类,Stack类也是同步的

另一方面,Deque接口不是线程安全的

因此,如果线程安全不是必需的,Deque可以为我们带来比Stack更好的性能

4. 迭代顺序

由于Stack和Deque都是java.util.Collection接口的子类型,因此它们也是Iterable。

然而,有趣的是,如果我们以相同的顺序将相同的元素压入Stack对象和Deque实例,它们的迭代顺序是不同的。

让我们通过例子来仔细看看它们。

首先,让我们将一些元素压入Stack对象并检查它的迭代顺序是什么:

@Test
void givenAStack_whenIterate_thenFromBottomToTop() {
    Stack<String> myStack = new Stack<>();
    myStack.push("I am at the bottom.");
    myStack.push("I am in the middle.");
    myStack.push("I am at the top.");

    Iterator<String> it = myStack.iterator();

    assertThat(it).toIterable().containsExactly(
        "I am at the bottom.",
        "I am in the middle.",
        "I am at the top.");
}

如果我们执行上面的测试方法,它就会通过。这意味着,当我们遍历Stack对象中的元素时,顺序是从栈底到栈顶

接下来,让我们在Deque实例上执行同样的测试。在我们的测试中,我们将ArrayDeque类作为Deque实现。

此外,我们将使用ArrayDeque作为LIFO堆栈:

@Test
void givenADeque_whenIterate_thenFromTopToBottom() {
    Deque<String> myStack = new ArrayDeque<>();
    myStack.push("I am at the bottom.");
    myStack.push("I am in the middle.");
    myStack.push("I am at the top.");

    Iterator<String> it = myStack.iterator();

    assertThat(it).toIterable().containsExactly(
        "I am at the top.",
        "I am in the middle.",
        "I am at the bottom.");
}

如果我们运行,这个测试也会通过。

因此Deque的迭代顺序是从上到下

当我们谈论LIFO堆栈数据结构时,迭代堆栈中元素的正确顺序应该是从上到下

也就是说,Deque的迭代器按照我们期望的堆栈方式工作。

5. 无效的LIFO堆栈操作

经典的LIFO堆栈数据结构仅支持push()、pop()和peek()操作。Stack类和Deque接口都支持它们。到目前为止,一切都很好。

但是,不允许通过LIFO堆栈中的索引访问或操作元素,因为它违反了LIFO规则

在本节中,让我们看看是否可以使用Stack和Deque调用无效的堆栈操作。

5.1 java.util.Stack类

由于其父Vector是基于数组的数据结构,因此Stack类具有通过索引访问元素的能力:

@Test
void givenAStack_whenAccessByIndex_thenElementCanBeRead() {
    Stack<String> myStack = new Stack<>();
    myStack.push("I am the 1st element."); //index 0
    myStack.push("I am the 2nd element."); //index 1
    myStack.push("I am the 3rd element."); //index 2
 
    assertThat(myStack.get(0)).isEqualTo("I am the 1st element.");
}

如果我们运行它,测试将通过。

在测试中,我们将3个元素压入Stack对象。之后,我们要访问位于堆栈底部的元素。

遵循LIFO规则,我们必须弹出上面的所有元素才能访问底部元素。

但是,在这里,使用Stack对象,我们只能通过其索引访问元素

此外,使用Stack对象,我们甚至可以通过其索引插入和删除元素。让我们创建一个测试方法来证明它:

@Test
void givenAStack_whenAddOrRemoveByIndex_thenElementCanBeAddedOrRemoved() {
    Stack<String> myStack = new Stack<>();
    myStack.push("I am the 1st element.");
    myStack.push("I am the 3rd element.");

    assertThat(myStack.size()).isEqualTo(2);

    myStack.add(1, "I am the 2nd element.");
    assertThat(myStack.size()).isEqualTo(3);
    assertThat(myStack.get(1)).isEqualTo("I am the 2nd element.");

    myStack.remove(1);
    assertThat(myStack.size()).isEqualTo(2);
}

如果我们运行,测试也会通过。

因此,使用Stack类,我们可以像操作数组一样操作其中的元素。这违反了LIFO契约。

5.2 java.util.Deque接口

Deque不允许我们通过索引访问、插入或删除元素。这听起来比Stack类好。

然而,由于Deque是一个“双端队列”,我们可以从两端插入或删除一个元素。

换句话说,当我们使用Deque作为LIFO堆栈时,我们可以直接从栈底插入/移除元素

让我们构建一个测试方法,看看这是如何发生的。同样,我们将在测试中继续使用ArrayDeque类:

@Test
void givenADeque_whenAddOrRemoveLastElement_thenTheLastElementCanBeAddedOrRemoved() {
    Deque<String> myStack = new ArrayDeque<>();
    myStack.push("I am the 1st element.");
    myStack.push("I am the 2nd element.");
    myStack.push("I am the 3rd element.");

    assertThat(myStack.size()).isEqualTo(3);

    //insert element to the bottom of the stack
    myStack.addLast("I am the NEW element.");
    assertThat(myStack.size()).isEqualTo(4);
    assertThat(myStack.peek()).isEqualTo("I am the 3rd element.");

    //remove element from the bottom of the stack
    String removedStr = myStack.removeLast();
    assertThat(myStack.size()).isEqualTo(3);
    assertThat(removedStr).isEqualTo("I am the NEW element.");
}

在测试中,首先,我们使用addLast()方法将一个新元素插入到堆栈底部。如果插入成功,我们将尝试使用removeLast()方法将其删除。

如果我们执行测试,它就会通过。

因此,Deque也不遵守LIFO契约

5.3 基于Deque实现LifoStack

我们可以创建一个简单的LifoStack接口来遵守LIFO契约:

public interface LifoStack<E> extends Collection<E> {
    E peek();

    E pop();

    void push(E item);
}

当我们创建LifoStack接口的实现时,我们可以包装Java标准Deque实现。

让我们创建一个ArrayLifoStack类作为例子来快速理解它:

public class ArrayLifoStack<E> implements LifoStack<E> {
    private final Deque<E> deque = new ArrayDeque<>();

    @Override
    public void push(E item) {
        deque.addFirst(item);
    }

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

    @Override
    public E peek() {
        return deque.peekFirst();
    }

    // forward methods in Collection interface to the deque object

    @Override
    public int size() {
        return deque.size();
    }
    // ...
}

正如ArrayLifoStack类所示,它仅支持我们的LifoStack接口和java.util.Collection接口中定义的操作。

这样,就不会违反LIFO法则。

6. 总结

从Java 1.6开始,java.util.Stack和java.util.Deque都可以用作LIFO堆栈。本文讨论了这两种类型之间的区别。

我们还分析了为什么我们应该在Stack类上使用Deque接口来处理LIFO堆栈。

此外,正如我们通过示例讨论的那样,Stack和Deque或多或少都违反了LIFO规则。

最后,我们展示了一种创建遵循LIFO契约的堆栈接口的方法。

与往常一样,本教程的完整源代码可在GitHub上获得。

Show Disqus Comments

Post Directory

扫码关注公众号:Taketoday
发送 290992
即可立即永久解锁本站全部文章