Standard Lists

Steven J. Zeil

Last modified: Oct 26, 2023
Contents:

The list abstraction is closely related to the linked list data structure that we have already exampled.

1 The Standard list

From the point of view of the public interface, std::list is very similar to std::vector. The primary differences are:

The standard list is shown here.

namespace std {
  template <class T>
  class list {
  public:
    typedef T&         reference;
    typedef const T&   const_reference;
    typedef ...        iterator;
    typedef ...        const_iterator;
    typedef ...        size_type;
    typedef ...        difference_type;
    typedef T          value_type;
    typedef T*         pointer;
    typedef const T*   const_pointer
    typedef std::reverse_iterator<iterator>       reverse_iterator;
    typedef std::reverse_iterator<const_iterator> const_reverse_iterator;

    //  construct/copy/destroy:
    explicit list(const Allocator& = Allocator());
    explicit list(size_type n, const T& value = T());
    template <class InputIterator>
      list(InputIterator first, InputIterator last);
    list(const list<T>& x);
   ~list();
    list<T>& operator=(const list<T>& x);
    template <class InputIterator>
      void assign(InputIterator first, InputIterator last);
    void assign(size_type n, const T& t);

    // iterators:
    iterator               begin();
    const_iterator         begin() const;
    iterator               end();
    const_iterator         end() const;
    reverse_iterator       rbegin();
    const_reverse_iterator rbegin() const;
    reverse_iterator       rend();
    const_reverse_iterator rend() const;

    // _lib.list.capacity_ capacity:
    bool      empty() const;
    size_type size() const;
    size_type max_size() const;
    void      resize(size_type sz, T c = T());
    // element access:
    reference       front();
    const_reference front() const;
    reference       back();
    const_reference back() const;
    // _lib.list.modifiers_ modifiers:
    void push_front(const T& x);
    void pop_front();
    void push_back(const T& x);
    void pop_back();
    iterator insert(iterator position, const T& x);
    void     insert(iterator position, size_type n, const T& x);
    template <class InputIterator>
      void insert(iterator position, InputIterator first,
                  InputIterator last);
    iterator erase(iterator position);
    iterator erase(iterator position, iterator last);
    void     swap(list<T>&);
    void     clear();

    // _lib.list.ops_ list operations:
    void splice(iterator position, list<T>& x);
    void splice(iterator position, list<T>& x, iterator i);
    void splice(iterator position, list<T>& x, iterator first,
                iterator last);
    void remove(const T& value);
    template <class Predicate> void remove_if(Predicate pred);
    void unique();
    template <class BinaryPredicate>
      void unique(BinaryPredicate binary_pred);
    void merge(list<T>& x);
    template <class Compare> void merge(list<T>& x, Compare comp);
    void sort();
    template <class Compare> void sort(Compare comp);
    void reverse();
  };
  template <class T>
    bool operator==(const list<T>& x, const list<T>& y);
  template <class T>
    bool operator< (const list<T>& x, const list<T>& y);
  template <class T>
    bool operator!=(const list<T>& x, const list<T>& y);
  template <class T>
    bool operator> (const list<T>& x, const list<T>& y);
  template <class T>
    bool operator>=(const list<T>& x, const list<T>& y);
  template <class T>
    bool operator<=(const list<T>& x, const list<T>& y);

  // specialized algorithms:
  template <class T, class Allocator>
    void swap(list<T>& x, list<T>& y);
}

It is, for the most part, identical to the vector interface, except that list does not allow you to access elements by indexing (e.g., v[i]). Instead, any access to the contents of a std::list will almode always be provided via iterators.

list provides a few specialized operations for shifting nodes from one list to another (splice). These aren’t used all that often, but can sometimes significantly reduce the complexity of an algorithm by avoiding the need to copy long sequences of data.

list also provides some utility functions for sorting, merging and other list operations. In many cases, these same functions exist as separate function templates in std, but may not be usable with lists. For example, std has a sort function, but std::sort usually employs a variation of quicksort, and the quicksort algorithm requires random access iterators. list provides bi-directional iterators, but not random-access, so the std::sort function will not work with lists. Instead, therefore, list provides its own sort function as a member function of the list<T> class.

Let’s look at this declaration, piece by piece.

1.1 Constructors

list<int> l1;

l1 is a list of integers. Initially, it has 0 elements


list<string> l2(10, "abc");

