In Haskell, difference lists, in the sense of
[a] representation of a list with an efficient concatenation operation
seem to be implemented in terms of function composition.
Functions and (dynamic) function compositions, though, must also be represented somehow in the computer's memory using data structures, which raises the question of how dlists could be implemented in Haskell without using function compositions, but, rather, through some basic purely-functional node-based data structures. How could this be done with the same performance guarantees as those of function composition?
As shown in this answer the trick is the rearrangement of
(.)
tree into the($)
list on access.We can emulate this with
which will juggle the
Append
nodes to push the leftmost to become the uppermost-left, on the first access, after which the nexttail
operation becomes O(1) (*) :and
tail x
is to producetail
should be trivial to implement. Of course if will only pattern match the leftmostList
node's list (with(x:xs)
) when that node is finally discovered, and won't touch contents of anything else inside theAppend
nodes as they are juggled. Laziness is thus naturally preserved.(*) edit: O(1) in case the leftmost
List
node's list isn't empty. Overall, taking into account the possible nested-on-the-leftAppend
nodes that will have to be rearranged as they come to the fore after the first leftmostList
node is exhausted, the access to all n elements of the result will be O(n+m), where m is the number of empty lists.update: A historical note: this is actually quite similar (if not exactly the same) as the efficient tree-fringe enumeration problem, dealt with in 1977 by John McCarthy, whose
gopher
function did exactly the same kind of nodes re-arrangement (tree rotations to the right) as the proposed heretail
would.(++)
's notoriously bad asymptotics come about when you use it in a left-associative manner - that is, when(++)
's left argument is the result of another call to(++)
. Right-associative expressions run efficiently, though.To put it more concretely: evaluating a left-nested append of
m
lists liketo WHNF requires you to force
m
thunks, because(++)
is strict in its left argument.In general, to fully evaluate
n
elements of such a list, this'll require forcing that stack ofm
thunksn
times, for O(m*n) complexity. When the entire list is built from appends of singleton lists (ie(([w] ++ [x]) ++ [y]) ++ [z]
),m = n
so the cost is O(n2).Evaluating a right-nested append like
to WHNF is much easier (O(1)):
Evaluating
n
elements just requires evaluatingn
thunks, which is about as good as you can expect to do.Difference lists work by paying a small (O(m)) up-front cost to automatically re-associate calls to
(++)
.Now a left-nested expression,
gets evaluated in a right-associative manner:
You perform O(m) steps to evaluate the composed function, then O(n) steps to evaluate the resulting expression, for a total complexity of O(m+n), which is asymptotically better than O(m*n). When the list is made up entirely of appends of singleton lists,
m = n
and you get O(2n) ~ O(n), which is asymptotically better than O(n2).This trick works for any
Monoid
.See also, for example, the Codensity monad, which applies this idea to monadic expressions built using
(>>=)
(rather than monoidal expressions built using(<>)
).I hope I have convinced you that
(++)
only causes a problem when used in a left-associative fashion. Now, you can easily write a list-like data structure for which append is strict in its right argument, and left-associative appends are therefore not a problem.We recover O(1) WHNF of left-nested appends,
but at the expense of slow right-nested appends.
Then, of course, you end up writing a new type of difference list which reassociates appends to the left!
Carl hit it in his comment. We can write
We could get
toList
by just derivingFoldable
, but let's write it out instead to see just what's going on.toList
is O(n), where n is the total number of internal nodes (i.e., the total number ofmappend
operations used to form theTList
). This should be pretty clear: eachNode
is inspected exactly once.mempty
,mappend
, andsingleton
are each obviously O(1).This is exactly the same as for a
DList
:Why, operationally, is this the same? Because, as you indicate in your question, the functions are represented in memory as trees. And they're represented as trees that look a lot like
TList
s!singletonD x
produces a closure containing a (boring)(:)
and an excitingx
. When applied, it does O(1) work.mempty
just produces theid
function, which when applied does O(1) work.mappend as bs
produces a closure that, when applied, does O(1) work of its own, plus O(length as + length bs) work in its children.The shapes of the trees produced for
TList
andDList
are actually the same. You should be able to convince yourself that they also have identical asymptotic performance when used incrementally: in each case, the program has to walk down the left spine of the tree to get to the first list element.Both
DList
andTList
are equally okay when built up and then used only once. They're equally lousy when built once and converted to lists multiple times.As Will Ness showed with a similar type, the explicit tree representation is better if you want to add support for deconstructing the representation, as you can actually get your hands on the structure.
TList
can support a reasonably efficientuncons
operation (that improves the structure as it works). To get efficientunsnoc
as well, you'll need to use a fancier representation (a catenable deque). This implementation also has potentially wretched cache performance. You can switch to a cache-oblivious data structure, but it is practically guaranteed to be complicated.