Archivo de la categoría: Complejidad computacional

Números y complejidad computacional: simple algoritmo para encontrar primos

Hola a todos!

Uno de los posts más exitosos en este blog, ha sido desde su publicación, es «un simple algoritmo para encontrar números primos«. El algoritmo no es tan simple cómo lo mencioné originalmente, aunque aún creo que la idea de almacenar persistentemente los resultados es bastante buena.

Los comentarios giraron mucho en torno a la complejidad del algoritmo. Muchos creen que un algoritmo al ser pequeño (de poco texto) es un algoritmo simple. Sin embargo, no esto no es del todo cierto.

Para detallar lo anterior, he escrito un post donde hablo de la complejidad computacional y porqué es importante. Un problema puede tener muchas soluciones, pero aplicar cada una de ellas puede ser costoso en distintas formas: algunas soluciones tendrán ventajas sobre otras desde distintos puntos de vista.

Por ahora vamos a casarnos con el tradicional punto de vista del tiempo. Queremos resolver un problema en la menor cantidad posible de pasos. En este caso, el problema de encontrar primos.

Empecemos por una solución «fácil»; al menos, fácil a la vista. Para esta parte, quiero estrenar esta forma mostrar código en junto con mis publicaciones en distintos lenguajes de programación. Así, si no estás acostumbrado a mi pseudo-código, a Java o a C; podrás elegir ver el código en el lenguaje que prefieras. Las implementaciones que ofrezco de inicio no son las únicas que podría presentar; se aceptan sugerencias para agregar más implementaciones en otros lenguajes de programación (aunque a mi juicio, ya son bastantes 😛 ).

Desgraciadamente, WordPress.com no permite insertar objetos HTML en los posts directamente, así que me limitaré a dejar el enlace al código.

http://www.nachintoch.mx/code

En fin. Tomando el pseudocódigo como modelo, el algoritmo realiza 11(n /2 -1) +3 operaciones para el peor caso, por lo que el algoritmo tiene una función de complejidad en tiempo lineal. Hasta aquí no se ve tan mal; de hecho el algoritmo «simple» ya incluye algunas optimizaciones: ningún número es divisible por otro más grande que su mitad y excluir al 1 del ciclo nos da otra pequeña ventaja al eliminar sus respectivas 11 operaciones elementales. Pero el algoritmo aún puede mejorarse aún más; ya que por ejemplo, más que la mitad, ningún número es divisible por un número más grande que su raíz.

Además, podemos usar propiedades de números y algunos resultados conocidos en la teoría de números, muestras numéricas o probabilidades para reducir el tiempo del algoritmo. También podríamos a recurrir a la paralelización: crear hilos cada un cierto intervalo de posibles divisores y hacer que todos los hilos concurrentemente («simultáneamente») verifiquen si el número es divisible por algún número en el intervalo que le ha tocado verificar.

Todo esto puede parecer demasiado; el algoritmo (en pseudo-código) es de apenas 6 líneas pequeñas de código ¿no es ya suficientemente bueno, pequeño y rápido? ¡Ni siquiera va a iterar tantas veces como el valor del número que se quiere saber es primo!

Aunque es cierto que se ve pequeño y su complejidad no es tan alta, veamos que pasa en los campos de aplicación del problema.

En la teoría y en como primeras prácticas de programación, este es un problema común que se puede resolver con cualquiera de los algoritmos ya mostrados. Sin embargo, en la práctica; una de las áreas donde determinar si un número es primo o no es crucial, es en la seguridad informática.

Una de las «piedras angulares» de la seguridad informática es la dificultad de encontrar números primos. Pero ¿qué dificultad si ya hemos resuelto el problema con un algoritmo simple? Por ejemplo, los certificados y las capas de encriptación que usa la web con HTTPS; intercambian  información cifrada por una llave de 256 bits (comúnmente); es decir, números que pueden ser tan grandes cómo 2²⁵⁶; para darnos una idea, puede ser un número decimal hasta de 78 dígitos. ¿cómo se ve un número de 71 dígitos?

