3 Major Keys

Photo by Florian Berger on Unsplash

Before we get into the keys, let's get some of the foundational stuff and prep work out of the way:

You need to understand how to analyze the time and space complexity of an algorithm.

Review your data structures and algorithms. Especially ones you think aren’t relevant (because in my experience those are always the ones that show up in the interview…)

Make sure you’re proficient with at least one programming language. Interviews can be very stressful. You don’t want the language which should be helping you ace the challenge be the reason you didn’t.

Do practice problems online. There are many websites like LeetCode, HackerRank that have an abundance of practice material.

There are many other ways to prep, but I’m sure you already know that. You came here for the good stuff… well, here it is!

Lateral Thinking

Some questions are “cleverly” designed to test your lateral thinking. How many different perspectives can you attack a problem from? Usually, your first interpretation of the problem will lead to the most inefficient solution. When your interviewer asks you to “do better”, it’s time to shift those lateral thinking gears into place.

Lateral thinking is all about breaking through any “perceived” limitations. I can’t teach you how to do that, otherwise, we’d all be geniuses. What I can do is teach you how to conjure an environment that fosters lateral thinking.

The secret is — you’re not supposed to do anything… your conscious mind that is. After looking at the problem, digesting it, your subconscious mind will start spitting ideas at you. Ideas that seemingly come from nowhere. Your job is to evaluate and explore the merits of those ideas. Eventually, a ting of inspiration will help you arrive at the solution.

Unnecessary thoughts distract your mind and hinder your ability to think. Thoughts like:

What if I can’t get this solution? Will I fail the interview?

How much time do I have left? How am I going to solve this in 7 minutes?

These thoughts act as a gateway into a downward spiral, which ends in panic. Dispell these thoughts from your mind and focus on the question. Don’t let these useless thoughts take up precious thought-generating real estate, real estate that should be allocated to solving the question.

Remain calm during interviews. Stress and panic hinder your ability to solve a question. Try to have fun, keep it lax, and let your brain do what it does best.

Of course, telling you to “not panic” is easier said than done, but this will come naturally with practice. Now let's look at a question.

Buy Low Sell High

You’re given historical price data for a stock:

You must find a start_time and end_time such that buying a stock at the start_time and selling that stock at the end_time yields the highest profit. Obviously, end_time must come after start_time:

You may pause here and try to come up with a solution. Your input data is an array of prices, and your output must be the index at which to buy/sell:

Input:

[5, 3, 2, 4, 6, 7, 6, 5, 5, 6, 7, 9, 10, 9, 7, 5, 6] Output:

(2, 12)

The first solution you may consider is the “brute force” solution. You create an outer/inner loop to consider every pair of start and end times. You keep track of which pair has the highest difference in price as you’re looping through, and return that pair at the end:

This algorithm has a complexity of O(N²). Can we do better? Of course, we can! This is where lateral thinking comes into play. The question is framed with the notion of time, which we know only goes forward, never backward. Consequently, our brain spits out solutions that “go forward”, that is to say from left to right. There is no constraint that disallows us from traversing the data backward.

We know we will always buy at some local minimum and sell at some local maximum (unless the graph is always decreasing). The root of the question is to pair every local minimum with a local maximum and choose the pairing with the highest profit. The difficulty of the “forward” approach lies in the temporal nature of the question. When we go left to right, we must remember every local minimum/maximum we’ve seen in the past in case a better pairing surfaces in the future. This pairing logic is extremely complicated to program in one loop.

If we go from right to left, we don’t have to keep track of every local minimum/maximum. The problem becomes much simpler. We know data we haven’t traversed yet occurred in the past, so we only need to keep track of the best we’ve seen and replace it if we find something better:

A brief explanation of the code: For every local maximum, we keep track of the “best” local minimum until we find a higher local maximum. We repeat this logic until we’ve traversed the array, keeping track of the best pairing throughout. Here’s a graph which illustrates the algorithm:

