6. Cifras y Letras: Prueba de Numeros

portada

En el capítulo anterior construimos la Prueba de Letras del programa Cifras y Letras. En este capítulo vamos a codificar una versión de la prueba numérica del popular programa de televisión. Las reglas de este juego son las siguientes:

  • Se elige un número al azar entre el 0 y el 999 y se le reparten 5 números al jugador

  • El jugador debe acercarse lo máximo posible al número aleatorio haciendo operaciones matemáticas simples ( +, -, *, /) con los números que le han sido repartidos

  • El tiempo para conseguirlo está limitado

Este es un juego muy particular, porque a pesar de la sencillez de sus reglas, su implementación conlleva resolver varios problemas. Vamos a intentar explicarte paso a paso los módulos que hemos utilizado nosotros para resolverlo. De todas formas, es muy posible que se te ocurran formas más óptimas de implementarlo.

6.1. Reparto de números

Vamos a empezar creando una función llamada get_numbers(array, k) que elija los números con los que se va a jugar. Esta función recibirá los siguientes argumentos de entrada:

  • array variable tipo LIST que contiene los valores de entre los que se obtienen los números del juego

  • k variable tipo INT que contiene la cantidad de números con los que se va a jugar

La función debe devolver una lista con los números elegidos. En el juego original, el número objetivo sólo podía generarse mediante la combinación de los números [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 25, 50, 75, 100]. Para acercarnos lo máximo posible al juego clásico, vamos a configurar esta lista de valores como los valores por defecto del argumento array. De manera que si el jugador no proporciona una lista alternativa, el juego utilizará los mismos números que el programa original.

get_numbers([1,2,3,4,5,6,7,8,9],k=5)
[1, 9, 3, 6, 8]

Solución:

import random

def get_numbers(array=[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 25, 50, 75, 100], k=5):
    return random.sample(array, 5)
get_numbers()
[100, 5, 7, 10, 1]

Note

Hemos utilizado argumentos por defecto para asignar unos valores a las variables de entrada cuando decidamos invocar a la función sin unos valores concretos. Asignar valores por defecto en Python es muy sencillo, basta con asignar el valor en la propia definición de la función.

See also

Puedes encontrar más información sobre el uso de los argumentos por defecto aquí.

6.2. Generar el número objetivo

Una vez que tenemos los números con los que vamos a jugar, necesitamos generar el número objetivo. Vamos a implementar una función llamada generate_operation(values) que utilice los valores devueltos por la función get_numbers() y que mediante la aplicación pseudo-aleatoria de operadores algebraicos sencillos, devuelva el número objetivo.

Esta función debe “memorizar” las operaciones que se han realizado para que al final del juego, si no hemos conseguido dar con la combinación adecuada, el programa nos muestre las operaciones que permiten obtener el número objetivo.

Por esto, la función generate_operation(values) debe devolver dos salidas:

  • el número entero resultado de la operación aleatoria

  • la operación realizada en formato STRING

Note

Recuerda que el resultado de esta operación tiene que ser un número entero positivo por lo que tendrás que tener cuidado con las divisiones y con las restas. Recuerda también que cada número solo se puede utilizar una vez.

Para que la variable STRING con el resultado de la operación no genere dudas, sería recomendable intentar agrupar las operaciones con paréntesis.

La versión que vamos a implementar nosotros, no tiene por qué usar todos los números seleccionados, pero sí debería elegir como mínimo dos ellos para que exista un desafío. Por ejemplo:

print(generate_operation([10, 4, 20, 50, 2]))
('(((10+2)*(4+20))-50)', 238)

Solución:

A la hora de resolver un problema programando, siempre es recomendable dedicarle 5 minutos a visualizar la solución. A veces, este sencillo ejercicio de reflexión, es suficiente para darnos cuenta que el programa podría simplificarse si lo dividimos en módulos de menor complejidad.