10000000000000000000000000000000000000000000000000000000000000000000000

Y hay muchos más números como ese. ¿cuánto tardaría nuestro programa en determinar si uno de estos números de 71 dígitos es primo?

Considerando el número anterior cómo n, entonces la función de complejidad de nuestro algoritmo T(n) = 11(n /2 -1) +3 nos da 55×10⁶⁹.

Si cada operación elemental pudiera ser realizada en tan solo un milisegundo, entonces el número anterior es la cantidad de milisegundos necesarios para determinar si 1×10⁷⁰ es primo o no (no lo es, porqué es par. Pero seguro algún número cercano a él es primo; y no sólo los cercanos más pequeños, sino también números más grandes). Para ponerlo en perspectiva, los milisegundos anteriores son equivalentes a 1.8945×10⁶⁰ años. Si por el contrario, cada operación se realizara en un nanosegundo, el tiempo que requerimos para determinar si 1×10⁷⁰ es primo son 1.8945×10⁵⁴ años… El tiempo que se estima le queda de vida al son 5 mil millones de años; 5,000’000,000 = 5×10⁹. Nadie; ni si quiera el sol, tienen tiempo de esperar a que nuestro algoritmo determine si estos números son primos.

Usualmente, las nuevas versiones de este tipo de sistemas de seguridad, tienden a aumentar el tamaño de las llaves que usan; normalmente involucrando números primos en alguna forma importante; precisamente porque determinar si un número tan grande como estos es primo, es computacionalmente muy muy costoso. De allí la importancia de buscar nuevos algoritmos para este fin: más rápidos y eficientes.

Desde los días en los que «hablaba con mi hermano» he aprendido que no es trivial crear este tipo de soluciones para estos problemas. Requieren de un profundo estudio y si el lector encuentra curiosidad por esto, los invitaría a buscar y revisar publicaciones académicas donde aparecen propuestas de algoritmos para encontrar primos grandes de forma eficiente y cómo funcionan los algoritmos utilizados en la práctica para este mismo fin.

La moraleja que nos puede quedar, es que por muy simple e inofensivo que parezca un problema; su solución puede ser de grandes implicaciones y puede ser mucho más complicada de lo que aparenta… ¿nunca se ha preguntado cuan difícil es; computacionalmente hablando, resolver una torre de Hanoi de 64 discos? ¿podría el Sol esperar a ver esa solución?

Que el límite sea la imaginación.

Spoiler alert: no, ni el Sol puede esperar a resolver una torre de 64 discos si cada operación toma un milisegundo.

Complejidad Computacional: ¿Qué tiene que ver el término NP-hard con Nintendo-hard?

Hola a todos!

Tiene ya casi un año que tengo esta publicación entre mis borradores; que diversas circunstancias, he dejado junto con otras que he publicado últimamente o estoy en proceso de publicar.

Este post, tiene tanto un objetivo divulgativo como un objetivo divertido y tiene mucho que ver con uno de los últimos posts sobre complejidad, donde explico qué es a muy grandes rasgos y cuál es su utilidad. Aquí les dejo un enlace; ya que si no conoces lo que es la complejidad computacional, podría ser difícil entender este post.

Bien, con eso aclarado, continuemos a la parte informativa.

Como sabemos; existen problemas que «no podemos resolver» sin usar algo que se conoce como el No Determinismo. Justo como con la mayoría de algoritmos (y programas) que conocemos y a los que estamos acostumbrados, podemos calcular su tiempo de ejecución y acotarlo usando alguna función matemática. Comentaba sobre NP – algoritmos no deterministas con tiempo de ejecución polinomial (queda acotado por un polinomio de algún grado).

Pues resulta que la universidad de Cornell (NY, EE UU) publicó un artículo en 2015 donde se hace un estudio que concluye en que:

