Figure 1: An example liquid-sorting puzzle
The object of the liquid-sorting puzzle is to sort all the initially-mixed colored liquids into vials full of uniform color; the image below shows an example of the initial mixed state. You may pour any vial onto any other; this transfers the top color layer from the one vial to the other. The only conditions are: (a) the receiving vial must have room, (b) no merging unalike colors—the receiving vial be empty or topped with the same color as you're pouring.
Figure 1: An example liquid-sorting puzzle
Figure 2: A sequence of liquid-pouring moves. Figure by https://arxiv.org/pdf/2202.09495
You can set up some tricky puzzles this way (in fact, it's NP-complete), and in fact it's possible to wedge the game into an unwinnable state.
A perfect storm emerged: I did not like the idea of unwinnable states and I had always thought about learning the APL programming language. So I figured this would be a perfectly unhinged opportunity to address both issues by writing an APL program to solve all liquid sorting puzzles. Here is the source code (complete explanation follows; see next section):
top←{⍵↑⍨+/∧\⍵=⊃⍵}
vacancy←{t←top' '~⍨⍺ ⋄ (2>≢∪t,top' '~⍨⍵)×(+/' '=⍵)⌊≢t}
pour←{after←(-⍺ vacancy ⍵)⌽(⌽⍺),⍵ ⋄ (⌽(≢⍺)↑after)((≢⍺)↓after)}
{{c←{+/' '≠⍵}¨⍵ ⋄ (((↑⍸¨↓2>⌿c),'→',↑⍸¨↓2<⌿c)⍪' '),⍵}↑⌽⍬{0=≢⍵:⍬ ⋄ p←⊃⍵ ⋄ vials←⊃p ⋄ ∧/{2>≢∪⍵}¨vials:p ⋄ (⊂vials[⍋vials])∊⍺:⍺∇1↓⍵ ⋄ intos←∘.pour⍨vials ⋄ afters←{⍵[⍋+/{+/2≠/⍵}¨⍵;]}∪↑,{v←vials ⋄ =/⍵:v ⋄ v[⍵]←⊃⍵⌷intos ⋄ v}¨⍳⍴intos ⋄ (⍺,⊂vials[⍋vials])∇(1↓⍵),{(⊂⍵),p}¨↓afters},⊂⊂⍵}'ABAB' 'BABA' ' '
What a marvelous pile of hieroglyphs! And yet it's a complete search
algorithm. You hand this function the starting state of your puzzle,
such as ('ABAB' 'BABA', ' ') (which represents a game of three vials
stacked with colors 'A' and 'B': ABAB, BABA, and empty.) Like magic,
it spits out a nicely-formatted sequence of minimal steps to solve the
puzzle:
┌→───────────────────────────┐
↓ ┌→───┐ ┌→───┐ ┌→───┐ │
│ 1 → 3 │ABAB│ │BABA│ │ │ │
│ └────┘ └────┘ └────┘ │
│ ┌→───┐ ┌→───┐ ┌→───┐ │
│ 2 → 1 │BAB │ │BABA│ │A │ │
│ └────┘ └────┘ └────┘ │
│ ┌→───┐ ┌→───┐ ┌→───┐ │
│ 2 → 3 │BBAB│ │ABA │ │A │ │
│ - └────┘ └────┘ └────┘ │
│ ┌→───┐ ┌→───┐ ┌→───┐ │
│ 1 → 2 │BBAB│ │BA │ │AA │ │
│ └────┘ └────┘ └────┘ │
│ ┌→───┐ ┌→───┐ ┌→───┐ │
│ 1 → 3 │AB │ │BBBA│ │AA │ │
│ └────┘ └────┘ └────┘ │
│ ┌→───┐ ┌→───┐ ┌→───┐ │
│ 2 → 1 │B │ │BBBA│ │AAA │ │
│ └────┘ └────┘ └────┘ │
│ ┌→───┐ ┌→───┐ ┌→───┐ │
│ 2 → 3 │BBBB│ │A │ │AAA │ │
│ └────┘ └────┘ └────┘ │
│ ┌→───┐ ┌→───┐ ┌→───┐ │
│ │BBBB│ │ │ │AAAA│ │
│ - - - └────┘ └────┘ └────┘ │
└∊───────────────────────────┘
I find I'm charmed by APL. At first, it seems perversely cryptic. Then you discover that each symbol represents a common array manipulation such as iteration, slicing, mapping, filtering. Each row of code is a compact way to describe a sequence of array manipulations, readable at a glance once you have the descriptive vocabulary.
The shift in density reminds me a little of the contrast between spanish, english, and chinese:
Each has tradeoffs—pronunciation, density, ambiguity, and so forth. In my case, APL represents a sudden density upgrade, and it's interesting to see what you can do with it.
Conceptually, it's a basic breadth-first search for the shortest sequence of moves to a solved state.
At a language level, APL opens up new ways of thinking. Here's an example of an array-manipulation way to think about the pouring subroutine:
To
pourone vial onto another:
- Write the vials as lists like
[AABC ][ADE ], with liquid contents listed from top to bottom and padding space added at the end.- Reverse the first vial and concatenate:
[ CBAA|ADE ].- Having pre-computed how many units of liquid can flow between the vials, cyclically rotate the concatenated list by this amount. In our case, we rotate by two, pouring the two units of A from the first vial onto the second:
[ CB|AAADE]. Note that this shuffles two units of A onto the second vial, and two compensating units of empty space onto the first vial.- Now if we split them apart and reverse, we get their final states:
[BC ][AAADE].What a neat way to do that! And it's short:
pour←{after←(-(⍺ vacancy ⍵))⊖((⌽⍺),⍵)⋄((⌽(≢⍺)↑after)((≢⍺)↓after))}
top←{⍵↑⍨+/∧\⍵=⊃⍵} : helper top. given a vial, return the top block of monochromatic color. So AAABC => AAA. For an empty vial, returns a list of spaces.vacancy←{t←top' '~⍨⍺ ⋄ (2>≢∪t,top' '~⍨⍵)×(+/' '=⍵)⌊≢t} : helper vacancy, binary operator returns the number of liquid units that could be transferred from vial α onto vial ⍵. It's the minimum of: free slots in ⍵, monochromatic liquid on top of α, and the amount of liquid in α (to exclude empty α).pour←{after←(-⍺ vacancy ⍵)⌽(⌽⍺),⍵ ⋄ (⌽(≢⍺)↑after)((≢⍺)↓after)} : helper pour, binary operator pours vial ⍺ onto vial ⍵, returning the resulting pair. Performs a legal-move check, then the cyclic rotation trick. If the move is illegal, the zero rotation automatically returns the vials unchanged.{{c←{+/' '≠⍵}¨⍵ ⋄ (((↑⍸¨↓2>⌿c),'→',↑⍸¨↓2<⌿c)⍪' '),⍵} ⍝ the pretty-printer for solution paths
↑⌽ ⍝ ... applied to the solution path in the next line
⍬{ ⍝ search is a binary operator. left arg is the visited set (initially empty ⍬), right arg is initial game state
0=≢⍵:⍬ ⋄ ⍝ if empty queue, return empty path (failure)
p←⊃⍵ ⋄ vials←⊃p ⋄ ⍝ pop the queue; call the path `p` and its final state `vials`.
∧/{2>≢∪⍵}¨vials:p ⋄ ⍝ goal check: if each vial is monochromatic or empty, we win. return the path p.
(⊂vials[⍋vials])∊⍺:⍺∇1↓⍵ ⋄ ⍝ visited check: canonicalize the path. if already seen, recur on the rest of the queue. (search is an anonymous recursive function)
intos←∘.pour⍨vials ⋄ ⍝ beautifully concise: compute all possible pouring moves as an outer product
afters←{⍵[⍋+/{+/2≠/⍵}¨⍵;]}∪↑,{v←vials ⋄ =/⍵:v ⋄ v[⍵]←⊃⍵⌷intos ⋄ v}¨⍳⍴intos ⋄
⍝ convert the moves into game states. illegal moves are no-ops, to be pruned by the visited check.
(⍺,⊂vials[⍋vials])∇(1↓⍵),{(⊂⍵),p}¨↓afters ⍝ recur: add current path to the visited set, add sorted children to the popped queue, and recur the search loop.
},⊂⊂⍵ ⍝ search is a binary operator. this is its right hand side; a singleton containing the initial game state
}'ABAB' 'BABA' ' ' ⍝ call search-then-print on this game state.
The punchline—things am proud of include:
∘.pour⍨vials to pour everything into everything else-(⍺ vacancy ⍵))⊖((⌽⍺),⍵)The original draft of this algorithm had a few mistakes: it didn't forbid pouring from a vial to itself, violating conservation of liquid. And the "fail if the queue is empty" condition was misformatted.
singleton←{1↓1,⊂⍵}
top←{0=⍴⍵:⍵⋄⊃(⍵⊂⍨1,2≠/⍵)}
vacancy←{(2>≢∪' '~⍨((top ⍺),(top ⍵)))×((+/(' '≠⍺))⌊(+/(' '=⍵))⌊(≢top ⍺))}
pour←{after←(-(⍺ vacancy ⍵))⊖((⌽⍺),⍵)⋄((⌽(≢⍺)↑after)((≢⍺)↓after))}
{{(((↑⍸¨(↓⍣1)2>⌿{≢' '~⍨⍵}¨⍵),('→',↑⍸¨(↓⍣1)2<⌿{≢' '~⍨⍵}¨⍵))⍪(' ' ' ' ' ')),⍵}↑⌽(⍬{0≡⍴⍵:⍬ ⋄ p←⊃⍵ ⋄ vials←⊃p⋄∧/{2>≢∪⍵}¨vials: p ⋄ (⊂{⍵[⍋⍵]} vials)∊⍺ : ⍺∇(1↓⍵) ⋄ intos←∘.pour⍨vials⋄afters ← {⍵[(⍋+/{+/2≠/⍵}¨⍵);]} ∪↑((⍴vials)*2)⍴{v←vials ⋄ v[⍵]←⊃(⍵⌷intos)⋄v}¨(⍳⍴intos)⋄ news←{(⊂((1↓⍴⍵)⍴⍵)),p}¨ (⊂⍤⊢⌺1⊢afters) ⋄ (⍺,(⊂{⍵[⍋⍵]}vials))∇({⍵[⍸{⍵≡∪⍵}¨⍵]}((1↓⍵),news)) } singleton⊂⍵)}('ABAB' 'BABA' ' ')