Stacks and Queues

So far we’ve looked at collections that provide very basic data storage, essentially abstractions over an array. In this section, we’re going to look at what happens when we add a few very basic behaviors that entirely change the utility of the collections.

Stack

A stack is a collection that returns objects to the caller in a Last-In-First-Out (LIFO) pattern. What this means is that the last object added to the collection will be the first object returned.

Stacks differ from list and array-like collections. They cannot be indexed directly, objects are added and removed using different methods, and their contents are more opaque than lists and arrays. What I mean by this is that while a list-based collection provides a Contains method, a stack does not. Additionally, a stack is not enumerable. To understand why this is, let’s look at what a stack is and how the usage of a stack drives these differences.

One of the most common analogies for a stack is the restaurant plate stack. This is a simple spring-loaded device onto which clean plates are stacked. The spring ensures that regardless of how many plates are in the stack, the top plate can be easily accessed. Clean plates are added to the top of the stack, and when a customer removes a plate, he or she is removing the top-most plate (the most recently added plate).

We start with an empty plate rack.

An empty plate stack the spring is holding no plates
An empty plate stack (the spring is holding no plates)

And then we add a red, a blue, and a green plate to the rack in that order.

The key point to understand here is that as new plates are added, they are added to the top of the stack. If a customer retrieves a plate, he or she will get the most recently added plate (the green plate). The next customer would get the blue plate, and finally the red plate would be removed.

Now that we understand how a stack works, let’s define a few new terms. When an item is added to the stack, it is “pushed” on using the Push method. When an item is removed from the stack, it is “popped” off using the Pop method. The top item in the stack, the most recently added, can be “peeked” at using the Peek method. Peeking allows you to view the item without removing it from the stack (just like the customer at the plate rack would be able to see the color of the top plate). With these terms in mind, let’s look at the implementation of a Stack class.

Class Definition

The Stack class defines Push, Pop, and Peek methods, a Count property, and uses the LinkedList<T> class to store the values contained in the stack.

Push

Behavior Adds an item to the top of the stack.
Performance O(1)

Since we’re using a linked list as our backing store, all we need to do is add the new item to the end of the list.

Pop

Behavior Removes and returns the last item added to the stack. If the stack is empty, an InvalidOperationException is thrown.
Performance O(1)

Push adds items to the back of the list, so we will “pop” them from the back. If the list is empty, an exception is thrown.

Peek

Behavior Returns the last item added to the stack but leaves the item on the stack. If the stack is empty, an InvalidOperationException is thrown.
Performance O(1)

Count

Behavior Returns the number of items in the stack.
Performance O(1)

Since the stack is supposed to be an opaque data structure, why do we have a Count property? Knowing whether a stack is empty (Count == 0) is very useful, especially since Pop throws an exception when the stack is empty.

Example: RPN Calculator

The classic stack example is the Reverse Polish Notation (RPN) calculator.

RPN syntax is quite simple. It uses:

<operand> <operand> <operator>

rather than the traditional:

<operand> <operator> <operand>.

In other words, instead of saying “4 + 2,” we would say “4 2 +.” If you want to understand the historical significance of RPN syntax, I encourage you to head to Wikipedia or your favorite search engine.

The way RPN is evaluated, and the reason that a stack is so useful when implementing an RPN calculator, can be seen in the following algorithm:

So given the input string “4 2 +,” the operations would be:

Now the stack contains a single value: six (the answer).

The following is a complete implementation of a simple calculator that reads an equation (for example, “4 2 +”) from console input, splits the input at every space ([“4”, “2”, and “+”]), and performs the RPN algorithm on the input. The loop continues until the input is the word “quit”.

Queue

Queues are very similar to stacks—they provide an opaque collection from which objects can be added (enqueued) or removed (dequeued) in a manner that adds value over a list-based collection.

Queues are a First-In-First-Out (FIFO) collection. This means that items are removed from the queue in the same order that they were added. You can think of a queue like a line at a store checkout counter—people enter the line and are serviced in the order they arrive.

