<<

Inductive Data Structures 15-150: Principles of – Lecture 03

Giselle Reis

By now you should be getting used to the idea of inductively defined functions. Most of the time, they will use a well-know feature of programming, namely, recursion. The inductive functions you have seen so far operated only on numbers, but this is too limited. In computer science we deal with lists, trees, graphs, etc., and it would be nice if we could use the same elegant inductive treatment on these data structures as well. In this document we will explain how to do just that.

1 Inductive data structures

The reason why we could define the factorial operation so nicely on natural numbers is because we used the inductive definition of natural numbers (remember that this is a finite way to define potentially infinite domains). Using s for the successor operation, natural numbers are naturally defined as:

0 : N s(n): N → N In this notation, the number commonly written as 3 is written as s(s(s(0))) instead.

1.1 Lists Lists are very useful data structures for computer science, so let’s try to define them inductively as well. The basic building blocks of lists are the smallest possible lists, those that cannot be split into smaller lists (the “atomic” elements1). This is the empty list, written as nil. The constructor of lists is the operation that creates bigger lists from smaller pieces. It is called cons (from constructor) and takes as argument a new element and a list, and constructs a list with that element at the head (position 0). Mathematically:

nil : int list cons(x, L): int ∗ int list → int list Remark 1. The type of cons requires the type of x, the head of the list. For this reason, all elements in a list must have the same type. Observe that cons is not a function, but rather a constructor for elements of a domain. This means that the list [1, 2, 3] is represented as:

