Skip to the questions

Software Engineer · Fresher to 3 years

DSA interview questions

The 40 data structures and algorithms questions that come up most in coding rounds. Each one has a short answer you can say out loud in about 30 seconds, and a full solution in Python, with its time and space complexity, for when you need it.

Read the quick answers

3 Basics · 4 Arrays · 3 Strings · 3 Hashing · 2 Two pointers · 6 Sorting & searching · 3 Recursion · 3 Stacks & queues · 3 Linked lists · 5 Trees · 2 Graphs · 3 DPUpdated 27 Oct 2026

When is your interview?

Before you startHow to work through any coding question

Interviewers score the process, not just the final code. Work through these steps out loud:

  1. Clarify the input. How big can it get? Sorted or not? Duplicates? Negative numbers? Can it be empty? What should you return when there's no answer?
  2. Work one example by hand, plus one edge case, and agree on the expected output.
  3. Say the brute-force solution and its complexity, even if you won't code it. It shows you understand the problem and gives you a fallback.
  4. Optimise. Look for repeated work and use the clue table below to pick a pattern.
  5. Code it, with clear names, explaining each part as you write it.
  6. Test by tracing your code on the example and the edge cases, line by line, not by rereading it.
  7. State the time and space complexity.

Which pattern does the question point to?

Clue in the questionPattern to tryQuestions
A sorted array, find a value or a pairBinary search, two pointers11, 16, 20
"Subarray" or "substring" that meets a conditionSliding window, running sum6, 15
"Have I seen this before?", counts, pairsHash map or set9, 11, 12
Matching brackets, "next greater", undoStack26, 27
Shortest path in an unweighted graph, level by levelBFS with a queue31, 36
Every combination, permutation or subsetBacktracking23, 24
"How many ways" or "the minimum cost", with choices that repeatDynamic programming38, 39, 40
A cycle or the middle of a linked listSlow and fast pointers30
Overlapping rangesSort by start, then sweep21
Top k, kth largest, "always take the smallest next"Heap4, 21

Rough input limits, if you're unsure which complexity is needed: with n up to a few thousand, O(n²) is usually fine; up to about 10⁶, aim for O(n log n) or O(n); beyond that, O(n) or O(log n). As a rule of thumb, C++ and Java manage around 10⁸ simple steps a second, and Python closer to 10⁷.

01Basics and complexity

0/3 got it

Q01Not marked yetFresherAsked very oftenConcept

What are time and space complexity, and what does Big O notation tell you?

Say this first~30 sec to say

Time complexity describes how the number of steps an algorithm takes grows as the input size n grows; space complexity describes how its extra memory grows. Big O gives the growth rate as an upper bound, usually for the worst case, ignoring constants and smaller terms. So O(n) means doubling the input roughly doubles the work, and O(n²) means it roughly quadruples. It lets you compare algorithms without depending on the machine.

Could you say it?
Full answer4 min read

The common growth rates

Big ONameExampleSteps for n = 1,000,000
O(1)ConstantReading nums[i], a dict lookup1
O(log n)LogarithmicBinary search (question 16)about 20
O(n)LinearOne loop over the input1,000,000
O(n log n)LinearithmicMerge sort, Python's sorted()about 20,000,000
O(n²)QuadraticComparing every pair; bubble sort10¹² (far too slow)
O(2ⁿ)ExponentialEvery subset (question 24)Unusable beyond n ≈ 30
O(n!)FactorialEvery permutation (question 23)Unusable beyond n ≈ 12

Reading complexity from code

Python
def first_item(items):           # O(1): one step, whatever the size
    return items[0]

def total(items):                # O(n): one pass
    result = 0
    for x in items:
        result += x
    return result

def has_duplicate_pair(items):   # O(n²): every pair, with O(1) extra space
    for i in range(len(items)):
        for j in range(i + 1, len(items)):
            if items[i] == items[j]:
                return True
    return False

def has_duplicate(items):        # O(n) time, but O(n) extra space for the set
    seen = set()
    for x in items:
        if x in seen:
            return True
        seen.add(x)
    return False

print(has_duplicate_pair([3, 1, 4, 1]), has_duplicate([3, 1, 4, 1]))   # True True

The last two functions show the most common trade-off in coding rounds: spending memory (a set or a dict) to save time.

The rules for simplifying

  • Drop constants: O(2n) is O(n). Big O describes the shape of the growth, not the exact count.
  • Keep the biggest term: O(n² + n) is O(n²), because n² dominates for large n.
  • Steps one after another add up; nested loops multiply: a loop inside a loop over the same input is O(n²).
  • Different inputs get different letters: looping over a list of size a and then one of size b is O(a + b), not O(n).

Space complexity usually means the extra memory the algorithm needs, not the input itself. Recursion counts too: each call that hasn't returned yet holds a stack frame, so recursing n levels deep uses O(n) space.

Follow-ups

  • What's the difference between O, Θ and Ω? O is an upper bound, Ω a lower bound, and Θ a tight bound (both at once). In interviews, "Big O" is normally used loosely to mean the tight worst-case bound.
  • What is amortised O(1)? An operation that's occasionally expensive but cheap on average over many calls. Appending to a Python list is the classic example: once in a while the list grows and copies itself, but each append is O(1) on average.
  • Best, average or worst case? Quote the worst case unless asked otherwise. Quick sort is the usual example where they differ: O(n log n) on average, O(n²) at worst (question 19).
Q02Not marked yetFresherAsked very oftenCoding

Check whether a number is prime

Say this first~30 sec to say

A prime is a number greater than 1 whose only divisors are 1 and itself. Try dividing by every number from 2 up to √n: if n had a factor larger than √n, it would pair with one smaller than √n, which you'd already have found. That's O(√n) time instead of O(n). Handle n < 2 first, since those aren't prime, and after checking 2 you can skip the even divisors.

Could you say it?
Full answer3 min read
Python
import math

def is_prime(n):
    if n < 2:
        return False
    if n < 4:
        return True                       # 2 and 3
    if n % 2 == 0:
        return False
    for d in range(3, math.isqrt(n) + 1, 2):   # odd divisors up to √n
        if n % d == 0:
            return False
    return True

print([n for n in range(30) if is_prime(n)])   # [2, 3, 5, 7, 11, 13, 17, 19, 23, 29]
print(is_prime(97), is_prime(91))              # True False: 91 = 7 × 13
print(is_prime(25), is_prime(-7))              # False False

Why √n is enough

The divisors of 36 come in pairs: 1 × 36, 2 × 18, 3 × 12, 4 × 9 and 6 × 6. The pairs mirror around √36 = 6, so if nothing up to 6 divides a number, nothing above 6 can either. math.isqrt gives the exact integer square root; int(n ** 0.5) goes through floating point and can be off by one for very large n.

Edge cases to mention: 0, 1 and negative numbers (not prime), 2 (the only even prime), and perfect squares such as 25, which is why the loop runs up to and including √n.

All primes up to a limit: the Sieve of Eratosthenes

When you need many primes, cross out multiples instead of testing each number:

Python
import math

def primes_up_to(limit):
    is_p = [True] * (limit + 1)
    is_p[0:2] = [False, False]                    # 0 and 1 aren't prime
    for i in range(2, math.isqrt(limit) + 1):
        if is_p[i]:
            for multiple in range(i * i, limit + 1, i):
                is_p[multiple] = False            # a multiple of i can't be prime
    return [i for i, prime in enumerate(is_p) if prime]

print(primes_up_to(50))   # [2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47]
ApproachTimeWhen to use
Try every divisor up to n − 1O(n)Never: say it, then improve it
Try divisors up to √nO(√n)Testing one number
Sieve of EratosthenesO(n log log n) for all numbers up to nMany primes, or many tests

Follow-ups

  • Why does the sieve start crossing out at i × i? Smaller multiples of i, such as 2i and 3i, were already crossed out by smaller primes.
  • How would you find the prime factors of n? Divide by 2 while you can, then by odd numbers up to √n, dividing out each factor fully; whatever is left above 1 is itself prime.
Q03Not marked yetFresherAsked very oftenCoding

Reverse the digits of a number

Say this first~30 sec to say

Repeatedly take the last digit with n % 10, add it to the result with result = result * 10 + digit, and remove it from n with n //= 10, until n is 0. That's O(d) time for d digits, which is O(log n), and O(1) space. For a negative number, reverse its absolute value and put the sign back; trailing zeros disappear, so 120 becomes 21. The same loop gives palindrome numbers, Armstrong numbers and digit sums.

Could you say it?
Full answer3 min read
Python
def reverse_number(n):
    sign = -1 if n < 0 else 1
    n = abs(n)
    result = 0
    while n > 0:
        digit = n % 10                 # the last digit
        result = result * 10 + digit   # shift left and append it
        n //= 10                       # drop the last digit
    return sign * result

print(reverse_number(12345))   # 54321
print(reverse_number(-120))    # -21
print(reverse_number(0))       # 0

Tracing 123

ndigitresult
12333
12232
11321
0loop ends

Why abs() first: in Python, % follows the sign of the divisor, so -123 % 10 is 7, not -3. Working on the absolute value avoids that surprise, and works the same way in every language.

The same loop, three more programs

Python
def is_palindrome_number(n):
    return n >= 0 and reverse_number(n) == n

def digit_sum(n):
    n, total = abs(n), 0
    while n:
        total += n % 10
        n //= 10
    return total

def is_armstrong(n):                  # 153 = 1³ + 5³ + 3³
    power = len(str(n))
    total, m = 0, n
    while m:
        total += (m % 10) ** power
        m //= 10
    return total == n

print(is_palindrome_number(121), is_palindrome_number(123))   # True False
print(digit_sum(9875))                                         # 29
print([n for n in range(1, 1000) if is_armstrong(n)])
# [1, 2, 3, 4, 5, 6, 7, 8, 9, 153, 370, 371, 407]

The string shortcut: int(str(abs(n))[::-1]) also works in Python. Mention it, but write the arithmetic version: that's what the question is testing.

Follow-ups

  • What about overflow? In Java or C++, reversing a large int such as 1,999,999,999 overflows. Check before multiplying: if result > (Integer.MAX_VALUE - digit) / 10, stop and report it. Python's integers never overflow.
  • What's the time complexity in terms of n? The loop runs once per digit, and a number n has about log₁₀ n digits, so it's O(log n).

02Arrays

0/4 got it

Q04Not marked yetFresherAsked very oftenCoding

Find the largest and second-largest elements in an array

Say this first~30 sec to say

Make one pass, tracking two values, first and second. When a number beats first, the old first moves down to second; when it falls strictly between them, it becomes the new second. That's O(n) time and O(1) space, better than sorting at O(n log n). Ask about duplicates first: in [5, 5, 3], is the second largest 5 or 3? Usually it means the second largest distinct value, 3.

Could you say it?
Full answer2 min read
Python
def top_two(nums):
    if not nums:
        raise ValueError("empty array")
    first = second = float("-inf")
    for x in nums:
        if x > first:
            first, second = x, first      # the old first becomes second
        elif first > x > second:          # strictly between: skips copies of first
            second = x
    return first, (second if second != float("-inf") else None)

print(top_two([12, 35, 1, 10, 34, 1]))   # (35, 34)
print(top_two([5, 5, 3]))                # (5, 3)
print(top_two([7, 7]))                   # (7, None): no second distinct value
print(top_two([-4, -9]))                 # (-4, -9)

Starting both at negative infinity means any real number replaces them, so arrays full of negative numbers work too.

ApproachTimeSpaceNote
Sort, then read from the endO(n log n)O(1) to O(n)Simple, but does more work than needed
Two passes: find the max, then the max of the restO(n)O(1)Fine, slightly more code
One pass with two variablesO(n)O(1)What interviewers expect

