Advent of Code 2024 day 9

Day 9 was the first challenge that needed a real change of representation from part 1 to part 2.

Reading the input is just opening the file and converting each character to a number.

let diskMap = fmap digitToInt text

That's the same for both parts.

Part 1

This uses a block-based representation of the disk. I keep track of the files and the free spaces. The free space is a Set of free block numbers; the files are a Map from block number to the file ID in that block.

type Disk = M.IntMap Int
type Free = S.IntSet

First, I expand the disk map into the initial block representation. I walk over the elements in the disk map, building up the disk and free as I go: that's a fold over the disk map.

Each step in the fold needs to know a few things:

  • is the current element of the map a file (or free space)?
  • what is the current block?
  • what is the current file ID?
  • what's the disk and free found so far?

That means I pass a 5-tuple around to keep track of these things. There's a lot to keep track of, but the expandMapItem function is mostly plumbing.

expand :: [Int] -> (Disk, Free)
expand diskMap = (disk, free)
  where (_, _, _, disk, free) = foldl' expandMapItem (True, 0, 0, M.empty, S.empty) diskMap

expandMapItem :: (Bool, Int, Int, Disk, Free) -> Int -> (Bool, Int, Int, Disk, Free)
expandMapItem (True, pos, fileID, disk, free) size =
  (False, pos + size, fileID + 1, disk', free)
  where
    fileExtent = take size $ zip [pos..] (repeat fileID)
    file = M.fromList fileExtent
    disk' = M.union disk file
expandMapItem (False, pos, fileID, disk, free) size =
  (True, pos + size, fileID, disk, free')
  where
    gap = S.fromList $ take size [pos..]
    free' = S.union free gap
 

For my own peace of mind, I have a little function that shows the layout of the disk. I can compare this with the examples in the problem description.

showDiskFree :: Disk -> Free -> String
showDiskFree disk free = showDisk disk ++ "\n" ++ showFree free

showDisk :: Disk -> String
showDisk disk = [showBlock i | i <- [0..pMax]]
  where (pMax, _) = M.findMax disk
        showBlock i = maybe '.' intToDigit $ M.lookup i disk

showFree :: Free -> String  
showFree free = [if S.member i free then '+' else '.' | i <- [0..pMax]]
  where pMax = S.findMax free

Once I've got the disk map in the block-based form I want, it's time to shuffle blocks!

First, I need to work out when to stop. The disk is packed when the highest-location file block is before the lowest-location free block.

ispackedBlock :: (Disk, Free) -> Bool
ispackedBlock (disk, free) = dMax < fMin
  where (dMax, _) = M.findMax disk
        fMin = S.findMin free

I pack one block (the highest location) by finding its location, finding the location of the smallest free block, and swapping them.

packBlocksStep :: (Disk, Free) -> (Disk, Free)
packBlocksStep (disk, free) 
  | ispackedBlock (disk, free) = (disk, free)
  | otherwise = (M.insert to fID disk1, S.insert from free1)
  where ((from, fID), disk1) = M.deleteFindMax disk
        (to, free1) = S.deleteFindMin free

I pack all the blocks by calling iterate packBlockStep (disk, free), pulling out the first arrangment that isPackedBlock.

packBlocks :: (Disk, Free) -> (Disk, Free)
packBlocks (disk, free) = head $ dropWhile (not . ispackedBlock) $ iterate packBlocksStep (disk, free)

Calculating the checksum follows the definition in the task.

checksum :: Disk -> Int
checksum disk = sum $ fmap (uncurry (*)) $ M.toAscList disk

Part 2

Part 2 operates on whole files rather than blocks, so I need a representation at that level. I settled in having a list of Region, with each Region being either Free or Used.

data Region = Free Int -- size
            | Used Int Int -- size, fileID
            deriving (Show, Eq)

type RDisk = [Region] -- key is start of region

I define a couple of "getter" utility functions.

fileID :: Region -> Int
fileID (Used _ fID) = fID
fileID _ = -1

freeSize :: Region -> Int
freeSize (Free size) = size
freeSize _ = 0

Converting the disk map to the region-based form follows a similar pattern to the previous version of expand.

expand :: [Int] -> RDisk
expand diskMap = reverse disk
  where (_, _, _, disk) = foldl' expandRegion (True, 0, 0, []) diskMap

expandRegion :: (Bool, Int, Int, RDisk) -> Int -> (Bool, Int, Int, RDisk)
expandRegion (True, pos, fID, disk) size =
  (False, pos + size, fID + 1, (Used size fID) : disk)
expandRegion (False, pos, fID, disk) 0 =
  (True, pos, fID, disk)
expandRegion (False, pos, fID, disk) size =
  (True, pos + size, fID, (Free size) : disk)

Note that I don't create zero-sized free spaces.

I also need the same block-based representation for part 1, and for calculating the checksum. That's handled by toBlocks.

toBlocks :: RDisk -> (Disk, Free)
toBlocks rdisk = (disk, free)
  where (_, disk, free) = foldl' toBlock (0, M.empty, S.empty) rdisk

toBlock :: (Int, Disk, Free) -> Region -> (Int, Disk, Free)
toBlock (pos, disk, free) (Free size) = (pos + size, disk, free')
  where
    gap = S.fromList $ take size [pos..]
    free' = S.union free gap
toBlock (pos, disk, free) (Used size fileID) = (pos + size, disk', free)
  where
    fileExtent = take size $ zip [pos..] (repeat fileID)
    file = M.fromList fileExtent
    disk' = M.union disk file    

Now I've got the region-based representation, time to pack files. Following the puzzle definition, I work in decreasing order of file ID, making one attempt to pack each file.

packFiles :: RDisk -> RDisk
packFiles disk = packBelow maxID disk
  where maxID = maximum $ fmap fileID disk

packBelow :: Int -> RDisk -> RDisk
packBelow _ [] = []
packBelow 0 disk = disk
packBelow fid disk = packBelow (fid - 1) disk'
  where disk' = tidy $ packFile fid disk

packFile does the work. I need to identify two regions: the file that's moving and the free space it's moving into. Once I've got those two, I can split the list of regions into three parts: the chunk before the free space, the chunk between the free space and the file, and the chunk after the file.

From the puzzle description, you can see the chunks in the diagram below.

0099.111...2...333.44.5555.6666.777.8888..
       |^^^|                   |^^^|
 prefix            middle            suffix

Diagram showing how the regions are split

I use span to split the list into the prefix-and-middle and suffix. findFree finds the gap to put the file in, if it exists. Once I've got all the bits, I move the file and add the new Free chunks as needed.

packFile :: Int -> RDisk -> RDisk
packFile fid disk
  | isNothing gap = disk
  | otherwise = prefix ++ [Used fSize fid, Free (gapSize - fSize)] ++ mid ++ [Free fSize] ++ suffix
  where
    (prefixMid, ((Used fSize _) : suffix)) = span ((/= fid) . fileID) disk
    gap = findFree fSize prefixMid
    (prefix, Free gapSize, mid) = fromJust gap

findFree :: Int -> RDisk -> Maybe (RDisk, Region, RDisk)
findFree size disk
  | null suffix = Nothing
  | otherwise = Just (prefix, head suffix, tail suffix)
  where (prefix, suffix) = break ((>= size) . freeSize) disk

The final utility is tidy, which removes zero-sized Free regions, and combines adjacent Free regions.

tidy :: RDisk -> RDisk
tidy = foldr tidyRegion []

tidyRegion :: Region -> RDisk -> RDisk
tidyRegion (Free 0) rdisk = rdisk
tidyRegion (Free size) ((Free size1) : rdisk) = tidyRegion (Free (size + size1)) rdisk
tidyRegion region rdisk = region : rdisk

This is quite slow, but I'm doing a lot of list copying with all the splitting and joining. I'm sure it would be much faster using a Sequence, or even using another Map-based representation. But it's not so slow that I can be bothered to make the change.

Code

You can get the code from my locally-hosted Git repo, or from Codeberg.