cons(1, cons(2, cons(3, nil))) Using this inductive definition, let’s define a couple of functions on lists. The first one is the function that computes the length of a list, which is defined inductively on its only argument: ( 0 if L = nil length(L) = 1 + length(L0) if L = cons(x, L0) We can also define a function that appends two lists. In this case, the function has two arguments, and we can choose to induct on the first, the second, or both lists. The smart choice here is to induct on the first one (if you want to see why, try out a definition that inducts on the second list).

1Legacy terminology from the time when we thought atoms were indivisible, remember that?

1 ( L if L = nil (L ,L ) = 2 1 1 2 0 0 cons(x, append(L ,L2)) if L1 = cons(x, L ) Many other functions on lists can be elegantly defined this way. Here are some ideas for you to try: adding all elements of a list, reverting a list, finding out if a list is a palindrome.

1.2 Trees Another relevant for computer science are trees. Trees are used in the most various domains, e.g., modeling state space search, representing hierarchical data (remember the expression trees?), storing data that needs to be quickly recovered, among others. In this section, we will deal with binary trees. There are many small variations of binary trees: data only in leaves, data only in inner nodes, same type of data in leaves and inner nodes, different type of data in leaves and inner nodes... In this document we show only trees with data in leaves, but adapting this to the other types is not too difficult. The basic building block of a is the smallest possible tree, which is composed of one node: a leaf. This node contains data (an integer in this particular case), so it is only considered a tree if the constructor is applied to an int. In order to build bigger trees, we use the node constructor, which simply takes two trees: a left and a right one, and join them in one node.

leaf(n): int → int tree node(Tl,Tr): int tree ∗ int tree → int tree Remark 2. Observe again how the types of elements in the leaves and nodes must be consistent. All leaves contain an integer. If we wanted to have leaves with different types, we would need to define different constructors for each one. The consequence is that it would be possible to predict, at compile time, what should be the type of the data, depending on the constructor found.

Here are some possible generalizations of this inductive definition for you to try: inner nodes containing an integer, leaves with strings, n-ary trees. Using the definition, the tree:

1 2 3 4 is written as:

node(node(leaf(1), leaf(2)), node(leaf(3), leaf(4))) Similar to what we did for lists, we can define an inductive function to compute the number of nodes in a tree: ( 1 if = leaf(x) size(T ) = 1 + size(Tl) + size(Tr) if T = node(Tl,Tr) The next function computes the mirror of a tree (every left/right subtree changes sides): ( leaf(x) if T = leaf(x) mirror(T ) = node(mirror(Tr), mirror(Tl)) if T = node(Tl,Tr) Can you write a function that computes the height of a tree?

2 2 Proofs by Structural Induction

One of the reasons for defining inductive domains and functions is because it makes reasoning about such structures much easier. Typically, this is achieved via proofs by induction. So far you have been practicing induction on natural numbers, but it is possible to generalize the concept to other inductively defined domains as well2. We will call this more general of induction structural in- duction, because it reasons about inductively defined structures (of which natural numbers are just a particular kind). Structural induction works the same way as induction. Most of the times there will be a base case, handling the special case when the structure is atomic (i.e. it is a basic building block). The inductive hypothesis states that the theorem holds for some structure(s). The inductive cases check that, for each possible way one can construct bigger structures from the smaller ones in the IH, the theorem holds (go back and read this sentence again). Let’s look at two examples of proofs by structural induction.

Theorem 1. ∀L1 : int list.∀L2 : int list.length(append(L1,L2)) = length(L1) + length(L2)

Proof. The proof proceeds by structural induction on L1. The proof has one base case and one inductive case.

• Base case: L1 = nil

To show: ∀L2 : int list.length(append(nil,L2)) = length(nil) + length(L2)

length(append(nil,L2)) = length(nil) + length(L2) length(L2) = length(nil) + length(L2) [def. append] length(L2) = 0 + length(L2) [def. length] length(L2) = length(L2) [math (+ neutral element)]

0 • Inductive case: L1 = cons(x, L ) 0 0 To show: ∀L2 : int list.length(append(cons(x, L ),L2)) = length(cons(x, L )) + length(L2) 0 0 IH: ∀L2 : int list.length(append(L ,L2)) = length(L ) + length(L2)

0 0 length(append(cons(x, L ),L2)) = length(cons(x, L )) + length(L2) 0 0 length(cons(x, append(L ,L2))) = length(cons(x, L )) + length(L2) [def append] 0 0 1 + length(append(L ,L2)) = length(cons(x, L )) + length(L2) [def length] 0 0 1 + (length(L ) + length(L2)) = length(cons(x, L )) + length(L2) [IH] 0 0 1 + (length(L ) + length(L2)) = (1 + length(L )) + length(L2) [def length] 0 0 1 + (length(L ) + length(L2)) = 1 + (length(L ) + length(L2)) [+ associativity]

The next proof is about trees, and in this case two IHs are needed. Theorem 2. ∀T : int tree.size(mirror(T )) = size(T ) Proof. The proof proceeds by structural induction on T . The proof has one base case and one inductive case.

• Base case: T = leaf(x) To show: size(mirror(leaf(x))) = size(leaf(x))

size(mirror(leaf(x))) = size(leaf(x)) size(leaf(x)) = size(leaf(x)) [def mirror] 1 = 1 [def size ×2] 2To see the theory behind the generalization, check the notes on the meta-theory of induction.

3 • Inductive case: T = node(Tl,Tr)

To show: size(mirror(node(Tl,Tr))) = size(node(Tl,Tr)) Since a node needs two other trees to be constructed, we need two inductive hypotheses.

IH1: size(mirror(Tl)) = size(Tl)

IH2: size(mirror(Tr)) = size(Tr)

size(mirror(node(Tl,Tr))) = size(node(Tl,Tr)) size(node(mirror(Tr), mirror(Tl))) = size(node(Tl,Tr)) [def mirror] 1 + size(mirror(Tr)) + size(mirror(Tl)) = size(node(Tl,Tr)) [def size] 1 + size(Tr) + size(mirror(Tl)) = size(node(Tl,Tr)) [IH2] 1 + size(Tr) + size(Tl) = size(node(Tl,Tr)) [IH1] 1 + size(Tr) + size(Tl) = 1 + size(Tl) + size(Tr) [def size] 1 + size(Tl) + size(Tr) = 1 + size(Tl) + size(Tr) [+ commutativity]

3 Digression

At first those definitions may seem a too bulky and overly complicated. After all, who would prefer to write 10 as a sequence of s applications? Crazy people, surely, right? Well, everything has a reason. The perceived complication on the definition of inductive domains pays out when we are defining functions and proving properties about them. The proofs are much more regular and thus harder to make mistakes. By reducing the representation of objects to using only a few symbols, analysing what happens in each case becomes simple and objective. Simplicity and objectivity are great goals to have for this course (and for life, really).

4