Module 4

Data Structures & Algorithms

Data structures and algorithms for technical interviews and efficient, scalable code.

30 lessonsAI & MLHarinIT Academy
Module 4 · Lesson 4.1

Time & Space Complexity

4.1Time & Space Complexity

Time and Space Complexity are used to measure the efficiency of an algorithm.

When solving a programming problem, it is not enough to know that a program produces the correct answer. We also need to understand:

  • How much time will it take?
  • How much memory will it use?
  • How will its performance change when the input becomes very large?

For example, an algorithm that works well for 100 records may become extremely slow when processing 10 million records. Complexity analysis helps us identify such problems before they occur.

4.1.1What Is Algorithm Complexity?

  • Suppose an algorithm processes an input containing n elements.
  • We use n to represent the input size.
  • For example:
numbers = [10, 20, 30, 40, 50]

Here:

n = 5

If the list contains one million elements:

n = 1,000,000
  • Complexity analysis describes how the algorithm's resource requirements grow as n increases.
  • There are two primary types:
  • Time Complexity – how computation grows with input size.
  • Space Complexity – how memory usage grows with input size.

4.1.2Time Complexity

  • Time complexity describes how the number of operations performed by an algorithm grows as the input size increases.
  • It does not normally mean the exact number of seconds required.
  • Consider:
def print_numbers(numbers):
for number in numbers:
print(number)

If there are n elements, the loop executes n times.

Therefore:

Time Complexity = O(n)

If the input doubles, the number of iterations approximately doubles.

4.1.3Why Exact Execution Time Is Not Used

Suppose two computers execute the same program:

  • Computer A → 2 seconds
  • Computer B → 0.8 seconds
  • The exact execution time depends on:
  • CPU
  • RAM
  • Operating system
  • Programming language
  • Compiler/interpreter
  • Background processes
  • Hardware architecture

Therefore, algorithm analysis focuses on growth rate rather than machine-dependent execution time.

4.1.4Big-O Notation

  • The most commonly used notation for describing algorithm complexity is Big-O notation.
  • Big-O describes how an algorithm grows as the input size becomes large.
  • Common complexities include:
Big-ONameTypical Example
O(1)ConstantArray index access
O(log n)LogarithmicBinary search
O(n)LinearLinear search
O(n log n)LinearithmicMerge sort
O(n²)QuadraticBubble sort
O(n³)CubicTriple nested loops
O(2ⁿ)ExponentialNaive recursive Fibonacci
O(n!)FactorialGenerating permutations

A commonly remembered order is:

O(1)

O(log n)

O(n)

O(n log n)
O(n²)
O(n³)
O(2ⁿ)

O(n!)

Generally, the complexity toward the top scales better for large inputs.

4.1.5O(1) — Constant Time

An algorithm has O(1) complexity when its execution does not depend on the size of the input.

Example:

numbers = [10, 20, 30, 40, 50]
value = numbers[2]

Accessing an element by index is generally:

O(1)

Whether the list contains 5 elements or 5 million elements, accessing a particular index is generally constant time.

Another example:

def get_first(numbers):
return numbers[0]

Complexity:

Time = O(1)

4.1.6O(n) — Linear Time

An algorithm is O(n) when its work grows proportionally with the input size.

Example:

def find_number(numbers, target):
for number in numbers:
if number == target:
return True
return False

In the worst case, every element must be checked.

Therefore:

Time = O(n)

For:

n = 100

approximately 100 elements may be examined.

For:

n = 1,000,000

approximately 1,000,000 elements may be examined.

4.1.7O(log n) — Logarithmic Time

  • An algorithm has logarithmic complexity when the problem size is repeatedly reduced by a factor.
  • The classic example is binary search.
  • Suppose we have:
\[10, 20, 30, 40, 50, 60, 70, 80\]

Binary search doesn't check every element.

Instead, it repeatedly divides the search space:

8 elements
4 elements
2 elements
1 element

Therefore:

Time = O(log n)

This is why binary search is much faster than linear search for large sorted datasets.

4.1.8O(n²) — Quadratic Time

Quadratic complexity commonly occurs when one loop is nested inside another.

Example:

def print_pairs(numbers):
for i in numbers:
for j in numbers:
print(i, j)

The outer loop executes n times.

For every outer iteration, the inner loop also executes n times.

Therefore:

  • n × n = n²
  • So:
  • Time = O(n²)
  • If:
n = 1,000
  • the algorithm can perform roughly:
  • 1,000 × 1,000
  • = 1,000,000
  • iterations.

4.1.9O(n³) — Cubic Time

Three nested loops commonly result in O(n³).

def example(n):
for i in range(n):
for j in range(n):
for k in range(n):
print(i, j, k)
  • The number of operations is approximately:
  • n × n × n
  • = n³

Therefore:

Time = O(n³)

Such algorithms become expensive very quickly as n grows.

4.1.10O(n log n)

  • O(n log n) is an important complexity in efficient algorithms.
  • It is commonly associated with:
  • Merge Sort
  • Heap Sort
  • Average-case Quick Sort

For example, Merge Sort repeatedly divides the input into smaller pieces and then processes the elements at each level.

The result is:

Time = O(n log n)

This is generally much better than O(n²) for large datasets.

4.1.11O(2ⁿ) — Exponential Time

An exponential algorithm grows extremely quickly.

A classic example is naive recursive Fibonacci:

def fibonacci(n):
if n <= 1:
return n
return fibonacci(n - 1) + fibonacci(n - 2)

The function creates multiple recursive branches.

Its time complexity is exponential, commonly represented as:

  • O(2ⁿ)
  • This approach becomes impractical for relatively small values of n.
  • Dynamic Programming can dramatically improve this problem.

4.1.12O(n!) — Factorial Time

Factorial complexity commonly appears when an algorithm tries every possible permutation.

For example:

3! = 6

  • 5! = 120
  • 10! = 3,628,800
  • 15! = 1,307,674,368,000

Therefore, algorithms with O(n!) complexity become impractical very quickly.

4.1.13Best Case, Average Case and Worst Case

An algorithm may behave differently depending on the input.

Consider linear search:

def search(numbers, target):
for number in numbers:
if number == target:
return True
return False

Best Case

The target is the first element.

Time = O(1)

Average Case

The target is somewhere in the middle.

Time = O(n)

Worst Case

  • The target is the last element or doesn't exist.
  • Time = O(n)
  • When discussing algorithm complexity, worst-case complexity is often emphasized.

4.1.14Space Complexity

Space complexity describes how the memory requirements of an algorithm grow with input size.

Consider:

def calculate_sum(numbers):
    total = 0
    for number in numbers:
        total += number
return total

Only a few variables are created regardless of the number of elements.

Therefore, the auxiliary space is:

O(1)

4.1.15O(n) Space Complexity

Consider:

def duplicate(numbers):
    result = []
    for number in numbers:
        result.append(number)
return result

The result list grows as the input grows.

If the input contains n elements, the new list also contains approximately n elements.

Therefore:

Space = O(n)

4.1.16Time Complexity vs Space Complexity

Consider this algorithm:

def find_duplicates(numbers):
seen = set()
for number in numbers:
if number in seen:
return True

seen.add(number)

return False

The algorithm may process every element once:

Time = O(n)

The seen set may contain n elements:

Space = O(n)

Therefore:

Time Complexity = O(n)

Space Complexity = O(n)

4.1.17Time-Space Tradeoff

Sometimes we can make an algorithm faster by using additional memory.

For example, finding duplicates using nested loops:

for i in range(n):
for j in range(i + 1, n):
if numbers[i] == numbers[j]:
return True

has approximately:

Time = O(n²)

Space = O(1)

Using a set:

seen = set()
for number in numbers:
if number in seen:
return True

seen.add(number)

gives approximately:

Time = O(n)

Space = O(n)

We use additional memory to improve execution time.

This is called a time-space tradeoff.

4.1.18Big-O, Big-Ω and Big-Θ

  • Three important asymptotic notations are:
  • Big-O — O()
  • Represents an asymptotic upper bound.

Example:

O(n)

Big-Omega — Ω()

Represents an asymptotic lower bound.

Example:

Ω(1)

Big-Theta — Θ()

Represents a tight asymptotic bound.

Example:

Θ(n)

For practical DSA and coding interviews, Big-O is the notation you will encounter most frequently.

4.1.19Dropping Constants

  • Suppose an algorithm performs:
  • 5n + 10
  • operations.
  • We write:
  • O(5n + 10)
  • For asymptotic analysis, constants are ignored:

O(n)

  • Similarly:
  • O(3n² + 5n + 100)
  • becomes:
  • O(n²)

because n² is the dominant growth term.

4.1.20Sequential Operations

Consider:

for i in range(n):
print(i)
for j in range(n):
print(j)
  • The total work is:
  • n + n
  • = 2n

Therefore:

O(2n)

is simplified to:

O(n)

Sequential loops generally add their complexities rather than multiply them.

4.1.21Nested Operations

Consider:

for i in range(n):
for j in range(n):
print(i, j)

Here the loops are nested.

Therefore:

  • n × n
  • = n²
  • So:
  • O(n²)
  • A useful rule is:

Sequential loops usually add; nested loops usually multiply.

4.1.22Different Input Sizes

Consider:

for i in range(n):
for j in range(m):
print(i, j)

The complexity is:

O(n × m)

or:

O(nm)

We should not automatically write O(n²) unless n and m represent the same input size.

4.1.23Recursive Space Complexity

Recursion also consumes memory because each function call is stored on the call stack.

Example:

def factorial(n):
if n == 0:
return 1
return n * factorial(n - 1)
  • For:
  • factorial(4)
  • the call stack looks conceptually like:
factorial(4)
factorial(3)
factorial(2)
factorial(1)
factorial(0)

There can be n active calls.

Therefore:

Time = O(n)

Space = O(n)

4.1.24Amortized Complexity

  • Some operations are occasionally expensive but inexpensive on average over a sequence of operations.
  • For example, Python's:
  • numbers.append(value)
  • has an amortized time complexity of O(1).

Occasionally, the underlying dynamic array may need to resize, which requires copying elements.

That individual resize can take O(n), but across many append operations, the average cost remains:

O(1) amortized

This concept is called amortized analysis.

4.1.25Common Python Data Structure Complexity

Python List

OperationComplexity
list[i]O(1)
append()O(1) amortized
pop() from endO(1)
insert(0, x)O(n)
pop(0)O(n)
SearchO(n)
sort()O(n log n)

Python Dictionary

OperationAverage Complexity
LookupO(1)
InsertO(1)
DeleteO(1)
Search by valueO(n)

Python Set

OperationAverage Complexity
AddO(1)
RemoveO(1)
Membership testO(1)

These are typical/average-case complexities; implementation details and pathological cases can differ.

4.1.26Common Algorithm Complexities

AlgorithmBestAverageWorst
Linear SearchO(1)O(n)O(n)
Binary SearchO(1)O(log n)O(log n)
Bubble SortO(n)O(n²)O(n²)
Selection SortO(n²)O(n²)O(n²)
Insertion SortO(n)O(n²)O(n²)
Merge SortO(n log n)O(n log n)O(n log n)
Quick SortO(n log n)O(n log n)O(n²)
Heap SortO(n log n)O(n log n)O(n log n)

4.1.27Graph Complexity

  • For graph algorithms, we commonly use:
  • V = Number of vertices
  • E = Number of edges

Breadth-First Search (BFS) and Depth-First Search (DFS) typically have:

Time = O(V + E)

This is because the algorithm may visit each vertex and edge.

Their auxiliary space is typically:

Space = O(V)

4.1.28Practical Example: Two Sum

Suppose we need to find two numbers whose sum equals a target.

Brute Force

def two_sum(numbers, target):
for i in range(len(numbers)):
for j in range(i + 1, len(numbers)):
if numbers[i] + numbers[j] == target:
return i, j
return None

There are nested loops.

Therefore:

Time = O(n²)

Space = O(1)

Optimized Approach

def two_sum(numbers, target):
seen = {}
for i, number in enumerate(numbers):
required = target - number
if required in seen:
return seen[required], i

seen[number] = i

return None

Now:

Time = O(n)

Space = O(n)

The optimized solution is faster for large inputs because it uses additional memory.

4.1.29How to Analyze an Algorithm

When analyzing an algorithm, follow this process:

  • Step 1: Identify the input size
  • Determine what n represents.
  • Step 2: Identify loops
  • Determine how many times each loop executes.
  • Step 3: Check nested loops
  • Nested loops often multiply their complexities.
  • Step 4: Check recursion
  • Determine:
  • Number of recursive calls
  • Recursion depth
  • Number of branches
  • Step 5: Identify additional data structures
  • Look for:
  • Lists
  • Sets
  • Dictionaries
  • Arrays
  • Queues
  • Stacks
  • Temporary variables
  • Step 6: Find the dominant term
  • For example:
  • O(n² + n + 10)
  • becomes:
  • O(n²)
  • Step 7: State both complexities
  • Always try to report:
  • Time Complexity = ?
  • Space Complexity = ?

4.1.30Complexity Comparison

For a very large input, the difference between complexity classes becomes significant.

ComplexityGrowth
O(1)Constant
O(log n)Very slow growth
O(n)Linear
O(n log n)Moderately fast growth
O(n²)Fast growth
O(n³)Very fast growth
O(2ⁿ)Extremely fast growth
O(n!)Extremely fast growth

For example, when n becomes very large:

O(log n) << O(n) << O(n²) << O(2ⁿ)

This is why algorithm selection becomes increasingly important as datasets grow.

4.1.31Important Rules to Remember

Rule 1 — One loop

for i in range(n):

...

O(n)

Rule 2 — Two nested loops

for i in range(n):
for j in range(n):

...

  • O(n²)
  • Rule 3 — Three nested loops
  • O(n³)
  • Rule 4 — Halving the search space
  • O(log n)
  • Rule 5 — Efficient comparison sorting
  • O(n log n)
  • Rule 6 — Hash-table lookup
  • Typically:
  • O(1) average
  • Rule 7 — Additional collection containing n elements
  • Usually:
  • O(n) space
  • Rule 8 — Recursive depth of n
  • Often:
  • O(n) stack space

4.1.32Interview Questions

Question 1

What is time complexity?

Answer: Time complexity describes how the computational work of an algorithm grows as the input size increases.

Question 2

What is space complexity?

Answer: Space complexity describes how the memory requirements of an algorithm grow as the input size increases.

Question 3

What is Big-O notation?

Answer: Big-O notation describes the asymptotic upper-bound growth rate of an algorithm.

Question 4

What is the complexity of accessing an array element by index?

Answer:

O(1)

Question 5

What is the complexity of linear search?

Answer:

Worst Case = O(n)

Question 6

What is the complexity of binary search?

Answer:

O(log n)

assuming the data is sorted and the search is implemented appropriately.

Question 7

What is the complexity of merge sort?

Answer:

O(n log n)

Question 8

What is a time-space tradeoff?

Answer: A time-space tradeoff occurs when additional memory is used to reduce execution time, or when less memory is used at the cost of additional computation.

4.1.33Practice Problems

Problem 1

Find the time complexity:

for i in range(n):
print(i)

Answer:

O(n)

Problem 2

for i in range(n):
for j in range(n):
print(i, j)

Answer:

O(n²)

Problem 3

numbers = []
for i in range(n):
    numbers.append(i)

Answer:

Time = O(n)

  • Space = O(n)
  • Problem 4
  • numbers.sort()
for number in numbers:
print(number)

Answer:

Sorting = O(n log n)

Traversal = O(n)

Overall = O(n log n)

The dominant term determines the final complexity.

4.1.34Summary

Time and Space Complexity are essential for evaluating algorithm efficiency.

The most important complexities to remember are:

O(1) → Constant

O(log n) → Logarithmic

O(n) → Linear

O(n log n) → Linearithmic

O(n²) → Quadratic

O(n³) → Cubic

O(2ⁿ) → Exponential

O(n!) → Factorial

The key objective of complexity analysis is to understand how an algorithm behaves as the input becomes larger, rather than focusing only on whether the algorithm works for a small example.

For DSA interviews and real-world software development, always ask:

  • 1. What is the input size?
  • 2. How many times does the algorithm process the input?
  • 3. Are there nested loops?
  • 4. Is recursion involved?
  • 5. What additional memory is required?
  • 6. Can the time complexity be reduced?
  • 7. Can memory be reduced?

Once these questions become natural, you will have a strong foundation for the remaining topics in Module 4 – Data Structures & Algorithms.

Module 4 · Lesson 4.2

Arrays

An array is a linear data structure used to store multiple elements in a sequence.

Arrays are one of the most fundamental data structures because they provide efficient access to elements using an index.

For example:

Index: 0 1 2 3 4

↓ ↓ ↓ ↓ ↓

Array: 10 20 30 40 50

In Python, the built-in list is commonly used when we need array-like behavior.

numbers = [10, 20, 30, 40, 50]

4.2.1Characteristics of Arrays

An array generally has the following characteristics:

Stores multiple values.

  • Elements are accessed using indexes.
  • Elements are stored in a logical sequence.
  • Supports traversal.
  • Supports searching and updating.
  • Provides fast indexed access.

Can be used to implement other data structures.

In Python, lists are dynamic arrays, meaning they can grow and shrink as elements are added or removed.

4.2.2Creating an Array in Python

The simplest approach is using a list:

numbers = [10, 20, 30, 40, 50]

We can also create an empty list:

numbers = []

Then add elements:

  • numbers.append(10)
  • numbers.append(20)
  • numbers.append(30)
  • The result is:
\[10, 20, 30\]

4.2.3Accessing Array Elements

Array elements are accessed using their index.

numbers = [10, 20, 30, 40, 50]
print(numbers[0])
print(numbers[2])
print(numbers[4])

Output:

  • 10
  • 30
  • 50
  • Python uses zero-based indexing.

Therefore:

  • Index 0 → First element
  • Index 1 → Second element
  • Index 2 → Third element

...

The last element can also be accessed using:

numbers[-1]

4.2.4Updating an Array Element

An existing element can be modified using its index.

numbers = [10, 20, 30, 40, 50]

numbers[2] = 35

print(numbers)

Output:

\[10, 20, 35, 40, 50\]

The update operation is generally:

Time Complexity = O(1)

4.2.5Traversing an Array

Traversal means visiting every element.

Using a for loop

numbers = [10, 20, 30, 40, 50]
for number in numbers:
print(number)

Output:

  • 10
  • 20
  • 30
  • 40
  • 50
  • Time complexity:

O(n)

because every element is visited.

4.2.6Traversal Using Indexes

We can also traverse using indexes:

numbers = [10, 20, 30, 40, 50]
for i in range(len(numbers)):
print(numbers[i])

This approach is useful when we need both the index and value.

A more Pythonic approach is:

for index, value in enumerate(numbers):
print(index, value)

4.2.7Inserting Elements

Python lists provide several ways to insert elements.

Append

Adds an element to the end.

numbers = [10, 20, 30]

numbers.append(40)

print(numbers)

Result:

\[10, 20, 30, 40\]

Appending is generally:

O(1) amortized

Insert at a Specific Position

numbers = [10, 20, 30, 40]

numbers.insert(2, 25)

print(numbers)

Result:

\[10, 20, 25, 30, 40\]

Elements after the insertion position need to be shifted.

Therefore:

Time Complexity = O(n)

4.2.8Deleting Elements

Delete by Index

numbers = [10, 20, 30, 40]

del numbers[1]

print(numbers)

Result:

\[10, 30, 40\]

Deleting from the middle requires shifting elements.

Therefore:

Time Complexity = O(n)

Remove by Value

numbers = [10, 20, 30, 40]

numbers.remove(30)

print(numbers)

Result:

\[10, 20, 40\]

Searching for the value requires traversal:

O(n)

Therefore, remove() is generally:

O(n)

Pop from the End

numbers = [10, 20, 30]
value = numbers.pop()
print(value)

Output:

30

Removing from the end is generally:

O(1)

Pop from the Beginning

numbers = [10, 20, 30]

numbers.pop(0)

The remaining elements must shift.

Therefore:

O(n)

4.2.9Array Operations and Complexity

OperationComplexity
Access by indexO(1)
Update by indexO(1)
TraverseO(n)
SearchO(n)
AppendO(1) amortized
Insert at beginningO(n)
Insert at middleO(n)
Delete from beginningO(n)
Delete from middleO(n)
Delete from endO(1)
SortO(n log n)

Understanding these complexities is important when designing efficient algorithms.

4.2.10Searching an Array

Searching means finding whether a particular element exists.

Consider:

numbers = [10, 20, 30, 40, 50]
target = 30
if target in numbers:
print("Found")

For an unsorted list, searching generally requires:

O(n)

4.2.11Linear Search

A linear search checks elements one by one.

def linear_search(numbers, target):
for i in range(len(numbers)):
if numbers[i] == target:
return i
return -1

Example:

numbers = [10, 20, 30, 40, 50]
result = linear_search(numbers, 40)
print(result)

Output:

3

Complexity:

Best Case = O(1)

Worst Case = O(n)

Space = O(1)

4.2.12Finding the Maximum Element

We can find the maximum value using a single traversal.

def find_max(numbers):
maximum = numbers[0]
for number in numbers:
if number > maximum:
maximum = number
return maximum

Example:

numbers = [12, 5, 30, 8, 25]
print(find_max(numbers))

Output:

30

Complexity:

Time = O(n)

Space = O(1)

4.2.13Finding the Minimum Element

def find_min(numbers):
minimum = numbers[0]
for number in numbers:
if number < minimum:
minimum = number
return minimum

Complexity:

Time = O(n)

Space = O(1)

4.2.14Calculating the Sum

def array_sum(numbers):
    total = 0
    for number in numbers:
        total += number
return total

Example:

numbers = [10, 20, 30, 40]
print(array_sum(numbers))

Output:

100

Complexity:

Time = O(n)

Space = O(1)

4.2.15Reversing an Array

An array can be reversed using two pointers.

def reverse_array(numbers):
    left = 0
    right = len(numbers) - 1
    while left < right:
        numbers[left], numbers[right] = numbers[right], numbers[left]

left += 1

right -= 1

return numbers

Example:

numbers = [10, 20, 30, 40, 50]
print(reverse_array(numbers))

Output:

\[50, 40, 30, 20, 10\]

Complexity:

Time = O(n)

Space = O(1)

This is an important interview pattern.

4.2.16Finding the Second Largest Element

A common interview problem is finding the second-largest value without sorting.

def second_largest(numbers):
largest = float("-inf")
second = float("-inf")
for number in numbers:
if number > largest:
second = largest
largest = number
elif largest > number > second:
second = number
return second

Example:

numbers = [10, 40, 20, 50, 30]
print(second_largest(numbers))

Output:

40

Complexity:

Time = O(n)

  • Space = O(1)
  • A sorting-based solution would typically require:
  • O(n log n)

so the single-pass solution is more efficient.

4.2.17Removing Duplicates

Suppose:

numbers = [1, 2, 2, 3, 4, 4, 5]

We want:

\[1, 2, 3, 4, 5\]

A simple solution is:

unique = list(set(numbers))

This is convenient, but a set does not preserve the original ordering in the same conceptual way as a sequence.

If order matters, use:

def remove_duplicates(numbers):
    seen = set()
    result = []
    for number in numbers:
        if number not in seen:
            seen.add(number)

result.append(number)

return result

Typical complexity:

Time = O(n)

Space = O(n)

4.2.18Prefix Sum

A prefix sum stores cumulative sums.

Given:

\[2, 4, 6, 8, 10\]

the prefix sum becomes:

\[2, 6, 12, 20, 30\]

Implementation:

def prefix_sum(numbers):
result = [0] * len(numbers)

result[0] = numbers[0]

for i in range(1, len(numbers)):
    result[i] = result[i - 1] + numbers[i]
return result

Time complexity:

O(n)

Space complexity:

O(n)

Prefix sums are extremely useful for range-sum problems.

4.2.19Array Range Sum

Suppose we have:

numbers = [2, 4, 6, 8, 10]

and need the sum between indexes 1 and 3.

That means:

4 + 6 + 8 = 18

Using prefix sums:

prefix = [2, 6, 12, 20, 30]

The range sum can be calculated as:

  • prefix[3] - prefix[0]
  • = 20 - 2
  • = 18

This changes repeated range-sum queries from potentially O(n) each to approximately O(1) after O(n) preprocessing.

4.2.20Two-Dimensional Arrays

A two-dimensional array can represent a matrix.

matrix = [
    [1, 2, 3],
    [4, 5, 6],
\[7, 8, 9\]

]

It can be visualized as:

1 2 3

4 5 6

7 8 9

Access:

print(matrix[1][2])

Output:

6

The first index represents the row and the second represents the column.

4.2.21Traversing a Matrix

matrix = [
    [1, 2, 3],
    [4, 5, 6],
\[7, 8, 9\]

]

for row in matrix:
for value in row:
print(value)

For an m × n matrix:

Time = O(m × n)

If the matrix is n × n:

Time = O(n²)

4.2.22Transposing a Matrix

The transpose converts rows into columns.

Original:

1 2 3

4 5 6

Transpose:

1 4

2 5

3 6

Using Python:

matrix = [
    [1, 2, 3],
\[4, 5, 6\]

]

transpose = [list(row) for row in zip(*matrix)]
print(transpose)

Output:

\[[1, 4], [2, 5], [3, 6]\]

4.2.23Common Array Interview Patterns

Arrays appear frequently in coding interviews.

Important patterns include:

1. Traversal

Visit each element once.

Typical complexity:

O(n)

2. Two Pointers

  • Useful for:
  • Reversing arrays
  • Pair-sum problems
  • Removing duplicates
  • Sorted arrays

3. Sliding Window

  • Useful for:
  • Maximum subarray problems
  • Longest substring problems
  • Fixed-size windows

4. Prefix Sum

  • Useful for:
  • Range-sum queries
  • Subarray calculations

5. Hashing

  • Useful for:
  • Duplicate detection
  • Frequency counting
  • Two Sum
  • Lookup problems

6. Binary Search

Useful when the data or search space has the required ordering/monotonic property.

4.2.24Frequency Counting

Suppose we want to count how many times each number appears.

def frequency(numbers):
    counts = {}
    for number in numbers:
        counts[number] = counts.get(number, 0) + 1
return counts

Example:

numbers = [1, 2, 2, 3, 3, 3]
print(frequency(numbers))

Result:

{1: 1, 2: 2, 3: 3}

Typical complexity:

Time = O(n)

Space = O(n)

4.2.25Maximum Subarray Problem

One of the most famous array problems is finding the contiguous subarray with the maximum sum.

For:

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

the maximum-sum subarray is:

\[4, -1, 2, 1\]

with sum:

6

This can be solved efficiently using Kadane's Algorithm.

def max_subarray(numbers):
current = numbers[0]
maximum = numbers[0]
for number in numbers[1:]:
current = max(number, current + number)
maximum = max(maximum, current)
return maximum

Complexity:

Time = O(n)

Space = O(1)

This is an important example of optimizing an apparently difficult problem.

4.2.26Array Rotation

Suppose:

\[1, 2, 3, 4, 5\]

is rotated to the right by two positions:

\[4, 5, 1, 2, 3\]

A simple Python approach is:

def rotate(numbers, k):
    k %= len(numbers)
return numbers[-k:] + numbers[:-k]

This creates additional lists, so the auxiliary space is proportional to n.

An in-place reversal technique can perform the rotation with:

Time = O(n)

Space = O(1)

4.2.27Advantages of Arrays

Arrays provide several benefits:

Fast indexed access

O(1)

  • Simple structure
  • Arrays are easy to understand and implement.
  • Efficient traversal
  • Sequential access is straightforward.
  • Good cache locality
  • Contiguous storage in traditional arrays can make sequential processing efficient.
  • Foundation for other data structures
  • Arrays are commonly used to implement:
  • Stacks
  • Queues
  • Heaps
  • Hash tables
  • Matrices

4.2.28Disadvantages of Arrays

Arrays also have limitations:

Inserting into the beginning or middle can be expensive.

O(n)

Deleting from the beginning or middle can be expensive.

O(n)

  • Traditional fixed-size arrays have limited capacity.
  • Maintaining ordered elements can make insertion expensive.
  • Searching an unsorted array is generally:

O(n)

4.2.29Arrays vs Linked Lists

FeatureArrayLinked List
Random accessO(1)O(n)
SearchO(n)O(n)
Insert at beginningO(n)O(1)*
Delete at beginningO(n)O(1)*
Memory layoutTypically contiguousNode-based
Cache localityGoodGenerally poorer
Extra pointer memoryNoYes

* Assuming a pointer/reference to the head is already available.

4.2.30Arrays in AI and Machine Learning

Arrays are especially important in AI and Machine Learning.

For example:

features = [5.2, 3.1, 7.8, 2.4]

A dataset can be represented as a matrix:

Feature 1 Feature 2 Feature 3

Sample 1 10 20 30

Sample 2 15 25 35

Sample 3 20 30 40

Machine-learning libraries such as NumPy represent these structures using multidimensional arrays.

For example:

import numpy as np
X = np.array([
    [10, 20, 30],
    [15, 25, 35],
\[20, 30, 40\]

])

  • Arrays therefore form the foundation for:
  • Feature matrices
  • Images
  • Tensors
  • Model parameters
  • Embeddings
  • Numerical computation
  • Matrix operations

4.2.31Important Array Problems

You should practice these problems before moving to the next data structure:

Beginner

  • Find the maximum element.
  • Find the minimum element.
  • Calculate the sum.
  • Calculate the average.
  • Reverse an array.
  • Count even and odd numbers.
  • Search for an element.
  • Find the second-largest element.
  • Remove duplicates.
  • Count frequencies.

Intermediate

  • Rotate an array.
  • Move zeros to the end.
  • Find missing number.
  • Find duplicate number.
  • Two Sum.
  • Merge two sorted arrays.
  • Find intersection of two arrays.
  • Find the maximum subarray.
  • Find leaders in an array.
  • Find a majority element.

Advanced

  • Product of array except self.
  • Three Sum.
  • Container With Most Water.
  • Trapping Rain Water.
  • Maximum Product Subarray.
  • Subarray Sum Equals K.
  • Longest Consecutive Sequence.
  • Merge Intervals.
  • Search in a Rotated Sorted Array.
  • Median of Two Sorted Arrays.

4.2.32Key Takeaways

The most important concepts to remember are:

Array indexing → O(1)

Array traversal → O(n)

Linear search → O(n)

Binary search → O(log n)

Append → O(1) amortized

Insert at beginning → O(n)

Delete at beginning → O(n)

Sorting → O(n log n)

Reverse in-place → O(n) time, O(1) space

Prefix sum → O(n) preprocessing

Hash-based lookup → O(1) average

The most important idea is that arrays provide extremely fast indexed access, but insertion and deletion in the middle or beginning can be expensive because elements may need to be shifted.

Arrays are therefore the foundation for many DSA techniques, including two pointers, sliding window, prefix sums, binary search, hashing, and dynamic programming.

Module 4 · Lesson 4.3

Strings

A string is a sequence of characters used to represent text.

Strings are one of the most frequently used data types in programming and are especially important in Data Structures & Algorithms, because many interview problems involve searching, comparing, transforming, and analyzing text.

Examples:

name = "Sreehari"
message = "Welcome to Python"
email = "sreehari@example.com"

A string can be viewed as a sequence of characters:

String: P Y T H O N

Index: 0 1 2 3 4 5

4.3.1Characteristics of Strings

  • Important characteristics of strings include:
  • Strings are sequences of characters.
  • Python strings use zero-based indexing.
  • Strings are immutable in Python.
  • Individual characters can be accessed using indexes.
  • Strings support slicing.

Strings can be searched, compared, split, joined, and transformed.

Python provides many built-in string methods.

Example:

text = "Python"
print(text[0])
print(text[2])

Output:

P

t

4.3.2Creating Strings

Strings can be created using single quotes:

name = 'Sreehari'

Double quotes:

name = "Sreehari"

Triple quotes:

message = """This is

a multiline

  • string."""
  • Single and double quotes are commonly used for normal strings.
  • Triple-quoted strings are useful for multiline text and documentation.

4.3.3String Indexing

Python strings use zero-based indexing.

text = "Python"

The indexes are:

P y t h o n

↓ ↓ ↓ ↓ ↓ ↓

0 1 2 3 4 5

Example:

print(text[0])
print(text[3])
print(text[5])

Output:

  • P
  • h
  • n

4.3.4Negative Indexing

Python also supports negative indexes.

P y t h o n

-6 -5 -4 -3 -2 -1

Example:

text = "Python"
print(text[-1])
print(text[-2])

Output:

n

o

This is particularly useful when accessing characters from the end.

4.3.5String Length

Use len() to find the number of characters.

text = "Python"
print(len(text))

Output:

6

Time complexity:

O(1)

for Python strings because the length is stored as part of the string object's metadata.

4.3.6String Slicing

Slicing extracts part of a string.

Syntax:

string[start:end]

Example:

text = "Python"
print(text[0:3])

Output:

  • Pyt
  • The end index is excluded.
  • Other Examples
text = "Python"
print(text[:3])
print(text[2:])
print(text[1:5])
print(text[:])

Output:

  • Pyt
  • thon
  • ytho
  • Python

4.3.7String Step

Slicing also supports a step.

text = "Python"
print(text[::2])

Output:

Pto

This takes every second character.

4.3.8Reversing a String

A simple Python technique is:

text = "Python"
reversed_text = text[::-1]
print(reversed_text)

Output:

nohtyP

Time complexity:

O(n)

Space complexity:

O(n)

because a new string is created.

4.3.9Strings Are Immutable

One of the most important properties of Python strings is immutability.

Consider:

text = "Python"

text[0] = "J"

This produces an error because individual characters cannot be modified directly.

Instead, create a new string:

text = "Python"
text = "J" + text[1:]
print(text)

Output:

Jython

4.3.10Why String Immutability Matters

Consider repeatedly modifying a string:

text = ""
for i in range(1000):
    text += str(i)

Because strings are immutable, repeated concatenation can create many intermediate string objects.

For large-scale string construction, use a list and join():

parts = []
for i in range(1000):
    parts.append(str(i))
text = "".join(parts)

This is generally more efficient.

4.3.11String Concatenation

Strings can be joined using +.

first_name = "Sreehari"
last_name = "Mekala"
full_name = first_name + " " + last_name
print(full_name)

Output:

Sreehari Mekala

For many pieces, prefer:

words = ["Python", "is", "powerful"]
sentence = " ".join(words)
print(sentence)

Output:

Python is powerful

4.3.12Membership Testing

Use in to determine whether a substring or character exists.

text = "Python Programming"
print("Python" in text)
print("Java" in text)

Output:

True

False

For a general substring search, the complexity can be proportional to the lengths of the strings involved.

For simple DSA analysis, it is important not to automatically assume every string operation is O(1).

4.3.13Searching for a Character

text = "Python"
print(text.find("t"))

Output:

2

If the substring is not found:

print(text.find("z"))

Output:

-1

4.3.14index() vs find()

Both can locate substrings.

text = "Python"
print(text.find("t"))
  • returns:
  • 2
  • But:
  • text.index("z")

raises a ValueError if the substring is not found.

Therefore:

find() → returns -1

index() → raises an exception

4.3.15Counting Characters

Use count():

text = "banana"
print(text.count("a"))

Output:

3

This is useful in frequency-related problems.

4.3.16Converting Case

Python provides several methods.

text = "Python Programming"
print(text.upper())
print(text.lower())
print(text.title())

Output:

  • PYTHON PROGRAMMING
  • python programming
  • Python Programming
  • Other useful methods include:
  • text.capitalize()
  • text.swapcase()

4.3.17Checking String Properties

Python provides useful methods:

text = "Python123"
print(text.isalpha())
print(text.isdigit())
print(text.isalnum())

Output:

  • False
  • False
  • True
  • Other methods include:
  • isupper()
  • islower()
  • isspace()
  • startswith()
  • endswith()

4.3.18Removing Whitespace

Use strip():

text = "   Python   "
print(text.strip())

Output:

  • Python
  • Related methods:
  • text.lstrip()
  • text.rstrip()
  • strip() → both sides
  • lstrip() → left side
  • rstrip() → right side

4.3.19Splitting Strings

The split() method converts a string into a list.

text = "Python is easy"
words = text.split()
print(words)

Output:

\['Python', 'is', 'easy'\]

You can specify a delimiter:

data = "apple,banana,orange"
fruits = data.split(",")
print(fruits)

Output:

\['apple', 'banana', 'orange'\]

4.3.20Joining Strings

The opposite of split() is often join().

words = ["Python", "is", "easy"]
sentence = " ".join(words)
print(sentence)

Output:

Python is easy

Another example:

letters = ["P", "y", "t", "h", "o", "n"]
word = "".join(letters)
print(word)

Output:

Python

4.3.21String Formatting

Python supports several approaches.

f-strings

name = "Sreehari"
age = 37
message = f"My name is {name} and I am {age} years old."
print(message)

f-strings are generally the preferred modern Python approach.

4.3.22Comparing Strings

Strings can be compared using comparison operators.

a = "apple"
b = "banana"
print(a == b)
print(a != b)

Output:

  • False
  • True
  • Lexicographical comparison is also possible:
print("apple" < "banana")

Output:

True

The comparison follows character ordering rules.

4.3.23String Traversal

A string can be traversed character by character.

text = "Python"
for character in text:
print(character)

Output:

  • P
  • y
  • t
  • h
  • o
  • n
  • Time complexity:

O(n)

4.3.24Character Frequency

One of the most common string interview problems is counting character frequencies.

def character_frequency(text):
    frequency = {}
    for character in text:
        frequency[character] = frequency.get(character, 0) + 1
return frequency

Example:

text = "banana"
print(character_frequency(text))

Result:

{'b': 1, 'a': 3, 'n': 2}

Typical complexity:

Time = O(n)

Space = O(k)

where k is the number of distinct characters.

4.3.25Check for Palindrome

  • A palindrome reads the same forward and backward.
  • Examples:
  • madam
  • level
  • racecar
  • A simple solution:
def is_palindrome(text):
return text == text[::-1]

Example:

print(is_palindrome("madam"))

Output:

True

Complexity:

Time = O(n)

Space = O(n)

because slicing creates a reversed string.

4.3.26Palindrome Using Two Pointers

We can avoid creating a reversed copy.

def is_palindrome(text):
left = 0
right = len(text) - 1
while left < right:
if text[left] != text[right]:
return False

left += 1

right -= 1

return True

Complexity:

Time = O(n)

Space = O(1)

This is an important example of the two-pointer technique.

4.3.27Reverse Words in a String

  • Given:
  • "Python is powerful"
  • we may want:
  • "powerful is Python"

Solution:

def reverse_words(text):
words = text.split()

words.reverse()

return " ".join(words)
  • Result:
  • powerful is Python
  • Typical complexity:

Time = O(n)

Space = O(n)

4.3.28Anagram

  • Two strings are anagrams if they contain the same characters with the same frequencies.
  • For example:
  • listen
  • silent
  • are anagrams.
  • A simple solution:
def are_anagrams(a, b):
return sorted(a) == sorted(b)
  • Complexity:
  • Time = O(n log n)
  • because sorting is required.
  • A frequency-based solution can achieve approximately:
  • Time = O(n)
def are_anagrams(a, b):
    if len(a) != len(b):
        return False
        counts = {}
        for character in a:
            counts[character] = counts.get(character, 0) + 1
for character in b:
if character not in counts:
return False

counts[character] -= 1

return all(value == 0 for value in counts.values())

4.3.29Remove Duplicate Characters

Suppose:

"programming"

We want unique characters while preserving their first occurrence.

def remove_duplicates(text):
    seen = set()
    result = []
    for character in text:
        if character not in seen:
            seen.add(character)

result.append(character)

return "".join(result)

Example:

print(remove_duplicates("programming"))
  • Result:
  • progamin
  • Typical complexity:

Time = O(n)

Space = O(k)

where k is the number of distinct characters.

4.3.30First Non-Repeating Character

  • Given:
  • "swiss"
  • The first non-repeating character is:

"w"

Solution:

def first_unique(text):
    frequency = {}
    for character in text:
        frequency[character] = frequency.get(character, 0) + 1
for character in text:
if frequency[character] == 1:
return character
return None

Complexity:

Time = O(n)

Space = O(k)

4.3.31Check if Two Strings Are Rotations

  • Consider:
  • "abcd"
  • A rotation could be:
  • "cdab"
  • A useful observation is:
  • "abcd" + "abcd"
  • contains:
  • "cdab"

Therefore:

def are_rotations(a, b):
return len(a) == len(b) and b in (a + a)

Example:

print(are_rotations("abcd", "cdab"))

Output:

True

4.3.32Longest Common Prefix

Given:

\["flower", "flow", "flight"\]

the longest common prefix is:

"fl"

A simple approach:

def longest_common_prefix(words):
if not words:
return ""
prefix = words[0]
for word in words[1:]:
while not word.startswith(prefix):
prefix = prefix[:-1]
if not prefix:
return ""
return prefix

This problem introduces important ideas related to:

  • String comparison
  • Prefix searching
  • Iterative reduction

A Trie, covered later in this module, provides another powerful approach to prefix-related problems.

4.3.33Substrings

  • A substring is a contiguous sequence of characters.
  • For:
  • "abc"
  • some substrings are:
  • a
  • b
  • c
  • ab
  • bc
  • abc

For a string of length n, the number of possible non-empty substrings is:

n(n + 1) / 2

Therefore, there can be:

O(n²)

substrings.

4.3.34Subsequences

  • A subsequence does not have to contain contiguous characters.
  • For:
  • "abc"
  • examples include:
  • a
  • b
  • c
  • ab
  • ac
  • bc
  • abc
  • Unlike substrings, characters can be skipped.

For a string of length n, there are:

2ⁿ

possible subsequences, including the empty subsequence.

Therefore, problems involving all subsequences can often have exponential complexity.

4.3.35Substring vs Subsequence

FeatureSubstringSubsequence
Characters contiguousYesNo
Order preservedYesYes
Example from abcdebcdace
Number of possibilitiesO(n²)O(2ⁿ)

This distinction is extremely important in DSA problems.

4.3.36Longest Substring Without Repeating Characters

This is a classic interview problem.

Given:

"abcabcbb"

the longest substring without repeated characters is:

  • "abc"
  • with length:
  • 3
  • A brute-force approach can be expensive.

The efficient solution uses the sliding window technique:

def longest_unique_substring(text):
    seen = set()
    left = 0
    maximum = 0
    for right in range(len(text)):
        while text[right] in seen:
            seen.remove(text[left])

left += 1

seen.add(text[right])

maximum = max(maximum, right - left + 1)
return maximum

Complexity:

Time = O(n)

Space = O(k)

where k is the number of distinct characters.

4.3.37String Matching

  • String matching means finding one string inside another.
  • For example:
  • Text:
  • "Python programming language"
  • Pattern:
  • "programming"
  • We want to determine where the pattern occurs.
  • Python provides:
  • text.find(pattern)

For advanced DSA, important string-matching algorithms include:

  • Naive String Matching
  • KMP Algorithm
  • Rabin-Karp Algorithm
  • Boyer-Moore Algorithm

These algorithms become important when processing large amounts of text efficiently.

4.3.38Naive String Matching

Suppose:

Text    = "ABABDABACD"
Pattern = "ABAC"

The naive approach compares the pattern at each possible position.

In the worst case, its complexity can be approximately:

O(n × m)

  • where:
  • n = text length
  • m = pattern length

4.3.39KMP Algorithm

The Knuth-Morris-Pratt (KMP) algorithm improves string matching by avoiding unnecessary comparisons.

It builds an auxiliary structure called the:

LPS array

  • LPS stands for:
  • Longest Proper Prefix which is also a Suffix.
  • KMP can perform pattern matching in:

O(n + m)

time.

It is an important advanced string algorithm.

4.3.40String Complexity Summary

OperationTypical Complexity
Access character by indexO(1)
Traverse stringO(n)
Slice stringO(k)
Reverse using slicingO(n)
Search substringDepends on algorithm
split()O(n) approximately
join()O(n) approximately
count()O(n) approximately
Convert caseO(n)
Concatenating two stringsO(n + m)
Sort charactersO(n log n)

Because Python strings are immutable, many operations create new strings.

4.3.41Strings in AI and Machine Learning

  • String processing is extremely important in AI.
  • Examples include:
  • Natural Language Processing
  • "Artificial intelligence is transforming industries."
  • can be processed into:
\["Artificial", "intelligence", "is", "transforming", "industries"\]
  • Applications
  • Strings are used in:
  • Text classification
  • Sentiment analysis
  • Chatbots
  • Search engines
  • Document processing
  • Named Entity Recognition
  • Tokenization
  • Text embeddings
  • Large Language Models
  • Email analysis
  • Resume processing

String processing is therefore an important foundation for NLP and Generative AI.

4.3.42Important String Interview Problems

Beginner

  • Reverse a string
  • Check palindrome
  • Count characters
  • Count vowels
  • Count words
  • Convert uppercase to lowercase
  • Remove spaces
  • Find string length
  • Find character frequency
  • Remove duplicate characters

Intermediate

  • Check anagrams
  • First non-repeating character
  • Reverse words
  • Longest common prefix
  • Check string rotation
  • Longest substring without repeating characters
  • Count substring occurrences
  • Compress a string
  • Group anagrams
  • Validate parentheses

Advanced

  • KMP string matching
  • Rabin-Karp
  • Longest palindromic substring
  • Longest palindromic subsequence
  • Minimum window substring
  • Edit distance
  • Word Break
  • Regular expression matching
  • Implement Trie
  • Word Search

4.3.43Key Takeaways

Remember these important concepts:

String indexing → O(1)

String traversal → O(n)

String slicing → O(k)

String reversal → O(n)

Character frequency → O(n)

Palindrome check → O(n)

Anagram using sorting → O(n log n)

Anagram using hashing → O(n) average

Longest unique substring → O(n) using sliding window

KMP string matching → O(n + m)

String construction → Prefer list + join for many pieces

The most important concepts to master from this section are string immutability, indexing, slicing, frequency counting, palindrome checking, anagrams, substring vs subsequence, two pointers, sliding window, and string matching. These concepts will be reused extensively in later sections such as Hash Tables, Recursion, Searching, Sliding Window, Two Pointers, Trie, Dynamic Programming, and Coding Interview Practice.

Module 4 · Lesson 4.4

Linked Lists

A Linked List is a linear data structure in which elements are stored inside individual nodes, and each node contains a reference to another node.

Unlike arrays, linked-list elements are not required to be stored next to each other in memory.

A simple singly linked list looks like this:

\[10 | •] → [20 | •] → [30 | •] → [40 | None\]

Head

  • Each node contains:
  • Data — the value stored in the node.
  • Next — a reference to the next node.

4.4.1Why Linked Lists?

Arrays provide very fast random access:

array[index] → O(1)

However, inserting or deleting elements near the beginning or middle can require shifting many elements.

  • Linked lists solve this problem differently.
  • For example:
  • 10 → 20 → 40

To insert 30 between 20 and 40, we can change references:

10 → 20 → 30 → 40

No shifting of all subsequent elements is required.

This makes linked lists particularly useful when frequent insertions and deletions are required.

4.4.2Structure of a Node

A singly linked-list node can be represented as:

  • Node
  • ├── data
  • └── next
  • In Python:
class Node:
    def __init__(self, data):
        self.data = data
  • self.next = None
  • Creating a node:
  • node = Node(10)
print(node.data)
print(node.next)

Output:

10

None

4.4.3Creating a Simple Linked List

We can manually connect nodes:

class Node:
    def __init__(self, data):
        self.data = data
  • self.next = None
  • first = Node(10)
  • second = Node(20)
  • third = Node(30)
  • first.next = second
  • second.next = third
  • The structure becomes:
  • first

\[10 | •] → [20 | •] → [30 | None\]

The first node is called the head.

4.4.4Head of a Linked List

  • The head is a reference to the first node.
  • head = first
  • Conceptually:
  • head

  • 10 → 20 → 30 → None
  • If the linked list is empty:
  • head = None
  • The list is:
  • None

4.4.5Traversing a Linked List

Unlike arrays, we cannot directly access a linked-list element by index.

We start at the head and follow next references.

def print_list(head):
current = head
while current is not None:
print(current.data)
current = current.next
  • For:
  • 10 → 20 → 30 → None
  • the output is:
  • 10
  • 20
  • 30
  • Time complexity:

O(n)

4.4.6Creating a LinkedList Class

A better implementation is to create a class representing the entire list.

class Node:
    def __init__(self, data):
        self.data = data

self.next = None

class LinkedList:
    def __init__(self):
        self.head = None
  • Now:
  • linked_list = LinkedList()
  • Initially:
  • head → None

4.4.7Inserting at the Beginning

Suppose we have:

  • 20 → 30 → 40
  • and want to insert 10 at the beginning.
  • We create a new node:
  • 10

Then point it to the current head:

10 → 20 → 30 → 40

Implementation:

def insert_at_beginning(self, data):
new_node = Node(data)
  • new_node.next = self.head
  • self.head = new_node
  • Time complexity:

O(1)

This is one of the major advantages of linked lists.

4.4.8Inserting at the End

  • Suppose:
  • 10 → 20 → 30
  • We want:
  • 10 → 20 → 30 → 40

We need to traverse to the last node.

def insert_at_end(self, data):
    new_node = Node(data)
    if self.head is None:
        self.head = new_node

return

current = self.head
while current.next is not None:
current = current.next
  • current.next = new_node
  • Without a tail pointer:
  • Time = O(n)

If the list maintains a tail reference, insertion at the end can be:

O(1)

4.4.9Searching a Linked List

To find a value, traverse the list.

def search(self, target):
current = self.head
while current is not None:
if current.data == target:
return True
current = current.next
return False

Complexity:

Best Case = O(1)

Worst Case = O(n)

Space = O(1)

4.4.10Deleting the First Node

  • Suppose:
  • 10 → 20 → 30
  • To remove 10:
  • 20 → 30
  • We simply move the head:
  • self.head = self.head.next
  • Complexity:

O(1)

4.4.11Deleting a Node by Value

  • Suppose:
  • 10 → 20 → 30 → 40
  • and we want to delete 30.

We need to find the node before it:

20

Then change:

  • 20.next
  • from:
  • 30
  • to:
  • 40
  • Implementation:
def delete(self, target):
    if self.head is None:
        return
if self.head.data == target:
    self.head = self.head.next

return

current = self.head
while current.next is not None:
    if current.next.data == target:
        current.next = current.next.next

return

current = current.next

Worst-case complexity:

Time = O(n)

4.4.12Linked List Complexity

OperationSingly Linked List
Access by indexO(n)
SearchO(n)
Insert at beginningO(1)
Delete at beginningO(1)
Insert at end*O(n)
Delete at end*O(n)
Insert after known nodeO(1)
Delete after known previous nodeO(1)
  • * Without an appropriate tail pointer.
  • The key difference from arrays is:
  • Array:
  • Random access → O(1)
  • Linked List:
  • Random access → O(n)

4.4.13Types of Linked Lists

  • There are three major types:
  • Singly Linked List
  • Doubly Linked List
  • Circular Linked List

4.4.14Singly Linked List

  • Each node contains:
  • Data
  • Next
  • Structure:
  • 10 → 20 → 30 → 40 → None

Traversal is normally possible only in the forward direction.

Python implementation:

class Node:
    def __init__(self, data):
        self.data = data

self.next = None

4.4.15Doubly Linked List

A doubly linked list contains two references in each node:

  • Previous
  • Data
  • Next
  • Structure:
  • None ← 10 ⇄ 20 ⇄ 30 → None
  • A node can move:
  • Forward
  • Backward
  • Node implementation:
class Node:
    def __init__(self, data):
        self.data = data

self.prev = None

self.next = None

4.4.16Advantages of Doubly Linked Lists

  • Doubly linked lists provide:
  • Forward traversal
  • Backward traversal
  • Efficient deletion when the target node is already known
  • Convenient insertion before or after a node

However, every node requires an additional prev reference.

Therefore, memory usage is higher than a singly linked list.

4.4.17Circular Linked List

  • In a circular linked list, the last node points back to the first node.
  • Instead of:
  • 10 → 20 → 30 → None
  • we have:
  • 10 → 20 → 30

↑ ↓

└─────────┘

The last node's next points to the head.

4.4.18Circular Singly Linked List

Example:

10 → 20 → 30

↑ ↓

└─────────┘

  • The structure can be useful for problems involving repeated cycles.
  • Examples include:
  • Round-robin scheduling
  • Circular buffers
  • Repeating processes
  • Multiplayer turn systems

4.4.19Circular Doubly Linked List

  • A circular doubly linked list combines both concepts.
  • Each node contains:
  • prev
  • data
  • next
  • and:
  • head.prev → tail
  • tail.next → head

This allows circular traversal in both directions.

4.4.20Array vs Linked List

FeatureArrayLinked List
Random accessO(1)O(n)
SearchO(n)O(n)
Insert at beginningO(n)O(1)
Delete at beginningO(n)O(1)
MemoryLower overheadExtra pointer/reference
Cache localityGenerally betterGenerally poorer
Dynamic sizePython lists are dynamicNaturally dynamic
Binary searchPossible on sorted arrayInefficient

The choice depends on the problem.

4.4.21Reversing a Linked List

  • Reversing a linked list is one of the most important interview problems.
  • Given:
  • 10 → 20 → 30 → None
  • we want:
  • 30 → 20 → 10 → None
  • The iterative solution uses three references:
  • previous
  • current
  • next
  • Implementation:
def reverse(self):
previous = None
current = self.head
while current is not None:
next_node = current.next

current.next = previous

previous = current

current = next_node

self.head = previous

Complexity:

Time = O(n)

Space = O(1)

This is an essential DSA pattern.

4.4.22Understanding Reversal

  • Suppose:
  • 10 → 20 → 30 → None
  • Initially:
  • previous = None
current  = 10

First iteration:

None ← 10 20 → 30

previous

Next:

None ← 10 ← 20 30

  • previous
  • Finally:
  • None ← 10 ← 20 ← 30

  • previous
  • Then:
  • head = previous
  • Result:
  • 30 → 20 → 10 → None

4.4.23Recursive Reversal

A linked list can also be reversed recursively.

def reverse_recursive(self, node):
if node is None or node.next is None:
return node
new_head = self.reverse_recursive(node.next)

node.next.next = node

node.next = None

return new_head

The recursive approach has:

Time = O(n)

Space = O(n)

because of the recursion call stack.

The iterative approach is usually preferred when constant auxiliary space is desired.

4.4.24Finding the Middle Node

  • A classic linked-list problem is finding the middle element.
  • Suppose:
  • 10 → 20 → 30 → 40 → 50
  • The middle is:
  • 30

A very useful technique is the slow and fast pointer technique.

def find_middle(head):
slow = head
fast = head
while fast is not None and fast.next is not None:
slow = slow.next
fast = fast.next.next
return slow
  • The slow pointer moves one step while the fast pointer moves two.
  • When the fast pointer reaches the end, the slow pointer is at the middle.
  • Complexity:

Time = O(n)

Space = O(1)

4.4.25Detecting a Cycle

Consider:

10 → 20 → 30 → 40

↑ ↓

└────┘

The linked list contains a cycle.

The Floyd Cycle Detection Algorithm uses two pointers:

Slow pointer moves one step.

Fast pointer moves two steps.

def has_cycle(head):
slow = head
fast = head
while fast is not None and fast.next is not None:
slow = slow.next
fast = fast.next.next
if slow == fast:
return True
return False

Complexity:

Time = O(n)

Space = O(1)

This is one of the most important linked-list interview techniques.

4.4.26Finding the Nth Node from the End

Suppose:

10 → 20 → 30 → 40 → 50

We want the second node from the end:

40

Use two pointers.

def nth_from_end(head, n):
first = head
second = head
for _ in range(n):
if first is None:
return None
first = first.next
while first is not None:
first = first.next
second = second.next
return second

The two pointers maintain a gap of n nodes.

Complexity:

Time = O(n)

Space = O(1)

4.4.27Merging Two Sorted Linked Lists

  • Suppose we have:
  • List 1:
  • 1 → 3 → 5
  • List 2:
  • 2 → 4 → 6
  • The merged list is:

1 → 2 → 3 → 4 → 5 → 6

Implementation:

def merge_lists(a, b):
    dummy = Node(0)
    current = dummy
    while a is not None and b is not None:
        if a.data <= b.data:
            current.next = a
a = a.next
else:
    current.next = b
b = b.next
current = current.next
if a is not None:
    current.next = a
else:
    current.next = b
return dummy.next

If the lists contain n and m nodes:

Time = O(n + m)

Space = O(1)

assuming no new nodes are created for the merged sequence.

4.4.28Removing Duplicates

  • Consider:
  • 10 → 20 → 20 → 30 → 30
  • We want:
  • 10 → 20 → 30
  • For a sorted linked list:
def remove_duplicates(head):
    current = head
    while current is not None and current.next is not None:
        if current.data == current.next.data:
            current.next = current.next.next
else:
current = current.next
return head

Because the list is sorted, duplicate values are adjacent.

Complexity:

Time = O(n)

Space = O(1)

4.4.29Intersection of Two Linked Lists

Suppose two linked lists eventually share the same node:

List A:

1 → 2 → 3

\

7 → 8

List B:

4 → 5 → 6 ─┘

The intersection begins at:

7

A useful two-pointer technique allows us to find the intersection in:

Time = O(n + m)

Space = O(1)

The key idea is that each pointer switches to the other list after reaching the end, equalizing the distance traveled.

4.4.30Palindrome Linked List

A linked list can be checked for palindrome structure.

Example:

  • 1 → 2 → 3 → 2 → 1
  • A common efficient approach is:
  • Find the middle.
  • Reverse the second half.
  • Compare both halves.
  • Optionally restore the list.
  • This combines several important techniques:
  • Slow/Fast Pointers

+

Linked List Reversal

+

Two Pointers

Typical complexity:

Time = O(n)

Space = O(1)

4.4.31Dummy Node Technique

A dummy node is an extra node placed before the actual head.

Example:

  • dummy → 10 → 20 → 30
  • It simplifies operations involving the first node.
  • For example:
  • dummy = Node(0)
  • dummy.next = head
  • Then:
  • dummy.next
  • represents the actual head.
  • Dummy nodes are particularly useful in:
  • Merging lists
  • Removing nodes
  • Inserting nodes
  • Partitioning lists
  • They reduce special-case logic.

4.4.32Common Linked List Interview Patterns

  • Pattern 1 — Two Pointers
  • Used for:
  • Finding middle
  • Detecting cycles
  • Finding nth node from end
  • Comparing sections
  • Pattern 2 — Reversal
  • Used for:
  • Reversing entire list
  • Reversing a portion
  • Palindrome problems
  • Pattern 3 — Dummy Node
  • Used for:
  • Merging
  • Deleting
  • Inserting
  • Partitioning
  • Pattern 4 — Fast and Slow Pointers
  • Used for:
  • Middle node
  • Cycle detection
  • Cycle entry

These patterns are more important than simply memorizing linked-list methods.

4.4.33Advantages of Linked Lists

1. Dynamic Size

A linked list can grow and shrink naturally.

2. Efficient Insertion

Insertion at the beginning is:

O(1)

3. Efficient Deletion

Deletion at the beginning is:

O(1)

4. No Element Shifting

Unlike arrays, linked lists don't need to shift every subsequent element after an insertion or deletion.

5. Useful Building Block

Linked lists can be used to implement:

  • Stacks
  • Queues
  • Hash-table buckets
  • Graph adjacency structures

4.4.34Disadvantages of Linked Lists

1. Slow Random Access

Accessing the nth node requires traversal:

O(n)

2. Extra Memory

  • Each node needs references such as:
  • next
  • or:
  • prev + next

3. Poor Cache Locality

Nodes may be located in different memory locations.

4. More Complex Implementation

Pointers/references must be maintained carefully.

5. Binary Search Is Not Efficient

Although a linked list can theoretically contain sorted values, finding the middle repeatedly requires traversal, so binary search does not provide the same practical benefit as it does with arrays.

4.4.35Linked List vs Array

A useful way to remember the difference:

ARRAY

\[10][20][30][40][50\]

  • Fast random access
  • versus:
  • LINKED LIST
\[10|•] → [20|•] → [30|•] → [40|•\]

  • Head
  • Array
  • Best when you need:
  • Fast indexing
  • Frequent reads
  • Good cache locality
  • Efficient sorting/searching
  • Linked List
  • Best when you need:
  • Frequent insertion/deletion
  • Sequential processing
  • Flexible node-based structures

4.4.36Linked Lists in Real-World Systems

  • Linked-list concepts appear in many systems.
  • Examples include:
  • Operating system scheduling
  • Memory management
  • Browser navigation structures
  • Undo/redo systems
  • Music playlists
  • Hash-table chaining
  • Graph representations
  • LRU cache implementations

A particularly important application is the LRU Cache, which commonly combines:

Hash Map + Doubly Linked List

This combination allows efficient lookup and ordering.

4.4.37Important Linked List Problems

Beginner

  • Create a linked list.
  • Traverse a linked list.
  • Insert at beginning.
  • Insert at end.
  • Insert at a given position.
  • Delete first node.
  • Delete last node.
  • Delete by value.
  • Search for a value.
  • Count nodes.

Intermediate

  • Reverse a linked list.
  • Find the middle node.
  • Find nth node from the end.
  • Detect a cycle.
  • Remove duplicates.
  • Merge two sorted lists.
  • Find intersection of two lists.
  • Check palindrome.
  • Remove nth node from the end.
  • Partition a linked list.

Advanced

  • Find the start of a cycle.
  • Reverse nodes in groups of k.
  • Sort a linked list.
  • Merge k sorted linked lists.
  • Copy a list with random pointers.
  • Flatten a multilevel linked list.
  • Implement an LRU Cache.
  • Rotate a linked list.
  • Add two numbers represented by linked lists.
  • Reorder a linked list.

4.4.38Complexity Summary

OperationSingly Linked List
Access first nodeO(1)
Access nth nodeO(n)
SearchO(n)
Insert at headO(1)
Delete at headO(1)
Insert after known nodeO(1)
Delete after known previous nodeO(1)
Insert at end without tailO(n)
Insert at end with tailO(1)
ReverseO(n)
Find middleO(n)
Detect cycleO(n)
Extra space for iterative algorithmsO(1)

4.4.39Key Takeaways

The most important concepts to remember are:

  • Node
  • ├── Data
  • └── Next
  • Singly Linked List
  • 10 → 20 → 30 → None
  • Doubly Linked List
  • None ← 10 ⇄ 20 ⇄ 30 → None
  • Circular Linked List
  • 10 → 20 → 30

↑ ↓

└─────────┘

And the most important interview techniques are:

  • Two Pointers
  • Fast & Slow Pointers
  • Linked List Reversal
  • Dummy Node
  • Cycle Detection
  • Merge Sorted Lists
  • Most important complexities

Access by index → O(n)

Search → O(n)

Insert at head → O(1)

Delete at head → O(1)

Reverse → O(n)

Find middle → O(n)

Detect cycle → O(n)

The central idea is:

Arrays are optimized for fast random access, while linked lists are optimized for efficient insertion and deletion when the relevant node position is already known.

Understanding this tradeoff is essential before moving to 4.5 Stacks, because stacks can be implemented efficiently using either arrays or linked lists.

Module 4 · Lesson 4.5

Stacks

A Stack is a linear data structure that follows the LIFO (Last In, First Out) principle.

This means the element added last is the element removed first.

A real-world example is a stack of plates:

┌───────┐

│ Plate │ ← Last added / First removed

├───────┤

│ Plate │

├───────┤

│ Plate │

├───────┤

│ Plate │ ← First added / Last removed

└───────┘

  • If we add:
  • 10 → 20 → 30
  • the stack becomes:
TOP
30

20

10

When we remove an element, 30 is removed first.

4.5.1LIFO Principle

LIFO stands for:

Last In, First Out

Example:

  • Push 10
  • Push 20
  • Push 30
  • Stack:
  • TOP → 30
  • 20
  • 10
  • Now:
  • Pop()
  • returns:
  • 30
  • The stack becomes:
  • TOP → 20
  • 10

4.5.2Basic Stack Operations

A stack typically supports these operations:

OperationMeaning
push()Add an element
pop()Remove the top element
peek() / top()View the top element
is_empty()Check whether stack is empty
size()Get number of elements

The top is the only end from which normal stack operations are performed.

4.5.3Push Operation

Push adds an element to the top of the stack.

Starting stack:

TOP
20

10

Push 30:

TOP
30
  • 20
  • 10
  • In Python:
stack = []
  • stack.append(10)
  • stack.append(20)
  • stack.append(30)
print(stack)

Output:

\[10, 20, 30\]
  • The right side represents the top.
  • Time complexity:
  • O(1) amortized

4.5.4Pop Operation

Pop removes the top element.

stack = [10, 20, 30]
value = stack.pop()
print(value)
print(stack)

Output:

30

\[10, 20\]

Time complexity:

O(1)

4.5.5Peek Operation

Peek returns the top element without removing it.

stack = [10, 20, 30]
print(stack[-1])

Output:

30

The stack remains:

\[10, 20, 30\]

Complexity:

O(1)

4.5.6Checking Whether a Stack Is Empty

stack = []
if not stack:
print("Stack is empty")

Another approach:

if len(stack) == 0:
print("Stack is empty")

Complexity:

O(1)

4.5.7Stack Using Python List

The simplest implementation is:

stack = []
  • stack.append(10)
  • stack.append(20)
  • stack.append(30)
print(stack.pop())
print(stack[-1])

Output:

30

20

Python's list provides the required operations efficiently when we use the end of the list as the stack top.

  • Avoid using:
  • stack.insert(0, value)
  • stack.pop(0)

because operations at the beginning of a Python list require shifting elements and are generally O(n).

4.5.8Implementing a Stack Class

We can create our own stack abstraction:

class Stack:
    def __init__(self):
        self.items = []
def push(self, value):
    self.items.append(value)
def pop(self):
if self.is_empty():
return None
return self.items.pop()
def peek(self):
if self.is_empty():
return None
return self.items[-1]
def is_empty(self):
return len(self.items) == 0
def size(self):
return len(self.items)
  • Usage:
  • stack = Stack()
  • stack.push(10)
  • stack.push(20)
  • stack.push(30)
print(stack.peek())
print(stack.pop())
print(stack.size())

Output:

  • 30
  • 30
  • 2

4.5.9Stack Using Linked List

  • A stack can also be implemented using a linked list.
  • Consider:
  • TOP

  • 30 → 20 → 10 → None
  • The head of the linked list acts as the stack top.
  • Node
class Node:
    def __init__(self, data):
        self.data = data

self.next = None

Stack

class Stack:
    def __init__(self):
        self.top = None
def push(self, data):
new_node = Node(data)

new_node.next = self.top

self.top = new_node

def pop(self):
if self.top is None:
return None
value = self.top.data

self.top = self.top.next

return value
def peek(self):
if self.top is None:
return None
return self.top.data

Both push() and pop() operate at the head.

Therefore:

Push → O(1)

Pop → O(1)

Peek → O(1)

4.5.10Array-Based Stack vs Linked-List Stack

FeatureArray/List StackLinked-List Stack
PushO(1) amortizedO(1)
PopO(1)O(1)
PeekO(1)O(1)
Memory overheadLowerHigher
Dynamic growthYes in Python listNaturally dynamic
Cache localityGenerally betterGenerally poorer
ImplementationSimpleMore complex

For most Python applications, a list is the simplest choice.

4.5.11Stack Overflow

  • In a fixed-size stack, stack overflow occurs when we try to push an element into a full stack.
  • For example:
  • Maximum capacity = 3
\[30\]
\[20\]
\[10\]
  • Trying:
  • push(40)
  • causes overflow.

With Python's dynamically growing list, the concept is different: memory exhaustion rather than a fixed stack capacity is the practical limitation.

4.5.12Stack Underflow

Stack underflow occurs when we try to remove an element from an empty stack.

Example:

stack = []
  • stack.pop()
  • Python raises an IndexError.
  • A custom implementation should usually check:
if not stack:
return None

before popping.

4.5.13Stack Complexity

OperationComplexity
PushO(1) amortized
PopO(1)
PeekO(1)
Is EmptyO(1)
SizeO(1)
SearchO(n)

The key advantage of a stack is that its primary operations are constant time.

4.5.14Applications of Stacks

  • Stacks are used extensively in computer science.
  • Important applications include:
  • Function calls
  • Recursion
  • Undo/Redo
  • Browser history
  • Expression evaluation
  • Parentheses matching
  • Syntax parsing
  • Depth-First Search
  • Backtracking
  • Monotonic stack problems

4.5.15Call Stack

One of the most important applications of stacks is function execution.

Consider:

def first():
    second()
def second():
    third()
def third():
print("Hello")

first()

The call stack conceptually becomes:

TOP
third()
  • second()
  • first()
  • main()
  • When third() finishes:
  • third() → removed
  • Then:
  • second() → removed
  • Then:
  • first() → removed

This is why recursion is closely connected to stacks.

4.5.16Stack and Recursion

Consider:

def countdown(n):
    if n == 0:
        return
print(n)
  • countdown(n - 1)
  • Calling:
  • countdown(3)
  • creates:
  • countdown(3)
  • countdown(2)
  • countdown(1)
  • countdown(0)

These calls are stored on the call stack.

Therefore, recursive algorithms can consume O(n) stack space when recursion depth is n.

4.5.17Balanced Parentheses

One of the most common stack problems is checking whether parentheses are balanced.

For example:

( )

( [ ] )

{ [ ( ) ] }

are valid.

But:

( ]

( [ )

{ [ } ]

are invalid.

A stack can solve this efficiently.

def is_balanced(expression):
    stack = []
    pairs = {
        ")": "(",
        "]": "[",
        "}": "{"
    }
for character in expression:
    if character in "([{":
        stack.append(character)
        elif character in ")]}":
        if not stack or stack.pop() != pairs[character]:
            return False
            return not stack

Example:

print(is_balanced("{[()]}"))

Output:

True

Complexity:

Time = O(n)

Space = O(n)

4.5.18Why a Stack Works for Parentheses

Consider:

{ [ ( ) ] }

  • Process:
  • { → push
  • [ → push
  • ( → push

) → pop (

] → pop [

} → pop {

Every closing bracket must match the most recently opened bracket.

That is exactly the LIFO behavior of a stack.

4.5.19Reverse a String Using a Stack

A stack can reverse a string.

def reverse_string(text):
    stack = []
    for character in text:
        stack.append(character)
result = []
while stack:
    result.append(stack.pop())
return "".join(result)

Example:

print(reverse_string("Python"))

Output:

nohtyP

Complexity:

Time = O(n)

Space = O(n)

4.5.20Evaluate Postfix Expressions

  • Stacks are widely used for evaluating mathematical expressions.
  • Consider postfix:
  • 2 3 + 4 *
  • This represents:

(2 + 3) * 4

Algorithm:

def evaluate_postfix(expression):
    stack = []
    for token in expression.split():
        if token.isdigit():
            stack.append(int(token))
else:
    right = stack.pop()
    left = stack.pop()
    if token == "+":
        stack.append(left + right)
elif token == "-":
    stack.append(left - right)
elif token == "*":
    stack.append(left * right)
elif token == "/":
    stack.append(left / right)
return stack.pop()

Example:

print(evaluate_postfix("2 3 + 4 *"))

Output:

20

4.5.21Infix, Prefix and Postfix

  • There are three common expression notations.
  • Infix
  • Operator appears between operands:
  • A + B
  • Prefix
  • Operator appears before operands:
  • + A B
  • Postfix
  • Operator appears after operands:
  • A B +

Stacks are commonly used to convert and evaluate these expressions.

4.5.22Infix to Postfix

  • Consider:
  • A + B * C
  • Because multiplication has higher precedence:

A + (B * C)

Postfix representation:

A B C * +

A stack can be used to manage operators and their precedence.

This is a classic application of stacks in compiler and expression-processing algorithms.

4.5.23Undo and Redo

  • Text editors can use stacks for undo operations.
  • Suppose the user performs:
  • Type A
  • Type B
  • Type C
  • Undo stack:
TOP
C
  • B
  • A
  • Press Undo:
  • C
  • is removed.
  • Another Undo removes:
  • B

Redo functionality can use a second stack:

  • Undo Stack
  • Redo Stack
  • This creates a common two-stack design.

4.5.24Browser History

Browser navigation can also be modeled using stacks.

For example:

Google
YouTube
GitHub
ChatGPT
  • Going backward removes the most recent page from the history stack.
  • A forward stack can store pages that were backed out of.
  • Conceptually:
  • Back Stack
  • Forward Stack

4.5.25Depth-First Search

DFS (Depth-First Search) uses stack behavior.

A graph traversal can be implemented recursively:

def dfs(node):
    if node is None:
        return

visited.add(node)

for neighbor in graph[node]:
    if neighbor not in visited:
        dfs(neighbor)

The recursion call stack effectively provides stack behavior.

DFS can also be implemented explicitly:

def dfs(graph, start):
    stack = [start]
    visited = set()
    while stack:
        node = stack.pop()
        if node in visited:
            continue

visited.add(node)

for neighbor in graph[node]:
    if neighbor not in visited:
        stack.append(neighbor)
return visited

This connection becomes important in Section 4.13 – Graph Traversal.

4.5.26Backtracking

  • Backtracking algorithms frequently use stack-like behavior.
  • Examples include:
  • Maze solving
  • Sudoku
  • N-Queens
  • Permutations
  • Combination generation
  • A typical pattern is:
Choose
Explore
If invalid → Undo
Try another choice

The recursive call stack stores the current decision path.

4.5.27Monotonic Stack

A monotonic stack is a special stack that maintains elements in increasing or decreasing order.

It is useful for problems such as:

  • Next Greater Element
  • Next Smaller Element
  • Daily Temperatures
  • Largest Rectangle in Histogram
  • Stock Span
  • For example, in Next Greater Element:

Input:

\[2, 1, 5, 3, 4\]

We want to find the first larger element to the right of each value.

A monotonic stack can solve many such problems in:

O(n)

instead of the brute-force:

O(n²)

This is an important advanced interview pattern.

4.5.28Next Greater Element

Consider:

\[2, 1, 5, 3, 4\]

The next greater element for each value is:

2 → 5

1 → 5

5 → -1

3 → 4

4 → -1

Result:

\[5, 5, -1, 4, -1\]

A monotonic stack solution:

def next_greater(numbers):
result = [-1] * len(numbers)
stack = []
for i, number in enumerate(numbers):
while stack and numbers[stack[-1]] < number:
index = stack.pop()

result[index] = number

stack.append(i)

return result

Complexity:

Time = O(n)

Space = O(n)

Although there is a nested-looking while loop, each element is pushed and popped at most once.

4.5.29Important Insight About Nested Loops

Consider:

while stack and numbers[stack[-1]] < number:
    stack.pop()

It may look like an O(n²) algorithm because of the nested loop structure.

However, each element can be:

Pushed → Once

Popped → Once

Therefore, across the entire algorithm:

Total stack operations = O(n)

So the overall time complexity is:

O(n)

This is an important example of amortized analysis.

4.5.30Stack vs Queue

Stacks and queues are both linear data structures, but their ordering principles differ.

FeatureStackQueue
PrincipleLIFOFIFO
AddTopRear
RemoveTopFront
ExampleStack of platesPeople waiting in line
Common useDFS, undoBFS, scheduling

Example stack:

Push → 10 → 20 → 30

Pop → 30

  • Example queue:
  • Enqueue → 10 → 20 → 30
  • Dequeue → 10

4.5.31Stack vs Array

A stack is an abstract data structure, while an array/list is a concrete storage structure.

For example:

Stack
implemented using

Python List

or:

Stack
implemented using
Linked List
  • The stack defines the behavior:
  • push
  • pop
  • peek

The underlying data structure determines how that behavior is implemented.

4.5.32Real-World Applications

  • Stacks are used in:
  • Function-call management
  • Recursion
  • Expression evaluation
  • Compilers
  • Syntax parsing
  • Browser history
  • Undo/redo
  • DFS
  • Backtracking
  • Maze solving
  • Parentheses validation
  • Monotonic-stack problems
  • Memory management

4.5.33Common Stack Interview Problems

Beginner

  • Implement a stack.
  • Push and pop elements.
  • Implement peek.
  • Check if stack is empty.
  • Reverse a string.
  • Check balanced parentheses.
  • Implement stack using a linked list.

Intermediate

  • Min Stack.
  • Evaluate postfix expression.
  • Infix to postfix conversion.
  • Next Greater Element.
  • Stock Span.
  • Remove adjacent duplicates.
  • Simplify a file path.
  • Decode a string.

Advanced

  • Largest Rectangle in Histogram.
  • Maximal Rectangle.
  • Daily Temperatures.
  • Trapping Rain Water using a stack.
  • Implement a queue using two stacks.
  • Implement a stack using two queues.
  • Design an expression evaluator.
  • Basic Calculator.
  • Online Stock Span.
  • Asteroid Collision.

4.5.34Min Stack

A popular interview problem is designing a stack that supports:

  • push()
  • pop()
  • top()
  • get_min()
  • all in:

O(1)

  • A common solution maintains two stacks:
  • Main Stack
  • Min Stack

Example:

Main Stack Min Stack

30 30

20 20

40 20

10 10

The minimum value is always available at the top of the minimum stack.

4.5.35Implement Queue Using Two Stacks

A queue follows FIFO, while a stack follows LIFO.

We can implement a queue using two stacks:

Input Stack

  • Output Stack
  • When elements need to be removed, transfer elements from the input stack to the output stack.
  • For example:

Input:

10 20 30

Transfer:

Output:

30 20 10

Now popping from the output stack gives:

10

which produces FIFO behavior.

This is a classic interview problem.

4.5.36Stack Complexity Summary

OperationComplexity
PushO(1) amortized
PopO(1)
PeekO(1)
Is EmptyO(1)
SizeO(1)
SearchO(n)
Reverse string using stackO(n)
Balanced parenthesesO(n)
DFS using stackO(V + E)
Monotonic stack problemsOften O(n)

4.5.37Key Takeaways

  • Remember the fundamental principle:
  • LIFO
  • Last In → First Out
  • The three most important operations are:
  • push() → Add to top

pop() → Remove from top

  • peek() → View top
  • Typical complexity:
  • Push → O(1)

Pop → O(1)

  • Peek → O(1)
  • The most important applications are:
  • Recursion
  • Function Calls
  • Undo/Redo
  • Browser History
  • Parentheses Matching
  • Expression Evaluation
  • DFS
  • Backtracking
  • Monotonic Stack

The most important interview patterns to master are:

Balanced Parentheses → Two Stacks → Fast Stack Operations → Monotonic Stack → Next Greater Element → Min Stack

Once stacks are understood, the next major linear data structure is 4.6 Queues, which reverses the ordering principle from LIFO to FIFO.

Module 4 · Lesson 4.6

Queues

A Queue is a linear data structure that follows the FIFO (First In, First Out) principle.

This means the element that enters the queue first is the element that leaves first.

A real-world example is people waiting in a line:

First Person Last Person

↓ ↓

\[10] → [20] → [30] → [40\]

↑ ↑

FRONT REAR

If 10 entered first, it will be removed first.

4.6.1FIFO Principle

  • FIFO stands for:
  • First In, First Out
  • Suppose we add:
  • Enqueue 10
  • Enqueue 20
  • Enqueue 30
  • The queue becomes:

FRONT REAR

↓ ↓

\[10] → [20] → [30\]
  • Now:
  • Dequeue()
  • removes:
  • 10
  • The queue becomes:

FRONT REAR

↓ ↓

\[20] → [30\]

This is the fundamental difference between a queue and a stack.

4.6.2Queue vs Stack

FeatureStackQueue
PrincipleLIFOFIFO
AddTopRear
RemoveTopFront
First addedRemoved lastRemoved first
Common useDFS, undoBFS, scheduling

Stack:

Push: 10 → 20 → 30

Pop: 30

  • Queue:
  • Enqueue: 10 → 20 → 30
  • Dequeue: 10

4.6.3Basic Queue Operations

A queue normally supports:

OperationDescription
enqueue()Add an element
dequeue()Remove the front element
front() / peek()View the front element
rear()View the last element
is_empty()Check whether queue is empty
size()Get number of elements

4.6.4Enqueue

  • Enqueue adds an element to the rear of the queue.
  • Starting queue:
  • FRONT

\[10] → [20\]

  • REAR
  • Enqueue 30:
  • FRONT

\[10] → [20] → [30\]

REAR

The new element is always added at the rear.

4.6.5Dequeue

Dequeue removes the element from the front.

FRONT

\[10] → [20] → [30\]
  • After:
  • dequeue()
  • we get:
  • 10
  • and the queue becomes:
  • FRONT

\[20] → [30\]

4.6.6Queue Using Python deque

The recommended Python implementation is collections.deque.

from collections import deque
queue = deque()
  • queue.append(10)
  • queue.append(20)
  • queue.append(30)
print(queue)

Output:

deque([10, 20, 30])

Remove from the front:

value = queue.popleft()
print(value)

Output:

  • 10
  • Now:
  • deque([20, 30])
  • Both:
  • queue.append(value)
  • queue.popleft()

are designed for efficient operations at their respective ends.

4.6.7Why Not Use a Python List?

You can technically implement a queue with:

queue = []
  • queue.append(10)
  • queue.append(20)
  • queue.append(30)
  • queue.pop(0)

However:

  • queue.pop(0)
  • requires shifting the remaining elements.
  • Its complexity is generally:

O(n)

For a queue, prefer:

from collections import deque
queue = deque()
  • queue.append(10)
  • queue.popleft()
  • This provides efficient front removal.

4.6.8Queue Implementation Using a Class

We can create a simple queue:

from collections import deque
class Queue:
    def __init__(self):
        self.items = deque()
def enqueue(self, value):
    self.items.append(value)
def dequeue(self):
if self.is_empty():
return None
return self.items.popleft()
def front(self):
if self.is_empty():
return None
return self.items[0]
def is_empty(self):
return len(self.items) == 0
def size(self):
return len(self.items)
  • Usage:
  • queue = Queue()
  • queue.enqueue(10)
  • queue.enqueue(20)
  • queue.enqueue(30)
print(queue.front())
print(queue.dequeue())
print(queue.size())

Output:

  • 10
  • 10
  • 2

4.6.9Queue Using a Linked List

A queue can also be implemented using a linked list.

The ideal structure maintains two references:

FRONT REAR

↓ ↓

[10] → [20] → [30] → None

  • The front is used for deletion.
  • The rear is used for insertion.
  • This allows:
  • Enqueue → O(1)
  • Dequeue → O(1)

4.6.10Node Implementation

class Node:
    def __init__(self, data):
        self.data = data

self.next = None

Queue:

class Queue:
    def __init__(self):
        self.front = None

self.rear = None

def enqueue(self, data):
    new_node = Node(data)
    if self.rear is None:
        self.front = self.rear = new_node
  • return
  • self.rear.next = new_node
  • self.rear = new_node
def dequeue(self):
if self.front is None:
return None
value = self.front.data

self.front = self.front.next

if self.front is None:
    self.rear = None
return value

4.6.11Queue Complexity

With a linked list containing both front and rear references:

OperationComplexity
EnqueueO(1)
DequeueO(1)
FrontO(1)
RearO(1)
Is EmptyO(1)
SearchO(n)

This is an efficient queue implementation.

4.6.12Queue Overflow

In a fixed-size queue, overflow occurs when the queue is full and another element is inserted.

For example:

Capacity = 3
\[10] [20] [30\]
  • Trying:
  • enqueue(40)
  • causes overflow.

Dynamic implementations such as Python's deque grow as needed, subject to available memory.

4.6.13Queue Underflow

Underflow occurs when we attempt to remove an element from an empty queue.

For example:

from collections import deque
queue = deque()
  • queue.popleft()
  • raises an IndexError.
  • A custom queue can safely check:
if not queue:
return None

4.6.14Types of Queues

  • The main types of queues are:
  • Simple Queue
  • Circular Queue
  • Priority Queue
  • Deque (Double-Ended Queue)

Each type is useful for different problems.

4.6.15Simple Queue

  • A simple queue follows:
  • FIFO
  • Elements are:
  • Added at the rear.
  • Removed from the front.

Example:

FRONT REAR

↓ ↓

  • 10 → 20 → 30 → 40
  • Dequeue removes:
  • 10

4.6.16Circular Queue

A Circular Queue connects the end of the queue back to the beginning.

Conceptually:

┌─────────────────┐

↓ │

\[10] → [20] → [30] → [40\]

↑ │

└───────────────────────┘

This allows unused positions at the beginning of a fixed-size array to be reused.

4.6.17Why Circular Queues?

Consider a fixed-size queue with capacity 5:

\[10] [20] [30] [40] [50\]
  • Remove:
  • 10
  • 20
  • We now have:
\[] [ ] [30] [40] [50\]
  • There is free space at the beginning.
  • A simple linear queue might incorrectly appear full if the rear is already at the end.
  • A circular queue reuses those positions.
\[60] [70] [30] [40] [50\]

This improves memory utilization.

4.6.18Circular Queue Implementation

  • A circular queue can be implemented using an array and modular arithmetic.
  • The key formula is:
  • rear = (rear + 1) % capacity
  • Similarly:
  • front = (front + 1) % capacity

Example:

class CircularQueue:
    def __init__(self, capacity):
        self.queue = [None] * capacity
  • self.capacity = capacity
  • self.front = 0
  • self.rear = 0
  • self.size = 0

The modulo operator allows indexes to wrap around to the beginning.

4.6.19Priority Queue

  • A Priority Queue removes elements based on priority rather than simply insertion order.
  • For example:
  • Patient A → Priority 2
  • Patient B → Priority 5
  • Patient C → Priority 1
  • If larger numbers indicate higher priority:
  • Patient B

is processed first even though it may have arrived later.

Priority queues are commonly implemented using heaps, which are covered in Section 4.11.

4.6.20Python Priority Queue

Python provides heapq:

import heapq
queue = []
  • heapq.heappush(queue, (2, "Patient A"))
  • heapq.heappush(queue, (1, "Patient B"))
  • heapq.heappush(queue, (3, "Patient C"))
print(heapq.heappop(queue))

Output:

(1, 'Patient B')

The smallest priority value is returned first.

4.6.21Deque

Deque stands for:

Double-Ended Queue

It allows insertion and removal from both ends.

FRONT REAR

↓ ↓

\[10] ↔ [20] ↔ [30] ↔ [40\]

Python provides:

from collections import deque

Example:

  • queue = deque([20, 30])
  • queue.appendleft(10)
  • queue.append(40)
print(queue)

Output:

  • deque([10, 20, 30, 40])
  • Remove from the left:
  • queue.popleft()
  • Remove from the right:
  • queue.pop()

4.6.22Deque Complexity

Typical deque operations at either end are:

appendleft() → O(1)

append() → O(1)

popleft() → O(1)

pop() → O(1)

This makes deque very useful for queue and sliding-window problems.

4.6.23Queue Applications

  • Queues are widely used in computer science.
  • Important applications include:
  • CPU scheduling
  • Printer scheduling
  • Network packet processing
  • Customer-service systems
  • Breadth-First Search
  • Task scheduling
  • Message queues
  • Producer-consumer systems
  • Buffer management
  • Web server request handling

4.6.24Queue in CPU Scheduling

Suppose several processes are waiting for CPU time:

  • P1 → P2 → P3 → P4
  • A scheduling algorithm can process them in queue order.
  • For Round Robin scheduling, processes repeatedly receive a fixed time slice.
  • Conceptually:
  • P1 → P2 → P3 → P4

↑ ↓

└─────────────────┘

This is closely related to circular queues.

4.6.25Queue in Networking

  • Network systems often process packets in the order they arrive.
  • For example:
  • Packet 1
  • Packet 2
  • Packet 3
  • Packet 4
  • A queue can temporarily store packets:
  • Incoming packets

\[Queue\]

Network processing

This allows producers and consumers to operate at different speeds.

4.6.26Queue in Message Processing

Modern distributed systems commonly use message queues.

Conceptually:

Producer
Message Queue
Consumer

For example:

Application
Messages
Queue
Worker
  • Queues provide buffering between components.
  • They can help with:
  • Asynchronous processing
  • Load balancing
  • Fault tolerance
  • Traffic spikes
  • Background jobs

4.6.27BFS Uses a Queue

  • One of the most important DSA applications of queues is Breadth-First Search (BFS).
  • Consider a graph:
  • A

/ \

B C

/ \ \

D E F

  • BFS visits nodes level by level:
  • A
  • B C
  • D E F
  • A queue stores nodes waiting to be processed.
  • Conceptually:
  • Queue:
\[A\]

Process A

Queue:

\[B, C\]

Process B

Queue:

\[C, D, E\]

Process C

Queue:

\[D, E, F\]

This is why queues are fundamental to BFS.

4.6.28BFS Example

from collections import deque
def bfs(graph, start):
    queue = deque([start])
    visited = {start}
    while queue:
        node = queue.popleft()
        print(node)
        for neighbor in graph[node]:
            if neighbor not in visited:
                visited.add(neighbor)
  • queue.append(neighbor)
  • For a graph with:
  • V = vertices
  • E = edges
  • BFS typically has:

Time = O(V + E)

Space = O(V)

4.6.29Producer-Consumer Problem

The Producer-Consumer model is another important application.

Producer

\[Queue\]

  • Consumer
  • The producer generates items.
  • The consumer processes them.
  • If the producer is faster:
  • Queue grows
  • If the consumer is faster:
  • Queue becomes empty

A queue acts as a buffer between the two.

Python provides queue.Queue for thread-safe producer-consumer scenarios.

4.6.30Queue vs Deque

FeatureQueueDeque
Add frontNoYes
Add rearYesYes
Remove frontYesYes
Remove rearNoYes
FIFOYesCan support
LIFONoCan support
Python implementationdeque, queue.Queuecollections.deque

A deque is more flexible because it supports both ends.

4.6.31Queue vs Priority Queue

FeatureNormal QueuePriority Queue
OrderingArrival orderPriority
PrincipleFIFOPriority-based
ExampleCustomer lineEmergency patients
Common implementationDeque/linked listHeap
  • Normal queue:
  • A → B → C
  • Dequeue:
  • A
  • Priority queue:
  • A(priority 2)
  • B(priority 1)
  • C(priority 3)
  • Remove:
  • B

because it has the highest priority under the chosen priority convention.

4.6.32Sliding Window and Queues

Queues and deques are heavily used in sliding window algorithms.

Suppose we want the maximum value in every window of size k:

\[1, 3, -1, -3, 5, 3, 6, 7\]

For:

k = 3

the windows are:

[1, 3, -1] → 3

[3, -1, -3] → 3

[-1, -3, 5] → 5

...

A monotonic deque can solve this efficiently in:

O(n)

This will become especially important in Section 4.21 Sliding Window.

4.6.33Queue Interview Problem: Implement Queue Using Stacks

  • A common interview problem is:
  • Implement a FIFO queue using only stacks.
  • Use two stacks:
  • Input Stack
  • Output Stack
  • Suppose:
  • enqueue(10)
  • enqueue(20)
  • enqueue(30)
  • Input stack:
TOP
30
  • 20
  • 10
  • Transfer to output:
TOP
10

20

30

Now popping from the output stack produces:

10

which gives FIFO behavior.

4.6.34Queue Interview Problem: Generate Binary Numbers

For a given n, generate binary numbers:

  • 1
  • 10
  • 11
  • 100
  • 101

...

A queue can be used to generate them systematically.

This demonstrates that queues are useful beyond simple insertion and deletion.

4.6.35Queue Interview Problem: First Non-Repeating Character

  • A queue can help track characters in the order they appeared.
  • For example:
  • a a b c
  • A frequency map stores counts, while a queue stores candidate characters.
  • This combination of:
  • Queue + Hash Table
  • is a common DSA pattern.

4.6.36Important Queue Patterns

Pattern 1 — BFS

Graph
Queue
Level-by-level traversal

Pattern 2 — Sliding Window

Window
Deque
Efficient insertion/removal

Pattern 3 — Producer-Consumer

Producer
Queue
Consumer

Pattern 4 — Scheduling

Tasks
Queue
Processor

Pattern 5 — Priority Processing

Tasks
Priority Queue / Heap
Highest-priority task

4.6.37Common Queue Interview Problems

Beginner

  • Implement a queue.
  • Enqueue elements.
  • Dequeue elements.
  • Implement front and rear.
  • Check whether a queue is empty.
  • Implement queue using a linked list.
  • Implement circular queue.

Intermediate

  • Implement a queue using two stacks.
  • Implement a stack using two queues.
  • Generate binary numbers using a queue.
  • Reverse a queue.
  • First non-repeating character in a stream.
  • Number of recent requests.
  • BFS traversal.
  • Level-order traversal of a binary tree.

Advanced

  • Sliding Window Maximum.
  • Rotten Oranges.
  • Shortest path in an unweighted graph.
  • Course Schedule.
  • Task scheduling.
  • Design a priority queue.
  • Design a thread-safe producer-consumer queue.
  • Multi-source BFS problems.
  • 0-1 BFS.

Implement an LRU Cache using a deque/hash-map combination.

4.6.38Queue Complexity Summary

OperationQueue
EnqueueO(1)
DequeueO(1)
FrontO(1)
RearO(1)
Is EmptyO(1)
SearchO(n)
BFSO(V + E)
Circular Queue insertionO(1)
Circular Queue deletionO(1)
Deque insertion/removal at endsO(1)

4.6.39Python Queue Structures

Python provides several useful choices.

collections.deque

Best general-purpose choice for efficient operations at both ends:

from collections import deque
queue = deque()

queue.append(10)

queue.append(20)

print(queue.popleft())

queue.Queue

Useful for thread-safe producer-consumer scenarios:

from queue import Queue
q = Queue()

q.put(10)

q.put(20)

print(q.get())

heapq

Useful when elements need to be processed by priority:

import heapq
pq = []

heapq.heappush(pq, 10)

heapq.heappush(pq, 5)

print(heapq.heappop(pq))

Output:

5

This is a priority queue pattern rather than a normal FIFO queue.

4.6.40Real-World Example

  • Consider an online food-ordering system.
  • Orders arrive:
  • Order 101
  • Order 102
  • Order 103
  • Order 104

A normal processing queue might look like:

FRONT REAR

↓ ↓

  • 101 → 102 → 103 → 104
  • The kitchen processes:
  • 101
  • then:
  • 102
  • then:
  • 103

This is FIFO.

If urgent orders need special treatment, a priority queue may be more appropriate.

4.6.41Key Differences to Remember

Stack

Last In
First Out

Queue

First In
First Out

Priority Queue

Highest/lowest priority
First Out

Deque

Both ends

Insert + Remove

4.6.42Key Takeaways

  • The most important principle is:
  • A queue follows FIFO — First In, First Out.
  • Remember the fundamental operations:
  • enqueue() → Add at rear
  • dequeue() → Remove from front

peek() → View front

  • Typical complexity:
  • Enqueue → O(1)
  • Dequeue → O(1)

Peek → O(1)

  • The most important queue types are:
  • Simple Queue
  • Circular Queue
  • Priority Queue
  • Deque
  • The most important applications are:
  • BFS
  • CPU Scheduling
  • Task Scheduling
  • Network Buffers
  • Message Queues
  • Producer-Consumer
  • Sliding Window
  • Level-Order Tree Traversal
  • For Python, remember:
from collections import deque
  • and prefer:
  • queue.append(value)
  • queue.popleft()
for a normal efficient FIFO queue.

The key interview patterns to master are BFS, circular queues, queue using two stacks, monotonic deque, sliding-window maximum, and producer-consumer problems.

Module 4 · Lesson 4.7

Hash Tables

A Hash Table is a data structure that stores data as key-value pairs and provides very fast average-case insertion, lookup, and deletion.

In Python, the built-in dictionary (dict) is a hash-table-based data structure.

For example:

student = {
    "name": "Sreehari",
    "age": 37,
    "course": "AI/ML"
}

Here:

Key → Value

-------------------------

"name" → "Sreehari"

"age" → 37

"course" → "AI/ML"

The key is used to efficiently locate its corresponding value.

4.7.1Why Hash Tables?

Suppose we have one million records and want to find a particular value.

With an unsorted array, we may need to search through many elements:

O(n)

A hash table can typically find the value in:

O(1) average

This makes hash tables extremely useful for:

  • Fast lookups
  • Frequency counting
  • Duplicate detection
  • Caching
  • Indexing
  • Database-like key-value access
  • Graph algorithms
  • Coding interview problems

4.7.2Hash Table Structure

Conceptually, a hash table looks like an array of buckets:

Index Bucket

-------------------------

0 ...

1 ...

2 ...

3 ...

4 ...

5 ...

6 ...

7 ...

A hash function converts a key into a value that determines where the key-value pair should be stored.

Conceptually:

Key
Hash Function
Hash Value
Bucket Index
Stored Value

For example:

"apple"
hash("apple")
some hash value
bucket 4

4.7.3Hash Function

  • A hash function converts a key into a numeric hash value.
  • Conceptually:
  • hash(key) → integer
  • For example:
  • hash("apple") → 123456...
  • The actual value depends on the programming language and implementation.
  • A good hash function should:
  • Be deterministic for a given key during the relevant table operation.
  • Distribute keys reasonably evenly.
  • Be efficient to compute.
  • Minimize collisions.

4.7.4Hashing

Hashing is the process of using a hash function to map a key to a location in a hash table.

Conceptually:

Key

Hash Function

Hash Value

Bucket

For example:

Key = "Sreehari"
hash("Sreehari")
12345
bucket 5

The exact bucket calculation depends on the implementation.

4.7.5Python Dictionary

Python's dictionary provides a convenient interface for hash-table operations.

student = {
    "name": "Sreehari",
    "age": 37,
    "city": "Hyderabad"
}

Access a value:

print(student["name"])

Output:

Sreehari

Average lookup complexity:

O(1)

4.7.6Inserting Values

  • Add a new key-value pair:
  • student["course"] = "AI/ML"
  • Now:
print(student)

might produce:

{

  • "name": "Sreehari",
  • "age": 37,
  • "city": "Hyderabad",
  • "course": "AI/ML"

}

Average complexity:

O(1)

4.7.7Updating Values

  • If the key already exists:
  • student["age"] = 38
  • The existing value is replaced.
  • Average complexity:

O(1)

4.7.8Deleting Values

  • Use del:
  • del student["city"]
  • Or:
  • student.pop("city")
  • Average complexity:

O(1)

4.7.9Checking Whether a Key Exists

Use in:

if "name" in student:
print("Name exists")

Average complexity:

O(1)

This is one of the most useful operations in DSA.

4.7.10Dictionary Methods

  • Important Python dictionary methods include:
  • student.keys()
  • student.values()
  • student.items()
  • student.get("name")
  • student.pop("name")
  • student.update(...)

Example:

student = {
    "name": "Sreehari",
    "age": 37
}
print(student.keys())
print(student.values())
print(student.items())

4.7.11get() Method

Direct access:

value = student["name"]

raises a KeyError if the key doesn't exist.

Using:

value = student.get("name")

returns the value.

For a missing key:

value = student.get("salary")

returns:

None

You can specify a default:

value = student.get("salary", 0)

Result:

0

4.7.12Dictionary Frequency Counting

One of the most important applications of hash tables is frequency counting.

Suppose:

numbers = [1, 2, 2, 3, 3, 3]
  • We want:
  • 1 → 1
  • 2 → 2
  • 3 → 3
  • Implementation:
frequency = {}
for number in numbers:
    frequency[number] = frequency.get(number, 0) + 1
print(frequency)

Output:

{1: 1, 2: 2, 3: 3}

Typical complexity:

Time = O(n)

Space = O(k)

where k is the number of distinct values.

4.7.13Character Frequency

The same technique works for strings.

text = "banana"
frequency = {}
for character in text:
    frequency[character] = frequency.get(character, 0) + 1
print(frequency)

Output:

{'b': 1, 'a': 3, 'n': 2}

This pattern is extremely common in coding interviews.

4.7.14Two Sum Problem

One of the most famous hash-table problems is Two Sum.

Given:

numbers = [2, 7, 11, 15]
target = 9
  • We want two numbers whose sum is 9.
  • The brute-force approach uses nested loops:
  • Time = O(n²)

A hash table can improve this to average:

Time = O(n)

Implementation:

def two_sum(numbers, target):
seen = {}
for i, number in enumerate(numbers):
required = target - number
if required in seen:
return [seen[required], i]

seen[number] = i

return []

Example:

print(two_sum([2, 7, 11, 15], 9))

Output:

\[0, 1\]

The hash table allows us to quickly determine whether the required complement has already been seen.

4.7.15How Two Sum Works

Input:

\[2, 7, 11, 15\]
  • Target:
  • 9
  • First value:
number = 2
required = 9 - 2 = 7
  • 7 isn't in the hash table.
  • Store:
  • 2 → index 0
  • Next:
number = 7
required = 9 - 7 = 2

2 exists.

Therefore:

2 + 7 = 9

We have solved the problem in one pass.

4.7.16Duplicate Detection

Hash tables are excellent for detecting duplicates.

def has_duplicate(numbers):
seen = set()
for number in numbers:
if number in seen:
return True

seen.add(number)

return False

Complexity:

Time = O(n) average

Space = O(n)

A set is essentially a hash-based collection of unique values.

4.7.17Hash Table vs Set

  • A dictionary stores:
  • Key → Value
  • A set stores:
  • Unique Keys
  • Example dictionary:
student = {
    "name": "Sreehari",
    "age": 37
}

Example set:

numbers = {10, 20, 30}

Both are hash-based structures in Python.

4.7.18Hash Collisions

  • A collision occurs when two different keys map to the same bucket.
  • Conceptually:
  • Key A ──┐
  • ├──→ Bucket 5
  • Key B ──┘
  • For example:
  • hash("apple") → bucket 5
  • hash("orange") → bucket 5
  • Both keys want the same location.
  • A hash-table implementation must handle this situation.

4.7.19Collision Resolution

  • Two major collision-resolution techniques are:
  • Separate Chaining
  • Open Addressing

4.7.20Separate Chaining

  • In separate chaining, each bucket can hold multiple entries.
  • Conceptually:
  • Bucket 0 → ...
  • Bucket 1 → ...
  • Bucket 2 → Key A → Key B
  • Bucket 3 → ...
  • Bucket 4 → ...

If two keys map to the same bucket, they can be stored in a chain associated with that bucket.

The chain may be implemented using a linked structure or another suitable representation.

4.7.21Open Addressing

  • In open addressing, all entries are stored inside the table itself.
  • If the preferred bucket is occupied, another location is searched according to a probing strategy.
  • Common techniques include:
  • Linear probing
  • Quadratic probing
  • Double hashing

4.7.22Linear Probing

Suppose the preferred position is occupied.

The algorithm checks the next position:

index
5 → occupied

6 → occupied

  • 7 → empty
  • The new element is placed at index 7.
  • The basic probing sequence is conceptually:
  • index
  • index + 1
  • index + 2
  • index + 3

...

with wraparound when necessary.

4.7.23Quadratic Probing

Instead of checking positions linearly, quadratic probing uses increasing offsets such as:

  • index + 1²
  • index + 2²
  • index + 3²

...

This can reduce some clustering patterns compared with simple linear probing.

4.7.24Double Hashing

  • Double hashing uses a second hash function to determine the probing step.
  • Conceptually:
  • index = hash1(key)
  • step = hash2(key)
  • Then:
  • index
  • index + step
  • index + 2 × step
  • index + 3 × step

...

This can provide better distribution than simple linear probing.

4.7.25Load Factor

  • The load factor measures how full a hash table is.
  • It is commonly represented as:
  • α = Number of Stored Elements / Number of Buckets
  • For example:
Elements = 7
Buckets  = 10

Then:

α = 7 / 10

= 0.7

A high load factor generally increases collision probability and can reduce performance.

4.7.26Resizing

When a hash table becomes too full, implementations may resize it.

Conceptually:

Small Table
Too Many Elements
Create Larger Table
Rehash Entries

The elements may need to be placed into new buckets because the table size changed.

This operation can be expensive individually, but resizing strategies allow insertion to remain efficient amortized over a sequence of operations.

4.7.27Average vs Worst-Case Complexity

Typical hash-table complexity is:

OperationAverageWorst Case
SearchO(1)O(n)
InsertO(1)O(n)
DeleteO(1)O(n)
  • Why can the worst case become O(n)?
  • Because many keys could experience collisions, causing the implementation to examine many entries.
  • Good hash functions and appropriate table management make average-case performance close to O(1).

4.7.28Hashable Keys in Python

  • Dictionary keys must be hashable.
  • Common hashable types include:
  • int
  • float
  • str
  • bool
  • tuple
  • For example:
data = {
    "name": "Sreehari",
    100: "value",
    (1, 2): "point"
}

Lists cannot normally be dictionary keys:

data = {
    [1, 2]: "value"
}

because lists are mutable and unhashable.

4.7.29Immutable Objects as Keys

A key should have a stable hash value while it is being used in a dictionary.

For example:

location = {
    (12.9, 77.6): "Bangalore"
}

A tuple can be used as a key when its contents are hashable.

This is useful for representing coordinates or composite keys.

4.7.30Grouping Data

  • Hash tables are useful for grouping records.
  • Suppose:
  • Students:
  • Alice → CSE

Bob → ECE

Charlie → CSE

David → ECE

We can group them by department:

groups = {}
students = [
    ("Alice", "CSE"),
    ("Bob", "ECE"),
    ("Charlie", "CSE"),
    ("David", "ECE")
]
for name, department in students:
    groups.setdefault(department, []).append(name)
print(groups)

Result:

{

"CSE": ["Alice", "Charlie"],

"ECE": ["Bob", "David"]

}

This is another important hash-table pattern.

4.7.31Counting with Counter

Python provides collections.Counter for frequency counting.

from collections import Counter
numbers = [1, 2, 2, 3, 3, 3]
counts = Counter(numbers)
print(counts)

Output:

  • Counter({3: 3, 2: 2, 1: 1})
  • For strings:
  • Counter("banana")
  • produces frequency information for each character.

4.7.32Hash Tables and Caching

Hash tables are commonly used for caches.

Suppose an expensive function produces a result:

Input → Expensive Calculation → Result

Instead of calculating it repeatedly:

Input
Check Cache
Found? → Return cached result

A dictionary can serve as the cache:

cache = {}
def expensive_operation(x):
if x in cache:
return cache[x]
result = x * x

cache[x] = result

return result

Typical lookup:

O(1) average

This idea is fundamental to memoization and dynamic programming.

4.7.33Hash Tables in Dynamic Programming

  • Consider Fibonacci.
  • Naive recursion repeatedly calculates the same values.
  • Memoization stores previously calculated results:
memo = {}
def fibonacci(n):
if n <= 1:
return n
if n in memo:
return memo[n]

memo[n] = fibonacci(n - 1) + fibonacci(n - 2)

return memo[n]

The dictionary provides fast lookup of previously computed values.

This is a major connection between:

Hash Tables
Memoization
Dynamic Programming

4.7.34Hash Tables in Graph Algorithms

  • Hash tables and sets are heavily used in graph problems.
  • For example:
  • visited = set()
  • can track which vertices have already been visited.
if neighbor not in visited:
    visited.add(neighbor)

Average membership testing is:

O(1)

This makes hash-based structures extremely useful for BFS and DFS.

4.7.35Hash Table for Word Frequency

Suppose we have:

"python is easy and python is powerful"

We can count words:

from collections import Counter
text = "python is easy and python is powerful"
words = text.split()
frequency = Counter(words)
print(frequency)

Result:

{

"python": 2,

"is": 2,

"easy": 1,

"and": 1,

"powerful": 1

}

  • This technique is fundamental in:
  • NLP
  • Search engines
  • Log analysis
  • Text analytics
  • Machine learning preprocessing

4.7.36Hash Tables in AI and Machine Learning

  • Hash-based structures are useful throughout AI/ML systems.
  • Examples include:
  • Feature Frequency
  • word → frequency
  • Vocabulary
  • token → ID
  • For example:
  • "machine" → 105
  • "learning" → 237
  • Caching
  • input → model result
  • Deduplication
  • hash → document
  • Lookup Tables
  • category → metadata
  • NLP
  • word → count
  • word → token ID

Hash tables therefore provide a foundation for many data-processing operations used in AI systems.

4.7.37Important Hash Table Interview Patterns

1. Frequency Map

  • Element → Count
  • Used for:
  • Character frequency
  • Word frequency
  • Anagrams
  • Majority elements

2. Seen Set

  • Element → Already Seen?
  • Used for:
  • Duplicate detection
  • Cycle detection
  • Visited tracking

3. Complement Lookup

  • Used in:
  • Two Sum
  • Pair problems

4. Grouping

  • Key → List of Values
  • Used in:
  • Group Anagrams
  • Categorization
  • Aggregation

5. Memoization

  • Input → Computed Result
  • Used in:
  • Dynamic Programming
  • Recursive optimization

4.7.38Group Anagrams

Given:

\["eat", "tea", "tan", "ate", "nat", "bat"\]

we want:

[

["eat", "tea", "ate"],

["tan", "nat"],

\["bat"\]

]

  • A hash table can group words using a canonical key.
  • For example:
  • eat → aet
  • tea → aet
  • ate → aet
  • Implementation:
from collections import defaultdict
def group_anagrams(words):
groups = defaultdict(list)
for word in words:
key = "".join(sorted(word))

groups[key].append(word)

return list(groups.values())

The dictionary maps:

canonical representation → words

This is a classic hash-table interview problem.

4.7.39Longest Consecutive Sequence

Given:

\[100, 4, 200, 1, 3, 2\]
  • the longest consecutive sequence is:
  • 1, 2, 3, 4
  • Length:
  • 4
  • A set allows fast membership checks.
def longest_consecutive(numbers):
    values = set(numbers)
    longest = 0
    for number in values:
        if number - 1 not in values:
            current = number
            length = 1
            while current + 1 in values:
                current += 1

length += 1

longest = max(longest, length)

return longest

Typical complexity:

Time = O(n) average

Space = O(n)

This is an excellent example of replacing sorting with hashing.

4.7.40Hash Table vs Array

FeatureArrayHash Table
Access by indexO(1)Not applicable
Search by valueO(n)O(1) average if value is a key
Key-value mappingNoYes
InsertDepends on positionO(1) average
DeleteDepends on positionO(1) average
OrderingSequence-basedNot primarily for positional access
Memory overheadLowerHigher

Use an array when position matters.

Use a hash table when fast key-based lookup matters.

4.7.41Hash Table vs Linked List

FeatureHash TableLinked List
LookupO(1) averageO(n)
SearchO(1) average by keyO(n)
InsertO(1) averageO(1) if position/node known
DeleteO(1) average by keyO(1) if previous/node known
Random accessKey-basedSequential
Memory overheadHash-table metadataNode references

These structures solve different problems.

4.7.42When Should You Use a Hash Table?

Consider a hash table when you need:

  • Fast lookup by key.
  • Frequency counting.
  • Duplicate detection.
  • Membership testing.
  • Grouping.
  • Caching.
  • Mapping one object to another.
  • Memoization.
  • Tracking visited objects.
  • A useful interview question is:
  • Can I use a hash table to remember something I've already seen?

If the answer is yes, there may be an O(n) solution hiding behind an O(n²) brute-force solution.

4.7.43Important Hash Table Problems

Beginner

  • Count frequencies.
  • Check for duplicates.
  • Find first non-repeating character.
  • Find the most frequent element.
  • Check whether two arrays contain the same elements.
  • Find common elements.
  • Find missing values.

Intermediate

  • Two Sum.
  • Group Anagrams.
  • Longest Consecutive Sequence.
  • Subarray Sum Equals K.
  • Longest Subarray with Given Sum.
  • Isomorphic Strings.
  • Word Pattern.
  • Happy Number.
  • Top K Frequent Elements.
  • Find All Duplicates.

Advanced

  • LRU Cache.
  • Minimum Window Substring.
  • Longest Substring Without Repeating Characters.
  • Four Sum / Four Sum Count.
  • Design a Hash Map.
  • Design a Hash Set.
  • Randomized Set.
  • Time-Based Key-Value Store.

4.7.44Designing a Simple Hash Table

A simplified hash table can be implemented from scratch.

class HashTable:
    def __init__(self, capacity=10):
        self.capacity = capacity

self.table = [[] for _ in range(capacity)]

def _index(self, key):
    return hash(key) % self.capacity
    def put(self, key, value):
        index = self._index(key)
        for i, (existing_key, _) in enumerate(self.table[index]):
            if existing_key == key:
                self.table[index][i] = (key, value)

return

self.table[index].append((key, value))

def get(self, key):
index = self._index(key)
for existing_key, value in self.table[index]:
if existing_key == key:
return value
return None

This simplified implementation uses separate chaining.

The real Python dict implementation is significantly more sophisticated and optimized.

4.7.45Complexity of the Simple Implementation

Average case:

put() → O(1)

get() → O(1)

Worst case:

put() → O(n)

get() → O(n)

The worst case occurs when many keys collide into the same bucket.

4.7.46Hash Table Security Considerations

  • Hash tables can also have security implications.
  • If an attacker can deliberately create large numbers of colliding keys, operations may become much slower.
  • Production-quality implementations therefore use carefully designed hashing strategies and table-management techniques.

Python's hashing behavior also has security-related implementation details, including randomized hashing for certain built-in types such as strings.

4.7.47Complexity Summary

OperationAverageWorst Case
InsertO(1)O(n)
SearchO(1)O(n)
DeleteO(1)O(n)
UpdateO(1)O(n)
MembershipO(1)O(n)

Space complexity:

O(n)

for storing n key-value entries.

4.7.48Key Takeaways

  • The central idea is:
  • A hash table maps keys to values so that key-based lookup can be performed in O(1) average time.
  • Remember:
Key
Hash Function
Bucket
Value
  • Important concepts:
  • Hash Function
  • Hashing
  • Collision
  • Separate Chaining
  • Open Addressing
  • Linear Probing
  • Quadratic Probing
  • Double Hashing
  • Load Factor
  • Resizing

For Python, the most important structures are:

dict

set

And the most important interview patterns are:

  • Frequency Map
  • Seen Set
  • Two Sum / Complement Lookup
  • Grouping
  • Memoization
  • Caching

The most important complexity to remember is:

  • Hash Table Lookup → O(1) average
  • Hash Table Insert → O(1) average
  • Hash Table Delete → O(1) average

Once you understand hash tables, many apparently expensive O(n²) problems can often be transformed into efficient O(n) solutions by using additional memory.

Module 4 · Lesson 4.8

Trees

  • A Tree is a non-linear hierarchical data structure consisting of nodes connected by edges.
  • Unlike arrays, linked lists, stacks, and queues—which are generally linear—trees represent relationships in a hierarchy.
  • Examples of hierarchical structures include:
  • File systems
  • Organization charts
  • HTML/XML documents
  • Database indexes
  • Decision trees
  • Compiler syntax trees
  • AI search structures
  • A simple tree looks like:
  • 10

/ \

5 20

/ \ / \

3 7 15 25

Here, 10 is at the top and the other nodes branch underneath it.

4.8.1Basic Terminology

  • Understanding tree terminology is essential before learning binary trees, binary search trees, heaps, and tries.
  • Consider:
  • A

/ \

B C

/ \ \

D E F

  • Root
  • The topmost node is called the root.
  • Root = A

A tree normally has exactly one root.

  • Parent
  • A node directly above another node is its parent.
  • A → B
  • A is the parent of B.
  • Child

A node directly below another node is its child.

A
B

B is a child of A.

  • Siblings
  • Nodes with the same parent are called siblings.
  • A

/ \

B C

B and C are siblings.

4.8.2Leaf Node

  • A leaf node is a node that has no children.
  • In:
  • A

/ \

B C

/ \ \

D E F

  • the leaf nodes are:
  • D
  • E
  • F

Leaf nodes are also called terminal nodes.

4.8.3Internal Node

  • A node with at least one child is generally called an internal node or non-leaf node.
  • In the example:
  • A
  • B
  • C
  • are internal nodes.

4.8.4Edge

An edge connects two nodes.

A

|

  • B
  • The connection between A and B is an edge.
  • A tree containing n nodes has:
  • n - 1
  • edges.
  • For example:
  • 5 nodes → 4 edges

4.8.5Path

  • A path is a sequence of nodes connected by edges.
  • For example:
  • A → B → E
  • is a path.

The number of edges in the path is its path length.

4.8.6Depth of a Node

  • The depth of a node is the number of edges from the root to that node.
  • Consider:
  • A

/ \

B C

/ \

D E

  • Depth:
  • A → 0
  • B → 1
  • C → 1
  • D → 2
  • E → 2
  • The root has depth 0.

4.8.7Height of a Node

The height of a node is the number of edges on the longest downward path from that node to a leaf.

For:

A

/

B

/

  • C
  • we have:
  • height(C) = 0
  • height(B) = 1
  • height(A) = 2

The height of a tree is the height of its root.

4.8.8Level

Nodes at the same depth are commonly considered to be at the same level.

Example:

A Level 0

/ \

B C Level 1

/ \ / \

D E F G Level 2

This concept is particularly important for level-order traversal.

4.8.9Subtree

  • A subtree is a tree formed by a node and all of its descendants.
  • Consider:
  • A

/ \

B C

/ \

D E

The subtree rooted at B is:

B

/ \

D E

4.8.10Ancestor and Descendant

For:

A

/

B

/

  • C
  • A is an ancestor of:
  • B
  • C
  • C is a descendant of:
  • A
  • B

These relationships are important in many tree algorithms.

4.8.11Degree of a Node

The degree of a node is the number of children it has.

Example:

A

/ | \

B C D

  • Here:
  • degree(A) = 3
  • degree(B) = 0
  • degree(C) = 0
  • degree(D) = 0

In a binary tree, the maximum degree of a node is 2.

4.8.12General Tree

A general tree is a tree in which a node can have any number of children.

Example:

A

/ | \

B C D

/ | \ / \

E F G H I

  • A has three children.
  • B has three children.
  • D has two children.

There is no fixed maximum number of children.

4.8.13Binary Tree

  • A binary tree is a tree in which each node has at most two children.
  • The children are called:
  • Left Child
  • Right Child

Example:

10

/ \

5 20

/ \

3 7

  • A node can have:
  • 0 children
  • 1 child
  • 2 children
  • but never more than two.

Binary trees are covered in detail in Section 4.9.

4.8.14Tree Representation

A basic tree node can be represented using a Python class.

For a binary-tree-style node:

class TreeNode:
    def __init__(self, value):
        self.value = value
  • self.left = None
  • self.right = None
  • Create nodes:
  • root = TreeNode(10)
  • root.left = TreeNode(5)
  • root.right = TreeNode(20)
  • This creates:
  • 10

/ \

5 20

4.8.15General Tree Representation

For a general tree, a node may contain a list of children.

class TreeNode:
    def __init__(self, value):
        self.value = value
  • self.children = []
  • Then:
  • root = TreeNode("A")
  • root.children.append(TreeNode("B"))
  • root.children.append(TreeNode("C"))
  • root.children.append(TreeNode("D"))
  • Structure:
  • A

/ | \

B C D

4.8.16Tree Traversal

  • Tree traversal means visiting all nodes of a tree according to a particular strategy.
  • The major traversal techniques are:
  • Preorder
  • Inorder
  • Postorder
  • Level Order

For a general tree, preorder and level-order are particularly common.

For binary trees, all four are important.

4.8.17Preorder Traversal

In preorder:

Visit Node
Visit Children

For a binary tree:

Node → Left → Right

Example:

A

/ \

B C

/ \

D E

Preorder:

A B D E C

4.8.18Preorder Algorithm

def preorder(root):
    if root is None:
        return
print(root.value)
  • preorder(root.left)
  • preorder(root.right)
  • For:
  • A

/ \

B C

/ \

D E

  • the traversal is:
  • A → B → D → E → C
  • Time complexity:

O(n)

because every node is visited once.

4.8.19Inorder Traversal

Inorder is primarily defined for binary trees:

Left
Node
Right

For:

A

/ \

B C

/ \

D E

the inorder traversal is:

D B E A C

Inorder traversal becomes especially important for Binary Search Trees because it visits values in sorted order.

4.8.20Postorder Traversal

Postorder:

Left
Right
Node

For:

A

/ \

B C

/ \

D E

postorder is:

  • D E B C A
  • Postorder is useful when child nodes must be processed before their parent.
  • Examples:
  • Deleting a tree
  • Evaluating expression trees
  • Calculating subtree properties

4.8.21Level-Order Traversal

Level-order traversal visits nodes level by level.

Example:

A

/ \

B C

/ \ \

D E F

Level order:

A B C D E F

It is typically implemented using a queue.

4.8.22Level-Order Implementation

from collections import deque
def level_order(root):
if root is None:
return []
result = []
queue = deque([root])
while queue:
node = queue.popleft()

result.append(node.value)

if node.left:
    queue.append(node.left)
if node.right:
    queue.append(node.right)
return result

Complexity:

Time = O(n)

Space = O(n)

This demonstrates the relationship:

Tree
Level-order traversal
Queue

4.8.23Recursive Tree Traversal

Trees naturally support recursive algorithms.

For example:

def preorder(root):
    if root is None:
        return
print(root.value)
  • preorder(root.left)
  • preorder(root.right)
  • Why does recursion work well?
  • Because every subtree is itself a smaller tree.
  • Tree
  • ├── Left Subtree
  • └── Right Subtree

This recursive structure is fundamental to tree algorithms.

4.8.24Tree Height

We can calculate tree height recursively.

def height(root):
    if root is None:
        return -1
        return 1 + max(
            height(root.left),
            height(root.right)
        )

For:

A

/

B

/

  • C
  • the height is:
  • 2

because the longest path has two edges.

4.8.25Counting Nodes

def count_nodes(root):
    if root is None:
        return 0
        return (
            1
            + count_nodes(root.left)
            + count_nodes(root.right)
        )

For:

A

/ \

B C

  • result:
  • 3
  • Complexity:

Time = O(n)

Space = O(h)

where h is the tree height due to recursion.

4.8.26Counting Leaf Nodes

def count_leaves(root):
    if root is None:
        return 0
        if root.left is None and root.right is None:
            return 1
            return (
                count_leaves(root.left)
                + count_leaves(root.right)
            )

This visits every node:

Time = O(n)

4.8.27Maximum Value in a Tree

For a general binary tree without ordering guarantees:

def maximum_value(root):
if root is None:
return float("-inf")
left_max = maximum_value(root.left)
right_max = maximum_value(root.right)
return max(root.value, left_max, right_max)

Because every node may need to be examined:

Time = O(n)

This differs from a Binary Search Tree, where ordering can sometimes eliminate entire subtrees.

4.8.28Balanced Tree

A tree is considered balanced when its subtrees have reasonably similar heights.

For a binary tree, a common definition is:

  • For every node:
  • |height(left) - height(right)| ≤ 1
  • Example of a balanced structure:
  • 10

/ \

5 20

/ \ / \

2 7 15 25

A highly unbalanced tree can look like:

10

\

20

\

30

\

40

The second structure behaves much like a linked list.

4.8.29Why Tree Balance Matters

  • Consider searching in a tree.
  • A balanced tree:
  • 8

/ \

4 12

/ \ / \

2 6 10 14

  • has height approximately:
  • O(log n)
  • An unbalanced tree:
  • 2

\

4

\

6

\

8

has height:

O(n)

This is why balanced search trees are important.

  • Examples include:
  • AVL Trees
  • Red-Black Trees

4.8.30Full Binary Tree

A full binary tree is a binary tree in which every node has either:

  • 0 children
  • or:
  • 2 children

Example:

1

/ \

2 3

/ \

4 5

There are no nodes with exactly one child.

4.8.31Complete Binary Tree

  • A complete binary tree has:
  • Every level completely filled except possibly the last.
  • The last level is filled from left to right.

Example:

1

/ \

2 3

/ \ /

4 5 6

This concept is particularly important for heaps.

4.8.32Perfect Binary Tree

  • A perfect binary tree has:
  • Every internal node with exactly two children.
  • All leaves at the same level.

Example:

1

/ \

2 3

/ \ / \

4 5 6 7

If the tree has height h, a perfect binary tree contains:

2^(h+1) - 1

nodes when the root is at height 0.

4.8.33Degenerate Tree

A degenerate tree is essentially a tree where each node has only one child.

Example:

10

\

20

\

30

\

  • 40
  • This behaves similarly to a linked list.
  • Height:

O(n)

4.8.34Tree Representation Using Arrays

Some trees can be represented efficiently using arrays.

For a complete binary tree, nodes can be stored level by level:

10

/ \

20 30

/ \

40 50

Array:

\[10, 20, 30, 40, 50\]

With zero-based indexing:

left child = 2*i + 1

right child = 2*i + 2

parent = (i - 1) // 2

This representation is especially important for heaps.

4.8.35Tree Representation Using References

For a binary tree:

class TreeNode:
    def __init__(self, value):
        self.value = value

self.left = None

self.right = None

This representation is flexible and works well for arbitrary binary trees.

4.8.36Tree Traversal Complexity

For a tree with n nodes:

TraversalTimeAuxiliary Space
PreorderO(n)O(h) recursive
InorderO(n)O(h) recursive
PostorderO(n)O(h) recursive
Level orderO(n)O(w)
  • Where:
  • h = tree height
  • w = maximum width of the tree
  • For a balanced tree:
  • h = O(log n)
  • For a skewed tree:

h = O(n)

4.8.37Tree Search

For a general tree, there is no ordering guarantee.

Therefore, searching may require visiting every node.

Using DFS:

def search(root, target):
    if root is None:
        return False
        if root.value == target:
            return True
            return (
                search(root.left, target)
                or search(root.right, target)
            )

Worst-case complexity:

O(n)

A Binary Search Tree can improve this significantly when balanced.

4.8.38Lowest Common Ancestor

The Lowest Common Ancestor (LCA) of two nodes is the lowest node in the tree that is an ancestor of both.

Example:

A

/ \

B C

/ \ / \

D E F G

  • The LCA of:
  • D and E
  • is:
  • B
  • The LCA of:
  • D and G
  • is:
  • A

LCA problems are extremely common in coding interviews.

4.8.39Diameter of a Tree

The diameter of a tree is the longest path between two nodes.

Example:

1

/ \

2 3

/ \

4 5

  • One longest path is:
  • 4 → 2 → 1 → 3
  • The diameter is:
  • 3 edges

A good solution can calculate the diameter in:

O(n)

using a postorder-style height calculation.

4.8.40Tree Applications

  • Trees are used extensively in computing.
  • File Systems
  • Computer
  • ├── Documents
  • ├── Pictures
  • └── Videos
  • Organization Hierarchies
  • CEO
  • ├── Manager A

│ ├── Employee 1

│ └── Employee 2

  • └── Manager B
  • HTML DOM
  • html
  • ├── head
  • └── body

├── div

└── p

  • Database Indexes
  • Trees are used extensively for efficient indexing.
  • AI Decision Trees
  • Is age > 30?

/ \

Yes No

/ \

Approved Review

Compilers

Source code can be represented as syntax trees.

4.8.41Trees in AI and Machine Learning

Trees are particularly important in AI/ML.

Decision Trees

A decision tree repeatedly splits data based on features.

Example:

Income > 50K?

/ \

Yes No

/ \

Credit > 700? Reject

/ \

Yes No

/ \

Approve Review

  • Random Forest
  • A Random Forest combines multiple decision trees.
  • Dataset

┌──────┼──────┐

↓ ↓ ↓

Tree 1 Tree 2 Tree 3

↓ ↓ ↓

└──────┼──────┘

Prediction

  • Gradient Boosting
  • Algorithms such as gradient-boosted trees build trees sequentially to improve predictions.
  • Trees therefore form an important bridge between DSA and Machine Learning.

4.8.42Tree vs Graph

  • Trees are actually a special type of graph.
  • A tree generally has:
  • Connected structure
  • No cycles
  • n - 1 edges for n nodes
  • A general graph may have:
  • Cycles
  • Multiple paths
  • Disconnected components
  • Arbitrary edge counts
  • Example tree:
  • A

├── B

└── C

Example graph:

A ─── B

│ / │

│ / │

C ─── D

The graph can contain cycles, while a tree cannot.

4.8.43Important Tree Properties

For a tree with n nodes:

  • Edges = n - 1
  • There is exactly one simple path between any two nodes.
  • A tree is connected.
  • A tree contains no cycles.
  • Removing any edge disconnects the tree.
  • Adding an edge to a tree creates a cycle.
  • These properties are fundamental to graph theory.

4.8.44Important Tree Interview Patterns

1. DFS

Use recursion or an explicit stack.

Tree
DFS
Subtrees

2. BFS

Use a queue.

Tree
Queue
Level-by-level

3. Divide and Conquer

Solve:

Left subtree

+

Right subtree

+

Current node

4. Bottom-Up Recursion

  • Calculate information from children before processing the parent.
  • Used for:
  • Height
  • Diameter
  • Balance
  • Maximum path sum

5. Top-Down Recursion

  • Pass information from the parent toward children.
  • Used for:
  • Path constraints
  • Root-to-leaf problems
  • Depth calculations

4.8.45Common Tree Problems

Beginner

  • Create a tree.
  • Count nodes.
  • Find tree height.
  • Count leaf nodes.
  • Search for a value.
  • Preorder traversal.
  • Inorder traversal.
  • Postorder traversal.
  • Level-order traversal.
  • Find maximum value.

Intermediate

  • Check if two trees are identical.
  • Check if a tree is balanced.
  • Find tree diameter.
  • Find lowest common ancestor.
  • Invert a binary tree.
  • Find maximum depth.
  • Find minimum depth.
  • Root-to-leaf path sum.
  • Sum of all nodes.
  • Zigzag level-order traversal.

Advanced

  • Maximum path sum.
  • Serialize and deserialize a tree.
  • Construct tree from preorder and inorder.
  • Construct tree from inorder and postorder.
  • Boundary traversal.
  • Vertical order traversal.
  • Morris traversal.
  • Recover a corrupted search tree.

Flatten a tree into a linked list.

Find nodes at distance K.

4.8.46Tree Complexity Summary

OperationGeneral Tree
SearchO(n)
TraversalO(n)
Count nodesO(n)
Find heightO(n)
Find leavesO(n)
Level-order traversalO(n)
InsertDepends on tree representation
DeleteDepends on tree type

For a generic tree, there is no universal O(log n) search guarantee.

That guarantee appears in specific ordered and balanced tree structures.

4.8.47Important Tree Types to Learn

As you continue through this module, several specialized trees become important:

Trees
├── Binary Trees
├── Binary Search Trees
├── AVL Trees
├── Red-Black Trees
├── Heaps
├── Tries
└── Segment Trees

Your syllabus specifically covers:

Module 4 · Lesson 4.9

Binary Trees

Lesson focus: This lesson is part of Module 4 — Data Structures & Algorithms. Detailed lesson content can be added here from the corresponding source material.
Module 4 · Lesson 4.10

Binary Search Trees

Lesson focus: This lesson is part of Module 4 — Data Structures & Algorithms. Detailed lesson content can be added here from the corresponding source material.
Module 4 · Lesson 4.11

Heaps

4.24Trie

4.25Segment Tree

Each solves a different class of problems.

4.8.48Tree vs Linked List

  • A linked list is essentially a restricted tree-like structure where nodes normally have at most one next direction.
  • Linked List:
  • 10 → 20 → 30 → 40
  • A tree branches:
  • 10

/ \

20 30

/ \ \

40 50 60

The major advantage of trees is that they can represent hierarchical relationships and, in specialized forms, support efficient searching.

4.8.49Key Takeaways

  • The most important tree terminology is:
  • Root
  • Parent
  • Child
  • Sibling
  • Leaf
  • Internal Node
  • Edge
  • Path
  • Depth
  • Height
  • Subtree
  • Ancestor
  • Descendant
  • Degree
  • The major traversal methods are:

Preorder → Node → Left → Right

Inorder → Left → Node → Right

Postorder → Left → Right → Node

  • Level Order → Level by Level
  • The most important structural types are:
  • General Tree
  • Binary Tree
  • Full Binary Tree
  • Complete Binary Tree
  • Perfect Binary Tree
  • Balanced Tree
  • Degenerate Tree
  • And remember these fundamental properties:
Tree with n nodes
n - 1 edges
Tree
Connected
No cycles

Finally, the most important algorithms and patterns to master are:

DFS, BFS, recursion, tree height, tree diameter, lowest common ancestor, subtree processing, and top-down/bottom-up recursion.

The next section, 4.9 Binary Trees, builds directly on these concepts and introduces the two-child left/right structure that forms the foundation for BSTs, heaps, expression trees, and many tree interview problems.

4.9Binary Trees

4.9Binary Trees

  • A Binary Tree is a tree data structure in which each node has at most two children.
  • The two children are called:
  • Left child
  • Right child
  • A binary tree can look like:
  • 10

/ \

5 20

/ \ \

3 7 30

  • Each node can have:
  • 0 children
  • 1 child
  • 2 children
  • but never more than two.

4.9.1Binary Tree Node

A binary-tree node normally contains three components:

  • Node
  • ├── Data
  • ├── Left
  • └── Right
  • In Python:
class TreeNode:
    def __init__(self, value):
        self.value = value
  • self.left = None
  • self.right = None
  • Create a simple binary tree:
  • root = TreeNode(10)
  • root.left = TreeNode(5)
  • root.right = TreeNode(20)
  • The structure is:
  • 10

/ \

5 20

4.9.2Building a Binary Tree

Let's create a larger tree:

  • root = TreeNode(10)
  • root.left = TreeNode(5)
  • root.right = TreeNode(20)
  • root.left.left = TreeNode(3)
  • root.left.right = TreeNode(7)
  • root.right.right = TreeNode(30)
  • The resulting tree:
  • 10

/ \

5 20

/ \ \

3 7 30

Notice that the tree does not necessarily have to be sorted.

That distinction becomes important when we study Binary Search Trees.

4.9.3Binary Tree Terminology

Consider:

A

/ \

B C

/ \ \

D E F

  • Root
  • A
  • is the root.
  • Children

B and C are children of A.

  • Parent
  • A is the parent of B and C.
  • Siblings
  • B and C are siblings.
  • D and E are siblings.
  • Leaf Nodes
  • D, E, F
  • are leaf nodes.
  • Internal Nodes
  • A, B, C
  • are internal nodes.

4.9.4Binary Tree Properties

For a binary tree:

Each node can have at most two children.

Therefore, at level 0:

  • Maximum nodes = 1
  • At level 1:
  • Maximum nodes = 2
  • At level 2:
  • Maximum nodes = 4
  • At level 3:
  • Maximum nodes = 8

In general, the maximum number of nodes at level L is:

2^L

when the root is at level 0.

4.9.5Maximum Number of Nodes

For a binary tree of height h, where the root has height 0, the maximum number of nodes is:

2^(h + 1) - 1

For example, height 2:

A

/ \

B C

/ \ / \

D E F G

Number of nodes:

2^(2 + 1) - 1

= 8 - 1

= 7

4.9.6Minimum Height

  • A binary tree containing n nodes can have a minimum height when it is as balanced as possible.
  • Approximately:
  • h = O(log n)

This is why balanced binary trees can support efficient operations.

4.9.7Maximum Height

The maximum height occurs when every node has only one child.

Example:

10

\

20

\

30

\

40

\

  • 50
  • If there are n nodes:
  • height = n - 1

Therefore:

Minimum height → O(log n)

Maximum height → O(n)

4.9.8Full Binary Tree

A Full Binary Tree is a binary tree where every node has either:

  • 0 children
  • or:
  • 2 children

Example:

1

/ \

2 3

/ \

4 5

  • Node 2 has two children.
  • Node 3 has zero children.
  • Node 4 and 5 have zero children.
  • There are no nodes with exactly one child.

4.9.9Complete Binary Tree

  • A Complete Binary Tree has:
  • Every level completely filled except possibly the last.
  • The last level filled from left to right.

Example:

1

/ \

2 3

/ \ /

4 5 6

This is complete.

But:

1

/ \

2 3

/ \

4 6

is not complete because the last level is not filled from left to right.

Complete binary trees are especially important for heaps.

4.9.10Perfect Binary Tree

  • A Perfect Binary Tree has:
  • Every internal node with exactly two children.
  • All leaf nodes at the same level.

Example:

1

/ \

2 3

/ \ / \

4 5 6 7

For height h:

Nodes = 2^(h + 1) - 1

4.9.11Balanced Binary Tree

A balanced binary tree has subtrees whose heights remain reasonably close.

Example:

10

/ \

5 20

/ \ / \

3 7 15 25

  • A balanced tree generally has:
  • Height = O(log n)
  • Balanced trees are important for efficient searching.

4.9.12Degenerate Binary Tree

A degenerate binary tree has essentially one child at each level.

Example:

10

\

20

\

30

\

  • 40
  • It behaves almost like a linked list.
  • For n nodes:
  • Height = O(n)

4.9.13Skewed Binary Tree

  • A skewed tree can lean entirely to one side.
  • Left-skewed
  • 40

/

30

/

20

/

  • 10
  • Right-skewed
  • 10

\

20

\

30

\

40

Both have height:

O(n)

4.9.14Binary Tree Traversals

The four major traversal techniques are:

  • 1. Preorder
  • 2. Inorder
  • 3. Postorder
  • 4. Level Order

Consider:

1

/ \

2 3

/ \

4 5

4.9.15Preorder Traversal

Preorder follows:

Node
Left
Right

For:

1

/ \

2 3

/ \

4 5

  • the result is:
  • 1 2 4 5 3
  • Implementation:
def preorder(root):
    if root is None:
        return
print(root.value)
  • preorder(root.left)
  • preorder(root.right)
  • Complexity:

Time = O(n)

Space = O(h)

where h is the tree height.

4.9.16Inorder Traversal

Inorder follows:

Left
Node
Right

For:

1

/ \

2 3

/ \

4 5

  • the result is:
  • 4 2 5 1 3
  • Implementation:
def inorder(root):
    if root is None:
        return

inorder(root.left)

print(root.value)

inorder(root.right)

Inorder traversal becomes especially important for Binary Search Trees, where it produces values in sorted order.

4.9.17Postorder Traversal

Postorder follows:

Left
Right
Node

For:

1

/ \

2 3

/ \

4 5

  • the result is:
  • 4 5 2 3 1
  • Implementation:
def postorder(root):
    if root is None:
        return

postorder(root.left)

postorder(root.right)

print(root.value)

Postorder is useful when children must be processed before their parent.

4.9.18Level-Order Traversal

Level-order visits nodes level by level.

Example:

1

/ \

2 3

/ \

4 5

  • Result:
  • 1 2 3 4 5
  • Implementation:
from collections import deque
def level_order(root):
if root is None:
return []
result = []
queue = deque([root])
while queue:
node = queue.popleft()

result.append(node.value)

if node.left:
    queue.append(node.left)
if node.right:
    queue.append(node.right)
return result

Complexity:

Time = O(n)

Space = O(w)

where w is the maximum width of the tree.

4.9.19Traversal Summary

For:

1

/ \

2 3

/ \

4 5

TraversalResult
Preorder1 2 4 5 3
Inorder4 2 5 1 3
Postorder4 5 2 3 1
Level Order1 2 3 4 5
  • A useful memory trick:
  • PRE:
  • Node first
  • IN:
  • Node in the middle
  • POST:
  • Node last

4.9.20Iterative Preorder

Preorder can be implemented without recursion using a stack.

def preorder_iterative(root):
if root is None:
return []
result = []
stack = [root]
while stack:
node = stack.pop()

result.append(node.value)

if node.right:
    stack.append(node.right)
if node.left:
    stack.append(node.left)
return result
  • Why add the right child first?
  • Because the stack is LIFO.
  • We want the left child to be processed first.

4.9.21Iterative Inorder

Inorder can also be implemented using a stack.

def inorder_iterative(root):
    result = []
    stack = []
    current = root
    while current is not None or stack:
        while current is not None:
            stack.append(current)
current = current.left
current = stack.pop()

result.append(current.value)

current = current.right
return result

This is a very important interview pattern.

4.9.22Iterative Postorder

Postorder can be implemented using stacks as well.

A simple two-stack approach:

def postorder_iterative(root):
if root is None:
return []
stack1 = [root]
stack2 = []
result = []
while stack1:
node = stack1.pop()

stack2.append(node)

if node.left:
    stack1.append(node.left)
if node.right:
    stack1.append(node.right)
while stack2:
    result.append(stack2.pop().value)
return result

There are also optimized one-stack approaches.

4.9.23Finding Maximum Depth

The maximum depth is the longest distance from the root to a leaf.

def max_depth(root):
if root is None:
return 0
left_depth = max_depth(root.left)
right_depth = max_depth(root.right)
return 1 + max(left_depth, right_depth)

Complexity:

Time = O(n)

  • Space = O(h)
  • For a balanced tree:
  • h = O(log n)
  • For a skewed tree:

h = O(n)

4.9.24Minimum Depth

Minimum depth is the shortest path from the root to a leaf.

def min_depth(root):
    if root is None:
        return 0
        if root.left is None:
            return 1 + min_depth(root.right)
            if root.right is None:
                return 1 + min_depth(root.left)
                return 1 + min(
                    min_depth(root.left),
                    min_depth(root.right)
                )

For shortest-depth problems, BFS can often be especially natural because it processes nodes level by level.

4.9.25Count Nodes

def count_nodes(root):
    if root is None:
        return 0
        return (
            1
            + count_nodes(root.left)
            + count_nodes(root.right)
        )
  • Complexity:
  • Time = O(n)
  • Every node is visited.

4.9.26Sum of Nodes

def sum_tree(root):
    if root is None:
        return 0
        return (
            root.value
            + sum_tree(root.left)
            + sum_tree(root.right)
        )

For:

10

/ \

5 20

  • result:
  • 35
  • Complexity:

O(n)

4.9.27Find Maximum Value

def maximum(root):
    if root is None:
        return float("-inf")
        return max(
            root.value,
            maximum(root.left),
            maximum(root.right)
        )

For a normal binary tree, every node may need to be examined.

Therefore:

Time = O(n)

Do not confuse this with a Binary Search Tree, where ordering provides additional information.

4.9.28Search in a Binary Tree

Because a normal binary tree has no ordering rule, we may need to search the entire tree.

def search(root, target):
    if root is None:
        return False
        if root.value == target:
            return True
            return (
                search(root.left, target)
                or search(root.right, target)
            )

Worst case:

O(n)

4.9.29Invert a Binary Tree

  • A famous interview problem is to invert or mirror a binary tree.
  • Original:
  • 1

/ \

2 3

/ \

4 5

After inversion:

1

/ \

3 2

/ \

5 4

Implementation:

def invert_tree(root):
if root is None:
return None
  • root.left, root.right = root.right, root.left
  • invert_tree(root.left)
  • invert_tree(root.right)
return root

Complexity:

Time = O(n)

Space = O(h)

This is a classic example of recursive tree processing.

4.9.30Check if Two Trees Are Identical

  • Given two trees:
  • Tree A:
  • 1

/ \

2 3

  • and:
  • Tree B:
  • 1

/ \

2 3

  • they are identical if:
  • Their corresponding values match.
  • Their left subtrees match.
  • Their right subtrees match.
def same_tree(a, b):
    if a is None and b is None:
        return True
        if a is None or b is None:
            return False
            return (
                a.value == b.value
                and same_tree(a.left, b.left)
                and same_tree(a.right, b.right)
            )

Complexity:

Time = O(n)

4.9.31Check if a Tree Is Symmetric

A tree is symmetric if its left and right sides are mirror images.

Example:

1

/ \

2 2

/ \ / \

3 4 4 3

This is symmetric.

A recursive solution compares:

left.left ↔ right.right

left.right ↔ right.left

def is_mirror(left, right):
    if left is None and right is None:
        return True
        if left is None or right is None:
            return False
            return (
                left.value == right.value
                and is_mirror(left.left, right.right)
                and is_mirror(left.right, right.left)
            )

4.9.32Root-to-Leaf Path

A common problem asks whether there is a path from root to a leaf whose values sum to a target.

Example:

5

/ \

4 8

/ / \

11 7 4

/ \

2 1

  • For target:
  • 22
  • one valid path is:
  • 5 → 4 → 11 → 2
  • because:
  • 5 + 4 + 11 + 2 = 22
  • Implementation:
def has_path_sum(root, target):
    if root is None:
        return False
        if root.left is None and root.right is None:
            return root.value == target
            remaining = target - root.value
            return (
                has_path_sum(root.left, remaining)
                or has_path_sum(root.right, remaining)
            )
  • Complexity:
  • Time = O(n)
  • Space = O(h)

4.9.33Lowest Common Ancestor

Consider:

1

/ \

2 3

/ \

4 5

The LCA of 4 and 5 is:

2

For a general binary tree, a recursive approach is:

def lowest_common_ancestor(root, p, q):
if root is None:
return None
if root == p or root == q:
return root
left = lowest_common_ancestor(root.left, p, q)
right = lowest_common_ancestor(root.right, p, q)
if left and right:
return root
return left if left else right
  • Complexity:
  • Time = O(n)
  • Space = O(h)

4.9.34Tree Diameter

  • The diameter is the longest path between two nodes.
  • Consider:
  • 1

/ \

2 3

/ \

4 5

  • Longest path:
  • 4 → 2 → 1 → 3
  • Diameter:
  • 3 edges

A common O(n) solution calculates subtree heights.

def diameter(root):
    maximum = 0
    def height(node):
        nonlocal maximum
if node is None:
return 0
left = height(node.left)
right = height(node.right)
maximum = max(maximum, left + right)
return 1 + max(left, right)

height(root)

return maximum

Time complexity:

O(n)

4.9.35Balanced Tree Check

We can check whether a binary tree is height-balanced.

A naive approach repeatedly calculates heights and may become O(n²).

A better bottom-up approach calculates height and balance information together.

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

Complexity:

Time = O(n)

Space = O(h)

This is an excellent example of bottom-up tree recursion.

4.9.36Zigzag Level-Order Traversal

  • Normal level order:
  • 1
  • 2 3
  • 4 5 6 7
  • Zigzag traversal:
  • 1
  • 3 2
  • 4 5 6 7
  • The direction alternates at every level.
  • A deque can be useful for this problem.
  • This combines:
  • Tree

+

BFS

+

Deque

4.9.37Left View of a Binary Tree

The left view contains the first visible node from each level.

Example:

1

/ \

2 3

/ \ \

4 5 6

Left view:

1 2 4

A BFS traversal can track the first node at each level.

4.9.38Right View of a Binary Tree

  • The right view contains the last visible node from each level.
  • For:
  • 1

/ \

2 3

/ \ \

4 5 6

right view:

1 3 6

These problems are common in interviews and demonstrate level-order traversal.

4.9.39Boundary Traversal

Boundary traversal visits the outer boundary of a binary tree.

Conceptually:

Left Boundary
Root
Right Boundary

Leaf nodes are generally included carefully to avoid duplicates.

This is an advanced traversal problem.

4.9.40Serialize and Deserialize

Serialization converts a tree into a storable/transmittable representation.

Example:

1

/ \

2 3

  • could be represented as:
  • "1,2,None,None,3,None,None"
  • Deserialization reconstructs the tree.
  • This problem is important in:
  • Data storage
  • APIs
  • Distributed systems
  • Coding interviews

4.9.41Binary Tree and Stack

DFS traversal can be implemented using a stack.

Binary Tree
Stack
DFS

For preorder:

stack = [root]
while stack:
    node = stack.pop()
    if node.right:
        stack.append(node.right)
if node.left:
    stack.append(node.left)
  • This demonstrates the connection between:
  • Section 4.5 – Stacks
  • and:
  • Section 4.9 – Binary Trees

4.9.42Binary Tree and Queue

Level-order traversal uses a queue.

Binary Tree
Queue
BFS
  • This connects:
  • Section 4.6 – Queues
  • with binary trees.

This relationship becomes extremely important in coding interviews.

4.9.43Binary Tree vs Binary Search Tree

These are not the same thing.

A Binary Tree only requires:

At most 2 children

There is no requirement that values be ordered.

Example:

10

/ \

50 2

This is a valid binary tree.

  • But it is not a valid Binary Search Tree.
  • A BST additionally requires an ordering rule.
  • Typically:
  • Left subtree < Node < Right subtree
  • BSTs are covered in the next section.

4.9.44Binary Tree vs Heap

  • A heap is also a specialized binary tree.
  • A typical binary heap must satisfy:
  • Complete binary tree structure.
  • Heap-order property.
  • For a min-heap:
  • Parent ≤ Children

Example:

1

/ \

3 5

/ \

7 9

Heaps are covered in Section 4.11.

4.9.45Binary Trees in AI

Binary trees have several AI and ML applications.

Decision Trees

A decision can branch into two outcomes:

Age > 30?

/ \

Yes No

/ \

Approve Review

  • Game Trees
  • Game-playing AI can represent possible moves:
  • Current State

/ \

Move A Move B

/ \ / \

State State State State

  • Algorithms such as Minimax use tree structures.
  • Search Problems
  • Trees can represent possible states and decisions.

4.9.46Binary Tree Complexity

For a binary tree containing n nodes:

OperationComplexity
TraversalO(n)
SearchO(n)
Count nodesO(n)
Find heightO(n)
Find maximumO(n)
Invert treeO(n)
Check symmetryO(n)
Check balanceO(n)
DiameterO(n)

Auxiliary recursion space:

O(h)

where h is tree height.

Therefore:

Balanced tree → O(log n)

Skewed tree → O(n)

4.9.47Important Binary Tree Patterns

Pattern 1 — DFS Recursion

Process Node
Process Left
Process Right
  • Used for:
  • Traversals
  • Height
  • Path sums
  • Diameter
  • Pattern 2 — BFS
Queue
Level by Level
  • Used for:
  • Level order
  • Minimum depth
  • Tree views
  • Zigzag traversal
  • Pattern 3 — Bottom-Up
  • Left Result

+

Right Result
Node Result
  • Used for:
  • Height
  • Diameter
  • Balance
  • Maximum path sum
  • Pattern 4 — Top-Down
Parent Information
Child
Grandchild
  • Used for:
  • Path problems
  • Depth
  • Root-to-leaf constraints

4.9.48Common Binary Tree Interview Problems

Beginner

  • Preorder traversal
  • Inorder traversal
  • Postorder traversal
  • Level-order traversal
  • Maximum depth
  • Minimum depth
  • Count nodes
  • Count leaves
  • Search for a value
  • Sum of nodes

Intermediate

  • Invert binary tree
  • Check identical trees
  • Check symmetric tree
  • Check balanced tree
  • Diameter of binary tree
  • Lowest Common Ancestor
  • Path Sum
  • Left View
  • Right View
  • Zigzag traversal

Advanced

  • Maximum path sum
  • Serialize and deserialize
  • Construct tree from traversals
  • Boundary traversal
  • Vertical traversal
  • Top view
  • Bottom view
  • Flatten binary tree
  • Nodes at distance K
  • Morris traversal

4.9.49Quick Revision

Remember the basic structure:

Node

/ \

Left Right

  • Each node has at most two children.
  • The four major traversals:
  • Preorder:
  • Node → Left → Right
  • Inorder:
  • Left → Node → Right
  • Postorder:
  • Left → Right → Node
  • Level Order:
  • Level by Level
  • For a tree with n nodes:
  • Traversal → O(n)

Search → O(n)

Height → O(n)

Recursion uses:

O(h)

  • auxiliary space.
  • The key structural types are:
  • Binary Tree

┌───┼─────────────┐

↓ ↓ ↓

Full Complete Perfect

And the most important distinction is:

A Binary Tree only restricts each node to at most two children. A Binary Search Tree additionally imposes an ordering relationship between left and right subtrees.

That distinction leads directly into 4.10 Binary Search Trees, where the tree structure is combined with an ordering property to support much faster searching in balanced cases.

4.10Binary Search Trees

4.10Binary Search Trees

A Binary Search Tree (BST) is a specialized binary tree that maintains an ordering relationship between the values in its left and right subtrees.

For every node:

Left Subtree < Node < Right Subtree

Example:

50

/ \

30 70

/ \ / \

20 40 60 80

Here:

  • All values in the left subtree of 50 are smaller than 50.
  • All values in the right subtree of 50 are greater than 50.
  • The same rule applies recursively to every node.

This ordering makes searching much more efficient than searching an ordinary binary tree when the BST is reasonably balanced.

4.10.1Binary Tree vs Binary Search Tree

  • A Binary Tree only requires:
  • Each node has at most 2 children
  • It does not require any ordering.

Example:

50

/ \

80 20

This is a valid binary tree.

But it is not a valid BST because:

80 > 50

while 80 is in the left subtree.

A BST requires:

50

/ \

< 50 > 50

4.10.2BST Property

Consider:

50

/ \

30 70

/ \ / \

20 40 60 80

For node 50:

Left subtree → 20, 30, 40

Right subtree → 60, 70, 80

Therefore:

20 < 30 < 40 < 50 < 60 < 70 < 80

The ordering is recursively maintained throughout the tree.

4.10.3BST Node

A BST node can be represented using the same structure as a binary-tree node:

class TreeNode:
    def __init__(self, value):
        self.value = value
  • self.left = None
  • self.right = None
  • Create the root:
  • root = TreeNode(50)

4.10.4Searching in a BST

  • Searching is the most important advantage of a BST.
  • Suppose we have:
  • 50

/ \

30 70

/ \ / \

20 40 60 80

  • We want to find:
  • 60
  • Start at 50.
  • Since:
  • 60 > 50
  • we go right.
  • 50

\

70

/

  • 60
  • Now:
  • 60 < 70

so go left.

We find 60.

Instead of examining every node, we eliminate an entire subtree at every step.

4.10.5BST Search Algorithm

def search(root, target):
if root is None:
return False
if root.value == target:
return True
if target < root.value:
return search(root.left, target)
return search(root.right, target)

Example:

print(search(root, 60))

Output:

True

4.10.6Iterative BST Search

The same operation can be implemented without recursion:

def search(root, target):
current = root
while current:
if current.value == target:
return True
if target < current.value:
current = current.left
else:
current = current.right
return False

This avoids recursion stack usage.

4.10.7BST Search Complexity

  • The complexity depends on the height of the tree.
  • Time = O(h)
  • where h is the height.
  • For a balanced BST:
  • h = O(log n)

Therefore:

Search = O(log n)

For a completely skewed BST:

h = O(n)

Therefore:

Search = O(n)

This distinction is extremely important.

4.10.8Inserting into a BST

  • To insert a value, we follow the BST ordering rule.
  • Consider:
  • 50

/ \

30 70

  • Insert:
  • 40
  • Since:
  • 40 < 50
  • go left.
  • Then:
  • 40 > 30
  • go right.
  • So:
  • 50

/ \

30 70

\

40

4.10.9BST Insert Algorithm

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

Usage:

root = None

for value in [50, 30, 70, 20, 40, 60, 80]:
root = insert(root, value)

Result:

50

/ \

30 70

/ \ / \

20 40 60 80

4.10.10BST Insertion Complexity

Insertion follows one path from root to the insertion location.

Therefore:

  • Time = O(h)
  • Balanced:
  • O(log n)
  • Skewed:

O(n)

4.10.11Handling Duplicate Values

  • A BST needs a rule for duplicate values.
  • Common strategies include:
  • Strategy 1 — Ignore duplicates
if value < root.value:

...

elif value > root.value:

...

  • Equal values are ignored.
  • Strategy 2 — Store duplicates on the left
  • value <= node
  • Strategy 3 — Store duplicates on the right
  • value >= node
  • Strategy 4 — Store a count
  • Instead of creating multiple nodes:
  • Node:
value = 50
count = 3

The appropriate strategy depends on the application.

4.10.12Inorder Traversal of a BST

One of the most important BST properties is:

  • Inorder traversal produces values in sorted order.
  • Consider:
  • 50

/ \

30 70

/ \ / \

20 40 60 80

  • Inorder:
  • 20 30 40 50 60 70 80
  • Recall:
  • Inorder:
  • Left → Node → Right

This is why inorder traversal is extremely important for BST problems.

4.10.13Getting Sorted Values from a BST

def inorder(root, result):
    if root is None:
        return
  • inorder(root.left, result)
  • result.append(root.value)
  • inorder(root.right, result)
  • Usage:
result = []

inorder(root, result)

print(result)

Output:

\[20, 30, 40, 50, 60, 70, 80\]

A BST can therefore act as an ordered data structure.

4.10.14Finding Minimum Value

In a BST, the minimum value is always located at the leftmost node.

Example:

50

/

30

/

20

/

  • 10
  • Minimum:
  • 10
  • Algorithm:
def find_min(root):
if root is None:
return None
current = root
while current.left:
current = current.left
return current.value

Complexity:

O(h)

4.10.15Finding Maximum Value

Similarly, the maximum value is at the rightmost node.

def find_max(root):
if root is None:
return None
current = root
while current.right:
current = current.right
return current.value

Complexity:

O(h)

Balanced BST:

O(log n)

4.10.16BST Deletion

  • Deletion is the most complicated basic BST operation.
  • There are three major cases:
  • Delete a leaf node.
  • Delete a node with one child.
  • Delete a node with two children.

4.10.17Case 1 — Delete a Leaf Node

Consider:

50

/ \

30 70

/

  • 20
  • Delete:
  • 20

Since 20 has no children, simply remove it:

50

/ \

30 70

This is the simplest deletion case.

4.10.18Case 2 — Delete a Node with One Child

Consider:

50

/

30

/

  • 20
  • Delete:
  • 30
  • Node 30 has one child:
  • 20
  • Replace 30 with 20:
  • 50

/

20

4.10.19Case 3 — Delete a Node with Two Children

Consider:

50

/ \

30 70

/ \ / \

20 40 60 80

  • Delete:
  • 50
  • The node has two children.

A common strategy is to replace it with its:

  • Inorder successor
  • The inorder successor is the smallest value in the right subtree.
  • Right subtree:
  • 70

/ \

60 80

  • Smallest value:
  • 60
  • Replace 50 with 60:
  • 60

/ \

30 70

/ \ \

20 40 80

Then remove the original 60.

4.10.20Inorder Successor

  • The inorder successor of a node is the next larger value in sorted order.
  • For:
  • 20 30 40 50 60 70 80
  • the successor of 50 is:
  • 60

If the node has a right subtree, the successor is usually the leftmost node in that right subtree.

4.10.21Inorder Predecessor

  • The inorder predecessor is the next smaller value.
  • For:
  • 20 30 40 50 60 70 80
  • the predecessor of 50 is:
  • 40

If the node has a left subtree, the predecessor is usually the rightmost node in that left subtree.

4.10.22BST Delete Implementation

def delete(root, value):
    if root is None:
        return None
        if value < root.value:
            root.left = delete(root.left, value)
elif value > root.value:
    root.right = delete(root.right, value)
else:
    # Case 1 and Case 2
if root.left is None:
return root.right
if root.right is None:
return root.left

# Case 3: two children

successor = root.right

while successor.left:
successor = successor.left
  • root.value = successor.value
  • root.right = delete(
  • root.right,
  • successor.value

)

return root

Complexity:

O(h)

4.10.23BST Complexity

For a BST with height h:

OperationComplexity
SearchO(h)
InsertO(h)
DeleteO(h)
Find MinimumO(h)
Find MaximumO(h)
Inorder TraversalO(n)

Balanced BST:

h = O(log n)

Therefore:

  • Search → O(log n)
  • Insert → O(log n)
  • Delete → O(log n)
  • Worst-case skewed BST:

h = O(n)

Therefore:

  • Search → O(n)
  • Insert → O(n)
  • Delete → O(n)

4.10.24Best, Average and Worst Cases

BST performance depends heavily on tree shape.

Best Case

A very balanced structure:

50

/ \

30 70

Height is small.

Average Case

For reasonably distributed insertions, performance can often be around:

O(log n)

but the exact behavior depends on the insertion sequence and assumptions.

Worst Case

  • Insert sorted values:
  • 10
  • 20
  • 30
  • 40
  • 50
  • The tree becomes:
  • 10

\

20

\

30

\

40

\

50

Now:

h = O(n)

and operations degrade to:

O(n)

4.10.25Why Sorted Insertion Is Dangerous

  • Suppose we insert:
  • 10, 20, 30, 40, 50, 60
  • The BST becomes:
  • 10

\

20

\

30

\

40

\

50

\

60

It has effectively become a linked list.

Therefore, a basic BST does not automatically guarantee O(log n) performance.

4.10.26Balanced Binary Search Trees

  • To prevent severe skewing, specialized self-balancing BSTs are used.
  • Important examples:
  • AVL Tree
  • Red-Black Tree
  • They maintain height close to:
  • O(log n)

Therefore, operations remain approximately:

  • Search → O(log n)
  • Insert → O(log n)
  • Delete → O(log n)

These structures are especially important in systems that need reliable ordered-map/set performance.

4.10.27AVL Tree

  • An AVL Tree is a self-balancing Binary Search Tree.
  • For every node:
  • |height(left) - height(right)| ≤ 1

If an insertion or deletion makes the tree unbalanced, rotations are performed.

Example:

10

\

20

\

30

This is unbalanced.

An AVL rotation can transform it into:

20

/ \

10 30

The tree becomes balanced.

4.10.28Red-Black Tree

  • A Red-Black Tree is another self-balancing BST.
  • Each node has an additional color:
  • Red
  • Black

The tree follows specific coloring rules that ensure its height remains O(log n).

Red-Black Trees are widely used in implementations of ordered maps and sets in various programming languages and libraries.

4.10.29BST Rotations

  • Rotations are local restructuring operations used to maintain balance.
  • Right Rotation
  • Before:
  • 30

/

20

/

  • 10
  • After right rotation:
  • 20

/ \

10 30

  • Left Rotation
  • Before:
  • 10

\

20

\

  • 30
  • After:
  • 20

/ \

10 30

Rotations preserve the BST ordering property.

4.10.30Kth Smallest Element

Because inorder traversal of a BST is sorted, the kth smallest element can be found using inorder traversal.

Example:

  • 20 30 40 50 60 70 80
  • The:
  • 1st smallest = 20
  • 2nd smallest = 30
  • 3rd smallest = 40
  • Implementation:
def kth_smallest(root, k):
    stack = []
    current = root
    while True:
        while current:
            stack.append(current)
current = current.left
current = stack.pop()

k -= 1

if k == 0:
return current.value
current = current.right

This is a very common interview problem.

4.10.31Kth Largest Element

Similarly, reverse inorder traversal gives values from largest to smallest:

Right → Node → Left

Example:

80 70 60 50 40 30 20

The kth largest can therefore be found efficiently using reverse inorder traversal.

4.10.32Validate a BST

  • A common interview problem asks:
  • Is this binary tree actually a valid BST?
  • Consider:
  • 10

/ \

5 15

/ \

6 20

This is not a valid BST.

Why?

Because 6 is in the right subtree of 10, but:

6 < 10

Simply comparing each node with its immediate parent is not sufficient.

4.10.33Valid BST Using Bounds

A robust approach passes valid minimum and maximum bounds.

def is_valid_bst(root, low=float("-inf"), high=float("inf")):
    if root is None:
        return True
        if not (low < root.value < high):
            return False
            return (
                is_valid_bst(root.left, low, root.value)
                and is_valid_bst(root.right, root.value, high)
            )

Complexity:

Time = O(n)

Space = O(h)

This is an important recursive pattern.

4.10.34BST as a Sorted Set

  • A BST can be used conceptually to maintain a collection of unique values while preserving sorted order.
  • For example:
  • Insert:
  • 50, 20, 70, 10, 30
  • Inorder traversal produces:
  • 10 20 30 50 70

This makes BSTs useful when we need:

  • Ordered data
  • Search
  • Insert
  • Delete
  • Minimum/maximum
  • Predecessor/successor

4.10.35Predecessor and Successor

For:

50

/ \

30 70

/ \ / \

20 40 60 80

For 50:

Predecessor = 40
Successor   = 60

These operations are important in ordered data structures.

4.10.36Range Search

  • Suppose we want all values between:
  • 35 and 65
  • in:
  • 50

/ \

30 70

/ \ / \

20 40 60 80

The values are:

40, 50, 60

A BST lets us skip subtrees that cannot contain values in the desired range.

This can be much more efficient than scanning every node, depending on tree balance and the size of the output.

4.10.37BST and Recursion

BST algorithms naturally follow the recursive structure:

Node

/ \

Smaller Larger

For searching:

target < node
left subtree

or:

target > node
right subtree

This is why recursion is particularly natural for BSTs.

4.10.38BST and Inorder Traversal

This relationship is worth memorizing:

BST
Inorder Traversal
Sorted Order

If:

BST = 50, 30, 70, 20, 40, 60, 80

then:

Inorder = 20, 30, 40, 50, 60, 70, 80
  • This property is used in:
  • Kth smallest
  • Kth largest
  • BST validation
  • Sorted output
  • Predecessor/successor
  • Range queries

4.10.39Building a Balanced BST from a Sorted Array

Suppose we have:

\[1, 2, 3, 4, 5, 6, 7\]

Choosing the middle element as the root:

4

/ \

2 6

/ \ / \

1 3 5 7

produces a balanced BST.

Implementation:

def sorted_array_to_bst(numbers):
if not numbers:
return None
middle = len(numbers) // 2
root = TreeNode(numbers[middle])

root.left = sorted_array_to_bst(

numbers[:middle]

)

root.right = sorted_array_to_bst(

numbers[middle + 1:]

)

return root

Complexity with slicing:

can involve extra copying. An index-based implementation avoids that extra copying and achieves:

Time = O(n)

Space = O(log n)

for a balanced result, excluding the output tree itself.

4.10.40BST Applications

  • BSTs are useful when applications require ordered data with dynamic updates.
  • Examples include:
  • Ordered sets
  • Ordered maps
  • Symbol tables
  • Searching
  • Range queries
  • Scheduling
  • Maintaining sorted data
  • Predecessor/successor queries
  • Database/indexing concepts

Self-balancing variants are particularly useful when predictable logarithmic performance is required.

4.10.41BST in Database Systems

Database indexes often use tree-based structures, although practical database systems commonly use B-Trees or B+ Trees rather than ordinary binary search trees.

The fundamental idea is similar:

Key
Ordered structure
Efficient search

For example:

Find customer ID = 105023

An index can avoid scanning every record.

This is one reason tree-based indexing is fundamental in database systems.

4.10.42BST vs Hash Table

This is an important interview comparison.

FeatureBSTHash Table
SearchO(h)O(1) average
InsertO(h)O(1) average
DeleteO(h)O(1) average
Sorted orderYesNot the primary purpose
Min/MaxO(h)Not naturally ordered
Range queriesGood with ordered treeUsually less natural
Worst-case basic structureO(n)O(n)
Balanced BSTO(log n)O(1) average
  • Use a hash table when:
  • Fast exact key lookup is the main requirement.
  • Use a BST/ordered tree when:
  • Ordering, ranges, predecessor/successor, or sorted traversal matter.

4.10.43BST vs Heap

FeatureBSTHeap
Main purposeOrdered searchingPriority access
Search arbitrary valueEfficient when balancedO(n)
MinimumO(log n) in balanced BSTO(1) in min-heap
MaximumO(log n) in balanced BSTO(n) in min-heap
InsertO(log n) balancedO(log n)
Delete arbitraryO(log n) balancedUsually O(n) without extra indexing
Sorted traversalYesNo
StructureOrdered by subtreesComplete binary tree

A heap is primarily designed for priority queues.

A BST is designed for ordered searching.

4.10.44Common BST Interview Problems

Beginner

  • Search in a BST.
  • Insert into a BST.
  • Find minimum.
  • Find maximum.
  • Inorder traversal.
  • Find predecessor.
  • Find successor.
  • Calculate height.

Intermediate

  • Delete a node.
  • Validate BST.
  • Kth smallest element.
  • Kth largest element.
  • Lowest Common Ancestor in BST.
  • Range Sum of BST.
  • Search for a value range.
  • Convert sorted array to BST.
  • Find closest value.
  • Find floor and ceiling.

Advanced

  • Construct BST from preorder.
  • Recover a corrupted BST.
  • Serialize and deserialize BST.
  • Balance an unbalanced BST.
  • Trim a BST.
  • Merge two BSTs.
  • Two Sum in a BST.
  • Find median in a BST.

Convert BST to sorted doubly linked list.

Implement an iterator for BST.

4.10.45Important BST Interview Pattern: Two Sum

Given a BST:

5

/ \

3 6

/ \ \

2 4 7

  • Target:
  • 9
  • Possible pair:
  • 2 + 7 = 9
  • One approach is:
BST
Inorder
Sorted Array
Two Pointers

This combines BST traversal with the Two Pointers pattern.

4.10.46Important BST Interview Pattern: Floor

The floor of a target is the greatest value in the BST that is less than or equal to the target.

  • Suppose:
  • Values:
  • 10, 20, 30, 40, 50
  • For:
target = 35
  • the floor is:
  • 30
  • For:
target = 40

the floor is:

40

BST ordering allows us to find the answer efficiently.

4.10.47Important BST Interview Pattern: Ceiling

  • The ceiling is the smallest value greater than or equal to the target.
  • For:
  • Values:
  • 10, 20, 30, 40, 50
  • and:
target = 35

the ceiling is:

40

Floor and ceiling problems are common in ordered-set implementations.

4.10.48BST Iterator

  • A BST iterator can return values in ascending order.
  • For:
  • 50

/ \

30 70

/ \ / \

20 40 60 80

  • the iterator should produce:
  • 20
  • 30
  • 40
  • 50
  • 60
  • 70
  • 80
  • A stack can simulate inorder traversal without storing the entire sorted list.
  • This combines:
  • BST

+

Inorder Traversal

+

Stack

and is a common interview problem.

4.10.49Complexity Summary

For a BST with height h:

OperationComplexity
SearchO(h)
InsertO(h)
DeleteO(h)
MinO(h)
MaxO(h)
PredecessorO(h)
SuccessorO(h)
Inorder traversalO(n)

Balanced BST

h = O(log n)

Therefore:

Search → O(log n)

Insert → O(log n)

Delete → O(log n)

Skewed BST

h = O(n)

Therefore:

Search → O(n)

Insert → O(n)

Delete → O(n)

4.10.50Key Takeaways

The most important BST rule is:

Node

/ \

< >

/ \

Smaller Larger

For every node:

All values in the left subtree are smaller, and all values in the right subtree are larger, subject to the chosen duplicate policy.

  • Remember:
  • Search → O(h)
  • Insert → O(h)
  • Delete → O(h)
  • where h is the tree height.
  • For a balanced BST:
  • h = O(log n)
  • so:
  • Search → O(log n)
  • Insert → O(log n)
  • Delete → O(log n)

The most important BST property to memorize is:

BST
Inorder Traversal
Sorted Order

And the three deletion cases are:

1. Leaf node

Remove

2. One child

Replace with child

3. Two children

Replace with inorder

successor/predecessor

Finally, remember the major distinction:

A normal Binary Search Tree can degrade to O(n). Self-balancing BSTs such as AVL and Red-Black Trees maintain O(log n) height.

The next topic, 4.11 Heaps, changes the focus from ordered searching to efficient priority-based access.

Heaps

A Heap is a special complete binary tree used when we need to repeatedly get the minimum or maximum element quickly.

  • The two main types are:
  • Min Heap → smallest element is always at the top.
  • Max Heap → largest element is always at the top.

1. What is a Heap?

Consider this Min Heap:

10

/ \

20 15

/ \ / \

30 40 25 50

  • Notice:
  • Parent <= Children
  • So:
  • 10 ≤ 20
  • 10 ≤ 15
  • 20 ≤ 30
  • 20 ≤ 40
  • 15 ≤ 25
  • 15 ≤ 50

Therefore, this is a Min Heap.

For a Max Heap, the opposite is true:

50

/ \

40 45

/ \ / \

20 30 35 10

Here:

Parent >= Children

2. Two Important Properties

A heap has two properties.

  • Property 1: Complete Binary Tree
  • All levels are completely filled except possibly the last level.
  • The last level is filled from left to right.
  • Valid:
  • 10

/ \

20 30

/ \ /

  • 40 50 60
  • Invalid:
  • 10

/ \

20 30

\ \

40 50

  • Property 2: Heap Order
  • For a Min Heap:
  • Parent <= Child
  • For a Max Heap:
  • Parent >= Child

3. Why do we need Heaps?

Suppose you have:

\[50, 20, 10, 40, 30, 60\]
  • And you repeatedly need the smallest number.
  • Sorting first would cost:
  • O(n log n)
  • A heap allows us to efficiently maintain the smallest element.
  • With a Min Heap:
  • 10

/ \

20 60

/ \ /

40 30 50

The minimum is always:

root = 10

Getting it is:

O(1)

4. Heap vs Binary Search Tree

This is an important interview question.

BST

50

/ \

30 70

/ \ / \

20 40 60 80

  • BST property:
  • Left < Root < Right
  • Heap
  • 20

/ \

30 40

/ \ / \

50 60 70 80

Heap property:

Parent <= Children

A heap does not guarantee that the left subtree is smaller than the right subtree.

5. Heap Representation Using an Array

This is one of the most important concepts.

  • We don't normally need to create a tree using nodes.
  • Instead:
  • 10

/ \

20 15

/ \ /

30 40 25

can be stored as:

\[10, 20, 15, 30, 40, 25\]
  • The relationship between parent and children can be calculated using indexes.
  • For an element at index i:
  • Parent
  • parent = (i - 1) // 2
  • Left child
left = 2 * i + 1

Right child

right = 2 * i + 2

Example:

Array:

index: 0 1 2 3 4 5

\[10, 20, 15, 30, 40, 25\]

For index 1:

value = 20

left child = 2(1) + 1 = 3

right child = 2(1) + 2 = 4

Therefore:

20

/ \

30 40

6. Inserting into a Heap

Suppose we have:

10

/ \

20 15

/ \

30 40

Array:

\[10, 20, 15, 30, 40\]

Now insert:

5

First, put it at the next available position:

10

/ \

20 15

/ \ /

30 40 5

But this violates the Min Heap property:

5 < 15

So we move 5 upward.

  • This operation is called:
  • Heapify Up
  • 10

/ \

20 5

/ \ /

30 40 15

  • Still:
  • 5 < 10
  • Move again:
  • 5

/ \

20 10

/ \ /

30 40 15

Now the heap is valid.

  • Complexity
  • Insertion:
  • O(log n)

because the element can move from the bottom to the root.

7. Removing the Minimum

In a Min Heap, the minimum is always:

  • root
  • Suppose:
  • 5

/ \

20 10

/ \ /

30 40 15

We want to remove 5.

We replace the root with the last element:

15

/ \

20 10

/ \

30 40

Now the heap property is broken:

15 > 10

So we move 15 downward.

This is called:

Heapify Down

10

/ \

20 15

/ \

  • 30 40
  • Complexity
  • Extract Min = O(log n)

8. Heap Operations

OperationComplexity
Get Min/MaxO(1)
InsertO(log n)
Extract Min/MaxO(log n)
DeleteO(log n)
SearchO(n)
Build HeapO(n)

That last one is important:

Building a heap from an unsorted array can be done in O(n), not O(n log n).

9. Python Heap

Python provides a built-in module:

import heapq

Python's heapq implements a Min Heap.

Create heap

import heapq
nums = [40, 10, 30, 20, 50]

heapq.heapify(nums)

print(nums)

The exact array arrangement may look like:

\[10, 20, 30, 40, 50\]

The important point is that it satisfies the heap property.

10. Insert

Use:

heapq.heappush(heap, value)

Example:

import heapq
heap = []
  • heapq.heappush(heap, 30)
  • heapq.heappush(heap, 10)
  • heapq.heappush(heap, 20)
print(heap)

Conceptually:

10

/ \

30 20

11. Get Minimum

Simply:

heap[0]

Example:

print(heap[0])

Complexity:

O(1)

12. Remove Minimum

Use:

heapq.heappop(heap)

Example:

minimum = heapq.heappop(heap)
print(minimum)
  • This removes and returns the smallest element.
  • Complexity:
  • O(log n)

13. Max Heap in Python

Python's heapq is a Min Heap.

To simulate a Max Heap, store negative values.

import heapq
heap = []
  • heapq.heappush(heap, -30)
  • heapq.heappush(heap, -10)
  • heapq.heappush(heap, -20)
maximum = -heapq.heappop(heap)
print(maximum)

Output:

  • 30
  • Why?
  • Internally:
\[-30, -10, -20\]

The smallest negative number corresponds to the largest original number.

14. Very Important: Heap ≠ Sorted Array

Suppose:

heap = [10, 20, 15, 40, 30, 25]

You cannot assume:

  • 10 < 20 < 15 < 40 < 30 < 25
  • The heap only guarantees:
  • parent <= children
  • So:
  • 10

/ \

20 15

/ \ /

40 30 25

The array is not necessarily sorted.

15. Where are Heaps Used?

Heaps are extremely important in coding interviews.

Common applications:

1. Priority Queue

Highest priority item comes first

2. Top K Problems

  • Examples:
  • Top K largest numbers
  • Top K smallest numbers
  • Kth largest element
  • Kth smallest element

3. Scheduling

  • CPU scheduling
  • Task scheduling
  • Event processing

4. Dijkstra's Algorithm

Priority queues/heaps are commonly used to efficiently select the next closest vertex.

5. Merge K Sorted Lists

A heap can keep track of the smallest current element from each list.

6. Median of a Stream

Use:

Max Heap + Min Heap

This is a very common interview problem.

16. The Most Important Heap Pattern

  • If the problem says:
  • "Find the K largest/smallest..."
  • Immediately think:
  • HEAP
  • For example:
  • Find the 3rd largest number.
  • Instead of sorting the entire array:
  • sorted(nums)

we can maintain a heap of size k.

For a large dataset:

n = 1,000,000
k = 10

A heap of size 10 can be much more efficient than fully sorting one million elements.

17. Heap Mental Model

Remember this:

HEAP

┌────────┴────────┐

│ │

Min Heap Max Heap

│ │

minimum top maximum top

│ │

Parent ≤ Parent ≥

children children

And the core operations:

INSERT
Heapify Up
DELETE ROOT
Replace with last
Heapify Down

🎯 What you should master for interviews

For Heaps, make sure you can explain and implement these without looking at notes:

  • Min Heap
  • Max Heap
  • Complete Binary Tree
  • Array representation
  • Parent/child index formulas
  • Heapify Up
  • Heapify Down
  • Insert
  • Extract Min/Max
  • Build Heap
  • Priority Queue
  • Kth Largest / Kth Smallest
  • Top K Frequent Elements
  • Merge K Sorted Lists
  • Find Median from Data Stream

The next natural step after understanding this is to implement a Min Heap from scratch in Python, without using heapq. That will make the heap concept much clearer.

Module 4 · Lesson 4.12

Graphs

  • Graphs in DSA
  • A Graph is a data structure used to represent relationships or connections between objects.
  • Think of:
  • 🏙️ Cities connected by roads
  • 👥 People connected through friendships
  • 💻 Computers connected in a network
  • 📦 Dependencies between software packages
  • 🗺️ Locations connected by routes
  • A graph consists of:
  • Vertices (Nodes) + Edges (Connections)

1. Simple Graph Example

Suppose we have 4 cities:

A ---- B

| |

| |

C ---- D

  • Here:
  • Vertices
  • A, B, C, D
  • There are 4 vertices.
  • Edges
  • A-B
  • A-C
  • B-D
  • C-D
  • There are 4 edges.
  • So:
V = 4
E = 4

2. Graph Terminology

  • These terms are extremely important.
  • Vertex / Node
  • A point in the graph.
  • A
  • Edge
  • Connection between two vertices.

A ---- B

Adjacent Vertices

Two vertices directly connected by an edge.

A ---- B

A and B are adjacent.

  • Degree
  • Number of edges connected to a vertex.
  • B

|

C --- A --- D

Degree of A:

3

because A has connections to B, C and D.

3. Directed Graph

In a directed graph, edges have a direction.

A ----> B

This means:

  • A → B
  • but it doesn't necessarily mean:
  • B → A

Example:

Google → YouTube

could represent a directed relationship.

4. Undirected Graph

An undirected edge works in both directions.

A ---- B

  • means:
  • A → B
  • B → A

Example:

Person A ---- Person B

If they are friends, the relationship is usually mutual.

5. Weighted Graph

Sometimes edges have a value/cost.

A ----5---- B

| |

10 3

| |

C ----2---- D

  • For example, these numbers could represent:
  • Distance
  • Cost
  • Time
  • Network latency
  • So:
  • A → B = 5
  • A → C = 10

6. Unweighted Graph

Edges don't have a weight.

A ---- B

| |

C ---- D

The important information is simply whether a connection exists.

7. Cyclic Graph

A graph contains a cycle if you can start at a node and return to it by following edges.

A ---- B

| |

| |

D ---- C

You can travel:

A → B → C → D → A

Therefore, it contains a cycle.

8. Acyclic Graph

No cycles.

A

|

B

|

C

|

D

You cannot return to a previously visited node by following the edges.

A directed acyclic graph is called a:

  • DAG
  • Directed Acyclic Graph
  • DAGs are extremely important for:
  • Task scheduling
  • Course prerequisites
  • Build systems
  • Dependency management

9. Connected Graph

A graph is connected if every vertex can be reached from every other vertex.

A ---- B

| |

C ---- D

All nodes are connected.

But:

A ---- B C ---- D

has two separate components.

So it is not connected.

We call:

{A, B}

one connected component and:

{C, D}

another.

10. How Do We Store a Graph?

This is one of the most important parts of graph problems.

  • There are three common representations:
  • Adjacency Matrix
  • Adjacency List
  • Edge List

11. Adjacency Matrix

Suppose:

A ---- B

| |

C ---- D

We can create:

A B C D

A 0 1 1 0

B 1 0 0 1

C 1 0 0 1

D 0 1 1 0

  • 1 means an edge exists.
  • 0 means no edge.
  • Python
graph = [
    [0, 1, 1, 0],
    [1, 0, 0, 1],
    [1, 0, 0, 1],
\[0, 1, 1, 0\]

]

  • Complexity
  • Space:
  • O(V²)
  • Checking whether an edge exists:

O(1)

This is useful when the graph is dense.

12. Adjacency List

This is the representation you'll use very frequently in coding interviews.

For:

A ---- B

| |

C ---- D

we can represent it as:

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

This means:

  • A → B
  • A → C
  • and:
  • B → A
  • B → D
  • etc.
  • Complexity
  • Space:

O(V + E)

This is generally much more efficient for sparse graphs.

13. Edge List

Simply store all edges:

edges = [
    ("A", "B"),
    ("A", "C"),
    ("B", "D"),
    ("C", "D")
]

For weighted graph:

edges = [
    ("A", "B", 5),
    ("A", "C", 10),
    ("B", "D", 3)
]

14. Graph in Python

A very common representation:

graph = {
    0: [1, 2],
    1: [0, 3],
    2: [0, 3],
    3: [1, 2]
}

Visualization:

0

/ \

1 2

\ /

3

15. Adding an Edge

For an undirected graph:

graph = {
    0: [],
    1: [],
    2: [],
    3: []
}
  • graph[0].append(1)
  • graph[1].append(0)
  • Now:

0 ---- 1

  • Why add both?
  • Because the graph is undirected.
  • For a directed graph:
  • graph[0].append(1)
  • Only one direction is stored:

0 ----> 1

16. Graph Traversal

This is where graphs become really important.

  • There are two fundamental traversal algorithms:
  • BFS
  • Breadth-First Search
  • DFS
  • Depth-First Search
  • For example:
  • A

/ \

B C

/ \ \

D E F

BFS

Visits level by level:

A → B → C → D → E → F

DFS

Goes deep first:

A → B → D → E → C → F

The exact DFS order can vary depending on adjacency ordering.

17. BFS Uses a Queue

Remember:

BFS → Queue

Example:

from collections import deque
queue = deque(["A"])
visited = set(["A"])
while queue:
    node = queue.popleft()
    print(node)
    for neighbor in graph[node]:
        if neighbor not in visited:
            visited.add(neighbor)
  • queue.append(neighbor)
  • The basic idea:
  • A

/ \

B C

/ \

D E

Start:

Queue = [A]

Process A:

Queue = [B, C]

Process B:

Queue = [C, D, E]

Process C:

Queue = [D, E]

And so on.

18. DFS Uses Stack / Recursion

  • Remember:
  • DFS → Stack
  • Recursive DFS:
def dfs(node, visited):
    if node in visited:
        return

visited.add(node)

print(node)
for neighbor in graph[node]:
    dfs(neighbor, visited)
  • Call:
  • visited = set()
  • dfs("A", visited)
  • Recursion internally behaves like a stack.

You can also implement DFS explicitly:

stack = ["A"]
visited = set()
while stack:
    node = stack.pop()
    if node in visited:
        continue

visited.add(node)

print(node)
for neighbor in graph[node]:
    if neighbor not in visited:
        stack.append(neighbor)

19. Why visited is Important

Consider:

A ---- B

| |

| |

C ---- D

If you don't maintain a visited set, you could keep going:

A → B → D → C → A → B → D → ...

forever.

Therefore:

visited = set()

is one of the most important patterns in graph algorithms.

20. BFS vs DFS

FeatureBFSDFS
Data structureQueueStack
PythondequeStack / recursion
StrategyLevel by levelGo deep
Shortest path in unweighted graph❌ generally
Cycle detection
Connected components
Maze problems
Tree traversalLevel orderPreorder/Inorder/Postorder

21. Graph Complexity

  • With an adjacency list:
  • V = number of vertices
  • E = number of edges
  • BFS:

O(V + E)

DFS:

O(V + E)

Space:

O(V + E)

This is one of the most important complexity facts to remember.

22. Important Graph Algorithms

Once you understand graphs, learn these in roughly this order:

Graph Representation
BFS
DFS
Connected Components
Cycle Detection
Shortest Path
Topological Sort
Union Find
Dijkstra
Minimum Spanning Tree
  • Important algorithms include:
  • Traversal
  • BFS
  • DFS
  • Shortest Path
  • BFS for unweighted graphs
  • Dijkstra
  • Bellman-Ford
  • Floyd-Warshall
  • Minimum Spanning Tree
  • Kruskal
  • Prim
  • Directed Graph
  • Topological Sort
  • Cycle Detection

23. Common Interview Problems

  • When you see these patterns, think Graph.
  • Problem 1
  • Find whether two people are connected.
  • Think:
  • BFS / DFS
  • Problem 2
  • Find the shortest path in an unweighted graph.
  • Think:
  • BFS
  • Problem 3
  • Count the number of islands.
  • Think:
  • DFS / BFS
  • Problem 4
  • Detect a cycle.
  • Think:
  • DFS / BFS / Union Find
  • depending on the graph type.
  • Problem 5
  • Course prerequisites.
  • Think:
  • Directed Graph

+

  • Cycle Detection / Topological Sort
  • Problem 6
  • Shortest distance with weighted edges.
  • Think:
  • Dijkstra

24. Graph Mental Model

Keep this picture in your mind:

GRAPH

┌─────────────┴─────────────┐

│ │

Directed Undirected

│ │

┌──────┴──────┐ │

DAG Cycles │

│ │

└─────────────┬─────────────┘

Representation

┌────────────┼────────────┐

│ │ │

Matrix List Edge List

Traversal

┌──────┴──────┐

│ │

BFS DFS

│ │

Queue Stack/Recursion

  • 🎯 For your DSA study
  • Don't jump directly into Dijkstra or advanced graph problems.
  • First become comfortable with:
  • Graph → Adjacency List → BFS → DFS → Visited Set → Connected Components → Cycle Detection

Once these are solid, Dijkstra, Topological Sort, Union Find, and Minimum Spanning Tree become much easier.

Module 4 · Lesson 4.13

Graph Traversal (BFS & DFS)

  • Graph Traversal — BFS & DFS
  • Graph traversal means visiting all reachable nodes of a graph systematically.
  • The two fundamental traversal algorithms are:
  • BFS — Breadth-First Search
  • DFS — Depth-First Search

These are extremely important for DSA interviews.

1. Example Graph

Let's use this graph throughout:

A

/ \

B C

/ \ \

D E F

Adjacency list:

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

We start traversal from A.

2. BFS — Breadth-First Search

BFS visits nodes level by level.

Think about how people stand in a queue:

First person in → First person out

Therefore:

BFS uses a Queue.

Our graph:

A Level 0

/ \

B C Level 1

/ \ \

D E F Level 2

BFS visits:

A → B → C → D → E → F

3. BFS Step by Step

Start:

Queue = [A]
Visited = {}

Step 1

  • Remove A:
  • Process A
  • Add B and C:
Queue = [B, C]
Visited = {A, B, C}

Step 2

  • Remove B:
  • Process B
  • Add D and E:
Queue = [C, D, E]
Visited = {A, B, C, D, E}

Step 3

  • Remove C:
  • Process C
  • Add F:
Queue = [D, E, F]
  • Continue:
  • D
  • E
  • F
  • Final traversal:

A → B → C → D → E → F

4. BFS Python Implementation

from collections import deque
def bfs(graph, start):
visited = set()
queue = deque()

queue.append(start)

visited.add(start)

while queue:
    node = queue.popleft()
    print(node)
    for neighbor in graph[node]:
        if neighbor not in visited:
            visited.add(neighbor)
  • queue.append(neighbor)
  • Call:
  • bfs(graph, "A")

Output:

  • A
  • B
  • C
  • D
  • E
  • F

5. Why Do We Need visited?

Consider:

A ---- B

| |

| |

C ---- D

If we don't track visited nodes:

A → B → D → C → A → B → D → C → ...

We could keep looping forever.

So we maintain:

visited = set()

This is one of the most important graph patterns.

6. DFS — Depth-First Search

  • DFS means:
  • Go as deep as possible before coming back.
  • Think of exploring a maze.
  • Instead of:
  • A

├── B

└── C

  • visiting both B and C first, DFS goes down one path.
  • For our graph:
  • A

/ \

B C

/ \ \

D E F

One possible DFS traversal:

A → B → D → E → C → F

The exact DFS order depends on the order of neighbors in the adjacency list.

7. DFS Using Recursion

DFS is naturally implemented using recursion.

def dfs(graph, node, visited):
    if node in visited:
        return

visited.add(node)

print(node)
for neighbor in graph[node]:
    dfs(graph, neighbor, visited)
  • Call:
  • visited = set()
  • dfs(graph, "A", visited)

Output:

  • A
  • B
  • D
  • E
  • C
  • F

8. How Does Recursive DFS Work?

  • Start:
  • dfs(A)
  • A has:
  • B, C

So go to B:

  • dfs(B)
  • B has:
  • D, E
  • Go to D:
  • dfs(D)
  • D has no children.
  • Return to B.
  • Then:
  • dfs(E)
  • E has no children.
  • Return to B.
  • Return to A.
  • Then:
  • dfs(C)
  • Then:
  • dfs(F)
  • Result:

A → B → D → E → C → F

The recursion stack remembers where we need to return.

9. DFS Without Recursion

You can also implement DFS using an explicit stack.

  • Remember:
  • BFS → Queue
  • DFS → Stack
def dfs(graph, start):
    visited = set()
    stack = [start]
    while stack:
        node = stack.pop()
        if node in visited:
            continue

visited.add(node)

print(node)
for neighbor in graph[node]:
    if neighbor not in visited:
        stack.append(neighbor)

This is iterative DFS.

10. BFS vs DFS

This is something you should memorize conceptually.

BFSDFS
Full nameBreadth-First SearchDepth-First Search
StrategyLevel by levelGo deep first
UsesQueueStack
Python structuredequelist / recursion
Shortest path, unweighted graph
Cycle detection
Connected components
Maze/path exploration
Tree level order

11. The Most Important Difference

Imagine:

A

/ \

B C

/ \

D E

/ \

F G

  • BFS
  • A
  • B C
  • D E
  • F G
  • Traversal:

A → B → C → D → E → F → G

DFS

A

|

B

|

D

|

F

Then backtrack:

A → B → D → F → C → E → G

So:

BFS explores wide. DFS explores deep.

12. BFS for Shortest Path

This is one of the most important uses of BFS.

Suppose:

A ---- B ---- D

| |

| |

C ---- E ---- F

  • Every edge has equal cost.
  • If you want the minimum number of edges from A to F, BFS is ideal.
  • BFS explores:
  • Distance 0:
  • A
  • Distance 1:
  • B, C
  • Distance 2:
  • D, E
  • Distance 3:
  • F

Therefore:

Shortest distance = 3

A key rule:

BFS finds shortest paths in an unweighted graph.

13. BFS with Distance

A common interview pattern:

from collections import deque
def shortest_path(graph, start, target):
    queue = deque([(start, 0)])
    visited = {start}
    while queue:
        node, distance = queue.popleft()
if node == target:
    return distance
    for neighbor in graph[node]:
        if neighbor not in visited:
            visited.add(neighbor)

queue.append((neighbor, distance + 1))

return -1

Example:

distance = shortest_path(graph, "A", "F")

print(distance)
  • Result:
  • 2
  • because:
  • A → C → F
  • has two edges.

14. DFS for Connected Components

Suppose:

A --- B C --- D

| |

E F

  • There are two connected components:
  • Component 1:
  • A, B, E
  • Component 2:
  • C, D, F
  • DFS can find them.
def count_components(graph):
    visited = set()
    count = 0
    for node in graph:
        if node not in visited:
            count += 1

dfs(graph, node, visited)

return count

This pattern is extremely common.

15. BFS/DFS on a Grid

This is another major interview pattern.

  • Consider:
  • 1 1 0
  • 0 1 0
  • 0 0 1
  • Grid problems often use BFS or DFS.
  • Examples:
  • Number of islands
  • Flood fill
  • Rotten oranges
  • Shortest path in a grid
  • Maze problems
  • Connected regions
  • For example:
  • Number of Islands
  • You see:
  • 1 1 0
  • 1 0 0
  • 0 0 1
  • Think immediately:
  • DFS / BFS

16. Time Complexity

  • For an adjacency-list graph:
  • V = number of vertices
  • E = number of edges
  • BFS

O(V + E)

DFS

O(V + E)

Why?

Each vertex is visited at most once:

O(V)

Each edge is examined:

O(E)

Therefore:

O(V + E)

17. Space Complexity

BFS:

O(V)

  • for:
  • Queue
  • Visited set
  • DFS:

O(V)

  • for:
  • Visited set
  • Recursion stack / explicit stack
  • So:
  • BFS → O(V)
  • DFS → O(V)

18. Interview Decision Rule

When you see a graph problem, ask:

Question 1

Is the graph unweighted and I need the shortest path?

→ BFS

Question 2

Do I need to explore everything deeply?

→ DFS

Question 3

Do I need connected components?

→ BFS or DFS

Question 4

Do I need to detect a cycle?

→ BFS or DFS

Question 5

Is it a grid problem?

→ BFS or DFS

Question 6

  • Is it a weighted shortest-path problem?
  • Don't automatically use BFS.
  • Think:
  • Positive weights → Dijkstra
  • Negative weights → Bellman-Ford

19. The Core Mental Model

Memorize this:

GRAPH

┌───────┴───────┐

│ │

BFS DFS

│ │

Queue Stack

│ │

Level by level Deep first

│ │

Shortest path Exploration

(unweighted) Components

│ Cycles

│ Backtracking

O(V + E)

⭐ What I recommend you practice next

Before moving to Recursion, make sure you can write these four from memory:

  • BFS using deque
  • DFS using recursion
  • DFS using a stack
  • Shortest path using BFS

Then practice these classic problems:

  • 1. Number of Islands
  • 2. Flood Fill
  • 3. Clone Graph
  • 4. Number of Connected Components
  • 5. Shortest Path in an Unweighted Graph
  • 6. Rotting Oranges
  • 7. Detect Cycle in a Graph

These will make BFS and DFS much more intuitive than simply memorizing the definitions.

Module 4 · Lesson 4.14

Recursion

  • Recursion in DSA
  • Recursion is a technique where a function calls itself to solve a smaller version of the same problem.
  • The basic idea is:
Big Problem
Smaller Problem
Even Smaller Problem
Base Case

Recursion is extremely important because it is the foundation for:

  • DFS
  • Tree traversal
  • Divide & Conquer
  • Backtracking
  • Dynamic Programming
  • Binary Search
  • Many coding interview problems

1. The Simplest Example

Let's print numbers from 5 down to 1.

def countdown(n):
    if n == 0:
        return
print(n)

countdown(n - 1)

countdown(5)

Output:

  • 5
  • 4
  • 3
  • 2
  • 1
  • What's happening?
countdown(5)
countdown(4)
countdown(3)
countdown(2)
countdown(1)
countdown(0)
STOP

2. Every Recursion Needs Two Things

This is the most important rule.

A recursive function normally has:

1. Base Case

The condition that stops recursion.

if n == 0:
    return

2. Recursive Case

The function calls itself with a smaller/simpler input.

countdown(n - 1)

So remember:

RECURSION
├── Base Case → STOP
└── Recursive Case → CALL YOURSELF

Without a base case:

def test():
    test()

This keeps calling itself until Python raises:

RecursionError

3. How Recursion Uses the Stack

This is extremely important for understanding recursion.

Consider:

def countdown(n):
    if n == 0:
        return
print(n)
  • countdown(n - 1)
  • Call:
  • countdown(3)
  • Python creates stack frames:

┌───────────────┐

│ countdown(1) │

├───────────────┤

│ countdown(2) │

├───────────────┤

│ countdown(3) │

└───────────────┘

Then countdown(1) calls countdown(0).

At 0, the base case is reached.

Then the stack starts unwinding.

  • Think:
  • CALLING PHASE
  • 3 → 2 → 1 → 0
  • RETURNING PHASE
  • 0 → 1 → 2 → 3

This "going down and coming back up" is the key to recursion.

4. Example: Factorial

Mathematically:

5! = 5 × 4 × 3 × 2 × 1

We can write:

5! = 5 × 4!

and:

4! = 4 × 3!

Therefore:

n! = n × (n-1)!

This naturally becomes recursion.

def factorial(n):
if n == 0:
return 1
return n * factorial(n - 1)

Call:

print(factorial(5))

Output:

120

5. Understand the Execution

  • For:
  • factorial(5)
  • Python does:
  • factorial(5)
  • = 5 × factorial(4)
  • = 5 × 4 × factorial(3)
  • = 5 × 4 × 3 × factorial(2)
  • = 5 × 4 × 3 × 2 × factorial(1)
  • = 5 × 4 × 3 × 2 × 1 × factorial(0)
  • Base case:
  • factorial(0) = 1

Now return:

1
1 × 1 = 1
2 × 1 = 2
3 × 2 = 6
4 × 6 = 24
5 × 24 = 120

Result:

120

6. Recursion Tree

Consider Fibonacci:

F(n) = F(n-1) + F(n-2)

def fibonacci(n):
if n <= 1:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
  • For:
  • fibonacci(4)
  • The recursion looks like:

F(4)

/ \

F(3) F(2)

/ \ / \

F(2) F(1) F(1) F(0)

/ \

F(1) F(0)

Notice something important:

F(2) gets calculated multiple times.

That's why naive Fibonacci recursion is inefficient.

7. Recursion Complexity

  • For factorial:
  • factorial(n)
  • There are n recursive calls.
  • Time:

O(n)

Space:

O(n)

because the recursion stack can contain n function calls.

8. Recursion vs Iteration

Factorial using a loop:

def factorial(n):
    result = 1
    for i in range(1, n + 1):
        result *= i
return result

Factorial using recursion:

def factorial(n):
if n == 0:
return 1
return n * factorial(n - 1)
  • Both have:
  • Time = O(n)
  • But recursion uses additional call-stack space.

9. When Should You Use Recursion?

  • Recursion is particularly useful when the problem naturally has a hierarchical or repeated structure.
  • Trees
  • A

/ \

B C

/ \

D E

  • Each subtree is itself a smaller tree.
  • Perfect for recursion.
  • Graph DFS
def dfs(node):
    for neighbor in graph[node]:
        dfs(neighbor)

Divide & Conquer

Problem
Divide

/ \

A B

↓ ↓

Solve Solve

\ /

  • Combine
  • Examples:
  • Merge Sort
  • Quick Sort
  • Binary Search
  • Backtracking
  • Examples:
  • N-Queens
  • Sudoku
  • Permutations
  • Combinations
  • Maze solving

10. Recursion on Trees

Suppose:

10

/ \

20 30

/ \

40 50

A tree can be processed recursively:

def preorder(node):
    if node is None:
        return
print(node.value)

preorder(node.left)

preorder(node.right)

This is why learning recursion is essential before mastering tree problems.

11. Recursion and DFS

  • Remember your previous topic:
  • DFS
  • Recursive DFS:
def dfs(node, visited):
    if node in visited:
        return

visited.add(node)

for neighbor in graph[node]:
    dfs(neighbor, visited)

The recursive call:

  • dfs(neighbor, visited)
  • is what allows DFS to go deeper.
  • So:
DFS
Recursion / Stack
Go deeper
Reach end
Backtrack

12. A Very Important Recursion Pattern

You will see this pattern repeatedly:

def solve(problem):
if base_condition:
return answer
smaller_problem = make_smaller(problem)
result = solve(smaller_problem)
return combine(result)

For example:

def sum_array(arr, index):
if index == len(arr):
return 0
return arr[index] + sum_array(arr, index + 1)

Example:

arr = [10, 20, 30]
print(sum_array(arr, 0))

Output:

60

Conceptually:

10 + sum(20,30)
20 + sum(30)
30 + sum()
0

Then:

30
20 + 30 = 50
10 + 50 = 60

13. Recursion with Multiple Calls

Some recursive algorithms make more than one recursive call.

Example:

def fibonacci(n):
if n <= 1:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
  • This creates a recursion tree.
  • These problems can become exponential without optimization.
  • This leads directly into:
  • Dynamic Programming

DP often takes an expensive recursive solution and avoids recalculating the same subproblems.

14. Recursion + Backtracking

This is another major connection.

Suppose we want all permutations of:

\[1, 2, 3\]

We can recursively:

Choose
Explore
Undo choice
Choose another
  • That's called backtracking.
  • Mental model:
  • Recursion

┌────────┼────────┐

│ │ │

DFS Divide Backtracking

& Conquer

15. Common Recursion Problems

Start with these in order:

Beginner

  • Print 1 to N
  • Print N to 1
  • Sum of numbers
  • Factorial
  • Power of a number
  • Reverse a string
  • Check palindrome

Intermediate

  • Fibonacci
  • Binary Search
  • Sum of array
  • Maximum element in array
  • Tree traversal
  • DFS

Advanced

  • Generate subsets
  • Generate permutations
  • Combination Sum
  • N-Queens
  • Sudoku
  • Maze problems

16. Recursion Checklist

Whenever you see a recursion problem, ask yourself:

1. What is the smallest possible input?

That's usually your base case.

2. How can I make the problem smaller?

For example:

n → n - 1

3. What should the recursive function return?

Don't just focus on the recursive call.

Understand what it returns.

4. How do I combine the result?

  • For factorial:
  • n × factorial(n-1)
  • For sum:
  • arr[index] + sum(rest)

17. The Most Important Mental Model

Think of recursion like this:

PROBLEM

Is it simplest?

/ \

YES NO

↓ ↓

RETURN Make smaller

Call yourself

Get result

Combine result

  • RETURN
  • And remember:
  • Recursion = Base Case + Smaller Problem + Recursive Call + Return/Combine
  • 🔥 Your DSA progression so far
  • You've now covered:
Arrays
Strings
Linked Lists
Stacks
Queues
Hash Tables
Trees
Binary Trees
BST
Heaps
Graphs
BFS / DFS
👉 Recursion

The next topics will become much easier because recursion is the foundation for Divide & Conquer, Backtracking, Tree algorithms, DFS, and Dynamic Programming.

Module 4 · Lesson 4.15

Searching Algorithms

  • Searching Algorithms
  • Searching means finding whether a particular value exists in a collection and, if it exists, finding its position.
  • For example:
numbers = [10, 20, 30, 40, 50]
target = 30

We want to find:

30 → index 2

The two fundamental searching algorithms you should master first are:

Linear Search

Binary Search

1. Linear Search

Linear Search checks elements one by one from the beginning.

Example:

\[10, 25, 40, 15, 30\]
Target = 15
  • Search:
  • 10 ❌
  • 25 ❌
  • 40 ❌
  • 15 ✅
  • So:
  • 15 is at index 3
  • Python Implementation
def linear_search(arr, target):
for i in range(len(arr)):
if arr[i] == target:
return i
return -1

Example:

numbers = [10, 25, 40, 15, 30]
result = linear_search(numbers, 15)
print(result)

Output:

3

If the value isn't present:

-1

2. Linear Search Complexity

  • Suppose there are n elements.
  • Best case
  • Target is the first element:
\[10, 20, 30, 40\]
Target = 10

Only one comparison.

O(1)

Worst case

Target is at the end:

\[10, 20, 30, 40\]
Target = 40

We check all elements.

O(n)

Average case

Approximately half the elements are checked:

O(n)

Therefore:

CaseComplexity
BestO(1)
AverageO(n)
WorstO(n)
SpaceO(1)

3. When Should You Use Linear Search?

  • Linear Search is useful when:
  • The array is unsorted
  • The dataset is small
  • You only need to search once
  • Simplicity is more important than optimization
  • For example:
arr = [50, 10, 90, 20, 30]

You cannot directly use ordinary binary search because the array isn't sorted.

4. Binary Search

Binary Search is much faster, but there is an important requirement:

The data must be sorted.

Example:

\[10, 20, 30, 40, 50, 60, 70\]

Target:

60

Instead of checking every element, binary search repeatedly cuts the search area in half.

5. Binary Search Step by Step

Array:

\[10, 20, 30, 40, 50, 60, 70\]
  • Target:
  • 60
  • Start:
left = 0
right = 6

Calculate middle:

mid = (0 + 6) // 2

= 3

  • Middle value:
  • 40
  • Compare:
  • 60 > 40

Therefore, ignore everything on the left:

\[50, 60, 70\]

Now:

left = 4
right = 6

Middle:

mid = (4 + 6) // 2

= 5

  • Value:
  • 60
  • Found!
index = 5

6. Why Binary Search Is Fast

  • Suppose there are:
  • 1,000,000 elements
  • Linear search could require:
  • 1,000,000 comparisons
  • Binary search approximately does:
1,000,000
500,000
250,000
125,000

...

  • 1
  • Only around:
  • log₂(1,000,000) ≈ 20
  • comparisons.
  • That's why binary search is:
  • O(log n)

7. Iterative Binary Search

This is the version you should learn first.

def binary_search(arr, target):
left = 0
right = len(arr) - 1
while left <= right:
mid = left + (right - left) // 2
if arr[mid] == target:
return mid
elif arr[mid] < target:
left = mid + 1
else:
right = mid - 1
return -1

Example:

numbers = [10, 20, 30, 40, 50, 60, 70]
print(binary_search(numbers, 60))

Output:

5

8. The Three Conditions

This is the most important part of binary search.

if arr[mid] == target:
return mid
  • We found it.
  • If:
  • arr[mid] < target

The target must be on the right:

left = mid + 1

If:

arr[mid] > target

The target must be on the left:

right = mid - 1

Remember:

arr[mid]

|

┌─────────┼─────────┐

↓ ↓ ↓

smaller equal greater

↓ ↓ ↓

RIGHT FOUND LEFT

9. Binary Search Complexity

CaseComplexity
BestO(1)
AverageO(log n)
WorstO(log n)
Space — iterativeO(1)

This is much better than linear search for large sorted datasets.

10. Recursive Binary Search

Binary Search can also be implemented recursively.

def binary_search(arr, target, left, right):
if left > right:
return -1
mid = left + (right - left) // 2
if arr[mid] == target:
return mid
if arr[mid] < target:
return binary_search(arr, target, mid + 1, right)
return binary_search(arr, target, left, mid - 1)

Call:

arr = [10, 20, 30, 40, 50, 60]
result = binary_search(
    arr,
    50,
    0,
    len(arr) - 1
)
print(result)

Output:

4

This connects directly with the Recursion topic you just studied.

11. Linear Search vs Binary Search

FeatureLinear SearchBinary Search
Sorted data required
ApproachOne by oneDivide in half
BestO(1)O(1)
AverageO(n)O(log n)
WorstO(n)O(log n)
Extra spaceO(1)O(1) iterative
Easy to implement⭐⭐⭐⭐⭐⭐⭐⭐⭐

12. Important Binary Search Variations

In interviews, binary search is much more than:

"Find this number."

You should learn these patterns.

1. Find First Occurrence

\[1, 2, 2, 2, 3, 4\]
Target = 2

Answer:

index 1

Not index 2 or 3.

2. Find Last Occurrence

Same array:

\[1, 2, 2, 2, 3, 4\]

Target:

2

Answer:

index 3

3. Lower Bound

Find the first position where:

arr[i] >= target

Example:

\[1, 3, 5, 7, 9\]
target = 6

Answer:

7

because 7 is the first value ≥ 6.

4. Upper Bound

Find the first position where:

arr[i] > target

13. Binary Search on Answer

This is an extremely important interview pattern.

Sometimes the array itself isn't what you're searching.

Instead, you're searching for an answer.

Example:

What is the minimum capacity required to ship all packages within D days?

You can search:

Possible capacity
Is this capacity

sufficient?

YES / NO

This often looks like:

minimum answer
binary search
feasibility function

This pattern appears in many difficult problems.

14. Searching a Rotated Sorted Array

Example:

\[40, 50, 60, 10, 20, 30\]

Originally:

\[10, 20, 30, 40, 50, 60\]
  • but it has been rotated.
  • A normal binary search doesn't directly work.
  • Interview question:

Search for 20 in a rotated sorted array.

This requires a modified binary search.

15. Search in a 2D Matrix

Example:

1 3 5

7 9 11

13 15 17

Find:

15

Depending on the matrix properties, binary search can be used.

This is another common interview pattern.

16. Python's Built-in Binary Search

Python has:

import bisect

For example:

import bisect
arr = [10, 20, 30, 40, 50]
index = bisect.bisect_left(arr, 30)
print(index)

Output:

2

bisect_left() finds the insertion position while maintaining sorted order.

But for DSA learning, implement binary search yourself first.

17. Important Interview Recognition

  • When you see:
  • "Sorted array"
  • Immediately ask:
  • Can I use Binary Search?
  • When you see:
  • "Find minimum/maximum value satisfying a condition"
  • Think:
  • Binary Search on Answer
  • When you see:
  • "First/last occurrence"
  • Think:
  • Modified Binary Search
  • When you see:
  • "Rotated sorted array"
  • Think:
  • Modified Binary Search

18. Searching Algorithm Mental Model

SEARCHING

┌──────────┴──────────┐

│ │

Linear Search Binary Search

│ │

Check one by one Sorted data

│ │

O(n) O(log n)

┌─────────────┼─────────────┐

│ │ │

First/Last Rotated Answer

Occurrence Array Search

🎯 What to master

Before moving to Sorting Algorithms, make sure you can implement these from memory:

  • Linear Search
  • Binary Search — iterative
  • Binary Search — recursive
  • First occurrence
  • Last occurrence
  • Lower bound / upper bound
  • Search in rotated sorted array
  • Binary Search on Answer

The biggest concept to take away is:

Linear Search scans. Binary Search eliminates half of the remaining search space at every step.

Module 4 · Lesson 4.16

Sorting Algorithms

  • Sorting Algorithms
  • Sorting means arranging data in a particular order.
  • For example:
  • Before:
\[40, 10, 30, 20, 50\]

After:

\[10, 20, 30, 40, 50\]

Sorting is one of the most important DSA topics because many algorithms become easier once data is sorted.

1. Why Do We Need Sorting?

Suppose:

numbers = [50, 10, 80, 20, 30]

If we sort:

\[10, 20, 30, 50, 80\]

Then we can efficiently perform:

  • Binary Search
  • Finding duplicates
  • Finding median
  • Finding minimum/maximum
  • Two-pointer problems
  • Interval problems
  • Greedy algorithms

So sorting is a foundation for many other DSA techniques.

2. Sorting Algorithms You Should Know

For interviews, learn these in this order:

Bubble Sort
Selection Sort
Insertion Sort
Merge Sort
Quick Sort
Heap Sort
Counting Sort

You don't need to use all of them regularly, but you should understand their concepts and complexities.

3. Bubble Sort

Bubble Sort repeatedly compares adjacent elements and swaps them if they're in the wrong order.

Example:

\[5, 3, 8, 4, 2\]
  • Compare:
  • 5 > 3
  • Swap:
\[3, 5, 8, 4, 2\]
  • Next:
  • 5 < 8
  • No swap.
  • Next:
  • 8 > 4
  • Swap:
\[3, 5, 4, 8, 2\]
  • And so on.
  • After one complete pass, the largest element moves toward the end.
  • Python
def bubble_sort(arr):
    n = len(arr)
    for i in range(n):
        swapped = False
        for j in range(0, n - i - 1):
            if arr[j] > arr[j + 1]:
                arr[j], arr[j + 1] = arr[j + 1], arr[j]

swapped = True

if not swapped:
    break
return arr

Complexity:

Best: O(n)

Average: O(n²)

Worst: O(n²)

Space: O(1)

Bubble Sort is mainly useful for learning, not production code.

4. Selection Sort

Selection Sort repeatedly finds the smallest element from the unsorted portion and puts it at the beginning.

Example:

\[40, 10, 30, 20\]
  • Find minimum:
  • 10
  • Move it to the beginning:
\[10, 40, 30, 20\]

Remaining:

\[40, 30, 20\]
  • Minimum:
  • 20
  • Result:
\[10, 20, 30, 40\]

Python

def selection_sort(arr):
n = len(arr)
for i in range(n):
min_index = i
for j in range(i + 1, n):
if arr[j] < arr[min_index]:
min_index = j

arr[i], arr[min_index] = arr[min_index], arr[i]

return arr

Complexity:

Best: O(n²)

Average: O(n²)

Worst: O(n²)

Space: O(1)

5. Insertion Sort

Insertion Sort works similarly to how you might arrange playing cards in your hand.

Example:

\[5, 2, 4, 6, 1, 3\]

Start:

\[5\]

Insert 2:

\[2, 5\]

Insert 4:

\[2, 4, 5\]

Insert 6:

\[2, 4, 5, 6\]

And so on.

Python

def insertion_sort(arr):
    for i in range(1, len(arr)):
        key = arr[i]
        j = i - 1
        while j >= 0 and arr[j] > key:
            arr[j + 1] = arr[j]

j -= 1

arr[j + 1] = key

return arr

Complexity:

Best: O(n)

Average: O(n²)

Worst: O(n²)

Space: O(1)

  • When is Insertion Sort useful?
  • It's surprisingly useful when:
  • Data is already almost sorted
  • Dataset is small
  • You need a simple in-place algorithm

6. The First Three

You should understand the difference:

Bubble Sort
Compare neighbors
Swap
Selection Sort
Find minimum
Place it
Insertion Sort
Take next element
Insert into sorted portion

7. Merge Sort

Now we move to an important efficient sorting algorithm.

  • Merge Sort uses:
  • Divide and Conquer
  • Suppose:
\[8, 3, 5, 4, 7, 6, 1, 2\]

Divide:

\[8, 3, 5, 4] [7, 6, 1, 2\]

Divide again:

\[8, 3] [5, 4] [7, 6] [1, 2\]

Continue until individual elements:

\[8] [3] [5] [4] [7] [6] [1] [2\]

Then merge sorted pieces:

\[3, 8\]
\[4, 5\]
\[6, 7\]
\[1, 2\]

Then:

\[3, 4, 5, 8\]
\[1, 2, 6, 7\]

Finally:

\[1, 2, 3, 4, 5, 6, 7, 8\]

8. Merge Sort Python

def merge_sort(arr):
    if len(arr) <= 1:
        return arr
        mid = len(arr) // 2
        left = merge_sort(arr[:mid])
        right = merge_sort(arr[mid:])
        return merge(left, right)
        def merge(left, right):
            result = []
            i = 0
            j = 0
            while i < len(left) and j < len(right):
                if left[i] <= right[j]:
                    result.append(left[i])

i += 1

else:
    result.append(right[j])

j += 1

result.extend(left[i:])

result.extend(right[j:])

return result

Complexity:

Best: O(n log n)

Average: O(n log n)

Worst: O(n log n)

Space: O(n)

9. Why n log n?

  • Merge Sort has two ideas:
  • Divide
  • Keep splitting:
n
n/2
n/4
n/8

...

  • Number of levels:
  • log n
  • Merge
  • At each level, we process approximately:
  • n
  • elements.

Therefore:

  • n × log n
  • So:
  • O(n log n)

10. Quick Sort

  • Quick Sort is another Divide & Conquer algorithm.
  • It selects an element called a:
  • Pivot

Then partitions the array around the pivot.

Example:

\[7, 2, 1, 6, 8, 5, 3, 4\]

Choose:

Pivot = 4

Partition:

smaller than 4 | 4 | greater than 4

\[2, 1, 3] 4 [7, 6, 8, 5\]

Then recursively sort the two sides.

Eventually:

\[1, 2, 3, 4, 5, 6, 7, 8\]

11. Quick Sort Complexity

  • Average:
  • O(n log n)
  • Worst case:
  • O(n²)

The worst case can occur when the pivot selection is consistently poor.

For example, if the array is already sorted and we always choose the first element as pivot:

\[1, 2, 3, 4, 5\]

we can get highly unbalanced partitions.

12. Heap Sort

  • You just learned Heaps, so Heap Sort is the natural connection.
  • Heap Sort uses a heap to repeatedly extract the largest/smallest element.
  • For a Max Heap:
  • 90

/ \

70 80

/ \ / \

40 50 60 30

  • Repeatedly remove the maximum:
  • 90
  • 80
  • 70
  • 60
  • 50
  • 40
  • 30
  • This produces sorted data.
  • Complexity:

Best: O(n log n)

Average: O(n log n)

Worst: O(n log n)

Space: O(1)

13. Counting Sort

Counting Sort works differently.

Instead of comparing elements, it counts how many times each value occurs.

Example:

\[4, 2, 2, 8, 3, 3, 1\]
  • Count:
  • 1 → 1
  • 2 → 2
  • 3 → 2
  • 4 → 1
  • 8 → 1

Then reconstruct:

\[1, 2, 2, 3, 3, 4, 8\]

Counting Sort can be very fast when the range of values is small.

Complexity:

O(n + k)

  • where:
  • n = number of elements
  • k = range of values

But it isn't appropriate for every dataset.

14. Sorting Complexity Table

This table is worth memorizing:

AlgorithmBestAverageWorstSpace
BubbleO(n)O(n²)O(n²)O(1)
SelectionO(n²)O(n²)O(n²)O(1)
InsertionO(n)O(n²)O(n²)O(1)
MergeO(n log n)O(n log n)O(n log n)O(n)
QuickO(n log n)O(n log n)O(n²)O(log n)*
HeapO(n log n)O(n log n)O(n log n)O(1)
CountingO(n+k)O(n+k)O(n+k)O(k)

* Quick Sort's stack space depends on the implementation and pivot behavior.

15. Stable vs Unstable Sorting

Another important interview concept is stability.

Suppose we have employees:

Name Salary

A 50000

B 50000

C 70000

If we sort by salary, a stable sort preserves the original order of A and B:

  • A → 50000
  • B → 50000
  • C → 70000
  • Stable sorting matters when sorting by multiple fields.
  • Common stable algorithms:
  • Bubble Sort
  • Insertion Sort
  • Merge Sort
  • Commonly unstable:
  • Selection Sort
  • Quick Sort
  • Heap Sort

16. In-Place Sorting

An algorithm is generally called in-place when it uses only a small amount of additional memory beyond the input.

Examples:

Bubble Sort → Yes

Selection Sort → Yes

Insertion Sort → Yes

Heap Sort → Yes

Merge Sort typically requires additional memory for merging.

17. Python's sort()

In real Python programs, you normally don't implement sorting algorithms yourself.

You use:

numbers = [40, 10, 30, 20, 50]

numbers.sort()

print(numbers)

Result:

\[10, 20, 30, 40, 50\]

Or:

numbers = sorted(numbers)

Python's built-in sorting uses Timsort, which combines ideas from merge sort and insertion sort.

For DSA interviews, however, you should understand the underlying algorithms.

18. Sorting + Other DSA Patterns

Sorting is often the first step in solving a problem.

Sorting + Two Pointers

Example:

Find two numbers that add up to target.

Sort
Left + Right pointers
  • Sorting + Greedy
  • Many greedy problems become easier after sorting.
  • Sorting + Binary Search
Sort
Binary Search

Sorting + Intervals

Example:

\[1,3\]
\[2,6\]
\[8,10\]

Sort by starting point, then merge overlapping intervals.

19. How to Recognize Which Sort?

For DSA learning:

Small / almost sorted
Insertion Sort
Need guaranteed O(n log n)
Merge Sort / Heap Sort
Average O(n log n), in-place
Quick Sort
Small integer range
Counting Sort

But in interviews, the more important question is often:

Do I actually need to implement a sorting algorithm, or can I use sorting as a step in a larger solution?

Usually, the latter.

20. Your Sorting Mental Model

SORTING

┌───────────────┴───────────────┐

│ │

Simple sorts Efficient sorts

│ │

┌─────┼─────┐ ┌───────┼────────┐

│ │ │ │ │ │

Bubble Selection Insertion Merge Quick Heap

Non-comparison

Counting Sort

🎯 What you should master

For your DSA progression, focus particularly on:

  • Bubble Sort — understand the mechanics
  • Selection Sort — understand selection
  • Insertion Sort — understand insertion
  • Merge Sort — master Divide & Conquer
  • Quick Sort — master partition + pivot
  • Heap Sort — connect it to your Heap knowledge
  • Counting Sort — understand when non-comparison sorting works

The three most important for interviews are generally Merge Sort, Quick Sort, and Heap Sort, while the first three are mainly valuable for building your fundamental understanding.

Module 4 · Lesson 4.17

Divide & Conquer

Divide & Conquer

Divide and Conquer is an algorithmic technique where we solve a large problem by:

  • 1. Divide
  • 2. Conquer
  • 3. Combine

It is one of the most important patterns in DSA because it connects directly to recursion, binary search, merge sort, and quick sort.

1. The Basic Idea

Suppose we have a big problem:

BIG PROBLEM
DIVIDE

/ \

Problem A Problem B

│ │

CONQUER CONQUER

│ │

Result A Result B

\ /

COMBINE

FINAL RESULT

Instead of solving one huge problem directly, we break it into smaller problems of the same type.

2. The Three Steps

Step 1 — Divide

Break the problem into smaller pieces.

Example:

\[8, 3, 5, 4, 7, 6, 1, 2\]

Divide:

\[8, 3, 5, 4] [7, 6, 1, 2\]

Divide again:

\[8, 3] [5, 4] [7, 6] [1, 2\]
  • Step 2 — Conquer
  • Solve the smaller problems.
  • Eventually:
\[8] [3] [5] [4] [7] [6] [1] [2\]
  • A single element is already sorted.
  • [8] → sorted
  • [3] → sorted
  • [5] → sorted

...

Step 3 — Combine

Combine the smaller solutions.

\[8] + [3\]

\[3, 8\]

Then:

\[5] + [4\]

\[4, 5\]

Eventually:

\[1, 2, 3, 4, 5, 6, 7, 8\]

This is exactly how Merge Sort works.

3. Divide & Conquer and Recursion

Divide & Conquer usually uses recursion.

The general structure is:

def solve(problem):
    # Base case
if smallest_problem(problem):
return answer
  • # Divide
  • left, right = divide(problem)
  • # Conquer
  • left_result = solve(left)
  • right_result = solve(right)
  • # Combine
return combine(left_result, right_result)

So:

Divide & Conquer
Recursion
Smaller problems
Combine results

4. Example — Merge Sort

This is the classic Divide & Conquer algorithm.

def merge_sort(arr):
if len(arr) <= 1:
return arr
mid = len(arr) // 2
left = merge_sort(arr[:mid])
right = merge_sort(arr[mid:])
return merge(left, right)

The key lines are:

left = merge_sort(arr[:mid])
right = merge_sort(arr[mid:])

We divide the problem into two smaller problems.

Then:

return merge(left, right)

combines the results.

5. Example — Binary Search

  • You just learned Binary Search.
  • It is also a Divide & Conquer algorithm.
  • Suppose:
\[10, 20, 30, 40, 50, 60, 70\]
  • Search for:
  • 60
  • Check the middle:
  • 40
  • Since:
  • 60 > 40
  • discard the left half.
  • Now:
\[50, 60, 70\]

Again divide.

\[50, 60, 70\]

60

Found.

So Binary Search follows:

Divide
Choose relevant half
Solve smaller problem

Complexity:

O(log n)

6. Example — Quick Sort

Quick Sort also uses Divide & Conquer.

Suppose:

\[7, 2, 1, 6, 8, 5, 3, 4\]
  • Choose pivot:
  • 4
  • Partition:
\[2, 1, 3] 4 [7, 6, 8, 5\]

Now recursively sort:

\[2, 1, 3\]

and:

\[7, 6, 8, 5\]

Eventually:

\[1, 2, 3, 4, 5, 6, 7, 8\]

So:

Quick Sort
Choose Pivot
Partition

/ \

Small Large

↓ ↓

Recursion

\ /

Sorted

7. Merge Sort vs Quick Sort

Both use Divide & Conquer.

Merge SortQuick Sort
DivideSplit arrayPartition around pivot
CombineMergeUsually nothing significant
BestO(n log n)O(n log n)
AverageO(n log n)O(n log n)
WorstO(n log n)O(n²)
StableYesUsually No
Extra spaceO(n)O(log n) average stack
Main ideaSplit + MergePivot + Partition
  • The important conceptual difference:
  • Merge Sort
  • Divide → Solve → Merge
  • Quick Sort
  • Pivot → Partition → Solve each side

8. Divide & Conquer Recursion Tree

Consider Merge Sort:

\[8,3,5,4\]

/ \

\[8,3] [5,4\]

/ \ / \

\[8] [3] [5] [4\]

Then the results come back:

\[3,8] [4,5\]

\ /

\[3,4,5,8\]

This is called a recursion tree.

9. Why Is It Efficient?

Suppose we have:

n = 1,000,000

Instead of working with one million elements at once, we repeatedly divide:

1,000,000
500,000
250,000
125,000

...

  • 1
  • The number of divisions is approximately:
  • log₂(n)
  • For one million:
  • ≈ 20

This is why many Divide & Conquer algorithms achieve:

O(n log n)

10. Recurrence Relations

This is an important DSA concept.

For Merge Sort, we can express its complexity as:

T(n) = 2T(n/2) + O(n)

Meaning:

2 smaller problems

+

  • O(n) work to merge
  • The solution is:
  • T(n) = O(n log n)

You don't need to master recurrence mathematics immediately, but you should recognize this pattern.

11. Master Theorem

For advanced DSA, you'll encounter:

T(n) = aT(n/b) + f(n)

Where:

a = number of subproblems
b = how much the input is divided

f(n) = work done outside recursion

For Merge Sort:

a = 2
b = 2

f(n) = O(n)

Therefore:

T(n) = 2T(n/2) + O(n)

and:

O(n log n)

12. Divide & Conquer vs Dynamic Programming

This is a common interview question.

Divide & Conquer

Usually breaks a problem into independent subproblems.

Example:

Merge Sort

Problem

/ \

Left Right

  • The left and right sides don't need the same result repeatedly.
  • Dynamic Programming
  • Often has overlapping subproblems.
  • Example Fibonacci:

F(5)

/ \

F(4) F(3)

/ \ / \

F(3) F(2) F(2) F(1)

Notice:

F(3)

F(2)

are calculated repeatedly.

DP stores the results to avoid repeating work.

13. Divide & Conquer vs Backtracking

Another important distinction.

Divide & Conquer

Divide
Solve independently
Combine
  • Examples:
  • Merge Sort
  • Quick Sort
  • Binary Search
  • Backtracking
Choose
Explore
If invalid → Undo
Try another choice
  • Examples:
  • N-Queens
  • Sudoku
  • Permutations
  • Subsets

14. Classic Divide & Conquer Algorithms

You should know these:

1. Binary Search

O(log n)

2. Merge Sort

O(n log n)

3. Quick Sort

Average O(n log n)

Worst O(n²)

4. Maximum Subarray

Can be solved using a Divide & Conquer approach.

5. Closest Pair of Points

A more advanced computational geometry example.

15. How to Recognize Divide & Conquer

When reading a problem, ask:

Question 1

  • Can I split the problem into smaller versions of the same problem?
  • If yes:
  • Potential Divide & Conquer

Question 2

  • Are the smaller problems mostly independent?
  • If yes:
  • Strong candidate

Question 3

  • Can I combine their answers efficiently?
  • If yes:
  • Very likely Divide & Conquer

16. The Most Important Pattern

Memorize:

DIVIDE & CONQUER

┌──────┴──────┐

│ │

DIVIDE BASE CASE

Smaller problems
RECURSION
CONQUER
Solve subproblems
COMBINE
Final answer

17. Your DSA Connection

You've now covered:

Recursion
Searching
Sorting
Divide & Conquer

These topics are strongly connected:

Recursion

┌─────────┴─────────┐

│ │

Divide & Conquer Backtracking

┌──────┼──────┐

│ │ │

Binary Merge Quick

Search Sort Sort

  • 🎯 What you should practice now
  • Implement these without looking at notes:
  • Binary Search
  • Merge Sort
  • Quick Sort

For each one, be able to answer:

  • What is being divided?
  • What is the base case?
  • What is the recursive call?
  • How are results combined?
  • What is the time complexity?
  • What is the space complexity?

Once you can answer those questions, you've genuinely understood Divide & Conquer, rather than just memorizing the definition.

Module 4 · Lesson 4.18

Greedy Algorithms

Greedy Algorithms

A Greedy Algorithm solves a problem by making the best choice available at the current moment, hoping that these local choices lead to the globally optimal solution.

The key idea is:

Take the best-looking choice now, and don't reconsider it later.

1. Simple Example

  • Suppose you need to make:
  • ₹36
  • using these denominations:

₹20, ₹10, ₹5, ₹2, ₹1

A greedy approach chooses the largest possible denomination each time:

36

20 remaining 16

10 remaining 6

5 remaining 1

  • 1
  • Result:
  • 20 + 10 + 5 + 1 = 36
  • Only 4 coins.
  • The greedy thinking was:
  • Take the largest useful coin available.

⚠️ Important: Greedy does not always work for every coin system. That distinction is very important.

2. Local Optimum vs Global Optimum

This is the heart of greedy algorithms.

  • Local optimum
  • The best decision right now.
  • Global optimum
  • The best possible solution overall.
  • Greedy assumes:
Best choice now
Doesn't hurt future choices
Eventually gives
Global optimum

But this assumption isn't always true.

3. Example Where Greedy Fails

Consider coins:

\[1, 3, 4\]
  • Target:
  • 6
  • Greedy chooses the largest coin first:
6
4
remaining 2
1
remaining 1
1
  • Result:
  • 4 + 1 + 1 = 3 coins
  • But the optimal answer is:
  • 3 + 3 = 2 coins
  • So:
  • Greedy → 3 coins ❌
  • Optimal → 2 coins ✅

Therefore:

You cannot blindly use a greedy strategy just because it looks sensible.

You need a reason/proof that the greedy choice is safe.

4. Greedy Algorithm Structure

A typical greedy solution looks like:

Problem
Find choices
Pick best current choice

Is solution complete?

/ \

No Yes

↓ ↓

Repeat choice Return

  • Usually there is:
  • A greedy choice
  • A way to determine the best current choice
  • A way to prove that the choice is safe

5. Classic Example — Activity Selection

This is one of the most important greedy problems.

Suppose you have activities:

ActivityStartEnd
A13
B25
C47
D69
E810
  • You want to select the maximum number of non-overlapping activities.
  • Greedy Idea
  • Choose the activity that:
  • Finishes earliest.
  • Why?
  • Because it leaves the maximum amount of time for future activities.
  • Sort by end time:
  • A: 1 → 3
  • B: 2 → 5
  • C: 4 → 7
  • D: 6 → 9
  • E: 8 → 10
  • Choose A:
  • A: 1 → 3

Next activity must start at or after 3.

C:

  • C: 4 → 7
  • Then:
  • E: 8 → 10

Answer:

A → C → E

Three activities.

6. Python Implementation

def activity_selection(activities):
    activities.sort(key=lambda x: x[1])
selected = []
last_end = float("-inf")
for start, end in activities:
    if start >= last_end:
        selected.append((start, end))

last_end = end

return selected

Example:

activities = [
    (1, 3),
    (2, 5),
    (4, 7),
    (6, 9),
    (8, 10)
]
print(activity_selection(activities))

Result:

\[(1, 3), (4, 7), (8, 10)\]
  • Complexity:
  • Sorting → O(n log n)
  • Selection → O(n)
  • Total → O(n log n)

7. Why Does Activity Selection Work?

This is where greedy becomes interesting.

  • Suppose two activities are available.
  • A: 1 → 3
  • B: 1 → 7
  • Which one should we choose?
  • Obviously:
  • A
  • because it finishes earlier.
  • Choosing B blocks more future activities.

So the greedy choice:

Earliest finishing activity

can be proven to be safe.

This is called the:

Greedy Choice Property

8. Greedy Choice Property

A problem has the greedy choice property if we can make a locally optimal choice that can be part of an optimal global solution.

In simple terms:

Best choice now
Safe to commit
Optimal solution remains possible

This is what separates a valid greedy algorithm from a random "take the biggest/smallest" strategy.

9. Optimal Substructure

Greedy problems often also have:

  • Optimal Substructure
  • The optimal solution to the whole problem contains optimal solutions to its smaller subproblems.
  • This concept also appears in Dynamic Programming.
  • So:
Greedy
├── Greedy Choice Property
└── Optimal Substructure

10. Fractional Knapsack

This is a classic greedy problem.

Suppose you have:

ItemValueWeight
A6010
B10020
C12030
  • Bag capacity:
  • 50
  • Calculate:
  • value / weight

A:

60 / 10 = 6

B:

100 / 20 = 5

C:

  • 120 / 30 = 4
  • Take highest value/weight first:
  • A → B → remaining capacity → C partially
  • Because fractions are allowed, greedy works.

11. Fractional vs 0/1 Knapsack

This distinction is extremely important.

Fractional Knapsack

You can take part of an item.

  • Take 50% of item
  • Greedy works.
  • 0/1 Knapsack
  • You either take the whole item or don't take it.
  • Take → 1
  • Don't take → 0
  • Greedy does not generally work.
  • Usually:
  • 0/1 Knapsack → Dynamic Programming
  • So:
  • Fractional Knapsack → Greedy
  • 0/1 Knapsack → DP
  • Memorize this.

12. Job Sequencing

Another classic greedy problem.

Suppose:

Job Deadline Profit

A 2 100

B 1 50

C 2 80

D 1 40

  • Each job takes one unit of time.
  • Goal:
  • Maximize profit.
  • Greedy strategy:
  • Sort jobs by decreasing profit.

Then place each job as late as possible before its deadline.

This preserves earlier slots for other jobs.

13. Minimum Number of Platforms

Another common problem.

Given arrival/departure times:

Arrival: 9:00 9:40 9:50

Departure: 9:10 9:50 10:20

  • We need to determine how many platforms are required.
  • A greedy approach:
  • Sort arrivals
  • Sort departures

Then use two pointers.

This connects Greedy + Sorting + Two Pointers.

14. Huffman Coding

  • Huffman Coding is a famous greedy algorithm.
  • Suppose character frequencies are:
  • A → 5
  • B → 9
  • C → 12
  • D → 13
  • E → 16
  • F → 45
  • Repeatedly combine the two smallest frequencies.
  • 5 + 9 = 14
  • 12 + 13 = 25
  • 14 + 16 = 30
  • 25 + 30 = 55
  • 45 + 55 = 100
  • A Huffman tree is constructed from these choices.
  • Greedy rule:
  • Always combine the two least frequent nodes.

15. Minimum Spanning Tree

  • Greedy algorithms are also heavily used in graphs.
  • Kruskal's Algorithm
  • Repeatedly select the:
  • Smallest-weight edge that doesn't create a cycle.

Example:

A --2-- B

| /

4 1

| /

  • C
  • Kruskal considers edges by weight:
  • 1
  • 2
  • 4
  • and keeps adding the smallest valid edge.
  • Kruskal uses:
  • Greedy + Sorting + Union Find

16. Prim's Algorithm

Prim's algorithm also finds a Minimum Spanning Tree.

  • The greedy rule is roughly:
  • Choose the cheapest edge that connects the current tree to a new vertex.
  • It commonly uses:
  • Priority Queue / Heap

So you can see how your previous topics connect:

Heap
Priority Queue
Prim's Algorithm
Greedy

17. Dijkstra's Algorithm

  • Dijkstra's shortest-path algorithm also uses a greedy strategy for graphs with non-negative edge weights.
  • At each step:
  • Choose the unprocessed vertex with the smallest known distance.
  • Typically:
Dijkstra
Greedy choice

+

Priority Queue / Min Heap

This is why learning:

Heap → Graph → Greedy

is useful.

18. Common Greedy Problems

You should recognize these:

Beginner

  • Activity Selection
  • Fractional Knapsack
  • Minimum Coins — when the denomination system supports the greedy strategy
  • Assign Cookies

Intermediate

  • Job Sequencing
  • Gas Station
  • Jump Game
  • Minimum Number of Arrows
  • Merge Intervals
  • Non-overlapping Intervals

Advanced

  • Huffman Coding
  • Kruskal's Algorithm
  • Prim's Algorithm
  • Dijkstra's Algorithm

19. Greedy vs Dynamic Programming

This is a very important interview question.

Greedy

Makes a choice and doesn't revisit it.

Choose best now
Continue
  • Dynamic Programming
  • Considers multiple possibilities and stores results.
  • Problem

/ \

Choice A Choice B

↓ ↓

Result A Result B

\ /

Choose best

20. Example: Knapsack

  • Fractional Knapsack
  • Greedy
  • because we can take fractions.
  • 0/1 Knapsack
  • Dynamic Programming

because choosing the locally best item can prevent the optimal combination.

This is an excellent example of why recognizing the problem structure matters.

21. How to Recognize Greedy Problems

Ask these questions:

Question 1

Can I make the best choice right now?

Potentially Greedy

Question 2

  • Once I make that choice, do I ever need to undo it?
  • No → Greedy candidate
  • Yes → Maybe Backtracking / DP

Question 3

Can I prove that my local choice is safe?

Yes → Strong Greedy candidate

Question 4

Does sorting reveal an obvious greedy order?

Very often:

Sort
Greedy selection

22. Greedy Mental Model

Remember this:

GREEDY
Make best choice

right now

Is it safe?

/ \

YES NO

↓ ↓

Commit Consider DP/

│ Backtracking

Continue

  • Optimal result
  • The critical word is:
  • Safe
  • A greedy algorithm isn't simply "choose the largest number."
  • It's:

Choose the locally optimal option for which you can establish that committing to it doesn't destroy the possibility of an optimal solution.

23. Your DSA Progression

You've now covered:

Recursion
Searching
Sorting
Divide & Conquer
👉 Greedy

And the connections are becoming important:

Sorting

┌────────┼────────┐

↓ ↓ ↓

Greedy Binary Two Pointers

Search
├── Activity Selection
  • ├── Job Scheduling
  • ├── Kruskal
  • ├── Prim
  • └── Dijkstra
  • 🎯 Focus on these 5 first

If you're preparing for interviews, master these examples before moving to Dynamic Programming:

  • Activity Selection
  • Fractional Knapsack
  • Job Sequencing
  • Merge Intervals / Non-overlapping Intervals
  • Jump Game

Once you understand why the greedy choice is safe in these problems, you'll be ready for the next major topic: Dynamic Programming, where the key difference is that a locally best choice is often not enough.

Module 4 · Lesson 4.19

Dynamic Programming

Dynamic Programming (DP)

Dynamic Programming is a technique for solving problems by:

Breaking a problem into smaller subproblems, solving each subproblem once, and storing the result so we don't calculate it again.

DP is one of the most important—and initially confusing—DSA topics.

The easiest way to understand it is through recursion → repeated work → memoization → DP.

1. Why Do We Need Dynamic Programming?

Consider Fibonacci:

F(n) = F(n-1) + F(n-2)

Recursive implementation:

def fib(n):
if n <= 1:
return n
return fib(n - 1) + fib(n - 2)
  • For:
  • fib(5)
  • the calls look like:
  • fib(5)

/ \

fib(4) fib(3)

/ \ / \

fib(3) fib(2) fib(2) fib(1)

/ \

  • fib(2) fib(1)
  • Notice:
  • fib(3)
  • fib(2)
  • are calculated multiple times.

This repeated work makes the naive recursive solution inefficient.

2. The Two Properties of DP

A problem is often a good candidate for DP when it has:

1. Overlapping Subproblems

The same smaller problem appears repeatedly.

Example:

fib(5)
fib(4), fib(3)
fib(4)
fib(3), fib(2)

fib(3) appears again.

2. Optimal Substructure

The optimal solution to a larger problem can be constructed from optimal solutions to smaller problems.

For example, in many optimization problems:

Best answer for N
Best answers for smaller states

So remember:

DP = Overlapping Subproblems + Optimal Substructure

3. DP and Recursion

This is the easiest way to understand DP.

Start with recursion:

Problem
Subproblem
Subproblem

But recursion repeats work.

So we add storage:

Problem
Subproblem
Store result
Reuse result

That's Memoization.

4. Memoization — Top Down

Memoization means:

Recursion + Cache

Example:

def fib(n, memo):
if n <= 1:
return n
if n in memo:
return memo[n]

memo[n] = fib(n - 1, memo) + fib(n - 2, memo)

return memo[n]

Call:

memo = {}
print(fib(10, memo))

Now each Fibonacci value is calculated only once.

5. Why Memoization Is Faster

Without memoization:

fib(5)

├── fib(4)

│ ├── fib(3)

│ └── fib(2)

└── fib(3)

├── fib(2)

└── fib(1)

Lots of repeated calculations.

With memoization:

fib(5)
fib(4)
fib(3)
fib(2)
Store results
  • When fib(3) is needed again:
  • Cache → return immediately
  • Complexity becomes:

Time = O(n)

Space = O(n)

instead of exponential time for naive Fibonacci.

6. Tabulation — Bottom Up

There is another approach:

Tabulation

Instead of starting with the big problem and recursively going down, we start with the smallest problems and build upward.

For Fibonacci:

def fib(n):
if n <= 1:
return n
dp = [0] * (n + 1)

dp[1] = 1

for i in range(2, n + 1):
    dp[i] = dp[i - 1] + dp[i - 2]
return dp[n]

For:

n = 5
  • we calculate:
  • dp[0] = 0
  • dp[1] = 1
  • dp[2] = 1
  • dp[3] = 2
  • dp[4] = 3
  • dp[5] = 5

Answer:

5

7. Memoization vs Tabulation

MemoizationTabulation
ApproachTop DownBottom Up
Uses recursionUsuallyNo
Uses cacheYesDP table
Starts withBig problemSmall problems
Easy to writeOftenOften
Stack usageYesNo

Remember:

Memoization → Top Down

Tabulation → Bottom Up

8. The Most Important DP Skill

  • The hardest part of DP isn't writing the code.
  • It's:
  • Defining the state.
  • A state describes the smaller problem you're solving.
  • For Fibonacci:
  • dp[i] = Fibonacci number at position i
  • For climbing stairs:
  • dp[i] = number of ways to reach stair i
  • For 0/1 Knapsack:
  • dp[i][capacity]
  • could represent:

Maximum value using the first i items with the given capacity.

9. Example — Climbing Stairs

Problem:

You have n stairs. You can climb either 1 or 2 steps at a time. How many different ways can you reach the top?

For:

n = 3
  • Ways:
  • 1 + 1 + 1
  • 1 + 2
  • 2 + 1

Answer:

3

DP Thinking

To reach stair n, your last move was either:

  • n-1 → n
  • or:
  • n-2 → n

Therefore:

dp[n] = dp[n-1] + dp[n-2]

This is exactly the Fibonacci pattern.

10. Python

def climb_stairs(n):
if n <= 2:
return n
dp = [0] * (n + 1)

dp[1] = 1

dp[2] = 2

for i in range(3, n + 1):
    dp[i] = dp[i - 1] + dp[i - 2]
return dp[n]

11. Space Optimization

Notice:

dp[i] = dp[i-1] + dp[i-2]

  • We don't actually need the entire array.
  • We only need the previous two values.
  • So:
def climb_stairs(n):
if n <= 2:
return n
prev2 = 1
prev1 = 2
for i in range(3, n + 1):
current = prev1 + prev2
prev2 = prev1
prev1 = current
return prev1

Now:

Time = O(n)

  • Space = O(1)
  • This technique is called:
  • Space optimization

12. 0/1 Knapsack

This is one of the most important DP problems.

Suppose:

ItemWeightValue
A220
B330
C440

Bag capacity:

5

For every item, we have two choices:

  • Take it
  • OR
  • Don't take it
  • That's why this becomes DP.

13. Knapsack State

A common state is:

dp[i][w]

  • meaning:
  • Maximum value we can obtain using the first i items with capacity w.
  • For item i, we have:
  • Don't take it

dp[i-1][w]

Take it

value[i] + dp[i-1][w-weight[i]]

Therefore:

dp[i][w] =

max(

dp[i-1][w],

value[i] + dp[i-1][w-weight[i]]

)

This is a classic DP recurrence.

14. DP vs Greedy

This is extremely important because you just studied Greedy.

  • Consider 0/1 Knapsack.
  • Greedy might say:
  • Take item with highest value/weight ratio.
  • But that can produce a non-optimal solution.
  • DP considers:
  • Take
  • OR
  • Don't Take
  • and finds the best combination.
  • So:
Greedy
Commit to a choice
DP
Explore states
Store results
Find optimum

15. Coin Change

Another classic DP problem.

Coins:

\[1, 3, 4\]
  • Target:
  • 6
  • We saw earlier that greedy gives:
  • 4 + 1 + 1
  • = 3 coins.
  • But optimal is:
  • 3 + 3
  • = 2 coins.
  • DP can find this.
  • Define:
  • dp[x] = minimum coins needed to make x
  • Then:
  • dp[x] = min(

dp[x-1] + 1,

dp[x-3] + 1,

dp[x-4] + 1

)

For 6:

dp[6] = 2

16. Longest Common Subsequence

Another major DP problem:

Find the longest sequence that appears in two strings while preserving order.

Example:

A = "abcde"
B = "ace"
  • LCS:
  • "ace"
  • Length:
  • 3
  • Typical state:

dp[i][j]

  • meaning:
  • LCS of the first i characters of A and first j characters of B.
  • If characters match:

dp[i][j] = dp[i-1][j-1] + 1

Otherwise:

dp[i][j] = max(

dp[i-1][j],

dp[i][j-1]

)

This is a very important 2D DP pattern.

17. Common Types of DP

You will encounter several patterns.

  • 1D DP
  • dp[i]
  • Examples:
  • Fibonacci
  • Climbing Stairs
  • House Robber
  • Coin Change
  • 2D DP

dp[i][j]

  • Examples:
  • 0/1 Knapsack
  • LCS
  • Edit Distance
  • Grid problems
  • Grid DP
  • dp[row][column]
  • Examples:
  • Unique Paths
  • Minimum Path Sum
  • Number of Paths
  • String DP

dp[i][j]

  • Examples:
  • LCS
  • Edit Distance
  • Longest Palindromic Subsequence
  • Interval DP
  • State represents an interval:
  • dp[left][right]
  • Examples:
  • Matrix Chain Multiplication
  • Burst Balloons
  • Palindrome-related problems

18. How to Identify a DP Problem

When you see a problem, ask:

Question 1

  • Does the problem ask for:
  • Maximum?
  • Minimum?
  • Number of ways?
  • Can we achieve it?

These are strong DP signals.

Question 2

Can I define the problem using smaller versions of itself?

Problem(n)
Problem(n-1)

Problem(n-2)

Potential DP.

Question 3

Do the same subproblems occur repeatedly?

Yes → DP candidate

Question 4

  • Can I define a state?
  • For example:
  • dp[i]

dp[i][j]

dp[i][capacity]

If yes, you're getting close.

19. The DP Problem-Solving Framework

This is the framework I strongly recommend memorizing:

DP PROBLEM

1. Define the state

2. Find the recurrence

3. Define base cases

4. Choose implementation

/ \

Memoization Tabulation

Top Down Bottom Up

\ /

5. Optimize space

20. Example of the Framework

  • Take Climbing Stairs.
  • Step 1 — State
  • dp[i] = number of ways to reach stair i
  • Step 2 — Recurrence

dp[i] = dp[i-1] + dp[i-2]

  • Step 3 — Base Cases
  • dp[1] = 1
  • dp[2] = 2
  • Step 4 — Implementation
  • Use tabulation:
for i in range(3, n + 1):
    dp[i] = dp[i - 1] + dp[i - 2]

Step 5 — Optimize

Only keep the previous two values.

21. DP vs Divide & Conquer

  • You just learned Divide & Conquer.
  • They look similar but have a major difference.
  • Divide & Conquer
  • Subproblems are usually independent.
  • Problem

/ \

A B

  • DP
  • Subproblems often overlap.
  • Problem

/ \

A B

\ /

  • Same
  • subproblem
  • So:
  • Divide & Conquer
  • → independent subproblems
  • Dynamic Programming
  • → overlapping subproblems

22. DP vs Recursion

Recursion:

Call
Call
Call

DP:

Recursion

+

Memory
Don't solve the same problem twice

This is why you can think of:

DP as optimized recursion in many problems.

23. Most Important DP Problems

Learn them in roughly this order:

Beginner

  • Fibonacci
  • Climbing Stairs
  • House Robber
  • Min Cost Climbing Stairs

Intermediate

  • Coin Change
  • 0/1 Knapsack
  • Unique Paths
  • Minimum Path Sum
  • Longest Increasing Subsequence
  • Partition Equal Subset Sum

Advanced

  • Longest Common Subsequence
  • Edit Distance
  • Longest Palindromic Subsequence
  • Matrix Chain Multiplication
  • Burst Balloons

24. The Most Important DP Patterns

Try to recognize these patterns:

1D DP
dp[i]

2D DP

dp[i][j]

Knapsack
Take / Don't Take
Grid
Top / Left
String
Character matching
Subsequence
Include / Exclude
Optimization
min / max
Counting
sum of ways

25. DP Mental Model

Keep this picture:

DYNAMIC PROGRAMMING

┌───────────────┴───────────────┐

│ │

Overlapping Optimal

Subproblems Substructure

│ │

└───────────────┬───────────────┘

Define State
Recurrence
Base Cases

┌──────────┴──────────┐

│ │

Memoization Tabulation

Top Down Bottom Up

│ │

└──────────┬──────────┘

Space Optimization

🎯 The key difference from Greedy

This is the one thing I want you to remember from this topic:

Greedy makes one choice and commits to it. DP considers the possibilities represented by its states and reuses their results.

For your DSA journey, don't try to memorize 20 DP solutions. Instead, get very good at defining dp[i] or dp[i][j], finding the recurrence, and identifying the base case. Once you can do those three things, most DP problems become much less intimidating.

Module 4 · Lesson 4.20

Backtracking

Backtracking

Backtracking is an algorithmic technique used to solve problems where we need to try multiple possible choices, and when a choice leads to an invalid solution, we undo the choice and try another.

  • The simplest mental model is:
  • Choose → Explore → Undo → Try next choice
  • Backtracking is closely related to recursion.

1. The Basic Idea

Imagine you're in a maze:

Start
Choose a path
Explore

Dead end?

Go back
Try another path

That's backtracking.

CHOICES

Choose

Explore

┌──────┴──────┐

│ │

Valid Invalid

│ │

↓ ↓

Continue Undo

Try another

2. Why Recursion?

Suppose we have:

Choices = [A, B, C]

We choose A:

\[A\]

Then choose B:

\[A, B\]

Then choose C:

\[A, B, C\]

When we reach the end, we return and undo C:

\[A, B\]

Then try another option.

This naturally fits recursion.

3. The Core Backtracking Template

This is one of the most important templates in DSA:

def backtrack(state):
    if is_solution(state):
        process(state)

return

for choice in choices:
    if is_valid(choice):
        make_choice(choice)
  • backtrack(state)
  • undo_choice(choice)
  • The most important line is:
  • undo_choice(choice)
  • That's the backtracking step.

4. Simple Example — Generate All Subsets

Suppose:

\[1, 2, 3\]

We want every possible subset.

Answer:

[]

\[1\]
\[2\]
\[3\]
\[1,2\]
\[1,3\]
\[2,3\]
\[1,2,3\]

For every number, we have two choices:

  • Take it
  • OR
  • Don't take it
  • This creates a decision tree.

5. Decision Tree

[]

/ \

\[1] [\]

/ \ / \

\[1,2] [1] [2] [\]

/ \ / \ / \ / \

\[1,2,3] [1,2] [1,3] [1] [2,3] [2] [3] [\]

Every path represents a subset.

This is classic backtracking.

6. Python — Subsets

def subsets(nums):
    result = []
    def backtrack(index, current):
        if index == len(nums):
            result.append(current.copy())
  • return
  • # Choice 1: include
  • current.append(nums[index])
  • backtrack(index + 1, current)
  • # Undo
  • current.pop()
  • # Choice 2: exclude
  • backtrack(index + 1, current)
  • backtrack(0, [])
return result

Example:

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

7. Understand append() and pop()

  • This pattern is extremely important:
  • current.append(choice)
  • backtrack(...)
  • current.pop()
  • Think:
Choose
Add choice
Explore
Remove choice
Try next choice

The pop() is the actual undo operation.

8. Another Example — Permutations

Given:

\[1, 2, 3\]

Generate all permutations:

\[1,2,3\]
\[1,3,2\]
\[2,1,3\]
\[2,3,1\]
\[3,1,2\]
\[3,2,1\]

At every position, we choose one unused number.

9. Permutation Decision Tree

[]

/ | \

1 2 3

/ \ / \ / \

2 3 1 3 1 2

| | | | | |

3 2 3 1 2 1

Each path produces one permutation.

10. Python — Permutations

def permutations(nums):
    result = []
    def backtrack(current, used):
        if len(current) == len(nums):
            result.append(current.copy())

return

for i in range(len(nums)):
    if used[i]:
        continue
  • used[i] = True
  • current.append(nums[i])
  • backtrack(current, used)
  • current.pop()
  • used[i] = False
  • backtrack([], [False] * len(nums))
return result

11. Why used?

Suppose:

nums = [1, 2, 3]
  • If we've already selected 1, we shouldn't select it again.
  • So:
  • used[i] = True
  • means:
  • This element is currently part of the solution.
  • When we backtrack:
  • used[i] = False
  • we make it available again.

12. N-Queens

One of the most famous backtracking problems is:

Place N queens on an N × N chessboard so that no two queens attack each other.

For 4 queens:

. Q . .

. . . Q

Q . . .

. . Q .

  • No two queens share:
  • Same row
  • Same column
  • Same diagonal

13. N-Queens Backtracking

For each row:

Try column 1

Valid?

Place queen
Move to next row

Invalid?

Remove queen
Try column 2

This is exactly:

Choose
Check
Explore
Undo

14. Backtracking with Pruning

A naive approach might explore every possible combination.

That's expensive.

Backtracking becomes powerful when we stop exploring a path as soon as we know it cannot produce a solution.

This is called:

Pruning

Example:

Start

/ \

Valid Invalid

/ X

Continue STOP

We don't waste time exploring the invalid branch.

15. Example — N-Queens Pruning

Suppose:

Q . . .

. . Q .

Now we try placing another queen in a position where it attacks an existing queen.

Instead of continuing:

Invalid
STOP
Remove queen
Try another position

That's pruning.

16. Backtracking vs Brute Force

They are related but not identical.

Brute Force

  • Try everything:
  • Try A
  • Try B
  • Try C
  • Try D

...

  • Backtracking
  • Stop exploring a branch as soon as it becomes impossible.
  • Try A

Invalid?

STOP

Therefore:

Backtracking = systematic search + pruning

17. Backtracking vs DFS

  • This distinction is important.
  • DFS
  • DFS explores a graph/tree.
Node
Neighbor
Neighbor

Backtracking

Backtracking explores choices and undoes them.

Choose
Explore
Undo
Try next choice

Backtracking often uses DFS internally.

So:

Backtracking
DFS

+

Undo choices

+

Pruning

18. Backtracking vs Dynamic Programming

  • You just studied DP, so this distinction is useful.
  • Backtracking
  • Usually explores many possible solutions:
  • Choice A
  • ├── Choice B
  • ├── Choice C
  • └── Choice D
  • It may need to find:
  • All solutions
  • One valid solution
  • Best solution among possibilities
  • DP

Usually identifies repeated subproblems and stores their answers.

Same subproblem
Calculate once
Store
Reuse

So:

Backtracking → Explore possibilities

DP → Reuse subproblem results

19. Common Backtracking Problems

Learn these in this order:

Beginner

  • Generate Subsets
  • Generate Permutations
  • Combination Sum

Intermediate

  • Letter Combinations of a Phone Number
  • Generate Parentheses
  • Palindrome Partitioning
  • Word Search

Advanced

  • N-Queens
  • Sudoku Solver
  • Rat in a Maze

20. Combination Sum

Example:

candidates = [2, 3, 6, 7]
target = 7

Possible answers:

\[2, 2, 3\]
\[7\]

The algorithm:

Choose 2
Choose 2
Choose 3
Target reached
Save solution
Undo 3
Try another

This is a classic backtracking pattern.

21. Backtracking Template for Interviews

A very useful generic template:

def backtrack(start, path):
    if is_complete(path):
        result.append(path.copy())

return

for choice in choices:
    if not valid(choice):
        continue
  • # Choose
  • path.append(choice)
  • # Explore
  • backtrack(start, path)
  • # Undo
  • path.pop()
  • Remember the three lines:
  • path.append(choice)
  • backtrack(...)
  • path.pop()
  • That's the heart of backtracking.

22. How to Recognize Backtracking

  • Look for phrases such as:
  • "Generate all..."
  • "Find all possible..."
  • "Return all combinations..."
  • "Try every arrangement..."
  • "Place N objects..."
  • "Find a valid configuration..."
  • "Can we form...?"

These are strong signals.

Then ask:

Do I have multiple choices at each step?

YES

Can I reject invalid choices early?

YES
Backtracking

23. Time Complexity

  • Backtracking is often exponential.
  • For subsets:
  • Each element → 2 choices
Total = 2ⁿ
  • So:
  • O(2ⁿ)
  • For permutations:

n!

So approximately:

O(n!)

This can become very expensive.

That's why pruning is so important.

24. The Most Important Mental Model

Memorize:

BACKTRACKING

CHOOSE

VALID?

/ \

NO YES

↓ ↓

SKIP EXPLORE

COMPLETE?

/ \

YES NO

↓ ↓

SAVE CONTINUE

UNDO

NEXT CHOICE

25. Your DSA Connection

Your progression is now:

Recursion

├──────────────┐

↓ ↓

Divide & Backtracking

Conquer │

│ ↓

│ DFS-style

│ exploration

Sorting

└──────────────┐

Algorithms

And compared with your previous topics:

Greedy
Make one choice and commit
DP
Solve states and reuse results
Backtracking
Try a choice
Explore
Undo
Try another

🎯 What to master now

Before moving to Sliding Window, make sure you can implement these from memory:

  • Generate Subsets
  • Generate Permutations
  • Combination Sum
  • Word Search
  • N-Queens

If you understand the pattern choose → recurse → undo, you have the core of Backtracking.

Module 4 · Lesson 4.21

Sliding Window

Sliding Window

Sliding Window is a problem-solving technique mainly used for arrays and strings when the problem involves a contiguous subarray or substring.

The core idea is:

Instead of repeatedly examining the same elements, maintain a moving "window" over the data.

1. What is a Window?

Suppose:

arr = [2, 1, 5, 1, 3, 2]

A window of size 3 could be:

[2, 1, 5] 1 3 2

Then slide it:

2 [1, 5, 1] 3 2

Then:

2 1 [5, 1, 3] 2

Then:

2 1 5 [1, 3, 2]

Instead of recalculating every window from scratch, we remove the element leaving the window and add the element entering it.

2. Why Sliding Window?

Consider:

\[2, 1, 5, 1, 3, 2\]

Question:

  • Find the maximum sum of any subarray of size 3.
  • Brute force:
  • 2 + 1 + 5 = 8
  • 1 + 5 + 1 = 7
  • 5 + 1 + 3 = 9
  • 1 + 3 + 2 = 6

Answer:

9

  • But imagine n = 1,000,000.
  • Recalculating every window from scratch is wasteful.
  • Sliding Window lets us do it in O(n).

3. Fixed-Size Sliding Window

This is the easiest type.

Suppose:

arr = [2, 1, 5, 1, 3, 2]
k = 3

Start with:

\[2, 1, 5\]
  • Sum:
  • 8
  • Slide one position.
  • Remove 2:
  • 8 - 2 = 6
  • Add 1:
  • 6 + 1 = 7
  • Window:
\[1, 5, 1\]
  • Next:
  • 7 - 1 + 3 = 9
  • Window:
\[5, 1, 3\]
  • Next:
  • 9 - 5 + 2 = 6
  • Final:
  • 6
  • Maximum:
  • 9

4. Python — Fixed Window

def max_sum_subarray(arr, k):
    window_sum = sum(arr[:k])
    maximum = window_sum
    for right in range(k, len(arr)):
        window_sum += arr[right]

window_sum -= arr[right - k]

maximum = max(maximum, window_sum)
return maximum

Example:

arr = [2, 1, 5, 1, 3, 2]
print(max_sum_subarray(arr, 3))

Output:

9

Complexity:

Time = O(n)

Space = O(1)

5. The Important Transformation

  • Without Sliding Window:
  • Window 1 → calculate everything
  • Window 2 → calculate everything again
  • Window 3 → calculate everything again
  • With Sliding Window:
Current window
Remove outgoing element
Add incoming element
New window

That's the entire idea.

6. Variable-Size Sliding Window

This is much more important for interviews.

The window doesn't always have a fixed size.

Example:

Find the smallest subarray whose sum is at least 7.

Given:

\[2, 3, 1, 2, 4, 3\]
  • Target:
  • 7
  • We use two pointers:
  • left
  • right
  • The window is:
\[left ........ right\]

7. Step-by-Step

Start:

\[2\]
  • Sum:
  • 2
  • Not enough.
  • Expand:
\[2, 3\]
  • Sum:
  • 5
  • Still not enough.
  • Expand:
\[2, 3, 1\]
  • Sum:
  • 6
  • Still not enough.
  • Expand:
\[2, 3, 1, 2\]
  • Sum:
  • 8
  • Now:
  • sum >= 7
  • Try shrinking from the left:
\[3, 1, 2\]
  • Sum:
  • 6
  • Too small.

So the best window found so far is:

\[2, 3, 1, 2\]
  • Length:
  • 4
  • Continue.
  • Eventually:
\[4, 3\]
  • Sum:
  • 7
  • Length:
  • 2

Answer:

2

8. Variable Window Template

This is one of the most useful templates to memorize:

def sliding_window(arr, target):
    left = 0
    current = 0
    answer = float("inf")
    for right in range(len(arr)):
        current += arr[right]
while current >= target:
answer = min(answer, right - left + 1)

current -= arr[left]

left += 1

return answer

The important structure:

Expand right

Condition satisfied?

YES
Shrink left
Continue

9. The Two Pointers

  • Sliding Window generally uses:
  • left
  • right

Example:

left right

↓ ↓

\[2, 3, 1, 2, 4, 3\]

right expands the window:

\[2, 3, 1, 2\]

↑ ↑

left right

left shrinks it:

\[3, 1, 2\]

↑ ↑

left right

This is why Sliding Window and Two Pointers are closely related.

10. Classic Problem — Longest Substring Without Repeating Characters

Given:

"abcabcbb"

Find the longest substring without duplicate characters.

Answer:

  • "abc"
  • Length:
  • 3

11. Sliding Window Approach

Start:

\[a\]

Then:

\[ab\]

Then:

\[abc\]

Next character is a.

We have a duplicate:

\[abca\]

So move left forward until the duplicate disappears.

\[bca\]
  • Continue.
  • The window always maintains:
  • No duplicate characters

This is a variable-size window.

12. Python Implementation

def longest_unique_substring(s):
    left = 0
    characters = set()
    maximum = 0
    for right in range(len(s)):
        while s[right] in characters:
            characters.remove(s[left])

left += 1

characters.add(s[right])

maximum = max(
    maximum,
    right - left + 1
)
return maximum

Example:

s = "abcabcbb"
print(longest_unique_substring(s))

Output:

3

13. Why Is It O(n)?

At first glance, we have:

for right ...

and:

while ...
  • You might think:
  • O(n²)
  • But that's not the case.
  • left only moves forward.

left: 0 → 1 → 2 → 3 → ...

It never moves backward.

Similarly, right moves:

0 → 1 → 2 → 3 → ...

So each element enters the window once and leaves the window at most once.

Therefore:

Time = O(n)

This is called amortized O(n) behavior.

14. Fixed vs Variable Sliding Window

Fixed WindowVariable Window
SizeConstant kChanges
Typical questionMaximum sum of size KLongest/smallest satisfying condition
PointersUsually right + calculated leftleft + right
ExampleMax sum of K elementsSmallest subarray ≥ target

15. Common Fixed-Window Problems

  • Look for:
  • "Subarray of size K"
  • "Substring of length K"
  • "Maximum/minimum sum of K consecutive elements"
  • Examples:
  • Maximum sum of K elements
\[2, 1, 5, 1, 3, 2\]
k = 3

Maximum number of vowels in substring of length K

"abciiidef"

k = 3

These are classic fixed windows.

16. Common Variable-Window Problems

  • Look for:
  • "Longest substring..."
  • "Smallest subarray..."
  • "At most K..."
  • "At least K..."
  • "No repeating..."
  • Examples:
  • Longest substring without repeating characters
  • "abcabcbb"
  • Minimum size subarray sum
\[2,3,1,2,4,3\]
target = 7

Longest substring with at most K distinct characters

This is another very common interview problem.

17. Frequency Map + Sliding Window

Sometimes a simple set isn't enough.

For example:

Find the longest substring containing at most 2 distinct characters.

We can maintain:

from collections import defaultdict
  • Then:
  • count = defaultdict(int)
  • Example window:
  • "aabbb"
  • Frequency:
  • a → 2
  • b → 3
  • If we exceed the allowed number of distinct characters, shrink from the left.
  • This gives the general pattern:
  • Sliding Window

+

Hash Map

This combination appears very frequently in interviews.

18. Example — At Most K Distinct Characters

from collections import defaultdict
def longest_k_distinct(s, k):
    count = defaultdict(int)
    left = 0
    maximum = 0
    for right in range(len(s)):
        count[s[right]] += 1
while len(count) > k:
    count[s[left]] -= 1
if count[s[left]] == 0:
    del count[s[left]]

left += 1

maximum = max(
    maximum,
    right - left + 1
)
return maximum

Example:

s = "eceba"
print(longest_k_distinct(s, 2))

Answer:

  • 3
  • because:
  • "ece"
  • has only two distinct characters.

19. Sliding Window Recognition

  • When you see:
  • subarray
  • substring
  • contiguous
  • consecutive
  • longest
  • shortest
  • maximum
  • minimum
  • at most K
  • at least K
  • exactly K
  • ask:
  • Can I maintain a window and move two pointers?

If yes, Sliding Window may be appropriate.

20. Sliding Window vs Two Pointers

  • These concepts overlap, but they're not exactly the same.
  • Two Pointers
  • Uses two indexes to traverse data.
  • left →
  • ← right
  • It doesn't necessarily maintain a "window."
  • Sliding Window
  • Specifically maintains a contiguous range:
\[left ........ right\]

and updates its state as the range moves.

So:

Sliding Window
Usually two pointers

+

Maintain window state

21. Important Caveat

The classic variable-size sliding window for sum conditions generally relies on the array having non-negative values.

For example:

\[2, 3, 1, 2, 4\]
  • If values are non-negative:
  • Expand window → sum doesn't decrease
  • Shrink window → sum doesn't increase
  • That monotonic behavior makes the technique work.
  • With negative numbers:
\[2, -5, 10\]
  • the sum can move unpredictably, so the same technique may fail.
  • In those cases, you may need:
  • Prefix sums
  • Hash maps
  • Monotonic structures
  • Other techniques

This is an important interview detail.

22. Sliding Window Mental Model

Memorize this:

SLIDING WINDOW

┌────────┴────────┐

│ │

Fixed Variable

│ │

Size K Condition-based

│ │

Move window Expand right

Condition?

/ \

No Yes

↓ ↓

Expand right Shrink left

Update answer

23. The Core Template

For a variable window:

left = 0
for right in range(len(arr)):
    # Add arr[right] to window
while window_is_invalid():
    # Remove arr[left]
  • left += 1
  • # Update answer
  • For strings:
left = 0
for right in range(len(s)):
    # Add s[right]
while invalid:
    # Remove s[left]
  • left += 1
  • # Update answer
  • The pattern is:
  • Expand → Validate → Shrink → Record

24. Your DSA Progression

You've now covered:

Recursion
Searching
Sorting
Divide & Conquer
Greedy
Dynamic Programming
Backtracking
👉 Sliding Window

And the important connections are:

Arrays / Strings
Two Pointers
Sliding Window
Hash Map / Set
O(n) solutions
  • 🎯 Master these 5 Sliding Window problems
  • Maximum Sum Subarray of Size K
  • Minimum Size Subarray Sum
  • Longest Substring Without Repeating Characters
  • Longest Substring with At Most K Distinct Characters
  • Permutation in String / Anagram-style window

If you can recognize whether a problem needs a fixed window or a variable window, you have learned the most important part of Sliding Window.

Module 4 · Lesson 4.22

Two Pointers

  • Two Pointers
  • Two Pointers is a problem-solving technique where we use two indexes/pointers to traverse an array or string efficiently.
  • The basic idea is:
  • Pointer 1 →
  • Pointer 2 →
  • Instead of using nested loops and repeatedly checking elements, we move the pointers intelligently.
  • Two Pointers is especially useful for:
  • Sorted arrays
  • Finding pairs
  • Removing duplicates
  • Palindromes
  • Comparing elements from both ends
  • Merging arrays
  • Partitioning

1. Simple Example

Suppose:

arr = [1, 2, 3, 4, 6]

We want to find two numbers whose sum is:

target = 6

Use two pointers:

left right

↓ ↓

\[1, 2, 3, 4, 6\]
  • Calculate:
  • 1 + 6 = 7
  • Too large.

Because the array is sorted, move right left:

left right

↓ ↓

\[1, 2, 3, 4, 6\]
  • Now:
  • 1 + 4 = 5
  • Too small.
  • Move left right:

left right

↓ ↓

\[1, 2, 3, 4, 6\]
  • Now:
  • 2 + 4 = 6
  • Found!
\[2, 4\]

2. Why Does This Work?

  • The important condition is:
  • The array is sorted.
  • If:
  • left + right < target

we need a larger sum, so move:

  • left += 1
  • If:
  • left + right > target

we need a smaller sum, so move:

right -= 1

Therefore:

  • sum < target → move left →
  • sum > target → move ← right
  • sum == target → found
  • This avoids checking every pair.

3. Brute Force vs Two Pointers

Brute force:

for i in range(len(arr)):
for j in range(i + 1, len(arr)):
if arr[i] + arr[j] == target:
return [i, j]
  • Complexity:
  • O(n²)
  • Two pointers:
left = 0
right = len(arr) - 1
while left < right:
    total = arr[left] + arr[right]
    if total == target:
        return [left, right]
        elif total < target:
            left += 1
else:
    right -= 1

Complexity:

O(n)

  • If sorting is required first:
  • Sorting = O(n log n)
  • Two pointers = O(n)
  • Total = O(n log n)

4. Classic Two-Sum Problem

Given:

arr = [2, 7, 11, 15]
target = 9
  • Because the array is sorted:
  • 2 + 15 = 17
  • Too large:
  • right--
  • Now:
  • 2 + 11 = 13
  • Too large:
  • right--
  • Now:
  • 2 + 7 = 9
  • Found.

5. Python Implementation

def two_sum_sorted(arr, target):
    left = 0
    right = len(arr) - 1
    while left < right:
        total = arr[left] + arr[right]
        if total == target:
            return [left, right]
            if total < target:
                left += 1
else:
    right -= 1
return []

6. Two Pointers From Both Ends

This is the most recognizable Two Pointer pattern.

left right

↓ ↓

\[1, 2, 3, 4, 5, 6, 7\]
  • Move:
  • left →
  • or:
  • ← right
  • until:
  • left >= right

This is commonly used for:

  • Two Sum II
  • Container With Most Water
  • Valid Palindrome
  • 3Sum
  • Pair problems

7. Valid Palindrome

  • Given:
  • "racecar"
  • Compare characters from both ends.
  • r a c e c a r

↑ ↑

L R

Compare:

r == r

Move:

↑ ↑

Then:

a == a

Continue until the pointers meet.

Python

def is_palindrome(s):
left = 0
right = len(s) - 1
while left < right:
if s[left] != s[right]:
return False

left += 1

right -= 1

return True

Complexity:

Time = O(n)

Space = O(1)

8. Two Pointers With a Condition

Another common pattern is:

left

\[...............\]

  • right
  • The pointers move based on a condition.
  • For example:
while left < right:
    if condition:
        left += 1
else:
    right -= 1

The key is figuring out which pointer should move and why.

9. Remove Duplicates From Sorted Array

Given:

\[1, 1, 2, 2, 3\]

We want:

\[1, 2, 3\]
  • Use two pointers.
  • One pointer tracks the position where the next unique value should go.
  • slow

\[1, 1, 2, 2, 3\]

Another pointer scans:

fast

\[1, 1, 2, 2, 3\]

Python

def remove_duplicates(nums):
    if not nums:
        return 0
        slow = 0
        for fast in range(1, len(nums)):
            if nums[fast] != nums[slow]:
                slow += 1

nums[slow] = nums[fast]

return slow + 1

After execution:

\[1, 2, 3, ...\]

The first 3 positions contain the unique values.

Complexity:

Time = O(n)

Space = O(1)

10. Slow and Fast Pointers

This is another very important Two Pointer pattern.

  • Instead of:
  • left / right
  • we use:
  • slow / fast

Example:

slow →

fast →

The fast pointer moves ahead while slow moves at a different rate.

This is especially important for Linked Lists.

11. Detect Cycle in Linked List

Suppose:

1 → 2 → 3 → 4

↑ ↓

← ← ←

  • There is a cycle.
  • Use:
  • slow moves 1 step
  • fast moves 2 steps
  • Eventually:
  • slow == fast

Therefore, a cycle exists.

This is called:

Floyd's Cycle Detection Algorithm

Python

def has_cycle(head):
slow = head
fast = head
while fast and fast.next:
slow = slow.next
fast = fast.next.next
if slow == fast:
return True
return False

Complexity:

Time = O(n)

Space = O(1)

12. Finding the Middle of a Linked List

  • Again:
  • slow → 1 step
  • fast → 2 steps

Example:

1 → 2 → 3 → 4 → 5

Eventually:

slow = 3
fast = 5

So slow points to the middle.

def middle_node(head):
slow = head
fast = head
while fast and fast.next:
slow = slow.next
fast = fast.next.next
return slow

This is one of the most important linked-list patterns.

13. Container With Most Water

Given:

\[1, 8, 6, 2, 5, 4, 8, 3, 7\]
  • Each number represents a vertical line.
  • We want the two lines that form the container holding the most water.
  • Start:

left right

↓ ↓

\[1, 8, 6, 2, 5, 4, 8, 3, 7\]
  • Area is determined by:
  • width × min(left_height, right_height)
  • The key greedy/two-pointer observation:

Move the pointer at the shorter line.

Why?

Because the shorter line limits the height. Moving the taller line inward cannot increase the limiting height enough to compensate for losing width.

Complexity:

O(n)

This is a classic interview problem.

14. 3Sum

Another famous problem.

Given:

\[-1, 0, 1, 2, -1, -4\]

Find triplets that sum to zero.

First sort:

\[-4, -1, -1, 0, 1, 2\]

Then:

Fix one element

Use two pointers for the remaining elements

For example:

-1 + 0 + 1 = 0

This gives a common pattern:

Sort
Fix one element
Two Pointers
  • Typical complexity:
  • O(n²)
  • instead of:
  • O(n³)
  • with three nested loops.

15. Two Pointers vs Sliding Window

  • You just learned Sliding Window, so this distinction is important.
  • Two Pointers
  • Can work with:
  • left + right
  • and doesn't necessarily represent a contiguous window.

Example:

  • Two Sum
  • Sliding Window
  • Maintains a contiguous section:
\[left ........ right\]

Example:

Longest substring without repeating characters

So:

Two Pointers
├── Opposite ends
├── Slow/Fast
└── Sliding Window

Sliding Window is essentially one important application of the two-pointer idea.

16. When Should You Think Two Pointers?

Look for:

Sorted array

\[1, 2, 3, 4, 5\]
  • Think:
  • Two Pointers
  • Pair/triplet
  • Find two numbers...
  • Find three numbers...
  • Think:
  • Two Pointers
  • Palindrome
  • Compare from both ends
  • Think:
  • Two Pointers
  • Linked list
  • Middle
  • Cycle
  • Nth node from end
  • Think:
  • Slow + Fast pointers
  • Contiguous subarray/substring
  • Think:
  • Sliding Window

17. Common Two-Pointer Patterns

There are four patterns worth remembering.

Pattern 1 — Opposite Ends

left → ← right

  • Used for:
  • Two Sum
  • Palindrome
  • Container With Most Water
  • 3Sum
  • Pattern 2 — Same Direction
  • slow →
  • fast →
  • Used for:
  • Remove duplicates
  • Partitioning
  • Moving zeroes
  • Pattern 3 — Slow/Fast Linked List
  • slow → 1 step
  • fast → 2 steps
  • Used for:
  • Cycle detection
  • Find middle
  • Find nth node
  • Pattern 4 — Sliding Window
\[left ........ right\]
  • Used for:
  • Longest substring
  • Minimum subarray
  • Maximum window
  • At most K distinct

18. Complexity Advantage

The main reason Two Pointers is powerful is that it can convert:

O(n²)

into:

O(n)

For example:

Brute Force

for i:
    for j:
        check pair

becomes:

Two Pointers

left = 0
right = n - 1
while left < right:

...

Each pointer generally moves only forward/backward through the data.

19. Two Pointers Mental Model

TWO POINTERS

┌───────────┼───────────┐

│ │ │

Opposite Same Linked List

Ends Direction

│ │ │

left/right slow/fast slow/fast

│ │ │

Pair problems Duplicates Cycles

Palindrome Partition Middle

3Sum Move zeroes

└─────────────┐

  • Sliding Window
  • 🎯 Master these problems
  • For interviews, practice these in order:
  • Two Sum II — Sorted Array
  • Valid Palindrome
  • Remove Duplicates from Sorted Array
  • Move Zeroes
  • Container With Most Water
  • 3Sum
  • Middle of Linked List
  • Linked List Cycle
  • Longest Substring Without Repeating Characters — Sliding Window
  • The most important recognition rule is:

Sorted array + pair/triplet → think Two Pointers.

And:

Linked list + middle/cycle → think Slow & Fast Pointers.

Module 4 · Lesson 4.23

Union Find

Union-Find (Disjoint Set Union)

Union-Find, also called Disjoint Set Union (DSU), is a data structure used to efficiently manage groups of connected elements.

  • It mainly supports two operations:
  • Find → Which group does this element belong to?
  • Union → Merge two groups.

The classic use cases are graph connectivity, cycle detection, and Minimum Spanning Tree (Kruskal's algorithm).

1. The Basic Idea

Imagine 6 people:

1 2 3 4 5 6

Initially, everyone is in their own group:

{1} {2} {3} {4} {5} {6}

Now connect 1 and 2:

Union(1, 2)

Groups become:

{1,2} {3} {4} {5} {6}

  • Then:
  • Union(2, 3)
  • Now:

{1,2,3} {4} {5} {6}

If we ask:

Are 1 and 3 connected?

Answer:

YES

That's what Union-Find helps us determine efficiently.

2. The Two Main Operations

Find

  • Find(x)
  • returns the representative/root of the group containing x.
  • For example:
  • 1 → 2 → 3
  • If 3 is the root:
  • Find(1) → 3
  • Find(2) → 3
  • Find(3) → 3
  • Union
  • Union(a, b)

combines the groups containing a and b.

Example:

  • Group A: {1,2}
  • Group B: {3,4}
  • Union(1,3)
  • Result:

{1,2,3,4}

3. Why Use a Tree?

  • Union-Find represents each group as a tree.
  • For example:
  • 1

/ \

2 3

/

  • 4
  • The root:
  • 1
  • represents the entire group.
  • So:
  • Find(4)
  • follows:
  • 4 → 3 → 1
  • and returns:
  • 1

4. Parent Array

The easiest way to implement Union-Find is with a parent array.

Initially:

Elements: 0 1 2 3 4

Parent: 0 1 2 3 4

  • Each element is its own parent.
  • Meaning:
  • 0 → 0
  • 1 → 1
  • 2 → 2
  • 3 → 3
  • 4 → 4
  • There are 5 separate groups.

5. Union Operation

Suppose:

Union(0, 1)

We can make:

1 → 0

Parent array:

\[0, 0, 2, 3, 4\]

Tree:

0

/

  • 1
  • Then:
  • Union(1, 2)
  • Find the roots:
  • Find(1) → 0
  • Find(2) → 2
  • Connect:
  • 2 → 0
  • Now:
  • 0

/ \

1 2

6. Basic Python Implementation

class UnionFind:
    def __init__(self, n):
        self.parent = list(range(n))
def find(self, x):
while x != self.parent[x]:
x = self.parent[x]
return x
def union(self, a, b):
root_a = self.find(a)
root_b = self.find(b)
if root_a == root_b:
return False

self.parent[root_b] = root_a

return True

Example:

  • uf = UnionFind(5)
  • uf.union(0, 1)
  • uf.union(1, 2)
print(uf.find(0))
print(uf.find(2))

Both belong to the same group.

7. The Problem With Basic Union-Find

  • Suppose we keep doing:
  • Union(1,2)
  • Union(2,3)
  • Union(3,4)
  • Union(4,5)
  • We might accidentally create:
  • 1

\

2

\

3

\

4

\

  • 5
  • Now:
  • Find(5)
  • has to travel through many nodes.
  • In the worst case:

O(n)

We need to keep the tree shallow.

That's where Union by Rank/Size comes in.

8. Union by Rank

  • The idea:
  • Attach the smaller tree under the larger tree.
  • For example:
  • Tree A:
  • 1

/ \

2 3

Tree B:

4

|

5

Tree A is larger.

So attach B under A:

1

/|\

2 3 4

|

5

This keeps the tree relatively shallow.

9. Union by Size

Instead of tracking rank, we can track the number of nodes in each set.

Initially:

size = [1,1,1,1,1]
  • If we union:
  • 0 and 1
  • we get:
  • size[0] = 2

If we later union that group with a group of size 5:

size 2 + size 5

attach the smaller tree to the larger tree.

10. Path Compression

This is the other major optimization.

  • Suppose:
  • 5 → 4 → 3 → 2 → 1
  • We call:
  • Find(5)
  • Initially:
  • 5 → 4 → 3 → 2 → 1
  • After path compression:
  • 1

/ | \ \

2 3 4 5

Now all nodes point directly to the root.

So future Find() operations are extremely fast.

11. Path Compression Code

def find(self, x):
    if self.parent[x] != x:
        self.parent[x] = self.find(self.parent[x])
return self.parent[x]
  • This line:
  • self.parent[x] = self.find(self.parent[x])
  • is the key.

It makes every node on the path point directly to the root.

12. Optimized Union-Find

This is the version worth remembering for interviews:

class UnionFind:
    def __init__(self, n):
        self.parent = list(range(n))

self.size = [1] * n

def find(self, x):
    if self.parent[x] != x:
        self.parent[x] = self.find(self.parent[x])
return self.parent[x]
def union(self, a, b):
    root_a = self.find(a)
    root_b = self.find(b)
    if root_a == root_b:
        return False
        if self.size[root_a] < self.size[root_b]:
            root_a, root_b = root_b, root_a

self.parent[root_b] = root_a

self.size[root_a] += self.size[root_b]

return True

This uses:

Path Compression

+

Union by Size

13. Complexity

With both optimizations:

Path Compression

+

Union by Rank/Size

the amortized complexity is:

O(α(n))

  • where α(n) is the inverse Ackermann function.
  • For all practical input sizes:
  • α(n) ≈ very small constant

So Union-Find operations are effectively:

Almost O(1)

This is why DSU is so powerful.

14. Union-Find for Cycle Detection

One of the most important applications.

Consider this graph:

1 ----- 2

\ /

\ /

3

Edges:

(1,2)

(2,3)

(1,3)

Start:

{1} {2} {3}

  • Process:
  • Union(1,2)
  • Now:

{1,2} {3}

  • Process:
  • Union(2,3)
  • Now:

{1,2,3}

  • Process:
  • Union(1,3)
  • But:
  • Find(1) == Find(3)
  • They're already in the same group.

Therefore:

Adding this edge creates a cycle.

15. Cycle Detection Code

def has_cycle(n, edges):
uf = UnionFind(n)
for a, b in edges:
if not uf.union(a, b):
return True
return False

Example:

edges = [
    (0, 1),
    (1, 2),
    (0, 2)
]
print(has_cycle(3, edges))

Output:

True

16. Union-Find + Kruskal's Algorithm

This is one of the most important connections to your previous Greedy Algorithms topic.

  • Kruskal's algorithm finds a:
  • Minimum Spanning Tree (MST)
  • The basic process:
  • 1. Sort edges by weight
  • 2. Take the smallest edge
  • 3. Check whether it creates a cycle
  • 4. If not, add it
  • 5. Repeat

Union-Find performs step 3.

17. Example

Edges:

A --1-- B

A --4-- C

B --2-- C

B --5-- D

C --3-- D

  • Sort:
  • 1: A-B
  • 2: B-C
  • 3: C-D
  • 4: A-C
  • 5: B-D
  • Take:
  • A-B
  • No cycle.
  • Union(A,B)
  • Take:
  • B-C
  • No cycle.
  • Union(B,C)
  • Take:
  • C-D
  • No cycle.
  • Union(C,D)
  • We now have:

A -- B -- C -- D

MST complete.

18. Union-Find Mental Model

Think of it as:

UNION-FIND

┌─────────┴─────────┐

│ │

FIND UNION

│ │

Find group/root Merge groups

│ │

└─────────┬─────────┘

Connected?

┌─────────┴─────────┐

↓ ↓

Same Different

↓ ↓

Cycle? Merge them

19. Common Union-Find Problems

You should practice these:

Beginner

  • Number of Connected Components
  • Graph Cycle Detection
  • Redundant Connection

Intermediate

  • Accounts Merge
  • Number of Provinces
  • Most Stones Removed
  • Satisfiability of Equality Equations

Advanced

  • Kruskal's Minimum Spanning Tree
  • Dynamic Connectivity
  • Network connectivity problems

20. Number of Connected Components

Suppose:

0 -- 1 2 -- 3

4

There are:

{0,1}

{2,3}

{4}

Therefore:

3 connected components

Union-Find can maintain a count:

class UnionFind:
    def __init__(self, n):
        self.parent = list(range(n))

self.size = [1] * n

self.count = n

def find(self, x):
    if self.parent[x] != x:
        self.parent[x] = self.find(self.parent[x])
return self.parent[x]
def union(self, a, b):
    root_a = self.find(a)
    root_b = self.find(b)
    if root_a == root_b:
        return
if self.size[root_a] < self.size[root_b]:
    root_a, root_b = root_b, root_a
  • self.parent[root_b] = root_a
  • self.size[root_a] += self.size[root_b]
  • self.count -= 1
  • Every successful union reduces:
  • number of components
  • by one.

21. Union-Find vs BFS/DFS

  • You've already learned Graph Traversal.
  • Both can determine connectivity, but they are useful in different situations.
  • BFS/DFS
  • Good when you want to:
  • Traverse a graph
  • Find paths
  • Explore neighbors
  • Calculate distances
  • Perform graph traversal
  • Union-Find
  • Good when you have:
  • Repeated connectivity queries
  • Dynamic merging of groups
  • Cycle detection
  • Kruskal's algorithm
  • Think:
BFS / DFS
Explore graph
Union-Find
Manage connected components

22. Important Limitation

  • Union-Find is excellent for answering:
  • "Are these two nodes connected?"
  • But it doesn't naturally tell you:
  • "What is the shortest path between them?"
  • For shortest path, you typically use:
  • BFS
  • Dijkstra
  • Bellman-Ford
  • Floyd-Warshall

So don't think of Union-Find as a replacement for graph traversal.

23. How to Recognize Union-Find Problems

  • Look for phrases such as:
  • "Are these nodes connected?"
  • "Merge these groups."
  • "How many connected components?"
  • "Detect whether adding this edge creates a cycle."
  • "Combine accounts/users/groups."
  • "Minimum spanning tree."

These are strong signals for:

Union-Find / DSU

24. Your DSA Connections

Your topics are now connecting nicely:

GRAPHS

┌───────────┼────────────┐

│ │ │

BFS/DFS Union-Find Shortest Path

│ │ │

Traversal Components Dijkstra

Kruskal

Greedy

And:

Heap
Priority Queue
Prim / Dijkstra
Sorting
Kruskal
Union-Find

25. What You Should Memorize

For interviews, the most important Union-Find implementation is:

class UnionFind:
    def __init__(self, n):
        self.parent = list(range(n))

self.size = [1] * n

def find(self, x):
    if self.parent[x] != x:
        self.parent[x] = self.find(self.parent[x])
return self.parent[x]
def union(self, a, b):
    root_a = self.find(a)
    root_b = self.find(b)
    if root_a == root_b:
        return False
        if self.size[root_a] < self.size[root_b]:
            root_a, root_b = root_b, root_a

self.parent[root_b] = root_a

self.size[root_a] += self.size[root_b]

return True

And remember just three concepts:

  • 1. Find → Find the root/group
  • 2. Union → Merge two groups
  • 3. Optimizations:

Path Compression

+

Union by Size/Rank

Once you understand those three, Kruskal's Algorithm, cycle detection, connected components, and many graph connectivity problems become much easier.

Module 4 · Lesson 4.24

Trie

Trie

A Trie (pronounced "try") is a tree-based data structure used to efficiently store and search strings/prefixes.

It is especially useful when the problem involves:

  • Words
  • Prefixes
  • Autocomplete
  • Dictionary lookup
  • Spell checking
  • Word search
  • Prefix matching
  • The key idea is:

A Trie stores characters along paths, so words sharing a prefix share the same path.

1. Why Do We Need a Trie?

  • Suppose we have these words:
  • apple
  • app
  • apply
  • apt
  • bat
  • ball

Notice that several words share prefixes:

  • app
  • apple
  • apply
  • A Trie can share the common a → p → p path.
  • Instead of storing every word independently:
  • apple
  • app
  • apply
  • we store:
  • a

|

p

|

p

/ \

l ...

|

e

The shared prefix is stored only once.

2. Trie Structure

  • Suppose we insert:
  • cat
  • car
  • can
  • The Trie looks approximately like:
  • root

|

c

|

a

/ | \

t r n

  • Each edge represents a character.
  • So:
  • root → c → a → t
  • represents:
  • "cat"
  • and:
  • root → c → a → r
  • represents:
  • "car"

3. Trie Node

  • A Trie node usually contains:
  • children
  • is_end
  • For example:
class TrieNode:
    def __init__(self):
        self.children = {}
  • self.is_end = False
  • children stores the next characters.
  • is_end tells us:
  • Does a complete word end at this node?

4. Why is_end Is Important

  • Suppose we insert:
  • app
  • apple
  • The Trie contains:
  • a → p → p → l → e
  • But how do we know that "app" is itself a complete word?
  • We mark:
  • a

|

p

|

p ← is_end = True

|

l

|

e ← is_end = True

Therefore:

"app" → word exists

"ap" → only prefix

"apple" → word exists

5. Trie Operations

  • The three fundamental operations are:
  • Insert
  • Search
  • Starts With / Prefix Search

6. Insert

  • Suppose:
  • insert("cat")
  • Start at root:
root
c
a
t

At t:

node.is_end = True

Now "cat" is stored.

7. Python — Insert

class Trie:
    def __init__(self):
        self.root = TrieNode()
def insert(self, word):
    node = self.root
    for char in word:
        if char not in node.children:
            node.children[char] = TrieNode()

node = node.children[char]

node.is_end = True

8. Search

  • Suppose:
  • Trie contains:
  • cat
  • car
  • can
  • Search:
  • "car"
  • Follow:
root
c
a
r

At r:

is_end == True

Therefore:

"car" exists

9. Search Code

def search(self, word):
node = self.root
for char in word:
if char not in node.children:
return False
node = node.children[char]
return node.is_end

10. Prefix Search

This is where Trie becomes especially powerful.

  • Suppose:
  • Words:
  • apple
  • app
  • apply
  • apt
  • bat
  • ball
  • Search:
  • "app"
  • We don't necessarily care whether "app" is a complete word.
  • We want to know:
  • Does any word start with "app"?
  • Follow:
  • a → p → p
  • If that path exists:
  • Yes

11. starts_with()

def starts_with(self, prefix):
node = self.root
for char in prefix:
if char not in node.children:
return False
node = node.children[char]
return True

Example:

  • trie.starts_with("app")
  • returns:
  • True

12. Complete Trie Implementation

Here's the implementation worth knowing:

class TrieNode:
    def __init__(self):
        self.children = {}

self.is_end = False

class Trie:
    def __init__(self):
        self.root = TrieNode()
def insert(self, word):
    node = self.root
    for char in word:
        if char not in node.children:
            node.children[char] = TrieNode()

node = node.children[char]

node.is_end = True

def search(self, word):
node = self.root
for char in word:
if char not in node.children:
return False
node = node.children[char]
return node.is_end
def starts_with(self, prefix):
node = self.root
for char in prefix:
if char not in node.children:
return False
node = node.children[char]
return True
  • Usage:
  • trie = Trie()
  • trie.insert("apple")
  • trie.insert("app")
  • trie.insert("apply")
print(trie.search("app"))
print(trie.search("ap"))
print(trie.starts_with("app"))

Output:

  • True
  • False
  • True

13. Trie Complexity

  • Let:
  • L = length of the word
  • Insert

O(L)

Search

O(L)

Prefix Search

O(L)

This is independent of the total number of words in the Trie.

That's a major advantage.

14. Trie vs Hash Set

  • You might ask:
  • Why not simply use a Python set?
  • For exact word lookup:
words = {"cat", "car", "can"}

A set is excellent.

You can do:

"cat" in words

approximately:

O(1)

  • But a set doesn't naturally represent prefixes.
  • For example:
  • Find all words starting with "ca"
  • Trie is much better suited.

15. Trie vs Hash Table

FeatureHash TableTrie
Exact searchExcellentExcellent
Prefix searchNot naturalExcellent
InsertO(L) averageO(L)
SearchO(L) averageO(L)
AutocompleteDifficultNatural
MemoryUsually lowerCan be higher

Trie trades memory for powerful prefix operations.

16. Autocomplete

This is one of the most practical Trie applications.

  • Suppose the dictionary contains:
  • apple
  • application
  • apply
  • app
  • apt
  • banana
  • User types:
  • "app"
  • Trie follows:
  • a → p → p

Then we explore the subtree:

app

├── l

│ ├── e

│ └── y

└── ...

  • Possible suggestions:
  • app
  • apple
  • apply
  • application

This is why Tries are commonly associated with:

Autocomplete

17. How to Get All Words With a Prefix

We first locate the prefix node:

app

Then perform DFS from that node.

Conceptually:

def collect_words(node, prefix, result):
    if node.is_end:
        result.append(prefix)
for char, child in node.children.items():
    collect_words(
        child,
        prefix + char,
        result
    )

Notice the connection:

Trie
Tree
DFS / Recursion

This connects directly to the topics you've already learned.

18. Trie + DFS

Suppose:

root

/

c

|

a

/ \

t r

To retrieve all words:

DFS
Explore children
Build characters
When is_end=True
Found a word

So your previous Recursion + DFS knowledge is directly useful here.

19. Word Search Problems

  • Trie becomes extremely powerful when combined with a grid.
  • Suppose:
  • c a t
  • r e d
  • t o p
  • and a dictionary:
  • cat
  • red
  • top
  • Instead of searching every word independently, we can build a Trie and search the board using DFS.
  • Pattern:
  • Trie

+

DFS

+

Backtracking

This is a common advanced DSA combination.

20. Trie + Backtracking

Suppose you're solving:

Find all dictionary words that can be formed in a grid.

You can:

1. Build Trie

2. Start DFS from every cell

3. Follow Trie characters

4. If character doesn't exist

→ Stop immediately

5. If complete word

→ Save it

  • The Trie provides pruning.
  • Without a Trie, you may explore many impossible strings.
  • With a Trie:
Not a valid prefix
STOP

This is an excellent example of combining multiple DSA techniques.

21. Trie for Prefix Counting

  • A Trie can also store:
  • How many words pass through this node?
  • For example:
  • apple
  • app
  • apply
  • apt
  • At prefix:
  • "app"
  • there are:
  • 3 words

We can store a count:

node.prefix_count += 1

This allows efficient prefix-count queries.

22. Trie for Word Frequency

We can also store:

  • word_count
  • For example:
  • apple → 5

app → 3

This is useful when building dictionary or text-processing systems.

23. Binary Trie

A special type of Trie uses only:

0

1

This is called a:

  • Binary Trie
  • It's commonly used for bitwise problems.
  • For example:
  • Find the maximum XOR of two numbers.
  • Numbers are represented as binary:
  • 5 = 101
  • 7 = 111
  • A Binary Trie stores bits:
  • root

/ \

0 1

/ \ / \

...

This is an important advanced Trie application.

24. When Should You Think "Trie"?

  • Look for words like:
  • Prefix
  • Dictionary
  • Autocomplete
  • Starts with
  • Search words
  • Word suggestions
  • Common prefix
  • Maximum XOR
  • Many string lookups
  • These should trigger:
  • TRIE

25. Trie vs BST

  • Both are tree structures, but they're fundamentally different.
  • BST
  • Organizes values based on comparison:
  • left < root < right
  • Trie
  • Organizes strings based on characters:
root
character
character
character
  • So:
  • BST
  • → ordered values
  • Trie
  • → character/prefix structure

26. Trie Mental Model

Memorize this:

TRIE
Character-by-character

structure

┌────────────┼────────────┐

│ │ │

Insert Search Prefix

│ │ │

O(L) O(L) O(L)

Applications

┌──────────────┼──────────────┐

│ │ │

Autocomplete Dictionary Word Search

DFS + Backtracking

27. Your DSA Connection

You're now at:

Recursion
Trees
Graphs
DFS
Backtracking
👉 Trie

And Trie combines several concepts you've already learned:

Trie
├── Tree structure
├── Recursion

├── DFS

└── Backtracking

🎯 Master these Trie operations

You should be able to implement from memory:

  • 1. insert(word)
  • 2. search(word)
  • 3. starts_with(prefix)

Then practice:

  • Implement Trie
  • Word Search II
  • Longest Common Prefix
  • Replace Words
  • Design Add and Search Words
  • Maximum XOR using Binary Trie
  • The key idea to remember is:

A Trie turns strings into paths through a tree, making prefix-based operations extremely efficient.

Module 4 · Lesson 4.25

Segment Tree

Segment Tree

A Segment Tree is a tree-based data structure used to efficiently answer range queries and perform updates on an array.

This is an advanced DSA topic, but the core idea is quite simple:

Store information about ranges in a tree so that a range query can be answered without scanning every element.

  • Typical operations:
  • Range Sum
  • Range Minimum
  • Range Maximum
  • Range GCD
  • Point Update
  • Sometimes Range Update with Lazy Propagation

1. Why Do We Need a Segment Tree?

Suppose:

arr = [2, 1, 5, 3, 4]
  • We want to repeatedly ask:
  • What is the sum from index 1 to 3?
  • That's:
  • 1 + 5 + 3 = 9
  • A simple approach:
  • sum(arr[1:4])
  • takes:

O(n)

for each query in the worst case.

If we have:

1,000,000 elements

+

1,000,000 queries

this can become very expensive.

A Segment Tree can answer range queries in:

  • O(log n)
  • and point updates in:
  • O(log n)

2. The Basic Idea

Suppose:

\[2, 1, 5, 3\]

Instead of storing only individual elements, we store information about ranges.

\[0,3\]
sum = 11

/ \

\[0,1] [2,3\]
sum = 3    sum = 8

/ \ / \

\[0] [1] [2] [3\]

2 1 5 3

  • Every node represents a range.
  • For example:
  • [0,3] → entire array
  • [0,1] → first half
  • [2,3] → second half

3. Why Is It Called a Segment Tree?

Because each node represents a segment/range of the array.

For:

\[2, 1, 5, 3, 4, 7, 6, 8\]

we might have:

\[0,7\]

/ \

\[0,3] [4,7\]

/ \ / \

\[0,1] [2,3] [4,5] [6,7\]

The tree recursively divides the array into segments.

This is similar to Divide & Conquer.

4. Segment Tree and Divide & Conquer

You just learned Divide & Conquer.

Segment Tree uses the same fundamental idea:

Big range
Divide into two smaller ranges
Divide again
Single elements

So:

Segment Tree
Divide & Conquer

+

Tree

+

Range Queries

5. Building a Segment Tree

Suppose:

arr = [2, 1, 5, 3]

For sum:

\[0,3\]

11

/ \

\[0,1] [2,3\]

3 8

/ \ / \

2 1 5 3

We calculate:

\[0,3] = [0,1] + [2,3\]

11 = 3 + 8

And:

[0,1] = 2 + 1 = 3

[2,3] = 5 + 3 = 8

6. Python Implementation

Let's build a Segment Tree for range sum.

class SegmentTree:
    def __init__(self, arr):
        self.n = len(arr)

self.tree = [0] * (4 * self.n)

self.build(arr, 1, 0, self.n - 1)

def build(self, arr, node, left, right):
    if left == right:
        self.tree[node] = arr[left]
  • return
  • mid = (left + right) // 2
  • self.build(arr, node * 2, left, mid)
  • self.build(arr, node * 2 + 1, mid + 1, right)
  • self.tree[node] = (
  • self.tree[node * 2]
  • + self.tree[node * 2 + 1]

)

The important idea is:

node

├── left child = node * 2

└── right child = node * 2 + 1

7. Why 4 * n?

A Segment Tree can be stored in an array.

We allocate approximately:

tree = [0] * (4 * n)

This gives enough space for the tree in the standard recursive implementation.

For:

n = 10
  • we allocate roughly:
  • 40
  • positions.

You don't need to memorize the exact mathematical reason initially—just remember the common implementation pattern:

[0] * (4 * n)

8. Range Sum Query

Suppose:

arr = [2, 1, 5, 3, 4]
  • Query:
  • sum(1, 3)
  • We want:
  • 1 + 5 + 3 = 9

The Segment Tree doesn't necessarily visit every element.

It uses already-computed ranges.

9. Query Cases

  • When querying a node's range, there are three possibilities.
  • Case 1 — Completely Outside
  • Query: [1,3]
  • Node: [4,5]
  • No overlap.
  • Return:
  • 0
for a sum query.
  • Case 2 — Completely Inside
  • Query: [1,3]
  • Node: [1,3]
  • Perfect match.
  • Return the stored sum immediately.
  • Case 3 — Partial Overlap
  • Query: [1,3]
  • Node: [0,4]
  • We need to split the query.
\[0,2\]
\[3,4\]

and recursively investigate.

10. Query Code

def query(self, node, left, right, ql, qr):
    # Completely outside
if qr < left or right < ql:
return 0

# Completely inside

if ql <= left and right <= qr:
    return self.tree[node]
    mid = (left + right) // 2
    left_sum = self.query(
        node * 2,
        left,
        mid,
        ql,
        qr
    )
  • right_sum = self.query(
  • node * 2 + 1,
  • mid + 1,
  • right,
  • ql,
  • qr

)

return left_sum + right_sum

Call it:

tree.query(

1,

0,

len(arr) - 1,

1,

3

)

Result:

9

11. Point Update

Now suppose:

arr = [2, 1, 5, 3]
  • and we change:
  • arr[2] = 10
  • New array:
\[2, 1, 10, 3\]
  • We need to update the affected nodes.
  • Only the path from index 2 to the root needs to change.
  • Before:

[0,3] = 11

/ \

[0,1]=3 [2,3]=8

After:

[0,3] = 16

/ \

[0,1]=3 [2,3]=13

Only O(log n) nodes are updated.

12. Update Code

def update(self, node, left, right, index, value):
    if left == right:
        self.tree[node] = value

return

mid = (left + right) // 2

if index <= mid:
    self.update(
        node * 2,
        left,
        mid,
        index,
        value
    )
else:
    self.update(
        node * 2 + 1,
        mid + 1,
        right,
        index,
        value
    )
  • self.tree[node] = (
  • self.tree[node * 2]
  • + self.tree[node * 2 + 1]

)

13. Complexity

For an array of size n:

Build

O(n)

  • Range Query
  • O(log n)
  • Point Update
  • O(log n)
  • Space

O(n)

So:

OperationComplexity
BuildO(n)
QueryO(log n)
UpdateO(log n)
SpaceO(n)

14. Why Is Query O(log n)?

  • A Segment Tree has approximately:
  • log₂(n)
  • levels.

At each level, the query visits only a limited number of relevant nodes rather than scanning the entire array.

For example:

n = 1,000,000

Tree height is approximately:

log₂(1,000,000) ≈ 20

So range queries can be handled efficiently.

15. Segment Tree for Minimum

Segment Trees aren't limited to sums.

Suppose:

arr = [5, 2, 8, 1, 6]

We can store the minimum at every node:

\[0,4\]

1

/ \

\[0,2] [3,4\]

2 1

  • The combine operation changes from:
  • left_sum + right_sum
  • to:
  • min(left_min, right_min)

16. Segment Tree for Maximum

Similarly:

max(left_max, right_max)

So the same structure can support:

  • Range Sum
  • Range Minimum
  • Range Maximum
  • Range GCD
  • Range XOR
  • The key is the combine operation.

17. The General Segment Tree Pattern

Think:

Segment Tree
Divide range

┌────────┴────────┐

↓ ↓

Left range Right range

│ │

└────────┬────────┘

  • Combine results
  • For sum:
  • combine = +
  • For minimum:
  • combine = min()
  • For maximum:
  • combine = max()
  • For GCD:
  • combine = gcd()

18. Segment Tree vs Prefix Sum

This is a very important comparison.

  • Suppose you only need range sums and the array never changes.
  • Use:
  • Prefix Sum

Example:

prefix[i] = sum of elements before i

Range sum can be answered in:

O(1)

But what if values change frequently?

arr[5] = 100

Now the prefix sums after index 5 must be updated.

That could take:

O(n)

A Segment Tree handles:

Update → O(log n)

Query → O(log n)

19. Segment Tree vs Fenwick Tree

Another important DSA comparison.

A Fenwick Tree, also called a Binary Indexed Tree (BIT), can efficiently handle certain operations such as:

  • Prefix Sum
  • Point Update
  • Typically:
  • Update → O(log n)

Query → O(log n)

A Segment Tree is more flexible.

Fenwick TreeSegment Tree
Prefix sumExcellentExcellent
Range sumExcellentExcellent
Point updateO(log n)O(log n)
Range minimumLimitedExcellent
Range maximumLimitedExcellent
Complex queriesLimitedMore flexible
ImplementationSimplerMore complex

For DSA interviews, Segment Trees are generally more versatile.

20. Lazy Propagation

Now we reach the advanced part.

  • Suppose we want to perform:
  • Add 5 to every element from index 100 to 500.
  • Updating every individual element would take:

O(n)

  • But we want:
  • O(log n)
  • A Segment Tree can use:
  • Lazy Propagation

Instead of immediately updating every child, we store a pending update on a node.

Think:

Range update
Store "add 5"
Don't immediately visit every child
Push the update only when needed

This is called lazy because we delay the work.

21. Lazy Propagation Mental Model

Without lazy propagation:

Update [100,500]
Visit many nodes
Potentially O(n)

With lazy propagation:

Update [100,500]
Cover large ranges
Store pending update

O(log n) / O(log n) style range operations

With a properly implemented lazy Segment Tree, range updates and range queries can typically be handled in:

O(log n)

22. When Should You Think Segment Tree?

  • Look for questions involving:
  • "Range sum"
  • "Range minimum"
  • "Range maximum"
  • "Range query"
  • combined with:
  • "Updates"
  • Especially:
  • Many queries

+

Many updates

For example:

Given an array, support thousands of operations where each operation either updates an element or asks for the sum between indices L and R.

Think:

SEGMENT TREE

23. When NOT to Use a Segment Tree

  • Don't automatically use one.
  • If you have:
  • Static array

+

  • Only range sum queries
  • consider:
  • Prefix Sum
  • If you have:
  • Point updates

+

  • Prefix/range sums
  • consider:
  • Fenwick Tree
  • If you have:
  • Complex range queries

+

  • Updates
  • consider:
  • Segment Tree

24. Segment Tree and Your Previous Topics

This topic combines several things you've already studied.

Segment Tree

┌─────────────┼─────────────┐

↓ ↓ ↓

Recursion Divide & Conquer Trees

│ │ │

└─────────────┼─────────────┘

Range Queries

┌──────┴──────┐

↓ ↓

Query Update

↓ ↓

O(log n) O(log n)

So don't think of Segment Tree as an isolated topic.

It's essentially:

A recursive tree representation of array ranges.

25. A Small Complete Example

class SegmentTree:
    def __init__(self, arr):
        self.n = len(arr)

self.tree = [0] * (4 * self.n)

self.build(arr, 1, 0, self.n - 1)

def build(self, arr, node, left, right):
    if left == right:
        self.tree[node] = arr[left]
  • return
  • mid = (left + right) // 2
  • self.build(arr, node * 2, left, mid)
  • self.build(arr, node * 2 + 1, mid + 1, right)
  • self.tree[node] = (
  • self.tree[node * 2]
  • + self.tree[node * 2 + 1]

)

def query(self, node, left, right, ql, qr):
    if qr < left or right < ql:
        return 0
        if ql <= left and right <= qr:
            return self.tree[node]
            mid = (left + right) // 2
            return (
                self.query(
                    node * 2,
                    left,
                    mid,
                    ql,
                    qr
                )
                +
                self.query(
                    node * 2 + 1,
                    mid + 1,
                    right,
                    ql,
                    qr
                )
            )
def update(self, node, left, right, index, value):
    if left == right:
        self.tree[node] = value

return

mid = (left + right) // 2

if index <= mid:
    self.update(
        node * 2,
        left,
        mid,
        index,
        value
    )
else:
    self.update(
        node * 2 + 1,
        mid + 1,
        right,
        index,
        value
    )
  • self.tree[node] = (
  • self.tree[node * 2]
  • + self.tree[node * 2 + 1]

)

Usage:

arr = [2, 1, 5, 3, 4]
st = SegmentTree(arr)

# Range sum [1, 3]

print(
    st.query(
        1,
        0,
        len(arr) - 1,
        1,
        3
    )
)

# Update index 2

st.update(

1,

0,

len(arr) - 1,

2,

10

)

# Query again

print(
    st.query(
        1,
        0,
        len(arr) - 1,
        1,
        3
    )
)
  • First result:
  • 9
  • After changing 5 → 10:
  • 1 + 10 + 3 = 14
  • Second result:
  • 14

26. The Most Important Mental Model

Memorize this:

ARRAY
Divide into ranges

┌───────────────┐

│ Segment Tree │

└───────┬───────┘

┌────────────┴────────────┐

↓ ↓

Range Query Update

│ │

O(log n) O(log n)

│ │

└────────────┬────────────┘

Combine()

┌──────────┼──────────┐

↓ ↓ ↓

Sum Min Max

🎯 What to master

Don't try to memorize every Segment Tree variation immediately. First become comfortable with these four concepts:

  • Build the tree → O(n)
  • Range Query → O(log n)
  • Point Update → O(log n)
  • Combine operation → sum/min/max/etc.

Then learn:

Lazy Propagation → range updates

The key sentence to remember is:

A Segment Tree stores information about intervals of an array, allowing range queries and updates to be performed in logarithmic time.

Module 4 · Lesson 4.26

Binary Search

  • Binary Search
  • Binary Search is an efficient searching algorithm that repeatedly divides the search space in half.
  • The key idea is:

Look at the middle element. Eliminate half of the remaining possibilities. Repeat.

For a sorted array, this reduces the search from O(n) to O(log n).

1. Why Binary Search?

Suppose we have:

\[10, 20, 30, 40, 50, 60, 70, 80, 90\]
  • We want to find:
  • 70
  • Linear Search
  • Check one by one:
  • 10 → 20 → 30 → 40 → 50 → 60 → 70
  • Potentially:

O(n)

  • Binary Search
  • Check middle:
  • 50

70 is greater than 50, so eliminate everything on the left:

\[60, 70, 80, 90\]
  • Middle:
  • 70
  • Found!
  • Only a couple of comparisons.
  • O(log n)

2. The Most Important Requirement

  • Classic Binary Search requires:
  • The search space must have a monotonic/ordered property.
  • For a normal sorted-array search:
\[10, 20, 30, 40, 50, 60, 70\]

The values are ordered.

Then we can safely eliminate half the search space.

Without an ordering/property that lets us discard half, binary search doesn't work.

3. Basic Binary Search

Suppose:

arr = [10, 20, 30, 40, 50, 60, 70]
target = 60
  • Maintain:
  • left
  • right
  • Initially:
left = 0
right = 6
  • Calculate:
  • mid = (left + right) // 2
  • So:
mid = 3
  • arr[mid] = 40
  • Since:
  • 60 > 40
  • move:
left = mid + 1

Now:

\[50, 60, 70\]
  • Middle:
  • 60
  • Found.

4. Python Implementation

def binary_search(arr, target):
left = 0
right = len(arr) - 1
while left <= right:
mid = left + (right - left) // 2
if arr[mid] == target:
return mid
elif arr[mid] < target:
left = mid + 1
else:
right = mid - 1
return -1

Example:

arr = [10, 20, 30, 40, 50, 60, 70]
print(binary_search(arr, 60))

Output:

5

5. Why left + (right-left)//2?

You will often see:

mid = (left + right) // 2

This is mathematically fine in Python.

In languages with fixed-width integers such as Java/C++:

(left + right)

can theoretically overflow.

Therefore the safer general pattern is:

mid = left + (right - left) // 2

For interviews, I recommend using this form.

6. How the Search Space Shrinks

  • Suppose there are:
  • 16 elements
  • After one comparison:
  • 8
  • Then:
  • 4
  • Then:
  • 2
  • Then:
  • 1
  • So:
  • 16 → 8 → 4 → 2 → 1
  • That's why the complexity is:
  • O(log₂ n)

7. Complexity

OperationComplexity
Best caseO(1)
Average/WorstO(log n)
Space — iterativeO(1)

If you implement Binary Search recursively, the auxiliary stack is:

O(log n)

8. Binary Search Template

Memorize this basic structure:

left = 0
right = len(arr) - 1
while left <= right:
mid = left + (right - left) // 2
if condition(mid):
return mid
elif need_right:
left = mid + 1
else:
right = mid - 1

The real skill is figuring out:

What condition allows me to eliminate half the search space?

9. Binary Search With Duplicates

Suppose:

arr = [1, 2, 2, 2, 3, 4]

Find 2.

Basic Binary Search may return any occurrence:

  • index 1
  • index 2
  • or index 3
  • But sometimes interview questions ask:
  • Find the first occurrence of 2.
  • Or:
  • Find the last occurrence of 2.

These are important Binary Search variations.

10. First Occurrence

For:

\[1, 2, 2, 2, 3, 4\]

we want:

index = 1
  • When we find 2, don't stop.
  • Instead:
  • answer = mid
right = mid - 1
  • Why?
  • Because there might be another 2 on the left.
  • Python
def first_occurrence(arr, target):
left = 0
right = len(arr) - 1
answer = -1
while left <= right:
mid = left + (right - left) // 2
if arr[mid] == target:
answer = mid
right = mid - 1
elif arr[mid] < target:
left = mid + 1
else:
right = mid - 1
return answer

11. Last Occurrence

Same idea, but when we find the target:

answer = mid

left = mid + 1

because we want to see whether another occurrence exists on the right.

def last_occurrence(arr, target):
left = 0
right = len(arr) - 1
answer = -1
while left <= right:
mid = left + (right - left) // 2
if arr[mid] == target:
answer = mid
left = mid + 1
elif arr[mid] < target:
left = mid + 1
else:
right = mid - 1
return answer

12. Lower Bound

This is a very important Binary Search concept.

Lower bound means:

Find the first position where arr[i] >= target.

Example:

arr = [1, 2, 4, 4, 7, 9]
target = 4

Lower bound:

index = 2

because:

  • arr[2] = 4
  • and it's the first value ≥ 4.
  • If:
target = 5

lower bound is:

index = 4
  • because:
  • arr[4] = 7
  • is the first value ≥ 5.

13. Upper Bound

  • Upper bound means:
  • Find the first position where arr[i] > target.
  • For:
\[1, 2, 4, 4, 7, 9\]

and:

target = 4

upper bound:

index = 4

because:

7 > 4

14. Python's Built-in Binary Search

Python provides:

import bisect
  • Then:
  • bisect.bisect_left(arr, target)
  • gives lower bound.
  • And:
  • bisect.bisect_right(arr, target)
  • gives upper bound.

Example:

import bisect
arr = [1, 2, 4, 4, 7, 9]
print(bisect.bisect_left(arr, 4))
print(bisect.bisect_right(arr, 4))

Output:

2

4

Knowing this is useful in Python interviews, although you should still understand the manual implementation.

15. Binary Search on the Answer

This is one of the most important advanced Binary Search patterns.

  • Sometimes the answer isn't directly present in an array.
  • Instead, we're searching for:
  • minimum possible value
  • or:
  • maximum possible value

We can binary search over the answer space.

16. Example — Minimum Eating Speed

Suppose there are banana piles:

\[3, 6, 7, 11\]

Koko has:

  • 8 hours
  • Find the minimum eating speed k such that all bananas can be eaten.
  • Possible speeds:
  • 1
  • 2
  • 3

...

11

We can test:

  • Can speed = k finish within 8 hours?
  • The answer has a monotonic property:
  • small speed → impossible
  • large speed → possible
  • So:
  • False False False False True True True

This is perfect for Binary Search.

17. Binary Search on Answer

The pattern is:

Search space

\[minimum, maximum\]

mid

Can mid work?

/ \

YES NO

↓ ↓

Try smaller Need larger

This is sometimes called:

Binary Search on the Answer

18. The Key Recognition Pattern

  • Look for:
  • Minimum possible X
  • Maximum possible X
  • Smallest capacity
  • Minimum speed
  • Maximum distance
  • Minimum time

Then ask:

If I can achieve X, can I also achieve every larger/smaller value?

If yes, you may have a binary-search-on-answer problem.

19. Example — Ship Packages Within D Days

Suppose:

weights = [1,2,3,4,5,6,7,8,9,10]
days = 5
  • We want:
  • Minimum ship capacity required to ship everything within 5 days.
  • Possible capacity:
  • 10 → 55

For each capacity, check whether shipping is possible.

Example:

capacity = 15
  • Maybe:
  • Day 1: 1+2+3+4+5 = 15
  • Day 2: 6+7 = 13
  • Day 3: 8
  • Day 4: 9
  • Day 5: 10
  • Possible.
  • We then ask:
  • Can we reduce capacity?

Binary Search finds the minimum valid capacity.

20. Rotated Sorted Array

Another extremely common interview problem.

Normally:

\[1, 2, 3, 4, 5, 6, 7\]

But suppose it was rotated:

\[4, 5, 6, 7, 1, 2, 3\]
  • It is no longer globally sorted.
  • But it contains sorted portions.
  • Binary Search can still be used.
  • At every step, determine:
  • Which half is sorted?

Then determine whether the target lies in that half.

21. Example

arr = [4, 5, 6, 7, 1, 2, 3]
target = 2
  • Middle:
  • 7
  • Left side:
\[4,5,6,7\]

is sorted.

Target 2 isn't in that range.

Therefore discard it:

\[1,2,3\]

Continue Binary Search.

Answer:

index = 5

22. Peak Element

Another Binary Search variation.

Given:

\[1, 2, 3, 1\]

A peak is:

  • 3
  • We don't necessarily need the array to be sorted.
  • Instead, we use a monotonic property.
  • Compare:
  • arr[mid]
  • with:
  • arr[mid + 1]
  • If:
  • arr[mid] < arr[mid + 1]

there must be a peak somewhere on the right.

Otherwise, there is a peak on the left/current side.

This is a powerful lesson:

Binary Search does not always require a sorted array. It requires a property that lets you safely eliminate half the search space.

23. Binary Search on a Monotonic Predicate

This is the generalized view of Binary Search.

  • Imagine a condition:
  • X
  • For possible answers:

1 2 3 4 5 6 7 8

F F F F T T T T

  • We want the first True.
  • Binary Search finds it.
  • This pattern appears everywhere:
  • Impossible → Possible

False → True

or:

Possible → Impossible

True → False

24. Binary Search Mental Model

Think of Binary Search as:

SEARCH SPACE
middle

Can eliminate half?

/ \

YES NO

↓ ↓

Wrong logic Binary search

  • doesn't apply
  • The key question isn't:
  • "Is the array sorted?"
  • The deeper question is:
  • Can I eliminate half of the search space based on a reliable condition?

25. Common Binary Search Problems

  • Basic
  • Binary Search
  • Search Insert Position
  • First and Last Position
  • Lower Bound / Upper Bound

Intermediate

  • Search in Rotated Sorted Array
  • Find Minimum in Rotated Sorted Array
  • Find Peak Element
  • Single Element in a Sorted Array
  • Binary Search on Answer
  • Koko Eating Bananas
  • Capacity to Ship Packages Within D Days
  • Split Array Largest Sum
  • Minimum Days to Make Bouquets
  • Aggressive Cows
  • Allocate Books

26. Binary Search vs Two Pointers

  • You've just studied Two Pointers.
  • They're different.
  • Two Pointers
  • Usually:
  • left →
  • ← right
  • Both pointers move through the data.
  • Binary Search
  • Usually:

left ───────── right

mid

Then we eliminate half:

left ── mid ← discard

  • or:
  • discard → mid ── right
  • So:
  • Two Pointers → Move pointers strategically
  • Binary Search → Eliminate half the search space

27. Binary Search vs Sliding Window

Sliding Window

Maintains a contiguous region:

\[left ........ right\]
  • and adjusts it.
  • Binary Search
  • Maintains a search interval:
\[left ........ right\]

but discards half of it.

So the same words left, right, and mid appear, but the logic is different.

28. Binary Search Template You Should Memorize

Exact Search

def binary_search(arr, target):
left = 0
right = len(arr) - 1
while left <= right:
mid = left + (right - left) // 2
if arr[mid] == target:
return mid
if arr[mid] < target:
left = mid + 1
else:
right = mid - 1
return -1

First Valid / Minimum Answer

The other template to learn is:

left = low
right = high
while left < right:
mid = left + (right - left) // 2
if is_valid(mid):
right = mid
else:
left = mid + 1
return left

This second template is extremely important for interview problems.

29. How to Recognize Binary Search

When you see:

Sorted array
Binary Search
  • But also look for:
  • Minimum possible
  • Maximum possible
  • Smallest value satisfying condition
  • Largest value satisfying condition
  • Can we do it within X?
  • Is X feasible?

Then think:

  • Binary Search on Answer
  • And always ask:
  • Can I eliminate half the search space?

30. Your DSA Progression

Your topics are now connecting like this:

DSA

┌────────────┼────────────┐

↓ ↓ ↓

Sorting Searching Trees

│ │ │

↓ ↓ ↓

Sorted Binary Segment

Array Search Tree

Two Pointers

And the advanced connection:

Binary Search
├── Sorted Array
├── First/Last Position
├── Rotated Array
├── Peak Finding
└── Binary Search on Answer
├── Koko Bananas
  • ├── Shipping Capacity
  • ├── Aggressive Cows
  • └── Book Allocation
  • 🎯 The three Binary Search patterns you should master

1. Exact search

Find target

2. Boundary search

  • First occurrence
  • Last occurrence
  • Lower bound
  • Upper bound

3. Answer-space search

Minimum X such that condition(X) is true

If you master those three patterns, Binary Search becomes much more than "searching a sorted array"—it becomes a general technique for reducing a monotonic search space from O(n) to O(log n).

Module 4 · Lesson 4.27

Graph Problems

Graph Problems

Graph Problems is not one single algorithm. It is a collection of problems where we model objects as vertices (nodes) and relationships as edges.

  • You already learned:
  • Graphs
  • BFS
  • DFS
  • Union-Find
  • Heaps
  • Greedy
  • Dynamic Programming

Now the important part is learning which technique to choose for which graph problem.

1. Graph Representation

Suppose we have:

A ----- B

| |

| |

C ----- D

  • Vertices:
  • A, B, C, D
  • Edges:

(A,B)

(A,C)

(B,D)

(C,D)

There are two common representations.

Adjacency List

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

Usually preferred for sparse graphs.

Space:

O(V + E)

Adjacency Matrix

A B C D

A 0 1 1 0

B 1 0 0 1

C 1 0 0 1

D 0 1 1 0

Space:

O(V²)

Useful when you need very fast edge-existence checks.

2. The First Question: What Kind of Graph Problem?

  • When you see a graph problem, don't immediately start coding.
  • First ask:
  • What am I trying to find?
ProblemTypical Algorithm
Visit all nodesBFS / DFS
Connected componentsBFS / DFS / Union-Find
Shortest path, unweightedBFS
Shortest path, positive weightsDijkstra
Negative edge weightsBellman-Ford
All-pairs shortest pathFloyd-Warshall
Detect cycleDFS / BFS / Union-Find
Topological orderingDFS / Kahn's BFS
Minimum Spanning TreeKruskal / Prim
Connectivity after unionsUnion-Find
Strongly connected componentsTarjan / Kosaraju
Bridges / articulation pointsTarjan
DAG optimizationDP / Topological Order

This table is worth knowing.

3. Connected Components

Suppose:

A -- B C -- D

E

There are:

{A,B}

{C,D}

{E}

Therefore:

3 connected components

We can solve this using DFS:

def count_components(graph):
    visited = set()
    count = 0
    def dfs(node):
        visited.add(node)
for neighbor in graph[node]:
    if neighbor not in visited:
        dfs(neighbor)
for node in graph:
    if node not in visited:
        dfs(node)

count += 1

return count

Complexity:

O(V + E)

4. BFS Shortest Path

For an unweighted graph, BFS finds the shortest path measured by number of edges.

Example:

A -- B -- C

| |

D --------

  • From:
  • A → C
  • BFS explores level by level:
  • Level 0:
  • A
  • Level 1:
  • B, D
  • Level 2:
  • C
  • Shortest distance:
  • 2

So remember:

Unweighted shortest path → BFS

5. BFS Implementation

from collections import deque
def bfs_shortest_path(graph, start, target):
    queue = deque([(start, 0)])
    visited = {start}
    while queue:
        node, distance = queue.popleft()
if node == target:
    return distance
    for neighbor in graph[node]:
        if neighbor not in visited:
            visited.add(neighbor)

queue.append((neighbor, distance + 1))

return -1

Complexity:

Time = O(V + E)

Space = O(V)

6. DFS Problems

  • DFS is useful when you need to explore an entire structure.
  • Typical examples:
  • Connected components
  • Cycle detection
  • Path existence
  • Island problems
  • Backtracking through graphs
  • Topological sorting
  • Strongly connected components
  • Basic DFS:
def dfs(graph, node, visited):
    visited.add(node)
for neighbor in graph[node]:
    if neighbor not in visited:
        dfs(graph, neighbor, visited)

7. Number of Islands

This is one of the most famous graph interview problems.

  • Given:
  • 1 1 0 0
  • 1 0 0 1
  • 0 0 1 1
  • 1 0 0 0
  • 1 represents land.
  • We need to count islands.
  • Each group of connected 1s is an island.
  • Island 1:
  • 1 1
  • 1
  • Island 2:
  • 1
  • 1
  • Island 3:
  • 1
  • Island 4:
  • 1

Answer:

4

The standard approach:

Find unvisited land
DFS/BFS
Mark entire island visited
Count +1

This is essentially:

Graph Traversal

+

Connected Components

8. Cycle Detection

Graph cycle detection depends on the graph type.

Undirected Graph

You can use:

  • DFS
  • or:
  • Union-Find

Example:

A ---- B

\ /

C

When exploring C, if we find an already visited node that isn't the parent, we have a cycle.

9. Undirected Cycle Detection With DFS

def has_cycle(graph):
    visited = set()
    def dfs(node, parent):
        visited.add(node)
for neighbor in graph[node]:
if neighbor not in visited:
if dfs(neighbor, node):
return True
elif neighbor != parent:
return True
return False
for node in graph:
if node not in visited:
if dfs(node, None):
return True
return False

10. Directed Graph Cycle Detection

  • Directed graphs require different logic.
  • Consider:
  • A → B → C

↑ |

└───┘

There's a directed cycle:

B → C → B

A useful DFS technique uses three states:

  • 0 = unvisited
  • 1 = currently visiting
  • 2 = completely processed

If during DFS we encounter a node with state:

1

we found a cycle.

11. Topological Sorting

Topological sorting applies to a:

Directed Acyclic Graph (DAG)

Example:

Learn Python
Data Structures
Algorithms
Machine Learning
  • A valid order is:
  • Python
  • Data Structures
  • Algorithms
  • Machine Learning
  • Topological sorting is useful for:
  • Course prerequisites
  • Build systems
  • Task scheduling
  • Dependency resolution

12. Kahn's Algorithm

  • Kahn's algorithm uses:
  • BFS + Indegree
  • Suppose:
  • A → C
  • B → C
  • C → D
  • Indegree:
A = 0
B = 0
C = 2
D = 1

Start with nodes having:

indegree = 0

So:

  • A, B
  • Process them and reduce the indegree of their neighbors.
  • Eventually:
  • A → B → C → D
  • or another valid ordering.

13. Topological Sort — Python

from collections import deque
def topological_sort(graph, indegree):
    queue = deque()
    for node in indegree:
        if indegree[node] == 0:
            queue.append(node)
result = []
while queue:
node = queue.popleft()

result.append(node)

for neighbor in graph[node]:
    indegree[neighbor] -= 1
if indegree[neighbor] == 0:
    queue.append(neighbor)
if len(result) != len(graph):
return []  # Cycle exists
return result

Complexity:

O(V + E)

14. Dijkstra's Algorithm

Now suppose edges have weights:

A --4-- B

| |

2 1

| |

C --5-- D

We want the shortest weighted path.

If all edge weights are:

>= 0

use:

Dijkstra

Dijkstra uses a priority queue / min heap.

This connects directly to your previous topics:

Heap
Priority Queue
Dijkstra

15. Dijkstra's Mental Model

  • Start:
  • distance[A] = 0
  • All others:

Then:

Choose node with smallest known distance
Relax its edges
Update neighbors
Put updated distances into priority queue
Repeat
  • "Relax" means:
  • new_distance =
  • current_distance + edge_weight

If this is smaller than the current known distance, update it.

16. Dijkstra Code

import heapq
def dijkstra(graph, start):
    distances = {
        node: float("inf")
        for node in graph
    }

distances[start] = 0

heap = [(0, start)]
while heap:
    distance, node = heapq.heappop(heap)
if distance > distances[node]:
    continue
for neighbor, weight in graph[node]:
    new_distance = distance + weight
    if new_distance < distances[neighbor]:
        distances[neighbor] = new_distance
  • heapq.heappush(
  • heap,
  • (new_distance, neighbor)

)

return distances

Typical complexity with a binary heap:

O((V + E) log V)

17. Dijkstra Limitation

Dijkstra does not work correctly with negative edge weights in general.

Example:

  • A → B = 5
  • A → C = 2
  • C → B = -10
  • The negative edge can invalidate Dijkstra's greedy assumption.

For graphs with negative edge weights, consider:

Bellman-Ford

18. Bellman-Ford

  • Bellman-Ford can handle:
  • Negative edge weights
  • and can also detect:
  • Negative cycles
  • Basic idea:
  • Relax every edge
  • V - 1 times
  • Complexity:
  • O(VE)

It's slower than Dijkstra, but more general.

19. Floyd-Warshall

  • What if you want:
  • Shortest paths between every pair of vertices?
  • Use:
  • Floyd-Warshall
  • It uses Dynamic Programming.
  • State:

dp[i][j]

  • means shortest distance from:
  • i → j
  • The key recurrence:

dp[i][j] =

min(

dp[i][j],

dp[i][k] + dp[k][j]

)

  • Complexity:
  • O(V³)
  • So:
  • Single source → Dijkstra / Bellman-Ford

All pairs → Floyd-Warshall

20. Minimum Spanning Tree

A Minimum Spanning Tree (MST) connects all vertices with:

  • No cycles
  • Minimum possible total edge weight
  • Two famous algorithms:
  • Kruskal
  • Prim

21. Kruskal

Kruskal:

Sort edges by weight
Take smallest edge

Would it create a cycle?

/ \

No Yes

↓ ↓

Add it Skip

It uses:

Greedy

+

Sorting

+

Union-Find

That's a very important connection.

22. Prim

Prim starts from a vertex and grows the MST.

  • At each step:
  • Choose the cheapest edge connecting the current tree to a new vertex.
  • Typically:
Prim
Min Heap
Greedy

So:

Kruskal → Union-Find

Prim → Heap

23. Bipartite Graph

A graph is bipartite if its vertices can be divided into two groups such that no edge connects vertices within the same group.

Example:

Group A Group B

A -------- 1

B -------- 2

C -------- 3

We can check this using:

  • BFS
  • or:
  • DFS
  • with two colors.

24. Bipartite Check

  • Assign:
  • Color 0
  • to a starting node.
  • All its neighbors get:
  • Color 1
  • Their neighbors get:
  • Color 0
  • and so on.
  • If we ever find:
  • neighbor has same color
  • then:
  • Not bipartite

25. Bipartite Code

from collections import deque
def is_bipartite(graph):
    color = {}
    for start in graph:
        if start in color:
            continue

color[start] = 0

queue = deque([start])

while queue:
    node = queue.popleft()
    for neighbor in graph[node]:
        if neighbor not in color:
            color[neighbor] = 1 - color[node]

queue.append(neighbor)

elif color[neighbor] == color[node]:
return False
return True

Complexity:

O(V + E)

26. Strongly Connected Components

For directed graphs, a Strongly Connected Component (SCC) is a group where every node can reach every other node.

Example:

A → B

↑ ↓

  • D ← C
  • All four nodes can reach each other.
  • Two famous algorithms:
  • Kosaraju
  • Tarjan

You don't necessarily need these first when learning graph problems, but they are important advanced topics.

27. Bridges

A bridge is an edge whose removal increases the number of connected components.

Example:

A -- B -- C

The edge:

B -- C

is a bridge.

Remove it:

A -- B C

  • Graph becomes disconnected.
  • Bridge-finding is commonly solved using:
  • Tarjan's algorithm / DFS low-link values

28. Articulation Point

An articulation point is a vertex whose removal disconnects the graph.

Example:

A -- B -- C

|

D

Remove B:

A C

  • D
  • The graph becomes disconnected.
  • So:
  • B = articulation point

Again, DFS + low-link concepts are commonly used.

29. Graph Problem Decision Tree

This is probably the most useful thing to remember from this topic:

GRAPH PROBLEM

┌────────────┼─────────────┐

│ │ │

Connectivity Shortest Path Structure

│ │ │

┌────┴────┐ ┌───┴────┐ ┌───┴────┐

↓ ↓ ↓ ↓ ↓ ↓

BFS/DFS DSU BFS Weighted DAG MST

│ │

│ Dijkstra

│ │

│ Negative weights

│ ↓

│ Bellman-Ford

└── All pairs
Floyd-Warshall

30. Graph Algorithm Cheat Sheet

ProblemAlgorithm
Traverse graphDFS / BFS
Connected componentsDFS / BFS / Union-Find
Unweighted shortest pathBFS
Weighted shortest path, non-negativeDijkstra
Negative edgesBellman-Ford
All-pairs shortest pathFloyd-Warshall
Detect undirected cycleDFS / Union-Find
Detect directed cycleDFS / Kahn
Topological orderingDFS / Kahn
Minimum spanning treeKruskal / Prim
Dynamic connectivityUnion-Find
Bipartite checkBFS / DFS
Strongly connected componentsTarjan / Kosaraju
BridgesTarjan
Articulation pointsTarjan

31. The Most Important Graph Patterns

For interviews, I recommend learning graph problems in this order:

Level 1 — Traversal

  • 1. BFS
  • 2. DFS
  • 3. Connected Components
  • 4. Number of Islands

Level 2 — Basic Properties

  • 5. Cycle Detection
  • 6. Bipartite Graph
  • 7. Flood Fill

Level 3 — Ordering

  • 8. Topological Sort
  • 9. Course Schedule
  • 10. Course Schedule II

Level 4 — Shortest Path

  • 11. BFS shortest path
  • 12. Dijkstra
  • 13. Bellman-Ford
  • 14. Floyd-Warshall

Level 5 — MST

15. Kruskal

16. Prim

Level 6 — Advanced

  • 17. Union-Find
  • 18. SCC
  • 19. Bridges
  • 20. Articulation Points
  • 32. The Big Picture

Your entire Graph section can be visualized like this:

GRAPH

┌──────────────────┼──────────────────┐

│ │ │

Traversal Shortest Path MST

│ │ │

BFS / DFS ┌──────┼──────┐ ┌───┴───┐

│ │ │ │ │ │

Components BFS Dijkstra Bellman Kruskal Prim

Islands │ Ford

Cycle │

Floyd-Warshall
All-Pairs Paths

And the supporting data structures:

Heap
Dijkstra / Prim
Queue
BFS / Kahn
Stack / Recursion
DFS
Union-Find
Kruskal / Connectivity
Hash Set / Map
Visited / Distances / Indegree

🎯 What you should be able to recognize

When you see a graph problem, first classify it:

"Visit/explore" → BFS / DFS

"Connected?" → BFS / DFS / Union-Find

"Shortest, no weights" → BFS

"Shortest, positive weight" → Dijkstra

"Negative weights" → Bellman-Ford

"All pairs shortest path" → Floyd-Warshall

"Dependencies/prerequisite"→ Topological Sort

"Minimum total connection" → MST

"Can split into 2 groups?" → Bipartite

"Cycle?" → DFS / Union-Find

"Repeatedly merge groups" → Union-Find

The biggest skill in Graph Problems is not memorizing algorithms—it is recognizing the graph pattern from the wording of the problem. Once you identify the pattern, the algorithm choice becomes much easier.

Module 4 · Lesson 4.28

DP Problems

DP Problems — Dynamic Programming

Since you've already covered Dynamic Programming as a concept, this section is about the actual problem patterns you'll encounter in coding interviews.

The most important thing to understand is:

DP is usually about finding a smaller version of the same problem, storing its answer, and reusing it.

1. The Core DP Idea

Consider:

F(n) = F(n-1) + F(n-2)

For Fibonacci:

F(0) = 0

F(1) = 1

A naive recursive solution:

def fib(n):
if n <= 1:
return n
return fib(n - 1) + fib(n - 2)
  • has repeated work.
  • For example:
  • fib(5)

├── fib(4)

│ ├── fib(3)

│ └── fib(2)

└── fib(3)

├── fib(2)

└── fib(1)

  • fib(3) and fib(2) are calculated multiple times.
  • DP says:
  • Calculate each state once and remember it.

2. The Two Main DP Approaches

Top-Down — Memoization

Start with recursion and cache results.

def fib(n, memo={}):
if n <= 1:
return n
if n in memo:
return memo[n]

memo[n] = fib(n - 1, memo) + fib(n - 2, memo)

return memo[n]

This is:

Recursion

+

Memoization

=

Top-Down DP

3. Bottom-Up — Tabulation

Start from the smallest cases.

def fib(n):
if n <= 1:
return n
dp = [0] * (n + 1)

dp[1] = 1

for i in range(2, n + 1):
    dp[i] = dp[i - 1] + dp[i - 2]
return dp[n]

This is:

Small problems
Build bigger problems
Final answer

4. The 5-Step DP Framework

For almost every DP problem, ask these questions:

Step 1 — What does dp[i] mean?

Example:

  • dp[i] = number of ways to reach step i
  • Step 2 — What is the recurrence?
  • For stairs:

dp[i] = dp[i-1] + dp[i-2]

  • Step 3 — What are the base cases?
  • dp[0] = 1
  • dp[1] = 1
  • Step 4 — What order do we calculate states?
  • Usually:
  • small → large
  • Step 5 — Can we reduce memory?
  • Maybe we only need:
  • previous
  • previous_previous
  • instead of the entire array.

5. DP Problem Families

Most interview DP problems fall into recognizable patterns.

DP

┌──────────────┼──────────────┐

│ │ │

1D DP Grid DP Knapsack

│ │ │

Stairs Paths 0/1 Knapsack

House Robber Obstacles Unbounded

Climbing Minimum Path Subset Sum

├──────────────┐

↓ ↓

String DP Sequence DP

│ │

LCS LIS

Edit Distance LCS

Palindrome Subsequence

Then advanced:

  • Interval DP
  • Tree DP
  • Bitmask DP
  • Digit DP
  • DP on DAGs
  • State Machine DP

6. 1D DP — Climbing Stairs

You have:

n stairs

You can climb:

  • 1 step
  • or
  • 2 steps
  • For:
n = 4
  • ways include:
  • 1+1+1+1
  • 1+1+2
  • 1+2+1
  • 2+1+1
  • 2+2

Answer:

  • 5
  • Define:
  • dp[i] = number of ways to reach stair i
  • To reach stair i:
from i-1

or

from i-2

Therefore:

dp[i] = dp[i-1] + dp[i-2]

7. Space Optimization

  • We don't actually need the entire DP array.
  • We only need:
  • dp[i-1]
  • dp[i-2]
  • So:
def climb_stairs(n):
if n <= 2:
return n
prev2 = 1
prev1 = 2
for i in range(3, n + 1):
current = prev1 + prev2
prev2 = prev1
prev1 = current
return prev1

Complexity:

Time = O(n)

Space = O(1)

8. House Robber

This is a classic interview DP problem.

Houses contain:

\[2, 7, 9, 3, 1\]

You cannot rob adjacent houses.

  • What is the maximum amount?
  • The answer is:
  • 12
  • by robbing:
  • 2 + 9 + 1
  • DP State
  • Define:
  • dp[i] = maximum money we can rob from first i houses

At each house we have two choices:

Don't rob current house

dp[i-1]

Rob current house
current + dp[i-2]

Therefore:

dp[i] = max(

dp[i-1],

nums[i] + dp[i-2]

)

This pattern is extremely important:

Take or Skip

9. Take or Skip Pattern

Many DP problems have:

Current item

┌────┴────┐

↓ ↓

Take Skip

│ │

↓ ↓

state 1 state 2

  • Examples:
  • House Robber
  • 0/1 Knapsack
  • Subset Sum
  • Maximum subsequence
  • Many scheduling problems
  • When you see:
  • "Choose or don't choose"
  • think:
  • DP

10. Grid DP

Suppose:

1 1 1

1 1 1

1 1 1

You start at top-left and want to reach bottom-right.

You can move:

  • Right
  • Down
  • Define:

dp[i][j]

as:

Number of ways to reach cell (i,j).

You can arrive from:

  • top
  • or
  • left

Therefore:

dp[i][j] =

dp[i-1][j] + dp[i][j-1]

11. Unique Paths

For a 3 × 3 grid:

S . .

. . .

. . E

  • Number of paths:
  • 6
  • Implementation:
def unique_paths(m, n):
    dp = [[0] * n for _ in range(m)]
    for i in range(m):
        dp[i][0] = 1
for j in range(n):
    dp[0][j] = 1
for i in range(1, m):
    for j in range(1, n):
        dp[i][j] = (
            dp[i - 1][j]
            + dp[i][j - 1]
        )
return dp[m - 1][n - 1]

Complexity:

Time = O(mn)

Space = O(mn)

12. Minimum Path Sum

  • Suppose:
  • 1 3 1
  • 1 5 1
  • 4 2 1

Find the minimum cost from top-left to bottom-right.

Define:

dp[i][j]

as the minimum cost to reach (i,j).

Again:

from top

or

from left

So:

dp[i][j] =

grid[i][j] +

min(

dp[i-1][j],

dp[i][j-1]

)

This is another classic Grid DP pattern.

13. 0/1 Knapsack

This is one of the most important DP problems.

Suppose we have items:

Weight Value

2 3

3 4

4 5

5 6

  • Bag capacity:
  • 7
  • Each item can be selected:
  • 0 times
  • or
  • 1 time
  • Hence:
  • 0/1 Knapsack

14. Knapsack State

Define:

dp[i][w]

  • as:
  • Maximum value using the first i items with capacity w.
  • For each item:
  • Don't take it
  • or:
  • Take it

Therefore:

dp[i][w] = max(

dp[i-1][w],

value[i] +

dp[i-1][w-weight[i]]

)

provided the item fits.

15. Why Is This DP?

  • Because the same smaller problems occur repeatedly.
  • The problem becomes:
  • Items 1..i
  • Capacity w
  • which depends on:
  • Items 1..i-1
  • Capacity w
  • That's the optimal-substructure property.

16. Unbounded Knapsack

  • Difference:
  • 0/1 Knapsack
  • Each item:
  • 0 or 1 time
  • Unbounded Knapsack
  • Each item can be used:
  • unlimited times
  • This distinction appears in:
  • Coin Change
  • Rod Cutting
  • Unbounded Knapsack

17. Coin Change

Coins:

\[1, 2, 5\]
  • Target:
  • 11
  • Minimum number of coins?

Answer:

  • 3
  • because:
  • 5 + 5 + 1
  • Define:
  • dp[x] = minimum coins needed to make amount x
  • Transition:

dp[x] =

min(

dp[x-coin] + 1

)

for every available coin.

18. Coin Change Code

def coin_change(coins, amount):
dp = [float("inf")] * (amount + 1)

dp[0] = 0

for x in range(1, amount + 1):
    for coin in coins:
        if coin <= x:
            dp[x] = min(
                dp[x],
                dp[x - coin] + 1
            )
return -1 if dp[amount] == float("inf") else dp[amount]

Complexity:

O(amount × number_of_coins)

19. Subset Sum

Given:

\[2, 3, 7, 8, 10\]

Can we choose some numbers whose sum is:

  • 11
  • Yes:
  • 3 + 8 = 11
  • The state is commonly:
  • dp[i][sum]
  • meaning:
  • Can we make sum using the first i elements?
  • Again:
  • Take
  • or
  • Skip

This is closely related to Knapsack.

20. String DP

Now we move into one of the most important interview areas.

  • Common problems:
  • Longest Common Subsequence
  • Edit Distance
  • Longest Palindromic Subsequence
  • Distinct Subsequences
  • Word Break

21. Longest Common Subsequence — LCS

Given:

A = "abcde"
B = "ace"
  • The longest common subsequence is:
  • "ace"
  • Length:
  • 3
  • Define:

dp[i][j]

  • as:
  • LCS length between the first i characters of A and first j characters of B.
  • If:

A[i-1] == B[j-1]

then:

dp[i][j] =

dp[i-1][j-1] + 1

Otherwise:

dp[i][j] =

max(

dp[i-1][j],

dp[i][j-1]

)

22. Why LCS Is Important

LCS teaches a very common DP pattern:

Two sequences
Compare current elements

Same?

┌────┴────┐

YES NO

↓ ↓

Diagonal max(left, top)

Once you understand LCS, many string DP problems become easier.

23. Edit Distance

  • Given:
  • "horse"
  • and:
  • "ros"
  • Find the minimum number of operations to transform one into the other.
  • Allowed:
  • Insert
  • Delete
  • Replace
  • Define:

dp[i][j]

  • as:
  • Minimum operations to convert first i characters into first j characters.
  • If characters match:

dp[i][j] = dp[i-1][j-1]

Otherwise:

dp[i][j] =

  • 1 + min(
  • insert,
  • delete,
  • replace

)

This is a classic 2D String DP problem.

24. Longest Increasing Subsequence — LIS

Given:

\[10, 9, 2, 5, 3, 7, 101, 18\]
  • The LIS length is:
  • 4
  • For example:
  • 2, 3, 7, 101
  • The basic DP solution:

dp[i] =

length of LIS ending at i

Then for every previous j:

if nums[j] < nums[i]:
    dp[i] = max(
        dp[i],
        dp[j] + 1
    )

Basic complexity:

O(n²)

There is also an advanced Binary Search solution:

O(n log n)

This is a nice connection between:

DP

+

Binary Search

25. State Machine DP

Another important pattern.

Suppose you're buying and selling stocks.

At each day, you may be in states such as:

  • Holding stock
  • Not holding stock
  • Then:
  • Current state

/ \

Buy Sell

↓ ↓

Holding Not Holding

This is called:

  • State Machine DP
  • Common examples:
  • Best Time to Buy and Sell Stock
  • Stock with cooldown
  • Stock with transaction fee
  • Stock with at most K transactions

26. Interval DP

Interval DP solves problems involving ranges:

\[i ... j\]
  • Typical examples:
  • Matrix Chain Multiplication
  • Burst Balloons
  • Palindrome partitioning
  • Optimal BST
  • State:

dp[i][j]

means:

Best answer for interval [i, j].

Then we try possible split points:

i k j

|-------|-------|

This is another Divide & Conquer + DP combination.

27. Tree DP

DP can also be performed on trees.

Example:

Maximum sum of non-adjacent nodes in a tree.

For each node, there may be two states:

  • Take node
  • Skip node
  • For example:
  • 10

/ \

5 20

  • If we take 10, we cannot take its children.
  • So:
  • DP on tree

+

DFS

is a common pattern.

28. DP on DAG

A Directed Acyclic Graph can also be solved using DP.

Example:

A → B → D

\ ↑

→ C ───

If we want the longest path:

  • dp[node]
  • can represent the longest path ending at that node.
  • Topological sorting gives us the correct processing order.
  • So:
DAG
Topological Sort
DP

This is an important advanced graph/DP connection.

29. Bitmask DP

  • When the number of elements is small, we can represent a subset using bits.
  • For example, with 4 cities:
  • 0000
  • means no cities visited.
  • 0101
  • means:
  • City 0 visited
  • City 2 visited
  • Bitmask DP is commonly used for:
  • Traveling Salesman Problem
  • Assignment problems
  • Subset optimization
  • Typical state:
  • dp[mask][i]
  • meaning:

Best answer when the visited set is mask and we're currently at i.

30. Digit DP

  • Digit DP is an advanced technique for counting numbers satisfying constraints.
  • For example:
  • How many numbers from 1 to N have no repeated digits?
  • State may include:
  • position
  • tight
  • started
  • mask

This is generally an advanced topic and can be left until you're comfortable with the simpler DP patterns.

31. How to Recognize DP

  • Look for these phrases:
  • Counting
  • How many ways...
  • Number of ways...
  • Optimization
  • Maximum...
  • Minimum...
  • Longest...
  • Shortest...
  • Choices
  • Choose or skip
  • Take or don't take
  • Repeated subproblems
  • Same smaller problem appears repeatedly
  • Sequential decisions
  • At each step, choose one of several options

These are strong DP signals.

32. DP vs Greedy

This is extremely important because you recently studied Greedy Algorithms.

  • Suppose:
  • Choose items to maximize value
  • Greedy
  • Make the best-looking choice right now.
Current best
Choose
Never reconsider
  • DP
  • Explore different possibilities through states and retain the best result.
  • Current state

↙ ↘

Choice A Choice B

↓ ↓

subproblem subproblem

↘ ↙

best result

A greedy choice isn't always globally optimal.

33. DP vs Backtracking

  • You also studied Backtracking.
  • Backtracking
  • Explores possibilities:
  • Choice
  • ├── Choice

│ ├── Choice

│ └── Choice

  • └── Choice
  • and may explore many branches.
  • DP

If different branches reach the same state, DP remembers the result.

Backtracking
Repeated same state
Memoization
DP

This connection is extremely important.

34. DP vs Divide & Conquer

  • Divide & Conquer
  • Break into independent subproblems:
  • Problem
  • ├── Subproblem A
  • └── Subproblem B
  • Usually the subproblems don't overlap.

Example:

  • Merge Sort
  • DP
  • Subproblems overlap.
  • Problem

/ \

State A State B

\ /

\ /

State C

Since State C repeats, store its answer.

35. The DP Recognition Formula

A useful mental model:

PROBLEM
Can divide into

smaller states?

YES

Do states repeat/overlap?

/ \

NO YES

↓ ↓

Divide & Conquer DP

And DP usually has:

Optimal Substructure

+

Overlapping Subproblems

36. DP Problem Cheat Sheet

ProblemPattern
Climbing Stairs1D DP
House RobberTake / Skip
Coin ChangeUnbounded Knapsack
0/1 KnapsackTake / Skip
Subset SumKnapsack
Unique PathsGrid DP
Minimum Path SumGrid DP
LCSString DP
Edit DistanceString DP
LISSequence DP
Stock ProblemsState Machine DP
Matrix ChainInterval DP
Burst BalloonsInterval DP
Tree RobberTree DP
DAG Longest PathDAG DP
TSPBitmask DP

37. DP Problem-Solving Workflow

When you get a DP question, don't immediately write code.

Use this process:

1. Define the state

2. Identify the choices

3. Write the recurrence

4. Define base cases

5. Decide top-down or bottom-up

6. Calculate complexity

7. Optimize memory if possible

  • For example:
  • House Robber
  • State:
  • dp[i] = maximum money from first i houses
  • Choices:
  • Skip house
  • Take house
  • Recurrence:
  • dp[i] = max(

dp[i-1],

nums[i] + dp[i-2]

)

  • Base cases:
  • dp[0] = 0
  • dp[1] = nums[0]
  • That's the DP thinking process.

38. Your DP Learning Roadmap

I'd recommend practicing in this order:

DP

┌──────────────┐

│ 1D DP │

└──────┬───────┘

  • Climbing Stairs
  • House Robber
  • Min Cost Climbing

┌──────────────┐

│ Grid DP │

└──────┬───────┘

  • Unique Paths
  • Min Path Sum
  • Obstacles

┌──────────────┐

│ Knapsack │

└──────┬───────┘

  • 0/1 Knapsack
  • Subset Sum
  • Coin Change

┌──────────────┐

│ String DP │

└──────┬───────┘

  • LCS
  • Edit Distance
  • Palindromes

┌──────────────┐

│ Sequence DP │

└──────┬───────┘

LIS

Subsequence

Advanced

┌────────┼────────┐

↓ ↓ ↓

Interval Tree Bitmask

DP DP DP

🎯 Most important 10 DP problems

If your goal is DSA/interview preparation, master these first:

  • Climbing Stairs
  • House Robber
  • Coin Change
  • 0/1 Knapsack
  • Subset Sum
  • Unique Paths
  • Minimum Path Sum
  • Longest Common Subsequence
  • Edit Distance
  • Longest Increasing Subsequence

Once these are comfortable, move to stock DP, interval DP, tree DP, DAG DP, and bitmask DP.

The single most important DP habit is:

Before coding, clearly say what dp[state] means. If you can't define the state in one sentence, you're probably not ready to write the recurrence yet.

Module 4 · Lesson 4.29

Coding Interview Practice

Coding Interview Practice

This section is where you combine everything you've learned so far and practice solving problems the way you would in a real technical interview.

  • You've covered:
  • Arrays
  • Strings
  • Linked Lists
  • Stacks
  • Queues
  • Hash Tables
  • Trees
  • Binary Trees
  • BST
  • Heaps
  • Graphs
  • BFS / DFS
  • Recursion
  • Searching
  • Sorting
  • Divide & Conquer
  • Greedy
  • Dynamic Programming
  • Backtracking
  • Sliding Window
  • Two Pointers
  • Union-Find
  • Trie
  • Segment Tree
  • Binary Search

Now the goal changes from:

  • "Do I know this data structure?"
  • to:
  • "Can I recognize the pattern and solve an unseen problem?"

1. What Interviewers Actually Test

A coding interview usually tests four things:

CODING INTERVIEW

┌────────────┼────────────┐

↓ ↓ ↓

Problem Algorithm Code

Recognition Choice Quality

│ │ │

└────────────┴────────────┘

  • Complexity
  • You need to be able to:
  • Understand the problem
  • Identify the pattern
  • Choose the data structure/algorithm
  • Write correct code
  • Explain time and space complexity
  • Handle edge cases

2. The Most Important Skill — Pattern Recognition

  • Don't memorize hundreds of solutions.
  • Instead, learn to recognize patterns.
  • For example:
  • "Find two numbers that add to target"
  • Think:
  • Hash Map
if the array isn't sorted.
  • If sorted:
  • Two Pointers
  • "Longest substring with..."
  • Think:
  • Sliding Window
  • "Find shortest path in an unweighted graph"
  • Think:
  • BFS
  • "Minimum/maximum possible X"
  • Think:
  • Binary Search on Answer
  • "Choose or skip items"
  • Think:
  • DP
  • or:
  • Backtracking

depending on whether subproblems overlap and whether you need all combinations.

3. Interview Pattern Cheat Sheet

This is the table I'd recommend memorizing.

Problem ClueThink
Find pairHash Map / Two Pointers
Sorted array + pairTwo Pointers
Contiguous subarraySliding Window
Contiguous substringSliding Window
First/last occurrenceBinary Search
Sorted arrayBinary Search
Minimum possible XBinary Search on Answer
Matching bracketsStack
Next greater elementMonotonic Stack
Top K elementsHeap
Frequency countingHash Map
Prefix matchingTrie
Connected componentsDFS/BFS/Union-Find
Unweighted shortest pathBFS
Weighted shortest pathDijkstra
DependenciesTopological Sort
Minimum spanning treeKruskal/Prim
Choose/skipDP
Generate all possibilitiesBacktracking
Tree traversalDFS/BFS
Tree height/depthDFS
Linked-list cycleSlow/Fast Pointers
Merge sorted listsTwo Pointers / Heap
Range query + updatesSegment Tree
Repeated group mergingUnion-Find

This recognition ability is more valuable than memorizing code.

4. The Interview Problem-Solving Process

  • When the interviewer gives you a problem, follow this sequence.
  • Step 1 — Understand the problem
  • Ask yourself:
  • What is the input?
  • What is the output?
  • What constraints exist?
  • Don't start coding immediately.

5. Step 2 — Work Through an Example

Suppose:

arr = [2, 7, 11, 15]
target = 9

Expected:

\[0, 1\]

because:

2 + 7 = 9

Manually understand what the problem is asking.

6. Step 3 — Start With Brute Force

This is extremely important in interviews.

For Two Sum:

for i in range(n):
for j in range(i + 1, n):
if arr[i] + arr[j] == target:
return [i, j]

Complexity:

O(n²)

Then ask:

Can I improve this?

Use a Hash Map:

O(n)

Interviewers often want to see this progression.

7. Step 4 — Identify the Pattern

  • Ask:
  • Is it...
  • Array?
  • String?
  • Linked List?
  • Tree?
  • Graph?
  • Then:
  • Pair?
  • Range?
  • Prefix?
  • Shortest path?
  • Optimization?
  • Combinations?
  • This narrows the algorithm.

8. Step 5 — Choose the Data Structure

  • For example:
  • Need fast lookup
  • Hash Map / Hash Set
  • Need smallest/largest repeatedly
  • Heap
  • Need FIFO
  • Queue
  • Need LIFO
  • Stack
  • Need prefix search
  • Trie
  • Need connected groups
  • Union-Find

9. Step 6 — Write the Algorithm in English

Before code, explain:

"I'll use a hash map to store each number I've already seen. For each number, I'll check whether target - number exists."

This demonstrates your reasoning.

Then code.

10. Step 7 — Code

Example:

def two_sum(nums, target):
seen = {}
for i, num in enumerate(nums):
complement = target - num
if complement in seen:
return [seen[complement], i]

seen[num] = i

return []

11. Step 8 — Test the Code

  • Never stop immediately after writing code.
  • Test:
  • Normal case
  • [2,7,11,15], target=9
  • No answer
  • [1,2,3], target=10
  • Duplicate values
  • [3,3], target=6
  • Single element
  • [5], target=5
  • Think about edge cases.

12. Step 9 — Explain Complexity

  • Always finish with:
  • Time Complexity: O(n)
  • Space Complexity: O(n)

This is expected in interviews.

13. The Most Important Interview Patterns

  • Rather than practicing randomly, organize your practice by pattern.
  • Pattern 1 — Hash Map
  • Typical questions:
  • Two Sum
  • Group Anagrams
  • Top K Frequent Elements
  • Longest Consecutive Sequence
  • Subarray Sum Equals K
  • Core idea:
Store information
Fast lookup
O(1) average lookup

14. Pattern 2 — Two Pointers

  • Problems:
  • Two Sum II
  • Valid Palindrome
  • 3Sum
  • Container With Most Water
  • Remove Duplicates
  • Pattern:
  • left

\[...............\]

right

15. Pattern 3 — Sliding Window

  • Problems:
  • Longest Substring Without Repeating
  • Minimum Size Subarray Sum
  • Longest Repeating Character Replacement
  • Permutation in String
  • Minimum Window Substring
  • Pattern:
  • Expand right

Condition violated?

Shrink left
Update answer

16. Pattern 4 — Binary Search

  • Problems:
  • Binary Search
  • Search Rotated Array
  • First/Last Position
  • Find Peak
  • Koko Eating Bananas
  • Ship Packages
  • Aggressive Cows
  • Core question:
  • Can I eliminate half of the search space?

17. Pattern 5 — Stack

  • Problems:
  • Valid Parentheses
  • Min Stack
  • Daily Temperatures
  • Next Greater Element
  • Largest Rectangle in Histogram
  • Especially:
  • Next greater/smaller
  • should make you think:
  • Monotonic Stack

18. Pattern 6 — Linked List

  • Problems:
  • Reverse Linked List
  • Merge Two Sorted Lists
  • Linked List Cycle
  • Middle of Linked List
  • Remove Nth Node
  • LRU Cache
  • Important techniques:
  • Slow/Fast
  • Dummy Node
  • Two Pointers

19. Pattern 7 — Trees

  • Problems:
  • Maximum Depth
  • Invert Binary Tree
  • Diameter
  • Level Order Traversal
  • Lowest Common Ancestor
  • Validate BST
  • Serialize/Deserialize
  • Think:
  • DFS
  • BFS
  • Recursion

20. Pattern 8 — Heap

  • Problems:
  • Kth Largest
  • Top K Frequent
  • Merge K Sorted Lists
  • Median from Data Stream
  • K Closest Points
  • Think:
Repeatedly need min/max
Heap

21. Pattern 9 — Graph

  • Problems:
  • Number of Islands
  • Clone Graph
  • Course Schedule
  • Rotting Oranges
  • Word Ladder
  • Network Connectivity
  • Think:
  • BFS
  • DFS
  • Union-Find
  • Topological Sort

22. Pattern 10 — Backtracking

  • If the question asks:
  • "Generate all..."
  • "Find all combinations..."
  • "Find all permutations..."
  • think:
  • Backtracking
  • Examples:
  • Subsets
  • Permutations
  • Combination Sum
  • N-Queens
  • Word Search
  • Template:
def backtrack(path):
    if is_complete(path):
        result.append(path[:])

return

for choice in choices:
    make_choice(choice)
  • backtrack(path)
  • undo_choice(choice)
  • The key is:
Choose
Explore
Undo

23. Pattern 11 — Dynamic Programming

  • Think DP when you see:
  • Maximum
  • Minimum
  • Number of ways
  • Longest
  • Shortest
  • Choose/skip
  • Repeated subproblems
  • Examples:
  • House Robber
  • Coin Change
  • Knapsack
  • LCS
  • LIS
  • Edit Distance
  • Unique Paths
  • The first question:
  • What does dp[state] represent?

24. Pattern 12 — Greedy

  • Greedy is useful when:
  • A locally optimal choice can be proven to lead to a global optimum.
  • Examples:
  • Activity Selection
  • Jump Game
  • Gas Station
  • Merge Intervals
  • Minimum Number of Arrows
  • Kruskal
  • Prim
  • Don't use Greedy simply because it "looks good."

You need a reason the greedy choice is safe.

25. Pattern 13 — Intervals

Given:

\[1,3\]
\[2,6\]
\[8,10\]
  • Think:
  • Sort by start/end
  • Common problems:
  • Merge Intervals
  • Insert Interval
  • Meeting Rooms
  • Non-overlapping Intervals
  • Minimum Meeting Rooms

This is a very common interview category.

26. Pattern 14 — Trie

  • Think Trie when you see:
  • Prefix
  • Autocomplete
  • Dictionary
  • Starts With
  • Word Search
  • Examples:
  • Implement Trie
  • Word Search II
  • Replace Words
  • Word Dictionary

27. Pattern 15 — Union-Find

  • Think DSU when you see:
  • Connected components
  • Merge groups
  • Dynamic connectivity
  • Cycle detection
  • and especially:
  • Minimum Spanning Tree
  • → Kruskal + Union-Find.

28. Pattern 16 — Segment Tree

Think Segment Tree when you see:

Range Query

+

  • Frequent Updates
  • Examples:
  • Range Sum
  • Range Minimum
  • Range Maximum

Advanced:

Lazy Propagation

29. A Good Interview Practice Order

Since you've completed the theory, I recommend this order:

Phase 1

Arrays + Hash Maps
Phase 2
Two Pointers + Sliding Window
Phase 3
Stack + Queue
Phase 4
Binary Search
Phase 5
Linked Lists
Phase 6
Trees + BST
Phase 7
Heap
Phase 8
Graphs
Phase 9
Backtracking
Phase 10
Greedy
Phase 11
Dynamic Programming
Phase 12
Trie + Union-Find
Phase 13

Segment Tree

30. 30 Essential Interview Problems

If you want a compact list, start with these:

  • Arrays / Hashing
  • Two Sum
  • Group Anagrams
  • Top K Frequent Elements
  • Product of Array Except Self
  • Two Pointers / Sliding Window
  • Valid Palindrome
  • 3Sum
  • Container With Most Water
  • Longest Substring Without Repeating Characters
  • Minimum Window Substring
  • Stack
  • Valid Parentheses
  • Daily Temperatures
  • Largest Rectangle in Histogram
  • Binary Search
  • Binary Search
  • Search in Rotated Sorted Array
  • Koko Eating Bananas
  • Linked List
  • Reverse Linked List
  • Linked List Cycle
  • Merge Two Sorted Lists
  • Trees
  • Maximum Depth of Binary Tree
  • Binary Tree Level Order Traversal
  • Validate Binary Search Tree
  • Lowest Common Ancestor
  • Heap
  • Kth Largest Element
  • Merge K Sorted Lists
  • Graphs
  • Number of Islands
  • Course Schedule
  • Rotting Oranges
  • DP / Backtracking
  • House Robber
  • Coin Change
  • Combination Sum

These 30 cover a surprisingly large number of interview patterns.

31. How to Practice Each Problem

Don't just read the solution.

Use this process:

PROBLEM
Try for 15–20 min

Can't solve?

Identify the pattern
Study the solution
Close the solution
Code it yourself
Test edge cases
Explain complexity aloud
Re-solve later

The last step is extremely important.

32. The "Interview Explanation" Template

When solving a problem in an interview, use:

1. Clarify

"Let me confirm that the input can contain duplicates..."

2. Brute Force

"The straightforward solution would be O(n²)..."

3. Optimization

"We can reduce this to O(n) using a hash map..."

4. Algorithm

"I'll maintain a map of previously seen values..."

5. Code

Write cleanly.

6. Test

Walk through an example.

7. Complexity

"Time complexity is O(n), and space complexity is O(n)."

This makes your solution much easier for an interviewer to follow.

33. Don't Memorize Solutions — Memorize Patterns

  • For example, don't memorize:
  • "Two Sum solution"
  • Instead memorize:
Complement lookup
Hash Map
  • Don't memorize:
  • "Koko solution"
  • Memorize:
  • Minimum possible X

+

Can X satisfy the condition?

  • Binary Search on Answer
  • Don't memorize:
  • "House Robber solution"
  • Memorize:
Take or Skip
DP

That's how you become capable of solving new problems, not just known ones.

34. Your Final Interview Pattern Map

CODING PROBLEM

┌───────────────────┼───────────────────┐

↓ ↓ ↓

Array/String Linked List Graph

│ │ │

┌─────┼─────┐ Slow/Fast ┌────┼─────┐

↓ ↓ ↓ │ ↓ ↓ ↓

Hash Two Sliding Cycle BFS DFS DSU

Map Ptr Window Middle │ │

│ │ │

└──────────────┐ │ │

↓ │ │

Heap │ │

│ │ │

Top K Shortest Path

┌────────┼────────┐

↓ ↓ ↓

BFS Dijkstra Bellman

Ford

┌─────────────────────────────────────────────┐

│ │

↓ ↓

Optimization Search

│ │

DP Binary Search

│ │

┌─────┼──────┐ ┌──────┴──────┐

↓ ↓ ↓ ↓ ↓

1D Grid Knapsack Sorted Array Answer Space

Advanced DP

┌─────────────────────────────────────────────┐

↓ ↓

Backtracking Trie

│ │

Permutations Prefixes

Subsets Words

N-Queens Autocomplete

🎯 Your goal for this module

You don't need to be able to solve every possible DSA problem.

You should reach the point where you can look at a new problem and quickly say:

  • "This looks like Sliding Window."
  • or:
  • "This is Binary Search on the answer."
  • or:
  • "This is a Take-or-Skip DP."
  • or:
  • "This is BFS because I need the shortest path in an unweighted graph."

That pattern-recognition skill is the real objective of Coding Interview Practice.

Module 4 · Lesson 4.30

DSA Project

DSA Project

For the final DSA Project, the goal is to build something that demonstrates that you can apply multiple data structures and algorithms together—not just implement them individually.

  • Since you're working through the complete DSA module, I recommend a project that is practical, interview-friendly, and expandable.
  • 🚀 Recommended Project: Smart Delivery Route Optimizer
  • Build a system that manages delivery locations, finds routes, optimizes delivery order, and analyzes the network.
  • Think of it as a small version of a Google Maps + delivery optimization system.

1. Project Objective

  • Given:
  • Warehouses
  • Delivery locations
  • Roads
  • Distances
  • Delivery priorities

the system should answer questions such as:

  • What is the shortest route from the warehouse to a customer?
  • Which locations are connected?
  • What is the cheapest network connecting all locations?
  • What is the best order to visit multiple locations?
  • Can we detect disconnected locations?

This allows you to use a large portion of your DSA syllabus.

2. Example

Suppose we have:

5

A ------------- B

| |

2| |3

| |

C ------------- D

  • 4
  • Locations:
  • A = Warehouse
  • B = Customer 1
  • C = Customer 2
  • D = Customer 3
  • Edges represent roads.
  • Your application could answer:
  • Shortest A → D
  • Maybe:
  • A → B → D
Cost = 5 + 3 = 8

3. DSA Concepts Used

This is where the project becomes valuable.

DSA TopicProject Usage
ArraysStore locations/edges
StringsLocation names
Hash MapsLocation lookup
Linked ListsCustom adjacency representation
StackDFS/path reconstruction
QueueBFS
HeapPriority queue
GraphRoad network
BFSUnweighted shortest path
DFSNetwork exploration
DijkstraWeighted shortest path
Union-FindConnectivity/cycle detection
KruskalMinimum spanning tree
Binary SearchSearch route/cost threshold
SortingSort roads/deliveries
GreedyDelivery optimization
DPRoute optimization
BacktrackingGenerate possible routes
TrieLocation-name autocomplete
Segment TreeDynamic range statistics

That's a very strong DSA project.

4. Project Architecture

Keep the project modular.

dsa_route_optimizer/
├── main.py

├── data/

│ ├── locations.json

│ └── roads.json

├── structures/

│ ├── graph.py

│ ├── heap.py

│ ├── trie.py

│ ├── union_find.py

│ └── segment_tree.py

├── algorithms/

│ ├── bfs.py

│ ├── dfs.py

│ ├── dijkstra.py

│ ├── kruskal.py

│ ├── binary_search.py

│ ├── greedy.py

│ ├── dynamic_programming.py

│ └── backtracking.py

├── services/

│ ├── route_service.py

│ ├── delivery_service.py

│ └── network_service.py

├── tests/

└── README.md

This also gives you good software-engineering practice.

5. Phase 1 — Graph

Start with the road network.

Represent it as an adjacency list:

graph = {
    "A": [("B", 5), ("C", 2)],
    "B": [("A", 5), ("D", 3)],
    "C": [("A", 2), ("D", 4)],
    "D": [("B", 3), ("C", 4)]
}
  • Here:
  • A → B = 5
  • A → C = 2
  • B → D = 3
  • C → D = 4

6. Phase 2 — BFS

Implement:

def bfs(start):

...

  • Use it to answer:
  • Can the warehouse reach the customer?
  • For an unweighted network:
  • A → B → D

BFS can also find the minimum number of roads.

7. Phase 3 — DFS

Implement:

def dfs(start):

...

  • Use DFS for:
  • Network exploration
  • Connected components
  • Cycle detection
  • For example:
Warehouse
├── Customer 1

├── Customer 2

└── Customer 3

DFS can explore the complete reachable network.

8. Phase 4 — Dijkstra

Now introduce road distances.

Question:

  • Find the shortest route from A to D.
  • Run:
  • Dijkstra(A)

Example:

  • A → C → D
  • Cost:
  • 2 + 4 = 6
  • while:
  • A → B → D
  • costs:
  • 5 + 3 = 8

Therefore:

Shortest path = A → C → D

Distance = 6

This becomes your main routing feature.

9. Phase 5 — Path Reconstruction

  • Don't just return:
  • 6
  • Return:
  • Distance: 6
  • Route:
  • A → C → D
  • Maintain a parent dictionary:
parent = {
    "C": "A",
    "D": "C"
}

Then reconstruct:

D
C
A

Reverse it:

A → C → D

This is an important interview technique.

10. Phase 6 — Priority Queue / Heap

Dijkstra requires repeatedly finding the node with the smallest distance.

Use:

import heapq

Example:

heap = [
    (0, "A")
]
  • Then:
  • distance, node = heapq.heappop(heap)
  • This demonstrates your Heap knowledge.

11. Phase 7 — Union-Find

Now add a feature:

Detect whether adding a new road creates a cycle.

Example:

A --- B

\ /

  • C
  • When adding:
  • A → C
  • Union-Find checks:
  • Find(A) == Find(C)
  • If yes:
  • Cycle detected

12. Phase 8 — Kruskal

Now calculate:

What is the minimum-cost network connecting every location?

This is a Minimum Spanning Tree problem.

Use:

Sorting

+

Greedy

+

Union-Find

Algorithm:

Sort roads by cost
Take cheapest road

Does it create cycle?

↓ ↓

No Yes

↓ ↓

Add Skip

  • Return:
  • MST Cost = ₹...
  • Roads selected = ...

13. Phase 9 — Trie

Now add a search feature.

  • Suppose locations are:
  • Hyderabad
  • Hitech City
  • Hosur
  • Hampi
  • Bangalore
  • User types:

"Hi"

  • Trie can provide:
  • Hitech City
  • This demonstrates:
  • Trie

+

DFS

14. Phase 10 — Binary Search

Suppose roads have maximum allowed distances.

  • User asks:
  • Find the first route whose distance is at least 50 km.
  • If route distances are sorted:
\[10, 20, 35, 50, 65, 80\]

use:

Binary Search

You can also create a more interesting feature:

  • What is the minimum vehicle capacity required to complete all deliveries?
  • That's:
  • Binary Search on Answer

15. Phase 11 — Greedy Delivery Optimization

Suppose you have:

Warehouse
Customer A
  • Customer B
  • Customer C
  • Customer D
  • Each customer has:
  • priority
  • distance
  • delivery deadline

You can implement a greedy strategy:

Choose highest-priority feasible delivery

This demonstrates your Greedy Algorithms topic.

Important: clearly document that this is a heuristic unless you've proven it produces an optimal solution.

16. Phase 12 — Dynamic Programming

Now make the project more interesting.

Suppose you have a limited delivery capacity:

  • Vehicle capacity = 10
  • Customers have:
  • Weight
  • Profit/Priority

Now determine which deliveries should be selected.

  • This becomes a:
  • 0/1 Knapsack
  • problem.

Example:

Customer Weight Value

A 2 20

B 5 50

C 3 30

D 4 40

  • Vehicle capacity:
  • 7
  • Use DP to maximize value.

17. Phase 13 — Backtracking

Suppose there are only a few delivery locations.

You want to generate possible delivery orders:

  • A → B → C
  • A → C → B
  • B → A → C
  • B → C → A
  • C → A → B
  • C → B → A
  • Use:
  • Backtracking

This is useful for demonstrating how a brute-force solution can be built before optimizing.

18. Phase 14 — Segment Tree

  • This can be an optional advanced module.
  • Suppose every day you have:
  • Day 1 → 50 deliveries
  • Day 2 → 70 deliveries
  • Day 3 → 40 deliveries

...

  • You want queries like:
  • Total deliveries from Day 10 to Day 20.
  • and updates like:
  • Day 15 changed from 80 to 100.
  • Segment Tree provides:
  • Range Query → O(log n)

Update → O(log n)

This demonstrates your advanced DSA knowledge.

19. Final CLI

You can make the application interactive.

Example:

====================================

SMART DELIVERY ROUTE OPTIMIZER

====================================

  • 1. Add Location
  • 2. Add Road
  • 3. Find Shortest Route
  • 4. Check Connectivity
  • 5. Detect Cycle
  • 6. Find Minimum Network
  • 7. Search Location
  • 8. Optimize Deliveries
  • 9. Show Network
  • 10. Exit

Enter choice:

20. Example Interaction

  • User:
  • Enter source: Hyderabad
  • Enter destination: Bangalore
  • System:
  • Shortest Route

--------------------------------

Hyderabad
Hosur
Bangalore

Distance: 575 km

Estimated Cost: ₹4,600

21. Add Complexity Reporting

This is especially good for a DSA project.

  • For each operation, show:
  • Algorithm: Dijkstra
  • Vertices: 1000
  • Edges: 5000
  • Time Complexity:
  • O((V + E) log V)
  • Space Complexity:

O(V)

This proves that you understand not only how the algorithm works but also why you chose it.

22. Project Dashboard

If you want to turn this into a portfolio project, build a simple web UI.

Possible dashboard:

┌──────────────────────────────────────────┐

│ SMART DELIVERY OPTIMIZER │

├────────────┬────────────┬────────────────┤

│ Locations │ Roads │ Deliveries │

│ 150 │ 420 │ 85 │

├────────────┴────────────┴────────────────┤

│ │

│ NETWORK GRAPH │

│ │

│ A -------- B │

│ | | │

│ C -------- D │

│ │

├──────────────────────────────────────────┤

│ Shortest Route │

│ Hyderabad → Bangalore │

│ Distance: 575 km │

│ Algorithm: Dijkstra │

└──────────────────────────────────────────┘

23. Suggested Technology

  • Since the purpose is DSA learning, don't make the framework too complicated.
  • Backend
  • Python
  • Data
  • Start with:
  • JSON
  • Later:
  • SQLite
  • UI — optional
  • Streamlit
  • or:
  • Flask/FastAPI + HTML/JavaScript

For a pure DSA project, even a CLI application is enough.

24. Project Modules

I would divide it into these modules:

Module 1

Graph Management
Module 2
BFS / DFS
Module 3
Shortest Path
Module 4
Union-Find
Module 5
Minimum Spanning Tree
Module 6
Trie Search
Module 7
Delivery Optimization
Module 8
DP
Module 9
Backtracking
Module 10
Segment Tree
Module 11
Testing + Complexity Analysis
Module 12

UI / Dashboard

25. Testing Strategy

  • Create tests for each algorithm.
  • For Dijkstra:
  • Test 1 → Simple graph
  • Test 2 → Disconnected graph
  • Test 3 → Multiple possible paths
  • Test 4 → Single node
  • Test 5 → Large graph
  • For Union-Find:
  • Test 1 → Separate groups
  • Test 2 → Merge groups
  • Test 3 → Duplicate union
  • Test 4 → Cycle detection
  • For Trie:
  • Test 1 → Insert
  • Test 2 → Search
  • Test 3 → Prefix
  • Test 4 → Duplicate word
  • Test 5 → Empty string
  • This makes the project much more professional.

26. GitHub README

  • Your README should explain:
  • # Smart Delivery Route Optimizer
  • ## Problem
  • Optimize delivery routes using classical
  • Data Structures and Algorithms.
  • ## Features
  • - BFS
  • - DFS
  • - Dijkstra
  • - Kruskal
  • - Union-Find
  • - Trie
  • - Binary Search
  • - Dynamic Programming
  • - Backtracking
  • - Segment Tree
  • ## Complexity

...

## Architecture

...

## Example

...

## How to Run

python main.py

27. What This Project Demonstrates

A recruiter/interviewer can see:

Python
Data Structures
Algorithms
Graph Theory
Optimization
Complexity Analysis
Testing
Software Design

That's much stronger than having 20 separate files containing isolated algorithm implementations.

28. Your Final DSA Project Goal

The finished project should allow you to demonstrate:

DSA PROJECT

┌─────────────────┼─────────────────┐

↓ ↓ ↓

Data Structures Algorithms Analysis

│ │ │

↓ ↓ ↓

Graph Dijkstra O(V+E)

Heap BFS/DFS O(log n)

Trie Kruskal O(n log n)

DSU DP Space

Segment Tree Backtracking

🎯 Recommended final deliverable

Build Smart Delivery Route Optimizer v1 with these 8 core features first:

  • Graph creation
  • BFS/DFS traversal
  • Dijkstra shortest path
  • Union-Find connectivity/cycle detection
  • Kruskal minimum spanning tree
  • Trie-based location search
  • DP-based delivery selection
  • Backtracking route generation

Then add Segment Tree + dashboard as advanced extensions.

That gives you a single project that touches almost the entire Module 4 – Data Structures & Algorithms syllabus and, more importantly, gives you something concrete to discuss in a coding interview.