A LinkedList is a data structure that organizes the data contained within it by making each location, called a node, responsible for holding references to its immediate neighbors in the list (along with the actual data we wish to store in the list). It's a fascinating data structure, but before we get too far, it's time for a quiz.
What is similar about the following images?
If you answered that each one is balancing an object on their head, you are half way to understanding the linked list. Just for fun, we'll call each person in the photos above a node. A node is something that can keep track of an object (by balancing it on its head, in this case).
But nodes can do more than just keep track of an object. Consider the following nodes...
Each node here is holding the hand of their two closest neighbors. This is cool, but not nearly as cool as it would be if each of them was balancing an object on their head. Shut your eyes and see if you can you imagine it with objects appearing on their heads? If so, you've just realized two things: 1) pretty much how a linked list works and 2) that it really is cool.
The LinkedList itself just manages how to get to the front of the chain of nodes and the back of the chain of nodes. Essentially, the LinkedList contains two attributes: head
— a reference to the node at the beginning of the list and tail
— a reference to the node at the end of the list.
In Pictures
Our LinkedList keeps track of the first (or head
) node and the last (or tail
) node. Each node is responsible for holding hands with its neighbors. Of course, if you don't have a neighbor, leaving your hand dangling in the air just looks stupid, so our stick figures put their hand(s) in their pocket(s) to signify that they don't have a neighbor.
A LinkedList containing one star consists of a reference to the head and tail (which happen to be the same thing) and one node balancing a star on its head and both hands in its pockets.
A LinkedList with two stars in it consists of a head pointing to the first node and a tail pointing to the last node. As expected, the two nodes hold hands because they are neighbors. I can get to the second element in my list by either going directly to it (through the tail
reference) or by going to the first element and asking it for its next neighbor.
If we add a really fat star to the LinkedList above it will look something like this:
I can't get to the second element directly now. I either have to go the the first element and ask to see its next neighbor or go to the last element and ask to see its previous neighbor. Either way, it is bound to be a lot of fun, but I've heard it's hard to balance without a big toe, and these guys don't have any toes. Having them balance stars on their heads seems like a lot to ask, even if they aren't real stars, so in the rest of this discussion we'll draw the LinkedList and Node objects in a way that describes what's going on in memory.
In Textbookish Pictures
Empty LinkedList
LinkedList with One Element
LinkedList with Two Element
LinkedList with Three Elements
In Code
In Java, we declare the LinkedList<E>
to implement the List<E>
interface and define an inner Node
class like this:
public class LinkedList<E> implements List<E> { private Node head; private Node tail; private class Node { private E value; private Node next; private Node prev; public Node(E val, Node nxt, Node prv) { value = val; next = nxt; prev = prv; } } public LinkedList() { clear(); } @Override public void clear() { head = null; tail = null; } // ...
Introduction to add() (to the end)
If the list is empty, adding an element involves
- creating a
Node
with no neighbors and the element referenced as its value. - Pointing the
head
of our list to the newly created node - Pointing the
tail
of our list to the newly created node
You can see this in the code below and the graphic below the code.
If the list has at least one element, then we leave the head
of the list alone, need to tell what used to be the last node in the list that it needs to point to the newly created node (steps 4 and 5 below).
In Code
@Override public boolean add(E element) { Node newNode = new Node(element, null, tail); // 1 if(head == null) { head = newNode; // 2 tail = newNode; // 3 }else{ tail.next = newNode; // 4 tail = newNode; // 5 } return true; }
In Pictures
Red indicates what it looked like before the addition and green indicates what it looks like after the addition.
In Song
Fa la la la la
isEmpty() and size()
In Code
@Override public boolean isEmpty() { return head==null; } @Override public int size() { int count = 0; Node walker = head; while(walker!=null) { walker = walker.next; ++count; } return count; }
- We know the list is empty if and only if there is no first element (
head==null
), so that's a simple method to implement. - If you were paying attention earlier, you may have noticed that my LinkedList class doesn't keep track of its size. As a result, any time
size()
is called, we must walk through all the elements in the list. - This makes size() a O(n) algorithm, but I've chosen to do it this way so we can focus on managing the links in the LinkedList and avoid getting distracted by incrementing and decrementing a size attribute.
Secret Time
- If you've made it this far, you deserve to know at least one of my secrets.
- I don't like doing the same thing over and over again.
- I've noticed that dealing with a linked list means that I'll be walking through it frequently.
- I've made a secret method to help with this1)
- Okay, so how do we keep a secret?2) We'll make it
private
.
private Node walkTo(int index) { if(index<0) { throw new IndexOutOfBoundsException("Index: " + index); } Node walker = head; int i = 0; try { for(; i<index; ++i) { walker = walker.next; } } catch(NullPointerException e) { throw new IndexOutOfBoundsException("Index: " + index + ", Size: " + i); } return walker; }
- I'm catching the
NullPointerException
and creating anIndexOutOfBoundsException
, why?3) - I can now get a reference to the ith node by calling
walkTo(i-1)
. - I'll use this secret to implement
get()
andset()
:
@Override public E get(int index) { Node node = walkTo(index); if(node==null) { throw new IndexOutOfBoundsException("Index: " + index + ", Size: " + index); } return node.value; } @Override public E set(int index, E element) { Node node = walkTo(index); if(node==null) { throw new IndexOutOfBoundsException("Index: " + index + ", Size: " + index); } E oldValue = node.value; node.value = element; return oldValue; }
- Notice that if
walkTo(i)
returnsnull
, it means thati
is one past the end of my list; Therefore, I want to throw anIndexOutOfBounds
exception.
Introduction to add() with an Index
- The
List
interface contains anadd
method that specifies where in the list the element should be added. - The first step is for us to walk to the appropriate spot in the list using our handy-dandy, super-secrect
walkTo()
method. (step 1 in code) - Once there, we have three possibilities:
- We are at the end of the list
- The
node
returned isnull
if we are at the end of the list because we will have just walked one step past the last node in the list. - Since we already have a method implemented to add to the end of a list, we can just call it.
- The
- We are at the beginning of the list
- We know we are at the beginning if the
node
returned has aprev
withnull
in it. - Here we create a new node, connect it with what used to be the first element in the list, and update the
head
of the list (steps 2-4 in code)
- We know we are at the beginning if the
- If we are not at the beginning and not at the end, then we must be somewhere in the middle
- Here we create a new node and introduce it to its new neighbors, who are no longer neighbors (steps 5-7)
- We are at the end of the list
In Code
@Override public void add(int index, E element) { Node node = walkTo(index); // 1 if(node==null) { // At end of list add(element); } else if(node.prev==null) { // At beginning of list Node newNode = new Node(element, node, null); // 2 node.prev = newNode; // 3 head = newNode; // 4 } else { // Somewhere in middle of list Node newNode = new Node(element, node, node.prev); // 5 node.prev = newNode; // 6 newNode.prev.next = newNode; // 7 } }
In Pictures
Iterators and the LinkedList
- The
List
interface provides for two types of iterator interfaces:Iterator
andListIterator
. - A class that implements the
Iterator
interface must provide three methods:boolean hasNext()
— returns true if there is another element accessible to the iterator.E next()
— advances the iterator to the next element in the list and returns the value stored there.void remove()
— removes the element returned by the last call tonext()
. Note: you can only callremove()
if you have previously callednext()
.
- A class that implements the
ListIterator
interface must do everything anIterator
does along with a number of other methods. These include:boolean hasPrevious()
— returns true if there is a previous element accessible to the iterator.E previous()
— returns the value of the element the iterator is currently pointing to and moves the iterator to the previous element in the list.int nextIndex()
— returns the location of the next element seen by the iterator as an index value.int previousIndex()
— returns the current location of the iterator as an index value.void add(E element)
— inserts an element into the list at the current position of the iterator.void set(E element)
— replaces the element in the list at the current position of the iterator.
- Since the
ListIterator
does everything anIterator
can do, we'll just implement theListIterator
and use it to provide functionality for both theIterator
andListIterator
. - We'll need to keep track of three things in our list iterator:
location
— Where the iterator is currently pointing (an actual reference to theNode
in the list).index
— Where the iterator is currently pointing (an integer corresponding to the location in the list).isModifiable
— Aboolean
indicating whether we can callremove()
,add()
, orset()
.
- This class will be an inner class so it will have access to the
LinkedList
's attributes.
In Code
private class LLIterator implements ListIterator<E> { private Node location; private int index; private boolean isModifiable; private LLIterator() { location = new Node(null, head, null); index = -1; isModifiable = false; }
- We'll continue on with more of this inner class in a moment, but let's take a look at the construtor first.
- When an iterator starts out, it points to one element before the first element in the list (index of -1).
- Since we don't have a
Node
sitting one element before the first element in the list, we need to create one and have it point to the first element in the list, i.e., thehead
. - Recall that the
Node
constructor takes in(value, next, previous)
so thelocation
of the iterator is a node with no value whose next node is the first node in the list.
ListIterator.hasNext/Previous()
- If
location
doesn't have a next, then we are at the end of the list, sohasNext()
is just a simple check: - The only time we don't have a previous element is when we are one position before the beginning of the list, so
hasPrevious()
is a simple check against our index position.4)
@Override public boolean hasNext() { return location.next!=null; } @Override public boolean hasPrevious() { return index!=-1; }
ListIterator.next/previousIndex() and ListIterator.set()
nextIndex()
andpreviousIndex()
are pretty simple too.- Our implementation of
set()
doesn't bother to make sure I have a legal index before assigninglocation.value
to the new element. Can you see why it's not needed?
@Override public int nextIndex() { return index+1; } @Override public int previousIndex() { return index; } @Override public void set(E element) { if(!isModifiable) { throw new IllegalStateException("Must call next or previous before remove"); } location.value = element; }
ListIterator.next/previous()
- Our implementation of
next()
callshasNext()
to make sure we have something to move to, and then makes the move. previous()
is slightly more complicated because we need to treat the situation where we are at the first element in the list with a bit more care.- Suppose I do the following:
- Create a list iterator (at index -1).
- Call next which advances the iterator to index 0 and returns the first element in the list.
- At this point, I should be able to call
previous()
— it should return the first element in the list and move to index -1. - That's all good, but to move to index -1, we need to create a temporary
Node
for the iterator to point to (exactly like we did in the constructor).
@Override public E next() { if(!hasNext()) { throw new NoSuchElementException("No next element available."); } isModifiable = true; location = location.next; ++index; return location.value; } @Override public E previous() { if(!hasPrevious()) { throw new NoSuchElementException("No next element available."); } E value = location.value; if(index==0) { location = new Node(null, head, null); index = -1; isModifiable = false; } else { location = location.prev; --index; isModifiable = true; } return value; }
ListIterator.remove()
- We now have all but
add()
andremove()
implemented. - These two are the most complicated of the methods in this class because we need to handle things differently depending on the position of the iterator when add/remove is called.
add()
will be left as an exercise for the reader.... enjoy.- In the code below, we have two conditionals.
- The first conditional determines if the iterator is either:
- At the beginning of the list (1) in which case we update the
head
of the list to point to the second element in the list, or - Not at the beginning of the list (2) in which case we update the previous node so that it's
next
now points to one after the iterator.
- At the beginning of the list (1) in which case we update the
- The second conditional determines if the iterator is either:
- At the end of the list (3) in which case we update the
tail
of the list to point to the second to last element in the list, or - Not at the end of the list (4) in which case we update the next node so that it's
prev
now points to one before the iterator.
- At the end of the list (3) in which case we update the
In Code
@Override public void remove() { if(!isModifiable) { throw new IllegalStateException("Must call next or previous before remove"); } if(location.prev==null) { // At beginning of list LinkedList.this.head = location.next; // 1 } else { // In middle of list location.prev.next = location.next; // 2 } if(location.next==null) { // At end of list LinkedList.this.tail = location.prev; // 3 } else { // In middle of list location.next.prev = location.prev; // 4 } isModifiable = false; } @Override public void add(E element) { throw new UnsupportedOperationException("ListIterator.add() not supported."); } }
In Pictures
Iterator-Related Methods in the LinkedList
Meanwhile, back in the outer class, i.e., the LinkedList
class, we've got more to do. We can implement the iterator-related methods:
@Override public Iterator<E> iterator() { return new LLIterator(); } @Override public ListIterator<E> listIterator() { return new LLIterator(); } @Override public ListIterator<E> listIterator(int index) { if(index<0) { throw new IndexOutOfBoundsException("Index: " + index); } ListIterator<E> itr = new LLIterator(); int i=0; try { for(; i<index; ++i) { itr.next(); } } catch(NoSuchElementException e) { throw new IndexOutOfBoundsException("Index: " + index + ", Size: " + i); } return itr; }
- Our
LLIterator
class provides all the functionality for theIterator
interface, so we can just return anLLIterator
object for the first method. - The second method is equally simple to implement since the first two return an iterator that points to one before the start of the list.
- The
listIterator(int index)
involves one additional step beyond just creating the iterator. Here we need to advance the iterator to positionindex-1
.- If
index=0
the iterator should point to one before the start of the list (so that when you callnext()
you get the element at index 0.) - If
index=1
the iterator should point to the start of the list (so that when you callnext()
you get the element at index 1.) - If
index=20
the iterator should point to the twentieth of the list (so that when you callnext()
you get the element at index 20.) - If the index passed in is outside of the range of legitimate values, an
IndexOutOfBoundsException
is thrown.
- If
LinkedList.remove()
- Now that we have the iterator defined, we can use it to help implement many of the remaining methods in the
LinkedList
class. - For example, to remove the element at a given index, we can just get an iterator at index (actually one before it), call next, and then use the iterator's remove method:
@Override public E remove(int index) { ListIterator<E> itr = listIterator(index); E value = itr.next(); itr.remove(); return value; }
- We can also use the iterator to navigate the list while we look for a match to a particular value:
@Override public boolean remove(Object target) { boolean removed = false; ListIterator<E> itr = listIterator(); while(!removed && itr.hasNext()) { if(target.equals(itr.next())) { itr.remove(); removed = true; } } return removed; } @Override public boolean contains(Object target) { Iterator<E> itr = iterator(); boolean found = false; while(!found && itr.hasNext()) { found = target.equals(itr.next()); } return found; } @Override public int lastIndexOf(Object target) { int index = size(); ListIterator<E> itr = listIterator(index); boolean found = false; while(!found && itr.hasPrevious()) { found = target.equals(itr.previous()); --index; } if(!found) { index = -1; } return index; }
- There are a few other methods that we haven't implemented here, but those will be left as an exercise.
2)... posting it on the internet for the world to see is probably not helping ensure secrecy here.
3)Because
NullPointerException
would be caused by walking off the end of the list, which will only happen if we are given an index that is too large for our list.
4)With our implementation, we can't easily use the index to implement
hasNext()
since we didn't include a size
attribute in the LinkedList
class. We could call size()
, doing something like: return index<size()-1;
but that wouldn't be very efficient. Do you know why? You should.
Last modified: Monday, 29-Jul-2024 06:54:28 EDT