CS140 Lecture notes -- Doubly Linked Lists
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:
- You can traverse lists forward and backward.
- You can insert anywhere in a list easily. This includes
inserting before a node, after a node, at the front of
the list, and at the end of the list.
- You can delete nodes very easily.
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:
- 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.
- 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:
- Dllist *new_dllist(): Allocates and returns a new doubly linked list.
- void free_dllist(Dllist *l): Destroys the list, calling free()
on all allocated memory in the list. The list does not have to
be empty.
- Dllist_Node *dll_prepend(Dllist *l, void *val):
Adds a new node at the beginning of the list and returns a pointer to this node.
This node's value is val, and a pointer to the node is returned.
- Dllist_Node *dll_append(Dllist *l, void *val):
Adds a new node at the end of the list and returns a pointer to this node.
This node's value is val, and a pointer to the node is returned.
- Dllist_Node *dll_insert_before(Dllist_Node *n, void *val):
Adds a new node to the list
right before the specified node and returns a pointer to this new node.
This node's value is val.
- Dllist_Node *dll_insert_after(Dllist_Node *n, void *val):
Adds a new node to the list
right after the specified node and returns a pointer to this new node.
This node's value is val.
- void dll_delete_node(Dllist_Node *n): Deletes and frees node n.
- Dllist_Node *dll_sentinel(Dllist *l): Returns a pointer to the
sentinel node
for the list. When you are traversing through the list either forwards or
in reverse, you will know that you have reached the end of the list when
you reach the sentinel node. Hence you will need to be able to get a pointer
to the sentinel node, and dll_sentinel provides you with a method for
doing so.
- Dllist_Node *dll_first(Dllist *l): Returns a pointer to the first node
in the list.
If the list is empty, this returns the sentinel.
- Dllist_Node *dll_last(Dllist *l): Returns a pointer to the last node
in the list.
If the list is empty, this returns the sentinel.
- Dllist_Node *dll_next(Dllist_Node *n): Returns a pointer to the next node
in the list after n.
If n is the last node on the list,
then dll_next(n) returns the sentinel.
You could also just use n->next, but that is bad form because then you
make your code dependent on the implementation of the dllist. If the implementor
changes the implementation, perhaps doing something as simple as changing the
name of the next field to flink, then your code will break.
On the other hand, if you call dll_next, then your code will not break,
even if the implementor changes the implementation of the dllist code.
- Dllist_Node *dll_prev(Dllist_Node *n): Returns a pointer to the previous node
in the list before n.
If n is the first node on the list,
then dll_prev(n) returns the sentinel.
You could also just use n->prev, but see the comments for dll_next
to see why this is a bad idea.
- int dll_empty(Dllist *l): Returns true if l is empty and false
otherwise.
- void *dll_val(Dllist_Node *n): Returns node n's val
field. You could just use n->val, but see the comments for dll_next
to see why this is a bad idea.
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:
- it mallocs a container struct and a sentinel node.
- it makes the next and prev links of the sentinel node point to
the sentinel node
- 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:
- 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.
- When the list becomes empty, it still has the sentinel node, so next we
must free the sentinel node
- 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:
- inserting before a node using dll_insert_before.
- inserting after a node using dll_insert_after.
- inserting at the front of the list using dll_prepend.
- 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:
- malloc() a new node
- set the new node's val field to value
- 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:
- setting the new node's next field to node,
- setting the new node's prev field to prev_node.
- making the new node be the predecessor node for node. We do this
by setting node->prev to the new node.
- making prev_node point to the new node. We do this by setting
prev_node->next to the new node.
- 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:
- dll_insert_after(node, value) is really just an insertion before
node->next.
- dll_prepend(list, value) is really just an insertion before the
first node in the list.
- 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:
- 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.
- make the next field of the
Before Node point to the After Node.
- make the prev field of the After Node
point to the Before Node
- 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:
- You only need to insert at the front or back of the list
- You only need to delete from the front of the list
- 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.