Edge cases to mention: an empty array, a single element, all elements equal, and negative numbers.

Follow-ups

  • What about the kth largest? Keep a min-heap of size k: O(n log k). heapq.nlargest(3, nums) does exactly that, and quickselect averages O(n).
Python
import heapq

print(heapq.nlargest(3, [12, 35, 1, 10, 34, 1]))   # [35, 34, 12]
  • How many comparisons does the one-pass version make? Up to 2n. A tournament method can do it in n + log₂ n − 2 comparisons; worth knowing it exists if the interviewer pushes.
Q05Not marked yetFresherAsked very oftenCoding

Find the one missing number in an array holding 1 to n

Say this first~30 sec to say

The array holds n − 1 distinct numbers from 1 to n, so the missing one is the expected sum minus the actual sum: n(n + 1)/2 − sum(nums). That's O(n) time and O(1) space. XOR works too: XOR every number from 1 to n with every value in the array; matching pairs cancel out and only the missing number is left. XOR also avoids integer overflow in languages with fixed-size integers.

Could you say it?
Full answer2 min read
Python
def missing_number(nums):
    n = len(nums) + 1                     # one number is missing
    return n * (n + 1) // 2 - sum(nums)

def missing_number_xor(nums):
    n = len(nums) + 1
    result = 0
    for i in range(1, n + 1):
        result ^= i                       # every number that should be there
    for v in nums:
        result ^= v                       # every number that is there
    return result                         # pairs cancel; the missing one is left

print(missing_number([1, 2, 4, 5, 6]))       # 3
print(missing_number_xor([1, 2, 4, 5, 6]))   # 3
print(missing_number([2]))                   # 1
print(missing_number([1, 2, 3]))             # 4: the missing number can be n itself

Why XOR works: a ^ a == 0, a ^ 0 == a, and the order doesn't matter. Every number that's present appears twice in total and cancels; the missing one appears once.

ApproachTimeSpace
Sort, then look for the gapO(n log n)O(1)
Put everything in a set, then check 1..nO(n)O(n)
Sum formulaO(n)O(1)
XORO(n)O(1), and no overflow

Questions to ask first: Is it exactly one number missing? Are the values 1 to n or 0 to n? Is the array sorted? If it's sorted, binary search on where nums[i] != i + 1 starts finds the gap in O(log n).

Follow-ups

  • What about overflow? In Java or C, n * (n + 1) / 2 overflows a 32-bit int once n passes about 65,000. Use a long, or XOR.
  • What if two numbers are missing? The sum gives x + y and the sum of squares gives x² + y², which is enough to solve for both. Or XOR everything to get x ^ y, then split the numbers into two groups on any set bit of that result.
Q06Not marked yetFresherAsked very oftenCoding

Find the contiguous subarray with the largest sum

Say this first~30 sec to say

Kadane's algorithm. Walk the array keeping current, the best sum of a subarray ending at this element, and best, the best seen so far. At each number, either extend the previous subarray or start fresh here: current = max(x, current + x), which is the same as dropping the running sum once it goes negative. O(n) time, O(1) space, versus O(n²) for checking every subarray. Starting from the first element makes an all-negative array return its largest element.

Could you say it?
Full answer3 min read
Python
def max_subarray_sum(nums):
    best = current = nums[0]
    for x in nums[1:]:
        current = max(x, current + x)    # extend the subarray, or restart at x
        best = max(best, current)
    return best

print(max_subarray_sum([-2, 1, -3, 4, -1, 2, 1, -5, 4]))   # 6: [4, -1, 2, 1]
print(max_subarray_sum([-3, -1, -2]))                       # -1

Why it works: a running sum that has gone negative can only make any subarray that continues it smaller, so it's always better to start again from the next element.

Tracing [-2, 1, -3, 4, -1, 2, 1, -5, 4]

xcurrentbest
-2-2-2
11 (restart)1
-3-21
44 (restart)4
-134
255
166
-516
456

Returning the subarray as well

Python
def max_subarray(nums):
    best = current = nums[0]
    start = best_start = best_end = 0
    for i in range(1, len(nums)):
        if current + nums[i] < nums[i]:
            current, start = nums[i], i          # restart here
        else:
            current += nums[i]
        if current > best:
            best, best_start, best_end = current, start, i
    return best, nums[best_start:best_end + 1]

print(max_subarray([-2, 1, -3, 4, -1, 2, 1, -5, 4]))   # (6, [4, -1, 2, 1])

The close variant: one stock buy and one sell

Given daily prices, find the best profit from buying once and selling later. Track the lowest price so far, and the best profit if you sold today:

Python
def max_profit(prices):
    lowest, profit = float("inf"), 0
    for price in prices:
        lowest = min(lowest, price)
        profit = max(profit, price - lowest)
    return profit

print(max_profit([7, 1, 5, 3, 6, 4]))   # 5: buy at 1, sell at 6
print(max_profit([7, 6, 4, 3, 1]))      # 0: prices only fall, so don't trade

Follow-ups

  • What does the brute force cost? Checking every start and end is O(n²) with a running sum, or O(n³) if each sum is recomputed.
  • What about an empty array? Agree with the interviewer: raise an error, or return 0 if an empty subarray is allowed.
  • Maximum product subarray? Track both the largest and the smallest product ending at each element, because a negative number turns the smallest into the largest.
Q07Not marked yetFresherAsked oftenCoding

Rotate an array by k positions

Say this first~25 sec to say

First set k = k % n, because rotating by n changes nothing. To rotate right in place: reverse the whole array, then reverse the first k elements, then reverse the rest. Every element ends up exactly where it belongs, in O(n) time and O(1) extra space. If extra space is allowed, slicing does it in one line: nums[-k:] + nums[:-k].

Could you say it?
Full answer2 min read
Python
def reverse(nums, lo, hi):
    while lo < hi:
        nums[lo], nums[hi] = nums[hi], nums[lo]
        lo, hi = lo + 1, hi - 1

def rotate_right(nums, k):
    n = len(nums)
    if n == 0:
        return nums
    k %= n
    reverse(nums, 0, n - 1)    # [7, 6, 5, 4, 3, 2, 1]
    reverse(nums, 0, k - 1)    # [5, 6, 7, 4, 3, 2, 1]
    reverse(nums, k, n - 1)    # [5, 6, 7, 1, 2, 3, 4]
    return nums

print(rotate_right([1, 2, 3, 4, 5, 6, 7], 3))    # [5, 6, 7, 1, 2, 3, 4]
print(rotate_right([1, 2, 3], 4))                # [3, 1, 2]: 4 % 3 = 1
print(rotate_right([1, 2, 3], -1))               # [2, 3, 1]: same as rotating left by 1

In Python, -1 % 3 is 2, so a negative k quietly becomes the equivalent right rotation.

With extra space

Python
def rotated(nums, k):
    if not nums:
        return []
    k %= len(nums)
    return nums[-k:] + nums[:-k]

print(rotated([1, 2, 3, 4, 5, 6, 7], 3))   # [5, 6, 7, 1, 2, 3, 4]
ApproachTimeExtra space
Move every element one step, k timesO(n · k)O(1)
Copy into a new array at (i + k) % nO(n)O(n)
Three reversalsO(n)O(1)

Rotating left by k is the same as rotating right by n − k. With reversals, reverse the first k, then the rest, then the whole array.

Follow-ups

  • Why does reversing work? Reversing the whole array puts the last k elements at the front, but backwards; reversing each part separately puts both parts back in order.
  • Is there a built-in? collections.deque(nums).rotate(k) rotates a deque right by k.

03Strings

0/3 got it

Q08Not marked yetFresherAsked very oftenCoding

Check whether a string is a palindrome

Say this first~30 sec to say

Use two pointers, one at each end. Compare the two characters and move both pointers inward until they meet; any mismatch means it isn't a palindrome. That's O(n) time and O(1) extra space. Ask first whether case, spaces and punctuation count: "A man, a plan, a canal: Panama" is a palindrome only if you skip non-alphanumeric characters and ignore case. In Python, s == s[::-1] is the one-liner, but it builds a reversed copy.

Could you say it?
Full answer2 min read
Python
def is_palindrome(s):
    left, right = 0, len(s) - 1
    while left < right:
        if s[left] != s[right]:
            return False
        left += 1
        right -= 1
    return True

def is_palindrome_sentence(s):              # ignore case, spaces and punctuation
    left, right = 0, len(s) - 1
    while left < right:
        if not s[left].isalnum():
            left += 1
        elif not s[right].isalnum():
            right -= 1
        elif s[left].lower() != s[right].lower():
            return False
        else:
            left, right = left + 1, right - 1
    return True

print(is_palindrome("racecar"), is_palindrome("chai"))           # True False
print(is_palindrome(""), is_palindrome("a"))                     # True True
print(is_palindrome_sentence("A man, a plan, a canal: Panama"))  # True

The warm-up: reverse a string

Strings are immutable in Python and Java, so the two-pointer swap works on a list of characters:

Python
def reverse_string(s):
    chars = list(s)
    left, right = 0, len(chars) - 1
    while left < right:
        chars[left], chars[right] = chars[right], chars[left]
        left, right = left + 1, right - 1
    return "".join(chars)

print(reverse_string("Pune"))   # enuP

Edge cases to mention: the empty string and a single character (both palindromes), even and odd lengths, and mixed case.

Follow-ups

  • Can it become a palindrome by deleting at most one character? On the first mismatch, check whether skipping the left character or the right one leaves a palindrome. Still O(n).
  • What's the longest palindromic substring? Expand around each centre (each character, and each gap between two characters): O(n²) time, O(1) space.
  • What about a palindrome number? Reverse its digits arithmetically (question 3).
Q09Not marked yetFresherAsked very oftenCoding

Check whether two strings are anagrams of each other

Say this first~30 sec to say

Two strings are anagrams if they contain the same characters the same number of times. Count the characters of both and compare the counts: O(n) time, using a hash map, or a fixed array of 26 counts for lowercase English letters. Alternatively, sort both and compare, in O(n log n). Check the lengths first, since different lengths can't be anagrams, and ask whether case and spaces matter, as in "Dormitory" and "Dirty room".

Could you say it?
Full answer2 min read
Python
from collections import Counter

def is_anagram(a, b):
    return len(a) == len(b) and Counter(a) == Counter(b)

def is_anagram_counts(a, b):              # lowercase a-z, without Counter
    if len(a) != len(b):
        return False
    counts = [0] * 26
    for ch_a, ch_b in zip(a, b):
        counts[ord(ch_a) - ord("a")] += 1
        counts[ord(ch_b) - ord("a")] -= 1
    return all(c == 0 for c in counts)    # every letter balanced out

print(is_anagram("listen", "silent"), is_anagram("chai", "chat"))   # True False
print(is_anagram_counts("anagram", "nagaram"))                       # True
print(sorted("listen") == sorted("silent"))                          # True

Phrases: normalise first

Python
def letters(s):
    return sorted(ch.lower() for ch in s if ch.isalnum())

print(letters("Dormitory") == letters("Dirty room"))   # True
ApproachTimeExtra space
Sort both and compareO(n log n)O(n) for the sorted copies
Hash map of countsO(n)O(k) for k distinct characters
Array of 26 countsO(n)O(1), but only for a-z

Follow-ups

  • Group a list of words into anagram groups. Use a dict keyed by each word's sorted letters:
Python
from collections import defaultdict

def group_anagrams(words):
    groups = defaultdict(list)
    for word in words:
        groups["".join(sorted(word))].append(word)
    return list(groups.values())

print(group_anagrams(["eat", "tea", "tan", "ate", "nat", "bat"]))
# [['eat', 'tea', 'ate'], ['tan', 'nat'], ['bat']]
  • What about Unicode text? The 26-slot array only covers a to z. A hash map works for any characters.
