Getting Started with Stacks and Queues: A Practical Guide with Examples
Welcome to a journey of discovery as we delve into the world of stacks and queues! These fundamental data structures have many purposes in computer science and programming, and understanding them is important for any developer.
Stacks and queues are both linear data structures that store elements in a specific order. The main difference between them is the way elements are accessed.
From common algorithms to real-world applications, we will uncover the hidden gems that make stacks and queues so essential.
STACKS
Stack is a data structure that follows the Last In First Out (LIFO) principle, meaning the most recent addition is the first one to be taken out. Think about it like a stack of plates, the last plate added to the stack is the first one you grab to eat. It’s a simple yet powerful concept that is used in many computer algorithms and programming languages.
Stacks are often used in combination with other data structures to solve problems like:
- The implementation of depth-first search (DFS) algorithms in graph traversal.
- Implementation of the backtracking algorithm, which is used to solve problems such as finding all possible routes or finding the shortest path in a graph.
- Check for balanced parentheses in an expression.
- Undo/Redo functionality in text editors and other applications.
- Application history (being able to go back to the previous page)
Here is an example of basic stack implementation in JavaScript using an array structure:
class Stack {
constructor() {
this.items = [];
}
push(item) {
this.items.push(item); // add item to the end
}
pop() {
if (this.items.length === 0) return null;
return this.items.pop(); //remove the last item
}
peek() {
return this.items[this.items.length - 1]; //last items value
}
isEmpty() {
return this.items.length === 0; //if stack has items or not
}
}
It can be used as follows:
let stack = new Stack();
console.log(stack.isEmpty()); // true
stack.push(10);
stack.push(20);
console.log(stack.peek()); // 20
console.log(stack.pop()); // 20
console.log(stack.pop()); // 10
console.log(stack.isEmpty()); // true
Alternatively, we can use linked lists to form a stack. Here is an example implementation:
class Node {
constructor(val) {
this.val = val;
this.next = null;
}
}
class Stack {
constructor() {
this.top = null;
this.bottom = null;
this.length = 0;
}
push(val) {
let newNode = new Node(val);
if (!this.top) {
this.top = newNode;
this.bottom = newNode;
} else {
let temp = this.top;
this.top = newNode;
this.top.next = temp;
}
return ++this.length;
}
pop() {
if (!this.top) {
return null;
}
let temp = this.top;
if (this.top === this.bottom) {
this.bottom = null;
}
this.top = this.top.next;
this.length--;
return temp.val;
}
peek() {
return this.top ? this.top.val : null;
}
}
Using a linked list to implement a stack has advantages over using an array:
- Efficient insertion and deletion: With a linked list, elements can be inserted and removed from the top of the stack in constant time (O(1)), as opposed to an array where the insertion and deletion at the top of the stack require shifting all the elements, which has a time complexity of O(n).
- Dynamic size: With a linked list implementation, the size of the stack can change dynamically as elements are added and removed. With an array implementation, the size of the stack is fixed and if more elements are added than the array can hold, a new array must be created and the elements must be copied over, which can be an expensive operation with O(n) complexity.
Problems from LeetCode
I have selected a group of problems that involve the use and understanding of stacks. We will get through the steps with explanations to have a better understanding.
20. Valid Parentheses
The question: Given a string s
containing just the characters '('
, ')'
, '{'
, '}'
, '['
and ']'
, determine if the input string is valid.
An input string is valid if:
- Open brackets must be closed by the same type of brackets.
- Open brackets must be completed in the correct order.
- Every close bracket has a corresponding open bracket of the same kind.
The approach: We will iterate through the string characters. For each open bracket, we will use a stack to store it. We will also utilize a map that tells us the corresponding close brackets for each open bracket.
For each closing bracket, we check if the top of the stack is the corresponding open bracket. If it is, we pop the top element of the stack and continue.
If the top element is not the corresponding open bracket or the stack is empty, we will return false. If the stack is empty, the input string is valid. Otherwise, it is not.
Here is the function that implements this logic:
function isValid(s) {
let stack = [];
let mapping = { '(': ')', '{': '}', '[': ']' };
for (let i = 0; i < s.length; i++) {
let char = s[i];
if (mapping[char]) {
stack.push(char);
} else {
let topElement = stack.pop();
if (mapping[topElement] !== char) {
return false;
}
}
}
return stack.length === 0;
}
Let’s look at a simple diagram of this process.
Input String: "{[()]}"
Step 1: push '{' to stack
Step 2: push '[' to stack
Step 3: push '(' to stack
Step 4: pop '(' from stack
Step 5: pop '[' from stack
Step 6: pop '{' from stack // stack is emthy
The process step by step:
- The input string is read, character by character.
- If the current character is an opening bracket, it is pushed onto the stack.
- If the current character is a closing bracket, we will check whether it matches the opening bracket on top of the stack. If it does, it pops the top element from the stack.
- If at any point, the current character does not match the opening bracket on top of the stack, or the stack is empty, the function returns false, as the input string is not valid.
- If the function reaches the end of the input string, and the stack is empty, meaning all the brackets in the input string were matched correctly, the function returns true.
94. Binary Tree Inorder Traversal
The question: Given the root
of a binary tree, return the in-order traversal of its nodes' values.
The approach: There are multiple correct approaches to solving this problem. Since our focus is on stacks, let’s see how we can approach this problem step by step.
- Start at the root of the tree
- Go as far left as possible, adding each node to the stack as you go
- When you can’t go any further left, pop the top element from the stack and add its value to the result array.
- Go to the right child of the popped node and repeat the process
- Keep repeating the steps until the stack is empty and you have visited all the nodes.
- The resulting array will contain the in-order traversal of the binary tree.
1
/ \
2 3
/ \ / \
4 5 6 7
result array: [4, 2, 5, 1, 6, 3, 7]
Here is how to implement this logic using stacks:
function inorderTraversal(root) {
let stack = [];
let result = [];
let current = root;
while (current || stack.length) {
while (current) {
stack.push(current);
current = current.left;
}
current = stack.pop();
result.push(current.val);
current = current.right;
}
return result;
}
This code uses a stack to keep track of the nodes to be visited and a result array to store the in-order traversal of the binary tree. It starts by initializing the current node to the root of the tree, and a while loop that continues until the current node is null and the stack is empty.
The inner while loop pushes the left child of each node onto the stack as you traverse down the tree. When there are no more left children, the function pops the top element from the stack, adds its value to the result array, and sets the current to its right child.
QUEUES
A queue is a data structure in which elements are inserted at the rear and removed from the front. It follows the First-In-First-Out (FIFO) principle. Queues are significant because they allow for the efficient management of data by controlling access and ensuring that elements are processed in a specific order.
Queues can be used in many different contexts, including:
- Task scheduling in operating systems
- Managing network packets
- Storing elements in a tree or graph data structure
- Buffering and rate-limiting in real-time systems
A basic example of a queue implemented using an array structure:
class Queue {
constructor() {
this.queue = [];
}
enqueue(item) {
this.queue.push(item);
}
dequeue() {
return this.queue.shift();
}
peek() {
return this.queue[0];
}
isEmpty() {
return this.queue.length === 0;
}
}
const queue = new Queue();
queue.enqueue(1);
queue.enqueue(2);
console.log(queue.peek()); // 1
console.log(queue.dequeue()); // 1
console.log(queue.peek()); // 2
The better way of implementing a queue is to use linked lists. It will provide the best efficiency for common operations.
Here is a queue formed by using a linked list structure:
class Node {
constructor(value) {
this.value = value;
this.next = null;
}
}
class Queue {
constructor() {
this.head = null;
this.tail = null;
this.size = 0;
}
enqueue(value) {
let newNode = new Node(value);
if (!this.head) {
this.head = newNode;
this.tail = newNode;
} else {
this.tail.next = newNode;
this.tail = newNode;
}
this.size++;
return this.size;
}
dequeue() {
if (!this.head) return null;
let removedNode = this.head;
if (this.head === this.tail) {
this.tail = null;
}
this.head = this.head.next;
this.size--;
return removedNode.value;
}
peek() {
if (!this.head) return null;
return this.head.value;
}
isEmpty() {
return this.size === 0;
}
}
Using a linked list structure to implement a queue in JavaScript can be beneficial for several reasons:
- No wasted memory: In a linked list-based queue, we only use the memory we need, and no memory is wasted.
- Good for large data sets: When working with large data sets, linked list-based queues can be more efficient than array-based queues, as the constant-time operations of adding and removing elements from the head or tail of a linked list are faster than resizing an array.
- Better for handling node insertion and deletion: In a linked list-based queue, inserting and deleting elements is an O(1) operation, while in an array-based queue, it takes O(n) time.
Problems from LeetCode
Here is a selection of problems that demonstrate the use and implementation of queues. Each problem includes step-by-step explanations and analysis to enhance understanding of the queue data structure.
114. Flatten Binary Tree to Linked List
The problem: The goal is to flatten a binary tree into a linked list format where the right child pointer points to the next node in the list and the left child pointer is always null, and the order should be the same as a pre-order traversal of the binary tree.
The approach: Each node should be added to a queue as it is visited. The queue will store the nodes in the same order as a pre-order traversal.
After all the nodes have been added to the queue, we can iterate through the queue, setting the left child of each node to null and the right child to the next node in the queue.
Here is the implementation of this logic in the queue structure:
function flatten(root) {
let queue = []
preOrder(root, queue)
let curr = queue.shift()
while (queue.length > 0) {
let next = queue.shift()
curr.left = null
curr.right = next
curr = next
}
}
function preOrder(node, queue) {
if (!node) return
queue.push(node)
preOrder(node.left, queue)
preOrder(node.right, queue)
}
Let’s see an example demonstrating these steps:
1
/ \
2 5
/ \ \
3 4 6
The function iterates through the queue, starting with the first node (1). It sets the left child of the current node (1) to null and the right child to the next node in the queue (2). The tree now looks like this:
1
\
2
/ \
3 4
/ / \
5 6
The iteration continues, setting the left child of the current node (2) to null and the right child to the next node in the queue (3). The tree will become:
1
\
2
\
3
/ \
4 5
/ \
6
This process continues until all the nodes have been processed and the tree is completely flattened into a linked list format:
1
\
2
\
3
\
4
\
5
\
6
1700. Number of Students Unable to Eat Lunch
The problem: The school cafeteria offers circular and square sandwiches at lunch break, referred to by numbers 0
and 1
respectively. All students stand in a queue. Each student either prefers square or circular sandwiches.
The number of sandwiches in the cafeteria equals the number of students. The sandwiches are placed in a stack. At each step:
- If the student at the front of the queue prefers the sandwich on the top of the stack, they will take it and leave the queue.
- Otherwise, they will leave it and go to the queue’s end.
This continues until none of the queue students want to take the top sandwich and are thus unable to eat.
You are given two integer arrays students
and sandwiches
where sandwiches[i]
is the type of the ith
sandwich in the stack (i = 0
is the top of the stack) and students[j]
is the preference of the jth
student in the initial queue (j = 0
is the front of the queue). Return the number of students that are unable to eat.
The approach: As long as there are still students in the queue and there is still a sandwich that matches the preference of the first student(this condition is crucial!) in the queue, we will keep going to iterate through the queue.
If the preference of the first student in the queue matches the type of the first sandwich in the stack, the student is removed from the queue and the sandwich is also removed from the stack.
If the preferences do not match, the student is moved to the back of the queue.
We can implement this logic in JavaScript like:
var countStudents = function(students, sandwiches) {
while (students.length > 0 && students.includes(sandwiches[0])) {
if (students[0] == sandwiches[0]) {
students.shift();
sandwiches.shift();
}
else students.push(students.shift());
}
return students.length
};
Let’s see an example of how this code is executed step by step:
The function is called with the following input arrays:
students = [0, 1, 1, 0]
sandwiches = [1, 0, 1, 0]
- The while loop starts and checks the condition, which is true, as there are students in the queue and there is a sandwich that matches the preference of the first student in the queue (0).
- The first element of the “students” array is compared to the first element of the “sandwiches” array. They do not match, so the else statement is executed.
- The first element of the “students” array is removed from the front of the array and added to the back of the array:
students = [1, 1, 0, 0]
4. The while loop restarts and the condition is checked again, which is still true.
5. The first element of the “students” array is compared to the first element of the “sandwiches” array. There is a match this time, so the first element of both arrays are removed:
students = [1, 0, 0]
sandwiches = [0, 1, 0]
6. The while loop restarts and the condition is checked again, which is still true.
This will go on until there are no students in the queue and there is no sandwich that matches the preference of the first student.
Conclusion
We have explored the structure and usage of both stacks and queues, and how they can be used to solve algorithm problems, how we may encounter them during an interview.
We have also seen that it is significant to understand the difference between the two data structures and use the correct one for the specific task that is given.
Knowing different types of implementations for data structures can help you choose the best one for a particular task or use case. Having a variety of implementation options can provide flexibility in your code, allowing you to adapt to changing requirements or improve performance as needed.
There will be different approaches that come to mind for many questions like these. It is good that we keep alternative solutions in our pockets!