Los títulos de videojuegos de franquicias de Nintendo como Super Mario, The Legend of Zelda, Donkey Kong, Metroid y Pokémon; representan problemas NP-difíciles.

En la jerga gamer, se le suele llamar Nintendo-hard a los videojuegos que son difíciles; mucho muy difíciles, indiferentemente que hayan sido producidos y/o publicados por Nintendo. Esto en referencia a que los juegos clásicos de Nintendo (como los del estudio), son muy muy difíciles.

Además, consideremos que cualquier problema NP puede ser convertido en una instancia de otro problema NP-díficil; es decir, los problemas NP-difíciles pueden resolver otros problemas NP (usando alguna transformación del problema NP original). Así entonces,

Podemos decir que ya que los juegos clásicos de Nintendo (al menos los del estudio) representan problemas NP-díficiles y además son Nintnedo-hard; entonces los problemas NP-hard o NP-díficiles, son también problemas computacionalmente Nintendo-hard o Nintendo-difíciles.

Así, ya sea que P = NP o no; al menos NP-hard = Nintendo-hard.

Y así, el artículo con más facha académica en este blog, es también uno de los más nerdos.

Referencias.

  1. Aloupis, G.; Demaine, E. D.; Guo, A.; Viglietta, G., (2015), «Classic Nintendo Game are (computationally) Hard» en Cornell University Library. [En línea]. EE UU, disponible en: https://arxiv.org/abs/1203.1895 [Accesado el día 17 de octubre del 2016].
  2. Arora, S. y Barak, B., (2007), «Computational complexity: a modern aproach», Cambridge University Press.

Complejidad computacional: ¿qué es?

Hola a todos!

Estoy redactando un post donde retomo el problema de generar o determinar si un número entero dado es primo, ya que creo que el post original da mucho de que hablar y no lo he hecho en bastante tiempo. Sin embargo, en el post donde hablo del problema originalmente, se llegó a debatir mucho sobre su eficiencia o complejidad, pero parece que esto metía mucho ruido y no ha sido clarificado del todo, al menos en este blog.

Así que decidí escribir este post adicional, donde pretendo explicar de manera simple y superficial qué es la complejidad computacional, o para fines prácticos, qué es la complejidad de un algoritmo.


Vamos a empezar con un simple ejemplo. Supongamos, por ejemplo que queremos calcular una potencia de 2 a un número entero positivo cualquiera. Es decir, queremos crear un programa de computadora que nos diga cuál es el resultado de 2^a, con a un entero positivo.

Una primera solución, sería la siguiente:

Power(Integer a)
Begin
    Integer x <- 2
    while(a > 1) do
        x <- 2 *x
        a <- a -1
    end
    return a
End

Llamemos a esta solución solución1. La solución es correcta; en cuanto a elevar 2 a a, lo hace correctamente. Entonces, ¿tiene algo de malo?

La mayoría de las arquitecturas, nos permiten hacer uso de una operación llamada corrimiento de bits. El corrimiento de bits, consiste en desplazar todos los bits de un registro del procesador en alguna dirección, a la izquierda o la derecha (tomando en cuenta si la arquitectura en cuestión es Big Endian o Little Endian). Por ejemplo, si tenemos una arquitectura Little Endian, donde la memoria representa los valores tal y cómo lo haríamos normalmente (el LSB es el que está al extremo derecho de número, escribiéndolo horizontalmente), supongamos que tenemos almacenado en memoria el valor o0100101 (37 en binario) en un registro del procesador y hacemos un corrimiento de 2 bits a la izquierda.

El resultado de dicho corrimiento dejaría al registro con el siguiente valor: 10010100 (148 en binario) Si nos ponemos a escribir más ejemplos cómo este, notaremos que ocurre un patrón: el corrimiento, cuando hace crecer el valor del número en su representación en los registros del procesador, equivale a multiplicarlo por la potencia de 2 a la número de bit corridos. En el caso anterior, teníamos 37 y lo corrimos 2 bits, entonces 2² = 4 y 37 *4 = 148, que fue el resultado del corrimiento.

