¿Cuál es el punto de usar programación dinámica cuando la complejidad de tiempo en la mayoría de los códigos es O (n ^ 2) (que no es tan bueno, es decir, usamos dobles para bucles incluso en DP)?

Hmmm … esto va a ser largo.

Primero tratemos de entender qué es DP.
La programación dinámica es una técnica matemática para resolver problemas.

En términos simples, significa Recursión (o iteración, para el caso, depende de si sigues un enfoque ascendente o descendente) junto con Memoization .

Ahora entendamos la memorización. Sí, suena como una palabra elegante, pero en términos crudos significa anotar cosas, es decir, almacenar lo que ha calculado una vez y almacenarlo en una nota (diccionario) para su posterior reutilización para evitar cálculos repetidos, por lo tanto, disminuir el cálculo efectivo hora.

Ahora, como somos claros con los términos básicos, tomemos algunos ejemplos para defender la causa de la DP.

Problema: escriba un programa para dar el enésimo número de Fibonacci, dado n> 0.

Alguien, que no piensa en Memoization, inmediatamente piensa en algo simple y recursivo.

  func fib (int n) {
    si (n == 1 || n == 2)
       retorno 1;
    más 
       retorno (fib (n-1) + fib (n-2));
 } 

Parece estar bien ¿Correcto? Intente ejecutarlo en una PC doméstica normal con un procesador de ~ 3 GHz y 4 a 8 GB de RAM. Tome n como un número impar de 50 o 60. Usted puede ir y tomar una taza de café cuando este programa le dé la respuesta.

Porque preguntas ? ¿Qué tiene de malo este programa? Es un programa simple que se ejecuta primero mientras se estudia la recursión por primera vez.
La respuesta, radica en la complejidad. Este algo lleva tiempo exponencial. O (2 ^ n). Cuando ejecutas esto para una n más alta, los sentidos de una máquina de computadora se lanzan porque sigue calculando fib (Ns más bajas) repetidamente.

Si dibuja el árbol de recursión para las llamadas de función para este algo, verá cuántas llamadas repetidas se realizan.

Ahora, utilice un enfoque ascendente para este programa.

  // Haz un poco de precalulación.  es decir, memorizar primero.
 int [] memo = new int [1000];
 Arrays.fill (memo, 0);
 memo [0] = memo [1] = 1;

 // Hacer precalulación (es decir, Memoize), esto lleva O (n) tiempo.
 precalc () {
    para (int i = 2; i <1000; i ++) {
       memo [i] = memo [i-1] + memo [i-2];
    }
 }

 // Ahora esta función es un paseo de pastel que toma O (1) tiempo.
 func fib (int n) {
     memo de retorno [n-1];
 } 

Como puede ver, debido a la construcción de un diccionario, la complejidad se vuelve lineal, es decir, O (n) + O (1) = O (n). Ejecute esto para un rango superior, obtendrá el resultado al instante.

Ohh, lo entiendo, no estás impresionado. No es un ejemplo perfecto de DP.

Considere esto ahora:

Problema:
Dadas algunas denominaciones de monedas en la matriz denom [] = {1,3,5}; Encuentre el número mínimo de monedas requerido para hacer una suma de S = 11.

Inmediatamente saltas, lees esta pregunta y dices, esto se puede resolver fácilmente usando Greedy algo.

  static int minCoins (int [] arr, int S) {

    Arrays.sort (arr);
    int cuenta = 0;
    int N = S;
    para (int j = a.size () - 1; j> = 0; j -) {

       si (N> 0) {
         cuenta + = N / arr [j];
         N = N% arr [j];

       }

    }

    si (N == 0)
      cuenta de retorno;
    más 
      volver -1;

	 } 

Luce bien. Pero, ahora intente esto para denom [] = {1,10,25} y suma S = 40. Ohh, lamentablemente el codicioso algo bombardea.
Dicha pregunta se puede resolver correctamente para todas las entradas que utilizan DP.

  static int countCoins (int [] monedas, int S) {

    int [] resultado = nuevo int [S + 1];

    Arrays.fill (resultado, 1000);

    resultado [0] = 0;
    para (int i = 1; i <= S; i ++) {
       for (int j = 0; j <monedas.length; j ++) {

          if (monedas [j] <= i && (resultado [i-monedas [j]] + 1 <resultado [i]))
             resultado [i] = resultado [i-monedas [j]] + 1;

       }
    }

    resultado devuelto [S];
	 } 

Volviendo al “punto” de complejidad de O (n ^ 2).

Si un problema habla de calcular la longitud de la subsecuencia común más larga.
Dadas dos secuencias, encuentre la longitud de la subsecuencia común más larga presente en ambas. Una subsecuencia es una secuencia que aparece en el mismo orden relativo, pero no necesariamente contigua. Por ejemplo, “abc”, “abg”, “bdf”, “aeg”, ‘”acefg”, etc. son subsecuencias de “abcdefg”. Entonces, una cadena de longitud n tiene 2 ^ n subsecuencias posibles diferentes.

  / * Una implementación recursiva ingenua del problema LCS * /
 #include 
 #include 
 
 int max (int a, int b);
 
 / * Devuelve la longitud de LCS para X [0..m-1], Y [0..n-1] * /
 int lcs (char * X, char * Y, int m, int n)
 {
    si (m == 0 || n == 0)
      devuelve 0;
    si (X [m-1] == Y [n-1])
      devuelve 1 + lcs (X, Y, m-1, n-1);
    más
      return max (lcs (X, Y, m, n-1), lcs (X, Y, m-1, n));
 } 