A continuación vamos a explicarte una posible solución, pero recuerda que en el mundo de la programación existen muchas maneras de resolver el mismo problema. Nuestro objetivo es generar de manera incremental una secuencia de operaciones que iremos añadiendo a dos variables:

  • secret variable de tipo STRING que vamos aumentando mediante concatenación de nuevas operaciones y nuevos operandos

  • result variable de tipo INT que almacena el número objetivo y vamos actualizando con las nuevas operaciones y nuevos operandos.

La forma en la que se van a ir añadiendo estas operaciones puede responder a dos esquemas:

  • Modelo 1. secret = secret + random_op1 + operand1

  • Modelo 2. secret = secret + random_op1 + (operand1 + random_op2 + operand2)

Para aclararlo, piensa en el siguiente ejemplo. Imagina que la variable secret contiene la siguiente operación:

secret = "(10 + 5)"

Si la aumentamos según el Modelo 1, necesitaríamos un valor aleatorio operand1 y una operación aleatoria random_op1. Por ejemplo:

operand1="3"
random_op1="/"
secret = secret + random_op1 + operand1

Resultando la siguiente operación:

secret
'(10 + 5)/3'

Si la aumentamos de nuevo según el Modelo 2, necesitaríamos dos valores aleatorios operand1 y operand2 y dos operaciónes aleatorias random_op1 y random_op2. Por ejemplo:

operand1="15"
operand2="1"
random_op1="+"
random_op2="*"
secret = secret + random_op1 + "(" + operand1 + random_op2 + operand2+ ")"

Resultando la siguiente operación:

secret
'(10 + 5)+(15*1)'

Estos dos modelos nos va a dar mucha flexibilidad a la hora de aumentar la operación pseudo-aleatoria secret para generar el número objetivo.

El primer paso, por tanto, es agrupar los valores devueltos por la función get_numbers() en conjuntos de uno o dos números. Para ello hemos implementado la función group(values) que hace uso de la función random.randint(0,1) para decidir si realizamos una agrupación de un número o de dos (siempre y cuando queden números suficientes).

import random
def group(values):
    operands=values.copy()
    group_list=[]
    while operands:
        if len(values)==len(operands):
            ops=random.sample(operands,2)
        elif random.randint(0,1) ==0 and len(operands)>=2:
            ops=random.sample(operands,2)
        else:
            ops=random.sample(operands,1)
        
        group_list.append(ops)
        for i in ops: 
            operands.remove(i)
    return group_list
valores=[10, 4, 20, 50, 2]
agrupa(valores)
[[50, 4], [2], [20, 10]]

Note

Este código es particularmente interesante por varios motivos. Notar que la primera instrucción de la función crea una copia de la variable de entrada haciendo:

operandos=valores.copy()

Esta línea crea una lista operands idéntica en contenido a values. El propósito es ir eliminando los números que vayamos agrupando de la lista operands pero manteniendo intacta la lista original values.

Por otro lado, la manera de detectar que hemos terminado de agrupar todos los elementos de la lista operands es mediante la condición:

while operandos:

Que es equivalente a:

while operandos!=None:

En muchas ocasiones cuando las condiciones son sencillas y sólo buscan chequear si una variable contiene o no un valor, se pueden construir de manera compacta. Si no te sientes cómodo o se te complica la lectura del condicional, puedes seguir construyendo la condición completa.

Por último, nota que la primera condición del bloque if/elif/else pretende forzar que la primera agrupación siempre sea del tipo Modelo 2.

Una vez que tenemos agrupados los numeros con los que vamos a jugar ya podemos empezar a pensar en la manera de añadir operaciones aleatorias. Esta parte es probablemente la más complicada porque no todas las operaciones están permitidas si tenemos presente que el número objetivo no puede ser ni decimal ni negativo. Por esto, tenemos que estudiar cuidadosamente si la operación aleatoria que queremos añadir cumple estas condiciones.