Q10Not marked yetFresherAsked oftenCoding

Reverse the order of words in a sentence

Say this first~25 sec to say

Split the sentence into words, reverse the list and join the words with single spaces: " ".join(reversed(s.split())). With no argument, split() also drops leading, trailing and repeated spaces. That's O(n) time and O(n) space. If built-ins are off limits, scan from the end, collecting each word. With a mutable character array, the in-place trick is to reverse the whole thing, then reverse each word back.

Could you say it?
Full answer2 min read
Python
def reverse_words(s):
    return " ".join(reversed(s.split()))

print(repr(reverse_words("  the sky   is blue ")))   # 'blue is sky the'

Without split(): scan from the end

Python
def reverse_words_manual(s):
    words, i = [], len(s) - 1
    while i >= 0:
        while i >= 0 and s[i] == " ":     # skip spaces
            i -= 1
        end = i
        while i >= 0 and s[i] != " ":     # walk back to the start of the word
            i -= 1
        if end >= 0:
            words.append(s[i + 1:end + 1])
    return " ".join(words)

print(repr(reverse_words_manual("  the sky   is blue ")))   # 'blue is sky the'

In place, on a character array

Python
def reverse_words_in_place(chars):
    def reverse(lo, hi):
        while lo < hi:
            chars[lo], chars[hi] = chars[hi], chars[lo]
            lo, hi = lo + 1, hi - 1

    reverse(0, len(chars) - 1)            # 'eulb si yks eht'
    start = 0
    for i in range(len(chars) + 1):
        if i == len(chars) or chars[i] == " ":
            reverse(start, i - 1)         # turn each word back round
            start = i + 1
    return chars

print("".join(reverse_words_in_place(list("the sky is blue"))))   # blue is sky the

That version assumes single spaces between words. It's the expected answer when the interviewer says "O(1) extra space" and gives you a mutable array.

Follow-ups

  • Reverse each word but keep the order? " ".join(word[::-1] for word in s.split()) turns "the sky" into "eht yks".
  • Why does the in-place version work? Reversing the whole string puts the words in the right order but spells each one backwards; reversing each word fixes the spelling.

04Hashing

0/3 got it

Q11Not marked yetFresherAsked very oftenCoding

Find two numbers in an array that add up to a given target

Say this first~30 sec to say

Walk the array once with a hash map from value to index. For each x, check whether target - x is already in the map: if so, that's the pair; if not, store x. O(n) time and space, instead of O(n²) for checking every pair. If the array is sorted, use two pointers from both ends instead: move the left one when the sum is too small and the right one when it's too big, for O(1) space.

Could you say it?
Full answer2 min read
Python
def two_sum(nums, target):
    seen = {}                             # value -> index
    for i, x in enumerate(nums):
        if target - x in seen:
            return seen[target - x], i
        seen[x] = i                       # store after checking
    return None

print(two_sum([2, 7, 11, 15], 9))   # (0, 1)
print(two_sum([3, 3], 6))           # (0, 1)
print(two_sum([3, 4], 6))           # None: one 3 can't be used twice

Checking before storing means an element is never paired with itself, which is why [3, 4] with target 6 correctly returns None.

Sorted input: two pointers

Python
def two_sum_sorted(nums, target):
    left, right = 0, len(nums) - 1
    while left < right:
        total = nums[left] + nums[right]
        if total == target:
            return left, right
        if total < target:
            left += 1                     # need a bigger sum
        else:
            right -= 1                    # need a smaller sum
    return None

print(two_sum_sorted([1, 3, 4, 6, 9], 10))   # (0, 4): 1 + 9
print(two_sum_sorted([1, 3, 4, 6, 9], 2))    # None
ApproachTimeExtra spaceNeeds sorted input?
Check every pairO(n²)O(1)No
Hash mapO(n)O(n)No
Sort, then two pointersO(n log n)O(1), but sorting loses the original indicesSorts it
Two pointersO(n)O(1)Yes

Follow-ups

  • Return every pair, not just one? Keep going instead of returning, and decide with the interviewer how duplicates should count.
  • Three numbers that sum to zero (3Sum)? Sort, then for each element run two pointers on the rest: O(n²), skipping duplicates.
  • Why does the two-pointer version never miss a pair? When the sum is too small, the left number can't pair with anything: its best partner, the largest remaining number, already isn't enough. So moving it is safe, and the same logic applies on the right.
Q12Not marked yetFresherAsked very oftenCoding

Find the first non-repeating character in a string

Say this first~20 sec to say

Two passes. First, count every character with a hash map. Then walk the string again in its original order and return the first character whose count is 1. That's O(n) time; the map holds one entry per distinct character, which is O(1) space for a fixed alphabet. Calling s.count(ch) for every character would be O(n²).

Could you say it?
Full answer2 min read
Python
from collections import Counter

def first_unique(s):
    counts = Counter(s)
    for i, ch in enumerate(s):            # the string's order, not the map's
        if counts[ch] == 1:
            return i, ch
    return None

print(first_unique("swiss"))      # (1, 'w')
print(first_unique("aabb"))       # None
print(first_unique("chai"))       # (0, 'c')

Without Counter

Python
def first_unique_plain(s):
    counts = {}
    for ch in s:
        counts[ch] = counts.get(ch, 0) + 1
    for i, ch in enumerate(s):
        if counts[ch] == 1:
            return i
    return -1

print(first_unique_plain("leetcode"))   # 0
print(first_unique_plain("loveleetcode"))   # 2

Why loop over the string, not the map? A Python dict keeps insertion order, so looping over it would also work in Python. A Java HashMap doesn't keep any order, so walking the string is the answer that works in every language.

Follow-ups

  • What if the characters arrive as a stream? Keep the counts plus a queue of candidates; after each new character, pop from the front of the queue while its count is above 1. The front is always the current answer.
  • Only lowercase letters? An array of 26 counts replaces the hash map.
Q13Not marked yetFresherAsked oftenConcept

What is hashing, and how does a hash table handle collisions?

Say this first~30 sec to say

Hashing turns a key into a number. A hash table uses that number, modulo the table size, to pick a bucket in an array, so insert, lookup and delete are O(1) on average. Two keys landing in one bucket is a collision: chaining keeps a small list per bucket, while open addressing probes for another free slot. When the load factor gets too high, the table doubles and rehashes.

Could you say it?
Full answer3 min read

A small hash map with chaining

Python
class ChainedHashMap:
    def __init__(self, capacity=8):
        self.buckets = [[] for _ in range(capacity)]
        self.size = 0

    def _bucket(self, key):
        return self.buckets[hash(key) % len(self.buckets)]

    def put(self, key, value):
        bucket = self._bucket(key)
        for pair in bucket:
            if pair[0] == key:
                pair[1] = value            # existing key: update it
                return
        bucket.append([key, value])        # new key: add it to this bucket's chain
        self.size += 1
        if self.size / len(self.buckets) > 0.75:   # load factor too high
            self._resize()

    def get(self, key, default=None):
        for k, v in self._bucket(key):
            if k == key:
                return v
        return default

    def _resize(self):
        pairs = [pair for bucket in self.buckets for pair in bucket]
        self.buckets = [[] for _ in range(len(self.buckets) * 2)]
        self.size = 0
        for k, v in pairs:
            self.put(k, v)                 # every key goes to a new bucket

cities = ["Pune", "Delhi", "Chennai", "Mumbai", "Kochi", "Indore", "Surat"]
table = ChainedHashMap()
for i, city in enumerate(cities):
    table.put(city, i)
print(table.get("Kochi"), table.get("Goa"), len(table.buckets))   # 4 None 16

The seventh insert pushed the load factor to 7/8, above 0.75, so the table grew to 16 buckets.

The two ways to handle collisions

ChainingOpen addressing
HowEach bucket holds a list (or tree) of entriesEvery entry sits in the array itself; on a collision, try other slots
ProbingNot neededLinear (next slot), quadratic, or double hashing
DeletingRemove from the listNeeds "tombstone" markers so searches don't stop early
Used byJava's HashMap (long chains become red-black trees since Java 8)Python's dict and set

What makes a good hash function: it's deterministic (the same key always gives the same hash), it spreads keys evenly across buckets, and it's fast. Keys whose contents can change shouldn't be used, because a changed hash puts the key in the wrong bucket.

Follow-ups

  • What's the worst case? If every key collides, every operation scans one long chain: O(n). Good hash functions and resizing make that very unlikely.
  • What is the load factor? Entries divided by buckets. Java's HashMap resizes above 0.75 by default.
  • Why is "O(1)" only average? Collisions and occasional resizes cost extra; averaged over many operations, each one is still constant time.

05Two pointers and sliding window

0/2 got it

Q14Not marked yetFresherAsked oftenCoding

In one pass, rearrange an array of 0s, 1s and 2s into sorted order

Say this first~30 sec to say

Use three pointers (the Dutch national flag algorithm): low is where the next 0 goes, high is where the next 2 goes, and mid scans. If nums[mid] is 0, swap it to low and move both forward; if it's 1, just move mid; if it's 2, swap it to high and move high back, but don't move mid, because the value swapped in hasn't been checked yet. One pass, O(n) time and O(1) space.

Could you say it?
Full answer2 min read
Python
def sort_colours(nums):
    low, mid, high = 0, 0, len(nums) - 1
    while mid <= high:
        if nums[mid] == 0:
            nums[low], nums[mid] = nums[mid], nums[low]
            low += 1
            mid += 1
        elif nums[mid] == 1:
            mid += 1
        else:
            nums[mid], nums[high] = nums[high], nums[mid]
            high -= 1                     # mid stays: the new nums[mid] is unchecked
    return nums

print(sort_colours([2, 0, 2, 1, 1, 0]))   # [0, 0, 1, 1, 2, 2]
print(sort_colours([2, 2, 0]))            # [0, 2, 2]
print(sort_colours([]))                   # []

What the pointers guarantee at every step

[ 0 0 0 | 1 1 1 | ? ? ? ? | 2 2 2 ]
          ^low    ^mid    ^high
  0 .. low-1 are 0s, low .. mid-1 are 1s, mid .. high are unchecked, high+1 .. end are 2s

The loop ends when the unchecked region is empty.

The simpler two-pass answer: count the 0s, 1s and 2s, then overwrite the array. Also O(n), but it reads the array twice, and only works because the values are known in advance. Mention it, then give the one-pass version.

The close variant: move all zeros to the end, keeping the order of the rest

Python
def move_zeroes(nums):
    write = 0                             # where the next non-zero goes
    for read in range(len(nums)):
        if nums[read] != 0:
            nums[write], nums[read] = nums[read], nums[write]
            write += 1
    return nums

print(move_zeroes([0, 1, 0, 3, 12]))   # [1, 3, 12, 0, 0]

Follow-ups

  • Why not move mid after swapping with high? The element that came from high could be a 0, a 1 or a 2, so it has to be examined.
  • Is it stable? No. Equal values can change their relative order, which doesn't matter for plain numbers.
Q15Not marked yet1–3 yrsAsked oftenCoding

Find the longest stretch of a string in which no character repeats

Say this first~25 sec to say

Use a variable-size sliding window. Move right through the string and remember the last index where each character was seen. If the character at right was last seen inside the current window, jump left to just past that position. The window then always holds unique characters, so track its largest length. Each character is visited once: O(n) time, and O(k) space for k distinct characters.

Could you say it?
Full answer2 min read
Python
def longest_unique_substring(s):
    last_seen = {}
    left = best = best_start = 0
    for right, ch in enumerate(s):
        if ch in last_seen and last_seen[ch] >= left:   # a repeat inside the window
            left = last_seen[ch] + 1
        last_seen[ch] = right
        if right - left + 1 > best:
            best, best_start = right - left + 1, left
    return best, s[best_start:best_start + best]