El corrimiento de bits, cómo la multiplicación, negación o asignación, es una operación definida en el conjunto de instrucciones de la máquina (en casi todos los casos), por lo que podemos pensar que se realiza en una sola unidad de tiempo.

Los lenguajes de programación, incluso aquellos «temidos» y considerados cómo de «medio nivel» (si eso existe), cómo C, abstraen la representación numérica del procesador y simplemente podemos correr, suponiendo que todas las computadoras son little endian. Esta operación normalmente se usa con «>>» para corrimiento a la derecha y «<<» para corrimiento a la izquierda en la mayoría de los lenguajes: C, Java, Phyton, PHP…

Entonces, felizmente, podemos escribir el siguiente programa que resuelve el problema originalmente planteado: elevar un número entero positivo cualquiera a a otro b potencia de 2:

 Power(Integer a)
 Begin
    return 2 << --a {pow como el de súper Mario}
 End

Punto número 1 que reconocerán la mayoría de los programadores: es un programa muy pequeño comparado con el original. Punto número 2: es mucho más rápido. Llamemos a esta solución solución2. Aquí retomamos la cuestión de la complejidad.


No sólo el número de instrucciones cambia en el algoritmo, sino que también cambia el número de instrucciones que se van a ejecutar. ¿qué quiere decir esto?

Supongamos que a = 5.

Según la solución1:

  1. Creamos x.
  2. Hacemos x = 2.
  3. Entramos a un ciclo, preguntando si a = 5 > 1.
  4. Esto es verdad y continuamos en el ciclo.
  5. hacemos x = 2 *x que es x = 2 *2 = 4
  6. hacemos a = a -1 que es a = 5 -1 = 4.
  7. Repetimos el ciclo, preguntando si a = 4 > 1.
  8. Esto es verdad y continuamos en el ciclo.
  9. hacemos x = 2 *x que es x = 2 *4 = 8
  10. hacemos a = a -1 que es a = 4 -1 = 3
  11. Repetimos el ciclo, preguntando si a = 3 > 1.
  12. Esto es verdad y continuamos en el ciclo.
  13. hacemos x = 2 *x que es x = 2 *8 = 16
  14. hacemos a = a -1 que es a = 3 -1 = 2
  15. Repetimos el ciclo, preguntando si a = 2 > 1.
  16. Esto es verdad y continuamos en el ciclo.
  17. hacemos x = 2 *x que es x = 2 *16 = 32
  18. hacemos a = a -1 que es a = 2 -1 = 1
  19. Repetimos el ciclo, preguntando si a = 1 > 1.
  20. Esto es falso, y salimos del ciclo.
  21. Devolvemos el valor de a (32 que en efecto es 2⁵ cómo podrá corroborar cualquiera con suficiente papel, lápiz y paciencia o una calculadora).

Contamos 21 instrucciones sólo para hacer 2⁵, ¿qué pasaría si ahora a = 88888? El algoritmo sería linealmente más lento. ¿Qué quiere decir esto?

Para saber cuánto tiempo se va a tardar un algoritmo en ejecutarse, no es necesario probarlo con todas las posibles entradas (por ejemplos, todas las combinaciones de enteros positivos de 64 bits para a y b). Si no que contamos las instrucciones en el algoritmo. Para esto usamos las operaciones elementales.


Las operaciones elementales, son un conjunto de operaciones que se considera se realizan en un tiempo constante y bien definido; aunque no necesariamente todos los procesadores y arquitecturas lo hacen en sólo un tiempo (u operación) en la vida real.