Vamos a empezar estudiando el Modelo 1, por ser más sencillo. Para ello vamos a implementar un par de funciones:

  • compute_1_operand(result,operand,operation). Esta función va a realizar el cálculo presuponiendo que la operación propuesta es válida. La salida es el valor numérico resultante de aplicar la nueva operación y el nuevo operando al valor del número objetivo. Sus argumentos de entrada son los siguientes:

    • result variable INT en la que vamos a recibir el valor del número objetivo que hemos construido hasta el momento

    • operand variable INT en la que vamos a recibir el número de la lista values que vamos a utilizar para aumentar la operación secreta

    • operation variable STRING que contiene la operación algebráica que vamos a utilizar para aumentar la operación secreta

  • verify_1_operand(result,operand,operation). Esta función valida un aumento de la operación pseudo-aleatoria de acuerdo al Modelo 1. La validación es muy sencilla. Basta con amplicar la función compute_1_operand y verificar si el valor devuelto es negativo o decimal. Si el número es negativo, entonces la operación propuesta (una resta) no puede ser validada. Si el valor devuelto es decimal, entonces la operación propuesta (una división) no puede ser validada. En ambos casos, la función debe devolver False. En el resto de las situaciones, la operación se valida y la función devuelve True. Los argumentos de entrada de esta función son los mismos que los de la función compute_1_operand.

def compute_1_operand(result,operand,operation):
    if operation=="-":
        out=result-operand
    elif operation=="+":
        out=result+operand
    elif operation=="*":
        out=result*operand
    else:
        out=result/operand
    return out   

Podemos comprobar su funcionamiento:

compute_1_operand(10,30,"*")
300
compute_1_operand(10,30,"/")
0.3333333333333333
compute_1_operand(10,30,"+")
40
compute_1_operand(10,30,"-")
-20

Note

Si trabajamos siempre con números enteros, la única circunstancia que puede resultar en un número decimal es que proceda de una división en la que el numerador no es divisible por el denominador.

Con esta función implementada, la verificación es muy sencilla:

def verify_1_operand(result,operand,operation):
    if compute_1_operand(result,operand,operation) < 0 or \
       isinstance(compute_1_operand(result,operand,operation),float):
        return False
    else:
        return True

Si reevaluamos los ejemplos anteriores vemos que la operación suma y la multiplicación está permitidas:

verify_1_operand(result = 10, operand = 30, operation = "+")
True
verify_1_operand(result = 10, operand = 30, operation = "*")
True

Por el contrario, si en lugar de una suma o una multiplicación ampliamos el número objetivo con una división o una resta, el resultado no se puede validar porque generarían números negativos o decimales:

verify_1_operand(result = 10, operand = 30, operation = "/")
False
verify_1_operand(result = 10, operand = 30, operation = "-")
False

El siguiente paso es estudiar el Modelo 2. Para ello también vamos a necesitar dos funciones:

  • compute_2_operands(result,operand1,operand2,operation1,operation2). Esta función va a actualizar el valor del número objetivo. La salida es el valor numérico resultante de aplicar las nuevas operaciones y los nuevos operadores al valor del número objetivo y sus argumentos de entrada son:

    • result variable de tipo INT que contiene el valor del número objetivo que hemos construido hasta el momento

    • operand1 y operand2 son dos variables de tipo INT en las que vamos a recibir los dos números de la lista values que vamos a utilizar para aumentar la operación secreta

    • operation1 y operation2 son dos variables de tipo STRING que contienen las dos operaciones algebráicas que vamos a utilizar para aumentar la operación secreta

  • verify_2_operands(result,operand1,operand2,operation1,operation2). Esta función valida un aumento de la operación pseudo-aleatoria de acuerdo al Modelo 2. La salida es, nuevamente, una variable booleana que indica si la operación se puede ampliar o no. Los argumentos son los mismos que los descritos en la función compute_2_operand

def compute_2_operands(result,operand1,operand2,operation1,operation2):
    if operation1=="-":
        out=result-calcula_1_operando(operand1,operand2,operation2)
    elif operation1=="+":
        out=result+calcula_1_operando(operand1,operand2,operation2)
    elif operation1=="*":
        out=result*calcula_1_operando(operand1,operand2,operation2)
    else:
        out=result/calcula_1_operando(operand1,operand2,operation2)
    return out  

Podemos comprobar su funcionamiento evaluando las siguientes expresiones:

  • \(30+(10+30)\)

