Lab 4 Solutions

Solution Files

Lab Checkoff Questions

  • What are the two restrictions for a key in a dictionary?
  • A key must be unique and a key cannot be a list or a dictionary
  • Why is it important not to violate the abstraction barrier?
  • They separate parts of a program so that changes in one part won't affect another
  • Describe the tree's constructor function
  • tree(label, branches=[]) returns a tree object with a root node called label and the specified branches
  • Attendance

    You need to submit the lab problems in addition to attending to get credit for lab.

    If you miss lab for a good reason (such as sickness or a scheduling conflict) or you don't get checked in for some reason, email cs61a@berkeley.edu within one week to receive attendance credit.

    Sequences

    Sequences are ordered collections of values that support element-selection and have length. We've worked with lists, but other Python types are also sequences, including strings.

    Let us go through an example of some actions we can do with strings.

    >>> x = 'Hello there Oski!'
    >>> x
    'Hello there Oski!'
    >>> len(x)
    17
    >>> x[6:]
    'there Oski!'
    >>> x[::-1]
    '!iksO ereht olleH'

    Since strings are sequences, we can do with strings many of the same things that we can do to lists. We can even loop through a string just like we can with a list:

    >>> x = 'I am not Oski.'
    >>> vowel_count = 0
    >>> for i in range(len(x)):
    ...     if x[i] in 'aeiou':
    ...         vowel_count += 1
    >>> vowel_count
    5

    A for statement executes code for each element of a sequence, such as a list or range. Each time the code is executed, the name right after for is bound to a different element of the sequence.

    for <name> in <expression>:
        <suite>

    First, <expression> is evaluated. It must evaluate to a sequence. Then, for each element in the sequence in order,

    1. <name> is bound to the element.
    2. <suite> is executed.

    Here is an example:

    for x in [-1, 4, 2, 0, 5]:
        print("Current elem:", x)

    This would display the following:

    Current elem: -1
    Current elem: 4
    Current elem: 2
    Current elem: 0
    Current elem: 5

    A dictionary contains key-value pairs and allows the values to be looked up by their key using square brackets. Each key must be unique.

    >>> d = {2: 4, 'two': ['four'], (1, 1): 4}
    >>> d[2]
    4
    >>> d['two']
    ['four']
    >>> d[(1, 1)]
    4

    The sequence of keys or values or key-value pairs can be accessed using .keys() or .values() or .items().

    >>> for k in d.keys():
    ...     print(k)
    ...
    2
    two
    (1, 1)
    >>> for v in d.values():
    ...     print(v)
    ...
    4
    ['four']
    4
    >>> for k, v in d.items():
    ...     print(k, v)
    ...
    2 4
    two ['four']
    (1, 1) 4

    By default, iterating through a dictionary iterates through its keys.

    >>> for x in d:
    ...     print(x)
    ...
    2
    two
    (1, 1)

    You can check whether a dictionary contains a key using in:

    >>> 'two' in d
    True
    >>> 4 in d
    False

    Attempting to access a key that does not exist in a dictionary will cause an error. You can use .get(<key>, <default>), which returns the value corresponding to <key> in a dictionary or, if it doesn't exist, returns <default>.

    >>> d[3]
    KeyError: 3
    >>> d.get(3, "fun")
    "fun"
    >>> d.get(2, "fun")
    4

    The contents of a dictionary can be modified using =:

    >>> d
    {2: 4, 'two': ['four'], (1, 1): 4}
    >>> d[(1, 1)] = "61a"
    >>> d[(1, 1)]
    '61a'

    A dictionary comprehension is an expression that evaluates to a new dictionary.

    >>> {3*x: 3*x + 1 for x in range(2, 5)}
    {6: 7, 9: 10, 12: 13}

    Tree Recursion

    A tree recursive function is a recursive function that makes more than one call to itself, resulting in a tree-like series of calls.

    For example, this is the Virahanka-Fibonacci sequence: 0, 1, 1, 2, 3, 5, 8, 13, ....

    Each term is the sum of the previous two terms. This tree-recursive function calculates the nth Virahanka-Fibonacci number.

    def virfib(n):
        if n == 0 or n == 1:
            return n
        return virfib(n - 1) + virfib(n - 2)

    Calling virfib(6) results in a call structure that resembles an upside-down tree (where f is virfib):

    Virahanka-Fibonacci tree.

    Each recursive call f(i) makes a call to f(i-1) and a call to f(i-2). Whenever we reach an f(0) or f(1) call, we can directly return 0 or 1 without making more recursive calls. These calls are our base cases.

    A base case returns an answer without depending on the results of other calls. Once we reach a base case, we can go back and answer the recursive calls that led to the base case.

    As we will see, tree recursion is often effective for problems with branching choices. In these problems, you make a recursive call for each branching choice.

    Data Abstraction

    A data abstraction is a set of functions that compose and decompose compound values. One function called the constructor puts together two or more parts into a whole (such as a rational number; also known as a fraction), and other functions called selectors return parts of that whole (such as the numerator or denominator).

    def rational(n, d):
        "Return a fraction n / d for integers n and d."
    
    def numer(r):
        "Return the numerator of rational number r."
    
    def denom(r):
        "Return the denominator of rational number r."

    Crucially, one can use a data abstraction without knowing how these functions are implemented. For example, we (humans) can verify that mul_rationals is implemented correctly just by knowing what rational, numer, and denom do without knowing how they are implemented.

    def mul_rationals(r1, r2):
        "Return the rational number r1 * r2."
        return rational(numer(r1) * numer(r2), denom(r1) * denom(r2))

    However, for Python to run the program, the data abstraction requires an implementation. Using knowledge of the implementation crosses the abstraction barrier, which separates the part of a program that depends on the implementation of the data abstraction from the part that does not. A well-written program typically will minimize the amount of code that depends on the implementation so that the implementation can be changed later on without requiring much code to be rewritten.

    When using a data abstraction that has been provided, write your program so that it will still be correct even if the implementation of the data abstraction changes.

    Tree

    A tree is a data structure that represents a hierarchy of information. A file system is a good example of a tree structure. For example, within your cs61a folder, you have folders separating your projects, lab assignments, and homework. The next level is folders that separate different assignments, hw01, lab01, hog, etc., and inside those are the files themselves, including the starter files and ok. Below is an incomplete diagram of what your cs61a directory might look like.

    cs61a_tree

    As you can see, unlike trees in nature, the tree abstract data type is drawn with the root at the top and the leaves at the bottom.

    For a tree t:

    • Its root label can be any value, and label(t) returns it.
    • Its branches are trees, and branches(t) returns a list of branches.
    • An identical tree can be constructed with tree(label(t), branches(t)).
    • You can call functions that take trees as arguments, such as is_leaf(t).
    • That's how you work with trees. No t == x or t[0] or x in t or list(t), etc.
    • There's no way to change a tree (that doesn't violate an abstraction barrier).

    Here's an example tree t1, for which its branch branches(t1)[1] is t2.

    t2 = tree(5, [tree(6), tree(7)])
    t1 = tree(3, [tree(4), t2])

    Example Tree

    A path is a sequence of trees in which each is the parent of the next.

    Our tree abstract data type consists of a root and a list of its branches. To create a tree and access its root value and branches, use the following interface of constructor and selectors:

    • Constructor

      • tree(label, branches=[]): creates a tree object with the given label value at its root node and list of branches. Notice that the second argument to this constructor, branches, is optional — if you want to make a tree with no branches, leave this argument empty.
    • Selectors

      • label(tree): returns the value in the root node of tree.
      • branches(tree): returns the list of branches of the given tree (each of which is also a tree)
    • Convenience function

      • is_leaf(tree): returns True if tree's list of branches is empty, and False otherwise.

    For example, the tree generated by

    number_tree = tree(1,
             [tree(2),
              tree(3,
                   [tree(4),
                    tree(5)]),
              tree(6,
                   [tree(7)])])

    would look like this:

       1
     / | \
    2  3  6
      / \  \
     4   5  7

    To extract the number 3 from this tree, which is the label of the root of its second branch, we would do this:

    label(branches(number_tree)[1])

    The print_tree function prints out a tree in a human-readable form. The exact form follows the pattern illustrated above, where the root is unindented, and each of its branches is indented one level further.

    def print_tree(t, indent=0):
        """Print a representation of this tree in which each node is
        indented by two spaces times its depth from the root.
    
        >>> print_tree(tree(1))
        1
        >>> print_tree(tree(1, [tree(2)]))
        1
          2
        >>> numbers = tree(1, [tree(2), tree(3, [tree(4), tree(5)]), tree(6, [tree(7)])])
        >>> print_tree(numbers)
        1
          2
          3
            4
            5
          6
            7
        """
        print('  ' * indent + str(label(t)))
        for b in branches(t):
            print_tree(b, indent + 1)

    Required Questions

    Sequences

    Important: For all WWPD questions, type Function if you believe the answer is <function...>, Error if it errors, and Nothing if nothing is displayed.

    Many languages provide map, filter, reduce functions for sequences. Python also provides these functions (and we'll formally introduce them later on in the course), but to help you better understand how they work, you'll be implementing these functions in the following problems.

    In Python, the map and filter built-ins have slightly different behavior than the my_map and my_filter functions we are defining here.

    Q1: Map

    my_map takes in a one argument function fn and a sequence seq and returns a list containing fn applied to each element in seq.

    Use only a single line for the body of the function. (Hint: use a list comprehension.)

    def my_map(fn, seq):
        """Applies fn onto each element in seq and returns a list.
        >>> my_map(lambda x: x*x, [1, 2, 3])
        [1, 4, 9]
        >>> my_map(lambda x: abs(x), [1, -1, 5, 3, 0])
        [1, 1, 5, 3, 0]
        >>> my_map(lambda x: print(x), ['cs61a', 'summer', '2023'])
        cs61a
        summer
        2023
        [None, None, None]
        """
    
    return [fn(elem) for elem in seq]

    Use Ok to test your code:

    python3 ok -q my_map

    Use Ok to run the local syntax checker (which checks that you used only a single line for the body of the function):

    python3 ok -q my_map_syntax_check

    Q2: Filter

    my_filter takes in a predicate function pred and a sequence seq and returns a list containing all elements in seq for which pred returns True. (A predicate function is a function that takes in an argument and returns either True or False.)

    Use only a single line for the body of the function. (Hint: use a list comprehension.)

    def my_filter(pred, seq):
        """Keeps elements in seq only if they satisfy pred.
        >>> my_filter(lambda x: x % 2 == 0, [1, 2, 3, 4])  # new list has only even-valued elements
        [2, 4]
        >>> my_filter(lambda x: (x + 5) % 3 == 0, [1, 2, 3, 4, 5])
        [1, 4]
        >>> my_filter(lambda x: print(x), [1, 2, 3, 4, 5])
        1
        2
        3
        4
        5
        []
        >>> my_filter(lambda x: max(5, x) == 5, [1, 2, 3, 4, 5, 6, 7])
        [1, 2, 3, 4, 5]
        """
    
    return [elem for elem in seq if pred(elem)]

    Use Ok to test your code:

    python3 ok -q my_filter

    Use Ok to run the local syntax checker (which checks that you used only a single line for the body of the function):

    python3 ok -q my_filter_syntax_check

    Q3: Reduce

    my_reduce takes in a two argument function combiner and a non-empty sequence seq and combines the elements in seq into one value using combiner.

    def my_reduce(combiner, seq):
        """Combines elements in seq using combiner.
        seq will have at least one element.
        >>> my_reduce(lambda x, y: x + y, [1, 2, 3, 4])  # 1 + 2 + 3 + 4
        10
        >>> my_reduce(lambda x, y: x * y, [1, 2, 3, 4])  # 1 * 2 * 3 * 4
        24
        >>> my_reduce(lambda x, y: x * y, [4])
        4
        >>> my_reduce(lambda x, y: x + 2 * y, [1, 2, 3]) # (1 + 2 * 2) + 2 * 3
        11
        """
    
    total = seq[0] for elem in seq[1:]: total = combiner(total, elem) return total # Alternate solution def my_reduce_alternate(combiner, seq): def helper(curr, seq): if len(seq) == 0: return curr return helper(combiner(curr, seq[0]), seq[1:]) return helper(seq[0], seq[1:])

    Use Ok to test your code:

    python3 ok -q my_reduce

    Data Abstraction

    Cities

    Say we have a data abstraction for cities. A city has a name, a latitude coordinate, and a longitude coordinate.

    Our data abstraction has one constructor:

    • make_city(name, lat, lon): Creates a city object with the given name, latitude, and longitude.

    We also have the following selectors in order to get the information for each city:

    • get_name(city): Returns the city's name
    • get_lat(city): Returns the city's latitude
    • get_lon(city): Returns the city's longitude

    Here is how we would use the constructor and selectors to create cities and extract their information:

    >>> berkeley = make_city('Berkeley', 122, 37)
    >>> get_name(berkeley)
    'Berkeley'
    >>> get_lat(berkeley)
    122
    >>> new_york = make_city('New York City', 74, 40)
    >>> get_lon(new_york)
    40

    All of the selector and constructor functions can be found in the lab file if you are curious to see how they are implemented. However, the point of data abstraction is that, when writing a program about cities, we do not need to know the implementation.

    Q4: Distance

    We will now implement the function distance, which computes the distance between two city objects. Recall that the distance between two coordinate pairs (x1, y1) and (x2, y2) can be found by calculating the sqrt of (x1 - x2)**2 + (y1 - y2)**2. We have already imported sqrt for your convenience. Use the latitude and longitude of a city as its coordinates; you'll need to use the selectors to access this info!

    from math import sqrt
    def distance(city_a, city_b):
        """
        Returns the distance between city_a and city_b according to their
        coordinates.
    
        >>> city_a = make_city('city_a', 0, 1)
        >>> city_b = make_city('city_b', 0, 2)
        >>> distance(city_a, city_b)
        1.0
        >>> city_c = make_city('city_c', 6.5, 12)
        >>> city_d = make_city('city_d', 2.5, 15)
        >>> distance(city_c, city_d)
        5.0
        """
    
    lat_1, lon_1 = get_lat(city_a), get_lon(city_a) lat_2, lon_2 = get_lat(city_b), get_lon(city_b) return sqrt((lat_1 - lat_2)**2 + (lon_1 - lon_2)**2)

    Use Ok to test your code:

    python3 ok -q distance

    Q5: Closer City

    Next, implement closer_city, a function that takes a latitude, longitude, and two cities, and returns the name of the city that is closer to the provided latitude and longitude.

    You may only use the selectors get_name get_lat get_lon, constructors make_city, and the distance function you just defined for this question.

    Hint: How can you use your distance function to find the distance between the given location and each of the given cities?

    def closer_city(lat, lon, city_a, city_b):
        """
        Returns the name of either city_a or city_b, whichever is closest to
        coordinate (lat, lon). If the two cities are the same distance away
        from the coordinate, consider city_b to be the closer city.
    
        >>> berkeley = make_city('Berkeley', 37.87, 112.26)
        >>> stanford = make_city('Stanford', 34.05, 118.25)
        >>> closer_city(38.33, 121.44, berkeley, stanford)
        'Stanford'
        >>> bucharest = make_city('Bucharest', 44.43, 26.10)
        >>> vienna = make_city('Vienna', 48.20, 16.37)
        >>> closer_city(41.29, 174.78, bucharest, vienna)
        'Bucharest'
        """
    
    new_city = make_city('arb', lat, lon) dist1 = distance(city_a, new_city) dist2 = distance(city_b, new_city) if dist1 < dist2: return get_name(city_a) return get_name(city_b)

    Use Ok to test your code:

    python3 ok -q closer_city

    Q6: Don't violate the abstraction barrier!

    Note: this question has no code-writing component (if you implemented the previous two questions correctly).

    When writing functions that use a data abstraction, we should use the constructor(s) and selector(s) whenever possible instead of assuming the data abstraction's implementation. Relying on a data abstraction's underlying implementation is known as violating the abstraction barrier.

    It's possible that you passed the doctests for the previous questions even if you violated the abstraction barrier. To check whether or not you did so, run the following command:

    Use Ok to test your code:

    python3 ok -q check_city_abstraction

    The check_city_abstraction function exists only for the doctest, which swaps out the implementations of the original abstraction with something else, runs the tests from the previous two parts, then restores the original abstraction.

    The nature of the abstraction barrier guarantees that changing the implementation of a data abstraction should not affect the functionality of any programs that use that data abstraction, as long as the constructors and selectors were used properly.

    If you passed the Ok tests for the previous questions but not this one, the fix is simple! Just replace any code that violates the abstraction barrier with the appropriate constructor or selector.

    Make sure that your functions pass the tests with both the first and the second implementations of the data abstraction and that you understand why they should work for both before moving on.

    Trees

    Q7: WWPD: Trees

    Use Ok to test your knowledge with the following "What Would Python Display?" questions:

    python3 ok -q wwpd -u

    Type Error if the code errors and Nothing if nothing is displayed. Draw out the tree on the board or a piece of paper if you get stuck!

    >>> from lab04 import *
    >>> t = tree(1, tree(2))
    Error
    >>> t = tree(1, [tree(2)])
    Nothing
    >>> label(t)
    1
    >>> label(branches(t)[0])
    2
    >>> x = branches(t)
    >>> len(x)
    1
    >>> is_leaf(x[0])
    True
    >>> branch = x[0]
    >>> label(t) + label(branch)
    3
    >>> len(branches(branch))
    0
    >>> from lab04 import *
    >>> b1 = tree(5, [tree(6), tree(7)])
    >>> b2 = tree(8, [tree(9, [tree(10)])])
    >>> t = tree(11, [b1, b2])
    >>> for b in branches(t):
    ...     print(label(b))
    5
    8
    >>> for b in branches(t):
    ...     print(is_leaf(branches(b)[0]))
    ...
    True
    False
    >>> [label(b) + 100 for b in branches(t)]
    [105, 108]
    >>> [label(b) * label(branches(b)[0]) for b in branches(t)]
    [30, 72]

    Q8: Perfectly Balanced

    Implement sum_tree, which returns the sum of all the labels in tree t.

    def sum_tree(t):
        """Add all elements in a tree.
    
        >>> t = tree(4, [tree(2, [tree(3)]), tree(6)])
        >>> sum_tree(t)
        15
        """
    
    total = 0 for b in branches(t): total += sum_tree(b) return label(t) + total

    Use Ok to test your code:

    python3 ok -q sum_tree

    Then, implement balanced, which returns whether every branch of t has the same total sum and that the branches themselves are also balanced.

    Example Tree

    • For example, the tree above is balanced because each branch has the same total sum, and each branch is also itself balanced.
    def balanced(t):
        """Checks if each branch has same sum of all elements and
        if each branch is balanced.
    
        >>> t = tree(1, [tree(3), tree(1, [tree(2)]), tree(1, [tree(1), tree(1)])])
        >>> balanced(t)
        True
        >>> t = tree(1, [t, tree(1)])
        >>> balanced(t)
        False
        >>> t = tree(1, [tree(4), tree(1, [tree(2), tree(1)]), tree(1, [tree(3)])])
        >>> balanced(t)
        False
        """
    
    for b in branches(t): if sum_tree(branches(t)[0]) != sum_tree(b) or not balanced(b): return False return True

    Use Ok to test your code:

    python3 ok -q balanced

    Challenge: Solve both of these parts with just 1 line of code each.

    Check Your Score Locally

    You can locally check your score on each question of this assignment by running

    python3 ok --score

    This does NOT submit the assignment! When you are satisfied with your score, submit the assignment to Gradescope to receive credit for it.

    Submit Assignment

    Submit this assignment by uploading any files you've edited to the appropriate Gradescope assignment. Lab 00 has detailed instructions.

    Correctly completing all questions is worth one point. Please ensure your TA has taken your attendance before leaving.

    Optional Questions

    These questions are optional. If you don't complete them, you will still receive credit for this assignment. They are great practice, so do them anyway!

    Q9: Number of Trees

    A full binary tree is a tree where each node has either 2 branches or 0 branches, but never 1 branch.

    Write a function which returns the number of unique full binary tree structures that have exactly n leaves. See the doctests for visualizations of the possible full binary tree sturctures that have 1, 2, and 3 leaves.

    Hint: A full binary tree can be constructed by connecting two smaller full binary trees to a root node. If the two smaller full binary trees have a and b leaves, the new full binary tree will have a + b leaves. For example, as shown in the first diagram below, a full binary tree with 4 leaves can be constructed by connecting a full binary tree that has three leaves (yellow) with a full binary tree that has one leaf (orange). A full binary tree with 4 leaves can also be constructed by connecting two full binary trees with 2 leaves each (second diagram) 4-leaf Full Binary Tree 1

    4-leaf Full Binary Tree 2

    For those interested in combinatorics, this problem does have a closed form solution):

    def num_trees(n):
        """Returns the number of unique full binary trees with exactly n leaves. E.g.,
    
        1   2        3       3    ...
        *   *        *       *
           / \      / \     / \
          *   *    *   *   *   *
                  / \         / \
                 *   *       *   *
    
        >>> num_trees(1)
        1
        >>> num_trees(2)
        1
        >>> num_trees(3)
        2
        >>> num_trees(8)
        429
    
        """
    
    if n == 1: return 1 return sum(num_trees(k) * num_trees(n-k) for k in range(1, n))

    Use Ok to test your code:

    python3 ok -q num_trees

    Q10: Only Paths

    Implement only_paths, which takes a Tree of numbers t and a number n. It returns a new tree with only the nodes of t that are on a path from the root to a leaf with labels that sum to n, or None if no path sums to n.

    Here is an illustration of the doctest examples involving t.

    only_paths

    def only_paths(t, n):
        """Return a tree with only the nodes of t along paths from the root to a leaf of t 
        for which the node labels of the path sum to n. If no paths sum to n, return None.
    
        >>> print_tree(only_paths(tree(5, [tree(2), tree(1, [tree(2)]), tree(1, [tree(1)])]), 7))
        5
          2
          1
            1
        >>> t = tree(3, [tree(4), tree(1, [tree(3, [tree(2)]), tree(2, [tree(1)]), tree(5), tree(3)])])
        >>> print_tree(only_paths(t, 7))
        3
          4
          1
            2
              1
            3
        >>> print_tree(only_paths(t, 9))
        3
          1
            3
              2
            5
        >>> print(only_paths(t, 3))
        None
        """
    
    if n == label(t) and is_leaf(t):
    return t
    new_branches = [only_paths(b, n - label(t)) for b in branches(t)]
    if any(new_branches):
    return tree(label(t), [b for b in new_branches if b is not None])

    Use Ok to test your code:

    python3 ok -q only_paths