Compare commits

...

23 Commits

Author SHA1 Message Date
a7ee5d06a0 Add in-progress notes for 2026.07.21 2026-07-21 21:06:54 -04:00
059c848d3d Add more detailed error messages for the RB assignment 2026-07-21 20:13:27 -04:00
d488ac5575 Add more assignment details 2026-07-21 16:09:18 -04:00
95544077bc Add notes for BFS as well 2026-07-21 16:05:00 -04:00
42a3430627 Add 2026.07.21 overview and related document 2026-07-21 15:57:43 -04:00
c97e452c44 Add 2026.07.15 session 2026-07-14 20:44:18 -04:00
953ce7f2cb Implement a sample solution which passes all test cases 2026-07-14 19:24:04 -04:00
4ca03b5487 Add an overview for this week 2026-07-08 20:14:37 -04:00
732159838d Add 2026.07.01 homework 2026-07-01 23:13:22 -04:00
94f913a52b Add more Git instructions 2026-06-12 17:44:47 -04:00
40db395a85 Add Git instructions 2026-06-12 17:36:50 -04:00
14c14d1797 Construct a sample tree with the rebalance implementation missing 2026-06-11 12:36:53 -04:00
0fd98a46ca Update test cases and sample code for 2026.05.27 homework 2026-06-11 11:53:55 -04:00
e2dcba1600 Add homework for 2026.05.27 2026-06-03 16:00:09 -07:00
093e2e980d Add 2026.06.03 overview 2026-06-03 15:24:47 -07:00
d5b078210f Add homework for 2026.05.27 2026-05-28 10:27:06 -07:00
dc637640c8 Add tree exercises 2026-05-27 15:43:36 -07:00
10b15d3515 Move 2026.05.20 to 2026.05.27 2026-05-27 15:07:45 -07:00
873f3bfa83 Add None example for 2026.05.13 homework 2026-05-20 15:59:13 -07:00
7b407b4eb1 Add example solution 2026-05-20 15:41:27 -07:00
3e978c5dcd Add 2026.05.20 overview 2026-05-19 11:05:46 -07:00
c8fe899770 Add homework 2026-05-13 15:59:17 -07:00
93f5702d5f Update 2026.05.06 homework 2026-05-13 15:07:06 -07:00
23 changed files with 19040 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
.idea

View File

@@ -4,6 +4,14 @@ class Student:
self.year = year
class Node:
next = None
previous = None
def __init__(self, student: Student):
self.student = student
# homework: implement this Queue which orders students by year.
# students with a higher year are placed in front of students with a lower year
class Queue:

View File

@@ -0,0 +1,130 @@
# Also do the 2026.05.06 homework
#
# Implement insertion, removal and search in a binary search tree.
# Properties of binary search trees:
# - Each node has at most two children
# - The left subtree of a node contains only nodes with values less than the node's value
# - The right subtree of a node contains only nodes with values greater than the node's value
#
# Example of what such a tree would look like:
# 10
# / \
# 5 15
# / \ \
# 3 7 20
#
# Example of an *invalid* tree:
# 10
# / \
# 5 15
# / \ \
# 3 7 20
# \
# 12
#
# Don't worry about balancing the tree for now, we will cover that later
class Node:
def __init__(self, value, left=None, right=None):
self.value = value
self.left: Node | None = left
self.right: Node | None = right
class BinarySearchTree:
def __init__(self, root=None):
self.root: Node | None = root
def find_prev_node(self, value):
prev_node: Node | None = None
node = self.root
while node is not None:
prev_node = node
if node.value >= value:
node = node.left
else:
node = node.right
return prev_node
def find_existing_node_and_parent(self, value):
prev_node: Node | None = None
node = self.root
while node is not None:
if value == node.value:
return node, prev_node
prev_node = node
if value < node.value:
node = node.left
else:
node = node.right
return None, None
def insert(self, value):
prev_node = self.find_prev_node(value)
if prev_node is None:
self.root = Node(value)
return
if prev_node.value >= value:
prev_node.left = Node(value)
else:
prev_node.right = Node(value)
def remove(self, value):
node, prev_node = self.find_existing_node_and_parent(value)
if prev_node is None and node is not None:
self.root = None
return
elif node is not None:
if prev_node.value >= value:
prev_node.left = None
else:
prev_node.right = None
def search(self, value):
node = self.root
while node is not None:
if value == node.value:
return node
elif value < node.value:
node = node.left
else:
node = node.right
return None
print("================ Inserts ================")
test_tree = BinarySearchTree()
test_tree.insert(5)
test_tree.insert(3)
print(test_tree.root.left.value)
test_tree.insert(4)
test_tree.insert(6)
print(test_tree.root.left.right.value)
print(test_tree.root.right.value)
print("================ Searches ================")
# is the search result giving us the expected node?
print(test_tree.search(4) == test_tree.root.left.right)
print(test_tree.search(6) == test_tree.root.right)
print(test_tree.search(20) is None)
print("================ Removals ================")
test_tree.remove(4)
print(test_tree.root.left.right)
test_tree.remove(6)
print(test_tree.root.right)
test_tree.remove(3)
print(test_tree.root.left)
test_tree.remove(5)
print(test_tree.root)

