Você está na página 1de 242

Machine Translated by Google

Tópicos de Graduação em Ciência da Computação

Antti Laaksonen

Guiado para

Competitivo
Programação
Algoritmos de Aprendizagem e Melhoria
Através de concursos
Machine Translated by Google

Tópicos de Graduação em Computação


Ciência

Editor da série

Ian Mackie

Conselho Consultivo

Samson Abramsky, Universidade de Oxford, Oxford, Reino


Unido Chris Hankin, Imperial College London, Londres,
Reino Unido Dexter C. Kozen, Universidade Cornell, Ithaca,
EUA Andrew Pitts, Universidade de Cambridge, Cambridge,
Reino Unido Hanne Riis Nielson, Universidade Técnica da Dinamarca, Kongens Lyngby,
Dinamarca Steven S. Skiena, Stony Brook University, Stony Brook, EUA Iain Stewart,
University of Durham, Durham, Reino Unido
Machine Translated by Google

Tópicos de Graduação em Ciência da Computação (UTiCS) oferece conteúdo instrucional


de alta qualidade para alunos de graduação que estudam em todas as áreas da computação
e ciência da informação. Do material básico e teórico básico aos tópicos e aplicativos do
último ano, os livros da UTiCS adotam uma abordagem nova, concisa e moderna e são
ideais para autoestudo ou para um curso de um ou dois semestres. Os textos são todos de
autoria de especialistas estabelecidos em suas áreas, revisados por um conselho consultivo
internacional e contêm inúmeros exemplos e problemas. Muitos incluem soluções totalmente
trabalhadas.

Mais informações sobre esta série em http://www.springer.com/series/7592


Machine Translated by Google

Antti Laaksonen

Guia para Competitividade


Programação
Algoritmos de Aprendizagem e Melhoria
Através de concursos

123
Machine Translated by Google

Antti Laaksonen
Departamento de Ciência da Computação
Universidade de Helsinque
Helsinque
Finlândia

ISSN 1863-7310 ISSN 2197-1781 (eletrônico)


Tópicos de Graduação em Ciência da Computação
ISBN 978-3-319-72546-8 ISBN 978-3-319-72547-5
(eBook) https://doi.org/10.1007/978-3-319-72547-5

Número de controle da Biblioteca do Congresso: 2017960923

© 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.

Impresso em papel sem ácido

Este selo da Springer é publicado pela empresa registrada Springer International Publishing AG, parte da Springer Nature.

O endereço da empresa registrada é: Gewerbestrasse 11, 6330 Cham, Suíça


Machine Translated by Google

Prefácio

O objetivo deste livro é fornecer uma introdução abrangente à programação competitiva


moderna. Supõe-se que você já conheça os fundamentos da programação, mas não é
necessário conhecimento prévio em design de algoritmos ou concursos de programação.
Como o livro cobre uma ampla gama de tópicos de várias dificuldades, é adequado tanto para
iniciantes quanto para leitores mais experientes.
Os concursos de programação já têm uma longa história. O Concurso Internacional de
Programação Colegiada para estudantes universitários foi iniciado durante a década de 1970,
e a primeira Olimpíada Internacional de Informática para estudantes do ensino médio foi
organizada em 1989. Ambas as competições são agora eventos estabelecidos com um
grande número de participantes de todo o mundo.
Hoje, a programação competitiva é mais popular do que nunca. A Internet desempenhou
um papel significativo neste progresso. Existe agora uma comunidade online ativa de
programadores competitivos, e muitos concursos são organizados toda semana. Ao mesmo
tempo, a dificuldade dos concursos está aumentando. Técnicas que apenas os melhores
participantes dominavam há alguns anos agora são ferramentas padrão conhecidas por um
grande número de pessoas.
A programação competitiva tem suas raízes no estudo científico de algoritmos.
No entanto, enquanto um cientista da computação escreve uma prova para mostrar que seu
algoritmo funciona, um programador competitivo implementa seu algoritmo e o submete a um
sistema de concurso. Em seguida, o algoritmo é testado usando um conjunto de casos de
teste e, se passar em todos eles, é aceito. Este é um elemento essencial na programação
competitiva, porque fornece uma maneira de obter automaticamente fortes evidências de que
um algoritmo funciona. De fato, a programação competitiva provou ser uma excelente maneira
de aprender algoritmos, pois incentiva a projetar algoritmos que realmente funcionem, em vez
de esboçar ideias que podem funcionar ou não.
Outro benefício da programação competitiva é que os problemas do concurso exigem
raciocínio. Em particular, não há spoilers nas declarações de problemas. Este é realmente
um problema grave em muitos cursos de algoritmos. Você recebe um bom problema para
resolver, mas a última frase diz, por exemplo: “Dica: modifique o algoritmo de Dijkstra para
resolver o problema”. Depois de ler isso, não é necessário pensar muito, porque você já sabe
como resolver o problema. Isso nunca acontece em competição

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 ahslaaks@cs.helsinki.fi.
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.

Helsinki, Finlândia Antti Laaksonen


Outubro de 2017
Machine Translated by Google

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

2 Técnicas de Programação ......................... 9


2.1 Recursos de idioma .............................. 9
2.1.1 Entrada e Saída .............................. 10
2.1.2 Trabalhando com Números......................... 12
2.1.3 Código de encurtamento .............................. 14
2.2 Algoritmos Recursivos .............................. 15
2.2.1 Gerando Subconjuntos .............................. 15
2.2.2 Gerando Permutações ....................... 16
2.2.3 Retrocesso .............................. 18
2.3 Manipulação de Bits ......................... 20
2.3.1 Operações de bits .............................. 21
2.3.2 Representando Conjuntos .............................. 23
3 Eficiência .............................................. 27
3.1 Complexidade do Tempo .................................. 27
3.1.1 Regras de Cálculo .................................. 27
3.1.2 Complexidades de Tempo Comum ........ 30
3.1.3 Estimando a Eficiência.............................. 31
3.1.4 Definições formais......................... 32
3.2 Exemplos ............................................. 32
3.2.1 Soma Máxima do Subarray ....................... 32
3.2.2 Problema das Duas Rainhas .............................. 35

4 Classificando e Pesquisando .............................. 37


4.1 Algoritmos de Ordenação .............................. 37
4.1.1 Ordenação por Bolha .............................. 38

vii
Machine Translated by Google

viii Conteúdo

4.1.2 Mesclar Ordenação .................................. 39


4.1.3 Classificando o Limite Inferior ....................... 40
4.1.4 Ordenação de Contagem .............................. 41
.........................................
4.1.5 Classificação na Prática 41
4.2 Resolvendo Problemas Classificando ....................... 43
4.2.1 Algoritmos de Linha de Varredura ....................... 44
4.2.2 Agendamento de Eventos......................... 45
4.2.3 Tarefas e Prazos ....................... 45
4.3 Pesquisa Binária ....................................... 46
4.3.1 Implementando a Pesquisa ....................... 47
4.3.2 Encontrando Soluções Ótimas....................... 48

5 Estruturas de Dados ............................................. 51


5.1 Matrizes Dinâmicas .............................. 51
5.1.1 Vetores ....................................... 52
5.1.2 Iteradores e Intervalos .......................... 53
5.1.3 Outras Estruturas.............................. 54
5.2 Definir Estruturas ....................................... 55
5.2.1 Conjuntos e Multiconjuntos ....................... 55
5.2.2 Mapas ....................................... 57
5.2.3 Filas Prioritárias .............................. 58
5.2.4 Conjuntos Baseados em Políticas ....................... 59
5.3 Experimentos ................................... 60
5.3.1 Definir versus Ordenar......................... 60
5.3.2 Mapa versus Matriz ......................... 61
5.3.3 Fila de Prioridade versus Multiconjunto ................ 62
6 Programação Dinâmica .................................... 63
6.1 Conceitos Básicos ......................... 63
6.1.1 Quando o ganancioso falha ....................... 63
6.1.2 Encontrando uma Solução Ótima ......... 64
6.1.3 Soluções de Contagem ....................... 67
6.2 Outros Exemplos ................................... 68
6.2.1 Subsequência Crescente Mais Longa ........ 69
6.2.2 Caminhos em uma Grade .............................. 70
6.2.3 Problemas da mochila.................................. 71
6.2.4 Das permutações aos subconjuntos .................. 72
6.2.5 Contando telhas .............................. 74
7 Algoritmos de Gráfico ........................................ 77
7.1 Noções Básicas de Gráficos......................... 78
7.1.1 Terminologia do Gráfico .............................. 78
7.1.2 Representação Gráfica .............................. 80
7.2 Travessia do Gráfico .............................. 83
7.2.1 Pesquisa em Profundidade .............................. 83
Machine Translated by Google

Conteúdo ix

7.2.2 Pesquisa em Largura .............................. 85


7.2.3 Aplicativos .............................. 86
7.3 Caminhos mais curtos.............................. 87
7.3.1 Algoritmo de Bellman-Ford ....................... 88
7.3.2 Algoritmo de Dijkstra .......................... 89
7.3.3 Algoritmo Floyd-Warshall ....................... 92
7.4 Gráficos Acíclicos Dirigidos .............................. 94
7.4.1 Classificação Topológica .............................. 94
7.4.2 Programação Dinâmica ......................... 96
7.5 Gráficos Sucessores .................................. 97
7.5.1 Encontrando Sucessores .............................. 98
7.5.2 Detecção de Ciclo .............................. 99
7.6 Árvores Geradoras Mínimas .............................. 100
7.6.1 Algoritmo de Kruskal ....................... 101
7.6.2 Estrutura Union-Find .......................... 103
7.6.3 Algoritmo de Prim.............................. 106
8 Tópicos de Design de Algoritmos ......................... 107
8.1 Algoritmos de Bit-Paralelo .............................. 107
8.1.1 Distâncias de Hamming.............................. 107
8.1.2 Contando Subgrades .............................. 108
8.1.3 Acessibilidade em Gráficos ....................... 110
8.2 Análise Amortizada .................................. 111
8.2.1 Método de Dois Ponteiros 111 .......................
8.2.2 Elementos Menores Mais Próximos ....................... 113
8.2.3 Mínimo da Janela Deslizante ....................... 114
8.3 Encontrando Valores Mínimos.............................. 115
8.3.1 Pesquisa Ternária .............................. 115
8.3.2 Funções Convexas .............................. 116
8.3.3 Minimizando Somas .............................. 117
9 Consultas de intervalo .............................. 119
9.1 Consultas em Matrizes Estáticas.............................. 119
9.1.1 Consultas de Soma .............................. 120
9.1.2 Consultas Mínimas .............................. 121
9.2 Estruturas de Árvore ......................................... 122
9.2.1 Árvores Indexadas Binárias 122 .......................
9.2.2 Árvores de Segmentos................................ 125
9.2.3 Técnicas Adicionais.............................. 128
10 Algoritmos de Árvore ....................................... 131
10.1 Técnicas Básicas .............................. 131
10.1.1 Travessia da Árvore .............................. 132
10.1.2 Cálculo de Diâmetros .......................... 134
10.1.3 Todos os Caminhos Mais Longos ....................... 135
Machine Translated by Google

x Conteúdo

10.2 Consultas em Árvore........................................ 137


10.2.1 Encontrando Antepassados .............................. 137
10.2.2 Subárvores e Caminhos......................... 138
10.2.3 Ancestrais Comuns Mais Baixos......................... 140
10.2.4 Mesclando Estruturas de Dados ....................... 142
10.3 Técnicas Avançadas ......................... 144
10.3.1 Decomposição Centróide ........................ 144
10.3.2 Decomposição Pesada-Luz ....................... 145
11 Matemática.............................................. 147
11.1 Teoria dos Números ........................................ .......... .................... 147
11.1.1 Primos e Fatores 148
11.1.2 Peneira de Eratóstenes .......................... 150
11.1.3 Algoritmo de Euclides ....................... 151
11.1.4 Exponenciação Modular ....................... 153
11.1.5 Teorema de Euler .............................. 153
11.1.6 Resolvendo Equações .............................. 155
11.2 Combinatória ....................................... 156
11.2.1 Coeficientes Binomiais .......................... 157
11.2.2 Números Catalões.............................. 159
11.2.3 Inclusão-Exclusão .......................... 161
11.2.4 Lema de Burnside.............................. 163
11.2.5 Fórmula de Cayley .............................. 164
11.3 Matrizes .............................................. 164
11.3.1 Operações da Matriz .............................. 165
11.3.2 Recorrências Lineares ....................... 167
11.3.3 Gráficos e Matrizes ....................... 169
11.3.4 Eliminação Gaussiana ....................... 170
11.4 Probabilidade ........................................ 173
11.4.1 Trabalhando com Eventos.............................. 174
11.4.2 Variáveis Aleatórias.................................. 175
11.4.3 Cadeias de Markov .............................. 178
11.4.4 Algoritmos Aleatórios ....................... 179
11.5 Teoria dos Jogos ....................................... 181
11.5.1 Estados do Jogo .............................. 181
11.5.2 Jogo Nim......................... 182
11.5.3 Teorema de Sprague-Grundy ....................... 184
12 Algoritmos Gráficos Avançados .............................. 189
12.1 Conectividade Forte .................................... 189
12.1.1 Algoritmo de Kosaraju .......................... 190
12.1.2 Problema 2SAT.............................. 192
12.2 Caminhos Completos......................... 193
12.2.1 Caminhos Eulerianos .............................. 194
Machine Translated by Google

Conteúdo XI

12.2.2 Caminhos Hamiltonianos .............................. 195


12.2.3 Aplicativos .............................. 196
12.3 Vazões Máximas......................................... 198
12.3.1 Algoritmo Ford-Fulkerson ....................... 199
12.3.2 Caminhos Disjuntos ................... 202
12.3.3 Máximo de Correspondências.......................... 203
12.3.4 Coberturas de Caminho................................. 205
12.4 Árvores de Pesquisa em Profundidade .............................. 207
12.4.1 Biconectividade ................... 207
12.4.2 Subgrafos Eulerianos .............................. 209
13 Geometria ........................................................ 211
13.1 Técnicas Geométricas ........................ 211
13.1.1 Números Complexos......................... 211
13.1.2 Pontos e Linhas .............................. 213
13.1.3 Área do Polígono ......................... 216
13.1.4 Funções de Distância ........................... 218
13.2 Algoritmos de Linha de Varredura .............................. 220
13.2.1 Pontos de Interseção .......................... 220
13.2.2 Problema do Par Mais Próximo ..........................
221
13.2.3 Problema de Casco Convexo 224
.......................
14 Algoritmos de String ....................................... 225
14.1 Tópicos Básicos......................................... 225
14.1.1 Estrutura Trie ................................ 226
14.1.2 Programação Dinâmica ....................... 227
14.2 Hash de String .............................. 228
14.2.1 Hash polinomial .......................... 228
14.2.2 Aplicativos .............................. 229
14.2.3 Colisões e Parâmetros ....................... 230
14.3 Algoritmo Z ........................................ 231
14.3.1 Construindo o Z-Array........................ 232
14.3.2 Aplicativos .............................. 233
14.4 Matrizes de Sufixos ........................................ 234
14.4.1 Método de Duplicação de Prefixo ....................... 235
14.4.2 Encontrando Padrões.............................. 236
14.4.3 Matrizes LCP .............................. 236
15 Tópicos Adicionais ....................................... 239
15.1 Técnicas de Raiz Quadrada .............................. 239
15.1.1 Estruturas de Dados .............................. 240
15.1.2 Subalgoritmos ................... 241
15.1.3 Partições Inteiras .............................. 243
15.1.4 Algoritmo de Mo .............................. 244
Machine Translated by Google

xii Conteúdo

15.2 Árvores de Segmento Revisitadas .............................. 245 15.2.1 Propagação


Preguiçosa ......... ......................... 246 15.2.2 Árvores Dinâmicas ........................ .........
249 15.2.3 Estruturas de dadosbidimensionais..
em nós .......................
.........................
251 15.2.4 253
Árvores
15.3
Armadilhas ........................ ......................... 253 15.3.1 Dividindo e
Mesclando ....................... .... 253 15.3.2 Implementação .............................. 255 15.3.3
Técnicas Adicionais.... ......................... 257 15.4 Otimização de Programação
Dinâmica ..................... 258 15.4 .1 Truque de Casco Convexo ....................... 258
15.4.2 Otimização de Dividir e Conquistar ........ ......... 260 15.4.3 Otimização de
Knuth .......................... 261 15.5 Diversos...... ......................... 262 15.5.1 Encontro no
Meio ......... ............... 263 15.5.2 Contando Subconjuntos ........................ ..... 263 15.5.3
Pesquisa Binária Paralela .............. ............ 265 15.5.4 Conectividade
Dinâmica .......................... 266

Apêndice A: Fundamentos Matemáticos .......................... 269

Referências .................................................... . 277

Índice .................................................... ..... 279


Machine Translated by Google

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.

1.1 O que é Programação Competitiva?

A programação competitiva combina dois tópicos: o design de algoritmos e a implementação


de algoritmos.

Projeto de Algoritmos O cerne da programação competitiva é inventar algoritmos eficientes


que resolvam problemas computacionais bem definidos. O projeto de algoritmos requer
resolução de problemas e habilidades matemáticas. Muitas vezes, uma solução para um
problema é uma combinação de métodos conhecidos e novos insights.
A matemática desempenha um papel importante na programação competitiva. Na verdade,
não há limites claros entre o projeto de algoritmos e a matemática. Este livro foi escrito para
que não seja necessária muita experiência em matemática. O apêndice do livro revisa alguns
conceitos matemáticos que são usados ao longo do livro,

© Springer International Publishing AG, parte da Springer Nature 2017 A. 1


Laaksonen, Guide to Competitive Programming, Undergraduate Topics in
Computer Science, https://doi.org/10.1007/978-3-319-72547-5_1
Machine Translated by Google

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.

Implementação de Algoritmos Na programação competitiva, as soluções dos problemas são


avaliadas testando um algoritmo implementado usando um conjunto de casos de teste.
Assim, depois de chegar a um algoritmo que resolva o problema, o próximo passo é implementá-lo
corretamente, o que requer boas habilidades de programação. A programação competitiva difere
muito da engenharia de software tradicional: os programas são curtos (geralmente no máximo
algumas centenas de linhas), devem ser escritos rapidamente e não é necessário mantê-los após a
competição.
No momento, as linguagens de programação mais populares usadas em concursos são C++,
Python e Java. Por exemplo, no Google Code Jam 2017, entre os 3.000 melhores participantes, 79%
usaram C++, 16% usaram Python e 8% usaram Java. Muitas pessoas consideram C++ como a melhor
escolha para um programador competitivo. Os benefícios de usar C++ são que é uma linguagem
muito eficiente e sua biblioteca padrão contém uma grande coleção de estruturas de dados e
algoritmos.
Todos os programas de exemplo neste livro são escritos em C++, e as estruturas de dados e
algoritmos da biblioteca padrão são frequentemente usados. Os programas seguem o padrão C++11,
que pode ser utilizado na maioria dos concursos atuais. Se você ainda não pode programar em C++,
agora é um bom momento para começar a aprender.

1.1.1 Concursos de Programação

IOI A Olimpíada Internacional de Informática é um concurso anual de programação para alunos do


ensino médio. Cada país pode enviar uma equipe de quatro alunos para o concurso. Geralmente há
cerca de 300 participantes de 80 países.
O IOI consiste em dois concursos de cinco horas de duração. Em ambos os concursos, os
participantes são convidados a resolver três difíceis tarefas de programação. As tarefas são divididas
em subtarefas, cada uma com uma pontuação atribuída. Enquanto os competidores são divididos em
equipes, eles competem como indivíduos.
Os participantes do IOI são selecionados através de concursos nacionais. Antes do IOI, muitos
concursos regionais são organizados, como a Olimpíada do Báltico em Informática (BOI), a Olimpíada
da Europa Central em Informática (CEOI) e a Olimpíada de Informática da Ásia-Pacífico (APIO).

ICPC O International Collegiate Programming Contest é um concurso anual de programação para


estudantes universitários. Cada equipe do concurso é composta por três alunos e, diferentemente do
IOI, os alunos trabalham juntos; há apenas um computador disponível para cada equipe.

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

1.1 O que é Programação Competitiva? 3

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.

Concursos Online Existem também muitos concursos online abertos a todos.


No momento, o site de concursos mais ativo é o Codeforces, que organiza concursos semanalmente.
Outros sites populares de concursos incluem AtCoder, CodeChef, CS Academy, HackerRank e Topcoder.

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.

1.1.2 Dicas para praticar

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.

Finalmente, algumas pessoas desprezam a implementação de algoritmos: é divertido projetar


algoritmos, mas é chato implementá-los. No entanto, a capacidade de implementar algoritmos de forma
rápida e correta é um ativo importante, e essa habilidade pode ser praticada. É uma má ideia gastar a
maior parte do tempo do concurso escrevendo código e encontrando bugs, em vez de pensar em como
resolver problemas.

1.2 Sobre este livro

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.

A estrutura do livro é a seguinte:

• O Capítulo 2 revisa os recursos da linguagem de programação C++ e, em seguida, discute


algoritmos recursivos e manipulação de bits.
• O Capítulo 3 se concentra na eficiência: como criar algoritmos que podem processar rapidamente
grandes conjuntos de dados.

• O Capítulo 4 discute algoritmos de ordenação e busca binária, com foco em seu ap


aplicações no projeto de algoritmos.
• O Capítulo 5 passa por uma seleção de estruturas de dados da biblioteca padrão C++,
como vetores, conjuntos e mapas.
• O Capítulo 6 apresenta uma técnica de projeto de algoritmo chamada programação dinâmica,
e apresenta exemplos de problemas que podem ser resolvidos usando-o.
• O Capítulo 7 discute algoritmos de grafos elementares, como encontrar caminhos mais curtos
e árvores geradoras mínimas.
• O Capítulo 8 trata de alguns tópicos avançados de projeto de algoritmos, como paralelismo de bits e análise
amortizada. • O Capítulo 9 se concentra no processamento eficiente de consultas de intervalo de matriz,
como cálculo
somas de valores e determinação de valores mínimos.
• Capítulo 10 apresenta algoritmos especializados para árvores, incluindo métodos para processar consultas de
árvores. • Capítulo 11 discute tópicos matemáticos que são relevantes em profissionais competitivos

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

1.3 Conjunto de Problemas CSES 5

1.3 Conjunto de Problemas CSES

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

Sua tarefa é simular a execução do algoritmo para um determinado valor de n.

Entrada

A única linha de entrada contém um inteiro n.

Saída

Imprime uma linha que contém todos os valores de n durante o algoritmo.


Restrições

• 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>

usando o namespace std;

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";
}

O código primeiro lê o número de entrada n e, em seguida, simula o algoritmo e


imprime o valor de n após cada etapa. É fácil testar se o algoritmo corretamente
lida com o caso de exemplo n = 3 dado na declaração do problema.
Agora é hora de enviar o código para o CSES. Em seguida, o código será compilado e
testado usando um conjunto de casos de teste. Para cada caso de teste, o CSES nos dirá se nosso código
passou ou não, e também podemos examinar a entrada, a saída esperada e o
saída produzida pelo nosso código.
Após testar nosso código, o CSES fornece o seguinte relatório:

veredicto de teste tempo(s)

Nº 1 ACEITO 0,06 / 1,00


#2 ACEITO 0,06 / 1,00
#3 ACEITO 0,07 / 1,00
#4 ACEITO 0,06 / 1,00
#5 ACEITO 0,06 / 1,00
#6 LIMITE DE TEMPO EXCEDIDO – / 1,00
#7 LIMITE DE TEMPO EXCEDIDO – / 1,00
#8 RESPOSTA ERRADA 0,07 / 1,00
#9 LIMITE DE TEMPO EXCEDIDO – / 1,00
#10 ACEITO 0,06 / 1,00

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

1.3 Conjunto de Problemas CSES 7

corrigir o problema, basta alterar nosso código para que o tipo de n seja longo.
Então teremos o resultado desejado:

teste veredito tempo(s)

Nº 1 ACEITO 0,05 / 1,00 Nº 2 ACEITO


0,06 / 1,00
#3 ACEITO 0,07 / 1,00 #4 ACEITO
0,06 / 1,00
Nº 5 ACEITO 0,06 / 1,00
#6 ACEITO 0,05 / 1,00 #7 ACEITO
0,06 / 1,00
#8 ACEITO 0,05 / 1,00 #9 ACEITO
0,07 / 1,00
#10 ACEITO 0,06 / 1,00

Como este exemplo mostra, mesmo algoritmos muito simples podem conter bugs sutis.
A programação competitiva ensina como escrever algoritmos que realmente funcionam.

1.4 Outros Recursos

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.

2.1 Recursos de idioma


Um modelo de código C++ típico para programação competitiva se parece com isso:

#include <bits/stdc++.h>

usando o namespace std;

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

© Springer International Publishing AG, parte da Springer Nature 2017 9


A. Laaksonen, Guide to Competitive Programming, Undergraduate
Topics in Computer Science, https://doi.org/10.1007/978-3-319-72547-5_2
Machine Translated by Google

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:

g++ -std=c++11 -O2 -Wall test.cpp -o test

Este comando produz um teste de arquivo binário a partir do código-fonte test.cpp. O


compilador segue o padrão C++11 (-std=c++11), otimiza o código (-O2) e mostra avisos sobre
possíveis erros (-Wall).

2.1.1 Entrada e Saída


Na maioria dos concursos, fluxos padrão são usados para ler a entrada e escrever a saída. Em C+
+, os fluxos padrão são cin para entrada e cout para saída. Também funções C, como scanf e
printf, podem ser usadas.
A entrada para o programa geralmente consiste em números e strings separados por
espaços e novas linhas. Eles podem ser lidos a partir do fluxo cin da seguinte forma:

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

123 456
macaco

O fluxo cout é usado para saída da seguinte forma:

int a = 123, b = 456; string x =


"macaco"; cout << a <<
""
<<b<< ""
<< x << "\n";

A entrada e a saída às vezes são um gargalo no programa. As seguintes linhas


no início do código tornam a entrada e a saída mais eficientes:
Machine Translated by Google

2.1 Recursos de idioma 11

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);

O código a seguir imprime dois números inteiros:

