The data abstraction: a sequence of elements
Arrays (and vectors) work by storing elements of a sequence contiguously in memory
Easy to access elements by number
Inserting things into the middle is slow and awkward
An Alternate View
We can approach the same abstraction differently:
Linked lists store each element in a distinct node.
Nodes are linked by pointers.
List Nodes
class <E> Node {
public:
E item;
Node<E> next;
public Node (E element, Node<E> next) {
item = element;
this.next = next;
}
};
A linked list consists of a number of nodes.
Each node provides an item field and a next pointer.
A constructor is useful for convenient initialization of those fields.
We can move from node to node by tracing the pointers:
Node<String> current = first;
while (current != null)
{
doSomethingWith (current.item);
current = current.next; // move forward one step
}
or
for (Node<String> current = head; current != null; current = current.next)
{
doSomethingWith (current.item);
}
We insert by working from the node prior to the insertion point:
//
// Insert value after node afterThis
//
Node<String> newNode = new Node<String>(value, afterThis.next);
afterThis.next = newNode;
We delete by moving the previous pointer “around” the unwanted node.
// Remove value after node afterThis
{
Node<String> toRemove = afterThis.next;
afterThis.next = toRemove.next;
}
// Remove value after node afterThis
// Remove value after node afterThis
{
Node<String> toRemove = afterThis.next;
// Remove value after node afterThis
{
Node<String> toRemove = afterThis.next;
afterThis.next = toRemove.next;
// Remove value after node afterThis
{
Node<String> toRemove = afterThis.next;
afterThis.next = toRemove.next;
}
Local variable toRemove
will disappear as we leave the { ... }
, making the “Baker” node unreachable (“garbage”) and eligible for later recovery and reuse (“garbage collection”).
We need two data types to build a linked list:
The linked list node, which we have already looked at
and a header for the entire list:
class <Data> LListHeader {
Node<Data> first;
LListHeader();
⋮
};
The header for the entire linked list.
In practice, this is sometimes not done not as a separate type, but by simply declaring the appropriate first (a.k.a., front or head) data member as a private member of an ADT.
Here is the class interface:
public class Book implements Comparable<Book>, Iterable<Author>, Cloneable {
⋮
public Book() { ... }
public Book(String theTitle, String theIsbn, int theYear, int theEdition,
Iterable<Author> theAuthors) { ... }
/**
* @return the title
*/
public String getTitle() { ... }
/**
* @param title the title to set
*/
public void setTitle(String title) { ... }
⋮
public void addAuthor(Author au) { ... }
public void removeAuthor(Author au) { ... }
public boolean equals(Object obj) { ... }
public int compareTo(Book book) { ... }
public String toString() { ... }
public Iterator<Author> iterator() { ... }
public Book clone() { ... }
}
We will keep the authors in a linked list:
public class Book implements Comparable<Book>, Iterable<Author>, Cloneable {
⋮
private int numAuthors;
private static class Node {
public Author item;
public Node next;
public Node(Author author, Node next) {
item = author;
this.next = next;
}
}
private Node firstAuthor;
private Node lastAuthor;
⋮
}
We use a nested class to hold the Node
type.
firstAuthor
and lastAuthor
hold links to the first and last node in the list.
public String toString() {
StringBuilder buffer = new StringBuilder(title);
buffer.append(": ");
buffer.append(isbn);
Node current = firstAuthor;
while (current != null) {
if (current != firstAuthor) {
if (current.next != null) {
buffer.append(", ");
} else {
buffer.append(" and ");
}
}
current = current.next;
}
buffer.append(", " + year).append(", ed. " + edition);
buffer.append(", ").append(isbn);
return buffer.toString();
}
The highlighted portion is a “typical” linked list traversal.
I often find that when writing this style of while loop, I am so focused on actions required to process the data in the current node that I forget to add the update of current in the final line of the loop body. So, often, I prefer to write my traversals more like this:
public String toString() {
StringBuilder buffer = new StringBuilder(title);
buffer.append(": ");
buffer.append(isbn);
for(Node current = firstAuthor; current != null; current = current.next) {
if (current != firstAuthor) {
if (current.next != null) {
buffer.append(", ");
} else {
buffer.append(" and ");
}
}
}
buffer.append(", " + year).append(", ed. " + edition);
buffer.append(", ").append(isbn);
return buffer.toString();
}
which helps alleviate the problem of forgetting to update (or to initialize) the current pointer. It also has the advantage of making current
a variable that is local to the loop, so that in a more elaborate algorithm, if I have other loops that traverse a linked list, I can re-use the name “current” without worrying about interfering with its use in other parts of the algorithm.
It’s a general rule of good programming style that variables should be declared in such a way as to make them as local as possible.
To support:
public class Book {
⋮
public void removeAuthor (Author au) { ... }
we would need to first locate the indicated author within our list. For that, we do a traversal but stop early if we find what we are looking for.
Node current = firstAuthor;
Node prev = null;
while (current != null && !au.equals(current.item)) {
current = current.next;
}
if (current != null) {
// We found the author we were looking for.
⋮
}
}
In this case, I use a while loop instead of the for loop specifically because I need access to the value of current
upon leaving the loop – it can’t be local to the loop body.
We’ll fill in the missing non-search portion of this function later.
Although not really a search, the equals
code is similar, in that we do a traversal with a possible early exit. But now we have two walk two lists:
public boolean equals(Object obj) {
if (obj != null && getClass().equals(obj.getClass())) {
Book book = (Book) obj;
if (title.equals(book.title)
&& isbn.equals(book.isbn)
&& year == book.year
&& edition == book.edition
&& numAuthors == book.numAuthors) {
Node left = firstAuthor;
Node right = book.firstAuthor;
while (left != null) {
if (!left.item.equals(right.item))
return false;
left = left.next;
right = right.next;
}
return true;
} else {
return false;
}
} else {
return false;
}
}
void addAuthor (Author au) { ... }
could be implemented differently depending on whether we want to keep
the authors in the order in which they were added, or in alphabetical
order by author name.
public void addAuthor(Author au) {
if (lastAuthor == null) {
firstAuthor = lastAuthor = new Node(au, null);
} else {
lastAuthor = lastAuthor.next = new Node(au, null);
}
++numAuthors;
}
or
public void addAuthor(Author au) {
Node current = firstAuthor;
Node prev = null;
while (current != null && current.item.compareTo(au) < 0) {
prev = current;
current = current.next;
}
if (current == null) {
// Adding to the end of the list
if (prev == null) {// list is empty
firstAuthor = lastAuthor = new Node(au, null);
} else {
lastAuthor = prev.next = new Node(au, null);
}
} else if (current = firstAuthor) {
// Adding to the start
firstAuthor = new Node(au, firstAuthor);
} else {
// Adding in the middle
prev.next = new Node(au, current);
}
++numAuthors;
}
It’s only fair to point out that getting all the pointer switching correct that we need to do for these algorithms is not simple. You have to take particular care with the “special cases” of inserting at the start and end of the list, and of inserting into an empty list. Even experienced programmers can struggle with linked list manipulation, particularly with some of the still more complicated variations that we will introduce later.
The key, I find, to getting the code correct is to do a variation on desk checking – draw pictures of the data. In particular, before and after pictures of the list before you make any changes and of how you want it to look when you are done can show you how many different values in your data need to be altered. Then you can draw intermediate pictures to figure out a sensible sequence of steps that will get you from the beginning to the end. Once you have those, it’s much easier to write the code that takes you from one picture to the next.
Earlier, we started looking at the idea of removing a node, and saw that we needed to start by searching for the node to be removed:
public void removeAuthor(Author au) {
Node current = firstAuthor;
Node prev = null;
while (current != null) {
if (au.equals(current.item)) {
if (prev == null) {
firstAuthor = firstAuthor.next;
} else {
prev.next = current.next;
}
--numAuthors;
break;
}
prev = current;
current = current.next;
}
}
class <E> DNode
{
E data;
DNode<E> prev;
DNode<E> next;
DNode (E e, DNode<Data> prv, DNode<Data> nxt) {
data = e;
prev = prv;
next = nxt;
}
}
we can
Move backwards as well as forward in the list
prev
pointersEasily add in front of a node
Of course, now we have almost twice as many pointers to update whenever we add or remove a node, so the code becomes a bit messier and difficult to get correct.
It may be clear by now that much of the difficulty in coding linked lists lies in the special care that must be taken with special cases:
Your textbook suggests a way to simplify this coding, by introducing header and trailer nodes, often called sentinel nodes.
A sentinel in a data structure is a reserved position at one end or the other that doesn’t hold “real” data, but is used to help keep algorithms from running off the end of the structure.
A purely empty list with sentinels would look like this.
A list with data keeps the data between the sentinels. The (head)
and (tail)
nodes do not hold data, and we will have to craft the code so that traversals never actually visit those nodes. But when adding or removing “real” data, this structure pretty much eliminates all of those special cases I listed above. A new node or a node to be removed is never going to be first/last/only because the sentinels sit in the first and last position.