A run of items, added or removed
One call, many items. Nothing new happens per item, because every one of them is the single add or the single remove you already know. What is new is the bookkeeping around the loop: where the cursor is when the loop starts, whether it moves with the work, and what the count says at the end.
Two ways to get this wrong, and they are the 2 that cost the most. Attaching every item to the same node, so each one overwrites the last and only the final item survives. And walking from the front again for every item, which is correct and slow, and slow is a thing you can lose marks for when the specification calls it out.
Adding a run at the front
Say you are handed a List from the Java library and asked to put everything in it at the front of your list, in the order it comes.
public void addAllAtFront(List aList)
{
if (aList == null)
{
throw new IllegalArgumentException("list was null");
}
Node cur = this.head; // the sentinel
Node rest = this.head.next; // the old first node
for (Object item : aList)
{
cur.next = new Node(item, null);
cur = cur.next; // the cursor moves with the work
this.size++;
}
cur.next = rest; // the old items follow the new ones
}
The guard is the first statement, not the second. An enhanced for loop over a null list throws a NullPointerException, which is not the exception the specification asked for, so a guard written below the loop never gets the chance to run. Put it first and the method fails in the way it promised to fail.
The old first node is saved before the loop. The first item goes right after the sentinel, so the cursor starts there with no walk at all. The first attach overwrites head.next, so rest has to hold the old first node before that line runs.
The cursor moves with the work. That is the line people leave out. Attach, then step onto what you attached, so the next item hangs off the item before it. Leave the step out and every item is attached to the sentinel, so each one replaces the last. The last line then points the sentinel back at rest, and none of the new items survive. Do the 2 lines in the other order and the cursor steps onto the old first node before anything is attached.
The count goes up once per item. Inside the loop is the right place, because then it is right no matter how the loop ends. Adding the source list's size once before the loop is fine too, as long as the loop really does add every one of them.
The order comes from the source. Front of the source goes on first. Calling addFirst once per item puts the run at the front in reverse order, because each new item goes in ahead of the one before it.
The empty source needs no special case. The loop does not run, the last line points the sentinel back at rest, and the list is left as it was. Writing an if for it adds a branch that can only be wrong.
On a list with no sentinel this method needs one more branch, because there is no node in front of the head for the cursor to start on, and the first item has to become the head. That is the same front case as everywhere else, and it is exactly what the sentinel is buying here.
Adding a run in the middle
Same idea, different starting point. Walk once to the node at index - 1, remember what came after it, then attach each item in turn and step the cursor. When the items run out, attach the remembered tail back on.
Node cur = this.head;
for (int i = 0; i < index - 1; i++)
{
cur = cur.next;
}
Node rest = cur.next; // save it before you overwrite it
for (Object item : aList)
{
cur.next = new Node(item, null);
cur = cur.next;
this.size++;
}
cur.next = rest; // stitch the tail back on
Saving rest first is the same aim before you break rule, stretched over a loop instead of a single statement. Forget it and the moment the first item attaches, nothing remembers where the rest of the list went.
Removing a run
Two kinds of run get removed, and they are different jobs.
A run by position. Remove count items starting at an index. Walk once to the node at index - 1, then step a second cursor forward count nodes and point the first one at where the second one landed. One write, whatever the count is.
Node cur = this.head;
for (int i = 0; i < index - 1; i++)
{
cur = cur.next;
}
Node stop = cur.next;
int removed = 0;
while (removed < count && stop != null)
{
stop = stop.next;
removed++;
}
cur.next = stop;
this.size -= removed;
The stop != null half of that test is the decision that matters. If the list runs out before the count does, you have to choose: remove what is there and report how many, or refuse the whole call because it asked for more than exists. Both are defensible, and the specification in front of you says which. What is not defensible is walking off the end, because the next .next throws. Count what you actually removed and subtract that, not what you were asked for.
A run by value. Remove every item that matches a rule, wherever they sit. That is the walk worked through in Removing a node, with one cursor on the node being examined and one on the last node you kept, and with the rule that the trailing cursor does not move on a delete. Removing 5 matches in a row and removing 5 matches scattered through the list are the same code, and the adjacent ones are the case that exposes a wrong trailing cursor.
In the practice APE
The addLast problem in Practice exam 2 adds a run at the end of a list with a sentinel. Its header comment is the specification.
The front version above, traced on a list that holds 5 values and an argument that holds 3:
before: [ / ] -> 1 -> 2 -> 3 -> 4 -> 5 -> null size 5
aList holds first, second, third
start: the cursor is on the sentinel, rest holds 1
item 1: attach first after the sentinel, step onto it size 6
item 2: attach second after first, step onto it size 7
item 3: attach third after second, step onto it size 8
stitch: third.next = rest, the node holding 1
after: [ / ] -> first -> second -> third
-> 1 -> 2 -> 3 -> 4 -> 5 -> null
Now the empty list case, which is where the sentinel earns its keep. The list is empty, so head.next is null and rest holds null. The cursor stands on the sentinel, and the sentinel is the node the first item attaches to. The stitch puts null after the last item, which ends the list. Same code, no branch. If the cursor had started at head.next instead, it would be null and the first attach would throw.
And the empty argument, on a list that already holds a value: the loop does not run, the stitch puts the old first node back after the sentinel, nothing changes, and size must not move.
Readiness check
A list with a sentinel holds p q. The argument holds x y z in that order. Use the front method above.
- Where is the cursor standing when the item loop starts, and which node does
resthold? - After the first pass of the item loop, which node is the cursor on?
- What does the list read at the end, and what is the size?
- Now empty the list and run the same call. What does
resthold, and what does the last line attach?
Answers
1. On the sentinel, and rest holds the node with p. No walk runs, because the run goes right after the sentinel.
2. On the new node holding x, because the cursor steps onto whatever it just attached.
3. x y z p q, size 5.
4. rest holds null, so the last line sets the next of the node holding z to null. That ends the list, and the cursor still started on the sentinel, so no branch was needed.
Someone deletes the line that moves the cursor inside the item loop of the front method. The list holds p and the argument holds x y z.
- What does the list read at the end?
- What does
sizesay, and why does that disagree with the values? - Now put the step back but write the 2 lines in the other order, stepping before attaching. What happens?
Answers
1. p. Every item attached to the sentinel, so each one replaced the last. The last line then pointed the sentinel back at p, and none of the 3 items survived.
2. It says 4, because the count went up once per item whether or not the item survived. The count matches what the loop did and the list does not, which is how this bug announces itself.
3. The cursor steps onto p before anything is attached, so x goes in after p, and the cursor ends the loop on y. The stitch then points y back at p. The node holding z is lost, and the list loops forever: p x y p x y and on. On an empty list the first step lands on null and the attach throws a NullPointerException.
A list holds a b c d e, size 5. You remove a run of 3 items starting at index 1, using the removal above.
- Which node does the first cursor land on?
- Where does the second cursor land, and how many steps did it take?
- What does the list read afterward, and what is the size?
- Now ask for a run of 9 starting at index 3. What does the list read, and what does the code count as removed?
Answers
1. On the node holding a, at index 0, after 0 steps.
2. On the node holding e, after 3 steps past b, c and d.
3. a e, size 2. One write did all of it.
4. The walk runs out after d and e, so the loop stops on the null half of its test having counted 2. The list reads a b c, size 3. Subtracting the 9 that was asked for rather than the 2 that happened would leave the size negative, which is exactly why you count what you did.
Further reading
- List (Java SE 19 & JDK 19). The interface the argument above has. Read
size,getanditerator, which are the 3 ways to walk it, and the contract that an enhanced for loop uses under the hood. - LinkedList (Java SE 19 & JDK 19).
addAllandaddAll(int, Collection)are the library's versions of this method, with the order guarantee written down. - Iterator (Java SE 19 & JDK 19). What the enhanced for loop actually calls, one item at a time.
- ConcurrentModificationException (Java SE 19 & JDK 19). What the library throws when a run is removed from a collection while something is walking it, and why removing through the iterator is the sanctioned way.
- The Collection Interface (The Java Tutorials). Oracle's own treatment of bulk operations and of removing while walking.
- The List Interface (The Java Tutorials). The range view idiom that removes a run of positions in one statement.
- Linked list (Wikipedia). Insertion and deletion in the general structure, and why attaching to a remembered tail costs nothing extra.
- SLList: A Singly-Linked List, from Pat Morin's Open Data Structures. The implementation that keeps a tail reference, which is the same trick as the cursor above.
The one thing to keep
A run is the single operation in a loop, plus a cursor that moves with the work so you never search twice. Guard the argument before anything else, walk once to the place the run starts, attach and step for every item, and count what you actually did rather than what you were asked for. The empty source list needs no branch, because a loop that runs zero times already does the right thing.