Linked Lists (Part 2)

We left off having defined a LinkedList class with some basic methods. The code from last time is here, and by the time this class is over we’ll end up here. Importantly, in class I polled the audience on what they might want LinkedList to do. The notes here contain different functionality than the livecode will, because I wanted to respect that spirit of inquiry! The Python docs that I referred to are here.

class ListNode:
    def __init__(self, data):
        self.data = data
        self.next = None

class LinkedList:
    def __init__(self):
        self.first = None

    def __append_to(self, node: ListNode, data):
        if not node.next:
            node.next = ListNode(data)
        else:
            self.__append_to(node.next, data)

    def append(self, data):
        if not self.first:
            self.first = ListNode(data)
        else:
            self.__append_to(self.first, data)

Without implementing more, it’s hard to test that our append method is doing what we expect! So let’s keep going and make our class more complete. It’s easy to guess what we might want to add, since the actions available to a LinkedList should be pretty similar to those available for a normal Python list.

length

First: length. We’ll follow exactly the same pattern as append, with a case distinction on whether there’s a first element, and a recursive helper method that walks down the elements of a nonempty list.

Help me with this one! We should be able to copy the code for append and modify it only a little bit.

Think, then click!
    def __length_from(self, node: ListNode) -> int:
        if not node.next:
            return 1
        return 1 + self.__length_from(node.next)

    def length(self) -> int:
        if not self.first:
            return 0
        return self.__length_from(self.first)

The __length_from method starts at some node in our list, and counts the number of nodes that come after it (including itself). This is done recursively: if nothing comes after, return 1. Otherwise, count the length from next, and add 1 to that length.

There are two important things to notice here. First of all, length and __length_from return a value, instead of modifying the list like append. So we need return in both the base and recursive cases. If the recursive case read 1 + self.__length_from(node.next) (without the return), it would compute the correct length, but we couldn’t access its value afterward.

Second, even though we don’t use the self argument in __length_from, we have to include it if __length_from is a method of the LinkedList class. This is a little ugly, and indeed, it feels unnecessary. We could implement __length_from as a regular function instead. Even better, we could implement it internally to the length function. After all, nobody should be calling __length_from except from inside length! So an alternative way to write this would be:

    def length(self) -> int:
        def length_from(node: ListNode) -> int:
            if not node.next:
                return 1
            return 1 + length_from(node.next)
        
        if not self.first:
            return 0
        return length_from(self.first)

Either way, we can add some test cases that make sure our function works well with append.

l = LinkedList()
assert(l.length() == 0)

l.append("hello")
assert(l.length() == 1)

l.append("world")
assert(l.length() == 2)

Access the nth value

The next useful part of a list interface is a function to access the nth value of a list. Here we have to be careful with the input. What if it’s too high, or negative? Python lists throw an exception, so we’ll do the same. More on this on Monday!

Note that if n is at least 0, and strictly less than the length of the list, the list cannot be empty. (In that case the length of the list would be 0.) So in nth, our bound check on n rules out that base case already.

The __nth_from method is interesting. Like __length_from, we have a base case and a recursive case. But the base case checks whether n == 0 instead of whether node.next is None. This makes sense, because we want to stop our recursion when we reach the node we’re looking for, which might not be the end of the list! Instead, we recur with the value n-1, since taking n steps from node is the same as taking n-1 steps from node.next.

    def __nth_from(self, node: ListNode, n: int):
        if n == 0:
            return node.data
        return self.__nth_from(node.next, n - 1)

    def nth(self, n: int):
        if n < 0 or self.length() <= n:
            raise Exception("index out of range")
        return self.__nth_from(self.first, n)

Note the lack of type annotations. We don’t know what type of value nth or nth_from will produce, because our lists are heterogenous—they could contain any types of data in any combination. We don’t know in advance what type of value is stored in data. If we had a list that was homogenous (containing some type we don’t know in advance, but guaranteed to be the same) we could use a type variable.

Again, let’s add some tests.

l = LinkedList()
assert(l.length() == 0)

l.append("hello")
assert(l.length() == 1)
assert(l.nth(0) == "hello")

l.append("world")
assert(l.length() == 2)
assert(l.nth(0) == "hello")
assert(l.nth(1) == "world")

Try out what happens if you ask for a value that’s out of range.

repr

Finally, if we want to see the entire list we’re building, we can implement repr:

    def __repr_from(self, node):
        if not node.next:
            return repr(node.data)
        else:
            return repr(node.data) + ", " + self.__repr_from(node.next)

    def __repr__(self):
        if not self.first:
            return "[]"
        return "[" + self.__repr_from(self.first) + "]"

Next time, we’ll build on all this to explore another new data structure: binary search trees.