What’s one thing you learned? What’s still confusing?
Testing with pytest: Beyond Basic assert
Parametrize, fixtures, pytest.raises, and pytest-cov.
Building APIs with FastAPI
REST APIs with FastAPI, Pydantic validation, serving ML models, testing endpoints.
Reading Real Python Code: A FastAPI Service End-to-End
Read a complete 180-line FastAPI service line-by-line. Bridge from 'I know Python syntax' to 'I can read a codebase.'
Interactive Labs for This Track
Loop Visualizer
You're a factory robot repeating the same task on an assembly line — watch how loops automate repetitive work
List Slicing
You have a playlist of 50 songs — grab just tracks 10 through 20 with a single slice expression
Sorting Algorithms
You're organizing a library of 10,000 books — which sorting method is fastest?
Ask questions, share insights
from collections import defaultdict
# --- Adjacency List (best for sparse graphs) ---
# Space: O(V + E)
# Check if edge exists: O(degree(v))
# Iterate all neighbors: O(degree(v))
graph_list: dict[str, list[str]] = {
"A": ["B", "C"],
"B": ["A", "D", "E"],
"C": ["A", "F"],
"D": ["B"],
"E": ["B", "F"],
"F": ["C", "E"],
}
# Weighted adjacency list: {node: [(neighbor, weight)]}
weighted_graph: dict[str, list[tuple[str, int]]] = {
"A": [("B", 4), ("C", 2)],
"B": [("A", 4), ("D", 3), ("E", 1)],
"C": [("A", 2), ("F", 5)],
"D": [("B", 3)],
"E": [("B", 1), ("F", 2)],
"F": [("C", 5), ("E", 2)],
}
# --- Adjacency Matrix (best for dense graphs) ---
# Space: O(V^2)
# Check if edge exists: O(1)
# Iterate all neighbors: O(V)
# For vertices ["A", "B", "C", "D"]
# Row i = from vertex i, Col j = to vertex j
adj_matrix = [
[0, 1, 1, 0], # A -> B, C
[1, 0, 0, 1], # B -> A, D
[1, 0, 0, 0], # C -> A
[0, 1, 0, 0], # D -> B
]
# --- Edge List (simple, used in Kruskal's MST) ---
edges: list[tuple[str, str, int]] = [
("A", "B", 4),
("A", "C", 2),
("B", "D", 3),
("C", "F", 5),
]| Representation | Space | Edge Lookup | Iterate Neighbors | Best For |
|---|---|---|---|---|
| Adjacency List | O(V+E) | O(degree) | O(degree) | Sparse graphs (most real-world) |
| Adjacency Matrix | O(V^2) | O(1) | O(V) | Dense graphs, fast edge checks |
| Edge List | O(E) | O(E) | O(E) | Input format, Kruskal's MST |
A social network has 1 billion users averaging 200 friends each. Which representation, and what does the other one cost?
from collections import deque
def bfs(graph: dict, start: str) -> list[str]:
"""Breadth-first search: explores level by level. O(V+E).
Uses a queue (FIFO). Guarantees shortest path in unweighted graphs.
"""
visited: set[str] = {start}
queue: deque[str] = deque([start])
order: list[str] = []
while queue:
node = queue.popleft()
order.append(node)
for neighbor in graph.get(node, []):
if neighbor not in visited:
visited.add(neighbor)
queue.append(neighbor)
return order
def bfs_shortest_path(graph: dict, start: str, end: str) -> list[str] | None:
"""BFS to find the shortest path (fewest edges) between two nodes.
Track the predecessor of each visited node to reconstruct the path.
"""
if start == end:
return [start]
visited: set[str] = {start}
queue: deque[list[str]] = deque([[start]]) # queue of paths
while queue:
path = queue.popleft()
node = path[-1]
for neighbor in graph.get(node, []):
if neighbor not in visited:
new_path = path + [neighbor]
if neighbor == end:
return new_path
visited.add(neighbor)
queue.append(new_path)
return None # no path exists
def dfs_iterative(graph: dict, start: str) -> list[str]:
"""Depth-first search (iterative, using explicit stack). O(V+E).
Goes as deep as possible before backtracking. Useful for cycle
detection, topological sort, and finding all connected components.
"""
visited: set[str] = set()
stack: list[str] = [start]
order: list[str] = []
while stack:
node = stack.pop() # LIFO: go deep first
if node not in visited:
visited.add(node)
order.append(node)
for neighbor in reversed(graph.get(node, [])):
if neighbor not in visited:
stack.append(neighbor)
return order
def dfs_recursive(graph: dict, start: str,
visited: set | None = None) -> list[str]:
"""Depth-first search (recursive). O(V+E).
Cleaner than iterative for problems that naturally recurse,
like finding all paths or generating combinations.
"""
if visited is None:
visited = set()
visited.add(start)
order = [start]
for neighbor in graph.get(start, []):
if neighbor not in visited:
order.extend(dfs_recursive(graph, neighbor, visited))
return order
# Example graph
# A -- B -- D
# | |
# C -- F -- E
g = {
"A": ["B", "C"],
"B": ["A", "D", "E"],
"C": ["A", "F"],
"D": ["B"],
"E": ["B", "F"],
"F": ["C", "E"],
}
print("BFS from A:", bfs(g, "A")) # A, B, C, D, E, F
print("DFS from A:", dfs_iterative(g, "A")) # A, B, D, E, F, C
print("Shortest A→F:", bfs_shortest_path(g, "A", "F")) # ['A', 'C', 'F']def count_connected_components(graph: dict) -> int:
"""Count the number of connected components using DFS. O(V+E)."""
all_nodes = set(graph.keys())
visited: set[str] = set()
components = 0
for node in all_nodes:
if node not in visited:
dfs_recursive(graph, node, visited)
components += 1
return componentsdef dfs(graph, node, visited):
visited.add(node)
for nbr in graph[node]:
if nbr not in visited:
dfs(graph, nbr, visited)Watch the call stack on the right: it grows as DFS plunges deep (A → B → E → F → C reaches depth 5) and shrinks on the way back. That depth IS the max recursion depth — for a graph with a long path, naive recursive DFS can hit Python's default 1000-frame limit. Iterative DFS with an explicit stack avoids that ceiling.
BFS and DFS differ mainly in one line: BFS uses `queue.popleft()`, DFS a stack (or recursion). Which guarantees the shortest path in an UNWEIGHTED graph, and why does the other not?
From A, the cheapest single edge is A→C at cost 2, while A→B costs 4. In the finished distance table, which route does Dijkstra report for A→F?
When a shorter route to a node is found, the code pushes a new heap entry and never removes the old one. What does if d > dist[u]: continue do about that?
u → v has u appearing before v in the ordering. Used for task scheduling, build systems, and dependency resolution.from collections import deque
def topological_sort_kahn(graph: dict[str, list[str]]) -> list[str] | None:
"""Kahn's algorithm: BFS-based topological sort. O(V+E).
Repeatedly removes nodes with no incoming edges (in-degree 0).
If not all nodes are removed, a cycle exists.
"""
# Count incoming edges (in-degree) for each node
in_degree: dict[str, int] = {node: 0 for node in graph}
for node in graph:
for neighbor in graph[node]:
in_degree[neighbor] = in_degree.get(neighbor, 0) + 1
# Start with all nodes that have no prerequisites
queue: deque[str] = deque(n for n, d in in_degree.items() if d == 0)
order: list[str] = []
while queue:
node = queue.popleft()
order.append(node)
for neighbor in graph[node]:
in_degree[neighbor] -= 1
if in_degree[neighbor] == 0:
queue.append(neighbor)
# If order doesn't contain all nodes, a cycle was detected
return order if len(order) == len(graph) else None
# Why that length check detects cycles is the question below.
def topological_sort_dfs(graph: dict[str, list[str]]) -> list[str] | None:
"""DFS-based topological sort (Tarjan's algorithm). O(V+E).
Each node has 3 states: unvisited (0), in-progress (1), done (2).
If we encounter an in-progress node, there is a cycle.
"""
state: dict[str, int] = {n: 0 for n in graph}
stack: list[str] = []
has_cycle = False
def dfs(node: str) -> None:
nonlocal has_cycle
if has_cycle:
return
state[node] = 1 # in progress
for neighbor in graph.get(node, []):
if state[neighbor] == 1: # back edge = cycle
has_cycle = True
return
if state[neighbor] == 0:
dfs(neighbor)
state[node] = 2 # done
stack.append(node) # add to result AFTER processing all deps
for node in graph:
if state[node] == 0:
dfs(node)
return None if has_cycle else stack[::-1]
# Course prerequisite example:
# CS101 → CS201 → CS301
# ↘ ↗
# CS202
prereqs: dict[str, list[str]] = {
"CS101": ["CS201", "CS202"],
"CS201": ["CS301"],
"CS202": ["CS301"],
"CS301": [],
}
print("Kahn's order:", topological_sort_kahn(prereqs))
# ['CS101', 'CS201', 'CS202', 'CS301'] or similar valid order
print("DFS order:", topological_sort_dfs(prereqs))Kahn's algorithm reports a cycle by checking `len(order) == len(graph)` at the very end. How does a length comparison detect a cycle?
Union-Find tracks a collection of elements partitioned into disjoint sets. It answers two questions in nearly O(1) amortized time: "Do these two elements belong to the same set?" and "Merge the sets of these two elements."
class UnionFind:
"""Disjoint Set Union with path compression and union by rank.
After applying both optimizations:
- find: O(α(n)) ≈ O(1) amortized (α is the inverse Ackermann function)
- union: O(α(n)) ≈ O(1) amortized
"""
def __init__(self, n: int) -> None:
"""Initialize n separate singleton sets."""
self.parent: list[int] = list(range(n)) # each node is its own root
self.rank: list[int] = [0] * n # tree height upper bound
self.components: int = n # number of distinct sets
def find(self, x: int) -> int:
"""Find the root representative of x's set.
Path compression: after finding the root, point every node on the
path directly to the root. Future find() calls will be O(1).
"""
if self.parent[x] != x:
self.parent[x] = self.find(self.parent[x]) # path compression
return self.parent[x]
def union(self, x: int, y: int) -> bool:
"""Merge the sets containing x and y.
Union by rank: attach the shorter tree under the taller one.
This keeps trees shallow, making find() fast.
Returns True if they were in different sets (a merge happened).
"""
root_x, root_y = self.find(x), self.find(y)
if root_x == root_y:
return False # already in the same set
# Attach smaller rank tree under larger rank tree
if self.rank[root_x] < self.rank[root_y]:
root_x, root_y = root_y, root_x
self.parent[root_y] = root_x
if self.rank[root_x] == self.rank[root_y]:
self.rank[root_x] += 1
self.components -= 1
return True
def connected(self, x: int, y: int) -> bool:
"""Check if x and y are in the same set."""
return self.find(x) == self.find(y)
# --- Number of islands using Union-Find ---
def count_islands(grid: list[list[str]]) -> int:
"""Count connected land masses ('1') in a 2D grid. O(m*n * α(m*n))."""
if not grid:
return 0
rows, cols = len(grid), len(grid[0])
uf = UnionFind(rows * cols)
land_cells = 0
for r in range(rows):
for c in range(cols):
if grid[r][c] == "1":
land_cells += 1
idx = r * cols + c
for dr, dc in [(0, 1), (1, 0)]: # only right and down to avoid duplicates
nr, nc = r + dr, c + dc
if 0 <= nr < rows and 0 <= nc < cols and grid[nr][nc] == "1":
uf.union(idx, nr * cols + nc)
# Count unique roots among land cells
return len({uf.find(r * cols + c)
for r in range(rows)
for c in range(cols)
if grid[r][c] == "1"})
grid = [
["1", "1", "0", "0", "0"],
["1", "1", "0", "0", "0"],
["0", "0", "1", "0", "0"],
["0", "0", "0", "1", "1"],
]
print(count_islands(grid)) # 3Watch Dijkstra step through a weighted graph, which node it settles next, and why. The same panel still has Part 1's min-heap view, which is worth a second look now that you know what the heap is being used for:
Tests · Implement Dijkstra, heap operations, and BFS path finding!
{node: [neighbours]}. A matrix only wins when the graph is dense or you need O(1) "is there an edge?"collections.deque (popleft is O(1)) never list.pop(0) (O(n))if d > dist[u]: continue guardWhy does Dijkstra's algorithm fail on graphs with negative edge weights?
import heapq
def dijkstra(graph: dict[str, list[tuple[str, int]]], source: str
) -> dict[str, int]:
"""Dijkstra's shortest path algorithm. O((V + E) log V).
Args:
graph: weighted adjacency list {node: [(neighbor, weight), ...]}
source: starting vertex
Returns:
dist: shortest distance from source to every reachable vertex
Algorithm:
1. Initialize dist[source]=0, all others=infinity
2. Use min-heap: (distance, node)
3. Extract the node with the smallest tentative distance
4. Relax its edges: if dist[u] + weight < dist[v], update dist[v]
5. Repeat until heap is empty
Key insight: when we pop a node from the heap, its distance is finalized
(greedy choice). We never need to re-visit it with a shorter path.
"""
dist: dict[str, float] = defaultdict(lambda: float("inf"))
dist[source] = 0
# (distance, node)
heap: list[tuple[float, str]] = [(0, source)]
while heap:
d, u = heapq.heappop(heap)
# Skip if we already found a shorter path to u
if d > dist[u]:
continue
for v, weight in graph.get(u, []):
new_dist = dist[u] + weight
if new_dist < dist[v]:
dist[v] = new_dist
heapq.heappush(heap, (new_dist, v))
return dict(dist)
# The `if d > dist[u]: continue` line above is the subject of the question
# below -- it is what makes this "lazy deletion" work.
def dijkstra_with_path(graph: dict, source: str, target: str
) -> tuple[int, list[str]]:
"""Dijkstra returning both the distance and the actual path."""
dist: dict[str, float] = defaultdict(lambda: float("inf"))
dist[source] = 0
prev: dict[str, str | None] = {source: None}
heap = [(0, source)]
while heap:
d, u = heapq.heappop(heap)
if d > dist[u]:
continue
if u == target:
break
for v, weight in graph.get(u, []):
new_dist = dist[u] + weight
if new_dist < dist[v]:
dist[v] = new_dist
prev[v] = u
heapq.heappush(heap, (new_dist, v))
# Reconstruct path
path: list[str] = []
node: str | None = target
while node is not None:
path.append(node)
node = prev.get(node)
path.reverse()
return int(dist[target]), path
# Example weighted graph:
#
# 4 3
# A ─────> B ─────> D
# │ │
# 2│ 1│
# ▼ ▼
# C ─────> F ─────> E
# 5 2
wg: dict[str, list[tuple[str, int]]] = {
"A": [("B", 4), ("C", 2)],
"B": [("D", 3), ("E", 1)],
"C": [("F", 5)],
"D": [],
"E": [("F", 2)],
"F": [],
}
distances = dijkstra(wg, "A")
print("Distances from A:")
for node in sorted(distances):
print(f" A -> {node}: {distances[node]}")
# A -> A: 0
# A -> B: 4
# A -> C: 2
# A -> D: 7
# A -> E: 5
# A -> F: 7
dist, path = dijkstra_with_path(wg, "A", "F")
print(f"\nShortest path A→F: {' → '.join(path)} (cost {dist})")
# Shortest path A→F: A → B → E → F (cost 7)