chiark / gitweb /
New centralised loop-finder, using Tarjan's algorithm.
authorSimon Tatham <anakin@pobox.com>
Wed, 24 Feb 2016 18:57:03 +0000 (18:57 +0000)
committerSimon Tatham <anakin@pobox.com>
Wed, 24 Feb 2016 18:57:03 +0000 (18:57 +0000)
In the course of another recent project I had occasion to read up on
Tarjan's bridge-finding algorithm. This analyses an arbitrary graph
and finds 'bridges', i.e. edges whose removal would increase the
number of connected components. This is precisely the dual problem to
error-highlighting loops in games like Slant that don't permit them,
because an edge is part of some loop if and only if it is not a
bridge.

Having understood Tarjan's algorithm, it seemed like a good idea to
actually implement it for use in these puzzles, because we've got a
long and dishonourable history of messing up the loop detection in
assorted ways and I thought it would be nice to have an actually
reliable approach without any lurking time bombs. (That history is
chronicled in a long comment at the bottom of the new source file, if
anyone is interested.)

So, findloop.c is a new piece of reusable library code. You run it
over a graph, which you provide in the form of a vertex count and a
callback function to iterate over the neighbours of each vertex, and
it fills in a data structure which you can then query to find out
whether any given edge is part of a loop in the graph or not.

findloop.c [new file with mode: 0644]
puzzles.h

