Coverage for src/algolib/maths/algebra/polynomial.py: 76%

112 statements  

« prev     ^ index     » next       coverage.py v7.10.4, created at 2025-08-20 19:37 +0000

1# src/algolib/maths/algebra/polynomial.py 

2""" 

3Lightweight univariate polynomial with real coefficients. 

4 

5Notes 

6----- 

7- Coefficients are stored in **ascending** degree order: ``p(x) = c0 + c1*x + … + cn*x**n``. 

8- The public API is immutable; the internal representation is a tuple of ``float``. 

9- Supported operations include addition, subtraction, multiplication (convolution), 

10 evaluation (Horner scheme), derivative, and antiderivative (indefinite integral). 

11 

12Examples 

13-------- 

14>>> p = Polynomial([1, 2, 3]) # 1 + 2x + 3x^2 

15>>> q = Polynomial.identity() # 1 

16>>> (p * q).coeffs == p.coeffs 

17True 

18>>> p.derivative().coeffs # d/dx (1 + 2x + 3x^2) = 2 + 6x 

19(2.0, 6.0) 

20""" 

21 

22from __future__ import annotations 

23 

24from dataclasses import dataclass 

25from typing import Iterable, Sequence, Tuple, Union 

26 

27from algolib.exceptions import InvalidTypeError, InvalidValueError 

28 

29Number = Union[int, float] 

30 

31 

32def _strip_trailing_zeros(cs: Sequence[Number]) -> Tuple[float, ...]: 

33 """Remove trailing zeros to keep a canonical representation. 

34 

35 Parameters 

36 ---------- 

37 cs : Sequence[Number] 

38 Input coefficients in ascending degree order. 

39 

40 Returns 

41 ------- 

42 Tuple[float, ...] 

43 Coefficients with all high‑degree zeros removed. At least one 

44 coefficient is preserved (the zero polynomial becomes ``(0.0,)``). 

45 """ 

46 if not cs: 

47 return (0.0,) 

48 out = list(map(float, cs)) 

49 # remove highest-degree zeros, keep at least one coefficient 

50 while len(out) > 1 and out[-1] == 0.0: 

51 out.pop() 

52 return tuple(out) 

53 

54def _horner_kahan(coeffs: Sequence[Number], x: Number) -> Number: 

55 """Evaluate ``p(x)`` via Horner's method with Kahan compensation for the 

56 accumulating sum. 

57 

58 Notes 

59 ----- 

60 The Kahan term stabilizes the *addition* in each fused step ``s*x + c_k``. 

61 This is a lightweight compromise that improves accuracy for floating‑point 

62 inputs without adding significant overhead. 

63 

64 Parameters 

65 ---------- 

66 coeffs : Sequence[Number] 

67 Coefficients in ascending degree order. 

68 x : Number 

69 Evaluation point. Real and complex values are supported. 

70 

71 Returns 

72 ------- 

73 Number 

74 The evaluated value ``p(x)``. 

75 """ 

76 n = len(coeffs) 

77 if n == 1: 

78 return float(coeffs[0]) 

79 s: Number = float(coeffs[-1]) 

80 c: Number = 0.0 

81 for k in range(n - 2, -1, -1): 

82 prod = s * x 

83 y = float(coeffs[k]) - c 

84 t = prod + y 

85 c = (t - prod) - y 

86 s = t 

87 return s 

88 

89 

90 

91@dataclass(frozen=True, slots=True) 

92class Polynomial: 

93 """ 

94 Univariate polynomial with real coefficients. 

95 

96 Parameters 

97 ---------- 

98 coeffs : Sequence[Number] 

99 Coefficients in ascending degree order: 

100 ``[c0, c1, ..., cn]`` represents :math:`\\sum_{k=0}^n c_k x^k`. 

101 

102 Raises 

103 ------ 

104 InvalidTypeError 

105 If any coefficient is not a real number. 

106 InvalidValueError 

107 If ``coeffs`` is empty. 

108 

109 Notes 

110 ----- 

111 - Construction enforces a **canonical form** by stripping trailing zeros; the 

112 zero polynomial is thus represented as ``(0.0,)`` and has degree 0. 

113 - All operations return new ``Polynomial`` instances (immutable API). 

114 """ 

115 

116 coeffs: Tuple[float, ...] # stored canonical (no trailing zeros) 

117 

118 # ------------------------------- construction ------------------------------- 

119 

120 def __init__(self, coeffs: Iterable[Number]) -> None: 

121 # Validate and convert to floats, mapping type errors to our domain error. 

122 tmp: list[float] = [] 

123 try: 

124 for c in coeffs: 

125 if not isinstance(c, (int, float)): 

126 raise InvalidTypeError("coeffs must contain real numbers (int or float)") 

127 tmp.append(float(c)) 

128 except InvalidTypeError: 

129 # re-raise our own type error untouched 

130 raise 

131 except Exception as e: 

132 # any other conversion error -> InvalidTypeError for a clean API 

133 raise InvalidTypeError("coeffs must contain real numbers (int or float)") from e 

134 

135 if len(tmp) == 0: 

136 raise InvalidValueError("coeffs must be non-empty") 

137 

138 # Strip trailing zeros but keep at least one 

139 i = len(tmp) - 1 

140 while i > 0 and tmp[i] == 0.0: 

141 i -= 1 

142 object.__setattr__(self, "coeffs", tuple(tmp[: i + 1])) 

