Why should we do proofs?
We will focus a lot on proofs in CSE 331. In this document I will motivate why doing proofs is good even though you might not do proofs for a living.1 While doing this, we will also go through examples of how to write algorithm ideas and details as well as proof ideas and details (which you will need to write in your homework solutions).
-
Of course if you do, I can personally attest to the great fun in doing so!
-
And no, this friend does not have a background in theoretical computer science: his Ph.D. was in graphics.
-
I had never thought about this question before and I was not able to derive a good algorithms on the spot. And no, I did not get a job offer from them :-)
-
If it does not face any number then it does not have a neighbor.
-
Yes, I know I said one should not just depend on testing and proof the correctness. Please bear with me.
-
This is where we use the fact that the initial values are strictly increasing.
-
Yeah, I know this is a bummer!
-
Truth be told I do not have a nice proof myself: if you find one, please let me know!
Some reasons to do proofs
In this section, I will lay out some reasons why I think it is beneficial for you guys to do proofs. The first two are probably more along the lines of "if you do proofs for a living" situation. The rest of the reasons should be valid for all of you. I will try and make the reasons as concrete as possible: in the next section, we will consider algorithms for the specific problem of generating all permutations (recall that we previously had punted on designing an algorithm for this problem).Sometimes you might not have a choice
One of the easiest way to verify an algorithm idea you have is to code up the algorithm and then test it on some (say random) inputs. However, sometimes this might not be a choice. E.g. if you work on Quantum Computing , then you do not have a quantum computer to run your quantum code on! So currently pretty much the only choice you have is to prove that your algorithm is indeed correct. For example, one of the crowning achievements of quantum computing is Shor's algorithm to computes the factors of large numbers efficiently on a quantum computer (that recall does not exist yet!). (You might also want to read Scott Aaronson's high level description of Shor's algorithm .) The reason why factoring large numbers is important is that if one can solve this problem efficiently then one can break the RSA cryptosystem . RSA is used everywhere (e.g. when you use your credit card online, RSA is used to make the transaction secure), so this is a big deal.
Below is a TED talk by Scott that gives a very high level overview of quantum computing (starting roughly in the middle of the talk).
Someone's life might depend on it
This might sounds a bit dramatic but think of how many critical system we use that are running programs: e.g. airplanes and medical devices. Bugs in these software can literally take lives. Even if no lives are at stake, software bugs can have significant financial consequences. See e.g. this Wikipedia page of notable software bug or this list of infamous software bugs . Software Testing is definitely one way to weed out bugs but for mission/life-critical applications, it would be better if you could be "double sure" that the software works correctly. Formal verification is one way to do this: this is the process of coming up with a formal proof that the software indeed works as it is supposed to. It is kind of cool that computers are used to generate proofs!
Computers have also been used to generate proofs where stakes are maybe slightly less higher: e.g. all the current known proof of the Four Color Theorem are computer generated! This video has an overview of the statement of the theorem, its history and some more details on the proof:
Proofs can be a debugging tool
We already mentioned that computer generated proofs are used to check for correctness of software (and hardware as well). However, even human generated proofs can be useful for debugging programs. Testing with random inputs is a fine way to test your program for random inputs. However, such testing is very unlikely to catch pathological corner cases. One of my friends who works with Google maps once told me that he tries to prove the correctness of algorithms he is implementing as that helps him identify corner cases. (And when you are at Google "corner cases" might not be that rare.)2
To gain a better understanding of algorithms
This one (at least at the surface) might be most philosophical of the reasons to work on proofs: a proof is a distillation of a solid understanding of the algorithm you are considering. In many cases in your jobs you will just use existing implementations of algorithms as a blackbox (and of course you should do this!) But if you had to modify the code you would need to understand the algorithm that the library implements. Proving the correctness of an algorithm is many a times a great way to really understand what an algorithm is doing. In 331 we will see many such examples when we do greedy algorithms. In particular, when we look at our first scheduling problem, we will see multiple potential algorithms all of whom look plausible but only one works. More confusingly, it is not clear why the "chosen one" works while the others don't. The best way I know to convince oneself that the chosen algorithm works is to prove that it works: and this is what we will do when we get to greedy algorithms in the course.
However, we will only get to greedy algorithms close to the mid-term exams, so instead of making you wait with bated breath, next we will look at the specific problem of generating permutations and two algorithms that are increasingly less obvious that they work. And then we will prove that they work. (Well, I'll leave the crux of the proof of correctness of the second algorithm as an exercise.)
Using Intuition
A common way folks try to "prove" that their algorithm works is with their intuition. E.g. a very common answer I have heard is that "It is obvious that my algorithm is correct." While it might be true that your intuition can be right most(?) times, it is also true that intuitions can be wrong. See this presentation by Peter Winkler on puzzles that show that our intuition can be wrong (the talk starts at the 4:25 mark):
Generating all permutations
In this section, we will consider the following problem:Generating all permutations
Input:
$n$ distinct $a_0,\dots,a_{n-1}$ numbers in increasing order (i.e, $a_0\lt a_1\lt\cdots \lt a_{n-1}$).Output:
All possible permutations of the $n$ numbers where each permutation occurs exactly once.A note on the order
Note that the problem statement does not specify that the permutations need to be output in any particular order: the algorithm you design has the full freedom in picking this order. In fact, the algorithms we will see for this problem has the following additional nice property: any permutation in the order can be obtained from the previous permutation with just one swap. (This latter property is true in the example above.)
Lest you think that this problem is some obscure mathematical problem, people have been using algorithms to generate all permutations for change ringing for church bells as early as 1621(!) for $n=4$. A solution for up to $n=6$ was listed in a book by Fabian Stedman from the second half of 1600s. By some accounts , he might be the first group theorist in math. (Group theory is now a very well studied and still active field of math.)
Generating all permutations is also useful in more recent times. See e.g. this survey by Robert Sedgewick . On a more personal note, some years back I interviewed at one of the Wall street firms. One of the phone interview questions was to design an efficient algorithms to generate all permutations. The interviewee said that they frequently needed to do this task.3
Algorithms
We will now consider algorithms to generate all permutations. Since the goal of this article of this article is to show the effectiveness of a proof in understanding algorithms, I will be a bit sneaky and just present the algorithm without building it up by first considering more naive algorithms. However, to get warmed up you might want to think about the following:Exercise
Present an $O(n^{n+1})$ time algorithm to generate all permutations of $n$ numbers.
Hint: Use a brute force algorithm where you search through sequences that are not permutations.
Steinhaus–Johnson–Trotter algorithm
We will next present the Steinhaus–Johnson–Trotter algorithm. We will follow the presentation of this algorithm from this paper .Algorithm Idea
The algorithm ``marks" the $n$ numbers in a certain way, which dictates how the algorithm proceeds. In particular, this algorithm moves from one permutation to the next by only exchanging two adjacent numbers in the current permutation. We will use the following notation to simplify the description of the algorithm idea:
- All numbers will have a direction: either "left" or "right."
- A number faces the adjacent number according to its current direction and this adjacent number is called its neighbor4.
- A number can be either active for inactive.
- The largest active number is the cursor.
- A cursor is stuck if it has no neighbors or the neighbor is larger than the cursor.
Give the above notation, the algorithm proceeds as follows. Initially all $n$ numbers are pointing "left" and all but the smallest number is active. The rest of the algorithm repeats the following. First, we print/output the current permutation. If there is no active number then we terminate. Then find the cursor $c$. Swap $c$ and its neighbor. Make all numbers strictly greater than $c$ to be active. Finally, if $c$ is stuck then reverse its direction and make it inactive.
As we will see later the above is actually an iterative version of a natural recursive algorithm where $a_{n-1}$ is put in all the $n$ positrons for every permutation of $a_0,\dots,a_{n-1}$.
Algorithm Details
//Input: A[i] for 0<= i<n //Define data structures to rember the direction and states bool direction[n]; //0 denotes "left" and 1 denotes "right" bool isActive[n]; //0 denotes "inactive" and 1 denotes "active" //Initialize the data structures for(i=0; i<n; i++) direction[i]=0; //All numbers first point "left" for(i=1; i<n; i++) isActive[i]=1; isActive[0]=0;// All but the smallest number is active int c = getCursor(isActive); while(true) { print(A); //Print the current permutation c = getCursor(isActive); if (c == -1) // There are no active numbers left break; int nbr = getNeighbor(c, direction); Swap( A[c], A[nbr]); // Need to prove that nbr is never out of bounds here // Also swap the flags Swap(direction[c], direction[nbr]); Swap(isActive[c], isActive[nbr]); c = nbr; // Update the index of the cursor //Reactivate all i s.t. A[i] > A[c] for(i=c+1; i<n; i++) if(A[i] > A[c]) isActive[i]=true; //Now check if c is stuck nbr = getNeighbor(c, direction); if( nbr == -1 or (nbr != -1 and A[c] < A[nbr])) // c is stuck { direction[c] = !direction[c]; // Reverse direction of c isActive[c] = false; //Deactivate c } } //Function that returns the value of cursor //It returns if there is no active number int getCursor(bool isActive[]) { int c=-1; for(i=0; i<n; i++) if(isActive[i]) if(c == -1) // Encountered an active number for the first time c=i; else if (A[i]>A[c]) // Update if found a larger active number c=i; return c; } //Function that returns the neighbor of a number i // It returns -1 if i has no neighbors int getNeighbor(int i, bool direction[]) { int nbr; if(direction[i]) // Direction is "right" nbr = i+1; else // Direction is "left" nbr = i-1; if(nbr >=n) // If there is no right neighbor nbr = -1; return nbr; }
Proof of Correctness
We will now argue the correctness of the Steinhaus–Johnson–Trotter algorithm. The algorithm at first blush might seem a bit opaque. The best way to gain understanding of an algorithm that you are not familiar with and/or do not feel comfortable with is to run the algorithm on some small input.5 Below is a run of the algorithm with input $n=4$ and $A[i]=i+1$ for $0\le i\le 3$. (Click on near the colored boxes to advance the run of the algorithm: each click runs one iteration of the algorithm. The different permutations printed by the algorithm appears on the left. The status of the flags appear on the right (green corresponds to active while red corresponds to the number being inactive. 'L' and 'R' denote the direction of the number being "left" or "right" respectively.Note that the first four permutations are the permutations achieved by "moving" $4$ from right end of the permutation $1,2,3$ to the left end. In fact, in each of the successive four permutation is the number $4$ moving from one end point to other end point of a permutation on $\{1,2,3\}$. In fact the order of permutation of $\{1,2,3\}$ as they appear above is exactly the order of permutations of $\{1,2,3\}$ that was presented as an example above. Further, all these permutations on $\{1,2,3\}$ appear in the order that the algorithm would produce them if it were given $\{1,2,3\}$ as input. This suggests that the algorithm is working recursively and hence, a proof by induction might do the trick. Indeed this is what we will do.
Proof Idea
Consider the following recursive algorithm. Given the numbers A[0], A[1], ..., A[n-1]
the algorithm proceeds as follows. It recursively computes all the permutations of A[0],A[1], ... , A[n-2]
. Given such a permutation $\sigma$ of the first $n-1$ numbers the algorithm then produces $n$ permutations of all the $n$ numbers by placing A[n-1]
in the $n$ possible locations in $\sigma$ so that we get a permutation on all the $n$ numbers. It can be argued by induction on $n$ that this recursive algorithms produces all the permutations of A[0], A[1], ..., A[n-1]
exactly once.
To prove the correctness of Steinhaus–Johnson–Trotter algorithm, we will essentially argue that the latter algorithm is an iterative version of the recursive algorithm above. However, we will basically combine this reduction above along with the inductive proof of correctness of the recursive algorithm above by presenting a single proof of correctness of the Steinhaus–Johnson–Trotter algorithm. In particular, the argument will work as follows: we will divide the iterations of the algorithm in which A[n-1]
is active and the iterations when it is not. We will argue that if one drops all the iterations where A[n-1]
is active, then the algorithm has basically the same behavior as when it would be given A[0], A[1], ..., A[n-2]
as input (which by induction will produce all the permutations of the first $n-1$ numbers. Then we argue that in the "active" phases the algorithm is basically moving A[n-1]
back and forth. (Note: In this paragraph we are using A[n-1]
to denote the last element in the original input. We will resolve this ambiguity in notation the proof details.)
Proof Details
We will assume that the functions getCursor
and getNeighbor
are correct (i.e. we will not explicitly prove their correctness since they implement the simple logic laid out in the proof idea part). In fact, for clarity (unless necessary) we will refer to the description of the algorithm in the comments instead of going line-by-line. (In particular, we will ignore the parts of the algorithm that are essentially doing book-keeping i.e. lines 27-31. Finally, we will use $a_0,a_1,\dots,a_{n-1}$ to denote the original input (to avoid confusion with A[0], A[1], ...., A[n-1]
, which will denote the current permutation being considered by the algorithm.
We will prove the correctness of the algorithm by induction on all inputs of size $n$. For the case case of $n=2$ note that the first time line 18 is executed the algorithm outputs $a_0,a_1$ (and at this point only $a_1$ is active). Then in line 26, A[0]
and A[1]
are swapped so we have the array A
having $(a_1,a_0)$. No reactivation happens in lines 34-36 and both the numbers are inactive after line 43 is executed. In the next iteration $a_1,a_0$ is output in line 18 and then algorithm terminates after executing line 22.
We now assume that the algorithm on receiving any $n-1$ numbers correctly outputs all its permutations (in particular, in each iteration when it gets to line 18 it is ready to output the "current" permutation). Now consider the case when the algorithm receives as input the numbers $a_0,\dots,a_{n-1}$. Call an iteration of the while
loop on line 16 to be active if $a_{n-1}$ is active and inactive otherwise. We will make the following claims.
Claim 1:
The algorithm has $n$ active iterations followed by one inactive iteration and this pattern repeats till all the numbers are inactive. In the $n$ active iterations, $a_{n-1}$ moves fromA[0]
to A[n-1]
or vice-versa. In particular, in each of these $n$ iterations $a_{n-1}$ is places in all the possible $n$ "empty" positions in the permutation on $a_0,\dots,a_{n-1}$ produced at the end of the previous inactive iteration.
Claim 2:
If one drops all the active iterations from the run of the algorithms and keeps the inactive iterations in sequence, then this is same as the algorithm running on $a_0,\dots,a_{n-2}$ (except $a_{n-1}$ "switches" from one end point ofA
to the other).
For the time being we assume that Claims 1 and 2 are true and we finish the rest of the proof. By claim 1, on the $n$ consecutive active iterations, the algorithm outputs all the permutations where the sub-permutation on $a_0,\dots,a_{n-2}$ remains the same. By induction and Claim 2, each inactive iteration produces a unique sub-permutation on $a_0,\dots a_{n-1}$. This implies each iteration of the algorithm produces a unique permutation. To argue that the algorithm prints all permutations note that by Claim 2 and induction all of the sub-permutations on $a_0,\dots,a_{n-2}$ are produced. Further, at the end of this inactive iteration all of $a_0,\dots a_{n-1}$ are inactive and $a_{n-1}$ becomes active. Further, by lines 35-36, none of $a_0,\dots,a_{n-2}$ will ever be activated in the future. Further, it is easy to verify that $a_{n-1}$ will also become inactive after $n$ active iterations after which the algorithm will terminate, as desired. To complete the proof, we complete the proofs of Claims 1 and 2.
Proof Idea of Claim 1:
We first note that due to due to lines 35 and 36 an inactive iteration has to be followed by an active iteration. Further, once $a_{n-1}$ is active it can only get inactive via line 43, i.e. when $a_{n-1}$ gets stuck. Further note that when $a_{n-1}$ is active and has a valid neighbor it gets swapped with its neighbor (and its direction does not change). Thus if, $a_{n-1}$ first gets active atA[0]
(or A[n-1]
) then it will take $n$ swaps before it reaches A[n-1]
(or A[0]
) and this is the only way that $a_{n-1}$ can get stuck.6 The rest of the proof follows by induction (on each "phase" of $n$ active iteration and the subsequent inactive iteration) and noting that initially $a_{n-1}$ is in A[n-1]
.
Proof Idea of Claim 2:
This follows from noting that in an inactive iteration $a_{n-1}$ is inactive (by definition). In other words, $a_{n-1}$ plays no part other than being at eitherA[0]
or A[n-1]
(this latter claim follows from the proof of Claim 1 above). In particular, one can show by induction on the number of inactive iterations that if we drop $a_{n-1}$ from A
then it would be exactly the same the array A
if the algorithm were presented $a_0,\dots,a_{n-2}$ as input.
Note:
We still need to prove that in line 25nbr
is never out of bounds (i.e. it is always between $0$ and $n-1$). This can be proven by induction and is left as an exercise.
Tip
In general, the strategy above is very useful for arguing the correctness of your algorithm. After you have designed the algorithm, run it on some small inputs. If you fail, then you have found a bug so go back to re-designing the algorithm. Once you cannot find any bugs in the small examples, try and spot a "pattern" that you then try to formalize and convert into a proof of correctness. (Sometime you might find out that the pattern does not hold for larger examples in which case you will have to go back and run larger examples or (if you are very confident that your algorithm is correct) you can just try to directly convert your intuition behind the algorithm into a proof of correctness.
Runtime analysis
We will not formally analyze the runtime of the algorithm above, but we wiil leave it as an exercise:Exercise
Prove that the Steinhaus–Johnson–Trotter algorithm as stated above takes time $O\left((n+1)!\right)$.
Hint: Bound the number of iterations of the while
loop.
Heap's Algorithm
There is another algorithm for generating all permutation called Heap's algorithm (not to be confused with the heap data structure ). In the interest of showing your algorithms that look pretty non-obvious, we will just state the algorithm details (without presenting the algorithm idea).Algorithm Details
//Input: A[i] for 0<= i<n permId = new int[n]; //Initialize the permId data structure for( i=0; i<n; i++) permId[i]=0; print(A); // Print the current permutation //The main loop j=1; while(j< n) { if(permId[j]<j) //Do a swap to generate the next permutation { if( j is odd) swapId =permId[j]; else swapId=0; Swap(A[swapId], A[j]); print(A); // Print the current permutation //Update the book-keeping permId[j]++; j=1; } else permId[j++]=0; }
Proof of Correctness
As we did with the Steinhaus–Johnson–Trotter algorithm, we will run Heap's algorithm on an example. Below is a run of the algorithm with input $n=4$ and $A[i]=i+1$ for $0\le i\le 3$. (Click on any of the boxes to advance the run of the algorithm: each click runs till the next new permutation is generated. The two numbers swapped are highlighted in cyan in the previous permutation.)The above run (like in the run of the Steinhaus–Johnson–Trotter algorithm) reveals a nice pattern: for now just concentrate on the last column. Each of the numbers appear $(n-1)!=6$ time. In particular, the prefix of size three contains all the $(n-1)!=6$ permutations on $a_0,a_1,a_2$. Further, this is true for every subsequent chunk of $(n-1)!$ permutation. This suggests that one could do some sort of inductive proof. The main issue is what to induct on. You might have noticed another strong pattern: the numbers in the last column appear in the order $a_3,a_2,a_1,a_0$. In particular, they appear in the reverse order of the initial permutation. In fact this is also true of the six distinct prefixes of size three for each of the six consecutive permutation. If this was true recursively for general $n$, this would be enough to carry out a proof of correctness by induction. Unfortunately, this stronger claim on how thw last column looks like is not true: consider a run for $n=5$.7 Thus, this is a great example of how our initial intuition can be wrong!
Of course one could try and feel more comfortable about Heap's algorithm by running it on larger examples but the problem with an runtime of $\Omega((n+1)!)$ is that this becomes infeaible very quickly. Thus, we would ideally like to have a proof of correctness for Heap's algorithm, which is left as an exercise.8
Exercise
Prove that the Heap's algorithm always ouputs each of the $n!$ permutations of $a_0,\dots,a_{n-1}$ exactly once.
Runtime analysis
We will not formally analyze the runtime of the algorithm above, but we wiil leave it as an exercise:Exercise
Prove that Heap's algorithm as stated above takes time $O\left((n+1)!\right)$.
Hint: Bound the number of iterations of the for
loop.
Further Reading
I found these slides from a talk by Sedgewick to be useful. In particular, it presents a recursive version of Heap's algorithm and then develops the iterative version (which is was is presented above) from it, which makes it more intuitive.
Here is a
Here is a video by Alan Cline that walks thourgh three results and talks about what insights the proofs give for those problems:
If you want a textbook to practice proofs, students have found How to Prove It by Daniel J. Velleman to be helpful.