Title: How can you quickly find all the nodes connected to a subset of a bipartite graph?
Tags: python,algorithm,math,graph-theory

Question: Given a set of left nodes and right nodes, and edges between them (a bipartite graph), I need to support quick lookups from sets of left nodes to the subset of right nodes connected to anything on the left.
That's easy if the graph is very sparse or dense. Below is a sample adjacency-list implementation that would support fast connected_endpoints() calls on sparse graphs. For middling amounts of connectivity though, the intermediate computations look like O(len(input) * len(result)) despite the size of the data involved suggesting there might be an O(len(input) + len(result)) solution.
Does there exist a data structure supporting these 3 operations (or something similar) relatively quickly -- maybe O(1) for add/remove and O(in+out) for the connected edges search, give or take polylogarithmic factors?
from typing import *
from collections import defaultdict

A = TypeVar('A')
B = TypeVar('B')

class Graph(Generic[A, B]):
    def __init__(self):
        self.edges = defaultdict(set)
    
    def set_edge(self, start: A, end: B):
        """Desired: Amortized O(1)"""
        self.edges[start].add(end)
    
    def unset_edge(self, start: A, end: B):
        """Desired: Amortized O(1)"""
        s = self.edges[start]
        s.discard(end)
        if not s:
            self.edges.pop(start, None)

    def connected_endpoints(self, start: Set[A]) -> Set[B]:
        """Desired: Amortized O(len(start) + len(<return>))"""
        empty = set()
        if not start:
            return empty
        return set.union(*(self.edges.get(node, empty) for node in start))

