Recursive generator for combinations of a multiset?

John O'Hagan research at johnohagan.com
Wed Nov 27 05:25:12 EST 2013


On Tue, 26 Nov 2013 10:33:06 +0000
Oscar Benjamin <oscar.j.benjamin at gmail.com> wrote:

> On 26 November 2013 06:18, John O'Hagan <research at johnohagan.com>
> wrote:
> >
[...]
> >
> > def _multicombs(prepend, words, r, chkstr):
> >     """chkstr is the string of remaining availalable characters"""
> >     if r == 0:
> >         yield prepend, chkstr
> >         return
> >     (word, count, rem), *remaining = words
> >     newstr = chkstr
> >     for k in range(max(r-rem, 0), min(count, r) + 1):
> >         for letter in word * k:
> >             if letter in newstr:
> >                 newstr = newstr.replace(letter, '' , 1)
> >             else:
> >                 return
> >         yield from _multicombs(prepend + (word,) * k, remaining,
> > r-k, newstr)
> 
> Further micro-optimisations are possible. One is to inline the lower
> recursion levels. For example if the termination condition is checked
> immediately before recursion you can eliminate a redundant generator
> function call.
> 
> > By progressively checking against the available characters, it
> > avoids fruitless recursion branches. I'm not 100% sure I'm doing
> > this right yet but so far it's promising. This is the quickest
> > non-redundant approach I've used so far (although strangely, the
> > original product-only version which produces a lot of redundant
> > results is still by far the quickest.)
> 
> Bear in mind that the interpreter does a lot of  "redundant" things so
> what you might think of as non-redundant code can actually be highly
> redundant when interpreter over-head is accounted for.

I don't mind redundancy as long as I don't have to see it in the output!

> Recently I've been trying out PyPY in my own work and it is *really
> fast*. In fact it is so much faster that I've given up on the idea of
> speed-optimising code for CPython: just use PyPy instead - although it
> is worth optimising highly intensive code for PyPy.

I'll have a look at it.

> > I'll try to explain better what I'm actually doing. It's quite long,
> > but you seem to be wondering, or maybe someone else is!
> 
> [snip]
> 
> Okay I understand now. I was confused because I think of anagrams as
> being words of the same length. I didn't understand how your multiword
> version works but I think I do now. In my own words: You want to
> generate all sequences of English words having the same letters as
> some input string, ignoring the spaces in both the input and output
> strings (so that the number or length of the individual words need not
> be the same).
> 
> [snip]
> >
> > For example, take the phrase "break beat". I make a wordlength
> > dictionary of sub-alphabet words, using the system dictionary file,
> > like this:
> >
> >  {1: ['a'], 2: ['at', 'be', 're'], 3: ['are', 'ark', 'art', 'ate',
> >  'baa', 'bar', 'bat', 'bee', 'bet', 'bra', 'ear', 'eat', 'ebb',
> > 'eke', 'era', 'ere', 'eta', 'rat', 'tab', 'tar', 'tea', 'tee'], 4:
> > ['abet', 'area', 'babe', 'bake', 'barb', 'bare', 'bark', 'bate',
> > 'beak', 'bear', 'beat', 'beer', 'beet', 'beta', 'brat', 'rake',
> > 'rate', 'reek', 'take', 'tare', 'teak', 'tear', 'tree', 'trek'], 5:
> > ['abate', 'baker', 'beret', 'brake', 'break', 'eater', 'karat',
> > 'kebab', 'taker'], 6: ['aerate', 'beaker', 'beater', 'berate',
> > 'betake', 'karate', 'rebate', 'retake']}
> 
> Okay, how about the following (pseudoish-code)?
> 
> def word_sequences(prepend, target, subwords):
>     # subwords is a list rather than a dict
>     for n in range(1, len(subwords)):
>         for word in subwords[n]:
>             if equal_in_a_multiset_sense(word, target):
>                 yield prepend + ' ' + target
>             elif is_a_submultiset(word, target):
>                 recurse_target = multiset_subtract(target, word)
>                 subsubwords =
> list_of_subwords_by_length[:len(recurse_target)] if any(subsubwords):
>                     yield from word_sequences(prepend + ' ' + word,
> recurse_target, subsubwords)
> 

[...]

That is very clever. Direct, economical and does effectively the
same pruning job as my approach without all the combinatorial
brainstrain. I got it working by changing "for n in range..." to "for
words in subwords" (otherwise it missed the one-letter words) and the
first yield needed to be "prepend + ' ' + word". 

I simplified it a bit more to this:

def word_sequences(prepend, target, subwords):
    """subwords is a list of lists of subwords grouped by length,
        in order of length"""
    for words in subwords:
        for word in words:
            recurse_target = subset_subtract(target, word)
            if recurse_target:
                yield from word_sequences(prepend + ' ' + word,
                        recurse_target, subwords[:len(recurse_target)])
            elif recurse_target == '':
                yield prepend + ' ' + word

with just one function to do the subset testing:

def subset_subtract(target, word):
    for i in word:
        if i in target:
            target = target.replace(i, '' ,1)
        else:
            return
    return target

Speed-wise it is somewhat faster than any of my non-duplicate-producing
attempts, but still way slower than the current champion, the redundant
cartesian product-only version. 

However, because it iterates through all the remaining words on each
recursion, it seems to produce n! of each unique result, where n in the
number of words in the result, so this is the new champion as far as
redundancy is concerned. I'll keep working on it, the totally
different approach is interesting.

--

John 



More information about the Python-list mailing list