Building a CAS in OCaml Part 4
2026-04-27 · 25 min
Part 3 can expand a product and take a GCD. It cannot undo either. This part adds the operation that runs expansion backwards, which is the hardest classical algorithm in a CAS, and then the field it makes possible: factorization over Z by way of a finite field, Hensel lifting and subset recombination, and rational functions with partial fractions on top.
The route
Factoring modulo a prime is easy and factoring over Z is not, so the whole algorithm is an elaborate way of borrowing the easy case.
f over Z| square-free split Yun, from Part 3| make monic change of variablevf mod p pick p keeping degree and squarefreeness| square-free mod p Musser, because x^p has zero derivative| distinct-degree gcd with x^(p^d) - x| equal-degree Cantor-Zassenhaus, randomizedvu_1 * ... * u_r mod p| Hensel lift p -> p^k, k from a coefficient boundvu_1 * ... * u_r mod p^k| recombine which subsets divide f over Zvirreducible factors over Z
Two places hide the difficulty. The lift needs a bound that is a real bound, or the answer is only congruent to the truth. And the factorization mod p can be finer than the one over Z, so the last step is a search, and in the worst case an exponential one.
F_p gets its own module
Part 3 kept a small Pmod inside Upoly, enough for one GCD. Factorization needs division with remainder, modular exponentiation, the Frobenius map and Bezout coefficients, so it moves out.
module Zp = structlet norm p a =let r = a mod p inif r < 0 then r + p else rlet add p a b =let s = a + b inif s >= p then s - p else slet sub p a b =let s = a - b inif s < 0 then s + p else slet mul p a b = a * b mod plet neg p a = if a = 0 then 0 else p - alet pow p a n =let rec go acc b n =if n = 0 then acc else if n land 1 = 1 then go (mul p acc b) (mul p b b) (n lsr 1)else go acc (mul p b b) (n lsr 1)ingo 1 (norm p a) n(* Extended Euclid, iterative, tracking only the coefficient needed. *)let inv p a =let a = norm p a inif a = 0 then invalid_arg "Fp.Zp.inv: zero has no inverse";let r0 = ref p and r1 = ref a inlet t0 = ref 0 and t1 = ref 1 inwhile !r1 <> 0 dolet q = !r0 / !r1 inlet r = !r0 - (q * !r1) inr0 := !r1;r1 := r;let t = !t0 - (q * !t1) int0 := !t1;t1 := tdone;norm p !t0end
Over a field there is no pseudo-division: the leading coefficient is always invertible, so the division is the one from school.
(* Division with remainder. F_p is a field, so unlike Z there is nopseudo-division here: the leading coefficient is always invertible. *)let divmod p (a : t) (b : t) : t * t =if is_zero b then raise Division_by_zero;let db = degree b and ib = Zp.inv p (lc b) inif degree a < db then (zero, a)else beginlet q = Array.make (degree a - db + 1) 0 inlet r = ref a inwhile (not (is_zero !r)) && degree !r >= db dolet d = degree !r - db inlet c = Zp.mul p (lc !r) ib inq.(d) <- c;r := sub p !r (shift (scale p b c) d)done;(normalize q, !r)end
Extended Euclid, because Hensel lifting needs the cofactors and not just the GCD.
(* Extended Euclid: returns (g, s, t) with s*a + t*b = g, g monic.Hensel lifting needs the cofactors, not just the GCD. *)let ext_gcd p (a : t) (b : t) : t * t * t =let r0 = ref a and r1 = ref b inlet s0 = ref one and s1 = ref zero inlet t0 = ref zero and t1 = ref one inwhile not (is_zero !r1) dolet q, r = divmod p !r0 !r1 inr0 := !r1;r1 := r;let s = sub p !s0 (mul p q !s1) ins0 := !s1;s1 := s;let t = sub p !t0 (mul p q !t1) int0 := !t1;t1 := tdone;let c = Zp.inv p (lc !r0) in(scale p !r0 c, scale p !s0 c, scale p !t0 c)
Exponentiation in the quotient ring, twice: once with an int exponent, and once with a bignum, because equal-degree factorization needs a to the power (p^d - 1)/2 and that overflows immediately.
(* a^n mod m, by repeated squaring in the quotient ring F_p[x]/(m). *)let pow_mod p (a : t) (n : int) (m : t) : t =let rec go acc b n =if n = 0 then accelse if n land 1 = 1 then go (rem p (mul p acc b) m) (rem p (mul p b b) m) (n lsr 1)else go acc (rem p (mul p b b) m) (n lsr 1)ingo one (rem p a m) n(* The same with an exponent too large for an int: equal-degreefactorization needs a^((p^d - 1)/2), which overflows immediately. *)let pow_mod_big p (a : t) (n : Bigint.t) (m : t) : t =let two = Bigint.of_int 2 inlet acc = ref one and b = ref (rem p a m) and e = ref n inwhile not (Bigint.is_zero !e) dolet q, r = Bigint.divmod !e two inif not (Bigint.is_zero r) then acc := rem p (mul p !acc !b) m;b := rem p (mul p !b !b) m;e := qdone;!acc
Square-free, in characteristic p
Part 3 argued that a polynomial and its derivative share exactly the repeated factors. In characteristic p that argument has a hole: the derivative of x^p is zero, so a p-th power is invisible to it. Yun becomes Musser, with a branch that takes a p-th root and recurses.
Over a prime field the root is free, because a^p = a for every coefficient, so only the exponents move.
let pth_root p (a : t) : t =let n = degree a innormalize (Array.init ((n / p) + 1) (fun i -> a.(i * p)))let rec square_free p (f : t) : (t * int) list =if degree f <= 0 then []else beginlet f = monic p f inlet fd = diff p f inif is_zero fd then(* f is a p-th power: f(x) = g(x)^p, so every multiplicity in gis multiplied by p. *)List.map (fun (g, m) -> (g, m * p)) (square_free p (pth_root p f))else beginlet c = ref (gcd p f fd) inlet w = ref (div p f !c) inlet out = ref [] and i = ref 1 inwhile degree !w > 0 dolet y = gcd p !w !c inlet z = div p !w y inif degree z > 0 then out := (z, !i) :: !out;w := y;c := div p !c y;incr idone;(* Whatever is left in c is a p-th power. *)let tail =if degree !c > 0 thenList.map (fun (g, m) -> (g, m * p)) (square_free p (pth_root p !c))else []inList.rev_append !out tailendend
The case that makes the branch necessary, from the tests. Modulo 13 the polynomial (x+1)^13 is x^13 + 1, and its derivative is identically zero.
(* The characteristic-p case that Yun cannot see: (x+1)^13 has a zeroderivative, so the algorithm has to take a p-th root instead. *)let h = Fp.of_list p (List.init 14 (fun i -> if i = 0 || i = 13 then 1 else 0)) incheck_str "(x+1)^13 is x^13 + 1 mod 13" "x^13 + 1" (s h);check "derivative vanishes" (Fp.is_zero (Fp.diff p h));match Fp.square_free p h with| [ (g, 13) ] -> check "p-th power decomposes correctly" (Fp.equal g (f [ 1; 1 ]))| l ->incr checks;incr failures;Printf.printf "FAIL: p-th power gave %d pieces\n" (List.length l)
Distinct-degree factorization
x^(p^d) - x is the product of every monic irreducible whose degree divides d. A GCD with it therefore peels off exactly the factors of degree d, once the smaller ones are gone. The algorithm is that identity and the Frobenius map, nothing else.
The loop stops early: anything left whose degree exceeds half of what remained cannot be a product of two pieces, so it is already irreducible.
let distinct_degree p (f : t) : (t * int) list =let out = ref [] inlet fstar = ref f inlet xp = ref x inlet d = ref 1 inwhile degree !fstar >= 2 * !d doxp := pow_mod p !xp p !fstar;let g = gcd p !fstar (sub p !xp x) inif degree g > 0 then beginout := (g, !d) :: !out;fstar := div p !fstar g;xp := rem p !xp !fstarend;incr ddone;(* Anything left is irreducible: its degree exceeds half the degreeof what remained, so it cannot be a product of two pieces. *)if degree !fstar > 0 then out := (!fstar, degree !fstar) :: !out;List.rev !out
Equal-degree factorization
Splitting a product of r irreducibles that all have the same degree needs randomness. For odd p, half the nonzero elements of each factor field are squares, so a random a raised to (p^d - 1)/2 lands on 1 in some factors and -1 in others, and a GCD separates the two groups. Each attempt works about half the time.
let rec equal_degree p (f : t) (d : int) : t list =if degree f = 0 then []else if degree f = d then [ monic p f ]else beginlet n = degree f inlet e =(* (p^d - 1) / 2, which needs a bignum for any interesting d. *)Bigint.div (Bigint.sub (Bigint.pow (Bigint.of_int p) d) Bigint.one) (Bigint.of_int 2)inlet result = ref None inwhile !result = None dolet a = random p (n - 1) inif degree a > 0 then beginlet g = gcd p a f inlet split =if degree g > 0 && degree g < n then Some gelse beginlet b = pow_mod_big p a e f inlet g = gcd p (sub p b one) f inif degree g > 0 && degree g < n then Some g else Noneendinmatch split with| Some g -> result := Some (equal_degree p g d @ equal_degree p (div p f g) d)| None -> ()enddone;match !result with Some l -> l | None -> assert falseend
Three passes, each making the next one easier.
let factor p (f : t) : int * (t * int) list =if is_zero f then invalid_arg "Fp.factor: zero";let c = lc f inlet f = monic p f inlet out = ref [] inList.iter(fun (g, m) ->List.iter(fun (h, d) -> List.iter (fun q -> out := (q, m) :: !out) (equal_degree p h d))(distinct_degree p g))(square_free p f);(c, List.sort (fun (a, _) (b, _) -> compare a b) !out)let is_irreducible p (f : t) : bool =degree f > 0 && (match factor p f with _, [ (_, 1) ] -> true | _ -> false)
Berlekamp, as an independent check
The map is linear on , and the dimension of its kernel is exactly the number of irreducible factors of f. That number is worth having for its own sake: it is a check on distinct-degree plus equal-degree that shares none of their code.
Build the matrix of the map, subtract the identity, and count the nullity.
let berlekamp_matrix p (f : t) : int array array =let n = degree f inlet q = Array.make_matrix n n 0 infor i = 0 to n - 1 dolet row = pow_mod p (shift one i) p f infor j = 0 to n - 1 doq.(j).(i) <- coeff row jdone;(* Subtract the identity: the matrix of v -> v^p - v. *)q.(i).(i) <- Zp.sub p q.(i).(i) 1done;q
Gaussian elimination over F_p. One basis vector per free column.
(* Gaussian elimination over F_p, returning a basis of the nullspace. *)let nullspace p (m : int array array) : int array list =let rows = Array.length m inif rows = 0 then []else beginlet cols = Array.length m.(0) inlet a = Array.map Array.copy m inlet pivot_of_col = Array.make cols (-1) inlet r = ref 0 infor c = 0 to cols - 1 doif !r < rows then begin(* Find a row with a nonzero entry in this column. *)let piv = ref (-1) infor i = rows - 1 downto !r doif a.(i).(c) <> 0 then piv := idone;if !piv >= 0 then beginlet tmp = a.(!r) ina.(!r) <- a.(!piv);a.(!piv) <- tmp;let inv = Zp.inv p a.(!r).(c) infor j = 0 to cols - 1 doa.(!r).(j) <- Zp.mul p a.(!r).(j) invdone;for i = 0 to rows - 1 doif i <> !r && a.(i).(c) <> 0 then beginlet factor = a.(i).(c) infor j = 0 to cols - 1 doa.(i).(j) <- Zp.sub p a.(i).(j) (Zp.mul p factor a.(!r).(j))doneenddone;pivot_of_col.(c) <- !r;incr rendenddone;(* One basis vector per free column. *)let basis = ref [] infor c = cols - 1 downto 0 doif pivot_of_col.(c) < 0 then beginlet v = Array.make cols 0 inv.(c) <- 1;for c2 = 0 to cols - 1 doif pivot_of_col.(c2) >= 0 then v.(c2) <- Zp.neg p a.(pivot_of_col.(c2)).(c)done;basis := v :: !basisenddone;!basisend
And the cross-check, run on random polynomials over five different primes.
(* Berlekamp shares none of the code of distinct-degree plusequal-degree, so its factor count is an independent check on both. *)let test_berlekamp_agrees () =Random.init 20260427;let primes = [ 5; 7; 11; 13; 17 ] inList.iter(fun q ->for _ = 1 to 40 dolet d = 2 + Random.int 5 inlet g = Fp.monic q (Fp.random q d) inif Fp.degree g = d && Fp.degree (Fp.gcd q g (Fp.diff q g)) = 0 then beginlet _, fs = Fp.factor q g incheck "berlekamp nullity equals the factor count"(Fp.berlekamp_count q g = List.length fs);check "the factors multiply back"(Fp.equal (List.fold_left (fun acc (h, _) -> Fp.mul q acc h) Fp.one fs) g);check "every factor is irreducible"(List.for_all (fun (h, _) -> Fp.is_irreducible q h) fs)enddone)primes
How far to lift
Every factor of f has coefficients bounded by 2^n times the 2-norm of f. Generous, cheap, and an actual bound rather than an estimate, which is what the correctness of the whole algorithm rests on.
(* Every factor of f has coefficients bounded by 2^n times the2-norm of f, and the 2-norm is at most (n+1) times the max norm.Generous, and cheap to compute, and what matters is that it is anactual bound and not an estimate. *)let coefficient_bound (f : Upoly.t) : Bigint.t =let n = Upoly.degree f inBigint.mul (Bigint.pow (Bigint.of_int 2) n)(Bigint.mul (Bigint.of_int (n + 1)) (Upoly.max_norm f))(* Smallest k with p^k > 2*bound, so that a symmetric representativemodulo p^k is the integer itself rather than a congruent stand-in. *)let lift_exponent (p : int) (bound : Bigint.t) : int =let target = Bigint.mul (Bigint.of_int 2) bound inlet k = ref 1 and m = ref (Bigint.of_int p) inwhile Bigint.compare !m target <= 0 dom := Bigint.mul !m (Bigint.of_int p);incr kdone;!k
Keeping the symmetric representative throughout means the lifted factors are already the integer factors once the modulus is large enough, with no final adjustment.
(* Coefficients reduced into (-m/2, m/2]. Keeping the symmetricrepresentative throughout means the lifted factors are already theinteger factors once the modulus is large enough, with no finaladjustment. *)let center (a : Upoly.t) (m : Bigint.t) : Upoly.t =let half = Bigint.div m (Bigint.of_int 2) inUpoly.normalize(Array.map(fun c ->let r = Bigint.rem c m inlet r = if Bigint.sign r < 0 then Bigint.add r m else r inif Bigint.compare r half > 0 then Bigint.sub r m else r)a)
Asserted, not assumed, in the tests.
let test_bound () =(* The bound has to be a bound, not an estimate: every coefficient ofevery factor must fit inside it. *)let f = p [ -6; 11; -6; 1 ] inlet b = Factor.coefficient_bound f inlet _, fs = Factor.factor f incheck "every factor fits inside the bound"(List.for_all (fun (g, _) -> Bigint.compare (Upoly.max_norm g) b <= 0) fs);let k = Factor.lift_exponent 5 b incheck "the lift exponent clears twice the bound"(Bigint.compare (Bigint.pow (Bigint.of_int 5) k) (Bigint.mul (Bigint.of_int 2) b) > 0);check "and is minimal"(Bigint.compare (Bigint.pow (Bigint.of_int 5) (k - 1)) (Bigint.mul (Bigint.of_int 2) b) <= 0)
Hensel lifting
The step is exact linear algebra, not a search. Write the error as . A correction , changes the product by modulo , so u and v have to satisfy . The Bezout relation solves that in one line.
One step of the lift.
let hensel_step (p : int) (m : Bigint.t) (f : Upoly.t) (g : Upoly.t) (h : Upoly.t)(g1 : Fp.t) (h1 : Fp.t) (s : Fp.t) (t : Fp.t) : Upoly.t * Upoly.t =let err = Upoly.sub f (Upoly.mul g h) inlet c = Upoly.divexact_int err m inlet cp = Upoly.to_fp c p inlet u0 = Fp.rem p (Fp.mul p t cp) g1 inlet q = Fp.div p (Fp.mul p t cp) g1 inlet v0 = Fp.add p (Fp.mul p s cp) (Fp.mul p q h1) inlet u = Upoly.of_fp u0 p and v = Upoly.of_fp v0 p in(Upoly.add g (Upoly.scale u m), Upoly.add h (Upoly.scale v m))
Iterated to p^k, and then extended to any number of factors by splitting one off at a time.
(* Lift a two-factor split all the way to p^k. *)let hensel_lift2 (p : int) (k : int) (f : Upoly.t) (g1 : Fp.t) (h1 : Fp.t) : Upoly.t * Upoly.t =let d, s, t = Fp.ext_gcd p g1 h1 inif not (Fp.is_one d) then invalid_arg "hensel_lift2: factors are not coprime mod p";let g = ref (Upoly.of_fp g1 p) and h = ref (Upoly.of_fp h1 p) inlet m = ref (Bigint.of_int p) infor _ = 1 to k - 1 dolet g', h' = hensel_step p !m f !g !h g1 h1 s t inm := Bigint.mul !m (Bigint.of_int p);g := center g' !m;h := center h' !mdone;(!g, !h)(* Lift a factorization into any number of pieces, by splitting off oneat a time. f must be monic, and every factor monic mod p. *)let rec hensel_lift_many (p : int) (k : int) (f : Upoly.t) (factors : Fp.t list) : Upoly.t list =match factors with| [] -> []| [ _ ] -> [ f ]| g1 :: rest ->let h1 = List.fold_left (fun acc q -> Fp.mul p acc q) Fp.one rest inlet g, h = hensel_lift2 p k f g1 h1 ing :: hensel_lift_many p k h rest
The prime has to leave the degree alone and leave f square-free. The second condition is the one that matters: a prime dividing the discriminant merges two distinct factors, and the lift would then be lifting the wrong factorization.
(* A usable prime has to leave the degree alone and leave the inputsquare-free. The second condition is the one that matters: a primedividing the discriminant merges two distinct factors into one andthe lift would then be lifting the wrong factorization. *)let choose_prime (f : Upoly.t) : int =(* No prime can work if f is not square-free over Z to begin with:the gcd condition below would never hold, and the search would runforever. Refuse rather than spin. *)if Upoly.degree (Upoly.gcd_modular f (Upoly.diff f)) > 0 then raise Not_square_free;let lead = Upoly.lc f inlet p = ref 3 and found = ref 0 inwhile !found = 0 dop := Fp.next_prime (!p + 1);let pi = !p inif Upoly.bigint_mod_int lead pi <> 0 then beginlet fp = Upoly.to_fp f pi inif Fp.degree fp = Upoly.degree f && Fp.degree (Fp.gcd pi fp (Fp.diff pi fp)) = 0 thenfound := pienddone;!found
Verbatim, from the tests: lifting 5 -> 5^k, with the congruence checked at every k, and a case whose true coefficients are far larger than the prime.
(* The Hensel step is an identity, not a heuristic: after lifting top^k the product must be congruent to f modulo p^k exactly. *)let test_hensel () =let f = p [ 2; 3; 1 ] in(* x^2 + 3x + 2 = (x+1)(x+2), and mod 5 the factors are coprime *)let q = 5 inlet g1 = Fp.of_list q [ 1; 1 ] and h1 = Fp.of_list q [ 2; 1 ] inList.iter(fun k ->let g, h = Factor.hensel_lift2 q k f g1 h1 inlet m = Bigint.pow (Bigint.of_int q) k inlet err = Upoly.sub f (Upoly.mul g h) incheck (Printf.sprintf "lift to 5^%d is congruent" k)(Array.for_all (fun c -> Bigint.is_zero (Bigint.rem c m)) err);check (Printf.sprintf "lift to 5^%d keeps the degrees" k)(Upoly.degree g = 1 && Upoly.degree h = 1))[ 1; 2; 3; 5; 8 ];(* Lifted far enough, the factors are the integer ones. *)let k = Factor.lift_exponent q (Factor.coefficient_bound f) inlet g, h = Factor.hensel_lift2 q k f g1 h1 incheck "far enough is exact over Z" (Upoly.equal (Upoly.mul g h) f);(* A non-trivial case, where the true factors have coefficientslarger than the prime. *)let f2 = Upoly.mul (p [ 37; 1 ]) (p [ -41; 1 ]) inlet q2 = 7 inlet a1 = Upoly.to_fp (p [ 37; 1 ]) q2 and b1 = Upoly.to_fp (p [ -41; 1 ]) q2 inlet k2 = Factor.lift_exponent q2 (Factor.coefficient_bound f2) inlet g2, h2 = Factor.hensel_lift2 q2 k2 f2 a1 b1 incheck "coefficients larger than the prime are recovered"(Upoly.equal (Upoly.mul g2 h2) f2)
Recombination
A true factor is a product of some subset of the lifted ones. Subsets are tried smallest first, because the true factors usually use few pieces and stopping early is the whole game.
(* Enumerate the subsets of a list by size, smallest first, because thetrue factors are usually products of few lifted pieces and stoppingearly is the whole game. *)let subsets_of_size (k : int) (l : 'a list) : 'a list list =let rec go k l =if k = 0 then [ [] ]elsematch l with| [] -> []| x :: rest -> List.map (fun s -> x :: s) (go (k - 1) rest) @ go k restingo k l(* Try products of the lifted factors against f. A subset whose productdivides f exactly over Z is a true factor, and the pieces it usedare removed before the search continues. *)let recombine (f : Upoly.t) (lifted : Upoly.t list) (m : Bigint.t) : Upoly.t list =let remaining = ref lifted inlet current = ref f inlet out = ref [] inlet size = ref 1 inwhile List.length !remaining > 0 && !size <= List.length !remaining / 2 dolet found = ref false inlet candidates = subsets_of_size !size !remaining inList.iter(fun subset ->if not !found then beginlet prod = center (List.fold_left Upoly.mul Upoly.one subset) m inif Upoly.degree prod > 0 thenmatch Upoly.divides (Upoly.primitive_part prod) !current with| Some q ->found := true;out := Upoly.primitive_part prod :: !out;current := q;remaining := List.filter (fun g -> not (List.memq g subset)) !remaining| None -> ()end)candidates;if not !found then incr sizedone;if Upoly.degree !current > 0 then out := !current :: !out;List.rev !out
The monic case, assembled.
(* Input must be monic, square-free, of positive degree. *)let factor_monic_squarefree (f : Upoly.t) : Upoly.t list =if Upoly.degree f <= 1 then [ f ]else beginlet p = choose_prime f inlet fp = Fp.monic p (Upoly.to_fp f p) inlet _, mods = Fp.factor p fp inlet mods = List.map fst mods inif List.length mods = 1 then [ f ]else beginlet k = lift_exponent p (coefficient_bound f) inlet m = Bigint.pow (Bigint.of_int p) k inlet lifted = hensel_lift_many p k f mods inrecombine f lifted mendend
A non-monic input is made monic by a change of variable rather than by dragging the leading coefficient through the lift.
(* A non-monic f is made monic by a change of variable rather than bydragging the leading coefficient through the lift: with b = lc(f) andn = deg(f), the polynomial b^(n-1) * f(x/b) is monic with integercoefficients, and a factor g(x) of it maps back to the primitivepart of g(b*x). *)let to_monic (f : Upoly.t) : Upoly.t =let n = Upoly.degree f inlet b = Upoly.lc f inUpoly.normalize(Array.init (n + 1) (fun i ->(* coefficient of x^i is a_i * b^(n-1-i) *)if i = n then Bigint.oneelse Bigint.mul (Upoly.coeff f i) (Bigint.pow b (n - 1 - i))))let from_monic (g : Upoly.t) (b : Bigint.t) : Upoly.t =(* g(b*x), then take the primitive part. *)Upoly.primitive_part(Upoly.normalize(Array.init (Upoly.degree g + 1) (fun i -> Bigint.mul (Upoly.coeff g i) (Bigint.pow b i))))let factor_squarefree (f : Upoly.t) : Upoly.t list =if Upoly.degree f <= 0 then []else beginlet f = Upoly.primitive_part f inif Upoly.degree f <= 1 then [ f ]else beginlet b = Upoly.lc f inif Bigint.equal b Bigint.one then factor_monic_squarefree felseList.map (fun g -> from_monic g b) (factor_monic_squarefree (to_monic f))endend
And the whole thing: content, square-free split, factor each piece.
(* The whole thing: content, then square-free split, then factor eachsquare-free piece. Returns the integer content and the irreduciblefactors with their multiplicities. *)let factor (f : Upoly.t) : Bigint.t * (Upoly.t * int) list =if Upoly.is_zero f then (Bigint.zero, [])else beginlet c = Upoly.content f inlet c = if Bigint.sign (Upoly.lc f) < 0 then Bigint.neg c else c inlet prim = Upoly.divexact_int f c inif Upoly.degree prim = 0 then (c, [])else beginlet out = ref [] inList.iter(fun (g, m) -> List.iter (fun q -> out := (q, m) :: !out) (factor_squarefree g))(square_free prim);(c, List.rev !out)endend
Verbatim
From the REPL.
> factor x^4 - 1, x(1 + x)*(-1 + x)*(1 + x^2)> factor 6*x^6 - 6, x6*(1 + x)*(-1 + x)*(1 + x + x^2)*(1 + x^2 - x)> factor 9*x^2 + 12*x + 4, x(2 + 3*x)^2> factor x^16 - 1, x(1 + x)*(-1 + x)*(1 + x^2)*(1 + x^4)*(1 + x^8)
The case the whole design exists for. x^4 + 1 is irreducible over Z and reducible modulo every prime, so no amount of cleverness mod p will ever produce the answer: only recombination can.
> factor x^4 + 1, x1 + x^4> factormod x^4 + 1, 51 * (x^2 + 2)^1 * (x^2 + 3)^1> factormod x^4 + 1, 131 * (x^2 + 5)^1 * (x^2 + 8)^1
The worst case, on purpose
The Swinnerton-Dyer polynomials are the product over every sign choice of x - (+-sqrt 2 +- sqrt 3 +- sqrt 5 ...). They are irreducible over Z and split into factors of degree at most two modulo every prime, which makes recombination search every subset before concluding there is nothing to find.
Built by adjoining one square root at a time, in Z[sqrt q][x], so the norm keeps everything in Z[x].
let swinnerton_dyer (k : int) : Upoly.t =(* The product over every sign choice of x - (+-sqrt p1 +- sqrt p2 ...).Adjoining one square root at a time: if f has roots r_i, thenf(x - sqrt q) * f(x + sqrt q) has roots r_i +- sqrt q. Writingf(x + sqrt q) = A(x) + sqrt q * B(x) makes f(x - sqrt q) itsconjugate, so the product is the norm A^2 - q*B^2 and stays inZ[x]. A and B come out of a Horner pass in Z[sqrt q][x]. *)let adjoin (f : Upoly.t) (q : int) : Upoly.t =let qb = Bigint.of_int q inlet x = Upoly.of_list [ 0; 1 ] in(* (a, b) stands for a + sqrt q * b; multiplying by (x + sqrt q)gives (a*x + q*b, a + b*x). *)let a = ref Upoly.zero and b = ref Upoly.zero infor i = Upoly.degree f downto 0 dolet a' = Upoly.add (Upoly.mul !a x) (Upoly.scale !b qb) inlet b' = Upoly.add !a (Upoly.mul !b x) ina := Upoly.add a' (Upoly.const (Upoly.coeff f i));b := b'done;Upoly.sub (Upoly.mul !a !a) (Upoly.scale (Upoly.mul !b !b) qb)inlet primes = [ 2; 3; 5; 7 ] inList.fold_left adjoin (Upoly.of_list [ 0; 1 ]) (List.filteri (fun i _ -> i < k) primes)
./_out/main factorbench
./_out/main factorbench
Verbatim. The two middle columns are the point: the mod-p column is what recombination starts from and the over-Z column is what it ends with.
polynomial degree mod p over Z time (s)x^16 - 1 16 8 5 0.0010x^32 - 1 32 10 6 0.0029x^64 - 1 64 12 7 0.0169Swinnerton-Dyer 2 (sqrt 2, 3) 4 2 1 0.0001Swinnerton-Dyer 3 (+ sqrt 5) 8 4 1 0.0003Swinnerton-Dyer 4 (+ sqrt 7) 16 8 1 0.0049product of 5 random irreducibles 25 8 5 0.0049
Eight pieces mod p collapsing to one over Z means every subset up to size eight is tried and rejected. That is 256 trial divisions to learn that a degree-16 polynomial does not factor, and the count doubles with each further square root.
Rational functions
A quotient kept in lowest terms by the GCD from Part 3. Dividing by it is what makes this a normal form, and it is also the most expensive thing the arithmetic does, which is why that GCD was worth the trouble.
type t = {num : Poly.t;den : Poly.t; (* never zero, coprime with num, positive lex-leading coefficient *)}(* Cancel the common factor and fix the sign. Dividing by the GCD iswhat makes this a normal form, and it is also the single mostexpensive thing a rational function arithmetic does, which is whyPart 3 spent so long making the GCD fast. *)let make (num : Poly.t) (den : Poly.t) : t =if Poly.is_zero den then raise Division_by_zero;if Poly.is_zero num then { num = Poly.zero; den = Poly.one }else beginlet g = Poly.gcd num den inlet n = Poly.divide_exn num g and d = Poly.divide_exn den g in(* Push the content and the sign into the numerator so that thedenominator is primitive with a positive leading coefficient. *)let dp = Poly.primitive d inlet scale =match (Poly.lead_term d, Poly.lead_term dp) with| Some (_, c1), Some (_, c2) -> Rational.div c1 c2| _ -> Rational.onein{ num = Poly.scale n (Rational.inv scale); den = dp }end
The field operations, and a derivative by the quotient rule. The normalizing constructor cancels whatever each one leaves behind.
let neg (r : t) : t = { r with num = Poly.neg r.num }let add (a : t) (b : t) : t =make (Poly.add (Poly.mul a.num b.den) (Poly.mul b.num a.den)) (Poly.mul a.den b.den)let sub (a : t) (b : t) : t = add a (neg b)let mul (a : t) (b : t) : t = make (Poly.mul a.num b.num) (Poly.mul a.den b.den)let inv (a : t) : t =if is_zero a then raise Division_by_zero else make a.den a.numlet div (a : t) (b : t) : t =if is_zero b then raise Division_by_zero else make (Poly.mul a.num b.den) (Poly.mul a.den b.num)let pow (a : t) (n : int) : t =if n >= 0 then make (Poly.pow a.num n) (Poly.pow a.den n)else inv (make (Poly.pow a.num (-n)) (Poly.pow a.den (-n)))let equal (a : t) (b : t) : bool = Poly.equal a.num b.num && Poly.equal a.den b.den(* d/dv of n/d is (n'd - nd') / d^2, and the normalizing constructorcancels whatever that leaves behind. *)let diff (a : t) (v : string) : t =make(Poly.sub (Poly.mul (Poly.diff a.num v) a.den) (Poly.mul a.num (Poly.diff a.den v)))(Poly.mul a.den a.den)
Which makes a zero test a look at the numerator rather than an attempt to prove an identity.
> cancel (x^2 - 1)/(x + 1)-1 + x> together 1/(x + 1) + 1/(x - 1)2*x*(-1 + x^2)^(-1)> together 1/(x - 1) - 1/x - 1/(x*(x - 1))0
Partial fractions
Splitting n/(d1*d2) with coprime denominators is the Bezout identity and nothing more. Doing it over the irreducible factors rather than the square-free ones is what needs the factoriser, and it is why this section comes after the previous eleven.
Division with remainder over Q[v], which needs a leading coefficient that is a unit, so this is the univariate case and nothing else.
(* Ordinary division with remainder, which needs the leadingcoefficient to be invertible. Over Q that means a nonzero constant,so this is the univariate case and nothing else; everywhere elsepseudo-division is the only option. *)let quo_rem (v : string) (a : t) (b : t) : t * t =if is_zero b then raise Division_by_zero;let lcb = lc_in b v inlet inv =match to_rational lcb with| Some r when not (Rational.is_zero r) -> Rational.inv r| _ -> raise (Not_univariate "quo_rem: leading coefficient is not a constant")inlet db = degree_in b v inlet x = var v inlet q = ref zero and r = ref a inwhile (not (is_zero !r)) && degree_in !r v >= db dolet d = degree_in !r v - db inlet c = scale (lc_in !r v) inv inlet term = mul c (pow x d) inq := add !q term;r := sub !r (mul term b)done;(!q, !r)
Extended Euclid on top of it.
(* Extended Euclid over Q[v]: returns (g, s, t) with s*a + t*b = g. Thepartial-fraction split is exactly this identity applied to twocoprime denominators. *)let ext_gcd (v : string) (a : t) (b : t) : t * t * t =let r0 = ref a and r1 = ref b inlet s0 = ref one and s1 = ref zero inlet t0 = ref zero and t1 = ref one inwhile not (is_zero !r1) dolet q, r = quo_rem v !r0 !r1 inr0 := !r1;r1 := r;let s = sub !s0 (mul q !s1) ins0 := !s1;s1 := s;let t = sub !t0 (mul q !t1) int0 := !t1;t1 := tdone;match to_rational (lc_in !r0 v) with| Some c when not (Rational.is_zero c) ->let i = Rational.inv c in(scale !r0 i, scale !s0 i, scale !t0 i)| _ -> (!r0, !s0, !t0)(* ------------------------------------------------------------------ *)(* Integer normalization *)(* ------------------------------------------------------------------ *)(* Scale by the unique positive rational that clears all denominators,divides out the integer content, and makes the lex-leadingcoefficient positive. A GCD is only defined up to a unit, and over Qevery nonzero rational is a unit, so a convention like this is whatmakes `gcd` a function rather than a relation. *)let primitive (p : t) : t =if is_zero p then pelse beginlet den_lcm =List.fold_left(fun l (_, c) ->let d = Rational.den c inBigint.div (Bigint.mul l d) (Bigint.gcd l d))Bigint.one p.termsinlet scaled = scale p (Rational.of_bigint den_lcm) inlet num_gcd =List.fold_left (fun g (_, c) -> Bigint.gcd g (Rational.num c)) Bigint.zero scaled.termsinlet r = scale scaled (Rational.make Bigint.one num_gcd) inmatch lead_term r with| Some (_, c) when Rational.sign c < 0 -> neg r| _ -> rend
The split itself, and the expansion of a repeated factor, which is writing the numerator in base q.
(* Splitting n/(d1*d2) with d1 and d2 coprime is the Bezout identityand nothing more: from s*d1 + t*d2 = 1, multiplying by n anddividing by d1*d2 gives n*s/d2 + n*t/d1. The remainders keep thenumerator degrees below the denominator degrees. *)let split_coprime (v : string) (n : Poly.t) (d1 : Poly.t) (d2 : Poly.t) : Poly.t * Poly.t =let g, s, t = Poly.ext_gcd v d1 d2 inif not (Poly.is_one g) then invalid_arg "split_coprime: denominators share a factor";let a = snd (Poly.quo_rem v (Poly.mul n t) d1) inlet b = snd (Poly.quo_rem v (Poly.mul n s) d2) in(* n/(d1*d2) = a/d1 + b/d2, up to a polynomial that the caller hasalready divided out. *)(a, b)(* n/q^k as a sum of c_j/q^j with deg c_j < deg q: repeatedly divide byq and read off the remainders, which is writing n in base q. *)let expand_power (v : string) (n : Poly.t) (q : Poly.t) (k : int) : (Poly.t * int) list =let out = ref [] and cur = ref n infor j = k downto 1 dolet quo, rem = Poly.quo_rem v !cur q inif not (Poly.is_zero rem) then out := (rem, j) :: !out;cur := quodone;List.rev !out
Assembled: a polynomial part, then one term per irreducible factor power.
(* The full decomposition of a rational function in one variable:a polynomial part, then one term per irreducible factor power of thedenominator. Irreducible over Q, which is why this needs thefactoriser and not just a square-free split. *)let apart (a : t) (v : string) : Poly.t * (Poly.t * Poly.t * int) list =if Poly.is_one a.den then (a.num, [])else beginlet poly_part, rest = Poly.quo_rem v a.num a.den inif Poly.is_zero rest then (poly_part, [])else begin(* Factor the denominator over Q. *)let factors =match Poly.to_upoly v a.den with| None -> raise (Poly.Not_univariate "apart: denominator is not univariate")| Some (u, _) ->let _, fs = Factor.factor u inList.map (fun (g, m) -> (Poly.of_upoly v g, m)) fsin(* Peel the factor powers off one at a time with Bezout. Scaletracks the leading constant the factorisation dropped. *)let prod = List.fold_left (fun acc (g, m) -> Poly.mul acc (Poly.pow g m)) Poly.one factors inlet scale =match (Poly.lead_term a.den, Poly.lead_term prod) with| Some (_, c1), Some (_, c2) -> Rational.div c1 c2| _ -> Rational.oneinlet numerator = ref (Poly.scale rest (Rational.inv scale)) inlet remaining = ref prod inlet out = ref [] inList.iter(fun (g, m) ->let gk = Poly.pow g m inlet other = Poly.divide_exn !remaining gk inif Poly.is_one other then beginList.iter (fun (c, j) -> out := (c, g, j) :: !out) (expand_power v !numerator g m);numerator := Poly.zero;remaining := Poly.oneendelse beginlet a1, a2 = split_coprime v !numerator gk other inList.iter (fun (c, j) -> out := (c, g, j) :: !out) (expand_power v a1 g m);numerator := a2;remaining := otherend)factors;(poly_part, List.rev !out)endend
Verbatim.
> apart 1/(x^2 - 1), x-1/2*(1 + x)^(-1) + 1/2*(-1 + x)^(-1)> apart (x^3)/((x - 1)^2), x2 + x + (-1 + x)^(-2) + 3*(-1 + x)^(-1)> apart 1/(x^3 + x), xx^(-1) - x*(1 + x^2)^(-1)> apart 1/((x - 1)^2*(x + 2)), x-1/9*(-1 + x)^(-1) + 1/9*(2 + x)^(-1) + 1/3*(-1 + x)^(-2)
Solving
Factor, then read the roots off the factors. Degree one is exact, degree two is the quadratic formula, and an irreducible of higher degree is reported as itself rather than pretending there is a formula.
(* Factor, then read the roots off the factors. Degree one is exact,degree two is the quadratic formula, and anything irreducible ofhigher degree is reported as itself: RootOf(p, x) names a rootwithout pretending there is a formula for it. *)let solve (e : Expr.t) (v : string) : Expr.t list =let _, fs = factor_list e v inlet c = new_ctx () inList.concat_map(fun (g, _) ->let p = to_poly c (Expr.simplify g) inmatch Poly.degree_in p v with| 1 ->let a = Poly.coeff_in p v 1 and b = Poly.coeff_in p v 0 in[ Expr.simplify (Expr.Mul [ of_poly c (Poly.neg b); Expr.Pow (of_poly c a, Expr.int (-1)) ]) ]| 2 ->let a = of_poly c (Poly.coeff_in p v 2) inlet b = of_poly c (Poly.coeff_in p v 1) inlet cc = of_poly c (Poly.coeff_in p v 0) inlet disc =Expr.simplify(Expr.Add [ Expr.Pow (b, Expr.int 2); Expr.Mul [ Expr.int (-4); a; cc ] ])inlet root = Expr.Pow (disc, Expr.rat 1 2) inlet denom = Expr.Pow (Expr.Mul [ Expr.int 2; a ], Expr.int (-1)) in[ Expr.simplify (Expr.Mul [ Expr.Add [ Expr.Mul [ Expr.int (-1); b ]; root ]; denom ]);Expr.simplify(Expr.Mul[ Expr.Add [ Expr.Mul [ Expr.int (-1); b ]; Expr.Mul [ Expr.int (-1); root ] ]; denom ])]| _ -> [ Expr.Fun ("RootOf", [ g; Expr.Sym v ]) ])fs
Verbatim.
> solve x^2 - 5*x + 6, x3, 2> solve x^2 + x - 1, x1/2*(-1 + 5^(1/2)), 1/2*(-1 - 5^(1/2))> solve x^5 - x - 1, xRootOf(-1 + x^5 - x, x)> roots 6*x^2 - 5*x + 1, x1/2, 1/3
Crossing the bridge again
A negative integer power is a division rather than an opaque kernel: that one change to Part 3's converter is the difference between an expression tree and a field of fractions.
(* Like to_poly, except that a negative integer power is a divisionrather than an opaque kernel. That one change is the differencebetween an expression tree and the field of fractions. *)let rec to_ratfun (c : ctx) (e : Expr.t) : Ratfun.t =match e with| Expr.Num r -> Ratfun.of_poly (Poly.of_rational r)| Expr.Sym s ->ignore (kernel c (Expr.Sym s));Ratfun.of_poly (Poly.var s)| Expr.Add xs -> List.fold_left (fun acc x -> Ratfun.add acc (to_ratfun c x)) Ratfun.zero xs| Expr.Mul xs -> List.fold_left (fun acc x -> Ratfun.mul acc (to_ratfun c x)) Ratfun.one xs| Expr.Pow (b, Expr.Num n) when Rational.is_integer n -> (match Rational.to_int_opt n with| Some k -> Ratfun.pow (to_ratfun c b) k| None -> raise (Not_rational "exponent too large"))| Expr.Pow _ | Expr.Fun _ -> Ratfun.of_poly (Poly.var (kernel c e))let of_ratfun (c : ctx) (r : Ratfun.t) : Expr.t =if Ratfun.is_polynomial r then of_poly c (Ratfun.num r)elseExpr.simplify(Expr.Mul[ of_poly c (Ratfun.num r); Expr.Pow (of_poly c (Ratfun.den r), Expr.int (-1)) ])
Factorization only crosses when the polynomial is univariate. The multivariate case needs evaluation plus a multivariate Hensel lift, and saying so beats guessing.
(* Univariate over Q only: the multivariate case needs evaluation plusa multivariate Hensel lift, which is not in this part. *)let factor_list (e : Expr.t) (v : string) : Rational.t * (Expr.t * int) list =let c = new_ctx () inlet p = to_poly c (Expr.simplify e) inmatch Poly.to_upoly v p with| None -> raise (Not_factorable "factor: not univariate in that variable")| Some (u, scale) ->let cont, fs = Factor.factor u inlet k = Rational.mul scale (Rational.of_bigint cont) in(k, List.map (fun (g, m) -> (of_poly c (Poly.of_upoly v g), m)) fs)let factor (e : Expr.t) (v : string) : Expr.t =let k, fs = factor_list e v inlet terms =List.map (fun (g, m) -> if m = 1 then g else Expr.Pow (g, Expr.int m)) fsinlet terms = if Rational.is_one k then terms else Expr.Num k :: terms inmatch terms with [] -> Expr.Num k | [ t ] -> t | ts -> Expr.Mul ts
Verbatim.
> factor x^2 - y^2, xcannot factor: factor: not univariate in that variable
Tests
Three properties do most of the work here, and all three are stated without reference to an expected answer, so they can be run on random input a few hundred times.
Plant a product of irreducibles and demand it back.
(* Randomized: plant a product of irreducibles and demand it back. *)let test_factor_property () =Random.init 31337;let rec irreducible_of_degree d =let f =Upoly.of_bigint_list(List.init (d + 1) (fun i ->if i = d then Bigint.one else Bigint.of_int (Random.int 13 - 6)))inif Upoly.degree f = d && Factor.is_irreducible f then f else irreducible_of_degree dinfor _ = 1 to 60 dolet n = 2 + Random.int 2 inlet planted = List.init n (fun _ -> irreducible_of_degree (1 + Random.int 3)) inlet f = List.fold_left Upoly.mul Upoly.one planted inlet c, fs = Factor.factor f incheck "the factors multiply back to the input"(Upoly.equal(Upoly.scale (List.fold_left (fun acc (g, m) -> Upoly.mul acc (Upoly.pow g m)) Upoly.one fs) c)f);check "every factor is irreducible" (List.for_all (fun (g, _) -> Factor.is_irreducible g) fs);check "the total degree is preserved"(List.fold_left (fun acc (g, m) -> acc + (Upoly.degree g * m)) 0 fs = Upoly.degree f);(* Each planted factor has to divide the input, and so appear. *)check "every planted factor divides some returned factor"(List.for_all(fun g ->List.exists (fun (h, _) -> Upoly.divides (Upoly.primitive_part g) h <> None) fs)planted)done
Reassembling a partial fraction decomposition has to give back the original fraction exactly, which is what catches a wrong Bezout coefficient.
(* The property: reassembling the decomposition has to give back theoriginal fraction, exactly. That is checkable on random input, andit is what actually catches a wrong Bezout coefficient. *)let test_apart_reassembles () =Random.init 4242;let random_linear () = add x (n (Random.int 9 - 4)) inlet random_quadratic () = add (add (pow x 2) (mul (n (Random.int 3)) x)) (n (1 + Random.int 4)) infor _ = 1 to 120 dolet den =List.fold_left(fun acc _ ->mul acc (if Random.bool () then random_linear () else random_quadratic ()))one(List.init (1 + Random.int 3) (fun i -> i))inlet num =List.fold_left (fun acc k -> add acc (mul (n (Random.int 7 - 3)) (pow x k))) zero(List.init 4 (fun i -> i))inif (not (is_zero den)) && not (is_zero num) then beginlet f = r num den inmatch Ratfun.apart f "x" with| exception Division_by_zero -> ()| poly_part, terms ->let rebuilt =List.fold_left(fun acc (c, g, j) -> Ratfun.add acc (r c (pow g j)))(Ratfun.of_poly poly_part) termsincheck "partial fractions reassemble to the original" (Ratfun.equal rebuilt f);(* Every numerator must have degree below its denominator, orit was not fully decomposed. *)check "numerator degrees are below the denominator degrees"(List.for_all (fun (c, g, _) -> degree_in c "x" < degree_in g "x") terms)enddone
And factor then expand, and apart then together, both have to be the identity.
(* factor then expand has to be the identity *)List.iter(fun src ->check ("factor then expand is the identity: " ^ src)(Expr.compare_expr (Algebra.expand (Algebra.factor (p src) "x")) (Algebra.expand (p src)) = 0))[ "x^2 - 1"; "x^4 - 1"; "6*x^3 - 6"; "9*x^2 + 12*x + 4"; "x^5 - x"; "x^8 - 1";"x^4 - 8*x^2 + 12"; "2*x^3 + 3*x^2 - 2*x - 3" ];
What is deliberately not here
Three gaps, in order of how much they hurt.
multivariate factorization factor x^2 - y^2 is refused. The route isevaluation down to one variable, factorthere, then multivariate Hensel liftingback up, with a leading-coefficientcorrection that has no univariate analogue.algebraic numbers solve returns RootOf for degree 5 andabove, and sqrt for degree 2, but there isno arithmetic on either. Q[x]/(p) wouldgive it.no calculus at all diff still refuses sin(x), there is noseries, no limit, no integral.Part 5: derivatives with a function table, lazy power series,limits by Gruntz, and symbolic integration.
Download
The snapshot at the end of this part. New in Part 4: fp.ml, factor.ml, ratfun.ml, and the tests test_fp.ml, test_factor.ml, test_ratfun.ml. Changed: upoly.ml (its Pmod moved into fp.ml), poly.ml (division with remainder, extended Euclid, the Upoly bridge), algebra.ml, main.ml, test_upoly.ml, test_algebra.ml. Unchanged from Part 3: bigint.ml, rational.ml, expr.ml, parser.ml.