Problema de selección de actividades

 

Planteamiento: dado un conjunto de n intervalos de la forma [ai,bi], 0 ≤ ai < bi≤2,000,000,000 encontrar un subconjunto de ellos de tamaño máximo de tal forma que la intersección de cada par sea vacía (es decir, que ningún par se “traslape”).

 

Clasificacion

Fuerza bruta

D.P. / Memorización

Glotón (óptimo)

Límites razonables

1<=n<=40

1<=n<=5,000

1<=n<=100,000

Complejidad en tiempo

O(2n)

O(n2)

O(n log n)

Complejidad en memoria

O(n)

O(n)

O(n)

Nivel OIEG

Avanzado

Avanzado

Avanzado

Nivel OMI

Normal

Normal

Normal

Nivel IOI

Básico

Básico

Básico

 

Dentro de los algoritmos “glotones”, éste es quizá el más conocido y el que más se utiliza como ejemplo para ilustrar las ideas detrás de esta clase de algoritmos. Puedes encontrar más sobre este problema si buscas “Event Scheduling Problem” (problema de selección de eventos, o algo así). La variable importante es el número de intervalos, n.

 

Puedes imaginarte el proceso de “buscar la solución” como un proceso en el que vas decidiendo, para cada intervalo, si lo tomas o lo dejas. Recuerda que como todo “proceso” debes hacer todo organizadamente. Dependiendo de cómo tomes las decisiones, puedes llegar a alguna de las siguientes soluciones (podrías llegar a otra solución completamente diferente, claro!).

 

Fuerza bruta:

 

            Algo que ayuda mucho es imaginar que estás en una situación y ver qué consecuencias tendrá el que tomes cada una de las posibles decisiones, es decir, dado que estás en una situación X a partir de la cual puedes tomar alguna de las decisiones D1, D2, ...Dk, a qué situaciones te conducirá cada decision, digamos Y1, Y2, ..., Yk . Si logras determinar cuál de todas estas situaciones es la mejor simplemente tomas la decisión que te conduzca a esa situación. Supongamos que el primer intervalo que eliges (el que inicia más a la izquierda, digamos) es [ak,bk], entonces el siguiente intervalo que elijas [ai,bi] debe cumplir que bk ≤ ai es decir, debe empezar al terminar el primer intervalo o después. Esta simple condición nos lleva a diseñar nuestra primera solución: supongamos que el último de todos los intervalos que hemos elegido termina en

T, ahora tenemos un conjunto de posibles decisiones que podemos tomar: para cada uno de los intervalos que empiecen en T o después de T, {[aj,bj]} podemos decidir que ése sea el siguiente intervalo elegido. Si elegimos el intervalo [aj,bj] eso nos lleva a una situación en la que T=bj la cual es una situación similar a la

anetrior.


 

int maxIntervalos ( int T)//0<=T<=2,000,000,000
   {
      int mejorOpcion=0;
      REP (i, n)
         if(T<=intervalos[i].inicio)//podemos elegir este intervalo            
            {
               int opcion=1+maxIntervalos(intervalos [i].final);
               if(opcion>mejorOpcion)
                  mejorOpcion=opcion;
            }      
      return mejorOpcion;
   }
 

Memorización:

Para memorizar las soluciones que se van calculando necesitamos un areglo de tamaño 2,000,000,001 (necesitamos espacio para cada posible valor de T), esto claramente no es posible, pero no es difícil darse cuenta de que a lo más habrá n diferentes valores de T con n<<T. La siguiente idea surge inmediatamente, en lugar de usar T usaremos el índice del último intervalo elegido k.

 

int maxIntervalos ( int k)//0<=k< n
   {
      int T= intervalos[k].final;
      int mejorOpcion=0;
      REP (i, n)
         if(T<=intervalos[i].inicio)             
             {
                 int opcion=1+maxIntervalos(i);
                 if(opcion>mejorOpcion)
                    mejorOpcion=opcion;
             }      
         return mejorOpcion;
     }
 

Ahora para memorizar solamente necesitamos un arreglo de tamaño n.

 

int cache[n];
int maxIntervalos( int k)   
   { 
      if (cache[k]>=0)
         return cache[k];
      int T=intervalos[k].final, mejorOpcion=0;
      REP (i, n)
         if(T<=intervalos[i].inicio)             
            {
               int opcion=1+maxIntervalos(i);
               if(opcion>mejorOpcion)
                  mejorOpcion=opcion;
            }      
      return cache[k]=mejorOpcion;
   }

 

Programación Dinámica:

 

