Introducción a la incompletitud · Capítulo 3
El programa que no puede existir
Formalizamos la noción de problema computacional, introducimos la decidibilidad, la semidecidibilidad y las reducciones de Turing, y demostramos la no decidibilidad del problema de la parada mediante un argumento diagonal autorreferencial. Por el camino se establecen varios problemas no decidibles relacionados — ParadaEnVacío y AdivinaConsistente — que serán las herramientas técnicas directas de la demostración de incompletitud.
La ciencia de la computación no trata sobre ordenadores de la misma manera en que la astronomía no trata sobre telescopios.
En el capítulo anterior, mencionamos que las máquinas de Turing y los programas en Python son equivalentes en términos de los «problemas» que pueden resolver. En este capítulo, definiremos formalmente la noción de problema y nos adentraremos en uno de los ejes centrales de la teoría de la computación: la decidibilidad (Sección 3.1).
A continuación, procederemos a introducir la máquina universal (Sección 3.2), un concepto que introdujo Turing al presentar su modelo de computación2. Esta máquina nos servirá para encontrar problemas no decidibles (Sección 3.3). Finalmente, introduciremos el concepto de reducción (Sección 3.5), que nos permitirá encontrar aun más problemas no decidibles.
Haremos todo esto para deducir el resultado principal de este capítulo: la no decidibilidad del problema de la parada (Sección 3.6). Este problema es extremadamente relevante, no solo a nivel histórico, sino porque será uno de los pilares de la demostración principal de este trabajo, que haremos en el Capítulo 5.
3.1 Problemas decidibles
Comenzamos, como anticipamos, definiendo de forma precisa qué es un problema.34
Un problema computacional (o problema)5 es una función , donde:
- es el conjunto de entradas. Un elemento se llama una entrada.
- es el conjunto de soluciones. Un elemento se llama una solución.
Dado un llamamos a la solución de .
Un problema no es más que una función que mapea entradas a salidas, a las que llamamos soluciones. Cualquier problema puede reducirse a esta terminología, por muy complejo que sea. Veamos la primera descripción de un problema.
- Entrada: un grafo .
- Solución: los ciclos hamiltonianos presentes en .
Vamos a definir los conjuntos de entrada y salida del Problema 3.2. La entrada a nuestro problema es un grafo , que no es más que un conjunto de vértices y una matriz de adyacencia representando sus arcos (). Como salida, tendremos un conjunto de caminos en , (es posible que un grafo tenga ningún, uno o varios ciclos hamiltonianos). Podemos especificar los caminos como subconjuntos ordenados de .
Para probar los resultados de nuestro trabajo, utilizaremos un subconjunto de problemas: aquellos cuya salida es únicamente sí o no. Los llamaremos problemas de decisión.
Dado un alfabeto , un problema de decisión6 es un problema computacional en el que .
Cualquier problema computacional puede «transformarse» en un problema de decisión preguntándonos por la existencia de soluciones. Un ejemplo de esto lo vemos en el Problema 3.4.
- Entrada: codificación de un grafo .
- Solución: si hay al menos un ciclo hamiltoniano en .
Este problema es de decisión, pues no pregunta explícitamente por cuáles son los ciclos hamiltonianos presentes en un determinado grafo, sino por si existe un ciclo hamiltoniano en él. Evidentemente, estos problemas son más restrictivos que los problemas computacionales generales, pero tienen mucho más interés a la hora de hablar de qué problemas pueden computarse, como veremos más adelante.
En el caso de los problemas de decisión, tiene sentido hablar de su complementario.
Dado un problema de decisión , definimos su problema complementario (o problema contrario), denotado , como:
En algunas ocasiones, usaremos la notación C- para referirnos al problema complementario: dado un problema , llamaremos a su problema contrario.
Para continuar, es necesario comentar algunas particularidades de los programas en Python para tratar con problemas. Como bien sabemos, los programas sólo aceptan strings como entrada y strings como solución. Podemos pensar en un programa en Python como en una función
donde es el conjunto de todos los strings posibles en Python. Dependiendo de la versión de Python, este conjunto será mayor o menor.7
Para que un ordenador pueda resolver un problema, necesitaremos codificar las entradas y las soluciones del problema mediante un formato que pueda entender: las cadenas de caracteres (strings). Tal y como se demuestra en la Proposición 3.6, para cualquier problema podremos suponer que las entradas y soluciones están codificadas mediante strings.
Para cada problema , existe un problema equivalente .
Demostración
Si queremos resolver un problema de ordenador, deberemos codificar las entradas y las salidas mediante un formato que pueda entender. La idea es poder traducir un problema a otro problema de strings, de modo que procese la misma función para entradas correctamente codificadas, y se devuelva no en caso de que la codificación de entrada sea incorrecta.
Para formalizar este concepto convenientemente, definiremos los conjuntos y . A lo llamaremos el elemento error.
Expandiremos el problema para aceptar el elemento error como entrada, de modo que su solución sea el elemento error. Lo llamaremos , definido como:
Sea la función que, para cada codificación en , asigna la entrada de correspondiente. Si la codificación es inválida, asigna .
Sea la función que, para cada solución de , asigna la codificación en correspondiente. Para la solución error , asigna la codificación no. Del mismo modo que para las entradas, también podemos definir , que para cada codificación de una solución en , obtiene su solución en . Para codificaciones inválidas, asigna .
Ahora estamos en condiciones de definir .
El problema resuelve el mismo problema que , y es capaz de tratar con entradas erróneas. Basta ver que, si es una codificación correcta de una entrada (es decir, ), entonces:
siendo la codificación de , . Dada la codificación de la solución , es fácil obtener la solución .
En el caso de que sea una codificación de entrada incorrecta, será
Esto no supone una restricción: las entradas y las soluciones pueden ser codificadas como cadenas de caracteres. Deberemos, sin embargo, tener en cuenta que es posible que haya entradas que no sean una codificación correcta de una entrada del problema original. En tal caso, asignaremos la solución no a tal entrada.
Vemos un ejemplo de esto en el Problema 3.7. Este problema tendrá asociada una cierta codificación para las entradas y las soluciones. Observa que este problema es idéntico al Problema 3.2, y que podemos traducir las entradas y las soluciones de uno y otro conociendo la codificación elegida.
- Entrada: codificación de un grafo .
- Solución: codificación de los ciclos hamiltonianos presentes en .
En el caso de los problemas de decisión, las soluciones siempre serán sí y no. Nótese que no será solución tanto para las instancias negativas del problema como para las codificaciones incorrectas.
De aquí en adelante, todos los problemas tendrán cadenas de caracteres como entradas y soluciones.
A continuación, concretaremos la relación entre programas y problemas que ya veníamos intuyendo desde el Capítulo 2.
Dado un problema y un programa , diremos que resuelve si, para cada entrada , para y devuelve un string tal que .
Si es un problema de decisión resuelto por , diremos que decide .
Un problema computacional describe lo que queremos resolver. Un programa describe la forma particular de computar la solución a un problema.
Es muy importante destacar que, en la definición anterior, es necesario que pare para todas las entradas. O, lo que es lo mismo, si cicla para alguna entrada, no resuelve . Esta condición puede relajarse dando lugar a problemas semidecidibles (véase la Sección 3.4).
En el caso de los problemas de decisión, esta característica de «resolver» nos conduce a la definición de computabilidad.
Sea un problema computacional. Si existe un programa que lo resuelve, decimos que el problema es computable.
En el caso en el que sea un problema de decisión, si existe un programa que lo decide decimos que el problema es decidible.
Veamos un problema más sencillo que el Problema 3.2. El Problema 3.10, , tiene como entrada una palabra del alfabeto , y como solución sí en caso de que la palabra tenga más a que b, y no en caso contrario o en el caso de que la codificación sea incorrecta. Para este problema, la codificación es incorrecta si la entrada es una cadena de caracteres que no conforma una palabra del alfabeto .
Evidentemente, es un problema de decisión. Ahora nos queda preguntarnos si este problema es decidible. La respuesta es afirmativa, y se demuestra en detalle en la Proposición 3.11.
- Entrada: una cadena de caracteres del alfabeto .
- Salida: si la cadena de caracteres tiene más
aqueb.
Si queremos saber si el problema es decidible, deberemos escribir un programa que lo resuelva y nunca cicle, y que tenga en cuenta las codificaciones incorrectas. Esto es precisamente lo que hace el programa a continuación:
import utilidades
def mas_a_que_b(palabra):
# comprobamos si la codificación es correcta, es decir (si es del alfabeto {'a', 'b'}), si no es correcta devolvemos 'no'
if not utilidades.en_alfabeto(palabra, {'a', 'b'}):
return 'no'
if palabra.count('a') > palabra.count('b'):
return 'sí'
return 'no'Explicamos el programa brevemente. Vemos en la función mas_a_que_b la función main del programa, que acepta una palabra como entrada. Comprobamos que la palabra pertenezca al alfabeto mediante la función en_alfabeto de la librería utilidades (que importamos al comienzo del archivo). En caso de que la palabra no pertenezca al alfabeto, devolvemos no, pues la codificación es incorrecta. Si pertenece al alfabeto, comprobamos si tiene más a que b. En caso de ser así, devuelve sí y, si no, devuelve no. Vemos algunas de las salidas del programa mas_a_que_b.py en la tabla siguiente.
| Comando | Salida |
|---|---|
mas_a_que_b('abaab') |
sí |
mas_a_que_b('abbab') |
no |
mas_a_que_b('') |
no |
mas_a_que_b('abaabc') |
no |
mas_a_que_b.pyDado que hemos encontrado un programa mas_a_que_b.py que decide , el problema es decidible.
El problema es decidible.
Demostración
El Programa 3.1 decide . En efecto, vemos que el programa para para todas las entradas y devuelve:
- Si , es (entrada con codificación incorrecta).
- Si :
- Si tiene más
aqueb, es . - Si no tiene más
aqueb, es .
Una nota respecto a nuestra definición de decidibilidad. Normalmente, en los cursos de teoría de la computación, esta definición se hace respecto a máquinas de Turing. Sin embargo, al haber probado la equivalencia con los programas en Python en el Capítulo 2, podemos definirlo directamente en términos de programas. Según la definición de máquinas de Turing, sin embargo, también podríamos probar que es decidible: basta ver que la máquina del Ejemplo 2.11 decide el problema, pues nunca cicla y acepta las palabras que tienen más que .
3.2 Universalidad
En la Sección 2.3 vimos cómo podemos simular una máquina de Turing mediante un programa en Python. Pero, ¿es posible simular un programa en Python mediante un programa en Python? La respuesta es sí, y no debería resultar sorprendente, pues muchas de las operaciones que ejecutamos en el día a día en nuestros ordenadores involucran este tipo de simulaciones.
Por ejemplo, al ejecutar un programa en Python cualquiera, estamos ejecutando el intérprete de Python, que es un programa en sí mismo.
Esta idea de «programas que ejecutan programas» puede parecer obvia, pero fue realmente revolucionaria, y es el motivo por el que los ordenadores personales son hoy la norma, llegando a encontrarse hasta en nuestros bolsillos. Quizás la cosa más increíble que puede hacer un ordenador es ejecutar cualquier cosa — no simplemente un conjunto de programas fijos preinstalados por el fabricante.8
Para entender cómo esto puede ser posible en el caso de un programa en Python, deberemos de entender el funcionamiento de la función nativa exec9. Si probamos en el intérprete:
>>> exec("print('resultado:', 11+2)")
resultado: 13
Vemos cómo a exec pasamos una sentencia en Python en forma de string, y la ejecuta. De hecho, observa cómo ha ejecutado la suma , y no simplemente ha imprimido 11+2.
Esto podemos llevarlo un paso más allá: dentro de exec podemos insertar varias líneas e incluso definir funciones, variables, etc.
>>> exec("def hola(nombre):\n\tprint(f'Hola, {nombre}')\nhola('TFG')")
Hola, TFG
Una vez conocemos esto, podemos crear el siguiente programa:
import utilidades
def maquina_universal(programa, entrada):
# esto define las funciones del programa, pero no las ejecuta
try:
exec(programa)
except Exception as excepcion:
return 'error: ' + str(excepcion)
# extraemos una referencia a la función main, que está definida localmente
main = utilidades.extraer_main(programa, locals())
# invocamos la función
return main(entrada)Veamos qué es lo que hace el Programa 3.2. En primer lugar, vemos que la función main admite dos parámetros: un programa y una entrada, ambos string. Ejecutamos el programa definido en programa, tal y como explicamos anteriormente. Esta línea está dentro de un bloque try-except dado que es posible que el programa esté mal definido, en cuyo caso se lanza una excepción. Esta excepción la cazamos, dado que según la Definición 2.2, el programa no debe lanzar ninguna excepción. De haber una excepción, devolvemos un string con los detalles del error.
Si no hay error, exec(programa) se ejecutará satisfactoriamente, y las funciones que se definan en programa serán accesibles desde la función maquina_universal. En concreto, todas las definiciones de programa serán accesibles desde locals(). Aprovechamos esto para extraer una referencia a la función main de programa, que finalmente ejecutamos con la entrada entrada.
Hemos logrado crear un programa en Python que es capaz de ejecutar otro programa. Esto nos permitirá ejecutar cualquiera de los problemas que hemos creado, sin más que pasar el programa a un string. En la librería utilidades se encuentra la función leer, que dada una ruta a un archivo devuelve sus contenidos como un único string.
Algunos ejemplos de ejecución de maquina_universal.py se especifican en la tabla siguiente.
| Comando | Salida |
|---|---|
maquina_universal('no es un programa', 'una entrada cualquiera') |
'error'... |
maquina_universal(leer('./mas_a_que_b.py'), 'abaab') |
sí |
maquina_universal(leer('./mas_a_que_b.py'), leer('./mas_a_que_b.py')) |
no |
maquina_universal(leer('./mas_a_que_b_v2.py'), leer('./mas_a_que_b.py')) |
sí |
maquina_universal(leer('./si.py'), leer('./mas_a_que_b.py')) |
sí |
maquina_universal(leer('./simula_turing.py'), utilidades.MAU(leer('./maquinas_turing/mas_a_que_b.mt'), 'abaab')) |
configuración final aceptante |
maquina_universal.pyEn la primera fila podemos ver que, al insertar un programa inválido como entrada, la salida de maquina_universal.py será de error. En la segunda fila, ejecutamos el programa mas_a_que_b.py con la entrada abaab. El resultado es exactamente el esperado (observa la primera fila de la tabla de salidas de anterior).
En la tercera fila, usamos el propio código de mas_a_que_b.py como entrada para sí mismo. Esto tiene sentido, dado que un programa, como hemos comentado, no es más que un string. Recuerda que el Programa 3.1 trata entradas fuera del alfabeto como una entrada incorrecta, y es por ello por lo que la salida es no. Modificamos esto en el Programa 3.3, en el que aceptamos cualquier entrada y contamos su número de a y b. Vemos cómo, en la cuarta fila, la salida es sí, dado que en este caso la codificación es correcta.
def mas_a_que_b_v2(palabra):
if palabra.count('a') > palabra.count('b'):
return 'sí'
return 'no'En la quinta fila, utilizamos el Programa 3.4. Este programa siempre devuelve sí, independientemente de la entrada que pasemos. Evidentemente, este programa es el que decide el Problema 3.12 (es decir, es decidible).
def si(entrada):
# siempre devuelve 'sí'
return 'sí'- Entrada: una cadena de caracteres cualquiera.
- Salida:
sí.
En la sexta y última fila vemos cómo maquina_universal.py puede ejecutar cualquier programa en Python, incluso el Programa 2.2, que simula máquinas de Turing. Como entrada al programa pasamos la codificación de una máquina de Turing y la entrada. En este caso, la salida describe una configuración final aceptante.
Esta capacidad de los programas de ejecutarse a sí mismos es extremadamente interesante y es una característica que nos causará muchos problemas, como veremos en la Sección 3.3.
Llamamos a una máquina universal si es capaz de simular cualquier máquina de Turing, dado que este concepto lo introdujo Turing en su famosa publicación2. Por el resultado de equivalencia (Teorema 2.23), vemos que el Programa 3.2 es, en efecto, una máquina universal (algo que podíamos intuir con el nombre del programa).
Como vimos al principio, el concepto de universalidad es una constante en los ordenadores que usamos: el hardware es universal, porque puede ser usado para ejecutar cualquier máquina de Turing, del mismo modo que lo es un sistema operativo, pues puede compilar programas que simulan máquinas de Turing. El intérprete de Python también es universal por este mismo motivo, así como la máquina virtual de Java. Incluso un programa en sí mismo (Programa 3.2) puede ser universal.
3.3 Problemas no decidibles
En la Sección 3.1 mostramos cómo probar que un problema es decidible, lo cual es sencillo: basta encontrar un programa que lo decida. Sin embargo, probar que un problema no lo es es mucho más complicado. En este apartado encontraremos varios problemas no decidibles.
- Entrada: un programa y una entrada .
- Salida:
sísi está bien definido y ,noen caso contrario.
Comencemos por el problema .10 Claramente, se trata de un problema de decisión, pues devuelve sí o no. En esta sección, probaremos que escribir un programa que decida es imposible. En caso de que fuese posible hacerlo, vemos en la tabla siguiente las posibles salidas que tendría.
| Comando | Salida |
|---|---|
universal('no es un programa', 'una entrada cualquiera') |
no |
universal(leer('./mas_a_que_b_v2.py'), 'aaba') |
sí |
universal(leer('./mas_a_que_b_v2.py'), leer('./mas_a_que_b_v2.py')) |
sí |
universal(leer('./si.py'), 'aaba') |
sí |
universal(leer('./si.py'), leer('./si.py')) |
sí |
universal.pyEn la primera fila vemos cómo, para codificaciones incorrectas, el programa devuelve no. Las filas siguientes son evidentes, y similares a las de la tabla de maquina_universal.py.
Introduciremos dos problemas más. El primero de ellos, , podemos obtenerlo a partir de (Problema 3.13) con .
- Entrada: un programa .
- Salida:
sísi está bien definido y ,noen caso contrario.
En caso de existir un programa que decida este problema, vemos en la tabla siguiente las posibles salidas que tendría.
| Comando | Salida |
|---|---|
diagonal('no es un programa') |
no |
diagonal(leer('./mas_a_que_b_v2.py')) |
sí |
diagonal(leer('./si.py')) |
sí |
diagonal(leer('./diagonal.py')) |
¿ sí o no ? |
diagonal.pyLas tres primeras salidas no requieren más explicación. Sin embargo, centrémonos en la última: ¿cuál sería el resultado de ejecutar diagonal.py con el código de diagonal.py?
Parece ser que tanto sí como no son ambas respuestas correctas — basta ver la definición del Problema 3.14. Tomando como diagonal.py y como el mismo programa, entonces, si , el programa devolverá sí, mientras que si , entonces el programa devolverá no.
Llegado a este punto deberíamos sospechar que las cosas no van a ir bien. Terminemos este «circo recursivo» con un último problema, .11 En caso de existir un programa que lo decida, c_diagonal.py, vemos sus posibles salidas en la tabla siguiente.
- Entrada: un programa .
- Salida:
nosi está bien definido y ,síen caso contrario.
| Comando | Salida |
|---|---|
c_diagonal('no es un programa') |
sí |
c_diagonal(leer('./mas_a_que_b_v2.py')) |
no |
c_diagonal(leer('./si.py')) |
no |
c_diagonal(leer('./c_diagonal.py')) |
¿ sí o no ? |
c_diagonal.pyVolvemos a repetir la misma pregunta que nos hicimos para el caso de (Problema 3.14). ¿Qué salida tendría un hipotético programa que lo resolviese, si se usa a sí mismo como entrada? En este caso, esta pregunta es justamente la que nos demuestra que escribir tal programa es imposible. Esto nos indica que el problema no es decidible. Veamos una demostración más precisa de este hecho.
El problema es no decidible.
Demostración
Supongamos que es decidible. Entonces, existe un programa en Python c_diagonal.py que resuelve . Entonces, podemos preguntarnos por la salida del comando:
c_diagonal(leer('./c_diagonal.py'))
Este comando pregunta:
¿Es cierto que c_diagonal.py no devuelve 'sí' cuando se ejecuta a sí mismo?
La respuesta a la pregunta anterior puede ser afirmativa o negativa. Veremos que ninguna de ambas puede ser posible.
Si la respuesta es afirmativa, entonces sabemos que c_diagonal.py no devuelve sí cuando se ejecuta a sí mismo, luego la respuesta debería haber sido negativa — una contradicción, es decir, la respuesta no puede ser afirmativa.
En caso de que sea la respuesta negativa, entonces c_diagonal.py devuelve no cuando se ejecuta a sí mismo, luego la respuesta debería haber sido afirmativa — de nuevo una contradicción, en otras palabras, la respuesta tampoco puede ser negativa.
Concluimos, por tanto, que es imposible escribir el programa c_diagonal.py. Como no podemos escribir un programa que decida , el problema es no decidible.
Acabamos de encontrar nuestro primer problema no decidible: . Este hecho es de especial relevancia pues, a partir de él, podemos probar que tampoco lo es.
El problema (Problema 3.13) es no decidible.
Demostración
Procederemos por contradicción. Asumiremos que es decidible. Entonces, podemos usarlo para escribir el Programa 3.5 y el Programa 3.6, que resuelven (deciden) (Problema 3.14) y (Problema 3.15), respectivamente. Pero esto contradice la no decidibilidad de probada en la Proposición 3.16.
from universal import universal
def diagonal(programa):
return universal(programa, programa)from diagonal import diagonal
def c_diagonal(programa):
salida = diagonal(programa)
if salida == 'sí':
return 'no'
else:
return 'sí'Podemos ver la cadena de problemas que hemos usado en la Proposición 3.17 en la figura siguiente.
Probar la no decidibilidad de ha sido un proceso largo, que ha requerido de otros problemas. Sería posible demostrar este resultado usando un único programa, pero, usando las propias palabras de Turing, «[tal] prueba, aunque perfectamente sólida, [tendría] la desventaja de que puede dejar al lector con la sensación de que ‘debe haber algo mal’»2.
Ya hemos demostrado que dos de los tres problemas de la cadena anterior no son decidibles. El siguiente resultado nos permitirá probar que el último de ellos tampoco lo es.
Sea un problema decidible. Entonces, es decidible.
Demostración
Si es un problema decidible, tenemos un programa d.py que lo decide. Entonces, podemos escribir el siguiente programa, que decide (observa que el Programa 3.6 usa la misma estructura):
from d import d
def c_d(entrada):
salida = d(entrada)
if salida == 'sí':
return 'no'
else:
return 'sí'Sea un problema no decidible. Entonces, es no decidible.
Por el Corolario 3.19, al saber que (Problema 3.15) es no decidible (Proposición 3.16), tenemos que (Problema 3.14) es no decidible.
Saber que estos problemas no son decidibles es muy útil, pues nos permiten probar lo que buscamos en este capítulo: que el problema de la parada, que introduciremos en la Sección 3.6, tampoco es decidible.
Para poder demostrar esto, sin embargo, deberemos usar una nueva herramienta, que desarrollaremos en la Sección 3.5. Antes, comentaremos otra clase de problemas: aquellos que «casi» son decidibles.
3.4 Problemas semidecidibles
En la Definición 3.9, exigimos que el programa pare para cada entrada para poder decir que el problema es decidible. Si relajamos esta condición obtenemos una nueva categoría de problemas.
Dado un problema de decisión y un programa , diremos que semidecide si:
- para cada entrada con , para y ;
- para cada entrada con , es indefinido o .
Decimos que un problema de decisión es semidecidible si existe un programa que lo semidecide.
Es claro que todo problema decidible es semidecidible. El recíproco no es cierto. Veremos un ejemplo de esto más adelante, en el Problema 4.25.
No profundizaremos en la noción de problema semidecidible, ya que no será necesario para nuestro estudio. Cuando sea necesario, recurriremos a la definición aquí especificada.
3.5 Reducciones
En la Sección 3.3, hemos usado unos problemas para poder resolver otros o para probar que no pueden resolverse (véase la figura de la cadena de problemas anterior). En teoría de la computación, esta intuición se formaliza en el concepto de reducción. Cuando decimos que « se reduce a », nos referimos a que « se puede resolver si se puede resolver».
Sean y dos problemas. Decimos que se Turing-reduce, o simplemente reduce,12 a , y lo denotamos , si existe un programa que resuelve tras asumir la existencia de un programa que resuelve .
Al programa del que asumimos su existencia lo llamamos oráculo, siguiendo la terminología de las máquinas con oráculo de Turing.13
Intuitivamente, si podemos reducir a , es porque es al menos tan «difícil» como o, recíprocamente, es al menos tan «fácil» como . Esta intuición se recoge en la proposición siguiente.
Sean y problemas, y sea . Entonces:
- (a) si es decidible, es decidible.
- (b) si no es decidible, no es decidible.
Demostración
La afirmación (a) se deduce de la definición de reducción de Turing: si el problema es decidible, podemos crear un programa que lo resuelve. Al reducirse a , podemos crear un programa que resuelve a partir de , por lo que es decidible.
La afirmación (b) es inmediata a partir de (a).
Esta proposición nos permite probar que un problema es decidible (o no) a partir de otro. Esto nos permite proceder por varias vías. En primer lugar, si queremos saber que un problema es fácil, nos basta encontrar un problema decidible que reduzca a , . Entonces, será decidible.
Por otra parte, podemos probar que un problema es difícil. Basta tener un problema no decidible tal que . Entonces, tampoco será decidible.
Veamos un primer ejemplo de reducción. En la Sección 3.3 ya vimos que (Problema 3.14) y (Problema 3.13) son ambos no decidibles. Supongamos que únicamente conocemos que es no decidible. Entonces, podremos probar la no decidibilidad de mediante una sencilla reducción.14 Observa el programa siguiente.
from universal import universal # oráculo
def diagonal(programa):
salida = universal(programa, programa)
if salida == 'sí':
return 'sí'
else:
return 'no'Es sencillo ver que diagonal_a_universal.py es una reducción de a . En efecto, el programa diagonal_a_universal.py resuelve asumiendo la existencia de un programa universal.py que resuelva .
3.6 El problema de la parada
Dado un programa y una entrada , nos hemos preguntado por cuál es la salida de tal programa. Ahora, vamos a preguntarnos por si el programa para.
Este problema, de reconocer si un programa para dada una entrada, es el conocido como problema de la parada. Los motivos por los que lo estudiamos son varios: en primer lugar, este problema tiene aplicación práctica. En caso de ser decidible, podríamos escribir un algoritmo que, para cualquier programa, nos comprobara si es capaz de terminar. Evidentemente, no todos los programas paran: algunos de ellos están diseñados para ejecutarse de forma indefinida, como es el caso de los servidores web o de un sistema operativo.
Por otra parte, este problema tiene una especial relevancia histórica. Fue uno de los primeros problemas en ser demostrados no computables, y es uno de los gérmenes de la teoría de la computación.
En nuestro caso, sin embargo, estudiar este problema nos permitirá deducir el resultado central de este trabajo.
Antes de hablar sobre este problema, debemos recordar que la noción de parada que definió Turing está relacionada con las máquinas de su mismo nombre: una máquina de Turing, dada una entrada, puede parar y aceptar, parar y rechazar, o ciclar.
En nuestro caso, diremos que un programa en Python para dada una entrada si la salida está definida, de acuerdo a la Definición 2.3. Un programa podría, de este modo, no parar por muchas razones: podría entrar en un bucle infinito, lanzar una excepción o devolver un objeto que no sea un string. En los dos últimos casos, puede parecer contraintuitivo decir que el programa no ha parado: al fin y al cabo, el programa para al lanzar una excepción o devolver un objeto que no sea un string. En estos casos, es mejor pensar que el programa se ha «congelado»: no está ejecutando más código, pero no puede terminar satisfactoriamente. En este sentido, el programa «no ha parado».
Ahora estamos en condiciones de presentar la formulación del problema.
- Entrada: un programa y una entrada .
- Solución: si el programa para con entrada .
Nos preguntamos si este problema es decidible. La respuesta es negativa, y esperable teniendo en cuenta todos los problemas de este estilo que ya hemos probado no decidibles (Problema 3.13, Problema 3.14, Problema 3.15).
El problema es no decidible.
Demostración
Haremos la reducción mediante el Programa 3.10.
Para ello, transformaremos un programa que devuelve sí / no devuelve sí en un programa que para / no para. En concreto, será el programa programa que se inserta como entrada en la función universal_a_parada del Programa 3.10, y será el Programa 3.9.
En concreto,
- Si
programadevuelvesícon entradaentrada(esto quiere decir que ), entoncesmaquina_universal_paradapara con la entradaentrada(). Observa cómo en el Programa 3.9 devolvemossí. El valor que devolvemos es irrelevante, lo importante es que el programa para. - Si
programano devuelvesícon entradaentrada(), entoncesmaquina_universal_paradano para (cicla) con la entradaentrada(). En el Programa 3.9 usamos la funciónciclarde la libreríautilidades, que simplemente entra en un bucle infinito.
Hemos demostrado que el Programa 3.10 es una reducción de a . El resultado se sigue de las Proposición 3.17 y Proposición 3.22.
import utilidades
from maquina_universal import maquina_universal
def maquina_universal_parada(entrada_codificada):
(programa, entrada) = utilidades.UAM(entrada_codificada)
salida = maquina_universal(programa, entrada)
if salida == 'sí':
return 'sí' # para
else:
utilidades.ciclar() # ciclaimport utilidades
from parada import parada # oráculo
def universal_a_parada(programa, entrada):
entrada_codificada = utilidades.MAU(programa, entrada)
maquina_universal_parada = utilidades.leer('maquina_universal_parada.py')
return parada(maquina_universal_parada, entrada_codificada)En la demostración anterior hemos usado una técnica nueva: crear un programa nuevo a partir de otro. En concreto, modificamos maquina_universal.py (Programa 3.2), la máquina universal en Python que introdujimos en la Sección 3.2, para crear una máquina universal que para para las salidas positivas y cicla para cualquier otra salida.
A continuación, introduciremos un nuevo problema de la parada, más sencillo que el problema ya enunciado.15
- Entrada: un programa .
- Solución: si el programa para con entrada vacía.
Por tratarse de un problema «más sencillo», inicialmente no tiene por qué ser no decidible. Para probarlo, deberemos realizar un «truco»: ignorar la entrada.
import utilidades
from maquina_universal import maquina_universal
def ignora_entrada(entrada_ignorada):
programa = utilidades.leer('disco/programa.txt')
entrada = utilidades.leer('disco/entrada.txt')
return maquina_universal(programa, entrada)ignora_entrada.pyPara ello, usamos el Programa 3.11. Este programa, en lugar de ejecutar la entrada entrada_ignorada, ejecuta la máquina universal sobre un programa y una entrada almacenadas en disco. Concretamente, usa como programa el almacenado en disco/programa.txt, y como entrada la almacenada en disco/entrada.txt.
Una vez conocemos el funcionamiento de ignora_entrada.py, es sencillo encontrar una reducción de a .
El problema es no decidible.
Demostración
Basta hacer la reducción mediante el Programa 3.12. En efecto, vemos que tal programa resuelve , asumiendo la existencia de un programa parada_en_vacio.py que resuelve . Dado un programa programa y una entrada entrada, parada_a_parada_en_vacio almacenará los valores de las variables en disco/programa.txt y disco/entrada.txt, respectivamente. A continuación, se devuelve si el programa ignora_entrada.py para o no, lo que equivale a decir, por la forma en la que está escrito tal programa, si programa para con entrada entrada, resolviendo .
El resultado se sigue de las Proposición 3.24 y Proposición 3.22.
import utilidades
from parada_en_vacio import parada_en_vacio # oráculo
def parada_a_parada_en_vacio(programa, entrada):
utilidades.escribir('disco/programa.txt', programa)
utilidades.escribir('disco/entrada.txt', entrada)
return parada_en_vacio(utilidades.leer('ignora_entrada.py'))Para concluir, introducimos una tarea de predicción: .16
- Entrada: un programa que, con cualquier entrada, si termina devuelve
sí(acepta) ono(rechaza). - Salida requerida:
sísi acepta con entrada vacía;nosi rechaza con entrada vacía. Si cicla, se permite cualquiera de las dos respuestas. - Terminación: el predictor debe terminar en todas las entradas, incluso cuando cicla.
A diferencia de un problema de decisión ordinario, esta tarea no fija una única respuesta correcta para cada entrada: en los programas que ciclan se admiten ambas. Lo que buscamos es un predictor computable total que respete los resultados de todos los programas que sí terminan. Ni siquiera con esa libertad existe tal predictor.
No existe un programa computable total que resuelva .
Demostración
Supongamos que existe un predictor adivina_consistente.py que satisface las condiciones anteriores. Construimos el programa del Programa 3.13: dada la descripción de un programa , prepara un programa sin entrada que simula , consulta al predictor e invierte su respuesta.
Si acepta, devuelve no; si rechaza, devuelve sí. Si cicla, el predictor puede responder cualquiera de las dos cosas y devuelve la contraria. En todos los casos termina, porque el predictor es total: no ejecuta la simulación consultada.
Ejecutemos ahora sobre su propia descripción. Como termina, solo hay dos casos:
- Si devuelve
sí, el predictor debe respondersísobre el programa que simula , pero invierte esa respuesta y devuelveno. - Si devuelve
no, el predictor debe responderno, pero devuelvesí.
Ambos casos son contradictorios. La libertad de respuesta sobre programas que ciclan no ofrece una salida, porque termina.
import utilidades
from adivina_consistente import adivina_consistente # predictor total supuesto
def modifica_adivina_consistente(programa):
utilidades.escribir('disco/programa.txt', programa)
utilidades.escribir('disco/entrada.txt', programa)
salida = adivina_consistente(utilidades.leer('ignora_entrada.py'))
if salida == 'sí':
return 'no'
else:
return 'sí'El problema y la tarea serán centrales en la demostración de dos variantes del resultado principal de este trabajo, en el Capítulo 5. A modo de resumen, se proporciona la tabla siguiente con todos los problemas presentados en este capítulo.
3.7 Resumen
| Problema | Entrada | Salida | ¿Decidible? |
|---|---|---|---|
— sí, si ; — no, en caso contrario |
Sí (Proposición 3.11) | ||
| cualquiera | — sí |
Sí | |
| , | — sí, si ; — no, en caso contrario |
No (Proposición 3.17) | |
— sí, si ; — no, en caso contrario |
No | ||
— no, si ; — sí, en caso contrario |
No (Proposición 3.16) | ||
| , | — sí, si para con entrada ; — no, en caso contrario |
No (Proposición 3.24) | |
— sí, si para con ; — no, en caso contrario |
No (Proposición 3.26) | ||
sí si acepta; no si rechaza; cualquiera de las dos si cicla. El predictor siempre debe terminar |
Sin predictor computable total (Proposición 3.28) |
Notas al pie
Haines, L., Horning, J., & Wulf, W. (eds.) (1993). Report on the workshop on grand challenges in computer science. Communications of the ACM, 36(11).
↩Turing, A.M. (1937). On computable numbers, with an application to the Entscheidungsproblem. Proceedings of the London Mathematical Society, 42(1):230–265.
↩ ↩2 ↩3MacCormick, J. (2018). What Can Be Computed? Princeton University Press.
↩Sipser, M. (2012). Introduction to the Theory of Computation (3rd ed.). Cengage Learning.
↩Llamamos a los problemas computacionales y no para distinguirlos de los programas en Python, y para reforzar la noción de que un problema computacional es una función.
↩Al referirnos a un «problema» sin especificar si es computacional o de decisión, no entraremos en ambigüedad: basta ver las salidas para saber si es de decisión.
↩En las últimas versiones de Python, es posible trabajar con UTF-8.
↩Véase la cita de Howard Aiken que comienza el Capítulo 2.
↩Lutz, M. (2013). Learning Python (5th ed.). O'Reilly Media.
↩Este problema no tiene, inicialmente, relación alguna con la máquina universal que definimos en el Programa 3.2.
↩Notar que .
↩Existen otros tipos de reducciones, pero nosotros sólo usaremos esta. Es por ello por lo que usamos el término reducción en lugar de reducción de Turing, ya que no da lugar a ambigüedad.
↩Turing, A.M. (1939). Systems of logic based on ordinals. Proceedings of the London Mathematical Society, 45:161–228. https://people.math.ethz.ch/~halorenz/4students/Literatur/TuringOrdinalLogic.pdf
↩Recuerda que ya dijimos que podíamos obtener a partir de .
↩En algunos libros, se enuncia este problema como el propio problema de la parada.
↩Aaronson, S. (2011). Rosser's Theorem via Turing machines. https://scottaaronson.blog/?p=710
↩