l2 is a list of strings initialized to hold 10 copies of "abc"


  template <class T>
    // construct / copy / destroy    
    list();
    explicit list(size_type n, const T& value = T());

    template <class InputIterator>
    list(InputIterator first, InputIterator last);

    list(const list<T>& x);

    ~list();

The last constructor is rather curious. It’s actually a template function, and is designed to initialize the list with a sequence of values taken from some other container. The desired sequence is indicated via a pair of iterators, one for the beginning of the sequence, the other for the position just after the last desired element.

char[] greeting = "Hello";
list<char> l3(greeting+1, greeting+4);

l3 is a list of characters containing 'e', 'l' and another 'l'.

— In fact, a similar constructor was available for vectors as well, but I didn’t mention it then because we had not yet introduced iterators. All the standard containers support this type of constructor template. For vectors and lists, this is an $O(n)$ operation, where $n$ is the number of elements in the indicated range.

1.2 Copying

list<double> v, w;
  ⋮
v = w;
list<double> z(v);

    template <class InputIterator>
    list(InputIterator first, InputIterator last);

    list(const list<T>& x);

    ~list();

    list<T>& operator=(const list<T>& x);

    template <class InputIterator>
    void assign(InputIterator first, InputIterator last);

    void assign(size_type n, const T& u);

1.3 Size

list<int>::size_type n;
n = l1.size();

How many elements currently in l1?


l1.resize(n, x);

Add or remove elements, as necessary, at the end of the list to make l1 have exactly n elements. If elements need to be added, the value of the new elements will be x.


l1.resize(n);

Same as the last example, but if any elements need to be added to the list, the new elements are formed using their data type’s default constructor.



// capacity size_type size() const; size_type max_size() const; void resize(size_type sz, T c = T()); bool empty() const;

1.4 Inserting Elements

l2.push_back("def");

Adds a new element, "def" to the end of the list l2. l2.size() increases by 1.


l2.push_front("def");

Adds a new element, "def" to the front of the list l2. l2.size() increases by 1.


list<std::string>::iterator pos;
  ⋮
l2.insert(pos, "def");

Inserts a new element, "def", at the indicated position within the list l2. Any elements already in l2 at that position or higher are moved back one position to make room. l2.size() increases by 1.


    //  modifiers:
    void push_back(const T& x);
    void push_front(const T& x);
    void pop_back();
    void pop_front();
    iterator insert(iterator position, const T& x);
    void     insert(iterator position, size_type n, const T& x);

    template <class InputIterator>
    void insert(iterator position,
                InputIterator first, InputIterator last);

    iterator erase(iterator position);
    iterator erase(iterator first, iterator last);
    void     swap(list<T>&);
    void     clear();

1.5 Removing Elements

l2.pop_back();

Removes the element at the end of the list, decreasing the size() by 1.


l2.pop_front();

Removes the element at the front of the list, decreasing the size() by 1.


list<std::string>::iterator pos;
  ⋮
l2.erase(pos);

Removes the element at the indicated position within the list l2. Any elements already in l2 at higher positions are moved forward one position to “take up the slack”. l2.size() decreases by 1.


list<std::string>::iterator start, stop;
  ⋮
l2.erase(start, stop);

Removes the elements at the indicated positions from start up to, but not including stop, within the list l2. Any elements already in l2 at higher positions are moved forward to “take up the slack”. l2.size() decreases by the number of elements removed.


You can remove all elements from a list like this:

list<std::string>::iterator pos;
  ⋮
l2.clear();

    //  modifiers:
    void push_back(const T& x);
    void push_front(const T& x);
    void pop_back();
    void pop_front();
    iterator insert(iterator position, const T& x);
    void     insert(iterator position, size_type n, const T& x);

    template <class InputIterator>
    void insert(iterator position,
                InputIterator first, InputIterator last);

    iterator erase(iterator position);
    iterator erase(iterator first, iterator last);
    void     swap(list<T>&);
    void     clear();

1.6 Access to Elements

x = l1.front();
l1.front() = l1.front()+1;

Provides access to the first element in the list.


x = l1.back();
l1.back() = l1.back()+1;

Provides access to the last element in the list.


    // element access:
    reference       front();
    const_reference front() const;
    reference       back();
    const_reference back() const;

1.6.1 Access to Interior Elements

None of the operations described so far give access to elements other than the front and back.

list<string> names;
   ⋮
for (list<string>::iterator pos = names.begin(); 
      pos != names.end(); ++pos)
{
  cout << "One of the names is " << *pos << endl;
}

or, in C++11,

list<string> names;
   ⋮
