La forma más rápida de generar una cadena única aleatoria con longitud aleatoria en Python 3

Sé cómo crear una cadena aleatoria, como:

''.join(secrets.choice(string.ascii_uppercase + string.digits) for _ in range(N)) 

Sin embargo, no debería haber duplicados, así que en este momento solo estoy comprobando si la clave ya existe en una lista, como se muestra en el siguiente código:

 import secrets import string import numpy as np amount_of_keys = 40000 keys = [] for i in range(0,amount_of_keys): N = np.random.randint(12,20) n_key = ''.join(secrets.choice(string.ascii_uppercase + string.digits) for _ in range(N)) if not n_key in keys: keys.append(n_key) 

Lo cual está bien para una pequeña cantidad de claves como 40000 , sin embargo, el problema no se adapta bien, cuantas más claves haya. Así que me pregunto si hay una forma más rápida de llegar al resultado para obtener aún más claves, como 999999

Mejoras básicas, sets y nombres locales.

Use un conjunto , no una lista, y las pruebas de singularidad son mucho más rápidas; establecer las pruebas de membresía toma tiempo constante independientemente del tamaño establecido, mientras que las listas toman O (N) tiempo lineal. Use una comprensión de set para producir una serie de claves a la vez para evitar tener que buscar y llamar al método set.add() en un bucle; apropiadamente aleatorias, las claves más grandes tienen una probabilidad muy pequeña de producir duplicados de todos modos.

Debido a que esto se hace en un circuito cerrado, vale la pena optimizar al máximo todas las búsquedas de nombres:

 import secrets import numpy as np from functools import partial def produce_amount_keys(amount_of_keys, _randint=np.random.randint): keys = set() pickchar = partial(secrets.choice, string.ascii_uppercase + string.digits) while len(keys) < amount_of_keys: keys |= {''.join([pickchar() for _ in range(_randint(12, 20))]) for _ in range(amount_of_keys - len(keys))} return keys 

El argumento de la palabra clave _randint vincula el nombre np.random.randint a un local en la función, que es más rápido de referencia que los globales, especialmente cuando se trata de búsquedas de atributos.

El pickchar() parcial evita buscar atributos en módulos o más locales; es un solo invocable que tiene todas las referencias en su lugar, por lo que es más rápido de ejecutar, especialmente cuando se realiza en un bucle.

El bucle while se mantiene iterando solo si se produjeron duplicados. Producimos suficientes claves en un solo conjunto de comprensión para completar el rest si no hay duplicados.

Tiempos para esa primera mejora.

Para 100 artículos, la diferencia no es tan grande:

 >>> timeit('p(100)', 'from __main__ import produce_amount_keys_list as p', number=1000) 8.720592894009314 >>> timeit('p(100)', 'from __main__ import produce_amount_keys_set as p', number=1000) 7.680242831003852 

pero cuando empiece a ampliar esto, notará que el costo de la prueba de membresía O (N) en una lista realmente reduce su versión:

 >>> timeit('p(10000)', 'from __main__ import produce_amount_keys_list as p', number=10) 15.46253142200294 >>> timeit('p(10000)', 'from __main__ import produce_amount_keys_set as p', number=10) 8.047800761007238 

Mi versión ya es casi el doble de rápida que 10k artículos; Los artículos de 40k se pueden ejecutar 10 veces en unos 32 segundos:

 >>> timeit('p(40000)', 'from __main__ import produce_amount_keys_list as p', number=10) 138.84072386901244 >>> timeit('p(40000)', 'from __main__ import produce_amount_keys_set as p', number=10) 32.40720253501786 

La versión de la lista tomó más de 2 minutos, más de diez veces más.

Función random.choice de Numpy, no criptográficamente fuerte

