06: Linked Lists

Announcements

Please use Piazza responsibly: mark things as resolved (or not) when they’re done or when you have new follow-up questions or answers. We can’t read and re-read each question so we use the “resolved” status to check for things that are done or still need attention.

Quiz makeups: Generally all makeups must be completed within a week of a quiz. We’ll make an exception for the first quiz (snow, etc.) but in the future, if you miss a quiz, you need to make it up that week unless you are totally out of school due to illness or whatnot.

Note that if you modify the signatures of public methods in code we give you, it may still compile on your end, but it won’t on Gradescope. That’s because you’ve in essence removed the methods we’ve given you (different signature = different method as far as Java is concerned.) It still compiles for you because Eclipse (may) auto-correct the tests, but we only look at your source, and use our tests, when grading. You can add methods, but you can’t change signatures (e.g., add or remove parameters to the methods, or alter the return type, etc.).

Assignment 03 guidance

You may have noticed from the writeup and from the assignment code that there are built-in methods in the Java APIs for String and List (and possibly elsewhere) that do some or all of what you need. Use them! That’s (part of) the point of this assignment! Lists are not complicated: they are lists of items that you can do things to. Rather than write it all yourself (as you need to with arrays), you are welcome to use the built-in methods. For example, can you use String.indexOf? Yes! Etc.

Review: the StringListInterface

So last class we talked about this interface:

public interface StringListInterface {
    public void add(String s);
    public void add(int i, String s) throws IndexOutOfBoundsException;
    public String remove(int i) throws IndexOutOfBoundsException;
    public String get(int i) throws IndexOutOfBoundsException;
    public int size();
}

and wrote an array-based implementation for it.

Today I’m going to spend a little more time on an alternate implementation for StringListInterface. This will be especially useful for those of you intending to go on to 187, but it’s important regardless, since in some sense seeing-is-believing that you can have two very different ways of doing something that obey the same contract (but that might differ in, say, run-time). And it will give some context to the box-and-arrow diagrams that we’ll be using later in the class to explain why various data structures work the way they do.

First, let’s write a short, silly program demonstrating how our current code works. Let’s write a method to print out the contents of a StringListInterface:

public static void print(StringListInterface sli) {
  for (int i = 0; i < sli.size(); i++) {
      System.out.print(sli.get(i) + " ");
  }
  System.out.println();
}

Notice this is in terms of StringListInterface: it knows nothing about the ArrayStringList and only depends upon methods in the interface.

Next, a silly test method:

public static void main(String[] args) {
    StringListInterface sli = new StringArrayList();

    sli.add("Marc");
    sli.add("not");
    sli.add("so good.");
    print(sli);

    sli.add("is", 1);
    print(sli);

    sli.remove(2);
    print(sli);
}

OK, so our StringListInterface works. What about if we build it a different way?

Lions, tigers, bears, and linked lists

Remember, we built our ArrayStringList using an array as our underlying container. We hid this detail from users of the class, so it’s entirely possible we could have used a different way to build the list. Java’s builtins (that is, language constructs not library classes or methods) that let you “connect” or collect multiple variables are limited to arrays and references, but it turns out — not coincidentally — that these are all you need to build basically every other data structure. We (definitely!) won’t do them all, but we will do one more today: the linked-list. You’ll also benefit from this when we talk about how some other data structures are built later (though again, not in this level of detail, except for very simple structures).

Arrays are a fixed-size container of a sequence of cells; linked lists circumvent this restriction by making the cells (or “nodes” as they’re called in linked lists) explicit objects rather than an implicit property of an array. Here’s a simple container for Strings:

public class StringNode {
    private final String contents;

    public StringNode(String contents) {
            this.contents = contents;
    }

    public String getValue() {
            return contents;
    }
}

(On board). A simple object to hold a String and a method to get it. So far so good, except there’s no concept here of more than one StringNode being part of the same group of StringNodes. What do we do to fix this? Add a reference to a next StringNode to the current one!

public class StringNode {
    private final String contents;
    private StringNode next;

    public StringNode(String contents) {
            this.contents = contents;
            next = null;
    }

    public String getValue() {
            return contents;
    }

    public StringNode getNext() {
            return next;
    }

    public void setNext(StringNode n) {
            next = n;
    }
}

(Again on board) you can see if we want to add a node to the end of the list, it’s pretty straightforward. Inserting into the middle (or removing) is a little more complicated, but we’ll get there in a bit.

Let’s use this StringNode to build an alternate implementation of StringListInterface.

But first: clicker questions (Yay?)

Building LinkedStringList

We’ll need to keep a reference to the underlying list of nodes as an instance variable, and it turns out that’s the only instance variable we need. By convention, this is called head, and it’s initialized to null (an empty list) when we start:

private StringNode head;

public LinkedStringList() {
    head = null;
}

Let’s add the “simple” methods from last lecture, starting with size. What do we need to do? There’s no underlying array, so we can’t just examine the length attribute and return it. Instead, we need to traverse the list. In other words, (on board) we will start at the head element (if it exists), and follow the next references until we reach the end, signaled by a null value. Don’t forget that head is null, so your code should account for this:

