···11+import fileinput
22+from collections import deque, defaultdict
33+from functools import reduce
44+from itertools import permutations
55+from concurrent.futures import ProcessPoolExecutor
66+77+from utils import parse_line, memoize
88+99+from more_itertools import set_partitions
1010+1111+1212+GRAPH = defaultdict(set)
1313+FLOW = {}
1414+1515+# Parse input.
1616+for line in fileinput.input():
1717+ valve, rate, tunnels = parse_line(r"Valve (\w+) has flow rate=(\d+); tunnels? leads? to valves? (.+)", line)
1818+1919+ for tunnel in tunnels.split(", "):
2020+ GRAPH[valve].add(tunnel)
2121+2222+ FLOW[valve] = rate
2323+2424+# Construct compressed graph
2525+def bfs(start, end):
2626+ seen = set()
2727+ horizon = deque([(start, 0)])
2828+ while horizon:
2929+ curr, dist = horizon.popleft()
3030+ if curr in seen:
3131+ continue
3232+3333+ if curr == end:
3434+ return dist
3535+3636+ seen.add(curr)
3737+3838+ for n in GRAPH[curr]:
3939+ horizon.append((n, dist + 1))
4040+4141+4242+# Solve part 1.
4343+START = "AA"
4444+START_TO_REAL = {}
4545+REAL = [v for v in GRAPH if FLOW[v] > 0]
4646+COMPRESSED = defaultdict(set)
4747+4848+for valve in REAL:
4949+ START_TO_REAL[valve] = bfs(START, valve)
5050+5151+for a, b in permutations(REAL, 2):
5252+ dist = bfs(a, b)
5353+ COMPRESSED[a].add((b, dist))
5454+ COMPRESSED[b].add((a, dist))
5555+5656+5757+@memoize
5858+def search(nodes, max_time):
5959+ best = 0
6060+ seen = {}
6161+ horizon = deque()
6262+6363+ # Recompute compressed graph based on limited nodes available.
6464+ compressed = {
6565+ a: [(n, d) for n, d in b if n in nodes]
6666+ for a, b in COMPRESSED.items() if a in nodes
6767+ }
6868+6969+ # Seed start positions with time and pressure based on the time it takes
7070+ # to get from AA to that valve, + 1 minute to open the valve.
7171+ for node in nodes:
7272+ start_time = START_TO_REAL[node] + 1
7373+ start_pressure = (max_time - start_time) * FLOW[node]
7474+ state = (start_time, node, start_pressure, frozenset([node]))
7575+ horizon.append(state)
7676+7777+ while horizon:
7878+ time, curr, pressure, opened = horizon.popleft()
7979+ key = (curr, time, opened)
8080+8181+ if seen.get(key, -1) > pressure:
8282+ continue
8383+8484+ seen[key] = pressure
8585+8686+ if time >= max_time:
8787+ continue
8888+ elif len(opened) >= len(nodes):
8989+ continue
9090+9191+ if pressure > best:
9292+ best = pressure
9393+9494+ # move
9595+ for n, dist in compressed[curr]:
9696+ if n not in opened:
9797+ new_time = time + dist + 1
9898+ extra_pressure = (max_time - new_time) * FLOW[n]
9999+ new_opened = frozenset([n]) | opened
100100+ new_state = (new_time, n, pressure + extra_pressure, new_opened)
101101+102102+ horizon.append(new_state)
103103+104104+ return best
105105+106106+print("Part 1:", search(COMPRESSED, 30))
107107+108108+109109+# Solve part 2.
110110+def dual_search(partition):
111111+ a, b = partition
112112+ return search(frozenset(a), 26) + search(frozenset(b), 26)
113113+114114+115115+part_2 = 0
116116+partitions = list(set_partitions(REAL, 2))
117117+118118+# This is not the optimal solution yet. If it were, I wouldn't be parallelizing.
119119+parallel = True
120120+121121+if parallel:
122122+ with ProcessPoolExecutor() as pool:
123123+ try:
124124+ results = pool.map(dual_search, partitions)
125125+ except:
126126+ pass
127127+ part_2 = reduce(max, results)
128128+129129+else:
130130+ # Render loading bar if tqdm installed.
131131+ try:
132132+ from tqdm import tqdm as loading
133133+ except ImportError:
134134+ loading = lambda x, total: x
135135+136136+ for p in loading(set_partitions(REAL, 2), total=len(partitions)):
137137+ part_2 = max(part_2, dual_search(p))
138138+139139+print("Part 2:", part_2)
140140+