std::forward_list
So far, we've only seen array-like structures, but, as we saw, insertion and deletion in the middle of the data structures are very inefficient operations for contiguous data structures. And that's where linked-list-like structures come into the picture. A lot of applications require frequent insertion and deletion in the middle of a data structure. For example, any browser with multiple tabs can have an extra tab added at any point in time and at any location. Similarly, any music player will have a list of songs that you can play in a loop, and you can also insert any songs in the middle. In such cases, we can use a linked-list structure for good performance. We'll see the use case of a music player in Activity 1, Implementing a Song Playlist. Now, let's explore what kind of containers C++ provides us with.
The basic structure of a linked list requires us to have a pointer and to manage memory allocation and deallocation manually using the new
and delete
operators. Although it is not difficult, it can lead to bugs that are difficult to trace. Hence, just like std::array
provides a thin wrapper over C-style arrays, std::forward_list
provides a thin wrapper over a basic linked list.
The purpose of std::forward_list
is to provide some additional functionality without compromising performance compared to a basic linked list. To maintain performance, it doesn't provide functions to get the size of the list or to get any element but the first one directly. Hence, it has a function called front()
to get the reference to the first element, but nothing like back()
to access the last element. It does provide functions for common operations, such as insertion, deletion, reverse, and splice. These functions don't affect the memory requirements or performance over basic linked lists.
Additionally, just like std::vector
, std::forward_list
can also take a custom allocator as the second template parameter if required. Hence, we can easily use it for advanced applications that benefit from custom memory management.
Inserting and Deleting Elements in forward_list
std:: forward_list
provides the push_front
and insert_after
functions, which can be used to insert an element in a linked list. Both of these are slightly different compared to insertion functions for vectors. push_front
is useful for inserting an element at the front. Since forward_list
doesn't have direct access to the last element, it doesn't provide a push_back
function. For insertion at a specific location, we use insert_after
instead of insert
. This is because inserting an element in a linked list requires updating the next pointer of the element, after which we want to insert a new element. If we provide just the iterator, where we want to insert a new element, we can't get access to the previous element quickly, since traversing backward is not allowed in forward_list
.
Since this is a pointer-based mechanism, we don't really need to shift the elements during insertion. Hence, both of the insertion functions are quite a bit faster compared to any array-based structures. Both the functions just modify the pointers to insert a new element at the intended position. This operation is not dependent on the size of the list and therefore has a time complexity of O(1). We shall take a look at the implementation of these functions in the following exercise.
Now, let's see how we can insert elements in a linked list:
std::forward_list<int> fwd_list = {1, 2, 3}; fwd_list.push_front(0); // list becomes {0, 1, 2, 3} auto it = fwd_list.begin(); fwd_list.insert_after(it, 5); // list becomes {0, 5, 1, 2, 3} fwd_list.insert_after(it, 6); // list becomes {0, 6, 5, 1, 2, 3}
forward_list
also provides emplace_front
and emplace_after
, which is similar to emplace
for a vector. Both of these functions do the same thing as insertion functions, but more efficiently by avoiding extra copying and moving.
forward_list
also has pop_front
and erase_after
functions for the deletion of elements. pop_front
, as the name suggests, removes the first element. Since it doesn't require any shifting, the operation is quite fast in practice and has a time complexity of O(1). erase_after
has two overloads – to remove a single element (by taking an iterator to its previous element), and to remove multiple elements in a range (by taking an iterator to the element before the first element of the range and another iterator to the last element).
The time complexity of the erase_after
function is linear to the number of elements that are erased because the deletion of elements can't be done via deallocating just a single chunk of memory. Since all the nodes are scattered across random locations in memory, the function needs to deallocate each of them separately.
Now, let's see how we can remove the elements from the list:
std::forward_list<int> fwd_list = {1, 2, 3, 4, 5}; fwd_list.pop_front(); // list becomes {2, 3, 4, 5} auto it = fwd_list.begin(); fwd_list.erase_after(it); // list becomes {2, 4, 5} fwd_list.erase_after(it, fwd_list.end()); // list becomes {2}
Let's explore what other operations we can do with forward_list
in the following section.
Other Operations on forward_list
Apart from the erase
functions to delete elements based on its position determined by iterators, forward_list
also provides the remove
and remove_if
functions to remove elements based on their values. The remove
function takes a single parameter – the value of the elements to be removed. It removes all the elements that match the given element based on the equality operator defined for the type of the value. Without the equality operator, the compiler doesn't allow us to call that function and throws a compilation error. Since remove
only deletes the elements based on the equality operator, it is not possible to use it for deletion based on other conditions, since we can't change the equality operator after defining it once. For a conditional removal, forward_list
provides the remove_if
function. It takes a predicate as a parameter, which is a function taking an element of the value type as a parameter, and a Boolean as the return value. So, all the elements for which the predicate returns true are removed from the list. With the latest C++ versions, we can easily specify the predicate with lambdas as well. The following exercise should help you to understand how to implement these functions.
Exercise 3: Conditional Removal of Elements from a Linked List Using remove_if
In this exercise, we'll use the sample information of a few Indian citizens during the elections and remove ineligible citizens, based on their age, from the electoral roll. For simplicity, we'll just store the names and ages of the citizens.
We shall store the data in a linked list and remove the required elements using remove_if
, which provides a way to remove elements that meet a certain condition, instead of defining the positions of the elements to be removed:
- Let's first include the required headers and add the
struct citizen
:#include <iostream> #include <forward_list> struct citizen { std::string name; int age; }; std::ostream& operator<<(std::ostream& os, const citizen& c) { return (os << "[Name: " << c.name << ", Age: " << c.age << "]"); }
- Now, let's write a
main
function and initialize a few citizens in astd::forward_list
. We'll also make a copy of it to avoid having to initialize it again:int main() { std::forward_list<citizen> citizens = {{"Raj", 22}, {"Rohit", 25}, {"Rohan", 17}, {"Sachin", 16}}; auto citizens_copy = citizens; std::cout << "All the citizens: "; for (const auto &c : citizens) std::cout << c << " "; std::cout << std::endl;
- Now, let's remove all of the ineligible citizens from the list:
citizens.remove_if( [](const citizen& c) { return (c.age < 18); }); std::cout << "Eligible citizens for voting: "; for(const auto& c: citizens) std::cout << c << " "; std::cout << std::endl;
The
remove_if
function removes all the elements for which the given predicate is true. Here, we've provided a lambda since the condition is very simple. If it were a complicated condition, we could also write a normal function that takes one parameter of the underlying type of list and returns a Boolean value. - Now, let's find out who'll be eligible for voting next year:
citizens_copy.remove_if( [](const citizen& c) { // Returns true if age is less than 18 return (c.age != 17); }); std::cout << "Citizens that will be eligible for voting next year: "; for(const auto& c: citizens_copy) std::cout << c << " "; std::cout << std::endl; }
As you can see, we are only keeping those citizens with an age of 17.
- Run the exercise. You should get an output like this:
All the citizens: [Name: Raj, Age: 22] [Name: Rohit, Age: 25] [Name: Rohan, Age: 17] [Name: Sachin, Age: 16] Eligible citizens for voting: [Name: Raj, Age: 22] [Name: Rohit, Age: 25] Citizens that will be eligible for voting next year: [Name: Rohan, Age: 17]
The remove_if
function has a time complexity of O(n) since it simply traverses the list once while removing all the elements as required. If we want to remove the elements with specific values, we can use another version of remove
, which simply takes one parameter of the object and removes all the objects from the list matching the given value. It also requires us to implement the ==
operator for the given type.
forward_list
also provides a sort
function to sort the data. All the array-related structures can be sorted by a generic function, std::sort(first iterator, last iterator)
. However, it can't be used by linked list-based structures because we can't access any data randomly. This also makes the iterators provided by forward_list
different from the ones for an array or a vector. We'll take a look at this in more detail in the next section. The sort
function that is provided as part of forward_list
has two overloads – sort
based on the less than operator (<
), and sort
based on a comparator provided as a parameter. The default sort
function uses std::less<value_type>
for comparison. It simply returns true
if the first parameter is less than the second one, and hence, requires us to define the less than operator (<
) for custom-defined types.
In addition to this, if we want to compare it based on some other parameters, we can use the parametric overload, which takes a binary predicate. Both the overloads have a linearathmic time complexity – O(n × log n). The following example demonstrates both overloads of sort
:
std::forward_list<int> list1 = {23, 0, 1, -3, 34, 32}; list1.sort(); // list becomes {-3, 0, 1, 23, 32, 34} list1.sort(std::greater<int>()); // list becomes {34, 32, 23, 1, 0, -3}
Here, greater<int>
is a predicate provided in the standard itself, which is a wrapper over the greater than operator (>
) to sort the elements into descending order, as we can see from the values of the list..
Other functions provided in forward_list
are reverse
and unique
. The reverse
function simply reverses the order of the elements, in a time duration that is linear to the number of elements present in the list, that is, with a time complexity of O(n). The unique
function keeps only the unique elements in the list and removes all the repetitive valued functions except the first one. Since it is dependent on the equality of the elements, it has two overloads – the first takes no parameters and uses the equality operator for the value type, while the second takes a binary predicate with two parameters of the value type. The unique
function was built to be linear in time complexity. Hence, it doesn't compare each element with every other element. Instead, it only compares consecutive elements for equality and removes the latter one if it is the same as the former one based on the default or custom binary predicate. Hence, to remove all of the unique elements from the list using the unique
function, we need to sort the elements before calling the function. With the help of a given predicate, unique
will compare all the elements with their neighboring elements and remove the latter elements if the predicate returns true
.
Let's now see how we can use the reverse
, sort
, and unique
functions for lists:
std::forward_list<int> list1 = {2, 53, 1, 0, 4, 10}; list1.reverse(); // list becomes {2, 53, 1, 0, 4, 10} list1 = {0, 1, 0, 1, -1, 10, 5, 10, 5, 0}; list1.sort(); // list becomes {-1, 0, 0, 0, 1, 1, 5, 5, 10, 10} list1.unique(); // list becomes {-1, 0, 1, 5, 10} list1 = {0, 1, 0, 1, -1, 10, 5, 10, 5, 0}; list1.sort(); // list becomes {-1, 0, 0, 0, 1, 1, 5, 5, 10, 10}
The following example will remove elements if they are not greater than the previously valid element by at least 2:
list1.unique([](int a, int b) { return (b - a) < 2; }); // list becomes {-1, 1, 5, 10}
Note
Before calling the unique
function, the programmer must make sure that the data is already sorted. Hence, we are calling the sort
function right before it. The unique
function compares the element with the previous element that has already met the condition. Additionally, it always keeps the first element of the original list. Hence, there's always an element to compare with.
In the next section, we will take a look at how the forward_list
iterator is different from the vector/array iterators.