CS140 Lecture notes -- Doubly Linked Lists

Jim Plank (with modifications by Brad Vander Zanden)


Doubly Linked Lists and Circular Lists

Doubly linked lists are like singly linked lists, except each node has two pointers -- one to the next node, and one to the previous node. This makes life nice in many ways: The implementation that we will use has a couple of other features that makes life easier on the implementor, without being noticeable to the user of the doubly-linked list:

  1. Circular Linked List: A circular list is one in which the last node in the list points to the first node. Circular lists are useful in certain applications where you want to repeatedly go around the list. For example, when multiple applications are running on a PC, it is common for the operating system to put the running applications on a list and then to cycle through them, giving each of them a slice of time to execute, and then making them wait while the CPU is given to another application. It is convenient for the operating system to use a circular list so that when it reaches the end of the list it can cycle around to the front of the list. Circular linked lists also make our implementation easier, because they eliminate the boundary conditions associated with the beginning and end of the list, thus eliminating the special case code required to handle these boundary conditions.

  2. Sentinel Node: The Wikipedia notes briefly mention using a sentinel node to simplify the implementation of linked lists. A sentinel node is a dummy node that goes at the front of a list. In a doubly-linked list, the sentinel node points to the first and last elements of the list. We no longer need to keep separate pointers for the head and tail of the list, like we had to do with singly-linked lists. We also do not have to worry about updating the head and tail pointers, since as we shall see, this happens automatically if we insert after a sentinel node, hence prepending an item to the list, or insert before a sentinel node, hence appending an item to the list. We could eliminate the container object that we used for singly linked lists, since the sentinel node can keep track of both the first and last elements in the list. If we did so, then we would return a pointer to the sentinel node to the user. However, data structures are generally designed with a container object that mediates the communication between the user of the data structure and the implementation of the data structure, so we will retain the container object.


Implementation

The implementation of each dllist is as a circular doubly-linked list with a sentinel node. Each node has two pointers -- a forward link (next) to the next node on the list, and a backward link (prev) to the previous node on the list. A Dllist container object contains a pointer to the sentinel node and is the object that is returned to the user.

The list is circular in both directions -- the sentinel's next points to the first node on the list, and its prev points to the last node on the list. The first node's prev points to the sentinel, as does the last node's next.

Some ascii art: Here's an empty list l:

l -->sentinel_node-----------+--> |-----------|
                             |    | next ---------\
                             |    | prev -------\  |
                             |    | val = ?   |  | |
                             |    |-----------|  | |
                             |                   | |
                             \-------------------+-/
Note that the sentinel node's next and prev fields simply point back to itself.

Here's the list after calling dll_append(l, p) (or dll_prepend(l, p) for that matter). We will assume that p contains the pointer address 0x100:

l -->sentinel_node-----------+--> |-----------|       |-------------|
                             |    | next -----|------>| next ----------\
                             |  --| prev      |<------|-prev        |  |
                             |  | | val = ?   |       | val = 0x100 |  |
                             |  | |-----------|   /-->|-------------|  | 
                             |  \----------------/                     |
                             \---------------------------------------+-/
Here's that list after calling dll_append(l, p1) with p1 having the value 0x200:
l -->sentinel_node-------->|--------|      |-------------|      |-------------|
                      /--->| next --|----->| next -------|----->| next -------|----\
                      |  /-|-prev   |<-----|-prev        |<-----|-prev        |<-\ |
                      |  | | val = ?|      | val = 0x100 |      | val = 0x200 |  | |
                      |  | |--------|      |-------------|      |-------------|  | |
                      |  |                                                       | |
                      |  \-------------------------------------------------------/ |
                      |                                                            |
                      \------------------------------------------------------------/
I won't go over more examples with ascii art. You should be getting the hang of this by now.


The Dllist API

The API for doubly linked lists is in dllist.h. Like singly lists, it defines two structs: a container struct called Dllist that holds administrative information about the list, and a node struct called Dllist_Node that contains the information for a single node in the list.

typedef struct dllist_node {
  struct dllist_node *next;
  struct dllist_node *prev;
  void *val;
} Dllist_Node;

typedef struct {
  Dllist_Node *sentinel_node;
} Dllist;

Here are the operations supported by dllist.o:


Code Implementation

The implementations for several of the dllist functions are given here, and you will be expected to write the rest in lab:


Dllist Creation and Destruction

The code to create a new dllist performs the following set of tasks:

  1. it mallocs a container struct and a sentinel node.
  2. it makes the next and prev links of the sentinel node point to the sentinel node
  3. it returns a pointer to the container struct
Here is the code:
Dllist* new_dllist()
{
  Dllist *d;
  Dllist_Node *node;

  d = (Dllist *) malloc (sizeof(Dllist));
  node = (Dllist_Node *)malloc(sizeof(Dllist_Node));
  d->sentinel_node = node;
  node->next = node;
  node->prev = node;
  return d;
}
The code to destroy a dllist performs the following two tasks:

  1. It iterates through the nodes of the list and deletes each node. Note how it iterates through the nodes by repeatedly accessing the first element of the list and deleting it. It stops when the list becomes empty.
  2. When the list becomes empty, it still has the sentinel node, so next we must free the sentinel node
  3. Finally it frees the container object.
