Ежедневный бит (е) C ++ # 140, связанные списки C ++ в задачах интервью.
Сегодня мы немного отойдем от типичного расписания содержания интервью и вместо проблемы собеседования рассмотрим немного теории и поговорим о связанных списках.
Хотя в практических приложениях связанные списки встречаются редко, в интервью они часто появляются. Отчасти это связано с тем, что структура узлов позволяет формулировать сложные задачи, подобные деревьям и графам, без дополнительной топологической сложности.
std::list и std::forward_list
Стандартная библиотека предлагает два типа списков: std::list — двусвязный список и std::forward_list — односвязный список. Прямой список существует в первую очередь как оптимизация пространства, экономя 8 байтов на элемент в 64-битных архитектурах.
Оба обеспечивают идеальную стабильность итераторов и ссылок, т. е. единственная операция, которая делает недействительными итераторы или ссылки, — это стирание элемента и только для удаленного элемента. Стабильность распространяется даже на перемещение элементов между списками.
#include <list> std::list<int> first{1,2,3}; std::list<int> second{4,5,6}; // Get iterator to the element with value 2 auto it = std::next(first.begin()); // Move the element to the begining of the second list second.splice(second.begin(), first, it); // first == {1, 3}, second == {2,4,5,6} // iterator still valid // *it == 2
Откройте пример в Compiler Explorer.
Стабильность итератора — это один из вариантов использования std::list или std::forward_list в практических приложениях. Единственной разумной альтернативой было бы обертывание каждого элемента в std::unique_ptr, что обеспечивает стабильность ссылок (но не стабильность итератора) независимо от контейнера-оболочки.
#include <vector> #include <memory> std::vector<std::unique_ptr<int>> stable; stable.push_back(std::make_unique<int>(1)); stable.push_back(std::make_unique<int>(2)); stable.push_back(std::make_unique<int>(3)); // get a stable weak reference (or pointer) to an element int *it = stable[1].get(); stable.erase(stable.begin()); // invalidates all iterators // it still valid, *it == 2
Откройте пример в Compiler Explorer.
Конечно, за эту стабильность мы платим производительностью. Связанные списки — это контейнеры на основе узлов, что означает, что каждый элемент размещается в отдельном узле, потенциально очень удаленном друг от друга в памяти. Когда мы объединяем это с неотъемлемыми издержками косвенного обращения, обход std::list может регулярно заканчиваться в 5–10 раз медленнее, чем эквивалентный плоский std::vector.
Помимо стабильности итератора, мы также получаем доступ к набору операций O(1), которые потенциально могут перевесить накладные расходы, присущие std::list.
#include <list> std::list<int> data{1,2,3,4,5}; // O(1) splicing between lists, or within one list // effectively rotate left by one element data.splice(data.end(), data, data.begin()); // data == {2,3,4,5,1} // O(1) erase // iterator to element with value 4 auto it = std::next(data.begin(), 2); data.erase(it); // data == {2,3,5,1} // O(1) insertion // effectively push_front() data.insert(data.begin(), 42); // data = {42,2,3,5,1}
Откройте пример в Compiler Explorer.
Мы не можем использовать некоторые стандартные алгоритмы, потому что std::list предоставляет только двунаправленные итераторы, а std::forward_list — только прямые итераторы. Оба списка предоставляют пользовательские реализации сортировки, уникального, объединения, обратного, удаления. и remove_if в качестве функций-членов.
#include <list> std::list<int> data{1,2,3,4,5}; data.reverse(); // data = {5,4,3,2,1} data.sort(); // data = {1,2,3,4,5} data.remove_if([](int v) { return v % 2 == 0; }); // data == {1,3,5}
Откройте пример в Compiler Explorer.
У std::forward_list есть еще одна особенность; поскольку мы можем стирать и вставлять только после итератора, std::forward_list предлагает модифицированный интерфейс.
#include <forward_list> std::forward_list<int> data{1,2,3,4,5}; // before_begin() iterator auto it = data.before_begin(); // insert and erase only possible after the iterator data.insert_after(it, 42); // data == {42,1,2,3,4,5} data.erase_after(it); // data == {1,2,3,4,5}
Откройте пример в Compiler Explorer.
Пользовательские списки
При реализации простого пользовательского связанного списка у вас может возникнуть соблазн использовать простую реализацию с использованием std::unique_ptr.
#include <memory> struct Node { int value; std::unique_ptr<Node> next; }; std::unique_ptr<Node> head = std::make_unique<Node>(20,nullptr); head->next = std::make_unique<Node>(42,nullptr); // head->value == 20 // head->next->value == 42
Откройте пример в Compiler Explorer.
К сожалению, этот подход неприменим. Основная проблема здесь — дизайн. Мы смешиваем право собственности со структурной информацией. В данном случае эта проблема проявляется при разрушении. Поскольку мы связали владение со структурой, уничтожение списка будет рекурсивным, что может привести к исчерпанию стека и сбою.
#include <memory> struct Node { int value; std::unique_ptr<Node> next; }; { std::unique_ptr<Node> head = std::make_unique<Node>(0,nullptr); // Depending on the architecture/compiler, the specific number // of elements we can handle without crash will differ. Node* it = head.get(); for (int i = 0; i < 100000; ++i) it = (it->next = std::make_unique<Node>(0,nullptr)).get(); } // BOOM
Откройте пример в Compiler Explorer.
Если нам нужны как операции O(1), так и стабильность итератора, единственный вариант — полагаться на ручное управление ресурсами (в этот момент мы могли бы также использовать std::list или std::forward_list).
Если мы хотим зафиксировать структуру связанного списка со стабильностью ссылок, мы можем положиться на вышеупомянутую комбинацию std::vector и std::unique_ptr.
#include <vector> #include <memory> struct List { struct Node { int value; Node* next; }; Node *head = nullptr; Node *new_after(Node* prev, int value) { nodes_.push_back(std::make_unique<Node>(value, nullptr)); if (prev == nullptr) return head = nodes_.back().get(); else return prev->next = nodes_.back().get(); } private: std::vector<std::unique_ptr<Node>> nodes_; }; List list; auto it = list.new_after(nullptr, 1); it = list.new_after(it, 2); it = list.new_after(it, 3); // list.head->value == 1 // list.head->next->value == 2 // list.head->next->next->value == 3
Откройте пример в Compiler Explorer.
Принципиальное отличие от предыдущего подхода заключается в том, что списку принадлежат все узлы, а структура кодируется только с использованием слабых указателей.
Наконец, если нам не нужны стабильные итераторы или ссылки, но требуются операции O(1), мы можем использовать метод плоского списка. Мы можем хранить все узлы непосредственно в std::vector. Единственной проблемной операцией в этом случае является erase(), которая по своей сути является линейной для std::vector.
Однако связанный список уже по своей сути кодирует порядок элементов, поэтому вместо стирания с середины std::vector мы всегда можем стереть с конца, заменив местами удаляемый элемент. с последним элементом в std::vector.
#include <vector> inline constexpr ptrdiff_t nill = -1; struct List { struct Node { int value; ptrdiff_t next; ptrdiff_t prev; }; ptrdiff_t new_after(ptrdiff_t prev, int value) { storage.push_back({value, nill, prev}); if (prev != nill) storage[prev].next = std::ssize(storage)-1; else head = std::ssize(storage)-1; return std::ssize(storage)-1; } void erase(ptrdiff_t idx) { // move head if (idx == head) head = storage[idx].next; // unlink the erased element if (storage[idx].next != nill) storage[storage[idx].next].prev = storage[idx].prev; if (storage[idx].prev != nill) storage[storage[idx].prev].next = storage[idx].next; // relink the last element if (idx != std::ssize(storage)-1) { if (storage.back().next != nill) storage[storage.back().next].prev = idx; if (storage.back().prev != nill) storage[storage.back().prev].next = idx; } // swap and O(1) erase std::swap(storage[idx],storage.back()); storage.pop_back(); } ptrdiff_t get_head() { return head; } Node& at(ptrdiff_t idx) { return storage[idx]; } private: ptrdiff_t head = nill; std::vector<Node> storage; }; List list; ptrdiff_t idx = list.new_after(nill, 1); idx = list.new_after(idx, 2); idx = list.new_after(idx, 3); idx = list.new_after(idx, 4); idx = list.new_after(idx, 5); // list == {1,2,3,4,5} idx = list.get_head(); list.erase(idx); // list == {2,3,4,5}
Откройте пример в Compiler Explorer.
Основные операции
При разработке решения проблемы кодирования три наиболее частые операции со связанными списками:
- объединить два отсортированных списка
- перевернуть отсортированный список
- сканирование с двумя итераторами
И std::list, и std::forward_list имеют встроенную операцию слияния.
#include <list> #include <forward_list> { std::list<int> left{2,4,5}; std::list<int> right{1,3,9}; left.merge(right); // left == {1,2,3,4,5,9} // right == {} } { std::forward_list<int> left{2,4,5}; std::forward_list<int> right{1,3,9}; left.merge(right); // left == {1,2,3,4,5,9} // right == {} }
Откройте пример в Compiler Explorer.
Тем не менее, реализовать его с нуля также не особенно сложно. Мы потребляем объединенный список по одному элементу за раз, перемещая позицию вставки по мере необходимости.
#include <forward_list> std::forward_list dst{1, 3, 5, 6}; std::forward_list src{2, 4, 7}; auto dst_it = dst.begin(); while (!src.empty()) { if (std::next(dst_it) == dst.end() || *std::next(dst_it) >= src.front()) { dst.splice_after(dst_it, src, src.before_begin()); } else { ++dst_it; } } // dst == {1,2,3,4,5,6,7} // src == {}
Откройте пример в Compiler Explorer.
Та же ситуация применима и к обращению списка. Оба списка предоставляют встроенную обратную операцию с настраиваемой реализацией, даже более простой, чем слияние.
#include <forward_list> std::forward_list<int> src{1,2,3,4,5,6,7}; std::forward_list<int> dst; while (!src.empty()) dst.splice_after(dst.before_begin(), src, src.before_begin()); // dst == {7,6,5,4,3,2,1} // src == {} dst.reverse(); // dst == {1,2,3,4,5,6,7}
Откройте пример в Compiler Explorer.
Наконец, сканирование с использованием двух итераторов — распространенный метод поиска последовательности элементов, соответствующих определенному свойству. Пока это свойство вычисляется строго из элементов, входящих и исходящих из последовательности, нам не нужно обращаться к элементам, которые в данный момент находятся в последовательности.
#include <forward_list> std::forward_list<int> data{4,2,1,1,1,3,5}; // find the longest subsequence with sum less than 4 // two iterators denoting the sequence [left, right) auto left = data.begin(); auto right = data.begin(); int sum = 0; int len = 0; int max = 0; while (right != data.end()) { // extend right, until we break the property while (sum < 4 && right != data.end()) { max = std::max(max, len); ++len; sum += *right; ++right; } // shrink from left, until the property is restored while (sum >= 4 && left != right) { sum -= *left; --len; ++left; } } // max == 3, i.e. {1,1,1}