BioInformática Problemas P – NP Dr. Eric Jeltsch F. Introducción: Las computadoras actuales de silicio están comenzando a mostrar sus limitaciones: se están alcanzando los límites impuestos por las leyes de la física en cuanto a la miniaturización de los componentes necesaria para aumentar la velocidad de proceso y la capacidad de almacenamiento en las memorias de silicio está siendo sobrepasada con creces por otras tecnologías (memorias holográficas, ADN). La barrera de los petaflops (1015 operaciones por segundo) necesaria para complejos problemas de simulación y optimización y para muchos problemas de la IA no se alcanzará con computadoras paralelas de silicio clásicas. La computación biomolecular (con ADN, ARN, proteínas, o con membranas) o la computación cuántica quizás no sustituyan a los semiconductores de silicio en los PC's que tenemos sobre nuestras mesas pero probablemente entren en juego como potentes tecnologías de la futura super-computación paralela avanzada. Estos nuevos modelos no convencionales no son simples mejoras sobre modelos anteriores de cómputo sino que aprovechan el paralelismo inherente de los procesos biológicos (ensamblamiento o hibridación del ADN) y de los procesos físicos (superposición de estados cuánticos). La frontera entre los problemas `tratables' y los `intratables' o no resolubles de forma eficiente ha permanecido prácticamente inalterada durante los últimos 40 años. La computación cuántica parece que ha desplazado esta frontera y demuestra su hegemonía (al menos teórica) frente a los modelos de cómputo clásicos (máquinas de Turing, RAM, circuitos) al comprobarse que pueden resolver en tiempo polinómico problemas que cualquier modelo clásico resolvería en tiempo exponencial; por ejemplo, el problema de la factorización de números de gran importancia en la criptografía actual. Creemos que la investigación en la frontera de la biología y la física con las tecnologías de la información puede llevar al desarrollo de nuevos e importantes sistemas de información (algoritmos y software) y tecnologías de computación (hardware). La cuestión es qué y cómo podemos aprender (y entender) de los sistemas biológicos y físicos y cómo podemos adoptarlos y adaptarlos para desarrollar las tecnologías de la información del futuro. La computación biomolecular y la computación cuántica persiguen estos objetivos. El avance tecnológico actual está permitiendo manipular de forma cada vez más precisa la materia a nivel molecular e incluso atómico. Estos avances tecnológicos pueden hacer realidad estos dos nuevos modelos de computación. En el siglo XX se han intentado simular procesos computacionales presentes en la Naturaleza. En el siglo XXI, los esfuerzos se encaminarán a utilizar la propia Naturaleza para realiza cómputos. Estos estudios nos permitirán también descifrar las leyes del procesamiento de la información en la Naturaleza. Una teoría única de la información que incluya la física, la computación y la biología. Problemas P- NP Para muestra un botón. Clases de complejidad más importantes L | NL | P | NP | Co-NP | NP-C | Co-NP-C | NP-hard | UP | #P | #P-C | NC | P-C PSPACE | PSPACE-C | EXPTIME | EXPSPACE | BQP | BPP | RP | ZPP | PCP | IP | PH ___________________________________________________________________ Escuela de Ingeniería en Computación, Universidad de La Serena 1 BioInformática Problemas P – NP Dr. Eric Jeltsch F. Definición: “La Teoría de la Complejidad Computacional es la parte de la teoría de la computación que estudia los recursos requeridos durante el cálculo para resolver un problema”. Los recursos comúnmente estudiados son el tiempo (número de pasos de ejecución de un algoritmo para resolver un problema) y el espacio (cantidad de memoria utilizada para resolver un problema). Se pueden estudiar igualmente otros parámetros, tales como el número de procesadores necesarios para resolver el problema en paralelo. La teoría de la complejidad difiere de la teoría de la computabilidad en que esta última se ocupa de la factibilidad de expresar problemas como algoritmos efectivos sin tener en cuenta los recursos necesarios para ello. Los problemas que tienen una solución con orden de complejidad lineal son los problemas que se resuelven en un tiempo que se relaciona linealmente con su tamaño. En computación, cuando el tiempo de ejecución de un algoritmo (mediante el cual se obtiene una solución al problema) es menor que un cierto valor calculado a partir del número de variables implicadas (generalmente variables de entrada) usando una fórmula polinómica, se dice que dicho problema se puede resolver en un "tiempo polinómico". Por ejemplo, si determinar el camino óptimo que debe recorrer un cartero que pasa por n casas necesita menos de 50n² + n segundos, entonces el problema es resoluble en un "tiempo polinómico". De esa manera, tiempos de 2n2 + 5n, ó 4n6 + 7n4 - 2n2 son polinómicos; pero 2n no lo es. Dentro de los tiempos polinómicos, podemos distinguir los lineales de orden O(n), los cuadráticos O(n2), cúbicos O(n3), etc... Hoy en día las máquinas resuelven problemas mediante algoritmos que tienen como máximo una complejidad o coste computacional polinómico, es decir, la relación entre el tamaño del problema y su tiempo de ejecución es polinómica. Éstos son problemas agrupados en el conjunto P. Los problemas con coste factorial o combinatorio están agrupados en el conjunto NP. Estos problemas no tienen una solución algorítmica, es decir, una máquina no puede resolverlos en un tiempo razonable. Más adelante se insiste en estas definiciones. La complejidad en tiempo de un problema es el número de pasos que lleva resolver dicho problema, a partir del tamaño de la entrada utilizando el algoritmo más eficiente a disposición. Intuitivamente, si se toma una entrada de longitud n que puede resolverse en n² pasos, se dice que ese problema tiene una complejidad en tiempo de n². Por supuesto, el número exacto de pasos depende de la máquina en la que se implementa, del lenguaje utilizado y de otros factores. Para no tener que hablar del costo exacto de un cálculo se utiliza la notación O. Cuando un problema tiene costo en tiempo O(n²), en una configuración de computador y lenguaje dado, este costo será el mismo en la mayoría de los computadores, de manera que esta notación generaliza la noción de coste independientemente del equipo utilizado. Ejemplos a) Extraer cualquier elemento de un vector. La indexación en un vector lleva el mismo tiempo sea cual fuere el índice que se quiera buscar. Por tanto, es una operación de complejidad constante O(1). b) Buscar en un diccionario tiene complejidad logarítmica. Se puede iniciar la búsqueda de una palabra por la mitad del diccionario. Inmediatamente se sabe si se ha encontrado la palabra o, en el caso contrario, en cuál de las dos mitades hay que repetir el proceso (es un proceso recursivo) hasta llegar al resultado. En cada (sub)búsqueda el problema (las páginas en las que la palabra puede estar) se ha reducido a la mitad, lo que se corresponde con la función logarítmica. Este procedimiento de búsqueda (conocido ___________________________________________________________________ Escuela de Ingeniería en Computación, Universidad de La Serena 2 BioInformática Problemas P – NP Dr. Eric Jeltsch F. como búsqueda binaria o quicksort) en una estructura ordenada tiene complejidad logarítmica O(ln n). c) El proceso más común para ordenar un conjunto de elementos tiene complejidad cuadrática. El procedimiento consiste en crear una colección vacía de elementos. A ella se añade, en orden, el menor elemento del conjunto original que aún no haya sido elegido, lo que implica hacer un recorrido completo del conjunto original (O(n), siendo n el número de elementos del conjunto). Este recorrido sobre el conjunto original se realiza hasta que queda todos sus elementos están en la secuencia de resultado. Se puede ver que hay que hacer n selecciones (se ordena todo el conjunto) cada una con un coste n de ejecución: el procedimiento es de orden cuadrático O(n²). Hay que aclarar que hay diversos algoritmos de ordenación con mejores resultados que el descrito. Clases de complejidad Los problemas se clasifican en conjuntos de complejidad comparable llamados clases de complejidad. Definición: “La clase de complejidad P es el conjunto de los problemas de decisión que pueden ser resueltos en una máquina (de Turing) determinista en tiempo polinómico, lo que corresponde intuitivamente a problemas que pueden ser resueltos aún en el peor de sus casos”. Definición: “La clase de complejidad NP es el conjunto de los problemas de decisión que pueden ser resueltos en tiempo polinómico por una máquina de Turing nodeterminista”. NP es el acrónimo en inglés de Polinómico No determinista (NonDeterministic Polynomial-time). La clase NP es importante porque contiene muchos problemas de búsqueda y optimización para los que se desea saber si existe cierta solución o si existe una mejor solución que las conocidas. Entre estos están el problema del camino Hamiltoniano o problema del viajare, donde se quiere saber si existe una ruta óptima que pasa por todos los nodos en un cierto grafo y el problema de satisfacibilidad booleana (SAT), donde se desea saber si una cierta fórmula de lógica proposicional puede ser cierta para algún conjunto de valores booleanos para las variables. Todos los problemas de esta clase tienen la propiedad de que, sin embargo, cualquier solución suya puede ser verificada efectivamente. Dada su importancia, se han hecho muchos esfuerzos para encontrar algoritmos que resuelvan algún problema de NP en tiempo polinómico. Aún así, para algunos problemas de NP no es posible encontrar siquiera un algoritmo mejor que simplemente realizar una búsqueda exhaustiva (éstos son los problemas del conjunto NP-completo). Definición: “La clase de complejidad NP-completo es el subconjunto de los problemas de decisión en NP tal que todo problema en NP se puede transformar polinomialmente en cada uno de los problemas de NP-completo”. Una transformación polinomial es un algoritmo determinista que transforma instrucciones de un problema en instrucciones del otro. Se puede decir que los problemas de NP-completo son los problemas más difíciles de NP y, muy probablemente, no formen parte de la clase de complejidad P. La razón es que de tenerse una solución polinómica para un problema de NP-completo, todos los problemas de NP tendrían también una solución en tiempo polinómico. Como consecuencia de esta definición, de tenerse un algoritmo en P para uno de los problemas NP-completos, se tendría una solución en P para todos los problemas de NP. Esta definición dada fue propuesta por Stephen Cook en 1971. Al principio parecía sorprendente que existieran problemas NP-completos, pero Cook demostró (teorema de ___________________________________________________________________ Escuela de Ingeniería en Computación, Universidad de La Serena 3 BioInformática Problemas P – NP Dr. Eric Jeltsch F. Cook) que el problema de satisfacibilidad booleana (SAT) es NP-completo. Desde entonces se ha demostrado que otros miles de problemas pertenecen a esta clase, casi siempre por reducción a partir de otros problemas para los que ya se había demostrado su pertenencia a NP-completo; muchos de estos problemas aparecen en el libro de Garey and Johnson's de 1979 Computers and Intractability: A Guide to NPcompleteness. Como ejemplo de un problema NP-completo está el problema de la suma de subconjuntos que se puede enunciar como sigue: dado un conjunto S de enteros, ¿existe un subconjunto no vacío de S cuyos elementos sumen cero? Es fácil verificar si una respuesta es correcta, pero no se conoce mejor solución que explorar todos los 2n-1 subconjuntos posibles hasta encontrar uno que cumpla con la condición. Actualmente, todos los algoritmos conocidos para problemas NP-completos utilizan tiempo exponencial con respecto al tamaño de la entrada. Se desconoce si hay mejores algoritmos, por la cual, para resolver un problema NP-completo de tamaño arbitrario se utiliza uno de los siguientes enfoques: Aproximación: Un algoritmo que rápidamente encuentra una solución no necesariamente óptima, pero dentro de un cierto rango de error. En algunos casos, encontrar una buena aproximación es suficiente para resolver el problema, pero no todos los problemas NP-completos tienen buenos algoritmos de aproximación. Probabilístico: Una algoritmo probabilístico obtiene en promedio una buena solución al problema planteado, para una distribución de los datos de entrada dada. Casos particulares: Cuando se reconocen casos particulares del problema para los cuales existen soluciones rápidas. Heurísticas: Un algoritmo que trabaja razonablemente bien en muchos casos. En general son rápidos, pero no existe medida de la calidad de la respuesta. El saber si las clases P y NP son iguales es el más importante problema abierto en Computación teórica. Incluso ya se ha mencionado el premio de un millón de dólares para quien lo resuelva. Si P = NP, P contendría las zonas NP y NP-completo. Actualmente los investigadores piensan que las clases cumplen con el diagrama mostrado por lo que P y NP-completo tendrían intersección vacía. La importancia de la pregunta P = NP radica en que de encontrarse un algoritmo en P para un problema NP___________________________________________________________________ Escuela de Ingeniería en Computación, Universidad de La Serena 4 BioInformática Problemas P – NP Dr. Eric Jeltsch F. completo, todos los problemas NP-completos (y por tanto, todos los problemas de NP) tendrían soluciones en tiempo polinómico. NP-hard En teoría de la complejidad computacional, la clase de complejidad NP-hard es el conjunto de los problemas de decisión que contiene los problemas H tales que todo problema L en NP puede ser transformado polinomialmente en H. Esta clase puede ser descrita como conteniendo los problemas de decisión que son al menos tan difíciles como un problema de NP. Esta afirmación se justifica porque si podemos encontrar un algoritmo A que resuelve uno de los problemas H de NP-hard en tiempo polinómico, entonces es posible construir un algoritmo que trabaje en tiempo polinómico para cualquier problema de NP ejecutando primero la reducción de este problema en H y luego ejecutando el algoritmo A. Asumiendo que el lenguaje L es NP-completo, 1. L está en NP 2. ∀L' en NP, L' ≤ L En el conjunto NP-Hard se asume que el lenguaje L satisface la propiedad 2, pero no la la propiedad 1. La clase NP-completo puede definirse alternativamente como la intersección entre NP y NP-hard. Ejemplos El problema de la suma de subconjuntos es un problema importante en la teoría de la complejidad y en la criptografía. El problema se define como: dado un conjunto de enteros, ¿existe algún subconjunto cuya suma sea exactamente cero? Por ejemplo, dado el conjunto { −7, −3, −2, 5, 8}, la respuesta es SI, porque el subconjunto { −3, −2, 5} suma cero. Este problema es probablemente el más simple de explicar de los problemas NP-completos. Un problema equivalente es: dado un conjunto de enteros y un entero s, ¿existe algún subconjunto cuya suma sea s? La suma de subconjuntos también puede verse como un caso especial del problema de la mochila. Existen problemas NP-hard que no son NPcompletos, por ejemplo el problema de parada. Este problema consiste en tomar un programa y sus datos y decidir si va a terminar o si se ejecutará indefinidamente. Se trata de un problema de decisión y es fácil demostrar que es NP-hard pero no NPcompleto. Por ejemplo, el problema de satisfacibilidad booleana puede reducirse al problema de parada transformándolo en la descripción de una máquina de Turing que prueba todos los valores de las variables; cuando encuentra una combinación que satisface la fórmula se detiene y en caso contrario reintenta desde el principio, quedándose en un lazo infinito. Para ver que el problema de parada no está en NP es suficiente notar que todos los problemas de NP tienen un algoritmo asociado pero el problema de parada es indecidible. Ejemplos: En el artículo de 2002, "PRIMES in P", Manindra Agrawal con sus estudiantes encontró un algoritmo que trabaja en tiempo polinómico para el problema de saber si un número es primo. Anteriormente se sabía que ese problema estaba en NP, si bien no en NPcompleto, ahora se sabe que también está en P. ___________________________________________________________________ Escuela de Ingeniería en Computación, Universidad de La Serena 5 BioInformática Problemas P – NP Dr. Eric Jeltsch F. Un problema interesante en teoría de los grafos es el de isomorfismo de grafos: Dos grafos son isomorfos si se puede transformar uno en el otro simplemente renombrando los vértices. De los dos problemas siguientes: Isomorfismo de grafos: ¿Es el grafo G1 isomorfo al grafo G2? Isomorfismo de subgrafos: ¿Es el grafo G1 isomorfo a un subgrafo del grafo G2? El problema de isomorfismo de subgrafos es NP-completo. Se sospecha que el problema de isomorfismo de grafos no está ni en P ni en NP-completo, aunque está en NP. Se trata de un problema difícil, pero no tanto como para estar en NP-completo. Otros problemas NP-Completos La forma más sencilla de demostrar que un nuevo problema es NP-completo es primero demostrar que está en NP y luego transformarlo polinomialmente en un problema que ya esté en NP-completo. Por ello, resulta útil conocer algunos de los problemas para los que existe prueba de pertenencia a la clase NP-completo. De algunos de ellos ya se ha hablado y otros los veremos con más detalle a continuación: Problema de satisfacibilidad booleana (SAT), Buscaminas Richard Kaye de la Universidad de Birmingham (England) en “Minesweeper is NP-complete” publicado en Mathematical Intelligencer volume 22 nº4 páginas 9-15, Año 2000 Tetris Problema del ciclo hamiltoniano Problema del vendedor viajero Problema de isomofirmo de subgrafos Problema de la suma de subconjuntos Problema de la clique Problema de satisfacibilidad booleana En teoría de la complejidad computacional, el Problema de satisfacibilidad booleana (SAT) fue el primer problema identificado como perteneciente a la clase de complejidad NP-completo. Los circuitos booleanos constan de puertas lógicas llamadas Y, O ó NO. Los datos o variables que se introducen en estos circuitos son V(verdadero) o F (falso). Cada puerta acepta un determinado número de datos de entrada y devuelve el valor lógico de esta combinación. Por ejemplo, una puerta Y( ) tiene como datos de entrada p, q y devuelve p q, cuyo valor es V si tanto p como q eran V o F en cualquier otro caso. Una puerta NO( ) transforma una entrada V en una salida F y una entrada F en una salida V. El problema SAT se pregunta si, para un circuito booleano dado, existe una selección de datos de entrada que produce una salida V. Si esto puede parecer fácil, no olvidemos que un circuito puede contener una cantidad enorme de puertas lógicas y, por tanto, también una cantidad enorme de datos de entrada. Un ejemplo de SAT sería el saber si existen valores lógicos para p,q,r,s tales que la expresión p r s q rs es cierta. Se deduce que esto ocurre si (p, q, r, s) = (Verdadero, Falso, Verdadero, Falso). Sin embargo, para la expresión p q p q no hay ninguna asignación de verdad de sus variables que la haga verdadera. ___________________________________________________________________ 6 Escuela de Ingeniería en Computación, Universidad de La Serena BioInformática Problemas P – NP Dr. Eric Jeltsch F. Clique En Computación, el problema de la Clique o problema de la liga de amigos es un problema NP-completo según la Teoría de la complejidad computacional. Una clique en un grafo es un conjunto de vértices dos a dos adyacentes. En el grafo de la derecha, los vértices 1, 2 y 5 forman una clique. En cambio, los vértices 2, 3 y 4 no (dado que 2 y 4 no son adyacentes). El problema de la clique es el problema de optimización que consiste en encontrar una clique de tamaño máximo en un grafo (un subgrafo completo de tamaño máximo). Este problema se puede enunciar como un problema de decisión si la pregunta que se hace es saber si existe una clique de tamaño k en el grafo. CLIQUE = {<G, k>| G es un grafo con un clique de tamaño mayor o igual a k} Un ejemplo de algoritmo de fuerza bruta para encontrar una clique en un grafo consiste en listar todos los subconjuntos de vértices V y verificar para cada uno de ellos si forma una clique. Este algoritmo es polinómico si k es una constante pero no lo es cuando se hace depender a k de, por ejemplo, |V|/2. Un mejor algoritmo consiste en arrancar con cliques de un solo elemento (cualquier elemento sirve) e intentar mezclar cliques para obtener otras más grandes, hasta que no queden más mezclas por intentarse. Dos cliques pueden ser mezcladas si cada nodo de la primera es adyacente a cada nodo de la segunda. String Matching (búsqueda en texto) como un problema P. Otro ejemplo de problemas de tipo P, es la solución a través de la Programación Dinámica para String Matching. El problema “string matching” se formaliza como sigue. Consideremos que un texto es un array T[1..n] de longitud n y que los pattern o “formas” es un array P[1..m] de longitud m. Se supone que los elementos de P y T son caracteres extraídos de un alfabeto finito. El array de caracteres de P y T son a menudos llamados string de caracteres. Algoritmos String Matching son frecuentemente utilizados para la búsqueda de formas particulares en las sucesiones de ADN. Ejemplo: El objetivo es encontrar todas las ocurrencias del pattern P = abaa en el texto T = abcabaabcabac. Tal como se ve en Fig. 1 el pattern ocurre solamente una vez en el texto. En este caso todo carácter del pattern esta conectado por una línea vertical y ensombrecido para caracterizar los efectos del matching. Una implementación “naive” es la que se propone, pero muy simple de implementar, la cual se basa en encontrar todos los “shifts” válidos usando un loop que va chequeando las condiciones P[1 ..m] = T[s+1..s+m] para toda de las n – m + 1 posibles valores de s. ___________________________________________________________________ Escuela de Ingeniería en Computación, Universidad de La Serena 7 BioInformática Problemas P – NP Dr. Eric Jeltsch F. Naive-String-Matcher(T, P) n=long[T]; m= long[P] for s=0 to n-m do if P[1..m] = T[s+1..s+m] then print “pattern ocurrió en la posición” s. Aquí se muestra una propuesta de implementación del algoritmo. Implementación del algoritmo y ejecución del ejemplo, considerando que el texto T= abcabaabcabac y el pattern P= abaa, entregando como resultado posición=3 public class KMP_Naive { static int n = 1; private static int[] iniSig (String pattern) { int[] siguiente = new int [pattern.length ()]; int i = 0, j = -1; siguiente[0] = -1; while (i < pattern.length () - 1) { while (j >= 0 && pattern.charAt (i) != pattern.charAt (j)) j = siguiente[j]; i++; j++; siguiente[i] = j; } return siguiente; } public static int kmp_Busca(String texto, String pattern) { int[] siguiente = iniSig (pattern); int i = 0, j = 0; n = 1; while (i < texto.length ()) { while (j >= 0 && pattern.charAt (j) != texto.charAt (i)) { j = siguiente[j]; } i++; j++; if (j == pattern.length ()) return i - pattern.length (); } return -1; } public static void main (String[] args) { String p = "abaa";//pattern P int[] siguiente = iniSig(p); for (int i = 0; i < siguiente.length; i++) System.out.println ("siguiente[" + i + "] = " + siguiente[i]); System.out.println("posicion = " + kmp_Busca("abcabaabcabac", p)); } } La ejecución del mismo considerando como ejemplo el texto T= abcabaabcabac y el pattern P = abaa, entrega como resultado posición = 3. ___________________________________________________________________ Escuela de Ingeniería en Computación, Universidad de La Serena 8 BioInformática Problemas P – NP Dr. Eric Jeltsch F. Ahora si el pattern P es relativamente grande y el alfabeto de donde se extraen los caracteres es razonablemente grande entonces el algoritmo de Boyer-Moore es más eficiente para String matching. Longest Common Subsequence”, (LCS). Dentro de este contexto se encuentran una serie de problemas, entre ellos el hallar la subsecuencia común más larga entre 2 string, conocido como “Longest Common Subsequence”, en adelante (LCS). Este es otro ejemplo clásico de la técnica de programación dinámica. Antes de que definamos el problema general de la subsecuencia común más largo, empezamos con un pequeño ejemplo. Suponga que se da un string (como modelo) y un string más largo (como texto). La idea es saber si los caracteres del modelo aparecen en orden (pero posiblemente separado) dentro del texto. Si ello ocurre, decimos que el modelo es una subsecuencia del texto, y si es la subsecuencia más grande por ejemplo "nomi" es una subsecuencia de "conocimiento" como solución al problema. Formalmente, digamos que una sucesión X = < x1 , x2 , ....., xm > y otra sucesión Z =< z1 , z2 , ....., zk > es una subsucesión de X si existe una sucesión de índices estrictamente creciente i1 , i2 , ....., ik de índices de k, tal que para todo j=1, 2, 3..., k, se tiene x i j = z j. Por ejemplo, Z=<B, C, D, B> es una subsucesión de X= <A, B, C, B, D, A, B>, con subíndices <2, 3, 5, 7>. Dadas dos secuencias X e Y, se dice que una sucesión Z es una subsucesión común de X e Y si Z es una subsucesión de ambas , es decir de X e Y. Por ejemplo, si X= <A, B, C, B, D, A, B> , Y= < B, D, C, A, B, A> entonces la sucesión <B, C, A> es una subsucesión común de X e Y. Observar que <B, C, A> no es la más larga de X e Y, pues <B, C, B, A> es también común a ambas y tiene longitud 4. Notar que tampoco es única, pues la sucesión <B, D, A, B> también lo es. Sin embargo, no existe LCS de longitud 5 o mayor. ¿Por qué podríamos querer nosotros resolver el problema de la subsecuencia común más larga? Hay varias aplicaciones que motivan este estudio. * En la biología molecular. Sucesiones de ADN (genes) pueden representarse como sucesiones de cuatro elementos básicos ACGT que se corresponden a las cuatro submoleculas que forman el ADN. Cuando los biólogos encuentran una nueva sucesión, ellos quieren saber qué otras sucesiones son similares a ella. De allí que la computación de las dos sucesiones similares pasan por encontrar la longitud de su subsecuencia común más largo. * Comparación de archivos. En Unix-Linux comandos "diff" se usan para comparar dos versiones diferentes del mismo archivo, para determinar qué cambios se han hecho al archivo. Funciona encontrando una subsecuencia común más larga de las líneas de los ___________________________________________________________________ Escuela de Ingeniería en Computación, Universidad de La Serena 9 Problemas P – NP BioInformática Dr. Eric Jeltsch F. dos archivos; en donde cualquier línea en la subsecuencia no se ha cambiado. En este caso debemos pensar que cada línea de un archivo es un carácter dentro de un string. Un approach “fuerza bruta” para resolver LCS es enumerar todas las subsecuencias de X y chequearlas todas ellas para ver si es también subsecuencia de Y, hasta encontrar la subsecuencia más larga. Estas observaciones nos dan un algoritmo recursivo altamente ineficiente, pues recordar que toda subsecuencia de X corresponde a un subconjunto de los índices {1, 2, 3,..., m} de X, existiendo 2m subsucesiones de X, de manera que el tiempo es exponencial, haciéndolo impracticable para sucesiones más largas. Basado en Teorema 16.1 pág. 315 (CLR) podemos admitir una subestructura optimal de un LCS, y en este contexto al igual que en el problema de Parentización de Matrices se establece una recurrencia para el costo de una solución optimal. Se define c[i,j] como la longitud de un LCS de secuencias Xi , Yj. Si i=0 o j=0 una de las sucesiones tiene longitud 0, así LCS tiene longitud 0. La subestructura optimal de LCS está dada por la formula recursiva: 0 i0 o j0 c[i, j ] c[i 1, j 1] 1 i, j 0 y xi yi m ax(c[i, j 1], c[i 1, j ]) i, j 0 y x y i i Basada en estas ecuaciones podríamos escribir un procedimiento recursivo para computar la longitud de un LCS de 2 secuencia, pues existen solamente O(mn problemas distintos. int lcs_long(char * A, char * B) { if (*A = = ' \0 ' || *B = = ' \0 ') return 0; else if (*A == *B) return 1 + lcs_long(A+1, B+1); else return max(lcs_long(A+1,B), lcs_long(A,B+1)); } Sin embargo, el objetivo es usar Programación Dinámica. El procedimiento considera 2 sucesiones X = < x1 , x2 , ....., xm > e Y =< y1 , y2 , ....., yn > como entrada, se procede a almacenar los valores c[i,j], en una tabla c[0..m, 0..n] cuyas entradas son computadas primera fila de c es llenada de izq. a der., luego la segunda fila, y así sucesivamente. Al mismo tiempo se mantiene una tabla b[1..m, 1..n] para simplificar la construcción de una solución optimal. LCS-Long(X, Y) m= long[X] n= long[Y] for i=1 to m do c[i,0]=0 for j=1 to n do c[0, j]=0 for i=1 to m do for j=1 to n do if xi = yj then c[i, j]= c[i -1,j -1] + 1 b[i, j] =” “ ___________________________________________________________________ 10 Escuela de Ingeniería en Computación, Universidad de La Serena BioInformática Problemas P – NP Dr. Eric Jeltsch F. if else c[i -1, j ] c[i , j -1 ] then c[i, j]= c[i -1,j ] b[i, j] =” “ else c[i, j]= c[i,j - 1] b[i, j] = ” “ return c y b. La figura siguiente muestra la tabla que se genera por el procedimiento anterior sobre la secuencia X= <A, B, C, B, D, A, B> e Y= < B, D, C, A, B, A>. El tiempo de ejecución del procedimiento es claramente O(mn). El siguiente procedimiento recursivo imprime la salida de LCS de X e Y. La llamada inicial es printLCS(b, X, long[X], long[Y]). printLCS(b, X, i, j). if i=0 o j=0 then return if b[i, j] = “ ” then printLCS(b, X, i -1, j -1) print xi else if b[i, j] = “ ” then printLCS(b, X, i -1, j ) else printLCS(b, X, i , j – 1 ) Para la tabla b en figura anterior este procedimiento imprime”BCBA”. Implementación /* Hallar la Longest Common Subsequence, LCS de 2 secuencias como entrada, usando Programación Dinámica. Considerar que el programa lee los argumentos de la línea de comando. El primer argumento es la cadena X de caracteres o string, en este caso se consideran solo enteros(int) mientras que el segundo argumento es la cadena Y de caracteres o string, también enteros(int). La línea de comando es: x1 x2 ... xm . y1 y2 ... yn. Para notar el termino de una cadena con ___________________________________________________________________ 11 Escuela de Ingeniería en Computación, Universidad de La Serena BioInformática respecto al inicio de la separador. Ejemplo de uso Problemas P – NP otra se considera Dr. Eric Jeltsch F. un punto "." como >java LCS 1 0 0 1 0 1 0 1 . 0 1 0 1 1 0 1 1 0 1 0 0 1 0 1 0 1 0 1 0 1 1 0 1 1 0 LCS tiene longitud: 6 1 0 0 1 1 0 */ public class LCS { public static void main(String[] args) { int[] x, y; // las 2 secuencia int n, m; // x's van de x1 a xn; y's van de y1 a ym int[][] longit; // longit[i][j] es la longitud de la subsucesion //comun mas grande de la long-i subsucesion de los x's, y la long-j //subsucesion de los y's. int[][] reconstruir;//es la variable "b" , //util para simplificar la construccion de una solucion optimal. int i, j, k, xs, ys; // lee la linea de comando con los argumentos k = args.length; i=0; while (! args[i].equals(".")) i++; n = i; m = k - i-1; x = new int[n+1]; // parten los indices en 1 y = new int[m+1]; for (j=0; j<i; j++) x[j+1] = Integer.parseInt(args[j]); for (j=0; j<k-i-1; j++) y[j+1] = Integer.parseInt(args[i+1+j]); longit = new int[n+1][m+1]; reconstruir = new int[n+1][m+1]; System.out.println(); for (j=1; j <= n; j++) System.out.print(x[j]+" "); System.out.println(); for (j=1; j<= m; j++) System.out.print(y[j]+" "); System.out.println(); //Si uno del par de sucesiones tiene longitud 0, entonces el //largo de su LCS es también 0. for (xs = 1; xs <= n; xs++) longit[xs][0] = 0; for (ys = 1; ys <= m; ys++) longit[0][ys] = 0; // Ahora calculamos LCS que sucesivamente compara prefijos de x ___________________________________________________________________ 12 Escuela de Ingeniería en Computación, Universidad de La Serena Problemas P – NP BioInformática Dr. Eric Jeltsch F. // con prefijos de y for (xs = 1; xs <= n; xs++) { for (ys = 1; ys <= m; ys++) { if (x[xs] == y[ys]) { longit[xs][ys] = longit[xs - 1][ys - 1] + 1; reconstruir[xs][ys] = 3; // "3" es la flecha diagonal. //Indica los elementos que han sido seleccionados // (vea CLR pag. 317) } else if (longit[xs - 1][ys] >= longit[xs][ys-1]) { longit[xs][ys] = longit[xs-1][ys]; reconstruir[xs][ys] = 1; // "1" es la flecha horizontal } else { longit[xs][ys] = longit[xs][ys-1]; reconstruir[xs][ys] = 2; // "2" es la flecha vertical } } } System.out.println("LCS tiene longitud: " + longit[n][m]); printLCS(reconstruir, x, n, m); System.out.println(); } private static void printLCS(int[][]reconstruir, int[] x,int n, int m) { if (n==0 || m==0) { System.out.println(); return; } else if (reconstruir[n][m] == 3) { printLCS(reconstruir, x, n-1, m-1); System.out.print(" " + x[n]); } else if (reconstruir[n][m] == 2) printLCS(reconstruir, x, n, m-1); else printLCS(reconstruir, x, n-1, m); } } ___________________________________________________________________ 13 Escuela de Ingeniería en Computación, Universidad de La Serena BioInformática Problemas P – NP Dr. Eric Jeltsch F. Longest increasing subsequence. (LIS). Bajo un esquema similar es posible presentar la siguiente aplicación: Dada una sucesión de enteros X = < x1 , x2 , ....., xm >, los cuales no contienen elementos duplicados, entonces es posible diseñar un algoritmo para encontrar la subsucesión creciente más grande xi1 xi2 .... xik, donde i1 i2 .... ik que puede encontrarse en X. También conocido como Longest increasing subsequence. (LIS). Por ejemplo, la subsucesión más grande de {3, 1, 4, 5, 9, 2, 6, 0, 7, 8, 12, 10} es {3, 4, 5, 6, 7, 8, 12} y otra es {1, 4, 5, 6, 7, 8, 10}. Implementación: /* Observación: Esta es una solucion para encontrar una ( de varias posibles) subsucesión creciente más larga que esta insertada en una sucesión de simbolos. Ejemplo de uso: > java LIS 3 5 6 8 9 12 23 4 3 5 6 8 9 12 23 4 ^ ^ ^ ^ ^ ^ ^ Una LIS tiene longitud 7. */ public class LIS { public static void main(String[] args) { int n; // la longitud de la suc de entrada int[] x; // la sucesion de entrada int[] lis; // lis[i] es la longitud de la LIS(longest increasing //subsequence), cuyo miembro final es x[i]. int[] previo; /* si x[i] pertenece a una LIS determinada por el algoritmo, previo[i] es el indice de los predecesor x[i]'s en la LIS. El miembro inicial de la LIS tiene previo[i] = n. */ boolean[] en_LIS; // en_LIS[i] es true ssi x[i] esta en la LIS. int i, j, k, lis_valor, lis_max, k_max, i_max, fin_de_longest; // inicializar variables y leer la sucesión de entrada, desde la línea //de comando. n = args.length; x = new int[n]; lis = new int[n]; previo = new int[n]; en_LIS = new boolean[n]; for (i=0; i < n; i++) x[i] = Integer.parseInt(args[i]); // hallar una LIS, sin contar la existencia del total de ellas. lis[0] = 1; previo[0] = n; i_max = 0; fin_de_longest = 0; for (i=1; i<n; i++) { lis_max = 0; k_max = 0; for (k=0; k < i; k++) { ___________________________________________________________________ 14 Escuela de Ingeniería en Computación, Universidad de La Serena BioInformática Problemas P – NP Dr. Eric Jeltsch F. lis_valor = (x[k] < x[i] ? 1 : 0) * lis[k]; if (lis_valor > lis_max) { lis_max = lis_valor; k_max = k; } } lis[i] = lis_max + 1; if (lis_max == 0) previo[i] = n; else previo[i] = k_max; if (lis[i] > i_max) { i_max = lis[i]; fin_de_longest = i; } } //marca todos los elementos que estan en la LIS j = fin_de_longest; en_LIS[j] = true; while (previo[j] != n) { j = previo[j]; en_LIS[j] = true; } //print la sucesión de entrada System.out.println(); for (i=0; i<n; i++) System.out.print(x[i] + " "); System.out.println(); //marca los elementos que estan en la LIS for (i=0; i<n; i++) { if (en_LIS[i]) System.out.print("^" + rellenar(x[i]) + " "); else System.out.print(" " + rellenar(x[i]) + " "); } System.out.println(); System.out.println("Una LIS tiene longitud" + lis[fin_de_longest]); } //return el número de digitos en n private static int digitos(int n) { int m = n / 10; if (m == 0) return 1; else return( 1 + digitos(m)); } /*return un string de whitespace, de longitud una menor que el número de dígitos en el argumenmto de entrada.*/ private static String rellenar(int n) { int d = digitos(n) - 1; StringBuffer rellenar = new StringBuffer(); for (int i = 0; i < d; i++) rellenar.append(" "); ___________________________________________________________________ 15 Escuela de Ingeniería en Computación, Universidad de La Serena BioInformática Problemas P – NP Dr. Eric Jeltsch F. return (rellenar.toString()); } } Camino Hamiltoniano Sea G un grafo dirigido completo con N vértices y con un costo c(i,j) para cada arco (i,j). Se trata de hallar un camino dirigido que partiendo de cualquier vértice pase por cada uno de los otros una sola vez y que su costo sea mínimo. Un tal camino se llama hamiltoniano. Consideremos un ejemplo de aplicación. Se tienen N lotes para elaborar por una máquina, uno por vez. Cuando se termina de procesar el lote i, y antes de comenzar el lote j, se pierde un tiempo c(i,j) para ajustar la máquina. Se pregunta cual debe ser el orden en que deben ser elaborados los lotes para que sea mínimo el tiempo total perdido para los ajustes. Acá los lotes están representados por los vértices del grafo, (i,j) significa que el lote j se procesa a continuación del lote i y c(i,j) es el tiempo perdido por el ajuste. Entonces el operador J comienza decidiendo el orden de entrada de los lotes en la máquina. Llega el operador P, al cual J le informa todos lotes que faltan ordenar y, del resto de lotes, solo le dice cual fue el último ordenado. Llamemos i al último lote ordenado y sea S el conjunto de lotes que falta ordenar. Denotemos (i,S) al estado del problema, es decir, P tiene toda la información que necesita para seguir decidiendo el orden de los lotes S. Llamemos j el siguiente lote que decide P. Entonces el siguiente estado será (j,S-{j}). Así los arcos del grafo para este problema son de la forma (i,S)(j,S) donde jS. Los estados (i,S) de este grafo quedan particionados por card(S) y se trata de encontrar un camino optimo que parta de un (i,S) con card(S)=N-1 y llegue a un (i,S) con card(S)=1 Empezamos los cálculos del camino mínimo partiendo de conjuntos S con un solo elemento. f(i,{k})= c(i,k) i,k=1,2,...,N ik, para k=2,3,...,N-1 f(i,S)= min jS {h(i,j)+f(j,S-{j})}, donde card(S)=k e iS. El j que realiza el mínimo se llama j(i,S). El procedimiento de calculo tiene N-2 iteraciones. En la iteración k (2kN-1) calculamos f(i,S) y j(i,S) para todo (i,S) tal que card S=k e iS. Para saber cual es el primer lote a fabricar encontramos un i1 tal que f(i1,S1) sea mínimo con card(S)=N-1 y luego usamos j(i,S) reiteradamente. Es decir, si j1, j2, ...,jN la solución optima, la misma esta dada por j1 = i1 ___________________________________________________________________ 16 Escuela de Ingeniería en Computación, Universidad de La Serena BioInformática Problemas P – NP Dr. Eric Jeltsch F. j2= j( j1, S1 -{j1}) j3= j( j2 , S1 -{j1,j2}) ... jN= j( jN-1, S1-{j1,j2,...,jN-1}) Conteo de operaciones El problema que acabamos de exponer es típico de una clase de problemas (NP-Hard) para los cuales los algoritmos que se conocen son ineficientes en el sentido que el tiempo de cálculo crece exponencialmente con el tamaño del problema. Mostramos que este es el caso para este algoritmo. Consideremos el numero de operaciones de suma o comparación en la iteración k. En la iteración k, f(i,S) debe calcularse para (N-k) N distintos argumentos (i,S) y para cada k argumento deben hacerse 2k sumas y comparaciones. Por lo tanto el número total de N 1 N 1 N operaciones es : 2 k ( N k ) N ( N 1) 2 k k 1 Por otra parte en la iteración k+1 debe tenerse en la memoria activa todos los valores de N f(i,S) con card(S)=k, un total de (N-k) k números. Esta expresión tiene su máximo para =N/2. Usando la formula de Stirling obtenemos Memoria: N / 2 2 N Una operación elemental por la computadora requiere 4/(33106) segundos y un número entero ocupa 2 bytes. Usando estos datos, el problema para N=25 requiere 20 min para ser resuelto y 3300 Mb de memoria. Shortest Common Superstring Problem (SCS), como problema NP-Hard Sea S = {s1,…,sn} un conjunto de strings (blocks) sobre algún alfabeto Σ. Un superstring de S es un string x tal que todo si en S es un substring de x. El problema es hallar el shortest (common) superstring. Este problema se ha demostrado que es NP-Completo. Esto significa que no podríamos esperar que se encuentre una solución optimal en tiempo polinomial. No obstante se ha realizado una conjetura que usa la estrategia greedy. Ejemplo: Sea S = {ate, half, lethal, alpha, alfalfa}. Un trivial superstring es “atehalflethalalphaalfalfa” de longitud 25 (que es una simple concatenación de todos los bloques). Una shortest common superstring es “lethalphalfalfate” de longitud 17. Notar que una permutación comprimida de los bloques es actualmente un superstring. ___________________________________________________________________ 17 Escuela de Ingeniería en Computación, Universidad de La Serena BioInformática Problemas P – NP Dr. Eric Jeltsch F. Computación por ADN Algunos de los problemas a los que nos enfrentamos en computación no pueden ser resueltos de una manera lineal, es decir, los recursos que se necesitan para encontrar una solución (tiempo, espacio, ...) crecen exponencialmente respecto a su complejidad. Por ejemplo, hallar una clave. La única manera de hacerlo sería ir probando todas las combinaciones posibles. Siendo una clave de m posibles caracteres diferentes y siendo n su longitud, el tiempo que tardaría un computador en encontrar la solución sería mn. Hasta ahora lo que estamos acostumbrados a tratar son modelos de computación convencionales, basados en el modelo de Turing, que es un modelo orientado a máquinas. Sin embargo, recientes estudios y experimentos han permitido vislumbrar una tecnología más allá del silicio, que rebasa sus capacidades. El problema anterior de hallar una clave podría ser resuelto en un tiempo constante. Más adelante se observó cierta similitud entre algunos procesos matemáticos con algunos procesos biológicos, dando lugar a la idea de que podrían construirse máquinas basadas en la biología. En noviembre de 2001, Shapiro realizó una simulación de una máquina de Turing con moléculas de ADN. Las moléculas de ADN son cadenas dobles de azúcar en cuyos extremos se encuentra una base nitrogenada. Dicha base nitrogenada puede ser del tipo A, C, G o T. Dichas bases se combinan entre sí mediante un enlace de hidrógeno por parejas enfrentadas: A<-> T y G<-> C. De esta manera, las cadenas se van uniendo formando una larga hélice. Utilizando las moléculas de ADN, Leonard Adleman consiguió resolver el problema del camino hamiltoniano con siete nodos. En este problema se recibe un grafo direccionado, de manera que hay que encontrar un camino que lleve desde un nodo inicial a un nodo final pasando una y sólo una vez por cada una de sus aristas. La manera tradicional de resolverlo es mediante un algoritmo exponencial, probando todos los caminos y luego comparándolos. Adleman consiguió realizar un algoritmo lineal. Especificó trece operaciones posibles a realizar con las moléculas de ADN: desnaturalización, renaturalización, medida de la longitud, extracción, alargar, copiar, síntesis, acortar, cortar, empastar, alterar, PCR y lectura. De estas operaciones utilizó únicamente: Síntesis Creación de una molécula de ADN con una base concreta. Desnaturalización Formar dos hebras simples a partir de una helicoidal calentando lentamente la solución de ADN. Renaturalización Obtener una doble cadena de ADN a partir de dos simples. Esto se hace con un enfriado lento que recupera los enlaces de hidrógeno. Las ases tienen que ser complementarias para que encajen. ___________________________________________________________________ 18 Escuela de Ingeniería en Computación, Universidad de La Serena BioInformática Problemas P – NP Dr. Eric Jeltsch F. Medida de la longitud Cuenta el número de pares de bases. Como la molécula de ADN está cargada negativamente, se puede contar según su carga. También puede hacerse según su masa. Extracción o selección Dados un tubo (para poner los datos) y una cadena, clasifica las moléculas del tubo según si contienen o no a una cadena, elegida previamente, como subcadena.Puede que haya moléculas repetidas. El experimento consistía en codificar cada nodo X mediante dos cadenas de ADN: X y X'. De esta manera identificaba cada arco mediante la unión de dos cadenas: si un arco va de A (identificado por A y A') a B (identificado por B y B'), el arco estará representado por la unión de A' y B. Si el arco va de B a A, la codificación será B'A. Adleman metió en un mismo recipiente todas las representaciones de arcos junto con las cadenas complementarias codificando los nodos completos (X seguido de X'), cada una de ellas repetida muchas veces. Una vez hecho esto, comenzó a renaturalizar. De esta forma, se unen las cadenas que encajan. Para que dos cadenas encajen tiene que existir una cadena complementaria que las una. Es decir, las cadenas con la codificación de los nodos obligan a unir a las cadenas que tienen un arco que empieza en ese mismo nodo y con otro arco que termina en él. Así, cada hélice que se va formando codifica un camino a lo largo del grafo. Una vez que las moléculas están suficientemente acopladas, se pasa a la fase de deshechar los caminos que no sean hamiltonianos. Como sabía que tenía que pasar una sola vez por cada nodo, fue separando las cadenas que no tenían longitud siete. Una vez obtuvo todas las cadenas de longitud siete, comprobó las que tenían como primer nodo el nodo inicial y como último nodo, el nodo final. De las que quedaron seleccionó las que tenían todos los nodos (es decir, fue comprobando nodo por nodo que estaba incluido en la cadena). Las cadenas resultantes eran caminos hamiltonianos. Visto así podría parecer un proceso largo y tedioso que sería fácilmente resuelto mediante ordenador. Pero si se automatizara el proceso, veríamos que el número de pasos, además de ser pequeño, es constante, sea cual sea el tamaño del problema. Sin embargo, resuelto mediante ordenadores convencionales, este problema puede costar tanto como para volverse irresoluble. ___________________________________________________________________ 19 Escuela de Ingeniería en Computación, Universidad de La Serena BioInformática Problemas P – NP Dr. Eric Jeltsch F. 28/nov/01 ¿Computadoras de ADN? Según la agencia Reuter, científicos israelitas construyeron una computadora con ADN tan diminuta que un millón de ellas podría caber en un tubo de ensayo y realizar 1.000 millones de operaciones por segundo con un 99,8 por ciento de precisión. La periodista Patricia Reaney, comenta en el cable de esta agencia fechado el 22 de noviembre que, en lugar de usar cifras y fórmulas para resolver un problema, las operaciones de entrada y salida de datos, y los programas de la microscópica computadora están formados por moléculas de ADN que almacenan y procesan la información codificada en organismos vivos. Los científicos consideran esas computadoras de ADN como futuras competidoras de sus parientes más convencionales ya que la miniaturización está llegando a sus límites, y el ADN tiene el potencial de ser mucho más rápido que las computadoras convencionales. "Hemos construido una computadora, a nanoescala y formada por biomoléculas, que es tan pequeña que no se puede hacer funcionar una a una. Cuando un millón de millones de computadoras funcionan a la vez son capaces de realizar 1.000 millones de operaciones", dijo a Reuters el profesor Ehud Shapiro, del Instituto Weizman, en Israel. Es la primera máquina de computación programable de forma autónoma en la cual la entrada de datos, el software y las piezas están formados por biomoléculas. Aunque es demasiado sencilla como para tener aplicaciones inmediatas, podría formar la base de una computadora de ADN en el futuro, la cual operaría dentro de las células humanas, actuaría como un dispositivo de vigilancia para detectar posibles cambios que puedan causar enfermedades, y sintetizaría medicamentos para curar dichos padecimientos. El modelo podría asimismo formar la base de computadoras que luego se podrían usar para búsquedas en las bibliotecas de ADN sin necesidad de secuenciar cada molécula, lo que podría acelerar el conocimiento sobre el ADN. "La célula viva contiene increíbles máquinas moleculares que manipulan las moléculas codificadoras de información, como el ADN y el ARN, su pariente, en formas que son esencialmente muy similares a la computación", dijo Shapiro, director del equipo de investigación que desarrolló la computadora de ADN. "Al no saber aún cómo modificar de manera eficaz esas máquinas, o crear unas nuevas. El truco es hallar máquinas que ya existen en la naturaleza y que, cuando se combinen, puedan ser dirigidas hacia la computación", añadió. En un artículo publicado en la revista científica Nature, Shapiro y su equipo describen su computadora de ADN, la cual es un modelo molecular de una de los equipos de computación más simples, el automatón, que puede responder a ciertas preguntas que requieren sólo un sí o un no. Los datos se representan por pares de moléculas en una cadena de ADN y dos enzimas naturales que actúan como un equipo para leer, copiar y manipular el código. Cuando se ___________________________________________________________________ 20 Escuela de Ingeniería en Computación, Universidad de La Serena BioInformática Problemas P – NP Dr. Eric Jeltsch F. mezcla todo en un tubo de ensayo, el software y el equipo operan la molécula de entrada de datos para crear los datos de salida. Lo más interesante es que la computadora de ADN consume muy poca energía, por lo que si se coloca dentro de la célula no necesitaría mucha energía para funcionar. Bibliografía - Cormen, y otros. McGraw-Hill - Baase, Sara. “Computer algorithms: introduction to design and analysis”. Capítulo 7. Addison-wesley. Reading. 1983. En la Web: http://web. mat.bham.ac.uk/R.W.Kaye/minesw/minesw.htm http://www.cecm.sfu.ca/personal/jborwein/algorithms.html http://es.wikipedia.org/wiki/Tiempo_polin%C3%B3mico http://es.wikipedia.org/wiki/NP http://es.wikipedia.org/wiki/Tetris http://es.wikipedia.org/wiki/NP-completo http://es.wikipedia.org/wiki/Buscaminas http://es.wikipedia.org/wiki/Problema_de_satisfacibilidad_booleana http://es.wikipedia.org/wiki/Problema_de_la_clique http://www.claymath.org/Popular_Lectures/Minesweeper/ http://www.claymath.org/millennium/P_vs_NP/ http://www.computer.org/cise/articles/Top_Algorithms.htm http://axxon.com.ar/not/c-108CompuADN.htm ___________________________________________________________________ 21 Escuela de Ingeniería en Computación, Universidad de La Serena