Answer: Since you are interested in both asymptotic time complexity as well as actual runtime performance, I'll present both: speedups that don't affect the time complexity, as well as an algorithmic speedup to querying. Note that while the query complexity drops, the insert/delete operations become much more costly- more on that later.
Runtime Speedups
Invert Sets. If any of the adjacency sets in your list grow larger than V/2, invert it to list the vertices not connected to the vertex. Unlike the normal adjacency sets that you union together, these inverted sets you'll want to take the intersection, to get the vertices that they all lack an edge to. Finally, invert again (subtract from your universal set), and union with the rest of your sets. This will result in a large speedup for dense graphs, not only because the inverted sets will be smaller, but because each successive intersection (potentially) shrinks the working set even smaller. This will not affect the time complexity.
Binary Search. Building on the last idea, if you use an ordered map, you can search for "the next element greater than X", for some X. If X is an element you know to be in your adjacency sets that you've already unioned so far, but its followed (according to whatever ordering you use) by a bunch of vertices that are not present, you can quickly check a batch of them at once.  To make it more concrete, say 4 is connected vertex, but I haven't seen 5, 6, or 7 yet. I quick query for the smallest element larger than 4, and either immediately get a new, distinct vertex to add to my working set, or can rule all three out at once. For large "runs" of unseen vertices, this can be faster than a linear scan, as it only incurs a O(log(V)) overhead. This will not improve the time complexity.
Just Union. For small adjacency sets, nothing you do will be faster than just unioning them with either each other or your working set. For sets smaller than some threshold, remove those sets from the equation, do whatever fancy algorithm you choose, then combine the result with the union of those sets.
Honorable Mention
The first is a failed half-idea, but I want to share just in case someone has an idea to make it work.
Subset Tree. Lets say you have a fast way of checking if a set B is a subset of set A. You're allowed to preprocess B. You don't have to give me details, just a yes/no answer, and you're even allowed to be wrong sometimes, as long as there's no false positives (infrequent false negatives are okay). If you can produce an algorithm that can do this in sublinear time, we can likely produce a subquadratic time algorithm for the original problem. Just take each set, and represent it in a binary tree, where each node is a set, the root is the adjacency set itself, and the children of each node are the left/right half of the set (partition it into 2x almost-equal halves).
We can then find the new/novel elements contributed by each adjacency list in time O(P*log(R)) where P is the number of new/novel elements, and R is total number of elements in that adjacency set. We just query the topmost node, asking whether its a subset of our current working set. If it is not, then it has a vertex that we don't yet, so recurse on the left and right child. If it is a subset, then stop. If/when you hit a leaf, and the leaf is not a subset of the working set, it corresponds to the novel vertex itself, and we add it to our working set. Given that there's only V vertices, if the subset query took time Q (again, ideally sublinear), this would take O(Q*V*log(V)) time overall to query matching vertices.
Algorithmic Speedup
Alright, now we're into the meat of it. This speedup, just like the one mentioned in the previous section, is focused on reducing the redundant comparisons or checks while performing the union of the adjacency sets. Imagine initially that we're given a per-existing bipartite graph. I'll address insert/delete operations after we talk about the core algorithm.
The trick we're going to use here is to frame them as strings. Take your vertices, assign a unique number to them, and use this to sort then in linear time via counting sort. Now, convert vertex to a single unique token, and form a string. We converted the adjacency sets to adjacency strings. Then, build a generalized suffix tree from these strings. This is done in linear time with respect to the combined sizes of the strings, which amounts to O(V^2). Also preprocess the suffix tree for lowest common ancestor (LCA) queries, this will take O(V^2) time, but means we can query LCA(X, Y) in O(1) time going forward. This often boils down to reducing the problem to range min query (RMQ), but there's other options as well. Also map each string to each of its suffixes in the tree, and also to its depth-first-search index (you'll likely already compute this for the LCA precomputation).
Now, say we're given a subset of adjacency strings (or the vertices they correspond to anyways), and want to know what vertices they connect to. Create a map from tokens to lists of strings. Iterate over each string, and add the string to the token corresponding to its first token (so "ABC" gets appended to the list for the "A" token). Starting with the first nonempty token's string list (ordered by tokens), get the associated list of strings (e.g. all strings starting with "A"). Order these strings- you can do this in linear time w.r.t. the number of strings by counting sort by its DFS-index. Next, do pairwise LCA computations on each adjacent pair of strings. What this tells us (in constant time) is the number of common vertices these two adjacency strings share in their prefix, allowing us to "skip ahead". For each of these LCA computations, advance the second string in the pair to the token following that returned by the LCA computation by adding its corresponding suffix to that token's list (e.g. LCA("ABCD", "ABXY") returns "B", so "ABXY"'s suffix is "XY", which we append to the string list for "X"). Do this for all the pairs. As a special case, for the very first pair, for its first string, advance that string to the next token in said string, adding the string to said token's string list (e.g. if the first pair is LCA("EFG", ...), add "FG" to the "F" token's string list). Just a reminder, each string should take constant time to process here.
After that is done, add that first token (in our earlier example, "A") to your result set, and move onto the next token + string list, repeating the process on these new strings/suffixes. The process repeats until the strings are fully consumed. At that point, the result set will contain every vertex encountered. While we do a constant amount of work for instance of a repeated maximal-substring, we only do work proportional to its number of tokens for one of those strings. The others advance/skip over the common prefix ("substring" really, but its a prefix at that point in time during processing).
So the question becomes, what's the worst case adjacency strings, which maximize the number of non-overlapping substrings. This appears to be a skipping pattern: "ABCDEF", "A C E ", " B D F", "A  D  ", " B  E ", "  C  F", etc. How many tokens will this have? Well, it will have: V + 2*V/2 + 3*V/3 + ..., or N*V. But in this case, the number of adjacency lists is V as well, and V is 1 + 2 + 3 + ... = N*(N+1)/2. So, our time complexity is O(V^(3/2)) for queries.
Now, what about insert/delete? We can't just rebuild the entire suffix tree + associated data (LCA, indices, etc.) each time we want to add or remove an edge. Well, I believe we can actually mutate this structure. We should be able to remove the suffixes associated with with the updated adjacency string in linear time w.r.t. the length of the string. I say should because I haven't had time to code it up to verify, but I'm pretty certain of the fact. After that string is removed, we can update the string to add/remove a token associated with the edge, and then re-insert that string into our generalized suffix array. An algorithm like Ukkonen's algorithm (an online algorithm) will handle this with zero modifications.
As for the LCA array and the DFS-indices... well that's a bit more difficult. I believe that its probably doable. The DFS-indices are just a hack to let us order the strings, so we can probably get around that another way, such as maintaining a BST of suffixes that we index into and just take the O(log(V)) factor hit. For the LCA, you can likely modify the algorithm used in "Algorithms on Strings, Trees, and Sequences" Gusfield 1997, chapter 8 "Constant-Time Lowest Common Ancestor Retrieval". I haven't had the time to adapt that algorithm to support online updates, but again I truly believe that its possible. If that's done, you should be able to support O(V) time insertion / deletion. If you wish to add multiple edges from the same vertex, you can batch these inserts / deletes, and have the same O(V). This does mean that if you're making enough updates to the same select few vertices, the ammortized cost of insert/delete may be sublinear, perhaps even O(1). In the general case however, its O(V) insert/delete, and O(V^(3/2)) query time.