Search code examples
pythonalgorithmpermutationcombinatoricspython-itertools

Nested Permutations from Model


I'm writing a program that takes a permutations "model" of strings, and outputs all permutations according to that model. The model looks something like this:

model = Mix([
    [
        "this",
        PickOne(["is", "isn't"])
    ],
    PickOne([
        Mix([
            "absolutely",
            "great"
        ])
    ])
])

Within the outputted permutations,

  1. list objects will output the containing objects in sequence
  2. Mix objects will output the containing objects in every possible order with ever possible max length (including zero)
  3. PickOne objects will output only one of its containing objects at a time

So, the desired output of the above example would be:

[
    ["this", "is"],
    ["this", "isn't"],
    ["this", "is", "absolutely"],
    ["this", "is", "great"],
    ["this", "isn't", "absolutely"],
    ["this", "isn't", "great"],
    ["absolutely"],
    ["great"],
    ["absolutely", "this", "is"],
    ["great", "this", "is"],
    ["absolutely", "this", "isn't"],
    ["great", "this", "isn't"],
    []
]

So far, I've implemented the permutations for the Mix class like this:

class Mix(list):
    def __init__(self, *args, **kwargs):
        super(Mix, self).__init__(*args, **kwargs)
        self.permutations = []
        for L in range(0, len(self)+1):
            for subset in itertools.combinations(self, L):
                subset_permutations = itertools.permutations(subset)
                self.permutations.extend(subset_permutations)

and my PickOne class is simply this

class PickOne(list):
    pass

which I'm planning on just testing for in my main function and handling accordingly (if type(obj) is PickOne: ...).

itertools provides simplification and efficiency for simpler use-cases, however I'm at a loss on how it will help me for this implementation (beyond what I already implemented in Mix). Any ideas of how itertools can be used to help in a Pythonic implementation of the above?


Solution

  • I'm having some troubles wrapping my head around all those combinations, but I think your Mix class also has to yield the product of all those permutations. I've created a similar model to test this. This might work a bit different than yours, but it should be easy to adapt:

    class CombModel:
        def __init__(self, *options):
            self.options = options
    
    class Atom(CombModel):
        def __iter__(self):
            for x in self.options:
                yield x
    
    class All(CombModel):
        def __iter__(self):
            for prod in itertools.product(*self.options):
                yield prod
    
    class One(CombModel):
        def __iter__(self):
            for x in self.options:
                for y in x:
                    yield y
    
    class Mix(CombModel):
        def __iter__(self):
            for n in range(len(self.options) + 1):
                for perm in itertools.permutations(self.options, n):
                    for prod in itertools.product(*perm):
                        yield prod
    

    The CombModel only provides a var-arg constructor for all the sub-classes. Atom is a "leaf" in the model and it's there so that I can "iterate" even over single strings or integers. This saves some if/else logic in all the other classes. All is what seem to be plain lists in your model, yielding the product of the different options. One and Mix correspond to your PickOne and Mix.

    Using this rather simple model comb = Mix(All(Atom(1), Atom(21, 22)), One(Atom(31, 32), Atom(4))) I get the these combinations:

    ()
    ((1, 21),)
    ((1, 22),)
    (31,)
    (32,)
    (4,)
    ((1, 21), 31)
    ((1, 21), 32)
    ((1, 21), 4)
    ((1, 22), 31)
    ((1, 22), 32)
    ((1, 22), 4)
    (31, (1, 21))
    (31, (1, 22))
    (32, (1, 21))
    (32, (1, 22))
    (4, (1, 21))
    (4, (1, 22))
    

    Your model translates to this:

    model = Mix(
        All(
            Atom("this"),
            One(Atom("is"), Atom("isn't"))
        ),
        One(
            Mix(
                Atom("absolutely"),
                Atom("great")
            )
        )
    )
    

    And gives those combinations:

    ()
    (('this', 'is'),)
    (('this', "isn't"),)
    ((),)
    (('absolutely',),)
    (('great',),)
    (('absolutely', 'great'),)
    (('great', 'absolutely'),)
    (('this', 'is'), ())
    (('this', 'is'), ('absolutely',))
    (('this', 'is'), ('great',))
    (('this', 'is'), ('absolutely', 'great'))
    (('this', 'is'), ('great', 'absolutely'))
    (('this', "isn't"), ())
    (('this', "isn't"), ('absolutely',))
    (('this', "isn't"), ('great',))
    (('this', "isn't"), ('absolutely', 'great'))
    (('this', "isn't"), ('great', 'absolutely'))
    ((), ('this', 'is'))
    ((), ('this', "isn't"))
    (('absolutely',), ('this', 'is'))
    (('absolutely',), ('this', "isn't"))
    (('great',), ('this', 'is'))
    (('great',), ('this', "isn't"))
    (('absolutely', 'great'), ('this', 'is'))
    (('absolutely', 'great'), ('this', "isn't"))
    (('great', 'absolutely'), ('this', 'is'))
    (('great', 'absolutely'), ('this', "isn't"))
    

    Note that those are still nested lists (or tuples, actually), but those can easily be flattened afterwards. Also, there are a few pseudo-duplicates (that would be duplicates when flattened) due to the "zero or more" nature of Mix. But apart from that, this looks rather close to your expected output.


    On closer inspection, it might indeed be necesary to flatten first, so that PickOne will select only one from the Mix and not the entire Mix...

    class All(CombModel):
        def __iter__(self):
            for prod in itertools.product(*self.options):
                yield flat(prod) # flatten here
    
    class One(CombModel):
        def __iter__(self):
            for x in self.options:
                for y in flat(x): # flatten here
                    yield y
    
    class Mix(CombModel):
        def __iter__(self):
            for n in range(len(self.options) + 1):
                for perm in itertools.permutations(self.options, n):
                    for prod in itertools.product(*perm):
                        yield flat(prod) # flatten here
    

    After weeding out the duplicates, the result is this:

    ()
    ('absolutely',)
    ('absolutely', 'this', 'is')
    ('absolutely', 'this', "isn't")
    ('great',)
    ('great', 'this', 'is')
    ('great', 'this', "isn't")
    ('this', 'is')
    ('this', 'is', 'absolutely')
    ('this', 'is', 'great')
    ('this', "isn't")
    ('this', "isn't", 'absolutely')
    ('this', "isn't", 'great')