Puede hacer esto aún más rápido si abandona el módulo de secrets y usa np.random.choice() lugar; sin embargo, esto no producirá una aleatoriedad de nivel criptográfico, pero elegir un carácter aleatorio es dos veces más rápido:

 def produce_amount_keys(amount_of_keys, _randint=np.random.randint): keys = set() pickchar = partial( np.random.choice, np.array(list(string.ascii_uppercase + string.digits))) while len(keys) < amount_of_keys: keys |= {''.join([pickchar() for _ in range(_randint(12, 20))]) for _ in range(amount_of_keys - len(keys))} return keys 

Esto hace una gran diferencia, ahora se pueden producir 10 claves de 40k en solo 16 segundos:

 >>> timeit('p(40000)', 'from __main__ import produce_amount_keys_npchoice as p', number=10) 15.632006907981122 

Más ajustes con el módulo de itertools y un generador.

También podemos tomar la función unique_everseen() de la sección Recetas del módulo itertools para que se encargue de la singularidad, luego usar un generador infinito y la función itertools.islice() para limitar los resultados solo al número que deseamos:

 # additional imports from itertools import islice, repeat # assumption: unique_everseen defined or imported def produce_amount_keys(amount_of_keys): pickchar = partial( np.random.choice, np.array(list(string.ascii_uppercase + string.digits))) def gen_keys(_range=range, _randint=np.random.randint): while True: yield ''.join([pickchar() for _ in _range(_randint(12, 20))]) return list(islice(unique_everseen(gen_keys()), amount_of_keys)) 

Esto es un poco más rápido aún, pero solo marginalmente:

 >>> timeit('p(40000)', 'from __main__ import produce_amount_keys_itertools as p', number=10) 14.698191125993617 

Os.urandom () bytes y un método diferente para producir cadenas

A continuación, podemos seguir con las ideas de Adam Barnes para usar UUID4 (que básicamente es solo una envoltura alrededor de os.urandom() ) y Base64. Pero al plegar la Base64 y reemplazar 2 caracteres por otros elegidos al azar, su método limita severamente la entropía en esas cadenas (no producirá el rango completo de valores únicos posibles, una cadena de 20 caracteres que solo se usa (256 ** 15) / (36 ** 20) == 1 en cada 99437 bits de entropía!).

La encoding Base64 utiliza caracteres y dígitos en mayúsculas y minúsculas, pero también agrega los caracteres - y / (o + y _ para la variante de URL segura). Solo para letras y dígitos en mayúsculas, debe escribir en mayúsculas y asignar esos dos caracteres adicionales a otros caracteres aleatorios, un proceso que elimina una gran cantidad de entropía de los datos aleatorios proporcionados por os.urandom() . En lugar de usar Base64, también puede usar la encoding Base32, que usa letras mayúsculas y los dígitos del 2 al 8, por lo que produce cadenas con 32 ** n posibilidades en comparación con 36 ** n. Sin embargo, esto puede acelerar las cosas más allá de los bashs anteriores:

 import os import base64 import math def produce_amount_keys(amount_of_keys): def gen_keys(_urandom=os.urandom, _encode=base64.b32encode, _randint=np.random.randint): # (count / math.log(256, 32)), rounded up, gives us the number of bytes # needed to produce *at least* count encoded characters factor = math.log(256, 32) input_length = [None] * 12 + [math.ceil(l / factor) for l in range(12, 20)] while True: count = _randint(12, 20) yield _encode(_urandom(input_length[count]))[:count].decode('ascii') return list(islice(unique_everseen(gen_keys()), amount_of_keys)) 

Esto es realmente rápido:

 >>> timeit('p(40000)', 'from __main__ import produce_amount_keys_b32 as p', number=10) 4.572628145979252 

40k teclas, 10 veces, en poco más de 4 segundos. Tan cerca de 75 veces más rápido; la velocidad de usar os.urandom() como fuente es innegable.

Esto es, criptográficamente fuerte otra vez ; os.urandom() produce bytes para uso criptográfico. Por otro lado, redujimos el número de cadenas posibles producidas en más del 90% ( ((36 ** 20) - (32 ** 20)) / (36 ** 20) * 100 es 90.5), no somos más tiempo utilizando los 0 , 1 , 8 y 9 dígitos en las salidas.