compute_2_operands(result = 30, operand1 = 10, operand2 = 30, operation1 = "+", operation2 = "+")
70
  • \(30/(10-30)\)

compute_2_operands(result = 30, operand1 = 10, operand2 = 30, operation1 = "/", operation2 = "-")
-1.5
  • \(30+(10-30)\)

compute_2_operands(result = 30, operand1 = 10, operand2 = 30, operation1 = "+", operation2 = "-")
10
  • \(30*(10/30)\)

compute_2_operands(result = 30, operand1 = 10, operand2 = 30, operation1 = "*", operation2 = "/")
10.0

De los ejemplos anteriores puedes comprobar que la función realiza su tarea correctamente. También puedes comprobar que la validación de las operaciones que ampliamos según el Modelo 2 no es tan sencilla como verificar el resultado. Por ejemplo, la operación \(30+(10+30)\) es completamente válida, y su resultado entero positivo así lo confirma. Por otro lado, la operación \(30/(10-30)\) no pasa la validación, doblemente, porque el resultado es un número decimal y además negativo. Pero ¿qué ocurre con los otros dos ejemplos? La operación \(30+(10-30)\) devuelve como resultado un número entero positivo. A priori podría parecer que responde a una secuencia de operaciones válida, pero no debería ser así ya que la operación que se corresponde con la resta \((10-30)\) devuelve un número negativo. Por último, en la operación \(30*(10/30)\), vemos que el resultado es positivo y además no tiene decimales, podríamos convertirlo a tipo entero y sería un resultado válido. Sin embargo, esta operación no debería ser aceptada porque la división \((10/30)\) tampoco cumple las normas.

Esto complica ligeramente el código de la función verify_2_operands, pero no mucho más.

def verify_2_operands(result,operand1,operand2,operation1,operation2):
    if compute_1_operand(operand1,operand2,operation2) < 0 or \
       isinstance(compute_1_operand(operand1,operand2,operation2),float):
            return False
    result2=compute_1_operand(operand1,operand2,operation2)
    if compute_1_operand(result,result2,operation1) < 0 or \
        isinstance(compute_1_operand(result,result2,operation1),float):
            return False
    
    return True

Podemos probar que nuestro código funciona verificando las operaciones anteriores:

  • \(30+(10+30)\)

verify_2_operands(result = 30, operand1 = 10, operand2 = 30, operation1 = "+", operation2 = "+")
True
  • \(30/(10-30)\)

verify_2_operands(result = 30, operand1 = 10, operand2 = 30, operation1 = "/", operation2 = "-")
False
  • \(30+(10-30)\)

verify_2_operands(result = 30, operand1 = 10, operand2 = 30, operation1 = "+", operation2 = "-")
False
  • \(30*(10/30)\)

verify_2_operands(result = 30, operand1 = 10, operand2 = 30, operation1 = "*", operation2 = "/")
False

De las cuatro operaciones anteriores la única permitida es la primera, ya que la segunda genera un denominador negativo, la tercera un sumando igualmente negativo y la cuarta un multiplicando decimal.

Ya casi hemos terminado. Hemos implementado las funciones necesarias para verificar si las operaciones aleatorias que generemos cumplirán las normas del juego. También hemos implementado las funciones para evaluar esas operaciones. Sólo nos hacen falta las funciones para construir la variable STRING secret con la operación pseudo-aleatoria.

Esta variable se actualiza de manera diferente dependiendo de si la ampliamos con operaciones del tipo del Modelo 1 o del Modelo 2. A modo de recordatorio:

  • Modelo 1. secret = secret + random_op1 + operand1

  • Modelo 2. secret = secret + random_op1 + (operand1 + random_op2 + operand2)

Para resolver este problema vamos a crear una función de ampliación de la veriable secret para cada modelo:

  • extend_1_operand(secret, operand, operation)

  • extend_2_operands(secret, operand1, operand2, operation1,operation2)

