Escolar Documentos
Profissional Documentos
Cultura Documentos
naldi@dc.ufscar.br
Agradecimentos
●
Aos professores Mário Felice e Diego Furtado por ceder
parte do material utilizado.
●
Material inspirado nas aulas do prof. Tim Roughgarden
Programação Dinâmica
●
Vamos começar a estudar uma técnica de
algoritmos chamada programação dinâmica
– Para tanto, vamos abordar um problema
bastante particular, mas bastante didático,
chamado:
●
Conjunto Independente de Peso Máximo
em Grafos Caminhos
Conjunto Independente de Peso Máximo
em Grafos Caminhos
●
Entrada: um grafo caminho G=(V, E) com pesos
não negativos nas arestas
– um grafo caminho é um grafo conexo sem
bifurcações, ou seja, o grau de todos os
vértices é dois, exceto pelos dois vértices dos
extremos que tem grau um.
●
Saída: conjunto independente de peso máximo
– um conjunto é independente se nenhum par
de vértices é adjacente, i.e., tem uma aresta
em comum.
Exemplo
●
Caminho com quatro vértices e pesos 1, 4, 5, 4
●
Antes de projetar um algoritmo usando a nova
técnica, vamos testar as técnicas que já
conhecemos
– para entender as dificuldades do problema
– e limitações das técnicas.
Abordagem por força bruta
●
Três passos:
– Vamos enumerar todos os 2n diferentes
subconjuntos
– Descartar todos os que tem dois vértices
adjacentes
– Encontrar o de peso máximo dentre os que
sobraram
●
Vantagem: Esta abordagem encontra o resultado
correto
●
Desvantagem: Ela leva tempo Θ(2n)
Abordagem gulosa
●
Dentre várias possíveis, uma ideia é escolher sempre o
vértice de maior peso para fazer parte da solução
●
Contra-exemplo: escolhendo o vértice de peso 5 do nosso
exemplo anterior (1, 4, 5, 4), ficamos impossibilitados de
escolher seus vizinhos.
●
A parte central do projeto de algoritmos
utilizando programação dinâmica é encontrar a
subestrutura ótima da solução do problema.
– Isso significa descrever uma solução ótima
em função de soluções ótimas para
subproblemas menores
– A princípio isso serve para reduzir o espaço
de uma busca exaustiva, mas o impacto
pode ser muito maior no tempo de execução,
como veremos depois.
Análise
●
No caso do problema do Conjunto Independente de
Peso Máximo em Grafos Caminhos consideramos:
– uma solução ótima S
– vn o último vértice do grafo caminho G
●
Então analisamos por dois casos:
● 1) vn não está em S
– sendo G' = G sem vn, temos que S é uma solução
ótima em G'
– Provando por contradição, suponha que S* é uma
solução melhor que S' para G'. Como S* também é um
conjunto independente em G, temos que S* é uma
solução melhor que S em G (contradição).
Análise
● 2) vn está em S
– neste caso vn-1 não pode estar em S
– seja G" = G sem vn e vn-1
– seja S" = S \ {vn}
– temos que S" é uma solução ótima em G"
●
Provando por contradição, suponha que S* é uma
solução melhor que S" para G". Note que S* U {v n} é uma
solução para G. Além disso, como S* é melhor que S" o
valor de S* U {vn} é maior que S" U {vn} = S (contradição).
Análise
●
Notem que identificando a subestrutura ótima da solução
conseguimos descrever a solução ótima da seguinte
forma
●
Uma solução ótima para G deve ser a melhor entre:
– uma solução ótima em G' (G sem o último vértice)
– vn + uma solução ótima em G" (G sem os dois últimos
vértices)
Algoritmo
●
Não sabemos qual dos dois casos se aplica, mas
isso nos sugere o seguinte algoritmo recursivo:
conjIndRec(G=(V,E), w) {
se |V| = 1 devolva v1
S' = conjIndRec(G')
S" = conjIndRec(G")
devolva max{w(S'), w(S") + w(vn)}
}
Eficiência
●
Podemos mostrar que este algoritmo está correto usando
indução e a recorrência obtida na análise da subestrutura
ótima.
●
No entanto, esse algoritmo não é eficiente, levanto tempo
exponencial no tamanho da entrada.
– Tentem escrever a recorrência de tempo do mesmo e
resolvê-la usando método da substituição.
– Percebam que vocês podem simplificar a recorrência, já
que basta um limitante inferior para mostrar que o
algoritmo leva tempo exponencial.
– No fim o tempo desse algoritmo será "parecido" com o
tempo de uma exponencia de base 2 que só anda nos
números pares (ou ímpares).
Algoritmo Programação Dinâmica
●
Lembrando como descrevemos a solução ótima para G
em função de soluções para subproblemas.
●
Uma solução ótima para G deve ser a melhor entre:
– uma solução ótima em G' (G sem o último vértice)
– vn + uma solução ótima em G" (G sem os dois
últimos vértices)
●
Podemos escrever essa relação como uma recorrência:
– A[n] = max{A[n-1], A[n-2] + w[vn]}
Memorização
●
Apesar do algoritmo recursivo que derivamos diretamente
levar tempo exponencial, observem:
– o padrão e a quantidade de subproblemas com os
quais temos que lidar
●
Apenas vértices do extremo direito do caminho são
removidos
●
Assim, cada subproblema é um prefixo do caminho
●
Como o caminho original tem n vértices, temos Θ(n)
diferentes subproblemas
Memorização
●
De fato, o algoritmo recursivo gasta tempo exponencial
por ficar recalculando inúmeras vezes os mesmos
subproblemas.
●
E podemos torná-lo polinomial (linear, no caso) se
– guardarmos numa tabela o valor de um subproblema
na primeira vez que o calcularmos
– e sempre verificarmos essa tabela antes de
recalcularmos um subproblema
●
Essa técnica é conhecida como memorização
(memorization) e tem uma relação próxima com
programação dinâmica.
Algoritmo Programação Dinâmica
●
Voltando à recorrência que obtivemos
– A[n] = max{A[n-1], A[n-2] + w[vn]}
●
Vamos finalmente construir nosso algoritmo iterativo de
programação dinâmica
– para tanto vamos preencher o vetor de baixo para
cima
– seguindo a regra da recorrência
Algoritmo Programação Dinâmica
●
// supomos que o vetor vai de 0 até n, e os vértices de 1 até n
conjInd(G=(V,E), w) {
A[0] = 0
A[1] = w1
para i = 2 até n
A[i] = max{A[i-1], A[i-2] + w[vi]}
devolva A[i]
}
●
Corretude: Segue da subestrutura ótima e pode ser provada por
indução.
●
Eficiência: O(n), pois o algoritmo só tem um laço principal e
realiza um número de operações constante dentro deste.
Algoritmo para reconstrução
●
Embora nosso algoritmo encontre o valor da solução ótima, ele não
obtém a solução em si.
●
Uma opção é modificar o algoritmo para que ele armazene a
solução que está construindo.
– Mas isso não costuma ser eficiente em questão de memória.
Especialmente em algoritmos de maior dimensão.
●
Por isso, em geral, o mais eficiente é reconstruir a solução fazendo
engenharia reversa no vetor de soluções.
●
Para tanto vamos olhar novamente para nossa recorrência:
– A[n] = max{A[n-1], A[n-2] + w[vn]}
●
E observar que
– um vértice vi está na solução para Gi se somente se w[vi] +
custo da solução para Gi-2 >= custo da solução para Gi-1
Algoritmo para reconstrução
●
Assim, vamos percorrer o vetor do fim para o início
●
E, em cada posição verificamos qual foi a escolha
que o algoritmo fez (usando a recorrência)
– adicionamos o vértice à solução (se for o caso)
– e avançamos no vetor (em direção ao início)
●
Para ficar mais claro, considere o seguinte
pseudocódigo
– supondo que o vetor A foi preenchido pelo
algoritmo conjInd
Algoritmo para reconstrução
recSol(G, w, A){
S=ᴓ
i=n
enquanto i >= 1
se A[i-1] >= A[i-2] + w[vi]
i=i-1
senão
Adicione vi em S
i=i-2
devolva S
}
Análise
●
Corretude: pra variar por indução.
●
Eficiência: O(n).
Princípios básicos
●
Os princípios básicos para projetar um algoritmo de
programação dinâmica são:
●
1) encontrar um número pequeno de subproblemas
●
2) encontrar uma maneira (em geral, uma recorrência) que
permita resolver um problema maior usando as soluções de
problemas menores
– essa maneira deve ser correta (claro)
– e rápida (recorrência deve depender de um número
polinomial de termos)
●
3) depois de calcular os subproblemas, encontrar a solução
final deve ser fácil
– um valor da tabela (normalmente a última posição)
●
Observação
●
*** Observe que para poder aplicar esses princípios é
necessário que o problema apresente a propriedade de
subestrutura ótima, ou seja, que uma solução ótima para
uma determinada instância possa ser descrita em função
de soluções ótimas para sub-instâncias da mesma.