algorithmmathpermutation

How do I turn a permutation into an index (and back) in a context where there is a fixed amount of each permutable elements?


The problem

Let's say you have 4 red balls, 2 blue balls and 1 green ball. That is 7 balls, and considering a ball can't be distinguished from another if it has the exact same color, this makes for a total of (7!)/(4!2!1!) = 105 possible permutations (Source).

Meaning, say we associate red with 0, blue with 1 and green with 2, there are 105 ways to arrange the [0, 0, 0, 0, 1, 1, 2] array.

More generally, this can be seen as n balls, m ball types, and nₖ balls of type k with 1 <= k <= m and (n1+n2+...+nk) = n, having (n!)/(n1!*n2!*...*nk!) possible permutations.

Is there an algorithm that can get a unique number from a given permutated array, and get this permutated array back from that exact, same, unique number (also refered to as index)?

Constraints

Allowed

I don't care about the order of the permutations, the only thing that matters is that you can get a unique index from each one, and get the permutation back from its index.


This isn't a duplicate of:

This isn't off-topic

Considering all of these questions are on SO, were well-received, weren't deemed as off-topic, and do not necessarely ask for a specific programming language implementation (including the most recent one), it makes sense for this one to be on-topic, too.

Besides, I'm asking for one, specific, reversable algorithm, in the context of writing software.

The structure has changed. Nowadays you have Math SE and Computer Science SE.

ComputerScience.SE is older than all questions I have linked, Math.SE is older than all but one of them.

Math.SE says that "Algorithm implementation/design" (emphasis mine) belongs to SO.