Los argumentos de entrada son:

  • secret es una variable tipo STRING en la que vamos concatenando las nuevas operaciones

  • operand, operand1, operand2 son las variables de tipo STRING en las que vamos a recibir los números de la lista values que vamos a utilizar para aumentar la operación secreta

  • operation, operation1, operation2 son las variables de tipo STRING con las operaciones algebráicas que vamos a utilizar para aumentar la operación secreta

def extend_1_operand(secret, operand, operation):
    secret="("+secret+operation+operand+")"
    return secret

def extend_2_operands(secret, operand1, operand2, operation1,operation2):
    if len(secret)==0:
        secret=secret+"("+operand1+operation2+operand2+")"
    else:
        secret="("+secret+operation1+"("+operand1+operation2+operand2+")"+")"
    return secret
extend_1_operand(secret = "10", operand = "30", operation = "/")
'(10/30)'
extend_2_operands(secret = "30", operand1 = "10", operand2 = "30", operation1 = "+", operation2 = "+")
'(30+(10+30))'

Tip

Podríamos haber introducido en la solución un mecanismo de chequeo del tipo de las variables. Es decir, las funciones compute_1_operand o compute_2_operands requiren que los operandos sean tipos INT, mientras que las funciones extend_1_operand y extend_2_operands requiren que sean STRING. Podríamos introducir un código que hiciera uso de la funcion isinstance para asegurarnos que los argumentos se le pasan adecuadamente y en caso contrario, convertirlos o mostrar un mensaje de error.

See also

Existen lenguajes de programación, como Java, en el que hay que especificar el tipo de las variables que se le pasan a las funciones. Este tipo de lenguajes se conocen como Lenguajes de tipado fuerte. Puedes encontrar más información aquí.

Finalmente, ya podemos implementar la función generate_operation(values) que genera las operaciones a partir de los valores devueltos por la función get_numbers()

def generate_operation(values):
    groups=group(values)
    operators=["+","-","*","/"]
    operand1, operand2=groups[0]
    operation=operators[random.randint(0,3)]
    while not verify_1_operand(operand1,operand2,operation):
        operation=operators[random.randint(0,3)]
    secret=extend_1_operand(str(operand1), str(operand2), operation)
    result=compute_1_operand(operand1,operand2,operation)
    
    for g in groups[1:]:
        if len(g)==1:
            operand=g[0]
            operation=operators[random.randint(0,3)]
            while not verify_1_operand(result,operand,operation):
                operation=operators[random.randint(0,3)]
            secret=extend_1_operand(secret, str(operand), operation)
            result=compute_1_operand(result,operand,operation)
        else:
            operand1=g[0]
            operand2=g[1]
            operation1=operators[random.randint(0,3)]
            operation2=operators[random.randint(0,3)]
            while not verify_2_operands(result,operand1,operand2,operation1,operation2):
                operation1=operators[random.randint(0,3)]
                operation2=operators[random.randint(0,3)]
            secret=extend_2_operands(secret, str(operand1), str(operand2), operation1,operation2)
            result=compute_2_operands(result,operand1,operand2,operation1,operation2)
    return secret, result

Note

El código parece largo, pero es bastante sencillo de entender. El primer bloque genera la primera operación, que siempre es del tipo Modelo 2, inicializando las variables secret y result. El resto del código se limita a ir recorriendo grupo a grupo implementando una ampliación de tipo Modelo 1 o Modelo 2 dependiendo de la longitud del grupo correspondiente.

En todos los bloques se implementa un bucle while del que sólo se sale cuando la función de verificación ha devuelto True.

values = get_numbers(array=[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 25, 50, 75, 100], k=5)
print("Numbers to play: ",values)
secret, result = generate_operation(values)
print("The target number is: ", result)
print("The target operation is: ", secret)
Numbers to play:  [3, 100, 5, 75, 7]
The target number is:  26
The target operation is:  (((100+5)-(7+75))+3)

Tip

Si ejecutamos varias veces el código, podemos ver que la operación de división es poco frecuentemente. Esto es devido a que la condición de divisibilidad es más complicada de conseguir. Podemos forzar, de una manera muy sencilla, que nuestro código pruebe con más frecuencia la operación de división.

