15. Stacks and Queues
Over the past three lectures, we have focused on the List abstract data type. We wrote two implementations of this ADT using different data structures, a dynamic array and a linked chain, and we used tools from earlier in the course to analyze the complexity of their operations. We also saw how to use Iterator
s and the iteratee pattern to provide the clients with tools to more efficiently iterate over these collections.
Today, we’ll introduce two more ADTs, the Stack and the Queue. Both of these ADTs provide convenient ways to collect data over time and then retrieve elements in a specified order. For this reason, Stacks and Queues serve as building blocks for many other algorithms. Later in the course, we will use these ADTs to define iterators over trees and traversals over graphs. In the real-world, stacks and queues are ubiquitous in computational geometry (useful for image rendering and sensing), traffic and route planning (such as the organization and coordination of trains in rail yards), and expression parsing (in calculators and programming language compilers).
Stacks
A Stack is an ADT that models storing a collection of physical items in a pile. When we make a stack of books, we always add the next book on top of the existing stack. It would not make sense to add a book lower down in the stack, as doing this would require us to lift some of the other books and slot the new book in. For this reason, a stack is an example of an ordered collection. The positions of the books within the stack encode some useful information, namely the order that they were added; books that were added earlier sit below books that were added later.
When the books are in a stack, we can only see the cover of one book, the book at the top of the stack. Similarly, this is the only book that we can easily remove from the stack. If we tried to remove a book lower in the stack, we’d risk the stack toppling over. This is the defining characteristic of a stack: the only element that we can access or remove from a stack is the most recent element that was added.
The Stack
ADT supports adding elements and accessing and removing the most recent element that was added. For this reason, we say that a stack enforces the LIFO (an acronym for last in, first out) order condition.
This LIFO order condition constrains the operations that a stack can support. There is only one way that an element can be added, at the “top” of the stack. We support this through an operation push()
(when we place the book on top of the stack, it pushes down on the other books). There is only one way to remove an element from the stack; we must remove the “top” element with the pop()
operation.
previous
next
We have already seen the term stack earlier in the course, when we introduced the runtime stack in our very first lecture. As a collection of call frames, the runtime stack realizes the semantics of the stack ADT. New call frames are push()
ed on top of the stack, and the topmost frame corresponds to the method that is actively being executed. Call frames are removed (i.e., pop()
ped) from the runtime stack when the code reaches a return statement.
There is only one way to access an element within the stack; we can look at (i.e., return) the “top” element through an operation peek()
. Finally, it will be useful to know whether or not our stack contains any elements, which we can support with an isEmpty()
method. We’ll use this method to guard loops whose body pop()
s an element from a stack and does some work with it. In addition, by providing an isEmpty()
method, we can add a precondition to the peek()
and pop()
methods that avoids the need for exceptions or sentinel values.
Stack.java
|
|
|
|
Implementing a Stack
We can imagine representing the contents of a Stack
with a linearly-ordered data structure such as a dynamic array or a linked chain, where the indices in the data structure represent the order in which the elements were push()
ed onto the stack. In a Stack
implementation leveraging a dynamic array, for example, we’d have larger indices represent later additions to the stack because adding/removing from the end of a dynamic array is a more efficient operation (amortized \(O(1)\) and \(O(1)\) worst-case complexity, respectively) than adding/removing from the beginning (both \(O(N)\) due to the required element shifting).
We could implement DynamicArrayStack
class from scratch, representing the state in a similar manner to the DynamicArrayList
and developing the code for its methods similarly as well. However, this would lead to a lot of repeated code, something that we try to avoid. Instead, we’d like a way to leverage the code that we have already written for DynamicArrayList
within our DynamicArrayStack
implementation. A first thought for how to accomplish this is through inheritance. If DynamicArrayStack extends DynamicArrayList
, then it gains access to all of the list’s fields and methods. To complete its definition, we just need to supply definitions for the four Stack
methods, which can be done by calling DynamicArrayList
methods. Give this a try before looking at our implementation below.
DynamicArrayStack
with inheritance
Let’s evaluate this approach. The four Stack
methods are all defined correctly, and they leverage properties of dynamic arrays to achieve great performance; \(O(1)\) worst-case time complexity for pop()
, remove()
, and peek()
, and amortized \(O(1)\) time complexity for push()
. However, there is a concern with this approach. By establishing an inheritance relationship with DynamicArrayList
, we introduce all of the public
methods of DynamicArrayList
to the client interface of the DynamicArrayStack
class. A client can construct a DynamicArrayStack
and then call methods such as insert()
and delete()
on it to alter the order of its elements. In doing this, the client can violate the LIFO order condition (in other words, invalidate an invariant of the DynamicArrayStack
class), evidence of poor encapsulation of this code.
If the client stores a reference to a DynamicArrayStack
object in a variable with static type Stack
, then the compile-time reference rule will prevent access to these other list methods. However, we do not want to rely on this as a mechanism for encapsulation. When we defined the DynamicArrayStack
class, its specification documented that it would behave like a stack; it did not describe the accessibility of other list methods.
Java actually made this mistake in the standard library. Java's Stack
class extends the Vector
class, an older variant of an ArrayList
. Java discourages the use of the Stack
class for this reason (but preserves it for backward compatibility).
We’d like an alternate way to relate a DynamicArrayStack
class to the DynamicArrayList
class that allows for code reuse but also ensures proper encapsulation. A composition relationship is suitable for these goals.
Composition
In a composition relationship, one class is instantiated as a field within another class.
A class S
is in a composition relationship with a type T
, if objects of type S
construct an instance of type T
to store as one of their fields. The T
instance is entirely encapsulated within the S
instance and not accessible to outside clients.
Whereas inheritance is used to model “is a” relationships (a CheckingAccount
is an Account
), composition is used to model “has a” relationships. We can define our DynamicArrayStack
using a composition relationship with a DynamicArrayList
: a DynamicArrayStack
has a DynamicArrayList
in which it stores its elements.
We’ll add a private DynamicArrayList<T>
field list
that is responsible for storing the stack’s elements, and we’ll initialize list
to refer to a new DynamicArrayList
in the DynamicArrayStack
constructor. After this set-up, we can leverage the same logic as in our inheritance code, replacing instances of “this
” with “list
”.
DynamicArrayStack.java
|
|
|
|
By leveraging a composition relationship, we avoid the issues from above. Now, the only methods supported by DynamicArrayStack
are push()
, pop()
, peek()
, and isEmpty()
. These methods are able to enforce the LIFO order condition. In addition, we maintain the same good performance from above. This demonstrates that composition relationships provide a good mechanism for restricting the client interface of an existing type.
LinkedStack
For an alternate implementation of the Stack
interface, we can use a composition relationship with the SinglyLinkedList
class. In which order does it make sense to store the stack’s elements within this linked list? Should the “top” of the stack be at the beginning (i.e., head
) of the list or at the end (i.e., immediately preceding the tail
)? Think about which option will provide better performance.
Which order is better?
Using this insight, and using the DynamicArrayStack
class as a model of a composition relationship, take some time to develop the LinkedStack
class.
LinkedStack
with composition
Evaluating Arithmetic Expressions
Next, we’ll see an example of a computational task that can naturally be carried out with the help of two stacks, evaluating arithmetic expressions. To keep things simpler for this lecture, we’ll restrict our focus to only very simple expressions that consist of single-digit numerical arguments, addition, multiplication, and parentheses. This ensures that the value of the expression is always a non-negative integer. We say that an arithmetic expression is well-formed if it conforms to our usual mathematical conventions. In the case of our simple subset of expressions, we can capture this notion with the following rules.
- The digits “\(0\)”, “\(1\)”, “\(2\)”, “\(3\)”, “\(4\)”, “\(5\)”, “\(6\)”, “\(7\)”, “\(8\)”, and “\(9\)” are all well-formed expressions.
- Given a well-formed expression \(e\), “(\(e\))” is a well-formed expression.
- Given two well-formed expressions \(e_1\) and \(e_2\), both “\(e_1+e_2\)” and “\(e_1*e_2\)” are well-formed expressions.
These rules can be combined and applied an arbitrary number of times, allowing us to build up more complicated well-formed expressions such as “\(3*(1+(4*6))+7+2*(8*3)\)”. We refer to the two expressions \(e_1\) and \(e_2\) in the third rule as operands and the “\(+\)” or “\(*\)” as an operator.
The above rules give a context-free grammar for the language of well-formed expressions. In other words, they describe a subset of String
s that are nicely structured. You'll talk more about these ideas if you take CS 3110 or a theory of computation course.
We’ll evaluate the expression by processing it one character at a time; our evaluate
method has the form,
|
|
|
|
We can use an enhanced for
-loop to iterate over the characters of the String
(transformed to a char[]
by the toCharArray()
method) since arrays are Iterable
. We’ll need to introduce local variables to keep track of the characters we’ve already seen so they can be incorporated into our final calculation. For example, when we process the simple expression,
we will read the 3 first and need to remember, “we should apply an operation with 3 as its first operand.” Then, we’ll read the \(*\) and need to remember, “we’ll perform multiplication with whatever I was remembering as the first operand and whatever expression is coming next”. Only after we read the 4 will we have the information that we need to simplify the expression to the value \(3*4=12\).
There are times when we may need to keep track of more information. In the expression,
\[ 1 + 2 * (3 + 4 * (5 + 6)) \]the parentheses prevent us from carrying out any simplifications until we read the 6. Before this, the second operand of any of the operators was always an incomplete expression. Thus, we’d need a way to keep track of all 5 (or, more generally, an arbitrary number of) other operands and operators before the 6 is read. In mathematic expressions, operands that are closer together interact (i.e., form subexpressions that can be evaluated) before operands that are farther apart. In our example, we know that we’ll make use of the 5 operand in our simplification before we can use the 4, 3, 2, or 1 operands. The value of the parenthesized sub-expression starting “(\(5 + \)” will be needed to evaluate the expressions involving any of the other numbers. Therefore, we really only need immediate access to the 5 operand, and will only use the other operands once the 5 has been dealt with. Similarly, we know that we will evaluate the rightmost addition before dealing with any of the earlier operators. This insight motivates the use of two stacks to keep track of operands and operators (as well as parentheses).
|
|
|
|
As we read operands, we’ll push()
them onto the operands
stack, and as we read operators and opening parentheses, we’ll push them onto the operators
stack. How will we know when we have enough information to start simplifying the expression? We can make the following three observations:
- Whenever we read a ‘)’ in a well-formed expression, this “completes” the sub-expression enclosed by this and the corresponding, most recently seen, ‘(’. Therefore, we can
pop()
operators and operands to simplify the stacks until we find this ‘(’. - Since multiplication is (by convention) left-associative, when we read a ‘\(*\)’, we can simplify other multiplications that preceded it in its left operand. For example, in the expression “\(2*3*4\)”, we can perform the multiplication of \(2*3\) and replace this with 6 on the operands stack as soon as we read the second ‘\(*\)’.
- Since addition is (by convention) left-associative and has lower precedence than multiplication, we can simplify any multiplications or other additions that appear in the sub-expression of its first argument. For example, in the expression “\(2+3*4+5\)”, we can perform the multiplication of \(3*4\) and replace this with 12 on the operands stack and then perform the addition of \(2 + 12\) and replace this with 14 on the runtime stack when we read the second ‘+’.
- Once we reach the end of the expression, we have all of the information that we need to finish evaluating the expression, so we can
pop()
the remaining operators and operands from their stacks.
We’ll put these ideas into practice in the following animation, where we trace through the evaluation of the expression “\(3 * 4 + 6 * (2 + 7) * 5\)”.
previous
next
Using this example and the observations from above, try to complete the implementation of the evaluate()
method. A basic implementation, which computes the correct result given a well-formed expression of the type described above, is included below.
basic evaluate()
implementation
A more robust version, which uses assert
statements to detect and alert the client about malformed expression is provided in the lecture release code and includes a basic main()
method that uses the evaluate()
method in a simple calculator program, is included with the lecture release code. Exercise 15.8 outlines many extensions that can be made to enhance the set of expressions that can be evaluated.
Queues
A related ADT to the Stack
is the Queue
, which models a collection of people waiting in a line (i.e., queueing). When a new (reasonable) person wishes to join the queue, they do so at the “back” of the line. When a representative is ready to accept a person out of the queue, this person is removed from the “front” of the line. As a person stands waiting in the queue, they shift forward every time someone is serviced, making their way toward the “front” of the queue. Queues are another example of an ordered data structure whose order models the insertion time of the elements. Differing from stacks, queues adopt a FIFO (first in, first out) order condition.
The Queue
ADT supports adding elements and accessing and removing the first remaining element that had been added. For this reason, we say that a queue enforces the FIFO (an acronym for first in, first out) order condition.
We call the addition operation to a queue an enqueue()
and the removal operation a dequeue()
. A Queue
also supports a peek()
(which accesses the “front” element) and an isEmpty()
method, just like a Stack
.
previous
next
Queue.java
|
|
|
|
The Java library models both stacks and queues using a single interface, the Deque
(short for "double-ended queue"). The nomenclature for the methods varies slightly from the terminology that we have used in this lecture (e.g., addLast()
instead of enqueue()
and getFirst()
instead of dequeue()
). Both the LinkedList
and the ArrayDeque
(which leverages a dynamic array) classes implement the Deque
interface and provide the same runtime guarantees as our implementations.
Implementing a Queue
Just as we did for Stack
s, we can implement Queue
s by leveraging our List
implementations via composition relationships. First, let’s consider a LinkedQueue
implementation. Similar to before, we’ll use a use a composition relationship with the SinglyLinkedList
class, and we must consider in which order it makes sense to store the queue’s elements. Should the “front” of the queue be at the beginning (i.e., head
) of the list or at the end (i.e., immediately preceding the tail
)? Think about which option will provide better performance.
Which order is better?
Take some time to develop the LinkedQueue
class using this insight.
LinkedQueue
with composition
A Queue
with a DynamicArray
Implementing an efficient Queue
using dynamic array presents a greater challenge. We will not be able to do this through a composition relationship with a DynamicArrayList
. In a Queue
both “sides” of the array will be used since the elements are enqueue()
d on one side and dequeue()
d on the other. However, both inserting and removing elements at the start of a DynamicArrayList
are \(O(N)\) operations since both require that all of the later elements are shifted (either one index right to make space for the new first element or one index left to close the gap left by the removed first element). The requirement that the elements “pack” to the left of the DynamicArray
leads to this inefficiency. We’ll need to remove this requirement to achieve an \(O(1)\) (amortized) runtime for the queue operations. We can do this in a memory efficient way by modeling the queue as a dynamic circular buffer that adjusts its front
index after removals rather than shifting all of the remaining elements.
A circular buffer is a data structure that stores its elements at contiguous indices with the added condition that we view the last index as the immediate predecessor of the first index.
In this way, the indices “wrap around” to form a circular arrangement of the data. We can visualize the “wrapping” of a circular buffer of length 8 as follows:
To represent the circular buffer, we can use the indices of both the “front” element (front
) and the next unoccupied cell to fill (back
).
|
|
|
|
We leave it as an exercise (Exercise 15.3) to complete this DynamicArrayQueue
implementation, using the same capacity-doubling approach as the DynamicArrayList
to ensure an amortize \(O(1)\) time-complexity for push()
. The following animation illustrates the expected behavior of the DynamicArrayQueue
.
previous
next
While the LinkedQueue
implementation has a better worst-case runtime performance, queues that leverage circular buffers (sometimes with a bounded capacity and no resizing) are often preferable in memory constrained applications such as embedded systems.
Main Takeaways:
- Stacks and Queues are two additional ADTs (beyond Lists) that use an ordered, linear arrangement of their data. In each of these ADTs, only one of its elements can be viewed and/or removed at any particular time.
- Stacks adhere to the LIFO order condition, so the last element that was
push()
ed onto the stack is the only one that can bepeek()
ed orpop()
ped. Queues adhere to the FIFO order condition, so the first element that wasenqueue()
d is the only one that can bepeek()
ed ordequeue()
d. - We can implement stacks and queues via a composition relationship with a
DynamicArrayList
or aSinglyLinkedList
. Composition relationships, where a class constructs and manages an instance of another type, are a good mechanism to restrict the client interface of another type. - The operations on stacks and queues implemented using linked chains all have \(O(1)\) worst-case time complexity. The operations on stacks and queues implemented using dynamic arrays have amortized \(O(1)\) insertions and \(O(1)\) worst-case time complexities for their other methods.
- Stacks (in particular) and queues are useful for many tasks ranging from data structure traversal to expression parsing.
Exercises
You are checking out books from a library with a rather strange return policy: you may check out any number of books as you like at a time, but you must return them in the opposite order that you checked them out.
Consider the following operations done on a Stack
.
|
|
|
|
What does stack.peek()
return?
Suppose we alter the code in the last subproblem to use a Queue<Integer> queue
instead of stack
, replacing all invocations of push()
with enqueue()
and pop()
with dequeue()
.
What does queue.peek()
return?
push()
ed onto a Stack
in increasing order. While push()
ing the inputs onto the stack, 5 pop()
operations were interspersed arbitrarily (while satisfying the precondition). Determine which of the following pop()
sequence(s) is/are valid.Now suppose the same integers \([1..5]\) are enqueue()
d in increasing order into a Queue
. 5 dequeue()
operations are interspersed arbitrarily (while satisfying the precondition) during the enqueue()
s.
List all possible dequeue()
sequeunces.
Stack
Tracing
Stack<Integer> stack
is in scope.
|
|
|
|
|
|
|
|
DynamicArrayQueue
DynamicArrayQueue
by implementing the methods in the Stack
interface. Similar to DynamicArrayList
, we should double the size of the backing array when full.
Stack
s and Queue
s
Stack
s and Queue
s implement Iterable
?
Stack
with Queue
s
Stack
s and Queue
are both fundamental data structures that store elements in an ordered way, but they differ in how elements are removed. In fact, these two ADTs are so similar that we can implement a Stack
with Queue
s.
Implement a Queue
with two Stack
s. Is it possible to do this in a way that achieves an amortized \(O(1)\) runtime of the enqueue()
and dequeue()
operations?
|
|
|
|
Implement a Stack
with two Queue
s. Is it possible to do this in a way that achieves an amortized \(O(1)\) runtime of the pop()
and peek()
operations?
|
|
|
|
'('
and ')'
. A string of parentheses is considered matching if every opening parenthesis '('
has a corresponding closing parenthesis ')'
and the pairs are properly nested.
"{}"
and brackets "[]"
. We adopt the convention that "()[]"
and "([])"
(for example) are considered matching, but "([)]"
is not. Write a method that determines whether a given string of parentheses, brackets, and braces is valid.
Integer
s while also supports \(O(1)\) access to the current maximum element stored in the stack. (Hint: use a second stack)
|
|
|
|
- Operands: T (
true
), F (false
) - Operators (in decreasing precedence order):
- ! (negation)
- & (conjunction; i.e., logical and)
- ^ (exclusive or)
- | (disjunction; i.e., logical inclusive or)
|
|
|
|