Lecture from: 12.11.2024 | Video: Videos ETHZ
Linked Lists
Continuation from our intro to Linked Lists from last lecture…
Last time we looked at examples that demonstrate basic linked list manipulation. However, working directly with ListNode
objects can become cumbersome and error-prone, especially for more complex operations. It’s better to encapsulate the internal node structure within a LinkedList
class and provide methods to interact with the list. Before we introduce that class, let’s discuss another essential operation: outputting the elements of a linked list.
Outputting Linked List Elements
A frequent task is to print the elements of a linked list. A naive approach might lead to a common pitfall.
Incorrect Approach (Modifies the List)
// DON'T DO THIS! (Modifies the original list)
public void printList(ListNode head) { // Assuming head is the start of the list
while (head != null) {
System.out.print(head.value + " ");
head = head.next; // This modifies the original list!
}
System.out.println();
}
This approach has a significant problem: it modifies the original list. After calling this method, the original list will be empty because the head
reference is moved through the list and eventually becomes null
.
Correct Approach (Using a Temporary Variable)
To avoid modifying the original list, we use a temporary variable (current
) for traversal:
public void printList(ListNode head) {
ListNode current = head; // Assign head to current
while (current != null) {
System.out.print(current.value + " ");
current = current.next;
}
System.out.println();
}
This version preserves the original list structure because the head
reference remains unchanged. The current
variable is used for traversal, leaving the original list intact.
Now, before moving on to implementing a full LinkedList
class, let’s have a short intro on inner/nested classes, as these will be useful in our LinkedList
implementation.
The LinkedList
Class: Encapsulation and Inner Classes
As discussed, directly manipulating ListNode
objects is not ideal. We’ll create a LinkedList
class to encapsulate the nodes and provide a cleaner interface. Before that, a brief look at inner classes:
Inner/Nested Classes
Before defining the LinkedList
class, let’s introduce inner/nested classes. These are classes defined inside another class. They’re particularly useful for encapsulation and organization, especially when a helper class (like ListNode
) is only used by the enclosing class (like LinkedList
).
- Access to Outer Class Members: Inner classes have access to all members (fields and methods) of the outer class, even
private
ones. - Visibility: The visibility of the inner class itself can be controlled (e.g.,
private
,public
, etc.). Aprivate
inner class is only accessible within the outer class. OuterClass.this
: To disambiguate between members of the inner and outer class with the same name, useOuterClass.this.memberName
to refer to the outer class’s member.
Example
class OuterClass {
private int x = 3;
String foo() {
InnerClass inner = new InnerClass(9);
return inner.bar();
}
private class InnerClass {
private int x;
private InnerClass(int a) { x = a; }
private String bar() {
return x + " " + OuterClass.this.x; // Accesses outer x
}
}
}
OuterClass outer = new OuterClass();
System.out.println(outer.foo()); // Output: 9 3
Important Note: Instantiation of Inner Classes
Inner class instances can only exist within an instance of the outer class. You cannot create an inner class object directly from a static
method (like main
) without first having an instance of the outer class.
//This is incorrect: Trying to instatiate an InnerClass inside main() is not allowed.
public class OuterClass {
// ...
public static void main(String[] args) {
InnerClass inner = new InnerClass(9); // Compiler Error!
}
private class InnerClass { //...
}
}
//This is correct:
public class OuterClass {
// ...
public static void main(String[] args) {
OuterClass oc = new OuterClass(); // First create outer class
InnerClass inner = oc.new InnerClass(9); // Now you create the inner class.
// ...
}
private class InnerClass { //...
}
}
LinkedList
Class (Using a Nested Inner Class)
public class LinkedList {
private class ListNode { // ListNode is now a private inner class
private int value;
private ListNode next;
ListNode(int value) {
this.value = value;
}
ListNode(int value, ListNode next) {
this(value);
this.next = next;
}
// Recursive functions here...
}
private ListNode front; // Reference to the first node (head)
//private ListNode back; // We *could* also store a back reference (more on this later)
public LinkedList() {
front = null;
// back = null;
}
// ... public methods for list operations (size(), get(), add(), remove(), etc.)
}
Notice that ListNode
and front
are now private
. Clients cannot directly access or manipulate nodes; they must use the public methods of LinkedList
.
Basic LinkedList
Operations
1. size()
public int size() {
int count = 0;
ListNode current = front;
while (current != null) {
count++;
current = current.next;
}
return count;
}
Note that the size can be stored as a separate attribute too to improve performance.
2. get(int index)
public int get(int index) {
assert 0 <= index && index < size();
ListNode current = front;
for (int i = 0; i < index; i++) {
current = current.next;
}
return current.value;
}
3. back()
private ListNode back() { // Note: private, as clients don't need direct access
ListNode current = front;
while (current != null && current.next != null) {
current = current.next;
}
return current;
}
This version avoids potential NullPointerException
issues.
Example of a (subtly) faulty implementation:
Adding Elements
Now, let’s implement methods to add elements to our LinkedList
.
add(int value, int index)
Inserts a new node with the given value
at the specified index
.
public void add(int value, int index) {
assert 0 <= index && index < size(); //Enforces the index to be within bounds.
if (index == 0) {
front = new ListNode(value, front); // Adding at the beginning is a special case
} else {
ListNode current = front;
for (int i = 0; i < index - 1; i++) { // Traverse to the node *before* the insertion point
current = current.next;
}
current.next = new ListNode(value, current.next); // Insert the new node
}
}
addBack(int value)
(Adding at the End)
Adds a new node with the given value
at the end of the list. This uses our back()
method.
public void addBack(int value) {
ListNode newNode = new ListNode(value);
if (back() == null) { // If the list is empty
front = newNode; // The new node becomes the front
} else {
back().next = newNode; // Append to the end
}
}
Removing Elements
remove(int value)
Removes the first occurrence of a node with the given value
.
public void remove(int value) {
if (front == null) return; // Nothing to do if the list is empty
if (front.value == value) { // Special case: removing the first node
front = front.next;
return;
}
ListNode current = front;
while (current.next != null && current.next.value != value) { // Find node before one we want to delete
current = current.next;
}
if (current.next != null) { //Check to see if there was even a node to remove
current.next = current.next.next; // Remove node
}
}
removeAt(int index)
Removes the node at the given index
.
public void removeAt(int index) {
assert 0 <= index && index < size();
if (index == 0) {
front = front.next; // Removing the first element
} else {
ListNode current = front;
for (int i = 0; i < index - 1; i++) {
current = current.next;
}
current.next = current.next.next; // Removing the element at index
}
}
merge(LinkedList other)
Appends the other
list to the end of the current list.
public void merge(LinkedList other) {
if (front == null) {
front = other.front; // If this list is empty, just take the other list
} else {
back().next = other.front; // Append the other list
}
other.front = null; // The other list is now empty
}
Object Invariants for LinkedList
Invariants are essential for ensuring data structure integrity. For our LinkedList
, key invariants include:
size()
returns the correct number of reachable nodes.back()
returns a reachable node (ornull
if the list is empty).- The list contains no cycles (each node can be reached at most once starting from
front
).
Sorted Linked Lists
A sorted linked list maintains its elements in sorted order (e.g., ascending). This adds an additional object invariant: the values in the list must always be in increasing (or decreasing) order.
SortedLinkedList
Class
public class SortedLinkedList {
// Invariant: elements are sorted in increasing order
private ListNode front;
private class ListNode { // Inner class as before
// ... attributes and methods (same as LinkedList, so omitted here)
}
// ... constructor and other methods ...
}
add(int value)
for SortedLinkedList
The add()
method for a sorted linked list must ensure that the new element is inserted in the correct position to maintain the sorted order.
public void add(int value) {
if (front == null || value <= front.value) { //Check if the list is empty or new value comes at the front.
front = new ListNode(value, front); // Special case: adding at the beginning
} else {
ListNode current = front;
while (current.next != null && current.next.value < value) { //Find the appropriate place to insert.
current = current.next;
}
current.next = new ListNode(value, current.next); // Insert new node
}
}
Recursive Methods for Linked Lists
Recursive methods can provide elegant and concise solutions when working with recursive data structures like linked lists and trees.
Recursive Methods in ListNode
We can add recursive methods directly to our inner ListNode
class. This allows the outer LinkedList
class to call them, but they remain hidden and inaccessible from external code.
public class LinkedList { // LinkedList is now our outer class
// ... (other methods for LinkedList as defined before)
private class ListNode {
// ... (attributes and constructors as before)
public int size() { // Recursive size
return (next == null) ? 1 : 1 + next.size();
}
public void addBack(int v) { // Recursive addBack
if (next != null) {
next.addBack(v);
} else {
next = new ListNode(v);
}
}
public void visit() { // Recursive traversal (example from slide 42)
System.out.println(value);
if (next != null) next.visit();
}
}
// ... (other methods for LinkedList)
private ListNode front;
// ...
}
// Example usage from within LinkedList
public int sizeRecursive() {
if (front == null) {
return 0; // Empty list
} else {
return front.size(); // Call the recursive size() method of ListNode
}
}
public void visitAll() {
if(front != null) front.visit();
}
Recursive Methods in BinaryTreeNode
The same principles apply to trees. Here’s a recursive visit()
method for a BinaryTreeNode
:
class BinaryTreeNode {
// ... attributes (value, left, right) ...
void visit() {
System.out.println(value); // Visit the current node
if (left != null) left.visit(); // Recursively visit the left subtree
if (right != null) right.visit(); // Recursively visit the right subtree
}
}
Java’s Built-in Lists: java.util.LinkedList
and java.util.ArrayList
Java provides implementations of common data structures in the java.util
package.
1. java.util.LinkedList
- Doubly Linked List: Java’s
LinkedList
is a doubly linked list, meaning each node has references to both the next and the previous node. This allows for efficient traversal in both directions. - Methods: Offers methods like
addFirst()
,addLast()
,removeFirst()
,removeLast()
,getFirst()
,getLast()
, etc.
Example Usage:
import java.util.LinkedList;
LinkedList<String> names = new LinkedList<>(); // Note the use of generics (more later)
names.addFirst("Ann");
names.addFirst("Ben"); // Ben is now at the front
names.addLast("Clara");
// ... other operations ...
2. java.util.ArrayList
- Dynamic Array:
ArrayList
uses a dynamic array (a resizable array) internally. It provides fast random access (accessing elements by index) but can be less efficient for insertions or deletions, especially at the beginning of the list.
Example Usage:
import java.util.ArrayList;
ArrayList<String> names = new ArrayList<>();
names.add("Ann");
names.add("Ben");
// ... other operations ...
Continue here: 16 Java Built-in Lists, LinkedList, ArrayList, Wrapper Classes, Inheritance Basics