← Linked lists CS Skill Tree

Getting a value back out

Everything here reads and returns, and nothing here changes the list. That is the first thing to notice, because it means no pointer ever gets rewritten, size never moves, and the only ways to be wrong are stopping in the wrong place and crashing on a list that is shorter than you assumed.

There are 2 kinds of question you can ask a list. One is about positions: what sits at position k, or where a value sits. The other asks for the value that wins some comparison, such as the largest or the smallest. They need different loops for a reason worth understanding rather than memorizing.

By index

An array answers this in one step because every slot is the same size and the machine can compute where slot k sits. A linked list cannot. The nodes are separate objects scattered wherever they were allocated, and the only thing tying them together is the arrow in each one, so the only way to reach position k is to start at the front and take k steps. That is what you are paying for the cheap insert at the front.

indexOf asks the position question backward. It is handed a value and returns the position of the first node that holds it, counted from 0 at the first real node (its index), or minus 1 when no node does.

public int indexOf(Object wanted)
{
    int pos = 0;                // the position cur is standing on
    Node cur = this.head;
    while (cur != null)
    {
        if (wanted.equals(cur.data))
        {
            return pos;
        }
        cur = cur.next;
        pos++;
    }
    return -1;                  // no node holds it
}

Read 3 things off that.

The counter and the cursor move together. pos is 0 while cur is on the first node, and both step in the same pass, so pos always names the position cur is standing on. Every walk to a position depends on that pairing, whichever way the question is asked.

The bounds stop one short of the size. A method handed a position has to check it before it walks. Legal positions for reading a node are 0 through size minus 1, because you are naming a node that has to exist. An add accepts size itself, because it is naming a gap and there is one more gap than there are nodes. Two methods, 2 rules, in the same class. indexOf needs no such check, because it is handed a value and only ever returns a position the walk reached.

Standing on the node, or in front of it. Reaching position k from the first node takes k steps and leaves the cursor on that node, which is where a read wants to be. An insert or a remove wants to be standing on the node before, since it is about to rewrite that node's arrow, so it takes 1 step fewer. Decide what has to happen after the loop and the step count follows from it.

On a list with a sentinel, start the cursor on head.next with pos at 0. Starting on head with pos at 0 counts the sentinel as position 0, so every answer comes back 1 too high, and a value in the first real node is reported at position 1. Nothing crashes, which is why this one survives a quick test.

By a criterion, such as the largest

Now nobody tells you where to stop. You have to look at every value and keep the best one you have seen.

public Object max()
{
    if (this.head == null)
    {
        throw new NoSuchElementException("empty list");
    }
    Node best = this.head;
    Node cur = this.head.next;
    while (cur != null)
    {
        if (((Comparable) cur.data).compareTo(best.data) > 0)
        {
            best = cur;
        }
        cur = cur.next;
    }
    return best.data;
}

Three decisions in there, and each one has a reason.

The best starts as the first real node, not as null and not as some very small number. Starting at null would force a null test inside the loop on every single pass, which is slower to write and one more place to be wrong. Starting at a very small number works for int and has no equivalent for Object, and the moment the data could be anything, a stand-in value that happens to lose every comparison does not exist. The first value in the list is always a legal answer to start from, because it is one of the values in the running.

The walk starts on the second node. The first one is already the incumbent, so comparing it against itself is a wasted pass that changes nothing.

The empty list gets a guard. There is no largest value in a list with no values, and there is no correct value to return. Throwing says so. Returning null is the other defensible answer, and either is fine as long as the method says which one it does and the caller can tell the difference. What is not fine is quietly returning the first node's value from a list that has no nodes.

Smallest is the same method with the comparison reversed. Any other criterion is the same method with the comparison replaced, because the shape is not about size at all: hold the best so far, look at the next one, replace the best only when the new one wins.

Ask the sign, not the number

compareTo promises 3 things only. Negative when the receiver sorts before the argument, 0 when they tie, positive when it sorts after. It does not promise minus 1 and 1. String's version hands back the difference between the first 2 characters that differ, so "apple".compareTo("pear") returns minus 15. Write compareTo(x) > 0, never compareTo(x) == 1, because the second one is true for one of the many positive values a correct implementation is allowed to return.

By a criterion, such as the first one that matches

Searching for the first value that satisfies a rule is the indexOf walk with a different answer. A findFirst(Object wanted) method has the same loop and the same test, returns cur.data where indexOf returns pos, and needs no counter at all.