diff --git a/findloop.c b/findloop.c
new file mode 100644 (file)
index 0000000..e6b2654
--- /dev/null
@@ -0,0 +1,500 @@
+/*
+ * Routine for finding loops in graphs, reusable across multiple
+ * puzzles.
+ *
+ * The strategy is Tarjan's bridge-finding algorithm, which is
+ * designed to list all edges whose removal would disconnect a
+ * previously connected component of the graph. We're interested in
+ * exactly the reverse - edges that are part of a loop in the graph
+ * are precisely those which _wouldn't_ disconnect anything if removed
+ * (individually) - but of course flipping the sense of the output is
+ * easy.
+ */
+
+#include "puzzles.h"
+
+struct findloopstate {
+    int parent, child, sibling, visited;
+    int index, minindex, maxindex;
+    int minreachable, maxreachable;
+    int bridge;
+};
+
+struct findloopstate *findloop_new_state(int nvertices)
+{
+    /*
+     * Allocate a findloopstate structure for each vertex, and one
+     * extra one at the end which will be the overall root of a
+     * 'super-tree', which links the whole graph together to make it
+     * as easy as possible to iterate over all the connected
+     * components.
+     */
+    return snewn(nvertices + 1, struct findloopstate);
+}
+
+void findloop_free_state(struct findloopstate *state)
+{
+    sfree(state);
+}
+
+int findloop_is_loop_edge(struct findloopstate *pv, int u, int v)
+{
+    /*
+     * Since the algorithm is intended for finding bridges, and a
+     * bridge must be part of any spanning tree, it follows that there
+     * is at most one bridge per vertex.
+     *
+     * Furthermore, by finding a _rooted_ spanning tree (so that each
+     * bridge is a parent->child link), you can find an injection from
+     * bridges to vertices (namely, map each bridge to the vertex at
+     * its child end).
+     *
+     * So if the u-v edge is a bridge, then either v was u's parent
+     * when the algorithm ran and we set pv[u].bridge = v, or vice
+     * versa.
+     */
+    return !(pv[u].bridge == v || pv[v].bridge == u);
+}
+
+int findloop_run(struct findloopstate *pv, int nvertices,
+                 neighbour_fn_t neighbour, void *ctx)
+{
+    int u, v, w, root, index;
+    int nbridges, nedges;
+
+    root = nvertices;
+
+    /*
+     * First pass: organise the graph into a rooted spanning forest.
+     * That is, a tree structure with a clear up/down orientation -
+     * every node has exactly one parent (which may be 'root') and
+     * zero or more children, and every parent-child link corresponds
+     * to a graph edge.
+     *
+     * (A side effect of this is to find all the connected components,
+     * which of course we could do less confusingly with a dsf - but
+     * then we'd have to do that *and* build the tree, so it's less
+     * effort to do it all at once.)
+     */
+    for (v = 0; v <= nvertices; v++) {
+        pv[v].parent = root;
+        pv[v].child = -2;
+        pv[v].sibling = -1;
+        pv[v].visited = FALSE;
+    }
+    pv[root].child = -1;
+    nedges = 0;
+    debug(("------------- new find_loops, nvertices=%d\n", nvertices));
+    for (v = 0; v < nvertices; v++) {
+        if (pv[v].parent == root) {
+            /*
+             * Found a new connected component. Enumerate and treeify
+             * it.
+             */
+            pv[v].sibling = pv[root].child;
+            pv[root].child = v;
+            debug(("%d is new child of root\n", v));
+
+            u = v;
+            while (1) {
+                if (!pv[u].visited) {
+                    pv[u].visited = TRUE;
+
+                    /*
+                     * Enumerate the neighbours of u, and any that are
+                     * as yet not in the tree structure (indicated by
+                     * child==-2, and distinct from the 'visited'
+                     * flag) become children of u.
+                     */
+                    debug(("  component pass: processing %d\n", u));
+                    for (w = neighbour(u, ctx); w >= 0;
+                         w = neighbour(-1, ctx)) {
+                        debug(("    edge %d-%d\n", u, w));
+                        if (pv[w].child == -2) {
+                            debug(("      -> new child\n"));
+                            pv[w].child = -1;
+                            pv[w].sibling = pv[u].child;
+                            pv[w].parent = u;
+                            pv[u].child = w;
+                        }
+
+                        /* While we're here, count the edges in the whole
+                         * graph, so that we can easily check at the end
+                         * whether all of them are bridges, i.e. whether
+                         * no loop exists at all. */
+                        if (w > u) /* count each edge only in one direction */
+                            nedges++;
+                    }
+
+                    /*
+                     * Now descend in depth-first search.
+                     */
+                    if (pv[u].child >= 0) {
+                        u = pv[u].child;
+                        debug(("    descending to %d\n", u));
+                        continue;
+                    }
+                }
+
+                if (u == v) {
+                    debug(("      back at %d, done this component\n", u));
+                    break;
+                } else if (pv[u].sibling >= 0) {
+                    u = pv[u].sibling;
+                    debug(("    sideways to %d\n", u));
+                } else {
+                    u = pv[u].parent;
+                    debug(("    ascending to %d\n", u));
+                }
+            }
+        }
+    }
+
+    /*
+     * Second pass: index all the vertices in such a way that every
+     * subtree has a contiguous range of indices. (Easily enough done,
+     * by iterating through the tree structure we just built and
+     * numbering its elements as if they were those of a sorted list.)
+     *
+     * For each vertex, we compute the min and max index of the
+     * subtree starting there.
+     *
+     * (We index the vertices in preorder, per Tarjan's original
+     * description, so that each vertex's min subtree index is its own
+     * index; but that doesn't actually matter; either way round would
+     * do. The important thing is that we have a simple arithmetic
+     * criterion that tells us whether a vertex is in a given subtree
+     * or not.)
+     */
+    debug(("--- begin indexing pass\n"));
+    index = 0;
+    for (v = 0; v < nvertices; v++)
+        pv[v].visited = FALSE;
+    pv[root].visited = TRUE;
+    u = pv[root].child;
+    while (1) {
+        if (!pv[u].visited) {
+            pv[u].visited = TRUE;
+
+            /*
+             * Index this node.
+             */
+            pv[u].minindex = pv[u].index = index;
+            debug(("  vertex %d <- index %d\n", u, index));
+            index++;
+
+            /*
+             * Now descend in depth-first search.
+             */
+            if (pv[u].child >= 0) {
+                u = pv[u].child;
+                debug(("    descending to %d\n", u));
+                continue;
+            }
+        }
+
+        if (u == root) {
+            debug(("      back at %d, done indexing\n", u));
+            break;
+        }
+
+        /*
+         * As we re-ascend to here from its children (or find that we
+         * had no children to descend to in the first place), fill in
+         * its maxindex field.
+         */
+        pv[u].maxindex = index-1;
+        debug(("  vertex %d <- maxindex %d\n", u, pv[u].maxindex));
+
+        if (pv[u].sibling >= 0) {
+            u = pv[u].sibling;
+            debug(("    sideways to %d\n", u));
+        } else {
+            u = pv[u].parent;
+            debug(("    ascending to %d\n", u));
+        }
+    }
+
+    /*
+     * We're ready to generate output now, so initialise the output
+     * fields.
+     */
+    for (v = 0; v < nvertices; v++)
+        pv[v].bridge = -1;
+
+    /*
+     * Final pass: determine the min and max index of the vertices
+     * reachable from every subtree, not counting the link back to
+     * each vertex's parent. Then our criterion is: given a vertex u,
+     * defining a subtree consisting of u and all its descendants, we
+     * compare the range of vertex indices _in_ that subtree (which is
+     * just the minindex and maxindex of u) with the range of vertex
+     * indices in the _neighbourhood_ of the subtree (computed in this
+     * final pass, and not counting u's own edge to its parent), and
+     * if the latter includes anything outside the former, then there
+     * must be some path from u to outside its subtree which does not
+     * go through the parent edge - i.e. the edge from u to its parent
+     * is part of a loop.
+     */
+    debug(("--- begin min-max pass\n"));
+    nbridges = 0;
+    for (v = 0; v < nvertices; v++)
+        pv[v].visited = FALSE;
+    u = pv[root].child;
+    pv[root].visited = TRUE;
+    while (1) {
+        if (!pv[u].visited) {
+            pv[u].visited = TRUE;
+
+            /*
+             * Look for vertices reachable directly from u, including
+             * u itself.
+             */
+            debug(("  processing vertex %d\n", u));
+            pv[u].minreachable = pv[u].maxreachable = pv[u].minindex;
+            for (w = neighbour(u, ctx); w >= 0; w = neighbour(-1, ctx)) {
+                debug(("    edge %d-%d\n", u, w));
+                if (w != pv[u].parent) {
+                    int i = pv[w].index;
+                    if (pv[u].minreachable > i)
+                        pv[u].minreachable = i;
+                    if (pv[u].maxreachable < i)
+                        pv[u].maxreachable = i;
+                }
+            }
+            debug(("    initial min=%d max=%d\n",
+                   pv[u].minreachable, pv[u].maxreachable));
+
+            /*
+             * Now descend in depth-first search.
+             */
+            if (pv[u].child >= 0) {
+                u = pv[u].child;
+                debug(("    descending to %d\n", u));
+                continue;
+            }
+        }
+
+        if (u == root) {
+            debug(("      back at %d, done min-maxing\n", u));
+            break;
+        }
+
+        /*
+         * As we re-ascend to this vertex, go back through its
+         * immediate children and do a post-update of its min/max.
+         */
+        for (v = pv[u].child; v >= 0; v = pv[v].sibling) {
+            if (pv[u].minreachable > pv[v].minreachable)
+                pv[u].minreachable = pv[v].minreachable;
+            if (pv[u].maxreachable < pv[v].maxreachable)
+                pv[u].maxreachable = pv[v].maxreachable;
+        }
+
+        debug(("  postorder update of %d: min=%d max=%d (indices %d-%d)\n", u,
+               pv[u].minreachable, pv[u].maxreachable,
+               pv[u].minindex, pv[u].maxindex));
+
+        /*
+         * And now we know whether each to our own parent is a bridge.
+         */
+        if ((v = pv[u].parent) != root) {
+            if (pv[u].minreachable >= pv[u].minindex &&
+                pv[u].maxreachable <= pv[u].maxindex) {
+                /* Yes, it's a bridge. */
+                pv[u].bridge = v;
+                nbridges++;
+                debug(("  %d-%d is a bridge\n", v, u));
+            } else {
+                debug(("  %d-%d is not a bridge\n", v, u));
+            }
+        }
+
+        if (pv[u].sibling >= 0) {
+            u = pv[u].sibling;
+            debug(("    sideways to %d\n", u));
+        } else {
+            u = pv[u].parent;
+            debug(("    ascending to %d\n", u));
+        }
+    }
+
+    debug(("finished, nedges=%d nbridges=%d\n", nedges, nbridges));
+
+    /*
+     * Done.
+     */
+    return nbridges < nedges;
+}
+
+/*
+ * Appendix: the long and painful history of loop detection in these puzzles
+ * =========================================================================
+ *
+ * For interest, I thought I'd write up the five loop-finding methods
+ * I've gone through before getting to this algorithm. It's a case
+ * study in all the ways you can solve this particular problem
+ * wrongly, and also how much effort you can waste by not managing to
+ * find the existing solution in the literature :-(
+ *
+ * Vertex dsf
+ * ----------
+ *
+ * Initially, in puzzles where you need to not have any loops in the
+ * solution graph, I detected them by using a dsf to track connected
+ * components of vertices. Iterate over each edge unifying the two
+ * vertices it connects; but before that, check if the two vertices
+ * are _already_ known to be connected. If so, then the new edge is
+ * providing a second path between them, i.e. a loop exists.
+ *
+ * That's adequate for automated solvers, where you just need to know
+ * _whether_ a loop exists, so as to rule out that move and do
+ * something else. But during play, you want to do better than that:
+ * you want to _point out_ the loops with error highlighting.
+ *
+ * Graph pruning
+ * -------------
+ *
+ * So my second attempt worked by iteratively pruning the graph. Find
+ * a vertex with degree 1; remove that edge; repeat until you can't
+ * find such a vertex any more. This procedure will remove *every*
+ * edge of the graph if and only if there were no loops; so if there
+ * are any edges remaining, highlight them.
+ *
+ * This successfully highlights loops, but not _only_ loops. If the
+ * graph contains a 'dumb-bell' shaped subgraph consisting of two
+ * loops connected by a path, then we'll end up highlighting the
+ * connecting path as well as the loops. That's not what we wanted.
+ *
+ * Vertex dsf with ad-hoc loop tracing
+ * -----------------------------------
+ *
+ * So my third attempt was to go back to the dsf strategy, only this
+ * time, when you detect that a particular edge connects two
+ * already-connected vertices (and hence is part of a loop), you try
+ * to trace round that loop to highlight it - before adding the new
+ * edge, search for a path between its endpoints among the edges the
+ * algorithm has already visited, and when you find one (which you
+ * must), highlight the loop consisting of that path plus the new
+ * edge.
+ *
+ * This solves the dumb-bell problem - we definitely now cannot
+ * accidentally highlight any edge that is *not* part of a loop. But
+ * it's far from clear that we'll highlight *every* edge that *is*
+ * part of a loop - what if there were multiple paths between the two
+ * vertices? It would be difficult to guarantee that we'd always catch
+ * every single one.
+ *
+ * On the other hand, it is at least guaranteed that we'll highlight
+ * _something_ if any loop exists, and in other error highlighting
+ * situations (see in particular the Tents connected component
+ * analysis) I've been known to consider that sufficient. So this
+ * version hung around for quite a while, until I had a better idea.
+ *
+ * Face dsf
+ * --------
+ *
+ * Round about the time Loopy was being revamped to include non-square
+ * grids, I had a much cuter idea, making use of the fact that the
+ * graph is planar, and hence has a concept of faces.
+ *
+ * In Loopy, there are really two graphs: the 'grid', consisting of
+ * all the edges that the player *might* fill in, and the solution
+ * graph of the edges the player actually *has* filled in. The
+ * algorithm is: set up a dsf on the *faces* of the grid. Iterate over
+ * each edge of the grid which is _not_ marked by the player as an
+ * edge of the solution graph, unifying the faces on either side of
+ * that edge. This groups the faces into connected components. Now,
+ * there is more than one connected component iff a loop exists, and
+ * moreover, an edge of the solution graph is part of a loop iff the
+ * faces on either side of it are in different connected components!
+ *
+ * This is the first algorithm I came up with that I was confident
+ * would successfully highlight exactly the correct set of edges in
+ * all cases. It's also conceptually elegant, and very easy to
+ * implement and to be confident you've got it right (since it just
+ * consists of two very simple loops over the edge set, one building
+ * the dsf and one reading it off). I was very pleased with it.
+ *
+ * Doing the same thing in Slant is slightly more difficult because
+ * the set of edges the user can fill in do not form a planar graph
+ * (the two potential edges in each square cross in the middle). But
+ * you can still apply the same principle by considering the 'faces'
+ * to be diamond-shaped regions of space around each horizontal or
+ * vertical grid line. Equivalently, pretend each edge added by the
+ * player is really divided into two edges, each from a square-centre
+ * to one of the square's corners, and now the grid graph is planar
+ * again.
+ *
+ * However, it fell down when - much later - I tried to implement the
+ * same algorithm in Net.
+ *
+ * Net doesn't *absolutely need* loop detection, because of its system
+ * of highlighting squares connected to the source square: an argument
+ * involving counting vertex degrees shows that if any loop exists,
+ * then it must be counterbalanced by some disconnected square, so
+ * there will be _some_ error highlight in any invalid grid even
+ * without loop detection. However, in large complicated cases, it's
+ * still nice to highlight the loop itself, so that once the player is
+ * clued in to its existence by a disconnected square elsewhere, they
+ * don't have to spend forever trying to find it.
+ *
+ * The new wrinkle in Net, compared to other loop-disallowing puzzles,
+ * is that it can be played with wrapping walls, or - topologically
+ * speaking - on a torus. And a torus has a property that algebraic
+ * topologists would know of as a 'non-trivial H_1 homology group',
+ * which essentially means that there can exist a loop on a torus
+ * which *doesn't* separate the surface into two regions disconnected
+ * from each other.
+ *
+ * In other words, using this algorithm in Net will do fine at finding
+ * _small_ localised loops, but a large-scale loop that goes (say) off
+ * the top of the grid, back on at the bottom, and meets up in the
+ * middle again will not be detected.
+ *
+ * Footpath dsf
+ * ------------
+ *
+ * To solve this homology problem in Net, I hastily thought up another
+ * dsf-based algorithm.
+ *
+ * This time, let's consider each edge of the graph to be a road, with
+ * a separate pedestrian footpath down each side. We'll form a dsf on
+ * those imaginary segments of footpath.
+ *
+ * At each vertex of the graph, we go round the edges leaving that
+ * vertex, in order around the vertex. For each pair of edges adjacent
+ * in this order, we unify their facing pair of footpaths (e.g. if
+ * edge E appears anticlockwise of F, then we unify the anticlockwise
+ * footpath of F with the clockwise one of E) . In particular, if a
+ * vertex has degree 1, then the two footpaths on either side of its
+ * single edge are unified.
+ *
+ * Then, an edge is part of a loop iff its two footpaths are not
+ * reachable from one another.
+ *
+ * This algorithm is almost as simple to implement as the face dsf,
+ * and it works on a wider class of graphs embedded in plane-like
+ * surfaces; in particular, it fixes the torus bug in the face-dsf
+ * approach. However, it still depends on the graph having _some_ sort
+ * of embedding in a 2-manifold, because it relies on there being a
+ * meaningful notion of 'order of edges around a vertex' in the first
+ * place, so you couldn't use it on a wildly nonplanar graph like the
+ * diamond lattice. Also, more subtly, it depends on the graph being
+ * embedded in an _orientable_ surface - and that's a thing that might
+ * much more plausibly change in future puzzles, because it's not at
+ * all unlikely that at some point I might feel moved to implement a
+ * puzzle that can be played on the surface of a Mobius strip or a
+ * Klein bottle. And then even this algorithm won't work.
+ *
+ * Tarjan's bridge-finding algorithm
+ * ---------------------------------
+ *
+ * And so, finally, we come to the algorithm above. This one is pure
+ * graph theory: it doesn't depend on any concept of 'faces', or 'edge
+ * ordering around a vertex', or any other trapping of a planar or
+ * quasi-planar graph embedding. It should work on any graph
+ * whatsoever, and reliably identify precisely the set of edges that
+ * form part of some loop. So *hopefully* this long string of failures
+ * has finally come to an end...
+ */
index 0d5aeee14fe0b3b00bf88bea74c95243f87e9606..1847d9c7b8964a740061f79d5375785be901071c 100644 (file)
--- a/puzzles.h
+++ b/puzzles.h
@@ -467,6 +467,41 @@ void free_combi(combi_ctx *combi);
 /* divides w*h rectangle into pieces of size k. Returns w*h dsf. */
 int *divvy_rectangle(int w, int h, int k, random_state *rs);
 
