loading...
Cover image for Dict Moves in Python

Dict Moves in Python

rpalo profile image Ryan Palo Originally published at assertnotmagic.com ・3 min read

Quick tip time!

Today, I started the #100DaysOfCode challenge again (for the millionth time). I'm determined to actually succeed at this challenge, and I refuse to give up. This time, I'm using the Python Bytes Code Challenges website and their 100 days project suggestions. During today's challenge, I learned a neat little trick for working with dictionaries that I wanted to share.

The Challenge

The challenge is this: go through a dictionary of words, which is really just a copy of /usr/share/dict/words. Find the word that scores the highest in Scrabble, using these letter scores:

SCRABBLE_SCORES = [
  (1, "E A O I N R T L S U"),
  (2, "D G"),
  (3, "B C M P"),
  (4, "F H V W Y"), 
  (5, "K"), 
  (8, "J X"), 
  (10, "Q Z"),
]
LETTER_SCORES = {
    letter: score for score, letters in scrabble_scores
    for letter in letters.split()
}
# {"A": 1, "B": 3, "C": 3, "D": 2, ...}

The Issue

The issue is that I don't want to worry about whether or not there are any invalid characters in the input (for now at least). So if I look up the word "snoot!43@@@ ", right now, I'd prefer to see the score for SNOOT and then 0 points for the rest of the characters. I know there are a bunch of ways to do this, but the first way that popped into my head was to use a default of 0 (i.e. if you try to look up a character that's not in LETTER_SCORES, it returns zero instead of raising a KeyError.)

Enter DefaultDict

Luckily for us, Python comes with exactly the thing we need: a defaultdict, courtesy of the standard library's collections module. Its usage is reasonably straightforward: you supply the defaultdict with a class or function that constructs the default if the input isn't found. Let me show you.

from collections import defaultdict

zeros = defaultdict(int)
zeros["a"] = 1
zeros["b"] = zeros["definitely not in there"] + 4
print(zeros)
# => defaultdict(<int>, {"a": 1, "b": 4, "definitely not in there": 0})

Since the zeros dict can't find the "definitely not in there" key, it calls its default-maker function, int. Go ahead and open up your Python REPL and try just calling the int function with no arguments.

>>> int()
0

The int function, called with no arguments, returns 0 every time.

You can even create your own default-maker functions (and classes will work too)!

from random import choice

def confusing_default():
    possibles = ["1", 1, True, "banana"]
    return choice(possibles)

tricky_dict = defaultdict(confusing_default)
tricky_dict["Ryan"]
# => "banana"
tricky_dict["Python"]
# => True
tricky_dict["Why would you do this?"]
# => 1
tricky_dict
# => defaultdict(<confusing_default>, {"Ryan": "banana", "Python": True, "Why would you do this?": 1})

Often times, you can do things a little quicker with lambdas.

from random import randint

SCREAMING = defaultdict(lambda: "A")
for i in range(20):
    key = randint(0, 3)
    SCREAMING[key] += "A"
SCREAMING
# => defaultdict(<function <lambda> at 0x108707f28>, {0: 'AAAAAAAA', 1: 'AAAAAAA', 3: 'AAAAA', 2: 'AAAA'})

In fact, I actually think that using defaultdict(lambda: 0) is more explicit and less confusing than using defaultdict(int), as long as you're not creating huge numbers of these defaultdicts this way.

Upgrading to a DefaultDict

Now, finally, we're ready for the quick tip. Up above, I defined LETTER_SCORES as a plain, old Python dict. How do I get the default behaviors I want, quickly? One way is using the built-in dict.update() function, which merges two dictionaries.

FORGIVING_SCORES = defaultdict(lambda: 0)
FORGIVING_SCORES.update(LETTER_SCORES)

FORGIVING_SCORES["Q"]
# => 10

FORGIVING_SCORES["@"]
# => 0

Hooray!

Granted, this isn't a perfect solution, because the FORGIVING_SCORES defaultdict stores each of the invalid asks. It's probably OK if you're not expecting a huge number of invalid look-ups. If you are worried about staying space-efficient, though, it's probably better to do this:

score = LETTER_SCORES.get("@") or 0

The get function returns None if a KeyError occurs, and the or allows us to provide a sane default if the lookup goes bad. And everybody's happy!

EDIT 4/9/18: As Duke Lietu points out, you can do this even more simply by supplying get with a default:

score = LETTER_SCORES.get("@", 0)

Wrap Up

So, as it turns out, the entire reason for this blog post ended up not being the simplest solution to the initial problem. That being said, hopefully, you got to learn a bit more about how defaultdicts work and the dict.update method.

Thanks for reading!


Originally posted on assert_not magic?

Posted on Apr 8 '18 by:

rpalo profile

Ryan Palo

@rpalo

Ryan is an engineer in the Sacramento Area with a focus in Python, Ruby, and Rust. Bash/Python Exercism mentor. Coding, physics, calculus, music, woodworking. Message me on DEV!

Discussion

markdown guide
 

You could also just use dict.get(letter, 0).

There's also no need to use "D G" and then .split(), you could just do:

SCRABBLE_SCORES = [
  (1, "EAOINRTLSU"),
  #... 
]

LETTER_SCORES = {
    letter: score for score, letters in scrabble_scores
    for letter in letters
}

#...

i.e.:

>>> for letter in "abc": print(letter)
...
a
b
c
 

Hi! Thanks for the .get(letter, 0) tip. I've added it to the post, since I definitely agree that that's the best solution.

As far as the LETTER_SCORES variable goes, that was actually just provided as part of the setup of the problem, so I didn't bother to change that. You'll have to submit a pull request on the original repo :)

Thanks again for reaching out, though. I always love to learn that thing that makes my code that much cleaner!

 

Nice writeup. I have been writing python for 15+ years, but I never ran int(). Thanks for sharing, and good article.