Queues are commonly used in applications to provide a buffer to add items for future processing or to provide orderly access to a shared resource. For example, if a database is capable of handling only one connection, a queue might be used to allow threads to wait their turn (in order) to access the database.

Class Definition

The Queue, like the Stack, is backed by a LinkedList. Additionally, it provides the methods Enqueue (to add items), Dequeue (to remove items), Peek, and Count. Like Stack, it will not be treated as a general purpose collection, meaning it will not implement ICollection<T>.

Enqueue

Behavior Adds an item to the end of the queue.
Performance O(1)

This implementation adds the item to the start of the linked list. The item could just as easily be added to the end of the list. All that really matters is that items are enqueued to one end of the list and dequeued from the other (FIFO). Notice that this is the opposite of the Stack class where items are added and removed from the same end (LIFO).

Dequeue

Behavior Removes and returns the oldest item from the queue. An InvalidOperationException is thrown if the queue is empty.
Performance O(1)

Since Enqueue added the item to the start of the list, Dequeue must remove the item at the end of the list. If the queue contains no items, an exception is thrown.

Peek

Behavior Returns the next item that would be returned if Dequeue were called. The queue is left unchanged. An InvalidOperationException is thrown if the queue is empty.
Performance O(1)

Count

Behavior Returns the number of items currently in the queue. Returns 0 if the queue is empty.
Performance O(1)

Deque (Double-Ended Queue)

A double-ended queue, or deque, extends the queue behavior by allowing items to be added or removed from both sides of the queue. This new behavior is useful in several problem domains, specifically task and thread scheduling. It is also generally useful for implementing other data structures. We’ll see an example of using a deque to implement another data structure later.

Class Definition

The Deque class is backed by a doubly linked list. This allows us to add and remove items from the front or back of the list and access the First and Last properties. The main changes between the Queue class and the Deque class are that the Enqueue, Dequeue, and Peek methods have been doubled into First and Last variants.

Enqueue

EnqueueFirst

Behavior Adds the provided value to the head of the queue. This will be the next item dequeued by DequeueFirst.
Performance O(1)

EnqueueLast

Behavior Adds the provided value to the tail of the queue. This will be the next item dequeued by DequeueLast.
Performance O(1)

Dequeue

DequeueFirst

Behavior Removes and returns the first item in the deque. An InvalidOperationException is thrown if the deque is empty.
Performance O(1)

DequeueLast

Behavior Removes and returns the last item in the deque. An InvalidOperationException is thrown if the deque is empty.
Performance O(1)

PeekFirst

Behavior Returns the first item in the deque but leaves the collection unchanged. An InvalidOperationException is thrown if the deque is empty.
Performance O(1)

PeekLast

Behavior Returns the last item in the deque but leaves the collection unchanged. An InvalidOperationException is thrown if the deque is empty.
Performance O(1)

Count

Behavior Returns the number of items currently in the deque, or 0 if the deque is empty.
Performance O(1)

Example: Implementing a Stack

Deques are often used to implement other data structures.

We’ve seen a stack implemented using a LinkedList, so now let’s look at one implemented using a Deque.

You might wonder why I would choose to implement a Stack using a Deque rather than a LinkedList. The reason is one of performance and code reusability. A linked list has the cost of per-node overhead and reduced data locality—the items are allocated in the heap and the memory locations may not be near each other, causing a larger number of cache misses and page faults at the CPU and memory hardware levels. A better performing implementation of a queue might use an array as the backing store rather than a list. This would allow for less per-node overhead and could improve performance by addressing some locality issues.

Implementing a Stack or Queue as an array is a more complex implementation, however. By implementing the Deque in this more complex manner and using it as the basis for other data structures, we can realize the performance benefits for all structures while only having to write the code once. This accelerates development time and reduces maintenance costs.

We will look at an example of a Deque as an array later in this section, but first let’s look at an example of a Stack implemented using a Deque.