+/*
+ * findloop.c
+ */
+struct findloopstate;
+struct findloopstate *findloop_new_state(int nvertices);
+void findloop_free_state(struct findloopstate *);
+/*
+ * Callback provided by the client code to enumerate the graph
+ * vertices joined directly to a given vertex.
+ *
+ * Semantics: if vertex >= 0, return one of its neighbours; if vertex
+ * < 0, return a previously unmentioned neighbour of whatever vertex
+ * was last passed as input. Write to 'ctx' as necessary to store
+ * state. In either case, return < 0 if no such vertex can be found.
+ */
+typedef int (*neighbour_fn_t)(int vertex, void *ctx);
+/*
+ * Actual function to find loops. 'ctx' will be passed unchanged to
+ * the 'neighbour' function to query graph edges. Returns FALSE if no
+ * loop was found, or TRUE if one was.
+ */
+int findloop_run(struct findloopstate *state, int nvertices,
+                 neighbour_fn_t neighbour, void *ctx);
+/*
+ * Query whether an edge is part of a loop, in the output of
+ * find_loops.
+ *
+ * Due to the internal storage format, if you pass u,v which are not
+ * connected at all, the output will be TRUE. (The algorithm actually
+ * stores an exhaustive list of *non*-loop edges, because there are
+ * fewer of those, so really it's querying whether the edge is on that
+ * list.)
+ */
+int findloop_is_loop_edge(struct findloopstate *state, int u, int v);
+
 /*
  * Data structure containing the function calls and data specific
  * to a particular game. This is enclosed in a data structure so