leetcode solutions

Luigi | Sep 1, 2024 min read

This project is primarily for me. It’s a way to organize my leetcode solutions in a way that’s easily searchable and comparable. Also because I’ve noticed that many resources, especially when it comes to dynamic programming problems, often skip over the naive or memoized solutions, jumping straight to the optimized versions. I believe understanding these intermediate steps is crucial, so I aim to include them wherever relevant.

My goals with this blog are:

  • To create a more intuitive categorization of problems, making it easier to find and revisit specific types of challenges.
  • To provide visualizations that help explain the solutions, along with code that directly reflects these visualizations.

Not all solutions have visual aids just yet, but they will be added over time as I continue to build and refine this space.

Sorting

sorting
selection sort
def selection_sort(a):
    for i in range(0,len(a) - 1):
        minn = i
        for j in range(i+1, len(a)):
            if a[j] < a[minn]:
                minn = j
        a[minn], a[i] = a[i], a[minn]

visualization

time complexity
$$T(n) = O(n^2)$$

Because for $i=0$ we have $n-1$ comparisons, for $i=1$ we have $n-2$ comparisons, …, for $i=n-2$ we have $1$ comparison. The number of comparisons is then $ \sum_{i=0}^{n-1} i = \frac{n(n-1)}{2}$

Selection sort is NOT stable.

insertion sort
def insertion_sort(a):
    for i in range(1, len(a)):
        v = a[i]
        j = i
        while j > 0 and a[j - 1] > v:
            a[j] = a[j - 1]
            j -= 1
        a[j] = v

visualization

Insertion sort IS stable.

bubble sort
def bubble_sort(a):
    for i in range(len(a)-1, 0, -1):
        for j in range(1, i + 1):
            if a[j - 1] > a[j]:
                a[j - 1], a[j] = a[j], a[j - 1]

visualization

time complexity
$$T(n) = O(n^2)$$

Because for $i=n-1$ we have $n-1$ comparisons, for $i=n-2$ we have $n-2$ comparisons, …, for $i=1$ we have $1$ comparison. The number of comparisons is then $ \sum_{i=0}^{n-1} i = \frac{n(n-1)}{2}$

Bubble sort IS stable.

Slightly better algorithm with flag:

def bubble_sort_flag(a):
    i = len(a) - 1
    sorted = False
    while i >= 1 and not sorted:
        sorted = True
        for j in range(1,i + 1):
            if a[j - 1] > a[j]:
                a[j - 1], a[j] = a[j], a[j - 1]
                sorted = False
        i = i - 1
bucket sort
def bucket_sort(arr):
    n = len(arr)
    buckets = [[] for _ in range(n)]
    for num in arr:
        bi = int(n * num)
        buckets[bi].append(num)
    for bucket in buckets:
        insertion_sort(bucket)
    index = 0
    for bucket in buckets:
        for num in bucket:
            arr[index] = num
            index += 1
arr = [0.78, 0.17, 0.39, 0.26, 0.72, 0.94, 0.21, 0.12, 0.23, 0.68]

You can use any stable algorithm to sort each bucket.

time complexity
$$T(n) = O(n^2)$$

heap sort
import heapq

def heap_sort(arr):
    heapq.heapify(arr)
    result = []
    while arr:
        result.append(heapq.heappop(arr))
    return result
time complexity

Heapify complexity is $O(n)$, heappop complexity is $O(log_2n)$, hence: $$T(n) = O(nlog_2n)$$

Heap sort is NOT stable

quick sort
def partition(a, l, r):
    pivot = a[r]
    i, j  = l, r - 1
    while True:
        while a[i] < pivot:
            i += 1
        while a[j] > pivot and j > 0:
            j -= 1
        if i >= j:
            break
        a[i], a[j] = a[j], a[i]
    a[i], a[r] = a[r], a[i]
    return i

def quicksort(a, l, r):
    if l < r:
        pi = partition(a, l, r)
        quicksort(a, l, pi - 1)
        quicksort(a, pi + 1, r)

visualization

time complexity
  • On the best case scenario, every partition divide the array in half $$T(n) = 2T(\frac{n}{2}) + n$$ $$\rightarrow T(n) = O(nlog_2n)$$

  • On the worst case scenario, the array is already sorted $$T(n) = T(n-1) + n$$ $$\rightarrow T(n) = O(n^2)$$

  • On the average case scenario, the array is already sorted $$T(n) = \frac{1}{n} \sum_{k=1}^{n} (T(k-1) + T(n-k-1)) + n$$ $$\rightarrow T(n) = O(nlog_2n)$$

Because we have to sum every case a[0]|—k—| P |—(n-k-1)—|a[n-1] and get the average.

Quick sort is NOT stable.

merge sort
def merge(a, l, m, r):
    n1 = m - l + 1
    n2 = r - m
    L = [0] * n1
    R = [0] * n2
    for i in range(n1):
        L[i] = a[l + i]
    for j in range(n2):
        R[j] = a[m + 1 + j]
       
    i, j, k = 0, 0, l
    while i < n1 and j < n2:
        if L[i] <= R[j]:
            a[k] = L[i]
            i += 1
        else:
            a[k] = R[j]
            j += 1
        k += 1
    
    while i < n1:
        a[k] = L[i]
        i += 1
        k += 1
    while j < n2:
        a[k] = R[j]
        j += 1
        k += 1

def mergesort(a, l, r):
    if l < r:
        m = (r + l) // 2
        mergesort(a, l, m)
        mergesort(a, m + 1, r)
        merge(a, l, m, r)

visualization

time complexity

$$T(n) = 2T(\frac{n}{2}) + n$$ $$\rightarrow T(n) = O(nlog_2n)$$

