• No results found

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 by

F

++

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 processed

k

times (once by each ++), and the elements of

r

i will be processed

k

;

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 writing

R

as

r

, we can decompose a queue into three parts:

f

,

r

, and the collection

m =

f

reverse

r

1

;:::;reverse

r

k

g. Previously,

f

,

r

, and each

reverse

r

i was a stream, but now we can represent

f

and

r

as ordinary lists and each

reverse

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 collection

m

? 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 therefore

datatype

Queue = Empty

jQueue offF :

list, M :

list susp Queue, LenFM : int, R :

list, LenR : intg

LenFM

is the combined length of

F

and all the suspended lists in

M

(i.e., what used to be simply

LenF

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

+1g

fun 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

g

The real action is in the pseudo-constructor

queue

. If

R

is too long,

queue

creates a suspension to reverse

R

and adds the suspension to

M

. After checking the length of

R

,

queue

invokes a helper function

checkF

that guarantees that

F

is non-empty. If both

F

and

M

are empty, then the entire queue is empty. Otherwise, if

F

is empty we remove the first suspension from

M

, force it, and install the resulting list as the new

F

.

fun queue (

q

asfF =

f

, M =

m

, LenFM =

lenFM

, R =

r

, LenR =

lenR

g) =

if

lenRlenFM

then checkF

q

else checkFfF =

f

, M = snoc (

m

, $rev

r

), 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 checkF

q

else checkFfF =

f

, M = snoc (

m

, $rev

r

), LenFM =

lenFM

+

lenR

, R = [ ], LenR = 0g

and checkFfF = [ ], M = Empty, . . .g= Empty

jcheckFfF = [ ], M =

m

, LenFM =

lenFM

, R =

r

, LenR =

lenR

g) =

QueuefF = force (head

m

), M = tail

m

, LenFM =

lenFM

, R =

r

, LenR =

lenR

g jcheckF

q

= Queue

q

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

+1g

and 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

g

end

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 = tail

m

, LenFM =

lenFM

, R =

r

, LenR =

lenR

g jcheckF

q

= Queue

q

Note that

queue

and

checkF

call

snoc

and

tail

, which in turn call

queue

. 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 of

jList of

ElemOrList list susp

datatype

Queue = Empty

jQueue offF :

ElemOrList list, M :

Queue, LenFM : int,

R :

ElemOrList list, LenR : intg

Then

snoc

and

head

add and remove the

Elem

constructor when inserting or inspecting an ele- ment, and

queue

and

checkF

add and remove the

List

constructor when inserting or removing

a list from

M

. 3

These 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 only

O(1)

amortized time to each operation on bootstrapped queues. How- ever, the running time of the

snoc

and

tail

operations is not constant! Note that

snoc

calls

queue

, which in turn might call

snoc

on

M

. In this way we might get a cascade of calls to

snoc

, one at each level of the queue. However, successive lists in

M

at least double in size so the length of

M

is

O(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 depth

O(logn)

.

snoc

performs

O(1)

amortized work at each level, so in total

snoc

requires

O(log

n)

amortized time.

Similarly,

tail

might result in recursive calls to both

snoc

and

tail

. The

snoc

might in turn recursively call

snoc

and the

tail

might recursively call both

snoc

and

tail

. However, for any given level,

snoc

and

tail

can not both recursively call

snoc

. Therefore, both

snoc

and

tail

are each called at most once per level. Since both

snoc

and

tail

do

O(1)

amortized work at each level, the total amortized cost of

tail

is also

O(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

65536

elements. 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

and

tail

to

O(1)

by wrapping

M

in a suspension and executing all operations on

M

lazily. The type of

M

then becomes

list susp Queue susp

.

Yet another variation that yields

O(1)

behavior is to abandon structural decomposition and simply use a stream of type

list susp Stream

for

M

. Then every queue has exactly two levels. Adding a new list suspension to the end of the stream with ++ takes

O(

j

M

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.