int a = 123, b = 456; printf("%d


%d\n", 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);

Se a quantidade de dados for desconhecida, o seguinte loop é útil:

enquanto (cin >> x) {


// código
}

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:

freopen("input.txt", "r", stdin); freopen("saída.txt", "w",


stdout);

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

2.1.2 Trabalhando com Números

Inteiros O tipo inteiro mais usado na programação competitiva é o int, que é


... 231 ÿ 1 (cerca de ÿ2 · 109 ...
um tipo1 de 32 bits com um intervalo de valores de ÿ231 2 · 109). Se
o tipo int não é suficiente, o tipo long long de 64 bits pode ser usado. Tem um valor
faixa de -263 ... 263 ÿ 1 (cerca de ÿ9 · 1018 ... 9 · 1018).
O código a seguir define uma variável long long:

longo longo x = 123456789123456789LL;

O sufixo LL significa que o tipo do número é longo.


Um erro comum ao usar o tipo long long é que o tipo int ainda é
usado em algum lugar no código. Por exemplo, o código a seguir contém um erro sutil:

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.

Aritmética Modular Às vezes, a resposta para um problema é um número muito grande,


mas basta emitir “módulo m”, ou seja, o resto quando a resposta for
dividido por m (por exemplo, “módulo 109 + 7”). A ideia é que mesmo que a resposta real seja
muito grande, basta usar os tipos int e long long.
Denotamos por x mod m o resto quando x é dividido por m. Por exemplo,
17 mod 5 = 2, porque 17 = 3 · 5 + 2. Uma propriedade importante dos restos é que
valem as seguintes fórmulas:

(a + b) mod m = (a mod m + b mod m) mod m


(a ÿ b) mod m = (a mod m ÿ b mod m) mod m
(a · b) mod m = (a mod m · b mod m) mod m

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

2.1 Recursos de idioma 13

Por exemplo, o código a seguir calcula n!, o fatorial de n, módulo m:

longo longo x = 1; for (int


i = 1; i <= n; i++) { x = (x*i)%m;

} cout << x << "\n";

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;

No entanto, isso só é necessário quando há subtrações no código, e o restante pode se


tornar negativo.

Números de ponto flutuante Na maioria dos problemas de programação competitivos, basta


usar números inteiros, mas às vezes são necessários números de ponto flutuante. Os tipos de
ponto flutuante mais úteis em C++ são o double de 64 bits e, como uma extensão no compilador
g++, o double de 80 bits. Na maioria dos casos, o dobro é suficiente, mas o dobro longo é mais
preciso.
A precisão necessária da resposta geralmente é fornecida na declaração do problema.
Uma maneira fácil de gerar a resposta é usar a função printf e fornecer o número de casas
decimais na string de formatação. Por exemplo, o código a seguir imprime o valor de x com 9
casas decimais:

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.

duplo x = 0,3*3+0,1; printf("%.20f\n",


x); // 0,99999999999999988898

É 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

if (abs(ab) < 1e-9) {


// uma eB são iguais
}

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.

2.1.3 Código de encurtamento

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:

typedef long long ll;

Após isso, o código

longo longo a = 123456789; b longo


longo = 987654321; cout << a*b << "\n";

pode ser encurtado da seguinte forma:

lla = 123456789; llb =


987654321; cout << a*b <<
"\n";

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.

vetor typedef <int> vi; typedef


par<int,int> pi;

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:

#define F primeiro #define


S segundo
#define PB push_back #define MP
make_pair
Machine Translated by Google

2.1 Recursos de idioma 15

Após isso, o código

v.push_back(make_pair(y1,x1));
v.push_back(make_pair(y2,x2)); int d =
v[i].primeiro+v[i].segundo;

pode ser encurtado da seguinte forma:

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:

#define REP(i,a,b) for (int i = a; i <= b; i++)

Após isso, o código

for (int i = 1; i <= n; i++) {


pesquisa(i);
}

pode ser encurtado da seguinte forma:

REP(i,1,n)
{ pesquisa(i);
}

2.2 Algoritmos Recursivos

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.

2.2.1 Gerando Subconjuntos

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> ;

que conterá os elementos de cada subconjunto. A busca começa quando a função


é chamado com o parâmetro 1.

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);
}
}

Quando a função de busca é chamada com o parâmetro k, ela decide se


incluir o elemento k no subconjunto ou não, e em ambos os casos, então chama a si mesmo com
parâmetro k +1. Então, se k = n +1, a função percebe que todos os elementos foram
processado e um subconjunto foi gerado.
A Figura 2.1 ilustra a geração de subconjuntos quando n = 3. Em cada chamada de função,
ou o ramo superior (k está incluído no subconjunto) ou o ramo inferior (k não é
incluído no subconjunto) é escolhido.

2.2.2 Gerando Permutações

Em seguida, consideramos o problema de gerar todas as permutações de um conjunto de n elementos.


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). Novamente, podemos usar a recursão para realizar a pesquisa. A seguinte função
de busca mantém um vetor

Fig. 2.1 A árvore de recursão


ao gerar os subconjuntos
do conjunto {1, 2, 3}
Machine Translated by Google

2.2 Algoritmos Recursivos 17

vector<int> permutação;

que conterá cada permutação e uma matriz

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();

}
}
}

Cada chamada de função acrescenta um novo elemento à permutação e registra


que ela foi incluída na escolha. Se o tamanho da permutação for igual ao tamanho
do conjunto, uma permutação foi gerada.
Observe que a biblioteca padrão C++ também possui a função next_permutation
que pode ser usada para gerar permutações. A função recebe uma permutação e
produz a próxima permutação em ordem lexicográfica. O código a seguir passa pelas
permutações de {1, 2,..., n}:

for (int i = 1; i <= n; i++) { permutation.push_back(i);

} 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.

Fig. 2.2 As possíveis maneiras de colocar 4 rainhas em um tabuleiro de xadrez 4 × 4

Fig. 2.3 Soluções parciais para o problema da rainha usando retrocesso


Machine Translated by Google

2.2 Algoritmos Recursivos 19

O algoritmo pode ser implementado da seguinte forma:

void search(int y) { if (y == n)
{ contagem++;

Retorna;

} for (int x = 0; x < n; x++) {


if (col[x] || diag1[x+y] || diag2[x-y+n-1]) continue; col[x] = diag1[x+y] = diag2[x-y+n-1] = 1;
pesquisa(y+1); col[x] = diag1[x+y] = diag2[x-y+n-1] = 0;

}
}

A pesquisa começa chamando search(0). O tamanho do tabuleiro é n e o código calcula o


número de soluções a serem contadas. O código assume que as linhas e colunas do tabuleiro são
numeradas de 0 a n ÿ 1. Quando a busca é chamada com o parâmetro y, ela coloca uma rainha
na linha y e então chama a si mesma com o parâmetro y+1. Então, se y = n, uma solução foi
encontrada e o valor de count é aumentado em um.

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.

Na verdade, ninguém conhece uma maneira eficiente de contar o número de combinações de


rainhas para valores maiores de n. Atualmente, o maior valor de n para o qual o resultado é
conhecido é 27: existem 234907967154122528 combinações neste caso. Isso foi descoberto em
2016 por um grupo de pesquisadores que usou um cluster de computadores para calcular o
resultado [25].

Fig. 2.4 Numeração das


matrizes ao contar as
combinações no 4 × 4
quadro
Machine Translated by Google

20 2 Técnicas de Programação

2.3 Manipulação de bits

Na programação, um inteiro de n bits é armazenado internamente como um número binário que


consiste em n bits. Por exemplo, o tipo C++ int é um tipo de 32 bits, o que significa que cada
número int consiste em 32 bits. Por exemplo, a representação de bits do número int 43 é

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

bk2k +···+ b222 + b121 + b020.

pode ser usado. Por exemplo,

1 · 25 + 1 · 23 + 1 · 21 + 1 · 20 = 43.

A representação de bits de um número é assinada ou não. Normalmente, uma representação


com sinal é usada, o que significa que números negativos e positivos podem ser representados.
Uma variável com sinal de n bits pode conter qualquer inteiro entre ÿ2nÿ1 e 2nÿ1 ÿ 1. Por
exemplo, o tipo int em C++ é um tipo com sinal, então uma variável int pode conter qualquer
inteiro entre ÿ231 e 231 ÿ 1 .
O primeiro bit em uma representação com sinal é o sinal do número (0 para números não
negativos e 1 para números negativos), e os n ÿ 1 bits restantes contêm a magnitude do
número. O complemento de dois é usado, o que significa que o número oposto de um número
é calculado primeiro invertendo todos os bits no número e depois aumentando o número em
um. Por exemplo, a representação de bits do número int ÿ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:

intx = -43; sem


sinal int y = x; cout << x <<
"\n"; cout << y << "\n"; -43
// // //4294967253

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

2.3 Manipulação de bits 21

e em uma representação sem sinal, o próximo número após 2n ÿ 1 é 0. Por exemplo,


considere o seguinte código:

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.

2.3.1 Operações de bits

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) .

Usando a operação and, podemos verificar se um número x é par porque x & 1 = 0 se


x for par, e x & 1 = 1 se x for ímpar. Mais geralmente, x é divisível por 2k exatamente
quando x & (2k ÿ 1) = 0.

Operação Or A operação or x | y produz um número que tem um bit em posições onde


pelo menos um de x e y tem um bit. Por exemplo, 22 | 26 = 30, porque

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:

for (int k = 31; k >= 0; k--) { if (x&(1<<k)) cout <<


"1"; senão cout << "0";

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:

• __builtin_clz(x): o número de zeros no início do bit representado


tação
• __builtin_ctz(x): o número de zeros no final da representação de bit • __builtin_popcount(x): o
número de uns na representação de bit • __builtin_parity(x): a paridade (par ou ímpar) do número
de uns em a representação de bits

As funções podem ser usadas da seguinte forma:


Machine Translated by Google

2.3 Manipulação de bits 23

intx = 5328; cout << // 00000000000000000001010011010000


__builtin_clz(x) << "\n"; cout << __builtin_ctz(x) << "\n"; 19
cout << __builtin_popcount(x) << "\n"; cout << // // //4
__builtin_parity(x) << "\n"; // // 5
1

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.

2.3.2 Representando Conjuntos

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,

que corresponde ao número 28 + 24 + 23 + 21 = 282.


O código a seguir declara uma variável int x que pode conter um subconjunto de
{0, 1, 2,..., 31}. Depois disso, o código adiciona os elementos 1, 3, 4 e 8 ao conjunto
e imprime o tamanho do conjunto.

int x = 0;
x |= (1<<1);
x |= (1<<3);
x |= (1<<4);
x |= (1<<8);
cout << __builtin_popcount(x) << "\n"; // 4

Em seguida, o código a seguir imprime todos os elementos que pertencem ao conjunto:

for (int i = 0; i < 32; i++) {


if (x&(1<<i)) cout << i << " ";
}
// saída: 1348

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

Tabela 2.1 Implementando operações de conjunto como operações de bits

Operação Definir sintaxe Sintaxe de bits

Interseção aÿb a&b

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

O código a seguir percorre os subconjuntos de {0, 1,..., n ÿ 1}:

for (int b = 0; b < (1 <<n); b++) {


// subconjunto de processos b
}

Então, o código a seguir percorre os subconjuntos com exatamente k elementos:

for (int b = 0; b < (1 <<n); b++) {


if (__builtin_popcount(b) == k) {
// subconjunto de processos b
}
}

Por fim, o código a seguir percorre os subconjuntos de um conjunto x:

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

2.3 Manipulação de bits 25

conjunto de bits <10> s;


s[1] = 1;
s[3] = 1;
s[4] = 1;
s[7] = 1;
cout << s[4] << "\n"; cout << s[5] 1
<< "\n"; // // //0

A função count retorna o número de um bits no bitset:

cout << s.count() << "\n"; // 4

Também as operações de bits podem ser usadas diretamente para manipular conjuntos de bits:

conjunto de bits <10> a, b;


// ...
conjunto de bits <10> c = a&b;
conjunto de bits<10> d = a|b;
conjunto de bits<10> e = a^b;
Machine Translated by Google

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.

3.1 Complexidade do Tempo

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.

3.1.1 Regras de Cálculo

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).

© Springer International Publishing AG, parte da Springer Nature 2017 27


A. Laaksonen, Guide to Competitive Programming, Undergraduate
Topics in Computer Science, https://doi.org/10.1007/978-3-319-72547-5_3
Machine Translated by Google

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).

for (int i = 1; i <= n; i++) {


...
}

Então, a complexidade de tempo do código a seguir é O(n2):

for (int i = 1; i <= n; i++) {


for (int j = 1; j <= n; j++) {
...
}
}

Em geral, se houver k loops aninhados e cada loop passar por n valores, o


complexidade de tempo é O(nk ).
Uma complexidade de tempo não nos diz o número exato de vezes que o código dentro de um loop
é executado, porque mostra apenas a ordem de crescimento e ignora os fatores constantes. Nos
exemplos a seguir, o código dentro do loop é executado 3n, n + 5 e n/2 vezes, mas a complexidade de
tempo de cada código é O(n).

for (int i = 1; i <= 3*n; i++) {


...
}

for (int i = 1; i <= n+5; i++) {


...
}

for (int i = 1; i <= n; i += 2) {


...
}

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

3.1 Complexidade do Tempo 29

for (int i = 1; i <= n; i++) {


for (int j = 1; j <= i; j++) {
...
}
}

Se um algoritmo consiste em fases consecutivas, a complexidade de tempo total é a maior


complexidade de tempo de uma única fase. A razão para isso é que a fase mais lenta é o gargalo
do algoritmo. Por exemplo, o código a seguir consiste em três fases com complexidades de tempo
O(n), O(n2) e O(n). Assim, a complexidade de tempo total é O(n2).

for (int i = 1; i <= n; i++) {


...

} for (int i = 1; i <= n; i++) {


for (int j = 1; j <= n; j++) {
...
}

} for (int i = 1; i <= n; i++) {


...
}

Às vezes, a complexidade do tempo depende de vários fatores, e a fórmula da complexidade


do tempo contém várias variáveis. Por exemplo, a complexidade de tempo do código a seguir é
O(nm):

for (int i = 1; i <= n; i++) {


for (int j = 1; j <= m; j++) {
...
}
}

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);

A chamada f(n) causa n chamadas de função, e a complexidade de tempo de cada chamada


é O(1), então a complexidade de tempo total é O(n).
Como outro exemplo, considere a seguinte função:
Machine Translated by Google

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 é

1 + 2 + 4 +···+ 2nÿ1 = 2n ÿ 1 = O(2n).

3.1.2 Complexidades de Tempo Comum

A lista a seguir contém complexidades de tempo comuns de algoritmos:

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

3.1 Complexidade do Tempo 31

Um algoritmo é polinomial se sua complexidade de tempo é no máximo O(nk ) onde k é uma


constante. Todas as complexidades de tempo acima, exceto O(2n) e O(n!) são polinomiais. Na prática,
a constante k é geralmente pequena e, portanto, uma complexidade de tempo polinomial significa
aproximadamente que o algoritmo pode processar grandes entradas.
A maioria dos algoritmos neste livro são polinomiais. Ainda assim, existem muitos problemas
importantes para os quais nenhum algoritmo polinomial é conhecido, ou seja, ninguém sabe como
resolvê-los de forma eficiente. Problemas NP-difíceis são um importante conjunto de problemas, para
os quais nenhum algoritmo polinomial é conhecido.

3.1.3 Estimativa de Eficiência

Ao calcular a complexidade de tempo de um algoritmo, é possível verificar, antes de implementá-lo,


se ele é eficiente o suficiente para resolver um problema. O ponto de partida para as estimativas é o
fato de que um computador moderno pode realizar algumas centenas de milhões de operações
simples em um segundo.
Por exemplo, suponha que o limite de tempo para um problema seja de um segundo e o tamanho
da entrada seja n = 105. Se a complexidade de tempo for O(n2), o algoritmo executará cerca de
(105)2 = 1010 operações. Isso deve levar pelo menos algumas dezenas de segundos, então o
algoritmo parece ser muito lento para resolver o problema. No entanto, se a complexidade de tempo
for O(n log n), haverá apenas cerca de 105 log 105 ÿ 1,6·106 operações, e o algoritmo certamente se
ajustará ao limite de tempo.
Por outro lado, dado o tamanho da entrada, podemos tentar adivinhar a complexidade de tempo
necessária do algoritmo que resolve o problema. A Tabela 3.1 contém algumas estimativas úteis
assumindo um limite de tempo de um segundo.
Por exemplo, se o tamanho da entrada for n = 105, provavelmente é esperado que a complexidade
de tempo do algoritmo seja O(n) ou O(n log n). Essas informações facilitam o projeto do algoritmo,
pois excluem abordagens que produziriam um algoritmo com uma complexidade de tempo pior.

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.

Tabela 3.1 Estimando a complexidade do tempo a partir do tamanho da entrada

Tamanho de entrada Complexidade de tempo esperada

n ÿ 10 Sobre!)

n ÿ 20 O(2n)

n ÿ 500 O(n3)

n ÿ 5000 n O(n2)

ÿ 106 n é O(n log n) ou O(n)

grande O(1) ou O(log n)


Machine Translated by Google

32 3 Eficiência

3.1.4 Definições formais

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).

for (int i = 1; i <= n; i++) {


...
}

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.

3.2.1 Soma Máxima do Subarray

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

Fig. 3.1 O subarranjo de


soma máxima deste vetor é
[2, 4, ÿ3, 5, 2], cuja soma é
10

O(n3) Solução de Tempo Uma maneira direta de resolver o problema é percorrer


todos os subarranjos possíveis, calcular a soma dos valores em cada subarranjo e
manter a soma máxima. O código a seguir implementa esse algoritmo:

int melhor = 0; for


(int a = 0; a < n; a++) {
for (int b = a; b < n; b++) {
int soma = 0; for
(int k = a; k <= b; k++) { soma += array[k];

}
melhor = max(melhor,soma);
}

} cout << melhor << "\n";

As variáveis aeb fixam o primeiro e o último índice do subarray, e a soma dos


valores é calculada para a variável soma. A variável best contém a soma máxima
encontrada durante a busca. A complexidade de tempo do algoritmo é O(n3), pois
consiste em três loops aninhados que passam pela entrada.

O(n2) Solução de Tempo É fácil tornar o algoritmo mais eficiente removendo um


laço dele. Isso é possível calculando a soma ao mesmo tempo em que a extremidade
direita do subarray se move. O resultado é o seguinte código:

int melhor = 0; for


(int a = 0; a < n; a++) {
int soma = 0; for
(int b = a; b < n; b++) { soma += array[b]; melhor
= max(melhor,soma);

} cout << melhor << "\n";

Após esta mudança, a complexidade de tempo é O(n2).

Solução em tempo O(n) Acontece que é possível resolver o problema em tempo


O(n) , o que significa que apenas um loop é suficiente. A ideia é calcular, para cada
posição do array, a soma máxima de um subarray que termina nessa posição. Depois
disso, a resposta para o problema é o máximo dessas somas.
Machine Translated by Google

34 3 Eficiência

Considere o subproblema de encontrar o subarranjo de soma máxima que termina em posi


ção k. Existem duas possibilidades:

1. O subarray contém apenas o elemento na posição k.


2. O subarray consiste em um subarray que termina na posição k ÿ 1, seguido pelo
elemento na posição k.

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:

int melhor = 0, soma = 0;


for (int k = 0; k < n; k++) {
soma = max(array[k],soma+array[k]);
melhor = max(melhor,soma);
}
cout << melhor << "\n";

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

Tamanho da matriz O(n3) (s) O(n2) (s) O(n) (s)


n 102 0,0 0,0 0,0
103 0,1 0,0 0,0
104 >10,0 0,1 0,0
105 >10,0 5.3 0,0
106 >10,0 >10,0 0,0
107 >10,0 >10,0 0,0
Machine Translated by Google

3.2 Exemplos 35

3.2.2 Problema das Duas Rainhas

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

Fig. 3.2 Todas as formas possíveis


colocar dois não-atacantes
rainhas no 3 × 3
tabuleiro de xadrez

Tabela 3.3 Primeiros valores do


Tamanho da placa n Número de maneiras q(n)
função q(n): o número de
1 0
maneiras de colocar dois
rainhas não atacantes em um 2 0
n × n tabuleiro de xadrez 3 8

4 44

5 140

6 340

7 700

8 1288

9 2184

10 3480
Machine Translated by Google

36 3 Eficiência

Fig. 3.3 A rainha ataca


todas as casas marcadas com “*”
no quadro

Fig. 3.4 Posições possíveis


para rainhas na última linha
e coluna

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

q(n) = q(n ÿ 1) + (2n ÿ 1)(n2 ÿ 3(n ÿ 1) ÿ 1) ÿ (n ÿ 1)(n ÿ 2)

= q(n ÿ 1) + 2(n ÿ 1) 2(n ÿ 2),

que fornece uma solução O(n) para o problema.


Finalmente, verifica-se que também existe uma fórmula de forma fechada

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.

4.1 Algoritmos de Ordenação

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.

Nesta seção, veremos alguns algoritmos de ordenação fundamentais e examinaremos suas


propriedades. É fácil projetar um algoritmo de ordenação temporal O(n2) , mas também existem algoritmos
mais eficientes. Depois de discutir a teoria da ordenação, vamos nos concentrar no uso da ordenação na
prática em C++.

© Springer International Publishing AG, parte da Springer Nature 2017 37


A. Laaksonen, Guide to Competitive Programming, Undergraduate
Topics in Computer Science, https://doi.org/10.1007/978-3-319-72547-5_4
Machine Translated by Google

38 4 Classificando e Pesquisando

Fig. 4.1 Um array antes e depois


da ordenação

Fig. 4.2 A primeira rodada de


classificação de bolhas

4.1.1 Ordenação por Bolha

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:

for (int i = 0; i < n; i++) {


for (int j = 0; j < n-1; j++) { if (array[j] > array[j+1]) {

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

4.1 Algoritmos de Ordenação 39

Fig. 4.3 Esta matriz tem três


inversões: (3, 4), (3, 5) e (6, 7)

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).

O número de inversões indica quanto trabalho é necessário para classificar a matriz.


Um array está completamente ordenado quando não há inversões. Por outro lado, se os elementos
do array estão na ordem inversa, o número de inversões é

n(n - 1)
+ 2 +···+ (n ÿ 1) = 2 = O(n2), 1

que é o maior possível.


Trocar um par de elementos consecutivos que estão na ordem errada remove exatamente uma
inversão da matriz. Portanto, se um algoritmo de ordenação só pode trocar elementos consecutivos,
cada troca remove no máximo uma inversão e a complexidade de tempo do algoritmo é de pelo
menos O(n2).

4.1.2 Mesclar Ordenação

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.

2. Calcule a posição do elemento central: k = (a + b)/2.


3. Classifique recursivamente o array de submatriz[a ... k].
4. Classifique recursivamente o array de submatriz[k + 1 ... b].
5. Mescle os subarrays ordenados array[a ... k] e array[k + 1 ... b] em um array de subarrays
ordenado[a ... b].

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

Fig. 4.4 Classificando um


array usando merge sort

Fig. 4.5 O progresso de um


algoritmo de ordenação que
compara elementos de array

4.1.3 Classificando o Limite Inferior

É 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

log2(n!) = log2(1) + log2(2) +···+ log2(n).

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

log2(n!) ÿ (n/2) · log2(n/ 2),

então a altura da árvore e o número de passos do pior caso em um algoritmo de ordenação é


ÿ(n log n).
Machine Translated by Google

4.1 Algoritmos de Ordenação 41

Fig. 4.6 Classificando uma matriz

usando a classificação por contagem

4.1.4 Ordenação de Contagem

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.

4.1.5 Classificação na prática

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:

vetor<int> v = {4,2,5,3,5,8,3}; sort(v.begin(),v.end());

Após a ordenação, o conteúdo do vetor será [2, 3, 3, 4, 5, 5, 8]. O padrão


ordem de classificação está aumentando, mas uma ordem inversa é possível da seguinte forma:

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());

Um array comum pode ser classificado da seguinte forma:

int n = 7; int a[] // tamanho da matriz


= {4,2,5,3,5,8,3}; ordenar(a,a+n);

Em seguida, o código a seguir classifica a string s:

string s = "macaco"; sort(s.begin(),


s.end());

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

4.1 Algoritmos de Ordenação 43

é 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:

bool comp(string a, string b) { if (a.size() == b.size())


return a < b; senão return a.size() < b.size();

Agora, um vetor de strings pode ser classificado da seguinte forma:

sort(v.begin(), v.end(), comp);

4.2 Resolvendo Problemas Classificando

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.

4.2.1 Algoritmos de Linha de Varredura

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) .

Fig. 4.7 Uma instância do


problema do restaurante

Fig. 4.8 Resolvendo o


problema do restaurante usando
um algoritmo de linha de varredura
Machine Translated by Google

4.2 Resolvendo Problemas Classificando 45

Fig. 4.9 Uma instância do problema


de escalonamento e uma solução
ótima com dois eventos

Fig. 4.10 Se selecionarmos o


evento curto, podemos selecionar
apenas um evento, mas podemos
selecionar os dois eventos longos

Fig. 4.11 Se selecionarmos o


primeiro evento, não podemos
selecionar nenhum outro evento,
mas podemos selecionar os outros dois
eventos

4.2.2 Agendamento de Eventos

Muitos problemas de agendamento podem ser resolvidos classificando os dados de entrada e, em


seguida, usando uma estratégia gananciosa para construir uma solução. Um algoritmo ganancioso
sempre faz uma escolha que parece a melhor no momento e nunca retira suas escolhas.
Como exemplo, considere o seguinte problema: Dados n eventos com seus horários de início e
término, encontre uma programação que inclua o maior número possível de eventos. Por exemplo, a
Fig. 4.9 mostra uma instância do problema em que uma solução ótima é selecionar dois eventos.

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.

4.2.3 Tarefas e Prazos

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

Fig. 4.13 Melhorando a


solução trocando tarefas X
eY

Por exemplo, suponha que as tarefas sejam as seguintes:

prazo de duração da tarefa

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.

4.3 Pesquisa Binária

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

4.3 Pesquisa Binária 47

Fig. 4.14 A maneira


tradicional de implementar a
busca binária. A cada passo,
verificamos o elemento do meio
do subarray ativo e avançamos
para a parte esquerda ou direita

4.3.1 Implementando a Pesquisa

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:

int a = 0, b = n-1; enquanto (a


<= b) { int k = (a+b)/2; if
(matriz[k] == x) {

// x Encontrado em índice k

} if (matriz[k] < x) a = k+1; senão b = k-1;

Nesta implementação, o intervalo do subarray ativo é a ... b, e o intervalo inicial é 0 ... n ÿ 1. O


algoritmo reduz pela
O(log
metade
n). o tamanho do subarray em cada etapa, então a complexidade de tempo é

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

Fig. 4.15 Uma forma


alternativa de implementar
busca binária. Verificamos o
array da esquerda para a
direita pulando sobre os elementos

Segundo método Outra maneira de implementar a busca binária é percorrer o array da


esquerda para a direita fazendo saltos. O comprimento inicial do salto é n/2, e o
comprimento do salto é reduzido pela metade em cada rodada: primeiro n/4, depois n/8,
depois n/16, etc., até que finalmente o comprimento seja 1. Em cada rodada, fazemos
salta até terminarmos fora do array ou em um elemento cujo valor excede o valor alvo.
Após os saltos, ou o elemento desejado foi encontrado ou sabemos que ele não aparece
no array. A Figura 4.15 ilustra a técnica em nosso cenário de exemplo.
O código a seguir implementa a pesquisa:

intk = 0; for (int


b = n/2; b >= 1; b /= 2) { while (k+b < n && array[k+b] <= x)
k += b;

} if (matriz[k] == x) {
// x encontrado no índice k
}

Durante a pesquisa, a variável b contém o comprimento do salto atual. A complexidade


de tempo do algoritmo é O(log n), porque o código no loop while é executado no máximo
duas vezes para cada comprimento de salto.

4.3.2 Encontrando Soluções Ótimas

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

4.3 Pesquisa Binária 49

Fig. 4.16 Uma


programação de
processamento ideal: a
máquina 1 processa quatro
tarefas, a máquina 2 processa
três tarefas e a máquina 3 processa uma tarefa

intx = -1; for (int


b = z; b >= 1; b /= 2) { while (!valid(x+b)) x += b;

} 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.

5.1 Matrizes Dinâmicas

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.

© Springer International Publishing AG, parte da Springer Nature 2017 51


A. Laaksonen, Guide to Competitive Programming, Undergraduate
Topics in Computer Science, https://doi.org/10.1007/978-3-319-72547-5_5
Machine Translated by Google

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]

Então, os elementos podem ser acessados como em um array comum:

cout << v[0] << "\n"; cout << v[1] << // 3


"\n"; cout << v[2] << "\n"; 2
// // //5

Outra maneira de criar um vetor é fornecer uma lista de seus elementos:

vetor<int> v = {2,4,2,5,1};

Também podemos fornecer o número de elementos e seus valores iniciais:

vetor<int> a(8); vetor<int> Tamanho 8, valor inicial 0


b(8,2); // // // tamanho 8, valor inicial 2

O tamanho da função retorna o número de elementos no vetor. Por exemplo,


o código a seguir itera pelo vetor e imprime seus elementos:

for (int i = 0; i < v.size(); i++) {


cout << v[i] << "\n";
}

Uma maneira mais curta de iterar através de um vetor é a seguinte:

for (auto x : v) {
cout << x << "\n";
}

A função back retorna o último elemento de um vetor e a função


pop_back remove o último elemento:

vetor<int> v = {2,4,2,5,1};
cout << v.back() << "\n"; v.pop_back(); // 1

cout << v.back() << "\n"; // 5


Machine Translated by Google

5.1 Matrizes Dinâmicas 53

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.

5.1.2 Iteradores e Intervalos

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:

cout << *v.begin() << "\n";

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:

vetor<int> v = {2,3,3,5,7,8,8,8}; auto a =


lower_bound(v.begin(),v.end(),5); auto b =
upper_bound(v.begin(),v.end(),5); cout << *a << << *b << "\n";
""
// 5 7

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());

5.1.3 Outras Estruturas

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]

