Filter a Set for Matching String Permutations

Viewed 8246

I am trying to use itertools.permutations() to return all the permutations of the string and return only the ones which are members of a set of words.

import itertools

def permutations_in_dict(string, words): 
    '''
    Parameters
    ----------
    string : {str}
    words : {set}

    Returns
    -------
    list : {list} of {str}    

    Example
    -------
    >>> permutations_in_dict('act', {'cat', 'rat', 'dog', 'act'})
    ['act', 'cat']
    '''

My current solution works fine in terminal but somehow couldn't pass the test case...

return list(set([''.join(p) for p in itertools.permutations(string)]) & words)

Any help will be appreciated.

6 Answers

We can be a lot faster, at the expense of longer code. Reusing Raymond's benchmark and solutions (mine are prefixed with Kelly_):

Timings with string_size=20 and words_size=100000
154 ms ±  3 ms  match_sort
 31 ms ±  3 ms  Kelly_match_sort
291 ms ± 12 ms  match_multiset
 26 ms ±  0 ms  Kelly_match_counts
424 ms ± 15 ms  match_perfect_hash
207 ms ±  7 ms  Kelly_match_perfect_hash
164 ms ±  9 ms  Kelly_match_perfect_hash2
Timings with string_size=5 and words_size=100000
  7 ms ±  0 ms  match_sort
  3 ms ±  0 ms  Kelly_match_sort
 26 ms ±  0 ms  match_multiset
  3 ms ±  0 ms  Kelly_match_counts
 21 ms ±  0 ms  match_perfect_hash
 10 ms ±  0 ms  Kelly_match_perfect_hash
  9 ms ±  0 ms  Kelly_match_perfect_hash2

My Kelly_match_sort is like Raymond's match_sort, but for each word, I first check whether its count of the search string's most common letter matches. Only if it does, I then also do the sorting check. In the above two benchmarks, this pre-check already rules out about 94% and 86% of the words, respectively.

My Kelly_match_counts is similar to match_multiset in that it compares letter counts. But instead of using Counter(word) to count all letters, I count them individually with word.count(). From most to least common letter in the search string. And as soon as I find a mismatch, I reject the word and move on to the next word. As mentioned above, very often that already happens at the first letter.

My Kelly_match_perfect_hash is like match_perfect_hash, but uses math.prod instead of reduce with mul, and maps letters to primes directly, using a dictionary (instead of going through ord to index a list). And I use map with the dictionary's get method instead of a generator expressions.

The Kelly_match_perfect_hash2 version maps only letters, so it doesn't waste the smallest primes on characters that don't even appear in words.

Full code (Try it online!):

from collections import Counter
from itertools import permutations
from string import ascii_letters as letters
from random import choice
from operator import mul
from time import time
from functools import reduce
from math import prod
from statistics import mean, stdev

def match_sort(string, words):
    target = sorted(string)
    return sorted(word for word in words if sorted(word) == target)

def match_multiset(string, words):
    target = Counter(string)
    return sorted(word for word in words if Counter(word) == target)

primes = [2, 3, 5, 7, 11]
primes += [p for p in range(13, 1620) if all(pow(b, p-1, p) == 1 for b in (5, 11))]
anagram_hash = lambda s: reduce(mul, (primes[ord(c)] for c in s))

def match_perfect_hash(string, words):
    target = anagram_hash(string)
    return sorted(word for word in words if anagram_hash(word) == target)


def Kelly_match_sort(string, words):
    if not string:
        return [''] * words.count('')
    K = max(string, key=string.count)
    V = string.count(K)
    target = sorted(string)
    return sorted(
        word for word in words
        if word.count(K) == V
        if sorted(word) == target
    )


def Kelly_match_counts(string, words):
    if not string:
        return [''] * words.count('')
    (K, V), *kvs = Counter(string).most_common()
    matches = []
    for word in words:
        if word.count(K) == V:
            for k, v in kvs:
                if word.count(k) != v:
                    break
            else:
                if len(word) == len(string):
                    matches.append(word)
    matches.sort()
    return matches


primes2 = {chr(i): p for i, p in enumerate(primes)}.get
anagram_hash2 = lambda s: prod(map(primes2, s))

def Kelly_match_perfect_hash(string, words):
    target = anagram_hash2(string)
    return sorted(word for word in words if anagram_hash2(word) == target)


primes3 = dict(zip(letters, primes)).get
anagram_hash3 = lambda s: prod(map(primes3, s))

def Kelly_match_perfect_hash2(string, words):
    target = anagram_hash3(string)
    return sorted(word for word in words if anagram_hash3(word) == target)


funcs = [
    match_sort,
    Kelly_match_sort,
    match_multiset,
    Kelly_match_counts,
    match_perfect_hash,
    Kelly_match_perfect_hash,
    Kelly_match_perfect_hash2,
]

string_size = 20
words_size = 100000

print('Timings with string_size=%d and words_size=%d' % (string_size, words_size))

times = {func: [] for func in funcs}
for _ in range(10):
    population = letters[: 
    string_size+2]
    words = set()
    for i in range(words_size):
        word = ''.join([choice(population) for i in range(string_size)])
        words.add(word)
    string = word                # Arbitrarily search use the last word as the target

    for func in funcs:
        start = time()
        func(string, words)
        end = time()
        times[func].append(end - start)

for func in funcs:
    ts = [t * 1e3 for t in times[func]]
    print('%3d ms ± %2d ms ' % (mean(ts), stdev(ts)), func.__name__)

Why even bother with permutations? This is a much simpler problem if you look at the words as dictionaries of letters. I'm sure that there's a comprehension to do it better than this, but:

    letters = dict()
    for i in word:
      letters[i] = letters.get(i, 0) + 1

do this for the word then for each word in the set, make sure that the value for each key is greater than or equal to the value of that word's key. If it is, add it to your output.

Added bonus: this should be easy to parallelize if your list of words is exceedingly long.

Related