void free_dllist(Dllist *l)
{
  while (!dll_empty(l)) {
    dll_delete_node(dll_first(l));
  }
  free(l->sentinel_node);
  free(l);
}

Insertions into a Dllist

There are four ways we can insert into a dllist:

  1. inserting before a node using dll_insert_before.
  2. inserting after a node using dll_insert_after.
  3. inserting at the front of the list using dll_prepend.
  4. inserting at the end of the list using dll_append.

These notes show you the implementation for dll_insert_before. You will implement the remaining three functions in lab.

dll_insert_before(node, value) needs to create a new node and insert it before node. The following list of steps will accomplish this task:

  1. malloc() a new node
  2. set the new node's val field to value
  3. link the new node into the list right before node. It helps to make a variable point to the node that used to precede node and call this variable prev_node. The node that used to precede node can be found in node->prev. Now we can link the new node in the list by:
    1. setting the new node's next field to node,
    2. setting the new node's prev field to prev_node.
    3. making the new node be the predecessor node for node. We do this by setting node->prev to the new node.
    4. making prev_node point to the new node. We do this by setting prev_node->next to the new node.
  4. return the new node
Dllist_Node *dll_insert_before(Dllist_Node *node, void *v)
{
  Dllist_Node *newnode;
  Dllist_Node *prev_node = node->prev;

  newnode = (Dllist_Node *) malloc (sizeof(Dllist_Node));
  newnode->val = v;

  newnode->next = node;
  newnode->prev = prev_node;
  node->prev = newnode;
  prev_node->next = newnode;

  return newnode;
}
You can use similar logic to write the other insertion functions, or you can take advantage of the following observations and make each of the other insertion functions call dll_insert_before:

  1. dll_insert_after(node, value) is really just an insertion before node->next.
  2. dll_prepend(list, value) is really just an insertion before the first node in the list.
  3. dll_append(list, value) is really just an insertion before the sentinel node.


Deletion from a Dllist

To delete a node from a dllist you must 1) link the node that precedes the deleted node, called the Before Node with the node that follows the deleted node, called the After Node, and 2) delete the node. A detailed list of the tasks is as follows:

  1. locate the Before and After nodes. The prev field of the node to be deleted points to the Before node and the next field of the node to be deleted points to the After node.
  2. make the next field of the Before Node point to the After Node.
  3. make the prev field of the After Node point to the Before Node
  4. free the deleted node by calling free.

You will write the dll_delete_node function in lab.


Accessor Functions

The dll_first function returns the first element after the sentinel node:

Dllist_Node *dll_first(Dllist *l) {
  return l->sentinel_node->next;
}
Similarly, the dll_last function returns the first element before the sentinel node.
Dllist_Node *dll_last(Dllist *l) {
  return l->sentinel_node->prev;
}

The dll_next function returns the node after the current one in the list and the dll_prev function returns the node before the current one in the list:

Dllist_Node *dll_next(Dllist_Node *n) {
  return n->next;
}

Dllist_Node *dll_prev(Dllist_Node *n) {
  return n->prev;
}
The implementations for the other accessor functions are similarly trivial and can be found in dllist.c


Usage examples

The first example is one of our standards: reversing standard input. This is simple enough to need no explanation. It's in dllreverse.c.

The second example updates our sorting program from the singly linked list lecture. The code is identical except for the code for inserting items into the linked list. Recall that the strategy for inserting a name into a linked list is to traverse the nodes in the list until we find a node whose score is greater than the score of the person we want to insert. We then insert the new name before this node. When we use a singly linked list we need to maintain a pointer to the previous node as well as the current node because a singly linked list cannot support an "insert before" operation. However, a node in a doubly linked list has a pointer to its predecessor and therefore doubly linked lists can support an "insert before" operation. As a result we do not need to maintain the prev node pointer anymore and our insertion code is simplified:

#include <stdio.h>
#include <string.h>
#include "fields.h"
#include "dllist.h"

typedef struct {
  char *fname;
  char *lname;
  double score;
} Person;

/* Insert the new person into the list while maintaining a sorted order.
 * we need to traverse through the list until we find a node whose score
 * is greater than the score of the inserted person. We then need to
 * insert the new node *before* the node at which we stopped. Doubly
 * linked lists easily support this operation while singly linked lists
 * do not. 
 * 
 */
void insert_person(Person *p, Dllist *student_list) {
  Dllist_Node *current_node;
  Person *current_person;

  for (current_node = dll_first(student_list); 
        current_node != dll_sentinel(student_list);
	current_node = dll_next(current_node)) {
    current_person = (Person *)dll_val(current_node);
    if (p->score < current_person->score)
      break;
  }
  dll_insert_before(current_node, p);
}

...


Comparison Between Singly Linked and Doubly Linked Lists

At the beginning of these notes I cited a number of advantages that doubly linked lists have over singly linked lists. Now that you have seen how doubly linked lists are implemented you will probably agree that they are a little more complicated to implement and take a little more space than singly linked lists. Space is normally not an issue in today's computers. If you have an application that requires a linked list implementation and you have a pre-defined library such as the dllists library available, you should use it. If you have to write your own code, then use a singly linked list if:

  1. You only need to insert at the front or back of the list
  2. You only need to delete from the front of the list
  3. You only need to traverse the list in the forward direction

If one of these conditions is not met, then use a doubly linked list.