This algorithm has a complexity of O(N). Which is, of course, the best we can do. For this question, to think laterally meant to go backward. Situations where you may consider exploring the “backward” approach:

When you’re working with temporal data, as we did in this question.

When you’re working with strings (in English), which we read from left to right. What if traversing the string backward is easier?

Exercise: Here’s a coding challenge for practice: Backspace String Compare

Using Data Structures

To excel in programming interviews you need an excellent command of data structures. Questions that seem “easy enough” become hairy when you start writing the code. In these cases, leveraging data structures to help simplify the code you need to write is essential. You may need to use more than one data structure, or create your own altogether.

LRU Cache

In an LRU (least recently used) cache, the stalest item in the cache is evicted to make space for a new one:

Let’s think about how we would develop an LRU cache with get and put operations.

What if we used a queue in the form of a linked list? For the put operation, we insert an item into the queue, evicting the stalest (last) item if the queue is full. For the get operation, we traverse the queue to find the item. If found, we remove the item from its current location and insert it back into the list, thereby making it “fresh”:

The put operation inserts an item into the queue and if need be, pops an item if the queue is full. It has O(1) runtime complexity. The get operation, in the worst case, must traverse the entire queue to find an item. This operation has O(N) runtime complexity. Caches are usually optimized for reads (i.e. get operations), so we need a better implementation, ideally O(1). (You knew a simple linked list implementation wouldn’t cut it…)

The next data structure that comes to mind is a set or a hashmap. Something that has O(1) lookup. But how do we codify staleness? A linked list represents the staleness of an item beautifully (an item’s location in the list). A set doesn’t have any notion of order.

What if we store the “staleness” of an item in the hashmap as well? Would that help? The key would then be the hash of the item and the value would be its staleness. Our cache hit/miss logic would be O(1), we just need to confirm if the item exists in our hashmap. We still need to figure out how to update the “staleness” of the item on cache hit.

One possible solution is to keep an increasing count. We can use this count to update the staleness of the time on cache hit. A lower count means a staler item. During the put operation, should we need to evict an item, we evict the item with the lowest count:

The get operation now has a runtime complexity of O(1), and the put operation has a runtime complexity of O(N) (since if we need to evict an item, we must traverse the entire hashmap to find the stalest item). Using a clever trick, we’ve successfully optimized for get operations. Let’s see if we can do even better…

Our linked list approach gave us an O(1) put operation. Our hashmap approach gave us an O(1) get operation. If we can somehow find a way to merge both approaches, we can achieve constant time get and put operations.

I’m going to describe the solution in the next paragraph, see if you can figure it out before moving forward.

What “sucks” about the linked-list is we must traverse every item during the get operation. Assuming we have a hashmap, we don’t need to traverse the list. The part that “sucks” about the hashmap is when finding an item to evict, we must traverse all items in the hashmap. If we have a linked list at our disposal, then we can pop the last item, and we’ll know which item to evict in the hashmap. It seems like the hashmap and the linked list play to each other's weaknesses. Let’s start reasoning through the get & put operations.

To add a new item, we’ll insert it in the hashmap and the linked list:

What if the cache is full? What would eviction look like? For eviction, we can remove the last item in the queue. Since we have the evicted item on hand, we can remove it from the hashmap as well:

Sweet, that worked out pretty well. Okay now let’s consider the get operation. Checking if the item exists is trivial, we simply check the hashmap. If it doesn’t, we say it was a cache miss. What if it exists? We need to update the item’s staleness. This is where things get hairy. We need to traverse the linked list to find the item and move it to the front. The difficulty lies in having to traverse the list. Can we somehow avoid the traversal?

A linked list is, in a sense, a collection of pointers to nodes. We need to traverse the linked list to get to the pointer we want. We need an alternate way of accessing this pointer. If only we had a constant time lookup data structure where we can store these pointers… Oh wait, we do!

Let’s keep track of every item’s pointer in the linked list by storing them as values in the hashmap. Now we can use the hashmap to access the pointer, and use the pointer to re-position the item in the linked-list:

