Apuntes DAM
Volver al inicio

Hoja de cálculo en Python: fórmulas, funciones, rangos y referencias circulares

Ejercicio de PythonMuy difícilUnos 120 minutos

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

  1. 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!.
  2. 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.
  3. Las comparaciones dan VERDADERO o FALSO; 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).
  4. SUMA, PROMEDIO, MAX y MIN reciben 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!. PROMEDIO sin números da #DIV/0! y MAX o MIN sin números, 0. CONTAR cuenta los números de sus argumentos y rangos (sin propagar errores).
  5. 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 que Hoja.valor devuelva 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

Funciones
FunciónQué 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.

python
def suma(self):
    izq = self.producto()
    while self.mirar()[1] in ("+", "-"):
        op = self.tomar()[1]
        izq = ("bin", op, izq, self.producto())
    return izq
2. 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.

🐍PythonHoja de cálculo en Python: fórmulas, funciones, rangos y referencias circularesMuy difícil

Ejemplo

Entrada (lo que se escribe por teclado)
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 esperada
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
⏳
Test oculto #3
⏳
Test oculto #4
0/4 tests pasados · pulsa un test para ver su entrada y su salida esperada

Solución explicada

Ver la solución completa
python
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.

Dónde se explica