A continuación, va una lista de las operaciones elementales más comunes (indicando el operador con el que se utiliza en varios lenguajes de programación):

  • . Acceso. Accede a una localidad de memoria. Esto incluye la lectura de variables, constantes e invocación de rutinas.
    • ++ Incremento. Suma 1 a la variable (o registro) al que se aplique.
  • — Decremento. Resta 1 a la variable (o registro) al que se aplique.
  • ! Negación. Invierte el valor de un booleano.
  • >> Corrimiento de bits a la derecha.
  • << Corrimiento de bits a la izquierda.
  • (<tipo de datos>) Cast. Convierte un tipo de datos en otro.
  • new Instanciación. Crear un objeto (POO).
  • * Producto. Multiplica dos números.
  • / División. Divide dos números.
  • % Módulo. Devuelve lo que restaría de dividir un número entre otro. Por ejemplo 5 % 2 = 1.
  • + Suma. Suma dos números.
  • – Resta. Resta dos números.
  • < Menor que. Indica si un número tiene un valor menor que alguno otro.
  • > Mayor que. Indica si un número tiene un valor mayor que alguno otro.
  • <= Menor o igual que. Indica si un número tiene un valor menor o igual que alguno otro.
  • >= Mayor o igual que. Indica si un número tiene un valor mayor o igual que alguno otro.
  • == Igual a. Indica si dos valores son iguales.
  • != Diferente de. Indica si dos valores son diferentes.
  • && AND. Devuelve verdadero si y sólo si los booleanos que compara son verdaderos. En otro caso, devuelve falso.
  • || OR. Devuelve verdadero si alguno o ambos de los booleanos que compara es verdadero. Si ambos son falsos, devuelve falso.
  • = Asignación. Asigna un valor.
  • Saltar a una dirección de memoria también es una operación elemental. Esto incluye entrar en un if, un while, for, un goto, llamar a una función, devolver un valor (return), etc.

Conociendo ahora las operaciones elementales, analicemos el caso de la solución2.


Resolvamos exactamente el mismo problema, 2⁵ utilizando la solución2. De acuerdo a la misma, el procedimiento es el siguiente:

  1. Decrementamos el valor de a en una unidad, esto es a = 5 -1 = 4.
  2. Corremos el valor de 2 (00000010) 4 veces a la izquierda (00100000 = 32).
  3. Devolvemos el valor obtenido (00100000 = 32).

¡Sólo 3 operaciones contra las 21 de la solución1! Además, en la solución 1 hicimos trampa de alguna manera, ya que al no conocer las operaciones elementales, contábamos en una sola operación cosas cómo while(a > 1), donde hay 3 operaciones elementales: un salto en el flujo del programa (while), un acceso a memoria (el valor de a) y una comparación (>). También en las operaciones cómo a = a -1 se hizo un conteo inadecuado, ya que a diferencia de a–, a = a -1 no es una operación elemental, si no otras 3: el acceso al valor de a, la resta con 1 y la asignación del resultado de la resta a la dirección de a.

La solución1 es mucho más lenta que sólo 21 operaciones, pero la solución2 sólo hace 3 operaciones.

Además, note que el número de veces que se repite el ciclo de la solución1 depende de a. Prácticamente, el ciclo ocurre tantas veces cómo el valor de a. En general, si a = n, entonces el ciclo se repite n -1 veces. A esto se le llama tiempo de complejidad lineal, pues el tiempo de ejecución del algoritmo, depende de a en forma lineal.

Por otro lado,  la solución2 sólo ejecuta 3 operaciones elementales, sin importar el valor de a. A esto se le llama tiempo de complejidad constante, pues el tiempo de ejecución del algoritmo será siempre el mismo, sin importar los valores de entrada que le demos.

En general, queremos diseñar algoritmos de la menor complejidad posible, pero esto no es siempre posible. En todo caso, siempre hay que buscar la forma de hacer que nuestro algoritmo ejecute la menor cantidad de operaciones elementales en función de su vector de valores de entrada.

