6.5 Discussion
7.1.2 Queues Revisited
Consider the use of ++ in the banker’s queues of Section 3.4.2. During a rotation, the front stream
F
is replaced byF
++reverse R
. After a series of rotations,F
will have the form(((f
++reverse
r
1
)
++reverse
r
2)
++
reverse r
k)
Append is well-known to be inefficient in left-associative contexts like this because it repeat- edly processes the elements of the leftmost streams. For example, in this case, the elements of
f
will be processedk
times (once by each ++), and the elements ofr
i will be processedk
;i+1
can easily lead to quadratic behavior. In this case, fortunately, the total cost of the appends is still linear because each
r
i is at least twice as long as the one before. Still, this repeated pro- cessing does sometimes make these queues slow in practice. In this section, we use structural decomposition to eliminate this inefficiency.Given that
F
has the above form and writingR
asr
, we can decompose a queue into three parts:f
,r
, and the collectionm =
freverse
r
1
;:::;reverse
r
kg. Previously,
f
,r
, and eachreverse
r
i was a stream, but now we can representf
andr
as ordinary lists and eachreverse
r
i as a suspended list. This eliminates the vast majority of suspensions and avoids almost all of the overheads associated with lazy evaluation. But how should we represent the collectionm
? As we will see, this collection is accessed in FIFO order, so using structural decomposition we can represent it as a queue of suspended lists. As with any recursive type, we need a base case, so we will represent empty queues with a special constructor.1 The new representation is thereforedatatype
Queue = EmptyjQueue offF :
list, M :list susp Queue, LenFM : int, R :list, LenR : intgLenFM
is the combined length ofF
and all the suspended lists inM
(i.e., what used to be simplyLenF
in the old representation).R
can never be longer than this combined length. In addition,F
must always be non-empty. (In the old representation,F
could be empty if the entire queue was empty, but now we represent that case separately.)As always, the queue functions are simple to describe.
fun snoc (Empty,
x
) = QueuefF = [x
], M = Empty, LenFM = 1, R = [ ], LenR = 0g jsnoc (QueuefF =f
, M =m
, LenFM =lenFM
, R =r
, LenR =lenR
g,x
) =queuefF =
f
, M =m
, LenFM =lenFM
, R =x
::r
, LenR =lenR
+1gfun head (QueuefF =
x
::f
, . . .g) =x
fun tail (QueuefF =
x
::f
, M =m
, LenFM =lenFM
, R =r
, LenR =lenR
g) =queuefF =
f
, M =m
, LenFM =lenFM
;1, R =r
, LenR =lenR
gThe real action is in the pseudo-constructor
queue
. IfR
is too long,queue
creates a suspension to reverseR
and adds the suspension toM
. After checking the length ofR
,queue
invokes a helper functioncheckF
that guarantees thatF
is non-empty. If bothF
andM
are empty, then the entire queue is empty. Otherwise, ifF
is empty we remove the first suspension fromM
, force it, and install the resulting list as the newF
.fun queue (
q
asfF =f
, M =m
, LenFM =lenFM
, R =r
, LenR =lenR
g) =if
lenRlenFM
then checkFq
else checkFfF =
f
, M = snoc (m
, $revr
), LenFM =lenFM
+lenR
, R = [ ], LenR = 0g 1A slightly more efficient alternative is to represent queues up to some fixed size simply as lists.structure BootstrappedQueue : QUEUE= (assumes polymorphic recursion!)
struct
datatypeQueue = Empty
jQueue offF :list, M :list susp Queue, LenFM : int, R :list, LenR : intg
exception EMPTY
val empty = Empty fun isEmpty Empty
jisEmpty (Queue ) = false
fun queue (
q
asfF =f
, M =m
, LenFM =lenFM
, R =r
, LenR =lenR
g) =if
lenRlenFM
then checkFq
else checkFfF =
f
, M = snoc (m
, $revr
), LenFM =lenFM
+lenR
, R = [ ], LenR = 0gand checkFfF = [ ], M = Empty, . . .g= Empty
jcheckFfF = [ ], M =
m
, LenFM =lenFM
, R =r
, LenR =lenR
g) =QueuefF = force (head
m
), M = tailm
, LenFM =lenFM
, R =r
, LenR =lenR
g jcheckFq
= Queueq
and snoc (Empty,
x
) = QueuefF = [x
], M = Empty, LenFM = 1, R = [ ], LenR = 0g jsnoc (QueuefF =f
, M =m
, LenFM =lenFM
, R =r
, LenR =lenR
g,x
) =queuefF =
f
, M =m
, LenFM =lenFM
, R =x
::r
, LenR =lenR
+1gand head Empty = raise EMPTY
jhead (QueuefF =
x
::f
, . . .g) =x
and tail Empty = raise EMPTY
jtail (QueuefF =
x
::f
, M =m
, LenFM =lenFM
, R =r
, LenR =lenR
g) = queuefF =f
, M =m
, LenFM =lenFM
;1, R =r
, LenR =lenR
gend
Figure 7.1: Bootstrapped queues based on structural decomposition.
and checkFfF = [ ], M = Empty, . . .g = Empty
jcheckFfF = [ ], M =
m
, LenFM =lenFM
, R =r
, LenR =lenR
g) =QueuefF = force (head
m
), M = tailm
, LenFM =lenFM
, R =r
, LenR =lenR
g jcheckFq
= Queueq
Note that
queue
andcheckF
callsnoc
andtail
, which in turn callqueue
. These functions must therefore all be defined mutually recursively. The complete implementation appears in Figure 7.1.Remark: To implement these queues without polymorphic recursion, we redefine the datatype as
datatype
ElemOrList = Elem ofjList ofElemOrList list suspdatatype
Queue = EmptyjQueue offF :
ElemOrList list, M :Queue, LenFM : int,R :
ElemOrList list, LenR : intgThen
snoc
andhead
add and remove theElem
constructor when inserting or inspecting an ele- ment, andqueue
andcheckF
add and remove theList
constructor when inserting or removinga list from
M
. 3These queues create a suspension to reverse the rear list at exactly the same time as banker’s queues, and force the suspension one operation earlier than banker’s queues. Thus, since the re- verse computation contributes only
O(1)
amortized time to each operation on banker’s queues, it also contributes onlyO(1)
amortized time to each operation on bootstrapped queues. How- ever, the running time of thesnoc
andtail
operations is not constant! Note thatsnoc
callsqueue
, which in turn might callsnoc
onM
. In this way we might get a cascade of calls tosnoc
, one at each level of the queue. However, successive lists inM
at least double in size so the length ofM
isO(log n)
. Since the size of the middle queue decreases by at least a logarith- mic factor at each level, the entire queue can only have depthO(logn)
.
snoc
performsO(1)
amortized work at each level, so in totalsnoc
requiresO(log
n)
amortized time.Similarly,
tail
might result in recursive calls to bothsnoc
andtail
. Thesnoc
might in turn recursively callsnoc
and thetail
might recursively call bothsnoc
andtail
. However, for any given level,snoc
andtail
can not both recursively callsnoc
. Therefore, bothsnoc
andtail
are each called at most once per level. Since bothsnoc
andtail
doO(1)
amortized work at each level, the total amortized cost oftail
is alsoO(log
n)
. Remark:O(logn)
is constant in practice. To have a depth of more than five, a queue would need to contain at least
2
65536elements. In fact, if one represents queues of up to size 4 simply as lists, then all queues with fewer than about 4 billion elements will have no more than three
levels. 3
Although it makes no difference in practice, one could reduce the amortized running time of
snoc
andtail
toO(1)
by wrappingM
in a suspension and executing all operations onM
lazily. The type ofM
then becomeslist susp Queue susp
.Yet another variation that yields
O(1)
behavior is to abandon structural decomposition and simply use a stream of typelist susp Stream
forM
. Then every queue has exactly two levels. Adding a new list suspension to the end of the stream with ++ takesO(
jM
j)
time, but, since++ is incremental, this cost can be amortized over the operations on the top-level queue. Since these queues are not recursive, we have no need for polymorphic recursion. This variation is explored in greater detail in [Oka96a].
Hint to Practitioners: In practice, variations on these queues are the fastest known imple- mentations for applications that use persistence sparingly, but that require good behavior even in pathological cases.