And voila! We now have an LRU cache with an O(1) get operation and an O(1) put operation, all because we bent data structures to our will. Pretty nifty stuff.

Exercise: I’ll leave you to code this solution. It’s not that I’m lazy, I would code it up and paste it here… but you may get asked this question, or something similar, in an interview and having been through this exercise is good practice. Also, I’m a bit lazy…

Understand Time And Space

Almost every question you attempt will have solutions with various levels of runtime and spacetime complexity. Runtime complexity and spacetime complexity are, from what I’ve seen, related to some degree.

When an interviewer asks you if you can do better than your initial approach, you’ll likely need to sacrifice your spacetime complexity in order to achieve better runtime complexity. Luckily for you, interviewers seldom care about spacetime complexity. They may ask you to analyze it, but rarely will they ask you to improve it.

There are countless examples of the tradeoff between runtime and spacetime complexity. Take Finding a Duplicate for example:

Given an array, return True if there are duplicate numbers, False otherwise:

[0, 1, 2, 3] -> False

[3, 1, 2, 3] -> True

An O(1) spacetime solution is to use an outer/inner loop to compare every number with every other number, returning True if both numbers are the same. This solution has an O(N²) runtime complexity.

Let’s sacrifice some of our spacetime complexity and create a sorted array. Duplicates are now adjacent to one another and we simply need to traverse the array to find them. We’ve gone from O(1) to O(N) spacetime complexity, and we’ve reduced our runtime complexity to O(N log N).

Let’s sacrifice more spacetime complexity and use a hashmap. We’ll traverse the list, using the numbers as keys for our hashmap, and keeping a count of how many times we’ve seen that number as the value. Once we’ve created our hashmap, we traverse the hashmap and return True if we see any value greater than 1:

Our runtime complexity reduces to O(N) and our spacetime complexity raises slightly (since a hashmap is less memory-efficient than an array), albeit its still O(N).

There are countless of other coding challenges that exhibit the same time-space tradeoff. Take the LRU cache we developed in the previous section. The solution with the highest runtime efficiency also used the most space. Understanding the time-space complexity tradeoff gives you a systematic way to improve the runtime complexity of your solution. If you’re asked to find a better approach, ask yourself, how can I use space to my advantage:

Are there any useful values I can pre-compute?

Can I reformat my input in a way that’s optimized to work with my algorithm?

Can I map the data to represent a problem I’m familiar with?

In some cases, you’ll be asked to improve both time/space complexity and you won’t be able to conjure any data structures that will come to your rescue. Such questions are designed to stump you, and there is usually a detail in the question or a property of the problem you’re not leveraging. So make sure you read the question very carefully.

Closing Thoughts

Hopefully, my “tips” change the way you look at coding problems and help you during your interviews. I’ll re-iterate them again:

Lateral Thinking — Let your brain explore different ways to attack the problem.

— Let your brain explore different ways to attack the problem. Using Data Structures — Use data structures for cleaner and more efficient code.

— Use data structures for cleaner and more efficient code. Understand Time And Space — Sacrifice space for better time complexity (and vice versa). When asked to improve both time/space, try leveraging a property/detail of the problem.

Of course, completing the coding questions alone won’t be enough to pass the interview. There are other things interviewers consider. During the interview, make sure you:

Talk about what you’re thinking. (e.g. I’m having trouble with this aspect of the problem, I’m thinking if there’s some way I can get around it.)

Be positive! Light conversation and smiles go a long way.

Ask questions. You are evaluating the company just as much as they’re evaluating you. Is this a company you’d be excited to work for every day?

Exercises

The problems covered in this article were relatively simple but they sufficed to illustrate the point. Along with the exercises listed in each section, here are some other coding challenges to help you practice:

Two Sum

Merge Intervals

Item Order Data Structure: Write a data structure that will be used for keeping track of the order of items in a list. The data structure should have insert_item, delete_item, and reorder_item operations. All operations should have O(1) runtime complexity.

Good luck with your next coding challenge! :)