143 

144 

145 @staticmethod 

146 def zeros(deg: int) -> "Polynomial": 

147 """ 

148 Return the (canonical) zero polynomial. 

149 

150 Parameters 

151 ---------- 

152 deg : int 

153 Requested degree (non‑negative). This value is accepted for API symmetry, 

154 but the canonical zero polynomial always has degree 0 after construction. 

155 

156 Returns 

157 ------- 

158 Polynomial 

159 The zero polynomial. 

160 """ 

161 if deg < 0: 

162 raise InvalidValueError("degree must be non-negative.") 

163 return Polynomial([0.0] * (deg + 1)) 

164 

165 @staticmethod 

166 def constant(c: Number) -> "Polynomial": 

167 """Return a constant polynomial ``p(x) = c``.""" 

168 return Polynomial([c]) 

169 

170 @staticmethod 

171 def identity() -> "Polynomial": 

172 """Return the multiplicative identity polynomial ``1``.""" 

173 return Polynomial([1.0]) 

174 

175 # --------------------------------- queries ---------------------------------- 

176 

177 @property 

178 def degree(self) -> int: 

179 """Degree of the polynomial (``len(coeffs) - 1``). By convention, ``deg(0) = 0``.""" 

180 return len(self.coeffs) - 1 

181 

182 def __call__(self, x: Number) -> Number: 

183 """ 

184 Evaluate ``p(x)``. 

185 

186 Parameters 

187 ---------- 

188 x : float or complex 

189 Evaluation point. 

190 

191 Returns 

192 ------- 

193 float or complex 

194 The value ``p(x)`` computed via Horner's method with lightweight 

195 Kahan compensation. 

196 """ 

197 return _horner_kahan(self.coeffs, x) 

198 

199 # -------------------------------- calculus ---------------------------------- 

200 

201 def derivative(self) -> "Polynomial": 

202 r""" 

203 Return the analytical derivative :math:`p'(x)`. 

204 

205 Notes 

206 ----- 

207 If :math:`p(x) = a_0 + a_1 x + \cdots + a_n x^n`, then 

208 :math:`p'(x) = a_1 + 2 a_2 x + \cdots + n a_n x^{n-1}`. 

209 """ 

210 

211 if self.degree == 0: 

212 return Polynomial([0.0]) 

213 der = [k * self.coeffs[k] for k in range(1, len(self.coeffs))] 

214 return Polynomial(der) 

215 

216 def integral(self, c0: Number = 0.0) -> "Polynomial": 

217 r""" 

218 Return an antiderivative :math:`\int p(x)\,dx` with constant term ``c0``. 

219 

220 Parameters 

221 ---------- 

222 c0 : Number, default=0.0 

223 Constant of integration (the coefficient of :math:`x^0`). 

224 

225 Returns 

226 ------- 

227 Polynomial 

228 An antiderivative whose derivative equals ``self``. 

229 """ 

230 out = [float(c0)] 

231 out.extend(c / (k + 1.0) for k, c in enumerate(self.coeffs)) 

232 return Polynomial(out) 

233 

234 # --------------------------------- algebra ---------------------------------- 

235 

236 def __add__(self, other: "Polynomial") -> "Polynomial": 

237 if not isinstance(other, Polynomial): 

238 raise InvalidTypeError("other must be Polynomial") 

239 n = max(self.degree, other.degree) + 1 

240 cs = [0.0] * n 

241 for i, c in enumerate(self.coeffs): 

242 cs[i] += c 

243 for i, c in enumerate(other.coeffs): 

244 cs[i] += c 

245 return Polynomial(cs) 

246 

247 def __sub__(self, other: "Polynomial") -> "Polynomial": 

248 if not isinstance(other, Polynomial): 

249 raise InvalidTypeError("other must be Polynomial") 

250 n = max(self.degree, other.degree) + 1 

251 cs = [0.0] * n 

252 for i, c in enumerate(self.coeffs): 

253 cs[i] += c 

254 for i, c in enumerate(other.coeffs): 

255 cs[i] -= c 

256 return Polynomial(cs) 

257 

258 def __mul__(self, other: "Polynomial") -> "Polynomial": 

259 if not isinstance(other, Polynomial): 

260 raise InvalidTypeError("other must be Polynomial") 

261 na, nb = self.degree + 1, other.degree + 1 

262 out = [0.0] * (na + nb - 1) 

263 for i, ai in enumerate(self.coeffs): 

264 for j, bj in enumerate(other.coeffs): 

265 out[i + j] += ai * bj 

266 return Polynomial(out) 

267 

268 # --------------------------------- display ---------------------------------- 

269 

270 def __repr__(self) -> str: 

271 """Unambiguous representation, e.g. ``Polynomial(coeffs=(1.0, 2.0))``.""" 

272 return f"Polynomial(coeffs={self.coeffs})" 

273 

274 

275 def __str__(self) -> str: 

276 """Human‑readable form such as ``"3x^2 + 2x + 1"`` (or ``"0"`` for the zero polynomial).""" 

277 terms = [] 

278 for k, c in enumerate(self.coeffs): 

279 if c == 0.0: 

280 continue 

281 if k == 0: 

282 terms.append(f"{c:.12g}") 

283 elif k == 1: 

284 terms.append(f"{c:.12g}x") 

285 else: 

286 terms.append(f"{c:.12g}x^{k}") 

287 return " + ".join(reversed(terms)) if terms else "0"