Introducción a la incompletitud · Capítulo 2
Python es una máquina de Turing
Construimos el puente riguroso entre programas concretos en Python y máquinas de Turing abstractas mediante una cadena de simulaciones: máquina de Turing monocinta → máquina de Turing multicinta → máquina de acceso aleatorio → ordenador moderno → programa en Python → máquina de Turing monocinta. Esta equivalencia justifica el uso de Python como modelo formal de computación a lo largo de la serie, y sitúa el resultado dentro de la tesis de Church–Turing.
Hemos perfeccionado el ordenador de propósito general para cálculos científicos. Ahora intentamos emplear el mismo dispositivo con pequeñas modificaciones en una gran variedad de situaciones diferentes para las cuales no fue diseñado en primer lugar. En estas circunstancias, si llegara a resultar que la lógica básica de una máquina diseñada para la solución numérica de ecuaciones diferenciales coincide con la lógica básica de una máquina destinada a hacer facturas para una tienda departamental, consideraría esto como la coincidencia más asombrosa que haya encontrado.
La máquina de Turing, inventada por Alan Turing en 19372, es un modelo matemático sencillo que pretende representar la noción de algoritmo, y del que hablaremos en detalle en la Sección 2.2. Esta máquina es una de las herramientas más relevantes en las ciencias de la computación, pues su simplicidad permite demostrar resultados importantes, como la indecidibilidad del problema de parada (Proposición 3.24), a partir del cual podremos adentrarnos en la cuestión principal de nuestro trabajo: la incompletitud de las matemáticas. Sin embargo, esta potencia formal tiene una desventaja, que comprobaremos a partir de un simple ejemplo (Ejemplo 2.11): definir programas sencillos en este modelo es complejo y tedioso.
Este inconveniente es el que motiva este capítulo: en la Sección 2.3 probaremos la equivalencia entre los programas de ordenador (en nuestro caso, los programas en Python, que exploraremos en la Sección 2.1) y las máquinas de Turing. Esto nos permitirá ver un programa en Python como una representación de una máquina de Turing, eliminando el principal inconveniente y quedándonos con la potencia matemática ya comentada.
Es importante resaltar que los resultados que aquí aparecen son perfectamente generalizables a cualquier lenguaje de programación Turing-completo. Sin embargo, decidimos usar este lenguaje por su facilidad de comprensión y de uso.
Tanto el resultado de equivalencia que probamos en este capítulo como la observación anterior son especialmente interesantes, ya que guardan una fuerte relación con la tesis de Church–Turing, de la que hablaremos brevemente en la Sección 2.4.
2.1 Programas en Python
Python3 es un lenguaje de programación de alto nivel, interpretado, orientado a objetos y open source, ampliamente usado en la actualidad para una gran variedad de aplicaciones. Una de sus principales características es su legibilidad y facilidad de uso: cualquier persona experimentada en otros lenguajes podrá comprender y redactar programas en Python con mucha sencillez.
Usando Python, podemos escribir una gran variedad de programas. Para el propósito de nuestro estudio, nos centraremos en una clase de programas específica: aquella que tiene únicamente una cadena de caracteres como entrada y una cadena de caracteres como salida. Los llamaremos programas SISO (del inglés, Single Input–Single Output; véase Definición 2.2)4. En breve, comprobaremos cómo esta clase no es restrictiva: estos tipos de programas son tan expresivos como cualquier otro programa. De hecho, en este trabajo usaremos los términos «programa» y «programa SISO» indistintamente, y en ambas ocasiones nos referiremos a un programa en Python de tipo SISO.
Definimos una función SISO como una función de Python que acepta un único parámetro de tipo str como entrada y devuelve un str.
Respecto a un sistema informático de referencia , definimos un programa en Python de tipo SISO como una cadena de caracteres tal que:
- es código en Python sintácticamente correcto;
- define al menos una función, y la primera de ellas es de tipo SISO. A esta función la llamaremos función main.
Observemos que en la definición anterior se especifica un sistema informático , pero no se define de forma explícita. Podemos pensar en como cualquier sistema informático, con cualquier configuración de software y hardware, y con tanta memoria como sea necesaria.
También podemos preguntarnos qué queremos decir cuando nos referimos a un programa como «sintácticamente correcto», dado que esto dependerá de varios factores, como la versión de Python instalada en , así como el sistema operativo, arquitectura, etc. Es posible que un programa se ejecute en un sistema y falle o incluso dé una salida diferente en otro sistema . Sin embargo, y como veremos más adelante, usaremos estos programas como representaciones de máquinas de Turing. Los programas que presentamos aquí son independientes de arquitectura y de memoria, y las demostraciones que figuran en este trabajo son independientes de todas estas características.
También necesitaremos definir cuál es la salida de un programa en Python.
Sea un programa en Python respecto a un sistema informático . Sea la función main de , e la entrada de , una cadena de caracteres. La salida de respecto a la entrada , , se produce usando para ejecutar con entrada , y se define como:
- Si devuelve una cadena de caracteres , entonces .
- Si devuelve cualquier otro objeto en Python, entonces no está definido.
- Si lanza una excepción,5 entonces no está definido.
- Si no termina, entonces no está definido.
Nota. La definición anterior puede generalizarse fácilmente para entradas. Reemplazando por obtenemos la definición correspondiente para .
En otras palabras, la salida de un programa es la cadena de caracteres devuelta por su función main en caso de que lo haga, y no estará definida en caso contrario.
Al definir los programas en Python que usaremos en este trabajo, nos centramos en el uso de cadenas de caracteres (también llamadas strings) como entrada y salida, y evitamos el uso de otros objetos de Python que no sean strings. Inicialmente, podemos pensar que esto impone una limitación sobre los programas en Python que podemos escribir, pero esto realmente no es así. Para ello, basta observar que cualquier objeto en Python puede codificarse debidamente mediante una cadena de caracteres. Existen librerías nativas que permiten hacer esto, como pickle.6 Un ejemplo de esto se muestra en el Programa 2.1. La función function_with_encoded_object es una función main.
import pickle
from base64 import b64decode, b64encode
def function_with_encoded_object(encoded: str) -> str:
decoded = pickle.loads(b64decode(encoded.encode('ascii')))
return str(decoded)
adjacencies = {1: 2, 3: 4, 5: 3, 4: 2}
adjacencies_encoded = b64encode(pickle.dumps(adjacencies)).decode('ascii')
function_with_encoded_object(adjacencies_encoded)pickle.dumps serializa el objeto a bytes, no a str. Usamos Base64 para representar esos bytes como una cadena ASCII y los recuperamos antes de llamar a pickle.loads. La función también devuelve una cadena, de modo que tanto la entrada como la salida cumplen la definición SISO.
En resumen, hemos visto cómo la condición de ser SISO para un programa en Python no es realmente una restricción. Sin embargo, esta clase de problemas nos resultará muy conveniente, como veremos más adelante.
2.2 Máquinas de Turing
Las máquinas de Turing, primero descritas por Alan Turing en 19362, son dispositivos computacionales abstractos y simples creados para ayudar a investigar qué problemas pueden ser computados. Las «máquinas automáticas» —tal como Turing las llamó en 1936— fueron específicamente diseñadas para computar números reales. Estas máquinas no fueron nombradas «máquinas de Turing» hasta 1937, cuando Alonzo Church7 revisó la publicación original de Turing. Hoy se consideran uno de los modelos fundamentales de la teoría de la computación, pues son la representación matemática de un algoritmo8.
Antes de comprender cómo funcionan estas máquinas, haremos unas definiciones previas.
Un alfabeto es un conjunto finito. Sus elementos se llaman símbolos (o letras).
Una palabra sobre el alfabeto es una sucesión finita de elementos de . Es decir, es una palabra de si y solo si , con para todo .
El conjunto de todas las palabras sobre un alfabeto se denota .9
La longitud de una palabra es el número de símbolos que contiene, y se denota . Para , es .
La palabra vacía es la palabra de longitud cero. Es la misma para todos los alfabetos, y se denota .
La primera definición presentada por Turing en 19362 dista de la que se presenta en la Definición 2.5. Sin embargo, ambas son equivalentes.
Una máquina de Turing es una séptupla , donde:
- es un conjunto (finito) de estados.
- es un alfabeto de entrada.
- es un alfabeto de símbolos de la cinta (o alfabeto de trabajo), que incluye a (es decir, ).
- es la función de transición que asigna a cada estado y símbolo un valor que puede ser:
- si no está definido, o
- una tripleta , donde , , .
- es el estado inicial.
- es el símbolo blanco.
- es el conjunto de estados finales.
La definición anterior es un tanto abstracta, y podemos visualizarla mejor en la Figura 2.1. En ella, vemos las tres partes «físicas» de la máquina: la unidad de control, el cabezal y la cinta. La cinta contiene casillas, cada una de las cuales puede almacenar un símbolo. Inicialmente, los símbolos son los del alfabeto de entrada, pero conforme la máquina va ejecutando instrucciones se puede usar el alfabeto de trabajo. Estos símbolos incluyen un símbolo blanco, denotado , y se encuentran en todas las posiciones de la cinta que no codifican la palabra de entrada. El cabezal puede moverse por la cinta a la izquierda y a la derecha (o quedarse en el sitio), informando a la unidad de control de los símbolos que ve. El símbolo al que apunta el cabezal lo llamaremos símbolo escaneado. El cabezal puede reemplazar el símbolo escaneado por cualquier otro del alfabeto de trabajo, incluyendo el símbolo blanco. La unidad de control manda transiciones al cabezal, y cambia entre los diferentes estados en función de los símbolos leídos por el cabezal.
Es importante aclarar que la máquina de Turing realmente no tiene ninguna «unidad de control» o «cabezal»: es una descripción matemática. Lo que hemos hecho es proporcionar una especificación intuitiva de cómo se ejecuta el algoritmo que representa tal máquina.
Para describir la máquina en cada momento, necesitaremos el concepto de configuración.
Especificaremos la configuración de una máquina de Turing mediante dos parámetros:
- El estado en el que se encuentra la máquina.
- La palabra presente en la cinta y la posición del cabezal de lectura, que representaremos recuadrando el símbolo donde se encuentra el cabezal. Dado que la cinta se extiende infinitamente a ambos lados, eliminamos los caracteres blancos a la izquierda y a la derecha hasta llegar a la posición del cabezal.
Representaremos una configuración mediante
con uno de los símbolos de debidamente recuadrado.
Lo único que nos queda es precisar la noción de «paso de cálculo» que comentamos anteriormente. En cada paso, aplicamos una transición y cambiamos la configuración de la máquina. Esto es lo que denominamos proceso de cálculo, y formalizamos a continuación.
Dada una máquina de Turing y dada una entrada inicial , definimos su configuración inicial como:
A continuación, definimos un paso de cálculo para cada configuración de la máquina:
con , , en función del valor de la función de transición :
- Si no está definido (), la máquina para.
- Si con , , , la máquina reemplazará el símbolo presente en la posición de la cinta en la que se encuentra el cabezal por , moverá el cabezal una posición de acuerdo a (a la izquierda, derecha o no se moverá si es , o , respectivamente), y cambiará su estado a :
El proceso de cálculo se define como la sucesión de pasos de cálculo, comenzando por la configuración inicial.
Es posible que la máquina pare cuando llega a una configuración tal que la función de transición no está definida.10 Si esto no ocurre, la sucesión de pasos de cálculo será infinita y la máquina no parará nunca.
En definitiva, vemos que la máquina, en función del estado en el que se encuentre y el símbolo escaneado, ejecutará una cierta transición, definida en , que le dirá a qué estado pasar, por qué símbolo reemplazar el escaneado y el movimiento que tiene que realizar sobre la cinta.
Una nota importante respecto a las transiciones no definidas. A la hora de describir una función de transición, definiremos únicamente las transiciones para los estados y símbolos para los que están definidas. En caso contrario, podemos asumir que . Puedes ver esto en el Ejemplo 2.11, que comentaremos en detalle más adelante.
Introducimos una notación gráfica para la función de transición. La definiremos mediante un grafo dirigido: representaremos los estados como nodos (uno para cada estado) y las transiciones como arcos dirigidos.
El estado inicial se simbolizará, además de en la definición de la máquina, con una flecha apuntando al estado (no es un arco, sino que no proviene de ningún nodo):
Para cada transición , insertaremos un arco dirigido conectando los nodos correspondientes a los estados y como sigue:
En caso de que tengamos varias funciones de transición entre ambos estados ( y ), colocaremos varias líneas en el arco para simbolizarlo:
Finalmente, también indicaremos los estados finales rodeando dos veces el nodo:
Observemos cómo una máquina de Turing, dada una entrada, puede:
- parar y aceptar,
- parar y rechazar, o
- no parar (ciclar).
La máquina para cuando llega a una configuración para la que no hay una transición definida. Cuando ocurre esto, podemos definir una salida con los símbolos que quedan en la cinta.
Para una máquina de Turing , definimos su salida como los contenidos de la cinta una vez que la máquina ha parado, omitiendo los símbolos blancos a ambos lados.
Dada una máquina de Turing y dada una entrada inicial , decimos que acepta si para con entrada en un estado . Si lo hace en un estado , decimos que rechaza .
Al haber definido qué palabras acepta una máquina determinada, introducimos el concepto de lenguaje. Un lenguaje no es más que un conjunto de palabras.
Un lenguaje sobre un alfabeto es un conjunto de palabras sobre .
Si es una máquina de Turing, el lenguaje aceptado, denotado como , es el conjunto de palabras aceptadas por , es decir,
Concretemos todos los conceptos que hemos introducido mediante un ejemplo.
Sea una máquina de Turing tal que:
con , su función de transición, descrita mediante el grafo:
Equivalentemente podemos definir la función de transición explícitamente:
Veamos qué hace esta máquina. Empieza al principio de la cinta, y escanea los símbolos uno a uno, de izquierda a derecha. Hay tres posibilidades, dependiendo de si vemos , o :
- Si se lee una :
- Reemplazar la con una .
- Continuar escaneando hacia la derecha.
- Si encontramos una , reemplazarla con una . Habremos reemplazado el mismo número de que de , así que nos movemos al inicio de la cinta y reiniciamos el algoritmo.
- Si no encontramos una , tenemos una de más, así que rechazamos.
- Si se lee una :
- Reemplazar la con una .
- Continuar escaneando hacia la derecha.
- Si encontramos una , reemplazarla con una . Habremos reemplazado el mismo número de que de , así que nos movemos al inicio de la cinta y reiniciamos el algoritmo.
- Si no encontramos una , tenemos una de más, así que aceptamos.
- Si no encontramos o antes del al final de la entrada: el número de reemplazados equivale al número de reemplazados, así que rechazamos.
El lenguaje aceptado por esta máquina es el conjunto de palabras de con un número de mayor que de :11
Veamos un ejemplo de palabra aceptada: . Los pasos que da la máquina de Turing configuración tras configuración serán los siguientes:
Como la máquina para en el estado , la palabra es aceptada.
Veamos un ejemplo de una palabra no aceptada, como es :
Como la máquina para en el estado , la palabra no es aceptada.
La máquina también permite la entrada vacía (, que definimos en la Definición 2.4), en cuyo caso la sucesión de configuraciones será:
La máquina para inmediatamente en , y no es aceptada. Esto es esperado, pues tiene exactamente el mismo número de que de .
Estos ejemplos quedan resumidos en la Tabla 2.1, junto con el comportamiento de la máquina para otras palabras. Observa cuándo cada palabra es aceptada, así como el número de transiciones tras los que la máquina para.
Una vez que hemos descrito con precisión qué es una máquina de Turing, procederemos a probar el resultado más importante de este capítulo (Teorema 2.23), que ya anticipamos en la introducción: la equivalencia entre las máquinas de Turing recién expuestas y los programas en Python que definimos en la Sección 2.1.
| Entrada | Config. inicial | Config. al parar | ¿Aceptada? | N.º transiciones | |
|---|---|---|---|---|---|
| sí | 18 | ||||
| no | 7 | ||||
| no | 0 | ||||
| no | 16 | ||||
| no | 17 | ||||
| sí | 18 | ||||
| sí | 23 |
2.3 Equivalencia
Nuestro objetivo ahora es comprender por qué las máquinas de Turing tienen la misma potencia de cálculo que los programas en Python. En otras palabras, queremos demostrar que los problemas que podemos resolver en ambos son los mismos.12
Para probar la equivalencia entre ambos modelos de cálculo, recurriremos al concepto de simulación. Es normal para diferentes modelos computacionales simularse unos a otros. Un ejemplo de esto es la máquina virtual de Java (JVM), que es un modelo abstracto capaz de ejecutar cualquier programa en Java13. Otro ejemplo es el sistema operativo Linux, que es capaz de ejecutar la JVM. Por lo tanto, un ordenador Linux es capaz de ejecutar cualquier programa en Java. Esto es posible mediante la cadena de simulaciones:
Usamos la notación para indicar que simula a . El hecho de que simule quiere decir, sencillamente, que es posible realizar el procedimiento de cálculo de usando exclusivamente el procedimiento de cálculo de .
Procederemos a probar la equivalencia mostrada mediante la cadena de simulaciones que aparece en la Figura 2.6.
El trabajo que haremos en esta sección es arduo, y necesitaremos introducir diversos modelos computacionales para llegar a nuestro objetivo. Sin embargo, una vez probemos todas las simulaciones, sabremos que todas ellas son equivalentes, y podremos usar el modelo que queramos a conveniencia.
El primer modelo computacional que estudiaremos lo obtendremos añadiendo más cintas y cabezales a la máquina de Turing que definimos en la Sección 2.214.
Una máquina de Turing multicinta con cintas es una máquina de Turing convencional en la que tenemos cintas y cabezales de lectura. En este sentido, la configuración de una máquina de Turing multicinta se describe como:
donde cada una de las palabras de la cinta tiene uno de sus símbolos recuadrados, simbolizando la posición del cabezal en cada cinta.
La función será de la forma:
para las transiciones no definidas, y para las definidas:
con , , , para todo .
El proceso de cálculo será análogo al de una máquina de Turing de una sola cinta, teniendo en cuenta que el símbolo es el leído por el cabezal -ésimo, que es el símbolo por el que se cambia en el cabezal -ésimo, y es el movimiento que seguirá el cabezal -ésimo.
La entrada de la máquina se colocará en la primera cinta, con el cabezal en el primer símbolo de la palabra. El resto de cabezales apuntarán al símbolo vacío, dado que todas las cintas excepto la primera estarán llenas de símbolos vacíos.
Las palabras aceptadas por la máquina procederán de forma análoga al caso de una sola cinta, teniendo en cuenta si el estado donde la máquina para es final o no (está en ).
Esta definición es intuitiva y esperable. Simplemente incorporamos más cintas y cabezales. De ahora en adelante, también usaremos el término «máquina de Turing de una sola cinta» para referirnos a la máquina de Turing que definimos en primer lugar.
Uno puede pensar que estas máquinas tienen más poder computacional que las de una sola cinta, pero, sin embargo, son equivalentes. Es evidente que una máquina multicinta simula una máquina de una sola cinta (la Definición 2.5 es la misma que la Definición 2.12 para ). En la Proposición 2.13 vemos cómo el recíproco también es cierto.
Toda máquina de Turing multicinta puede ser simulada por una máquina de Turing de una sola cinta.
Demostración
Demostración.15 Sea una máquina de Turing con cintas. Vamos a crear una máquina con una sola cinta de modo que .
Para simular cintas y cabezales en un solo cabezal y una sola cinta, deberemos ampliar el alfabeto de trabajo . En primer lugar, vamos a introducir nuevos símbolos para poder saber la posición de cada uno de los cabezales. Definimos:
Y, hecho esto, definimos , el alfabeto de , como:
Observemos que los símbolos de son bien un delimitador , bien un grupo de caracteres de . De este modo, podemos simular cintas y cabezales en una sola cinta con un solo cabezal. Un ejemplo: si en tenemos la configuración:
Una de las posibles configuraciones en sería:
Observemos que varias configuraciones de son posibles, dado que las cintas son independientes (no importa cómo las «apilamos», y esto no tiene efecto en el proceso de cálculo). Esta característica de nuestra codificación se evidencia en la Figura 2.8. Sin embargo, esta independencia hace que esta peculiaridad no nos afecte: simplemente dejamos que la máquina vaya computando y vamos aplicando las transiciones y modificando las transiciones según se dicte.
Describir de forma exacta el conjunto de estados y la función de transición es un proceso arduo. En esta demostración, nos limitaremos a explicar su comportamiento; la construcción completa se da en la Proposición 2.14 más abajo.
La máquina comienza con la configuración inicial para la entrada: :
A continuación, va aplicando los movimientos de cinta a cinta y cabezal a cabezal. Llamaremos a estas cintas y cabezales «cintas virtuales» y «cabezales virtuales», respectivamente.
Para ello, comienza escaneando toda la cinta desde el delimitador izquierdo, y almacena los valores de los cabezales virtuales en estados de . El conjunto es ampliado para poder efectuar esta acción de «memorización». A continuación, volvemos al principio de la cinta, y vamos efectuando cada una de las acciones de por cada cinta virtual. Si en algún momento al desplazar cada cabezal virtual nos lo coloca sobre un delimitador , reemplazamos la posición de la cinta por el símbolo blanco de , , y desplazamos todos los símbolos de la cinta virtual a la izquierda o derecha según sea necesario.
Una vez hecho esto, cambiamos el estado inicial por el estado correspondiente según y así sucesivamente. El conjunto es el mismo, dado que vamos pasando por los mismos estados de en .
Es fácil ver que , pues hemos creado una máquina que imita a la perfección el proceso de cálculo de , y en caso de parar, para en un estado de . Las palabras aceptadas serán aquellas en las que, una vez que la máquina haya parado, el estado esté en .
Los detalles de esa construcción — el alfabeto de trabajo, los estados auxiliares y cada cláusula de — son los siguientes.
Toda máquina de Turing multicinta puede ser simulada por una máquina de Turing de una sola cinta.
Demostración
Sea una máquina de Turing con cintas. Definiremos la máquina . Para ello, definimos en primer lugar como:
Para definir , observemos que un símbolo de un alfabeto no tiene por qué ser únicamente un carácter. Para representar un único símbolo, lo rodearemos de corchetes (). Por tanto, el siguiente alfabeto tiene sentido:
En insertamos, además de los símbolos que hemos comentado, un símbolo delimitador .
El conjunto de estados lo hacemos más amplio que . Para ello, numeraremos todos los estados de :
Y definimos el nuevo conjunto de estados como:
Definimos como configuración inicial de la máquina con entrada a:16
Describiremos poco a poco. En primer lugar, queremos partir del mismo estado que la máquina multicinta (esto nos permitirá que el lenguaje aceptado sea el mismo, como veremos más adelante). En el momento en el que la máquina se encuentre en este estado, nos movemos a la derecha y comenzaremos a buscar la posición en la cinta del primer cabezal. Usaremos el estado para buscar el cabezal -ésimo, estando la máquina multicinta en el estado . Añadimos para hacer esto las siguientes transiciones, para cada :
Para buscar el cabezal -ésimo, estando la máquina multicinta en el estado , nos iremos moviendo a la derecha mientras que no hayamos encontrado un carácter de en la posición -ésima. Para ello, añadimos para cada y :
Nótese que, de forma explícita, hacemos que el símbolo no pueda estar en (de aquí en adelante, si no explicitamos, será para cada ), de modo que seguimos moviéndonos a la derecha hasta encontrar un carácter con cabezal en la posición -ésima de la celda actual.
Ahora estamos en condiciones de incluir las transiciones de en . Sea una transición de :
Para cada una de estas transiciones, deberemos insertar reglas en . Estas transiciones dependerán de cómo se mueva el cabezal, ya que si lo hace a la derecha o a la izquierda hasta toparse con el delimitador , deberemos extender la cinta de con un símbolo blanco y desplazar el delimitador una posición. Dada la transición anterior, y para cada :
- Si : insertamos varias transiciones. En primer lugar, la transición correspondiente a sustituir el símbolo en el que se encuentra el cabezal, , por el correspondiente, :
A continuación simulamos que el cabezal -ésimo se mueve a la derecha. En caso de que no hayamos alcanzado el separador , la transición es directa: indicamos que el cabezal está en la posición determinada, y volvemos al primer delimitador mediante el estado (que veremos más adelante):
En caso de que nos topemos con el delimitador , iniciaremos una subrutina indicada con los estados , en el que añadiremos un símbolo blanco y moveremos el delimitador a la derecha, siguiendo al estado :
- Si : añadimos de forma análoga las transiciones con los subestados y :
- Si : la transición es más inmediata, dado que no debemos mover el cabezal: simplemente reemplazamos el símbolo y cambiamos a :
Ahora nos queda una forma de cambiar la cinta que estamos analizando. Comenzaremos añadiendo para ello un estado de retorno, que posiciona el cabezal en el primer delimitador . Para cada y añadimos las transiciones:
Para leer la cinta siguiente, simplemente añadimos estas transiciones para cada y :
Si ya hemos leído la última cinta, cambiamos el estado a uno de los de de acuerdo a la función de transición. De nuevo revisitamos . Para cada transición , añadimos la transición de cambio de estado:
Ya hemos definido todas las transiciones necesarias. Ahora comprobaremos que la simulación es correcta, es decir, que . En efecto, hemos construido una máquina que ejecuta el mismo proceso de cálculo que , y que además pasa por los mismos estados de tras modificar la cinta. Precisamente por esto, en caso de parar la máquina, se encontrará en un estado (la hemos construido para que ocurra esto). De este modo, el lenguaje aceptado por ambas máquinas coincide, dado que usamos el mismo conjunto de estados finales .
Pensemos un momento en las consecuencias de que estos dos modelos de cálculo sean equivalentes. En primer lugar, nos da una idea del alcance de la máquina de Turing monocinta: introduciendo más complejidad en su descripción, no podemos aspirar a crear algoritmos más complejos, pues siempre podremos encontrar un algoritmo equivalente en una máquina de una sola cinta. Esto, como comentábamos al comienzo del capítulo, nos da una pista de una de las tesis más relevantes de las ciencias de la computación en la actualidad, y que discutimos en la Sección 2.4.
En segundo lugar, esto nos da una ventaja a la hora de querer resolver problemas: poder describir un programa en un modelo que nos da una mayor flexibilidad es mucho más sencillo.
Al avanzar en esta sección, es importante mantener en mente estas dos observaciones. Ahora, introduciremos un nuevo modelo computacional: la máquina de acceso aleatorio4.
Una máquina de Turing de acceso aleatorio con cintas es una máquina de Turing multicinta de cintas con dos cintas adicionales, limitadas a la izquierda:
- Una cinta de dirección, que almacena un número natural .17
- Una cinta llamada RAM, que almacena símbolos en cada una de sus pistas.
Denotamos como a la -ésima pista de la RAM.
La función será de la forma:
para las transiciones no definidas, y para las definidas:
con , , , , para todo .
La transición definida se ejecutará si el valor de , y tras ejecutarla el valor de cambiará a , y el valor de la cinta de dirección cambiará de a . Es decir, en una única transición, es capaz de acceder a la posición de la RAM, modificarla, y cambiar la posición de la RAM.
Una configuración de la máquina se especificará mediante:
con un símbolo de cada cinta debidamente recuadrado, y con un símbolo de recuadrado, concretamente, aquel al que apunte la cinta de dirección.
Podemos ver un ejemplo de tal configuración en la Figura 2.9.
Probar que una máquina de Turing de acceso aleatorio puede ser simulada mediante una máquina de Turing multicinta es sencillo pero tedioso. En la Proposición 2.16 se explican las rutinas que serían necesarias para tal simulación.
Toda máquina de Turing de acceso aleatorio con cintas puede ser simulada por una máquina de Turing multicinta.
Demostración
Para probar esto, necesitaremos una máquina de Turing de cintas. Las dos últimas cintas serán la cinta de dirección (-ésima cinta) y la RAM (-ésima cinta). A continuación, vemos que:
- Podemos crear una rutina que reemplace el símbolo de la posición del cabezal en la cinta por otro.
- Podemos crear una rutina que modifique la dirección de la cinta .
- Podemos crear una rutina que, dada una dirección en la cinta , mueva el cabezal de la cinta a la posición correspondiente. Podríamos implementar una rutina que vaya contando hasta el número de la cinta y, cada vez que va contando, va moviendo el cabezal de la cinta una posición a la derecha, comenzando al principio.
Implementar la operación adicional atómica de la máquina de Turing de acceso aleatorio en esta máquina multicinta es sencillo: podemos acceder al valor de , , viendo dónde se encuentra el cabezal en la cinta . En función de esto, ejecutamos la transición necesaria. Tras esto, modificamos a ejecutando la rutina (1), y cambiamos la dirección de la cinta ejecutando (2). Finalmente, modificamos la posición del cabezal en la cinta ejecutando la rutina (3).
A continuación, continuaremos con la cadena de simulaciones. Para ello, introduciremos una definición de ordenador. Ésta es una simplificación de la arquitectura de un ordenador pero, a efectos prácticos, todos los ordenadores modernos siguen la estructura que se especifica en la Definición 2.1718.
Definimos un ordenador moderno como aquel que dispone de los siguientes elementos:
- Registros. Un registro permite almacenar exactamente una unidad de información, en binario. Los registros tienen un tamaño fijo (en la mayoría de ordenadores actuales es de 64 bits). Esta unidad de información puede representar caracteres, instrucciones, etc.
- RAM. La RAM o memoria de acceso aleatorio (Random Access Memory) es el hardware del ordenador que almacena la información en uso por el ordenador.
- ROM. La ROM o memoria de solo lectura (Read-Only Memory) es el hardware del ordenador que almacena información, pero que no puede ser modificada. Normalmente, la ROM almacena programas fijos que indican al ordenador acciones básicas tales como cargar los programas en el encendido.
- Repertorio de instrucciones. Cada CPU contiene un conjunto fijo de acciones que puede ejecutar. Cada una de estas acciones se llama instrucción, y el conjunto de todas las instrucciones posibles es el repertorio de instrucciones. Estas instrucciones suelen ser operaciones aritméticas y condicionales con los registros, cargar de la RAM a los registros, etc.
Para nuestro trabajo, saber detalles sobre qué tipos de instrucciones puede ejecutar un ordenador es irrelevante. Lo importante es saber que, independientemente del lenguaje de programación en el que esté escrito, un programa puede ser «traducido»19 a este conjunto de instrucciones.
- Disco. Un ordenador tiene uno o varios discos (por simplicidad, hablaremos sólo de uno). El disco permite lectura y escritura, y almacena las entradas y salidas a los programas, así como los programas que quieran ejecutarse.
Simular este ordenador mediante una máquina de Turing de acceso aleatorio es inmediato: podemos simular todos los componentes mediante cintas y la RAM.
Un ordenador moderno puede ser simulado por una máquina de Turing de acceso aleatorio.
Demostración
Veamos cómo podemos simular cada uno de los elementos del ordenador mediante una máquina de Turing de acceso aleatorio . La Figura 2.10 simboliza las afirmaciones siguientes.
- La ROM de puede ser simulada incluyendo los estados y transiciones necesarios en .
- Cada instrucción de puede implementarse del mismo modo, mediante transiciones y estados.
- Los registros de que sean usados para guardar direcciones de memoria pueden ser simulados por la cinta de direcciones de .
- Otros registros de propósito general de pueden ser simulados incorporando cintas adicionales a .
- La RAM de puede ser simulada con la RAM de .
- El disco de puede ser simulado con una cinta en (que podemos llamar cinta de entrada/salida).
La demostración anterior no es estricta, y hacemos esto a propósito: definir de forma concreta cómo implementar cada uno de los componentes del ordenador queda fuera del objeto de este trabajo, y sería más relevante si hablásemos de arquitectura de computadores. Ilustraremos esta simulación mediante un ejemplo.
Vamos a simular una instrucción de un ordenador moderno en una máquina de Turing de acceso aleatorio. Sea ADD la instrucción de suma:
Esta instrucción toma los números en binario de los registros R0 y R1 y guarda su suma en el registro R2. Por ejemplo, si hacemos
almacenaremos la suma de los registros r4 y r5 en el registro r9.
Antes de realizar la simulación, observemos una cosa: si el tamaño de cada uno de los registros es de bits, podemos hacer que la RAM esté compuesta de registros de bits y que la cinta de dirección haga que el cabezal de la RAM apunte al primer bit del registro correspondiente. Dicho esto, para realizar la simulación basta seguir el siguiente proceso:
- Colocamos en la cinta de dirección la dirección correspondiente al registro
R0. De este modo, el cabezal de la RAM apuntará al primer bit del registroR0. - Copiamos los contenidos de la RAM (los bits a partir de la posición del cabezal de la RAM) en una cinta nueva, a la que llamaremos .
- Repetimos los pasos anteriores para copiar el registro
R1en la cinta . - Vamos repitiendo los pasos siguientes:
- Comprobamos si en la cinta el número almacenado es . En tal caso, seguimos al paso 5.
- Si no lo es, decrementamos en el número almacenado en la cinta e incrementamos en el número almacenado en la cinta . Sumar y restar a un número en binario en una cinta es una operación fácilmente implementable en una máquina de Turing.
- Llegado a este punto, en la cinta tendremos almacenado y en la cinta tendremos el resultado de la suma.
- Ahora colocamos en la cinta de dirección la dirección correspondiente al registro
R2. De este modo, el cabezal de la RAM apunta al registroR2. - Copiamos los contenidos de la cinta a la RAM (los bits a partir de la posición del cabezal de la RAM, que apuntarán al registro
R2).
Nótese que la simulación que hemos hecho no modifica los registros R0 y R1, únicamente modifica R2.
El resultado que buscamos está a la vuelta de la esquina. La Proposición 2.20 y la Proposición 2.21 cierran el círculo y permiten probar el Teorema 2.23144.
Un programa en Python puede ser simulado por un ordenador moderno.
Demostración
La prueba es inmediata: en el momento en el que podemos simular un ordenador moderno de un solo núcleo, podemos simular cualquier software, incluyendo programas en Python.
Otra forma de ver esto es imaginar que configuramos un ordenador para ejecutar un programa en Python justo al iniciarse. De este modo, podemos pensar en el ordenador como un conjunto de hardware que simula el programa en Python.
Una máquina de Turing de una sola cinta puede ser simulada por un programa en Python.
Demostración
Para probar esto, deberemos de crear un programa en Python que, dada la descripción de una máquina de Turing de una sola cinta , sea capaz de aplicar todos los pasos de cálculo y devolver la configuración en la que la máquina para.
import utilities
from turing import Turing
def simulate_turing(input):
machine_encoding, machine_input = utilities.UAM(input)
# creamos la máquina de Turing a partir de la entrada de la función SISO
turing_machine = Turing(machine_encoding, machine_input)
# ejecutamos la máquina
turing_machine.run()
# si llegamos aquí, la máquina ha parado: devolvemos su configuración
return str(turing_machine)Podemos ver la simulación en el Programa 2.2. Los programas que lo acompañan están en simulate_turing.py y turing.py. Explicaremos cada una de las líneas.
En primer lugar, se importa la clase Turing del paquete turing. Puedes encontrar el código en turing.py. Esta clase se encarga de instanciar una máquina de Turing, e incorpora las funciones necesarias para realizar la simulación.
A continuación tenemos nuestra función main, que acepta un string como entrada. Para poder codificar varias entradas, recurrimos a las funciones MAU y UAM de la librería utilities. Dada una lista de strings, la función MAU (de Múltiple A Uno) los convierte en un único string usando la cadena :: como separador. La función UAM realiza el proceso contrario, y convierte un string en una lista de strings, separando por la cadena ::.
De este modo, el string de entrada tendrá la forma
Es decir, estará compuesto de los strings correspondientes a la codificación de la máquina de Turing y a la entrada unidos por ::.
Codificar una máquina de Turing mediante un string es sencillo. Lo haremos mediante archivos, que no son más que cadenas de caracteres. Codificaremos cada uno de los estados y símbolos con strings, y crearemos una cadena de caracteres uniendo las siguientes cadenas de caracteres mediante saltos de línea, en este orden:
- Los estados, separados por espacios.
- Los símbolos del alfabeto de entrada, separados por espacios.
- Los símbolos del alfabeto de trabajo, separados por espacios.
- El símbolo blanco.
- El estado inicial.
- Los estados finales, separados por espacios.
- Una línea para cada transición
(donde EA es el estado actual, SL es el símbolo leído, ES es el estado siguiente, SR es el símbolo de reemplazo y M es el movimiento, pudiendo M ser L, R o S), con el siguiente formato:
En la línea con UAM, separamos la entrada y guardamos la codificación de la máquina en machine_encoding y la entrada en machine_input, mediante la función UAM anteriormente explicada.
En la línea que crea la instancia de Turing, creamos una instancia de la clase Turing con la codificación de la máquina y la entrada. Al crear la instancia, se ejecutarán funciones que comprueban si la descripción es correcta. En caso de haber un error, la máquina no ejecutará ningún paso y la salida será de tipo error.
En la línea que llama a run, ejecutamos la máquina, aplicando transición tras transición hasta que pare. En caso de no parar, el programa en Python tampoco parará.
En la línea de retorno, devolvemos la configuración de la máquina al haber parado, y si acepta o rechaza.
De esta forma hemos simulado mediante un programa en Python.
El programa simulate_turing está implementado en simulate_turing.py. Mostraremos un ejemplo de la codificación explicada, y cómo usarlo, mediante el Ejemplo 2.22.
Una codificación de la máquina del Ejemplo 2.11 puede ser:20
q_0 q_a q_b q_R q_F # states
a b # input alphabet
a b X _ # work alphabet
_ # blank symbol
q_0 # initial state
q_F # final states
# transitions
q_0, a : q_a, X, R
q_0, b : q_b, X, R
q_0, X : q_0, X, R
q_a, a : q_a, a, R
q_a, b : q_R, X, L
q_a, X : q_a, X, R
q_a, _ : q_F, _, S
q_b, a : q_R, X, L
q_b, b : q_b, b, R
q_b, X : q_b, X, R
q_R, a : q_R, a, L
q_R, b : q_R, b, L
q_R, X : q_R, X, L
q_R, _ : q_0, _, REsta codificación se encuentra en turing_machines/more_as_than_b.mt. Para probar simulate_turing.py, clona intro-to-incompleteness, abre el intérprete de Python en su raíz y ejecuta:
from utilities import read, MAU
from simulate_turing import simulate_turing
encoding = read('./turing_machines/more_as_than_b.mt')
input_str = 'aababb'En este caso hemos puesto como entrada, pero se puede reemplazar por cualquier entrada de . Para ejecutar la función simulate_turing, simplemente hacemos:
simulate_turing(MAU(encoding, input_str))
'q_0 : X X X X X X [_] (rejects)'Vemos que el programa Python devuelve la cadena de caracteres 'q_0 : X X X X X X [_] (rejects)'. Es decir, la máquina de Turing para en la configuración
que no es un estado final (), y por tanto la entrada es rechazada.
Es posible probar con cualquier entrada y con otras máquinas de Turing de turing_machines/. Algunos de los archivos corresponden a codificaciones incorrectas, para poder ver cómo simulate_turing.py es un programa en Python tal y como lo hemos definido (SISO, sin errores, etc.).
Una vez hemos probado la secuencia de simulaciones de la Figura 2.6, el teorema siguiente queda demostrado.
Dado un programa en Python , existe una máquina de Turing (de una sola cinta) que computa la misma función que .
Acabamos de probar que las máquinas de Turing y los programas en Python son iguales en términos de los programas que podemos escribir en ellos. Esto nos será útil cuando definamos el concepto de problema en el Capítulo 3.
2.4 Relación con la tesis de Church-Turing
El trabajo que hemos realizado en este capítulo ha sido arduo, pero el resultado que hemos obtenido es extremadamente interesante. Sin embargo, debemos recordar que la elección del lenguaje de programación Python ha sido completamente arbitraria: podríamos sustituir a Python por cualquier otro lenguaje de programación capaz de codificar una máquina de Turing.
¿Qué nos dice esto de lo que puede ser computado? Podemos verlo de dos formas: por una parte, los programas en Python (así como los ordenadores en sí) no son más que máquinas de Turing con «muchos atajos» y extensiones. Por otra (y de forma más interesante), la máquina de Turing, que inicialmente puede parecer inocente por la sencillez de su descripción, tiene un potencial de cálculo enorme, pues puede ejecutar cualquier programa de ordenador.
En el Capítulo 1 ya expusimos la tesis de Church-Turing. Por conveniencia, la referenciamos de nuevo a continuación.
Tesis de Church-Turing. Toda función calculable (algoritmo) puede calcularse mediante una máquina de Turing.
Como hemos dicho, se trata de una tesis, es decir, es una afirmación que se supone pero que no se demuestra. Probar este resultado es prácticamente imposible, pero hay evidencias que lo refuerzan, como lo que acabamos de probar: hemos visto cómo un modelo de computación, en apariencia, mucho más complejo que la máquina de Turing, resulta ser equivalente a ella.
Sin embargo, siempre existe la posibilidad de que avances en la comprensión de cómo se comporta el universo nos abran las puertas a nuevos y más potentes modelos de cálculo. Este no es el caso de la computación cuántica, que puede acelerar la resolución de algunos problemas, pero no amplía la gama de funciones calculables21.
Esta tesis tiene profundas implicaciones. Concluiremos este capítulo con algunas preguntas.
- ¿Es la mente humana una máquina de Turing?
- ¿Es el universo una máquina de Turing? Esto es, ¿son las leyes que rigen el universo (las leyes de la física) computables?
- En caso de que esto no sea así, ¿es posible crear una máquina más poderosa que la de Turing?
Respecto a la primera pregunta, es importante realizar una puntualización histórica: Turing afirmaba que la respuesta era afirmativa, es decir, que la mente humana podría simularse en un ordenador. Esta tesis fue central en sus trabajos de Inteligencia Artificial22, en los que defendía que el desarrollo de esta disciplina solo era cuestión de tiempo.
Notas al pie
Aiken, H. (1956). Elektronische Rechenmaschinen und Informationsverarbeitung. Nachrichtentechnische Fachberichte, 2:32–34. (Traducción al inglés en MacCormick4.)
↩Turing, A.M. (1937). On computable numbers, with an application to the Entscheidungsproblem. Proceedings of the London Mathematical Society, 42(1):230–265.
↩ ↩2 ↩3Martelli, A. (2006). Python in a Nutshell. O'Reilly Media.
↩MacCormick, J. (2018). What Can Be Computed?: A Practical Guide to the Theory of Computation. Princeton University Press.
↩ ↩2 ↩3 ↩4Nos referimos a las excepciones que se pueden definir implícitamente en el lenguaje o que pueden resultar de errores en su uso, dado que en la definición de el programa es correcto sintácticamente.
↩La librería
↩picklepuede codificar y descodificar una gran variedad de tipos de datos, incluyendo todos los nativos. Tiene algunas limitaciones para objetos más complejos pero, dado que todos los objetos se pueden reducir a estos tipos de datos, podemos afirmar que cualquier objeto puede ser codificado. Véase Lutz23.Church, A. (1937). Review of Turing 1936–37. Journal of Symbolic Logic, 2(1):42–43.
↩De Mol, L. (2021). Turing machines. In E.N. Zalta (ed.), Stanford Encyclopedia of Philosophy. https://plato.stanford.edu/entries/turing-machine/
↩Esta notación proviene de la operación de clausura de Kleene.
↩Nótese que esta definición permite que la máquina siga cambiando su configuración aun habiendo llegado a un estado final. Normalmente, las máquinas de Turing paran llegado al estado final, y esto es algo de lo que nos podemos asegurar simplemente haciendo para todo , .
↩Usamos la notación: es igual al número de apariciones del símbolo en .
↩No introduciremos el concepto de «problema» con precisión hasta el Capítulo 3 — por ahora, nos quedaremos con la noción intuitiva de problema, sin mayor explicación.
↩Evans, B., & Flanagan, D. (2018). Java in a Nutshell. O'Reilly Media.
↩Sipser, M. (2012). Introduction to the Theory of Computation (3rd ed.). Cengage Learning.
↩ ↩2Basada en la demostración del teorema 3.13 de Sipser14.
↩Siempre es posible cambiar la posición inicial del cabezal de una máquina de Turing ejecutando las transiciones necesarias.
↩Suponemos .
↩Nisan, N., & Schocken, S. (2005). The Elements of Computing Systems: Building a Modern Computer from First Principles. MIT Press.
↩Mediante procesos de compilación y/o interpretación.
↩Incluimos comentarios y líneas vacías, que ignoraremos.
↩Kaye, P., Laflamme, R., & Mosca, M. (2007). An Introduction to Quantum Computing. Oxford University Press.
↩Turing, A.M. (1950). Computing machinery and intelligence. Mind, 59(236):433–460.
↩Lutz, M. (2013). Learning Python. O'Reilly Media.
↩