Lecture 06: Linked Lists

Announcements

Lab 04: You folks are awesome, ‘nuff said.

Grading / regrading: We are posting grades into Gradescope and then Moodle. It’s important that you monitor your grades and let us know if you see an error. You are responsible for checking in with us if you spot a problem (start with Garrett, contact me if you’re unsatisfied with his answer), and for doing so promptly. We won’t review questions about grading a week after the grade is posted.

Piazza: As noted in the course policies, we are going to start making some of the private questions public if they are relevant to the entire class.

Quiz: There will be a quiz in discussion next week. Practice questions are on the site on the Assignments page. You’ll have the entire discussion to do the quiz, but I expect it should only take 25 minutes (that is, everyone is being given double time).

Review: the StringListInterface

So last class we talked about this interface:

public interface StringListInterface {
    public void add(String s) throws IndexOutOfBoundsException;
    public void add(String s, int i) 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. You also confessed your knowledge of generics from 121. Suckers! (Not really. Really it’s an opportunity for some more learnin’.)

So now I’m going to spend a little less time on generics, and instead 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 ArrayStringList();

    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).

Arrays are a fixed-size container; linked lists circumvent this restriction by making the cells explicit objects rather than an implicit property of an array. Here’s a simple container for Strings:

public class StringCell {
    private final String contents;

    public StringCell(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 StringCell being part of the same group of StringCells. What do we do to fix this? Add a reference to a next StringCell to the current one!

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

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

    public String getValue() {
            return contents;
    }

    public StringCell getNext() {
            return next;
    }

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

(Again on board) you can see if we want to add a cell 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 StringCell to build an alternate implementation.

Building LinkedStringList

We’ll need to keep a reference to the underlying list of cells 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 StringCell 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;
    StringCell current = head;
    while (current != null) {
            size++;
            current = current.getNext();
    }
    return size;
}

Unlike arrays, where we can just jump to the cell 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;
    StringCell 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 (StringCell 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) {
    StringCell cell = new StringCell(s);
    if (head == null) {
        head = cell;
        return;
    }

    StringCell current = head;
    while (current.getNext() != null) {
        current = current.getNext();
    }
    current.setNext(cell);
}

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(String s, int i) throws IndexOutOfBoundsException {
    StringCell cell = new StringCell(s);

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

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

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;
    }

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

    StringCell cellToDelete = cellBefore.getNext();
    if (cellToDelete == null) {
        throw new IndexOutOfBoundsException();
    }

    result = cellToDelete.getValue();
    cellBefore.setNext(cellToDelete.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 cell, set its next to head, set head to it), whereas adding a cell 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 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.

In class exercise

Write the removeAll method for the LinkedStringList.

The best way:

public void removeAll() {
    head = null;
}