TODO:
- optimize out multiply operations
- iterators
- implement more methods for better compatability with std::vector
- resize
- assign
- front
- back
- reserve (?)
- capacity (?)
- shrink_to_fit (?)
- clear
- insert
- emplace
- erase
- pop_back
A header-only, persistent vector for C++17, mimiking std::vector interface.
Implemented as a bit-mapped trie (à la Clojure's PersistentVector / Hickey-style
HAMT vectors). pvector supports efficient structural sharing between "versions"
of the container: operations that would normally require a copy (e.g.
updating an element) instead only copy the path from the root to the
affected node, reusing the rest of the tree with the original vector.
The whole implementation lives in a single header: pvector.hpp.
| Thread Safety | This implementation is not thread safe even for persisten operations. It is optimized for maxmal performance in single-threaded applications. Under-the-hood reference counting would require extensive use of atomic operations which would dramatically deteriorate the single-thread performance. |
| Use Patterns | This implementation is optimized for frequent updates of a single instance of pvector without jeopardizing the true persistent (copy-on-write) operations. |
| Memory Management | No optimizations for lowering the number of allocate/deallocate cycles are considered, in favour of minimizing the memory usage. It is inherent to the nature of this data structure to trigger many small allocations, thus, if one really needs the maximal performance it is advised to use a dedicated fast allocator. This implementation meets requirements of AllocatorAwareContainer. |
pidhii::pvector<T, BranchFactor, Alloc> is a vector-like container backed
by an internal tree of fixed branching factor (2^BranchFactor children per
node, 32 by default). Values are stored only in the leaves; internal
("branch") nodes hold pointers to children.
Every pvector instance is reference-counted at the node level: copying a
pvector (via the copy constructor or copy-assignment) is O(1) — it merely
bumps the root node's reference count. Mutating operations (set(),
operator[] (non-const), at() (non-const), emplace_back(), push_back())
check the reference count along the path to the target element and only
duplicate nodes that are actually shared, using an in-place update
otherwise. This gives pvector "copy-on-write" semantics similar to
persistent data structures found in functional languages, while still
allowing efficient in-place mutation of a uniquely-owned vector.
template <typename T, size_t BranchFactor = 5, typename Alloc = std::allocator<T>>
class pvector;| Parameter | Description |
|---|---|
T |
The element type stored in the vector. |
BranchFactor |
Log2 of the number of children per internal node. Default 5 → 32-way branching (2^5). |
Alloc |
Allocator used for the underlying leaf/branch node allocations (rebound internally as needed). |
Increasing BranchFactor reduces tree depth (and thus pointer-chasing) at
the cost of larger nodes (2^BranchFactor pointers/elements per node) and
proportionally larger copies when a node needs to be duplicated during a
copy-on-write.
-
Copy construction / copy assignment — O(1). The new
pvectorshares the same root node as the source; only the root's reference count is incremented. -
Move construction / move assignment — O(1), takes ownership of the source's root and leaves the source empty (
root == nullptr). -
set(i, value)(called on an lvalue) — returns a newpvectorwith thei-th element replaced, leaving the original untouched. Internally this copies only the leaf and branch nodes along the path from the root to indexi; every other subtree is shared (its nodes' reference counts are simply incremented). -
set(i, value)(called on an rvalue, e.g.std::move(v).set(i, x)) — same semantics, but since the temporary is uniquely owned by the expression, this is used as an optimization hint; if the vector is not shared with anyone else, the update happens in place instead of copying. -
operator[](mutable) /at()(mutable) /emplace_back()— these also transparently perform copy-on-write: if the path to the target element is uniquely owned (reference count == 1), the update happens in-place; if it is shared with anotherpvectorinstance, only the necessary nodes along that path are duplicated first.
pvector(); // empty vector
pvector(const pvector &other); // O(1) — shares structure with `other`
pvector(pvector &&other); // O(1) — takes ownership from `other`
pvector &operator=(const pvector &other); // O(1) — shares structure with `other`
pvector &operator=(pvector &&other); // O(1) — takes ownership from `other`size_t size() const noexcept; // number of elements
bool empty() const noexcept; // size() == 0const T &get(size_t i) const; // throws std::out_of_range if i >= size()
const T &operator[](size_t i) const noexcept;
T &operator[](size_t i) noexcept; // triggers copy-on-write if shared
const T &at(size_t i) const; // throws std::out_of_range if i >= size()
T &at(size_t i); // throws std::out_of_range; triggers copy-on-write if sharedget(i)— unambiguous, read-only getter with bounds checking.operator[]— equivalent toget()when called on aconstvector; otherwise ensures unique ownership of the requested element before returning a mutable reference (same complexity asset()). No bounds checking.at(i)— equivalent tooperator[]but always bounds-checked, throwingstd::out_of_rangefor indices>= size().
const_reference front() const; // throws std::out_of_range if empty()
reference front(); // triggers copy-on-write if shared
const_reference back() const; // throws std::out_of_range if empty()
reference back(); // triggers copy-on-write if sharedAccess the first / last element of the vector, respectively. Both throw
std::out_of_range when called on an empty vector. The mutable overloads
behave like operator[] / at(): they transparently perform copy-on-write
if the accessed element's path is shared with another pvector instance.
pvector<int> v;
for (int i = 0; i < 10; ++i) v.emplace_back(i * 10);
int first = v.front(); // 0
int last = v.back(); // 90
v.front() = -1; // mutate first element (copy-on-write if shared)template <typename U>
pvector set(size_t i, U &&x) const &; // copy-then-update: original vector is untouched
template <typename U>
pvector set(size_t i, U &&x) &&; // move-then-update: optimized in-place pathReturns a new pvector with element i replaced by x. Throws
std::out_of_range if i >= size().
pvector<int> v;
for (int i = 0; i < 10; ++i) v.emplace_back(i);
pvector<int> v2 = v.set(3, 999); // v is unchanged; v2[3] == 999template <typename... Args>
T &emplace_back(Args &&...args);
void push_back(const T &x);
void push_back(T &&x);emplace_back() constructs a new element in place at the end of the vector
(growing the underlying tree as necessary — adding new levels/nodes when the
current capacity is exceeded) and returns a reference to it.
push_back() is a thin convenience wrapper implemented in terms of
emplace_back() (via copy or move construction of the argument,
respectively), provided for familiarity/compatibility with std::vector.
pvector<int> v;
v.push_back(1); // copies 1 into a new element
int x = 2;
v.push_back(std::move(x)); // moves x into a new element
int &ref = v.emplace_back(3); // constructs in place, returns referencevoid resize(size_t size); // new elements are default-constructed
void resize(size_t size, const_reference val); // new elements are copy-constructed from `val`Grows or shrinks the vector to contain exactly size elements:
- If
size == size(), this is a no-op. - If
size > size(), new elements are appended at the end — either default-constructed (resize(size)) or copy-constructed fromval(resize(size, val)) — growing the underlying tree with new levels/nodes as necessary. - If
size < size(), trailing elements (indices[size, size())) are destroyed and the tree is optimized to drop any now-unnecessary levels.
Like other mutating operations, resize() respects structural sharing: if
the vector's nodes are shared with another pvector instance, only the
nodes that actually need to change are copied first, leaving other
pvectors referencing the same tree untouched.
Note that there is no variadic/forwarding overload — only these two
overloads are provided, mirroring std::vector::resize.
pvector<int> v;
v.resize(10); // 10 default-constructed ints (== 0)
v.resize(20, -1); // grow to 20 elements; new ones are -1
v.resize(5); // shrink to 5 elements, destroying the restvoid clear();Empties the vector: all elements are destroyed and size() becomes 0
afterwards. Safe to call on an already-empty vector. Like other mutating
operations, this only releases the nodes owned by this pvector instance —
if the tree is shared with another pvector, the other instance's view of
the data is unaffected (shared nodes are simply dereferenced, not
destroyed, until their reference count drops to zero).
pvector<int> v;
for (int i = 0; i < 50; ++i) v.emplace_back(i);
v.clear();
assert(v.empty());Let N be the number of elements and B = 2^BranchFactor the branching
factor. The tree has depth O(log_B N).
| Operation | Complexity |
|---|---|
size(), empty() |
O(1) |
| Copy construction / copy assignment | O(1) (structural sharing) |
| Move construction / move assignment | O(1) |
get(i), at(i) const, operator[] const |
O(log_B N) |
front(), back() (const) |
O(log_B N) |
at(i), operator[] (mutable), set(i, x), front()/back() (mutable) — unique ownership |
O(log_B N) (in-place update of the path) |
at(i), operator[] (mutable), set(i, x), front()/back() (mutable) — shared ownership |
O(log_B N) (copy of the path, size proportional to B * log_B N) |
emplace_back(), push_back() |
Amortized O(log_B N) |
resize() (grow or shrink by k elements) |
O(k + log_B N) (amortized; may add/drop tree levels) |
clear() |
O(N) (destroys all owned elements/nodes) |
#include "pvector.hpp"
using pidhii::pvector;
pvector<int> v;
for (int i = 0; i < 100; ++i)
v.emplace_back(i);
int x = v.at(42); // bounds-checked read
v[42] = 100; // mutable access (copy-on-write if shared)pvector<int> original;
for (int i = 0; i < 10; ++i)
original.emplace_back(i);
pvector<int> updated = original.set(5, -1);
// `original` is completely unaffected:
assert(original.at(5) == 5);
assert(updated.at(5) == -1);pvector<int> a;
for (int i = 0; i < 1000; ++i)
a.emplace_back(i);
pvector<int> b = a; // O(1): b shares a's internal tree
b[0] = 42; // triggers copy-on-write only for the path to index 0
assert(a.at(0) == 0); // `a` remains unaffected
assert(b.at(0) == 42);pvector is header-only — simply #include "pvector.hpp" and use the
pidhii::pvector class template. No separate compilation is required for
the library itself.
A GoogleTest-based unit test suite is provided in test_pvector.cpp,
covering construction, element access (get()/at()/operator[]/
front()/back()), persistent set(), resize() (growing/shrinking, with
and without an explicit value), clear(), copy/move/copy-assignment
semantics and structural sharing, multi-level tree behavior at scale, and
element lifetime correctness (constructor/destructor balance).
To build and run the tests (requires GoogleTest installed on the system):
g++ -std=c++17 -Wall -Wextra -g test_pvector.cpp -o test_pvector -lgtest -lgtest_main -lpthread
./test_pvectorFor extra confidence, the tests can also be built with AddressSanitizer and UndefinedBehaviorSanitizer enabled:
g++ -std=c++17 -Wall -Wextra -g -O0 -fsanitize=address,undefined \
test_pvector.cpp -o test_pvector_asan -lgtest -lgtest_main -lpthread
./test_pvector_asan