View File

@@ -0,0 +1,121 @@
# Example: expression tree + evaluation
from enum import Enum
class TokenType(Enum):
NUMBER = 1
OPERATOR = 2
# either store an operator (+, -, *, /) or a number
class Token:
def __init__(self, type: TokenType, value: str):
self.type = type
self.value = value
# each individual element in the tree
class Node:
left = None
right = None
def __init__(self, token: Token, left=None, right=None):
self.token = token
self.left = left
self.right = right
# holder for the tree
class ExpressionTree:
def __init__(self, root: Node | None):
self.root = root
def evaluate(tree: ExpressionTree) -> int:
return _evaluate(tree.root)
# evaluate a tree recursively starting from the node
def _evaluate(node: Node | None) -> int:
if node is None:
return 0
if node.token.type == TokenType.NUMBER:
return int(node.token.value)
elif node.token.type == TokenType.OPERATOR:
if node.left is None or node.right is None:
raise ValueError(f"Operator {node.token.value} requires two operands")
left = _evaluate(node.left)
right = _evaluate(node.right)
if node.token.value == "+":
return left + right
elif node.token.value == "-":
return left - right
elif node.token.value == "*":
return left * right
elif node.token.value == "/":
return left // right
raise ValueError(f"Unknown operator: {node.token.value}")
# test cases
test_cases = [
(ExpressionTree(Node(Token(TokenType.NUMBER, "5"))), 5),
(
ExpressionTree(
Node(
Token(TokenType.OPERATOR, "+"),
Node(Token(TokenType.NUMBER, "3")),
Node(Token(TokenType.NUMBER, "2")),
)
),
5,
),
(
ExpressionTree(
Node(
Token(TokenType.OPERATOR, "+"),
Node(Token(TokenType.NUMBER, "3")),
Node(
Token(TokenType.OPERATOR, "*"),
Node(Token(TokenType.NUMBER, "2")),
Node(Token(TokenType.NUMBER, "3")),
),
)
),
9,
),
(
ExpressionTree(
Node(
Token(TokenType.OPERATOR, "+"),
Node(
Token(TokenType.OPERATOR, "-"),
Node(Token(TokenType.NUMBER, "3")),
Node(Token(TokenType.NUMBER, "2")),
),
Node(
Token(TokenType.OPERATOR, "*"),
Node(Token(TokenType.NUMBER, "2")),
Node(Token(TokenType.NUMBER, "3")),
),
)
),
7,
),
]
def test_evaluate() -> None:
for tree, expected in test_cases:
evaluation = evaluate(tree) == expected
print(f"Test case: evaluate({tree}) == {expected} -> {evaluation}")
if __name__ == "__main__":
test_evaluate()

View File

@@ -0,0 +1,36 @@
# Tree example:
# 10
# / \
# 5 15
# / \ \
# 3 7 20
class Node:
def __init__(self, value, left=None, right=None):
self.value = value
self.left: Node | None = left
self.right: Node | None = right
root_node = Node(10)
root_node.left = Node(5)
root_node.left.left = Node(3)
root_node.left.right = Node(7)
root_node.right = Node(15)
root_node.right.right = Node(20)
def find_depth(root: Node | None, subtree_depth=0) -> int:
if root is None:
return subtree_depth
left_depth = find_depth(root.left, subtree_depth + 1)
right_depth = find_depth(root.right, subtree_depth + 1)
return max(left_depth, right_depth)
print(find_depth(root_node))
root_node.right.right.right = Node(1)
print("After adding another level:", find_depth(root_node))

View File

