Data Structures & Algorithms
Complete study notes covering fundamental data structures, algorithms, and problem-solving patterns for tech interviews. All code examples are in Python.
BASIC
Arrays
Static Arrays
# Insert n into arr at the next open position.
# Length is the number of 'real' values in arr, and capacity
# is the size (aka memory allocated for the fixed size array).
def insertEnd(arr, n, length, capacity):
if length < capacity:
arr[length] = n
# Remove from the last position in the array if the array
# is not empty (i.e. length is non-zero).
def removeEnd(arr, length):
if length > 0:
# Overwrite last element with some default value.
# We would also the length to decreased by 1.
arr[length - 1] = 0
# Insert n into index i after shifting elements to the right.
# Assuming i is a valid index and arr is not full.
def insertMiddle(arr, i, n, length):
# Shift starting from the end to i.
for index in range(length - 1, i - 1, -1):
arr[index + 1] = arr[index]
# Insert at i
arr[i] = n
# Remove value at index i before shifting elements to the left.
# Assuming i is a valid index.
def removeMiddle(arr, i, length):
# Shift starting from i + 1 to end.
for index in range(i + 1, length):
arr[index - 1] = arr[index]
# No need to 'remove' arr[i], since we already shifted
def printArr(arr, capacity):
for i in range(capacity):
print(arr[i])
Dynamic Arrays
# Python arrays are dynamic by default, but this is an example of resizing.
class Array:
def __init__(self):
self.capacity = 2
self.length = 0
self.arr = [0] * 2 # Array of capacity = 2
# Insert n in the last position of the array
def pushback(self, n):
if self.length == self.capacity:
self.resize()
# insert at next empty position
self.arr[self.length] = n
self.length += 1
def resize(self):
# Create new array of double capacity
self.capacity = 2 * self.capacity
newArr = [0] * self.capacity
# Copy elements to newArr
for i in range(self.length):
newArr[i] = self.arr[i]
self.arr = newArr
# Remove the last element in the array
def popback(self):
if self.length > 0:
self.length -= 1
# Get value at i-th index
def get(self, i):
if i < self.length:
return self.arr[i]
# Here we would throw an out of bounds exception
# Insert n at i-th index
def insert(self, i, n):
if i < self.length:
self.arr[i] = n
return
# Here we would throw an out of bounds exception
def print(self):
for i in range(self.length):
print(self.arr[i])
print()
Stacks
# Implementing a stack is trivial using a dynamic array
# (which we implemented earlier).
class Stack:
def __init__(self):
self.stack = []
def push(self, n):
self.stack.append(n)
def pop(self):
return self.stack.pop()
Linked Lists
Singly Linked List
class ListNode:
def __init__(self, val):
self.val = val
self.next = None
# Implementation for Singly Linked List
class LinkedList:
def __init__(self):
# Init the list with a 'dummy' node which makes
# removing a node from the beginning of list easier.
self.head = ListNode(-1)
self.tail = self.head
def insertEnd(self, val):
self.tail.next = ListNode(val)
self.tail = self.tail.next
def remove(self, index):
i = 0
curr = self.head
while i < index and curr:
i += 1
curr = curr.next
# Remove the node ahead of curr
if curr:
curr.next = curr.next.next
def print(self):
curr = self.head.next
while curr:
print(curr.val, ' -> ')
curr = curr.next
print()
Doubly Linked List
class ListNode:
def __init__(self, val):
self.val = val
self.next = None
self.prev = None
# Implementation for Doubly Linked List
class LinkedList:
def __init__(self):
# Init the list with 'dummy' head and tail nodes which makes
# edge cases for insert & remove easier.
self.head = ListNode(-1)
self.tail = ListNode(-1)
self.head.next = self.tail
self.tail.prev = self.head
def insertFront(self, val):
newNode = ListNode(val)
newNode.prev = self.head
newNode.next = self.head.next
self.head.next.prev = newNode
self.head.next = newNode
def insertEnd(self, val):
newNode = ListNode(val)
newNode.next = self.tail
newNode.prev = self.tail.prev
self.tail.prev.next = newNode
self.tail.prev = newNode
# Remove first node after dummy head (assume it exists)
def removeFront(self):
self.head.next.next.prev = self.head
self.head.next = self.head.next.next
# Remove last node before dummy tail (assume it exists)
def removeEnd(self):
self.tail.prev.prev.next = self.tail
self.tail.prev = self.tail.prev.prev
def print(self):
curr = self.head.next
while curr != self.tail:
print(curr.val, " -> ")
curr = curr.next
print()
Queues
class ListNode:
def __init__(self, val):
self.val = val
self.next = None
class Queue:
# Implementing this with dummy nodes would be easier!
def __init__(self):
self.left = self.right = None
def enqueue(self, val):
newNode = ListNode(val)
# Queue is non-empty
if self.right:
self.right.next = newNode
self.right = self.right.next
# Queue is empty
else:
self.left = self.right = newNode
def dequeue(self):
# Queue is empty
if not self.left:
return None
# Remove left node and return value
val = self.left.val
self.left = self.left.next
return val
def print(self):
cur = self.left
while cur:
print(cur.val, ' -> ', end ="")
cur = cur.next
print() # new line
Linked List Extra
1. Single Linked List
Structure and Basic Operations
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
class SingleLinkedList:
def __init__(self):
self.head = None
def insert_at_beginning(self, val):
new_node = ListNode(val)
new_node.next = self.head
self.head = new_node
## NOTE WHEN YOU GO FOR INSERTION AND DELETION IN LINKED LIST YOU ALWAYS SEE current.next in while loop
def insert_at_end(self, val):
new_node = ListNode(val)
if not self.head: // this means , if there is no self.head = NULL or false then the self.head=new_node will be executed
self.head = new_node
return
current = self.head
while current.next: // note to use these types in just traversal where you want to reach the end
current = current.next
current.next = new_node
def delete_node(self, val):
if not self.head:
return
if self.head.val == val:
self.head = self.head.next
return
current = self.head
while current.next and current.next.val != val:
current = current.next
if current.next:
current.next = current.next.next
## NOTE WHEN YOU GO FOR SEARCHING AND DISPLAYING IN LINKED LIST YOU ALWAYS SEE current in while loop
def search(self, val):
current = self.head
while current:
if current.val == val:
return True
current = current.next
return False
def display(self):
result = []
current = self.head
while current: // note to use these types in just traversal where you want to print value and traverse till end
result.append(current.val)
current = current.next
return result
2. Double Linked List
class DoublyListNode:
def __init__(self, val=0, prev=None, next=None):
self.val = val
self.prev = prev
self.next = next
class DoublyLinkedList:
def __init__(self):
self.head = None
self.tail = None
def insert_at_beginning(self, val):
new_node = DoublyListNode(val)
if not self.head:
self.head = self.tail = new_node
else:
new_node.next = self.head
self.head.prev = new_node
self.head = new_node
def insert_at_end(self, val):
new_node = DoublyListNode(val)
if not self.tail:
self.head = self.tail = new_node
else:
self.tail.next = new_node
new_node.prev = self.tail
self.tail = new_node
def delete_node(self, val):
current = self.head
while current:
if current.val == val:
if current.prev:
current.prev.next = current.next
else:
self.head = current.next
if current.next:
current.next.prev = current.prev
else:
self.tail = current.prev
return
current = current.next
def display_forward(self):
result = []
current = self.head
while current:
result.append(current.val)
current = current.next
return result
def display_backward(self):
result = []
current = self.tail
while current:
result.append(current.val)
current = current.prev
return result
3. Circular Linked List
class CircularListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
class CircularLinkedList:
def __init__(self):
self.head = None
def insert_at_beginning(self, val):
new_node = CircularListNode(val)
if not self.head:
self.head = new_node
new_node.next = new_node
else:
# Find the last node
current = self.head
while current.next != self.head:
current = current.next
new_node.next = self.head
current.next = new_node
self.head = new_node
def insert_at_end(self, val):
new_node = CircularListNode(val)
if not self.head:
self.head = new_node
new_node.next = new_node
else:
current = self.head
while current.next != self.head:
current = current.next
current.next = new_node
new_node.next = self.head
def delete_node(self, val):
if not self.head:
return
# If only one node
if self.head.next == self.head and self.head.val == val:
self.head = None
return
# If head needs to be deleted
if self.head.val == val:
current = self.head
while current.next != self.head:
current = current.next
current.next = self.head.next
self.head = self.head.next
return
# Delete other nodes
current = self.head
while current.next != self.head:
if current.next.val == val:
current.next = current.next.next
return
current = current.next
def display(self):
if not self.head:
return []
result = []
current = self.head
while True:
result.append(current.val)
current = current.next
if current == self.head:
break
return result
4. Most Asked LeetCode Questions by Category
Easy Level Questions:
- Reverse Linked List (206)
- Merge Two Sorted Lists (21)
- Remove Duplicates from Sorted List (83)
- Linked List Cycle (141)
- Palindrome Linked List (234)
- Delete Node in a Linked List (237)
Medium Level Questions:
- Add Two Numbers (2)
- Remove Nth Node From End (19)
- Swap Nodes in Pairs (24)
- Rotate List (61)
- Partition List (86)
- Linked List Cycle II (142)
Hard Level Questions:
- Merge k Sorted Lists (23)
- Reverse Nodes in k-Group (25)
5. Common Patterns and Solutions
Pattern 1: Two Pointers (Fast & Slow)
"if not head " this means it it used to check if the linked list is null that is empty , just to check for the edge case
# Detect cycle in linked list
def has_cycle(head):
if not head or not head.next:
return False
slow = fast = head
while fast and fast.next:
slow = slow.next
fast = fast.next.next
if slow == fast:
return True
return False
# Find middle of linked list
def find_middle(head):
if not head:
return None
slow = fast = head
while fast and fast.next:
slow = slow.next
fast = fast.next.next
return slow
Pattern 2: Reverse Linked List Variations
# Reverse entire linked list
def reverse_list(head):
prev = None
current = head
while current:
next_temp = current.next
current.next = prev
prev = current
current = next_temp
return prev
# Reverse first n nodes
def reverse_n(head, n):
if n == 1:
return head
prev = None
current = head
count = 0
while current and count < n:
next_temp = current.next
current.next = prev
prev = current
current = next_temp
count += 1
if head:
head.next = current
return prev
Pattern 3: Merge Operations
# Merge two sorted lists
def merge_two_lists(l1, l2):
dummy = ListNode(0)
current = dummy
while l1 and l2:
if l1.val <= l2.val:
current.next = l1
l1 = l1.next
else:
current.next = l2
l2 = l2.next
current = current.next
current.next = l1 or l2
return dummy.next
6. Key Problem-Solving Techniques
1. Dummy Node Pattern
- Use when you might need to modify the head
- Simplifies edge cases
2. Two Pointers
- Fast/Slow for cycle detection and finding middle
- Distance-based for removing nth from end
3. Stack for Palindrome Check
- Store first half, compare with second half
4. Recursion for Reversal
- Clean recursive solutions for reversal problems
7. Time & Space Complexities
| Operation | Single LL | Double LL | Circular LL |
|---|---|---|---|
| Insert Beginning | O(1) | O(1) | O(1) |
| Insert End | O(n) | O(1) | O(n) |
| Delete | O(n) | O(n) | O(n) |
| Search | O(n) | O(n) | O(n) |
| Space | O(1) | O(1) | O(1) |
Fibonacci
# Recursive implementation to calculate the n-th Fibonacci number
def fibonacci(n):
# Base case: n = 0 or 1
if n <= 1:
return n
# Recursive case: fib(n) = fib(n - 1) + fib(n - 2)
return fibonacci(n - 1) + fibonacci(n - 2)
Sorting
Insertion Sort
# Python implementation of Insertion Sort
def insertionSort(arr):
# Traverse through 1 to len(arr)
for i in range(1, len(arr)):
j = i - 1
while j >= 0 and arr[j + 1] < arr[j]:
# arr[j] and arr[j + 1] are out of order so swap them
tmp = arr[j + 1]
arr[j + 1] = arr[j]
arr[j] = tmp
j -= 1
return arr
Merge Sort
# Implementation of MergeSort
def mergeSort(arr, s, e):
if e - s + 1 <= 1:
return arr
# The middle index of the array
m = (s + e) // 2
# Sort the left half
mergeSort(arr, s, m)
# Sort the right half
mergeSort(arr, m + 1, e)
# Merge sorted halfs
merge(arr, s, m, e)
return arr
# Merge in-place
def merge(arr, s, m, e):
# Copy the sorted left & right halfs to temp arrays
L = arr[s: m + 1]
R = arr[m + 1: e + 1]
i = 0 # index for L
j = 0 # index for R
k = 0 # index for arr
# Merge the two sorted halfs into the original array
while i < len(L) and j < len(R):
if L[i] <= R[j]:
arr[k] = L[i]
i += 1
else:
arr[k] = R[j]
j += 1
k += 1
# One of the halfs will have elements remaining
while i < len(L):
arr[k] = L[i]
i += 1
k += 1
while j < len(R):
arr[k] = R[j]
j += 1
k += 1
Quick Sort
# Implementation of QuickSort
def quickSort(arr, s, e):
if e - s + 1 <= 1:
return
pivot = arr[e]
left = s # pointer for left side
# Partition: elements smaller than pivot on left side
for i in range(s, e):
if arr[i] < pivot:
tmp = arr[left]
arr[left] = arr[i]
arr[i] = tmp
left += 1
# Move pivot in-between left & right sides
arr[e] = arr[left]
arr[left] = pivot
# Quick sort left side
quickSort(arr, s, left - 1)
# Quick sort right side
quickSort(arr, left + 1, e)
return arr
Bucket Sort
def bucketSort(arr):
# Assuming arr only contains 0, 1 or 2
counts = [0, 0, 0]
# Count the quantity of each val in arr
for n in arr:
counts[n] += 1
# Fill each bucket in the original array
i = 0
for n in range(len(counts)):
for j in range(counts[n]):
arr[i] = n
i += 1
return arr
ADDITIONAL SORTING ALGORITHMS FROM MERGED FILES
def bubble_sort(arr):
"""
Bubble Sort - Time Complexity: O(n²)
Repeatedly steps through the list, compares adjacent elements and swaps them if in wrong order.
"""
n = len(arr)
for i in range(n):
for j in range(0, n-i-1):
if arr[j] > arr[j+1]:
arr[j], arr[j+1] = arr[j+1], arr[j]
return arr
def selection_sort(arr):
"""
Selection Sort - Time Complexity: O(n²)
Finds minimum element in unsorted region and swaps with first unsorted element.
"""
n = len(arr)
for i in range(n):
min_idx = i
for j in range(i+1, n):
if arr[j] < arr[min_idx]:
min_idx = j
arr[i], arr[min_idx] = arr[min_idx], arr[i]
return arr
def merge_sort(arr):
"""
Merge Sort - Time Complexity: O(n log n)
Divides array into two halves, recursively sorts them and then merges the sorted halves.
"""
if len(arr) <= 1:
return arr
# Divide array into two halves
mid = len(arr) // 2
left = arr[:mid]
right = arr[mid:]
# Recursively sort the two halves
left = merge_sort(left)
right = merge_sort(right)
# Merge the sorted halves
return merge(left, right)
def merge(left, right):
result = []
i = j = 0
while i < len(left) and j < len(right):
if left[i] <= right[j]:
result.append(left[i])
i += 1
else:
result.append(right[j])
j += 1
result.extend(left[i:])
result.extend(right[j:])
return result
def quick_sort(arr):
if len(arr) <= 1:
return arr
pivot = arr[0]
less = [x for x in arr[1:] if x <= pivot]
greater = [x for x in arr[1:] if x > pivot]
return quick_sort(less) + [pivot] + quick_sort(greater)
def bucket_sort(arr):
counts = [0, 0, 0] # For values 0-2
for n in arr:
counts[n] += 1
i = 0
for n in range(len(counts)):
for _ in range(counts[n]):
arr[i] = n
i += 1
return arr
---
Binary Search
Search Array
arr = [1, 3, 3, 4, 5, 6, 7, 8]
# Python implementation of Binary Search
def binarySearch(arr, target):
L, R = 0, len(arr) - 1
while L <= R:
mid = (L + R) // 2
if target > arr[mid]:
L = mid + 1
elif target < arr[mid]:
R = mid - 1
else:
return mid
return -1
Trees
Binary Search Tree Search
class TreeNode:
def __init__(self, val):
self.val = val
self.left = None
self.right = None
def search(root, target):
if not root: # this means if root is null or none return False
return False
if target > root.val:
return search(root.right, target)
elif target < root.val:
return search(root.left, target)
else:
return True
BST Insert and Remove
class TreeNode:
def __init__(self, val):
self.val = val
self.left = None
self.right = None
# Insert a new node and return the root of the BST.
def insert(root, val):
if not root:
return TreeNode(val)
if val > root.val:
root.right = insert(root.right, val)
elif val < root.val:
root.left = insert(root.left, val)
return root
# Return the minimum value node of the BST.
def minValueNode(root):
curr = root
while curr and curr.left:
curr = curr.left
return curr
# Remove a node and return the root of the BST.
def remove(root, val):
if not root:
return None
if val > root.val:
root.right = remove(root.right, val)
elif val < root.val:
root.left = remove(root.left, val)
else:
if not root.left:
return root.right
elif not root.right:
return root.left
else:
minNode = minValueNode(root.right)
root.val = minNode.val
root.right = remove(root.right, minNode.val)
return root
Depth-First Search
class TreeNode:
def __init__(self, val):
self.val = val
self.left = None
self.right = None
def inorder(root):
if not root:
return
inorder(root.left)
print(root.val)
inorder(root.right)
def preorder(root):
if not root:
return
print(root.val)
preorder(root.left)
preorder(root.right)
def postorder(root):
if not root:
return
postorder(root.left)
postorder(root.right)
print(root.val)
Breadth-First Search
from collections import deque
class TreeNode:
def __init__(self, val):
self.val = val
self.left = None
self.right = None
def bfs(root):
queue = deque()
if root:
queue.append(root)
level = 0
while len(queue) > 0:
print("level: ", level)
for i in range(len(queue)):
curr = queue.popleft()
print(curr.val)
if curr.left:
queue.append(curr.left)
if curr.right:
queue.append(curr.right)
level += 1
Understanding Trees in Data Structures
I'll explain the key concepts covered in the Trees section of the NeetCode course, with Python code examples for each topic.
1. Binary Search Trees (BST)
A Binary Search Tree is a tree data structure where each node has at most two children, and for any node:
- All values in the left subtree are less than the node's value
- All values in the right subtree are greater than the node's value
This property makes searching efficient with O(log n) time complexity in balanced trees.
class TreeNode:
def __init__(self, val):
self.val = val
self.left = None
self.right = None
def search(root, target):
if not root:
return False
if target > root.val:
return search(root.right, target)
elif target < root.val:
return search(root.left, target)
else:
return True
2. BST Insert and Remove Operations
Insert
To insert a value in a BST, we recursively traverse the tree to find the appropriate position:
def insert(root, val):
if not root:
return TreeNode(val)
if val > root.val:
root.right = insert(root.right, val)
elif val < root.val:
root.left = insert(root.left, val)
return root
Remove
Removing a node is more complex, especially when the node has two children:
def minValueNode(root):
curr = root
while curr and curr.left:
curr = curr.left
return curr
def remove(root, val):
if not root:
return None
if val > root.val:
root.right = remove(root.right, val)
elif val < root.val:
root.left = remove(root.left, val)
else:
# Case 1: Leaf node or node with one child
if not root.left:
return root.right
elif not root.right:
return root.left
# Case 2: Node with two children
# Find inorder successor (smallest in right subtree)
minNode = minValueNode(root.right)
root.val = minNode.val
# Delete the inorder successor
root.right = remove(root.right, minNode.val)
return root
3. Depth-First Search (DFS)
DFS explores as far as possible along each branch before backtracking. There are three main types of DFS traversals:
Inorder Traversal (Left → Root → Right)
def inorder(root):
if not root:
return
inorder(root.left)
print(root.val) # Process the node
inorder(root.right)
Preorder Traversal (Root → Left → Right)
def preorder(root):
if not root:
return
print(root.val) # Process the node
preorder(root.left)
preorder(root.right)
Postorder Traversal (Left → Right → Root)
def postorder(root):
if not root:
return
postorder(root.left)
postorder(root.right)
print(root.val) # Process the node
4. Breadth-First Search (BFS)
BFS explores all nodes at the present depth before moving to nodes at the next depth level. It uses a queue to keep track of nodes to visit:
from collections import deque
def bfs(root):
queue = deque()
if root:
queue.append(root)
level = 0
while queue:
print(f"level: {level}")
# Process all nodes at current level
for i in range(len(queue)):
curr = queue.popleft()
print(curr.val)
# Add children to queue for next level
if curr.left:
queue.append(curr.left)
if curr.right:
queue.append(curr.right)
level += 1
Key Applications and Problems
-
BST Operations:
- Search in a Binary Search Tree
- Finding the Lowest Common Ancestor in a BST
-
BST Modifications:
- Insert into a Binary Search Tree
- Delete Node in a BST
-
Tree Traversals:
- Binary Tree Inorder Traversal
- Finding the Kth Smallest Element in a BST
- Constructing a Binary Tree from Preorder and Inorder Traversal
-
Level Order Traversal:
- Binary Tree Level Order Traversal
- Binary Tree Right Side View
Time and Space Complexity
-
BST Operations (search, insert, delete):
- Time: O(h) where h is the height of the tree (O(log n) for balanced trees, O(n) worst case)
- Space: O(h) for recursion stack
-
DFS Traversals:
- Time: O(n) where n is the number of nodes
- Space: O(h) for recursion stack
-
BFS Traversal:
- Time: O(n)
- Space: O(w) where w is the maximum width of the tree
Understanding these tree operations and traversal methods is fundamental for solving many complex problems in computer science and forms the basis for more advanced tree-based data structures.
Backtracking
Tree Maze
class TreeNode:
def __init__(self, val):
self.val = val
self.left = None
self.right = None
def canReachLeaf(root):
if not root or root.val == 0:
return False
if not root.left and not root.right:
return True
if canReachLeaf(root.left):
return True
if canReachLeaf(root.right):
return True
return False
def leafPath(root, path):
if not root or root.val == 0:
return False
path.append(root.val)
if not root.left and not root.right:
return True
if leafPath(root.left, path):
return True
if leafPath(root.right, path):
return True
path.pop()
return False
Heap / Priority Queue
Push and Pop
# Min Heap
class Heap:
def __init__(self):
self.heap = [0]
def push(self, val):
self.heap.append(val)
i = len(self.heap) - 1
# Percolate up
while i > 1 and self.heap[i] < self.heap[i // 2]:
tmp = self.heap[i]
self.heap[i] = self.heap[i // 2]
self.heap[i // 2] = tmp
i = i // 2
def pop(self):
if len(self.heap) == 1:
return None
if len(self.heap) == 2:
return self.heap.pop()
res = self.heap[1]
# Move last value to root
self.heap[1] = self.heap.pop()
i = 1
# Percolate down
while 2 * i < len(self.heap):
if (2 * i + 1 < len(self.heap) and
self.heap[2 * i + 1] < self.heap[2 * i] and
self.heap[i] > self.heap[2 * i + 1]):
# Swap right child
tmp = self.heap[i]
self.heap[i] = self.heap[2 * i + 1]
self.heap[2 * i + 1] = tmp
i = 2 * i + 1
elif self.heap[i] > self.heap[2 * i]:
# Swap left child
tmp = self.heap[i]
self.heap[i] = self.heap[2 * i]
self.heap[2 * i] = tmp
i = 2 * i
else:
break
return res
def top(self):
if len(self.heap) > 1:
return self.heap[1]
return None
def heapify(self, arr):
# 0-th position is moved to the end
arr.append(arr[0])
self.heap = arr
cur = (len(self.heap) - 1) // 2
while cur > 0:
# Percolate down
i = cur
while 2 * i < len(self.heap):
if (2 * i + 1 < len(self.heap) and
self.heap[2 * i + 1] < self.heap[2 * i] and
self.heap[i] > self.heap[2 * i + 1]):
# Swap right child
tmp = self.heap[i]
self.heap[i] = self.heap[2 * i + 1]
self.heap[2 * i + 1] = tmp
i = 2 * i + 1
elif self.heap[i] > self.heap[2 * i]:
# Swap left child
tmp = self.heap[i]
self.heap[i] = self.heap[2 * i]
self.heap[2 * i] = tmp
i = 2 * i
else:
break
cur -= 1
Heapify
import heapq
# Min Heap
class Heap:
# ... not showing push, pop to save space.
def heapify(self, arr):
# 0-th position is moved to the end
arr.append(arr[0])
self.heap = arr
cur = (len(self.heap) - 1) // 2
while cur > 0:
# Percolate down
i = cur
while 2 * i < len(self.heap):
if (2 * i + 1 < len(self.heap) and
self.heap[2 * i + 1] < self.heap[2 * i] and
self.heap[i] > self.heap[2 * i + 1]):
# Swap right child
tmp = self.heap[i]
self.heap[i] = self.heap[2 * i + 1]
self.heap[2 * i + 1] = tmp
i = 2 * i + 1
elif self.heap[i] > self.heap[2 * i]:
# Swap left child
tmp = self.heap[i]
self.heap[i] = self.heap[2 * i]
self.heap[2 * i] = tmp
i = 2 * i
else:
break
cur -= 1
Hashing
Hash Usage
names = ["alice", "brad", "collin", "brad", "dylan", "kim"]
countMap = {}
for name in names:
# If countMap does not contain name
if name not in countMap:
countMap[name] = 1
else:
countMap[name] += 1
Hash Implementation
class Pair:
def __init__(self, key, val):
self.key = key
self.val = val
class HashMap:
def __init__(self):
self.size = 0
self.capacity = 2
self.map = [None, None]
def hash(self, key):
index = 0
for c in key:
index += ord(c)
return index % self.capacity
def get(self, key):
index = self.hash(key)
while self.map[index] != None:
if self.map[index].key == key:
return self.map[index].val
index += 1
index = index % self.capacity
return None
def put(self, key, val):
index = self.hash(key)
while True:
if self.map[index] == None:
self.map[index] = Pair(key, val)
self.size += 1
if self.size >= self.capacity // 2:
self.rehash()
return
elif self.map[index].key == key:
self.map[index].val = val
return
index += 1
index = index % self.capacity
def remove(self, key):
if not self.get(key):
return
index = self.hash(key)
while True:
if self.map[index].key == key:
self.map[index] = None
self.size -= 1
return
index += 1
index = index % self.capacity
def rehash(self):
self.capacity = 2 * self.capacity
newMap = []
for i in range(self.capacity):
newMap.append(None)
oldMap = self.map
self.map = newMap
self.size = 0
for pair in oldMap:
if pair:
self.put(pair.key, pair.val)
def print(self):
for pair in self.map:
if pair:
print(pair.key, pair.val)
Graphs
Matrix DFS
# Matrix (2D Grid)
grid = [[0, 0, 0, 0],
[1, 1, 0, 0],
[0, 0, 0, 1],
[0, 1, 0, 0]]
## This is used to find the number of unique paths
# Count paths (backtracking)
def dfs(grid, r, c, visit):
ROWS, COLS = len(grid), len(grid[0])
if (min(r, c) < 0 or
r == ROWS or c == COLS or
(r, c) in visit or grid[r][c] == 1):
return 0
if r == ROWS - 1 and c == COLS - 1:
return 1
visit.add((r, c))
count = 0
count += dfs(grid, r + 1, c, visit)
count += dfs(grid, r - 1, c, visit)
count += dfs(grid, r, c + 1, visit)
count += dfs(grid, r, c - 1, visit)
visit.remove((r, c))
return count
Matrix BFS
from collections import deque
# Matrix (2D Grid)
grid = [[0, 0, 0, 0],
[1, 1, 0, 0],
[0, 0, 0, 1],
[0, 1, 0, 0]]
# Shortest path from top left to bottom right
def bfs(grid):
ROWS, COLS = len(grid), len(grid[0])
visit = set()
queue = deque()
queue.append((0, 0))
visit.add((0, 0))
length = 0
while queue:
for i in range(len(queue)):
r, c = queue.popleft()
if r == ROWS - 1 and c == COLS - 1:
return length
neighbors = [[0, 1], [0, -1], [1, 0], [-1, 0]]
for dr, dc in neighbors:
if (min(r + dr, c + dc) < 0 or
r + dr == ROWS or c + dc == COLS or
(r + dr, c + dc) in visit or grid[r + dr][c + dc] == 1):
continue
queue.append((r + dr, c + dc))
visit.add((r + dr, c + dc))
length += 1
Adjacency List
from collections import deque
# GraphNode used for adjacency list
class GraphNode:
def __init__(self, val):
self.val = val
self.neighbors = []
# Or use a HashMap
adjList = { "A": [], "B": [] }
# Given directed edges, build an adjacency list
edges = [["A", "B"], ["B", "C"], ["B", "E"], ["C", "E"], ["E", "D"]]
adjList = {}
for src, dst in edges:
if src not in adjList:
adjList[src] = []
if dst not in adjList:
adjList[dst] = []
adjList[src].append(dst)
# Count paths (backtracking)
def dfs(node, target, adjList, visit):
if node in visit:
return 0
if node == target:
return 1
count = 0
visit.add(node)
for neighbor in adjList[node]:
count += dfs(neighbor, target, adjList, visit)
visit.remove(node)
return count
# Shortest path from node to target
def bfs(node, target, adjList):
length = 0
visit = set()
visit.add(node)
queue = deque()
queue.append(node)
while queue:
for i in range(len(queue)):
curr = queue.popleft()
if curr == target:
return length
for neighbor in adjList[curr]:
if neighbor not in visit:
visit.add(neighbor)
queue.append(neighbor)
length += 1
return length
Dynamic Programming
1D DP
# Brute Force
def bruteForce(n):
if n <= 1:
return n
return bruteForce(n - 1) + bruteForce(n - 2)
# Memoization
def memoization(n, cache):
if n <= 1:
return n
if n in cache:
return cache[n]
cache[n] = memoization(n - 1) + memoization(n - 2)
return cache[n]
# Dynamic Programming
def dp(n):
if n < 2:
return n
dp = [0, 1]
i = 2
while i <= n:
tmp = dp[1]
dp[1] = dp[0] + dp[1]
dp[0] = tmp
i += 1
return dp[1]
2D DP
# Brute Force - Time: O(2 ^ (n + m)), Space: O(n + m)
def bruteForce(r, c, rows, cols):
if r == rows or c == cols:
return 0
if r == rows - 1 and c == cols - 1:
return 1
return (bruteForce(r + 1, c, rows, cols) +
bruteForce(r, c + 1, rows, cols))
print(bruteForce(0, 0, 4, 4))
# Memoization - Time and Space: O(n * m)
def memoization(r, c, rows, cols, cache):
if r == rows or c == cols:
return 0
if cache[r][c] > 0:
return cache[r][c]
if r == rows - 1 and c == cols - 1:
return 1
cache[r][c] = (memoization(r + 1, c, rows, cols, cache) +
memoization(r, c + 1, rows, cols, cache))
return cache[r][c]
print(memoization(0, 0, 4, 4, [[0] * 4 for i in range(4)]))
# Dynamic Programming - Time: O(n * m), Space: O(m), where m is num of cols
def dp(rows, cols):
prevRow = [0] * cols
for r in range(rows - 1, -1, -1):
curRow = [0] * cols
curRow[cols - 1] = 1
for c in range(cols - 2, -1, -1):
curRow[c] = curRow[c + 1] + prevRow[c]
prevRow = curRow
return prevRow[0]
Bit Manipulation
Bit Operator
# AND
n = 1 & 1
# OR
n = 1 | 0
# XOR ## 0 is same else different
n = 0 ^ 1
# NOT (negation)
n = ~n
# Bit shifting
n = 1
n = n << 1 # left shift , *2 (multiply by 2 ),
n = n >> 1 #right shift , /2 (divide by 2 )
# Counting Bits
def countBits(n):
count = 0
while n > 0:
if n & 1 == 1:
count += 1
n = n >> 1 # same as n // 2
return count
ADVANCE
1. Arrays - 0. Kadane's Algo (and Variants)
# Brute Force: O(n^2)
def bruteForce(nums):
maxSum = nums[0]
for i in range(len(nums)):
curSum = 0
for j in range(i, len(nums)):
curSum += nums[j]
maxSum = max(maxSum, curSum)
return maxSum
# Kadane's Algorithm: O(n)
def kadanes(nums):
maxSum = nums[0]
curSum = 0
for n in nums:
curSum = max(curSum, 0)
curSum += n
maxSum = max(maxSum, curSum)
return maxSum
# Return the left and right index of the max subarray sum,
# assuming there's exactly one result (no ties).
# Sliding window variation of Kadane's: O(n)
def slidingWindow(nums):
maxSum = nums[0]
curSum = 0
maxL, maxR = 0, 0
L = 0
for R in range(len(nums)):
if curSum < 0:
curSum = 0
L = R
curSum += nums[R]
if curSum > maxSum:
maxSum = curSum
maxL, maxR = L, R
return [maxL, maxR]
1. Arrays - 1. Sliding Window Fixed
# Check if array contains a pair of duplicate values,
# where the two duplicates are no farther than k positions from
# eachother (i.e. arr[i] == arr[j] and abs(i - j) + 1 <= k).
# O(n * k)
def closeDuplicatesBruteForce(nums, k):
for L in range(len(nums)):
for R in range(L + 1, min(len(nums), L + k)):
if nums[L] == nums[R]:
return True
return False
# Same problem using sliding window.
# O(n)
def closeDuplicates(nums, k):
window = set() # Cur window of size <= k
L = 0
for R in range(len(nums)):
if R - L + 1 > k:
window.remove(nums[L])
L += 1
if nums[R] in window:
return True
window.add(nums[R])
return False
1. Arrays - 2. Sliding Window Variable
# Find the length of longest subarray with the same
# value in each position: O(n)
def longestSubarray(nums):
length = 0
L = 0
for R in range(len(nums)):
if nums[L] != nums[R]:
L = R
length = max(length, R - L + 1)
return length
# Find length of the minimum size subarray where the sum is
# greater than or equal to the target.
# Assume all values in the input are positive.
# O(n)
def shortestSubarray(nums, target):
L, total = 0, 0
length = float("inf")
for R in range(len(nums)):
total += nums[R]
while total >= target:
length = min(R - L + 1, length)
total -= nums[L]
L += 1
return 0 if length == float("inf") else length
1. Arrays - 3. Two Pointers
# Given a string of characters, return true if it's a palindrome,
# return false otherwise: O(n)
def isPalindrome(word):
L, R = 0, len(word) - 1
while L < R:
if word[L] != word[R]:
return False
L += 1
R -= 1
return True
# Given a sorted array of integers, return the indices
# of two elements (in different positions) that sum up to
# the target value. Assume there is exactly one solution.
# O(n)
def targetSum(nums, target):
L, R = 0, len(nums) - 1
while L < R:
if nums[L] + nums[R] > target:
R -= 1
elif nums[L] + nums[R] < target:
L += 1
else:
return [L, R]
1. Arrays - 4. Prefix Sums
class PrefixSum:
def __init__(self, nums):
self.prefix = []
total = 0
for n in nums:
total += n
self.prefix.append(total)
def rangeSum(self, left, right):
preRight = self.prefix[right]
preLeft = self.prefix[left - 1] if left > 0 else 0
return (preRight - preLeft)
2. Linked Lists - 1. Fast and Slow Pointers
# Find the middle of a linked list with two pointers.
# Time: O(n), Space: O(1)
def middleOfList(head):
slow, fast = head, head
while fast and fast.next:
slow = slow.next
fast = fast.next.next
return slow
# Determine if the linked list contains a cycle.
# Time: O(n), Space: O(1)
def hasCycle(head):
slow, fast = head, head
while fast and fast.next:
slow = slow.next
fast = fast.next.next
if slow == fast:
return True
return False
# Determine if the linked list contains a cycle and
# return the beginning of the cycle, otherwise return null.
# Time: O(n), Space: O(1)
def cycleStart(head):
slow, fast = head, head
while fast and fast.next:
slow = slow.next
fast = fast.next.next
if slow == fast:
break
if not fast or not fast.next:
return None
slow2 = head
while slow != slow2:
slow = slow.next
slow2 = slow2.next
return slow
3. Tries - 1. Trie
USED IN PLACE OF HASHMAP WHERE THE INSERTETION, SEARCH AND FIND PREFIX IS O(1)
class TrieNode:
def __init__(self):
self.children = {}
self.word = False
class Trie:
def __init__(self):
self.root = TrieNode()
def insert(self, word):
curr = self.root
for c in word:
if c not in curr.children:
curr.children[c] = TrieNode()
curr = curr.children[c]
curr.word = True
def search(self, word):
curr = self.root
for c in word:
if c not in curr.children:
return False
curr = curr.children[c]
return curr.word
def startsWith(self, prefix):
curr = self.root
for c in prefix:
if c not in curr.children:
return False
curr = curr.children[c]
return True
3. Tries - 2. Union Find
To find:Disjoint set,connected components and cycle detection
class UnionFind:
def __init__(self, n):
self.par = {}
self.rank = {}
for i in range(1, n + 1):
self.par[i] = i
self.rank[i] = 0
# Find parent of n, with path compression. , used to find the parent
def find(self, n):
p = self.par[n]
while p != self.par[p]:
self.par[p] = self.par[self.par[p]]
p = self.par[p]
return p
# Union by height / rank.
# Return false if already connected, true otherwise.
def union(self, n1, n2):
p1, p2 = self.find(n1), self.find(n2)
if p1 == p2:
return False
if self.rank[p1] > self.rank[p2]:
self.par[p2] = p1
elif self.rank[p1] < self.rank[p2]:
self.par[p1] = p2
else:
self.par[p1] = p2
self.rank[p2] += 1
return True
ARRAY TYPE UNION FIND
class UnionFind:
def __init__(self, n):
# parent[i] = parent of node i
self.par = [i for i in range(n + 1)]
# rank[i] = rank (approx. tree height) of node i
self.rank = [0] * (n + 1)
# Find with path compression
def find(self, n):
if self.par[n] != n:
self.par[n] = self.find(self.par[n]) # path compression
return self.par[n]
# Union by rank
def union(self, n1, n2):
p1, p2 = self.find(n1), self.find(n2)
if p1 == p2:
return False # already connected
if self.rank[p1] > self.rank[p2]:
self.par[p2] = p1
elif self.rank[p1] < self.rank[p2]:
self.par[p1] = p2
else:
self.par[p1] = p2
self.rank[p2] += 1
return True
3. Tries - 3. Segment Tree
class SegmentTree:
def __init__(self, total, L, R):
self.sum = total
self.left = None
self.right = None
self.L = L
self.R = R
# O(n)
@staticmethod
def build(nums, L, R):
if L == R:
return SegmentTree(nums[L], L, R)
M = (L + R) // 2
root = SegmentTree(0, L, R)
root.left = SegmentTree.build(nums, L, M)
root.right = SegmentTree.build(nums, M + 1, R)
root.sum = root.left.sum + root.right.sum
return root
# O(logn)
def update(self, index, val):
if self.L == self.R:
self.sum = val
return
M = (self.L + self.R) // 2
if index > M:
self.right.update(index, val)
else:
self.left.update(index, val)
self.sum = self.left.sum + self.right.sum
# O(logn)
def rangeQuery(self, L, R):
if L == self.L and R == self.R:
return self.sum
M = (self.L + self.R) // 2
if L > M:
return self.right.rangeQuery(L, R)
elif R <= M:
return self.left.rangeQuery(L, R)
else:
return (self.left.rangeQuery(L, M) +
self.right.rangeQuery(M + 1, R))
3. Tries - 4. Iterative DFS
# Definition for a binary tree node.
class TreeNode:
def __init__(self, val, left, right):
self.val = val
self.left = left
self.right = right
# Time and space: O(n)
def inorder(root):
stack = []
curr = root
while curr or stack:
if curr:
stack.append(curr)
curr = curr.left
else:
curr = stack.pop()
print(curr.val)
curr = curr.right
# Time and space: O(n)
def preorder(root):
stack = []
curr = root
while curr or stack:
if curr:
print(curr.val)
if curr.right:
stack.append(curr.right)
curr = curr.left
else:
curr = stack.pop()
# Time and space: O(n)
def postorder(root):
stack = [root]
visit = [False]
while stack:
curr, visited = stack.pop(), visit.pop()
if curr:
if visited:
print(curr.val)
else:
stack.append(curr)
visit.append(True)
stack.append(curr.right)
visit.append(False)
stack.append(curr.left)
visit.append(False)
4. Heaps - 1. Heaps
import heapq
class Median:
def __init__(self):
self.small, self.large = [], []
def insert(self, num):
# Push to the max heap and swap with min heap if needed.
heapq.heappush(self.small, -1 * num)
if (self.small and self.large and (-1 * self.small[0]) > self.large[0]):
val = -1 * heapq.heappop(self.small)
heapq.heappush(self.large, val)
# Handle uneven size
if len(self.small) > len(self.large) + 1:
val = -1 * heapq.heappop(self.small)
heapq.heappush(self.large, val)
if len(self.large) > len(self.small) + 1:
val = heapq.heappop(self.large)
heapq.heappush(self.small, -1 * val)
def getMedian(self):
if len(self.small) > len(self.large):
return -1 * self.small[0]
elif len(self.large) > len(self.small):
return self.large[0]
# Even # of elements, return avg of two middle nums
return (-1 * self.small[0] + self.large[0]) / 2
5. Backtracking - 1. Subsets
# Time: O(n * 2^n), Space: O(n)
def subsetsWithoutDuplicates(nums):
subsets, curSet = [], []
helper(0, nums, curSet, subsets)
return subsets
def helper(i, nums, curSet, subsets):
if i >= len(nums):
subsets.append(curSet.copy())
return
# decision to include nums[i]
curSet.append(nums[i])
helper(i + 1, nums, curSet, subsets)
curSet.pop()
# decision NOT to include nums[i]
helper(i + 1, nums, curSet, subsets)
# Time: O(n * 2^n), Space: O(n)
def subsetsWithDuplicates(nums):
nums.sort()
subsets, curSet = [], []
helper2(0, nums, curSet, subsets)
return subsets
def helper2(i, nums, curSet, subsets):
if i >= len(nums):
subsets.append(curSet.copy())
return
# decision to include nums[i]
curSet.append(nums[i])
helper2(i + 1, nums, curSet, subsets)
curSet.pop()
# decision NOT to include nums[i]
while i + 1 < len(nums) and nums[i] == nums[i + 1]:
i += 1
helper2(i + 1, nums, curSet, subsets)
5. Backtracking - 2. Combinations
# Given n numbers (1 - n), return all possible combinations
# of size k. (n choose k math problem).
# Time: O(k * 2^n)
def combinations(n, k):
combs = []
helper(1, [], combs, n, k)
return combs
def helper(i, curComb, combs, n, k):
if len(curComb) == k:
combs.append(curComb.copy())
return
if i > n:
return
# decision to include i
curComb.append(i)
helper(i + 1, curComb, combs, n, k)
curComb.pop()
# decision to NOT include i
helper(i + 1, curComb, combs, n, k)
# Time: O(k * C(n, k))
def combinations2(n, k):
combs = []
helper2(1, [], combs, n, k)
return combs
def helper2(i, curComb, combs, n, k):
if len(curComb) == k:
combs.append(curComb.copy())
return
if i > n:
return
for j in range(i, n + 1):
curComb.append(j)
helper2(j + 1, curComb, combs, n, k)
curComb.pop()
5. Backtracking - 3. Permutation
# Time: O(n^2 * n!)
def permutationsRecursive(nums):
return helper(0, nums)
def helper(i, nums):
if i == len(nums):
return [[]]
resPerms = []
perms = helper(i + 1, nums)
for p in perms:
for j in range(len(p) + 1):
pCopy = p.copy()
pCopy.insert(j, nums[i])
resPerms.append(pCopy)
return resPerms
# Time: O(n^2 * n!)
def permutationsIterative(nums):
perms = [[]]
for n in nums:
nextPerms = []
for p in perms:
for i in range(len(p) + 1):
pCopy = p.copy()
pCopy.insert(i, n)
nextPerms.append(pCopy)
perms = nextPerms
return perms
6. Graphs - 1. Dijkstra
import heapq
# Given a connected graph represented by a list of edges, where
# edge[0] = src, edge[1] = dst, and edge[2] = weight,
# find the shortest path from src to every other node in the
# graph. There are n nodes in the graph.
# O(E * logV), O(E * logE) is also correct.
def shortestPath(edges, n, src):
adj = {}
for i in range(1, n + 1):
adj[i] = []
# s = src, d = dst, w = weight
for s, d, w in edges:
adj[s].append([d, w])
shortest = {}
minHeap = [[0, src]]
while minHeap:
w1, n1 = heapq.heappop(minHeap)
if n1 in shortest:
continue
shortest[n1] = w1
for n2, w2 in adj[n1]:
if n2 not in shortest:
heapq.heappush(minHeap, [w1 + w2, n2])
return shortest
6. Graphs - 2. Prim's
import heapq
# Given a list of edges of a connected undirected graph,
# with nodes numbered from 1 to n,
# return a list edges making up the minimum spanning tree.
def minimumSpanningTree(edges, n):
adj = {}
for i in range(1, n + 1):
adj[i] = []
for n1, n2, weight in edges:
adj[n1].append([n2, weight])
adj[n2].append([n1, weight])
# Initialize the heap by choosing a single node
# (in this case 1) and pushing all its neighbors.
minHeap = []
for neighbor, weight in adj[1]:
heapq.heappush(minHeap, [weight, 1, neighbor])
mst = []
visit = set()
visit.add(1)
while len(visit) < n:
weight, n1, n2 = heapq.heappop(minHeap)
if n2 in visit:
continue
mst.append([n1, n2])
visit.add(n2)
for neighbor, weight in adj[n2]:
if neighbor not in visit:
heapq.heappush(minHeap, [weight, n2, neighbor])
return mst
6. Graphs - 3. Kruskal's
import heapq
class UnionFind:
def __init__(self, n):
self.par = {}
self.rank = {}
for i in range(1, n + 1):
self.par[i] = i
self.rank[i] = 0
# Find parent of n, with path compression.
def find(self, n):
p = self.par[n]
while p != self.par[p]:
self.par[p] = self.par[self.par[p]]
p = self.par[p]
return p
# Union by height / rank.
# Return false if already connected, true otherwise.
def union(self, n1, n2):
p1, p2 = self.find(n1), self.find(n2)
if p1 == p2:
return False
if self.rank[p1] > self.rank[p2]:
self.par[p2] = p1
elif self.rank[p1] < self.rank[p2]:
self.par[p1] = p2
else:
self.par[p1] = p2
self.rank[p2] += 1
return True
# Given an list of edges of a connected undirected graph,
# with nodes numbered from 1 to n,
# return a list edges making up the minimum spanning tree.
def minimumSpanningTree(edges, n):
minHeap = []
for n1, n2, weight in edges:
heapq.heappush(minHeap, [weight, n1, n2])
unionFind = UnionFind(n)
mst = []
while len(mst) < n - 1:
weight, n1, n2 = heapq.heappop(minHeap)
if not unionFind.union(n1, n2):
continue
mst.append([n1, n2])
return mst
6. Graphs - 4. Topological Sort
# Given a directed acyclical graph, return a valid
# topological ordering of the graph.
def topologicalSort(edges, n):
adj = {}
for i in range(1, n + 1):
adj[i] = []
for src, dst in edges:
adj[src].append(dst)
topSort = []
visit = set()
for i in range(1, n + 1):
dfs(i, adj, visit, topSort)
topSort.reverse()
return topSort
def dfs(src, adj, visit, topSort):
if src in visit:
return
visit.add(src)
for neighbor in adj[src]:
dfs(neighbor, adj, visit, topSort)
topSort.append(src)
7. Dynamic Programming - 1. 0-1 Knapsack
# Brute force Solution
# Time: O(2^n), Space: O(n)
def dfs(profit, weight, capacity):
return dfsHelper(0, profit, weight, capacity)
def dfsHelper(i, profit, weight, capacity):
if i == len(profit):
return 0
# Skip item i
maxProfit = dfsHelper(i + 1, profit, weight, capacity)
# Include item i
newCap = capacity - weight[i]
if newCap >= 0:
p = profit[i] + dfsHelper(i + 1, profit, weight, newCap)
maxProfit = max(maxProfit, p)
return maxProfit
# Memoization Solution
# Time: O(n * m), Space: O(n * m)
def memoization(profit, weight, capacity):
N, M = len(profit), capacity
cache = [[-1] * (M + 1) for _ in range(N)]
return memoHelper(0, profit, weight, capacity, cache)
def memoHelper(i, profit, weight, capacity, cache):
if i == len(profit):
return 0
if cache[i][capacity] != -1:
return cache[i][capacity]
cache[i][capacity] = memoHelper(i + 1, profit, weight, capacity, cache)
newCap = capacity - weight[i]
if newCap >= 0:
p = profit[i] + memoHelper(i + 1, profit, weight, newCap, cache)
cache[i][capacity] = max(cache[i][capacity], p)
return cache[i][capacity]
# Dynamic Programming Solution
# Time: O(n * m), Space: O(n * m)
def dp(profit, weight, capacity):
N, M = len(profit), capacity
dp = [[0] * (M + 1) for _ in range(N)]
for i in range(N):
dp[i][0] = 0
for c in range(M + 1):
if weight[0] <= c:
dp[0][c] = profit[0]
for i in range(1, N):
for c in range(1, M + 1):
skip = dp[i-1][c]
include = 0
if c - weight[i] >= 0:
include = profit[i] + dp[i-1][c - weight[i]]
dp[i][c] = max(include, skip)
return dp[N-1][M]
# Memory optimized Dynamic Programming Solution
# Time: O(n * m), Space: O(m)
def optimizedDp(profit, weight, capacity):
N, M = len(profit), capacity
dp = [0] * (M + 1)
for c in range(M + 1):
if weight[0] <= c:
dp[c] = profit[0]
for i in range(1, N):
curRow = [0] * (M + 1)
for c in range(1, M + 1):
skip = dp[c]
include = 0
if c - weight[i] >= 0:
include = profit[i] + dp[c - weight[i]]
curRow[c] = max(include, skip)
dp = curRow
return dp[M]
7. Dynamic Programming - 2. Unbounded Knapsack
# Brute force Solution
# Time: O(2^m), Space: O(m)
def dfs(profit, weight, capacity):
return dfsHelper(0, profit, weight, capacity)
def dfsHelper(i, profit, weight, capacity):
if i == len(profit):
return 0
maxProfit = dfsHelper(i + 1, profit, weight, capacity)
newCap = capacity - weight[i]
if newCap >= 0:
p = profit[i] + dfsHelper(i, profit, weight, newCap)
maxProfit = max(maxProfit, p)
return maxProfit
# Memoization Solution
# Time: O(n * m), Space: O(n * m)
def memoization(profit, weight, capacity):
N, M = len(profit), capacity
cache = [[-1] * (M + 1) for _ in range(N)]
return memoHelper(0, profit, weight, capacity, cache)
def memoHelper(i, profit, weight, capacity, cache):
if i == len(profit):
return 0
if cache[i][capacity] != -1:
return cache[i][capacity]
cache[i][capacity] = memoHelper(i + 1, profit, weight, capacity, cache)
newCap = capacity - weight[i]
if newCap >= 0:
p = profit[i] + memoHelper(i, profit, weight, newCap, cache)
cache[i][capacity] = max(cache[i][capacity], p)
return cache[i][capacity]
# Dynamic Programming Solution
# Time: O(n * m), Space: O(n * m)
def dp(profit, weight, capacity):
N, M = len(profit), capacity
dp = [[0] * (M + 1) for _ in range(N)]
for i in range(N):
dp[i][0] = 0
for c in range(M + 1):
if weight[0] <= c:
dp[0][c] = (c // weight[0]) * profit[0]
for i in range(1, N):
for c in range(1, M + 1):
skip = dp[i-1][c]
include = 0
if c - weight[i] >= 0:
include = profit[i] + dp[i][c - weight[i]]
dp[i][c] = max(include, skip)
return dp[N-1][M]
# Memory optimized Dynamic Programming Solution
# Time: O(n * m), Space: O(m)
def optimizedDp(profit, weight, capacity):
N, M = len(profit), capacity
dp = [0] * (M + 1)
for i in range(N):
curRow = [0] * (M + 1)
for c in range(1, M + 1):
skip = dp[c]
include = 0
if c - weight[i] >= 0:
include = profit[i] + curRow[c - weight[i]]
curRow[c] = max(include, skip)
dp = curRow
return dp[M]
7. Dynamic Programming - 3. LCS (Longest Common Subsequence)
# Time: O(2^(n + m)), Space: O(n + m)
def dfs(s1, s2):
return dfsHelper(s1, s2, 0, 0)
def dfsHelper(s1, s2, i1, i2):
if i1 == len(s1) or i2 == len(s2):
return 0
if s1[i1] == s2[i2]:
return 1 + dfsHelper(s1, s2, i1 + 1, i2 + 1)
else:
return max(dfsHelper(s1, s2, i1 + 1, i2),
dfsHelper(s1, s2, i1, i2 + 1))
# Time: O(n * m), Space: O(n + m)
def memoization(s1, s2):
N, M = len(s1), len(s2)
cache = [[-1] * M for _ in range(N)]
return memoHelper(s1, s2, 0, 0, cache)
def memoHelper(s1, s2, i1, i2, cache):
if i1 == len(s1) or i2 == len(s2):
return 0
if cache[i1][i2] != -1:
return cache[i1][i2]
if s1[i1] == s2[i2]:
cache[i1][i2] = 1 + memoHelper(s1, s2, i1 + 1, i2 + 1, cache)
else:
cache[i1][i2] = max(memoHelper(s1, s2, i1 + 1, i2, cache),
memoHelper(s1, s2, i1, i2 + 1, cache))
return cache[i1][i2]
# Time: O(n * m), Space: O(n + m)
def dp(s1, s2):
N, M = len(s1), len(s2)
dp = [[0] * (M+1) for _ in range(N+1)]
for i in range(N):
for j in range(M):
if s1[i] == s2[j]:
dp[i+1][j+1] = 1 + dp[i][j]
else:
dp[i+1][j+1] = max(dp[i][j+1], dp[i+1][j])
return dp[N][M]
# Time: O(n * m), Space: O(m)
def optimizedDp(s1, s2):
N, M = len(s1), len(s2)
dp = [0] * (M + 1)
for i in range(N):
curRow = [0] * (M + 1)
for j in range(M):
if s1[i] == s2[j]:
curRow[j+1] = 1 + dp[j]
else:
curRow[j+1] = max(dp[j + 1], curRow[j])
dp = curRow
return dp[M]
7. Dynamic Programming - 4. Palindromes
# Time: O(n^2), Space: O(n)
def longest(s):
length = 0
for i in range(len(s)):
# odd length
l, r = i, i
while l >= 0 and r < len(s) and s[l] == s[r]:
if (r - l + 1) > length:
length = r - l + 1
l -= 1
r += 1
# even length
l, r = i, i + 1
while l >= 0 and r < len(s) and s[l] == s[r]:
if (r - l + 1) > length:
length = r - l + 1
l -= 1
r += 1
return length
# Same solution, without duplicate code.
# Time: O(n^2), Space: O(n)
def longest(s):
length = 0
for i in range(len(s)):
# odd length
length = max(length, helper(s, i, i))
# even length
length = max(length, helper(s, i, i + 1))
return length
def helper(s, l, r):
maxLength = 0
while l >= 0 and r < len(s) and s[l] == s[r]:
if (r - l + 1) > maxLength:
maxLength = r - l + 1
l -= 1
r += 1
return maxLength
Advanced Topics
Advanced algorithms and data structures including trees, graphs, and optimization techniques.
extra algo might need to learn
trees: AVL red black B tree(B+ and B-)
Graphs travel salesmen problem
BackTracking Extra
Full Permutations (all n numbers)
# Time: O(n! * n) - n! permutations, each taking O(n) to copy
def permutations(n):
perms = []
helper([], perms, n)
return perms
def helper(curPerm, perms, n):
if len(curPerm) == n:
perms.append(curPerm.copy())
return
for j in range(1, n + 1):
if j not in curPerm: # Check if number is already used
curPerm.append(j)
helper(curPerm, perms, n)
curPerm.pop()
k-Permutations (k numbers from 1 to n)
# Time: O(P(n,k) * k) where P(n,k) = n!/(n-k)!
def permutations_k(n, k):
perms = []
helper_k([], perms, n, k)
return perms
def helper_k(curPerm, perms, n, k):
if len(curPerm) == k:
perms.append(curPerm.copy())
return
for j in range(1, n + 1):
if j not in curPerm: # Check if number is already used
curPerm.append(j)
helper_k(curPerm, perms, n, k)
curPerm.pop()
Extra approch from yt
from typing import List
def combine(n: int, k: int) -> List[List[int]]:
"""
Generate all combinations (order does NOT matter) of k numbers from [1..n]
using the binary decision tree approach: at each index, either pick or skip.
Time: O(C(n, k)) combinations; recursion overhead proportional to n
Space: O(k) for current solution + O(n) recursion
"""
res = []
sol = []
nums = list(range(1, n + 1))
def backtrack(i: int) -> None:
# If we have k elements, record one combination
if len(sol) == k:
res.append(sol[:])
return
# If we ran out of items, stop
if i == n:
return
# Option 1: don't pick nums[i]
backtrack(i + 1)
# Option 2: pick nums[i]
sol.append(nums[i])
backtrack(i + 1)
sol.pop()
backtrack(0)
return res
def permute(n: int, k: int) -> List[List[int]]:
"""
Generate all permutations (order DOES matter) of length k from [1..n]
using a binary-tree-like approach. The twist: when we "pick", we reset
the index to 0 to allow picking any remaining element again (in any order).
Time: O(P(n, k)) where P(n, k) = n! / (n-k)!
Space: O(k) for current solution + recursion
"""
res = []
sol = []
nums = list(range(1, n + 1))
def backtrack(i: int) -> None:
# If we have k elements, record one permutation
if len(sol) == k:
res.append(sol[:])
return
# If we exhausted indices without reaching k, stop
if i == n:
return
# Option 1: don't pick nums[i] at this depth, move on
backtrack(i + 1)
# Option 2: pick nums[i] (if not already used),
# then reset i to 0 to allow picking any element next
if nums[i] not in sol:
sol.append(nums[i])
backtrack(0) # reset to allow any index next
sol.pop()
backtrack(0)
return res
def combine_with_loop(n: int, k: int) -> List[List[int]]:
"""
Generate combinations using the for-loop style:
- At each level, iterate candidates starting from 'start'
- Ensures increasing indices -> avoids duplicates automatically
Time: O(C(n, k))
Space: O(k) + recursion
"""
res = []
sol = []
nums = list(range(1, n + 1))
def backtrack(start: int) -> None:
if len(sol) == k:
res.append(sol[:])
return
# Iterate indices from 'start' to end, ensuring combinations are unique
for i in range(start, n):
sol.append(nums[i])
backtrack(i + 1) # next choices must start after i
sol.pop()
backtrack(0)
return res
def permute_with_loop(n: int, k: int) -> List[List[int]]:
"""
Generate permutations using the for-loop style:
- At each level, iterate through all nums
- Use 'sol' membership to avoid reusing the same number in the current path
- Order matters, so we always start from 0 at each depth
Time: O(P(n, k))
Space: O(k) + recursion
"""
res = []
sol = []
nums = list(range(1, n + 1))
def backtrack() -> None:
if len(sol) == k:
res.append(sol[:])
return
for i in range(n):
if nums[i] in sol:
continue
sol.append(nums[i])
backtrack()
sol.pop()
backtrack()
return res
Linked List Extra
Trees
🌲 Binary Search Tree (BST) – Basics
In BST:
- Left subtree has values < node
- Right subtree has values > node
1. BST Node Class
Same as before:
class Node:
def __init__(self, val):
self.val = val
self.left = None
self.right = None
2. Insert in BST
def insert(root, key):
if not root:
return Node(key)
if key < root.val:
root.left = insert(root.left, key)
else:
root.right = insert(root.right, key)
return root
3. Search in BST
def search(root, key):
if not root or root.val == key:
return root
if key < root.val:
return search(root.left, key)
else:
return search(root.right, key)
4. Delete in BST
def delete(root, key):
if not root:
return None
if key < root.val:
root.left = delete(root.left, key)
elif key > root.val:
root.right = delete(root.right, key)
else:
# Found node
if not root.left:
return root.right
elif not root.right:
return root.left
# Get inorder successor
temp = find_min(root.right)
root.val = temp.val
root.right = delete(root.right, temp.val)
return root
def find_min(root):
while root.left:
root = root.left
return root
🧠 LeetCode Tree Problem Patterns
These are core problem types you’ll frequently face on LeetCode.
1. Recursive DFS Traversal (Inorder/Preorder/Postorder)
Use when: You need to visit all nodes or do some operation top-down or bottom-up.
def inorder(root): # Left → Root → Right
if not root: return
inorder(root.left)
print(root.val)
inorder(root.right)
2. Iterative BFS / Level Order
Use when: You need shortest path or level-wise operations.
from collections import deque
def level_order(root):
if not root: return
q = deque([root])
while q:
node = q.popleft()
print(node.val)
if node.left: q.append(node.left)
if node.right: q.append(node.right)
3. Max Depth of Tree
def maxDepth(root):
if not root:
return 0
return 1 + max(maxDepth(root.left), maxDepth(root.right))
4. Check if Tree is Balanced
def isBalanced(root):
def dfs(node):
if not node:
return 0
left = dfs(node.left)
right = dfs(node.right)
if left == -1 or right == -1 or abs(left - right) > 1:
return -1
return 1 + max(left, right)
return dfs(root) != -1
5. Lowest Common Ancestor (LCA) in BST
def lowestCommonAncestor(root, p, q):
if p.val < root.val and q.val < root.val:
return lowestCommonAncestor(root.left, p, q)
elif p.val > root.val and q.val > root.val:
return lowestCommonAncestor(root.right, p, q)
else:
return root
6. Check if Tree is Symmetric
def isSymmetric(root):
def isMirror(t1, t2):
if not t1 and not t2: return True
if not t1 or not t2: return False
return (t1.val == t2.val and
isMirror(t1.left, t2.right) and
isMirror(t1.right, t2.left))
return isMirror(root, root)
7. Diameter of Binary Tree
Max path length between any two nodes.
def diameterOfBinaryTree(root):
diameter = 0
def dfs(node):
nonlocal diameter
if not node: return 0
left = dfs(node.left)
right = dfs(node.right)
diameter = max(diameter, left + right)
return 1 + max(left, right)
dfs(root)
return diameter
Graphs
0-1 knapsack
def knapsack_dfs(weights, values, capacity):
n = len(weights)
def dfs(index, current_weight, current_value):
# Base case: no more items or capacity exceeded
if index == n or current_weight > capacity:
return 0 if current_weight > capacity else current_value
# Decision 1: Skip current item
skip = dfs(index + 1, current_weight, current_value)
# Decision 2: Include current item (if it fits)
take = 0
if current_weight + weights[index] <= capacity:
take = dfs(index + 1, current_weight + weights[index], current_value + values[index])
return max(skip, take)
return dfs(0, 0, 0)
# Example usage
weights = [1, 3, 4, 5]
values = [1, 4, 5, 7]
capacity = 7
print(knapsack_dfs(weights, values, capacity)) # Output: 9
Unbounded knapsack
def unbounded_knapsack_dfs(weights, values, capacity):
n = len(weights)
def dfs(current_weight, current_value):
max_value = current_value
for i in range(n):
if current_weight + weights[i] <= capacity:
# Try taking item i again (unbounded)
max_value = max(max_value, dfs(current_weight + weights[i], current_value + values[i]))
return max_value
return dfs(0, 0)
# Example usage
weights = [1, 3, 4, 5]
values = [1, 4, 5, 7]
capacity = 7
print(unbounded_knapsack_dfs(weights, values, capacity)) # Output: 10
1. Cycle Detection in an Undirected Graph (DFS with Parent Tracking)
def has_cycle_undirected(graph):
visited = set()
def dfs(node, parent):
# Mark the current node as visited
visited.add(node)
# Traverse all neighbors of the current node
for neighbor in graph[node]:
if neighbor not in visited:
# Recurse for unvisited neighbor
if dfs(neighbor, node):
return True
elif neighbor != parent:
# If the neighbor is visited and is not the parent,
# then a cycle exists (back edge)
return True
return False
# Check every connected component
for node in graph:
if node not in visited:
if dfs(node, -1): # Start DFS with parent as -1
return True
return False # No cycles found
🧪 Example usage for undirected graph:
graph = {
0: [1, 3],
1: [0, 2],
2: [1, 3],
3: [0, 2]
}
print(has_cycle_undirected(graph)) # Output: True
2. Cycle Detection in a Directed Graph (DFS with Recursion Stack)
def has_cycle_directed(graph):
visited = set() # Tracks all visited nodes
rec_stack = set() # Tracks nodes in the current DFS path
def dfs(node):
visited.add(node)
rec_stack.add(node)
for neighbor in graph[node]:
if neighbor not in visited:
if dfs(neighbor): # Recurse
return True
elif neighbor in rec_stack:
# A back edge found, meaning a cycle exists
return True
# Backtrack: remove node from recursion stack
rec_stack.remove(node)
return False
# Check every node (for disconnected components)
for node in graph:
if node not in visited:
if dfs(node):
return True
return False # No cycles found
🧪 Example usage for directed graph:
graph = {
0: [1],
1: [2],
2: [3],
3: [1] # Cycle here: 1 → 2 → 3 → 1
}
print(has_cycle_directed(graph)) # Output: True
Python extras general
list.sort()
sorted(list)
both are same but written in diferent way
list.index(<what index you want >)
Python Sorting with key Parameter and Lambda Functions
The key function is called once for each element and returns a value used for comparison:
# Basic syntax
sorted(iterable, key=function)
list.sort(key=function)
# The key function transforms each element for comparison
# Original: [element1, element2, element3]
# After key: [key(element1), key(key2), key(element3)]
# Then sorted based on these key values
Lambda Functions with Sorting
Basic Lambda Sorting
# Sort by absolute value
numbers = [-5, 2, -1, 8, -3]
sorted_abs = sorted(numbers, key=lambda x: abs(x))
print(sorted_abs) # Output: [-1, 2, -3, -5, 8]
# Sort strings by length
words = ["python", "java", "c", "javascript"]
sorted_length = sorted(words, key=lambda x: len(x))
print(sorted_length) # Output: ['c', 'java', 'python', 'javascript']
# Sort by last character
sorted_last = sorted(words, key=lambda x: x[-1])
print(sorted_last) # Output: ['java', 'c', 'python', 'javascript']
Most used Python library
from functools import cache
from collections import defaultdict , Counter
from math import random
and others
🔑 1. collections module
One of the most important for DSA.
✅ Counter
- Counts frequency of elements quickly.
from collections import Counter
arr = [1,2,2,3,3,3]
cnt = Counter(arr) # {3:3, 2:2, 1:1}
cnt.most_common(1) # [(3,3)] → most frequent element
✅ defaultdict
- Dictionary with a default value type.
- No
KeyErroron missing keys.
Types you’ll often use:
from collections import defaultdict
graph = defaultdict(list) # adjacency list
graph[1].append(2)
graph[2].append(3)
freq = defaultdict(int) # counts
freq["a"] += 1
visited = defaultdict(bool) # visited flags
✅ deque
- Double-ended queue. O(1) pops from left.
- Great for BFS, sliding window problems.
from collections import deque
q = deque([1,2,3])
q.append(4) # right
q.appendleft(0) # left
q.popleft() # O(1), unlike list pop(0)
🔑 2. itertools module
✅ permutations
from itertools import permutations
list(permutations([1,2,3], 2))
# [(1,2),(1,3),(2,1),(2,3),(3,1),(3,2)]
✅ combinations
from itertools import combinations
list(combinations([1,2,3], 2))
# [(1,2),(1,3),(2,3)]
✅ product (Cartesian product)
from itertools import product
list(product([1,2], [3,4]))
# [(1,3),(1,4),(2,3),(2,4)]
✅ accumulate (prefix sums)
from itertools import accumulate
list(accumulate([1,2,3,4]))
# [1,3,6,10]
🔑 3. heapq module
Min-heaps (priority queues).
import heapq
arr = [5,1,8,3]
heapq.heapify(arr) # arr becomes min-heap
heapq.heappop(arr) # pops smallest
heapq.heappush(arr, 0) # push new element
For max-heap, push negative values:
heap = []
heapq.heappush(heap, -5)
🔑 4. bisect module
Binary search helpers.
import bisect
arr = [1,3,4,7]
bisect.bisect_left(arr, 4) # 2 (first index of 4)
bisect.bisect_right(arr, 4) # 3 (after last 4)
Great for binary search problems.
🔑 5. functools module
✅ lru_cache / cache
Memoization for recursion.
from functools import cache
@cache
def fib(n):
if n <= 1: return n
return fib(n-1) + fib(n-2)
🔑 6. math module
Useful math functions.
import math
math.gcd(24, 36) # 12
math.lcm(4, 6) # 12
math.factorial(5) # 120
math.comb(5,2) # 10 (nCr)
🔑 7. random (sometimes for testing)
import random
random.choice([1,2,3]) # pick random
🔑 8. Built-in functions often used in DSA
sum(iterable)→ prefix/sum calculationssorted(iterable, key=...)→ sorting with custom ruleenumerate(iterable)→ index + valuezip(a,b)→ combine listsmap,filter,any,all
✅ Most common combos on LeetCode
- Graph →
defaultdict(list),deque - Freq / Counting →
Counter,defaultdict(int) - Heap problems →
heapq - Subsets / Permutations →
itertools - Binary Search →
bisect - DP recursion →
functools.cache
1. i != len(s)
- You use this when
iis an index and you want to stop before going out of bounds. - Since the last valid index is
len(s) - 1, we must stop wheni == len(s).
✅ Example: iterating through a string/array
i = 0
while i != len(s): # same as while i < len(s)
print(s[i]) # safe, because i is always < len(s)
i += 1
2. i != len(s) - 1
- You use this when your code looks ahead one step (
s[i+1]). - If
i == len(s) - 1, thens[i+1]would be out of bounds ❌. - So you stop one step earlier.
✅ Example: checking pairs in a string
for i in range(len(s)):
if i != len(s) - 1 and s[i] == s[i+1]: # safe because i+1 won’t overflow
print("pair:", s[i], s[i+1])
3. i != len(s) - 2
- You use this when your code looks ahead two steps (
s[i+2]). - If
i == len(s) - 2or more, thens[i+2]would overflow. - So you stop two steps earlier.
✅ Example: checking triplets in a string
for i in range(len(s)):
if i != len(s) - 2 and s[i] == s[i+2]: # safe for i+2
print("triplet pattern")
4. i <= len(s)
-
Careful! Usually this is ❌ unless you’re using dummy index math.
-
Because the last valid index is
len(s) - 1. -
But sometimes it’s used when:
- You’re counting characters (not indexing),
- Or using
dparrays with sizelen(s) + 1(like in DP problems for substrings).
✅ Example: DP on string (extra row/col for empty prefix)
dp = [[0] * (len(s)+1) for _ in range(len(t)+1)]
for i in range(len(s)+1): # safe, we made dp size +1
...
5. i <= len(s) - 1
- This is the same as
i < len(s)ori != len(s). - You use it in normal loops when you want to include the last element.
✅ Example:
i = 0
while i <= len(s) - 1: # same as while i < len(s)
print(s[i])
i += 1
🔑 Key Rule to Remember:
- Normal traversal →
i < len(s)(ori != len(s)) - When you look ahead
+k→ stop atlen(s) - k - When using DP with extra row/col → use
<= len(s)
graphs notion page
https://gossamer-meter-104.notion.site/Graph-Cheatsheet-By-Sid-1a08d36d3d77806bbb0be158a67a907e
Cycle Directed and undirected in graphs :+1:
🚀 1. Cycle Detection in Undirected Graph
👉 In an undirected graph, a cycle exists if you can return to a vertex without repeating edges, except the first and last vertex being the same.
There are two classic approaches:
✅ Method 1: DFS with Parent Tracking
We check if a node is visited again during DFS and it is not the parent (to avoid false cycles due to the bidirectional edge).
from collections import defaultdict
class Graph:
def __init__(self, V):
self.V = V
self.graph = defaultdict(list)
def add_edge(self, u, v):
self.graph[u].append(v)
self.graph[v].append(u) # undirected
def is_cyclic_util(self, v, visited, parent):
visited[v] = True
for neighbor in self.graph[v]:
if not visited[neighbor]:
if self.is_cyclic_util(neighbor, visited, v):
return True
elif neighbor != parent:
return True
return False
def is_cyclic(self):
visited = [False] * self.V
for i in range(self.V):
if not visited[i]:
if self.is_cyclic_util(i, visited, -1):
return True
return False
# Example
g = Graph(5)
g.add_edge(0, 1)
g.add_edge(1, 2)
g.add_edge(2, 0) # cycle
print("Undirected Graph Cycle:", g.is_cyclic()) # True
✅ Method 2: Union-Find (Disjoint Set Union, DSU)
We try to join edges; if two vertices already belong to the same set, a cycle exists.
class UnionFind:
def __init__(self, n):
self.parent = list(range(n))
def find(self, x):
if self.parent[x] != x:
self.parent[x] = self.find(self.parent[x]) # path compression
return self.parent[x]
def union(self, x, y):
px, py = self.find(x), self.find(y)
if px == py:
return False # cycle
self.parent[px] = py
return True
def is_cyclic_undirected(edges, n):
uf = UnionFind(n)
for u, v in edges:
if not uf.union(u, v):
return True
return False
# Example
edges = [(0, 1), (1, 2), (2, 0)] # cycle
print("Undirected Graph Cycle:", is_cyclic_undirected(edges, 3)) # True
🚀 2. Cycle Detection in Directed Graph
👉 In directed graphs, a cycle exists if a vertex is visited again in the same DFS path (recursion stack). Simply revisiting a visited node is not enough (that could be a different path).
✅ Method 1: DFS + Recursion Stack
We maintain:
visited[v]: if a node has been visited at allrecStack[v]: if a node is in the current DFS path
from collections import defaultdict
class DirectedGraph:
def __init__(self, V):
self.V = V
self.graph = defaultdict(list)
def add_edge(self, u, v):
self.graph[u].append(v)
def is_cyclic_util(self, v, visited, recStack):
visited[v] = True
recStack[v] = True
for neighbor in self.graph[v]:
if not visited[neighbor]:
if self.is_cyclic_util(neighbor, visited, recStack):
return True
elif recStack[neighbor]:
return True # back edge → cycle
recStack[v] = False
return False
def is_cyclic(self):
visited = [False] * self.V
recStack = [False] * self.V
for node in range(self.V):
if not visited[node]:
if self.is_cyclic_util(node, visited, recStack):
return True
return False
# Example
g = DirectedGraph(4)
g.add_edge(0, 1)
g.add_edge(1, 2)
g.add_edge(2, 0) # cycle
print("Directed Graph Cycle:", g.is_cyclic()) # True
✅ Method 2: Kahn’s Algorithm (Topological Sort)
If a graph has a cycle, we cannot get a valid topological ordering (some nodes will always have non-zero in-degree).
from collections import deque, defaultdict
def is_cyclic_directed_kahn(V, edges):
graph = defaultdict(list)
indegree = [0] * V
for u, v in edges:
graph[u].append(v)
indegree[v] += 1
q = deque([i for i in range(V) if indegree[i] == 0])
count = 0
while q:
node = q.popleft()
count += 1
for neighbor in graph[node]:
indegree[neighbor] -= 1
if indegree[neighbor] == 0:
q.append(neighbor)
return count != V # if not all nodes processed → cycle
# Example
edges = [(0,1), (1,2), (2,0)]
print("Directed Graph Cycle (Kahn):", is_cyclic_directed_kahn(3, edges)) # True
🔑 Summary of Methods
| Graph Type | Method | Complexity |
|---|---|---|
| Undirected | DFS with parent tracking | O(V+E) |
| Union-Find (DSU) | O(E α(V)) | |
| Directed | DFS with recursion stack | O(V+E) |
| Kahn’s Algorithm (Topo sort) | O(V+E) |
👉 Variations covered:
- Undirected: DFS, Union-Find
- Directed: DFS (recStack), BFS (Kahn’s Topological sort)
✅ Method 1: DFS-based Topological Sort
We push nodes onto a stack after exploring all their neighbors (post-order).
from collections import defaultdict
class Graph:
def __init__(self, V):
self.V = V
self.graph = defaultdict(list)
def add_edge(self, u, v):
self.graph[u].append(v)
def topo_sort_dfs_util(self, v, visited, stack):
visited[v] = True
for neighbor in self.graph[v]:
if not visited[neighbor]:
self.topo_sort_dfs_util(neighbor, visited, stack)
stack.append(v) # push after visiting all children
def topo_sort_dfs(self):
visited = [False] * self.V
stack = []
for i in range(self.V):
if not visited[i]:
self.topo_sort_dfs_util(i, visited, stack)
return stack[::-1] # reverse gives correct order
# Example
g = Graph(6)
g.add_edge(5, 2)
g.add_edge(5, 0)
g.add_edge(4, 0)
g.add_edge(4, 1)
g.add_edge(2, 3)
g.add_edge(3, 1)
print("Topological Sort (DFS):", g.topo_sort_dfs())
# Possible Output: [5, 4, 2, 3, 1, 0]
✅ Method 2: Kahn’s Algorithm (BFS)
We use in-degree (number of incoming edges):
- Put all nodes with
indegree = 0into a queue. - Repeatedly pop from queue, add to order, and decrease indegree of neighbors.
- If some nodes remain unprocessed → cycle exists.
from collections import defaultdict, deque
def topo_sort_kahn(V, edges):
graph = defaultdict(list)
indegree = [0] * V
# Build graph + indegree
for u, v in edges:
graph[u].append(v)
indegree[v] += 1
# Start with all 0-indegree nodes
q = deque([i for i in range(V) if indegree[i] == 0])
topo_order = []
while q:
node = q.popleft()
topo_order.append(node)
for neighbor in graph[node]:
indegree[neighbor] -= 1
if indegree[neighbor] == 0:
q.append(neighbor)
if len(topo_order) != V:
return "Cycle detected → no topo sort"
return topo_order
# Example
edges = [(5,2), (5,0), (4,0), (4,1), (2,3), (3,1)]
print("Topological Sort (Kahn):", topo_sort_kahn(6, edges))
# Possible Output: [4, 5, 2, 3, 1, 0]
🔑 Key Points
- DFS method → uses recursion & stack (post-order).
- Kahn’s Algorithm → uses in-degree & BFS queue.
- If graph has a cycle → no valid topo order exists.
Extra graph algo :+1:
1. Floyd–Warshall Algorithm
Idea:
- It’s a Dynamic Programming algorithm.
- Used to find the shortest path between all pairs of vertices in a weighted graph.
- Works with negative edges (but not negative cycles).
- Time complexity: O(V³), where
V= number of vertices.
How it works:
-
Start with a
distmatrix = adjacency matrix of the graph. -
Iteratively try to improve distances by checking if going through an intermediate vertex
kgives a shorter path. -
Formula:
dist[i][j] = min(dist[i][j], dist[i][k] + dist[k][j])
Example:
Graph:
0 → 1 (weight 3)
0 → 2 (weight INF)
1 → 2 (weight 1)
2 → 0 (weight 2)
Initial matrix (INF = no edge):
0 3 INF
INF 0 1
2 INF 0
After applying Floyd-Warshall:
0 3 4
3 0 1
2 5 0
So shortest path between every pair is found.
Python Code:
def floyd_warshall(graph):
V = len(graph)
dist = [row[:] for row in graph] # copy
for k in range(V):
for i in range(V):
for j in range(V):
if dist[i][k] + dist[k][j] < dist[i][j]:
dist[i][j] = dist[i][k] + dist[k][j]
return dist
INF = float('inf')
graph = [
[0, 3, INF],
[INF, 0, 1],
[2, INF, 0]
]
result = floyd_warshall(graph)
for row in result:
print(row)
2. Bellman–Ford Algorithm
Idea:
- Used to find shortest path from a single source to all vertices.
- Works with negative edge weights and also detects negative cycles.
- Time complexity: O(V × E), where
V= vertices,E= edges.
How it works:
-
Initialize distance array with
dist[src] = 0, others = ∞. -
Relax all edges
V-1times: For each edge(u, v, w):if dist[u] + w < dist[v]: dist[v] = dist[u] + w -
Run one more iteration to check for negative cycles. If distances still improve → cycle exists.
Example:
Graph edges:
0 → 1 (4)
0 → 2 (5)
1 → 2 (-3)
2 → 3 (4)
3 → 1 (6)
- Start from
src = 0. - After Bellman-Ford:
dist = [0, 4, 1, 5]
Python Code:
def bellman_ford(edges, V, src):
dist = [float('inf')] * V
dist[src] = 0
# Relax edges V-1 times
for _ in range(V - 1):
for u, v, w in edges:
if dist[u] != float('inf') and dist[u] + w < dist[v]:
dist[v] = dist[u] + w
# Check for negative cycles
for u, v, w in edges:
if dist[u] != float('inf') and dist[u] + w < dist[v]:
print("Graph contains negative weight cycle")
return None
return dist
# Graph: (u, v, w)
edges = [
(0, 1, 4),
(0, 2, 5),
(1, 2, -3),
(2, 3, 4),
(3, 1, 6)
]
print(bellman_ford(edges, 4, 0))
✅ Key difference:
- Floyd–Warshall → all-pairs shortest paths, dense graphs, O(V³).
- Bellman–Ford → single-source shortest path, handles negative weights, O(V×E).
🔹 Dijkstra’s Algorithm
-
Goal: Shortest path from a single source → all vertices.
-
Works with: Only non-negative edge weights.
-
Time Complexity:
- With simple array: O(V²)
- With min-heap/priority queue: O((V + E) log V)
-
Approach: Greedy (always picks the nearest unvisited node).
🔹 Bellman–Ford Algorithm
- Goal: Shortest path from a single source → all vertices.
- Works with: Negative edge weights and can detect negative cycles.
- Time Complexity: O(V × E) → slower than Dijkstra.
- Approach: Dynamic Programming (relaxes edges repeatedly).
🔹 Floyd–Warshall Algorithm
- Goal: Shortest paths between all pairs of vertices.
- Works with: Negative edges, but no negative cycles.
- Time Complexity: O(V³).
- Approach: Dynamic Programming (updates adjacency matrix with intermediate vertices).
📊 Comparison Table
| Algorithm | Single-source or All-pairs? | Handles Negative Weights? | Detects Negative Cycles? | Complexity | Typical Use |
|---|---|---|---|---|---|
| Dijkstra | Single-source | ❌ No (only non-negative) | ❌ No | O((V+E)logV) | Road networks, routing |
| Bellman–Ford | Single-source | ✅ Yes | ✅ Yes | O(V×E) | Finance (arbitrage), graphs with negatives |
| Floyd–Warshall | All-pairs | ✅ Yes | ❌ No (but breaks if present) | O(V³) | Dense graphs, APSP (all pairs shortest path) |
🧠 How They’re Related
-
All solve shortest path problems but under different constraints.
- Dijkstra = fast but limited (no negatives).
- Bellman–Ford = slower, but more general.
- Floyd–Warshall = solves the most general case (all pairs).
-
Bellman–Ford vs Dijkstra:
- Both → single source shortest path.
- Dijkstra is faster but limited to non-negative edges.
- Bellman–Ford is slower but works with negatives.
-
Floyd–Warshall vs Bellman–Ford:
- Bellman–Ford runs from one source.
- To get all-pairs shortest paths with Bellman–Ford, you’d need to run it V times (once from each source). That would be O(V² × E) → worse than Floyd–Warshall’s O(V³) for dense graphs.
👉 In practice:
- If graph is small/dense and need all-pairs shortest paths → Floyd–Warshall.
- If graph has negative edges but no negative cycles → Bellman–Ford.
- If graph has no negative edges and is large/sparse → Dijkstra.
🌳 Tree Algorithms
1. AVL Tree
- A self-balancing Binary Search Tree (BST).
- Maintains balance factor = height(left) – height(right) ∈ {–1, 0, 1}.
- Ensures O(log N) insertion, deletion, and search.
- Uses rotations (LL, RR, LR, RL) to stay balanced.
Python Implementation:
class Node:
def __init__(self, key):
self.key = key
self.left = None
self.right = None
self.height = 1
class AVLTree:
def insert(self, root, key):
if not root:
return Node(key)
elif key < root.key:
root.left = self.insert(root.left, key)
else:
root.right = self.insert(root.right, key)
root.height = 1 + max(self.getHeight(root.left),
self.getHeight(root.right))
balance = self.getBalance(root)
# Left Left
if balance > 1 and key < root.left.key:
return self.rightRotate(root)
# Right Right
if balance < -1 and key > root.right.key:
return self.leftRotate(root)
# Left Right
if balance > 1 and key > root.left.key:
root.left = self.leftRotate(root.left)
return self.rightRotate(root)
# Right Left
if balance < -1 and key < root.right.key:
root.right = self.rightRotate(root.right)
return self.leftRotate(root)
return root
def leftRotate(self, z):
y = z.right
T2 = y.left
y.left = z
z.right = T2
z.height = 1 + max(self.getHeight(z.left), self.getHeight(z.right))
y.height = 1 + max(self.getHeight(y.left), self.getHeight(y.right))
return y
def rightRotate(self, z):
y = z.left
T3 = y.right
y.right = z
z.left = T3
z.height = 1 + max(self.getHeight(z.left), self.getHeight(z.right))
y.height = 1 + max(self.getHeight(y.left), self.getHeight(y.right))
return y
def getHeight(self, root):
return root.height if root else 0
def getBalance(self, root):
return self.getHeight(root.left) - self.getHeight(root.right) if root else 0
# Example Usage
tree = AVLTree()
root = None
for key in [10, 20, 30, 40, 50, 25]:
root = tree.insert(root, key)
print("AVL Tree balanced insertions done.")
2. Red–Black Tree
-
Another self-balancing BST.
-
Each node is either Red or Black.
-
Rules ensure balance:
- Root is always black.
- No two red nodes in a row.
- Every path from root to null has same # of black nodes.
-
Complexity: O(log N).
-
Used in sets, maps in C++ STL, Java TreeMap.
👉 Full implementation is quite long. In Python, we usually use libraries like bintrees or simulate. Do you want me to write full Red-Black Tree code, or just a simplified insert with coloring?
3. B-Tree (and B+ Tree)
- A multi-way search tree (not binary).
- Used in databases and filesystems.
- Each node can have multiple keys & children.
- B+ Tree → stores all values in leaf nodes, internal nodes only store keys (better for range queries).
Simplified B-Tree in Python:
class BTreeNode:
def __init__(self, t, leaf=False):
self.t = t # minimum degree
self.keys = []
self.children = []
self.leaf = leaf
class BTree:
def __init__(self, t):
self.root = BTreeNode(t, True)
def traverse(self, node=None):
if node is None:
node = self.root
for i, key in enumerate(node.keys):
if not node.leaf:
self.traverse(node.children[i])
print(key, end=" ")
if not node.leaf:
self.traverse(node.children[len(node.keys)])
# Insert code is long; real DB engines use optimized versions.
👉 Implementing full B+ Tree is heavy; for interviews, you just need the concepts and operations (search, insert, split nodes).
🌐 Graph Algorithms
4. Traveling Salesman Problem (TSP)
-
Goal: Visit all cities exactly once and return to start with minimum cost.
-
NP-hard problem.
-
Approaches:
- Brute Force → O(N!)
- Dynamic Programming (Held-Karp) → O(N²·2^N)
Python (DP Bitmasking):
from functools import lru_cache
def tsp(dist):
n = len(dist)
@lru_cache(None)
def dp(mask, pos):
if mask == (1 << n) - 1:
return dist[pos][0] # return to start
ans = float('inf')
for city in range(n):
if not mask & (1 << city):
ans = min(ans, dist[pos][city] + dp(mask | (1 << city), city))
return ans
return dp(1, 0) # start at city 0
# Example
dist = [
[0, 20, 42, 35],
[20, 0, 30, 34],
[42, 30, 0, 12],
[35, 34, 12, 0]
]
print("TSP Min Cost:", tsp(dist))
5. Kahn’s Algorithm (Topological Sort using BFS)
-
Works on Directed Acyclic Graphs (DAGs).
-
Uses in-degree array + queue.
-
Steps:
- Find nodes with in-degree = 0, push to queue.
- Pop node, add to result, reduce in-degree of neighbors.
- Repeat until done.
Python Code:
from collections import deque, defaultdict
def kahn_topological_sort(V, edges):
indegree = [0] * V
adj = defaultdict(list)
for u, v in edges:
adj[u].append(v)
indegree[v] += 1
q = deque([i for i in range(V) if indegree[i] == 0])
topo = []
while q:
u = q.popleft()
topo.append(u)
for v in adj[u]:
indegree[v] -= 1
if indegree[v] == 0:
q.append(v)
if len(topo) == V:
return topo
else:
return "Cycle detected → Not a DAG"
# Example
edges = [(5, 2), (5, 0), (4, 0), (4, 1), (2, 3), (3, 1)]
print("Topological Sort:", kahn_topological_sort(6, edges))
✅ Summary
- AVL Tree → Self-balancing BST (rotations).
- Red-Black Tree → Balanced BST using colors.
- B-Tree / B+ Tree → Used in databases, multi-key nodes.
- TSP → NP-hard, solved via DP bitmask.
- Kahn’s Algorithm → Topological sort (DAG).
🔴 Red-Black Tree (RBT)
Key ideas:
- Each node is RED or BLACK.
- Root is BLACK; leaves (NIL sentinels) are BLACK.
- No two consecutive REDs on any path.
- Every path from a node to its descendant NILs has the same number of BLACK nodes.
- Operations: BST insert/delete + fixups (rotations & recolors) to restore invariants.
Below is a compact, correct implementation using a single shared NIL sentinel. It supports: insert, delete, search, inorder.
class RBNode:
__slots__ = ("key", "color", "left", "right", "parent")
def __init__(self, key=None, color="BLACK", left=None, right=None, parent=None):
self.key = key
self.color = color
self.left = left
self.right = right
self.parent = parent
class RedBlackTree:
def __init__(self):
self.NIL = RBNode() # sentinel leaf (BLACK)
self.root = self.NIL
# ---------- Utility ----------
def _left_rotate(self, x):
y = x.right
x.right = y.left
if y.left is not self.NIL:
y.left.parent = x
y.parent = x.parent
if x.parent is None: # x was root
self.root = y
elif x is x.parent.left:
x.parent.left = y
else:
x.parent.right = y
y.left = x
x.parent = y
def _right_rotate(self, y):
x = y.left
y.left = x.right
if x.right is not self.NIL:
x.right.parent = y
x.parent = y.parent
if y.parent is None:
self.root = x
elif y is y.parent.right:
y.parent.right = x
else:
y.parent.left = x
x.right = y
y.parent = x
def search(self, key):
cur = self.root
while cur is not self.NIL and cur.key != key:
cur = cur.left if key < cur.key else cur.right
return None if cur is self.NIL else cur
def inorder(self, node=None, out=None):
if node is None:
node, out = self.root, []
if node is self.NIL:
return out
self.inorder(node.left, out)
out.append((node.key, node.color))
self.inorder(node.right, out)
return out
# ---------- Insert ----------
def insert(self, key):
node = RBNode(key=key, color="RED", left=self.NIL, right=self.NIL)
y = None
x = self.root
while x is not self.NIL:
y = x
x = x.left if node.key < x.key else x.right
node.parent = y
if y is None:
self.root = node
elif node.key < y.key:
y.left = node
else:
y.right = node
self._insert_fixup(node)
def _insert_fixup(self, z):
while z.parent and z.parent.color == "RED":
if z.parent is z.parent.parent.left:
y = z.parent.parent.right # uncle
if y.color == "RED": # case 1
z.parent.color = "BLACK"
y.color = "BLACK"
z.parent.parent.color = "RED"
z = z.parent.parent
else:
if z is z.parent.right: # case 2
z = z.parent
self._left_rotate(z)
# case 3
z.parent.color = "BLACK"
z.parent.parent.color = "RED"
self._right_rotate(z.parent.parent)
else:
y = z.parent.parent.left
if y.color == "RED":
z.parent.color = "BLACK"
y.color = "BLACK"
z.parent.parent.color = "RED"
z = z.parent.parent
else:
if z is z.parent.left:
z = z.parent
self._right_rotate(z)
z.parent.color = "BLACK"
z.parent.parent.color = "RED"
self._left_rotate(z.parent.parent)
self.root.color = "BLACK"
# ---------- Delete ----------
def _transplant(self, u, v):
if u.parent is None:
self.root = v
elif u is u.parent.left:
u.parent.left = v
else:
u.parent.right = v
v.parent = u.parent
def _minimum(self, x):
while x.left is not self.NIL:
x = x.left
return x
def delete(self, key):
z = self.search(key)
if z is None:
return False
y = z
y_original_color = y.color
if z.left is self.NIL:
x = z.right
self._transplant(z, z.right)
elif z.right is self.NIL:
x = z.left
self._transplant(z, z.left)
else:
y = self._minimum(z.right)
y_original_color = y.color
x = y.right
if y.parent is z:
x.parent = y
else:
self._transplant(y, y.right)
y.right = z.right
y.right.parent = y
self._transplant(z, y)
y.left = z.left
y.left.parent = y
y.color = z.color
if y_original_color == "BLACK":
self._delete_fixup(x)
return True
def _delete_fixup(self, x):
while x is not self.root and x.color == "BLACK":
if x is x.parent.left:
w = x.parent.right
if w.color == "RED":
w.color = "BLACK"
x.parent.color = "RED"
self._left_rotate(x.parent)
w = x.parent.right
if w.left.color == "BLACK" and w.right.color == "BLACK":
w.color = "RED"
x = x.parent
else:
if w.right.color == "BLACK":
w.left.color = "BLACK"
w.color = "RED"
self._right_rotate(w)
w = x.parent.right
w.color = x.parent.color
x.parent.color = "BLACK"
w.right.color = "BLACK"
self._left_rotate(x.parent)
x = self.root
else:
w = x.parent.left
if w.color == "RED":
w.color = "BLACK"
x.parent.color = "RED"
self._right_rotate(x.parent)
w = x.parent.left
if w.right.color == "BLACK" and w.left.color == "BLACK":
w.color = "RED"
x = x.parent
else:
if w.left.color == "BLACK":
w.right.color = "BLACK"
w.color = "RED"
self._left_rotate(w)
w = x.parent.left
w.color = x.parent.color
x.parent.color = "BLACK"
w.left.color = "BLACK"
self._right_rotate(x.parent)
x = self.root
x.color = "BLACK"
# --- quick demo ---
if __name__ == "__main__":
rbt = RedBlackTree()
for k in [7,3,18,10,22,8,11,26,2,6,13]:
rbt.insert(k)
# delete a couple
rbt.delete(18)
rbt.delete(11)
print("RBT inorder (key,color):", rbt.inorder())
What to notice: insert/delete are standard BST ops followed by fixups. Rotations and recoloring keep the invariants.
🟦 B-Tree (B-) — balanced multiway search tree
Parameters:
- Minimum degree
t(t ≥ 2). - Each node has
t-1 … 2t-1keys (except root), andt … 2tchildren. - All leaves at the same depth.
- Great for disks/DBs: large branching factor, few levels.
We’ll implement search, insert (with node splitting), and traverse. (Deletion is possible but long; happy to add if you want it too.)
class BTreeNode:
def __init__(self, t, leaf=False):
self.t = t
self.leaf = leaf
self.keys = [] # sorted
self.children = [] # len = len(keys)+1 when internal
def __repr__(self):
return f"BTreeNode(leaf={self.leaf}, keys={self.keys})"
class BTree:
def __init__(self, t=3):
if t < 2: raise ValueError("t must be >= 2")
self.t = t
self.root = BTreeNode(t, leaf=True)
def search(self, k, x=None):
x = self.root if x is None else x
i = 0
while i < len(x.keys) and k > x.keys[i]:
i += 1
if i < len(x.keys) and k == x.keys[i]:
return x, i
if x.leaf:
return None
return self.search(k, x.children[i])
def traverse(self, x=None, out=None):
x = self.root if x is None else x
out = [] if out is None else out
i = 0
for i in range(len(x.keys)):
if not x.leaf:
self.traverse(x.children[i], out)
out.append(x.keys[i])
if not x.leaf:
self.traverse(x.children[i+1], out)
return out
# -------- insertion helpers --------
def _split_child(self, x, i):
t = self.t
y = x.children[i] # full child
z = BTreeNode(t, leaf=y.leaf) # new node
# move top half of y.keys to z
z.keys = y.keys[t:] # t .. 2t-2
mid = y.keys[t-1]
y.keys = y.keys[:t-1]
if not y.leaf:
z.children = y.children[t:] # t .. 2t-1
y.children = y.children[:t]
# insert z into x
x.children.insert(i+1, z)
x.keys.insert(i, mid)
def _insert_nonfull(self, x, k):
i = len(x.keys) - 1
if x.leaf:
x.keys.append(None)
while i >= 0 and k < x.keys[i]:
x.keys[i+1] = x.keys[i]
i -= 1
x.keys[i+1] = k
else:
while i >= 0 and k < x.keys[i]:
i -= 1
i += 1
if len(x.children[i].keys) == 2*self.t - 1:
self._split_child(x, i)
if k > x.keys[i]:
i += 1
self._insert_nonfull(x.children[i], k)
def insert(self, k):
r = self.root
if len(r.keys) == 2*self.t - 1:
s = BTreeNode(self.t, leaf=False)
s.children.append(r)
self.root = s
self._split_child(s, 0)
self._insert_nonfull(s, k)
else:
self._insert_nonfull(r, k)
# --- quick demo ---
if __name__ == "__main__":
bt = BTree(t=3) # order up to 5 children
for k in [10,20,5,6,12,30,7,17,3,4,50,60,1,2,8,9,11,13,14,15]:
bt.insert(k)
print("BTree traverse:", bt.traverse())
print("Search 14:", bt.search(14))
print("Search 99:", bt.search(99))
What to notice: inserts go into a non-full node; if a child is full, we split it first, pushing the median up.
🟩 B+ Tree — all values in leaves, great for range scans
Differences vs B-Tree:
-
All actual (key,value) records live in leaves; internal nodes only guide the search (store keys).
-
Leaves are linked as a doubly-linked list for fast range queries.
-
Here we’ll make a small educational implementation:
- Order
m(max keys per node) for simplicity (not tuned for IO pages). - Supports
insert(key, value),search(key), andrange_search(lo, hi).
- Order
class BPlusLeaf:
def __init__(self, m):
self.m = m
self.keys = []
self.vals = []
self.next = None
self.prev = None
self.parent = None
self.is_leaf = True
class BPlusInternal:
def __init__(self, m):
self.m = m
self.keys = []
self.children = []
self.parent = None
self.is_leaf = False
class BPlusTree:
def __init__(self, m=4):
if m < 3: raise ValueError("order m must be >= 3")
self.m = m
self.root = BPlusLeaf(m)
def _find_leaf(self, key):
x = self.root
while not x.is_leaf:
i = 0
while i < len(x.keys) and key >= x.keys[i]:
i += 1
x = x.children[i]
return x
def search(self, key):
leaf = self._find_leaf(key)
for i, k in enumerate(leaf.keys):
if k == key:
return leaf.vals[i]
return None
def range_search(self, lo, hi):
# find start leaf
leaf = self._find_leaf(lo)
out = []
while leaf:
for k, v in zip(leaf.keys, leaf.vals):
if k > hi:
return out
if lo <= k <= hi:
out.append((k, v))
leaf = leaf.next
return out
def insert(self, key, value):
leaf = self._find_leaf(key)
# insert into leaf (sorted)
i = 0
while i < len(leaf.keys) and key > leaf.keys[i]:
i += 1
if i < len(leaf.keys) and leaf.keys[i] == key:
leaf.vals[i] = value # upsert
return
leaf.keys.insert(i, key)
leaf.vals.insert(i, value)
if len(leaf.keys) > self.m: # split leaf
self._split_leaf(leaf)
def _split_leaf(self, leaf):
mid = (len(leaf.keys) + 1) // 2
new_leaf = BPlusLeaf(self.m)
new_leaf.keys = leaf.keys[mid:]
new_leaf.vals = leaf.vals[mid:]
leaf.keys = leaf.keys[:mid]
leaf.vals = leaf.vals[:mid]
# link leaves
new_leaf.next = leaf.next
if new_leaf.next:
new_leaf.next.prev = new_leaf
leaf.next = new_leaf
new_leaf.prev = leaf
# push separator to parent
sep = new_leaf.keys[0]
if leaf.parent is None:
new_root = BPlusInternal(self.m)
new_root.keys = [sep]
new_root.children = [leaf, new_leaf]
leaf.parent = new_leaf.parent = new_root
self.root = new_root
else:
self._insert_in_parent(leaf, sep, new_leaf)
def _insert_in_parent(self, left, key, right):
parent = left.parent
# find position to insert key/child
i = 0
while i < len(parent.children) and parent.children[i] is not left:
i += 1
parent.keys.insert(i, key)
parent.children.insert(i+1, right)
right.parent = parent
if len(parent.keys) > self.m:
self._split_internal(parent)
def _split_internal(self, node):
mid = len(node.keys)//2
sep_key = node.keys[mid]
right = BPlusInternal(self.m)
right.keys = node.keys[mid+1:]
right.children = node.children[mid+1:]
for c in right.children:
c.parent = right
node.keys = node.keys[:mid]
node.children = node.children[:mid+1]
if node.parent is None:
new_root = BPlusInternal(self.m)
new_root.keys = [sep_key]
new_root.children = [node, right]
node.parent = right.parent = new_root
self.root = new_root
else:
self._insert_in_parent(node, sep_key, right)
# --- quick demo ---
if __name__ == "__main__":
bpt = BPlusTree(m=4)
data = [(5,"e"),(15,"o"),(25,"y"),(35,"cc"),(45,"mm"),(1,"a"),(2,"b"),(3,"c"),
(4,"d"),(6,"f"),(7,"g"),(8,"h"),(9,"i"),(10,"j"),(11,"k"),(12,"l")]
for k,v in data:
bpt.insert(k,v)
print("Search 10:", bpt.search(10))
print("Range [4,11]:", bpt.range_search(4,11))
What to notice:
- Internal nodes only direct search with separator keys.
- All records live in leaves; leaf nodes are linked for efficient ranges.
- Splits propagate up similarly to B-Tree.
🧭 Already shared (reposting quickly for completeness)
Traveling Salesman Problem (Held-Karp, DP with bitmask)
from functools import lru_cache
def tsp(dist):
n = len(dist)
@lru_cache(None)
def dp(mask, i):
if mask == (1 << n) - 1:
return dist[i][0]
best = float('inf')
for j in range(n):
if not (mask & (1 << j)):
best = min(best, dist[i][j] + dp(mask | (1 << j), j))
return best
return dp(1, 0)
# demo
# dist = [[...]]
# print(tsp(dist))
Kahn’s Algorithm (Topological Sort, BFS indegrees)
from collections import defaultdict, deque
def kahn_topological_sort(V, edges):
indeg = [0]*V
adj = defaultdict(list)
for u,v in edges:
adj[u].append(v)
indeg[v] += 1
q = deque([i for i in range(V) if indeg[i]==0])
topo = []
while q:
u = q.popleft()
topo.append(u)
for v in adj[u]:
indeg[v] -= 1
if indeg[v]==0:
q.append(v)
return topo if len(topo)==V else "Cycle detected"
When to use what (quick guide)
- AVL: frequent searches + many inserts/deletes in memory; strict height balance (lower lookup variance).
- Red-Black: slightly faster inserts/deletes on average (fewer rotations); widely used in language libs (TreeMap/TreeSet).
- B-Tree: on-disk indexes with large branching factor; internal nodes store keys+values.
- B+ Tree: databases & filesystems; all data in leaves + linked leaves → blazing fast range scans and sequential reads.
- TSP: small N exact solutions; for large N use heuristics (2-opt, 3-opt, Christofides).
- Kahn: topological order for DAGs; course scheduling, build pipelines.
def process_status_code(status_code):
match status_code:
case 200:
return "OK - Success"
case 404:
return "Not Found"
case 500:
return "Internal Server Error"
case 403:
return "Forbidden"
case _: # Default case (wildcard)
return f"Unknown status code: {status_code}"
# Usage
print(process_status_code(200)) # OK - Success
print(process_status_code(404)) # Not Found
print(process_status_code(999)) # Unknown status code: 999
Threading allows programs to run multiple tasks simultaneously, improving performance especially for I/O-bound operations. Both Python and Java support threading, but with some key differences in syntax and behavior.[1][2][3]
Basic Thread Creation
Python Threading
import threading
import time
def worker(name, delay):
print(f"Thread {name} starting")
time.sleep(delay)
print(f"Thread {name} finished")
# Create and start threads
t1 = threading.Thread(target=worker, args=("A", 2))
t2 = threading.Thread(target=worker, args=("B", 1))
t1.start()
t2.start()
# Wait for threads to complete
t1.join()
t2.join()
print("All threads completed")
Java Threading
class WorkerThread extends Thread {
private String name;
private int delay;
public WorkerThread(String name, int delay) {
this.name = name;
this.delay = delay;
}
public void run() {
System.out.println("Thread " + name + " starting");
try {
Thread.sleep(delay * 1000);
} catch (InterruptedException e) {
System.out.println("Thread interrupted");
}
System.out.println("Thread " + name + " finished");
}
}
public class ThreadExample {
public static void main(String[] args) {
WorkerThread t1 = new WorkerThread("A", 2);
WorkerThread t2 = new WorkerThread("B", 1);
t1.start();
t2.start();
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
System.out.println("Main thread interrupted");
}
System.out.println("All threads completed");
}
}
Alternative Java approach using Runnable
class Worker implements Runnable {
private String name;
private int delay;
public Worker(String name, int delay) {
this.name = name;
this.delay = delay;
}
public void run() {
System.out.println("Thread " + name + " starting");
try {
Thread.sleep(delay * 1000);
} catch (InterruptedException e) {
System.out.println("Thread interrupted");
}
System.out.println("Thread " + name + " finished");
}
}
public class RunnableExample {
public static void main(String[] args) {
Thread t1 = new Thread(new Worker("A", 2));
Thread t2 = new Thread(new Worker("B", 1));
t1.start();
t2.start();
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
System.out.println("Main thread interrupted");
}
System.out.println("All threads completed");
}
}
Thread Synchronization (Locks)
Python Synchronization
import threading
import time
counter = 0
lock = threading.Lock()
def increment(name, times):
global counter
for i in range(times):
with lock: # Acquire lock automatically
temp = counter
time.sleep(0.001) # Simulate work
counter = temp + 1
print(f"{name}: {counter}")
# Create threads
t1 = threading.Thread(target=increment, args=("Thread-1", 5))
t2 = threading.Thread(target=increment, args=("Thread-2", 5))
t1.start()
t2.start()
t1.join()
t2.join()
print(f"Final counter: {counter}")
Java Synchronization
class Counter {
private int count = 0;
public synchronized void increment(String name) {
int temp = count;
try {
Thread.sleep(1); // Simulate work
} catch (InterruptedException e) {
System.out.println("Thread interrupted");
}
count = temp + 1;
System.out.println(name + ": " + count);
}
public int getCount() {
return count;
}
}
class IncrementThread extends Thread {
private Counter counter;
private String name;
private int times;
public IncrementThread(Counter counter, String name, int times) {
this.counter = counter;
this.name = name;
this.times = times;
}
public void run() {
for (int i = 0; i < times; i++) {
counter.increment(name);
}
}
}
public class SyncExample {
public static void main(String[] args) {
Counter counter = new Counter();
IncrementThread t1 = new IncrementThread(counter, "Thread-1", 5);
IncrementThread t2 = new IncrementThread(counter, "Thread-2", 5);
t1.start();
t2.start();
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
System.out.println("Main thread interrupted");
}
System.out.println("Final counter: " + counter.getCount());
}
}
Producer-Consumer Pattern
Python Producer-Consumer
import threading
import queue
import time
import random
# Thread-safe queue
q = queue.Queue(maxsize=3)
def producer(name):
for i in range(5):
item = f"{name}-item-{i}"
q.put(item)
print(f"Producer {name}: Added {item}")
time.sleep(random.uniform(0.1, 0.5))
def consumer(name):
while True:
try:
item = q.get(timeout=2)
print(f"Consumer {name}: Processing {item}")
time.sleep(random.uniform(0.2, 0.8))
q.task_done()
except queue.Empty:
print(f"Consumer {name}: No more items")
break
# Start threads
producer_thread = threading.Thread(target=producer, args=("P1",))
consumer_thread = threading.Thread(target=consumer, args=("C1",))
producer_thread.start()
consumer_thread.start()
producer_thread.join()
consumer_thread.join()
Java Producer-Consumer
import java.util.LinkedList;
import java.util.Queue;
class Buffer {
private Queue<String> queue = new LinkedList<>();
private int capacity;
public Buffer(int capacity) {
this.capacity = capacity;
}
public synchronized void put(String item) throws InterruptedException {
while (queue.size() == capacity) {
wait(); // Wait if buffer is full
}
queue.add(item);
System.out.println("Produced: " + item);
notifyAll(); // Notify waiting consumers
}
public synchronized String get() throws InterruptedException {
while (queue.isEmpty()) {
wait(); // Wait if buffer is empty
}
String item = queue.poll();
System.out.println("Consumed: " + item);
notifyAll(); // Notify waiting producers
return item;
}
}
class Producer extends Thread {
private Buffer buffer;
private String name;
public Producer(Buffer buffer, String name) {
this.buffer = buffer;
this.name = name;
}
public void run() {
try {
for (int i = 0; i < 5; i++) {
String item = name + "-item-" + i;
buffer.put(item);
Thread.sleep(100);
}
} catch (InterruptedException e) {
System.out.println("Producer interrupted");
}
}
}
class Consumer extends Thread {
private Buffer buffer;
private String name;
public Consumer(Buffer buffer, String name) {
this.buffer = buffer;
this.name = name;
}
public void run() {
try {
for (int i = 0; i < 5; i++) {
buffer.get();
Thread.sleep(200);
}
} catch (InterruptedException e) {
System.out.println("Consumer interrupted");
}
}
}
public class ProducerConsumer {
public static void main(String[] args) {
Buffer buffer = new Buffer(3);
Producer producer = new Producer(buffer, "P1");
Consumer consumer = new Consumer(buffer, "C1");
producer.start();
consumer.start();
try {
producer.join();
consumer.join();
} catch (InterruptedException e) {
System.out.println("Main thread interrupted");
}
}
}
Key Differences
| Feature | Python | Java |
|---|---|---|
| Thread Creation | threading.Thread(target=func) [21] | Extend Thread class or implement Runnable [6] |
| Synchronization | with lock: or lock.acquire()/release() [21] | synchronized keyword or explicit locks [4][5] |
| Built-in Queues | queue.Queue() thread-safe [6] | Manual implementation with wait()/notify() [8] |
| Exception Handling | Automatic cleanup with with statement [3] | Manual try-catch in threads [7] |
| GIL Impact | Limited CPU parallelism due to Global Interpreter Lock [8] | True parallelism for CPU-bound tasks [9] |
Passing objects as parameters in Java involves passing a copy of the object reference (not the object itself), which allows the method to access and modify the original object's properties. Java is always "pass-by-value," but for objects, the value being passed is the reference to the object in memory.[1][2][3][4]
Basic Object Parameter Passing
Simple object passing
class Person {
private String name;
private int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
public String getName() { return name; }
public int getAge() { return age; }
public void setName(String name) { this.name = name; }
public void setAge(int age) { this.age = age; }
public void displayInfo() {
System.out.println("Name: " + name + ", Age: " + age);
}
}
public class ObjectParameterExample {
// Method that takes a Person object as parameter
public static void displayPersonInfo(Person p) {
System.out.println("Displaying person info:");
p.displayInfo();
}
// Method that modifies the object
public static void updatePersonAge(Person p, int newAge) {
p.setAge(newAge);
System.out.println("Updated age to: " + newAge);
}
public static void main(String[] args) {
Person person1 = new Person("John", 25);
// Pass object to method
displayPersonInfo(person1);
// Modify object through method
updatePersonAge(person1, 30);
// Check if original object was modified
person1.displayInfo(); // Age will be 30
}
}
Multiple object parameters
class Student {
private String name;
private double grade;
public Student(String name, double grade) {
this.name = name;
this.grade = grade;
}
public String getName() { return name; }
public double getGrade() { return grade; }
public void setGrade(double grade) { this.grade = grade; }
}
public class MultipleObjectParameters {
// Method taking multiple object parameters
public static void compareStudents(Student s1, Student s2) {
System.out.println("Comparing students:");
System.out.println(s1.getName() + ": " + s1.getGrade());
System.out.println(s2.getName() + ": " + s2.getGrade());
if (s1.getGrade() > s2.getGrade()) {
System.out.println(s1.getName() + " has higher grade");
} else if (s2.getGrade() > s1.getGrade()) {
System.out.println(s2.getName() + " has higher grade");
} else {
System.out.println("Both have same grade");
}
}
// Method that swaps grades between students
public static void swapGrades(Student s1, Student s2) {
double temp = s1.getGrade();
s1.setGrade(s2.getGrade());
s2.setGrade(temp);
System.out.println("Grades swapped!");
}
public static void main(String[] args) {
Student alice = new Student("Alice", 85.5);
Student bob = new Student("Bob", 92.0);
compareStudents(alice, bob);
System.out.println("\nBefore swap:");
System.out.println("Alice: " + alice.getGrade());
System.out.println("Bob: " + bob.getGrade());
swapGrades(alice, bob);
System.out.println("\nAfter swap:");
System.out.println("Alice: " + alice.getGrade());
System.out.println("Bob: " + bob.getGrade());
}
}
Object Modification vs Reference Change
What works - Modifying object properties
class Counter {
private int count;
public Counter(int count) {
this.count = count;
}
public int getCount() { return count; }
public void setCount(int count) { this.count = count; }
public void increment() { this.count++; }
}
public class ObjectModification {
// This WORKS - modifies the object's state
public static void incrementCounter(Counter c) {
c.increment();
System.out.println("Counter incremented inside method: " + c.getCount());
}
// This WORKS - changes object properties
public static void resetCounter(Counter c, int newValue) {
c.setCount(newValue);
System.out.println("Counter reset to: " + newValue);
}
public static void main(String[] args) {
Counter myCounter = new Counter(5);
System.out.println("Original count: " + myCounter.getCount());
incrementCounter(myCounter);
System.out.println("After increment: " + myCounter.getCount()); // Will be 6
resetCounter(myCounter, 10);
System.out.println("After reset: " + myCounter.getCount()); // Will be 10
}
}
What doesn't work - Reassigning the reference
class Box {
private String contents;
public Box(String contents) {
this.contents = contents;
}
public String getContents() { return contents; }
public void setContents(String contents) { this.contents = contents; }
}
public class ReferenceReassignment {
// This DOESN'T work - cannot change what the original reference points to
public static void tryToChangeReference(Box b) {
b = new Box("New Box"); // This creates a new object but doesn't change original
System.out.println("Inside method: " + b.getContents());
}
// This WORKS - modifies the existing object
public static void changeContents(Box b) {
b.setContents("Modified Contents");
}
public static void main(String[] args) {
Box originalBox = new Box("Original Contents");
System.out.println("Before tryToChangeReference: " + originalBox.getContents());
tryToChangeReference(originalBox);
System.out.println("After tryToChangeReference: " + originalBox.getContents());
// Still "Original Contents" - reference wasn't changed
System.out.println("\nBefore changeContents: " + originalBox.getContents());
changeContents(originalBox);
System.out.println("After changeContents: " + originalBox.getContents());
// Now "Modified Contents" - object was modified
}
}
Array as Object Parameter
public class ArrayParameter {
// Arrays are objects, so same rules apply
public static void modifyArray(int[] arr) {
for (int i = 0; i < arr.length; i++) {
arr[i] = arr[i] * 2;
}
System.out.println("Array modified inside method");
}
// This won't change the original array reference
public static void tryToReplaceArray(int[] arr) {
arr = new int[]{100, 200, 300}; // Creates new array, doesn't affect original
System.out.println("New array created inside method");
}
public static void displayArray(int[] arr) {
System.out.print("Array contents: ");
for (int value : arr) {
System.out.print(value + " ");
}
System.out.println();
}
public static void main(String[] args) {
int[] numbers = {1, 2, 3, 4, 5};
System.out.println("Original array:");
displayArray(numbers);
modifyArray(numbers);
System.out.println("After modification:");
displayArray(numbers); // Values will be doubled
tryToReplaceArray(numbers);
System.out.println("After trying to replace:");
displayArray(numbers); // Still the doubled values, not {100, 200, 300}
}
}
Custom Objects with Complex Operations
class BankAccount {
private String accountNumber;
private double balance;
public BankAccount(String accountNumber, double balance) {
this.accountNumber = accountNumber;
this.balance = balance;
}
public String getAccountNumber() { return accountNumber; }
public double getBalance() { return balance; }
public void deposit(double amount) {
if (amount > 0) {
balance += amount;
System.out.println("Deposited: $" + amount);
}
}
public boolean withdraw(double amount) {
if (amount > 0 && balance >= amount) {
balance -= amount;
System.out.println("Withdrew: $" + amount);
return true;
}
System.out.println("Insufficient funds or invalid amount");
return false;
}
public void displayAccount() {
System.out.println("Account: " + accountNumber + ", Balance: $" + balance);
}
}
public class BankOperations {
// Transfer money between accounts
public static boolean transferMoney(BankAccount from, BankAccount to, double amount) {
System.out.println("Attempting to transfer $" + amount);
if (from.withdraw(amount)) {
to.deposit(amount);
System.out.println("Transfer successful!");
return true;
} else {
System.out.println("Transfer failed!");
return false;
}
}
// Apply interest to an account
public static void applyInterest(BankAccount account, double interestRate) {
double interest = account.getBalance() * interestRate / 100;
account.deposit(interest);
System.out.println("Interest applied: " + interestRate + "%");
}
// Compare two accounts
public static void compareAccounts(BankAccount acc1, BankAccount acc2) {
System.out.println("\nAccount comparison:");
acc1.displayAccount();
acc2.displayAccount();
if (acc1.getBalance() > acc2.getBalance()) {
System.out.println(acc1.getAccountNumber() + " has higher balance");
} else if (acc2.getBalance() > acc1.getBalance()) {
System.out.println(acc2.getAccountNumber() + " has higher balance");
} else {
System.out.println("Both accounts have equal balance");
}
}
public static void main(String[] args) {
BankAccount alice = new BankAccount("ACC001", 1000.0);
BankAccount bob = new BankAccount("ACC002", 500.0);
System.out.println("Initial state:");
alice.displayAccount();
bob.displayAccount();
// Transfer money
transferMoney(alice, bob, 200.0);
System.out.println("\nAfter transfer:");
alice.displayAccount();
bob.displayAccount();
// Apply interest
applyInterest(alice, 2.5);
applyInterest(bob, 2.5);
System.out.println("\nAfter interest:");
alice.displayAccount();
bob.displayAccount();
// Compare accounts
compareAccounts(alice, bob);
}
}
Key Rules for Object Parameters
What you can do
- Modify object properties: Change field values using setter methods[3][1]
- Call object methods: Invoke any public methods on the passed object[2]
- Access object data: Read object properties through getter methods[5]
- Modify object state: Change the internal state of the object[3]
What you cannot do
- Change the reference: Cannot make the original variable point to a different object[4][6]
- Reassign the parameter:
obj = new Object()inside method won't affect original[4]
Memory explanation
When you pass an object to a method, Java copies the reference value (memory address) to the method parameter, so both the original variable and the parameter point to the same object in memory. This means:[7][3]
- Changes to the object affect the original (same object in memory)[1]
- Reassigning the parameter creates a new reference but doesn't change the original variable[4]
This behavior makes object passing in Java very powerful for modifying objects while maintaining clear boundaries about what can and cannot be changed.[1][3]