ShareMyScreen/AdventOfCode/2022/16/ProboscideaVolcanium

From J Wiki
Jump to navigation Jump to search

The Problem << >>

This is a terrifying problem. A graph-maximization problem, but I have to find the absolutely best answer, which means I have to try all the possibilities. I peek at my problem data and my terror grows. There seem to be 50 valves! Just turning each one on or not would take 250 trials. What will I do?

On these big problems the strategies are:

  • reduce the number of options at each turn
  • prune the search tree by cutting off branches that can't hold the solution
    • try the best alternatives first
    • after each move see if it is impossible for the position to lead to a best solution

Cutoff is usually where the big gain is, but it's easier to trim the search. I'll start with trimming.

Looking again, I see two interesting features in my puzzle data:

  • 30 of the valves have flow 0 and don't need to be turned on
  • most of the valves lead to just one other valve, forming a chain of valves with no options

But no, that last bit isn't right. You could have a valuable valve in the middle of a long chain, and it could be right to go just far enough to pick up that valve and then turn around and come back. But I can certainly combine sequential valves of flow 0 into a single node of the graph. That says that I should represent the connectivity by having each valve connect only to valves that have nonzero flow, giving the amount of time it takes to reach that valve.

For that matter, there's no reason for the problem to contain any valves with 0 flow. I can tear them out and replace them with connections between valves with nonzero flow. That will replace one branching node with an increased branching factor at its neighbors. Is that a good trade? Yes it is, because it will give each node a direct pointer/distance to its neighbors, which will allow it to order its alternatives better.

In fact, I can make this simplification to the graph as the solution proceeds: when I turn on a valve I will tear its node out of the problem. Of course I'll have to remember what I did so that I can patch the valve back in when I backtrack.

I'm seeing only 6 nodes with branching factors of 5 so if I can hold the number of evaluations to some multiple of 65 it will be OK. I am hoping that the 30-minute cutoff will help stop the search.

I am ready to read in the data. When I finish I will have

  • flows - the flow of each valve
  • nbrs - for each neighbor, a box containing a table, each row of which is (neighbor,distance). To start with the distances will be 1.

I will sort the names so that I know the starting node comes first.

