Motivation
Haskell lists ([a]) are singly linked: operating on the front is cheap,
but any operation on the end forces a traversal of the whole list.
The goal of this project is to improve the complexity of those inefficient
operations while keeping an interface similar to that of Data.List.
| Operation | [a] |
SymmetricList a |
|---|---|---|
cons, head
|
||
tail, uncons
|
|
|
snoc (xs ++ [x]) |
||
last |
||
init, unsnoc
|
|
|
reverse |
||
length |
||
append (<>) |
How does it compare?
| symmetric-list | deque | Data.Sequence | |
|---|---|---|---|
| Mixed use of both ends | O(1) amortized | O(n) per operation | O(1) amortized |
head / last |
O(1) worst case | O(n) if its side is empty | O(1) |
length |
O(1) | O(n) | O(1) |
reverse |
O(1) | O(1) | O(n) |
| Indexing / splitting | — | O(n) | O(log n) |
Use Data.Sequence if you need random access or splitting, deque for a
queue consumed from one end, and symmetric-list when you work on both ends
and want the Data.List API.
Beyond efficiency: correctness
This library does not only propose an efficient data structure (there are
already several implementations of double-ended lists, such as Data.Sequence
in containers). It also aims to study its correctness.
Currently, every operation is checked with property-based testing
(QuickCheck) against its
Data.List counterpart. The next step is to add formal verification with
Liquid Haskell, proving that
every operation preserves the representation invariants.
Usage
The module is designed to be imported qualified, since many of its names
(head, last, reverse, map, ...) clash with the Prelude:
import Data.SymmetricList (SymmetricList) import qualified Data.SymmetricList as SymmetricList xs :: SymmetricList Int xs = SymmetricList.snoc 4 (SymmetricList.fromList [1, 2, 3]) -- SymmetricList.last xs == 4 -- SymmetricList.toList (SymmetricList.init xs) == [1, 2, 3] -- xs <> xs == SymmetricList.append xs xs
This convention follows the guide
Designing Haskell libraries for qualified import:
the type and the functions that operate on it live in a module with the same
name, meant to be imported qualified, and operators (such as <>) are used
unqualified.
Representation
We start from a list
If
This way the first element is at the head of
In Haskell:
data SymmetricList a = SL !Int !Int ([a], [a]) -- lenL lenR (xs, ys)
Storing the lengths makes length
Invariant
If one of the two lists is empty, the other has at most one element:
This guarantees that head and last always find their element in
Inserting at the end: snoc
Adding to the end means adding to the head of
If
Example, starting from fromList [1..6] (which splits the elements in half):
Removing from the end: init / unsnoc
Removing the last element means removing the head of
The interesting case is when
Example:
cons, tail and uncons are symmetric: they operate on reverse simply swaps the sides:
Why $O(1)$ amortized?
balance costs
each cheap operation changes cons, snoc, tail, init, ...) costs
Benchmarks
Comparison against [a] with tasty-bench,
on the list [1..n] (see bench/Bench.hs). Compiled with
GHC 9.10.3 and -O2; absolute times depend on the machine.
For the operations that return a list (snoc, init, unsnoc), the [a]
version is evaluated to normal form: because of lazy evaluation, evaluating to
WHNF would only measure the first constructor and not the traversal.
Individual operations, $n = 100,000$
| Operation | SymmetricList |
[a] |
Speedup |
|---|---|---|---|
last |
6.88 ns | 939 μs | ~136 000× |
reverse |
15.5 ns | 1.19 ms | ~77 000× |
snoc |
18.6 ns | 1.65 ms | ~89 000× |
init |
15.5 ns | 1.65 ms | ~106 000× |
unsnoc |
21.7 ns | 5.25 ms | ~242 000× |
length |
8.38 ns | 825 μs | ~98 000× |
The time of SymmetricList stays constant as [a]
grows linearly:
last |
|||
|---|---|---|---|
SymmetricList |
8.11 ns | 6.95 ns | 6.88 ns |
[a] |
1.50 μs | 17.6 μs | 939 μs |
Amortized cost: emptying from the end
unsnoc is applied repeatedly until the list is empty, so that the cost of
the rebalances is included in the measurement. With [a] this is SymmetricList it is
SymmetricList |
[a] |
|
|---|---|---|
| 1 000 | 38.1 μs | 13.0 ms |
| 10 000 | 418 μs | 1.41 s |
| 100 000 | 6.50 ms | — |
(With [a] it is not run for
Running the benchmarks
cabal bench symmetric-list-bench # export results cabal bench symmetric-list-bench --benchmark-options='--csv bench.csv --svg bench.svg'
References
- Richard Bird and Jeremy Gibbons. Algorithm Design with Haskell. Cambridge University Press, 2020. The implementation follows the presentation of symmetric lists in this book.
- Chris Okasaki. Purely Functional Data Structures. Cambridge University Press, 1998. The canonical reference for this structure.