Espero que hasta aquí pueda apreciar la importancia de la complejidad: un algoritmo es eficiente si su complejidad es eficiente, esto incluye (de acuerdo a los teóricos) a los algoritmos constantes, lineales y superlineales (el tiempo de ejecución es del orden de n*log(n), la base del logaritmo es indiferente, aunque normalmente es 2). A partir de complejidad cuadrada (del orden de n²), se consideran los algoritmos cómo ineficientes, aunque en la práctica existen muchos algoritmos de orden de hasta n⁴ que se ejecutan considerablemente rápido.

Para hacer un poco más clara la importancia de los tiempos de ejecución en función de los valores de entrada (o dicho en otras palabras la complejidad del algoritmo), veamos el siguiente ejemplo:

Haremos una búsqueda en un arreglo de enteros. Considere que el arreglo es representado por una variable de nombre «haystack» y el entero que queremos buscar es representado por la variable «needle». Una de las formas más inmediatas/intuitivas de hacer la búsqueda es mediante una exploración completa: revisar cada localidad de memoria en el arreglo e indicar si en algún momento encontramos la ajuga en el pajar. En pseudocódigo; esto se ve así:

Search(Integer needle, Integer[] haystack)
Begin
    FOREACH (Integer straw IN haystack) DO
        IF (straw = needle) THEN
            RETURN true;
        END IF
    END FOR
    RETURN false;
End

Si nos detenemos a analizar el número de instrucciones que se van a ejecutar en este programa, dependen completamente del tamaño del arreglo «haystack» (pajar). Si el arreglo es de tamaño 1, necesitaremos hacer una sola comparación contra needle (aguja). Si el arreglo es de tamaño 3 y en efecto needle no existe en haystack, deberemos hacer todas las 3 comparaciones.

En general, este tipo de búsquedas en estructuras lineales; como los arreglos, sin poder suponer que están ordenadas en alguna forma, tardan tanto en ejecutarse como el tamaño del arreglo. Es posible que el tiempo de ejecución de un algoritmo dependa de más de un parámetro; y que dependa de más de una forma: por ejemplo, si queremos ordenar los números en haystack de menor a mayor comparando la primer localidad de memoria contra todas las demás y la segunda con la todas las demás…

Sort(Integer[] array)
Begin
    FOREACH (Integer to_compare IN array) DO
        FOREACH (Integer all_others IN array) DO
            IF (to_compare < all_others) THEN
                Swap(all_others, to_compare)
            ENDIF
        ENDFOR
    ENDFOR
End

El programa anterior describe en pseudocógido la idea que sugería hace un momento, donde la instrucción «Swap» de alguna forma realiza el intercambio de la localidad «all_others» con «to_compare» para que el arreglo «array» quede al final ordenado de menor a mayor.

Esta forma tan intuitiva e inmediata de realizar un ordenamiento es ineficiente. Y es un ejemplo de un algoritmo con tiempo de ejecución de orden cuadrado; pues los dos FOR dependen del tamaño de array, y uno se repite por cada iteración en el primero; así que por cada elemento en array, hay que recorrer todo array y así el número de instrucciones es el cuadrado del tamaño de array.

Esto es solo un pequeño esbozo de lo que son las categorías de complejidad computacional. La categoría más «simple», es denominada P; de polinomial. Contiene a todos los algoritmos que se ejecutan en algún orden de complejidad de tiempo cuya función puede quedar acotada por un polinomio.

Los dos ejemplos anteriores están en P, ya que por ejemplo; la búsqueda tiene complejidad de tiempo lineal, que depende de n directamente y esto es por supuesto acotado por un polinomio de grado 1. El ordenamiento queda acotado de la misma forma por un polinomio de segundo grado.

Arriba de P, tenemos NP. No voy a entrar en detalle de lo que significa un programa «no determinista»; ya que puede resultar confuso para algunos lectores. Sin embargo, es un tema muy interesante y recomendaría a todos los curiosos que busquen bibliografía académica al respecto (libros de teoría de la computación son un buen punto de inicio).

