Advanced collections

Day 23: Exercise Solutions

Python Guru with a screen instead of a face, typing on a computer keyboard with a dark blue background to match the day 23 image.

Here are our solutions for the day 23 exercises in the 30 Days of Python series. Make sure you try the exercises yourself before checking out the solutions!

1) Write a generator that generates prime numbers in a specified range.

I'm going to be using this solution from the day 8 exercise as a starting point, since the logic is going to be almost entirely the same:

for dividend in range(2, 101):
    for divisor in range(2, dividend):
        if dividend % divisor == 0:
            break
    else:
        print(dividend)

If you're not sure about why this works, I'd recommend looking at the original exercise walkthough.

Building on our earlier solution, the first thing we need to to create a function and place all of our code inside of it.

def gen_primes(limit):
    for dividend in range(2, limit + 1):
        for divisor in range(2, dividend):
            if dividend % divisor == 0:
                break
        else:
            print(dividend)

Here I've added a parameter called limit which is going determine where our generator ultimately stops. It determines the highest number we're going to check.

With that, we're almost done. Now we just need to turn our function into a generator by using the yield keyword. We're going to put this in the else branch of the inner for loop, instead of printing the dividend.

def gen_primes(limit):
    for dividend in range(2, limit + 1):
        for divisor in range(2, dividend):
            if dividend % divisor == 0:
                break
        else:
            yield dividend

Now when we call our function we get back a generator iterator:

def gen_primes(limit):
    for dividend in range(2, limit + 1):
        for divisor in range(2, dividend):
            if dividend % divisor == 0:
                break
        else:
            yield dividend

primes = gen_primes(101)  # <generator object gen_primes at 0x7f02ca556c80>

And we can use this generator iterator to retrieve primes one at a time using the next function:

...

primes = gen_primes(101)  # <generator object gen_primes at 0x7f02ca556c80>

print(next(primes))  # 2
print(next(primes))  # 3
print(next(primes))  # 5

2)  Below we have an example where map is being used to process names in a list. Rewrite this code using a generator expression.

names = [" rick", " MORTY  ", "beth ", "Summer", "jerRy    "]

names = map(lambda name: name.strip().title(), names)

The generator expression syntax is just like the comprehensions we've been writing before. The only difference is that we use regular round parentheses rather than square brackets or curly braces.

names = [" rick", " MORTY  ", "beth ", "Summer", "jerRy    "]

names = (name.strip().split() for name in names)

For cases like this, where we're forced to use a lambda expression, generator expressions generally end up being shorter and more readable.

3) Write a small program to deal cards for a game of Texas Hold'em.

The deal order is slightly complicated, but there is a detailed explanation in the day 23 post, so check back there if you need a reminder.

First things first, we need to create our deck of cards. There are several ways we can do this.

One of option you may have considered it something like this:

ranks = (2, 3, 4, 5, 6, 7, 8, 9, 10, "jack", "queen", "king", "ace")
suits = ("clubs", "diamonds", "hearts", "spades")

cards = []

for rank in ranks:
    for suit in suits:
        cards.append((rank, suit))

We can verify that all of the cards are there by printing cards and printing len(deck), which should be 52 for a standard 52 card deck of cards.

Since we're creating a new list from existing collections, it's also possible to do this with comprehensions, like this:

ranks = (2, 3, 4, 5, 6, 7, 8, 9, 10, "jack", "queen", "king", "ace")
suits = ("clubs", "diamonds", "hearts", "spades")

cards = [(rank, suit) for suit in suits for rank in ranks]

We can verify once again that we get what we want by printing the contents of cards and the length of the collection.

However, instead of writing this comprehension, we can call upon the itertools.product, which does very much the same thing, and it gives us back an iterator.

import itertools

ranks = (2, 3, 4, 5, 6, 7, 8, 9, 10, "jack", "queen", "king", "ace")
suits = ("clubs", "diamonds", "hearts", "spades")

cards = list(itertools.product(ranks, suits))

Any of these options are perfectly valid.

