Skip to content

Latest commit

 

History

History
972 lines (739 loc) · 21.3 KB

README.md

File metadata and controls

972 lines (739 loc) · 21.3 KB

Doubly Linked Lists

A mini-course in doubly linked lists in JS for novice programmers.

Prerequisites

Mastery of linked lists

Contents

Part 1. Methods for doubly linked lists

Doubly linked lists are like linked lists (aka singly linked lists), except every node has a prev reference and a next reference.

The clients must keep track of head and last themselves.

dlists.js

Here's the class definition for DNode:

class DNode {
    constructor(value) {
        this.value = value;
        this.prev = undefined;
        this.next = undefined;
    }
}

index.html

<!DOCTYPE html>
<html>
  <head>
    <title>Doubly Linked Lists</title>
    <script src="dlists.js"></script>
  </head>
</html>
class DNode {
    
    ...

    // Creates a new node to hold value, then inserts the new node after
    // this node.
    //
    // Returns a reference to the new node
    insertAfter(value) {
        
        var newNode = new DNode(value);

        newNode.next = this.next;
        newNode.prev = this;

        if (newNode.next != undefined) {
            newNode.next.prev = newNode;
        }

        this.next = newNode;

        return newNode;
    }
}

// Create list: A, D, B, E, C
var a = new DNode("A");
var b = a.insertAfter("B");
var c = b.insertAfter("C");
var d = a.insertAfter("D");
var e = b.insertAfter("E");

assert(a.value == "A");
assert(a.prev == undefined);
assert(a.next == d);

assert(d.value == "D");
assert(d.prev == a);
assert(d.next == b);

assert(b.value == "B");
assert(b.prev == d);
assert(b.next == e);

assert(e.value == "E");
assert(e.prev == b);
assert(e.next == c);

assert(c.value == "C");
assert(c.prev == e);
assert(c.next == undefined);

The algorithmic performance of insertAfter(...) is O(1).

class DNode {
    
    ...
    
    // Creates a new node to hold value, then inserts the new node before
    // this node.
    //
    // Returns a reference to the new node
    insertBefore(value) {

        var newNode = new DNode(value);

        newNode.next = this;
        newNode.prev = this.prev;

        if (newNode.prev != undefined) {
            newNode.prev.next = newNode;
        }

        this.prev = newNode;

        return newNode;
    }
}

// Create list: C, D, B, E, A
var a = new DNode("A");
var b = a.insertBefore("B");
var c = b.insertBefore("C");
var d = b.insertBefore("D");
var e = a.insertBefore("E");

assert(c.value == "C");
assert(c.prev == undefined);
assert(c.next == d);

assert(d.value == "D");
assert(d.prev == c);
assert(d.next == b);

assert(b.value == "B");
assert(b.prev == d);
assert(b.next == e);

assert(e.value == "E");
assert(e.prev == b);
assert(e.next == a);

assert(a.value == "A");
assert(a.prev == e);
assert(a.next == undefined);

The algorithmic performance of insertBefore(...) is O(1).

class DNode {
    
    ...
    
    // Creates a new node to hold value, then appends the new node to 
    // the end of the list.
    //
    // this node __must__ be the last node in the list
    // 
    // Returns a reference to the new node
    append(value) {
        if (this.next == undefined) {
            return this.insertAfter(value);
        } else {
            console.error("this node __must__ be the last node in the list");
        }
    }
}

// Test for append
// Create a list A, B, C
var a = new DNode("A");
var b = a.append("B");
var c = b.append("C");

assert(a.value == "A");
assert(a.prev == undefined);
assert(a.next == b);

assert(b.value == "B");
assert(b.prev == a);
assert(b.next == c);

assert(c.value == "C");
assert(c.prev == b);
assert(c.next == undefined);

The algorithmic performance of append(...) is O(1).

Comparison to singly linked list

The append(...) method for a singly linked list is O(N).

The append(...) method for a doubly linked list is O(1).

This dramatic difference in performance is the primary motivator for using doubly linked lists instead of singly linked lists --- at least for our purposes.

In Part 2, append(...) turns out to be an important method that is used by the Queue class.

class DNode {
    
    ...
    
    // Creates a new node to hold value, then prepend the new node to 
    // the head of the list.
    //
    // this node __must__ be the first node in the list
    // 
    // Returns a reference to the new node
    prepend(value) {
        if (this.prev == undefined) {
            return this.insertBefore(value);
        } else {
            console.error("this node __must__ be the first node in the list");
        }
    }
}

// Test for prepend
// Create a list C, B, A
var a = new DNode("A");
var b = a.prepend("B");
var c = b.prepend("C");

assert(c.value == "C");
assert(c.prev == undefined);
assert(c.next == b);

assert(b.value == "B");
assert(b.prev == c);
assert(b.next == a);

