TLDR: You can use a PRNG for a collision-free traversal of all numbers that can be converted to a bit-vector of a given length. Use this to select m numbers, then convert them to the target space as desired.
import random
from itertools import islice
def lcg_cycle(bits: int, seed=None):
"""Pseudo-randomly cycle through all unsigned integers of at most ``bits``"""
n = 2**bits
current = seed if seed is not None else random.randrange(0, n)
for _ in range(n): # run one cycle
current = (5 * current + 1) % n
yield current
# | n| |m|
print(*(f'{num:012b}' for num in islice(lcg_cycle(12), 4)))
# => 101111000001 101011000110 010111011111 110101011100
print(*(f'{num:012b}' for num in islice(lcg_cycle(12), 4)))
# => 111010111110 100110110111 000010010100 001011100101
print(*(f'{num:03b}' for num in lcg_cycle(2)))
# => 000 001 010 011
print(*(f'{num:03b}' for num in lcg_cycle(2)))
# => 011 000 001 010
Additional pseudo-randomness can be introduced by randomly shifting this fixed sequence, and using a different conversion from numbers to bits.
Since there are many cheap 1:1 mappings between "bit sequence" and "positive number", we can look at the problem as "pick m numbers out of the range [0,n)". A robust way to do that is to construct some traversal sequence of the entire range, and pick as many values as we need.
For example, for n=8 we could choose the traversal sequence seq = [0, 5, 6, 7, 2, 3, 1, 4]. Then pick m=3 numbers as seq[:3], seq[1:4], or similar (including wrap-around). It is pretty trivial to create additional perceived randomness, e.g. by adding an offset to the numbers, or by changing our number->bits mapping.
def twisted_bits(num, length: int) -> str:
"""Less obvious int -> bin conversion"""
bits = f"{num:0{length}b}" # original "unsigned int" bit pattern
return bits[1::2] + bits[0::2] # shuffle odd bits to front
def rand8b():
"""Produce a random sequence of the 8-bit pattern"""
seq = [0, 5, 6, 7, 2, 3, 1, 4] # chosen by fair roll of dice!
offset = random.randrange(0, len(seq))
for item in seq:
yield twisted_bits((item + offset) % len(seq), 4)
print(*rand8b()) # 0101 0010 0011 0100 0111 0000 0110 0001
print(*rand8b()) # 0001 0110 0111 0000 0011 0100 0010 0101
Now, that reduces our problem to "generate any traversal sequence". The bit-mangling and offset might be enough to use a range for small bit counts, but not for the larger ones that are problematic in the first place. So we want a similarly lazy sequence to traverse the range but in a seemingly random fashion.
Fittingly, that is what a Pseudo Random Number Generator with a full period does. One such generator class that is simple to implement for arbitrary n=2**N (i.e. length of bit vectors) are the linear congruential generators. This is for example part of the CPython dict collision resolution strategy.
def lcg_cycle(bits: int, seed=None):
n = 2**bits
current = seed if seed is not None else random.randrange(0, n)
for _ in range(n):
current = (5 * current + 1) % n
yield current
# always generates all values from a random starting point
print(set(lcg_cycle(3))) # {0, 1, 2, 3, 4, 5, 6, 7}
print(set(lcg_cycle(3))) # {0, 1, 2, 3, 4, 5, 6, 7}
One can play with the constants, but 5, 1 should appear random enough.
Depending on how much apparent randomness is needed, the LCG might be enough on its own; for few bits, its cycle may be visible. As before, it can be combined with a random offset and non-standard int->bin mapping to obscure its traversal further.
from itertools import islice
def rand_bits(bits: int, count=None):
"""Produce a pseudo-random sequence of fixed-size bit patterns"""
n = 2 ** bits
offset = random.randrange(0, n)
for item in islice(lcg_cycle(bits), count or n):
yield twisted_bits((item + offset) % n, bits)
print(*rand_bits(32, 4))
# => 11101010011110010000111100100100 11011100110100110001011011101101 10011000100101010011110111011010 01000011011000000000000001111011
print(*rand_bits(32, 4))
# => 11110000110011011111000001101111 00101100111111000001110011111000 01011001111000101111101110100101 00111010011001010101010100000110
print(*rand_bits(32, 4))
# => 00001101101011111010010110001101 11010111010100110110110111100010 11000111100001100101011110001011 01111000100001001110011111011000