Unexpected Random behavior in HashMap

Hello!

I'm new to rust, and I'm learning it through Advent of code. Day 6 involves the counting of an exponentially growing number of fish. The fish have a timer that ticks down, and when it's 0 it reverts to 6 and creates a new fish with a timer of 8.

I've come up with a solution that uses a map. However, I was rather stumped by random behavior that appears when a Hashmap is used, but the code works with a BTreeMap. Below is my (bad) code with a BTreeMap (feel free to offer constructive criticism).

use std::{collections::BTreeMap, collections::HashMap, fs};

fn main() {
    let filename = "src/data.txt";
    let contents = fs::read_to_string(filename).expect("Something went wrong reading the file");
    let state: Vec<i64> = contents
        .split(",")
        .map(|fish| fish.trim().parse().unwrap())
        .collect();
    // For some reason, Hash map has strange random behaviors and doesn't work?
    let mut map: BTreeMap<i64, i64> = BTreeMap::new();

    //Create initial hash map based on contents of file
    for i in state {
        if map.contains_key(&i) {
            let old = map.get_mut(&i).unwrap();
            *old += 1;
        } else {
            map.insert(i, 1);
        }
    }

    // Laternfish in the ocean reproduce every 6 days. A new laternfish reproduces after 8 days.
    //How many lanternfish are there after 80 and 256 days?
    for i in 0..256 {
        let mut temp_map: BTreeMap<i64, i64> = BTreeMap::new();
        for fish_days in map.keys() {
            if *fish_days == 0 {
                // If fish has timer of zero days, set back to 6
                if temp_map.contains_key(&6) {
                    let old_6 = temp_map.get_mut(&6).unwrap();
                    *old_6 = *map.get(&6).unwrap() + *old_6;
                } else {
                    temp_map.insert(6, *map.get(fish_days).unwrap());
                }
                // Also make a new fish with a timer of 8
                if temp_map.contains_key(&8) {
                    let old_8 = temp_map.get_mut(&8).unwrap();
                    *old_8 = *map.get(&8).unwrap() + *old_8;
                } else {
                    temp_map.insert(8, *map.get(fish_days).unwrap());
                }
            } else {
                // Insert the fish into the new map with a decremented timer
                if temp_map.contains_key(&(*fish_days - 1)) {
                    let old = temp_map.get_mut(&(fish_days - 1)).unwrap();
                    *old = *map.get(fish_days).unwrap() + *old;
                } else {
                    temp_map.insert(fish_days - 1, *map.get(fish_days).unwrap());
                }
            }
        }
        map = temp_map.clone();
        if i == 79 {
            println!(
                "There are {} Fish after {} days",
                map.values().sum::<i64>(),
                i + 1
            );
        }
        // println!("Day {}: {}",i+1, rtn)
    }
    println!(
        "There are {} Fish after {} days",
        map.values().sum::<i64>(),
        256
    );
}

where the data.txt is a single line of 3,4,3,1,2. The expected correct values are 5934 for 80 days and 26984457539 for 256 days. Replacing the BTree map with HashMap results in random results. When debugging, it seems as though value updates will randomly drop or duplicate.

The below snippet from the documentation seems to apply

It is a logic error for a key to be modified in such a way that the key’s hash, as determined by the [ Hash ] trait, or its equality, as determined by the [ Eq ] trait, changes while it is in the map. This is normally only possible through [ Cell ], [ RefCell ] global state, I/O, or unsafe code. The behavior resulting from such a logic error is not specified, but will not result in undefined behavior. This could include panics, incorrect results, aborts, memory leaks, and non-termination.

But I don't see how this would apply, because I'm putting the keys into a new map (unless it does, due to my still poor understanding of pointers). If it does apply, how would I modify my code to decrement the key in the new map without changing the key in the old map?

I'm not sure what your issue is, but I don't think it's related to the section of HashMap's documentation you quote. That is about mutating keys while in a map, which is hard to do by accident and you don't appear to be doing it here. (Also note BTreeMap has almost the same disclaimer, except with regard to ordering instead of hash.) It's more likely that you have an algorithmic error. Most likely, you are accidentally relying on iteration order (BTreeMap's is predictable; HashMap's is random).

It looks to me as if your algorithm assumes the keys will be returned in ascending order. This will be true for BTreeMap but not true for HashMap ( the order will be random ).

Interesting. I'll look more into this, although I never intended to rely on iteration order

The new count for fish_days == 6 should the old count for 7 plus the old count for 0. Let's see what your code does.

if *fish_days == 0 {
    // If fish has timer of zero days, set back to 6
    if temp_map.contains_key(&6) {
        let old_6 = temp_map.get_mut(&6).unwrap();                   // B
        *old_6 = *map.get(&6).unwrap() + *old_6;                     // B
    } else {
        temp_map.insert(6, *map.get(fish_days).unwrap());            // A
    }
// ...
} else {
    // Insert the fish into the new map with a decremented timer
    if temp_map.contains_key(&(*fish_days - 1)) {
        let old = temp_map.get_mut(&(fish_days - 1)).unwrap();        // A
        *old = *map.get(fish_days).unwrap() + *old;                   // A
    } else {
        temp_map.insert(fish_days - 1, *map.get(fish_days).unwrap()); // B
    }
}
  • Case A: process fish_days == 0 before fish_days == 7
    • process fish_days == 0
      • temp_map won't contain a count for 6 yet
      • So we insert the old count for 0
    • process fish_days == 7
      • temp_map may have a count for 6 already
      • If so, add to it the old count for 7
      • If not, insert the old count for 7

Seems ok.

  • Case B: The other way around
    • process fish_days == 7
      • temp_map won't contain a count for 6 yet
      • So we insert the old count for 7
    • process fish_days == 0
      • temp_map may have a count for 6 already
      • If so, add to it the old count for 6 :boom:
      • If not, insert the old count for 0

Whoops, sometimes we get the old count for 7 plus the old count for 6. That's your bug, and it's dependent on the order the keys are iterated.


Incidentally, I suggest checking out the Entry API.

3 Likes

Argh! this seems to be the problem. Thank you

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.