Notice that all of the error checking is now deferred to the Deque and any optimization or bug fix made to the Deque will automatically apply to the Stack class. Implementing a Queue is just as easy and as such is left as an exercise to the reader.

Array Backing Store

As mentioned previously, there are benefits to using an array rather than a linked list as the backing store for the Deque<int> (a deque of integers). Conceptually this seems simple, but there are actually several issues that need to be addressed for this to work.

Let’s look at some of these issues graphically and then see how we might deal with them. Along the way, keep in mind the growth policy issues discussed in the ArrayList section and that those same issues apply here.

When the collection is created, it is a 0-length array. Let’s look at how some actions affect the internal array. As we go through this, notice that the green “h” and red “t” in the figures refer to “head” and “tail,” respectively. The head and tail are the array indexes that indicate the first and last items in the queue. As we add and remove items, the interaction between head and tail will become clearer.

Adding a value to the front of the dequeue
Adding a value to the front of the deque
Adding a value to the end of the deque
Adding a value to the end of the deque
Adding another value to the front of the deque the head index wraps around
Adding another value to the front of the deque; the head index wraps around

Notice what has happened at this point. The head index has wrapped around to the end of the array. Now the first item in the deque, what would be returned by DequeueFirst, is the value at array index three (zero).

Adding a value to the end of the deque
Adding a value to the end of the deque

At this point, the array is filled. When another item is added, the following will occur:

  1. The growth policy will define the size of the new array.
  2. The items will be copied from head to tail into the new array.
  3. The new item will be added.
    1. EnqueueFirst - The item is added at index zero (the copy operation leaves this open).
    2. EnqueueLast - The item is added to the end of the array.
Adding a value to the end of the expanded deque
Adding a value to the end of the expanded deque

Now let’s see what happens as items are removed from the Deque.

Removing the first item from the expanded deque
Removing the first item from the expanded deque
Removing the last item from the expanded deque
Removing the last item from the expanded deque

The critical point to note is that regardless of the capacity of the internal array, the logical contents of the Deque are the items from the head index to the tail index, taking into account the need to wrap around at the end of the array. An array that provides the behavior of wrapping around from the head to the tail is often known as a circular buffer.

With this understanding of how the array logic works, let’s dive right into the code.

Class Definition

The array-based Deque methods and properties are the same as the list-based, so they will not be repeated here. However, the list has been replaced with an array and there are now three properties to contain the size, head, and tail information.

Enqueue

Growth Policy

When the internal array needs to grow, the algorithm to increase the size of the array, copy the array contents, and update the internal index values needs to run. The Enqueue method performs that operation and is called by both EnqueueFirst and EnqueueLast. The startingIndex parameter is used to determine whether to leave the array slot at index zero open (in the case of EnqueueFirst).

Pay specific attention to how the data is unwrapped in cases where the walk from head to tail requires going around the end of the array back to zero.

EnqueueFirst

Behavior Adds the provided value to the head of the queue. This will be the next item dequeued by DequeueFirst.
Performance O(1) in most cases; O(n) when growth is necessary.

EnqueueLast

Behavior Adds the provided value to the tail of the queue. This will be the next item dequeued by DequeueLast.
Performance O(1) in most cases; O(n) when growth is necessary.

Dequeue

DequeueFirst

Behavior Removes and returns the first item in the deque. An InvalidOperationException is thrown if the deque is empty.
Performance O(1)

DequeueLast

Behavior Removes and returns the last item in the deque. An InvalidOperationException is thrown if the deque is empty.
Performance O(1)

PeekFirst

Behavior Returns the first item in the deque but leaves the collection unchanged. An InvalidOperationException is thrown if the deque is empty.
Performance O(1)

PeekLast

Behavior Returns the last item in the deque but leaves the collection unchanged. An InvalidOperationException is thrown if the deque is empty.
Performance O(1)

Count

Behavior Returns the number of items currently in the deque or 0 if the deque is empty.
Performance O(1)

Next Up

This completes the fourth part about stacks and queues. Next up, we'll move on to the binary search tree.
Tags:

Comments

Related Articles