require 'strings'
oneline =: {{  NB. y is a line for a valve, result is valvename;flow;list of boxed valvenames
name =. 2 {. 'Valve ' takeafter y  NB. 2-character name
flow =. 0 ". ';' taketo '=' takeafter y  NB. Integer flow rate
neigh =. _4 <@(2&{.)\ ' ' takeafter 'valve' takeafter y  NB. boxed list of neighbors
name;flow;<neigh
}}
   ]lines =. /:~ oneline onaoclines
+--+--+----------+
|AA|0 |+--+--+--+|
|  |  ||DD|II|BB||
|  |  |+--+--+--+|
+--+--+----------+
|BB|13|+--+--+   |
|  |  ||CC|AA|   |
|  |  |+--+--+   |
+--+--+----------+
...

Because of the way x ; y is defined I had to box the last thing in the result of oneline because it was already boxed. Now I will extract flows and nbrs, giving the initial distance of 1:

   ]flows =: > 1{"1 lines
0 13 2 20 3 0 0 22 0 21
   ]nbrs =. ,.&1&.> ({."1 lines)&i.&.> {:"1 lines
+---+---+---+---+---+---+---+---+---+---+
|3 1|2 1|3 1|2 1|5 1|4 1|5 1|6 1|0 1|8 1|
|8 1|0 1|1 1|0 1|3 1|6 1|7 1|   |9 1|   |
|1 1|   |   |4 1|   |   |   |   |   |   |
+---+---+---+---+---+---+---+---+---+---+

I still have an uneasy feeling about this. At each valve I will have to try opening the valve and then moving, but also moving without opening the valve, because if the valve has a small flow the time might be more valuable than the flow. Are there times when I can be sure that I should open the valve? I can think of two:

  • when the valve has the highest flow among all unopened valves
  • when the valve was a one-valve cul-de-sac. In that case if I don't open the valve the move to it was certainly wasted. (I see that there are several such valves in my puzzle data and this will change their branching factor from 2 to 1).

There is still the possibility of unproductive thrashing as the solution tries not-opening one valve after another and eventually runs out of time in each branch after many tries. Cutoff would help that. I can certainly implement a simple improvement: I won't move back to a valve v if no valve has been opened since the last time I visited v. Actually I can do better: I won't even visit a valve if I could have visited it earlier and have opened no valves since. That will reduce the thrashing; whether enough, I don't know.

I guess it's time for typing. I start a script.

best =: 0  NB. the final result: the best total flow found
nmoves =: 0
NB. emulate the passage of time
time =: {{   NB. x is clock,(flow from opened valves),(total flow so far)   y is duration   result is new clock,flow,total (or _1 _1 _1 to call for exit)
'clock flow total' =. x   NB. extract current state
total =. total + flow * y =. y <. 30 - clock  NB. limit duration to the end of the simulation period; add to total flow
clock =. clock + y  NB. advance the clock
if. cutoff res =. clock,flow,total do. _1 _1 _1 return. end.  NB. check for cutoff; if so, abort
res
}}
NB. check for termination, whether early or not
cutoff =: {{   NB. y is clock,(flow from opened valves),(total flow so far)   result is 1 if branch should be pruned.  Updates best if branch completes
'clock flow total' =. y   NB. extract current state
best =: total >. best  NB. immediately update best total so it can prune other branches
clock = 30   NB. the only pruning is when time expires
}}
NB. move to a new cell.
move =: {{  NB. x is clock,(flow from opened valves),(total flow so far)   y is (new valve,duration,1 to force opening);(list of valves visited since most recent opening)
'ddata excl' =. y  NB. extract destination, force-opening, valves excluded from nonopening move
'valve delay fopen' =. ddata  NB. extract move and force-open flag
'clock flow total' =. x time delay   NB. extract current state and account for move delay
if. clock < 0 do. i. 0 0 return. end.   NB. if branch ended, return
vflow =. valve { flows  NB. get the flow of the valve we have moved to
if. vflow = >./ flows do. fopen =. 1 end.  NB. if we are standing at the largest flow, always open it
dests =. valve {:: nbrs  NB. extract list of neighbors
destsc =. dests ,. 01 = (dvalves =. {."1 dests) #@{::"0 _ nbrs  NB. before we change the topology, see if each move is to a cul-de-sac (valves with only 1 nbr, which must be this valve)
NB. first case: open the valve we are on and take it out of the connections
if. vflow do.  NB. special case of 1st node which might have no flow: don't open
  mparms =. (0,vflow,0) + (clock,flow,total) time 1   NB. delay 1 minute for valve-opening delay, and increase the flow for subsequent moves
  if. 0 > {. mparms do. i. 0 0 return. end.   NB. if branch ended, return
  restore =. vacate valve   NB. remove the valve from the connection list
  flows =: 0 valve} flows  NB.  once the valve has been opened, there is no gain to be had from it
  for_d. destsc do. mparms move d;dvalves end.  NB. try each move; don't allow a non-opener to go to anywhere we could have moved to directly
  flows =: vflow valve} flows   NB. undo the valve opening: restore flows...
  nbrs =: (1{::restore) (0{::restore)} nbrs   NB. ...and connections
else. vacate valve  NB. valve has no flow.  remove the valve from the connection list permanently
end.
NB. second case: don't open the valve
if. -. fopen do.
  mparms =. clock,flow,total [ excl =. excl , valve [ destsc =. destsc #~ -. dvalves e. excl  NB. remove excluded dests, append this valve to excluded list
  for_d. destsc do. mparms move d;excl end.  NB. apply exclusion
end.
i. 0 0
}}
NB. tear a valve out of the connections
vacate =: {{     NB. y is valve number to vacate;  result is (indexes on nbrs that changed);(previous values)
'dvalve ddist' =. |: y {:: nbrs   NB. indexes of valves to change, and distance from this valve to there
oldconns =. dvalve { nbrs  NB. save the old connections for restoration
newconns =. dvalve ,."1 +/~ ddist  NB. Each row gives connections to add to corresponding box of nbrs
newconns =. y (00 ,.~ 2 $"0 i.#dvalve)} newconns  NB. diagonals are reflexive and must be removed; change the dest to y, which also must be removed
newconns =. newconns <@((, <./)/../@:|:@:, >)"2 0 oldconns  NB. append new to old and take minimum of duplicate paths
newconns =. y ((~: {."1) # ])&.> newconns  NB. remove the now-dead link to y
nbrs =: newconns dvalve} nbrs   NB. Install the new connections.  Leave y, as it is now orphaned
dvalve;<oldconns  NB. return info to restore what we did
}}

Time to test. To save time I will forcibly vacate cells where the flow is 0, except for the starting cell which must remain so I can move to it initially.

   vacate"0 I. 0 (0}) 0 = flows
   0 0 0 move 0 0 0;$0  NB. initial move: clock,flow,total=0, move to cell 0 with delay 0 and no forced opening; empty exclusion list

The moment of truth...

   best
1458

...or of failure. 1651 was expected. Hung be the heavens with black. All I know is that the answer is wrong, with no information available for troubleshooting. I look at flows and nbrs which should have been restored to their initial values, and indeed they were. This is a Scott of the Antarctic moment. I am just going outside and I may be some time...

I have to fix it. But it seemed so right. I was worried that the program might run too long, not that it would be wrong. Should I roll up my sleeves and start tracing and adding debug typeout, or think about what might be wrong? In such situations I always rush headlong into thought.

I'm sure I don't visit a valve twice. It must be that I am discarding a path improperly: that one of my time-saving tricks was overeager.

After a few minutes I haven't seen anything. This is going to require my ultimate debugging measure: I'll sleep on it.

When I wake up I realize that I did make a mistake. When there are no more moves to make, the branch ends immediately and prematurely. It should stay active until the 30 seconds are up. That will be easy to fix. I will simply check for that case and call move right after I have opened the valve with the line

  if. 0 = #dests do. i. 0 0 [ mparms time 30-clock return. end.  NB. If we are opening the last valve, run out the clock and exit

Another moment of truth...

   0 0 0 move 0 0 0;$0  NB. initial move: clock,flow,total=0, move to cell 0 with delay 0 and no forced opening; empty exclusion list
   best
1651

...with success this time. Now I try my full puzzle data.

After a minute I run jbreak to interrupt it. Rats. I will have to prune the tree.

Pruning doesn't have to be perfect. Most of the branches are making bad decisions early and wallowing around in the middle of the solution space. I'll order the alternatives so that each list of neighbors goes to the valve with the highest flow

   nbrs =: (\:   flows {~ {."1)&.> nbrs

(I also put a similar line into vacate so that the tunnels stay ordered after a valve is vacated.)

I will try a very simple cutoff first. I will cut off if the flow established so far will not beat the best total to date even if I immediately turn on the biggest valve, then the next-biggest, and so on, till all valves are turned on. The fastest I could turn on the valves is one every other turn.

This will create a schedule of maximum improvement for each number of cycles left that will not change unless a valve is opened. This suggests that I compute the schedule only when a valve is opened.

NB. prep for cutoff: called after any valve opened, after connections & flows have been updated
NB. returns previous value
cutoffprep =: {{   NB. y is clock
NB. max you could get is highest for 2 clocks, then top-2-highest etc
(cutoffvec =: +/\ 2 # +/\ (<. -: 31 - y) {. \:~ flows) ] cutoffvec  NB. calculate new value and return old
}}

cutoff will use the cutoffvec calculated in cutoffprep.

NB. check for termination, whether early or not
cutoff =: {{   NB. y is clock,(flow from opened valves),(total flow so far)   result is 1 if branch should be pruned.  Updates best if branch completes
'clock flow total' =. y   NB. extract current state
if. best >: total + (flow&* + {&cutoffvec) 30 - clock do. 1 return. end.  NB. get max total if we continue flow and open valves quickly; prune if too small
best =: best >. total  NB. update best-so-far
clock = 30   NB. also prune when time expires
}}

All that remains is to call cutoffprep when a valve is opened. I do this immediately before & after the moves from the opened valve:

...
  restorecutoff =. cutoffprep clock   NB. establish new cutoff profile
  for_d. destsc do. mparms move d;dvalves end.  NB. try each move; don't allow a non-opener to go to anywhere we could have moved to directly
  cutoffvec =: restorecutoff  NB. Restore cutoff profile
...

Full of hope, I try the new version. It completes in about a second, and with the correct answer.

In Part 2, I have to train an elephant as a helper and the two of us will roam the tunnels simultaneously. That changes my view of the problem:

  • there will be 2 positions/times/exclusion lists
  • the valve state will become a global value
  • the times for the players must be kept as close together as possible to make cutoff more effective

Can I still simplify the connections by vacating rooms as I turn valves off? Yes, that will still work because vacating a room leaves its tunnel connections unchanged. If I vacate a room while the elephant is in it no harm will be done.

I am going to rewrite the whole program (except vacate which is unchanged). It will be an easy rewrite since the structure is unchanged. Now that I know the cutoff scheme is effective, I type happily.

best =: 0  NB. the final result: the best total flow found
openedflowtoend =: 0  NB. total flow contributed at end-of-problem by valves in 'opened'
MAXTIME =: 26  NB. number of cycles to run
cutoffvec =: (MAXTIME+1)$1e6   NB. cumulative max flow possible for each cycle
opened =: MAXTIME$0   NB. valves opened for each minute


NB. emulate the passage of time in the first player
time =: {{   NB. x is list of clock times  y is duration   result is new times (or empty to call for exit)
if. cutoff x =. (MAXTIME <. y + {. x) 0} x do. '' return. end.  NB. check for cutoff; if so, abort
x
}}
NB. restore to position before cutoffprep.  y is value returned from cutoffprep
cutoffrestore =: {{ 'openedflowtoend cutoffvec' =: y }}
NB. prep for cutoff: called after any valve opened, after connections & flows have been updated
NB. returns previous value
cutoffprep =: {{   NB. y is clock times
res =. openedflowtoend;cutoffvec  NB. previous state
NB. max you could get is highest 2 for 2 clocks, then top-2-highest etc
openedflowtoend =: +/ +/\ opened   NB. total contributed at end by opened valves
cutoffvec =: +/\ 2 # +/\ _2 +/\ ((MAXTIME+1) - <./ y) {. \:~ flows  NB. max flow available from opening 2 valves every other cycle
if. best < openedflowtoend do. bestopened =: opened end.
best =: best >. openedflowtoend  NB. extrapolated flow is legit - this way we don't have to mark time till MAXTIME
res
}}
NB. check for termination, whether early or not
cutoff =: {{   NB. y is clock times   result is 1 if branch should be pruned.  Updates best if branch completes
if. best >: openedflowtoend + (MAXTIME-<./y) { cutoffvec do. 1 return. end.  NB. cut off is possible
MAXTIME = <./ y  NB. if time not up, keep looking.  best was updated when the valve was opened
}}

NB. move to a new cell.  The move is made at the head of all lists
move =: {{  NB. x is (clock times; locations; exclusion lists)   y is (new location,transit time)
'moveto delay' =. y  NB. extract move, delay
'clock locs excls' =. x   NB. extract current state
if. '' -: clock =. clock time delay do. i. 0 0 return. end.   NB. Update clock, cut off if possible
locs =. moveto 0} locs   NB. move to new position
NB. To keep the clocks close together, make the move in the earliest clock
earlyx =. (i. <./) clock
clock =. earlyx |. clock [ locs =. earlyx |. locs [ excls =. earlyx |. excls  NB. Now we will make move in the head
if. ({.clock) >: MAXTIME-1 do. i. 0 0 return. end.  NB. if there's not enough time for earliest player to open a valve, cut off


valve =. {. locs  NB. the valve we are at
vflow =. valve { flows  NB. get the flow of the valve we have moved to
allownoopen =. vflow < >./ flows      NB. if we are standing at the largest flow, always open it
dests =. valve {:: nbrs  NB. extract list of neighbors
NB. first case: open the valve we are on and take it out of the connections
if. vflow do.  NB. special case of 1st node which might have no flow: don't open
  if. '' -: oclock =. clock time 1 do. i. 0 0 return. end.   NB. delay 1 minute for valve-opening delay, possibly cutting off
  NB. Open the valve and save all restorable state
  otime =. {. oclock  NB. cycle when valve starts running
  rc =. cutoffprep oclock [ rv =. vacate valve [ flows =: 0 valve} flows [ opened =: (vflow + ro =. otime { opened) otime} opened
  NB. move to this valve with 0 delay to move to other valves; clear exclusion list for this player
  (oclock;locs;<((<i. 0 2)) 0} excls) move valve,00
  NB. restore state before valve opening
  cutoffrestore rc [ flows =: vflow valve} flows [ nbrs =: (1{::rv) (0{::rv)} nbrs [ opened =: ro otime} opened
elseif. 0 = >./ clock do. vacate valve  NB. if valve has no flow, it has been vacated - except for room AA where we start
end.
NB. second case: don't open the valve
if. allownoopen do.
  NB. Create list of excluded dests and the times at which they become excluded - nodes that earlier paths will visit
  extimes =. extimes , MAXTIME [ 'exdests extimes' =. |: 0 {:: excls  NB. append high time for 'not found'
  NB. add to the exclusions this valve and any other valve we can reach, for use by descendants
  excls =. (< (, <./)/../ |: (valve,0) ,~ (dests +"1 (0,{. clock)) , 0 {:: excls) 0} excls  NB. keep only the earliest exclusion
  mparms =. clock;locs;<excls  NB. parms for loop
  for_d. dests do. if. (({.clock) + 1{d) < extimes {~ exdests i. {.d do. mparms move d end. end.  NB. apply exclusion
end.
NB. after we have exhausted our possibilities, set our time to max and let the others finish
((MAXTIME 0} clock);locs;<excls) move valve,0
i. 0 0
}}
NB. tear a valve out of the connections
vacate =: {{     NB. y is valve number to vacate;  result is (indexes on nbrs that changed);(previous values)
'dvalve ddist' =. |: y {:: nbrs   NB. indexes of valves to change, and distance from this valve to there
oldconns =. dvalve { nbrs  NB. save the old connections for restoration
newconns =. dvalve ,."1 +/~ ddist  NB. Each row gives connections to add to corresponding box of nbrs
newconns =. y (00 ,.~ 2 $"0 i.#dvalve)} newconns  NB. diagonals are reflexive and must be removed; change the dest to y, which also must be removed
newconns =. newconns <@((, <./)/../@:|:@:, >)"2 0 oldconns  NB. append new to old and take minimum of duplicate paths
newconns =. y ((~: {."1) # ])&.> newconns  NB. remove the now-dead links to y/reflexive
newconns =. (\:   flows {~ {."1)&.> newconns  NB. order largest values first
nbrs =: newconns dvalve} nbrs   NB. Install the new connections.  Leave y, as it is now orphaned
dvalve;<oldconns  NB. return info to restore what we did
}}

After fixing a bug (the next-to-last line in move was omitted), it works. The program runs for a couple of minutes: I guess alternating between the two players gives a much deeper region where thrashing can occur. I'm not going to look into it any more, though.

This was an interesting problem.

Revision

After sleeping on it another night I realize that I had another bug. I reasoned that if A, B, and C are mutually connected and I am moving from A to B without opening A's valve, I can put C onto the exclusion list, meaning that C must not be visited until a valve has been opened. The justification was that A could have moved to C quicker directly than by going to B first. That is true when all tunnels have delay 1, but after some vacating the delays have increased. It might be that A to C directly takes 3 minutes, but A to B and B to C each 1 minute so I must keep C alive.

That is easy to fix, by making the list of excluded nodes into a table of nodes, each with a time at which it should become excluded. Visiting the node before that time is still allowed. This change is included in the part-2 version above.