Since you stepped N = 5 (!), I'll take a stab at this.
Fuchs's [citation needed] attitudes about recursion aside, I think one way to explain what's going on is that he's tinkered with the following recursive algorithm. I hope this one isn't magic to you.
def permute0(lst, n):
if n < 2:
print(lst)
else:
for j in range(n - 1, -1, -1): # j in [0, n) descending
# generate permutations with lst[n - 1] fixed
lst[j], lst[n - 1] = lst[n - 1], lst[j] # swap
permute0(lst, n - 1)
lst[j], lst[n - 1] = lst[n - 1], lst[j] # swap back
The first optimization is that, in the interior stack frames, only the value of the variable j
needs be stored, in the array that he calls p
. I'm not going to bother with that one.
The second optimization is that he's done a swap-ectomy. We can reduce the swapping a little by not swapping an element with itself.
def permute1(lst, n):
if n < 2:
print(lst)
else:
permute1(lst, n - 1)
for j in range(n - 2, -1, -1):
lst[j], lst[n - 1] = lst[n - 1], lst[j]
permute1(lst, n - 1)
lst[j], lst[n - 1] = lst[n - 1], lst[j]
To reduce the number of swaps further, we're going to have to be cleverer. permute2
, unlike the previous versions, does not restore lst
before returning. permute2
is not recursive because it uses permute1
to do the dirty work.
def permute2(lst, n):
if n < 2:
print(lst)
else:
permute1(lst, n - 1)
for j in range(n - 2, -1, -1):
lst[j], lst[n - 1] = lst[n - 1], lst[j]
permute1(lst, n - 1)
What does permute2
do to lst
? Ignoring the calls to permute1
, which leave lst
unchanged, it rotates lst
one element to the left. Now we could write permute2a
, which calls permute2
and looks for the next element to swap into lst[n - 1]
where permute2
put it, but we can't write a whole tower of permute
s.
We need an inductive hypothesis about the behavior of our not-yet-extant permute
function that we can use to prove the next one up. This is a creative act, and the source of a lot of "magic" in computer science. What I'm about to write is probably not a realistic depiction of how I think.
The class of algorithms we're looking in has two natural constraints. First, these algorithms make the minimum number of swaps. Second, they're self-similar and exhaust all permutations on the first N - 1 elements before moving the last. Together, these constraints force one of the elements swapped to be stored in the location corresponding to Fuchs's i
.
N = 0: there's only one permutation
N = 1: there's only one permutation
N = 2: the graph looks like 0 1 - 1 0
N = 3: the graph looks like
0 1 2 1 2 0 2 0 1
| | |
-------+------- all bipartite connections
| | |
0 2 1 2 1 0 1 0 2
Start without loss of generality at 0 1 2
. (The initial contents of lst
do not matter.) Then we have to start
0 1 2 # forced w.l.o.g.
1 0 2 # forced because 2 cannot be moved yet.
The only interesting choice arises here. We could finish
2 0 1 # chose 1
0 2 1 # forced because 1 cannot be moved yet
1 2 0 # forced because we must move element 1 and 0 1 2 is already visited
2 1 0 # forced because 0 cannot be moved yet.
The other possible continuation is
1 2 0 # chose 0
2 1 0 # forced because 0 cannot be moved yet
2 0 1 # forced because we must move element 0 and 0 1 2 is already visited
0 2 1 # forced because 1 cannot be moved yet.
At this point, I notice that N = 2 and the first possibility for N = 3 both reverse the permutation. I'm going to try to construct permute3
that reverses lst
. Let's see what it might do for N = 4.
0 1 2 3
...
2 1 0 3
3 1 0 2
...
0 1 3 2
0 2 3 1
...
3 2 0 1
3 2 1 0
...
1 2 3 0 # oops
Well, that didn't work. We need an odd number of recursive calls to leave the subarray reversed. That's the first suggestion that maybe we need different behavior for i
odd and i
even.
Like permute2
, for N = 4, this hypothetical algorithm rotates the elements one to the left. Variations on that theme seem to be dead ends. It's late enough here, and I've done enough fruitless experimentation, that Fuchs's algorithm is starting to seem magical to me as well.
Let me write permute2a
after all. Recall that permute2
rotates lst
one element to the left. If permute2a
makes swaps with position j
increasing while compensating for the rotations, it swaps the same position repeatedly (say 0
, since no other position is guaranteed to be accessible).
def permute2a(lst, n):
if n < 2:
print(lst)
else:
permute2(lst, n - 1)
for j in range(n - 2, -1, -1):
lst[0], lst[n - 1] = lst[n - 1], lst[0]
permute2(lst, n - 1)
Now, an aha moment where I realize that, if permute2
called permute2a
instead of permute1
, the pair almost would be equivalent to Fuchs's algorithm. This still seems magical to me, but it's time for me to call it a day. Maybe tomorrow.
In fact, permute2a
can handle not just a left rotation but any fixed permutation from permute2
that is a cycle, i.e., that a permutation that, when applied repeatedly to a single element in its domain, gives every other element. Given that permute2
behaves this way, the effect of permute2a
is to apply permute2
's (N - 1)-cycle permutation N times, except it swaps elements in and out of position 0 in between cycles, with the effect that each element is moved along the cycle N - 1 times, which has no effect. The effect of permute2a
on lst
, regardless of permute2
, is thus to swap positions 0 and N - 1.
Now all we have to do is argue that permute2
can use permute2a
. We're helped a lot by the fact that the behavior of permute2a
is insensitive to the implementation details of permute2
. Moreover, since permute2a
touches only the first and last element of the subarray, the current implementation of permute2
is most of the way there. In fact, if N is even, it works as is.
0123
...
2103
2130
...
3120
3021
...
2031
1032
...
3012
The problem with N odd is that the same element will be swapped twice into last position.
01234
...
31204
31240
...
41230
41032
...
31042
32041
...
42031
12034 # oops
All we have to do now is show that the new permute2
cycles its elements (when N is even). I'm going to use a little group theory because I can't see a simple elementary proof. In cycle notation, the permutation is
(0 n-2)(0 n-1)(0 n-2)(1 n-1)(0 n-2)...(0 n-2)(n-3 n-1)(0 n-2)(n-2 n-1)(0 n-2).
Disjoint cycles commute.
(0 n-2)(0 n-1)[(0 n-2)...n-2 times...(0 n-2)](1 n-1)...(n-3 n-1)(n-2 n-1)(0 n-2).
Since n is even, n-2 consecutive swaps has no effect.
(0 n-2)(0 n-1)(1 n-1)...(n-3 n-1)(n-2 n-1)(0 n-2).
The sequence (0 n-1)(1 n-1)...(n-3 n-1)(n-2 n-1)
, as we observed before, is a cycle.
(0 n-2)(0 n-1 n-2 ... 1)(0 n-2).
This is a conjugated cycle, which is also a cycle.
(0 n-3 n-4 ... 1 n-2 n-1).
Here's the final version. I claim that it's equivalent to Fuchs's algorithm modulo the explicit stack.
def permute3(lst, n):
if n < 2:
print(lst)
else:
permute3(lst, n - 1)
for k in range(n - 2, -1, -1):
i = n - 1
j = 0 if i % 2 == 0 else k
lst[j], lst[i] = lst[i], lst[j]
permute3(lst, n - 1)