As operações de um deque também funcionam em tempo médio O(1). No entanto, os deques


fatores constantes maiores do que vetores, então deques deve ser usado apenas se houver necessidade
para manipular ambas as extremidades da matriz.
C++ também fornece duas estruturas de dados especializadas que são, por padrão, baseadas em um
deque. Uma pilha tem as funções push e pop para inserir e remover elementos
no final da estrutura e a função top que recupera o último elemento:

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

Então, em uma fila, os elementos são inseridos no final da estrutura e removidos


da frente da estrutura. Ambas as funções frontais e traseiras são fornecidas
para acessar o primeiro e o último elemento.
Machine Translated by Google

5.1 Matrizes Dinâmicas 55

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

5.2 Definir Estruturas

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.

5.2.1 Conjuntos e Multiconjuntos

A biblioteca padrão C++ contém duas estruturas de conjunto:

• set é baseado em uma árvore de busca binária balanceada e suas operações funcionam em O(log n)
Tempo.

• unordered_set é baseado em uma tabela de hash e suas operações funcionam, em média,1


em tempo O(1).

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:

cout << s.size() << "\n";


for (auto x : s) {
cout << x << "\n";
}

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

5.2 Definir Estruturas 57

auto primeiro = s.begin();


auto ultimo = s.end(); último--;
cout << *primeiro << " "
<< *último << "\n";

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().

cout << *s.lower_bound(x) << "\n";


cout << *s.upper_bound(x) << "\n";

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

A função erase remove todas as cópias de um valor de um multiset:

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

A função count verifica se existe uma chave em um mapa:

if (m.count("aybabtu")) {
// chave existe
}

Em seguida, o código a seguir imprime todas as chaves e valores em um mapa:

for (auto x : m) {
""
cout << x.primeiro << << x.segundo << "\n";
}

5.2.3 Filas Prioritárias


Uma fila de prioridade é um multiconjunto que suporta a inserção de elementos e, dependendo do
tipo da fila, recuperação e remoção do elemento mínimo ou máximo.
A inserção e a remoção levam tempo O(log n) e a recuperação leva tempo O(1).
Uma fila de prioridade geralmente é baseada em uma estrutura de heap, que é um binário especial
árvore. Enquanto um multiset fornece todas as operações de uma fila prioritária e muito mais,
a vantagem de usar uma fila de prioridade é que ela tem fatores constantes menores. Assim, se
só precisamos encontrar com eficiência os elementos mínimos ou máximos, é uma boa ideia
use uma fila de prioridade em vez de um conjunto ou multiconjunto.
Machine Translated by Google

5.2 Definir Estruturas 59

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

Se quisermos criar uma fila de prioridade que suporte a localização e remoção do


menor elemento, podemos fazê-lo da seguinte forma:

priority_queue<int,vector<int>,maior<int>> q;

5.2.4 Conjuntos Baseados em Políticas

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:

#include <ext/pb_ds/assoc_container.hpp> usando namespace


__gnu_pbds;

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:

typedef tree<int,null_type,less<int>,rb_tree_tag, tree_order_statistics_node_update>


indexed_set;

Então, podemos criar um conjunto da seguinte forma:

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

Então, a função order_of_key retorna a posição de um determinado elemento:

cout << s.order_of_key(7) << "\n"; // 2

Se o elemento não aparece no conjunto, obtemos a posição que o elemento


teria no conjunto:

cout << s.order_of_key(6) << "\n"; cout << // 2


s.order_of_key(8) << "\n"; // 3

Ambas as funções funcionam em tempo logarítmico.

5.3 Experimentos

Nesta seção, apresentamos alguns resultados sobre a eficiência prática do


estruturas de dados apresentadas neste capítulo. Embora as complexidades de tempo sejam uma ótima ferramenta,
eles nem sempre dizem toda a verdade sobre a eficiência, por isso vale a pena
também fazer experimentos com implementações reais e conjuntos de dados.

5.3.1 Conjunto versus classificação

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

Tamanho de entrada conjunto(s) unordered_set (s) Ordenação (s)


n 106 0,65 0,34 0,11

2 · 106 1,50 0,76 0,18

4 · 106 3,38 1,63 0,33

8 · 106 7,57 3,45 0,68

16 · 106 17h35 7,18 1,38

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

Tamanho de entrada mapa(s) unordered_map(s) Array(s)


n 106 0,55 0,23 0,01

2 · 106 1,14 0,39 0,02

4 · 106 2,34 0,73 0,03

8 · 106 4,68 1,46 0,06

16 · 106 9,57 2,83 0,11

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.

5.3.2 Mapa versus Matriz

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

Tamanho de multiconjunto(s) prioridade_fila(s)


entrada n 106 2 · 1,17 0,19

106 2,77 0,41

4 · 106 6.10 1,05

8 · 106 13,96 2,52

16 · 106 30,93 5,95

5.3.3 Fila de prioridade versus multiconjunto

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.

A Tabela 5.3 mostra os resultados do experimento. Acontece que neste problema um


fila de prioridade é cerca de cinco vezes mais rápida do que um multiset.
Machine Translated by Google

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.

6.1 Conceitos Básicos

Nesta seção, passamos pelos conceitos básicos de programação dinâmica no contexto de um


problema de troca de moedas. Primeiro apresentamos um algoritmo guloso para o problema, que
nem sempre produz uma solução ótima. Depois disso, mostramos como o problema pode ser
resolvido de forma eficiente usando programação dinâmica.

6.1.1 Quando o ganancioso falha

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

© Springer International Publishing AG, parte da Springer Nature 2017 63


A. Laaksonen, Guide to Competitive Programming, Undergraduate
Topics in Computer Science, https://doi.org/10.1007/978-3-319-72547-5_6
Machine Translated by Google

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.

6.1.2 Encontrando uma Solução Ótima

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

6.1 Conceitos Básicos 65

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,

resolver(10) = resolver(7) + 1 = resolver(4) + 2 = resolver(0) + 3 = 3.

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
ÿÿ

ÿÿ mincÿcoins solve(x ÿ c) + 1 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):

int solve(int x) { if (x < 0)


return INF; se (x == 0) retornar 0;
int melhor = INF; for (auto c :
moedas) { melhor = min(melhor,
solve(xc)+1);

} 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

bool pronto[N]; valor


inteiro [N];

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:

int solve(int x) { if (x < 0)


return INF; se (x == 0) retornar 0;
if (pronto[x]) valor de retorno [x]; int
melhor = INF; for (auto c : moedas) { melhor =
min(melhor, solve(xc)+1);

}
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:

valor[0] = 0; for (int


x = 1; x <= n; x++) { valor[x] = INF; for (auto c :
moedas) { if (xc >= 0) { valor[x] = min(valor[x],
valor[xc]+1);

}
}
}
Machine Translated by Google

6.1 Conceitos Básicos 67

Na verdade, a maioria dos programadores competitivos prefere essa implementação, porque é


mais curta e tem fatores constantes menores. A partir de agora, também usamos implementações
iterativas em nossos exemplos. Ainda assim, muitas vezes é mais fácil pensar em soluções de
programação dinâmica em termos de funções recursivas.

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];

Então, podemos modificar o algoritmo da seguinte forma:

valor[0] = 0; for (int


x = 1; x <= n; x++) { valor[x] = INF; for (auto c :
moedas) {

if (xc >= 0 && valor[xc]+1 < valor[x]) { valor[x] = valor[xc]+1; primeiro[x]


= c;

}
}
}

Depois disso, o código a seguir imprime as moedas que aparecem em uma solução ótima para a
soma n:

while (n > 0) { cout <<


primeiro[n] << "\n"; n -= primeiro[n];

6.1.3 Soluções de Contagem

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

Novamente, podemos resolver o problema recursivamente. Seja solve(x) o número de maneiras


pelas quais podemos formar a soma x. Por exemplo, ifcoins = {1, 3, 4}, thensolve(5) = 6 e a fórmula
recursiva é
Machine Translated by Google

68 6 Programação Dinâmica

resolver(x) =resolver(x ÿ 1)+


resolver(x ÿ 3)+
solve(x ÿ 4).

Então, a função recursiva geral é a seguinte:

ÿ0 x<0
resolver(x) = 1 x=0
ÿÿ

ÿÿ cÿmoedas solve(x ÿ c) 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];

6.2 Outros Exemplos

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

6.2 Outros Exemplos 69

Fig. 6.1 A subsequência


crescente mais longa desta
matriz é [2, 5, 7, 8]

6.2.1 Subsequência crescente mais longa

A subsequência crescente mais longa em uma matriz de n elementos é uma sequência de


comprimento máximo de elementos da matriz que vai da esquerda para a direita, e cada elemento na
sequência é maior que o elemento anterior. Por exemplo, a Fig. 6.1 mostra a subsequência crescente
mais longa em uma matriz de oito elementos.
Podemos encontrar eficientemente a subsequência crescente mais longa em uma matriz usando
programação dinâmica. Seja length(k) o comprimento da subsequência crescente mais longa que
termina na posição k. Então, se calcularmos todos os valores de length(k) onde 0 ÿ k ÿ n ÿ 1,
descobriremos o comprimento da subsequência crescente mais longa. Os valores da função para
nosso array de exemplo são os seguintes:

comprimento(0) = 1
comprimento(1) = 1
comprimento(2) = 2
comprimento(3) = 1
comprimento(4) = 3
comprimento(5) = 2
comprimento(6) = 4
comprimento(7) = 2

Por exemplo, length(6) = 4, porque a subsequência crescente mais longa que


termina na posição 6 consiste em 4 elementos.
Para calcular um valor de length(k), devemos encontrar uma posição i < k para a qual array[i] <
array[k] e length(i) seja o maior possível. Então sabemos que comprimento(k) = comprimento(i)+1,
porque essa é uma maneira ótima de anexar array[k] a uma subsequência. No entanto, se não houver
tal posição i, então length(k) = 1, o que significa que a subsequência contém apenas array[k].

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.

for (int k = 0; k < n; k++) {


comprimento[k] = 1;
for (int i = 0; i < k; i++) {
if (array[i] < array[k]) { comprimento[k] =
max(comprimento[k],comprimento[i]+1);
}
}
}
Machine Translated by Google

70 6 Programação Dinâmica

Fig. 6.2 Um caminho ideal do


canto superior esquerdo ao canto
inferior direito

Fig. 6.3 Duas maneiras possíveis de


alcançar um quadrado em um caminho

O algoritmo resultante funciona claramente em tempo O(n2) .2

6.2.2 Caminhos em uma Grade

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

soma(y, x) = max(soma(y, x ÿ 1), soma(y ÿ 1, x)) + valor[y][x],

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

6.2 Outros Exemplos 71

int soma[N][N];

e calcule as somas da seguinte forma:

for (int y = 1; y <= n; y++) {


for (int x = 1; x <= n; x++) { soma[y][x] =
max(soma[y][x-1], soma[y-1][x])+valor[y] [x];
}
}

A complexidade de tempo do algoritmo é O(n2).

6.2.3 Problemas da mochila

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

possível(x, k) = possível(x ÿ wk , k ÿ 1) ou possível(x, k ÿ 1),

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.

Fig. 6.4 Construindo somas


usando os pesos [1, 3, 3, 5]
Machine Translated by Google

72 6 Programação Dinâmica

Fig. 6.5 Resolvendo


o problema da mochila
para os pesos [1, 3, 3, 5]
usando 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:

possível[0][0] = verdadeiro; for (int


k = 1; k <= n; k++) {
for (int x = 0; x <= m; x++) {
if (xw[k] >= 0) { possível[x][k] |
= possível[xw[k]][k-1];
}
possível[x][k] |= possível[x][k-1];
}
}

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:

possível[0] = verdadeiro; for


(int k = 1; k <= n; k++) { for (int x = mw[k]; x >= 0;
x--) { possível[x+w[k]] |= possível[x] ;

}
}

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.

6.2.4 De Permutações a Subconjuntos

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

6.2 Outros Exemplos 73

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

passeios({3, 4}) = 2 e last({3, 4}) = 5,

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

Fig. 6.6 Uma maneira de preencher o


4 × 7 grade usando 1 × 2 e
2 × 1 telhas

melhor[0] = {0,0};

Então, podemos preencher o array da seguinte forma:

for (int s = 1; s < (1 << n); s++) {


// valor inicial: n+1 passeios são necessários
melhor[s] = {n+1,0};
for (int p = 0; p < n; p++) {
if (s&(1<<p)) {
opção automática = melhor[s^(1<<p)];
if (opção.segundo+peso[p] <= x) {
// adicionar p para um passeio existente

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.

6.2.5 Contando Ladrilhos

À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

6.2 Outros Exemplos 75

m caracteres do conjunto { , de , , }. Por exemplo, a solução na Fig. 6.6 consiste


quatro linhas que correspondem às seguintes strings:




Suponha que as linhas da grade sejam indexadas de 1 a n. Vamos contar (k, x)


denotar o número de maneiras de construir uma solução para as linhas 1 ... k tal que a string x
corresponde à linha k. É possível usar programação dinâmica aqui, porque o
o estado de uma linha é restringido apenas pelo estado da linha anterior.
Uma solução é válida se a linha 1 não contiver o caractere , linha n não
contém o personagem e , e todas as linhas consecutivas são compatíveis. Por exemplo, o
linhas são compatíveis, enquanto as
linhas e não são compatíveis.
Como uma linha consiste em m caracteres e há quatro opções para cada caractere,
o número de linhas distintas é no máximo 4m. Podemos percorrer o O(4m) possível
estados para cada linha, e para cada estado, existem O(4m) estados possíveis para o
linha, então a complexidade de tempo da solução é O(n42m). Na prática, é uma boa ideia
gire a grade de modo que o lado mais curto tenha comprimento m, porque o fator 42m domina
a complexidade do tempo.
É possível tornar a solução mais eficiente utilizando uma representação mais compacta
para as linhas. Acontece que basta saber quais colunas do
linha anterior contém o quadrado superior de um ladrilho vertical. Assim, podemos representar uma
linha usando apenas os caracteres e onde é ,uma combinação dos caracteres
, ,e . Usando esta representação, existem apenas 2m de linhas distintas, e o tempo
complexidade é O(n22m).
Como nota final, há também uma fórmula direta para calcular o número de ladrilhos:

n/2 m /2
ÿa ÿb
4 · cos2 + cos2 n + 1 m + 1
a=1 b=1

Esta fórmula é muito eficiente, pois calcula o número de ladrilhos em O(nm)


tempo, mas como a resposta é um produto de números reais, um problema ao usar o
fórmula é como armazenar os resultados intermediários com precisão.
Machine Translated by Google

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.

© Springer International Publishing AG, parte da Springer Nature 2017 77


A. Laaksonen, Guide to Competitive Programming, Undergraduate
Topics in Computer Science, https://doi.org/10.1007/978-3-319-72547-5_7
Machine Translated by Google

78 7 Algoritmos Gráficos

7.1 Noções básicas de 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.

7.1.1 Terminologia do Gráfico

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ó

Fig. 7.1 Um gráfico com 1 2


5 nós e 7 arestas
5

3 4

Fig. 7.2 Um caminho do nó 1 2


1 ao nó 5
5

3 4
Machine Translated by Google

7.1 Noções básicas de gráficos 79

Fig. 7.3 Um ciclo de três


1 2
nós

3 4

Fig. 7.4 O gráfico da esquerda é


1 2 1 2
conectado, o gráfico da direita é
não

3 4 3 4

Fig. 7.5 Um gráfico com três


1 2 4 5
componentes
8

3 6 7

Fig. 7.6 Uma árvore


1 2

3 4

de um gráfico. Por exemplo, o grau do nó 2 é 3, porque seus vizinhos são 1, 4,


e 5.
A soma dos graus em um grafo é sempre 2m, onde m é o número de arestas,
porque cada aresta aumenta o grau de exatamente dois nós em um. Por esta razão,
a soma dos graus é sempre par. Um grafo é regular se o grau de cada nó é
uma constante d. Um grafo está completo se o grau de cada nó for n ÿ 1, ou seja, o grafo
contém todas as arestas possíveis entre os nós.
Em um grafo direcionado, o grau de entrada de um nó é o número de arestas que terminam em
o nó, e o grau de saída de um nó é o número de arestas que começam no nó.

Fig. 7.7 Um gráfico direcionado


1 2

3 4

Fig. 7.8 Um gráfico ponderado 5


1 2 7

1 6 5

3 4 3
7
Machine Translated by Google

80 7 Algoritmos Gráficos

Fig. 7.9 Graus de nós 3 3


1 2
1
5

3 4
2 3

Fig. 7.10 Indegrees e 0/3 01/02


graus de saída
1 2
0/1
5

3 4

1/1 3/0

Fig. 7.11 Um gráfico bipartido


1 2 3
e sua coloração

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.

7.1.2 Representação Gráfica

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.

Listas de Adjacências Na representação da lista de adjacências, cada nó x do grafo é


atribuída uma lista de adjacências que consiste em nós para os quais há uma aresta de x.
As listas de adjacência são a maneira mais popular de representar gráficos, e a maioria dos algoritmos
podem ser implementados de forma eficiente usando-os.
Uma maneira conveniente de armazenar as listas de adjacência é declarar uma matriz de vetores como
segue:
Machine Translated by Google

7.1 Noções básicas de gráficos 81

Fig. 7.12 Gráficos de exemplo (uma) (b)


4 4

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.

Para um gráfico ponderado, a estrutura pode ser estendida da seguinte forma:

vetor<par<int,int>> adj[N];

Neste caso, a lista de adjacências do nó a contém o par (b,w) sempre quando


existe uma aresta do nó a ao nó b com peso w. Por exemplo, o gráfico da Fig.
7.12b pode ser armazenado da seguinte forma:

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:

for (auto u : adj[s]) {


// nó de processo você

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];

onde cada valor adj[a][b] indica se o grafo contém uma aresta do nó a ao nó b.


Se a aresta estiver incluída no gráfico, então adj[a][b] = 1 e, caso contrário,
adj[a][b] = 0. Por exemplo, a matriz de adjacência para o gráfico na Fig. 7.12a é

0100
ÿ 0011 ÿ
ÿ ÿ

.
ÿ

0001 ÿ

ÿ 1000 ÿ

Se o gráfico for ponderado, a representação da matriz de adjacência pode ser


estendida para que a matriz contenha o peso da aresta se a aresta existir. Usando
esta representação, o gráfico da Fig. 7.12b corresponde à seguinte matriz:

0500
ÿ 0076 ÿ
ÿ ÿ

0005 ÿ

ÿ 2000 ÿ

A desvantagem da representação da matriz de adjacência é que uma matriz de


adjacência contém n2 elementos e geralmente a maioria deles é zero. Por esta razão, a
representação não pode ser usada se o gráfico for grande.

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});

Se o gráfico for ponderado, a estrutura pode ser estendida da seguinte forma:

vetor<tuple<int,int,int>> arestas;
Machine Translated by Google

7.1 Noções básicas de gráficos 83

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});

7.2 Percurso do Gráfico

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.

7.2.1 Pesquisa em profundidade

A busca em profundidade (DFS) é uma técnica direta de travessia de grafos. O algoritmo


começa em um nó inicial e prossegue para todos os outros nós que são alcançáveis a partir do
nó inicial usando as arestas do grafo.
A busca em profundidade sempre segue um único caminho no gráfico, desde que encontre
novos nós. Depois disso, ele retorna aos nós anteriores e começa a explorar outras partes do
grafo. O algoritmo rastreia os nós visitados, de modo que processa cada nó apenas uma vez.

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.

Implementação A busca em profundidade pode ser convenientemente implementada usando


recursão. A seguinte função dfs inicia uma busca em profundidade em um determinado nó. A
função assume que o gráfico é armazenado como listas de adjacência em um array

vetor<int> adj[N];

e também mantém uma matriz

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

Fig. 7.13 Pesquisa em profundidade 1 2 1 2

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);
}
}

A complexidade de tempo da busca em profundidade é O(n + m) onde n é o número de


nós e m é o número de arestas, porque o algoritmo processa cada nó e
borda uma vez.
Machine Translated by Google

7.2 Percurso do Gráfico 85

Fig. 7.14 Largura primeiro 1 2 3 1 2 3


procurar

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

7.2.2 Pesquisa em largura

A busca em largura (BFS) visita os nós de um grafo em ordem crescente de sua


distância do nó inicial. Assim, podemos calcular a distância do ponto de partida
nó para todos os outros nós usando busca em largura. No entanto, a busca em largura é
mais difícil de implementar do que a pesquisa em profundidade.
A busca em largura percorre os nós um nível após o outro. Primeiro a pesquisa
explora os nós cuja distância do nó inicial é 1, então os nós cujas
distância é 2, e assim por diante. Este processo continua até que todos os nós tenham sido visitados.
A Figura 7.14 mostra como a busca em largura processa um gráfico. Suponha que o
a busca começa no nó 1. Primeiro a busca visita os nós 2 e 4 com distância 1, então
nós 3 e 5 com distância 2 e, finalmente, nó 6 com distância 3.

Implementação A busca em largura é mais difícil de implementar do que a busca em profundidade


pesquisa, porque o algoritmo visita nós em diferentes partes do grafo. Um típico
implementação é baseada em uma fila que contém nós. A cada passo, o próximo nó
na fila será processado.
O código a seguir assume que o gráfico é armazenado como listas de adjacência e principais
contém as seguintes estruturas de dados:

fila<int> q;
bool visitado[N];
int distância[N];

A fila q contém nós a serem processados em ordem crescente de distância.


Novos nós são sempre adicionados ao final da fila e o nó no início
da fila é o próximo nó a ser processado. A matriz visitada indica qual
nós que a busca já visitou, e a distância do array conterá o
distâncias do nó inicial a todos os nós do grafo.
A busca pode ser implementada da seguinte forma, começando no nó x:
Machine Translated by Google

86 7 Algoritmos Gráficos

Fig. 7.15 Verificando a


1 2
conectividade de um gráfico
3

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);

}
}

Como na busca em profundidade, a complexidade de tempo da busca em largura é O(n+m),


onde n é o número de nós e m é o número de arestas.

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.

Verificação de Conectividade Um grafo é conectado se houver um caminho entre quaisquer


dois nós do grafo. Assim, podemos verificar se um grafo está conectado iniciando em um nó
arbitrário e descobrindo se podemos alcançar todos os outros nós.
Por exemplo, na Fig. 7.15, como uma busca em profundidade a partir do nó 1 não visita todos
os nós, podemos concluir que o grafo não está conectado. De maneira semelhante, também
podemos encontrar todos os componentes conectados de um grafo iterando pelos nós e sempre
iniciando uma nova busca em profundidade se o nó atual ainda não pertencer a nenhum
componente.

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

7.2 Percurso do Gráfico 87

Fig. 7.16 Encontrando um ciclo em


1 2
um gráfico

4 5

Fig. 7.17 Um conflito quando


1 2
verificando a bipartidação

4 5

Outra maneira de determinar se um gráfico contém um ciclo é simplesmente calcular o


número de nós e arestas em cada componente. Se um componente contém c nós
e nenhum ciclo, deve conter exatamente c ÿ 1 arestas (portanto, deve ser uma árvore). Se houver
c ou mais arestas, o componente seguramente contém um ciclo.

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.

7.3 Caminhos mais curtos

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.

7.3.1 Algoritmo Bellman-Ford

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.

Implementação A implementação do algoritmo de Bellman-Ford abaixo determina as distâncias


mais curtas de um nó x para todos os nós do grafo. O código assume que o grafo é armazenado
como uma lista de arestas que consiste em tuplas da forma (a, b,w), significando que existe
uma aresta do nó a ao nó b com peso w.
O algoritmo consiste em n ÿ 1 rodadas, e em cada rodada o algoritmo percorre todas as
arestas do grafo e tenta reduzir as distâncias. O algoritmo constrói uma distância de matriz que
conterá as distâncias do nó x a todos os nós. A constante INF denota uma distância infinita.
Machine Translated by Google

7.3 Caminhos mais curtos 89

Fig. 7.19 Um gráfico com


3 2 1
um ciclo negativo
1 2 4

5 3 ÿ7

for (int i = 1; i <= n; i++) {


distância[i] = INF;

} distância[x] = 0; for (int i


= 1; i <= n-1; i++) {
for (auto e : arestas) {
int a, b, w;
empate(a, b, w) = e;
distancia[b] = min(distância[b], distancia[a]+w);
}
}

A complexidade de tempo do algoritmo é O(nm), porque o algoritmo consiste em n ÿ1 rodadas


e itera por todas as m arestas durante uma rodada. Se não houver ciclos negativos no grafo, todas
as distâncias são finais após n ÿ 1 rodadas, porque cada caminho mais curto pode conter no
máximo n ÿ 1 arestas.
Existem várias maneiras de otimizar o algoritmo na prática. Primeiro, as distâncias finais
geralmente podem ser encontradas antes de n ÿ1 rodadas, então podemos simplesmente parar o
algoritmo se nenhuma distância puder ser reduzida durante uma rodada. Uma variante mais
avançada é o algoritmo SPFA (“Shortest Path Faster Algorithm” [8]) que mantém uma fila de nós
que podem ser usados para reduzir as distâncias. Apenas os nós na fila serão processados, o
que geralmente resulta em uma pesquisa mais eficiente.

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.

7.3.2 Algoritmo de Dijkstra

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

