Lesson 3

Welcome to our deep dive into the practical application of **Stack** data structures for solving algorithmically complex problems. Today, we will explore how a Stack, a Last-In-First-Out (LIFO) data structure, can come in handy for solving seemingly difficult computational challenges. We will focus on two problems that you can face during the technical interviews or in the real-work coding!

Let's start with our first problem. Imagine you have a list of integers, and your task is to determine the *preceding smaller value* for every number on the list. If a smaller previous element does not exist, you have to return `-1`

.

**Problem 1: Problem Actualization**

Now, let's give this problem some context for better understanding its real-world application. Imagine you are working in finance, analyzing historical stock prices. For each day, you would like to know the previous day when the price was lower than the current price. This situation is a perfect instance where our problem comes into play, and solving it would make your everyday job a lot easier.

**Problem 1: Naive Approach**

While approaching this problem, your initial heuristic might lead you down the path of comparing each number with all its previous numbers. While this method does offer a solution, it's not an efficient one. Would you guess it's time complexity? Exactly, it is $O(N^2)$! As the scale increases, this approach generates a lot of unnecessary computations, rendering it inefficient for larger data sets.

Instead of the naive, brute-force approach, a more elegant and efficient solution would involve the use of `Stacks`

. A Stack would allow us to track only relevant numbers, discarding the ones that won't contribute to the solution. This ensures higher accuracy in our solutions and optimizes computational resources.

Let's dissect our solution to understand each step better:

We start off by initializing an empty `Stack`

and a `result`

list with -1.

Python`1result = [-1] 2stack = []`

This `-1`

in the result list serves as a placeholder for the first element, as it has no preceding elements.

Next, we loop through our list of numbers. For every number, a while loop keeps popping items from the stack until we find a number smaller than the current one or until the stack is empty.

Python`1for num in numbers: 2 while stack and stack[-1] >= num: 3 stack.pop()`

At this point, if our stack is empty, it means there are no previous smaller elements, so we add -1 to the result list. If the stack is not empty, we add the top element of the stack (i.e., the previous smaller number) to our result list.

Python`1result.append(stack[-1] if stack else -1)`

After this, we add the current number to our stack.

Python`1stack.append(num)`

We then proceed to the next number and repeat the process.

Finally, as the first element of the result list was just an initial placeholder and not part of our actual solution, we return the result list from the second element to the end.

Python`1return result[1:]`

And just like that, we have an efficient solution to our first problem! Here is the full code:

Python`1def findSmallerPreceeding(numbers): 2 result = [-1] 3 stack = [] 4 for num in numbers: 5 while stack and stack[-1] >= num: 6 stack.pop() 7 result.append(stack[-1] if stack else -1) 8 stack.append(num) 9 return result[1:]`

Now, you might be wondering, "Sure, this approach seems way more efficient, but why does it work? How can we just ignore certain numbers and trust that our stack is leading us to the correct answers?" Well, that's the beauty of this approach.

By popping out the numbers from the stack that are larger than or equal to the current number, we are notifying the process that these numbers can't possibly be the "previous smaller value" for any other number that follows in the list. Why? Because if there is a number smaller than these popped numbers, it would be smaller than our current number as well, and it would inevitably be positioned in between our current number and the popped numbers. As a result, our current number is closer to any forthcoming numbers, fulfilling the 'smaller' and 'most recent' criteria in our quest.

Our second problem requires us to design a stack with a special feature. This stack should be capable of performing all typical operations like pushing an element onto the stack, popping the top element from the stack, and fetching the top element. In addition, it should also support a special function `get_min()`

that returns the smallest element in the stack — all within a time complexity of $O(1)$.

**Problem 2: Problem Actualization**

To understand when this problem may come in handy, imagine you are dealing with a stack of papers, each assigned with a different numerical value, and you always need to have access to the paper with the smallest number. In such a scenario, how would you engineer your stack so that the smallest paper can be found instantly? Our `get_min()`

function is the answer to this problem.

**Problem 2: Naive Approach**

The initial heuristic might be to maintain a separate variable to keep track of the minimum element. However, this approach becomes problematic when the minimum element is removed from the stack, as it triggers additional overhead to update the new minimum.

We can solve this issue by maintaining a secondary stack that mirrors the main stack but tracks the minimum values in parallel. When an element is pushed onto the main stack, we check if it's less than or equal to the current top element of the secondary stack. If it is, we also push it onto our secondary stack. This ingenious method paves the way to query the minimum element with a `get_min()`

function at a constant time complexity of $O(1)$.

Starting from the ground up, we'll build our structure and the `MinStack`

class to house all the method operations:

We'll first initialize two empty lists, `stack`

and `min_stack`

.

Python`1class MinStack: 2 def __init__(self): 3 self.stack = [] 4 self.min_stack = []`

This `stack`

stores the added elements, and `min_stack`

holds the minimum values.

Next, we'll shape the `push`

method, which pushes an element onto `stack`

, and if this new element is smaller than or equal to the current smallest element (the top element of `min_stack`

), it is also pushed onto our `min_stack`

.

Python`1 def push(self, x): 2 self.stack.append(x) 3 if not self.min_stack or x <= self.min_stack[-1]: 4 self.min_stack.append(x)`

Our `pop`

method is up next. It removes the top element from the `stack`

, and if the smallest element is being removed, it also pops the corresponding element from the `min_stack`

.

Python`1 def pop(self): 2 if self.stack: 3 if self.stack[-1] == self.min_stack[-1]: 4 self.min_stack.pop() 5 return self.stack.pop()`

The `top`

method returns the top element of `stack`

.

Python`1 def top(self): 2 return self.stack[-1] if self.stack else None`

Finally, the `get_min`

method, the star of our show, retrieves the minimum element in the stack.

Python`1 def get_min(self): 2 return self.min_stack[-1] if self.min_stack else None`

Congratulations! We've built a data structure — a `Stack`

with the customized `get_min`

function.

In this lesson, we delved deep into the usefulness of `Stack`

data structures for crafting elegant solutions to complex problems. We grappled with two unique algorithmic challenges and discovered how Stack's *LIFO property* (Last-In-First-Out) is pivotal in problem-solving contexts and forms an integral part of many real-world applications. That's it for now, I believe it's time to practice!