La complejidad temporal del enfoque recursivo ingenuo anterior es O (2 ^ n) en el peor de los casos y el peor de los casos ocurre cuando todos los caracteres de X e Y no coinciden, es decir, la longitud de LCS es 0.

Teniendo en cuenta la implementación anterior, a continuación se muestra un árbol de recursión parcial para las cadenas de entrada “AXYT” y “AYZX”

lcs (“AXYT”, “AYZX”)
/ \
lcs (“AXY”, “AYZX”) lcs (“AXYT”, “AYZ”)
/ \ / \
lcs (“AX”, “AYZX”) lcs (“AXY”, “AYZ”) lcs (“AXY”, “AYZ”) lcs (“AXYT”, “AY”)

Tienes la idea del árbol de recursión.

Como podemos observar desde el árbol de recursión que muchos subproblemas se resuelven repetidamente, podemos resolverlos una vez y almacenar el resultado en algún lugar. De modo que si la próxima vez se produce un subproblema de este tipo, en lugar de calcularlo de nuevo, podemos hacer una búsqueda desde nuestro memo (diccionario).

  / * Solución DP del problema LCS * /

	 static int longestCommonSubSeq (char [] s1, char [] s2) {

    int N = s1.length;
    int [] [] dp = nuevo int [N + 1] [N + 1];

    para (int i = 0; i <N; i ++) {
       dp [0] [i] = dp [i] [0] = 0;
    }

    para (int i = 1; i <= N; i ++) {
       para (int j = 1; j <= N; j ++) {
          dp [i] [j] = max (dp [i-1] [j], dp [i] [j-1]);
          if (s1 [i-1] == s2 [j-1]) {
             dp [i] [j] = max (dp [i-1] [j-1] + 1, dp [i] [j]);
          }
       }
    }
		 devolver dp [N] [N];
	 } 

Esto se ejecuta en tiempo O (n ^ 2). Ahora, no hace falta ser un genio para darse cuenta de que 2 ^ n es mucho más pobre que O (n ^ 2).

La programación dinámica (DP) es una implementación eficiente de cierto tipo de problemas de recursión. Si cree que hay principalmente [matemáticas] O (n ^ 2) \; [/ math] implementaciones de DP, entonces no tienes toda la razón. A veces es de hecho la complejidad óptima o al menos el único límite superior conocido. Y eso es de lo que debe preocuparse, la complejidad óptima de tiempo y espacio. Si algunas otras técnicas le brindan medidas de complejidad similares a las de DP y es fácil de entender e implementar, seguramente debería elegir eso para resolver el problema. Pero no subestimes el poder de DP todavía. Si pasa tiempo resolviendo muchos problemas de DP, estoy seguro de que comenzará a enamorarse de la técnica.

El problema de la subsecuencia creciente más larga (LIS) es un muy buen ejemplo. La solución DP directa es [matemática] O (n ^ 2) \ ;, [/ matemática] sin embargo, esta no es la solución óptima. Un algoritmo mejor tiene una complejidad [matemática] O (n.log \; n) \; [/ matemática]. Curiosamente, y aquí está la parte más importante, se basa en la solución DP. Encuentra el “cuello de botella” en el algoritmo DP y lo optimiza. Básicamente, convierte una búsqueda lineal de la subsecuencia creciente más larga anterior vista hasta ahora en una búsqueda logarítmica.

Sin embargo, para dominar el DP, debes atravesar las puertas de las recursiones. Y créanme que será muy gratificante ya que las recursiones forman la base de al menos otras tres técnicas principales de resolución de problemas, que detallaré a continuación, después de una distracción menor.


Las recursiones de cola se traducen en un solo ciclo con bastante facilidad, como en el siguiente ejemplo:

[matemáticas] F (n) = n \ veces F (n-1) \; [/ matemáticas] y [matemáticas] F (0) = 1 [/ matemáticas]

Lo anterior se reconoce fácilmente como una función recursiva para el Factorial: [matemática] F (n) = n! [/ Matemática].

Una implementación directa usando recursión es [matemática] O (n) \; [/ math] complejidad de tiempo y [math] O (n) \; [/ math] complejidad espacial. ¿Por qué? El espacio de la pila de llamadas de función no es gratis, ¿verdad?

Pero cuando usa una solución iterativa para lo anterior, la complejidad del tiempo es [matemática] O (n) \; [/ math] y la complejidad del espacio es [math] O (1). [/ math] Esto es esencialmente lo que es DP.

Otra relación recursiva bastante simple son los números de Fibonacci :