Fig. 7.20 Dijkstra's ÿ ÿ ÿ


9
6 6
algoritmo 3 4 3 4
2 2

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ó.

Implementação Uma implementação eficiente do algoritmo de Dijkstra requer que


podemos encontrar eficientemente o nó de distância mínima que não foi processado. Um
estrutura de dados apropriada para isso é uma fila de prioridade que contém os nós restantes
ordenados por suas distâncias. Usando uma fila de prioridade, o próximo nó a ser processado pode
ser recuperado em tempo logarítmico.
Uma implementação típica de livro didático do algoritmo de Dijkstra usa uma fila de prioridade
que possui uma operação para modificar um valor na fila. Isso nos permite ter
uma única instância de cada nó na fila e atualize sua distância quando necessário.
No entanto, as filas de prioridade de biblioteca padrão não fornecem tal operação e
uma implementação um pouco diferente é geralmente usada na programação competitiva.
Machine Translated by Google

7.3 Caminhos mais curtos 91

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:

for (int i = 1; i <= n; i++) {


distância[i] = INF;

} 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]) {

distância[b] = distância[a]+w; q.push({-


distância[b],b});
}
}
}

A complexidade de tempo da implementação acima é O(n + m log m), porque o algoritmo


passa por todos os nós do grafo e adiciona para cada aresta no máximo uma distância da
fila de prioridade.

Arestas Negativas A eficiência do algoritmo de Dijkstra é baseada no fato de que o grafo


não possui arestas negativas. No entanto, se o gráfico tiver uma aresta negativa, o

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

Fig. 7.21 Um gráfico onde


2 2 3
o algoritmo de Dijkstra falha
1 4

6 3 ÿ5

Fig. 7.22 Uma entrada para 7


3 4 2
o algoritmo Floyd-Warshall
2 9 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.

7.3.3 Algoritmo Floyd-Warshall

O algoritmo Floyd-Warshall fornece uma maneira alternativa de abordar o problema de encontrar


caminhos mais curtos. Ao contrário dos outros algoritmos deste capítulo, ele encontra os caminhos
mais curtos entre todos os pares de nós do grafo em uma única execução.
O algoritmo mantém uma matriz que contém as distâncias entre os nós. A
matriz inicial é construída diretamente com base na matriz de adjacência do gráfico.
Em seguida, o algoritmo consiste em rodadas consecutivas e, a cada rodada, ele seleciona
um novo nó que pode atuar como nó intermediário nos caminhos a partir de agora e reduz
distâncias usando esse nó.
Vamos simular o algoritmo Floyd-Warshall para o gráfico da Fig. 7.22. Nisso
caso, a matriz inicial é a seguinte:

0 5 ÿ 9 1 502 ÿ ÿ ÿ
ÿ 207 ÿ 9 ÿ 702 1 ÿ ÿ 2 0 ÿ
ÿ ÿ

ÿ ÿ

ÿ ÿ

ÿ ÿ

ÿ ÿ

Na primeira rodada, o nó 1 é o novo nó intermediário. Existe um novo caminho


entre os nós 2 e 4 com comprimento 14, porque o nó 1 os conecta. Há também um
novo caminho entre os nós 2 e 5 com comprimento 6.

0 5 ÿ 9 1 502 14 6
ÿ ÿ
ÿ ÿ

ÿ
ÿ 207 ÿ ÿ

9 14 702 ÿ

ÿ 16ÿ20 ÿ
Machine Translated by Google

7.3 Caminhos mais curtos 93

Fig. 7.23 Um caminho 7


mais curto do nó 2 ao nó 4 3 4 2

2 9 5

2 1 1
5

Na segunda rodada, o nó 2 é o novo nó intermediário. Isso cria novos caminhos


entre os nós 1 e 3 e entre os nós 3 e 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:

for (int i = 1; i <= n; i++) {


for (int j = 1; j <= n; j++) { if (i == j) dist[i][j] = 0;
senão if (adj[i][j]) dist[i][j] = adj[i][j]; senão
dist[i][j] = INF;

}
}

Depois disso, as distâncias mais curtas podem ser encontradas da seguinte forma:
Machine Translated by Google

94 7 Algoritmos Gráficos

Fig. 7.24 Um gráfico e um 1 2 3


classificação topológica

4 5 6

4 1 5 2 3 6

for (int k = 1; k <= n; k++) {


for (int i = 1; i <= n; i++) {
for (int j = 1; j <= n; j++) {
dist[i][j] = min(dist[i][j],dist[i][k]+dist[k][j]);
}
}
}

A complexidade de tempo do algoritmo é O(n3), porque contém três


loops que passam pelos nós do grafo.
Como a implementação do algoritmo Floyd-Warshall é simples, o algoritmo
pode ser uma boa escolha, mesmo que seja necessário apenas encontrar um único caminho mais curto no
gráfico. No entanto, o algoritmo só pode ser usado quando o gráfico é tão pequeno que um
complexidade de tempo cúbico é rápido o suficiente.

7.4 Gráficos Acíclicos Dirigidos

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.

7.4.1 Classificação Topológica

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

7.4 Gráficos Acíclicos Dirigidos 95

Fig. 7.25 A primeira pesquisa 1 2 3


adiciona os nós 6, 3, 2 e 1 a
a lista

4 5 6

Fig. 7.26 A segunda pesquisa 1 2 3


adiciona os nós 5 e 4 à lista

4 5 6

Fig. 7.27 O final


4 5 1 2 3 6
classificação topológica

A ideia é percorrer os nós do grafo e sempre iniciar uma análise em profundidade.


pesquisa no nó atual se ainda não tiver sido processado. Durante as buscas, o
os nós têm três estados possíveis:

• estado 0: o nó não foi processado (branco)


• estado 1: o nó está em processamento (cinza claro)
• estado 2: o nó foi processado (cinza escuro)

Inicialmente, o estado de cada nó é 0. Quando uma busca atinge um nó pela primeira


tempo, seu estado se torna 1. Finalmente, depois que todas as arestas do nó foram processadas,
seu estado se torna 2.
Se o grafo contiver um ciclo, descobriremos isso durante a busca, pois
mais cedo ou mais tarde chegaremos a um nó cujo estado é 1. Neste caso, não é possível
construir uma ordenação topológica. Se o gráfico não contém um ciclo, podemos construir
uma ordenação topológica adicionando cada nó a uma lista quando seu estado se torna 2. Finalmente,
inverter a lista e obter uma ordenação topológica para o gráfico.
Agora estamos prontos para construir uma ordenação topológica para nosso grafo de exemplo. O primeiro
A busca (Fig. 7.25) prossegue do nó 1 ao nó 6 e adiciona os nós 6, 3, 2 e 1 ao nó
a lista. Então, a segunda busca (Fig. 7.26) prossegue do nó 4 para o nó 5 e adiciona
nós 5 e 4 à lista. A lista invertida final é [4, 5, 1, 2, 3, 6], que corresponde
para uma ordenação topológica (Fig. 7.27). Observe que uma classificação topológica não é única; pode
ser várias ordenações topológicas para um grafo.
A Figura 7.28 mostra um gráfico que não possui uma ordenação topológica. Durante a busca,
chegamos ao nó 2 cujo estado é 1, o que significa que o grafo contém um ciclo. De fato,
existe um ciclo 2 ÿ 3 ÿ 5 ÿ 2.
Machine Translated by Google

96 7 Algoritmos Gráficos

Fig. 7.28 Este grafo não 1 2 3


possui ordenação
topológica, pois contém um ciclo

4 5 6

7.4.2 Programação Dinâmica

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:

• Qual é o caminho mais curto/longo do nó a ao nó b? • Quantos caminhos


diferentes existem? • Qual é o número mínimo/máximo de arestas em um
caminho? • Quais nós aparecem em todos os caminhos possíveis?

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

caminhos(x) = caminhos(s1) + caminhos(s2) +···+ caminhos(sk ),

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,

caminhos(6) = caminhos(2) + caminhos(3),

porque as arestas que terminam no nó 6 são 2 ÿ 6 e 3 ÿ 6. Como paths(2) = 2 e paths(3) = 2, concluímos


que paths(6) = 4. Os caminhos são os seguintes:

•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

7.4 Gráficos Acíclicos Dirigidos 97

Fig. 7.29 Calculando o 122


número de caminhos do nó 1 1 2 3
para o nó 6

4 5 6
114

Fig. 7.30 Um gráfico e seu 3


gráfico de caminhos mais curtos
1 2 8
2
5 4 5

3 4 1
2

3
1 2
2
5 4 5

3 4 1
2

Fig. 7.31 Problema da moeda como um


gráfico acíclico dirigido
0 1 2 3 4 5 6

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.

7.5 Gráficos Sucessores

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

Fig. 7.32 Um gráfico sucessor


9 3 1 2 5

7 6

4 8

Fig. 7.33 Andando em um


4 6 2 5 2 5 2
gráfico sucessor

gráfico consiste em um ou mais componentes, cada um dos quais contém um ciclo e


alguns caminhos que levam a ela.
Os gráficos sucessores às vezes são chamados de gráficos funcionais, porque qualquer sucessor
gráfico corresponde a uma função succ(x) que define as arestas do gráfico. o
o parâmetro x é um nó do gráfico, e a função fornece o sucessor do nó.
Por exemplo, a função

x 123456789
succ(x) 357622163

define o gráfico na Fig. 7.32.

7.5.1 Encontrando Sucessores

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

7.5 Gráficos Sucessores 99

x 123456789
succ(x, 1) 357622163 succ(x,
2) 721255327 succ(x, 4)
327255123 succ(x, 8) 721255327

···

Fig. 7.34 Um ciclo em 6


um gráfico sucessor

1 2 3 4 5

Após o pré-cálculo, qualquer valor de succ(x, k) pode ser calculado apresentando k


como uma soma de potências de dois. Tal representação sempre consiste em partes
O(log k), portanto, calcular um valor de succ(x, k) leva tempo O(log k). Por exemplo, se
queremos calcular o valor de succ(x, 11), usamos a fórmula

succ(x, 11) = succ(succ(succ(x, 8), 2), 1).

Em nosso gráfico de exemplo,

succ(4, 11) = succ(succ(succ(4, 8), 2), 1) = 5.

7.5.2 Detecção de Ciclo

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

100 7 Algoritmos Gráficos

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;

Depois disso, a duração do ciclo pode ser calculada da seguinte forma:

b = succ(a);
comprimento = 1;
while (a != b) { b = succ(b);
comprimento++;

7.6 Árvores geradoras mínimas

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

7.6 Árvores geradoras mínimas 101

Fig. 7.35 Um gráfico e uma 5


3 2 3 9
árvore geradora

1 6 3 4

5 5 6 7
2

5
3 2 3 9

1 3 4

5 6
2

Fig. 7.36 Uma árvore


3 2 3
geradora mínima com peso 20
1 3 4

5 5 6 7
2

Fig. 7.37 Uma árvore 5


2 3 9
geradora máxima com peso 32
1 6 4

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.

7.6.1 Algoritmo de Kruskal

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

102 7 Algoritmos Gráficos

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

Fig. 7.38 Algoritmo 2 3 2 3


de Kruskal
1 4 1 4

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

7.6 Árvores geradoras mínimas 103

Fig. 7.39 Uma árvore


2 3
geradora mínima hipotética
1 4

5 6

Fig. 7.40 Incluindo a aresta 5–6 2 3


reduz o peso da árvore geradora
1 4

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.

Implementação Ao implementar o algoritmo de Kruskal, é conveniente usar a representação de lista


de arestas do grafo. A primeira fase do algoritmo ordena as arestas da lista em tempo O(m log m).
Depois disso, a segunda fase do algoritmo constrói a árvore geradora mínima da seguinte forma:

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.

O problema é como implementar de forma eficiente as mesmas funções e unir.


Uma possibilidade é implementar a função da mesma forma que uma travessia de grafo e verificar se
podemos ir do nó a ao nó b. No entanto, a complexidade de tempo de tal função seria O(n +m) e o
algoritmo resultante seria lento, pois a mesma função será chamada para cada aresta do grafo.

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.

7.6.2 Estrutura Union-Find

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

104 7 Algoritmos Gráficos

Fig. 7.41 Uma estrutura de


4 5 2
união-localização com três conjuntos
1 7
3

6 8

Fig. 7.42 Unindo dois conjuntos


4 2
em um único conjunto
1 7
3

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.

Neste caso, os representantes dos conjuntos são 4, 5 e 2. Podemos encontrar o representante de


qualquer elemento seguindo o caminho que começa no elemento. Por exemplo, o elemento 2 é o
representante do elemento 6, pois seguimos o caminho 6 ÿ 3 ÿ 2. Dois elementos pertencem ao mesmo
conjunto exatamente quando seus representantes são os mesmos.

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.

Implementação A estrutura union-find pode ser convenientemente implementada usando arrays. Na


implementação a seguir, o link do array indica para cada elemento o próximo elemento no caminho, ou o
próprio elemento se for um representante, e o tamanho do array indica para cada representante o tamanho
do conjunto correspondente.
Inicialmente, cada elemento pertence a um conjunto separado:

for (int i = 1; i <= n; i++) link[i] = i; for (int i = 1; i <= n; i++)


tamanho[i] = 1;

A função find retorna o representante para um elemento x. A representatividade


pode ser encontrado seguindo o caminho que começa em x.
Machine Translated by Google

7.6 Árvores geradoras mínimas 105

int encontrar(int x){


while (x != link[x]) x = link[x];
retorna x;
}

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:

bool mesmo(int a, int b) {


return find(a) == find(b);
}

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.

void unite(int a, int b) { a = find(a); b =


encontrar(b); if (tamanho[a] <
tamanho[b]) swap(a,b); tamanho[a]
+= tamanho[b]; ligação[b] = a;

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:

int find(int x) { if (x == link[x])


return x; return link[x] = find(link[x]);

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

106 7 Algoritmos Gráficos

Fig. 7.43 Algoritmo de Prim 2 3 2 3


3

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

7.6.3 Algoritmo de Prim

O algoritmo de Prim é um método alternativo para construir árvores geradoras mínimas.


O algoritmo primeiro adiciona um nó arbitrário à árvore e, em seguida, sempre escolhe uma
aresta de peso mínimo que adiciona um novo nó à árvore. Finalmente, todos os nós foram
adicionados e uma árvore geradora mínima foi encontrada.
O algoritmo de Prim se assemelha ao algoritmo de Dijkstra. A diferença é que o algoritmo
de Dijkstra sempre seleciona um nó cuja distância do nó inicial é mínima, mas o algoritmo de
Prim simplesmente seleciona um nó que pode ser adicionado à árvore usando uma aresta de
peso mínimo.
Como exemplo, a Fig. 7.43 mostra como o algoritmo de Prim constrói um mínimo
spanning tree para nosso gráfico de exemplo, assumindo que o nó inicial é o nó 1.
Assim como o algoritmo de Dijkstra, o algoritmo de Prim pode ser implementado de forma
eficiente usando uma fila de prioridade. A fila de prioridade deve conter todos os nós que podem
ser conectados ao componente atual usando uma única aresta, em ordem crescente dos pesos
das arestas correspondentes.
A complexidade de tempo do algoritmo de Prim é O(n + m log m) que é igual à complexidade
de tempo do algoritmo de Dijkstra. Na prática, os algoritmos de Prim e Kruskal são eficientes, e
a escolha do algoritmo é uma questão de gosto. Ainda assim, a maioria dos programadores
competitivos usa o algoritmo de Kruskal.
Machine Translated by Google

Tópicos de design de algoritmo


8

Este capítulo discute uma seleção de tópicos de projeto de algoritmos.


A Seção 8.1 enfoca algoritmos de bits paralelos que usam operações de bits para processar dados
com eficiência. Normalmente, podemos substituir um loop for por operações de bit, o que pode melhorar
notavelmente o tempo de execução do algoritmo.
A seção 8.2 apresenta a técnica de análise amortizada, que pode ser utilizada para estimar o tempo
necessário para uma sequência de operações em um algoritmo. Usando a técnica, podemos analisar
algoritmos para determinar elementos menores mais próximos e mínimos de janela deslizante.

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.

8.1 Algoritmos de Bit-Paralelo

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.

8.1.1 Distâncias de Hamming

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.

© Springer International Publishing AG, parte da Springer Nature 2017 107


A. Laaksonen, Guide to Competitive Programming, Undergraduate
Topics in Computer Science, https://doi.org/10.1007/978-3-319-72547-5_8
Machine Translated by Google

108 8 Tópicos de Design de Algoritmos

Considere o seguinte problema: Dadas n cadeias de bits, cada uma de comprimento k,


calcule a distância mínima de Hamming entre duas cadeias. Por exemplo, a resposta para
[00111, 01101, 11110] é 2, porque

• 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:

int hamming(string a, string b) { int d = 0; for (int i = 0;


i < k; i++) { if (a[i] != b[i]) d++;

} return d;
}

No entanto, como as strings consistem em bits, podemos otimizar a solução armazenando


as strings como números inteiros e calculando distâncias usando operações de bits. Em
particular, se k ÿ 32, podemos apenas armazenar as strings como valores int e usar a seguinte
função para calcular as distâncias:

int hamming(int a, int b) { return


__builtin_popcount(a^b);
}

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.

8.1.2 Contando Subgrades

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

8.1 Algoritmos de Bit-Paralelo 109

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

Tamanho n Algoritmo(s) original(is) Algoritmo(s) de bits paralelos


5000 0,84 0,06

10.000 3,24 0,18

15.000 7,23 0,37

20.000 12,79 0,63

25.000 19,99 0,97

Fig. 8.1 Esta grade contém


duas subgrades com preto
cantos

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:

int contagem = (linha[a]&linha[b]).count();

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

110 8 Tópicos de Design de Algoritmos

Tabela 8.2 Os tempos de execução dos algoritmos para contar as subgrades

Tamanho da grade n Algoritmo(s) original(is) Algoritmo(s) de bits paralelos


1000 0,65 0,05

1500 2.17 0,14

2000 5,51 0,30

2500 12,67 0,52

3000 26,36 0,87

Fig. 8.2 Um gráfico e seus alcance(1) = 5


1 2
atingir valores. Por exemplo, alcance(2) = 3
alcance(2) = 3, porque 5 alcance(3) = 3
os nós 2, 4 e 5 podem ser alcance(4) = 2
alcançado a partir do nó 2 3 4
alcance(5) = 1

8.1.3 Acessibilidade em Gráficos

Dado um grafo acíclico direcionado de n nós, considere o problema de calcular para


cada nó x um valor alcance(x): o número de nós que podem ser alcançados a partir do nó
x. Por exemplo, a Fig. 8.2 mostra um gráfico e seus valores de alcance.
O problema pode ser resolvido usando programação dinâmica em tempo O(n2) , construindo para cada
nó uma lista de nós que podem ser alcançados a partir dele. Então, para criar
um algoritmo de bits paralelos, representamos cada lista como um conjunto de bits de n bits. Isso nos permite
para calcular eficientemente a união de duas dessas listas usando uma operação ou . Assumindo
esse alcance é uma matriz de estruturas de bitset e o gráfico é armazenado como adjacência
listas em adj, o cálculo para o nó x pode ser feito da seguinte forma:

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

Tamanho do Tempo(s) de execução Uso de memória (MB)


gráfico n 2 · 104 0,06 50

4 · 104 0,17 200

6 · 104 0,32 450

8 · 104 0,51 800

105 0,78 1250


Machine Translated by Google

8.1 Algoritmos de Bit-Paralelo 111

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.

8.2 Análise Amortizada


A estrutura de um algoritmo geralmente nos diz diretamente sua complexidade de tempo, mas às
vezes uma análise direta não fornece uma imagem verdadeira da eficiência. A análise amortizada
pode ser usada para analisar uma sequência de operações cuja complexidade de tempo varia. A
ideia é estimar o tempo total usado para todas essas operações durante o algoritmo, em vez de focar
em operações individuais.

8.2.1 Método de Dois Ponteiros

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

Fig. 8.3 Encontrando um subarray


1 3 2 5 112 3
com soma 8 usando o método
de dois ponteiros

1 3 2 5 112 3

1 3 2 5 112 3
Machine Translated by Google

112 8 Tópicos de Design de Algoritmos

Fig. 8.4 Resolvendo o problema


1 4 5 6 7 9 9 10
2SUM usando o método de dois
ponteiros

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

8.2 Análise Amortizada 113

8.2.2 Elementos menores mais próximos

A análise amortizada é frequentemente usada para estimar o número de operações realizadas em


uma estrutura de dados. As operações podem ser distribuídas de forma desigual, de modo que a
maioria das operações ocorre durante uma determinada fase do algoritmo, mas o número total de
operações é limitado.
Como exemplo, suponha que queremos encontrar para cada elemento do array o elemento menor
mais próximo, ou seja, o primeiro elemento menor que precede o elemento no array.
É possível que não exista tal elemento, caso em que o algoritmo deve relatar isso. Em seguida,
resolveremos o problema com eficiência usando uma estrutura de pilha.
Percorremos o array da esquerda para a direita e mantemos uma pilha de elementos do array.
Em cada posição do array, removemos elementos da pilha até que o elemento superior seja menor
que o elemento atual ou a pilha esteja vazia. Então, informamos que o elemento superior é o elemento
menor mais próximo do elemento atual, ou se a pilha estiver vazia, não existe tal elemento.
Finalmente, adicionamos o elemento atual à pilha.
A Figura 8.5 mostra como o algoritmo processa um array. Primeiro, o elemento 1 é adicionado à
pilha. Como é o primeiro elemento da matriz, claramente não possui um elemento menor mais
próximo. Depois disso, os elementos 3 e 4 são adicionados à pilha. O elemento menor mais próximo
de 4 é 3, e o elemento menor mais próximo de 3 é 1. Então, o próximo elemento 2 é menor que os
dois elementos superiores na pilha, então os elementos 3 e 4 são removidos da pilha. Assim, o
elemento menor de 2 mais próximo é 1. Depois disso, o elemento 2 é adicionado à pilha. O algoritmo
continua assim, até que todo o array tenha sido processado.

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

114 8 Tópicos de Design de Algoritmos

A eficiência do algoritmo depende do número total de operações de pilha.


Se o elemento atual for maior que o elemento do topo da pilha, ele será adicionado
diretamente à pilha, o que é eficiente. No entanto, às vezes a pilha pode conter vários
elementos maiores e leva tempo para removê-los. Ainda assim, cada elemento é
adicionado exatamente uma vez à pilha e removido no máximo uma vez da pilha. Assim,
cada elemento causa operações de pilha O(1), e o algoritmo funciona em tempo O(n) .

8.2.3 Mínimo da janela deslizante

Uma janela deslizante é um subarray de tamanho constante que se move da esquerda


para a direita através de um array. Em cada posição da janela, queremos calcular algumas
informações sobre os elementos dentro da janela. A seguir, focaremos no problema de
manter o mínimo da janela deslizante, o que significa que queremos relatar o menor valor
dentro de cada janela.
Os mínimos da janela deslizante podem ser calculados usando uma ideia semelhante
à que usamos para calcular os elementos menores mais próximos. Desta vez mantemos
uma fila onde cada elemento é maior que o elemento anterior, e o primeiro elemento
sempre corresponde ao elemento mínimo dentro da janela. Após cada movimento de
janela, removemos os elementos do final da fila até que o último elemento da fila seja
menor que o novo elemento da janela ou a fila fique vazia. Também removemos o primeiro
elemento da fila se ele não estiver mais dentro da janela. Por fim, adicionamos o novo
elemento de janela à fila.
A Figura 8.6 mostra como o algoritmo processa um array quando o tamanho da janela
deslizante é 4. Na primeira posição da janela, o menor valor é 1. Então a janela se move
um passo para a direita. O novo elemento 3 é menor que os elementos 4 e 5 na fila, então
os elementos 4 e 5 são removidos da fila e o elemento 3

Fig. 8.6 Encontrando mínimos


de janela deslizante em tempo
214 5 3 412
linear
145

214 5 3 412

1 3

214 5 3 412

34

214 5 3 412

214 5 3 412

12
Machine Translated by Google

8.2 Análise Amortizada 115

é 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) .

8.3 Encontrando Valores Mínimos

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.

8.3.1 Pesquisa Ternária

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].

Fig. 8.7 Uma função e seu


valor mínimo
Machine Translated by Google

116 8 Tópicos de Design de Algoritmos

Fig. 8.8 Procurando o mínimo


usando ternário xG xR
procurar
uma

xR

xG
uma

Fig. 8.9 Exemplo de uma


função convexa: f (x) = x2

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).

8.3.2 Funções Convexas

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

8.3 Encontrando Valores Mínimos 117

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.

8.3.3 Minimizando Somas

Dados n números a1, a2,..., an, considere o problema de encontrar um valor de x que
minimize a soma

|a1 ÿ x|+|a2 ÿ x|+···+|an ÿ x|.

Por exemplo, se os números são [1, 2, 9, 2, 6], a solução ótima é escolher x = 2, que
produz a soma

|1 ÿ 2|+|2 ÿ 2|+|9 ÿ 2|+|2 ÿ 2|+|6 ÿ 2| = 12.

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:

nx2 ÿ 2x(a1 + a2 +···+ an) + (a2 + a2 +···+


1 a22 ) n

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

Neste capítulo, discutimos as estruturas de dados para o processamento eficiente de consultas de


intervalo em arrays. As consultas típicas são consultas de soma de intervalo (calculando a soma de
valores) e consultas de intervalo mínimo (encontrando o valor mínimo).
A Seção 9.1 se concentra em uma situação simples em que os valores do array não são modificados
entre as consultas. Nesse caso, basta pré-processar a matriz para que possamos determinar com
eficiência a resposta para qualquer consulta possível. Primeiro, aprenderemos a processar consultas
de soma usando uma matriz de soma de prefixo e, em seguida, discutiremos o algoritmo de tabela
esparsa para processar consultas mínimas.
A Seção 9.2 apresenta duas estruturas de árvore que nos permitem processar consultas e atualizar
valores de array de forma eficiente. Uma árvore indexada binária suporta consultas de soma e pode
ser vista como uma versão dinâmica de uma matriz de soma de prefixo. Uma árvore de segmentos é
uma estrutura mais versátil que suporta consultas de soma, consultas mínimas e várias outras
consultas. As operações de ambas as estruturas funcionam em tempo logarítmico.

9.1 Consultas em arrays estáticos

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.

© Springer International Publishing AG, parte da Springer Nature 2017 119


A. Laaksonen, Guide to Competitive Programming, Undergraduate
Topics in Computer Science, https://doi.org/10.1007/978-3-319-72547-5_9
Machine Translated by Google

120 9 consultas de intervalo

9.1.1 Soma de consultas

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

somaq (a, b) = somaq (0, b) ÿ somaq (0, a ÿ 1).

Ao definir sumq (0, ÿ1) = 0, a fórmula acima também vale quando a = 0.


Como exemplo, a Fig. 9.2 mostra como calcular a soma dos valores no intervalo [3, 6] usando a matriz de soma
de prefixo. Podemos ver no array original que sumq (3, 6) = 8 + 6 + 1 + 4 = 19. Usando o array de prefixo sum,
precisamos examinar apenas dois valores: sumq (3, 6) = sumq (0, 6) ÿ somaq (0, 2) = 27 ÿ 8 = 19.

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

