Apostila de Algoritmos

Propaganda
IME 04-06319 - ALGORITMOS
Paulo Eustáquio Duarte Pinto
Universidade Estadual do Rio de Janeiro
Instituto de Matemática
Departamento de Informática e Ciência da Computação
Rio de Janeiro, agosto de 2008
1
CONTEÚDO
0. INTRODUÇÃO
1. RECURSÃO
1.1 Conceitos básicos
1.2 Problemas clássicos
1.3 Análise da Recursão
1.4 Exercícios Propostos
2. BACKTRACKING
2.1 Conceitos básicos
2.2 Problemas clássicos
2.3 Jogos
2.4 Exercícios Propostos
3. PROGRAMAÇÃO DINÂMICA
3.1 Conceitos básicos
3.2 Problemas clássicos
3.3 Exercícios Propostos
4. MÉTODO GULOSO
4.1 Conceitos básicos
4.2 Problemas clássicos
4.3 Exercícios Propostos
5. PROBLEMAS NP-COMPLETOS
2
0. INTRODUÇÃO
A ênfase deste curso é no estudo de uma ampla variedade de
Algoritmos: métodos de solução de problemas adequados para implementação
em computadores. Serão mostrados os problemas clássicos de cada método
estudado, bem como uma visão simplificada da complexidade desses algoritmos.
Sempre que possível serão também abordados problemas propostos nas
diversas maratonas de programação da ACM.
0.1 Introdução à complexidade de Algoritmos - Notações O e Ω
Duas características muito importantes dos algoritmos são o seu tempo
de execução e a memória requerida. Quando se faz um algoritmo para resolver
determinado problema, não basta que o algoritmo esteja correto. É importante
que ele possa ser executado em um tempo razoável e dentro das restrições de
memória existentes. Além disso, ele deve permanecer viável, à medida que o
tempo passa, quando a quantidade de dados envolvida normalmente cresce. O
estudo do comportamento dos algoritmos em termos do tempo de execução e
memória, em função do crescimento dos dados envolvidos, denomina-se
Complexidade de Algoritmos. Os parâmetros estudados normalmente são os
seguintes:
a) Complexidade de pior caso - caracterização do tempo de
execuçãomáximo, para determinado tamanho da entrada, bem como
das características da entrada que levam a esse tempo máximo. Este
é o principal parâmetro para se avaliar um algoritmo.
b) Complexidade de caso médio - caracterização do tempo de execução
médio do algoritmo, para determinado tamanho da entrada,
considerando a média de todas as possibilidades. Em muitas situações
este parâmetro é útil.
c) Complexidade de melhor caso - caracterização do tempo de
execução mínimo, para determinado tamanho da entrada, bem como
das características da entrada que levam a esse tempo mínimo.
d) Memória requerida para se executar o algoritmo para determinado
tamanho de entrada.
A determinação da complexidade de pior caso teria que ser feita
contando-se todas as instruções executadas e o tempo de execução de cada
uma delas, considerando-se a pior entrada possível. Normalmente isso não é
viável. O que se faz é determinar um limite superior para esse tempo, o mais
próximo possível da realidade. Para tanto, fixa-se o estudo na instrução mais
3
executada do algoritmo e determina-se uma função t(n), que dá a variação do
tempo de execução em função de n, o tamanho da entrada.
O limite superior descrito anteriormente é definido pela conceituação
O(t(n)), definida da seguinte forma:
Sejam f, h duas funções reais positivas de variável inteira n. Diz-se que
f é O(h), escrevendo-se f = O(h), quando existir uma constante c > 0 e um valor
inteiro n0, tal que
n > n0 => f(n) ≤ c.g(n) .
Exs:
f = n3 – 1
f = 5 + 10 log n + 3 log2 n
=> f = O (n3) = O (n4)
=> f = O (log2 n )
Por convenção, os algoritmos que tenham complexidade de pior caso
iguais ou inferiores a O(nk) são considerados eficientes. Algoritmos cuja
complexidade sejam, por exemplo, O(2n) são considerados ineficientes.
Outro ponto importante é o seguinte: dado um problema, pode-se ter
encontrado um algoritmo para resolvê-lo. Surge a pergunta se esse é o melhor
algoritmo possível. Algumas vezes essa resposta pode ser obtida com a ajuda
cos conceitos dados a seguir.
Def: Dadas as funções sobre variáveis inteiras f e g. Dizemos que
f é Ω (g) , se existirem uma constante c e um número n0 tal que
f(n) ≥ c.g(n) , para todo n > n0.
Se P é um problema, então dizemos que o limite inferior para P é dado
por uma função h tal que a complexidade de pior caso de qualquer algoritmo
que resolva P é Ω (h).
Desta forma, quando conseguimos determinar o limite inferior para um
problema e, ao mesmo tempo, conseguimos um algoritmo cuja complexidade de
pior caso seja igual a esse limite, então estamos diante de um algoritmo ótimo,
pois não se pode conseguir algoritmo com complexidade mais baixa que o
mesmo.
4
0.2 Bibliografia recomendada
Esta apostila está fortemente baseada no primeiro livro indicado abaixo
e não pretende substituir um livro texto, necessário para se complementar a
compreensão de cada tema abordado. Os seguintes livros são indicados:
Algorithms R. Sedgewick;Addison-Wesley, 1988
Introduction To Algorithms T. H. Cormen et all; McGraw Hill, 1998
Estrut. de Dados e Seus Algoritmos J.L.Szwarcfiter, LTC 1994
Recomenda-se, também, o acesso aos seguintes sites que abordam
problemas das maratonas de programação ACM e da Olimpíada de Informática:
http://icpc.baylor.edu
http://acm.uva.es
http://olympiads.win.tue.nl/ioi
http://olimpiada.ic.unicamp.br
5
1. RECURSÃO
1.1 Conceitos básicos
Esta técnica de construção de algoritmos consiste basicamente em se
subdividir um problema em problemas menores de mesma natureza que o
problema original e obter a solução como uma composição das soluções dos
problemas menores. Para a solução dos problemas menores adota-se a mesma
estratégia de subdivisão, até o nível em que o subproblema seja muito simples,
quando então sua solução é exibida, geralmente com poucos passos.
A maneira de subdividir e de compor a solução pode ser diferente para
cada caso. A seguir são mostrados dois exemplos clássicos de algoritmos
recursivos: Fatorial e Fibonacci.
O cálculo de fatorial tem a seguinte recursão:
Fatorial (p):
Início:
Se (p = 0) Então
Retornar 1
Senão
Retornar p.Fatorial(p-1);
Fim;
(A1.1)
Nesta recursão o único problema resolvido diretamente é o de 0! . Os
demais são resolvidos a partir da solução de cada problema imediatamente
menor.
Para a da série de Fibonacci, temos:
Fibonacci(p):
Início:
Se (p ≤ 1) Então
Retornar p
Senão
Retornar Fibonacci(p-1) + Fibonacci(p-2);
Fim;
6
(A1.2)
Há quatro outras visões sobre a técnica de recursão, que certamente
complementam essa visão inicial:
a) A técnica pode ser vista como uma maneira de se resolver problemas
de "trás para frente", isto é: a solução enfatiza os passos finais para a
solução do problema, supondo problemas menores resolvidos. No caso Fatorial,
para se obter Fatorial(n), a idéia é multiplicar Fatorial (n-1) por n .
b) A técnica pode ser ainda imaginada como o equivalente matemático da
indução finita, onde, para se demonstrar fatos matemáticos usam-se duas
etapas:
b.1.1) Mostra-se que a hipótese vale para valores particulares e pequenos
de n (0, 1, etc).
b.1.2) Supondo-se a hipótese verdadeira para todos os valores inferiores
a n, demonstra-se que ela continua valendo para n.
Na recursão, temos que:
b.2.1) Exibe-se um algoritmo para resolver casos particulares e pequenos
(0, 1, etc). Os problemas pequenos são chamados “problemas infantís”.
b.2.2) Exibe-se um algoritmo para achar a solução do problema “grande”
a partir da composição de problemas menores.
c) A técnica é o equivalente procedural da formulação de recorrências
(funções recursivas), onde, de forma análoga ao ítem b), uma função é definida
em duas partes. Na primeira, é dada uma fórmula fechada para um ou mais
valores de n. Na segunda, a definição da função para n é feita a partir da
mesma função aplicada a valores menores que n, para valores superiores aos da
primeira parte. Outra relação de recursão com recorrência é que muitas
propriedades de soluções recursivas são obtidas com o uso de recorrências.
d) Os procedimentos recursivos são aqueles que "chamam a sí mesmo".
É claro, então, que a chamada a sí mesmo sempre se dá no contexto de buscar
a solução de problemas menores, para compor a solução do maior. Além disso
todo procedimento recursivo tem que ter também uma chamada externa.
Note que todo algoritmo recursivo tem uma solução não recursiva
equivalente. Muitas vezes, entretanto, a expressão recursiva é mais natural e
mais clara, como para os exemplos mostrados a seguir.
7
1.2 Problemas clássicos
Neste tópico são apresentados os seguintes algoritmos clássicos com
solução recursiva:
a) Torre de Hanói
b) Quicksort
c) Mínimo e Máximo
d) Cálculo de Combinação
e) Torneio
1.2.1 Torre de Hanói
O problema baseia-se em um jogo infantil, onde há n pratos de tamanhos
diferentes, com um furo no meio e três varetas A, B e C, que possibilitam que
esses pratos possam ser empilhados em cada uma delas. O empilhamento só
pode ser feito colocando-se pratos menores em cima de maiores. Inicialmente
todos os pratos estão na vareta A. O problema é levar esses pratos para a
vareta C, podendo usar as três varetas como empilhamento temporário.
A
B
C
Esse problema tem a seguinte solução recursiva:
Hanoi(n, A, B, C);
Início:
Se (n > 0) Então
Hanoi (n-1, A, C, B);
Mover topo de A para C;
Hanoi (n-1, B, A, C);
Fim;
(A1.3)
Qualquer solução não recursiva para este problema é muito mais
complicada que a mostrada acima. A complexidade desse algoritmo é O(2n), não
sendo, portanto, um algoritmo eficiente. Entretanto isso é o melhor que pode
ser feito, pois o problema é, em sí, exponencial (!).
No exemplo, com os pratos numerados de 3 a 1, de baixo para cima, a
solução seria:
8
Mover 1 de A p/ C; → Mover 2 de A p/ B; → Mover 1 de C para B;
(Resolvido o problema para 2 pratos em B);
Mover 3 de A para C;
(Resolver novamente o problema de 2 pratos, só que p/ C):
Mover 1 de B p/ A; → Mover 2 de B p/ C; → Mover 1 de A p/ C;
1.2.2 Quicksort
É considerado o melhor algoritmo de ordenação e foi um dos primeiros
algoritmos recursivos propostos. A idéia é fazer, sucessivamente, partições em
subvetores, de forma que a parte esquerda contenha sempre elementos
menores ou iguais aos da direita. O problema simples é quando o tamanho de
uma partição é 1. A partição baseia-se em escolher um elemento como um pivô,
fazendo-se trocas para colocar maiores ou iguais de um lado e menores ou
iguais do outro.
(A1.4)
Sort(E, D);
Início:
Se (D > E) Então
Particao(E, D, I, J);
Sort(E, J);
Sort(I, D);
Fim;
Procedimento Particao (E, D, I, J) /* Baseada no elem. do meio */
Início:
I ← E; J ← D; t ← A[ (E+D)/2 ];
Enquanto (I ≤ J):
Enquanto (A[I] < t):
I ← I + 1; Fe;
Enquanto (t < A[J]):
J ← J - 1; Fe;
Se (I ≤ J) Então
Troca_Elem(I, J); I ← I + 1; J ← J - 1;
Fe;
Fim;
A seguir é dado um exemplo, mostrando os elementos envolvidos em
comparações e trocas(estas indicadas em vermelho).
Passo
1
2
3
4
5
6
7
8
9
9
10 11 12
1
2
3
4
E
X
E
M
P
L
O
F
A
C
I
L
E
X
E
M
P
L
O
F
A
C
I
L
E
L
E
I
C
A
F
O
L
P
M X
E
L
E
I
C
A
F
Partição (1, 7)
E
F
E
A
C
I
L
(1,5) (6,7)
E
F
E
A
C
Partição(1,5)
C
A
E
F
E
(1,2) (3,3) (4,5)
C
A
Partição (1,2)
A
C
(1,1) (2,2)
5
E
Partição(4,5)
E
F
(4,4) (5,5)
I
L
Partição(6,7)
I
L
(6,6) (7,7)
7
8
E
E
F
I
L
Partição(8,12)
X
(8,10) (11,12)
L
P
O
L
M P
O
L
M
Partição(8,10)
L
O
M
(8,8) (9,10)
O
M
Partição(9,10)
M
O
(9,9) (10,10)
10
L
M
O
M
X
O
9
C
(1,7) (8,12)
F
6
A
Partição (1,12)
P
X
P
X
X
P
Partição(11,12)
(11,11) (12,12)
Situação Final
a) Análise do Algoritmo
a.1) Complexidade:
Melhor caso = Vetor Ordenado; NC =~nlog2n = O(n log2n)
Pior caso = Há várias possibilidades. Vetor em “Zig Zag”, p.
Ex. NC =~ n2/2 = O(n2)
Caso Médio: NC =~ nlog2n = O(n log2n)
a.2) Estabilidade: Algoritmo não estável
a.3) Situações Especiais: Algoritmo de uso amplo, extremamente
rápido.
a.4) Memória necessária: pilha de recursão (log2n).
b) Observações:
b.1) Número de comparações: 42
10
Número de trocas: 16
b.2) Este é um dos mais antigos e estudados algoritmos na
Informática, tendo sido desenvolvido inicialmente por Hoare, em 1962.
b.3) Notar o mecanismo de partição (1,5), (1,2) e (11,12). No
primeiro caso, o subvetor é dividido em 3 partes (ao final J = 2, I = 4); no
segundo caso, o vetor é subdividido em 2 partes (ao final J = 1, I = 2); no
terceiro caso, o vetor também é subdvidido em 2 partes (mas ao final J = 10
(!?) e I = 12).
O algoritmo tem complexidade O(n2), pior que de alguns outros
algoritmos de ordenação. Entretanto sua grande importância deriva do fato de
o pior caso é algo raro de acontecer. A complexidade de caso médio é O(nlogn),
e o algoritmo é muito rápido para o caso médio.
1.2.3 Mínimo e Máximo
O problema é determinar os valores mínimo e máximo de um conjunto de
números S. Esse problema tem uma solução trivial que é se fazer dois “loops”
para encontrar separadamente os valores mínimo e máximo, executando
exatamente 2n-2 comparações. O seguinte algoritmo recursivo permite uma
melhora desse resultado:
MinMax (S);
Início:
Seja S = [a1,..., an]
Se (|S| = 1) Então Retornar (a1, a1);
Senão Se (|S| = 2) Então
Se (a1 > a2) Então Retornar (a2 , a1);
Senão
Retornar (a1, a2 );
Senão
m ← Int(|S| /2);
(b1, c1 ) ← MinMax(S1 = [a1,..., am]);
(b2, c2 ) ← MinMax(S2 = [am+1 ,... an]);
Retornar (min{ b1, b2}, max{c1, c2 });
Fim;
(A1.5)
Esta solução recursiva necessita apenas de (3n/2 - 2) comparações, mas
a prova desse resultado fica como exercício. Esse resultado permite, então
formular um algoritmo não recursivo com igual número de comparações, que é o
seguinte: criam-se dois vetores, um de mínimos e outro de máximos. Esses
vetores são preenchidos a partir da comparação, dois a dois, dos elementos
11
iniciais. O elemento perdedor vai para o vetor de mínimos e o vencedos, para o
de máximos. Verifica-se, então, o menor dos mínimos e o maior dos máximos. O
total de comparações, é então:
n/2 + n/2 -1 + n/2 - 1 ≅ 3n/2 - 2.
1.2.4 Cálculo de Combinação
O cálculo de número de combinações é simples, mas envolve um grande
número de operações de multiplicação e divisão. Dependendo da ordem em que
as operações são realizadas e da linguagem de implementação, pode-se
facilmente "estourar" a capacidade numérica do ambiente e obter resultados
errados. Isso acontece, por exemplo, no cálculo de Comb(50,2), se for
implementada diretamente a fórmula clássica.
A recursão fornece uma alternativa para algumas situações, bastando-se
observar que
Comb(n,p) = Comb(n,p-1)*(n-p+1)/p,
o que sugere imediatamente a implementação a seguir:
Comb(n, p);
Início:
Se (p = 1) Então
Retornar n;
Senão
Retornar Comb(n, p-1)*(n-p+1)/p;
Fim;
(A1.6)
Com essa implementação, muitas situações que dariam erro usando-se a
fórmula clássica, passam a funcionar corretamente, como no caso do exemplo
mencionado. A complexidade do algoritmo é O(p).
Outra possibilidade é considerar Comb(n,p) = Comb(n-1,p)*n/(n-p), o que
sugere a seguinte versão alternativa, com complexidade O(n):
Comb(n, p);
Início:
Se (n = p) Então
Retornar 1;
Senão
Retornar Comb(n-1, p)*n/(n-p);
12
(A1.7)
Fim;
1.2.5 Torneio
Um problema de organização de torneios com n competidores, onde
todos competidores jogam entre sí, é planejar as rodadas de forma que haja o
menor número delas. Quando n é par, queremos que haja (n - 1) rodadas, e em
cada rodada todos os times jogam. Quando n é impar então há n rodadas, sendo
que em cada rodada jogam (n - 1) times e um deles fica "bye". Neste último
caso, pode-se considerar que exista mais um time no grupo, que será
considerado o emparelhamento "bye", de forma que admitiremos que haja
sempre um número n par de competidores.
Uma solução para o problema pode ser construir uma matriz R, n x n,
onde a primeira coluna da matriz contenha os times e as colunas 2 a n, indiquem
as rodadas de 1 a (n - 1). Cada elemento R(i, j) da matriz indica que R(i, j) é o
adversário do time i na rodada j - 1. Vejamos um exemplo para n = 4.
r1
r2
r3
r4
1
2
3
4
2
1
4
3
3
4
1
2
4
3
2
1
Uma matriz R desse tipo tem as seguintes propriedades:
a) R[i,1] ← i
b) Todas as linhas são permutações dos elementos 1,2...n
c) Todas as colunas são permutações dos elementos 1, 2...n
d) Se j > 1, R[i,j] = t ⇒ R[t,j] = i
A essência do emparelhamento é expressa pela propriedade d).
Este problema tem uma solução interessante recursiva quando n é
potência de 2. O quadro abaixo ilustra a solução para n = 8:
r1
r2
r3
r4
r5
r6
r7
r8
1
2
3
4
5
6
7
8
13
2
1
4
3
6
5
8
7
3
4
4
3
1
2
2
1
7
8
8
7
5
6
6
5
5
6
7
8
6
5
8
7
7
8
5
6
8
7
6
5
1
2
3
4
2
1
4
3
3
4
1
2
4
3
2
1
Pode-se observar a seguinte simetria nessa tabela: dividindo-a em 4
seções iguais, vê-se que a seção esquerda superior é igual à direita inferior e
que a esquerda inferior é igual à direita superior. Além disso, a seção esquerda
inferior corresponde à esquerda superior, somando-se n/2 aos números
respectivos. Pode-se verificar que essa simetria é recursiva, quando se
substitui um quadro pela seção esquerda superior, agora com número de
elementos dividido por 2. O Esquema abaixo ilustra a composição recursiva:
I
III
II
IV
A recursão é a seguinte:
A matriz é dividida em 4 quadrantes iguais. O quadrantes I é preendhido
recursivamente. Os demais quadrantes são assim obtidos:
a) O quadrante II é copiado do quadrante I, com o acréscimo de n/2 a
cada elemento.
b) O quadrante III é uma cópia do II.
c) O quadrante IV é uma cópia do I.
A recursão se encerra quando se chega ao tamanho 1. Então é preenchido com
o número 1.
Notar que essa solução preserva as propriedades necessárias à matriz.
Isso sugere o algoritmo a seguir, que usa uma matriz R n x n. m indica o
tamanho da submatriz, Evidentemente, a chamada externa é: Torneio (n).
(A1.8)
Torneio (m):
Início:
14
Se (m = 1) Então
R[1,1] ← 1;
Senão
p ← m/2;
Torneio(p);
Para i de 1 até p:
Para j de 1 até p:
R[i + p, j]
← R[i, j] + p;
R[i, j + p]
← R[i + p, j];
R[i + p, j + p] ← R[i, j];
Fp;
Fp;
Fim;
O algoritmo tem, evidentemente, complexidade O(n2).
O argumento a seguir mostra que a recursão está correta,
considerando-se que o problema esteja resolvido para o quadrante I.
Realmente, a composição das soluções gera um conjunto de rodadas como o
desejado, pois:
a) nas n/2 rodadas finais, cada competidor da metade 1 compete com
todos os competidores da metade 2 e vice-versa.
b) em cada uma dessas rodadas participam todos os competidores, por
construção da tabela.
c) as atribuições dos jogos são coerentes, considerando-se as duas
metades, também por construção simétrica da tabela.
Para n potência de 2, este problema tem outra solução recursiva,
levemente diferente da apresentada, que consiste do seguinte:
a) preenche-se recursivamente o quadrante I. O problema elementar é
como no caso anterior.
b) O quadrante II é preenchido como no caso anterior.
c) O quadrante III é preenchido com permutações circulares dos
elementos do quadrante II.
d) O quadrante IV é preenchido de forma forçada pelo preenchimento
do quadranto III (ver a propriedade d necessária à matriz). A figura abaixo
exemplifica a idéia.
r1
r2
r3
r4
r5
r6
r7
r8
1
2
3
4
5
6
7
8
15
2
1
4
3
6
7
8
5
3
4
4
3
1
2
2
1
7
8
8
5
5
6
6
7
5
6
7
8
6
5
8
7
7
8
5
6
8
7
6
5
1
2
3
4
4
1
2
3
3
4
1
2
2
3
4
1
Finalmente, uma solução recursiva pode ser estabelecida para qualquer
número de elementos, a partir da idéia deste último algoritmo. O número de
rodadas será (n - 1) quando n for par ou n quando ímpar. Neste último caso
pode-se imaginar que há um competidor adicional "bye", que participa dos
emparelhamentos, de forma que podemos considerar o número de
competidores um número par. Não há maiores dificuldades na recursão quando
n = 4k, para algum inteiro k, pois a solução acima aplica-se
n é da forma
diretamente. A situação problemática é para números da forma: n = 4k + 2,
porque, neste caso, a primeira metade da tabela tem tamanho n/2 x (n/2+1), o
que geraria uma solução contendo n rodadas, que não é o objetivo perseguido.
Vamos verificar, entretanto, que há uma forma de contornar esse
incoveniente, eliminando uma das colunas dos quadrantes III e IV.
16
Torneio (m):
(A1.8)
Início:
Se (m = 1) Então R[1, 1] ← 1
Senão Se (m ímpar) Então
Torneio (m+1);
Se (m = n) Então
Considera o emparelhamento com (m+1) como “bye”;
Senão
p ← m/2;
Torneio (p);
Se (p ímpar) Então q ← p + 1
Senão
q ← p;
Para i de 1 a p
Para j de 1 a q:
R[i + p, j] ← R[i, j] + p;
Se (R[i + p, j] = m) Então R[i + p, 0] ← j;
Fp;
Para j de 1 até p:
R[i, j + q] ← p + 1 + (i + j – 2) mod p;
R[p + 1 + (i + j – 2) mod p, j + q] ← i;
Fp;
Fp;
Se (p ímpar) Então
Para i de 1 a ma:
R[i, R[i, 0] ] ← R[i, q + 1];
Para j de (q + 1) a (q + p):
R[i, j] ← R[i, j+1];
Fp;
Fp;
Fim;
A recursão pode ser assim explicada:
a) Se n for ímpar, resolve-se o problema para n+1 e, no final, abandonase a última linha e substitui-se o emparelhamento com o último
número pelo emparelhamento “bye”.
b) Resolve-se, recursivamente o problema para n/2.
17
c) Preenche-se os quadrantes II, III e IV conforme a segunda solução
mostrada anteriormente para potências de 2, ou seja: o quadrante II
é uma cópia do quadrante I, adicionando-se n/2 a todos os elementos.
Caso o elemento seja “bye”, o correspondente também é tornado
“bye”. O quadrante III é preenchido com permutações circulares dos
números n/2+1 a n. O quadrante IV é preenchido de maneira forçada.
d) Quando n é da forma n = 4k+2, temos alguns emparelhamentos “bye”
indesejados nos quadrantes I e II, conforme descrito. Além disso a
solução contém n rodadas. Mas podemos eliminar a primeira coluna
dos quadrantes III e IV. Podemos observar que esta coluna contém
os números com a seguinte ordenação: n/2+1, n/2+2...n, 1, 2, ...n/2.
Cada par (n/2+i, i), contendo um elemento do quadrante III e outro
do quadrante IV, pode ser movido para a coluna do “bye” pois, pela
simetria envolvida, essa coluna é a mesma para o par. Assim,
consegue-se eliminar uma coluna e temos uma solução em n-1 rodadas,
que era o objetivo inicial.
Vejamos como se resolveria o problema para n = 5.
Como 5 é ímpar, resolvemos o problema para n1 = 6. Para tanto, começase fazendo a chamada recursiva para resolver o problema para n2 = 3. Como 3 é
ímpar, resolve-se o problema para n3 = 4, que faz a chamada recursiva para
n4 = 2, com chamada para n5 = 1 e depois obtendo:
r1
1
2
r2
2
1
A partir dessa solução, obtem-se sem dificuldade a solução para
n3 = 4:
r1
1
2
3
4
r2
2
1
4
3
r3
3
4
1
2
r4
4
3
2
1
Como o bojetivo era a solução para n2 = 3, elimina-se a última linha e
transforma-se o emparelhamento com 4 em emparelhamento “bye”, obtendo:
r1
r2
r3
r4
18
1
2
3
2
1
-
3
1
3
2
Agora volta-se ao problema para n1 = 6. Aplicando-se o procedimento
mencionado, obtemos:
r1
r2
r3
r4
r5
r6
r7
1
2
3
-
4
5
6
2
1
-
3
5
6
4
3
-
1
2
6
4
5
4
5
6
-
1
3
2
5
4
-
6
2
1
3
6
-
4
5
3
2
1
Como 6 é um número da forma 4k+2, temos uma solução indesejada em 6
rodadas. A seguir, move-se os pares (4, 1), (5, 2), (6, 3), da coluna r5
para as colunas respectivas de “bye” e depois elimina-se essa coluna,
redefinindo as colunas r6 e r7, finalizando a solução para n1 = 6.
r1
1
2
3
4
5
6
r2
2
1
r3
3
6
1
6
3
4
6
5
r1
1
2
3
4
5
6
r2
2
1
6
5
4
3
r3
3
5
1
6
2
4
r4
4
3
2
1
6
5
5
4
5
2
r4
r5
r6
5
6
4
3
1
2
r5
5
6
4
3
1
2
r6
6
4
5
2
3
1
4
3
2
1
19
r7
6
4
5
2
3
1
Para obter a solução final (n = 5), elimina-se a linha 6 e transforma-se os
emparelhamentos com 6 para emparelhamentos “bye”, obtendo, então:
r1
1
2
3
4
5
r2
2
1
5
4
r3
3
5
1
2
r4
4
3
2
1
-
r5
5
4
3
1
r6
4
5
2
3
Um esboço do algoritmo é mostrado a seguir:
Torneio (m);
Início:
Se (m = 1) Então R[1,1] ← 1;
Senão Se (m ímpar) Então
Torneio(m+1);
Transforma emparelhamento com (m+1) em “bye”;
Senão
Torneio (m/2);
Copia Quadrante I p/ Quadrante II, acrescentando m/2;
Gera permutação circular no quadrante III, para os elementos
(m/2 + 1) a m;
Preenche de maneira forçada o Quadrante IV;
Se (m/2 ímpar) Então
Move os elementos da coluna m/2+2 para a coluna de “bye”;
Move uma posição para a esquerda as colunas m/2+3 a m+1;
Fim;
1.3 Análise da Recursão
Serão apresentados três aspectos importantes para a análise do método
de Recursão. O primeiro é o critério de balanceamento, que serve como
orientação para se gerar bons programas recursivos, em geral. O segundo é
uma ferramenta para análise da complexidade da recursão. O terceiro é uma
ferramenta para evitar recursões ineficientes.
20
1.3.1 Balanceamento
Um critério importante na sudivisão de um problema em problemas
menores é o do Balanceamento: os problemas devem ter tamanhos iguais ou
bem próximos, sob pena de se obter soluções ineficientes. Vejamos dois
algoritmos distintos de ordenação: Mergesort e Bubblesort.
O algoritmo Mergesort pode ser expresso pela recursão a seguir:
(A1.9)
Mergesort(E, D);
Início:
Se D > E Então
m ← (E+D)/2;
Mergesort(E, m);
Mergesort(m+1, D);
Merge (E, m, D);
Fim;
Chamada externa: Merge(1,n);
No algoritmo Mergesort, cada problema de tamanho n foi dividido em
dois subproblemas de tamanho aproximado n/2. A subdivisão é, pois,
balanceada e a complexidade do algoritmo é O(nlogn).
Já o algoritmo Bubblesort, pode ser expresso por:
Bubblesort(m);
Início:
Se (m > 1) Então
Para j de 1 a (m -1):
Se (Vet[j] > Vet[j+1]) Então
Troca(Vet[j], Vet[j+1]);
Fp;
Bubblesort(m -1);
Fim;
Chamada externa: Bubblesort(n);
(A1.10)
Neste caso, cada problema de tamanho n foi subdividido em dois
problemas: um de tamanho 1 e outro de tamanho n-1. Para este algoritmo temos
a complexidade O(n2), ilustrando o fato de que o critério de balanceamento é
algo positivo na recursão.
21
Quando um procedimento recursivo divide um problema grande em mais
de um subproblema menor, a técnica costuma ser denominada Divisão e
Conquista.
1.3.2 Árvore de recursão
A análise da complexidade de algoritmos recursivos fica em geral
facilitada quando se constrói a árvore de recursão das chamadas. No caso do
algoritmo Fatorial, a árvore de recursão tem o seguinte aspecto:
n
n-1
...
1
Como, em cada etapa da árvore é executada apenas uma instrução, a
complexidade do algoritmo é equivalente ao número de nós da árvore
(chamadas recursivas) que é O(n).
Já no caso de Fibonacci, a árvore tem o seguinte aspecto:
n
n-1
n-2
n-2
n-3
n-3
n -4
.....................................................................
0
1 .........................................
0
1
Como, em cada etapa da árvore é executada apenas uma instrução, a
complexidade do algoritmo é igual ao número de nós da árvore que é O(2n), o
que já torna este algoritmo inviável para valores de n bastante baixos (algo em
torno de n = 30), qualquer que seja o computador utilizado! Para este algoritmo
existe uma solução não recursiva trivial eficiente cuja complexidade é O(n).
Basicamente o que está errado nessa solução é que o mesmo subproblema pode
estar sendo resolvido milhares de vezes, à medida que se desce na árvore.
A recorrência associada à série de Fibonacci é a seguinte:
T(0) = 0
T(1) = 1
22
T(n) = T(n-1) + T(n-2)
Podemos também criar uma recorrência que indica o número de
chamadas recursivas da versão recursiva do algoritmo para Fibonacci:
T(0) = 1
T(1) = 1
T(n) = 1 + T(n-1) + T(n-2) = 2*Fib(n+1) - 1.
A solução dessa recorrência mostra que a complexidade do algoritmo é
exponencial.
1.3.3 Memoization
Para problemas como o anterior, cuja recorrência leve a uma
complexidade exponencial, pode-se muitas vezes evitar essa complexidade
através de uma técnica que tabele os resultados de chamadas intermediárias ,
de tal forma que a recursão para essas chamadas só ocorrerá uma vez. Nas
demais, será utilizado um resultado encontrado em uma tabela apropriada.
Consideremos o problema Moedas, cujo objetivo é o de determinar o
número de possibilidades diferentes de gerar um troco no Brasil, utilizando
apenas os 6 tipos de moedas de centavos existentes: 1 (R$ 0,01), 5 (R$0,05),
10 (R$0,10), 25 (R$0,25), 50 (R$0,50) e 100 (R$ 1).
Expressaremos todos os valores em centavos, daquí por diante. Por
exemplo, para dar um troco de 11 temos 4 possibilidades diferentes:
10 + 1 ; 2 x 5 + 1; 5 + 6 x 1; 11 x 1.
Esse problema tem uma formulação recursiva, que pode ser estabelecida
da seguinte maneira:
Seja T(m,n) o número de possibilidades distintas de se gerar um troco
de valor n, usando os m tipos iniciais de moedas. Inicialmente, supondo n > 100,
podemos escrever:
T(m,n) = T(1, n - 1) + T(2, n - 5) + T(3, n - 10) + ...T(6, n - 100).
O que pode ser assim entendido: A primeira parcela corresponde a se
tomar as T(1, n - 1) maneiras distintas dar um troco para (n - 1) usando apenas
a moeda de 1 centavo (neste caso T(1, n - 1) = 1, evidentemente). A segunda,
T(2, n - 5) corresponde a se considerar os trocos possíveis para (n - 5)
23
centavos fixando-se sempre a primeira moeda como uma moeda de 5 e
utilizando a solução para (n-5) com moedas de 1 e 5. De forma análoga para os
demais. A parcela T(6, n - 100) corresponde aos tipos de troco que sempre tem
uma moeda de 100, agregada às diversas possibilidades para trocos de
(n - 100) centavos usando todos os tipos de moedas possíveis. Para
completarmos a idéia recursiva, temos que estabelecer:
T(m, 0) = 1 (pois só há uma maneira de dar troco 0).
T(m, n) = 0, quando n < 0, pois não há troco negativo.
A recursão, então, pode ser assim apresentada, considerando-se um
vetor V[1..m], contendo os valores dos m (=6) tipos de moedas, em ordem
crescente:
V[1] ← 1; V[2] ← 5; V[3] ← 10; V[4] ← 25; V[5] ← 50; V[6] ← 100;
T(m,n) ← 0, se n < 0;
T(m,n) ← 1, se n = 0;
T(m,n) ← Σ T(i, n - V[i]), n > 0, 1 ≤ i ≤ m.
Não é preciso analisar muito para se perceber que essa formulação leva
a um algoritmo exponencial. Entretanto, se criarmos uma matriz T, m x n, onde
elemento da matriz tem o significado da recursão acima, podemos estabelecer
o algoritmo abaixo, que tabela os resultados necessários na matriz, de forma
que a recursão para cada par de valores só será chamada uma vez. O algoritmo
é o seguinte:
24
Moedas (q, p);
Início:
Se (p < 0) Então k ← 0;
Senão Se (p = 0) Então k ← 1;
Senão Se (T[q, p] = 0) Então
k ← 0;
Para i de 1 a q:
k ← k + Moedas(i, p - V[i]);
Fp;
T[q, p] ← k;
Senão k ← T[q, p];
Retornar (k);
Fim;
(A 1.11)
A tabela abaixo mostra quais subproblemas teriam que ser tabelados
para n = 15;
1
2
3
4
5
6
0
1
1
1
1
2
1
3
1
4
1
5
1
2
2
6
1
7
1
8
1
9
1
10 11 12 13 14 15
1
1
1
1
1
3
6
A tabela mostra que os seguintes subproblemas seriam tabelados:
Todos os subproblemas T[1, i] para i < 15;
Os subproblemas T[2, 0], T[2, 5], T[2, 10]
O subproblema T[3, 5];
Desta forma, o algoritmo não é mais exponencial e passa a ser O(n2).
25
1.4 Exercícios Propostos
Escrever algoritmos recursivos para os problemas descritos a seguir.
1.1 - (ALGOC)
Quer-se fazer a geração de constantes numéricas usando apenas as
seguintes operações:
ONE - gera o número 1
MONE - gera o número -1
DUP - duplica o valor
ADD - soma 1 ao valor
(Maratona ACM - 1998 - América do Sul)
1.2 - (Nós mais distantes em uma árvore binária)
Encontrar os dois nós mais distantes em uma árvore binária. (Se houver
mais de um par listar todos os pares).
1.3 - (Algoritmo de Euclides)
Encontrar o máximo divisor comum de dois números inteiros, usando a
idéia do algoritmo de Euclides.
1.4 - (Seleção 2 menores)
Selecionar os dois menores elementos de um conjunto de n números.
1.5 - (Busca por Faixa em Árvores Binárias de Busca)
Mostrar todas as chaves de uma ABB, cujos valores situem-se dentro de
uma faixa [c, f] dada.
1.6 - (Desenho de árvore binária)
Desenhar uma árvore binária, tal que a raiz apareça no centro da página,
a raiz da subárvore esquerda no centro da metade esquerda da página, etc;
1.7 - (Caminho externo de AB)
Calcular o tamanho do caminho externo de uma árvore binária.
1.8 - (Radix Sort)
Ordenar um conjunto de elementos, de forma análoga ao Quicksort,
usando como critério de partição o valor do bit de dada posição.
1.9 - (Cálculo de Potências)
Calcular pn , onde n é um número inteiro.
26
1.10 - (Desenho de aproximação de segmento)
Desenhar uma aproximação do segmento de linha que une dois pontos de
coordenadas (x1, x2) e (y1, y2) usando subsegmentos apenas de coordenadas
inteiras.
1.11 - (Pesquisa binária)
Buscar um elemento em um vetor ordenado usando o critério de pesquisa
binária.
1.12 - (Problema de Josephus)
Quer-se selecionar um elemento dentre n, numerados de 1 a n, que estão
numa lista circular. A seleção é feita recebendo-se um inteiro p e fazendo-se a
contagem circular de 1 a p eliminando-se, sucessivamente, da lista o elemento
que recebeu a contagem p. Sempre que se elimina um elemento, a próxima
contagem começa no elemento seguinte da lista. O selecionado é o último a sair.
1.13 - (Fractais)
Pesquisar algoritmos para desenhos de fractais. (Ver "Algorithms +
Data Structures = Programs", de N. Wirth, Prentice_Hall, 1976.
1.14 - (Torneio)
Fazer um algoritmo alternativo para Torneio, quando n é um quadrado
perfeito.
27
2. BACKTRACKING
2.1 Conceitos básicos
É um método organizado de se testar exaustivamente as possibilidades
de configuração de objetos ou situações, em geral com o objetivo de escolher
configurações com propriedades dadas. Um exemplo clássico é o algoritmo para
se gerar todas as combinações p a p de n elementos.
Este método é também usado para a implementação de jogos por
computador, onde, em cada situação, este tem que escolher a melhor jogada
dentre as normalmente inúmeras possíveis, sendo que essa escolha deve
resultar do aprofundamento do exame das possíveis respostas do oponente,
combinadas a novas jogadas etc.
Diz-se que essa técnica é um método organizado de teste exaustivo,
porque ela deve ser tal que descubra, o mais cedo possível, configurações
inviáveis, o que pode levar ao abandono do exame de inúmeras situações
inviáveis derivadas. Isso quer dizer que o método nem sempre examina
exaustivamente as possibilidades, mas o abandono de certas possibilidades
inviáveis só aumenta a eficiência do processo.
Pode-se apresentar um algoritmo recursivo geral para esta idéia, que
será denominado Config, expresso da seguinte forma:
Config();
(A2.1)
Início:
Para cada elemento envolvido, efetuar:
Se o elemento pode entrar na configuração, Então
Colocar o elemento na configuração;
Se a configuração tem a propriedade desejada Então
Imprimir a configuração;
Senão
Config();
Retirar o elemento da configuração;
Fim;
Algumas observações sobre este modelo:
1) Evidentemente há uma chamada externa ao procedimento, normalmente
precedida do esvaziamento da configuração.
2) O teste para descobrir se o elemento participa da configuração pode variar
bastante de natureza, dependendo da situação. Quanto mais cedo se descobrir
28
elementos inviáveis na configuração, melhor, pois menor aprofundamento é
necessário.
3) Grande parte das variáveis envolvidas pode ser de escopo global.
4) Muitas vezes pode ser necessário o uso de parâmetros na chamada
recursiva.
Inicialmente apresentamos um algoritmo para gerar permutações dos
números 1 a n. O algoritmo usa um vetor P, inicialmente vazio, acrescentando,
sucessivamente e de todas as maneiras, os números 1 a n em P, sem repetir.
(A2.2)
Perm;
Início:
Para i de 1 a n
Se (i ∉ P) Então:
P ← P + i;
Se (|P| = n) Então
Imprimir P;
Senão
Perm();
P ← P - i;
Fp;
Fim;
Externamente deve ser feito:
P ← ∅;
Perm;
Uma forma mais diretamente implementável para o algoritmo acima
utiliza uma variável externa para controlar o tamanho do vetor P (np) e um
vetor booleano auxiliar para controlar testar se i ∉ P. Obtemos:
Externamente deve ser feito:
Para j de 1 a n: S[j] ← False; Fp;
np ← 0;
Perm;
29
Perm;
Início:
Para i de 1 a n
Se (not S[i] ) Então:
np ← np + 1;
P[np] ← i;
S[I] ← True;
Se (np = n) Então
Imprimir P;
Senão
Perm;
np ← np - 1;
S[I] ← False;
Fp;
Fim;
(A2.2)
Note a semelhança entre o algoritmo de Permutação e o modelo inicial
apresentado para a técnica de Backtracking. Na verdade, o problema de gerar
permutações é efetivamente um dos modelos básicos desta técnica, inclusive
em termos de complexidade. Vê-se claramente que a complexidade deste
algoritmo é O(n.n!), que não pode, entretanto, ser melhorada, já que o
problema é, em sí, exponencial.
Como esta técnica lança mão basicamente da recursão, temos também
aquí a árvore de recursão de cada algoritmo. No caso do algoritmo de
Permutação, se supusermos n = 5, ela tem a seguinte estrutura:
Perm
1
4
2
3
4
5
.....................................................
2
3
4
5
.................................................
3
4
5
...............................
5
5
O número de folhas da árvore é 5! e cada caminho da raiz até uma das
folhas obtem uma permutação diferente.
De uma maneira geral, a árvore de recursão de backtracking tende a ser
uma árvore do mesmo tipo da árvore de recursão de Permutação, onde o
30
número de possibilidades é exponencial. Isto esclarece a observação feita
inicialmente de que é importante evitar possibilidades inviáveis, o mais cedo
possível. Em termos da árvore de recursão, isso significa "podar" todo um ramo
da árvore, sempre que se descobre uma inviabilidade em uma configuração
parcial, o que leva a uma maior eficiência do algoritmo. No ítem relativo aos
Jogos, algumas técnicas de "poda" serão explicadas.
2.2 Problemas clássicos
Neste tópico são apresentados os seguintes algoritmos clássicos com
solução por backtracking:
a) Geração de combinações
b) Damas pacíficas
c) Torneio
d) Geração de subconjuntos
e) Soma de subconjuntos
f) Partição aproximada
31
2.2.1 Geração de combinações
As combinações t a t dos n elementos de um conjunto podem ser
apresentadas em ordem lexicográfica. Uma forma de fazer isso é modificar a
solução dada para a geração de permutações, colocando-se a restrição de que
cada novo elemento só entra na configuração, se for maior que o último que
entrou. Outra forma é utilizar um parâmetro que indica o valor mínimo dos
elementos que podem entrar na configuração. Esse algoritmo é o seguinte:
Comb(j);
Início:
Para i de j a n:
nc ← nc + 1; P[nc] ← i;
Se (nc = t) Então
Imprimir P;
Senão
Comb(i+1);
nc ← nc - 1;
Fp;
Fim;
(A2.3)
Externamente deve ser feito:
nc ← 0; Comb(1);
A complexidade do algoritmo acima, para valores baixos de p, é
polinomial. Este algoritmo também ilustra o mecanismo de "poda" mencionado
anteriormente. Se desenharmos a nova árvore de recursão e a compararmos
com a anterior, para o caso de n = 5, temos várias podas a cada nível da árvore.
32
2.2.2 Damas pacíficas
Este é um quebra-cabeças que utiliza os conceitos e o tabuleiro do jogo
de xadrez. O objetivo é colocar 8 damas no tabuleiro, de forma que elas não se
"ataquem", segundo as regras desse jogo. O exemplo abaixo ilustra uma das 92
possíveis soluções (das quais apenas 12 são soluções não simétricas):
1 ℜ
ℜ
2
ℜ
3
ℜ
4
ℜ
5
ℜ
6
ℜ
7
ℜ
8
1 2 3 4 5 6 7 8
O seguinte algoritmo resolve esse problema:
Damas_pacificas;
Início:
Para c de 1 até 8:
Se (Permite(l+1, c)) Então
l ← l + 1;
TAB[l] ← c;
Se (l = 8) Então
Imprimir TAB;
Senão
Damas_pacificas;
TAB[l] ← 0;
l ← l -1 ;
Fp;
Fim;
(A2.4)
Externamente:
Zerar TAB;
l ← 0;
Damas_pacificas();
Foi usada a função Permite(l, c) que verifica se é possível se colocar mais
uma dama na posição (l, c), linha l, coluna c, sem atacar as demais (l -1). Notar
que tanto o vetor quanto a variável l são globais. O algoritmo obtem todas as
33
92 soluções possíveis, mas poderia ser modificado para exibir apenas a
primeira solução encontrada (!). Embora não seja dada a complexidade do
algoritmo, ele também ilustra a explicação anterior sobre “poda” na árvore de
recursão.
2.2.3 Torneio
Podemos retornar ao problema de organização de torneios com n
competidores, discutido no ítem 1.2.5. O seguinte algoritmo resolve o
problema:
Torneio:
(A2.5)
Início:
jog ← menor jogador não emparelhado na rodada;
rod ← (NE+1)/n
Para i de (jog+1) até n:
Se (Pode_emparelhar(jog, i, rod)) Então
NE ← NE+2;
R[jog, rod] ← i; R[i, rod] ← jog;
Se (NE = n2) Então
Imprime R;
Senão
Torneio;
R[jog, rod] ← 0; R[i, rod] ← 0;
NE ← NE - 2;
Fp;
Fim;
Externamente:
R[*,*] ← 0;
Para j de 1 até n: R[j,1] ← j; Fp;
NE ← n; Torneio();
Neste algoritmo R tem o mesmo significado que anteriormente, tanto
que inicialmente faz-se com que a coluna 1 contenha os números 1 a n.
Pode_emparelhar verifica se os parâmetros são diferentes e se já não foram
emparelhados anteriormente (pode ser usada outra matriz para essa função).
NE indica o número de emparelhamentos já realizados.
A figura seguinte mostra um dos milhares de emparelhamentos obtido
para n = 8:
34
c
1
2
3
4
r1
2
1
4
3
r2
3
4
1
2
r3
4
3
2
1
r4
5
8
6
7
r5
6
7
5
8
r6
8
5
7
6
r7
7
6
8
5
5
6
7
8
6
5
8
7
7
8
5
6
8
7
6
5
1
3
4
2
3
1
2
4
2
4
3
1
4
2
1
3
O algoritmo gera todas as configurações possíveis mas pode ser
modificado para gerar apenas uma delas, ou configurações com determinadas
propriedades.
2.2.4 Geração de subconjuntos
Muitos problemas envolvem a geração de alguns subconjuntos de dado
conjunto. Vejamos inicialmente um modelo geral para a geração de
subconjuntos de dado conjunto, mesmo sabendo que tal algoritmo tem
complexidade exponencial, pois um conjunto com n elementos tem 2n
subconjuntos distintos.
A idéia é se colocar os elementos do conjunto em uma lista C e se tomar,
a cada passo um desses elementos, combinando com o mesmo todos os demais
elementos à sua direita na lista.
Isso fornece o algoritmo a seguir, que usa uma variável global ns e um
vetor S que guarda o índice dos elementos do subconjunto:
35
Gera_subconjuntos (pi):
Início:
Para i de pi a n:
ns ← ns + 1; S[ ns] ← i;
Imprimir SubConj;
Se (i < n) Então
Gera_subconjuntos (i + 1);
ns ← ns - 1;
Fp;
Fim;
(A2.6)
Externamente:
ns ← 0; Gera_subconjuntos(1);
Notar que no Backtracking acima simplificou-se, ligeiramente, o modelo
geral, pois cada solução parcial é uma solução final e, além disso, também pode
ser uma subsolução de uma outra. A sequência de subconjuntos gerada para o
conjunto {a, b, c, d} seria:
{a}, {a, b}, {a, b, c}, {a, b, c, d}, {a, b, d}, {a, c}, {a, c, d}, {a, d}, {b}, {b, c},
{b, c, d}, {b, d}, {c}, {c, d}, {d}.
2.2.5 Soma de subconjuntos
Um problema interessante e que tem muitas variantes com aplicações
práticas é o de, dado um conjunto C com n números inteiros, determinar se
existe e quais combinações desses elementos têm soma igual a um valor s dado.
Este problema é um caso particular de outro, muito famoso, denominado
problema da Partição, que consiste em, dado um conjunto, determinar formas
de particioná-lo em dois outros, tais que a soma dos elementos de cada
partição seja a mesma. Ou seja, Soma de Subconjuntos é uma particularização
de Partição, quando s não é a metade da soma dos elementos desse conjunto.
Uma das possíveis soluções para soma de Subconjuntos é obtida da
seguinte forma: ordenam-se os números e agrupam-se os mesmos de forma
organizada, procurando obter o alvo s dado. Se, num dado ponto, a soma fica
maior que s, o último elemento é abandonado, bem como todos aqueles maiores
que o mesmo. O seguinte algoritmo resolve o problema:
36
Soma_subconjuntos(j):
(A2.7)
Início:
i ← j;
Enquanto (i ≤ n) e ((soma + C[i]) ≤ s):
nr ← nr + 1; soma ← soma + C[i]; subConj[nr] ← i;
Se (soma = s) Então
Imprimir subConj;
Senão
Soma_subconjuntos(i+1);
subConj[nr] ← 0; soma ← soma - C[i]; nr ← nr - 1;
i ← i+1;
Fe;
Fim;
Externamente:
soma ← 0; nr ← 0;
Ordena C;
Soma_subconjuntos(1);
O vetor subConj guarda os índices dos elementos que participam da soma
desejada. Notar que foi utilizado um parâmetro na recursão. Notar, também a
"poda" colocada no "loop" que pode fazer o algoritmo funcionar bem para
valores pequenos de s. No caso não são gerados todos os subconjuntos, mas
apenas aqueles que possam levar à solução do problema. Muitas possibilidades
podem ser abandonadas o mais cedo possível.
2.2.6 Partição aproximada
O problema mencionado anteriormente, Partição, nem sempre tem
solução. Uma variante do mesmo, denominada Partição Aproximada, faz o
particionamento do conjunto de forma a minimizar a diferença da soma entre
as duas partições. Isso pode ser o melhor a se fazer em determinadas
situações.
O algoritmo a ser mostrado constitui-se um interessante paradigma de
Backtracking onde a solução passa a ser buscada cada vez mais em
subconjuntos restritos. O algoritmo a seguir é baseado no anterior, mas não se
geram todos os subconjuntos. Apenas se examinam subconjuntos que tenham
chance de melhorar uma solução prévia, caracterizada por dois parâmetros: dif
= menor diferença entre partições já encontrada e msmp = maior soma da
melhor partição já encontrada. A primeira solução considerada é uma partição
37
contendo um dos conjuntos nulos e o outro todos os elementos. Gradualmente
vai-se obtendo soluções cada vez melhores e cada melhoria é armazenada, em
substituição à melhor solução anteriormente. A melhor partição guardada é a
solução e ela é exibida. O algoritmo é mostrado a seguir.
Part_aproximada(pi):
(A2.8)
Início:
i ← pi;
Enquanto (i ≤ n) e ((soma + C[i] ) < msmp):
nr ← nr + 1; soma ← soma + C[i]; Part[nr] ← i;
Se (abs(tot – 2*soma) < dif) Então
Guarda; dif ← abs(tot – 2*soma);
msmp ← max(soma, tot - soma);
Part_aproximada(i + 1);
Part[nr] ← 0; soma ← soma - C[i]; nr ← nr - 1;
i ← i+1;
Fe;
Fim;
Externamente:
soma ← 0; nr ← 0; tot ← ΣC[i];
Ordena C; Part_aproximada(1);
Imprime solução guardada;
dif ← tot; msmp ← tot;
Neste algoritmo é chamado o procedimento Guarda, que armazena a
melhor solução encontrada até o momento e atualiza o valor de dif. Se houver
interesse em apenas uma solução, um mecanismo que pode parar a recursão é,
quando a diferença (dif) atingir 0.
Como exemplo, se tomarmos o conjunto {3, 4, 5, 7}, teremos a seguinte
sequência de subconjuntos examinados e guardados:
{}
{3}
{3, 4}
{3, 5}
{3, 7}
Guardados msmp
19
{3}
16
{3, 4}
12
{3, 5}
11
{3, 7}
10
dif
19
13
5
3
1
38
2.3 Jogos
Uma das aplicações típicas da técnica de Backtracking é a programação
de jogos, cujo desenvolvimento permitiu a utilização do método em vários
outros tipos de problema. Considere jogos tais como xadrez, damas, jogo-davelha, onde há 2 jogadores. Os jogadores fazem jogadas alternadas e o estado
do jogo pode ser representado pela posição do tabuleiro.
2.3.1 Árvore de jogo
Suponha que há um número finito de configurações de tabuleiro e que o
jogo tem uma regra de parada. A esse tipo de jogo pode-se associar uma
árvore chamada "árvore do jogo". Cada nó representa uma posição do tabuleiro.
A raiz é a situação inicial e cada nível da árvore contém as possíveis jogadas de
um dos jogadores, de forma alternada.
A árvore a seguir representa uma situação de decisão no jogo-da-velha,
onde o computador tem que jogar. Suas jogadas são marcadas com "x", e as do
oponente com "o":
o
x
x
A (1)
D (0)
B (-1)
o
o
x
(1) O computador joga
C (0)
E(-1)
H (0)
F (0)
G (1)
I (0)
J (1)
As possibilidades de continuação do jogo são as seguintes:
A e J - o computador joga na linha 3 e ganha;
B - o computador joga na linha do meio e o jogo continua
C - o computador joga na linha 1 e o jogo continua
D - o oponente joga na linha 3 e o jogo continua
E - o oponente joga na linha 1 e ganha
F - o oponente joga na linha 3 e o jogo continua
G - o oponente joga na linha do meio e o jogo continua
H - o computador joga na última linha e é empate
I - o computador joga na linha do meio e é empate
39
Uma técnica para analisar o jogo consiste em atribuir às folhas 1 quando
o computador ganha, 0 quando há empate ou -1 quando o oponente ganha. Esses
valores podem ser propagados árvore acima, através do critério denominado
MiniMax, que é o seguinte: cada nó pai recebe o valor máximo dos filhos quando
é uma posição de jogada do computador, e o valor mínimo quando é uma posição
de jogada do oponente.
Esses números refletem a melhor estratégia para cada competidor.
Quando for a vez do computador e o nó tiver valor 1, então há uma estratégia
vencedora, dada por um caminho de valor 1 em todos os nós .
Para jogos mais complicados, como o xadrez, por exemplo, a valoração
dos nós assume uma gama mais ampla, podendo ir de –999 a 999, por exemplo,
como será visto adiante.
O algoritmo para o jogo consiste, portanto, em se construir a árvore,
usando a ténica MiniMax e depois escolher a jogada usando a mesma.
O cálculo dos valores dos nós pode ser feito pelo algoritmo seguinte, que
basicamente é um percurso em pós-ordem na árvore de jogo, onde F
representa um nó da árvore e Modo tem valor Min, quando o nó é de jogada do
oponente ou Max, quando é de jogada do computador:
Avalia_no (F, Modo);
(A2.9)
Início:
Se (F é folha) Então
Retornar (Calcula_valor(F));
Senão
Se (Modo = Max) Então Valor ← -∞;
Senão
Valor ← ∞;
Para cada filho w de F:
Se (Modo = Max) Então
Valor ← Max (Valor, Avalia_no(w, Min));
Senão
Valor ← Min (Valor, Avalia_no(w, Max));
Retornar Valor
Fim;
A função Calcula_valor avalia o valor da posição final do jogo. A
constante Infinito representa o maior valor inteiro que pode ser representado.
40
2.3.2 Poda Heurística
Alguns jogos, entretanto, têm um número de alternativas absurdamente
grande, o que inviabiliza a construção e exame de toda a árvore. As folhas
muitas vezes representarão apenas configurações parciais do jogo.
No caso do xadrez a ordem de grandeza do número de folhas é 35100. O
que se faz, então é a introdução de Heurísticas para avaliação de posições do
tabuleiro, somente aprofundando a análise de situações mais críticas e
aplicando o método MiniMax para a árvore "podada" obtida e considerando,
agora, que a valoração poderá variar numa gama mais ampla do que a vista para
o jogo da velha, já que não se pode mais aplicar o critério absoluto de posição
perdida/ganha/empatada, uma vez que a avaliação não parte dos nós finais.
A perícia de um jogo para xadrez reside, portanto, na qualidade das
heurísticas utilizadas. A avaliação heurística de uma configuração do tabuleiro
é empírica, levando em conta o balanço ponderado das peças presentes e o
valor de elementos estratégicos e táticos. O sucesso desse tipo de programa
evidencia que existem boas heurísticas para a situação. Entretanto fica
também evidente que nenhum programa é imbatível por um ser humano, visto
que o programa não joga de forma exata.
Quando se usa poda heurística, a cada jogada é construída uma nova
árvore, que só é usada para a decisão do próximo lance.
2.3.3 Poda alfa_beta
Uma das melhorias que podem ser introduzidas no método MiniMax é
denominada poda Alfa_beta, que permite abandonar o exame de trechos da
árvore. Seja a situação abaixo da árvore de jogo:
Max
Min
x
f1 (120)
f2(≤ 32)
f3
h1 (32)
Suponha que se acabou de determinar o valor do nó f1, depois de
percorrer a sub-árvore correspondente. Quando se começa a examinar a subárvore do nó irmão f2 e se descobre que o valor do primeiro filho é 32, pode-se
abandonar todo o exame de f2, atribuindo a esse nó o valor aproximado 32, já
que o mesmo não vai influenciar no cálculo de x, pois f2 tem o valor limitante
32.
41
De forma análoga à descrita, o exame de muitos trechos da árvore pode
ser abandonado, tornando sua avaliação mais rápida. Na realidade, a construção
da árvore pode ser simultânea à avaliação, o que significa que pode não ser
necessária a construção de toda a árvore do jogo.
Dessa forma a poda Alfa-beta pode tornar o jogo mais eficiente.
42
2.4 Exercícios Propostos
Escrever algoritmos para os problemas descritos a seguir, usando
backtracking:
2.1 – (Soma de Subconjuntos)
Dado um conjunto com n números, onde há repetições, e um valor s,
listar, ordenadamente, as somas de elementos do conjunto cujo valor seja s,
sem repetir configurações de soma.
2.2 - (Percurso do cavalo)
Quer-se fazer um cavalo percorrer todas as casas de um tabuleiro de
xadrez de forma a não repetir nenhuma posição pela qual já passou. Dica:
tentar uma heurística para dar preferência para casas com menos
possibilidades de saídas.
2.3 - (Soma de Quadrados)
Escrever um algoritmo para, dado um inteiro positivo n, listar todas as
combinações de somas de quadrados de inteiros menores que n, iguais ao
quadrado de n. Observar que pode repetir quadrado. Exemplo: para n = 3,
temos: 32 = 12+12+12+12+12+12+12+12+12 = 12+12+12+12+12+12 = 12 + 22 + 22.
2.4 - (Vasos)
Dados 3 vasos contendo água, com capacidades (c1, c2, c3), situação
inicial (s1, s2, s3), quer-se determinar qual o número mínimo de operações de
transferência, para se atingir o objetivo (o1, o2, o3) dado. Cada transferência
ou enche o vaso de destino ou esvazia o de origem e a quantidade de água é
mantida no processo. Observar que pode não haver solução. (Maratona ACM 2000 - América do Sul)
2.5 – (Expressão)
Fazer um algoritmo para, dados n+1 números inteiros positivos, verificar
se é possível escrever o primeiro como uma combinação linear dos n restantes
(usando somas e subtrações, apenas).
Ex: para {13, 9, 5, 11, 20} é possível, pois 13 = 9 – 5 – 11 + 20; já para {3, 200,
150, 8, 15} não é possível.
43
2.6 - (Quadrado Mágico)
Apresentar um quadrado mágico para dado n. Um quadrado mágico é um
quadrado de lado n, divido em n2 células, preenchido com os números 1 a n2, tal
que a soma das linhas = soma das colunas = soma das diagonais principais.
2.7 - (Quadrados latinos ortogonais)
Apresentar dois quadrados latinos ortogonais de lado n. Um quadrado
latino de lado n é uma matriz (n x n) tal que, cada linha e cada coluna, são
preenchidas com uma permutação dos números 1 a n. Dois quadrados latinos A
e B são ortogonais se, para todo i e j, A[i,j] ≠ B[i, j].
2.8 - (Chumbo Ou Ouro)
A empresa ACM descobriu que pode-se transformar Chumbo em Ouro,
submetendo o primeiro a uma mistura dos elementos A, B, C, que estejam numa
proporção de p, q e r nessa mistura. Dadas n misturas dos elementos químicos
A, B, C, em proporções dadas, determinar se pode ser criada uma nova mistura
na proporção desejada. (Maratona ACM - 1998 - Final)
2.9 - (Palavras cruzadas)
Dados dois números l e c, que indicam a quantidade de linhas e de colunas
em um diagrama de palavras cruzadas e l "strings" de tamanho c cada um,
sendo que cada "string" contém palavras e '*' . Cada "string" se encaixe
exatamente em uma das linhas do retângulo, e o * corresponde a uma posição
não usada do diagrama. Quer-se saber se esses "strings" podem ser
encaixados no diagrama tal que se formem palavras válidas, contidas em um
vetor.
2.10 - (Retângulo de dominós)
O conjunto das peças do jogo de dominó é formado por 28 retângulos
cada um com dois quadrados contendo números 0 a 6. Cada peça é uma
combinação diferente das 7 possibilidades. Podemos dispor todas as peças de
um jogo de dominó em um retângulo de 7x8, podendo algumas peças serem
colocadas horizontalmente e outras verticalmente. Verificar se uma
configuração dada em um retângulo 7 x 8 corresponde às 28 peças do jogo.
(Maratona ACM - 1991 - Final).
2.11 - (Jogo da velha)
Implementar um programa para o Jogo da velha.
44
2.12 - (Jogo da Último palito)
Implementar um programa para o Jogo do Último palito, onde dois
jogadores iniciam com uma pilha de 24 palitos e, alternadamente, cada um pode
retirar 1 a 3 palitos, vencendo o último a retirar palitos.
2.13 - (Resta 1)
Implementar um programa para o jogo do Resta 1.
2.14 - (Quebra-cabeças)
Descrever um algoritmo para solucionar o quebra-cabeças que consiste
de um tabuleiro de 4 x 4, com 15 peças contendo os números 1 a 15 e um uma
posição nula. É dada uma configuração inicial e quer-se a chegar à configuração
padrão onde os números estão ordenados no tabuleiro.
2.15 - (Árvores de Jogo)
Desenhar árvores de Jogo para os seguintes jogos:
a) Jogo de Palitos, semelhante a 2.12, só que iniciando com 5 palitos.
b) Jogo da Multiplicação, onde é sorteado um número menor que 100. Começase com o número 1 e, alternadamente, cada adversário multiplica esse número
por outro que pode variar entre 2 e 5. Aquele que atingir ou superar o número
escolhido é o vencedor.
2.16 – (Permutações com repetições)
Dados n números, onde há repetições, listar as Permutações, sem
repetir nenhuma configuração.
2.17 - (Arranjos e Combinações com repetições)
Dados n números, onde há repetições, listar arranjos (A(n,p)) e
combinações (Comb(n,p)), sem repetir nenhuma configuração.
2.18 - (Contração de Inteiros)
Dada uma sequência de n números inteiros (n < 101), quer-se determinar
a ordem de contrações a serem feitas, tal que o resultado final seja um
número p dado. Cada contração toma dois elementos vizinhos da sequência,
substitui o primeiro pela diferença entre ele e seguinte, e elimina o elemento
seguinte. (Maratona ACM - 1998 - América do Sul)
2.19 – (Permutações de Knuth)
45
Fazer um algoritmo para gerar permutações, obtendo as permutações
para n elementos a partir daquelas para (n –1), colocando o n-ésimo elemento
em todas as n posições possíveis de cada permutação gerada com (n -1)
elementos.
Ex. No caso de n = 3 a ordem de geração seria: 321, 231, 213, 312, 132, 123.
2.20 – (Problemas em Grafos)
Fazer algoritmos para a solução dos seguintes problemas em grafos:
a) Determinar um ciclo Hamiltoniano.
b) Determinar a maior clique.
c) Determinar o maior conjunto independente.
d) Determinar uma coloração mínima de vértices.
e) Determinar uma coloração mínima de arestas.
46
3. PROGRAMAÇÃO DINÂMICA
3.1 Conceitos básicos
A aplicação da Divisão e Conquista na solução de problemas pode algumas
vezes levar a algoritmos ineficientes, tal como os exemplos mostrados para o
cálculo da sequência de Fibonacci e para o problema Moedas. Basicamente, o
que pode dar errado é o mesmo subproblema estar sendo resolvido mais de
uma vez, devido à formulação recursiva. Em alguns casos, milhares de vezes.
A técnica de Programação Dinâmica pode ser compreendida como uma
forma de evitar esse inconveniente, colocando resultados intermediários em
"tabelas", para fugir à repetição da solução de um mesmo subproblema.
Ela é usada tipicamente para problemas de otimização, onde se procura
o mínimo ou o máximo de alguma propriedade para certas configurações.
A solução de um problema usando esta técnica pode ser compreendida
como tendo uma formulação recursiva, complementada pelo enfoque "bottomup" para a composição dos subproblemas em problemas maiores. No caso da
sequência de Fibonacci, o problema é resolvido com um "loop" ascendente,
guardando cada elemento da sequência calculado em uma posição de um vetor.
Para ilustrar a técnica, consideraremos, inicialmente, o problema do
cálculo de probabilidades de vitória em "matches" onde dois competidores, C1 e
C2 disputam uma série de jogos em regime de "melhor de n". Isso que quer
dizer que aquele que atingir primeiro n pontos vence o "match". Adotaremos as
regras adicionais de que cada vitória vale 2 pontos, cada empate 1 e, se no final
houver empate em n a n, tem que ser adotado um critério adicional tal como o
sorteio do vencedor. Esta é a fórmula usada em muitos campeonatos de
futebol, onde se usa n = 3 ou 5. Nos "matches" finais dos campeonatos
mundiais de xadrez a fórmula é semelhante, só que cada vitória vale 1 ponto e
os empates são descartados.
Supondo que C1 e C2 tenham igual força, o problema será colocado como:
"qual a chance do competidor C1 se sagrar campeão, dado que
faltam i pontos para C1 alcançar a meta e j pontos para C2?"
O problema pode ser formulado recursivamente, usando-se a seguinte
recorrência:
47
P(i, j) = 1, se (i = 0 e j > 0)
(a)
= 0, se (i > 0 e j = 0)
(b)
= (P(i-1, j-1) + P(i-2, j) + P(i, j-2))/3, se (i > 1 e j > 1) (c)
= 1/2, se (i = 1 e j = 1)
(d)
= 2/3 + P(1,j-2), se (i = 1 e j > 1)
(e)
= P(i, j-2)/3, se (j = 1 e i > 1)
(f)
As diversas condições dessa recorrência refletem as seguintes
situações:
(a) - o competidor 1 já venceu.
(b) - o competidor 2 já venceu.
(c) - a idéia central da recorrência: se houver empate, cai-se na situação
de chances de P(i-1, j-1); se vitória, de P(i-2, j); se derrota, P(i, j-2). A chance
de cada possibilidade é 1/3.
(d) - situação particular, P(1,1), obviamente = 1/2
(e) - situação particular, com i = 1, onde a vitória e empate bastam para
o competidor 1.
(f) - situação particular onde apenas a vitória serve para o competidor 1.
Usando-se essa recorrência, constrói-se a tabela a seguir (n = 5), com
complexidade O(2n).
i
5
4
3
2
1
0
0
1/18
4/27
14/54 31/81
1/2
0
1/9
2/9
10/27 1/2
50/81
0
1/6
1/3
1/2
17/27 20/27
0
1/3
1/2
2/3
7/9
23/27
0
1/2
2/3
5/6
8/9
17/18
-
1
1
1
1
1
0
1
2
3
4
5
j
Podemos simplificar a recorrência anterior, introduzindo algumas
condições artificiais, equivalentes a uma nova coluna e uma nova linha na tabela,
correspondendo aos índices i = -1 e j = -1, além do valor P(0,0) = 1/2. Obtemos
a seguinte recorrência, que engloba a anterior:
48
P(i, j) = 1, se i ∈ {-1,0} e j > 0
= 0, se i > 0 e j ∈ {-1,0}
= 1/2, se i = 0, j = 0
= (P(i-1, j-1) + P(i-2, j) + P(i, j-2))/3, se i ≥ 1 e j ≥ 1
(a)
(b)
(c)
(d)
Se os valores P(i,j) forem calculados em ordem não decrescente da soma
(i+j), então pode ser feita uma implementação não recursiva do cálculo,
segundo o esquema delineado abaixo:
i+j = 2
i
i+j = 3
j
Usando essa idéia, chega-se ao seguinte algoritmo, cuja complexidade é
2
O(n ):
Chance_em_Match(k)
(A3.1)
Início:
P(0,0) ← 1/2;
Para r de 1 até k:
P[-1,r] ← 1; P[0,r] ← 1;
P[r, -1] ← 0;
P[r,0] ← 0;
Para s de 1 até (r-1):
P[s, r-s] ← (P[s, r-s-2] + P[s-1,r-s-1] + P[s-2, r-s])/3;
Fp;
Fp;
Fim;
Externamente deve ser feita a chamada Chance_em_Match(i+j), para os
valores i e j desejados.
Note-se que o algoritmo preenche um triângulo de valores, podendo
gerar mais valores que os necessários para determinada situação. Entretanto,
basta fazer uma pequena alteração no algoritmo para que seja calculado apenas
o "retângulo" desejado. Essa alteração fica como exercício.
Esse algoritmo ilustra bem todos os pontos ditos acima sobre a técnica
de programação dinâmica, pois a formulação recursiva permitiu chegar a um
algoritmo não recursivo eficiente.
49
Podemos, também, retomar o problema Moedas, do Capítulo 1, que
consiste em se determinar o número de possibilidades de dar troco em moedas
para um valor n, expresso em centavos. Para esse problema, formulamos a
seguinte recursão:
T(m, n) = número de possibilidades distintas de dar um troco de n
centavos, usando os m tipos iniciais de moedas (no exemplo, foram usadas
moedas de 1, 5, 10, 25, 50 e 100 centavos). E:
T(m, n) = 0, se n < 0;
T(m, n) = 1, se n = 0;
T(m, n) = Σ T(i, m - MO[i]), se n > 0, 1 ≤ i ≤ m
Se observarmos a ordem do cálculo dos valores T(m, n) na recursão,
podemos verificar que uma matriz T, m x n, pode ser preenchida por linha, de
forma crescente tal que sempre que se necessite calcular T(m, n) todos os
valores necessários já estejam calculados e tabelados. Isto, então sugere um
algoritmo simples, de complexidade O(m2.n), que preenche a matriz desejada.
O algoritmo é o seguinte:
Moedas(m, n);
(A 3.2)
Início:
Para i de 1 até m: T[i, 0] ← 1; Fp;
Para i de 1 até m:
Para j de 1 até n:
tot ← 0;
Para k de 1 até i:
Se ((j - MO[k]) ≥ 0) Então tot ← tot + T[k, j - MO[k]];
Fp;
T[i, j] ← tot;
Fp;
Fp;
Fim;
50
Esse algoritmo preenche a tabela abaixo, por coluna, da esquerda para a
direita:
m\n
1
2
3
4
5
6
0
1
2
3
4
5
6
7
8
9
10 11 12 13 14 15 16 17 18 19 20
1
1
1
1
1
1
1
1
1
1
1
1
1
1
1
1
1
1
1
1
1
1
1
1
1
1
2
2
2
2
2
3
3
3
3
3
4
4
4
4
4
5
1
1
1
1
1
2
2
2
2
2
4
4
4
4
4
6
6
6
6
6
9
1
1
1
1
1
2
2
2
2
2
4
4
4
4
4
6
6
6
6
6
9
1
1
1
1
1
2
2
2
2
2
4
4
4
4
4
6
6
6
6
6
9
1
1
1
1
1
2
2
2
2
2
4
4
4
4
4
6
6
6
6
6
9
3.2 Problemas clássicos
Neste tópico são apresentados os seguintes algoritmos clássicos com
solução por Programação Dinâmica:
a) Mochila (0/1)
b) Produto de Matrizes
c) Triangularização de Polígonos
d)Árvore de Busca Ótima
e) Distância de Edição
3.2.1 Mochila (Versão 0/1 - básica)
Este problema se apresenta em várias versões, a partir de uma idéia
básica (não muito elogiável): um ladrão tem à sua disposição um conjuntos de t
ítens que quer roubar. Sua mochila pode carregar um peso M e cada ítem i tem
peso pi. A questão é saber se existe uma combinação de ítens que encham
exatamente o peso da mochila e, neste caso, quais são esses ítens. Seja:
K(q, n) = (x, y) = Solução quando se consideram apenas os q ítens iniciais
e um volume n;
x = V ou F, indicando se existe a combinação desejada
y =V ou F, indicando se o elemento m pertence ou não solução.
Este problema pode ser formulado recursivamente da seguinte forma:
K(q,
K(q,
K(q,
K(q,
n)
n)
n)
n)
= (V, F), se n = 0
= (V, F), se K(q -1, n) = (V, ?),
= (V, V), se K(q -1, n - pq) = (V, ?) ,
= (F, F), nos demais casos
0 ≤ n ≤ M, 1 ≤ q ≤ t;
0 ≤ n ≤ M, 1 ≤ q ≤ t;
Um possível algoritmo para implementar essa solução seria:
51
(A3.3)
Mochila(q, n):
Início:
Se (n = 0) Então
Retornar (V, F)
Senão Se (q = 0) Então
Retornar (F, F)
Senão:
(x, y) ← Mochila (q - 1, n);
Se (x) Então
Retornar (V, F)
Senão:
(x, y) ← Mochila (q -1, n - pq );
Se (x) Então
Retornar (V, V)
Senão
Retornar (F, F);
Fim;
Externamente:
Mochila(t, M);
Evidentemente, tal recursão leva a um algoritmo ineficiente. Mas aquí,
também, uma solução pode ser obtida por Programação Dinâmica, através da
composição bottom-up dos subproblemas, conforme o exemplo a seguir:
Seja M = 20 e os seguintes ítens:
B
A
vol= 7
C
D
vol=3
vol=5
E
vol=9
vol=15
Podemos construir uma solução considerando, sucessivamente, as
combinações possíveis, quando se adiciona, passo a passo, um dos ítens como um
possível ítem a ser usado:
52
Ítem/
vol
0
A (7)
0
B (3)
FF FF
FF V
V
FF VF FF V
V
V
VF FF VF
V
VF FF FF VF FF VF FF VF VF VF VF FF VF
C (5)
D (9)
E (15)
1
2
3
4
5
6
7
8
VF FF FF FF FF FF FF FF FF
VF FF FF FF FF FF FF V
FF
V
VF FF FF V FF FF FF VF FF
V
VF FF FF VF FF V FF VF V
V
V
VF FF FF VF FF VF FF VF VF
9
10
11
12
13
14
15
16
17
18
19
20
FF FF FF FF FF FF FF FF FF FF FF FF
FF FF FF FF FF FF FF FF FF FF FF FF
FF FF FF FF FF FF FF FF
FF FF V
V
FF V VF
V
FF VF VF
FF FF FF FF FF
V
V
FF V
FF
V
V
V
VF VF V
VF V
V
V
Nesta tabela, foram colocadas, artificialmente, a linha para o ítem
0 e a coluna para o volume 0. Isto facilita a escrita do algoritmo de
Programação Dinâmica a seguir. Note que a célula K[E, 20] = (V, V),
indica que o problema tem solucão e que o ítem E faz parte da mesma. O
peso dos itens é guardado no vetor P.
Algoritmo:
Mochila:
(A3.4)
Início:
K[0, 0].x ← V; K[0, 0].y ← F
Para j de 1 a M:
K[0, j].x ← F; K[0, j].y ← F;
Fp;
Para i de 1 a t:
Para j de 0 a M:
Se (K[i - 1, j].x) Então
K[i, j].x ← V; K[i, j].y ← F;
Senão
Se (j ≥ V[i]) e (K[i - 1, j - P[i]].x) Então
K[i, j].x ← V; K[i, j].y ← V;
Senão
K[i, j].x ← F; K[i, j].y ← F;
Fp;
Fp;
Fim;
53
Pode-se verificar que o preenchimento dessa tabelas obedece
exatamente à idéia da recursão mostrada anteriormente. E esta nova
solução para o problema tem complexidade O(t.M).
Uma variante imediata do problema é encontrar a solução mais
próxima do objetivo procurado, que também é resolvida com a mesma
tabela e verificando, na última linha, o valor mais próximo do procurado.
Para apresentar os itens que fazem parte da solução, pode-se usar o
seguinte algoritmo:
Solução:
Início:
j ← M;
i ← t;
Enquanto (j ≠ 0):
Enquanto (K[i, j].y ≠ ‘V’):
i ← i - 1;
Fe;
Escrever V[i];
j ← j – V[i];
Fe;
Fim;
i ← i - 1;
A apresentação de todas as soluções possíveis requer um algoritmo de
Backtracking, cuja elaboração fica como exercício.
3.2.1.1 Mochila (Matriz com apenas um valor)
Uma primeira mudança que pode ser feito no algoritmo anterior é
guardar apenas um valor em cada célula da matriz. Esse valor pode ser o índice
do item. No exemplo mostrado, a matriz poderia ser a seguinte, onde as células
não preenchidas podem ter o valor -1:
Ítem/
vol
0
A (7)
B (3)
C (5)
D (9)
E (15)
0
0
0
0
0
0
0
1
2
3
2
2
2
2
4
5
3
3
3
6
7
8
1
1
1
1
1
3
3
3
3.2.1.2 Mochila em vetor
54
9
10
4
4
2
2
2
2
11
12
3
3
3
13
14
15
16
17
18
19
20
4
4
3
3
3
4
4
4
4
5
4
4
5
Na verdade basta usar um vetor, ao invés de uma matriz, para a busca
da solução para a Mochila. Esse vetor equivale à última linha da matriz. Seu
preenchimento tem uma sutileza, que é a de percorrer o vetor da direita para
a esquerda. Inicialmente o vetor é preenchido com -1. O algoritmo é o seguinte:
MochilaVetor;
Início
K[0] ← 0;
Para j de 1 a M: K[j] ← -1; Fp;
Para i de 1 a t:
Para j de M-P[i] descendo a 0:
Se (K[j+P[i]] = -1) e (K[j] ≥ 0) Então K[j+P[i]] ← i;
Fp;
Fp;
Fim;
Outra alternativa, ainda, é, na versão que usa matriz, fazer com que
cada linha da matriz indique a quantidade de itens na solução. Assim, a primeira
linha apresentaria as soluções com 1 elemento; a segunda, com dois elementos e
assim sucessivamente.
3.2.1.3 Mochila com peso e valor
Outra versão ainda do problema Mochila consiste em atribuir a cada
item não apenas um peso, mas também um valor. Neste caso, o objetivo passa a
ser o preenchimento da mochila para obter o valor máximo. O algoritmo pode
ser o seguinte, onde, em cada posição do vetor guardamos dois dados: o índice
do item (me) e o valor máximo obtido (vm). Usa-se a mesma inicialização do
algoritmo anterior e temos a mesma sutileza a considerar.
MochilaPesoValor;
Início:
K[0].me ← 0; K[0].vm ← 0;
Para j de 1 a M: K[j].me ← -1; K[j].vm ← 0; Fp;
Para i de 1 a t:
Para j de M-P[i] descendo a 0:
Se (K[j].me ≥ 0) e (K[j+P[i]].vm < (K[j].vm+V[i]) Então
K[j+P[i]].me ← i; K[j+P[i]].vm ← K[j].vm+V[i];
Fp;
Fp;
55
Fim;
Voltando ao exemplo inicial, consideremos a seguinte conjunto de pares
de valores, onde o primeiro valor do par é o peso do item e o segundo, seu
valor: S = {(7, 10) , (3, 6), (5, 11), (9, 12), (15, 15)}. Obteríamos o seguinte
vetor:
0
0
0
1
2
3
-1 -1 2
0
0
6
4
5
6
7
8
9
10 11 12 13 14 15 16 17 18 19 20
-1 3
-1 1
3
4
2
-1 3
-1 4
3
4
4
5
4
5
0
11 0
10 17 12 16 0
21 0
23 27 22 29 21 28 26
3.2.1.4 Mochila (Versão múltiplos ítens)
A última variante para o problema de itens com peso e valor é imaginar
que o número de ítens de cada tipo é ilimitado. Neste caso, a variante torna-se
um problema de maximização: maximizar o valor total de ítens de t tipos
colocados numa mochila, cada tipo com peso pi e valor vi, associados, de forma
que não seja ultrapassado o peso máximo M. A quantidade disponível de ítens
de cada tipo é suposta ilimitada.
Uma solução recursiva para o problema seria:
Mochila(k):
Início:
Se (k = 0) Então Retornar 0
Senão
MaxVal ← 0; Maxi ← 0
Para i de 1 a n:
MaxAux ← vi + Mochila(k - pi)
Se (MaxVal < MaxAux) Então
MaxVal ← MaxAux;
Maxi ← i;
Marca Maxi;
Mochila(k - pMaxi);
Fim;
Externamente:
Mochila(M);
(A3.5)
Esta solução tem complexidade O(2n). Entretanto, quando todas as
variáveis envolvidas são inteiros positivos, há uma solução eficiente por
programação dinâmica, que consiste em se determinar qual o ítem final que
56
otimiza o problema, para cada peso ≤ M, iniciando com um tipo de ítem e
considerando, gradativamente, cada novo tipo. No exemplo abaixo,
consideramos 3 tipos:
A
peso = 5
B
valor = 6
peso = 8
C
valor = 7
peso = 11
valor = 13
Considerando apenas o tipo A, a melhor solução para todo peso acima de
5 usar somente esse tipo:
Peso
5
6
7
8
9
10 11 12 13 14 15 16 17 18 19 2
0
21 2
2
2
3
2
4
TMel A A A A A A A A A A A A A A A A A A A A
VMax 6 6 6 6 6 12 12 12 12 12 18 18 18 18 18 2 2 2 2 2
4
4
4
4
4
Considerando os tipos A e B, a melhor solução para os diversos pesos é:
Peso 5 6 7 8
TMel A A A B
VMax 6 6 6 7
9
10 11
12 13 14 15 16 17 18 19 20 21 22 23 24
B
A A A A A A A A A A A A A A A
7
12 12 12 13 13 18 18 18 19 19 24 24 24 25 25
Considerando finalmente A, B e C, a melhor solução muda para:
Peso 5 6 7 8
TMel A A A B
VMax 6 6 6 7
9
10 11
12 13 14 15 16 17 18 19 20 21 22 23 24
B
A C
C
7
12 13 13 13 13 18 19 19 19 20 24 25 26 26 26
A A A A A A B
A A C
C
A
Para o preenchimento da tabela, considerando os tipos de ítens 1 a k, o
que se faz é, para cada peso, verificar qual dos k ítens gera o máximo VMax,
através da computação (vi + Vmax[Peso- pi], esta última parcela obtida
anteriormente, para (k -1) ítens.
Usam-se, portanto, 2 vetores: TMel que indica o último melhor tipo para
um peso e VMax, que indica o valor máximo total, considerando a escolha de
TMel.
Pode-se verificar que o preenchimento dessas tabelas obedece
exatamente à idéia da recursão mostrada anteriormente. E esta nova solução
para o problema tem complexidade O(nM).
57
O seguinte algoritmo implementa esse processo :
Mochila(t, M);
(A3.6)
Início:
Para j de 0 até M:
VMax[j] ← 0;
Para i de 1 até n:
Se ((j - pi) ≥ 0) e (VMax[j] < (vi +Vmax[j- pi] ) Então
TMel[j] ← i;
VMax[j] ← vi +Vmax[j- pi];
Fp;
Fp;
Fim;
58
3.2.3 Produto de Matrizes
Este é um problema que surge em processamentos matriciais pesados,
tais como na área de avaliação de reservas petrolíferas, onde são necessários
até mesmo processadores matriciais, tal a intensidade de cálculos. O problema
está ligado à minimização do número de operações a serem realizadas, quando
se fazem multiplicações sequenciais de várias matrizes. Suponhamos, por
exemplo, que tenhamos que multiplicar as seguintes matrizes:
M1 (5 x 20), M2 (20 x 50), M3 (50 x 5), M4 (5 x 100), na ordem dada.
Como a operação é associativa, podemos efetuar o cálculo de várias
formas, dentre as quais:
a) (M1 x M2) x (M3 x M4 ), com um total de operações de:
5 x 20 x 50 (M1 x M2)
+ 50 x 5 x 100 (M3 x M4)
+ 5 x 50 x 100 (produto dos produtos)
= 55.000
b) (M1 x ((M2 x M3) x M4)), com um total de operações de:
= 20 x 50 x 5 (M2 x M3)
+ 20 x 5 x 100 (produto anterior x M4)
+ 5 x 20 x 100 (M1 x produto anteior)
= 25.000
Esses números ilustram que pode haver uma variação significativa no
número de operações, dependendo da ordem da multiplicação usada. O
problema é encontrar a ordem de multiplicação que leve ao número mínimo de
operações.
Há, evidentemente, a solução ineficiente de backtracking que tenta
todas as ordens possíveis e escolhe aquela com menos operações.
Para chegarmos à formulação mais eficiente para a multiplicação das
matrizes M1, M2, .... Mn, onde cada matriz Mi tem ri-1 linhas e ri colunas,
devemos perceber que as dimensões das matrizes podem ser representadas
pela sequência: r0 r1 ...ri ...rn, onde o número de colunas de uma matriz é o
número de linhas da seguinte.
Dada uma sequência de matrizes, Mi....Mj, se conhecermos a maneira
otimizada (quantidade de operações minimizada) de nultiplicar cada partição
dessa sequência, podemos descobri a maneira otimizada para toda a sequência,
co mostrado a seguir.
59
Seja mi,j o total de operações mínimo para o produto Mi....Mj.
Considerando k o índice que identifica a última operação que minimizou mi,j,
devemos ter:
mi,j = mi,k + mk+1.j + ri-1 x rk x rj
mi,i = 0
E, portanto, mi,j deve ser tal que
mi,j = Min (mi,k + mk+1.j + ri-1 x rk x rj ), para i ≤ k < j
mi,i = 0, para i = j.
Para se conseguir uma solução eficiente para o problema, basta que os
subproblemas sejam calculados e tabelados em ordem não decrescente de
tamanhos de subsequências.
O seguinte algoritmo faz isso. Ele utiliza duas matrizes m e mk. Na
matriz m guarda-se o valor minimizado das operações de i a j e na matriz mk
guarda-se o valor de k relativo à última operação.
Produto_Matrizes();
(A3.7)
Início:
Para k de 1 a n: m[k,k] ← 0; Fp;
Para d de 1 a (n - 1):
Para i de 1 a (n - d):
j ← i + d;
m[i, j] ← ∞;
Para k de i até (j - 1):
Se ((m[i, k]+m[k+1, j] + r[i -1].r[k].r[j] ) < m[i, j]) Então
m[i, j] ← m[i, k] + m[k+1, j] + r[i-1].r[k].r[j];
mk[i, j] ← k;
Fp;
Fp;
Fp;
Fim;
Para o exemplo anterior, as matrizes obtidas são as seguintes:
60
1
2
3
4
1
0
0
0
0
m[i, j]
2
3
5.000 5.500
0
5.000
0
0
0
0
4
8.000
15.000
25.000
0
1
0
0
0
0
mk[i, j]
2
3
1
1
0
2
0
0
0
0
4
3
3
3
0
A solução ótima, cujo valor é 8.000, corresponde à seguinte ordem de
multiplicação:
((M1) x(M2 x M3))x (M4)
A determinação da sequência de multiplicação é obtida através de uma
árvore que pode ser gerada, de forma recursiva, examinando-se a tabela mk. A
recursão é dada por:
Raiz(i, j);
(A3.6)
Início:
Se i > j, Então
raiz da árvore ← mk[i, j];
raiz da subárvore esquerda ← Raiz(i, mk[i, j]);
raiz da subárvore direita ← Raiz (mk[i, j] +1, j)
Fim
O algoritmo Produto_Matrizes tem complexidade O(n3), enquanto o
algoritmo Raizes tem complexidade O(n.logn).
3.2.4 Triangularização de Polígonos
Um problema que tem bastante analogia com o anterior é o de
triangularização de polígonos convexos. Embora haja elementos geométricos
envolvidos, que serão fundamentalmente tratados no Capítulo 6, é suficiente
utilizar, por enquanto, algumas noções intuitivas como a de um polígono ser
representado no plano pelas coordenadas (xi, yi) de seus vértices.
Normalmente o polígono de n lados é representado pela sequência de vértices
<v0, v1, v2, ... vn>, onde vn = v0.
O problema é determinar uma divisão completa do polígono em triângulos
sem superposição, cujos vértices sejam vértices do polígono, de forma que se
maximize ou minimize alguma propriedade p dos triângulos (o perímetro, por
exemplo). Este tipo de operação tem inúmeras aplicações em computação
gráfica, no tratamento de imagens.
61
Veja-se no exemplo abaixo, duas possíveis triangularizações:
v1
v2
v0
v3
v5
v4
Há uma notável semelhança entre a triangularização de um polígono de n
lados e a multiplicação de (n - 1) matrizes, ou de uma maneira mais geral, a
colocação de parêntesis em uma expressão com (n - 1) operandos.
A solução recursiva que resolve o problema fixa um lado (vn v0, por
exemplo) e varia o vértice a formar um triângulo com esse lado. Cada vértice
escolhido divide o polígono em dois outros, caracterizando, assim, dois
subproblemas menores. Quando se forma um triângulo com o vértice vi, os dois
polígonos formados são dados por <v0, v1, ... vi, v0> e <vi, vi+1, ...vn, vi>. A solução é
obtida encontrando-se o melhor resultado da soma dos subproblemas com cada
triângulo. Essa solução, evidentemente, é ineficiente.
A solução por programação dinâmica é obtida de forma análoga à que foi
obtida para o produto de matrizes. A recorrência básica é dada por:
Considerando minimização, seja ti,j o total mínimo para a propriedade
desejada no polígono <vi, vi+1, ... vj, vi>. Considerando k o índice que identifica a
última triangularização que minimizou ti,j, devemos ter:
ti,j = ti,k + tk+1.j + p(i, k, j)
ti,i = 0
E, portanto, mi,j deve ser tal que
ti,j = Min (ti,k + tk+1.j + p(i, k, j)), para i ≤ k < j
ti,i = 0
O algoritmo é totalmente análogo ao A3.4, de 3.2.2.
3.2.5 Árvores de Busca Ótimas
Quando se quer fazer buscas na memória, em um conjunto fixo de n
chaves, tal que as probabilidades de acesso às chave sejam iguais, pode-se
62
utilizar pesquisa binária ou uma árvore de busca cheia, sendo o tempo médio de
busca ≅ log2n. Entretanto, quando as chaves têm probabilidades de acesso
distintas, pode-se construir uma árvore de busca ótima, cujo tempo médio de
acesso varia entre 1 e log2n, dependendo dessas probabilidades.
A situação mencionada ocorre em inúmeras aplicações de processamento
de textos. Uma delas, por exemplo, é a indexação de "sites" na Internet,
baseada em palavras significativas. O trabalho a ser feito é o de obter a
descrição do site e identificar palavras interessantes para indexação. As
palavras são consideradas tão mais interessantes, quanto menor for sua
frequência de acesso em textos da língua. Este trabalho fica mais eficiente se
for mantida uma lista de palavras comuns, que devem ser descartadas na
indexação. Normalmente constrói-se uma árvore binária de busca ótima com
essas palavras comuns.
O exemplo a seguir é para as 5 chaves com respectivas frequências:
Prob.
(pi)
.2
.4
.1
.1
.2
Chave (ki)
DE
E
NO
POR
QUE
acesso
Uma árvore de busca cheia, que também corresponde à pesquisa binária,
para essas chaves, é a seguinte:
NO
DE
POR
E
QUE
Nessa árvore, o número médio de comparações é:
NC = (.2 * 2) + (.4 * 3) + (.1 * 1) + (.1 * 2) + (.2 * 3) = 2.7
Essa não é a melhor possibilidade, pois a árvore abaixo é melhor:
POR
E
QUE
63
DE
NO
NC = (.2 * 3) + (.4 * 2) + (.1 * 3) + (.1 * 1) + (.2 * 2) = 2.2
A solução de backtracking para a construção da melhor árvore toma as
chaves k1, k2,... kn ordenadas e verifica, recursivamente, qual árvore tem
menor NC, supondo, sucessivamente, que a raiz é cada uma das chaves ki da
ordenação, tendo à direita e à esquerda, subárvores de busca ótimas para as
chaves k1, k2,... ki-1 e ki+1, ki+2,... kn, respectivamente.
Ou seja, os subproblemas para determinar a subárvore ótima para a
sequência de chaves ki, ki+1,... kj baseiam-se na seguinte recorrência:
NC[i,j] = Min ((NC[i, k-1] + ∑ (pq ), q de i a k-1) + (NC[k+1, j] + ∑(pq ), q de
k+1 a j) + pk ), para k de i a j
NC[i,i] = pi
Ou,
NC[i,j] = Min (NC[i, k-1] + NC[k+1,j] ) + ∑ (pq ), q de i a j, para k de i a j
NC[i,i] = pi
O desenho a seguir ilustra a recorrência:
kk
Subárvore p/
T1
chaves ki,... kk-1
T2
Subárvore p/
chaves kk+1,... kj
Na nova árvore, as chaves de T1 ganharam um nível a mais. Daí a parcela
∑ (pq ), q de i a k-1. Fato análogo ocorre com T2. Como a chave kk está no nível
1, temos a parcela pk.
O algoritmo de backtracking é, evidentemente exponencial e, portanto,
ineficiente. A recorrência acima justifica um algoritmo não recursivo, desde
que NC[i,j] seja calculado e guardado em ordem não decrescente da diferença
de tamanhos das subárvores.
O algoritmo tem estrutura bastante parecida com o de 3.2.2. São usadas
duas matrizes: NC, que guarda os valores mínimos da busca média para as
64
diversas subárvores e Raiz, que guarda os diversos k indicadores da
minimização de cada subárvore, que são as raizes da subárvore ótima de cada
uma das sequências de chaves.
Árvore_Ótima;
(A3.7)
Início:
Para k de 1 a n:
NC[k,k] ← p[k];
NC[k,k-1] ← 0; Raiz[k,k] ← k;
Fp;
Para d de 1 a (n –1):
Para i de 1 a (n- d):
j ← i + d;
NC[i,j] ← Infinito;
s ← 0;
Para k de i até j: s ← s + p[k]; Fp;
Para k de i até j:
Se (NC[i,k-1] + NC[k+1,j] + s ) < NC[i,j] Então
NC[i,j] ← NC[i,k-1] + NC[k+1,j] + s;
Raiz[i,j] ← k;
Fp;
Fp;
Fp;
Fim;
No caso do exemplo anterior,
NC[i,j]
1
2
3
4
1 .2
.8
1
1.3
2 0
.4
.6
.9
3 0
0
.1
.3
4 0
0
0
.1
5 0
0
0
0
as matrizes obtidas são as seguintes:
Raiz[i,j]
5
1
2
3
4
5
1.9
1
2
2
2
2
1.5
0
2
2
2
2
.7
0
0
3
3
4
.4
0
0
0
4
5
.2
0
0
0
0
5
65
A árvore ótima obtida é:
E
DE
POR
NO
QUE
NC = (.2 * 2) + (.4 * 1) + (.1 * 3) + (.1 * 2) + (.2 * 3) = 1.9
O algoritmo Árvore-Ótima tem complexidade O(n3). A construção da
árvore ótima é feita com um algoritmo igual ao do algoritmo Raizes de 3.2.2 e
tem complexidade O(n.logn).
3.2.6. Distância de Edição
Um problema com muitas aplicações práticas na edição de textos,
incluindo programas, é o de se verificar as diferenças entre duas versões de
um texto. O mesmo problema, no contexto da Biologia, aparece na
identificação de modificações em cadeias de DNA.
As modificaçõe que podem ocorrer entre duas versões de um texto, isto é, de
uma cadeia de caracteres para outra, são de três tipos: inserção de um novo
caracter; exclusão de um caracter antigo ou troca de um caracter por outro.
O problema é então o de, dadas duas cadeias A e B, determinar qual o número
mínimo de modificações que transformou A em B. Esse número é chamado a
distância de edição entre as cadeias.
A solução do problema, dada em termos de Programação Dinâmica, é formulada
considerando o seguinte princípio:
Sejam dois Strings A e B, Ai o substring formado pelos primeiros i
caracteres de A e Bj aquele formado pelos j primeiros caracteres de B. Seja
ainda D[i, j], o número mínimo procurado para transformar Ai em Bj.
Considerando os problemas menores resolvidos, a transformação mínima de Ai
em Bj, pode ter se dado, nas etapas finais, de uma das seguintes formas:
a) Partir de Ai-1 e Bj e abandonar o caracter i de A;
(deleção
em A)
b) Partir de Ai e Bj-1 e inserir um caracter em Bj-1;
(inserção em B)
c) Partir de Ai-1 e Bj-1 e copiar o caracter i de A;
(manutenção)
d) Partir de Ai-1 e Bj-1 , copiar o caracter i de A e modificá-lo;
(alteração)
66
Essas considerações permitem formular o problema recursivamente, de
forma que possa ser tratado como um problema de Programação Dinâmica, da
maneira a seguir, onde D é a matriz de distâncias de edição mínimas:
D(i,0) = i 0 ≤ i ≤ n;
D(0,j) = j 0 ≤ j ≤ m;
D(i, j) = Min { D(i-1, j) + 1, D(i, j-1) + 1,
D(i-1, j-1) + ind(i, j) } ,
onde
ind(i,j)
= 0, se o caracter Ai = Bj, 1, caso contrário
Como exemplo, vejamos o cálculo da distância da edição mínima de ATAAGC em
AAAAACG.
A
T
A
A
G
C
0
1
2
3
4
5
6
0
0
1
2
3
4
5
6
A
1
1
0
1
2
3
4
5
A
2
2
1
1
1
2
3
4
A
3
3
2
2
1
1
2
3
A
4
4
3
3
2
1
2
4
A
5
5
4
4
3
2
2
3
C
6
6
5
5
4
3
3
2
G
7
7
6
6
5
4
3
3
No exemplo a distância de edição é 3. A sequência de transformações pode ser
mostrada pela tabela a seguir:
Passo A
B
Operação
1
A
A
Manteve o A
2
AT
AA
Trocou T por A
3
ATA
AAA
Manteve o A
4
ATAA
AAAA
Manteve o A
5
ATAAG
AAAAA
Trocou G por A
6
ATAAGC
AAAAAC
Manteve o C
7
ATAAGC
AAAAACG Inseriu o G
A formulação anterior leva ao seguinte algoritmo:
DistanciaEdicao(A, n, B, m);
( A 8.5)
Entrada: A e B = strings de tamanho n e m, respectivamente;
Saída:
D, a matriz de distâncias de edição mínimas.
Inicio
67
Para i de 0 a n:
D[i, 0] ← i; Fp;
Para j de 1 a m:
D[0, j] ← j; Fp;
Para i de 1 a n:
Para j de 1 a m:
x ← D[i -1, j] + 1;
y ← D[i, j -1] + 1;
Se (Ai = Bj) Então
z ← D[i -1, j -1]
Senão
z ← D[I -1, j -1] + 1;
D[i,j] ← Min{ x, y, z } ;
Fp;
Fp;
Fim;
68
3.4 Exercícios Propostos
Escrever algoritmos, com solução por Programação Dinâmica, para os
problemas descritos a seguir.
3.1 - (Cabo de guerra)
Dados os pesos de n pessoas, quer-se particionar essas pessoas em 2
grupos, para realizar um "cabo de guerra". A divisão deve ser tal que o número
de participantes de cada grupo difere no máximo em 1 e a diferença de pesos
é a mínima possível. Escrever um
algoritmo que determina a menor
diferença de peso possível, dados n e os pesos.
3.2 – (Partição aproximada)
Dado um conjunto de números inteiros, determinar um particionamento
desse conjunto tal que se tenha a diferença mínima entre a soma das duas
partições.
3.3 - (Comboio)
Dada uma ponte com um limite de carga lc , quer-se organizar a travessia
de um comboio com n carros cada um de peso pi e com um tempo ti de
travessia, mantendo a ordem dos carros no comboio, em grupos que respeitem
a carga máxima da ponte, de forma a minimizar o tempo total de travessia.
(Maratona ACM - 1999 - Índia)
3.4 – (Jogo de Euler)
O jogo de Euler se desenvolve em uma matriz 4x4 contendo 16 palitos, e
é entre dois competidores que, alternadamente, retiram 1 a 3 palitos em
posições consecutivas na matriz. Aquele que retirar o último palito perde.
Determinar se dada configuração intermediária é uma posição vencedora ou
perdedora.
3.5 – (Cortes)
Dado um tronco de madeira de tamanho n, que deve ser cortado em k
tamanhos, t1, t2, t3...tk, em uma máquina onde o custo de cada corte é
proporcional ao tamanho da peça cortada, determinar o custo mínimo do
processo de corte para se obter os pedaços desejados.
3.6 - (Chance_Em_Matches II)
Criar tabelas para determinar chances em "matches", onde cada vitória
vale 1 ponto e os empates são descartados.
69
3.7 - (Formatação de textos)
Quer-se formatar um texto contendo n palavras para impressão, com
comprimentos c1,... cn. Cada linha tem capacidade máxima M. Entre duas
palavras é colocado um espaço e cada palavra fica inteiramente numa linha.
Distribuir as palavras no menor número de linhas, de forma a minimizar a soma
dos cubos dos tamanhos dos espaços em branco no final das linhas (exceto a
última).
3.8 - (Maior subsequência comum)
Dados dois strings s1 e s2, determinar o maior substring st comum a
ambos.
3.9 - (Troco) - (A3.11)
Dadas as moedas de centavo de um país de valor c1, c2,... cn, qual o
número mínimo de moedas para dar um troco t?
3.10 - (Mochila recursiva)
Escrever um algoritmo recursivo equivalente a A3.3.
3.11 - (Subsequência monotônica crescente)
Dados n inteiros distintos, determinar a maior subsequência monotônica
crescente.
3.12 - (Partição)
Dados n inteiros, particionar o conjunto em dois outros cuja soma dos
elementos seja igual.
3.13 - (Distância de Edição)
Dado um texto original T1 e um novo texto T2, modificado a partir de T1,
determinar a Distância de Edição entre T1 e T2 (Distância de edição é o menor
número de modificações possível que transforma T1 em T2, onde a contagem de
modificações é feita caracter a caracter).
3.14 Preencher a Tabela do problema Moedas, com m = 4 (moedas de 1, 2, 5 e
10) e n = 15.
3.15 Preencher a Tabela do problema Mochila, com t = 5 (4, 5, 6, 7 e 8) e M =
12.
3.16 - (Triângulo de Pascal)
70
Construir o triângulo de Pascal para um inteiro n.
3.17 – (Barcaça)
Tem-se uma barcaça de comprimento cb para transportar veículos entre as
margens de um rio e uma fila com n veículos. Cada veículo i tem o comprimento
ci. Os carros são arrumados na barcaça, por ordem de entrada na fila e há dois
compartimentos na barcaça, onde os carros ficam enfilados em um desses
compartimentos. Explicar a idéia de um algoritmo para determinar o número
máximo de veículos que podem ser transportados.
Ex: cb = 50 Fila: 10, 20, 30, 35, 10.
Neste caso pode-se transportar os 4
primeiros veículos.
cb = 50 Fila: 30, 30, 35, 10, 20. Neste caso pode-se transportar os 2
primeiros veículos.
71
4. MÉTODO GULOSO
4.1 Conceitos básicos
Em algumas situações um enfoque ingênuo é suficiente para resolver
certos problemas. Por exemplo, tomemos o problema Troco Mínimo, proposto
como exercício no capítulo anterior.
Suponha que as moedas de centavos de determinado país tenham valores
20, 10, 5, 1 e se queira dar um troco de 37 centavos, usando-se o número
mínimo de moedas. Uma solução é usar uma moeda de 20, uma de 10, uma de
cinco e duas de 1. Essa solução foi obtida tentando-se usar prioritariamente as
moedas de maior valor. Será que essa estratégia sempre resolve esse
problema, qualquer que sejam os valores das moedas? A resposta é que não! Ela
depende do valor das moedas!
Se, por exemplo, as moedas tiverem valores 12, 5 e 1 e o troco for de 20
centavos, a estratégia anterior escolheria uma moeda de 12, uma de cinco e
três de 1, usando 5 moedas. Entretanto o troco poderia ser feito com 4
moedas de 5.
Em problemas de maximização ou minimização, onde a solução
geralmente é encontrar um conjunto que maximize ou minimize determinada
propriedade, o enfoque ingênuo mencionado é denominado Método Guloso e
consiste em, a cada passo da solução, acrescentar à solução parcial um novo
elemento que otimize momentâneamente a propriedade desejada.
O Método Guloso tem a característica de gerar soluções muito simples,
nas situações em que pode ser utilizado. Essas situações são aquelas onde a
otimização global pode ser obtida a partir de otimizações locais.
Matematicamente é possível caracterizar essas situações, através de uma
estrutura denominada Matróide, mas que não será aquí apresentada. Muitos
problemas importantes em Grafos são resolvidos com esse enfoque, como será
visto no Capítulo 7.
Finalmente vale ressaltar que o nome do método deriva da característica
de o método sempre tentar "pegar" o pedaço maior disponível e nunca se
arrepender da escolha feita.
4.2 Problemas clássicos
Neste tópico são apresentados os seguintes algoritmos clássicos com
solução pelo Método Guloso:
a) Códigos de Huffman
72
b) Merge ótimo
c) Seleção/Sequenciamento de Tarefas
d) Mochila fracionária
4.2.1 Códigos de Huffman
Este é um importante método de compressão de dados sem perda,
largamente utilizado, que pode conseguir compactação de dados de até mais de
90%. Basicamente, a compressão é obtida através da codificação de
caracteres com número variável de bits, de forma que os caracteres mais
usados sejam representados em poucos bits.
O problema de compressão de dados é, então, o de, dado um conjuntos
de caracteres c1, c2... cn, , cujas frequências de acesso respectivas são
f1,f2,...fn, definir uma codificação para cada caracter ci , de forma que um
conjunto de dados codificados tenha o comprimento médio mínimo em bits.
A codificação de Huffman cria uma código de comprimentos variáveis
para os diversos caracteres, a partir das frequências dos mesmos. Este código
é um código de prefixo, o que quer dizer que nenhuma codificação é prefixo
para uma outra, o que elimina ambiguidades na decodificação. É utilizada uma
árvore binária para se definir a codificação, que é também a mesma árvore
usada na decodificação de dados compactados.
Vejamos um exemplo. Se tivermos a seguinte codificação de prefixos:
Letra
Código
E
00
L
010
M
011
O
10
P
110
X
111
A
codificação da palavra EXEMPLO, usando esse código, seria:
001110001111001010
Essa codificação é, realmente, uma codificação de prefixos, o que pode
ser verificado na seguinte árvore, que incorpora essa codificação:
0
0
1
1
0
E
L
0
1
M
1
0
O
P
1
X
Nesta árvore, estritamente binária, as folhas são os caracteres e a
codificação de cada caracter é dada pelo caminho desde a raiz até essa folha,
73
sendo que cada aresta esquerda recebe o valor 0 e cada direita recebe 1.
Numa árvore ótima não há links nulos.
A construção da árvore ótima tem que ser de tal forma que se minimize
o caminho externo ponderado da árvore, que é dado por
CM(T) = ∑ fi.li , onde
fi = frequência de acesso do caracter ci
li = tamanho da codificação em bits para o caracter ci
= nível do caracter na árvore - 1
Este problema pode ser resolvido pelo Método Guloso, de forma
"bottom-up", criando a árvore partindo das folhas para a raiz.
Inicialmente todas as folhas (os caracteres) estão isolados e, a cada
passo, faz-se a união das subárvores com caminho externo ponderado mínimo.
Voltando ao exemplo anterior e considerando as seguintes frequências de
acesso:
Letra
Freq.
E
.20
L
.08
M
.12
O
.29
P
.14
X
.17
No primeiro passo, faz-se a união dos caracteres L e M:
0
1
L
Subárvo E
re
Freq.
.20
L+M
M
O
P
X
.20
.29
.14
.17
Agora as duas menores frequências são P e X:
0
P
Subárvo E
re
Freq.
.20
L+M
O
P+ X
.20
.29
.31
Agora, a união é entre E e L+M:
74
1
X
0
E
1
0
1
L
M
Obtemos:
Subárv. E+(L+M) O
Freq.
.40
.29
P+ X
.31
A seguir a união é entre O e (P+X):
0
1
0
O
1
P
Subárv.
Freq.
X
E+(L+M) O+(P+X)
.40
.60
Finalmente, obtemos a árvore mostrada inicialmente:
0
0
1
0
E
L
Árvore
Freq.
1
0
1
0
O
M
1
P
1
X
(E+(L+M))+(O+(P+X))
1
O seguinte algoritmo reflete esse processo. O algoritmo usa um Heap H
(para se ter a ordenação parcial dos nós) e uma árvore T, que guardará a
codificação, cujo nó tem a estrutura:
No = (ld, le : ↑No; tipo: (0..1); caracter: char; f: real;)
ld e le são ponteiros;
75
tipo indica o tipo de nó: 0 = nó interno; 1 = folha;
caracter contém o caracter relativo à codificação;
f = contém a frequência acumulada do nó;
O Heap conterá uma estrutura semelhante, a menos dos links.
Inicialmente cria-se o Heap H, a partir dos caracteres a serem
codificados. Todos os nós recebem tipo = 1. A árvore T vai sendo criada
conforme o processo mencionado, usando o Heap H para a ordenação dos nós
segundo a soma das frequências.
Huffman;
(A4.1)
Início:
CriaFolhas; CriaHeap(H);
Para i de 1 até (n - 1):
p ← H[1]; Troca (1, n-i+1); DesceHeap (1, n-i);
q ← H[1];
Alocar(z); z↑.tipo ← 0; z↑.le ← p; z↑.ld ← H[1];
z↑.f ← p↑.f + q↑.f;
H[1] ← z;
DesceHeap (1, n-i);
Fp;
Retornar H[1];
Fim;
Externamente:
T ← Huffman;
O procedimento CriaFolhas cria as folhas, contendo cada uma o
símbolo e sua frequência. O procedimento CriaHeap cria um Heap
contendo ponteiros para as folhas. O procedimento Troca (i, j) troca os
elementos i e j do Heap; o procedimento DesceHeap (i, m) executa a
descida no Heap, até o elemento de ordem m.
Será ilustrado, a seguir, o processo de decodificação de dados compactados, supondo os bits em um vetor de bits Bit de tamanho n, com resultado
no vetor de caracteres Sai. O processo usa a árvore de codificação T.
(A4.2)
Decodifica;
Início:
i ← 1; j ← 1;
Enquanto (i ≤ n):
a ← T;
76
Enquanto (a↑.tipo ≠ 1):
Se (Bit[i] = 0) Então
a ← a↑.le;
Senão
a ← a↑.ld;
i ← i+1;
Fe;
Sai[j] ← a↑.caracter; j ← j+1;
Fe;
Fim;
Como foi dito inicialmente, a correção dos algoritmos gulosos tem que
ser demonstrada. Para tanto, considera-se que uma árvore ótima é a que tem o
custo mínimo, onde o custo da árvore T, é o caminho externo ponderado, dado
por c(T) = ∑fi.li, (frequência x profundidade de cada folha) considerando
apenas as folhas das árvores. A correção do método de Huffman baseia-se no
seguinte lema:
Lema: Para um conjunto de símbolos, existe uma árvore ótima onde os 2
símbolos de menor frequência são irmãos.
A demonstração deste lema é bem simples. Basta verificar que eles têm que
estar localizados no último nível da árvore, onde existem pelo menos duas
folhas, ou a árvore não seria ótima. Então podem ser colocados como irmãos.
Teorema: O algoritmo de Huffman é correto.
Prova: Indução em n (número de símbolos). Para n = 2, o resultado é trivial.
Seja T uma árvore de Huffman e T’ uma árvore ótima, para n > 2. Em T, os 2
símbolos de menor frequência, f1 + f2 são irmãos. Caso em T’ esses símbolos
não sejam irmãos, podemos remanejar para que sejam. Consideremos,
respectivamente, as árvores T1 e T1’ para n-1 símbolos onde os dois
símbolos
de menor frequência foram fundidos.
Pela hipótese de
indução, T1 e T1’ são ótimas e c(T1) = c(T1’). Como c(T) = c(T1) + f1 + f2 e
c(T’) = c(T1’) + f1 + f2, segue-se que c(T) = c(T’)
ótima. Logo, o algoritmo é correto.
4.2.2 Merge ótimo
77
e,
portanto, T também é
Um problema muito semelhante ao anterior é o de se fazer o merge de n
listas ordenadas, de tamanhos diferentes. Quer-se determinar a ordem do
merge, visando minimizar o número de operações.
Dadas duas listas l1 e l2, de tamanhos t1 e t2, respectivamente, o número
de operações para se fazer a intercalação (merge) das mesmas é igual a t1 + t2.
Daí, que o problema tem solução análoga ao anterior. A solução é criar uma
árvore estritamente binária que conterá nas folhas uma referência a cada
lista. A união gradativa das listas é feita escolhendo-se as duas de menor
tamanho, no momento. Vejamos um exemplo:
Lista
A
B
C
D
E
Tamanho 300
500
150
400
200
Usando-se o enfoque anterior, no primeiro passo, faz-se o merge das
listas C e E:
C
E
Lista
A
B
C+E
D
Tamanho
300
500
350
400
Agora os dois menores tamanhos são A e (C+E):
A
C
Lista
Tamanho
A+(C+E)
650
E
B
500
D
400
Em seguida, toma-se os dois menores tamanhos, D e B:
D
Lista
Tamanho
A+(C+E)
650
B
B+D
900
78
Finalmente obtemos a árvore desejada:
A
D
C
Lista
Tamanho
B
E
(A+(C+E))+(D+B)
1550
O total de comparações, usando-se essa sequência de merges, será:
1o merge: F = C+E = 150 + 200 = 350
2o merge: G = A+ F = 300 + 350 = 650
3o merge: H = D+B = 900
4o merge
I = G + H = 650 + 900 = 1550
Total
= 350 + 650 + 900 + 1550 = 3350
Para se fazer o merge, a partir da árvore gerada, basta observar que o
percurso dessa árvore em pós-ordem indica a sequência de intercalações a
serem feitas. O algoritmo é mostrado a seguir, supondo que o nó da árvore tem
a seguinte estrutura:
No = (le, ld: ↑No; L: ↑Lista);
79
(A4.3)
Merge(Arv);
Início:
Se (Arv não é folha) Então:
L1 ← Merge (Arv↑.le);
L2 ← Merge (Arv↑.ld);
Retornar (Intercala(L1, L2));
Senão
Retornar Arv↑.L;
Fim;
Externamente:
Merge(I);
Neste algoritmo o procedimento Intercala(L1, L2) faz o Merge das duas
listas L1 e L2 e devolve o endereço do início da lista resultado.
4.2.3 Seleção/Sequenciamento de Tarefas
Uma classe de problemas que têm solução gulosa é a de seleção e
sequenciamento de tarefas. Nessa classe de problemas tem-se 1 ou mais
processadores de tarefas e um conjunto de tarefas com durações dadas, que
têm que ser executadas por esses processadores. Algumas vezes são também
dados parâmetros complementares das tarefas tais como datas limite de início
e de fim, receitas auferidas pela realização das mesmas ou penalidades pela
não execução. Os problemas a serem resolvidos consistem da seleção de um
conjunto de tarefas viáveis, podendo-se ter objetivos complementares tais
como a maximização do tamanho do conjunto selecionado ou da receita ou
minimização dos prejuízos. Trataremos a seguir de algumas dessas variantes.
4.2.3.1 Seleção de Tarefas com datas de início e fim fixas
A primeira e mais simples variante do problema consiste em, dadas n
tarefas com datas de início e de fim fixas, di e df, respectivamente, 1
processador, selecionar o maior conjunto viável de tarefas. Vejamos um
exemplo:
tarefa
T1
T2
T3
T4
80
T5
T6
T7
T8
di
df
1
14
10
13
2
3
2
6
9
11
12
15
5
8
7
8
A solução do problema é simples. Consiste em ordenar as tarefas, não
decrescentemente, por data de fim e, gulosamente, ir formando um conjunto
viável, selecionando as tarefas de acordo com a ordenação, tal que cada tarefa
é adicionada ao conjunto viável quando é compatível com a última tarefa
selecionada. No exemplo, a ordenação seria a seguinte:
tarefa
di
df
T3
2
3
T4
2
6
T8
7
8
T7
5
8
T5
9
11
T2
10
13
T1
1
14
T6
12
15
E as tarefas selecionadas seriam:
tarefa
di
df
T3
2
3
T8
7
8
T5
9
11
T6
12
15
Esse conjunto foi obtido da seguinte forma: T3 é selecionada por ser a
primeira da ordenação, T4 é descartada porque conflita com T3. T8 é
selecionada porque é compatível com T3. T7 é abandonada porque conflita com
T8. T5 é selecionada porque não conflita com T8; T2 e T1 são descartadas
porque conflitam com T5; T6 é selecionada porque é compatível com T5;
O algoritmo, que gera o conjunto de saída S seria o seguinte:
Seleção_Tarefa();
Início:
S ← ∅; r ← -∞;
OrdenaTarefas();
Para i de 1 a n:
Se (di[i] > r) Então
S ← S U {T[i]}; r ← df[i];
Fp;
Fim;
A complexidade do algoritmo é, obviamente, O(n.log n). Vejamos uma
prova que indica a correção do algoritmo.
Seja S a sequência gerada pelo algoritmo e So uma sequência ótima,
ambas ordenadas por df. O número de tarefas de S é menor ou igual ao de So,
81
pois este conjunto é ótimo. Vamos argumentar que esses números são iguais.
Seja j o menor índice tal que as tarefas de índice j sejam distintas nos dois
conjuntos. Temos: df[Tj] ≤ df[Toj], onde Tj é a tarefa j de S e Toj, a tarefa j
de So. Isso porque o algoritmo obriga a esse tipo de escolha. Podemos
substituir, em So, a tarefa Toj pela tarefa Tj. Então por argumento análogo,
podemos substituir todas as tarefas de So pelas de mesmo índice em S. Ao
final desse processo, todas as tarefas de S estão em So e não pode existir
mais nenhuma tarefa em So, pois isso significaria que o algoritmo deixou de
selecionar uma tarefa que seria viável, em relação ao conjunto escolhido. Logo,
o tamanho de S é o mesmo de So, ou seja, o algoritmo escolhe um número
ótimo de tarefas.
4.2.3.2 Seleção de Tarefas com receita máxima
Outra versão do problema de Seleção de Tarefas é o de determinar o
sequenciamento ótimo das tarefas T1, T2... Tn, onde todas elas são executadas
num tempo unitário e cada Ti tem associados um tempo limite li aceitável bem
como uma receita ri . Caso a tarefa não seja feita dentro do tempo limite, a
receita é 0. O sequenciamento ótimo é aquele que gera a maior receita.
Evidentemente, o problema tem uma solução de força bruta, que tem que
examinar 2n possibilidades (numero de subconjuntos), não sendo eficiente. O
Método Guloso fornece uma solução de complexidade O(n2), como será visto.
Problemas deste tipo têm grandes aplicações no planejamento de
trabalho, em qualquer contexto.
Como exemplo, suponhamos a situação abaixo, com 6 tarefas:
tarefa
l
r
T1
1
7
T2
1
8
T3
3
4
T4
4
6
T5
3
10
T6
4
5
O sequenciamento ótimo nesse caso é {T2, T5, T4, T6}, com uma receita
de 29. As tarefas T1 e T3 seriam feitas fora do prazo, com receita 0.
Na solução pelo Método Guloso, as tarefas são ordenadas inicialmente
de forma não crescente pela receita. Vai sendo construído o conjunto solução
S examinando, em sequência, cada tarefa da ordenação, verificando se ela pode
ser incluída em S. Em cada passo, toma-se uma decisão definitiva para uma das
tarefas. Ou ela é incluída em S ou definitivamente descartada. No exemplo, o
passo inicial de ordenação geraria a sequência:
82
Tarefa
l
r
T5
3
10
T2
1
8
T1
1
7
T4
4
6
T6
4
5
T3
3
4
A primeira inclusão em S seria a de T5 , seguida de T2. Quando se
examina a possibilidade de incluir T1 em S verifica-se que não é possível, pois
ela não poderá ser executada dentro do prazo. A seguir T4 e T6 são incluídas
em S. Finalmente, verifica-se que T3 também não pode ser incluída em S,
gerando o resultado apontado antes. Note-se que a ordem de seleção das
tarefas não é temporal, mas pelo valor das receitas.
Para que o algoritmo tenha a complexidade desejada, O(n2) a
implementação deve ser a seguinte: S deve ser mantido ordenado pelo tempo
limite. O teste de viabilidade de inserção de um nova tarefa Ti em S é
equivalente a se verificar se a inserção não inviabiliza a execução de qualquer
tarefa que já está em S (isso pode ser feito checando se, a partir da posição li
em S há alguma tarefa cuja posição seja igual ao próprio tempo limite).
No teste para a inserção de T3, por exemplo, S estaria assim:
S
l
T2
1
T5
2
T4
4
T6
4
Não seria possível incluir T3 em S, pois T3 teria que ser incluída na
posição 3. Entretanto temos T6 na posição 4 e seu tempo limite é igual a 4.
O algoritmo é mostrado a seguir:
Sequenciamento_Tarefa();
Início:
OrdenaTarefas(); S ← ∅;
Para i de 1 a n:
Se (ViavelIncluir(S,T[i])) Então
Inclui (S, T[i]);
Fp;
Fim;
O procedimento OrdenaTarefas ordena as tarefas em ordem não
crescente de receitas e o procedimento ViavelIncluir(S,T[i]) executa o teste
83
mostrado acima. O procedimento Inclui (S, T[i]) inclui T[i] em S mantendo S
ordenado por tempo limite.
A seguir é dada uma explicação de que o algoritmo está correto.
Seja S a sequência gerada pelo algoritmo e So uma sequência ótima,
ambas ordenadas por l. A receita total de S é menor ou igual à de So, pois este
conjunto é ótimo. Vamos argumentar que esses números são iguais. Se S = So,
nada há a provar. Além disso, não podemos ter So ⊂ S, pois So não seria ótimo,
nem S ⊂ So, pois isso significaria que o algoritmo não funcionou direito,
deixando de selecionar tarefas compatíveis com o conjunto. Então existem pelo
menos duas tarefas distintas Ta e Tb, Ta ∈ S, e Ta ∉ So e Tb ∈ So, e Tb ∉ S.
Podemos rearrumar S e So, tal que todas as tarefas comuns estejam colocadas
no mesmo índice. Para tanto, tomamos cada tarefa Tc comum nos dois
conjuntos e mudamos de posição aquela que tem menor índice. Note que essa
transposição sempre pode ser feita, porque o maior índice garante isso. Seja
Ta a tarefa de maior receita de S que não esteja em So. Então r[Ta]
≥ r[Tb], para qualquer tarefa Tb de So que não esteja em S pois, caso
contrário, o algoritmo teria escolhido erradamente. Então podemos substituir
a tarefa Tb em So, de mesmo índice que Ta, por Ta, que necessariamente tem
que ter a mesma receita. ou So não seria ótimo. Isso mostra que podemos
trocar todas as tarefas de S em So e, como S não pode ser subconjunto de So,
os dois conjuntos ficam iguais e têm mesma receita.
4.2.3.3 Sequenciamento de Tarefas com penalidades fixas
Outra versão do problema de Seleção de Tarefas é o de determinar o
sequenciamento ótimo das tarefas T1, T2... Tn, onde todas elas são executadas
num tempo unitário e cada Ti tem associados um tempo limite li aceitável bem
como uma penalidade pi caso não seja concluída dentro desse prazo limite. O
sequenciamento ótimo é aquele que gera a menor penalidade.
Como exemplo, suponhamos a situação abaixo, com 6 tarefas:
tarefa
l
p
T1
1
7
T2
1
8
T3
3
4
T4
4
6
T5
3
10
T6
4
5
O sequenciamento ótimo nesse caso é {T2, T5, T4, T6, T1, T3}, com uma
penalidade de 11, correspondendo às tarefas T1 e T3, que seriam feitas fora do
prazo.
84
A solução deste problema é exatamente a mesma solução anterior, a
menos do fato de ser necessário incluir no sequenciamento, as tarefas não
viáveis. O algoritmo é análogo e a complexidade também é O(n2).
Após a ordenação não crescente pela penalidade, teríamos:
Tarefa
l
p
T5
3
10
T2
1
8
T1
1
7
T4
4
6
T6
4
5
T3
3
4
O conjunto viável seria composto das tarefas {T2, T5, T4, T6}, que podem
ser executadas dentro do prazo e as tarefas T1 e T3 seriam realizadas
atrasadamente, gerando o sequenciamento de penalidade 11 (7 + 4):
Tarefa
l
T2
1
T5
3
T4
4
T6
4
T1
1
T3
3
4.2.3.4 Sequenciamento de Tarefas com durações e penalidades
variáveis
Uma última versão do problema de Seleção de Tarefas aquí apresentada
é uma variante do problema anterior, quando as durações são variáveis e as
penalidades são especificadas por dia de atraso, sendo que o início de todas as
tarefas deveria ser no dia 1.
Exemplificando, com 6 tarefas, onde a linha d significa duração e a linha
p penalidade diária, temos:
tarefa
d
p
T1
2
7
T2
1
8
T3
3
4
T4
4
6
T5
3
10
T6
4
5
O sequenciamento ótimo nesse caso é {T2, T1, T5, T4, T3, T6}, com uma
penalidade total de 178, (penalidades de 0, 7, 30, 36, 40, 65,
respectivamente).
A solução deste problema é ordenar as tarefas em ordem crescente pela
razão d/p e tomar como sequenciamento essa ordenação. A prova de que essa
solução está correta é bastante simples e baseia-se no seguinte fato: tomando
85
duas tarefas seguidas em um sequenciamento ótimo, digamos Ti e Ti+1, o
intercambiamento de posições dessas tarefas modificaria a penalidade total
em (di+1.pi - di.pi+1), correspondendo a se aumentar a duração da primeira tarefa
em di+1 e reduzir o da seguinte em di. Então para todas as tarefas em sequência
na solução ótima devemos ter:
di+1.pi - di.pi+1 ≥ 0, ou
di+1.pi ≥ di.pi+1 ou
finalmente, di+1/pi+1 ≥ di/pi, que é a condição apontada.
Após a ordenação não decrescente pela razão d/p, teríamos:
Tarefa
d
p
T2
1
8
T1
2
7
T5
3
10
T4
4
6
T3
3
4
T6
4
5
Isso fornece a penalidade referida acima de 178. O algoritmo consiste,
apenas em ordenar as tarefas segundo a razão d/p. Sua complexidade é
O(n.log n) e o cálculo das penalidades, trivial.
4.2.4 Mochila fracionária
Este problema é uma outra variante do problema da Mochila apresentado
em 3.2.1. Aquí consideraremos que há apenas 1 objeto de cada tipo e, além
disso, que pode-se também pegar partes fracionárias do objeto (uma barra de
chocolate, por exemplo).
O enfoque do Método Guloso resolve facilmente esse problema. Basta
ordenar os objetos em ordem não crescente pelo valor ponderado (valor/peso)
e escolher os objetos pela ordenação até se ultrapassar o limite M da mochila,
retirando a fração excedente do último objeto escolhido.
86
Mochila_Fracionaria();
A(4.5)
Início:
OrdenaObjetos();
i ← 0;
S ← ∅;
pesototal ← 0;
Enquanto (pesototal ≤ M) e (i < n):
S ← S U Objeto[i];
pesototal ← pesototal + peso[i];
i ← i+1;
Fe;
Se (pesototal > M) Então
S ← S - Objeto[i-1] + Fração(Objeto[i-1], pesototal-M,M);
Fim;
A demonstração de que este algoritmo está correto é simples: Se S' é
uma solução ótima melhor que S, então há dois objetos x ∈ S e y ∈ S', tais que
x ≠ y, com valores ponderados diferentes. Mas como x tem valor ponderado
maior que y, se substituirmos y ou parte de y por x ou parte de x, obteremos
uma solução S'' melhor que S', uma contradição.
É interessante comparar a solução deste problema com a versão 0-1 do
mesmo (versão não fracionária). Isso será feito retomando o exemplo 3.2.1:
A
peso = 5
valor = 6
B
peso = 8
valor = 7
C
peso = 11
valor = 13
Considerando M = 19, a solução do Guloso para o problema da mochila
fracionária seria:
tomar o objeto A (valor ponderado = 1,2),
mais o objeto C (valor ponderado 1,18)
e 3/8 do objeto B (valor ponderado 0,88),
gerando um valor total de 21,6.
Já para a versão 0-1, o mesmo enfoque Guloso levaria à solução:
tomar o objeto A (valor ponderado = 1,2),
mais o objeto C (valor ponderado 1,18)
perfazendo um peso total de 16 e valor total de 19,
87
o que é uma solução errada, pois a solução ótima seria tomar os objetos B e C,
com peso total de 19 e valor total de 20.
Este exemplo ilustra a sutileza da aplicação dos diversos métodos de
construção de algoritmos para problemas semelhantes. Cada caso tem que ser
considerado especialmente.
88
4.3 Exercícios Propostos
Escrever algoritmos, com solução pelo Método Guloso, para os problemas
descritos a seguir.
4.1 - (Abastecimento de combustível)
Dados n postos de gasolina, determinar o número mínimo de paradas
para abastecimento numa viagem que passa por todos os postos, conhecendo-se
as distâncias entre eles, o consumo e a capacidade do tanque do carro.
4.2 - (Cobertura de pontos)
Dado um conjunto {x1, x2.... xn} de pontos na reta real, determinar o
menor conjunto de intervalos fechados unitários que cobrem esses pontos.
4.3 - (Troco Mínimo)
Dadas k moedas de centavos de um país 1, c, c2, ...ck-1(todas são
potências de c), demonstrar que o problema Troco Mínimo funciona pelo
Método Guloso.
4.4 - (Execução de tarefas com penalidades)
Dadas n tarefas de duração unitária, que têm que ser executadas por um
único processador, seus tempos limites de execução e as multas a serem pagas
em caso de atraso na conclusão, determinar a sequência de execução das
tarefas tal que a multa total seja mínima.
4.5 - (Execução de tarefas com penalidades)
Dadas n tarefas que têm que ser executadas por um único processador,
suas durações e as multas diárias a serem pagas em caso de atraso no início da
execução, determinar a sequência de execução das tarefas cuja multa total
seja mínima.
4.6 - (Escolha de Tarefas)
Dadas n tarefas que competem por determinado recurso, especificadas
por seus tempos de início e de término, selecionar o maior conjunto viável de
tarefas a serem executadas.
4.7 - (Travessia).
Dadas n pessoas que devem atravessar uma ponte e que levam tempos de
travessia distintas, determinar as manobras necessárias para se levar o menor
tempo de travessia, considerando as seguintes restrições:
a) só podem atravessar 1 ou duas pessoas de cada vez.
89
b) está escuro e só há uma lanterna, de forma que sempre que alguém vai
para o outro lado, a lanterna tem que ser devolvida.
4.8 - Para o problema anterior, determinar o tempo mínimo de travessia, dados
os seguintes tempos individuais:
a) 1 2 5 10
b) 1 5 5 5
c) 1 3 4 5 9 10
4.9 - Criar a árvore de Huffman para os seguintes dados:
A
B
C
D
E
F
G
.3
.15
.1
.1
.08
.05
.13
H
.09
4.10 - (Árvore de Busca Ótima)
Apresentar um contraexemplo para o fato do Método Guloso não
resolver o problema da árvore de busca ótima.
4.11 - Determinar a sequência ótima de Merge para os seguintes arquivos:
A
200
B
500
C
300
D
600
E
100
F
200
G
300
4.12 - Determinar o sequenciamento ótimo de tarefas para os
Taref T1
T2
T3
T4
T5
T6
a
T.Lim 3
1
2
2
4
8
Rece. 9
5
9
10
3
15
90
H
250
seguintes dados:
T7
3
8
5. PROBLEMAS NP-COMPLETOS
5.1 Complexidades
de
Algoritmos
x
Tempo
de
execução
Algoritmos
1
2
3
4
5
Complexidade
33n
46nlgn
13n2
3.4n3
2n
Tam: entrada
10
100
,00033
,003
,0015
,03
,0013
,13
,0034
3,4
1.000
10.000
100.000
,033
,33
3,3
,45
6,1
1,3 min
13
22 min
1,5 dias
,94 h
39 dias
108 anos
,001
1016 anos
-
2000
82000
280
2200
67
260
20
26
Tempo permitid Tam.
entrada
1
30000
60
1800000
5.2 Problemas Polinomiais
x
Problemas
Problemas polinomiais são aqueles para os quais existem algoritmos
complexidade é polinomial .
cuja
Exemplos:
-Ordenação de Dados
-Buscas e atualização em árvores
-Buscas em grafos
-Ciclos Eulerianos
-Problemas com solução gulosa, em geral
-Problemas com solução por Programação dinâmica em geral
-Programação linear
Problemas exponenciais são aqueles para os quais somente existem
algoritmos com complexidade exponencial ou superior
Exemplos:
-Torre de Hanoi
-Geração de Permutações
91
5.3 Problemas ainda não classificados
Existe uma grande classe de problemas para
se existe algoritmo polinomial ou não .
Exemplos:
-Partição (Soma de subconjuntos)
-Mochila
-Empacotamento
-Programação inteira
-Caixeiro viajante
-Ciclo Hamiltoniano
-Coloração de vértices em grafos
-Satisfatibilidade
-Clique Máxima em grafos
-Conjunto independente máximo em grafos
5.4 Classe P
x
os quais não se sabe ainda
Classe NP
As classes P e NP são uma tentativa de lidar com os problemas ainda não
classificados. Nesta teoria os problemas são transformados em Problemas
de Decisão.
Um problema de decisão apenas responde SIM ou NÃO a determinada
pergunta:
Exemplo: Considerando-se o problema: Coloração de Vértices em Grafos.
Na versão tradicional do problema, quer-se saber qual é o valor mínimo de
k para o qual existe uma k-coloração própria em G.
Na versão Decisão deste problema, ele se transforma em:
Existe uma coloração própria de vértices com ≤ k cores no grafo G?
Problemas polinomiais são agrupados na classe P de problemas .
A classe NP é constituída de Problemas de Decisão para os quais, quando a
resposta do mesmo é SIM, existe um CERTIFICADO cujo tamanho é
polinomial em função da entrada e cuja correção pode ser verificada em
tempo polinomial.
Exemplo de certificado para o Problema Coloração de Vértices:
92
Uma atribuição de cores aos vértices do grafo.
Esse certificado tem tamanho equivalente ao tamanho do grafo (portanto
seu tamanho é polinomial em função da entrada). Além disso, pode-se
verificar, em tempo polinomial se o certificado é correto. (Basta fazer a
atribuição de cores dada pelo certificado e checar se não existem 2
vizinhos com a mesma cor. Além disso deve-se verificar se o número de
cores usada é ≤ k).
Notar que nada é exigido em relação à resposta NÃO.
5.5 Exemplos de problemas na classe NP
Os problemas não classificados listados anteriormente.
-Partição (Soma de subconjuntos)
-Mochila
-Empacotamento
-Programação inteira
-Caixeiro viajante
-Ciclo Hamiltoniano
-Coloração de vértices em grafos
-Satisfatibilidade
-Clique Máxima em grafos
-Conjunto independente máximo em grafos
É interessante o caso do problema PRIMO:
Dado k > 0, k é primo?
Até o início de 2002 sabia-se que PRIMO ∈ NP. A partir do algoritmo
KMS, criado nesse ano, passou-se a saber que PRIMO ∈ P.
5.6 Redução polinomial de problemas
Sejam dois problemas de decisão D1 e D2 e sabe-se que existe um
algoritmo A2 para solucionar D2. Suponhamos que se consiga transformar
D1 em D2 e também transformar a solução de D2 na solução de D1. Se
essas transformações forem polinomiais, então diz-se que
D1 se reduz
polinomialmente a D2 e pode-se transformar polinomialmente o algoritmo
A2 para solucionar D1.
93
Ex: Ciclo Hamiltoniano reduz-se, polinomialmente, a Caixeiro Viajante.
CH: Dado G, existe ciclo Hamiltoniano em G?
CV: Dado G´, completo, com arestas ponderadas, existe ciclo Hamiltoniano
com peso total ≤ k?
Transformação de CH em CV:
A partir de G para CH, cria-se G´ completo para CV, com os seguintes
pesos: para cada aresta (v,w) de G´, se ela existir em G, seu peso = 1;
senão, seu peso = 2. k é feito = |V(G)|.
5.7 A Classe NP-Completo
Define-se a seguinte sub-classe de NP:
A classe NP-completo é constituída dos problemas D, tais que:
a) D ∈ NP
b) Se D1 ∈ NP então D1 reduz-se polinomialmente a D.
Em 1971, Cook demonstrou que SATISFATIBILIDADE ∈ NP-Completo.
Desde então foi demonstrado que mais de 2000 também pertencem `a
mesma classe, inclusive os listados anteriormente:
-Partição (Soma de subconjuntos)
-Mochila
-Empacotamento
-Programação inteira
-Caixeiro viajante
-Ciclo Hamiltoniano
-Coloração de vértices em grafos
-Clique Máxima em grafos
-Conjunto independente máximo em grafos
5.8 As Classes P, NP e NP-Completo
Problemas em aberto:
a) P = NP?
b) Se algum problema que pertença a NP-Completo tiver
um algoritmo polinomial Þ todos os problemas de NP
são polinomiais Þ P = NP
c) Não há muitas esperanças de que o ítem b ocorra,
mas ninguém ainda provou.
d) “Soluções” para problemas NP-Completos:
-Backtracking c/ heurísticas
-Algoritmos aproximativos específicos
-Algoritmos probabilísticos
94
FIM
95
Download