Escolar Documentos
Profissional Documentos
Cultura Documentos
Antti Laaksonen
eu
Machine Translated by Google
Conteúdo
Prefácio ix
I Técnicas básicas 1
1. Introdução 3
1.1 Linguagens de programação . . . ... ... ... ........ ... .. 3
1.2 Entrada e saída. . . . . ... ... ... ... ........ ... .. 4
1.3 Trabalhando com números . . . . ... ... ... ........ ... .. 6
1.4 Código de encurtamento. . . . . . ... ... ... ... ........ ... .. 8
1.5 Matemática. . ..... ... ... ... ... ........ ... . . 10
1.6 Concursos e recursos . ... ... ... ... ........ ... . . 15
2 Complexidade 17
temporal 2.1 Regras de cálculo . .. .. .. . . . . ... ... ........ ... . . 17
2.2 Classes de complexidade. . . . . . . . . . ... ... ........ ... . . 20
2.3 Estimando a eficiência . . ... ... ... ... ........ ... .. 21
2.4 Soma máxima do subarranjo. . . . . . ... ... ........ ... .. 21
3 Classificação 25
.....
3.1 Teoria da classificação . . ... ... ... ... ........ . . . . . 25
3.2 Classificando em C++. . . . . . ... ... ... ... ........ . . . . . 29
3.3 Pesquisa binária. . . . . . . ... ... ... ... ........ . . . . . 31
4 Estruturas de dados 35
4.1 Matrizes dinâmicas. . . . . . ... ... ... ... ........ ... . . 35
4.2 Definir estruturas. . . . . . . ... ... ... ... ........ ... . . 37
4.3 Estruturas do mapa. . . . . . ... ... ... ... ........ ... . . 38
4.4 Iteradores e intervalos . . . ... ... ... ... ........ ... . . 39
4.5 Outras estruturas . . . . . ... ... ... ... ........ ... .. 41
4.6 Comparação com classificação . . . . . ... ... ... ........ ... .. 44
5 Pesquisa completa 47
5.1 Gerando subconjuntos . . . . ... ... ... ... ........ ... . . 47
5.2 Gerando permutações . . . ... ... ... ........ ... . . 49
5.3 Retrocesso . . ..... ... ... ... ... ........ ... . . 50
5.4 Removendo a pesquisa . . . . ... ... ... ... ........ ... . . 51
5.5 Encontre-se no meio. . . . ... ... ... ... ........ ... . . 54
iii
Machine Translated by Google
6 Algoritmos gananciosos 57
6.1 Problema da moeda. . . . . . .
... ... ... ... ........ ... . . 57
6.2 Agendamento . . . . . . . . .
... ... ... ... ........ ... . . 58
6.3 Tarefas e prazos . . . ... ... ... ... ........ ... . . 60
6.4 Minimizando somas . . . . . ... ... ... ... ........ ... . . 61
6.5 Compressão de dados . . . . . . . ... ... ... ........ ... . . 62
8 Análise amortizada 77
8.1 Método dos dois indicadores. .. .. . . . . . . . . . . . . . . . . . . . . . . 77
8.2 Elementos menores mais próximos. . . ... ... ... ........ . . . . . 79
8.3 Janela deslizante mínima . . . ... ... ... ........ . . . . . 81
9 Consultas de 83
. . . . .. .. .
intervalo 9.1 Consultas de array estáticas ... ... ........ ... . . 84
9.2 Árvore indexada binária . . . . . . . . . . . . ... ........ ... . . 86
9.3 Árvore de segmentos . . . . . . . ... ... ... ... ........ ... . . 89
9.4 Técnicas adicionais . . ... ... ... ... ........ ... . . 93
Manipulação de 10 95
bits 10.1 Representação de bits . .. .. .. ... ... ... ........ ... . . 95
10.2 Operações de bits . . . . . . . . . . ... ... ... ........ ... . . 96
10.3 Representando conjuntos . . . . . . . ... ... ... ........ ... . . 98
10.4 Otimizações de bits . . . . . . . . ... ... ... ........ ... . . 100
10.5 Programação dinâmica . ... ... ... ... ........ ... . . 102
4
Machine Translated by Google
16 Gráficos 149
. . . . .... . .. . .
direcionados 16.1 Ordenação topológica ... ........ ... . . 149
16.2 Programação dinâmica . ... ... ... ... ........ ... . . 151
16.3 Caminhos sucessores . . . . . . ... ... ... ... ........ ... . . 154
16.4 Detecção de ciclo . . . . . . ... ... ... ... ........ ... . . 155
18 Consultas de 163
árvore 18.1 Encontrando ancestrais . .. .. .. ... ... ... ........ ... . . 163
18.2 Subárvores e caminhos . . . . . . ... ... ... ........ ... . . 164
18.3 Menor ancestral comum. . . ... ... ... ........ ... . . 167
18.4 Algoritmos off-line . . . . ... ... ... ... ........ ... . . 170
v
Machine Translated by Google
22 Combinatória 207
22.1 Coeficientes binomiais. . . ... ... ... ... ........ ... . . 208
22.2 Números catalães. . . . . ... ... ... ... ........ ... . . 210
22.3 Inclusão-exclusão . . . . ... ... ... ... ........ ... . . 212
22.4 Lema de Burnside. . . . ... ... ... ... ........ ... . . 214
22.5 Fórmula de Cayley. . . . . ... ... ... ... ........ ... . . 215
23 Matrizes 217
23.1 Operações . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 217
23.2 Recorrências lineares . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 220
23.3 Gráficos e matrizes . . ... ... ... ... ........ . . . . . 222
24 Probabilidade 225
24.1 Cálculo . . . ..... ... ... ... ... ........ ... . . 225
24.2 Eventos . . . . . . . . . . .
... ... ... ... ........ ... . . 226
...
24.3 Variáveis aleatórias . . . . ... ... ... ........ ... . . 228
24.4 Cadeias de Markov. . . . . .
... ... ... ... ........ ... . . 230
24.5 Algoritmos randomizados. . . . ... ... ... ........ ... . . 231
26 Algoritmos de 243
String 26.1 Terminologia de String . . .. . .... . ... ... ........ ... . . 243
26.2 Estrutura experimental . . . . . . . ... ... ... ... ........ ... . . 244
26.3 Hash de strings . . . . . . ... ... ... ... ........ ... . . 245
26.4 Algoritmo Z . . . ..... ... ... ... ... ........ ... . . 247
vi
Machine Translated by Google
29 Geometria 265
29.1 Números complexos . . . . ... ... ... ... ........ ... . . 266
29.2 Pontos e retas . ..... ... ... ... ... ........ ... . . 268
29.3 Área do polígono . . . ..... ... ... ... ... ........ ... . . 271
29.4 Funções de distância . . . . ... ... ... ... ........ ... . . 272
Bibliografia 281
vii
Machine Translated by Google
viii
Machine Translated by Google
Prefácio
ix
Machine Translated by Google
x
Machine Translated by Google
Parte I
Técnicas básicas
1
Machine Translated by Google
Machine Translated by Google
Capítulo 1
Introdução
Linguagens de programação
No momento, as linguagens de programação mais populares utilizadas 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 [29].
Alguns participantes também usaram vários idiomas.
Muitas pessoas pensam que C++ é a melhor escolha para um programador competitivo,
e C++ está quase sempre disponível em sistemas de concurso. Os benefícios de usar C+
+ são que ela é uma linguagem muito eficiente e sua biblioteca padrão contém uma grande
coleção de estruturas de dados e algoritmos.
Por outro lado, é bom dominar vários idiomas e compreender os seus pontos fortes.
Por exemplo, se números inteiros grandes forem necessários no problema, Python pode
ser uma boa escolha, porque contém operações integradas para cálculo com
3
Machine Translated by Google
inteiros grandes. Ainda assim, a maioria dos problemas em concursos de programação são definidos de forma
que o uso de uma linguagem de programação específica não seja uma vantagem injusta.
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 atualmente. Se você ainda não consegue
programar em C++, agora é um bom momento para começar a aprender.
#incluir <bits/stdc++.h>
int main() { // a
solução vem aqui
}
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. Além disso, as
funções C scanf e printf podem ser usadas.
A entrada do programa geralmente consiste em números e strings separados por espaços e
novas linhas. Eles podem ser lidos no fluxo cin da seguinte forma:
interno a,
b; cadeia
x; cin >> a >> b >> x;
4
Machine Translated by Google
Esse tipo de código sempre funciona, desde que haja pelo menos um espaço
ou nova linha entre cada elemento da entrada. Por exemplo, o código acima pode
ler ambas as seguintes entradas:
123 456
macaco
ios::sync_with_stdio(0);
cin.tie(0);
Observe que a nova linha "\n" funciona mais rápido que endl, porque endl sempre
causa uma operação de descarga.
As funções C scanf e printf são uma alternativa aos fluxos padrão C++. Geralmente são um
pouco mais rápidos, mas também são mais difíceis de usar. O código a seguir lê dois inteiros da
entrada:
interno a,
b; scanf("%d %d", &a, &b);
cordas;
getline(cin,s);
Este loop lê os elementos da entrada um após o outro, até que não haja mais dados
disponíveis na entrada.
5
Machine Translated by Google
Em alguns sistemas de concurso, arquivos são usados para entrada e saída. Uma
solução fácil para isso é escrever o código normalmente usando fluxos padrão, mas
adicionar as seguintes linhas ao início do código:
Inteiros
O tipo inteiro mais usado na programação competitiva é int, que é um tipo de 32 bits
com intervalo de valores de -2. Se31o 31 ...2intÿ1
tipo ou for
não cerca de ÿ2· 109
suficiente, ...2·
o tipo 109long
long . de 64 bits
pode ser usado. Tem uma faixa de valores de 63 63 ÿ2 ...2
ÿ1 ou cerca de ÿ9·1018 ...9·1018 .
O código a seguir define uma variável longa e longa :
intuma = 123456789;
longo longo b = a*a;
cout << b << "\n"; //-1757895751
Aritmética modular
Denotamos por x mod m o resto quando x é dividido por m. Por exemplo, 17 mod 5 = 2,
porque 17 = 3·5+2.
Às vezes, a resposta para um problema é um número muito grande, mas é suficiente produzi-lo
como “módulo m”, ou seja, o resto quando a resposta é dividida por m (para
6
Machine Translated by Google
exemplo, ”módulo 109 + 7”). A ideia é que mesmo que a resposta real seja muito
grande, basta usar os tipos int e long long.
Uma propriedade importante do resto é que além da subtração e
multiplicação, o resto pode ser obtido antes da operação:
Assim, podemos pegar o restante após cada operação e os números nunca ficarão
muito grandes.
Por exemplo, o código a seguir calcula n!, o fatorial de n, módulo m:
longo longo x = 1;
for (int i = 2; i <= n; i++) { x = (x*i)%m;
Normalmente queremos que o resto esteja sempre entre 0...mÿ1. No entanto, em C++
e outras linguagens, o resto de um número negativo é zero ou negativo. Uma maneira fácil
de garantir que não haja restos negativos é primeiro calcular o resto normalmente e depois
adicionar m se o resultado for negativo:
x = x%m;
se (x < 0) x += m;
printf("%.9f\n", x);
Uma dificuldade ao usar números de ponto flutuante é que alguns números não podem
ser representados com precisão como números de ponto flutuante e haverá erros de
arredondamento. Por exemplo, o resultado do código a seguir é surpreendente:
duplo x = 0,3*3+0,1;
printf("%.20f\n", x); //0.9999999999999988898
7
Machine Translated by Google
se (abs(ab) <1e-9) {
//a e b são iguais
}
Observe que, embora os números de ponto flutuante sejam imprecisos, os números inteiros até
um determinado limite ainda podem ser representados com precisão. Por exemplo, usando double,
é possível representar com precisão todos os inteiros cujo valor absoluto é no máximo 253.
Código de encurtamento
O código curto é ideal em programação competitiva, porque os programas devem ser escritos
o mais rápido possível. Por causa disso, os programadores competitivos geralmente definem
nomes mais curtos para tipos de dados e outras partes do código.
Digite nomes
Usando o comando typedef é possível dar um nome mais curto a um tipo de dados.
Por exemplo, o nome long long é longo, então podemos definir um nome mais curto ll:
tudo a = 123456789;
tudo b = 987654321;
cout << a*b << "\n";
O comando typedef também pode ser usado com tipos mais complexos. Por exemplo,
o código a seguir fornece o nome vi para um vetor de inteiros e o nome pi para um par
que contém dois inteiros.
8
Machine Translated by Google
Macros
Outra forma de encurtar o código é definir macros. Uma macro significa 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 .
#define F primeiro
#define S segundo
v.push_back(make_pair(y1,x1));
v.push_back(make_pair(y2,x2)); int d =
v[i].primeiro+v[i].segundo;
v.PB(MP(y1,x1));
v.PB(MP(y2,x2)); int d =
v[i].F+v[i].S;
Uma macro também pode ter parâmetros que permitem encurtar loops
e outras estruturas. Por exemplo, podemos definir a seguinte macro:
REP(i,1,n)
{ pesquisa(i);
}
Às vezes, as macros causam bugs que podem ser difíceis de detectar. Por exemplo,
considere a seguinte macro que calcula o quadrado de um número:
Esta macro nem sempre funciona conforme o esperado. Por exemplo, o código
9
Machine Translated by Google
corresponde ao código
Agora o código
corresponde ao código
Matemática
A matemática desempenha um papel importante na programação competitiva e não é
possível tornar-se um programador competitivo de sucesso sem ter boas habilidades
matemáticas. Esta seção discute alguns conceitos matemáticos importantes e fórmulas
que serão necessárias posteriormente neste livro.
Fórmulas de soma
onde k é um número inteiro positivo, tem uma fórmula fechada que é um polinômio de grau
k +1. Por exemplo1 ,
n n(n+1)
x = 1+2+3+...+ n =
x=1 2
e
n
2 2 2 2 2 = 1 +2 +3 +...+ nx = n(n+1)(2n+1) .
x=1 6
3,7,11,15
1 Existe até uma fórmula geral para tais somas, chamada fórmula de Faulhaber, mas é demasiado
complexo a ser apresentado aqui.
10
Machine Translated by Google
3,6,12,24
é uma progressão geométrica com constante 2. A soma de uma progressão geométrica pode ser calculada
usando a fórmula
bk ÿ a
a+ ak + ak2 +··· + b =
k ÿ1
onde a é o primeiro número, b é o último número e a razão entre números
consecutivos é k. Por exemplo,
24·2ÿ3
3+6+12+24 = = 45.
2-1
Esta fórmula pode ser derivada da seguinte forma. Deixar
S = a+ ak + ak2 +··· + b.
e resolvendo a equação
kS ÿ S = bk ÿ a
produz a fórmula.
Um caso especial de soma de uma progressão geométrica é a fórmula
nÿ1 n
1+2+4+8+...+2 = 2 ÿ1.
Este limite superior consiste em log2 (n)+1 partes (1, 2·1/2, 4·1/4, etc.), e o valor de
cada parte é no máximo 1.
11
Machine Translated by Google
Teoria de conjuntos
X = {2,4,7}
4 ÿ X e 5 ÿ X.
{f (n) : n ÿ S},
onde f (n) é alguma função. Este conjunto contém todos os elementos da forma f (n), onde n é um
elemento em S. Por exemplo, o conjunto
X = {2n : n ÿ Z}
12
Machine Translated by Google
Lógica
O valor de uma expressão lógica é verdadeiro (1) ou falso (0). Os operadores lógicos
mais importantes são ¬ (negação), ÿ (conjunção), ÿ (disjunção), ÿ
(implicação) e ÿ (equivalência). A tabela a seguir mostra os significados
destes operadores:
significa que para cada elemento x no conjunto, existe um elemento y no conjunto tal
que y é menor que x. Isto é verdadeiro no conjunto dos inteiros, mas falso no conjunto dos
números naturais.
Usando a notação descrita acima, podemos expressar muitos tipos de lógica
proposições. Por exemplo,
significa que se um número x for maior que 1 e não for um número primo, então existem
números a e b maiores que 1 e cujo produto é x. Esta proposição
é verdadeiro no conjunto de inteiros.
Funções
3/2 = 1 e 3/2 = 2.
min(1,2,3) = 1 e máx(1,2,3) = 3.
13
Machine Translated by Google
ou recursivamente
0! = 1n ! =
n·(nÿ1)!
Os números de Fibonacci surgem em muitas situações. Eles podem ser definidos recursivamente
da seguinte forma:
f (0) = 0 f (1)
= 1 f (n) = f
(nÿ1)+ f (nÿ2)
Os primeiros números de Fibonacci são
0,1,1,2,3,5,8,13,21,34,55,...
Existe também uma fórmula fechada para calcular os números de Fibonacci, que às vezes é chamada
de fórmula de Binet:
Logaritmos
O logaritmo de um número x é denotado logk (x), onde k é a base do logaritmo. De acordo
a = x.
com a definição, logk (x) = a exatamente quando k
Uma propriedade útil dos logaritmos é que logk (x) é igual ao número de vezes que
temos que dividir x por k antes de chegarmos ao número 1. Por exemplo, log2 (32) = 5
porque são necessárias 5 divisões por 2:
32 ÿ 16 ÿ 8 ÿ 4 ÿ 2 ÿ 1
e consequentemente,
n
log (x ) = n·logk (x).
a
registro = logk (a)ÿlogk (b).
b
Outra fórmula útil é
logk (x)
logu (x) = ,
logk (u)
14
Machine Translated by Google
e usando isso, é possível calcular logaritmos para qualquer base se houver uma maneira
de calcular logaritmos para alguma base fixa.
O logaritmo natural ln(x) de um número x é um logaritmo cuja base é e ÿ 2,71828. Outra
propriedade dos logaritmos é que o número de dígitos de um inteiro x na base b é logb (x)+1. Por
exemplo, a representação de 123 na base 2 é 1111011 e log2 (123)+1 = 7.
Concursos e recursos
IOI
A Olimpíada Internacional de Informática (IOI) é um concurso anual de programação para alunos
do ensino médio. Cada país pode enviar uma equipe de quatro estudantes para o concurso.
Geralmente há cerca de 300 participantes de 80 países.
Alguns países organizam concursos práticos on-line para futuros participantes do IOI, como a
Competição Aberta de Informática da Croácia [11] e a Olimpíada de Computação dos EUA [68].
Além disso, uma grande coleção de problemas de concursos polacos está disponível online [60].
ICPC
O International Collegiate Programming Contest (ICPC) é um concurso anual de programação para
estudantes universitários. Cada equipe no 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úmero2 de vagas finais disponíveis, por isso mesmo avançar para as finais é uma grande
conquista em algumas regiões.
Em cada competição do ICPC, as equipes têm cinco horas para resolver cerca de dez
problemas de algoritmos. Uma solução para um problema só é aceita se resolver todos os casos
de teste de forma eficiente. Durante a competição, os competidores poderão visualizar os resultados de outras
2O número exato de vagas finais varia de ano para ano; em 2017, foram 133 vagas finais.
15
Machine Translated by Google
equipes, mas na última hora o placar está congelado e não é possível ver os
resultados das últimas finalizações.
Os temas que podem aparecer no ICPC não são tão bem especificados como os do IOI. De
qualquer forma, é claro que é necessário mais conhecimento no ICPC, especialmente mais competências
matemáticas.
Concursos on-line
Existem também muitos concursos online abertos a todos. No momento, o site de
concursos mais ativo é o Codeforces, que organiza concursos semanalmente.
No Codeforces, os participantes são divididos em duas divisões: os iniciantes competem na Div2 e os
programadores mais experientes na Div1. Outros sites de concurso incluem AtCoder, CS Academy,
HackerRank e Topcoder.
Algumas empresas organizam concursos online com finais presenciais. Exemplos de tais concursos
são Facebook Hacker Cup, Google Code Jam e Yandex.Algorithm. É claro que as empresas também
utilizam esses concursos para recrutamento: um bom desempenho num concurso é uma boa forma de
provar as suas competências.
Livros
Já existem alguns livros (além deste livro) que enfocam programação competitiva e resolução de
problemas algorítmicos:
Os dois primeiros livros são destinados a iniciantes, enquanto o último livro contém
materiais avançados.
É claro que livros gerais sobre algoritmos também são adequados para programadores
competitivos . Alguns livros populares são:
16
Machine Translated by Google
Capítulo 2
Complexidade de tempo
Regras de cálculo
rotações
Uma razão comum pela qual um algoritmo é lento é que ele contém muitos loops que
passam pela entrada. Quanto mais loops aninhados o algoritmo contém, mais lento ele é.
Se houver k loops aninhados, a complexidade do tempo é O(nk ).
Por exemplo, a complexidade de tempo do código a seguir é O(n):
2 ):
E a complexidade de tempo do código a seguir é O(n
}
}
17
Machine Translated by Google
Ordem de grandeza
A complexidade de tempo não nos diz o número exato de vezes que o código dentro
de um loop é executado, mas apenas mostra a ordem de grandeza. 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).
2 ):
Como outro exemplo, a complexidade de tempo do código a seguir é O(n
Fases
Se o algoritmo consistir em fases consecutivas, a complexidade de tempo total será a
maior complexidade de tempo de uma única fase. A razão para isso é que a fase mais
lenta geralmente é o gargalo do código.
Por exemplo, o código a seguir consiste em três fases com complexidades de tempo
O(n), O(n 2 ) e O(n). Assim, a complexidade total do tempo é O(n 2 ).
18
Machine Translated by Google
Várias variáveis
Às vezes, a complexidade do tempo depende de vários fatores. Neste caso, a fórmula de
complexidade de tempo contém diversas variáveis.
Por exemplo, a complexidade de tempo do código a seguir é O(nm):
}
}
Recursão
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 total do tempo é o produto desses
valores.
Por exemplo, considere a seguinte função:
void f(int n) { if (n
== 1) retorno; f(n-1);
A chamada f(n) causa n chamadas de função e a complexidade de tempo de cada chamada é O(1).
Assim, a complexidade total do tempo é O(n).
Como outro exemplo, considere a seguinte função:
void g(int n) { if (n
== 1) retorno; g(n-1);
g(n-1);
Neste caso cada chamada de função gera duas outras chamadas, exceto n = 1. Vejamos
o que acontece quando g é chamado com o parâmetro n. A tabela a seguir mostra as
chamadas de função produzidas por esta única chamada:
nÿ1 n
1+2+4+··· +2 = 2 ÿ1 = O(2n ).
19
Machine Translated by Google
Aulas de complexidade
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 típico de tempo constante é uma fórmula direta que calcula a resposta.
O (logn) 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, porque log2 n é igual ao número de
vezes que n deve ser dividido por 2 para obter 1.
O( n) Um algoritmo de raiz quadrada é mais lento que O(logn) , mas mais rápido que O(n).
Uma propriedade especial das raízes quadradas é que n = n/ n, então a raiz
quadrada n fica, em certo sentido, no meio da entrada.
O(n) Um algoritmo linear passa pela entrada um número constante de vezes. Esta é muitas vezes a
melhor complexidade de tempo possível, porque normalmente é necessário aceder a cada
elemento de entrada pelo menos uma vez antes de reportar a resposta.
O(nlogn) Essa complexidade de tempo geralmente indica que o algoritmo classifica a entrada, porque a
complexidade de tempo dos algoritmos de classificação eficientes é O(nlogn).
Outra possibilidade é que o algoritmo utilize uma estrutura de dados onde cada operação leva
tempo O(logn) .
2
Sobre ) Um algoritmo quadrático geralmente contém dois loops aninhados. É possível percorrer todos
2
os pares de elementos de entrada em O(n ) tempo.
3
Sobre ) Um algoritmo cúbico geralmente contém três loops aninhados. É possível ir no tempo.
3
através de todos os trios dos elementos de entrada em O (n
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).
1Um livro clássico sobre o assunto é Computers and Intractability, de MR Garey e DS Johnson:
Um Guia para a Teoria da NP-Completude [28].
20
Machine Translated by Google
Estimando a eficiência
Ao calcular a complexidade temporal de um algoritmo, é possível verificar, antes de
implementar o algoritmo, se ele é eficiente o suficiente para o problema. O ponto de
partida para estimativas é o fato de que um computador moderno pode realizar
centenas de milhões de operações por segundo.
Por exemplo, suponha que o limite de tempo para um problema seja de um segundo e que o algoritmo
o tamanho da entrada é n = Se a complexidade do tempo for O(n 2
executará = 1010 operações. Isso
2 )
105 . cerca de (105
deve levar pelo menos algumas dezenas de segundos, então o algoritmo parece muito
lento para resolver o problema.
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 a seguir contém algumas estimativas úteis
assumindo um limite de tempo de um segundo.
ÿ1 2 4 ÿ3 5 2 ÿ5 2
21
Machine Translated by Google
ÿ1 2 4 ÿ3 5 2 ÿ5 2
Algoritmo 1
Uma maneira simples de resolver o problema é percorrer todas as submatrizes
possíveis, calcular a soma dos valores em cada submatriz 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++) {
soma interna
= 0; for (int k = a; k <= b; k++) { soma
+= matriz[k];
} melhor = max(melhor,soma);
}
Algoritmo 2
É fácil tornar o Algoritmo 1 mais eficiente removendo um loop dele. Isto é possível calculando a soma ao
mesmo tempo que a extremidade direita da submatriz se move. O resultado é o seguinte código:
int melhor = 0;
for (int a = 0; a < n; a++) {
soma interna
= 0; for (int b = a; b < n; b++) { soma
+= matriz[b]; melhor
= max(melhor,soma);
}
2
Após esta mudança, a complexidade do tempo é O(n ).
22
Machine Translated by Google
Algoritmo 3
2. A submatriz consiste em uma submatriz que termina na posição k ÿ1, seguida por
o elemento na posição k.
Neste último caso, como queremos encontrar um subarranjo com soma máxima, o
a submatriz que termina na posição k ÿ1 também deve ter a soma máxima. Por isso,
podemos resolver o problema de forma eficiente calculando a soma máxima do subarranjo
para cada posição final da esquerda para a direita.
O código a seguir implementa o algoritmo:
O algoritmo contém apenas um loop que passa pela entrada, então o tempo
complexidade é O (n). Esta é também a melhor complexidade de tempo possível, porque qualquer
algoritmo para o problema deve examinar todos os elementos do array pelo menos uma vez.
Comparação de eficiência
É interessante estudar o quão eficientes os algoritmos são na prática. A seguir
tabela mostra os tempos de execução dos algoritmos acima para diferentes valores de n
em um computador moderno.
Em cada teste, a entrada foi gerada aleatoriamente. O tempo necessário para a leitura
a entrada não foi medida.
3
Em [8], este algoritmo de tempo linear é atribuído a JB Kadane, e o algoritmo às vezes é
chamado algoritmo de Kadane.
23
Machine Translated by Google
24
Machine Translated by Google
Capítulo 3
Ordenação
Teoria de classificação
13829256
12235689
Sobre 2 ) algoritmos
Algoritmos simples para classificar um array funcionam em O(n 2 ) tempo. Tais algoritmos
são curtos e geralmente consistem em dois loops aninhados. Um famoso O(n 2 ) classificação de tempo
25
Machine Translated by Google
13829256
13289256
13282956
13282596
13282569
Inversões
Bubble sort é um exemplo de algoritmo de classificação que sempre troca elementos consecutivos
no array. Acontece que a complexidade de tempo de tal algoritmo é sempre pelo menos O(n
classificando o array. 2 ), porque no pior caso, O(n 2 ) são necessárias trocas para
26
Machine Translated by Google
12263598
tem três inversões: (6,3), (6,5) e (9,8). O número de inversões indica quanto
trabalho é necessário para classificar o array. Um array é completamente ordenado
quando não há inversões. Por outro lado, se os elementos do array estiverem na
ordem inversa, o número de inversões é o maior possível:
n(nÿ1) 2 1+2+···
+(nÿ1) = = O(n )
2
Algoritmos O(nlogn)
É possível ordenar um array de forma eficiente em tempo O(nlogn) usando algoritmos que não se
limitam à troca de elementos consecutivos. Um desses algoritmos é merge sort1 Merge sort
, que é baseado em recursão.
classifica um subarray array[a...b] da seguinte forma:
13628259
1362 8259
1236 2589
1De acordo com [47], a classificação por mesclagem foi inventada por J. von Neumann em 1945.
27
Machine Translated by Google
12235689
É possível classificar um array mais rápido do que no tempo O(nlogn) ? Acontece que isso não é
possível quando nos restringimos a algoritmos de classificação baseados na comparação de
elementos de array.
O limite inferior para a complexidade do 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. O processo cria a seguinte árvore:
x < y?
x < y? x < y?
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, num total de n! caminhos. Por esta
razão, a altura da árvore deve ser de pelo menos
Obtemos um limite inferior para esta soma escolhendo os últimos n/2 elementos e alterando o valor
de cada elemento para log2 (n/2). Isso produz uma estimativa
Classificação de contagem
O limite inferior nlogn não se aplica a algoritmos que não comparam elementos de array, mas usam
outras informações. Um exemplo de tal algoritmo é a classificação de contagem que classifica uma
matriz em tempo O(n) , assumindo que cada elemento da matriz é um número inteiro entre 0... c e c
= O(n).
O algoritmo cria um array contábil, cujos índices são elementos do array original. O algoritmo
itera pelo array original e calcula quantas vezes cada elemento aparece no array.
28
Machine Translated by Google
13699359
123456789
102011003
Classificando em C++
vetor<int> v = {4,2,5,3,5,8,3};
classificar(v.begin(),v.end());
classificar(v.rbegin(),v.rend());
interno n = 7; // tamanho do
array int a[] = {4,2,5,3,5,8,3};
ordenar(a,a+n);
29
Machine Translated by Google
string s = "macaco";
classificar(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 sort 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 saber a ordem de dois elementos.
A maioria dos tipos de dados C++ possui um operador de comparação integrado e os elementos
desses tipos podem ser classificados automaticamente. Por exemplo, os números são classificados
de acordo com seus valores e as strings são classificadas em ordem alfabética.
Os pares (par) são classificados principalmente de acordo com seus primeiros elementos (primeiros).
Porém, se os primeiros elementos de dois pares forem iguais, eles serão ordenados de acordo com
seus segundos elementos (segundo):
vetor<par<int,int>> v;
v.push_back({1,5});
v.push_back({2,3});
v.push_back({1,2});
classificar(v.begin(), v.end());
vetor<tupla<int,int,int>> v;
v.push_back({2,1,4});
v.push_back({1,5,3});
v.push_back({2,1,3});
classificar(v.begin(), v.end());
2Observe que em alguns compiladores mais antigos, a função make_tuple deve ser usada para criar uma tupla
em vez de colchetes (por exemplo, make_tuple(2,1,4) em vez de {2,1,4}).
30
Machine Translated by Google
estrutura P
{ int x, y;
operador bool<(const P &p) {
se (x! = px) retornar x < px; senão
retorne y < py;
}
};
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:
Pesquisa binária
Um método geral para procurar um elemento em um array é usar um loop for que itera
pelos elementos do array. Por exemplo, o código a seguir procura um elemento x em
uma matriz:
31
Machine Translated by Google
Método 1
A maneira usual de implementar a pesquisa binária é semelhante à procura de uma palavra em
um dicionário. A busca mantém uma região ativa no array, que inicialmente contém todos os
elementos do array. Em seguida, uma série de etapas são executadas, cada uma delas
reduzindo pela metade o tamanho da região.
A cada passo, a busca verifica o elemento intermediário da região ativa. Se o elemento do
meio for o elemento de destino, a pesquisa termina. Caso contrário, a busca continua
recursivamente para a metade esquerda ou direita da região, dependendo do valor do elemento
do meio.
A ideia acima pode ser implementada da seguinte forma:
int a = 0, b = n-1;
enquanto (a <= b)
{ int k = (a+b)/2; if
(array[k] == x) { // x
encontrado no índice k
Nesta implementação, a região ativa é a...b, e inicialmente a região é 0...n ÿ1. O algoritmo
reduz pela metade o tamanho da região em cada etapa, então a complexidade do tempo é
O(logn).
Método 2
Um método alternativo para implementar a pesquisa binária é baseado em uma maneira
eficiente de iterar pelos elementos do array. A ideia é dar saltos e diminuir a velocidade à
medida que nos aproximamos do elemento alvo.
A pesquisa percorre o array da esquerda para a direita e o comprimento inicial do salto é n/
2. A cada passo, o comprimento do salto será dividido pela metade: primeiro n/4, depois n/8, n/
16, etc., até que finalmente o comprimento seja 1. Após os saltos, ou o elemento alvo foi
encontrado ou sabemos que ele não aparece na matriz.
O código a seguir implementa a ideia acima:
int k = 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
}
32
Machine Translated by Google
Funções C++
A biblioteca padrão C++ contém as seguintes funções baseadas em pesquisa binária
e que funcionam em tempo logarítmico:
• lower_bound retorna um ponteiro para o primeiro elemento da matriz cujo valor está em
menos x.
As funções assumem que o array está classificado. Se não existir tal elemento, o ponteiro
aponta para o elemento após o último elemento da matriz. Por exemplo, o código a seguir
descobre se um array contém um elemento com valor x:
} int k = x+1;
33
Machine Translated by Google
A pesquisa encontra o maior valor de x para o qual ok(x) é falso. Assim, o próximo
valor k = x+1 é o menor valor possível para o qual ok(k) é verdadeiro. O comprimento
inicial do salto z deve ser grande o suficiente, por exemplo, algum valor para o qual
sabemos de antemão que ok(z) é verdadeiro.
O algoritmo chama a função ok O(log z) vezes, então a complexidade total do tempo
depende da função ok. Por exemplo, se a função funcionar em tempo O(n) , a
complexidade total do tempo será O(nlog z).
A ideia é usar a busca binária para encontrar o maior valor de x para o qual f (x)
< f (x+1). Isso implica que k = x+1 porque f (x+1) > f (x+2). O código a seguir
implementa a pesquisa:
intx = -1;
para (int b = z; b >= 1; b /= 2) {
enquanto (f(x+b) < f(x+b+1)) x += b;
} int k = x+1;
Observe que, diferentemente da pesquisa binária comum, aqui não é permitido que
valores consecutivos da função sejam iguais. Neste caso não seria possível saber como
continuar a busca.
34
Machine Translated by Google
Capítulo 4
Estruturas de dados
Matrizes dinâmicas
Um array dinâmico é um array cujo tamanho pode ser alterado durante a execução do programa. O
array dinâmico mais popular em C++ é a estrutura vetorial , que pode ser usada quase como um
array comum.
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]
35
Machine Translated by Google
vetor<int> v;
v.push_back(5);
v.push_back(2);
cout << v.back() << "\n"; // 2
v.pop_back();
cout << v.back() << "\n"; //5
vetor<int> v = {2,4,2,5,1};
Outra forma de criar um vetor é fornecer o número de elementos e o valor inicial de cada
elemento:
36
Machine Translated by Google
Definir estruturas
Um conjunto é uma estrutura de dados que mantém uma coleção de elementos. As operações
básicas dos conjuntos são inserção, pesquisa e remoção de elementos.
A biblioteca padrão C++ contém duas implementações de conjunto: O conjunto de estrutura
é baseado em uma árvore binária balanceada e suas operações funcionam em tempo O(logn) .
A estrutura unordered_set usa hashing e suas operações funcionam em tempo O(1)
na média.
A escolha de qual implementação de conjunto usar geralmente é uma questão de gosto.
O benefício da estrutura set é que ela mantém a ordem dos elementos e fornece funções que
não estão disponíveis em unordered_set. Por outro lado, unordered_set pode ser mais eficiente.
O código a seguir cria um conjunto que contém números inteiros e mostra algumas das
operações. A função insert adiciona um elemento ao conjunto, a função count retorna o
número de ocorrências de um elemento no conjunto e a função erase remove um elemento do
conjunto.
definir<int> s;
s.inserir(3);
s.inserir(2);
s.inserir(5);
cout << s.count(3) << "\n"; // 1 cout <<
s.count(4) << "\n"; // 0 s.erase(3);
s.inserir(4);
cout <<
s.count(3) << "\n"; // 0 cout << s.count(4)
<< "\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 cria um conjunto, imprime o número de
elementos nele e, em seguida, itera por todos os elementos:
Uma propriedade importante dos conjuntos é que todos os seus elementos são
distintos. Assim, a função count 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 ele já estiver lá. O código a seguir ilustra isso:
definir<int> s;
s.inserir(5);
s.inserir(5);
s.inserir(5);
cout << s.count(5) << "\n"; //1
37
Machine Translated by Google
multiset<int> s;
s.inserir(5);
s.inserir(5);
s.inserir(5);
cout << s.count(5) << "\n"; //3
s.apagar(5);
cout << s.count(5) << "\n"; //0
Muitas vezes, apenas uma instância deve ser removida, o que pode ser feito da seguinte forma:
s.erase(s.find(5)); cout
<< s.count(5) << "\n"; //2
Estruturas de mapas
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á automaticamente
adicionada ao mapa com um valor padrão. Por exemplo, no código a seguir, a chave “aybabtu” com valor
0 é adicionada ao mapa.
mapa<string,int> m;
cout << m["aybabtu"] << "\n"; //0
38
Machine Translated by Google
if (m.count("aybabtu")) { // a chave
existe
}
para (auto x: m) {
""
cout << x.primeiro << << x.segundo << "\n";
}
Iteradores e intervalos
Muitas funções na biblioteca padrão C++ operam com iteradores. Um iterador é uma variável que aponta
para um elemento em uma estrutura de dados.
Os iteradores frequentemente usados, start e end , definem um intervalo que contém
todos os elementos em uma estrutura de dados. O início do iterador aponta para o primeiro
elemento na estrutura de dados e o final do iterador aponta para a posição após o último elemento.
A situação é a seguinte:
ÿ s.begin()
classificar(v.begin(), v.end());
reverso(v.begin(), v.end());
random_shuffle(v.begin(), v.end());
Essas funções também podem ser usadas com um array comum. Nesse caso, as funções recebem
ponteiros para o array em vez de iteradores:
39
Machine Translated by Google
ordenar(um,
um+n); reverso(a,
a+n); random_shuffle(a, a+n);
Definir iteradores
Iteradores são frequentemente usados para acessar elementos de um conjunto. O código a seguir cria um
iterador que aponta para o menor elemento de um conjunto:
set<int>::iterador it = s.begin();
auto it = s.begin();
O elemento para o qual um iterador aponta pode ser acessado usando o símbolo * . Por
exemplo, o código a seguir imprime o primeiro elemento do conjunto:
40
Machine Translated by Google
automático it = s.lower_bound(x);
if (it == s.begin()) {
cout << *it << "\n"; } else
if (it == s.end()) { it--; cout << *it <<
"\n"; }
else { int a = *it; isto--; int
b = *isto;
if (xb <ax) cout << b
<< "\n"; senão
cout << a << "\n";
O código assume que o conjunto não está vazio e passa por todos os casos
possíveis usando um iterador . Primeiro, o iterador aponta para o menor elemento
cujo valor é pelo menos x. Se for igual a start, o elemento correspondente é o mais
próximo de x. Se for igual a end, o maior elemento do conjunto está mais próximo
de x. Se nenhum dos casos anteriores for válido, o elemento mais próximo de x é o
elemento que lhe corresponde ou o elemento anterior.
Outras estruturas
Conjunto de bits
Um bitset é uma matriz cujo valor é 0 ou 1. Por exemplo, o código a seguir cria um bitset
que contém 10 elementos:
conjunto de
bits<10>s;
s[1] = 1;
s[3] = 1;
s[4] = 1;
s[7] = 1; cout << s[4] << "\n"; // 1
cout << s[5] << "\n"; //0
A vantagem de usar bitsets é que eles requerem menos memória do que arrays comuns,
porque cada elemento em um bitset usa apenas um bit de memória. Por exemplo, se n bits
forem armazenados em uma matriz int , 32n bits de memória serão usados, mas um conjunto
de bits correspondente requer apenas n bits de memória. Além disso, os valores de um
conjunto de bits podem ser manipulados de forma eficiente usando operadores de bits, o que
possibilita otimizar algoritmos usando conjuntos de bits.
O código a seguir mostra outra maneira de criar o bitset acima:
41
Machine Translated by Google
Deque
Um deque é um array dinâmico cujo tamanho pode ser alterado com eficiência em ambas
as extremidades do array. Assim como um vetor, um deque fornece as funções push_back
e pop_back, mas também inclui 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 maneira:
deque<int> d;
d.push_back(5); // [5]
d.push_back(2); // [5,2]
d.push_front(3); // [3,5,2]
d.pop_back(); // [3,5]
d.pop_front(); // [5]
A implementação interna de um deque é mais complexa que a de um vetor e, por esse motivo, um
deque é mais lento que um vetor. Ainda assim, tanto a adição quanto a remoção de elementos levam
tempo O(1) em média em ambas as extremidades.
Pilha
Uma pilha é uma estrutura de dados que fornece duas operações de tempo O(1):
adicionar um elemento ao topo e remover um elemento do topo. Só é possível
acessar o elemento superior de uma pilha.
O código a seguir mostra como uma pilha pode ser usada:
pilha<int> s;
s.push(3);
s.push(2);
s.push(5);
cout << s.top(); // 5 s.pop();
cout <<
s.top(); //2
42
Machine Translated by Google
Fila
Uma fila também fornece duas operações de tempo O(1): adicionar um elemento ao
final da fila e remover o primeiro elemento da fila. Só é possível acessar o primeiro
e o último elemento de uma fila.
O código a seguir mostra como uma fila pode ser usada:
fila<int> q;
q.push(3);
q.push(2);
q.push(5);
cout << q.front(); //3 q.pop();
cout <<
q.front(); //2
Fila de prioridade
Uma fila prioritária mantém um conjunto de elementos. As operações suportadas são inserção
e, dependendo do tipo de fila, recuperação e remoção do elemento mínimo ou máximo. A
inserção e a remoção levam tempo O(logn) e a recuperação leva tempo O(1).
prioridade_queue<int> q;
q.push(3);
q.push(5);
q.push(7);
q.push(2);
cout << q.top() << "\n"; // 7 q.pop();
cout <<
q.top() << "\n"; // 5 q.pop(); q.push(6);
cout <<
q.top() <<
"\n"; // 6 q.pop();
Se quisermos criar uma fila de prioridade que suporte a localização e remoção do menor
elemento, podemos fazer o seguinte:
prioridade_queue<int,vetor<int>,maior<int>> q;
43
Machine Translated by Google
O compilador g++ também oferece suporte a algumas estruturas de dados que não fazem parte da
biblioteca padrão C++. Tais estruturas são chamadas de estruturas de dados baseadas em políticas.
Para utilizar essas estruturas, as seguintes linhas devem ser adicionadas ao código:
Depois disso, podemos definir uma estrutura de dados indexed_set que é semelhante a set ,
mas pode ser indexada 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;
indexed_sets;
s.inserir(2);
s.inserir(3);
s.inserir(7);
s.inserir(9);
A especialidade deste conjunto é que temos acesso aos índices que os elementos
teriam em um array ordenado. A função find_by_order retorna um iterador para o
elemento em uma determinada posição:
automático x = s.find_by_order(2);
cout << *x << "\n"; //7
Caso o elemento não apareça no conjunto, obtemos a posição que o elemento ocuparia no
conjunto:
44
Machine Translated by Google
A = [5,2,8,9,4] e B = [3,2,9,5],
Algoritmo 1
Construímos um conjunto de elementos que aparecem em A e, depois disso, iteramos
através dos elementos de B e verifique se cada elemento também pertence a A.
Isso é eficiente porque os elementos de A estão em um conjunto. Usando a estrutura do conjunto ,
a complexidade de tempo do algoritmo é O (nlogn).
Algoritmo 2
Não é necessário manter um conjunto ordenado, então em vez da estrutura do conjunto
também podemos usar a estrutura unordered_set . Esta é uma maneira fácil de fazer o
algoritmo mais eficiente, porque só precisamos alterar os dados subjacentes
estrutura. A complexidade de tempo do novo algoritmo é O(n).
Algoritmo 3
Em vez de estruturas de dados, podemos usar classificação. Primeiro, classificamos as listas A e
B. Depois disso, iteramos pelas duas listas ao mesmo tempo e encontramos o
elementos comuns. A complexidade de tempo da classificação é O (nlogn), e o resto
o algoritmo funciona em tempo O(n) , então a complexidade total do tempo é O(nlogn).
Comparação de eficiência
A tabela a seguir mostra quão eficientes são os algoritmos acima quando n varia
e os elementos das listas são inteiros aleatórios entre 1...109 :
Os algoritmos 1 e 2 são iguais, exceto pelo fato de usarem estruturas de conjuntos diferentes. Em
este problema, esta escolha tem um efeito importante no tempo de execução, porque
O Algoritmo 2 é 4–5 vezes mais rápido que o Algoritmo 1.
No entanto, o algoritmo mais eficiente é o Algoritmo 3, que utiliza classificação.
Ele usa apenas metade do tempo em comparação com o Algoritmo 2. Curiosamente, o tempo
a complexidade do Algoritmo 1 e do Algoritmo 3 é O (nlogn), mas apesar disso,
O algoritmo 3 é dez vezes mais rápido. Isso pode ser explicado pelo fato de que a classificação é uma
45
Machine Translated by Google
46
Machine Translated by Google
capítulo 5
Pesquisa completa
A pesquisa completa é um método geral que pode ser usado para resolver quase
qualquer problema de algoritmo. A ideia é gerar todas as soluções possíveis para o
problema usando força bruta e depois selecionar a melhor solução ou contar o número
de soluções, dependendo do problema.
A pesquisa completa é uma boa técnica se houver tempo suficiente para percorrer
todas as soluções, pois a pesquisa geralmente é fácil de implementar e sempre dá a
resposta correta. Se a busca completa for muito lenta, outras técnicas, como algoritmos
gananciosos ou programação dinâmica, poderão ser necessárias.
Gerando subconjuntos
Consideramos primeiro o problema de gerar todos os subconjuntos de um conjunto de n
elementos. Por exemplo, os subconjuntos de {0,1,2} são, {0}, {1}, {2}, {0,1}, {0,2}, {1,2} e {0,1, 2}.
Existem dois métodos comuns para gerar subconjuntos: podemos realizar uma
pesquisa recursiva ou explorar a representação de bits de inteiros.
Método 1
Uma maneira elegante de percorrer todos os subconjuntos de um conjunto é usar recursão.
A seguinte função de pesquisa gera os subconjuntos do conjunto {0,1,...,n ÿ 1}. A função
mantém um subconjunto vetorial que conterá os elementos de cada subconjunto.
A pesquisa começa quando a função é chamada com parâmetro 0.
void search(int k) { if (k
== n) { //
processar
subconjunto } else
{ search(k+1);
47
Machine Translated by Google
pesquisa(0)
pesquisa(1) pesquisa(1)
Método 2
Outra forma de gerar subconjuntos é baseada na representação de bits de inteiros.
Cada subconjunto de um conjunto de n elementos pode ser representado como uma sequência de n bits,
n
que corresponde a um número inteiro entre 0...2 ÿ1. Aqueles na sequência de bits
indique quais elementos estão incluídos no subconjunto.
A convenção usual é que o último bit corresponde ao elemento 0, o segundo
o último bit corresponde ao elemento 1 e assim por diante. Por exemplo, a representação de bits
de 25 é 11001, que corresponde ao subconjunto {0,3,4}.
O código a seguir percorre os subconjuntos de um conjunto de n elementos
48
Machine Translated by Google
Gerando permutações
A seguir consideramos o problema de gerar todas as permutações de um conjunto de n elementos.
Por exemplo, as permutações de {0,1,2} são (0,1,2), (0,2,1), (1,0,2), (1,2,0), (2,0 ,1)
e (2,1,0). Novamente, existem duas abordagens: podemos usar recursão ou
percorrer as permutações iterativamente.
Método 1
Assim como os subconjuntos, as permutações podem ser geradas usando recursão.
A seguinte pesquisa de função passa pelas permutações do conjunto {0,1,...,n ÿ 1}.
A função constrói uma permutação vetorial que contém a permutação, e 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
= 0; i < n;
i++) { if (chosen[i]) continue;
escolhido[i] = verdadeiro;
permutação.push_back(i);
procurar(); escolhido[i] = falso;
permutação.pop_back();
}
}
}
Método 2
Outro método para gerar permutações é começar com a permutação {0,1,...,n ÿ 1} e
usar repetidamente uma função que constrói a próxima permutação em ordem
crescente. A biblioteca padrão C++ contém a função next_permutation que pode ser
usada para isso:
} do
{ // permutação do processo
} while (next_permutation(permutation.begin(),permutation.end()));
49
Machine Translated by Google
Retrocesso
Um algoritmo de retrocesso começa com uma solução vazia e estende o
solução passo a passo. A pesquisa percorre recursivamente todas as maneiras diferentes de como
uma solução pode ser construída.
Como exemplo, considere o problema de calcular o número de maneiras n
as rainhas podem ser colocadas em um tabuleiro de xadrez n× n de modo que não haja duas rainhas atacando cada uma
P P
P P
P P
P P
P P P P
P P P P
P P P P
50
Machine Translated by Google
void search(int y) { if (y
== n) { contagem+
+;
retornar;
Podando a pesquisa
Muitas vezes podemos otimizar o retrocesso podando a árvore de pesquisa. A ideia
é adicionar “inteligência” ao algoritmo para que ele perceba o mais rápido possível
se uma solução parcial não pode ser estendida para uma solução completa. Essas
otimizações podem ter um efeito tremendo na eficiência da pesquisa.
1Não há nenhuma maneira conhecida de calcular eficientemente valores maiores de q(n). O recorde
atual é q(27) = 234907967154122528, calculado em 2016 [55].
51
Machine Translated by Google
Algoritmo básico
A primeira versão do algoritmo não contém nenhuma otimização. Simplesmente usamos o retrocesso
para gerar todos os caminhos possíveis do canto superior esquerdo ao canto inferior direito e
contamos o número de tais caminhos.
Otimização 1
Em qualquer solução, primeiro movemos um passo para baixo ou para a direita. Sempre
há dois caminhos simétricos em relação à diagonal da grade após a primeira etapa. Por
exemplo, os seguintes caminhos são simétricos:
Portanto, podemos decidir que primeiro movemos sempre um passo para baixo (ou para a direita)
e, finalmente, multiplicamos o número de soluções por dois.
52
Machine Translated by Google
Otimização 2
Se o caminho atingir o quadrado inferior direito antes de ter visitado todos os outros
quadrados da grade, fica claro que não será possível completar a solução. Um
exemplo disso é o seguinte caminho:
Otimização 3
Se o caminho tocar uma parede e puder virar para a esquerda ou para a direita, a grade se
divide em duas partes que contêm quadrados não visitados. Por exemplo, na seguinte situação,
o caminho pode virar à esquerda ou à direita:
Neste caso, não podemos mais visitar todas as praças, portanto podemos encerrar a busca.
Esta otimização é muito útil:
Otimização 4
A ideia da Otimização 3 pode ser generalizada: se o caminho não puder continuar em frente,
mas puder virar à esquerda ou à direita, a grade se divide em duas partes, ambas contendo
quadrados não visitados. Por exemplo, considere o seguinte caminho:
53
Machine Translated by Google
É claro que não podemos mais visitar todas as praças, então podemos encerrar a busca. Após essa
otimização, a busca fica muito eficiente:
Agora é um bom momento para parar de otimizar o algoritmo e ver o que conseguimos . O tempo de
execução do algoritmo original era de 483 segundos e agora, após as otimizações, o tempo de
execução é de apenas 0,6 segundos. Assim, o algoritmo tornou-se quase 1000 vezes mais rápido
após as otimizações.
Este é um fenômeno comum no retrocesso, porque a árvore de busca geralmente é grande e até
mesmo observações simples podem efetivamente podar a busca. Especialmente úteis são as
otimizações que ocorrem durante as primeiras etapas do algoritmo, ou seja, no topo da árvore de
busca.
Encontre-se no meio
Meet in the middle é uma técnica onde o espaço de busca é dividido em duas partes de tamanho
aproximadamente igual. Uma pesquisa separada é realizada para ambas as partes e, finalmente, os
resultados das pesquisas são combinados.
A técnica pode ser utilizada se houver uma forma eficiente de combinar os resultados
das pesquisas. Nessa situação, as duas pesquisas podem exigir menos tempo do que uma
pesquisa grande. Normalmente, podemos transformar um fator de 2n em um fator de 2n/2
usando a técnica de encontro no meio.
Como exemplo, considere um problema em que nos é dada uma lista de n
números e um número x, e queremos descobrir se é possível escolher alguns
números da lista de modo que a sua soma seja x. Por exemplo, dada a lista [2,4,5,9]
e x = 15, podemos escolher os números [2,4,9] para obter 2+4+9 = 15. No entanto,
se x = 10 para o mesma lista, não é possível formar a soma.
Um algoritmo simples para o problema é percorrer todos os subconjuntos dos elementos e
verificar se a soma de algum dos subconjuntos é x. O tempo de execução de tal algoritmo é O(2n ),
porque existem 2n subconjuntos. No entanto, usando a técnica meet in the middle, podemos obter
um algoritmo de tempo O(2n/2) mais eficiente2 . Observe que O(2n ) e O(2n/2) são complexidades
diferentes porque 2n/2 é igual a 2 n.
54
Machine Translated by Google
Podemos implementar o algoritmo para que sua complexidade de tempo seja O(2n/2).
Primeiro, geramos listas ordenadas SA e SB, o que pode ser feito em tempo O(2n/2) usando uma
técnica semelhante a mesclagem. Depois disso, como as listas estão ordenadas, podemos
verificar no tempo O(2n/2) se a soma x pode ser criada a partir de SA e SB.
55
Machine Translated by Google
56
Machine Translated by Google
Capítulo 6
Algoritmos gananciosos
Um algoritmo ganancioso constrói uma solução para o problema sempre fazendo a escolha
que parece ser a melhor no momento. Um algoritmo ganancioso nunca retira suas escolhas, mas
constrói diretamente a solução final. Por esta razão, algoritmos gananciosos são geralmente
muito eficientes.
A dificuldade em projetar algoritmos gulosos é encontrar uma estratégia gananciosa que
sempre produza uma solução ótima para o problema. As escolhas localmente ótimas em um
algoritmo ganancioso também devem ser globalmente ótimas. Muitas vezes é difícil argumentar
que um algoritmo ganancioso funciona.
Problema de moeda
{1,2,5,10,20,50,100,200}
Algoritmo ganancioso
Um algoritmo simples e ganancioso para o problema sempre seleciona a maior moeda possível,
até que a soma de dinheiro necessária seja construída. Este algoritmo funciona no caso do
exemplo, porque primeiro selecionamos duas moedas de 200 cêntimos, depois uma moeda de
100 cêntimos e finalmente uma moeda de 20 cêntimos. Mas esse algoritmo sempre funciona?
Acontece que se as moedas forem moedas de euro, o algoritmo ganancioso funciona sempre,
ou seja, produz sempre uma solução com o menor número possível de moedas.
A correção do algoritmo pode ser mostrada da seguinte forma:
Primeiro, cada moeda 1, 5, 10, 50 e 100 aparece no máximo uma vez em uma
solução ótima, porque se a solução contivesse duas dessas moedas, poderíamos substituir
57
Machine Translated by Google
troque-os por uma moeda e obtenha uma solução melhor. Por exemplo, se a solução contivesse
moedas 5+5, poderíamos substituí-las pela moeda 10.
Da mesma forma, as moedas 2 e 20 aparecem no máximo duas vezes numa solução ótima,
porque poderíamos substituir as moedas 2+2+2 pelas moedas 5+1 e as moedas 20+20+20 pelas
moedas 50 + 10. Além disso, uma moeda 20+20+20 por moedas 50 + 10. solução não pode conter
moedas 2 + 2 + 1 ou 20+20+10, porque poderíamos substituí-las pelas moedas 5 e 50.
Usando estas observações, podemos mostrar para cada moeda x que não é possível
construir de forma otimizada uma soma x ou qualquer soma maior usando apenas moedas
menores que x. Por exemplo, se x = 100, a maior soma ótima usando as moedas menores
é 50+20+20+5+2+2 = 99. Assim, o algoritmo ganancioso que sempre seleciona a moeda
maior produz a solução ótima.
Este exemplo mostra que pode ser difícil argumentar que um algoritmo ganancioso
funciona, mesmo que o algoritmo em si seja simples.
Caso Geral
No caso geral, o conjunto de moedas pode conter quaisquer moedas e o algoritmo ganancioso
não produz necessariamente uma solução ótima.
Podemos provar que um algoritmo ganancioso não funciona mostrando um contra-exemplo
onde o algoritmo dá uma resposta errada. Neste problema podemos facilmente encontrar um
contra-exemplo: se as moedas forem {1,3,4} e a soma alvo for 6, o algoritmo ganancioso
produz a solução 4+1+1 enquanto a solução ótima é 3+3.
Não se sabe se o problema geral da moeda pode ser resolvido usando algum algoritmo
ganancioso1 . Contudo, como veremos no Capítulo 7, em alguns casos, o problema geral pode ser
resolvido de forma eficiente usando um algoritmo de programação dinâmica que sempre dá a
resposta correta.
Agendamento
Muitos problemas de escalonamento podem ser resolvidos usando algoritmos gananciosos.
Um problema clássico é o seguinte: dados n eventos com horários de início e término,
encontre um cronograma que inclua tantos eventos quanto possível. Não é possível
selecionar parcialmente um evento. Por exemplo, considere os seguintes eventos:
Neste caso o número máximo de eventos é dois. Por exemplo, podemos selecionar os eventos
B e D da seguinte forma:
1No entanto, é possível verificar em tempo polinomial se o algoritmo guloso apresentado neste
capítulo funciona para um determinado conjunto de moedas [53].
58
Machine Translated by Google
A
B
C
D
É possível inventar vários algoritmos gananciosos para o problema, mas qual deles funciona em
todos os casos?
Algoritmo 1
A primeira ideia é selecionar eventos tão curtos quanto possível. No caso de exemplo, este
algoritmo seleciona os seguintes eventos:
A
B
C
D
Contudo, selecionar eventos curtos nem sempre é uma estratégia correta. Por exemplo,
o algoritmo falha no seguinte caso:
Algoritmo 2
Outra ideia é sempre selecionar o próximo evento possível que comece o mais cedo
possível. Este algoritmo seleciona os seguintes eventos:
A
B
C
D
No entanto, podemos encontrar um contra-exemplo também para este algoritmo. Por exemplo,
no caso a seguir, o algoritmo seleciona apenas um evento:
Se selecionarmos o primeiro evento, não será possível selecionar nenhum outro evento.
Porém, seria possível selecionar os outros dois eventos.
59
Machine Translated by Google
Algoritmo 3
A terceira ideia é sempre selecionar o próximo evento possível que termine o mais cedo
possível. Este algoritmo seleciona os seguintes eventos:
A
B
C
D
Acontece que este algoritmo sempre produz uma solução ótima. A razão para isto é
que é sempre uma escolha ideal selecionar primeiro um evento que termine o mais cedo
possível. Depois disso, é uma escolha ideal selecionar o próximo evento usando a
mesma estratégia, etc., até que não possamos selecionar mais nenhum evento.
Uma forma de argumentar que o algoritmo funciona é considerar o que acontece se
selecionarmos primeiro um evento que termina mais tarde do que aquele que termina o mais cedo possível.
Agora, teremos no máximo um número igual de opções sobre como podemos selecionar o próximo
evento. Conseqüentemente, selecionar um evento que termina mais tarde nunca pode produzir uma
solução melhor, e o algoritmo ganancioso está correto.
Tarefas e prazos
0 5 10
C B A D
60
Machine Translated by Google
X S
a b
YX
b a
Minimizando somas
A seguir consideramos um problema onde recebemos n números a1,a2,...,an e nossa
tarefa é encontrar um valor x que minimize a soma
c c c.
|a1 ÿx | +|a2 ÿx | +··· +|an ÿ x|
Caso c = 1
Neste caso, devemos minimizar a soma
No caso geral, a melhor escolha para x é a mediana dos números, ou seja, o número
do meio após a classificação. Por exemplo, a lista [1,2,9,2,6] torna-se [1,2,2,6,9] após
a classificação, então a mediana é 2.
A mediana é uma escolha ótima, porque se x for menor que a mediana, a soma
se tornará menor ao aumentar x, e se x for maior que a mediana, a soma se tornará
menor ao diminuir x. Portanto, a solução ótima é que x é a mediana. Se n for par e
houver duas medianas, ambas as medianas e todos os valores entre elas serão
escolhas ótimas.
Caso c = 2
Neste caso, devemos minimizar a soma
(a1 ÿ x) 2 +(a2 ÿ x) 2
+··· +(um ÿ x) 2.
61
Machine Translated by Google
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 .
Compressão de dados
Um código binário atribui para cada caractere de uma string uma palavra-código que consiste em bits.
Podemos compactar a string usando o código binário, substituindo cada caractere pela palavra-código
correspondente. Por exemplo, o código binário a seguir atribui palavras-código para os caracteres A – D:
palavra-código de caractere
A 00
B 01
C 10
D 11
Este é um código de comprimento constante , o que significa que o comprimento de cada palavra-código
é o mesmo. Por exemplo, podemos compactar a string AABACDACA da seguinte forma:
000001001011001000
Usando este código, o comprimento da string compactada é de 18 bits. No entanto, podemos compactar
melhor a string se usarmos um código de comprimento variável , onde as palavras-código podem ter
comprimentos diferentes. Então podemos fornecer palavras-código curtas para caracteres que aparecem
com frequência e palavras-código longas para caracteres que aparecem raramente. Acontece que um
código ideal para a string acima é o seguinte:
palavra-código do caractere
A 0
B 110
C 10
D 111
Um código ideal produz uma string compactada o mais curta possível. Neste caso, a string compactada
usando o código ideal é
001100101110100,
62
Machine Translated by Google
portanto, apenas 15 bits são necessários em vez de 18 bits. Assim, graças a um código melhor foi
possível salvar 3 bits na string compactada.
Exigimos que nenhuma palavra-código seja um prefixo de outra palavra-código. Por exemplo,
não é permitido que um código contenha as palavras-código 10 e 1011. O
a razão para isso é que queremos ser capazes de gerar a string original a partir de
a string compactada. Se uma palavra-código pudesse ser um prefixo de outra palavra-código, isso
nem sempre seria possível. Por exemplo, o código a seguir não é válido:
palavra-código de caractere
A 10
B 11
C 1011
D 111
Usando este código, não seria possível saber se a string compactada 1011
corresponde à string AB ou à string C.
Codificação de Huffman
5 1 2 1
A B C D
2
0 1
5 2 1 1
A C B D
2D. A. Huffman descobriu esse método ao resolver uma tarefa de um curso universitário e
publicou o algoritmo em 1952 [40].
63
Machine Translated by Google
4
0 1
2 2
0 1
C
5 1 1
A B D
9
0 1
5 4
0 1
A
2 2
0 1
C
1 1
B D
Agora todos os nós estão na árvore, então o código está pronto. As seguintes palavras-código
pode ser lido na árvore:
palavra-código de caractere
A 0
B 110
C 10
D 111
64
Machine Translated by Google
Capítulo 7
Programaçao dinamica
A programação dinâmica é uma técnica que combina a correção da busca completa e a eficiência
de algoritmos gananciosos. A programação dinâmica pode ser aplicada se o problema puder ser
dividido em subproblemas sobrepostos que possam ser resolvidos de forma independente.
• Encontrar uma solução ótima: Queremos encontrar uma solução que seja tão grande ou tão
pequena quanto possível.
Veremos primeiro como a programação dinâmica pode ser usada para encontrar um valor ótimo
solução, e então usaremos a mesma ideia para contar as soluções.
Compreender a programação dinâmica é um marco na carreira de todo programador competitivo.
Embora a ideia básica seja simples, o desafio é como aplicar a programação dinâmica a diferentes
problemas. Este capítulo apresenta um conjunto de problemas clássicos que são um bom ponto de
partida.
Problema de moeda
65
Machine Translated by Google
Formulação recursiva
resolver(0) = 0
resolver(1) = 1
resolver(2) = 2
resolver(3) = 1
resolver(4) = 1
resolver(5) = 2
resolver(6) = 2
resolver(7) = 2
resolver( 8) = 2
resolver(9) = 3
resolver(10) = 3
Por exemplo, resolve(10) = 3, porque são necessárias pelo menos 3 moedas para formar a soma
10. A solução ideal é 3+3+4 = 10.
A propriedade essencial de resolver é que seus valores podem ser calculados
recursivamente a partir de seus valores menores. A ideia é focar na primeira moeda
que escolhermos para a soma. Por exemplo, no cenário acima, a primeira moeda
pode ser 1, 3 ou 4. Se escolhermos primeiro a moeda 1, a tarefa restante é formar a
soma 9 usando o número mínimo de moedas, o 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:
resolver(x) = min(resolver(xÿ1)+1,
resolver(xÿ3)+1,
resolver(xÿ4)+1).
Agora estamos prontos para fornecer uma função recursiva geral que calcula o
número mínimo de moedas necessárias para formar uma soma x:
ÿÿ
x<0
resolver(x) = ÿÿÿ
0 x=0
ÿÿÿ
mincÿmoedas resolvem (xÿ c)+1 x > 0
66
Machine Translated by Google
soma vazia. Por fim, se x > 0, a variável c percorre todas as possibilidades de como
escolher a primeira moeda da soma.
Uma vez encontrada uma função recursiva que resolva o problema, podemos
implementar diretamente uma solução em C++ (a constante INF denota infinito):
int resolver(int x) { se
(x < 0) retornar INF; se (x
== 0) retornar 0; int melhor
= INF; for (auto c:
moedas) {melhor =
min(melhor, resolver(xc)+1);
} retornar melhor;
}
Ainda assim, esta função não é eficiente, porque pode haver um número exponencial de maneiras
de construir a soma. Porém, a seguir veremos como tornar a função eficiente usando uma técnica
chamada memoização.
Usando memoização
A ideia da programação dinâmica é usar a memorização para calcular com
eficiência os valores de uma função recursiva. Isso significa que os valores da função são
armazenados em uma matriz após calculá-los. Para cada parâmetro, o valor da função é calculado
recursivamente apenas uma vez e, depois disso, o valor pode ser recuperado diretamente do array.
bool pronto[N];
valor interno [N];
int resolver(int x) { se
(x < 0) retornar INF; se (x
== 0) retornar 0; if
(pronto[x]) retornar valor[x]; int melhor
= INF; for (auto c:
moedas) {melhor =
min(melhor, resolver(xc)+1);
} valor[x] = melhor;
pronto[x] = verdadeiro;
retornar melhor;
}
67
Machine Translated by Google
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);
}
}
}
int primeiro[N];
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;
}
}
}
68
Machine Translated by Google
Depois disso, o seguinte código pode ser usado para imprimir as moedas que aparecem
em uma solução ótima para a soma n:
enquanto (n > 0)
{ cout << primeiro[n] << "\n"; n
-= primeiro[n];
}
• 1+1+1+1+1 • 3+1+1
• 1+1+3 • 1+4
• 1+3+1 • 4+1
+ resolver (x ÿ 3) +
resolver (xÿ4).
ÿ0 x<0
resolver(x) = ÿÿÿ
1 x=0
ÿÿÿ
contagem[0] =
1; for (int x = 1; x <= n; x++) { for (auto
c : moedas) { if (xc >= 0)
{ contagem[x] +=
contagem[xc];
}
}
}
69
Machine Translated by Google
contagem[x] %=m;
depois da linha
contagem[x] += contagem[xc];
01234567
62517483
comprimento (0) = 1
comprimento (1) = 1
comprimento (2) = 2
comprimento (3) = 1
comprimento (4) = 3
comprimento (5) = 2
comprimento (6) = 4
comprimento (7) = 2
70
Machine Translated by Google
Para calcular um valor de comprimento(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 esta é uma maneira ideal de adicionar array[k] a uma subsequência.
No entanto, se não existir tal posição i, então comprimento(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 dos seus valores menores,
podemos usar programação dinâmica. No código a seguir, os valores da função serão
armazenados em um comprimento de array.
Este código funciona em O(n2 ) tempo, porque consiste em dois loops aninhados. Contudo ,
também é possível implementar o cálculo de programação dinâmica de forma mais eficiente
em tempo O(nlogn) . Você pode encontrar uma maneira de fazer isso?
Nosso próximo problema é encontrar um caminho do canto superior esquerdo até o canto inferior
direito de uma grade n × n , de modo que nos movamos apenas para baixo e para a direita. Cada
quadrado contém um número inteiro positivo e o caminho deve ser construído de forma que a
soma dos valores ao longo do caminho seja a maior possível.
A imagem a seguir mostra um caminho ideal em uma grade:
37927
98355
17985
3 8 6 4 10
63978
71
Machine Translated by Google
int soma[N][N];
Problemas de mochila
O termo mochila refere-se a problemas onde 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, nos concentramos no seguinte problema: Dada uma lista de pesos [w1,w2,...,wn],
determine todas as somas que podem ser construídas usando os pesos.
Por exemplo, se os pesos forem [1,3,3,5], as seguintes somas são possíveis:
0 1 2 3 4 5 6 7 8 9 10 11 12
XXXXXXXXX XX
Neste caso, todas as somas entre 0...12 são possíveis, exceto 2 e 10. Para
por exemplo, a soma 7 é possível porque podemos selecionar os pesos [1,3,3].
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 da seguinte forma:
72
Machine Translated by Google
A fórmula é baseada no fato de que podemos usar ou não o peso sem na soma. Se
usarmos wk, a tarefa restante é formar a soma x ÿ wk usando os primeiros k ÿ1
pesos, e se não usarmos wk, a tarefa restante é formar a soma x usando os
primeiros k ÿ1 pesos. Como os casos base,
verdadeiro x = 0
possível(x,0) =
falso x = 0
k\x 0 1 2 3 4 5 6 7 8 9 10 11 12
0X
1 XX
2XXXX
3XXXXXX
4XXXXXXXXX XX
possível[0][0] = verdadeiro;
for (int k = 1; k <= n; k++) {
para (int x = 0; x <= W; x++) {
se (xw[k] >= 0) possível[x][k] |= possível[xw[k]][k-1]; possível[x][k] |=
possível[x][k-1];
}
}
No entanto, aqui está uma implementação melhor que usa apenas uma matriz
unidimensional Possible[x] que indica se podemos construir um subconjunto com a 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++) {
para (int x = W; x >= 0; x--) {
if (possível[x]) possível[x+w[k]] = verdadeiro;
}
}
Observe que a ideia geral apresentada aqui pode ser usada em muitos problemas
de mochila. Por exemplo, se recebermos objetos com pesos e valores, podemos
determinar para cada soma de pesos a soma do valor máximo de um subconjunto.
73
Machine Translated by Google
Editar distância
A distância de edição ou distância de Levenshtein1 é o número mínimo de
operações de edição necessárias para transformar uma string em outra string. As
operações de edição permitidas são as seguintes:
Por exemplo, a distância de edição entre LOVE e MOVIE é 2, pois podemos primeiro realizar a
operação LOVE ÿ MOVE (modificar) e depois a operação MOVE ÿ MOVIE (inserir). Este é o menor número
possível de operações, pois fica claro que apenas uma operação não é suficiente.
Suponha que recebamos uma string x de comprimento n e uma string y de comprimento m, e queremos
calcular a distância de edição entre x e y. Para resolver o problema, definimos uma função distance(a,b)
que fornece a distância de edição entre os prefixos x[0...a] e y[0...b]. Assim, usando esta função, a distância
de edição entre x e y é igual à distância (nÿ1,mÿ1).
+1, distância(aÿ1,b
ÿ1)+custo(a,b)).
0 1 2 3 4 5
eu 1 1 2 3 4 5
Ó 2 2 1 2 3 4
V 3 3 2 1 2 3
E 4 4 3 2 2 2
1A distância tem o nome de VI Levenshtein que a estudou em conexão com códigos binários
[49].
74
Machine Translated by Google
O canto inferior direito da tabela nos diz que a distância de edição entre LOVE e
MOVIE é 2. A tabela também mostra como construir a sequência mais curta de operações
de edição. Neste caso o caminho é o seguinte:
FILME
0 1 2 3 4 5
eu 1 1 2 3 4 5
Ó 2 2 1 2 3 4
V 3 3 2 1 2 3
E 4 4 3 2 2 2
Contando coisas
Às vezes, os estados de uma solução de programação dinâmica são mais complexos do que
combinações fixas de números. Como exemplo, considere o problema de calcular o número de
maneiras distintas de preencher uma grade n× m usando blocos de tamanho 1×2 e 2×1 . Por
exemplo, uma solução válida para a grade 4×7 é
Deixe count(k, x) denotar o número de maneiras de construir uma solução para as linhas 1...k
da grade, de modo que a string x corresponda à linha k. É possível usar programação dinâmica aqui,
porque o estado de uma linha é restringido apenas pelo estado da linha anterior.
75
Machine Translated by Google
n/2 m/2 ÿa ÿb
4·(cos2 +cos2 )
uma=1 b=1 n+1 m+1
2Surpreendentemente, esta fórmula foi descoberta em 1961 por duas equipes de pesquisa [43, 67] que trabalharam
independentemente.
76
Machine Translated by Google
Capítulo 8
Análise amortizada
Soma do subarray
13251123
13251123
Este problema pode ser resolvido em tempo O(n) usando o método dos dois ponteiros.
A ideia é manter ponteiros que apontem para o primeiro e o último valor de um subarray.
Em cada volta, o ponteiro esquerdo se move um passo para a direita e o ponteiro direito
se move para a direita, desde que a soma do subarranjo resultante seja no máximo x. Se
a soma for exatamente x, uma solução foi encontrada.
77
Machine Translated by Google
13251123
13251123
Em seguida, o ponteiro esquerdo move-se um passo para a direita. O ponteiro certo não
mova, porque caso contrário a soma do subarranjo excederia x.
13251123
13251123
Problema 2SUM
Outro problema que pode ser resolvido usando o método dos dois ponteiros é o seguinte
problema, também conhecido como problema 2SUM: dado um array de n números e uma
soma alvo x, encontre dois valores de array tais que sua soma seja x, ou relate que não
tais valores 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 turno. O ponteiro
direito começa no último valor e sempre se move para a esquerda até que a soma dos
valores esquerdo e direito seja no máximo x. Se a soma for exatamente x, uma solução foi encontrada.
Por exemplo, considere a seguinte matriz e uma soma alvo x = 12:
1 4 5 6 7 9 9 10
78
Machine Translated by Google
1 4 5 6 7 9 9 10
1 4 5 6 7 9 9 10
1 4 5 6 7 9 9 10
1Durante muito tempo, pensou-se que resolver o problema 3SUM de forma mais eficiente do que em O(n2 )
tempo não seria possível. Contudo, em 2014, descobriu-se [30] que este não é o caso.
79
Machine Translated by Google
13425342
13425342
134
13425342
1 2
13425342
1 25
13425342
1 2 34
13425342
1 2
80
Machine Translated by Google
21453412
21453412
145
21453412
1 3
21453412
34
O próximo novo elemento 1 é menor que todos os elementos da fila. Assim, todos
os elementos são removidos da fila e ela conterá apenas o elemento 1:
21453412
81
Machine Translated by Google
21453412
12
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) .
82
Machine Translated by Google
Capítulo 9
Consultas de intervalo
Neste capítulo, discutimos estruturas de dados que nos permitem processar consultas de intervalo
com eficiência. Em uma consulta de intervalo, nossa tarefa é calcular um valor com base em
um subarray de um array. Consultas de intervalo típicas são:
01234567
13846134
} retornar s;
}
83
Machine Translated by Google
Primeiro nos concentramos em uma situação em que o array é estático, ou seja, os valores
do array nunca são atualizados entre as consultas. Neste caso, basta construir uma estrutura
de dados estática que nos diga a resposta para qualquer consulta possível.
Consultas de soma
01234567
13486142
01234567
1 4 8 16 22 23 27 29
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) da seguinte forma:
somaq(a,b) = somaq(0,b)ÿsomaq(0,aÿ1)
01234567
13486142
Neste caso sumq(3,6) = 8+6+1+4 = 19. Esta soma pode ser calculada a partir de dois
valores da matriz de soma de prefixo:
01234567
1 4 8 16 22 23 27 29
84
Machine Translated by Google
D C
B A
onde S(X) denota a soma dos valores em uma submatriz retangular do canto
superior esquerdo até a posição de X.
Consultas mínimas
As consultas mínimas são mais difíceis de processar do que as consultas de soma. Ainda assim, existe
um método de pré-processamento de tempo O(nlogn) bastante simples, após o qual podemos responder
a qualquer consulta mínima em O(1) time1 . Observe que, como as consultas mínimas e máximas
podem ser processadas de forma semelhante, podemos nos concentrar nas consultas mínimas.
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, para a matriz
01234567
13486142
772
minq(a,b) = min(minq(a,a+wÿ1),minq(a+w,b)),
1Essa técnica foi introduzida em [7] e às vezes chamada de método de tabela esparsa . Existem
também técnicas mais sofisticadas [22] onde o tempo de pré-processamento é apenas O(n), mas tais
algoritmos não são necessários em programação competitiva.
85
Machine Translated by Google
onde bÿa+1 é uma potência de dois e w = (bÿa+1)/2. O cálculo de todos esses valores leva
tempo O(nlogn) .
Depois disso, qualquer valor de minq(a,b) pode ser calculado no 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
13486142
13486142
01234567
13486142
Estrutura
Mesmo que o nome da estrutura seja uma árvore binária indexada, ela geralmente é
representada como um array. Nesta seção, assumimos que todos os arrays são indexados em
um, porque isso torna a implementação mais fácil.
Deixe p(k) denotar a maior potência de dois que divide k. Armazenamos um binário
árvore indexada como uma árvore de array tal que
2A estrutura de árvore binária indexada foi apresentada por PM Fenwick em 1994 [21].
86
Machine Translated by Google
ou seja, 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] contém
o valor de sumq(5,6).
Por exemplo, considere a seguinte matriz:
12345678
13486142
1 4 4 16 6 7 4 29
A imagem a seguir mostra mais claramente como cada valor no binário indexado
tree corresponde a um intervalo na matriz original:
12345678
1 4 4 16 6 7 4 29
Usando uma árvore indexada binária, qualquer valor de sumq(1,k) pode ser calculado
em tempo O(logn) , porque um intervalo [1,k] sempre pode ser dividido em intervalos O(logn)
cujas somas são armazenadas na árvore .
Por exemplo, o intervalo [1,7] consiste nos seguintes intervalos:
12345678
1 4 4 16 6 7 4 29
Para calcular o valor de sumq(a,b) onde a > 1, podemos usar o mesmo truque
que usamos com matrizes de soma de prefixos:
somaq(a,b) = somaq(1,b)ÿsomaq(1,aÿ1).
87
Machine Translated by Google
Implementação
As operações de uma árvore indexada binária podem ser implementadas de forma eficiente
usando operações de bits. O principal fato necessário é que podemos calcular qualquer valor de
p(k) usando a fórmula
p(k) = k&ÿ k.
A função a seguir calcula o valor de sumq(1,k):
} retornar s;
}
}
}
88
Machine Translated by Google
Árvore de segmento
Um segmento tree3 é uma estrutura de dados que suporta duas operações: processar uma
consulta de intervalo e atualizar um valor de array. As árvores de segmento podem suportar
consultas de soma, consultas mínimas e máximas e muitas outras consultas para que ambas
as operações funcionem em tempo O(logn) .
Comparada a uma árvore binária indexada, a vantagem de uma árvore de segmentos é que
ela é uma estrutura de dados mais geral. Embora as árvores indexadas binárias suportem apenas
consultas de soma4 , as árvores de segmento também suportam outras consultas. Por outro lado,
uma árvore de segmentos requer mais memória e é um pouco mais difícil de implementar.
Estrutura
Uma árvore de segmentos é uma árvore binária tal que os nós no nível inferior da árvore
correspondem aos elementos da matriz e os outros nós contêm informações necessárias
para processar consultas de intervalo.
Nesta seção, 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, podemos sempre acrescentar
elementos extras a ele.
Discutiremos primeiro as árvores de segmentos que suportam consultas de soma. Como
exemplo, considere a seguinte matriz:
01234567
58632726
39
22 17
13 9 9 8
58632726
89
Machine Translated by Google
Acontece que qualquer intervalo [a,b] pode ser dividido em intervalos O(logn) cujos
os valores são armazenados em nós de árvore. Por exemplo, considere o intervalo [2,7]:
01234567
58632726
Aqui sumq(2,7) = 6+3+2+7+2+6 = 26. Neste caso, os dois nós da árvore a seguir
correspondem ao intervalo:
39
22 17
13 9 9 8
58632726
39
22 17
13 9 9 8
58632726
O caminho de baixo para cima sempre consiste em nós O(logn) , então cada atualização
altera nós O (logn) na árvore.
Implementação
Armazenamos uma árvore de segmentos como um array de 2n elementos onde n é o tamanho do
matriz original e uma potência de dois. Os nós da árvore são armazenados de cima para baixo:
90
Machine Translated by Google
tree[1] é o nó superior, tree[2] e tree[3] são seus filhos e assim por diante.
Finalmente, os valores de tree[n] a tree[2n ÿ1] correspondem aos valores do array
original no nível inferior da árvore.
Por exemplo, a árvore de segmentos
39
22 17
13 9 9 8
58632726
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
39 22 17 13 9 9 8 5 8 6 3 2 7 2 6
Usando esta representação, o pai de tree[k] é tree[k/2] e seus filhos são tree[2k] e
tree[2k +1]. Observe que isso implica que a posição de um nó é par se for filho à
esquerda e ímpar se for filho à direita.
A função a seguir calcula o valor de sumq(a,b):
} retornar s;
}
A função mantém um intervalo que é inicialmente [a + n,b + n]. Então, a cada passo,
o intervalo é movido um nível acima na árvore e, antes disso, os valores dos nós
que não pertencem ao intervalo superior são adicionados à soma.
A função a seguir aumenta o valor da matriz na posição k em x:
91
Machine Translated by Google
Outras consultas
As árvores de segmento podem suportar todas as consultas de intervalo onde é possível dividir um intervalo
em duas partes, calcule a resposta separadamente para ambas as partes e então com eficiência
combine as respostas. Exemplos de tais consultas são mínimo e máximo,
máximo divisor comum e operações de bits e, ou e xor.
Por exemplo, a árvore de segmentos a seguir oferece suporte a consultas mínimas:
3 1
5 3 1 2
58631726
3 1
5 3 1 2
58631726
92
Machine Translated by Google
Técnicas adicionais
Compressão de índice
Uma limitação nas estruturas de dados construídas sobre um array é que os elementos
são indexados usando números inteiros consecutivos. As dificuldades surgem quando
são necessários índices grandes . Por exemplo, se desejarmos , usar o índice 109, o array
deverá conter 109 elementos que exigiriam muita memória.
No entanto, muitas vezes podemos contornar essa limitação usando compactação de
índice, onde os índices originais são substituídos pelos índices 1,2,3, etc. Isso pode ser
feito se conhecermos previamente todos os índices necessários durante o algoritmo.
A ideia é substituir cada índice original x por c(x) onde c é uma função que
compacta os índices. Exigimos que a ordem dos índices não mude, então se a < b,
então c(a) < c(b). Isso nos permite realizar consultas de maneira conveniente, mesmo
se os índices estiverem compactados.
Por exemplo, se os índices originais forem 555, 109 e 8, os novos índices serão:
c(8) = 1 c(555)
= 2 c(109 ) =
3
Atualizações de intervalo
93
Machine Translated by Google
01234567
3 5 ÿ2 0 0 ÿ1 ÿ3 0
94
Machine Translated by Google
Capítulo 10
Manipulação de bits
Representação de bits
Na programação, um número 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.
Aqui está a representação de bits do número inteiro 43:
000000000000000000000000101011
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, podemos usar a fórmula
k 2 1 0
bk2 +...+ b22 + b12 + b02 .
Por exemplo,
5 3 1 0 1·2 +1·2 +1·2
+1·2 = 43.
1111111111111111111111111010101.
95
Machine Translated by Google
Numa 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.
Existe 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 assinado
x = ÿ43 é igual ao número sem sinal y = 2 32-43 :
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
31
uma variável int , então o próximo número após 231 ÿ1 é ÿ2 .
Operações de bits
E operação
A operação and x & y produz um número que possui um bit em posições onde x e y
possuem 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. De forma mais geral, x é divisível por 2k
exatamente quando x & (2k ÿ1) = 0.
Ou operação
A operação ou x | y produz um número que possui um bit em posições onde pelo menos
um de x e y possui um bit. Por exemplo, 22 | 26 = 30, porque
10110 (22) |
11010 (26) =
11110 (30)
96
Machine Translated by Google
Operação Xor
^
A operação xor x y produz um número que possui um bit nas posições onde
exatamente um de xey tem um bit . Por exemplo, 22 ^ 26 = 12, porque
10110 (22) ^
11010 (26) = 01100
(12)
Não operação
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, porque a operação inverte todos os bits. Por exemplo, se os números forem números inteiros
de 32 bits , o resultado será o seguinte:
x = 29 000000000000000000000000011101
~x = ÿ30 1111111111111111111111111100010
Mudanças de bits
Formulários
Um número da forma 1 << k tem um bit na posição k e todos os outros bits são zero, portanto,
podemos usar esses números 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 inteiro x:
97
Machine Translated by Google
Funções adicionais
Embora as funções acima suportem apenas números inteiros , também existem funções longas e longas
versões das funções disponíveis com o sufixo ll.
Representando conjuntos
Cada subconjunto de um conjunto {0,1,2,...,nÿ1} pode ser representado como um número inteiro de
n bits cujos bits indicam quais elementos pertencem ao subconjunto. Esta é uma forma eficiente de
representar conjuntos, porque cada elemento requer apenas um bit de memória e as operações de
conjuntos 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} é
000000000000000000000100011010,
43
=1 +2 +2
282.
que corresponde ao número 28 +2
Definir implementação
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.
interno x =
0; x |= (1<<1);
x |= (1<<3); x |
= (1<<4); x |=
(1<<8); cout
<< __builtin_popcount(x) << "\n"; //4
98
Machine Translated by Google
} // saída: 1 3 4 8
Definir operações
As operações de conjunto podem ser implementadas da seguinte forma como operações de bits:
int b = 0; do
{ //
processa o subconjunto b
} enquanto (b=(bx)&x);
99
Machine Translated by Google
Otimizações de bits
Muitos algoritmos podem ser otimizados usando operações de bits. Essas otimizações não
alteram a complexidade de tempo do algoritmo, mas podem ter um grande impacto no
tempo real de execução do código. Nesta seção discutimos exemplos de tais situações.
Distâncias de Hamming
A distância de Hamming hamming (a,b) entre duas strings aeb de igual comprimento é o
número de posições onde as strings diferem. Por exemplo,
hamming(01101,11001) = 2.
Considere o seguinte problema: Dada uma lista de n cadeias de bits, cada uma
com comprimento k, calcule a distância mínima de Hamming entre duas cadeias na
lista. 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 é percorrer todos os pares de strings
e calcular suas distâncias de Hamming, o que produz um algoritmo de tempo O(n 2k) .
A seguinte função pode ser usada para calcular distâncias:
} retornar d;
}
Na função acima, a operação xor constrói uma string de bits que possui um bit em posições
onde a e b diferem. Em seguida, o número de bits é calculado usando a função
__builtin_popcount .
Para comparar as implementações, geramos uma lista de 10.000 sequências de bits
aleatórios de comprimento 30. Usando a primeira abordagem, a pesquisa levou 13,5
segundos e, após a otimização de bits, levou apenas 0,5 segundos. Assim, o código
otimizado para bits foi quase 30 vezes mais rápido que o código original.
100
Machine Translated by Google
Contando subredes
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
todos os cantos são pretos. Por exemplo, a grade
Existe um O(n 3
) algoritmo de tempo para resolver o problema: passe por todos
2
Sobre ) pares de linhas e para cada par (a,b) calcule o número de colunas que contêm um
quadrado preto em ambas as linhas em tempo O(n) . O código a seguir pressupõe que color[y]
[x] denota a cor na linha y e na coluna x:
contagem interna
= 0; for (int i = 0; i < n; i++) { if (color[a]
[i] == 1 && color[b][i] == 1) contagem++;
}
Então, essas colunas representam subgrades count(countÿ1)/2 com cantos pretos, porque
podemos escolher quaisquer duas delas para formar uma subgrade.
Para otimizar este algoritmo, dividimos a grade em blocos de colunas de modo que
cada bloco consista em N colunas consecutivas. Então, cada linha é armazenada como
uma lista de números de N bits que descrevem as cores dos quadrados. Agora podemos
processar N colunas ao mesmo tempo usando operações de bits. No código a seguir,
color[y][k] representa um bloco de N cores como bits.
contagem interna
= 0; for (int i = 0; i <= n/N; i++) {
contagem += __builtin_popcount(color[a][i]&color[b][i]);
}
3/N) hora.
O algoritmo resultante funciona em O(n
Geramos uma grade aleatória de tamanho 2500×2500 e comparamos a implementação
original e a implementação otimizada para bits. Enquanto o código original levou 29,6
segundos, a versão otimizada para bits levou apenas 3,1 segundos com N = 32 (números
inteiros ) e 1,7 segundos com N = 64 (números longos ).
101
Machine Translated by Google
Programaçao dinamica
As operações de bits fornecem uma maneira eficiente e conveniente de implementar algoritmos
de programação dinâmica cujos estados contêm subconjuntos de elementos, porque tais estados
podem ser armazenados como números inteiros. A seguir discutiremos exemplos de combinação
de operações de bits e programação dinâmica.
Seleção ideal
Como primeiro exemplo, considere o seguinte problema: Recebemos os preços de k
produtos em n dias e queremos comprar cada produto exatamente uma vez. No
entanto, podemos comprar no máximo um produto por dia. Qual é o preço total
mínimo ? Por exemplo, considere o seguinte cenário (k = 3 en = 8 ):
01234567
produto 0 69528916
produto 1 82627572
produto 2 53973514
01234567
produto 0 69528916
produto 1 82627572
produto 2 53973514
Seja preço[x][d] o preço do produto x no dia d. Por exemplo, no cenário acima, preço[2][3] =
7. Então, seja total(S,d) o preço total mínimo para comprar um subconjunto S de produtos no dia
d. Usando esta função, a solução para o problema é total({0...k ÿ1},nÿ1).
int total[1<<K][N];
102
Machine Translated by Google
total[s^(1<<x)][d-1]+preço[x][d]);
}
}
}
}
De permutações a subconjuntos
Usando programação dinâmica, muitas vezes é possível transformar uma iteração
sobre permutações em uma iteração sobre subconjuntos1 . A vantagem disso é que
n!, o número de permutações, é muito maior que 2n o,número de subconjuntos. Por
exemplo, se n = 20, então 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: Existe um elevador com peso
máximo x, e n pessoas com pesos conhecidos que desejam ir do térreo ao último
andar. Qual é o número mínimo de viagens necessárias se as pessoas entrarem no
elevador na ordem ideal?
Por exemplo, suponha que x = 10, n = 5 e os pesos sejam os seguintes:
peso da pessoa 0 1
2
3
2 3
3 5
4 6
Neste caso, o número mínimo de viagens é 2. Uma ordem ideal é {0,2,3,1,4}, que
divide as pessoas em duas viagens: primeiro {0,2,3} (peso total 10), e então {1,4}
(peso total 9).
103
Machine Translated by Google
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 obter um algoritmo
de tempo O(2nn) mais eficiente . A ideia é calcular para cada subconjunto de pessoas dois valores:
o número mínimo de viagens necessárias e o peso mínimo das pessoas que viajam no último
grupo.
Deixe peso[p] denotar o peso da pessoa p. Definimos duas funções: rides(S) é o número
mínimo de passeios para um subconjunto S, e last(S) é o peso mínimo do último passeio. Por
exemplo, no cenário acima
passeios({1,3,4}) = 2 e último({1,3,4}) = 5,
porque as viagens ideais são {1,4} e {3}, e a segunda viagem tem peso 5. Claro ,
nosso objetivo final é calcular o valor das viagens ({0...nÿ1}).
Podemos calcular os valores das funções recursivamente e depois aplicar a programação
dinâmica. A ideia é passar por todas as pessoas que pertencem a S e escolher de forma
otimizada a última pessoa p que entra no elevador. Cada uma dessas escolhas gera um
subproblema para um subconjunto menor de pessoas. Se last(S \ p)+weight[p] ÿ x, podemos
adicionar p ao último passeio. Caso contrário, teremos que reservar um novo passeio que
inicialmente contenha apenas p.
Para implementar programação dinâmica, declaramos um array
par<int,int> melhor[1<<N];
que contém para cada subconjunto S um par (passeios(S),último(S)). Definimos o valor para um
grupo vazio da seguinte forma:
melhor[0] = {1,0};
}
melhor[s] = min(melhor[s], opção);
}
}
}
104
Machine Translated by Google
Observe que o loop acima garante que para quaisquer dois subconjuntos S1 e S2 tais
que S1 ÿ S2, processamos S1 antes de S2. Assim, os valores da programação
dinâmica são calculados na ordem correta.
Contando subconjuntos
Nosso último problema neste capítulo é o seguinte: Seja X = {0...nÿ1}, e a cada subconjunto S ÿ
X é atribuído um valor inteiro [S]. Nossa tarefa é calcular para cada S
soma(S) = valor[A],
AÿS
• valor[] = 3 • valor[{2}] = 5
• valor[{0}] = 1 • valor[{0,2}] = 1
• valor[{1}] = 4 • valor[{1,2}] = 3
• valor[{0,1}] = 5 • valor[{0,1,2}] = 3
soma({0,2}) = valor[]+valor[{0}]+valor[{2}]+valor[{0,2}]
= 3+1+5+1 = 10.
parcial({0,2},1) = valor[{2}]+valor[{0,2}],
soma(S) = parcial(S,nÿ1).
parcial(S,ÿ1) = valor[S],
porque neste caso nenhum elemento pode ser removido de S. Então, no caso geral
podemos usar a seguinte recorrência:
105
Machine Translated by Google
int soma[1<<N];
Este código calcula os valores de parcial(S,k) para k = 0...nÿ1 para a soma do array.
Como parcial(S,k) é sempre baseado em parcial(S,k ÿ1), podemos reutilizar a soma do
array, o que produz uma implementação muito eficiente.
106
Machine Translated by Google
parte II
Algoritmos gráficos
107
Machine Translated by Google
Machine Translated by Google
Capítulo 11
Muitos problemas de programação podem ser resolvidos modelando o problema como um problema
de gráfico e usando um algoritmo de gráfico apropriado. Um exemplo típico de gráfico é uma rede de
estradas e cidades de um país. Às vezes, porém, o gráfico fica oculto no problema e pode ser difícil
detectá-lo.
Esta parte do livro discute algoritmos de grafos, concentrando-se especialmente
em tópicos importantes na programação competitiva. Neste capítulo, abordamos
conceitos relacionados a gráficos e estudamos diferentes maneiras de representar
gráficos em algoritmos.
Terminologia gráfica
Um gráfico consiste em nós e arestas. Neste livro, a variável n denota o número
de nós em um gráfico e a variável m denota o número de arestas.
Os nós são numerados usando inteiros 1,2,...,n.
Por exemplo, o gráfico a seguir consiste em 5 nós e 7 arestas:
1 2
3 4
1 2
3 4
109
Machine Translated by Google
Conectividade
Um grafo é conectado se existe um caminho entre quaisquer dois nós. Por exemplo,
o seguinte gráfico está conectado:
1 2
3 4
1 2
3 4
1 2 4 5
3 6 7
1 2
3 4
Direções de borda
Um gráfico é direcionado se as arestas puderem ser percorridas em apenas uma direção. Para
por exemplo, o seguinte gráfico é direcionado:
1 2
3 4
110
Machine Translated by Google
Pesos de borda
Em um gráfico ponderado , cada aresta recebe um peso. Os pesos são frequentemente
interpretados como comprimentos de aresta. Por exemplo, o gráfico a seguir é ponderado:
5
1 2 7
1 6 5
3 4 3
7
Vizinhos e diplomas
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. Por exemplo, no gráfico a seguir, os vizinhos do nó 2 são 1, 4 e
5, portanto seu grau é 3.
1 2
3 4
1 2
3 4
111
Machine Translated by Google
Colorações
Na coloração de um grafo, cada nó recebe uma cor para que nenhum nó adjacente
têm a mesma cor.
Um gráfico é bipartido se for possível colori-lo com duas cores. Acontece que
que um grafo é bipartido exatamente quando não contém um ciclo com
número de arestas. Por exemplo, o gráfico
1 2 3
4 5 6
1 2 3
4 5 6
No entanto, o gráfico
1 2 3
4 5 6
1 2 3
4 5 6
Simplicidade
Um grafo é simples se nenhuma aresta começa e termina no mesmo nó e não há
múltiplas arestas entre dois nós. Freqüentemente assumimos que os gráficos são simples. Para
Por exemplo, o gráfico a seguir não é simples:
1 2 3
4 5 6
112
Machine Translated by Google
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. A seguir passaremos por três representações comuns.
Uma maneira conveniente de armazenar as listas de adjacências é declarar uma matriz de vetores
do seguinte modo:
vetor<int> adj[N];
A constante N é escolhida para que todas as listas de adjacências possam ser armazenadas. Por
exemplo, o gráfico
1 2 3
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 gráfico não for direcionado, ele pode ser armazenado de maneira semelhante, mas cada aresta é
adicionado em ambas as direções.
vetor<par<int,int>> adj[N];
5 7
1 2 3
26 5
113
Machine Translated by Google
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});
Uma matriz de adjacência é uma matriz bidimensional que indica quais arestas o gráfico
contém. Podemos verificar com eficiência a partir de uma matriz de adjacência se existe
uma aresta entre dois nós. A matriz pode ser armazenada como um array
int adj[N][N];
1 2 3
1234
1 0100
2 0011
3 0001
4 1000
114
Machine Translated by Google
5 7
1 2 3
26 5
1234
1 0500
2 0076
3 0005
4 2000
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 processa todas as arestas do grafo e
não é necessário encontrar arestas que começam 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
1 2 3
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});
115
Machine Translated by Google
vetor<tupla<int,int,int>> arestas;
Cada elemento nesta lista tem a forma (a,b,w), o que significa que há uma aresta do nó
a ao nó b com peso w. Por exemplo, o gráfico
5 7
1 2 3
26 5
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});
1Em alguns compiladores mais antigos, a função make_tuple deve ser usada em vez dos colchetes (para
exemplo, make_tuple(1,2,5) em vez de {1,2,5}).
116
Machine Translated by Google
Capítulo 12
Percurso gráfico
Pesquisa em profundidade
Exemplo
Vamos considerar como a pesquisa em profundidade processa o seguinte gráfico:
1 2
4 5
1 2
4 5
117
Machine Translated by Google
1 2
4 5
1 2
4 5
Implementação
A pesquisa 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ências em uma matriz
vetor<int> adj[N];
bool visitado[N];
que rastreia os nós visitados. Inicialmente, cada valor do array é falso, e quando a busca
chega ao nó s, o valor de visitado[s] torna-se verdadeiro. A função pode ser implementada da
seguinte forma:
void dfs(int s) { if
(visitado[s]) return; visitado[s]
= verdadeiro; //
processa nós for (auto
u: adj[s]) { dfs(u);
}
}
118
Machine Translated by Google
Pesquisa ampla
Exemplo
Vamos considerar como a pesquisa em largura processa o seguinte gráfico:
1 2 3
4 5 6
Suponha que a pesquisa comece no nó 1. Primeiro, processamos todos os nós que podem ser
alcançado a partir do nó 1 usando uma única aresta:
1 2 3
4 5 6
1 2 3
4 5 6
Finalmente, visitamos o nó 6:
1 2 3
4 5 6
119
Machine Translated by Google
distância do nó
1 0
2 1
3 2
4 1
5 2
6 3
Implementação
A busca em largura é mais difícil de implementar do que a busca em profundidade, porque o
algoritmo visita nós em diferentes partes do grafo. Uma implementação típica é baseada em
uma fila que contém nós. A cada etapa, o próximo nó da fila será processado.
fila<int> q; bool
visitado[N]; distância
interna [N];
visitado[x] = verdadeiro;
distância[x] = 0;
q.push(x);
enquanto (!q.empty()) { int
s = q.front(); q.pop(); // processa
nós for (auto u : adj[s])
{ if (visited[u]) continue;
visitado[u] = verdadeiro;
distância[u] =
distância[s]+1; q.push(u);
}
}
120
Machine Translated by Google
Formulários
Usando os algoritmos de travessia de grafos, podemos verificar muitas propriedades dos grafos.
Normalmente, tanto a pesquisa em profundidade quanto a pesquisa em largura podem ser usadas,
mas na prática, a pesquisa em profundidade é uma escolha melhor, porque é mais fácil de implementar.
Nas aplicações a seguir, assumiremos que o gráfico não é direcionado.
Verificação de conectividade
Um grafo é conectado se existe um caminho entre quaisquer dois nós do grafo. Assim,
podemos verificar se um grafo está conectado começando em um nó arbitrário e descobrindo
se podemos alcançar todos os outros nós.
Por exemplo, no gráfico
1 2
4 5
1 2
4 5
Como a busca não visitou 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 gráfico
iterando pelos nós e sempre iniciando uma nova pesquisa em profundidade se o nó atual ainda não
pertencer a nenhum componente.
Encontrando ciclos
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, o gráfico
1 2
4 5
121
Machine Translated by Google
1 2
4 5
Após passar do nó 2 para o nó 5 notamos que o vizinho 3 do nó 5 já foi visitado. Assim, o gráfico
contém um ciclo que passa pelo nó 3, por exemplo, 3 ÿ 2 ÿ 5 ÿ 3.
Verificação de bipartidaridade
Um grafo é bipartido se seus nós puderem ser coloridos com duas cores, de modo que não haja nós
adjacentes com a mesma cor. É surpreendentemente fácil verificar se um gráfico é bipartido usando
algoritmos de passagem de gráfico.
A ideia é colorir o nó inicial de azul, todos os seus vizinhos de vermelho, todos os
seus vizinhos de azul e assim por diante. Se em algum momento da busca notarmos
que dois nós adjacentes possuem 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, o gráfico
1 2
4 5
1 2
4 5
Notamos que a cor de ambos os nós 2 e 5 é vermelha, embora sejam nós adjacentes no gráfico.
Assim, o gráfico não é bipartido.
Este algoritmo sempre funciona, porque quando há apenas duas cores disponíveis , a cor do nó
inicial de um componente determina as cores de todos os outros nós do componente. Não faz
diferença se o nó inicial é vermelho ou azul.
Observe que, no caso geral, é difícil descobrir se os nós em um grafo podem ser coloridos
usando k cores, de modo que nenhum nó adjacente tenha a mesma cor. Mesmo quando k = 3,
nenhum algoritmo eficiente é conhecido, mas o problema é NP-difícil.
122
Machine Translated by Google
Capítulo 13
Encontrar o 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 com uma
rede rodoviária é calcular o comprimento mais curto possível de uma rota entre duas
cidades, dados os comprimentos das estradas.
Em um gráfico não ponderado, o comprimento de um caminho é igual ao número de suas arestas,
e podemos simplesmente usar a pesquisa em largura para encontrar o caminho mais curto. No entanto,
neste capítulo nos concentramos em grafos ponderados onde são necessários algoritmos mais
sofisticados para encontrar os caminhos mais curtos.
Algoritmo Bellman-Ford
O algoritmo Bellman-Ford1 encontra os 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 monitora as distâncias do nó inicial a todos os nós do gráfico. Inicialmente,
a distância até o nó inicial é 0 e a distância até todos os outros nós é infinita. O algoritmo
reduz as distâncias encontrando arestas que encurtam os caminhos até que não seja
possível reduzir nenhuma distância.
Exemplo
Vamos considerar como o algoritmo Bellman-Ford funciona no gráfico a seguir:
0 5
ÿ
1 2 2
7
3 3 6
ÿ
3 4 2
ÿ 1 ÿ
123
Machine Translated by Google
0 5 5
1 2 2
7
3 3 5
ÿ
3 4 2
3 1 7
0 5 5
1 2 2
7
3 3 5
7
3 4 2
3 1 4
0 5 5
1 2 2
7
3 3 5
6
3 4 2
3 1 4
Depois disso, nenhuma aresta pode reduzir qualquer distância. Isso significa
que as distâncias são finais e calculamos com sucesso as distâncias mais curtas
do nó inicial a todos os nós do gráfico.
Por exemplo, a distância mais curta 3 do nó 1 ao nó 5 corresponde ao seguinte caminho:
0 5 5
1 2 2
7
3 3 5
6
3 4 2
3 1 4
124
Machine Translated by Google
Implementação
A seguinte implementação do algoritmo Bellman-Ford determina as distâncias mais
curtas de um nó x a todos os nós do gráfico. O código assume que o gráfico é
armazenado como uma lista de arestas que consiste em tuplas na forma (a,b,w), o
que significa que há 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 gráfico e tenta reduzir as distâncias. O algoritmo constrói um
array de distâncias que conterá as distâncias de x a todos os nós do gráfico. A
constante INF denota uma distância infinita.
Assim, uma forma possível de tornar o algoritmo mais eficiente é pará-lo se nenhuma
distância puder ser reduzida durante uma rodada.
Ciclos negativos
O algoritmo Bellman-Ford também pode ser usado para verificar se o gráfico contém um
ciclo com comprimento negativo. Por exemplo, o gráfico
3 2 1
1 2 4
5 3 ÿ7
125
Machine Translated by Google
Algoritmo SPFA
O algoritmo SPFA (“Shortest Path Faster Algorithm”) [20] é uma variante do algoritmo
Bellman-Ford, que muitas vezes é mais eficiente que o algoritmo original.
O algoritmo SPFA não percorre todas as arestas em cada rodada, mas em vez disso,
escolhe as arestas a serem examinadas de forma mais inteligente.
O algoritmo mantém uma fila de nós que podem ser usados para reduzir as
distâncias. Primeiro, o algoritmo adiciona o nó inicial x à fila. Então, o algoritmo
sempre processa o primeiro nó da fila, e quando uma aresta a ÿ b reduz uma
distância, o nó b é adicionado à fila.
A eficiência do algoritmo SPFA depende da estrutura do gráfico: o algoritmo é
frequentemente eficiente, mas sua complexidade de tempo no pior caso ainda é O(nm)
e é possível criar entradas que tornam o algoritmo tão lento quanto o Bellman original–
Algoritmo de Ford.
Algoritmo de Dijkstra
O algoritmo de Dijkstra2 encontra os caminhos mais curtos do nó inicial até todos os
nós do grafo, como o algoritmo de Bellman-Ford. A vantagem do algoritmo de Dijsktra é
que ele é mais eficiente e pode ser usado para processar gráficos grandes. No entanto,
o algoritmo exige que não haja arestas de peso negativo no gráfico.
Assim como o algoritmo Bellman-Ford, o algoritmo de Dijkstra mantém distâncias
aos nós e as reduz durante a busca. O algoritmo de Dijkstra é eficiente, pois processa
cada aresta do gráfico apenas uma vez, aproveitando o fato de que não há arestas
negativas.
Exemplo
Vamos considerar como o algoritmo de Dijkstra funciona no gráfico a seguir quando o
nó inicial é o nó 1:
ÿ
6 ÿ
3 4 2
2 9 5
ÿ
2 1 1
ÿ
5 0
2E. W. Dijkstra publicou o algoritmo em 1959 [14]; entretanto, seu artigo original não
menciona como implementar o algoritmo de forma eficiente.
126
Machine Translated by Google
ÿ
6 9
3 4 2
2 9 5
1
2 1 1
5 5 0
ÿ
6 3
3 4 2
2 9 5
1
2 1 1
5 5 0
9 6 3
3 4 2
2 9 5
1
2 1 1
5 5 0
7 6 3
3 4 2
2 9 5
1
2 1 1
5 5 0
127
Machine Translated by Google
Bordas negativas
A eficiência do algoritmo de Dijkstra baseia-se no fato de o gráfico não conter arestas
negativas. Se houver uma borda negativa, o algoritmo poderá fornecer resultados
incorretos. Como exemplo, considere o seguinte gráfico:
2 2 3
1 4
6 3 ÿ5
Implementação
A seguinte implementação do algoritmo de Dijkstra calcula as distâncias mínimas de um nó x para outros
nós do gráfico. O gráfico é armazenado como listas de adjacência para que adj[a] contenha um par (b,w)
sempre quando houver uma aresta do nó a ao nó b com peso w.
}
}
}
128
Machine Translated by Google
Observe que a fila de prioridade contém distâncias negativas aos 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 usar distâncias
negativas, podemos usar diretamente a prioridade padrão queue3 . Observe
também que pode haver diversas instâncias do mesmo nó na fila de prioridade;
entretanto, apenas a instância com distância mínima será processada.
A complexidade de tempo da implementação acima é O(n+ mlogm), porque o
algoritmo passa por todos os nós do grafo e adiciona para cada aresta no máximo
uma distância até a fila de prioridade.
Algoritmo Floyd-Warshall
O algoritmo Floyd-Warshall4 fornece uma forma alternativa de abordar o problema de
encontrar caminhos mais curtos. Ao contrário dos outros algoritmos deste capítulo, ele
encontra todos os caminhos mais curtos entre os nós em uma única execução.
O algoritmo mantém uma matriz bidimensional que contém distâncias entre os
nós. Primeiro, as distâncias são calculadas apenas usando arestas diretas entre os
nós e, depois disso, o algoritmo reduz as distâncias usando nós intermediários nos
caminhos.
Exemplo
Vamos considerar como o algoritmo Floyd – Warshall funciona no gráfico a seguir:
7
3 4 2
2 9 5
2 1 1
5
12345105ÿ91
2502ÿÿ3ÿ207ÿ
49ÿ70251ÿÿ20
3É claro que também poderíamos declarar a fila prioritária como no Capítulo 4.5 e usar distâncias positivas,
mas a implementação seria um pouco mais longa.
4O algoritmo recebeu o nome de RW Floyd e S. Warshall, que o publicaram de forma independente
em 1962 [23, 70].
129
Machine Translated by Google
12345
105ÿ91
2 5 0 2 14 6 3 ÿ 2 0 7 ÿ 4 9 14 7
02516ÿ20
12345
1 0 5 7 9 1 2 5 0 2 14
6
3 7 2 0 7 8 4 9 14 7 0
2
516820
O algoritmo continua assim, até que todos os nós tenham sido nomeados como
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:
12345
1057312502
86
3720784387
02
516820
Por exemplo, a matriz nos diz que a distância mais curta entre os nós 2
e 4 é 8. Isso corresponde ao seguinte caminho:
130
Machine Translated by Google
7
3 4 2
2 9 5
2 1 1
5
Implementação
A vantagem do algoritmo Floyd – Warshall é que é fácil de implementar. O código a
seguir constrói uma matriz de distância onde distância[a][b] é a distância mais curta
entre os nós a e b. Primeiro, o algoritmo inicializa a distância usando a matriz de
adjacência adj do gráfico:
}
}
Depois disso, as distâncias mais curtas podem ser encontradas da seguinte forma:
3
A complexidade de tempo do algoritmo é O(n ), porque contém três
loops aninhados que passam pelos nós do gráfico.
Como a implementação do algoritmo Floyd-Warshall é simples, o algoritmo pode ser
uma boa escolha, mesmo que seja necessário apenas para encontrar um único caminho
mais curto no grafo. No entanto, o algoritmo só pode ser usado quando o gráfico é tão
pequeno que uma complexidade de tempo cúbico é rápida o suficiente.
131
Machine Translated by Google
132
Machine Translated by Google
Capítulo 14
Algoritmos de árvore
5 1 4
8 6 2 3 7
As folhas de uma árvore são os nós com grau 1, ou seja, com apenas um vizinho.
Por exemplo, as folhas da árvore acima são os nós 3, 5, 7 e 8.
Em uma árvore enraizada , um dos nós é nomeado raiz da árvore e todos
outros nós são colocados abaixo da raiz. Por exemplo, na árvore a seguir,
o nó 1 é o nó raiz.
2 3 4
5 6 7
133
Machine Translated by Google
A estrutura de uma árvore enraizada é 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, na árvore acima, a subárvore do nó 2 consiste nos nós 2, 5, 6 e 8:
5 6
Travessia de árvore
Algoritmos gerais de travessia de grafos podem ser usados para percorrer os nós de uma árvore.
Porém, o percurso de uma árvore é mais fácil de implementar do que o de um grafo geral,
porque não há ciclos na árvore e não é possível chegar a um nó em múltiplas direções.
A maneira típica de percorrer uma árvore é iniciar uma busca em profundidade em um ponto arbitrário.
nó. A seguinte função recursiva pode ser usada:
dfs(x, 0);
Programaçao dinamica
A programação dinâmica pode ser usada para calcular algumas informações durante o
percurso de uma árvore. Usando programação dinâmica, podemos, por exemplo, calcular
em tempo O(n) para cada nó de uma árvore enraizada o número de nós em sua subárvore
ou o comprimento do caminho mais longo do nó até uma folha.
134
Machine Translated by Google
Diâmetro
O diâmetro de uma árvore é o comprimento máximo de um caminho entre dois nós. Por
exemplo, considere a seguinte árvore:
5 1 4
6 2 3 7
5 1 4
6 2 3 7
Observe que pode haver vários caminhos de comprimento máximo. No caminho acima,
poderíamos substituir o nó 6 pelo nó 5 para obter outro caminho com comprimento 4.
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 duas pesquisas em profundidade.
Algoritmo 1
Uma maneira geral de abordar muitos problemas de árvores é primeiro enraizar a árvore arbitrariamente.
Depois disso, podemos tentar resolver o problema separadamente para cada subárvore.
Nosso primeiro algoritmo para calcular o diâmetro é baseado nesta ideia.
Uma observação importante é que todo caminho em uma árvore enraizada possui um
ponto mais alto: o nó mais alto que pertence ao caminho. Assim, podemos calcular para cada
135
Machine Translated by Google
nó o comprimento do caminho mais longo cujo ponto mais alto é o nó. Um daqueles
caminhos corresponde ao diâmetro da árvore.
Por exemplo, na árvore a seguir, o nó 1 é o ponto mais alto do caminho
que corresponde ao diâmetro:
2 3 4
5 6 7
Algoritmo 2
Outra maneira eficiente de calcular o diâmetro de uma árvore é baseada em duas
pesquisas em profundidade. Primeiro, escolhemos um nó arbitrário a na árvore e encontramos o
o nó b mais distante de a. Então, encontramos o nó c mais distante de b. O diâmetro
da árvore é a distância entre b e c.
No gráfico a seguir, a, b e c poderiam ser:
5 1 4
b a c
6 2 3 7
136
Machine Translated by Google
b x c
6 2 1 4 7
5 3
a
3 5
1 2
4 6
Também neste problema, um bom ponto de partida para resolver o problema é enraizar
a árvore arbitrariamente:
2 3 4
5 6
137
Machine Translated by Google
2 3 4
5 6
Esta parte é fácil de resolver em tempo O(n) , porque podemos usar programação
dinâmica como fizemos anteriormente.
Então, a segunda parte do problema é calcular para cada nó x o comprimento máximo de um
caminho através do seu pai p. Por exemplo, o caminho mais longo do nó 3 passa pelo seu pai 1:
2 3 4
5 6
À primeira vista, parece que deveríamos escolher o caminho mais longo a partir da p.
Contudo, isto nem sempre funciona, porque o caminho mais longo de p pode passar por x. Aqui
está um exemplo desta situação:
2 3 4
5 6
Ainda assim, podemos resolver a segunda parte em tempo O(n) armazenando dois
comprimentos máximos para
cada nó x: • maxLength1 (x): o comprimento máximo de um caminho de x
138
Machine Translated by Google
Árvores binárias
Uma árvore binária é uma árvore enraizada onde cada nó possui uma subárvore esquerda e
direita. É possível que uma subárvore de um nó esteja vazia. Assim, cada nó em uma árvore binária
possui zero, um ou dois filhos.
Por exemplo, a árvore a seguir é uma árvore binária:
2 3
4 5 7
Os nós de uma árvore binária possuem três ordenações naturais que correspondem a
diferentes maneiras de percorrer recursivamente a árvore:
1 1
2 2
139
Machine Translated by Google
140
Machine Translated by Google
Capítulo 15
Árvores abrangentes
Uma árvore geradora de um grafo consiste em todos os nós do grafo e algumas das arestas
do grafo, de modo que haja um caminho entre quaisquer dois nós. Assim como as árvores
em geral, as árvores geradoras são conectadas e acíclicas. Geralmente existem diversas
maneiras de construir uma árvore geradora.
Por exemplo, considere o seguinte gráfico:
5
3 2 3 9
1 6 3 4
5 5 6 7
2
5
3 2 3 9
1 3 4
5 6
2
O peso de uma árvore geradora é a soma dos pesos de suas arestas. Por exemplo,
o peso da árvore geradora acima é 3+5+9+3+2 = 22.
Uma árvore geradora mínima é uma árvore geradora cujo peso é o menor possível.
O peso de uma árvore geradora mínima para o gráfico de exemplo é 20, e tal árvore pode
ser construída da seguinte forma:
3 2 3
1 3 4
5 5 6 7
2
141
Machine Translated by Google
De maneira semelhante, uma árvore geradora máxima é uma árvore geradora cujo
peso é o maior possível. O peso de uma árvore geradora máxima para o gráfico de
exemplo é 32:
5
2 3 9
1 6 4
5 5 6 7
Observe que um gráfico pode ter várias árvores geradoras mínimas e máximas,
então as árvores não são únicas.
Acontece que vários métodos gananciosos podem ser usados para construir árvores
geradoras mínimas e máximas. Neste capítulo, discutimos dois algoritmos que processam
as arestas do gráfico ordenadas por seus pesos. Nós nos concentramos em encontrar
árvores geradoras mínimas, mas os mesmos algoritmos podem encontrar árvores
geradoras máximas processando as arestas na ordem inversa.
Algoritmo de Kruskal
No algoritmo1 de Kruskal , a árvore geradora inicial contém apenas os nós do gráfico e
não contém arestas. Em seguida, o algoritmo percorre as arestas ordenadas por seus
pesos e sempre adiciona uma aresta à árvore se não criar um ciclo.
Exemplo
Vamos considerar como o algoritmo de Kruskal processa o seguinte gráfico:
5
3 2 3 9
1 6 3 4
5 5 6 7
2
142
Machine Translated by Google
peso da borda
5–6 2
1–2 3 3–
63
1–5 5 2–
3 5 2–5
6
4–6 7 3–
49
2 3
1 4
5 6
A primeira aresta a ser adicionada à árvore é a aresta 5–6 que cria um componente {5,6}
unindo os componentes {5} e {6}:
2 3
1 4
5 6
2
Depois disso, as arestas 1–2, 3–6 e 1–5 são adicionadas de maneira semelhante:
3 2 3
1 3 4
5 5 6
2
Após essas etapas, a maioria dos componentes foi unida e há dois componentes na
árvore: {1,2,3,5,6} e {4}.
A próxima aresta na lista é a aresta 2–3, mas não será incluída na árvore, porque os
nós 2 e 3 já estão no mesmo componente. Pela mesma razão, a aresta 2–5 não será
incluída na árvore.
143
Machine Translated by Google
3 2 3
1 3 4
5 5 6 7
2
Depois disso, o algoritmo não adicionará novas arestas, porque o grafo está conectado e existe
um caminho entre dois nós quaisquer. O gráfico resultante é uma árvore geradora mínima com peso
2+3+3+5+7 = 20.
2 3
1 4
5 6
No entanto, não é possível que a árvore acima seja uma árvore geradora mínima para o gráfico.
A razão para isso é que podemos remover uma aresta da árvore e substituí-la pela aresta de peso
mínimo 5–6. Isso produz uma árvore geradora cujo peso é menor:
2 3
1 4
5 6
2
Por esta razão, é sempre ideal 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 é ideal adicionar a próxima aresta em ordem de peso à árvore e assim por
diante. Conseqüentemente, o algoritmo de Kruskal funciona corretamente e sempre
produz uma árvore geradora mínima.
144
Machine Translated by Google
Implementação
Ao implementar o algoritmo de Kruskal, é conveniente usar a representação da lista
de arestas do gráfico. A primeira fase do algoritmo classifica as arestas da lista em
tempo O(mlogm) . Depois disso, a segunda fase do algoritmo constrói a árvore
geradora mínima da seguinte forma:
para (...) { if (!
same(a,b)) unir(a,b);
}
O loop passa pelas arestas da lista e sempre processa uma aresta a – b onde a e b
são dois nós. Duas funções são necessárias: a função mesmo determina se a e b estão
no mesmo componente, e a função unir une os componentes que contêm a e b.
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(logn) são suportadas: a operação unite une dois conjuntos e a operação find encontra
o representante do conjunto que contém um determinado element2 .
Estrutura
Em uma estrutura union-find, um elemento em cada conjunto é o representante do conjunto
e há uma cadeia de qualquer outro elemento do conjunto até o representante. Por exemplo,
suponha que os conjuntos sejam {1,4,7}, {5} e {2,3,6,8}:
4 5 2
1 7
3
6 8
2A estrutura aqui apresentada foi introduzida em 1971 por JD Hopcroft e JD Ullman [38].
Mais tarde, em 1975, RE Tarjan estudou uma variante mais sofisticada da estrutura [64] que é discutida
em muitos livros didáticos de algoritmos atualmente.
145
Machine Translated by Google
4 2
1 7
3
6 8
Implementação
A estrutura union-find pode ser implementada usando arrays. Na implementação a seguir,
o link da matriz contém para cada elemento o próximo elemento da cadeia ou o próprio
elemento se for um representante, e o tamanho da matriz indica para cada representante
o tamanho do conjunto correspondente.
Inicialmente, cada elemento pertence a um conjunto separado:
146
Machine Translated by Google
Algoritmo de Prim
O algoritmo de Prim3 é um método alternativo para encontrar uma árvore geradora
mínima. O algoritmo primeiro adiciona um nó arbitrário à árvore. Depois disso, o
algoritmo sempre escolhe uma aresta de peso mínimo que adiciona um novo nó à
árvore. Finalmente, todos os nós foram adicionados à árvore 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 uma aresta cuja distância do nó inicial é
mínima, mas o algoritmo de Prim simplesmente seleciona a aresta de peso mínimo
que adiciona um novo nó à árvore.
Exemplo
Vamos considerar como funciona o algoritmo de Prim no gráfico a seguir:
5
3 2 3 9
1 6 3 4
5 5 6 7
2
3O algoritmo recebeu o nome de RC Prim, que o publicou em 1957 [54]. No entanto, o mesmo
algoritmo foi descoberto já em 1930 por V. Jarník.
147
Machine Translated by Google
2 3
1 4
5 6
3 2 3
1 4
5 6
Depois disso, existem duas arestas com peso 5, então podemos adicionar o nó 3 ou o nó
5 à árvore. Vamos adicionar o nó 3 primeiro:
5
3 2 3
1 4
5 6
O processo continua até que todos os nós tenham sido incluídos na árvore:
5
3 2 3
1 3 4
5 6 7
2
Implementação
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+mlogm) 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.
148
Machine Translated by Google
Capítulo 16
Gráficos direcionados
Classificação topológica
Uma classificação topológica é uma ordenação dos nós de um grafo direcionado tal que se
existe um caminho do nó a para o nó b, então o nó a aparece antes do nó b no
encomenda. Por exemplo, para o gráfico
1 2 3
4 5 6
4 1 5 2 3 6
Um gráfico acíclico sempre possui uma classificação topológica. No entanto, se o gráfico contiver
um ciclo, não é possível formar uma ordenação topológica, porque nenhum nó do ciclo
pode aparecer antes dos demais nós do ciclo na ordenação. Acontece que
a pesquisa em profundidade pode ser usada para verificar se um gráfico direcionado contém um ciclo
e, se não contiver um ciclo, construir uma ordenação topológica.
149
Machine Translated by Google
Algoritmo
A ideia é percorrer os nós do gráfico e sempre começar uma profundidade
pesquise no nó atual se ele ainda não foi processado. Durante as pesquisas,
os nós têm três estados possíveis:
Exemplo 1
No gráfico de exemplo, a pesquisa primeiro prossegue do nó 1 ao nó 6:
1 2 3
4 5 6
1 2 3
4 5 6
1 2 3
4 5 6
150
Machine Translated by Google
Assim, a lista final é [6,3,2,1,5,4]. Processamos todos os nós, então uma topologia
4 5 1 2 3 6
Observe que uma classificação topológica não é única e pode haver diversas classificações topológicas.
Exemplo 2
Vamos agora considerar um gráfico para o qual não podemos construir uma classificação topológica,
porque o gráfico contém um ciclo:
1 2 3
4 5 6
1 2 3
4 5 6
Programaçao dinamica
Se um gráfico direcionado for acíclico, a programação dinâmica poderá ser aplicada a ele. Para
Por exemplo, podemos resolver eficientemente os seguintes problemas relativos a caminhos de um
nó inicial para um nó final:
151
Machine Translated by Google
1 2 3
4 5 6
•1ÿ2ÿ3ÿ6
•1ÿ4ÿ5ÿ2ÿ3ÿ6
•1ÿ4ÿ5ÿ3ÿ6
onde a1,a2,...,ak são os nós dos quais existe uma aresta para x. Desde o gráfico
é acíclico, os valores de caminhos (x) podem ser calculados na ordem de um topológico
organizar. Uma classificação topológica para o gráfico acima é a seguinte:
1 4 5 2 3 6
1 2 3
1 2 3
4 5 6
1 1 3
152
Machine Translated by Google
3
1 2 8
2
5 4 5
3 4 1
2
3
1 2
2
5 4 5
3 4 1
2
1 1
3
1 2
2
5 4 5
3
3 4 1
2
2 3
153
Machine Translated by Google
0 1 2 3 4 5 6
Caminhos sucessores
No restante do capítulo, focaremos nos gráficos sucessores. Nesses gráficos,
o grau externo de cada nó é 1, ou seja, exatamente uma aresta começa em cada nó. A
O gráfico sucessor consiste em um ou mais componentes, cada um dos quais contém um
ciclo e alguns caminhos que levam a ele.
Os gráficos sucessores às vezes são chamados de gráficos funcionais. A razão para
isto é que qualquer gráfico sucessor corresponde a uma função que define as arestas
do gráfico. O parâmetro para a função é um nó do gráfico, e o
função fornece o sucessor desse nó.
Por exemplo, a função
x123456789
succ(x) 3 5 7 6 2 2 1 6 3
9 3 1 2 5
7 6
4 8
4 6 2 5 2 5 2
154
Machine Translated by Google
x 1 2 3 4 5 6 7 8 9 succ(x,1) 3 5 7 6
2 2 1 6 3 succ(x,2) 7 2 1 2 5 5 3 2 7 succ(x,4)
3 2 7 2 5 5 1 2 3 succ(x,8) 7 2 1 2 5 5 3 2 7
···
Depois disso, qualquer valor de succ(x,k) pode ser calculado apresentando o número
de etapas k como uma soma de potências de dois. Por exemplo, se quisermos calcular
o valor de succ(x,11), primeiro formamos a representação 11 = 8+2+1. Usando isso,
succ(x,11) = succ(succ(succ(x,8),2),1).
succ(4,11) = succ(succ(succ(4,8),2),1) = 5.
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 a nossa caminhada no nó
inicial, qual é o primeiro nó do ciclo e quantos nós o ciclo contém?
Por exemplo, no gráfico
1 2 3 4 5
155
Machine Translated by Google
Algoritmo de Floyd
O algoritmo2 de Floyd avança no gráfico usando dois ponteiros a e b.
Ambos os ponteiros começam em um nó x que é o nó inicial do gráfico. Então, a
cada volta, o ponteiro a dá um passo à frente e o ponteiro b dá dois passos à frente.
O processo continua até que os ponteiros se encontrem:
a = succ(x); b
= succ(succ(x)); while
(a != b) { a = succ(a);
b = succ(succ(b));
uma =
x; while (a != b) { a =
succ(a); b =
succ(b);
} primeiro = a;
b = succ(a);
comprimento
= 1; enquanto (a! =
b) { b = succ
(b); comprimento++;
}
156
Machine Translated by Google
Capítulo 17
Conectividade forte
Em um gráfico direcionado, as arestas podem ser percorridas em apenas uma direção, portanto, mesmo que
o gráfico está conectado, isso não garante que haveria um caminho de
um nó para outro nó. Por esta razão, é significativo definir um novo conceito
isso requer mais do que conectividade.
Um grafo é fortemente conectado se existe um caminho de qualquer nó para todos os outros
nós no gráfico. Por exemplo, na imagem a seguir, o gráfico da esquerda é
fortemente conectado, enquanto o gráfico da direita não está.
1 2 1 2
3 4 3 4
O gráfico da direita não está fortemente conectado porque, por exemplo, não há
caminho do nó 2 ao nó 1.
Os componentes fortemente conectados de um gráfico dividem o gráfico em
peças fortemente conectadas que sejam tão grandes quanto possível. O fortemente conectado
os componentes formam um gráfico de componentes acíclicos que representa a estrutura
profunda do gráfico original.
Por exemplo, para o gráfico
1 2 3
4 5 6
1 2 3
4 5 6
157
Machine Translated by Google
C D
Algoritmo de Kosaraju
Pesquisa 1
A primeira fase do algoritmo de Kosaraju constrói uma lista de nós na ordem em que uma
busca 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 à lista após ser
processado.
No gráfico de exemplo, os nós são processados na seguinte ordem:
1 2 3
13/10
7
4 5 6
4/5 3/6 11/12
1De acordo com [1], SR Kosaraju inventou este algoritmo em 1978, mas não o publicou. Em
1981, o mesmo algoritmo foi redescoberto e publicado por M. Sharir [57].
158
Machine Translated by Google
tempo de processamento do nó
4 5
5 6
2 7
1 8
6 12
7 13
3 14
Pesquisa 2
1 2 3
4 5 6
1 2 3
4 5 6
Observe que como todas as arestas estão invertidas, o componente não “vaza” para outras
partes no gráfico.
159
Machine Translated by Google
1 2 3
4 5 6
1 2 3
4 5 6
Problema 2SAT
A forte conectividade também está associada ao problema 2SAT2 .Neste problema,
recebemos uma fórmula lógica
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 um valor a cada variável para que a fórmula seja
verdadeira, ou afirmar que isso não é possível.
Por exemplo, a fórmula
ÿ x1 = falso
x2 = falso
ÿÿÿÿÿ
x3 = verdadeiro
x4 = verdadeiro
ÿÿÿÿÿ
160
Machine Translated by Google
No entanto, a fórmula
é sempre falso, independentemente de como atribuímos os valores. A razão para isto é que
não podemos escolher um valor para x1 sem criar uma contradição. Se x1 for falso, x2 e ¬x2
deverão ser verdadeiros, o que é impossível, e se x1 for verdadeiro, ambos x3 e ¬x3 deverão
ser verdadeiros, o que também é impossível.
O problema 2SAT pode ser representado como um grafo cujos nós correspondem
variáveis xi e negações ¬xi as , e as arestas determinam as conexões entre
variáveis. Cada par (ai ÿ bi) gera duas arestas: ¬ai ÿ bi e ¬bi ÿ ai .
Isso significa que se ai não for válido, bi deverá ser válido e vice-versa.
O gráfico para a fórmula L1 é:
¬x3 x2 ¬x1 x4
¬x4 x1 ¬x2 x3
E o gráfico da fórmula L2 é:
¬x1
x3 x2 ¬x2 ¬x3
x1
A estrutura do gráfico nos diz se é possível atribuir os valores das variáveis para
que a fórmula seja verdadeira. Acontece que isso pode ser feito exatamente quando
não há nós xi e ¬xi , de modo que ambos os nós pertencem ao mesmo componente
fortemente conectado. Se existirem tais nós, o gráfico contém um caminho de xi para
¬xi e também um caminho de ¬xi para xi , portanto tanto , xi quanto ¬xi devem ser
verdadeiros, o que não é possível.
No gráfico da fórmula L1 não existem nós xi e ¬xi tais que ambos os nós
pertencem ao mesmo componente fortemente conectado, portanto existe uma
solução. No gráfico da fórmula L2 todos os nós pertencem ao mesmo componente
fortemente conectado, portanto não existe solução.
Se existir uma solução, os valores das variáveis podem ser encontrados percorrendo
os nós do gráfico componente em uma ordem de classificação 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 do componente não tiverem valores
atribuídos, seus valores serão determinados de acordo com os valores do componente, e se
161
Machine Translated by Google
eles já têm valores, permanecem inalterados. O processo continua até que cada
variável receba um valor.
O gráfico componente para a fórmula L1 é o seguinte:
A B C D
162
Machine Translated by Google
Capítulo 18
Consultas de árvore
Este capítulo discute técnicas para processar consultas em subárvores e caminhos de uma
árvore enraizada. Por exemplo, essas consultas são:
Encontrando ancestrais
O k- ésimo ancestral de um nó x em uma árvore enraizada é o nó que alcançaremos
se movermos k níveis acima de x. Deixe antepassado(x,k) denotar o k-ésimo
ancestral de um nó x (ou 0 se não existir tal ancestral). Por exemplo, na árvore a
seguir, ancestral(2,1) = 1 e ancestral(8,2) = 4.
4 5 2
3 7 6
163
Machine Translated by Google
Felizmente, usando uma técnica semelhante à usada no Capítulo 16.3, qualquer valor
do ancestral (x, k) pode ser calculado com eficiência em tempo O (logk) após o pré-processamento.
A ideia é pré-calcular todos os valores ancestral(x,k) onde k ÿ n é uma potência de dois.
Por exemplo, os valores para a árvore acima são os seguintes:
x12345678
ancestral (x,1) 0 1 4 1 1 2 4 7
ancestral (x,2) 0 0 1 0 0 1 1 4
ancestral(x,4) 0 0 0 0 0 0 0 0
···
Subárvores e caminhos
Uma matriz de passagem de árvore contém os nós de uma árvore enraizada na ordem em que
uma pesquisa em profundidade a partir do nó raiz os visita. Por exemplo, na árvore
2 3 4 5
6 7 8 9
2 3 4 5
6 7 8 9
126347895
164
Machine Translated by Google
Consultas de subárvore
Cada subárvore de uma árvore corresponde a uma submatriz da matriz de passagem de árvore, tal
que o primeiro elemento do subarray é o nó raiz. Por exemplo, o seguinte
subarray contém os nós da subárvore do nó 4:
126347895
Usando esse fato, podemos processar com eficiência consultas relacionadas a subárvores de
uma árvore. Como exemplo, considere um problema em que cada nó recebe um valor,
e nossa tarefa é apoiar as seguintes consultas:
• atualizar o valor de um nó
• calcular a soma dos valores na subárvore de um nó
Considere a seguinte árvore onde os números azuis são os valores do
nós. Por exemplo, a soma da subárvore do nó 4 é 3+4+3+1 = 11.
2
1
3 2 5 3 4 3 5 1
6 7 8 9
4 4 3 1
A ideia é construir um array de passagem em árvore que contenha três valores para
cada nó: o identificador do nó, o tamanho da subárvore e o valor do
nó. Por exemplo, a matriz da árvore acima é a seguinte:
ID do nó 126347895
tamanho da subárvore 921141111
valor do nó 234534311
Usando esta matriz, podemos calcular a soma dos valores em qualquer subárvore primeiro
descobrir o tamanho da subárvore e depois os valores dos nós correspondentes.
Por exemplo, os valores na subárvore do nó 4 podem ser encontrados da seguinte forma:
ID do nó 126347895
tamanho da subárvore 921141111
valor do nó 234534311
Para responder às consultas de forma eficiente, basta armazenar os valores dos nós
em uma árvore binária indexada ou de segmento. Depois disso, podemos atualizar um valor e
calcule a soma dos valores em tempo O (logn) .
165
Machine Translated by Google
Consultas de caminho
Usando uma matriz de passagem em árvore, também podemos calcular com eficiência somas de valores em
caminhos do nó raiz para qualquer nó da árvore. Considere um problema onde nosso
A tarefa é apoiar as seguintes consultas:
• alterar o valor de um nó
4
1
5 2 3 3 4 5 5 2
6 7 8 9
3 5 3 1
Podemos resolver este problema como antes, mas agora cada valor na última linha do
a matriz é a soma dos valores em um caminho da raiz ao nó. Por exemplo,
a seguinte matriz corresponde à árvore acima:
ID do nó 126347895
tamanho da subárvore 921141111
soma do caminho 4 9 12 7 9 14 12 10 6
ID do nó 126347895
tamanho da subárvore 921141111
soma do caminho 4 9 12 7 10 15 13 11 6
Assim, para apoiar ambas as operações, deveríamos ser capazes de aumentar todos os valores
em um intervalo e recuperar um único valor. Isso pode ser feito em tempo O(logn) usando um
árvore binária indexada ou de segmento (ver Capítulo 9.4).
166
Machine Translated by Google
2 3 4
5 6 7
A seguir discutiremos duas técnicas eficientes para encontrar o menor valor comum
ancestral de dois nós.
Método 1
Uma maneira de resolver o problema é usar o fato de que podemos encontrar eficientemente o
k-ésimo ancestral de qualquer nó da árvore. Usando isso, podemos dividir o problema de
encontrar o ancestral comum mais baixo em duas partes.
Usamos dois ponteiros que apontam inicialmente para os dois nós cujo menor valor comum
ancestral que devemos encontrar. Primeiro, movemos um dos ponteiros para cima para que ambos
ponteiros apontam para nós no mesmo nível.
No cenário de exemplo, 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:
2 3 4
5 6 7
167
Machine Translated by Google
Depois disso, determinamos o número mínimo de passos necessários para mover ambos
ponteiros para cima para que apontem para o mesmo nó. O nó ao qual o
os ponteiros apontam depois disso é o ancestral comum mais baixo.
No cenário de exemplo, basta mover ambos os ponteiros um passo para cima para
nó 2, que é o menor ancestral comum:
2 3 4
5 6 7
Como ambas as partes do algoritmo podem ser executadas em tempo O(logn) usando
informações pré-computadas, podemos encontrar o menor ancestral comum de quaisquer dois
nós em tempo O (logn) .
Método 2
Outra maneira de resolver o problema é baseada em uma travessia de árvore array1 . Outra vez,
a ideia é percorrer os nós usando uma pesquisa em profundidade:
2 3 4
5 6 7
No entanto, usamos uma matriz de passagem de árvore diferente da anterior: adicionamos cada
nó para a matriz sempre quando a pesquisa em profundidade percorre o nó,
e não apenas na primeira visita. Portanto, um nó que possui k filhos aparece k +1
vezes na matriz e há um total de 2n-1 nós na matriz.
1Este algoritmo de menor ancestral comum foi apresentado em [7]. Esta técnica às vezes é
chamada de técnica do tour de Euler [66].
168
Machine Translated by Google
ID do nó 125268621314741
profundidade 123234321212321
ID do nó 125268621314741
profundidade 123234321212321
ÿ
Distâncias de nós
A distância entre os nós a e b é igual ao comprimento do caminho de a a b. Acontece
que o problema de calcular a distância entre os nós se reduz a encontrar o seu
ancestral comum mais baixo.
Primeiro, enraízamos a árvore arbitrariamente. Depois disso, a distância dos nós a e b
pode ser calculado usando a fórmula
profundidade(a)+profundidade(b)ÿ2· profundidade(c),
2 3 4
5 6 7
169
Machine Translated by Google
Algoritmos off-line
Até agora, discutimos algoritmos online para consultas em árvores. Esses algoritmos
são capazes de processar consultas uma após a outra para que cada consulta seja respondida
antes de receber a próxima consulta.
Contudo, em muitos problemas, a propriedade online não é necessária. Nisso
seção, nos concentramos em algoritmos offline. Esses algoritmos recebem um conjunto de
perguntas que podem ser respondidas em qualquer ordem. Muitas vezes é mais fácil criar um ambiente off-line
2
1
3 2 5 3 4 3 5 1
6 7 8 9
4 4 3 1
Neste problema, podemos usar estruturas de mapas para responder às consultas. Para
Por exemplo, os mapas para o nó 4 e seus filhos são os seguintes:
134
121
4 3 1
1 1 1
170
Machine Translated by Google
Se criarmos tal estrutura de dados para cada nó, poderemos processar facilmente todas as
consultas fornecidas, porque podemos lidar com todas as consultas relacionadas a um nó
imediatamente após criar sua estrutura de dados. Por exemplo, a estrutura do mapa acima para o
nó 4 nos diz que sua subárvore contém dois nós cujo valor é 3.
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 é filho de s.
Por exemplo, na árvore acima, o mapa para o nó 4 é criado mesclando os seguintes
mapas:
3 4 3 1
1 1 1 1
trocar(a,b);
É garantido que o código acima funciona em tempo constante quando a e b são estruturas de dados
da biblioteca padrão C++.
171
Machine Translated by Google
Por exemplo, suponha que queremos encontrar os ancestrais comuns mais baixos de
pares de nós (5,8) e (2,7) na seguinte árvore:
2 3 4
5 6 7
Nas árvores a seguir, os nós cinzas indicam os nós visitados e os grupos tracejados de
nós pertencem ao mesmo conjunto. Quando o algoritmo visita o nó 8, ele percebe que
o nó 5 foi visitado e o nó mais alto em seu conjunto é 2. Assim, o nó mais baixo
ancestral comum dos nós 5 e 8 é 2:
2 3 4
5 6 7
2 3 4
5 6 7
172
Machine Translated by Google
Capítulo 19
Caminhos e circuitos
• Um caminho euleriano é um caminho que passa por cada aresta exatamente uma vez.
Caminhos Eulerianos
Um caminho euleriano1 é um caminho que passa exatamente uma vez por cada aresta do gráfico.
Por exemplo, o gráfico
1 2
4 5
1.
1 2 5.
2. 4. 3
4 5 6.
3.
1L. Euler estudou esses caminhos em 1736, quando resolveu o famoso problema da ponte de Königsberg.
Este foi o nascimento da teoria dos grafos.
173
Machine Translated by Google
1 2
4 5
6.
1 2 5.
1. 3. 3
2.
4 5 4.
Existência
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
1 2
4 5
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 grau ímpar,
então há um caminho euleriano entre os nós 2 e 5, mas o gráfico não contém um Circuito
Euleriano.
Em um gráfico direcionado, focamos nos graus de entrada e saída dos nós. Um grafo
direcionado contém um caminho euleriano exatamente quando todas as arestas pertencem
ao mesmo componente conectado e
174
Machine Translated by Google
• em um nó, o grau de entrada é um valor maior que o grau de saída, em outro nó, o
grau de saída é um valor 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 contém um caminho euleriano que começa no nó cujo grau externo é maior e
termina no nó cujo grau interno é maior.
Por exemplo, no gráfico
1 2
4 5
4. 6. 3
4 5 2.
3.
Algoritmo de Hierholzer
O algoritmo de Hierholzer2 é um método eficiente para construir um circuito Euleriano. O
algoritmo consiste em várias rodadas, cada uma das quais adiciona novas arestas ao
circuito. Claro, assumimos que o gráfico contém um circuito Euleriano; caso contrário, o
algoritmo de Hierholzer não poderá encontrá-lo.
Primeiro, o algoritmo constrói um circuito que contém algumas (não necessariamente
todas) das arestas do gráfico. Depois disso, o algoritmo estende o circuito passo a passo
adicionando subcircuitos a ele. O processo continua até que todas as arestas tenham sido
adicionadas ao circuito.
O algoritmo estende o circuito sempre encontrando um nó x que pertence ao
circuito, mas possui uma aresta de saída que não está incluída no circuito. O
algoritmo constrói um novo caminho a partir do nó x que contém apenas arestas que
ainda não estão no circuito. Mais cedo ou mais tarde, o caminho retornará ao nó x, o
que cria um subcircuito.
Se o grafo contém apenas um caminho euleriano, ainda podemos usar o algoritmo de
Hierholzer para encontrá-lo adicionando uma aresta extra ao grafo e removendo a aresta
após o circuito ter sido construído. Por exemplo, em um gráfico não direcionado, adicionamos
a aresta extra entre os dois nós de graus ímpares.
A seguir veremos como o algoritmo de Hierholzer constrói um circuito Euleriano
para um gráfico não direcionado.
175
Machine Translated by Google
Exemplo
Consideremos o seguinte gráfico:
2 3 4
5 6 7
1
1.
3.
2.
2 3 4
5 6 7
1
1.
6.
5.
2 3 4
4.
2.
5 6 7
3.
1
1.
10.
9. 5.
2 3 4
2. 8. 4. 6.
5 6 7
3. 7.
176
Machine Translated by Google
Agora que todas as arestas estão incluídas no circuito, construímos com sucesso um
circuito euleriano.
Caminhos hamiltonianos
Um caminho hamiltoniano é um caminho que visita cada nó do gráfico exatamente uma vez.
Por exemplo, o gráfico
1 2
4 5
1 2 4.
1. 3. 3
4 5
2.
1.
1 2 2.
5. 3
4 5 3.
4.
Existência
Nenhum método eficiente é conhecido para testar se um gráfico contém um caminho hamiltoniano,
e o problema é NP-difícil. Ainda assim, em alguns casos especiais, podemos ter certeza de que
um grafo contém um caminho hamiltoniano.
Uma observação simples é que se o grafo for completo, ou seja, houver uma aresta entre
todos os pares de nós, ele também contém um caminho hamiltoniano. Também foram alcançados
resultados mais fortes:
• Teorema de Ore: Se a soma dos graus de cada par de nós não adjacentes for
pelo menos n, o gráfico contém um caminho hamiltoniano.
177
Machine Translated by Google
Uma propriedade comum nesses teoremas e em outros resultados é que eles garantem a
existência de um caminho hamiltoniano se o grafo tiver um grande número de arestas. Isso faz
sentido, porque quanto mais arestas o gráfico contiver, mais possibilidades haverá de construir um
caminho hamiltoniano.
Construção
Como não existe uma maneira eficiente de verificar se existe um caminho hamiltoniano, é
claro que também não existe um método para construir eficientemente o caminho, porque
caso contrário poderíamos apenas tentar construir o caminho e ver se ele existe.
Uma maneira simples de procurar um caminho hamiltoniano é usar um algoritmo
de retrocesso que percorre todas as maneiras possíveis de construir o caminho. A
complexidade de tempo de tal algoritmo é de pelo menos O(n!), porque existem n!
diferentes maneiras de escolher a ordem de n nós.
Uma solução mais eficiente é baseada na programação dinâmica (ver Capítulo 10.5). A ideia é
calcular os valores de uma função possível (S, x), onde S é um subconjunto de nós e x é um dos
nós. A função indica se existe um caminho hamiltoniano que visita os nós de S e termina no nó x. É
possível implementar esta solução em O(2nn
2
) tempo.
Sequências de De Bruijn
Uma sequência de 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
dessa stringn + nÿ1 caracteres. Por exemplo, quando n = 3 e k = 2, um exemplo de
é k , 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.
Acontece que cada sequência de De Bruijn corresponde a um caminho euleriano
em um grafo. A ideia é construir um gráfico onde cada nó contém uma string de n
ÿ1 caracteres e cada aresta adiciona um caracter à string. O gráfico a seguir
corresponde ao cenário acima:
1 01 1
00 1 0 11
0 1
0 10 0
Um caminho euleriano neste gráfico corresponde a uma string que contém todas
as strings de comprimento n. A string contém os caracteres do nó inicial e todos os
caracteres das arestas. O nó inicial possui nÿ1 caracteres e existem k
n n
caracteres nas bordas, então o comprimento da string é k + nÿ1.
178
Machine Translated by Google
Passeios de cavaleiro
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
Regra de Warnsdorf
A regra de Warnsdorf é uma heurística simples e eficaz para encontrar o percurso de um cavaleiro3 .
Usando a regra, é possível construir um passeio com eficiência, mesmo em uma prancha grande.
A ideia é sempre mover o cavalo para que ele acabe em uma casa onde o número de
movimentos possíveis seja o menor possível.
Por exemplo, na seguinte situação, existem cinco casas possíveis para as quais
o cavalo pode mover-se (casas a... e):
1 a
2
b e
c d
Nesta situação, a regra de Warnsdorf move o cavalo para a casa a, porque após
esta escolha, só existe um único movimento possível. As outras opções moveriam
o cavalo para casas onde haveria três movimentos disponíveis.
3Esta heurística foi proposta no livro de Warnsdorf [69] em 1823. Existem também algoritmos
polinomiais para encontrar viagens de cavaleiros [52], mas são mais complicados.
179
Machine Translated by Google
180
Machine Translated by Google
Capítulo 20
Fluxos e cortes
6
5 2 3 5
1 3 8 6
4 4 5 2
1
Fluxo máximo
No problema de fluxo máximo , nossa tarefa é enviar o máximo de fluxo possível da fonte
para o sumidouro. O peso de cada aresta é uma capacidade que restringe o fluxo que
pode passar pela aresta. Em cada nó intermediário, o fluxo de entrada e saída deve ser
igual.
Por exemplo, o tamanho máximo de um fluxo no gráfico de exemplo é 7. O
A imagem a seguir mostra como podemos rotear o fluxo:
6/6
3/5 2 3 5/5
1 3/3 1/8 6
4/4 4 5 2/2
1/1
181
Machine Translated by Google
A notação v/k significa que um fluxo de v unidades é roteado através de uma aresta cuja
capacidade é de k unidades. O tamanho do fluxo é 7, porque a fonte envia 3+4 unidades de fluxo e
o sumidouro recebe 5 + 2 unidades de fluxo. É fácil perceber que esse fluxo é máximo, pois a
capacidade total das arestas que levam ao coletor é 7.
Corte mínimo
No problema do corte mínimo , nossa tarefa é remover um conjunto de arestas do gráfico de forma
que não haja caminho da fonte até o sumidouro após a remoção e o peso total das arestas removidas
seja mínimo.
O tamanho mínimo de um corte no gráfico de exemplo é 7. Basta remover
as arestas 2 ÿ 3 e 4 ÿ 5:
6
5 2 3 5
1 3 8 6
4 4 5 2
1
Algoritmo Ford-Fulkerson
O algoritmo Ford-Fulkerson [25] encontra o fluxo máximo em um gráfico. O algoritmo
começa com um fluxo vazio e, a cada passo, encontra um caminho da origem ao destino
que gera mais fluxo. Finalmente, quando o algoritmo não consegue mais aumentar o
fluxo, o fluxo máximo foi encontrado.
O algoritmo usa uma representação especial do gráfico onde cada aresta original possui uma
aresta reversa em outra direção. O peso de cada aresta indica quanto mais fluxo poderíamos
direcionar através dela. No início do algoritmo, o peso de cada aresta original é igual à capacidade
da aresta e o peso de cada aresta reversa é zero.
182
Machine Translated by Google
6
2 3
5 0 5
0 0
1 30 08 6
4 2
0 1 0
4 5
0
Descrição do algoritmo
O algoritmo Ford-Fulkerson consiste em várias rodadas. Em cada rodada, o algoritmo
encontra um caminho da origem ao destino, de modo que cada aresta do caminho tenha um
peso positivo. Se houver mais de um caminho possível disponível, podemos escolher
qualquer um deles.
Por exemplo, suponha que escolhemos o seguinte caminho:
6
2 3
5 0 5
0 0
1 30 08 6
4 2
0 1 0
4 5
0
4
2 3
3 2 5
0
1 30 26 6
24 0
0 1 2
4 5
0
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 posteriormente usando as
arestas invertidas do gráfico, caso se verifique que seria benéfico encaminhar o fluxo de
outra maneira.
O algoritmo aumenta o fluxo desde que haja um caminho da fonte ao sumidouro através
de arestas de peso positivo. No presente exemplo, nosso próximo caminho pode ser o
seguinte:
183
Machine Translated by Google
4
2 3
3 2 5
2 0
1 30 26 6
4 0
0 1 2
4 5
0
1
2 3
3 5 2
2 3
1 03 26 6
1 0
3 1 2
4 5
0
Ainda precisamos de mais duas rodadas antes de atingir o fluxo máximo. Por
exemplo , podemos escolher os caminhos 1 ÿ 2 ÿ 3 ÿ 6 e 1 ÿ 4 ÿ 5 ÿ 3 ÿ 6. Ambos os caminhos
aumente o fluxo em 1 e o gráfico final será o seguinte:
0
2 3
2 6 0
3 5
1 03 17 6
0 0
4 0 2
4 5
1
Encontrando caminhos
184
Machine Translated by Google
Cortes mínimos
Acontece que uma vez que o algoritmo Ford-Fulkerson encontrou um fluxo máximo,
ele também determinou um corte mínimo. Seja A o conjunto de nós que podem ser
alcançados a partir da fonte usando arestas de peso positivo. No gráfico de exemplo,
A contém os nós 1, 2 e 4:
0
2 3
2 6 0
3 5
1 03 17 6
0 0
4 0 2
4 5
1
Já o corte mínimo consiste nas arestas do grafo original que começam em algum
nó de A, terminam em algum nó fora de A, e cuja capacidade é totalmente utilizada
no fluxo máximo. No gráfico acima, tais arestas são 2 ÿ 3 e 4 ÿ 5, que correspondem
ao corte mínimo 6+1 = 7.
Por que o fluxo produzido pelo algoritmo é máximo e por que o corte é mínimo? A
razão é que um gráfico não pode conter um fluxo cujo tamanho seja maior que o peso
de qualquer corte do gráfico. Portanto, sempre que um fluxo e um corte são igualmente
grandes, eles são um fluxo máximo e um corte mínimo.
Consideremos qualquer corte do gráfico tal que a fonte pertença a A, o sumidouro
pertença a B e existam algumas arestas entre os conjuntos:
A B
185
Machine Translated by Google
Caminhos separados
2 3
1 6
4 5
2 3
1 6
4 5
186
Machine Translated by Google
2 3
1 6
4 5
Podemos reduzir também este problema ao problema de fluxo máximo. Desde cada
nó pode aparecer em no máximo um caminho, temos que limitar o fluxo que passa
os nós. Um método padrão para isso é dividir cada nó em dois nós, tais
que o primeiro nó possui 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ó.
Em nosso exemplo, o gráfico fica assim:
2 2 3 3
1 6
4 4 5 5
2 2 3 3
1 6
4 4 5 5
Máximo de correspondências
O problema de correspondência máxima pede para encontrar um conjunto de nós de tamanho máximo
pares em um gráfico não direcionado, de modo que cada par esteja conectado com uma aresta e
cada nó pertence a no máximo um par.
Existem algoritmos polinomiais para encontrar correspondências máximas em geral
gráficos [17], mas tais algoritmos são complexos e raramente vistos em programação
concursos. No entanto, em grafos bipartidos, o problema de correspondência máxima é muito
mais fácil de resolver, pois podemos reduzi-lo ao problema de vazão máxima.
187
Machine Translated by Google
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 esquerdo para o grupo direito. Por exemplo, no gráfico bipartido a
seguir, os grupos são {1,2,3,4} e {5,6,7,8}.
1 5
2 6
3 7
4 8
1 5
2 6
3 7
4 8
1 5
2 6
3 7
4 8
1 5
2 6
3 7
4 8
188
Machine Translated by Google
Teorema de Hall
O teorema de Hall pode ser usado para descobrir se um grafo bipartido possui 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 gráfico.
Suponha que queremos encontrar uma correspondência que contenha todos os nós esquerdos.
Seja X qualquer conjunto de nós à esquerda e seja f (X) o conjunto de seus vizinhos. De acordo
com o teorema de Hall, um emparelhamento que contém todos os nós à esquerda existe exatamente
quando, para cada X, a condição |X| ÿ |f (X)| segura.
Vamos estudar o teorema de Hall no gráfico de exemplo. Primeiro, seja X = {1,3} que
produz f (X) = {5,6,8}:
1 5
2 6
3 7
4 8
1 5
2 6
3 7
4 8
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 fornece uma explicação por que
não podemos formar tal emparelhamento. Como X contém mais nós do que f (X), não há pares para
todos os nós em X. Por exemplo, no gráfico acima, ambos os nós 2 e 4 devem estar conectados ao
nó 7, o que não é possível.
Teorema de Kÿnig
Uma cobertura mínima de nós de um gráfico é um conjunto mínimo de nós tal que cada
aresta do gráfico tenha pelo menos um ponto final no conjunto. Em um grafo geral,
encontrar uma cobertura mínima de nós é um problema NP-difícil. No entanto, se o gráfico
ÿ
for bipartido, o teorema de Konig nos diz que o tamanho da cobertura mínima do nó e o tamanho
189
Machine Translated by Google
de uma correspondência máxima são sempre iguais. Assim, podemos calcular o tamanho de
uma cobertura mínima de nó usando um algoritmo de fluxo máximo.
Vamos considerar o seguinte gráfico com correspondência máxima de tamanho 3:
1 5
2 6
3 7
4 8
Agora, o teorema de Konig nos diz que o tamanho da cobertura mínima de um nó também é 3.
1 5
2 6
3 7
4 8
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 possível de nós, de modo que não há dois
nós no conjunto conectados por uma aresta. Mais uma vez, 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 Konig para resolver o problema de forma eficiente. No
gráfico de exemplo, o conjunto independente máximo é o seguinte:
1 5
2 6
3 7
4 8
Coberturas de caminho
190
Machine Translated by Google
Em uma cobertura de caminho com nós disjuntos, cada nó pertence a exatamente um caminho. Como um
por exemplo, considere o seguinte gráfico:
1 2 3 4
5 6 7
Uma cobertura mínima de caminho disjunto de nó deste gráfico consiste em três caminhos. Para
por exemplo, podemos escolher os seguintes caminhos:
1 2 3 4
5 6 7
Observe que um dos caminhos contém apenas o nó 2, então é possível que um caminho
não contém arestas.
Podemos encontrar uma cobertura mínima de caminho disjunto de nós construindo uma correspondência
gráfico onde cada nó do gráfico original é representado por dois nós: um esquerdo
nó e um nó direito. Existe uma aresta de um nó esquerdo para um nó direito se houver
é essa aresta no gráfico original. Além disso, o gráfico correspondente contém um
fonte e um coletor, e há arestas da origem para todos os nós esquerdos e de
todos os nós certos para o coletor.
Uma correspondência máxima no grafo resultante corresponde a uma cobertura mínima do caminho
nó-disjunto no grafo original. Por exemplo, a seguinte correspondência
gráfico para o gráfico acima contém uma correspondência máxima de tamanho 4:
1 1
2 2
3 3
4 4
5 5
6 6
7 7
191
Machine Translated by Google
Uma cobertura de caminho geral é uma cobertura de caminho onde um nó pode pertencer a mais de
um caminho. Uma cobertura mínima do caminho geral pode ser menor que um mínimo
cobertura de caminho disjunto de nó, porque um nó pode ser usado várias vezes em caminhos.
Considere novamente o seguinte gráfico:
1 2 3 4
5 6 7
A cobertura geral mínima do caminho deste gráfico consiste em dois caminhos. Para
por exemplo, o primeiro caminho pode ser o seguinte:
1 2 3 4
5 6 7
1 2 3 4
5 6 7
Uma cobertura mínima de caminho geral pode ser encontrada quase como uma cobertura mínima
de caminho disjunto de nós. É suficiente adicionar algumas novas arestas ao gráfico correspondente
de modo que haja uma aresta a ÿ b sempre que houver um caminho de a para b no
gráfico original (possivelmente através de várias arestas).
O gráfico correspondente para o gráfico acima é o seguinte:
1 1
2 2
3 3
4 4
5 5
6 6
7 7
192
Machine Translated by Google
Teorema de Dilworth
Uma anticadeia é um conjunto de nós de um grafo tal que não há caminho de nenhum
nó para outro nó usando as bordas do gráfico. O teorema de Dilworth afirma
que em um gráfico acíclico direcionado, o tamanho de uma cobertura mínima do caminho geral é igual
o tamanho de uma anticadeia máxima.
Por exemplo, os nós 3 e 7 formam uma anticadeia no gráfico a seguir:
1 2 3 4
5 6 7
193
Machine Translated by Google
194
Machine Translated by Google
Parte III
Tópicos avançados
195
Machine Translated by Google
Machine Translated by Google
Capítulo 21
A teoria dos números é um ramo da matemática que estuda números inteiros. A teoria dos
números é um campo fascinante, porque muitas questões que envolvem números inteiros são
muito difíceis de resolver, mesmo que pareçam simples à primeira vista.
Como exemplo, considere a seguinte equação:
33=
3x _ +z 33 + y
É fácil encontrar três números reais x, y e z que satisfaçam a equação. Por exemplo, podemos
escolher
x = 3,
3
você = 3,
3
z= 3.
No entanto, é um problema aberto na teoria dos números se existem três inteiros x, y e z que
satisfaçam a equação [6].
Neste capítulo, nos concentraremos em conceitos básicos e algoritmos da teoria dos
números. Ao longo do capítulo, assumiremos que todos os números são inteiros, salvo indicação
em contrário.
Primos e fatores
Um número a é chamado de fator ou divisor de um número 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 número n > 1 é primo se seus únicos fatores positivos forem 1 e n. Por
exemplo, 7, 19 e 41 são primos, mas 35 não é primo, porque 5·7 = 35. Para cada
número n > 1, existe uma única fatoração prima
ÿ1 ÿ2 ÿk
n=p 1 p. 2 ··· p k ,
onde p1, p2,..., pk são primos distintos e ÿ1,ÿ2,...,ÿk são números positivos.
Por exemplo, a fatoração primária para 84 é
2 1 1 84 =
2 ·3 ·7 .
197
Machine Translated by Google
pois para cada pi primo , existem ÿi + 1 maneiras de escolher quantas vezes ele
aparece no fator. Por exemplo, o número de fatores de 84 é ÿ(84) = 3·2·2 = 12.
Os fatores são 1, 2, 3, 4, 6, 7, 12, 14, 21, 28, 42 e 84.
A soma dos fatores de n é
k k ai+1
eu ÿ1 p
ÿ(n) = ai
(1+ pi +...+ p eu
)= ,
eu=1 eu=1 pi ÿ1
onde a última fórmula é baseada na fórmula de progressão geométrica. Por exemplo, a soma dos fatores
de 84 é
3 2 ÿ1 2 3 ÿ1 2 7 ÿ1
ÿ(84) = · · = 7·4·8 = 224.
2-1 3-1 7ÿ1
O produto dos fatores de n é
ÿ(n)/2
µ(n) = n ,
porque podemos formar pares ÿ(n)/2 a partir dos fatores, cada um com produto n.
Por exemplo, os fatores de 84 produzem os pares 1 · 84, 2 · 42, 3 · 28, etc., e o
produto dos fatores é µ(84) = 846 = 351298031616.
Um número n é chamado de número perfeito se n = ÿ(n)ÿn, ou seja, n é igual à
soma de seus fatores entre 1 e nÿ1. Por exemplo, 28 é um número perfeito, porque
28 = 1+2+4+7+14.
Número de 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 P, poderíamos formar um novo primo
p1p2 ··· pn +1
Densidade de primos
A densidade de primos significa quantas vezes existem primos entre os números.
Deixe ÿ(n) denotar o número de primos entre 1 e n. Por exemplo, ÿ(10) = 4, porque
existem 4 primos entre 1 e 10: 2, 3, 5 e 7.
É possível mostrar que
n
ÿ(n ) ,
ÿlnn
o que significa que os primos são bastante frequentes. Por exemplo, o número de primos entre 1 e 106 é
ÿ(106 ) = 78498 e 106 /ln106 ÿ 72382.
198
Machine Translated by Google
Conjecturas
Existem muitas conjecturas envolvendo números primos. A maioria das pessoas pensa
que as conjecturas são verdadeiras, mas ninguém foi capaz de prová-las. Por exemplo,
as seguintes conjecturas são famosas:
• Conjectura de Goldbach: Cada inteiro par n > 2 pode ser representado como um
soma n = a+ b para que a e b sejam primos.
Algoritmos básicos
Se um número n não é primo, ele pode ser representado como um produto a· b, onde
a ÿ n ou b ÿ n, portanto certamente possui um fator entre 2 e n. Usando esta
observação, podemos testar se um número é primo e encontrar a fatoração prima de
um número em tempo O( n).
A seguinte função prime verifica se o número n fornecido é primo. A função tenta
dividir n por todos os números entre 2 e n, e se nenhum deles dividir n, então n é primo.
bool prime(int n) { if (n
<2) retorna falso; for (int x = 2;
x*x <= n; x++) { if (n%x == 0) retornar
falso;
}
retornar verdadeiro;
}
} se (n > 1) f.push_back(n);
retornar f;
}
199
Machine Translated by Google
Observe que cada fator primo aparece no vetor tantas vezes quantas divide
3
o número. Por exemplo, 24 = 2 ·3, então o resultado da função é [2,2,2,3].
Peneira de Eratóstenes
2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
0020302350307520305
O loop interno do algoritmo é executado n/x vezes para cada valor de x. Por isso,
um limite superior para o tempo de execução do algoritmo é a soma harmônica
n
n/x = n/2+ n/3+ n/4+··· + n/n = O(nlogn).
x=2
Algoritmo de Euclides
O máximo divisor comum dos números a e b, mdc(a,b), é o maior número que
divide a e b, e o mínimo múltiplo comum de a e b, lcm(a,b), é o menor número
que é divisível por a e b. Por exemplo, mdc(24,36) = 12 e lcm(24,36) = 72.
200
Machine Translated by Google
a b=0
mdc(a,b) =
mdc(b,a mod b) b = 0
Por exemplo,
Aritmética modular
Na aritmética modular, o conjunto de números é limitado de modo que apenas os números
0,1,2,...,mÿ1 sejam usados, onde m é uma constante. Cada número x é representado pelo
número x mod m: o resto após a divisão de x por m. Por exemplo, se m = 17, então 75 é
representado por 75 mod 17 = 7.
Muitas vezes podemos tirar os restos antes de fazer os cálculos. Em particular, o
as seguintes fórmulas são válidas:
1Euclides foi um matemático grego que viveu por volta de 300 a.C. Este é talvez o primeiro
algoritmo conhecido na história.
201
Machine Translated by Google
Exponenciação modular
n mod m. Isso pode ser
Muitas vezes é necessário calcular eficientemente o valor de x feito em
tempo O(logn) usando a seguinte recursão:
ÿ1 n=0
= n/2 n/2 x ·
nx ÿÿÿ x n é par
nÿ1
ÿÿÿ
· xn é ímpar x
n/2 é calculado
É importante que no caso de n par, o valor de x
apenas uma vez. Isso garante que a complexidade de tempo do algoritmo seja O(logn), porque n é
sempre reduzido à metade quando é par.
n mod m:
A função a seguir calcula o valor de x
mÿ 1x módulo m = 1
x ÿ(m) mod m = 1
quando x e m são coprimos. O teorema de Fermat segue do teorema de Euler, porque se m é primo, então
ÿ(m) = mÿ1.
Modular inverso
ÿ1 de tal modo que
O inverso de x módulo m é um número x
xxÿ1 mod m = 1.
ÿ1
Por exemplo, se x = 6 e m = 17, então x = 3, porque 6·3 mod 17 = 1.
Usando inversos modulares, podemos dividir os números módulo m, porque a divisão
ÿ1
por x corresponde à multiplicação por x . Por exemplo, para avaliar o valor
202
Machine Translated by Google
de 36/6 mod 17, podemos usar a fórmula 2·3 mod 17, porque 36 mod 17 = 2 e ÿ1 6
mod 17 = 3.
No entanto, nem sempre existe um inverso modular. Por exemplo, se x = 2 e
m = 4, a equação
xxÿ1 mod m = 1
não pode ser resolvido, porque todos os múltiplos de 2 são pares e o resto nunca pode
ÿ1 ser 1 quando m = 4. Acontece que o valor de x mod m pode ser calculado exatamente
quando x e m são primos.
Se existir um inverso modular, ele pode ser calculado usando a fórmula
ÿ1x
x= ÿ(m)ÿ1 .
ÿ1 mÿ2 x = x
.
Por exemplo,
ÿ1 17ÿ2 6 mod 17 = 6
módulo 17 = 3.
Esta fórmula nos permite calcular com eficiência inversos modulares usando o algoritmo de
expoente modular. A fórmula pode ser derivada usando o teorema de Euler. Primeiro, o inverso modular
deve satisfazer a seguinte equação:
xxÿ1 mod m = 1.
ÿ1
então os números x e x sãoÿ(m)ÿ1
iguais.
Aritmética computacional
203
Machine Translated by Google
Resolvendo equações
Equações diofantinas
Uma equação diofantina é uma equação da forma
machado+ por = c,
onde a, b e c são constantes e os valores de x e y devem ser encontrados. Cada número na equação deve ser um
número inteiro. Por exemplo, uma solução para a equação 5x+2y = 11 é x = 3 e y = ÿ2.
Podemos resolver com eficiência uma equação diofantina usando o algoritmo de Euclides.
Acontece que podemos estender o algoritmo de Euclides para que ele encontre números x e y que
satisfaçam a seguinte equação:
Uma equação diofantina pode ser resolvida se c for divisível por mdc(a,b), e outros-
sábio, não pode ser resolvido.
Como exemplo, vamos encontrar os números x e y que satisfazem a seguinte equação:
39x+15y = 12
39ÿ2·15 = 9 15ÿ1·9
=6
9ÿ1·6 = 3
39·2+15·(ÿ5) = 3
39·8+15·(ÿ20) = 12,
número inteiro.
204
Machine Translated by Google
x = a1 mod m1 x = a2
mod m2
···
x = um mod mn
m1m2 ···mn
Xk = .
mk
ÿ1 ÿ1 ÿ1
x = a1X1X1 m1 + a2X2X2 m2 +··· + anXnXn homem
.
porque
ÿ1
XkXk mk 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.
x+ m1m2 ···mn
são soluções.
Outros resultados
Teorema de Lagrange
O teorema de Lagrange afirma que todo número inteiro positivo pode ser representado como um
2222+b+d+c
soma de quatro quadrados, ou . Por exemplo, o número 123 pode ser
2 2 2 +5 +3
seja, a representado como a soma 82 +5 .
205
Machine Translated by Google
Teorema de Zeckendorf
O teorema de Zeckendorf afirma que todo número inteiro positivo tem uma representação única
como uma soma de números de Fibonacci, de modo que não há dois números iguais ou números
de Fibonacci consecutivos. Por exemplo, o número 74 pode ser representado como a soma
55+13+5+1.
Triplos pitagóricos
Um triplo pitagórico é um triplo (a, b, c) que satisfaz o teorema de Pitágoras, o que
2 2 2 + ba = c
, significa que existe um triângulo retângulo com comprimentos laterais a, b e
c. Por exemplo, (3,4,5) é um triplo pitagórico.
Se (a,b, c) é um triplo pitagórico, todos os triplos da forma (ka,kb,kc) também
são triplos pitagóricos onde k > 1. Um triplo pitagórico é primitivo se a, b e c são
coprimos, e todos Os triplos pitagóricos podem ser construídos a partir de triplos
primitivos usando um multiplicador k.
A fórmula de Euclides pode ser usada para produzir todos os triplos pitagóricos primitivos.
Cada um desses triplos tem a forma
(n 2 ÿ m2 ,2nm,n 2 + m2 ),
onde 0 < m < n, n e m são primos e pelo menos um de n e m é par. Por exemplo, quando
m = 1 e n = 2, a fórmula produz o menor triplo pitagórico
2 2 +1
(22 ÿ1 2 ,2·2·1,2 ) = (3,4,5).
Teorema de Wilson
O teorema de Wilson afirma que um número n é primo exatamente quando
Conseqüentemente, o teorema de Wilson pode ser usado para descobrir se um número é primo.
Contudo, na prática, o teorema não pode ser aplicado a grandes valores de n, porque é difícil
calcular valores de (nÿ1)! quando n é grande.
206
Machine Translated by Google
Capítulo 22
Combinatória
• 1+1+1+1 • 2+2
• 1+1+2 • 3+1
• 1+2+1 • 1+3
• 2+1+1 •4
Muitas vezes, um problema combinatório pode ser resolvido usando uma função
recursiva. Neste problema, podemos definir uma função f (n) que fornece o número de
representações para n. Por exemplo, f (4) = 8 de acordo com o exemplo acima. Os valores
da função podem ser calculados recursivamente da seguinte forma:
1 n=0
f (n) =
f (0)+ f (1)+··· + f (nÿ1) n > 0
O caso base é f (0) = 1, pois a soma vazia representa o número 0. Então, se n > 0,
consideramos todas as formas de escolher o primeiro número da soma. Se o
primeiro número for k, existem representações f (n ÿ k) para a parte restante da soma.
Assim, calculamos a soma de todos os valores da forma f (nÿ k) onde k < n.
Os primeiros valores da função são:
f (0) = 1 f
(1) = 1 f
(2) = 2 f
(3) = 4 f (4)
=8
Às vezes, uma fórmula recursiva pode ser substituída por uma fórmula fechada.
Neste problema,
f (n) = 2 n-1 ,
207
Machine Translated by Google
que se baseia no fato de que existem nÿ1 posições possíveis para sinais + na soma e
podemos escolher qualquer subconjunto delas.
Coeficientes binomiais
n
O coeficiente binomial é igual ao número de maneiras pelas quais podemos escolher um
k
5
subconjunto de k elementos de um conjunto de n elementos. Por exemplo, = 10, porque o conjunto
3
{1,2,3,4,5} possui 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}
Fórmula 1
n n-1 n-1
= +
k k ÿ1 k
n n
= = 1,
0 n
porque sempre existe exatamente uma maneira de construir um subconjunto vazio e um subconjunto que
contém todos os elementos.
Fórmula 2
n não!
= .
k k!(nÿ k)!
Propriedades
Para coeficientes binomiais,
n n
= ,
k nÿk
208
Machine Translated by Google
n n n nÿ1 1 n n n
(a+ b) = n/D 0b+ b +...+ a
1nÿ1b+
uma ba0 .
0 1 n-1 n
Os coeficientes binomiais também aparecem no triângulo de Pascal , onde cada valor
é igual à soma dos dois valores acima:
1
1 1
121
131 3 1
46 4 1
... ... ... ... ...
Caixas e bolas
“Caixas e bolas” é um modelo útil, onde contamos as maneiras de colocar 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 soluções:
209
Machine Translated by Google
O processo de colocação das bolas nas caixas pode ser representado como
uma string que consiste nos 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 passamos para a próxima caixa à direita.
Usando esta notação, cada solução é uma string que contém k vezes o símbolo
“o” e nÿ1 vezes o símbolo ”ÿ”. Por exemplo, a solução superior direita na imagem
acima corresponde à string ”ÿ ÿ o ÿ o ÿ”. Assim, o número de soluções é
k+nÿ 1k
.
Cenário 3: Cada caixa pode conter no máximo uma bola e, além disso, duas
caixas adjacentes não podem conter uma bola. Por exemplo, quando n = 5 ek = 2,
existem 6 soluções:
Coeficientes multinomiais
O coeficiente multinomial
n não!
= ,
k1,k2,...,km k1!k2!···km!
Números catalães
O número catalão Cn é igual ao 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 o seguinte parêntese
expressões usando três parênteses esquerdo e direito:
• ()()() •
(())() • ()
(()) • ((()))
• (()())
210
Machine Translated by Google
Fórmula 1
n-1
Cn = CiCnÿiÿ1.
eu=0
A soma passa pelas formas de dividir a expressão em duas partes, de modo que ambas as
partes sejam expressões válidas e a primeira parte seja a mais curta possível, mas não vazia. Para
qualquer i, a primeira parte contém i +1 pares de parênteses e o número de expressões é o produto
dos seguintes valores:
O caso base é C0 = 1, porque podemos construir uma expressão de parênteses vazia usando
zero pares de parênteses.
Fórmula 2
1 2n
Cn
= n+1 n
211
Machine Translated by Google
a ideia é inverter cada parêntese que pertence a tal prefixo. Por exemplo, a expressão ())()
( contém um prefixo ()), e após reverter o prefixo, a expressão se torna )((()(.
2n 2n 2n n 2n 1 2n
- = - = .
n n+1 n n+1 n n+1 n
Contando árvores
Os números catalães também estão relacionados às árvores:
Inclusão-exclusão
Inclusão-exclusão é uma técnica que pode ser utilizada 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
onde A e B são conjuntos e |X| denota o tamanho de X. A fórmula pode ser ilustrada da
seguinte forma:
AAÿBB _
212
Machine Translated by Google
A mesma ideia pode ser aplicada quando o número de conjuntos for maior. Quando lá
são 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|
e a imagem correspondente é
A ÿCB ÿC
A ÿB ÿC
A B
AÿB _
|A ÿB ÿC| = |A| +|B| +|C| ÿ|A ÿB| ÿ|A ÿC| ÿ|B ÿC| +|A ÿB ÿC|.
Desarranjos
Como exemplo, contemos o número de desarranjos dos elementos {1,2,...,n}, ou
seja, permutações onde nenhum elemento permanece em seu lugar original. Por
exemplo, quando n = 3, existem duas perturbações: (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)}
213
Machine Translated by Google
então basta calcular o tamanho do sindicato. Usando inclusão-exclusão, isso se reduz ao cálculo do
tamanho das interseções, o que pode ser feito de forma eficiente. Por exemplo, quando n = 3, o tamanho
de |X1 ÿ X2 ÿ X3| é
|X1| +|X2| +|X3| ÿ|X1 ÿ X2| ÿ|X1 ÿ X3| ÿ|X2 ÿ X3| +|X1 ÿ X2 ÿ X3|
= 2+2+2ÿ1ÿ1ÿ1+1
= 4,
ÿ0 n=1
f (n) = ÿÿÿ
1 n=2
ÿÿÿ
Lema de Burnside
O lema de Burnside pode ser usado para contar o número de combinações de modo
que apenas um representante seja contado para cada grupo de combinações simétricas.
O lema de Burnside afirma que o número de combinações é
n c(k)
,
n
k=1
214
Machine Translated by Google
mgcd(k,n)
n-1 mgcd(eu,n)
.
eu=0
n
Fórmula de Cayley
n-2 árvores rotuladas que contêm n nós.
A fórmula de Cayley afirma que existem n
Os nós são rotulados como 1,2,...,n, e duas árvores são diferentes se sua estrutura
ou rotulagem forem diferentes.
Por exemplo, quando n = 4, o número de árvores rotuladas é 44ÿ2 = 16:
1 2 3 4
2 3 4 1 3 4 1 2 4 1 2 3
2 1 3 4 2 143 2 3 1 4
241 3 3 124 3 2 1 4
A seguir veremos como a fórmula de Cayley pode ser derivada usando códigos de Prüfer.
215
Machine Translated by Google
Código Prufer
Um código de Prüfer é uma sequência de nÿ2 números que descreve 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 é removida e o rótulo do seu único vizinho é
adicionado ao código.
Por exemplo, vamos calcular o código de Prüfer do seguinte gráfico:
1 2
3 4
3 4
216
Machine Translated by Google
Capítulo 23
Matrizes
é 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 na coluna j em uma matriz. Por exemplo, na matriz acima, A[2,3] = 8 e
A[3,1] = 9.
Um caso especial de matriz é um vetor que é uma matriz unidimensional de tamanho
n×1. Por exemplo,
4
V=
7
5ÿÿ
ÿÿ
ÿ 6 7 9 ÿ
AT
= 13 0 5
ÿ
ÿ
ÿ
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
024
ÿÿ ÿÿ
Operações
A soma A + B das matrizes A e B é definida se as matrizes forem do mesmo tamanho. O
resultado é uma matriz onde cada elemento é a soma dos elementos correspondentes em A e
B.
217
Machine Translated by Google
Por exemplo,
Multiplicar uma matriz A por um valor x significa que cada elemento de A é multi-
aplicado por x. Por exemplo,
Multiplicação da matriz
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 cujo os elementos são calculados usando a fórmula
n
AB[eu, j] = A[i,k]·B[k, j].
k=1
A ideia é que cada elemento de AB seja uma soma dos produtos dos elementos de A e
B conforme imagem a seguir:
A AB
Por exemplo,
14 1·1+4·2 1·6+4·9 9 42
16
39 = 3·1+9·2 3·6+9·9 = 21 99
29
86
ÿÿ ÿ ÿ· 8·1+6·2 8·6+6·9
ÿÿ ÿÿ
20 102
ÿÿ ÿ ÿ.
218
Machine Translated by Google
Multiplicar uma matriz por uma matriz identidade não a altera. Por exemplo,
100 14 14 14 14
ÿ ÿ ÿ· = ÿ 10 =
010 39 39 e 39 39
ÿ·
01 ÿ ÿ.
001
ÿÿ ÿ
86 ÿÿ
86
ÿÿ ÿÿ
86
ÿÿ
86
ÿÿ
Poder da matriz
k
A potência A de uma matriz A é definido se A é uma matriz quadrada. A definição é
baseada na multiplicação de matrizes:
Ak
= UMA · UMA · UMA ··· UMA
k vezes
Por exemplo,
25 25 · 25 · 25 = 48 165
.
1 43 = 14 14 14 33 114
25 10
.
1 40 = 01
25 25 25
1 48 = 1 44 · 1 44 .
Determinante
O determinante det(A) de uma matriz A é definido se A for uma matriz quadrada.
Se A tiver tamanho 1 × 1, então det(A) = A[1,1]. O determinante de uma matriz maior
é calculado recursivamente usando a fórmula
n
det(A) = A[1, j]C[1, j],
j=1
onde C[i, j] é o cofator de A em [i, j]. O cofator é calculado usando a fórmula
219
Machine Translated by Google
243
162 567 517
det( 5 1 6 )ÿ4·det( )+3·det( ) = 81.
ÿÿ ÿ ÿ) = 2·det( 4 4 2
724
ÿ1
O determinante de A nos diz se existe uma matriz inversa A tal
ÿ1 que ÿ1
= I, onde I é uma matriz identidade. Acontece que A existe exatamente
A· A quando det(A) = 0, e pode ser calculado usando a fórmula
C[j,i]
A ÿ1 [i, j] = .
det(A)
Por exemplo,
A A-1 EU
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
Números de Fibonacci
Um exemplo simples de recorrência linear é a seguinte função que define o
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.
220
Machine Translated by Google
f = f (eu +1)
X·
(eu) f (eu +1) f (eu +2)
Assim, os valores f (i) e f (i +1) são dados como “entrada” para X, e X calcula os valores f (i +1)
e f (i +2) a partir deles. Acontece que tal matriz é
01
X= .
11
Por exemplo,
01 · f (5) = 0 1 · 5 = = f (6)
.
11 f (6) 11 8 8 13 f (7)
Assim, podemos calcular f (n) usando a fórmula
f (n) n · f (0) = 0 1 0
=X .
f (n+1) f (1) 11n · _ 1
n
O valor de X pode ser calculado em tempo O (logn) , então o valor de f (n) também pode
será calculado em tempo O (logn) .
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 ÿ ÿ
f (eu +1) ÿ
ÿ
= f (eu +2)
ÿ ÿ
X· .
ÿ ÿ ÿ ÿ
ÿ .. ÿ ..
ÿ ÿ
. .
ÿ ÿ ÿ ÿ
f (eu + k ÿ1) f (eu + k)
Tal matriz é
ÿ
01 0 0 ··· 0 ÿ
ÿ
ÿ
00 1 ··· 0 ÿ
ÿ
00 0 01 ··· 0 ÿ
X= ÿ
.. .. .. .. .. . ÿ
.
ÿ
ÿ
. . . . . .. ÿ
00 0 0 ··· 1
ÿ ÿ
Nas primeiras k ÿ1 linhas, cada elemento é 0, exceto que um elemento é 1. Essas linhas
substituem f (i) por f (i +1), f (i +1) por f (i +2), e assim sobre. A última linha contém os
coeficientes de recorrência para calcular o novo valor f (i + k). logn) tempo usando
3
Agora, f (n) pode ser calculado em O(k a fórmula
ÿ
f (n) ÿ ÿ
f (0) ÿ
ÿ
f (n+1) ÿ
n · ÿ
f (1) ÿ
=X .
ÿ ÿ ÿ ÿ
ÿ .. ÿ ÿ .. ÿ
. .
ÿ ÿ ÿ ÿ
f (n+ k ÿ1) f (k ÿ1)
221
Machine Translated by Google
Gráficos e matrizes
Contando caminhos
1 2 3
4 5 6
a matriz de adjacência é
000100
ÿ ÿ
ÿ
ÿ
100011
ÿ
ÿÿ010000ÿÿ01
V= ÿ
ÿ 0000ÿÿÿ .
ÿ
000000
ÿ0 0 1 0 1 0 ÿ
001110
ÿ ÿ
200022 ÿ
ÿÿ020000ÿÿ02
V4
= ÿ
0000ÿÿÿ ÿ
000000 ÿ
ÿ0 0 1 1 1 0 ÿ
Usando uma ideia semelhante em um grafo ponderado, podemos calcular para cada par de nós
o comprimento mínimo de um caminho entre eles que contém exatamente n arestas. Para
calcular isso, temos que definir a multiplicação de matrizes de uma nova maneira, de modo que
não calculemos o número de caminhos, mas minimizemos os comprimentos dos caminhos.
222
Machine Translated by Google
2 4
1 2 3
41 1 2 3
4 5 6
2
Vamos construir uma matriz de adjacência onde ÿ significa que uma aresta não
existem e outros valores correspondem aos pesos das arestas. A matriz é
ÿÿÿ4ÿÿ
ÿ ÿ
2ÿÿÿ12 ÿ
ÿÿÿ4ÿÿÿÿÿÿÿ1ÿÿÿÿÿ
V=
ÿ
ÿÿ
ÿ
ÿ
.
ÿ
ÿÿÿÿÿÿ ÿ
ÿ
ÿÿ3ÿ2ÿ ÿ
Em vez da fórmula
n
AB[eu, j] = A[i,k]·B[k, j]
k=1
para multiplicação de matrizes, calculamos um mínimo em vez de uma soma e uma soma
de elementos em vez de um produto. Após esta modificação, as potências da matriz
correspondem aos caminhos mais curtos no gráfico.
Por exemplo, como
ÿ ÿ 10 11 9 ÿ
ÿ ÿ
ÿ 9ÿÿÿ89
ÿ
ÿ ÿ ÿ 11 ÿ ÿ ÿ ÿ ÿ ÿ ÿ 8 ÿ ÿ ÿ ÿ ÿ
V4 =
ÿ
ÿ ÿÿ ,
ÿ
ÿ
ÿÿÿÿÿÿ
ÿ
ÿ ÿ 12 13 11 ÿ ÿ
Teorema de Kirchhoff
O teorema de Kirchhoff fornece uma maneira de calcular o número de árvores geradoras
de um gráfico como determinante de uma matriz especial. Por exemplo, o gráfico
1 2
3 4
223
Machine Translated by Google
1 2 1 2 1 2
3 4 3 4 3 4
Para calcular o número de árvores geradoras, construímos uma matriz Laplaceana L, onde L[i,i] é
o grau do nó i e L[i, j] = ÿ1 se houver uma aresta entre os nós i e j, e caso contrário, L[i, j] = 0. A
matriz Laplaceana para o gráfico acima é a seguinte:
ÿ 3 ÿ1 ÿ1 ÿ1 ÿ
ÿ ÿ
ÿ ÿ1 1 0 0 ÿ
eu = ÿ ÿ
ÿ1 0 2 ÿ1
ÿ
ÿ1 0 ÿ1 2 ÿ
100
det( 0 2 ÿ1
ÿÿ ÿ ÿ) = 3.
0 ÿ1 2
ÿ
nÿ1 ÿ1 ··· ÿ1 ÿ1 nÿ1 ··· ÿ1 ÿ
ÿ
ÿ det( ÿ ÿ ÿ
.. .. .. .. ÿ
nÿ2 ) = n .
. . . .
ÿ ÿ
ÿ1 ÿ1 ··· nÿ1
224
Machine Translated by Google
Capítulo 24
Probabilidade
Cálculo
Para calcular a probabilidade de um evento, podemos usar combinatória ou simular o processo que
gera o evento. Como exemplo, vamos calcular a probabilidade de tirar três cartas com o mesmo valor
de um baralho embaralhado (por exemplo, ÿ8, ÿ8 e ÿ8).
Método 1
Podemos calcular a probabilidade usando a fórmula
4
13 1
3
52
=
425
.
3
225
Machine Translated by Google
Método 2
Outra forma de calcular a probabilidade é simular o processo que gera o evento. Neste exemplo,
compramos três cartas, portanto o processo consiste em três etapas. Exigimos que cada etapa
do processo seja bem-sucedida.
Tirar a primeira carta certamente dá certo, porque não há restrições.
A segunda etapa é bem-sucedida com probabilidade de 3/51, porque restam 51 cartas e 3 delas têm
o mesmo valor da primeira carta. De forma semelhante, a terceira etapa é bem-sucedida com
probabilidade de 2/50.
A probabilidade de todo o processo ter sucesso é
3 2 1
1· · = .
51 50 425
Eventos
Um evento na teoria da probabilidade pode ser representado como um conjunto
UMA ÿ X,
X = {1,2,3,4,5,6}.
UMA = {2,4,6}.
Por exemplo, ao lançar um dado, p(x) = 1/6 para cada resultado x, então a
probabilidade do evento “o resultado é par” é
226
Machine Translated by Google
Complemento
A probabilidade do complemento A¯ é calculada usando a fórmula
P(A¯ ) = 1ÿ P(A).
União
A probabilidade da união A ÿB é calculada usando a fórmula
A = ”o resultado é par”
e
B = ”o resultado é menor que 4”
é
A ÿB = ”o resultado é par ou menor que 4”,
e sua probabilidade é
Probabilidade Condicional
A probabilidade condicional
P(UMA ÿB)
P(A|B) =
P(B)
227
Machine Translated by Google
Interseção
A = ”o naipe é de paus”
e
B = ”o valor é quatro”
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 é
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 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 número total de resultados é
36 e existem três maneiras possíveis de obter a soma 10: [4,6], [5,5] e [6,4].
228
Machine Translated by Google
Valor esperado
P(X = x)x,
x
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. Significa que a soma
E[X1 + X2 + ··· + Xn] sempre é igual à soma E[X1] + E[X2] + ··· + E[Xn]. Esse
a fórmula é válida mesmo que as variáveis aleatórias dependam umas das outras.
Por exemplo, ao lançar dois dados, a soma esperada é
0+0+1+1 1
= .
4 2
n-1 n
,
n
porque nenhuma bola deve ser colocada nele. Portanto, usando a linearidade, o esperado
o número de caixas vazias é
n-1 n
n· .
n
Distribuições
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
229
Machine Translated by Google
uma + b
E[X] = .
2
Em uma distribuição binomial, são feitas n tentativas e a probabilidade de uma
única tentativa ser bem-sucedida é p. A variável aleatória X conta o número de tentativas
bem-sucedidas e a probabilidade de um valor x é
x n-x n
P(X = x) = p (1ÿ p) ,
x
x n-x
onde p e (1ÿ p) correspondem a tentativas bem-sucedidas e malsucedidas e é o
n
x número de maneiras pelas quais podemos escolher a ordem das tentativas.
Por exemplo, ao lançar 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.
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 mudança para outros
estados. Uma cadeia de Markov pode ser representada como um grafo cujos nós são
estados e as arestas são transições.
Como exemplo, considere um problema em que estamos no andar 1 de um prédio de n
andares . Em cada etapa, caminhamos aleatoriamente um andar para cima ou um andar para
baixo, exceto que sempre caminhamos um andar para cima a partir do andar 1 e um andar para
baixo a partir do andar n. Qual é a probabilidade de estar no andar m após k passos?
Neste problema, cada andar do edifício corresponde a um estado de Markov
corrente. Por exemplo, se n = 5, o gráfico é o seguinte:
230
Machine Translated by Google
1 2 3 4 5
A distribuição de probabilidade de uma cadeia de Markov é um vetor [p1, p2,..., pn], onde pk é a probabilidade
de o estado atual ser k. A fórmula p1 + p2 +···+ pn = 1 sempre é válida.
ÿ
0 1/2 0 0 0 ÿ
1 0 1/2 0 0 ÿ
ÿ ÿ 0 1/2 0 1/2 0 ÿ ÿ ÿ
.
ÿ
0 0 1/2 0 1
ÿ
0 0 0 1/2 0 ÿ
ÿ
0 1/2 0 0 0 ÿÿ
1 ÿ ÿ
0 ÿ
1 0 1/2 0 0 ÿ
0 ÿ
1 ÿ
ÿ ÿ 0 1/2 0 1/2 0 ÿ ÿ ÿ
ÿ ÿ
= ÿ
.
ÿ ÿÿ0ÿÿ ÿ ÿÿ0ÿÿ ÿ
ÿ ÿ ÿ
ÿ ÿ ÿ
0 0 1/2 0 1 0 0
ÿ
0 0 0 1/2 0 ÿ ÿ
0 ÿ ÿ
0 ÿ
Algoritmos randomizados
Às vezes podemos usar a aleatoriedade para resolver um problema, mesmo que o problema não
esteja relacionado a probabilidades. Um algoritmo randomizado é um algoritmo baseado na
aleatoriedade.
Um algoritmo de Monte Carlo é um algoritmo aleatório que às vezes pode dar
uma resposta errada. Para que tal algoritmo seja útil, a probabilidade de uma
resposta errada deve ser pequena.
231
Machine Translated by Google
Estatísticas de pedidos
A estatística de k-ésima ordem de um array é o elemento na posição k após classificar o array em ordem
crescente. É fácil calcular qualquer estatística de ordem em tempo O(nlogn) classificando primeiro o array, mas
é realmente necessário classificar o array inteiro apenas para encontrar um elemento?
Acontece que podemos encontrar estatísticas de pedidos usando um algoritmo aleatório sem classificar a
matriz. O algoritmo, chamado quickselect1 , é um algoritmo de Las Vegas : seu tempo de execução geralmente
2 ) na pior das hipóteses.
é O(n), mas O(n
O algoritmo escolhe um elemento aleatório x do array e move os elementos menores
que x para a parte esquerda do array e todos os outros elementos para a parte direita do
array. Isso leva O(n) tempo quando há n elementos. Suponha que a parte esquerda
contenha elementos a e a parte direita contenha elementos b . Se a = k, o elemento x é
a estatística de k-ésima ordem. 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. A busca continua de forma
semelhante, até que o elemento seja encontrado.
Quando cada elemento x é escolhido aleatoriamente, o tamanho do array é reduzido pela metade
em cada etapa, então a complexidade de tempo para encontrar a estatística de k-ésima ordem é de cerca de
2
O pior caso do algoritmo ainda requer O(n ) 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 de
isso acontecer é tão pequena que isso nunca acontece na prática.
232
Machine Translated by Google
68 87
= ,
13 32
mas
68 3 87 3
= .
13 6 32 6
Coloração de gráfico
Dado um grafo que contém n nós em arestas , nossa tarefa é encontrar uma maneira de
colorir os nós do grafo usando duas cores de modo que, para pelo menos m/2 arestas, as
extremidades tenham cores diferentes. Por exemplo, no gráfico
1 2
3 4
1 2
3 4
233
Machine Translated by Google
234
Machine Translated by Google
Capítulo 25
Teoria do jogo
Neste capítulo, focaremos em jogos para dois jogadores que não contêm elementos
aleatórios. Nosso objetivo é encontrar uma estratégia que possamos seguir para vencer
o jogo, não importa o que o adversário 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 onde os
jogadores removem gravetos de pilhas e, em seguida, generalizaremos a estratégia
utilizada nesses jogos para outros jogos.
Estados do jogo
Um estado vencedor é um estado em que o jogador vencerá o jogo se jogar de forma otimizada,
e um estado perdedor é um estado em que o jogador perderá o jogo se o oponente jogar de
forma otimizada. 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 podemos remover 1,
235
Machine Translated by Google
2 ou 3 palitos e ganhe 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 forma mais geral, se houver um movimento que leve do estado atual para um estado perdedor, o
estado atual é um estado vencedor e, caso contrário, o estado atual é um estado perdedor. Usando esta
observação, podemos classificar todos os estados de um jogo começando pelos estados perdedores
onde não há movimentos possíveis.
Os estados 0...15 do jogo acima podem ser classificados da seguinte forma (W denota
um estado vencedor e L denota um estado perdedor):
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
LWWWLWWWLWWWLWWW
É fácil analisar este jogo: um estado k é um estado perdedor se k for divisível por 4, caso
contrário é um estado vencedor. Uma maneira ideal de jogar é sempre escolher um movimento após
o qual o número de paus na pilha seja divisível por 4. Finalmente, não há mais paus e o oponente
perdeu.
É claro que esta estratégia exige que o número de paus 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 vencerá o jogo se jogar de forma
otimizada.
Gráfico de estado
1 2
5
7
8
6
O estado final neste jogo é sempre o estado 1, que é um estado perdedor, porque
não há movimentos válidos. A classificação dos estados 1...9 é a seguinte:
123456789
LWLWLWL
Surpreendentemente, neste jogo, todos os estados pares são estados vencedores e todos os estados
ímpares são estados perdedores.
236
Machine Translated by Google
Jogo Nim
O jogo nim é um jogo simples que tem um papel importante na teoria dos jogos, pois muitos
outros jogos podem ser jogados utilizando a mesma estratégia. Primeiro focamos no nim e
depois generalizamos a estratégia para outros jogos.
Existem n montes em nim, e cada monte contém um certo número de bastões.
Os jogadores se movem alternadamente e, em cada turno, o jogador escolhe uma pilha
que ainda contém gravetos e remove dela qualquer número de gravetos. O vencedor é
o jogador que retirar o último palito.
Os estados em nim têm a forma [x1, x2,..., xn], onde xk denota o número de
bastões na pilha k. Por exemplo, [10,12,5] é um jogo onde existem três pilhas com
10, 12 e 5 palitos. 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 facilmente classificar qualquer estado nim calculando a soma
nim s = x1 ÿ x2 ÿ··· ÿ xn, onde ÿ é a operação xor1 . 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 o nim sum está relacionado ao jogo nim? Podemos explicar isso por
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 xk muda, a soma nim também muda, então a soma
nim é diferente de 0 após o movimento.
Estados vencedores: podemos passar para um estado perdedor se houver algum
heap k para o qual xk ÿ s < xk. Nesse caso, podemos remover bastões do heap k para
que ele contenha xk ÿ s bastões, o que levará a um estado perdedor. Sempre existe um
heap, onde xk tem um bit na posição do bit mais à esquerda de s.
Como exemplo, considere o estado [10,12,5]. Este estado é um estado vencedor, porque
a sua soma nim é 3. Assim, tem de haver um movimento que conduza 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
1A estratégia ótima para nim foi publicada em 1901 por CL Bouton [10].
237
Machine Translated by Google
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
Misère jogo
No jogo misère, o objetivo do jogo é oposto, então o jogador que retirar o último stick perde o jogo.
Acontece que o jogo misère nim pode ser jogado de maneira ideal, quase como o jogo nim padrão.
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 numa situação em que cada pilha conteria 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
um stick. Porém, no jogo misère, escolhemos um movimento de forma que haja um número ímpar de
pilhas com uma vara.
Essa estratégia funciona porque sempre aparece no jogo um estado onde a estratégia muda , e
esse estado é um estado vencedor, pois contém exatamente um heap que possui mais de um stick,
então a soma nim não é 0.
Teorema de Sprague-Grundy
O teorema de Sprague-Grundy2 generaliza a estratégia utilizada em nim para todos os jogos que
preencham os seguintes requisitos:
A ideia é calcular para cada estado do jogo um número Grundy que corresponda ao número de palitos
em uma pilha nim. Quando conhecemos os números Grundy de todos os estados, podemos jogar como
o jogo nim.
Números sujos
O número Grundy de um estado de jogo é
238
Machine Translated by Google
onde g1, g2,..., gn são os números Grundy dos estados para os quais podemos
mover, 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 não houver movimentos possíveis em
um estado, seu número Grundy é 0, porque mex() = 0.
Por exemplo, no gráfico de estado
0 1 0
2 0 2
*
*
****@
01 01
012
02 10
3041
04132
239
Machine Translated by Google
Assim, cada estado do jogo labirinto corresponde a uma pilha no jogo nim. Para
Por exemplo, o número Grundy para o quadrado inferior direito é 2, então é um vencedor
estado. Podemos chegar a um estado de derrota e vencer o jogo avançando quatro passos
esquerda ou dois degraus acima.
Observe que, diferentemente do jogo nim original, pode ser possível passar para um
estado cujo número Grundy é maior que o número Grundy do atual
estado. No entanto, o oponente sempre pode escolher um movimento que cancele tal movimento,
portanto, não é possível escapar de um estado perdedor.
Subjogos
A seguir assumiremos que nosso jogo consiste em subjogos, e em cada turno, o
o jogador primeiro escolhe um subjogo e depois uma jogada no subjogo. O jogo termina
quando não for possível fazer nenhum movimento em nenhum subjogo.
Neste caso, o número Grundy de um jogo é a soma nim do número Grundy
números dos subjogos. O jogo pode ser jogado como um jogo nim calculando
todos os números Grundy para subjogos e depois sua soma nim.
Por exemplo, considere um jogo que consiste em três labirintos. Neste jogo,
em cada turno, o jogador escolhe um dos labirintos e depois move a figura
O labirinto. Suponha que o estado inicial do jogo seja o seguinte:
@ @ @
01 01 0123 01234
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 num jogo divide o jogo em subjogos que são independentes uns dos
outros. Neste caso, o número Grundy do jogo é
240
Machine Translated by Google
Neste jogo, o valor de f (n) é baseado nos valores de f (1),..., f (nÿ1). Os casos
base são f (1) = f (2) = 0, porque não é possível dividir os montes de 1 e 2 palitos.
Os primeiros números de Grundy são:
f (1) = 0 f
(2) = 0 f (3)
= 1 f (4) =
0 f (5) = 2
f (6) = 1 f
(7) = 0 f
(8) = 2
241
Machine Translated by Google
242
Machine Translated by Google
Capítulo 26
Algoritmos de string
Este capítulo trata de algoritmos eficientes para processamento de strings. Muitos problemas de
strings podem ser facilmente resolvidos em 2 ) tempo, mas o desafio é encontrar algoritmos
O(n) que funcionam em tempo O(n) ou O(nlogn) .
Por exemplo, um problema fundamental de processamento de strings é o problema de
correspondência de padrões : dada uma string de comprimento n e um padrão de
comprimento m, nossa tarefa é encontrar as ocorrências do padrão na string. Por exemplo,
o padrão ABC ocorre duas vezes na string ABABCBABC.
O problema de correspondência de padrões pode ser facilmente resolvido em tempo O(nm)
por um algoritmo de força bruta que testa todas as posições onde o padrão pode ocorrer na string.
Porém, neste capítulo, veremos que existem algoritmos mais eficientes que requerem apenas tempo
O(n+ m).
Terminologia de strings
Ao longo do capítulo, presumimos que a indexação baseada em zero é usada em strings.
Assim, uma string s de comprimento n consiste em caracteres s[0],s[1],...,s[nÿ1]. O
conjunto de caracteres que podem aparecer nas strings é chamado de alfabeto. Por
exemplo, o alfabeto {A,B,...,Z} consiste nas letras maiúsculas do inglês.
Uma substring é uma sequência de caracteres consecutivos em uma string. Usamos a notação
s[a...b] para nos referir a uma substring de s que começa na posição a e termina na posição b. Uma
string de comprimento n possui n(n + 1)/2 substrings. Por exemplo, as substrings de ABCD são A,
B, C, D, AB, BC, CD, ABC, BCD e ABCD.
Uma subsequência é uma sequência de caracteres (não necessariamente consecutivos)
em uma string em sua ordem original. Uma string de comprimento n possui 2n ÿ1 subsequências.
Por exemplo, as subsequências de ABCD são A, B, C, D, AB, AC, AD, BC, BD, CD, ABC, ABD,
ACD, BCD e ABCD.
Um prefixo é uma substring que começa no início de uma string, e um sufixo é uma substring
que termina no final de uma string. Por exemplo, os prefixos de ABCD são A, AB, ABC e ABCD, e
os sufixos de ABCD são D, CD, BCD e ABCD.
Uma rotação pode ser gerada movendo os caracteres de uma string um por um , do início
ao fim (ou vice-versa). Por exemplo, as rotações de ABCD são ABCD, BCDA, CDAB e DABC.
243
Machine Translated by Google
Um ponto final é um prefixo de uma string de modo que a string pode ser construída
repetindo o ponto final. A última repetição pode ser parcial e conter apenas o prefixo
do ponto final. Por exemplo, o período mais curto de ABCABCA é ABC.
Uma borda é uma string que é ao mesmo tempo um prefixo e um sufixo de uma string. Por
exemplo, as fronteiras de ABACABA são A, ABA e ABACABA.
As strings são comparadas usando a ordem lexicográfica (que corresponde à
ordem alfabética). Isso significa que x < y se x = y e x for um prefixo de y, ou se
houver uma posição k tal que x[i] = y[i] quando i < k e x[k] < y[k] .
Estrutura de teste
Um trie é uma árvore enraizada que mantém um conjunto de strings. Cada string do conjunto
é armazenada como uma cadeia de caracteres que começa na raiz. Se duas strings tiverem
um prefixo comum, elas também terão uma cadeia comum na árvore.
Por exemplo, considere a seguinte tentativa:
C T
A H
N E
*
A D R
eu S E
* * *
Esta tentativa corresponde ao conjunto {CANAL, CANDY, THE, THERE}. O caractere * em um nó significa que
uma string do conjunto termina no nó. Esse caractere é necessário porque uma string pode ser um prefixo de outra
string. Por exemplo, na tentativa acima, THE é um prefixo de THERE.
int tentativa[N][A];
244
Machine Translated by Google
Hash de string
O hash de strings é uma técnica que nos permite verificar com eficiência se duas strings
são iguais1 . A ideia no hash de strings é comparar valores de hash de strings
em vez de seus personagens individuais.
onde s[0], s[1],..., s[nÿ1] são interpretados como os códigos dos caracteres de s, e A e B
são constantes pré-escolhidas.
Por exemplo, os códigos dos personagens de ALLEY são:
BECO
65 76 76 69 89
Pré-processando
Usando hashing polinomial, podemos calcular o valor hash de qualquer substring de uma
string s em tempo O(1) após um pré-processamento de tempo O(n) . A ideia é construir
um array h tal que h[k] contenha o valor hash do prefixo s[0...k]. Os valores da matriz
podem ser calculados recursivamente da seguinte forma:
k
Além disso, construímos um array p onde p[k] = A mod B:
p[0] = 1 p[k]
= (p[k ÿ1]A) mod B.
1A técnica foi popularizada pelo algoritmo de correspondência de padrões de Karp – Rabin [42].
245
Machine Translated by Google
A construção dessas matrizes leva O(n) tempo. Depois disso, o valor hash de qualquer
substring s[a...b] pode ser calculado em tempo O(1) usando a fórmula
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 hash conclui que as strings são iguais, mas na realidade não
são, e o algoritmo pode fornecer resultados incorretos.
Colisões são sempre possíveis porque o número de strings diferentes é maior que o
número de valores de hash diferentes. Contudo, 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 de 109 , por exemplo da seguinte forma:
UMA = 911382323
B = 972663749
Usando essas constantes, o tipo long long pode ser usado ao calcular valores
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 hash pode ser usado:
246
Machine Translated by Google
n
1 1ÿ(1ÿ ) .
B
Cenário 3: Todos os pares de strings x1, x2,..., xn são comparados entre si.
A probabilidade de uma ou mais colisões é
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
247
Machine Translated by Google
S. Assim, z[k] = p nos diz que s[0... p ÿ 1] é igual a s[k...k + p ÿ 1]. Muitos problemas de
processamento de strings podem ser resolvidos com eficiência usando o array Z.
Por exemplo, a matriz Z de ACBACDACBACBACDA é a seguinte:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
ACBACDACBACBACDA
–002005007002001
Neste caso, por exemplo, z[6] = 5, porque a substring ACBAC de comprimento 5 é uma
prefixo de s, mas a substring ACBACB de comprimento 6 não é um prefixo de s.
Descrição do algoritmo
A seguir descrevemos um algoritmo, denominado algoritmo Z2 , que constrói
eficientemente o array Z em tempo O(n) . O algoritmo calcula os valores da
matriz Z da esquerda para a direita usando informações já armazenadas na
matriz Z 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 e y é o maior possível. Como sabemos que s[0... yÿ x] e s[x... y] são
iguais, podemos usar esta informação ao calcular os valores Z para as posições x+1, x+2,..., você.
ACBACDACBACBACDA
–???????????????
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
ACBACDACBACBACDA
–002005?????????
2O algoritmo Z foi apresentado em [32] como o método mais simples conhecido para correspondência de
padrões de tempo linear, e a ideia original foi atribuída a [50].
248
Machine Translated by Google
x sim
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
ACBACDACBACBACDA
–00200500???????
x sim
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
ACBACDACBACBACDA
–00200500???????
Porém, não temos informações sobre a string após a posição 10, então
precisamos comparar as substrings caractere por caractere:
x sim
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
ACBACDACBACBACDA
–00200500???????
x sim
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
ACBACDACBACBACDA
–002005007??????
x sim
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
ACBACDACBACBACDA
–002005007002001
249
Machine Translated by Google
Usando a matriz Z
Muitas vezes é uma questão de gosto usar hashing de string ou o algoritmo Z.
Ao contrário do hashing, o algoritmo Z sempre funciona e não há risco de colisões.
Por outro lado, o algoritmo Z é mais difícil de implementar e alguns problemas só podem
ser resolvidos usando hashing.
Como exemplo, 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
esse problema de forma eficiente usando hash de string, mas o algoritmo Z fornece outra
maneira de resolver o problema.
Uma ideia comum no processamento de strings é construir uma string que consiste em
múltiplas strings 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. A matriz Z de p#s nos informa as posições onde p ocorre em s, porque tais posições
contêm o comprimento de p.
Por exemplo, se s =HATTIVATTI e p =ATT, a matriz Z é a seguinte:
0 1 2 3 4 5 6 7 8 9 10 11 12 13
ATT#HATTIVATTI
–0000300003000
Implementação
Aqui está uma breve implementação do algoritmo Z que retorna um vetor que corresponde
ao array Z.
} retornar z;
}
250
Machine Translated by Google
Capítulo 27
Um algoritmo de raiz quadrada é um algoritmo que possui uma raiz quadrada em seu tempo
complexidade. Uma raiz quadrada pode ser vista como um “logaritmo do pobre”: a complexidade
O( n) é melhor que O(n) , mas pior que O(logn). De qualquer forma, muitos quadrados
algoritmos raiz são rápidos e utilizáveis na prática.
Como exemplo, considere o problema de criar uma estrutura de dados que suporte
duas operações em um array: modificar um elemento em uma determinada posição e
calculando a soma dos elementos no intervalo fornecido. Já resolvemos anteriormente o
problema usando árvores binárias indexadas e de segmento, que suportam ambas as operações em
Tempo O(logn) . Porém, agora resolveremos o problema de outra maneira usando um
estrutura de raiz quadrada que nos permite modificar elementos em tempo O(1) e calcular
somas em tempo O( n).
A ideia é dividir o array em blocos de tamanho n para que cada bloco contenha
a soma dos elementos dentro do bloco. Por exemplo, um array de 16 elementos irá
ser dividido em blocos de 4 elementos da seguinte forma:
21 17 20 13
5863272671756232
21 15 20 13
5863252671756232
21 15 20 13
5863252671756232
251
Machine Translated by Google
Combinando algoritmos
Nesta seção, discutimos dois algoritmos de raiz quadrada baseados na combinação de dois
algoritmos em um algoritmo. Em ambos os casos, poderíamos usar qualquer um dos
2 ) tempo. No entanto, por
algoritmos sem o outro e resolver o problema em O(n combinando
os algoritmos, o tempo de execução é de apenas O(n n).
Processamento de caso
Suponha que recebamos uma grade bidimensional que contém n células. Cada célula recebe uma letra, e nossa
tarefa é encontrar duas células com a mesma letra cuja distância seja mínima, onde a distância entre as células
(x1, y1) e (x2, y2) é |x1 ÿ x2| +|y1 ÿ y2|. Por exemplo, considere a seguinte grade:
A F B A
C E G E
B D A F
A C B D
Uma maneira de resolver o problema é escolher um dos algoritmos e usar ), porque todos
isso para todas as letras. Se usarmos o Algoritmo 1, o tempo de execução é 2
O(n células podem conter a mesma letra e, neste caso, k = n. Além disso, se usarmos o Algoritmo
2, o tempo de execução é O(n, 2 ), porque todas as células podem ter letras diferentes, e em
neste caso , são necessárias n pesquisas.
252
Machine Translated by Google
Processamento em lote
Nosso próximo problema também trata de uma grade bidimensional que contém n células.
Inicialmente, cada célula, exceto uma, é branca. Realizamos n ÿ 1 operações, cada uma das
quais primeiro calcula a distância mínima de uma determinada célula branca a uma célula preta
e, em seguida, pinta a célula branca de preto.
Por exemplo, considere a seguinte operação:
Primeiro, calculamos a distância mínima da célula branca marcada com * até uma célula
preta. A distância mínima é 2, porque podemos avançar dois passos para a esquerda até
uma célula preta. Então, pintamos a célula branca de preto:
253
Machine Translated by Google
operação, a distância mínima até uma célula preta é a distância calculada pelo
Algoritmo 1 ou a distância calculada pelo Algoritmo 2.
O algoritmo resultante funciona em tempo O(n n). Primeiro, o Algoritmo 1 é executado
O( n) vezes, e cada busca funciona em O(n) tempo. Segundo, ao usar o Algoritmo 2 em
um lote, a lista contém O( n) células (porque limpamos a lista entre os lotes) e cada
operação leva O( n) tempo.
Partições inteiras
Alguns algoritmos de raiz quadrada são baseados na seguinte observação: se um
inteiro positivo n é representado como uma soma de inteiros positivos, tal soma sempre
contém no máximo O( n) números distintos. A razão para isto é que para construir uma
soma que contenha um número máximo de números distintos, devemos escolher
números pequenos. Se escolhermos os números 1,2,...,k, a soma resultante é
k(k +1)
.
2
Mochila
Suponha que recebamos uma lista de pesos inteiros cuja soma é n. Nossa tarefa é descobrir
todas as somas que podem ser formadas usando um subconjunto de pesos. Por exemplo, se
os pesos forem {1,3,3}, as somas possíveis são as seguintes:
• 0 (conjunto
vazio) • 1
•3
• 1+3 = 4
• 3+3 = 6 •
1+3+3 = 7
Usando a abordagem padrão da mochila (ver Capítulo 7.4), o problema pode ser resolvido
da seguinte forma: definimos uma função possível(x,k) cujo valor é 1 se a soma x puder ser
formada usando os primeiros k pesos, e 0 caso contrário. Como a soma dos pesos é n, existem
no máximo n pesos e todos os valores da função podem ser calculados em O(n) . No entanto,
podemos tornar o 2 ) tempo usando programação dinâmica.
algoritmo mais eficiente usando o fato de que existem no máximo O( n) pesos distintos.
Assim, podemos processar os pesos em grupos que consistem em pesos semelhantes.
Podemos processar cada grupo em tempo O(n) , o que produz um algoritmo de tempo O(n n).
A ideia é utilizar um array que registre as somas dos pesos que podem ser formados a
partir dos grupos processados até o momento. A matriz contém n elementos: o elemento k
é 1 se a soma k puder ser formada e 0 caso contrário. Para processar um grupo de pesos,
varremos o array da esquerda para a direita e registramos as novas somas de pesos que
podem ser formadas usando este grupo e os grupos anteriores.
254
Machine Translated by Google
Construção de cordas
Dada uma string s de comprimento n e um conjunto de strings D cujo comprimento total é m,
considere o problema de contar o número de maneiras pelas quais s pode ser formado como uma
concatenação de strings em D. Por exemplo, se s = ABAB e D = {A,B,AB}, existem 4 maneiras:
• A+B+A+B
• AB+A+B
• A+B+AB
• AB+AB
Algoritmo de Mo
O algoritmo1 de Mo pode ser usado em muitos problemas que requerem o processamento de
consultas de intervalo em um array estático, ou seja, os valores do array não mudam entre as consultas.
Em cada consulta, recebemos um intervalo [a,b] e devemos calcular um valor com base
nos elementos do array entre as posições a e b. Como o array é estático, as consultas
podem ser processadas em qualquer ordem, e o algoritmo de Mo processa as consultas
em uma ordem especial que garante que o algoritmo funcione de forma eficiente.
O algoritmo de Mo mantém um intervalo ativo do array, e a resposta a uma consulta sobre o
intervalo ativo é conhecida a cada momento. O algoritmo processa as consultas uma por uma e
sempre move os pontos finais do intervalo ativo inserindo e removendo elementos. A complexidade
de tempo do algoritmo é O(n nf (n)) onde o array contém n elementos, há n consultas e cada
inserção e remoção de um elemento leva tempo O(f (n)).
1De acordo com [12], este algoritmo tem o nome de Mo Tao, um programador competitivo chinês,
mas a técnica apareceu anteriormente na literatura [44].
255
Machine Translated by Google
Exemplo
Como exemplo, considere um problema em que recebemos um conjunto de consultas,
cada uma delas correspondendo a um intervalo em uma matriz, e nossa tarefa é calcular
para cada consulta o número de elementos distintos no intervalo.
No algoritmo de Mo, as consultas são sempre ordenadas da mesma forma,
mas depende do problema como a resposta à consulta é mantida. Neste problema,
podemos manter uma contagem de array onde count[x] indica o número de vezes
que um elemento x ocorre no intervalo ativo.
Quando passamos de uma consulta para outra, o intervalo ativo muda.
Por exemplo, se o intervalo atual for
425424334
e o próximo intervalo é
425424334
256
Machine Translated by Google
Capítulo 28
Uma árvore de segmentos é uma estrutura de dados versátil que pode ser usada para resolver um
grande número de problemas de algoritmos. No entanto, existem muitos tópicos relacionados às
árvores de segmentos que ainda não abordamos. Agora é hora de discutir algumas variantes mais
avançadas de árvores de segmentos.
} retornar s;
}
Agora podemos calcular qualquer valor de sumq(a,b) (a soma dos valores do array no intervalo
[a,b]) da seguinte forma:
257
Machine Translated by Google
72
39 33
22 17 20 13
13 9 9 8 8 12 8 5
5863272671756232
a b
Propagação preguiçosa
Usando propagação lenta, podemos construir uma árvore de segmentos que suporte ambos os intervalos
atualizações e consultas de intervalo em tempo O (logn) . A ideia é realizar atualizações e
consultas de cima para baixo e realizar atualizações preguiçosamente para que sejam propagadas
derrube a árvore somente quando for necessário.
Em uma árvore de segmentos lentos, os nós contêm dois tipos de informações. Como em um
árvore de segmento comum, cada nó contém a soma ou algum outro valor relacionado
para a submatriz correspondente. Além disso, o nó pode conter informações
relacionado a atualizações lentas, que não foram propagadas para seus filhos.
Existem dois tipos de atualizações de intervalo: cada valor da matriz no intervalo é
aumentado em algum valor ou atribuído algum valor. Ambas as operações podem ser
implementado usando ideias semelhantes, e é até possível construir uma árvore que
suporta ambas as operações ao mesmo tempo.
Vamos considerar um exemplo onde nosso objetivo é construir uma árvore de segmentos que
suporte duas operações: aumentar cada valor em [a,b] por uma constante e calcular
258
Machine Translated by Google
72/0
39/0 33/0
5863272671756232
90/0
45/0 45/0
5863292671756232
a b
259
Machine Translated by Google
Tanto em atualizações quanto em consultas, o valor de uma atualização lenta é sempre propagado
aos filhos do nó antes de processá-lo. A ideia é que as atualizações
será propagado para baixo somente quando for necessário, o que garante que
as operações são sempre eficientes.
A imagem a seguir mostra como a árvore muda quando calculamos o
valor de suma(a,b). O retângulo mostra os nós cujos valores mudam, porque
uma atualização lenta é propagada para baixo.
90/0
45/0 45/0
5863292671756232
a b
Observe que às vezes é necessário combinar atualizações lentas. Isso acontece quando
um nó que já possui uma atualização lenta recebe outra atualização lenta. Quando
calculando somas, é fácil combinar atualizações preguiçosas, porque a combinação de
as atualizações z1 e z2 correspondem a uma atualização z1 + z2.
Atualizações polinomiais
Atualizações lentas podem ser generalizadas para que seja possível atualizar intervalos usando
polinômios da forma
k kÿ1
p(você) = tku + tkÿ1u +··· + t0.
Neste caso, a atualização para um valor na posição i em [a,b] é p(i ÿ a). Para
Por exemplo, adicionar o polinômio p(u) = u + 1 a [a,b] significa que o valor em
a posição a aumenta em 1, o valor na posição a+1 aumenta em 2 e assim por diante.
Para suportar atualizações polinomiais, cada nó recebe k +2 valores, onde k
é igual ao grau do polinômio. O valor s é a soma dos elementos em
o intervalo, e os valores z0, z1,..., zk são os coeficientes de um polinômio que
corresponde a uma atualização lenta.
Agora, a soma dos valores em um intervalo [x, y] é igual
y ÿx
k kÿ1 + zkÿ1u
s+ zku +··· + z0.
você=0
260
Machine Translated by Google
O valor dessa soma pode ser calculado de forma eficiente usando fórmulas de soma.
Por exemplo, o termo z0 corresponde à soma (yÿ x+1)z0, e o termo z1u corresponde
à soma
(yÿ x)(yÿ x+1)
z1(0+1+··· + yÿ x) = z1 2 .
Ao propagar uma atualização na árvore, os índices de p(u) mudam, pois em cada intervalo
[x, y], os valores são calculados para u = 0,1,..., yÿ x. No entanto, isso não é um problema,
porque p (u) = p(u + h) é um polinômio de grau igual a p(u).
Por exemplo, se p(u) = t2u 2
+ t1u ÿ t0, então
2
p (u) = t2(u + h) + t1(você + h)ÿ t0 = t2u 2 +(2ht2 + t1)você + t2h2 + t1h ÿ t0.
Árvores dinâmicas
Uma árvore de segmentos comum é estática, o que significa que cada nó possui uma
posição fixa no array e a árvore requer uma quantidade fixa de memória. Em uma árvore
de segmentos dinâmicos, a memória é alocada apenas para nós que são realmente
acessados durante o algoritmo, o que pode economizar uma grande quantidade de memória.
Os nós de uma árvore dinâmica podem ser representados como estruturas:
nó de estrutura
{ int valor;
interno x,
y; nó *esquerda, *direita;
nó (int v, int x, int y): valor (v), x (x), y (y) {}
};
// cria um novo nó
node *x = new node(0, 0, 15); //
altera o valor x-
>valor = 5;
Uma árvore de segmentos dinâmicos é útil quando o array subjacente é esparso, ou seja, o
intervalo [0,nÿ1] de índices permitidos é grande, mas a maioria dos valores do array são zeros.
Enquanto uma árvore de segmentos comum usa memória O(n) , uma árvore de segmentos
dinâmica usa apenas memória O(klogn) , onde k é o número de operações realizadas.
Uma árvore de segmentos esparsos inicialmente possui apenas um nó [0,n ÿ 1]
cujo valor é zero, o que significa que todo valor do array é zero. Após as atualizações,
novos nós são adicionados dinamicamente à árvore. Por exemplo, se n = 16 e os
elementos nas posições 3 e 10 foram modificados, a árvore contém os seguintes nós:
261
Machine Translated by Google
[0,15]
[0,7] [8,15]
[0,3] [8,11]
[2,3] [10,11]
[3] [10]
Qualquer caminho do nó raiz para uma folha contém O(logn) nós, então cada operação adiciona no
máximo O(logn) novos nós à árvore. Assim, após k operações, a árvore contém no máximo O(klogn) nós.
Observe que se soubermos que todos os elementos serão atualizados no início do algoritmo, uma
árvore de segmentos dinâmica não será necessária, pois podemos usar uma árvore de segmentos
comum com compressão de índice (Capítulo 9.4). Porém, isso não é possível quando os índices são
gerados durante o algoritmo.
Usando uma implementação dinâmica, também é possível criar uma árvore de segmentos persistentes
que armazena o histórico de modificações da árvore. Nessa implementação , podemos acessar
eficientemente todas as versões da árvore que existiram durante o algoritmo.
Após cada atualização, a maioria dos nós da árvore permanece a mesma, portanto, uma maneira
eficiente de armazenar o histórico de modificações é representar cada árvore no histórico como uma árvore.
262
Machine Translated by Google
Estruturas de dados
Em vez de valores únicos, os nós em uma árvore de segmentos também podem conter
estruturas de dados que mantêm informações sobre os intervalos correspondentes. Nessa
árvore, as operações levam tempo O(f (n) logn) , onde f (n) é o tempo necessário para
processar um único nó durante uma operação.
Como exemplo, considere uma árvore de segmentos que suporta consultas no
formato “quantas vezes um elemento x aparece no intervalo [a,b]?” Por exemplo, o
elemento 1 aparece três vezes no seguinte intervalo:
31231112
Para suportar tais consultas, construímos uma árvore de segmentos onde cada nó
recebe uma estrutura de dados que pode ser questionada quantas vezes qualquer elemento
x aparece no intervalo correspondente. Usando esta árvore, a resposta a uma consulta pode
ser calculada combinando os resultados dos nós que pertencem ao intervalo.
Por exemplo, a seguinte árvore de segmentos corresponde à matriz acima:
12342
2
123 12
112 31
13 23 1 12
11 11 2 11
31 11 21 31 11 11 11 21
263
Machine Translated by Google
Podemos construir a árvore de modo que cada nó contenha uma estrutura de mapa .
Neste caso, o tempo necessário para processar cada nó é O(logn), portanto a complexidade
total de tempo de uma consulta é O(log2 n). A árvore usa memória O(nlogn) , porque
existem O(logn) níveis e cada nível contém O(n) elementos.
Bidimensionalidade
Uma árvore de segmentos bidimensional suporta consultas relacionadas a submatrizes
retangulares de uma matriz bidimensional. Tal árvore pode ser implementada como
árvores de segmentos aninhados: uma árvore grande corresponde às linhas do array e
cada nó contém uma pequena árvore que corresponde a uma coluna.
Por exemplo, na matriz
7616
8752
3971
8538
a soma de qualquer submatriz pode ser calculada a partir da seguinte árvore de segmentos:
86
53 33
26 27 16 17
42 44
28 14 25 19
15 13 6 8 11 14 10 9
20 22 20 24
13 7 15 7 12 8 13 11
As operações de uma árvore de segmentos bidimensionais levam tempo O(log2 n), porque )
2
a árvore grande e cada árvore pequena consistem em níveis O (logn) . A árvore requer
O(n memória, porque cada pequena árvore contém O(n) valores.
264
Machine Translated by Google
Capítulo 29
Geometria
Depois disso, basta somar as áreas dos triângulos. A área de um triângulo pode ser
calculada, por exemplo, usando a fórmula de Heron
265
Machine Translated by Google
É claro para um ser humano qual das linhas é a escolha correta, mas a situação é difícil para um
computador.
No entanto, acontece que podemos resolver o problema usando outro método
isso é mais conveniente para um programador. Ou seja, existe uma fórmula geral
x1 y2 ÿ x2 y1 + x2 y3 ÿ x3 y2 + x3 y4 ÿ x4 y3 + x4 y1 ÿ x1 y4,
que calcula a área de um quadrilátero cujos vértices são (x1, y1), (x2, y2), (x3, y3) e (x4, y4). Esta
fórmula é fácil de implementar, não existem casos especiais e podemos até generalizar a fórmula
para todos os polígonos.
Números complexos
(4,2)
266
Machine Translated by Google
Por exemplo, o código a seguir define um ponto p = (4,2) e imprime suas coordenadas
xey:
P p = {4,2}; cout
""
<< pX << << pY << "\n"; // 4 2
P v = {3,1};
P você = {2,2};
Ps = v+você;
""
cout << sX << << sY << "\n"; //5 3
Funções
Pa = {4,2}; Pb =
{3,-1}; cout <<
abs(ba) << "\n"; //3.16228
P v = {4,2}; cout
<< arg(v) << "\n"; // 0,463648 v *= polar(1,0,0,5);
cout << arg(v) << "\n"; //
0,963648
267
Machine Translated by Google
Pontos e linhas
O produto vetorial a× b dos vetores a = (x1, y1) e b = (x2, y2) é calculado usando a fórmula
x1 y2 ÿ x2 y1. O produto vetorial nos diz se b vira para a esquerda ( valor positivo), não vira
(zero) ou vira para a direita (valor negativo) quando é colocado diretamente após a.
b b
b
a a a
Pa = {4,2}; Pb
= {1,2}; C p =
(conj(a)*b).Y; //6
Localização do ponto
Os produtos cruzados podem ser usados para testar se um ponto está localizado no lado
esquerdo ou direito de uma linha. Suponha que a reta passe pelos pontos s1 e s2,
estamos olhando de s1 a s2 e o ponto é p.
Por exemplo, na imagem a seguir, p está no lado esquerdo da linha:
s2
s1
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 reta.
268
Machine Translated by Google
d
b
c
a
Nesse caso, podemos usar produtos cruzados para verificar se todos os pontos estão
na mesma reta. Depois disso, podemos classificar os pontos e verificar se os segmentos
de linha se sobrepõem.
Caso 2: Os segmentos de reta possuem um vértice comum que é a única interseção
apontar. Por exemplo, na imagem a seguir o ponto de intersecção é b = c:
b=c
Este caso é fácil de verificar, pois existem apenas quatro possibilidades para o ponto de
intersecção: a = c, a = d, b = c e b = d.
Caso 3: Existe exatamente um ponto de intersecção que não é um vértice de nenhuma linha
segmento. Na imagem a seguir, o ponto p é o ponto de intersecção:
Outra característica dos produtos vetoriais é que a área de um triângulo pode ser
calculada usando a fórmula
|(aÿ c)×(b ÿ c)|
,
2
269
Machine Translated by Google
s2
s1
A área do triângulo cujos vértices são s1, s2 e p pode ser calculada de duas
maneiras: é ambas ((s1 ÿ 21p)|s2× ÿ(s2
s1|d 1
e Assim,
ÿ p)). 2
a distância mais curta é
(s1 ÿ p)×(s2 ÿ p)
d= .
|s2 ÿ s1|
270
Machine Translated by Google
Á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:
1 n-1 n-1
| 1 (pi × pi+1)| = | 2 (xi yi+1 ÿ xi+1 yi)|,
2
eu=1 eu=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 no limite do polígono , e o primeiro e o último vértice
são iguais, ou seja, p1 = pn.
Por exemplo, a área do polígono
(5,5)
(2,4)
(4,3) (7,3)
(4,1)
é
|(2·5ÿ5·4)+(5·3ÿ7·5)+(7·1ÿ4·3)+(4·3ÿ4·1)+(4·4ÿ2·3) | = 17/2.
2
A ideia da fórmula é percorrer trapézios cujo um lado é um lado de
o polígono, e outro lado fica na linha horizontal y = 0. Por exemplo:
(5,5)
(2,4)
(4,3) (7,3)
(4,1)
271
Machine Translated by Google
Teorema de Pick
O teorema de Pick fornece outra maneira de calcular a área de um polígono, desde que todos os
vértices do polígono tenham coordenadas inteiras. De acordo com o teorema de Pick , a área do polígono
é
uma+ b/2ÿ1,
(5,5)
(2,4)
(4,3) (7,3)
(4,1)
é 6+7/2ÿ1 = 17/2.
Funções de distância
Uma função de distância define a distância entre dois pontos. A função de distância usual é a distância
euclidiana onde a distância entre os pontos (x1, y1) e (x2, y2) é
(5,2) (5,2)
(2,1) (2,1)
(5ÿ2)2 +(2ÿ1)2 = 10
e a distância de Manhattan é
|5ÿ2| +|2ÿ1| = 4.
A imagem a seguir mostra regiões que estão a uma distância de 1 do ponto central, usando as distâncias
Euclidiana e Manhattan:
272
Machine Translated by Google
Coordenadas rotativas
Alguns problemas são mais fáceis de resolver se as distâncias de Manhattan forem usadas em vez das
distâncias euclidianas. Como exemplo, considere um problema onde temos n pontos no plano
bidimensional e nossa tarefa é calcular a distância máxima de Manhattan entre quaisquer dois pontos.
C
A
D
B
C
A
D
B
C
B
D
273
Machine Translated by Google
C
B
D
Considere dois pontos p1 = (x1, y1) e p2 = (x2, y2) cujas coordenadas giradas =
1 , y 1 2(x, y) e2 ptan
2 =distância
(x ). Agora,
entre
existem duas maneiras de expressar o Manhat - são p 1
p1 e p2:
=
Por exemplo, se p1 = (1,0) e p2 = (3,3), as coordenadas giradas são p 1 (1,ÿ1) e p 2
= (6,0) e a distância de Manhattan é
Isto é fácil, porque a diferença horizontal ou vertical das coordenadas giradas deve
ser máxima.
274
Machine Translated by Google
Capítulo 30
Muitos problemas geométricos podem ser resolvidos usando algoritmos de linha de varredura . A ideia
em tais algoritmos é representar uma instância do problema como um conjunto de eventos
que correspondem a pontos do plano. Os eventos são processados em ordem crescente
ordenar de acordo com suas coordenadas x ou y.
Como exemplo, considere o seguinte problema: Existe uma empresa que tem
n funcionários, e sabemos de cada funcionário seus horários de chegada e saída
um certo dia. Nossa tarefa é calcular o número máximo de funcionários que
estavam no escritório ao mesmo tempo.
O problema pode ser resolvido modelando a situação de modo que cada funcionário
são atribuídos dois eventos que correspondem aos seus horários de chegada e saída. Depois
classificando os eventos, nós os analisamos e monitoramos o número de pessoas
no escritório. Por exemplo, a tabela
John
Maria
Peter
Lisa
275
Machine Translated by Google
John
Maria
Peter
Lisa
++ + ÿÿ+ÿÿ
12 3 21210
Pontos de intersecção
Dado um conjunto de n segmentos de reta, cada um deles horizontal ou vertical,
considere o problema de contar o número total de pontos de interseção. Por
exemplo, quando os segmentos de linha são
276
Machine Translated by Google
1 2
3 3
1 2
1 2
Dado um conjunto de n pontos, nosso próximo problema é encontrar dois pontos cuja
distância euclidiana seja mínima. Por exemplo, se os pontos forem
Este é outro exemplo de problema que pode ser resolvido em tempo O(nlogn)
usando um algoritmo de linha de varredura1 . Percorremos os pontos da esquerda para
a direita e mantemos um valor d: a distância mínima entre dois pontos vistos até agora. No
1Além desta abordagem, existe também um algoritmo de divisão e conquista de tempo O(nlogn) [56] que
divide os pontos em dois conjuntos e resolve recursivamente o problema para ambos os conjuntos.
277
Machine Translated by Google
Um casco convexo é o menor polígono convexo que contém todos os pontos de um determinado
conjunto. Convexidade significa que um segmento de linha entre quaisquer dois vértices do polígono
está completamente dentro do polígono.
Por exemplo, para os pontos
278
Machine Translated by Google
O algoritmo de Andrew [3] fornece uma maneira fácil de construir o casco convexo
para um conjunto de pontos em tempo O (nlogn) . O algoritmo primeiro localiza o mais à esquerda
e mais à direita, e então 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 em
1 2 3 4
5 6 7 8
9 10 11 12
13 14 15 16
17 18 19 20
279
Machine Translated by Google
280
Machine Translated by Google
Bibliografia
[2] RK Ahuja e JB Orlin. Algoritmos de caminho aumentador direcionado à distância para problemas
de fluxo máximo e fluxo máximo paramétrico. Logística de Pesquisa Naval , 38(3):413–430,
1991.
[3] SOU André. Outro algoritmo eficiente para cascos convexos em duas dimensões.
Cartas de Processamento de Informações, 9(5):216–219, 1979.
[4] B. Aspvall, MF Plass e RE Tarjan. Um algoritmo de tempo linear para testar a veracidade de
certas fórmulas booleanas quantificadas. Cartas de Processamento de Informações , 8(3):121–
123, 1979.
[6] M. Beck, E. Pine, W. Tarrat e KY Jensen. Novas representações inteiras como a soma de três
cubos. Matemática da Computação, 76(259):1683–1690, 2007.
[9] J. Bentley e D. Wood. Um algoritmo de pior caso ideal para relatar interseções de retângulos.
Transações IEEE em Computadores, C-29(7):571–577, 1980.
[10] CL Bouton. Nim, um jogo com uma teoria matemática completa. Anais de Matemática,
3(1/4):35–39, 1901.
281
Machine Translated by Google
[14] EW Dijkstra. Uma nota sobre dois problemas na conexão com gráficos. Nu-
merische Mathematik, 1(1):269–271, 1959.
[15] K. Diks et al. Procurando um desafio? The Ultimate Problem Set das Competições de
Programação da Universidade de Varsóvia, Universidade de Varsóvia, 2012.
[19] S. Even, A. Itai e A. Shamir. Sobre a complexidade dos problemas de calendário e fluxo
de múltiplas mercadorias. 16º Simpósio Anual sobre Fundamentos da Ciência da
Computação, 184–193, 1975.
[20] D. Fanding. Um algoritmo mais rápido para caminho mais curto – SPFA. Journal of
Southwest Jiaotong University, 2, 1994.
[21] PM Fenwick. Uma nova estrutura de dados para tabelas de frequência cumulativa. Macio-
software: Practice and Experience, 24(3):327–336, 1994.
[22] J. Fischer e V. Heun. Melhorias teóricas e práticas no problema RMQ, com aplicações em
ACV e LCE. No Simpósio Anual sobre Correspondência de Padrões Combinatórios, 36–
48, 2006.
[24] LR Ford. Teoria do fluxo de rede. RAND Corporation, Santa Monica, Califórnia-
nia, 1956.
[26] R. Freivalds. Máquinas probabilísticas podem usar menos tempo de execução. No IFIP
congresso, 839–842, 1977.
282
Machine Translated by Google
[33] S. Halim e F. Halim. Programação Competitiva 3: O Novo Limite Inferior dos Concursos de
Programação, 2013.
[35] C. Hierholzer e C. Wiener. Über die Möglichkeit, einen Linienzug ohne Wiederholung und ohne
Unterbrechung zu umfahren. Mathematische Annalen, 6(1), 30–32, 1873.
[38] JE Hopcroft e JD Ullman. Um algoritmo de fusão de lista linear. Relatório técnico , Universidade
Cornell, 1971.
[39] E. Horowitz e S. Sahni. Computando partições com aplicações para o problema da mochila.
Jornal do ACM, 21(2):277–292, 1974.
283
Machine Translated by Google
[48] JB Kruskal. Sobre a menor subárvore de um grafo e o problema do caixeiro viajante. Anais da
American Mathematical Society, 7(1):48–50, 1956.
[50] MG Main e RJ Lorentz. Um algoritmo O(nlogn) para encontrar todas as repetições em uma
string. Jornal de Algoritmos, 5(3):422–432, 1984.
[51] J. Pachocki e J. Radoszewski. Onde usar e como não usar hash de string polinomial. Olimpíadas
de Informática, 7(1):90–100, 2013.
[56] MI Shamos e D. Hoey. Problemas de ponto mais próximo. Nos Anais do 16º Simpósio Anual
sobre Fundamentos da Ciência da Computação, 151–162, 1975.
[57] Sr. Sharir. Um algoritmo de forte conectividade e suas aplicações na análise de fluxo de dados.
Computadores e Matemática com Aplicações, 7(1):67–72, 1981.
[64] RE Tarjan. Eficiência de um algoritmo de união de conjuntos bom, mas não linear. Jornal do
ACM, 22(2):215–225, 1975.
284
Machine Translated by Google
285
Machine Translated by Google
286