Writing a solver for Net [entries|reading|network|archive]
simont

[ userinfo | dreamwidth userinfo ]
[ archive | journal archive ]

Wed 2019-03-27 13:36
Writing a solver for Net

I had mail yesterday from a computer science student asking how I went about writing the solver for the game Net in my puzzle collection. I took the question literally, i.e. not just ‘what is the algorithm?’ but ‘what was your thought process that allowed you to come up with the algorithm?’. I thought that was a good question, and so I wrote a fairly complete answer.

Having done that, of course, it seems a shame to only send it to a single recipient! So I'll post it here as well, in case anyone else is interested.

Generally, if I'm trying to write a solver for a puzzle game, my first step is to solve an instance of the same puzzle by hand (or more than one if I don't get the hang of it immediately), and watch my own thought processes. That tells me what kinds of deduction can be made, and once I know that, it's usually not too hard to automate them in a program.

Net was actually the very first puzzle in my collection, and I got into it by playing an existing version in the form of somebody else's Flash web game. (This was around 2002.) Initially I played it by unscientific trial and error – just turn things so that they mostly fit together, and when you find a problem, try tweaking things and hope it makes them better. But after a while I started to notice individual deductions such as:

  • a T-piece next to the grid edge can only go one way, because none of the three connected sides of it can point at the barrier. Similarly, a corner piece in the corner can only go one way, and so can a straight-edge piece on the grid edge.
  • That lets you decide for sure what the orientation of a piece is, so you can lock it down and treat it as definite.
  • Then an unconnected edge of a locked piece (say, the other side of that straight-edge) can be treated like a grid edge in turn (so that a T-piece next to it can align only one way).
  • And a definite connected edge due to a locked piece gives you further things you can do. For example, an endpoint next to that edge now has only one way it can connect.

So that gave me the general idea that each piece eventually becomes ‘locked’ into a single orientation, and the more pieces are locked, the more you can deduce about other pieces using those as a starting point.

