What are the common ways to represent a graph?
1) Adjacency Matrix
- Common way to represent a graph as a matrix
- Square matrix where the rows and columns represent the vertices of the graph, and the entries (elements) of the matrix indicate whether there is an edge between the corresponding vertices
Example of an undirected graph with 4 vertices (A, B, C, D) and 4 edges (A-B, B-C, C-D, D-A)
A --- B
D --- C
A B C D
A 0 1 0 1
B 1 0 1 0
C 0 1 0 1
D 1 0 1 02) Adjacency List
- In a adjacency list, each vertex is associated with a list of its neighboring vertices directly connected to it
Example of an undirected graph with 4 vertices (A, B, C, D) and four edges
A --- B
C --- D
{
"A": ["B", "C"],
"B": ["A", "D"],
"C": ["A", "D"],
"D": ["B", "C"],
}
paths = defaultdict(list)
for edge in edges:
paths[edge[0]].append(edge[1])
paths[edge[1]].append(edge[0])|
Graph Traversal using DFS
1) Initialize
- Choose a starting node as the source node
- Create a hashset or array for marking the source node as visited
2) Visit the current node
- Process the current node (eg print or perform any other operation you need to do)
3) Recursive approach (Using Recusion)
- For each unvisited neighbor of the current node:
- Recursively call the DFS function with the neighbor as the new current node
- Mark the neighbor as visited
4) Stack-Based Approach (Using an Explicit Stack)
- Push the starting node onto the stack
- While the stack is not empty:
- Pop a node from the stack (current node)
- Process the current node (eg print or perform any other operation)
- For each unvisited neighbor:
Push onto the stack
Mark as visited
5) Backtracking
- If there are no more unvisited neighbors for the current node, backtrack by returning from the recursive function (if recursion) or popping nodes from the stack until a node with unvisited neighbors is found (if using an explicit stack)
6) Termination
- The DFS algorithm terminates when all nodes reachable from the source node have been visited
- This means that all the connected components of the graph have been explored
Graph Traversal using Breadth First Search (BFS)
1) Use a queue
- BFS uses a queue to keep track of the nodes to be visited
- Follows FIFO principle
2) Initialization
- Select a source node to begin the traversal
- Create an empty queue to hold the nodes to be visited
- Mark the source node as visited and enqueue it onto the queue
3) Traversal
- While the queue is not empty:
- Dequeue a node from the from of the queue (let’s call it “current node”)
- Process the current node (print it, perform operation)
- Enqueue all the unvisited neighbors of the current node into the queue
- Mark each enqueued neighbor as visited
4) Termination
- The BFS algorithm continues until the queue becomes empty, meaning all reachable nodes from the source node have been visited
BFS Algo for Graphs
https://leetcode.com/explore/learn/card/queue-stack/231/practical-application-queue/1372/
https://algo.monster/problems/graph_bfs
Implement classic binary search
def binary_search(arr: List[int], target: int) -> int:
l, r = 0, len(arr) - 1
index = -1
while l <= r:
mid = (l + r) // 2
if arr[mid] == target:
return mid
elif arr[mid] < target:
l = mid + 1
else:
r = mid - 1
return index
def binary_search(arr: List[int], target: int) -> int:
left, right = 0, len(arr) - 1
first_true_index = -1
while left <= right:
mid = (left + right) // 2
if feasible(mid):
first_true_index = mid
right = mid - 1
else:
left = mid + 1
return first_true_indexRecursion
What makes a function properly recursive?
1. Base case/exit.
2. Recursive call. Calling the function with different arguments in order to break the problem down to its fundamental parts
Frame: Whenever a function is called, a new frame is opened in which the function performs its computation. Once the computation is finished, the results are returned to the calling function and the frame, along with the function and its contents, is discarded.
Strategy for recursion
- What’s the simplest possible input
- Play around w/ examples and visualize
- Generalize the pattern
- Write code by combining recursive pattern with base case
Full, Complete, and Perfect Binary Trees
Full Binary Tree
- Every node has 0 or 2 children
Complete Binary Tree
- All levels are completely filled except possibly the last level and all nodes in the last level are as far left as possible
Perfect binary tree
- All nodes have 2 children and all leaf nodes have the same level
Binary Search Tree
A special type of binary tree in which every node follows the ordering property
all left descendants < node < all right descendants
Balanced Binary Tree
Every node in a balanced binary tree fulfills the condition:
Pre-order Traversal
def preorder(root: Node | None) -> list[int]:
result = []
def dfs(node: Node | None) -> None:
if not node:
return
result.append(node.val)
dfs(node.left)
dfs(node.right)
dfs(root)
return resultIn-order Traversal
def in_order_traversal_bst(tree: Node | None) -> list[int]:
"""
Given a binary search tree, return the in-order traversal of its nodes' values.
"""
res = []
def dfs(tree):
if not tree:
return
dfs(tree.left)
res.append(tree.val)
dfs(tree.right)
dfs(tree)
return resPost-order Traversal
def postorder(root: Node | None) -> list[int]:
"""
Processes the root/node after its subtrees
"""
result = []
def dfs(node: Node | None) -> None:
if not node:
return
dfs(node.left)
dfs(node.right)
result.append(node.val)
dfs(root)
return resultImplement DFS Fundamental Algorithm for Finding a target within a tree
def dfs(root: Node | None, target):
# https://algo.monster/problems/dfs_intro
if root is None:
return None
if root.val == target:
return root
# return non-null return value from the recursive calls
left = dfs(root.left, target)
if left is not None:
return left
# at this point, we know left is null, and right could be null or non-null
# we return right child's recursive call result directly because
# - if it's non-null we should return it
# - if it's null, then both left and right are null, we want to return null
return dfs(root.right, target)
# FYI, can also be simplified to
#return dfs(root.left, target) or dfs(root.right, target)