print(longest_unique_substring("abcabcbb"))   # (3, 'abc')
print(longest_unique_substring("pwwkew"))     # (3, 'wke')
print(longest_unique_substring("bbbbb"))      # (1, 'b')
print(longest_unique_substring("abba"))       # (2, 'ab')
print(longest_unique_substring(""))           # (0, '')

Why the >= left check matters: in "abba", when the second a arrives, its last position (0) is already outside the window, which starts at 2 after the second b. Without the check, left would jump backwards to 1 and the window "bba" would contain a repeat.

Tracing "pwwkew"

rightchleftwindowbest
0p0p1
1w0pw2
2w2w2
3k2wk2
4e2wke3
5w3kew3

The sliding-window template

  1. Grow the window by moving right.
  2. While the window breaks the rule, shrink it from left.
  3. Record the answer for the valid window.

The same shape solves "smallest subarray with sum at least k", "longest substring with at most k distinct characters" and many more.

Follow-ups

  • What's the brute force? Check every substring for repeats: O(n³), or O(n²) with a set per starting point.
  • Can you do it with a set instead of a map? Yes: when a repeat arrives, remove characters from the left one at a time until it's gone. Still O(n), because each character enters and leaves the window once.

06Sorting and searching

0/6 got it

Q17Not marked yetFresherAsked very oftenCoding

Sort an array using bubble sort

Say this first~25 sec to say

Walk through the array swapping neighbours that are out of order. After each pass, the largest remaining element has bubbled to the end, so the next pass can stop one place earlier. If a pass makes no swaps, the array is already sorted and you can stop, which makes the best case O(n). Otherwise it's O(n²) time, O(1) space, and stable: equal elements keep their order.

Could you say it?
Full answer3 min read
Python
def bubble_sort(nums):
    n = len(nums)
    for end in range(n - 1, 0, -1):       # the last unsorted position
        swapped = False
        for i in range(end):
            if nums[i] > nums[i + 1]:
                nums[i], nums[i + 1] = nums[i + 1], nums[i]
                swapped = True
        if not swapped:                   # nothing moved: already sorted
            break
    return nums

print(bubble_sort([5, 1, 4, 2, 8]))   # [1, 2, 4, 5, 8]
print(bubble_sort([1, 2, 3]))         # [1, 2, 3]: one pass, no swaps, stop

Pass by pass for [5, 1, 4, 2, 8]

PassArray after the passSwaps
1[1, 4, 2, 5, 8]3
2[1, 2, 4, 5, 8]1
3[1, 2, 4, 5, 8]0: stop early

If they ask for selection or insertion sort instead

Python
def selection_sort(nums):
    n = len(nums)
    for i in range(n - 1):
        smallest = i
        for j in range(i + 1, n):         # find the smallest in the unsorted part
            if nums[j] < nums[smallest]:
                smallest = j
        nums[i], nums[smallest] = nums[smallest], nums[i]
    return nums

def insertion_sort(nums):
    for i in range(1, len(nums)):
        current, j = nums[i], i - 1
        while j >= 0 and nums[j] > current:
            nums[j + 1] = nums[j]         # shift bigger items one place right
            j -= 1
        nums[j + 1] = current             # drop current into the gap
    return nums

print(selection_sort([64, 25, 12, 22, 11]))   # [11, 12, 22, 25, 64]
print(insertion_sort([12, 11, 13, 5, 6]))     # [5, 6, 11, 12, 13]

Selection sort makes at most n − 1 swaps, useful when writes are expensive, but it isn't stable. Insertion sort is stable and very fast on small or nearly sorted arrays, which is why real-world sorts such as Timsort use it for short runs.

Follow-ups

  • Why is bubble sort O(n²)? Up to n − 1 passes, each comparing up to n − 1 pairs.
  • Would you use it in production? No. Its value is in teaching and in interviews; use the language's built-in sort.
Q18Not marked yetFresherAsked oftenConcept

Linear search vs binary search: how do they differ, and when can each be used?

Say this first~30 sec to say

Linear search checks items one by one until it finds the target: O(n), and it works on any list, sorted or not, including linked lists. Binary search repeatedly halves a sorted range: O(log n), but it needs the data sorted and indexable by position. For a million items, that's up to a million checks against about 20. Sorting just to search once costs O(n log n), more than one linear scan; sorting pays off when you'll search many times.

Could you say it?
Full answer2 min read
Linear searchBinary search
Needs sorted dataNoYes
Needs access by indexNo: works on linked lists and streamsYes
Time (worst case)O(n)O(log n)
Extra spaceO(1)O(1) iterative, O(log n) recursive
Best forSmall or unsorted data, one-off searchesLarge sorted data, repeated searches
Python
import math

def linear_search(nums, target):
    for i, x in enumerate(nums):
        if x == target:
            return i
    return -1

print(linear_search([7, 3, 9, 1], 9))   # 2

for n in (10, 1_000, 1_000_000, 1_000_000_000):
    print(f"{n:>13,} items: linear up to {n:,} checks, binary up to {math.floor(math.log2(n)) + 1}")
           10 items: linear up to 10 checks, binary up to 4
        1,000 items: linear up to 1,000 checks, binary up to 10
    1,000,000 items: linear up to 1,000,000 checks, binary up to 20
1,000,000,000 items: linear up to 1,000,000,000 checks, binary up to 30

When does sorting first pay off? Searching k times costs about k × n with linear search, and about n log n + k log n with sort-then-binary-search. Once k is bigger than roughly log n, sorting wins.

Follow-ups

  • Is x in my_list linear or binary in Python? Linear: Python doesn't know the list is sorted. Use bisect for sorted lists, or a set for O(1) membership checks.
  • What other searches exist? Hash lookups (O(1) on average, no order needed), and specialised ones such as jump search and interpolation search, which you rarely need to code.
Q19Not marked yetFresherAsked oftenConcept

How do bubble, insertion, merge and quick sort compare on time, extra space and stability?

Say this first~30 sec to say

Bubble and insertion sort are O(n²) in place, with O(1) extra space, and both are stable; insertion sort is fast on small or nearly sorted data. Merge sort is O(n log n) in every case and stable, but needs O(n) extra space. Quick sort is O(n log n) on average and sorts in place, but it's O(n²) in the worst case and not stable. Stable means equal elements keep their original order.

Could you say it?
Full answer4 min read
AlgorithmBestAverageWorstExtra spaceStable
Bubble sortO(n), with the early exitO(n²)O(n²)O(1)Yes
Selection sortO(n²)O(n²)O(n²)O(1)No
Insertion sortO(n)O(n²)O(n²)O(1)Yes
Merge sortO(n log n)O(n log n)O(n log n)O(n)Yes
Quick sortO(n log n)O(n log n)O(n²)O(log n) for recursionNo
Heap sortO(n log n)O(n log n)O(n log n)O(1)No

Merge sort: divide, sort each half, merge

Python
def merge_sort(nums):
    if len(nums) <= 1:
        return nums
    mid = len(nums) // 2
    left, right = merge_sort(nums[:mid]), merge_sort(nums[mid:])
    merged, i, j = [], 0, 0
    while i < len(left) and j < len(right):
        if left[i] <= right[j]:           # <= keeps equal items in order: stable
            merged.append(left[i])
            i += 1
        else:
            merged.append(right[j])
            j += 1
    return merged + left[i:] + right[j:]

print(merge_sort([38, 27, 43, 3, 9, 82, 10]))   # [3, 9, 10, 27, 38, 43, 82]

Quick sort: partition around a pivot, then sort each side

Python
def partition(nums, low, high):
    pivot = nums[high]                    # Lomuto scheme: last element as pivot
    i = low
    for j in range(low, high):
        if nums[j] < pivot:
            nums[i], nums[j] = nums[j], nums[i]
            i += 1
    nums[i], nums[high] = nums[high], nums[i]
    return i                              # the pivot's final position

def quick_sort(nums, low=0, high=None):
    if high is None:
        high = len(nums) - 1
    if low < high:
        p = partition(nums, low, high)
        quick_sort(nums, low, p - 1)
        quick_sort(nums, p + 1, high)
    return nums

print(quick_sort([10, 7, 8, 9, 1, 5]))   # [1, 5, 7, 8, 9, 10]

Quick sort's worst case comes from bad pivots: with the last element as pivot, an already sorted array splits into sizes n − 1 and 0 every time. Choosing a random pivot, or the median of three, makes that very unlikely.

Why stability matters

Python
records = [("Asha", 85), ("Ravi", 72), ("Priya", 85), ("Arjun", 72)]
print(sorted(records, key=lambda r: r[1]))
# [('Ravi', 72), ('Arjun', 72), ('Asha', 85), ('Priya', 85)]

Equal scores kept their original order, so sorting by name first and then by score gives a list ordered by score, then by name.

What languages use: Python's sorted() is Timsort, a stable merge-and-insertion hybrid. Java's Arrays.sort uses a dual-pivot quick sort for primitive arrays and Timsort for objects, where stability matters.

Follow-ups

  • Which sort for a linked list? Merge sort: it needs no random access, and merging linked nodes needs no extra array.
  • Which sort for nearly sorted data? Insertion sort, or Timsort, which detects the sorted runs.
  • Can a comparison sort beat O(n log n)? Not in the worst case. Counting sort and radix sort can run in linear time because they don't compare elements, but only for suitable data such as small integer ranges.
Q20Not marked yet1–3 yrsAsked oftenCoding

Find a value in a sorted array that has been rotated at an unknown point

Say this first~25 sec to say

Use a modified binary search. At every step, at least one half around mid is in sorted order. If nums[low] <= nums[mid], the left half is sorted: search there if the target lies between nums[low] and nums[mid], otherwise search the right half. If instead the right half is sorted, do the mirror-image check. Still O(log n) time and O(1) space, assuming the values are distinct.

Could you say it?
Full answer3 min read
Python
def search_rotated(nums, target):
    low, high = 0, len(nums) - 1
    while low <= high:
        mid = (low + high) // 2
        if nums[mid] == target:
            return mid
        if nums[low] <= nums[mid]:                    # left half is sorted
            if nums[low] <= target < nums[mid]:
                high = mid - 1
            else:
                low = mid + 1
        else:                                         # right half is sorted
            if nums[mid] < target <= nums[high]:
                low = mid + 1
            else:
                high = mid - 1
    return -1

nums = [15, 18, 22, 3, 6, 9, 12]
print(search_rotated(nums, 6))    # 4
print(search_rotated(nums, 18))   # 1
print(search_rotated(nums, 7))    # -1
print(search_rotated([1], 1))     # 0

Tracing a search for 6 in [15, 18, 22, 3, 6, 9, 12]

lowhighmidnums[mid]Sorted halfDecision
0633Right: [3, 6, 9, 12]6 is in 3..12: go right
4659Left: [6, 9]6 is in 6..9: go left
4446Found

A related question: find the rotation point (the minimum)

Python
def find_min(nums):
    low, high = 0, len(nums) - 1
    while low < high:
        mid = (low + high) // 2
        if nums[mid] > nums[high]:
            low = mid + 1                 # the drop is to the right of mid
        else:
            high = mid                    # mid could be the minimum
    return nums[low]

print(find_min([15, 18, 22, 3, 6, 9, 12]))   # 3
print(find_min([1, 2, 3]))                    # 1: not rotated at all

Follow-ups

  • What if there are duplicates? When nums[low] == nums[mid] == nums[high], you can't tell which half is sorted, so shrink both ends by one. The worst case becomes O(n), for example [1, 1, 1, 1, 0, 1, 1].
  • Why <= in nums[low] <= nums[mid]? When low == mid, that one-element left half is trivially sorted, and the check must treat it so.
