head and tail
So, we can build lists using (:)
and (++)
. To inspect the parts of a list,
we use pattern matching. Remember, []
and (:)
are data constructors, so we
can use them in patterns. For inspecting the head or tail of a list, we also
have
head :: [a] -> a
head (x:_) = x
head [] = error "Cannot take head of the empty list"
tail :: [a] -> [a]
tail (_:xs) = xs
tail [] = error "Cannot take tail of the empty list"
head
and tail
are partial functions that will make your program crash if you
try to apply them to the empty list. We discussed before how to make these
functions safe by making them return their result wrapped in Maybe
, and
returning Nothing
if we try to take the head or tail of the empty list. There
is a replacement of the standard library that does exactly this, emphasizing
safety above everything else. The standard library opted for convenience over
safety, because if the return value is wrapped in Maybe
, then we need to
unwrap it using pattern matching to use it.
Exercise
We can use pattern matching or head
and tail
to take a list apart into
its head and tail. It isn't uncommon to want to split a list xs
into two
lists, one containing the first \(n\) elements of xs
, and the other
containing the remaining elements. Or maybe we only want one of these two
lists. The standard library has three functions that do this kind of stuff:
take :: Int -> [a] -> [a]
drop :: Int -> [a] -> [a]
splitAt :: Int -> [a] -> ([a], [a])
take n xs
returns the list containing the first n
elements of xs
. If
xs
has fewer than n
elements, then the result is the entire list:
>>> take 3 [1,2,3,4,5]
[1,2,3]
>>> take 10 [1,2,3,4,5]
[1,2,3,4,5]
drop n xs
returns the list obtained by removing the first n
elements of
xs
. If xs
has fewer than n
elements, then the result is the empty
list:
>>> drop 3 [1,2,3,4,5]
[4,5]
>>> drop 10 [1,2,3,4,5]
[]
splitAt n xs
returns both lists. The result is a pair where the first list
contains the first n
elements of xs
, and the second list contains the
rest. If xs
has fewer than n
elements, then the result is (xs, [])
. In
other words, splitAt n xs
behaves as if it were implemented as
splitAt n xs = (take n xs, drop n xs)
but this implementation would be inefficient because take n xs
and
drop n xs
would both traverse the first n
elements of xs
. The actual
implementation of splitAt n xs
achieves the split using a single traversal
of the first n
elements of xs
. Figure out how to do this. Implement
splitAt
without using take
and drop
, and then use splitAt
to
implement take
and drop
. (You did the splitAt
part of this exercise
before, for our custom IntList
type. This exercise asks you for
implementing the real splitAt
function, for lists.)
Solution
splitAt :: Int -> [a] -> ([a], [a])
splitAt 0 xs = ([], xs)
splitAt _ [] = ([], [])
splitAt n (x:xs) = (n : ls, rs)
where
(ls, rs) = splitAt (n - 1) xs
take, drop :: Int -> [a] -> [a]
take n = fst . splitAt n
drop n = snd . splitAt n
Exercise
head
and tail
have two cousins called last
and init
. While head
returns the first element in a list, last
returns the last element in the
list. Similarly, tail
returns the list with its first element removed, and
init
returns the list with its last element removed:
>>> last [1,2,3,4,5]
5
>>> init [1,2,3,4,5]
[1,2,3,4]
Just as head
and tail
, last
and init
are undefined when the input
list is empty.
Given how lists are implemented, head
and tail
are constant-time
operations. The same is not true for last
and init
. They need to walk
down the entire list to find the last element and either return it or
remove it. Still, it is useful to have these functions for when we really
need them.
Implement last
and init
.
Solution
last :: [a] -> a
last [] = error "Cannot take last element of an empty list"
last [x] = x
last (_:xs) = last xs
init :: [a] -> [a]
init [] = error "Cannot take init of an empty list"
init [x] = []
init (x:xs) = x : init xs
Exercise
Lists are great as long as we add, remove, and modify elements at the head of the list. For example, we can easily implement a stack based on lists:
newtype Stack a = Stack [a]
emptyStack :: Stack a
emptyStack = Stack []
isEmpty :: Stack a -> Bool
isEmpty (Stack xs) = null xs
push :: a -> Stack a -> Stack a
push x (Stack xs) = Stack (x:xs)
pop :: Stack a -> (a, Stack a)
pop (Stack []) = error "Cannot pop from empty stack"
pop (Stack (x:xs)) = (x, Stack xs)
A queue is harder to implement as a list because the enqueue
operations
adds elements at one end of the queue, and the dequeue
operation removes
elements at the other end. There do exist purely functional queue
implementations that support enqueue
and dequeue
in worst-case constant
time, but they're too advanced to discuss here. After defining
q' = enqueue x q
, we still have access to the old version of q
, and
there's nothing that prevents us from throwing away q'
and applying
another operation to q
instead. Algorithms people call such data
structures persistent: we have access to all previous versions of a data
structure. Immutability gives us persistence "for free". At the same time,
persistent data structures aren't easy to implement. As a case in point,
even achieving amortized constant time[^amortization] for enqueue
and
dequeue
operations in the presence of persistence isn't easy. So we'll
settle for something much less ambitious here. We want a queue that achieves
amortized constant time for enqueue
and dequeue
operations as long as we
do not use persistence: After producing a new queue q'
using
q' = enqueue x q
or (x, q') = dequeue q
, we never access q
again.
Such a queue can be implemented using two lists:
data Queue a = Queue
{ front :: [a]
, back :: [a]
}
You should think about this queue as being split into two parts at an
arbitrary position. The elements before this split position are stored in
front
. The elements after this split position are stored in back
.
However, there's a twist: The elements in front
are stored in the order in
which they occur in the queue, while the elements in back
are stored in
reverse order.
Implement the following operations for this Queue a
data type:
emptyQueue :: Queue a
should return an empty queue.isEmpty :: Queue a -> Bool
should report whether the given queue is empty.enqueue :: a -> Queue a -> Queue a
should add a new element to the end of the queue. Specifically,enqueue x q
returns a new queue that is the same asq
but withx
added to the end.dequeue :: Queue a -> (a, Queue a)
takes a queueq
and returns a pair(x, q')
.x
is the element at the front ofq
.q'
is the queue obtained by removingx
from the front ofq
.
Argue that these operations take amortized constant time, that is, any sequence of \(n\) such operations takes \(O(n)\) time in total.
Solution
emptyQueue
and isEmpty
are fairly trivial:
emptyQueue :: Queue a
emptyQueue = Queue [] []
isEmpty :: Queue a -> Bool
isEmpty (Queue f b) = null f && null b
Given that back
stores a list of elements at the back of q
in
reverse order, appending a new element to the end of q
is as simple as
adding this element to the front of back q
:
enqueue :: a -> Queue a -> Queue a
enqueue x q = q { back = x : back q }
Where things get a little more interesting is dequeue
. If front q
is
non-empty, then we can simply remove the head of front q
:
dequeue (Queue (x:f) b) = (x, Queue f b)
But what if front q = []
? In that case, we want to remove the last
element of back q
. We could use init
and last
to accomplish this,
but that would take linear time every time we call dequeue
. Here's a
nifty little trick to avoid this cost.
Logically, the two queues Queue [] b
and Queue (reverse b) []
represent the same sequence. For a list xs
, reverse xs
is the
reversal of xs
:
>>> reverse [1,2,3,4,5]
[5,4,3,2,1]
Thus, we convert Queue [] b
into Queue (reverse b) []
, and then we
can remove from the head of reverse b
as before. The whole
implementation of dequeue
looks like this:
dequeue :: Queue a -> (a, Queue a)
dequeue (Queue [] []) = error "Cannot dequeue from an empty queue"
dequeue (Queue [] b ) = dequeue (Queue (reverse b) [])
dequeue (Queue (x:f) b ) = (x, Queue f b)
Whenever front q
is empty, dequeue q
takes linear time in the length
of back q
, because reverse b
takes linear time:
reverse :: [a] -> [a]
reverse = go []
where
go rev [] = rev
go rev (x:xs) = go (x:rev) xs
The amortized cost per enqueue
and dequeue
operation is constant
though. Indeed, enqueue
takes constant time to add its argument to the
head of back q
. Each element moves from back q
to front q
exactly
once, whenever front q
is empty. This takes linear time in the length
of back q
, that is, constant time per element in back q
. Thus, we
can charge the cost of moving elements from back q
to front q
to the
enqueue
operations that add these elements to back q
, at constant
cost per enqueue
operation. With the moving of elements from back q
to front q
paid for, all that dequeue
does now is remove elements
from the front of front q
, which takes constant time per dequeue
operation.