@@ -0,0 +1,229 @@
# Implement left rotates and right rotates for trees.
#
# Example right rotate on 10:
#
# 10
# / \
# 5 15
# / \
# 3 7
# /
# 2
#
# Turns into:
#
# 5
# / \
# 3 10
# / / \
# 2 7 15
#
# Notice how the right child of 10's left child (7) becomes the left child of 10.
#
# Left rotates are the reverse. See this example on 16:
# 16
# \
# 33
# / \
# 22 55
#
# Turns into:
#
# 33
# / \
# 16 55
# \
# 22
#
# where the left child of 16's right child (22) turns into 16's right child.
#
# Copy this entire file, implement rotate_left and rotate_right,
# and run the tests using `python3 <name-of-file>.py`.
class Node:
def __init__(self, value, left=None, right=None, parent=None):
self.value = value
self.left: Node | None = left
self.right: Node | None = right
# required to re-set the child later
self.parent: Node | None = parent
def rotate_left(node: Node) -> Node:
if node.right is None:
return node
original_parent = node.parent
new_parent = node.right
new_right_child = node.right.left
node.right = new_right_child
if new_right_child is not None:
new_right_child.parent = node
new_parent.left = node
node.parent = new_parent
new_parent.parent = original_parent
if original_parent is not None:
if original_parent.left == node:
original_parent.left = new_parent
elif original_parent.right == node:
original_parent.right = new_parent
return new_parent
def rotate_right(node: Node) -> Node:
if node.left is None:
return node
original_parent = node.parent
new_parent = node.left
new_left_child = node.left.right
node.left = new_left_child
if new_left_child is not None:
new_left_child.parent = node
new_parent.right = node
node.parent = new_parent
new_parent.parent = original_parent
if original_parent is not None:
if original_parent.left == node:
original_parent.left = new_parent
elif original_parent.right == node:
original_parent.right = new_parent
return new_parent
# --- Tests ---------------------------------------------------------------
# Both rotate functions are expected to return the new root of the rotated
# subtree (the parent would then point its child link at that new root).
def to_tuple(node: Node | None):
"""Turn a tree into nested (value, left, right) tuples for easy comparison."""
if node is None:
return None
return (
node.value,
to_tuple(node.left),
to_tuple(node.right),
node.parent.value if node.parent is not None else None,
)
def trees_equal(a: Node | None, b: Node | None) -> bool:
return to_tuple(a) == to_tuple(b)
if __name__ == "__main__":
# note that matching is done by value for simplicity here
def test_rotate_right_example():
# The worksheet example: right rotate on 10.
root = Node(
10,
left=Node(
5,
left=Node(3, left=Node(2, parent=Node(3)), parent=Node(5)),
right=Node(7, parent=Node(5)),
parent=Node(10),
),
right=Node(15, parent=Node(10)),
)
expected = Node(
5,
left=Node(3, left=Node(2, parent=Node(3)), parent=Node(5)),
right=Node(
10,
left=Node(7, parent=Node(10)),
right=Node(15, parent=Node(10)),
parent=Node(5),
),
)
return rotate_right(root), expected
def test_rotate_left_example():
# The worksheet example: left rotate on 16.
root = Node(
16,
right=Node(
33,
left=Node(22, parent=Node(33)),
right=Node(55, parent=Node(33)),
parent=Node(16),
),
)
expected = Node(
33,
left=Node(16, right=Node(22, parent=Node(16)), parent=Node(33)),
right=Node(55, parent=Node(33)),
)
return rotate_left(root), expected
def test_rotate_right_two_nodes():
# Smallest case: a root with only a left child.
return rotate_right(Node(2, left=Node(1, parent=Node(2)))), Node(1, right=Node(2, parent=Node(1)))
def test_rotate_left_two_nodes():
# Smallest case: a root with only a right child.
return rotate_left(Node(1, right=Node(2, parent=Node(1)))), Node(2, left=Node(1, parent=Node(2)))
def test_round_trip():
# A left rotate undone by a right rotate restores the original tree.
original = Node(1, left=Node(0, parent=Node(1)),
right=Node(3, left=Node(2, parent=Node(3)), right=Node(4, parent=Node(3)), parent=Node(1)))
expected = Node(1, left=Node(0, parent=Node(1)),
right=Node(3, left=Node(2, parent=Node(3)), right=Node(4, parent=Node(3)), parent=Node(1)))
return rotate_right(rotate_left(original)), expected
def test_rotate_with_parent():
# run it on 33 instead of the root
root = Node(16, right=Node(33, left=Node(22), right=Node(55)))
root.right.parent = root
root.right.left.parent = root.right
root.right.right.parent = root.right
original = root.right
expected = Node(
55,
left=Node(33, left=Node(22, parent=Node(33)), parent=Node(55)),
parent=Node(16),
)
return rotate_left(original), expected # pyright: ignore[reportArgumentType]
tests = [
("rotate_right on worksheet example (10)", test_rotate_right_example),
("rotate_left on worksheet example (16)", test_rotate_left_example),
("rotate_right on two nodes", test_rotate_right_two_nodes),
("rotate_left on two nodes", test_rotate_left_two_nodes),
("rotate_left then rotate_right restores the tree", test_round_trip),
("rotate_left works when there is a parent", test_rotate_with_parent),
]
passed = 0
for name, test in tests:
try:
result, expected = test()
if trees_equal(result, expected):
print(f"PASS: {name}")
passed += 1
else:
print(f"FAIL: {name}")
print(f" expected {to_tuple(expected)}")
print(f" got {to_tuple(result)}")
except Exception as e: # noqa: BLE001 - surface any bug as a failed test
print(f"ERROR: {name}: {type(e).__name__}: {e}")
print(f"\n{passed}/{len(tests)} tests passed")

