Guide To Competitive Programming
Guide To Competitive Programming
Antti Laaksonen
Guiado para
Competitivo
Programação
Algoritmos de Aprendizagem e Melhoria
Através de concursos
Machine Translated by Google
Editor da série
Ian Mackie
Conselho Consultivo
Antti Laaksonen
123
Machine Translated by Google
Antti Laaksonen
Departamento de Ciência da Computação
Universidade de Helsinque
Helsinque
Finlândia
© Springer International Publishing AG, parte da Springer Nature 2017 Este trabalho
está sujeito a direitos autorais. Todos os direitos são reservados à Editora, quer se trate da totalidade ou de parte do
material, nomeadamente os direitos de tradução, reimpressão, reutilização de ilustrações, recitação, difusão, reprodução em
microfilmes ou de qualquer outra forma física, e transmissão ou armazenamento de informação e recuperação, adaptação
eletrônica, software de computador ou por metodologia semelhante ou diferente agora conhecida ou desenvolvida no futuro.
O uso de nomes descritivos gerais, nomes registrados, marcas comerciais, marcas de serviço, etc. nesta publicação não
implica, mesmo na ausência de uma declaração específica, que tais nomes estejam isentos das leis e regulamentos de
proteção relevantes e, portanto, livres para uso geral. usar.
A editora, os autores e os editores podem presumir com segurança que os conselhos e informações contidos neste livro são
verdadeiros e precisos na data de publicação. Nem o editor nem os autores ou os editores dão garantia, expressa ou
implícita, com relação ao material aqui contido ou por quaisquer erros ou omissões que possam ter sido cometidos. A editora
permanece neutra em relação a reivindicações jurisdicionais em mapas publicados e afiliações institucionais.
Este selo da Springer é publicado pela empresa registrada Springer International Publishing AG, parte da Springer Nature.
Prefácio
v
Machine Translated by Google
vi Prefácio
programação. Em vez disso, você tem um conjunto completo de ferramentas disponíveis e precisa
descobrir qual delas usar.
Resolver problemas de programação competitivos também melhora as habilidades de programação
e depuração. Normalmente, uma solução recebe pontos apenas se resolver corretamente todos os
casos de teste, portanto, um programador competitivo bem-sucedido deve ser capaz de implementar
programas que não tenham bugs. Essa é uma habilidade valiosa em engenharia de software, e não é
por acaso que as empresas de TI estão interessadas em pessoas com experiência em programação
competitiva.
Leva muito tempo para se tornar um bom programador competitivo, mas também é uma
oportunidade de aprender muito. Você pode ter certeza de que obterá uma boa compreensão geral
dos algoritmos se passar algum tempo lendo o livro, resolvendo problemas e participando de concursos.
Se você tiver algum feedback, eu gostaria de ouvi-lo! Você sempre pode me enviar um
mensagem para [email protected].
Sou muito grato a um grande número de pessoas que me enviaram comentários sobre as versões
preliminares deste livro. Esse feedback melhorou muito a qualidade do livro. Agradeço especialmente
a Mikko Ervasti, Janne Junnila, Janne Kokkala, Tuukka Korhonen, Patric Östergård e Roope Salmi por
fornecerem feedback detalhado sobre o manuscrito. Agradeço também a Simon Rees e Wayne
Wheeler pela excelente colaboração na publicação deste livro com a Springer.
Conteúdo
1 Introdução ............................................. 1
1.1 O que é Programação Competitiva? .......................
1
1.1.1 Concursos de Programação.......................... 2
1.1.2 Dicas para Praticar.......................... 3
1.2 Sobre este livro .................................... 3
1.3 Conjunto de Problemas do CSES .............................. 5
1.4 Outros Recursos ....................................... 7
vii
Machine Translated by Google
viii Conteúdo
Conteúdo ix
x Conteúdo
Conteúdo XI
xii Conteúdo
Introdução
1
Este capítulo mostra o que é programação competitiva, descreve o conteúdo do livro e discute
recursos de aprendizado adicionais.
A Seção 1.1 aborda os elementos da programação competitiva, apresenta uma seleção
de concursos de programação populares e dá conselhos sobre como praticar programação
competitiva.
A Seção 1.2 discute os objetivos e tópicos deste livro e descreve brevemente o conteúdo
de cada capítulo.
A Seção 1.3 apresenta o Conjunto de Problemas CSES, que contém uma coleção de
problemas práticos. Resolver os problemas durante a leitura do livro é uma boa maneira de
aprender programação competitiva.
A Seção 1.4 discute outros livros relacionados à programação competitiva e a
projeto de algoritmos.
2 1. Introdução
como conjuntos, lógica e funções, e o apêndice pode ser usado como referência ao ler o livro.
O ICPC consiste em várias etapas e, finalmente, as melhores equipes são convidadas para as
Finais Mundiais. Embora existam dezenas de milhares de participantes no concurso, há apenas um
pequeno número1 de vagas finais disponíveis, portanto, mesmo avançar para as finais é uma grande
conquista.
1O número exato de vagas finais varia de ano para ano; em 2017, foram 133 vagas finais.
Machine Translated by Google
Em cada concurso ICPC, as equipes têm cinco horas para resolver cerca de dez problemas de
algoritmo. Uma solução para um problema só é aceita se resolver todos os casos de teste de forma eficiente.
Durante a competição, os competidores podem ver os resultados de outras equipes, mas na última hora
o placar fica congelado e não é possível ver os resultados das últimas inscrições.
Algumas empresas organizam concursos online com finais no local. Exemplos de tais concursos
são Facebook Hacker Cup, Google Code Jam e Yandex.Algorithm. É claro que as empresas também
usam esses concursos para recrutamento: ter um bom desempenho em um concurso é uma boa
maneira de provar suas habilidades em programação.
Aprender programação competitiva requer uma grande quantidade de trabalho. No entanto, existem
muitas maneiras de praticar, e algumas delas são melhores que outras.
Ao resolver problemas, deve-se ter em mente que o número de problemas resolvidos não é tão
importante quanto a qualidade dos problemas. É tentador selecionar problemas que parecem bons e
fáceis e resolvê-los, e pular problemas que parecem difíceis e tediosos. No entanto, a maneira de
realmente melhorar as habilidades é focar no último tipo de problemas.
Outra observação importante é que a maioria dos problemas de concursos de programação podem
ser resolvidos usando algoritmos simples e curtos, mas a parte difícil é inventar o algoritmo. A
programação competitiva não é aprender de cor algoritmos complexos e obscuros, mas sim aprender a
resolver problemas e maneiras de abordar problemas difíceis usando ferramentas simples.
O IOI Syllabus [15] regulamenta os tópicos que podem aparecer na Olimpíada Internacional de
Informática, e o syllabus tem sido um ponto de partida para a seleção de tópicos para este livro. No
entanto, o livro também discute alguns tópicos avançados que são (a partir de 2017) excluídos do IOI,
mas podem aparecer em outros concursos. Exemplos de tais tópicos são fluxos máximos, teoria nim e
matrizes de sufixos.
Machine Translated by Google
4 1. Introdução
Embora muitos tópicos de programação competitivos sejam discutidos em livros-texto de algoritmos padrão,
também existem diferenças. Por exemplo, muitos livros didáticos se concentram na implementação de algoritmos
de classificação e estruturas de dados fundamentais a partir do zero, mas esse conhecimento não é muito
relevante na programação competitiva, porque a funcionalidade da biblioteca padrão pode ser usada. Depois, há
tópicos que são bem conhecidos na comunidade de programação competitiva, mas raramente discutidos em
livros didáticos. Um exemplo desse tópico é a estrutura de dados de árvore de segmento que pode ser usada
para resolver um grande número de problemas que, de outra forma, exigiriam algoritmos complicados.
Um dos propósitos deste livro foi documentar técnicas de programação competitivas que geralmente são
discutidas apenas em fóruns online e postagens em blogs. Sempre que possível, foram dadas referências
científicas para métodos específicos da programação competitiva. No entanto, isso nem sempre foi possível,
porque muitas técnicas agora fazem parte do folclore da programação competitiva e ninguém sabe quem as
descobriu originalmente.
gramatura.
• Capítulo 12 apresenta técnicas avançadas de grafos, como comunicação fortemente conectada
componentes e fluxos máximos.
• Capítulo 13 concentra-se em algoritmos geométricos e apresenta técnicas que
problemas geométricos podem ser resolvidos convenientemente.
• Capítulo 14 lida com técnicas de string, como hashing de string, o algoritmo Z e o uso de matrizes de sufixo. •
Capítulo 15 discute uma seleção de tópicos mais avançados, como algoritmos de raiz quadrada e otimização
de programação dinâmica.
Machine Translated by Google
O Conjunto de Problemas CSES fornece uma coleção de problemas que podem ser usados para
praticar programação competitiva. Os problemas foram organizados em ordem de dificuldade e todas
as técnicas necessárias para resolvê-los são discutidas neste livro. O conjunto de problemas está
disponível no seguinte endereço:
https://cses.fi/problemset/
Vamos ver como resolver o primeiro problema no conjunto de problemas, chamado Weird Algorithm.
O enunciado do problema é o seguinte:
Considere um algoritmo que recebe como entrada um inteiro positivo n. Se n for par, o algoritmo o divide
por dois, e se n for ímpar, o algoritmo multiplica por três e soma um. O algoritmo repete isso, até que n
seja um. Por exemplo, a sequência para n = 3 é a seguinte:
3 ÿ 10 ÿ 5 ÿ 16 ÿ 8 ÿ 4 ÿ 2 ÿ 1
Entrada
Saída
• 1 ÿ n ÿ 106
Exemplo
Entrada:
Resultado:
3 10 5 16 8 4 2 1
Este problema está ligado à famosa conjectura de Collatz que afirma que o algoritmo acima termina
para todo valor de n. No entanto, ninguém foi capaz de provar isso. Neste problema, porém, sabemos
que o valor inicial de n será no máximo um milhão, o que torna o problema muito mais fácil de resolver.
Este problema é um problema de simulação simples, que não requer muita reflexão. Aqui está uma
maneira possível de resolver o problema em C++:
Machine Translated by Google
6 1. Introdução
#include <iostream>
int main(){
int n;
cin >> n;
enquanto (verdadeiro) {
cout << n << if (n == " ";
1) quebra;
se (n%2 == 0) n/= 2;
senão n = n*3+1;
}
cout<<"\n";
}
Isso significa que nosso código passou em alguns dos casos de teste (ACCEPTED), foi algumas
vezes muito lento (TIME LIMIT EXCEEDED) e também produziu uma saída incorreta
(RESPOSTA ERRADA). Isso é bastante surpreendente!
O primeiro caso de teste que falha tem n = 138367. Se testarmos nosso código localmente usando este
entrada, verifica-se que o código é realmente lento. Na verdade, nunca termina.
A razão pela qual nosso código falha é que n pode se tornar muito grande durante a simulação. Em
particular, pode se tornar maior que o limite superior de uma variável int. Para
Machine Translated by Google
corrigir o problema, basta alterar nosso código para que o tipo de n seja longo.
Então teremos o resultado desejado:
Como este exemplo mostra, mesmo algoritmos muito simples podem conter bugs sutis.
A programação competitiva ensina como escrever algoritmos que realmente funcionam.
Além deste livro, já existem vários outros livros sobre programação competitiva.
Skiena's and Revilla's Programming Challenges[28] é um livro pioneiro na área publicado em
2003. Um livro mais recente é Competitive Programming 3 [14] de Halim e Halim. Ambos os
livros acima são destinados a leitores sem experiência em programação competitiva.
Procurando um desafio? [7] é um livro avançado, que apresenta uma coleção de problemas
difíceis de concursos de programação poloneses. A característica mais interessante do livro é
que ele fornece análises detalhadas de como resolver os problemas. O livro destina-se a
programadores experientes e competitivos.
É claro que livros de algoritmos gerais também são boas leituras para programadores
competitivos. O mais abrangente deles é Introduction to Algorithms [6] escrito por Cormen,
Leiserson, Rivest e Stein, também chamado de CLRS. Este livro é um bom recurso se você
quiser verificar todos os detalhes sobre um algoritmo e como provar rigorosamente que ele está
correto.
O projeto de algoritmos de Kleinberg e Tardos [19] enfoca técnicas de projeto de algoritmos
e discute detalhadamente o método de dividir e conquistar, algoritmos gulosos, programação
dinâmica e algoritmos de fluxo máximo. The Algorithm De sign Manual [27] de Skiena é um livro
mais prático que inclui um grande catálogo de problemas computacionais e descreve maneiras
de resolvê-los.
Machine Translated by Google
Técnicas de programação
2
Este capítulo apresenta alguns dos recursos da linguagem de programação C++ que são úteis na
programação competitiva e fornece exemplos de como usar operações de recursão e bits na
programação.
A Seção 2.1 discute uma seleção de tópicos relacionados a C++, incluindo entrada e
métodos de saída, trabalhando com números e como encurtar o código.
A Seção 2.2 se concentra em algoritmos recursivos. Primeiro vamos aprender uma maneira
elegante de gerar todos os subconjuntos e permutações de um conjunto usando recursão. Depois
disso, usaremos o retrocesso para contar o número de maneiras de colocar n rainhas não atacantes
em um tabuleiro de xadrez n × n .
A Seção 2.3 discute os fundamentos das operações de bits e mostra como usá-los para
representar subconjuntos de conjuntos.
#include <bits/stdc++.h>
int main(){
// solução vem aqui
}
A linha #include no início do código é um recurso do compilador g++ que nos permite incluir toda
a biblioteca padrão. Assim, não é necessário separar
10 2 Técnicas de Programação
incluem bibliotecas como iostream, vetor e algoritmo, mas estão disponíveis automaticamente.
A linha using declara que as classes e funções da biblioteca padrão podem ser usadas
diretamente no código. Sem a linha using teríamos que escrever, por exemplo, std::cout, mas
agora basta escrever cout.
O código pode ser compilado usando o seguinte comando:
int a, b;
seqüência x;
cin >> a >> b >> x;
Esse tipo de código sempre funciona, supondo que haja pelo menos um espaço ou nova linha
entre cada elemento na entrada. Por exemplo, o código acima pode ler as duas entradas a seguir:
123 456
macaco
ios::sync_with_stdio(0); cin.tie(0);
Observe que a nova linha "\n" funciona mais rápido que endl, porque endl sempre causa uma
operação de liberação.
As funções C scanf e printf são uma alternativa aos fluxos padrão C++. Eles geralmente são um
pouco mais rápidos, mas também mais difíceis de usar. O código a seguir lê dois inteiros da entrada:
int a, b;
scanf("%d %d", &a, &b);
Às vezes, o programa deve ler uma linha de entrada inteira, possivelmente contendo espaços.
Isso pode ser feito usando a função getline:
cordas;
getline(cin, s);
Este loop lê os elementos da entrada um após o outro, até que não haja mais
dados disponíveis na entrada.
Em alguns sistemas de concurso, os arquivos são usados para entrada e saída. Uma solução fácil
para isso é escrever o código como de costume usando fluxos padrão, mas adicione as seguintes
linhas ao início do código:
Depois disso, o programa lê a entrada do arquivo “input.txt” e grava a saída no arquivo “output.txt”.
Machine Translated by Google
12 2 Técnicas de Programação
int a = 123456789;
longo longo b = a*a;
cout << b << "\n"; // -1757895751
Mesmo que a variável bis do tipo long long, ambos os números na expressão
a*a são do tipo int, e o resultado também é do tipo int. Por isso, a variável
b terá um resultado errado. O problema pode ser resolvido alterando o tipo de a para
long long ou alterando a expressão para (long long)a*a.
Normalmente, os problemas do concurso são definidos de modo que o tipo long long seja suficiente. Ainda assim,
é bom saber que o compilador g++ também fornece um tipo de 128 bits __int128_t
... 2127 ÿ 1 (cerca de ÿ1038 ... 1038). No entanto, este tipo
com um intervalo de valores de ÿ2127
não está disponível em todos os sistemas de concurso.
Assim, podemos tomar o resto após cada operação, e os números nunca serão
ficar muito grande.
1Na verdade, o padrão C++ não especifica exatamente os tamanhos dos tipos numéricos e os limites
dependem do compilador e da plataforma. Os tamanhos fornecidos nesta seção são aqueles que você provavelmente
ver ao usar sistemas modernos.
Machine Translated by Google
Normalmente queremos que o resto esteja sempre entre 0 ... mÿ1. No entanto, em C++ e
outras linguagens, o restante de um número negativo é zero ou negativo.
Uma maneira fácil de garantir que não haja restos negativos é primeiro calcular o resto como de
costume e depois adicionar m se o resultado for negativo:
x = x%m; se
(x < 0) x += m;
printf("%.9f\n", x);
Uma dificuldade ao usar números de ponto flutuante é que alguns números não podem ser
representados com precisão como números de ponto flutuante e haverá erros de arredondamento.
Por exemplo, no código a seguir, o valor de x é um pouco menor que 1, enquanto o valor correto
seria 1.
É arriscado comparar números de ponto flutuante com o operador ==, porque é possível que
os valores sejam iguais, mas não são devido a erros de precisão.
Uma maneira melhor de comparar números de ponto flutuante é assumir que dois
números são iguais se a diferença entre eles for menor que ÿ, onde ÿ é um número
pequeno. Por exemplo, no código a seguir ÿ = 10ÿ9:
Machine Translated by Google
14 2 Técnicas de Programação
Observe que, embora os números de ponto flutuante sejam imprecisos, os números inteiros até
um certo limite ainda podem ser representados com precisão. Por exemplo, usando double, é
possível representar com precisão todos os inteiros cujo valor absoluto seja no máximo 253.
Nomes de tipos O comando typedef pode ser usado para dar um nome curto a um tipo
de dados. Por exemplo, o nome long long é longo, então podemos definir um nome curto ll
da seguinte forma:
O comando typedef também pode ser usado com tipos mais complexos. Por exemplo,
o código a seguir dá o nome vi para um vetor de inteiros e o nome pi para um par que
contém dois inteiros.
Macros Outra maneira de encurtar o código é definir macros. Uma macro especifica que
certas strings no código serão alteradas antes da compilação. Em C++, as macros são
definidas usando a palavra-chave #define.
Por exemplo, podemos definir as seguintes macros:
v.push_back(make_pair(y1,x1));
v.push_back(make_pair(y2,x2)); int d =
v[i].primeiro+v[i].segundo;
v.PB(MP(y1,x1));
v.PB(MP(y2,x2)); int d =
v[i].F+v[i].S;
Uma macro também pode ter parâmetros, o que possibilita encurtar loops e
outras estruturas. Por exemplo, podemos definir a seguinte macro:
REP(i,1,n)
{ pesquisa(i);
}
A recursão geralmente fornece uma maneira elegante de implementar um algoritmo. Nesta seção, discutimos
algoritmos recursivos que passam sistematicamente por soluções candidatas para um problema. Primeiro, nos
concentramos na geração de subconjuntos e permutações e, em seguida, discutimos a técnica de retrocesso
mais geral.
Nossa primeira aplicação de recursão é gerar todos os subconjuntos de um conjunto de n elementos. Por exemplo,
os subconjuntos de{1, 2, 3} são ÿ,{1},{2},{3},{1, 2},{1, 3},{2, 3} e {1, 2, 3}.
A seguinte pesquisa de função recursiva pode ser usada para gerar os subconjuntos. A função mantém um vetor
Machine Translated by Google
16 2 Técnicas de Programação
subconjunto vector<int> ;
void search(int k) {
if (k == n+1) {
// subconjunto de processos
} senão {
// incluir k dentro a subconjunto
subconjunto.push_back(k);
pesquisa(k+1);
subconjunto.pop_back();
// não inclua k dentro a subconjunto
pesquisa(k+1);
}
}
vector<int> permutação;
bool escolhido[n+1];
que indica para cada elemento se ele foi incluído na permutação. A pesquisa começa
quando a função é chamada sem parâmetros.
void search() { if
(permutation.size() == n) {
// permutação do processo
} else { for
(int i = 1; i <= n; i++) { if (escolhido[i]) continue;
escolhido[i] = verdadeiro;
permutação.push_back(i); procurar();
escolhido[i] = falso; permutação.pop_back();
}
}
}
} faça {
// permutação do processo
} while (next_permutation(permutation.begin(), permutation.end()));
Machine Translated by Google
18 2 Técnicas de Programação
2.2.3 Retrocesso
Um algoritmo de retrocesso começa com uma solução vazia e estende a solução passo a passo.
A busca recursiva passa por todas as diferentes maneiras como uma solução pode ser construída.
Como exemplo, considere o problema de calcular o número de maneiras pelas quais n rainhas
podem ser colocadas em um tabuleiro de xadrez n × n de modo que duas rainhas não se ataquem.
Por exemplo, a Fig. 2.2 mostra as duas soluções possíveis para n = 4.
O problema pode ser resolvido usando o retrocesso, colocando rainhas no tabuleiro, linha por
linha. Mais precisamente, exatamente uma rainha será colocada em cada linha para que nenhuma
rainha ataque qualquer uma das rainhas colocadas antes. Uma solução foi encontrada quando
todas as n rainhas foram colocadas no tabuleiro.
Por exemplo, a Fig. 2.3 mostra algumas soluções parciais geradas pelo algoritmo de retrocesso
quando n = 4. No nível inferior, as três primeiras configurações são ilegais, porque as rainhas se
atacam. No entanto, a quarta configuração é válida e pode ser estendida para uma solução
completa colocando mais duas rainhas no tabuleiro.
Há apenas uma maneira de colocar as duas rainhas restantes.
void search(int y) { if (y == n)
{ contagem++;
Retorna;
}
}
A matriz col acompanha as colunas que contêm uma rainha, e as matrizes diag1 e diag2
registram as diagonais. Não é permitido adicionar outra rainha a uma coluna ou diagonal que já
contenha uma rainha. Por exemplo, a Fig. 2.4 mostra a numeração das colunas e diagonais do
tabuleiro 4 × 4.
O algoritmo de retrocesso acima nos diz que existem 92 maneiras de colocar 8 rainhas no
tabuleiro 8 × 8. Quando n aumenta, a busca rapidamente se torna lenta, pois o número de soluções
cresce exponencialmente. Por exemplo, já leva cerca de um minuto em um computador moderno
para calcular que existem 14772512 maneiras de colocar 16 rainhas no tabuleiro 16 × 16.
20 2 Técnicas de Programação
00000000000000000000000000101011.
Os bits na representação são indexados da direita para a esquerda. Para converter uma
representação de bit bk ... b2b1b0 em um número, a fórmula
1 · 25 + 1 · 23 + 1 · 21 + 1 · 20 = 43.
1111111111111111111111111010101.
Em uma representação sem sinal, apenas números não negativos podem ser usados, mas
o limite superior para os valores é maior. Uma variável sem sinal de n bits pode conter qualquer
número inteiro entre 0 e 2n ÿ 1. Por exemplo, em C++, uma variável int sem sinal pode conter
qualquer número inteiro entre 0 e 232 ÿ 1.
Há uma conexão entre as representações: um número com sinal ÿx é igual a um número
sem sinal 2n ÿ x. Por exemplo, o código a seguir mostra que o número com sinal x = ÿ43 é igual
ao número sem sinal y = 232 ÿ 43:
Se um número for maior que o limite superior da representação de bits, o número excederá.
Em uma representação assinada, o próximo número após 2nÿ1 ÿ 1 é ÿ2nÿ1,
Machine Translated by Google
int x = 2147483647
cout << x << "\n"; // 2147483647
x++;
cout << x << "\n"; // -2147483648
Inicialmente, o valor de x é 231 ÿ 1. Este é o maior valor que pode ser armazenado em
uma variável int, então o próximo número após 231 ÿ 1 é ÿ231.
Operação And A operação and x & y produz um número que tem um bit em posições
onde xey têm um bit . Por exemplo, 22 e 26 = 18, porque
10110 (22)
e 11010 (26) =
10010 (18) .
10110 (22)
| 11010 (26)
= 11110 (30) .
ˆ
Operação Xor A operação xor xy produz um número que tem um bit em posições onde
exatamente um de x e y tem um bit. Por exemplo, 22 ˆ 26 = 12, porque
10110 (22)
ˆ 11010 (26)
= 01100 (12) .
Not Operation A operação not ~x produz um número onde todos os bits de x foram
invertidos. A fórmula ~x = ÿx ÿ1 é válida, por exemplo, ~29 = ÿ30. O resultado da operação
not no nível do bit depende do comprimento da representação do bit,
Machine Translated by Google
22 2 Técnicas de Programação
porque a operação inverte todos os bits. Por exemplo, se os números forem números int de 32
bits, o resultado será o seguinte:
x = 29 0000000000000000000000000011101 ~x = ÿ30
1111111111111111111111111100010
Deslocamentos de bits O deslocamento de bit para a esquerda x << k acrescenta k bits zero ao
número, e o deslocamento de bit para a direita x >> k remove os k últimos bits do número. Por
exemplo, 14 << 2 = 56, porque 14 e 56 correspondem a 1110 e 111000. Da mesma forma, 49 >>
3 = 6, porque 49 e 6 correspondem a 110001 e 110. Observe que x << k corresponde a multiplicar
x por 2k , e x >> k corresponde à divisão de x por 2k arredondado para um número inteiro.
Máscaras de bits Uma máscara de bits da forma 1 << k tem um bit na posição k, e todos os outros
bits são zero, então podemos usar essas máscaras para acessar bits únicos de números. Em
particular, o k- ésimo bit de um número é um exatamente quando x & (1 << k) não é zero. O código
a seguir imprime a representação de bits de um número int x:
Também é possível modificar bits únicos de números usando ideias semelhantes. A fórmula x |
(1 << k) define o k- ésimo bit de x como um, a fórmula x & ~(1 << k) define o k- ésimo bit de x como
zero, e a fórmula x ˆ (1 << k) inverte bit
o k-deésimo bit zero,
x como de x. Então, a fórmula
e a fórmula x & ÿxx define
& (x ÿ 1) define
todos o último
os bits um
como zero, exceto o último bit. A fórmula x | (x ÿ 1) inverte todos os bits após o último bit.
Finalmente, um número positivo x é uma potência de dois exatamente quando x & (x ÿ 1) = 0.
Uma armadilha ao usar máscaras de bits é que 1<<k é sempre uma máscara de bits int. Uma
maneira fácil de criar uma máscara de bits longos é 1LL<<k.
Funções Adicionais O compilador g++ também fornece as seguintes funções para contar bits:
Observe que as funções acima suportam apenas números int, mas também existem
versões longas das funções disponíveis com o sufixo ll.
Cada subconjunto de um conjunto {0, 1, 2,..., n ÿ 1} pode ser representado como um inteiro de n bits
cujos bits um indicam quais elementos pertencem ao subconjunto. Esta é uma forma eficiente
para representar conjuntos, porque cada elemento requer apenas um bit de memória, e definir
operações podem ser implementadas como operações de bits.
Por exemplo, como int é um tipo de 32 bits, um número int pode representar qualquer subconjunto
do conjunto {0, 1, 2,..., 31}. A representação de bits do conjunto {1, 3, 4, 8} é
00000000000000000000000100011010,
int x = 0;
x |= (1<<1);
x |= (1<<3);
x |= (1<<4);
x |= (1<<8);
cout << __builtin_popcount(x) << "\n"; // 4
Operações de Set A Tabela 2.1 mostra como as operações de set podem ser implementadas como bits
operações. Por exemplo, o código a seguir primeiro constrói os conjuntos x = {1, 3, 4, 8}
e y = {3, 6, 8, 9} e então constrói o conjunto z = x ÿ y = {1, 3, 4, 6, 8, 9}:
Machine Translated by Google
24 2 Técnicas de Programação
União aÿb um | b
Complemento uma ~a
Diferença a\b a & (~b)
int x = (1<<1)|(1<<3)|(1<<4)|(1<<8);
int y = (1<<3)|(1<<6)|(1<<8)|(1<<9);
int z = x|y;
cout << __builtin_popcount(z) << "\n"; // 6
intb = 0;
fazer {
// subconjunto de processos b
} while (b=(bx)&x);
Bitsets C++ A biblioteca padrão C++ também fornece a estrutura bitset, que
corresponde a uma matriz cujo cada valor é 0 ou 1. Por exemplo, o seguinte
código cria um bitset de 10 elementos:
Machine Translated by Google
Também as operações de bits podem ser usadas diretamente para manipular conjuntos de bits:
Eficiência
3
A eficiência dos algoritmos desempenha um papel central na programação competitiva. Neste capítulo,
aprendemos ferramentas que facilitam o projeto de algoritmos eficientes.
A Seção 3.1 introduz o conceito de complexidade de tempo, que nos permite estimar os tempos
de execução de algoritmos sem implementá-los. A complexidade de tempo de um algoritmo mostra a
rapidez com que seu tempo de execução aumenta quando o tamanho da entrada aumenta.
A Seção 3.2 apresenta dois exemplos de problemas que podem ser resolvidos de várias maneiras.
Em ambos os problemas, podemos facilmente projetar uma solução de força bruta lenta, mas também
podemos criar algoritmos muito mais eficientes.
A complexidade de tempo de um algoritmo estima quanto tempo o algoritmo usará para uma
determinada entrada. Ao calcular a complexidade de tempo, muitas vezes podemos descobrir se o
algoritmo é rápido o suficiente para resolver um problema – sem implementá-lo.
Uma complexidade de tempo é denotada por O(···) onde os três pontos representam alguma
função. Normalmente, a variável n denota o tamanho da entrada. Por exemplo, se a entrada for um
array de números, n será o tamanho do array e se a entrada for uma string, n será o comprimento da
string.
Se um código consiste em comandos únicos, sua complexidade de tempo é O(1). Por exemplo, a
complexidade de tempo do código a seguir é O(1).
28 3 Eficiência
a++;
b++;
c = a+b;
A complexidade de tempo de um loop estima o número de vezes que o código dentro do loop é
executado. Por exemplo, a complexidade de tempo do código a seguir é O(n), porque o código dentro do
loop é executado n vezes. Assumimos que “...” denota um código cuja complexidade de tempo é O(1).
Como outro exemplo, a complexidade de tempo do código a seguir é O(n2), porque (n2 + n) vezes.
o código dentro do loop é executado 1 + 2 + ... + n = 12
Machine Translated by Google
A complexidade de tempo de uma função recursiva depende do número de vezes que a função
é chamada e da complexidade de tempo de uma única chamada. A complexidade de tempo total
é o produto desses valores. Por exemplo, considere a seguinte função:
void f(int n) { if (n == 1)
return; f(n-1);
30 3 Eficiência
void g(int n) { if (n == 1)
return; g(n-1); g(n-1);
O que acontece quando a função é chamada com um parâmetro n? Primeiro, há duas chamadas com o
parâmetro n ÿ1, depois quatro chamadas com o parâmetro n ÿ2, depois oito chamadas com o parâmetro n ÿ
3 e assim por diante. Em geral, haverá 2k chamadas com parâmetro n ÿ k onde k = 0, 1,..., n ÿ 1. Assim, a
complexidade de tempo é
O(1) O tempo de execução de um algoritmo de tempo constante não depende do tamanho da entrada. Um
algoritmo de tempo constante típico é uma fórmula direta que calcula o
responda.
O(log n) Um algoritmo logarítmico geralmente reduz pela metade o tamanho da entrada em cada etapa. O
tempo de execução de tal algoritmo é logarítmico, pois log2 n é igual ao número de vezes que n deve ser
dividido por 2 para obter 1. Observe que a base do logaritmo não é mostrada na complexidade de tempo.
O( ÿn) Um algoritmo de raiz quadrada é mais lento que O(log n), mas mais rápido que O(n). Uma propriedade
especial das raízes quadradas é que ÿn = n/ ÿn, então n elementos podem ser divididos em O( ÿn) blocos
de O( ÿn) elementos.
O(n) Um algoritmo linear passa pela entrada um número constante de vezes. Geralmente, essa é a melhor
complexidade de tempo possível, porque geralmente é necessário acessar cada elemento de entrada
pelo menos uma vez antes de relatar a resposta.
O(n log n) Essa complexidade de tempo geralmente indica que o algoritmo classifica a entrada, porque a
complexidade de tempo dos algoritmos de classificação eficientes é O(n log n). Outra possibilidade é que
o algoritmo utilize uma estrutura de dados onde cada operação leva tempo O(log n).
O(n2) Um algoritmo quadrático geralmente contém dois laços aninhados. É possível percorrer todos os pares
dos elementos de entrada em tempo O(n2) .
O(n3) Um algoritmo cúbico geralmente contém três laços aninhados. É possível ir
através de todos os tripletos dos elementos de entrada em tempo O(n3) .
O(2n) Essa complexidade de tempo geralmente indica que o algoritmo itera por todos os subconjuntos dos
elementos de entrada. Por exemplo, os subconjuntos de {1, 2, 3} são ÿ, {1}, {2}, {3}, {1, 2}, {1, 3}, {2, 3} e
{1, 2, 3}.
O(n!) Essa complexidade de tempo geralmente indica que o algoritmo itera por todas as permutações dos
elementos de entrada. Por exemplo, as permutações de {1, 2, 3} são (1, 2, 3), (1, 3, 2), (2, 1, 3), (2, 3, 1),
(3, 1 , 2) e (3, 2, 1).
Machine Translated by Google
Ainda assim, é importante lembrar que uma complexidade de tempo é apenas uma estimativa de
eficiência, pois oculta os fatores constantes. Por exemplo, um algoritmo executado em tempo O(n)
pode realizar operações n/2 ou 5n , o que tem um efeito importante no tempo real de execução do
algoritmo.
n ÿ 10 Sobre!)
n ÿ 20 O(2n)
n ÿ 500 O(n3)
n ÿ 5000 n O(n2)
32 3 Eficiência
O que significa exatamente que um algoritmo funciona em tempo O( f (n))? Isso significa que
existem constantes c e n0 tais que o algoritmo realiza no máximo cf (n) operações para todas as
entradas onde n ÿ n0. Assim, a notação O fornece um limite superior para o tempo de execução do
algoritmo para entradas suficientemente grandes.
Por exemplo, é tecnicamente correto dizer que a complexidade de tempo do seguinte
algoritmo de redução é O(n2).
No entanto, um limite melhor é O(n), e seria muito enganoso fornecer o limite O(n2), porque
todos na verdade assumem que a notação O é usada para fornecer uma estimativa precisa da
complexidade do tempo.
Há também duas outras notações comuns. A notação ÿ fornece um limite inferior para o tempo
de execução de um algoritmo. A complexidade de tempo de um algoritmo é ÿ( f (n)), se houver
constantes c e n0 tais que o algoritmo execute pelo menos cf (n) operações para todas as entradas
onde n ÿ n0. Finalmente, a notação ÿ fornece um limite exato: a complexidade de tempo de um
algoritmo é ÿ( f (n)) se for O( f (n)) e ÿ( f (n)).
Por exemplo, como a complexidade de tempo do algoritmo acima é O(n) e ÿ(n), também é ÿ(n).
Podemos usar as notações acima em muitas situações, não apenas para nos referirmos às
complexidades de tempo dos algoritmos. Por exemplo, podemos dizer que um array contém valores
O(n) ou que um algoritmo consiste em rodadas O(log n).
3.2 Exemplos
Nesta seção, discutimos dois problemas de projeto de algoritmos que podem ser resolvidos de
várias maneiras diferentes. Começamos com algoritmos simples de força bruta e, em seguida,
criamos soluções mais eficientes usando várias ideias de design de algoritmos.
Dado um array de n números, nossa primeira tarefa é calcular a soma máxima do subarray, ou
seja, a maior soma possível de uma sequência de valores consecutivos no array. O problema é
interessante quando pode haver valores negativos no array. Por exemplo, a Fig. 3.1 mostra um
array e seu subarray de soma máxima.
Machine Translated by Google
3.2 Exemplos 33
}
melhor = max(melhor,soma);
}
34 3 Eficiência
Neste último caso, como queremos encontrar um subarray com soma máxima, o subarray
que termina na posição k ÿ 1 também deve ter a soma máxima. Assim, podemos resolver
o problema de forma eficiente, calculando a soma máxima do subarranjo para cada final
posição da esquerda para a direita.
O código a seguir implementa o algoritmo:
O algoritmo contém apenas um loop que passa pela entrada, então o tempo
complexidade é O(n). Esta é também a melhor complexidade de tempo possível, porque qualquer
algoritmo para o problema tem que examinar todos os elementos do array pelo menos uma vez.
Comparação de Eficiência Quão eficientes são os algoritmos acima na prática? Tabela 3.2
mostra os tempos de execução dos algoritmos acima para diferentes valores de n em um moderno
computador. Em cada teste, a entrada foi gerada aleatoriamente, e o tempo necessário para
a leitura da entrada não foi medida.
A comparação mostra que todos os algoritmos funcionam rapidamente quando o tamanho da entrada é
entradas pequenas, mas maiores, trazem diferenças notáveis nos tempos de execução. o
O algoritmo O(n3) torna-se lento quando n = 104, e o algoritmo O(n2) torna -se
lento quando n = 105. Somente o algoritmo O(n) é capaz de processar até mesmo o maior
entradas instantaneamente.
Tabela 3.2 Comparando os tempos de execução dos algoritmos de soma de submatriz máxima
3.2 Exemplos 35
Dado um tabuleiro de xadrez n × n , nosso próximo problema é contar o número de maneiras pelas quais podemos
coloque duas rainhas no tabuleiro de forma que elas não se ataquem. Por
Por exemplo, como mostra a Fig. 3.2 , existem oito maneiras de colocar duas rainhas no 3 × 3
quadro. Seja q(n) o número de combinações válidas para uma placa n × n . Por
exemplo, q(3) = 8, e a Tabela 3.3 mostra os valores de q(n) para 1 ÿ n ÿ 10.
Para começar, uma maneira simples de resolver o problema é percorrer todas as maneiras possíveis
colocar duas damas no tabuleiro e contar as combinações onde as damas fazem
não atacar uns aos outros. Tal algoritmo funciona em tempo O(n4) , porque existem n2
maneiras de escolher a posição da primeira rainha, e para cada uma dessas posições, existem
n2 ÿ 1 maneiras de escolher a posição da segunda rainha.
Como o número de combinações cresce rapidamente, um algoritmo que conta as combinações
uma a uma certamente será muito lento para processar valores maiores de n. Assim, para
criar um algoritmo eficiente, precisamos encontrar uma maneira de contar combinações em grupos.
Uma observação útil é que é muito fácil calcular o número de quadrados que
uma única rainha ataca (Fig. 3.3). Primeiro, ele sempre ataca n ÿ 1 quadrados horizontalmente
e n ÿ1 quadrados verticalmente. Então, para ambas as diagonais, ele ataca d ÿ1 quadrados onde
d é o número de quadrados na diagonal. Usando essas informações, podemos calcular
4 44
5 140
6 340
7 700
8 1288
9 2184
10 3480
Machine Translated by Google
36 3 Eficiência
em tempo O(1) o número de quadrados onde a outra rainha pode ser colocada, o que resulta
em um algoritmo de tempo O(n2) .
Outra maneira de abordar o problema é tentar formular uma função recursiva que conte o
número de combinações. A questão é: se sabemos o valor de q(n), como podemos usá-lo
para calcular o valor de q(n + 1)?
Para obter uma solução recursiva, podemos nos concentrar na última linha e na última
coluna do quadro n× n (Fig. 3.4). Primeiro, se não houver rainhas na última linha ou coluna, o
número de combinações é simplesmente q(n ÿ 1). Então, há 2n ÿ 1 posições para uma rainha
na última linha ou coluna. Ela ataca 3(n ÿ 1) casas, então há n2 ÿ 3(n ÿ 1) ÿ 1 posições para a
outra rainha. Finalmente, existem (n ÿ 1)(n ÿ 2) combinações onde ambas as rainhas estão na
última linha ou coluna. Como contamos essas combinações duas vezes, precisamos remover
esse número do resultado. Combinando tudo isso, obtemos uma fórmula recursiva
n4 5n3 3n2 n
q(n) =
ÿ
+ ÿ
,
2 3 2 3
que pode ser provado usando indução e a fórmula recursiva. Usando esta fórmula, podemos
resolver o problema em tempo O(1).
Machine Translated by Google
Classificando e Pesquisando
4
Muitos algoritmos eficientes são baseados na classificação dos dados de entrada, porque a classificação
geralmente facilita a solução do problema. Este capítulo discute a teoria e a prática da ordenação como
uma ferramenta de projeto de algoritmos.
A Seção 4.1 discute primeiro três algoritmos de ordenação importantes: ordenação por bolhas,
ordenação por mesclagem e ordenação por contagem. Depois disso, aprenderemos a usar o algoritmo de
ordenação disponível na biblioteca padrão C++.
A Seção 4.2 mostra como a ordenação pode ser usada como uma sub-rotina para criar algoritmos
eficientes. Por exemplo, para determinar rapidamente se todos os elementos do array são únicos, podemos
primeiro classificar o array e depois simplesmente verificar todos os pares de elementos consecutivos.
A Seção 4.3 apresenta o algoritmo de busca binária, que é outro importante bloco de construção de
algoritmos eficientes.
O problema básico na ordenação é o seguinte: Dado um array que contém n elementos, ordene os
elementos em ordem crescente. Por exemplo, a Fig. 4.1 mostra um array antes e depois da ordenação.
38 4 Classificando e Pesquisando
Bubble sort é um algoritmo de ordenação simples que funciona em tempo O(n2) . O algoritmo consiste em n
rodadas e, em cada rodada, ele itera pelos elementos da matriz.
Sempre que dois elementos consecutivos são encontrados em ordem errada, o algoritmo os troca. O algoritmo
pode ser implementado da seguinte forma:
swap(matriz[j],matriz[j+1]);
}
}
}
Após a primeira rodada de ordenação por bolha, o maior elemento estará na posição correta e, mais
geralmente, após k rodadas, os k maiores elementos estarão nas posições corretas. Assim, após n rodadas,
todo o array será ordenado.
Por exemplo, a Fig. 4.2 mostra a primeira rodada de trocas quando a ordenação por bolha é usada para
ordenar uma matriz.
Bubble sort é um exemplo de algoritmo de ordenação que sempre troca elementos consecutivos no array.
Acontece que a complexidade de tempo de tal algoritmo é sempre pelo menos O(n2), porque no pior caso,
trocas O(n2) são necessárias para ordenar o array.
Machine Translated by Google
Inversões Um conceito útil ao analisar algoritmos de ordenação é uma inversão: um par de índices
de array (a, b) tal que a < b e array[a] >array[b], ou seja, os elementos estão na ordem errada. Por
exemplo, a matriz na Fig. 4.3 tem três inversões: (3, 4), (3, 5) e (6, 7).
n(n - 1)
+ 2 +···+ (n ÿ 1) = 2 = O(n2), 1
Se quisermos criar um algoritmo de ordenação eficiente, temos que ser capazes de reordenar os
elementos que estão em diferentes partes do array. Existem vários algoritmos de ordenação que
funcionam em tempo O(n log n). Um deles é o merge sort, que é baseado em recursão. Merge sort
classifica um array de subarray[a ... b] da seguinte forma:
1. Se a = b, não faça nada, pois um subarray que contém apenas um elemento já está ordenado.
Por exemplo, a Fig. 4.4 mostra como o merge sort classifica um array de oito elementos.
Primeiro, o algoritmo divide o array em dois subarrays de quatro elementos. Em seguida, ele
classifica esses subarrays recursivamente chamando a si mesmo. Finalmente, ele mescla os
subarrays ordenados em um array ordenado de oito elementos.
A ordenação por mesclagem é um algoritmo eficiente, pois reduz pela metade o tamanho do
subarray em cada etapa. Então, mesclar os subarrays ordenados é possível em tempo linear,
porque eles já estão ordenados. Como existem níveis recursivos O(log n) e o processamento de
cada nível leva um tempo total O(n) , o algoritmo funciona em tempo O(n log n).
Machine Translated by Google
40 4 Classificando e Pesquisando
É possível classificar uma matriz mais rapidamente do que no tempo O(n log n)? Acontece que
isso não é possível quando nos restringimos a algoritmos de ordenação baseados na
comparação de elementos de array.
O limite inferior para a complexidade de tempo pode ser comprovado considerando a
ordenação como um processo onde cada comparação de dois elementos fornece mais
informações sobre o conteúdo do array. A Figura 4.5 ilustra a árvore criada neste processo.
Aqui “x < y?” significa que alguns elementos x e y são comparados. Se x < y, o processo
continua para a esquerda e, caso contrário, para a direita. Os resultados do processo são as
formas possíveis de ordenar o array, um total de n! caminhos. Por esta razão, a altura da árvore
deve ser pelo menos
Obtemos um limite inferior para essa soma escolhendo os últimos n/2 elementos e alterando o
valor de cada elemento para log2(n/ 2). Isso produz uma estimativa
O limite inferior ÿ(n log n) não se aplica a algoritmos que não comparam elementos de matriz, mas usam outras
informações. Um exemplo de tal algoritmo é a ordenação por contagem que ordena um array em tempo O(n)
assumindo que cada elemento no array é um inteiro entre 0 ... c e c = O(n).
O algoritmo cria um array de escrituração, cujos índices são elementos do array original. O algoritmo percorre o
array original e calcula quantas vezes cada elemento aparece no array. Como exemplo, a Fig. 4.6 mostra uma matriz
e a matriz de escrituração correspondente. Por exemplo, o valor na posição 3 é 2, porque o valor 3 aparece 2 vezes
na matriz original.
A construção da matriz de contabilidade leva tempo O(n) . Após isso, o array ordenado pode ser criado em tempo
O(n), pois o número de ocorrências de cada elemento pode ser recuperado do array de escrituração. Assim, a
complexidade de tempo total da ordenação por contagem é O(n).
A ordenação por contagem é um algoritmo muito eficiente, mas só pode ser usado quando a constante c for
pequena o suficiente, de modo que os elementos do array possam ser usados como índices na contabilidade
variedade.
Na prática, quase nunca é uma boa ideia implementar um algoritmo de ordenação feito em casa, porque todas as
linguagens de programação modernas têm bons algoritmos de ordenação em suas bibliotecas padrão. Há muitas
razões para usar uma função de biblioteca: certamente é correta e eficiente, e também fácil de usar.
Em C++, a função sort de forma eficiente1 classifica o conteúdo de uma estrutura de dados. Por
Por exemplo, o código a seguir classifica os elementos de um vetor em ordem crescente:
1O padrão C++11 requer que a função sort funcione em tempo O(n log n); a implementação exata depende do
compilador.
Machine Translated by Google
42 4 Classificando e Pesquisando
sort(v.rbegin(),v.rend());
Classificar uma string significa que os caracteres da string são classificados. Por exemplo,
a string “macaco” torna-se “ekmnoy”.
Operadores de comparação A função de classificação requer que um operador de comparação seja definido para
o tipo de dados dos elementos a serem classificados. Na ordenação, este operador será utilizado sempre que for
necessário descobrir a ordem de dois elementos.
A maioria dos tipos de dados C++ tem um operador de comparação integrado e os elementos desses tipos
podem ser classificados automaticamente. Os números são classificados de acordo com seus valores e as strings
são classificadas em ordem alfabética. Os pares são classificados principalmente de acordo com seus primeiros
elementos e secundariamente de acordo com seus segundos elementos:
vetor<par<int,int>> v; v.push_back({1,5});
v.push_back({2,3}); v.push_back({1,2});
sort(v.begin(), v.end());
// resultado: [(1,2),(1,5),(2,3)]
De maneira semelhante, as tuplas são classificadas principalmente pelo primeiro elemento, secundariamente por
o segundo elemento, etc.2:
vetor<tupla<int,int,int>> v; v.push_back({2,1,4});
v.push_back({1,5,3}); v.push_back({2,1,3});
sort(v.begin(), v.end());
// resultado: [(1,5,3),(2,1,3),(2,1,4)]
Estruturas definidas pelo usuário não possuem um operador de comparação automaticamente. O operador
deve ser definido dentro da estrutura como uma função operador<, cujo parâmetro
2Observe que em alguns compiladores mais antigos, a função make_tuple deve ser usada para criar
uma tupla em vez de chaves (por exemplo, make_tuple(2,1,4) em vez de {2,1,4}).
Machine Translated by Google
é outro elemento do mesmo tipo. O operador deve retornar true se o elemento for menor que o parâmetro e false
caso contrário.
Por exemplo, o ponto de estrutura a seguir contém as coordenadas xey de um ponto. O operador de
comparação é definido para que os pontos sejam ordenados principalmente pela coordenada x e secundariamente
pela coordenada y.
ponto de estrutura
{ int x, y; bool
operator<(const point &p) { if (x == px) return y < py;
senão retorna x < px;
}
};
Funções de comparação Também é possível fornecer uma função de comparação externa à função de
classificação como uma função de retorno de chamada. Por exemplo, a seguinte função de comparação comp
classifica as strings principalmente por comprimento e secundariamente por ordem alfabética:
Muitas vezes, podemos resolver facilmente um problema em tempo O(n2) usando um algoritmo de força bruta,
mas esse algoritmo é muito lento se o tamanho da entrada for grande. De fato, um objetivo frequente no projeto de
algoritmos é encontrar algoritmos de tempo O(n) ou O(n log n) para problemas que podem ser resolvidos
trivialmente em tempo O(n2) . A classificação é uma maneira de atingir esse objetivo.
Por exemplo, suponha que queremos verificar se todos os elementos em um array são únicos.
Um algoritmo de força bruta passa por todos os pares de elementos em tempo O(n2) :
bool ok = verdadeiro;
for (int i = 0; i < n; i++) {
for (int j = i+1; j < n; j++) { if (array[i] == array[j]) ok
= false;
}
}
Machine Translated by Google
44 4 Classificando e Pesquisando
No entanto, podemos resolver o problema em tempo O(n log n) ordenando primeiro o array.
Então, se houver elementos iguais, eles estão próximos uns dos outros no array ordenado, então
eles são fáceis de encontrar em tempo O(n) :
bool ok = verdadeiro;
sort(matriz, matriz+n); for (int i = 0;
i < n-1; i++) { if (array[i] == array[i+1]) ok = false;
Vários outros problemas podem ser resolvidos de maneira semelhante em tempo O(n log n),
como contar o número de elementos distintos, encontrar o elemento mais frequente e encontrar dois
elementos cuja diferença seja mínima.
Um algoritmo de linha de varredura modela um problema como um conjunto de eventos que são
processados em uma ordem de classificação. Por exemplo, suponha que haja um restaurante e
saibamos os horários de entrada e saída de todos os clientes em um determinado dia. Nossa tarefa
é descobrir o número máximo de clientes que visitaram o restaurante ao mesmo tempo.
Por exemplo, a Fig. 4.7 mostra uma instância do problema onde há quatro clientes A, B, C e D.
Nesse caso, o número máximo de clientes simultâneos é três entre a chegada de A e a saída de B.
Para resolver o problema, criamos dois eventos para cada cliente: um evento de chegada e outro
de saída. Então, ordenamos os eventos e passamos por eles de acordo com seus tempos. Para
encontrar o número máximo de clientes, mantemos um balcão cujo valor aumenta quando um cliente
chega e diminui quando um cliente sai. O maior valor do contador é a resposta para o problema.
A Figura 4.8 mostra os eventos em nosso cenário de exemplo. Cada cliente recebe dois eventos:
“+” denota um cliente que chega e “-” denota um cliente que sai.
O algoritmo resultante funciona em tempo O(n log n), porque ordenar os eventos leva tempo O(n log
n) e a parte da linha de varredura leva tempo O(n) .
Neste problema, existem várias maneiras de classificar os dados de entrada. Uma estratégia é
classificar os eventos de acordo com sua duração e selecionar os eventos mais curtos possíveis. No
entanto, esta estratégia nem sempre funciona, como mostra a Fig. 4.10. Então, outra ideia é ordenar
os eventos de acordo com seus horários de início e sempre selecionar o próximo evento possível que
comece o mais cedo possível. No entanto, podemos encontrar um contra-exemplo também para esta
estratégia, mostrado na Fig. 4.11.
Uma terceira ideia é classificar os eventos de acordo com seus horários de término e sempre
selecionar o próximo evento possível que termine o mais cedo possível. Acontece que esse algoritmo
sempre produz uma solução ótima. Para justificar isso, considere o que acontece se primeiro
selecionarmos um evento que termina mais tarde do que o evento que termina o mais cedo possível.
Agora, teremos no máximo um número igual de opções restantes de como podemos selecionar o
próximo evento. Portanto, selecionar um evento que termine mais tarde nunca pode resultar em uma
solução melhor, e o algoritmo guloso está correto.
Finalmente, considere um problema onde nos são dadas n tarefas com durações e prazos e nossa
tarefa é escolher uma ordem para realizar as tarefas. Para cada tarefa, ganhamos d ÿ x pontos onde
d é o prazo final da tarefa e x é o momento em que terminamos a tarefa.
Qual é a maior pontuação total possível que podemos obter?
Machine Translated by Google
46 4 Classificando e Pesquisando
Fig. 4.12 Um
cronograma ideal para as tarefas
A4 2
B3 10
C2 8
D4 15
A Figura 4.12 mostra um cronograma ideal para as tarefas em nosso cenário de exemplo.
Usando este esquema, C rende 6 pontos, B rende 5 pontos, A rende -7 pontos e D rende 2
pontos, então a pontuação total é 6.
Acontece que a solução ótima para o problema não depende dos prazos, mas uma
estratégia gananciosa correta é simplesmente executar as tarefas classificadas por suas
durações em ordem crescente. A razão para isso é que, se alguma vez realizarmos duas
tarefas uma após a outra, de modo que a primeira tarefa demore mais do que a segunda,
podemos obter uma solução melhor se trocarmos as tarefas.
Por exemplo, na Fig. 4.13, existem duas tarefas X e Y com durações a e b. Inicialmente, X
é agendado antes de Y . No entanto, como pontos
a > b, as
a menos
tarefasedevem
Y dá mais
ser trocadas.
pontos, então
Agoraa X dá b
pontuação total aumenta em a ÿ b > 0. Assim, em uma solução ótima, uma tarefa mais curta
deve sempre vir antes de uma tarefa mais longa, e as tarefas devem ser classificadas por suas
durações.
A busca binária é um algoritmo de tempo O(log n) que pode ser usado, por exemplo, para
verificar eficientemente se um array ordenado contém um determinado elemento. Nesta seção,
primeiro focamos na implementação da busca binária e, depois disso, veremos como a busca
binária pode ser usada para encontrar soluções ótimas para problemas.
Machine Translated by Google
Suponha que recebemos um array ordenado de n elementos e queremos verificar se o array contém um
elemento com um valor alvo x. A seguir, discutimos duas maneiras de implementar um algoritmo de busca
binária para esse problema.
Primeiro método A maneira mais comum de implementar a pesquisa binária se assemelha a procurar
uma palavra em um dicionário.3 A pesquisa mantém um subarray ativo no array, que inicialmente contém
todos os elementos do array. Em seguida, uma série de etapas são executadas, cada uma das quais
metade do intervalo de pesquisa. Em cada etapa, a pesquisa verifica o elemento do meio do subarray
ativo. Se o elemento do meio tiver o valor de destino, a pesquisa será encerrada.
Caso contrário, a pesquisa continua recursivamente para a metade esquerda ou direita do subarray,
dependendo do valor do elemento do meio. Por exemplo, a Fig. 4.14 mostra como um elemento com
valor 9 é encontrado no array.
A pesquisa pode ser implementada da seguinte forma:
// x Encontrado em índice k
3Algumas pessoas, incluindo o autor deste livro, ainda usam dicionários impressos. Outro exemplo é
encontrar um número de telefone em uma lista telefônica impressa, que é ainda mais obsoleta.
Machine Translated by Google
48 4 Classificando e Pesquisando
} if (matriz[k] == x) {
// x encontrado no índice k
}
Suponha que estamos resolvendo um problema e temos uma função valid(x) que retorna
true se x for uma solução válida e false caso contrário. Além disso, sabemos que valid(x)
é falso quando x < k e verdadeiro quando x ÿ k. Nesta situação, podemos usar a busca
binária para encontrar eficientemente o valor de k.
A ideia é buscar o maior valor de x para o qual valid(x) é falso. Assim, o próximo valor
k = x + 1 é o menor valor possível para o qual valid(k) é verdadeiro. A pesquisa pode ser
implementada da seguinte forma:
Machine Translated by Google
} intk = x+1;
O comprimento inicial do salto z tem que ser um limite superior para a resposta, ou seja,
qualquer valor para o qual certamente sabemos que valid(z) é verdadeiro. O algoritmo chama a
função válida O(log z) vezes, então o tempo de execução depende da função válida. Por exemplo,
se a função funciona em tempo O(n) , o tempo de execução é O(n log z).
Exemplo Considere um problema em que nossa tarefa é processar k jobs usando n máquinas.
Cada máquina i recebe um inteiro pi : o tempo para processar um único trabalho. Qual é o tempo
mínimo para processar todos os trabalhos?
Por exemplo, suponha que k = 8, n = 3 e os tempos de processamento sejam p1 = 2, p2 = 3 e
p3 = 7. Nesse caso, o tempo total mínimo de processamento é 9, seguindo o cronograma da Fig.
4.16 .
Seja valid(x) uma função que descobre se é possível processar todos os jobs usando no
máximo x unidades de tempo. Em nosso cenário de exemplo, claramente valid(9) é verdadeiro,
porque podemos seguir o cronograma da Fig. 4.16. Por outro lado, valid(8) deve ser false, pois o
tempo mínimo de processamento é 9.
Calcular o valor de valid(x) é fácil, porque cada máquina i pode processar no máximo x/ pi
trabalhos em x unidades de tempo. Assim, se a soma de todos os valores de x/ pi for k ou mais, x
é uma solução válida. Então, podemos usar a busca binária para encontrar o valor mínimo de x
para o qual valid(x) é verdadeiro.
Quão eficiente é o algoritmo resultante? A função valid leva tempo O(n) , então o algoritmo
funciona em tempo O(n log z), onde z é um limite superior para a resposta.
Um valor possível para z é kp1 que corresponde a uma solução onde apenas a primeira máquina
é usada para processar todos os trabalhos. Este é certamente um limite superior válido.
Machine Translated by Google
Estruturas de dados
5
Este capítulo apresenta as estruturas de dados mais importantes da biblioteca padrão C++. Na
programação competitiva, é crucial saber quais estruturas de dados estão disponíveis na biblioteca
padrão e como usá-las. Isso geralmente economiza uma grande quantidade de tempo ao implementar
um algoritmo.
A Seção 5.1 descreve primeiro a estrutura vetorial que é uma matriz dinâmica eficiente.
Depois disso, focaremos no uso de iteradores e intervalos com estruturas de dados e discutiremos
brevemente deques, pilhas e filas.
A Seção 5.2 discute conjuntos, mapas e filas de prioridade. Essas estruturas de dados são
frequentemente usadas como blocos de construção de algoritmos eficientes, pois nos permitem
manter estruturas dinâmicas que suportam buscas e atualizações eficientes.
A seção 5.3 mostra alguns resultados sobre a eficiência das estruturas de dados na prática.
Como veremos, existem diferenças importantes de desempenho que não podem ser detectadas
apenas observando as complexidades de tempo.
Em C++, arrays comuns são estruturas de tamanho fixo e não é possível alterar o tamanho de um
array após criá-lo. Por exemplo, o código a seguir cria uma matriz que contém n valores inteiros:
int array[n];
Um array dinâmico é um array cujo tamanho pode ser alterado durante a execução do programa.
A biblioteca padrão C++ fornece vários arrays dinâmicos, sendo o mais útil deles a estrutura vetorial.
52 5 Estruturas de Dados
5.1.1 Vetores
Um vetor é um array dinâmico que nos permite adicionar e remover elementos com eficiência
no final da estrutura. Por exemplo, o código a seguir cria um vetor vazio
e adiciona três elementos a ele:
vetor<int> v;
v.push_back(3); [3]
v.push_back(2); // // //[3,2]
v.push_back(5); // [3,2,5]
vetor<int> v = {2,4,2,5,1};
for (auto x : v) {
cout << x << "\n";
}
vetor<int> v = {2,4,2,5,1};
cout << v.back() << "\n"; v.pop_back(); // 1
Os vetores são implementados para que as operações push_back e pop_back funcionem em tempo
O(1) em média. Na prática, usar um vetor é quase tão rápido quanto usar um array comum.
Um iterador é uma variável que aponta para um elemento de uma estrutura de dados. O início do iterador
aponta para o primeiro elemento de uma estrutura de dados e o final do iterador aponta para a posição
após o último elemento. Por exemplo, a situação pode ter a seguinte aparência em um vetor v que consiste
em oito elementos:
[ 5, 2, 3, 1, 2, 5, 7, 1 ] ÿ
ÿ v.end()
v.começar()
Observe a assimetria nos iteradores: begin() aponta para um elemento na estrutura de dados, enquanto
end() aponta para fora da estrutura de dados.
Um intervalo é uma sequência de elementos consecutivos em uma estrutura de dados. A maneira
usual de especificar um intervalo é fornecer iteradores para seu primeiro elemento e a posição após seu
último elemento. Em particular, os iteradores begin() e end() definem um intervalo que contém todos os
elementos em uma estrutura de dados.
As funções da biblioteca padrão C++ normalmente operam com intervalos. Por exemplo, o código a
seguir primeiro classifica um vetor, depois inverte a ordem de seus elementos e, finalmente, embaralha
seus elementos.
sort(v.begin(),v.end());
reverse(v.begin(),v.end());
random_shuffle(v.begin(),v.end());
O elemento para o qual um iterador aponta pode ser acessado usando a sintaxe *. Por
Por exemplo, o código a seguir imprime o primeiro elemento de um vetor:
Para dar um exemplo mais útil, lower_bound fornece um iterador para o primeiro elemento em um
intervalo classificado cujo valor é pelo menos x, e upper_bound fornece um iterador para o primeiro
elemento cujo valor é maior que x:
Observe que as funções acima só funcionam corretamente quando o intervalo fornecido é classificado.
As funções usam busca binária e encontram o elemento solicitado em tempo logarítmico.
Machine Translated by Google
54 5 Estruturas de Dados
Se não houver tal elemento, as funções retornam um iterador para o elemento após o
último elemento do intervalo.
A biblioteca padrão C++ contém um grande número de funções úteis que são
vale a pena explorar. Por exemplo, o código a seguir cria um vetor que contém o
elementos únicos do vetor original em uma ordem ordenada:
sort(v.begin(),v.end());
v.erase(unique(v.begin(),v.end()),v.end());
Um deque é um array dinâmico que pode ser manipulado eficientemente em ambas as extremidades do
a estrutura. Como um vetor, um deque fornece as funções push_back e
pop_back, mas também fornece as funções push_front e pop_front
que não estão disponíveis em um vetor. Um deque pode ser usado da seguinte forma:
deque<int> d;
d.push_back(5); // [5]
d.push_back(2); // [5,2]
d.push_front(3); // [3,5,2]
// [3,5]
d.pop_back(); d.pop_front();
// [5]
pilha<int> s;
s.push(2); [2]
s.push(5); cout // // //[2,5]
<< s.top() << "\n"; s.pop(); cout << s.top() // 5
<< "\n"; // [2]
// 2
fila<int> q;
q.push(2); [2]
q.push(5); cout // // //[2,5]
<< q.front() << "\n"; q.pop(); cout << q.back() // 2
<< "\n"; // [5]
// 5
Um conjunto é uma estrutura de dados que mantém uma coleção de elementos. As operações básicas
de conjuntos são inserção, pesquisa e remoção de elementos. Os conjuntos são implementados para que todos
as operações acima são eficientes, o que muitas vezes nos permite melhorar os tempos de execução
de algoritmos usando conjuntos.
• set é baseado em uma árvore de busca binária balanceada e suas operações funcionam em O(log n)
Tempo.
Ambas as estruturas são eficientes e, muitas vezes, qualquer uma delas pode ser usada. Já que são
usado da mesma maneira, nos concentramos na estrutura do conjunto nos exemplos a seguir.
O código a seguir cria um conjunto que contém inteiros e mostra alguns de seus
operações. A inserção de função adiciona um elemento ao conjunto, a contagem da função
retorna o número de ocorrências de um elemento no conjunto, e a função erase
remove um elemento do conjunto.
1A complexidade de tempo do pior caso das operações é O(n), mas é muito improvável que isso ocorra.
Machine Translated by Google
56 5 Estruturas de Dados
set<int> s;
s.inserir(3);
s.inserir(2);
s.inserir(5);
cout << s.count(3) << "\n"; cout << s.count(4) // 1
<< "\n"; s.apagar(3); // 0
s.inserir(4);
cout << s.count(3) << "\n"; // 0
cout << s.count(4) << "\n"; // 1
Uma propriedade importante dos conjuntos é que todos os seus elementos são distintos. Assim, o
função contagem sempre retorna 0 (o elemento não está no conjunto) ou 1 (o
elemento está no conjunto), e a função insert nunca adiciona um elemento ao conjunto se
já está lá. O código a seguir ilustra isso:
set<int> s;
s.inserir(3);
s.inserir(3);
s.inserir(3);
cout << s.count(3) << "\n"; // 1
Um conjunto pode ser usado principalmente como um vetor, mas não é possível acessar os elementos
usando a notação []. O código a seguir imprime o número de elementos em um conjunto e
então itera pelos elementos:
A função find(x) retorna um iterador que aponta para um elemento cujo valor
é x. No entanto, se o conjunto não contiver x, o iterador será end().
auto it = s.find(x);
if (it == s.end()) {
// x é não encontrado
}
Conjuntos Ordenados A principal diferença entre as duas estruturas de conjuntos C++ é que o conjunto
é ordenado, enquanto unordered_set não é. Assim, se quisermos manter a ordem
dos elementos, temos que usar a estrutura do conjunto.
Por exemplo, considere o problema de encontrar o menor e o maior valor em um
definir. Para fazer isso de forma eficiente, precisamos usar a estrutura do conjunto. Como os elementos são
ordenados, podemos encontrar o menor e o maior valor da seguinte forma:
Machine Translated by Google
Observe que como end() aponta para um elemento após o último elemento, temos que
diminua o iterador em um.
A estrutura set também fornece as funções lower_bound(x) e
upper_bound(x) que retorna um iterador para o menor elemento em um conjunto cujo
valor é pelo menos ou maior que x, respectivamente. Em ambas as funções, se o pedido
elemento não existe, o valor de retorno é end().
Multiconjuntos Um multiconjunto é um conjunto que pode ter várias cópias do mesmo valor. C++ tem
as estruturas multiset e unordered_multiset que se assemelham a set e
unordered_set. Por exemplo, o código a seguir adiciona três cópias do valor
5 para um multiconjunto.
multiconjunto<int> s;
s.inserir(5);
s.inserir(5);
s.inserir(5);
cout << s.count(5) << "\n"; // 3
s.apagar(5);
cout << s.count(5) << "\n"; // 0
Muitas vezes, apenas um valor deve ser removido, o que pode ser feito da seguinte forma:
s.erase(s.find(5));
cout << s.count(5) << "\n"; // 2
Observe que as funções contar e apagar têm um fator O(k) adicional onde
k é o número de elementos contados/removidos. Em particular, não é eficiente contar
o número de cópias de um valor em um multiset usando a função de contagem.
5.2.2 Mapas
Um mapa é um conjunto que consiste em pares de valores-chave. Um mapa também pode ser visto
como uma matriz generalizada. Enquanto as chaves em um array comum são sempre inteiros consecutivos
0, 1,..., n ÿ 1, onde n é o tamanho do array, as chaves em um mapa podem ser de qualquer dado
tipo e eles não precisam ser valores consecutivos.
Machine Translated by Google
58 5 Estruturas de Dados
A biblioteca padrão C++ contém duas estruturas de mapa que correspondem ao conjunto
estruturas: o mapa é baseado em uma árvore de busca binária balanceada e elementos de acesso
leva tempo O(log n), enquanto unordered_map usa hashing e elementos de acesso
leva tempo O(1) em média.
O código a seguir cria um mapa cujas chaves são strings e os valores são inteiros:
mapa<string,int> m;
m["macaco"] = 4;
m["banana"] = 3;
m["cravo"] = 9;
cout << m["banana"] << "\n"; // 3
Se o valor de uma chave for solicitado, mas o mapa não o contiver, a chave será
adicionados automaticamente ao mapa com um valor padrão. Por exemplo, no seguinte
código, a chave “aybabtu” com valor 0 é adicionada ao mapa.
mapa<string,int> m;
cout << m["aybabtu"] << "\n"; // 0
if (m.count("aybabtu")) {
// chave existe
}
for (auto x : m) {
""
cout << x.primeiro << << x.segundo << "\n";
}
Por padrão, os elementos em uma fila de prioridade C++ são classificados em ordem decrescente e é
possível localizar e remover o maior elemento da fila. O código a seguir ilustra isso:
priority_queue<int> q; q.push(3);
q.push(5); q.push(7); q.push(2);
cout << q.top() << "\n"; q.pop();
cout << q.top() << "\n"; q.pop();
q.push(6); cout << q.top() << "\n";
q.pop(); // 7
// 5
// 6
priority_queue<int,vector<int>,maior<int>> q;
O compilador g++ também fornece algumas estruturas de dados que não fazem parte da biblioteca padrão
C++. Essas estruturas são chamadas de estruturas baseadas em políticas . Para usar essas estruturas, as
seguintes linhas devem ser adicionadas ao código:
Depois disso, podemos definir uma estrutura de dados indexed_set que é como set, mas pode
ser indexado como um array. A definição para valores int é a seguinte:
indexed_set s;
s.inserir(2); s.inserir(3);
s.inserir(7); s.inserir(9);
Machine Translated by Google
60 5 Estruturas de Dados
A especialidade deste conjunto é que temos acesso aos índices que os elementos
teria em uma matriz ordenada. A função find_by_order retorna um iterador para
o elemento em uma determinada posição:
auto x = s.find_by_order(2);
cout << *x << "\n"; // 7
5.3 Experimentos
Muitos problemas podem ser resolvidos usando conjuntos ou classificação. É importante perceber
que algoritmos que usam ordenação são geralmente muito mais rápidos, mesmo que isso não seja evidente por
apenas olhando para as complexidades do tempo.
Como exemplo, considere o problema de calcular o número de elementos únicos
em um vetor. Uma maneira de resolver o problema é adicionar todos os elementos a um conjunto e retornar
o tamanho do conjunto. Como não é necessário manter a ordem dos elementos,
pode usar um conjunto ou um unordered_set. Então, outra forma de resolver o
O problema é primeiro ordenar o vetor e depois passar por seus elementos. É fácil contar
o número de elementos únicos após a classificação do vetor.
A Tabela 5.1 mostra os resultados de um experimento em que os algoritmos acima foram
testado usando vetores aleatórios de valores int. Acontece que o unordered_set
Machine Translated by Google
5.3 Experimentos 61
Tabela 5.1 Os resultados de um experimento onde o número de elementos únicos em um vetor foi
calculado. Os dois primeiros algoritmos inserem os elementos em uma estrutura de conjunto, enquanto o último algoritmo
classifica o vetor e inspeciona elementos consecutivos
Tabela 5.2 Os resultados de um experimento onde foi determinado o valor mais frequente em um vetor.
Os dois primeiros algoritmos usam estruturas de mapa e o último algoritmo usa uma matriz comum
algoritmo é cerca de duas vezes mais rápido que o algoritmo definido, e o algoritmo de ordenação
é mais de dez vezes mais rápido que o setalgoritmo. Observe que tanto o setalgoritmo
e o algoritmo de ordenação funciona em tempo O(n log n); ainda o último é muito mais rápido. o
razão para isso é que a ordenação é uma operação simples, enquanto a busca binária balanceada
árvore usada em conjunto é uma estrutura de dados complexa.
Os mapas são estruturas convenientes em comparação com os arrays, porque qualquer índice pode ser usado,
mas eles também têm grandes fatores constantes. Em nosso próximo experimento, criamos um vetor
de n inteiros aleatórios entre 1 e 106 e então determinou o valor mais frequente
contando o número de cada elemento. Primeiro usamos mapas, mas desde a parte superior
bound 106 é bem pequeno, também pudemos usar arrays.
A Tabela 5.2 mostra os resultados do experimento. Enquanto unordered_map é sobre
três vezes mais rápido que map, um array é quase cem vezes mais rápido. Assim, matrizes
devem ser usados sempre que possível em vez de mapas. Especialmente, note que enquanto
unordered_map fornece operações de tempo O(1), existem grandes fatores constantes
escondidos na estrutura de dados.
Machine Translated by Google
62 5 Estruturas de Dados
Tabela 5.3 Os resultados de um experimento onde os elementos foram adicionados e removidos usando um multiset
e uma fila prioritária
As filas de prioridade são realmente mais rápidas que os multisets? Para descobrir isso, realizamos
outro experimento onde criamos dois vetores de n números int aleatórios. Primeiro,
adicionamos todos os elementos do primeiro vetor a uma estrutura de dados. Em seguida, passamos pelo
segundo vetor e removeu repetidamente o menor elemento da estrutura de dados
e adicionei o novo elemento a ele.
Programaçao dinamica
6
A programação dinâmica é uma técnica de projeto de algoritmo que pode ser usada para encontrar
soluções ótimas para problemas e contar o número de soluções. Este capítulo é uma introdução à
programação dinâmica, e a técnica será usada muitas vezes mais adiante no livro ao projetar
algoritmos.
A Seção 6.1 discute os elementos básicos da programação dinâmica no contexto de um problema
de troca de moedas. Neste problema, recebemos um conjunto de valores de moedas e nossa tarefa
é construir uma soma de dinheiro usando o menor número possível de moedas. Existe um algoritmo
guloso simples para o problema, mas, como veremos, nem sempre produz uma solução ótima. No
entanto, usando programação dinâmica, podemos criar um algoritmo eficiente que sempre encontra
uma solução ótima.
A Seção 6.2 apresenta uma seleção de problemas que mostram algumas das possibilidades da
programação dinâmica. Os problemas incluem determinar a subsequência crescente mais longa em
uma matriz, encontrar um caminho ótimo em uma grade bidimensional e gerar todas as somas de
peso possíveis em um problema de mochila.
Suponha que recebemos um conjunto de valores de moedas moedas = {c1, c2,..., ck } e uma soma
alvo de dinheiro n, e nos pedem para construir a soma n usando o menor número de moedas possível
64 6 Programação Dinâmica
possível. Não há restrições sobre quantas vezes podemos usar cada valor de moeda. Por
exemplo, se moedas = {1, 2, 5} e n = 12, a solução ótima é 5 + 5 + 2 = 12, o que requer três
moedas.
Existe um algoritmo ganancioso natural para resolver o problema: sempre selecione a
maior moeda possível para que a soma dos valores das moedas não exceda a soma alvo.
Por exemplo, se n = 12, primeiro selecionamos duas moedas de valor 5 e depois uma moeda
de valor 2, o que completa a solução. Isso parece uma estratégia razoável, mas é sempre
ideal?
Acontece que essa estratégia nem sempre funciona. Por exemplo, se moedas = {1, 3, 4}
e n = 6, a solução ótima tem apenas duas moedas (3 + 3 = 6), mas a estratégia gananciosa
produz uma solução com três moedas (4 + 1 + 1 = 6 ). Este simples contra-exemplo mostra
que o algoritmo guloso não está correto.1 Como poderíamos resolver o problema, então? É
claro que poderíamos tentar encontrar outro algoritmo ganancioso, mas não há outras
estratégias óbvias que possamos considerar.
Outra possibilidade seria criar um algoritmo de força bruta que passasse por todas as formas
possíveis de selecionar moedas. Tal algoritmo certamente daria resultados corretos, mas
seria muito lento em grandes entradas.
No entanto, usando programação dinâmica, podemos criar um algoritmo que é quase
como um algoritmo de força bruta, mas também é eficiente. Assim, podemos ter certeza de
que o algoritmo está correto e usá-lo para processar grandes entradas. Além disso, podemos
usar a mesma técnica para resolver um grande número de outros problemas.
Para usar programação dinâmica, devemos formular o problema recursivamente para que a
solução do problema possa ser calculada a partir de soluções para subproblemas menores.
No problema da moeda, um problema recursivo natural é calcular os valores de uma função
solve(x): qual é o número mínimo de moedas necessário para formar uma soma x?
Claramente, os valores da função dependem dos valores das moedas. Por exemplo, se
moedas = {1, 3, 4}, os primeiros valores da função são os seguintes:
resolve(0) = 0
resolve(1) = 1
resolve(2) = 2
resolve(3) = 1
resolve(4) = 1
resolve(5) = 2
resolve(6) = 2
resolve(7) = 2
resolve( 8) = 2
resolver(9) = 3
resolver(10) = 3
1É uma questão interessante quando exatamente o algoritmo guloso funciona. Pearson [24] descreve
um algoritmo eficiente para testar isso.
Machine Translated by Google
Por exemplo, solve(10) = 3, porque são necessárias pelo menos 3 moedas para formar
a soma 10. A solução ótima é 3 + 3 + 4 = 10.
A propriedade essencial de solve é que seus valores podem ser calculados recursivamente
a partir de seus valores menores. A ideia é focar na primeira moeda que escolhemos para
a soma. Por exemplo, no cenário acima, a primeira moeda pode ser 1, 3 ou 4. Se primeiro
escolhermos a moeda 1, a tarefa restante é formar a soma 9 usando o número mínimo de
moedas, que é um subproblema do original problema. Claro, o mesmo se aplica às moedas
3 e 4. Assim, podemos usar a seguinte fórmula recursiva para calcular o número mínimo de
moedas:
solve(x) = min(solve(x ÿ 1) + 1,
solve(x ÿ 3) + 1,
solve(x ÿ 4) + 1).
O caso base da recursão é solve(0) = 0, porque nenhuma moeda é necessária para formar
uma soma vazia. Por exemplo,
Agora estamos prontos para fornecer uma função recursiva geral que calcula o mínimo
número de moedas necessárias para formar uma soma x:
ÿÿ x<0
resolver(x) = 0 x=0
ÿÿ
Primeiro, se x < 0, o valor é infinito, pois é impossível formar uma soma negativa de
dinheiro. Então, se x = 0, o valor é zero, porque nenhuma moeda é necessária para formar
uma soma vazia. Finalmente, se x > 0, a variável c passa por todas as possibilidades de
como escolher a primeira moeda da soma.
Uma vez encontrada uma função recursiva que resolve o problema, podemos
implemente uma solução em C++ (a constante INF denota infinito):
} retornar melhor;
}
Ainda assim, essa função não é eficiente, porque pode haver um grande número de
maneiras de construir a soma e a função verifica todas elas. Felizmente, verifica-se que
existe uma maneira simples de tornar a função eficiente.
Machine Translated by Google
66 6 Programação Dinâmica
Memoização A ideia chave na programação dinâmica é a memoização, o que significa que armazenamos
cada valor de função em um array diretamente após calculá-lo. Então, quando o valor for necessário
novamente, ele poderá ser recuperado do array sem chamadas recursivas.
Para fazer isso, criamos arrays
onde ready[x] indica se o valor de solve(x) foi calculado e, se for, value[x] contém esse valor. A constante
N foi escolhida para que todos os valores requeridos caibam nas matrizes.
Depois disso, a função pode ser implementada de forma eficiente da seguinte forma:
}
pronto[x] = verdadeiro;
valor[x] = melhor;
retornar melhor;
}
A função trata os casos base x < 0 e x = 0 como anteriormente. Em seguida, ele verifica de ready[x] se
solve(x) já foi armazenado em value[x], e se estiver, a função o retorna diretamente. Caso contrário, a
função calcula o valor de solve(x) recursivamente e o armazena em value[x].
Esta função funciona de forma eficiente, porque a resposta para cada parâmetro x é calculada
recursivamente apenas uma vez. Após um valor de solve(x) ter sido armazenado em value[x], ele pode ser
recuperado com eficiência sempre que a função for chamada novamente com o parâmetro x. A
complexidade de tempo do algoritmo é O(nk), onde n é a soma alvo e k é o número de moedas.
Implementação iterativa Observe que também podemos construir iterativamente o valor do array usando
um loop da seguinte forma:
}
}
}
Machine Translated by Google
Construindo uma solução Às vezes nos pedem para encontrar o valor de uma solução ótima e dar
um exemplo de como tal solução pode ser construída. Para construir uma solução ótima em nosso
problema de moedas, podemos declarar uma nova matriz que indica para cada soma de dinheiro a
primeira moeda em uma solução ótima:
int primeiro[N];
}
}
}
Depois disso, o código a seguir imprime as moedas que aparecem em uma solução ótima para a
soma n:
Vamos agora considerar outra variante do problema da moeda, onde nossa tarefa é calcular o
número total de maneiras de produzir uma soma x usando as moedas. Por exemplo, se moedas = {1,
3, 4} e x = 5, há um total de 6 maneiras:
•1+1+1+1+1•1+1 •3+1+1•1
+3•1+3+1 +4•4+1
68 6 Programação Dinâmica
ÿ0 x<0
resolver(x) = 1 x=0
ÿÿ
Se x < 0, o valor é zero, pois não há soluções. Se x = 0, o valor é um, porque há apenas
uma maneira de formar uma soma vazia. Caso contrário, calculamos a soma de todos os
valores da forma solve(x ÿ c) onde c está em moedas.
O código a seguir constrói uma contagem de matriz de forma que count[x] seja igual a
valor de solve(x) para 0 ÿ x ÿ n:
contagem[0] = 1; for
(int x = 1; x <= n; x++) { for (auto c: moedas) { if
(xc >= 0) {
contagem[x] += contagem[xc];
}
}
}
Muitas vezes o número de soluções é tão grande que não é necessário calcular o número
exato, mas basta dar a resposta módulo m onde, por exemplo, m = 109 + 7. Isso pode ser feito
alterando o código para que todos os cálculos são feitos módulo m. No código acima, basta
adicionar a linha
contagem[x] %= m;
depois da linha
contagem[x] += contagem[xc];
Depois de ter discutido os conceitos básicos de programação dinâmica, agora estamos prontos
para passar por um conjunto de problemas que podem ser resolvidos de forma eficiente
usando programação dinâmica. Como veremos, a programação dinâmica é uma técnica
versátil que tem muitas aplicações no projeto de algoritmos.
Machine Translated by Google
comprimento(0) = 1
comprimento(1) = 1
comprimento(2) = 2
comprimento(3) = 1
comprimento(4) = 3
comprimento(5) = 2
comprimento(6) = 4
comprimento(7) = 2
Como todos os valores da função podem ser calculados a partir de seus valores menores,
podemos usar a programação dinâmica para calcular os valores. No código a seguir, os valores da
função serão armazenados em um tamanho de matriz.
70 6 Programação Dinâmica
Nosso próximo problema é encontrar um caminho do canto superior esquerdo ao canto inferior
direito de uma grade n × n , com a restrição de que só podemos nos mover para baixo e para a
direita. Cada quadrado contém um número inteiro e o caminho deve ser construído de forma que a
soma dos valores ao longo do caminho seja a maior possível.
Como exemplo, a Fig. 6.2 mostra um caminho ótimo em uma grade 5 × 5. A soma dos valores
no caminho é 67, e essa é a maior soma possível em um caminho do canto superior esquerdo ao
canto inferior direito.
Suponha que as linhas e colunas da grade sejam numeradas de 1 a n, e valor[y][x] seja igual ao
valor do quadrado (y, x). Seja soma(y, x) a soma máxima em um caminho do canto superior
esquerdo até o quadrado (y, x). Então, sum(n, n) nos diz a soma máxima do canto superior esquerdo
ao canto inferior direito. Por exemplo, na grade acima, sum(5, 5) = 67. Agora podemos usar a
fórmula
que se baseia na observação de que um caminho que termina no quadrado (y, x) pode vir do
quadrado (y, x ÿ 1) ou do quadrado (y ÿ 1, x) (Fig. 6.3). Assim, selecionamos a direção que maximiza
a soma. Assumimos que soma(y, x) = 0 se y = 0 ou x = 0, então a fórmula recursiva também
funciona para os quadrados mais à esquerda e mais acima.
Como a função soma tem dois parâmetros, a matriz de programação dinâmica também
tem duas dimensões. Por exemplo, podemos usar um array
2Neste problema, também é possível calcular os valores de programação dinâmica de forma mais eficiente em tempo
O(n log n). Você pode encontrar uma maneira de fazer isso?
Machine Translated by Google
int soma[N][N];
O termo mochila refere-se a problemas em que um conjunto de objetos é fornecido e subconjuntos com
algumas propriedades devem ser encontrados. Problemas de mochila muitas vezes podem ser resolvidos
usando programação dinâmica.
Nesta seção, focamos no seguinte problema: Dada uma lista de pesos [w1,w2,..., w], determine
todas as somas que podem ser construídas usando os pesos.
Por exemplo, a Fig. 6.4 mostra as somas possíveis para pesos [1, 3, 3, 5]. Neste caso, todas as
somas entre 0 ... 12 são possíveis,
escolherexceto 2 e 10.
os pesos [1, Por
3, 3].exemplo, a soma 7 é possível porque podemos
Para resolver o problema, focamos em subproblemas onde usamos apenas os primeiros k pesos
para construir somas. Seja possível(x, k) = verdadeiro se pudermos construir uma soma x usando os
primeiros k pesos e, caso contrário, possível(x, k) = falso. Os valores da função podem ser calculados
recursivamente usando a fórmula
que se baseia no fato de que podemos usar ou não o peso wk na soma. Se usarmos wk , a tarefa
restante é formar a soma x ÿ wkéusando
restante formar os primeiros
a soma k ÿ 1 os
x usando pesos, e se não
primeiros k ÿ 1usarmos wk casos
pesos. Os , a tarefa
básicos
são
verdadeiro x = 0
possível(x, 0) =
falso x = 0,
porque se nenhum peso for usado, só podemos formar a soma 0. Finalmente,possible(x, n) nos diz
se podemos construir uma soma x usando todos os pesos.
72 6 Programação Dinâmica
A Figura 6.5 mostra todos os valores da função para os pesos [1, 3, 3, 5] (o símbolo “ ”
indica os valores verdadeiros). Por exemplo, a linha k = 2 nos diz que podemos construir as
somas [0, 1, 3, 4] usando os pesos [1, 3].
Seja m a soma total dos pesos. A seguinte dinâmica de tempo O(nm)
solução de programação corresponde à função recursiva:
Acontece que também existe uma forma mais compacta de implementar o cálculo de
programação dinâmica, usando apenas um array unidimensional possível[x] que indica se
podemos construir um subconjunto com soma x. O truque é atualizar o array da direita para
a esquerda para cada novo peso:
}
}
Observe que a ideia geral de programação dinâmica apresentada nesta seção também
pode ser usada em outros problemas da mochila, como em uma situação em que objetos
têm pesos e valores e temos que encontrar um subconjunto de valor máximo cujo peso não
exceda um determinado limite.
Usando programação dinâmica, muitas vezes é possível alterar uma iteração sobre por
mutações em uma iteração sobre subconjuntos. A vantagem disso é que n!, o número de
Machine Translated by Google
permutações, é muito maior do que 2n, o número de subconjuntos. Por exemplo, se n = 20, n! ÿ
2,4 · 1018 e 2n ÿ 106. Assim, para certos valores de n, podemos passar eficientemente pelos
subconjuntos, mas não pelas permutações.
Como exemplo, considere o seguinte problema: há um elevador com peso máximo x, e n
pessoas que querem ir do térreo ao último andar.
As pessoas são numeradas 0, 1,..., n ÿ 1, e o peso da pessoa i é peso[i].
Qual é o número mínimo de passeios necessários para levar todos ao último andar?
Por exemplo, suponha que x = 12, n = 5 e os pesos sejam os seguintes:
• peso[0] = 2 • peso[1]
= 3 • peso[2] = 4 •
peso[3] = 5 • peso[4]
=9
Nesse cenário, o número mínimo de passeios é dois. Uma solução ótima é a seguinte: primeiro,
as pessoas 0, 2 e 3 pegam o elevador (peso total 11) e, em seguida, as pessoas 1 e 4 pegam o
elevador (peso total 12).
O problema pode ser facilmente resolvido em tempo O(n!n) testando todas as permutações
possíveis de n pessoas. No entanto, podemos usar programação dinâmica para criar um algoritmo
de tempo O(2nn) mais eficiente . A ideia é calcular para cada subconjunto de pessoas dois valores:
o número mínimo de corridas necessárias e o peso mínimo de pessoas que pedalam no último
grupo.
Seja passeio(S) o número mínimo de viagens para um subconjunto S, e last(S) denote o peso
mínimo da última viagem em uma solução onde o número de viagens é mínimo. Por exemplo, no
cenário acima
porque a maneira ideal para as pessoas 3 e 4 chegarem ao último andar é que elas façam dois
passeios separados e a pessoa 4 vá primeiro, o que minimiza o peso do segundo passeio. Claro,
nosso objetivo final é calcular o valor de passeios({0 ... n ÿ 1}).
Podemos calcular os valores das funções recursivamente e depois aplicar a programação
dinâmica. Para calcular os valores de um subconjunto S, passamos por todas as pessoas que
pertencem a S e escolhemos otimamente a última pessoa p que entra no elevador. Cada uma
dessas escolhas produz um subproblema para um subconjunto menor de pessoas. Se last(S \ p)
+ peso[p] ÿ x, podemos adicionar p ao último passeio. Caso contrário, temos que reservar um novo
passeio que contenha apenas p.
Uma maneira conveniente de implementar o cálculo de programação dinâmica é usar
operações de bits. Primeiro, declaramos um array
par<int,int> melhor[1<<N];
que contém para cada subconjunto S um par (rides(S), last(S)). Para um subconjunto vazio, não
são necessárias viagens:
Machine Translated by Google
74 6 Programação Dinâmica
melhor[0] = {0,0};
opção.segundo += peso[p];
} senão {
// reserva uma nova corrida para p
opção.primeiro++;
opção.segundo = peso[p];
}
melhor[s] = min(melhor[s], opção);
}
}
}
Observe que o laço acima garante que para quaisquer dois subconjuntos S1 e S2 tal
que S1 ÿ S2, processamos S1 antes de S2. Assim, os valores de programação dinâmica são
calculado na ordem correta.
Às vezes, os estados de uma solução de programação dinâmica são mais complexos do que
combinações fixas de valores. Como exemplo, considere o problema de calcular
o número de maneiras distintas de preencher uma grade n × m usando ladrilhos de tamanho 1 × 2 e 2 × 1. Por
Por exemplo, há um total de 781 maneiras de preencher a grade 4 × 7, sendo uma delas a
solução mostrada na Fig. 6.6.
O problema pode ser resolvido usando programação dinâmica passando pelo
grade linha por linha. Cada linha em uma solução pode ser representada como uma string que contém
Machine Translated by Google
•
•
•
•
n/2 m /2
ÿa ÿb
4 · cos2 + cos2 n + 1 m + 1
a=1 b=1
Algoritmos Gráficos
7
Muitos problemas de programação podem ser resolvidos considerando a situação como um grafo e
usando um algoritmo de grafo apropriado. Neste capítulo, aprenderemos o básico sobre gráficos e uma
seleção de algoritmos de gráficos importantes.
A Seção 7.1 discute a terminologia dos gráficos e as estruturas de dados que podem ser usadas
para representar gráficos em algoritmos.
A Seção 7.2 apresenta dois algoritmos fundamentais de travessia de grafos. A busca em
profundidade é uma maneira simples de visitar todos os nós que podem ser alcançados a partir de um
nó inicial, e a busca em largura visita os nós em ordem crescente de distância do nó inicial.
A Seção 7.3 apresenta algoritmos para encontrar caminhos mais curtos em grafos ponderados.
O algoritmo de Bellman-Ford é um algoritmo simples que encontra caminhos mais curtos de um nó
inicial para todos os outros nós. O algoritmo de Dijkstra é um algoritmo mais eficiente que requer que
todos os pesos das arestas sejam não negativos. O algoritmo Floyd-Warshall determina os caminhos
mais curtos entre todos os pares de nós de um grafo.
A Seção 7.4 explora propriedades especiais de grafos acíclicos direcionados. Aprenderemos como
construir uma ordenação topológica e como usar programação dinâmica para processar de forma
eficiente tais grafos.
A Seção 7.5 enfoca os grafos sucessores onde cada nó tem um sucessor único.
Discutiremos uma maneira eficiente de encontrar sucessores de nós e o algoritmo de Floyd para
detecção de ciclos.
A Seção 7.6 apresenta os algoritmos de Kruskal e Prim para construir árvores geradoras mínimas.
O algoritmo de Kruskal é baseado em uma estrutura de união-localização eficiente que também tem
outros usos no projeto de algoritmos.
78 7 Algoritmos Gráficos
Nesta seção, primeiro passamos pela terminologia que é usada ao discutir grafos e suas
propriedades. Depois disso, focamos em estruturas de dados que podem ser usadas para
representar grafos na programação de algoritmos.
Um grafo consiste em nós (também chamados de vértices) que são conectados por arestas. Neste
livro, a variável n denota o número de nós em um grafo e a variável m denota o número de arestas.
Os nós são numerados usando inteiros 1, 2,..., n.
Por exemplo, a Fig. 7.1 mostra um grafo com 5 nós e 7 arestas.
Um caminho leva de um nó a outro nó através das arestas do grafo. O comprimento de um
caminho é o número de arestas nele. Por exemplo, a Fig. 7.2 mostra um caminho 1 ÿ 3 ÿ 4 ÿ 5 de
comprimento 3 do nó 1 ao nó 5. Um ciclo é um caminho onde o primeiro e o último nó são iguais.
Por exemplo, a Fig. 7.3 mostra um ciclo 1 ÿ 3 ÿ 4 ÿ 1.
Um grafo é conectado se houver um caminho entre quaisquer dois nós. Na Fig. 7.4, o grafo da
esquerda está conectado, mas o grafo da direita não está conectado, pois não é possível ir do nó
4 para nenhum outro nó.
As partes conectadas de um grafo são chamadas de seus componentes. Por exemplo, o gráfico
na Fig. 7.5 tem três componentes: {1, 2, 3}, {4, 5, 6, 7} e {8}.
Uma árvore é um grafo conectado que não contém ciclos. A Figura 7.6 mostra um exemplo de
grafo que é uma árvore.
Em um grafo direcionado , as arestas podem ser percorridas em apenas uma direção. A Figura
7.7 mostra um exemplo de gráfico direcionado. Este grafo contém um caminho 3 ÿ 1 ÿ 2 ÿ 5 do nó
3 ao nó 5, mas não há caminho do nó 5 ao nó 3.
Em um grafo ponderado , cada aresta recebe um peso. Os pesos são frequentemente
interpretados como comprimentos de arestas, e o comprimento de um caminho é a soma de seus
pesos de arestas. Por exemplo, o gráfico na Fig. 7.8 é ponderado e o comprimento do caminho 1 ÿ
3 ÿ 4 ÿ 5 é 1 + 7 + 3 = 11. Este é o caminho mais curto do nó 1 ao nó 5.
Dois nós são vizinhos ou adjacentes se houver uma aresta entre eles. O grau de um nó é o
número de seus vizinhos. A Figura 7.9 mostra o grau de cada nó
3 4
3 4
Machine Translated by Google
3 4
3 4 3 4
3 6 7
3 4
3 4
1 6 5
3 4 3
7
Machine Translated by Google
80 7 Algoritmos Gráficos
3 4
2 3
3 4
1/1 3/0
4 5 6
1 2 3
4 5 6
A Figura 7.10 mostra o grau de entrada e de saída de cada nó de um grafo. Por exemplo,
o nó 2 tem grau de entrada 2 e grau de saída 1.
Um grafo é bipartido se for possível colorir seus nós usando duas cores em tal
maneira que nenhum nó adjacente tenha a mesma cor. Acontece que um grafo é bipartido
exatamente quando não possui um ciclo com número ímpar de arestas. Por exemplo,
A Fig. 7.11 mostra um gráfico bipartido e sua coloração.
Existem várias maneiras de representar gráficos em algoritmos. A escolha de uma estrutura de dados
depende do tamanho do gráfico e da forma como o algoritmo o processa. Próximo
passaremos por três representações populares.
26 5
5 7
1 2 3 1 2 3
vetor<int> adj[N];
A constante N é escolhida para que todas as listas de adjacências possam ser armazenadas. Por exemplo,
o gráfico na Fig. 7.12a pode ser armazenado da seguinte forma:
adj[1].push_back(2);
adj[2].push_back(3);
adj[2].push_back(4);
adj[3].push_back(4);
adj[4].push_back(1);
Se o grafo não é direcionado, ele pode ser armazenado de maneira semelhante, mas cada aresta é adicionada
em ambas as direções.
vetor<par<int,int>> adj[N];
adj[1].push_back({2,5});
adj[2].push_back({3,7});
adj[2].push_back({4,6});
adj[3].push_back({4,5});
adj[4].push_back({1,2});
Usando listas de adjacência, podemos encontrar eficientemente os nós para os quais podemos mover
de um dado nó através de uma aresta. Por exemplo, o loop a seguir passa por
todos os nós para os quais podemos passar do nó s:
Matriz de Adjacência Uma matriz de adjacência indica as arestas que um grafo contém.
Podemos verificar eficientemente a partir de uma matriz de adjacência se há uma aresta entre dois
nós. A matriz pode ser armazenada como uma matriz
Machine Translated by Google
82 7 Algoritmos Gráficos
int adj[N][N];
0100
ÿ 0011 ÿ
ÿ ÿ
.
ÿ
0001 ÿ
ÿ 1000 ÿ
0500
ÿ 0076 ÿ
ÿ ÿ
0005 ÿ
ÿ 2000 ÿ
Lista de arestas Uma lista de arestas contém todas as arestas de um gráfico em alguma ordem. Esta é uma
maneira conveniente de representar um grafo se o algoritmo processar todas as suas arestas e não for necessário
encontrar arestas que comecem em um determinado nó.
A lista de arestas pode ser armazenada em um vetor
vetor<par<int,int>> arestas;
onde cada par (a, b) denota que existe uma aresta do nó a ao nó b. Assim, o gráfico da
Fig. 7.12a pode ser representado da seguinte forma:
bordas.push_back({1,2});
bordas.push_back({2,3});
bordas.push_back({2,4});
bordas.push_back({3,4});
bordas.push_back({4,1});
vetor<tuple<int,int,int>> arestas;
Machine Translated by Google
Cada elemento nesta lista é da forma (a, b,w), o que significa que existe uma aresta do nó a
ao nó b com peso w. Por exemplo, o gráfico na Fig. 7.12b pode ser representado da seguinte
forma1:
bordas.push_back({1,2,5});
bordas.push_back({2,3,7});
bordas.push_back({2,4,6});
bordas.push_back({3,4,5});
bordas.push_back({4,1,2});
Esta seção discute dois algoritmos de grafos fundamentais: busca em profundidade e busca
em largura. Ambos os algoritmos recebem um nó inicial no grafo e visitam todos os nós que
podem ser alcançados a partir do nó inicial. A diferença nos algoritmos é a ordem em que eles
visitam os nós.
A Figura 7.13 mostra como a busca em profundidade processa um gráfico. A busca pode
começar em qualquer nó do grafo; neste exemplo começamos a busca no nó 1. Primeiro a
busca explora o caminho 1 ÿ 2 ÿ 3 ÿ 5, então retorna ao nó 1 e visita o nó 4 restante.
vetor<int> adj[N];
1Em alguns compiladores mais antigos, a função make_tuple deve ser usada em vez das chaves (por
exemplo, make_tuple(1,2,5) em vez de {1,2,5}).
Machine Translated by Google
84 7 Algoritmos Gráficos
3 3
4 5 4 5
passo 1 passo 2
1 2 1 2
3 3
4 5 4 5
etapa 3 Passo 4
1 2 1 2
3 3
4 5 4 5
passo 5 passo 6
1 2 1 2
3 3
4 5 4 5
passo 7 passo 8
bool visitado[N];
que mantém o controle dos nós visitados. Inicialmente, cada valor da matriz é falso e, quando
a busca chega ao nó s, o valor de visitado[s] se torna verdadeiro. A função
pode ser implementado da seguinte forma:
void dfs(int s) {
se (visitado[s]) retornar;
visitado[s] = verdadeiro;
// nó de processo s
for (auto u: adj[s]) {
dfs(u);
}
}
4 5 6 4 5 6
passo 1 passo 2
1 2 3 1 2 3
4 5 6 4 5 6
etapa 3 Passo 4
fila<int> q;
bool visitado[N];
int distância[N];
86 7 Algoritmos Gráficos
4 5
visitado[x] = verdadeiro;
distância[x] = 0; q.push(x);
while (!q.vazio()) { int s =
q.front(); q.pop();
// nó de processo s
for (auto u : adj[s]) { if (visited[u])
continue; visitado[u] = verdadeiro;
distância[u] = distância[s]+1; q.push(u);
}
}
7.2.3 Aplicativos
Usando os algoritmos de travessia de grafos, podemos verificar muitas propriedades dos grafos.
Normalmente, tanto a busca em profundidade quanto a busca em largura podem ser usadas,
mas na prática, a busca em profundidade é a melhor escolha, porque é mais fácil de implementar.
Nas aplicações descritas abaixo, assumiremos que o gráfico não é direcionado.
Detecção de Ciclo Um grafo contém um ciclo se, durante uma travessia do grafo, encontrarmos
um nó cujo vizinho (diferente do nó anterior no caminho atual) já foi visitado. Por exemplo, na
Fig. 7.16, uma busca em profundidade a partir do nó 1 revela que o gráfico contém um ciclo.
Após passar do nó 2 para o nó 5, notamos que o vizinho 3 do nó 5 já foi visitado. Assim, o grafo
contém um ciclo que passa pelo nó 3, por exemplo, 3 ÿ 2 ÿ 5 ÿ 3.
Machine Translated by Google
4 5
4 5
Verificação de Bipartite Um grafo é bipartido se seus nós podem ser coloridos usando duas cores
para que não haja nós adjacentes com a mesma cor. É surpreendentemente fácil verificar
se um grafo é bipartido usando algoritmos de travessia de grafos.
A ideia é escolher duas cores X e Y , colorir o nó inicial X, todos os seus vizinhos
S , todos os seus vizinhos X, e assim por diante. Se em algum ponto da busca notamos que
dois nós adjacentes têm a mesma cor, isso significa que o grafo não é bipartido.
Caso contrário, o gráfico é bipartido e uma coloração foi encontrada.
Por exemplo, na Fig. 7.17, uma pesquisa em profundidade do nó 1 mostra que o gráfico é
não bipartido, pois notamos que ambos os nós 2 e 5 devem ter a mesma cor,
enquanto eles são nós adjacentes no grafo.
Este algoritmo sempre funciona, pois quando há apenas duas cores disponíveis,
a cor do nó inicial em um componente determina as cores de todos os outros nós
no componente. Não faz qualquer diferença quais são as cores.
Observe que no caso geral é difícil descobrir se os nós em um grafo podem ser
colorido usando k cores para que nenhum nó adjacente tenha a mesma cor. O problema
já é NP-difícil para k = 3.
Encontrar um caminho mais curto entre dois nós de um grafo é um problema importante que
tem muitas aplicações práticas. Por exemplo, um problema natural relacionado a uma estrada
rede é calcular o menor comprimento possível de uma rota entre duas cidades,
dados os comprimentos das estradas.
Em um grafo não ponderado, o comprimento de um caminho é igual ao número de suas arestas, e
podemos simplesmente usar a busca em largura para encontrar um caminho mais curto. No entanto, nesta seção
Machine Translated by Google
88 7 Algoritmos Gráficos
Fig. 7.18 O 0 ÿ
0 2
2 2
algoritmo de Bellman-Ford 1 2 5 1 2 5
7 7
3 3 5 3 3 5
ÿ ÿ
3 4 2 3 4 2
ÿ2 ÿ2
ÿ ÿ
3 7
passo 1 passo 2
0 2 0 2
2 2
1 2 5 1 2 5
7 7
3 3 5 3 3 5
2 7 2 3
3 4 3 4
ÿ2 ÿ2
3 1 3 1
etapa 3 Passo 4
focamos em grafos ponderados onde algoritmos mais sofisticados são necessários para
encontrar caminhos mais curtos.
O algoritmo de Bellman-Ford encontra caminhos mais curtos de um nó inicial para todos os nós
do grafo. O algoritmo pode processar todos os tipos de gráficos, desde que o gráfico não
contenha um ciclo com comprimento negativo. Se o gráfico contiver um ciclo negativo, o
algoritmo poderá detectá-lo.
O algoritmo acompanha as distâncias do nó inicial até todos os nós do grafo. Inicialmente, a
distância até o nó inicial é 0 e a distância até qualquer outro nó é infinita. O algoritmo então
reduz as distâncias encontrando arestas que encurtam os caminhos até que não seja possível
reduzir nenhuma distância.
A Figura 7.18 mostra como o algoritmo de Bellman-Ford processa um gráfico. Primeiro, o
algoritmo reduz as distâncias usando as arestas 1 ÿ 2, 1 ÿ 3 e 1 ÿ 4, depois usando as arestas
2 ÿ 5 e 3 ÿ 4 e, finalmente, usando a aresta 4 ÿ 5. Depois disso, nenhuma aresta pode ser usada
para reduzir distâncias, o que significa que as distâncias são finais.
5 3 ÿ7
Ciclos negativos O algoritmo de Bellman-Ford também pode ser usado para verificar se o gráfico
contém um ciclo com comprimento negativo. Nesse caso, qualquer caminho que contenha o ciclo
pode ser encurtado infinitas vezes, de modo que o conceito de caminho mais curto não faz sentido.
Por exemplo, o gráfico da Fig. 7.19 contém um ciclo negativo 2 ÿ 3 ÿ 4 ÿ 2 com comprimento ÿ4.
Um ciclo negativo pode ser detectado usando o algoritmo Bellman-Ford executando o algoritmo
por n rodadas. Se a última rodada reduz qualquer distância, o gráfico contém um ciclo negativo.
Observe que esse algoritmo pode ser usado para procurar um ciclo negativo em todo o gráfico,
independentemente do nó inicial.
O algoritmo de Dijkstra encontra caminhos mais curtos do nó inicial para todos os nós do grafo,
como o algoritmo de Bellman-Ford. O benefício do algoritmo de Dijkstra é que ele
Machine Translated by Google
90 7 Algoritmos Gráficos
2 9 5 2 9 5
1
ÿ
1 1
2 1 2 1
5 5
ÿ
0 5 0
passo 1 passo 2
ÿ
3 9 3
6 6
3 4 2 3 4 2
2 9 5 2 9 5
1 1 1 1
2 1 2 1
5 5
5 0 5 0
etapa 3 Passo 4
7 3 7 3
6 6
3 4 2 3 4 2
2 9 5 2 9 5
1 1 1 1
2 1 2 1
5 5
5 0 5 0
passo 5 passo 6
é mais eficiente e pode ser usado para processar gráficos grandes. No entanto, o algoritmo
requer que não haja arestas de peso negativo no grafo.
Como o algoritmo de Bellman-Ford, o algoritmo de Dijkstra mantém distâncias para o
nós e os reduz durante a busca. A cada passo, o algoritmo de Dijkstra seleciona um
nó que ainda não foi processado e cuja distância é a menor possível. Então,
o algoritmo passa por todas as arestas que começam no nó e reduz as distâncias
usando-os. O algoritmo de Dijkstra é eficiente, pois só processa cada aresta em
o gráfico uma vez, usando o fato de que não há arestas negativas.
A Figura 7.20 mostra como o algoritmo de Dijkstra processa um gráfico. Como no
Algoritmo de Bellman-Ford, a distância inicial para todos os nós, exceto para o início
nó, é infinito. O algoritmo processa os nós na ordem 1, 5, 4, 2, 3 e em
cada nó reduz distâncias usando arestas que começam no nó. Observe que a distância
para um nó nunca muda após o processamento do nó.
A ideia é adicionar uma nova instância de um nó à fila de prioridade sempre que sua
distância mudar.
Nossa implementação do algoritmo de Dijkstra calcula as distâncias mínimas de um nó
x para todos os outros nós do grafo. O grafo é armazenado como listas de adjacências de
forma que adj[a] contenha um par (b, w) sempre quando houver uma aresta do nó a ao nó
b com peso w. A fila de prioridade
priority_queue<pair<int,int>> q;
contém pares da forma (ÿd, x), significando que a distância atual até o nó x é d.
A distância da matriz contém a distância para cada nó, e a matriz processada indica se um
nó foi processado.
Observe que a fila de prioridade contém distâncias negativas para os nós. A razão para
isso é que a versão padrão da fila de prioridade C++ encontra o máximo de elementos,
enquanto queremos encontrar o mínimo de elementos. Ao explorar distâncias negativas,
podemos usar diretamente a fila de prioridade padrão.2 Observe também que, embora
possa haver várias instâncias de um nó na fila de prioridade, apenas a instância com a
distância mínima será processada.
A implementação é a seguinte:
} distância[x] = 0;
q.push({0,x}); while (!
q.empty()) { int a = q.top().second;
q.pop(); se (processado[a]) continuar; processado[a]
= verdadeiro; for (auto u : adj[a]) { int b = u.primeiro,
w = u.segundo; if (distância[a]+w < distância[b]) {
2Claro, também podemos declarar a fila de prioridade como na Seção. 5.2.3 e usar distâncias positivas,
mas a implementação seria mais longa.
Machine Translated by Google
92 7 Algoritmos Gráficos
6 3 ÿ5
2 1 1
5
algoritmo pode dar resultados incorretos. Como exemplo, considere o gráfico da Fig. 7.21.
O caminho mais curto do nó 1 ao nó 4 é 1 ÿ 3 ÿ 4 e seu comprimento é 1. No entanto, o
algoritmo de Dijkstra encontra incorretamente o caminho 1 ÿ 2 ÿ 4 seguindo avidamente
as arestas de peso mínimo.
0 5 ÿ 9 1 502 ÿ ÿ ÿ
ÿ 207 ÿ 9 ÿ 702 1 ÿ ÿ 2 0 ÿ
ÿ ÿ
ÿ ÿ
ÿ ÿ
ÿ ÿ
ÿ ÿ
0 5 ÿ 9 1 502 14 6
ÿ ÿ
ÿ ÿ
ÿ
ÿ 207 ÿ ÿ
9 14 702 ÿ
ÿ 16ÿ20 ÿ
Machine Translated by Google
2 9 5
2 1 1
5
05791
ÿ 5 0 2 14 6 7 ÿ
ÿ ÿ
ÿ
207 8 ÿ
9 14 7 0 2 ÿ
ÿ16820 ÿ
O algoritmo continua assim, até que todos os nós tenham sido nomeados nós
intermediários. Após a conclusão do algoritmo, a matriz contém as distâncias
mínimas entre quaisquer dois nós:
05731
ÿ 50286 72078 ÿ
ÿ ÿ
ÿ ÿ
ÿ ÿ
38702 ÿ
ÿ 16820 ÿ
Por exemplo, a matriz nos diz que a distância mais curta entre os nós 2 e 4 é
8. Isso corresponde ao caminho da Fig. 7.23.
Implementação O algoritmo Floyd-Warshall é particularmente fácil de implementar.
A implementação abaixo constrói uma matriz de distância onde dist[a][b] denota
a distância mais curta entre os nós a e b. Primeiro, o algoritmo inicializa dist
usando a matriz de adjacência adj do gráfico:
}
}
Depois disso, as distâncias mais curtas podem ser encontradas da seguinte forma:
Machine Translated by Google
94 7 Algoritmos Gráficos
4 5 6
4 1 5 2 3 6
Uma classe importante de grafos são os grafos acíclicos direcionados, também chamados de DAGs. Tal
grafos não contêm ciclos, e muitos problemas são mais fáceis de resolver se assumirmos
que este é o caso. Em particular, sempre podemos construir uma ordenação topológica para o
gráfico e, em seguida, aplicar programação dinâmica.
Uma ordenação topológica é uma ordenação dos nós de um grafo direcionado tal que se houver
é um caminho do nó a para o nó b, então o nó a aparece antes do nó b na ordenação.
Por exemplo, na Fig. 7.24, uma possível ordenação topológica é [4, 1, 5, 2, 3, 6].
Um grafo direcionado tem uma ordenação topológica exatamente quando é acíclico. Se o gráfico
contém um ciclo, não é possível formar uma ordenação topológica, pois nenhum nó do
ciclo pode aparecer antes dos outros nós do ciclo na ordenação. Acontece que
A pesquisa em profundidade pode ser usada para verificar se um grafo direcionado contém um ciclo e,
se não, para construir uma ordenação topológica.
Machine Translated by Google
4 5 6
4 5 6
96 7 Algoritmos Gráficos
4 5 6
Usando programação dinâmica, podemos responder com eficiência a muitas perguntas sobre caminhos
em grafos acíclicos direcionados. Exemplos de tais perguntas são:
Observe que muitos dos problemas acima são difíceis de resolver ou não são bem definidos
para gráficos gerais.
Como exemplo, considere o problema de calcular o número de caminhos do nó a ao nó b. Seja
paths(x) o número de caminhos do nó a ao nó x. Como caso base, paths(a) = 1. Então, para calcular
outros valores de paths(x), podemos usar a fórmula recursiva
onde s1,s2,...,sk são os nós a partir dos quais existe uma aresta para x. Como o gráfico é acíclico, os
valores dos caminhos podem ser calculados na ordem de uma ordenação topológica.
A Figura 7.29 mostra os valores dos caminhos em um cenário de exemplo onde queremos
para calcular o número de caminhos do nó 1 ao nó 6. Por exemplo,
•1ÿ2ÿ3ÿ6•1ÿ2ÿ6
•1ÿ4ÿ5ÿ2ÿ3ÿ6•1
ÿ4ÿ5ÿ2ÿ6
Processamento de caminhos mais curtos A programação dinâmica também pode ser usada para
responder perguntas sobre caminhos mais curtos em grafos gerais (não necessariamente acíclicos). Ou
seja, se conhecemos as distâncias mínimas de um nó inicial para outros nós (por exemplo, depois de
usar o algoritmo de Dijkstra), podemos facilmente criar um grafo de caminhos mais curtos acíclicos direcionados
Machine Translated by Google
4 5 6
114
3 4 1
2
3
1 2
2
5 4 5
3 4 1
2
que indica para cada nó os caminhos possíveis para chegar ao nó usando um caminho mais curto
do nó inicial. Por exemplo, a Fig. 7.30 mostra um gráfico e o correspondente
gráfico de caminhos mais curtos.
Problema da moeda revisitado De fato, qualquer problema de programação dinâmica pode ser
representado como um grafo acíclico direcionado onde cada nó corresponde a um grafo dinâmico.
estado de programação e as arestas indicam como os estados dependem um do outro.
Por exemplo, considere o problema de formar uma soma de dinheiro n usando moedas
{c1, c2,..., ck } (Seção 6.1.1). Neste cenário, podemos construir um gráfico onde cada
nó corresponde a uma soma de dinheiro, e as arestas mostram como as moedas podem ser
escolhido. Por exemplo, a Fig. 7.31 mostra o gráfico para as moedas {1, 3, 4} e n = 6.
Usando esta representação, o caminho mais curto do nó 0 ao nó n corresponde a
uma solução com o número mínimo de moedas e o número total de caminhos de
do nó 0 ao nó n é igual ao número total de soluções.
Outra classe especial de grafos direcionados são os grafos sucessores. Nesses gráficos, o
outdegree de cada nó é 1, ou seja, cada nó tem um único sucessor. Um sucessor
Machine Translated by Google
98 7 Algoritmos Gráficos
7 6
4 8
x 123456789
succ(x) 357622163
Como cada nó de um grafo sucessor possui um único sucessor, também podemos definir um
função succ(x, k) que dá o nó que alcançaremos se começarmos no nó x e
caminhe k passos para frente. Por exemplo, em nosso gráfico de exemplo succ(4, 6) = 2, porque
chegaremos ao nó 2 caminhando 6 passos a partir do nó 4 (Fig. 7.33).
Uma maneira direta de calcular um valor de succ(x, k) é começar no nó x e
caminhe k passos para frente, o que leva tempo O(k) . No entanto, usando o pré-processamento, qualquer
o valor de succ(x, k) pode ser calculado apenas em tempo O(log k).
Seja u o número máximo de passos que daremos. A ideia é
pré-calcule todos os valores de succ(x, k) onde k é uma potência de dois e no máximo u. este
pode ser feito com eficiência, pois podemos usar a seguinte recorrência:
succ(x) k=1
succ(x, k) =
succ(succ(x, k/2), k/2) k > 1
A ideia é que um caminho de comprimento k que começa no nó x pode ser dividido em dois
caminhos de comprimento k/2. Pré-calculando todos os valores de succ(x, k) onde k é uma potência de
dois e no máximo u leva tempo O(n log u), porque os valores O(log u) são calculados para
cada nó. Em nosso gráfico de exemplo, os primeiros valores são os seguintes:
Machine Translated by Google
x 123456789
succ(x, 1) 357622163 succ(x,
2) 721255327 succ(x, 4)
327255123 succ(x, 8) 721255327
···
1 2 3 4 5
Considere um grafo sucessor que contém apenas um caminho que termina em um ciclo.
Podemos fazer as seguintes perguntas: se começarmos nossa caminhada no nó inicial,
qual é o primeiro nó do ciclo e quantos nós contém o ciclo? Por exemplo, na Fig. 7.34,
começamos nossa caminhada no nó 1, o primeiro nó que pertence ao ciclo é o nó 4 e o
ciclo consiste em três nós (4, 5 e 6).
Uma maneira simples de detectar o ciclo é percorrer o grafo e acompanhar todos os
nós que foram visitados. Uma vez que um nó é visitado pela segunda vez, podemos
concluir que o nó é o primeiro nó do ciclo. Este método funciona em tempo O(n) e também
usa memória O(n) . No entanto, existem algoritmos melhores para detecção de ciclos. A
complexidade de tempo de tais algoritmos ainda é O(n), mas eles usam apenas memória
O(1), o que pode ser uma melhoria importante se n for grande.
Um desses algoritmos é o algoritmo de Floyd, que caminha no gráfico usando dois
ponteiros a e b. Ambos os ponteiros começam no nó inicial x. Então, em cada turno, o
ponteiro a dá um passo à frente e o ponteiro b dá dois passos à frente. O processo
continua até que os ponteiros se encontrem:
Machine Translated by Google
a = succ(x); b =
succ(succ(x)); while (a != b)
{ a = succ(a); b = succ(succ(b));
Neste ponto, o ponteiro a deu k passos e o ponteiro b deu 2k passos, então o comprimento
do ciclo divide k. Assim, o primeiro nó que pertence ao ciclo pode ser encontrado movendo o
ponteiro a para o nó x e avançando os ponteiros passo a passo até que se encontrem novamente.
a = x;
enquanto (a != b) {
a = succ(a); b =
succ(b);
} primeiro = a;
b = succ(a);
comprimento = 1;
while (a != b) { b = succ(b);
comprimento++;
Uma árvore geradora contém todos os nós de um grafo e algumas de suas arestas para que
haja um caminho entre quaisquer dois nós. Como as árvores em geral, as árvores geradoras
são conectadas e acíclicas. O peso de uma árvore geradora é a soma dos pesos de suas
arestas. Por exemplo, a Fig. 7.35 mostra um gráfico e uma de suas árvores geradoras. O peso
desta árvore geradora é 3 + 5 + 9 + 3 + 2 = 22.
Uma árvore geradora mínima é uma árvore geradora cujo peso é o menor possível.
A Figura 7.36 mostra uma árvore geradora mínima para nosso gráfico de exemplo com peso 20.
De forma semelhante, uma árvore geradora máxima é uma árvore geradora cujo peso é o maior
possível. A Figura 7.37 mostra uma árvore geradora máxima para nosso gráfico de exemplo
com peso 32. Observe que um gráfico pode ter várias árvores geradoras mínimas e máximas,
portanto as árvores não são únicas.
Machine Translated by Google
1 6 3 4
5 5 6 7
2
5
3 2 3 9
1 3 4
5 6
2
5 5 6 7
2
5 5 6 7
Acontece que vários métodos gulosos podem ser usados para construir árvores geradoras
mínimas e máximas. Esta seção discute dois algoritmos que processam as arestas do grafo
ordenadas por seus pesos. Nós nos concentramos em encontrar árvores geradoras mínimas,
mas os mesmos algoritmos também podem encontrar árvores geradoras máximas processando
as arestas na ordem inversa.
O algoritmo de Kruskal constrói uma árvore geradora mínima adicionando vorazmente arestas
ao grafo. A árvore geradora inicial contém apenas os nós do grafo e não contém arestas. Em
seguida, o algoritmo percorre as arestas ordenadas por seus pesos e sempre adiciona uma
aresta ao grafo se não criar um ciclo.
O algoritmo mantém os componentes do gráfico. Inicialmente, cada nó do grafo pertence a
um componente separado. Sempre quando uma aresta é adicionada ao gráfico, dois
componentes são unidos. Finalmente, todos os nós pertencem ao mesmo componente e uma
árvore geradora mínima foi encontrada.
Como exemplo, vamos construir uma árvore geradora mínima para nosso gráfico de exemplo
(Fig. 7.35). O primeiro passo é ordenar as arestas em ordem crescente de seus pesos:
Machine Translated by Google
peso da
borda 5–6 2
1–2 3
3–6 3
1–5 5
2–3 5
2–5 6
4–6 7
3–4 9
5 6 5 6
2
passo 1 passo 2
3 2 3 3 2 3
1 4 1 3 4
5 6 5 6
2 2
etapa 3 Passo 4
3 2 3 3 2 3
1 3 4 1 3 4
5 5 6 5 5 6 7
2 2
passo 5 passo 6
Em seguida, percorremos a lista e adicionamos cada aresta ao grafo se ela unir dois
componentes separados. A Figura 7.38 mostra os passos do algoritmo. Inicialmente, cada nó
pertence ao seu próprio componente. Em seguida, as primeiras arestas da lista (5–6, 1–2, 3–
6 e 1–5) são adicionadas ao gráfico. Depois disso, a próxima aresta seria 2–3, mas essa
aresta não é adicionada, pois criaria um ciclo. O mesmo se aplica à aresta 2–5. Finalmente, a
aresta 4-6 é adicionada e a árvore geradora mínima está pronta.
Por que isso funciona? É uma boa pergunta por que o algoritmo de Kruskal funciona. Por
que a estratégia gananciosa garante que encontraremos uma árvore geradora mínima?
Vamos ver o que acontece se a aresta de peso mínimo do grafo não for incluída na árvore
geradora. Por exemplo, suponha que uma árvore geradora mínima de nosso grafo de exemplo
não contenha a aresta de peso mínimo 5–6. Não sabemos a estrutura exata dessa árvore
geradora, mas em qualquer caso ela deve conter algumas arestas. Suponha que a árvore se
pareça com a árvore da Fig. 7.39.
No entanto, não é possível que a árvore da Fig. 7.39 seja uma árvore geradora mínima,
porque podemos remover uma aresta da árvore e substituí-la pelo mínimo
Machine Translated by Google
5 6
5 6
2
borda de peso 5–6. Isso produz uma árvore geradora cujo peso é menor, mostrado na Fig. 7.40.
Por esta razão, é sempre ótimo incluir a aresta de peso mínimo na árvore para produzir uma
árvore geradora mínima. Usando um argumento semelhante, podemos mostrar que também é ótimo
adicionar a próxima aresta em ordem de peso à árvore e assim por diante.
Portanto, o algoritmo de Kruskal sempre produz uma árvore geradora mínima.
for (...) { if (!
mesmo(a,b)) unite(a,b);
}
O laço percorre as arestas da lista e sempre processa uma aresta (a, b ) onde aeb são dois nós.
Duas funções são necessárias: a função same determina se a e b estão no mesmo componente, e a
função unite une os componentes que contêm a e b.
Resolveremos o problema usando uma estrutura union-find que implementa ambas as funções
em tempo O(log n). Assim, a complexidade de tempo do algoritmo de Kruskal será O(m log n) após a
ordenação da lista de arestas.
Uma estrutura union-find mantém uma coleção de conjuntos. Os conjuntos são disjuntos, portanto,
nenhum elemento pertence a mais de um conjunto. Duas operações de tempo O(log n) são suportadas:
Machine Translated by Google
6 8
6 8
a operação unite une dois conjuntos e a operação find encontra o representante do conjunto que contém um
determinado elemento.
Em uma estrutura union-find, um elemento em cada conjunto é o representante do conjunto e há um
caminho de qualquer outro elemento do conjunto para o representante. Por exemplo, suponha que os
conjuntos sejam {1, 4, 7}, {5} e {2, 3, 6, 8}. A Figura 7.41 mostra uma maneira de representar esses conjuntos.
Para unir dois conjuntos, o representante de um conjunto é conectado ao representante do outro conjunto.
Por exemplo, a Fig. 7.42 mostra uma maneira possível de unir os conjuntos {1, 4, 7} e {2, 3, 6, 8}. A partir daí,
o elemento 2 é o representante de todo o conjunto e o antigo representante 4 aponta para o elemento 2.
A eficiência da estrutura union-find depende de como os conjuntos são unidos. Acontece que podemos
seguir uma estratégia simples: sempre conecte o representante do conjunto menor ao representante do
conjunto maior (ou se os conjuntos forem de tamanho igual, podemos fazer uma escolha arbitrária). Usando
esta estratégia, o comprimento de qualquer caminho será O(log n), então podemos encontrar o representante
de qualquer elemento de forma eficiente seguindo o caminho correspondente.
A função same verifica se os elementos a e b pertencem ao mesmo conjunto. Isso pode ser feito
facilmente usando a função find:
A função unite une os conjuntos que contêm os elementos a e b (os elementos devem estar em
conjuntos diferentes). A função primeiro encontra os representantes dos conjuntos e, em seguida, conecta
o conjunto menor ao conjunto maior.
A complexidade de tempo da função find é O(log n) assumindo que o comprimento de cada caminho é
O(log n). Neste caso, as funções same e unite também funcionam em tempo O(log n). A função unite
garante que o comprimento de cada caminho seja O(log n) conectando o conjunto menor ao conjunto maior.
Compactação de caminho Aqui está uma maneira alternativa de implementar a operação de localização:
Esta função usa compressão de caminho: cada elemento no caminho apontará diretamente para seu
representante após a operação. Pode-se mostrar que usando esta função, as operações união-encontrar
funcionam em tempo O(ÿ(n)) amortizado , onde ÿ(n) é a função inversa de Ackermann que cresce muito
lentamente (é quase uma constante). No entanto, a compactação de caminho não pode ser usada em
algumas aplicações da estrutura union-find, como no algoritmo de conectividade dinâmica (Seção 15.5.4).
Machine Translated by Google
1 4 1 4
5 6 5 6
passo 1 passo 2
5 5
3 2 3 3 2 3
1 4 1 3 4
5 6 5 6
etapa 3 Passo 4
5 5
3 2 3 3 2 3
1 3 4 1 3 4
5 6 5 6 7
2 2
passo 5 passo 6
A Seção 8.3 discute a pesquisa ternária e outras técnicas para calcular com eficiência
valores mínimos de certas funções.
Os algoritmos de bits paralelos são baseados no fato de que bits individuais de números podem ser
manipulados em paralelo usando operações de bits. Assim, uma maneira de projetar um algoritmo
eficiente é representar as etapas do algoritmo de forma que possam ser implementadas de forma
eficiente usando operações de bits.
A distância de Hamming (a, b) entre duas cordas a e b de igual comprimento é o número de posições
em que as cordas diferem. Por exemplo,
hamming(01101, 11001) = 2.
• Hamming(00111, 01101) = 2, •
Hamming(00111, 11110) = 3, e •
Hamming(01101, 11110) = 3.
Uma maneira direta de resolver o problema é passar por todos os pares de strings e calcular
suas distâncias de Hamming, o que produz um algoritmo de tempo O(n2k) . A seguinte função
calcula a distância entre as strings a e b:
} return d;
}
Na função acima, a operação xor constrói uma string que tem um bit em posições onde a e
b diferem. Em seguida, o número de um bits é calculado usando a função __builtin_popcount.
A Tabela 8.1 mostra uma comparação dos tempos de execução do algoritmo original e do
algoritmo de bits paralelos em um computador moderno. Neste problema, o algoritmo bit-
paralelo é cerca de 20 vezes mais rápido que o algoritmo original.
Como outro exemplo, considere o seguinte problema: Dada uma grade n × n em que cada
quadrado é preto (1) ou branco (0), calcule o número de subgrades cujos cantos são todos
pretos. Por exemplo, a Fig. 8.1 mostra duas dessas subgrades em uma grade.
Machine Translated by Google
Tabela 8.1 Os tempos de execução dos algoritmos ao calcular as distâncias mínimas de Hamming de
n cadeias de bits de comprimento k = 30
Existe um algoritmo de tempo O(n3) para resolver o problema: passe por todos os O(n2)
pares de linhas, e para cada par(a, b) calcular, em tempo O(n), o número de colunas
que contêm um quadrado preto em ambas as linhas a e b. O código a seguir assume que
color[y][x] denota a cor na linha y e na coluna x:
int contagem = 0;
for (int i = 0; i < n; i++) {
if (color[a][i] == 1 && color[b][i] == 1) {
contagem++;
}
}
Então, depois de descobrir que existem colunas de contagem onde ambos os quadrados são
preto, podemos usar a fórmula count(count ÿ 1)/2 para calcular o número de
subgrades cuja primeira linha é a e a última linha é b.
Para criar um algoritmo de bits paralelos, representamos cada linha k como uma linha de bitset de n bits[k]
onde um bits denota quadrados pretos. Então, podemos calcular o número de colunas
onde as linhas a e b têm quadrados pretos usando uma operação and e contando
o número de um bits. Isso pode ser feito convenientemente da seguinte maneira usando o bitset
estruturas:
A Tabela 8.2 mostra uma comparação do algoritmo original e do algoritmo bit-paralelo para
diferentes tamanhos de grade. A comparação mostra que o algoritmo bit-paralelo
pode ser até 30 vezes mais rápido que o algoritmo original.
Machine Translated by Google
alcance[x][x] = 1;
for (auto u : adj[x]) {
alcance[x] |= alcance[u];
}
A Tabela 8.3 mostra alguns tempos de execução para o algoritmo de bits paralelos. Em cada teste,
o grafo tem n nós e 2n arestas aleatórias a ÿ b onde a < b. Observe que o
Tabela 8.3 Os tempos de execução dos algoritmos ao contar nós alcançáveis em um gráfico
algoritmo usa uma grande quantidade de memória para grandes valores de n. Em muitos concursos,
o limite de memória pode ser de 512 MB ou menos.
No método de dois ponteiros, dois ponteiros percorrem uma matriz. Ambos os ponteiros se movem
apenas para uma direção, o que garante que o algoritmo funcione com eficiência. Como primeiro
exemplo de como aplicar a técnica, considere um problema em que recebemos um array de n
inteiros positivos e uma soma alvo x, e queremos encontrar um subarray cuja soma seja x ou relatar
que não existe tal subarray.
O problema pode ser resolvido em tempo O(n) usando o método dos dois ponteiros. A ideia é
manter ponteiros que apontam para o primeiro e o último valor de um subarray. Em cada turno, o
ponteiro da esquerda se move um passo para a direita e o ponteiro da direita se move para a direita
enquanto a soma do subarray resultante for no máximo x. Se a soma se tornar exatamente x, uma
solução foi encontrada.
Por exemplo, a Fig. 8.3 mostra como o algoritmo processa um array quando a soma alvo é x = 8.
O subarray inicial contém os valores 1, 3 e 2, cuja soma é 6. Então, o ponteiro esquerdo move um
passo para a direita, e o ponteiro da direita não se move, porque senão a soma excederia x.
Finalmente, o ponteiro esquerdo move um passo para a direita e o ponteiro direito move dois passos
para a direita. A soma do subarray é 2 + 5 + 1 = 8, então o subarray desejado foi encontrado.
O tempo de execução do algoritmo depende do número de passos que o ponteiro direito move.
Embora não haja um limite superior útil em quantos passos o ponteiro pode mover
1 3 2 5 112 3
1 3 2 5 112 3
Machine Translated by Google
1 4 5 6 7 9 9 10
1 4 5 6 7 9 9 10
em uma única volta, sabemos que o ponteiro move um total de O(n) passos durante o algoritmo,
pois só se move para a direita. Como os ponteiros esquerdo e direito movem passos O(n) , o
algoritmo funciona em tempo O(n) .
Problema 2SUM Outro problema que pode ser resolvido usando o método de dois ponteiros é
o problema 2SUM: dado um array de n números e uma soma alvo x, encontre dois valores de array
de forma que sua soma seja x, ou relate que tais valores não existem.
Para resolver o problema, primeiro classificamos os valores do array em ordem crescente.
Depois disso, iteramos pelo array usando dois ponteiros. O ponteiro esquerdo começa no primeiro
valor e se move um passo para a direita em cada curva. O ponteiro direito começa no último valor
e sempre se move para a esquerda até que a soma do valor esquerdo e direito seja no máximo x.
Se a soma for exatamente x, uma solução foi encontrada.
Por exemplo, a Fig. 8.4 mostra como o algoritmo processa uma matriz quando a soma alvo é x
= 12. Na posição inicial, a soma dos valores é 1 + 10 = 11, que é menor que x. Em seguida, o
ponteiro esquerdo move-se um passo para a direita e o ponteiro direito move-se três passos para a
esquerda, e a soma torna-se 4 + 7 = 11. Depois disso, o ponteiro esquerdo move-se um passo para
a direita novamente. O ponteiro da direita não se move e uma solução 5 + 7 = 12 foi encontrada.
O tempo de execução do algoritmo é O(n log n), porque ele primeiro classifica a matriz em
tempo O(n log n) e, em seguida, ambos os ponteiros movem etapas O(n) .
Observe que também é possível resolver o problema de outra forma em tempo O(n log n)
usando busca binária. Em tal solução, primeiro ordenamos a matriz e, em seguida, iteramos pelos
valores da matriz e, para cada valor binário, procuramos outro valor que produza a soma x. De fato,
muitos problemas que podem ser resolvidos usando o método de dois ponteiros também podem
ser resolvidos usando estruturas de ordenação ou conjuntos, às vezes com um fator logarítmico
adicional.
O problema mais geral do kSUM também é interessante. Neste problema temos que encontrar
k elementos tais que sua soma seja x. Acontece que podemos resolver o 3SUM
problema em tempo O(n2) estendendo o algoritmo 2SUM acima. Você pode ver como podemos
fazer isso? Por muito tempo, pensou-se que O(n2) seria a melhor complexidade de tempo possível
para o problema 3SUM. No entanto, em 2014, Grønlund e Pettie [12] mostraram que este não é o
caso.
Machine Translated by Google
13425342 13425342
1 13
passo 1 passo 2
13425342 13425342
134 1 2
etapa 3 Passo 4
13425342 13425342
1 25 1 2 3
passo 5 passo 6
13425342 13425342
1 2 34 1 2
passo 7 passo 8
Fig. 8.5 Encontrando os elementos menores mais próximos em tempo linear usando uma pilha
Machine Translated by Google
214 5 3 412
1 3
214 5 3 412
34
214 5 3 412
214 5 3 412
12
Machine Translated by Google
é adicionado à fila. O menor valor ainda é 1. Depois disso, a janela se move novamente e o
menor elemento 1 não pertence mais à janela. Assim, ele é removido da fila e o menor valor
agora é 3. Além disso, o novo elemento 4 é adicionado à fila. O próximo novo elemento 1 é
menor que todos os elementos da fila, então todos os elementos são removidos da fila e
contém apenas o elemento 1. Finalmente, a janela atinge sua última posição. O elemento 2 é
adicionado à fila, mas o menor valor dentro da janela ainda é 1.
Como cada elemento do array é adicionado à fila exatamente uma vez e removido da fila
no máximo uma vez, o algoritmo funciona em tempo O(n) .
Suponha que exista uma função f (x) que primeiro só diminui, depois atinge seu valor mínimo
e depois só aumenta. Por exemplo, a Fig. 8.7 mostra tal função cujo valor mínimo está
marcado com uma seta. Se soubermos que nossa função tem essa propriedade, podemos
encontrar eficientemente seu valor mínimo.
A pesquisa ternária fornece uma maneira eficiente de encontrar o valor mínimo de uma função
que primeiro diminui e depois aumenta. Suponha que sabemos que o valor de x que minimiza
f (x) está em um intervalo [xL , xR]. A ideia é dividir o intervalo em três partes de tamanho
igual [xL , a], [a, b] e [b, xR] escolhendo
2xL + xR xL + 2xR
a= eb = _ .
3 3
Então, se f (a) < f (b), concluímos que o mínimo deve estar no intervalo [xL , b], caso contrário
deve estar no intervalo [a, xR]. Depois disso, continuamos recursivamente a busca, até que o
tamanho do intervalo ativo seja pequeno o suficiente.
Como exemplo, a Fig. 8.8 mostra a primeira etapa da pesquisa ternária em nosso cenário
de exemplo. Como f (a) > f (b), o novo intervalo se torna [a, xR].
xR
xG
uma
uma
Na prática, muitas vezes consideramos funções cujos parâmetros são inteiros, e a busca
é encerrada quando o intervalo contém apenas um elemento. Como o tamanho do novo
intervalo é sempre 2/3 do intervalo anterior, o algoritmo funciona em tempo O(log n), onde n
é o número de elementos no intervalo original.
Observe que, ao trabalhar com parâmetros inteiros, também podemos usar a pesquisa
binária em vez da pesquisa ternária, pois basta encontrar a primeira posição x para a qual f
(x) ÿ f (x + 1).
Uma função é convexa se um segmento de linha entre quaisquer dois pontos no gráfico da
função sempre estiver acima ou no gráfico. Por exemplo, a Fig. 8.9 mostra o gráfico de f (x)
= x2, que é uma função convexa. De fato, o segmento de reta entre os pontos a e b está
acima do gráfico.
Se soubermos que o valor mínimo de uma função convexa está no intervalo [xL , xR],
podemos usar a busca ternária para encontrá-lo. No entanto, observe que vários pontos de
uma função convexa podem ter o valor mínimo. Por exemplo, f (x) = 0 é convexo e seu valor
mínimo é 0.
As funções convexas têm algumas propriedades úteis: se f (x) eg(x) são funções
convexas, então também f (x)+g(x) e max( f (x), g(x)) também são funções convexas. Por exemplo,
Machine Translated by Google
se temos n funções convexas f1, f2,..., fn, sabemos imediatamente que também a função
f1 + f2 + ... + fn tem que ser convexa e podemos usar a busca ternária para encontrar
seu valor mínimo.
Dados n números a1, a2,..., an, considere o problema de encontrar um valor de x que
minimize a soma
Por exemplo, se os números são [1, 2, 9, 2, 6], a solução ótima é escolher x = 2, que
produz a soma
Como cada função |ak ÿ x| é convexa, a soma também é convexa, então podemos
usar a busca ternária para encontrar o valor ótimo de x. No entanto, há também uma
solução mais fácil. Acontece que a escolha ótima para x é sempre a mediana dos
números, ou seja, o elemento do meio após a ordenação. Por exemplo, a lista [1, 2, 9, 2,
6] se torna [1, 2, 2, 6, 9] após a classificação, então a mediana é 2.
A mediana é sempre ótima, pois se x for menor que a mediana, a soma se torna
menor aumentando x, e se x for maior que a mediana, a soma se torna menor diminuindo
x. Se n for par e houver duas medianas, ambas as medianas e todos os valores entre
elas são escolhas ótimas.
Então, considere o problema de minimizar a função
2 2
(a1 ÿ x) + (a2 ÿ x) +···+ (um ÿ x) 2.
Por exemplo, se os números são [1, 2, 9, 2, 6], a melhor solução é escolher x = 4, que
produz a soma
2 2 2 2
(1 - 4) + (2 ÿ 4) + (9 ÿ 4) + (2 ÿ 4) + (6 ÿ 4) 2 = 46.
Novamente, essa função é convexa e poderíamos usar a busca ternária para resolver
o problema, mas também existe uma solução simples: a escolha ótima para x é a média
dos números. No exemplo, a média é (1 + 2 + 9 + 2 + 6)/5 = 4. Isso pode ser comprovado
apresentando a soma da seguinte forma:
A última parte não depende de x, então podemos ignorá-la. As partes restantes formam
uma função nx2 ÿ 2xs onde s = a1 + a2 +···+ an. Esta é uma parábola que se abre para
cima com raízes x = 0 e x = 2s/n, e o valor mínimo é a média das raízes x = s/ n, ou seja,
a média dos números a1, a2,..., an .
Machine Translated by Google
Consultas de intervalo
9
Nesta seção, focamos em uma situação onde o array é estático, ou seja, os valores do array nunca
são atualizados entre as consultas. Nesse caso, basta pré-processar o array para que possamos
responder com eficiência às consultas de intervalo.
Primeiro, discutiremos uma maneira simples de processar consultas de soma usando uma matriz
de soma de prefixo, que também pode ser generalizada para dimensões mais altas. Depois disso,
aprenderemos o algoritmo de tabela esparsa para processar consultas mínimas, o que é um pouco
mais difícil. Observe que, embora nos concentremos no processamento de consultas mínimas, sempre
podemos processar consultas máximas usando métodos semelhantes.
Deixe sumq (a, b) (“consulta de soma de intervalo”) denotar a soma de valores de matriz em um intervalo [a, b].
Podemos processar com eficiência qualquer consulta de soma construindo primeiro uma matriz de soma de prefixo.
Cada valor no array prefix sum é igual à soma dos valores no array original até a posição correspondente, ou seja,
o valor na posição k é sumq (0, k). Por exemplo, a Fig. 9.1 mostra uma matriz e sua matriz de soma de prefixo.
A matriz de soma de prefixo pode ser construída em tempo O(n) . Então, como a matriz de soma de prefixo
contém todos os valores de sumq (0, k), podemos calcular qualquer valor de sumq (a, b) no tempo O(1) usando a
fórmula
Dimensões Superiores Também é possível generalizar esta ideia para dimensões superiores.
Por exemplo, a Fig. 9.3 mostra um array de soma de prefixo bidimensional que pode ser usado para calcular a
soma de qualquer subarray retangular em tempo O(1). Cada soma nesta matriz
01234567
matriz de soma de prefixo 1 4 8 16 22 23 27 29
01234567
matriz de soma de prefixo 1 4 8 16 22 23 27 29
B UMA
Machine Translated by Google
01234567
tamanho do intervalo 2 1 3 4 6 112 –
01234567
tamanho da faixa 4 1 3 111 –––
01234567
tamanho do intervalo 8 1 –––––––
onde S(X) denota a soma dos valores em uma submatriz retangular do canto superior esquerdo
até a posição de X.
Deixe minq (a, b)(“intervalo mínimo consulta”) denotar o valor mínimo do array em um intervalo
[a, b]. Em seguida, discutiremos uma técnica com a qual podemos processar qualquer consulta
mínima em tempo O(1) após um pré-processamento em tempo O(n log n). O método é devido
a Bender e Farach-Colton [3] e muitas vezes chamado de algoritmo de tabela esparsa.
A ideia é pré-calcular todos os valores de minq (a, b) onde b ÿ a + 1 (o comprimento do
intervalo) é uma potência de dois. Por exemplo, a Fig. 9.4 mostra os valores pré-calculados
para uma matriz de oito elementos.
O número de valores pré-calculados é O(n log n), porque existem comprimentos de intervalo
O(log n) que são potências de dois. Os valores podem ser calculados de forma eficiente usando
a fórmula recursiva
onde b ÿ a + 1 é uma potência de dois ew = (b ÿ a + 1)/2. Calcular todos esses valores leva
tempo O(n log n).
Depois disso, qualquer valor de minq (a, b) pode ser calculado em tempo O(1) como um
mínimo de dois valores pré-calculados. Seja k a maior potência de dois que não excede b ÿ a +
1. Podemos calcular o valor de minq (a, b) usando a fórmula
01234567
tamanho da faixa 4 1 3 4 8 6 142
01234567
tamanho da faixa 4 1 3 4 8 6 142
Na fórmula acima, o intervalo [a, b] é representado como a união dos intervalos [a, a + k ÿ 1] e [b ÿ k
+ 1, b], ambos de comprimento k.
Como exemplo, considere o intervalo [1, 6] na Fig. 9.5. O comprimento do intervalo é 6, e a maior
potência de dois que não excede 6 é 4. Assim, o intervalo [1, 6] é a união dos intervalos [1, 4] e [3, 6].
Como minq (1, 4) = 3 e minq (3, 6) = 1, concluímos que minq (1, 6) = 1.
Observe que também existem técnicas sofisticadas usando as quais podemos processar consultas
de intervalo mínimo em tempo O(1) após um pré-processamento apenas em tempo O(n) (ver, por
exemplo, Fischer e Heun [10]), mas elas estão além do escopo de este livro.
Esta seção apresenta duas estruturas de árvore, usando as quais podemos processar consultas de
intervalo e atualizar valores de matriz em tempo logarítmico. Primeiro, discutimos árvores indexadas
binárias que suportam consultas de soma e, depois disso, focamos em árvores de segmento que
também suportam várias outras consultas.
Uma árvore indexada binária (ou uma árvore Fenwick) [9] pode ser vista como uma variante dinâmica
de uma matriz de soma de prefixo. Ele fornece duas operações de tempo O(log n): processar uma
consulta de soma de intervalo e atualizar um valor. Mesmo que o nome da estrutura seja uma árvore
indexada binária, a estrutura geralmente é representada como uma matriz. Ao discutir árvores
indexadas binárias, assumimos que todos os arrays são indexados por um, porque isso facilita a
implementação da estrutura.
Seja p(k) a maior potência de dois que divide k. Armazenamos uma árvore binária indexada como
uma árvore de matriz de tal forma que
isto é, cada posição k contém a soma dos valores em um intervalo do array original cujo comprimento
é p(k) e que termina na posição k. Por exemplo, como p(6) = 2, tree[6]
Machine Translated by Google
12345678
árvore indexada binária 144 16 6 7 4 29
contém o valor de sumq (5, 6). A Figura 9.6 mostra um array e a árvore indexada binária correspondente.
A Figura 9.7 mostra mais claramente como cada valor na árvore indexada binária corresponde a um
intervalo na matriz original.
Usando uma árvore indexada binária, qualquer valor de sumq (1, k) pode ser calculado em tempo O(log
n), porque um intervalo [1, k] sempre pode ser dividido em subintervalos O(log n) cujas somas foram
armazenadas na árvore. Por exemplo, para calcular o valor de sumq (1, 7), dividimos o intervalo [1, 7] em
três subintervalos [1, 4], [5, 6] e [7, 7] (Fig. 9.8) .
Como as somas desses subintervalos estão disponíveis na árvore, podemos calcular a soma de todo o
intervalo usando a fórmula
Então, para calcular o valor de sumq (a, b) onde a > 1, podemos usar o mesmo truque
que usamos com matrizes de soma de prefixo:
Podemos calcular tanto sumq (1, b) quanto sumq (1, a ÿ 1) em tempo O(log n), então a complexidade total
do tempo é O(log n).
Após atualizar um valor de array, vários valores na árvore indexada binária devem ser atualizados. Por
exemplo, quando o valor na posição 3 muda, devemos atualizar
Machine Translated by Google
as subfaixas [3, 3], [1, 4] e [1, 8] (Fig. 9.9). Como cada elemento do array pertence a
subintervalos O(log n), basta atualizar os valores da árvore O(log n).
Implementação As operações de uma árvore indexada binária podem ser implementadas de forma
eficiente usando operações de bits. O fato chave necessário é que podemos calcular facilmente
qualquer valor de p(k) usando a fórmula de bits
p(k) = k & ÿ k,
}
retornar s;
}
}
}
39
22 17
13 9 9 8
5863 2 7 2 6
Uma árvore de segmentos é uma estrutura de dados que fornece duas operações O(log n)time:
processamento de uma consulta de intervalo e atualização de um valor de matriz. As árvores de
segmento suportam consultas de soma, consultas mínimas e muitas outras consultas. Árvores de
segmentos têm suas origens em algoritmos geométricos (veja, por exemplo, Bentley e Wood [4]), e
a elegante implementação bottom-up apresentada nesta seção segue o livro de Sta ´nczyk [30].
Uma árvore de segmento é uma árvore binária cujos nós de nível inferior correspondem aos
elementos da matriz e os outros nós contêm informações necessárias para o processamento de
consultas de intervalo. Ao discutir as árvores de segmentos, assumimos que o tamanho do array é
uma potência de dois, e a indexação baseada em zero é usada, porque é conveniente construir uma
árvore de segmentos para tal array. Se o tamanho do array não for uma potência de dois, sempre
podemos acrescentar elementos extras a ele.
Discutiremos primeiro as árvores de segmento que suportam consultas de soma. Como exemplo,
a Fig. 9.10 mostra um array e a árvore de segmentos correspondente para consultas de soma. Cada
nó interno da árvore corresponde a um intervalo de matrizes cujo tamanho é uma potência de dois.
Quando uma árvore de segmento suporta consultas de soma, o valor de cada nó interno é a soma
dos valores de matriz correspondentes e pode ser calculado como a soma dos valores de seu nó
filho esquerdo e direito.
Acontece que qualquer intervalo [a, b] pode ser dividido em subintervalos O(log n) cujos valores
são armazenados em nós de árvore. Por exemplo, a Fig. 9.11 mostra o intervalo [2, 7] na matriz
original e na árvore de segmentos. Neste caso, dois nós da árvore correspondem ao intervalo, e
sumq (2, 7) = 9 + 17 = 26. Quando a soma é calculada usando nós localizados o mais alto possível
na árvore, no máximo dois nós em cada nível de a árvore são necessários. Portanto, o número total
de nós é O(log n).
Após uma atualização de array, devemos atualizar todos os nós cujo valor depende do valor
atualizado. Isso pode ser feito percorrendo o caminho do elemento de matriz atualizado até o nó
superior e atualizando os nós ao longo do caminho. Por exemplo, a Fig. 9.12 mostra os nós que
mudam quando o valor na posição 5 muda. O caminho de baixo para cima sempre consiste em
O(log n) nós, então cada atualização altera O(log n) nós na árvore.
Machine Translated by Google
39
22 17
13 9 9 8
5863 2 7 2 6
39
22 17
13 9 9 8
5863 2 7 2 6
Implementação Uma maneira conveniente de armazenar o conteúdo de uma árvore de segmentos é usar
um array de 2n elementos onde n é o tamanho do array original. Os nós da árvore são
armazenado de cima para baixo: tree[1] é o nó superior, tree[2] e tree[3] são seus
crianças, e assim por diante. Finalmente, os valores de tree[n] a tree[2n ÿ 1] correspondem
para o nível inferior da árvore, que contém os valores da matriz original. Observação
que o elemento tree[0] não é usado.
Por exemplo, a Fig. 9.13 mostra como nossa árvore de exemplo é armazenada. Observe que o pai
de tree[k] é tree[ k/2], seu filho da esquerda é tree[2k], e seu filho da direita é
árvore[2k + 1]. Além disso, a posição de um nó (além do nó superior) é par
se for filho da esquerda e ímpar se for filho da direita.
Machine Translated by Google
}
retornar s;
}
Outras Consultas As árvores de segmentos podem suportar qualquer consulta de intervalo em que
podemos dividir um intervalo em duas partes, calcular a resposta separadamente para ambas as partes e,
em seguida, combinar as respostas com eficiência. Exemplos de tais consultas são mínimo e máximo,
máximo divisor comum e operações de bit e, ou, e xor.
Por exemplo, a árvore de segmentos na Fig. 9.14 suporta consultas mínimas. Nesta árvore, cada nó
contém o menor valor no intervalo de matriz correspondente. O nó superior da árvore contém o menor valor
em toda a matriz. As operações podem ser implementadas como anteriormente, mas em vez de somas,
são calculados mínimos.
A estrutura de uma árvore de segmentos também nos permite usar um método de estilo de busca
binária para localizar elementos do array. Por exemplo, se a árvore suporta consultas mínimas, podemos
encontrar a posição de um elemento com o menor valor em tempo O(log n). Por exemplo, a Fig. 9.15 mostra
como o elemento com o menor valor 1 pode ser encontrado percorrendo um caminho para baixo a partir do
nó superior.
Machine Translated by Google
3 1
5 3 1 2
5863 1 7 2 6
3 1
5 3 1 2
5863 1 7 2 6
012
matriz compactada 534
01234567
matriz de diferença 3 0 ÿ2 0 0 4 ÿ3 0
01234567
matriz de diferença 3 3 ÿ2 0 0 1 ÿ3 0
Após a compactação do índice, podemos, por exemplo, construir uma árvore de segmentos para o
array compactado e realizar consultas. A única modificação necessária é que temos que comprimir os
índices antes das consultas: um intervalo [a, b] no array original corresponde ao intervalo [c(a), c(b)] no
array compactado.
Atualizações de intervalo Até agora, implementamos estruturas de dados que suportam consultas de
intervalo e atualizações de valores únicos. Vamos agora considerar uma situação oposta, onde devemos
atualizar intervalos e recuperar valores únicos. Focamos em uma operação que aumenta todos os
elementos em um intervalo [a, b] por x.
Acontece que podemos usar as estruturas de dados apresentadas neste capítulo também nesta
situação. Para fazer isso, construímos um array de diferenças cujos valores indicam as diferenças entre
valores consecutivos no array original. A matriz original é a matriz de soma de prefixo da matriz de
diferença. A Figura 9.17 mostra um array e seu array de diferenças.
Por exemplo, o valor 2 na posição 6 no array original corresponde à soma 3 ÿ 2 + 4 ÿ 3 = 2 no array de
diferenças.
A vantagem do array de diferenças é que podemos atualizar um intervalo no array original alterando
apenas dois elementos no array de diferenças. Mais precisamente, para aumentar os valores no intervalo
[a, b] em x, aumentamos o valor na posição a em x e diminuímos o valor na posição b + 1 em x. Por
exemplo, para aumentar os valores originais do array entre as posições 1 e 4 em 3, aumentamos o valor
do array de diferença na posição 1 em 3 e diminuímos o valor na posição 5 em 3 (Fig. 9.18).
Assim, atualizamos apenas valores únicos e processamos consultas de soma no array de diferenças,
para que possamos usar uma árvore indexada binária ou uma árvore de segmentos. Uma tarefa mais
difícil é criar uma estrutura de dados que suporte consultas de intervalo e atualizações de intervalo. Na
Sec. 15.2.1, veremos que isso também é possível usando uma árvore de segmentos preguiçosa.
Machine Translated by Google
Algoritmos de árvore
10
As propriedades especiais das árvores nos permitem criar algoritmos especializados para árvores e
que funcionam de forma mais eficiente do que algoritmos de grafos gerais. Este capítulo apresenta
uma seleção de tais algoritmos.
A Seção 10.1 apresenta conceitos básicos e algoritmos relacionados a árvores. Um problema
central é encontrar o diâmetro de uma árvore, ou seja, a distância máxima entre dois nós. Vamos
aprender dois algoritmos de tempo linear para resolver o problema.
A Seção 10.2 se concentra no processamento de consultas em árvores. Aprenderemos a usar um
array de travessia de árvore para processar várias consultas relacionadas a subárvores e caminhos.
Depois disso, discutiremos métodos para determinar os ancestrais comuns mais baixos e um
algoritmo offline baseado na mesclagem de estruturas de dados.
A Seção 10.3 apresenta duas técnicas avançadas de processamento de árvores: decom centroid
posição e decomposição pesada-leve.
8 6 2 3 7
2 3 4
5 6 7
árvore é recursiva: cada nó da árvore atua como a raiz de uma subárvore que contém o
próprio nó e todos os nós que estão nas subárvores de seus filhos.
Por exemplo, a Fig. 10.2 mostra uma árvore enraizada onde o nó 1 é a raiz da árvore.
Os filhos do nó 2 são os nós 5 e 6, e o pai do nó 2 é o nó 1.
A subárvore do nó 2 consiste nos nós 2, 5, 6 e 8.
Algoritmos gerais de travessia de grafos podem ser usados para percorrer os nós de uma árvore. No
entanto, a travessia de uma árvore é mais fácil de implementar do que a de um grafo geral, porque
não há ciclos na árvore, e não é possível alcançar um nó de mais de
uma direção.
Uma maneira típica de percorrer uma árvore é iniciar uma busca em profundidade em um nó arbitrário.
A seguinte função recursiva pode ser usada:
dfs(x, 0);
Programação Dinâmica A programação dinâmica pode ser usada para calcular algumas informações
durante um percurso em árvore. Por exemplo, o código a seguir calcula para cada nó s um valor
count[s]: o número de nós em sua subárvore. A subárvore contém o próprio nó e todos os nós nas
subárvores de seus filhos, então podemos calcular o número de nós recursivamente da seguinte forma:
}
}
Percursos de Árvores Binárias Em uma árvore binária, cada nó tem uma subárvore esquerda e direita
(que pode estar vazia), e há três ordenações populares de percursos de árvores:
• pré-encomenda: primeiro processe o nó raiz, depois percorra a subárvore esquerda e depois percorra
a subárvore certa
• em ordem: primeiro percorra a subárvore esquerda, depois processe o nó raiz e depois percorra
a subárvore certa
• pós-ordem: primeiro percorra a subárvore esquerda, depois percorra a subárvore direita, depois
processar o nó raiz
2 3
4 5 7
6
Machine Translated by Google
6 2 3 7
2 3 4
5 6 7
Primeiro Algoritmo Uma maneira geral de abordar problemas de árvore é primeiro enraizar a árvore
arbitrariamente e então resolver o problema separadamente para cada subárvore. Nosso primeiro
algoritmo para calcular diâmetros é baseado nessa ideia.
Uma observação importante é que todo caminho em uma árvore enraizada tem um ponto mais
alto: o nó mais alto que pertence ao caminho. Assim, podemos calcular para cada nó x o comprimento
do caminho mais longo cujo ponto mais alto é x. Um desses caminhos corresponde ao diâmetro da
árvore. Por exemplo, na Fig. 10.5, o nó 1 é o ponto mais alto no caminho que corresponde ao
diâmetro.
Calculamos para cada nó x dois valores:
5 3
uma
A programação dinâmica pode ser usada para calcular os valores acima para todos os nós
em tempo O(n) . Primeiro, para calcular toLeaf(x), passamos pelos filhos de x, escolhemos um
filho c com o máximo toLeaf(c) e adicionamos um a esse valor. Então, para calcular
maxLength(x), escolhemos dois filhos distintos aeb tais que a soma toLeaf(a) + toLeaf(b) seja
máxima e adicionamos dois a essa soma. (Os casos em que x tem menos de dois filhos são
casos especiais fáceis.)
Segundo Algoritmo Outra forma eficiente de calcular o diâmetro de uma árvore é baseada em
duas buscas em profundidade. Primeiro, escolhemos um nó arbitrário a na árvore e encontramos
o nó b mais distante de a. Então, encontramos o nó mais distante c de b. O diâmetro da árvore
é a distância entre b e c.
Por exemplo, a Fig. 10.6 mostra uma maneira possível de selecionar os nós a, b e c ao
calcular o diâmetro para nossa árvore de exemplo.
Este é um método elegante, mas por que funciona? Ajuda desenhar a árvore de modo que
o caminho que corresponde ao diâmetro seja horizontal e todos os outros nós fiquem pendurados
nele (Fig. 10.7). O nó x indica o lugar onde o caminho do nó a une o caminho que corresponde
ao diâmetro. O nó mais distante de a é o nó b, o nó c ou algum outro nó que esteja pelo menos
tão distante do nó x. Assim, este nó é sempre uma escolha válida para um ponto final de um
caminho que corresponde ao diâmetro.
2 3 4
5 6
2 3 4
5 6
2 3 4
5 6
através de seu filho 2 (Fig. 10.9). Esta parte é fácil de resolver em tempo O(n) , porque
pode usar programação dinâmica como fizemos anteriormente.
Então, a segunda parte do problema é calcular para cada nó x o máximo
comprimento de um caminho para cima através de seu pai p. Por exemplo, o caminho mais longo de
o nó 3 passa por seu pai 1 (Fig. 10.10). À primeira vista, parece que devemos
primeiro mova para p e, em seguida, escolha o caminho mais longo (para cima ou para baixo) de
pág. No entanto, isso nem sempre funciona, porque esse caminho pode passar por x
(Fig. 10.11). Ainda assim, podemos resolver a segunda parte em tempo O(n) armazenando o máximo
comprimentos de dois caminhos para cada nó x:
Fig. 10.12 1
Encontrando ancestrais de nós
4 5 2
3 7 6
8
Machine Translated by Google
x 12345678
ancestral(x, 1) 01411247
ancestral(x, 2) 00100114
ancestral(x, 4) 00000000
···
Um array de travessia de árvore contém os nós de uma árvore enraizada na ordem em que um
a busca em profundidade do nó raiz os visita. Por exemplo, a Fig. 10.13 mostra um
tree e a matriz transversal de árvore correspondente.
Uma propriedade importante dos arrays de travessia de árvore é que cada subárvore de uma
cor de árvore responde a um subarray no vetor de travessia de árvore tal que o primeiro elemento do
subarray é o nó raiz. Por exemplo, a Fig. 10.14 mostra o subarray que corresponde à
subárvore do nó 4.
Consultas de subárvore Suponha que cada nó na árvore receba um valor e nossa tarefa
é processar dois tipos de consultas: atualizar o valor de um nó e calcular o
soma de valores na subárvore de um nó. Para resolver o problema, construímos uma árvore
array transversal que contém três valores para cada nó: o identificador do nó, o
tamanho da subárvore e o valor do nó. Por exemplo, a Fig. 10.15 mostra uma árvore
e a matriz correspondente.
2 3 4 5
6 7 8 9
126347895
126347895
3 35
23 4 5 1
6 7 8 9
4 4 3 1
ID do nó 126347895
tamanho da subárvore 9 21141111
valor do nó 234534311
Usando esta matriz, podemos calcular a soma dos valores em qualquer subárvore determinando primeiro
o tamanho da subárvore e, em seguida, somando os valores da subárvore correspondente.
nós. Por exemplo, a Fig. 10.16 mostra os valores que acessamos ao calcular o
soma de valores na subárvore do nó 4. A última linha do array nos diz que a soma
de valores é 3 + 4 + 3 + 1 = 11.
Para responder às consultas com eficiência, basta armazenar a última linha do array em um
indexado binário ou árvore de segmento. Depois disso, podemos atualizar um valor e calcular
a soma dos valores em tempo O(log n).
Consultas de caminho Usando uma matriz de travessia de árvore, também podemos calcular com eficiência somas de
valores nos caminhos do nó raiz para qualquer nó da árvore. Como exemplo, considere
um problema onde nossa tarefa é processar dois tipos de consultas: atualizar o valor de um
nó e calcular a soma dos valores em um caminho da raiz para um nó.
Para resolver o problema, construímos um array transversal de árvore que contém para cada
nó seu identificador, o tamanho de sua subárvore e a soma de valores em um caminho do
raiz ao nó (Fig. 10.17). Quando o valor de um nó aumenta em x, as somas de
todos os nós em sua subárvore aumentam em x. Por exemplo, a Fig. 10.18 mostra a matriz após
aumentando o valor do nó 4 em 1.
Para suportar ambas as operações, precisamos ser capazes de aumentar todos os valores em um intervalo
e recuperar um único valor. Isso pode ser feito em tempo O(log n) usando um binário indexado
ou árvore de segmentos e uma matriz de diferenças (consulte a Seção 9.2.3).
Machine Translated by Google
3 53
25 4 5 2
6 7 8 9
3 53 1
ID do nó 126347895
tamanho da subárvore 9 21141111
soma do caminho 4 9 12 7 9 14 12 10 6
2 3 4
5 6 7
O ancestral comum mais baixo de dois nós de uma árvore enraizada é o nó mais baixo cuja
subárvore contém ambos os nós. Por exemplo, na Fig. 10.19 o menor valor comum
ancestral dos nós 5 e 8 é o nó 2.
Um problema típico é processar com eficiência as consultas que exigem que encontremos o menor
ancestral comum de dois nós. A seguir, discutiremos duas técnicas eficientes para
processamento de tais consultas.
Primeiro Método Como podemos encontrar eficientemente o k- ésimo ancestral de qualquer nó na árvore,
podemos usar esse fato para dividir o problema em duas partes. Usamos dois ponteiros que
inicialmente apontam para os dois nós cujo ancestral comum mais baixo devemos encontrar.
Primeiro, garantimos que os ponteiros apontem para nós no mesmo nível da árvore.
Se este não for o caso inicialmente, movemos um dos ponteiros para cima. Depois disso, nós
Machine Translated by Google
1 1
2 3 4 2 3 4
5 6 7 5 6 7
8 8
Fig. 10.20 Duas etapas para encontrar o menor ancestral comum dos nós 5 e 8
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14
ID do nó 125268621314741
profundidade 1 2 3 2 3 4 3 21212 3 2 1
Fig. 10.21 Uma matriz transversal de árvore estendida para processar consultas de ancestral comum mais baixo
determine o número mínimo de passos necessários para mover ambos os ponteiros para cima
que eles apontarão para o mesmo nó. O nó para o qual os ponteiros apontam após este
é o ancestral comum mais baixo. Como ambas as partes do algoritmo podem ser executadas
em tempo O(log n) usando informações pré-computadas, podemos encontrar o menor valor comum
ancestral de quaisquer dois nós em tempo O(log n).
A Figura 10.20 mostra como podemos encontrar o menor ancestral comum dos nós 5 e
8 em nosso cenário de exemplo. Primeiro, movemos o segundo ponteiro um nível acima para que ele
aponta para o nó 6 que está no mesmo nível do nó 5. Em seguida, movemos os dois ponteiros
um passo para cima para o nó 2, que é o ancestral comum mais baixo.
Segundo Método Outra maneira de resolver o problema, proposta por Bender e Farach Colton [3], é
baseada em um array transversal de árvore estendido, às vezes chamado de Euler
árvore de passeio. Para construir a matriz, percorremos os nós da árvore usando busca em profundidade
e adicione cada nó à matriz sempre que a pesquisa em profundidade passar por ela
o nó (não apenas na primeira visita). Assim, um nó que tem k filhos aparece k + 1
vezes na matriz, e há um total de 2n ÿ 1 nós na matriz. Armazenamos dois
valores na matriz: o identificador do nó e a profundidade do nó na árvore.
A Figura 10.21 mostra a matriz resultante em nosso cenário de exemplo.
Agora podemos encontrar o menor ancestral comum dos nós a e b encontrando o nó
com a profundidade mínima entre os nós a e b na matriz. Por exemplo, Fig. 10.22
mostra como encontrar o menor ancestral comum dos nós 5 e 8. O nó de profundidade mínima entre
eles é o nó 2 cuja profundidade é 2, então o menor ancestral comum
dos nós 5 e 8 é o nó 2.
Observe que, como um nó pode aparecer várias vezes na matriz, pode haver várias maneiras de
escolher as posições dos nós a e b. No entanto, qualquer escolha corretamente
determina o ancestral comum mais baixo dos nós.
Machine Translated by Google
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14
ID do nó 125268621314741
profundidade 1 2 3 2 3 4 3 21212 3 2 1
2 3 4
5 6 7
Usando essa técnica, para encontrar o ancestral comum mais baixo de dois nós, basta processar
uma consulta de intervalo mínimo. Uma maneira usual é usar uma árvore de segmentos para processar
tais consultas em tempo O(log n). No entanto, como o array é estático, também podemos processar
consultas em tempo O(1) após um pré-processamento em tempo O(n log n).
Até agora, discutimos algoritmos online para consultas em árvore. Esses algoritmos são capazes de
processar consultas uma após a outra de forma que cada consulta seja respondida antes de receber
a próxima consulta. No entanto, em muitos problemas, a propriedade online não é necessária, e
podemos usar algoritmos offline para resolvê-los. Tais algoritmos
Machine Translated by Google
3 352 3 4 5 1
6 7 8 9
4 4 3 1
4 3
1 1 11
recebem um conjunto completo de perguntas que podem ser respondidas em qualquer ordem.
Algoritmos offline são geralmente mais fáceis de projetar do que algoritmos online.
Um método para construir um algoritmo offline é realizar uma travessia de árvore em profundidade
e manter as estruturas de dados nos nós. Em cada nó s, criamos uma estrutura de dados d[s] que é
baseada nas estruturas de dados dos filhos de s. Então, usando essa estrutura de dados, todas as
consultas relacionadas a s são processadas.
Como exemplo, considere o seguinte problema: Nos é dada uma árvore enraizada onde cada nó
tem algum valor. Nossa tarefa é processar consultas que pedem para calcular o número de nós com
valor x na subárvore do nó s. Por exemplo, na Fig. 10.24, a subárvore do nó 4 contém dois nós cujo
valor é 3.
Neste problema, podemos usar estruturas de mapas para responder às consultas. Por exemplo, a
Fig. 10.25 mostra os mapas para o nó 4 e seus filhos. Se criarmos essa estrutura de dados para cada
nó, podemos processar facilmente todas as consultas fornecidas, porque podemos lidar com todas as
consultas relacionadas a um nó imediatamente após a criação de sua estrutura de dados.
No entanto, seria muito lento criar todas as estruturas de dados do zero. Em vez disso, em cada nó
s, criamos uma estrutura de dados inicial d[s] que contém apenas o valor de s. Depois disso, passamos
pelos filhos de s e mesclamos d[s] e todas as estruturas de dados d[u] onde u é um filho de s. Por
exemplo, na árvore acima, o mapa para o nó 4 é criado mesclando os mapas na Fig. 10.26. Aqui o
primeiro mapa é a estrutura de dados inicial para o nó 4 e os outros três mapas correspondem aos nós
7, 8 e 9.
A fusão no nó s pode ser feita da seguinte forma: Percorremos os filhos de s e em cada filho u
mesclamos d[s] e d[u]. Sempre copiamos o conteúdo de d[u] para d[s]. No entanto, antes disso,
trocamos o conteúdo de d[s] e d[u] se d[s] for menor
Machine Translated by Google
do que d[u]. Ao fazer isso, cada valor é copiado apenas O(log n) vezes durante a árvore
transversal, o que garante que o algoritmo seja eficiente.
Para trocar o conteúdo de duas estruturas de dados a e b de forma eficiente, podemos apenas usar
o seguinte código:
trocar(a,b);
É garantido que o código acima funciona em tempo constante quando a e b são C++
estruturas de dados de biblioteca padrão.
Nesta seção, discutimos duas técnicas avançadas de processamento de árvores. A decomposição centroide
divide uma árvore em subárvores menores e as processa recursivamente. A decomposição leve pesada
representa uma árvore como um conjunto de caminhos especiais, o que nos permite
processar consultas de caminho com eficiência.
7 8
Machine Translated by Google
2 3 4
5 6 7
nó 5. Depois disso, o nó 5 é removido da árvore e processamos as três subárvores {1, 2}, {3, 4} e {6,
7, 8} recursivamente.
Usando a decomposição do centroide, podemos, por exemplo, calcular eficientemente o número
de caminhos de comprimento x em uma árvore. Ao processar uma árvore, primeiro encontramos um
centroide e calculamos o número de caminhos que passam por ele, o que pode ser feito em tempo
linear. Depois disso, removemos o centroide e processamos recursivamente as árvores menores.
O algoritmo resultante funciona em tempo O(n log n).
A decomposição leve pesada1 divide os nós de uma árvore em um conjunto de caminhos que são
chamados de caminhos pesados . Os caminhos pesados são criados para que um caminho entre
quaisquer dois nós da árvore possa ser representado como subcaminhos O(log n) de caminhos
pesados. Usando a técnica, podemos manipular nós em caminhos entre nós de árvore quase como
elementos em uma matriz, com apenas um fator O(log n) adicional.
Para construir os caminhos pesados, primeiro enraizamos a árvore arbitrariamente. Em seguida,
iniciamos o primeiro caminho pesado na raiz da árvore e sempre movemos para um nó que tenha
uma subárvore de tamanho máximo. Depois disso, processamos recursivamente as subárvores
restantes. Por exemplo, na Fig. 10.28, existem quatro caminhos pesados: 1–2–6–8, 3, 4–7 e 5
(observe que dois dos caminhos têm apenas um nó).
Agora, considere qualquer caminho entre dois nós na árvore. Como sempre escolhemos a
subárvore de tamanho máximo ao criar caminhos pesados, isso garante que podemos dividir o
caminho em subcaminhos O(log n) para que cada um deles seja um subcaminho de um único
caminho pesado. Por exemplo, na Fig. 10.28, o caminho entre os nós 7 e 8 pode ser dividido em dois
subcaminhos pesados: primeiro 7–4, depois 1–2–6–8.
O benefício da decomposição pesada-leve é que cada caminho pesado pode ser tratado como
uma matriz de nós. Por exemplo, podemos atribuir uma árvore de segmento para cada caminho
pesado e oferecer suporte a consultas de caminho sofisticadas, como calcular o valor mínimo do nó
em um caminho ou aumentar o valor de cada nó em um caminho. Tais consultas podem ser
1Sleator e Tarjan [29] introduziram a ideia no contexto de sua estrutura de dados link/cut tree.
Machine Translated by Google
processado em tempo O(log2 n),2 porque cada caminho consiste em caminhos pesados O(log n)
e cada caminho pesado pode ser processado em tempo O(log n).
Embora muitos problemas possam ser resolvidos usando a decomposição pesada-leve, é bom
ter em mente que muitas vezes há outra solução que é mais fácil de implementar.
Em particular, as técnicas apresentadas na Sec. 10.2.2 muitas vezes pode ser usado em vez de
decomposição pesada-leve.
Matemática
11
Este capítulo trata de tópicos matemáticos recorrentes na programação competitiva. Vamos discutir
resultados teóricos e aprender como usá-los na prática em algoritmos.
A Seção 11.1 discute tópicos teóricos dos números. Aprenderemos algoritmos para encontrar
fatores primos de números, técnicas relacionadas à aritmética modular e métodos eficientes para
resolver equações inteiras.
A Seção 11.2 explora maneiras de abordar problemas combinatórios: como contar eficientemente
todas as combinações válidas de objetos. Os tópicos desta seção incluem coeficientes binomiais,
números catalães e inclusão-exclusão.
A Seção 11.3 mostra como usar matrizes na programação de algoritmos. Por exemplo,
aprenderemos como tornar um algoritmo de programação dinâmica mais eficiente explorando uma
maneira eficiente de calcular potências de matrizes.
A Seção 11.4 primeiro discute técnicas básicas para calcular probabilidades de eventos e o conceito
de cadeias de Markov. Depois disso, veremos exemplos de algoritmos baseados em aleatoriedade.
A Seção 11.5 enfoca a teoria dos jogos. Primeiro, aprenderemos a jogar de maneira otimizada um
jogo simples de stick usando a teoria nim e, depois disso, generalizaremos a estratégia para uma ampla
variedade de outros jogos.
A teoria dos números é um ramo da matemática que estuda os números inteiros. Nesta seção,
discutiremos uma seleção de tópicos e algoritmos teóricos de números, como encontrar números
primos e fatores e resolver equações inteiras.
148 11 Matemática
= pÿ1 pÿ2
1 2 ··· pÿkkn,
onde p1, p2,..., pk são primos distintos e ÿ1, ÿ2,...,ÿk são inteiros positivos.
Por exemplo, a fatoração primária para 84 é
84 = 22 · 31 · 71.
pois para cada primo pi , existem ÿi +1 maneiras de escolher quantas vezes ele
aparece no fator. Por exemplo, como 12 = 22 · 3, ÿ (12) = 3 · 2 = 6.
Então, seja ÿ (n) a soma dos fatores de um inteiro n. Por exemplo, ÿ (12) = 28,
porque 1 + 2 + 3 + 4 + 6 + 12 = 28. Para calcular o valor de ÿ (n), podemos usar a
fórmula
k k
pÿi+1 ÿ 1
ÿ (n) = (1 + pi +···+ pÿi ) = eu
eu
,
i=1 i=1 pi - 1
Algoritmos Básicos Se um inteiro n não é primo, ele pode ser representado como
um produto a · b, onde a ÿ ÿn ou b ÿ ÿn, então certamente tem um fator entre 2 e ÿn .
Usando essa observação, podemos testar se um inteiro é primo e encontrar sua
fatoração em tempo O( ÿn) .
A seguinte função prime verifica se um dado inteiro n é primo. A função tenta
dividir n por todos os inteiros entre 2 e ÿn , e se nenhum deles
primo.dividir n, então n é
Machine Translated by Google
} return verdadeiro;
}
Então, os seguintes fatores de função constroem um vetor que contém a fatoração primária
de n. A função divide n por seus fatores primos e os adiciona ao vetor. O processo termina
quando o número restante n não possui fatores entre 2 e ÿn .
Se n > 1, é primo e último fator.
Observe que cada fator primo aparece no vetor tantas vezes quanto divide o
número. Por exemplo, 12 = 22 · 3, então o resultado da função é [2, 2, 3].
Propriedades dos primos É fácil mostrar que existe um número infinito de primos. Se o
número de primos fosse finito, poderíamos construir um conjunto P = {p1, p2,..., pn} que conteria
todos os primos. Por exemplo, p1 = 2, p2 = 3, p3 = 5 e assim por diante.
No entanto, usando tal conjunto P, poderíamos formar um novo primo
p1 p2 ··· pn + 1
isso seria maior do que todos os elementos em P. Isso é uma contradição, e o número de
primos tem que ser infinito.
A função de contagem de primos ÿ(n) fornece o número de primos até n. Por exemplo,
ÿ(10) = 4, pois os primos até 10 são 2, 3, 5 e 7. É possível mostrar que
n
ÿ(n) ÿ ln ,
n
o que significa que os primos são bastante frequentes. Por exemplo, uma aproximação para
ÿ(106) é 106/ ln 106 ÿ 72382, e o valor exato é 78498.
Machine Translated by Google
150 11 Matemática
2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
0 0 1 0 1 0 111 0 1 0 111 0 1 0 1
2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
2 3 2 5 2 7 2 3 2 11 2 13 2 3 2 17 2 19 2
Fig.11.2 Uma peneira estendida de Eratóstenes que contém o menor fator primo de cada número
O loop interno do algoritmo é executado n/x vezes para cada valor de x. Desta forma,
um limite superior para o tempo de execução do algoritmo é a soma harmônica
n
n/x = n/2 + n/3 + n/4 +···= O(n log n).
x=2
0,03
0,07
8 · 106 0,14
16 · 106 0,28
32 · 106 0,57
64 · 106 1,16
O máximo divisor comum dos inteiros a e b, denotado por mdc(a, b), é o maior
inteiro que divide a e b. Por exemplo, mdc(30, 12) = 6. Um conceito relacionado
é o menor múltiplo comum, denotado lcm(a, b), que é o menor inteiro que
é divisível por a e b. A fórmula
ab
lcm(a, b) =
mdc(a, b)
pode ser usado para calcular os menores múltiplos comuns. Por exemplo, lcm(30, 12) =
360/mdc(30, 12) = 60.
Uma maneira de encontrar mdc(a, b) é dividir a e b em fatores primos e então escolher
para cada primo a maior potência que aparece em ambas as fatorações. Por exemplo,
para calcular mdc(30, 12), podemos construir as fatorações 30 = 2 · 3 · 5 e
12 = 22 · 3, e conclua que mdc(30, 12) = 2 · 3 = 6. No entanto, esta técnica é
não é eficiente se a e b forem números grandes.
O algoritmo de Euclides fornece uma maneira eficiente de calcular o valor de gcd(a, b).
O algoritmo é baseado na fórmula
uma b=0
mdc(a, b) =
mdc(b, a mod b) b = 0.
Por exemplo,
152 11 Matemática
xxxxxxxx
Por que o algoritmo funciona? Para entender isso, considere a Fig. 11.3. onde x =
mdc(a, b). Como x divide a e b, ele também deve dividir a mod b, o que mostra por que a
fórmula recursiva é válida.
Pode-se provar que o algoritmo de Euclides funciona em tempo O(log n), onde n =
min(a, b).
ax + by = mdc(a, b).
30 · 1 + 12 · (ÿ2) = 6.
Podemos resolver também este problema usando a fórmula gcd(a, b) = gcd(b, a mod b).
Suponha que já resolvemos o problema para gcd(b, a mod b), e conhecemos os valores x
e y para os quais
que é igual
} else { int
x,y,g;
empate(x,y,g) = mdc(b,a%b); return
{y,x-(a/b)*y,g};
}
}
Machine Translated by Google
int x,y,g;
empate(x,y,g) = mdc(30,12);
"" ""
cout << x << << e << <<g<<"\n"; // 1 -2 6
Muitas vezes é necessário calcular eficientemente o valor de xn mod m. Isto pode ser feito
em tempo O(log n) usando a seguinte fórmula recursiva:
ÿ 1 n=0
xn = xn/2 · xn/2 n é par
ÿÿ
ÿÿ
xnÿ1 · x n é ímpar
Por exemplo, para calcular o valor de x100, primeiro calculamos o valor de x50 e
então use a fórmula x100 = x50 · x50. Então, para calcular o valor de x50, primeiro
calcule o valor de x25 e assim por diante. Como n é sempre pela metade quando é par, o
o cálculo leva apenas tempo O(log n).
O algoritmo pode ser implementado da seguinte forma:
Dois inteiros aeb são chamados coprimos se mdc(a, b ) = 1. Função totiente de Euler
... de n. Por exemplo,
ÿ(n) dá o número de inteiros entre 1 n que são primos
ÿ(10) = 4, porque 1, 3, 7 e 9 são primos de 10.
Qualquer valor de ÿ(n) pode ser calculado a partir da fatoração primária de n usando o
Fórmula
k
ÿ(n) = pÿiÿ1(pi - 1).
eu
i=1
154 11 Matemática
xÿ(m) mod m = 1
para todos os inteiros coprimos positivos x e m. Por exemplo, o teorema de Euler nos diz que 74 mod
10 = 1, porque 7 e 10 são primos e ÿ(10) = 4.
Se m é primo, ÿ(m) = m ÿ 1, então a fórmula se torna
xmÿ1 mod m = 1,
que é conhecido como o pequeno teorema de Fermat. Isso também implica que
que pode ser usado para calcular valores de xn se n for muito grande.
x · invm(x) mod m = 1.
Um inverso multiplicativo modular existe exatamente quando x e m são primos. Nesse caso, pode
ser calculado pela fórmula
invm(x) = xÿ(m)ÿ1,
invm(x) = xmÿ2.
Por exemplo,
ax + por = c,
onde a, b e c são constantes e os valores de xey devem ser encontrados. Cada número na
equação tem que ser um número inteiro. Por exemplo, uma solução para a equação
5x + 2a = 11
é x = 3 e y = ÿ2.
Podemos resolver eficientemente uma equação diofantina usando a equação de Euclides estendida
algoritmo (Seção 11.1.3) que fornece inteiros x e y que satisfazem a equação
ax + by = mdc(a, b).
Uma equação diofantina pode ser resolvida exatamente quando c é divisível por gcd(a, b).
Como exemplo, vamos encontrar inteiros x e y que satisfaçam a equação
A equação pode ser resolvida, porque mdc(39, 15) = 3 e 3 | 12. O algoritmo de Euclides
estendido nos dá
39 · 2 + 15 · (ÿ5) = 3,
39 · 8 + 15 · (ÿ20) = 12,
x = a1 mod m1 x =
a2 mod m2
···
x = um mod mn
Machine Translated by Google
156 11 Matemática
Onde
m1m2 ··· mn
Xk = .
mk
Nesta solução, para cada k = 1, 2,..., n,
Porque
Como todos os outros termos da soma são divisíveis por mk , eles não têm efeito sobre o resto
e x mod mk = ak .
Por exemplo, uma solução para
x = 3 mod 5 x
= 4 mod 7 x =
2 mod 3
3 · 21 · 1 + 4 · 15 · 1 + 2 · 35 · 2 = 263.
Uma vez que encontramos uma solução x, podemos criar um número infinito de outras
soluções, porque todos os números da forma
x + m1m2 ··· mn
são soluções.
11.2 Combinatória
n n-1 n-1
= + .
k k-1 k
n n!
=
k k!(n ÿ k)!
n n n n
+ + +···+ = 2n.
0 1 2 n
n n n n n
(a + b) = anb0 + anÿ1b1 +···+ a1bnÿ1 + a0 bilhões.
0 1 n-1 n
Machine Translated by Google
158 11 Matemática
Coeficientes binomiais também aparecem no triângulo de Pascal (Fig. 11.4) onde cada valor
é igual à soma dos dois valores acima.
n n!
= ,
k1, k2,..., km k1!k2!··· km!
dá o número de maneiras que um conjunto de n elementos pode ser dividido em subconjuntos de tamanhos
k1, k2,..., km, onde k1 + k2 +···+ km = n. Coeficientes multinomiais podem ser vistos
como generalização de coeficientes binomiais; se m = 2, a fórmula acima corresponde
à fórmula do coeficiente binomial.
tarefa restante é escolher as posições para as caixas vazias restantes. Existem n ÿ 2k + 1 dessas
caixas e k + 1 posições para elas. Assim, usando a fórmula de nÿk+1 Cenário 2, o número de
soluções é nÿ2k+1 .
• ()()() • (())()
• ()(()) • ((()))
• (()())
160 11 Matemática
nÿ1
Cn = CiCnÿiÿ1
i=0
1 2n
Cn = ,
n+1 n
2n 2n 2n n 2n 1 2n
ÿ
= ÿ
= .
n n+1 n n+1 n n+1 n
Contando Árvores Também podemos contar certas estruturas de árvores usando números catalães.
Primeiro, Cn é igual ao número de árvores binárias de n nós, supondo que os filhos da
esquerda e da direita sejam distinguidos. Por exemplo, como C3 = 5, existem 5 árvores
binárias de 3 nós (Fig. 11.8). Então, Cn também é igual ao número de árvores enraizadas
gerais de n + 1 nós. Por exemplo, existem 5 árvores enraizadas de 4 nós (Fig. 11.9).
Machine Translated by Google
Fig. 11.10
Princípio de inclusão-exclusão
para dois conjuntos
AAÿBB
Fig. 11.11
Princípio de inclusão-exclusão C
para três conjuntos
AÿCBÿC
AÿBÿC
UMA B
AÿB
11.2.3 Inclusão-Exclusão
Inclusão-exclusão é uma técnica que pode ser usada para contar o tamanho de uma união
de conjuntos quando os tamanhos das interseções são conhecidos e vice-versa. Um
exemplo simples da técnica é a fórmula
|A ÿ B|=|A|+|B|ÿ|A ÿ B|,
onde A e B são conjuntos e |X| denota o tamanho de X. A Figura 11.10 ilustra a fórmula.
Neste caso, queremos calcular o tamanho da união AÿB que corresponde à área da região
que pertence a pelo menos um círculo na Fig. 11.10. Podemos calcular a área de A ÿ B
somando primeiro as áreas de A e B e depois subtraindo a área de A ÿ B do resultado.
A mesma ideia pode ser aplicada quando o número de conjuntos é maior. Quando há
três conjuntos, a fórmula de inclusão-exclusão é
162 11 Matemática
Se uma interseção contém um número ímpar de conjuntos, seu tamanho é adicionado à resposta
e, caso contrário, seu tamanho é subtraído da resposta.
Observe que existem fórmulas semelhantes para calcular o tamanho de uma interseção a partir
dos tamanhos das uniões. Por exemplo,
|A ÿ B|=|A|+|B|ÿ|A ÿB |
|X1 ÿ X2 ÿ X3|=|X1|+|X2|+|X3|
ÿ|X1 ÿ X2|ÿ|X1 ÿ X3|ÿ|X2 ÿ X3|
+|X1 ÿ X2 ÿ X3| =
2 + 2 + 2 ÿ 1 ÿ 1 ÿ 1 + 1 = 4,
ÿ0 n=1
f (n) = 1 n=2
ÿÿ
O lema de Burnside pode ser usado para contar o número de combinações distintas de modo
que as combinações simétricas sejam contadas apenas uma vez. O lema de Burnside afirma
que o número de combinações é
n
1
c(k),
n
k=1
onde existem n maneiras de alterar a posição de uma combinação e existem c(k) combinações
que permanecem inalteradas quando a k-ésima maneira é aplicada.
Como exemplo, vamos calcular o número de colares de n pérolas, onde cada pérola tem m
cores possíveis. Dois colares são simétricos se forem semelhantes após girá-los. Por exemplo,
a Fig. 11.12 mostra quatro colares simétricos, que devem ser contados como uma única
combinação.
Existem n maneiras de mudar a posição de um colar, porque ele pode ser girado k = 0, 1,...,
n ÿ1 passos no sentido horário. Por exemplo, se k = 0, todos os mn colares permanecem
iguais, e se k = 1, apenas os m colares em que cada pérola tem a mesma cor permanecem os
mesmos. No caso geral, um total de colares mgcd(k,n) permanece o mesmo, porque blocos de
pérolas de tamanho gcd(k, n) se substituirão. Assim, de acordo com o lema de Burnside, o
número de colares distintos é
nÿ1
1
mgcd(k,n) .
n
k=0
34 + 3 + 32 + 3
= 24.
4
Machine Translated by Google
164 11 Matemática
1 2 3 4 1 243 1 3 2 4
1 3 4 2 1 4 2 3 1 4 32
2 1 3 4 2 143 2 3 1 4
241 3 3 1 2 4 3 2 1 4
3 4
A fórmula de Cayley afirma que há um total de nnÿ2 árvores rotuladas distintas de n nós.
Os nós são rotulados como 1, 2,..., n, e duas árvores são consideradas distintas se suas
estrutura ou rotulagem é diferente. Por exemplo, quando n = 4, existem 44ÿ2 = 16
árvores rotuladas, mostradas na Fig. 11.13.
A fórmula de Cayley pode ser provada usando códigos de Prüfer. Um código Prüfer é uma sequência
de n ÿ 2 números que descrevem uma árvore rotulada. O código é construído seguindo
um processo que remove n ÿ 2 folhas da árvore. A cada passo, a folha com o
menor rótulo é removido e o rótulo de seu único vizinho é adicionado ao código.
Por exemplo, o código Prüfer da árvore na Fig. 11.14 é [4, 4, 2], porque removemos
folhas 1, 3 e 4.
Podemos construir um código Prüfer para qualquer árvore e, mais importante, o código original
árvore pode ser reconstruída a partir de um código Prüfer. Assim, o número de árvores rotuladas de
n nós é igual a nnÿ2, o número de códigos Prüfer de comprimento n.
11.3 Matrizes
6 13 7 4
A= ÿ 7082 ÿ
ÿ 9 5 4 18 ÿ
Machine Translated by Google
é uma matriz de tamanho 3 × 4, ou seja, possui 3 linhas e 4 colunas. A notação [i, j] refere-
se ao elemento na linha i e coluna j em uma matriz. Por exemplo, na matriz acima, A[2, 3] =
8 e A[3, 1] = 9.
Um caso especial de uma matriz é um vetor que é uma matriz unidimensional de tamanho
n × 1. Por exemplo,
4
V= ÿ 7ÿ
ÿ 5ÿ
é um vetor que contém três elementos.
A transposta AT de uma matriz A é obtida quando as linhas e colunas de A são
trocado, ou seja, AT [i, j] = A[j,i]:
679
ÿ 13 0 5 ÿ
ÿ ÿ
AT = ÿ
784 ÿ
ÿ 4 2 18 ÿ
Uma matriz é uma matriz quadrada se tiver o mesmo número de linhas e colunas. Por
exemplo, a seguinte matriz é uma matriz quadrada:
3 12 4
S= ÿ 5 9 15 ÿ
ÿ 02 4 ÿ
Multiplicar uma matriz A por um valor x significa que cada elemento de A é multiplicado
por x. Por exemplo,
614 2·62·12·4 12 2 8
2· = = .
392 2·32·92·2 6 18 4
n
AB[i, j] = (A[i, k] · B[k, j]).
k=1
Machine Translated by Google
166 11 Matemática
UMA AB
A idéia é que cada elemento de AB seja uma soma dos produtos dos elementos de A e B
de acordo com a Fig. 11.15. Por exemplo,
14 1·1+4·21·6+4·9 9 42
1
ÿ3 9ÿ · = ÿ3·1+9·23·6+9·9 ÿ = ÿ 21 99
629
ÿ 8 6ÿ ÿ8·1+6·28·6+6·9 ÿ ÿ 20 102ÿÿ.
Multiplicar uma matriz por uma matriz identidade não a altera. Por exemplo,
100 14 14 14 14
ÿ 010 ÿ · ÿ 3 1
9ÿ = ÿ 3 9ÿ e ÿ3 9ÿ · = ÿ3 9
001
ÿ 001 ÿ ÿ 8 6ÿ ÿ 8 6ÿ ÿ 8 6ÿ ÿ 8 6ÿ ÿ .
1Embora o algoritmo direto O(n3)time seja suficiente na programação competitiva, existem algoritmos
teoricamente mais eficientes. Em 1969, Strassen [31] descobriu o primeiro algoritmo desse tipo, agora
chamado de algoritmo de Strassen, cuja complexidade de tempo é O(n2.81). O melhor algoritmo atual,
proposto por Le Gall [11] em 2014, funciona em tempo O(n2.37) .
Machine Translated by Google
Ak = A · A · A ··· A
k vezes
Por exemplo,
3
25 25 25 25 48 165
= · · = .
14 14 14 14 33 114
0
25 10
= .
14 01
A matriz Ak pode ser calculada eficientemente em tempo O(n3 log k) usando o algoritmo
na Sec. 11.1.4. Por exemplo,
8 4 4
25 25 25
= · .
14 14 14
Uma recorrência linear é uma função f (n) cujos valores iniciais são f (0), f (1), . . . , f (kÿ
1) e valores maiores são calculados recursivamente usando a fórmula
f (0) = 0
f (1) = 1
f (n) = f (n ÿ 1) + f (n ÿ 2)
Neste caso, k = 2 e c1 = c2 = 1.
Machine Translated by Google
168 11 Matemática
f (i) = f (i + 1) f
X·
f (i + 1) (i + 2)
Assim, os valores f (i) ef (i + 1) são dados como “entrada” para X, e X calcula os valores f (i +
1) ef (i + 2) a partir deles. Acontece que tal matriz é
01
X= .
11
Por exemplo,
01 01 5
· f (5) = · = = f (6)
.
11 f (6) 11 8 8 13 f (7)
n
01
f (n)
= Xn ·
f (0) = · 0 .
f (n + 1) f (1) 11 1
O valor de Xn pode ser calculado em tempo O(log n), então o valor de f (n) também pode ser
calculado em tempo O(log n).
Caso Geral Consideremos agora o caso geral onde f (n) é qualquer recorrência linear.
Novamente, nosso objetivo é construir uma matriz X para a qual
f (i) f (i + 1)
ÿ f (i + 1) ÿ ÿ f (i + 2) ÿ
X· =
ÿ ÿ ÿ ÿ
ÿ
.. ÿ ÿ
.. ÿ .
ÿ
. ÿ ÿ
. ÿ
ÿ f (i + k ÿ 1) ÿ ÿ f (i + k) ÿ
Tal matriz é
01 0 ··· 0
ÿ 00 1 ··· 0 ÿ
ÿ ÿ
.. .. .. ..
X=
ÿ ÿ
. . . ... . ÿ .
ÿ ÿ
00 0 ··· 1 ÿ
Nas primeiras k ÿ 1 linhas, cada elemento é 0, exceto que um elemento é 1. Essas linhas
substituem f (i) por f (i + 1), f (i + 1) por f (i + 2), e assim sobre. Então, a última linha contém
os coeficientes da recorrência para calcular o novo valor f (i + k).
Machine Translated by Google
4 1 12 3
4 5 6 4 5 6
2
Agora, f (n) pode ser calculado em tempo O(k3 log n) usando a fórmula
f (n) f (0)
ÿ f (n + 1) ÿ ÿ f (1) ÿ
= Xn · .
ÿ ÿ ÿ ÿ
ÿ
.. ÿ ÿ
.. ÿ
. ÿ ÿ
. ÿ
ÿ f (n + k ÿ 1) ÿ ÿ f (k - 1) ÿ
010000 ÿ
M= ÿ
ÿ
ÿ
ÿ
.
ÿ ÿ
000000 ÿ
ÿ 001010 ÿ
Então, a matriz
001110
ÿ 200022 ÿ
ÿ ÿ
020000
ÿ ÿ
M4 = 020000
ÿ
ÿ
ÿ
ÿ ÿ
000000
ÿ ÿ
ÿ 001110 ÿ
fornece o número de caminhos que contêm exatamente 4 arestas. Por exemplo,
M4[2, 5] = 2, porque existem dois caminhos de 4 arestas do nó 2 ao nó 5: 2 ÿ 1 ÿ
4 ÿ 2 ÿ 5 e 2 ÿ 6 ÿ 3 ÿ 2 ÿ 5.
Usando uma ideia semelhante em um grafo ponderado, podemos calcular para cada par
de nós (a, b) o menor comprimento de um caminho que vai de a a b e contém exatamente n
Machine Translated by Google
170 11 Matemática
arestas. Para calcular isso, definimos a multiplicação de matrizes de uma nova maneira, de modo que não
calculemos números de caminhos, mas minimizemos comprimentos de caminhos.
Como exemplo, considere o gráfico da Fig. 11.16b. Vamos construir uma matriz de
adjacência onde ÿ significa que uma aresta não existe, e outros valores correspondem
aos pesos das arestas. A matriz é
ÿÿÿ 4 ÿ ÿ
ÿ 2 ÿÿÿ 1 ÿ 4 ÿÿÿÿ 2 ÿ
ÿ ÿ
ÿ ÿ
M= ÿ ÿ
.
ÿ
ÿ
ÿ 1 ÿÿÿÿ ÿ
ÿÿÿÿÿÿ ÿ
ÿÿÿ3ÿ2ÿ ÿ
Em vez da fórmula
n
AB[i, j] = (A[i, k] · B[k, j])
k=1
n
AB[i, j] = min (A[i, k] + B[k, j])
k=1
ÿ ÿ 10 11 9 ÿ
ÿ 9 ÿÿÿ 8 ÿ 11 ÿÿÿÿ ÿ 8 ÿÿÿÿ 9 ÿ
ÿ ÿ
ÿ ÿ
M4 = ÿ
ÿ
ÿ
ÿ
,
ÿ ÿ
ÿÿÿÿÿÿ ÿ
ÿ ÿ ÿ 12 13 11 ÿ ÿ
ÿ
.. .. .. .. ÿ
. . ... . . ÿ
1 0 ··· 0 c1 0 1 ··· 0
ÿ c2 ÿ
ÿ ÿ
ÿ
.. .. .. .. ÿ
,
ÿ
. . ... . . ÿ
ÿ 0 0 ··· 1 cn ÿ
que nos diz que a solução é x1 = c1, x2 = c2,..., xn = cn. Para fazer isso, usamos três
tipos de operações de linha de matriz:
Cada operação acima preserva as informações das equações, o que garante que a
solução final esteja de acordo com as equações originais. Podemos processar
sistematicamente cada coluna da matriz para que o algoritmo resultante funcione em tempo O(n3) .
Como exemplo, considere o seguinte grupo de equações:
2 4 1 16
ÿ 1 2 5 17 ÿ
ÿ 311 8 ÿ
Machine Translated by Google
172 11 Matemática
1
12 2
8
ÿ 1 2 5 17 ÿ
ÿ 311 8 ÿ
Em seguida, adicionamos a primeira linha à segunda linha (multiplicado por -1) e a primeira linha a
a terceira linha (multiplicado por -3):
1
12 2
8
ÿ 9 ÿ
ÿ
00 2
9 ÿ
ÿ 0 ÿ5 ÿ1 2
ÿ16 ÿ
Depois disso, processamos a segunda coluna. Uma vez que o segundo valor no segundo
linha é zero, primeiro trocamos a segunda e a terceira linha:
1
12 2
8
ÿ ÿ
ÿ 0 ÿ5 ÿ1 2 ÿ16 ÿ
9
ÿ0 0 2
9 ÿ
Então multiplicamos a segunda linha por -1 5 e adicione-o à primeira linha (multiplicado por
ÿ2):
3 8
10 10 5
ÿ 1 16
ÿ
ÿ
ÿ
01 10 5
ÿ
9
00 2
9
ÿ ÿ
2
Finalmente, processamos a terceira coluna multiplicando-a primeiro por 3 9 e depois adicionando
1
para a primeira linha (multiplicado por - 10 ) e para a segunda linha (multiplicado por - 10 ):
1001
ÿ 0103 ÿ
ÿ 0012 ÿ
Agora a última coluna da matriz nos diz que a solução para o grupo original
de equações é x1 = 1, x2 = 3, x3 = 2.
Observe que a eliminação de Gauss só funciona se o grupo de equações tiver um único
solução. Por exemplo, o grupo
x1 + x2 = 2
2x1 + 2x2 = 4
Machine Translated by Google
x1 + x2 = 5 x1 + x2
=7
não pode ser resolvido, porque as equações são contraditórias. Se não houver uma
solução única, perceberemos isso durante o algoritmo, pois em algum momento não
poderemos processar uma coluna com sucesso.
11.4 Probabilidade
No nosso exemplo, os resultados desejados são aqueles em que o valor de cada carta
4
é o mesmo. Existem 13 desses
3
resultados, porque existem 13 possibilidades para o
4
valor das cartas e 3 maneiras de escolher 3 naipes de 4 naipes possíveis. Então, há um total de resultados, porque
52
escolhemos 3 cartas de 52 cartas. Assim, a probabilidade do evento é
3
4
13 3
1
=
52 3
425.
2Um baralho de cartas consiste em 52 cartas. Cada carta tem um naipe (espadas ÿ, ouros ÿ, paus ÿ ou
copas ÿ) e um valor (um número inteiro entre 1 e 13).
Machine Translated by Google
174 11 Matemática
têm o mesmo valor que a primeira carta. De maneira semelhante, a terceira etapa é bem-sucedida
com probabilidade 2/50. Assim, a probabilidade de que todo o processo seja bem-sucedido é
3 2 1
1 · =
· 51 50 425.
P(X) = p(x).
xÿX
Por exemplo, ao lançar um dado, p(x) = 1/6 para cada resultado x, então a probabilidade do
evento “o resultado é par” é
Como os eventos são representados como conjuntos, podemos manipulá-los usando operações
padrão de conjuntos:
P(A¯) = 1 ÿ P(A).
1 ÿ (5/6) 10.
Aqui 5/6 é a probabilidade de que o resultado de um único lançamento não seja seis, e (5/6)10
é a probabilidade de que nenhum dos dez lançamentos seja seis. O complemento disso é a
resposta para o problema.
Machine Translated by Google
P(A ÿ B) = P(A)P(B|A),
onde P(B|A) é a probabilidade condicional de que B aconteça assumindo que sabemos que
A acontece. Por exemplo, usando os eventos do nosso exemplo anterior, P(B|A) = 1/3,
porque sabemos que o resultado pertence ao conjunto {2, 4, 6} e um dos resultados é
menor que 4. Desta forma,
P(A ÿ B) = P(A)P(B).
Uma variável aleatória é um valor gerado por um processo aleatório. Por exemplo, ao
lançar dois dados, uma possível variável aleatória é
Por exemplo, se os resultados forem [4, 6] (o que significa que primeiro lançamos um quatro e
depois um seis), então o valor de X é 10.
Denotamos por P(X = x) a probabilidade de que o valor de uma variável aleatória X seja
x. Por exemplo, ao lançar dois dados, P(X = 10) = 3/36, porque o total
Machine Translated by Google
176 11 Matemática
o número de resultados é 36 e há três maneiras possíveis de obter a soma 10: [4, 6], [5, 5]
e [6, 4].
Valores esperados O valor esperado E[X] indica o valor médio de uma variável aleatória
X. O valor esperado pode ser calculado como uma soma
P(X = x)x,
x
Uma propriedade útil dos valores esperados é a linearidade. Isso significa que a soma
E[X1 + X2 +···+ Xn] sempre é igual à soma E[X1] + E[X2]+···+ E[Xn]. Isso vale mesmo se
as variáveis aleatórias dependerem umas das outras. Por exemplo, ao lançar dois dados,
a soma esperada de seus valores é
Vamos agora considerar um problema onde n bolas são colocadas aleatoriamente em n caixas,
e nossa tarefa é calcular o número esperado de caixas vazias. Cada bola tem a mesma
probabilidade de ser colocada em qualquer uma das caixas.
Por exemplo, a Fig. 11.17 mostra as possibilidades quando n = 2. Neste caso, o
número esperado de caixas vazias é
0+0+1+1 1
= .
4 2
Então, no caso geral, a probabilidade de que uma única caixa esteja vazia é
n-1 n
,
n
porque nenhuma bola deve ser colocada nele. Portanto, usando linearidade, o número esperado
de caixas vazias é
n-1 n
n· .
n
Machine Translated by Google
x 2 3 4 5 6 7 8 9 10 11 12
P(X = x) 1/36 2/36 3/36 4/36 5/36 6/36 5/36 4/36 3/36 2/36 1/36
a+b
E[X] = .
2
Em uma distribuição binomial, n tentativas são feitas e a probabilidade de que uma única
tentativa seja bem-sucedida é p. A variável aleatória X conta o número de tentativas bem-
sucedidas e a probabilidade de um valor x é
nÿx n
P(X = x) = px (1 ÿ p) ,
x
E[X] = pn.
xÿ1
P(X = x) = (1 ÿ p) p,
1
E[X] = .
p
Machine Translated by Google
178 11 Matemática
Uma cadeia de Markov é um processo aleatório que consiste em estados e transições entre
eles. Para cada estado, conhecemos as probabilidades de nos mudarmos para outros estados. Um Markov
A cadeia pode ser representada como um grafo cujos nós correspondem aos estados e arestas
descrever as transições.
Como exemplo, considere um problema em que estamos no andar 1 de um prédio de n andares.
A cada passo, andamos aleatoriamente um andar para cima ou um andar para baixo, exceto que
sempre subimos um andar do andar 1 e um andar abaixo do andar n. O que é
a probabilidade de estar no andar m após k passos?
Neste problema, cada andar do edifício corresponde a um estado em uma Markov
corrente. Por exemplo, a Fig. 11.18 mostra a cadeia quando n = 5.
A distribuição de probabilidade de uma cadeia de Markov é um vetor [p1, p2,..., pn], onde
pk é a probabilidade de que o estado atual seja k. A fórmula p1 + p2 +···+ pn = 1
sempre mantém.
No cenário acima, a distribuição inicial é [1, 0, 0, 0, 0], porque sempre
começam no piso 1. A próxima distribuição é [0, 1, 0, 0, 0], porque só podemos mover
do piso 1 para o piso 2. Depois disso, podemos mover um andar para cima ou um andar para baixo,
então a próxima distribuição é [1/2, 0, 1/2, 0, 0], e assim por diante.
Uma maneira eficiente de simular o passeio em uma cadeia de Markov é usar programação dinâmica. A
ideia é manter a distribuição de probabilidade, e a cada passo ir
através de todas as possibilidades como podemos nos mover. Usando este método, podemos simular um
caminhada de m passos em tempo O(n2m) .
As transições de uma cadeia de Markov também podem ser representadas como uma matriz que atualiza
a distribuição de probabilidade. No cenário acima, a matriz é
0 1/20 00
ÿ ÿ
ÿ
101/200 ÿ
0 1/201/2 0
ÿ
.
ÿ ÿ
ÿ ÿ
001/201
ÿ 00 01/2 0 ÿ
Quando multiplicamos uma distribuição de probabilidade por esta matriz, obtemos a nova distribuição após
mover um passo. Por exemplo, podemos passar da distribuição
Machine Translated by Google
0 1/20 00 1 0
ÿ ÿ ÿ 0ÿ ÿ 1ÿ
ÿ
101/200 0 1/201/2 0 ÿ ÿ ÿ
ÿ
ÿ
ÿ
ÿÿ0ÿÿ
ÿ
ÿ
= ÿÿ0ÿÿ
ÿ
ÿ
.
ÿ
001/201
ÿ
0 ÿ
0 ÿ
ÿ 00 01/2 0 ÿ ÿ 0ÿ ÿ 0ÿ
Às vezes, podemos usar aleatoriedade para resolver um problema, mesmo que o problema
não esteja relacionado a probabilidades. Um algoritmo aleatório é um algoritmo baseado na
aleatoriedade. Existem dois tipos populares de algoritmos aleatórios:
• Um algoritmo de Monte Carlo é um algoritmo que às vezes pode dar uma resposta
errada. Para que tal algoritmo seja útil, a probabilidade de uma resposta errada
deve ser pequena.
• Um algoritmo de Las Vegas é um algoritmo que sempre dá a resposta correta, mas seu
tempo de execução varia aleatoriamente. O objetivo é projetar um algoritmo que seja
eficiente com alta probabilidade.
A seguir, passaremos por três exemplos de problemas que podem ser resolvidos usando
esses algoritmos.
180 11 Matemática
3 4
Observe que o pior caso do algoritmo requer O(n2)tempo, porque é possível que x seja
sempre escolhido de tal forma que seja um dos menores ou maiores elementos do array e O(n)
passos sejam necessários. No entanto, a probabilidade disso é tão pequena que podemos
supor que isso nunca aconteça na prática.
68 87
= ,
13 32
mas
68 3 87 3
= .
13 6 32 6
Coloração de Grafos Dado um grafo que contém n nós e m arestas, nosso problema final é
encontrar uma maneira de colorir os nós usando duas cores de modo que, para pelo menos m/
2 arestas, as extremidades tenham cores diferentes. Por exemplo, a Fig. 11.19 mostra uma
coloração válida de um gráfico. Neste caso, o grafo contém sete arestas, e as extremidades de
cinco delas têm cores diferentes na coloração.
O problema pode ser resolvido usando um algoritmo de Las Vegas que gera colorações
aleatórias até que uma coloração válida seja encontrada. Em uma coloração aleatória, a cor de
cada nó é escolhida independentemente para que a probabilidade de ambas as cores seja 1/2.
Assim, o número esperado de arestas cujos extremos têm cores diferentes é m/2.
Machine Translated by Google
Como se espera que uma coloração aleatória seja válida, encontraremos rapidamente uma coloração válida
na prática.
Nesta seção, focamos em jogos de dois jogadores onde os jogadores se movem alternadamente e têm o
mesmo conjunto de movimentos disponíveis, e não há elementos aleatórios. Nosso objetivo é encontrar
uma estratégia que possamos seguir para vencer o jogo, não importa o que o oponente faça, se tal estratégia
existir.
Acontece que existe uma estratégia geral para tais jogos, e podemos analisar os jogos usando a teoria
nim. Primeiramente, analisaremos jogos simples em que os jogadores retiram varetas de pilhas e, em
seguida, generalizaremos a estratégia utilizada nesses jogos para outros jogos.
Vamos considerar um jogo que começa com um monte de n varetas. Dois jogadores se movem
alternadamente e, a cada movimento, o jogador deve remover 1, 2 ou 3 varetas do monte.
Finalmente, o jogador que remover a última vareta ganha o jogo.
Por exemplo, se n = 10, o jogo pode proceder da seguinte forma:
Este jogo consiste nos estados 0, 1, 2,..., n, onde o número do estado corresponde
corresponde ao número de varetas restantes.
Um estado vencedor é um estado em que o jogador vencerá o jogo se jogar de maneira ideal, e um
estado perdedor é um estado em que o jogador perderá o jogo se o oponente jogar de maneira ideal.
Acontece que podemos classificar todos os estados de um jogo de modo que cada estado seja um estado
vencedor ou um estado perdedor.
No jogo acima, o estado 0 é claramente um estado perdedor, porque o jogador não pode fazer nenhum
movimento. Os estados 1, 2 e 3 são estados vencedores, porque o jogador pode remover 1, 2 ou 3 varetas
e ganhar o jogo. O estado 4, por sua vez, é um estado perdedor, pois qualquer movimento leva a um estado
que é um estado vencedor para o oponente.
De maneira mais geral, se houver um movimento que leve do estado atual para um estado perdedor, é
um estado vencedor e, caso contrário, é um estado perdedor. Usando esta observação, podemos classificar
todos os estados de um jogo começando com estados perdedores onde não há movimentos possíveis. A
Figura 11.20 mostra a classificação dos estados 0 ... 15 (W denota um estado vencedor e L denota um
estado perdedor).
Machine Translated by Google
182 11 Matemática
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
L WWW L WWW L WWW L WWW
4
5
7
8
6
É fácil analisar este jogo: um estado k é um estado perdedor se k é divisível por 4, caso
contrário é um estado vencedor. Uma ótima maneira de jogar o jogo é sempre escolher um
movimento após o qual o número de varetas na pilha seja divisível por 4. Finalmente, não há
mais varetas e o oponente perdeu. Claro, esta estratégia requer que o número de varetas não
seja divisível por 4 quando for a nossa jogada. Se for, não há nada que possamos fazer, e o
adversário ganhará o jogo se jogar de forma otimizada.
Consideremos então outro jogo de varetas, onde em cada estado k, é permitido remover
qualquer número x de varetas tal que x seja menor que k e divida k. Por exemplo, no estado 8
podemos remover 1, 2 ou 4 varetas, mas no estado 7 o único movimento permitido é remover
1 vareta. A Figura 11.21 mostra os estados 1 ... 9 do jogo como um
são
grafo
os estados
de estados,
e as cujos
arestas
nós
são os movimentos entre eles:
O estado final neste jogo é sempre o estado 1, que é um estado perdedor, porque não há
movimentos válidos. A Figura 11.22 mostra a classificação dos estados 1 ... 9.
neste
Acontece
jogo, todos
que,
os estados pares são estados vencedores e todos os estados ímpares são estados perdedores.
O jogo nim é um jogo simples que tem um papel importante na teoria dos jogos, pois muitos
outros jogos podem ser jogados usando a mesma estratégia. Primeiro, focamos no nim e,
depois disso, generalizamos a estratégia para outros jogos.
Existem n heaps em nim, e cada heap contém um certo número de bastões. Os jogadores
se movem alternadamente e, em cada turno, o jogador escolhe um monte que ainda contém
varetas e remove qualquer número de varetas. O vencedor é o jogador que remove a última
vara.
Machine Translated by Google
Os estados em nim são da forma [x1, x2,..., xn], onde xi denota o número de varetas na pilha
i. Por exemplo, [10, 12, 5] é um estado onde existem três heaps com 10, 12 e 5 bastões. O
estado [0, 0,..., 0] é um estado perdedor, pois não é possível remover nenhum stick, e este é
sempre o estado final.
Análise Acontece que podemos classificar facilmente qualquer estado nim calculando a soma
nim s = x1 ÿ x2 ÿ···ÿ xn, onde ÿ denota a operação xor. Os estados cuja soma nim é 0 são
estados perdedores e todos os outros estados são estados vencedores. Por exemplo, a soma
nim de [10, 12, 5] é 10 ÿ 12 ÿ 5 = 3, então o estado é um estado vencedor.
Mas como a soma nim está relacionada ao jogo nim? Podemos explicar isso observando
como a soma nim muda quando o estado nim muda.
Estados perdedores: O estado final [0, 0,..., 0] é um estado perdedor e sua soma nim é 0,
como esperado. Em outros estados perdedores, qualquer movimento leva a um estado vencedor,
porque quando um único valor xi muda, o nim sum também muda, então o nim sum é diferente
de 0 após o movimento.
Estados vencedores: Podemos passar para um estado perdedor se houver algum heap i para
ÿ s < xi . xi ÿ s permanece,
o qual nesteo caso,
que levará
podemos
a umremover
estado perdedor.
bastões doSempre
heap i existe
para que
umele
heap,
contenha
onde xixi
tem um bit na posição do bit mais à esquerda de s.
Exemplo Como exemplo, considere o estado [10, 12, 5]. Este estado é um estado vencedor,
porque sua soma nim é 3. Assim, deve haver um movimento que leve a um estado perdedor.
A seguir, descobriremos tal movimento.
A soma nim do estado é a seguinte:
10 1010
12 1100 5
0101 3
0011
Nesse caso, o heap com 10 bastões é o único heap que possui um bit na posição do bit mais
à esquerda da soma nim:
10 1010
12 1100 5
0101
3 0011
9 1001
12 1100
5 0101
0 0000
Jogo Misère Em um jogo misère nim, o objetivo do jogo é oposto, então o jogador que retirar a
última vareta perde o jogo. Acontece que o jogo misère nim pode ser jogado de forma otimizada
quase como o jogo nim padrão.
Machine Translated by Google
184 11 Matemática
2 0 2
A ideia é primeiro jogar o jogo misère como o jogo padrão, mas mudar a estratégia no final do
jogo. A nova estratégia será introduzida em uma situação em que cada heap conterá no máximo um
stick após o próximo movimento. No jogo padrão, devemos escolher um movimento após o qual haja
um número par de pilhas com uma vareta. No entanto, no jogo misère, escolhemos um movimento
para que haja um número ímpar de pilhas com uma vareta.
Essa estratégia funciona porque um estado onde a estratégia muda sempre aparece no jogo, e
esse estado é um estado vencedor, porque contém exatamente um heap que tem mais de um stick,
então a soma nim não é 0.
O teorema de Sprague-Grundy generaliza a estratégia usada em nim para todos os jogos que
atendem aos seguintes requisitos:
Números Grundy A idéia é calcular para cada estado de jogo um número Grundy que corresponda
ao número de varetas em uma pilha de nim. Quando sabemos os números de Grundy de todos os
estados, podemos jogar o jogo como o jogo nim.
O número Grundy de um estado de jogo é calculado usando a fórmula
onde g1, g2,..., gn são os números Grundy dos estados para os quais podemos passar do estado, e
a função mex fornece o menor número não negativo que não está no conjunto. Por exemplo, mex({0,
1, 3}) = 2. Se um estado não tem movimentos possíveis, seu número Grundy é 0, porque mex(ÿ) = 0.
Por exemplo, a Fig. 11.23 mostra um gráfico de estado de um jogo onde cada estado recebe seu
número Grundy. O número Grundy de um estado perdedor é 0, e o número Grundy de um estado
vencedor é um número positivo.
Machine Translated by Google
*
*
****@
Subjogos Suponha que nosso jogo consiste em subjogos e, em cada turno, o jogador primeiro
escolhe um subjogo e depois um lance no subjogo. O jogo termina quando não é possível fazer
nenhum movimento em nenhum subjogo. Neste caso, o número Grundy de um jogo é igual à
soma nim dos números Grundy dos subjogos. O jogo pode então ser jogado como um jogo nim,
calculando todos os números Grundy para subjogos e, em seguida, sua soma nim.
Como exemplo, considere um jogo que consiste em três labirintos. Em cada turno, o jogador
escolhe um dos labirintos e depois move a figura no labirinto. A Figura 11.26 mostra uma
configuração inicial do jogo, e a Figura 11.27 mostra os números Grundy correspondentes. Nesta
configuração, a soma nim dos números Grundy é 2 ÿ 3 ÿ 3 = 2, então o primeiro jogador pode
ganhar o jogo. Um movimento ideal é subir dois degraus no primeiro labirinto, o que produz a
soma nim 0 ÿ 3 ÿ 3 = 0.
Machine Translated by Google
186 11 Matemática
@ @ @
significando que o movimento k divide o jogo em m subjogos cujos números Grundy são
ak,1, ak,2,..., ak,m.
Um exemplo de tal jogo é o jogo de Grundy. Inicialmente, há um único heap
que tem n varas. Em cada turno, o jogador escolhe um monte e o divide em dois
heaps não vazios de modo que os heaps tenham tamanhos diferentes. O jogador que fizer o
último movimento ganha o jogo.
Seja g(n) o número Grundy de um heap de tamanho n. O número Grundy
pode ser calculado percorrendo todas as maneiras de dividir o heap em dois heaps. Por
exemplo, quando n = 8, as possibilidades são 1 + 7, 2 + 6 e 3 + 5, então
Neste jogo, o valor de g(n) é baseado nos valores de g(1), . . . , g(n ÿ 1). o
casos base são g(1) = g(2) = 0, porque não é possível dividir os heaps de 1
e 2 paus em pilhas menores. Os primeiros números Grundy são:
g(1) = 0
g(2) = 0
g(3) = 1
g(4) = 0
Machine Translated by Google
g(5) = 2
g(6) = 1
g(7) = 0
g(8) = 2
O número Grundy para n = 8 é 2, então é possível ganhar o jogo. A jogada
vencedora é criar pilhas 1 + 7, porque g(1) ÿ g(7) = 0.
Machine Translated by Google
3 4 3 4
1 2 3
4 5 6
UMA
C D
Um grafo componente é um grafo acíclico direcionado, por isso é mais fácil de processar do que o grafo
gráfico original. Como o grafo não contém ciclos, sempre podemos construir um
classificação topológica e usar programação dinâmica para processá-lo.
4 5 6
4 5 6
1 2 3 1 2 3
7 7
4 5 6 4 5 6
passo 1 passo 2
1 2 3 1 2 3
7 7
4 5 6 4 5 6
etapa 3 Passo 4
Após isso, o algoritmo percorre a lista de nós criada pela primeira busca, em
ordem inversa . Se um nó não pertence a um componente, o algoritmo cria um novo
componente iniciando uma busca em profundidade que adiciona todos os novos nós encontrados durante o
pesquisa para o novo componente. Observe que, como todas as arestas estão invertidas, os componentes
não “vaze” para outras partes do gráfico.
A Figura 12.5 mostra como o algoritmo processa nosso gráfico de exemplo. A ordem de processamento
dos nós é [3, 7, 6, 1, 2, 5, 4]. Primeiro, o nó 3 gera o componente
{3, 6, 7}. Em seguida, os nós 7 e 6 são ignorados, pois já pertencem a um componente. Depois disso, o
nó 1 gera o componente {1, 2} e o nó 2 é ignorado.
Finalmente, os nós 5 e 4 geram os componentes {5} e {4}.
A complexidade de tempo do algoritmo é O(n+m), porque o algoritmo executa
duas buscas em profundidade.
Machine Translated by Google
onde cada ai e bi é uma variável lógica (x1, x2,..., xn) ou uma negação de uma variável lógica
(¬x1, ¬x2,..., ¬xn). Os símbolos “ÿ” e “ÿ” denotam operadores lógicos “e” e “ou”. Nossa tarefa
é atribuir a cada variável um valor para que a fórmula seja verdadeira, ou afirmar que isso não
é possível.
Por exemplo, a fórmula
L1 = (x2 ÿ ¬x1) ÿ (¬x1 ÿ ¬x2) ÿ (x1 ÿ x3) ÿ (¬x2 ÿ ¬x3) ÿ (x1 ÿ x4)
ÿ x1 = falso
x2 = falso
ÿÿÿÿ
x3 = verdadeiro
ÿÿÿÿ x4 = verdadeiro
No entanto, a fórmula
é sempre falso, independentemente de como atribuímos os valores. A razão para isso é que
não podemos escolher um valor para x1 sem criar uma contradição. Se x1 for falso, tanto x2
quanto ¬x2 devem ser verdadeiros, o que é impossível, e se x1 for verdadeiro, x3 e ¬x3
devem ser verdadeiros, o que também é impossível.
Uma instância do problema 2SAT pode ser representada como um grafo de implicação
cujos nós correspondem às variáveis xi e negações ¬xi , e as arestas determinam
entre as variáveis.
as conexões
Cada
par (ai ÿ bi) gera duas arestas: ¬ai ÿ bi e ¬bi ÿ ai .
Isso significa que se ai não vale, bi deve valer e vice-versa.
Por exemplo, a Fig. 12.6 mostra o gráfico de implicação de L1 e a Fig. 12.7 mostra o gráfico
de implicação de L2.
A estrutura do gráfico de implicação nos diz se é possível atribuir os valores das variáveis
para que a fórmula seja verdadeira. Isso pode ser feito exatamente quando não há nós xi e
¬xi tais que ambos os nós pertencem ao mesmo fortemente
¬x4 x1 ¬x2 x3
Machine Translated by Google
x3 x2 ¬x2 ¬x3
x1
componente conectado. Se houver tais nós, o grafo contém um caminho de xi a ¬xi e também um
caminho de ¬xi a xi , então xi e ¬xi devem serde
verdadeiros,
implicaçãoode
que
L1não
nãoépossui
possível.
os nós
Por xiexemplo,
e ¬xi taiso que
grafo
ambos os nós pertençam ao mesmo componente fortemente conectado, então existe uma solução.
Então, no grafo de implicação de L2 todos os nós pertencem ao mesmo componente fortemente
conectado, então não há soluções.
Se existir uma solução, os valores das variáveis podem ser encontrados percorrendo os nós do
gráfico de componentes em uma ordem topológica reversa. Em cada etapa, processamos um
componente que não contém arestas que levam a um componente não processado. Se as variáveis no
componente não tiverem valores atribuídos, seus valores serão determinados de acordo com os valores
no componente e, se já tiverem valores, os valores permanecerão inalterados. O processo continua até
que cada variável tenha recebido um valor.
A Figura 12.8 mostra o gráfico de componentes de L1. Os componentes são A = {¬x4}, B = {x1, x2,
¬x3}, C = {¬x1, ¬x2, x3} e D = {x4}. Ao construir a solução, primeiro processamos o componente D onde
x4 se torna verdadeiro. Depois disso, processamos o componente C onde x1 e x2 se tornam falsos e x3
se torna verdadeiro. Todas as variáveis receberam valores, de modo que os componentes restantes A
e B não alteram os valores das variáveis.
Observe que esse método funciona, porque o grafo de implicação tem uma estrutura especial: se
houver um caminho do nó xi para o nó x j e do nó x j para o nó ¬x j , então o nó xi nunca se torna
verdadeiro.
A razão para isso é que também existe um caminho do nó ¬x j para o nó ¬xi , e tanto xi quanto xj se
tornam falsos .
Um problema mais difícil é o problema 3SAT, onde cada parte da fórmula é da forma (ai ÿ bi ÿ ci).
Este problema é NP-difícil, então nenhum algoritmo eficiente para resolver o problema é conhecido.
Nesta seção, discutimos dois tipos especiais de caminhos em grafos: um caminho euleriano é um
caminho que passa por cada aresta exatamente uma vez, e um caminho hamiltoniano é um caminho
que visita cada nó exatamente uma vez. Embora esses caminhos pareçam bastante semelhantes à
primeira vista, os problemas computacionais relacionados a eles são muito diferentes.
Machine Translated by Google
4 5 4 5 6.
3.
3 1. 3. 3
2.
4 5 4 5 4.
Um caminho euleriano é um caminho que passa exatamente uma vez por cada aresta de um grafo.
Além disso, se tal caminho começa e termina no mesmo nó, é chamado de circuito euleriano. A Figura
12.9 mostra um caminho euleriano do nó 2 ao nó 5, e a Fig. 12.10 mostra um circuito euleriano que
começa e termina no nó 1.
A existência de caminhos e circuitos eulerianos depende dos graus dos nós.
Primeiro, um grafo não direcionado tem um caminho euleriano exatamente quando todas as arestas
pertencem ao mesmo componente conectado e
No primeiro caso, cada caminho euleriano é também um circuito euleriano. No segundo caso, os
nós de grau ímpar são os pontos finais de um caminho euleriano, que não é um circuito euleriano. Na
Fig. 12.9, os nós 1, 3 e 4 têm grau 2 e os nós 2 e 5 têm grau 3. Exatamente dois nós têm um grau
ímpar, então existe um caminho Euleriano entre os nós 2 e 5, mas o grafo não tem um circuito euleriano.
Na Fig. 12.10, todos os nós têm um grau par, então o grafo tem um circuito euleriano.
Para determinar se um grafo direcionado possui caminhos eulerianos, focamos nos graus de entrada
e de saída dos nós. Um grafo direcionado contém um caminho euleriano exatamente quando todas as
arestas pertencem ao mesmo componente fortemente conexo e
No primeiro caso, cada caminho euleriano também é um circuito euleriano e, no segundo caso, o
grafo possui um caminho euleriano que começa no nó cujo grau de saída é maior e termina no nó cujo
grau de entrada é maior. Por exemplo, na Fig. 12.11, os nós 1, 3 e 4 têm grau de entrada 1 e grau de
saída 1, o nó 2 tem grau de entrada 1 e grau de saída
Machine Translated by Google
3 4. 6. 3
4 5 4 5 2.
3.
2, e o nó 5 tem grau de entrada 2 e grau de saída 1. Portanto, o grafo contém um caminho euleriano
do nó 2 ao nó 5.
Se um grafo não tem um circuito euleriano, mas tem um caminho euleriano, ainda podemos usar
o algoritmo de Hierholzer para encontrar o caminho adicionando uma aresta extra ao grafo e
removendo a aresta após a construção do circuito. Por exemplo, em um grafo não direcionado,
adicionamos a aresta extra entre os dois nós de grau ímpar.
Como exemplo, a Fig. 12.12 mostra como o algoritmo de Hierholzer constrói um circuito euleriano
em um grafo não direcionado. Primeiro, o algoritmo adiciona um subcircuito 1 ÿ 2 ÿ 3 ÿ 1, depois um
subcircuito 2 ÿ 5 ÿ 6 ÿ 2 e, finalmente, um subcircuito 6 ÿ 3 ÿ 4 ÿ 7 ÿ 6. do circuito, construímos com
sucesso um circuito euleriano.
Um caminho hamiltoniano é um caminho que visita cada nó de um grafo exatamente uma vez. Além
disso, se tal caminho começa e termina no mesmo nó, é chamado de circuito hamiltoniano. Por
exemplo, a Fig. 12.13 mostra um gráfico que tem um caminho hamiltoniano e um circuito hamiltoniano.
1 1
1.
3.
2.
2 3 4 2 3 4
5 6 7 5 6 7
passo 1 passo 2
1 1
1. 1.
6. 10.
5. 9. 5.
2 3 4 2 3 4
4.
2. 2. 8. 4. 6.
5 6 7 5 6 7
3. 3. 7.
etapa 3 Passo 4
1.
1 2 1 2 4. 1 2 2.
3 1. 3. 3 5. 3
4 5 4 5 4 5 3.
2. 4.
12.2.3 Aplicativos
Sequências De Bruijn Uma sequência De Bruijn é uma string que contém cada string de
comprimento n exatamente uma vez como uma substring, para um alfabeto fixo de k caracteres. O comprimento
de tal string é kn + n ÿ 1 caracteres. Por exemplo, quando n = 3 e k = 2, um
exemplo de uma sequência de De Bruijn é
0001011100.
As substrings desta string são todas combinações de três bits: 000, 001, 010, 011,
100, 101, 110 e 111.
Machine Translated by Google
00 1 0 11
0 1
0 0
10
Uma sequência de De Bruijn sempre corresponde a um caminho Euleriano em um grafo onde cada
nó contém uma string de n ÿ 1 caracteres, e cada aresta adiciona um caractere à string. Por exemplo,
o gráfico da Fig. 12.14 corresponde ao cenário onde n = 3 ek = 2. Para criar uma sequência De Bruijn,
começamos em um nó arbitrário e seguimos um caminho Euleriano que visita cada aresta exatamente
uma vez. Quando os caracteres no nó inicial e nas bordas são somados, a string resultante tem kn +n
ÿ1 caracteres e é uma sequência De Bruijn válida.
b e
c d
No problema de fluxo máximo , temos um grafo ponderado direcionado que contém dois nós
especiais: uma fonte é um nó sem arestas de entrada e um sink é um nó sem arestas de saída.
Nossa tarefa é enviar o máximo de fluxo possível da fonte para o coletor. Cada borda tem uma
capacidade que restringe o fluxo que pode passar pela borda, e em cada nó intermediário, o
fluxo de entrada e saída tem que ser igual.
Como exemplo, considere o gráfico da Fig. 12.17, onde o nó 1 é a fonte e o nó 6 é o
sorvedouro. A vazão máxima neste gráfico é 7, mostrada na Fig. 12.18. A notação v/k significa
que um fluxo de v unidades é roteado através de uma aresta cuja capacidade é k unidades. O
tamanho do fluxo é 7, pois a fonte envia 3 + 4 unidades de fluxo e o coletor recebe 5 + 2
unidades de fluxo. É fácil ver que esse fluxo é máximo, pois a capacidade total das arestas que
levam ao sumidouro é 7.
Acontece que o problema de fluxo máximo está conectado a outro problema de grafo, o
problema de corte mínimo , onde nossa tarefa é remover um conjunto de arestas do grafo de
modo que não haja caminho da fonte para o sorvedouro após a remoção e o peso total das
arestas removidas é mínimo.
Por exemplo, considere novamente o gráfico da Fig. 12.17. O tamanho mínimo de corte é
7, pois basta remover as arestas 2 ÿ 3 e 4 ÿ 5, conforme mostrado na Fig. 12.19.
Depois de remover as bordas, não haverá caminho da fonte para o coletor. O tamanho do corte
é 6 + 1 = 7, e o corte é mínimo, pois não existe corte válido cujo peso seja menor que 7.
1 3 8 6
4 4 5 2
1
4/4 4 5 2/2
1/1
Machine Translated by Google
4 4 5 2
1
Fig. 12.20 6
2 3
Representação 5 0 5
gráfica no algoritmo de Ford-Fulkerson 0 0
1 30 08 6
4 2
0 1 0
4 5
0
Não é coincidência que a vazão máxima e o corte mínimo sejam iguais em nosso gráfico
de exemplo. Em vez disso, acontece que eles são sempre iguais, então os conceitos são
dois lados da mesma moeda. Em seguida, discutiremos o algoritmo de Ford-Fulkerson que
pode ser usado para encontrar o fluxo máximo e o corte mínimo de um grafo. O algoritmo
também nos ajuda a entender por que eles são iguais.
6 4
2 3 2 3
5 0 5 3 2 5
0 0 2 0
1 30 08 6 1 30 26 6
4 2 4 0
0 1 0 0 1 2
4 5 4 5
0 0
passo 1
4 1
2 3 2 3
3 2 5 3 5 2
2 0 2 3
1 30 26 6 1 03 26 6
4 0 1 0
0 1 2 3 1 2
4 5 4 5
0 0
passo 2
1 1
2 3 2 3
3 5 2 3 5 1
2 3 2 4
1 03 26 6 1 03 17 6
1 0 0 0
3 1 2 4 0 2
4 5 4 5
0 1
etapa 3
1 0
2 3 2 3
3 5 1 2 6 0
2 4 3 5
1 03 17 6 1 03 17 6
0 0 0 0
4 0 2 4 0 2
4 5 4 5
1 1
Passo 4
1 03 17 6
30 50
4 0 2
4 5
1
de busca em profundidade para encontrar caminhos. Pode-se provar que isso garante que
o fluxo aumente rapidamente, e a complexidade de tempo do algoritmo é O(m2n).
O algoritmo de dimensionamento de capacidade1 usa a pesquisa em profundidade para
encontrar caminhos em que cada peso de borda seja pelo menos um valor limite de número
inteiro. Inicialmente, o valor limite é um número grande, por exemplo, a soma de todos os
pesos das arestas do gráfico. Sempre quando um caminho não pode ser encontrado, o valor
do limite é dividido por 2. O algoritmo termina quando o valor do limite se torna 0. A
complexidade de tempo do algoritmo é O(m2 log c), onde c é o valor do limite inicial.
Na prática, o algoritmo de dimensionamento de capacidade é mais fácil de implementar, porque a primeira
pesquisa em profundidade pode ser usada para encontrar caminhos. Ambos os algoritmos são eficientes o
suficiente para problemas que normalmente aparecem em concursos de programação.
Cortes Mínimos Acontece que uma vez que o algoritmo de Ford-Fulkerson encontrou um fluxo
máximo, ele também determinou um corte mínimo. Considere o grafo produzido pelo algoritmo
e seja A o conjunto de nós que podem ser alcançados a partir da fonte usando arestas de peso
positivo. Agora o corte mínimo consiste nas arestas do grafo original que começam em algum
nó em A, terminam em algum nó fora de A, e cuja capacidade é totalmente utilizada no fluxo
máximo. Por exemplo, na Fig. 12.22, A consiste nos nós 1, 2 e 4, e as arestas de corte mínimas
são 2 ÿ 3 e 4 ÿ 5, cujo peso é 6 + 1 = 7.
Por que o fluxo produzido pelo algoritmo é máximo e por que o corte é mínimo?
A razão é que um grafo não pode conter um fluxo cujo tamanho seja maior que o peso de
qualquer corte do grafo. Assim, sempre que um fluxo e um corte são iguais, eles são um fluxo
máximo e um corte mínimo.
Para ver por que o acima é válido, considere qualquer corte do grafo tal que a fonte pertença
a A, a dreno pertença a B e existam algumas arestas entre os conjuntos (Fig. 12.23). O
tamanho do corte é a soma dos pesos das arestas que vão de A a B. Este é um limite superior
para o fluxo no gráfico, pois o fluxo deve proceder de A a B. Assim, o tamanho de um vazão
máxima é menor ou igual ao tamanho de qualquer corte no gráfico. Por outro lado, o algoritmo
de Ford-Fulkerson produz um fluxo cujo tamanho é exatamente tão grande quanto o tamanho
de um corte no grafo. Assim, o fluxo tem que ser um fluxo máximo, e o corte tem que ser um
corte mínimo.
1Este elegante algoritmo não é muito conhecido; uma descrição detalhada pode ser encontrada em um
livro de Ahuja, Magnanti e Orlin [1].
Machine Translated by Google
UMA B
4 5
2 3
1 6
4 5
4 5
Muitos problemas de grafos podem ser resolvidos reduzindo-os ao problema de fluxo máximo.
Nosso primeiro exemplo de tal problema é o seguinte: recebemos um grafo direcionado com
uma fonte e um sumidouro, e nossa tarefa é encontrar o número máximo de caminhos
disjuntos da fonte ao sumidouro.
4 4 5 5
2 6
3 7
4 8
Teorema de Hall O teorema de Hall pode ser usado para descobrir se um grafo bipartido tem uma
correspondência que contém todos os nós esquerdos ou direitos. Se o número de nós esquerdo e
direito for o mesmo, o teorema de Hall nos diz se é possível construir um emparelhamento perfeito
que contenha todos os nós do grafo.
Suponha que queremos encontrar uma correspondência que contenha todos os nós esquerdos.
Seja X qualquer conjunto de nós esquerdos e seja f (X) o conjunto de seus vizinhos. De acordo com Hall
Machine Translated by Google
Fig. 12.28
1 5
Correspondência máxima como fluxo máximo
2 6
3 7
4 8
2 6
3 7
4 8
2 6
3 7
4 8
teorema, uma correspondência que contém todos os nós esquerdos existe exatamente quando para
cada conjunto possível X, a condição |X|ÿ| f (X)| detém.
Vamos estudar o teorema de Hall em nosso gráfico de exemplo. Primeiro, seja X = {1, 3} que resulta
em f (X) = {5, 6, 8} (Fig. 12.29). A condição do teorema de Hall é válida, pois |X| = 2 e | f (X)| = 3. Então,
seja X = {2, 4} que resulta em f (X) = {7}(Fig. 12.30).
Neste caso, |X| = 2 e | f (X)| = 1, então a condição do teorema de Hall não é válida. Isso significa que não
é possível formar um emparelhamento perfeito para o gráfico. Este resultado não é surpreendente, pois
já sabemos que o emparelhamento máximo do gráfico é 3 e não 4.
Se a condição do teorema de Hall não for válida, o conjunto X explica por que não podemos formar
tal emparelhamento. Como X contém mais nós que f (X), não há pares para todos os nós em X. Por
exemplo, na Fig. 12.30, ambos os nós 2 e 4 devem ser conectados com o nó 7, o que não é possível.
Teorema de Kÿonig Uma cobertura mínima de nós de um grafo é um conjunto mínimo de nós tal que
cada aresta do grafo tenha pelo menos uma extremidade no conjunto. Em um grafo geral, encontrar uma
cobertura mínima de nós é um problema NP-difícil. No entanto, se o grafo for bipartido, o teorema de
Kÿonig nos diz que o tamanho de uma cobertura mínima de nós sempre é igual ao tamanho de um
emparelhamento máximo. Assim, podemos calcular o tamanho de uma cobertura mínima de nós usando
um algoritmo de fluxo máximo.
Machine Translated by Google
2 6
3 7
4 8
2 6
3 7
4 8
5 6 7
Uma cobertura de caminho é um conjunto de caminhos em um grafo de modo que cada nó do grafo
pertença a pelo menos um caminho. Acontece que em grafos acíclicos direcionados, podemos reduzir
o problema de encontrar uma cobertura mínima de caminho ao problema de encontrar um fluxo
máximo em outro grafo.
Coberturas de caminhos de nós disjuntos Em uma cobertura de caminhos de nós disjuntos , cada nó
pertence a exatamente um caminho. Como exemplo, considere o gráfico da Fig. 12.33. Uma cobertura
mínima de caminhos disjuntos de nós deste grafo consiste em três caminhos (Fig. 12.34).
Podemos encontrar uma cobertura mínima de caminhos disjuntos de nós construindo um grafo
correspondente onde cada nó do grafo original é representado por dois nós: um nó esquerdo e um nó
Machine Translated by Google
3 4
3 3
4 4
5 5
6 6
7 7
2 6 7
nó direito. Existe uma aresta de um nó esquerdo para um nó direito se houver tal aresta
no gráfico original. Além disso, o gráfico correspondente contém uma fonte e um coletor,
e existem arestas da fonte para todos os nós esquerdos e de todos os nós direitos para o
afundar. Cada aresta no emparelhamento máximo do gráfico de emparelhamento corresponde a um
aresta na cobertura mínima do caminho disjunto de nós do grafo original. Assim, o tamanho
da cobertura mínima do caminho disjunto de nós é n ÿ c, onde n é o número de nós
no gráfico original, e c é o tamanho do emparelhamento máximo.
Por exemplo, a Fig. 12.35 mostra o gráfico correspondente ao gráfico da Fig. 12.33.
A correspondência máxima é 4, de modo que a cobertura mínima do caminho disjunto de nós consiste em
7 ÿ 4 = 3 caminhos.
Coberturas de caminho gerais Uma cobertura de caminho geral é uma cobertura de caminho onde um nó pode pertencer
mais de um caminho. Uma cobertura de caminho geral mínima pode ser menor que um mínimo
cobertura de caminho disjunto de nó, porque um nó pode ser usado várias vezes em caminhos. Considerar
novamente o gráfico da Fig. 12.33. A cobertura geral mínima do caminho deste gráfico consiste
de dois caminhos (Fig. 12.36).
Uma cobertura de caminho geral mínima pode ser encontrada quase como um nó-disjunto mínimo
cobertura do caminho. Basta adicionar algumas novas arestas ao grafo correspondente para que haja
é uma aresta a ÿ b sempre quando existe um caminho de a para b no grafo original
(possivelmente através de vários nós). A Figura 12.37 mostra o gráfico de correspondência resultante
para o nosso gráfico de exemplo.
Machine Translated by Google
3 3
4 4
5 5
6 6
7 7
5 6 7
Teorema de Dilworth Uma anticadeia é um conjunto de nós em um grafo tal que não há caminho de
nenhum nó para outro nó usando as arestas do grafo. O teorema de Dilworth afirma que em um grafo
acíclico direcionado, o tamanho de uma cobertura de caminho geral mínima é igual ao tamanho de uma
anticadeia máxima. Por exemplo, na Fig. 12.38, os nós 3 e 7 formam uma anticadeia de dois nós. Esta
é uma anticadeia máxima, porque uma cobertura geral mínima de caminho deste grafo tem dois
caminhos (Fig. 12.36).
Quando a busca em profundidade processa um grafo conectado, ela também cria uma árvore de
abrangência direcionada enraizada que pode ser chamada de árvore de busca em profundidade. Então,
as arestas do grafo podem ser classificadas de acordo com seus papéis durante a busca. Em um grafo
não direcionado, haverá dois tipos de arestas: arestas de árvore que pertencem à árvore de busca em
profundidade e arestas de retorno que apontam para nós já visitados. Observe que uma borda traseira
sempre aponta para um ancestral de um nó.
Por exemplo, a Fig. 12.39 mostra um gráfico e sua árvore de busca em profundidade. As arestas
sólidas são arestas de árvore e as arestas tracejadas são arestas traseiras.
Nesta seção, discutiremos algumas aplicações para árvores de busca em profundidade no
processamento de grafos.
12.4.1 Biconectividade
1 4 5 1
2 2 4
3 6 7
3 6 5
4 5 6 4 5
3 7
2 4 6 7
1 3 8
gráfico é biconectado, mas o gráfico da direita não é. O gráfico da direita não é biconectado,
porque remover o nó 3 do gráfico desconecta o gráfico dividindo-o em
dois componentes {1, 4} e {2, 5}.
Um nó é chamado de ponto de articulação se a remoção do nó do gráfico desconecta o gráfico. Assim,
um grafo biconectado não possui pontos de articulação.
De maneira semelhante, uma aresta é chamada de ponte se remover a aresta do grafo
desconecta o gráfico. Por exemplo, na Fig. 12.41, os nós 4, 5 e 7 são articulação
pontos e as arestas 4–5 e 7–8 são pontes.
Podemos usar a pesquisa em profundidade para encontrar com eficiência todos os pontos de articulação e pontes
em um gráfico. Primeiro, para encontrar pontes, começamos uma busca em profundidade em um nó arbitrário,
que constrói uma árvore de busca em profundidade. Por exemplo, a Fig. 12.42 mostra uma profundidade em primeiro lugar
árvore de busca para nosso gráfico de exemplo.
Uma aresta a ÿ b corresponde a uma ponte exatamente quando é uma aresta de árvore, e
não há back edge da subárvore de b para a ou qualquer ancestral de a. Por exemplo,
na Fig. 12.42, a aresta 5 ÿ 4 é uma ponte, porque não há aresta traseira dos nós
Machine Translated by Google
5 6 7 8
1 2 3 4
5 6 7 8
{1, 2, 3, 4} para o nó 5. No entanto, a aresta 6 ÿ 7 não é uma ponte, pois existe um back
aresta 7 ÿ 5, e o nó 5 é um ancestral do nó 6.
Encontrar pontos de articulação é um pouco mais difícil, mas podemos usar novamente a primeira
árvore de busca de profundidade. Primeiro, se um nó x é a raiz da árvore, é um ponto de articulação
exatamente quando tem dois ou mais filhos. Então, se x não é a raiz, é uma articulação
apontar exatamente quando tem um filho cuja subárvore não contém uma borda traseira para um
ancestral de x.
Por exemplo, na Fig. 12.42, o nó 5 é um ponto de articulação, porque é a raiz
e tem dois filhos, e o nó 7 é um ponto de articulação, pois a subárvore de seu
filho 8 não contém uma borda traseira para um ancestral de 7. No entanto, o nó 2 não é
um ponto de articulação, porque há uma borda traseira 3 ÿ 4, e o nó 8 não é um
ponto de articulação, pois não tem filhos.
Geometria
13
Um número complexo é um número da forma x + yi, onde i = ÿÿ1 é a unidade imaginária. Uma
interpretação geométrica de um número complexo é que ele representa um ponto bidimensional
(x, y) ou um vetor desde a origem até um ponto (x, y). Por exemplo, a Fig. 13.1 ilustra o número
complexo 4 + 2i.
212 13 Geometria
Depois disso, podemos definir um tipo complexo P que representa um ponto ou um vetor:
typedef complexo<C> P;
#define X real()
#define Y imag()
Pp = {4,2};
"" 4 2
cout << pX << << pY << "\n"; //
Então, o código a seguir cria vetores v = (3, 1) e u = (2, 2), e depois disso
calcula a soma s = v + u.
Pv = {3,1};
Pu = {2,2};
Ps = v+u;
cout << sX << "" 5 3
<< sY << "\n"; //
Machine Translated by Google
Funções A classe complexa também possui funções que são úteis em problemas geométricos.
As seguintes funções só devem ser usadas quando o tipo de coordenada for long double (ou outro
tipo de ponto flutuante).
A função abs(v) calcula o comprimento |v| de um vetor v = (x, y) usando a fórmula x2 + y2. A
função também pode ser usada para calcular a distância entre os pontos (x1, y1) e (x2, y2),
porque essa distância é igual ao comprimento do vetor (x2 ÿ x1, y2 ÿ y1). Por exemplo, o código a
seguir calcula a distância entre os pontos (4, 2) e (3, ÿ1)
Pa = {4,2};
Pb = {3,-1}; cout <<
abs(ba) << "\n"; // 3,16228
A função arg(v) calcula o ângulo de um vetor v = (x, y) em relação ao eixo x. A função fornece
o ângulo em radianos, onde r radianos é igual a 180r/ ÿ graus. O ângulo de um vetor que aponta
para a direita é 0, e os ângulos diminuem no sentido horário e aumentam no sentido anti-horário.
A função polar(s, a) constrói um vetor cujo comprimento é s e que aponta para um ângulo a,
dado em radianos. Um vetor pode ser girado por um ângulo a multiplicando-o por um vetor com
comprimento 1 e ângulo a.
O código a seguir calcula o ângulo do vetor (4, 2), gira 1/2 radianos
sentido anti-horário e, em seguida, calcula o ângulo novamente:
O produto vetorial a × b dos vetores a = (x1, y1) eb = (x2, y2 ) é definido como x1 y2 ÿ x2 y1. Ele
nos diz a direção para a qual b gira quando é colocado diretamente após a. Existem três casos
ilustrados na Fig. 13.2:
b b
b
uma uma uma
214 13 Geometria
s2
s1
Pa = {4,2};
Pb = {1,2}; Cp =
(conj(a)*b).Y; // 6
Localização do ponto de teste Os produtos cruzados podem ser usados para testar se um
ponto está localizado no lado esquerdo ou direito de uma linha. Suponha que a linha passe
pelos pontos s1 e s2, estamos olhando de s1 a s2 e o ponto é p. Por exemplo, na Fig. 13.3,
p está localizado no lado esquerdo da linha.
O produto vetorial (p ÿ s1) × (p ÿ s2) nos diz a localização do ponto p. Se o produto vetorial
for positivo, p está localizado no lado esquerdo, e se o produto vetorial for negativo, p está
localizado no lado direito. Finalmente, se o produto vetorial for zero, os pontos s1, s2 e p
estão na mesma linha.
Caso 2: Os segmentos de reta têm um vértice comum que é o único ponto de interseção.
Por exemplo, na Fig. 13.5 o ponto de interseção é b = c. Este caso é fácil de verificar, pois
existem apenas quatro possibilidades para o ponto de interseção: a = c, a = d, b = c e b = d.
Machine Translated by Google
uma
s2
s1
Caso 3: Existe exatamente um ponto de interseção que não é vértice de nenhum segmento
de reta. Na Fig. 13.6, o ponto p é o ponto de interseção. Nesse caso, os segmentos de linha
se cruzam exatamente quando os pontos c e d estão em lados diferentes de uma linha que
passa por a e b, e os pontos a e b estão em lados diferentes de uma linha que passa por c e d.
Podemos usar produtos cruzados para verificar isso.
Distância de um ponto a uma linha Outra propriedade dos produtos cruzados é que a área
de um triângulo pode ser calculada usando a fórmula
|(a ÿ c) × (b ÿ c)|
,
2
onde a, b e c são os vértices do triângulo. Usando esse fato, podemos derivar uma fórmula
para calcular a distância mais curta entre um ponto e uma linha. Por exemplo, na Fig. 13.7, d
é a distância mais curta entre o ponto p e a linha definida pelos pontos s1 e s2.
Machine Translated by Google
216 13 Geometria
uma
uma
A área de um triângulo cujos vértices são s1, s2 ep pode ser calculada em dois 1 |s2 ÿ
s1|d (a fórmula
maneiras: é 12
padrão ensinada na escola) e 2 ((s1 ÿ p) ×
ambos (s2 ÿ p)) (a fórmula do produto vetorial). Assim, a menor distância é
Uma fórmula geral para calcular a área de um polígono, às vezes chamada de fórmula do
cadarço, é a seguinte:
nÿ1 nÿ1
1
(2,4)
(4,3) (7,3)
(4,1)
(2,4)
(4,3) (7,3)
(4,1)
Aqui os vértices são p1 = (x1, y1), p2 = (x2, y2), . . . , pn = (xn, yn) em tal ordem que pi e pi+1 são
vértices adjacentes na fronteira do polígono, e o primeiro e o último vértice são os mesmos, ou seja,
p1 = pn.
Por exemplo, a área do polígono na Fig. 13.10 é
|(2 · 5 ÿ 5 · 4) + (5 · 3 ÿ 7 · 5) + (7 · 1 ÿ 4 · 3) + (4 · 3 ÿ 4 · 1) + (4 · 4 ÿ 2 · 3) | =
17/2.
2
A idéia por trás da fórmula é passar por trapézios cujo lado é um lado do
polígono e o outro lado está na linha horizontal y = 0. Por exemplo, a Fig. 13.11
mostra um desses trapézios. A área de cada trapézio é
yi + yi+1
(xi+1 ÿ xi) 2 ,
nÿ1 nÿ1
yi + yi+1 1
(xi+1 ÿ xi) = (xi yi+1 ÿ xi+1 yi) .
i=1
2 2 i=1
218 13 Geometria
(2,4)
(4,3) (7,3)
(4,1)
a + b/2 ÿ 1,
6 + 7/2 ÿ 1 = 17/2.
Uma função de distância define a distância entre dois pontos. A função distância
usual é a distância euclidiana onde a distância entre os pontos (x1, y1) e (x2, y2) é
(5 ÿ 2)2 + (2 ÿ 1)2 = ÿ 10
e a distância de Manhattan é
|5 ÿ 2|+|2 ÿ 1| = 4.
Machine Translated by Google
(5,2) (5,2)
(2,1) (2,1)
UMA
A Figura 13.14 mostra regiões que estão a uma distância de 1 do ponto central, usando as
distâncias Euclidiana e Manhattan.
Alguns problemas são mais fáceis de resolver se forem usadas distâncias de Manhattan
em vez de distâncias euclidianas. Como exemplo, dado um conjunto de pontos no plano
bidimensional, considere o problema de encontrar dois pontos cuja distância de Manhattan
seja máxima. Por exemplo, na Fig. 13.15, devemos selecionar os pontos B e C para obter a
distância máxima de Manhattan 5.
Uma técnica útil relacionada às distâncias de Manhattan é transformar as coordenadas de
modo que um ponto (x, y) se torne (x + y, y ÿ x). Isso gira o conjunto de pontos em 45ÿ e o
dimensiona. Por exemplo, a Fig. 13.16 mostra o resultado da transformação em nosso cenário
de exemplo.
Machine Translated by Google
220 13 Geometria
Então, considere dois pontos p1 = (x1, y1) e p2 = (x2, y2) cuja transformada
as coordenadas são p 1 = (x 1, y 1) ep 2 = (x 2, y 2). Agora existem duas maneiras de expressar
a distância de Manhattan entre p1 e p2:
Muitos problemas geométricos podem ser resolvidos usando algoritmos de linha de varredura . A ideia em
algoritmos é representar uma instância do problema como um conjunto de eventos que
correspondem aos pontos do plano. Em seguida, os eventos são processados em ordem crescente
de acordo com suas coordenadas x ou y.
1 2
É fácil resolver o problema em tempo O(n2) , pois podemos percorrer todos os pares possíveis de
segmentos de reta e verificar se eles se cruzam. No entanto, podemos resolver o problema com mais
eficiência em tempo O(n log n) usando um algoritmo de linha de varredura e uma estrutura de dados de
consulta de intervalo. A ideia é processar as extremidades dos segmentos de linha da esquerda para a
direita e focar em três tipos de eventos:
Para armazenar as coordenadas y de segmentos horizontais, podemos usar uma árvore binária
indexada ou de segmentos, possivelmente com compressão de índice. O processamento de cada evento
leva tempo O(log n), então o algoritmo funciona em tempo O(n log n).
Dado um conjunto de n pontos, nosso próximo problema é encontrar dois pontos cuja distância euclidiana
seja mínima. Por exemplo, a Fig. 13.19 mostra um conjunto de pontos, onde o par mais próximo é pintado
de preto.
Este é outro exemplo de um problema que pode ser resolvido em tempo O(n log n) usando um
algoritmo de linha de varredura.1 Percorremos os pontos da esquerda para a direita e mantemos
1Criar um algoritmo eficiente para o problema do par mais próximo já foi um importante problema em aberto na geometria
computacional. Finalmente, Shamos e Hoey [26] descobriram um algoritmo de divisão e conquista que funciona em tempo
O(n log n). O algoritmo de linha de varredura apresentado aqui tem elementos comuns com seu algoritmo, mas é mais fácil
de implementar.
Machine Translated by Google
222 13 Geometria
a valor d: a distância mínima entre dois pontos vistos até agora. Em cada ponto,
encontramos seu ponto mais próximo à esquerda. Se a distância for menor que d, é a
nova distância mínima e atualizamos o valor de d.
Se o ponto atual for (x, y) e houver um ponto à esquerda dentro de uma distância
menor que d, a coordenada x de tal ponto deve estar entre [x ÿ d, x] e a coordenada y
deve estar entre [y ÿ d, y + d]. Assim, basta considerar apenas os pontos localizados
nessas faixas, o que torna o algoritmo eficiente. Por exemplo, na Fig. 13.20, a região
marcada com linhas tracejadas contém os pontos que podem estar a uma distância d
do ponto ativo.
A eficiência do algoritmo é baseada no fato de que a região sempre contém apenas
pontos O(1). Para ver por que isso acontece, considere a Fig. 13.21. Como a distância
mínima atual entre dois pontos é d, cada quadrado d/2 × d/2 pode conter no máximo um
ponto. Assim, são no máximo oito pontos na região.
Machine Translated by Google
Fig. 13.23 Construindo a parte superior do casco convexo usando o algoritmo de Andrew
Machine Translated by Google
224 13 Geometria
Podemos percorrer os pontos da região em tempo O(log n) mantendo um conjunto de pontos cujas coordenadas x
estão entre [x ÿ d, x] de modo que os pontos sejam ordenados em ordem crescente de acordo com suas coordenadas
y. A complexidade de tempo do algoritmo é O(n log n), porque passamos por n pontos e determinamos para cada ponto
seu ponto mais próximo à esquerda no tempo O(log n).
Um casco convexo é o menor polígono convexo que contém todos os pontos de um determinado conjunto de pontos.
Aqui convexidade significa que um segmento de linha entre quaisquer dois vértices do polígono está completamente
dentro do polígono. Por exemplo, a Fig. 13.22 mostra o casco convexo de um conjunto de pontos.
Existem muitos algoritmos eficientes para a construção de cascos convexos. Talvez o mais simples entre eles seja
o algoritmo de Andrew [2], que descreveremos a seguir. O algoritmo primeiro determina os pontos mais à esquerda e
mais à direita no conjunto e, em seguida, constrói o casco convexo em duas partes: primeiro o casco superior e depois
o casco inferior.
Ambas as partes são semelhantes, então podemos nos concentrar na construção do casco superior.
Primeiro, ordenamos os pontos principalmente de acordo com as coordenadas x e secundariamente de acordo com
as coordenadas y. Depois disso, passamos pelos pontos e adicionamos cada ponto ao casco. Sempre depois de
adicionar um ponto ao casco, nos certificamos de que o último segmento de linha no casco não vire à esquerda. Desde
que vire à esquerda, removemos repetidamente o penúltimo ponto do casco. A Figura 13.23 mostra como o algoritmo
de Andrew cria o casco superior para nosso conjunto de pontos de exemplo.
Machine Translated by Google
Algoritmos de String
14
Ao longo do capítulo, assumimos que todas as strings são indexadas a zero. Por exemplo, uma string s
de comprimento n consiste em caracteres s[0], s[1],..., s[n ÿ 1].
Uma substring é uma sequência de caracteres consecutivos em uma string. Usamos a notação
s[a ... b] para nos referirmos a uma substring de s que começa na posição a e termina na posição b.
Um prefixo é uma substring que contém o primeiro caractere de uma string e um sufixo é uma substring
que contém o último caractere de uma string.
Uma subsequência é qualquer sequência de caracteres em uma string em sua ordem original. Tudo
substrings são subsequências, mas o inverso não é verdadeiro (Fig. 14.1).
N E
UMA D R
eu S E
Um trie é uma árvore enraizada que mantém um conjunto de strings. Cada string no conjunto é
armazenada como uma cadeia de caracteres que começa no nó raiz. Se duas strings tiverem um
prefixo comum, elas também terão uma cadeia comum na árvore. Como exemplo, o trie na Fig.
14.2 corresponde ao conjunto {CANAL, CANDY, THE, THERE}. Um círculo em um nó significa que
uma string no conjunto termina no nó.
Depois de construir um trie, podemos verificar facilmente se ele contém uma determinada string
seguindo a cadeia que começa no nó raiz. Também podemos adicionar uma nova string à trie
seguindo primeiro a cadeia e, em seguida, adicionando novos nós, se necessário. Ambas as
operações funcionam em tempo O(n) , onde n é o comprimento da string.
Um trie pode ser armazenado em uma matriz
int tri[N][A];
onde N é o número máximo de nós (o comprimento total máximo das strings no conjunto) e A é o
tamanho do alfabeto. Os nós trie são numerados 0, 1, 2,... de tal forma que o número da raiz é 0, e
trie[s][c] especifica o próximo nó na cadeia quando nos movemos do nó s usando o caractere c.
Existem várias maneiras de estender a estrutura do trie. Por exemplo, suponha que recebemos
consultas que exigem que calculemos o número de strings no conjunto que possuem um
determinado prefixo. Podemos fazer isso de forma eficiente armazenando para cada nó trie o
número de strings cuja cadeia passa pelo nó.
Machine Translated by Google
R 1 1 1 2 2
A programação dinâmica pode ser usada para resolver muitos problemas de strings. A seguir,
discutiremos dois exemplos de tais problemas.
A subsequência comum mais longa A subsequência comum mais longa de duas strings é a
string mais longa que aparece como uma subsequência em ambas as strings. Por exemplo, a
subsequência comum mais longa de TOUR e OPERA é OR.
Usando programação dinâmica, podemos determinar a subsequência comum mais longa
de duas strings xey em O(nm)time, onde n e m denotam os comprimentos das strings.
Para fazer isso, definimos uma função lcs(i, j) que fornece o comprimento da subsequência
comum mais longa dos prefixos x[0 ... i] e y[0 ... j]. Então, podemos usar a recorrência
A ideia é que se os caracteres x[i] e y[j] forem iguais, nós os combinamos e aumentamos o
comprimento da subsequência comum mais longa em um. Caso contrário, removemos o último
caractere de x ou y, dependendo da escolha ideal.
Por exemplo, a Fig. 14.3 mostra os valores da função lcs em nosso cenário de exemplo.
Edit Distances A distância de edição (ou distância Levenshtein) entre duas strings denota o
número mínimo de operações de edição que transformam a primeira string na segunda string.
As operações de edição permitidas são as seguintes:
Por exemplo, a distância de edição entre LOVE e MOVIE é 2, pois podemos primeiro realizar
a operação LOVE ÿ MOVE (modificar) e depois a operação MOVE ÿ MOVIE (inserir).
Podemos calcular a distância de edição entre duas strings xey em tempo O(nm) , onde n e
m são os comprimentos das strings. Seja edit(i, j) a distância de edição entre os prefixos x[0 ...
i] e y[0 ... j]. Os valores da função podem ser calculados usando a recorrência
Machine Translated by Google
V 3 2 1 2 3
E 4 3 2 2 2
b) + 1, editar(a ÿ 1, b ÿ 1)
+ custo(a, b)),
onde cost(a, b) = 0 se x[a] = y[b], e caso contrário cost(a, b) = 1. A fórmula considera três maneiras
de editar a string x: inserir um caractere no final de x , remova o último caractere de x ou combine/
modifique o último caractere de x. No último caso, se x[a] = y[b], podemos combinar os últimos
caracteres sem editar.
Por exemplo, a Fig. 14.4 mostra os valores da função de edição em nosso cenário de exemplo.
Usando o hashing de strings , podemos verificar com eficiência se duas strings são iguais comparando
seus valores de hash. Um valor de hash é um número inteiro calculado a partir dos caracteres da
string. Se duas strings são iguais, seus valores de hash também são iguais, o que torna possível
comparar strings com base em seus valores de hash.
Uma maneira usual de implementar o hashing de strings é o hashing polinomial, o que significa que o
valor de hash de uma string s de comprimento n é
onde s[0],s[1],...,s[n ÿ 1] são interpretados como códigos de caracteres, e A e B são constantes pré-
escolhidas.
Por exemplo, vamos calcular o valor de hash da string ABACB. Os códigos de caracteres de A, B
e C são 65, 66 e 67. Então, precisamos fixar as constantes; suponha que A = 3 e B = 97. Assim, o
valor de hash é
Quando o hashing polinomial é usado, podemos calcular o valor de hash de qualquer substring
de uma string s em tempo O(1) após um pré-processamento em tempo O(n) . A ideia é construir um
array h tal que h[k] contenha o valor de hash do prefixo s[0 ... k]. Os valores da matriz podem ser
calculados recursivamente da seguinte forma:
p[0] = 1
p[k] = (p[k ÿ 1]A) mod B.
Construir as matrizes acima leva tempo O(n) . Depois disso, o valor de hash de qualquer
substring s[a ... b] pode ser calculada em tempo O(1) usando a fórmula
14.2.2 Aplicativos
Podemos resolver com eficiência muitos problemas de strings usando hashing, pois isso nos permite
comparar substrings arbitrárias de strings em tempo O(1). Na verdade, muitas vezes podemos
simplesmente pegar um algoritmo de força bruta e torná-lo eficiente usando hashing.
Rotação Mínima Uma rotação de uma string pode ser criada movendo repetidamente o primeiro
caractere da string para o final da string. Por exemplo, as rotações do ATLAS são ATLAS, TLASA,
LASAT, ASATL e SATLA. Em seguida, consideraremos o problema de encontrar a rotação
lexicograficamente mínima de uma corda. Por exemplo, a rotação mínima do ATLAS é ASATL.
Podemos resolver o problema com eficiência combinando hashing de string e pesquisa binária.
A ideia chave é que podemos descobrir a ordem lexicográfica de duas cordas em tempo logarítmico.
Primeiro, calculamos o comprimento do prefixo comum das strings usando a pesquisa binária. Aqui,
o hashing nos permite verificar no tempo O(1) se dois prefixos de um determinado comprimento
correspondem. Depois disso, verificamos o próximo caractere após o prefixo comum, que determina
a ordem das strings.
Então, para resolver o problema, construímos uma string que contém duas cópias da string
original (por exemplo, ATLASATLAS) e percorremos suas substrings de comprimento n mantendo a
substring mínima. Como cada comparação pode ser feita em tempo O(log n), o algoritmo funciona
em tempo O(n log n).
Um risco evidente ao comparar valores de hash é uma colisão, o que significa que duas strings têm
conteúdos diferentes, mas valores de hash iguais. Nesse caso, um algoritmo que se baseia nos
valores de hash conclui que as strings são iguais, mas na realidade não são, e o algoritmo pode
fornecer resultados incorretos.
As colisões são sempre possíveis, porque o número de strings diferentes é maior que o número
de valores de hash diferentes. No entanto, a probabilidade de uma colisão é pequena se as
constantes A e B forem cuidadosamente escolhidas. Uma maneira usual é escolher constantes
aleatórias próximas a 109, por exemplo, como segue:
A = 911382323
B = 972663749
Usando tais constantes, o tipo long long pode ser usado ao calcular valores de hash, porque os
produtos AB e BB caberão em long long. Mas é suficiente ter cerca de 109 valores de hash
diferentes?
Vamos considerar três cenários onde o hashing pode ser usado:
Cenário 1: As strings xey são comparadas entre si. A probabilidade de um
colisão é 1/B assumindo que todos os valores de hash são igualmente prováveis.
Cenário 2: Uma string x é comparada com as strings y1, y2,..., yn. A probabilidade
de uma ou mais colisões é
1 ÿ (1 ÿ 1/ B) n.
Cenário 3: Todos os pares de strings x1, x2,..., xn são comparados entre si. o
probabilidade de uma ou mais colisões é
B · (B ÿ 1) · (B ÿ 2)···(B ÿ n + 1) 1 ÿ
.
Bn
Machine Translated by Google
14.3 Algoritmo Z
– 00500 2 0 2 0
– 0 0 ????????
x y
0123456789
ABCABC ABAB
– 005 ??????
1Gusfield [13] apresenta o algoritmo Z como o método mais simples conhecido para casamento de padrões
de tempo linear e atribui a ideia original a Main e Lorentz [22].
Machine Translated by Google
ABCABC ABAB
– 005 ??????
x y
0123456789
ABCABC ABAB
– 0050 ?????
ABCABC ABAB
– 00500 ????
x y
0123456789
ABCABC ABAB
– 00500 2 ???
O algoritmo resultante funciona em tempo O(n) , pois sempre que dois caracteres coincidem
ao comparar substrings caractere por caractere, o valor de y aumenta.
Assim, o trabalho total necessário para comparar substrings é apenas O(n).
14.3.2 Aplicativos
O algoritmo Z fornece uma maneira alternativa de resolver muitos problemas de string que
também podem ser resolvidos usando hashing. No entanto, ao contrário do hashing, o algoritmo
Z sempre funciona e não há risco de colisões. Na prática, muitas vezes é uma questão de
gosto usar hashing ou o algoritmo Z.
– 000300 2 0300 1
– 0 1 0 7 0 1 030 1
2 0 ABAACBAB
3 3 ACBAB
4 7 B
5 1 BAACBAB
6 5 BAB
7 4 CBAB
Encontrando Bordas Uma borda é uma string que é tanto um prefixo quanto um sufixo de
uma string, mas não a string inteira. Por exemplo, as bordas de ABACABACABA são A,
ABA e ABACABA. Todas as bordas de uma string podem ser encontradas com eficiência
usando o algoritmo Z, porque um sufixo na posição k é uma borda exatamente quando k +
z[k] = n onde n é o comprimento da string. Por exemplo, na Fig. 14.11, 4 + z[4] = 11, o que
significa que ABACABA é uma borda da string.
A matriz de sufixos de uma string descreve a ordem lexicográfica de seus sufixos. Cada
valor na matriz de sufixos é uma posição inicial de um sufixo. Por exemplo, a Fig. 14.12
mostra a matriz de sufixos da string ABAACBAB.
Muitas vezes é conveniente representar a matriz de sufixos verticalmente e também
mostrar os sufixos correspondentes (Fig. 14.13). No entanto, observe que a matriz de
sufixos contém apenas as posições iniciais dos sufixos e não seus caracteres.
Machine Translated by Google
Uma maneira simples e eficiente de criar o array de sufixos de uma string é usar uma construção de
duplicação de prefixo, que funciona em tempo O(n log2 n) ou O(n log n), dependendo do
a implementação.2 O algoritmo consiste em rodadas numeradas 0, 1,..., log2 n e rodada i passa por ,
substrings cujo comprimento é 2i . Durante uma rodada, cada
substring x de comprimento 2i recebe um rótulo inteiro l(x) tal que l(a) = l(b) exatamente quando
a = b e l(a) < l(b) exatamente quando a < b.
Na rodada 0, cada substring consiste em apenas um caractere, e podemos, por exemplo,
use rótulos A = 1, B = 2 e assim por diante. Então, na rodada i, onde i > 0, usamos os rótulos
para substrings de comprimento 2iÿ1 para construir rótulos para substrings de comprimento 2i . Para dar um
rótulo l(x) para uma substring x de comprimento 2i , dividimos x em duas metades a e b de comprimento
2iÿ1 cujos rótulos são l(a) e l(b). (Se a segunda metade começa fora da corda, nós
suponha que seu rótulo seja 0.) Primeiro, damos a x um rótulo inicial que é um par (l(a),l(b)).
Então, depois que todas as substrings de comprimento 2i receberam rótulos iniciais, ordenamos a inicial
rótulos e dar rótulos finais que são inteiros consecutivos 1, 2, 3, etc. O propósito de
dando os rótulos é que após a última rodada, cada substring tem um rótulo único , e o
os rótulos mostram a ordem lexicográfica das substrings. Então, podemos construir facilmente
a matriz de sufixos com base nos rótulos.
A Figura 14.14 mostra a construção dos rótulos para ABAACBAB. Por exemplo,
após a rodada 1, sabemos que l(AB) = 2 e l(AA) = 1. Então, na rodada 2, o valor inicial
rótulo para ABAA é (2, 1). Como existem dois rótulos iniciais menores ((1, 6) e (2, 0)),
o rótulo final isl(ABAA) = 3. Observe que neste exemplo, cada rótulo já é único
2A ideia de duplicação de prefixo deve-se a Karp, Miller e Rosenberg [17]. Há também mais
algoritmos de tempo O(n) avançados para construir matrizes de sufixos; Kärkkäinen e Sanders [16] fornecem
um algoritmo bastante simples.
Machine Translated by Google
1 6 AB 1 6 AB 1 6 AB
4 7 B 4 7 B 4 7 B
após a rodada 2, porque os primeiros quatro caracteres das substrings determinam completamente
sua ordem lexicográfica.
O algoritmo resultante funciona em tempo O(n log2 n), porque existem rodadas O(log n) e
ordenamos uma lista de n pares em cada rodada. De fato, uma implementação O(n log n) também
é possível, porque podemos usar um algoritmo de ordenação em tempo linear para ordenar os
pares. Ainda assim, uma implementação direta de tempo O(n log2 n) usando apenas a função de
classificação C++ geralmente é eficiente o suficiente.
A matriz LCP de uma string fornece para cada sufixo um valor LCP: o comprimento do prefixo
comum mais longo do sufixo e o próximo sufixo na matriz de sufixos. Figura 14.16
Machine Translated by Google
2 1 ABAACBAB
3 0 ACBAB
4 1 B
5 2 BAACBAB
6 0 BAB
–
7 CBAB
mostra a matriz LCP para a string ABAACBAB. Por exemplo, o valor LCP do sufixo BAACBAB é
2, porque o prefixo comum mais longo de BAACBAB e BAB é BA. Observe que o último sufixo na
matriz de sufixos não tem um valor LCP.
A seguir apresentamos um algoritmo eficiente, devido a Kasai et al. [18], para construir o array
LCP de uma string, desde que já tenhamos construído seu array de sufixos.
O algoritmo é baseado na seguinte observação: Considere um sufixo cujo valor LCP é x. Se
removermos o primeiro caractere do sufixo e obtivermos outro sufixo, saberemos imediatamente
que seu valor LCP deve ser pelo menos x ÿ 1. Por exemplo, na Fig. 14.16, o valor LCP do sufixo
BAACBAB é 2, então saiba que o valor LCP do sufixo AACBAB deve ser pelo menos 1. Na
verdade, é exatamente 1.
Podemos usar a observação acima para construir eficientemente a matriz LCP calculando os
valores LCP em ordem decrescente do comprimento do sufixo. Em cada sufixo, calculamos seu
valor LCP comparando o sufixo e o próximo sufixo na matriz de sufixo caractere por caractere.
Agora podemos usar o fato de que sabemos o valor LCP do sufixo que tem mais um caractere.
Assim, o valor de LCP atual deve ser pelo menos x ÿ 1, onde x é o valor de LCP anterior, e não
precisamos comparar os primeiros x ÿ 1 caracteres dos sufixos. O algoritmo resultante funciona
em tempo O(n) , pois somente comparações O(n) são feitas durante o algoritmo.
Usando o array LCP, podemos resolver com eficiência alguns problemas avançados de string.
Por exemplo, para calcular o número de substrings distintas em uma string, podemos simplesmente
subtrair a soma de todos os valores no array LCP do número total de substrings, ou seja, a
resposta para o problema é
n(n + 1) 2
ÿ c,
8·9
ÿ 7 = 29
2
substrings distintas.