To model that in a computer, the obvious approach is to store a set of possible orientations for each piece. Initially every piece has a set consisting of all four possible orientations (except that the straight piece has only two, because it's 180-degree symmetric). So you set up that data structure; keep iterating over the grid looking for reasons why a given orientation is ruled out; and when you stop being able to make any progress, you check to see how many possible orientations each piece has left. If any piece has no orientations then the puzzle has no solution (or your solver has a bug, of course); if any piece has more than one orientation, then either the puzzle has multiple solutions, or else one of them is not actually legal for a reason your solver hasn't spotted.

That's the basic stage of a solver for Net. Then you start trying to make it cleverer by adding more deductions, possibly with more stored data as well.

My next insight was that sometimes you can find out useful things without needing to lock a piece down completely:

  • if one edge of a corner piece is known to be connected, then the opposite side must be unconnected, and vice versa.

That suggested to me that it's also worth storing data for each edge of the grid, showing the set of possible states that edge might be in. (The two states are ‘connected’ and ‘unconnected’, so the four possible sets are ‘connected’, ‘unconnected’, ‘don't know yet’ and ‘help, I haven't got any possible state for this edge’.)

Once you have that data, it simplifies the deductive system: now you don't have to go through all possible cases for a square and its neighbour. Instead, you can repeatedly ask two questions:

  • Does my knowledge about the possible states of this square rule out any states of its bordering edges?
  • Does my knowledge about the possible states of this edge rule out any states of its bordering squares?

You can answer those questions by checking a single (square, edge) pair and just iterating over every possible state of the square. Any state of the square that would put that edge in a state you know to be impossible can be thrown away; conversely, any state of the edge that is not achieved by one of the remaining states of the square can be thrown away too.

And now you don't even have to write the code in a way that has a huge list of detailed special cases about how T-pieces, corners, straights and endpoints each behave. It's all done in a completely uniform way.

Those are the ‘local’ deductions. Of course, Net has another pair of rules: you mustn't make a loop, and you must ensure everything is connected together.

Loop avoidance is dealt with by a bit of computer science. You may already have heard of the ‘union-find’ data structure, also known as ‘disjoint-set data structure’ or ‘Tarjan's disjoint set forest’ or other names along those lines. It lets you maintain information about a set of objects some of which are known to be ‘equivalent’ to one another in some way, under the conditions that everything starts out considered to be separate, and sometimes you find out a new piece of information that means objects X and Y are now known to be equivalent. And it tracks transitivity, so if you previously knew A=B and X=Y, and then you tell it that B=X, then the query interface will automatically know all of A=Y, B=Y and A=X as well.

That structure is tailor-made for tracking connectivity in a graph, if it's ‘incremental’ (that is, edges are always added, never subtracted). So the solver's third piece of stored data is a disjoint-set forest whose elements are the squares of the grid, in which two elements are considered equivalent if there's a path of known connected edges linking those squares to each other. Then you get two new deductive rules in the solver:

  • Whenever you rule out the ‘unconnected’ state for an edge, unify the two squares it separates in the dsf.
  • Conversely, if you have an unknown edge for which the dsf tells you that the two squares it separates are already connected to each other, you must rule out the ‘connected’ state for that edge, because connecting that edge would make a second path between those two squares, i.e. would form a loop.

That leaves the rule about all the squares being connected together. That was actually the most difficult one.

Going back to my original plan of playing the game by hand and watching the techniques my own brain came up with, I initially found that the connectedness requirement made me think of local deductions such as:

  • Two endpoints next to each other cannot be connected together. (Otherwise, they couldn't connect to anything else).
  • In particular, if a cluster of four endpoint squares is arranged in a T-shape, then the one at the centre of the T must connect to the only one of its edges that doesn't go to another endpoint.
  • A straight edge between two endpoints cannot be oriented so that it links them together, because again, those three squares would form a closed subgraph.
  • A corner piece bordering three endpoints must be connected on the fourth edge, otherwise it would link together two endpoints.

I could have coded each of those local deductions separately, but I preferred to try harder to find a single underlying rule that they're all special cases of.

One important point is that unlike the earlier set of local deductions, these all have exceptions. Suppose your entire Net grid is 2×1 in size. Then two endpoint squares can connect together – indeed, they must! So it's not quite as simple as ‘these configurations are disallowed’: it's more that they're disallowed unless the resulting subgraph would be as big as the whole grid.

As I continued to play by hand, I found I was forming an intuitive notion of a ‘dead end’. A dead end is an oriented edge (that is, a particular side of a particular edge of the grid) which has the property that if that edge is in the ‘connected’ state, then the set of squares you can reach on that side of it can't possibly be the whole grid.

So I'd find myself mentally identifying larger and larger parts of the grid as ‘dead ends’:

  • Any edge going into an endpoint is a dead end
  • Any edge going into a straight piece is a dead end if the opposite edge is a dead end in the outgoing direction
  • Any edge going into a corner piece is a dead end if both the adjacent edges of that corner are outgoing dead ends
  • Any edge going into a T-piece is a dead end if all the other edges of that T-piece square are outgoing dead ends

and then I'd be able to say to myself, ‘Both sides of this edge are dead ends, so if the edge is connected, then they'll link together into some kind of closed subgraph that isn't the whole grid.’

But, again, there's that exception. It's not actually enough to tag an edge as ‘dead end’ or ‘not dead end’: it's not a binary property. It matters how big is the set of squares you can reach – and if it's the whole grid, then perhaps that dead-end configuration is allowed after all.

After thinking this through for a while, I worked out how to make it rigorous. The right way to consider it is to store the following item of data for each direction of each edge:

  • If this edge is connected, then what is the maximum number of squares that could possibly be reachable from this side of it?

Initially, you can set all those values to the size of the whole grid, because obviously it won't be possible to reach more squares than that. But then you can start doing deductions:

  • An edge going into an endpoint: set the ‘maximum reachable’ value to 1. If you connect into that endpoint, there can't possibly be a path going onward from there into any other square.
  • An edge going into a straight piece: the ‘maximum reachable’ value can be at most the value of the opposite outgoing edge, plus 1 for the straight square itself.
  • An edge going into a corner: you have to consider two ways the corner could be oriented. (If it connects on this edge, its other connection could be either of the two adjacent edges.) If both of those possibilities give you a small maximum value – or if one of them is ruled out completely – then you can bound the maximum for this edge.

And eventually I worked out that this boils down to a general rule you can apply to any kind of square, and all of those are special cases of it:

  • Given an edge E going into a square S, iterate over all orientations of S that aren't ruled out yet. For each one, find all the connected edges of S other than E; then the maximum number of reachable squares from edge E with S in that state is given by summing the max values for those outgoing edges, and adding 1 for S itself. Then the max for E is bounded by the largest one of those values you worked out for any orientation of S.

With the dead-end avoidance code in that form, it's nicely rigorous, and takes the special cases in its stride. Give it a 2×1 grid and it will happily connect the two endpoints together without complaining that they're an illegal dead end; give it any larger grid and it will know that you can't connect any two endpoints together.

That's all the deductions I've implemented so far. There is another one I use quite often in play, and I haven't got round to adding it to the solver yet:

  • If you can find a loop of edges in the grid with only one of them in an unknown state, then you can determine the state of that edge by parity. Add up the degree of every square in the region enclosed by the loop (1 for an endpoint, 3 for a T-piece, 2 for straights and corners); then any connection within that region reduces the count by 2, but nothing can change whether the count is odd or even. So the number of times the solution graph crosses your loop must be odd if and only if the sum of degrees inside the region is odd, and even otherwise.

The difficult part of implementing that technique is efficiently identifying an edge that you can nail down in that way, and the corresponding region in which you have to count up parities. I do know how to do that – but I'll leave it as an exercise for the reader!

LinkReply
[personal profile] kaberettWed 2019-03-27 14:09
oooh, thank you for the write-up!
Link Reply to this | Thread
[personal profile] simontWed 2019-03-27 15:11
You're welcome!

(I must admit that when I first had the thought 'I wonder if anyone else would be interested in this huge pile of text I've just written about Net', you were the very first person who came to mind, so I'm glad you've proved me right :-)
Link Reply to this | Parent | Thread
[personal profile] kaberettWed 2019-03-27 15:18

<3

Link Reply to this | Parent
[personal profile] simontWed 2019-03-27 15:15
Addendum: there's one other kind of deduction I've just remembered about. Very occasionally, on a grid that is both small and wrapping, you find that there's a whole row or column consisting of corners and straights, except for just one endpoint or T-piece. In that situation you can deduce whether the opposite sides of the odd piece are the same or not (using that opposite sides of a straight are the same, and opposite sides of a corner are different), and in all cases, that lets you deduce something about the orientation of the odd piece.

That would be feasible to implement, using an augmented version of the disjoint-set forest that lets you track a set of boolean flags and remember information of the form 'I know these two flags are [ equal | opposite ], even though I don't know the actual value of either one yet'. I've already got that data structure for use in Loopy, and it would be easy to reuse in Net.

But I've never yet bothered, because that is such a niche deduction – it's not exactly guaranteed to apply only to small wrapping grids, but the larger the grid, the less likely it is to come up.
Link Reply to this
[personal profile] crazyscotThu 2019-03-28 05:14
Thanks for sharing! Have you considered animating the solver?
Link Reply to this | Thread
[personal profile] simontThu 2019-03-28 06:47
Literally animating sounds like a lot of effort that can't be reused. But it has occurred to me before that it would be reasonably easy and quite entertaining to make many of the solvers able to output a save file, which you could then load back in to the ordinary GUI and hit Redo repeatedly to replay it.

It wouldn't show you the whole story, though – some of the deductions the solver uses have no direct representation in the visible UI. For Net in particular, most of the working state of the solver has that property: there's no UI for storing even the set of not-ruled-out orientations of a tile, let alone the state of an individual edge or the max-reachable-squares counters. So it would only be pretty – it wouldn't be especially useful as a debugging aid when the solver had a bug, or as an insight into what the solver had spotted that you hadn't if you were trying to use it to debug your own thought processes.
Link Reply to this | Parent
navigation
[ go | Previous Entry | Next Entry ]
[ add | to Memories ]