Here's a faster solution using a dictionary of tuples for the 256 possible characters:
bits = [1, 1, 0, 0, 0, 0, 1, 0, 1, 1, 1, 1, 0, 1, 1, 0]
chars = { tuple(map(int,f"{n:08b}"[::-1])):chr(n) for n in range(0,256) }
def toChars(bits):
return "".join(chars[tuple(bits[i:i+8])] for i in range(0,len(bits),8) )
roughly 3x faster than original solution
[EDIT] and an even faster one using bytes and zip:
chars = { tuple(map(int,f"{n:08b}")):n for n in range(256) }
def toChars(bits):
return bytes(chars[b] for b in zip(*(bits[7-i::8] for i in range(8)))).decode()
about 2x faster than the previous one (on long lists)
[EDIT2] a bit of explanations for this last one ...
b in the list comprehension will be a tuple of 8 bits
chars[b] will return an integer corresponding to the 8 bits
bytes(...).decode() converts the list of integers to a string based on the chr(n) of each value
zip(*(... 8 bit iterators...)) unpacks the 8 striding ranges of bits running in parallel, each from a different starting point
The strategy with the unpacked zip is to go through the bits in steps of 8. For example, if we were going through 8 parallel ranges, we would get this:
bits[7::8] -> [ 0, 0, ... ] zip returns: (0,1,0,0,0,1,1)
bits[6::8] -> [ 1, 1, ... ] (0,1,1,0,1,1,1)
bits[5::8] -> [ 0, 1, ... ] ...
bits[4::8] -> [ 0, 0, ... ]
bits[3::8] -> [ 0, 1, ... ]
bits[2::8] -> [ 0, 1, ... ]
bits[1::8] -> [ 1, 1, ... ]
bits[0::8] -> [ 1, 1, ... ]
The zip function will take one column of this per iteration and return it as a tuple of bits.