Merge sort IS stable.

sorting in python
a = [6, 9, 2, 9, 90, 18]
a.sort() # -> [2, 6, 9, 9, 18, 90]

You can also use sorted like this

a = [6, 9, 2, 9, 90, 18]
a = sorted(a) # -> [2, 6, 9, 9, 18, 90]

The catch with sorted is that it can be used to sort tuples while tuple.sort() won’t work

a = (6, 9, 2, 9, 90, 18)
# a.sort() # a is not an array
a = sorted(a) # now a will be a sorted array

If the array is made of tuple it will always sort by the first element of the tuple:

a = [(6, 9), (2, 100), (9, 77), (90, 99), (18, 89)]
a.sort() # -> [(2, 100), (6, 9), (9, 77), (18, 89), (90, 99)]

You can specify how to sort the array with the key parameter. E.g you want the array to be sorted in reverse:

a = [6, 9, 2, 9, 90, 18]
a.sort(key = lambda x: -x) # same as a.sort(reverse = True)

Same thing for the sorted method.

key parameter can also be used to specify how to order in case we have a matching in the first tuple element:

a = [(6, 9), (2, 100), (9, 77), (9, 99), (90, 99), (18, 89)]
a.sort(key = lambda x: (x[0],- x[1])) # -> [(2, 100), (6, 9), (9, 99), (9, 77), (18, 89), (90, 99)]

In this case we have a matching in the tuple (9,77), (9,99) and it is sorted decreasingly for the second element of the tuple.

1636. Sort Array by Increasing Frequency [desc] (link)
count = Counter(nums)
nums.sort(key=lambda x: (count[x],-x))
return nums
347. Top K Frequent Elements [desc] (link)

You can also solve this problem with heap, the solution with heap will have a complexity of $O(nlogk)$ while this solution is $O(n)$.

n = len(nums)
counter = Counter(nums)
buckets = [0] * (n + 1)

for num, freq in counter.items():
	if buckets[freq] == 0:
		buckets[freq] = [num]
	else:
		buckets[freq].append(num)    
ret = []
for i in range(n, -1, -1):
	if buckets[i] != 0:
		ret.extend(buckets[i])
	if len(ret) == k:
		break
return ret

Heap

heap

A heap is an array arr where the following relationship

$$\forall i \in [1,n/2]$$ $$arr[i] > arr[2i]$$ $$arr[i] > arr[2i + 1]$$

holds.