Así que quizás deberíamos usar el truco urandom() para producir una encoding Base36 adecuada; tendremos que producir nuestra propia función b36encode() :

 import string import math def b36encode(b, _range=range, _ceil=math.ceil, _log=math.log, _fb=int.from_bytes, _len=len, _b=bytes, _c=(string.ascii_uppercase + string.digits).encode()): """Encode a bytes value to Base36 (uppercase ASCII and digits) This isn't too friendly on memory because we convert the whole bytes object to an int, but for smaller inputs this should be fine. """ b_int = _fb(b, 'big') length = _len(b) and _ceil(_log((256 ** _len(b)) - 1, 36)) return _b(_c[(b_int // 36 ** i) % 36] for i in _range(length - 1, -1, -1)) 

y usa eso:

 def produce_amount_keys(amount_of_keys): def gen_keys(_urandom=os.urandom, _encode=b36encode, _randint=np.random.randint): # (count / math.log(256, 36)), rounded up, gives us the number of bytes # needed to produce *at least* count encoded characters factor = math.log(256, 36) input_length = [None] * 12 + [math.ceil(l / factor) for l in range(12, 20)] while True: count = _randint(12, 20) yield _encode(_urandom(input_length[count]))[-count:].decode('ascii') return list(islice(unique_everseen(gen_keys()), amount_of_keys)) 

Esto es razonablemente rápido, y sobre todo produce el rango completo de 36 letras y dígitos en mayúsculas:

 >>> timeit('p(40000)', 'from __main__ import produce_amount_keys_b36 as p', number=10) 8.099918447987875 

Por supuesto, la versión base32 es casi el doble de rápida que esta (gracias a una implementación eficiente de Python utilizando una tabla), pero el uso de un codificador Base36 personalizado sigue siendo el doble de la velocidad de la versión numpy.random.choice() no segura desde el punto de numpy.random.choice() criptográfico.

Sin embargo, el uso de os.urandom() produce un sesgo de nuevo; tenemos que producir más bits de entropía de lo que se requiere para entre 12 y 19 "dígitos 36 base". Para 17 dígitos, por ejemplo, no podemos producir 36 ** 17 valores diferentes usando bytes, solo el equivalente más cercano a 256 ** 11 bytes, que es aproximadamente 1.08 veces demasiado alto, por lo que terminaremos con un sesgo hacia A , B y, en menor medida, C (gracias Stefan Pochmann por señalarlo).

Escoger un entero debajo (36 ** length) y mapear enteros a base36

Por lo tanto, debemos llegar a un método seguro y aleatorio que nos pueda dar valores distribuidos uniformemente entre 0 (inclusive) y 36 ** (desired length) (exclusivo). Luego podemos asignar el número directamente a la cadena deseada.

Primero, mapeando el entero a una cadena; Se ha ajustado lo siguiente para producir la cadena de salida más rápido:

 def b36number(n, length, _range=range, _c=string.ascii_uppercase + string.digits): """Convert an integer to Base36 (uppercase ASCII and digits)""" chars = [_c[0]] * length while n: length -= 1 chars[length] = _c[n % 36] n //= 36 return ''.join(chars) 

A continuación, necesitamos un método rápido y criptográficamente seguro para seleccionar nuestro número en un rango. Aún puede usar os.urandom() para esto, pero luego tendría que enmascarar los bytes hasta un número máximo de bits, y luego hacer un bucle hasta que su valor real esté por debajo del límite. Esto ya está implementado por la función secrets.randbelow() . En las versiones de Python <3.6 puede usar random.SystemRandom().randrange() , que usa el mismo método exacto con un random.SystemRandom().randrange() adicional para admitir un límite inferior mayor que 0 y un tamaño de paso.

Usando secrets.randbelow() la función se convierte en:

 import secrets def produce_amount_keys(amount_of_keys): def gen_keys(_below=secrets.randbelow, _encode=b36number, _randint=np.random.randint): limit = [None] * 12 + [36 ** l for l in range(12, 20)] while True: count = _randint(12, 20) yield _encode(_below(limit[count]), count) return list(islice(unique_everseen(gen_keys()), amount_of_keys)) 

y esto es bastante parecido a la solución base64 (probablemente sesgada):

 >>> timeit('p(40000)', 'from __main__ import produce_amount_keys_below as p', number=10) 5.135716405988205 

Esto es casi tan rápido como el enfoque de Base32, ¡pero produce la gama completa de teclas!

Así que es una carrera de velocidad, ¿verdad?

Sobre la base del trabajo de Martijn Pieters, tengo una solución que aprovecha hábilmente otra biblioteca para generar cadenas aleatorias: uuid .

Mi solución es generar un uuid4 , base64, codificarlo y mayúsculas, para obtener solo los caracteres que buscamos y luego dividirlos en una longitud aleatoria.

Esto funciona para este caso porque la longitud de las salidas que buscamos, (12-20), es más corta que la encoding base64 más corta de un uuid4. También es muy rápido, porque uuid es muy rápido.

También lo hice un generador en lugar de una función regular, porque pueden ser más eficientes.

Curiosamente, el uso de la función randint la biblioteca estándar fue más rápido que el de numpy .

Aquí está la salida de prueba:

 Timing 40k keys 10 times with produce_amount_keys 20.899942063027993 Timing 40k keys 10 times with produce_amount_keys, stdlib randint 20.85920040300698 Timing 40k keys 10 times with uuidgen 3.852462349983398 Timing 40k keys 10 times with uuidgen, stdlib randint 3.136272903997451 

Aquí está el código para uuidgen() :

 def uuidgen(count, _randint=np.random.randint): generated = set() while True: if len(generated) == count: return candidate = b64encode(uuid4().hex.encode()).upper()[:_randint(12, 20)] if candidate not in generated: generated.add(candidate) yield candidate 

Y aquí está todo el proyecto. (En commit d9925d en el momento de escribir).


Gracias a los comentarios de Martijn Pieters, he mejorado un poco el método, aumentando la entropía y acelerándolo en un factor de aproximadamente 1/6.

Todavía hay mucha entropía perdida al convertir todas las letras en minúsculas en mayúsculas. Si eso es importante, entonces es posiblemente recomendable utilizar b32encode() , que tiene los caracteres que queremos, menos 0 , 1 , 8 y 9 .

La nueva solución dice lo siguiente:

 def urandomgen(count): generated = set() while True: if len(generated) == count: return desired_length = randint(12, 20) # # Faster than math.ceil # urandom_bytes = urandom(((desired_length + 1) * 3) // 4) # # candidate = b64encode(urandom_bytes, b'//').upper() # # The above is rolled into one line to cut down on execution # time stemming from locals() dictionary access. candidate = b64encode( urandom(((desired_length + 1) * 3) // 4), b'//', ).upper()[:desired_length] while b'/' in candidate: candidate = candidate.replace(b'/', choice(ALLOWED_CHARS), 1) if candidate not in generated: generated.add(candidate) yield candidate.decode() 

Y la salida de prueba:

 Timing 40k keys 10 times with produce_amount_keys, stdlib randint 19.64966493297834 Timing 40k keys 10 times with uuidgen, stdlib randint 4.063803717988776 Timing 40k keys 10 times with urandomgen, stdlib randint 2.4056471119984053 

El nuevo commit en mi repository es 5625fd .


Los comentarios de Martijn sobre la entropía me hicieron pensar. El método que utilicé con base64 y .upper() hace que las letras sean mucho más comunes que los números. Revisé el problema con una mente más binaria.

La idea era tomar la salida de os.urandom() , interpretarla como una larga cadena de números sin signo de 6 bits y usar esos números como un índice para una matriz de los caracteres permitidos. El primer número de 6 bits seleccionaría un carácter del rango A..Z0..9A..Z01 , el segundo número de 6 bits seleccionaría un carácter del rango 2..9A..Z0..9A..T , y así.

Esto tiene un ligero aplastamiento de la entropía, ya que el primer carácter tendrá un poco menos de probabilidad de contener 2..9 , el segundo carácter U..Z0 menos probable que contenga U..Z0 , y así sucesivamente, pero es mucho mejor que antes.

Es ligeramente más rápido que uuidgen() , y un poco más lento que urandomgen() , como se muestra a continuación:

 Timing 40k keys 10 times with produce_amount_keys, stdlib randint 20.440480664998177 Timing 40k keys 10 times with uuidgen, stdlib randint 3.430628580001212 Timing 40k keys 10 times with urandomgen, stdlib randint 2.0875444510020316 Timing 40k keys 10 times with bytegen, stdlib randint 2.8740892770001665 

No estoy completamente seguro de cómo eliminar el último trozo de trituración de entropía; compensar el punto de inicio para que los personajes solo muevan el patrón un poco, al azar la compensación será lenta, barajar el mapa aún tendrá un período … Estoy abierto a las ideas.

El nuevo código es el siguiente:

 from os import urandom from random import randint from string import ascii_uppercase, digits # Masks for extracting the numbers we want from the maximum possible # length of `urandom_bytes`. bitmasks = [(0b111111 << (i * 6), i) for i in range(20)] allowed_chars = (ascii_uppercase + digits) * 16 # 576 chars long def bytegen(count): generated = set() while True: if len(generated) == count: return # Generate 9 characters from 9x6 bits desired_length = randint(12, 20) bytes_needed = (((desired_length * 6) - 1) // 8) + 1 # Endianness doesn't matter. urandom_bytes = int.from_bytes(urandom(bytes_needed), 'big') chars = [ allowed_chars[ (((urandom_bytes & bitmask) >> (i * 6)) + (0b111111 * i)) % 576 ] for bitmask, i in bitmasks ][:desired_length] candidate = ''.join(chars) if candidate not in generated: generated.add(candidate) yield candidate 

Y el código completo, junto con un README más profundo sobre la implementación, se termina en de0db8 .

Intenté varias cosas para acelerar la implementación, como se ve en el repository. Algo que definitivamente ayudaría es una encoding de caracteres donde los números y las letras mayúsculas ASCII son secuenciales.

Una simple y rápida:

 def b36(n, N, chars=string.ascii_uppercase + string.digits): s = '' for _ in range(N): s += chars[n % 36] n //= 36 return s def produce_amount_keys(amount_of_keys): keys = set() while len(keys) < amount_of_keys: N = np.random.randint(12, 20) keys.add(b36(secrets.randbelow(36**N), N)) return keys 

- Edición: lo que sigue se refiere a una revisión previa de la respuesta de Martijn. Después de nuestra discusión, agregó otra solución, que es esencialmente la misma que la mía, pero con algunas optimizaciones. Sin embargo, no ayudan mucho, es solo un 3,4% más rápido que el mío en mis pruebas, por lo que en mi opinión solo complican las cosas. -

En comparación con la solución final de Martijn en su respuesta aceptada, la mía es mucho más simple, alrededor del factor 1.7 más rápido y sin prejuicios:

 Stefan 8.246490597876106 seconds. 8 different lengths from 12 to 19 Least common length 19 appeared 124357 times. Most common length 16 appeared 125424 times. 36 different characters from 0 to Z Least common character Q appeared 429324 times. Most common character Y appeared 431433 times. 36 different first characters from 0 to Z Least common first character C appeared 27381 times. Most common first character Q appeared 28139 times. 36 different last characters from 0 to Z Least common last character Q appeared 27301 times. Most common last character E appeared 28109 times. Martijn 14.253227412021943 seconds. 8 different lengths from 12 to 19 Least common length 13 appeared 124753 times. Most common length 15 appeared 125339 times. 36 different characters from 0 to Z Least common character 9 appeared 428176 times. Most common character C appeared 434029 times. 36 different first characters from 0 to Z Least common first character 8 appeared 25774 times. Most common first character A appeared 31620 times. 36 different last characters from 0 to Z Least common last character Y appeared 27440 times. Most common last character X appeared 28168 times. 

Martijn tiene un sesgo en el primer carácter, A aparece con demasiada frecuencia y 8 demasiado rara vez. Realicé mi prueba diez veces, su primer personaje más común siempre fue A o B (cinco veces cada uno), y su carácter menos común siempre fue 7 , 8 o 9 (dos, tres y cinco veces, respectivamente). También verifiqué las longitudes por separado, la longitud 17 fue particularmente mala, su primer personaje más común siempre apareció unas 51500 veces, mientras que su primer personaje menos común apareció unas 25400 veces.

Nota divertida: estoy usando el módulo de secrets que Martijn desestimó 🙂

Todo mi guión:

 import string import secrets import numpy as np import os from itertools import islice, filterfalse import math #------------------------------------------------------------------------------------ # Stefan #------------------------------------------------------------------------------------ def b36(n, N, chars=string.ascii_uppercase + string.digits): s = '' for _ in range(N): s += chars[n % 36] n //= 36 return s def produce_amount_keys_stefan(amount_of_keys): keys = set() while len(keys) < amount_of_keys: N = np.random.randint(12, 20) keys.add(b36(secrets.randbelow(36**N), N)) return keys #------------------------------------------------------------------------------------ # Martijn #------------------------------------------------------------------------------------ def b36encode(b, _range=range, _ceil=math.ceil, _log=math.log, _fb=int.from_bytes, _len=len, _b=bytes, _c=(string.ascii_uppercase + string.digits).encode()): b_int = _fb(b, 'big') length = _len(b) and _ceil(_log((256 ** _len(b)) - 1, 36)) return _b(_c[(b_int // 36 ** i) % 36] for i in _range(length - 1, -1, -1)) def produce_amount_keys_martijn(amount_of_keys): def gen_keys(_urandom=os.urandom, _encode=b36encode, _randint=np.random.randint, _factor=math.log(256, 36)): while True: count = _randint(12, 20) yield _encode(_urandom(math.ceil(count / _factor)))[-count:].decode('ascii') return list(islice(unique_everseen(gen_keys()), amount_of_keys)) #------------------------------------------------------------------------------------ # Needed for Martijn #------------------------------------------------------------------------------------ def unique_everseen(iterable, key=None): seen = set() seen_add = seen.add if key is None: for element in filterfalse(seen.__contains__, iterable): seen_add(element) yield element else: for element in iterable: k = key(element) if k not in seen: seen_add(k) yield element #------------------------------------------------------------------------------------ # Benchmark and quality check #------------------------------------------------------------------------------------ from timeit import timeit from collections import Counter def check(name, func): print() print(name) # Get 999999 keys and report the time. keys = None def getkeys(): nonlocal keys keys = func(999999) t = timeit(getkeys, number=1) print(t, 'seconds.') # Report statistics about lengths and characters def statistics(label, values): ctr = Counter(values) least = min(ctr, key=ctr.get) most = max(ctr, key=ctr.get) print(len(ctr), f'different {label}s from', min(ctr), 'to', max(ctr)) print(f' Least common {label}', least, 'appeared', ctr[least], 'times.') print(f' Most common {label}', most, 'appeared', ctr[most], 'times.') statistics('length', map(len, keys)) statistics('character', ''.join(keys)) statistics('first character', (k[0] for k in keys)) statistics('last character', (k[-1] for k in keys)) for _ in range(2): check('Stefan', produce_amount_keys_stefan) check('Martijn', produce_amount_keys_martijn) 

Advertencia: Esto no es criptográficamente seguro . Quiero dar un enfoque alternativo y numpy al de la gran respuesta de Martijn.

numpy funciones numpy no están realmente optimizadas para ser llamadas repetidamente en un bucle para tareas pequeñas; más bien, es mejor realizar cada operación a granel. Este enfoque proporciona más claves de las que necesita (de manera masiva, en este caso porque exageré demasiado la necesidad de sobreestimar) y, por lo tanto, es menos eficiente en cuanto a memoria, pero sigue siendo muy rápido.

  1. Sabemos que todas sus longitudes de cadena están entre 12 y 20. Simplemente genere todas las longitudes de cadena de una sola vez. Sabemos que el set final tiene la posibilidad de recortar la lista final de cadenas, por lo que deberíamos anticiparnos y hacer más “longitudes de cadena” de las que necesitamos. 20,000 extra es excesivo, pero es para hacer un punto:

    string_lengths = np.random.randint(12, 20, 60000)

  2. En lugar de crear todas nuestras secuencias en un bucle for , cree una lista 1D de caracteres que sea lo suficientemente larga para ser cortada en 40,000 listas. En el peor de los casos, todas las longitudes de cadena aleatorias en (1) fueron la longitud máxima de 20. Eso significa que necesitamos 800,000 caracteres.

    pool = list(string.ascii_letters + string.digits)

    random_letters = np.random.choice(pool, size=800000)

  3. Ahora solo necesitamos cortar esa lista de caracteres aleatorios. Usando np.cumsum() podemos obtener índices de inicio secuenciales para las sublistas, y np.roll() desplazará esa matriz de índices en 1, para dar una matriz correspondiente de índices finales.

    starts = string_lengths.cumsum()

    ends = np.roll(string_lengths.cumsum(), -1)

  4. Cortar la lista de caracteres aleatorios por los índices.

    final = [''.join(random_letters[starts[x]:ends[x]]) for x, _ in enumerate(starts)]

Poniendolo todo junto:

 def numpy_approach(): pool = list(string.ascii_letters + string.digits) string_lengths = np.random.randint(12, 20, 60000) ends = np.roll(string_lengths.cumsum(), -1) starts = string_lengths.cumsum() random_letters = np.random.choice(pool, size=800000) final = [''.join(random_letters[starts[x]:ends[x]]) for x, _ in enumerate(starts)] return final 

Y los resultados de timeit :

 322 ms ± 7.97 ms per loop (mean ± std. dev. of 7 runs, 1 loop each) 

Enfoque alternativo: singularidad en la creación en lugar de por prueba

El enfoque obvio para su pregunta sería generar una salida aleatoria y luego verificar si es única. Aunque no ofrezco una implementación, aquí hay un enfoque alternativo:

  1. Genera una salida que se vea lo más aleatoria posible
  2. Genera una salida que se garantiza que sea única, y se ve algo aleatoria
  3. Combínalos

Ahora tiene una salida que se garantiza que es única, y parece ser aleatoria.

Ejemplo

Supongamos que desea generar 999999 cadenas con longitudes de 12 y 20. El enfoque, por supuesto, funcionará para todos los conjuntos de caracteres, pero sea sencillo y supongamos que desea utilizar solo 0-9.

  1. Genera salidas aleatorias con longitudes de 6 a 14.
  2. Permuta aleatoriamente los números 000000 a 999999 (sí, 6 dígitos es bastante para ‘sacrificar’ en aparente aleatoriedad, pero con un conjunto de caracteres más grande no necesitará tantos caracteres)
  3. Ahora combínelos de tal manera que la singularidad debe ser preservada. La forma más trivial sería la simple concatenación de las entidades, pero, por supuesto, puede pensar en soluciones menos obvias.

Ejemplo a pequeña escala

  1. Generar aleatoriedad:

    sdfdsf xxer ver

  2. Generar singularidad

    xd ae bd

  3. Combinar

    xdsdfdsf aexxer bdver

Tenga en cuenta que este método supone que tiene un número mínimo de caracteres por entrada, lo que parece ser el caso en su pregunta.