operadores=["+","-","*","/",,"/","/","/","/","/","/"]  
operacion=operadores[random.randint(0,len(operadores)-1)]

Aún así, aunque reforcemos la aparición de la división, es probable que aunque se pruebe más frecuentemente siga siendo descartada igual de frecuentemente porque evitar números decimales siendo la condición más complicada de validar.

6.3. Lectura de la jugada

Una vez que el jugador es informado de la lista de valores con la que va a jugar y del número objetivo, debe introducir su jugada en forma de secuencia de operaciones aritméticas en formato STRING y utlizando paréntesis para evitar malentendidos con las prioridades de los operadores.

Para favorecer la simplicidad del código, vamos a obviar la necesidad de verificar si el usuario ha utilizado algún valor diferente de los valores propuestos o si los ha utilizado más de una vez.

Tip

El secreto de la implementación de este apartado está en llamar a la función eval. Esta función es capaz de evaluar una operación algebráica codificada en formato STRING.

See also

Puedes consultar la documentación de la función eval aquí

play = input("Which is your proposed mathematical operation?: ")
result = eval(play)
print("Your operation produces the result: ", result)
Which is your proposed mathematical operation?: (1+8)/3
Your operation produces the result:  3.0

6.4. Puntuación

Para poder competir con diferentes jugadores, hace falta una manera de puntuar. Nosotros nos hemos decantado por el siguiente criterio:

  • Si el jugador consigue el número exacto, conseguirá 8 puntos

  • Si se queda a un número del valor objetivo, conseguirá 6 puntos

  • Si se queda a dos números del valor objetivo, conseguirá 5 puntos, y así sucesivamente

  • El juegador obtiene 0 puntos si se quedan a una diferencia mayor de 6 números del valor objetivo

Para ello vamos a programar una función scoring(number, target) que recibe los argumentos:

  • number es el valor obtenido por parte del jugador

  • target es el valor objetivo

La función sólo devuelve un valor de tipo INT que contiene la puntuación obtenida por el jugador.

Solución:

def scoring(number, target):
    difference = abs(number - target)
    if difference == 0:
        return 8
    elif difference > 6:
        return 0
    else:
        return 7 - difference
scoring(100,100)
8
scoring(98,100)
5
scoring(92,100)
0

6.5. Programa principal

A estas alturas ya disponemos de todas las piezas para implementar el juego completo. Esta es la parte más divertida de programar. Vamos a montar el juego completo utilizando cada una de los módulos implementados.

Solución:

def main():
    values = get_numbers(array=[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 25, 50, 75, 100], k=5)
    secret, target = generate_operation(values)
    print("Target number: ", target)
    print("You can use those values: ",values)
    play = input("Enter your proposed mathematical operation: ")
    result = eval(play)
    score = scoring(result, target)
    if result==target:
        print("Congratulations! You got: {} points".format(score))
    elif score>0:
        print("Not bad! You got {} points".format(score))
        print("This was the solution: ",secret)
    else:
        print("Too far, you got 0 points")
        print("This was the solution: ",secret)     
main()
Target number:  496
You can use those values:  [100, 1, 3, 2, 4]
Enter your proposed mathematical operation: (100*(3+2))-4
Congratulations! You got: 8 points

6.6. Extensiones del Juego

Este es un ejemplo muy evidente de una aplicación que puede resolverse de múltiples maneras. Te animamos a que lo resuelvas de una manera más imaginativa en el que las operaciones de resta y división sean tran frecuentes como las de suma y multiplicación. Aquí tienes otras ideas de mejoras que puedes intentar implementar:

  • Una etapa previa en la que el jugador pueda seleccionar un nivel de dificultad. La elección del nivel de dificultad afectará fundamentalmente el número de operandos

  • Añadir puntos extra a la puntuación del jugador en caso de que se utilice la lista completa de números

  • Un interfaz de usuario más agradable. Utilizando diferentes tipos de fuente y diferentes tamaños o colores

  • Un juego cronometrado. De manera que los puntos también dependan de la velocidad con la que se resuelva cada partida.