Shuffling multiple iterators in order I have a Python function w/ specified functionality, and two proposed approaches - which do you prefer, and why? (performance, simplicity, clarity, etc) (both use import random)



PROBLEM: Need a plain Python function that accepts an arbitrary number of iterables (tuples, lists, dictionaries), and returns them shuffled in the same order:

a = (1, 2, {3: 4}, 5)
b = [(5,6), [7,8], [9,0], [1,2]]
c = {'arrow': 5, 'knee': 'guard', 0: ('x',2)}

x, y, z = magic(a, b, c)
print(x, y, z, sep='\n')
# ({3: 4}, 1, 2)
# [[9, 0], (5, 6), [7, 8]]
# {0: ('x', 2), 'arrow': 5, 'knee': 'guard'}


The function must:


Return iterables shuffled in the same order (see above)
Accept any number of iterables
Preserve iterables types
Support nested iterables of any depth and type
Not shuffle nested elements themselves (eg. [7,8] above doesn't become [8,7])
Return iterables with length of shortest iterable's length w/o raising error (see above)
Return shuffled iterables in the same order they're passed in (see above)


NOTE: requires Python 3.7. Can work with Python 3.6- if excluding dicts, as dicts aren't ordered.

SOLUTION 1:

def ordered_shuffle(*args):
    args_types = [type(arg) for arg in args]                               # [1]
    _args      = [arg if type(arg)!=dict else arg.items() for arg in args] # [2]
    args_split = [arg for arg in zip(*_args)]                              # [3]
    args_shuffled = random.sample(args_split, len(args_split))             # [4]
    args_shuffled = map(tuple, zip(*args_shuffled))                        # [5]
    return [args_types[i](arg) for i, arg in enumerate(args_shuffled)]     # [6]


Explanation: img



SOLUTION 2:

def shuffle_containers(*args):
    min_length = min(map(len, args))
    idx = list(range(min_length))
    random.shuffle(idx)
    results = []
    for arg in args:
        if isinstance(arg, list):
            results.append([arg[i] for i in idx])
        elif isinstance(arg, tuple):
            results.append(tuple(arg[i] for i in idx))
        elif isinstance(arg, dict):
            items = list(arg.items())
            results.append(dict(items[i] for i in idx))
        else:
            raise ValueError(
                "Encountered", type(arg),
                "expecting only list, dict, or tuple"
            )
    return results
functools.singledispatch

functools library includes the singledispatch() decorator.  It lets you provide a generic function, but provide special cases based on the type of the first argument.

import functools
import random

@functools.singledispatch
def shuffle(arg, order):
    """this is the generic shuffle function"""

    lst = list(arg)
    return type(arg)(lst[i] for i in order)


@shuffle.register(dict)
def _(arg, order):
    """this is shuffle() specialized to handle dicts"""

    item = list(arg.items())
    return dict(item[i] for i in order)


def ordered_shuffle(*args):
    min_length = min(map(len, args))

    indices = random.sample(range(min_length), min_length)

    return [shuffle(arg, indices) for arg in args]


Usage:

a = (1, 2, {3: 4}, 5)
b = [(5,6), [7,8], [9,0], [1,2]]
c = {'arrow': 5, 'knee': 'guard', 0: ('x',2)}

ordered_shuffle(a, b, c)


Output:

[({3: 4}, 1, 2),
 [[9, 0], (5, 6), [7, 8]],
 {0: ('x', 2), 'arrow': 5, 'knee': 'guard'}]