DSA House Robber III
House Robber III places houses on a binary tree, where robbing one house blocks its parent and its children. Each node answers with two numbers, the best haul when it is robbed and the best when it is skipped.
The problem
Houses stand at the nodes of a binary tree. Each house holds a value. Robbing a house blocks its parent and its children, because neighbouring houses are linked by alarms.
No two robbed houses may be neighbours in the tree. The answer is the largest total that one night of robbing can take. Houses hold non-negative values in this article.
The cases used in this article
class TreeNode:
def __init__(self, value, left=None, right=None):
self.value = value
self.left = left
self.right = right
tree_a = TreeNode(3,
TreeNode(2, None, TreeNode(3)),
TreeNode(3, None, TreeNode(1)))
tree_b = TreeNode(3,
TreeNode(4, TreeNode(1), TreeNode(3)),
TreeNode(5, None, TreeNode(1)))
tree_c = TreeNode(1, TreeNode(100), TreeNode(2, None, TreeNode(100)))
for name, expected in [("Tree A", 7), ("Tree B", 9), ("Tree C", 200), ("single house 5", 5)]:
print(name, "-> best haul:", expected)Tree C is the teaching case. Its root is worth almost nothing, and its two rich leaves sit in different branches. The best night robs both leaves and skips everything between them.
Intuition: rob level by level
A first idea groups houses by depth. Whole levels are robbed or skipped as blocks, alternating down the tree. Two totals compete, the even levels and the odd levels.
Level totals on Tree C
class TreeNode:
def __init__(self, value, left=None, right=None):
self.value = value
self.left = left
self.right = right
tree = TreeNode(1, TreeNode(100), TreeNode(2, None, TreeNode(100)))
levels = []
level = [tree]
while level:
levels.append(sum(node.value for node in level))
nxt = []
for node in level:
if node.left is not None:
nxt.append(node.left)
if node.right is not None:
nxt.append(node.right)
level = nxt
even = sum(levels[0::2])
odd = sum(levels[1::2])
print("level sums:", levels)
print("even levels total:", even, " odd levels total:", odd)
print("the level method answers:", max(even, odd))
print("the true best haul is 200")The level method answers 102, and the true best is 200. The winning night mixes levels inside different branches. It robs the left leaf at level 1 and the right leaf at level 2, a mix no whole-level choice can express.
Brute force: try every subset of houses
The brute force lists every subset of the houses. A subset is legal when no house in it has its parent also in it. The best legal total is the answer.
A counter records the subsets examined. The count doubles with each added house, which is the cost this method cannot escape.
All subsets on two trees
class TreeNode:
def __init__(self, value, left=None, right=None):
self.value = value
self.left = left
self.right = right
def brute(root):
nodes = []
def collect(node, parent):
if node is None:
return
index = len(nodes)
nodes.append((node.value, parent))
collect(node.left, index)
collect(node.right, index)
collect(root, -1)
best = 0
examined = 0
for mask in range(2 ** len(nodes)):
examined += 1
total = 0
legal = True
for i, (value, parent) in enumerate(nodes):
if mask >> i & 1:
if parent >= 0 and mask >> parent & 1:
legal = False
break
total += value
if legal and total > best:
best = total
return best, examined
tree_a = TreeNode(3,
TreeNode(2, None, TreeNode(3)),
TreeNode(3, None, TreeNode(1)))
tree_c = TreeNode(1, TreeNode(100), TreeNode(2, None, TreeNode(100)))
for name, tree in [("Tree A", tree_a), ("Tree C", tree_c)]:
best, examined = brute(tree)
print(name, "-> best haul:", best, "subsets examined:", examined)Both answers match the cases, 7 and 200. Tree C has only four houses and already needs sixteen subsets. Twenty houses would need over a million.
The optimal solution: two numbers returned per node
The state at each node is a pair of numbers. The first is the best haul from that node's subtree when the node is robbed, and the second is the best haul when the node is skipped.
Robbing a node forces both children into their skipped numbers. Skipping a node lets each child choose its own better number. An absent child answers the pair (0, 0), since an empty subtree holds nothing either way.
The pair walk on every case
class TreeNode:
def __init__(self, value, left=None, right=None):
self.value = value
self.left = left
self.right = right
def rob_pair(node):
if node is None:
return (0, 0)
left = rob_pair(node.left)
right = rob_pair(node.right)
robbed = node.value + left[1] + right[1]
skipped = max(left) + max(right)
return (robbed, skipped)
tree_a = TreeNode(3,
TreeNode(2, None, TreeNode(3)),
TreeNode(3, None, TreeNode(1)))
tree_b = TreeNode(3,
TreeNode(4, TreeNode(1), TreeNode(3)),
TreeNode(5, None, TreeNode(1)))
tree_c = TreeNode(1, TreeNode(100), TreeNode(2, None, TreeNode(100)))
for name, tree, expected in [("Tree A", tree_a, 7), ("Tree B", tree_b, 9), ("Tree C", tree_c, 200), ("single house 5", TreeNode(5), 5)]:
pair = rob_pair(tree)
answer = max(pair)
print(name, "root pair (robbed, skipped):", pair, "-> best haul:", answer, "match:", answer == expected)All four cases match. Tree A does best by robbing its root, and Tree B does best by skipping its root. The pair keeps both options open until the root makes the final choice.
Walkthrough: Tree C from the leaves up
The walk computes Tree C bottom-up, naming each node. The leaves answer first. Their pairs flow into their parents, and the root's pair is read last.
Each node's pair, in return order
class NamedNode:
def __init__(self, name, value, left=None, right=None):
self.name = name
self.value = value
self.left = left
self.right = right
def rob_named(node):
if node is None:
return (0, 0)
left = rob_named(node.left)
right = rob_named(node.right)
robbed = node.value + left[1] + right[1]
skipped = max(left) + max(right)
print("node", node.name, "- pair (robbed, skipped):", (robbed, skipped))
return (robbed, skipped)
tree = NamedNode("root, value 1", 1,
NamedNode("left leaf", 100),
NamedNode("right child, value 2", 2, None, NamedNode("right leaf", 100)))
pair = rob_named(tree)
print("best haul:", max(pair))The root's robbed number is 101, its own 1 plus the two skipped numbers below it. Its skipped number is 200, the two leaves taken through different branches. Skipping the root wins by a wide margin.
Complexity
The pair walk visits each node once and does fixed work per node, so its time is O(n). The recursion stack grows with the height of the tree, so the extra space is O(height).
The brute force examines 2 to the power n subsets. The counter below runs it on chains of houses, where the subset count is easy to read against the visit count of the pair walk.
Counting the work at three sizes
class TreeNode:
def __init__(self, value, left=None, right=None):
self.value = value
self.left = left
self.right = right
def build_chain(values):
root = None
for value in reversed(values):
root = TreeNode(value, None, root)
return root
def brute_subsets(root):
nodes = []
def collect(node, parent):
if node is None:
return
index = len(nodes)
nodes.append((node.value, parent))
collect(node.left, index)
collect(node.right, index)
collect(root, -1)
examined = 0
for mask in range(2 ** len(nodes)):
examined += 1
return examined
def pair_visits(node, counter):
if node is None:
return (0, 0)
counter[0] += 1
left = pair_visits(node.left, counter)
right = pair_visits(node.right, counter)
return (node.value + left[1] + right[1], max(left) + max(right))
for n in [5, 10, 15]:
tree = build_chain([1] * n)
counter = [0]
pair_visits(tree, counter)
print("houses", n, "- brute subsets:", brute_subsets(tree), " pair visits:", counter[0])At 15 houses the subset count is 32,768 against 15 visits. Doubling backtracking is replaced by one bottom-up pass. Each node's pair summarises its whole subtree, so no choice is ever re-examined.
Edge cases and pitfalls
An empty tree holds nothing and answers 0. A single house answers its own value. A chain behaves like the street version of the problem, alternating rob and skip down its length.
Edge cases, run
class TreeNode:
def __init__(self, value, left=None, right=None):
self.value = value
self.left = left
self.right = right
def rob_pair(node):
if node is None:
return (0, 0)
left = rob_pair(node.left)
right = rob_pair(node.right)
robbed = node.value + left[1] + right[1]
skipped = max(left) + max(right)
return (robbed, skipped)
print("empty tree pair:", rob_pair(None), "best:", max(rob_pair(None)))
print("single house:", rob_pair(TreeNode(5)))
chain = TreeNode(3, None, TreeNode(2, None, TreeNode(3)))
print("chain 3-2-3 root pair:", rob_pair(chain), "best:", max(rob_pair(chain)))
print("root skipped wins:", rob_pair(TreeNode(3, TreeNode(2), TreeNode(4))))
print("root robbed wins:", rob_pair(TreeNode(10, TreeNode(1), TreeNode(1))))The chain row robs the root and the grandchild for 6. The pitfall in the pair formulas is swapping the two child numbers. Robbing takes the children's skipped numbers, never their better numbers, or neighbours end up robbed together.
Exercise
A node has value 5. Its left child pair is (6, 4) and its right child pair is (2, 7), each written as (robbed, skipped). What is this node's pair?