Skip to article frontmatterSkip to article content

Algoritmos Gulosos

A special thanks to the TA Filipe Vilas Boas for scribing this page

Projeto de algoritmos usando estratégias gulosas

Estratégias gulosas para projeto de algoritmos são úteis em diversas áreas de estudo, desde algoritmos exatos até algoritmos aproximativos e heurísticas. Um algoritmo guloso é caracterizado por uma sequência de escolhas gulosas feitas com o objetivo de construir iterativamente uma solução final para um problema. Tal solução final geralmente é representada como um conjunto de itens definidos de acordo com o problema em questão. Uma escolha para ser gulosa deve possuir as seguintes características:

  1. Localmente ótima: dentre as opções de escolha em um dado momento, precisa decidir incluir na solução aquele item que demonstrar o melhor benefício imediato. Naturalmente, diversos critérios podem ser usados para essa avaliação, o que nos dá margem para produzir mais de uma escolha gulosa para um problema;
  2. Factível: não deve violar nenhuma restrição do problema;
  3. Irrevocável: uma vez tomada, a escolha gulosa nunca mais será desfeita.

Como escolhas do algoritmo não são desfeitas, algoritmos gulosos tendem a ser eficientes em tempo mas nem sempre levam à solução ótima global. Quando isso acontece, temos um procedimento que pode ser um algoritmo aproximativo ou apenas uma heurística gulosa.

Por hora vamos focar nos problemas para os quais existem algoritmos gulosos ótimos que os solucionam. Para um dado problema e algoritmo guloso, duas propriedades devem ser garantidas para que a otimalidade da estratégia seja garantida:

Essas duas propriedades se verificadas em conjunto, simplificam a tarefa de demonstrar que um algoritmo guloso é ótimo. De fato, a outra alternativa seria demonstrar de forma indutiva que alguma invariante do algoritmo se mantém e garante a resposta correta – abordagem mais genérica, porém geralmente mais desafiadora.


Estudo de caso: problema do troco

// change-greedy(v, n)
// v: vetor de valores das moedas (ordenado decrescente)
// n: valor a ser trocado
troco = []
for i = 1 to |v|
   while n >= v[i]
      troco.adiciona(v[i])   // adiciona moeda ao troco
      n = n - v[i]
return troco

Estudo de caso: problema do escalonamento de tarefas

Cada tarefa ii é representada por um início sis_i e um término fif_i. O objetivo é escalonar um conjunto de tarefas que não se sobrepõem (são compatíveis).

// schedule-compatible(s, f)
// s: vetor de tempos de início
// f: vetor de tempos de término
// n: número de tarefas
a = [1, 2, ..., n] // índices das tarefas
ordene a por f[i] crescente
T = conjunto vazio
i = 1
while i <= n
   m = a[i]
   T = T U {m}
   i = i + 1
   while i <= n and s[a[i]] < f[m] // incompatível
      i = i + 1
return T

O algoritmo acima é ótimo e, para garantir isso, precisamos verificar as duas propriedades pertinentes: (1) escolha gulosa; e (2) subestrutura ótima. Via de regra, com essas duas propriedades o algoritmo será automaticamente válido por indução: (1) garante que é sempre seguro fazer a escolha gulosa e ainda assim encontrar alguma solução ótima, isto é, passo base; e (2) garante que é seguro fazer a escolha gulosa a todo passo, para todos os subproblemas que forem sobrando, isto é, passo indutivo.

Vamos verificar cada uma das propriedades a seguir:

Propriedade da escolha gulosa

Como o conjunto de itens que compõem uma solução são exatamente as tarefas escolhidas, vamos representar o problema de escalonamento como um conjunto de todas essas tarefas candidatas. Portanto, seja P={1,,n}P = \{1, · · · , n\} o problema de escalonar essas nn tarefas. Assim, quando o algoritmo realiza a escolha gulosa (a tarefa mm de menor término), o subproblema restante a se resolver é: P={iPsifm}P' = \{i ∈ P \mid s_i ≥ f_m\}, isto é, o restante de tarefas compatíveis com mm. Com isso, acabamos de decompor o problema original em subproblemas a partir da escolha gulosa do algoritmo. Da mesma forma, uma solução para PP que faz a escolha gulosa pode ser indicada como Sp={m}SpS_p = \{m\} ∪ S_{p'}.

Essa decomposição inicial é fundamental pois, de acordo com a propriedade da escolha gulosa, deveríamos ser capazes de transformar qualquer solução genérica ótima SpS'_p em uma solução de igual qualidade SpS_p que necessariamente faça a escolha gulosa (não confundir SpS_{p'} com SpS'_p). Quase sempre, o argumento para garantir essa transformação é incluir a escolha gulosa na solução genérica (uma troca) de maneira a garantir a manter a otimalidade da solução.

Para o caso do escalonamento de tarefas, queremos transformar SpS'_p em SpS_p . Para isso, seja mm a escolha gulosa e seja jj a tarefa de menor término em SpS'_p . Pense sobre isso, mm termina primeiro em PP e jj termina primeiro em uma solução genérica de PP, denotada como SpS'_p :

  1. Se m=jm = j, a solução genérica já faz a escolha gulosa. Portanto, Sp=SpS_p = S'_p .

  2. Se mjm \not = j, só pode ser que fjfmf_j ≥ f_m. Dessa forma, qualquer tarefa que seja compatível com jj também será compatível com mm. Assim, trocar jj por mm em SpS'_p não altera a qualidade nem a viabilidade da solução. Portanto, Sp=(Sp\{j}){m}S_p = (S'_p \, \backslash \, \{ j\}) ∪ \{m\}.

