Hoja de cálculo en Python: fórmulas, funciones, rangos y referencias circulares
Programa el motor de una hoja de cálculo como Calc o Excel: analiza las fórmulas con descenso recursivo y precedencia, resuelve las referencias entre celdas, los rangos y funciones como SUMA, PROMEDIO o SI, y produce los errores de verdad: #DIV/0!, #¡VALOR!, #¿NOMBRE? y las referencias circulares.
- Hoja de cálculo: fórmulas y referencias
- Análisis por descenso recursivo
- Precedencia de operadores
- Recursividad con memoria
- Detección de ciclos
- Valores de error que se propagan
Enunciado
La hoja de cálculo es la aplicación ofimática que más se usa en una empresa, y por dentro es un pequeño lenguaje de programación: cada fórmula es una expresión con operadores, referencias a otras celdas y funciones, y la hoja tiene que calcular cada celda en el orden correcto (primero aquello de lo que depende), detectar los ciclos y explicar los errores con códigos que todo el mundo conoce.
La entrada tiene una celda por línea: el nombre (una letra de columna y una fila de 1 a 99), un espacio y el contenido: un número (con coma decimal), un texto o una fórmula que empieza por =. Las fórmulas siguen la sintaxis de la hoja de cálculo en español: los argumentos se separan con ;, los textos van entre comillas y & une textos. El código de partida ya lee las celdas, sabe escribir los valores y trae el analizador léxico (la expresión regular TOKEN): falta el analizador sintáctico y el cálculo.
Qué tiene que hacer el programa
- Precedencia, de menor a mayor: comparaciones (
=,<>,<,>,<=,>=, una como mucho),&, suma y resta, multiplicación y división, signo (-) y lo primario: números, textos (""es una comilla), celdas, rangos (A1:B3, solo como argumento de una función), funciones y paréntesis. Los nombres de funciones y celdas no distinguen mayúsculas. Una fórmula que no se puede analizar, o una función conocida con un número de argumentos que no admite, da#SINTAXIS!. - Una celda vacía vale 0 en las cuentas y
""al unir textos (y una fórmula que da una celda vacía muestra 0). Un texto en una operación aritmética da#¡VALOR!(como un rango fuera de una función), dividir entre 0 da#DIV/0!y una función que no existe,#¿NOMBRE?. Los errores se propagan: si un operando o argumento es un error, el resultado es ese error (el primero, de izquierda a derecha). Los booleanos valen 1 y 0 en las cuentas. - Las comparaciones dan
VERDADEROoFALSO; entre tipos distintos, los números son menores que los textos, y estos que los booleanos; los textos se comparan sin distinguir mayúsculas.SI(condición; valor_si; [valor_no])solo calcula la rama elegida (sin tercer argumento,FALSO); una condición de texto da#¡VALOR!.REDONDEAR(x; n)redondea a n decimales alejándose del cero en los empates (2,675 → 2,68). SUMA,PROMEDIO,MAXyMINreciben valores o rangos: en los rangos se saltan los textos, los booleanos y las celdas vacías (los errores sí se propagan); un texto como argumento directo da#¡VALOR!.PROMEDIOsin números da#DIV/0!yMAXoMINsin números, 0.CONTARcuenta los números de sus argumentos y rangos (sin propagar errores).- Una celda que se necesita a sí misma, directa o indirectamente, da
#CIRCULAR!(y también las que dependen de ella). Al final ya están escritas las celdas por filas (A1: valor) y el resumen de errores: tu trabajo es queHoja.valordevuelva el valor correcto, calculando cada celda una sola vez.
Entrada
Una celda por línea: A1 contenido (número con coma decimal, texto o =fórmula).
Datos de referencia
| Función | Qué hace |
|---|---|
| SUMA(a; b; …) | suma los números |
| PROMEDIO(a; …) | media de los números (#DIV/0! si no hay) |
| MAX(a; …) y MIN(a; …) | el mayor y el menor (0 si no hay) |
| CONTAR(a; …) | cuántos números hay |
| SI(cond; sí; [no]) | elige un valor según la condición |
| REDONDEAR(x; n) | redondea a n decimales |
Ejemplos de ejecución
Tu programa debe escribir exactamente esta salida para estas entradas. Las pruebas del editor incluyen estos ejemplos y otros casos ocultos.
Las notas de una clase
Entrada
A1 Alumno B1 Examen C1 Prácticas D1 Final E1 Resultado A2 Ana B2 7,5 C2 9 D2 =REDONDEAR(B2*0,6+C2*0,4;1) E2 =SI(D2>=5;"Aprobado";"Suspenso") A3 Luis B3 4 C3 5,5 D3 =REDONDEAR(B3*0,6+C3*0,4;1) E3 =SI(D3>=5;"Aprobado";"Suspenso") A4 Eva B4 6 C4 3 D4 =redondear(b4*0,6+c4*0,4;1) E4 =si(d4>=5;"Aprobado";"Suspenso") A6 Media D6 =PROMEDIO(D2:D4) A7 Mejor D7 =MAX(D2:D4) A8 Aprobados D8 =CONTAR(D2:D4)-(D3<5)-(D4<5) A9 Informe B9 ="Media de "&CONTAR(B2:B4)&" alumnos: "&REDONDEAR(D6;2)
Salida por consola
A1: Alumno B1: Examen C1: Prácticas D1: Final E1: Resultado A2: Ana B2: 7,5 C2: 9 D2: 8,1 E2: Aprobado A3: Luis B3: 4 C3: 5,5 D3: 4,6 E3: Suspenso A4: Eva B4: 6 C4: 3 D4: 4,8 E4: Suspenso A6: Media D6: 5,83 A7: Mejor D7: 8,1 A8: Aprobados D8: 1 A9: Informe B9: Media de 3 alumnos: 5,83 Sin errores
Errores de todo tipo
Entrada
A1 10 A2 0 A3 hola B1 =A1/A2 B2 =A1+A3 B3 =SUMA(A1:A3) B4 =SUMA(A1;A3) B5 =RAIZ(A1) B6 =A1+*2 B7 =SI(A2=0;0;A1/A2) B8 =B1+1 C1 =C2+1 C2 =C3+1 C3 =C1*2 C4 =C9+5 C5 =C9&"x" C6 =A1:A3 C7 =SI(A1;2;3;4) C8 =PROMEDIO(A3) C9 =PROMEDIO(A3:A3)
Salida por consola
A1: 10 B1: #DIV/0! C1: #CIRCULAR! A2: 0 B2: #¡VALOR! C2: #CIRCULAR! A3: hola B3: 10 C3: #CIRCULAR! B4: #¡VALOR! C4: #DIV/0! B5: #¿NOMBRE? C5: #DIV/0! B6: #SINTAXIS! C6: #¡VALOR! B7: 0 C7: #SINTAXIS! B8: #DIV/0! C8: #¡VALOR! C9: #DIV/0! Celdas con error: 15 (B1 #DIV/0!, C1 #CIRCULAR!, B2 #¡VALOR!, C2 #CIRCULAR!, C3 #CIRCULAR!, B4 #¡VALOR!, C4 #DIV/0!, B5 #¿NOMBRE?, C5 #DIV/0!, B6 #SINTAXIS!, C6 #¡VALOR!, C7 #SINTAXIS!, B8 #DIV/0!, C8 #¡VALOR!, C9 #DIV/0!)
Guía paso a paso
Intenta resolverlo por tu cuenta y abre un paso solo cuando te atasques: cada uno te acerca a la solución sin dártela entera.
1. Una función por nivel de precedencia
En el descenso recursivo, cada nivel llama al siguiente para sus operandos y repite mientras vea su operador. Así 1+2*3 agrupa la multiplicación primero sin ninguna regla especial.
def suma(self):
izq = self.producto()
while self.mirar()[1] in ("+", "-"):
op = self.tomar()[1]
izq = ("bin", op, izq, self.producto())
return izq2. Un árbol y después el cálculo
El analizador devuelve tuplas como ("bin", "+", ("ref", "A1"), ("num", 2.0)) y evaluar las recorre con recursividad. Separar el análisis del cálculo permite, por ejemplo, que SI calcule solo una rama.
3. Memoria y ciclos
Guarda el valor de cada celda calculada (memo) y las que se están calculando ahora (en_curso). Si te piden una que está en curso, hay un ciclo: devuelve #CIRCULAR!, y el error se propagará por todas las celdas del ciclo.
4. Los errores son valores
No uses excepciones para #DIV/0! y compañía: son valores que se guardan en la celda y se propagan. Una función numero(v) que convierta un valor para operar (o devuelva el error) evita repetir comprobaciones.
Resuélvelo aquí
El editor trae el esqueleto del programa. Pulsa «Ejecutar» para comprobarlo con los ejemplos y con 2 casos ocultos que buscan los errores típicos.
Ejemplo
A1 Alumno B1 Examen C1 Prácticas D1 Final E1 Resultado A2 Ana B2 7,5 C2 9 D2 =REDONDEAR(B2*0,6+C2*0,4;1) E2 =SI(D2>=5;"Aprobado";"Suspenso") A3 Luis B3 4 C3 5,5 D3 =REDONDEAR(B3*0,6+C3*0,4;1) E3 =SI(D3>=5;"Aprobado";"Suspenso") A4 Eva B4 6 C4 3 D4 =redondear(b4*0,6+c4*0,4;1) E4 =si(d4>=5;"Aprobado";"Suspenso") A6 Media D6 =PROMEDIO(D2:D4) A7 Mejor D7 =MAX(D2:D4) A8 Aprobados D8 =CONTAR(D2:D4)-(D3<5)-(D4<5) A9 Informe B9 ="Media de "&CONTAR(B2:B4)&" alumnos: "&REDONDEAR(D6;2)
A1: Alumno B1: Examen C1: Prácticas D1: Final E1: Resultado A2: Ana B2: 7,5 C2: 9 D2: 8,1 E2: Aprobado A3: Luis B3: 4 C3: 5,5 D3: 4,6 E3: Suspenso A4: Eva B4: 6 C4: 3 D4: 4,8 E4: Suspenso A6: Media D6: 5,83 A7: Mejor D7: 8,1 A8: Aprobados D8: 1 A9: Informe B9: Media de 3 alumnos: 5,83 Sin errores
Solución explicada
Ver la solución completa
1import re
2import sys
3from decimal import Decimal, ROUND_HALF_UP
4
5
6class Error:
7 """Un valor de error de la hoja: #DIV/0!, #¡VALOR!, #¿NOMBRE?, #SINTAXIS! o #CIRCULAR!"""
8 def __init__(self, codigo):
9 self.codigo = codigo
10
11 def __str__(self):
12 return self.codigo
13
14
15def formatear(v):
16 """Cómo se ve un valor en la celda: 7,5 · 12 · VERDADERO · texto · #DIV/0!"""
17 if isinstance(v, Error):
18 return str(v)
19 if isinstance(v, bool):
20 return "VERDADERO" if v else "FALSO"
21 if isinstance(v, (int, float)):
22 if abs(v - round(v)) < 1e-9:
23 return str(int(round(v)))
24 return f"{v:.2f}".rstrip("0").rstrip(".").replace(".", ",")
25 return "" if v is None else v
26
27
28FUNCIONES = {"SUMA", "PROMEDIO", "MAX", "MIN", "CONTAR", "SI", "REDONDEAR"}
29ARIDAD = {"SI": (2, 3), "REDONDEAR": (2, 2)}
30CELDA = re.compile(r"[A-Z][1-9][0-9]?")
31TOKEN = re.compile(r'\s*(?:(?P<num>\d+(?:,\d+)?)|(?P<txt>"(?:[^"]|"")*")|(?P<id>[A-Za-z]+[0-9]*)|(?P<op><>|<=|>=|[-+*/&=<>();:]))')
32
33def tokens(formula):
34 resultado, i = [], 0
35 while i < len(formula.rstrip()):
36 m = TOKEN.match(formula, i)
37 if not m:
38 raise SyntaxError
39 i = m.end()
40 tipo = m.lastgroup
41 valor = m.group(tipo)
42 if tipo == "id":
43 valor = valor.upper()
44 tipo = "ref" if CELDA.fullmatch(valor) else "nombre"
45 resultado.append((tipo, valor))
46 return resultado
47
48
49class Analizador:
50 """Descenso recursivo: comparación < & < suma y resta < producto y división < signo < primario."""
51
52 def __init__(self, formula):
53 self.t = tokens(formula)
54 self.i = 0
55
56 def mirar(self):
57 return self.t[self.i] if self.i < len(self.t) else (None, None)
58
59 def tomar(self, valor=None):
60 tipo, v = self.mirar()
61 if tipo is None or (valor is not None and v != valor):
62 raise SyntaxError
63 self.i += 1
64 return tipo, v
65
66 def analizar(self):
67 arbol = self.comparacion()
68 if self.i != len(self.t):
69 raise SyntaxError
70 return arbol
71
72 def comparacion(self):
73 izq = self.concatenacion()
74 if self.mirar()[1] in ("=", "<>", "<", ">", "<=", ">="):
75 op = self.tomar()[1]
76 return ("bin", op, izq, self.concatenacion())
77 return izq
78
79 def concatenacion(self):
80 izq = self.suma()
81 while self.mirar()[1] == "&":
82 self.tomar()
83 izq = ("bin", "&", izq, self.suma())
84 return izq
85
86 def suma(self):
87 izq = self.producto()
88 while self.mirar()[1] in ("+", "-"):
89 op = self.tomar()[1]
90 izq = ("bin", op, izq, self.producto())
91 return izq
92
93 def producto(self):
94 izq = self.signo()
95 while self.mirar()[1] in ("*", "/"):
96 op = self.tomar()[1]
97 izq = ("bin", op, izq, self.signo())
98 return izq
99
100 def signo(self):
101 if self.mirar()[1] == "-":
102 self.tomar()
103 return ("neg", self.signo())
104 if self.mirar()[1] == "+":
105 self.tomar()
106 return self.signo()
107 return self.primario()
108
109 def primario(self):
110 tipo, v = self.tomar()
111 if tipo == "num":
112 return ("num", float(v.replace(",", ".")))
113 if tipo == "txt":
114 return ("txt", v[1:-1].replace('""', '"'))
115 if tipo == "ref":
116 if self.mirar()[1] == ":":
117 self.tomar()
118 fin = self.tomar()
119 if fin[0] != "ref":
120 raise SyntaxError
121 return ("rango", v, fin[1])
122 return ("ref", v)
123 if tipo == "nombre":
124 self.tomar("(")
125 args = []
126 if self.mirar()[1] != ")":
127 args.append(self.comparacion())
128 while self.mirar()[1] == ";":
129 self.tomar()
130 args.append(self.comparacion())
131 self.tomar(")")
132 minimo, maximo = ARIDAD.get(v, (1, 99))
133 if v in FUNCIONES and not minimo <= len(args) <= maximo:
134 raise SyntaxError
135 return ("fun", v, args)
136 if v == "(":
137 dentro = self.comparacion()
138 self.tomar(")")
139 return dentro
140 raise SyntaxError
141
142
143def celdas_de(desde, hasta):
144 c1, c2 = sorted([desde[0], hasta[0]])
145 f1, f2 = sorted([int(desde[1:]), int(hasta[1:])])
146 return [chr(c) + str(f) for f in range(f1, f2 + 1) for c in range(ord(c1), ord(c2) + 1)]
147
148
149def numero(v):
150 if isinstance(v, Error):
151 return v
152 if v is None:
153 return 0
154 if isinstance(v, bool):
155 return 1 if v else 0
156 if isinstance(v, str):
157 return Error("#¡VALOR!")
158 return v
159
160
161def texto(v):
162 if isinstance(v, Error):
163 return v
164 return formatear(v)
165
166
167def clave(v):
168 """Para comparar valores de tipos distintos: números < textos < booleanos."""
169 if v is None:
170 v = 0
171 if isinstance(v, bool):
172 return (2, v)
173 if isinstance(v, str):
174 return (1, v.lower())
175 return (0, v)
176
177
178class Hoja:
179 def __init__(self, celdas):
180 self.celdas = celdas
181 self.memo = {}
182 self.en_curso = set()
183
184 def valor(self, celda):
185 if celda in self.memo:
186 return self.memo[celda]
187 if celda in self.en_curso:
188 return Error("#CIRCULAR!") # se ha vuelto a pedir una celda que se está calculando
189 contenido = self.celdas.get(celda)
190 if contenido is None:
191 return None
192 if not contenido.startswith("="):
193 v = float(contenido.replace(",", ".")) if re.fullmatch(r"-?\d+(,\d+)?", contenido) else contenido
194 else:
195 self.en_curso.add(celda)
196 try:
197 v = self.evaluar(Analizador(contenido[1:]).analizar())
198 except SyntaxError:
199 v = Error("#SINTAXIS!")
200 self.en_curso.discard(celda)
201 if v is None:
202 v = 0
203 self.memo[celda] = v
204 return v
205
206 def valores(self, args):
207 """Los valores de los argumentos de SUMA, PROMEDIO…: en los rangos solo cuentan los números."""
208 lista = []
209 for a in args:
210 if a[0] == "rango":
211 for c in celdas_de(a[1], a[2]):
212 v = self.valor(c)
213 if isinstance(v, Error):
214 return v
215 if isinstance(v, (int, float)) and not isinstance(v, bool):
216 lista.append(v)
217 else:
218 v = numero(self.evaluar(a))
219 if isinstance(v, Error):
220 return v
221 lista.append(v)
222 return lista
223
224 def evaluar(self, n):
225 tipo = n[0]
226 if tipo in ("num", "txt"):
227 return n[1]
228 if tipo == "ref":
229 return self.valor(n[1])
230 if tipo == "rango":
231 return Error("#¡VALOR!")
232 if tipo == "neg":
233 v = numero(self.evaluar(n[1]))
234 return v if isinstance(v, Error) else -v
235 if tipo == "bin":
236 op, a, b = n[1], self.evaluar(n[2]), self.evaluar(n[3])
237 if op == "&":
238 a, b = texto(a), texto(b)
239 for x in (a, b):
240 if isinstance(x, Error):
241 return x
242 return a + b
243 if op in ("+", "-", "*", "/"):
244 a, b = numero(a), numero(b)
245 for x in (a, b):
246 if isinstance(x, Error):
247 return x
248 if op == "/" and b == 0:
249 return Error("#DIV/0!")
250 return {"+": a + b, "-": a - b, "*": a * b}[op] if op != "/" else a / b
251 for x in (a, b):
252 if isinstance(x, Error):
253 return x
254 ka, kb = clave(a), clave(b)
255 return {"=": ka == kb, "<>": ka != kb, "<": ka < kb, ">": ka > kb, "<=": ka <= kb, ">=": ka >= kb}[op]
256 nombre, args = n[1], n[2]
257 if nombre not in FUNCIONES:
258 return Error("#¿NOMBRE?")
259 if nombre == "SI":
260 cond = self.evaluar(args[0])
261 if isinstance(cond, Error):
262 return cond
263 if isinstance(cond, str):
264 return Error("#¡VALOR!")
265 # solo se calcula la rama elegida: =SI(B1=0;0;A1/B1) nunca divide entre 0
266 if cond:
267 return self.evaluar(args[1])
268 return self.evaluar(args[2]) if len(args) == 3 else False
269 if nombre == "CONTAR":
270 total = 0
271 for a in args:
272 celdas = celdas_de(a[1], a[2]) if a[0] == "rango" else None
273 vals = [self.valor(c) for c in celdas] if celdas else [self.evaluar(a)]
274 total += sum(1 for v in vals if isinstance(v, (int, float)) and not isinstance(v, bool))
275 return total
276 if nombre == "REDONDEAR":
277 x, d = numero(self.evaluar(args[0])), numero(self.evaluar(args[1]))
278 for v in (x, d):
279 if isinstance(v, Error):
280 return v
281 return float(Decimal(str(x)).quantize(Decimal(1).scaleb(-int(d)), rounding=ROUND_HALF_UP))
282 vals = self.valores(args)
283 if isinstance(vals, Error):
284 return vals
285 if nombre == "SUMA":
286 return sum(vals)
287 if nombre == "PROMEDIO":
288 return sum(vals) / len(vals) if vals else Error("#DIV/0!")
289 if not vals:
290 return 0
291 return max(vals) if nombre == "MAX" else min(vals)
292
293
294def main():
295 celdas = {}
296 for num, linea in enumerate(sys.stdin.read().splitlines(), 1):
297 if not linea.strip():
298 continue
299 partes = linea.strip().split(" ", 1)
300 nombre = partes[0].upper()
301 if len(partes) < 2 or not CELDA.fullmatch(nombre):
302 print(f"Línea {num}: no se entiende «{linea.strip()}»")
303 continue
304 if nombre in celdas:
305 print(f"Línea {num}: {nombre} se sobrescribe")
306 celdas[nombre] = partes[1].strip()
307 hoja = Hoja(celdas)
308 errores = []
309 for c in sorted(celdas, key=lambda c: (int(c[1:]), c[0])):
310 v = hoja.valor(c)
311 print(f"{c}: {formatear(v)}")
312 if isinstance(v, Error):
313 errores.append(f"{c} {v}")
314 print(f"Celdas con error: {len(errores)} ({', '.join(errores)})" if errores else "Sin errores")
315
316
317main()Cada nivel de la gramática es una función, y la recursividad entre ellas (un paréntesis vuelve a llamar a la comparación) es lo que permite anidar sin límite. Es la misma técnica de los compiladores y de las calculadoras de las hojas de cálculo reales.
Calcular cada celda una sola vez con memoria convierte un recorrido que podría ser exponencial en lineal: una celda de la que dependen cien solo se calcula la primera vez. Las hojas reales mantienen además un grafo de dependencias para recalcular solo lo que cambia.
Que los errores sean valores con nombre (#DIV/0!, #¡VALOR!) es una decisión de diseño de las hojas de cálculo: el error se ve en la celda donde nace y en todas las que dependen de ella, y así se encuentra su origen siguiendo la cadena.
SI con evaluación perezosa permite proteger una fórmula (=SI(B2=0; 0; A2/B2)): en la mayoría de lenguajes de programación, if y los operadores and y or funcionan igual.
Para ir más allá
- Añade la potencia
^(que se asocia por la derecha) y el porcentaje%. - Añade
BUSCARV(valor; rango; columna)con coincidencia exacta. - Recalcula solo las celdas afectadas al cambiar el valor de una, con el grafo de dependencias.