A grandes rasgos; un programa no determinista, se caracteriza por contar con puntos donde puede divergir a varias opciones arbitrariamente. Imagine un IF donde podemos ir tanto al THEN como al ELSE simultáneamente y sin mayor contrariedad… Y sí, esto tiene que ver con la computación cuántica.

Hay muchos problemas que se salen de la categoría P (polinomial), pues si tiempo de ejecución no puede ser modelado por ningún polinomio, sino por funciones que crecen más rápido: como las exponenciales o los factoriales; y algunas de estos problemas que se salen de P pueden resolverse con algoritmos que pertenecen a la clase NP.

Un; a mi gusto, excelente ejemplo de este tipo de problemas, es el problema de las torres de Hanoi. Las torres de Hanoi, son un juego propuesto por el matemático francés Èduard Lucas; quien al parecer inventó la leyenda de los monjes que tenían que mover 1 disco al día.

El problema de las torres de Hanoi es bastante divertido; consiste en lo siguiente:

  • Se cuenta con tres postes (palos) y n discos. n por supuesto debe ser un número entero no negativo; o natural para los cuates.
  • Cada disco tiene un radio único.
  • Inicialmente, todos los discos están apilados en el mismo poste.
  • Inicialmente, la torre original de discos; está ordenada de mayor a menor, contando a partir de la base de la torre (el disco más pequeño está hasta arriba).
  • El objetivo del juego es mover todos los discos a alguno de los otros dos postes con las siguientes restricciones:
    • Sólo es posible mover un disco a la vez.
    • Sólo es posible colocar un disco de radio menor sobre otro de radio mayor; o en un poste vacío.

Resolver el problema de las torres de Hanoi; la mejor solución posible al problema, aquella con el menor número de pasos para reacomodar la torre en otro poste, resulta en un algoritmo (determinista) que tiene una función de complejidad de orden exponencial. Más precismente es: 2^n -1 (recuerde que n es el número de discos).

Es decir, si tenemos una torre de apenas 64 discos; tendremos que realizar 1.84467440737 x10¹⁹ movimientos. Si pudiéramos realizar cada movimiento en un milisegundo (imposible para cualquier persona), esto nos tomaría 1.84467440737 x10¹⁶ segundos, o lo que es lo mismo 3.07445734562 x10¹⁴ minutos = 5.12409557603 x10¹² horas = 213503982335 días = 30500568904.9 semanas = 7625142226.24 meses…

O lo que es lo mismo; 635’428,518.853 años ¡más de 600 millones de años! Ni siquiera el Sol tiene tanto tiempo… Y no existe forma de resolver Hanoi con una complejidad más amigable, esta es la mejor solución. Si está interesado en conocer más sobre el problema de las torres de Hanoi, cuento con notas al respecto que puede consultar en: http://www.nachintoch.mx/teaching/intro_data_structure/


Y ya para concluir, espero que con esta simple y breve explicación quede un poco más clara la idea de complejidad y porqué buscar soluciones más y más eficientes a los problemas es importante.

En particular; me interesa la cuestión de número primos, ya que los informados sabrán que encontrar primos es computacionalmente muy ineficiente; y este único hecho es lo que hace funcionar a nuestros sistemas criptográficos y todos los sistemas de seguridad informática, de alguna forma se basan en el hecho de que encontrar primos es computacionalmente difícil; lo que me parece maravilloso y peligroso. Si alguien encontrara formas eficientes de encontrar primos, se acaba lo poco que nos queda de privacidad en línea.

Espero haber aclarado algunas ideas y haber interesado algunos otros en aprender un poco más; ya sea en cuanto a la teoría de la computación, complejidad computacional, estructuras de datos o incluso seguridad informática (lo que puede incluir teoría de números y criptografía). Sin más, me despido. Trataré de seguir escribiendo algunos otros post que he tenido en borrador los últimos dos años.