assert(a.value == "A");
assert(a.prev == b);
assert(a.next == undefined);

The algorithmic performance of prepend(...) is O(1).

class DNode {
    
    ...
    
    // Removes this node from the list.
    // 
    // Returns [v, prev, next] where:
    //      - v is the value that was removed
    //      - prev is this node's previous node
    //      - next is this node's next node
    remove() {
        if (this.prev != undefined) {
            this.prev.next = this.next;
        }

        if (this.next != undefined) {
            this.next.prev = this.prev;
        }

        return [this.value, this.prev, this.next];
    }
}

// Test for remove
// Create a list A, B, C
function newList() {
    var a = new DNode("A");
    var b = a.append("B");
    var c = b.append("C");
    return [a, b, c];
}

// Remove A, then B, then C
var [a,b,c] = newList();

var [v, prev, next] = a.remove();
assert(v == "A");
assert(prev == undefined);
assert(next == b);

var [v, prev, next] = b.remove();
assert(v == "B");
assert(prev == undefined);
assert(next == c);

var [v, prev, next] = c.remove();
assert(v == "C");
assert(prev == undefined);
assert(next == undefined);

// Remove B
var [a,b,c] = newList();

var [v, prev, next] = b.remove();
assert(v == "B");
assert(prev == a);
assert(next == c);

// Remove C, then B, then A
var [a,b,c] = newList();

var [v, prev, next] = c.remove();
assert(v == "C");
assert(prev == b);
assert(next == undefined);

var [v, prev, next] = b.remove();
assert(v == "B");
assert(prev == a);
assert(next == undefined);

var [v, prev, next] = a.remove();
assert(v == "A");
assert(prev == undefined);
assert(next == undefined);

The algorithmic performance of remove(...) is O(1).

class DNode {
    
    ...

    // Removes the first node from the list.
    //
    // this node __must__ be the first node in the list
    //
    // Returns [v, head], where v is the value of the removed node, and
    // head is a reference for the new head node
    removeFirst() {
        if (this.prev == undefined) {
            var [v, _, newHead] = this.remove();
            return [v, newHead];
        } else {
            console.error("this node __must__ be the first node in the list");
        }
    }
}

// Test for removeFirst
// Create a list A, B, C
var a = new DNode("A");
var b = a.append("B");
var c = b.append("C");

var [aValue, newHead] = a.removeFirst();
assert(aValue == "A");
assert(b == newHead);
assert(newHead.prev == undefined);

var [bValue, newHead] = newHead.removeFirst();
assert(bValue == "B");
assert(c == newHead);
assert(newHead.prev == undefined);

var [cValue, newHead] = newHead.removeFirst();
assert(cValue == "C");
assert(newHead == undefined);

The algorithmic performance of removeFirst(...) is O(1).

class DNode {
    
    ...

    // Removes the last node from the list.
    //
    // this node __must__ be the last node in the list
    //
    // Returns [v, last], where v is the value of the removed node, and
    // last is a reference for the new last node
    removeLast() {
        if (this.next == undefined) {
            var [v, newLast, _] = this.remove();
            return [v, newLast];
        } else {
            console.error("this node __must__ be the last node in the list");
        }
    }
}

// Test for removeLast
// Create a list A, B, C
var a = new DNode("A");
var b = a.append("B");
var c = b.append("C");

var [cValue, newLast] = c.removeLast();
assert(cValue == "C");
assert(b == newLast);
assert(newLast.next == undefined);

var [bValue, newLast] = newLast.removeLast();
assert(bValue == "B");
assert(a == newLast);
assert(newLast.next == undefined);

var [aValue, newLast] = newLast.removeLast();
assert(aValue == "A");
assert(newLast == undefined);

The algorithmic performance of removeLast(...) is O(1).

class DNode {
    
    ...

    // Deletes the first node with the specified value.
    // It is an error if value is not found in the list.
    //
    // Returns [head, last], where
    //   head is the head of the new list
    //   last is the last node of the new list
    //
    // Arguments:
    //   value, the value to search for
    //   head, a reference to the first node in the list
    //   last, a reference to the last node in the list
    removeValue(value, head, last) {

        // Base Case 1: When we have found the sought-after value
        if (this.value == value) {

            if (this.prev == undefined) {
                head = this.next;
            }

            if (this.next == undefined) {
                last = this.prev;
            }

            this.remove();

            return [head, last];
        }
        
        // Base Case 2: When we have reached the end of the list
        else if (this.next == undefined) {
            console.error("The list did not contain the value we're looking for");
        }
        
        // Recursive case
        else {
            return this.next.removeValue(value, head, last);
        }

    }
}

// Test for removeValue(...)
var [a,b,c] = newList();

// remove first
var [newHead, newLast] = a.removeValue("A", a, c);
assert(newHead == b);
assert(newLast == c);
assert(b.prev == undefined);