public int size() {
    int size = 0;
    StringNode current = head;
    while (current != null) {
            size++;
            current = current.getNext();
    }
    return size;
}

Unlike arrays, where we can just jump to the node we want, a linked-list (almost) always requires that we traverse its elements until we get to the one we want.

Note that we don’t have to traverse the whole list for size; we could maintain a size instance variable and just return it instead if we wanted to.

Now let’s do get:

public String get(int i) throws IndexOutOfBoundsException {
    int j = 0;
    StringNode current = head;
    while (current != null) {
        if (i == j) {
            return current.getValue();
        }
        current = current.getNext()
        j++;
    }
    throw new IndexOutOfBoundsException();
}

Note you could call size() first if you wanted, but then your code would always traverse the whole list – or you could check against your size instance variable, if you chose to declare one.

Also note we could re-write the while loop as a for loop:

public String get(int i) throws IndexOutOfBoundsException {
    int j = 0;
    for (StringNode current = head; current != null; current = current.getNext()) {
            if (i == j) {
                return current.getValue();
            }
            j++;
    }
    throw new IndexOutOfBoundsException();
}

This is (maybe) nicer, since we won’t forget to follow the reference (current.getNext()) by accident. But we might still forget the j++.

Now let’s look at add. If we want to add at the end of the list we can again traverse it to get to the end, and adjust the last element’s next reference appropriately. Note that we have to stop our traversal just before we get to the end, not after we go past it, which also means we have to special-case the head (on board first):

public void add(String s) {
    StringNode node = new StringNode(s);
    if (head == null) {
        head = node;
        return;
    }

    StringNode current = head;
    while (current.getNext() != null) {
        current = current.getNext();
    }
    current.setNext(node);
}

Similarly, if we want to add somewhere in the middle of the list, we have to stop just before we get there, and do surgery on both the previous and current (added) item to make the links in the list line up, again with a special case for the first spot:

@Override
public void add(int i, String s) throws IndexOutOfBoundsException {
    StringNode node = new StringNode(s);

    if (i == 0) {
         node.setNext(head);
         head = node;
         return;
    }

    StringNode nodeBefore = head;
    for (int j = 0; j < i-1; j++) {
         nodeBefore = nodeBefore.getNext();
         if (nodeBefore == null) {
            throw new IndexOutOfBoundsException();
         }
    }
    node.setNext(nodeBefore.getNext());
    nodeBefore.setNext(node);
}

Finally the remove method, which again has to do some surgery on the previous node (if it exists). Let’s do some examples on the board first, then here’s the code:

public String remove(int i) throws IndexOutOfBoundsException {
    String result;

    if (i == 0) {
        if (head == null) {
            throw new IndexOutOfBoundsException();
        }
        result = head.getValue();
        head = head.getNext();
        return result;
    }

    StringNode nodeBefore = head;
    for (int j = 0; j < i-1; j++) {
        nodeBefore = nodeBefore.getNext();
        if (nodeBefore == null) {
            throw new IndexOutOfBoundsException();
        }
    }

    StringNode nodeToDelete = nodeBefore.getNext();
    if (nodeToDelete == null) {
        throw new IndexOutOfBoundsException();
    }

    result = nodeToDelete.getValue();
    nodeBefore.setNext(nodeToDelete.getNext());
    return result;
}

And we’ll run it in our toy program, switching ArrayStringList to LinkedStringList. Notice the behavior doesn’t change. (StringListInterface sli = new LinkedStringList();)

Implications

Now we’ve seen two different ways to implement a simple abstract data type (the List), using arrays, and using references. All other abstract data types can be implemented in terms of one or both of these mechanisms.

We’ve also seen that both implementations have the same results. Though, if you think about it, one or the other is more efficient (or at least different) in some ways.

For example, the array-based list uses up to twice the memory of its current size(). The linked list uses only what it needs, plus space for the link. It actually turns out they’re about equivalent.

For random access, the array-based implementation is better, since the get method is really a thin wrapper around array indexing, which is quite fast.

For addition or removal, it kind of depends. Adding something to the head of the linked list is really fast (make a node, set its next to head, set head to it), whereas adding a node to the front of an array-based list requires moving every single element in the array, and possibly enlarging the array, too.

On the other hand, adding an element to the end of the linked list is slow, since you have to traverse the entire list first. You could also keep a reference to the end of the list (called a tail pointer), but then you also have to update it and handle it in every method that modifies the list. On the third hand, only the implementor of the linked list needs to do this, not the user – as in our demo, both have the same interface, and neither expose their inner workings in terms of results (though runtime and memory usage might differ).

We won’t go into this comprehensive level of detail for (most) data structure implementation again in this course, though we will definitely revisit the idea of using an array or references to build more complicated data structures, like say this tree (on board). But that’s about as far as we’ll go, with diagrams rather than code. We’ll be more interested in the interface of the abstract data types and what behaviors they provide, rather than the fine details of the implementations.