Solution

  • Ranking ("indexing") in lexicographic order, the index of a permutation is the number of smaller permutations. A smaller permutation has an equal prefix followed by a suffix whose first element is smaller, and whose rest is in any order.

    Ranking (permutation → index)

    My rank function goes through the suffixes from short to long. Its n and ways are the length of the suffix and the number of ways that suffix can be permuted. For your example, they end up as 7 and 105. My freq tracks the values in the suffix and their frequencies. For each suffix with first element x, I consider every smaller element y from the suffix as replacement, and add to the index the number of ways the rest of the suffix can be permuted.

    def rank(perm):
        """Return the index of the given permutation."""
        freq = Counter()
        n = 0
        ways = 1
        index = 0
        for x in reversed(perm):
            freq[x] += 1
            for y in freq:
                if y < x:
                    index += ways * freq[y] // freq[x]
            n += 1
            ways = ways * n // freq[x]
        return index
    

    Simple unranking (index → permutation)

    First a simple solution. Instead of trying to "invert the code" of rank, I just start with the smallest permutation and progressively make it bigger with swaps, using rank to tell me when I went too far.

    def unrank_simple(index, elements):
        """Return the permutation of the elements that has the given index."""
        perm = sorted(elements)
        n = len(perm)
        for i in range(n):
            for j in range(i + 1, n):
                perm[i], perm[j] = perm[j], perm[i]
                if rank(perm) > index:
                    perm[i], perm[j] = perm[j], perm[i]
                    break
        return perm
    

    Fast unranking

    The below function is fast for quite large cases. It works similar to rank. First it computes all frequencies and the total number of ways. Then it computes one permutation element at a time, from first to last. It updates n, freq and ways so they reflect the remaining suffix of the permutation.

    def unrank(index, elements):
        """Return the permutation of the elements that has the given index."""
    
        freq = Counter()
        n = 0
        ways = 1
        for x in sorted(elements):
            freq[x] += 1
            n += 1
            ways = ways * n // freq[x]
    
        perm = [None] * n
        for i in range(n):
            for x in freq:
                ways_with_x_here = ways * freq[x] // n
                if index < ways_with_x_here:
                    break
                index -= ways_with_x_here
            perm[i] = x
            ways = ways_with_x_here
            freq[x] -= 1
            if not freq[x]:
                del freq[x]
            n -= 1
        return perm
    

    Correctness

    I've tested with all 105 permutations of your example. Abbreviated output, showing my three functions correctly rank and unrank all permutations:

    0 [0, 0, 0, 0, 1, 1, 2] True True True
    1 [0, 0, 0, 0, 1, 2, 1] True True True
    2 [0, 0, 0, 0, 2, 1, 1] True True True
    3 [0, 0, 0, 1, 0, 1, 2] True True True
    4 [0, 0, 0, 1, 0, 2, 1] True True True
    ...
    100 [2, 1, 0, 0, 0, 0, 1] True True True
    101 [2, 1, 0, 0, 0, 1, 0] True True True
    102 [2, 1, 0, 0, 1, 0, 0] True True True
    103 [2, 1, 0, 1, 0, 0, 0] True True True
    104 [2, 1, 1, 0, 0, 0, 0] True True True
    

    Speed

    Here are times for ranking and unranking five random permutations. For your question's example, for the larger case from your comment, and for an even much larger case with 1000 elements:

    elements: [0, 0, 0, 0, 1, 1, 2]
    ways: 105
    
                         rank     unrank
    Test #1:  correct   0.0 ms    0.0 ms
    Test #2:  correct   0.0 ms    0.0 ms
    Test #3:  correct   0.0 ms    0.0 ms
    Test #4:  correct   0.0 ms    0.0 ms
    Test #5:  correct   0.0 ms    0.0 ms
    
    
    elements: [1] * 500 + [2, 3, 4, 5, 6, 7]
    ways: 16292279781882720
    
                         rank     unrank
    Test #1:  correct   0.3 ms    0.5 ms
    Test #2:  correct   0.4 ms    0.5 ms
    Test #3:  correct   0.4 ms    0.5 ms
    Test #4:  correct   0.3 ms    0.5 ms
    Test #5:  correct   0.4 ms    0.5 ms
    
    
    elements: random.choices(range(100), k=1000)
    ways: 255162988647149562479012494411356412582180138200423158945380311225856966307407159167529717311901033020388338741195219463559379392323469500678211728725796590317647593303012144893491277456812722847744479351186896996819090854205008161083160385760927158443815073936379337020933153530051495041333488065069224485483771507620316400979506030893191352578031604610948496856618359745997931939987962450897672043160187946818640050712888098145229124145993708164358381529092692821325916456392152922693370897149951514523769128512800091260868624598513768575355846676767810543733865729360105929911935549844199905957438160062288007288875965042155604055693371904064005108330882066911410177240192625559883669891971814817429195121579118550674229346268287984254565519201350047598084385594228018996474797411379620782415246464680714591658564805229395802087064866823472583306304428519079819975792761118058478244194783455122673876174140278366969319694137793177005431406267646069555561145766245935367879696377335588050947200024794769361191216173874884824514763816170735422795629903086661384991761719994555418700393307880335808855693637029592515547953477340839433783056354855225133333398345805539080546742255989446421151482598292970089834959005517579559854706508484264709968086752092586061091687092165980288664009132767191301069307588375093280649481073370158262409514731715150009588035860451264976525804479972264281889576300376295733365110077320395581619728696594342920963226714939247906918693436233368885136213662070258251260289179427851437841908761339478645034630405011183533970180815093413161869517661858201953335435260437278285932471966175053646232160937160252321600366648164451961949387592973036276490790074394814256349825171223006016995957718161642053070654653015744325335759233986466299969036481195763505220099744393890306803630080000000000000000000000000000000000000000000000000000000000000000000000000000000000000
    
                         rank     unrank
    Test #1:  correct  94.0 ms   93.4 ms
    Test #2:  correct  96.2 ms   96.4 ms
    Test #3:  correct  92.8 ms   91.0 ms
    Test #4:  correct  92.1 ms   93.8 ms
    Test #5:  correct  95.0 ms   92.3 ms
    

    Full code

    The solutions, correctness testing, and benchmarks:

    from collections import Counter
    import random
    from time import perf_counter as time
    
    
    def rank(perm):
        """Return the index of the given permutation."""
        freq = Counter()
        n = 0
        ways = 1
        index = 0
        for x in reversed(perm):
            freq[x] += 1
            for y in freq:
                if y < x:
                    index += ways * freq[y] // freq[x]
            n += 1
            ways = ways * n // freq[x]
        return index
    
    
    def unrank_simple(index, elements):
        """Return the permutation of the elements that has the given index."""
        perm = sorted(elements)
        n = len(perm)
        for i in range(n):
            for j in range(i + 1, n):
                perm[i], perm[j] = perm[j], perm[i]
                if rank(perm) > index:
                    perm[i], perm[j] = perm[j], perm[i]
                    break
        return perm
    
    
    def unrank(index, elements):
        """Return the permutation of the elements that has the given index."""
    
        freq = Counter()
        n = 0
        ways = 1
        for x in sorted(elements):
            freq[x] += 1
            n += 1
            ways = ways * n // freq[x]
    
        perm = [None] * n
        for i in range(n):
            for x in freq:
                ways_with_x_here = ways * freq[x] // n
                if index < ways_with_x_here:
                    break
                index -= ways_with_x_here
            perm[i] = x
            ways = ways_with_x_here
            freq[x] -= 1
            if not freq[x]:
                del freq[x]
            n -= 1
        return perm
    
    
    def test(elements):
    
        # Compute all unique permutations for testing
        from itertools import permutations
        perms = sorted(map(list, set(permutations(elements))))
    
        # Test ranking and unranking
        for index, perm in enumerate(perms):
            print(
                index,
                perm,
                rank(perm) == index,
                unrank_simple(index, elements) == perm,
                unrank(index, elements) == perm
            )
        print()
    
    test([0, 0, 0, 0, 1, 1, 2])
    
    def count_ways(elements):
        n = 0
        freq = Counter()
        ways = 1
        for x in elements: 
            n += 1
            freq[x] += 1
            ways = ways * n // freq[x]
        return ways
    
    def benchmark(elements):
        print('\n\nelements:', elements)
        elements = eval(elements)
        print('ways:', count_ways(elements))
        print('\n                     rank     unrank')
        for i in range(1, 6):
            perm = elements.copy()
            random.shuffle(perm)
            t0 = time()
            index = rank(perm)
            t1 = time()
            perm2 = unrank(index, elements)
            t2 = time()
            print(f'Test #{i}:  {'correct' if perm2 == perm else 'wrong'} {(t1 - t0) * 1e3 :5.1f} ms  {(t2 - t1) * 1e3 :5.1f} ms')
    
    benchmark('[0, 0, 0, 0, 1, 1, 2]')
    benchmark('[1] * 500 + [2, 3, 4, 5, 6, 7]')
    benchmark('random.choices(range(100), k=1000)')
    

    Attempt This Online!