An variation of the union-find algorithm/data structure where instead of keeping track of just disjoint sets, we keep track of disjoint dicts -- keep track of multiple Set[Key] -> Set[Value] mappings, where each mapping's keys are guaranteed to be disjoint. This data structure is curren
| 9313 | |
| 9314 | |
| 9315 | class DisjointDict(Generic[TKey, TValue]): |
| 9316 | """An variation of the union-find algorithm/data structure where instead of keeping |
| 9317 | track of just disjoint sets, we keep track of disjoint dicts -- keep track of multiple |
| 9318 | Set[Key] -> Set[Value] mappings, where each mapping's keys are guaranteed to be disjoint. |
| 9319 | |
| 9320 | This data structure is currently used exclusively by 'group_comparison_operands' below |
| 9321 | to merge chains of '==' and 'is' comparisons when two or more chains use the same expression |
| 9322 | in best-case O(n), where n is the number of operands. |
| 9323 | |
| 9324 | Specifically, the `add_mapping()` function and `items()` functions will take on average |
| 9325 | O(k + v) and O(n) respectively, where k and v are the number of keys and values we're adding |
| 9326 | for a given chain. Note that k <= n and v <= n. |
| 9327 | |
| 9328 | We hit these average/best-case scenarios for most user code: e.g. when the user has just |
| 9329 | a single chain like 'a == b == c == d == ...' or multiple disjoint chains like |
| 9330 | 'a==b < c==d < e==f < ...'. (Note that a naive iterative merging would be O(n^2) for |
| 9331 | the latter case). |
| 9332 | |
| 9333 | In comparison, this data structure will make 'group_comparison_operands' have a worst-case |
| 9334 | runtime of O(n*log(n)): 'add_mapping()' and 'items()' are worst-case O(k*log(n) + v) and |
| 9335 | O(k*log(n)) respectively. This happens only in the rare case where the user keeps repeatedly |
| 9336 | making disjoint mappings before merging them in a way that persistently dodges the path |
| 9337 | compression optimization in '_lookup_root_id', which would end up constructing a single |
| 9338 | tree of height log_2(n). This makes root lookups no longer amoritized constant time when we |
| 9339 | finally call 'items()'. |
| 9340 | """ |
| 9341 | |
| 9342 | def __init__(self) -> None: |
| 9343 | # Each key maps to a unique ID |
| 9344 | self._key_to_id: dict[TKey, int] = {} |
| 9345 | |
| 9346 | # Each id points to the parent id, forming a forest of upwards-pointing trees. If the |
| 9347 | # current id already is the root, it points to itself. We gradually flatten these trees |
| 9348 | # as we perform root lookups: eventually all nodes point directly to its root. |
| 9349 | self._id_to_parent_id: dict[int, int] = {} |
| 9350 | |
| 9351 | # Each root id in turn maps to the set of values. |
| 9352 | self._root_id_to_values: dict[int, set[TValue]] = {} |
| 9353 | |
| 9354 | def add_mapping(self, keys: set[TKey], values: set[TValue]) -> None: |
| 9355 | """Adds a 'Set[TKey] -> Set[TValue]' mapping. If there already exists a mapping |
| 9356 | containing one or more of the given keys, we merge the input mapping with the old one. |
| 9357 | |
| 9358 | Note that the given set of keys must be non-empty -- otherwise, nothing happens. |
| 9359 | """ |
| 9360 | if not keys: |
| 9361 | return |
| 9362 | |
| 9363 | subtree_roots = [self._lookup_or_make_root_id(key) for key in keys] |
| 9364 | new_root = subtree_roots[0] |
| 9365 | |
| 9366 | root_values = self._root_id_to_values[new_root] |
| 9367 | root_values.update(values) |
| 9368 | for subtree_root in subtree_roots[1:]: |
| 9369 | if subtree_root == new_root or subtree_root not in self._root_id_to_values: |
| 9370 | continue |
| 9371 | self._id_to_parent_id[subtree_root] = new_root |
| 9372 | root_values.update(self._root_id_to_values.pop(subtree_root)) |
no outgoing calls
searching dependent graphs…