Fig. 9.1 Uma matriz e sua matriz 01234567


de soma de prefixo 1 3 4 8 6 142
matriz original

01234567
matriz de soma de prefixo 1 4 8 16 22 23 27 29

Fig. 9.2 Calculando uma soma de 01234567


intervalo usando a matriz de soma de 1 3 4 8 6 142
matriz original
prefixo

01234567
matriz de soma de prefixo 1 4 8 16 22 23 27 29

Fig. 9.3 Calculando uma soma


de intervalo bidimensional D C

B UMA
Machine Translated by Google

9.1 Consultas em arrays estáticos 121

Fig. 9.4 Pré-processamento 01234567


para consultas mínimas
matriz original 1 3 4 8 6 142

01234567
tamanho do intervalo 2 1 3 4 6 112 –

01234567
tamanho da faixa 4 1 3 111 –––

01234567
tamanho do intervalo 8 1 –––––––

corresponde a um subarray que começa no canto superior esquerdo do array. A soma do


subarray cinza pode ser calculada usando a fórmula

S(A) ÿ S(B) ÿ S(C) + S(D),

onde S(X) denota a soma dos valores em uma submatriz retangular do canto superior esquerdo
até a posição de X.

9.1.2 Consultas Mínimas

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

minq (a, b) = min(minq (a, a + w ÿ 1), minq (a + w, b)),

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

minq (a, b) = min(minq (a, a + k ÿ 1), minq (b ÿ k + 1, b)).


Machine Translated by Google

122 9 consultas de intervalo

Fig. 9.5 Calculando um intervalo 01234567


mínimo usando dois intervalos 1 3 4 8 6 142
tamanho do intervalo 6
sobrepostos

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.

9.2 Estruturas em Árvore

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.

9.2.1 Árvores Indexadas Binárias

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

tree[k] = sumq (k ÿ p(k) + 1, k),

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

9.2 Estruturas em Árvore 123

Fig. 9.6 Um array e sua árvore 12345678


indexada binária
matriz original 1 3 4 8 6 142

12345678
árvore indexada binária 144 16 6 7 4 29

Fig. 9.7 Intervalos em um binário 12345678


árvore indexada
144 16 6 7 4 29

Fig. 9.8 Processando uma consulta de 12345678


soma de intervalo usando um binário
árvore indexada
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

somaq (1, 7) = somaq (1, 4) + somaq (5, 6) + somaq (7, 7) = 16 + 7 + 4 = 27.

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:

somaq (a, b) = somaq (1, b) ÿ somaq (1, a ÿ 1)

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

124 9 consultas de intervalo

Fig. 9.9 Atualizando um valor em 12345678


uma árvore indexada binária
144 16 6 7 4 29

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,

que isola o bit menos significativo de k.


Primeiro, a seguinte função calcula o valor de sumq (1, k):

int soma(int k) { int s = 0;


while (k >= 1) { s +=
árvore[k]; k -= k&-k;

}
retornar s;
}

Então, a seguinte função aumenta o valor do array na posição k em x (x pode ser


positivo ou negativo):

void add(int k, int x) { while (k <= n)


{ árvore[k] += x; k += k&-k;

}
}

A complexidade de tempo de ambas as funções é O(log n), porque as funções


acessam valores O(log n) na árvore indexada binária, e cada movimento para a próxima
posição leva tempo O(1).
Machine Translated by Google

9.2 Estruturas em Árvore 125

Fig. 9.10 Um array e a árvore de 01234567


segmentos correspondente para 58632726
consultas de soma

39

22 17

13 9 9 8

5863 2 7 2 6

9.2.2 Árvores de Segmento

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

126 9 consultas de intervalo

Fig. 9.11 Processando um intervalo 01234567


consulta de soma usando um segmento
5863 2 7 2 6
árvore

39

22 17

13 9 9 8

5863 2 7 2 6

Fig. 9.12 Atualizando uma matriz 01234567


valor em uma árvore de segmentos
5863 2 7 2 6

39

22 17

13 9 9 8

5863 2 7 2 6

Fig. 9.13 Conteúdo de um 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15


árvore de segmento em uma matriz
39 22 17 13 9 9 8 5 8 6 3 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

9.2 Estruturas em Árvore 127

A função a seguir calcula o valor de sumq (a, b):

int soma(int a, int b) { a += n; b+=n;


ints = 0; while (a <= b) { if (a%2
== 1) s += árvore[a++]; if (b%2
== 0) s += árvore[b--]; a/= 2; b/=
2;

}
retornar s;
}

A função mantém um intervalo na matriz da árvore do segmento. Inicialmente, o intervalo é [a + n, b +


n]. A cada passo, o intervalo é movido um nível mais alto na árvore e os valores dos nós que não pertencem
ao intervalo mais alto são adicionados à soma.
A seguinte função aumenta o valor do array na posição k em x:

void add(int k, int x) {


k += n;
árvore[k] += x;
para (k /= 2; k >= 1; k /= 2) {
árvore[k] = árvore[2*k]+árvore[2*k+1];
}
}

Primeiro, o valor no nível inferior da árvore é atualizado. Após isso, os valores de


todos os nós internos da árvore são atualizados, até que o nó superior da árvore seja alcançado.
Ambas as funções acima funcionam em tempo O(log n), porque uma árvore de segmentos de n
elementos consiste em níveis O(log n) e as funções se movem um nível mais alto na árvore a cada etapa.

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

128 9 consultas de intervalo

Fig. 9.14 Uma árvore de segmentos para


o processamento de consultas de intervalo
1
mínimo

3 1

5 3 1 2

5863 1 7 2 6

Fig. 9.15 Usando busca binária


para encontrar o elemento mínimo 1

3 1

5 3 1 2

5863 1 7 2 6

Fig. 9.16 Comprimindo um array 01234567


usando compressão de índice
matriz original 0 0 5 0030 4

012
matriz compactada 534

9.2.3 Técnicas Adicionais


Compactação de índice Uma limitação nas estruturas de dados que são construídas em
matrizes é que os elementos são indexados usando números inteiros consecutivos.
Dificuldades surgem quando grandes índices são necessários. Por exemplo, se quisermos
usar o índice 109, o array deve conter 109 elementos que exigiriam muita memória.
No entanto, se conhecermos todos os índices necessários durante o algoritmo de
antemão, podemos contornar essa limitação usando a compactação de índice. A ideia é
substituir os índices originais por inteiros consecutivos 0, 1, 2 e assim por diante. Para fazer
isso, definimos uma função c que comprime os índices. A função dá a cada índice original i
um índice comprimido c(i) de tal forma que se aeb são dois índices e a < b , então c(a) < c(b).
Depois de compactar os índices, podemos realizar consultas convenientemente usando-os.

A Figura 9.16 mostra um exemplo simples de compressão de índice. Aqui, apenas os


índices 2, 5 e 7 são realmente usados, e todos os outros valores de matriz são zeros. Os
índices compactados são c(2) = 0, c(5) = 1 e c(7) = 2, o que nos permite criar uma matriz
compactada que contém apenas três elementos.
Machine Translated by Google

9.2 Estruturas em Árvore 129

Fig. 9.17 Uma matriz e sua 01234567


matriz de diferenças
matriz original 3 3 111 5 2 2

01234567

matriz de diferença 3 0 ÿ2 0 0 4 ÿ3 0

Fig. 9.18 Atualizando um intervalo 01234567


de array usando o array de
matriz original 3 6 444 5 2 2
diferenças

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.

10.1 Técnicas Básicas


Uma árvore é um grafo acíclico conectado que consiste em n nós e n ÿ 1 arestas. A remoção de
qualquer aresta de uma árvore a divide em dois componentes, e a adição de qualquer aresta cria um
ciclo. Há sempre um caminho único entre quaisquer dois nós de uma árvore. As folhas de uma árvore
são os nós com apenas um vizinho.
Como exemplo, considere a árvore da Fig. 10.1. Esta árvore consiste em 8 nós e 7 arestas, e
suas folhas são os nós 3, 5, 7 e 8.
Em uma árvore enraizada , um dos nós é apontado como a raiz da árvore e todos os outros nós
são colocados abaixo da raiz. Os vizinhos inferiores de um nó são chamados de filhos, e o vizinho
superior de um nó é chamado de pai. Cada nó tem exatamente um pai, exceto a raiz que não tem
pai. A estrutura de uma raiz

© Springer International Publishing AG, parte da Springer Nature 2017 131


A. Laaksonen, Guide to Competitive Programming, Undergraduate
Topics in Computer Science, https://doi.org/10.1007/978-3-319-72547-5_10
Machine Translated by Google

132 10 Algoritmos de Árvore

Fig. 10.1 Uma árvore que


5 1 4
consiste em 8 nós e 7
arestas

8 6 2 3 7

Fig. 10.2 Uma árvore enraizada


1
onde o nó 1 é a raiz

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.

10.1.1 Travessia da Árvore

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:

void dfs(int s, int e) {


// nó de processo s
for (auto u : adj[s]) {
if (u != e) dfs(u, s);
}
}

A função recebe dois parâmetros: o nó atual se o nó anterior e .


O objetivo do parâmetro e é garantir que a pesquisa se mova apenas para os nós
que ainda não foram visitados.
Machine Translated by Google

10.1 Técnicas Básicas 133

A seguinte chamada de função inicia a pesquisa no nó x:

dfs(x, 0);

Na primeira chamada e = 0, pois não há nó anterior, e é permitido


prossiga para qualquer direção na árvore.

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:

void dfs(int s, int e) {


contagem[s] = 1; for
(auto u : adj[s]) {
se (u == e) continua; dfs(u,s);
contagem[s] += contagem[u];

}
}

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

Por exemplo, na Fig. 10.3, a pré-encomenda é [1, 2, 4, 5, 6, 3, 7], a encomenda é [4, 2, 6, 5, 1, 3,


7], e a pós-ordem é [4, 6, 5, 2, 7, 3, 1].
Se conhecermos a pré-ordem e a ordem de uma árvore, podemos reconstruir sua estrutura exata.
Por exemplo, a única árvore possível com pré-encomenda [1, 2, 4, 5, 6, 3, 7] e

Fig. 10.3 Uma árvore binária


1

2 3

4 5 7

6
Machine Translated by Google

134 10 Algoritmos de Árvore

Fig. 10.4 Uma árvore cujo


5 1 4
diâmetro é 4

6 2 3 7

Fig. 10.5 O nó 1 é o ponto


1
mais alto no caminho do diâmetro

2 3 4

5 6 7

em ordem [4, 2, 6, 5, 1, 3, 7] é mostrado na Fig. 10.3. A pós-ordem e a ordem também determinam


exclusivamente a estrutura de uma árvore. No entanto, se conhecermos apenas a pré-venda e a pós-
venda, pode haver mais de uma árvore que corresponda aos pedidos.

10.1.2 Cálculo de Diâmetros


O diâmetro de uma árvore é o comprimento máximo de um caminho entre dois nós. Por exemplo, a
Fig. 10.4 mostra uma árvore cujo diâmetro é 4 que corresponde a um caminho de comprimento 4
entre os nós 6 e 7. Observe que a árvore também tem outro caminho de comprimento 4 entre os nós
5 e 7.
A seguir, discutiremos dois algoritmos de tempo O(n) para calcular o diâmetro de uma árvore. O
primeiro algoritmo é baseado em programação dinâmica e o segundo algoritmo usa buscas em
profundidade.

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:

• toLeaf(x): o comprimento máximo de um caminho de x para qualquer folha •


maxLength(x): o comprimento máximo de um caminho cujo ponto mais alto é x

Por exemplo, na Fig. 10.5, toLeaf(1) = 2, porque existe um caminho 1 ÿ 2 ÿ 6, e maxLength(1) = 4,


porque existe um caminho 6 ÿ 2 ÿ 1 ÿ 4 ÿ 7. Em neste caso, maxLength(1) é igual ao diâmetro.
Machine Translated by Google

10.1 Técnicas Básicas 135

Fig. 10.6 Nós a, b e c ao


5 1 4
calcular o
diâmetro
b uma c
6 2 3 7

Fig. 10.7 Por que o b x c


algoritmo funciona? 6 2 1 4 7

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.

10.1.3 Todos os Caminhos Mais Longos

Nosso próximo problema é calcular para cada nó da árvore x um valor maxLength(x): o


comprimento máximo de um caminho que começa no nó x. Por exemplo, a Fig. 10.8 mostra
uma árvore e seus valores maxLength. Isso pode ser visto como uma generalização do
problema do diâmetro da árvore, pois o maior desses comprimentos é igual ao diâmetro da
árvore. Além disso, este problema pode ser resolvido em tempo O(n) .
Mais uma vez, um bom ponto de partida é enraizar a árvore arbitrariamente. A primeira parte
do problema é calcular para cada nó x o comprimento máximo de um caminho que desce por
um filho de x. Por exemplo, o caminho mais longo do nó 1 vai
Machine Translated by Google

136 10 Algoritmos de Árvore

Fig. 10.8 Calculando maxComprimento(1) = 2


comprimentos máximos de caminho 3 5 maxComprimento(2) = 2
maxComprimento(3) = 3
1 2
maxComprimento(4) = 3
4 6 maxComprimento(5) = 3
maxComprimento(6) = 3

Fig. 10.9 O caminho mais longo 1


que começa no nó 1

2 3 4

5 6

Fig. 10.10 O caminho mais longo 1


do nó 3 passa por sua
pai

2 3 4

5 6

Fig. 10.11 Neste caso, o 1


segundo caminho mais longo do
pai deve ser escolhido

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:

• maxLength1(x): o comprimento máximo de um caminho de x para uma folha


• maxLength2(x) o comprimento máximo de um caminho de x para uma folha, em outro
direção do que o primeiro caminho
Machine Translated by Google

10.1 Técnicas Básicas 137

Por exemplo, na Fig. 10.11, maxLength1(1) = 2 usando o caminho 1 ÿ 2 ÿ 5 e maxLength2(1)


= 1 usando o caminho 1 ÿ 3.
Finalmente, para determinar o caminho de comprimento máximo do nó x para cima através
de seu pai p, consideramos dois casos: se o caminho que corresponde a maxLength1(p) passa
por x, o comprimento máximo é maxLength2(p) + 1 e, caso contrário, o caminho comprimento
máximo é maxLength1(p) + 1.

10.2 Consultas em Árvore

Nesta seção, focamos no processamento de consultas em árvores enraizadas. Tais consultas


são tipicamente relacionadas a subárvores e caminhos da árvore, e podem ser processadas
em tempo constante ou logarítmico.

10.2.1 Encontrando Antepassados

O k- ésimo ancestral de um nó x em uma árvore enraizada é o nó que alcançaremos se


movermos k níveis para cima de x. Deixe ancestral(x, k) denotar o k- ésimo ancestral de um nó
x (ou 0 se não houver tal ancestral). Por exemplo, na Fig. 10.12, ancestral(2, 1) = 1 e ancestral(8,
2) = 4.
Uma maneira fácil de calcular qualquer valor de ancestral(x, k) é realizar uma sequência de
k movimentos na árvore. No entanto, a complexidade de tempo deste método é O(k), que pode
ser lenta, pois uma árvore de n nós pode ter um caminho de n nós.
Felizmente, podemos calcular eficientemente qualquer valor de ancestral(x, k) em tempo
O(log k) após o pré-processamento. Como na Sec. 7.5.1, a idéia é primeiro pré-calcular todos
os valores de ancestral(x, k) onde k é uma potência de dois. Por exemplo, os valores para a
árvore na Fig. 10.12 são os seguintes:

Fig. 10.12 1
Encontrando ancestrais de nós

4 5 2

3 7 6

8
Machine Translated by Google

138 10 Algoritmos de Árvore

x 12345678
ancestral(x, 1) 01411247
ancestral(x, 2) 00100114
ancestral(x, 4) 00000000
···

Como sabemos que um nó sempre tem menos de n ancestrais, basta calcular


O(log n) valores para cada nó e o pré-processamento leva O(n log n)tempo. Depois disto,
qualquer valor de ancestral(x, k) pode ser calculado em tempo O(log k) representando k
como uma soma onde cada termo é uma potência de dois.

10.2.2 Subárvores e Caminhos

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

Fig. 10.13 Uma árvore e sua matriz transversal de árvore

126347895

Fig. 10.14 A subárvore do nó 4 na matriz de travessia da árvore


Machine Translated by Google

10.2 Consultas em Árvore 139

Fig. 10.15 Uma travessia de árvore 2


array para calcular subárvore 1
somas

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

Fig. 10.16 Calculando o


ID do nó 126347895
soma de valores na subárvore
do nó 4 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

140 10 Algoritmos de Árvore

Fig. 10.17 Uma travessia de árvore 4


array para calcular o caminho 1
somas

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

Fig. 10.18 Aumentando o ID do nó 126347895


valor do nó 4 por 1
tamanho da subárvore 9 21141111
soma do caminho 4 9 12 7 10 15 13 11 6

Fig. 10.19 O mais baixo


1
ancestral comum dos nós 5
e 8 é o nó 2

2 3 4

5 6 7

10.2.3 Ancestrais Comuns Mais Baixos

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

10.2 Consultas em Árvore 141

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

142 10 Algoritmos de Árvore

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.22 Encontrando o ancestral comum mais baixo dos nós 5 e 8

Fig. 10.23 Calculando a 1


distância entre os nós 5 e 8

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).

Calculando distâncias Finalmente, considere o problema de processar consultas onde precisamos


calcular a distância entre os nós a e b (ou seja, o comprimento do caminho entre a e b). Acontece que
esse problema se reduz a encontrar o ancestral comum mais baixo dos nós. Primeiro, enraizamos a
árvore arbitrariamente. Depois disso, a distância dos nós a e b pode ser calculada usando a fórmula

profundidade(a) + profundidade(b) ÿ 2 · profundidade(c),

onde c é o menor ancestral comum de a e b.


Por exemplo, para calcular a distância entre os nós 5 e 8 na Fig. 10.23, primeiro determinamos que
o menor ancestral comum dos nós é o nó 2. Então, como as profundidades dos nós são depth(5) = 3,
depth( 8) = 4, e depth(2) = 2, concluímos que a distância entre os nós 5 e 8 é 3 + 4 ÿ 2 · 2 = 3.

10.2.4 Mesclando Estruturas de Dados

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

10.2 Consultas em Árvore 143

Fig. 10.24 A subárvore do nó 4 2


contém dois nós
1
cujo valor é 3

3 352 3 4 5 1

6 7 8 9
4 4 3 1

Fig. 10.25 Processando 134


consultas usando estruturas de mapa 121

4 3

1 1 11

Fig. 10.26 Mesclando


3 4 3
estruturas de mapa em um nó
1 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

144 10 Algoritmos de Árvore

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.

10.3 Técnicas Avançadas

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.

10.3.1 Decomposição Centróide

Um centroide de uma árvore de n nós é um nó cuja remoção divide a árvore em subárvores


cada um dos quais contém no máximo n/2 nós. Toda árvore tem um centróide, e pode ser
encontrado enraizando a árvore arbitrariamente e sempre movendo para a subárvore que tem o
número máximo de nós, até que o nó atual seja um centroide.
Na técnica de decomposição do centroide , primeiro localizamos um centroide da árvore e
processa todos os caminhos que passam pelo centroide. Depois disso, removemos o centroide
da árvore e processa as subárvores restantes recursivamente. Desde a remoção do
o centroide sempre cria subárvores cujo tamanho é no máximo metade do tamanho do original
árvore, a complexidade de tempo de tal algoritmo é O(n log n), desde que possamos
processar cada subárvore em tempo linear.
Por exemplo, a Fig. 10.27 mostra a primeira etapa de um algoritmo de decomposição do centroide.
Nesta árvore, o nó 5 é o único centroide, então primeiro processamos todos os caminhos que passam

Fig. 10.27 Centroide


1 2 3 4
decomposição

7 8
Machine Translated by Google

10.3 Técnicas Avançadas 145

Fig. 10.28 Decomposição


1
pesada-leve

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).

10.3.2 Decomposição Pesada-Luz

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

146 10 Algoritmos de Árvore

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.

2A notação logk n corresponde a (log n)k .


Machine Translated by Google

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.

11.1 Teoria dos Números

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.

© Springer International Publishing AG, parte da Springer Nature 2017 147


A. Laaksonen, Guide to Competitive Programming, Undergraduate
Topics in Computer Science, https://doi.org/10.1007/978-3-319-72547-5_11
Machine Translated by Google

148 11 Matemática

11.1.1 Primos e Fatores

Um inteiro a é chamado de fator ou divisor de um inteiro b se a divide b. Se a é um


fator de b, escrevemos a | b, caso contrário escrevemos a b. Por exemplo, os fatores
de 24 são 1, 2, 3, 4, 6, 8, 12 e 24.
Um inteiro n > 1 é primo se seus únicos fatores positivos são 1 e n. Por exemplo,
7, 19 e 41 são primos, mas 35 não é primo, porque 5 · 7 = 35. Para cada inteiro n >
1, existe uma única fatoração de primos

= 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.

Seja ÿ (n) o número de fatores de um inteiro n. Por exemplo, ÿ (12) = 6, porque


os fatores de 12 são 1, 2, 3, 4, 6 e 12. Para calcular o valor de ÿ (n), podemos usar
a fórmula
k
ÿ (n) = (ÿi + 1),
i=1

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

onde a última forma é baseada na fórmula de progressão geométrica. Por exemplo,


ÿ (12) = (23 ÿ 1)/(2 ÿ 1) · (32 ÿ 1)/(3 ÿ 1) = 28.

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

11.1 Teoria dos Números 149

