I’ve decided to call this permutation sequence question a “classic problem” because it is I think a very reasonable combinatorics/counting question. Also, the problem is marked as a “Leetcode Hard”, so I figure I should get one of those on this blog before too long.
Discussion and Approach to Solution
As a change of pace, I tried to see what it would be like to make a “literate code” approach. This, uh, didn’t work. I ended up with a block comment above the code, so I’ve extracted that out here:
for [1, 2, 3, 4], we have 3! sequences that begin with 1, 3! that begin with 2, etc… so we end up with 4 * 3! = 4!, that checks out. So in general, if we have a size-n set, there are n! distinct values, and the first (n-1)! begin with 1, then the next (n-1)! begin with 2, etc. This reasoning applies recursively. So we want to find the highest “m” in [1, n] such that (m-1)(n-1)! < k (or equiv, the smallest m such that m(n-1)! >= k. Maybe that’s a better formulation). Note the “punning” between m as an element of the remaining set, and n as the count of elements left in the set. In turn, m is the index of the m’th largest element, not literally the value m.
So that’s a terse comment. To take a step back:
If we consider the n! possible permutations of [1..n], in lexicographical order, the first (n-1)! begin with 1, the next (n-1)! begin with 2, and so on. This leads to n sub-sequences of length (n-1)!, and we can be confident that the concatenation of these sequences gives us the original sequence back, as there are n*(n-1)!=n! elements altogether.
This suggests an analogy to digits (if we consider the sequence [0..999], the first 100 “begin” with 0, the next 100 begin with 1, and so for 10 times, leading to 100*10 = 1000 elements). We can repeat this recursively, for the sequence [0..99] there are again 10 sub-sequences. Unspooling the recursive reasoning, it means we can “index” into that sequence by looking at successively-lower-magnitude digits. This feels like a no-op because it’s sort of what we already do with normal indexing.
With this factorial-esque indexing, there are n biggest-“digit” sequences, then n-1 next-digit-sequences, and so on. The formulation in the code below is not explicitly recursive, but instead currentIndex is basically, well, the index of the implicit sequence of [result + start]. And we select certain elements from start to “increment” our currentIndex.
Code
class Solution(object):
def getPermutation(self, n, k):
# for [1, 2, 3, 4], we have 3! sequences that begin with 1, 3! that begin with 2, etc...
# so we end up with 4 * 3! = 4!, that checks out.
# So in general, if we have a size-n set, there are n! distinct values,
# and the first (n-1)! begin with 1, then the next (n-1)! begin with 2, etc.
# This reasoning applies recursively.
# So we want to find the highest "m" in [1, n] such that (m-1)(n-1)! < k
# (or equiv, the smallest m such that m(n-1)! >= k. Maybe that's a better formulation).
# Note the "punning" between m as an element of the remaining set, and n as the *count*
# of elements left in the set. In turn, m is the *index* of the m'th largest element, not
# literally the value m.
def fact(n):
res = 1
for i in range(1, n):
res *= i
return res
result = []
start = list([i for i in range(1, n+1)])
currentIndex = 1
while currentIndex != k:
# find the smallest m. Careful reasoning about indexing
# to avoid off-by-one errors (1-indexed the range of permutations,
# but m itself should be zero-indexed as it goes into `start`)
incrementN = fact(len(start))
m = -1
for m in range(len(start)):
# My one bug: I left out the "currentIndex + " part, which lead
# to an interesting behavior: n=5, k=1 and n=5, k=120 both passed without this!
if currentIndex + (m*incrementN) > k:
m -= 1
break
# remove m from start, we've selected it
assert(m >= 0 and m < len(start))
result.append(start[m])
start.pop(m)
currentIndex += m*incrementN
# At this point we know we should emit the "0th" remaining permutation.
# Some subtlety in making sure we're appending to result in the same "direction"
# as done in the while loop
result += start
return ''.join(map(str, result))
Discussion as an interview question
This is a hard-core question about recursive reasoning. I’m not sure about its appropriateness. As notes for my own attempt, while I didn’t recall my approach I did actually implement this code a long, long time ago, and in a previous form of this blog talked about the general approach. You can see my older implementation (when I was literally studying enumerations) is much better. So in revisiting this I was able to recall the broad strokes of a way that would work, and then take it from there.
Discussion general
This is a really neat computer science problem! I have a soft spot for enumeration problems, and have enjoyed jumping into Knuth 4A a few times particularly for Gray codes. I have dreams of using those to help provide exhaustive test suites for other combinatoric algorithms, but I just don’t have the time. There is a lot of depth to this topic I’m not familiar with; reconciling my “analogy” with the more in-depth treatment of Factorial Number Systems is something I should do one day.
More recursive problems (which I guess will include enumeration problems?) here.