Sliding-window exponentiation in Python
Exponentiation that precomputes odd powers and processes the exponent in windows to reduce multiplications.
- File
sliding_window_exponentiation.py, 173 lines- Level
- I0 not checked yet
- Toolchain
- Python 3.14, pytest 9
- Test framework
- pytest
- Run the tests
python3 -m pytest
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/sliding-window-exponentiation/. The authors are listed in prov/YXZZ39.jsonl.
"""Left-to-right sliding-window exponentiation in any monoid.
power(x, e, k, mul) computes x^e with the odd powers x, x^3, ..., x^(2^k - 1)
precomputed, and the exponent read from the top in windows of at most k binary
digits that start and end with a 1. Squarings of the identity are skipped, so
the counts are those of the addition chain that the method builds.
For integers the products go through limb arrays (radix 2^w, schoolbook), so
the tests can check limb code against Python's int.
"""
from typing import Callable, TypeVar
T = TypeVar("T")
class Counter:
def __init__(self) -> None:
self.squarings = 0
self.products = 0
@property
def total(self) -> int:
return self.squarings + self.products
def windows(e: int, k: int) -> list[tuple[int, int]]:
"""The decomposition of e >= 1, from the top, as (value, length) pairs.
A pair with value 0 and length 1 is a single zero digit; any other pair is
a window of `length` <= k digits whose first and last digits are 1.
"""
if e < 1 or k < 1:
raise ValueError("need e >= 1 and k >= 1")
out = []
i = e.bit_length() - 1
while i >= 0:
if (e >> i) & 1 == 0:
out.append((0, 1))
i -= 1
continue
j = max(i - k + 1, 0)
while (e >> j) & 1 == 0: # the window must end with a 1
j += 1
out.append(((e >> j) & ((1 << (i - j + 1)) - 1), i - j + 1))
i = j - 1
return out
def power(x: T, e: int, k: int, mul: Callable[[T, T], T], one: T, cnt: Counter | None = None) -> T:
"""x^e for e >= 0 in the monoid given by mul and one, with window width k."""
if k < 1:
raise ValueError("k must be at least 1")
if e == 0:
return one
cnt = cnt if cnt is not None else Counter()
table = {1: x} # @line L1: the odd powers x, x^3, ..., x^(2^k - 1)
if k > 1 and e > 1:
x2 = mul(x, x)
cnt.squarings += 1
top = min((1 << k) - 1, e)
for u in range(3, top + 1, 2):
table[u] = mul(table[u - 2], x2)
cnt.products += 1
y = None # @line L2: y = 1, kept symbolic so that 1 * 1 is not counted
for u, length in windows(e, k): # @line L3: the windows from the top
for _ in range(length): # @line L4: shift: y <- y^(2^length)
if y is not None:
y = mul(y, y)
cnt.squarings += 1
if u: # @line L5: add the window: y <- y * x^u
if y is None:
y = table[u]
else:
y = mul(y, table[u])
cnt.products += 1
return y # @line L6
def fixed_window_power(x: T, e: int, k: int, mul: Callable[[T, T], T], one: T, cnt: Counter | None = None) -> T:
"""The 2^k-ary method: all powers x^0 .. x^(2^k - 1), windows at fixed positions."""
if e == 0:
return one
cnt = cnt if cnt is not None else Counter()
table = [one, x]
for u in range(2, min(1 << k, e + 1)):
table.append(mul(table[-1], x))
if u == 2:
cnt.squarings += 1
else:
cnt.products += 1
digits = []
while e:
digits.append(e & ((1 << k) - 1))
e >>= k
y = None
for d in reversed(digits):
if y is not None:
for _ in range(k):
y = mul(y, y)
cnt.squarings += 1
if d:
if y is None:
y = table[d]
else:
y = mul(y, table[d])
cnt.products += 1
elif y is None:
raise AssertionError("the top digit is nonzero")
return y
def best_width(t: int) -> int:
"""The k minimizing the expected count t - 1 + t / (k + 1) + 2^(k - 1)."""
return min(range(1, 16), key=lambda k: t / (k + 1) + 2 ** (k - 1))
# Integers as limb arrays in radix 2^w.
def to_limbs(x: int, w: int) -> list[int]:
out = []
while x:
out.append(x & ((1 << w) - 1))
x >>= w
return out
def from_limbs(d: list[int], w: int) -> int:
v = 0
for x in reversed(d):
v = (v << w) | x
return v
def limb_mul(a: list[int], b: list[int], w: int) -> list[int]:
"""Schoolbook product of limb arrays, without leading zero limbs."""
if not a or not b:
return []
mask = (1 << w) - 1
c = [0] * (len(a) + len(b))
for i, x in enumerate(a):
carry = 0
for j, y in enumerate(b):
s = c[i + j] + x * y + carry
c[i + j] = s & mask
carry = s >> w
c[i + len(b)] = carry
while c and c[-1] == 0:
c.pop()
return c
def power_limbs(a: list[int], e: int, k: int, w: int = 8, cnt: Counter | None = None) -> list[int]:
"""a^e for a limb array a in radix 2^w."""
return power(a, e, k, lambda p, q: limb_mul(p, q, w), [1], cnt)
def modpow_limbs(a: list[int], e: int, m: list[int], k: int, w: int = 8, cnt: Counter | None = None) -> list[int]:
"""a^e mod m: limb products, each reduced modulo m (the reduction uses Python's %)."""
M = from_limbs(m, w)
if M < 1:
raise ZeroDivisionError("modulus must be positive")
def mulmod(p: list[int], q: list[int]) -> list[int]:
return to_limbs(from_limbs(limb_mul(p, q, w), w) % M, w)
one = to_limbs(1 % M, w)
x = to_limbs(from_limbs(a, w) % M, w)
return power(x, e, k, mulmod, one, cnt)
test_sliding_window_exponentiation.py 164 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/sliding-window-exponentiation/. The authors are listed in prov/YXZZ39.jsonl.
import random
import pytest
from sliding_window_exponentiation import (
Counter,
best_width,
fixed_window_power,
from_limbs,
limb_mul,
modpow_limbs,
power,
power_limbs,
to_limbs,
windows,
)
def imul(a, b):
return a * b
def test_windows_reassemble_and_shape():
rng = random.Random(1)
for _ in range(2000):
e = rng.getrandbits(rng.randint(1, 200)) or 1
k = rng.randint(1, 8)
acc = 0
ws = windows(e, k)
for u, length in ws:
acc = (acc << length) | u
if u:
assert u & 1 and 1 <= length <= k and u >> (length - 1) == 1
else:
assert length == 1
assert acc == e
t = e.bit_length()
nwin = sum(1 for u, _ in ws if u)
assert nwin <= -(-t // k) # proved bound on the number of windows
def test_power_integers_all_small():
for k in range(1, 7):
for x in (0, 1, 2, 3, -5):
for e in range(0, 300):
assert power(x, e, k, imul, 1) == x**e
def test_counts_bound_and_exactness():
rng = random.Random(2)
for _ in range(1500):
t = rng.randint(1, 300)
e = rng.getrandbits(t) | (1 << (t - 1))
k = rng.randint(1, 8)
c = Counter()
assert power(7, e, k, lambda a, b: a * b % 1000003, 1, c) == pow(7, e, 1000003)
ws = windows(e, k)
nwin = sum(1 for u, _ in ws if u)
table = 0 if (k == 1 or e == 1) else (min((1 << k) - 1, e) + 1) // 2
assert c.total == table + (t - ws[0][1]) + (nwin - 1)
assert c.total <= t - 2 + -(-t // k) + 2 ** (k - 1)
for t in (1, 2, 10, 64): # all ones is the worst case of the binary method
c = Counter()
assert power(3, (1 << t) - 1, 1, lambda a, b: a * b % 1000003, 1, c) == pow(3, (1 << t) - 1, 1000003)
assert c.total == 2 * t - 2
def test_worked_example():
e = 2989
assert bin(e) == "0b101110101101"
assert windows(e, 3) == [(5, 3), (3, 2), (0, 1), (5, 3), (5, 3)]
exps, y = [], 0
for u, length in windows(e, 3):
y = (y << length) + u
exps.append(y)
assert exps == [5, 23, 46, 373, 2989]
c = Counter()
assert power(3, e, 3, imul, 1, c) == 3**e
assert (c.squarings, c.products) == (10, 6)
b = Counter()
power(3, e, 1, imul, 1, b)
assert (b.squarings, b.products) == (11, 7)
f = Counter()
assert fixed_window_power(3, e, 3, imul, 1, f) == 3**e
assert f.total == 18
assert windows(13, 3) == [(3, 2), (0, 1), (1, 1)]
def test_average_counts_and_best_width():
rng = random.Random(1)
t = 1024
es = [rng.getrandbits(t) | (1 << (t - 1)) for _ in range(60)]
means = {}
for k in range(1, 8):
tot = nwin = 0
for e in es:
c = Counter()
power(1, e, k, lambda a, b: a, 1, c)
tot += c.total
nwin += sum(1 for u, _ in windows(e, k) if u)
means[k] = tot / len(es)
assert abs(nwin / len(es) - t / (k + 1)) < 0.03 * t / (k + 1)
assert abs(means[k] - (t - 1 + t / (k + 1) + 2 ** (k - 1))) < 15
assert min(means, key=means.get) == 6
fixed = []
for k in (5, 6):
tot = 0
for e in es:
c = Counter()
fixed_window_power(1, e, k, lambda a, b: a, 1, c)
tot += c.total
fixed.append(tot / len(es))
assert min(fixed) > means[6] + 30
assert [best_width(t) for t in (64, 256, 512, 1024, 2048, 4096)] == [3, 5, 5, 6, 7, 7]
@pytest.mark.parametrize("w", [4, 8])
def test_limbs_against_python(w):
rng = random.Random(10 + w)
for _ in range(100):
a = rng.getrandbits(rng.randint(0, 32))
e = rng.randint(0, 60)
k = rng.randint(1, 6)
assert from_limbs(power_limbs(to_limbs(a, w), e, k, w), w) == a**e
for _ in range(120):
m = rng.getrandbits(rng.randint(1, 96)) or 1
a = rng.getrandbits(rng.randint(0, 120))
e = rng.getrandbits(rng.randint(0, 200))
k = rng.randint(1, 7)
r = modpow_limbs(to_limbs(a, w), e, to_limbs(m, w), k, w)
assert from_limbs(r, w) == pow(a, e, m)
assert all(0 <= d < (1 << w) for d in r)
edge = [(0, 0, 7), (0, 5, 7), (5, 0, 1), (2, 100, 1), ((1 << 64) - 1, (1 << 64) - 1, (1 << 61) - 1)]
for a, e, m in edge:
assert from_limbs(modpow_limbs(to_limbs(a, w), e, to_limbs(m, w), 4, w), w) == pow(a, e, m)
assert limb_mul([], [1], w) == []
def test_matrix_monoid():
def mm(p, q):
return (
(p[0][0] * q[0][0] + p[0][1] * q[1][0], p[0][0] * q[0][1] + p[0][1] * q[1][1]),
(p[1][0] * q[0][0] + p[1][1] * q[1][0], p[1][0] * q[0][1] + p[1][1] * q[1][1]),
)
one = ((1, 0), (0, 1))
fib = [0, 1]
for _ in range(400):
fib.append(fib[-1] + fib[-2])
for e in range(0, 400, 7):
for k in (1, 3, 5):
assert power(((1, 1), (1, 0)), e, k, mm, one)[0][1] == fib[e]
def test_errors():
with pytest.raises(ValueError):
power(2, 5, 0, imul, 1)
with pytest.raises(ValueError):
windows(0, 3)
with pytest.raises(ZeroDivisionError):
modpow_limbs([2], 3, [], 2, 8)
What the next level needs
- the tests did not run in this build