// remove last
var [newHead, newLast] = b.removeValue("C", b, c);
assert(newHead == b);
assert(newLast == b);
assert(b.prev == undefined);

// remove middle
var [a,b,c] = newList();

var [newHead, newLast] = a.removeValue("B", a, c);
assert(newHead == a);
assert(newLast == c);
assert(a.next == c);
assert(c.prev == a);

The algorithmic performance of removeValue(...) is O(N).

class DNode {
    
    ...
    
    // Finds and returns the smallest value in this list
    findSmallest() {

        // Base Case: When we have reached the end of the list
        if (this.next == undefined) {
            return this.value;
        }
        
        // Recursive case
        else {
            var smallest = this.next.findSmallest();

            if (this.value < smallest) {
                return this.value;
            } else {
                return smallest;
            }
        }
    }
 }
 
 // Test findSmallest(...)
var one = new DNode("1");
var two = one.append("2");
var three = two.append("3");
assert(one.findSmallest() == "1");

var two = new DNode("2");
var one = two.append("1");
var three = one.append("3");
assert(two.findSmallest() == "1");

var two = new DNode("2");
var three = two.append("3");
var one = three.append("1");
assert(two.findSmallest() == "1");

The algorithmic performance of findSmallest(...) is O(N).

class DNode {
    
    ...

    // Sorts the list in ascending order.
    //
    // Arguments: 
    //   head, a reference to the first node in the list
    //   last, a reference to the last node in the list
    //
    // Returns the head of the new list.
    sort(head, last) {

        // Base case
        if (this.next == undefined) {
            return this;
        }

        // Recursive case
        else {
            var smallest = this.findSmallest();
            var [sublist, _] = this.removeValue(smallest, head, last);
            var sortedSublist = sublist.sort();
            return sortedSublist.prepend(smallest);
        }
    }
}

// Test sort()
var one = new DNode(1);
var two = one.append(2);
var three = two.append(3);
var sorted = one.sort(one, three);

aNode = sorted;
bNode = aNode.next;
cNode = bNode.next;

assert(aNode.value == 1);
assert(bNode.value == 2);
assert(cNode.value == 3);
assert(cNode.next == undefined);


var two = new DNode(2);
var one = two.append(1);
var three = one.append(3);
var sorted = two.sort(two, three);

aNode = sorted;
bNode = aNode.next;
cNode = bNode.next;

assert(aNode.value == 1);
assert(bNode.value == 2);
assert(cNode.value == 3);
assert(cNode.next == undefined);


var two = new DNode(2);
var three = two.append(3);
var one = three.append(1);
var sorted = two.sort(two, one);

aNode = sorted;
bNode = aNode.next;
cNode = bNode.next;

assert(aNode.value == 1);
assert(bNode.value == 2);
assert(cNode.value == 3);

The algorithmic performance of sort(...) is O(N^2).

// Modifies this this list by concatenating secondList to this list
//
// Arguments: 
//   firstLast, a reference to the last node in this list
//   secondHead, a reference to the first node in the second list
//
//   firstLast and secondHead must != undefined
concat(firstLast, secondHead) {
    firstLast.next = secondHead;
    secondHead.prev = firstLast;
}

var aNode = new DNode("A");
var bNode = new DNode("B");
aNode.concat(aNode, bNode);

assert(aNode.next == bNode);
assert(bNode.prev == aNode);

Algorithmic performance

concat(...) is O(1)

Part 2. Queues and stacks

A queue is a simple and useful data structure. It is often implemented using a linked list (in our implementation we use a doubly linked list).

A queue is an object that has three methods:

  • queue.enqueue(value) appends value to queue's list
  • queue.dequeue() removes the first value from the queue's list, and returns it
  • isEmpty() returns true if, and only if, the queue's list is empty

For example:

var q = new Queue();

q.enqueue(1);
q.enqueue(2);
q.enqueue(3);

var value = q.dequeue();
assert(value == 1);

q.enqueue(4);

var value = q.dequeue();
assert(value == 2);

var value = q.dequeue();
assert(value == 3);

var value = q.dequeue();
assert(value == 4);

A queue is the natural datastructure for representing a line of people waiting to get on a bus:

  • You step in line via the enqueue method
  • When it is your turn, you step out of the line via the dequeue method

A queue is said to be a FIFO data structure, which stands for First In, First Out. The first person to step in line is also the first person to step out of line.

In a future mini course, and also in a future project, we will use queues to accomplish amazing feats!

Queue implementation

Here is a complete queue implementation.

class Queue {
    constructor() {
        this.head = undefined;
        this.last = undefined;
    }

    enqueue(value) {
        if (this.head == undefined) {
            this.head = this.tail = new DNode(value);
        } else {
            this.tail = this.tail.append(value);
        }
    }

