Introduction to Programming, Aug-Dec 2008 Lecture 12, Monday 29 Sep 2008 ====================================================================== Abstract datatypes ------------------ Haskell provides the built in type list. It is often convenient to have additional collective types. One such type is a stack. A stack is a structure in which we can add elements one at a time and remove elements one at a time such that the element removed first is the one that was most recently added --- a last-in-first-out structure. The insert operation is usually called push and the remove operation is usually called pop. Thus, we have: push :: a -> (Stack a) -> (Stack a) pop :: Stack a -> (a,Stack a) Notice that pop requires the stack to be nonempty and returns a pair of values --- the element at the top of the stack and the resulting stack with this value removed. We also add the following function that checks if the given stack is empty. isempty :: (Stack a) -> Bool We have yet to define how to represent a stack. Here is one possible definition: data Stack a = Empty | St a (Stack a) We can now instantiate the functions in terms of this definition. push :: a -> (Stack a) -> (Stack a) push x s = St x s pop :: Stack a -> (a,Stack a) pop (St x s) = (x,s) isempty :: (Stack a) -> Bool isempty Empty = True isempty _ = False Note that we could directly use the builtin list type and write data Stack a = St [a] We need a constructor to associate with the value [a], but otherwise all the functions we use are derived from the structure of lists. push x (St xs) = St (x:xs) pop (St (x:xs)) = (x,St xs) isempty (St l) = (l == []) Our aim is to provide a definition for stacks that is independent of the representation. How do we specify a stack independent of its representation? We can describe how the functions are connected to each other. For instance. pop (push x s) = (x,s) isempty (push x s) = False ... We will not demonstrate a complete list of such "axioms" that describe a stack but assume that such a list exists. This is called an abstract datatype. We can use modules to implement abstract datatypes. For instance, we could have a module of the form module Stackmodule(Stack,push,pop,isempty) where data Stack a = St [a] push x (St xs) = St (x:xs) pop (St (x:xs)) = (x,St xs) isempty (St l) = (l == []) This ensures that anyone who imports the module only knows the name of the datatype, Stack, and the three functions associated with this type. Actually, we need one more function --- without knowing the internal structure of Stack, the only way to build a stack is to use push and pop starting from an empty stack. For this, we need an extra function that provides an empty stack to start with, since we cannot explicitly write out an expression for an empty stack. We thus augment the module with: emptystack :: Stack a emptystack = St [] An application of stacks ------------------------ One application of stacks is in expression evaluation. Consider an arithmetic expression such as 2+3*6-4. Without parentheses, this expression can be evaluated in many ways. Normally, we associate a higher precedence to * and / than to + or - (recall the BODMAS rule from school) so we would read this expression as 2+(3*6)-4 or 2+18-4, which is 16. If we wrote the same expression converting each infix arithmetic operator to a corresponding Haskell function, we would have the expression ((-) ((+) 2 ((*) 3 6)) 4). The first form is called infix, to denote that each binary operators appears in between its arguments. The second form is called prefix, since each operator (or its equivalent function) appears before its arguments. We can now define a symmetric notation called postfix, in which each operator appears after its arguments. For instance, here is the postfix form of the expression above: ((2 (3 6 *) +) 4 -) Interestingly, postfix expressions can be evaluated unambiguously using a stack without any parenthesis. Here is how it is done: - Scan the expression from left to rigth - When you see a number, push it on the stack - When you see an operator, - pop the stack once to get the second argument - pop the stack again to get the first argument - apply the operator to these arguments and push back the result In the example above, we have the expression 2 3 6 * + 4 - which is evaluated step by step as follows. We use the list based implementation of the stack from last time. Symbol Action Old stack New stack 2 push 2 St [] St [2] 3 push 3 St [2] St [3,2] 6 push 6 St [3,2] St [6,3,2] * pop arg2,arg1 St [6,3,2] St [2], arg2 = 6 arg1 = 3 push arg1*arg2 St [2] St [18,2] + pop arg2,arg1 St [18,2] St [], arg2 = 18, arg1 = 2 push arg1+arg2 St [] St [20] 4 push 4 St [20] St [4,20] - pop arg2,arg1 St [4,20] St [], arg2 = 4, arg1 = 20 push arg1-arg2 St [] St [16] In fact, early scientific calculators manufactured by Hewlett-Packard expected their inputs to be in postfix to make evaluation easier (though it is debatable whether the average user found this convenient!) Postfix is also sometimes called Polish notation (or even "reverse Polish") because it was used by a school of logicians from Poland to write formulas in formal logic in an unambiguous way without parentheses. Queues ------ A queue is a first-in-first-out structure. Like a stack, it has basic operations to add and remove elements, but the element that is removed is the one that was added earliest. Here are the operations that we would like to perform on queues: addq :: a -> (Queue a) -> (Queue a) removeq :: (Queue a) -> (a,Queue a) isemptyq :: (Queue a) -> Bool Notice that the signatures are the same as those for the functions push, pop and isempty that we defined for stacks. We can again implement a queue using a list, as follows. data Queue a = Qu [a] addq x (Qu xs) = (Qu xs ++ [x]) removeq (Qu (x:xs) = (x,Qu xs) isempty (Qu l) = (l == []) Here, removeq and isemptyq have essentially the same definition as pop and isempty for stacks. Only addq is different --- the new element is appended at the end of the list rather than at the beginning. This is an important difference --- adding an element to a queue takes time proportional to the size of the queue. Removing an element takes constant time. In contrast, in a stack, both push and pop take constant time, independent of the size of the stack. Suppose we push and pop n elements in a stack. This will take O(n) time, regardless of the way the pushes and pops are interleaved. On the other hand, for a queue, if we perform n addq operations and n removeq operations, the time taken depends on how they are ordered. If we alternate addq with removeq, the queue never grows beyond length 1 and the overall complexity is O(n). In the worst case, however, if we first do n addq's and then n removeq's, it takes time O(n^2) to build up the queue, since each addq takes time proportional to the length of the queue. We could, of course, reverse the representation and add elements to the front of the list and remove them from the rear. Then, addq would be a constant time operation while removeq would take time proportional to the length of the list. Implementing queues with two lists ----------------------------------- Can we do better? Can we find an implementation of a queue in which n addq's and n removeq's take O(n) time, regardless of the order in which these operations appear? We imagine that we break a queue into two parts and use a separate list to represent the front and the rear. We will remove elements from the front portion, so the first element in the front should be at the head. We will add elements to the end of the rear, so, to avoid traversing the rear portion each time we add a new element, we will maintain the rear portion in reverse, with the end of the queue at the head of the list. Here is the data declaration: data Queue a = Nuqu [a] [a] Here is the definition addq: addq x (Nuqu ys zs) = Nuqu ys (x:zs) Recall that zs represents the rear of the queue, in reverse, so the last element of the queue is at the head of zs, and x is added before this element. How about removeq? If the left list is nonempty, we just extract its head. If it is empty, we reverse the entire rear into the front and then extract its head. removeq (Nuqu (x:xs) ys) = (x,Nuqu xs ys) removeq (Nuqu [] ys) = removeq (Nuqu (reverse ys) []) Why is this any better? After all, after adding n elements the queue would be Nuqu [] [xn,...,x2,x1], so the first removeq will take O(n) time. Note, however, that the O(n) time taken to extract x1 also transfers [x2,..,xn] to the front of the queue. Thus, after one removeq, we have Nuqu [x2,...,xn] []. The next (n-1) removeq operations take only O(1) time each. In this way, overall, adding n elements and then removing them takes only O(n) operation. The O(n) cost of extracting the first element can be thought of as amortised, or spread out, over the next n-1 removeq operations.