bool prime(int n) { if (n < 2)


return false; for (int x = 2; x*x <= n; x+
+) { if (n%x == 0) return false;

} 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.

vetor<int> fatores(int n) { vetor<int> f; for (int x


= 2; x*x <= n; x++) { while (n%x == 0)
{ f.push_back(x); n/= x;

} if (n > 1) f.push_back(n); retornar f;

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

Fig. 11.1 Resultado da peneira de Eratóstenes para n = 20

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

11.1.2 Peneira de Eratóstenes

A peneira de Eratóstenes é um algoritmo de pré-processamento que constrói uma matriz


peneira a partir da qual podemos verificar eficientemente se qualquer inteiro x entre 2...

melhor. Se x é primo, então sieve[x] = 0 e, caso contrário, sieve[x] = 1. Por exemplo, a Fig.
11.1 mostra o conteúdo da peneira para n = 20.
Para construir a matriz, o algoritmo itera pelos inteiros 2 ... n um por
1. Sempre que um novo primo x é encontrado, o algoritmo registra que os números 2x, 3x,
4x, etc., não são primos. O algoritmo pode ser implementado da seguinte forma,
assumindo que cada elemento da peneira é inicialmente zero:

for (int x = 2; x <= n; x++) {


se (peneira[x]) continuar;
for (int u = 2*x; u <= n; u += x) {
peneira[u] = 1;
}
}

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

Na verdade, o algoritmo é mais eficiente, pois o loop interno será executado


somente se o número x for primo. Pode-se mostrar que o tempo de execução do algoritmo
é apenas O(n log log n), uma complexidade muito próxima de O(n). Na prática, a peneira de
Eratóstenes é muito eficiente; A Tabela 11.1 mostra alguns tempos reais de execução.
Existem várias maneiras de estender a peneira de Eratóstenes. Por exemplo, podemos
calcule para cada número k seu menor fator primo (Fig. 11.2). Após isso, podemos
...
fatorar eficientemente qualquer número entre 2 n usando a peneira. (Observe que um número
n tem O(log n) fatores primos.)
Machine Translated by Google

11.1 Teoria dos Números 151

Tabela 11.1 Tempos de execução de


Limite superior n Tempo(s) de execução
a peneira de Eratóstenes
106 2 · 106 4 · 106 0,01

0,03

0,07

8 · 106 0,14

16 · 106 0,28

32 · 106 0,57

64 · 106 1,16

128 · 106 2,35

11.1.3 Algoritmo de Euclides

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,

mdc(30, 12) = mdc(12, 6) = mdc(6, 0) = 6.

O algoritmo pode ser implementado da seguinte forma:

int mdc(int a, int b) {


se (b == 0) retornar a;
return mdc(b, a%b);
}
Machine Translated by Google

152 11 Matemática

Fig. 11.3 Por que o uma

algoritmo de Euclides funciona?


b b a mod b

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).

Algoritmo de Euclides Estendido O algoritmo de Euclides também pode ser estendido


para dar inteiros x e y para os quais

ax + by = mdc(a, b).

Por exemplo, quando a = 30 e b = 12,

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

bx + (a mod b)y = mdc(a, b).

Então, como a mod b = a ÿ a/b · b,

bx + (a ÿ a/b · b)y = mdc(a, b),

que é igual

ay + b(x ÿ a/b · y ) = mdc(a, b).

Assim, podemos escolher x = y e y = x ÿ a/b · y . Usando essa ideia, a função a seguir


retorna uma tupla (x, y, gcd(a, b)) que satisfaz a equação.

tupla<int,int,int> gcd(int a, int b) { if (b == 0) { return {1,0,a};

} else { int
x,y,g;
empate(x,y,g) = mdc(b,a%b); return
{y,x-(a/b)*y,g};
}
}
Machine Translated by Google

11.1 Teoria dos Números 153

Podemos usar a função da seguinte forma:

int x,y,g;
empate(x,y,g) = mdc(30,12);
"" ""
cout << x << << e << <<g<<"\n"; // 1 -2 6

11.1.4 Exponenciação Modular

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:

int modpow(int x, int n, int m) {


se (n == 0) retorna 1%m;
long long u = modpow(x,n/2,m);
u = (u*u)%m;
se (n%2 == 1) u = (u*x)%m;
devolva você;
}

11.1.5 Teorema de Euler

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

Por exemplo, como 10 = 2 · 5, ÿ(10) = 20 · (2 ÿ 1) · 50 · (5 ÿ 1) = 4.


Machine Translated by Google

154 11 Matemática

O teorema de Euler afirma que

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

xn mod m = xn mod (mÿ1) mod m,

que pode ser usado para calcular valores de xn se n for muito grande.

Inversos Multiplicativos Modulares O inverso multiplicativo modular de x em relação a m é um valor


invm(x) tal que

x · invm(x) mod m = 1.

Por exemplo, inv17(6) = 3, porque 6 · 3 mod 17 = 1.


Usando inversos multiplicativos modulares, podemos dividir números módulo m, porque a divisão
por x corresponde à multiplicação por invm(x). Por exemplo, como sabemos que inv17(6) = 3, podemos
calcular o valor de 36/6 mod 17 de outra forma usando a fórmula 36 · 3 mod 17.

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,

que é baseado no teorema de Euler. Em particular, se m é primo, ÿ(m) = m ÿ 1 e a fórmula se torna

invm(x) = xmÿ2.

Por exemplo,

inv17(6) mod 17 = 617ÿ2 mod 17 = 3.

A fórmula acima nos permite calcular eficientemente o multiplicativo modular


inversas usando o algoritmo de exponenciação modular (Seção 11.1.4).
Machine Translated by Google

11.1 Teoria dos Números 155

11.1.6 Resolvendo Equações

Equações Diofantinas Uma equação Diofantina é uma equação da forma

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

39x + 15a = 12.

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,

e multiplicando isso por 4, a equação se torna

39 · 8 + 15 · (ÿ20) = 12,

então uma solução para a equação é x = 8 ey = ÿ20.


Uma solução para uma equação diofantina não é única, porque podemos formar um número
infinito de soluções se conhecermos uma solução. Se um par (x, y) é uma solução, então também
todos os pares
kb ka
x+ ,yÿ
gcd(a, b) gcd(a, b) são

soluções, onde k é qualquer número inteiro.

Teorema do Resto Chinês O Teorema do Resto Chinês resolve um grupo de equações da


forma

x = a1 mod m1 x =
a2 mod m2
···
x = um mod mn
Machine Translated by Google

156 11 Matemática

onde todos os pares de m1, m2,..., mn são primos.


Acontece que uma solução para as equações é

x = a1X1invm1 (X1) + a2X2invm2 (X2) +···+ um Xninvmn (Xn),

Onde
m1m2 ··· mn
Xk = .
mk
Nesta solução, para cada k = 1, 2,..., n,

ak Xk invmk (Xk ) mod mk = ak ,

Porque

Xk invmk (Xk ) mod mk = 1.

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

A combinatória estuda métodos para contar combinações de objetos. Normalmente, o objetivo é


encontrar uma maneira de contar as combinações de forma eficiente sem gerar cada combinação
separadamente. Nesta seção, discutimos uma seleção de técnicas combinatórias que podem ser
aplicadas a um grande número de problemas.
Machine Translated by Google

11.2 Combinatória 157

11.2.1 Coeficientes Binomiais


n
O coeficiente binomial de k k dá o número de maneiras que podemos escolher um
5
elementos de um conjunto de n elementos. Por exemplo, {1, 3
subconjunto = 10, porque o conjunto
2, 3, 4, 5} tem 10 subconjuntos de 3 elementos:

{1, 2, 3},{1, 2, 4},{1, 2, 5},{1, 3, 4},{1, 3, 5},

{1, 4, 5},{2, 3, 4},{2, 3, 5},{2, 4, 5},{3, 4, 5}


Coeficientes binomiais podem ser calculados recursivamente usando a fórmula

n n-1 n-1
= + .
k k-1 k

com os casos básicos


n n
= = 1.
0 n
Para ver por que essa fórmula funciona, considere um elemento arbitrário x no conjunto.
Se decidirmos incluir x em nosso subconjunto, a tarefa restante é escolher k ÿ 1 elementos
de n ÿ 1 elementos. Então, se não incluirmos x em nosso subconjunto, teremos que
escolher k elementos de n ÿ 1 elementos.
Outra maneira de calcular coeficientes binomiais é usar a fórmula

n n!
=
k k!(n ÿ k)!

que se baseia no seguinte raciocínio: Existem n! permutações de n elementos.


Passamos por todas as permutações e sempre incluímos os primeiros k elementos da
por mutação no subconjunto. Como a ordem dos elementos no subconjunto e fora do
subconjunto não importa, o resultado é dividido por k! e (n ÿ k)!
Para coeficientes binomiais,
n n
= ,
k n-k
porque na verdade dividimos um conjunto de n elementos em dois subconjuntos: o primeiro
contém k elementos e o segundo contém n ÿ k elementos.
A soma dos coeficientes binomiais é

n n n n
+ + +···+ = 2n.
0 1 2 n

A razão do nome “coeficiente binomial” pode ser vista quando o


(a + b) é elevado à enésima potência:

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

Fig. 11.4 Primeiras 5 linhas de 1


Triângulo de Pascal
1 1
121
1 3 3 1
1 4 6 4 1
... ... ... ... ...

Fig. 11.5 Cenário 1: Cada


caixa contém no máximo uma bola

Coeficientes binomiais também aparecem no triângulo de Pascal (Fig. 11.4) onde cada valor
é igual à soma dos dois valores acima.

Coeficientes multinomiais O coeficiente multinomial

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.

Caixas e bolas “Caixas e bolas” é um modelo útil, onde contamos as formas de


coloque k bolas em n caixas. Consideremos três cenários:
Cenário 1: Cada caixa pode conter no máximo uma bola. Por exemplo, quando n = 5
e k = 2, existem 10 combinações (Fig. 11.5). Nesse cenário, o número de
n
combinações é diretamente o coeficiente binomial k.
Cenário 2: Uma caixa pode conter várias bolas. Por exemplo, quando n = 5 e
k = 2, existem 15 combinações (Fig. 11.6). Nesse cenário, o processo de colocação
as bolas nas caixas podem ser representadas como uma string que consiste em símbolos “o”
e “ÿ.” Inicialmente, suponha que estamos na caixa mais à esquerda. O símbolo “o”
significa que colocamos uma bola na caixa atual, e o símbolo “ÿ” significa que
mover para a próxima caixa à direita. Agora cada solução é uma string de comprimento k + n ÿ 1
que contém k símbolos “o” e n ÿ 1 símbolos “ÿ”. Por exemplo, o canto superior direito
solução na Fig. 11.6 corresponde à string “ÿ ÿ o ÿ o ÿ.” Assim, podemos
concluir que o número de combinações é k+nÿ1
k.
Cenário 3: Cada caixa pode conter no máximo uma bola e, além disso, não há duas bolas adjacentes.
caixas podem conter uma bola. Por exemplo, quando n = 5 e k = 2, existem 6
combinações (Fig. 11.7). Neste cenário, podemos supor que k bolas são inicialmente
colocados nas caixas e há uma caixa vazia entre cada duas caixas adjacentes. o
Machine Translated by Google

11.2 Combinatória 159

Fig. 11.6 Cenário 2: Uma caixa


pode conter várias bolas

Fig. 11.7 Cenário 3: Cada


caixa contém no máximo uma
bola e duas caixas adjacentes
não contêm uma bola

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 .

11.2.2 Números catalães

O número catalão Cn fornece o número de expressões de parênteses válidas que consistem em


n parênteses esquerdos e n parênteses direitos. Por exemplo, C3 = 5, porque podemos construir
um total de cinco expressões entre parênteses usando três parênteses esquerdos e três
parênteses direitos:

• ()()() • (())()
• ()(()) • ((()))
• (()())

O que é exatamente uma expressão de parênteses válida? As seguintes regras precisamente


defina todas as expressões de parênteses válidas:

• Uma expressão de parênteses vazia é válida. •


Se uma expressão A é válida, então também a expressão (A) é válida. • Se as
expressões A e B são válidas, então também a expressão AB é válida.

Outra maneira de caracterizar expressões de parênteses válidas é que, se escolhermos


qualquer prefixo de tal expressão, ele deve conter pelo menos tantos parênteses esquerdos
quantos parênteses direitos, e a expressão completa deve conter um número igual de parênteses
esquerdo e direito.
Machine Translated by Google

160 11 Matemática

Os números catalães podem ser calculados usando a fórmula

nÿ1

Cn = CiCnÿiÿ1
i=0

onde consideramos as maneiras de dividir a expressão de parênteses em duas partes que


são expressões de parênteses válidas, e a primeira parte é a mais curta possível, mas não
vazia. Para cada i, a primeira parte contém i + 1 pares de parênteses e o número de
expressões válidas é o produto dos seguintes valores:

• Ci : o número de maneiras de construir uma expressão entre parênteses usando os parênteses


da primeira parte, sem contar os parênteses mais externos
• Cnÿiÿ1: o número de maneiras de construir uma expressão de parênteses usando as teses de
parênteses da segunda parte

O caso base é C0 = 1, porque podemos construir um parêntese vazio expres


usando zero pares de parênteses.
Os números catalães também podem ser calculados usando a fórmula

1 2n
Cn = ,
n+1 n

que pode ser explicado da seguinte forma: 2n


Há um total de n maneiras de construir uma expressão de parênteses (não
necessariamente válida) que contém n parênteses à esquerda e n parênteses à direita. Vamos
calcular o número de tais expressões que não são válidas.
Se uma expressão de parênteses não for válida, ela deve conter um prefixo onde o
número de parênteses à direita excede o número de parênteses à esquerda. A ideia é
escolher o prefixo mais curto e inverter cada parêntese no prefixo. Por exemplo, a expressão
())()( tem o prefixo ()), e depois de inverter os parênteses, a expressão se torna )((()(. A
expressão resultante consiste em n + 1 parênteses esquerdo e n ÿ 1 direito). De fato, existe
uma maneira única de produzir qualquer expressão de n + 1 parênteses esquerdo e n ÿ 1
direito da maneira acima.O número de tais expressões é igual ao número de expressões de
2n
parênteses inválidas.
n+1 ,
Assim, o número de expressões de parênteses válidas pode ser calculado usando a fórmula

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

11.2 Combinatória 161

Fig. 11.8 Existem 5 árvores


binárias de 3 nós

Fig. 11.9 Existem 5 árvores


enraizadas de 4 nós

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 é

|A ÿ B ÿ C|=|A|+|B|+|C|ÿ|A ÿ B|ÿ|A ÿ C|ÿ|B ÿ C|+|A ÿ B ÿ C|,

que corresponde à Fig. 11.11.


No caso geral, o tamanho da união X1 ÿ X2 ÿ···ÿ Xn pode ser calculado percorrendo
todas as interseções possíveis que contenham alguns dos conjuntos X1, X2,..., Xn.
Machine Translated by Google

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 |

|A ÿ B ÿ C|=|A|+|B|+|C|ÿ|A ÿ B|ÿ|A ÿ C|ÿ|B ÿ C|+|A ÿ B ÿ C|.

Contando Desarranjos Como exemplo, vamos contar o número de desarranjos de {1,


2,..., n}, ou seja, permutações onde nenhum elemento permanece em seu lugar original.
Por exemplo, quando n = 3, existem dois desarranjos: (2, 3, 1) e (3, 1, 2).
Uma abordagem para resolver o problema é usar inclusão-exclusão. Seja Xk o
conjunto de permutações que contém o elemento k na posição k. Por exemplo, quando
n = 3, os conjuntos são os seguintes:

X1 = {(1, 2, 3), (1, 3, 2)}


X2 = {(1, 2, 3), (3, 2, 1)}
X3 = {(1, 2, 3), (2, 1, 3)}

O número de desarranjos é igual

n!ÿ|X1 ÿ X2 ÿ···ÿ Xn|,

então basta calcular |X1ÿX2ÿ···ÿXn|. Usando inclusão-exclusão, isso reduz o cálculo de


tamanhos de interseções. Além disso, uma interseção de c conjuntos distintos Xk tem
(n ÿ c)! elementos, porque tal interseção consiste em todas as permutações que contêm
c elementos em seus lugares originais. Assim, podemos calcular eficientemente os
tamanhos das interseções. Por exemplo, quando n = 3,

|X1 ÿ X2 ÿ X3|=|X1|+|X2|+|X3|
ÿ|X1 ÿ X2|ÿ|X1 ÿ X3|ÿ|X2 ÿ X3|
+|X1 ÿ X2 ÿ X3| =
2 + 2 + 2 ÿ 1 ÿ 1 ÿ 1 + 1 = 4,

então o número de desarranjos é 3! ÿ 4 = 2.


Acontece que o problema também pode ser resolvido sem usar inclusão-exclusão.
Seja f (n) o número de desarranjos para {1, 2,..., n}. Podemos usar a seguinte fórmula
recursiva:

ÿ0 n=1
f (n) = 1 n=2
ÿÿ

ÿÿ (n ÿ 1)( f (n ÿ 2) + f (n ÿ 1)) n > 2


Machine Translated by Google

11.2 Combinatória 163

Fig. 11.12 Quatro colares


simétricos

A fórmula pode ser provada considerando as possibilidades de como o elemento 1 muda


no desarranjo. Existem n ÿ 1 maneiras de escolher um elemento x que substitua o elemento 1.
Em cada escolha, há duas opções:
Opção 1: Também substituímos o elemento x pelo elemento 1. Depois disso, a tarefa
restante é construir um desarranjo de n ÿ 2 elementos.
Opção 2: Substituimos o elemento x por algum outro elemento diferente de 1. Agora temos
que construir um desarranjo de n ÿ 1 elemento, porque não podemos substituir o elemento x
pelo elemento 1, e todos os outros elementos devem ser alterados.

11.2.4 Lema de Burnside

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

Por exemplo, o número de colares distintos de 4 pérolas e 3 cores é

34 + 3 + 32 + 3
= 24.
4
Machine Translated by Google

164 11 Matemática

Fig. 11.13 Existem 16


1 2 3 4
árvores rotuladas distintas de 4
nós
2 3 4 1 3 4 1 2 4 1 2 3

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

Fig. 11.14 Código Prüfer de


1 2
esta árvore é [4, 4, 2]
5

3 4

11.2.5 Fórmula de Cayley

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

Uma matriz é um conceito matemático que corresponde a uma matriz bidimensional em


programação. Por exemplo,

6 13 7 4
A= ÿ 7082 ÿ

ÿ 9 5 4 18 ÿ
Machine Translated by Google

11.3 Matrizes 165

é 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 ÿ

11.3.1 Operações da Matriz

A soma A + B das matrizes A e B é definida se as matrizes forem do mesmo tamanho.


O resultado é uma matriz onde cada elemento tem a soma dos elementos correspondentes
em A e B. Por exemplo,

614 493 6+41+94+33 10 10 7


+ = = .
392 813 +89+12+3 11 10 5

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

O produto AB das matrizes A e B é definido se A for de tamanho a × n e B for de tamanho


n × b, ou seja, a largura de A é igual à altura de B. O resultado é uma matriz de tamanho a ×
b cuja elementos são calculados usando a fórmula

n
AB[i, j] = (A[i, k] · B[k, j]).
k=1
Machine Translated by Google

166 11 Matemática

Fig. 11.15 Intuição por trás


da multiplicação de matrizes B
Fórmula

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ÿÿ.

Podemos usar diretamente a fórmula acima para calcular o produto C de dois n × n


matrizes A e B em O(n3) tempo1:

for (int i = 1; i <= n; i++) {


for (int j = 1; j <= n; j++) {
for (int k = 1; k <= n; k++) {
C[i][j] += A[i][k]*B[k][j];
}
}
}

A multiplicação de matrizes é associativa, então A(BC) = (AB)C vale, mas não é


comutativo, então geralmente AB = B A.
Uma matriz identidade é uma matriz quadrada onde cada elemento na diagonal é 1
e todos os outros elementos são 0. Por exemplo, a seguinte matriz é a matriz identidade
3 × 3:
100
eu = ÿ 010 ÿ
ÿ 001 ÿ

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

11.3 Matrizes 167

A potência Ak de uma matriz A é definida se A for uma matriz quadrada. A definição é


baseado na multiplicação de matrizes:

Ak = A · A · A ··· A
k vezes

Por exemplo,
3
25 25 25 25 48 165
= · · = .
14 14 14 14 33 114

Além disso, A0 é uma matriz identidade. Por exemplo,

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

11.3.2 Recorrências Lineares

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 (n) = c1 f (n ÿ 1) + c2 f (n ÿ 2) +···+ ck f (n ÿ k),

onde c1, c2,..., ck são coeficientes constantes.


A programação dinâmica pode ser usada para calcular qualquer valor de f (n) em tempo O(kn)
calculando todos os valores de f (0), f (1), . . . , f (n) um após o outro. No entanto, como
veremos a seguir, também podemos calcular o valor de f (n) em tempo O(k3 log n) usando
operações matriciais. Esta é uma melhoria importante se k for pequeno e n for grande.

Números de Fibonacci Um exemplo simples de recorrência linear é a seguinte função que


define os números de Fibonacci:

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

Para calcular eficientemente os números de Fibonacci, representamos a fórmula de Fibonacci


como uma matriz quadrada X de tamanho 2 × 2, para a qual vale o seguinte:

f (i) = f (i + 1) f

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)

Assim, podemos calcular f (n) usando a fórmula

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 ÿ

ÿ ck ckÿ1 ckÿ2 ··· c1 ÿ

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

11.3 Matrizes 169

Fig. 11.16 Gráficos de exemplo (uma) (b)


para operações com matrizes 2 4
1 2 3 1 2 3

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) ÿ

11.3.3 Gráficos e Matrizes


As potências de matrizes de adjacência de grafos têm propriedades interessantes. Quando M
é uma matriz de adjacência de um grafo não ponderado, a matriz Mn dá para cada par de nós
(a, b) o número de caminhos que começam no nó a, terminam no nó b e contêm exatamente
n arestas. É permitido que um nó apareça em um caminho várias vezes.
Como exemplo, considere o gráfico da Fig. 11.16a. A matriz de adjacência deste
gráfico é
000100
ÿ 100011 010000 ÿ
ÿ ÿ

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

agora usamos a fórmula

n
AB[i, j] = min (A[i, k] + B[k, j])
k=1

para multiplicação de matrizes, calculamos mínimos em vez de somas e somas de elementos


em vez de produtos. Após essa modificação, as potências da matriz minimizam os comprimentos
dos caminhos no gráfico. Por exemplo, como

ÿ ÿ 10 11 9 ÿ
ÿ 9 ÿÿÿ 8 ÿ 11 ÿÿÿÿ ÿ 8 ÿÿÿÿ 9 ÿ
ÿ ÿ

ÿ ÿ

M4 = ÿ

ÿ
ÿ

ÿ
,
ÿ ÿ

ÿÿÿÿÿÿ ÿ

ÿ ÿ ÿ 12 13 11 ÿ ÿ

podemos concluir que o comprimento mínimo de um caminho de 4 arestas do nó


2 ao nó 5 é 8. Tal caminho é 2 ÿ 1 ÿ 4 ÿ 2 ÿ 5.

11.3.4 Eliminação Gaussiana

A eliminação gaussiana é uma maneira sistemática de resolver um grupo de equações


lineares. A idéia é representar as equações como uma matriz e então aplicar uma sequência
de operações simples de linhas de matrizes que preservam as informações das equações
e determinam um valor para cada variável.
Machine Translated by Google

11.3 Matrizes 171

Suponha que recebemos um grupo de n equações lineares, cada uma contendo


n variáveis:
a1,1x1 + a1,2x2 +···+ a1,n xn = b1
a2,1x1 + a2,2x2 +···+ a2,n xn = b2
···
an,1x1 + an,2x2 +···+ an,n xn = bn
Representamos as equações como uma matriz da seguinte forma:

a1,1 a1,2 ··· a1,n b1


ÿ a2,1 a2,2 ··· a2,n b2 ÿ
ÿ ÿ

ÿ
.. .. .. .. ÿ

. . ... . . ÿ

ÿ an,1 an,2 ··· an,n bn ÿ

Para resolver as equações, queremos transformar a matriz em

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:

1. Troque os valores de duas linhas.


2. Multiplique cada valor em uma linha por uma constante não negativa.
3. Adicione uma linha, multiplicada por uma constante, a outra linha.

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:

2x1 + 4x2 + x3 = 16x1 +


2x2 + 5x3 = 17 3x1 +
x2 + x3 = 8

Neste caso, a matriz é a seguinte:

2 4 1 16
ÿ 1 2 5 17 ÿ

ÿ 311 8 ÿ
Machine Translated by Google

172 11 Matemática

Processamos a matriz coluna por coluna. A cada passo, garantimos que o


coluna atual tem um na posição correta e todos os outros valores são zeros. Para
1
processar a primeira coluna, primeiro multiplicamos a primeira linha por 2 :

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

11.3 Matrizes 173

tem um número infinito de soluções, porque ambas as equações contêm a mesma


informação. Por outro lado, o grupo

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

Uma probabilidade é um número real entre 0 e 1 que indica a probabilidade de um evento.


Se é certo que um evento acontecerá, sua probabilidade é 1, e se um evento é impossível,
sua probabilidade é 0. A probabilidade de um evento é denotada P(···) onde os três pontos
descrevem o evento. Por exemplo, ao lançar um dado, existem seis resultados possíveis
1, 2,..., 6 e P(“o resultado é par”) = 1/2.
Para calcular a probabilidade de um evento, podemos usar a combinatória ou simular
o processo que gera o evento. Como exemplo, considere um experimento em que tiramos
as três cartas do topo de um baralho de cartas embaralhado.2 Qual é a probabilidade de
que cada carta tenha o mesmo valor (por exemplo, ÿ8, ÿ8 e ÿ8)?
Uma maneira de calcular a probabilidade é usar a fórmula

número de resultados desejados


.
número total de resultados

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.

Outra forma de calcular a probabilidade é simular o processo que gera o evento. Em


nosso exemplo, compramos três cartas, então o processo consiste em três etapas.
Exigimos que cada etapa do processo seja bem-sucedida.
Comprar a primeira carta certamente dá certo, porque qualquer carta serve. A segunda
etapa é bem-sucedida com probabilidade 3/51, pois restam 51 cartas e 3 delas

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.

11.4.1 Trabalhando com Eventos

Uma maneira conveniente de representar eventos é usar conjuntos. Por exemplo, os


resultados possíveis ao lançar um dado são {1, 2, 3, 4, 5, 6}, e qualquer subconjunto deste
conjunto é um evento. O evento “o resultado é par” corresponde ao conjunto {2, 4, 6}.
A cada resultado x é atribuída uma probabilidade p(x), e a probabilidade P(X) de um evento
X pode ser calculada usando a fórmula

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” é

p(2) + p(4) + p(6) = 1/2.

Como os eventos são representados como conjuntos, podemos manipulá-los usando operações
padrão de conjuntos:

• O complemento A¯ significa “A não acontece”. Por exemplo, ao lançar um


dados, o complemento de A = {2, 4, 6} é A¯ = {1, 3, 5}.
• A união A ÿ B significa “A ou B acontecem.” Por exemplo, a união de A = {2, 5}
e B = {4, 5, 6} é A ÿ B = {2, 4, 5, 6}.
• A interseção A ÿ B significa “A e B acontecem”. Por exemplo, o cruzamento
de A = {2, 5} e B = {4, 5, 6} é A ÿ B = {5}.

Complemento A probabilidade de A¯ é calculada usando a fórmula

P(A¯) = 1 ÿ P(A).

Às vezes, podemos resolver um problema facilmente usando complementos, resolvendo o


problema oposto. Por exemplo, a probabilidade de obter pelo menos um seis ao jogar um dado
dez vezes é

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

11.4 Probabilidade 175

União A probabilidade de A ÿ B é calculada usando a fórmula

P(A ÿ B) = P(A) + P(B) ÿ P(A ÿ B).

Por exemplo, considere os eventos A = “o resultado é par” e B = “o resultado é menor que


4” ao lançar um dado. Neste caso, o evento AÿB significa “o resultado é par ou menor que
4”, e sua probabilidade é

P(A ÿ B) = P(A) + P(B) ÿ P(A ÿ B) = 1/2 + 1/2 ÿ 1/6 = 5/6.

Se os eventos A e B são disjuntos, ou seja, Aÿ B é vazio, a probabilidade do evento A ÿ


B é simplesmente

P(A ÿ B) = P(A) + P(B).

Interseção A probabilidade de A ÿ B pode ser calculada usando a fórmula

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|A) = 1/2 · 1/3 = 1/6.

Os eventos A e B são independentes se

P(A|B) = P(A) e P(B|A) = P(B),

o que significa que o fato de B acontecer não altera a probabilidade de A, e vice-versa.


Neste caso, a probabilidade da interseção é

P(A ÿ B) = P(A)P(B).

11.4.2 Variáveis Aleatórias

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 é

X = “a soma dos resultados”.

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

Fig. 11.17 Possíveis maneiras de


colocar duas bolas em quatro caixas

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

onde x passa por todos os valores possíveis de X.


Por exemplo, ao lançar um dado, o resultado esperado é

1/6 · 1 + 1/6 · 2 + 1/6 · 3 + 1/6 · 4 + 1/6 · 5 + 1/6 · 6 = 7/2.

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 é

E[X1 + X2] = E[X1] + E[X2] = 7/2 + 7/2 = 7.

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

11.4 Probabilidade 177

Distribuições A distribuição de uma variável aleatória X mostra a probabilidade de cada valor


que X pode ter. A distribuição consiste em valores P(X = x). Por exemplo, ao lançar dois dados,
a distribuição de sua soma é:

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

Em uma distribuição uniforme, a variável aleatória X tem n valores possíveis a, a + 1,..., b e


a probabilidade de cada valor é 1/ n. Por exemplo, ao lançar um dado, a = 1, b = 6 e P(X = x)
= 1/6 para cada valor x.
O valor esperado de X em uma distribuição uniforme é

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

onde px e (1 ÿ p)nÿx correspondem a tentativas bem-sucedidas e malsucedidas, e


n
x é o número de maneiras que podemos escolher a ordem das tentativas.
Por exemplo, ao jogar um dado dez vezes, a probabilidade de lançar um seis
exatamente três vezes é (1/6)3(5/6)7 10 3 .
O valor esperado de X em uma distribuição binomial é

E[X] = pn.

Em uma distribuição geométrica, a probabilidade de que uma tentativa seja bem-sucedida


é p, e continuamos até que o primeiro sucesso aconteça. A variável aleatória X conta o número
de tentativas necessárias e a probabilidade de um valor x é

xÿ1
P(X = x) = (1 ÿ p) p,

onde (1 ÿ p)xÿ1 corresponde às tentativas mal sucedidas e p corresponde à primeira tentativa


bem sucedida.
Por exemplo, se jogarmos um dado até obtermos um seis, a probabilidade de que o número
de lances é exatamente 4 é (5/6)31/6.

O valor esperado de X em uma distribuição geométrica é

1
E[X] = .
p
Machine Translated by Google

178 11 Matemática

Fig. 11.18 Uma cadeia de Markov 1 1/2 1/2 1/2


para um edifício constituído por
cinco andares 1 2 3 4 5

1/2 1/2 1/2 1

11.4.3 Cadeias de Markov

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

11.4 Probabilidade 179

[1, 0, 0, 0, 0] para a distribuição [0, 1, 0, 0, 0] da seguinte forma:

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ÿ

Calculando as potências da matriz de forma eficiente, podemos calcular a distribuição após m


passos no tempo O(n3 log m).

11.4.4 Algoritmos Aleatórios

À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.

Estatísticas de ordem A estatística de ordem k de um array é o elemento na posição k após


ordenar o array em ordem crescente. É fácil calcular qualquer estatística de ordem em tempo
O(n log n) ordenando primeiro o array, mas é realmente necessário ordenar o array inteiro
apenas para encontrar um elemento?
Acontece que podemos encontrar estatísticas de pedidos usando um algoritmo de Las
Vegas, cujo tempo de execução esperado é O(n). O algoritmo escolhe um elemento
aleatório x da matriz e move os elementos menores que x para a parte esquerda da matriz
e todos os outros elementos para a parte direita da matriz. Isso leva tempo O(n) quando
existem n elementos.
Suponha que a parte esquerda contenha elementos a e que a parte direita contenha elementos b .
Se a = k, o elemento x é a estatística de ordem k. Caso contrário, se a > k, encontramos recursivamente
a estatística de ordem k para a parte esquerda, e se a < k, encontramos recursivamente a estatística
de ordem r para a parte direita onde r = k ÿ a ÿ 1. A busca continua em um maneira semelhante, até
que o elemento desejado seja encontrado.
Quando cada elemento x é escolhido aleatoriamente, o tamanho da matriz cerca de metade em
cada passo, então a complexidade de tempo para encontrar a estatística de ordem k é sobre

