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 defaultdict
s work and the dict.update
method.
Thanks for reading!
Originally posted on assert_not magic?
Top comments (3)
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:i.e.:
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.