Q21Not marked yet1–3 yrsAsked oftenCoding

Combine overlapping intervals into a list of non-overlapping ones

Say this first~30 sec to say

Sort the intervals by start. Then walk through them with a result list: if the current interval starts after the last merged interval ends, it's separate, so append it; otherwise they overlap, so extend the last merged interval's end to the larger of the two ends. Sorting puts overlapping intervals next to each other, so one pass is enough. O(n log n) time for the sort, plus O(n) for the pass.

Could you say it?
Full answer2 min read
Python
def merge_intervals(intervals):
    merged = []
    for start, end in sorted(intervals):            # by start, then by end
        if merged and start <= merged[-1][1]:       # overlaps the last one
            merged[-1][1] = max(merged[-1][1], end)
        else:
            merged.append([start, end])
    return merged

print(merge_intervals([[1, 3], [8, 10], [2, 6], [15, 18]]))   # [[1, 6], [8, 10], [15, 18]]
print(merge_intervals([[1, 4], [4, 5]]))                      # [[1, 5]]: touching counts here
print(merge_intervals([[1, 10], [2, 3]]))                     # [[1, 10]]: max() keeps the longer end
print(merge_intervals([]))                                     # []

Questions to ask: Do [1, 4] and [4, 5] overlap? For meeting times that end at 4:00 and start at 4:00, usually not, so the check becomes start < merged[-1][1]. Is the input already sorted? Can it be changed in place?

Follow-up: the minimum number of meeting rooms

Sort by start time and keep a min-heap of the end times of meetings in progress. If the earliest-ending meeting has finished, reuse its room:

Python
import heapq

def min_meeting_rooms(meetings):
    ends = []                                       # end times of rooms in use
    for start, end in sorted(meetings):
        if ends and ends[0] <= start:
            heapq.heapreplace(ends, end)            # reuse the room that freed up first
        else:
            heapq.heappush(ends, end)               # open a new room
    return len(ends)

print(min_meeting_rooms([[0, 30], [5, 10], [15, 20]]))   # 2
print(min_meeting_rooms([[7, 10], [2, 4]]))              # 1

Follow-ups

  • Insert one new interval into a sorted, non-overlapping list? Add everything that ends before it, merge everything that overlaps it, then add the rest: O(n), no sort needed.
  • Can one person attend every meeting? Sort by start and check that no meeting starts before the previous one ends.

07Recursion and backtracking

0/3 got it

Q22Not marked yetFresherAsked oftenConcept

What is recursion, and what happens if there is no base case?

Say this first~30 sec to say

Recursion is a function solving a problem by calling itself on a smaller version of it. It needs a base case, answered directly, and a recursive case that moves towards it. Each call gets its own frame on the call stack until it returns. With no base case, or one that's never reached, calls pile up until the stack runs out: a stack overflow, which Python reports as RecursionError after about 1,000 calls.

Could you say it?
Full answer2 min read
Python
def factorial(n):
    if n <= 1:                         # base case: answered directly
        return 1
    return n * factorial(n - 1)        # recursive case: a smaller problem

print(factorial(5))   # 120

def forever(n):
    return forever(n + 1)              # no base case

try:
    forever(0)
except RecursionError as error:
    print(error)                       # maximum recursion depth exceeded

What the call stack looks like for factorial(4)

factorial(4) waits for factorial(3)
  factorial(3) waits for factorial(2)
    factorial(2) waits for factorial(1)
      factorial(1) returns 1            ← base case
    factorial(2) returns 2 × 1 = 2
  factorial(3) returns 3 × 2 = 6
factorial(4) returns 4 × 6 = 24

Four frames are open at the deepest point, so the space used is O(n), on top of the O(n) time.

Recursion or a loop?

RecursionIteration
Reads naturally forTrees, graphs, divide and conquer, backtrackingSimple repetition
Extra memoryOne stack frame per pending callUsually O(1)
RiskStack overflow on deep inputsNone of that kind

Anything recursive can be rewritten with a loop and an explicit stack, which is how you avoid the limit on very deep inputs.

Follow-ups

  • What is the recursion limit in Python? 1,000 by default (sys.getrecursionlimit()). You can raise it with sys.setrecursionlimit, but the real fix for deep inputs is an iterative version.
  • What is tail recursion? A recursive call that's the very last thing a function does, which some languages turn into a loop. Python and Java don't, so it doesn't save stack space there.
  • When does recursion repeat work? When subproblems overlap, as in plain recursive Fibonacci. Memoisation fixes that (question 38).
Q24Not marked yet1–3 yrsAsked sometimesCoding

List every subset of a set of distinct numbers

Say this first~30 sec to say

Each element is either in or out of a subset, so n elements give 2ⁿ subsets. Recursively, at each element, branch twice, once including it and once excluding it, and record the subset when every element has been decided. That's O(n · 2ⁿ) time, since each of the 2ⁿ subsets takes up to n steps to copy. An iterative version starts from [[]] and, for each number, adds a copy of every existing subset with that number appended.

Could you say it?
Full answer2 min read
Python
def subsets(nums):
    result = []

    def backtrack(i, current):
        if i == len(nums):
            result.append(current[:])     # copy: current keeps changing
            return
        current.append(nums[i])           # include nums[i]
        backtrack(i + 1, current)
        current.pop()                     # exclude nums[i]
        backtrack(i + 1, current)

    backtrack(0, [])
    return result

print(subsets([1, 2, 3]))
# [[1, 2, 3], [1, 2], [1, 3], [1], [2, 3], [2], [3], []]

Iteratively: double the list for each number

Python
def subsets_iterative(nums):
    result = [[]]
    for x in nums:
        result += [subset + [x] for subset in result]
    return result

print(subsets_iterative([1, 2, 3]))
# [[], [1], [2], [1, 2], [3], [1, 3], [2, 3], [1, 2, 3]]

With bitmasks: each number from 0 to 2ⁿ − 1 is one subset

Python
def subsets_bitmask(nums):
    n = len(nums)
    return [[nums[j] for j in range(n) if mask >> j & 1] for mask in range(1 << n)]

print(subsets_bitmask([1, 2, 3]))
# [[], [1], [2], [1, 2], [3], [1, 3], [2, 3], [1, 2, 3]]

Mask 5 is 101 in binary, so it picks the first and third numbers: [1, 3].

Why current[:]? current is one list that keeps changing as the recursion goes on. Appending it directly would store the same list eight times, and it ends up empty.

Follow-ups

  • What if the input has duplicates? Sort it, and when you skip a number, also skip every copy of it that follows.
  • Only subsets of size k? Stop recursing once current has k items: that's the combinations problem.
  • Subsets that add up to a target? The same include-or-exclude tree, pruned as soon as the sum goes past the target (for non-negative numbers).

08Stacks and queues

0/3 got it

Q25Not marked yetFresherAsked very oftenConcept

Stack vs queue: how do they differ, and where is each used?

Say this first~30 sec to say

A stack is LIFO, last in, first out: you push and pop at the same end, like a pile of plates. A queue is FIFO, first in, first out: you add at the back and remove from the front, like a ticket counter line. Both do their main operations in O(1). Stacks power function calls, undo, the browser's back button, bracket matching and DFS; queues power BFS, task scheduling, print jobs and message queues.

Could you say it?
Full answer3 min read
Python
from collections import deque

stack = []
stack.append("a")
stack.append("b")
stack.append("c")
print(stack.pop())       # c: last in, first out
print(stack[-1])         # b: peek without removing

queue = deque()
queue.append("a")
queue.append("b")
queue.append("c")
print(queue.popleft())   # a: first in, first out

A Python list works as a stack, but not as a queue: list.pop(0) shifts every remaining element, O(n). collections.deque adds and removes at both ends in O(1).

StackQueue
OrderLast in, first outFirst in, first out
Addpush (Python: append)enqueue (Python: append)
Removepop, from the same enddequeue (Python: popleft), from the other end
Used forFunction calls, undo, back button, DFS, expression parsingBFS, scheduling, buffers, rate-limited jobs

The common follow-up: build a queue from two stacks

Python
class QueueWithStacks:
    def __init__(self):
        self.inbox, self.outbox = [], []

    def enqueue(self, x):
        self.inbox.append(x)

    def dequeue(self):
        if not self.outbox:                  # refill only when empty
            while self.inbox:
                self.outbox.append(self.inbox.pop())   # reverses the order
        return self.outbox.pop()

q = QueueWithStacks()
for x in (1, 2, 3):
    q.enqueue(x)
print(q.dequeue(), q.dequeue())   # 1 2
q.enqueue(4)
print(q.dequeue(), q.dequeue())   # 3 4

Each element moves from inbox to outbox at most once, so dequeue is amortised O(1) even though one call can move many elements.

Follow-ups

  • What's a deque? A double-ended queue: add and remove at both ends in O(1). It can act as a stack or a queue.
  • What's a priority queue? Items come out in order of priority, not arrival. It's usually a heap: O(log n) to add or remove, O(1) to peek (heapq in Python, PriorityQueue in Java).
  • How would you build a stack from queues? Push to one queue, then rotate the older items behind the new one, so the newest is always at the front: O(n) push, O(1) pop.
Q26Not marked yetFresherAsked very oftenCoding

Check whether the brackets in an expression are balanced

Say this first~25 sec to say

Walk through the expression with a stack. Push every opening bracket. For each closing bracket, the stack must be non-empty and its top must be the matching opener: pop it, or return false. At the end, the expression is balanced only if the stack is empty, because anything left over was never closed. O(n) time, and O(n) space in the worst case.

Could you say it?
Full answer2 min read
Python
def is_balanced(expr):
    pairs = {")": "(", "]": "[", "}": "{"}
    stack = []
    for ch in expr:
        if ch in "([{":
            stack.append(ch)
        elif ch in pairs:
            if not stack or stack.pop() != pairs[ch]:
                return False              # nothing to close, or the wrong kind
    return not stack                      # leftovers were never closed

print(is_balanced("{[()()]}"))       # True
print(is_balanced("([)]"))           # False: closed in the wrong order
print(is_balanced("(("))             # False: never closed
print(is_balanced("a * (b + c)]"))   # False: nothing to close
print(is_balanced(""))               # True

Tracing "([)]"