n + n/2 + n/4 + n/8 +···= O(n).


Machine Translated by Google

180 11 Matemática

Fig. 11.19 Uma coloração válida 1 2


de um gráfico
5

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.

Verificando a Multiplicação de Matrizes Dadas as matrizes A, B e C, cada uma de tamanho n


× n, nosso próximo problema é verificar se AB = C vale. Claro, podemos resolver o problema
apenas calculando o produto AB no tempo O(n3) , mas pode-se esperar que verificando
a resposta seria mais fácil do que calculá-la do zero.
Acontece que podemos resolver o problema usando um algoritmo de Monte Carlo cuja
complexidade de tempo é apenas O(n2). A ideia é simples: escolhemos um vetor aleatório X de
n elementos e calculamos as matrizes ABX e C X. Se ABX = C X, informamos que AB = C, caso
contrário informamos que AB = C.
A complexidade de tempo do algoritmo é O(n2), pois podemos calcular as matrizes ABX e
CX em tempo O(n2) . Podemos calcular a matriz ABX eficientemente usando a representação
A(B X), de modo que apenas duas multiplicações de matrizes de tamanho n × n e n × 1 são
necessárias.
A desvantagem do algoritmo é que há uma pequena chance de que o algoritmo
comete um erro quando informa que AB = C. Por exemplo,

68 87
= ,
13 32

mas

68 3 87 3
= .
13 6 32 6

No entanto, na prática, a probabilidade de o algoritmo cometer um erro é pequena, e podemos


diminuir a probabilidade verificando o resultado usando vários vetores aleatórios X antes de
relatar que AB = C.

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

11.4 Probabilidade 181

Como se espera que uma coloração aleatória seja válida, encontraremos rapidamente uma coloração válida
na prática.

11.5 Teoria dos Jogos

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.

11.5.1 Estados do jogo

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:

• O jogador A remove 2 varetas (8 varetas restantes). •


O jogador B remove 3 varetas (faltam 5 varetas). • O
jogador A remove 1 stick (4 sticks restantes). • O jogador
B remove 2 varetas (2 varetas restantes). • O jogador A
remove 2 varetas e vence.

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

Fig. 11.20 Classificação dos estados 0 ... 15 no jogo do bastão

Fig. 11.21 Gráfico de estado 1 2


do jogo de divisibilidade
3

4
5
7
8
6

Fig. 11.22 Classificação dos 123456789


estados 1divisibilidade
... 9 no jogo de
LWLWLWLWL

É 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.

11.5.2 Jogo Nim

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

11.5 Teoria dos Jogos 183

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

O novo tamanho do heap deve ser 10 ÿ 3 = 9, então removeremos apenas um stick.


Depois disso, o estado será [9, 12, 5], que é um estado perdedor:

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

Fig. 11.23 Números Grundy de


0 1 0
estados de jogo

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.

11.5.3 Teorema de Sprague-Grundy

O teorema de Sprague-Grundy generaliza a estratégia usada em nim para todos os jogos que
atendem aos seguintes requisitos:

• Há dois jogadores que se movem alternadamente. • O


jogo consiste em estados, e os movimentos possíveis em um estado não dependem de
de quem é a vez.
• O jogo termina quando um jogador não consegue fazer uma
jogada. • O jogo certamente termina mais cedo ou mais tarde. •
Os jogadores têm informações completas sobre os estados e movimentos permitidos, e não há
aleatoriedade no jogo.

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

mex({g1, g2,..., gn}),

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

11.5 Teoria dos Jogos 185

Fig. 11.24 Possíveis movimentos


no primeiro turno

*
*
****@

Fig. 11.25 Números Grundy de


01 01
estados de jogo
012
02 10
3041
04132

Considere um estado cujo número de Grundy é x. Podemos pensar que corresponde a um


heap nim que possui x sticks. Em particular, se x > 0, podemos passar para estados cujos
números Grundy são 0, 1,..., x ÿ 1, o que simula a remoção de gravetos de um heap nim. Há
uma diferença, embora possa ser possível mover para um estado cujo número Grundy seja maior
que x e “adicionar” a um heap. No entanto, o oponente sempre pode cancelar qualquer
movimento, então isso não muda a estratégia.
Como exemplo, considere um jogo em que os jogadores movem uma figura em um labirinto.
Cada quadrado do labirinto é chão ou parede. Em cada turno, o jogador deve mover a figura
alguns passos para a esquerda ou para cima. O vencedor do jogo é o jogador que faz o último
movimento. A Figura 11.24 mostra uma possível configuração inicial do jogo, onde @ denota a
figura e * denota um quadrado onde ela pode se mover. Os estados do jogo são todos os
quadrados do labirinto. A Figura 11.25 mostra os números Grundy dos estados nesta configuração.

De acordo com o teorema de Sprague-Grundy, cada estado do jogo do labirinto corresponde


a uma pilha no jogo nim. Por exemplo, o número Grundy do quadrado inferior direito é 2, então é
um estado vencedor. Podemos chegar a um estado perdedor e vencer o jogo movendo quatro
degraus para a esquerda ou dois degraus para cima.

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

Fig. 11.26 Um jogo que


consiste em três subjogos

@ @ @

Fig. 11.27 Números Grundy 01 01 0123 01234


em subjogos
012 10 01 1 0
02 10 2 012 2 1
3041 3 120 3 2
04132 40253 40123

O jogo de Grundy Às vezes, um movimento em um jogo divide o jogo em subjogos que


são independentes entre si. Neste caso, o número Grundy de um estado do jogo é

mex({g1, g2,..., gn}),

onde existem n movimentos possíveis e

gk = ak,1 ÿ ak,2 ÿ ... ÿ ak,m,

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

g(8) = mex({g(1) ÿ g(7), g(2) ÿ g(6), g(3) ÿ g(5)}).

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

11.5 Teoria dos Jogos 187

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

Algoritmos de Gráficos Avançados


12

Este capítulo discute uma seleção de algoritmos de grafos avançados.


A Seção 12.1 apresenta um algoritmo para encontrar as componentes fortemente conectadas
de um grafo. Depois disso, aprenderemos como resolver com eficiência o problema 2SAT usando
o algoritmo.
A Seção 12.2 enfoca os caminhos eulerianos e hamiltonianos. Um caminho euleriano passa por
cada aresta do grafo exatamente uma vez, e um caminho hamiltoniano visita cada nó exatamente
uma vez. Embora os conceitos pareçam bastante semelhantes à primeira vista, os problemas
computacionais relacionados a eles são muito diferentes.
A Seção 12.3 mostra primeiro como podemos determinar o fluxo máximo de uma fonte para um
coletor em um grafo. Depois disso, veremos como reduzir vários outros problemas de grafos ao
problema de fluxo máximo.
A Seção 12.4 discute as propriedades da busca em profundidade e problemas relacionados a
grafos biconectados.

12.1 Conectividade Forte


Um grafo direcionado é chamado fortemente conectado se houver um caminho de qualquer nó
para todos os outros nós do grafo. Por exemplo, o gráfico da esquerda na Fig. 12.1 é fortemente
conectado enquanto o gráfico da direita não é. O grafo da direita não está fortemente conectado,
porque, por exemplo, não há caminho do nó 2 para o nó 1.
Um grafo direcionado sempre pode ser dividido em componentes fortemente conexos. Cada um
desses componentes contém um conjunto máximo de nós, de modo que existe um caminho de
qualquer nó para todos os outros nós, e os componentes formam um grafo de componentes
acíclicos que representa a estrutura profunda do grafo original. Por exemplo, a Fig. 12.2 mostra um
gráfico, seus componentes fortemente conectados e o gráfico de componentes correspondente.
Os componentes são A = {1, 2}, B = {3, 6, 7}, C = {4} e D = {5}.

© Springer International Publishing AG, parte da Springer Nature 2017 189


A. Laaksonen, Guide to Competitive Programming, Undergraduate
Topics in Computer Science, https://doi.org/10.1007/978-3-319-72547-5_12
Machine Translated by Google

190 12 Algoritmos Gráficos Avançados

Fig. 12.1 O gráfico da esquerda é


1 2 1 2
fortemente conectado, o direito
gráfico não é

3 4 3 4

Fig. 12.2 Um gráfico, seu


1 2 3
fortemente conectado
componentes e o 7
gráfico de componentes
4 5 6

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.

12.1.1 Algoritmo de Kosaraju

O algoritmo de Kosaraju é um método eficiente para encontrar os componentes fortemente conectados de


um grafo. O algoritmo realiza duas buscas em profundidade: a primeira busca
constrói uma lista de nós de acordo com a estrutura do gráfico, e o segundo
pesquisa forma os componentes fortemente conectados.
A primeira fase do algoritmo de Kosaraju constrói uma lista de nós na ordem em
qual a pesquisa em profundidade os processa. O algoritmo percorre os nós e
inicia uma busca em profundidade em cada nó não processado. Cada nó será adicionado ao
lista depois de processada.
Por exemplo, a Fig. 12.3 mostra a ordem de processamento dos nós em nosso exemplo
gráfico. A notação x/y significa que o processamento do nó começou no tempo x e terminou
no tempo y. A lista resultante é [4, 5, 2, 1, 6, 7, 3]
A segunda fase do algoritmo de Kosaraju forma os componentes fortemente conectados. Primeiro, o
algoritmo inverte todas as arestas do grafo. Isso garante que
durante a segunda busca, sempre encontraremos componentes válidos fortemente conectados.
A Figura 12.4 mostra o gráfico em nosso exemplo depois de inverter as arestas.
Machine Translated by Google

12.1 Conectividade Forte 191

1/8 2/7 14/09


1 2 3
13/10
7

4 5 6

4/5 3/6 12/11

Fig. 12.3 A ordem de processamento dos nós

Fig. 12.4 Um gráfico com


1 2 3
bordas invertidas
7

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

Fig. 12.5 Construindo os componentes fortemente conectados

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

192 12 Algoritmos Gráficos Avançados

12.1.2 Problema 2SAT

No problema 2SAT, nos é dada uma fórmula lógica

(a1 ÿ b1) ÿ (a2 ÿ b2) ÿ···ÿ (am ÿ bm),

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)

é verdadeiro quando as variáveis são atribuídas da seguinte forma:

ÿ x1 = falso
x2 = falso
ÿÿÿÿ

x3 = verdadeiro

ÿÿÿÿ x4 = verdadeiro

No entanto, a fórmula

L2 = (x1 ÿ x2) ÿ (x1 ÿ ¬x2) ÿ (¬x1 ÿ x3) ÿ (¬x1 ÿ ¬x3)

é 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

Fig. 12.6 O gráfico de


¬x3 x2 ¬x1 x4
implicação de L1

¬x4 x1 ¬x2 x3
Machine Translated by Google

12.1 Conectividade Forte 193

Fig. 12.7 O gráfico de


¬x1
implicação de L2

x3 x2 ¬x2 ¬x3

x1

Fig. 12.8 O gráfico de UMA B C D


componentes de L1

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.

12.2 Caminhos Completos

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

194 12 Algoritmos Gráficos Avançados

Fig. 12.9 Um gráfico e um 1.


1 2 1 2 5.
caminho Euleriano
3 2. 4. 3

4 5 4 5 6.
3.

Fig. 12.10 Um gráfico e um 6.


circuito euleriano 1 2 1 2 5.

3 1. 3. 3
2.
4 5 4 5 4.

12.2.1 Caminhos Eulerianos

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

• o grau de cada nó é par, ou • o grau de


exatamente dois nós é ímpar e o grau de todos os outros nós é par.

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

• em cada nó, o grau de entrada é igual ao grau de saída, ou • em


um nó, o grau de entrada é um maior que o grau de saída, em outro nó, o grau de saída é um grau
maior que o grau de entrada, e em todos os outros nós, o grau de entrada é igual ao grau de saída .

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

12.2 Caminhos Completos 195

Fig. 12.11 Um grafo direcionado 5.


e um caminho euleriano 1 2 1 2 1.

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.

Construção O algoritmo de Hierholzer é um método eficiente para construir um circuito euleriano


para um grafo. O algoritmo consiste em várias rodadas, cada uma das quais adiciona novas arestas
ao circuito. É claro que assumimos que o grafo contém um circuito euleriano; caso contrário, o
algoritmo de Hierholzer não pode encontrá-lo.
O algoritmo começa com um circuito vazio que contém apenas um único nó e, em seguida,
estende o circuito passo a passo adicionando subcircuitos a ele. O processo continua até que todas
as arestas tenham sido adicionadas ao circuito. O circuito é estendido encontrando um nó x que
pertence ao circuito, mas tem uma borda de saída que não está incluída no circuito. Então, um novo
caminho do nó x que contém apenas arestas que ainda não estão no circuito é construído. Mais cedo
ou mais tarde, o caminho retornará ao nó x, que cria um subcircuito.

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.

12.2.2 Caminhos Hamiltonianos

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.

Problemas relacionados a caminhos hamiltonianos são NP-difíceis: ninguém conhece uma


maneira geral de verificar eficientemente se um grafo tem um caminho ou circuito hamiltoniano. É
claro que, em alguns casos especiais, podemos ter certeza de que um grafo contém um caminho
hamiltoniano. Por exemplo, se o grafo é completo, ou seja, existe uma aresta entre todos os pares
de nós, ele certamente contém um caminho hamiltoniano.
Uma maneira simples de procurar um caminho hamiltoniano é usar um algoritmo de retrocesso
que percorre todas as maneiras possíveis de construir um caminho. A complexidade de tempo de tal
Machine Translated by Google

196 12 Algoritmos Gráficos Avançados

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

Fig. 12.12 Algoritmo de Hierholzer

1.
1 2 1 2 4. 1 2 2.

3 1. 3. 3 5. 3

4 5 4 5 4 5 3.
2. 4.

Fig. 12.13 Um gráfico, um caminho hamiltoniano e um circuito hamiltoniano

um algoritmo é pelo menos O(n!), porque existem n! diferentes maneiras de escolher o


ordem de n nós. Então, usando programação dinâmica, podemos criar um sistema mais eficiente
O(2nn2) solução de tempo, que determina para cada subconjunto de nós S e cada nó
x ÿ S se existe um caminho que visita todos os nós de S exatamente uma vez e termina no nó x.

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

12.2 Caminhos Completos 197

Fig. 12.14 Construindo uma


01
sequência De Bruijn a partir de um 1 1
caminho Euleriano

00 1 0 11
0 1

0 0
10

Fig. 12.15 Um passeio de cavalo


aberto em um tabuleiro 5 × 5
1 4 11 16 25
12 17 2 5 10
3 20 7 24 15
18 13 22 9 6
21 8 19 14 23

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.

Passeios do Cavalo Um passeio do cavalo é uma sequência de movimentos de um cavalo em um


tabuleiro de xadrez n × n seguindo as regras do xadrez, de modo que o cavalo visite cada casa
exatamente uma vez. Um passeio de cavalo é chamado fechado se o cavalo finalmente retornar à
casa inicial e, caso contrário, é chamado de aberto. Por exemplo, a Fig. 12.15 mostra um passeio de
cavalo aberto em um tabuleiro 5 × 5.
Um passeio de cavalo corresponde a um caminho hamiltoniano em um grafo cujos nós
representam as casas do tabuleiro, e dois nós são conectados por uma aresta se um cavalo puder se
mover entre as casas de acordo com as regras do xadrez. Uma maneira natural de construir o passeio
de um cavaleiro é usar o retrocesso. Como há um grande número de movimentos possíveis, a busca
pode ser mais eficiente usando heurísticas que tentam guiar o cavalo para que um passeio completo
seja encontrado rapidamente.
A regra de Warnsdorf é uma heurística simples e eficaz para encontrar o passeio de um cavaleiro.
Usando a regra, é possível construir um passeio com eficiência mesmo em uma prancha grande. A
ideia é sempre mover o cavalo de modo que ele termine em uma casa onde o número de movimentos
de acompanhamento possíveis seja o menor possível. Por exemplo, na Fig. 12.16, existem cinco casas
possíveis para as quais o cavalo pode se mover (casas a ... e). Nesta situação, a regra de Warnsdorf
move o cavalo para a casa a, pois após esta escolha, há apenas um movimento possível. As outras
opções moveriam o cavalo para casas onde haveria três movimentos disponíveis.
Machine Translated by Google

198 12 Algoritmos Gráficos Avançados

Fig. 12.16 Usando


1 uma

a regra de Warndorf para construir


passeio de um cavaleiro 2

b e

c d

12.3 Fluxos Máximos

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.

Fig. 12.17 Um gráfico com 6


fonte 1 e sumidouro 6 5 2 3 5

1 3 8 6

4 4 5 2
1

Fig. 12.18 A vazão máxima do 6/6


3/5 2 3 5/5
gráfico é 7
1 3/3 1/8 6

4/4 4 5 2/2
1/1
Machine Translated by Google

12.3 Fluxos Máximos 199

Fig. 12.19 O corte mínimo 6


5 2 3 5
do gráfico é 7
1 3 8 6

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.

12.3.1 Algoritmo Ford-Fulkerson

O algoritmo de Ford-Fulkerson encontra o fluxo máximo em um grafo. O algoritmo começa


com um fluxo vazio e, a cada etapa, encontra um caminho da origem ao sorvedouro que
gera mais fluxo. Finalmente, quando o algoritmo não pode mais aumentar o fluxo, o fluxo
máximo foi encontrado.
O algoritmo usa uma representação gráfica especial onde cada aresta original tem uma
aresta reversa em outra direção. O peso de cada aresta indica quanto mais fluxo poderíamos
passar por ela. No início do algoritmo, o peso de cada aresta original é igual à capacidade
da aresta e o peso de cada aresta reversa é zero. A Figura 12.20 mostra a nova
representação para nosso gráfico de exemplo.
O algoritmo de Ford-Fulkerson consiste em várias rodadas. Em cada rodada, o algoritmo
encontra um caminho da origem ao sorvedouro de modo que cada aresta do caminho tenha
um peso positivo. Se houver mais de um caminho possível disponível, qualquer um deles
pode ser escolhido. Depois de escolher o caminho, o fluxo aumenta em x unidades, onde x
é o menor peso de aresta no caminho. Além disso, o peso de cada aresta no caminho
diminui em x, e o peso de cada aresta reversa aumenta em x.
A ideia é que aumentar o fluxo diminui a quantidade de fluxo que pode passar pelas
bordas no futuro. Por outro lado, é possível cancelar o fluxo mais tarde usando as bordas
inversas se for benéfico rotear o fluxo de outra maneira. O algoritmo aumenta o fluxo desde
que haja um caminho da fonte ao sorvedouro através de arestas de peso positivo. Então,
se não houver tais caminhos, o algoritmo termina e o fluxo máximo é encontrado.

A Figura 12.21 mostra como o algoritmo de Ford-Fulkerson encontra o fluxo máximo


para nosso gráfico de exemplo. Neste caso, há quatro rodadas. Na primeira rodada, o
Machine Translated by Google

200 12 Algoritmos Gráficos Avançados

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

Fig. 12.21 O algoritmo de Ford-Fulkerson

algoritmo escolhe o caminho 1 ÿ 2 ÿ 3 ÿ 5 ÿ 6. O peso mínimo da aresta em


este caminho é 2, então o fluxo aumenta em 2 unidades. Em seguida, o algoritmo escolhe três
outros caminhos que aumentam o fluxo em 3, 1 e 1 unidades. Depois disso, não há caminho com
arestas de peso positivo, então o fluxo máximo é 2 + 3 + 1 + 1 = 7.

Encontrando caminhos O algoritmo de Ford-Fulkerson não especifica como devemos


escolher os caminhos que aumentam o fluxo. Em qualquer caso, o algoritmo terminará
mais cedo ou mais tarde e encontrar corretamente o fluxo máximo. No entanto, a eficiência do
algoritmo depende de como os caminhos são escolhidos. Uma maneira simples de encontrar caminhos é
use a pesquisa em profundidade. Normalmente isso funciona bem, mas na pior das hipóteses, cada caminho apenas
aumenta o fluxo em uma unidade, e o algoritmo é lento. Felizmente, podemos evitar
esta situação usando uma das seguintes técnicas:
O algoritmo de Edmonds-Karp escolhe cada caminho de modo que o número de arestas
o caminho é o menor possível. Isso pode ser feito usando a pesquisa em largura
Machine Translated by Google

12.3 Fluxos Máximos 201

Fig. 12.22 Os nós 1, 2 e 4 0


2 3
pertencem ao conjunto A 2 6 0

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

202 12 Algoritmos Gráficos Avançados

Fig. 12.23 Roteando o fluxo de


A para B

UMA B

Fig. 12.24 Dois


2 3
caminhos disjuntos de
arestas do nó 1 ao nó 6 1 6

4 5

2 3

1 6

4 5

Fig. 12.25 Um caminho


2 3
disjunto de nós do nó 1 ao nó 6
1 6

4 5

12.3.2 Caminhos Disjuntos

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.

Caminhos de arestas disjuntos Primeiro, focamos no problema de encontrar o número máximo


de caminhos de arestas disjuntos da origem ao sorvedouro. Isso significa que cada aresta
pode aparecer em no máximo um caminho. Por exemplo, na Fig. 12.24, o número máximo de
caminhos disjuntos de arestas é 2 (1 ÿ 2 ÿ 4 ÿ 3 ÿ 6 e 1 ÿ 4 ÿ 5 ÿ 6).
Acontece que o número máximo de caminhos disjuntos de arestas sempre é igual ao fluxo
máximo do grafo onde a capacidade de cada aresta é um. Depois que o fluxo máximo foi
construído, os caminhos disjuntos de arestas podem ser encontrados vorazmente seguindo
os caminhos da origem ao sorvedouro.

Caminhos de nós disjuntos Em seguida, considere o problema de encontrar o número máximo


de caminhos de nós disjuntos da origem ao sorvedouro. Nesse caso, todos os nós, exceto a
fonte e o sumidouro, podem aparecer em no máximo um caminho, o que pode reduzir o
número máximo de caminhos disjuntos. De fato, em nosso grafo de exemplo, o número
máximo de caminhos de nós disjuntos é 1 (Fig. 12.25).
Podemos reduzir também este problema ao problema de fluxo máximo. Como cada nó
pode aparecer em no máximo um caminho, temos que limitar o fluxo que passa pelos nós.
Uma construção padrão para isso é dividir cada nó em dois nós, de modo que
Machine Translated by Google

12.3 Fluxos Máximos 203

Fig. 12.26 Uma construção


2 2 3 3
que limita o fluxo através
os nós
1 6

4 4 5 5

Fig. 12.27 Correspondência


1 5
máxima

2 6

3 7

4 8

o primeiro nó tem as arestas de entrada do nó original, o segundo nó tem as arestas de saída do nó


original e há uma nova aresta do primeiro nó para o segundo nó. A Figura 12.26 mostra o gráfico
resultante e sua vazão máxima em nosso exemplo.

12.3.3 Máximo de Correspondências

Um emparelhamento máximo de um grafo é um conjunto de pares de nós de tamanho máximo onde


cada par está conectado com uma aresta e cada nó pertence a no máximo um par. Embora resolver
o problema de correspondência máxima em um grafo geral exija algoritmos complicados, o problema
é muito mais fácil de resolver se assumirmos que o grafo é bipartido. Neste caso podemos reduzir o
problema ao problema de fluxo máximo.
Os nós de um grafo bipartido podem sempre ser divididos em dois grupos, de modo que todas as
arestas do grafo vão do grupo da esquerda para o grupo da direita. Por exemplo, a Fig. 12.27 mostra
um emparelhamento máximo de um grafo bipartido cujo grupo esquerdo é {1, 2, 3, 4} e o grupo direito
é {5, 6, 7, 8}.
Podemos reduzir o problema de emparelhamento máximo bipartido ao problema de fluxo máximo
adicionando dois novos nós ao grafo: uma fonte e um coletor. Também adicionamos arestas da fonte
a cada nó esquerdo e de cada nó direito ao sorvedouro. Depois disso, o tamanho de um fluxo máximo
no gráfico resultante é igual ao tamanho de um casamento máximo no gráfico original. Por exemplo, a
Fig. 12.28 mostra a redução e a vazão máxima para nosso gráfico de exemplo.

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

204 12 Algoritmos Gráficos Avançados

Fig. 12.28
1 5
Correspondência máxima como fluxo máximo
2 6

3 7

4 8

Fig. 12.29 X = {1, 3} ef (X) =


1 5
{5, 6, 8}

2 6

3 7

4 8

Fig. 12.30 X = {2, 4} ef (X) =


1 5
{7}

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

12.3 Fluxos Máximos 205

Fig. 12.31 Uma cobertura


mínima do nó 1 5

2 6

3 7

4 8

Fig. 12.32 Um conjunto


1 5
independente máximo

2 6

3 7

4 8

Fig. 12.33 Um exemplo de


1 2 3 4
gráfico para construir o caminho
cobre

5 6 7

Por exemplo, como o emparelhamento máximo do nosso gráfico de exemplo é 3, o teorema de K


ÿonig nos diz que o tamanho de uma cobertura mínima de nós também é 3. A Figura 12.31 mostra
como essa cobertura pode ser construída.
Os nós que não pertencem a uma cobertura mínima de nós formam um conjunto máximo
independente. Este é o maior conjunto de nós possível, de modo que não haja dois nós no conjunto
conectados por uma aresta. Novamente, encontrar um conjunto independente máximo em um grafo
geral é um problema NP-difícil, mas em um grafo bipartido podemos usar o teorema de K ÿonig para
resolver o problema de forma eficiente. A Figura 12.32 mostra um conjunto independente máximo
para nosso gráfico de exemplo.

12.3.4 Coberturas de Caminho

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

206 12 Algoritmos Gráficos Avançados

Fig. 12.34 Um mínimo


1 5 6 7
cobertura do caminho do nó-disjunto

3 4

Fig. 12.35 Uma correspondência


1 1
gráfico para encontrar um mínimo
cobertura do caminho do nó-disjunto 2 2

3 3

4 4

5 5

6 6

7 7

Fig. 12.36 Um mínimo


1 5 6 3 4
cobertura geral do caminho

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

12.3 Fluxos Máximos 207

Fig. 12.37 Um gráfico de 1 1


correspondência para encontrar uma
cobertura de caminho geral mínima 2 2

3 3

4 4

5 5

6 6

7 7

Fig. 12.38 Os nós 3 e 7 1 2 3 4


formam uma anticadeia máxima

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).

12.4 Árvores de pesquisa em profundidade

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

Um grafo conectado é chamado biconectado se permanecer conectado após a remoção de qualquer


nó único (e suas arestas) do grafo. Por exemplo, na Fig. 12.40, a esquerda
Machine Translated by Google

208 12 Algoritmos Gráficos Avançados

1 4 5 1

2 2 4

3 6 7
3 6 5

Fig. 12.39 Um gráfico e sua árvore de busca em profundidade

Fig. 12.40 O gráfico da esquerda é 1 2 3 1 2


biconectado, o gráfico certo
não é 3

4 5 6 4 5

Fig. 12.41 Um gráfico com


2 6
três pontos de articulação e
duas pontes 1 4 5 8

3 7

Fig. 12.42 Encontrando pontes 5


e pontos de articulação usando
pesquisa em profundidade

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

