Homework
Homework
Other related courses:
ITC: https://sites.google.com/view/itc-ucp-2017
PF/OOP: https://sites.google.com/view/pf-ucp-2018/
DSA: https://sites.google.com/view/dsa-ucp2017/home
DS: https://sites.google.com/view/ds-ucp-2017/home
Algo-2019: https://sites.google.com/view/algo-ucp-2019
Historic Perspective
What Alkhwarzmi saw in - Euclid's work translation
Discovering GCD Algorithm
Geometric Sequence (logarithm function)
Prune and Search
Geometric Series - Proof of 1+2+4+8+...+N/2+N = N+N/2+N/4+...+ 8+4+2+1 <=2N = O(N)
THE PROBLEM: Given an N-1- element array, Finding an element from within a range of 1-N.
N^2 Algorithm - Finding each value from within the range into the array.
N log N + N log N - Sorting + binary Searching.
N log N + N - Sorting + One Scan Algorithm.
N (reading) + N(Boolean array filling, with respect to each value) + N(Searching a value which one is missing by scanning Boolean array).
With no extra space.
N (reading) + Summing from 1-N + Subtracting each value from Sum variable.
Bit Complexity and analysis of the above 5 solutions
2N Bit reading Algorithm finding the missing element.
Suppose we have an algorithm running in:
n² time
Someone proposes:
Use 100 machines (distributed computing).
New effective runtime becomes:
n² / 100
Core Question:
Is dividing by 100 enough?
Or should we design a fundamentally better algorithm?
We compared runtime behavior for increasing input sizes:
When n = 100
When n = 1,000
When n = 10,000
When n = 100,000
When n = 10^10
When n ≈ 10^18 (Google-scale data)
n² / 100 still behaves like n².
Constant division does NOT change growth rate.
For very large inputs:
Asymptotic behavior dominates hardware improvements.
A better order (for example n^1.9 instead of n²) eventually beats:
Distributed computing
More machines
Hardware scaling
Algorithmic improvement > Constant speedup
Suppose:
Algorithm A runs in n^k1 time
Algorithm B runs in n^k2 time
We compare them by analyzing:
limit as n → infinity of (n^k1 / n^k2)
First algorithm grows slower.
First algorithm is better.
First algorithm grows faster.
Second algorithm is better.
Both grow at same rate.
Only constant factor difference.
Considered asymptotically equivalent.
Big-O of g(n) is the set of all functions f(n) such that:
f(n) ≤ c × g(n)
for all n ≥ n₀
Where:
c is a positive constant.
n₀ is some threshold input size.
For small n, behavior may fluctuate.
After some point (n₀):
f(n) stays below c × g(n).
Constants do NOT matter asymptotically.
Only growth trend matters.
Initially curves may cross.
After n₀:
f(n) is always bounded above by c × g(n).
We defined Big-O (upper bound).
Comparison of functions using limit of f(n) / g(n) as n → infinity.
Growth rate matters, not constants.
little-o(g(n)) contains functions that grow strictly slower than g(n).
Condition:
If limit of f(n)/g(n) → 0
⇒ f(n) grows strictly slower than g(n)
f(n) is always eventually smaller than g(n)
Not just bounded — strictly dominated
Belong to little-o(n²):
n log n
n^1.5
log n
(log n)^100
Do NOT belong:
n²
n^2.1
n^50
L’Hopital rule idea to compare growth
Example: log n vs n
→ n grows faster
Key fact:
Any power of n grows faster than any power of log n
Opposite of Big-O
Represents functions that grow at least as fast
Lower bound guarantee
Condition:
f(n) ≥ c × g(n) for n ≥ n₀
Meaning:
Algorithm takes at least this much time
Belong to Omega(n²):
n²
100 n²
n^2.1
n^50
Do NOT belong:
n
n log n
n^1.5
Strictly faster growing functions
Limit of f(n)/g(n) → infinity
Meaning:
f(n) grows strictly faster than g(n)
Most important notation
Theta(g(n)) means:
g(n) is both:
Upper bound
Lower bound
So:
c1 × g(n) ≤ f(n) ≤ c2 × g(n)
After some n₀
Exact growth rate
Not just:
worst case
not just lower bound
But actual order of growth
We analyze growth instead of exact formula.
Replace all terms with largest value n
Sum ≤ n + n + n + … + n
= n²
So:
Upper bound = O(n²)
Each term ≥ 1
Sum ≥ n
Not tight enough
Take second half terms:
Each ≥ n/2
Count = n/2 terms
Sum ≥ (n/2) × (n/2)
= n² / 4
Upper bound ≈ n²
Lower bound ≈ n²
Therefore:
Sum = Theta(n²)
This is a tight bound
For any polynomial:
a n^k + b n^(k-1) + … + constant
Dominant term decides complexity
Example:
10 n² + 17 n + 60 → Theta(n²)
Ignore:
constants
lower degree terms
Instead of summing every line:
Focus on the slowest growing part
Dominant loop determines complexity
Example:
100 lines each running n² → still Theta(n²)
Two research directions in Computer Science:
Design faster algorithms
Prove no faster algorithm exists
If both meet:
Upper bound = Lower bound
⇒ Problem optimally solved
Best comparison-based sorting:
Time = n log n
There exists proof:
No comparison sorting can beat n log n
Therefore:
Sorting problem solved optimally
Goal of the lecture:
Not only write algorithms
But mathematically prove they are correct
Main idea:
Algorithm correctness comes from mathematics
Connection between programming and mathematical induction
Tool introduced:
Loop Invariant Method
Like arranging playing cards in hand
Left side cards already sorted
Insert new card in correct position
Assume:
Elements from index 0 to i-1 are already sorted
Insert element at index i into correct position
Shift larger elements right until correct location found
Loop stops when:
Correct position found OR
Beginning of array reached
Instead of swapping repeatedly:
Store element in temporary variable
Shift elements
Insert once at correct place
After each iteration:
Left portion of array remains sorted
Like bubbles rising to surface
Compare adjacent elements
Swap if out of order
Largest element moves to end in each pass
After 1 pass → largest element fixed
After 2 passes → 2 largest elements fixed
After n-1 passes → array sorted
If no swap happens in a pass:
Array already sorted
Stop early
Bubble sort is not just sorting —
it is a strategy of pushing elements gradually to correct position
Repeatedly find minimum element
Place it at correct position
Find minimum in entire array → put at index 0
Find minimum in remaining array → put at index 1
Continue until sorted
Sorted portion grows from left side
We prove statements using three steps:
Base Case
Assume true for k
Prove true for k+1
Sum of numbers 1 to n formula
Instead of assuming only k
Assume all values up to k are true
Similar to falling dominoes
Show that all amounts ≥ certain value can be formed
Key learning:
Sometimes multiple base cases are required
Induction behaves like recursion
Recursion = executing induction backwards
Base case = stopping condition
Recursive calls = inductive steps
Used to prove program correctness.
A property that:
True before loop starts
Remains true after every iteration
Helps prove final result is correct
Property true before loop starts
After one iteration → still true
When loop ends → desired result obtained
Algorithm:
Assume first element is maximum
Compare with each next element
Update maximum if larger element found
Loop Invariant
At start of iteration i:
max contains maximum among elements 0 to i-1
When loop ends:
max contains maximum of entire array
Property:
Before iteration i:
Subarray 0 to i-1 is sorted
After inserting element:
Subarray 0 to i is sorted
When loop ends:
Entire array sorted
Instead of guessing program correctness:
We mathematically prove:
Program → maintains property → produces correct output
Big-O → Upper bound
Omega → Lower bound
Theta → Exact growth
little-o → Strictly smaller growth
little-omega → Strictly larger growth
Using limit of f(n) / g(n):
→ 0 ⇒ f grows slower
→ constant ⇒ same order
→ infinity ⇒ f grows faster
Set from 1 to n:
Exactly n elements
Increasing step size does not change order
Examples:
step 1 → n elements
step 2 → n/2 elements
step k → n/k elements
If k is constant → still Theta(n)
Constants never change complexity class
But:
If step = log n
→ number of iterations = n / log n
Loop Condition Complexity
i*i ≤ n sqrt(n)
i³ ≤ n n^(1/3)
i^k ≤ n n^(1/k)
Key idea:
Maximum value of i determines number of iterations
We estimate instead of calculating exact value.
Replace all terms with n
→ n terms of n → n²
Take second half terms
Each ≥ n/2
→ about n²/4
Therefore:
Sum = Theta(n²)
Upper bound → n × n² = n³
Lower bound → (n/2 terms each ≥ (n/2)²) → n³/8
Therefore:
Theta(n³)
Sum of k-th powers:
1^k + 2^k + … + n^k → Theta(n^(k+1))
Sum Type Growth
1 to n n²
Squares n³
Up to n² terms n⁴
Important insight:
Number of terms × size of largest term determines order
Count total operations, do NOT blindly multiply loops
Outer runs n times
Inner runs i times
Total = 1 + 2 + 3 + … + n = n²
Outer n times
Inner n times
Total = n²
Outer n² times
Inner i times
Total = 1 + 2 + … + n² = n⁴
Important Warning:
Calling expensive function inside loop changes complexity drastically.
Example:
for i = 1 to n:
length(string)
If length() scans string each time → O(n²)
But:
len = length(string)
for i = 1 to len:
→ O(n)
Number of times a value doubles or halves to reach n
i = 1
while i ≤ n:
i *= 2
Complexity → log n
Outer log n
Inner log i
Total → (log n)²
log(n^k) = k log n
log base change only constant factor
log₂ n and log₁₀ n are same order
Therefore:
All logs treated as same in Big-O
1 + 2 + 4 + 8 + … + n
Sum ≈ 2n − 1 → Theta(n)
Key takeaway:
Geometric growth does NOT produce n²
Series Type Complexity
1 + 2 + … + n n²
1 + 2 + 4 + … + n n
n added log n times n log n
Discrete sum ≈ continuous area
Helps estimate growth when formula unknown
Outer loop doubles: i = 1, 2, 4, 8 … n → log n iterations
Inner loop runs from 1 to i
Third loop doubles up to j → log j
Not every nested loop becomes n³
Must sum costs carefully instead of multiplying blindly
Use summations + bounds
Recognizing patterns:
1 + 2 + 4 + 8 + … + n
→ Sum proportional to n
Important rule:
Increasing geometric series → dominated by last term
Decreasing geometric series → dominated by first term
log n grows very slowly
n grows faster
2^n grows extremely fast
Confusing log n and n leads to wrong complexity conclusions
If something doubles repeatedly:
Growth seems slow initially
Suddenly explodes near the end
Key idea:
Exponential growth hides its impact until very late
Instead of loops → analyze recursive algorithms
General form:
T(n) depends on smaller inputs
T(n) = T(n−1) + 1
Each level cost = constant
Number of levels = n
Result:
Time = O(n)
T(n) = T(n/2) + n
Recursion Tree:
Level Cost
1 n
2 n/2
3 n/4
… …
Sum = n + n/2 + n/4 + …
Result:
O(n)
T(n) = 2T(n−1) + 1
Each level doubles calls
Forms geometric expansion
Result:
O(2^n)
T(n) = 3T(n−1) + 1
Result:
O(3^n)
Pattern Complexity Source
Increasing geometric Last level dominates
Decreasing geometric First level dominates
Equal cost per level Multiply by number of levels
T(n) = 5T(n/5) + n
Each level cost = n
Number of levels = log₅ n
Result:
O(n log n)
T(n) = 25T(n/5) + n²
Each level cost = n²
Levels = log₅ n
Result:
O(n² log n)
T(n) = a T(n/b) + f(n)
Where:
a = number of subproblems
b = shrink factor
f(n) = work per level
Example:
T(n) = 100T(n/50) + n
Total cost dominated by last level
Result:
O(n^(log base 50 of 100))
(Polynomial but > n)
Count work at each level
Identify pattern (constant / increasing / decreasing)
Multiply by levels if equal
Otherwise take dominant level
If ratio < 1 → bounded by first term
If ratio > 1 → bounded by last term
Example: problem splits into parts of size n/5, n/4, n/3
Cannot form simple geometric series directly
Analyze using recursion tree levels
Some branches finish early
Some branches finish late
Therefore:
Compute lower bound using fastest shrinking branch
Compute upper bound using slowest shrinking branch
Actual complexity lies between the two bounds
Steps:
Identify smallest subproblem → gives minimum depth
Identify largest subproblem → gives maximum depth
Count branching factor at each level
Convert number of nodes into polynomial power of n
Important insight:
Many recurrences cannot be solved exactly — but tight bounds are enough
Recursive definition:
Calls for n-1 and n-2
Every level doubles nodes approximately
Depth roughly proportional to n
Result:
Between exponential ranges
Exact solution related to the golden ratio
Key learning:
Exponential algorithms grow extremely fast
Polynomial differences are small
Exponential differences are massive
Example Insight:
n vs 2n → similar
n vs n² → big difference
n vs 2ⁿ → enormous difference
Series:
1 + 1/2 + 1/3 + 1/4 + ... + 1/n
Group terms in powers of two ranges
Replace each group with its largest value
Each group sums to constant
Number of groups = log n
Replace terms with smallest in each group
Each group contributes at least constant fraction
Harmonic sum grows proportional to log n
Connection:
Integral of 1/x behaves like logarithm
Continuous mathematics explains discrete algorithm behavior
Repeated square-root shrinking:
n → sqrt(n) → sqrt(sqrt(n)) → ...
Depth becomes log log n
Each level costs n
Result:
Total complexity = n log log n
General form:
a subproblems
each size n/b
plus extra work n^c
Compare:
a vs b^c
Equal growth
→ Multiply one level cost by number of levels
Subproblems shrink faster
→ First level dominates
Subproblems expand faster
→ Last level dominates
(This builds intuition for the Master Theorem)
Nodes per level grow as powers of a
Levels determined by dividing by b
Leaves ≈ n raised to log base b of a
This determines final complexity.
Goal:
Choose pivot element
Smaller elements left
Larger elements right
After partition:
Pivot reaches correct position
Two independent smaller problems remain
This is Divide & Conquer
Worst case:
One side size n-1
Other side size 1
Total work:
n + (n-1) + (n-2) + ... + 1
Result:
Worst case complexity = n²
Instead of fixed pivot:
Choose random index
50% chance pivot lies in middle 50% of array
Expected balanced split
Split roughly:
25% and 75%
Each level total work ≈ n
Number of levels ≈ log n
Randomized Quick Sort runs in n log n time (expected)
Divide problem
Solve smaller pieces
Combine results
Quick Sort key advantage:
After partition, left and right sides never interact again
A powerful algorithm design paradigm
Idea:
Divide problem into smaller parts
Solve smaller parts recursively
Combine the results
Important note:
Division does NOT have to be only into two parts — can be many parts
Goal:
Given stock prices over time
Buy low and sell high
Maximize profit
Key transformation:
Convert prices into daily changes
Problem becomes:
Find the subarray with maximum total sum
This is the Maximum Subarray Problem
Method:
Try every possible start index
Try every possible end index
Compute sum for each subarray
Complexity:
Extremely slow (very high polynomial time)
Improvement:
Reuse running sum instead of recomputing
Result:
Reduced but still inefficient
Split array into:
Left half
Right half
Possible answers:
Entirely in left half
Entirely in right half
Crossing the middle
Crossing solution must:
End in left side near middle
Start in right side near middle
So compute:
Best suffix from left
Best prefix from right
Combine them
Maximum of three values:
Left solution
Right solution
Crossing solution
Two recursive calls on half array
Linear work to combine
Result:
Runs in n log n time
Divide & Conquer structure identical to merge sort:
Break into halves
Solve recursively
Combine answers
Shows:
Divide & Conquer is a general algorithmic strategy, not a single algorithm
Steps:
Divide array repeatedly until single elements
Merge sorted pieces
Each merge costs linear time
Observation:
Every level costs linear work
Number of levels = logarithmic
Result:
Merge Sort complexity = n log n
Idea:
Treat every element as a sorted array
Repeatedly merge pairs using a queue
Advantages:
No recursion stack
Same time complexity
For a problem:
Find best algorithm
Then prove no better algorithm exists
This is called:
Finding the lower bound
All comparison-based sorting algorithms:
Compare two elements at a time
Eventually determine element order
Sorting equals:
Finding correct permutation of elements
For n elements:
There are n! possible orders
Algorithm must distinguish all possibilities
Represented by:
Decision tree of comparisons
Depth of decision tree determines runtime
Minimum depth required:
At least proportional to n log n
No comparison-based sorting algorithm can be faster than:
n log n (in worst case)
Merge Sort already achieves n log n
Therefore:
Merge Sort is asymptotically optimal
No programmer, language, or computer can beat this limit
(as long as sorting uses comparisons)
Some problems cannot be solved using fixed loops
Example: generate subsets of size k
Number of loops depends on k → unknown at compile time
Conclusion:
Variable-depth problems naturally require recursion
Input:
A collection of elements
A number k
Goal:
Produce all subsets containing exactly k elements
For each element:
Two choices:
Include the element
Exclude the element
This produces two recursive calls:
Reduce remaining elements
Adjust remaining selections
Every combination problem follows:
Include / Exclude decision tree
This directly corresponds to the mathematical definition of combinations.
While building solution:
Add element to current set
Recurse
Remove element (undo step)
Continue exploring
This is called:
Backtracking
Goal:
Produce all sequences like:
000, 001, 010, …, 111
Recursive Strategy
Ask recursion for strings of length n-1
Then:
Add ‘0’ in front
Add ‘1’ in front
Result:
Each level doubles the number of strings
For a set {A, B, C}
Every subset is either:
Without the new element
With the new element
So:
Take all previous subsets and duplicate them with the new element added
Connection:
Same pattern as binary strings:
0 → exclude
1 → include
Given a grid:
Start at top-left
Reach bottom-right
Allowed moves:
Right
Down
From each cell:
Move right
Move down
Stop when:
Destination reached
Boundary hit
Some cells blocked
Recursion stops at blocked cell
Two versions:
Print all paths
Count number of paths
Both use same recursive structure.
Given:
Source pole
Helper pole
Destination pole
n disks
Rules:
Move one disk at a time
Larger disk never above smaller
To move n disks:
Move n-1 to helper
Move largest to destination
Move n-1 to destination
Each level doubles moves
Result:
Exponential number of steps
Expand matrix using minors
Each step reduces matrix size
Stops at 1×1 matrix
Note:
Conceptually useful
Computationally inefficient
Better method:
Gaussian elimination (used in practice)
Most recursive problems follow one of these templates:
combinations
subsets
subset sum
binary strings
power set
grid paths
maze problems
Tower of Hanoi
determinant expansion
Given a string of length n
Print all permutations
OR store them in a vector
At position i:
Fix one character at position i
Recursively permute the remaining characters
Restore original order (backtrack)
For index i:
Swap s[i] with every position j ≥ i
Recurse for i + 1
Swap back (restore state)
This guarantees:
No repetition
All permutations generated
Correct backtracking behavior
Base case: when index reaches string length → print/store permutation
Always restore string after recursive call
Backtracking prevents duplicate states
Directly print permutations
Store permutations in vector<string>
Pass vector by reference
Use push_back() instead of printing
Divide and Conquer Strategy:
Divide problem into smaller subproblems
Solve subproblems recursively
Combine their results
If recurrence is:
T(n) = aT(n/b) + n^c
→ Total time = O(n^c)
→ Total time = O(n^(log_b a))
→ Total time = O(n^c log n)
If problem divides unevenly:
May require advanced method (e.g., Akra–Bazzi)
Not covered fully in lecture
Time complexity = O(n²)
Split each number into:
Left half
Right half
So:
X = XL and XR
Y = YL and YR
4 multiplications:
XL × YL
XL × YR
XR × YL
XR × YR
Recurrence:
T(n) = 4T(n/2) + O(n)
Result:
Time complexity = O(n²)
No improvement.
Observation:
Middle term can be computed as:
(XL + XR)(YL + YR) minus previously computed parts
This reduces:
4 multiplications → 3 multiplications
T(n) = 3T(n/2) + O(n)
Using Master Theorem:
Time complexity = O(n^(log₂3))
Approximately:
O(n^1.585)
Major breakthrough:
Faster than quadratic multiplication
Multiplication is expensive
Addition and shifting are cheap
So:
Reduce multiplications even if additions increase.
Given n points in 2D plane:
Find pair with minimum distance.
Compare every pair
Time complexity = O(n²)
Split points into left and right halves
Recursively find:
Minimum distance in left half (dl)
Minimum distance in right half (dr)
Let:
d = min(dl, dr)
Key challenge:
Closest pair might lie across the dividing line.
Only consider points within distance d of the midline
Form a vertical strip of width 2d
Divide strip into small grids of size d × d
Key geometric result:
Each grid contains at most constant number of points
Thus:
Each point compared with only constant neighbors
O(n)
T(n) = 2T(n/2) + O(n)
Using Master Theorem:
Time complexity = O(n log n)
QuickSort works using:
A partition procedure
A pivot element
Recursive calls on left and right subarrays
If p < r:
Partition the array
Get pivot index q
Recursively sort:
Left side → p to q-1
Right side → q+1 to r
Last element chosen as pivot
All elements ≤ pivot moved to left
All elements > pivot moved to right
Pivot placed in final correct position
Two indices move through array:
j scans elements
i tracks boundary of smaller elements
Swap occurs only when needed
Pivot is swapped at the end
Always O(n)
Single linear pass
No nested loops
Occurs when:
Pivot always smallest or largest element
One side size = n−1
Other side size = 0
Recurrence:
T(n) = T(n−1) + O(n)
Result:
O(n²)
Occurs when:
Pivot divides array evenly
Recurrence:
T(n) = 2T(n/2) + O(n)
Result:
O(n log n)
Example:
10% on one side
90% on the other side
Recurrence:
T(n) = T(n/10) + T(9n/10) + O(n)
Analysis shows:
Still O(n log n)
Important Insight:
Even moderately unbalanced partitions still give n log n.
Instead of choosing fixed pivot:
Pick random index between p and r
Swap with last element
Run normal partition
Avoids consistently bad input cases
Makes worst-case behavior extremely unlikely
Expected time complexity = O(n log n)
Find the k-th smallest element in an array.
Examples:
k = 1 → smallest element
k = n → largest element
k = n/2 → median
Sort array → O(n log n)
Then pick k-th element
Too expensive.
Partition the array
Get pivot index q
Compare k with pivot position
If k = pivot position → Done
If k < pivot position → Recurse on left side
If k > pivot position → Recurse on right side (adjust k)
Important:
Only one recursive call is made.
T(n) = T(n−1) + O(n)
→ O(n²)
T(n) ≈ T(9n/10) + O(n)
Result:
Expected time = O(n)
Major Result:
k-th smallest element can be found in linear time (expected)
Random pivot likely splits reasonably
Probability of extremely bad splits is very small
Uses expectation and probability analysis
Students should revise:
Expectation
Harmonic series
Basic probability
Goal:
Achieve guaranteed O(n) time (not expected)
Divide array into groups of 5
Sort each group (constant time per group)
Extract medians of each group
Recursively find median of medians
Use that as pivot
Ensures strong pivot guarantee
Guarantees enough elements eliminated each step
Leads to worst-case linear time
T(n) = T(n/5) + T(7n/10) + O(n)
Result:
O(n)
This is the first deterministic linear-time selection algorithm.
If we can:
Find median in O(n)
Partition around it
Then sorting becomes:
T(n) = 2T(n/2) + O(n)
Which gives:
O(n log n)
QuickSort uses partitioning to divide the array around a pivot element.
Worst case occurs when the pivot splits the array very unevenly (for example: 1 element on one side and the rest on the other side).
Best case occurs when the pivot splits the array roughly in half.
Key observations:
Even if the split is not perfectly balanced (e.g., 25%–75%), the overall complexity remains O(n log n).
Randomization is used to reduce the probability of repeatedly choosing bad pivots.
Instead of always choosing a fixed pivot:
Generate a random index between p and r.
Swap that element with the last element.
Run the normal partition algorithm.
Benefits:
Avoids adversarial input cases.
Makes worst-case behavior extremely unlikely.
Expected running time remains O(n log n).
If the dataset contains many repeated values:
Standard QuickSort may perform poorly because partitions become unbalanced.
Improvement:
Use three-way partitioning:
Elements less than pivot
Elements equal to pivot
Elements greater than pivot
This prevents unnecessary recursive calls on repeated values and significantly improves performance when duplicates exist.
Problem:
Find the k-th smallest element in an unsorted array without fully sorting it.
Examples:
k = 1 → smallest element
k = n → largest element
k = n/2 → median
Algorithm idea:
Partition the array.
Check the pivot index.
Recurse only on the side that contains the desired element.
Key property:
Only one recursive branch is explored.
Expected running time:
O(n)
Goal:
Guarantee linear time selection without randomness.
Main idea:
Divide the array into groups of 5 elements.
Sort each small group.
Extract the median of each group.
Recursively find the median of those medians.
Use that value as the pivot.
Important property:
This pivot guarantees that a significant fraction of elements are discarded each step.
Worst-case recurrence:
T(n) = T(n/5) + T(7n/10) + O(n)
Result:
O(n) deterministic time.
Median-of-medians follows a strategy called Prune and Search.
Steps:
Perform some preprocessing in linear time.
Discard a large fraction of the input.
Recursively solve the smaller problem.
Because the problem size shrinks geometrically, the total time remains linear.
Previously shown:
Any comparison-based sorting algorithm requires at least:
Ω(n log n) comparisons.
However, if additional information about the data range is known, faster sorting may be possible.
If the values lie within a known range (for example 1 to n):
Sorting can be done without comparisons.
Algorithm steps:
Create a count array to store frequency of each value.
Compute prefix sums to determine final positions.
Build the output array using these positions.
Key properties:
Time complexity: O(n + k)
(where k is the range of values)
Stable sorting algorithm.
The prefix sum step transforms the count array into cumulative counts.
This allows us to determine:
How many elements are ≤ a given value.
The exact final index where each element should be placed.
This idea is also useful in many problems such as range queries and frequency analysis.
A point P dominates point Q if P.x > Q.x and P.y > Q.y
In 1D, domination is simple — larger value wins
In 2D, if one point is larger in X but smaller in Y, neither dominates the other — they are incomparable
Given n points in 2D, find all maximal points
A point is maximal if no other point dominates it
Maximal points form a staircase shape when visualized
For each point, compare it against every other point
If no point dominates it, add it to the solution set
Sort the solution set to reconstruct the staircase line
Simple but inefficient for large inputs
Find the median X-coordinate using Randomized Select — O(n)
Partition points into left and right halves — O(n)
Recursively compute maxima for both halves
Merge step: Right-side maxima are always included; left-side points are kept only if their Y-value is greater than the leftmost Y of right maxima
Recurrence: T(n) = 2T(n/2) + O(n) → solves to O(n log n)
Sort points by X-coordinate, then sweep a vertical line left to right
Maintain a running solution set (staircase)
When a new point arrives, remove all previous points with smaller Y (they are now dominated)
Amortized analysis shows total cost is O(n) after sorting
Right-to-left variant is cleaner — rightmost point is always maximal; only compare Y-values going backward
Used in fighter jets, satellite imagery, radar systems
Each building is represented as: (x_start, x_end, height)
Goal: Compute the visible skyline silhouette from a set of overlapping buildings
Differs from 2D Maxima — buildings have width, so points cannot simply be deleted when a building ends
Convert each building into two events: (x_start, +height) and (x_end, −height)
Sort all events by X
Use a Multimap / Multiset (backed by AVL tree) to track active building heights
On start event → insert height; on end event → delete height
Current maximum height in the map defines the skyline at that X
Each insertion/deletion costs O(log n) → Total: O(n log n)
Divide buildings into two halves
Recursively compute skyline for each half
Merge the two skylines by taking the maximum height at each X-coordinate
Merge step runs in O(n) → Recurrence solves to O(n log n)
Any comparison-based sorting algorithm must make at least Ω(n log n) comparisons
An array of n elements has n! possible arrangements; sorting means finding one specific ordering
This is modeled as a decision tree — every algorithm must traverse its height
Height is minimized when the tree is balanced → minimum height = log(n!) ≈ n log n
Proof: lower half of the sum log(n!) is bounded below by n/2 × log(n/2), which gives Ω(n log n)
Works only when element values fall within a known range [1, k]
Does not contradict the Ω(n log n) lower bound — it exploits range information, not comparisons
Steps: build a frequency (counts) array C of size k, then compute prefix sums (cumulation)
After cumulation, C[i] tells the last valid position in output array B for value i
Traverse input array A in reverse order, place each element at C[A[i]], then decrement C[A[i]]
Reverse traversal is what makes it a stable sort — equal keys retain their original order
Complexity: O(n + k); if k >> n, performance degrades significantly
A sort is stable if equal-key records maintain their original relative order
Counting Sort is stable — due to reverse traversal
Bubble Sort is stable — equal elements are never swapped
Insertion Sort and Merge Sort are also stable
Selection Sort and QuickSort are not stable by default
Exam note: Reversing the traversal direction in Counting Sort removes stability
Sorts integers by processing one digit at a time, from least significant to most significant
Each pass uses a stable sort (Counting Sort) on digits 0–9
The digit range is always [0, 9] regardless of number size — so k = 10 per pass
Total complexity: O(d × n) where d = number of digits per number
This is not comparison-based — it compares digits, not full numbers
If numbers have d = log n digits, complexity approaches O(n log n); if d = n, it becomes O(n²)
Application: sorting dates — first by day, then month, then year (least to most significant)
Counting Sort fails here — k = n² means O(n²) space and time
Solution: represent each number as a pair (quotient, remainder) by dividing by n
Both quotient and remainder fall in [0, n−1] — a manageable range
Apply Radix Sort: first sort by remainder, then by quotient (stable sort each pass)
Total complexity: 2 × O(n + n) = O(n) — linear time achieved
Designed for floating-point numbers uniformly distributed in [0, 1)
Create n buckets, each representing a sub-interval of [0, 1)
Each element a[i] goes into bucket ⌊n × a[i]⌋ and is inserted in sorted order within that bucket
After all insertions, concatenate all buckets into final sorted array
Insertion into a bucket costs O(size of bucket); merging all buckets costs O(n)
Expected time is O(n) due to uniform distribution — few collisions per bucket
Stability requires inserting at the end (or traversing in reverse for the final pass)
Random Variable, Expectation, PDF, Indicator Random Variable
Hiring Problem
Randomizing an array of n size
Its analysis
Harmonic Series
Its lower bound
Its upper bound
Analysis of Randomized Quicksort
Its analysis
The discussion on the variance of Randomized QuickSort i.e. O(n) and what is the meaning its repurcussion with
ChevbeChev relationship
Euler (1700s) posed the famous Königsberg Bridge Problem: can you cross every bridge exactly once and return to your starting point?
He modeled land regions as vertices and bridges as edges — inventing the field of Graph Theory
This path (visiting every edge exactly once) is called an Eulerian Path
Graph Theory was dismissed as mathematics for ~50 years before becoming the core of Computer Science
A graph G consists of V (vertices) and E (edges)
Vertices represent entities (regions, users, locations); edges represent connections between them
A maze, a social network, a city map — all are naturally modeled as graphs
Graphs allow compact representation: instead of storing infinite spatial points, store only meaningful connections
Undirected graph: edges are bidirectional — if A connects to B, B connects to A (e.g., Facebook friendships)
Directed graph: edges have direction — a connection from A to B does not imply B to A (e.g., Twitter follows)
Adjacency matrix of an undirected graph is always symmetric
Adjacency Matrix:
An n × n matrix where entry (i, j) = 1 if an edge exists between vertex i and j
Space complexity: O(n²)
Checking if an edge exists: O(1)
Finding all neighbors of a vertex: O(n) — must scan entire row
Adjacency List:
An array/hash table where each entry points to a linked list of neighbors
Space complexity: O(n + E)
Finding all neighbors: O(degree of vertex)
Far more memory-efficient for sparse graphs
Facebook's friendship graph has ~2 billion users; storing it as an adjacency matrix requires ~4 × 10¹⁸ bytes — completely infeasible
Adjacency list reduces this to roughly 2 billion × average friends (~200) — fits on a single hard drive
Key operations and their costs in adjacency list representation:
Add friend: O(1) — insert at front of both lists
Remove friend: O(degree of i + degree of j) — must search the list
New user sign-up: O(n) — vector resizing; use a hash table to reduce this
Delete account: O(n) — requires rebuilding matrix in adjacency matrix form
Twitter is a directed graph — following is one-way; Facebook friendship is undirected
A connected component is a maximal subset of vertices where every pair has a path between them — like isolated islands in the graph
Goal: precompute connected component numbers (CCN) for all vertices so that reachability queries can be answered in O(1)
Query: given vertices i and j, are they reachable from each other? → simply check if CCN[i] == CCN[j]
DFS explores a graph by going as deep as possible before backtracking — analogous to in-order tree traversal (LRN)
Each vertex stores: visited (boolean), ccn (component number), clock_begin, clock_end
The visited flag prevents infinite loops in cyclic graphs
DFS Algorithm:
Initialize all vertices as visited = false, ccn = -1
For each unvisited vertex, call explore(v, cn)
Inside explore: mark visited → assign pre-clock → recursively explore unvisited neighbors → assign post-clock
A global clock counter increments on every pre/post visit
A global cn counter increments each time a new component starts
Pass a component number cn into each explore call
Assign v.ccn = cn when a vertex is first visited
All vertices reachable within one DFS call share the same CCN
Increment cn only when starting a fresh explore from the outer loop — each fresh call = new component
Total time complexity: O(n + E) — linear in input size
A graph G = (V, E): vertices represent entities, edges represent connections
Adjacency Matrix: O(n²) space; edge existence check in O(1); listing neighbors in O(n)
Adjacency List: O(V + E) space; edge existence in O(degree); listing neighbors in O(degree)
Use adjacency matrix when the graph is dense; adjacency list when it is sparse
DFS on adjacency list runs in O(V + E); on adjacency matrix in O(V²)
CLRS implementation assigns each vertex: discovery time (d), finish time (f), color, and parent (π)
Colors: White = unvisited, Grey = in progress, Black = fully explored
When exploring vertex u's neighbor v, set v.parent = u before the recursive call
After DFS from a source vertex, backtracking through parent pointers reconstructs the path from source to any reachable vertex
If a vertex has no parent assigned, no path exists from the source
Note: DFS paths are not necessarily shortest paths
When DFS runs on a directed graph, every edge falls into one of four types:
Tree edges: edges actually used during DFS traversal — form the DFS forest
Back edges: edges pointing from a vertex to one of its ancestors — indicate a cycle
Forward edges: edges pointing to an already-fully-explored descendant — shortcut in the tree
Cross edges: edges between vertices in disjoint DFS subtrees — neither ancestor nor descendant
Detection using clock intervals:
Back edge: the destination's interval contains the source's interval
Cross edge: the two intervals are completely disjoint
Forward edge: the source's interval contains the destination's interval
A DAG is a directed graph with no cycles
A linked list is a simple example of a DAG
Prerequisite course structures are DAGs — a cycle would make a degree impossible to complete
Maximum edges in a DAG with n vertices without creating a cycle: O(n²) — specifically n(n−1)/2
Every DAG must contain at least one source (no incoming edges) and one sink (no outgoing edges)
Using adjacency matrix:
Source: find a column that is all zeros → O(V²)
Sink: find a row that is all zeros → O(V²)
Using adjacency list:
Naive approach: for each vertex, check if it appears in any other vertex's list → O(V × (V + E))
Better approach: mark all vertices that appear as destinations; any unmarked vertex is a source → O(V + E)
Using DFS clock — elegant approach:
Run one DFS on the graph → vertex with maximum finish time is the source
Compute the reverse graph (transpose: swap all edge directions) → costs O(V + E)
Run DFS on reversed graph → vertex with maximum finish time in reversed graph is the sink of original
Total cost: O(V + E)
Given a DAG with dependency constraints, find a valid linear ordering of vertices such that for every edge u → v, u comes before v
Real-world examples: course prerequisites, build systems, construction task scheduling, getting dressed in order
Algorithm — DFS-based Topological Sort:
Run DFS on the entire graph, computing finish times
As each vertex finishes, insert it at the front of a linked list
The final linked list gives the topological order
Total time: O(V + E) — a single DFS pass
Why it works:
Vertices that finish last are sources (no dependencies) — they go to the front
Vertices that finish first are sinks — they go to the back
The reverse finishing order naturally encodes the dependency ordering
To find the minimum number of steps (parallel stages) to complete all tasks:
Find all current source vertices — these can be executed simultaneously in one step
Remove them and their edges, then repeat
This is equivalent to finding the longest path in the DAG
Useful for: minimum semesters to complete a degree, parallel task scheduling
DFS uses pre-visit (assign values before exploring children) and post-visit (assign values after all descendants finish)
Pre-visit is useful for passing information downward (e.g., parent pointers, distance from source, component numbers)
Post-visit is useful for computing values that depend on descendants (e.g., subtree heights, topological finish times)
Topological Sort: run DFS, insert each vertex at the front of a linked list when its finish time is computed → result is a valid dependency ordering in O(V + E)
Source vertex of a DAG always has the maximum finish time in DFS
BFS is inspired by the ripple effect in water — all points on the same ring are equidistant from the center
Start by inserting the source vertex into a queue
Repeatedly dequeue a vertex, then enqueue all its unvisited neighbors
Vertices are colored: White = unvisited, Grey = in queue, Black = fully processed
Grey color prevents a vertex from being inserted into the queue twice — avoids duplicate processing
Continue until the queue is empty
CLRS Implementation:
Initialize all vertices: color = White, distance = ∞, parent = Nil
Set source: color = Grey, distance = 0, parent = Nil
Enqueue source, then process until queue is empty
For each dequeued vertex u, visit white neighbors v: set v.color = Grey, v.distance = u.distance + 1, v.parent = u, enqueue v
After processing u: set u.color = Black
DFS can go arbitrarily deep in the wrong direction — not guaranteed to find shortest path
BFS expands layer by layer: first all vertices at distance 1, then distance 2, then distance 3, and so on
BFS always computes shortest paths from the source to every reachable vertex
Parent pointers set during BFS enable backtracking to reconstruct the full shortest path
Time complexity: O(V + E) — same as DFS
A grid graph stores only the grid values (V cells); edges are computed on the fly from neighbor coordinates
Neighbors of cell (i, j) are up to 8 surrounding cells — no need to store adjacency lists explicitly
BFS on a grid finds the shortest path from a source cell to a target cell
Parent pointers serve double duty as visited markers — if a cell's parent is set, it has been visited
Variants of the grid path problem:
Single source (e.g., person at position 2) to single target (e.g., bunker at position 3)
Multiple sources to multiple targets — naive approach costs O(n × (V + E))
Optimal approach: to find nearest target for every source in O(V + E) — this is a midterm exam question
Definition:
A subset S of a directed graph G is a Strongly Connected Component if for every pair of vertices u, v ∈ S, both a u → v path and a v → u path exist
A single vertex is always its own SCC
A simple linked list has n SCCs (one per node)
A cyclic linked list has exactly 1 SCC
Key observations:
If an SCC is "shrunk" to a single node, the resulting meta-graph is always a DAG
The reverse graph G^R has the same SCCs as the original G
The source SCC of G^R corresponds to the sink SCC of G
Algorithm:
Compute the reverse graph G^R
Run DFS on G^R to compute finish times (topological ordering)
Process vertices in decreasing finish time order, running explore on the original graph G
Each new explore call that starts a fresh component identifies a new SCC
Why it works:
The vertex with the highest finish time in G^R corresponds to a sink SCC of G
Running explore on the original G from that vertex visits only that SCC — edges out of it lead to already-visited vertices
Repeating this process peels off one SCC at a time in sink-to-source order
Time complexity: O(V + E)
O(V + E) to compute G^R
O(V + E) to run DFS on G^R for ordering
O(V + E) to run explore calls on G
The lecture begins with a recap of reachability in graphs and how graphs are represented.
Focus on checking:
Whether a path exists between two vertices
Whether a direct edge exists
Depth First Search (DFS) is introduced as a key traversal method.
DFS explores nodes recursively:
Mark node as visited
Store pre-visit (start time)
Explore all neighbors recursively
DFS helps in:
Traversing the graph
Extracting connected components
Finding paths using parent tracking
A single DFS call explores one connected component.
To find all components:
Run DFS on all unvisited vertices
Each DFS tree corresponds to a separate component.
A DAG has no cycles.
Important properties:
At least one source vertex (no incoming edges)
At least one sink vertex (no outgoing edges)
Using DFS:
The node with the largest finishing time is always a source
Reversing a graph flips all edges.
Key idea:
Sources become sinks
Sinks become sources
Useful for advanced graph algorithms
A universal sink:
In-degree = n−1
Out-degree = 0
Naive approach:
Check full matrix → O(n²)
Optimized approach:
Eliminate candidates using row/column checks
Achieve O(n) solution without scanning full matrix
Applicable only for DAGs
Used for dependency resolution
Key property:
All edges go forward in ordering
Efficient method:
Use DFS finishing times
Insert nodes in reverse finish order
No explicit sorting required
In directed graphs:
u and v are strongly connected if:
u → v and v → u paths exist
If SCCs are compressed into single nodes:
The resulting graph becomes a DAG
Steps:
Run DFS and store finishing order
Reverse the graph
Run DFS in decreasing finish time order
Each DFS call gives one SCC
Efficient and widely used method
Works in levels (layers)
Explores:
Distance 1 → Distance 2 → Distance 3 …
Guarantees:
Shortest path in unweighted graphs
Supports:
Multi-source shortest paths by initializing queue with multiple nodes
Edges can have weights (costs)
Goal:
Minimize total cost from source to all vertices
BFS fails for weighted graphs
Idea evolution:
Convert weights → multiple nodes (inefficient)
Use alarm clock idea (priority-based processing)
Maintains:
Distance array
Parent pointers (for path reconstruction)
Uses:
Min-Heap / Priority Queue
Process:
Always pick node with smallest distance
Relax its edges
If a shorter path is found:
Update distance
Update parent
Core operation in shortest path algorithms
Using heap:
O((V + E) log V)
Fails with negative edge weights
Because:
Once a node is processed, it is never revisited
Negative edges can produce better paths later
Lecture starts with a recap of:
Graph representation (Adjacency Matrix vs List)
DFS traversal and its applications
Important note:
Time complexity of DFS = O(V + E)
Connected Components
Source & Sink detection in DAG
Topological Sorting
Strongly Connected Components (SCC)
Real-world analogy:
MS Paint fill tool
Minesweeper expansion
Idea:
Start from a node and recursively fill all connected same-type neighbors
This is essentially DFS in disguise
Works level-by-level (layered expansion)
Used for:
Shortest path in unweighted graphs
Uses queue and guarantees minimum edges path
Real-world graphs are always weighted
Time, cost, fuel, distance, etc.
Key point:
Weights are on edges, not nodes
Adjacency Matrix
Store weight instead of 1
Adjacency List
Store pairs: (neighbor, weight)
Trick:
Convert weight into multiple dummy nodes
Problem:
Extremely inefficient for large weights
Use priority queue (min-heap)
Always process node with smallest distance
Initialize:
Distance = ∞
Source distance = 0
Use Relaxation:
Update distance if shorter path found
O((V + E) log V)
Does NOT work with negative edges
Once a node is removed from heap:
It is never revisited
Negative edge may later produce a shorter path
Example insight:
Later relaxation may improve already finalized node
This breaks Dijkstra’s correctness
Instead of greedy selection:
Relax ALL edges repeatedly
Initialize:
Distance[source] = 0
Others = ∞
Repeat (V - 1) times:
For every edge (u → v):
Relax it
Because:
Maximum edges in shortest path = V - 1
If:
dist[u] + weight(u,v) < dist[v]
Then:
Update dist[v]
Set parent[v] = u
O(V × E)
Cycle whose total weight < 0
You can loop infinitely and reduce cost
So:
Shortest path does NOT exist
Run Bellman-Ford one extra iteration
If any value still updates:
Negative cycle exists
Do Topological Sort
Process nodes in that order
Relax edges once
O(V + E) (much faster than Bellman-Ford)
Modify relaxation:
Use max instead of min
Works only in DAG
General Graph:
Shortest path → Easy
Longest path → Hard (NP-complete)
DAG:
Both shortest and longest path → Efficient
GPS navigation (weighted shortest paths)
Network routing
Game scoring (negative edges = rewards)
Scheduling with dependencies
GIS systems & grid graphs
Goal: Multiply two polynomials fast
Naive method:
Time = O(n²)
Problem:
Too slow for large inputs (especially in AI / CNNs)
Key insight from lecture:
Convolution = Polynomial Multiplication
CNNs heavily rely on convolution → we MUST optimize this
Instead of multiplying directly:
Convert polynomial from coefficient form → sample form
Multiply in sample form (cheap)
Convert back to coefficient form
Example:
P(x) = 2 + 3x + 4x² - 5x³
Stored as:
[2, 3, 4, -5]
Multiplication cost → O(n²)
Represent polynomial using points:
(x₁, y₁), (x₂, y₂), ..., (xₙ, yₙ)
Key property:
n distinct points uniquely define polynomial
C(xi) = A(xi) * B(xi)
Just multiply y-values:
Time = O(n)
C(xi) = A(xi) + B(xi)
Time = O(n)
We need:
Coefficient → Sample (forward transform)
Sample → Coefficient (inverse transform)
Naive way:
Uses Vandermonde matrix
Cost = O(n²)
We want:
Conversion in O(n log n)
That’s exactly what FFT does
A(x), B(x) (coefficient form)
Convert to sample form (FFT)
Point-wise multiplication (O(n))
Convert back (Inverse FFT)
O(n log n)
The lecture introduces the core FFT trick:
Even indices
Odd indices
A(x) = A_even(x²) + x * A_odd(x²)
This reduces problem size from n → n/2
Instead of computing directly:
Solve smaller subproblems
Combine results
This leads to recurrence:
T(n) = 2T(n/2) + O(n)
Result:
T(n) = O(n log n)
Instead of evaluating full polynomial:
You compute:
A_even(x²)
A_odd(x²)
Then combine:
A(x) = A_even(x²) + x * A_odd(x²)
This is the core recursive idea of FFT
Polynomial evaluation at one point:
O(n) using Horner’s Rule
But we need:
Evaluation at n points efficiently
FFT solves this smartly using special points (roots of unity — next lecture)
Method Time
Naive multiplication O(n²)
Better algorithms O(n^(1+ε))
FFT O(n log n)
Even if ε is very small:
n^(1+ε) is still slower than n log n
So:
n log n < n^(1+ε)
This lecture builds:
Bridge between:
Algebra (polynomials)
Signals (convolution)
AI (CNNs)
Deep Learning (Convolutions)
Image Processing
Audio Processing
Signal Processing
Large integer multiplication
Lecture starts with this idea:
We write a recursive solution
But it becomes very slow
Because of:
Repeated computations
Example (Catalan / Fibonacci-like):
Same values are recomputed again and again
Leads to exponential time
Key idea from lecture:
“Same subproblems are solved multiple times”
Avoid recomputation
Two ways:
Catalan numbers follow:
C(n+1) = Σ [ C(i) * C(n - i) ]
i = 0 to n
C0 = 1
C1 = 1
C2 = 2
C3 = 5
C4 = 14
To compute:
C5 → need C0, C1, C2, C3, C4
Meaning:
“To compute one value, you need all previous values”
Calls explode like a tree
Same values recomputed again and again
Time Complexity:
Exponential
Store already computed values in an array
Before computing, check:
if value exists → return it
if(dp[n] != -1)
return dp[n];
dp[n] = compute(...);
return dp[n];
Recursion still exists, but repeated calls become table lookups
Time reduces from exponential → O(n²) (for Catalan)
MUST pass array by reference
Otherwise:
Every recursive call gets a new copy
Memoization fails
Lecture explicitly warns about this
Instead of recursion:
Build solution from smallest → largest
dp[0] = 1
for i = 1 to n:
dp[i] = 0
for j = 0 to i-1:
dp[i] += dp[j] * dp[i-1-j]
“No recursion at all”
“Everything is computed in order”
Time = O(n²)
DP =
Solve problem by:
Breaking into subproblems
Storing results
Reusing them
1. Identify subproblems
2. Find recurrence
3. Store results
4. Build solution
Tile a 2 × n floor using:
2×1 vertical tiles
1×2 horizontal tiles
Look at last column:
Vertical tile → remaining:
2 × (n-1)
Two horizontal tiles → remaining:
2 × (n-2)
f(n) = f(n-1) + f(n-2)
This is Fibonacci 👀
Many DP problems are hidden Fibonacci-type problems
Overlapping subproblems
Recursive structure
Exponential recursion
Same values computed repeatedly
Approach Idea
Memoization Recursion + cache
Tabulation Iterative build
Almost 40% programming problems = DP
If your solution gives TLE → probably needs DP
Lecture connects:
DP problems
→ can be seen as
Path problems in DAGs
Lecture 21 teaches:
Why recursion fails
How to optimize it
How to think in DP
Given an array → find subarray with maximum sum
Instead of solving directly, lecture changes the problem:
Define:
dp[i] = max sum of subarray ending at index i
Because:
“Every optimal subarray must end somewhere”
So:
Compute answer for every index
Take maximum of all
At index i, two choices:
1. Extend previous subarray
2. Start new from i
So:
dp[i] = max(arr[i], dp[i-1] + arr[i])
If previous sum is negative → discard it
If positive → extend it
max(dp[i]) over all i
max_ending_here = arr[0]
max_so_far = arr[0]
for i = 1 → n:
max_ending_here = max(arr[i], max_ending_here + arr[i])
max_so_far = max(max_so_far, max_ending_here)
O(n) (BEST POSSIBLE)
Find longest subsequence where:
a[i1] < a[i2] < a[i3] ...
Define:
dp[j] = length of LIS ending at index j
Look at all previous elements:
dp[j] = max(dp[i] + 1) for all i < j where arr[i] < arr[j]
for j = 0 → n:
dp[j] = 1
for i = 0 → j-1:
if arr[i] < arr[j]:
dp[j] = max(dp[j], dp[i] + 1)
max(dp[j])
Only look backwards, never forward
To reconstruct sequence:
Store:
parent[j] = index from which dp[j] came
Then:
Start from max index
Trace back using parent array
O(n²)
LIS can be converted to a graph problem (DAG)
Nodes = indices
Edge: i → j if
i < j AND arr[i] < arr[j]
Then problem becomes:
Find LONGEST PATH in DAG
You have a rod of length n
You can cut it into pieces
Each length has a price
Maximize profit
You can cut like:
1 + (n-1)
2 + (n-2)
3 + (n-3)
...
dp[n] = max(
price[i] + dp[n - i]
) for all i = 1 → n
Try all possible first cuts
O(n²)
Lecture 22 is actually teaching ONE idea repeatedly:
1. Redefine problem → "ending at i"
2. Build recurrence using previous states
3. Compute for all i
4. Take global max
Problem dp meaning
Max Subarray sum ending at i
LIS length ending at i
Rod Cutting best profit for length n
Solve local optimal at each index
Combine → get global solution
Max Subarray:
O(n⁴) → O(n³) → O(n²) → O(n) (Kadane)
Given a rod of length n and a price table p[i] (profit for selling a piece of length i), find the cutting strategy that maximizes total profit
Two outputs required: the maximum profit and the exact cut lengths used
Example: for a rod of length 4, selling two pieces of length 2 each (profit 10) beats selling the whole rod (profit 9)
At each step, try all possible first cuts of length i (1 ≤ i ≤ n), then recursively solve the remaining rod of length n - i
The recurrence: R(n) = max over i of { p[i] + R(n - i) }
Base case: R(0) = 0
This generates every possible binary partitioning of the rod — 2ⁿ total subproblems
Subproblems repeat massively — e.g., R(2) is computed from many different paths
Allocate an array revenue[0..n], initialize all to -1, set revenue[0] = 0
Before computing, check if revenue[n] ≥ 0 — if yes, return cached result immediately
Otherwise compute normally and store result before returning
Reduces complexity from O(2ⁿ) to O(n²)
Build solutions from smaller subproblems upward: compute R(1), R(2), ..., R(n) in order
For each length j, try all first cuts i from 1 to j: R(j) = max(p[i] + R(j-i))
Since all smaller values are already computed, no recursion needed — pure table lookup
Space: O(n) for the revenue table
Recovering the actual cuts (bookkeeping):
Maintain a separate array S where S[j] stores the first cut length that achieved the maximum for rod of length j
To reconstruct: go to S[n], record that cut, subtract it from n, repeat until n = 0
This is equivalent to following parent pointers in a DAG
Given an n × n grid where edges go right, down, or diagonally right-down
This grid is a DAG — no cycles possible given edge directions
Goal: find the longest (or shortest) weighted path from top-left (S) to bottom-right (T)
Recurrence: LP(i,j) = max(LP(i-1,j) + w_up, LP(i,j-1) + w_left, LP(i-1,j-1) + w_diag)
Base case: the top row and left column are filled from the source outward
DP fills the matrix row by row — each cell needs only its left, above, and diagonal-above-left neighbors
Time: O(n²) — a single sweep; Space: O(n) if only one previous row is kept (since each row depends only on the row above)
To find shortest path: replace max with min — no other change needed
Given two words of lengths m and n, find the minimum number of edits (insertions, deletions, substitutions) to transform one into the other
Alignment cost: matching characters → cost 0; mismatch or gap → cost 1
Applications: autocorrect (suggest closest dictionary word), DNA/gene sequence matching, forensic fingerprint comparison
Recurrence — E(i, j) = minimum edit distance between first i characters of word 1 and first j characters of word 2:
E(i, j) = min(1 + E(i-1, j), 1 + E(i, j-1), diff(i,j) + E(i-1, j-1))
Where diff(i,j) = 0 if characters match, 1 if they don't
Base cases: E(i, 0) = i and E(0, j) = j
Bottom-Up DP:
Build an (m+1) × (n+1) matrix
Initialize row 0 as 0, 1, 2, ..., n and column 0 as 0, 1, 2, ..., m
Fill row by row using the recurrence — pure table lookup
Time: O(m × n); Space: O(m × n), reducible to O(min(m, n)) with row-rolling
This is a shortest path problem on a DAG — horizontal/vertical edges have cost 1, diagonal edges have cost 0 (match) or 1 (mismatch)
Find the longest sequence of characters that appears in both words in order (not necessarily contiguous)
Same 2D matrix structure as edit distance — but maximize instead of minimize
Diagonal edges get value 1 if characters match, 0 otherwise; horizontal/vertical edges always have value 0
This is a longest path problem on the same DAG
Bookkeeping arrows (↑, ←, ↖) stored during DP allow reconstruction of the actual common subsequence
Given items with weights and profits, and a bag of capacity T, maximize total profit without exceeding capacity
Variants covered:
Unbounded knapsack: each item can be picked unlimited times → equivalent to Rod Cutting
0/1 knapsack: each item picked at most once → requires 2D DP table
Multiple knapsacks: minimize difference between two bags' totals, etc.
The knapsack problem appears in nearly every competitive programming contest and technical interview
Coin Change is a knapsack variant: given coin denominations with unlimited supply, find the minimum number of coins summing to target T — identical structure to unbounded knapsack
All DP problems have an underlying DAG — the recurrence defines the edges, the table defines the nodes
The pattern is always: identify overlapping subproblems → memoize (top-down) or fill table (bottom-up)
Bookkeeping arrays store which choice was made at each step, enabling solution reconstruction
Edit distance, LCS, rod cutting, grid paths, and knapsack are all shortest/longest path problems on DAGs
Complexity drops from exponential (2ⁿ or 3ⁿ) to polynomial (n² or n×m) through DP
Each item is either picked (1) or not picked (0) — no repetition allowed
Each item has a weight and a value/profit
Goal: maximize total profit without exceeding bag capacity T
Recurrence: K(n, T) = max(K(n-1, T), V[n] + K(n-1, T - W[n]))
Base case: K(0, T) = 0 for all T
Time complexity: O(2ⁿ) for naive recursion; O(n × T) with DP
Subset Sum Problem:
Special case of 0/1 knapsack where weight = value for each item
Find a subset whose elements sum exactly to target t
One of the hardest known problems — only known solution is O(2ⁿ) in general
Unbounded Knapsack:
Each item can be picked any number of times
Rod Cutting is a special case: weight = length of cut, full capacity must be used
Coin Change is also a special case: capacity must be exactly filled
Bounded Knapsack:
Each item can be picked at most a fixed number of times
Requires an additional count array specifying how many times each item may be selected
Fractional Knapsack:
Fractions of items can be taken
Solvable greedily in O(n log n): sort by profit-to-weight ratio, pick greedily
Not a DP problem — but greedy only works when fractions are allowed
Maximize Number of Items (not profit):
Same recurrence, but replace value with 1 for each picked item
Drop the value array entirely; count of items is what gets maximized
All knapsack variants are longest path problems on a DAG
Each node in the recursion tree represents a subproblem state (n, T)
Picking an item = taking an edge with weight = item value
Not picking = taking an edge with weight = 0
DP avoids recomputing overlapping subproblems (same (n, T) appears multiple times in the recursion tree)
Memoization stores results indexed by (n, T) — a 2D table
Build a 2D table dp[i][w] = max profit using first i items with capacity w
Fill row by row: for each item i, for each capacity w from 0 to T
Two choices: skip item i → dp[i-1][w], or pick item i → V[i] + dp[i-1][w - W[i]]
Take the max of both choices
Time: O(n × T); Space: O(n × T), reducible to O(T) with row-rolling
K lists each of size n, one knapsack of capacity T
Select one item from each list to maximize total profit within capacity
Extends naturally — outer loop over lists, inner DP per list
Increasing number of lists increases dimensionality of the DP table
Given a chain of matrices A1, A2, ..., An, compute their product with minimum total multiplications
Matrix multiplication is associative but not commutative — different parenthesizations give same result but vastly different costs
Example: for matrices of dimensions 50×20, 20×1, 1×10, 10×100:
Order (((A×B)×C)×D) costs ~51,500 multiplications
Order (A×(B×(C×D))) costs only ~1,200 multiplications
Cost of multiplying an m×n matrix by an n×t matrix = m × n × t multiplications
Let C[i][j] = minimum cost to multiply the chain from matrix i to matrix j
Base case: C[i][i] = 0 (single matrix, no multiplication needed)
Recurrence: try all possible split points k between i and j:
C[i][j] = min over k of { C[i][k] + C[k+1][j] + m_{i-1} × m_k × m_j }
Where matrix Ai has dimensions m_{i-1} × m_i
The last term m_{i-1} × m_k × m_j is the cost of multiplying the two resulting subchains
Fill the table diagonally — subchains of length 1, then 2, then 3, up to n
Each entry depends only on smaller subchains (already computed)
Time: O(n³); Space: O(n²)
Bookkeeping array stores the optimal split point k for each (i, j) → enables reconstruction of optimal parenthesization
Every deep neural network layer is a matrix multiplication
A layer with p inputs and q outputs is represented as a p × q weight matrix
Stacking layers = chaining matrix multiplications
The constraint that output dimensions match input dimensions of the next layer is exactly the compatibility condition for matrix chain multiplication
Transformer attention, linear layers, and embeddings are all matrix multiplications under the hood
Choosing efficient computation orders in neural networks is the same optimization problem as matrix chain multiplication
Knapsack and all its variants reduce to longest/shortest path on a DAG
Overlapping subproblems are the signal to apply DP — memoize or fill bottom-up
Matrix chain multiplication shows that order of operations matters enormously — same algebraic result, wildly different computational cost
Both problems follow the same DP template: define subproblem, write recurrence, fill table, reconstruct solution via bookkeeping
All DP problems have an underlying DAG — recognizing it makes the solution structure clear
A greedy choice picks the locally best option at each step hoping it leads to a globally optimal solution
This is not always correct — greedy choices must be formally proven
Counter-example: coin change with denominations {1, 3, 4}, paying 6
Greedy (pick largest first): 4 + 1 + 1 = 3 coins
Optimal: 3 + 3 = 2 coins
Greedy failed because the locally best choice (4) blocked a globally better solution
Greedy algorithms are valid only when local optimality guarantees global optimality
This must be mathematically proven — intuition is not enough
Standard proof technique: proof by contradiction
Assume some other algorithm gives a better solution (call it OPT)
Show that our greedy solution is at least as good as OPT
This creates a contradiction — so no better solution exists
Without this proof, a greedy algorithm is just a guess
Problem: Items have weights and values; fractions of items can be taken; maximize total value within capacity T
Greedy Strategy: Sort items by value-to-weight ratio (v/w), pick in decreasing order; take a fraction of the last item if needed
Why greedy works here (proof sketch):
Suppose OPT picks a different first item j instead of our greedy item i
Since item i has the highest v/w ratio, replacing j's contribution with item i's contribution (same weight) gives equal or higher value
This does not reduce total value → our greedy solution is at least as good as OPT → contradiction with OPT being strictly better
Therefore greedy gives the optimal solution
Why greedy fails for 0/1 knapsack:
Without fractions, picking the highest ratio item may use capacity that could fit multiple smaller items with better combined value
Example: one item of weight 90, ratio 1000/90 ≈ 11.1 beats five items of weight 20, ratio 20 — but the five items fill capacity 100 with total value 100 vs single item value 1000 but leaves 10 capacity wasted
Fractions remove this problem — you always fill capacity exactly
Let GA = greedy algorithm's solution; OPT = any other claimed optimal solution
Sort both solutions by the same greedy criterion
Find the first position where they differ
Show that swapping OPT's choice with GA's choice at that position does not decrease the objective
Repeat until OPT is transformed into GA without loss → GA is optimal
Real-world scenario: connect n cities with roads such that every city is reachable, using minimum total road cost
Building all possible roads costs O(n²) — too expensive
Goal: find the minimum cost subset of edges that keeps the graph fully connected
Key observation:
A minimum cost road network cannot contain cycles
If a cycle exists, one edge in the cycle can be removed without disconnecting the network — reducing total cost
Therefore the minimum cost connected subgraph is always a tree
This is called the Minimum Spanning Tree (MST)
Greedy algorithms are powerful but require formal proof of correctness — never assume a greedy choice works
The standard proof strategy is by contradiction: assume a better solution exists, then show swapping toward the greedy choice doesn't hurt
Fractional knapsack is solvable greedily in O(n log n); 0/1 knapsack is not — it requires DP
Minimum Spanning Tree is a natural greedy problem — the MST algorithm and its proof will be covered in the next part of this lecture
Shortest path from a source yields a spanning tree (via parent pointers) — but it is not always the MST
Counter-example: three nodes connected by edges of cost 100, 100, and 1
Dijkstra picks both 100-cost edges → total cost 200
MST picks the 1-cost edge and one 100-cost edge → total cost 101
The objectives are fundamentally different: shortest path minimizes distance from source; MST minimizes total edge weight of the connected subgraph
A spanning tree of a graph G = (V, E) is a subset of n-1 edges that keeps all vertices connected with no cycles
Any two vertices in a spanning tree have exactly one unique path between them
A cut partitions the vertex set into two non-empty subsets S and V-S; the cut edges are all edges crossing between them
Removing all cut edges disconnects the two subsets
Among all edges crossing a cut, the minimum weight edge is called a safe edge
Adding the safe edge to the current partial MST is always a valid greedy choice
This is the foundational property that both Prim's and Kruskal's algorithms exploit
Proof sketch: if OPT chose a different crossing edge, swapping it for the safe edge cannot increase total cost → safe edge is at least as good
Strategy: Start from any vertex; repeatedly add the cheapest edge connecting the current tree to any unvisited vertex
Algorithm steps:
Initialize all vertex keys to ∞, source key to 0, all parents to Nil
Insert all vertices into a min-priority queue Q
While Q is not empty: extract the minimum key vertex u, add it to MST
For each neighbor v of u: if edge weight w(u,v) < v.key → update v.key = w(u,v) and v.parent = u
Key difference from Dijkstra:
Dijkstra: v.dist = u.dist + w(u,v) — accumulates distance from source
Prim's: v.key = w(u,v) — stores only the direct edge weight, no accumulation
This single line change converts Dijkstra into Prim's
Cycle prevention: once a vertex is extracted from Q, it is never reinserted — edges back into the already-built tree are ignored automatically
Time complexity: O(E log V)
Build heap: O(V)
V extractions at O(log V) each → O(V log V)
E relaxation calls at O(log V) each → O(E log V)
Total: O(E log V)
Strategy: Sort all edges by weight; greedily add the cheapest edge that connects two different components
Algorithm steps:
Initialize each vertex as its own singleton set (individual MST)
Sort all edges by weight in ascending order
For each edge (u, v) in sorted order:
If FindSet(u) ≠ FindSet(v) → safe edge found, include it; call Union(u, v)
If FindSet(u) == FindSet(v) → would create a cycle, skip it
Stop when n-1 edges have been selected
Example trace (from lecture):
Pick edge weight 2 → merges two singletons
Pick edge weight 3 → merges two other singletons
Pick edge weight 4 → extends a component
Pick edge weight 5 → extends further
Edge weight 8 → skipped (would create a cycle — both endpoints already in same component)
Kruskal's requires efficient set membership queries and merges — this is the Union-Find (Disjoint Set Union) data structure
Two operations:
MakeSet(x) → create a new singleton set containing x
FindSet(x) → return the representative (ID) of the set containing x
Union(x, y) → merge the two sets containing x and y; choose one representative for the combined set
Representative concept:
Each set has one designated representative (like a class president)
FindSet(x) returns this representative
If FindSet(u) == FindSet(v) → same set → edge would create a cycle → skip
If FindSet(u) != FindSet(v) → different sets → safe to add edge → call Union
Property
Prim's
Kruskal's
Strategy
Grow one tree from a source
Merge disjoint components
Data structure needed
Min-heap (priority queue)
Union-Find + sorted edges
Best for
Dense graphs
Sparse graphs
Time complexity
O(E log V)
O(E log E) = O(E log V)
Gradient descent in neural networks is also a greedy algorithm
At each step, compute the gradient (slope) in every dimension and move in the direction of steepest descent
The greedy choice: take the step that most reduces the loss locally
Whether this local greedy choice leads to the global optimum is the central question of optimization theory — exactly the same question we ask about greedy algorithms in general
MST is not a shortest path problem — the two problems have different objectives and different algorithms
Both Prim's and Kruskal's are greedy algorithms built on the safe edge / cut property
Prim's is essentially Dijkstra with one line changed — no accumulation of distances
Kruskal's requires Union-Find to efficiently detect cycles and merge components
The Union-Find data structure and its time complexity analysis will be covered in the next lecture
Start from any source vertex; set its key to 0, all others to ∞, all parents to Nil
Insert all vertices into a min-priority queue (min-heap)
Each iteration: extract the minimum key vertex u, add it to MST via its parent pointer
For each neighbor v of u (via adjacency list): if edge weight w(u,v) < v.key → update v.key = w(u,v) and v.parent = u
Key difference from Dijkstra — one line change:
Dijkstra: v.dist = u.dist + w(u,v) — accumulates path distance
Prim's: v.key = w(u,v) — stores only the local edge weight, no accumulation
This means Prim's is purely greedy on local edge costs, not cumulative distances
Cycle prevention:
Once a vertex leaves the heap, it is marked (boolean flag)
Any edge pointing back to an already-extracted vertex is skipped — it would create a cycle
Edges are only relaxed toward vertices still inside the heap
Adjacency list format: each entry is a pair (neighbor, weight) — the weight is the second element used in the relaxation condition
Time complexity: O(E log V)
Build heap: O(V)
V extractions × O(log V) each = O(V log V)
E relaxation updates × O(log V) each = O(E log V)
Dominant term: O(E log V)
Advanced note: Using a Fibonacci heap instead of a binary heap removes the log factor from updates, giving O(E + V log V) — not required but worth knowing
Sort all edges by weight in ascending order
Process edges one by one: include an edge (u, v) only if u and v belong to different components
Skip any edge that would create a cycle (same component)
Stop after selecting n-1 edges → MST complete
Correctness argument:
Each selected edge connects two disjoint MSTs — it is always a safe edge
Proof by contradiction: if any other algorithm chose a different crossing edge, swapping it for Kruskal's choice cannot increase total cost → Kruskal's is optimal
Competitive programming and exam problems will give a greedy problem and ask you to prove it is optimal
The proof obligation: show that local greedy choices lead to globally optimal solutions
Standard method: proof by contradiction — assume a better solution exists, then show swapping toward the greedy choice never worsens the result
Without this proof, a greedy algorithm is just a heuristic
Kruskal's needs to efficiently answer: do vertices u and v belong to the same component?
Two operations:
MakeSet(x) → initialize x as its own singleton set; x is its own representative
FindSet(x) → return the representative (root) of x's set; traverse parent pointers until reaching the root
Union(x, y) → merge the sets containing x and y; make one root the parent of the other
How it works conceptually:
Each set has one representative (like a class president)
FindSet(x) climbs the parent chain until it finds the root (node that is its own parent or has Nil parent)
If FindSet(u) == FindSet(v) → same component → adding edge (u,v) creates a cycle → skip
If FindSet(u) != FindSet(v) → different components → safe to add → call Union(u, v)
Why arbitrary union can be slow:
If always making the larger tree the child of the smaller, parent chains can grow to length n
In worst case (chain-like merges), FindSet costs O(n) per call
With e edges processed in Kruskal's, total cost could reach O(e × n) = O(n²)
The fix — Union by Size (or Rank):
Always attach the smaller tree under the larger tree
This bounds tree height at O(log n)
FindSet cost drops to O(log n) per call
Total Kruskal's complexity: O(E log E) = O(E log V)
Prim's and Dijkstra differ by exactly one line — understanding this connection deepens intuition for both
Prim's builds MST by greedily extending one tree; Kruskal's builds it by merging disjoint components
Union-Find is the critical data structure for Kruskal's — enables O(1) average cycle detection
Naive Union-Find (arbitrary parent choice) degrades to O(n²); Union by Size restores efficiency
Path compression (covered next lecture) further improves Union-Find to nearly O(1) per operation
Naive union (arbitrarily choosing which set becomes child) can create a chain-like linked list structure
In the worst case, FindSet must traverse all n nodes → O(n) per call
With e edges in Kruskal's, total cost becomes O(e × n) = O(n²) — unacceptably slow
Rank = an upper bound on the height of the tree rooted at a vertex (starts at 0 for singletons)
When merging two sets, always attach the smaller rank tree under the larger rank tree
The rank of the resulting root increases only when both trees have equal rank — a rare event
This bounds the height of any tree to O(log V)
FindSet cost per call: O(log V)
Total Kruskal's cost: O(E log E) = O(E log V)
Why equal rank is the only case that increases height:
If ranks differ, the smaller tree joins the larger → no height increase for the root
If ranks are equal, one becomes root of the other → height increases by exactly 1 → rank incremented by 1
This can happen at most log V times per element before ranks diverge
When traversing the parent chain during FindSet(x), repoint all visited nodes directly to the root
On the way up: collect all nodes traversed into a list; once root is found, set every node's parent directly to the root
Future FindSet calls on any of those nodes cost O(1)
This is predicting the future — paying a small cost now to make all future calls extremely cheap
Combined result (Union by Rank + Path Compression):
Total cost for e operations: O(e × log* V)
log* V = iterated logarithm = how many times you must apply log before the value drops to ≤ 1
For any practical input size (even the number of atoms in the universe), log* ≤ 5
For all practical purposes, this is essentially O(e) — nearly linear
Kruskal's final time complexity:
Sorting edges: O(E log E)
Union-Find operations: O(E × log* V)
Total: O(E log E) = O(E log V)
Class P (Polynomial Time):
Problems solvable by an algorithm in O(n^k) time for some constant k
Called tractable problems — feasible to solve in practice
Examples covered in this course: sorting (O(n log n)), shortest path (O(E log V)), MST (O(E log V)), matrix multiplication (O(n³))
All problems seen so far are in class P
Why polynomial vs exponential matters enormously:
A computer doing 100 million operations/second on an O(n²) problem: handles ~10,000 inputs per second
Making the machine 100× faster: handles only ~10× more inputs (square root improvement)
On an O(2ⁿ) problem: handles only 26 inputs per second
Making the machine 100× faster: handles only 26 + 6 = 32 inputs — just 6 more!
Making the machine 1000× faster: handles only ~10 more inputs
The gap between polynomial and exponential is not small — it is civilizational
Problems where a proposed solution can be verified in polynomial time
Finding the solution may be unknown or exponential — but checking a given answer is fast
NP automatically includes all of P (if you can solve it in polynomial time, you can certainly verify it)
Examples:
SAT (Satisfiability): given a Boolean formula, is there an assignment of variables making it true? → Given an assignment, verify in O(n) by evaluating the expression
Factorization: is n = p × q? → Given p and q, multiply and check in O(log n) time
Eulerian circuit: does a given traversal visit every edge exactly once? → Check in O(E) time
NP-Complete: a problem is NP-complete if it satisfies both:
It is in NP (a verifier exists)
Every problem in NP is polynomial-time reducible to it
What Cook and Levin proved (independently):
SAT is NP-complete
If SAT can be solved in polynomial time → every NP problem can be solved in polynomial time → P = NP
This was one of the most important theorems of the 20th century in computer science
Implication for proving NP-completeness of new problems:
To prove a new problem B is NP-complete, two steps suffice:
Show B is in NP (give a polynomial-time verifier)
Show that SAT (or any known NP-complete problem) reduces to B in polynomial time
If B can be solved in polynomial time, that would solve SAT, which would solve all NP problems
Therefore B is at least as hard as every NP problem → B is NP-complete
The central unsolved question: does P = NP?
No one has proven P = NP (found polynomial algorithms for NP-complete problems)
No one has proven P ≠ NP (ruled out the possibility entirely)
This is one of the Millennium Prize Problems — worth $1 million if solved
Most researchers believe P ≠ NP, but no proof exists
The next lecture will cover how to formally prove NP-completeness reductions
Union by Rank prevents degenerate chains; Path Compression amortizes future lookups to near O(1)
Combined, Kruskal's runs in O(E log V) — practical for real networks
P problems are tractable; NP problems have verifiable solutions but possibly intractable finding algorithms
Exponential vs polynomial is not a small difference — it is the difference between feasible and impossible
Cook-Levin: SAT is the universal hardest NP problem — all NP problems reduce to it
Understanding NP-completeness is essential for recognizing when a problem likely has no efficient algorithm
Given a text of length m and a pattern of length n, slide the pattern across the text one position at a time
At each position, compare up to n characters → worst case O(m × n)
On average: pattern matches about halfway before mismatch → roughly O(m × n/2) in practice
Goal: find all occurrences of a pattern in a text in O(m + n) time
Key insight: when a mismatch occurs, the naive approach discards all comparison progress and restarts from scratch — KMP reuses already-matched characters
LPS = Longest Prefix that is also a Suffix
For each position i in the pattern, compute the length of the longest proper prefix of pattern[0..i] that is also a suffix of pattern[0..i]:
Single character → LPS = 0 (no non-trivial prefix/suffix)
If prefix and suffix differ at position → LPS = 0
Example for pattern ABABCABABAB:
A → 0, AB → 0, ABA → 1, ABAB → 2, ABABC → 0
ABABCA → 1, ABABCAB → 2, ABABCABA → 3, ABABCABAB → 4
Construction: scan left to right; either extend the previous match by 1 or fall back using the previous LPS value → O(n) time
Maintain two pointers: i (position in text), j (position in pattern)
If text[i] == pattern[j]: advance both
If mismatch and j > 0: set j = LPS[j-1] — do not move i — reuse already-matched prefix
If mismatch and j == 0: advance i only
When j reaches pattern length: match found; set j = LPS[j-1] to find next potential match
Why it works: LPS[j-1] tells how many characters at the start of the pattern already match the tail of what has been processed — no need to re-examine them
Time complexity: O(m + n) — i never moves backward; j can only decrease as much as it has increased
Applications: forensics/fingerprint matching, text editors (Ctrl+F), DNA sequence search, pattern detection in log files
A complete binary tree stored compactly in an array — no pointers needed
Index arithmetic for zero-indexed array:
Left child of i: 2*i + 1
Right child of i: 2*i + 2
Parent of i: (i-1) / 2
Each formula is a single arithmetic operation → O(1)
Max-heap property: every node's value ≥ both its children's values
Heapify-Up (O(log n)):
Used after inserting a new element at the end
Compare with parent; if larger, swap and recurse upward
Height of complete binary tree = log n → at most log n swaps
Heapify-Down (O(log n)):
Used after extracting the max (swap root with last, shrink size)
Compare root with larger child; if smaller, swap and recurse downward
Assumes subtrees below are already valid heaps
Extract-Max (O(log n)):
Swap root with last element, decrement size, call Heapify-Down on root
Naive approach: insert elements one by one, calling Heapify-Up each time → O(n log n)
Smarter approach (Floyd's algorithm):
All leaf nodes (bottom half of array) are already valid heaps — skip them
Start from the last internal node (index n/2 - 1) and call Heapify-Down going upward
Why this is O(n):
Bottom level: n/2 nodes, 0 levels to sink → cost 0
Next level up: n/4 nodes, 1 level max → total n/4
Next: n/8 nodes, 2 levels max → total n/4
Summing all levels forms a geometric series → total sum ≤ n
Therefore Build-Heap runs in O(n)
Call Build-Heap: O(n)
Repeat n times: Extract-Max (O(log n)) and place at the end of the array
Result: sorted array in-place, no extra space needed
Total: O(n log n) — same asymptotic complexity as Merge Sort but in-place like Quick Sort
std::priority_queue in the STL is nothing but a max-heap
For custom data types, overload the < operator or provide a comparator class
Comparator class has operator() returning bool with two arguments
Using > in comparator creates a min-heap; using < creates a max-heap
Essential for Dijkstra's, Prim's, and any algorithm requiring repeated minimum/maximum extraction
Problem: 10 GB of data on disk, only 1 GB of RAM → cannot load everything at once
Algorithm (External Merge Sort):
Phase 1 — Create sorted chunks:
Load RAM-sized chunks (e.g., 10 MB each) one at a time
Sort each chunk in RAM using any in-memory sort
Write sorted chunk to a temporary file (chunk1.dat, chunk2.dat, ...)
Repeat until all data is processed → ~1000 sorted files for 10 GB / 10 MB
Phase 2 — k-way merge using priority queue:
Create one file reader per chunk file
Insert the first element from each reader into a min-priority queue as a (value, reader) pair
Repeatedly extract the minimum, write it to the output file, then read the next element from the same reader and re-insert
When a reader is exhausted (chunk finished), do not re-insert
Continue until the priority queue is empty → fully sorted output file
Buffering optimization: accumulate ~4 KB of output before writing to disk to reduce I/O cost (write in page-aligned blocks)
Time complexity: O(N log k) where N = total records and k = number of chunks
KMP eliminates redundant comparisons using the LPS table — one of the most elegant uses of DP in string algorithms
Binary heaps store trees in arrays with no pointers — index arithmetic replaces pointer traversal
Build-Heap is O(n) not O(n log n) — a non-obvious result requiring geometric series analysis
Heap Sort is the only O(n log n) in-place comparison sort (besides Quick Sort which is average case)
External sorting shows that priority queues solve problems beyond simple in-memory algorithms
Class P: problems solvable in polynomial time O(n^k) — sorting, shortest path, MST, matrix multiplication
Class NP: problems whose solutions can verified in polynomial time — P ⊆ NP automatically
NP-Hard: every problem in NP is polynomial-time reducible to it — if solved in polynomial time, all NP problems collapse to polynomial
NP-Complete: both in NP and NP-Hard — the intersection that represents the hardest verifiable problems
To prove a problem A is NP-Complete, prove two things:
A is in NP: give a polynomial-time verifier — given a proposed solution, check it efficiently
A is NP-Hard: show that some known NP-Complete problem (e.g., 3-SAT) reduces to A in polynomial time
Why step 2 is powerful: if A can be solved in polynomial time, then the reduction shows all NP problems can too → P = NP
The Cook-Levin theorem established SAT (and then 3-SAT) as the first NP-Complete problem, opening a whole field of reductions
YES/NO 3-SAT: given a 3-SAT formula, simply answer "yes" (satisfying assignment exists) or "no" — no assignment required
Proof (3-SAT ≤_p YES/NO 3-SAT):
Assume a hypothetical algorithm H solves YES/NO 3-SAT in polynomial time
Use H to solve full 3-SAT as follows:
Ask H: is the formula satisfiable? If No → done
Fix x1 = True, simplify formula (remove satisfied clauses, reduce others) → O(n) conversion
Ask H again: is the simplified formula still satisfiable?
If Yes → x1 = True is consistent; keep it, move to x2
If No → x1 must be False; set x1 = False, repeat
Repeat for each of the n variables
Time complexity: n variables × O(n) per simplification × one H call per step → O(n² × T(H))
If H runs in polynomial time, total is polynomial → 3-SAT is solved in polynomial time
This is a lock-picking argument — each pin position is determined one by one without trying all combinations
Independent Set: find a set of K vertices in a graph where no two are adjacent
Proof sketch (Independent Set ≤_p YES/NO Independent Set):
Assume algorithm H answers YES/NO for independent set of size K
Algorithm to find the actual set:
Ask H: does an independent set of size K exist? If No → done
Pick any vertex v; remove it and all its edges from G to get G'
Ask H on (G', K-1): does G' have an independent set of size K-1?
If Yes → v is in the solution; recurse on (G', K-1)
If No → v is not in solution; try next vertex
Repeat until K vertices are found
Removing a vertex from adjacency matrix: delete one row and one column → O(V²)
Total: V calls × O(V²) per call → O(V³) — polynomial
Longest path in a DAG is solvable in polynomial time via topological sort + DP
Longest path in a general graph is NP-Hard
Hamiltonian path: a path visiting every vertex exactly once — equivalent to a longest path of length n-1
If you can solve Longest Path in a general graph, Hamiltonian Path is solved automatically
Therefore Hamiltonian Path is NP-Complete (verifiable in polynomial time, and equivalent to Longest Path which is NP-Hard)
TSP: given a weighted complete graph, find the shortest Hamiltonian cycle (visit every vertex exactly once and return to start)
Two variants:
TSP-T (decision version): does there exist a Hamiltonian cycle with total weight ≤ T?
This is in NP — given a proposed cycle, verify it visits all vertices once and sum weights ≤ T → O(n) verification
This is NP-Complete
General TSP (optimization version): find the minimum weight Hamiltonian cycle
This is NP-Hard but NOT in NP
Why not in NP? Even if someone gives you a cycle, you cannot verify in polynomial time that it is the minimum without checking all other cycles
Verification of optimality requires exponential time
This is a concrete example of a problem that is NP-Hard but outside NP entirely
P ⊆ NP-Complete ⊆ NP
NP-Hard problems may or may not be in NP
NP-Complete = NP ∩ NP-Hard
General TSP sits outside NP (harder than NP-Complete)
TSP-T, 3-SAT, Independent Set, Clique, Set Cover, Hamiltonian Path are all NP-Complete
Proving NP-Completeness always requires two steps: membership in NP (give verifier) and NP-Hardness (give reduction from known NP-Complete problem)
Reductions are constructive — a polynomial algorithm for the new problem immediately gives a polynomial algorithm for the old one
The lock-picking analogy: fixing one variable at a time reduces exponential search to polynomial sequential decisions
Longest path on DAGs is easy (O(V+E)); on general graphs it is NP-Hard — the graph structure matters enormously
TSP is the canonical example of a problem harder than NP-Complete — optimization versions often escape NP entirely
Hamiltonian path is your assignment — read the reduction proof and reproduce it
Cross product A × B = all ordered pairs (a, b) where a ∈ A and b ∈ B; size = |A| × |B|
Power set of A = set of all subsets; size = 2^|A|
Relation R on sets A and B = any subset of A × B
Set of all possible relations on A = power set of A × A → size = 2^(n²)
Function = special relation where each domain value maps to exactly one range value — isPrime is a function; a mapping with one input giving two outputs is not
Relation properties:
Reflexive: (a, a) must be in R for every element a — the entire diagonal of the matrix must be filled
Symmetric: if (a, b) ∈ R then (b, a) ∈ R — matrix equals its own transpose
Antisymmetric: if (a, b) ∈ R and (b, a) ∈ R then a = b — no off-diagonal pair can appear with its reverse
A relation containing only diagonal entries is simultaneously symmetric and antisymmetric — an important edge case
Connection to databases: table joins produce relations; WHERE clauses project them — relational algebra directly implements these concepts
A graph is nothing but a relation represented as an adjacency matrix
Entry (i, j) = 1 means a 1-hop path exists from i to j
R² = R ∘ R: if (a, b) ∈ R and (b, c) ∈ R then (a, c) ∈ R² — all 2-hop paths
R³: all 3-hop reachability; R^k: all k-hop reachability
The full reachability (transitive closure) is the union of R¹ through R^(n-1)
Computing this is equivalent to computing the Floyd-Warshall transitive closure
Matrix multiplication with + replaced by min gives shortest distances instead of reachability
Goal: compute shortest path distance between every pair of vertices simultaneously
Why useful: Google Maps, routing protocols, all-source planning
Baseline approaches (run single-source V times):
Graph type Best single-source algorithm All-pairs total cost
Unweighted BFS — O(V+E) O(V(V+E))
Weighted, positive Dijkstra — O(E log V) O(VE log V)
Weighted, negative Bellman-Ford — O(VE) O(V²E) = O(V⁴) for dense graphs
Goal: beat V × Bellman-Ford with a smarter all-pairs algorithm
Key insight: replace + with min and × with + in matrix multiplication
Standard matrix multiplication: C[i][j] = Σ A[i][k] × B[k][j] Modified (shortest path): D[i][j] = min over all k of (D_prev[i][k] + W[k][j])
Initialize: diagonal = 0, all off-diagonal = ∞ (or actual edge weights)
Each matrix "multiplication" extends paths by one more hop
Computing L^m gives all shortest paths using at most m hops
Need L^(n-1) to capture all possible shortest paths
Naive approach: compute L¹, L², ..., L^(n-1) one by one → O(n⁴)
Fast matrix exponentiation (repeated squaring):
To compute L^n: compute L^(n/2), then square it → only log n matrix multiplications
Each matrix multiplication costs O(V³)
Total: O(V³ log V)
Same divide-and-conquer idea as fast integer exponentiation
Strassen connection: standard matrix multiplication is O(n³) but Strassen's algorithm (divide-and-conquer on sub-matrices) achieves O(n^2.81); best known today is roughly O(n^2.37)
Key insight: restrict which intermediate vertices are allowed, and build up the solution
DP state definition:
d[u][v][k] = weight of shortest path from u to v using only vertices {1, 2, ..., k} as intermediate nodes
Recurrence:
Either vertex k is not used → d[u][v][k] = d[u][v][k-1]
Or vertex k is used → d[u][v][k] = d[u][k][k-1] + d[k][v][k-1]
Take the minimum of the two cases
Base case: k = 0 → d[u][v][0] = direct edge weight W[u][v] (or ∞ if no edge)
Answer: d[u][v][n] for all pairs u, v
Time complexity: three nested loops over u, v, k → O(V³) — removes the log factor from the matrix exponentiation approach
Negative cycle detection: if any d[v][v][n] < 0 after running the algorithm, a negative cycle exists through v
Connection to transitive closure: the same recurrence with boolean OR instead of min gives the transitive closure (reachability) matrix
Problem: Given three points A, B, C, determine whether going from vector AB to vector AC is a clockwise or counterclockwise rotation
Expensive approach (not used):
Compute dot product → get angle → take cos inverse
Involves floating point division, square roots, and inverse trigonometric functions — computationally heavy
Elegant approach — Cross Product Sign:
Represent the two vectors as 2D vectors from the shared origin
Compute the 2×2 determinant (the z-component of the 3D cross product):
Vector 1: (bx - ax, by - ay)
Vector 2: (cx - ax, cy - ay)
Value = (bx - ax)(cy - ay) - (by - ay)(cx - ax)
Interpretation of the sign:
Value > 0 → counterclockwise (left turn)
Value < 0 → clockwise (right turn)
Value = 0 → collinear (all three points on the same line)
Why this is superior:
No division, no trigonometry, no floating point issues
Pure integer arithmetic — O(1) constant time
The sign of the cross product z-component directly encodes the orientation
Setup: given n points in 2D, find the convex hull — the smallest convex polygon enclosing all points
Rubber band analogy: stretch a rubber band around all the points and release it — where it snaps to is the convex hull
Key property of convex shapes: any line segment between two points inside or on the boundary lies entirely within the shape — this is why convex shapes are ideal for collision detection, coverage, and optimization
Output: a subset of the input points listed in clockwise (or counterclockwise) order that forms the boundary polygon
Step 1 — Anchor point: find the point with the minimum y-coordinate (bottom-most point) → O(n)
Step 2 — Sort by polar angle: compute the orientation value (cross product z-component) of every other point relative to the anchor and a horizontal reference ray; sort all points by this value → O(n log n)
After sorting, points are arranged in counterclockwise angular order around the anchor
Step 3 — Build hull using a stack:
Push the first three sorted points onto a stack (they form a base triangle)
For each remaining point p in sorted order:
While the top two points on the stack and p form a clockwise turn (right turn):
Pop the top of the stack (that point is not on the hull)
Push p onto the stack
Why clockwise turns are removed: a right turn means the current top point is "inside" the correct hull boundary — it creates a concavity that must be eliminated
Amortized analysis: each point is pushed at most once and popped at most once → the main loop runs in O(n) total after sorting
Total time complexity: O(n log n) — dominated by the sorting step
Core idea: repeatedly find the point that makes the smallest counterclockwise angle from the current direction — like wrapping a string around the point set
Algorithm:
Start from the bottom-most point (minimum y)
From the current point, find the point q that minimizes the orientation value across all remaining points — this is the "most clockwise" point
Add q to the hull; make q the new current point
Repeat until returning to the start
Finding the minimum orientation each step: scan all n points, compute orientation with the current direction, pick the minimum → O(n) per step
Time complexity: O(n × h) where h = number of hull vertices
Comparison with Graham Scan:
Graham Scan is always O(n log n) regardless of hull size
Jarvis March is O(n × h) — better when h is very small (e.g., h = 3 or 4 points on the hull)
For large h (h ≈ n), Jarvis March degrades to O(n²)
Problem: given a convex hull and a query point P, determine if P is inside or outside
Key insight: if P is inside the convex hull, then for every consecutive edge of the hull, P should lie on the same (left) side — i.e., every orientation check should give the same sign
Algorithm:
Traverse the hull edges in order
For each edge (from hull[i] to hull[i+1]), compute orientation with P
If all orientations are counterclockwise (positive) → P is inside
If any orientation is clockwise (negative) → P is outside
Time complexity: O(n) — one orientation check per hull edge
Faster version using binary search: since the hull is sorted, binary search can reduce this to O(log n) for a pre-built hull — directly applicable to collision detection in games
Unity's collider system is essentially a convex hull around each game object
Checking whether two objects have collided = checking if any point of one hull is inside the other hull
Without a game engine, implementing collision detection requires exactly this: build the convex hull of each object, then run the point-in-hull test
The fact that no interviewees at PITB could answer this question from first principles highlights why understanding the underlying geometry matters
Computing a^p naively requires p-1 multiplications → O(p) time
Divide and conquer insight:
If someone gives you a^(p/2), you can square it to get a^p — halving the work
Repeat recursively: compute a^(p/4), square it to get a^(p/2), square again to get a^p
Recursive algorithm:
If p == 0: return 1
If p is even: return square of a^(p/2)
If p is odd: return a × square of a^(p/2) (integer division handles the floor)
Time complexity: each step halves p → O(log p) multiplications
Why this matters:
Computing 8^(10^1000) naively: requires 10^1000 multiplications — more operations than atoms in the universe, takes ~10^970 centuries on a supercomputer
Using fast powering: requires ~3200 multiplications — done in under a second
This is one of the two most important algorithms in cryptography (along with GCD)
Goal: Alice and Bob communicate over a public channel (everyone can see their messages) but only they can understand the content — this is public key cryptography
Historical ciphers:
Substitution ciphers: shift each letter by a fixed amount → easily broken by trying all 26 shifts or using letter frequency analysis
Enigma machine (WWII): mechanical cipher used by Germany and Japan; broken by Turing — ships' crews were ordered to destroy the machines before capture
Modern cryptography is built entirely on number theory, particularly divisibility and modular arithmetic
Divisibility: B divides A (written B | A) if there exists an integer C such that A = B × C
Key properties (all provable from the definition):
Transitivity: if A | B and B | C then A | C
Linearity: if A | B and A | C then A | (B + C) and A | (B - C)
Modular congruence: a ≡ b (mod m) if m | (a - b)
Example: 3 ≡ 22 (mod 19) because 3 - 22 = -19, which is divisible by 19
Congruence classes: all integers congruent to 3 mod 19 form the set {3, 22, 41, ..., -16, -35, ...}
Important: -1 ≡ 18 (mod 19) — negative numbers have positive representatives in modular arithmetic
Key property: if a₁ ≡ a₂ and b₁ ≡ b₂ (mod m), then (a₁ + b₁) ≡ (a₂ + b₂) and (a₁ - b₁) ≡ (a₂ - b₂) (mod m)
Additive identity: 0 (adding 0 leaves any number unchanged) Multiplicative identity: 1
Additive inverse of a in mod m: simply m - a (always easy to compute)
Multiplicative inverse of a in mod p: a number x such that a × x ≡ 1 (mod p)
In a large 1000-digit modulus, building the multiplication table to find inverses is computationally infeasible
The elegant solution uses Fermat's Little Theorem
Fermat's Little Theorem: for any prime p and any number a not divisible by p:
a^(p-1) ≡ 1 (mod p)
Observation from the power table (mod 7):
Any number raised to the p-1 power always gives 1
Some numbers are generators: their successive powers cycle through all non-zero elements {1, 2, 3, ..., p-1}
In a prime field, there are many generators; a randomly chosen element is a generator with high probability
Finding the multiplicative inverse using Fermat:
a^(p-1) ≡ 1 (mod p)
Therefore a × a^(p-2) ≡ 1 (mod p)
So the multiplicative inverse of a is a^(p-2) — computable in O(log p) via fast powering
Primality testing: if a^(p-1) ≡ 1 (mod p) for random a, the number p is very likely prime (Fermat primality test) — with rare exceptions called Carmichael numbers
Problem: Alice and Bob want to create a shared secret over a public channel without anyone else being able to reconstruct it
Color mixing analogy:
Public color: shared and visible to all
Each person mixes in their private secret color
They exchange the mixed colors publicly
Each person then mixes in their own private color again → both arrive at the same three-way mixture
An eavesdropper has both mixed colors but cannot extract the private components (no un-mixing mechanism)
Protocol:
Alice and Bob publicly agree on a large prime p and a generator g (both known to everyone)
Alice picks secret a; computes A = g^a mod p; publishes A
Bob picks secret b; computes B = g^b mod p; publishes B
Alice computes B^a = g^(ab) mod p
Bob computes A^b = g^(ab) mod p
Both have the same shared secret g^(ab) mod p
Security: knowing A = g^a and g, recovering a requires solving the discrete logarithm problem — computationally infeasible for 1000-digit primes (would take centuries even on a supercomputer)
Goal: not just a shared secret, but actual message passing where only Alice can decrypt Bob's message
Setup:
Public parameters: large prime p, generator g (both public)
Alice generates secret a; publishes A = g^a (her public key)
Bob encrypts message M:
Picks random secret b
Computes the Diffie-Hellman shared secret: K = A^b = g^(ab)
Sends ciphertext pair: (C1, C2) = (g^b, M × K mod p)
Alice decrypts:
Receives (C1, C2) = (g^b, M × g^(ab))
Computes C1^a = g^(ab) — the same shared secret
Computes the multiplicative inverse of g^(ab) using: (g^(ab))^(p-2) via fast powering
Multiplies C2 × inverse(g^(ab)) → only M remains
Why it works: M × g^(ab) × (g^(ab))^(-1) = M — the shared secret cancels out, leaving only the message