CharacterActionStack after
(push(
[push( [
)top is [, but ) needs (return False

Why a counter isn't enough: with only one kind of bracket, a counter works: add 1 for (, subtract 1 for ), and fail if it ever goes negative or doesn't end at 0. With several kinds, "([)]" has balanced counts but is still wrong. Only a stack remembers the order.

Follow-ups

  • The fewest brackets to add to make it valid? Count unmatched closers as you go, and add whatever is left on the stack at the end.
  • Generate every valid string of n pairs? Backtracking: add ( while you have some left, and ) only while it wouldn't close more than you've opened.
  • Where is this used for real? Compilers and code editors matching brackets, and validating HTML or XML tags.
Q27Not marked yet1–3 yrsAsked oftenCoding

For each element in an array, find the first larger element to its right

Say this first~30 sec to say

Use a monotonic stack of indices still waiting for an answer; their values decrease from bottom to top. For each new element, pop every waiting index with a smaller value: the new element is their answer. Then push the new index. Anything left at the end has no larger element, so it gets −1. Each index is pushed and popped once: O(n) time and O(n) space, versus O(n²) for scanning right from every element.

Could you say it?
Full answer2 min read
Python
def next_greater(nums):
    result = [-1] * len(nums)
    stack = []                                # indices waiting for an answer
    for i, x in enumerate(nums):
        while stack and nums[stack[-1]] < x:
            result[stack.pop()] = x           # x is the first bigger value to their right
        stack.append(i)
    return result

print(next_greater([4, 5, 2, 25]))     # [5, 25, 25, -1]
print(next_greater([13, 7, 6, 12]))    # [-1, 12, 12, -1]
print(next_greater([5, 4, 3]))         # [-1, -1, -1]

Tracing [4, 5, 2, 25]

ixPopped (and answered)Stack after (values)
044
154 → 55
225, 2
3252 → 25, 5 → 2525

The same pattern in a common variant: daily temperatures

For each day, how many days until a warmer one? Store the distance instead of the value:

Python
def daily_temperatures(temps):
    wait = [0] * len(temps)
    stack = []
    for i, t in enumerate(temps):
        while stack and temps[stack[-1]] < t:
            j = stack.pop()
            wait[j] = i - j
        stack.append(i)
    return wait

print(daily_temperatures([73, 74, 75, 71, 69, 72, 76, 73]))   # [1, 1, 4, 2, 1, 1, 0, 0]

Follow-ups

  • What if the array is circular? Loop over the indices twice (i % n), only pushing during the first pass.
  • The next smaller element? Flip the comparison, so the stack's values increase from bottom to top.
  • Where else does this pattern appear? Stock span, the largest rectangle in a histogram, and trapping rainwater.

09Linked lists

0/3 got it

Q28Not marked yetFresherAsked very oftenConcept

Array vs linked list: what are the trade-offs, and when would you use each?

Say this first~30 sec to say

An array stores elements in one contiguous block, so jumping to any index is O(1) and cache-friendly, but inserting or deleting in the middle shifts everything after it: O(n). A linked list scatters nodes in memory, each pointing to the next, so inserting or deleting at a node you hold is O(1), but reaching the i-th element means walking from the head: O(n). Use arrays by default, and linked lists when inserts and deletes at known spots dominate.

Could you say it?
Full answer3 min read
OperationArray (dynamic)Singly linked list
Read the i-th elementO(1)O(n)
Search for a valueO(n)O(n)
Add at the endO(1) amortisedO(1) with a tail pointer
Add at the frontO(n)O(1)
Insert or delete after a node you holdO(n)O(1)
Memory per elementJust the valueThe value plus a pointer
Cache friendlinessGood: neighbours sit togetherPoor: nodes are scattered
Python
class Node:
    def __init__(self, value, next=None):
        self.value = value
        self.next = next

def to_list(head):
    values = []
    while head:
        values.append(head.value)
        head = head.next
    return values

head = Node(1, Node(2, Node(3)))          # 1 -> 2 -> 3

def insert_after(node, value):            # O(1): only two pointers change
    node.next = Node(value, node.next)

insert_after(head, 99)
print(to_list(head))                      # [1, 99, 2, 3]

nums = [1, 2, 3]
nums.insert(1, 99)                        # O(n): 2 and 3 shift right
print(nums)                               # [1, 99, 2, 3]

Kinds of linked list: singly linked (next only), doubly linked (next and prev, so you can delete a node in O(1) without knowing its predecessor), and circular (the tail points back to the head).

Where linked lists are used: an LRU cache (a doubly linked list plus a hash map gives O(1) "move to front" and "evict oldest"), adjacency lists for graphs, and undo histories.

Follow-ups

  • Is Python's list an array or a linked list? A dynamic array of references. Java's ArrayList is the same idea; Java's LinkedList is a doubly linked list (Java kit, question 26).
  • Why is a dynamic array's append O(1) amortised? When it runs out of room it allocates a bigger block, often 1.5 to 2 times larger, and copies everything. Those copies are rare enough that the average cost per append stays constant.
Q29Not marked yetFresherAsked very oftenCoding

Reverse a linked list

Say this first~25 sec to say

Walk the list once with three pointers: prev (starting at None), current (starting at the head) and next. At each node, save current.next, point current.next back at prev, then move prev and current one step forward. When current reaches None, prev is the new head. O(n) time and O(1) extra space. The recursive version is shorter but uses O(n) stack space.

Could you say it?
Full answer3 min read
Python
class Node:
    def __init__(self, value, next=None):
        self.value = value
        self.next = next

def build(values):
    head = None
    for v in reversed(values):
        head = Node(v, head)
    return head

def to_list(head):
    out = []
    while head:
        out.append(head.value)
        head = head.next
    return out

def reverse_list(head):
    prev, current = None, head
    while current:
        nxt = current.next          # 1. save the rest of the list
        current.next = prev         # 2. turn this node's pointer around
        prev, current = current, nxt   # 3. step forward
    return prev                     # the old tail is the new head

print(to_list(reverse_list(build([1, 2, 3, 4]))))   # [4, 3, 2, 1]
print(to_list(reverse_list(build([7]))))            # [7]
print(to_list(reverse_list(None)))                  # []

What changes at each step for 1 → 2 → 3

start:     None    1 -> 2 -> 3
           prev    cur

step 1:    None <- 1    2 -> 3
                   prev cur

step 2:    None <- 1 <- 2    3
                        prev cur

step 3:    None <- 1 <- 2 <- 3    None
                             prev cur   → return prev (3)

Recursively

Python
def reverse_recursive(head):
    if head is None or head.next is None:
        return head                       # an empty or one-node list is its own reverse
    new_head = reverse_recursive(head.next)
    head.next.next = head                 # the next node now points back here
    head.next = None
    return new_head

print(to_list(reverse_recursive(build([1, 2, 3]))))   # [3, 2, 1]

Follow-ups

  • Reverse only the part between positions m and n? Walk to the node before m, reverse n − m + 1 nodes with the same loop, then reconnect both ends.
  • Reverse in groups of k? Reverse k nodes at a time, linking each reversed group's tail to the next group's new head.
  • Is a linked list a palindrome? Find the middle with slow and fast pointers (question 30), reverse the second half, then compare the two halves: O(n) time, O(1) space.
Q30Not marked yetFresherAsked very oftenCoding

Check whether a linked list contains a loop

Say this first~30 sec to say

Floyd's cycle detection. Move a slow pointer one node at a time and a fast pointer two nodes at a time. If there's no loop, the fast pointer reaches the end. If there is a loop, the fast pointer enters it and closes the gap by one node per step, so the two must meet. O(n) time and O(1) space, versus O(n) space for remembering visited nodes in a set.

Could you say it?
Full answer4 min read
Python
class Node:
    def __init__(self, value):
        self.value = value
        self.next = None

def has_cycle(head):
    slow = fast = head
    while fast and fast.next:
        slow = slow.next
        fast = fast.next.next
        if slow is fast:              # the same node object, not just equal values
            return True
    return False

nodes = [Node(i) for i in range(5)]
for a, b in zip(nodes, nodes[1:]):
    a.next = b                        # 0 -> 1 -> 2 -> 3 -> 4
print(has_cycle(nodes[0]))            # False
nodes[4].next = nodes[2]              # 4 points back to 2: a loop
print(has_cycle(nodes[0]))            # True

Follow-up 1: where does the loop start?

After the pointers meet, move one of them back to the head and advance both one step at a time. They meet again exactly at the start of the loop:

Python
def loop_start(head):
    slow = fast = head
    while fast and fast.next:
        slow, fast = slow.next, fast.next.next
        if slow is fast:
            slow = head
            while slow is not fast:
                slow, fast = slow.next, fast.next
            return slow
    return None

print(loop_start(nodes[0]).value)   # 2

Why: say the head is a steps from the loop's start, they meet b steps into the loop, and the loop has length L. Fast travelled twice as far as slow, and the extra distance is whole laps: 2(a + b) = a + b + kL, so a = kL − b. Walking a steps from the meeting point therefore lands on the loop's start, exactly when the pointer from the head arrives there.

Follow-up 2: the middle node uses the same two pointers

Python
def middle(head):
    slow = fast = head
    while fast and fast.next:
        slow, fast = slow.next, fast.next.next
    return slow                       # fast reached the end; slow is halfway

letters = [Node(ch) for ch in "abcde"]
for a, b in zip(letters, letters[1:]):
    a.next = b
print(middle(letters[0]).value)       # c

For an even number of nodes this returns the second of the two middle nodes.

The simpler, O(n)-space answer: walk the list and store each node in a set; if you meet a node that's already in the set, there's a loop.

Follow-ups

  • How long is the loop? Once the pointers meet, keep one still and count the steps until the other comes back to it.
  • Why compare with is? Two different nodes can hold equal values; a loop means the same node object comes round again.

10Trees and BST

0/5 got it

Q31Not marked yetFresherAsked very oftenCoding

Traverse a binary tree in inorder, preorder and postorder

Say this first~30 sec to say

All three are depth-first and differ only in when the root is visited. Preorder: root, left, right; useful for copying a tree. Inorder: left, root, right; on a BST it gives the values in sorted order. Postorder: left, right, root; useful for deleting a tree or evaluating an expression, since children come first. Each is a few lines of recursion: O(n) time, and O(h) stack space for a tree of height h.

Could you say it?
Full answer4 min read
Python
class TreeNode:
    def __init__(self, value, left=None, right=None):
        self.value = value
        self.left = left
        self.right = right

#        1
#       / \
#      2   3
#     / \
#    4   5
root = TreeNode(1, TreeNode(2, TreeNode(4), TreeNode(5)), TreeNode(3))

def preorder(node, out):
    if node:
        out.append(node.value)            # root first
        preorder(node.left, out)
        preorder(node.right, out)
    return out

def inorder(node, out):
    if node:
        inorder(node.left, out)
        out.append(node.value)            # root in the middle
        inorder(node.right, out)
    return out

def postorder(node, out):
    if node:
        postorder(node.left, out)
        postorder(node.right, out)
        out.append(node.value)            # root last
    return out

print(preorder(root, []))    # [1, 2, 4, 5, 3]
print(inorder(root, []))     # [4, 2, 5, 1, 3]
print(postorder(root, []))   # [4, 5, 2, 3, 1]

The usual follow-up: level-order (breadth-first) traversal

Use a queue, and process one level at a time by noting how many nodes it holds:

Python
from collections import deque

def level_order(root):
    if not root:
        return []
    levels, queue = [], deque([root])
    while queue:
        level = []
        for _ in range(len(queue)):       # everything queued now is one level
            node = queue.popleft()
            level.append(node.value)
            if node.left:
                queue.append(node.left)
            if node.right:
                queue.append(node.right)
        levels.append(level)
    return levels

print(level_order(root))   # [[1], [2, 3], [4, 5]]

Inorder without recursion

Python
def inorder_iterative(root):
    out, stack, node = [], [], root
    while stack or node:
        while node:                       # go as far left as possible
            stack.append(node)
            node = node.left
        node = stack.pop()
        out.append(node.value)
        node = node.right                 # then handle the right subtree
    return out

print(inorder_iterative(root))   # [4, 2, 5, 1, 3]

Remembering which is which: the name says where the root goes: pre = before its children, in = between them, post = after them.

Follow-ups

  • Can you rebuild a tree from its traversals? Yes, from preorder plus inorder (or postorder plus inorder) when the values are unique. Preorder alone isn't enough.
  • Why prefer the iterative versions? A very deep tree can exceed the recursion limit; an explicit stack has no such limit.
Q32Not marked yetFresherAsked oftenConcept

Binary tree vs binary search tree: what is the difference?

Say this first~30 sec to say

A binary tree is any tree where each node has at most two children, with no rule about where values go. A binary search tree (BST) adds one: for every node, everything in its left subtree is smaller and everything in its right subtree is larger. So search, insert and delete follow one path down: O(h), which is O(log n) if the tree is balanced but O(n) if it degenerates into a chain, say after inserting sorted data.

Could you say it?
Full answer3 min read
Python
class TreeNode:
    def __init__(self, value):
        self.value = value
        self.left = self.right = None

def insert(root, value):
    if root is None:
        return TreeNode(value)
    if value < root.value:
        root.left = insert(root.left, value)
    else:
        root.right = insert(root.right, value)   # this version sends duplicates right
    return root

def contains(root, value):
    while root and root.value != value:
        root = root.left if value < root.value else root.right   # one path down
    return root is not None

def inorder(node):
    return inorder(node.left) + [node.value] + inorder(node.right) if node else []

def height(node):
    return 0 if node is None else 1 + max(height(node.left), height(node.right))

root = None
for v in [50, 30, 70, 20, 40, 60, 80]:
    root = insert(root, v)
print(inorder(root))                       # [20, 30, 40, 50, 60, 70, 80]: sorted
print(contains(root, 60), contains(root, 65))   # True False
print(height(root))                        # 3: balanced, about log₂ 7

chain = None
for v in [10, 20, 30, 40, 50, 60, 70]:     # sorted input
    chain = insert(chain, v)
print(height(chain))                       # 7: every node only has a right child
Balanced (height 3)            Degenerate (height 7)
        50                     10
      /    \                     \
    30      70                    20
   /  \    /  \                     \
  20  40  60  80                     30 ... 70
Binary treeBinary search treeBalanced BST (AVL, red-black)
Ordering ruleNoneLeft < node < rightSame, plus a height limit
SearchO(n): check everythingO(h): O(log n) to O(n)O(log n) guaranteed
Inorder traversalAny orderSortedSorted
ExamplesExpression trees, heaps (a complete binary tree)Simple ordered setsJava's TreeMap, C++'s std::map

Follow-ups

  • How are duplicates handled? Pick a rule and say it: send them to one side (as above), or store a count in each node.
  • How do balanced trees stay balanced? They rotate nodes after inserts and deletes to keep the height O(log n).
  • How do you delete a node with two children? Replace its value with its inorder successor (the smallest value in its right subtree), then delete that successor.
Q33Not marked yetFresherAsked oftenCoding

Find the height of a binary tree

Say this first~25 sec to say

Recursively: an empty tree has height 0, and any other node's height is 1 plus the larger of its two subtrees' heights. Every node is visited once, so it's O(n) time, with O(h) recursion stack. Say which definition you're using: counting nodes on the longest root-to-leaf path (a single node has height 1) or edges (a single node has height 0). They differ by exactly one.

Could you say it?
Full answer3 min read
Python
class TreeNode:
    def __init__(self, value, left=None, right=None):
        self.value, self.left, self.right = value, left, right

def height(node):
    if node is None:
        return 0                          # empty tree
    return 1 + max(height(node.left), height(node.right))

root = TreeNode(1, TreeNode(2, TreeNode(4, TreeNode(8))), TreeNode(3))
print(height(root))          # 4: nodes on the longest path, 1 -> 2 -> 4 -> 8
print(height(root) - 1)      # 3: edges on that path
print(height(None))          # 0

Without recursion: count the levels with BFS

A very deep, chain-like tree can exceed Python's recursion limit. Counting levels with a queue avoids that:

Python
from collections import deque

def height_iterative(root):
    if not root:
        return 0
    depth, queue = 0, deque([root])
    while queue:
        depth += 1
        for _ in range(len(queue)):       # one full level per pass
            node = queue.popleft()
            queue.extend(child for child in (node.left, node.right) if child)
    return depth

print(height_iterative(root))   # 4

A common follow-up: is the tree height-balanced?

Calling height at every node costs O(n²). Instead, return the height and the "balanced" answer together, using −1 to mean "unbalanced":

Python
def is_balanced(root):
    def check(node):                      # the height, or -1 if unbalanced
        if node is None:
            return 0
        left, right = check(node.left), check(node.right)
        if left == -1 or right == -1 or abs(left - right) > 1:
            return -1
        return 1 + max(left, right)
    return check(root) != -1

print(is_balanced(root))                                    # False: node 2's sides differ by 2
print(is_balanced(TreeNode(1, TreeNode(2), TreeNode(3))))   # True

Follow-ups

  • What's the diameter of a tree? The longest path between any two nodes. At each node it's the left height plus the right height; track the maximum while computing heights, in O(n).
  • Minimum depth? BFS, stopping at the first leaf you reach.
Q34Not marked yet1–3 yrsAsked oftenCoding

Check whether a binary tree follows the BST ordering rule

Say this first~30 sec to say

Comparing each node with only its own children isn't enough: a value deep in the left subtree can still be bigger than an ancestor higher up. Instead, pass down the allowed range. The root may hold anything; its left subtree must stay below the root's value and its right subtree above it, and each step narrows the range. Each node is checked once: O(n) time, O(h) stack. Alternatively, do an inorder traversal and check the values come out strictly increasing.

Could you say it?
Full answer3 min read
Python
class TreeNode:
    def __init__(self, value, left=None, right=None):
        self.value, self.left, self.right = value, left, right

def is_valid_bst(node, low=float("-inf"), high=float("inf")):
    if node is None:
        return True
    if not low < node.value < high:
        return False
    return (is_valid_bst(node.left, low, node.value) and      # left: below this value
            is_valid_bst(node.right, node.value, high))       # right: above it

good = TreeNode(8, TreeNode(3, TreeNode(1), TreeNode(6)), TreeNode(10))

#        10
#       /  \
#      5    15
#          /  \
#         6    20       6 is in 10's right subtree, but smaller than 10
tricky = TreeNode(10, TreeNode(5), TreeNode(15, TreeNode(6), TreeNode(20)))

print(is_valid_bst(good))     # True
print(is_valid_bst(tricky))   # False

def children_only_check(node):            # the tempting but wrong version
    if node is None:
        return True
    if node.left and node.left.value >= node.value:
        return False
    if node.right and node.right.value <= node.value:
        return False
    return children_only_check(node.left) and children_only_check(node.right)

print(children_only_check(tricky))   # True: fooled, because 6 < 15 is all it checks

The inorder way

Python
def is_valid_bst_inorder(root):
    previous = float("-inf")
    stack, node = [], root
    while stack or node:
        while node:
            stack.append(node)
            node = node.left
        node = stack.pop()
        if node.value <= previous:        # must be strictly increasing
            return False
        previous = node.value
        node = node.right
    return True

print(is_valid_bst_inorder(good), is_valid_bst_inorder(tricky))   # True False

Follow-ups

  • What about duplicate values? Agree on the rule first. With "strictly less on the left, strictly greater on the right", duplicates make the tree invalid, which is what the code above enforces.
  • Why not use Integer.MIN_VALUE as the starting bound in Java? A node holding exactly that value would wrongly fail the check. Use null for "no bound" (or a long), just as this code uses infinity.
Q35Not marked yet1–3 yrsAsked oftenCoding

Find the nearest common ancestor of two nodes in a binary tree

Say this first~30 sec to say

Recurse from the root. If the node is empty or is one of the targets, return it. Otherwise search both subtrees: if both return a node, the targets sit on different sides, so this node is the lowest common ancestor (LCA); if only one does, pass its result up. Each node is visited at most once: O(n) time, O(h) stack. In a BST, simply walk down from the root until the two values split.

Could you say it?
Full answer3 min read
Python
class TreeNode:
    def __init__(self, value, left=None, right=None):
        self.value, self.left, self.right = value, left, right

def lca(root, p, q):
    if root is None or root is p or root is q:
        return root
    left = lca(root.left, p, q)
    right = lca(root.right, p, q)
    if left and right:              # p and q were found on different sides
        return root
    return left or right            # both on one side, or neither found

#          3
#        /   \
#       5     1
#      / \   / \
#     6   2 0   8
#        / \
#       7   4
n7, n4 = TreeNode(7), TreeNode(4)
n2 = TreeNode(2, n7, n4)
n5 = TreeNode(5, TreeNode(6), n2)
n1 = TreeNode(1, TreeNode(0), TreeNode(8))
root = TreeNode(3, n5, n1)

print(lca(root, n5, n1).value)   # 3
print(lca(root, n7, n4).value)   # 2
print(lca(root, n5, n4).value)   # 5: a node counts as its own ancestor

In a binary search tree

The values tell you which way to go: while both targets are smaller than the current node, go left; while both are bigger, go right; the first node where they split is the LCA. O(h) time, O(1) space:

Python
def insert(root, value):
    if root is None:
        return TreeNode(value)
    if value < root.value:
        root.left = insert(root.left, value)
    else:
        root.right = insert(root.right, value)
    return root

def lca_bst(root, a, b):
    while root:
        if a < root.value and b < root.value:
            root = root.left
        elif a > root.value and b > root.value:
            root = root.right
        else:
            return root             # the paths to a and b split here
    return None

bst = None
for v in [6, 2, 8, 0, 4, 7, 9, 3, 5]:
    bst = insert(bst, v)
print(lca_bst(bst, 2, 8).value, lca_bst(bst, 2, 4).value, lca_bst(bst, 3, 5).value)   # 6 2 4

Follow-ups

  • What if one of the nodes might not be in the tree? The code above would wrongly return the other node. Count how many targets were actually found, and only accept the answer if both were.
  • What if each node has a parent pointer? Walk up from both nodes; it becomes "the intersection point of two linked lists".
  • The distance between two nodes? depth(p) + depth(q) − 2 × depth(LCA).

11Graphs

0/2 got it

Q36Not marked yetFresherAsked oftenConcept

BFS vs DFS: how does each work, and when would you use which?

Say this first~30 sec to say

Both visit every node reachable from the start in O(V + E) time, keeping a visited set so cycles don't loop forever. BFS explores level by level with a queue: all neighbours first, then their neighbours. In an unweighted graph it finds the shortest path in number of edges. DFS goes as deep as possible down one path before backtracking, with a stack or recursion. It suits exhaustive searches: cycle detection, topological sort, connected components and backtracking puzzles.

Could you say it?
Full answer4 min read
Python
from collections import deque

graph = {
    "A": ["B", "C"],
    "B": ["D", "E"],
    "C": ["F"],
    "D": [],
    "E": ["F"],
    "F": [],
}

def bfs(start):
    order, visited, queue = [], {start}, deque([start])
    while queue:
        node = queue.popleft()
        order.append(node)
        for nxt in graph[node]:
            if nxt not in visited:
                visited.add(nxt)          # mark when queued, so nothing is queued twice
                queue.append(nxt)
    return order

def dfs(node, visited=None, order=None):
    if visited is None:
        visited, order = set(), []
    visited.add(node)
    order.append(node)
    for nxt in graph[node]:
        if nxt not in visited:
            dfs(nxt, visited, order)
    return order

print(bfs("A"))   # ['A', 'B', 'C', 'D', 'E', 'F']: level by level
print(dfs("A"))   # ['A', 'B', 'D', 'E', 'F', 'C']: deep first, then back up

BFS gives the shortest path in an unweighted graph

Record each node's parent as you discover it, then walk back from the goal:

Python
def shortest_path(start, goal):
    parent, queue = {start: None}, deque([start])
    while queue:
        node = queue.popleft()
        if node == goal:
            path = []
            while node is not None:
                path.append(node)
                node = parent[node]
            return path[::-1]
        for nxt in graph[node]:
            if nxt not in parent:
                parent[nxt] = node
                queue.append(nxt)
    return None

print(shortest_path("A", "F"))   # ['A', 'C', 'F']: 2 edges, not 3 via B and E
BFSDFS
Data structureQueueStack, or recursion
OrderLevel by levelOne branch to the bottom, then backtrack
Shortest path (unweighted)YesNo
MemoryGrows with the graph's widthGrows with its depth
Typical usesShortest paths, levels, spreading from several sources at onceCycles, topological sort, components, mazes, backtracking

Follow-ups

  • What if the edges have weights? BFS no longer gives the shortest path. Use Dijkstra's algorithm, which is BFS with a priority queue, for non-negative weights.
  • How do you detect a cycle in a directed graph? DFS with three states per node (unvisited, in progress, done): reaching an "in progress" node means a cycle.
  • What does O(V + E) mean? Each vertex is visited once and each edge is examined once (twice in an undirected graph).
Q37Not marked yet1–3 yrsAsked sometimesCoding

Count the separate land regions in a grid of land (1) and water (0) cells

Say this first~30 sec to say

Scan every cell. When you find land that hasn't been visited, that's a new island: count it, then flood-fill from it with DFS or BFS, marking every connected land cell (up, down, left and right) as visited so it isn't counted again. Each cell is handled a constant number of times: O(rows × cols) time, and up to O(rows × cols) space for the visited set and the stack or queue.

Could you say it?
Full answer3 min read
Python
def count_islands(grid):
    if not grid:
        return 0
    rows, cols = len(grid), len(grid[0])
    seen = set()

    def flood(r, c):
        stack = [(r, c)]                  # iterative DFS: no recursion limit
        seen.add((r, c))
        while stack:
            r, c = stack.pop()
            for dr, dc in ((1, 0), (-1, 0), (0, 1), (0, -1)):
                nr, nc = r + dr, c + dc
                if (0 <= nr < rows and 0 <= nc < cols
                        and grid[nr][nc] == "1" and (nr, nc) not in seen):
                    seen.add((nr, nc))
                    stack.append((nr, nc))

    islands = 0
    for r in range(rows):
        for c in range(cols):
            if grid[r][c] == "1" and (r, c) not in seen:
                islands += 1              # a new island
                flood(r, c)               # mark all of it
    return islands

grid = [
    "11000",
    "11000",
    "00100",
    "00011",
]
print(count_islands(grid))                 # 3
print(count_islands(["111", "010", "111"]))   # 1: all connected through the middle
print(count_islands(["000"]))              # 0

Questions to ask: Do diagonal neighbours count as connected? (If so, check 8 directions.) Can the grid be modified? If yes, you can "sink" each visited cell by setting it to "0" instead of keeping a seen set, saving memory.

Follow-ups

  • The area of the largest island? Make the flood fill return how many cells it marked, and keep the maximum.
  • Rotting oranges, or the distance from every cell to the nearest water? BFS starting from all the source cells at once (multi-source BFS), counting levels as minutes or distance.
  • Is there another approach? Union-find: join every pair of neighbouring land cells, then count the distinct groups. It shines when land is added cell by cell and you need the count after each addition.

12Dynamic programming

0/3 got it

Q38Not marked yetFresherAsked very oftenCoding

Find the nth Fibonacci number: plain recursion, then memoised, then bottom-up

Say this first~30 sec to say

Plain recursion, fib(n) = fib(n-1) + fib(n-2), recomputes the same values again and again: O(2ⁿ) time. Memoisation stores each result the first time it's computed, so every n is solved once: O(n) time and O(n) space. Bottom-up tabulation fills values upwards from fib(0) in a loop, and since each value only needs the previous two, two variables are enough: O(n) time and O(1) space. That progression is dynamic programming.

Could you say it?
Full answer4 min read
Python
def fib_recursive(n):                     # O(2^n): recomputes everything
    if n < 2:
        return n
    return fib_recursive(n - 1) + fib_recursive(n - 2)

def fib_memo(n, memo=None):               # top-down: O(n) time and space
    if memo is None:
        memo = {}
    if n < 2:
        return n
    if n not in memo:
        memo[n] = fib_memo(n - 1, memo) + fib_memo(n - 2, memo)
    return memo[n]

def fib_bottom_up(n):                     # bottom-up: O(n) time, O(1) space
    a, b = 0, 1
    for _ in range(n):
        a, b = b, a + b
    return a

print(fib_recursive(20), fib_memo(20), fib_bottom_up(20))   # 6765 6765 6765
print(fib_bottom_up(90))                                     # 2880067194370816120
print([fib_bottom_up(i) for i in range(10)])                 # the series: [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]

How badly plain recursion repeats itself

Python
calls = 0

def fib_counted(n):
    global calls
    calls += 1
    return n if n < 2 else fib_counted(n - 1) + fib_counted(n - 2)

fib_counted(25)
print(calls)   # 242785 calls to compute fib(25)
fib(5) calls fib(4) and fib(3)
fib(4) calls fib(3) and fib(2)     ← fib(3) is now being computed twice
fib(3) calls fib(2) and fib(1)     ← and fib(2) three times

What dynamic programming is: a technique for problems with overlapping subproblems (the same smaller question comes up many times) and optimal substructure (the answer is built from answers to smaller questions). Solve each subproblem once and reuse it, either top-down with a memo, or bottom-up with a table.

Top-down (memoisation)Bottom-up (tabulation)
StyleThe natural recursion, plus a cacheA loop that fills a table in order
ComputesOnly the subproblems actually neededEvery subproblem up to n
RiskRecursion depth on large nNone
SpaceThe memo plus the call stackThe table, often shrinkable to a few variables

In Python, @functools.lru_cache turns the plain recursive version into the memoised one with a single line.

The same recurrence: climbing stairs one or two steps at a time

To reach step n, your last move came from step n − 1 or step n − 2, so ways(n) = ways(n-1) + ways(n-2):

Python
def climb_stairs(n):
    a, b = 1, 1                           # ways to reach step 0 and step 1
    for _ in range(n - 1):
        a, b = b, a + b
    return b

print([climb_stairs(n) for n in range(1, 8)])   # [1, 2, 3, 5, 8, 13, 21]

Follow-ups

  • Can you beat O(n)? Yes: matrix exponentiation, or the fast-doubling formulas, give O(log n).
  • Why not use the closed-form (Binet) formula? It needs floating-point powers of the golden ratio, which lose precision once n passes about 70.
Q39Not marked yet1–3 yrsAsked oftenCoding

Find the fewest coins that add up to a given amount

Say this first~30 sec to say

Build a table where dp[a] is the fewest coins making amount a: dp[0] = 0, the rest infinity. For each amount up to the target, try every coin c that fits: dp[a] = min(dp[a], dp[a - c] + 1). Return dp[amount], or −1 if it's still infinity. O(amount × coins) time, O(amount) space. Greedy fails here: for coins [1, 3, 4] and amount 6 it picks 4 + 1 + 1, but 3 + 3 is better.

Could you say it?
Full answer3 min read
Python
def coin_change(coins, amount):
    INF = float("inf")
    dp = [0] + [INF] * amount
    for a in range(1, amount + 1):
        for c in coins:
            if c <= a and dp[a - c] + 1 < dp[a]:
                dp[a] = dp[a - c] + 1     # use coin c on top of the best way to make a - c
    return dp[amount] if dp[amount] != INF else -1

def greedy_change(coins, amount):          # the tempting but wrong approach
    count = 0
    for c in sorted(coins, reverse=True):
        count += amount // c
        amount %= c
    return count if amount == 0 else -1

print(coin_change([1, 3, 4], 6), greedy_change([1, 3, 4], 6))   # 2 3
print(coin_change([1, 2, 5], 11))                                # 3: 5 + 5 + 1
print(coin_change([2], 3))                                       # -1: impossible
print(coin_change([1], 0))                                       # 0

The table for coins [1, 3, 4] and amount 6

amount0123456
fewest coins0121122
built from11 + 1344 + 13 + 3

When does greedy work? For "canonical" coin systems such as India's 1, 2, 5, 10, 20 and 50, greedy happens to give the optimal answer. For arbitrary coins it can be wrong, which is why the question needs DP.

The common variant: how many ways can you make the amount?

Python
def count_ways(coins, amount):
    ways = [1] + [0] * amount             # one way to make 0: no coins
    for c in coins:                       # coins in the outer loop: each combination counted once
        for a in range(c, amount + 1):
            ways[a] += ways[a - c]
    return ways[amount]

print(count_ways([1, 2, 5], 5))   # 4: 5, 2+2+1, 2+1+1+1, 1+1+1+1+1

Swapping the two loops would count 1 + 2 and 2 + 1 separately: that counts orderings, a different question.

Follow-ups

  • How is this related to 0/1 knapsack? Both fill a table of take-or-skip decisions. Coin change lets you reuse a coin any number of times; in 0/1 knapsack each item can be taken at most once, so the inner loop runs over capacities from high to low.
  • How would you list the coins used? Store which coin last improved each dp[a], then walk back from the target amount.
Q40Not marked yet1–3 yrsAsked oftenCoding

Find the length of the longest sequence of characters that two strings share in the same order (gaps allowed)

Say this first~30 sec to say

This is the longest common subsequence (LCS). Build a 2D table where dp[i][j] is the LCS length of the first i characters of one string and the first j of the other. If those last characters match, dp[i][j] = dp[i-1][j-1] + 1; if not, it's the larger of dp[i-1][j] and dp[i][j-1], dropping one character from either string. The answer is the bottom-right cell. O(m × n) time and space, and O(min(m, n)) space if you only need the length.

Could you say it?
Full answer3 min read
Python
def lcs(a, b):
    m, n = len(a), len(b)
    dp = [[0] * (n + 1) for _ in range(m + 1)]
    for i in range(1, m + 1):
        for j in range(1, n + 1):
            if a[i - 1] == b[j - 1]:
                dp[i][j] = dp[i - 1][j - 1] + 1          # extend the match
            else:
                dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])   # drop a character

    # Walk back from the corner to recover one of the longest subsequences
    out, i, j = [], m, n
    while i and j:
        if a[i - 1] == b[j - 1]:
            out.append(a[i - 1])
            i, j = i - 1, j - 1
        elif dp[i - 1][j] >= dp[i][j - 1]:
            i -= 1
        else:
            j -= 1
    return dp[m][n], "".join(reversed(out))

print(lcs("ABCBDAB", "BDCABA"))   # (4, 'BCBA')
print(lcs("AGGTAB", "GXTXAYB"))   # (4, 'GTAB')
print(lcs("abc", "xyz"))          # (0, '')

The table for "ACE" and "ABCDE"

""ABCDE
""000000
A011111
C011222
E011223

The bottom-right cell is 3: "ACE".

Only the length, in O(min(m, n)) space

Each row only needs the row above it:

Python
def lcs_length(a, b):
    if len(a) < len(b):
        a, b = b, a                       # keep the shorter string across the row
    previous = [0] * (len(b) + 1)
    for ch in a:
        current = [0]
        for j, other in enumerate(b, 1):
            if ch == other:
                current.append(previous[j - 1] + 1)
            else:
                current.append(max(previous[j], current[j - 1]))
        previous = current
    return previous[-1]

print(lcs_length("ABCBDAB", "BDCABA"))   # 4

Subsequence vs substring: a subsequence can skip characters ("ACE" in "ABCDE"); a substring must be contiguous. For the longest common substring, a mismatch resets the cell to 0 instead of taking the max.

Follow-ups

  • Where is LCS used? diff and Git's comparison of files, and comparing DNA sequences.
  • What's edit distance? The fewest inserts, deletes and replacements to turn one string into another: the same kind of 2D table, with a cost for each operation.
  • What does the brute force cost? Every subsequence of one string checked against the other: O(2ᵐ · n).

Last step

Write these from memory

Before a coding round, make sure you can write each of these on a blank page, without help and without running the code:

  • Q6
  • Q11
  • Q16
  • Q20
  • Q21
  • Q26
  • Q27
  • Q29
  • Q30
  • Q31
  • Q34
  • Q38
  • Q39

0 of 13 done