[matemáticas] F (n) = F (n-1) + F (n-2) \; [/ matemáticas] y [matemáticas] F (0) = 0 \; [/ matemáticas] y [matemáticas] F (1 ) = 1 [/ matemáticas]

La implementación recursiva directa conduce a una complejidad temporal de [matemáticas] O (2 ^ n) [/ matemáticas], lo cual es bastante malo. ¿Puedes descubrir su complejidad espacial? SUGERENCIA: es la profundidad de la pila de recursión.

La solución DP es sencilla y utiliza la complejidad [matemática] O (n) \; [/ matemática] y [matemática] O (1) \; [/ matemática], después de que nos damos cuenta de que podemos mejorar el tiempo almacenando en caché los valores que necesitamos varias veces (en lugar de calcularlo cada vez). De hecho, necesitamos almacenar en caché solo dos valores.

Clasificaría DP como implementaciones eficientes en tiempo y espacio de relaciones de recursión que resuelven eficientemente recursivas aditivas / multiplicativas como:

[matemáticas] F (n + k) \; [/ matemáticas] o [matemáticas] F (kn) \; [/ math] en términos de [math] F (n + i) \; [/ matemáticas] o [matemáticas] F (ni), \; [/ math] para [math] 0 \ leq i \ lt k. [/ math]

Hay una cantidad tan grande de problemas interesantes que se pueden resolver con DP, que en broma le dije a mi colega que debería haber una clase especial de problemas con DP completo 🙂


La técnica de dividir y conquistar también se basa en la definición recursiva de los problemas. Sin embargo, en este caso estamos más interesados ​​en particionar el dominio. Así que básicamente:

[matemáticas] F (D): x_i \ en D \; [/ math] donde [math] x_i \; [/ math] es la entrada.

Queremos particionar el dominio en sí mismo a [matemáticas] D_1 \; [/ math] y [math] D_2 \; [/ math] tal que [math] D = D_1 \ cup D_2 \; [/ math] y [math] D_1 \ cap D_2 = \ phi \ ;. [/ math] El ejemplo sería:

[math] MSort (L [1, 2n]) [/ math] = [math] MSort (L [1, n]) \; [/ math] [math] [/ math] merge [math] MSort (L [ n + 1, 2n]). [/ matemáticas]

[matemáticas] QSort (L [1, 2n]) [/ matemáticas] = [matemáticas] QSort (P [1, k-1]) \; [/ math] unirse [math] \; P [k] \; [/ math] join [math] QSort (P [k + 1, 2n]) \; [/ matemáticas] donde [matemáticas] P [k] \; [/ math] es el elemento pivote.

[matemáticas] BinSearch (L (1,2n + 1), x) [/ matemáticas] = [matemáticas] (L (n + 1) == x)? n: (L (n + 1)


Algoritmos codiciosos , también pueden formularse recursivamente. Al igual que en el método de dividir y conquistar, la recursión funciona en el dominio de las entradas en lugar de la entrada individual, es decir, en el caso del tipo de selección:

[matemáticas] SSort (L_n): \ {x_g, SSort (L_ {n-1}) \} \; [/ math] donde [math] x_g \; [/ math] es el elemento codicioso min o max de la lista de números en [math] L_n. [/ math]

Puede notar que la complejidad es [matemática] O (n ^ 2) \; [/ math] para el tipo de selección anterior, pero realice la selección de [math] x_g \; [/ math] eficiente y terminas con heapsort , que es [math] O (n.log \; n). [/ math] En esta técnica, el dominio se reduce por un número constante de elementos en cada paso, mientras que en la técnica de dividir y conquistar , el dominio se divide por un número constante de particiones. (sustractivo vs divisivo).


Back Tracking, otra técnica importante para resolver problemas también se puede describir en términos de recursividad.


Piense desde la perspectiva del diseñador del problema, solo necesitan encontrar relaciones recursivas, piensan que son increíbles y usan su talento como guionistas para formular un problema, que se resuelve cuando resuelve la relación recursiva. Un ejemplo es el número catalán. Si no conocía este número antes, (a menos que sea algo talentoso) tendrá dificultades para encontrar el número de paréntesis o árbol binario en el marco de tiempo de una entrevista en el mundo real.

Entonces, el punto crucial es dominar la ciencia del uso de la recursividad para resolver el problema (si es posible una formulación recursiva) y aprender el arte de elegir la estrategia de implementación correcta.


PREGUNTA: ¿Puedes formular la clasificación como un problema DP?

SUGERENCIA: De manera directa, obtendrá [matemáticas] O (n ^ 2) \; [/ math] complejidad del tiempo. ¿Puede optimizarlo para obtener [matemáticas] O (n.log \; n) \; [/ math] complejidad? ¿Cómo establecerás la relación recursiva?

Si la solución de programación dinámica toma menos tiempo que el algoritmo aplicado anteriormente, entonces usamos DP. Si encontramos alguna otra solución que funcione mejor que la programación dinámica, la usaremos. DP es solo un paradigma para resolver un problema junto con la memorización, que si da menor complejidad de tiempo para la pregunta dada que exhibe una subestructura óptima.