View File

@@ -0,0 +1,40 @@
# Tree example:
# 10
# / \
# 5 15
# / \ \
# 3 7 20
class Node:
def __init__(self, value, left=None, right=None):
self.value = value
self.left: Node | None = left
self.right: Node | None = right
root_node = Node(10)
root_node.left = Node(5)
root_node.left.left = Node(3)
root_node.left.right = Node(7)
root_node.right = Node(15)
root_node.right.right = Node(20)
def invert_tree(root: Node | None):
if root is None:
return
invert_tree(root.left)
invert_tree(root.right)
root.left, root.right = root.right, root.left
invert_tree(root_node)
print(root_node.left.value)
print(root_node.right.value)
print(root_node.left.left.value)
print(root_node.right.right.value)
print(root_node.right.left.value)

View File

@@ -0,0 +1,16 @@
## Outline
- Review homework and do some tree exercises
- Introduce tree balancing
- Introduce red-black trees
- Basic properties
- Root property: the root is black
- External property: every nil child pointer is considered a black node
- Internal property: children and parents of a red node are black
- Depth property: all nodes have the same black depth
- Insertion and balancing algorithms
- Assignment: implement red-black tree balancing
## Resources
- [Red-black tree - Wikipedia](https://en.wikipedia.org/wiki/Redblack_tree)

Binary file not shown.

View File

@@ -0,0 +1,41 @@
## Outline
- Write a red-black tree with rebalancing
- Assignment: start on the red-black tree (moved to 2026.07.01)
- Requirement: you must use the left- and right-rotation code in the previous assignment
- You may change the code to fit the class
- Next week: introduce graphs and graph algorithms
## Git reference
There are two repositories to keep track of:
- [My code](https://gitea.bchen.dev/brendan/dsa-tutoring)
- Get notes and sample code
- [Your code](https://gitea.bchen.dev/ethan/dsa-homework)
- Submit your homework
To submit homework, add all new files and changes to your code:
```
git add .
```
Commit with a message:
```
git commit -m "Hello World"
```
Push changes:
```
git push
```
To pull new changes to the notes and sample code, run this in the `dsa-tutoring` directory:
```
git pull
```

View File

@@ -0,0 +1,41 @@
## Outline
- Review tree rotations and red-black trees
- Properties of a red-black tree
- Top (root) node is black
- The children and parent of a red node are black
- Null nodes are colored black
- The path from any particular node to a null node must contain the
same number of black nodes
- Dive into the steps for rebalancing a red-black tree
Also see the accompanying whiteboard pictures.
![Rotations](rotations.png)
![Red-black trees](red-black-trees.png)
## Assignment
**Clarifications on the red-black tree example:**
- In your assignment, you should first color a node _red_ when inserting it.
- Then, check for red-red violations (the second rule). Then, rebalance if necessary.
- If the uncle of the inserted node (parent → parent → right child) is red,
you'll want to do two things:
- Color the parent, uncle, and grandparent in a way which preserves all the rules
(which ones are colored which, are an exercise left to you)
- _Recursively_ run the rebalancing algorithm on the grandparent
- Otherwise, your task is to figure out the correct conditions in which
each of the rebalancing algorithms apply.
Copy 2026.07.01/homework.py into your own [Git repository](https://gitea.bchen.dev/ethan/dsa-homework),
with a new folder for the date. Implement the `rebalance_from_just_inserted`
function.
Don't change the test cases, but use them to inform how you implement your
code. Try to pass all the test cases.
Follow the instructions from last session (2026.06.12) to commit and push
your changes to Gitea.

Binary file not shown.

After

Width:  |  Height:  |  Size: 486 KiB

View File

@@ -0,0 +1,494 @@
import traceback
class Node:
def __init__(self, value, left=None, right=None, parent=None, is_red=False):
self.value = value
self.left: Node | None = left
self.right: Node | None = right
self.is_red = is_red
# required to traverse the tree and perform various checks
self.parent: Node | None = parent
class RedBlackTree:
def __init__(self):
self.root: Node | None = None
def insert(self, value):
# assumption: inserting the same value twice will
# insert nothing the second time
node = Node(value)
if self.root is None:
# the root is always black (Node defaults to black)
self.root = node
return
# insert like a normal binary search tree
prev: Node | None = None
current = self.root
while current:
if current.value == value:
return
prev = current
if current.value > value:
current = current.left
elif current.value < value:
current = current.right
# a newly inserted (non-root) node is always red, and it needs a
# parent pointer so rebalancing can walk back up toward the root
node.is_red = True
node.parent = prev
if prev and prev.value > value:
prev.left = node
elif prev and prev.value < value:
prev.right = node
self.rebalance_from_just_inserted(node)
def delete(self, value, root=None, parent=None):
# does nothing if the value doesn't exist
if parent is None:
root = self.root
if root is None:
return
if root.value == value:
new_root: Node | None
if root.left is None:
new_root = root.right
elif root.right is None:
new_root = root.left
else:
# both left and right subtrees exist
# make left subtree root the tree root, and re-attach
# the right subtree
right_subtree_root = root.right
new_root = root.left
prev = None
current = new_root
while current:
prev = current
current = current.right
prev.right = right_subtree_root
if parent is None:
self.root = new_root
else:
if parent.value > root.value:
parent.left = new_root
else:
parent.right = new_root
elif root.value > value:
self.delete(value, root.left, root)
elif root.value < value:
self.delete(value, root.right, root)
def rebalance_from_just_inserted(self, node: Node | None):
if node is None:
return
parent = node.parent
if parent is not None and not parent.is_red:
return
elif parent is None:
node.is_red = False
return
grandparent = parent.parent
if grandparent is None:
return
uncle = grandparent.right if grandparent.left == parent else grandparent.left
if uncle is not None and uncle.is_red:
uncle.is_red = False
parent.is_red = False
grandparent.is_red = True
self.rebalance_from_just_inserted(grandparent)
return
if grandparent.left == parent and parent.right == node:
node, parent = parent, self.rotate_left(parent)
elif grandparent.right == parent and parent.left == node:
node, parent = parent, self.rotate_right(parent)
new_grandparent: Node | None = None
if grandparent.left == parent and parent.left == node:
new_grandparent = self.rotate_right(grandparent)
elif grandparent.right == parent and parent.right == node:
new_grandparent = self.rotate_left(grandparent)
if grandparent == self.root and new_grandparent is not None:
self.root = new_grandparent
self.root.is_red = False
grandparent.is_red = True
def rotate_left(self, node: Node) -> Node:
if node.right is None:
return node
original_parent = node.parent
new_parent = node.right
new_right_child = node.right.left
node.right = new_right_child
if new_right_child is not None:
new_right_child.parent = node
new_parent.left = node
node.parent = new_parent
new_parent.parent = original_parent
if original_parent is not None:
if original_parent.left == node:
original_parent.left = new_parent
elif original_parent.right == node:
original_parent.right = new_parent
return new_parent
def rotate_right(self, node: Node) -> Node:
if node.left is None:
return node
original_parent = node.parent
new_parent = node.left
new_left_child = node.left.right
node.left = new_left_child
if new_left_child is not None:
new_left_child.parent = node
new_parent.right = node
node.parent = new_parent
new_parent.parent = original_parent
if original_parent is not None:
if original_parent.left == node:
original_parent.left = new_parent
elif original_parent.right == node:
original_parent.right = new_parent
return new_parent
def visualize(self) -> str:
# renders the tree top-down with the root on top and branches
# ( / and \ ) drawn down to each child. spacing is computed so that
# subtrees never overlap, no matter the shape of the tree.
if self.root is None:
return "<empty tree>"
# render() returns, for the subtree rooted at `node`:
# lines - the block of text drawing the subtree
# width - how many characters wide that block is
# height - how many lines tall that block is
# middle - the column where this node's value is centred (so the
# caller knows where to attach its branch)
def render(node: Node) -> tuple[list[str], int, int, int]:
# label shows the value plus its color: R (red) or B (black)
label = f"{node.value}{'R' if node.is_red else 'B'}"
label_width = len(label)
# leaf: just the value on a single line
if node.left is None and node.right is None:
return [label], label_width, 1, label_width // 2
# only a left child
if node.right is None:
lines, width, height, mid = render(node.left)
first = (mid + 1) * " " + (width - mid - 1) * "_" + label
second = mid * " " + "/" + (width - mid - 1 + label_width) * " "
shifted = [line + label_width * " " for line in lines]
return [first, second] + shifted, width + label_width, height + 2, width + label_width // 2
# only a right child
if node.left is None:
lines, width, height, mid = render(node.right)
first = label + mid * "_" + (width - mid) * " "
second = (label_width + mid) * " " + "\\" + (width - mid - 1) * " "
shifted = [label_width * " " + line for line in lines]
return [first, second] + shifted, width + label_width, height + 2, label_width // 2
# two children: render each side, then place this node between them
left_lines, left_w, left_h, left_mid = render(node.left)
right_lines, right_w, right_h, right_mid = render(node.right)
first = (
(left_mid + 1) * " "
+ (left_w - left_mid - 1) * "_"
+ label
+ right_mid * "_"
+ (right_w - right_mid) * " "
)
second = (
left_mid * " "
+ "/"
+ (left_w - left_mid - 1 + label_width + right_mid) * " "
+ "\\"
+ (right_w - right_mid - 1) * " "
)
# pad the shorter side so the two blocks line up row-for-row
if left_h < right_h:
left_lines += [left_w * " "] * (right_h - left_h)
elif right_h < left_h:
right_lines += [right_w * " "] * (left_h - right_h)
merged = [l + label_width * " " + r for l, r in zip(left_lines, right_lines)]
return (
[first, second] + merged,
left_w + right_w + label_width,
max(left_h, right_h) + 2,
left_w + label_width // 2,
)
lines, _, _, _ = render(self.root)
return "\n".join(lines)
# =============== TEST CASES ===================
#
# These run automatically: python red_black_tree.py
#
# Each case inserts ONE value into a hand-built red-black tree and checks the
# result, printing the tree BEFORE, the ACTUAL tree after your rebalance, and
# the EXPECTED tree. A case passes when the actual tree matches the expected
# tree AND the result is still a valid red-black tree.
#
# HOW TO ADD YOUR OWN CASE:
# Append a Case(...) to the CASES list below with four fields:
# name - short description
# before - a function returning the tree BEFORE the insert (None = empty)
# insert - the value to insert
# expected - a function returning the tree you expect AFTERWARD
# Build trees with node(value, "R" or "B", left=..., right=...). Any child
# you leave out is treated as a (black) nil leaf.
from dataclasses import dataclass
from typing import Callable
def node(value, color, left=None, right=None):
"""Build a Node with an explicit color ("R"/"B"), wiring parent pointers."""
n = Node(value, is_red=(color == "R"))
n.left = left
n.right = right
if left is not None:
left.parent = n
if right is not None:
right.parent = n
return n
def tree(root):
"""Wrap a root Node (or None) in a RedBlackTree."""
t = RedBlackTree()
t.root = root
return t
@dataclass
class Case:
name: str
before: Callable # () -> Node | None
insert: int
expected: Callable # () -> Node | None
CASES = [
Case(
"Empty tree -> black root",
before=lambda: None,
insert=25,
expected=lambda: node(25, "B"),
),
Case(
"Black parent -> new red child, no rebalancing",
before=lambda: node(25, "B"),
insert=15,
expected=lambda: node(25, "B", left=node(15, "R")),
),
Case(
"Duplicate insert -> tree unchanged",
before=lambda: node(25, "B", left=node(15, "R")),
insert=15,
expected=lambda: node(25, "B", left=node(15, "R")),
),
Case(
"Red uncle -> recolor (grandparent is the root)",
before=lambda: node(25, "B", left=node(15, "R"), right=node(35, "R")),
insert=10,
expected=lambda: node(
25, "B",
left=node(15, "B", left=node(10, "R")),
right=node(35, "B"),
),
),
Case(
"LL -> right-rotate the grandparent",
before=lambda: node(30, "B", left=node(20, "R")),
insert=10,
expected=lambda: node(20, "B", left=node(10, "R"), right=node(30, "R")),
),
Case(
"RR -> left-rotate the grandparent",
before=lambda: node(30, "B", right=node(40, "R")),
insert=50,
expected=lambda: node(40, "B", left=node(30, "R"), right=node(50, "R")),
),
Case(
"LR -> left-rotate parent, then right-rotate grandparent",
before=lambda: node(30, "B", left=node(20, "R")),
insert=25,
expected=lambda: node(25, "B", left=node(20, "R"), right=node(30, "R")),
),
Case(
"RL -> right-rotate parent, then left-rotate grandparent",
before=lambda: node(30, "B", right=node(40, "R")),
insert=35,
expected=lambda: node(35, "B", left=node(30, "R"), right=node(40, "R")),
),
Case(
"Cascade: recolor propagates up, then rotate near the root",
before=lambda: node(
11, "B",
left=node(
2, "R",
left=node(1, "B"),
right=node(7, "B", left=node(5, "R"), right=node(8, "R")),
),
right=node(14, "B", right=node(15, "R")),
),
insert=4,
expected=lambda: node(
7, "B",
left=node(
2, "R",
left=node(1, "B"),
right=node(5, "B", left=node(4, "R")),
),
right=node(
11, "R",
left=node(8, "B"),
right=node(14, "B", right=node(15, "R")),
),
),
),
]
def validate_rb(t):
"""Return a list of red-black property violations ([] means valid)."""
problems = []
root = t.root
if root is None:
return problems
if root.is_red:
problems.append("root is red")
if root.parent is not None:
problems.append("root has a non-None parent pointer")
black_heights = set()
seen = set()
def check(n, low, high, black_count):
if n is None:
black_heights.add(black_count + 1) # nil leaves count as black
return
if id(n) in seen:
# a proper tree never reaches the same node twice
problems.append(f"not a tree: node {n.value} reached twice (cycle or shared subtree)")
return
seen.add(id(n))
if low is not None and n.value <= low:
problems.append(f"BST order broken at {n.value}")
if high is not None and n.value >= high:
problems.append(f"BST order broken at {n.value}")
if n.is_red and ((n.left and n.left.is_red) or (n.right and n.right.is_red)):
problems.append(f"red node {n.value} has a red child")
if n.left is not None and n.left.parent is not n:
problems.append(f"broken parent pointer: left child {n.left.value} does not point back to {n.value}")
if n.right is not None and n.right.parent is not n:
problems.append(f"broken parent pointer: right child {n.right.value} does not point back to {n.value}")
nb = black_count + (0 if n.is_red else 1)
check(n.left, low, n.value, nb)
check(n.right, n.value, high, nb)
check(root, None, None, 0)
if len(black_heights) > 1:
problems.append(f"unequal black-heights on paths to nil: {sorted(black_heights)}")
return problems
def _indent(text: str) -> str:
return "\n".join(" " + line for line in text.split("\n"))
def run_tests():
passed = 0
for i, case in enumerate(CASES, 1):
print("=" * 64)
print(f"CASE {i}: {case.name}")
print("=" * 64)
t = tree(case.before())
print("BEFORE:")
print(_indent(t.visualize()))
print(f"\n insert({case.insert})\n")
# Everything the student's code can affect is inside this guard, so a
# buggy rebalance (even one that builds a cyclic/broken tree) fails just
# this case instead of aborting the whole suite.
try:
t.insert(case.insert)
actual = t.visualize()
expected_tree = tree(case.expected())
expected = expected_tree.visualize()
violations = validate_rb(t)
expected_problems = validate_rb(expected_tree)
except RecursionError:
print(" RESULT: ERROR - hit maximum recursion depth.")
print(" Your rebalance likely created a cycle or otherwise broke the")
print(" tree structure (a child pointing back up at an ancestor).\n")
continue
except Exception:
print(f" RESULT: ERROR - see stack trace below\n")
print(traceback.format_exc())
print("")
continue
# Guard the author (you) against a typo when adding a new case: the
# 'expected' tree should itself be a valid red-black tree.
if expected_problems:
print(" RESULT: BAD TEST - the 'expected' tree is not a valid red-black tree:")
for p in expected_problems:
print(f" - {p}")
print()
continue
print("ACTUAL (what your code produced):")
print(_indent(actual))
print("\nEXPECTED:")
print(_indent(expected))
print()
if actual == expected and not violations:
print(" RESULT: PASS")
passed += 1
else:
print(" RESULT: FAIL")
if actual != expected:
print(" - actual tree does not match the expected tree")
for v in violations:
print(f" - red-black property broken: {v}")
print()
print("=" * 64)
print(f"{passed}/{len(CASES)} cases passed")
print("=" * 64)
if __name__ == "__main__":
run_tests()

Binary file not shown.

After

Width:  |  Height:  |  Size: 571 KiB

View File

@@ -0,0 +1,10 @@
## Outline
Review the algorithm for a red-black tree rebalancing.
Next week, we will begin an introduction of graphs.
## Assignment
See the `red_black_tree` assignment from last week 2026.07.01.

View File

@@ -0,0 +1,70 @@
## Fundamentals
- A *node* or *vertex* contains a data point
- An *edge* is what connects one node to another
A graph is just a collection of vertices and edges.
> [!QUESTION] What is a real-life example of a simple graph with only vertices and edges?
Some additional properties we can put on a graph:
- A *weight* is a numerical value that can be assigned to an edge
- We can also assign a *direction* to an edge, such that it only points from one node to another, not the other way around
We can, of course, combine both of these properties too.
> [!QUESTION] What's something we can model with weights in a graph? What about with directional edges?
Going forward, we'll use $V$ to represent the number of vertices in a graph, and $E$ to represent the number of edges.
## Representing a graph
The *adjacency list* stores a list of connected vertices for each node, and it can fit in a dictionary or hash map structure.
```
1 -> 2, 4
2 -> 1, 3
3 -> 2
4 -> 1
```
> [!QUESTION] How would you draw out this graph?
> [!QUESTION] What would this mapping look like if we wanted to add weights? What about directional edges?
The *adjacency matrix* is a 2D array where each position `arr[x][y]` represents an edge, and `x` and `y` each represent a node.
```
[
[0, 1, 0, 1],
[1, 0, 1, 0],
[0, 1, 0, 0],
[1, 0, 0, 0]
]
```
> [!QUESTION] How would you draw out this graph? What would it look like as an adjacency list?
> [!QUESTION] How do we add weights and/or directional edges to this graph?
## Categorizing graphs
We say a graph is *directed and acyclic*, or a *directed acyclic graph (DAG)*, if there are no cycles formed using the directional edges.
> [!QUESTION] What's something we can model with a DAG?
> [!QUESTION] What's another data structure we went over that also classifies as a DAG?
A graph is *dense* if $E$ is closer to $V^2$, and *sparse* if $E$ is closer to $V$.
> [!QUESTION] What's the implication for the graph if $E$ is closer to $V^2$, i.e. how is it different compared to a sparse graph?
> [!QUESTION] What's the maximum number of edges we can have in a graph with no self-loops, relative to the number of vertices $V$?
A graph can contain *self-loops* (a loop from a vertex to itself). A graph can also have *multiple edges* going from one vertex to another.
> [!QUESTION] How do we represent a self-loop using an adjacency matrix?
Finally, a graph is *connected* if every vertex is reachable from every other vertex.

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 509 KiB

View File

@@ -0,0 +1,18 @@
## Outline
- Go over the homework
- Go over the basics of graphs, see [Introduction to graphs](./graphs-intro.md)
![Graphs introduction whiteboard](./graphs.png)
To open the `.excalidraw` file:
- Launch https://excalidraw.com
- Click the top left menu, then select "Open"
- Select the `.excalidraw` file from the file picker
## Assignment
- Try to complete the red-black implementation from last week
- Complete the exercise for the 2D array (see the picture)

View File

@@ -0,0 +1,13 @@
## Breadth-first search (BFS)
In the notes on [depth-first search](./depth-first-search.md), we mention that the difference between DFS and BFS is the order in which nodes are traversed.
Using the same example, what would a breadth-first traversal look like if we start at vertex 0 in this graph?
```
0 --- 1
| |
3 --- 2
```
Can we also formalize an algorithm for breadth-first search?

View File

@@ -0,0 +1,46 @@
## Traversal introduction
Many algorithms that involve graphs must involve some way to traverse the elements of a graph. The two simplest ways of traversal are depth-first search (DFS) and [breadth-first search (BFS)](https://en.wikipedia.org/wiki/Breadth-first_search). The major difference here is the *order* in which nodes are traversed.
If we start from vertex 0 in the tree, in what order would you expect depth-first search to traverse the nodes? (There are multiple correct answers!)
```
0
/ \
1 2
/ \
3 4
```
Note that a single traversal step checks for already-visited nodes. So, if the path is `0 -> 1 -> 3`, the path cannot become `0 -> 1 -> 3 -> 1`.
What about starting from vertex 0 in this graph?
```
0 --- 1
| |
3 --- 2
```
What about this one?
```
0
/ \
1 5
/ \ \
2 3 6
\ / /
4 7
\ /
8
```
## Formalizing the algorithm
Based on these examples, can we create a formal algorithm that takes a starting node, producing a valid traversal path for all three graphs?
Some starter questions:
- Could recursion help us here?
- What are some ways we can track already-visited nodes? What's the most *time-efficient* way to do so?

View File

@@ -0,0 +1,25 @@
# Adjacency list
first_graph = {
1: [3, 20],
2: [20],
3: [1, 20],
20: [2, 3]
}
# First exercise: go from 1, to 20, to 2
first_graph[first_graph[1][1]][0]
# pass visited by reference
def depth_first_search(graph: dict, start, visited=set()):
print(start)
if len(visited)==len(graph):
return visited
for x in graph[start]:
if x not in visited:
visited.add(x)
return depth_first_search(graph, x, visited)
pass
depth_first_search(first_graph, 1)

View File

@@ -0,0 +1,11 @@
## Outline
To make our knowledge of graphs useful, we'll go over our first traversal method today: [depth-first search](./depth-first-search.md). We will also go over [breadth-first search](./breadth-first-search.md), and how both algorithms can be useful.
Assignment: write functions for depth-first search and breadth-first search. Because there is no starter file, the constraints are listed below:
- You may use either an adjacency list or adjacency matrix to represent your graph
- You should demonstrate that your algorithm works by running it through some test cases
- For each test case, specify the graph, starting point, and expected output(s)
Bonus: can you output *all* the valid paths for both DFS and BFS in a particular case?