Advent of Code 2020 day 23

    Day 23 is one where the the obvious approach works fine for the small problem in part 1, but doesn't scale to the larger problem in part 2. But I think it's interesting to cover both, so this is a post of two parts.

    Part 1

    This uses an obvious representation for the circle of cups, a circular pointed list. This is an example of a zipper, a data structure with one element picked out as a focus. In this case, the focus of the pointed list is the current element. Functions like previous and next move the focus, and you can insert and delete elements from the list. This is a natural fit to the "circle of labelled things" in the problem.

    I needed to remove and re-insert three items, so I quickly whipped up takeRight and dropRight functions to help that.

    takeRight :: Int -> P.PointedList a -> [a]
    takeRight 0 _ = []
    takeRight n xs = (xs ^. P.focus):(takeRight (n - 1) $ P.next xs)
    
    dropRight :: Int -> P.PointedList a -> Maybe (P.PointedList a)
    dropRight 0 xs = Just xs
    dropRight n xxs = case (P.deleteRight xxs) of
      Just xs -> dropRight (n - 1) xs
      Nothing -> Nothing
    

    (Note the use of a lens ^. to get the focused element; see AoC 2019 day 23 for more use of lenses.)

    I also needed something to find valid destinations

    validDestination 0 max missing = validDestination max max missing
    validDestination n max missing
      | n `elem` missing = validDestination (n - 1) max missing
      | otherwise = n
    

    The main "play one step of the game" function is a fairly direct translation of the steps into code, inserting deleting and shifting as needed.

    playOne cups = P.next replacedAtCurrent
      where current = cups ^. P.focus
            held = takeRight 3 $ P.next cups
            shorter = fromJust $ dropRight 3 $ P.next cups
            destination = validDestination (current - 1) 9 held
            shorterAtDestination = fromJust $ P.find destination shorter
            replaced = foldr P.insertRight shorterAtDestination $ reverse held
            replacedAtCurrent = fromJust $ P.find current replaced
    

    Finally, a bit of plumbing to connect the input to the solution

    part1 nums = label $ playN cups 100
      where cups = fromJust $ P.fromList nums
    
    label cups = concatMap show $ tail $ takeRight (P.length cups) atOne
      where atOne = fromJust $ P.find 1 cups
    
    playN cups n = (iterate playOne cups) !! n
    

    Part 2

    This scaled up from ten cups and one hundred rounds to one million cups and ten million rounds. A pointed list wasn't going to cope. But as all the moves were "what's the next cup around the circle", a circular linked list would be enough, if I could make the pointer updates happen in a mutable structure (much like day 15). It's easier to work through the updates, and the intermediate pointers needed, with an example.

    I start with the cups after the first move of the example. Given the current cup, I can pick the first held cup and the first cup after the held ones.

    Cups before a move, showing the current, held, and after held pointers
    Before the move

    I can then splice out the held cups by making current point to after held. I then identify the destination cup (and the cup after the destination).

    Cups with the held ones removed, showing the destination and after destination pointers
    Removing the held-out cups

    Then I do the other pointer updates. Destination now points to held, and the last held cup points to the cup after the destination.

    Cups after a move, showing the changed pointers at the start and end of the held cups
    Reinserting the held-out cups

    If I neaten that up a bit, I show the result after the move, ready for current to move on one cup.

    The same cups as before,but rearranged neatly
    After the move

    Now I know what I'm doing, I just need to implement it. First, I need to decide on a data structure. Each cup has an index and a pointer to the next cup, and that's all. That suggests using a Vector, where the contents of cups[i] is the next cup from cup i. The state at in the last diagram above would be like this:

    Index 1 2 3 4 5 6 7 8 9
    3 5 2 6 4 7 8 9 1

    After cup 1 comes cup 3; after cup 2 comes cup 5; and so on.

    I also saw a suggestion (from somewhere) that keeps things a bit neater. Vectors have zero-based indexing, but I want to use one-based indexing for the pointers. I can repurpose cups[0] to be a pointer to the current cup.

    Index 0 1 2 3 4 5 6 7 8 9
    2 3 5 2 6 4 7 8 9 1

    After all of that, the code for each game step update follows from the description.

    gameStep :: Int -> V.MVector s Int -> ST s ()
    gameStep maxCups cups = 
      do  current <- V.read cups 0
          held1 <- V.read cups current
          held2 <- V.read cups held1
          held3 <- V.read cups held2
          afterHeld <- V.read cups held3
    
              -- close the loop, removing the held cups
          V.write cups current afterHeld 
    
          let destination = 
                validDestination (current - 1) maxCups [held1, held2, held3]
          afterDestination <- V.read cups destination
    
              -- make the held come after the destination
          V.write cups destination held1 
    
              -- make the end of the held point into the rest of the loop
          V.write cups held3 afterDestination
    
              -- advance current
          nextCup <- V.read cups current
              -- and store it
          V.write cups 0 nextCup
          return ()
    

    There's also a bit of setup to create the initial vector then run the game.

    runGame :: [Int] -> Int -> Int -> [Int]
    runGame seed cupsNeeded roundsNeeded =
      runST $ 
        do cups <- seedGame seed cupsNeeded
           gameLoop roundsNeeded cupsNeeded cups
           mapM (V.read cups) [0..cupsNeeded] 
    
    seedGame :: [Int] -> Int -> ST s (V.MVector s Int)
    seedGame seed cupsNeeded = 
      do  cups <- V.new (cupsNeeded + 1)
          let extended = seed ++ [10, 11..]
          forM_ [0..cupsNeeded] $ \i -> V.write cups i (i + 1)
          forM_ (zip seed $ tail extended) $ \(i, j) -> V.write cups i j
          V.write cups 0 (head seed)
          let end = if cupsNeeded > (length seed)
                    then cupsNeeded
                    else last seed
          V.write cups end (head seed)
          return cups
    
    gameLoop targetRound maxCups cups =
        do forM_ [1..targetRound]
                 (\_ -> gameStep maxCups cups)
           return ()
    

    It takes about 1.8 seconds to run both parts.

    Code

    You can find the code here or on GitLab.

    Neil Smith

    Read more posts by this author.