You could use bitwise arithmetic (for positive and negative values, but excluding 0, for which the behavior is not defined as there is no 1 to trail):
(x & -x).bit_length() - 1
or:
(x ^ -x).bit_lenght() - 2
or, possibly with a more interesting 0 behavior (thanks to @TomKarzes comment):
(x ^ (x - 1)).bit_length() - 1
To understand how they work, let us consider this last expression. When we do x - 1, if x is odd, only that bit is flipped and then when we do the xor ^ that is the only bit that survives the operation. Similarly, when x is not odd, every trailing zero gets flipped to 1 and the trailed1 gets flipped to 0, all the other bits are untouched, and then we do the xor all the bits up to the trailed 1 gets to be set. Then, int.bit_length() counts how many significant bits are found, and the -1 is just a constant offset we need to have to get "number of trailing zeros".
Why the first methods also work is due to the two-complement representation of negative numbers. The exact details are left as an exercise to the reader.
To show that they work:
for i in range(-15, 16):
print(
f'{i:5b}',
f'{i ^ -i:3d}',
f'{(i & -i).bit_length() - 1:2d}',
f'{(i ^ -i).bit_length() - 2:2d}',
f'{(i ^ (i - 1)).bit_length() - 1:2d}')
-1111 -2 0 0 0
-1110 -4 1 1 1
-1101 -2 0 0 0
-1100 -8 2 2 2
-1011 -2 0 0 0
-1010 -4 1 1 1
-1001 -2 0 0 0
-1000 -16 3 3 3
-111 -2 0 0 0
-110 -4 1 1 1
-101 -2 0 0 0
-100 -8 2 2 2
-11 -2 0 0 0
-10 -4 1 1 1
-1 -2 0 0 0
0 0 -1 -2 0
1 -2 0 0 0
10 -4 1 1 1
11 -2 0 0 0
100 -8 2 2 2
101 -2 0 0 0
110 -4 1 1 1
111 -2 0 0 0
1000 -16 3 3 3
1001 -2 0 0 0
1010 -4 1 1 1
1011 -2 0 0 0
1100 -8 2 2 2
1101 -2 0 0 0
1110 -4 1 1 1
1111 -2 0 0 0
Note the different behavior of the two expressions for 0 input.
Particularly, (x ^ (x - 1)).bit_length() - 1 may lead to simpler expressions if you want 0 input to produce 0 output.
Speed-wise this should be brutally faster than any loop-based solution, and on-par with look-up based solutions (but without size limitation and without using additional memory):
def trail_zero_loop(x):
if x != 0:
k = -1
r = 0
while not r:
# x, r = divmod(x, 2) # ~15% slower
r = x % 2
x = x // 2
k += 1
return k
else:
return -1
def trail_zero_and(x):
return (x & -x).bit_length() - 1
def trail_zero_xor(x):
if x != 0:
# return (x ^ -x).bit_length() - 2 # ~1% slower
return (x ^ (x - 1)).bit_length() - 1
else:
return -1
_LOOKUP =[ 32, 0, 1, 26, 2, 23, 27, 0, 3, 16, 24, 30, 28, 11, 0, 13, 4, 7, 17, 0, 25, 22, 31, 15, 29, 10, 12, 6, 0, 21, 14, 9, 5, 20, 8, 19, 18 ]
def trail_zero_lookup(x):
if x != 0:
return _LOOKUP[(x & -x) % 37]
else:
return -1
n = 2 ** 30
%timeit trail_zero_loop(n)
# 3.15 µs ± 25.4 ns per loop (mean ± std. dev. of 7 runs, 100000 loops each)
%timeit trail_zero_and(n)
# 228 ns ± 9.87 ns per loop (mean ± std. dev. of 7 runs, 1000000 loops each)
%timeit trail_zero_xor(n)
# 253 ns ± 7.25 ns per loop (mean ± std. dev. of 7 runs, 1000000 loops each)
%timeit trail_zero_lookup(n)
# 294 ns ± 1.81 ns per loop (mean ± std. dev. of 7 runs, 1000000 loops each)
The eventual additional bit-shift operation takes only some nanoseconds so it should be irrelevant here.