Algorithms Notes
Algorithms Notes
Author: TickQ
Date: November 13, 2024
Version: 1.0
Let’s get 4K
Contents
Chapter 1 Complexity 1
1.1 Time Complexity . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1
1.2 Recurrence Relation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2
1.3 Telescoping . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2
1.4 Master Theorem . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4
1.5 Space Complexity . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6
Chapter 2 Correctness 8
2.1 Loop Invariant & Termination . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 8
2.2 Tips . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 9
Chapter 11 Hashing 61
11.1 Collision Resolution . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 61
11.2 Perfect Hash Function . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 62
Chapter 13 Revision 66
ii
Chapter 1 Complexity
There are many ways to solve problems using different algorithms in Computer Science, but how do we
identify which is better than the other? Does one work better in a specific situation? Therefore, we have two
methods to evaluate and compare which algorithm is more optimal. The two methods used are time complexity
and space complexity. In short words:
Time Complexity: The time taken by an algorithm to run based on input
Space Complexity: The amount of memory needed by the algorithm to solve given problem. We will
determine input space complexity, auxiliary space complexity and total space complexity.
1.1.1 Example 1
for i in range(n):
print("Hello")
In this example, we have a for loop that will loop for n times, print ”Hello” for n times and halts. So the time
complexity is O(n).
1.1.2 Example 2
for i in range(n):
for j in range(n/2):
print("Hello")
n
In this example, we have a for loop that will loop for n times, and we have another inner loop that loops for 2
2
times. n ∗ ( n2 ) is n2 so the complexity is still O(n2 )
1.1.3 Example 3
def factorial(n):
if n == 0:
return 1
return n * factorial(n-1)
1.2 Recurrence Relation
In this example, we have a function to compute the factorial of an input n. The base case is 0, and it will
recursively call factorial until it reaches the base case. Observe that this function will call itself for n times
recursively because of factorial(n-1). So the complexity is O(n)
1.1.4 Example 4
Observe that only n will determine how many times the function will call itself, we can see that n is divided by
two every time, so this function will be O(logN )
a, b and c all means constant operations. As we can see, return a constant number or perform multiplications are
constant. With recurrence relationship, we can analyse the time complexity easily. The two ways of analysing
complexity are telescoping and master theorem.
1.3 Telescoping
1.3.1 Example 1
Given recurrence relationship of
T(0) = a
T(1) = b
T(N) = T(N-1) + c
We know that T(N-1) will move towards the base case, now we will move on to next step. If we want to find
T(N-1), just sub N-1 into the function. Then it will be T(N-1) = T(N-2) + c, we can then substitute T(N-1) into
T(N)
T(N-1) = T(N-2) + c
T(N) = T(N-1) + c
T(N) = [T(N-2) + c] + c
2
1.3 Telescoping
If we go on
T(0) = a
T(1) = b
T(N) = [[T(N-3) + c] + c] + c
We can observe a pattern here, there are 3 c’s and it is N-3. If we go on to the next level, it will be 4 c’s and N-4,
so we can change the formula to
T(0) = a
T(1) = b
T(N) = T(N-k) + kc
Now, we just need to make T(N-k) the base case. For it to reach the base case, it must be either T(0) or T(1), let’s
choose 0
N-k = 0
k = N
T(N) = T(N-N) + Nc
= a + Nc
1.3.2 Example 2
T(0) = a
T(1) = b
T(N) = T(N//2) + c
T(N//2) = T(N//4) + c
T(N) = [T(N//4) + c] + c
T(N//4) = T(N//8) + c
T(N) = [[ T(N//8) + c] + c] + c
T(N) = T(N//(2^k)) + kc
N//2^k = 1
N = 2^k
log_2(N) = log_2(2^k)
k = log_2(N)
N
The difference here is that we need to use base case 1 instead of 0, because we can’t find an answer for 2k
= 0.
So the complexity will be O(logN )
3
1.4 Master Theorem
1.3.3 Example 3
T(0) = a
T(N) = T(N-1) + cN^3
N-k = 0
N = k
If your confused on how it became N 4 , just see it as we have N 3 , and we are adding N of them together, so
N ∗ N 3 = N 4 . So the complexity will be O(N 4 )
1.3.4 Tips
Sometimes you don’t have to work everything out, we can figure out the complexity half way through
when we know that some will definitely dominate the other
( )
T (N ) = a T Nb + f (N )
f (N ) = O(N k · logp N )
logb a > k: O(N logb a )
logb a = k
p > −1: O(N k · logp+1 N )
p = −1: O(N k · log log N )
p < −1: O(N k · 1)
logb a < k
p > −1: O(N k · logp N )
p = −1: O(N k · 1)
p < −1: O(N k · 1)
After memorizing/used to it, we can figure out the complexity in 5 seconds.
4
1.5 Space Complexity
1.4.2 Example 1
T(0) = a
T(1) = b
T(N) = T(N//2) + c
k = 0
p = 0
a = 1
b = 2
log_2(1) = 0
0 = 0
--> Go to second case
p > -1
--> Go to first case
= O(N^0 log^(0+1)N)
1.4.3 Example 2
T(0) = a
T(1) = b
T(N) = 2T(N//2) + n^2c
k = 2
p = 0
a = 2
b = 2
log_2(2) = 1
1 < 2
--> Go to third case
p > -1
--> Go to first case
= O(N^2 log^(0)N)
5
1.5 Space Complexity
In our previous factorial function, the input space is O(1) because n is essentially a positive number.
[Link] Example 1
for i in range(n):
for j in range(n/2):
print("Hello")
This example has O(1) auxiliary space, because we are simply creating two new variables i and j
[Link] Example 2
This example has O(N ) auxiliary space, because we have created a list of N None.
6
1.5 Space Complexity
[Link] In-place
An in-place algorithm means that we have O(1) auxiliary space. It doesn’t guarantee that we are not using
extra space, but it guarantees that we are only using constant, O(1) space. For example, when we are running
selection sort, we have two loops, and we will have a min variable to figure out the minimum element in every
iteration. min is using extra space but it is constant, so selection sort is a in-place algorithm.
Let’s look back at the factorial example. Is this an in-place algorithm?
def factorial(n):
if n == 0:
return 1
return n * factorial(n-1)
The answer is No! When we perform recursion, each call is stored in the recursion stack, so we are using extra
spaces. In this case, the function will have O(logN ) auxiliary space because we will have logN calls until we
have reached the base case. So anything involving recursion is not in-place.
The answer is No! Time complexity can never be lower than space complexity, because we need more or equal
time to create the extra spaces/process the input items. So time complexity must ≥ space complexity
7
Chapter 2 Correctness
When we write an algorithm, how do we determine if it is correct or not? There are two proofs of correct-
ness: loop invariant and termination. Recall that an algorithm must halt for any input.
2.1.1 Example 1
def selection_sort(my_list):
# Loop entry Invariant
for i in range(len(my_list)):
# Start Invariant
smallest = i
for j in range(i+1, len(my_list)):
if lst[j] < smallest:
smallest = lst[j]
my_list[i], my_list[smallest] = my_list[smallest], my_list[i]
# Maintenance Invariant
# End Invariant
Loop entry invariant: my_list[0..-1] is sorted. Since [0..-1] is just an empty list, it is indeed sorted
Start invariant: my_list[0..i-1] is sorted
Maintenance invariant: my_list[0..i] is sorted
End invariant: my_list[0..n-1] is sorted
2.1.2 Example 2
def palindrome(word):
left = 1
right = len(word)
# Loop entry Invariant
2.2 Tips
Loop entry invariant: word[0..left-1] is the same as word[right+1..n-1]. True because they are both empty
list
Start invariant: word[0..left-1] is the same as word[right+1..n-1]
Maintenance invariant: word[0..left] is the same as word[right..n-1]
End invariant: word is a palindrome
2.2 Tips
Loop entry invariant is usually [0..-1] or [1..-1]
Start invariant usually involves [0..i-1] or [1..i-1] or [i+1..n], because we haven’t processed the i-th one
Maintenance invariant usually involves [0..i] or [1..i] or [i..n] because we have processed the i-th one
End invariant is usually [0..n] or [1..n]
It doesn’t matter if it starts from 0 or 1, because we are not writing code, anything reasonable should be
fine
9
Chapter 3 Sorting Algorithms
Some of the sorting algorithms we have learned: selection sort, bubble sort, insertion sort and heap sort.
All of these are comparison based sorting algorithms, and we do have some non-comparison based sorting
algorithms which are counting sort and radix sort.
Name Best Case (Time) Worst Case (Time) Auxiliary Space Is Stable
Bubble Sort O(N ) O(N 2 ) O(1) Yes
Selection Sort O(N 2 ) O(N 2 ) O(1) No
Insertion Sort O(N ) O(N 2 ) O(1) Yes
Heap Sort O(N ) O(N log N ) O(1) No
Table 3.1: Comparison Based Complexity Table
numbers = [9, 3, 7, 2, 4, 2, 1]
# Step 1
biggest_num = 9
# Step 2
count_array = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
3.3 Stable Counting Sort
# Step 3
count_array = [0, 1, 1, 1, 1, 0, 0, 1, 0, 1]
# Step 4
numbers = [1, 2, 3, 4, 7, 9]
Code
def counting_sort(numbers):
# Step 1: O(N)
biggest_num = max(numbers)
# Step 2: O(M)
count_array = [0 for _ in range(biggest_num+1)]
# Step 3: O(N)
for i in range(len(numbers)):
count_array[numbers[i]] += 1
# Step 4: O(N+M)
sorted_numbers = []
for i in range(len(count_array)):
for j in range(len(count_array[i])):
sorted_numbers.append(i)
return sorted_numbers
11
3.3 Stable Counting Sort
a and b are just to distinguish which comes first. Using this approach, we can make counting sort stable.
Code
def counting_sort(numbers):
...
count_array = [[] for _ in range(biggest_num+1)]
...
for i in range(len(numbers)):
# Only difference
count_array[numbers[i]].append(numbers[i])
...
The time complexity is also the same. But for space complexity, the auxiliary space becomes O(N +M ) because
now we are storing a list of item, not number of occurrences.
12
3.4 Radix Sort
Let’s say we want to sort with base 10. As shown in Figure 3.2, we have 5 items and the biggest number is
892. We will figure out k using the formula:
k = logb M + 1
So log10 892 ≈ 2.92, by adding 1 and rounding down we get k = 3. This means that we have 3 columns, and we
will perform three counting sort on each column. Our base is 10 which is essentially the M we need in counting
sort, so the count_array in each counting sort has only size 10.
13
3.5 Complexity Summary
We can also use radix sort to sort strings, we just need to make the base 26 and use ASCII to update.
14
3.5 Complexity Summary
Name Best Case (Time) Worst Case (Time) Auxiliary Space Is Stable
Bubble Sort O(N ) O(N 2 ) O(1) Yes
Selection Sort O(N 2 ) O(N 2 ) O(1) No
Insertion Sort O(N ) O(N 2 ) O(1) Yes
Heap Sort O(N ) O(N log N ) O(1) No
Unstable Counting Sort O(N + M ) O(N + M ) O(N ) No
Stable Counting Sort O(N + M ) O(N + M ) O(N + M ) Yes
Radix Sort O(N ) O(k(N + b)) O(N + b) Yes
Table 3.3: Sorting Algorithm Complexity Table
15
Chapter 4 Divide and Conquer
Divide and conquer means splitting the original problem into subproblems, solve each subproblem inde-
pendently and combine their solutions to yield the final solution. One of the most well known divide and conquer
algorithms is quick sort. General idea of quick sort:
1. Choose a pivot
2. Partition the list based on the pivot. The pivot will be at its final position after partitioning
3. Recursively call the function to partition the two sides and eventually sort the list
After partitioning, the pivot 3 will get to its final place. All items to its left will be ≤ than the pivot, all
4.2 Quick Select
items to the right will be > than the pivot. For quick sort, we will recursively call left side and right side to
further partition and sort. Observe that if we have always chosen the median to be the pivot, we can partition the
list evenly, and the number of recursive calls will be only O(logN ). However, if we have chosen the smallest or
largest item, either left or right side will be empty and we will need to call the function recursively for N times,
and the number of recursive calls will be O(N ). Now the question is, is there a way for us to always choose the
median?
4.2.1 Application
Assume we have 100 students, and we want to scale their grades. For the top 10 students, we
will deduct 2 marks from each of their grades. For the bottom 20 students, if their
grades are below the median, we will add 3 marks to their grades. Otherwise, we will add
2 marks.
To solve this, we will use the quick select algorithm to find the median by selecting the element at position
0.5 × N , where N is the number of students (100). This will allow us to split the students into left and right
sides based on the median. For the top 10 students, we can obtain them by calling quick select on the left side
for 0.8 × 0.5 × N , which gives us the top 10 students. Then, we deduct 2 marks from each of their grades. Note
that we can’t use 0.2 × 0.5 because it would give us the bottom 10 students on the right side. For the bottom
20 students, we call quick select on the left side for 0.4 × 0.5 × N , which gives us the bottom 20 students. We
then compare each of their grades to the median. If a grade is below the median, we add 3 marks. Otherwise,
we add 2 marks.
17
4.3 Median of Medians
As you can see in Figure 4.2, the median (orange) will definitely be greater than the left bottom red part,
and less than the right upper green part. The green and red part are 30% each and 60% in total, the the median is
in the middle 40%. Therefore, if we use MoM to find the pivot for quick select, we can guarantee logN recursive
calls.
Now we can guarantee the number of recursive calls for quick sort is logN with quick select and median of
medians. So the best and worst case of quick sort are now both O(N logN ), but can we improve the best case?
Observe that if we have a list of same item, other partition algorithms will still need N recursive calls, because
they have the property of ≤ p on the left side. But for DNF, we only need one partition and we will exit the
quick sort. Why? Because we will be left with the white part (=p). So DNF can reduce the complexity of quick
sort to O(N )!
18
4.5 Complexity Summary
4.4.1 Challenge
Code everything out
Write down different combinations and analyze their complexity
If the pivot is ≥ 10% of the items, what is the quick sort complexity?
I am gonna reveal it, it is O(N logN ), because of log 10 N recursions
9
19
Chapter 5 Graphs and Shortest Distance
Graph can be used to solve so many real world problems. For example, GPS navigation, network routing,
job scheduling, task/resource allocation and so much more! Therefore, it is important for us to learn about
graphs!
5.1 Graph
Graph is essentially a set of vertices/nodes and a set of edges/links
G = (V, E)
This means that in a graph G, we have a set of vertices V and a set of edges E. A graph can be weighted
or unweighted. For example, if we want to compute the shortest distance from one location to another, there
must be different stations in between, some are nearer, and we will use the weight to represent the distance. For
unweighted graph’s example, if we want to build a social network graph, the weights are not important because
we just want to know whether two persons know each other. A weighted graph can be represented as
G = (V, E, W )
where W represents weight. Think of it from an OOP point of view. In this case, we need to create a class for
vertex, edge and graph.
5.1.1 Edge
An edge is just a link between two vertices, which can be represented by
E = (U, V )
or
E = (U, V, W )
One with weight and one without. This is a directed edge, it is an edge from vertex u to vertex v. To create an
undirected edge (no direction, just a link), we can simply do something like this in the code
class Edge:
def __init__(self, u, v, w):
self.u = u
self.v = v
self.w = w
edge1 = Edge(u, v, w)
edge2 = Edge(v, u, w)
u.add_edge(edge1)
v.add_edge(edge2)
5.1.2 Vertex
In a Vertex class, we usually store an id to represent which vertex it is, and a edges list to know its outgoing
edges. Note that edges in this example are stored in an adjacency list, we will go through what this is later, but
adjacency list / adjacency matrix have different implementation.
class Vertex:
def __init__(self, id, edges=[]):
[Link] = id
[Link] = edges
5.1.3 Graph
In the Graph class, it will just store a set of vertices because the edges are included in the vertices already.
Of course, different implementations could be different.
class Graph:
def __init__(self, vertices):
[Link] = vertices
...
21
5.2 How to Represent Graphs
5.1.5 Tree
Just to recap, a tree is a graph that is acyclic (no cycle) and connected. The directed graph in Figure 5.1
shows an example of a cycle, from 2 -> 3 -> 4 -> 2. There are many types of tree, the one we use most often is
binary tree, and a binary has at most two outgoing edges (left and right child) for every vertex.
22
5.3 BFS and DFS
in the graph. It doesn’t matter if it is sparse or dense, because we will only use the space whenever necessary as
shown in Figure 5.3.
23
5.3 BFS and DFS
24
5.4 Dijkstra
25
5.4 Dijkstra
5.4 Dijkstra
Dijkstra is a well known shortest distance algorithm, it is actually just BFS + priority queue. As we want
to find the shortest distance, we will use a min heap. Dijkstra helps us to find the shortest distance from one
vertex to all the other vertices. The idea:
1. Set the distance of all vertices to infinity, and set the distance of the source vertex to be 0
2. Push the source vertex onto the min heap
3. Pop the vertex u on top of the heap
4. Traverse through the edges of vertex u, update distance if v has not been visited and [Link] + w <
[Link]. If updated, either push it onto the heap / update if the vertex is already in the heap.
5. Repeat step 3 and 4 until the min heap becomes empty
After running the algorithm, we should get the shortest distance from the source vertex to all the other vertices.
We can also get back the path by including a pre variable and perform backtracking to get back the path
Pseudocode
pq = MinHeap()
[Link](source)
while not pq.is_empty():
vertex = [Link]()
[Link] = True
for edge in [Link]:
u, v, w = edge
if not [Link] and [Link] + w < [Link]:
[Link] = [Link] + w
[Link] = u
if v in pq:
[Link](v)
else:
[Link](v)
# Backtracking
path = []
[Link](destination)
current = destination
while [Link] != None:
current = [Link]
[Link](current)
[Link]()
26
5.5 Directed Acyclic Graph
27
5.5 Directed Acyclic Graph
28
Chapter 6 Minimum Spanning Tree
Minimum spanning tree (MST) is a tree that spans every vertex but with the minimum total edges and
weights to connect all vertices. It is the minimum number of edges to connect all edges and maximum number
of edges in graph without cycle. We usually use MST to find the sub-graph in a graph, and it only work with
undirected and weighted graph.
MST in the same graph may not be unique, because we could have one vertex with multiple edges and
the same weight. So Prim and Kruskal might not get the same MST everytime. MST can work with both
negative edges and negative cycles, because MST is a tree and we know that a tree is acyclic. We have two
MST algorithms which are Prim and Kruskal.
30
6.2 Kruskal’s Algorithm
For instance, in Figure 6.2, vertex 1 has 3 vertices in the tree, and vertex 1 is the root. For vertices 2 and 3,
their parents are vertex 1. Vertex 4 is the root and has two vertices in the tree, vertex 5 is its child.
If we want to merge vertex 2 and vertex 5. We will perform the find operation, it will keep going up until
it reaches the root (vertex with negative value). After we get the root of both vertices, we will compare the size.
The tree with less children will merge with the one with more children. So
find(2) = 1
find(5) = 4
disjoint_set[1] = -3
disjoint_set[4] = -2
31
6.3 Complexity Summary
32
Chapter 7 Dynamic Programming
Dynamic programming (DP) has always been the most popular competitive programming (CP) questions,
and it is hard to get it at first, it requires a lot of practices. DP is somewhat similar to divide and conquer because
1. Take a big problem
2. Divide into smaller problems
3. Combine solutions
Their differences are that DP will reuse the optimal solutions due to overlapping sub-problems. For example,
when we compute Fibonacci numbers, the i-th Fibonacci number will be fib(i-1) + fib(i-2), or the total of its
previous two numbers. A common solution:
fib_nums = [1, 1, 2, 3, 5, 8, 13, ...]
def fib(n):
if n == 1 or n == 2:
return 1
return fib(n-1) + fib(n-2)
But there is actually a lot of overlapping problems. The 4th Fibonacci number is 3 and the 5th Fibonacci number
is 5. The 4th one is fib[2] + fib[3], and the 5th one is fib[3] + fib[4]. The fib[3] is overlapping, but if we use
the previous recursion approach, we will reach fib[4] twice because we are not reusing it. So we can actually
memorize fib[4] to avoid recomputing the problems we have seen before.
fib_nums = [1, 1, 2, 3, 5, 8, 13, ...]
# Bottom up
memo = [-1] * n
memo[1] = 1
memo[2] = 1
for i in range(2, n):
memo[i] = memo[i-2] + memo[i-1]
# Top down
memo = [-1] * n
memo[1] = 1
memo[2] = 1
def fib(n):
if memo[n] != -1:
return memo[n]
memo[n] = fib(n-1) + fib(n-2)
return memo[n]
With memo, we can memorize the previous solution and reuse it. The only downside of it is that we need to use
extra spaces to store the solutions. However, it is definitely faster.
As shown in the above example, I have created two approaches: bottom up and top down. Bottom up means
start from the base case, solve it and use it to solve bigger cases until we have reached the final one. Top down
means start from the final one, divide it to a smaller one until we have reached the base case.
7.1 LeetCode 198: House Robber
7.1.1 Solution
We will use the bottom up approach. At first, we create a memo list with all infinity. Make the base case, 0
be nums[0] and we can run the loop. To rob the maximum amount of money, we will either rob the i-1 one, or
the i-2 one plus the current one. At the end, memo[n-1] will store the maximum amount of money the robber
can rob.
34
7.2 LeetCode 62: Unique Path
7.2.1 Solution
There are many ways to solve this. But I will choose to start from the future. Assume we start from the
destination, it will be one because we only have one way to stay at the destination. To the left of the destination,
we can only go right to reach the destination. Same goes for to the top of the destination. But for top left, we
have two options: move right then go down, or move down then go right. This is two ways, so we will be adding
the total ways on the right and the total ways at the bottom. Same goes for everything as shown in Figure 7.3.
35
7.3 LeetCode 300: Longest Increasing Subsequence
7.2.4 Challenge
If we have walls in the matrix, how many ways can we walk to the destination? Tips: If array[i][j] is a wall,
make memo[i][j] = 0
36
7.3 LeetCode 300: Longest Increasing Subsequence
7.3.1 Solution
At first, we will set all values in the memo array to be 1, which means that the longest increasing subse-
quence for that num is 1 (itself). Then we will loop through the array, find its previous longest subsequence x,
and nums[x] must be the current number, or else it won’t be increasing. If nums[x] the current number, we
know that all of the previous ones before x will also be less than the current one. So we just need two loops.
37
7.4 LeetCode 1143: Longest Common Subsequence
7.4.1 Solution
This solution uses a 2D dp array where memo[i][j] represents the length of the longest common subse-
quence between the first i characters of text1 and the first j characters of text2. If text1[i-1] == text2[j-1], it
means that the last character of two characters are the same, so we increment the length from memo[i-1][j-1].
Otherwise, we take the maximum of removing last character of text1 or last character of text2. See the i as the
number of prefixes in text1 and j as the number of prefixes in text2.
38
7.5 LeetCode 72: Edit Distance
7.5.1 Solution
This solution for Edit Distance uses a 2D memo array where memo[i][j] represents the minimum edit
distance to convert the first i characters of word1 into the first j characters of word2. For memo[i-1][j-1], we are
39
7.6 LeetCode 53: Maximum Subarray
comparing the i-1/j-1 character of both strings. For memo[i-1][j], we delete the i-th character of first string. For
memo[i][j-1], we insert the j-th character of second string to the first string. For example
s1: ababa
s2: abecd
i = 3, j = 2 -> s1 = abab, s2 = abe
For memo[i-1][j-1], we have s1 = aba and s2 = ab, just compare and since b != e we need to replace
For memo[i-1][j], we have s1 = aba and s2 = abe, this means we have deleted b from s1
For memo[i][j-1], we have s1 = abab and s2 = ab, this means we need to insert e to s1
40
7.6 LeetCode 53: Maximum Subarray
7.6.1 Solution
In the Maximum Subarray problem, memo[i] represents the largest sum of any subarray that ends at position
i. If adding the previous maximum sum (from memo[i-1]) to array[i] gives a positive result, we add them
together in memo[i]. If not, we start a new subarray at array[i]. The largest value in the memo array at the end
will be the maximum subarray sum.
41
Chapter 8 DP Graph Algorithm
As we said, Dijkstra can’t work with negative edges due to its greediness. For example
In Figure 8.1, we know that 1->3->4 will be the shortest path from vertex 1 to vertex 4. However, Dijkstra
will give us the 1->2->4 instead, because 7 is greater than both 3 and 5, so it will not be proceeded first. Therefore,
we need dynamic programming graph algorithms like Bellman Ford and Floyd Warshall.
For example, during our first iteration i=1 in Figure 8.2, a is the only one without infinity, so we can only
update ab and ac. For the rest, infinity plus anything is still infinity. During our second iteration i=2, we will
still use the distance in the previous column, now we can update bd and ce too. So the code will look something
like
for edge in edges:
u, v, w = edge
bf_arr[i][v] = min(bf_arr[i-1][u] + w, bf_arr[i-1][v])
We will loop for v-1 iterations, and the v-th iteration is used to check if there exists a negative cycle. Figure
8.2 has no negative cycles, so it has the exactly same values for i=4 and i=5. But if there exists a negative cycle
in the graph, i=4 and i=5 will be different, so this is how we can check if a negative cycle exists. We can also
terminate early if we noticed two consecutive columns stop changing values, because this means all cases after
it will not update too.
43
8.1 Bellman Ford
For example, in Figure 8.3, there exists a negative cycle, so in the v-th iteration, i=4 and i=5 are not the
same. The reason for this is because, the shortest number of paths in a graph with v vertices must be v-1. If
doesn’t make sense to have a shortest path with more than v-1 vertices, unless there exists a negative cycle.
Instead of using the value in the previous column, we will just use the value in the existing column. We start
from source being 0, and all the others are infinity.
44
8.2 Floyd Warshall Algorithm
As shown in Figure 8.4, we can reuse the existing one and finish in two iterations, which is faster and
more space efficient than the 2D array implementation. If we want to check negative cycle using 1D array
implementation, we will still check whether (v-1)-th iteration and v-th iteration are the same.
45
8.2 Floyd Warshall Algorithm
Figure 8.5 is an example of the initial setup, the rest of the column will be infinity. But for simplicity, I just
made it empty for better visualization.
The code for Floyd Warshall is very simple
for k in range(len(vertices)):
for i in range(len(vertices)):
for j in range(len(vertices)):
matrix[i][j] = min(matrix[i][k] + matrix[k][j], matrix[i][j])
46
8.2 Floyd Warshall Algorithm
8.2.4 All-Pair
Floyd Warshall is an all-pair shortest path algorithm because it computes the shortest path from each
node to every other node in the graph, rather than just from a single source. It is useful in application like
network routing and transportation system. For example, when we use Waze or Google Map, we want to find
the shortest path from one location to the other. Others might want to find it too. With Floyd Warshall, this can
be done in O(V 3 ) time. If we used previous algorithms like Dijkstra or Bellman Ford, it requires complexity
of O(V 3 logV ) and O(V 4 ) correspondingly in the worst case (If you don’t get it, it is basically V times the
complexity, because we want to know the shortest path from every source to the other vertices, so we need to
run the algorithm from every source). However, if the graph is unweighted, we will prefer BFS because all-pair
47
8.3 Complexity Summary
in BFS is O(V 2 ) in the best case and O(V 3 ) in the worst case. The best case is better than Floyd Warshall and
worst case is same as Floyd Warshall. But only use BFS if the graph is unweighted.
Note: E can be V or V 2 depending whether the graph is sparse or dense.
BFS: O(V ) × O(V + E) = O(V (V + E)) = O(V 2 )/O(V 3 )
Bellman Ford: O(V ) × O(V E) = O(V (V E)) = O(V 3 )/O(V 4 )
Dijkstra: O(V ) × O(ElogV ) = O(V (ElogV )) = O(V 2 logV )/O(V 3 logV )
Floyd Warshall: O(V 3 )
48
Chapter 9 Flow Network
A flow network is a directed weighted graph. It has a source (a vertex without incoming edges) and a sink
(a vertex without outgoing edges). On every edge, we will have flow and capacity, and the flow must be ≤
capacity. According to the flow conservation property, the total flow out of the source equals the total flow into
the sink. For every vertex, the incoming flow must equal the outgoing flow as well.
As you can see in Figure 9.1, the flow of the network is 5, which we can observe through the outgoing
flow from the source and the incoming flow to the sink. Often, we want to find the max flow in the network to
solve real-world problems. To do this, we have to build a residual network, perform Ford-Fulkerson, and find
the min-cut max-flow.
From Figure 9.1, we can build a residual network similar to Figure 9.2.
9.2 Ford-Fulkerson
After we have built the residual network, we can perform Ford-Fulkerson. An augmenting path is a valid
path from the source to the sink. We can use BFS to find the path and stop when there are no more augmenting
paths. After finding an augmenting path, we will take the minimum capacity - flow for each edge, then we can
flow through that path and update the residual network. For instance, the forward edge should subtract that flow
and the reverse edge should add that flow.
50
9.3 Min-Cut Max-Flow
Figure 9.3 is an example of the process of Ford-Fulkerson. We have two augmenting paths and we update
the flow correspondingly. Since we can no longer find an augmenting path in the last iteration, we will stop.
The max flow is then 7 derived from 4+4-1 from the source or 5+3-1 to the sink.
For the previous example, the min-cut is shown in Figure 9.4. The two sets are {S, A, B, C} and {D, T}.
51
9.4 Feasibility
9.4 Feasibility
Circulation with demands is a feasibility problem. In this case, we will not have a source or sink, and every
node can store some demands. There are two types of feasibility problems, one with a lower bound and one
without. But there is also a quick way to determine feasibility before running the algorithm: If the sum of the
demands is 0, it may be feasible. If not 0, it is directly not feasible.
To retrieve the flow, we look at the final graph after running Ford-Fulkerson. If the original graph has an
edge from x -> y, and in the residual network it has y -> x with a flow of 1, then we will add it to the original
graph, as shown in Figure 9.5. In Figure 9.5, since the flow of both the source and sink are maximized, this
circulation is indeed feasible. But if one of them is not maximized, the circulation will not be feasible.
52
9.5 Application
To retrieve the flow, we need to include lower_bound/flow/capacity. The first one just indicates the lower bound,
and the second flow will include the lower bound. The rest are the same as the previous example.
9.5 Application
There are various applications for network flow, we will discuss the bipartite matching problem.
9.5.1 Problem
Assume we have 100 students and 4 different time slots. Each student can choose 3 preferred time slots,
but they will only be allocated one. Each time slot can have at most 25 students. How can we assign students to
their classes to ensure that they get one of their preferred time slots?
53
9.5 Application
9.5.2 Solution
We will create a source vertex connected to all students, with each student represented by a node. From
the source to each student node, we will set a capacity of 1, which means that each student can only be allocated
one slot. Then, we will create 4 extra nodes to represent the time slots. Each time slot node will connect to a
sink node with a capacity of 25, because each time slot can have at most 25 students. Finally, since each student
has three preferences, we will create an edge from each student node to the corresponding time slot nodes to
indicate their preferences. Once the network is set up, we can run the Ford-Fulkerson algorithm to find the
optimal assignment.
54
Chapter 10 String Retrieval Data Structures
A trie is a data structure used to store strings. Some of the key properties are:
Each node in the trie represents a character
We need a terminal node to indicate this path is a word
The path from root to the terminal node represents a word
For each node, we will have an array of N is the number of characters. For example, if I only allow a-b,
then N = 26
Treat each edge as a character (I didn’t include it the figure because it might look messy)
Note that Figure 10 is something we will draw in practice, the actual one will have N characters for each
node. The $ symbol represents the terminal node. So in this example, we have four words: TICKQ, HAHA,
HELLO and HELL. It is very fast to retrieve, but it wastes a lot of spaces
10.0.1 Insertion
When we insert a new word, we will just start from the root and proceed character by character. If current
char doesn’t exist, we will create a new node.
Pseudocode
class Node:
def __init__(self, character):
[Link] = character
[Link] = [None] * 26
class Trie:
...
def insert(self, word):
10.1 Prefix Tree
current = [Link]
self.insert_aux(current, word, 0)
character = word[level]
if [Link][character] == None:
[Link][character] = Node(character)
root = [Link][level]
self.insert_aux(root, word, level+1)
You can store a lot of information in the node, like the current char’s frequency or child character with the highest
frequency etc.
10.0.2 Searching
When you want to search if a word exists in the trie, just follow character by character. If we see a None
halfway, it means the word doesn’t exist. But if we reached the terminal, it means the word exists and we can
return the result.
56
10.2 Suffix Tree
57
10.2 Suffix Tree
T A T A T $
1 2 3 4 5 6
Table 10.1: Suffix Table
The naive approach will require O(N 2 ) space, but we can improve it with a table.
58
10.3 Suffix Array and Prefix Doubling
It will store the [start, end] index in the array. So the space complexity will be reduced to O(N ).
59
10.3 Suffix Array and Prefix Doubling
ID 1 2 3 4 5 6
Rank 3 2 3 4 2 1
Table 10.2: Rank Table
1. We have sorted their first 1 characters. We are now sorting on the first 2 characters, compare the suffixes.
(a). Compare ID1 and ID6
(b). Compare ID2 and ID5
2. We have sorted their first 2 characters. We are now sorting on the first 4 characters, compare the suffixes.
(a). Compare ID1 and ID4
(b). Compare ID1 and ID3
1. Answer
(a). ID1 and ID6 have different rank, and rank[6] < rank[1], so ID6 has lower rank than ID1.
(b). ID2 and ID5 have same rank, as it is 2k = 2 (first 2 characters), k = 1 and we will add 1. ID2 + 1 =
ID3, ID5 + 1 = ID6. ID3 has higher rank than ID6, so ID2 has higher rank than ID5
2. Answer
(a). ID1 and ID4 have different rank, and rank[3] < rank[4], so ID3 has lower rank than ID4.
(b). ID1 and ID3 have same rank, as it is 2k = 4 (first 2 characters), k = 2 and we will add 2. ID1 + 2 =
ID3, ID3 + 2 = ID5. ID3 has higher rank than ID5, so ID1 has higher rank than ID3
60
Chapter 11 Hashing
A hash table is a data structure that stores data with a pair of key and value. Each key will have its own
index obtained through a hash function and its operations like insert, search and delete are O(1). However, the
array size is limited, and we can have more keys than the array size which will lead to collision because multiple
keys might be mapped to the same index by the hash function.
62
Chapter 12 AVL Tree
We know that for a binary search tree (BST), its operations are not guaranteed to be O(logN ) because
three can be imbalanced and result in O(N ). Therefore, we have AVL tree, which is a self balancing binary
search tree. Since it is always balanced, the search and insert complexity is always O(logN ) because the tree is
balanced.
12.2 Examples
12.2.1 Example 1
Figure 12.1 is an example of a simple imbalanced case. The number below each node means the height
of its left sub-tree and right sub-tree. While calculating it, we will take the max_height(left, right) + 1. For
example, node 15’s left child node, 8 has height of 0 and 1, we will take 1, +1, so the height of node 15’s left
sub-tree is 2. W can see that the difference between the two numbers of node 15 is 2 (2 - 0), so it is imbalanced
and we need to handle this. We will see which side has greater height and move to that direction. As shown in
the figure, it is node 15, node 8 and node 9. We will take the three nodes, take the middle node to be the root
and so on as shown in the bottom example. Then, we simply insert it to the original graph. Now it is balanced.
12.2.2 Example 2
Now the question might be, where should we go if both children have the same height? The answer is: just
follow which direction u went.
In Figure 12.2, node 9 has same height for its children (both 1). So previously node 15 moved left to
node 9, now node 9 will also move left to node 8. You might wonder where you should insert 10, just follow
the property of BST and insert it (left sub-tree smaller than root, right sub-tree greater than root). Since 10 is
64
12.3 Tips
> 7& > 9& < 15, it should belong to the left child of node 15.
But wait, after balancing it, the root (node 7) still has an imbalanced factor -2 (1-3). So we need to balance
again.
Same goes for this one, after rotating, we know node 2 is left child of node 7, so we just don’t remove it.
Moving on, we know 10 is left child of node 15, don’t remove it too! But node 8 is left child of 9 and 9 became
a root, so we will follow the BST property..
12.3 Tips
Practice inserting and deleting and balancing
Always write the height and balance factor so you don’t mess up or made careless mistakes
65
Chapter 13 Revision
Complexity
Know to analyze time complexity
What is the best/worst/average/space/output-sensitive
Variants
What if I used linear queue instead of min heap
What if my graph is undirected/directed
What if my graph is sparse/dense
What happened if my edge is negative
What if I changed min heap to max heap
What if I used adjacency list/adjacency matrix
Time complexity must ≤ space complexity
Recurrence Relation
Given an algorithm, can you write the recurrence relation
2*func(n-1) and func(n-1)*func(n-1) are different. One called once, one called twice
Recursion uses aux space, it is not in-place
Do you know how to use Telescoping (for both T(N-1) and T(N//2) cases)
Do you remember Master Theorem (only T(N//2) cases)
Correctness
Loop invariant (before loop/start of loop/after loop/end of loop/termination)
Can you choose the correct invariant
Sorting Algorithm
Do you know what is comparison based and what is non-comparison based
Complexity of counting sort and radix sort
When should we use counting sort
If biggest range is O(N 3 ), should we use counting sort and why
Why is radix sort always linear (most of the time)
What are the application
Can we sort with strings or just number
Divide and Conquer
Quick select application, how to use it in application
Different partitioning (In-place Hoares, DNF), what are their complexity
How to improve quick sort’s best case and worst case time complexity
What is stability? What is stable and what is not
Graphs and Shortest Distance
What are their complexities
Do you understand the code well
How does Dijkstra works
Minimum Spanning Tree
Trees don’t have cycle so it will work with negative edges + negative cycle
What are their applications
How to convert it to maximum spanning tree (negate the edges? decreasing order? min heap to max
heap?)
What are their complexities?
Is MST unique?
Can Prim and Kruskal obtain the same MST every time?
Dynamic Programming
Do you remember the recurrence relation?
How does each of them work
How to calculate the total ways
What are the complexities
Top down and bottom up
DP Graph Algorithm
Which algorithms work with negative edges which don’t
What does the Floyd Warshall matrix look like after 3 iterations
k is intermediate
What does the Bellman-Ford array look like after running 2 iterations
What is all-pair
Bellman-Ford and Floyd Warshall are DP not greedy
When can it terminate earlier
What to use if the graph is unweighted
What to use if the graph is weighted
Flow Network
What are the complexities? Why O(F E)
What are the applications? What is bipartite matching
How to find min-cut max-flow
How to run Ford-Fulkerson
How to eliminate demand
What if there is lower bound
String Retrieval
Prefix doubling questions (Remember, 2K)
What are their complexities
Why do we use trie? Fast time but waste space?
Hashing
What is a perfect hash function
What are the collision resolution techniques?
Linear Probe
Quadratic Probe
Double Hashing
Cuckoo Hashing
What is primary clustering and secondary clustering
AVL Tree
What is a balance factor
How to balance when you insert/delete
67
Why it is always O(logN )
68