Y no olviden que el límite es la imaginación… Y el tiempo que pueda tardar en ejecutarse un programa :S

Java: recursión y programas iterativos

Hola a todos!

Les comparto unas notas sobre recursión, donde se explica de manera muy breve qué es la recursión y se centra más en ejemplos y ejercicios al respecto. El ejercicio consiste en definir una función recursiva que realice la suma de los primos entre 1 y un número n natural dada. Para esto, también habrá que definir una función que determine si un número es primo y para ello también podemos usar recursión y aplicar la función auxiliar que ayuda a determinar si un número es primo para todos sus anteriores.

Por ejemplo, para saber si 6 es primo, necesitamos ver si es divisible entre 5. Como esto es falso, ahora comprobamos contra 4. También es falso. Pero al comprobar contra 3, es cierto que 6 es divisible por 3. Por lo tanto 6 no es primo (ya que tiene un divisor distinto de la unidad y de sí mismo). Pero si hacemos el mismo procedimiento con 7, llegaríamos a 2 notando que nadie entre 6 y 2 divide a 7. Por lo tanto 7 es primo. La función que determina si 7 o cualquier otro número es primo, realizaría este procedimiento de forma recursiva; es decir, llamándose a sí misma, hasta encontrar un divisor del número, o llegar a 2.

Luego, el ejercicio solicita crear una versión iterativa de estas soluciones recursivas, utilizando algún ciclo en lugar de llamadas a la misma función.

No quisiera publicar la respuesta al problema junto con las notas, preferiría que si alguien se quiere tomar la molestia, lo haga en los comentarios en alguna forma.

Enlace al texto: http://www.nachintoch.mx/teaching/intro_data_structure/25_feb-Benchmarks%20y%20recursi%C3%B3n.pdf

Un comentario adicional

Yo sé que estas notas de alguna manera invocan a lo que alguna vez fuera la publicación más visitada y con mayor discusión en los comentarios en este blog: «Un simple algoritmo para calcular números primos» (hasta que se me ocurrió cambiar el nombre de dominio ¬¬). Las amplias discuciones y dudas respecto al porqué algo «tan simple» cómo determinar si el número 1’744,263 es primo, daban para mucho material sobre complejidad computacional. Y sí, llevo ya varios meses trabajando en una entrada larga y exhaustiva donde explico qué es la complejidad computacional, porqué el inocente problema de encontrar primos no es trivial, qué es el No-Determinismo y cómo es que todos nuestros sistemas criptográficos (nuestras contraseñas de sesión, facebook, email, contraseñas del IEEE 802.11 (WiFi), etc), dependen del hecho de que encontrar número primos grandes es computacionalmente muy costoso y si un día alguien logra resolver el problema con un algoritmo verdaderamente simple, se nos acaba la seguridad informática como la conocemos, y esa persona se haría acreedora a un premio de 1 millón de dólares (es en serio).

Pero desgraciadamente, por falta de tiempo y la mala costumbre de dormir varias horas de vez en cuando, no la he podido terminar aunque ya va bastante avanzada dicha entrada. Sus comentarios y aportaciones son muy valiosas y estímulos para retomarla más seguido.

En fin, espero estas «simples» notas, les sean de utilidad 😛

Saludos!

Cómputo concurrente: Más soluciones simples al problema del consenso

Hola a todos!

Sólo para compartirles el contenido sobre la última clase que di, son dos simples soluciones al problema del consenso.

La primera es una solución para cualquier número de procesos, que ceden la decisión a dos procesos distinguidos.

La segunda, es sólo para dos procesos, pero usa una cola concurrente y representa al ganador de la toma de decisión con un estado de la cola misma, más que con un contenido en la misma.

A continuación el enlace:

Haz clic para acceder a 18nov-solucionesalconsensoconejemplos.pdf

Espero les sean de utilidad!