Para usar programación dinámica necesitamos encontrar en qué orden debemos llenar la tabla de soluciones (“cache” en el ejemplo anterior) de tal forma que cuando calculemos uno de los elementos de la matriz, todas las soluciones de las cuales depende ya estén calculadas. Este problema puede ser muy difícil de resolver por lo que uno a veces se pregunta “¿por qué preocuparme del orden en el que debe llenarse la matriz si la memorización lo hace por mi?”, bueno, hay algunos casos en los que el número de llamadas recursivas (mas bien, el nivel de la recursión) es tan alto que la memoria destinada a guardar las llamadas recursivas (el “stack”) se llena (en este caso se dice que “la pila se desborda”, “stack overflow”) y por tanto aunque haya suficiente memoria para guardar los resultados parciales, la solución por memorización falla. Al usar programación dinámica no debemos preocuparnos por la pila, ya que no hacemos llamadas recursivas sino que la tabla de soluciones se llena iterativamente.

 

Ordenemos los intervalos de izquierda a derecha respecto al inicio de los intervalos (ejercicio: será mejor ordenar respecto al final de los intervalos?), de modo que si p<q entonces intervalos[p].inicio≤ intervalos[q].inicio. Sea maxInter[p] el máximo número de intervalos que podemos elegir si el primero (de izquierda a derecha) es el p-ésimo intervalo. Nota que el valor de maxInter[p] solo depende de los intervalos q>p, es decir, de los intervalos que empiezan después de p. Así que el orden en que debemos llenar la tabla maxInter es de derecha a izquierda, es decir, maxInter[n-1], maxInter[n-2],..., maxInter[1], maxInter[0].

 

int maxInter[MAX_N+1];
//maxInter[p] indicará el máximo número de intervalos que podemos elegir, 
//si el primero en ser elegido (contados de izquierda a derecha) es el p-ésimo
int calcularMaxIntervalos(void)
   {
      sort(intervalos de menor a mayor inicio);
      maxInter[n-1]=1;
      for(int p=n-2; p>=0; p--)
         {
            int q=p+1;//q debe indicar el siguiente intervalo que podemos elegir
            while(q<n && intervalos[q].inicio<intervalos[p].final)
               q++;
            maxInter[p]=1;//al menos podamos elegir el intervalo p
            if(q<n)//ademas de tomar el intervalo p, también podemos elegir el q
               maxInter[p]=MAX(maxInter[p], 1+maxInter[q]);                        
          }
      int best=maxInter[0];//este NO es necesariamente el mejor!!! ejercicio: por que?
      for(int p=1;p<n;p++)
         best=MAX(best, maxInter[p]);
      return best;
   }

 

En cada paso del código anterior, suponemos elegimos que el intervalo p. Ahora, dado que lo elegimos, nuestras opciones se reducen a que el siguiente intervalo en ser elegido no empiece antes de que el intervalo p termine. Pero dado que estamos calculando los resultados óptimos de derecha a izquierda, todos los resultados que necesitamos ya fueron calculados, por lo que solo hay que elegir el mayor de todos haciendo referencia a la tabla en lugar de hacer una llamada recursiva.

 

 

Glotón

            Hay una optimización más que es posible hacer en cierto tipo de problemas. En programación dinámica tuvimos que verificar cuál es la solución óptima para todos los q>p tales que el intervalo q no empieza antes de que termine p, es decir, verificar todas las posibles sub-soluciones y elegir la mejor. Ciertos problemas tienen la propiedad de que no es necesario verificar todas las posibles sub-soluciones para saber cual de ellas es la mejor.

 

Ordenemos ahora los intervalos de menor a mayor final, de modo que si p<q entonces intervalos[p].final≤ intervalos[q].final. Supongamos que de todos los intervalos, el que termina primero es [ai,bi]. Imagínate ahora que el máximo número de intervalos que puedes elegir es K. De esos K intervalos, sea [aj,bj] el que termina primero, entonces el máximo número de intervalos que podemos elegir de modo que no empiecen antes de bj es K-1, pero, espera! Si esos K-1 intervalos no empiezan antes de bj entonces tampoco empiezan antes de bi, porque ya habíamos dicho que bi ≤ bj, por lo tanto podemos elegir [ai,bi] en lugar de [aj,bj], y aún así tenemos K intervalos, es decir, haber elegido desde un principio [ai,bi] era “óptimo”. Esta es la parte crucial de la demostración de que elegir siempre el primero en terminar es óptimo, es decir, la propiedad “greedy choice”. Ejercicio: completa la prueba como se vió en el entrenamiento.

 

int calcularMaxIntervalos(void)
   {
      sort(intervalos de menor a mayor final);
      int cuenta=1, ultimoElegido=0, i=1;//elijamos siempre el primero
      while(i<n)
         {
            if(intervalos[ultimoElegido].final<=intervalos[i].inicio)
               {
                  ultimoElegido=i;//aceptar este intervalo.El ultimo elegido es i
                  cuenta++;
               }
            i++;
         }
      return cuenta;
   }

 

Autor: Omar Ocegueda (Khayyam)