Structural induction is a technique for proving:
Examples of functional languages (also called
applicative languages) include:
In this section we present the syntax that we'll use for the remainder
of this set of notes, including our discussion of structural induction.
This is essentially the syntax of the extended ISWIM language used by
Burstall in his paper
on structural induction.
Function definitions
We'll assume that a slightly different syntax is used to
define non-recursive and recursive functions:
Other uses of "let"
The keyword let can also be used to bind names to values and to
expose parts of a compound object.
Here is an example of the former:
Note that one way to think about these uses of let is as
"syntactic sugar" for (a more readable version of) application:
Here is an example of the use of let to expose parts
of a compound object:
if-then-else
We will use the following syntax:
case statements with pattern match
Case statements can be used instead of nested if-then-else
statements.
For example, the statement:
data
We will assume that we have the usual integer and boolean
literals and operators, as well as lists, using nil to mean the empty
list, and using "::" or "cons"
to add a new element to the front of a list.
For example, given list L, both of the following expressions
evaluate to the list that starts with a 2 and has L as its tail:
Below are two examples of short functions written in our functional
language.
The first is a function that concatenates two lists:
Here's a trace of the call reverse(a::b::c::nil):
Question 1.
Write a function member that returns true iff
a given item is in a given list.
Question 2.
Write a function union that returns the "union" of two lists
(a list that includes every element in either list, but with no duplicates).
Overview
In the third case (properties of operations), structural induction
is especially useful when the operation is defined in an
applicative (functional) language.
Therefore, we will start with a brief review of functional languages
and some of their important features.
Functional Languages
In general, (pure) functional languages have the following characteristics:
LISP
1958
McCarthy
ISWIM ( If You See What I Mean )
1965
Landin
ML
1975
Milner
FP
1976
Backus
Krc, SASL, Miranda
1976, 83, 85
Turner
Hope
1980
Burstall
Most functional languages have (most of) the following features:
Syntax
A non-recursive function f may not include calls to f
in the expression that defines the function body;
a recursive function may.
let PI = 3.1415 in ...
This means that whenever PI is found in ..., it can be replaced by 3.1415,
and similarly for max in:
let max = maxInList(L) in (if max > 0 then 1/max else 0)
In the second example, it is convenient to use "max" rather
than the longer expression "maxInList(L)";
furthermore, because we have referential transparency, the
implementation of this let can store and reuse the
result of the call to maxInList, rather than repeating the call.
let ID = E1 in E2
is actually
(λ ID . E2) E1
let(i, rem) = intDiv(5,2) in ...
where intDiv is a function that returns a pair of values.
This use of let avoids having to define and use functions that
"take apart" that pair; e.g.:
let i = divPart(intDiv(5,2)) in
let rem = remPart(intDiv(5,2)) in ...
if <condition> then <expression1> else <expression2>
if isEmpty(L) then E1
else let h = head(L) and t = tail(L) in E2
can be expressed as:
case(L){
nil: E1
cons(h,t): E2
}
or
case(L){
nil: E1
h::t: E2
}
which are more readable.
2::L
We will also assume that types (including recursive types)
can be defined by specifying the
name of the type, and a set of one or more constructors.
For example:
cons(2, L)
tree == empty /* nullary operator */
| leaf(int) /* unary operator */
| node(tree, tree) /* binary operator */
In a function, if t is a tree, a case may be used
to determine what the root constructor is:
case(t) {
empty: ....
leaf(i): .....
node(lt, rt): ....
}
Examples
let rec concat(L1, L2) =
case(L1) {
nil: L2
x::L: x::concat(L,L2)
}
To see how this function works, let's consider what happens if
we call concat as follows:
concat(a::b::nil, c::nil)
Here's the sequence of "reductions"; each time we replace a call to
concat with its result:
concat(a::b::nil, c::nil)
Our second example function reverses the items in a list:
a::concat(b::nil, c::nil)
a::b::(nil, c::nil)
a::b::c::nil
let rec reverse(L) =
case(L) {
nil: nil
x::L: concat(reverse(L), x::nil)
}
Note that the variable L is used multiple times in this code:
This may make the code a bit confusing (i.e., it is perhaps
not ideal stylistically) but it works fine.
Each <pattern>:<expression> pair forms
its own scope, and the variables used in the pattern are bound
to values that hold in that scope.
So the formal parameter L represents the whole list,
but the L used in the last line of the code represents just
the tail of that list (and it's the tail that is passed
by the recursive call).
reverse(a::b::c::nil)
concat(reverse(b::c::nil), a::nil)
concat(concat(reverse(c::nil),b::nil), a::nil)
concat(concat(concat(reverse(nil), c::nil),b::nil), a::nil)
concat(concat(concat(nil, c::nil), b::nil), a::nil)
concat(concat(c::nil, b::nil), a::nil)
concat(c::b::nil), a::nil)
c::b::a::nil
hasDups: | list → boolean |
concat: | (list X list) → list |
compose: | ((α1 → α2) X ((α3 X α4) → α1)) → ((α3 X α4) → α2) |
overlap: | (list X list) → boolean |
Note that the type of overlap is new (is different from the types of the previously defined functions)!
Another common example of the use of higher-order functions is the reduce function, which converts a list of values to a single value. (In the Burstall paper, it is called lit.) Here's the definition:
let rec reduce(L, f, b) = // L is a list, // f is a binary function, and // b is the "base value", to be used when f is applied to the // last element of the list case(L) { nil: b x::Tail: f(x, reduce(Tail, f, b)) }To understand reduce, think of f as an operator (e.g., +), and think of reduce as:
In fact, f needs to be a function (essentially, the prefix version of the operator you want); e.g.:
Question 1.
Write a function has0 that has one list parameter L, and that uses reduce to determine whether the value zero is in L. (Note that the function you pass as the second argument to reduce needs to be of type: (int X boolean) → boolean.)
Question 2.
Write a function member that has two parameters, an item x and a list L, and that uses reduce to determine whether x is in L. (Hint: Make use of an anonymous function in defining the function to be to passed to reduce as its second argument.)
Recall that for lambda expressions there are 2 standard reduction orders:
It is worth noting that there are advantages and disadvantages to
both approaches:
Here are some examples to illustrate the idea of strictness:
Call-by-name and call-by-value are sometimes referred to as
lazy and eager evaluation.
Those terms are also used for constructors (like cons for
lists, or leaf and node for the trees defined above).
An eager constructor requires that all of its arguments be fully
evaluated, while a lazy constructor does not.
One advantage of a lazy constructor is that we can make use
of (conceptually) infinite objects.
For example, here's a function that creates an infinite list:
Here's an example of a use of the from function (to sum the
values 1 - 3):
Trace the call sumPrimes(3).
Recall that structural induction is a technique for proving properties
involving recursive data structures like lists and trees.
I think that the best way to think about structural induction is as a proof
by induction on the height of the data structure's abstract-syntax tree.
When you think about it that way, it is very similar to standard
proofs by induction, which involve showing that some
property P holds for all values of n greater than or equal to zero;
i.e.: ∀ n ≥ 0, P(n).
To prove this by induction we must:
For structural induction, we want to show that some property P holds
for every possible instance of our recursive data structure.
We use the same basic approach:
In the proof of the Church-Rosser Theorem, we saw one example of
structural induction: we used it to show that the "walk" relation
on lambda expressions has the diamond property.
The base case involved non-recursive var expressions, and
the inductive cases involved expressions built using apply
or lambda.
We can also use structural induction to prove properties of
code that operates on a recursive data structure.
For example, here is the definition of the
concat function again (which operates on lists):
Given the following definition of reverse:
OK, now how about proving something useful, and for which the
proof requires some work (discovering and proving an additional
lemma).
Recall that we made reversing a list more efficient by using an
accumulating parameter:
But now we're stuck!
We need a way to introduce a concat into the RHS.
Before reading further, see if you can think of a valid lemma;
one of the form:
One possibility is to use exactly what we need for our proof (and no more):
However, if you try that (a good idea!) you'll find that it's too weak;
the induction hypothesis is not strong enough to allow us to carry
through the induction step.
So instead we'll:
Now we can go back to the proof where we got stuck, and use our new lemma
to do one more transformation step on the RHS, making it equal the LHS:
Both example proof 2 and the lemma we used for example proof 3
involved more than one list, yet we were able to do the proofs
using induction on just one of those lists.
That worked because only function concat has two list
parameters, and it only "dissects" (does a case on) the first one.
One way to think about what we did (and what we need to do in general
to prove properties involving two lists) is to think of the "proof
space" as a graph:
For some code involving multiple data structures,
we'll need several base cases.
For example, we might show that the property holds when both lists
are empty, and when one is empty and the other is non-empty.
That corresponds to showing that the property holds along
both axies of the graph.
Then the induction step can show:
To illustrate this approach,
consider the following eq function, which
tests whether its two list parameters contain the same items in the same order:
For some other examples of structural induction, including a proof of
correctness for a very simple compiler (one that just compiles
individual expressions), see
Proving properties of
programs by structural induction by R. M. Burstall.
Below is an outline of how Burstall's compiler works, and what
the proof of correctness involves.
The proof involves definitions of the following:
Parameter-passing modes
As we mentioned when we defined NOR and AOR, they correspond to
call-by-name and call-by-value parameter passing
in functional languages.
(Note: Although procedural languages often allow programmers to specify
modes for each of a function's formal parameters--in particular,
value vs reference--functional languages do not usually
provide a similar mechanism for a choice between name and value
parameters.
Instead, that is either specified by the definition of the language
itself, or there may be a particular implementation--a compiler or
interpreter--that implements the language using call-by-name
semantics, and another that implements it using call-by-value
semantics.)
In a functional language with no side effects, points 3 and 4
can be addressed in the implementation:
Problem 3 can be solved by using call-by-need instead of call-by-name:
call-by-need evaluates an actual the first time it is
used then reuses that value rather than recomputing it again and again.
Problem 4 can be solved whenever it can be determined
(by a static analysis called strictness analysis) that a
particular parameter will always be evaluated (that the function is
strict in that parameter).
In that case, the parameter can be passed by
value without affecting the behavior of the program (thus avoiding the
complications of implementing call-by-name).
In example 1, function f is strict in x (because the condition x > 0
is always evaluated), but f is not strict in y.
In example 2, function f is strict in both x and y (because the plus
operator requires that both arguments be evaluated).
Lazy constructors and infinite objects
let rec from(n) = n::from(n+1)
If cons (::) is not lazy, then any application of from
diverges.
But if cons is lazy, then from(x) simply creates a
list whose head is x, and whose tail is from(x+1), which remains
unevaluated until it is used.
let rec sum(n, L) =
if n=0 then 0
else head(L) + sum(n-1, tail(L))
in
sum(3, from(1))
If we assume that function parameters are passed by value, but
constructors are lazy, then this program works as follows:
sum(3, from(1))
= sum(3, 1::from(2)) // evaluate 2nd param, but construct lazily
= head(1::from(2)) + sum(2, tail(1::from(2)))
= 1 + sum(2, from(2)) // result of applying head and tail
= 1 + sum(2, 2::from(3)) // evaluate args to sum
= 1 + head(2::from(3)) + sum(1, tail(2::from(3)))
= 1 + 2 + sum(1, from(3))
= 1 + 2 + sum(1, 3::from(4))
= 1 + 2 + head(3::from(4)) + sum(0, tail(3::from(4)))
= 1 + 2 + 3 + sum(0, from(4))
= 1 + 2 + 3 + sum(0, 4::from(5))
= 1 + 2 + 3 + 0
= 6
Below is another example that uses an infinite list created by function
from.
The function sumPrimes sums the first n prime numbers,
making use of function sum defined above, as well as
two auxiliary functions, filter(n,L), and sieve(L).
let rec filter(n, L) =
// removes from list L all numbers evenly divisible by n
// note that L can be infinite if :: is lazy
case(L) {
nil: nil
x::L1: if (x mod n)=0 then filter(n, L1)
else x::filter(n, L1)
}
let rec sieve(L) =
// precondition: if L == x::L1 (i.e., L is nonempty with head x)
// then x is prime and L1 includes all numbers not evenly divisible
// by a number in the range 2..x-1, and is in ascending order.
// postcondition: L includes only prime numbers
//
// again, L can be infinite if :: is lazy
case(L) {
nil: nil
x::L1: x::sieve(filter(x, L1))
}
let sumPrimes(n)= sum(n, sieve(from(2)))
Structural Induction
∀ v in [0..n], P(n) ⇒ P(n+1)
i.e., show that if the property holds for all values up to n, then
it must hold for n+1, too. Since we've shown that it holds for
n = 0, this means that it holds for n = 1, which means that it
holds for n = 2, etc.
Step two (the inductive step) involves one case
for each recursive operator; i.e., we must show that the induction
holds for all possible ways to build up a "taller" data structure
by combining shorter ones.
Some example proofs
let rec concat(L1, L2) =
case(L1) {
nil: L2
x::L: x::concat(L,L2)
}
Example 1: Prove that for all lists L: concat(L, nil)
= L
For this kind of proof (of the form left-hand-side = right-hand-side),
we must transform each side of the equation (using
the definition of the function itself and the induction hypothesis)
so that we arrive at the same final term for both.
Here we go...
Base case: L = nil
LHS:
concat(nil, nil)
= nil // def of concat
RHS:
nil
Induction step:
assume: ∀ L of length ≤ n, concat(L, nil) = L
show: true for L of length n+1, i.e., L is x::Tail
LHS:
concat(x::Tail, nil)
= x::concat(Tail, nil) // def of concat
= x::Tail // ind. hyp.
RHS:
x::Tail
Example 2: Prove that for all lists L1, L2, L3:
concat(L1, concat(L2, L3)) =
concat(concat(L1, L2), L3)
Now let's try a slightly harder one: we'll show that concat
is associative, using structural induction on L1.
Base case: L1 = nil
LHS:
concat(nil, concat(L2, L3))
= concat(L2, L3) // def of concat
RHS:
concat(concat(nil, L2), L3)
= concat(L2, L3) // def of concat
Induction step:
assume: ∀ L1 of length ≤ n, concat(L1, concat(L2, L3)) = concat(concat(L1, L2), L3)
show: true for L1 of length n+1, i.e., L1 is x::Tail
LHS:
concat(x::Tail, concat(L2, L3))
= x::concat(Tail, concat(L2, L3)) // def of concat
= x::concat(concat(Tail, L2), L3) // ind. hyp.
RHS:
concat(concat(x::Tail, L2), L3)
= concat(x::concat(Tail, L2), L3) // def of concat
= x::concat(concat(Tail, L2), L3) // def of concat
let rec reverse(L) =
case(L) {
nil: nil
x::Tail: concat(reverse(Tail), x::nil)
}
Prove, using structural induction on L1 that
for all lists L1, L2: reverse(concat(L1, L2)) = concat(reverse(L2), reverse(L1))
let reverse(L) = rev2(L,nil) // 2nd param is answer so far
let rec rev2(L,A) =
case(L) {
nil: A // when all of the list is processed the answer is A
x::Tail: rev2(Tail, x::A)
}
How do we know that this definition is correct; i.e., that it always
produces the same results as the original definition of reverse?
We don't until we prove that, using structural induction.
Example 3: Prove that for all lists L: reverse(L)
= rev2(L, nil)
Base case: L = nil
LHS:
reverse(nil)
= nil // def of reverse
RHS:
rev2(nil, nil)
= nil // def of rev2
Induction step:
assume: ∀ L of length ≤ n, reverse(L) = rev2(L, nil)
show: true for L of length n+1, i.e., L is x::Tail
LHS:
reverse(x::Tail)
= concat(reverse(Tail), x::nil) // def of reverse
= concat(rev2(Tail, nil), x::nil) // ind. hyp.
RHS:
rev2(x::Tail, nil)
= rev2(Tail, x::nil) // def of rev2
rev2(L1, L2) = concat(rev2(X, Y), Z)
rev2(L, x::nil) = concat(rev2(L, nil), x::nil)
Prove that for all L1, L2:
rev2(L1, L2) =
concat(rev2(L1, nil), L2)
Base case: L1 = nil
LHS:
rev2(nil, L2)
= L2 // def of rev2
RHS:
concat(rev2(nil, nil), L2)
= concat(nil, L2) // def of rev2
= L2 // def of concat
Induction step:
assume: ∀ L1 of length ≤ n, rev2(L1, L2) = concat(rev2(L1, nil), L2)
show: true for L1 of length n+1, i.e., L1 is x::Tail
LHS:
rev2(x::Tail, L2)
= rev2(Tail, x::L2) // def of rev2
= concat(rev2(Tail, nil), x::L2) // ind. hyp.
RHS:
concat(rev2(x::Tail, nil), L2)
= concat(rev2(Tail, x::nil), L2) // def of rev2
= concat(concat(rev2(Tail, nil), x::nil), L2) // ind hyp
= concat(rev2(Tail, nil), concat(x::nil, L2)) // associativity of concat
= concat(rev2(Tail, nil), x::concat(nil, L2)) // def of concat
= concat(rev2(Tail, nil), x::L2) // def of concat
LHS:
= concat(rev2(Tail, nil), x::nil)
RHS:
= rev2(Tail, x::nil)
= concat(rev2(Tail, nil), x::nil) // new lemma
Double Induction
|
2 |
length(L2) |
1 |
|
0 +----------------
0 1 2 3 ...
length(L1)
In general, our goal will be to show that some property P
holds for all lengths greater than or equal to zero for each list;
i.e., P(L1, L2) holds at every point on the graph.
To do this, we need a proof that:
For our example lemma, step 1 (the base case)
showed that the property of interest
held when length(L1) was 0, and length(L2) was arbitrary.
That corresponds to the (infinite) set of points along the y axis.
Step 2 (the induction step) showed that P(n, m) ⇒ P(n+1, m);
i.e., if the property holds when length(L1) = n and length(L2)
is arbitrary, then it must also hold when length(L1) = n+1 and length(L2)
is arbitrary.
That lets us take one "sideways" step in the graph.
Since the base case covered the entire y axis, sideways steps are
sufficient to cover the whole graph.
P(n, m) ⇒ P(n+1, m+1)
i.e., that we can take
one "diagonal" step (to the right and up) from any point.
Since the base cases covered both the x and the y axis, diagonal
steps allow us to cover the whole graph.
let rec eq(L1, L2) =
case (L1) {
nil: case (L2) {
nil: true
y::L: false
}
x::Tail1: case (L2) {
nil: false
y::Tail2: if (x == y) then eq(Tail1, Tail2)
else false
}
}
Suppose we want to prove that eq(L1, L2) = eq(L2, L1).
If we try using induction on just L1, we can't even prove the base
case.
We can, however, do the proof using the three base cases and
the one induction step described above as follows:
Prove that for all L1, L2:
eq(L1, L2) =
eq(L2, L1)
Base case 1: L1 = L2 = nil
LHS:
eq(nil, nil)
RHS:
eq(nil, nil)
Base case 2: L1 = nil, L2 ≠ nil (i.e., L2 is x::Tail)
LHS:
eq(nil, x::Tail)
= false // def of eq
RHS:
eq(x::Tail,nil)
= false // def of eq
Base case 3: L1 ≠ nil, L2 = nil
...same as Base case 2 but with LHS and RHS reversed...
Induction step:
assume: ∀ L1 of length ≤ n, and L2 of length ≤ m, eq(L1, L2) = eq(L2, L1)
show: true for L1 of length n+1 and L2 of length m+1; i.e., L1 is x::Tail1 and L2 is y::Tail2
LHS:
eq(x::Tail1, y::Tail2)
= if (x==y) then eq(Tail1, Tail2) else false // def of eq
= if (x==y) then eq(Tail2, Tail1) else false // ind hyp
RHS:
eq(y::Tail2, x::Tail1)
= if (y==x) then eq(Tail2, Tail1) else false // def of eq
= if (x==y) then eq(Tail2, Tail1) else false // commutativity of ==
Burstall's Paper
Given these definitions, the proof
shows that compiling an expression and executing the resulting
list of instructions on the target machine yields the
result defined by the intended semantics
(where "yields" means "leaves at top-of-stack");
i.e., the proof shows that the following diagram commutes:
comp
e ------------------> mp
| |
val | | mpval
| |
v v
v ------------------> s'
load
i.e., that the value produced by compiling (applying function comp)
and executing (applying function mpval), which corresponds to
the path
------------------->
|
|
|
v
produces the same result
as the intended semantics (applying function val, and pushing the
result onto the stack), which corresponds to
the path
|
|
|
v
--------------------->
The proof is by structural induction on the expression.