Now we should make a function to shuffle our deck and return a new iterator so that we can draw cards from it. To shuffle the deck we're going to use the shuffle function in the random module.

import itertools
import random

def shuffle_deck(cards):
    deck = list(cards)
    random.shuffle(deck)

    return iter(deck)

ranks = (2, 3, 4, 5, 6, 7, 8, 9, 10, "jack", "queen", "king", "ace")
suits = ("clubs", "diamonds", "hearts", "spades")

cards = list(itertools.product(ranks, suits))

There are a few things involved in this shuffle_deck function.

We accept a set of cards as a parameter, which we used to compose a deck. This deck is a list for good reason: the random.shuffle function takes in a sequence, and is going to perform an in-place shuffle. This means we need a mutable sequence type to work with.

We then shuffle the deck by passing it to the random.shuffle function. Once this is done return an iterator for the shuffled deck which we created by passing the deck to the iter function.

Next we need to tackle a bit of user interaction, since we need to know how many players the user wants to play with.

I'm actually going to define a function in this case, because I want to handle any invalid input, and I want to reprompt the user if they provide invalid input. I also want to put in a check to make sure the number of players is within the range specified in the exercise (2 - 10).

def get_players():
    while True:
        number_of_players = input("How many players are there? ").strip()

        try:
            number_of_players = int(number_of_players)
        except ValueError:
            print("You must enter an integer.")
        else:
            if number_of_players in range(2, 11):
                return number_of_players
            elif number_of_players < 2:
                print("You must have at least 2 players.")
            else:
                print("You can have a maximum of 10 players.")

Give that a try and make sure everything works.

Assuming there are no problems, it's time to make our functions to deal the cards. I think it makes sense to have a function to act as the dealer, which will shuffle the cards, and delegate to specialised functions dealing to the players, and dealing cards to the table.

Having a dealer function means we can easily play multiple hands.

Let's define all our functions and build them as we go:

import itertools
import random

def deal(cards, number_of_players):
    deck = shuffle_deck(cards)

    deal_to_players(deck, number_of_players)
    deal_to_table(deck)

def deal_to_players(deck, number_of_players):
    pass

def deal_to_table(deck)
    pass

def get_players():
    while True:
        number_of_players = input("How many players are there? ").strip()

        try:
            number_of_players = int(number_of_players)
        except ValueError:
            print("You must enter an integer.")
        else:
            if number_of_players in range(2, 11):
                return number_of_players
            elif number_of_players < 2:
                print("You must have at least 2 players.")
            else:
                print("You can have a maximum of 10 players.")

def shuffle_deck(cards):
    deck = list(cards)
    random.shuffle(deck)

    return iter(deck)

ranks = (2, 3, 4, 5, 6, 7, 8, 9, 10, "jack", "queen", "king", "ace")
suits = ("clubs", "diamonds", "hearts", "spades")

cards = list(itertools.product(ranks, suits))

I've filled out the deal function already, since it's fairly simple. It just takes in the cards our decks are composed of, and the number of players playing in this round.

In the function body, we construct our shuffled deck from the defined cards, and then we call the other deal functions to do all the hard work.

Let's turn to deal_to_players. First things first, we have to figure out how to distribute the cards so that each player gets one card in turn, and each player ends up with two cards.

For this, I'm going to use a pair of list comprehensions like so:

def deal_to_players(deck, number_of_players):
    first_cards = [next(deck)  for _ in  range(number_of_players)]
    second_cards = [next(deck)  for _ in  range(number_of_players)]

It's important that we use a list comprehension here and not a generator expression, because we actually want these cards to be drawn now. You'll see why this is important in a moment.

Once we have our first and second cards in this lists, we can zip them together to create a hand for each player.

def deal_to_players(deck, number_of_players):
    first_cards = [next(deck)  for _ in  range(number_of_players)]
    second_cards = [next(deck)  for _ in  range(number_of_players)]

    hands = zip(first_cards, second_cards)