Veja que em ambos os casos, a qualidade da solução não mudou porque a cardinalidade dos conjuntos permaneceu a mesma. Além disso, em ambos os casos, garantimos que m pertence à solução ótima.

Propriedade da subestrutura ótima

Subestrutura ótima tem tudo a ver com como problemas são decompostos em subproblemas. No caso de algoritmos gulosos, sempre teremos apenas um subproblema restante. Já fizemos essa decomposição na seção anterior, considerando a escolha gulosa que agora é segura: Sp={m}SpS_p = \{m\} ∪ S_{p'} . Isso nos diz, que solução para PP inclui a escolha gulosa mais alguma solução para o subproblema restante PP'.

A subestrutura ótima trata da seguinte questão: a solução do subproblema SpS_{p'} precisa ser ótima para que a solução do problema original SpS_p também seja ótima? Se a resposta for sim, o problema atende a propriedade da subestrutura ótima. Caso contrário, não atende. Veja que no caso de atender, estamos implicitamente afirmando que soluções ótimas para problemas são compostas de soluções também ótimas de seus respectivos subproblemas.

Para o problema do escalonamento, isso é facilmente verificado por contradição. Suponha que SpS_p seja uma solução ótima para PP, mas que SpS_{p'} não seja ótima para PP'. Dessa forma, deve existir uma solução ótima para PP', denotada como SpS'_{p'} . Segue dessa definição que Sp>Sp|S'_{p'} | > |S_{p'} |. Com isso, poderíamos construír uma solução para PP de melhor qualidade:

Sp=mSp\quad S'_p = {m} ∪ S'_{p'} , de cardinalidade Sp=1+Sp>Sp=1+Sp|S'_p | = 1 + |S'_{p'} | > |S_p | = 1 + |S_{p'} |.

Isso contradiz a premissa de que SpS_p é ótima. Conclusão, SpS_{p'} precisa ser ótima também. Juntando este resultado com o resultado da propriedade da escolha gulosa, concluímos que o algoritmo que faz a escolha gulosa da tarefa de menor término primeiro no problema do escalonamento é ótimo.


Estudo de caso: árvore geradora mínima

// prim(g)
// g: grafo com vértices e arestas
Q = conjunto de todos os vértices de g
T = conjunto vazio // árvore resultante
for v in Q
   key[v] = infinito
   parent[v] = nulo
key[raiz] = 0
while Q não está vazio
   u = vértice em Q com menor key[u]
   remova u de Q
   for v in vizinhos de u
      if v in Q and peso(u, v) < key[v]
         parent[v] = u
         key[v] = peso(u, v)
   if parent[u] != nulo
      T = T uniao {(parent[u], u)}
return T

Estudo de caso: compressão de documentos

Seja um documento formado por caracteres, nosso objetivo será encontrar uma forma de comprimí-lo de forma ótima. Por exemplo, seja o seguinte documento:

 escolha gulosa e subestrutura otima \fbox{ escolha gulosa e subestrutura otima }

Se ignorarmos os espaços apenas para facilitar o exemplo, percebemos que é muito comum que a distribuição das frequências de caracteres seja bem desbalanceada. Para o exemplo acima:

abceghilmorstu41131112132434\begin{array}{cccccccccccccccc} \text{a} & \text{b} & \text{c} & \text{e} & \text{g} & \text{h} & \text{i} & \text{l} & \text{m} & \text{o} & \text{r} & \text{s} & \text{t} & \text{u} \\ \hline 4 & 1 & 1 & 3 & 1 & 1 & 1 & 2 & 1 & 3 & 2 & 4 & 3 & 4 \end{array}

Olhando para essas frequências, fica evidente que se representarmos cada caractere usando a mesma quantidade de bits, espaço seria desperdiçado com os caracteres que acontecem raramente. Assim, definimos este problema de compressão como: dados um conjunto de caracteres que ocorrem em um documento e suas respectivas frequências, queremos encontrar uma codificação para cada caratere que minimize a quantidade de bits necessária para representar o documento inteiro. Por conveniência, vamos representar essas codificações através de uma árvore binária de prefixos. Considerando um outro exemplo:

abcdef4513121695\begin{array}{cccccccccccccccc} \text{a} & \text{b} & \text{c} & \text{d} & \text{e} & \text{f}\\ \hline 45 & 13 & 12 & 16 & 9 & 5 \end{array}

Em uma codificação de tamanho fixo, como temos apenas 6 caracteres no documento, 3 bits seriam suficientes, já que 23=862^3 = 8 ≥ 6:

!! Incluir figura ...

Veja que com essa representação, temos a oportunidade de encontrar outras codificações melhores: possivelmente as que utilizem menos bits para caracteres mais frequentes e mais bits para caracteres menos frequentes. O objetivo do problema é portanto encontrar uma árvore codificadora TT como esta que minimize:

B(T)=cCfreq(c)dT(c) B(T) = \sum_{c \, ∈ \, C} freq(c) ∗ d_T (c) , onde freqfreq representa a frequência dos caracteres e dTd_T representa a profundidade do caractere na árvore, isto é, quantos bits são utilizados para representá-lo.

O algoritmo a seguir implementa a codificação de Huffman, que é uma estratégia gulosa ótima para o problema:

// huffman(c, f)
// c: vetor de caracteres
// f: vetor de frequências
// n: número de caracteres
construa uma fila de prioridade Q com itens c[i] e prioridades f[i]
for i = 1 to n - 1
   l = extrai-min(Q)
   r = extrai-min(Q)
   z = novo nó de árvore binária
   filho-esquerdo(z) = l
   filho-direito(z) = r
   f(z) = f(l) + f(r)
   insira(Q, z, f(z))
return extrai-min(Q)