class Heap:
    @staticmethod
    def heapify(arr):
        """Transform the arr to a min-heap"""
        n = len(arr)
        for i in range(n // 2 - 1, -1, -1):
            Heap._heapify_down(arr, n, i)

    @staticmethod
    def heappush(arr, value):
        """Adds value to the heap while keeping the heap property"""
        arr.append(value)
        Heap._heapify_up(arr, len(arr) - 1)

    @staticmethod
    def heappop(arr):
        """Remove and returns heap smallest element"""              
        root = arr[0]
        arr[0] = arr[-1]
        arr.pop()
        Heap._heapify_down(arr, len(arr), 0)
        return root

    @staticmethod
    def _heapify_down(arr, n, i):
        """Keep the min-heap property starting from position i going down in the tree"""
        while True:
            smallest = i
            left = 2 * i + 1
            right = 2 * i + 2
            if left < n and arr[left] < arr[smallest]:
                smallest = left
            if right < n and arr[right] < arr[smallest]:
                smallest = right
            if smallest != i:
                arr[i], arr[smallest] = arr[smallest], arr[i]
                i = smallest
            else:
                break

    @staticmethod
    def _heapify_up(arr, i):
        """Keep the min-heap property starting from position i going up in the tree"""
        while i != 0 and arr[(i - 1) // 2] > arr[i]:
            parent = i // 2
            arr[i], arr[parent] = arr[parent], arr[i]
            i = parent

In python this class is implemented in the heapq library.

method time complexity
heapq.heapify(arr) $O(n)$
heapq.heppush(arr, element) $O(log_2n)$
heapq.heappop(arr) $O(log_2n)$
1046. Last Stone Weight [desc] (link)
hp = []
for i in range(len(stones)):
	hp.append(-1*stones[i])
heapq.heapify(hp)
        
while len(hp) > 1:
	x = heapq.heappop(hp)
	y = heapq.heappop(hp)
	if x != y:
		heapq.heappush(hp,-abs(x-y))

if len(hp) == 0:
	return 0
return -1*hp[0]

General problems

53. Maximum Subarray (sum) [desc] (link) kadane's algorithm
naive solution
currMax = nums[0] #not 0 because array can be all negative
for i in range(len(nums)):
    currSum = 0
    for j in range(i,len(nums)):
        currSum += nums[j]
        currMax = max(currMax, currSum)
return currMax
Kadane’s Algorithm
currMax = nums[0]
globalMax = nums[0]
for i in range(1,len(nums)):
    currMax = max(nums[i],currMax + nums[i])
    globalMax = max(globalMax, currMax)
return globalMax
287. Find the Duplicate Number [desc] (link)
slow,fast = 0,0
while True:
    slow = nums[slow]
    fast = nums[nums[fast]]
    if slow == fast:
        break
slow1 = 0
while True:
    slow1 = nums[slow1]
    slow = nums[slow]
    if slow1 == slow:
        return slow1
136. Single Number [desc] (link)
res = 0
for n in nums:
    res = n ^ res
return res
1. Two Sum [desc] (link)
d = {}
for i in range(len(nums)):
    if target-nums[i] in d:
        return [d[target-nums[i]],i]
    d[nums[i]] = i
167. Two Sum II - Input Array Is Sorted [desc] (link) 2 pointers
l, r = 0, len(numbers)-1
while l < r:
    two_sum = numbers[l] + numbers[r]
    if two_sum < target:
        l += 1
    elif two_sum > target:
        r -= 1
    else:
        return [l+1,r+1]
15. 3Sum [desc] (link)
nums.sort()
out = []
for i in range(len(nums)-2):
    if i > 0 and nums[i] == nums[i-1]:
        continue
    l, r = i+1,len(nums)-1
    while l < r:
        t_sum = nums[i] + nums[l] + nums[r]
        if t_sum < 0:
            l += 1
        elif t_sum > 0:
            r -= 1
        else:
            triplets = [nums[i],nums[l],nums[r]]
            out.append(triplets)
            while l < r and nums[l] == triplets[1]:
                l += 1
            while r > l and nums[r] == triplets[2]:
                r -= 1
return out
150. Evaluate Reverse Polish Notation [desc] (link)
stack = []
if len(tokens) == 1:
	return int(tokens[0])
for token in tokens:
	if token not in ['+','-','*','/']:
		stack.append(token)
	else:
		a, b = int(stack.pop()), int(stack.pop())
		if token == '+':
			stack.append(b + a)
		if token == '-':
			stack.append(b - a)
		if token == '/':
			stack.append(int(b / a))
		if token == '*':
			stack.append(b * a)
return stack[0]

Prefix array

238. Product of Array Except Self [desc] (link)
n = len(nums)
pref, suff = [1]*(n+1),[1]*(n+1)
for i in range(1,n+1):
	pref[i] = pref[i-1]*nums[i-1]
for i in range(n-1,-1,-1):
	suff[i] = suff[i+1] * nums[i]
out = []
for i in range(n):
	out.append(pref[i]*suff[i+1])
return out

Binary Search

704. Binary Search [desc] (link)
l,r = 0,len(nums)-1
while l<=r:
    m = (l+r) // 2
    if nums[m] == target:
        return m
    if nums[m] > target:
        r = m - 1
    else:
        l = m + 1
return -1

visualization

74. Search a 2D Matrix [desc] (link)
m = len(matrix)
n = len(matrix[0])
left, right= 0, m*n-1
while left <= right:
    mid = (left + right) // 2
    mid_row, mid_col = mid // n, mid % n
    if matrix[mid_row][mid_col] == target:
        return True
    elif matrix[mid_row][mid_col] < target:
        left = mid + 1     
    else:
		right = mid - 1
return False

visualization

153. Find Minimum in Rotated Sorted Array [desc] (link)
l, r = 0,len(nums)-1
while l < r:
    m = (l+r) // 2
    if nums[m] > nums[r]:
        l = m+1
    else:
        r = m
return nums[l]

visualization

33. Search in Rotated Sorted Array [desc] (link)
l,r = 0,len(nums)-1
while l <= r:
    m = (l+r) //2
    if nums[m] == target:
        return m
    if nums[l] <= nums[m]: #left half is sorted
        if nums[l] <= target <= nums[m]:
            r = m-1
        else:
            l = m+1
    else:
        if nums[m] <= target <= nums[r]:
            l = m+1
        else:
            r = m-1
return -1

Linked list

206. Reverse Linked List [desc] (link)
newList = None
while head:
	next = head.next
	head.next = newList
	newList = head
	head = next
return newList

visualization

21. Merge Two Sorted Lists [desc] (link)
newList = ListNode()
tail = newList
while list1 != None and list2 != None:
    if list1.val < list2.val:
        tail.next = list1
        list1 = list1.next
    else:
        tail.next = list2
        list2 = list2.next
        tail = tail.next
    if list1 != None:
        tail.next = list1
    elif list2 != None:
        tail.next = list2 
return newList.next
143. Reorder List [desc] (link)
# find middle
slow, fast = head, head.next
while fast and fast.next:
    slow = slow.next
    fast = fast.next.next

# reverse second half
second = slow.next
prev = slow.next = None
while second:
    tmp = second.next
    second.next = prev
    prev = second
    second = tmp

# merge two halfs
first, second = head, prev
while second:
    tmp1, tmp2 = first.next, second.next
    first.next = second
    second.next = tmp1
    first, second = tmp1, tmp2
19. Remove Nth Node From End of List [desc] (link)
dummyNode = ListNode()
dummyNode.next = head
l = dummyNode
i = 0
r = dummyNode.next
while i < n:
    r = r.next
    i += 1
        
while r:
    l = l.next
    r = r.next
      
l.next = l.next.next
return dummyNode.next
146. LRU Cache [desc] (link)
class LRUCache:

    def __init__(self, capacity: int):
        self.capacity = capacity
        self.cache = {}    
        self.lru = Node(0,0)
        self.mru = Node(0,0)
        self.lru.next = self.mru
        self.mru.prev =  self.lru

    def get(self, key: int) -> int:
        if key in self.cache:
            self.remove(self.cache[key])
            self.insert(self.cache[key])
            return self.cache[key].value
        return -1
        
    def put(self, key: int, value: int) -> None:
        if key in self.cache:
            self.remove(self.cache[key])
        self.cache[key] = Node(key,value)
        self.insert(self.cache[key])

        if len(self.cache) > self.capacity:
            #remove and delete the LRU
            lru = self.lru.next
            self.remove(lru)
            del self.cache[lru.key]
        
	# Removes any node from linked list in O(1)
    def remove(self, node):
        prev,next = node.prev,node.next
        prev.next,next.prev = next,prev

	# Inserts new node from mru
    def insert(self,node):
        prev,next = self.mru.prev, self.mru
        prev.next = next.prev = node
        node.next, node.prev = next,prev

visualization

Trees

visits
DFS
def dfs(root):
	if not root:
		return
	hl = dfs(root.left)
	hr = dfs(root.right)
	return

visualization

BFS
q = deque([root])
while q:
	v = q.popleft()
	if v.left:
		q.append(v.left)
	if v.right:
		q.append(v.right)

visualization

DFS iterative
q = []
while q:
	v = q.pop()
	if v.right:
		q.append(v.right)
	if v.left:
		q.append(v.left)

remember to put the right part first in the stack.

visualization

time and space complexity

The time complexity of all visits is $$O(n)$$

Because we can write the complexity of the visit like this $$T(n) = T(k) + T(n-k-1) + 1$$ where k is the number of nodes on one side of the root and n-k-1 on the other side. Let’s take in account 2 cases:

  • right skewed tree: we have $T(n) = T(0) + T(n-1) + 1$. The solution of this recursive relation is $$T(n) = O(n)$$
  • balanced tree (both left and right subtrees have an equal number of nodes): $T(n) = T(n/2) +T(n/2) + 1$. The solution of this recursive relation is $$T(n) = O(n)$$

The space complexity is the maximum length of the recursion stack (recursive) or the maximum length of the queue, which is $n$.

$$S(n) = O(n)$$

226. Invert Binary Tree [desc] (link)
def invertTree(self, root):
    if not root:
        return
    root.left, root.right = root.right,root.left
    self.invertTree(root.left)
    self.invertTree(root.right)
    return root

visualization

104. Maximum Depth of Binary Tree [desc] (link)
def dfs(root):
	if not root:
		return 0
	hl = dfs(root.left)
	hr = dfs(root.right)
	return 1 + max(hl, hr)
return dfs(root)

visualization

BFS solution
q = deque([root])
level = 0
while q:
	for i in range(len(q)):
		v = q.popleft()
		if v.left:
			q.append(v.left)
		if v.right:
			q.append(v.right)
	level += 1
return level
110. Balanced Binary Tree [desc] (link)
def dfs(root):
    if not root:
        return (True,0)
    b1,h1 = dfs(root.left)
    b2,h2 = dfs(root.right)
    if b1 and b2 and abs(h1-h2)<=1:
        return (True,max(h1,h2)+1)
    else:
        return (False,max(h1,h2)+1)   
return dfs(root)[0]
100. Same Tree [desc] (link)
def dfs(root1,root2):  
    if not root1 and not root2:
        return True
    if not root1 and root2:
        return False
    if root1 and not root2:
        return False
    a = dfs(root1.left,root2.left)
    b = dfs(root1.right,root2.right)
	return root1.val == root2.val and a and b
return dfs(p,q)

You can refactor this condition

if not root1 and not root2:
    return True
if not root1 and root2:
    return False
if root1 and not root2:
    return False

Which true/false table is the following:

root1 root2 output i want
T T nothing
T F F
F T F
F F T

The condition becomes:

if not root1 or not root2:
	return root1 == root2
Another way to do it

The comparison with the value can be at the beginning.

def dfs(root1,root2):        
    if not root1 and not root2:
        return True
    if not root1 and root2:
        return False
    if root1 and not root2:
        return False
    if root1.val != root2.val:
        return False 
    a = dfs(root1.left,root2.left)
    b = dfs(root1.right,root2.right)
    return  a and b
return dfs(p,q)
572. Subtree of Another Tree [desc] (link)
def sameTree(root1,root2):
    if not root1 and not root2:
        return True
    if not root1 and root2:
        return False
    if not root2 and root1:
        return False
    l = sameTree(root1.left, root2.left)
    r = sameTree(root1.right, root2.right)
    return l and r and root1.val == root2.val

def dfs(root,subRoot)->bool:
    if not root:
        return False
    if sameTree(root, subRoot):
        return True
    l = dfs(root.left, subRoot)
    r = dfs(root.right, subRoot)
    return l or r
return dfs(root,subRoot)

For each node, i check if it is the same tree. Check problem 100. Same Tree.

102. Binary Tree Level Order Traversal [desc] (link)
if not root:
    return []
q = deque([root])
out = []
while q:
    elem = []
    for i in range(len(q)):
        e = q.popleft()
        if e.left:
            q.append(e.left)
        if e.right:
            q.append(e.right)
        elem.append(e.val)
    out.append(elem)
return out
199. Binary Tree Right Side View [desc] (link)
if not root:
    return 
d = deque([root])
out = []
while d:
    l = []
    for i in range(len(d)):
        n = d.popleft()
        if n.left:
            d.append(n.left)
        if n.right:
            d.append(n.right)
        l.append(n.val)
    out.append(l[-1])
return out

Watch before problem 102. Binary Tree Level Order Trasversal

543. Diameter of Binary Tree [desc] (link)
diameter = [0]
def dfs(root):
    if not root:
        return 0       
    l = dfs(root.left)
    r = dfs(root.right)
    diameter[0] = max(diameter[0],l+r)
    return 1+ max(l,r) 
dfs(root)
return diameter[0]

diameter is the length of the longest path between any two nodes in a tree. This path may or may not pass through the root.

visualization

236. lowest-common-ancestor-of-a-binary-tree [desc] (link)
def dfs(root, v, path):      
	if not root:
        return False
    path.append(root.val)
    if root.val == v:
        return True
    fl = dfs(root.left, v, path)
    if fl:
        return True
    fr = dfs(root.right, v, path)
    if fr:
        return True
    path.pop()
    return False

p1, p2 = [], []
dfs(root,p.val,p1)
dfs(root,q.val,p2)
i = 0
ret = None
while i < len(p1) and i < len(p2):
    if p1[i] != p2[i]:
        break
    ret = p1[i]
    i+=1
return TreeNode(ret)

visualization

617. Merge Two Binary Trees [desc] (link)
def dfs(root1, root2):
    if not root1:
        return root2
    if not root2:
        return root1
    tree = TreeNode(root1.val + root2.val)
    tree.left = dfs(root1.left, root2.left)
    tree.right = dfs(root1.right, root2.right)
    return tree
return dfs(root1, root2)
124. Binary Tree Maximum Path Sum [desc] (link)
res = [root.val]
def dfs(root):
    if not root:
        return 0
    leftMax = dfs(root.left)
    rightMax = dfs(root.right)
    leftMax = max(leftMax, 0)
    rightMax = max(rightMax, 0)
    res[0] = max(res[0], root.val + leftMax + rightMax)
    return root.val + max(leftMax, rightMax)
dfs(root)
return res[0]

Backtracking

78. Subsets [desc] (link)
result = []
def dfs(i,sub):
    if i == len(nums):
        result.append(sub.copy())
        return
    sub.append(nums[i])
    dfs(i+1,sub)
    sub.pop()
    dfs(i+1,sub)

Python is convenient and you can squeeze down the append-recursiveCall-pop to just 1 line, like this:

dfs(i+1,sub + [nums[i]])

visualization
Or view interactive visualization with this link

Another 'optimized' way to do it
result = []
def dfs(i,sub):
    if i == len(nums):
		result.append(sub.copy())
        return
    for j in range(i,len(nums)):
        dfs(j+1,sub + [nums[j]])

Or view interactive visualization with this link

46. Permutations [desc] (link)
res = []
def dfs(permutation):
    if len(permutation) == len(nums):
        res.append(permutation.copy())
        return
    for i in range(len(nums)):
        if not nums[i] in permutation:
            permutation.append(nums[i])
            dfs(permutation)
            permutation.pop()
dfs([])
return res

visualization
Or view interactive visualization with this link

39. Combination Sum [desc] (link)
res = []
def dfs(i,sa):
    if sum(sa) == target:
        res.append(sa.copy())
        return
    if sum(sa) > target:
        return    
    for j in range(i,len(candidates)):
		sa.append(candidates[j])
        dfs(j,sa)
		sa.pop()
dfs(0,[])
return res

visualization
Or look at the tree with this link

90. Subsets II [desc] (link)
res = []
sub = []
nums.sort()
def backtrack(i):
    if i == len(nums):
        res.append(sub.copy())
        return
    sub.append(nums[i])
    backtrack(i+1)
    sub.pop()
    while i + 1 < len(nums) and nums[i] == nums[i + 1]:
        i += 1
    backtrack(i+1)
backtrack(0)
return res

Dynamic Programming

70. Climbing Stairs [desc] (link)
Naive DFS approach (TLE)
def dfs(step):
	if step == 0:
		return 1
	if step < 0:
		return 0
	l = dfs(step - 1)
	r = dfs(step - 2)
	return l + r
return dfs(n)

It is important, in order to easier the next step (memoization) that each node returns a value. For example you can do a code that works but recursive calls does not return any value, like this:

res = []
def dfs(step):
	if step == 0:
		res[0] += 1
		return
	if step < 0:
		return
	dfs(step - 1)
	dfs(step - 2)
return dfs(n)	

But it will be difficult now to achive the momoization step, what do we save into the dictionary? Here the node does not make any work, so we can’t save it’s work.

visualization
Or look at the tree with this link

time and space complexity
Time complexity: $$T(n) = 2T(n-1) + 1$$ $$=2(2T(n-2) + 1) + 1$$ $$=2(2(T(n-3) + 1) + 1) + 1$$ $$\equiv 2^3T(n-3) + 3$$ $$…$$ $$=2^nT(0) + n = O(2^n)$$

Space complexity: $$S(n) = O(n)$$ It is the depth of the recursion stack at max, which is $n$.

Dynamic Programming top-down (memoization)
mem = {}
def dfs(step):
	if step in mem:
		return mem[step]
	if step == 0:
		return 1
	if step < 0:
		return 0
	l = dfs(step - 1)
	r = dfs(step - 2)
	mem[step] = l + r
	return l + r
return dfs(n)	

visualization

Or look at the tree with this link

time and space complexity
Time complexity: $$T(n) = O(n)$$ In the memoized solution, the time complexity is always the maximum length of the cache.

Space complexity: $$S(n) = O(n)$$ It is the depth of the recursion stack at max, which is $n$.

Dynamic Programming bottom-up (true dynamic programming)

The key to understand true dynamic programming is that we don’t ask the question:

i’m at the 5th step, what are the ways i can reach this step?

But you ask, progressively:

i’m at the 5th step, what are the ways i can reach this step, given that i know how to reach each step below?

Now it is easier, because, to reach the 5th step, you can take 1 step from 4th step or 2 step from step 3 and you already know in how many ways you can reach both of them.

df = [1] * (n+1)
for i in range(2,n + 1):
	df[i] = df[i-1] + df[i-2]
return df[n]

visualization

time and space complexity
Time complexity: $$T(n) = O(n)$$

Space complexity: $$S(n) = O(n)$$

322. Coin Change [desc] (link)
Naive
def dfs(total):
    if total == 0:
        return 0
    if total < 0:
        return math.inf
    min_steps = math.inf
    for coin in coins:
        steps = dfs(total + coin)
        min_steps = min(min_steps, steps + 1)            
    return min_steps
result = dfs(amount)
return result if result != math.inf else -1

visualization

time and space complexity
Time complexity: $$T(n) = nT(t-1) + 1$$ $$= n(nT(t-2)) + 1) + 1$$ $$…$$ $$= O(n^t)$$

In the worst case we have in the available coins, a coin with value $1$. In this case the total amount is each time decreased by $1$.

Space complexity: $$S(n) = O(t)$$ It is the depth of the recursion stack at max, which is $t$.

Top-Down (memoization)
memo = {}
def dfs(total):
    if total in memo:
        return memo[total]
    if total == 0:
        return 0
    if total < 0:
        return math.inf
    min_steps = math.inf
    for coin in coins:
        steps = dfs(total + coin)
        min_steps = min(min_steps, steps + 1)            
    memo[total] = min_steps
    return min_steps
result = dfs(amount)
return result if result != math.inf else -1

visualization

time and space complexity
Time complexity: $$T(n) = O(n*t)$$ In the memoized solution, the time complexity is always the maximum length of the cache.

Space complexity: $$S(n) = O(t)$$ It is the depth of the recursion stack at max, which is $t$.

bottom-up (true dp)
dp = [amount + 1] * (amount + 1)
dp[0] = 0
for a in range(1, amount + 1):
    for c in coins:
        if a - c >= 0:
            dp[a] = min(dp[a], 1 + dp[a - c])
return dp[amount] if dp[amount] != amount + 1 else -1

visualization

time and space complexity
Time complexity: $$T(n) = O(n*t)$$

Space complexity: $$S(n) = O(t)$$

91. Decode Ways [desc] (link)
Top-Down (memoization)
memo = {}
def dfs(s):
    if s in memo:
		return memo[s]
    if len(s) == 0 or (len(s) == 1 and int(s) != 0):
        return 1
    l, r = 0, 0
    if int(s[0]) > 0:
        l = dfs(s[1:])
    if int(s[0:2]) > 9 and int(s[0:2]) < 27:
        r = dfs(s[2:])
    memo[s] = l + r
    return l + r
return dfs(s)

visualization

bottom-up

In this case i strongly believe the memoization solution is better and easier because you don’t have to handle a lot of edge cases involving zeros. Anyway here is the true dp solution.

dp = {}
dp[""] = 1
for i in range(len(s)-1,-1,-1):
    sub = s[i:len(s)] 
    if int(sub[0]) > 0:
        dp[sub] = dp[sub[1:]]
    else:
        dp[sub] = 0
    if len(sub) > 1 and 9 < int(sub[0:2]) < 27:
        dp[sub] += dp[sub[2:]]
return dp[s]

visualization

55. Jump Game [desc] (link)
DFS with memoization
cache = {}
def dfs(i):
    if i in cache:
        return cache[i]
    if i == len(nums) - 1:
        return True
    tmp = False
    for j in range(nums[i]):
        tmp = tmp or dfs(j+1+i)
    cache[i] = tmp
    return tmp
return dfs(0)
true dp
n = len(nums)
dp = [False] * n
dp[n - 1] = True
for i in range(n-2,-1,-1):
    found = False
    j = 1
    while j <= nums[i] and not found:
        if i+j < n and dp[i + j]:
			dp[i] = True
			found = True
        j+= 1
return dp[0]
greedy solution
goal = len(nums)-1
for i in range(goal-1,-1,-1):
    if i+nums[i] >= goal:
        goal = i
return goal == 0
45. Jump Game II [desc] (link)
DFS with memoization
cache = {}
def dfs(i):
    if i in cache:
        return cache[i]
    if i >= len(nums) - 1:
        return 0
    minn = math.inf
    for j in range(nums[i]):
        minn = min(minn, dfs(j+1+i) + 1)
    cache[i] = minn
    return minn
return dfs(0)
true dp
n = len(nums)
dp = [0] * n
dp[n - 1] = 0
for i in range(n-2,-1,-1):
    minn = math.inf
    j = 1
    while j <= nums[i]:
        if i + j < n:
            minn = min(dp[i+j]+1,minn)
        j+= 1
    dp[i] = minn
return dp[0]
300. Longest Increasing Subsequence [desc] (link)
top-down (memoization)
memo = {}
def dfs(i):
	if i == len(nums):
		return 0 
	if i in memo:
		return memo[i]
	a = 0
	for j in range(i+1,len(nums)):
		if i == -1 or nums[j] > nums[i]:
			a = max(a, dfs(j) + 1)
    memo[i] = a
	return a
return dfs(-1)

visualization
Or look at the tree with this link

bottom-up
n = len(nums)
LIS = [0] * (n+1)
for i in range(n-1,-1,-1):
	a = 0
	for j in range(i+1,n+1):
		a = max(a, LIS[j] + 1 if nums[i-1] < nums[j-1] or i == 0 else 0)
	LIS[i] = a
return LIS[0]

visualization

2D Dynamic Programming

1143. Longest Common Subsequence [desc] (link)
top-down
def dfs(i, j):
    if i == len(text1) or j == len(text2):
		return 0
	if text1[i] == text2[j]:
        return 1 + dfs(i + 1, j + 1)
	else:
		return max(dfs(i + 1, j), dfs(i, j + 1))        
return dfs(0, 0)

visualization

bottom-up
dp = [[0 for j in range(len(text2) + 1)] 
	for i in range(len(text1) + 1)]
for i in range(len(text1) - 1, -1, -1):
	for j in range(len(text2) - 1, -1, -1):
		if text1[i] == text2[j]:
			dp[i][j] = 1 + dp[i + 1][j + 1]
		else:
			dp[i][j] = max(dp[i][j + 1], dp[i + 1][j])                            
return dp[0][0]

visualization

72. Edit Distance [desc] (link)
top-down
def dfs(i, j):
	if i == len(word1):
		return len(word2) - j
	if j == len(word2):
		return len(word1) - i
	if word1[i] == word2[j]:
		return dfs(i + 1, j + 1)
	else:
		return 1 + min(dfs(i, j + 1), dfs(i + 1, j), dfs(i + 1, j + 1))
return dfs(0,0)
bottom-up
dp = [[0 for j in range(len(word2) + 1)] 
	for i in range(len(word1) + 1)]
for i in range(len(word1) + 1):
	dp[i][len(word2)] = len(word1) - i
for j in range(len(word2) + 1):
	dp[len(word1)][j] = len(word2) - j
        
for i in range(len(word1) - 1, -1, -1):
	for j in range(len(word2) - 1, -1, -1):
		if word1[i] == word2[j]:
			dp[i][j] = dp[i+1][j+1]
		else:
			dp[i][j] = 1 + min(dp[i][j+1], dp[i+1][j], dp[i+1][j+1])
return dp[0][0]

Graphs

visits
BFS
def bfs(root):
	q = deque([root])
	mark root
	while q:
		u = q.popleft()
		for all edges(u,v):
			if v is not marked:
				mark v
				q.append(v)
DFS-recurisve
def dfs(u):
	mark u
	for all edges(u,v):
		if v is not marked:
			dfs(v)
DFS-iterative
def dfs(root):
	s = [root]
	mark root
	while s:
		u = s.pop()
		for all edges(u,v):
			if v is not marked:
				mark v
				s.append(v)
time and space complexity

All graphs visits have the same complexity.

$$ O( \sum_{v \in G} (1 + \tau(v))) = O( n + \sum_{v \in G} \tau(v))$$

Where $\tau(v)$ is the cost of the operation of getting incident edges for a given node $v$. The cost of this operation depends on the graph representation.

  • If it is a edge list then $\tau(v) = O(m)$ bacause we have to scan the whole list. So in this case it is $$O(n + nm) = O(nm)$$
  • If it is adjacency list then $\tau(v) = O(\delta(v))$. Where $\delta(v)$ is the length of the list. So in this case it is $$O(n + 2m) = O(n + m)$$ Because for a foundamental property of undirected graphs we know that $\sum_{v \in V} \delta(v) = 2m$.
  • If it is adjacency matric then $\tau(v) = O(n)$ because we have to scan the entire row. So in this case it is $$O(n + n^2) = O(n^2)$$

The space complexity is $$S(n) = O(n + m)$$

topological sort
Problem statment:

Given a DAG (directed acyclic graph) a topological sort is a linear ordering of all vertices such that for any edge (u, v), u comes before v in the ordering.

So a topological ordering for the graph in the photo is: $[1,4,2,3,5,6]$. There are other possible topological ordering, for example $[1,4,5,6,2,3]$.

using DFS
def dfs(node):
	mark node as visited
	for (node,v) in edges:
		if v not visited:
			dfs(v)
	out.append(node)

out = []
for each node:
	if node not visited:
		dfs(node)
return out.reverse()

Be aware

This DFS algorithm runs fine and outputs a order for a graph even if the graph contains cycles. If you want an algorithm that can create a topological ordering but also detecting if the graph contains a cycle you have to use the BFS solution described below.

Let’s see an example of how the algorithm works:

The outuput does not depend on the node we start with. This is because we loop through all nodes and we run dfs on them. For this reason, let’s see another example, starting from another node.

using BFS can be used to detect cycles
indegree = an array indicating indegrees for each node
queue = []
# Add to the BFS queue all nodes with indegree 0
for i in indegree:
    if indegree[i] == 0:
        queue.append(i)
		
out = []
while q:
	u = q.popleft()
	out.append(u)
	for (u,v) in edges:
		indegree[v] -= 1
		if indegree[v] == 0:
			queue.append(v)
			
if len(out) == n:
	return out
else:
	return "graphs contains a cycle"

Topological ordering with BFS can be used to detect cycles

You can see how in the example below:

detect cycles

Detecting a cycle in a graph can be implemented using

  1. topological sort using BFS
  2. using a 3 state nodes configuration.

So, instead of only visited/unvisited, we will have 3 states: unvisited/currently being visited/visited. But why do we need 3 states?

Let’s implement the 3 state visit in python:

UNVISITED = 0
CURRENTLY_VISITING = 1
VISITED = 2
states = [0] * numCourses
def dfs(i):
    if states[i] == VISITED:
        return True
    states[i] = CURRENTLY_VISITING
    for v in adjList[i]:
        if states[v] == CURRENTLY_VISITING:
            return False
        a = dfs(v)
         if not a:
            return False
    states[i] = VISITED
    return True

for i in range(numCourses):
    if not dfs(i):
        return False
return True
200. Number of Islands [desc] (link)
def bfs(i,j):
    q = deque([(i,j)])
    grid[i][j] = '0'
    while q:
        r,c = q.popleft()
        if r+1 in range(len(grid)) and grid[r+1][c] == '1':
            q.append((r+1,c))
            grid[r+1][c] = '0'
        if r-1 in range(len(grid)) and grid[r-1][c] == '1':
            q.append((r-1,c))
            grid[r-1][c] = '0'
        if c+1 in range(len(grid[0])) and grid[r][c+1] == '1':
            q.append((r,c+1))
            grid[r][c+1] = '0'
        if c-1 in range(len(grid[0])) and grid[r][c-1] == '1':
            q.append((r,c-1))
            grid[r][c-1] = '0' 
count = 0
for i in range(len(grid)):
	for j in range(len(grid[0])):
        if grid[i][j] == '1':
            bfs(i,j)
            count +=1
return count
DFS solution
def dfs(self, grid, i, j):
	if i<0 or j<0 or i>=len(grid) or j>=len(grid[0]) or grid[i][j] != '1':
        return
    grid[i][j] = '#'
    self.dfs(grid, i+1, j)
    self.dfs(grid, i-1, j)
    self.dfs(grid, i, j+1)
    self.dfs(grid, i, j-1)

count = 0
for i in range(len(grid)):
    for j in range(len(grid[0])):
        if grid[i][j] == '1':
            self.dfs(grid, i, j)
            count += 1
return count
130. Surrounded Regions [desc] (link)
rows, cols= len(board), len(board[0])
def dfs(r,c): 
    if (r<0 or r==rows or c<0 or c==cols or board[r][c]!="O"):
        return 
    board[r][c]="T"
    dfs(r+1,c)
    dfs(r-1,c)
    dfs(r,c+1)
    dfs(r,c-1)
#(DFS)capture unsurrounded region(0->T)
for r in range(rows):
    for c in range(cols):
        if board[r][c]=="O" and (r==0 or r==rows-1 or c==0 or c==cols-1):
            dfs(r,c)
#capture surrounded region(0->x)
for r in range(1,rows-1):
    for c in range(1,cols-1):
        if board[r][c]=="O":
            board[r][c]="X" 
#uncapture unsurrounded region(T->0)
for r in range(rows):
    for c in range(cols):
        if board[r][c]=="T":
            board[r][c]="O"

visualization

207. Course Schedule [desc] (link) cycle detection or cycle detection with BFS topo sort
adjList = defaultdict(list)
for e in prerequisites:
    adjList[e[1]].append(e[0])

states = [0] * numCourses
def dfs(i):
    if states[i] == 2:
        return True
    states[i] = 1
    for v in adjList[i]:
        if states[v] == 1:
            return False
        a = dfs(v)
        if not a:
            return False
    states[i] = 2
    return True

for i in range(numCourses):
    if not dfs(i):
        return False
return True

visualization

using topological sort
adj_list = defaultdict(list)
for p in prerequisites:
    adj_list[p[1]].append(p[0])
        
in_degrees = [0] * numCourses
for p in prerequisites:
    in_degrees[p[0]] += 1
        
q = deque()
for i in range(numCourses):
    if in_degrees[i] == 0:
        q.append(i)
out = []
while q:
    v = q.popleft()
    out.append(v)
    for n in adj_list[v]:
        in_degrees[n] -= 1
        if in_degrees[n] == 0:
            q.append(n)
        
return len(out) == numCourses
210. Course Schedule II [desc] (link) topological sort
adj_list = defaultdict(list)
for p in prerequisites:
    adj_list[p[1]].append(p[0])
        
in_degrees = [0] * numCourses
for p in prerequisites:
    in_degrees[p[0]] += 1
        
q = deque()
for i in range(numCourses):
    if in_degrees[i] == 0:
        q.append(i)
out = []
while q:
    v = q.popleft()
    out.append(v)
    for n in adj_list[v]:
        in_degrees[n] -= 1
        if in_degrees[n] == 0:
            q.append(n)
if len(out) == numCourses:
    return out
else:
    return []

Greedy

interval-schedule

Problem:

Suppose you have a resource shared by many people and it can’t be use in parallel. You have to maximize the number of task that can be done and return those tasks.

Greedy algorithms are about choosing at each step the best solution possible, and never looking back. But what is the best solution in the case of the “interval scheduling” problem?

  1. I can choose the interval that starts first. But this one will lead to a non optimal solution.

  2. I can choose the shortest interval first. But this one will lead to a non optimal solution.

  3. I can choose the interval with the fewest conflicts. But this one will lead to a non optimal solution.

  4. I can choose the interval that ends first. This one will lead to a non optimal solution.

procedure IntervalSchedule (1,2,...,n)
	candidate <- {1,2,...,n}
	out <- {}
	while candidate
		k <- interval that ends first
		out <- out U {k}
		candidate <- candidate - {k}
		remove from candidate all the intervals incompatible with k
	return out

And this is the implementation in python:

intervals.sort(key = lambda x: x[1])
out = [intervals[0]]
t = intervals[0][1]
for i in range(1,len(intervals)):
    if intervals[i][0] >= t:
		out.append(intervals[i])
        t = intervals[i][1]
return out
435. Non-overlapping Intervals [desc] (link)

See the solution for interval-schedule, this algorithm is exactly that one but with a twist, here we don’t ask to return the right intervals but the number of intervals that are left out.

intervals.sort(key = lambda x: x[1])
t = intervals[0][1]
out = 0
for i in range(1,len(intervals)):
    if intervals[i][0] >= t:
        t = intervals[i][1]
    else:
        out += 1
return out
coin change

Time complexity of the coin change problem (see 322. Coin Change) is $$ O(N*K) $$

IF you have a canonical coin set (*) you can use a fully greedy approach. In the case of problem 322 the testcase does not always have a canonical coin system, so we have to use a bruteforce approach (which is dynamic programming).

The greedy choiche in this case is choosing at each step the largest coin that is less than the change. The pseudo-code of the algorithm is the following:

def coinChange(change, coins):
	S = {}
	while change > 0:
		c = largest coins[i]: coins[i] <= r
		if no such c:
			return no solution
		change = change - c
		S = S + {c}
	return S

In python the code is:

def coinChange(change, coins):
	out = []
	coins.sort()
	while r > 0 and coins:
		c = coins.pop()
		if c >= change:
			change = change - c
			out.append(c)
	if r == 0:
		return out
	else:
		return no solution

The algorithm requires a sort so:

$$T(n) \in O(nlogn)$$