for (auto pos = names.begin(); pos != names.end(); ++pos)
{
  cout << "One of the names is " << *pos << endl;
}

or, using the range-based for loop:

list<string> names;
   ⋮
for (string aName: names)
{
  cout << "One of the names is " << aName << endl;
}

1.7 Comparing Lists

Lists can be compared using == and < operators.

These rules are essentially the same as described earlier for vectors.

2 Implementing the List ADT

The std::list is usually implemented as pointers to the first and last nodes in a fairly conventional doubly-linked list of nodes like these:

template <class T>
struct listNode {
  T data;
  listNode<T>* prevLink;
  listNode<T>* nextLink;
};

Once that’s been established, the individual functions are pretty much straightforward variations on the linked list algorithms that we have already looked at.

Your text adds the additional wrinkle of using sentinel nodes at each end of the list. You can read an explanation of the implementation there.

2.1 iterators

The iterators are declared as classes that present the typical iterator interface, implemented using a pointer to the list node representing the current position and a pointer to the list object itself.

    class const_iterator
    {
      public:
  
        // Public constructor for const_iterator.
        const_iterator( ) : current{ nullptr }
          { }

        // Return the object stored at the current position.
        // For const_iterator, this is an accessor with a
        // const reference return type.
        const Object & operator* ( ) const
          { return retrieve( ); }
        
        const_iterator & operator++ ( )
        {
            current = current->next;
            return *this;
        }

        const_iterator operator++ ( int )
        {
            const_iterator old = *this;
            ++( *this );
            return old;
        }

        const_iterator & operator-- ( )
        {
            current = current->prev;
            return *this;
        }

        const_iterator operator-- ( int )
        {
            const_iterator old = *this;
            --( *this );
            return old;
        }
            
        bool operator== ( const const_iterator & rhs ) const
          { return current == rhs.current; }

        bool operator!= ( const const_iterator & rhs ) const
          { return !( *this == rhs ); }

      protected:
        Node *current;

        // Protected helper in const_iterator that returns the object
        // stored at the current position. Can be called by all
        // three versions of operator* without any type conversions.
        Object & retrieve( ) const
          { return current->data; }

        // Protected constructor for const_iterator.
        // Expects a pointer that represents the current position.
        const_iterator( Node *p ) :  current{ p }
          { }
        
        friend class List<Object>;
    };

You can see, for example, that incrementing one of these iterators involves simply following a “next” pointer by one hop.

3 Required Performance

The C++ standard specifies that a legal (i.e., standard-conforming) implementation of list must satisfy the following performance requirements:

Operation Speed
list() O(1)
list(n, x) O(n)
size() O(size())
push_back(x) O(1)
push_front(x) O(1)
pop_back O(1)
pop_front O(1)
insert O(1)
erase O(1)
front, back O(1)
splice O(1)

Perhaps the only surprise here is that size() is O(size()). The relatively seldom-used splice operation allows one to transfer arbitrarily long ranges of elements from one list to another. It is supposed to take place in O(1) time. But there’s no way for a list to know how many elements hjave been removed or added in a splice in that amount of time. Consequently, lists cannot keep track of their sizes as things are added and removed, the way that were able to do with vector. Hence every call to list::size() must actually walk the entire list, counting the nodes.

Beware, therefore, of writing loops like this:

void process (const list<T>& aList)
{
   int numProcessed = 0;
   auto pos = aList.begin();
   while (numProcessed < aList.size())
   {
      doSomethingWith(*pos);
	  ++pos;
	  ++numProcessed;
   }
}

(I often see code like this from clumsy conversions of code from arrays to lists.)

Because size() is O(size()), and because it is being called on each iteration of the loop, this function winds up being $O(\mbox{aList.size()}^2)$.

This is better, being $O(\mbox{aList.size()})$.

void process (const list<T>& aList)
{
   int numProcessed = 0;
   int numToBeProcessed = alist.size();
   auto pos = aList.begin();
   while (numProcessed < numToBeProcessed)
   {
      doSomethingWith(*pos);
	  ++pos;
	  ++numProcessed;
   }
}

Of course, this is both $O(\mbox{aList.size()})$ and simpler:

void process (const list<T>& aList)
{
   auto pos = aList.begin();
   while (pos != aList.end())
   {
      doSomethingWith(*pos);
	  ++pos;
   }
}

and this is the same complexity and still simpler:

void process (const list<T>& aList)
{
   for (const T& t: aList)
   {
      doSomethingWith(t);
   }
}