Returning from inside the loop ends the walk the moment the answer is known, and continuing the walk would only cost time. The interesting decision is the last line. A search that finds nothing has to report that, and there are 3 usual ways: return null, return minus 1 for a position, or throw. Pick the one the specification in front of you asks for.

The walk that compares neighbors, rather than comparing a node against a fixed value, is where the 2 dots crash happens.

// broken: nothing proved cur.next is a real node
while (cur != null)
{
    if (cur.data.equals(cur.next.data)) { ... }
    cur = cur.next;
}

// fixed: the test proves both reads before either happens
while (cur != null && cur.next != null)
{
    if (cur.data.equals(cur.next.data)) { ... }
    cur = cur.next;
}

Java evaluates && left to right and stops as soon as the answer is settled, so when cur is null the second half never runs. That ordering is what makes the fixed version safe, and writing the 2 halves the other way round undoes it.

In the practice APE

None of the 3 practice exams has a linked list problem that hands back 1 value, by index or by a criterion. The set problem in Practice exam 3 works at a position in a list with no sentinel. The findLargest problems in Practice exams 1 and 2 find the largest value of an array, with recursion. The official topic list names retrieval by index and by a criterion such as maximum or minimum, so the absence is about these 3 practice exams and not about what can be asked.

Practice it yourself, and it costs 10 minutes. Take any list class you already have in front of you and add 3 methods to it from a blank line: contains(Object), which returns true when some node holds the value, min(), and lastIndexOf(Object), which returns the position of the last node that holds the value. Then run each one against an empty list, a 1 value list, a list where the answer is the first value, and a list where it is the last. Those 4 lists find almost every bug described here.

Readiness check

TracePositions

A list with no sentinel holds p q r s t. You call the indexOf written above.

  1. indexOf("p"). How many times does cur step, and what comes back?
  2. indexOf("t"). How many steps, and what comes back?
  3. indexOf("z"). How many steps, and what comes back?
  4. The same values in a list with a sentinel, and the walk starts on head with pos at 0. What does indexOf("r") return?
Answers

1. 0 steps, and 0 comes back. The cursor is already on position 0 when it is set to head, and the first test matches.

2. 4 steps, and 4 comes back. t is in the last node, and the last position on a 5 value list is 4.

3. 5 steps, and minus 1 comes back. The cursor visits all 5 nodes, steps off the last one onto null, and the loop ends with nothing found.

4. 3, which is 1 too high. The sentinel was counted as position 0, so r, really at position 2, is reported at 3.

TraceWhere the best lands

A list holds the integers 4 9 1 9 6 and you run the max written above.

  1. What is best holding after each pass of the loop?
  2. Which of the 2 nines is returned, and why does that follow from the comparison?
  3. Change the comparison to >= 0. Which 9 comes back now?
  4. Run the same method on a list holding one value, 7. How many passes happen, and what comes back?
Answers

1. Starting on 4: after comparing 9 it holds 9, after 1 it still holds 9, after the second 9 it still holds the first 9, after 6 it still holds the first 9.

2. The first one. A later value has to be strictly greater to take over, and the second 9 only ties.

3. The second one, because a tie is now reason enough to move. Both are correct answers to what is the largest value, and they stop being interchangeable the moment the objects carry more than the field you compared.

4. Zero passes, because the cursor starts at head.next, which is null. 7 comes back, since the first node was the incumbent from the start.

TraceThe empty list, 3 ways

The list is empty. Say exactly what happens in each case.

  1. indexOf("a"), written above with no guard at all.
  2. max() with the guard written above.
  3. max() with the guard deleted, on a list with no sentinel.
Answers

1. Nothing goes wrong, and minus 1 comes back. The test is cur != null, it is false on the first check, and the body never runs. A walk that asks about the cursor itself never needs an empty guard.

2. NoSuchElementException, thrown on purpose, because a list with no values has no largest value.

3. A NullPointerException on the line that sets cur. Setting best to this.head only copies null, and the next line reads this.head.next off it.

Further reading

The one thing to keep

A read never changes anything, so the only questions are where to stop and what to do when there is nothing to return. By position, a counter that steps with the cursor always names where the cursor stands, the bounds for a read end at size minus 1, and reaching position k takes k steps because a read stands on the node rather than in front of it. By a criterion, hold the first real value as the best so far, start the walk on the second node, and replace the best only on a strict win. An empty list has no largest value and no value at any index, so say so out loud with a guard rather than letting the walk find out.