This zip step makes it very important that we don't have lazy collections assigned to first_cards and second_cards. It's not that zip can't handle them—it can—but we have to think about what happens when we use this zip object.

When we ask for an item fromzip, what does it do?

Well, first it asks for a card from first_cards, the it asks for a card from second_cards, it bundles them up into a tuple and it gives it to us. But first_cards and second_cards are both just asking for a card from deck to give zip the values it wants, and they're both drawing from the same deck iterator.

This means that each play will be dealt two cards in a row, rather than each player being dealt one card, and then another after each player has been dealt a card. This is a subtle point, but it's important that we be careful about things like this when working with lazy types.

Luckily, using a list comprehension solves this issue, because the cards are requested from the deck when we're creating first_cards, and again when we request second_cards.  By time we get to the zip step, we already have the cards; we're just waiting to bundle them up.

Once we have the hands for the players, we just need to print them.

def deal_to_players(deck, number_of_players):
    first_cards = [next(deck)  for _ in  range(number_of_players)]
    second_cards = [next(deck)  for _ in  range(number_of_players)]

    hands = zip(first_cards, second_cards)

    print()

    for i,  (first_card, second_card)  in  enumerate(hands, start=1):
        print(f"Player {i} was dealt: {first_card}, {second_card}")

    print()

Now let's deal with the final part of the app, which is dealing the cards to the table. This is comparatively simple, because we don't need to worry about the number of players, or anything like that.

First, we need to burn a card, and then deal three cards to the table in one go:

def deal_to_table(deck):
    next(deck)  # burn
    flop = ', '.join(str(next(deck))  for _ in  range(3))
    print(f"The flop: {flop}")

One thing to watch out for here is that join requires an iterable containing strings. We therefore have to convert the tuple we get back from next to a string using str.

From here, it's just a case of burning and dealing a single card for the next two steps of the deal process.

def deal_to_table(deck):
    next(deck)  # burn
    flop = ', '.join(str(next(deck))  for _ in  range(3))
    print(f"The flop: {flop}")

    next(deck)  # burn
    print(f"The turn: {next(deck)}")

    next(deck)  # burn
    print(f"The river: {next(deck)}")
    print()

With that, we just need to call deal and make sure that everything runs. Make sure your can call your deal function multiple times!

import itertools
import random

def deal(cards, number_of_players):
    deck = shuffle_deck(cards)

    deal_to_players(deck, number_of_players)
    deal_to_table(deck)

def deal_to_players(deck, number_of_players):
    first_cards = [next(deck)  for _ in  range(number_of_players)]
    second_cards = [next(deck)  for _ in  range(number_of_players)]

    hands = zip(first_cards, second_cards)

    print()

    for i,  (first_card, second_card)  in  enumerate(hands, start=1):
        print(f"Player {i} was dealt: {first_card}, {second_card}")

    print()

def deal_to_table(deck):
    next(deck)  # burn
    flop = ', '.join(str(next(deck))  for _ in  range(3))
    print(f"The flop: {flop}")

    next(deck)  # burn
    print(f"The turn: {next(deck)}")

    next(deck)  # burn
    print(f"The river: {next(deck)}")
    print()

def get_players():
    while True:
        number_of_players = input("How many players are there? ").strip()

        try:
            number_of_players = int(number_of_players)
        except ValueError:
            print("You must enter an integer.")
        else:
            if number_of_players in range(2, 11):
                return number_of_players
            elif number_of_players < 2:
                print("You must have at least 2 players.")
            else:
                print("You can have a maximum of 10 players.")

def shuffle_deck(cards):
    deck = list(cards)
    random.shuffle(deck)

    return iter(deck)

ranks = (2, 3, 4, 5, 6, 7, 8, 9, 10, "jack", "queen", "king", "ace")
suits = ("clubs", "diamonds", "hearts", "spades")

cards = list(itertools.product(ranks, suits))

deal(cards, get_players())

This was a long exercise, with some tricky components, but I hope you were able to manage. Feel free to ask any questions on our Discord server if you get stuck.