12.4 Árvores de pesquisa em profundidade 209

Fig. 12.43 Um gráfico e um 1 2 3 4


Subgrafo Euleriano

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.

12.4.2 Subgrafos Eulerianos

Um subgrafo euleriano de um grafo contém os nós do grafo e um subconjunto de


as arestas de modo que o grau de cada nó seja par. Por exemplo, a Fig. 12.43 mostra
um grafo e seu subgrafo euleriano.
Considere o problema de calcular o número total de subgrafos eulerianos para um
gráfico conectado. Acontece que existe uma fórmula simples para isso: sempre há
2k subgrafos eulerianos onde k é o número de bordas traseiras na busca em profundidade
árvore do gráfico. Observe que k = m ÿ (n ÿ 1) onde n é o número de nós e m
é o número de arestas.
A árvore de busca em profundidade ajuda a entender por que essa fórmula é válida. Considerar
qualquer subconjunto fixo de bordas traseiras na árvore de busca em profundidade. Para criar um Euleriano
subgrafo que contém essas arestas, precisamos escolher um subconjunto das arestas da árvore para que
que cada nó tem um grau par. Para fazer isso, processamos a árvore de baixo para
top e sempre inclua uma aresta de árvore no subgrafo exatamente quando ela aponta para um nó
cujo grau é mesmo com a borda. Então, como a soma dos graus é par, também a
grau do nó raiz será par.
Machine Translated by Google

Geometria
13

Este capítulo discute técnicas de algoritmo relacionadas à geometria. O objetivo geral do


capítulo é encontrar maneiras de resolver convenientemente problemas geométricos, evitando
casos especiais e implementações complicadas.
A Seção 13.1 apresenta a classe de números complexos C++ que possui ferramentas úteis
para problemas geométricos. Depois disso, aprenderemos a usar produtos cruzados para
resolver vários problemas, como testar se dois segmentos de linha se cruzam e calcular a
distância de um ponto a uma linha. Finalmente, discutimos maneiras de calcular áreas de
polígonos e explorar propriedades especiais das distâncias de Manhattan.
A Seção 13.2 enfoca algoritmos de linhas de varredura que desempenham um papel
importante na geometria computacional. Veremos como usar tais algoritmos para contar pontos
entre seções, encontrar pontos mais próximos e construir cascos convexos.

13.1 Técnicas Geométricas


Um desafio na resolução de problemas geométricos é como abordar o problema de modo que
o número de casos especiais seja o menor possível e haja uma maneira conveniente de
implementar a solução. Nesta seção, passaremos por um conjunto de ferramentas que facilitam
a resolução de problemas geométricos.

13.1.1 Números Complexos

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.

© Springer International Publishing AG, parte da Springer Nature 2017 211


A. Laaksonen, Guide to Competitive Programming, Undergraduate
Topics in Computer Science, https://doi.org/10.1007/978-3-319-72547-5_13
Machine Translated by Google

212 13 Geometria

Fig. 13.1 Número complexo


4 + 2i interpretado como um ponto
e um vetor
(4,2)

O complexo de classes de números complexos C++ é útil ao resolver problemas geométricos.


problemas. Usando a classe podemos representar pontos e vetores como números complexos,
e use os recursos da classe para manipulá-los. Para isso, vamos primeiro definir
um tipo de coordenada C. Dependendo da situação, um tipo adequado é longo ou longo
longo duplo. Como regra geral, é bom usar coordenadas inteiras sempre que
possível, porque os cálculos com números inteiros são exatos.
Aqui estão as possíveis definições de tipo de coordenada:

typedef longo longo C;

typedef longo duplo C;

Depois disso, podemos definir um tipo complexo P que representa um ponto ou um vetor:

typedef complexo<C> P;

Finalmente, as seguintes macros se referem às coordenadas x e y:

#define X real()
#define Y imag()

Por exemplo, o código a seguir cria um ponto p = (4, 2) e imprime seus x e y


coordenadas:

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

13.1 Técnicas Geométricas 213

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:

Pv = {4,2}; cout <<


arg(v) << "\n"; v *= polar(1,0,0,5); cout // 0,463648
<< arg(v) << "\n";
// 0,963648

13.1.2 Pontos e Linhas

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

a×b > 0 a×b = 0 a×b < 0

Fig. 13.2 Interpretação de produtos cruzados


Machine Translated by Google

214 13 Geometria

Fig. 13.3 Testando a


localização de um ponto p

s2

s1

• a × b > 0: b vira à esquerda


• a × b = 0: b não vira (ou vira 180 graus) • a × b < 0: b
vira à direita

Por exemplo, o produto vetorial dos vetores a = (4, 2) eb = (1, 2) é 4 · 2 ÿ 2 · 1 = 6, que


corresponde ao primeiro cenário da Fig. 13.2. O produto cruzado pode ser calculado usando
o seguinte código:

Pa = {4,2};
Pb = {1,2}; Cp =
(conj(a)*b).Y; // 6

O código acima funciona, porque a função conj nega a coordenada y de um


vetor, e quando os vetores (x1, ÿy1) e (x2, y2) são multiplicados, a coordenada
y do resultado é x1 y2 ÿ x2 y1.
A seguir, passaremos por algumas aplicações de produtos cruzados.

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.

Interseção de segmento de linha A seguir, considere o problema de testar se dois


segmentos de linha ab e cd se cruzam. Acontece que se os segmentos de linha se cruzam,
existem três casos possíveis: Caso 1: Os segmentos de linha estão na mesma linha e se
sobrepõem. Neste caso, há um número infinito de pontos de interseção. Por exemplo, na
Fig. 13.4, todos os pontos entre c e b são pontos de interseção. Para detectar este caso,
podemos usar produtos cruzados para testar se todos os pontos estão na mesma linha. Se
estiverem, podemos classificá-los e verificar se os segmentos de linha se sobrepõem.

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

13.1 Técnicas Geométricas 215

Fig. 13.4 Caso 1: os


d
segmentos de linha estão na
b
mesma linha e se sobrepõem
c
uma

Fig. 13.5 Caso 2: os b=c


segmentos de linha têm um
vértice d

uma

Fig. 13.6 Caso 3: os


uma

segmentos de reta têm um


ponto de interseção que não é d
um vértice
p

Fig. 13.7 Calculando a p


distância de p até a linha
d

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

Fig. 13.8 O ponto a está


dentro e o ponto b está fora
do polígono b

uma

Fig. 13.9 Enviando raios dos


pontos a e b

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 é

(s1 ÿ p) × (s2 ÿ p) |s2


d= .
ÿ s1|

Ponto em um polígono Finalmente, considere o problema de testar se um ponto está


localizado dentro ou fora de um polígono. Por exemplo, na Fig. 13.8, o ponto a está dentro
do polígono e o ponto b está fora do polígono.
Uma maneira conveniente de resolver o problema é enviar um raio do ponto para uma
direção arbitrária e calcular o número de vezes que ele toca o limite do polígono. Se o
número for ímpar, o ponto está dentro do polígono, e se o número for par, o ponto está fora
do polígono.
Por exemplo, na Fig. 13.9, os raios de um toque 1 e 3 vezes o limite do polígono, então
a está dentro do polígono. De maneira semelhante, os raios de b tocam 0 e 2 vezes o limite
do polígono, então b está fora do polígono.

13.1.3 Área do Polígono

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

| 1 (pi × pi+1)| = | 2 (xi yi+1 ÿ xi+1 yi)|.


2
i=1 i=1
Machine Translated by Google

13.1 Técnicas Geométricas 217

Fig. 13.10 Um polígono (5,5)


cuja área é 17/2

(2,4)

(4,3) (7,3)

(4,1)

Fig. 13.11 Calculando a (5,5)


área do polígono usando
trapézios

(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 ,

onde os vértices do polígono são pi e pi+1. Se xi+1 > xi , a área é positiva, e se


xi+1 < xi , a área étodos
negativa.
essesEntão, a área
trapézios, do polígono
o que produz aéfórmula
a soma das áreas de

nÿ1 nÿ1
yi + yi+1 1
(xi+1 ÿ xi) = (xi yi+1 ÿ xi+1 yi) .
i=1
2 2 i=1

Observe que o valor absoluto da soma é tomado, porque o valor da soma


pode ser positivo ou negativo, dependendo se andamos no sentido horário ou
anti-horário ao longo do limite do polígono.
Machine Translated by Google

218 13 Geometria

Fig. 13.12 Calculando a (5,5)


área do polígono usando Pick's
teorema

(2,4)

(4,3) (7,3)

(4,1)

Teorema de Pick O teorema de Pick fornece outra maneira de calcular a área de um


polígono, assumindo que todos os vértices do polígono têm coordenadas inteiras. O
teorema de Pick nos diz que a área do polígono é

a + b/2 ÿ 1,

onde a é o número de pontos inteiros dentro do polígono e b é o número de pontos


inteiros no limite do polígono. Por exemplo, a área do polígono na Fig. 13.12 é

6 + 7/2 ÿ 1 = 17/2.

13.1.4 Funções de Distância

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) é

(x2 ÿ x1)2 + (y2 ÿ y1)2.


Uma função de distância alternativa é a distância de Manhattan onde a distância entre
os pontos (x1, y1) e (x2, y2) é

|x1 ÿ x2|+|y1 ÿ y2|.

Por exemplo, na Fig. 13.13, a distância euclidiana entre os pontos é

(5 ÿ 2)2 + (2 ÿ 1)2 = ÿ 10

e a distância de Manhattan é

|5 ÿ 2|+|2 ÿ 1| = 4.
Machine Translated by Google

13.1 Técnicas Geométricas 219

(5,2) (5,2)

(2,1) (2,1)

Distância euclidiana distância de Manhattan

Fig. 13.13 Duas funções de distância

Fig. 13.14 Regiões dentro de


uma distância de 1

Distância euclidiana distância de Manhattan

Fig. 13.15 Os pontos B e C têm


a distância máxima de Manhattan
C

UMA

Fig. 13.16 Distância


máxima de Manhattan após
transformar as coordenadas 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:

|x1 ÿ x2|+|y1 ÿ y2| = max(|x 1 ÿ x 2|, |y 1 ÿ y 2|)

Por exemplo, se p1 = (1, 0) e p2 = (3, 3), as coordenadas transformadas são


página 1 = (1, ÿ1) e p 2 = (6, 0) e a distância de Manhattan é

|1 ÿ 3|+|0 ÿ 3| = max(|1 ÿ 6|, | ÿ 1 ÿ 0|) = 5.

As coordenadas transformadas fornecem uma maneira simples de operar com Manhattan


distâncias, porque podemos considerar as coordenadas xey separadamente. Em particular, para
maximizar a distância de Manhattan, devemos encontrar dois pontos cuja transformação
coordenadas maximizam o valor de

max(|x 1 ÿ x 2|, |y 1 ÿ y 2|).

Isso é fácil, porque a diferença horizontal ou vertical da transformação


coordenadas tem que ser o máximo.

13.2 Algoritmos de Linha de Varredura

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.

13.2.1 Pontos de Interseção

Dado um conjunto de n segmentos de linha, cada um deles sendo horizontal ou vertical,


considere o problema de contar o número total de pontos de interseção. Por exemplo,
na Fig. 13.17, existem cinco segmentos de linha e três pontos de interseção.

Fig. 13.17 Cinco linhas


segmentos com três
pontos de interseção
Machine Translated by Google

13.2 Algoritmos de Linha de Varredura 221

Fig. 13.18 Eventos que


correspondem aos segmentos
de linha
1 2
3 3
1 2

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:

(1) o segmento horizontal começa (2)


o segmento horizontal termina (3) o
segmento vertical

A Figura 13.18 mostra os eventos em nosso cenário de exemplo.


Depois de criar os eventos, passamos por eles da esquerda para a direita e usamos uma estrutura de
dados que mantém as coordenadas y dos segmentos horizontais ativos. No evento 1, adicionamos a
coordenada y do segmento à estrutura e, no evento 2, removemos a coordenada y da estrutura. Os pontos
de interseção são calculados no evento 3: ao processar um segmento vertical entre os pontos y1 e y2,
contamos o número de segmentos horizontais ativos cuja coordenada y está entre y1 e y2 e adicionamos
esse número ao número total de pontos de interseção.

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).

13.2.2 Problema do par mais próximo

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

Fig. 13.19 Uma instância do problema do par mais próximo

Fig. 13.20 Região onde deve estar o ponto mais próximo

Fig. 13.21 A região do ponto d


mais próximo contém pontos O(1)

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

13.2 Algoritmos de Linha de Varredura 223

Fig. 13.22 Casco convexo de um conjunto de pontos

passo 1 passo 2 etapa 3 Passo 4

passo 5 passo 6 passo 7 passo 8

passo 9 passo 10 passo 11 passo 12

passo 13 passo 14 passo 15 passo 16

passo 17 passo 18 passo 19 passo 20

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).

13.2.3 Problema de casco convexo

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

Este capítulo trata de tópicos relacionados ao processamento de strings.


A seção 14.1 apresenta a estrutura trie que mantém um conjunto de strings. Depois disso, os
algoritmos de programação dinâmica para determinar as subsequências comuns mais longas e as
distâncias de edição são discutidos.
A Seção 14.2 discute a técnica de hashing de strings, que é uma ferramenta geral para criar
algoritmos de string eficientes. A ideia é comparar valores de hash de strings em vez de seus caracteres,
o que nos permite comparar strings em tempo constante.
A Seção 14.3 introduz o algoritmo Z que determina para cada posição da string a substring mais
longa que também é um prefixo da string. O algoritmo Z é uma alternativa para muitos problemas de
strings que também podem ser resolvidos usando hashing.
A Seção 14.4 discute a estrutura do array de sufixos, que pode ser usada para resolver alguns
problemas de string mais avançados.

14.1 Tópicos Básicos

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).

© Springer International Publishing AG, parte da Springer Nature 2017 225


A. Laaksonen, Guide to Competitive Programming, Undergraduate
Topics in Computer Science, https://doi.org/10.1007/978-3-319-72547-5_14
Machine Translated by Google

226 14 Algoritmos de String

Fig. 14.1 NVELO é uma


uma substring ENVELOPE
subsequência, NEP é uma
subsequência
uma subsequência ENVELOPE

Fig. 14.2 Um trie que


C T
contém as strings CANAL,
CANDY, THE e THERE
UMA H

N E

UMA D R

eu S E

14.1.1 Estrutura Trie

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

14.1 Tópicos Básicos 227

Fig. 14.3 Os valores da função O PERA


lcs para
T 0 0 0 0 0
determinar a subsequência
comum mais longa de O 1 1 1 1 1
TOUR e OPERA
você 1 1 1 1 1

R 1 1 1 2 2

14.1.2 Programação Dinâmica

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

lcs(i ÿ 1, j ÿ 1) + 1 x[i] = y[j]


lcs(i, j) =
max(lcs(i, j ÿ 1), lcs(i ÿ 1, j)) caso contrário.

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:

• inserir um caractere (por exemplo, ABC ÿ ABCA)


• remover um caractere (por exemplo, ABC ÿ AC) •
modificar um caractere (por exemplo, ABC ÿ ADC)

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

228 14 Algoritmos de String

Fig. 14.4 Os valores da FILME


função de edição para
eu 1 2 3 4 5
determinando a distância de
edição entre LOVE e MOVIE O 2 1 2 3 4

V 3 2 1 2 3

E 4 3 2 2 2

editar(a, b) = min(editar(a, b ÿ 1) + 1, editar(a ÿ 1,

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.

14.2 Hash de String

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.

14.2.1 Hash polinomial

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 é

(s[0]Anÿ1 + s[1]Anÿ2 +···+ s[n ÿ 1]A0) mod B,

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 é

(65 · 34 + 66 · 33 + 65 · 32 + 66 · 31 + 67 · 30) mod 97 = 40.


Machine Translated by Google

14.2 Hash de String 229

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:

h[0] = s[0] h[k]


= (h[k ÿ 1]A + s[k]) mod B

Além disso, construímos um array p onde p[k] = Ak mod B:

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

(h[b] ÿ h[a ÿ 1]p[b ÿ a + 1]) mod B

assumindo que a > 0. Se a = 0, o valor de hash é simplesmente h[b].

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.

Correspondência de Padrões Um problema fundamental de strings é o problema de combinação


de padrões : dada uma string s e um padrão p, encontre as posições onde p ocorre em s. Por
exemplo, o padrão ABC ocorre nas posições 0 e 5 na string ABCABABCA (Fig. 14.5).
Podemos resolver o problema de casamento de padrões em tempo O(n2) usando um algoritmo
de força bruta que passa por todas as posições onde p pode ocorrer em s e compara strings
caractere por caractere. Então, podemos tornar o algoritmo de força bruta eficiente usando hashing,
porque cada comparação de strings leva apenas tempo O(1). Isso resulta em um algoritmo de tempo
O(n) .

Substrings Distintas Considere o problema de contar o número de substrings distintas de


comprimento k em uma string. Por exemplo, a string ABABAB tem duas substrings distintas de
comprimento 3: ABA e BAB. Usando hashing, podemos calcular o valor de hash de cada substring
e reduzir o problema a contar o número de inteiros distintos em uma lista, o que pode ser feito em
tempo O(n log n).

Fig. 14.5 O padrão ABC 012345678


aparece duas vezes na
ABC ABAB CA
string ABCABABCA
Machine Translated by Google

230 14 Algoritmos de String

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).

14.2.3 Colisões e Parâmetros

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.2 Hash de String 231

Tabela 14.1 Probabilidades de colisão em cenários de hash quando n = 106

Constante B Cenário 1 Cenário 2 Cenário 3

103 0,00 1,00 1,00


106 0,00 0,63 1,00
109 0,00 0,00 1,00
1012 0,00 0,00 0,39
1015 0,00 0,00 0,00
1018 0,00 0,00 0,00

A Tabela 14.1 mostra as probabilidades de colisão para diferentes valores de B quando n =


106. A tabela mostra que nos Cenários 1 e 2, a probabilidade de uma colisão é
insignificante quando B ÿ 109. No entanto, no Cenário 3 a situação é muito diferente: a
a colisão quase sempre acontecerá quando B ÿ 109.
O fenômeno do Cenário 3 é conhecido como paradoxo do aniversário: se houver n
pessoas em uma sala, a probabilidade de que duas pessoas tenham o mesmo aniversário é
grande mesmo que n seja muito pequeno. Em hash, correspondentemente, quando todos os valores de hash são
comparados entre si, a probabilidade de que dois valores de hash sejam iguais é
ampla.
Podemos diminuir a probabilidade de uma colisão calculando múltiplos hash
valores usando parâmetros diferentes. É improvável que uma colisão ocorra em todos os
valores de hash ao mesmo tempo. Por exemplo, dois valores de hash com parâmetro B ÿ 109
correspondem a um valor de hash com parâmetro B ÿ 1018, o que torna a probabilidade
de uma colisão muito pequena.
Algumas pessoas usam constantes B = 232 e B = 264, o que é conveniente, porque
operações com inteiros de 32 e 64 bits são calculadas módulo 232 e 264. No entanto,
essa não é uma boa escolha, pois é possível construir insumos que sempre geram
colisões quando constantes da forma 2x são usadas [23].

14.3 Algoritmo Z

A matriz Z z de uma string s de comprimento n contém para cada k = 0, 1,..., n ÿ 1 o


comprimento da substring mais longa de s que começa na posição k e é um prefixo de s.
Assim, z[k] = p nos diz que s[0 ... p ÿ 1] é igual a s[k ... k + p ÿ 1], mas s[p] e
s[k + p] são caracteres diferentes (ou o comprimento da string é k + p).
Por exemplo, a Fig. 14.6 mostra a matriz Z de ABCABCABAB. Na matriz, para
exemplo, z[3] = 5, porque a substring ABCAB de comprimento 5 é um prefixo de s, mas o
substring ABCABA de comprimento 6 não é um prefixo de s.
Machine Translated by Google

232 14 Algoritmos de String

Fig. 14.6 A matriz Z de 0123456789


ABCABABABAB
ABCABC ABAB

– 00500 2 0 2 0

Fig. 14.7 Cenário 1: 0123456789


Calculando o valor de z[3] ABCABC ABAB

– 0 0 ????????

x y
0123456789

ABCABC ABAB

– 005 ??????

14.3.1 Construindo o Z-Array

A seguir, descrevemos um algoritmo, chamado algoritmo Z, que constrói eficientemente a


matriz Z em tempo O(n) .1 O algoritmo calcula os valores da matriz Z da esquerda para a
direita usando informações já armazenadas na matriz e comparando substrings caractere
por caractere.
Para calcular eficientemente os valores da matriz Z, o algoritmo mantém um intervalo
[x, y] tal que s[x ... y] é um prefixo de s, o valor de z[x] foi determinado e y é como maior
possível. Como sabemos que s[0 ... y ÿ x] e s[x ... y] são iguais, podemos usar essa
informação ao calcular os valores de array subsequentes. Suponha que calculamos os
valores de z[0], z[1],..., z[k ÿ 1] e queremos calcular o valor de z[k]. Existem três cenários
possíveis:
Cenário 1: y < k. Nesse caso, não temos informações sobre a posição k, então
calculamos o valor de z[k] comparando substrings caractere por caractere.
Por exemplo, na Fig. 14.7, ainda não existe um intervalo [x, y], então comparamos as
substrings começando nas posições 0 e 3 caractere por caractere. Como z[3] = 5, o novo
intervalo [x, y] se torna [3, 7].
Cenário 2: y ÿ kek + z[k ÿ x ] ÿ y. Neste caso sabemos que z[k] = z[k ÿ x], porque s[0 ...
y ÿ x] e s[x ... y] são iguais e ficamos dentro do [x, y] variar. Por exemplo, na Fig. 14.8,
concluímos que z[4] = z[1] = 0.
Cenário 3: y ÿ kek + z[k ÿ x ] > y. Neste caso sabemos que z[k] ÿ y ÿ k + 1. No entanto,
como não temos informação após a posição y, temos que comparar substrings caractere
por caractere começando nas posições y ÿ k + 1 e y + 1 .
Por exemplo, na Fig. 14.9, sabemos que z[6] ÿ 2. Então, como s[2] = s[8], verifica-se que,
de fato, z[6] = 2.

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

14.3 Algoritmo Z 233

Fig. 14.8 Cenário 2: x y


Calculando o valor de z[4]
0123456789

ABCABC ABAB

– 005 ??????

x y
0123456789

ABCABC ABAB

– 0050 ?????

Fig. 14.9 Cenário 3: x y


Calculando o valor de z[6]
0123456789

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.

Correspondência de padrões Considere novamente o problema de correspondência de


padrões, onde nossa tarefa é encontrar as ocorrências de um padrão p em uma string s. Já
resolvemos o problema usando hashing, mas agora veremos como o algoritmo Z trata o problema.
Uma ideia recorrente no processamento de strings é construir uma string que consiste em
várias partes individuais separadas por caracteres especiais. Neste problema, podemos
construir uma string p#s, onde p e s são separados por um caractere especial # que não ocorre
nas strings. Então, a matriz Z de p#s nos diz as posições onde p ocorre em s, porque tais
posições contêm o comprimento de p.
Machine Translated by Google

234 14 Algoritmos de String

Fig. 14.10 Correspondência de 0 1 2 3 4 5 6 7 8 9 10 11 12


padrões usando o algoritmo Z
ABC # ABC ABAB CA

– 000300 2 0300 1

Fig. 14.11 Encontrando bordas 0 1 2 3 4 5 6 7 8 9 10


usando o algoritmo Z
ABA C ABA C ABA

– 0 1 0 7 0 1 030 1

Fig. 14.12 A matriz de sufixos da 01234567


string ABAACBAB
2 603 7 1 5 4

Fig. 14.13 Outra maneira de


0 2 AACBAB
representar a matriz de sufixos
1 6 AB

2 0 ABAACBAB

3 3 ACBAB

4 7 B

5 1 BAACBAB

6 5 BAB

7 4 CBAB

A Figura 14.10 mostra a matriz Z para s =ABCABABCA ep =ABC. Posições 4


e 9 contêm o valor 3, o que significa que p ocorre nas posições 0 e 5 em s.

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.

14.4 Matrizes de Sufixos

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

14.4 Matrizes de Sufixos 235

rótulos iniciais rótulos finais


rodada 0
–––––––– 1211 3 212
comprimento 1

rótulos iniciais rótulos finais


rodada 1
1, 2 2, 1 1, 1 1, 3 3, 2 2, 1 1, 2 2, 0 25136524
comprimento 2

rótulos iniciais rótulos finais


2 ª rodada
2, 1 5, 3 1, 6 3, 5 6, 2 5, 4 2, 0 4, 0 36148725
comprimento 4

rótulos iniciais rótulos finais


rodada 3
3, 8 6, 7 1, 2 4, 5 8, 0 7, 0 2, 0 5, 0 36148725
comprimento 8

Fig. 14.14 Construindo os rótulos para a string ABAACBAB

14.4.1 Método de Duplicação de Prefixo

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

236 14 Algoritmos de String

0 2 AACBAB 0 2 AACBAB 0 2 AACBAB

1 6 AB 1 6 AB 1 6 AB

2 0 ABAACBAB 2 0 ABAACBAB 2 0 ABAACBAB

3 3 ACBAB 3 3 ACBAB 3 3 ACBAB

4 7 B 4 7 B 4 7 B

5 1 BAACBAB 5 1 BAACBAB 5 1 BAACBAB

6 5 BAB 6 5 BAB 6 5 BAB

7 4 CBAB 7 4 CBAB 7 4 CBAB

Fig. 14.15 Encontrando as ocorrências de BA em ABAACBAB usando uma matriz de sufixos

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.

14.4.2 Encontrando Padrões

Depois de construir o array de sufixos, podemos encontrar eficientemente as ocorrências de


qualquer padrão na string. Isso pode ser feito em tempo O(k log n), onde n é o comprimento da
string e k é o comprimento do padrão. A ideia é processar o padrão caractere por caractere e
manter um intervalo no array de sufixos que corresponda ao prefixo do padrão processado até o
momento. Usando a pesquisa binária, podemos atualizar eficientemente o intervalo após cada
novo caractere.
Por exemplo, considere encontrar as ocorrências do padrão BA na string ABAACBAB (Fig.
14.15). Primeiro, nosso intervalo de pesquisa é [0, 7], que abrange toda a matriz de sufixos.
Então, depois de processar o caractere B, o intervalo se torna [4, 6]. Finalmente, após o
processamento do caractere A, o intervalo se torna [5, 6]. Assim, concluímos que BA tem duas
ocorrências no ABAACBAB nas posições 1 e 5.
Comparado ao hashing de strings e ao algoritmo Z discutido anteriormente, a vantagem do
array de sufixos é que podemos processar com eficiência várias consultas relacionadas a
diferentes padrões, e não é necessário conhecer os padrões de antemão ao construir o array de
sufixos.

14.4.3 Matrizes LCP

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

14.4 Matrizes de Sufixos 237

Fig. 14.16 A matriz LCP da string


0 1 AACBAB
ABAACBAB
1 2 AB

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,

onde n é o comprimento da string e c é a soma de todos os valores na matriz LCP.


Por exemplo, a string ABAACBAB tem

8·9
ÿ 7 = 29
2

substrings distintas.

Você também pode gostar