    dequeue() {
        if (this.head == undefined) {
            console.error("Cannot dequeue an empty queue");
        } else {
            var [value, newHead] = this.head.removeFirst();
            this.head = newHead;

            if (this.head == undefined) {
                this.tail = undefined;
            }

            return value;
        }
    }

    isEmpty() {
        return this.head == undefined;
    }
}

// Test for Queue
var q = new Queue();
assert(q.isEmpty());
q.enqueue(1);
assert(!q.isEmpty());
var value = q.dequeue();
assert(value == 1);
assert(q.isEmpty());

q.enqueue(1);
assert(!q.isEmpty());
q.enqueue(2);
assert(!q.isEmpty());
var value = q.dequeue();
assert(value == 1)
assert(!q.isEmpty());
var value = q.dequeue();
assert(value == 2)
assert(q.isEmpty());

q.enqueue(1);
assert(!q.isEmpty());
q.enqueue(2);
assert(!q.isEmpty());
q.enqueue(3);
var value = q.dequeue();
assert(value == 1)
assert(!q.isEmpty());
var value = q.dequeue();
assert(value == 2)
assert(!q.isEmpty());
var value = q.dequeue();
assert(value == 3)
assert(q.isEmpty());

Efficiency

Queues are efficient.

The algorithmic performance of enqueue(...) is O(1).

The algorithmic performance of dequeue(...) is O(1).

The algorithmic performance of isEmpty(...) is O(1).

A stack is a simple and useful data structure. It is often implemented using a linked list (in our implementation we use a doubly linked list).

Queues and stacks are complementary.

A stack is an object that has three methods:

  • stack.push(value) prepends value to stack's list
  • stack.pop() removes the first value from the stack's list, and returns it
  • isEmpty() returns true if, and only if, the stack's list is empty

For example:

var stack = new Stack();

stack.push(1);
stack.push(2);
stack.push(3);

var value = stack.pop();
assert(value == 3);

stack.push(4);

var value = stack.pop();
assert(value == 4);

var value = stack.pop();
assert(value == 2);

var value = stack.pop();
assert(value == 1);

A stack is the natural datastructure for representing a stack of books on your desk.

  • You add a book to the stack by plopping it on top of the stack
  • You retrieve a book from the stack by taking off the top book from the stack.

A stack is said to be a LIFO data structure, which stands for Last In, First Out. The last book added to the stack, is the first book to be removed from the stack.

Stack implementation

Here is a complete stack implementation.

class Stack {
    constructor() {
        this.head = undefined;
    }

    push(value) {
        if (this.head == undefined) {
            this.head = new DNode(value);
        } else {
            this.head = this.head.prepend(value);
        }
    }

    pop() {
        if (this.head == undefined) {
            console.error("Cannot pop an empty stack");
        } else {
            var [value, newHead] = this.head.removeFirst();
            this.head = newHead;

            return value;
        }
    }

    isEmpty() {
        return this.head == undefined;
    }

}

// Test for Stack
var stack = new Stack();
assert(stack.isEmpty());
stack.push(1);
assert(!stack.isEmpty());
var value = stack.pop();
assert(value == 1);
assert(stack.isEmpty());

stack.push(1);
assert(!stack.isEmpty());
stack.push(2);
assert(!stack.isEmpty());
var value = stack.pop();
assert(value == 2)
assert(!stack.isEmpty());
var value = stack.pop();
assert(value == 1)
assert(stack.isEmpty());

stack.push(1);
assert(!stack.isEmpty());
stack.push(2);
assert(!stack.isEmpty());
stack.push(3);
var value = stack.pop();
assert(value == 3)
assert(!stack.isEmpty());
var value = stack.pop();
assert(value == 2)
assert(!stack.isEmpty());
var value = stack.pop();
assert(value == 1)
assert(stack.isEmpty());

stack.push(1);
assert(!stack.isEmpty());
stack.push(2);
assert(!stack.isEmpty());
stack.push(3);
var value = stack.pop();
assert(value == 3)
assert(!stack.isEmpty());
stack.push(4);
assert(!stack.isEmpty());
var value = stack.pop();
assert(value == 4)
assert(!stack.isEmpty());
var value = stack.pop();
assert(value == 2)
assert(!stack.isEmpty());
var value = stack.pop();
assert(value == 1)
assert(stack.isEmpty());

Efficiency

Stacks are efficient.

The algorithmic performance of push(...) is O(1).

The algorithmic performance of pop(...) is O(1).

The algorithmic performance of isEmpty(...) is O(1).

Function Singly linked list Doubly linked List
append O(N) O(1)
prepend O(1) O(1)
removeFirst O(1) O(1)
removeLast O(N) O(1)
removeValue O(N) O(N)
findSmallest O(N) O(N)
sort O(N^2) O(N^2)
concat O(1) O(1)