Você está na página 1de 451

Machine Translated by Google

Machine Translated by Google


Machine Translated by Google

Algoritmos
2ª edição

por John Paul Mueller e Luca Massaron


Machine Translated by Google

Algoritmos para Leigos®, 2ª Edição


Publicado por: John Wiley & Sons, Inc., 111 River Street, Hoboken, NJ 07030-5774, www.wiley.com

Copyright © 2022 por John Wiley & Sons, Inc., Hoboken, Nova Jersey

Copyright de compilação de mídia e software © 2022 por John Wiley & Sons, Inc. Todos os direitos reservados.

Publicado simultaneamente no Canadá

Nenhuma parte desta publicação pode ser reproduzida, armazenada em um sistema de recuperação ou transmitida de
qualquer forma ou por qualquer meio, eletrônico, mecânico, fotocópia, gravação, digitalização ou outros, exceto conforme
permitido pelas Seções 107 ou 108 do Copyright dos Estados Unidos de 1976 Act, sem a permissão prévia por escrito da
Editora. Solicitações de permissão ao Editor devem ser endereçadas ao Departamento de Permissões, John Wiley & Sons,
Inc., 111 River Street, Hoboken, NJ 07030, (201) 748-6011, fax (201) 748-6008, ou online em http ://www.wiley.com/go/permissions.

Marcas registradas: Wiley, For Dummies, o logotipo Dummies Man, Dummies.com, Making Everything Easier e a imagem comercial
relacionada são marcas comerciais ou marcas registradas da John Wiley & Sons, Inc. e não podem ser usadas sem permissão por escrito.
Todas as outras marcas registradas são de propriedade de seus respectivos proprietários. A John Wiley & Sons, Inc. não está associada a
nenhum produto ou fornecedor mencionado neste livro.

LIMITE DE RESPONSABILIDADE/ISENÇÃO DE GARANTIA: ENQUANTO O EDITOR E OS AUTORES USAM SEUS

MELHORES ESFORÇOS NA PREPARAÇÃO DESTE TRABALHO, NÃO FAZEM REPRESENTAÇÕES OU GARANTIAS COM RESPEITO
PARA A PRECISÃO OU INTEGRIDADE DO CONTEÚDO DESTE TRABALHO E ESPECIFICAMENTE RENUNCIA A TODOS

GARANTIAS, INCLUINDO, SEM LIMITAÇÃO, QUAISQUER GARANTIAS IMPLÍCITAS DE COMERCIALIZAÇÃO OU ADEQUAÇÃO A UM DETERMINADO FIM. NENHUMA GARANTIA PODE
SER CRIADA OU ESTENDIDA POR VENDAS

REPRESENTANTES, MATERIAIS DE VENDA ESCRITOS OU DECLARAÇÕES PROMOCIONAIS PARA ESTE TRABALHO. O FATO DE UMA ORGANIZAÇÃO, SITE OU PRODUTO SER

REFERIDO NESTE TRABALHO COMO UMA CITAÇÃO E/OU FONTE POTENCIAL DE INFORMAÇÕES ADICIONAIS NÃO SIGNIFICA QUE O EDITOR E OS AUTORES

ENDOSSAR AS INFORMAÇÕES OU SERVIÇOS QUE A ORGANIZAÇÃO, SITE OU PRODUTO PODE FORNECER OU RECOMENDAÇÕES QUE PODE FAZER. ESTA OBRA É VENDIDA
COM O ENTENDIMENTO DE QUE A EDITORA É

NÃO ENVOLVIDO NA PRESTAÇÃO DE SERVIÇOS PROFISSIONAIS. OS CONSELHOS E ESTRATÉGIAS AQUI CONTIDOS

PODE NÃO SER ADEQUADO PARA SUA SITUAÇÃO. VOCÊ DEVE CONSULTAR UM ESPECIALISTA QUANDO APROPRIADO.

ALÉM DISSO, OS LEITORES DEVEM ESTAR CIENTES DE QUE OS SITES LISTADOS NESTE TRABALHO PODEM TER MUDADO OU DESAPARECIDOS ENTRE QUANDO ESTE TRABALHO
FOI ESCRITO E QUANDO É LIDO. NEM A EDITORA

NEM OS AUTORES SERÃO RESPONSÁVEIS POR QUALQUER PERDA DE LUCRO OU QUAISQUER OUTROS DANOS COMERCIAIS, INCLUINDO MAS NÃO LIMITADO A DANOS

ESPECIAIS, INCIDENTAIS, CONSEQUENCIAIS OU OUTROS.

Para obter informações gerais sobre nossos outros produtos e serviços, entre em contato com nosso Departamento de Atendimento
ao Cliente nos EUA pelo telefone 877-762-2974, fora dos EUA pelo telefone 317-572-3993 ou pelo fax 317-572-4002. Para suporte
técnico, visite https://hub.wiley.com/community/support/dummies.

Wiley publica em uma variedade de formatos impressos e eletrônicos e por impressão sob demanda. Alguns materiais incluídos nas versões
impressas padrão deste livro podem não estar incluídos em e-books ou em impressão sob demanda. Se este livro se referir a mídia como um
CD ou DVD que não está incluído na versão que você comprou, você pode baixar este material em http://booksupport.wiley.com. Para obter
mais informações sobre os produtos Wiley, visite www.wiley.com.

Número de controle da Biblioteca do Congresso: 2022934261

ISBN: 978-1-119-86998-6; 978-1-119-86999-3 (ebk); 978-1-119-87000-5 (ebk)


Machine Translated by Google

Conteúdo em resumo
Introdução ........................................................ 1

Parte 1: Introdução aos algoritmos . . . . . . . . . . . . . . . . . . . . . . . 7

CAPÍTULO 1: Apresentando Algoritmos . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 9


CAPÍTULO 2: Considerando o Projeto de Algoritmos . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 23
CAPÍTULO 3: Trabalhando com o Google Colab . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 41

CAPÍTULO 4: Realizando manipulações essenciais de dados usando Python . . . . . . . . . . . . . 59


CAPÍTULO 5: Desenvolvendo uma Classe de Computação de Matriz ....................... 79

Parte 2: Entendendo a necessidade de classificar e pesquisar . . . . . . . 97

CAPÍTULO 6: Estruturando Dados . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 99

CAPÍTULO 7: Organizando e Pesquisando Dados ................................. 117

Parte 3: Explorando o mundo dos gráficos . . . . . . . . . . . . . . . . . . . . . . 139

CAPÍTULO 8: Entendendo o básico do gráfico .................................. 141


........................................
CAPÍTULO 9: Reconectando os pontos CAPÍTULO 161
....................................
10: Descobrindo os segredos do gráfico CAPÍTULO 195

11: Obtendo a página da Web correta . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 207

Parte 4: Preparando Big Data CAPÍTULO 12: . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 223

Gerenciando Big Data CAPÍTULO 13: . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 225

Paralelizando Operações . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 249

CAPÍTULO 14: Compressão e ocultação de dados ............................. 267

Parte 5: Desafiando Problemas Difíceis CAPÍTULO . . . . . . . . . . . . . . . . . . . . . . 289

15: Trabalhando com Algoritmos Gananciosos . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 291


..
CAPÍTULO 16: Confiando na programação dinâmica . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 307

CAPÍTULO 17: Usando algoritmos aleatórios . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 331

CAPÍTULO 18: Executando Pesquisa Local . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 349

CAPÍTULO 19: Empregando Programação Linear . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 367

CAPÍTULO 20: Considerando Heurísticas . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 381

Parte 6: A Parte das Dez ........................................ 401

CAPÍTULO 21: Dez algoritmos que estão mudando o mundo .................... 403

CAPÍTULO 22: Dez problemas algorítmicos ainda a resolver .......................... 411

Índice .............................................................. 417


Machine Translated by Google
Machine Translated by Google

Índice
INTRODUÇÃO ................................................... 1
Sobre este livro .............................................. 1
Suposições Tolas. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3
Ícones usados neste livro ....................................... 3
. . .ir. a. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Além do livro Para onde 4
partir daqui ....................................... 5

PARTE 1: COMEÇANDO COM ALGORITMOS ............ 7

CAPÍTULO 1: Apresentando Algoritmos . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 9

Descrevendo Algoritmos. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 10
A maneira certa de fazer brinde: Definir algoritmo usa Encontrar . . . . . . . . . 12
algoritmos em todos os lugares . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 14
Usando computadores para resolver ........................... 15
problemas Obtendo o máximo das CPUs e GPUs modernas . . . . . . . . . . . . . 16
Trabalhando com chips para fins especiais . . . . . . . . . . . . . . . . . . . . . . . . . 17
Redes: Compartilhar é mais do que cuidar. . . . . . . . . . . . . . . . . . . . . . 18
Aproveitando os dados .................................. 18
disponíveis Distinguindo entre problemas e soluções . . . . . . . . . . . . . . . . . . . . 19
. . . .não
Ser correto e eficiente Descobrir que . . . existe
.......................... 19
almoço grátis Adaptar a estratégia ao .......................... 20
problema. . . . . . . . . . . . . . . . . . . . . . . 20
Descrevendo algoritmos em uma língua franca ..................... 20
..........
Enfrentando problemas que são como paredes de tijolos, só que mais difíceis 21
Estruturando dados para obter uma solução .......................... 21
Entendendo o ponto de vista de um computador ................... 22
Organizar os dados faz a diferença ........................ 22

CAPÍTULO 2: Considerando o Projeto de Algoritmos . . . . . . . . . . . . . . . . . . . . . .23. .


Começando a Resolver um ................................... 24
Problema Modelando problemas do mundo real . . . . . . . . . . . . . . . . . . . . . . . . . .25
...
Encontrar soluções e contra-exemplos. . . . . . . . . . . . . . . . . . . . . 26
De pé sobre os ombros de gigantes. . . . . . . . . . . . . . . . . . . . . . . . . 27
Dividindo e Conquistando. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 28
Evitando soluções de força bruta ............................. 29
Mantendo as coisas simples, bobas (KISS) . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .29
.
Resolver um problema geralmente é melhor ................... 30
Aprender que a ganância pode ser boa ............................. 30
Aplicando o raciocínio ganancioso..................................31
Alcançando uma boa solução 31 ..................................
Computando Custos e Seguintes Heurísticas 32 ......................

Índice v
Machine Translated by Google

Representando o problema como um espaço. . . . . . . . . . . . . . . . . . . . . . . 33


Indo aleatoriamente e sendo abençoado pela sorte . . . . . . . . . . . . . . . . . . . . 34
Usando uma heurística e uma função de custo . . . . . . . . . . . . . . . . . . . . . . . . 34
Avaliando Algoritmos . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 35
Simulando usando máquinas abstratas ......................... 36
Ficando ainda mais abstrato ................................ 37
Trabalhando com funções .................................... 38

CAPÍTULO 3: Trabalhando com o Google Colab


............................ 41

Definindo o Google Colab ....................................... 42


Entendendo o que o Google Colab faz ..................... 42
Familiarizando-se com os recursos do Google Colab .................. 44
Trabalhando com Notebooks ..................................... 47
Criando um novo bloco de .................................. 47
anotações Abrindo blocos de ............................... 47
........................................
anotações existentes Salvando 50
...................................
blocos de anotações Executando tarefas 51
comuns Criando células de . .código
..................................... 52
Criando células de texto .Criando
....................................... 54
células especiais Editando células...................................... 54
Movendo células. . Usando
. . . . . . .a. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 55
. . . . . . .Executando
aceleração de hardware ...................................... 55
o código Obtendo ................................. 55
.......................................... 56
ajuda . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 57

CAPÍTULO 4: Executando Manipulações Essenciais de Dados


Usando Python . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 59

Executando cálculos usando vetores e matrizes Noções básicas ............. 60


sobre operações escalares e vetoriais. . . . . . . . . . . . . . . . . 61
Executando a multiplicação vetorial. . . . . . . . . . . . . . . . . . . . . . . . . . . 63
Criar uma matriz é o caminho certo para começar .................... 63
. . . . . . . . . avançadas
Multiplicar matrizes Definir operações . . . . . . . . . de
.................... 64
matriz. . . . . . . . . . . . . . . . . . . . . . . 65
Criando Combinações da Maneira Certa. . . . . . . . . . . . . . . . . . . . . . . . . . 67
Distinção de permutações. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 68
Embaralhando combinações . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 69
Repetições de frente. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 70
Obtendo os resultados desejados usando recursão .................... 71
Explicando a recursão ...................................... 71
Eliminando a recursão de chamada ............................... 74
final Executando tarefas mais rapidamente . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 75
Considerando dividir e conquistar. . . . . . . . . . . . . . . . . . . . . . . . . . . . 75
Distinguir entre as diferentes soluções possíveis ........... 78

vi Algoritmos para Leigos


Machine Translated by Google

CAPÍTULO 5: Desenvolvendo uma Classe de Computação Matriz. . . . . . . . . . . . 79


Evitando o uso do NumPy . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 80
Entendendo por que usar uma classe é importante. . . . . . . . . . . . . . . . . . 81
Construindo a classe básica . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 82
Criando uma matriz ......................................... 83
Imprimindo a matriz resultante ............................... 84
Acessando elementos específicos da matriz ......................... 85
Realizando adição escalar e de matriz Realizando . . . . . . . . . . . . . . . . . . . . . . 86
multiplicação. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 87
Manipulando a matriz ...................................... 90
Transpondo uma matriz ..................................... 91
Calculando o determinante ............................... 91
Achatando a matriz ...................................... 95

PARTE 2: COMPREENDENDO A NECESSIDADE


PARA CLASSIFICAR E PESQUISAR . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 97

.........................................
CAPÍTULO 6: Estruturando Dados 99

Determinando a Necessidade de Estrutura.............................. 100


Facilitando a visualização do conteúdo ......................... 100
Correspondência de dados de várias fontes ....................... 101
Considerando a necessidade de correção ...................... 102
Empilhamento e empilhamento de dados em. . ordem
........................... 105
. . . . . . .de
Ordenação em pilhas Utilização ................................ 105
filas. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 107
Localizando dados usando dicionários ............................ 108
. . . . . . . . . os
Trabalhando com árvores Entendendo ................................ 109
conceitos básicos de árvores Construindo uma . . . . . . . . . . . . . . . . . . . . . . . . . 109
árvore .......................................... 110
Representando Relações em um Gráfico . . . . . . . . . . . . . . . . . . . . . . . . . . . . 112
Indo além das árvores ...................................... 113
Construindo gráficos . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 114

CAPÍTULO 7: Organizando e Pesquisando Dados . . . . . . . . . . . . . . . . . . . . . . 117

Classificando dados usando a classificação por mesclagem e a .................. 118


classificação rápida Entendendo por que a classificação de dados é importante. . . . . . . . . . . . . 118
..
Empregando melhores técnicas de classificação. . . . . . . . . . . . . . . . . . . . . . . . . . 122
Usando Árvores de Pesquisa e o Heap . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 127
Considerando a necessidade de pesquisar de forma eficaz. . . . . . . . . . . . . . . . . . 127
Construindo uma árvore de pesquisa . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 129
binária Executando pesquisas especializadas usando um heap binário. . . . . . . . . 131
132
Confiando em hash. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Colocando tudo em baldes ............................ 132
Evitando colisões ....................................... 134
Criando sua própria função de hash .......................... 135

Índice vii
Machine Translated by Google

PARTE 3: EXPLORANDO O MUNDO DOS GRÁFICOS ............ 139

........................ 141
CAPÍTULO 8: Noções básicas de gráficos
Explicando a Importância das Redes......................... 142
Considerando a essência de um gráfico. . . . . . . . . . . . . . . . . . . . . . . . 142
Encontrando gráficos em todos os lugares. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 145
Mostrando o lado social dos gráficos. . . . . . . . . . . . . . . . . . . . . . . . . . 146
Entendendo os subgráficos. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 147
Definindo como desenhar um gráfico . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 148
Distinguindo os atributos-chave 149 ..........................
Desenhando o gráfico. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 150
Medindo a Funcionalidade do Gráfico . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 151
Contando arestas e vértices 152 .............................
Centralidade de computação .............................. 154
Colocando um gráfico em formato numérico . . . . . . . . . . . . . . . . . . . . . . . . . . . 157
Adicionando um gráfico a uma matriz. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 157
Usando representações esparsas. . . . . . . . . . . . . . . . . . . . . . . . . . . . . 158
Usando uma lista para armazenar um gráfico. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 159

............................... 161
CAPÍTULO 9: Reconectando os pontos que
atravessam um gráfico com eficiência . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 162
.
Criando o gráfico. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 163
Aplicando a pesquisa em largura ............................. 164
...............................
Aplicando a pesquisa em profundidade 165
Determinando qual aplicativo usar Classificando . . . . . . . . . . . . . . . . . . . . . . 167
os elementos do gráfico .................................. 168
Trabalhando em Gráficos Acíclicos Dirigidos (DAGs). . . . . . . . . . . . . . . . . 169
Baseando-se na classificação topológica. . . . . . . . . . . . . . . . . . . . . . . . . . . . . 169
Reduzindo a uma árvore de abrangência mínima . . . . . . . . . . . . . . . . . . . . . . . . 170
Obtendo o contexto histórico da árvore de abrangência mínima ........ 170
Trabalhando com gráficos não ponderados versus ponderados. . . . . . . . . . . 171
Criando um exemplo de árvore geradora mínima. . . . . . . . . . . . . . . . 171
Descobrindo os algoritmos corretos para usar ................... 173
Apresentando filas de prioridade. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 174
Aproveitando o algoritmo de Prim. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 175
Testando o algoritmo de Kruskal. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 177
Determinando qual algoritmo funciona melhor ................... 179
Encontrando a rota mais curta .................................. 180
Definindo o que significa encontrar o caminho mais curto. . . . . . . . . . . . . 180
Adicionando uma aresta negativa. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 182
Explicando o algoritmo de Dijkstra. . . . . . . . . . . . . . . . . . . . . . . . . . . . 184
Explicando o algoritmo de Bellman-Ford. . . . . . . . . . . . . . . . . . . . . 187
Explicando o algoritmo Floyd-Warshall. . . . . . . . . . . . . . . . . . . . 190

viii Algoritmos para Leigos


Machine Translated by Google

CAPÍTULO 10: Descobrindo os segredos dos gráficos


........................... 195

Visualizando Redes Sociais como Gráficos . . . . . . . . . . . . . . . . . . . . . . . . 196


Agrupando redes em grupos . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 196
Descobrindo comunidades ................................. 199
Navegando em um gráfico . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 202
Contando os graus de separação. . . . . . . . . . . . . . . . . . . . . . . . 202
Andando um gráfico aleatoriamente. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 204

CAPÍTULO 11: Obtendo a página da Web correta . . . . . . . . . . . . . . . . . . . . . . . . . 207

Encontrando o mundo em um mecanismo de busca. . . . . . . . . . . . . . . . . . . . . . . . . . 208


Pesquisando dados na Internet ............................ 208
Considerando como encontrar os dados corretos . . . . . . . . . . . . . . . . . . . . . 209
Explicando o algoritmo PageRank . . . . . . . . . . . . . . . . . . . . . . . . . . . 210
Compreender o raciocínio por trás do algoritmo
PageRank. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 210
Explicando as porcas e parafusos do PageRank. . . . . . . . . . . . . . . . . . 212
Implementando PageRank . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 212
Implementando um script Python. . . . . . . . . . . . . . . . . . . . . . . . . . . . . 213
Lutando com uma implementação ingênua. . . . . . . . . . . . . . . . . . . . 216
Apresentando tédio e teletransporte. . . . . . . . . . . . . . . . . . . . . . 219
Olhando para dentro da vida de um motor de busca. . . . . . . . . . . . . . . . . . . 220
Considerando outros usos do PageRank . . . . . . . . . . . . . . . . . . . . . . . 221
Indo além do paradigma PageRank. . . . . . . . . . . . . . . . . . . . . . . . 221
Apresentando consultas semânticas. . . . . . . . . . . . . . . . . . . . . . . . . . . . . 222
Usando IA para classificar resultados de pesquisa . . . . . . . . . . . . . . . . . . . . . . . . . 222

PARTE 4: DISCUTINDO BIG DATA. . . . . . . . . . . . . . . . . . . . . . . . . . . . . 223

CAPÍTULO 12: Gerenciando Big Data .................................... 225

Transformando poder em dados ............................... 226


Entendendo as implicações de Moore. . . . . . . . . . . . . . . . . . . . . . . 226
Encontrando dados em todos os lugares. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 228
Colocando algoritmos nos negócios Fluxos . . . . . . . . . . . . . . . . . . . . . . . . . . 231
. . . com
de fluxo de dados Analisando fluxos . . . . a. . . . . . . . . . . . . . . . . . . . . . . . . . . . . 233
receita certa. . . . . . . . . . . . . . . . . . . . . 234
Reservando os dados certos . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 235
Esboçando uma resposta a partir de dados de ....................... 240
fluxo Filtrando elementos de fluxo de cor ......................... 240
Demonstração do filtro Bloom Encontrando. .o. . . . . . . . . . . . . . . . . . . . . . . . . 243
número de elementos distintos Aprendendo a contar. . . . . . . . . . . . . . . . . . . . 246
objetos em um fluxo ...................... 247

Índice ix
Machine Translated by Google

CAPÍTULO 13: Operações de Paralelização . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 249


Gerenciando Imensas Quantidades de Dados . . . . . . . . . . . . . . . . . . . . . . . . . 250
Entendendo o Paradigma Paralelo. . . . . . . . . . . . . . . . . . . . . . 251
Distribuindo arquivos e operações. . . . . . . . . . . . . . . . . . . . . . . . . . . 253
Empregando a solução MapReduce ........................ 255
Elaborando Algoritmos para MapReduce . . . . . . . . . . . . . . . . . . . . . . . 259
Configurando uma simulação MapReduce . . . . . . . . . . . . . . . . . . . . . . . . 260
Consulta por mapeamento . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 262

CAPÍTULO 14: Compressão e ocultação de dados ................. 267

Tornando os dados menores . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 268


Entendendo a codificação. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 268
Considerando os efeitos da compressão. . . . . . . . . . . . . . . . . . . . 270
Escolhendo um tipo específico de compressão. . . . . . . . . . . . . . . . . . 271
Escolhendo sua codificação com sabedoria. . . . . . . . . . . . . . . . . . . . . . . . . . . . 273
Codificação usando compressão Huffman. . . . . . . . . . . . . . . . . . . . . 276
Lembrar sequências com LZW ........................ 278
Escondendo seus segredos com criptografia. . . . . . . . . . . . . . . . . . . . . . . . 282
Substituindo caracteres ................................... 283
Trabalhando com criptografia AES. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 285

PARTE 5: DESAFIO DE PROBLEMAS DIFÍCEIS ........... 289

CAPÍTULO 15: Trabalhando com Algoritmos Gananciosos . . . . . . . . . . . . . . . . . 291


..
Decidindo quando é melhor ser ganancioso. . . . . . . . . . . . . . . . . . . . . . . 292
Entendendo por que ganancioso é bom. . . . . . . . . . . . . . . . . . . . . . . . 293
Mantendo algoritmos gulosos sob controle ................... 294
Considerando problemas NP completos. . . . . . . . . . . . . . . . . . . . . . . . 297
Descobrindo como o Greedy pode ser útil ....................... 299
Organizando dados de computador em .......................... 299
cache Competindo por recursos.Revisitando
................................ 301
a codificação de Huffman. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 303

CAPÍTULO 16: Confiando na programação dinâmica . . . . . . . . . . . . . . . . . 307

Explicando a Programação Dinâmica. . . . . . . . . . . . . . . . . . . . . . . . . . . . 308


Obtenção de uma base histórica . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 308
Tornar os problemas dinâmicos. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 309
Lançando recursão dinamicamente. . . . . . . . . . . . . . . . . . . . . . . . . . . . . 311
Aproveitando a memoização . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 314
Descobrindo as melhores receitas dinâmicas. . . . . . . . . . . . . . . . . . . . . . . . 316
Olhando dentro da mochila. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 317
Percorrendo as cidades .................................... 321
Aproximando a busca de strings .............................. 326

x Algoritmos para Leigos


Machine Translated by Google

CAPÍTULO 17: Usando algoritmos aleatórios . . . . . . . . . . . . . . . . . . . . . . 331

Definindo como funciona a randomização .......................... 332


Considerando por que a randomização é necessária . . . . . . . . . . . . . . . . . . 333
Entendendo como a probabilidade funciona ...................... 334
. . . o. .uso
Entendendo as distribuições Simulando . . . do
...................... 335
método de Monte Carlo Colocando a aleatoriedade em .............. 339
sua lógica. . . . . . . . . . . . . . . . . . . . . . . . . . 341
Calculando uma mediana usando seleção rápida ..................... 341
Fazendo simulações usando Monte Carlo ...................... 344
. . . .rápida
Encomendando mais rápido com classificação ........................ 347

CAPÍTULO 18: Realizando Pesquisa Local . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 349

Noções básicas sobre a pesquisa local . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 350


Conhecendo o bairro. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 351
Apresentando truques de pesquisa local . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 353
Explicando a escalada de colinas com n-rainhas. . . . . . . . . . . . . . . . . . . . . . 354
Descobrindo o recozimento simulado. . . . . . . . . . . . . . . . . . . . . . . . . . 357
Evitando repetições usando a Pesquisa Tabu.........................358
.......................
Resolvendo a Satisfação de Circuitos Booleanos 359
Resolvendo 2-SAT usando randomização..............................360
Implementando o código Python 361 . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Percebendo que o ponto de partida é importante. . . . . . . . . . . . . . . . 365

CAPÍTULO 19: Empregando Programação Linear . . . . . . . . . . . . . . . . . . . . 367


Usando funções lineares como uma ferramenta . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 368
Compreendendo a matemática básica que . . . . . . . . . . . . . . . . . . . . . . . . . 369
você precisa Aprender a simplificar ao planejar. . . . . . . . . . . . . . . . . . . . . . . . 371
Trabalhando com geometria usando simplex . . . . . . . . . . . . . . . . . . . . . . 372
Entendendo as limitações do uso da . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 373
programação linear na prática Configurando o . . . . . . . . . . . . . . . . . . . . . . . . . 374
. . . . . .e. a. . . . . . . . . . . . . . . . . . . . . . . . . .
PuLP em casa Otimizando a produção 375
receita ....................... 376

CAPÍTULO 20: Considerando Heurísticas


................................ 381

Heurísticas Diferenciadas .................................... 382


Considerando os objetivos da heurística ......................... 383
Indo da genética para a IA . . . . . . . . . . . ....................... 383
Robôs de roteamento usando heurística . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 384
Exploração em territórios desconhecidos . . . . . . . . . . . . . . . . . . . . . . . . . . . 385
Usando medidas de distância como heurística ..................... 387
Explicando algoritmos de localização de caminhos. . . . . . . . . . . . . . . . . . . . . . . . . . . 388
Criando um labirinto . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 388
Procurando por uma rota rápida do melhor . . . . . . . . . . . . . . . . . . . . . . . . . 392
primeiro Andando heuristicamente por A* . . . . . . . . . . . . . . . . . . . . . . . . . . 396

Índice xi
Machine Translated by Google

PARTE 6: A PARTE DE DEZ ................................... 401

.... 403
CAPÍTULO 21: Dez algoritmos que estão mudando o mundo
Usando rotinas de ......................................... 404
classificação Procurando coisas com rotinas de ....................... 404
pesquisa Agitando as coisas com números aleatórios . . . . . . . . . . . . . . . . . . . . . 405
Realizando compactação de dados . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 406
Mantendo os dados em ........................................ 406
segredo Alterando o domínio de .................................. 407
. . . Identificando
dados Analisando links .......................................... 407
......................................
padrões de dados Lidando com 408
automação e respostas automáticas . . . . . . . . . . . . . 409
Criando identificadores exclusivos . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 409

............ 411
CAPÍTULO 22: Dez problemas algorítmicos ainda a resolver
Resolvendo Problemas Rapidamente. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 412
Resolvendo problemas do 3SUM com mais eficiência. . . . . . . . . . . . . . . . . . . . . . . 412
Tornando a Multiplicação de Matrizes Mais Rápida.413
..........................
Determinando se um aplicativo terminará 413 ..................
Criando e usando funções unidirecionais.........................414
Multiplicando Números Realmente Grandes 414 ............................
Dividindo um Recurso Igualmente . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 415
Reduzindo Editar o Tempo de Cálculo da Distância . . . . . . . . . . . . . . . . . . . . . . . 415
. . . . . . . . . os
Jogando o Jogo da Paridade Entendendo ............................ 416
Problemas Espaciais ................................ 416

ÍNDICE . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 417

xii Algoritmos para Leigos


Machine Translated by Google

Introdução
você tentou sobre o assunto acabam sendo mais ao longo das linhas de muito bom
Você precisa aprender
auxílios sobre
indutores doalgoritmos parade
sono em vez a escola ou trabalho.
textos para No entanto,
lhe ensinar todos os livros
algo. Assumindo
que você pode superar os símbolos arcanos obviamente escritos por uma criança
demente de dois anos com uma propensão para rabiscos, você acaba não tendo ideia
de por que você quer saber alguma coisa sobre eles. A maioria dos textos de matemática são chatos!
No entanto, Algoritmos para Leigos, 2ª Edição é diferente. A primeira coisa que você
notará é que este livro tem uma falta definitiva de símbolos estranhos (especialmente do
tipo rabiscado) flutuando. Sim, você vê alguns (afinal, é um livro de matemática), mas o
que você encontra são instruções claras para usar algoritmos que realmente têm nomes
e um histórico por trás deles e que executam tarefas úteis. Você encontrará técnicas de
codificação simples para realizar tarefas incríveis que intrigarão seus amigos. Você
certamente pode deixá-los com inveja ao realizar proezas matemáticas que eles não
podem começar a entender. Você consegue tudo isso sem ter que forçar seu cérebro,
nem um pouco, e você nem vai adormecer (bem, a menos que você realmente queira
fazê-lo). Novidades nesta edição do livro são mais detalhes sobre como os algoritmos
funcionam, e você ainda pode criar seu próprio pacote básico de matemática para saber
como fazer isso na próxima entrevista de emprego.

Sobre este livro


Algoritmos para Leigos, 2ª Edição é o livro de matemática que você queria na faculdade, mas
não conseguiu. Você descobre, por exemplo, que os algoritmos não são novos. Afinal, os
babilônios usavam algoritmos para realizar tarefas simples já em 1.600 aC. Se os babilônios
conseguiram descobrir essas coisas, certamente você também pode! Este livro na verdade
tem três coisas que você não encontrará na maioria dos livros de matemática:

» Algoritmos que têm nomes reais e uma base histórica para que você possa
lembrar do algoritmo e saber por que alguém levou tempo para criá-lo

» Explicações simples de como o algoritmo realiza feitos incríveis de dados


manipulação, análise de dados ou previsão de probabilidade

» Código que mostra como usar o algoritmo sem realmente lidar com símbolos
misteriosos que ninguém sem um diploma de matemática pode entender

Introdução 1
Machine Translated by Google

Parte da ênfase deste livro está no uso das ferramentas certas. Este livro usa Python para
realizar várias tarefas. O Python possui recursos especiais que facilitam significativamente o
trabalho com algoritmos. Por exemplo, o Python fornece acesso a uma enorme variedade de
pacotes que permitem fazer praticamente qualquer coisa que você possa imaginar, e mais do
que alguns que você não pode. No entanto, ao contrário de muitos textos que usam Python,
este não o enterra em pacotes. Usamos um seleto grupo de pacotes que oferecem grande
flexibilidade com muitas funcionalidades, mas não exigem que você pague nada. Você pode ler
este livro inteiro sem gastar um centavo do seu dinheiro suado.

Você também descobre algumas técnicas interessantes neste livro. O mais importante é que
você não veja apenas os algoritmos usados para realizar tarefas; você também recebe uma
explicação de como os algoritmos funcionam. Ao contrário de muitos outros livros, Algorithms
For Dummies, 2nd Edition permite que você entenda completamente o que está fazendo, mas
sem exigir que você tenha um PhD em matemática. Cada um dos exemplos mostra a saída
esperada e informa por que essa saída é importante. Você não fica com a sensação de que
algo está faltando.

É claro que você ainda pode estar preocupado com toda a questão do ambiente de programação,
e este livro também não o deixa no escuro. Este livro conta com o Google Colab para fornecer
um ambiente de programação (embora você também possa usar o Jupy ter Notebook com
bastante facilidade). Como você acessa o Colab por meio de um navegador, você pode
programar em qualquer lugar e a qualquer momento que tiver acesso a um navegador, até
mesmo em seu smartphone enquanto estiver no consultório do dentista ou possivelmente de
cabeça para baixo assistindo a reprises do seu programa favorito.

Para ajudá-lo a absorver os conceitos, este livro usa as seguintes convenções:

» O texto que você deve digitar exatamente como aparece no livro está em negrito. A exceção
é quando você está trabalhando em uma lista de etapas: como cada etapa está em negrito,
o texto a ser digitado não está em negrito.

» As palavras que queremos que você digite e que também estejam em itálico são usadas como
espaços reservados, o que significa que você precisa substituí-las por algo que funcione
para você. Por exemplo, se você vir "Digite seu nome e pressione Enter", precisará substituir
Seu nome pelo seu nome real.

» Também usamos itálico para termos que definimos. Isso significa que você não precisa confiar
em outras fontes para fornecer as definições que você precisa.

» Os endereços da Web e o código de programação aparecem em monofont. Se estiver


lendo uma versão digital deste livro em um dispositivo conectado à Internet, clique
no link ao vivo para visitar esse site, como este: http://www.dummies.com.

» Quando precisar clicar em sequências de comandos, você as verá separadas por uma seta
especial, como esta: Arquivo ÿ Novo Arquivo, que informa para clicar em Arquivo e depois
em Novo Arquivo.

2 Algoritmos para Leigos


Machine Translated by Google

Suposições Tolas
Você pode achar difícil acreditar que presumimos algo sobre você – afinal, ainda nem
conhecemos você! Embora a maioria das suposições sejam realmente tolas, fizemos
algumas suposições para fornecer um ponto de partida para o livro.

A primeira suposição é que você esteja familiarizado com a plataforma que deseja usar,
pois o livro não fornece nenhuma orientação a esse respeito. (O Capítulo 3, no entanto,
mostra como acessar o Google Colab de seu navegador e usá-lo para trabalhar com os
exemplos de código do livro.) Para fornecer o máximo de informações sobre Python em
relação a algoritmos, este livro não discute quaisquer problemas específicos da plataforma.
Você realmente precisa saber como instalar aplicativos, usar aplicativos e geralmente
trabalhar com a plataforma escolhida antes de começar a trabalhar com este livro.

Este livro não é uma cartilha matemática. Sim, você vê muitos exemplos de matemática
complexa, mas a ênfase está em ajudá-lo a usar o Python para realizar tarefas comuns
usando algoritmos em vez de aprender teoria matemática. No entanto, você obtém
explicações de muitos dos algoritmos usados no livro para que possa entender como os
algoritmos funcionam. Os Capítulos 1 e 2 orientam você sobre o que você precisa saber
para usar este livro com sucesso. O Capítulo 5 é um capítulo especial que discute como
criar sua própria biblioteca matemática, o que o ajuda significativamente a entender como
a matemática funciona com o código para criar um pacote reutilizável. Também parece
ótimo em seu currículo dizer que você criou sua própria biblioteca de matemática.

Este livro também pressupõe que você pode acessar itens na Internet. Espalhados por
toda parte estão inúmeras referências a material online que melhorarão sua experiência
de aprendizado. No entanto, essas fontes adicionadas são úteis apenas se você realmente
as encontrar e usar. Você também deve ter acesso à Internet para usar o Google Colab.

Ícones usados neste livro


Ao ler este livro, você encontra ícones nas margens que indicam material de interesse (ou
não, conforme o caso). Veja o que os ícones significam:

As dicas são legais porque ajudam você a economizar tempo ou realizar alguma tarefa
sem muito trabalho extra. As dicas neste livro são técnicas que economizam tempo ou
indicadores de recursos que você deve experimentar para obter o máximo benefício do
Python ou na execução de tarefas relacionadas a algoritmos ou análises de dados.

Introdução 3
Machine Translated by Google

Não queremos parecer pais raivosos ou algum tipo de maníaco, mas você deve evitar fazer
qualquer coisa marcada com um ícone de Aviso. Caso contrário, você pode descobrir que seu
aplicativo não funciona conforme o esperado, obter respostas incorretas de algoritmos
aparentemente à prova de balas ou (na pior das hipóteses) perder dados.

Sempre que você vir este ícone, pense em uma dica ou técnica avançada. Você pode achar
esses pedaços de informações úteis muito chatos para palavras, ou eles podem conter a solução
que você precisa para executar um programa. Pule essas informações quando quiser.

Se você não obtiver mais nada de um capítulo ou seção em particular, lembre-se do material
marcado por este ícone. Este texto geralmente contém um processo essencial ou um pouco de
informação que você deve saber para trabalhar com Python ou para executar tarefas relacionadas
a algoritmos ou análises de dados com sucesso.

Além do livro
Este livro não é o fim de sua experiência de aprendizado de Python ou algoritmo — é apenas o
começo. Fornecemos conteúdo on-line para tornar este livro mais flexível e mais capaz de
atender às suas necessidades. Dessa forma, à medida que recebemos seus e-mails, podemos
responder a perguntas e informar como as atualizações do Python ou de seus complementos
associados afetam o conteúdo do livro. Na verdade, você ganha acesso a todas essas adições legais:

» Folha de cola: Você se lembra de usar notas de berço na escola para tirar uma nota melhor
em um teste, não é? Você faz? Bem, uma folha de dicas é mais ou menos assim. Ele
fornece algumas notas especiais sobre tarefas que você pode fazer com Python, Google
Colab e algoritmos que nem todas as outras pessoas conhecem. Para encontrar a folha
de dicas para este livro, vá para www.dummies.com e digite Algorithms For Dummies, 2nd
Edition Cheat Sheet na caixa de pesquisa. A folha de dicas contém informações realmente
interessantes, como encontrar os algoritmos que você normalmente precisa para executar
tarefas específicas.

» Atualizações: Às vezes, mudanças acontecem. Por exemplo, talvez não tenhamos visto
uma mudança futura quando olhamos para nossa bola de cristal durante a redação deste
livro. No passado, essa possibilidade significava simplesmente que o livro se tornava
desatualizado e menos útil, mas agora você pode encontrar atualizações para o livro, se
houver, acessando www.dummies.com e digitando Algoritmos para Leigos, 2ª Edição na
caixa de pesquisa.

Além dessas atualizações, confira as postagens do blog com respostas às


perguntas dos leitores e demonstrações de técnicas úteis relacionadas a livros em http://
blog.johnmuellerbooks.com/.

4 Algoritmos para Leigos


Machine Translated by Google

» Arquivos complementares: Ei! Quem realmente quer digitar todo o código do livro e
reconstruir todos esses gráficos manualmente? A maioria dos leitores prefere gastar
seu tempo realmente trabalhando com Python, executando tarefas usando algoritmos
e vendo as coisas interessantes que eles podem fazer, em vez de digitar. Felizmente
para você, os exemplos usados no livro estão disponíveis para download, então tudo
que você precisa fazer é ler o livro para aprender técnicas de uso de algoritmos. Você
pode encontrar esses arquivos pesquisando Algorithms For Dummies, 2nd Edition em
www.dummies.com e rolando para baixo no lado esquerdo da página que se abre. O
código fonte também está em http://www.johnmuellerbooks.com/source-code/, e https://
github.com/lmassaron/algo4d_2ed.

Para onde ir a partir daqui


É hora de começar sua aventura de aprendizado de algoritmos! Se você é completamente novo em
algoritmos, você deve começar com o Capítulo 1 e progredir no livro em um ritmo que permita que
você absorva o máximo possível do material. Certifique-se de ler sobre Python, porque o livro usa
essa linguagem conforme necessário para os exemplos.

Se você é um novato que está com pressa absoluta para começar a trabalhar com algoritmos o
mais rápido possível, você pode pular para o Capítulo 3 com o entendimento de que poderá achar
alguns tópicos um pouco confusos mais tarde.

Os leitores que têm alguma experiência com Python e têm as versões de linguagem apropriadas
instaladas podem economizar tempo de leitura indo diretamente para o Capítulo 5. Você sempre
pode voltar aos capítulos anteriores conforme necessário quando tiver dúvidas. No entanto, você
precisa entender como cada técnica funciona antes de passar para a próxima. Cada técnica,
exemplo de codificação e procedimento tem lições importantes para você, e você pode perder
conteúdo vital se começar a pular muitas informações.

Introdução 5
Machine Translated by Google
Machine Translated by Google

1 Introdução
com algoritmos
Machine Translated by Google

NESTA PARTE . . .

Definindo algoritmos e seu design

Como usar o Google Colab para trabalhar com algoritmos

Executando manipulações de dados essenciais

Construindo uma classe de manipulação de matrizes


Machine Translated by Google

NESTE CAPÍTULO

» Definindo o que se entende por algoritmo

» Depender de computadores para usar


algoritmos para fornecer soluções

» Determinar como os problemas diferem das


soluções

» Realizar manipulação de dados para que


você possa encontrar uma solução

Capítulo 1

Apresentando algoritmos
Seevocê estiver
comece suanaaventura
maioria das
compessoas, provavelmente
algoritmos, ficarádos
porque a maioria confuso
textosaonunca
abrir este livro
lhe diz
o que é um algoritmo, muito menos por que você gostaria de usar um. Ouvir sobre
algoritmos é como estar na escola novamente com o professor falando; você está
adormecendo por falta de interesse porque os algoritmos não parecem particularmente
úteis para entender no momento.

A primeira seção deste capítulo é dedicada a ajudá-lo a entender precisamente o que


o termo algoritmo significa e por que você se beneficia de saber como usar algoritmos.
Longe de serem arcanos, os algoritmos são realmente usados em todo lugar, e você
provavelmente usou ou foi ajudado por eles por anos sem realmente saber. Então,
eles são conhecimento furtivo! Na verdade, os algoritmos estão se tornando a espinha
dorsal que sustenta e regula o que é importante em uma sociedade cada vez mais
complexa e tecnológica como a nossa.

A segunda seção deste capítulo discute como você usa computadores para criar
soluções para problemas usando algoritmos, como distinguir entre problemas e
soluções e o que você precisa fazer para manipular dados para descobrir uma solução.
O objetivo é ajudá-lo a diferenciar entre algoritmos e outras tarefas que as pessoas
confundem com algoritmos. Resumindo, você descobre por que realmente quer saber
sobre algoritmos e como aplicá-los aos dados.

CAPÍTULO 1 Apresentando Algoritmos 9


Machine Translated by Google

A terceira seção do capítulo discute os algoritmos de uma maneira do mundo real, ou seja,
visualizando as terminologias usadas para entender algoritmos e apresentar algoritmos de
uma maneira que mostre que o mundo real geralmente não é perfeito.
Compreender como descrever um algoritmo de maneira realista também ajuda a moderar as
expectativas para refletir as realidades do que um algoritmo pode realmente fazer.

A seção final do capítulo discute os dados. Os algoritmos com os quais você trabalha neste
livro exigem entrada de dados em um formulário específico, o que às vezes significa alterar
os dados para corresponder aos requisitos do algoritmo. A manipulação de dados não altera
o conteúdo dos dados. Em vez disso, ele altera a apresentação e a forma dos dados para
que um algoritmo possa ajudá-lo a ver novos padrões que não eram aparentes antes (mas
estavam realmente presentes nos dados o tempo todo).

Descrevendo Algoritmos
Embora as pessoas tenham resolvido algoritmos manualmente por milhares de anos, fazer
isso pode consumir muito tempo e exigir muitos cálculos numéricos, dependendo da
complexidade do problema que você deseja resolver. Algoritmos têm tudo a ver com encontrar
soluções, e quanto mais rápido e fácil, melhor. Existe uma enorme lacuna entre algoritmos
matemáticos historicamente criados por gênios de seu tempo, como Euclides (https://
www.britannica.com/biography/Euclid-Greek mathematician), Sir Isaac Newton (https://
www.britannica.com/biography/
Isaac-Newton), ou Carl Friedrich Gauss (https://www.britannica.com/biography/
Carl-Friedrich-Gauss), e algoritmos modernos criados em universidades e laboratórios
privados de pesquisa e desenvolvimento. A principal razão para esta lacuna é o uso de
computadores. Usar computadores para resolver problemas empregando o algoritmo
apropriado acelera significativamente a tarefa. Você pode notar que mais soluções de
problemas aparecem rapidamente hoje, em parte porque a potência do computador é
barata e está aumentando constantemente.

Ao trabalhar com algoritmos, você considera as entradas, saídas desejadas e o processo


(uma sequência de ações) usado para obter uma saída desejada de uma determinada entrada.
No entanto, você pode errar a terminologia e visualizar os algoritmos de maneira errada
porque não considerou realmente como eles funcionam em um cenário do mundo real.

As fontes de informação sobre algoritmos geralmente os apresentam de uma maneira que se


prova confusa porque são muito sofisticados ou até mesmo totalmente incorretos. Embora
você possa encontrar outras definições, este livro usa as seguintes definições para termos
que as pessoas costumam confundir com algoritmos (mas não são):

10 PARTE 1 Introdução aos algoritmos


Machine Translated by Google

» Equação: Números e símbolos que, quando tomados em conjunto, equivalem a um valor


específico. Uma equação sempre contém um sinal de igual para que você saiba que os
números e símbolos representam o valor específico do outro lado do sinal de igual. As
equações geralmente contêm informações de variáveis apresentadas como um símbolo, mas
não precisam usar variáveis.

» Fórmula: Uma combinação de números e símbolos usados para expressar


informações ou ideias. As fórmulas normalmente apresentam conceitos
matemáticos ou lógicos, como definir o Máximo Divisor Comum (GCD) de
dois inteiros (o vídeo em https://www.khanacademy.org/math/cc-sixth-grade-math/
cc-6th-factors-and-multiples/cc-6th-gcf/v/greatest-common divisor diz como isso
funciona). Geralmente, eles mostram a relação entre duas ou mais variáveis.

» Algoritmo: Uma sequência de etapas utilizadas para resolver um problema. A sequência


apresenta um método único de abordar um problema, fornecendo uma solução
específica. Um algoritmo não precisa representar conceitos matemáticos ou lógicos,
embora as apresentações neste livro geralmente se enquadrem nessas categorias porque
as pessoas costumam usar algoritmos dessa maneira. Para que um processo represente
um algoritmo, ele deve ser:

• Finito: O algoritmo deve eventualmente resolver o problema. Este livro discute


problemas com uma solução conhecida para que você possa avaliar se um
algoritmo resolve o problema corretamente.

• Bem definida: A série de etapas deve ser precisa e apresentar etapas compreensíveis.
Especialmente porque os computadores estão envolvidos no uso de algoritmos, o
computador deve ser capaz de entender as etapas para criar um algoritmo utilizável.

• Eficaz: Um algoritmo deve resolver todos os casos do problema para o qual


alguém definiu. Um algoritmo deve sempre resolver o problema que tem que resolver.
Mesmo que você deva antecipar algumas falhas, a incidência de falhas é rara e ocorre
apenas em situações aceitáveis para o uso do algoritmo pretendido.

Com essas definições em mente, as seções a seguir ajudam a esclarecer a natureza


precisa dos algoritmos. O objetivo não é fornecer uma definição precisa para algoritmos,
mas sim ajudá-lo a entender como os algoritmos se encaixam no grande esquema das
coisas, para que você possa desenvolver sua própria compreensão do que são algoritmos
e por que eles são tão importantes.

CAPÍTULO 1 Apresentando Algoritmos 11


Machine Translated by Google

A maneira certa de fazer torradas:


Definindo usos do algoritmo
Um algoritmo sempre apresenta uma série de etapas e não necessariamente executa essas
etapas para resolver uma fórmula matemática. O escopo dos algoritmos é incrivelmente grande.
Você pode encontrar algoritmos que resolvem problemas em ciência, medicina, finanças,
produção e fornecimento industrial e comunicação. Os algoritmos fornecem suporte para todas
as partes da vida diária de uma pessoa. Sempre que uma sequência de ações para alcançar
algo em nossa vida é finita, bem definida e eficaz, você pode vê-la como um algoritmo. Por
exemplo, você pode transformar até mesmo algo tão trivial e simples como fazer torradas em
um algoritmo. Na verdade, o procedimento de fazer torradas geralmente aparece nas aulas de
ciência da computação, conforme discutido em http://brianaspinall.com/now-thats how-you-
make-toast-using-computer-algorithms/.

Infelizmente, o algoritmo no site é falho. O instrutor nunca remove o pão da embalagem e


nunca conecta a torradeira, então o resultado é um pão simples danificado ainda em sua
embalagem, enfiado em uma torradeira não funcional (veja a discussão em http://
blog.johnmuellerbooks.com/2013/ 03/04/procedimentos na redação técnica/ para detalhes).
Mesmo assim, a ideia é a correta, mas requer alguns pequenos, mas essenciais, ajustes para
tornar o algoritmo finito e eficaz.

Um dos usos mais comuns de algoritmos é como meio de resolver fórmulas. Por exemplo, ao
trabalhar com o GCD de dois valores inteiros, você pode executar a tarefa manualmente
listando cada um dos fatores para os dois inteiros e, em seguida, selecionando o maior fator
comum a ambos. Por exemplo, GCD (20, 25) é 5 porque 5 é o maior número que se divide
igualmente em 20 e 25. No entanto, processar cada GCD manualmente é demorado e
propenso a erros, então o matemático grego Euclides criou um algoritmo melhor para realizar
a tarefa. Você pode ver o método euclidiano demonstrado em https://www.khanacademy.org/
computing/
ciência da computação/criptografia/modaritmética/a/ algoritmo euclidiano .

No entanto, uma única fórmula, que é uma apresentação de símbolos e números usados para
expressar informações ou ideias, pode ter várias soluções, cada uma delas sendo um
algoritmo. No caso do GCD, outro algoritmo comum é o criado por Derrick Henry Lehmer
(https://www.imsc.res.in/~kapil/crypto/notes/
node11.html). Como você pode resolver qualquer fórmula de várias maneiras, as pessoas
gastam muito tempo comparando algoritmos para determinar qual deles funciona melhor em
uma determinada situação. (Veja uma comparação de Euclides com Lehmer em http://citeseerx.
ist.psu.edu/viewdoc/download?doi=10.1.1.31.693&rep=rep1&type=pdf.)

Como nossa sociedade e a tecnologia que a acompanha estão mudando rapidamente,


precisamos de algoritmos que possam acompanhar o ritmo. Realizações científicas como

12 PARTE 1 Introdução aos algoritmos


Machine Translated by Google

sequenciar o genoma humano foi possível em nossa época porque os cientistas encontraram
algoritmos que rodam rápido o suficiente para completar a tarefa. Medir qual algoritmo é melhor
em uma determinada situação, ou em uma situação de uso médio, é algo realmente sério e é
um tópico de discussão entre os cientistas da computação.

Quando se trata de ciência da computação, o mesmo algoritmo pode ter várias apresentações;
por que fazer isso de uma maneira quando você pode inventar vários métodos apenas por
diversão? Por exemplo, você pode apresentar o algoritmo euclidiano nas formas recursiva e
iterativa, conforme explicado em http://cs.stackexchange.com/questions/1447/
o que é mais eficiente para o gcd. Em suma, os algoritmos apresentam um método de resolução
de fórmulas, mas seria um erro dizer que existe apenas um algoritmo aceitável para qualquer
fórmula ou que existe apenas uma apresentação aceitável de um algoritmo. O uso de algoritmos
para resolver problemas de vários tipos tem uma longa história – não é algo que acabou de
acontecer.

Mesmo se você limitar seu olhar à ciência da computação, ciência de dados, inteligência
artificial e outras áreas técnicas, você encontrará muitos tipos de algoritmos – muitos para um
único livro. Por exemplo, The Art of Computer Programming, de Donald E. Knuth (Addison-
Wesley), abrange 3.168 páginas em quatro volumes (ver http://
www.amazon.com/exec/óbidos/ASIN/0321751043/datacservip0f-20/) e ainda não consegue cobrir
o tema (o autor pretendia escrever mais volumes).
No entanto, aqui estão alguns usos interessantes para você considerar:

» Pesquisando: Localizar informações ou verificar se as informações que você vê são as


informações que você deseja é uma tarefa essencial. Sem essa capacidade, você não
conseguiria realizar muitas tarefas online, como encontrar o site na Internet vendendo a
cafeteira perfeita para o seu escritório. Esses algoritmos mudam constantemente, como
mostra a recente mudança do Google em seu algoritmo (https://www.youaretech.
com/blog/2021/1/26/webpage-experience-a-major-google-algorithm update-
in-2021nbsp).

» Classificação: Determinar qual ordem usar para apresentar as informações é


importante porque a maioria das pessoas hoje sofre de sobrecarga de informações,
e colocar as informações em ordem é uma forma de reduzir a avalanche de dados.
Imagine ir à Amazon, descobrir que mais de mil cafeteiras estão à venda lá e ainda
não conseguir classificá-las por ordem de preço ou pela avaliação mais positiva.
Além disso, muitos algoritmos complexos exigem dados na ordem correta para
funcionar de forma confiável, portanto, a ordenação é um requisito importante para
resolver mais problemas.

» Transformação: A conversão de um tipo de dados em outro tipo de dados é


fundamental para entender e usar os dados de forma eficaz. Por exemplo, você
pode entender pesos imperiais muito bem, mas todas as suas fontes usam o
sistema métrico. A conversão entre os dois sistemas ajuda a entender os dados.

CAPÍTULO 1 Apresentando Algoritmos 13


Machine Translated by Google

» Agendamento: Tornar o uso dos recursos justo para todos os envolvidos é outra maneira pela
qual os algoritmos tornam sua presença conhecida em grande escala. Por exemplo, as luzes
de cronometragem nos cruzamentos não são mais dispositivos simples que contam os
segundos entre as mudanças de luz. Os dispositivos modernos consideram todos os tipos de
problemas, como a hora do dia, as condições climáticas e o fluxo de tráfego.

» Análise de gráficos: Decidir o caminho mais curto entre dois pontos encontra todos os
tipos de usos. Por exemplo, em um problema de roteamento, seu GPS não poderia
funcionar sem esse algoritmo específico porque ele nunca poderia direcioná-lo pelas
ruas da cidade usando a rota mais curta do ponto A ao ponto B. E mesmo assim, seu
GPS pode direcioná-lo para dirigir em um lago (https://theweek.com/articles/464674/8-
motoristas-que-cegamente-seguiram-gps-para-desastre).

» Criptografia: Manter os dados seguros é uma batalha contínua com os hackers que atacam
constantemente as fontes de dados. Os algoritmos tornam possível analisar dados, colocá-los
em alguma outra forma e depois retorná-los à sua forma original mais tarde.

» Geração de números pseudoaleatórios: Imagine jogar jogos que nunca variaram. Você
começa no mesmo lugar; execute os mesmos passos, da mesma maneira, toda vez
que você jogar. Sem a capacidade de gerar números aparentemente aleatórios, muitas
tarefas do computador se tornam impossíveis.

Esta lista apresenta uma visão geral incrivelmente curta. As pessoas usam algoritmos
para muitas tarefas diferentes e de muitas maneiras diferentes, e constantemente criam
novos algoritmos para resolver problemas existentes e novos. A questão mais importante
a ser considerada ao trabalhar com algoritmos é que, dada uma entrada específica, você
deve esperar uma saída específica. Questões secundárias incluem quantos recursos o
algoritmo requer para realizar sua tarefa e quanto tempo leva para concluir a tarefa.
Dependendo do tipo de problema e do tipo de algoritmo usado, você também pode
precisar considerar questões de precisão e consistência.

Encontrando algoritmos em todos os lugares


A seção anterior menciona o algoritmo de brinde por um motivo específico. Por alguma
razão, fazer torradas é provavelmente o algoritmo mais popular já criado. Muitas crianças
do ensino fundamental escrevem seu equivalente do algoritmo do brinde muito antes de
conseguirem resolver a matemática mais básica. Não é difícil imaginar quantas variações
do algoritmo toast existem e qual é a saída precisa de cada uma delas. Os resultados
provavelmente variam de acordo com o indivíduo e o nível de criatividade empregado.
Existem também sites dedicados a informar as crianças sobre algoritmos, como o de https://
www.idtech.com/blog/algorithms-for-kids. Em suma, os algoritmos aparecem em grande variedade e
muitas vezes em lugares inesperados.

14 PARTE 1 Introdução aos algoritmos


Machine Translated by Google

Cada tarefa que você executa em um computador envolve algoritmos. Alguns algoritmos aparecem
como parte do hardware do computador. O próprio ato de inicializar um computador envolve o uso de
um algoritmo. Você também encontra algoritmos em sistemas operacionais, aplicativos e todos os
outros softwares. Até mesmo os usuários confiam em algoritmos.
Os scripts ajudam os usuários a realizar tarefas de uma maneira específica, mas essas mesmas
etapas podem aparecer como instruções escritas ou como parte de uma declaração de política
organizacional.

As rotinas diárias geralmente se transformam em algoritmos. Pense em como você passa o seu dia.
Se você é como a maioria das pessoas, executa essencialmente as mesmas tarefas todos os dias na
mesma ordem, tornando seu dia um algoritmo que resolve o problema de como viver com sucesso
gastando a menor quantidade de energia possível. Afinal, é isso que uma rotina faz; nos torna eficientes.

Ao longo deste livro, você verá os mesmos três elementos para cada algoritmo:

1. Descreva o problema.

2. Crie uma série de passos para resolver o problema (bem definidos).

3. Execute as etapas para obter o resultado desejado (finito e eficaz).

Usando computadores para resolver problemas


O termo computador soa bastante técnico e possivelmente um pouco esmagador para algumas
pessoas, mas as pessoas hoje estão até o pescoço (possivelmente ainda mais profundas) em computadores.
Você usa pelo menos um computador, seu smartphone, na maioria das vezes. Se você tiver algum
tipo de dispositivo especial, como um marcapasso, ele também inclui um computador. Um carro pode
conter até 150 computadores na forma de microprocessadores embutidos que regulam o consumo de
combustível, combustão do motor, transmissão, direção e estabilidade (consulte https://spectrum.ieee.org/
software-eating-car para obter detalhes), fornecem sistemas avançados de assistência ao motorista
(ADAS) e mais linhas de código do que um caça a jato. Um computador existe para resolver problemas
rapidamente e com menos esforço do que resolvê-los manualmente. Conseqüentemente, não deve
surpreendê-lo que este livro use ainda mais computadores para ajudá-lo a entender melhor os
algoritmos.

Os computadores variam de várias maneiras. O computador em um relógio é bem pequeno; aquele


em uma área de trabalho bastante grande. Os supercomputadores são imensos e contêm muitos
computadores menores, todos encarregados de trabalhar juntos para resolver problemas complexos,
como prever o clima de amanhã. Os algoritmos mais complexos dependem de funcionalidades
especiais do computador para obter soluções para os problemas que as pessoas os projetam para
resolver. Sim, você pode usar menos recursos para executar a tarefa, mas a desvantagem é esperar
muito mais por uma resposta ou obter uma resposta que não seja suficiente

CAPÍTULO 1 Apresentando Algoritmos 15


Machine Translated by Google

precisão para fornecer uma solução útil. Em alguns casos, você espera tanto tempo que a
resposta não é mais importante. Com a necessidade de velocidade e precisão em mente, as
seções a seguir discutem alguns recursos especiais do computador que podem afetar os
algoritmos.

Aproveitando ao máximo as CPUs e


GPUs modernas
Processadores de uso geral, CPUs, começaram como um meio de resolver problemas usando
algoritmos. No entanto, sua natureza de uso geral também significa que uma CPU pode
executar muitas outras tarefas, como mover dados ou interagir com dispositivos externos. Um
processador de uso geral faz muitas coisas bem, o que significa que ele pode executar as
etapas necessárias para concluir um algoritmo, mas não necessariamente rápido. Os
proprietários dos primeiros processadores de uso geral poderiam adicionar coprocessadores
matemáticos (chips especiais específicos para matemática) aos seus sistemas para obter
uma vantagem de velocidade (consulte https://www.computerhope.com/jargon/m/mathcopr.htm para detalhes).
Hoje, os processadores de uso geral têm o coprocessador matemático embutido neles,
portanto, quando você obtém um processador Intel i9, na verdade obtém vários processadores
em um único pacote.

Uma GPU é um processador de propósito especial com recursos que se prestam a uma
execução mais rápida de algoritmos. Para a maioria das pessoas, as GPUs devem coletar
dados, manipulá-los de uma maneira especial e exibir uma imagem bonita na tela. No entanto,
qualquer hardware de computador pode servir a mais de um propósito. Acontece que as
GPUs são particularmente hábeis em realizar transformações de dados, que é uma tarefa
fundamental para resolver algoritmos em muitos casos. Não deveria surpreendê-lo descobrir
que as pessoas que criam algoritmos gastam muito tempo pensando fora da caixa, o que
significa que muitas vezes vêem métodos de resolução de problemas em abordagens não
tradicionais.

O ponto é que CPUs e GPUs formam os chips mais usados para realizar tarefas relacionadas
a algoritmos. O primeiro executa tarefas de propósito geral muito bem, e o segundo é
especializado em fornecer suporte para tarefas de uso intensivo de matemática, especialmente
aquelas que envolvem transformações de dados. O uso de vários núcleos possibilita o
processamento paralelo (executando mais de uma etapa algorítmica por vez). Adicionar
vários chips aumenta o número de núcleos disponíveis. Ter mais núcleos aumenta a
velocidade, mas vários fatores mantêm o ganho de velocidade no mínimo.
Usar dois chips i9 não produzirá o dobro da velocidade de apenas um chip i9.

16 PARTE 1 Introdução aos algoritmos


Machine Translated by Google

Trabalhando com chips especiais


Um coprocessador matemático e uma GPU são dois exemplos de chips comuns para fins
especiais, pois você não os vê usados para executar tarefas como inicializar o sistema.
No entanto, os algoritmos geralmente exigem o uso de chips incomuns para fins especiais para
resolver problemas. Este não é um livro de hardware, mas vale a pena gastar tempo mostrando
todos os tipos de chips interessantes, como os neurônios artificiais nos quais o UCSD está
trabalhando (https://www.marktechpost.com/2021/06/03/ucsd -pesquisadores-desenvolvem um-
dispositivo-neurônio-artificial-que-poderia-reduzir-uso-de-energia-e-tamanho -de-hardware-de-
rede-neural/). Imagine realizar processamento algorítmico usando dispositivos que simulam o
cérebro humano. Isso criaria um ambiente interessante para a execução de tarefas que de
outra forma não seriam possíveis hoje.

As redes neurais, tecnologia usada para simular o pensamento humano e possibilitar


técnicas de aprendizado profundo para cenários de aprendizado de máquina, agora se
beneficiam do uso de chips especializados (o artigo em https://analytics
indiamag.com/top-10-gpus-for-deep-learning-in-2021/ descreve dez deles). Esses tipos de chips
não apenas executam processamento algorítmico extremamente rápido, mas também aprendem
à medida que executam as tarefas, tornando-os ainda mais rápidos a cada iteração.
Computadores de aprendizagem eventualmente alimentarão robôs que podem se mover (de
certa forma) por conta própria, semelhante aos robôs vistos no filme I Robot. Alguns robôs até
exibem expressões faciais agora (https://www.sciencedaily.com/
releases/2021/05/210527145244.htm).

Não importa como eles funcionem, processadores especializados eventualmente irão


alimentar todos os tipos de algoritmos que terão consequências no mundo real. Você já pode
encontrar muitos desses aplicativos do mundo real de uma forma relativamente simples. Por
exemplo, imagine as tarefas que um robô de fabricação de pizza teria que resolver – as
variáveis que ele teria que considerar em tempo real. Esse tipo de robô já existe (este é
apenas um exemplo dos muitos robôs industriais usados para produzir bens materiais
empregando algoritmos), e você pode apostar que ele depende de algoritmos para descrever
o que fazer, bem como de chips especiais para garantir que as tarefas sejam feitas rapidamente (https://www
therobotreport.com/picnic-pizza-making-robot-is-now-available/).

Eventualmente, pode até ser possível usar a mente humana como um processador e enviar
as informações por meio de uma interface especial. A certa altura, as pessoas experimentaram
colocar processadores diretamente no cérebro, mas a mais recente inovação depende do
uso de veias para fazer a conexão (https://www.independent.
co.uk/life-style/gadgets-and-tech/brain-computer-interface-vein als-stent-neuralink-
b1556167.html). Imagine um sistema no qual humanos podem resolver problemas usando
algoritmos na velocidade dos computadores, mas com o potencial criativo “e se” dos
humanos.

CAPÍTULO 1 Apresentando Algoritmos 17


Machine Translated by Google

Redes: Compartilhar é mais do que cuidar


A menos que você tenha fundos ilimitados, usar alguns algoritmos de forma eficaz pode não ser
possível, mesmo com chips especializados. Nesse caso, você pode conectar computadores em
rede. Usando um software especial, um computador, um host, pode usar os processadores de
todos os computadores clientes que executam um agente (um tipo de aplicativo em segundo plano
na memória que torna o processador disponível). Usando essa abordagem, você pode resolver
problemas incrivelmente complexos transferindo partes do problema para vários computadores
clientes. À medida que cada computador da rede resolve sua parte do problema, ele envia os
resultados de volta ao host, que junta as peças para criar uma resposta consolidada, uma técnica
chamada computação em cluster.

Para que você não pense que isso é coisa de ficção científica, as pessoas estão usando técnicas
de computação em cluster (https://www.geeksforgeeks.org/an-overview-of-cluster computing/) em
todos os tipos de maneiras interessantes. Por exemplo, o artigo em https://
turingpi.com/12-amazing-raspberry-pi-cluster-use-cases/ detalha como você pode construir seu
próprio supercomputador (entre outras tarefas) combinando vários Raspberry Pi (https://
www.raspberrypi.org/) placas em um único cluster.

A computação distribuída, outra versão da computação em cluster (mas com uma organização
mais flexível), também é popular. Na verdade, você pode encontrar uma lista de projetos de
computação distribuída em https://en.wikipedia.org/wiki/List_of_distributed_computing_
projetos. A lista de projetos inclui alguns empreendimentos importantes, como Search for
Extraterrestrial Intelligence (SETI). Você também pode doar o poder de processamento extra do
seu computador para trabalhar na cura do câncer. A lista de projetos em potencial é incrível. A
maioria desses projetos é hospedada pela Berkeley Open Infrastructure for Network Computing
(https://boinc.berkeley.edu/), mas você pode encontrar outros
patrocinadores.

Aproveitando os dados disponíveis


Parte da solução de problemas usando um algoritmo não tem nada a ver com poder de
processamento, pensamento criativo fora da caixa ou qualquer coisa de natureza física. Para criar
uma solução para a maioria dos problemas, você também precisa de dados nos quais basear uma conclusão.
Por exemplo, no algoritmo de fazer torradas, você precisa saber sobre a disponibilidade de pão,
torradeira, eletricidade para alimentar a torradeira e assim por diante antes de poder resolver o
problema de fazer torradas. Os dados se tornam importantes porque você não pode terminar o
algoritmo quando falta um elemento da solução necessária. Claro, você também pode precisar de
dados de entrada adicionais. Por exemplo, a pessoa que quer a torrada pode não gostar de centeio.
Se este for o caso e tudo o que você tem é pão de centeio para usar, a presença de pão ainda não
resultará em um resultado bem-sucedido.

Os dados vêm de todos os tipos de fontes e em todos os tipos de formas. Você pode transmitir
dados de uma fonte como um monitor em tempo real, acessar uma fonte de dados pública, confiar em

18 PARTE 1 Introdução aos algoritmos


Machine Translated by Google

dados privados em um banco de dados, extrair os dados de sites ou obtê-los de inúmeras outras
maneiras numerosas demais para serem mencionadas aqui. Os dados podem ser estáticos
(imutáveis) ou dinâmicos (mudando constantemente). Você pode descobrir que os dados estão
completos ou faltam elementos. Os dados podem não aparecer na forma correta (como quando
você obtém unidades imperiais e exige unidades métricas ao resolver um problema de peso). Os
dados podem aparecer em um formato tabular quando você precisar deles em algum outro
formato. Ele pode residir de forma não estruturada (por exemplo, em um banco de dados NoSQL
ou apenas em vários arquivos de dados diferentes) quando você precisar da formatação de um
banco de dados relacional. Em suma, você precisa saber todo tipo de coisas sobre os dados
usados com seu algoritmo para resolver problemas com ele.

Como os dados vêm em tantas formas e você precisa trabalhar com eles de muitas maneiras,
este livro dá muita atenção aos dados. A partir do Capítulo 6, você descobrirá como a estrutura de
dados entra em ação. Passando para o Capítulo 7, você começa a ver como pesquisar dados
para encontrar o que precisa. Os capítulos 12 a 14 ajudam você a trabalhar com big data. No
entanto, você pode encontrar algum tipo de informação específica de dados em quase todos os
capítulos do livro, porque sem dados, um algoritmo não pode resolver nenhum problema.

Distinção entre problemas e


soluções
Este livro discute duas partes da visão algorítmica do mundo real. Por um lado, você tem
problemas, que são problemas que você precisa resolver. Um problema pode descrever a saída
desejada de um algoritmo ou pode descrever um obstáculo que você deve superar para obter a
saída desejada. As soluções são os métodos, ou etapas, usadas para resolver os problemas.
Uma solução pode estar relacionada a apenas uma etapa ou a várias etapas dentro do algoritmo.
Na verdade, a saída de um algoritmo, a resposta ao último passo, é uma solução. As seções a
seguir ajudam você a entender alguns dos aspectos importantes de problemas e soluções.

Ser correto e eficiente


Usar algoritmos é obter uma resposta aceitável. A razão pela qual você procura uma resposta
aceitável é que alguns algoritmos geram mais de uma resposta em resposta a dados de entrada
difusos. A vida muitas vezes torna impossível obter respostas precisas. Claro, obter uma resposta
precisa é sempre o objetivo, mas muitas vezes você acaba com uma resposta aceitável.

Obter a resposta mais precisa possível pode levar muito tempo. Quando você busca uma resposta
precisa que demora muito para ser obtida, a informação se torna inútil

CAPÍTULO 1 Apresentando Algoritmos 19


Machine Translated by Google

e você perdeu seu tempo. Escolher entre dois algoritmos que abordam o mesmo problema pode
se resumir a uma escolha entre velocidade e precisão. Um algoritmo rápido pode não gerar uma
resposta precisa, mas a resposta ainda pode funcionar bem o suficiente para fornecer uma saída
útil.

Respostas erradas podem ser um problema. Criar muitas respostas erradas rapidamente é tão
ruim quanto criar muitas respostas corretas com precisão lentamente. Parte do foco deste livro é
ajudá-lo a encontrar o meio-termo entre muito rápido e muito lento, e entre impreciso e muito
preciso. Mesmo que seu professor de matemática tenha enfatizado a necessidade de fornecer a
resposta correta da maneira expressa pelo livro que você usou na época, a matemática do
mundo real geralmente envolve a ponderação de escolhas e a tomada de decisões intermediárias
que afetam você de maneiras que você pode não pensar ser possível.

Descobrindo que não existe almoço grátis


Você pode ter ouvido o mito comum de que você pode ter tudo em termos de saída do
computador sem se esforçar muito para obter a solução. Infelizmente, não existe solução
absoluta para nenhum problema, e as melhores respostas costumam ser bastante caras. Ao
trabalhar com algoritmos, você descobre rapidamente a necessidade de fornecer recursos
adicionais quando precisa de respostas precisas rapidamente. O tamanho e a complexidade das
fontes de dados que você usa também afetam muito a resolução da solução. À medida que o
tamanho e a complexidade aumentam, você descobre que a necessidade de adicionar recursos também aumenta.

Adaptando a estratégia ao problema


A Parte 5 deste livro analisa as estratégias que você pode usar para diminuir o custo de trabalhar
com algoritmos. Os melhores matemáticos usam truques para obter mais resultados com menos
computação. Por exemplo, você pode criar um algoritmo definitivo para resolver um problema ou
usar vários algoritmos mais simples para resolver o mesmo problema, mas usando vários
processadores. A série de algoritmos simples geralmente funcionará mais rápido e melhor do
que o algoritmo único e complexo, mesmo que essa abordagem pareça contra-intuitiva.

Descrevendo algoritmos em uma língua franca


Os algoritmos fornecem uma base para a comunicação entre as pessoas, mesmo quando esses
indivíduos têm perspectivas diferentes e falam idiomas diferentes. Por exemplo, o Teorema de
Bayes (a probabilidade de um evento ocorrer dadas certas premissas; veja https://
betterexplained.com/articles/an-intuitive-and short-explanation-of-bayes-theorem/ para uma
explicação rápida deste incrível teorema)

P(B|E) = P(E|B)*P(B)/P(E)

20 PARTE 1 Introdução aos algoritmos


Machine Translated by Google

aparece da mesma forma se você fala inglês, espanhol, chinês, alemão, francês ou qualquer
outro idioma. Independentemente do idioma que você fala, o algoritmo parece o mesmo e age
da mesma forma com os mesmos dados. Os algoritmos ajudam a atravessar todos os tipos de
divisões que servem para separar os humanos uns dos outros, expressando ideias de uma
forma que qualquer um pode provar. Ao ler este livro, você descobrirá a beleza e a magia que
os algoritmos podem proporcionar ao comunicar até mesmo pensamentos sutis aos outros.

Além das notações matemáticas universais, os algoritmos aproveitam as linguagens de


programação como meio de explicar e comunicar as fórmulas que resolvem. Você pode
encontrar todos os tipos de algoritmos em C, C++, Java, Fortran, Python (como neste livro) e
outras linguagens. O pseudocódigo é uma maneira de descrever as operações do computador
usando palavras comuns em inglês. Alguns escritores contam com pseudocódigo para superar
o fato de que um algoritmo pode ser proposto em uma linguagem de programação que você não
conhece. Além disso, o pseudocódigo pode ser mais conciso do que uma linguagem de
programação porque você pode usar ideias intuitivas que a linguagem de programação pode
não capturar bem.

Enfrentando problemas que são como paredes de


tijolos, só que mais difíceis
Uma consideração importante ao trabalhar com algoritmos é que você pode usá-los para resolver
problemas de qualquer complexidade. O algoritmo não pensa, não tem emoção ou se importa
como você o usa (ou mesmo abusa dele). Você pode usar algoritmos de qualquer maneira
necessária para resolver um problema. Por exemplo, o mesmo grupo de algoritmos usado para
realizar o reconhecimento facial como alternativa às senhas de computador (por motivos de
segurança) pode encontrar terroristas à espreita em um aeroporto ou reconhecer uma criança
perdida vagando pelas ruas. O mesmo algoritmo tem usos diferentes; como usá-lo depende dos
interesses do usuário. Parte da razão pela qual você deseja ler este livro com atenção é para
ajudá-lo a resolver aqueles problemas difíceis que podem exigir apenas um algoritmo simples para resolver.

Estruturando dados para obter uma solução


Os humanos pensam sobre os dados de maneiras não específicas e aplicam várias regras aos
mesmos dados para entendê-los de maneiras que os computadores nunca podem. A visão de
dados de um computador é estruturada, simples, intransigente e definitivamente não é criativa.
Quando humanos preparam dados para serem usados por um computador, os dados geralmente
interagem com os algoritmos de maneiras inesperadas e produzem saídas indesejáveis. O
problema é aquele em que o ser humano não consegue apreciar a visão limitada dos dados que
um computador possui. As seções a seguir descrevem dois aspectos dos dados que você vê
ilustrados em muitos dos capítulos a seguir.

CAPÍTULO 1 Apresentando Algoritmos 21


Machine Translated by Google

Entendendo o ponto de vista de um computador


Um computador tem uma visão simples dos dados, mas também é uma visão que os humanos
normalmente não entendem. Por um lado, tudo é um número para um computador porque os
computadores não são projetados para trabalhar com nenhum outro tipo de dado. Os humanos
veem os caracteres na tela do computador e supõem que o computador interage com os dados
dessa maneira, mas o computador não entende os dados ou suas implicações. A letra A é
simplesmente o número 65 para o computador. Na verdade, não é mesmo o número 65. O
computador vê uma série de impulsos elétricos que equivalem a um valor binário de 0100 0001.

Os computadores também não entendem todo o conceito de maiúsculas e minúsculas.


Para um humano, o a minúsculo é simplesmente outra forma do A maiúsculo, mas para um
computador são dois valores diferentes. Um a minúsculo aparece como o número 97 (um valor
binário de 0110 0001).

Se esses tipos simples de comparações de uma única letra podem causar tais problemas entre
humanos e computadores, não é difícil imaginar o que acontece quando humanos começam a
presumir demais sobre outros tipos de dados. Por exemplo, um computador não pode ouvir ou
apreciar música. No entanto, a música sai dos alto-falantes do computador. O mesmo vale para os
gráficos. Um computador vê uma série de 0s e 1s, não um gráfico contendo uma bela cena do
campo.

É importante considerar os dados da perspectiva do computador ao usar algoritmos. O computador


vê apenas 0s e 1s, nada mais. Conseqüentemente, quando você começa a trabalhar com as
necessidades do algoritmo, você deve visualizar os dados dessa maneira. Você pode realmente
achar benéfico saber que a visão de dados do computador torna algumas soluções mais fáceis de
encontrar, não mais difíceis. Você descobre mais sobre essa estranheza ao visualizar os dados à
medida que o livro avança.

Organizar os dados faz a diferença


Os computadores também têm uma ideia estrita sobre a forma e a estrutura dos dados. Quando
você começa a trabalhar com algoritmos, descobre que grande parte do trabalho envolve fazer com
que os dados apareçam em um formato que o computador possa usar ao usar o algoritmo para
encontrar uma solução para um problema. Embora um ser humano possa ver mentalmente padrões
em dados que não estão organizados com precisão, os computadores realmente precisam da
precisão para encontrar o mesmo padrão. O benefício dessa precisão é que os computadores
muitas vezes podem tornar novos padrões visíveis. Na verdade, essa é uma das principais razões
para usar algoritmos com computadores — para ajudar a localizar novos padrões e depois usar
esses padrões para realizar outras tarefas. Por exemplo, um computador pode reconhecer o padrão
de gastos de um cliente para que você possa usar as informações para gerar mais vendas automaticamente.

22 PARTE 1 Introdução aos algoritmos


Machine Translated by Google

NESTE CAPÍTULO

» Considerando como resolver um problema

» Usando uma abordagem de dividir e conquistar


para resolver problemas

» Entendendo a abordagem gananciosa


para resolver problemas

» Determinar os custos das soluções de


problemas

» Realizando medições de algoritmo

Capítulo 2

Considerando Algoritmo
Projeto
incluir dados de entrada para fornecer a base para resolver o problema e, algumas
Um algoritmo
vezes,consiste em que
restrições umaqualquer
série de etapas
soluçãousadas para resolver
deve considerar umque
antes problema,
alguémque pode
o faça.
considerar o algoritmo como eficaz. A primeira seção deste capítulo o ajuda a considerar
a solução do problema (a solução para o problema que você está tentando resolver). Ele
ajuda você a entender a necessidade de criar algoritmos que sejam flexíveis (podem lidar
com uma ampla variedade de entradas de dados) e eficazes (produzem a saída desejada).

A segunda seção deste capítulo considera como derivar uma solução. Sentir-se sobrecarregado
por um problema é comum e a maneira mais comum de resolver o problema é dividir o
problema em partes menores e gerenciáveis. Essa abordagem de dividir e conquistar para
resolver problemas originalmente se referia à guerra (veja um histórico dessa abordagem em
https://classroom.synonym.com/civilization-invented divide-conquer-strategy-12746.html). No
entanto, as pessoas usam as mesmas ideias para reduzir problemas de todos os tipos.

CAPÍTULO 2 Considerando o Projeto de Algoritmos 23


Machine Translated by Google

A terceira seção do capítulo refere-se à abordagem gananciosa para a resolução de problemas.


A ganância normalmente tem uma conotação negativa (como roubar o copo de frutas do seu
amigo do prato dele), mas não neste caso. Um algoritmo ganancioso é aquele que faz uma
escolha ótima em cada estágio da solução do problema. Ao fazer isso, espera-se obter uma
solução ótima para resolver o problema. Infelizmente, essa estratégia nem sempre funciona,
mas sempre vale a pena tentar. Muitas vezes produz uma solução boa o suficiente , tornando-
se uma boa linha de base.

Não importa qual abordagem de solução de problemas você escolha, todo algoritmo vem com
custos. Sendo bons compradores, as pessoas que dependem muito de algoritmos querem o
melhor negócio possível, o que significa realizar uma análise de custo/benefício. É claro que
obter o melhor negócio também pressupõe que uma pessoa que usa o algoritmo tenha alguma
ideia de que tipo de solução é boa o suficiente. Obter uma solução que é muito precisa (uma
que oferece muitos detalhes) ou uma que oferece muito em termos de saída geralmente é um
desperdício, portanto, parte de manter os custos sob controle é obter o que você precisa como
saída e nada mais.

Para saber o que você tem com um algoritmo, você precisa saber como medi-lo de várias
maneiras. As medições criam uma imagem de usabilidade, tamanho, uso de recursos e custo
em sua mente. Mais importante, as medições oferecem os meios de fazer comparações. Você
não pode comparar algoritmos sem medições. Até que você possa comparar os algoritmos, não
poderá escolher o melhor para uma tarefa.

Começando a resolver um problema


Antes de resolver qualquer problema, você deve entendê-lo. Fazer isso não é apenas uma
questão de dimensionar o problema. Saber que você tem certas entradas e precisa de certas
saídas é um começo, mas isso não é suficiente para criar uma solução.
Parte do processo de solução é

» Descubra como outras pessoas criaram novas soluções de problemas

» Saiba quais recursos você tem em mãos

» Determinar os tipos de soluções que funcionaram para problemas semelhantes no passado

» Considere que tipos de soluções não produziram um resultado desejável

As seções a seguir ajudam você a entender essas fases da solução de um problema.


Perceba que você não executará necessariamente essas fases em ordem e que algumas
vezes você revisita uma fase depois de obter mais informações. O processo de iniciar uma
solução de problema é iterativo; você continua até que tenha uma boa compreensão do
problema em questão.

24 PARTE 1 Introdução aos algoritmos


Machine Translated by Google

Modelando problemas do mundo real


Os problemas do mundo real diferem daqueles encontrados nos livros didáticos, principalmente porque
o mundo real não tem imaginação alguma. Ao criar um livro didático, o autor geralmente cria um
exemplo simples para ajudar o leitor a entender os princípios básicos em ação. O exemplo modela
apenas um aspecto de um problema mais complexo. Um problema do mundo real pode exigir que você
combine várias técnicas para criar uma solução completa. Por exemplo, para localizar a melhor resposta
para um problema, você pode:

1. Precisa classificar a resposta definida por um critério específico.

2. Faça algum tipo de filtragem e transformação.

3. Pesquise o resultado.

Sem essa sequência de etapas, comparar adequadamente cada uma das respostas pode ser impossível
e você acaba com um resultado abaixo do ideal. Uma série de algoritmos usados em conjunto para
criar um resultado desejado é um conjunto. Você pode ler sobre seu uso em aprendizado de máquina
em Machine Learning For Dummies, 2nd Edition, de John Paul Mueller e Luca Massaron (Wiley). O
artigo em https://
machinelearningmastery.com/tour-of-ensemble-learning-algorithms/
fornece uma visão geral rápida de como os conjuntos funcionam.

No entanto, os problemas do mundo real são ainda mais complexos do que simplesmente
observar dados estáticos ou iterar esses dados apenas uma vez. Por exemplo, qualquer coisa
que se mova, como um carro, avião ou robô, recebe entrada constante. Cada entrada
atualizada inclui informações de erro que uma solução do mundo real precisará incorporar ao
resultado para manter essas máquinas funcionando corretamente. Além de outros algoritmos,
os cálculos constantes requerem o algoritmo de derivada integral proporcional (PID) (consulte
https://www.ni.com/en-us/innovations/white-papers/06/
pid-theory-explained.html para uma explicação detalhada deste algoritmo) para controlar a
máquina usando um loop de feedback. Cada cálculo traz a solução usada para controlar a
máquina em um foco melhor, e é por isso que as máquinas geralmente passam por um estágio
de estabilização quando você as liga pela primeira vez.

Ao modelar um problema do mundo real, você também deve considerar os problemas não óbvios que
surgem. Uma solução óbvia, mesmo baseada em dados matemáticos significativos e teoria sólida,
pode não funcionar. Por exemplo, durante a Segunda Guerra Mundial, os aliados tiveram um sério
problema com perdas de bombardeiros. Portanto, os engenheiros analisaram cada buraco de bala em
cada avião que voltou. Após a análise, os engenheiros usaram sua solução para blindar mais fortemente
os aviões aliados para garantir que mais deles voltassem. Não funcionou. Entra Abraham Wald. Este
matemático sugeriu uma solução não óbvia: colocar blindagem em todos os lugares que não tinham
buracos de bala (porque as áreas com buracos de bala já são fortes o suficiente; caso contrário, o
avião não teria retornado). A solução resultante funcionou e agora é usada como base para o viés de
sobrevivência (o fato de que os sobreviventes de um incidente geralmente não mostram

CAPÍTULO 2 Considerando o Projeto de Algoritmos 25


Machine Translated by Google

o que realmente causou uma perda) ao trabalhar com algoritmos. Você pode ler mais sobre
esse fascinante pedaço de história em https://medium.com/@penguinpress/an extract-from-
how-not-to-be-wrong-by-jordan-ellenberg-664e708cfc3d.

A modelagem do mundo real também pode incluir a adição do que os cientistas normalmente
consideram características indesejáveis. Por exemplo, os cientistas geralmente consideram o
ruído indesejável porque oculta os dados subjacentes. Considere um aparelho auditivo, que
remove o ruído para permitir que alguém ouça melhor (veja a discussão em https://www.ncbi.
nlm.nih.gov/pmc/articles/PMC4111515/ para detalhes). Existem muitos métodos para remover
ruídos, alguns dos quais você pode encontrar neste livro a partir do Capítulo 9 como parte de
outras discussões de tópicos.

No entanto, por mais contra-intuitivo que possa parecer, adicionar ruído aos dados também
requer um algoritmo que forneça uma saída útil com esse ruído instalado. Por exemplo, Ken
Perlin queria se livrar da aparência de máquina dos gráficos gerados por computador em 1983
e criou um algoritmo para fazer isso. O resultado é o ruído Perlin (consulte https://
catlikecoding.com/unity/tutorials/pseudorandom noise/perlin-noise/ para detalhes). O efeito é
tão útil que Perlin ganhou um Oscar por seu trabalho (veja https://cs.nyu.edu/~perlin/doc/oscar.

html para detalhes). Um cenário do mundo real geralmente requer escolhas que podem não
ser óbvias ao trabalhar no laboratório ou durante o processo de aprendizado.

A essência desta seção é que as soluções geralmente exigem várias iterações para serem
criadas, você pode ter que gastar muito tempo refinando-as e as soluções óbvias podem não
funcionar. Ao modelar um problema do mundo real, você começa com as soluções encontradas
nos livros didáticos, mas depois deve ir além da teoria para encontrar a solução real para o
seu problema. À medida que este livro avança, você é exposto a uma ampla variedade de
algoritmos — todos os quais ajudam a encontrar soluções. O importante a ser lembrado é que
você pode precisar combinar esses exemplos de várias maneiras e descobrir métodos para
interagir com os dados para que eles possam encontrar padrões que correspondam à saída
necessária.

Encontrar soluções e contra-exemplos


A seção anterior apresenta os caprichos da descoberta de soluções do mundo real, aquelas
que consideram problemas que as soluções encontradas no laboratório não podem levar em
consideração. No entanto, apenas encontrar uma solução – mesmo uma boa – não é
suficiente, porque mesmo as boas soluções falham ocasionalmente. Fazer o papel de
advogado do diabo localizando contra-exemplos é uma parte importante para começar a
resolver um problema. O objetivo dos contra-exemplos é

» Refutar potencialmente a solução

» Forneça limites que definam melhor a solução

26 PARTE 1 Introdução aos algoritmos


Machine Translated by Google

» Considerar situações em que a hipótese usada como base para a solução


permanece não testado

» Ajudá-lo a entender os limites da solução

Um cenário comum que ilustra uma solução e um contra-exemplo é a afirmação de que todos
os números primos são ímpares. (Números primos são inteiros positivos que podem ser
divididos uniformemente por eles mesmos e 1 para produzir um resultado inteiro.) É claro que
o número 2 é primo, mas também é par, o que torna a declaração original falsa.
Alguém fazendo a afirmação poderia qualificá-la dizendo que todos os números primos são
ímpares, exceto 2. A solução parcial para o problema de encontrar todos os números primos é
que você precisa encontrar números ímpares, exceto no caso de 2, que é par . Neste segundo
caso, refutar a solução não é mais possível, mas adicionar à declaração original fornece um
limite.

Ao lançar dúvidas sobre a afirmação original, você também pode considerar situações
em que a hipótese, todos os números primos, exceto 2, são ímpares, pode não ser
verdadeira. Por exemplo, 1 é um número ímpar, mas não é considerado primo (veja a
discussão em https://primes.utm.edu/notes/faq/one.html para detalhes). Portanto, agora
a declaração original tem dois limites, e você deve reformulá-la da seguinte forma: Os
números primos são maiores que 1 e geralmente ímpares, exceto 2, que é par. Os
limites para números primos são melhor definidos localizando e considerando contra-exemplos.

À medida que a complexidade de um problema cresce, o potencial para encontrar contra-


exemplos também cresce. Uma regra essencial a ser considerada é que, assim como a
confiabilidade, ter mais pontos de falha significa maior potencial de ocorrência de uma falha.
Pensar em algoritmos dessa maneira é importante. Conjuntos de algoritmos simples podem
produzir melhores resultados com menos contra-exemplos potenciais do que um único
algoritmo complexo.

De pé sobre os ombros de gigantes


Um mito que desafia a explicação é que as técnicas atualmente usadas para
processar grandes quantidades de dados são de alguma forma novas. Sim,
novos algoritmos aparecem o tempo todo, mas a base para esses algoritmos
são todos os algoritmos anteriores. De fato, quando você pensa em Sir Isaac
Newton, você pode pensar em alguém que inventou algo novo, mas mesmo
ele declarou (usando a ortografia correta para sua época): ” (consulte https://
en.wikiquote.org/wiki/Isaac_Newton para cotações e insights adicionais).

Os algoritmos que você usa hoje nem eram novos nos dias de Aristóteles (veja https://
plato.stanford.edu/entries/aristotle-mathematics/) e Platão (ver https://www.storyofmathematics.com/
greek_plato.html). As origens de

CAPÍTULO 2 Considerando o Projeto de Algoritmos 27


Machine Translated by Google

os algoritmos em uso hoje estão tão escondidos na história que o melhor que alguém pode dizer
é que a matemática depende de adaptações do conhecimento dos tempos antigos. O uso de
algoritmos desde a antiguidade deve dar uma certa sensação de conforto, pois os algoritmos em
uso hoje são baseados em conhecimentos testados há milhares de anos.

Isso não quer dizer que alguns matemáticos não tenham derrubado o carrinho de maçãs ao
longo dos anos. Por exemplo, a teoria de John Nash, Nash Equilibrium, mudou significativamente
a forma como a economia é considerada hoje (consulte https://www.masterclass.
com/articles/nash-equilibrium-explained). É claro que o reconhecimento por esse trabalho vem
lentamente. Nash teve que esperar muito tempo antes de receber muito reconhecimento
profissional (veja a história em https://www.princeton.
edu/main/news/archive/S42/72/29C63/index.xml) apesar de ter ganhado um Prêmio Nobel de
Economia por suas contribuições. Caso você esteja interessado, a história de John Nash é
retratada no filme Uma Mente Brilhante, que contém algumas cenas muito debatidas, incluindo
uma contendo uma afirmação de que o Equilíbrio de Nash de alguma forma derruba parte do
trabalho de Adam Smith, outro colaborador do teorias econômicas. (Veja uma dessas discussões
em https://www.quora.com/Was-Adam Smith-wrong-as-claimed-by-John-Nash-in-the-movie-A-
Beautiful-Mind.)

Dividindo e conquistando
Se resolver problemas fosse fácil, todos o fariam. No entanto, o mundo ainda está cheio de
problemas não resolvidos e a condição não deve mudar tão cedo, por uma simples razão: os
problemas geralmente parecem tão grandes que nenhuma solução é imaginável. Guerreiros
antigos enfrentaram um problema semelhante. Um exército adversário pareceria tão grande e
suas forças tão pequenas que tornariam o problema de vencer uma guerra inimaginavelmente
difícil, talvez impossível. No entanto, ao dividir o exército adversário em pequenos pedaços e
atacá-lo um pouco de cada vez, um pequeno exército poderia derrotar um oponente muito maior.
(Os antigos gregos, romanos e Napoleão Bonaparte foram todos grandes usuários da estratégia
de dividir para conquistar; veja Napoleon For Dummies, de J. David Markham [Wiley], para
detalhes.)

Você enfrenta o mesmo problema que aqueles guerreiros antigos. Muitas vezes, os recursos à
sua disposição parecem muito pequenos e inadequados. No entanto, ao dividir um grande
problema em pequenas partes para que você possa entender cada parte, você pode
eventualmente criar uma solução que funcione para o problema como um todo. Os algoritmos
têm essa premissa em seu núcleo: usar etapas para resolver problemas uma pequena parte de
cada vez. As seções a seguir ajudam você a entender a abordagem de dividir e conquistar para
a solução de problemas com mais detalhes.

28 PARTE 1 Introdução aos algoritmos


Machine Translated by Google

Evitando soluções de força bruta


Uma solução de força bruta é aquela em que você tenta cada resposta possível, uma de cada
vez, para localizar a melhor resposta possível. (Isso também é chamado de abordagem
exaustiva, principalmente porque você está tão cansado quando termina.) É minucioso, isso é
certo, mas também desperdiça tempo e recursos na maioria dos casos. Testar cada resposta,
mesmo quando é fácil provar que uma resposta específica não tem chance de sucesso,
desperdiça tempo que um algoritmo pode usar em respostas que têm mais chance de sucesso.
Além disso, testar as várias respostas usando essa abordagem geralmente desperdiça recursos,
como memória. Pense desta forma: você quer quebrar a combinação de um cadeado, então
você começa em 0, 0, 0, mesmo sabendo que essa combinação em particular não tem chance
de sucesso, dadas as características físicas dos cadeados de combinação. Uma solução de
força bruta continuaria testando 0, 0, 0 de qualquer maneira e depois passaria para o igualmente
ridículo 0, 0, 1.

Todo tipo de solução vem com vantagens, embora às vezes bem pequenas.
Uma solução de força bruta tem uma dessas vantagens. Como você testa todas as respostas,
não precisa realizar nenhum tipo de pré-processamento. O tempo economizado em pular o pré-
processamento, no entanto, dificilmente compensará o tempo perdido na tentativa de todas as
respostas. No entanto, você pode encontrar ocasião para usar uma solução de força bruta quando

» Encontrar uma solução perfeita, se existir, é essencial.

» O tamanho do problema é limitado.

» Você pode usar heurísticas para reduzir o tamanho do conjunto de soluções.

» A simplicidade de implementação é mais importante que a velocidade.

Mantendo as coisas simples, bobas (KISS)


A solução de força bruta, descrita na seção anterior, tem uma séria desvantagem. Ele analisa
todo o problema de uma só vez. É como entrar em uma biblioteca e procurar livro por livro nas
prateleiras sem nunca considerar nenhum método para tornar sua pesquisa mais simples. A
abordagem de dividir e conquistar para pesquisas de livros é diferente. Neste caso, você
começa dividindo a biblioteca em seções para crianças e para adultos. Depois disso, você
divide a seção de adultos em categorias. Por fim, você pesquisa apenas a parte da categoria
que contém o livro de seu interesse. Este é o propósito de sistemas de classificação como o
Dewey Decimal System (ver https://mcpl.info/childrens/how-use-dewey-decimal-system).

A questão é que dividir e conquistar simplifica o problema.

CAPÍTULO 2 Considerando o Projeto de Algoritmos 29


Machine Translated by Google

A parte de dividir para conquistar é uma maneira essencial de entender melhor um problema
também. Tentar entender o layout de uma biblioteca inteira pode ser difícil. No entanto, saber que
o livro que você deseja encontrar sobre psicologia comparativa aparece como parte da Classe 100
da Divisão 150 da Seção 156 facilita seu trabalho. Você pode entender esse problema menor
porque sabe que todo livro da Seção 156 conterá algo sobre o tópico sobre o qual você deseja
saber.
Algoritmos funcionam da mesma maneira. Ao simplificar o problema, você pode criar um conjunto
de etapas mais simples para encontrar uma solução de problema, o que reduz o tempo para
encontrar a solução, reduz o número de recursos usados e aumenta suas chances de encontrar
precisamente a solução de que você precisa.

Decompor um problema geralmente é melhor


Depois de dividir um problema em partes gerenciáveis, você precisa conquistar a parte em questão.
Isso significa criar uma definição precisa do problema. Você não quer qualquer livro sobre psicologia
comparativa; você quer um escrito por George Romanes. Saber que o livro que você deseja aparece
na Seção 156 do Sistema Decimal de Dewey é um bom começo, mas não resolve o problema.
Agora você precisa de um processo para revisar cada livro na Seção 156 para o livro específico
que você precisa. O processo pode ir ainda mais longe e buscar livros com conteúdo específico.
Para tornar esse processo viável, você deve decompor o problema completamente, definir
precisamente o que você precisa e, depois de entender o problema completamente, usar o conjunto
correto de etapas (algoritmo) para encontrar o que você precisa.

Aprendendo que a ganância pode ser boa


Em alguns casos, você não consegue ver o fim de um processo de solução ou mesmo saber se
está ganhando a guerra. Tudo o que você pode realmente fazer é garantir que você vença as
batalhas individuais para criar uma solução para o problema na esperança de também vencer a
guerra. Um método ganancioso para resolver problemas usa essa abordagem. Ele procura uma
solução global, escolhendo o melhor resultado possível em cada estágio da solução do problema.

Parece que vencer cada batalha significaria necessariamente vencer a guerra também, mas às
vezes o mundo real não funciona dessa maneira. Uma vitória de Pirro é aquela em que alguém
vence todas as batalhas, mas acaba perdendo a guerra porque o custo da vitória excede os ganhos
da vitória por uma margem tão grande. Você pode ler cerca de cinco vitórias de Pirro em https://
www.history.com/news/5-famous pyrrhic-victories. Essas histórias mostram que um algoritmo
ganancioso geralmente funciona, mas nem sempre, então você precisa considerar a melhor solução
geral para um problema em vez de ficar cego por vitórias provisórias. As seções a seguir descrevem
como evitar a vitória de Pirro ao trabalhar com algoritmos.

30 PARTE 1 Introdução aos algoritmos


Machine Translated by Google

Aplicando o raciocínio ganancioso


O raciocínio ganancioso é frequentemente usado como parte de um processo de otimização. O algoritmo
visualiza o problema um passo de cada vez e se concentra apenas no passo em questão. Todo algoritmo
guloso faz duas suposições:

» Você pode fazer uma única escolha ideal em uma determinada etapa.

» Ao escolher a seleção ideal em cada etapa, você pode encontrar uma solução ideal para o
problema geral.

Você pode encontrar muitos algoritmos gananciosos, cada um otimizado para executar tarefas específicas.
Aqui estão alguns exemplos comuns de algoritmos gulosos usados para análise de gráficos (consulte o
Capítulo 9 para mais informações sobre gráficos) e compactação de dados (consulte o Capítulo 14 para
obter mais informações sobre compactação de dados) e o motivo pelo qual você pode querer usá-los:

» Mínimo Spanning Tree (MST) de Kruskal: O algoritmo escolhe a aresta entre dois nós
com o menor valor, não o maior valor como a palavra ganancioso pode transmitir
inicialmente. Esse tipo de algoritmo pode ajudá-lo a encontrar o caminho mais curto
entre dois locais em um mapa ou executar outras tarefas relacionadas a gráficos.

» MST de Prim: Este algoritmo divide um gráfico não direcionado (um em que a direção
não é considerada) pela metade. Em seguida, seleciona a aresta que conecta as duas
metades para tornar o peso total das duas metades o menor possível. Você pode
encontrar esse algoritmo usado em um jogo de labirinto para localizar a distância mais
curta entre o início e o fim do labirinto.

» Codificação de Huffman: O algoritmo atribui um código a cada entrada de dados


exclusiva em um fluxo de entradas, com a entrada de dados mais usada recebendo o
código mais curto. Por exemplo, a letra E normalmente receberia o código mais curto
ao compactar texto em inglês, porque você a usa com mais frequência do que qualquer
outra letra do alfabeto. Ao alterar a técnica de codificação, você pode compactar o texto
e torná-lo consideravelmente menor, reduzindo o tempo de transmissão.

Alcançando uma boa solução


Cientistas e matemáticos usam algoritmos gananciosos com tanta frequência que o Capítulo 15 os aborda
em profundidade. No entanto, o que você realmente quer é uma boa solução, não apenas uma solução
específica. Na maioria dos casos, uma boa solução fornece resultados ótimos do tipo que você pode medir,
mas a palavra bom pode incluir muitos significados, dependendo do domínio do problema. Você deve
perguntar qual problema deseja resolver e qual solução resolve o problema da maneira que melhor atenda
às suas necessidades. Por exemplo, ao trabalhar em engenharia, pode ser necessário ponderar soluções
que considerem

CAPÍTULO 2 Considerando o Projeto de Algoritmos 31


Machine Translated by Google

peso, tamanho, custo ou outras considerações, ou talvez alguma combinação de todas essas
saídas que atendam a um requisito específico.

Para colocar esse problema em contexto, digamos que você construa uma máquina de moedas
que cria troco para valores monetários específicos usando o menor número possível de moedas
(talvez como parte de um checkout automático em uma loja). A razão para usar o mínimo de
moedas possível é reduzir o desgaste do equipamento, o peso das moedas necessárias e o
tempo necessário para fazer o troco (afinal, seus clientes estão sempre com pressa). Uma
solução gananciosa resolve o problema usando as maiores moedas possíveis. Por exemplo, para
produzir US$ 0,16 em troco, você usa um centavo (US$ 0,10), um níquel (US$ 0,05) e um centavo (US$ 0,01).

Um problema ocorre quando você não pode usar todos os tipos de moeda na criação de uma
solução. A máquina de troca pode estar sem moedas, por exemplo. Para fornecer US$ 0,40 em
troco, uma solução gananciosa começaria com um quarto (US$ 0,25) e um centavo (US$ 0,10).
Infelizmente, não há moedas, então a máquina de moedas produz cinco centavos (5 ×
$ 0,01) para um total de sete moedas. A solução ideal neste caso é usar quatro moedas de dez
centavos (4 × $ 0,10). Como resultado, o algoritmo guloso fornece uma solução particular, mas
não uma solução ótima. O problema da mudança recebe atenção considerável porque é muito
difícil de resolver. Você pode encontrar discussões adicionais, como “Combinatória do Problema
da Mudança”, de Anna Adamaszeka e Michal Adamaszek (https://www.sciencedirect.com/

ciência/artigo/pii/S0195669809001292) e “Coin Change” de Mayukh Sinha


(https://www.geeksforgeeks.org/coin-change-dp-7/).

Computando Custos e Seguintes Heurísticas


Mesmo quando você encontra uma boa solução, eficiente e eficaz, ainda precisa saber
exatamente quanto custa a solução. Você pode achar que o custo de usar uma solução específica
ainda é muito alto, mesmo quando todo o resto é considerado. Talvez a resposta chegue quase,
mas não exatamente, no prazo ou use muitos recursos de computação. A busca por uma boa
solução envolve a criação de um ambiente no qual você possa testar completamente o algoritmo,
os estados que ele cria, os operadores que ele usa para alterar esses estados e o tempo
necessário para derivar uma solução.

Muitas vezes, você descobre que uma abordagem heurística, que se baseia na autodescoberta
e produz resultados suficientemente úteis (não necessariamente ótimos, mas bons o suficiente)
é o método que você realmente precisa para resolver um problema. Fazer com que o algoritmo
execute parte do trabalho necessário economiza tempo e esforço porque você pode criar
algoritmos que veem padrões melhor do que os humanos. Consequentemente, a autodescoberta
é o processo de permitir que o algoritmo mostre um caminho potencialmente útil para uma solução
(mas você ainda precisa contar com a intuição e o entendimento humanos para saber se a
solução é a correta). As seções a seguir descrevem

32 PARTE 1 Introdução aos algoritmos


Machine Translated by Google

técnicas que você pode usar para calcular o custo de um algoritmo usando heurística como
um método para descobrir a utilidade real de qualquer solução.

Representando o problema como um espaço


Um espaço de problemas é um ambiente no qual ocorre uma busca por uma solução. Um
conjunto de estados e os operadores usados para alterar esses estados representam o
espaço do problema. Por exemplo, considere um jogo de peças com oito peças em um quadro
3 x 3. Cada peça mostra uma parte de uma imagem, e as peças começam em alguma ordem
aleatória para que a imagem seja embaralhada. O objetivo é mover uma peça de cada vez
para colocar todas as peças na ordem correta e revelar a imagem. Você pode ver um exemplo
desse tipo de quebra-cabeça em https://www.proprofsgames.com/puzzle/sliding/.

A combinação do estado inicial, dos blocos aleatórios e do estado objetivo - os blocos em


uma ordem específica - é a instância do problema. Você pode representar o quebra-cabeça
graficamente usando um gráfico de espaço do problema. Cada nó do grafo do espaço do
problema apresenta um estado (os oito ladrilhos em uma determinada posição). As arestas
representam operações, como mover o bloco número oito para cima. Quando você move o
bloco oito para cima, a imagem muda - ela se move para outro estado.

Ganhar o jogo passando do estado inicial para o estado objetivo não é a única consideração.
Para resolver o jogo com eficiência, você precisa realizar a tarefa com o menor número de
movimentos possível, o que significa usar o menor número de operações. O número mínimo
de movimentos usados para resolver o quebra-cabeça é a profundidade do problema.

Você deve considerar vários fatores ao representar um problema como um espaço. Por
exemplo, você deve considerar o número máximo de nós que caberão na memória e se o
número de nós que a memória pode suportar corresponde ao número esperado de nós
necessários para resolver o problema, que representa a complexidade do espaço. Quando
você não pode ajustar todos os nós na memória de uma só vez, o computador deve gerá-los
somente quando necessário e então descartar os nós anteriores para liberar memória ou
armazenar alguns nós em outros locais. Para determinar se os nós caberão na memória, você
deve considerar a complexidade do tempo, pois execuções mais longas do algoritmo
determinam o número máximo de nós criados para resolver o problema. Além disso, é
importante considerar o fator de ramificação,
que é o número médio de nós criados em cada etapa no gráfico do espaço do problema para
resolver um problema. Para a mesma solução, um algoritmo com um fator de ramificação
mais alto gerará mais nós do que um com um fator de ramificação mais baixo.

CAPÍTULO 2 Considerando o Projeto de Algoritmos 33


Machine Translated by Google

Indo aleatoriamente e sendo abençoado pela sorte


É possível resolver um problema de pesquisa usando técnicas de força bruta (descritas em
“Evitando soluções de força bruta”, anteriormente neste capítulo). A vantagem dessa
abordagem é que você não precisa de nenhum conhecimento específico de domínio para
usar um desses algoritmos. Um algoritmo de força bruta tende a usar a abordagem mais
simples possível para resolver o problema. A desvantagem é que uma abordagem de força
bruta funciona bem apenas para um pequeno número de nós. Aqui estão alguns dos
algoritmos comuns de busca de força bruta:

Técnica Descrição Contras Prós

Pesquisa em Começa no nó raiz, explora Deve armazenar Pode verificar nós


largura cada um dos nós filhos cada nó na memória, duplicados para
primeiro e depois desce para o que significa que utiliza economizar tempo e
o próximo nível. Ele progride uma quantidade sempre apresenta uma
nível por nível até encontrar considerável de memória solução.
uma solução. para um grande número
de nós.

Pesquisa em Começa no nó raiz e explora Não é possível É memória


profundidade um conjunto de nós filhos verificar nós eficiente.
conectados até atingir um nó duplicados, o que
folha. Progride ramo a ramo significa que ele pode
até encontrar uma solução. percorrer os mesmos
caminhos de nó mais de uma vez.

Pesquisa Pesquisas simultâneas É eficiente em termos Complexidade de


bidirecional do nó raiz e do nó de tempo e usa a implementação,
objetivo até que os dois memória com mais traduzindo-se em um
caminhos de busca se eficiência do que outros ciclo de desenvolvimento mais longo.
encontrem no meio. aproxima, e sempre
encontra uma solução.

Usando uma heurística e uma função de custo


Para algumas pessoas, a palavra heurística soa complicada. Seria tão fácil dizer que o
algoritmo faz um palpite e tenta novamente quando falha. Ao contrário dos métodos de força
bruta, os algoritmos heurísticos aprendem iterativamente tentando melhorar a solução ao
longo do tempo. Eles também usam funções de custo para fazer melhores escolhas.
Consequentemente, os algoritmos heurísticos são mais complexos, mas têm uma vantagem
distinta na resolução de problemas complexos. Assim como os algoritmos de força bruta,
existem muitos algoritmos heurísticos, e cada um vem com seu próprio conjunto de vantagens,
desvantagens e requisitos especiais. A lista a seguir descreve alguns dos algoritmos
heurísticos mais comuns:

34 PARTE 1 Introdução aos algoritmos


Machine Translated by Google

» Busca heurística pura: Expande os nós em ordem de custo. Ele mantém duas listas. A lista
fechada contém os nós já explorados; a lista aberta contém os nós que ainda deve
explorar. Em cada iteração, o algoritmo expande o nó com o menor custo possível. Todos
os seus nós filhos são colocados na lista fechada e os custos individuais dos nós filhos
são calculados. O algoritmo envia os nós filhos com baixo custo de volta para a lista aberta
e exclui os nós filhos com custo alto.

» A * search: rastreia o custo dos nós à medida que os explora (e escolhe os menos caros)
usando esta equação: f(n) = g(n) + h(n), onde

• n é o identificador do nó.

• g(n) é o custo para alcançar o nó até o momento.

• h(n) é o custo estimado para atingir a meta a partir do nó.

• f(n) é o custo estimado do caminho de n até o objetivo.

» Busca gananciosa do melhor primeiro: Escolhe o caminho mais próximo do objetivo


usando a equação f(n) = h(n). Ele pode encontrar soluções rapidamente, mas também
pode ficar preso em loops, então muitas pessoas não consideram uma abordagem ideal
para encontrar uma solução.

Avaliando Algoritmos
Obter insights sobre precisamente como os algoritmos funcionam é importante porque, de
outra forma, você não pode determinar se um algoritmo realmente funciona da maneira que
você precisa. Além disso, sem boas medições, você não pode realizar comparações
precisas para saber se você realmente precisa descobrir um novo método para resolver um
problema quando uma solução mais antiga funciona muito lentamente ou usa muitos recursos.
Conhecer a base a ser usada para comparar diferentes soluções e decidir entre elas é uma
habilidade essencial ao lidar com algoritmos.

A questão da eficiência faz parte da descoberta e do design de novos algoritmos desde que o
conceito de algoritmos surgiu, e é por isso que você vê tantos algoritmos diferentes competindo
para resolver o mesmo problema. O conceito de medir o tamanho das funções dentro de um
algoritmo e analisar como o algoritmo funciona não é novo; tanto Ada Lovelace quanto Charles
Babbage consideraram os problemas de eficiência do algoritmo em referência aos computadores
já em 1843 (ver https://www.computerhistory.org/babbage/adalovelace/).

Donald Knuth (https://www-cs-faculty.stanford.edu/~knuth/), cientista da computação,


matemático, professor emérito da Universidade de Stanford e autor do livro de vários
volumes The Art of Computer Programming (Addison-Wesley),

CAPÍTULO 2 Considerando o Projeto de Algoritmos 35


Machine Translated by Google

dedicou grande parte de suas pesquisas e estudos à comparação de algoritmos. Ele se


esforçou para formalizar como estimar as necessidades de recursos dos algoritmos de forma
matemática e permitir uma comparação correta entre soluções alternativas. Ele cunhou o
termo análise de algoritmos, que é o ramo da ciência da computação dedicado a entender
como os algoritmos funcionam de maneira formal. A análise mede os recursos necessários
em termos do número de operações que um algoritmo requer para chegar a uma solução ou
pelo seu espaço ocupado (como o armazenamento que um algoritmo requer na memória do
computador).

A análise de algoritmos requer alguma compreensão matemática e alguns cálculos, mas é


extremamente benéfico em sua jornada para descobrir, apreciar e usar algoritmos de forma
eficaz. Este tópico é consideravelmente mais abstrato do que outros tópicos deste livro. Para
tornar a discussão menos teórica, os capítulos posteriores apresentam mais aspectos práticos
de tal medição examinando os algoritmos juntos em detalhes. As seções a seguir fornecem o
básico.

Simulando usando máquinas abstratas


Quanto mais operações um algoritmo requer, mais complexo ele é. A complexidade é uma
medida da eficiência do algoritmo em termos de uso do tempo porque cada operação leva
algum tempo. Dado o mesmo problema, algoritmos complexos são geralmente menos
favoráveis do que algoritmos simples porque algoritmos complexos requerem mais tempo.
Pense naqueles momentos em que a velocidade de execução faz a diferença, como no setor
médico ou financeiro, ou ao voar no piloto automático em um avião ou foguete espacial. Medir
a complexidade do algoritmo é uma tarefa desafiadora, embora necessária se você quiser
empregar a solução certa. A primeira técnica de medição usa máquinas abstratas como a
Random Access Machine (RAM).

Máquinas abstratas não são computadores reais, mas sim teóricos — computadores que são
imaginados em seu funcionamento. É como sonhar acordado para cientistas da computação.
Você usa máquinas abstratas para considerar quão bem um algoritmo funcionaria em um
computador sem testá-lo na realidade, mas está limitado pelo tipo de hardware que você
usaria. Um computador com RAM realiza operações aritméticas básicas e interage com
informações na memória, e isso é tudo. Toda vez que um computador com RAM faz alguma
coisa, ele leva um passo de tempo (uma unidade de tempo). Ao avaliar um algoritmo em uma
simulação de RAM, você conta as etapas de tempo usando o seguinte procedimento:

1. Conte cada operação simples (aritméticas) como um passo de tempo.

2. Divida as operações complexas em operações aritméticas simples e conte o tempo


passos definidos no Passo 1.

3. Conte cada acesso de dados da memória como um passo de tempo.

36 PARTE 1 Introdução aos algoritmos


Machine Translated by Google

Para realizar essa contabilização, você escreve uma versão em pseudocódigo de seu algoritmo
(como mencionado no Capítulo 1) e executa essas etapas usando papel e lápis. No final, é uma
abordagem simples baseada em uma ideia básica de como os computadores funcionam, uma
aproximação útil que você pode usar para comparar soluções independentemente da potência e
velocidade do seu hardware ou da linguagem de programação que você usa.

Usar uma simulação é diferente de executar o algoritmo em um computador porque você usa
uma entrada padrão e predefinida. As medições reais do computador exigem que você execute
o código e verifique o tempo necessário para executá-lo. A execução de código em um
computador é na verdade um benchmark, outra forma de medição de eficiência, na qual você
também considera o ambiente do aplicativo (como o tipo de hardware usado e a implementação
do software). Um benchmark é útil, mas carece de generalização. Considere, por exemplo, como
um hardware mais recente pode executar rapidamente um algoritmo que demorava muito tempo
em seu computador anterior.

Ficando ainda mais abstrato


Se você achava que as coisas eram abstratas antes, esta seção faz com que as seções
anteriores pareçam concretas, mas cerre os dentes e siga em frente, porque você realmente
está à altura da tarefa! Medir uma série de etapas planejadas para alcançar uma solução para
um problema apresenta alguns desafios. A seção anterior discute a contagem de etapas de
tempo (número de operações), mas às vezes você também precisa calcular o espaço (como a
memória que um algoritmo consome). Você considera o espaço quando seu problema é
ganancioso por recursos. Dependendo do problema, você pode considerar um algoritmo melhor
quando funciona eficientemente em relação a um desses aspectos de consumo de recursos:

» Tempo de execução

» Requisitos de memória do computador

» Uso do disco rígido

» Consumo de energia

» Velocidade de transmissão de dados em uma rede

Alguns desses aspectos se relacionam com outros de maneira inversa, portanto, se, por
exemplo, você deseja um tempo de execução mais rápido, às vezes pode aumentar a memória
ou o consumo de energia para obtê-lo. Você não apenas pode ter diferentes configurações de
eficiência ao executar um algoritmo, mas também pode alterar as características do hardware e
a implementação do software para atingir seus objetivos. Em termos de hardware, usar um
supercomputador ou um computador de uso geral importa, e o software, ou linguagem usada
para escrever o algoritmo, é definitivamente um divisor de águas. Além disso, a quantidade e o
tipo de dados que você alimenta o algoritmo podem resultar em medições de desempenho
melhores ou piores.

CAPÍTULO 2 Considerando o Projeto de Algoritmos 37


Machine Translated by Google

As simulações de RAM contam o tempo porque quando você pode empregar uma solução em tantos
ambientes, e seu uso de recursos depende de tantos fatores, você precisa encontrar uma maneira de
simplificar as comparações para que elas se tornem padrão. Caso contrário, você não pode comparar
alternativas possíveis. A solução é, como muitas vezes acontece com muitos outros problemas, usar uma
única medida e dizer que um tamanho serve para todos. Nesse caso, a medida é o tempo, que você faz
igual ao número de operações, ou seja, a complexidade do algoritmo.

Uma simulação de RAM coloca o algoritmo em uma situação que é independente da linguagem e da
máquina (é independente da linguagem de programação e do tipo de computador). No entanto, explicar
como funciona uma simulação de RAM para outras pessoas requer um grande esforço. A análise de
algoritmos propõe usar o número de operações que você obtém de uma simulação de RAM e transformá-
las em uma função matemática que expressa como seu algoritmo se comporta em termos de tempo, que é
uma quantificação das etapas ou operações necessárias quando o número de dados insumos cresce. Por
exemplo, se seu algoritmo classifica objetos, você pode expressar a complexidade usando uma função que
informa quantas operações ele precisa dependendo do número de objetos que recebe.

Trabalhando com funções


Uma função em matemática é simplesmente uma maneira de mapear algumas entradas para uma resposta.
Expressa de uma maneira diferente, uma função é uma transformação (baseada em operações
matemáticas) que transforma (mapeia) sua entrada em uma resposta. Para certos valores de entrada
(geralmente indicados pelas letras x ou n), você tem uma resposta correspondente usando a matemática
que define a função. Por exemplo, uma função como f(n) = 2n diz que quando sua entrada é um número n,
sua resposta é o número n multiplicado por 2.

Uma função que descreve como um algoritmo relaciona sua solução à quantidade de dados que recebe é
algo que você pode analisar sem suporte específico de hardware ou software. Também é fácil comparar
com outras soluções, considerando o tamanho do seu problema. A análise de algoritmos é realmente um
conceito alucinante porque reduz uma série complexa de etapas em uma fórmula matemática.

Na maioria dos casos, uma análise de algoritmos não está interessada em definir exatamente a função. O
que você realmente precisa é uma comparação de uma função de destino com uma ou mais outras funções.
Essas funções de comparação aparecem dentro de um conjunto de funções propostas que apresentam
baixo desempenho quando comparadas à função alvo. Dessa forma, você não precisa inserir números em
funções de maior ou menor complexidade; em vez disso, você lida com funções simples, predefinidas e
bem conhecidas. É mais eficaz e semelhante a classificar o desempenho de algoritmos em categorias, em
vez de obter uma medida exata de desempenho.

38 PARTE 1 Introdução aos algoritmos


Machine Translated by Google

O conjunto de funções generalizadas é chamado de notação Big O e, neste livro, você


encontrará frequentemente esse pequeno conjunto de funções (colocadas entre
parênteses e precedidas por um O maiúsculo) usadas para representar o desempenho
de algoritmos. A Figura 2-1 mostra a análise de um algoritmo. Um sistema de coordenadas
cartesianas pode representar sua função medida pela simulação RAM, onde a abcissa
(a coordenada x) é o tamanho da entrada e a ordenada (a coordenada y) é o número
resultante de operações. Você pode ver três curvas representadas. O tamanho da
entrada é importante. No entanto, a qualidade também importa (por exemplo, ao solicitar
problemas, é mais rápido solicitar um insumo que já está quase encomendado).
Consequentemente, a análise mostra um pior caso, f1(n), um caso médio, f2(n), e um
melhor caso, f3(n). Mesmo que o caso médio possa lhe dar uma ideia geral, o que
realmente importa é o pior caso, porque podem surgir problemas quando seu algoritmo se esforça para e
A função Big O é aquela que, após um certo valor n0 (o limite para considerar uma
entrada grande), sempre resulta em um número maior de operações dada a mesma
entrada do que a função de pior caso f1. Assim, a função Big O é ainda mais pessimista
do que a que representa seu algoritmo, então não importa a qualidade da entrada, você
pode ter certeza de que as coisas não podem ficar piores do que isso.

FIGURA 2-1:
Complexidade de
um algoritmo no
caso de melhor,
média e pior entrada
caso.

Muitas funções possíveis podem resultar em resultados piores, mas a escolha de funções
oferecidas pela notação Big O que você pode usar é restrita porque sua finalidade é
simplificar a medição de complexidade propondo um padrão. Esta seção

CAPÍTULO 2 Considerando o Projeto de Algoritmos 39


Machine Translated by Google

contém apenas as poucas funções que fazem parte da notação Big O. A lista a
seguir descreve-os em ordem crescente de complexidade:

» Complexidade constante O(1): Ao mesmo tempo, não importa quanta entrada você
providenciar. No final, é um número constante de operações, não importa o tamanho dos
dados de entrada.

» Complexidade logarítmica O(log n): O número de operações cresce a uma taxa mais
lenta que a entrada, tornando o algoritmo menos eficiente com entradas pequenas e
mais eficiente com entradas maiores. Um algoritmo típico dessa classe é a busca binária,
conforme descrito no Capítulo 7 sobre organização e busca de dados.

» Complexidade linear O(n): As operações crescem com a entrada na proporção de 1:1.


Um algoritmo típico é a iteração, que é quando você varre a entrada uma vez e
aplica uma operação a cada elemento dela. O Capítulo 4 discute as iterações.

» Complexidade linear O(n log n): Complexidade é uma mistura entre logaritmo
mic e complexidade linear. É típico de alguns algoritmos inteligentes usados para ordenar
dados, como merge sort, heapsort e quicksort. O Capítulo 7 fala sobre a maioria deles.

» Complexidade quadrática O(n2): As operações crescem ao quadrado do número de


insumos. Quando uma iteração está aninhada dentro de outra iteração, você tem
complexidade quadrática. Por exemplo, você tem uma lista de nomes e, para encontrar
os mais semelhantes, você compara cada nome com todos os outros nomes. Alguns
algoritmos de ordenação menos eficientes apresentam tal complexidade: ordenação por
bolha, ordenação por seleção e ordenação por inserção.

» Complexidade cúbica O(n3): As operações crescem ainda mais rápido do que a complexidade
quadrática porque há várias iterações aninhadas. Quando um algoritmo tem essa ordem
de complexidade e processa uma quantidade modesta de dados (100.000 elementos), pode
ser executado por anos. Quando você tem um número de operações que é uma potência da
entrada, é comum referir-se ao algoritmo como sendo executado em tempo polinomial.

» Complexidade exponencial O(2n): O algoritmo leva duas vezes o número de operações


anteriores para cada novo elemento adicionado. Quando um algoritmo tem essa
complexidade, mesmo pequenos problemas podem demorar uma eternidade. Muitos
algoritmos que fazem buscas exaustivas têm complexidade exponencial. No entanto, o
exemplo clássico para este nível de complexidade é o cálculo dos números de Fibonacci
(que, sendo um algoritmo recursivo, é tratado no Capítulo 4).

» Complexidade fatorial O(n!): Se a entrada for 100 objetos e uma operação


em um computador leva de 10 a 6 segundos (uma velocidade razoável para computadores
hoje), completar a tarefa exigirá cerca de 10140 anos (uma quantidade de tempo impossível
porque a idade do universo é estimada em 1014 anos).
Um famoso problema de complexidade fatorial é o problema do caixeiro viajante, no
qual um caixeiro tem que encontrar o caminho mais curto para visitar muitas cidades e
voltar à cidade inicial (apresentado no Capítulo 18).

40 PARTE 1 Introdução aos algoritmos


Machine Translated by Google
NESTE CAPÍTULO

» Considerando o que o Google Colab


oferece

» Usando o Google Colab para realizar


tarefas comuns de desenvolvimento

» Fazendo aplicativos rodarem no Google


Colab

» Obtendo ajuda quando você precisa

Capítulo 3

Trabalhando com o Google


Colab

Colaboratório (https://colab.research.google.com/notebooks/welcome.
ipynb), ou Colab, abreviado, é um serviço baseado em nuvem do Google que permite que você
escrever código Python usando um ambiente semelhante a um notebook, em vez do
IDE normal. (Jupyter Notebook, https://jupyter.org/, fornece um ambiente semelhante ao
Colab na área de trabalho se você não tiver uma conexão com a Internet.) Você não
precisa instalar nada em seu sistema para usá-lo. O benefício dessa abordagem é que
você pode trabalhar com código em pequenas partes e obter resultados quase instantâneos
de qualquer trabalho que fizer. Um formato de notebook também se presta à saída em um
formato de relatório que funciona bem para apresentações e relatórios. A primeira seção
deste capítulo ajuda você a trabalhar com alguns conceitos básicos do Colab e entender
como o Colab difere de um IDE padrão (e por que essa diferença tem um benefício
significativo ao aprender algoritmos).

Você pode usar o Colab para realizar tarefas específicas em um paradigma orientado a células.
As próximas seções do capítulo abordam uma série de tópicos relacionados a tarefas que
começam com o uso de notebooks. Obviamente, você também deseja realizar outros tipos de
tarefas, como criar vários tipos de células e usá-los para criar blocos de anotações que tenham
uma aparência semelhante a um relatório com código funcional.

CAPÍTULO 3 Trabalhando com o Google Colab 41


Machine Translated by Google

Parte do trabalho com o Colab é saber como executar o código de exemplo, fazendo-o rodar o
mais rápido possível. Duas seções do capítulo são dedicadas ao uso da aceleração de hardware
e à execução do código de exemplo de várias maneiras.

Finalmente, este capítulo não pode abordar todos os aspectos do Colab, então a seção final do
capítulo serve como um recurso útil para localizar as informações mais confiáveis sobre o Colab.

Você não precisa digitar o código-fonte para este capítulo manualmente. Na verdade, usar a fonte
para download é muito mais fácil. Você pode encontrar a fonte para este capítulo em \A4D2E\
A4D2E; 03; Arquivo Colab Examples.ipynb da fonte para download. Consulte a Introdução para
obter detalhes sobre como localizar esses arquivos de origem.

Definindo o Google Colab


O Colab foi projetado para imitar um aplicativo de desktop chamado Jupyter Notebook (https://
jupyter.org/). Na verdade, é um pouco difícil diferenciar os dois aplicativos na funcionalidade que
eles fornecem. O Google Colab é a versão em nuvem do Notebook, e a página de boas-vindas
torna esse fato aparente. Ele ainda usa arquivos IPython (o nome anterior para Jupyter) Notebook
(.ipynb) para o site.

Embora os dois aplicativos sejam semelhantes e ambos usem arquivos .ipynb , eles têm algumas
diferenças que você precisa conhecer. A edição anterior deste livro usava o Jupyter Notebook,
mas o Colab oferece a capacidade de calcular em qualquer lugar em qualquer dispositivo que
tenha um navegador, então esta edição do livro se concentra no Colab. As seções a seguir ajudam
você a entender as diferenças do Colab.

Entendendo o que o Google Colab faz


Você pode usar o Colab para realizar muitas tarefas, mas para os propósitos deste livro, você o
usa para escrever e executar código, criar sua documentação associada e exibir gráficos. A fonte
para download deste livro foi projetada para ser executada no Colab, mas você também pode usá-
la com o Jupyter Notebook, se desejar.

Jupyter Notebook é um aplicativo localizado em que você usa recursos locais com ele.
Você poderia usar outras fontes, mas isso pode ser inconveniente ou impossível
em alguns casos. Por exemplo, de acordo com https://docs.github.com/
repositórios/trabalhando-com-arquivos/usando-arquivos/trabalhando-com- arquivos sem código,
seus arquivos do Notebook aparecerão como páginas HTML estáticas quando você usar um
repositório GitHub (GitHub é uma tecnologia de armazenamento baseada em nuvem
especificamente orientada para trabalhar com código.) Na verdade, alguns recursos não funcionam. Colab habilita

42 PARTE 1 Introdução aos algoritmos


Machine Translated by Google

permite que você interaja totalmente com seus arquivos do Notebook usando o GitHub como repositório, e
o Colab também oferece suporte a várias outras opções de armazenamento online, para que você possa
considerar o Colab como seu parceiro online na criação de código Python.

A outra razão pela qual você realmente precisa conhecer o Colab é que você pode usá-lo com seu dispositivo
alternativo. Durante o processo de escrita, alguns dos códigos de exemplo foram testados em um tablet
baseado em Android (um ASUS ZenPad 3S 10). O tablet de destino tem o Chrome instalado e executa o
código suficientemente bem para seguir os exemplos. Dito tudo isso, você provavelmente não vai querer
tentar escrever código usando um tablet desse tamanho - o texto era incrivelmente pequeno, por um lado, e
a falta de um teclado também pode ser um problema. A questão é que você não precisa ter um sistema
Windows, Linux ou OS X para testar o código, mas as alternativas podem não fornecer o desempenho
esperado.

O Google Colab geralmente não funciona com outros navegadores além do Chrome (o navegador usado
neste capítulo), Firefox ou Safari (os testes iniciais com o Microsoft Edge também foram encorajadores). Na
maioria dos casos, você vê uma mensagem de erro, como Este site pode não funcionar em seu navegador.
Use um navegador compatível e nenhuma outra exibição se você tentar iniciar o Colab em um navegador
não compatível.
O link Mais informações incluído leva você a https://research.google.com/
colaboratory/faq.html#browsers, onde você pode obter mais informações.

ALGUMAS DICAS DO FIREFOX


Mesmo com a ajuda online, você ainda pode descobrir que sua cópia do Firefox exibe
um SecurityError: A operação é insegura. mensagem de erro. A caixa de diálogo de erro inicial
aponta para algum problema não relacionado, como cookies, mas você vê essa mensagem de erro
quando clica em Detalhes. Simplesmente descartar a caixa de diálogo clicando em OK faz com que
o Colab pareça estar funcionando porque ele exibe seu código, mas você não verá os resultados da
execução do código.

Como primeiro passo para corrigir esse problema, certifique-se de que sua cópia do Firefox esteja
atualizada; versões mais antigas não fornecem o suporte necessário. Depois de atualizar sua cópia,
definir a preferência network.websocket.allowInsecureFromHTTPS usando About:Config como True
deve resolver o problema, mas às vezes não. Nesse caso, verifique se o Firefox realmente permite
cookies de terceiros selecionando a opção Sempre para Aceitar cookies de terceiros e dados do site e
a opção Lembrar histórico na seção Histórico na guia Privacidade e segurança da caixa de diálogo
Opções. Reinicie o Firefox após cada alteração e tente o Colab novamente. Se nenhuma dessas
correções funcionar, você deverá usar o Chrome para trabalhar com o Colab em seu sistema.

CAPÍTULO 3 Trabalhando com o Google Colab 43


Machine Translated by Google

Familiarizando-se com os recursos do Google Colab


O Google Colab fornece acesso a vários recursos por meio do sistema de menus.
Um desses recursos, a aceleração de hardware, aparece na seção “Usando a aceleração de hardware”, mais
adiante neste capítulo. Todos os recursos desta seção aparecem no menu Ferramentas.

Comandos de localização

A opção Toolsÿ Command Palette exibe uma lista de comandos que você pode executar, conforme mostrado
na Figura 3-1. Alguns desses comandos também possuem teclas de atalho, como Ctrl+Alt+M para adicionar
um comentário a uma célula. Todos esses comandos ajudam você a executar tarefas de formulário associadas
ao conteúdo do Notebook, como adicionar formulários.

FIGURA 3-1:
O uso de
comandos
do
Colab facilita a
configuração do seu Notebook.

Configurando as configurações

A opção Toolsÿ Settings exibe a caixa de diálogo Settings, mostrada na Figura 3-2.
As quatro guias de configurações executam estas tarefas:

» Site: Configura o funcionamento do site. O cenário mais interessante é o tema.


Selecionar Adaptável permite que o Colab escolha as cores da interface com base nas
condições de iluminação. Você também pode definir as configurações de exibição e acesso nesta guia.

» Editor: Determina como o texto aparece na tela e como a interface funciona.


Por exemplo, você pode definir as combinações de teclas para funcionar como as do Vim (um
editor de texto incluído nos sistemas Unix e Linux, geralmente como o utilitário vi; consulte
https://www.vim.org/) se desejado. Você também pode selecionar o tamanho da fonte, espaços
para cada nível de recuo e uma infinidade de outras configurações.

44 PARTE 1 Introdução aos algoritmos


Machine Translated by Google

» Colab Pro: Fornece um anúncio do Colab Pro (https://colab.


research.google.com/signup), que oferece alguns benefícios significativos, como GPUs
mais rápidas, tempos de execução mais longos e mais memória - tudo isso permite que
você faça mais trabalho em menos tempo.

» Diversos: Contém configurações divertidas. Você pode escolher entre três visuais
efeitos: adicionar trovões e relâmpagos usando a configuração de nível de potência;
deixando um Corgi correr pela parte superior da tela; e permitindo que um gatinho corra pela
parte superior da tela. Você pode escolher qualquer combinação desses efeitos visuais.

FIGURA 3-2:
A caixa de
diálogo
Configurações ajuda
a configurar o Colab IDE.

Personalizando atalhos de teclado


Se você não gostar dos atalhos de teclado padrão, poderá personalizá-los para atender às
suas necessidades. Para fazer isso, escolha Toolsÿ Keyboard Shortcuts, e você verá a
caixa de diálogo Key board Preferences, mostrada na Figura 3-3. Se você vir Definir atalho,
significa que o comando não possui um atalho no momento, portanto, você pode adicionar
um, se desejar. Veja como você trabalha com atalhos:

» Para adicionar ou alterar um atalho, coloque o cursor na caixa ao lado do comando e


pressione a tecla de atalho que deseja usar para esse comando.

» Para remover um atalho, pressione Excluir.

CAPÍTULO 3 Trabalhando com o Google Colab 45


Machine Translated by Google

FIGURA 3-3:
Customizar
teclas de atalho para
velocidade de
acesso aos comandos.

Comparando arquivos
Às vezes você precisa comparar dois arquivos para ver como eles diferem. Ao selecionar Toolsÿ
Diff Notebooks, o Colab abre uma nova guia do navegador e mostra dois cadernos lado a lado,
conforme mostrado na Figura 3-4. Estes são arquivos selecionados aleatoriamente do seu
Google Drive. Para selecionar os arquivos com os quais você realmente deseja trabalhar, clique
na seta para baixo ao lado do caminho do arquivo em cada painel. As diferenças aparecem na tela.

FIGURA 3-4:
O Colab permite
comparar dois arquivos
para ver como eles
diferem.

46 PARTE 1 Introdução aos algoritmos


Machine Translated by Google

Trabalhando com Notebooks


O notebook é a base das interações com o Colab. Na verdade, o Colab é construído em
notebooks, como mencionado anteriormente. Quando você coloca o mouse em
determinadas partes da página de boas-vindas em https://colab.research.google.com/notebooks/
bem-vindo.ipynb, você vê oportunidades para interagir com a página adicionando entradas de código
ou texto (que você pode usar para anotações conforme necessário). Essas entradas estão ativas,
então você pode interagir com elas. Você também pode mover as células e copiar o material
resultante para o seu Google Drive. É claro que, embora a interação com a página de boas-vindas
seja inesperada e divertida, o objetivo real deste capítulo é demonstrar como interagir com os
notebooks do Colab. As seções a seguir descrevem como realizar tarefas básicas relacionadas a
notebooks com o Colab.

Criando um novo caderno


Para criar um novo bloco de notas, escolha Arquivoÿ Novo Bloco de Notas. Você vê um novo
notebook Python 3 como o mostrado na Figura 3-5. (A versão mais recente do Colab não oferece
suporte ao Python 2, portanto, você não pode usar o Python 2 com ele.)

FIGURA 3-5:
Crie um novo bloco

de anotações
do Python 3.

O notebook mostrado na Figura 3-5 permite alterar o nome do arquivo clicando nele. Para executar o
código em uma célula específica, clique na seta apontando para a direita no lado esquerdo dessa
célula. Depois de executar o código, você deve escolher a próxima célula diretamente.

Abrindo notebooks existentes


Você pode abrir notebooks existentes encontrados no armazenamento local, no Google Drive ou no
GitHub. Você também pode abrir qualquer um dos exemplos do Colab ou fazer upload de arquivos
de fontes que você pode acessar, como uma unidade de rede em seu sistema. Em todos os casos, você

CAPÍTULO 3 Trabalhando com o Google Colab 47


Machine Translated by Google

comece escolhendo Arquivoÿ Abrir Notebook. Você vê a caixa de diálogo mostrada na Figura 3-6.

FIGURA 3-6:
Use esta caixa de
diálogo para abrir
blocos de
anotações existentes.

A visualização padrão mostra todos os arquivos que você abriu recentemente, independentemente da localização.
Os arquivos aparecem em ordem alfabética. Você pode filtrar o número de itens exibidos digitando uma string
no campo Filter Notebooks. Na parte superior estão outras opções para abrir notebooks.

Mesmo se você não estiver logado, ainda poderá acessar os projetos de exemplo do Colab. Esses projetos
ajudam você a entender o Colab, mas não permitem que você faça nada com seus próprios projetos. Mesmo
assim, você ainda pode experimentar o Colab sem fazer login no Google primeiro. As seções a seguir fornecem
mais detalhes sobre essas opções.

Como usar o Google Drive para notebooks existentes


O Google Drive é o local padrão para muitas operações no Colab e você sempre pode escolhê-lo como destino.
Ao trabalhar com o Google Drive, você vê uma lista de arquivos semelhantes aos mostrados na Figura 3-6. Para
abrir um arquivo específico, clique em seu link na caixa de diálogo. O arquivo é aberto na guia atual do seu
navegador.

Usando o GitHub para notebooks existentes


Ao trabalhar com o GitHub, inicialmente você precisa fornecer a localização do código-fonte online, conforme
mostrado na Figura 3-7. A localização deve apontar para um projeto público; você não pode usar o Colab para
acessar projetos privados.

48 PARTE 1 Introdução aos algoritmos


Machine Translated by Google

FIGURA 3-7:
Ao usar o
GitHub, você
deve
fornecer o
local do código-fonte.

Depois de fazer a conexão com o GitHub, você verá duas listas: repositórios, que são contêineres
de código relacionado a um projeto específico; e branches, uma implementação específica do
código. A seleção de um repositório e ramificação exibe uma lista de arquivos de notebook que
você pode carregar no Colab. Basta clicar no link necessário e ele será carregado como se você
estivesse usando um Google Drive (https://drive.google.com/), que é outro tipo de armazenamento
online.

Usando armazenamento local para notebooks existentes


Se você quiser usar a fonte para download deste livro, ou qualquer fonte local, selecione a guia
Upload da caixa de diálogo. No centro há um único botão, Escolher Arquivo. Clicar neste botão
abre a caixa de diálogo Abrir Arquivo do seu navegador. Você localiza o arquivo que deseja
carregar, exatamente como faria normalmente para abrir qualquer arquivo.

Selecionar um arquivo e clicar em Abrir faz o upload do arquivo para o Google Drive. Se você
fizer alterações no arquivo, essas alterações aparecerão no Google Drive, não na sua unidade local.
Dependendo do seu navegador, normalmente você vê uma nova janela aberta com o código
carregado. No entanto, você também pode simplesmente ver uma mensagem de sucesso; nesse
caso, agora você deve abrir o arquivo usando a mesma técnica que faria ao usar o Google Drive.
Em alguns casos, seu navegador pergunta se você deseja sair da página atual. Você deve dizer
ao navegador para fazer isso.

CAPÍTULO 3 Trabalhando com o Google Colab 49


Machine Translated by Google

O comando Fileÿ Upload Notebook também carrega um arquivo para o Google Drive. Na verdade,
carregar um notebook funciona como carregar qualquer outro tipo de arquivo e você vê a mesma caixa
de diálogo. Se você quiser fazer upload de outros tipos de arquivos, usar o comando Fileÿ Upload
Notebook é provavelmente mais rápido.

Salvando blocos de anotações

O Colab oferece um número significativo de opções para salvar seu notebook. No entanto, nenhuma
dessas opções funciona com sua unidade local. Depois de fazer upload do conteúdo da sua unidade
local para o Google Drive ou GitHub, o Colab gerencia o conteúdo na nuvem e não na sua unidade
local. Para salvar atualizações em sua unidade local, você deve baixar o arquivo em sua unidade local.
As seções a seguir analisam as opções baseadas em nuvem para salvar notebooks.

Como usar o Drive para salvar notebooks


O local padrão para armazenar seus dados é o Google Drive. Quando você escolhe Arquivoÿ Salvar, o
conteúdo que você cria vai para o diretório raiz do seu Google Drive.
Se você quiser salvar o conteúdo em uma pasta diferente, selecione essa pasta no Google Drive (https://
drive.google.com/).

O Colab rastreia as versões do seu projeto conforme você salva. No entanto, à medida que essas
revisões envelhecem, o Colab as remove. Para salvar uma versão que não envelhece, você usa o
comando Arquivoÿ Salvar e Fixar Revisão. Para ver as revisões do seu projeto, escolha Arquivoÿ
Histórico de revisões.

Você também pode salvar uma cópia do seu projeto escolhendo Arquivoÿ Salvar uma cópia na unidade.
A cópia recebe a palavra Copy como parte de seu nome. Claro, você pode renomeá-lo mais tarde. O
Colab armazena a cópia na pasta atual do Google Drive.

Usando o GitHub para salvar notebooks


O GitHub oferece uma alternativa ao Google Drive para salvar conteúdo. Ele oferece um método
organizado de compartilhamento de código para fins de discussão, revisão e distribuição. Você pode
encontrar o GitHub em https://github.com/. O código-fonte deste livro aparece em https://github.com/
lmassaron/algo4d_2ed, para que você possa acessá-lo facilmente no Colab.

Você pode usar apenas repositórios públicos ao trabalhar com o GitHub do Colab, embora o GitHub
também suporte repositórios privados. Para salvar um arquivo no GitHub, escolha Arquivoÿ Salvar uma
cópia no GitHub. Se você ainda não estiver conectado ao GitHub, o Colab exibe uma janela que solicita
suas informações de login. Depois de entrar, você verá uma caixa de diálogo semelhante à mostrada
na Figura 3-8.

50 PARTE 1 Introdução aos algoritmos


Machine Translated by Google

FIGURA 3-8:
Usar o GitHub
significa armazenar
seus dados em
um repositório.

Se sua conta não tiver um repositório no momento, você deverá criar um novo repositório ou
escolher um repositório existente para armazenar seus dados. Depois de salvar o arquivo, ele
aparece no repositório GitHub de sua escolha. O repositório inclui um link para abrir os dados
no Colab por padrão, a menos que você opte por não incluir esse recurso.

Usando gists do GitHub para salvar notebooks


Você usa gists do GitHub como meio de compartilhar arquivos únicos ou outros recursos com outras pessoas.
Algumas pessoas também os usam para projetos completos, mas a ideia é que você tenha um conceito que
deseja compartilhar – algo que não está totalmente formado e não representa um aplicativo utilizável. Você
pode ler mais sobre gists em https://help.github.com/articles/about-gists/.

Executando Tarefas Comuns


A maioria das tarefas no Colab e no Notebook funcionam da mesma forma. Cada um tem
células de código e células sem código, e você pode criar células de código no Colab e no
Notebook usando as opções do menu Inserir. Da mesma forma, ambos os ambientes têm
células sem código que vêm em três formas:

» Texto

» Cabeçalho da seção

» Campo de formulário, que vem nestes tipos:

• Lista suspensa

• Entrada

CAPÍTULO 3 Trabalhando com o Google Colab 51


Machine Translated by Google

• Controle deslizante

• Remarcação

As células não codificadas no Colab funcionam de maneira um pouco diferente das células Markdown
encontradas no Notebook, mas a ideia é a mesma. Duas adições interessantes no Colab que não
são encontradas no Bloco de Notas são a célula de código de rascunho, que permite experimentar o
código em tempo real, e os trechos de código, que são códigos enlatados para executar tarefas
específicas (você apenas os insere quando necessário).

Você também pode editar e mover células. Uma diferença importante entre os dois ambientes é que
você não pode alterar um tipo de célula no Colab, mas pode no Notebook. Uma célula que você cria
como um cabeçalho de seção não pode se transformar repentinamente em uma célula de código.
As seções a seguir oferecem uma breve visão geral dos vários recursos.

Criando células de código


A primeira célula que o Colab cria para você é uma célula de código. O Colab e o Notebook
compartilham os mesmos recursos em relação ao código, portanto, o código que você escreve no
Colab também funciona no bloco de notas (e vice-versa). No entanto, ao lado da célula, você vê um
menu de extras que você pode usar com o Colab (veja a Figura 3-9); estes não estão presentes no Notebook.

FIGURA 3-9:
As células de código
do Colab contêm
alguns extras não
encontrados no Notebook.

Você usa os ícones mostrados na Figura 3-9 para aumentar sua experiência de código Colab.
Veja o que esses recursos fazem (em ordem de aparição, da esquerda para a direita, na figura):

» Mover célula para cima: move a célula uma posição para cima na hierarquia de células.

» Mover célula para baixo: move a célula uma posição para baixo na hierarquia de células.

» Link para célula: exibe uma caixa de diálogo contendo um link que você pode usar para acessar uma
célula específica no bloco de anotações. Você pode incorporar esse link em qualquer lugar em uma página
da Web ou em um bloco de anotações para permitir que alguém acesse essa célula específica. A pessoa
ainda vê o bloco de anotações inteiro, mas não precisa procurar a célula que deseja discutir.

» (Opcional) Adicionar um comentário (supondo que você tenha o direito de fazer um


comentário): cria um balão de comentário à direita da célula. Isso não é o mesmo que um
comentário de código, que existe em linha com o código, mas afeta toda a célula. Você pode
editar, excluir ou resolver comentários. Um comentário resolvido é aquele que recebe atenção e
não é mais aplicável.

52 PARTE 1 Introdução aos algoritmos


Machine Translated by Google

» Abrir configurações do editor: Abre a mesma caixa de diálogo mostrada na Figura 3-2 e
discutida na seção “Se familiarizando com os recursos do Google Colab”, anteriormente neste
capítulo. Você precisa selecionar algum código antes que essa opção apareça.

» Espelhar célula na guia: Espelha a célula atualmente selecionada em um painel Célula que
aparece no lado direito da janela. Você pode rolar para onde quiser no código no painel
esquerdo e manter esse código acessível. A seta apontando para a direita permite que você
execute a célula a qualquer momento após fazer alterações no código do painel esquerdo.
Um par de setas de ponta dupla permite que você mova o foco de volta para o código
selecionado no painel esquerdo com um único clique. Você também pode mover o código da
célula para uma célula de rascunho, onde você pode brincar com ela sem modificar seu código
original. Você pode ter mais de um painel Célula. Você simplesmente seleciona o que deseja e
se move entre eles conforme necessário, o que permite mover facilmente de um lugar para outro
em seu código. Feche um painel de Célula clicando no X ao lado da palavra Célula.

» Excluir célula: remove a célula do bloco de anotações.

» Reticências verticais: contém vários recursos adicionais em um menu (nem todos podem
aparecer porque dependem do arquivo que você abriu, das tarefas que executou e de seus
direitos para trabalhar com o conteúdo do arquivo):

• Copiar Célula: Copia o conteúdo da célula selecionada no momento para o


prancheta.

• Cortar Célula: Exclui o conteúdo da célula selecionada no momento e a coloca em


a área de transferência.

• Limpar Saída: Remove a saída da célula. Você deve executar o código novamente para
gerar novamente a saída.

• Visualizar saída em tela cheia: Exibe a saída (não a célula inteira ou qualquer outra parte
do notebook) no modo de tela cheia no dispositivo host. Essa opção é útil ao exibir uma
quantidade significativa de conteúdo ou quando uma exibição detalhada de gráficos ajuda a
explicar um tópico. Pressione Esc para sair do modo de tela cheia.

• Adicionar um formulário: insere um formulário na célula à direita do código.


Você usa formulários para fornecer uma entrada gráfica para parâmetros. Os
formulários não aparecem no Notebook, mas devido à forma como você os cria,
eles não impedirão que você execute o código no Notebook. Você pode ler mais
sobre formulários em https://colab.research.google.com/notebooks/forms.ipynb.

As células de código também informam sobre o código e sua execução. O pequeno ícone
ao lado da saída exibe informações sobre a execução quando você passa o mouse sobre
ela. Clicar no ícone limpa a saída. Você deve executar o código novamente para gerar
novamente a saída.

CAPÍTULO 3 Trabalhando com o Google Colab 53


Machine Translated by Google

Criando células de texto


As células de texto funcionam como as células de marcação no Notebook. No entanto, a Figura
3-10 mostra que você recebe ajuda adicional para formatar o texto usando uma interface gráfica.
A marcação é a mesma, mas você tem a opção de permitir que a GUI o ajude a criar a marcação.
Por exemplo, neste caso, para criar o sinal de hash (#) para um título, clique no ícone de T duplo
que aparece primeiro na lista. Clicar no ícone de T duplo novamente aumentaria o nível do
cabeçalho. À direita, você vê como o texto aparecerá no bloco de anotações.

Observe o menu à direita da célula de texto. Este menu contém muitas das mesmas opções que
uma célula de código contém. Por exemplo, você pode criar uma lista de links para ajudar as
pessoas a acessar partes específicas do seu bloco de anotações por meio de um índice. Ao
contrário do Notebook, você não pode executar células de texto para resolver a marcação que elas contêm.

Criando células especiais


As células especiais que o Colab fornece são variações da célula de texto. Essas células especiais,
que você acessa usando a opção de menu Inserir, agilizam a criação das células necessárias. No
entanto, você não deve usar essas células especiais se precisar manter a compatibilidade entre o
Colab e o Notebook. As seções a seguir descrevem cada um desses tipos de células especiais.

Trabalhando com títulos


Ao escolher Insertÿ Section Header Cell, você verá uma nova célula criada abaixo da célula
selecionada no momento que possui a entrada de nível 1 de cabeçalho apropriada. Você pode
aumentar o nível do título clicando no ícone de T duplo. A GUI parece a mesma da Figura 3-10,
então você tem todos os recursos de formatação padrão para seu texto.

FIGURA 3-10:
Use a GUI para
facilitar a
formatação do seu texto.

54 PARTE 1 Introdução aos algoritmos


Machine Translated by Google

Trabalhando com um índice


Uma adição interessante ao Colab é a geração automática de uma tabela de conteúdo para seu notebook.
Para usar esse recurso, clique no ícone Índice no lado esquerdo da janela. O índice contém uma entrada
para cada título fornecido em seu código. As entradas são organizadas automaticamente de acordo com o
nível, para que você veja a hierarquia do seu código. Clicar em uma entrada leva você automaticamente
para esse local em seu código.

Editando células
Tanto o Colab quanto o Notebook têm menus de edição que contêm as opções que você espera, como a
capacidade de recortar, copiar e colar células. Os dois produtos também têm algumas diferenças
interessantes. Por exemplo, o Notebook permite dividir e mesclar células.
O Colab contém uma opção para mostrar ou ocultar o código como alternância. Essas diferenças dão a
cada produto um sabor ligeiramente diferente, mas não alteram sua capacidade de usar cada um para
criar e modificar código Python.

Movendo células
A mesma técnica que você usa para mover células no Notebook também funciona com o Colab.
A única diferença é que o Colab depende exclusivamente dos botões da barra de ferramentas (consulte a
Figura 3-9); O Notebook também possui opções de movimentação de células no menu Editar. Para mover
uma célula, selecione-a e clique nos botões Mover célula para cima ou Mover célula para baixo conforme
necessário.

Usando a aceleração de hardware


Mesmo que você não precise dele para os exemplos deste livro, o Colab oferece aceleração de hardware
na forma de uma unidade de processamento gráfico (GPU) ou unidade de processamento tensor (TPU).
Ambos os processadores especiais oferecem a capacidade de processar vários conjuntos de dados em
paralelo em alta velocidade. Ao trabalhar com big data (consulte o Capítulo 12) em um ambiente de
aprendizado de máquina ou aprendizado profundo, uma GPU ou TPU pode fazer uma grande diferença no
tempo necessário para realizar uma tarefa. A principal diferença entre uma GPU e uma TPU é que uma
GPU aparece como parte da maioria dos adaptadores de vídeo de última geração hoje e pode dobrar para
renderizar gráficos complexos, enquanto uma TPU é um processador personalizado projetado pelo Google
especificamente para tarefas de aprendizado de máquina e aprendizado profundo. (Existem outras
diferenças, mas elas não são importantes para este livro.)

CAPÍTULO 3 Trabalhando com o Google Colab 55


Machine Translated by Google

O suporte a GPU e TPU está desabilitado por padrão no Colab. Para habilitar o suporte a GPU
ou TPU, escolha Runtimeÿ Change Runtime Type. Uma caixa de diálogo Configurações do
Notebook é exibida. Nesta caixa de diálogo está a lista suspensa Hardware Accelerator, na qual
você pode escolher Nenhum (o padrão), GPU ou TPU.

Executando o Código
Para que seu código seja útil, você precisa executá-lo em algum momento. As seções anteriores
mencionaram a seta apontando para a direita que aparece na célula atual. Clicar nele executa
apenas a célula atual. Claro, você tem outras opções além de clicar na seta apontando para a
direita, e todas essas opções aparecem no menu Runtime (o menu Cell no Notebook). A lista a
seguir resume essas opções:

» Executando a célula atual: Ao invés de clicar na seta apontando para a direita, você
também pode escolher Runtime ÿ Run the Focused Cell para executar o código na célula
atual.

» Executando outras células: o Colab oferece opções no menu Runtime para


executar o código na próxima célula, na célula anterior ou em uma seleção de células.
Basta escolher a opção que corresponde à célula ou conjunto de células que deseja
executar.

» Executando todas as células: Em alguns casos, você deseja executar todo o código em um
caderno. Nesse caso, escolha Runtime ÿ Run All. A execução começa na parte superior
do notebook, na primeira célula que contém o código, e continua até a última célula que
contém o código no notebook. Você pode interromper a execução a qualquer momento
escolhendo Runtime ÿ Interrupt Execution.

Escolher Runtimeÿ Manage Sessions exibe uma caixa de diálogo contendo uma lista de todas as
sessões que estão sendo executadas atualmente para sua conta no Colab. Você pode usar essa
caixa de diálogo para determinar quando o código nesse notebook foi executado pela última vez
e quanta memória o notebook consome. Clique no ícone da lixeira para encerrar a execução de
um bloco de anotações específico.

56 PARTE 1 Introdução aos algoritmos


Machine Translated by Google

Conseguindo ajuda

O local mais óbvio para obter ajuda com o Colab é no menu Ajuda do Colab.
O menu não possui um link de ajuda geral, mas você pode encontrá-lo em https://colab.
research.google.com/notebooks/welcome.ipynb (o que requer que você faça
login no site do Colab). Este menu contém todas as entradas usuais:

» Perguntas Frequentes (FAQs): Leva você a uma página contendo


perguntas que outras pessoas fizeram.

» Pesquisar trechos de código: abre um painel mostrando tarefas comuns, como


trabalhando com uma câmera, na qual você pode pesquisar por exemplo um código que
possa atender às suas necessidades com uma pequena modificação. Clicar no botão
Inserir insere o código no local atual do cursor na célula que tem o foco. Cada uma das
entradas também mostra um exemplo do código.

» Report a Bug: Leva você a uma página onde você pode reportar erros do Colab.

» Faça uma pergunta no Stack Overflow: exibe uma nova guia do navegador, onde você
pode fazer perguntas de outros usuários. Você verá uma tela de login se ainda não tiver
feito login no Stack Overflow.

» Enviar comentários: exibe uma caixa de diálogo com links para locais onde você pode
obter informações adicionais. Se você realmente deseja enviar comentários, clique no
link Continuar assim mesmo na parte inferior da caixa de diálogo.

CAPÍTULO 3 Trabalhando com o Google Colab 57


Machine Translated by Google
Machine Translated by Google

NESTE CAPÍTULO

» Usando matrizes e vetores para


realizar cálculos

» Obtendo as combinações corretas

» Empregando técnicas recursivas para


obter resultados específicos

» Considerando maneiras de
acelerar os cálculos

Capítulo 4

Desempenho essencial
Manipulações de dados
Usando Python
a linguagem Python — os símbolos arcanos que você usa para se comunicar
Você provavelmente já usou tutoriais online ou outros métodos para aprender o básico de
seu computador. (Se não, você pode encontrar bons tutoriais básicos em https://
www.w3schools.com/python/ e https://www.tutorialspoint.com/python/
index.htm). No entanto, simplesmente saber como controlar uma linguagem usando suas
construções para realizar tarefas não é suficiente para criar um aplicativo útil. O objetivo dos
Manipular dados significa
algoritmos matemáticos pegar dados
é transformar um tipobrutos
de dadoeem
fazer algo
outro tipo com eles para
de dado.
alcançar um resultado desejado. (Este é um tópico abordado em Python for
Data Science For Dummies, de John Paul Mueller e Luca Massaron [Wiley].)
onde gastar dinheiro adicional em melhorias. Os dados de tráfego em sua
forma bruta não fazem nada para informá-lo - você deve manipulá-los para ver
o padrão de uma forma útil.
maneiras.

CAPÍTULO 4 Realizando manipulações essenciais de dados usando Python 59


Machine Translated by Google

Em tempos passados, as pessoas realizavam várias manipulações para tornar os dados úteis à
mão, o que exigia conhecimentos avançados de matemática. Felizmente, você pode encontrar
pacotes Python para realizar a maioria dessas manipulações usando um pouco de código. Você não
precisa mais memorizar manipulações misteriosas — apenas saiba quais recursos do Python usar.
É isso que este capítulo o ajuda a alcançar. Você descobre os meios para realizar vários tipos de
manipulação de dados usando pacotes Python de fácil acesso projetados especialmente para esse
propósito. (O Capítulo 5 dá o próximo passo e mostra como criar sua própria biblioteca de algoritmos
codificados manualmente.) Este capítulo começa com manipulações de vetores e matrizes. As
seções posteriores discutem técnicas como a recursão que podem tornar as tarefas ainda mais
simples, além de realizar algumas tarefas que são quase impossíveis usando outros meios. Você
também descobre como acelerar os cálculos para que gaste menos tempo manipulando os dados e
mais tempo fazendo algo realmente interessante com eles.

Você não precisa digitar o código-fonte para este capítulo manualmente. Na verdade,
usar a fonte para download é muito mais fácil. Você pode encontrar a fonte para este
capítulo em \A4D2E\A4D2E; 04; Vetores e Matrizes Básicos.ipynb, \A4D2E\A4D2E; 04;
Binary Search.ipynb e \A4D2E\A4D2E; 04; Arquivos Recursion.ipynb da fonte para
download. Consulte a Introdução para obter detalhes sobre como localizar esses
arquivos de origem.

Executando cálculos usando vetores e


Matrizes
Para realizar um trabalho útil com o Python, muitas vezes você precisa trabalhar com grandes
quantidades de dados que vêm em formulários específicos. Essas formas têm nomes estranhos,
mas os nomes são muito importantes. Os três termos que você precisa saber para este capítulo são
os seguintes:

» Escalar: Um único item de dados de base. Por exemplo, o número 2 mostrado por si só
é um escalar.

» Vetor: Um array unidimensional (essencialmente uma lista) de itens de dados. Por exemplo, uma matriz
contendo os números 2, 3, 4 e 5 seria um vetor.

» Matriz: Uma matriz de duas ou mais dimensões (essencialmente uma tabela) de itens de dados.
Por exemplo, uma matriz contendo os números 2, 3, 4 e 5 na primeira linha e 6, 7, 8 e 9 na
segunda linha é uma matriz.

O Python fornece uma variedade interessante de recursos por conta própria, mas você ainda precisa
fazer muito trabalho para executar algumas tarefas. Para reduzir a quantidade de trabalho que você

60 PARTE 1 Introdução aos algoritmos


Machine Translated by Google

fazer, você pode confiar no código escrito por outras pessoas e encontrado em pacotes. As
seções a seguir descrevem como usar o pacote NumPy (https://numpy.org/) para executar várias
tarefas em escalares, vetores e matrizes. Este capítulo fornece uma visão geral do NumPy,
enfatizando os recursos que você usará posteriormente (consulte https://www.
w3schools.com/python/numpy/default.asp para mais detalhes).

Entendendo as operações
escalares e vetoriais
O pacote NumPy fornece funcionalidades essenciais para computação científica em Python.
Para usar numpy, importe-o usando um comando como import numpy as np. Agora você pode
acessar numpy usando a abreviação comum de duas letras np.

Python fornece acesso a apenas um tipo de dados em qualquer categoria específica. Por
exemplo, se você precisar criar uma variável que represente um número sem uma parte decimal,
use o tipo de dados inteiro. Usar uma designação genérica como essa é útil porque simplifica o
código e dá ao desenvolvedor muito menos preocupações.
No entanto, em cálculos científicos, muitas vezes você precisa de um melhor controle sobre
como os dados aparecem na memória, o que significa ter mais tipos de dados, algo que numpy
fornece para você. Por exemplo, você pode precisar definir um escalar específico
como um short (um valor com 16 bits de comprimento). Usando numpy, você pode
defini-lo como myShort = np.short(15). O pacote NumPy fornece acesso a uma
variedade de tipos de dados (https://numpy.org/doc/stable/reference/arrays.scalars.html).

Use a função numpy array() para criar um vetor. Por exemplo, myVect = np.array([1, 2, 3, 4]) cria
um vetor com quatro elementos. Nesse caso, o vetor contém inteiros padrão do Python. Você
também pode usar a função Arrange () para produzir vetores, como myVect = np.arange(1, 10,
2), que preenche myVect com [1, 3, 5, 7, 9]. A primeira entrada informa o ponto de partida, a
segunda o ponto de parada e a terceira o passo entre cada número. Um quarto argumento
permite definir o tipo de dados para o vetor.

Você também pode criar um vetor com um tipo de dados específico. Tudo que você precisa fazer
é especificar o tipo de dados assim: myVect = np.int16([1, 2, 3, 4]) para preencher myVect com
um vetor contendo valores inteiros de 16 bits. Para verificar isso por si mesmo, você pode usar
print(type(myVect[0])), que gera <class 'numpy.int16'>.

Você pode executar funções matemáticas básicas em vetores como um todo, o que torna numpy
incrivelmente útil e menos propenso a erros que podem ocorrer ao usar construções
de programação, como loops, para executar a mesma tarefa. Por exemplo, ao iniciar
com myVect = np.array([1, 2, 3, 4]), myVect + 1 produz uma saída de array([2, 3, 4,
5], dtype=int16). Observe que a saída informa especificamente qual tipo de dados
está em uso. Como você pode esperar, myVect - 1 produz uma saída de array([0, 1,
2, 3], dtype=int16).

CAPÍTULO 4 Realizando manipulações essenciais de dados usando Python 61


Machine Translated by Google

Como pensamento final sobre operações escalares e vetoriais, você também pode executar
tarefas lógicas e de comparação. Por exemplo, o código a seguir executa operações de
comparação em duas matrizes:

a = np.array([1, 2, 3, 4]) b =
np.array([2, 2, 4, 4])

print(a == b)
print(a < b)

A saída neste caso é:

[Falso Verdadeiro Falso Verdadeiro]


[Verdadeiro Falso Verdadeiro Falso]

Começando com dois vetores, a e b, o código verifica se os elementos individuais de a são


iguais aos de b. Neste caso, a[0] não é igual a b[0]. No entanto, a[1] é igual a b[1]. A saída
é um vetor do tipo bool que contém valores True ou False com base nas comparações
individuais.

As operações lógicas dependem de funções especiais. Você verifica a saída lógica dos
operadores booleanos AND, OR, XOR e NOT. Aqui está um exemplo das funções lógicas:

a = np.array([Verdadeiro, Falso, Verdadeiro, Falso])


b = np.array([Verdadeiro, Verdadeiro, Falso, Falso])

print(np.logical_or(a, b))
print(np.logical_and(a,b))
print(np.logical_not(a))
print(np.logical_xor(a,b))

Ao executar este código, você vê estas saídas:

[Verdadeiro Verdadeiro Verdadeiro Falso]


[Verdadeiro Falso Falso]
[Falso Verdadeiro Falso Verdadeiro]
[Falso Verdadeiro Verdadeiro Falso]

Você pode ler mais sobre as funções lógicas em https://numpy.org/doc/stable/reference/


routines.logic.html .

62 PARTE 1 Introdução aos algoritmos


Machine Translated by Google

Executando a multiplicação vetorial


Adicionar, subtrair ou dividir vetores ocorre elemento por elemento, conforme descrito na seção
anterior. No entanto, quando se trata de multiplicação, as coisas ficam um pouco estranhas.
Na verdade, dependendo do que você realmente quer fazer, as coisas podem se tornar
bastante estranhas. Considere o tipo de multiplicação discutido na seção anterior. Ambos
myVect * myVect e np.multiply(myVect, myVect)
produzir uma saída elemento por elemento de [ 1, 4, 9, 16] ao iniciar com uma matriz de [1, 2,
3, 4].

Infelizmente, uma multiplicação elemento por elemento pode produzir resultados incorretos ao
trabalhar com algoritmos. Em muitos casos, o que você realmente precisa é de um produto
escalar, que é a soma dos produtos de duas sequências numéricas. Ao trabalhar com vetores,
o produto escalar é sempre a soma das multiplicações elemento por elemento individuais e
resulta em um único número. Por exemplo, myVect.dot(myVect) resulta em uma saída de 30.
Se você somar os valores da multiplicação elemento por elemento, descobrirá que eles
realmente somam 30. A discussão em https://www. mathsisfun.com/algebra/vectors-dot
product.html informa sobre produtos escalares e ajuda a entender onde eles podem se encaixar
nos algoritmos. Você pode aprender mais sobre as funções de manipulação de álgebra linear
para numpy em https://numpy.org/doc/stable/reference/

rotinas.linalg.html.

Criar uma matriz é o caminho certo para começar


Muitas das mesmas técnicas que você usa com vetores também funcionam com matrizes.
Para criar uma matriz básica, você simplesmente usa a função array() como faria com um
vetor, mas define dimensões adicionais. Uma dimensão é uma direção na matriz. Por exemplo,
uma matriz bidimensional contém linhas (uma direção) e colunas (uma segunda direção). A
chamada de matriz myMatrix = np.array([[1,2,3], [4,5,6], [7,8,9]]) produz uma matriz contendo
três linhas e três colunas, assim:

[[1 2 3]
[4 5 6]
[7 8 9]]

Observe como você incorpora três listas em uma lista de contêineres para criar as duas
dimensões. Para acessar um elemento de array específico, você fornece um valor de índice
de linha e coluna, como myMatrix[0, 0] para acessar o primeiro valor de 1. Você pode encontrar
uma lista completa de funções de criação de array de vetores e matrizes em https:/ /numpy.org/doc/
stable/reference/routines.array-creation.html.

CAPÍTULO 4 Realizando manipulações essenciais de dados usando Python 63


Machine Translated by Google

O pacote NumPy suporta uma classe de matriz real . A classe de matriz suporta recursos
especiais que facilitam a execução de tarefas específicas de matriz. Você descobrirá esses
recursos mais adiante no capítulo. Por enquanto, tudo o que você realmente precisa saber é
como criar uma matriz do tipo de dados matrix . O método mais fácil é fazer uma chamada
semelhante à que você usa para a função array , mas usando a função mat , como myMatrix
= np.mat([[1,2,3], [4,5,6 ], [7,8,9]]), que produz a seguinte matriz:

[[1 2 3]
[4 5 6]
[7 8 9]]

Para determinar que isso realmente é uma matriz, tente print(type(myMatrix)), que gera
<class 'numpy.matrix'>. Você também pode converter um array existente em uma matriz
usando a função asmatrix() . Use a função asarray() para converter um objeto de matriz de
volta em um formato de matriz .

O único problema com a classe matrix é que ela funciona apenas em matrizes bidimensionais.
Se você tentar converter uma matriz tridimensional para a matriz
classe, você vê uma mensagem de erro informando que a forma é muito grande para ser
uma matriz.

Multiplicando matrizes
A multiplicação de duas matrizes envolve as mesmas preocupações que a multiplicação de
dois vetores (como discutido na seção “Executando a multiplicação de vetores”, anteriormente
neste capítulo). O código a seguir produz uma multiplicação elemento por elemento de duas
matrizes:

a = np.array([[1,2,3],[4,5,6]])
b = np.array([[1,2,3],[4,5,6]])

imprima(a*b)

A saída fica assim:

[[ 1 4 9]
[16 25 36]]

Observe que a e b têm a mesma forma: duas linhas e três colunas. Para realizar uma
multiplicação elemento por elemento, as duas matrizes devem ter a mesma forma.
Caso contrário, você verá uma mensagem de erro informando que as formas estão erradas.
Assim como os vetores, a função multiplique() também produz um resultado elemento por
elemento.

64 PARTE 1 Introdução aos algoritmos


Machine Translated by Google

Os produtos pontuais funcionam de forma completamente diferente com matrizes. Nesse


caso, o número de colunas na matriz a deve corresponder ao número de linhas na matriz b.
No entanto, o número de linhas na matriz a pode ser qualquer número, e o número de
colunas na matriz b pode ser qualquer número, desde que você multiplique a por b. Por
exemplo, o código a seguir produz um produto escalar correto:

a = np.array([[1,2,3],[4,5,6]])
b = np.array([[1,2,3],[3,4,5],[5,6,7]])

print(a.ponto(b))

com uma saída de:

[[22 28 34]
[49 64 79]]

Observe que a saída contém o número de linhas encontradas na matriz a e o número


de colunas encontradas na matriz b. Então, como tudo isso funciona? Para obter o valor
*
encontrado na matriz de saída no índice [0,0] de 22, você soma os valores de a[0,0]
* *
b[0,0] (que é 1), a[0,1] b[2,0] (que
b[1,0]
é 15)
(quepara
é 6)obter
e a[0,2]
o valor de 22. funcionam
As outras entradas
exatamente
da mesma maneira.

Para realizar uma multiplicação elemento por elemento usando dois objetos de matriz ,
você deve usar a função numpy multiplicar() .

Definindo operações de matriz avançadas


Este livro leva você a todos os tipos de operações matriciais interessantes, mas você usa
algumas delas com frequência, e é por isso que elas aparecem neste capítulo. Ao trabalhar
com matrizes, às vezes você obtém dados em uma forma que não funciona com o algoritmo.
Felizmente, numpy vem com uma função especial reshape() que permite colocar os dados
em qualquer formato necessário. Na verdade, você pode usá-lo para remodelar um vetor em
uma matriz, conforme mostrado no código a seguir:

changeIt = np.array([1,2,3,4,5,6,7,8])
print(alterar)

changeIt = changeIt.reshape(2,4)
print(alterar)

changeIt = changeIt.reshape(2,2,2)
print(alterar)

CAPÍTULO 4 Realizando manipulações essenciais de dados usando Python 65


Machine Translated by Google

Ao executar este código, você vê estas saídas (espaços adicionados para maior clareza):

[1 2 3 4 5 6 7 8]

[[1 2 3 4]
[5 6 7 8]]

[[[1 2]
[3 4]]

[[5 6]
[7 8]]]

A forma inicial de changeIt é um vetor, mas usar a função reshape () a transforma em uma matriz.
Além disso, você pode moldar a matriz em qualquer número de dimensões que funcionem com os
dados. No entanto, você deve fornecer uma forma que se ajuste ao número necessário de
elementos. Por exemplo, chamando changeIt.
reshape(2,3,2) falhará porque não há elementos suficientes para fornecer uma matriz desse
tamanho.

Você pode encontrar duas operações matriciais importantes em algumas formulações de


algoritmos. Eles são a transposição e a inversa de uma matriz. A transposição ocorre quando uma
matriz de forma nxm é transformada em uma matriz mxn, trocando as linhas pelas colunas. A
maioria dos textos indica esta operação usando o super script T, como em AT. Você vê esta
operação usada com mais frequência para multiplicação para obter as dimensões corretas. Ao
trabalhar com numpy, você usa a transposição
função para realizar o trabalho necessário. Por exemplo, ao iniciar com uma matriz que possui duas
linhas e quatro colunas, você pode transpô-la para conter quatro linhas com duas colunas cada,
conforme mostrado neste exemplo:

changeIt = np.array([[1, 2, 3, 4], [5, 6, 7, 8]])


print(alterar)

changeIt = np.transpose(changeIt)
print(alterar)

As saídas ficam assim:

[[1 2 3 4]
[5 6 7 8]]

[[1 5]
[2 6]
[3 7]
[4 8]]

66 PARTE 1 Introdução aos algoritmos


Machine Translated by Google

Você aplica a inversão de matriz a matrizes de formato mxm, que são matrizes quadradas que
possuem o mesmo número de linhas e colunas. Esta operação é bastante importante porque
permite a resolução imediata de equações envolvendo multiplicação de matrizes, como y = bX,
onde você conhece o vetor y e a matriz X, e você tem que descobrir os valores no vetor b.
Como a maioria dos números escalares (exceções incluem zero) tem um número cuja
multiplicação resulta em um valor de 1, a idéia é encontrar uma matriz inversa cuja multiplicação
resultará em uma matriz especial chamada matriz identidade. Para ver uma matriz de identidade
em numpy, use a identidade
função, assim:

print(np.identidade(4))

que produz uma saída de:

[[1. 0. 0. 0.]
[0. 1. 0. 0.]
[0. 0. 1. 0.]
[0. 0. 0. 1.]]

Observe que uma matriz identidade contém todas as unidades da diagonal. Encontrar o inverso
de um escalar é bastante fácil (o número escalar n tem um inverso de n–1 que é 1/n). É uma
história diferente para uma matriz. A inversão de matrizes envolve um grande número de
cálculos. A inversa de uma matriz A é indicada como A–1. Ao trabalhar com numpy, você usa
a função linalg.inv() para criar um inverso. O exemplo a seguir mostra como criar uma inversa,
usá-la para obter um produto escalar e comparar esse produto escalar com a matriz identidade
usando a função allclose() .

a = np.array([[1,2], [3,4]])
b = np.linalg.inv(a)

print(np.allclose(np.dot(a,b), np.identity(2)))

A saída de True diz que b é o inverso de a. Às vezes, encontrar a inversa de uma matriz é
impossível. Quando uma matriz não pode ser invertida, ela é chamada de matriz singular ou
matriz degenerada. Matrizes singulares não são a norma; eles são bem raros.

Criando combinações do jeito certo


A modelagem de dados geralmente envolve a visualização dos dados de várias maneiras. Os dados
não são simplesmente uma sequência de números — eles apresentam uma sequência significativa
que, quando ordenada da maneira correta, transmite informações ao espectador. Criando os dados certos

CAPÍTULO 4 Realizando manipulações essenciais de dados usando Python 67


Machine Translated by Google

combinações manipulando sequências de dados é uma parte essencial para fazer com que os
algoritmos façam o que você quer que eles façam. As seções a seguir examinam três técnicas
de modelagem de dados: permutações, combinações e repetições.

Distinção de permutações
Quando você recebe dados brutos, eles aparecem em uma ordem específica. A ordem pode
representar praticamente qualquer coisa, como o log de um dispositivo de entrada de dados que
monitora algo como uma linha de produção. Talvez os dados sejam uma série de números que
representam o número de produtos feitos em um determinado momento. O motivo pelo qual você
recebe os dados em uma ordem específica é importante, mas essa ordem pode não ser suficiente
para obter a saída necessária de um algoritmo. Criar uma permutação de dados, uma reordenação
dos dados para que apresentem uma visão diferente, pode ajudar a alcançar o resultado desejado.

Você pode visualizar permutações de várias maneiras. Um método de visualizar uma permutação
é como uma apresentação aleatória da ordem da sequência. Nesse caso, você pode usar a
função numpy random.permutation() , conforme mostrado aqui:

a = np.array([1,2,3])
print(np.random.permutation(a))

O que você vê é uma versão aleatória dos dados originais, como [2 1 3]. Cada vez que você
executa esse código, você recebe uma ordenação aleatória diferente da sequência de dados, o
que é útil com algoritmos que exigem que você aleatorize o conjunto de dados para obter os
resultados desejados. Por exemplo, a amostragem é uma parte essencial da análise de dados e
a técnica mostrada é uma maneira eficiente de realizar essa tarefa.

Outra maneira de visualizar o problema é a necessidade de obter todas as permutações de um


conjunto de dados para que você possa experimentar cada uma por vez. Para realizar esta tarefa,
você precisa importar o pacote itertools . O código a seguir mostra uma técnica que você pode
usar para obter uma lista de todas as permutações de um determinado vetor:

de permutações de importação itertools

a = np.array([1,2,3])

para p em permutações(a):
imprimir(p)

68 PARTE 1 Introdução aos algoritmos


Machine Translated by Google

A saída que você vê é assim:

(1, 2, 3)
(1, 3, 2)
(2, 1, 3)
(2, 3, 1)
(3, 1, 2)
(3, 2, 1)

Se você quiser usar a compreensão da lista (https://www.w3schools.com/python/


python_lists_comprehension.asp) abordagem, que é um método mais curto de executar
tarefas repetitivas, você pode usar [print(p) for p in permutations(a)]
em vez de. Você pode ler mais sobre itertools em https://docs.python.org/3/
library/itertools.html.

Embaralhando combinações
Em alguns casos, você não precisa de um conjunto de dados inteiro; tudo que você realmente
precisa são alguns dos membros em combinações de um comprimento específico. Por
exemplo, você pode ter um conjunto de dados contendo quatro números e desejar apenas
combinações de dois números dele. (A capacidade de obter partes de um conjunto de dados
é uma função chave para gerar um gráfico totalmente conectado, descrito na Parte 3 do livro.)
O código a seguir mostra como obter essas combinações:

de combinações de importação do itertools

a = np.array([1,2,3,4])

para pente em combinações (a, 2):


imprimir (pente)

que produz esta saída:

(1, 2)
(1, 3)
(1, 4)
(2, 3)
(2, 4)
(3, 4)

A saída contém todas as combinações possíveis de dois números de a. Observe que este
exemplo usa a função de combinações() de itertools ( a função permutations()
função aparece na seção anterior). Claro, você pode não precisar de todos aqueles

CAPÍTULO 4 Realizando manipulações essenciais de dados usando Python 69


Machine Translated by Google

combinações; talvez um subconjunto aleatório deles funcionasse melhor. Nesse caso, você
pode contar com a função random.sample() para ajudá-lo, conforme mostrado aqui:

importar aleatório

piscina = []

para pente em combinações (a, 2):


pool.append(comb)

print(random.sample(pool, 3))

As combinações precisas que você vê como saída variam, como [(1, 2), (2, 3), (1, 4)]. No
entanto, a ideia é que você tenha limitado seu conjunto de dados de duas maneiras. Primeiro,
você não está usando todos os elementos de dados o tempo todo e, segundo, você não está
usando todas as combinações possíveis desses elementos de dados. O efeito é criar um
conjunto de elementos de dados de aparência relativamente aleatória que você pode usar
como entrada para um algoritmo. Python fornece uma série de métodos de randomização que
você pode ver em https://docs.python.org/3/library/random.html. Muitos dos exemplos
posteriores neste livro também contam com a randomização para ajudar a obter a saída correta
dos algoritmos.

Enfrentando repetições
Dados repetidos podem pesar injustamente a saída de um algoritmo para que você obtenha
resultados imprecisos. Às vezes, você precisa de valores exclusivos para determinar o resultado
de uma manipulação de dados. Felizmente, o Python facilita a remoção de certos tipos de
dados repetidos. Considere este exemplo:

a = np.matriz([1,2,3,4,5,6,6,7,7,1,2,3])
b = np.array(lista(conjunto(a)))

imprimir(b)

A saída contém apenas os elementos exclusivos:

[1, 2, 3, 4, 5, 6, 7]

Nesse caso, a começa com uma variedade de números em nenhuma ordem específica e com
muitas repetições. Em Python, um conjunto nunca contém dados repetidos. Conseqüentemente,
convertendo a lista de a para um conjunto e depois de volta para uma lista e, em seguida,
colocando essa lista em uma matriz, você obtém um vetor que não tem repetições.

70 PARTE 1 Introdução aos algoritmos


Machine Translated by Google

Obtendo os Resultados Desejados


Usando recursão
A recursão, um método elegante de resolver muitos problemas de computador, depende da
capacidade de uma função continuar chamando a si mesma até que satisfaça uma condição
específica. O termo recursão na verdade vem do verbo latino recurrere, que significa correr
de volta.

Quando você usa recursão, você resolve um problema chamando a mesma função várias
vezes, mas modificando os termos sob os quais você a chama. A principal razão para usar a
recursão é que ela fornece uma maneira mais fácil de resolver problemas ao trabalhar com
alguns algoritmos, pois imita a maneira como um humano o resolveria. Infelizmente, a
recursão não é uma ferramenta fácil porque requer algum esforço para entender como
construir uma rotina recursiva e pode causar problemas de falta de memória em seu
computador se você não definir algumas configurações de memória. As seções a seguir
detalham como a recursão funciona e fornecem um exemplo de como a recursão funciona
em Python.

Explicando a recursão
Muitas pessoas têm problemas ao usar a recursão porque não conseguem visualizar
facilmente como ela funciona. Na maioria dos casos, você chama uma função Python, ela faz
alguma coisa e depois para. No entanto, na recursão, você chama uma função Python, ela
faz algo e, em seguida, chama a si mesma repetidamente até que a tarefa atinja uma
condição específica - mas todas as chamadas anteriores ainda estão ativas. As chamadas se
desenrolam uma de cada vez até que a primeira chamada finalmente termine com a resposta
correta, e esse processo de desenrolamento é onde a maioria das pessoas encontra um
problema. A Figura 4-1 mostra a aparência da recursão ao usar um fluxograma.

Observe a condicional no centro. Para fazer a recursão funcionar, a função deve ter tal
condicional ou pode se tornar um loop sem fim. A condicional determina uma de duas coisas:

» As condições para encerrar a recursão não foram atendidas, então a função deve chamar a si
mesma novamente.

» As condições para encerrar a recursão foram atendidas, então a função retorna um


valor final que é usado para calcular o resultado final.

CAPÍTULO 4 Realizando manipulações essenciais de dados usando Python 71


Machine Translated by Google

FIGURA 4-1:
Na recursão
processo,
uma função

chama a si mesma
continuamente até que
atende a
uma condição.

Quando uma função chama a si mesma, ela não usa os mesmos argumentos que foram passados para
ela. Se ele usasse continuamente os mesmos argumentos, a condição nunca mudaria e a recursão nunca
terminaria. Conseqüentemente, a recursão requer que as chamadas subsequentes para a função alterem
os argumentos da chamada para aproximar a função de uma solução final.

Um dos exemplos mais comuns de recursão para todas as linguagens de programação é o cálculo de um
fatorial. Um fatorial é a multiplicação de uma série de números entre um ponto inicial e um ponto final em
que cada número da série é um a menos que o número anterior. Por exemplo, para calcular 5! (leia como
cinco fatorial), você múltiplo 5 * 2 * 1. O cálculo representa um exemplo simples e perfeito de recursão.
**4
Aqui está o código Python que você pode3usar para realizar o cálculo.

def fatorial(n):
print("fatorial chamado com n = ", str(n))
se n == 1 ou n == 0:

72 PARTE 1 Introdução aos algoritmos


Machine Translated by Google

print("Condição final atendida.")


retornar 1
senão:
return n * fatorial(n-1)

print(fatorial(5))

Aqui está a saída que você vê ao executar este exemplo:

fatorial chamado com n = 5


fatorial chamado com n = 4
fatorial chamado com n = 3
fatorial chamado com n = 2
fatorial chamado com n = 1
Condição final atendida.
120

O código atende à condição final quando n == 1. Cada chamada sucessiva para factorial()
usa factorial(n-1), que reduz o argumento inicial em 1.
A saída mostra cada chamada sucessiva para fatorial e o atendimento da condição final. O
resultado, 120, é igual a 5! (cinco fatorial).

É importante perceber que não existe apenas um método para usar a recursão para resolver
um problema. Como acontece com qualquer outra técnica de programação, você pode
encontrar todos os tipos de maneiras de realizar a mesma coisa. Por exemplo, aqui está outra
versão da recursão fatorial que usa menos linhas de código, mas efetivamente executa a
mesma tarefa:

def fatorial(n):
print("fatorial chamado com n = ", str(n))
se n > 1:
return n * fatorial(n-1)
print("Condição final atendida.")
retornar 1

print(fatorial(5))

Observe a diferença. Em vez de verificar a condição final, esta versão verifica a condição de
continuação. Enquanto n for maior que 1, o código continuará a fazer chamadas recursivas.
Mesmo que esse código seja mais curto que a versão anterior, também é menos claro porque
agora você deve pensar em qual condição encerrará a recursão.

CAPÍTULO 4 Realizando manipulações essenciais de dados usando Python 73


Machine Translated by Google

Eliminando a recursão de chamada de cauda

Muitas formas de recursão dependem de uma chamada de cauda. Na verdade, o primeiro exemplo
na seção anterior sim. Uma chamada de cauda ocorre sempre que a recursão faz uma chamada
para a função como a última coisa antes de retornar. Na seção anterior, a linha return n * fatorial(n-1)
é a chamada final.

As chamadas de cauda não são necessariamente ruins e representam a maneira pela qual a
maioria das pessoas escreve rotinas recursivas. No entanto, usar uma chamada de cauda força o
Python a acompanhar os valores de chamadas individuais até que a recursão retroceda. Cada
chamada consome memória. Em algum momento, o sistema ficará sem memória e a chamada
falhará, fazendo com que seu algoritmo falhe também. Dada a complexidade e os enormes
conjuntos de dados usados por alguns algoritmos hoje, as chamadas de cauda podem causar
problemas consideráveis para quem os usa.

Com um pouco de programação sofisticada, você pode potencialmente eliminar chamadas de


cauda de suas rotinas recursivas. Você pode encontrar uma série de técnicas realmente incríveis
online, como o uso de um trampolim, conforme explicado em https://blog.moertel.com/posts/2013-
06-12-recursion-to-iteration-4-trampolines.html. (Lembre-se, este não é o tipo de trampolim que você
pula em casa!) No entanto, a abordagem mais simples a ser adotada quando você deseja eliminar
a recursão é criar uma alternativa iterativa que execute a mesma tarefa. Por exemplo, aqui está
uma função fatorial() que usa iteração em vez de recursão para eliminar o potencial de problemas
de memória:

def fatorial(n):
print("fatorial chamado com n = ", str(n))
resultado = 1
enquanto n > 1:
resultado = resultado * n
n=n-1
print("O valor atual de n é ", str(n))
print("Condição final atendida.")
retornar resultado

print(fatorial(5))

A saída é muito semelhante a antes:

fatorial chamado com n = 5


O valor atual de n é 4
O valor atual de n é 3
O valor atual de n é 2
O valor atual de n é 1
Condição final atendida.
120

74 PARTE 1 Introdução aos algoritmos


Machine Translated by Google

O fluxo básico desta função é o mesmo da função recursiva. Um loop while substitui a chamada
recursiva, mas você ainda precisa verificar a mesma condição e continuar fazendo o loop até
que os dados atendam à condição. O resultado é o mesmo. No entanto, substituir recursão por
iteração não é trivial em alguns casos, conforme explorado no exemplo em https://
blog.moertel.com/posts/2013-06-03-recursion to-iteration-3.html.

Executando tarefas mais rapidamente


Obviamente, fazer as tarefas o mais rápido possível é sempre o ideal. No entanto, você sempre precisa
pesar cuidadosamente as técnicas que usa para alcançar essa eficiência.
Trocar um pouco de memória para executar uma tarefa mais rapidamente é ótimo, desde que você
tenha memória de sobra. Os capítulos posteriores do livro exploram todos os tipos de maneiras de
executar tarefas mais rapidamente, mas você pode tentar algumas técnicas essenciais,
independentemente do tipo de algoritmo com o qual esteja trabalhando em um determinado momento.
As seções a seguir exploram algumas dessas técnicas.

Considerando dividir e conquistar


Alguns problemas parecem esmagadores quando você os inicia. Tomemos, por exemplo, escrever
um livro. Se você considerar o livro inteiro, escrevê-lo é uma tarefa esmagadora.
No entanto, se você dividir o livro em capítulos e considerar apenas um capítulo, o problema parece
um pouco mais factível. É claro que um capítulo inteiro também pode parecer um pouco assustador,
então você divide a tarefa em títulos de primeiro nível, o que parece ainda mais factível, mas ainda não
o suficiente. Os títulos de primeiro nível podem conter títulos de segundo nível e assim por diante, até
que você tenha dividido o problema de escrever sobre um tópico em artigos curtos, tanto quanto
possível. É assim que funciona o dividir e conquistar. Você divide um problema em problemas menores
até encontrar um problema que possa resolver sem muita dificuldade.

Os computadores também podem usar a abordagem de dividir e conquistar. Tentar resolver um grande
problema com um enorme conjunto de dados pode levar dias – supondo que a tarefa seja factível. No
entanto, ao dividir o grande problema em partes menores, você pode resolvê-lo muito mais rapidamente
e com menos recursos. Por exemplo, ao pesquisar uma entrada em um banco de dados, a pesquisa
em todo o banco de dados não é necessária se você usar um banco de dados classificado. Digamos
que você esteja procurando a palavra Olá no banco de dados. Você pode começar dividindo o banco
de dados ao meio (letras A
a M e as letras N a Z). A letra H em Hello é menor que M no alfabeto, então você olha para a primeira
metade do banco de dados em vez da segunda. Dividindo a metade restante novamente (letras A a G
e letras H a M), você agora descobre que precisa da segunda metade do restante, que agora é apenas
um

CAPÍTULO 4 Realizando manipulações essenciais de dados usando Python 75


Machine Translated by Google

quarto do banco de dados. Outras divisões eventualmente ajudarão você a encontrar exatamente o
que deseja pesquisando apenas uma pequena fração de todo o banco de dados. Você chama essa
abordagem de pesquisa de pesquisa binária. O problema se torna uma das seguintes etapas:

1. Divida o conteúdo em questão ao meio.

2. Compare as chaves do conteúdo com o termo de pesquisa.

3. Escolha a metade que contém a chave.

4. Repita as etapas 1 a 3 até encontrar a chave.

A maioria dos problemas de divisão e conquista seguem uma abordagem semelhante, embora
algumas dessas abordagens se tornem bastante complicadas. Por exemplo, em vez de apenas
dividir o banco de dados ao meio, você pode dividi-lo em terços em alguns casos. No entanto, o
objetivo é o mesmo em todos os casos: divida o problema em uma parte menor e determine se
você pode resolver o problema usando apenas essa parte como um caso generalizado. Depois de
encontrar o caso generalizado que você sabe resolver, você pode usar essa peça para resolver
qualquer outra peça também. O código a seguir mostra uma versão extremamente simples de uma
pesquisa binária que pressupõe que você tenha a lista classificada.

def search(searchList, chave):


mid = int(len(searchList) / 2)
print("Pesquisando o ponto médio em ", str(searchList[mid]))

se médio == 0:
print("Chave não encontrada!")
chave de retorno

chave elif == searchList[mid]:


print("Chave encontrada!")
return searchList[mid]

chave elif > searchList[mid]:


print("searchList agora contém ",
searchList[mid:len(searchList)])
search(searchList[mid:len(searchList)], chave)
senão:
print("searchList agora contém ",
searchList[0:mid])
search(searchList[0:mid], chave)

aLista = lista(intervalo(1, 21))


search(aList, 5)

76 PARTE 1 Introdução aos algoritmos


Machine Translated by Google

Ao executar este código, você vê esta saída:

Pesquisando o ponto médio em 11


searchList agora contém [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
Pesquisando o ponto médio em 6
searchList agora contém [1, 2, 3, 4, 5]
Pesquisando o ponto médio em 3
searchList agora contém [3, 4, 5]
Pesquisando o ponto médio em 4
searchList agora contém [4, 5]
Pesquisando o ponto médio em 5
Chave encontrada!

Essa abordagem recursiva para a pesquisa binária começa com aList contendo os números
de 1 a 20. Ela procura um valor de 5 em aList. Cada iteração da recursão começa procurando
o ponto médio da lista, o meio e, em seguida, usando esse ponto médio para determinar a
próxima etapa. Quando a chave corresponde ao ponto médio, o valor é encontrado na lista
e a recursão termina.

Observe que este exemplo faz uma das duas chamadas recursivas. Quando a chave é
maior que o valor do ponto médio da lista existente, searchList[mid], o código chama search
novamente com apenas o lado direito da lista restante. Em outras palavras, cada chamada
para pesquisa usa apenas metade da lista encontrada na chamada anterior. Quando key é
menor ou igual a searchList[mid], search recebe a metade esquerda da lista existente.

A lista pode não conter um valor de pesquisa, portanto, você deve sempre fornecer um
método de escape para a recursão ou a pilha (uma área especial de memória usada para
armazenar as informações da chamada; consulte https://www.geeksforgeeks.org/stack-vs
-alocação de memória heap/ para detalhes) será preenchido, resultando em uma mensagem
de erro. Nesse caso, o escape ocorre quando mid == 0, o que significa que não há mais
searchList para pesquisar. Por exemplo, se você alterar search(aList, 5) para search(aList,
22), obterá a seguinte saída:

Pesquisando o ponto médio em 11


searchList agora contém [11, 12, 13, 14, 15, 16, 17, 18,
19, 20]
Pesquisando no ponto médio em 16
searchList agora contém [16, 17, 18, 19, 20]
Pesquisando o ponto médio em 18
searchList agora contém [18, 19, 20]
Pesquisando no ponto médio em 19
searchList agora contém [19, 20]
Pesquisando no ponto médio em 20

CAPÍTULO 4 Realizando manipulações essenciais de dados usando Python 77


Machine Translated by Google

searchList agora contém [20]


Pesquisando no ponto médio em 20
Chave não encontrada!

Observe também que o código procura a condição de escape antes de realizar qualquer outro
trabalho para garantir que o código não cause um erro inadvertidamente devido à falta de
conteúdo searchList . Ao trabalhar com recursão, você deve permanecer pró-ativo ou suportar
as consequências mais tarde.

Distinguir entre as diferentes


soluções possíveis
A recursão faz parte de muitas soluções de programação algorítmica diferentes, como você
verá nos próximos capítulos. Na verdade, é difícil evitar a recursão em muitos casos porque
uma abordagem iterativa se mostra não intuitiva, complicada e demorada. No entanto, você
pode criar várias versões diferentes da mesma solução, cada uma com suas próprias
características, falhas e virtudes.

A solução que este capítulo não considera é a pesquisa sequencial, porque uma pesquisa
sequencial geralmente leva mais tempo do que qualquer outra solução que você possa empregar.
No melhor cenário, uma pesquisa sequencial requer apenas uma comparação para concluir
a pesquisa, mas no pior cenário, você encontra o item desejado como a última verificação. Em
média, a pesquisa sequencial requer (n+1)/2 verificações ou O(n) tempo para ser concluída. (A
seção “Trabalhando com funções” do Capítulo 2 informa mais sobre a notação big-O.)

A pesquisa binária na seção anterior faz um trabalho muito melhor do que uma pesquisa
sequencial. Funciona em tempo logarítmico ou O(log n). Em um cenário de melhor caso, é
necessária apenas uma verificação, como em uma pesquisa sequencial, mas a saída do
exemplo mostra que mesmo um cenário de pior caso, em que o valor nem aparece na lista,
leva apenas seis verificações em vez das 21 verificações que uma busca sequencial exigiria.

No entanto, se você observar apenas os tempos de desempenho, os dados recebidos podem


levá-lo a pensar que uma solução específica funcionará incrivelmente bem para seu aplicativo
quando, na verdade, não funcionará. Você também deve considerar o tipo de dados com os
quais trabalha, a complexidade da criação da solução e uma série de outros fatores.
É por isso que os exemplos posteriores deste livro também consideram os prós e os contras
de cada abordagem — os perigos ocultos de escolher uma solução que parece ter potencial e
depois não produz o resultado desejado.

78 PARTE 1 Introdução aos algoritmos


Machine Translated by Google

NESTE CAPÍTULO

» Definindo por que você deseja criar sua


própria classe

» Montando a aula básica

» Executando alguns cálculos de


matriz padrão

» Usando a classe resultante

capítulo 5

Desenvolvendo uma Matriz


Classe de computação
porque essas bibliotecas realizam grande parte do trabalho de codificação. E se o código for
O uso de bibliotecas Python sem dúvida facilita a vida do desenvolvedor
devidamente verificados, os desenvolvedores têm menos bugs para se preocupar em
seu código. Portanto, pode ser difícil imaginar um cenário em que você não queira usar uma
biblioteca como NumPy (https://numpy.org/) para realizar manipulações de matrizes. Afinal,
geralmente está entre as dez principais bibliotecas Python (consulte https://towardsdatascience.com/
best-python-libraries-for-every-python-developer-77daab4fa40e). Mas mesmo que usar
o NumPy na maioria das situações seja benéfico, a primeira parte deste capítulo
discute por que você pode não querer usar o NumPy e fala sobre alguns benefícios de
criar sua própria classe para realizar pelo menos tarefas básicas de manipulação de matriz.
A próxima seção do capítulo o guia pelo processo de criação de uma classe básica
para trabalhar com matrizes, que inclui fundamentos como multiplicar uma matriz
por outra. Esta não será uma aula de qualidade comercial. Em vez disso, a ideia é
ajudá-lo a entender o que está envolvido na criação de tal classe. Uma classe real
provavelmente precisará fornecer mais funcionalidades, mas essa classe serve bem
para fins de demonstração.

Depois de ter uma classe básica para usar, você começa a adicionar recursos a ela
para manipular as matrizes que a classe cria. Por exemplo, você pode precisar saber como

CAPÍTULO 5 Desenvolvendo uma Classe de Computação Matriz 79


Machine Translated by Google

achate sua matriz para certos tipos de cálculo — e esse é o propósito da terceira seção do capítulo.
Novamente, ele não oferece nada que se aproxime da qualidade comercial, mas você pode ter uma
boa ideia do que precisa fazer para criar suas próprias extensões para a classe básica.

Você não precisa digitar o código-fonte para este capítulo manualmente. Na verdade, usar a fonte
para download é muito mais fácil. Você pode encontrar a fonte para este capítulo em \A4D2E\A4D2E;
05; Arquivo Matrix Class.ipynb da fonte para download. Consulte a Introdução para obter detalhes
sobre como localizar esse arquivo de origem.

Evitando o uso do NumPy


NumPy é uma biblioteca Python fenomenal com muita flexibilidade que oferece desempenho e
confiabilidade robustos. Claro, esses são todos os motivos para usar o NumPy e descartar quaisquer
pensamentos que você possa ter sobre a construção de sua própria classe.
No entanto, não há almoço grátis: assim como com qualquer outro auxílio ao desenvolvedor, o uso
do NumPy tem um preço. Se o custo de usar o NumPy em sua organização for muito alto, convém
revisar outras bibliotecas ou simplesmente criar sua própria. Aqui estão alguns problemas a serem
considerados ao trabalhar com o NumPy.

» O NumPy é usado por muitas pessoas, não apenas pela sua organização. Então um
quebrar a mudança no pacote NumPy que beneficia a maioria mais do que
prejudica a minoria sempre ocorrerá, de acordo com https://numpy.
org/neps/nep-0023-backwards-compatibility.html. Em outras palavras, algo tão
simples quanto uma atualização do NumPy pode fazer com que seu aplicativo falhe.

» Trabalhar com o NumPy é uma tarefa complexa. O Guia do Usuário tem 486 páginas
(https://numpy.org/doc/1.20/numpy-user.pdf) e não cobre todos os aspectos do uso do
NumPy (ou mesmo perto disso). Consequentemente, os desenvolvedores que usam o
NumPy enfrentam uma curva de aprendizado bastante íngreme para recursos que talvez
nem precisem.

» Fazer as alterações necessárias no NumPy pode ser difícil quando você considera
as diretrizes em https://numpy.org/doc/stable/dev/. Ou seja, para obter o tipo
de NumPy que sua organização precisa, você pode acabar passando por
obstáculos que nunca enfrentaria ao criar sua própria classe.

» Os detalhes de implementação subjacentes para NumPy exigem que você conheça C/


C++ além de Python. Por exemplo, estruturas NumPy como matrizes dependem de
arrays C++, em vez de listas duplamente vinculadas do Python. A vantagem dessa
abordagem é que o NumPy oferece um aumento significativo de velocidade, mas ao
custo de usar várias linguagens de programação. Usar uma abordagem nativa para
trabalhar com matrizes é mais lento, mas mais fácil de entender.

80 PARTE 1 Introdução aos algoritmos


Machine Translated by Google

» Usar NumPy é diferente de usar Python, o que pode ser um problema mesmo se a troca
for mais simples. Por exemplo, se você tiver duas matrizes de linha e quiser adicioná-
las, o NumPy permite que você faça isso usando uma única adição como esta: A + B.
O mesmo código em Python produz uma concatenação, o que significa que as duas
matrizes de linha são unidas. Para adicionar duas matrizes usando Python, você
pode usar uma solução de compreensão de lista, como [a + b for a, b in zip(A, B)].
Sim, a abordagem do Python é mais complicada, mas também é Python puro.

» NumPy usa valores que o Python não entende. O mais comum


reclamação fornece um valor chamado NaN, ou Not-a-Number, para representar
valores ausentes. A falta de suporte para NaN em Python torna difícil comparar valores,
então agora você está preso a encontrar uma solução NumPy para o que deveria ser
um problema Python (veja https://medium.com/nerd-for-tech/az -with-numpy
library-6269de9c5413 para detalhes).

» Usar um intervalo contíguo de memória para armazenar dados no NumPy pode


significar que o NumPy será realmente mais lento se o código Python executar
muitas inserções e exclusões porque os dados precisarão ser deslocados. A
mesma coisa que torna o NumPy um demônio da velocidade também pode ser
sua ruína nas circunstâncias certas (consulte https://towardsdatascience.
com/python-lists-are-sometimes-muito-faster-than-numpy-heres a-
proof-4b3dad4653ad para informações adicionais).

Existem outras considerações a serem feitas com relação ao uso do Python nativo
sobre o NumPy, mas esta lista fornece uma boa visão geral do que você deve pensar.
No entanto, também seria pouco útil fazer uma generalização e dizer que você deve
sempre usar Python ou sempre criar sua própria classe de matriz. Ao tomar decisões
de desenvolvimento, você precisa considerar o aplicativo como um todo e determinar
quais compensações fazem sentido em uma determinada situação.

Entendendo por que usar uma classe é


Importante
Há muitas razões para usar classes em Python. Muitas dessas razões se concentram
em técnicas de Programação Orientada a Objetos (OOP), como:

» Encapsulamento
» Herança

» Polimorfismo

CAPÍTULO 5 Desenvolvendo uma Classe de Computação Matriz 81


Machine Translated by Google

Você pode ler sobre esses motivos em artigos como https://www.programiz.com/


programação python/programação orientada a objetos e https://realpython.
com/python3-object-oriented-programming/. No entanto, esses motivos não cobrem
realmente por que você deseja criar classes para sua biblioteca de matrizes; especialmente
se você usar técnicas de programação funcional (consulte https://www.geeksforgeeks.
org/functional-programming-in-python/ como um exemplo). A necessidade de uma aula
vai além de usar um paradigma de programação específico ou a desculpa usual de
“parece uma boa ideia” que as pessoas usam sem ter a menor ideia do que querem dizer
com essa frase. Então, aqui estão alguns motivos para usar uma classe (ou classes) para
manter suas rotinas de matriz:

» Organização: As rotinas matriciais podem se tornar bastante complexas por si só, sem que
você precise lembrar onde as colocou. O uso de classes ajuda a organizar as rotinas,
independentemente do paradigma empregado para criá-las.

» Facilidade de acesso: Colocar suas rotinas de matrizes em um único arquivo significa que
você pode acessar todas elas com uma única importação. No entanto, se essa importação
for imensa, agora você dedicou recursos substanciais para usar potencialmente apenas
um ou dois recursos. Usando classes para manter suas rotinas de matriz, você pode dividir
seu pacote em partes gerenciáveis e apenas importar a parte que você precisa.

» Desenvolvimento mais fácil: Criar suas próprias rotinas de matriz exigirá


habilidades avançadas de programação. Você pode facilmente ficar sobrecarregado com a
complexidade do problema. Ao usar a abordagem de dividir e conquistar, você pode dividir
os problemas que precisa resolver em partes menores, tornando o pacote inteiro mais fácil
de desenvolver, pois você precisa lidar com apenas uma parte de cada vez.

» Eliminação de colisão: o uso de uma classe reúne tudo em um


cobertura. Mais importante, você acessa os métodos dentro da classe usando o mesmo
nome de classe, então é menos provável que você encontre qualquer tipo de problema de
colisão de nomes ao trabalhar com outros pacotes.

» Coesividade: Reunir todo o código em uma única classe tende a


promova uma abordagem coesa (unida) à codificação para que você não use uma
abordagem para uma parte do pacote e uma abordagem diferente para outras partes do
pacote.

Construindo a classe básica


Uma classe ou grupo de classes que lidam com manipulação de matrizes se tornará
bastante complicada em algum momento se você quiser criar um pacote totalmente
funcional e um tanto genérico. O nível de complexidade dependerá muito de como você planeja

82 PARTE 1 Introdução aos algoritmos


Machine Translated by Google

usar o pacote. O NumPy é tão complexo porque deve atender às necessidades de um grande grupo
de pessoas de maneira genérica. Sua aula pode ser mais simples porque você conhece suas
necessidades específicas. Mesmo assim, as seções a seguir o ajudam a abordar os elementos básicos
que provavelmente aparecerão em qualquer classe de matriz.

A classe Matrix foi escrita com simplicidade em mente para que você possa ver mais facilmente as
operações que estão ocorrendo. Conseqüentemente, ele não inclui código de verificação de tipo,
verificações de tamanho de matriz ou qualquer interceptação de erros. Uma classe usada no mundo
real conteria todos esses elementos (e mais) para garantir que a classe funcione com poucos erros.
Lembre-se da falta desses recursos ao trabalhar com o Matrix
classe verificando se a entrada está correta.

Criando uma matriz


A classe Matrix de exemplo começa simplesmente fornecendo código para criar uma matriz
bidimensional. Você constrói essa classe à medida que o capítulo avança, mas é importante começar
com uma boa base. O código a seguir ajuda você a criar matrizes bidimensionais (as mais comumente
usadas) que não replicam a maneira NumPy de fazer as coisas. (Na verdade, a classe Matrix mostra
métodos alternativos para a maneira NumPy de fazer as coisas ao longo do capítulo.)

Matriz de classe:
linhas = 0
colunas = 0
matriz = []
matrizLinha = []
dataCount = 0
listamatriz = []
tempProduto = 0

def __init__(self, Rows, Columns, Data = []):


se Dados == []:
Dados = [Nenhum] * (Linhas * Colunas)
self.matrix = []
self.rows = Linhas
self.columns = Colunas
para i no intervalo (linhas):
self.matrixRow = []
para j no intervalo (Colunas):
self.matrixRow.append(
Dados[self.dataCount])
self.dataCount += 1
self.matrix.append(self.matrixRow)

CAPÍTULO 5 Desenvolvendo uma Classe de Computação Matriz 83


Machine Translated by Google

O exemplo disponibiliza várias variáveis usadas na construção, manutenção e


manipulação das matrizes para visualização e uso dentro do código de suporte. Essas
variáveis aparecem no topo da definição de classe para que sejam fáceis de encontrar. A
função __init__() (o construtor de classe) usada para criar uma matriz não usará todas
essas variáveis, mas você as verá usadas com funções posteriores.

O construtor Matrix permite a criação de matrizes em branco de um determinado tamanho,


ou matrizes que já possuem valores inicializados usando uma lista Python. Ao criar uma
matriz em branco, o código preenche os valores com Nenhum para que seja fácil verificar
se a matriz foi inicializada.

Para criar uma matriz, o código define linhas individuais e, em seguida, anexa essas
linhas à matriz resultante. Cada coluna em uma linha contém um valor da lista Dados . A
variável dataCount rastreia o local atual na lista de dados .

Imprimindo a matriz resultante


Esta seção mostra como a classe Matrix funciona criando e imprimindo uma matriz. O
código a seguir cria e imprime uma matriz 2 x 3 que não foi inicializada:

minhaMatriz = Matriz(2, 3)
print(minhaMatriz.linhas)
print(minhaMatriz.colunas)
print(minhaMatriz.matriz)

A saída mostra que a propriedade rows contém 2 e a propriedade columns contém 3


conforme o esperado. A matriz resultante contém o valor Nenhum para cada valor na
saída bidimensional:

2
3
[[Nenhum, Nenhum, Nenhum], [Nenhum, Nenhum, Nenhum]]

O código a seguir cria uma matriz 2-x-3 inicializada contendo os valores 0


a 5. Usar a função range() facilita a geração de valores para teste:

z = lista(intervalo(6))
imprimir(z)
minhaMatriz2 = Matriz(2, 3, z)
print(minhaMatriz2.matriz)

84 PARTE 1 Introdução aos algoritmos


Machine Translated by Google

A saída a seguir mostra como a função __init__() funciona. A função orientada a linhas pega
cada membro da lista de entrada e cria uma linha de matriz a partir dele. Em vez disso, você
pode criar uma classe orientada a colunas, com base em suas necessidades específicas.
Para criar essa mesma matriz usando NumPy, você precisa importar NumPy e, em seguida,
especificar a matriz usando um código como este: myMatrix = np.array([[1, 2, 3], [4, 5, 6]]),
que já tem a forma.

[0, 1, 2, 3, 4, 5]
[[0, 1, 2], [3, 4, 5]]

Embora o NumPy não forneça um método direto de especificar a forma da matriz durante a
inicialização, ele fornece a função reshape() para modificar a forma da matriz após a
inicialização. A classe Matrix fornece matrizes de tamanho fixo. Não há uma melhor maneira
de realizar tarefas, simplesmente a maneira que funciona melhor em uma situação específica,
portanto, assumir que o método usado por uma biblioteca Python específica é sempre o
melhor geralmente é uma má ideia.

Acessando elementos de matriz específicos


É importante fornecer um método para acessar elementos de matriz específicos em sua
classe. A melhor maneira de realizar esta tarefa é criar uma função __getitem__() semelhante
à mostrada aqui:

def __getitem__(self, index):


return self.matrix[index]

A função __getitem__() é apenas um de uma longa lista de nomes de métodos especiais


disponíveis para uso em sua classe. (Certifique-se de adicionar __getitem__() e as funções
que seguem nas próximas seções diretamente à classe Matrix e não em um nível superior.)
Você pode ler mais sobre esses métodos especiais em https://docs.
python.org/3/reference/datamodel.html#special-method-names. Esses métodos especiais
fornecem um método específico do Python para executar tarefas como sobrecarga de
operadores, portanto, você deve usá-los sempre que possível na criação de sua classe.

No caso de __getitem__() (https://docs.python.org/3/reference/datamodel.


html#object.__getitem__), o código fornece um método de fatiar a matriz. Tudo que você
precisa fazer é retornar a parte da matriz referenciada pelo índice. O índice pode ser
qualquer fatia aceitável do Python, conforme mostrado no código a seguir:

print(minhaMatriz2[1])
print(minhaMatriz2[1][2])

CAPÍTULO 5 Desenvolvendo uma Classe de Computação Matriz 85


Machine Translated by Google

A saída demonstra que Python interpreta o índice apropriadamente dependendo do


que o usuário fornece como entrada (o primeiro índice é uma linha e o segundo fornece
linha e coluna):

[3, 4, 5]
5

Executando adição escalar e de matriz


Além da necessidade básica de criar, imprimir e fatiar a matriz, também é importante
realizar algumas manipulações essenciais, como adição. Não deve ser muito
surpreendente que você use o método especial __add__() para realizar esta tarefa,
como mostrado aqui.

def __add__(self, Valor):


self.matrixList = []
if tipo(Valor) == lista:
para i em range(self.rows):
para j no intervalo (self.columns):
self.matrixList.append(
self.matrix[i][j] + Valor[i][j])

senão:
para i em range(self.rows):
para j no intervalo (self.columns):
self.matrixList.append(
self.matrix[i][j] + Valor)
return Matrix(self.rows, self.columns,
self.matrixList)

O código cobre dois casos. O primeiro caso é que Value contém uma matriz, então o
código precisa adicionar uma matriz a outra matriz. O segundo caso é que Valor
contém um inteiro que é adicionado a cada membro da matriz. Independentemente da
forma de entrada fornecida pelo usuário, os valores são somados usando matrixList,
que é uma variável de lista interna.

Em algum momento, matrixList contém uma lista dos valores corretos. O código então
cria uma nova Matrix do tamanho correto e a retorna ao chamador. Essa abordagem
garante que o objeto retornado seja do tipo Matrix, e não do tipo list, o que seria o caso
se a função não executasse esta última etapa.

A função __add__() funciona tanto para adição escalar quanto de matriz, conforme
demonstrado no código a seguir.

86 PARTE 1 Introdução aos algoritmos


Machine Translated by Google

minhaMatriz2 += 2
print(minhaMatriz2.matriz)

minhaMatriz2 += [[2, 4, 6], [8, 10, 12]]


print(minhaMatriz2.matriz)

A saída mostra os valores de adição corretos. (Você pode verificar as saídas usando
NumPy, que usa a mesma técnica de adição que a classe Matrix .)

[[2, 3, 4], [5, 6, 7]]


[[4, 7, 10], [13, 16, 19]]

Fazendo multiplicação
Qualquer classe de manipulação de matriz utilizável também precisará suportar dois
tipos de multiplicação. O primeiro é o produto elemento a elemento, que simplesmente
realiza a multiplicação elemento a elemento. O segundo é um produto escalar, que é
frequentemente usado para álgebra linear. As seções a seguir mostram como realizar
os dois tipos de multiplicação usando a classe Matrix .

Produto com elementos


A abordagem do produto elementar é usada quando você deseja combinar duas
matrizes por meio da multiplicação, em vez de alguma outra operação, como adição.
Por exemplo, você pode ter uma matriz que mostra o número de horas trabalhadas
por dia e uma segunda matriz que mostra o salário por hora para cada dia trabalhado.
O uso do produto elementar criaria uma terceira matriz contendo o salário total para
cada dia trabalhado. Para criar este resultado, você usa o __mul__
() função, mostrada aqui:

def __mul__(self, MatrixIn):


self.matrixList = []
para i em range(self.rows):
para j no intervalo (self.columns):
self.matrixList.append(
self.matrix[i][j] * MatrixIn[i][j])
return Matrix(self.rows, self.columns,
self.matrixList)

O código simplesmente multiplica os elementos de uma matriz pelos mesmos


elementos da segunda matriz. Para fazer isso, ambas as matrizes precisam ter o
mesmo tamanho para a classe Matrix . Ao trabalhar com NumPy, você tem a opção de realizar um

CAPÍTULO 5 Desenvolvendo uma Classe de Computação Matriz 87


Machine Translated by Google

multiplicação escalar ou vetorial. Obviamente, você poderia adicionar mais código para tornar
isso possível na classe Matrix também. Aqui está a versão da classe Matrix em uso:

A = Matriz(2, 3, [1, 2, 3, 4, 5, 6])


B = Matriz(2, 3, [1, 2, 3, 4, 5, 6])
print(A.matriz)
print(B.matriz)
print((A * B).matriz)

Observe que você usa o operador * ao obter um produto interno com a classe Matrix . O mesmo
vale para NumPy: você usa o * para obter um produto interno. Aqui está a saída da multiplicação:

[[1, 2, 3], [4, 5, 6]]


[[1, 2, 3], [4, 5, 6]]
[[1, 4, 9], [16, 25, 36]]

Como mostrado, a multiplicação é direta neste caso. Por exemplo, A[0][1]


* B[0][1] equivale a 2 * 2, que aparece como 4 na saída.

Produto escalar
Muitas pessoas têm dificuldade em entender por que o produto escalar é útil, muito menos como
fazer isso acontecer. O produto escalar é útil ao realizar tarefas como tentar calcular as vendas
totais. Comece com um vetor chamado Price que contém os preços de três tipos de frutas:

Maçãs Cerejas Peras

1 2 1

Uma matriz, Vendas, tem o número de libras de cada item vendido a cada dia, conforme mostrado
aqui:

segunda-feira terça-feira quarta-feira quinta-feira sexta-feira

Maçãs 5 3 4 3 2

Cerejas 2 3 3 4 4

Peras 1 2 4 2 3

88 PARTE 1 Introdução aos algoritmos


Machine Translated by Google

As diferentes orientações do vetor para a matriz são importantes no cálculo do produto escalar.
Para obter o total de vendas, multiplique o vetor pelas colunas da tabela. Por exemplo, para
encontrar o total de segunda-feira, você executa a matemática assim: (1 * 5) + (2 * 2) + (1 * 1) ou
$ 10. A saída para todos os dias seria algo assim.

[[10, 11, 14, 13, 13]]

Trabalhar com uma matriz é um pouco mais complexo. Digamos que você tenha preços de matriz
que contém os preços de três tipos de frutas para cada dia de uma semana como este.

Maçãs Cerejas Peras

Segunda-feira 1 2 1

Terça-feira 1 3 2

Quarta-feira 2 2 1

Quinta-feira 2 3 2

Sexta-feira 1 2 2

Assim, na segunda-feira, as maçãs eram vendidas a US$ 1 o quilo, as cerejas a US$ 2 o quilo e
as peras a US$ 1 o quilo. O valor das vendas muda por dia da semana, então não se pode dizer
que o valor de cada produto é constante. A saída de tal cálculo seria algo assim:

[[10, 11, 14, 13, 13],


[13, 16, 21, 19, 20],
[15, 14, 18, 16, 15],
[18, 19, 25, 22, 22],
[11, 13, 18, 15, 16]]

Os valores de saída agora aparecem em uma diagonal, então a segunda saída apareceria como
o valor 16, ou (1 * 3) + (3 * 3) + (2 * 2), que são os valores de terça-feira em cada matriz. O total
de quarta-feira é 18, o de quinta-feira é 22 e o de sexta-feira é 16. É assim que o NumPy faz as
coisas. Sua classe personalizada pode gerar apenas os valores diagonais, para que ninguém
precise interpretar nada.

O código para realizar esse cálculo deve ser generalizado para aceitar um vetor ou uma matriz
como entrada, ficando assim:

def ponto(self, MatrixIn):


self.matrixList = []
para i em range(self.rows):

CAPÍTULO 5 Desenvolvendo uma Classe de Computação Matriz 89


Machine Translated by Google

para j no intervalo (MatrixIn.columns):


tempProduto = 0
para k no intervalo (self.columns):
tempProduct += self.matrix[i][k] * \
MatrixIn[k][j]
self.matrixList.append(tempProduct)
return Matrix(self.rows, MatrixIn.columns,
self.matrixList)

A funcionalidade de fatiamento fornecida pelo Python lida automaticamente com as diferenças


entre multiplicação de vetores e matrizes para você. No entanto, o número de colunas da primeira
entidade (vetor ou matriz) deve sempre corresponder ao número de linhas da segunda entidade. O
código de teste para este exemplo se parece com isso para um vetor:

Preço = Matriz(1, 3, [1, 2, 1])


Vendas = Matriz(3, 5,
[5, 3, 4, 3, 2, 2, 3, 3, 4, 4, 1, 2, 4, 2, 3])
print(Preço.matriz)
print(Vendas.matriz)
print(Preço.ponto(Vendas).matriz)

O código de teste de matriz se parece com isso:

Preços = Matriz(5, 3,
[1, 2, 1, 1, 3, 2, 2, 2, 1, 2, 3, 2, 1, 2, 2])
print(Preços.matriz)
print(Preços.ponto(Vendas).matriz)

Em ambos os casos, a saída é exatamente a mesma que você receberia do NumPy.

Manipulando a Matriz
Muitas maneiras estão disponíveis para manipular uma matriz, dependendo do resultado que você
precisa. Na verdade, alguns desses métodos aparecem ao longo deste livro. Algumas manipulações
são mais comuns do que outras, no entanto. As seções a seguir revisam três dessas manipulações:
transposição, cálculo de um determinante e achatamento.

90 PARTE 1 Introdução aos algoritmos


Machine Translated by Google

Transpondo uma matriz


A seção “Produto escalar”, anteriormente neste capítulo, analisa como obter o produto
escalar de duas matrizes. Essa seção analisa como obter as vendas totais de frutas,
mas seria difícil superestimar os usos do produto escalar. Para obter o produto escalar,
as duas matrizes devem ser orientadas de uma maneira específica. No entanto, você
pode descobrir que sua matriz não está orientada da maneira correta, e é aí que a
transposição entra em ação. Esta seção analisa uma transposição simples na qual as
linhas se tornam colunas. Para executar esta tarefa, você usa o seguinte código:

def transpor(auto):
self.matrixList = []
for i in range(self.columns):
para j no intervalo(self.rows):
self.matrixList.append(self.matrix[j][i])
return Matrix(self.columns, self.rows,
self.matrixList)

Essencialmente, o processo envolve copiar a matriz, mas de uma maneira que inverte
linhas e colunas. A matrixList agora contém os valores na ordem das colunas em vez da
ordem das linhas (a ordem das linhas é normal). Para criar a saída correta, a chamada
para Matrix() também deve inverter colunas e linhas para que a forma da matriz resultante
reflita a nova ordem. O código de teste para este exemplo é assim:

print(A.matriz)
print(A.transpose().matrix)

A saída reflete a mudança na orientação:

[[1, 2, 3], [4, 5, 6]]


[[1, 4], [2, 5], [3, 6]]

Calculando o determinante
As seções a seguir discutem como criar código para calcular um determinante (que se
aplica apenas a matrizes quadradas). Claro, você também pode precisar de alguns
insights sobre o que é um determinante e por que você deseja calculá-lo, então a próxima
seção abrange esses detalhes também.

Por que a matriz é tão determinada?


O determinante de uma matriz é um número especial que é usado em todos os
tipos de aplicações, como o cálculo da inversa de uma matriz (https://www.mathsisfun.
com/algebra/matrix-inverse-minors-cofactors-adjugate.html). O inverso

CAPÍTULO 5 Desenvolvendo uma Classe de Computação Matriz 91


Machine Translated by Google

de uma matriz é o conjunto de valores que, quando multiplicados pela matriz original,
resulta em uma matriz identidade. Uau, isso parece muito balbucio, então aqui está
uma versão simplificada: Se você tem o valor 10 e calcula o inverso de 10, que é 1/10,
então multiplicar 10 * 1/10
valor
resulta
1 para
emmatrizes.
uma saídaSe de
você
1. está
A matriz
realmente
identidade
entusiasmado
é como o
em aprender mais sobre inversas e a matriz de identidade, você pode encontrar um
excelente artigo em https://
www.mathsisfun.com/algebra/matrix-inverse.html. A questão é que às vezes você precisa da
inversa de uma matriz para realizar tarefas específicas, o que significa encontrar o determinante.

Você tem muitas maneiras de encontrar o determinante de uma matriz. Este exemplo usa a
expansão Laplace, que foi originalmente criada por Pierre-Simon Laplace (https://
www.britannica.com/biography/Pierre-Simon-marquis-de Laplace), porque essa abordagem se
presta muito facilmente ao uso de recursão.
Esta não é a maneira mais rápida de realizar o cálculo, mas é a mais fácil de entender.
Se você quiser ver uma das alternativas, confira o método de atalho explicado em
https://www.studypug.com/algebra-help/
o-determinante-de-um-3-x-3-matriz-geral-e-método de atalho.

Esta é uma daquelas situações em que você deseja ter uma calculadora especial à
mão para verificar a saída do seu código. O site Matrix Reshish em https://matrix.
reshish.com/determinant.php fornece calculadoras para calcular o determinante de uma matriz,
juntamente com todos os tipos de outras coisas úteis, como uma transposição e classificação
de matrizes. A calculadora Symbolab em https://www.symbolab.com/
solver/matriz-determinante-calculadora é útil porque mostra como resolver o problema
determinante passo a passo, o que pode ajudá-lo na hora de escrever seu código. Ele também
mostra muitos outros métodos de trabalho com matrizes e as soluções para problemas que
você pode encontrar.

Criando algum código de pré-requisito


O código para este exemplo começa com uma função copyMatrix() que permite copiar
parte de uma matriz em uma nova matriz usando técnicas de fatiamento. A razão pela
qual você precisa copiar partes de uma matriz é que a expansão de Laplace reduz a
tarefa de calcular o determinante a um caso simples; uma matriz 2-x-2.
Consequentemente, o código requer algum método para obter a matriz 2-x-2
necessária, que é o objetivo do código copyMatrix() , mostrado aqui:

def copyMatrix(self):
para i em range(self.rows):
para j no intervalo (self.columns):
self.matrixList.append(self.matrix[i][j])
return Matrix(self.rows, self.columns,
self.matrixList)

92 PARTE 1 Introdução aos algoritmos


Machine Translated by Google

Como mostrado, o código simplesmente cria uma nova matriz que é exatamente igual à
matriz de entrada. A razão pela qual você deve adotar essa abordagem é que copiar a
matriz de qualquer outra forma pode resultar em duas variáveis que apontam para a
mesma memória. Modificar uma variável necessariamente modificaria também o conteúdo
da outra variável. A função copyMatrix() garante que a nova matriz realmente aponte
para uma nova memória.

Fazendo o cálculo
Para calcular o determinante de uma matriz 2-x-2, você multiplica as diagonais e depois
subtrai a primeira multiplicação da segunda. Por exemplo, você pode começar com uma
matriz que se parece com isso:

[[1, 2],
[3, 4]]

Nesse caso, o determinante é (1 * 4) – (3 * 2) ou 4 – 6 para uma saída de –2. Mantenha


a recursão em mente agora enquanto observa o cálculo para a seguinte matriz 3-x-3:

[[ 2, 5, 1],
[5, 6, 7],
[10, 9, 8]]

O que você realmente vê aqui são três matrizes 2-x-2 que podem ser divididas assim:

1. 2 * ((6 * *
8) – (9 7)) = 2 * (48 – 63) = –30
2. 5 * ((5 * *
8) – (10 7)) = 5 * (40 – 70) = –150
3. 1 * ((5 * *
9) – (10 6)) = 1 * (45 – 60) = –15

Para obter o resultado final, você executa a matemática assim:

-30 - -150 + -15 = 105

A última parte pode parecer um pouco complicada porque não é fácil perceber se você
adiciona ou subtrai os resultados da submatriz 2-x-2. Na verdade, você apenas alterna
entre adição e subtração, sempre começando com a subtração, como mostrado. O
conceito mais importante a ser retirado neste caso é que você sempre multiplica a linha
superior pela submatriz 2-x-2. Trabalhar com uma matriz 4 x 4 é simplesmente uma
extensão desse princípio. Para realizar esta tarefa, a classe contém o seguinte código:

def determinante(self, Result=0):


# Aborde primeiro o caso mais simples, a matriz 2 X 2.

CAPÍTULO 5 Desenvolvendo uma Classe de Computação Matriz 93


Machine Translated by Google

if len(self.matrix) == 2:
twoOut = self.matrix[0][0] * self.matrix[1][1] - \ self.matrix[1][0] *
self.matrix[0][1]
retornar doisOut

# Determina o número de linhas em uma matriz maior


# do que 2 X 2.

linhas = list(range(len(self.matrix)))

# Processe cada coluna de foco por vez.


para foco em linhas:

# Crie uma cópia da matriz. submatriz =


self.copyMatrix()

# Remova a primeira linha da submatriz.

submatriz.matriz = submatriz.matriz[1:]

# Obtém o número de linhas restantes para


# processo.
sublinhas = len(submatriz.matriz)

# Crie a próxima matriz de tamanho menor fatiando


# as linhas de foco.
para i no intervalo (sublinhas):
submatrix.matrix[i] = \
submatrix.matrix[i][0:focus] + \ submatrix.matrix[i]
[focus+1:]

# Determine o sinal a ser usado ao realizar a # multiplicação. sinal


= (-1) ** (foco % 2)

# Chama a função determinante() recursivamente


# com cada matriz menor.
subdeterminante = submatriz.determinante()

# Totaliza os retornos das chamadas recursivas.


Resultado += sinal * self.matrix[0][focus] * \
subdeterminante

retornar resultado

94 PARTE 1 Introdução aos algoritmos


Machine Translated by Google

O código tem duas partes. O primeiro é o caso simples da matriz 2-x-2 que segue o processo
descrito anteriormente.

A segunda parte começa criando uma cópia da matriz atual na submatriz.


O código então obtém apenas a linha superior na submatriz. Em seguida, ele corta a próxima
matriz de tamanho menor. Portanto, se você estiver trabalhando atualmente com uma matriz
3-x-3, o código cria três matrizes 2-x-2 conforme descrito anteriormente. O código então
determina se deve adicionar ou subtrair cada um dos cálculos da submatriz e chama
determinante() recursivamente. A etapa final é adicionar ou subtrair cada um dos resultados
da chamada recursiva.

achatando a matriz
Inúmeros algoritmos exigem que você nivele uma matriz (essencialmente transforme-a em
uma lista) para obter o resultado desejado. Você também pode achatar uma matriz para ver
o resultado de um algoritmo com mais clareza ou para usá-lo de uma maneira específica.
Curiosamente, essa tarefa em particular também vê muita atividade para entrevistas de
emprego. Não importa o motivo que você tenha para achatar uma matriz, você tem muitas
maneiras de realizar essa tarefa e pode ver discussões sobre elas online. Aqui está a técnica
usada para a classe Matrix :

def achatar (auto):


self.matrixList = []
para i em range(self.rows):
para j no intervalo (self.columns):
self.matrixList.append(self.matrix[i][j])
nestedResult = Matrix(1, self.rows * self.columns,
self.matrixList)
nestedResult.matrix = nestedResult.matrix[0]
retornar nestedResult

Como acontece com outros elementos da classe, essa técnica não garante o resultado mais
rápido, mas fornece um método direto de resolver o problema. O código simplesmente cria
uma matrixList que anexa cada um dos elementos da matriz um após o outro. No entanto, o
resultado desse loop aninhado é uma lista, não uma Matrix, então o próximo passo é criar
uma Matrix contendo uma lista unidimensional.

CAPÍTULO 5 Desenvolvendo uma Classe de Computação Matriz 95


Machine Translated by Google

O problema com a saída neste ponto é que a Matrix resultante realmente contém
uma lista aninhada em outra lista. Para corrigir o resultado, o código torna
nestedResult.matrix igual a nestedResult.matrix[0]. O resultado é agora uma matriz
verdadeiramente achatada do tipo Matrix. Aqui está o código de teste para este exemplo:

A = Matriz(3, 3, [1, 2, 3, 4, 5, 6, 7, 8, 9])


print(A.matriz)
print(A.achatar().matriz)

A saída mostra que a matriz original é realmente achatada na saída:

[[1, 2, 3], [4, 5, 6], [7, 8, 9]]


[1, 2, 3, 4, 5, 6, 7, 8, 9]

96 PARTE 1 Começando com Algoritmos


Machine Translated by Google

2 Compreensão
a necessidade de classificar
e Pesquisar
Machine Translated by Google

NESTA PARTE . . .

Empilhamento e empilhamento de dados

Interagindo com árvores e gráficos

Usando classificação por mesclagem e classificação rápida

Confiando em árvores de pesquisa e no heap

Dados de hash
Machine Translated by Google

NESTE CAPÍTULO

» Definindo por que os dados exigem estrutura

» Trabalhando com pilhas, filas, listas e


dicionários

» Usando árvores para organizar dados

» Usando gráficos para representar dados com


relações

Capítulo 6

Estruturando dados
você pode fazer qualquer coisa com a maioria dos dados, você deve estruturá-lo de alguma maneira
Dados brutos sãovocê
para que apenas isso:
possa brutos.aNão
começar ver éo estruturado
que os dadosou contêm
limpo de(e,
forma alguma.
às vezes, Antes
o que nãoda
contém). A primeira parte deste capítulo discute a necessidade de transformar dados brutos
em dados estruturados.

Python fornece acesso a várias estruturas organizacionais para dados. O livro


usa essas estruturas, especialmente pilhas, filas e dicionários, para muitos dos
exemplos. Cada estrutura de dados fornece um meio diferente de trabalhar com
os dados e um conjunto diferente de ferramentas para executar tarefas como
classificar os dados em uma ordem específica. Este capítulo apresenta os
métodos organizacionais mais comuns, incluindo árvores e gráficos (ambos tão
importantes que aparecem em suas próprias seções).

Você não precisa digitar o código-fonte para este capítulo manualmente. Na verdade,
usar a fonte para download é muito mais fácil. Você pode encontrar a fonte deste
capítulo no A4D2E; 06; Graphs.ipynb, A4D2E; 06; Remediação.ipynb, A4D2E; 06;
Pilhas, Filas e Dicionários.ipynb e A4D2E; 06; Arquivos Trees.ipynb da fonte para
download. Consulte a Introdução para obter detalhes sobre como localizar esses
arquivos de origem.

CAPÍTULO 6 Estruturando Dados 99


Machine Translated by Google

Determinando a Necessidade de Estrutura


Estruturar dados envolve organizá-los de alguma forma para que todos os dados tenham os
mesmos atributos, aparência e componentes. Por exemplo, você pode obter dados de uma fonte
que contém datas em formato de string e outra fonte que usa objetos de data. Para usar as
informações, você deve fazer com que os tipos de dados correspondam. As fontes de dados
também podem estruturar os dados de forma diferente. Uma fonte pode ter o sobrenome e o
nome em um único campo; outra fonte pode usar campos individuais para a mesma informação.
Uma parte importante da estruturação de dados é a organização. Você não está alterando os
dados de forma alguma — simplesmente tornando os dados mais úteis.
(A estruturação de dados contrasta com a correção ou modelagem dos dados, em que às vezes
você altera valores para converter um tipo de dados em outro ou experimenta uma perda de
precisão, como com datas, ao mover entre fontes de dados.)

A estrutura é um elemento essencial para fazer os algoritmos funcionarem. Conforme mostrado


no exemplo de pesquisa binária no Capítulo 4, implementar um algoritmo usando dados
estruturados é muito mais fácil do que tentar descobrir como interpretar os dados em código. Por
exemplo, o exemplo de pesquisa binária depende de ter os dados em ordem de classificação.
Tentar realizar as comparações necessárias com dados não classificados exigiria muito mais
esforço e poderia até ser impossível de implementar. Com tudo isso em mente, você precisa
considerar os requisitos estruturais para os dados que você usa com seus algoritmos, conforme
discutido nas seções a seguir.

Facilitando a visualização do conteúdo


Para trabalhar com dados, é essencial entender o conteúdo dos dados. Um algoritmo de
pesquisa funciona apenas quando você entende o conjunto de dados para saber o que pesquisar
usando o algoritmo. Procurar palavras quando o conjunto de dados contém números é uma
tarefa impossível que sempre resulta em erros. No entanto, erros de pesquisa resultantes da não
compreensão do conteúdo do conjunto de dados ocorrem com frequência, mesmo com os
melhores mecanismos de pesquisa. Os humanos fazem suposições sobre o conteúdo do conjunto
de dados que fazem com que os algoritmos falhem. Consequentemente, quanto melhor você
puder ver e entender o conteúdo por meio de formatação estruturada, mais fácil será executar
tarefas baseadas em algoritmos com sucesso.

No entanto, até mesmo olhar para o conteúdo geralmente é propenso a erros ao lidar com
humanos e computadores. Por exemplo, se você tentar pesquisar um número para matted como
uma string quando o conjunto de dados contiver os números formatados como inteiros, a
pesquisa falhará. Computadores não traduzem automaticamente entre strings e inteiros como os
humanos fazem. Na verdade, os computadores veem tudo como números, e as strings são
apenas uma interpretação imposta aos números por um programador. Portanto, ao pesquisar por
“1” (a string), o computador o vê como uma solicitação do número 49 ao usar caracteres ASCII.
Para localizar o valor numérico 1, você deve procurar um 1 como um valor inteiro.

100 PARTE 2 Entendendo a necessidade de classificar e pesquisar


Machine Translated by Google

A estrutura também permite descobrir detalhes de dados diferenciados. Por exemplo,


um número de telefone pode aparecer no formulário (555)555-1212. Se você executar
uma pesquisa ou outra tarefa de algoritmo usando o formulário 555-555-1212, a pesquisa
poderá falhar devido à manipulação do código de área no início do termo de pesquisa,
usando 555- em vez de (555). Esses tipos de problemas causam problemas significativos
porque a maioria das pessoas vê as duas formas como iguais, mas o computador não.
Tentar impor forma aos humanos raramente funciona e geralmente resulta em frustração
que torna o uso do algoritmo ainda mais difícil, então a estrutura imposta através da
manipulação de dados se torna ainda mais importante.

Dados correspondentes de várias fontes


Interagir com dados de uma única fonte é um problema; interagir com dados de várias
fontes é outra bem diferente. No entanto, os conjuntos de dados atuais geralmente vêm
de mais de uma fonte, portanto, você precisa entender as complicações que o uso de
várias fontes de dados pode causar. Ao trabalhar com várias fontes de dados, você deve
fazer o seguinte:

» Determine se ambos os conjuntos de dados contêm todos os dados necessários. Dois


é improvável que os designers criem conjuntos de dados que contenham exatamente os
mesmos dados, no mesmo formato, do mesmo tipo e na mesma ordem. Consequentemente,
você precisa considerar se os conjuntos de dados fornecem os dados de que você precisa
ou se você precisa corrigir os dados de alguma forma para obter o resultado desejado,
conforme discutido na próxima seção.

» Verifique os dois conjuntos de dados para problemas de tipo de dados. Um conjunto


de dados pode ter as datas inseridas como strings e outro pode ter as datas inseridas
como objetos de data reais. As inconsistências entre os tipos de dados causarão
problemas para um algoritmo que espera dados em uma forma e os recebe em outra.

» Certifique-se de que todos os conjuntos de dados atribuam o mesmo significado aos


elementos de dados. Os dados criados por uma fonte podem ter um significado diferente
dos dados criados por outra fonte. Por exemplo, o tamanho de um inteiro pode variar entre
as origens, portanto, você pode ver um inteiro de 16 bits de uma origem e um inteiro de 32
bits de outra. Valores mais baixos têm o mesmo significado, mas o inteiro de 32 bits pode
conter valores maiores, o que pode causar problemas com o algoritmo.

» Verifique os atributos de dados. Os itens de dados têm atributos específicos (consulte https://
www.w3schools.com/python/python_datatypes.asp para detalhes). O Capítulo 4
aponta que essa interpretação pode mudar ao usar numpy. Na verdade, você descobre
que os atributos de dados mudam entre os ambientes e os desenvolvedores podem
alterá-los ainda mais criando tipos de dados personalizados. Para combinar dados de
várias fontes, você deve entender esses atributos para garantir que os dados sejam
interpretados corretamente.

CAPÍTULO 6 Estruturando Dados 101


Machine Translated by Google

Quanto mais tempo você gastar verificando a compatibilidade dos dados de cada uma das fontes
que deseja usar para um conjunto de dados, menor será a probabilidade de encontrar problemas
ao trabalhar com um algoritmo. Os problemas de incompatibilidade de dados nem sempre
aparecem como erros diretos. Em alguns casos, uma incompatibilidade pode causar outros
problemas, como resultados incorretos que parecem corretos, mas fornecem informações enganosas.

Combinar dados de várias fontes nem sempre significa criar um novo conjunto de dados que se
pareça exatamente com os conjuntos de dados de origem. Em alguns casos, você cria agregados
de dados ou executa outras formas de manipulação para criar novos dados a partir dos dados
existentes. A análise assume todos os tipos de formas, e algumas das formas mais exóticas podem
produzir erros terríveis quando usadas incorretamente. Como um exemplo extremo, considere o
que aconteceria ao combinar informações de pacientes de várias fontes e, em seguida, criar
entradas combinadas de pacientes em uma nova fonte de dados com todos os tipos de
incompatibilidades. Um paciente sem histórico de uma determinada doença pode acabar com
registros mostrando o diagnóstico e os cuidados com a doença.

Considerando a necessidade de correção


Depois de encontrar problemas com seu conjunto de dados, você precisa corrigi-lo para que o
conjunto de dados funcione corretamente com os algoritmos que você usa. Por exemplo, ao
trabalhar com tipos de dados conflitantes, você deve alterar os tipos de dados de cada fonte de
dados para que correspondam e, em seguida, criar a fonte de dados única usada com o algoritmo.
A maior parte dessa correção, embora demorada, é direta. Você simplesmente precisa garantir
que entende os dados antes de fazer alterações, o que significa poder ver o conteúdo no contexto
do que planeja fazer com ele.
No entanto, você precisa considerar o que fazer em dois casos especiais: duplicação de dados e
dados ausentes. As seções a seguir mostram como lidar com esses problemas.

Lidando com a duplicação de dados


Dados duplicados ocorrem por vários motivos. Alguns deles são óbvios. Um usuário pode inserir
os mesmos dados mais de uma vez. As distrações fazem com que as pessoas percam seu lugar
em uma lista e, às vezes, dois usuários entram no mesmo registro. Algumas das fontes de
duplicação são menos óbvias. A combinação de dois ou mais conjuntos de dados pode criar vários
registros quando os dados aparecem em mais de um local. Você também pode criar duplicações
de dados ao usar várias técnicas de modelagem de dados para criar novos dados de fontes de
dados existentes. Felizmente, pacotes como o Pandas permitem remover dados duplicados,
conforme mostrado no exemplo a seguir.

importar pandas como pd

df = pd.DataFrame({'A': [0,0,0,0,0,1,0],
'B': [0,2,3,5,0,2,0],
'C': [0,3,4,1,0,2,0]})

102 PARTE 2 Entendendo a necessidade de classificar e pesquisar


Machine Translated by Google

print(df, "\n")

df = df.drop_duplicates()
imprimir(df)

Ao executar esse código, você vê a seguinte saída, que mostra os dados originais primeiro e,
em seguida, os dados com duplicatas descartados:

abc
0000
1023
2034
3051
4000
5122
6000

abc
0000
1023
2034
3051
5122

A função drop_duplicates() remove os registros duplicados encontrados nas linhas 4 e 6 neste


exemplo. Ao ler seus dados de uma fonte em um DataFrame do pandas, você pode remover
rapidamente as entradas extras para que as duplicatas não ponderem injustamente a saída de
qualquer algoritmo que você use.

Lidando com valores ausentes


Valores ausentes também podem distorcer os resultados da saída de um algoritmo. Na verdade,
eles podem fazer com que alguns algoritmos reajam de forma estranha ou até gerem um erro.
Você tem muitas opções ao trabalhar com valores ausentes. Por exemplo, você pode defini-los
com um valor padrão, como 0 para números inteiros. Obviamente, usar uma configuração
padrão também pode distorcer os resultados. Outra abordagem é usar a média de todos os
valores, o que tende a fazer com que os valores ausentes não contem. Usar uma média é a
abordagem adotada no exemplo a seguir:

importar pandas como pd


importar numpy como np

CAPÍTULO 6 Estruturando Dados 103


Machine Translated by Google

df = pd.DataFrame({'A': [0,0,1,Nenhum],
'B': [1,2,3,4],
'C': [np.NAN,3,4,1]},
dtype=int)
print(df, "\n")

valores = pd.Series(df.mean(), dtype=int)


print(valores, "\n")

df = df.fillna(valores)
imprimir(df)

Ao executar esse código, você vê os dados originais que incluem um NaN e um


Nenhuma entrada primeiro e, em seguida, os dados com valores ausentes preenchidos:

AB C
0 0 1 NaN
1 02 3
2 13 4
3 Nenhum 4 1

A0
B 2
C2
dtype: int32

abc
0012
1023
2134
3041

A função fillna() permite que você se livre dos valores ausentes, sejam eles não um número
(NaN) ou simplesmente ausentes (Nenhum). Você pode fornecer os valores de dados
ausentes em vários formulários. Este exemplo se baseia em uma série que contém a média
para cada coluna de dados separada (da mesma forma que você faria ao trabalhar com um
banco de dados).

Observe que o código foi criado com cuidado para não introduzir erros na saída, garantindo
que os valores sejam do tipo de dados correto. Normalmente, a função mean() gera
valores de ponto flutuante, mas você pode forçar a série que ela preenche para o tipo
correto. Conseqüentemente, a saída não só carece de valores ausentes, mas também
contém valores do tipo correto.

104 PARTE 2 Entendendo a necessidade de classificar e pesquisar


Machine Translated by Google

Entendendo outros problemas de correção


A remediação pode assumir várias outras formas. Os aplicativos nem sempre impõem regras de entrada de
dados, portanto, os usuários podem inserir nomes de estado ou região incorretos. Os usuários também podem
escrever errado. Além disso, os valores às vezes estão fora do intervalo ou são simplesmente impossíveis em
uma determinada situação.

Nem sempre você pode limpar seus dados completamente na primeira tentativa. Muitas vezes, você fica ciente
de um problema executando o algoritmo e observando que os resultados estão distorcidos de alguma forma ou
que o algoritmo não funciona (mesmo que tenha funcionado em um subconjunto dos dados). Em caso de
dúvida, verifique seus dados para possíveis necessidades de correção.

Empilhamento e empilhamento de dados em ordem

O Python fornece várias metodologias de armazenamento (consulte https://www.


w3schools.com/python/python_lists.asp como um exemplo). Como você já viu neste capítulo, os pacotes
geralmente oferecem métodos de armazenamento adicionais. Tanto o NumPy quanto o Pandas fornecem
alternativas de armazenamento que você pode considerar ao trabalhar com vários problemas de estruturação
de dados.

Um problema comum de armazenamento de dados não é apenas o fato de você precisar armazenar os dados,
mas de armazená-los em uma ordem específica para poder acessá-los quando necessário. Por exemplo, você
pode querer garantir que o primeiro item que você coloca em uma pilha de itens para processar também seja o
primeiro item que você realmente processa. Com esse problema de ordenação de dados em mente, as seções
a seguir descrevem os métodos padrão do Python para garantir o armazenamento de dados ordenado que
permite que você tenha um arranjo de processamento específico.

Pedidos em pilhas
Uma pilha fornece armazenamento de dados LIFO (last in/first out). O pacote NumPy fornece uma implementação
de pilha real. Além disso, o Pandas associa pilhas a objetos como o DataFrame. No entanto, ambos os pacotes
ocultam os detalhes de implementação da pilha, e ver como uma pilha funciona realmente ajuda.
Conseqüentemente, o exemplo a seguir implementa uma pilha usando uma lista padrão do Python.

MinhaPilha = []
Tamanho da pilha = 3

CAPÍTULO 6 Estruturando Dados 105


Machine Translated by Google

def DisplayStack():
print("A pilha atualmente contém:") for Item em
MyStack:
imprimir(Item)

def Push(Value): if
len(MyStack) < StackSize:
MyStack.append(Value)
senão:
print("A pilha está cheia!")

def Pop(): if
len(MyStack) > 0:
print("Popping: ", MyStack.pop())
senão:
print("A pilha está vazia.")

# Testa a funcionalidade da pilha.

Empurre(1)
Empurre(2)
Empurre(3)
DisplayStack()

Empurre(4)

Pop()
DisplayStack()

Pop()
Pop()
Pop()

Este código vem em duas partes. A primeira parte fornece a implementação da pilha.
A segunda parte fornece código para testar a implementação. Ao executar esse
código, você vê a seguinte saída:

A pilha contém atualmente: 1

2
3
A pilha está cheia!

Estalando: 3

106 PARTE 2 Entendendo a necessidade de classificar e pesquisar


Machine Translated by Google

A pilha contém atualmente:


1
2
Popping: 2
Popping: 1 A
pilha está vazia.

O exemplo garante que a pilha mantenha a integridade dos dados e trabalhe com eles na
ordem que você espera. O código se baseia na manipulação de lista simples , mas é eficaz
em fornecer uma representação de pilha que você pode usar para qualquer necessidade.

Usando filas
Ao contrário das pilhas, as filas são estruturas de dados FIFO (first in/first out). Assim como
nas pilhas, você pode encontrar implementações predefinidas em muitos pacotes, incluindo
NumPy e Pandas. Felizmente, você também pode encontrar uma implementação de fila
específica em Python, demonstrada no código a seguir:

fila de importação

MinhaFila = fila.Fila(3)

print("Fila vazia: ", MinhaFila.empty())

MinhaFila.put(1)
MinhaFila.put(2)
MyQueue.put(3)
print("Fila cheia: ", MyQueue.full())

print("Popping: ", MyQueue.get()) print("Fila


cheia: ", MyQueue.full())

print("Popping: ", MyQueue.get())


print("Popping: ", MyQueue.get()) print("Fila
vazia: ", MyQueue.empty())

Você vê a seguinte saída ao executar este exemplo:

Fila vazia: Verdadeiro


Fila cheia: Verdadeiro
Estalando: 1

CAPÍTULO 6 Estruturando Dados 107


Machine Translated by Google

Fila cheia: Falso


Estalando: 2
Estalando: 3
Fila vazia: Verdadeiro

Usar a fila interna requer muito menos código do que construir uma pilha do zero usando uma
lista, mas observe como os dois diferem na saída. O exemplo de pilha empurra 1, 2 e 3 para a
pilha, então o primeiro valor retirado da pilha é 3. No entanto, neste exemplo, empurrar 1, 2 e 3
para a fila resulta em um primeiro valor retirado de 1.

Encontrar dados usando dicionários


Criar e usar um dicionário é muito parecido com trabalhar com uma lista, exceto que agora você
deve definir um par de chave e valor. A grande vantagem dessa estrutura de dados é que os
dicionários podem fornecer acesso rápido a itens de dados específicos usando a chave.
Existem limites para os tipos de chaves que você pode usar. Aqui estão as regras especiais para
criar uma chave:

» A chave deve ser exclusiva. Quando você insere uma chave duplicada, a informação
encontrada na segunda entrada ganha; a primeira entrada substitui a segunda.

» A chave deve ser imutável. Essa regra significa que você pode usar strings, números
ou tuplas para a chave. Você não pode, no entanto, usar uma lista para uma chave.

A diferença entre valores mutáveis e imutáveis é que os valores imutáveis não podem
mudar. Para alterar o valor de uma string, por exemplo, o Python cria uma nova string
que contém o novo valor e dá à nova string o mesmo nome da antiga. Em seguida,
destrói a string antiga.

Os dicionários Python são a implementação de software de uma estrutura de dados chamada


tabela de hash, uma matriz que mapeia chaves para valores. O Capítulo 7 explica os hashes em
detalhes e como o uso de hashes pode ajudar os dicionários a funcionar mais rápido. Você não
tem restrições sobre os valores que fornece. Um valor pode ser qualquer objeto Python, então
você pode usar um dicionário para acessar um registro de funcionário ou outros dados
complexos. O exemplo a seguir ajuda você a entender melhor como usar dicionários:

Cores = {"Sam": "Azul", "Amy": "Vermelho", "Sarah": "Amarelo"}

print(Cores["Sarah"])
print(Cores.chaves())

para Item em Colors.keys():


print("{0} gosta da cor {1}."
.format(Item, Cores[Item]))

108 PARTE 2 Entendendo a necessidade de classificar e pesquisar


Machine Translated by Google

Cores["Sarah"] = "Roxo"
Colors.update({"Harry": "Laranja"})
del Colors["Sam"]

imprimir(Cores)

Ao executar esse código, você vê a seguinte saída:

Amarelo
dict_keys(['Sarah', 'Amy', 'Sam'])
Sarah gosta da cor amarela.

Amy gosta da cor vermelha.


Sam gosta da cor azul.

{'Harry': 'Laranja', 'Sarah': 'Roxo', 'Amy': 'Vermelho'}

Como você pode ver, um dicionário sempre tem um par de chave e valor separados um do outro por
dois pontos (:). Em vez de usar um índice para acessar valores individuais, você usa a chave. A
função special keys() permite obter uma lista de teclas que você pode manipular de várias maneiras.
Por exemplo, você pode usar as chaves para executar o processamento iterativo dos valores de
dados que o dicionário contém.

Dicionários são um pouco como tabelas individuais dentro de um banco de dados. Você pode
atualizar, adicionar e excluir registros em um dicionário conforme mostrado. A função update() pode
substituir ou adicionar novas entradas ao dicionário.

Trabalhando com árvores


Uma estrutura de árvore se parece muito com o objeto físico no mundo natural. O uso de árvores
ajuda a organizar os dados rapidamente e encontrá-los em menos tempo do que usar muitas outras
técnicas de armazenamento de dados. Você geralmente encontra árvores usadas para rotinas de
pesquisa e classificação, mas elas também têm muitos outros propósitos. As seções a seguir ajudam
você a entender as árvores em um nível básico. Você encontra árvores usadas em muitos dos
exemplos nos próximos capítulos.

Entendendo o básico das árvores


Construir uma árvore funciona como construir uma árvore funcionaria no mundo físico, se alguém
pudesse fazer isso. Cada item que você adiciona à árvore é um nó. Os nós se conectam uns aos
outros usando links. A combinação de nós e links forma uma estrutura que se parece muito com uma
árvore invertida, conforme mostrado na Figura 6-1.

CAPÍTULO 6 Estruturando Dados 109


Machine Translated by Google

FIGURA 6-1:
Uma árvore em
Python se parece
muito com a
alternativa física.

Observe que a árvore tem apenas um nó raiz — assim como uma árvore física. O nó raiz
fornece o ponto de partida para os vários tipos de processamento que você executa.
Conectados ao nó raiz estão ramos ou folhas. Um nó folha é sempre um ponto final para a
árvore. Os nós de ramificação suportam outras ramificações ou folhas.
O tipo de árvore mostrado na Figura 6-1 é uma árvore binária porque cada nó tem, no máximo,
duas conexões.

Ao olhar para a árvore, o Ramo B é filho do nó Raiz. Isso porque o nó Raiz aparece primeiro na
lista. A Folha E e a Folha F são ambas filhas do Ramo B, tornando o Ramo B o pai da Folha E
e da Folha F. A relação entre os nós é importante porque as discussões sobre árvores
geralmente consideram o relacionamento filho/pai entre os nós. Sem esses termos, as
discussões sobre árvores podem se tornar bastante confusas.

Construindo uma árvore

Python não vem com um objeto de árvore embutido. Uma implementação de árvore básica
requer que você crie uma classe para conter o objeto de dados de árvore. O código a seguir
mostra como você pode criar uma classe de árvore básica.

class binaryTree:
def __init__(self, nodeData, left=Nenhum, right=Nenhum):
self.nodeData = nodeData
self.left = left
self.right = right

110 PARTE 2 Entendendo a necessidade de classificar e pesquisar


Machine Translated by Google

def __str__(self):
return str(self.nodeData)

Esse código apenas cria um objeto de árvore básico que define os três elementos que um
nó deve incluir: armazenamento de dados, conexão esquerda e conexão direita. Como os
nós folha não têm conexão, o valor padrão para esquerda e direita é Nenhum. A classe
também inclui um método para imprimir o conteúdo de nodeData para que você possa ver
quais dados o nó armazena.

Usar esta árvore simples requer que você não tente armazenar nada na esquerda ou na direita
diferente de uma referência a outro nó. Caso contrário, o código falhará porque não há
interceptação de erros. A entrada nodeData pode conter qualquer valor. O código a seguir
mostra como usar a classe binaryTree para construir a árvore mostrada na Figura 6-1:

árvore = binaryTree("Raiz")
RamoA = binaryTree("Ramo A")
RamoB = binaryTree("Ramo B")
tree.left = RamoA
tree.right = RamoB

FolhaC = binaryTree("Folha C")


FolhaD = binaryTree("Folha D")
LeafE = binaryTree("Folha E")
FolhaF = binaryTree("Folha F")
RamoA.esquerda = FolhaC

BranchA.right = LeafD
BranchB.left = LeafE
BranchB.right = LeafF

Você tem muitas opções ao construir uma árvore, mas construí-la de cima para baixo
(como mostrado neste código) ou de baixo para cima (no qual você constrói as folhas
primeiro) são dois métodos comuns. Claro, você não sabe se a árvore realmente funciona
neste momento. Atravessar a árvore significa verificar os links e verificar se eles realmente
se conectam como você acha que deveriam. O código a seguir mostra como usar a
recursão (conforme descrito no Capítulo 4) para percorrer a árvore que você acabou de construir:

def travessia(árvore):
if tree.left != Nenhum:
travessia(árvore.esquerda)
if tree.right != Nenhum:
travessia(árvore.direita)
print(tree.nodeData)

travessia (árvore)

CAPÍTULO 6 Estruturando Dados 111


Machine Translated by Google

A rotina recursiva, traverse(), começa a imprimir nas folhas e se move em direção à raiz, então você
vê a seguinte saída:

Folha C
Folha D
Filial A
Folha E
Folha F
Filial B
Raiz

Você pode ver que traverse() imprime ambas as folhas e o pai dessas folhas. A travessia segue
primeiro o ramo esquerdo e depois o ramo direito. O nó raiz vem por último.

Existem diferentes tipos de estruturas de armazenamento de dados. Aqui está uma lista rápida dos
tipos de estruturas que você normalmente encontra:

» Árvores balanceadas: Uma espécie de árvore que mantém uma estrutura balanceada através da
reorganização para que possa proporcionar tempos de acesso reduzidos.

» Árvores desequilibradas: Uma árvore que coloca novos itens de dados sempre que necessário na
árvore sem considerar o equilíbrio. Esse método de adicionar itens torna a construção da árvore mais
rápida, mas reduz a velocidade de acesso ao pesquisar ou classificar.

» Heaps: Uma árvore sofisticada que permite a inserção de dados na estrutura da árvore.
O uso de inserção de dados torna a classificação mais rápida.

Mais adiante no livro, você encontrará algoritmos que usam árvores balanceadas, árvores não
balanceadas e heaps. Por exemplo, o Capítulo 9 discute o algoritmo de Dijkstra e o Capítulo 14
discute a codificação de Huffman.

Representando relações em um gráfico


Os gráficos são outra forma de estrutura de dados comum usada em algoritmos. Você vê gráficos
usados em lugares como mapas para GPS e todos os tipos de outros lugares onde a abordagem de
cima para baixo de uma árvore não funciona. As seções a seguir descrevem os gráficos com mais
detalhes.

112 PARTE 2 Entendendo a necessidade de classificar e pesquisar


Machine Translated by Google

Indo além das árvores


Um grafo é uma espécie de extensão de árvore. Assim como nas árvores, você tem nós que se
conectam entre si para criar relacionamentos. No entanto, diferentemente das árvores binárias,
um grafo pode ter mais de uma ou duas conexões. Na verdade, os nós do grafo geralmente têm
uma infinidade de conexões. Para manter as coisas simples, porém, considere o gráfico mostrado
na Figura 6-2.

FIGURA 6-2:
Os nós do gráfico podem se
conectar uns aos outros em

uma infinidade

caminhos.

Nesse caso, o grafo cria um anel onde A se conecta a B e F. No entanto, não precisa ser assim.
O nó A pode ser um nó desconectado ou também pode se conectar a C. Um gráfico mostra a
conectividade entre os nós de uma maneira útil para definir relacionamentos complexos.

Os gráficos também adicionam algumas reviravoltas que você pode não ter pensado antes.
Por exemplo, um gráfico pode incluir o conceito de direcionalidade. Ao contrário de uma árvore,
que tem relacionamentos pai/filho, um nó de grafo pode se conectar a qualquer outro nó com
uma direção específica em mente. Pense nas ruas de uma cidade. A maioria das ruas é
bidirecional, mas algumas são ruas de mão única que permitem o movimento em apenas uma direção.

CAPÍTULO 6 Estruturando Dados 113


Machine Translated by Google

A apresentação de uma conexão gráfica pode não refletir a realidade do gráfico. Um


gráfico pode designar um peso para uma determinada conexão. O peso pode definir a
distância entre dois pontos, definir o tempo necessário para percorrer a rota ou fornecer
outros tipos de informações.

Construindo gráficos
A maioria dos desenvolvedores usa dicionários (ou às vezes listas) para construir
gráficos. Usar um dicionário facilita a construção do gráfico porque a chave é o nome
do nó e os valores são as conexões para aquele nó. Por exemplo, aqui está um
dicionário que cria o gráfico mostrado na Figura 6-2.

gráfico = {'A': ['B', 'F'],


'B': ['A', 'C'],
'C': ['B', 'D'],
'D': ['C', 'E'],
'E': ['D', 'F'],
'F': ['E', 'A']}

Este dicionário reflete a natureza bidirecional do gráfico na Figura 6-2. Ele poderia
facilmente definir conexões unidirecionais ou fornecer nós sem nenhuma conexão. No
entanto, o dicionário funciona muito bem para esse propósito, e você o vê usado em
outras áreas do livro. Agora é hora de percorrer o gráfico usando o seguinte código:

def find_path(graph, start, end, path=[]):


caminho = caminho + [início]

se início == fim:
print("Fim")
caminho de retorno

para nó no gráfico[início]:
print("Verificando Nó ", nó)

se o nó não estiver no caminho:


print("Caminho até agora", caminho)

newp = find_path(grafo, nó, fim, caminho)


se novo:
retornar novo

find_path(gráfico, 'B', 'E')

114 PARTE 2 Entendendo a Necessidade de Classificar e Pesquisar


Machine Translated by Google

Quando você executa esse código, o exemplo começa com o nó 'A' e percorre todas as conexões do
gráfico para produzir a seguinte saída:

Verificando o Nó A
Caminho até agora ['B']
Verificando o Nó B
Verificando o Nó F
Caminho até agora ['B', 'A']
Verificando o Nó E
Caminho até agora ['B', 'A', 'F']
Final

['B', 'A', 'F', 'E']

Capítulos posteriores discutem como encontrar o caminho mais curto. Por enquanto, o código encontra
apenas um caminho. Ele começa construindo o caminho nó por nó. Tal como acontece com todas as
rotinas recursivas, esta requer uma estratégia de saída, que é quando o valor inicial corresponde ao
valor final , o caminho termina.

Como cada nó no gráfico pode se conectar a vários nós, você precisa de um loop for para verificar
cada uma das conexões potenciais. Quando o nó em questão já aparece no caminho, o código o
ignora (o que pode impedir que o código entre em um loop infinito — um risco com algoritmos de
grafos). Caso contrário, o código rastreia o caminho atual e chama recursivamente find_path() para
localizar o próximo nó no caminho.

CAPÍTULO 6 Estruturando Dados 115


Machine Translated by Google
Machine Translated by Google

NESTE CAPÍTULO

» Realizando ordenações usando ordenação por


mesclagem e ordenação rápida

» Realização de pesquisas usando


árvores e o heap

» Considerando os usos para hash e


dicionários

Capítulo 7

Organizando e
Pesquisando dados
e excluir (CRUD) — para gerenciar dados. A primeira parte deste capítulo
Este capítulo trata
concentra-se do uso dasdequatro
na classificação operações
dados. Colocar deemdados
os dados — criar,
uma ordem ler, aatualizar,
que facilite execução
de operações CRUD é importante porque quanto menos código você precisar para fazer o acesso aos
dados funcionar, melhor. Além disso, embora a classificação de dados possa não parecer particularmente
importante, os dados classificados tornam as pesquisas consideravelmente mais rápidas, desde que a
classificação corresponda à pesquisa. A classificação e a pesquisa andam juntas: você classifica os dados
de uma maneira que torna a pesquisa mais rápida.

A segunda seção do capítulo discute a pesquisa. Você não ficará surpreso ao saber
que muitas maneiras diferentes estão disponíveis para pesquisar dados. Algumas
dessas técnicas são mais lentas que outras; alguns têm atributos que os tornam
atraentes para os desenvolvedores. O fato é que não existe uma estratégia de busca
perfeita, mas a exploração desse método continua.

A seção final do capítulo analisa hashing e dicionários. O uso da indexação torna a


classificação e a pesquisa significativamente mais rápidas, mas também traz
desvantagens que você precisa considerar (como o uso de recursos adicionais).
Um índice é um tipo de ponteiro ou um endereço. Não são os dados, mas apontam
para os dados, assim como seu endereço aponta para sua casa. Uma busca manual
quarteirão por quarteirão de sua casa na cidade seria demorada, pois a pessoa que procura

CAPÍTULO 7 Organizando e Pesquisando Dados 117


Machine Translated by Google

você precisaria perguntar a cada pessoa em cada endereço se você está lá, mas encontrar
seu endereço na lista telefônica e usar esse endereço para localizar sua casa é muito mais
rápido.

Você não precisa digitar o código-fonte para este capítulo manualmente. Na verdade, usar a
fonte para download é muito mais fácil. Você pode encontrar a fonte deste capítulo no A4D2E;
07; Hashing.ipynb, A4D2E; 07; Search Techniques.ipynb e A4D2E; 07; Classificando arquivos
Techniques.ipynb da fonte para download. Consulte a Introdução para obter detalhes sobre
como localizar esses arquivos de origem.

Classificando dados usando


Merge Sort e Quick Sort
A classificação é um dos fundamentos do trabalho com dados. Consequentemente, muitas
pessoas criaram muitas maneiras diferentes de classificar dados ao longo dos anos. Todas
essas técnicas resultam em dados ordenados, mas algumas funcionam melhor que outras, e
algumas funcionam excepcionalmente bem para tarefas específicas. As seções a seguir ajudam
você a entender a necessidade de pesquisar, bem como a considerar as várias opções de pesquisa.

Entendendo por que a


classificação de dados é importante
Um caso pode ser feito para não classificar os dados. Afinal, os dados ainda estão acessíveis,
mesmo que você não os classifique - e a classificação leva tempo. Claro, o problema com dados
não classificados é o mesmo problema daquela gaveta de lixo em sua cozinha (ou onde quer
que você tenha sua gaveta de lixo - supondo que você possa encontrá-la). Procurar qualquer
coisa na gaveta de lixo é demorado, porque você não pode nem começar a adivinhar onde
encontrar algo. Em vez de apenas chegar e pegar o que você quer, você deve tirar uma miríade
de outros itens que você não quer em um esforço para encontrar o item que você precisa.
Infelizmente, o item que você precisa pode não estar na gaveta de lixo em primeiro lugar; você
pode tê-lo jogado fora ou colocado em uma gaveta diferente.

A gaveta de lixo em sua casa é como dados não classificados em seu sistema. Quando os
dados não são classificados, você precisa pesquisar um item por vez e nem sabe se encontrará
o que precisa sem pesquisar primeiro cada item no conjunto de dados. É uma maneira frustrante
de trabalhar com dados. O exemplo de busca binária na seção “Considerando dividir e conquistar”
do Capítulo 4 aponta a necessidade de ordenar muito bem. Imagine tentar encontrar um item
em uma lista sem classificá-lo primeiro.
Cada busca se torna uma busca sequencial demorada.

118 PARTE 2 Entendendo a necessidade de classificar e pesquisar


Machine Translated by Google

Simplesmente classificar os dados não é suficiente. Se você tiver um banco de dados de funcionários
classificado por sobrenome, mas precisar procurar um funcionário por data de nascimento, a classificação não será útil.
Digamos que você queira encontrar todos os funcionários que fazem aniversário em um determinado dia. Para
encontrar a data de nascimento de que você precisa, você ainda deve pesquisar todo o conjunto de dados, um
item por vez. Consequentemente, a classificação deve se concentrar em uma necessidade específica. Sim,
você precisava do banco de dados de funcionários classificado por departamento em um ponto e por
sobrenome em outro momento, mas agora precisa classificá-lo por data de nascimento para usar o conjunto
de dados de maneira eficaz.

A necessidade de manter várias ordens classificadas para os mesmos dados é a razão pela qual os

desenvolvedores criaram índices. Classificar um índice pequeno é mais rápido do que classificar todo o
conjunto de dados. O índice mantém uma ordem de dados específica e aponta para o conjunto de dados
completo para que você possa encontrar o que precisa com extrema rapidez. Ao manter um índice para cada
requisito de classificação, você pode reduzir efetivamente o tempo de acesso aos dados e permitir que várias
pessoas acessem os dados ao mesmo tempo na ordem em que precisam acessá-los. A seção “Contando com
o hashing”, mais adiante neste capítulo, dá uma ideia de como a indexação funciona e por que você realmente
precisa dela em alguns casos, apesar do tempo e dos recursos adicionais necessários para manter os índices.

Muitas maneiras estão disponíveis para categorizar algoritmos de ordenação. Uma dessas maneiras é a
velocidade do tipo. Ao considerar a eficácia de um algoritmo de classificação específico na organização dos
dados, os benchmarks de tempo geralmente analisam dois fatores:

» Comparações: Para mover dados de um local em um conjunto de dados para outro,


você precisa saber para onde movê-los, o que significa comparar os dados de destino
com outros dados no conjunto de dados. Ter menos comparações significa melhor
desempenho.

» Trocas: Dependendo de como você escreve um algoritmo, os dados podem não chegar à
sua localização final no conjunto de dados na primeira tentativa. Os dados podem
realmente se mover várias vezes. O número de trocas afeta consideravelmente a
velocidade porque agora você está realmente movendo dados de um local para outro na memória.
Menos e menores exchanges (como ao usar índices) significam melhor desempenho.

Ordenar dados de forma ingênua Ordenar dados de forma ingênua com o objetivo de facilitar a pesquisa (e
outras tarefas) significa ordená-los usando métodos de força bruta - sem qualquer preocupação em fazer
qualquer tipo de adivinhação sobre onde os dados devem aparecer na lista. Além disso, essas técnicas tendem
a trabalhar com todo o conjunto de dados em vez de aplicar abordagens que provavelmente reduziriam o
tempo de classificação (como a técnica de dividir e conquistar descrita no Capítulo 4). No entanto, essas
pesquisas também são relativamente fáceis de entender e usam recursos de forma eficiente, portanto, você
não deve descartá-las completamente. Embora muitas pesquisas se enquadrem nessa categoria, as seções a
seguir examinam as duas abordagens mais populares.

CAPÍTULO 7 Organizando e Pesquisando Dados 119


Machine Translated by Google

Usando uma ordenação de seleção

A ordenação por seleção substituiu uma predecessora, a ordenação por bolhas, porque tende a
fornecer melhor desempenho do que a ordenação por bolhas. Embora ambas as ordenações tenham
uma velocidade de ordenação no pior caso de O(n2), a ordenação por seleção realiza menos trocas.
Uma ordenação por seleção funciona de duas maneiras: ou procura o menor item na lista e o coloca
na frente da lista (garantindo que o item esteja em seu local correto) ou procura o maior item e o
coloca no o verso da lista. De qualquer forma, a classificação é excepcionalmente fácil de implementar
e garante que os itens apareçam imediatamente no local final depois de movidos (e é por isso que
algumas pessoas chamam de classificação de comparação no local). Aqui está um exemplo de uma
ordenação por seleção. (Você pode encontrar esse código no arquivo de código-fonte para download
A4D2E; 07; Sorting Techniques.ipynb ; consulte a Introdução para obter detalhes.)

dados = [9, 5, 7, 4, 2, 8, 1, 10, 6, 3]

para scanIndex em range(0, len(data)):


minIndex = scanIndex

para compIndex no intervalo(scanIndex + 1, len(data)):


if data[compIndex] < data[minIndex]:
minIndex = compIndex

if minIndex != scanIndex:
dados[scanIndex], dados[minIndex] = \
dados[minIndex], dados[scanIndex]
imprimir(dados)

Você vê a seguinte saída à medida que o código é executado. Observe como cada número chega na
posição correta à medida que a classificação avança:

[1, 5, 7, 4, 2, 8, 9, 10, 6, 3]
[1, 2, 7, 4, 5, 8, 9, 10, 6, 3]
[1, 2, 3, 4, 5, 8, 9, 10, 6, 7]
[1, 2, 3, 4, 5, 6, 9, 10, 8, 7]
[1, 2, 3, 4, 5, 6, 7, 10, 8, 9]
[1, 2, 3, 4, 5, 6, 7, 8, 10, 9]
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

Alternando para uma classificação por inserção

Uma classificação por inserção funciona usando um único item como ponto de partida e adicionando
itens à esquerda ou à direita dele com base no fato de esses itens serem menores ou maiores que o
item selecionado. À medida que o número de itens classificados aumenta, o algoritmo verifica novos
itens em relação aos itens classificados e insere o novo item na posição correta na

120 PARTE 2 Entendendo a necessidade de classificar e pesquisar


Machine Translated by Google

a lista. Uma ordenação por inserção tem uma velocidade de ordenação no melhor caso de O(n) e uma velocidade de ordenação

no pior caso de O(n2).

Um exemplo de velocidade de classificação de melhor caso é quando todo o conjunto de dados já está
classificado porque a classificação por inserção não precisará mover nenhum valor. Um exemplo da velocidade
de classificação do pior caso é quando todo o conjunto de dados é classificado na ordem inversa porque cada
inserção exigirá a movimentação de todos os valores que já aparecem na saída.
Você pode ler mais sobre a matemática envolvida neste tipo em https://www.
khanacademy.org/computing/computer-science/algorithms/insertion sort/a/analysis-of-insertion-
sort.

A classificação por inserção ainda é um método de força bruta de classificação de itens, mas pode exigir menos
comparações do que uma classificação por seleção. Aqui está um exemplo de uma ordenação por inserção:

dados = [9, 5, 7, 4, 2, 8, 1, 10, 6, 3]

para scanIndex em range(1, len(data)):


temp = dados[scanIndex]

minIndex = scanIndex

enquanto minIndex > 0 e temp < data[minIndex - 1]:


dados[minIndex] = dados[minIndex - 1]
minÍndice -= 1

data[minIndex] = temp
imprimir(dados)

A saída de exemplo mostra que requer nove iterações para este exemplo, em contraste com sete para o
exemplo anterior. No entanto, você descobre que a classificação por inserção tem um desempenho melhor do
que a classificação por seleção em outros casos. Depende dos dados que você usa como entrada.

[5, 9, 7, 4, 2, 8, 1, 10, 6, 3]
[5, 7, 9, 4, 2, 8, 1, 10, 6, 3]
[4, 5, 7, 9, 2, 8, 1, 10, 6, 3]
[2, 4, 5, 7, 9, 8, 1, 10, 6, 3]
[2, 4, 5, 7, 8, 9, 1, 10, 6, 3]
[1, 2, 4, 5, 7, 8, 9, 10, 6, 3]
[1, 2, 4, 5, 7, 8, 9, 10, 6, 3]
[1, 2, 4, 5, 6, 7, 8, 9, 10, 3]
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

CAPÍTULO 7 Organizando e Pesquisando Dados 121


Machine Translated by Google

Empregando melhores técnicas de classificação


À medida que a tecnologia de classificação melhora, os algoritmos de classificação começam a adotar uma
abordagem mais inteligente para colocar os dados na ordem certa. A ideia é tornar o problema menor e mais
fácil de gerenciar. Em vez de trabalhar com um conjunto de dados inteiro, os algoritmos de classificação
inteligente trabalham com itens individuais, reduzindo o trabalho necessário para executar a tarefa. As seções
a seguir discutem duas dessas técnicas de classificação inteligente.

Reorganizando dados com classificação por mesclagem

Uma classificação de mesclagem funciona aplicando a abordagem de dividir e conquistar. A classificação


começa dividindo o conjunto de dados em partes individuais e classificando as partes. Em seguida, ele
mescla as peças de uma maneira que garante que classificou a peça mesclada.
A classificação e a mesclagem continuam até que todo o conjunto de dados seja novamente uma única peça.
A velocidade de classificação do pior caso da classificação por mesclagem é O(n log n), o que a torna
consideravelmente mais rápida do que as técnicas usadas na seção anterior (porque log n é sempre menor
que n). Esse tipo realmente requer o uso de duas funções. A primeira função funciona recursivamente para
separar as peças e juntá-las novamente.

dados = [9, 5, 7, 4, 2, 8, 1, 10, 6, 3]

def mergeSort(lista):
# Determina se a lista está dividida em
# peças individuais.
se len(lista) < 2:
lista de retorno

# Encontre o meio da lista.


meio = len(lista)//2
# Divida a lista em duas partes.
left = mergeSort(list[:middle])
direito = mergeSort(lista[meio:])

# Mesclar as duas peças classificadas em uma peça maior.


print("Lado esquerdo: ", esquerdo)
print("Lado direito: ", direito)
mesclado = mesclar (esquerda, direita)
print("Mesclado", mesclado)
retorno mesclado

A segunda função executa a tarefa real de mesclar os dois lados usando um processo iterativo. Aqui está o
código usado para mesclar as duas partes:

122 PARTE 2 Entendendo a necessidade de classificar e pesquisar


Machine Translated by Google

def merge(esquerda, direita):


# Quando o lado esquerdo ou o lado direito estiver vazio,
# significa que este é um item individual e é

# já ordenado. se não
len(esquerda):
voltar à esquerda

se não len(direita):
retorna certo

# Defina as variáveis usadas para mesclar as duas partes. resultado


= []
leftIndex = 0
rightIndex = 0
totalLen = len(esquerda) + len(direita)

# Continue trabalhando até que todos os itens sejam mesclados.


while (len(resultado) < totalLen):

# Realizar as comparações necessárias e mesclar # as peças


de acordo com o valor. if left[leftIndex] < right[rightIndex]:
result.append(left[leftIndex])

leftIndex+= 1
senão:
result.append(right[rightIndex]) rightIndex+=
1

# Quando o lado esquerdo ou direito for maior, # adicione os


elementos restantes ao resultado. if leftIndex == len(left) ou \
rightIndex == len(right): result.extend(left[leftIndex:] ou right[rightIndex:])

parar

retornar resultado

mergeSort(dados)

As instruções de impressão no código ajudam você a ver como funciona o processo de mesclagem.
Embora o processo pareça bastante complexo, é relativamente simples quando você trabalha no
processo de mesclagem mostrado aqui:

Lado esquerdo: [9]


Lado direito: [5]

CAPÍTULO 7 Organizando e Pesquisando Dados 123


Machine Translated by Google

Fundido [5, 9]
Lado esquerdo: [4]
Lado direito: [2]
Fundido [2, 4]
Lado esquerdo: [7]
Lado direito: [2, 4]
Mesclado [2, 4, 7]
Lado esquerdo: [5, 9]
Lado direito: [2, 4, 7]
Mesclado [2, 4, 5, 7, 9]
Lado esquerdo: [8]
Lado direito: [1]
Mesclado [1, 8]
Lado esquerdo: [6]
Lado direito: [3]
Fundido [3, 6]
Lado esquerdo: [10]
Lado direito: [3, 6]
Mesclado [3, 6, 10]
Lado esquerdo: [1, 8]
Lado direito: [3, 6, 10]
Mesclado [1, 3, 6, 8, 10]
Lado esquerdo: [2, 4, 5, 7, 9]
Lado direito: [1, 3, 6, 8, 10]
Mesclado [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

Resolvendo problemas de classificação da


melhor maneira usando a classificação rápida

A classificação rápida é um dos métodos mais rápidos de classificação de dados. Ao ler sobre
classificação de mesclagem e classificação rápida online, você descobre que algumas pessoas
preferem usar uma sobre a outra em uma determinada situação. Por exemplo, a maioria das pessoas
acha que uma classificação rápida funciona melhor para classificar matrizes e a classificação por
mesclagem funciona melhor para classificar listas vinculadas (consulte a discussão em https://
www.geeksforgeeks.org/why-quick-sort-preferred for-arrays -and-merge-sort-for-linked-lists/). Tony
Hoare escreveu a primeira versão do quick sort em 1959, mas desde então, os desenvolvedores
escreveram muitas outras versões do quick sort. O tempo médio de ordenação de uma ordenação
rápida é O(n log n), mas o tempo de ordenação do pior caso é O(n2).

124 PARTE 2 Entendendo a necessidade de classificar e pesquisar


Machine Translated by Google

ENTENDENDO A SOLUÇÃO RÁPIDA


PIOR DESEMPENHO
A classificação rápida raramente incorre no tempo de classificação do pior caso. No entanto, mesmo as versões

modificadas da classificação rápida podem ter um tempo de classificação de pior caso de O(n2) quando um desses eventos
ocorre:

• O conjunto de dados já está classificado na ordem desejada.

• O conjunto de dados é classificado na ordem inversa.

• Todos os elementos no conjunto de dados são iguais.

Todos esses problemas ocorrem por causa do ponto de pivô que uma função de classificação menos
inteligente usa (muitas vezes, a escolha feita é ruim). Felizmente, usar a técnica de programação
correta pode mitigar esses problemas definindo algo diferente do índice mais à esquerda ou mais à
direita como o ponto de pivô. As técnicas nas quais as versões modernas de classificação rápida se
baseiam incluem:

• Escolhendo um índice aleatório

• Escolhendo o índice do meio da partição

• Escolhendo a mediana do primeiro, meio e último elemento da partição para o pivô (especialmente
para partições mais longas)

A primeira parte da tarefa é particionar os dados. O código escolhe um ponto de pivô que determina o lado
esquerdo e direito da classificação. Aqui está o código de particionamento para este exemplo:

dados = [9, 5, 7, 4, 2, 8, 1, 10, 6, 3]

def partição (dados, esquerda, direita):


pivô = dados[esquerda]
lÍndice = esquerda + 1

rIndex = direito

enquanto Verdadeiro:

while lIndex <= rIndex e data[lIndex] <= pivô:


lÍndice += 1

while rIndex >= lIndex e data[rIndex] >= pivô:


rIndex -= 1
se rIndex <= lIndex:
parar

CAPÍTULO 7 Organizando e Pesquisando Dados 125


Machine Translated by Google

data[lIndex], data[rIndex] = \ data[rIndex],


data[lIndex] print(data)

data[left], data[rIndex] = data[rIndex], data[left] print(data)

retornar rIndex

O loop interno deste exemplo procura continuamente por elementos que estão no
lugar errado e os troca. Quando o código não pode mais trocar itens, ele sai do loop
e define um novo ponto de pivô, que retorna ao chamador. Esta é a parte iterativa do
processo. A parte recursiva do processo lida com o lado esquerdo e direito do
conjunto de dados, conforme mostrado aqui:

def quickSort(data, left, right): if right <= left:


return

senão:
pivô = partição(dados, esquerda, direita)
quickSort(dados, esquerda, pivô-1)
quickSort(dados, pivô+1, direita)

dados de retorno

quickSort(dados, 0, len(dados)-1)

A quantidade de comparações e trocas para este exemplo é relativamente pequena


em comparação com os outros exemplos. Aqui está a saída deste exemplo:

[9, 5, 7, 4, 2, 8, 1, 3, 6, 10] [6, 5, 7, 4, 2,


8, 1, 3, 9, 10] [6, 5, 3, 4 , 2, 8, 1, 7, 9, 10]
[6, 5, 3, 4, 2, 1, 8, 7, 9, 10] [1, 5, 3, 4, 2,
6, 8, 7 , 9, 10] [1, 5, 3, 4, 2, 6, 8, 7, 9, 10]
[1, 2, 3, 4, 5, 6, 8, 7, 9, 10] [1, 2, 3, 4, 5,
6, 8, 7, 9, 10] [1, 2, 3, 4, 5, 6, 8, 7, 9, 10]
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

126 PARTE 2 Entendendo a necessidade de classificar e pesquisar


Machine Translated by Google

Usando Árvores de Pesquisa e o Heap


As árvores de pesquisa permitem que você procure dados rapidamente. O Capítulo 4 apresenta
a ideia de uma busca binária, e a seção “Trabalhando com Árvores” do Capítulo 6 ajuda você a
entender as árvores até certo ponto. Obter itens de dados, colocá-los em ordem ordenada em
uma árvore e, em seguida, pesquisar nessa árvore é uma das maneiras mais rápidas de
encontrar informações.

Um tipo especial de estrutura de árvore é o heap binário, que coloca cada um dos elementos do
nó em uma ordem especial. O nó raiz sempre contém o menor valor.
Ao visualizar as ramificações, você vê que as ramificações de nível superior são sempre um
valor menor do que as ramificações e folhas de nível inferior. O efeito é manter a árvore
equilibrada e em uma ordem previsível para que a busca se torne extremamente eficiente.
O custo está em manter a árvore equilibrada. As seções a seguir descrevem detalhadamente
como as árvores de pesquisa e o heap funcionam.

Considerando a necessidade de pesquisar de forma eficaz


De todas as tarefas que os aplicativos realizam, a pesquisa é a mais demorada e a mais
necessária. Embora adicionar dados (e classificá-los posteriormente) exija algum tempo, o
benefício de criar e manter um conjunto de dados vem de usá-lo para realizar um trabalho útil, o
que significa pesquisá-lo em busca de informações importantes. Conseqüentemente, às vezes
você pode se dar bem com uma funcionalidade CRUD menos eficiente e até mesmo uma rotina
de classificação abaixo do ideal, mas as pesquisas devem ser realizadas da maneira mais
eficiente possível. O único problema é que nenhuma pesquisa executa todas as tarefas com
eficiência absoluta, portanto, você deve pesar suas opções com base no que espera fazer como
parte das rotinas de pesquisa.

Dois dos métodos mais eficientes de busca envolvem o uso da árvore de busca binária (BST) e
heap binário. Ambas as técnicas de busca contam com uma estrutura em forma de árvore para
armazenar as chaves usadas para acessar os elementos de dados. No entanto, a disposição
dos dois métodos é diferente, razão pela qual um tem vantagens sobre o outro na execução de
determinadas tarefas. A Figura 7-1 mostra a disposição de um BST.

Observe como as teclas seguem uma ordem em que os números menores aparecem à esquerda
e os números maiores aparecem à direita. O nó raiz contém um valor que está no meio do
intervalo de chaves, fornecendo ao BST uma abordagem bem ajustada e de fácil compreensão
para armazenar as chaves. Compare esse arranjo com o heap binário mostrado na Figura 7-2.

CAPÍTULO 7 Organizando e Pesquisando Dados 127


Machine Translated by Google

FIGURA 7-1:
A disposição das
teclas ao
usar um BST.

FIGURA 7-2:
A disposição das
chaves ao
usar um heap
binário.

Cada nível contém valores menores que o nível anterior e a raiz contém o valor máximo
de chave para a árvore. Além disso, neste caso específico, os valores menores aparecem
à esquerda e os maiores à direita (embora essa ordem não seja rigorosamente aplicada).
A figura na verdade descreve um heap binário máximo. Você também pode criar um
heap binário mínimo no qual a raiz contém o valor de chave mais baixo e cada nível é
construído para valores mais altos, com os valores mais altos aparecendo como parte
das folhas.

128 PARTE 2 Entendendo a necessidade de classificar e pesquisar


Machine Translated by Google

Como observado anteriormente, o BST tem algumas vantagens sobre o heap binário
quando usado para realizar uma pesquisa. A lista a seguir fornece alguns dos destaques
dessas vantagens:

» A busca por um elemento requer tempo O(log n) quando a árvore está balanceada,
em contraste com o tempo O(n) para um heap binário.

» Imprimir os elementos em ordem requer apenas tempo O(log n) , em contraste com o tempo O(n
log n) para um heap binário.

» Encontrar o piso e o teto requer tempo O(log n) .

» A localização do K-ésimo elemento menor/maior requer tempo O(log n) quando a árvore está
configurada corretamente.

Se esses tempos são importantes depende da sua aplicação. O BST tende a funcionar
melhor em situações em que você gasta mais tempo pesquisando e menos tempo
construindo a árvore. Um heap binário tende a funcionar melhor em situações dinâmicas
nas quais as chaves mudam regularmente. O heap binário também oferece vantagens,
conforme descrito na lista a seguir:

» Criar as estruturas necessárias requer menos recursos porque heaps binários


dependem de arrays, tornando-os também mais amigáveis ao cache.

» Construir um heap binário requer tempo O(n) , em contraste com BST, que requer
O(n log n) tempo.

» Não é necessário usar ponteiros para implementar a árvore.

» Contar com variações de heap binário (por exemplo, o Heap de Fibonacci) oferece vantagens
como aumentar e diminuir os tempos-chave do tempo O(1) (para executar tarefas como
adicionar e remover itens).

Construindo uma árvore de busca binária


Você pode construir um BST usando vários métodos. Algumas pessoas usam um dicionário;
outros usam código personalizado (veja os artigos em https://interactivepython.
org/courselib/static/pythonds/Trees/SearchTreeImplementation.html
e https://code.activestate.com/recipes/577540-python-binary-search tree/ por exemplo). No
entanto, a maioria dos desenvolvedores não quer reinventar a roda quando se trata de BST.
Com isso em mente, você precisa de um pacote, como o bintrees, que forneça todas as
funcionalidades necessárias para criar e interagir com o BST usando um mínimo de código.
Para baixar e instalar bintrees, abra um prompt de comando, digite pip install bintrees e
pressione Enter. Você vê bintrees instaladas em seu sistema. A documentação para este
pacote aparece em https://pypi.org/
projeto/bintrees/2.2.0/.

CAPÍTULO 7 Organizando e Pesquisando Dados 129


Machine Translated by Google

Você pode usar bintrees para todos os tipos de necessidades, mas o exemplo nesta seção
analisa especificamente um BST. Neste caso, a árvore está desequilibrada. O código a seguir
mostra como construir e exibir um BST usando bintrees. (Você pode encontrar esse código no
arquivo de código-fonte para download A4D2E; 07; Search Techniques.ipynb ; consulte a
Introdução para obter detalhes.)

de bintrees importar BinaryTree

dados = {3:'Branco', 2:'Vermelho', 1:'Verde', 5:'Laranja',


4:'Amarelo', 7:'Roxo', 0:'Magenta'}

árvore = BinaryTree(dados)
tree.update({6:'Teal'})

def displayKeyValue(chave, valor):


print('Chave: ', chave, 'Valor: ', valor)

tree.foreach(displayKeyValue)
print('Item 3 contém: ', tree.get(3))
print('O item máximo é: ', tree.max_item())

Você vê esta saída ao executar o código:

Chave: 0 Valor: Magenta


Chave: 1 Valor: Verde
Chave: 2 Valor: Vermelho
Chave: 3 Valor: Branco
Chave: 4 Valor: Amarelo
Chave: 5 Valor: Laranja
Chave: 6 Valor: Teal
Chave: 7 Valor: Roxo
O item 3 contém: Branco
O item máximo é: (7, 'Roxo')

Para criar uma árvore binária, você deve fornecer pares de chave e valor. Uma maneira de
realizar essa tarefa é criar um dicionário conforme mostrado. Depois de criar a árvore, você pode
usar a função update() para adicionar novas entradas. As entradas devem incluir um par de
chave e valor, conforme mostrado.

Este exemplo usa uma função para realizar uma tarefa com os dados em árvore. Nesse caso, a
função apenas imprime os pares de chave e valor, mas você pode usar a árvore como entrada
para um algoritmo para análise (entre outras tarefas). A função, displayKeyValue(), atua como
entrada para a função foreach() , que exibe os pares de chave e valor como saída. Você também
tem acesso a uma infinidade de outros recursos, como usar get para obter um único item ou
max_item() para obter o máximo de itens armazenados na árvore.

130 PARTE 2 Entendendo a necessidade de classificar e pesquisar


Machine Translated by Google

Executando pesquisas especializadas


usando um heap binário
Assim como no BST, você tem muitas maneiras de implementar um heap binário. Escrever um
manual ou usar um dicionário funciona bem, mas confiar em um pacote torna as coisas
consideravelmente mais rápidas e confiáveis. O pacote heapq vem com o Python, então você
nem precisa instalá-lo. Você pode encontrar a documentação para este pacote em https://
docs.python.org/3/library/heapq.html. O exemplo a seguir mostra como construir e pesquisar um
heap binário usando heapq:

importar heapq

dados = {3:'Branco', 2:'Vermelho', 1:'Verde', 5:'Laranja',


4:'Amarelo', 7:'Roxo', 0:'Magenta'}

pilha = []
para chave, valor em data.items():
heapq.heappush(heap, (chave, valor))
heapq.heappush(heap, (6, 'Teal'))
heap.sort()

para item na pilha:


print('Chave: ', item[0], 'Valor: ', item[1])
print('Item 3 contém: ', heap[3][1])
print('O máximo de itens é: ', heapq.nlargest(1, heap))

A execução deste código produz a seguinte saída:

Chave: 0 Valor: Magenta


Chave: 1 Valor: Verde
Chave: 2 Valor: Vermelho
Chave: 3 Valor: Branco
Chave: 4 Valor: Amarelo
Chave: 5 Valor: Laranja
Chave: 6 Valor: Teal
Chave: 7 Valor: Roxo
O item 3 contém: Branco
O item máximo é: [(7, 'Roxo')]

O código de exemplo executa as mesmas tarefas e fornece a mesma saída que o exemplo da
seção anterior, exceto que ele depende de um heap binário neste caso.
O conjunto de dados é o mesmo de antes. No entanto, observe a diferença na maneira como
você adiciona os dados ao heap usando heappush(). Além disso, após adicionar um novo item,
você deve chamar sort() para garantir que os itens apareçam na ordem de classificação. Manipulando o

CAPÍTULO 7 Organizando e Pesquisando Dados 131


Machine Translated by Google

data é como manipular uma lista, em contraste com a abordagem de dicionário usada para bintrees.
Seja qual for a abordagem usada, vale a pena escolher uma opção que funcione bem com o
aplicativo que você deseja criar e forneça os tempos de pesquisa mais rápidos possíveis para as
tarefas que você executa.

Confiando em hash
Um grande problema com a maioria das rotinas de classificação é que elas classificam todos os dados em um conjunto de dados.

Quando o conjunto de dados é pequeno, você dificilmente percebe a quantidade de dados que a
rotina de classificação tenta mover. No entanto, à medida que o conjunto de dados aumenta, a
movimentação de dados se torna perceptível enquanto você fica sentado olhando para a tela por
horas a fio. Uma maneira de contornar esse problema é classificar apenas as informações principais.
Uma chave são os dados de identificação para um registro de dados específico. Quando você
interage com um registro de funcionário, o nome ou número do funcionário geralmente serve como
chave para acessar todas as outras informações que você tem sobre o funcionário. Não faz sentido
classificar todas as informações do funcionário quando você realmente precisa apenas das chaves
classificadas, que é o uso do hash. Ao trabalhar com essas estruturas de dados, você obtém uma
grande vantagem de velocidade ao classificar a menor quantidade de dados apresentados pelas
chaves, em vez dos registros como um todo.

Colocando tudo em baldes


Até agora, as rotinas de busca e classificação no livro funcionam realizando uma série de
comparações até que o algoritmo encontre o valor correto. O ato de realizar comparações torna os
algoritmos mais lentos porque cada comparação leva algum tempo para ser concluída.

Uma maneira mais inteligente de executar a tarefa envolve prever a localização de um determinado
item de dados na estrutura de dados (qualquer que seja essa estrutura) antes de realmente procurá-
lo. É isso que uma tabela de hash faz — fornece os meios para criar um índice de chaves que
aponta para itens individuais em uma estrutura de dados para que um algoritmo possa prever
facilmente a localização dos dados. Colocar chaves no índice envolve o uso de uma função de hash
que transforma a chave em um valor numérico. O valor numérico atua como um índice na tabela de
hash e a tabela de hash fornece um ponteiro para o registro completo no conjunto de dados. Como
a função hash produz resultados repetíveis, você pode prever a localização dos dados necessários.
Em muitos casos, uma tabela de hash fornece um tempo de pesquisa de O(1). Em outras palavras,
você precisa apenas de uma comparação para encontrar os dados.

132 PARTE 2 Entendendo a necessidade de classificar e pesquisar


Machine Translated by Google

Uma tabela de hash contém um número específico de slots que você pode visualizar como
buckets para armazenar dados. Cada slot pode conter um item de dados. O número de slots
preenchidos quando comparado ao número de slots disponíveis é o fator de carga. Quando o
fator de carga é alto, o potencial de colisões (onde duas entradas de dados têm o mesmo valor
de hash) também se torna maior. A próxima seção do capítulo discute como evitar colisões,
mas tudo o que você realmente precisa saber por enquanto é que elas podem ocorrer.

Um dos métodos mais típicos para calcular o valor de hash para uma entrada é obter o módulo
do valor dividido pelo número de slots. Por exemplo, se você deseja armazenar o número 54
em uma tabela de hash contendo 15 slots, o valor de hash é 9. Consequentemente, o valor 54
vai para o slot 9 da tabela de hash quando os slots são números de 0 a 14. tabela de hash
conterá muito mais slots, mas 15 funciona bem para os propósitos desta seção. Depois de
colocar o item no slot de hash, você pode usar a função de hash uma segunda vez para
encontrar sua localização.

Teoricamente, se você tiver uma função de hash perfeita e um número infinito de slots, cada
valor que você apresentar à função de hash produzirá um valor único. Em alguns casos, o
cálculo de hash pode se tornar bastante complexo para garantir valores únicos na maioria das
vezes. No entanto, quanto mais complexo o cálculo do hash, menos benefício você recebe do
hash, portanto, manter as coisas simples é o melhor caminho a seguir.

O hash pode funcionar com todos os tipos de estruturas de dados. No entanto, para fins de
demonstração, o exemplo a seguir usa uma lista simples para armazenar os dados originais e
uma segunda lista para armazenar o hash resultante. (Você pode encontrar esse código no
arquivo de código-fonte baixável A4D2E; 07; Hashing.ipynb ; consulte a Introdução para obter
detalhes.)

dados = [22, 40, 102, 105, 23, 31, 6, 5]


hash_table = [Nenhum] * 15
tblLen = len(tabela_hash)

def hash_function(valor, table_size):


valor de retorno % table_size

para valor em dados:

hash_table[hash_function(valor, tblLen)] = valor

print(tabela_hash)

A execução desse código produz os seguintes valores de hash:

[105, 31, Nenhum, Nenhum, Nenhum, 5, 6, 22, 23, Nenhum, 40, Nenhum,
102, Nenhum, Nenhum]

CAPÍTULO 7 Organizando e Pesquisando Dados 133


Machine Translated by Google

Para encontrar um valor específico novamente, basta executá-lo através de hash_function().


Por exemplo, print(hash_table[hash_function(102, tblLen)]) exibe 102 como saída após
localizar sua entrada em hash_table. Como os valores de hash são exclusivos nesse caso
específico, hash_function() pode localizar os dados necessários todas as vezes.

Evitando colisões
Um problema ocorre quando duas entradas de dados têm o mesmo valor de hash. Se você
simplesmente escrever o valor na tabela de hash, a segunda entrada substituirá a primeira,
resultando em perda de dados. Colisões, o uso do mesmo valor de hash por dois valores,
exigem que você tenha algum tipo de estratégia em mente para lidar com elas. Claro, a melhor
estratégia é evitar a colisão em primeiro lugar.

Um dos métodos para evitar colisões é garantir que você tenha uma tabela de hash grande o
suficiente. Manter o fator de carga baixo é sua primeira linha de defesa contra ter que se
tornar criativo no uso de sua tabela de hash. No entanto, mesmo com uma mesa grande, nem
sempre é possível evitar colisões. Às vezes, o conjunto de dados potencial é tão grande, mas
o conjunto de dados usado é tão pequeno que evitar o problema se torna impossível. Por
exemplo, se você tem uma escola com 400 crianças e depende de seu número de seguro
social para identificação, as colisões são inevitáveis porque ninguém vai criar uma tabela de
hash com um bilhão de entradas para tantas crianças. O desperdício de memória seria
enorme. Consequentemente, uma função de hash pode ter que usar mais do que apenas uma
saída de módulo simples para criar o valor de hash. Aqui estão algumas técnicas que você
pode usar para evitar colisões:

» Valores parciais: Ao trabalhar com alguns tipos de informação, parte dessa informação
se repete, o que pode gerar colisões. Por exemplo, os três primeiros dígitos de um
número de telefone podem se repetir para uma determinada área, portanto, remover
esses números e usar apenas os quatro restantes pode ajudar a resolver um problema
de colisão.

» Dobrar: Criar um número único pode ser tão fácil quanto dividir o número original em
partes, somar as partes e usar o resultado para o valor de hash. Por exemplo, usando
o número de telefone 555-1234, o hash pode começar dividindo-o em pedaços: 55 51
234 e, em seguida, somando o resultado para obter 340 como o número usado para
gerar o hash.

» Mid-square: O hash eleva o valor em questão ao quadrado, usa algum número de dígitos
do centro do número resultante e descarta o restante desses dígitos. Por exemplo,
considere o valor 120. Quando elevado ao quadrado, você obtém 14.400.
O hash pode usar 440 para gerar o valor do hash e descartar o 1 da esquerda e o 0 da
direita.

134 PARTE 2 Entendendo a necessidade de classificar e pesquisar


Machine Translated by Google

Obviamente, existem tantas maneiras de gerar o hash quanto alguém tem imaginação para
criá-las. Infelizmente, nenhuma quantidade de criatividade vai resolver todos os problemas de
colisão, e ainda é provável que ocorram colisões. Portanto, você precisa de outro plano.
Quando ocorre uma colisão, você pode usar um dos seguintes métodos para resolvê-la:

» Sondagem linear (um tipo de endereçamento aberto): O código armazena o valor no


próximo slot aberto, examinando os slots sequencialmente até encontrar um slot aberto
para usar. O problema com essa abordagem é que ela assume um slot aberto para cada
valor potencial, o que pode não ser o caso. Além disso, o endereçamento aberto significa
que a busca diminui consideravelmente após o aumento do fator de carga. Você não
pode mais encontrar o valor necessário na primeira comparação.

» Rehashing: O código faz o hash do valor de hash mais uma constante. Por exemplo,
considere o valor 1.020 ao trabalhar com uma tabela de hash contendo 30 slots e uma
constante de 100. O valor de hash neste caso é 22. No entanto, se o slot 22 já contém
um valor, rehashing ((22 + 100) % 30) produz um novo valor de hash de 2. Nesse caso,
você não precisa pesquisar um valor na tabela de hash sequencialmente. Quando
implementada corretamente, uma pesquisa ainda pode incluir um número baixo de
comparações para localizar o valor de destino.

» Encadeamento: Cada slot na tabela de hash pode conter vários valores. Você pode
implementar essa abordagem usando uma lista dentro de uma lista. Toda vez que
ocorre uma colisão, o código simplesmente anexa o valor à lista no slot de destino.
Essa abordagem oferece o benefício de saber que o hash sempre produzirá o slot
correto, mas a lista dentro desse slot ainda exigirá algum tipo de pesquisa sequencial (ou
outra) para encontrar o valor específico.

Criando sua própria função de hash


Às vezes, você pode precisar criar funções de hash personalizadas para atender às
necessidades do algoritmo usado ou para melhorar seu desempenho. Além dos usos
criptográficos (que merecem um livro sozinho), o Capítulo 12 apresenta algoritmos comuns que
alavancam diferentes funções de hash, como o Bloom Filter, o HyperLogLog e o Count-Min
Sketch, que aproveitam as propriedades de funções de hash personalizadas para extrair
informações a partir de enormes quantidades de dados.

CAPÍTULO 7 Organizando e Pesquisando Dados 135


Machine Translated by Google

DESCOBRINDO O INESPERADO
USOS DE HASHES
Além dos algoritmos detalhados neste livro, outros algoritmos importantes são baseados em
hashes. Por exemplo, o algoritmo de hash sensível à localidade (LSH) depende de um grande
número de funções de hash para unir informações aparentemente separadas. Se você quer
saber como as empresas de marketing e os serviços de inteligência juntam diferentes pedaços
de informação com base em nomes e endereços que não são idênticos (por exemplo, adivinhar
que “Los Angels”, “Los Angles” e “Los Angleles” se referem a Los Angeles) a resposta é LSH.
O LSH divide as informações para verificar em partes e as digere usando muitas funções de
hash, resultando na produção de um resultado de hash especial, que é um endereço para um
bucket usado para armazenar palavras semelhantes. O LSH é bastante complexo em sua
implementação, mas confira este material do Massachusetts Institute of Technology (MIT): http://
www.mit.edu/~andoni/LSH/.

Você pode encontrar muitos exemplos de diferentes funções de hash no Python hashlib
pacote. O pacote hashlib contém algoritmos como estes:

» Algoritmos de Hash Seguro (SHA): Esses algoritmos incluem SHA1, SHA224,


SHA256, SHA384 e SHA512. Lançado pelo Instituto Nacional de Padrões e Tecnologia
(NIST) como um Padrão Federal de Processamento de Informações (FIPS) dos EUA,
os algoritmos SHA fornecem suporte para aplicativos e protocolos de segurança.

» Algoritmo MD5 da RSA: Inicialmente projetado para aplicações de segurança, esse


hash se tornou uma forma popular de arquivos de soma de verificação. As somas
de verificação reduzem os arquivos a um único número que permite determinar se
o arquivo foi modificado desde a criação do hash (permitindo determinar se o arquivo
baixado não foi corrompido e não foi alterado por um hacker). Para garantir a
integridade do arquivo, basta verificar se o checksum MD5 de sua cópia corresponde
ao original comunicado pelo autor do arquivo.

No entanto, você pode combinar a saída de várias funções de hash ao trabalhar com
aplicativos complexos que dependem de um grande conjunto de dados. Basta somar os
resultados das várias saídas depois de ter feito uma multiplicação em uma ou mais delas.
A soma de duas funções de hash tratadas dessa maneira retém as qualidades das
funções de hash originais, mesmo que o resultado seja diferente e impossível de
recuperar como os elementos originais da soma. Usar essa abordagem significa que
você tem uma nova função de hash para usar como sua receita de hash secreta para
algoritmos e aplicativos.

136 PARTE 2 Entendendo a necessidade de classificar e pesquisar


Machine Translated by Google

O trecho de código a seguir depende do pacote hashlib e do md5 e sha1


algoritmos de hash. Você apenas fornece um número para usar na multiplicação dentro da
soma de hash. (Como os números são infinitos, você tem uma função que pode produzir hashes
infinitos.)

de hashlib import md5, sha1

def hash_f(elemento, i, comprimento):


""" Função para criar muitas funções de hash """
h1 = int(md5(element.encode('ascii')).hexdigest(),16)
h2 = int(sha1(element.encode('ascii')).hexdigest(),16)
return (h1 + i*h2) % comprimento

Aqui está algum código para testar a função hash_f() com sua saída associada:

print (hash_f("CAT", 1, 10**5))


64018

print (hash_f("CAT", 2, 10**5))


43738

Se você quer saber onde encontrar outros usos de tabelas de hash ao seu redor, confira os
dicionários do Python. Dicionários são, na verdade, tabelas de hash, embora tenham uma
maneira inteligente de lidar com colisões, e você não perderá seus dados porque duas chaves
com hash casualmente têm o mesmo resultado. O fato de o índice do dicionário usar um hash
também é o motivo de sua velocidade na verificação da presença de uma chave. Além disso, o
uso de um hash explica por que você não pode usar todos os tipos de dados como uma chave.
A chave que você escolher deve ser algo que o Python possa transformar em um resultado de
hash. As listas, por exemplo, não podem ser compartilhadas porque são mutáveis; você pode
alterá-los adicionando ou removendo elementos. No entanto, se você transformar sua lista em
uma string, poderá usá-la como chave para um dicionário em Python.

CAPÍTULO 7 Organizando e Pesquisando Dados 137


Machine Translated by Google
Machine Translated by Google

3 Explorando o
mundo dos gráficos
Machine Translated by Google

NESTA PARTE . . .

Considerando gráficos e seus usos

Interagindo com gráficos de várias maneiras

Navegando em gráficos para atender a necessidades específicas

Pesquisando e classificando páginas da web


Machine Translated by Google

NESTE CAPÍTULO

» Definindo por que as redes são importantes

» Demonstrando técnicas de desenho


gráfico

» Considerando a funcionalidade do gráfico

» Usando formatos numéricos para


representar gráficos

Capítulo 8

Entendendo o gráfico
Fundamentos

ligados por um número de arestas ou arcos (dependendo da representação).


GrafosQuando
são estruturas
você pensaque apresentam
em um grafo, penseum
emnúmero de nós
uma estrutura (ou
como umvértices)
mapa,
onde cada local no mapa é um nó e as ruas são as arestas. Esta apresentação difere
de uma árvore onde cada caminho termina em um nó folha. Uma árvore pode parecer
um organograma ou uma hierarquia familiar. Mais importante, as estruturas das árvores
realmente se parecem com árvores e têm um início e um fim definidos. Este capítulo
começa ajudando você a entender a importância das redes, que são um tipo de gráfico
comumente usado para todos os tipos de propósitos.

Você pode representar gráficos de várias maneiras, a maioria delas abstratas. A menos que
você seja realmente bom em visualizar abstrações em sua mente (a maioria das pessoas não
é), você precisa saber como desenhar um gráfico para que você possa realmente vê-lo. As
pessoas confiam em sua visão para entender como as coisas funcionam. O ato de transformar
os números que representam um gráfico em uma visualização gráfica é plotagem. Linguagens
como Python são excelentes em plotagem porque é um recurso incrivelmente importante. Na
verdade, a plotagem é uma das razões pelas quais este livro usa Python em vez de outra
linguagem, como C (que é boa para executar um conjunto de tarefas completamente diferente).

CAPÍTULO 8 Noções básicas de gráficos 141


Machine Translated by Google

Depois de visualizar um gráfico, você precisa saber o que fazer com a representação gráfica. Este
capítulo começa medindo a funcionalidade do gráfico. Você executa tarefas como contar as arestas e
vértices para determinar coisas como a complexidade do gráfico. Ver um gráfico também permite que
você execute tarefas como centralidade de computação com maior facilidade. Você constrói sobre o
que descobriu neste capítulo no Capítulo 9.

A apresentação numérica de um gráfico é importante, mesmo que dificulte a compreensão do gráfico.


O enredo é para você, mas o computador realmente não entende o enredo (apesar de tê-lo desenhado
para você). Pense no computador mais como um pensador abstrato. Com a necessidade de apresentar
um gráfico em uma forma que o computador possa manipular, este capítulo discute três técnicas para
colocar um gráfico em formato numérico: matrizes, representações esparsas e listas. Todas essas
técnicas têm vantagens e desvantagens, e você as usará de maneiras específicas em capítulos futuros
(começando com o Capítulo 9). Outras maneiras também estão disponíveis para colocar um gráfico em
formato numérico, mas esses três métodos servirão bem para você se comunicar com o computador.

Você não precisa digitar o código-fonte para este capítulo manualmente. Na verdade,
usar a fonte para download é muito mais fácil. Você pode encontrar a fonte deste
capítulo no A4D2E; 08; Desenhe Graph.ipynb, A4D2E; 08; Centralidade do
gráfico.ipynb, A4D2E; 08; Conversão de gráfico.ipynb e A4D2E; 08; Medidas do gráfico.ipynb
arquivos da fonte para download. Consulte a Introdução para obter detalhes sobre como localizar esses
arquivos de origem.

Explicando a importância das redes


Uma rede é um tipo de grafo que associa nomes aos vértices (nós ou pontos), arestas (arcos ou linhas),
ou ambos. Associar nomes aos recursos do gráfico reduz o nível de abstração e facilita a compreensão
do gráfico. Os dados que os gráficos modelam tornam-se reais na mente da pessoa que os visualiza,
mesmo que o gráfico seja realmente uma abstração do mundo real colocado em uma forma que
humanos e computadores possam entender de maneiras diferentes. As seções a seguir o ajudam a
entender melhor a importância das redes para que você possa ver como seu uso neste livro simplifica
a tarefa de descobrir como os algoritmos funcionam e como você pode se beneficiar de seu uso.

Considerando a essência de um gráfico


Os grafos aparecem como pares ordenados na forma G = (V, E), onde G é o grafo, V é uma lista de
vértices e E é uma lista de arestas que conectam os vértices. Uma borda é

142 PARTE 3 Explorando o mundo dos gráficos


Machine Translated by Google

na verdade um par numérico que expressa os dois vértices que ele conecta. Conseqüentemente,
se você tem dois vértices que representam cidades, Houston (que é igual a 1) e Dallas (que é igual a
2), e deseja conectá-los com uma estrada, você cria uma aresta, Highway, que contém um par de
referências de vértices , Rodovia = [Houston, Dallas]. O gráfico apareceria como G = [(Houston,
Dallas)], que simplesmente diz que existe um primeiro vértice, Houston, com uma conexão com
Dallas, o segundo vértice. Usando a ordem de apresentação dos vértices, Houston é adjacente a
Dallas; em outras palavras, um carro sairia de Houston e entraria em Dallas.

Os gráficos vêm em várias formas. Um grafo não direcionado (como mostrado na Figura 8-1) é
aquele em que a ordem das entradas das arestas não importa. Um mapa rodoviário representaria um
gráfico não direcionado na maioria dos casos, porque o tráfego pode viajar ao longo da estrada em
ambas as direções.

FIGURA 8-1:
Apresentando
um gráfico simples não
direcionado.

Um grafo direcionado, como o mostrado na Figura 8-2, é aquele em que a ordem das entradas das
arestas importa porque o fluxo é da primeira entrada para a segunda. Nesse caso, a maioria das
pessoas chama as arestas de arcos para diferenciá-las das entradas não direcionadas. Considere
uma representação gráfica de uma sequência de semáforos em que Vermelho é igual a 1, Amarelo é
igual a 2 e Verde é igual a 3. Os três arcos necessários para expressar a sequência são: Go = [Red,
Green], Caution = [Green, Yellow] e Parar = [Amarelo, Vermelho]. A ordem das entradas é importante
porque o fluxo de Go, para Caution, para Stop é importante. Imagine o caos que resultaria se a luz
do sinal escolhesse ignorar a natureza do gráfico direcionado da sequência.

CAPÍTULO 8 Noções básicas de gráficos 143


Machine Translated by Google

FIGURA 8-2:
Criando a versão
direcionada do mesmo
gráfico.

Um terceiro tipo essencial de gráfico que você deve considerar é o gráfico misto. Pense no
roteiro novamente. Nem sempre é verdade que o tráfego flui nos dois sentidos em todas
as estradas. Ao criar alguns mapas, você deve considerar a presença de ruas de mão
única. Na Figura 8-3, você vê que deve ir do Ponto D para chegar ao Ponto A. No entanto,
você pode ir do Ponto E em qualquer uma das quatro direções e dos Pontos B, D, F e H
para chegar ao Ponto E Consequentemente, você precisa de subgráficos não direcionados
e direcionados no mesmo gráfico, que é o que você obtém com um gráfico misto.

FIGURA 8-3:
Um gráfico misto
mostra uma mistura
de subgráficos
direcionados

e não direcionados.

Outro tipo de gráfico a ser considerado é o gráfico ponderado (mostrado na Figura 8-4),
que possui valores atribuídos a cada uma das arestas ou arcos. Pense no roteiro novamente.
As pessoas muitas vezes querem saber mais do que simplesmente a direção a seguir;
eles também querem saber a que distância está o próximo destino ou quanto tempo alocar

144 PARTE 3 Explorando o mundo dos gráficos


Machine Translated by Google

por chegar lá. Um gráfico ponderado fornece esse tipo de informação e você usa os pesos de
muitas maneiras diferentes ao realizar cálculos usando gráficos.

FIGURA 8-4:
Usando um gráfico
ponderado para
tornar as coisas
mais realistas.

Junto com o gráfico ponderado, você também pode precisar de um gráfico rotulado de vértice
ao criar um roteiro. Ao trabalhar com um grafo rotulado de vértice, cada vértice tem um nome
associado a ele. Considere olhar para um mapa rodoviário onde o cartógrafo não tenha
rotulado as cidades. Sim, você pode ver as cidades, mas não sabe qual é qual sem rótulos.
Você pode encontrar tipos de gráficos adicionais descritos em http://web.cecs.pdx.edu/~sheard/
course/Cs163/Doc/Graphs.html.

Encontrando gráficos em todos os lugares

Os gráficos podem parecer um daqueles recursos matemáticos esotéricos que você achava
chatos na escola, mas os gráficos são realmente muito empolgantes porque você os usa o
tempo todo sem realmente pensar nisso. É claro que ajuda saber que você normalmente não
lidará com os números por trás dos gráficos. Pense em um mapa. O que você vê é um gráfico,
mas você o vê em formato gráfico, com cidades, estradas e todo tipo de outros recursos. A
questão é que, quando você vê um mapa, pensa em um mapa, não em um gráfico (mas seu
GPS vê um gráfico, e é por isso que sempre pode sugerir o caminho mais curto para o seu
destino). Se você começar a olhar ao redor, encontrará muitos itens comuns que são gráficos,
mas são chamados de outra coisa.

CAPÍTULO 8 Noções básicas de gráficos 145


Machine Translated by Google

Alguns gráficos não são de natureza visual, mas você ainda não os vê como gráficos. Por exemplo,
os sistemas de menu de telefone são uma forma de gráfico direcional. Na verdade, apesar de toda
a sua aparente simplicidade, os gráficos telefônicos são um tanto complexos. Eles podem incluir
loops e todos os tipos de outras estruturas interessantes. Algo que você pode tentar é mapear o
gráfico para um sistema de menus em algum ponto. Você pode se surpreender com o quão
complexo alguns deles podem ser.

Outra forma de sistema de menus aparece como parte dos aplicativos. Para executar tarefas, a
maioria dos aplicativos conduz você por uma série de etapas em um tipo especial de subaplicativo
chamado assistente. O uso de assistentes torna aplicativos aparentemente complexos muito mais
fáceis de usar, mas para que os assistentes funcionem, o desenvolvedor do aplicativo deve criar
um gráfico que descreva a série de etapas.

Pode surpreendê-lo descobrir que até as receitas dos livros de receitas são uma espécie de gráfico
(e criar uma representação pictórica das relações entre os ingredientes pode ser interessante).
Cada ingrediente na receita é um nó. Os nós se conectam usando as bordas criadas pelas
instruções para misturar os ingredientes. É claro que uma receita é apenas um tipo de química, e
os gráficos químicos mostram a relação entre os elementos de uma molécula. (Sim, as pessoas
realmente estão tendo essa discussão; você pode ver um desses tópicos em https://
stackoverflow.com/questions/7749073/
representando-uma-receita-de-cozinhar-em-um-gráfico-banco de dados, e você pode
até encontrar um artigo sobre isso em https://medium.com/@condenastitaly/when-
food-meets ai-the-smart-recipe-project-eea259f53ed2.)

O ponto é que você vê esses gráficos o tempo todo, mas não os vê como gráficos – você os vê
como outra coisa, como uma receita ou uma fórmula química.
Os gráficos podem representar muitos tipos de relacionamentos entre objetos, implicando uma
sequência de ordem, dependência de tempo ou causalidade.

Mostrando o lado social dos gráficos


Os gráficos têm implicações sociais porque muitas vezes refletem relacionamentos entre pessoas
em vários ambientes. Um dos usos mais óbvios dos gráficos é o organograma. Pense nisso: cada
nó é uma pessoa diferente na organização, com arestas conectando os nós para mostrar os vários
relacionamentos entre os indivíduos. O mesmo vale para todos os tipos de gráficos, como aqueles
que mostram a história da família. No entanto, no primeiro caso, o gráfico não é direcionado porque
a comunicação flui nos dois sentidos entre gerentes e subordinados (embora a natureza da
conversa seja diferente com base na direção). No segundo caso, o gráfico é direcionado porque
dois pais têm filhos. O fluxo mostra a direção da hereditariedade de um membro fundador para os
filhos atuais.

As mídias sociais também se beneficiam do uso de gráficos. Por exemplo, existe uma indústria
inteira para analisar as relações entre tweets no Twitter (https://

146 PARTE 3 Explorando o mundo dos gráficos


Machine Translated by Google

twittertoolsbook.com/10-awesome-twitter-analytics-visualization tools/ fala sobre apenas


algumas dessas ferramentas). A análise baseia-se no uso de gráficos para descobrir as
relações entre tweets individuais.

No entanto, você não precisa olhar para nada mais misterioso do que e-mail para ver
gráficos usados para necessidades sociais. O corpus da Enron inclui as 200.399
mensagens de e-mail de 158 executivos seniores, despejadas na Internet pela Federal
Energy Regulatory Commission (FERC). Cientistas e acadêmicos usaram esse corpus
para criar muitos gráficos sociais para divulgar como a sétima maior empresa dos Estados
Unidos precisou declarar falência em 2001 (ver https://www.
technologyreview.com/2013/07/02/177506/the-immortal-life-of-the-enron-e-mails/ _ e
https://new.pythonforengineers.com/blog/analysing the-enron-email-corpus/ para saber
como este corpus ajudou e está realmente ajudando a avançar na análise de gráficos
complexos).

Até mesmo seu computador tem gráficos sociais nele. Não importa qual aplicativo de e-mail você use,
você pode agrupar e-mails de várias maneiras, e esses métodos de agrupamento normalmente
dependem de gráficos para fornecer uma estrutura. Afinal, tentar seguir o fluxo da discussão sem
saber quais mensagens são respostas a outras mensagens é uma causa perdida. Sim, você poderia
fazer isso, mas à medida que o número de mensagens aumenta, o esforço requer cada vez mais
tempo até que seja desperdiçado por causa das restrições de tempo que a maioria das pessoas tem.

Entendendo os subgráficos
As relações representadas por gráficos podem tornar-se bastante complexas. Por exemplo, ao retratar
ruas da cidade, a maioria das ruas permite tráfego em ambas as direções, tornando um gráfico não
direcionado perfeito para fins de representação. No entanto, algumas ruas permitem o tráfego em
apenas uma direção, o que significa que você precisa de um grafo direcionado neste caso. A
combinação de ruas de mão dupla e de mão única torna a representação usando um único tipo de
gráfico impossível (ou pelo menos inconveniente). Misturar gráficos não direcionados e direcionados
em um único gráfico significa que você deve criar subgráficos para representar cada tipo de gráfico e,
em seguida, conectar os subgráficos em um gráfico maior. Alguns gráficos que contêm subgráficos
são tão comuns que possuem nomes específicos, o que é um gráfico misto neste caso.

Os subgráficos também são úteis para outros propósitos. Por exemplo, você pode querer analisar um
loop dentro de um gráfico, o que significa descrever esse loop como um subgrafo.
Você não precisa do gráfico inteiro, apenas dos nós e arestas necessários para realizar a análise.
Todos os tipos de disciplinas usam essa abordagem. Sim, os desenvolvedores o usam para garantir
que partes de um aplicativo funcionem conforme o esperado, mas os engenheiros da cidade também
o usam para entender a natureza do fluxo de tráfego em uma seção particularmente movimentada da cidade.
Profissionais médicos também usam subgráficos para entender o fluxo de sangue ou outros líquidos
entre os órgãos do corpo. Os órgãos são os nódulos e o sangue

CAPÍTULO 8 Noções básicas de gráficos 147


Machine Translated by Google

vasos são as bordas. Na verdade, muitos desses gráficos são ponderados – é essencial saber quanto
sangue está fluindo, não apenas o que está fluindo.

Gráficos complexos também podem ocultar padrões que você precisa conhecer. Por exemplo, o
mesmo ciclo pode aparecer em várias partes do gráfico ou você pode ver o mesmo ciclo em gráficos
diferentes. Ao criar um subgráfico do ciclo, você pode facilmente realizar comparações dentro do
mesmo gráfico ou entre gráficos para ver como eles se comparam. Por exemplo, um biólogo pode
querer comparar o ciclo de mutação de um animal com o ciclo de mutação de outro animal. Para fazer
essa comparação, o biólogo precisaria criar a representação como um subgráfico dos processos para
todo o animal. (Você pode ver uma visão interessante desse uso específico de gráficos em https://
www.sciencedirect.com/science/article/

pii/S1359027896000569.) O gráfico aparece próximo ao início do artigo como


Figura 1.

Definindo como desenhar um gráfico


Algumas pessoas podem visualizar dados diretamente em suas mentes. No entanto, a maioria das
pessoas realmente precisa de uma apresentação gráfica dos dados para entendê-los. Este ponto fica
claro pelo uso de gráficos em apresentações de negócios. Você pode contar aos outros sobre as
vendas do ano passado apresentando tabelas de números. Depois de um tempo, a maioria de seu
público cochilaria e você nunca conseguiria entender seu ponto de vista. A razão é simples: as tabelas
de números são precisas e apresentam muitas informações, mas não o fazem de uma forma que as
pessoas entendam.

Plotar os dados e mostrar os números de vendas como um gráfico de barras ajuda as pessoas a ver
as relações entre os números com maior facilidade. Se você quiser apontar que as vendas estão
aumentando a cada ano, um gráfico de barras com barras de comprimento crescente mostra esse
ponto rapidamente. Curiosamente, usar o gráfico realmente apresenta os dados de uma maneira
menos precisa. Tentar ver que a empresa faturou US$ 3.400.026,15 no ano passado e US$
3.552.215,82 este ano, olhando para um gráfico de barras, é quase impossível. Sim, a tabela mostraria
essas informações, mas as pessoas realmente não precisam conhecer esse nível de detalhe - elas
simplesmente precisam ver o aumento anual - o contraste nos ganhos de ano para ano. No entanto,
seu computador está interessado em detalhes, e é por isso que os gráficos são para humanos e as
matrizes são para computadores.

As seções a seguir ajudam você a descobrir as maravilhas da plotagem. Você obtém uma rápida visão
geral de como os gráficos funcionam com o Python. (Esses princípios aparecem em capítulos
posteriores de forma mais detalhada.) As seções a seguir oferecem um começo para que você possa
entender mais facilmente os gráficos apresentados posteriormente.

148 PARTE 3 Explorando o mundo dos gráficos


Machine Translated by Google

Distinguindo os atributos-chave
Antes de desenhar um gráfico, você precisa conhecer os atributos do gráfico. Como
mencionado anteriormente, os grafos consistem em nós (ou vértices) e arestas (para grafos
não direcionados) ou arcos (para grafos direcionados). Qualquer gráfico que você deseja
desenhar conterá esses elementos. No entanto, como você representa esses elementos
depende em parte do pacote que você escolhe usar. Por uma questão de simplicidade, o
livro conta com uma combinação de dois pacotes:

» NetworkX (https://networkx.org/): Contém código para desenhar gráficos.

» matplotlib (http://matplotlib.org/): Fornece acesso a todos os tipos de


rotinas de desenho, algumas das quais podem exibir gráficos criados pelo NetworkX.

Para usar pacotes em Python, você deve importá-los. Quando você precisa usar pacotes
externos, você deve adicionar um código especial, como as seguintes linhas de código que
fornecem acesso ao matplotlib e networkx. (Você pode encontrar esse código no arquivo de
código-fonte para download A4D2E; 08; Draw Graph.ipynb ; consulte a Introdução para obter
detalhes.)

importar networkx como nx


importar matplotlib.pyplot como plt

Agora que você tem acesso aos pacotes, crie um gráfico. Nesse caso, um gráfico é uma
espécie de contêiner que contém os principais atributos que definem o gráfico. A criação de
um contêiner permite desenhar o gráfico para que você possa vê-lo mais tarde. O código a
seguir cria um objeto NetworkX Graph :

AGraph = nx.Graph()

Adicionar os atributos-chave ao AGraph vem a seguir. Você deve adicionar nós e arestas
usando o seguinte código:

Nós = intervalo(1,5)
Bordas = [(1,2), (2,3), (3,4), (4,5), (1,3), (1,5)]

Como mencionado anteriormente, as arestas descrevem as conexões entre os nós. Nesse


caso, Nodes contém valores de 1 a 5, portanto, Edges contém conexões entre esses valores.

CAPÍTULO 8 Noções básicas de gráficos 149


Machine Translated by Google

API NETWORKX
Às vezes, um desenvolvedor faz uma grande alteração de código em uma API (Application
Programming Interface) para melhorar um produto. Este livro usa amplamente o NetworkX para
reduzir o trabalho necessário para criar gráficos. No entanto, a versão do NetworkX usada neste
livro é uma versão mais recente que é incompatível com o código mais antigo. Para trabalhar com
os exemplos deste livro, você deve ter o NetworkX 2.5 ou superior instalado.
Os exemplos foram testados usando o NetworkX 2.6.2, mas qualquer versão 2.5 ou superior
funcionará. Para verificar sua versão do NetworkX, use a lista pip ou o comando conda list
networkx . Você encontra NetworkX usado nos Capítulos 8, 9, 10, 11 e 16.

Claro, os nós e arestas estão apenas sentados lá agora e não aparecerão como parte do AGraph. Você
deve colocá-los no recipiente para vê-los. Use o código a seguir para adicionar os nós e arestas ao
AGraph.

AGraph.add_nodes_from(Nós)
AGraph.add_edges_from(Edges)

O pacote NetworkX contém todos os tipos de funções que você pode usar para interagir com nós e
arestas individuais, mas a abordagem mostrada aqui é a maneira mais rápida de fazer as coisas. Mesmo
assim, você pode descobrir que deseja adicionar arestas adicionais posteriormente. Por exemplo, você
pode querer adicionar uma aresta entre 2 e 4, nesse caso você chamaria a função AGraph.add_edge(2,
4) .

Desenhando o gráfico
Você pode interagir de várias maneiras com o objeto container AGraph que você criou na seção
anterior, mas muitas dessas maneiras de interagir são abstratas e não muito satisfatórias se você for
uma pessoa visualmente orientada. Às vezes é bom ver o que um objeto contém olhando para ele. O
código a seguir exibe o gráfico contido em AGraph:

draw_params = {
'with_labels':Verdadeiro,
'node_color':'skyblue',
'node_size':700, 'width':2,
'font_size':14,
'pos': nx.layout.spring_layout(AGraph, seed=1)}

nx.draw(AGraph, **draw_params)
plt.show()

150 PARTE 3 Explorando o mundo dos gráficos


Machine Translated by Google

O processo de desenho começa criando draw_params, que descreve como desenhar


o gráfico. Este exemplo diz ao NetworkX para usar rótulos, para definir a cor do nó
como azul celeste e vários outros recursos.

A função draw() fornece vários argumentos que você pode usar para vestir a tela. No
entanto, você pode fornecer esses argumentos separadamente usando draw_params,
conforme mostrado no código anterior. A Figura 8-5 mostra o gráfico contido no AGraph.

FIGURA 8-5:
Ver o que um
gráfico contém
torna mais fácil
Compreendo.

Como medir a funcionalidade do gráfico


Depois de visualizar e entender um gráfico, você precisa considerar a questão de quais
partes do gráfico são importantes. Afinal, você não quer gastar seu tempo realizando
análises em dados que realmente não importam no grande esquema das coisas. Pense
em alguém que está analisando o fluxo de tráfego para melhorar o sistema viário. As
interseções representam os vértices e as ruas representam as arestas ao longo das
quais o tráfego flui. Ao saber como o tráfego flui, ou seja, quais vértices e arestas
recebem mais tráfego, você pode começar a pensar em quais estradas alargar e quais
precisam de mais reparos porque mais tráfego as utiliza.

No entanto, apenas olhar para as ruas individuais não é suficiente. Um novo arranha-
céu pode trazer muito tráfego que afeta uma área inteira. O arranha-céu representa um
ponto central em torno do qual o fluxo de tráfego se torna mais importante. Os vértices
mais importantes são os centrais do novo arranha-céu. Calculando a centralidade,
os vértices mais importantes em um grafo, podem ajudá-lo a entender quais partes do
grafo requerem mais atenção. As seções a seguir discutem os problemas básicos que
você deve considerar ao medir a funcionalidade do gráfico, que é a capacidade do
gráfico de modelar um problema específico.

CAPÍTULO 8 Noções básicas de gráficos 151


Machine Translated by Google

Contando arestas e vértices


À medida que os gráficos se tornam mais complexos, eles transmitem mais informações, mas
também se tornam mais difíceis de entender e manipular. O número de arestas e vértices em
um grafo determina a complexidade do grafo. No entanto, você usa a combinação de arestas
e vértices para contar a história completa. Por exemplo, você pode ter um nó que não está
conectado aos outros nós de forma alguma. É legal criar tal nó em um gráfico para representar
um valor que não tenha conexões com os outros. Usando o código a seguir, você pode
determinar facilmente que o nó 6 não tem conexões com os outros porque não possui nenhuma
informação de borda. (Você pode encontrar esse código no arquivo de código-fonte para
download A4D2E; 08; Graph Measurements.ipynb ; consulte a Introdução para obter detalhes
sobre como encontrar esse arquivo.)

importar networkx como nx


importar matplotlib.pyplot como plt

AGraph = nx.Graph()

Nós = intervalo(1,5)
Bordas = [(1,2), (2,3), (3,4), (4,5), (1,3), (1,5)]

AGraph.add_nodes_from(Nós)
AGraph.add_edges_from(Edges)

AGraph.add_node(6)
sorted(nx.connected_components(AGraph))

Ao executar este código, você vê esta saída:

[{1, 2, 3, 4, 5}, {6}]

A saída desse código mostra que os nós de 1 a 5 estão conectados e que o nó 6 não possui
uma conexão. Você pode remediar essa situação adicionando outra borda; use o código a
seguir e verifique novamente:

AGraph.add_edge(1,6)
sorted(nx.connected_components(AGraph))

Desta vez você vê a saída desejada de

[{1, 2, 3, 4, 5, 6}]

A saída agora mostra que cada um dos nós se conecta a pelo menos um outro nó. No entanto,
você não sabe quais nós têm mais conexões. o

152 PARTE 3 Explorando o mundo dos gráficos


Machine Translated by Google

contagem de arestas para um determinado nó é o grau. Quanto maior o grau, mais


complexo o nó se torna. Ao conhecer o grau, você pode desenvolver uma ideia de quais
nós são mais importantes. O código a seguir mostra como obter o grau para o gráfico de
exemplo:

print([val for (node, val) em AGraph.degree()])

Ao executar este código, você vê os graus de cada nó em ordem:

[4, 2, 3, 2, 2, 1]

Os valores de grau aparecem na ordem dos nós, então o nó 1 tem quatro conexões e o
nó 6 tem apenas uma conexão. O nó 1 é, portanto, o mais importante, seguido pelo nó
3, que possui três conexões.

Ao modelar dados do mundo real, como os tweets sobre um tópico específico, os nós
também tendem a se agrupar. Você pode pensar nessa tendência como um tipo de
tendência – o que as pessoas sentem que é importante agora. O termo matemático
sofisticado para essa tendência é agrupamento, e medir essa tendência ajuda a entender
qual grupo de nós é mais importante em um gráfico. Aqui está o código que você usa
para medir o clustering para o gráfico de exemplo:

nx.clustering(AGraph)

A saída dessa medição se mostra interessante, conforme mostrado aqui:

{1: 0,16666666666666666, 2: 1,0, 3: 0,3333333333333333,


4: 0,0, 5: 0,0, 6: 0,0}

A saída mostra que é mais provável que os nós se agrupem em torno do nó 2, mesmo
que o nó 1 tenha o grau mais alto. Isso porque ambos os nós 1 e 3 têm graus altos e o
nó 2 está entre eles.

Agrupar gráficos ajuda a entender os dados. A técnica ajuda a mostrar que alguns nós
no grafo estão melhor conectados e alguns nós correm o risco de isolamento. Quando
você entende como os elementos se conectam em um gráfico, pode determinar como
fortalecer sua estrutura ou, ao contrário, destruí-la. Durante a Guerra Fria, cientistas
militares dos Estados Unidos e do bloco soviético estudaram agrupamento de gráficos
para entender melhor como interromper a cadeia de suprimentos do outro lado em caso
de conflito.

CAPÍTULO 8 Noções básicas de gráficos 153


Machine Translated by Google

USO DE ESPAÇO EM BRANCO NA SAÍDA

A saída para este exemplo aparece em duas linhas no livro, embora apareça em apenas
uma linha no Jupyter Notebook. A adição de espaço em branco ajuda a saída a aparecer
em um tamanho legível na página — não afeta as informações reais. Outros exemplos no
livro também mostram a saída em várias linhas, mesmo quando aparece em uma única
linha no Jupyter Notebook.

Centralidade de computação
A centralidade vem em várias formas diferentes, porque a importância geralmente depende de
diferentes fatores. Os elementos importantes de um gráfico ao analisar tweets serão diferentes
dos elementos importantes ao analisar o fluxo de tráfego. Felizmente, o NetworkX fornece
vários métodos para calcular a centralidade. Por exemplo, você pode calcular a centralidade
com base nos graus dos nós. O código a seguir usa o gráfico modificado da seção anterior do
capítulo. (Você pode encontrar esse código no arquivo de código-fonte para download A4D2E;
08; Graph Centrality.ipynb ; consulte a Introdução para obter detalhes sobre onde encontrar
esse arquivo.)

importar networkx como nx


importar matplotlib.pyplot como plt

AGraph = nx.Graph()

Nós = intervalo(1,6)
Bordas = [(1,2), (2,3), (3,4), (4,5),
(1,3), (1,5), (1,6)]

AGraph.add_nodes_from(Nós)
AGraph.add_edges_from(Edges)

nx.degree_centrality(AGraph)

Ao executar esse código, você vê a seguinte saída:

{1: 0,8, 2: 0,4, 3: 0,6000000000000001, 4: 0,4, 5: 0,4,


6: 0,2}

Os valores diferem pelo número de conexões para cada nó. Como o nó 1 tem quatro conexões
(tem o grau mais alto), ele também tem a centralidade mais alta. Você pode ver como isso
funciona plotando o gráfico usando este código:

154 PARTE 3 Explorando o mundo dos gráficos


Machine Translated by Google

draw_params = {
'with_labels':Verdadeiro,
'node_color':'skyblue',
'node_size':700, 'width':2,
'font_size':14,
'pos': nx.layout.spring_layout(AGraph, seed=5)}

nx.draw(AGraph, **draw_params)

Este código produz a saída mostrada na Figura 8-6.

FIGURA 8-6:
Traçar o gráfico
pode ajudá-lo a ver
a centralidade de
grau com maior facilidade.

O nó 1 está de fato no centro do gráfico com mais conexões. O grau do nó 1 garante


que seja o mais importante com base no número de conexões.
Ao trabalhar com gráficos direcionados, você também pode usar as funções
in_degree_central ity() e out_degree_centrality() para determinar a centralidade de
grau com base no tipo de conexão em vez de apenas no número de conexões.

Ao trabalhar com análise de tráfego, pode ser necessário determinar quais locais
são centrais com base em sua distância a outros nós. Mesmo que um shopping
center na periferia possa ter todos os tipos de conexões com ele, o fato de estar na
periferia pode reduzir seu impacto no tráfego. No entanto, um supermercado no
centro da cidade com poucas conexões pode ter um grande impacto no tráfego
porque está próximo a tantos outros nós. Para ver como isso funciona, adicione
outro nó, 7, que está desconectado do gráfico. A centralidade desse nó é infinita
porque nenhum outro nó pode alcançá-lo. O código a seguir mostra como calcular a
centralidade de proximidade para os vários nós no gráfico de exemplo:

AGraph.add_node(7)
nx.closeness_centrality(AGraph)

CAPÍTULO 8 Noções básicas de gráficos 155


Machine Translated by Google

Ao executar esse código, você vê a seguinte saída:

{1: 0,6944444444444445,
2: 0,5208333333333334,
3: 0,5952380952380952,
4: 0,462962962962963,
5: 0,5208333333333334,
6: 0,4166666666666667,
7: 0,0}

A saída mostra a centralidade de cada nó no gráfico com base em sua proximidade com
todos os outros nós. Observe que o nó 7 tem um valor de 0, o que significa que é uma
distância infinita para todos os outros nós. Por outro lado, o nó 1 tem um valor alto
porque está próximo de todos os nós com os quais tem uma conexão. Ao calcular a
centralidade de proximidade, você pode determinar quais nós são os mais importantes
com base em sua localização.

Outra forma de centralidade de distância é a intermediação. Digamos que você esteja


administrando uma empresa que transfere mercadorias por toda a cidade. Você gostaria
de saber quais nós têm o maior efeito nessas transferências. Talvez você possa rotear
algum tráfego em torno desse nó para tornar sua operação mais eficiente. Ao calcular a
centralidade de intermediação, você determina o nó que possui o maior número de
caminhos curtos chegando a ele. Aqui está o código usado para realizar este cálculo
(com o nó desconectado 7 ainda no lugar):

nx.betweenness_centrality(AGraph)

A saída desta verificação é

{1: 0,36666666666666664,
2: 0,0,
3: 0,13333333333333333,
4: 0,03333333333333333,
5: 0,06666666666666667,
6: 0,0,
7: 0,0}

Como você pode esperar, o nó 7 não tem efeito na transferência entre outros nós porque
não tem conexões com os outros nós. Da mesma forma, como o nó 6 é um nó folha com
apenas uma conexão com outro nó, ele não afeta as transferências. Observe novamente
a Figura 8-6. O subgrafo composto pelos nós 1, 3, 4 e 5 tem o maior efeito na
transferência de itens neste caso. Não existe conexão entre os nós 1
e 4, então os nós 3 e 5 atuam como intermediários. Neste caso, o nó 2 age como um nó
folha.

156 PARTE 3 Explorando o mundo dos gráficos


Machine Translated by Google

O NetworkX oferece várias outras funções de centralidade. Você pode encontrar uma lista
completa dessas funções em https://networkx.org/documentation/stable/
reference/algorithms/centrality.html. A consideração importante é determinar como você deseja
calcular a importância. Considerar a centralidade à luz do tipo de importância que você deseja
atribuir aos vértices e arestas em um grafo é essencial.

Colocando um gráfico em formato numérico


A precisão é uma parte importante do uso de algoritmos. Embora o excesso de precisão
esconda as imagens gerais dos humanos, os computadores prosperam nos detalhes. Muitas
vezes, quanto mais detalhes você puder fornecer, melhores serão os resultados obtidos. No
entanto, a forma desse detalhe é importante. Para usar determinados algoritmos, os dados
fornecidos devem aparecer em determinados formulários ou o resultado recebido não fará
sentido (conter erros ou outros problemas).

Felizmente, o NetworkX fornece várias funções para converter seu gráfico em formas que outros
pacotes e ambientes podem usar. Essas funções aparecem em https://networkx.org/
documentation/stable/reference/convert.html. As seções a seguir mostram como apresentar
dados de gráfico como um NumPy (https://numpy.
org/) matriz, SciPy (https://scipy.org/) representação esparsa e uma lista padrão do Python. Você
usa essas representações à medida que o livro avança para trabalhar com os vários algoritmos.
(O código nas seções a seguir aparece no arquivo A4D2E; 08; Graph Conversion.ipynb e se
baseia no gráfico que você criou na seção “Contando arestas e vértices”, anteriormente neste
capítulo.)

Adicionando um gráfico a uma matriz


Usando o NetworkX, você pode mover facilmente seu gráfico para uma matriz NumPy e vice-
versa, conforme necessário, para executar várias tarefas. Você usa o NumPy para executar
todos os tipos de tarefas de manipulação de dados. Ao analisar os dados em um gráfico, você
pode ver padrões que normalmente não seriam visíveis. Aqui está o código usado para converter
o gráfico em uma matriz que o NumPy pode entender:

importar networkx como nx


importar matplotlib.pyplot como plt

AGraph = nx.Graph()

CAPÍTULO 8 Noções básicas de gráficos 157


Machine Translated by Google

Nós = intervalo(1,6)
Bordas = [(1,2), (2,3), (3,4), (4,5),
(1,3), (1,5), (1,6)]

AGraph.add_nodes_from(Nós)
AGraph.add_edges_from(Edges)

nx.to_numpy_matrix(AGraph)

A saída deste código é a seguinte matriz:

matriz([[ 0., 1., 1., 0., 1., 1.],


[1., 0., 1., 0., 0., 0.],
[1., 1., 0., 1., 0., 0.],
[0., 0., 1., 0., 1., 0.],
[1., 0., 0., 1., 0., 0.],
[1., 0., 0., 0., 0., 0.]])

As linhas e colunas resultantes mostram onde existem conexões. Por exemplo, não há
conexão entre o nó 1 e ele mesmo, então a linha 1, coluna 1, tem um 0 nela.
No entanto, há uma conexão entre o nó 1 e o nó 2, então você vê um 1 na linha 1, coluna
2, e linha 2, coluna 1 (o que significa que a conexão vai nos dois sentidos como uma
conexão não direcionada).

O tamanho desta matriz é afetado pelo número de nós (a matriz tem tantas linhas e colunas
quantos nós), e quando ela cresce, tem muitos nós para representar porque o número total
de células é o quadrado do número de nós. Por exemplo, você não pode representar a
Internet usando tal matriz porque uma estimativa conservadora calcula que em 10^10 sites,
você precisaria de uma matriz com 10^20 células para armazenar sua estrutura, algo
impossível com a atual capacidade de computação .

Além disso, o número de nós afeta seu conteúdo. Se n for o número de nós, você encontrará
um mínimo de 0 unidades e um máximo de n^2 unidades. O fato de o número de uns ser
pequeno ou grande torna o grafo denso ou esparso, e isso é relevante porque se as
conexões entre os nós são poucas, como no caso de sites, existem soluções mais eficientes
para armazenar os dados do grafo.

Usando representações esparsas


O pacote SciPy também executa várias tarefas matemáticas, científicas e de engenharia.
Ao usar este pacote, você pode contar com uma matriz esparsa para armazenar os dados.
Uma matriz esparsa é aquela em que apenas as conexões reais aparecem na matriz; todas
as outras entradas não existem. O uso de uma matriz esparsa economiza recursos porque o

158 PARTE 3 Explorando o mundo dos gráficos


Machine Translated by Google

os requisitos de memória para uma matriz esparsa são pequenos. Aqui está o código que você usa
para criar uma matriz esparsa SciPy a partir de um gráfico NetworkX:

print(nx.to_scipy_sparse_matrix(AGraph))

Esta é a saída que você pode esperar ver neste código:

(0, 1) 1
(0, 2) 1
(0, 4) 1
(0, 5) 1
(1, 0) 1
(1, 2) 1
(2, 0) 1
(2, 1) 1
(2, 3) 1
(3, 2) 1
(3, 4) 1
(4, 0) 1
(4, 3) 1
(5, 0) 1

Como você pode ver, as entradas mostram as várias coordenadas de borda. Cada coordenada ativa
tem um 1 associado a ela. As coordenadas são baseadas em 0. Isso significa que (0, 1) na verdade
se refere a uma conexão entre os nós 1 e 2.

Usando uma lista para armazenar um gráfico

Dependendo de suas necessidades, talvez você também precise da capacidade de criar um dicionário
de listas. Muitos desenvolvedores usam essa abordagem para criar código que executa várias tarefas
de análise em gráficos. Você pode ver um exemplo em https://www.geeksforgeeks.org/generate-graph-
using-dictionary-python/.
O código a seguir mostra como criar um dicionário de listas para o gráfico de exemplo:

nx.to_dict_of_lists(AGraph)

Aqui está o dicionário de forma de listas do gráfico (observe que a ordem dos números que você vê
pode variar):

{1: [2, 3, 5, 6], 2: [1, 3], 3: [1, 2, 4], 4: [3, 5],


5: [1, 4], 6: [1]}

Observe que cada nó representa uma entrada de dicionário, seguida por uma lista dos nós aos quais
ele se conecta. Por exemplo, o nó 1 se conecta aos nós 2, 3, 5 e 6.

CAPÍTULO 8 Noções básicas de gráficos 159


Machine Translated by Google
Machine Translated by Google

NESTE CAPÍTULO

» Trabalhando com gráficos

» Executando tarefas de classificação

» Reduzindo o tamanho da árvore

» Localizando a rota mais curta entre dois pontos

Capítulo 9

Reconectando os pontos
ritmos para percorrer os pontos no mapa e as ruas que os conectam. Na verdade,
As configurações
quando do Sistema
você de Posicionamento
terminar Global
este capítulo, você (GPS) funcionam
entenderá porque para
a base usada você fazer
pode usar
um algo
navegador GPS funcionar (mas não necessariamente a mecânica de fazê-lo acontecer). É claro
que o requisito fundamental para usar um gráfico para criar um navegador GPS é a capacidade
de procurar conexões entre pontos no mapa, conforme discutido na primeira seção do capítulo.

Para entender um gráfico, você precisa classificar os nós, conforme descrito na


segunda seção do capítulo, para criar uma organização específica. Sem organização,
tomar qualquer tipo de decisão torna-se impossível. Um algoritmo pode acabar
andando em círculos ou dando uma saída inconveniente.

Ao visualizar um mapa, você não vê as informações no canto inferior direito


quando realmente precisa trabalhar com locais e estradas no canto superior esquerdo.
Um computador não sabe que precisa procurar em um lugar específico até que você diga para
fazer isso. Para focar a atenção em um local específico, você precisa reduzir o tamanho do
gráfico, conforme descrito na terceira seção do capítulo.

Após a simplificação do problema, um algoritmo pode encontrar a rota mais


curta entre dois pontos, conforme descrito na quarta seção do capítulo. Afinal,
você não quer gastar mais tempo no trânsito do que o necessário, lutando de
casa para o seu destino (e vice-versa). O conceito de encontrar o menor

CAPÍTULO 9 Reconectando os pontos 161


Machine Translated by Google

route é um pouco mais complicado do que você imagina, então a quarta seção analisa alguns dos requisitos
específicos para executar tarefas de roteamento em detalhes.

Você não precisa digitar o código-fonte para este capítulo manualmente. Na verdade, usar a
fonte para download é muito mais fácil. Você pode encontrar a fonte deste capítulo no A4D2E;
09; Graph Traversing.ipynb, A4D2E; 09; Árvore geradora mínima.
ipynb e A4D2E; 09; Arquivos Path.ipynb mais curtos da fonte para download.
Consulte a Introdução para obter detalhes sobre como localizar esses arquivos de origem.

Percorrendo um gráfico com eficiência


Percorrer um grafo significa pesquisar (visitar) cada vértice (nó) em uma ordem específica.
O processo de visitar um vértice pode incluir tanto a leitura quanto a atualização. Conforme você percorre um
grafo, um vértice não visitado não é descoberto. Após uma visita, o vértice é descoberto (porque você acabou de
visitá-lo) ou processado (porque o algoritmo tentou todas as arestas que partem dele). A ordem da busca
determina o tipo de busca realizada, e muitos algoritmos estão disponíveis para realizar esta tarefa. As seções a
seguir discutem dois desses algoritmos.

CONSIDERANDO A REDUNDÂNCIA

Ao percorrer uma árvore, cada caminho termina em um nó folha para que você saiba que chegou ao final
desse caminho. No entanto, ao trabalhar com um gráfico, os nós se interconectam de tal forma que você
pode ter que percorrer alguns nós mais de uma vez para explorar todo o gráfico. À medida que o grafo se
torna mais denso, aumenta a possibilidade de visitar o mesmo nó mais de uma vez. Gráficos densos podem
aumentar muito os requisitos computacionais e de armazenamento.

Para reduzir os efeitos negativos de visitar um nó mais de uma vez, é comum marcar cada nó visitado de
alguma maneira para mostrar que o algoritmo o visitou. Quando o algoritmo detecta que visitou um nó
específico, ele pode simplesmente pular esse nó e passar para o próximo nó. Marcar os nós visitados
diminui as penalidades de desempenho inerentes à redundância.

A marcação de nós visitados também permite verificar se a pesquisa foi concluída. Caso contrário, um
algoritmo pode terminar em um loop e continuar fazendo as rondas no gráfico indefinidamente.

162 PARTE 3 Explorando o mundo dos gráficos


Machine Translated by Google

Criando o gráfico
Para ver como percorrer um gráfico pode funcionar, você precisa de um gráfico. Os exemplos nesta
seção contam com um gráfico comum para que você possa ver como as duas técnicas funcionam.
O código a seguir mostra a lista de adjacências encontrada no final do Capítulo 8.

gráfico = {'A': ['B', 'C'],


'B': ['A', 'C', 'D'],
'C': ['A', 'B', 'D', 'E'],
'D': ['B', 'C', 'E', 'F'],
'E': ['C', 'D', 'F'],
'F': ['D', 'E']}

O gráfico apresenta um caminho bidirecional que vai de A, B, D e F em um lado (começando no nó


A) e A, C, E e F no segundo lado (novamente, começando no nó A). Existem também conexões
(que atuam como possíveis atalhos) indo de B para C, de C para D e de D para E. O uso do pacote
NetworkX apresentado no Capítulo 8 permite exibir a adjacência como uma imagem para que você
possa ver como os vértices e arestas aparecem (veja a Figura 9-1) usando o seguinte código:

importar networkx como nx


importar matplotlib.pyplot como plt

Gráfico = nx.Gráfico()
para nó no gráfico:
Graph.add_nodes_from(node)
para aresta no gráfico[nó]:
Graph.add_edge(node,edge)

pos = { 'A': [0,00, 0,50], 'B': [0,25, 0,75],


'C': [0,25, 0,25], 'D': [0,75, 0,75],
'E': [0,75, 0,25], 'F': [1,00, 0,50]}
draw_params = {'with_labels':True,
'node_color':'skyblue',
'node_size':700, 'width':2,
'font_size':14}

nx.draw(Graph, pos, **draw_params)


plt.show()

CAPÍTULO 9 Reconectando os pontos 163


Machine Translated by Google

FIGURA 9-1:
Representando o
gráfico de exemplo
do NetworkX.

Aplicando a pesquisa em largura


Uma busca em largura (BFS) começa em um nó inicial e explora todos os nós que
se conectam à raiz. Em seguida, ele procura o próximo nível - explorando cada
nível por sua vez até chegar ao fim. Conseqüentemente, no grafo de exemplo, a
busca explora de A a B e C antes de prosseguir para D. O BFS explora o grafo de
forma sistemática, explorando vértices ao redor do vértice inicial de forma circular.
Ele começa visitando todos os vértices a um passo do vértice inicial; ele então se
move dois passos para fora, e então três passos para fora, e assim por diante. O
código a seguir demonstra como realizar uma pesquisa ampla:

def bfs(gráfico, início):


fila = [iniciar]
enfileirado = [início]
caminho = lista()
enquanto fila:
print('A fila é: %s' % fila)
vértice = fila.pop(0)
print('Processando %s' % vértice)
para candidato em graph[vértice]:
se o candidato não estiver na fila:
enfileirado.append(candidato)
queue.append(candidato)
path.append(vértice + '>' + candidato)
print('Adicionando %s à fila'
% candidato)
caminho de retorno

passos = bfs(gráfico, 'A')


print('\nBFS:', passos)

164 PARTE 3 Explorando o mundo dos gráficos


Machine Translated by Google

Aqui está a saída que é impressa ao executar o snippet de código:

A fila é: ['A']
Processando A
Adicionando B à fila
Adicionando C à fila
A fila é: ['B', 'C']
Processamento B
Adicionando D à fila
A fila é: ['C', 'D']
Processando C
Adicionando E à fila
A fila é: ['D', 'E']
Processamento D
Adicionando F à fila
A fila é: ['E', 'F']
Processamento E
A fila é: ['F']
Processamento F

BFS: ['A>B', 'A>C', 'B>D', 'C>E', 'D>F']

A saída mostra como o algoritmo pesquisa. Está na ordem que você espera – um nível de cada
vez. A maior vantagem de usar o BFS é que ele garante o retorno de um caminho mais curto
entre dois pontos como a primeira saída quando usado para encontrar caminhos.

O código de exemplo usa uma lista simples como uma fila. Conforme descrito na seção “Usando
filas” do Capítulo 6, uma fila é uma estrutura de dados FIFO (first in/first out) que funciona como
uma linha em um banco, onde o primeiro item colocado na fila também é o primeiro item que
sai. Python fornece uma estrutura de dados ainda melhor chamada deque (pronunciado deck)
que você pode usar tanto como fila quanto como pilha. Você pode descobrir mais sobre a função
deque em https://pymotw.com/2/collections/deque.html.

Aplicando a pesquisa em profundidade


Além do BFS, você pode usar uma pesquisa em profundidade (DFS) para descobrir os vértices
em um grafo. Ao executar um DFS, o algoritmo começa em um nó inicial e, em seguida, explora
cada nó desse ponto por um único caminho até o final. Em seguida, ele rastreia e começa a
explorar os caminhos não tomados no caminho de pesquisa atual até atingir o nó inicial
novamente. Nesse ponto, se outros caminhos a partir do nó inicial estiverem disponíveis, o
algoritmo escolhe um e inicia a mesma busca novamente.
A ideia é explorar cada caminho completamente antes de explorar qualquer outro caminho. Para

CAPÍTULO 9 Reconectando os pontos 165


Machine Translated by Google

para que essa técnica de busca funcione, o algoritmo deve marcar cada vértice que visita.
Desta forma, ele sabe quais vértices requerem uma visita e pode determinar qual caminho
seguir. O uso de BFS ou DFS pode fazer a diferença de acordo com a maneira como
você precisa percorrer um gráfico. Do ponto de vista da programação, a diferença entre
os dois algoritmos é como cada um armazena os vértices para explorar o seguinte:

» Uma fila para BFS, uma lista que funciona de acordo com o princípio FIFO. Recentemente
os vértices descobertos não esperam muito pelo processamento.

» Uma pilha para DFS, uma lista que funciona de acordo com o last in/first out (LIFO)
princípio.

O código a seguir mostra como criar um DFS:

def dfs(gráfico, início): pilha =


[início] pais = {início:
início} caminho = lista()

enquanto pilha:

print('A pilha é: %s' % pilha) vertex =


stack.pop(-1) print('Processando %s' %
vértice) para candidato no gráfico[vértice]:

se o candidato não estiver nos pais:


pais[candidato] = vértice
pilha.append(candidato)
print('Adicionando %s à pilha' %
candidato)
caminho.append(pais[vértice] + '>' + vértice) caminho de
retorno[1:]

passos = dfs(grafo, 'A')


print('\nDFS:', passos)

Aqui está a saída que é impressa ao executar o snippet de código:

A pilha é: ['A']
Processando A
Adicionando B à pilha
Adicionando C à pilha
A pilha é: ['B', 'C']
Processando C
Adicionando D à pilha

166 PARTE 3 Explorando o mundo dos gráficos


Machine Translated by Google

Adicionando E à pilha
A pilha é: ['B', 'D', 'E']
Processamento E
Adicionando F à pilha
A pilha é: ['B', 'D', 'F']
Processamento F
A pilha é: ['B', 'D']
Processamento D
A pilha é: ['B']
Processamento B

DFS: ['A>C', 'C>E', 'E>F', 'C>D', 'A>B']

A última linha de saída mostra a ordem de pesquisa real. Observe que a pesquisa começa no nó
inicial, como esperado, mas segue pelo lado esquerdo do gráfico até o início. A etapa final é
pesquisar a única ramificação fora do loop que cria o grafo neste caso, que é D.

Observe que a saída não é a mesma do BFS. A rota de processamento começa com o nó A e se
move para o lado oposto do gráfico, para o nó F. O código então refaz para procurar caminhos
ignorados. Esse comportamento depende do uso de uma pilha no lugar de uma fila. Confiar em
uma pilha significa que você também pode implementar esse tipo de pesquisa usando recursão.
O uso de recursão tornaria o algoritmo mais rápido, para que você pudesse obter resultados mais
rapidamente do que usando um BFS. A desvantagem é que você usa mais memória ao usar a
recursão.

Determinando qual aplicativo usar


A escolha entre BFS e DFS depende de como você planeja aplicar a saída da pesquisa. Os
desenvolvedores geralmente empregam o BFS para localizar a rota mais curta entre dois pontos
o mais rápido possível. Isso significa que você geralmente encontra o BFS usado em aplicativos
como GPS, onde encontrar a rota mais curta é fundamental. Para os propósitos deste livro, você
também verá o BFS usado para spanning tree, caminho mais curto e muitos outros algoritmos de
minimização.

Um DFS encontra um caminho inteiro antes de explorar qualquer outro caminho. Você o usa
quando precisa pesquisar em detalhes, e não de forma geral. Você costuma ver o DFS usado em
jogos, onde encontrar um caminho completo é importante. É também uma abordagem ideal para
realizar tarefas como encontrar uma solução para um labirinto.

CAPÍTULO 9 Reconectando os pontos 167


Machine Translated by Google

Decida entre BFS e DFS com base nas limitações de cada técnica. O BFS precisa de muita memória porque
armazena sistematicamente todos os caminhos antes de encontrar uma solução. O DFS precisa de menos
memória, mas você não tem garantia de que encontrará a solução mais curta e direta.

Classificando os elementos do gráfico

A capacidade de pesquisar gráficos de forma eficiente depende da classificação. Afinal, imagine ir a uma
biblioteca e encontrar os livros colocados em qualquer ordem que a biblioteca sentisse como colocá-los nas
prateleiras. Localizar um único livro levaria horas. Uma biblioteca funciona porque os livros individuais aparecem
em locais específicos que os tornam fáceis de encontrar.

As bibliotecas também exibem outra propriedade que é importante ao trabalhar com certos tipos de gráficos.
Ao realizar uma pesquisa de livro, você começa com uma categoria específica e, em seguida, uma fileira de
livros e, em seguida, uma prateleira nessa fileira e, finalmente, o livro. Você passa de menos específico para
mais específico ao realizar a pesquisa, o que significa que você não revisita os níveis anteriores. Portanto, você
não acaba em partes estranhas da biblioteca que não têm nada a ver com o tópico em questão.

As seções a seguir revisam os Gráficos Acíclicos Dirigidos (DAGs), que são gráficos direcionados finitos que
não possuem nenhum loop. Em outras palavras, você começa de um local específico e segue uma rota
específica para um local final sem nunca voltar ao local inicial. Esse tipo de gráfico tem todos os tipos de usos
práticos, como cronogramas, com cada vértice representando um marco específico.

GRÁFICOS COM LOOPS

Às vezes você precisa expressar um processo de tal maneira que um conjunto de etapas se repita.
Por exemplo, ao lavar seu carro, você enxagua, ensaboa e depois enxagua novamente.
No entanto, se você encontrar uma mancha suja, uma área que o sabão não limpou na primeira vez,
ensaboe-a novamente e enxágue novamente para verificar se a mancha desapareceu. É isso que um loop
faz; ele cria uma situação em que um conjunto de etapas se repete de duas maneiras:

• Atende a uma condição específica: A mancha no carro desapareceu.

• Executa um número específico de vezes: Este é o número máximo de repetições que você executa
para evitar danos à pintura do carro.

168 PARTE 3 Explorando o mundo dos gráficos


Machine Translated by Google

Trabalhando em Gráficos Acíclicos Dirigidos (DAGs)


Os gráficos acíclicos dirigidos (DAGs) estão entre os tipos mais importantes de gráficos porque têm
muitos usos práticos. Os princípios básicos dos DAGs são que eles

» Siga uma ordem específica para que você não possa ir de um vértice a outro e voltar ao
vértice inicial usando qualquer rota.

» Forneça um caminho específico de um vértice para outro (mesmo que existam vários
caminhos) para que você possa criar um conjunto previsível de rotas.

Você vê DAGs usados para muitas necessidades organizacionais. Por exemplo, uma árvore
genealógica é um exemplo de um DAG. Mesmo quando a atividade não segue uma ordem cronológica
ou outra ordem de substituição, o DAG permite que você crie rotas previsíveis, o que torna os DAGs
mais fáceis de processar do que muitos outros tipos de gráficos com os quais você trabalha.

No entanto, os DAGs podem usar rotas opcionais. Imagine que você está construindo um hambúrguer.
O sistema de menu começa com um fundo de pão. Opcionalmente, você pode adicionar condimentos
ao fundo do pão ou passar diretamente para o hambúrguer no pão. A rota sempre termina com um
hambúrguer, mas você tem vários caminhos para chegar ao hambúrguer. Depois de colocar o
hambúrguer no lugar, você pode optar por adicionar queijo ou bacon antes de adicionar a parte superior
do pão. Você toma um caminho específico, mas cada caminho pode se conectar ao próximo nível de
várias maneiras.

Baseando-se na classificação topológica


Um elemento importante dos DAGs é que você pode representar uma infinidade de atividades usando-
os. No entanto, algumas atividades exigem que você aborde as tarefas em uma ordem específica. É
aqui que a classificação topológica entra em jogo. A ordenação topológica ordena todos os vértices de
um grafo em uma linha com as arestas diretas apontando da esquerda para a direita. Organizado
dessa forma, o código pode facilmente percorrer o grafo e processar os vértices um após o outro, em
ordem.

Ao usar a classificação topológica, você organiza o gráfico de modo que cada vértice do gráfico leve
a um vértice posterior na sequência. Por exemplo, ao criar um cronograma para a construção de um
arranha-céu, você não começa no topo e vai descendo. Você começa com a base e vai subindo. Cada
andar pode representar uma milha de pedra. Quando você completa o segundo andar, você não vai
para o terceiro e depois refaz o segundo andar. Em vez disso, você passa do terceiro andar para o
quarto andar e assim por diante. Qualquer tipo de agendamento que exija que você mude de um ponto
inicial específico para um ponto final específico pode contar com um DAG com classificação topológica.

A classificação topológica pode ajudá-lo a determinar que seu gráfico não tem ciclos (porque caso
contrário, você não pode ordenar as arestas conectando os vértices da esquerda para a direita; em

CAPÍTULO 9 Reconectando os pontos 169


Machine Translated by Google

menos um nó fará referência a um nó anterior). Além disso, a classificação topológica também


se mostra útil em algoritmos que processam gráficos complexos porque mostra a melhor
ordem para processá-los.

Você pode obter classificação topológica usando o algoritmo de travessia DFS. Basta observar
a ordem de processamento dos vértices pelo algoritmo. No exemplo anterior, a saída aparece
nesta ordem: A, C, E, F, D e B. Siga a sequência na Figura 9-1 e você notará que a
classificação topológica segue as arestas no perímetro externo do gráfico. Em seguida, ele faz
um tour completo: depois de chegar ao último nó da ordenação topológica, você está a apenas
um passo de A, o início da sequência.

Reduzindo a uma árvore geradora mínima


Muitos problemas que os algoritmos resolvem dependem da definição de um mínimo de
recursos a serem usados, como definir uma maneira econômica de alcançar todos os pontos
em um mapa. Esse problema foi primordial no final do século XIX e início do XX, quando as
redes ferroviárias e elétricas começaram a aparecer em muitos países, revolucionando o
transporte e os modos de vida. Usar empresas privadas para construir essas redes era caro
(levava muito tempo e mão de obra). O uso de menos material e uma força de trabalho menor
oferecia economia ao reduzir as conexões redundantes.

Alguma redundância é desejável em redes críticas de transporte ou energia, mesmo quando


se busca soluções econômicas. Se apenas um método conecta a rede, ele é facilmente
interrompido, interrompendo os serviços para muitos clientes.

Obtendo o contexto histórico


da árvore geradora mínima
Na Morávia, no leste da República Tcheca, o matemático tcheco Otakar Borÿvka encontrou
uma solução em 1926 que permite construir uma rede elétrica usando a menor quantidade de
fios possível. Sua solução é bastante eficiente porque não só permitiu encontrar uma maneira
de conectar todas as cidades da Morávia da maneira mais econômica possível, mas também
teve uma complexidade de tempo de O(m*log n), onde m é o número de arestas (o cabo
elétrico) en o número de vértices (as cidades). Outros melhoraram a solução de Borÿvka desde
então. Mesmo que os algoritmos que você encontra nos livros sejam mais bem projetados e
fáceis de entender (como os de Prim e Kruskal), eles não alcançam melhores resultados em
termos de complexidade de tempo.

Uma árvore geradora mínima define o problema de encontrar a maneira mais econômica de
realizar uma tarefa. Uma árvore geradora é a lista de arestas necessárias para conectar todos os

170 PARTE 3 Explorando o mundo dos gráficos


Machine Translated by Google

vértices em um grafo não direcionado. Um único gráfico pode conter várias árvores geradoras,
dependendo da disposição do gráfico, e determinar quantas árvores ele contém é uma questão
complexa. Cada caminho que você pode seguir do início ao fim em um gráfico é outra árvore geradora.
A árvore geradora visita cada vértice apenas uma vez; ele não faz um loop nem faz nada para repetir
os elementos do caminho.

Trabalhando com gráficos não


ponderados versus ponderados
Quando você trabalha em um gráfico não ponderado, as árvores geradoras têm o mesmo comprimento.
Em grafos não ponderados, todas as arestas têm o mesmo comprimento, e a ordem em que você as
visita não importa porque o caminho é sempre o mesmo. Todas as árvores geradoras possíveis têm o
mesmo número de arestas, n-1 arestas (n é o número de vértices), com o mesmo comprimento exato.
Além disso, qualquer algoritmo de travessia de grafos, como BFS ou DFS, é suficiente para encontrar
uma das possíveis árvores geradoras.

As coisas se tornam complicadas ao trabalhar com um gráfico ponderado com arestas de diferentes
comprimentos. Neste caso, das muitas árvores geradoras possíveis, poucas, ou apenas uma, têm o
comprimento mínimo possível. Uma árvore geradora mínima é uma árvore geradora que garante um
caminho com o menor peso de aresta possível. Um grafo não direcionado geralmente contém apenas
uma árvore geradora mínima, mas, novamente, depende da configuração. Pense nas árvores geradoras
mínimas desta forma: Ao olhar para um mapa, você vê vários caminhos para ir do ponto A ao ponto B.
Cada caminho tem lugares onde você deve virar ou mudar de estrada, e cada uma dessas junções é
um vértice . A distância entre os vértices representa o peso da aresta. Geralmente, um caminho entre o
ponto A e o ponto B fornece a rota mais curta.

Criando um exemplo de árvore


geradora mínima
Árvores geradoras mínimas nem sempre precisam considerar o óbvio. Por exemplo, ao considerar
mapas, você pode não estar interessado em distância; em vez disso, você pode querer considerar o
tempo, o consumo de combustível ou inúmeras outras necessidades. Cada uma dessas necessidades
pode ter uma árvore geradora mínima completamente diferente. Com isso em mente, o exemplo a
seguir ajuda você a entender melhor as árvores geradoras mínimas e demonstra como resolver o
problema de descobrir o menor peso de aresta para qualquer problema. Para demonstrar uma solução
de spanning tree mínima usando Python, o código a seguir atualiza o gráfico anterior adicionando pesos
de aresta.

importar networkx como nx


importar matplotlib.pyplot como plt

CAPÍTULO 9 Reconectando os pontos 171


Machine Translated by Google

gráfico = {'A': {'B':2, 'C':3},


'B': {'A':2, 'C':2, 'D':2},
'C': {'A':3, 'B':2, 'D':3, 'E':2},
'D': {'B':2, 'C':3, 'E':1, 'F':3},
'E': {'C':2, 'D':1, 'F':1},
'F': {'D':3, 'E':1}}

Gráfico = nx.Gráfico()
para nó no gráfico:
Graph.add_nodes_from(node)
para aresta, peso em graph[node].items():
Graph.add_edge(nó, aresta, peso=peso)

pos = { 'A': [0,00, 0,50], 'B': [0,25, 0,75],


'C': [0,25, 0,25], 'D': [0,75, 0,75],
'E': [0,75, 0,25], 'F': [1,00, 0,50]}

draw_params = {'with_labels':True,
'setas': Verdade,
'node_color':'skyblue',
'node_size':700, 'width':2,
'font_size':14}

rótulos = nx.get_edge_attributes(Gráfico,'peso')
nx.draw(Graph, pos, **draw_params)
nx.draw_networkx_edge_labels(Gráfico, pos,
font_size=14,
edge_labels=labels)
plt.show()

A Figura 9-2 mostra que todas as arestas têm um valor agora. Esse valor pode
representar algo como tempo, combustível ou dinheiro. Gráficos ponderados podem
representar muitos possíveis problemas de otimização que ocorrem no espaço
geográfico (como movimento entre cidades) porque representam situações nas quais
você pode ir e vir de um vértice.

Curiosamente, todas as arestas têm pesos positivos neste exemplo. No entanto, grafos
ponderados podem ter pesos negativos em algumas arestas. Muitas situações tiram
vantagem de arestas negativas. Por exemplo, eles são úteis quando você pode ganhar
e perder ao se mover entre vértices, como ganhar ou perder dinheiro ao transportar ou
trocar mercadorias, ou liberar energia em um processo químico.

172 PARTE 3 Explorando o mundo dos gráficos


Machine Translated by Google

FIGURA 9-2:
O gráfico de
exemplo torna-
se ponderado.

Nem todos os algoritmos são adequados para lidar com arestas negativas. É importante observar
aqueles que podem funcionar apenas com pesos positivos.

Descobrindo os algoritmos corretos para usar


Você pode encontrar muitos algoritmos diferentes para usar para criar uma árvore geradora mínima.
Os mais comuns são os algoritmos gulosos, que rodam em tempo polinomial. O tempo polinomial
é uma potência do número de arestas, como O(n2) ou O(n3) (consulte a seção “Considerando
problemas NP completos” do Capítulo 15 para obter informações adicionais sobre tempo polinomial).
Os principais fatores que afetam a velocidade de execução de tais algoritmos envolvem o processo
de tomada de decisão - ou seja, se uma determinada aresta pertence à árvore geradora mínima ou
se o peso total mínimo da árvore resultante excede um determinado valor. Com isso em mente, aqui
estão alguns dos algoritmos disponíveis para resolver uma árvore geradora mínima:

» Borÿvka's: Inventado por Otakar Borÿvka em 1926, este algoritmo conta com uma
série de estágios nos quais identifica as arestas com o menor peso em cada
estágio. Os cálculos começam examinando vértices individuais, encontrando o
menor peso para esse vértice e, em seguida, combinando caminhos para formar
florestas de árvores individuais até criar um caminho que combine todas as florestas
com o menor peso.

» Prim's: Originalmente inventado por Jarnik em 1930, Prim o redescobriu em 1957. Este
algoritmo começa com um vértice arbitrário e aumenta a árvore geradora mínima uma
aresta de cada vez, sempre escolhendo a aresta com o menor peso que se conecta a
um nó que é já não está na árvore para a árvore em crescimento.

» Kruskal's: Desenvolvido por Joseph Kruskal em 1956, utiliza uma abordagem que
combina o algoritmo de Borÿvka (criando florestas de árvores individuais) e o de Prim

CAPÍTULO 9 Reconectando os pontos 173


Machine Translated by Google

algoritmo (procurando a aresta mínima para cada vértice e construindo as


florestas uma aresta de cada vez).

Esses algoritmos usam uma abordagem gananciosa. Algoritmos gananciosos aparecem no


Capítulo 2 entre as famílias de algoritmos, e você os verá em detalhes no Capítulo 15. Em uma
abordagem gulosa, o algoritmo chega gradualmente a uma solução tomando, de forma
irreversível, a melhor decisão disponível em cada etapa . Por exemplo, se você precisa do
caminho mais curto entre muitos vértices, um algoritmo guloso pega as arestas mais curtas entre
as disponíveis entre todos os vértices.

Apresentando filas de prioridade


Mais adiante neste capítulo, você verá como implementar os algoritmos de Prim e Kruskal para
uma árvore geradora mínima e o algoritmo de Dijkstra para o caminho mais curto em um grafo
usando Python. No entanto, antes de fazer isso, você precisa de um método para encontrar as
arestas com o peso mínimo entre um conjunto de arestas. Tal operação implica ordenação, e
ordenar elementos custa tempo. É uma operação complexa, conforme descrito no Capítulo 7.
Como os exemplos reordenam repetidamente as arestas, uma estrutura de dados chamada fila
de prioridade é útil.

As filas de prioridade usadas aqui contam com estruturas de dados baseadas em árvore de heap
que permitem a ordenação rápida de elementos quando você os insere dentro do heap. Assim
como o chapéu mágico do mágico, os amontoados prioritários armazenam as arestas com seus
pesos e ficam imediatamente prontos para fornecer a aresta inserida cujo peso é o mínimo entre
os armazenados.

Este exemplo usa uma classe que permite realizar comparações de fila de prioridade que
determinam se a fila contém elementos e quando esses elementos contêm uma determinada
borda (evitando inserções duplas). A fila de prioridade tem outra característica útil (cuja utilidade
é explicada ao trabalhar no algoritmo de Dijkstra): Se você inserir uma aresta com um peso
diferente do armazenado anteriormente, o código atualiza o peso da aresta e reorganiza a
posição da aresta no heap.

do heapq importar heapify, heappop, heappush

classe priority_queue():
def __init__(self):
self.queue = lista()
heapify(self.queue)
self.index = dict()
def push(self, priority, label):
se rótulo em self.index:
self.queue = [(w,l)

174 PARTE 3 Explorando o mundo dos gráficos


Machine Translated by Google

para w,l em self.queue if l != label]


heapify(self.queue)
heappush(self.queue, (prioridade, etiqueta))
self.index[label] = prioridade
def pop(auto):
se self.queue:
return heappop(self.queue)
def __contains__(self, label):
rótulo de retorno em self.index
def __len__(self):
return len(self.queue)

Aproveitando o algoritmo de Prim


O algoritmo de Prim gera a árvore geradora mínima para um grafo percorrendo o
vértice do grafo por vértice. A partir de qualquer vértice escolhido, o algoritmo
adiciona arestas usando uma restrição na qual, se um vértice atualmente faz parte
da árvore geradora e o segundo vértice não faz parte dela, o peso da aresta entre
os dois deve ser o menor possível entre aqueles acessível. Procedendo desta
forma, a chance de criar ciclos na árvore geradora é menor (isso pode acontecer
se você adicionar uma aresta cujos vértices já estão em ambas as árvores
geradoras) e você tem a garantia de obter uma árvore mínima porque você adiciona
as arestas com o menor peso. Em termos de etapas, o algoritmo inclui essas três
fases, sendo a última iterativa:

1. Acompanhe as arestas da árvore geradora mínima e os vértices usados à medida


que se tornam parte da solução.

2. Comece em qualquer vértice no gráfico e coloque-o na solução.

3. Determine se ainda existem vértices que não fazem parte da solução:

uma. Enumere as arestas que tocam os vértices na solução.

b. Insira a aresta com o peso mínimo na árvore geradora. (Este é o princípio


ganancioso em ação no algoritmo: sempre escolha o mínimo em cada
etapa para obter um resultado mínimo geral.)

Ao traduzir essas etapas em código Python, você pode testar o algoritmo no gráfico
ponderado de exemplo usando o seguinte código:

def prim(gráfico, início):


caminho de árvore = {}
total = 0
fila = priority_queue()

CAPÍTULO 9 Reconectando os pontos 175


Machine Translated by Google

queue.push(0, (start, start)) while queue:

peso, (node_start, node_end) = queue.pop() se node_end


não estiver no caminho da árvore: treepath[node_end] =
node_start se peso:

print("Adicionado aresta de %s" \


" para %s ponderando %i"
% (node_start, node_end, peso))
total += peso para
next_node, weight \ in
graph[node_end].items():
queue.push(weight , (node_end, next_node)) print("Total
spanning tree length: %i" % total) return treepath

caminho da árvore = prim(grafo, 'A')

A execução do código imprime a seguinte saída:

Adicionada aresta de A a B com peso 2


Adicionada aresta de B a C ponderando 2
Adicionada aresta de B a D ponderando 2
Adicionada aresta de D a E ponderando 1
Adicionada aresta de E a F com ponderação 1
Comprimento total da árvore geradora: 8

O algoritmo imprime as etapas de processamento, mostrando a aresta que adiciona em


cada estágio e o peso que a aresta adiciona ao total. O exemplo exibe a soma total dos
pesos e o algoritmo retorna um dicionário Python contendo o vértice final como chave e
o vértice inicial como valor para cada aresta da árvore geradora resultante. Outra função,
represent_tree(), transforma os pares de chave e valor do dicionário em uma tupla e, em
seguida, classifica cada uma das tuplas resultantes para uma melhor capacidade de
leitura do caminho da árvore:

def represent_tree(treepath): progress


= list() for node in treepath: if node !
= treepath[node]:
progress.append((treepath[node],
node))
return classificado(progressão, chave=lambda x:x[0])

print(represent_tree(treepath))

176 PARTE 3 Explorando o mundo dos gráficos


Machine Translated by Google

Esta versão da saída é um pouco mais legível.

[('A','B'), ('B','C'), ('B','D'), ('D','E'), ('E','F') ]

A função represent_tree() reordena a saída do algoritmo de Prim para melhor


legibilidade. No entanto, o algoritmo funciona em um grafo não direcionado, o que
significa que você pode percorrer as arestas em ambas as direções. O algoritmo
incorpora essa suposição porque não há verificação de direcionalidade de borda para
adicionar à fila de prioridade para processamento posterior.

Testando o algoritmo de Kruskal


O algoritmo de Kruskal usa uma estratégia gananciosa, assim como o de Prim, mas
escolhe as arestas mais curtas de um pool global contendo todas as arestas. Para
determinar se uma aresta é uma parte adequada da solução, o algoritmo depende de
um processo agregativo no qual reúne vértices. Quando uma aresta envolve vértices já
na solução, o algoritmo a descarta para evitar a criação de um ciclo. O algoritmo procede
da seguinte forma:

1. Coloque todas as bordas em uma pilha e classifique-as de modo que as bordas mais curtas fiquem

em cima.

2. Crie um conjunto de árvores, cada uma contendo apenas um vértice (de modo que o número de árvores
seja igual ao número de vértices). Você conecta árvores como um agregado até que as árvores
convergem em uma árvore de comprimento mínimo que abrange todos os vértices.

3. Repita as seguintes operações até que a solução não contenha tantas arestas quanto o número de
vértices do grafo:

uma. Escolha a aresta mais curta do heap.

b. Determine se os dois vértices conectados pela aresta aparecem em


diferentes árvores do conjunto de árvores conectadas.

c. Quando as árvores diferem, conecte as árvores usando a borda (definindo uma


agregação).

d. Quando os vértices aparecerem na mesma árvore, descarte a aresta.

e. Repita as etapas de a a d para as bordas restantes no heap.

O exemplo a seguir demonstra como transformar essas etapas em código Python:

def kruskal(gráfico):
prioridade = priority_queue()
print("Enviando todas as arestas para a fila de prioridade")

CAPÍTULO 9 Reconectando os pontos 177


Machine Translated by Google

treepath = list() conectado


= dict() para nó no gráfico:
conectado[nó] = [nó] para
destino, peso no gráfico[nó].items():
priority.push(peso, (nó, destino))

print("Totalmente %i arestas" % len(priority)) print("Componentes


conectados: %s"
% valores.conectados())

total = 0
while len(treepath) < (len(graph)-1):
(peso, (início, fim)) = priority.pop() se o fim não estiver
conectado [início]:
treepath.append((início, fim)) print("Somando
os componentes %s e %s:"
%. = conectado[fim][:] para elemento em conectado[fim]:
conectado[elemento]= conectado[início] print("Total spanning
tree length: %i" % total) return sorted(treepath,
key=lambda x:x [0])

print('\nÁrvore geradora mínima: %s' % kruskal(graph))

O algoritmo de Kruskal, ao ser executado, retorna a seguinte saída:

Empurrando todas as arestas para a fila de prioridade


Totalmente 9 arestas
Componentes conectados: dict_values([['A'], ['E'], ['F'],
['B'], ['D'], ['C']])
Soma dos componentes ['E'] e ['D']: aresta adicionada
de E a D ponderando 1
Somando os componentes ['E', 'D'] e ['F']:
aresta adicionada de E a F ponderando 1
Somando os componentes ['A'] e ['B']: aresta
adicionada de A a B ponderando 2
Somando os componentes ['A', 'B'] e ['C']:
aresta adicionada de B a C ponderando 2
Somando os componentes ['A', 'B', 'C'] e ['E', 'D', 'F']:

178 PARTE 3 Explorando o mundo dos gráficos


Machine Translated by Google

aresta adicionada de B a D ponderando 2


Comprimento total da árvore geradora: 8

Árvore geradora mínima:


[('A','B'), ('B','C'), ('B','D'), ('E','D'), ('E','F') ]

O algoritmo de Kruskal oferece uma solução semelhante à proposta pelo algoritmo de Prim.
No entanto, diferentes grafos podem fornecer soluções diferentes para a árvore geradora
mínima ao usar os algoritmos de Prim e Kruskal, porque cada algoritmo procede de maneiras
diferentes para chegar às suas conclusões.

Determinando qual algoritmo funciona melhor


Os algoritmos de Prim e Kruskal produzem um único componente conectado, unindo todos
os vértices no grafo usando uma das sequências de arestas menos longas (uma árvore
geradora mínima). Ao somar os pesos das arestas, os algoritmos determinam o comprimento
da árvore geradora resultante. Como ambos os algoritmos fornecem uma solução funcional,
você deve confiar no tempo de execução e no tipo de gráfico ponderado para determinar qual
é o melhor.

Quanto ao tempo de execução, ambos os algoritmos fornecem resultados semelhantes com


classificação de complexidade Big-O de O(E*log(V)), onde E é o número de arestas e V o
número de vértices. No entanto, você deve considerar como eles resolvem o problema porque
há diferenças no tempo médio de execução esperado.

O algoritmo de Prim constrói incrementalmente uma única solução adicionando arestas,


enquanto o algoritmo de Kruskal cria um conjunto de soluções parciais e as agrega.
Ao criar sua solução, o algoritmo de Prim depende de estruturas de dados que são mais
complexas que as de Kruskal porque adiciona continuamente arestas potenciais como
candidatos e continua escolhendo a aresta mais curta para prosseguir em direção à sua
solução. Ao operar em um grafo denso, o algoritmo de Prim é preferível ao de Kruskal porque
sua fila de prioridade baseada em heaps faz todos os trabalhos de classificação de forma rápida e eficiente.

O exemplo usa uma fila de prioridade baseada em um heap binário para selecionar as arestas
mais curtas, mas existem estruturas de dados ainda mais rápidas, como o heap de Fibonacci,
que pode produzir resultados mais rápidos quando o heap contém muitas arestas. Usando
um heap de Fibonacci, a complexidade de execução do algoritmo de Prim pode mudar para
O(E +V*log(V)), o que é claramente vantajoso se você tiver muitas arestas (o componente E
agora é somado em vez de multiplicado) em comparação com o tempo de execução relatado
anteriormente O(E*log(V)).

O algoritmo de Kruskal não precisa muito de uma fila de prioridade (mesmo que um dos
exemplos use uma) porque a enumeração e classificação das arestas acontecem apenas

CAPÍTULO 9 Reconectando os pontos 179


Machine Translated by Google

uma vez no início do processo. Sendo baseado em estruturas de dados mais simples que
funcionam através das arestas classificadas, é o candidato ideal para gráficos esparsos
regulares com menos arestas.

Encontrando o caminho mais curto

A rota mais curta entre dois pontos não é necessariamente uma linha reta, especialmente
quando uma linha reta não existe em seu gráfico. Digamos que você precise executar linhas
elétricas em uma comunidade. A rota mais curta envolveria a execução das linhas conforme
necessário entre cada local, sem levar em consideração para onde essas linhas vão. No
entanto, a vida real tende a não permitir uma solução simples. Você pode precisar passar os
cabos ao lado das estradas e não em propriedades particulares, o que significa encontrar
rotas que reduzam as distâncias o máximo possível.

Definindo o que significa


encontrar o caminho mais curto
Existem muitas aplicações para algoritmos de rota mais curta. A ideia é encontrar o caminho
que oferece a menor distância entre o ponto A e o ponto B. Encontrar o caminho mais curto é
útil tanto para transporte (como chegar a um destino consumindo menos combustível) quanto
para comunicação (como rotear informações para permitir que chegar mais cedo). Aplicações
inesperadas do problema do caminho mais curto também podem surgir no processamento de
imagens (para separar contornos de imagens), jogos (como atingir certos objetivos do jogo
usando o menor número de movimentos) e muitos outros campos nos quais você pode reduzir
o problema a um problema não direcionado. ou gráfico ponderado direcionado.

O algoritmo de Dijkstra encontrou a maioria dos usos na resolução do problema do caminho


mais curto. Edsger W. Dijkstra, um cientista da computação holandês, desenvolveu o algoritmo
para demonstrar o poder de processamento do computador ARMAC (http://www-set.
win.tue.nl/UnsungHeroes/machines/armac.html) em 1959. O algoritmo inicialmente
resolveu a distância mais curta entre 64 cidades na Holanda com base em um
mapa gráfico simples.

Outros algoritmos podem resolver o problema do caminho mais curto. O Bellman-Ford e o


Floyd-Warshall são mais complexos, mas podem lidar com gráficos com pesos negativos.
(Pesos negativos podem apresentar alguns problemas para o algoritmo de Dijkstra.) Embora
não seja imediatamente intuitivo, pesos negativos têm uma representação significativa no
mundo real. Se você estiver medindo o consumo de energia ao se mover entre pontos no
espaço e estiver usando um motor elétrico, considere que, ao descer uma ladeira, um carro
elétrico produz energia, então seu consumo de energia será negativo em um gráfico para esse
arco. Outros exemplos de pesos negativos podem estar em um

180 PARTE 3 Explorando o mundo dos gráficos


Machine Translated by Google

rede quando você considera desgostos junto com gostos, ou quando você mapeia
transações financeiras em um gráfico.

Como o problema do caminho mais curto envolve gráficos que são ponderados e
direcionados, o gráfico de exemplo requer outra atualização antes de continuar (você
pode ver o resultado na Figura 9-3).

importar networkx como nx


importar matplotlib.pyplot como plt

gráfico = {'A': {'B':2, 'C':3},


'B': {'C':2, 'D':2},
'C': {'D':3, 'E':2},
'D': {'F':3},
'E': {'D':1,'F':1},
'F': {}}

Graph = nx.DiGraph()
para nó no gráfico:
Graph.add_nodes_from(node)
for edge, weight in graph[node].items():
Graph.add_edge(nó, aresta, peso=peso)

pos = { 'A': [0,00, 0,50], 'B': [0,25, 0,75], 'C': [0,25, 0,25],
'D': [0,75, 0,75], 'E': [0,75, 0,25], 'F': [1,00,
0,50]}

draw_params = {'with_labels':True,
'arrows': True,
'node_color':'skyblue',
'node_size':700, 'width':2,
'font_size':14}

nx.draw(Graph, pos, **draw_params)


nx.draw_networkx_edge_labels(Graph, pos,
font_size=14,
edge_labels=labels)

plt.show()

CAPÍTULO 9 Reconectando os pontos 181


Machine Translated by Google

FIGURA 9-3:
O gráfico de
exemplo torna-
se ponderado
e direcionado.

Adicionando uma aresta negativa


O exemplo pode aplicar algumas modificações simples ao gráfico na seção
anterior para inserir uma aresta negativa entre os nós B e C (a Figura 9-4 mostra
o posicionamento das arestas negativas):

ngraph = {'A': {'B':2, 'C':3},


'B': {'C':-1, 'D':2},
'C': {'D':3, 'E':2},
'D': {'F':3},
'E': {'D':-1,'F':1},
'F': {}}

nGraph = nx.DiGraph() for


node in ngraph:
nGraph.add_nodes_from(node)
for edge, weight in ngraph[node].items():
nGraph.add_edge(nó, aresta, peso=peso)
rótulos = nx.get_edge_attributes(nGraph,'weight')

nx.draw(nGraph, pos, **draw_params)


nx.draw_networkx_edge_labels(nGraph, pos,
font_size=14,
edge_labels=etiquetas)
plt.title("bordas negativas")
plt.show()

182 PARTE 3 Explorando o mundo dos gráficos


Machine Translated by Google

FIGURA 9-4:
Arestas negativas
são adicionadas ao
gráfico de exemplo.

Um caso especial é quando há ciclos negativos em um gráfico. Ter ciclos negativos


apresenta problemas para todos os algoritmos que calculam a rota mais curta. Uma
condição necessária para incluir um ciclo de peso negativo em um gráfico é usar
pesos negativos para as arestas, mas isso não é suficiente. Você também precisa ter
um ciclo, ou seja, uma série de nós conectados circularmente, cujos pesos somam
um número negativo. Como a soma do ciclo é negativa, um algoritmo guloso tentando
minimizar o custo total das arestas por onde transita pode nunca escapar e acabar
preso ali (como mostrado na Figura 9-5 entre A e C).

ncgraph = {'A': {'B':1},


'B': {'C':1, 'D':2},
'C': {'A':-3, 'D':3, 'E':2},
'D': {'F':3},
'E': {'D':1,'F':1},
'F': {}}

ncGraph = nx.DiGraph()
para nó em ncgraph:
ncGraph.add_nodes_from(node)
para aresta, peso em ncgraph[node].items():
ncGraph.add_edge(nó, aresta, peso=peso)

rótulos = nx.get_edge_attributes(ncGraph,'peso')

nx.draw(ncGraph, pos, **draw_params)


nx.draw_networkx_edge_labels(ncGraph, pos,
font_size=14,
edge_labels=labels)
plt.title("ciclo negativo")
plt.show()

CAPÍTULO 9 Reconectando os pontos 183


Machine Translated by Google

FIGURA 9-5:
Um ciclo negativo
em um gráfico
pode criar
problemas
para alguns algoritmos.

Essa variação do grafo original usa modificações para criar um ciclo negativo em torno
dos nós A, B e C: Ao chegar no nó C, o algoritmo não pode voltar para A porque é o
movimento menos custoso e porque o ciclo pode diminuir o custo total do caminho.

Explicando o algoritmo de Dijkstra


O algoritmo de Dijkstra requer um vértice inicial e (opcionalmente) final como entrada.
Se você não fornecer um vértice final, o algoritmo calcula a distância mais curta entre
o vértice inicial e todos os outros vértices no gráfico. Quando você define um vértice
final, o algoritmo para ao ler esse vértice e retorna o resultado até aquele ponto, não
importa quanto do gráfico permaneça inexplorado.

O algoritmo começa estimando a distância dos outros vértices do ponto inicial. Essa é
a crença inicial registrada na fila de prioridade e é definida como infinita por convenção.
Em seguida, o algoritmo prossegue para explorar os nós vizinhos, semelhante a uma
busca em largura (BFS). Isso permite que o algoritmo determine quais nós estão
próximos e que sua distância é o peso das arestas de conexão. Ele armazena essas
informações na fila de prioridade por uma atualização de peso apropriada.

Naturalmente, o algoritmo explora os vizinhos porque uma aresta direcionada os


conecta com o vértice inicial. O algoritmo de Dijkstra considera a direção da borda.

Nesse ponto, o algoritmo se move para o vértice mais próximo no grafo com base na
aresta mais curta da fila de prioridade. Tecnicamente, o algoritmo visita um novo vértice.
Começa a explorar os vértices vizinhos, excluindo os vértices que já visitou, determina
quanto custa visitar cada um dos não visitados

184 PARTE 3 Explorando o mundo dos gráficos


Machine Translated by Google

vértices, e avalia se a distância para visitá-los é menor que a distância registrada na


fila de prioridade.

Quando a distância na fila de prioridade é infinita, isso significa que é a primeira


visita do algoritmo a esse vértice e o algoritmo registra a distância mais curta.
Quando a distância registrada na fila de prioridade não é infinita, mas é maior do que
a distância que o algoritmo acabou de calcular, significa que o algoritmo encontrou
um atalho, um caminho mais curto para chegar a esse vértice a partir do ponto de
partida, e armazena as informações na fila de prioridade. Obviamente, se a distância
registrada na fila de prioridade for menor do que a que o algoritmo acabou de avaliar,
o algoritmo descarta a informação porque a nova rota é mais longa.
Após atualizar todas as distâncias aos vértices vizinhos, o algoritmo determina se
atingiu o vértice final. Caso contrário, ele pega a aresta mais curta presente na fila de
prioridade, a visita e começa a avaliar a distância até os novos vértices vizinhos.

Como explica a narrativa do algoritmo, o algoritmo de Dijikstra mantém uma


contabilidade precisa do custo para alcançar cada vértice que encontra e atualiza
suas informações apenas quando encontra um caminho mais curto. A complexidade
de execução do algoritmo usando um heap binário em notação Big-O é O((E +
V)*log(V)), onde E é o número de arestas e V o número de nós (vértices) no grafo .
Usando um heap Fibo nacci, a complexidade pode ser reduzida para O(E + V*log(V)).
O código a seguir mostra como implementar o algoritmo de Dijikstra usando Python:

def dijkstra(gráfico, início, fim):


inf = float('inf')
conhecido = set()
prioridade = priority_queue()
caminho = {início: início}

para vértice no gráfico:


se vértice == início:
prioridade.push(0, vértice)
senão:
prioridade.push(inf, vértice)

ultimo = inicio
enquanto durar != fim:

(peso, nó_real) = priority.pop()


se atual_node não for conhecido:
para next_node no gráfico[actual_node]:
upto_actual = priority.index[actual_node]
upto_next = priority.index[next_node]
to_next = upto_real + \

CAPÍTULO 9 Reconectando os pontos 185


Machine Translated by Google

graph[actual_node][next_node] if to_next <


upto_next:
priority.push(to_next, next_node) print("Encontrado
atalho de %s para %s" % (actual_node, next_node))
print("\tComprimento total até agora: %i" % to_next)
path[next_node] = actual_node

ultimo = atual_node
conhecido.adicionar(nó_real)

return priority.index, caminho

dist, caminho = dijkstra(grafo, 'A', 'F')

Depois de executar o código, você recebe a seguinte saída:

Atalho encontrado de A para C

Comprimento total até agora: 3


Atalho encontrado de A para B

Comprimento total até agora: 2


Atalho encontrado de B para D

Comprimento total até agora: 4


Atalho encontrado de C para E

Comprimento total até agora: 5


Atalho encontrado de D para F

Comprimento total até agora: 7


Atalho encontrado de E para F

Comprimento total até agora: 6

O algoritmo retorna algumas informações úteis: o caminho mais curto para o destino
e as distâncias mínimas registradas para os vértices visitados. Para visualizar o
caminho mais curto, você precisa de uma função reverse_path() que reorganiza o
caminho para torná-lo legível:

def reverse_path(caminho, início, fim): progressão = [fim]


while progressão[-1] != início:
progressão.append(caminho[progressão[-1]]) return
progressão[::-1]

print(caminho_reverso(caminho, 'A', 'F'))

186 PARTE 3 Explorando o mundo dos gráficos


Machine Translated by Google

Aqui está a saída que você vê depois de executar o código.

['A', 'C', 'E', 'F']

Você também pode saber a distância mais curta para cada nó encontrado consultando o dicionário
dist :

imprimir(dist)

A saída mostra a correlação entre o nó e a distância, conforme mostrado aqui:

{'D': 4, 'A': 0, 'B': 2, 'F': 6, 'C': 3, 'E': 5}

Explicando o algoritmo de Bellman-Ford


O algoritmo de Bellman-Ford é provavelmente um dos algoritmos mais estudados em cursos de
ciência da computação em universidades de todo o mundo porque tem muitas aplicações
importantes no mundo real. Este algoritmo é usado para encontrar o caminho mais curto para
chegar a cada roteador em uma rede de servidores, e é particularmente adequado para isso
porque, ao contrário de outros algoritmos, pode ser distribuído; ou seja, os cálculos necessários
para que o algoritmo seja concluído podem ser compartilhados entre vários computadores,
tornando a tarefa mais rápida e fácil,

O algoritmo de Bellman-Ford, desenvolvido para resolver o mesmo problema que o de Dijkstra,


adota uma abordagem diferente para resolver o problema, o que o torna mais adequado para um
mundo de computadores em rede. Embora o algoritmo de Dijkstra adote uma abordagem
gananciosa, explorando apenas as arestas mais próximas antes de decidir sobre um determinado
caminho, o algoritmo de Bellman-Ford verifica e prossegue em todas elas.

Essa escolha afeta a complexidade de execução do algoritmo em notação Big-O, que agora é
O(E*V), onde E é o número de arestas e V o número de nós (vértices) no grafo. No entanto, essa
escolha também facilita a divisão dos cálculos do algoritmo em tarefas separadas, pois cada
computador pode explorar um conjunto diferente de arestas consecutivas.

Roteamento de informações com mais eficiência


A implementação desta seção do algoritmo de Bellman-Ford requer o gráfico e um ponto inicial
como entrada porque considera todos os outros nós do gráfico como pontos finais. Começando
no início, ele traça duas informações: a distância de cada nó terminal (que inicialmente é definido
como infinito) e o último nó que fez o algoritmo atualizar as informações de distância sobre cada
outro nó. Ambas as informações são registradas usando um dicionário Python.

CAPÍTULO 9 Reconectando os pontos 187


Machine Translated by Google

O algoritmo então itera através de cada nó e depois através de cada aresta do grafo
(com base no nó que está sendo explorado). Esta é uma busca exaustiva através da
estrutura das arestas do grafo. A cada passo de iteração, o algoritmo calcula a distância
real do nó inicial ao nó que está sendo avaliado, somando a aresta que está sendo
explorada. Se a soma dos dois representar um caminho mais curto do que o conhecido
atualmente, a informação é atualizada tanto no dicionário de distância quanto no
dicionário do último nó (porque este dicionário representa o nó anterior no caminho
mais curto).

def bellman_ford(gráfico, início):


inf = float('inf')
distance = {node: inf if node!=start else 0.0 for node in
gráfico}
anterior = {nó: Nenhum para nó no gráfico}

para atual_node no gráfico:


para next_node no gráfico[actual_node]:
edge_weight = graph[actual_node][next_node]
tempDistance = (distance[actual_node] + edge_weight)

if tempDistance < distance[next_node]:


distance[next_node] = tempDistance
anterior[next_node] = atual_node
distância de retorno, anterior

Quando todas as iterações são concluídas, a função retorna a distância e os dicionários


de nós anteriores para cálculos adicionais.

Provando a versatilidade do algoritmo


O algoritmo Bellman-Ford é mais versátil do que outros algoritmos. Mesmo que você
não possa usar as distâncias que ele retorna imediatamente, outras manipulações de
dados fornecem informações mais úteis. Primeiro, reiterando as arestas do gráfico,
você pode identificar quando há discrepâncias. A soma da distância mais curta até um
nó com o custo de borda para alcançar outro nó de destino e a comparação com a
distância mais curta até esse nó de destino fornece uma dica sobre um ciclo negativo
quando há uma discrepância, pois a soma deve ser sempre igual ou maior do que a
distância mais curta. Quando há um ciclo negativo, as estimativas de distância usando
o algoritmo de Bellman Ford não são exatas. Segundo, usando as informações do nó
anterior, você pode reconstruir o caminho mais curto do nó de origem para todos os outros nós.

def detect_negative_cycle(gráfico, distância, anterior):


para atual_node no gráfico:
para next_node no gráfico[actual_node]:
edge_weight = graph[actual_node][next_node]

188 PARTE 3 Explorando o mundo dos gráficos


Machine Translated by Google

if distance[actual_node] + edge_weight <


distance[next_node]:
retornar Verdadeiro
retorna falso

def retrace_shortest_path(anterior, fim): caminho = [fim]


while caminho[0] != 'A':

caminho = [anterior[fim]] + caminho fim


= anterior[fim]
caminho de retorno

Usando o código a seguir, você pode ver as duas funções anteriores


detect_negative_cycle() e retrace_shortest_path() trabalhando nos dados do gráfico
original:

distância, anterior = bellman_ford(gráfico, 'A') print(distância)


print("caminho:", retrace_shortest_path(anterior, 'F')) neg_cycle
= detect_negative_cycle(grafo, distância, anterior) print(f"Existem ciclos
negativos no gráfico: {neg_cycle}")

A saída retorna as distâncias exatas para cada nó de destino e o caminho para


alcançar o nó F a partir do nó A. Mais importante, você sabe que não há ciclos
negativos no gráfico e pode confiar nos resultados.

{'A': 0,0, 'B': 2,0, 'C': 3,0, 'D': 4,0, 'E': 5,0, 'F': 6,0} caminho: ['A', 'C', 'E ', 'F']

Existem ciclos negativos no gráfico: Falso

O código a seguir testa o algoritmo usando um gráfico contendo uma aresta negativa:

distance, anterior = bellman_ford(ngraph, 'A') print(distance)


print("path:", retrace_shortest_path(previous, 'F')) neg_cycle =
detect_negative_cycle(ngraph, distance, previous) print(f"Existem ciclos
negativos no gráfico: {neg_cycle}")

O resultado a seguir aponta um novo caminho mais curto para alcançar o nó F a partir
do nó A, desta vez aproveitando a presença da borda negativa. A ausência de ciclos
negativos garante a validade dos cálculos.

{'A': 0.0, 'B': 2.0, 'C': 1.0, 'D': 2.0, 'E': 3.0, 'F': 4.0} caminho: ['A', 'B', 'C ', 'E', 'F']

Existem ciclos negativos no gráfico: Falso

CAPÍTULO 9 Reconectando os pontos 189


Machine Translated by Google

Finalmente, você pode testar o algoritmo usando um gráfico que tenha um ciclo negativo.

distância, anterior = bellman_ford(ncgraph, 'A')


imprimir (distância)
print("caminho:", retrace_shortest_path(anterior, 'F'))
neg_cycle = detect_negative_cycle(ncgraph, distance, anterior)
print(f"Existem ciclos negativos no gráfico: {neg_cycle}")

A saída é semelhante às outras saídas, mas por dois detalhes significativos: uma das distâncias
mais curtas é negativa (atingindo o nó inicial a partir do nó inicial) e a função de detecção de
ciclos negativos disparou um alarme.

{'A': -1,0, 'B': 1,0, 'C': 2,0, 'D': 3,0, 'E': 4,0, 'F': 5,0}
caminho: ['A', 'B', 'C', 'E', 'F']
Existem ciclos negativos no gráfico: Verdadeiro

No final, você não pode confiar nos resultados fornecidos para este gráfico porque ele tem um
ciclo negativo. Ao examinar alguns nós e arestas, você pode identificar facilmente uma aresta
negativa, mas em gráficos maiores, você pode confiar apenas no algoritmo de Bellman-Ford para
a verificação mais rápida.

Explicando o algoritmo Floyd-Warshall


Robert W. Floyd, cientista da computação e ganhador do prêmio Turing em 1978, concebeu o
algoritmo Floyd-Warshall em sua forma atual. Você pode encontrar outras formas desse algoritmo
formuladas por outros cientistas da computação no passado:

» Bernard Roy em 1959

» Stephen Warshall em 1962

» Também é semelhante ao algoritmo de Kleene publicado em 1956

Floyd-Warshall é um algoritmo de programação dinâmica porque resolve subproblemas de ping


sobrepostos (para evitar recalcular cálculos duplicados) e sua solução é obtida usando as
soluções de seus subproblemas (uma propriedade chamada subestrutura ótima).

O algoritmo Floyd-Warshall, assim como outros algoritmos vistos até agora neste livro, pode
calcular distâncias entre nós em um gráfico e funciona bem com arestas mostrando um custo
negativo. Ele produz uma matriz quadrada relacionando todos os nós em um gráfico com suas
distâncias relativas (tecnicamente chamada de matriz de distância). Como o algoritmo itera três
vezes nos nós do gráfico de maneira aninhada,

190 PARTE 3 Explorando o mundo dos gráficos


Machine Translated by Google

sua complexidade de tempo depende exclusivamente do número de nós presentes e é


O(V3), onde V representa vértices, ou seja, nós.

Comparando com outros algoritmos


Embora o desempenho do algoritmo Floyd-Warshall se assemelhe a Dijkstra e
Bellman-Ford, existem algumas diferenças importantes:

» O algoritmo Dijkstra funciona melhor (tem a menor complexidade) quando


encontrar a distância entre dois nós em um grafo, embora não possa funcionar
corretamente se algumas arestas no grafo tiverem um custo negativo.

» O algoritmo de Bellman-Ford retorna a distância de um nó em relação a todos os


nós presentes no gráfico. Ele funciona corretamente mesmo quando há pesos
negativos, mas é mais lento que o Dijkstra.

» O algoritmo Floyd-Warshall retorna a distância de cada nó em relação a todos os


outros nós presentes no gráfico e é relativamente eficiente em fazê-lo, mas é o
mais lento dos três porque retorna muito mais informações.
No entanto, em alguns casos, os algoritmos de Dijkstra e Bellman-Ford podem produzir
o mesmo resultado que o algoritmo Floyd-Warshall, mas requerem tempos de execução
mais longos e mais cálculos.

Os algoritmos de Bellman-Ford e Floyd-Warshall podem lidar com pesos negativos se


não houver ciclos negativos. Em vez disso, no caso de pesos de borda negativos, o
algoritmo de Dijkstra explora a situação para evitar visitar todos os nós. Em tal ocorrência,
os resultados fornecidos por um algoritmo de Dijkstra não são confiáveis.

Uma situação diferente ocorre quando há ciclos negativos no gráfico. Nesse caso, o
Bellman-Ford falhará e não retornará uma solução, mas o Floyd Warshall concluirá sua
execução mesmo assim. Você também pode detectar o ciclo negativo porque, na matriz
retornada, você descobre que algum nó tem uma distância negativa para si mesmo (as
distâncias para si mesmo estão na diagonal da matriz).

Usar três passes é melhor


Esta implementação do algoritmo Floyd-Warshall usa duas funções. O primeiro, dado
um grafo, obtém a distância entre dois nós se estiverem adjacentes; caso contrário, a
função retornará distância zero se os dois nós forem o mesmo, ou distância infinita se
os nós forem diferentes.

def dist(gráfico, início, fim):


se terminar no gráfico[início]:
return float(gráfico[início][fim])
elif início == fim:

CAPÍTULO 9 Reconectando os pontos 191


Machine Translated by Google

retornar 0,0
senão:
return float('inf')

Usando a função dist() , é fácil criar a matriz de distância inicial do algoritmo Floyd-Warshall
como um dicionário de dicionários, uma forma de representar uma matriz de distância
quadrada com a qual você pode determinar a distância mais curta entre um nó inicial
(representado em linhas) e um nó final (representado em colunas). A ideia central do
algoritmo é, de fato, partir de uma matriz quadrada cujas distâncias conhecidas são apenas
as distâncias dos nós adjacentes e expandi-la para conter o caminho mais curto entre os
nós (quando alcançáveis; caso contrário, a distância permanece infinita).

def floyd_warshall(gráfico):

mat = {linha: {col: dist(gráfico, linha, coluna)


para coluna no gráfico} para linha no gráfico}
para k no tapete:
para i no tapete:

para j no tapete:
if mat[i][j] > mat[i][k] + mat[k][j]:
mat[i][j] = mat[i][k] + mat[k][j]
tapete de retorno

O algoritmo opera usando três ciclos aninhados. Em todos os ciclos, ele itera pelos nós.
Durante as iterações, o algoritmo verifica as distâncias entre o nó pivô (da primeira iteração,
k no código), o nó inicial (da segunda iteração, i no código) e o nó de destino (da terceira
iteração, j no código). Por se tratar de uma triangulação, ele atualiza a distância entre o nó
inicial e o nó de destino se for maior que a distância de alcançar o nó de destino a partir do
nó inicial e passar pelo nó pivô. Ao passar por todas as passagens do algoritmo, você pode
ter certeza de que o algoritmo calcula a matriz de distância exata se o gráfico não tiver
ciclos negativos.

A função retorna a matriz de distância como saída. Como um dicionário de dicionários


expressa o resultado, você precisa de um trecho de código para desdobrá-lo em uma lista
de listas, uma estrutura de dados que contém os rótulos de linha e coluna para facilitar a leitura.
Naturalmente, você também pode usar outras estruturas de dados como um Pandas DataFrame (https://
pandas.pydata.org/pandas-docs/stable/reference/api/pandas.
DataFrame.html) em que você poderia representar melhor os rótulos de linha e coluna, mas
este exemplo usa um nível mais baixo de sofisticação para demonstrar melhor seu
funcionamento:

192 PARTE 3 Explorando o mundo dos gráficos


Machine Translated by Google

def print_mat(mat):
els = [item para item no tapete]
labels = [' '] + [item para item em els]
imprimível = [etiquetas]
para k, linha em enumerate(els):
printable.append([labels[k+1]] + [mat[row][col] for col in els])

retornar imprimível

Para determinar se existem ciclos negativos, você precisa considerar o que acontece com
nós cuja distância inicial é zero em tais circunstâncias. Normalmente, essa situação ocorre
quando você calcula a distância de um nó para si mesmo. Nesse caso, como o algoritmo
Floyd-Warshall procura caminhos alternativos mais curtos passando por outro nó, você
nunca encontrará um nó alternativo, a menos que o nó faça parte de um ciclo negativo.
Assim, observando a diagonal da matriz de distâncias produzida pelo algoritmo, onde estão
os caminhos de distância para os mesmos nós, se detectar algum valor negativo, significa
a presença de ciclos negativos no gráfico.
O código a seguir apenas extrai a diagonal da matriz de distância:

def extract_diagonal(mat):
return [mat[item][item] para item em mat]

Agora que o código está completo, você pode executar o algoritmo no gráfico de exemplo:

print_mat(floyd_warshall(gráfico))

Você vê a seguinte matriz de distância e a lê procurando o nó inicial nos rótulos das linhas
(na coluna mais à esquerda) e o nó final nos rótulos das colunas (que podem ser
encontrados na primeira linha). A diagonal, que representa a distância de um nó a si
mesmo, deve ser preenchida por valores zero. Se em uma célula da matriz você encontrar
um valor infinito (inf), isso implica que não há como no gráfico chegar a esse nó dado esse
ponto de partida. (Isso acontece normalmente em grafos direcionados.)

[[' ', 'A', 'B', 'C', 'D', 'E', 'F'],


['A', 0,0, 2,0, 3,0, 4,0, 5,0, 6,0],
['B', inf, 0,0, 2,0, 2,0, 4,0, 5,0],
['C', inf, inf, 0,0, 3,0, 2,0, 3,0],
['D', inf, inf, inf, 0,0, inf, 3,0],
['E', inf, inf, inf, 1,0, 0,0, 1,0],
['F', inf, inf, inf, inf, inf, 0.0]]

Você também pode inspecionar a matriz de distância do gráfico com a aresta negativa:

print_mat(floyd_warshall(ngraph))

CAPÍTULO 9 Reconectando os pontos 193


Machine Translated by Google

A saída revela que alguns caminhos têm valores negativos:

[[' ', 'A', 'B', 'C', 'D', 'E', 'F'], ['A', 0,0, 2,0, 1,0,
2,0, 3,0, 4,0], [' B', inf, 0,0, -1,0, 0,0, 1,0, 2,0],
['C', inf, inf, 0,0, 1,0, 2,0, 3,0], ['D', inf, inf, inf,
0,0, inf, 3.0], ['E', inf, inf, inf, -1.0, 0.0, 1.0],
['F', inf, inf, inf, inf, inf, 0.0]

Finalmente, você pode produzir a matriz de distância para o gráfico com o ciclo negativo e
extrair sua diagonal.

mat = floyd_warshall(ncgraph)
extract_diagonal(mat)

A diagonal de saída contém valores negativos:

[-1,0, -1,0, -2,0, 0,0, 0,0, 0,0]

Não há caminhos mais curtos entre qualquer par de arestas que fazem parte de um ciclo
negativo. Isso ocorre porque o comprimento produzido por qualquer um desses caminhos será
arbitrariamente negativo dependendo das passagens do algoritmo.

194 PARTE 3 Explorando o mundo dos gráficos


Machine Translated by Google

NESTE CAPÍTULO

» Vendo redes sociais em forma de gráfico

» Interagindo com o conteúdo do gráfico

Capítulo 10

Descobrindo o gráfico
Segredos
matemática. O Capítulo 9 aumenta seu conhecimento ajudando você a ver as
O Capítulo 8 ajuda
relação de você a entender
grafos os fundamentos
com algoritmos. dos gráficos
Este capítulo ajudaconforme eles
você a se se aplicam
concentrar ema
aplicar as teorias desses dois capítulos anteriores para interagir com gráficos de
maneira prática.

A primeira seção transmite o caráter das redes sociais usando gráficos.


Considera a importância das conexões criadas pelas redes sociais. Por exemplo,
a análise de conversas pode revelar padrões que ajudam você a entender o
tópico subjacente melhor do que simplesmente ler as conversas. Uma
ramificação de conversa específica pode atrair mais atenção porque é mais
importante do que outra ramificação de conversa. Obviamente, você deve
realizar essa análise ao lidar com problemas como spam. Análises desse tipo
podem levar a todo tipo de conclusões interessantes, como onde gastar mais
dinheiro em publicidade para atrair mais atenção e, portanto, vendas.

A segunda seção analisa gráficos de navegação para obter resultados específicos. Por
exemplo, ao conduzir, pode ser necessário saber qual o melhor percurso a seguir entre dois
pontos, uma vez que, apesar de um percurso ser mais curto, também tem uma construção que
torna o segundo percurso melhor. Às vezes, você precisa randomizar sua pesquisa para
descobrir uma melhor rota ou uma melhor conclusão. Esta seção do capítulo também discute
essa questão.

CAPÍTULO 10 Descobrindo Segredos do Gráfico 195


Machine Translated by Google

Você não precisa digitar o código-fonte para este capítulo manualmente. Na verdade, usar a
fonte para download é muito mais fácil. Você pode encontrar a fonte deste capítulo no
A4D2E; 10; Arquivo Graph Secrets.ipynb da fonte para download. Consulte a Introdução
para obter detalhes sobre como localizar esse arquivo de origem.

Visualizando Redes Sociais como Gráficos


Toda interação social necessariamente se conecta com todas as outras interações sociais
do mesmo tipo. Por exemplo, considere uma rede social como o Facebook. Os links em sua
página se conectam a membros da família, mas também se conectam a fontes externas que,
por sua vez, se conectam a outras fontes externas. Cada um dos membros de sua família
também tem links externos. Conexões diretas e indiretas entre várias páginas eventualmente
vinculam todas as outras páginas, mesmo que o processo de passar de uma página para
outra possa exigir o uso de inúmeros links. A conectividade também ocorre de várias outras
maneiras. A questão é que estudar redes sociais simplesmente vendo uma página no
Facebook ou outra fonte de informação é difícil. A Análise de Redes Sociais (SNA) é o
processo de estudar as interações em redes sociais usando gráficos chamados sociogramas,
nos quais os nós (como uma página do Facebook) aparecem como pontos e os laços (como
links de páginas externas) aparecem como linhas. As seções a seguir discutem algumas das
questões que envolvem o estudo das redes sociais como gráficos.

Agrupando redes em grupos


As pessoas tendem a formar comunidades – grupos de outras pessoas que têm ideias e
sentimentos semelhantes. Ao estudar esses agrupamentos, torna-se mais fácil atribuir certos
comportamentos ao grupo como um todo (embora atribuir um comportamento geral a um
indivíduo seja perigoso e não confiável). A ideia por trás do estudo de clusters é que, se
existe uma conexão entre as pessoas, elas geralmente têm um conjunto comum de ideias e
objetivos. Ao localizar clusters, você pode determinar essas ideias inspecionando a
associação ao grupo. Por exemplo, é comum tentar encontrar aglomerados de pessoas na
detecção de fraudes em seguros e na fiscalização tributária. Grupos inesperados de pessoas
podem levantar suspeitas de que fazem parte de um grupo de fraudadores ou sonegadores
de impostos porque não têm os motivos usuais para as pessoas se reunirem em tais circunstâncias.

Os gráficos de amizade podem representar como as pessoas se conectam. Os nós


representam indivíduos e as arestas representam suas conexões, como relacionamentos
familiares, contatos comerciais ou laços de amizade. Normalmente, os gráficos de amizade
não são direcionados porque representam relacionamentos mútuos e, às vezes, são
ponderados para representar a força do vínculo entre duas pessoas.

196 PARTE 3 Explorando o mundo dos gráficos


Machine Translated by Google

Ao procurar clusters em um gráfico de amizade, as conexões entre os nós nesses clusters dependem de
tríades — essencialmente, tipos especiais de triângulos. As conexões entre três pessoas podem se
enquadrar nestas categorias:

» Fechado: As três pessoas se conhecem. Pense em um ambiente familiar neste


caso, em que todos se conhecem.

» Aberto: Uma pessoa conhece duas outras pessoas, mas as outras duas pessoas
não se conhecem. Pense em uma pessoa que conhece um indivíduo no trabalho e
outro em casa, mas o indivíduo no trabalho não sabe nada sobre o indivíduo em
casa.

» Par conectado: Uma pessoa conhece uma das outras pessoas em uma tríade, mas
não conhece a terceira pessoa. Essa situação envolve duas pessoas que sabem
algo uma da outra conhecendo alguém novo – alguém que potencialmente quer
fazer parte do grupo.

» Desconectado: A tríade forma um grupo, mas ninguém do grupo se conhece. Este


último pode parecer um pouco estranho, mas pense em uma convenção ou
seminário. As pessoas nesses eventos formam um grupo, mas podem não saber
nada umas das outras. No entanto, como eles têm interesses semelhantes, você
pode usar o agrupamento para entender o comportamento do grupo.

Muitos estudos se concentram em gráficos não direcionados que se concentram apenas em associações.
Você também pode usar gráficos direcionados para mostrar que a Pessoa A conhece a Pessoa B, mas a
Pessoa B nem sabe que a Pessoa A existe. Nesse caso, você realmente tem 16 tipos diferentes de
tríades a serem consideradas. Para simplificar, este capítulo se concentra apenas nesses quatro tipos:
fechado, aberto, par conectado e não conectado.

As tríades ocorrem naturalmente nos relacionamentos, e muitas redes sociais da Internet têm alavancado
essa ideia para acelerar as conexões entre os participantes. A densidade de conexões é importante para
qualquer tipo de rede social, pois uma rede conectada pode divulgar informações e compartilhar conteúdo
com mais facilidade. Por exemplo, quando o LinkedIn, a rede social profissional (https://www.linkedin.com/),
decidiu aumentar a densidade de conexão de sua rede, começou procurando por tríades abertas e
tentando fechá-las convidando pessoas a se conectarem. O fechamento de tríades é a base do algoritmo
de sugestão de conexão do LinkedIn. Você pode descobrir mais sobre como funciona e as melhorias que
o LinkedIn fez lendo https://engineering.linkedin.com/blog/2021/optimizing-pymk-for equity-in-network-
creation.

O exemplo nesta seção se baseia no gráfico de amostra do Zachary's Karate Club descrito em
https://documentation.sas.com/doc/en/pgmsascdc/v_009/
casmlnetwork/casmlnetwork_network_examples06.htm. É um pequeno gráfico que permite
ver como as redes funcionam sem gastar muito tempo carregando um grande

CAPÍTULO 10 Descobrindo Segredos do Gráfico 197


Machine Translated by Google

conjunto de dados. Felizmente, esse conjunto de dados aparece como parte do pacote networkx
apresentado no Capítulo 8. A rede Zachary's Karate Club representa as relações de amizade
entre 34 membros de um clube de karatê de 1970 a 1972. O sociólogo Wayne W. Zachary a usou
como tema de estudo . Ele escreveu um artigo sobre isso intitulado “Um Modelo de Fluxo de
Informação para Conflito e Fissão em Pequenos Grupos”. O fato interessante sobre este gráfico e
seu papel é que naqueles anos, surgiu um conflito no clube entre um dos instrutores de karatê (nó
número 0) e o presidente do clube (nó número 33). Ao agrupar o gráfico, você pode prever quase
perfeitamente a divisão do clube em dois grupos logo após a ocorrência.

Como este exemplo também desenha um gráfico mostrando os grupos (para que você possa
visualizá-los mais facilmente), você também precisa usar o pacote matplotlib . O código a seguir
mostra como representar graficamente os nós e as bordas do conjunto de dados. (Você pode
encontrar esse código no arquivo de código-fonte para download A4D2E; 10; Social Networks.ipynb ;
consulte a Introdução para obter detalhes.)

importar networkx como nx


importar matplotlib.pyplot como plt
%matplotlib em linha

gráfico = nx.karate_club_graph()

pos=nx.spring_layout(gráfico)
nx.draw(graph, pos, with_labels=True)
plt.show()

Para exibir o gráfico na tela, você também precisa fornecer um layout que determine como
posicionar os nós na tela. Este exemplo usa o algoritmo direcionado à força de Fruchterman
Reingold (a chamada para nx.spring_layout). No entanto, você pode escolher um dos outros
layouts descritos na seção Layout do gráfico em https://networkx.github.io/documentation/
networkx-1.9/reference/
desenho.html. A Figura 10-1 mostra a saída do exemplo. (Sua saída pode parecer diferente
devido à forma como o algoritmo gera o gráfico.)

O algoritmo direcionado à força de Fruchterman-Reingold para gerar layouts automáticos de


gráficos cria layouts compreensíveis com nós e arestas separados que tendem a não se cruzar,
imitando o que acontece na física entre partículas eletricamente carregadas ou ímãs com o
mesmo sinal. Ao observar o resultado do gráfico, você pode ver que alguns nós têm apenas uma
conexão, alguns dois e alguns mais de dois. As arestas formam tríades, como mencionado
anteriormente. No entanto, a consideração mais importante é que a Figura 10-1 mostra claramente
o agrupamento que ocorre em uma rede social.

198 PARTE 3 Explorando o mundo dos gráficos


Machine Translated by Google

FIGURA 10-1:
Um gráfico
mostrando os
clusters de
rede de
relacionamentos entre amigos.

Descobrindo comunidades
Um grupo de pessoas fortemente associadas geralmente define uma comunidade. Na verdade,
o termo clique se aplica a um grupo cuja participação no grupo é exclusiva e todos conhecem
muito bem todos os outros. A maioria das pessoas tem memórias de infância de um grupo de
amigos na escola ou na vizinhança que sempre passavam o tempo juntos. Isso é uma
panelinha, na qual cada membro da panelinha está conectado aos outros.

Cliques estão mais associados a grafos não direcionados do que direcionados. Nos grafos
direcionados, concentre-se em componentes conectados, como quando existe um caminho de
conexão direta entre todos os pares de nós no próprio componente. Uma cidade é um exemplo
de componente fortemente conectado porque você pode chegar a qualquer destino a partir de
qualquer ponto de partida seguindo ruas de mão única e de mão dupla.

Matematicamente, um clique é ainda mais rigoroso porque implica um subgrafo (uma parte de
um grafo de rede que você pode separar de outras partes como um elemento completo por si
só) que tem conectividade máxima. Ao olhar para vários tipos de redes sociais, é fácil escolher
os clusters, mas o que pode ser difícil é encontrar os cliques – os grupos com conectividade
máxima – dentro dos clusters. Ao saber onde existem panelinhas, você pode começar a
entender melhor a natureza coesa de uma comunidade. Além disso, a natureza exclusiva dos
cliques tende a criar um grupo que tem suas próprias regras fora daquelas que podem existir
na rede social como um todo. O exemplo a seguir mostra como extrair cliques e

CAPÍTULO 10 Descobrindo Segredos do Gráfico 199


Machine Translated by Google

comunidades do gráfico Karate Club usado na seção anterior (observe que a instrução de
importação nas duas primeiras linhas deve aparecer em uma única linha):

de networkx.algorithms.community.kclique importar
k_clique_communities

gráfico = nx.karate_club_graph()
# Encontrando e imprimindo todos os cliques de quatro
cliques = nx.find_cliques(grafo)
print('Todos os cliques de quatro: %s'
% [c para c em cliques se len(c)>=4])

# Juntando panelinhas de quatro em comunidades


comunidades = nx.k_clique_communities(gráfico, k=4)
lista_comunidades = [lista(c) para c em comunidades]
nodes_list = [node for community in Communities_list for
nó na comunidade]
print('Encontrou estas comunidades: %s' % community_list)

# Imprimindo o subgráfico das comunidades


subgrafo = grafico.subgrafo(lista_de-nós)
nx.draw(subgraph, with_labels=True)
plt.show()

Todos os cliques de quatro: [[0, 1, 2, 3, 13], [0, 1, 2, 3, 7],


[33, 32, 8, 30], [33, 32, 23, 29]]
Encontrou estas comunidades: [[0, 1, 2, 3, 7, 13],
[32, 33, 29, 23], [32, 33, 8, 30]]

O exemplo começa extraindo apenas os nós no conjunto de dados do Karate Club que
possuem quatro ou mais conexões e, em seguida, imprime os cliques com um tamanho
mínimo de quatro. Claro, você pode definir qualquer nível de conectividade entre os nós que
julgar relevantes. Talvez você considere um clique uma comunidade na qual cada nó tem 20
conexões, mas outras pessoas podem ver um clique como uma comunidade em que cada nó
tem apenas três conexões.

Finalmente, você pode desenhar o subgráfico e exibi-lo. A Figura 10-2 mostra a saída deste
exemplo, que exibe o conjunto de cliques com quatro ou mais conexões.

Encontrar cliques em grafos é um problema complexo que requer muitos cálculos.


(É um problema difícil porque o número de cliques pode crescer exponencialmente com cada
nó adicionado.) O problema pode ser resolvido usando um algoritmo baseado em uma busca
de força bruta, o que significa tentar todos os subconjuntos possíveis de nós para

200 PARTE 3 Explorando o mundo dos gráficos


Machine Translated by Google

determinar se eles são panelinhas. No entanto, você também pode tentar algo mais inteligente,
como o algoritmo inventado pelos cientistas holandeses Coenraad Bron e Joep Ker boschin
em 1973, que listará todos os maiores cliques em um determinado gráfico.

FIGURA 10-2:
As comunidades
geralmente
contêm cliques que
podem ser
úteis para o SNA.

O algoritmo funciona por recursão e exploração sistemática. No final, ele lista todos os cliques
máximos. Um clique é considerado máximo quando você não pode adicionar mais nós a ele.
Para suas operações, o algoritmo utiliza três conjuntos de vértices, convencionalmente
chamados de R, para cliques potenciais; P, para nós restantes a explorar; e X, para os nós
pularem porque já foram explorados (o algoritmo não se repete). No início, R e X estão vazios
e P contém todos os nós do grafo.
O algoritmo é executado iterando pelos nós do conjunto P, considerando cada um deles como
candidato a uma clique. Iteração após iteração, o algoritmo considera apenas os vizinhos do
candidato, colocando-os em R, e move os demais para o conjunto X. Ao final da exploração, a
clique máxima resultante é retornada do conjunto R contendo todos os nós que são vizinhos
uns dos outros. Apesar de ser recursivo e iterativo, na prática este algoritmo é visto como um
dos algoritmos mais rápidos em encontrar cliques porque, no pior cenário, seu tempo de
execução em um grafo de n-vértices é O(3n/3).

A lista de panelinhas por si só não ajuda muito, no entanto, se você quiser ver as comunidades.
Para vê-los, você precisa contar com algoritmos especializados e complexos para mesclar
cliques sobrepostos e encontrar clusters, como o método de percolação de cliques descrito em
https://www.salatino.org/wp/clique percolation-method-in-python/ . O pacote NetworkX oferece
k_clique_
comunidades, uma implementação do algoritmo de percolação de cliques, que resulta na união
de todos os cliques de um determinado tamanho (o parâmetro k). Esses cliques de um certo
tamanho compartilham k-1 elementos (ou seja, eles diferem por apenas um componente, uma
regra verdadeiramente estrita).

CAPÍTULO 10 Descobrindo Segredos do Gráfico 201


Machine Translated by Google

A percolação de cliques fornece uma lista de todas as comunidades encontradas. Neste


exemplo, uma panelinha gira em torno do instrutor de karatê e outra gira em torno do
presidente do clube. Além disso, você pode extrair todos os nós que fazem parte de uma
comunidade em um único conjunto, o que ajuda a criar um subgráfico feito apenas de
comunidades.

Navegando em um gráfico
Navegar ou percorrer um gráfico significa visitar cada um dos nós do gráfico. A finalidade
de navegar em um gráfico pode incluir determinar o conteúdo do nó ou atualizá-lo conforme
necessário. Ao navegar em um gráfico, é perfeitamente possível que o código visite nós
específicos mais de uma vez devido à conectividade que os gráficos fornecem.
Consequentemente, você também precisa considerar marcar os nós como visitados depois
de ver seu conteúdo. O ato de navegar em um gráfico é importante para determinar como os
nós se conectam para que você possa executar várias tarefas. Os capítulos anteriores
discutem técnicas básicas de navegação em grafos. As seções a seguir ajudam você a
entender algumas das técnicas mais avançadas de navegação em gráficos.

Contando os graus de separação


O termo graus de separação define a distância entre os nós em um gráfico.
Ao trabalhar com um gráfico não direcionado sem arestas ponderadas, cada aresta conta
para um valor de um grau de separação. No entanto, ao trabalhar com outros tipos de
gráficos, como mapas, onde cada aresta pode representar um valor de distância ou tempo,
os graus de separação podem se tornar bastante diferentes. A questão é que os graus de
separação indicam algum tipo de distância. O exemplo nesta seção (e o que segue) baseia-
se nos dados do gráfico a seguir. (Você pode encontrar esse código no arquivo de código-
fonte para download A4D2E; 10; Graph Navigation.ipynb ; consulte a Introdução para obter
detalhes.)

importar networkx como nx


importar matplotlib.pyplot como plt
%matplotlib em linha

dados = {'A': ['B', 'F', 'H'],


'B': ['A', 'C'],
'C': ['B', 'D'],
'D': ['C', 'E'],
'E': ['D', 'F', 'G'],
'F': ['E', 'A'],
'G': ['E', 'H'],

202 PARTE 3 Explorando o mundo dos gráficos


Machine Translated by Google

'H': ['G', 'A']}

gráfico = nx.DiGraph(dados)
pos=nx.spring_layout(gráfico)
nx.draw_networkx_labels(graph, pos, font_size=16)
nx.draw_networkx_nodes(graph, pos, node_shape="o",
node_color='skyblue', node_size=800)
nx.draw_networkx_edges(graph, pos, width=1, arrows=True,
arrowstyle='-|>', arrowsize=20)
plt.show()

Esta é uma expansão do gráfico usado na seção “Indo além das árvores” do Capítulo
6 (Figura 6-2). A Figura 10-3 mostra como esse gráfico aparece para que você possa
visualizar o que a chamada de função está fazendo. Observe que este é um gráfico
direcionado (networkx DiGraph) porque o uso de um gráfico direcionado tem certas
vantagens ao determinar graus de separação (e realizar vários outros cálculos).

FIGURA 10-3:
Um gráfico de
amostra
usado para
fins de navegação.

Para descobrir os graus de separação entre dois itens, você deve ter um ponto de
partida. Para o propósito deste exemplo, você pode usar o nó 'A'. O código a seguir
mostra a chamada e a saída da função do pacote networkx necessária:

nx.shortest_path_length(gráfico, 'A')

{'A': 0, 'B': 1, 'C': 2, 'D': 3, 'E': 2, 'F': 1, 'G': 2,


'H': 1}

A distância entre o nó A e o nó A é 0, é claro. O maior grau de separação vem do nó


A para o nó D, que é 3. Você pode usar esse tipo de

CAPÍTULO 10 Descobrindo Segredos do Gráfico 203


Machine Translated by Google

informações para determinar qual rota tomar ou para realizar uma análise do custo em gás versus
o custo no tempo de vários caminhos. A questão é que saber a menor distância entre dois pontos
pode ser bastante importante. A rede x
O pacote usado para este exemplo vem com uma ampla variedade de algoritmos de medição de
distância, conforme descrito em https://networkx.org/documentation/stable/
reference/algorithms/shortest_paths.html.

Para ver como o uso de um gráfico direcionado pode fazer uma grande diferença ao realizar
cálculos de graus de separação, tente remover a conexão entre os nós A e F. Altere os dados
para que fiquem assim:

dados = {'A': ['B', 'H'],


'B': ['A', 'C'],
'C': ['B', 'D'],
'D': ['C', 'E'],
'E': ['D', 'F', 'G'],
'F': ['E', 'A'],
'G': ['E', 'H'],
'H': ['G', 'A']}

Quando você executa a chamada para nx.shortest_path_length desta vez, a saída se torna bem
diferente porque você não pode mais ir de A para F diretamente. Aqui está a nova saída da
chamada:

{'A': 0, 'B': 1, 'C': 2, 'D': 3, 'E': 3, 'F': 4, 'G': 2,


'H': 1}

Observe que a perda do caminho mudou alguns dos graus de separação.


A distância até o nó F é agora a maior em 4.

Andando em um gráfico aleatoriamente


Você pode encontrar a necessidade de percorrer um gráfico aleatoriamente. O ato de percorrer o
gráfico aleatoriamente, em vez de procurar um caminho específico, pode simular atividades
naturais, como um animal em busca de alimento. Também se aplica a todos os tipos de outras
atividades interessantes, como jogar. No entanto, a caminhada aleatória em grafos pode ter
aspectos práticos. Por exemplo, um carro está parado no trânsito por causa de um acidente,
então o caminho mais curto não está mais disponível. Em alguns casos, a escolha de uma
alternativa aleatória pode funcionar melhor porque o tráfego ao longo da segunda rota mais curta
pode ser intenso como resultado do engarrafamento ao longo da rota mais curta.

204 PARTE 3 Explorando o mundo dos gráficos


Machine Translated by Google

O pacote networkx não fornece os meios para obter um caminho aleatório diretamente.
No entanto, ele fornece os meios para localizar todos os caminhos disponíveis, após o
que você pode selecionar um caminho da lista aleatoriamente. O código a seguir mostra
como esse processo pode funcionar usando o gráfico da seção anterior:

importar aleatório
random.seed(0)

caminhos = nx.all_simple_paths(graph, 'A', 'H')

path_list = []
para caminho em caminhos:
path_list.append(caminho)
print("Caminho do Candidato: ", caminho)
sel_path = random.randint(0, len(path_list) - 1)

print("O caminho selecionado é: ", path_list[sel_path])

Candidato a caminho: ['A', 'B', 'C', 'D', 'E', 'G', 'H']


Candidato a caminho: ['A', 'H']
Candidato a caminho: ['A', 'F', 'E', 'G', 'H']
O caminho selecionado é: ['A', 'H']

O código define a semente para um valor específico para garantir que você obtenha
sempre o mesmo resultado. No entanto, alterando o valor da semente, você pode ver
resultados diferentes do código de exemplo. A questão é que mesmo o gráfico simples
mostrado na Figura 10-3 oferece três maneiras de ir do nó A ao nó H (duas das quais
são definitivamente mais longas que o caminho selecionado neste caso). Escolher
apenas um deles garante que você vá de um nó para o outro, embora de uma maneira
potencialmente indireta.

CAPÍTULO 10 Descobrindo Segredos do Gráfico 205


Machine Translated by Google
Machine Translated by Google

NESTE CAPÍTULO

» Entender por que é difícil encontrar o que


você deseja na web

» Revendo problemas que o PageRank resolve

» Implementando o algoritmo
PageRank com teletransporte

» Aprendendo como o uso do PageRank


está evoluindo

Capítulo 11
Obtendo o direito
página da Internet

exemplos interessantes de uma estrutura gráfica devido à extensão e


Os últimos capítulosderevisam
complexidade os gráficos
suas páginas detalhadamente.
interconectadas. A webexplica
Este capítulo é umapor
das mais
que a
capacidade de pesquisar informações era importante e desafiadora, e como os primeiros
mecanismos de busca quase falharam em sua tarefa até o Google e seu algoritmo de
busca, o PageRank, aparecerem.

Depois de fornecer uma compreensão dos algoritmos básicos que permitem a travessia
de grafos e extração de estruturas úteis (como a presença de clusters ou comunidades),
este capítulo completa a discussão de grafos apresentando o algoritmo PageRank que
revolucionou a vida das pessoas tanto quanto o web e Internet fizeram porque torna a
web utilizável. O algoritmo PageRank transforma links em páginas em recomendações,
semelhante à entrada de estudiosos especialistas. Usando exemplos e código, o capítulo
mostra como a escala crescente da web também desempenha um papel no sucesso do
algoritmo. Quanto maior a web, maior a probabilidade de você obter bons sinais para
um algoritmo inteligente como o PageRank. O PageRank não é apenas o mecanismo
por trás do Google e muitos outros mecanismos de pesquisa, mas é uma maneira
inteligente de obter informações latentes, como relevância, importância e reputação, de
uma estrutura gráfica (por exemplo, na detecção de fraudes ou na conservação natural).

CAPÍTULO 11 Obtendo a página da Web certa 207


Machine Translated by Google

Você não precisa digitar o código-fonte para este capítulo manualmente. Na verdade, usar a
fonte para download é muito mais fácil. Você pode encontrar a fonte deste capítulo no A4D2E;
11; Arquivo PageRank.ipynb da fonte para download. Consulte a Introdução para obter detalhes
sobre como localizar esse arquivo de origem.

Encontrando o mundo em um mecanismo de busca


As bibliotecas contam com catálogos e bibliotecários para oferecer uma maneira fácil de
encontrar textos específicos ou explorar determinados assuntos. Livros não são todos iguais:
alguns são bons em apresentar certos tipos de informação; outros são melhores. As
recomendações acadêmicas fazem de um livro uma fonte confiável porque essas
recomendações geralmente aparecem em outros livros como citações e citações. Esse tipo de
referência cruzada não existia na web inicialmente. A presença de determinadas palavras no
título ou no texto do corpo recomendava uma determinada página web. Essa abordagem era
praticamente como julgar um livro pelo título e pelo número de palavras que contém.

Para muitas pessoas, suas vidas pessoais e profissionais são inimagináveis sem a Internet e
a web hoje. A rede da Internet é composta de páginas interconectadas (entre outras coisas). A
web é composta por sites acessíveis por domínios, cada um composto por páginas e hiperlinks
que conectam sites internamente e com outros sites externamente. Os recursos de serviço e
conhecimento estão disponíveis na web se você souber exatamente onde procurar. Acessar a
web é impensável sem mecanismos de busca – aqueles sites que permitem que você encontre
qualquer coisa na web usando uma simples consulta.

Pesquisando dados na Internet


Com um tamanho estimado flutuando entre 35-40 bilhões de páginas (https://www.
worldwebsize.com/), a web não é fácil de representar em forma de gráfico. Estudos
descrevem a web como um gráfico em forma de gravata borboleta (veja https://immorlica.com/
socNet/broder.pdf). A web consiste principalmente em um núcleo interconectado e outras
partes que se conectam a esse núcleo. Desde sua primeira expansão no início da década
de 1990, muitos notaram um problema em estimar a forma e o tamanho da teia. Além
disso, percorrê-lo para encontrar informações não era fácil; muitas partes estavam
desconectadas ou difíceis de alcançar.

Os mecanismos de busca foram desenvolvidos para facilitar o manuseio do tamanho e formato


estranhos da web: para tornar a web acessível e útil para todos. Ao pegar qualquer estrada do
mundo real, você pode ir a qualquer lugar (você pode ter que cruzar os oceanos para fazê-lo).
Na web, porém, você não pode tocar em todos os sites apenas seguindo sua estrutura;
algumas partes não são facilmente acessíveis (elas estão desconectadas ou você não está
em posição de alcançá-las). Se você quiser encontrar algo na web, mesmo quando o tempo
não for um problema, você ainda precisa de um índice.

208 PARTE 3 Explorando o mundo dos gráficos


Machine Translated by Google

Considerando como encontrar os dados certos


Encontrar os dados certos tem sido um problema desde os primeiros anos da web, mas os primeiros
mecanismos de busca não apareceram até a década de 1990. Os mecanismos de pesquisa não
foram pensados antes porque outras soluções, como listagens de domínio simples ou catálogos de
sites especializados, funcionavam bem. Somente quando essas soluções pararam de escalar bem
por causa do tamanho crescente da web, os mecanismos de busca como Lycos, Magellan, Yahoo!,
Excite, Inktomi e AltaVista apareceram.

Todos estes motores de busca funcionavam com software especializado que visitava a web de forma
autónoma, utilizando listas de domínios e testando hiperligações encontradas nas páginas visitadas.
Essas aranhas exploraram cada novo link em um processo chamado rastreamento. Spiders são
softwares que lêem as páginas como texto simples (eles não conseguem entender imagens ou outro
conteúdo não textual).

Os primeiros mecanismos de busca funcionavam rastreando a web, coletando as informações de


aranhas e processando-as para criar índices invertidos. Os índices permitiam o retraçamento das
páginas com base nas palavras que as páginas continham. Quando você fazia uma consulta, esses
índices invertidos informavam todas as páginas que continham os termos e ajudavam a pontuar as
páginas, criando assim um ranking que se transformava em resultado de pesquisa. página menos
útil.

A pontuação era bastante ingênua porque geralmente contava com que frequência as palavras-
chave apareciam nas páginas ou se apareciam nos títulos ou no cabeçalho da própria página. Às
vezes, as palavras-chave eram ainda mais pontuadas se misturadas ou agrupadas. Essas técnicas
simples de indexação e pontuação permitiram que alguns usuários da web tirassem vantagem
usando vários truques:

» Spammers da Web: usaram sua capacidade de preencher os resultados da pesquisa com páginas
contendo conteúdo pobre e muita publicidade.

» Otimização antiética de mecanismos de pesquisa: Usada por pessoas que empregam seus
conhecimentos sobre mecanismos de pesquisa para aumentar a classificação do mecanismo de
pesquisa para as páginas que manipularam, apesar de sua baixa qualidade. Infelizmente, esses problemas
ainda persistem porque os mecanismos de pesquisa, mesmo os mais evoluídos, não estão imunes a
pessoas que querem burlar o sistema para obter uma classificação mais alta nos mecanismos de pesquisa.
O algoritmo PageRank pode eliminar muitos dos spammers mais antigos e pessoas antiéticas de SEO,
mas não é uma panacéia.

É essencial distinguir SEO antiético de SEO ético. As pessoas que usam SEO ético são profissionais
que empregam seus conhecimentos de mecanismos de busca para melhor promover páginas válidas e
úteis de forma legal e ética.

CAPÍTULO 11 Obtendo a página da Web certa 209


Machine Translated by Google

O surgimento de tais atores e a possibilidade de manipulação dos resultados dos buscadores


criaram a necessidade de melhores algoritmos de ranqueamento nos buscadores. Um desses
resultados é o algoritmo PageRank.

Explicando o algoritmo PageRank


O algoritmo PageRank recebeu o nome do cofundador do Google, Larry Page. Ele fez sua primeira
aparição pública em um artigo de 1998 intitulado “The Anatomy of a LargeScale Hypertextual Web
Search Engine”, de Sergey Brin e Larry Page, publicado pela revista Computer Networks and ISDN
Systems (http://ilpubs.stanford.
edu:8090/361/1/1998-8.pdf). Naquela época, Brin e Page eram candidatos a
doutorado, e o algoritmo, a própria base da tecnologia de busca do Google, era
inicialmente um projeto de pesquisa na Universidade de Stanford.

Simplificando, o PageRank pontua a importância de cada nó em um gráfico de tal forma que quanto
maior a pontuação, mais importante é o nó em um gráfico. Determinar a importância do nó em um
gráfico como a web significa calcular se uma página é relevante como parte dos resultados de uma
consulta, atendendo melhor os usuários que procuram um bom conteúdo da web.

Uma página é uma boa resposta a uma consulta quando corresponde aos critérios da consulta e
tem destaque no sistema de hiperlinks que une as páginas. A lógica por trás da proeminência é
que, como os usuários constroem a web, uma página tem importância na rede por um bom motivo
(a qualidade e a autoridade do conteúdo da página são avaliadas por sua importância na rede de
hiperlinks da web).

Entendendo o raciocínio por trás


do algoritmo PageRank
Em 1998, quando Brin e Page ainda eram estudantes em Stanford, a qualidade dos resultados de
pesquisa era um problema para qualquer um que usasse a web. Os principais mecanismos de
pesquisa da época lutavam tanto com uma estrutura da Web cada vez maior (a Parte 4 discute os
problemas de dimensionamento de algoritmos e como fazê-los funcionar com big data) quanto com
uma infinidade de spammers.

O termo spammers neste caso não se refere a spammers de e-mail (aqueles que enviam e-mails
não solicitados para sua caixa de entrada), mas sim a spammers da web (aqueles que sabem da
importância econômica de ter páginas no topo dos resultados de pesquisa). Esse grupo criou
truques sofisticados e maliciosos para enganar os resultados da pesquisa. Hacks populares por
spammers da web do dia incluem:

210 PARTE 3 Explorando o mundo dos gráficos


Machine Translated by Google

» Preenchimento de palavras- chave: Implica o uso excessivo de palavras-chave específicas em uma página
para induzir o mecanismo de pesquisa a pensar que a página discute seriamente o tópico da palavra-
chave.

» Texto invisível: Requer a cópia do conteúdo de um resultado de página sobre uma consulta em uma página
diferente usando a mesma cor para os caracteres e o plano de fundo.
O conteúdo copiado é invisível para os usuários, mas não para os spiders do mecanismo de busca (que
eram, e ainda são, apenas digitalizando dados textuais) e seus algoritmos.
O truque classifica a página com texto invisível tão alto quanto a página de origem em uma pesquisa.

» Cloaking: Define uma variante mais sofisticada de texto invisível para que,
em vez de texto, scripts ou imagens fornecem conteúdo aos spiders do mecanismo de pesquisa que
difere do que os usuários realmente veem.

Os spammers da Web usavam esses truques para enganar os mecanismos de pesquisa para
classificar as páginas com alta classificação, mesmo que o conteúdo da página fosse ruim e, na
melhor das hipóteses, enganoso. Esses truques têm consequências. Por exemplo, um usuário pode
procurar informações relacionadas à pesquisa universitária e, em vez disso, ser exposto a publicidade
comercial ou conteúdo impróprio. Os usuários ficavam desapontados porque muitas vezes acabavam
em páginas não relacionadas às suas necessidades, exigindo que eles reafirmassem suas consultas
e gastassem tempo procurando informações úteis entre as páginas de resultados, desperdiçando
energia em distinguir boas referências das ruins. Estudiosos e especialistas, notando a necessidade
de lidar com os resultados do spam e temendo que o desenvolvimento da web pudesse parar porque
os usuários tinham dificuldades para encontrar o que realmente queriam, começaram a trabalhar em
possíveis soluções.

Enquanto Brin e Page trabalhavam em seu algoritmo de resolução, outras ideias eram elaboradas
e divulgadas, ou desenvolvidas em paralelo. Uma dessas ideias foi o Hyper Search, de Massimo
Marchiori, que primeiro apontou a importância dos links da web na determinação da proeminência
de uma página da web como um fator a ser considerado durante uma pesquisa: https://www.
w3.org/People/Massimo/papers/WWW6/. Outra solução interessante foi um projeto de mecanismo
de busca na web chamado HITS (Hypertext-Induced Topic Search), também baseado na estrutura
de links da web e desenvolvido por Jon Kleinberg, um jovem cientista que trabalha na IBM Almaden
no Vale do Silício. O fato interessante sobre o HITS é que ele classifica as páginas em hubs (uma
página com muitos links para páginas autoritativas) e autoridades (páginas consideradas autoritativas
por muitos links de hubs), algo que o PageRank não faz explicitamente (mas implicitamente faz em
cálculos) (http://
pi.math.cornell.edu/~mec/Winter2009/RalucaRemus/Lecture4/lecture4.
html).

Quando chega a hora, a mesma ideia ou algo semelhante muitas vezes brota em lugares
diferentes. Às vezes, o compartilhamento de ideias básicas ocorre entre pesquisadores e
cientistas; às vezes as ideias são desenvolvidas de forma completamente independente (veja a
história do matemático japonês Takakazu Seki https://mathshistory.
st-andrews.ac.uk/Biographies/Seki/, que descobriu independentemente muitos dos

CAPÍTULO 11 Obtendo a página da Web correta 211


Machine Translated by Google

as mesmas coisas que matemáticos europeus como Newton, Leibniz e Ber noulli fizeram por volta
do mesmo período). Em 1998, apenas Brin e Page tomaram medidas para criar uma empresa de
mecanismos de busca com base em seu algoritmo, deixando a Universidade de Stanford e seus
estudos de doutorado para se concentrar em fazer seu algoritmo funcionar com mais de um bilhão
de páginas da web.

Explicando as porcas e parafusos do PageRank


A inovação trazida pelo PageRank é que um índice invertido de termos não é suficiente para
determinar se uma página corresponde à consulta de informações de um usuário. A correspondência
de palavras (ou significado, a correspondência de consulta semântica discutida no final do capítulo)
entre uma consulta e o texto da página é um pré-requisito, mas não é suficiente porque os hiperlinks
são necessários para avaliar se a página oferece conteúdo de qualidade e é autoritário.

Ao discutir sites, é importante distinguir entre links de entrada e de saída, e você não deve considerar
links internos que se conectam no mesmo site. Os links que você vê em uma página são de saída
quando levam a outra página em outro site. Os links que trazem alguém para sua página de outra
página em outro site são links de entrada (backlinks). Como criador da página, você usa links
externos para fornecer informações adicionais ao conteúdo da página. Você provavelmente não
usará links aleatórios em sua página (ou links apontando para conteúdo inútil ou ruim) porque isso
prejudicaria a qualidade da página. À medida que você aponta para um bom conteúdo usando links,
outros criadores de páginas usam links em suas páginas para apontar para sua página quando sua
página é interessante e de alta qualidade.

É uma cadeia de confiança. Os hiperlinks são como endossos ou recomendações para páginas.
Links de entrada mostram que outros criadores de páginas confiam em você e você compartilha
parte dessa confiança adicionando links de saída em suas páginas para apontar para outras páginas.

Implementando o PageRank
Representar essa cadeia de confiança matematicamente requer simultaneamente determinar
quanta autoridade sua página tem, medida por links de entrada, e quanto ela doa para outras
páginas por links de saída. Você pode realizar tais cálculos de duas maneiras:

» Simulação: Utiliza o comportamento de um internauta que navega aleatoriamente em


a web (um surfista aleatório). Essa abordagem requer que você recrie a estrutura da
web e execute a simulação.

212 PARTE 3 Explorando o mundo dos gráficos


Machine Translated by Google

» Computação de matriz: Replica o comportamento de um surfista aleatório usando


uma matriz esparsa (uma matriz na qual a maioria dos dados é zero) replicando a
estrutura da web. Essa abordagem requer algumas operações matriciais, conforme
explicado no Capítulo 5, e uma série de cálculos que chegam a um resultado por
aproximações sucessivas.

Embora seja mais abstrato, usar a computação matricial para PageRank requer
menos instruções de programação e você pode implementá-lo facilmente usando
Python. (Você pode experimentar o algoritmo PageRank em sites do mundo real
usando um verificador automático de PageRank, como https://checkpagerank.net/index.php.
Infelizmente, o programa pode produzir resultados imprecisos para sites mais novos
porque eles ainda não foram rastreados corretamente, ele pode dar uma ideia de como
é o PageRank na prática.)

Implementando um script Python


PageRank é uma função que pontua os nós em um gráfico com um número (quanto
maior o número, mais importante o nó). Ao pontuar uma página da web, o número pode
representar a probabilidade de uma visita aleatória de um surfista. Você expressa
probabilidades usando um número de 0,0 a no máximo 1,0 e, idealmente, ao representar
a probabilidade de estar em um determinado site entre todos os sites disponíveis, a
soma de todas as probabilidades das páginas na web deve ser igual a 1,0.

Existem muitas versões do PageRank, cada uma alterando um pouco sua receita para se adequar ao
tipo de gráfico que deve pontuar. O exemplo nesta seção apresenta a versão original para a web
apresentada no artigo mencionado anteriormente por Brin e Page e no artigo “The PageRank Citation
Ranking: Bringing Order to the Web” (http://ilpubs.stanford.edu :8090/422/1/1999-66.pdf).

O exemplo cria três redes web diferentes feitas de seis nós (páginas web).
A primeira é uma boa rede de trabalho, e as outras duas demonstram problemas que um
surfista aleatório pode encontrar por causa da estrutura da web ou das ações de um web
spam mer. Este exemplo também usa os comandos NetworkX discutidos no Capítulo 8
e o pacote NumPy para cálculos de matrizes, conforme descrito no Capítulo 4.

importar numpy como np


importar networkx como nx
importar matplotlib.pyplot como plt

Graph_A = nx.DiGraph()
Graph_B = nx.DiGraph()
Graph_C = nx.DiGraph()

CAPÍTULO 11 Obtendo a página da Web correta 213


Machine Translated by Google

Nós = intervalo(1,6)
Bordas_OK = [(1,2),(1,3),(2,3),(3,1),(3,2),(3,4),(4,5), (4,6) ,(5,4),(5,6),
(6,5),(6,1)]
Edges_dead_end = [(1,2),(1,3),(3,1),(3,2),(3,4),(4,5), (4,6),(5,4) ,(5,6),
(6,5),(6,1)]
Edges_trap = [(1,2),(1,3),(2,3),(3,1),(3,2),(3,4),(4,5), (4,6) ,(5,4),(5,6),(6,5)]

Graph_A.add_nodes_from(Nodes)
Graph_A.add_edges_from(Edges_OK)
Graph_B.add_nodes_from(Nós)
Graph_B.add_edges_from(Edges_dead_end)
Graph_C.add_nodes_from(Nodes)
Graph_C.add_edges_from(Edges_trap)

Este código exibe a primeira rede, a boa, conforme mostrado na Figura 11-1.

np.random.seed(2)
pos=nx.shell_layout(Graph_A)
draw_params = {'with_labels':True,
'arrows': True,
'node_color':'skyblue',
'node_size':700, 'width':2,
'font_size':14} nx.draw(Graph_A,
pos, **draw_params) plt.show()

FIGURA 11-1:
Uma rede
fortemente
conectada.

Todos os nós se conectam entre si. Este é um exemplo de um grafo fortemente


conectado, que não contém nós isolados ou nós únicos e enclaves que atuam como
becos sem saída. Um surfista aleatório pode correr livremente por ele e nunca parar, e
qualquer nó pode alcançar qualquer outro nó. A representação NetworkX de um gráfico direcionado usa

214 PARTE 3 Explorando o mundo dos gráficos


Machine Translated by Google

setas para representar a direção de uma aresta. Por exemplo, um surfista pode ir do nó 4 ao
nó 6 porque há uma linha grossa entrando no nó 6 do nó 4. No entanto, o surfista não pode ir
do nó 6 ao nó 4 porque a linha que entra no nó 4 do nó 6 está magra.

O segundo gráfico não está fortemente conectado. Ele apresenta uma armadilha para um
surfista aleatório porque o nó 2 não tem links de saída, e um usuário que visita a página pode
parar lá e não encontrar saída. Este não é um evento incomum considerando a estrutura da
web, mas também pode mostrar um artefato do spammer, de tal forma que o spammer criou
uma fábrica de spam com muitos links que direcionam para um site sem saída para prender os
internautas. A Figura 11-2 mostra a saída do código a seguir, que foi usado para exibir este
gráfico.

np.random.seed(2)
pos=nx.shell_layout(Gráfico_B)
nx.draw(Graph_B, pos, **draw_params)
plt.show()

FIGURA 11-2:
Uma rede com um
beco sem
saída no nó 2.

Outra situação que pode ser natural ou resultado da ação de um spammer é uma armadilha de
aranha. É outro beco sem saída para um surfista, desta vez não em uma única página, mas
em um site fechado que não possui links para uma rede externa de páginas. A Figura 11-3
mostra a saída do código a seguir, que foi usado para exibir este gráfico.

np.random.seed(2)
pos=nx.shell_layout(Graph_C)
nx.draw(Graph_C, pos, **draw_params)
plt.show()

CAPÍTULO 11 Obtendo a página da Web certa 215


Machine Translated by Google

FIGURA 11-3:
Uma rede com uma
armadilha de
aranha nos nós 4, 5 e 6.

É chamado de armadilha de aranha porque os spammers o criaram como uma maneira de


capturar aranhas de software de mecanismos de busca em um loop e deixá-los acreditar que os
únicos sites eram aqueles dentro da rede fechada.

Lutando com uma implementação ingênua


Dado um gráfico feito usando Python e NetworkX, você pode extrair sua estrutura e renderizá-lo
como uma matriz de transição, uma matriz que representa nós em colunas e
linhas:

» Colunas: contém o nó em que um internauta está

» Linhas: Contêm a probabilidade de o surfista visitar outros nós por causa de


links de saída

Na web real, a matriz de transição que alimenta o algoritmo PageRank é construída pela
exploração contínua de links por aranhas.

def inicialize_PageRank(gráfico):
nós = len(gráfico)
M = nx.to_numpy_matrix(gráfico)
saída = np.squeeze(np.asarray(np.sum(M, eixo=1)))
prob_outbound = np.array(
[1,0/contagem
if count>0 else 0.0 for count in outbound])
G = np.asarray(np.multiply(MT, prob_outbound))
p = np.ones(nós) / float(nós)
se np.min(np.sum(G,eixo=0)) < 1,0:
print('Aviso: G é subestocástico')
retornar G, p

216 PARTE 3 Explorando o mundo dos gráficos


Machine Translated by Google

O código Python cria a função initialize_PageRank(), que extrai a matriz de transição e


o vetor inicial das pontuações padrão do PageRank.

G, p = inicializar_PageRank(Gráfico_A)
imprimir(G)

[[ 0. 0. 0,33333333 0. 0. 0,5]
[ 0,5 0. 0,33333333 0. 0. 0.]
[ 0,5 1. 0. 0. 0. 0.]
[ 0. [ 0. 0. 0,33333333 0. 0,5 0.]
[ 0. 0. 0. 0,5 0. 0,5]
0. 0. 0,5 0,5 0. ]]

A matriz de transição impressa G representa a matriz de transição da rede descrita na


Figura 11-1. Cada coluna representa um nó na sequência de 1 a 6. Por exemplo, a
terceira coluna representa o nó 3. Cada linha na coluna mostra as conexões com outros
nós (links de saída para os nós 1, 2 e 4) e valores que definem o probabilidade de um
surfista aleatório usar qualquer um dos links de saída (ou seja, 1/3, 1/3, 1/3).

A diagonal da matriz é sempre zero, a menos que uma página tenha um link de saída
para si mesma (é uma possibilidade).

A matriz contém mais zeros do que valores. Isso também é verdade na realidade porque
as estimativas mostram que cada site tem apenas dez links de saída em média. Como
existem bilhões de sites, os valores diferentes de zero em uma matriz de transição que
representa a web são mínimos. Nesse caso, é útil usar uma estrutura de dados como
uma lista de adjacências (explicada na seção “Criando o gráfico” do Capítulo 9 e
mencionada novamente na seção “Distribuindo arquivos e operações” do Capítulo 13)
para representar dados sem desperdiçar espaço em disco ou memória com valores zero:

de scipy importação esparsa


sG = esparso.csr_matrix(G)
imprimir (sG)

(0, 2) 0,333333333333
(0, 5) 0,5
(1, 0) 0,5
(1, 2) 0,333333333333
(2, 0) 0,5
(2, 1) 1,0
(3, 2) 0,333333333333
(3, 4) 0,5
(4, 3) 0,5

CAPÍTULO 11 Obtendo a página da Web correta 217


Machine Translated by Google

(4, 5) 0,5
(5, 3) 0,5
(5, 4) 0,5

Este exemplo tem apenas 12 links de 30 possíveis (sem contar links para self,
que é o site atual). Outro aspecto particular da matriz de transição a ser observado é que,
se você somar as colunas, o resultado deve ser 1,0. Se for um valor menor que 1,0, a
matriz é subestocástica (o que significa que os dados da matriz não estão representando
probabilidades corretamente porque as probabilidades devem somar 1,0) e não pode
funcionar perfeitamente com estimativas de PageRank.

Acompanhando G está um vetor p, a estimativa inicial da pontuação total do PageRank,


distribuída igualmente entre os nós. Neste exemplo, como o PageRank total é 1,0 (a
probabilidade de um surfista aleatório estar na rede, que é 100%), ele é distribuído como
1/6 entre os seis nós:

imprimir(p)

[ 0,16666667 0,16666667 0,16666667 0,16666667


0,16666667 0,16666667]

Para estimar o PageRank, pegue a estimativa inicial para um nó no vetor p, multiplique-a


pela coluna correspondente na matriz de transição e determine quanto de seu PageRank
(sua autoridade) é transferido para outros nós. Repita para todos os nós e você saberá
como o PageRank é transferido entre os nós devido à estrutura da rede. Você pode obter
esse cálculo usando uma multiplicação de vetores de matriz:

print(np.dot(G, p))

[ 0,13888889 0,13888889 0,25 0,13888889


0,16666667 0,16666667]

Após a primeira multiplicação de matriz-vetor, você obtém outra estimativa de Pag


eRank que você usa para redistribuição entre os nós. Ao redistribuir várias vezes, a
estimativa do PageRank se estabiliza (os resultados não serão alterados) e você terá a
pontuação necessária. Usando uma matriz de transição contendo probabilidades e
estimativa por aproximação sucessiva usando multiplicação matriz-vetor obtém os
mesmos resultados que uma simulação de computador com um surfista aleatório:

def PageRank_naive(graph, iters = 50):


G, p = inicializar_PageRank(gráfico)
para i no intervalo (iters):
p = np.ponto(G,p)

218 PARTE 3 Explorando o mundo dos gráficos


Machine Translated by Google

return np.round(p,3)

print(PageRank_naive(Graph_A))

[0,154 0,154 0,231 0,154 0,154 0,154]

A nova função PageRank_naive() envolve todas as operações descritas anteriormente


e emite um vetor de probabilidades (a pontuação do PageRank) para cada nó na rede.
O nó 3 surge como o de maior importância. Infelizmente, a mesma função não
funciona com as outras duas redes:

print(PageRank_naive(Graph_B))
Aviso: G é subestocástico
[ 0. 0. 0. 0. 0. 0.]

print(PageRank_naive(Graph_C))
0. 0,333]0.
[0, 0,222 0,444

No primeiro caso, as probabilidades parecem drenar para fora da rede – o efeito de


um site sem saída e a matriz de transição subestocástica resultante. No segundo
caso, a metade inferior da rede recebe injustamente toda a importância, deixando a
parte superior como insignificante.

Apresentando tédio e teletransporte


Ambos os becos sem saída (rank sinks) e spider traps (ciclos) são situações comuns
na web devido às escolhas dos usuários e ações dos spammers. O problema, no
entanto, é facilmente resolvido fazendo com que o surfista aleatório salte aleatoriamente
para outro nó de rede (teletransporte, como nos dispositivos de ficção científica que o
levam instantaneamente de um lugar para outro). A teoria é que um surfista ficará
entediado em um ponto ou outro e se afastará de situações de impasse.
Matematicamente, você define um valor alfa que representa a probabilidade de
continuar a jornada aleatória no gráfico pelo surfista. O valor alfa redistribui a
probabilidade de estar em um nó independentemente da matriz de transição.

O valor originalmente sugerido por Brin e Page para alfa (também chamado de fator de
amortecimento) é 0,85 (ou apenas 0,15 de chance de se teletransportar), mas você pode alterá-
lo de acordo com suas necessidades. Para a web, funciona melhor entre 0,8 e 0,9 (leia sobre o
porquê em https://www.cise.ufl.edu/~adobra/DaMn/talks/damn05-santini.pdf).
Quanto menor o valor alfa, menor a viagem do surfista na rede, em média, antes de
reiniciar em outro lugar.

def PageRank_teleporting(graph, iters=50, alpha=0,85,


arredondamento = 3):

CAPÍTULO 11 Obtendo a página da Web correta 219


Machine Translated by Google

G, p = inicializar_PageRank(gráfico)
u = np.ones(len(p)) / float(len(p))
para i no intervalo (iters):
p = alfa * np.dot(G,p) + (1,0 - alfa) * u
return np.round(p / np.sum(p), arredondamento)

print('Gráfico A:', PageRank_teleporting(Gráfico_A,


arredondamento = 8))

print('Gráfico B:', PageRank_teleporting(Gráfico_B,


arredondamento = 8))

print('Gráfico C:', PageRank_teleporting(Gráfico_C,


arredondamento = 8))

Gráfico A: [ 0,15477863 0,15346061 0,22122243 0,15477863


0,15787985 0,15787985]
Aviso: G é subestocástico
Gráfico B: [ 0,16502904 0,14922238 0,11627717 0,16502904
0,20222118 0,20222118]
Gráfico C: [ 0,0598128 0,08523323 0,12286869 0,18996342
0,30623677 0,23588508]

Depois de aplicar as modificações em uma nova função, PageRank_teleporting(), você pode


obter estimativas semelhantes para o primeiro gráfico, bem como estimativas muito mais
realistas (e úteis) para o segundo e terceiro gráficos, sem cair nas armadilhas de becos sem
saída ou classificação afunda. Curiosamente, a função é equivalente à fornecida pelo NetworkX:
https://networkx.org/documentation/stable/
reference/algorithms/generated/networkx.algorithms.link_analysis.
pagerank_alg.pagerank.html.

nx.pagerank(Gráfico_A, alfa=0,85)

{1: 0,15477892494151968,
2: 0,1534602056628941,
3: 0,2212224378270561,
4: 0,15477892494151968,
5: 0,1578797533135051,
6: 0,15787975331350507}

Olhando para dentro da vida de um mecanismo de pesquisa


Embora relate apenas na estrutura de hiperlinks da web, o PageRank revela o quão autoritária
uma página pode se tornar. No entanto, o algoritmo de classificação do Google não é composto
apenas pelo PageRank. O algoritmo fornece bases sólidas para qualquer consulta e, inicialmente,
impulsionou a fama do Google como um mecanismo de pesquisa confiável. Hoje, o PageRank é
apenas um dos muitos fatores de classificação que intervêm no processamento de uma consulta.

220 PARTE 3 Explorando o mundo dos gráficos


Machine Translated by Google

Fontes especializadas em conhecimento de SEO citam mais de 200 fatores como contribuintes
para os resultados que o Google fornece. Para ver que outros tipos de fatores de classificação
o Google considera, consulte as listas em https://moz.com/learn/seo/on-page factors e https://
moz.com/learn/seo/off-site-seo (feito pela MOZ, uma empresa americana).

Você também deve considerar que o algoritmo do Google recebeu muitas atualizações e, neste
momento, é mais um conjunto de algoritmos diferentes, cada um nomeado com um nome de
fantasia (Caffeine, Panda, Penguin, Hummingbird, Pigeon, Mobile Update). Você pode encontrar
uma lista completa com curadoria por ano em https://www.search
enginejournal.com/google-algorithm-history/ graças ao SEJ, o Search Engine Journal. Muitas
dessas atualizações causaram mudanças nos rankings de busca anteriores e foram motivadas
pela necessidade de corrigir técnicas de spam ou tornar a navegação na web mais útil para os
usuários; por exemplo, o Mobile Update induziu muitos sites a tornar suas interfaces amigáveis
para celulares.

Considerando outros usos do PageRank


Embora o PageRank forneça melhores resultados de pesquisa, sua aplicabilidade não se limita
ao Google ou aos mecanismos de pesquisa. Você pode usar o PageRank em qualquer lugar
em que possa reduzir seu problema a um gráfico. Basta modificar e ajustar o algoritmo às suas
necessidades. A Universidade de Cornell enumerou alguns outros usos potenciais do
PageRank em diferentes setores (https://blogs.cornell.edu/info2040/2014/11/03/more-than-just
a-web-search-algorithm-googles-pagerank-in -non-internet-contexts/), e relatórios surpreendentes
surgiram do algoritmo sendo usado com sucesso em biologia computacional (https://
www.wired.com/2009/09/googlefoodwebs/).
Ao criar um teletransporte vinculado a nós específicos que você deseja explorar, você vê o
algoritmo brilhando em diversas aplicações, como as seguintes:

» Detecção de fraudes: revelando como certas pessoas e fatos estão relacionados em


maneiras inesperadas

» Recomendação de produto: sugerir produtos que uma pessoa com certa afinidade possa gostar

Indo além do paradigma PageRank


Nos últimos anos, o Google fez mais do que introduzir mais fatores de classificação que
modificam o algoritmo original do PageRank. Ele introduziu algumas mudanças radicais que
alavancam melhor o conteúdo da página (para evitar ser enganado pela presença de certas
palavras-chave) e adotou algoritmos de IA que classificam a relevância de uma página em um
resultado de pesquisa de forma autônoma. Essas mudanças levaram alguns especialistas em pesquisa a

CAPÍTULO 11 Obtendo a página da Web correta 221


Machine Translated by Google

declarar que o PageRank não determina mais a posição de uma página em uma pesquisa
(consulte https://www.entrepreneur.com/article/269574). Eles ainda debatem a questão, mas é
mais provável que seja seguro assumir que o PageRank ainda está alimentando o mecanismo
do Google como um fator de classificação, embora não o suficiente para listar uma página nos
melhores resultados após uma consulta.

Apresentando consultas semânticas


Se você atualmente tenta fazer perguntas, não apenas cadeias de palavras-chave, ao
Google, você notará que ele tende a responder com inteligência e entender a questão.
A partir de 2012, o Google tornou-se mais capaz de entender sinônimos e conceitos. No
entanto, após agosto de 2013, a atualização do Hummingbird (https://searchengineland.
com/google-hummingbird-172816) realmente mudou o jogo porque o mecanismo de busca
tornou-se capaz de entender pesquisas de conversação (consultas nas quais você pergunta
algo como diria a outra pessoa), bem como a semântica por trás das consultas e do conteúdo
de uma página.

Desde essa atualização, o algoritmo do Google tem funcionado desambiguando as intenções


dos usuários e os significados expressos pelas páginas, não apenas pelas palavras-chave. A
atualização fez com que o mecanismo de busca funcionasse mais de forma semântica, o que
significa entender o que as palavras implicam em ambos os lados: a consulta e as páginas da
web resultantes. Nesse sentido, não pode mais ser enganado brincando com palavras-chave.
Mesmo sem muito suporte do PageRank, o mecanismo de pesquisa pode ver como uma página
é escrita e ter uma noção se a página contém conteúdo bom o suficiente para inclusão nos
resultados de uma consulta.

Usando IA para classificar resultados de pesquisa


O PageRank ainda está no centro do ranking dos resultados de pesquisa, mas os
resultados têm menos peso devido à introdução da tecnologia de aprendizado de
máquina no ranking, o chamado RankBrain. De acordo com algumas fontes (ver https://
www.searchenginejournal.com/google-algorithm-history/rankbrain/), o algoritmo de aprendizado
de máquina agora atua como um pré-condicionador, um dispositivo de filtragem que avalia a
consulta recebida e calcula os pesos adequados para os sinais que detecta na consulta.

Embora o RankBrain ainda esteja envolto em sigilo, sua importância cresceu e o algoritmo
parece ser capaz de adivinhar, com precisão muito maior do que a realizada por um ser
humano, se o conteúdo de uma página pode aparecer nos resultados de pesquisa. Ele substitui
todos os outros fatores de classificação em casos difíceis de julgar.
Este é outro exemplo de um algoritmo adicional que limita o papel desempenhado pelo algoritmo
PageRank original.

222 PARTE 3 Explorando o mundo dos gráficos


Machine Translated by Google

4 Disputa
Big Data
Machine Translated by Google

NESTA PARTE . . .

Interagindo e gerenciando big data

Processando big data de maneiras mais eficientes

Tornando os dados menores

Escondendo dados usando criptografia


Machine Translated by Google

NESTE CAPÍTULO

» Familiarizando-se com a Lei de Moore


e suas implicações

» Entendendo o big data e seus 4 Vs

» Descobrindo como lidar com dados de


streaming infinitos

» Aproveitamento de amostragem,
hashing e esboços para dados de fluxo

Capítulo 12

Gerenciando Big Data


armazenar dados e analisá-los. Big data também não é uma moda passageira. Em vez disso, é uma realidade
Big datae éuma
mais do que
força umdo
motriz chavão
nossousado por
tempo. fornecedores
Você já deve terpara propor
ouvido falarformas
disso inovadoras
em muitas
publicações científicas e de negócios especializadas e até se perguntou o que o big data
realmente significa (não, não se trata de colocar informações em outdoors em letras grandes).
Do ponto de vista técnico, big data refere-se a quantidades grandes e complexas de dados
de computador, tão grandes (como o nome indica) e intrincadas que os dados com os quais
você não pode lidar disponibilizando mais armazenamento em seus computadores ou
disponibilizando novos computadores mais poderosos e mais rápidos em seus cálculos. A
primeira parte deste capítulo discute as implicações de big data e o que isso significa em
relação aos algoritmos.

Você precisa saber mais sobre dados do que de onde eles vêm ou suas implicações
para hardware, software e análise. A segunda parte do capítulo aborda a ideia de
transmissão de dados, que envolve colocá-los em um formato para que sejam
revisados de maneira ordenada. Isso significa aplicar ferramentas algorítmicas como
amostragem e hash, sobre as quais você pode ler na segunda seção deste capítulo.
Você também considera os efeitos da amostragem, que é o processo de reservar os
dados corretos para vários usos. (O hash é abordado em detalhes na seção "Contando
com o hash" do Capítulo 7.)

A terceira seção do capítulo aborda o esboço, que é um método de criação de um


resumo de dados simples e aproximado. Esse processo começa com o hash, mas
depois passa para a criação do resumo de dados usando vários algoritmos.

CAPÍTULO 12 Gerenciando Big Data 225


Machine Translated by Google

Você não precisa digitar o código-fonte para este capítulo manualmente. Na verdade, usar a
fonte para download é muito mais fácil. Você pode encontrar a fonte para este capítulo em
\A4D2E\A4D2E; 12; Gerenciando o arquivo .ipynb de Big Data da fonte para download.
Consulte a Introdução para obter detalhes sobre como localizar esses arquivos de origem.

Transformando poder em dados


Em 1965, Gordon Moore, cofundador da Intel e da Fairchild Semiconductor (duas empresas
gigantes que produzem componentes eletrônicos para eletrônicos e computadores), declarou em
um artigo da revista Electronics intitulado “Cramming More Components Onto Integrated Circuits”
que o número de componentes encontrados em circuitos integrados dobraria a cada ano na
próxima década (https://www.britannica.
com/tecnologia/lei de Moore). (Mais tarde, ele revisou sua previsão para a cada dois anos.)
Naquela época, os transistores dominavam a eletrônica. Ser capaz de colocar mais transistores
em um circuito usando um único componente eletrônico que reunia as funcionalidades de muitos
deles (um circuito integrado), significava ser capaz de tornar os dispositivos eletrônicos mais
capazes e úteis. Este processo é de integração e implica um forte processo de miniaturização da
eletrônica (tornando o mesmo circuito muito menor, o que faz sentido porque o mesmo volume
deve conter o dobro de circuitos do ano anterior).

À medida que a miniaturização avança, os dispositivos eletrônicos, produto final do processo,


tornam-se menores ou simplesmente mais potentes. Por exemplo, os computadores de hoje não
são maiores do que os computadores de uma década atrás, mas são decididamente mais
poderosos. O mesmo vale para telefones celulares. Mesmo tendo o mesmo tamanho de seus
predecessores, eles se tornaram capazes de realizar mais tarefas. Outros dispositivos, como
sensores, são simplesmente menores, o que significa que você pode colocá-los em todos os
lugares e até encontrar novos usos imprevistos.

Entendendo as implicações de Moore


O que Moore afirmou no artigo mencionado no início desta seção provou ser verdade por muitos
anos, e a indústria de semicondutores chama isso de Lei de Moore (ver http://www.mooreslaw.org/
para detalhes). A duplicação ocorreu nos primeiros dez anos, como previsto. Em 1975, Moore
corrigiu sua declaração, prevendo uma duplicação a cada dois anos. A Figura 12-1 mostra os
efeitos dessa duplicação. Essa taxa de duplicação ainda é válida (https://www.wired.com/beyond-
the-beyond/2020/03/
preparando-fim-moores-lei/), mas alguns opositores não estão felizes com isso, então eles dizem
que vai acabar. (Outros discordam.) A partir de 2012, ocorre um descompasso entre a expectativa
de colocar mais transistores em um componente para torná-lo mais rápido e o que as empresas
de semicondutores podem alcançar em relação a

226 PARTE 4 Lidando com Big Data


Machine Translated by Google

miniaturização. Na verdade, existem barreiras físicas para integrar mais circuitos em um


circuito integrado usando os atuais componentes de sílica. (No entanto, a inovação continuará;
você pode ler o artigo em https://www.nature.com/news/the chips-are-down-for-moore-s-
law-1.19338 para mais detalhes.) Além disso, a Lei de Moore não é realmente uma lei - é uma
observação, ou mesmo uma tentativa implícita de meta para a indústria se esforçar para
alcançar (uma profecia auto-realizável, em certo sentido).

FIGURA 12-1:
Colocando cada vez
mais transistores
em uma CPU.

Eventualmente, a Lei de Moore pode não se aplicar mais porque a indústria mudará
para novas tecnologias, como fazer componentes usando lasers ópticos em vez de
transistores (veja o artigo em https://sciencenordic.com/computers-denmark
forskerzonen/optical-computers-light -up-the-horizonte/1454763 para obter detalhes
sobre computação óptica) ou recorrer à computação quântica (consulte o artigo em
https://www.nasa.gov/feature/ames/quantum-supremacy). O que importa é que
desde 1965, aproximadamente a cada dois anos, a indústria de computadores
experimentou grandes avanços na eletrônica digital que tiveram consequências.

Algumas pessoas defendem que a Lei de Moore já não vale mais. A indústria de chips
manteve a promessa até agora, mas agora está diminuindo as expectativas. A Intel já
aumentou o tempo entre suas gerações de CPUs, dizendo que em cinco anos, a miniaturização
dos chips atingirá um muro. (Leia sobre as ondas no desenvolvimento de chips em https://
scitechdaily.com/twilight-for-silicon-end-of-moores law-in-view-as-silicon-chip-density-nears-
physical-limit/. )

CAPÍTULO 12 Gerenciando Big Data 227


Machine Translated by Google

A POLÍTICA E AS COISAS JURÍDICAS

Dependendo de com quem você fala, toda a questão de saber se uma lei resistirá ao teste do
tempo pode parecer diferente porque essa pessoa terá uma perspectiva diferente. Este livro não
está aqui para convencê-lo de um ponto de vista ou de outro; ele simplesmente relata a visão
predominante. Por exemplo, a Lei de Moore é indiscutivelmente tão comprovada quanto as leis
da termodinâmica. Se você examinar mais a física convencional, poderá encontrar muitas
discrepâncias com suas leis e muitas de suas suposições. Não se trata de desvalorizar a ciência
de forma alguma – apenas apontar o fato de que tudo na ciência, incluindo suas leis, é um
trabalho em andamento.

Sobre se a Lei de Moore deixará de existir, de modo geral, as leis não param de se aplicar; os
cientistas os reformam para que estejam mais de acordo com os fatos. Sério? Nós dissemos
isso? Bem, o que realmente queremos dizer é que a Lei de Moore pode sofrer a mesma
transformação. Leis lineares ou excessivamente simplistas raramente se aplicam em um sentido
geral porque não existem linhas retas em nenhum lugar da natureza, incluindo seus modelos
temporais. Portanto, o cenário mais provável é que a Lei de Moore se transforme em uma função
mais sigmoidal na tentativa de aderir à realidade.

A Lei de Moore tem um efeito direto sobre os dados. Começa com dispositivos mais inteligentes.
Quanto mais inteligentes os dispositivos, maior a difusão. (Eletrônicos estão em toda parte em nossos
dias.) Quanto maior a difusão, mais baixo o preço se torna, criando um loop infinito que impulsionou e
está impulsionando o uso de poderosas máquinas de computação e pequenos sensores em todos os
lugares. Com grandes quantidades de memória de computador disponíveis e discos de armazenamento
maiores para dados, as consequências são uma expansão da disponibilidade de dados, como sites,
registros de transações, uma série de medições diversas, imagens digitais e outros tipos de dados
inundados. de todo lugar.

Encontrando dados em todos os lugares

Os cientistas começaram a lutar contra quantidades impressionantes de dados por anos antes de
alguém cunhar o termo big data. A essa altura, a Internet não produzia a grande quantidade de dados
que produz hoje. É útil lembrar que big data não é apenas uma moda passageira criada por
fornecedores de software e hardware, mas tem base em muitos dos seguintes campos:

» Astronomia: Considere os dados recebidos de naves espaciais em uma missão


(como Voyager ou Galileo) e todos os dados recebidos de radiotelescópios, que
são antenas especializadas usadas para receber ondas de rádio de corpos
astronômicos. Um exemplo comum é o projeto Search for Extraterrestrial Intelligence (SETI)

228 PARTE 4 Lidando com Big Data


Machine Translated by Google

(https://www.seti.org/), que procura sinais extraterrestres observando


frequências de rádio que chegam do espaço. Os dados são baseados em
assinaturas tecnológicas, milhões delas, que geram grandes quantidades de
dados (https://www.sciencealert.com/scientists-detected-26-million possible-
technosignatures-they-all-came-from-us) mesmo que eles venham principalmente
de fontes humanas. A Proxima Centauri ligou recentemente (https://
theconversation.com/seti-new-signal-excites-alien hunters-heres-how-we-could-find-
out-if-its-real-152498), mas é a exceção e não a regra (fazendo com que todos se
sintam como mães que não ouvem de seus filhos).

Outro exemplo da quantidade surpreendente de dados gerados por diferentes


telescópios é a montagem da primeira imagem de um buraco negro localizado no
centro da galáxia M87 (para mais detalhes, leia: https://www.jpl.nasa.
gov/edu/news/2019/4/19/how-scientists-captured-the-first image-of-a-black-
hole/).

» Meteorologia: Pense em tentar prever o clima no curto prazo, dado o grande número
de medidas necessárias, como temperatura, pressão atmosférica, umidade, ventos
e precipitação em diferentes horários, locais e altitudes. A previsão do tempo é
realmente um dos primeiros problemas em big data, e bastante relevante. De acordo
com a Weather Analytics, uma empresa que fornece dados climáticos, mais de 33%
do Produto Interno Bruto (PIB) mundial é determinado pela forma como as condições
climáticas afetam a agricultura, pesca, turismo e transporte, só para citar alguns.
Datando da década de 1950, os primeiros supercomputadores da época eram usados
para processar o máximo de dados possível porque, em meteorologia, quanto mais
dados, mais precisa a previsão (ver https://bigdataanalyticsnews.com/big-data realce-
previsão do tempo/ para detalhes).

» Física: Considere as grandes quantidades de dados produzidos por experimentos


usando aceleradores de partículas na tentativa de determinar a estrutura da matéria,
espaço e tempo. Por exemplo, o Grande Colisor de Hádrons (https://home.
cern/ciência/aceleradores/grande colisor de hádrons), o maior acelerador de
partículas já criado, produz 15 PB (petabytes, ou 1 milhão de gigabytes) de
dados todos os anos como resultado de colisões de partículas (https://home.
cern/ciência/computação).

» Genômica: Sequenciar uma única fita de DNA, o que significa determinar a ordem
precisa das muitas combinações das quatro bases — adenina, guanina, citosina e
timina — que constituem a estrutura da molécula, requer muitos dados. Por
exemplo, um único cromossomo, uma estrutura que contém o DNA na célula, pode
exigir de 50 MB a 300 MB.
Um ser humano tem 46 cromossomos, e os dados de DNA de apenas uma pessoa
consomem um DVD inteiro. Imagine o armazenamento massivo necessário para

CAPÍTULO 12 Gerenciando Big Data 229


Machine Translated by Google

documentar os dados de DNA de um grande número de pessoas ou sequenciar outras


formas de vida na Terra (veja https://www.zymergen.com/blog/the-art of-biological-data-
science-bringing-biologys-great unknowns- em foco/).

» Oceanografia: Existem muitos sensores colocados na medida dos oceanos


temperatura, correntes e outras informações úteis. Além disso, usando hidrofones, é possível
gravar sons para monitoramento acústico para fins científicos (descobrindo fatos sobre
peixes, baleias e plâncton) e para fins de defesa militar (encontrando submarinos sorrateiros de
outros países).
Curiosamente, o Covid-19 tornou a coleta de ainda mais dados menos difícil (leia https://
www.eurekalert.org/news-releases/749064 para detalhes).

» Satélites: Gravar imagens de todo o globo e enviá-las de volta à Terra para monitorar a superfície
da Terra e sua atmosfera não é um negócio novo (TIROS 1, o primeiro satélite a enviar imagens
e dados, data de 1960) . A questão de quantos satélites estão lá em cima é complexa, mas você
pode ter uma ideia do número de satélites ativos e sua finalidade em https://www.geospatialworld.net/
blogs/how-many-satellites are-orbiting -a-terra-em-2021/. A quantidade de dados que chegam à
Terra é surpreendente e serve tanto para fins militares (vigilância) quanto para civis, como
acompanhar o desenvolvimento econômico, monitorar a agricultura e monitorar mudanças e
riscos. Um único satélite da Agência Espacial Europeia, Sentinel 1A, gera 5 PB de dados durante
dois anos de operação, como você pode ler em https://spaceflightnow.com/2016/04/28/europes-
sentinel satellites-generating-huge-big- arquivo de dados/.

Acompanhando essas tendências de dados mais antigas, novas quantidades de dados são agora
geradas ou transportadas pela Internet, criando novos problemas e exigindo soluções em termos
de armazenamento de dados e algoritmos de processamento:

» As estatísticas de uso da Internet estão crescendo cada vez mais, e você não pode determinar
se os números que você lê um dia ainda são válidos no dia seguinte (https://www.
internetadvisor.com/key-internet-statistics). Nos últimos anos, mais pessoas passaram a ter
acesso à Internet, não apenas por meio de computadores, mas também por meio de dispositivos
móveis, como smartphones. A pandemia do COVID-19 até intensificou essa tendência porque
forçou muitos a ficar em casa e fazer compras ou trabalhar de lá.

» A Internet das Coisas (IoT) tornou-se uma realidade. Você pode ter ouvido o termo muitas
vezes nos últimos 20 anos, mas agora o crescimento das coisas conectadas à Internet
explodiu. A ideia é colocar sensores e transmissores em tudo e usar os dados tanto para
controlar melhor o que acontece no mundo quanto para tornar os objetos mais inteligentes
(além disso, também é uma maneira prática de o governo monitorar as pessoas, se você for
uma conspiração teórico). Transmitindo

230 PARTE 4 Lidando com Big Data


Machine Translated by Google

os dispositivos estão ficando menores, mais baratos e consomem menos


energia; alguns já são tão pequenos que podem ser colocados em todos os
lugares. Os dispositivos IoT também vêm em formas que você pode não esperar
(https://www.asme.org/topics resources/content/9-cool-iot-devices-for-our-daily-lives).
Especialistas estimam que, em 2019, os dispositivos IoT já produziram cerca de
17,3ZB (um zettabyte corresponde a um milhão de petabytes) de dados. Espera-se
que esse número cresça mais de quatro vezes e se torne 73,1ZB até 2025 (https://
dataprot.net/statistics/iot-statistics/).

Colocando algoritmos nos negócios


A raça humana está agora em uma incrível interseção de tecnologias: volumes sem precedentes
de dados que são gerados por hardware cada vez menor e mais poderoso e estão sendo
analisados por algoritmos que esse mesmo processo ajudou a desenvolver. Não é simplesmente
uma questão de volume, que por si só é um desafio difícil. Conforme formalizado pela empresa
de pesquisa Gartner em 2001 e depois reprisado e expandido por outras empresas, como a IBM,
o big data pode ser resumido por quatro Vs que representam suas principais características
(https://edudataonline.
com/4-vs-of-big-data/):

» Volume: A quantidade de dados

» Velocidade: A velocidade de geração de dados

» Variedade: o número e os tipos de fontes de dados

» Veracidade: A qualidade e a voz oficial dos dados (quantificando erros, dados ruins e
ruído misturado com sinais), uma medida da incerteza dos dados

Continue lendo para obter mais detalhes sobre essas características-chave.

Volume
Cada característica de big data oferece um desafio e uma oportunidade. Por exemplo, o volume é um desafio para
os sistemas de armazenamento, forçando uma organização a revisar seus métodos e soluções predominantes e
forçando as tecnologias e algoritmos atuais a olhar para frente. No entanto, o volume também é uma oportunidade
porque você pode esperar uma certa quantidade de informações úteis em todos esses dados. Essas informações
úteis fornecem novos recursos e ajudam a obter resultados que antes não eram possíveis. Como exemplo, considere
como mais dados podem ajudar a entender as mudanças nas preferências dos consumidores nos mercados globais
ou a atender os clientes de uma empresa da melhor maneira.

CAPÍTULO 12 Gerenciando Big Data 231


Machine Translated by Google

Velocidade
Além dos desafios do volume, a velocidade exige que você capture os dados em tempo hábil ou
eles desaparecerão de vista. Para as organizações que são rápidas o suficiente para compreendê-
lo, a velocidade abre a capacidade de reagir a ameaças em tempo real e apresenta oportunidades
adicionais de ganhos de vários tipos (como um ganho financeiro). Não importa se os dados são
de ameaças de segurança ou de mercados financeiros, eles podem informá-lo sobre qualquer
coisa que aconteça sob o sol.

Variedade
A variedade exige que você lide com dados em vários formatos. Alguns dos formatos são
esperados porque você os planejou e concebeu; outras são inesperadas e complicadas porque
outras entidades as produziram. Variedade implica que você não pode contar com encontrar o que
está procurando em lugares específicos, ou escrito de maneiras que você possa entender
imediatamente. As técnicas usadas para buscar e processar informações tornam-se muito mais
flexíveis na época do big data, exigindo habilidades mais semelhantes às de um pesquisador ou
investigador do que de um bibliotecário ou arquivista.

Veracidade
A característica de veracidade auxilia na própria democratização dos dados. No passado, as
organizações acumulavam dados porque eram preciosos e difíceis de obter. Neste ponto, várias
fontes criam dados em quantidades tão crescentes que acumular não faz sentido (90% dos dados
do mundo foram criados nos últimos dois anos), portanto, limitar o acesso é inútil. Os dados estão
se transformando em uma mercadoria que muitos programas de dados abertos acontecem em
todo o mundo. (Os Estados Unidos têm uma longa tradição de acesso aberto; os primeiros
programas de dados abertos datam da década de 1970, quando a Administração Nacional
Oceânica e Atmosférica, NOAA, começou a divulgar dados meteorológicos livremente ao público.)
No entanto, porque os dados se tornaram uma mercadoria , a incerteza desses dados tornou-se
um problema. Você não sabe mais se os dados são completamente verdadeiros porque talvez
nem conheça sua fonte.

Os dados se tornaram tão onipresentes que seu valor não está mais nas informações reais (como
dados armazenados no banco de dados de uma empresa). O valor dos dados existe em como
você os usa. Aqui, algoritmos entram em jogo e mudam o jogo. Uma empresa como o Google se
alimenta de dados disponíveis gratuitamente, como o conteúdo de sites ou o texto encontrado em
textos e livros publicamente disponíveis. No entanto, o valor que o Google extrai dos dados deriva
principalmente de seus algoritmos. Por exemplo, o valor dos dados reside no algoritmo PageRank
(discutido no Capítulo 11), que é a própria base dos negócios do Google. O valor dos algoritmos
também vale para outras empresas.
O mecanismo de recomendação da Amazon contribui com uma parte significativa das receitas
da empresa. Muitas empresas financeiras usam negociação algorítmica e consultoria robótica,
aproveitando dados de ações disponíveis gratuitamente e informações econômicas para investimentos.

232 PARTE 4 Lidando com Big Data


Machine Translated by Google

Fluxos de dados de streaming


Quando os dados fluem em grandes quantidades, armazená-los pode ser difícil ou mesmo
impossível. Na verdade, armazenar tudo pode até não ser útil. Aqui estão alguns números das
estatísticas mais recentes, de 2019 a 2020, de apenas um pouco do que você pode esperar que
aconteça em um único minuto na Internet:

» 188 milhões de emails enviados

» 350.000 novos tweets enviados no Twitter

» 3,8 milhões de consultas solicitadas no Google

» 1.000.000 de pessoas logadas em suas contas no Facebook

Dados esses volumes, acumular os dados o dia todo para análise incremental pode não parecer
eficiente. Você simplesmente o armazena em algum lugar e o analisa outro dia (que é a
estratégia de arquivamento generalizada que é típica de bancos de dados e data warehouses).
No entanto, consultas de dados úteis tendem a perguntar sobre os dados mais recentes no
fluxo, e os dados se tornam menos úteis quando envelhecem (em alguns setores, como
financeiro, um dia pode ser muito tempo). Além disso, você pode esperar que ainda mais dados
cheguem amanhã (a quantidade de dados aumenta diariamente), e isso torna difícil, se não
impossível, extrair dados de repositórios à medida que você insere novos dados. Extraindo
dados antigos de repositórios como dados novos derrama é semelhante ao castigo de Sis
yphus. Sísifo, como narra um mito grego, recebeu um castigo terrível do deus Zeus: ser forçado
a rolar eternamente uma imensa pedra até o topo de uma colina, apenas para vê-la rolar de
volta a cada vez (ver https://www. mythweb. com/
encyc/gallery/sisyphus_c.html para detalhes adicionais).

Às vezes, os dados chegam tão rápido e em quantidades tão grandes que é impossível gravá-
los no disco: Novas informações chegam mais rápido do que o tempo necessário para gravá-las
no disco rígido. Este é um problema típico de experimentos de partículas com aceleradores de
partículas, como o Large Hadron Collider, exigindo que os cientistas decidam quais dados
manter (https://home.cern/science/computing/storage). Claro, você pode enfileirar dados por
algum tempo, mas não por muito tempo, porque a fila crescerá rapidamente e se tornará
impossível de manter. Por exemplo, se mantidos na memória, os dados da fila logo levarão a
um erro de falta de memória.

Como novos fluxos de dados podem tornar o processamento anterior de dados antigos obsoletos
e a procrastinação não é uma solução, as pessoas criaram várias estratégias para lidar
instantaneamente com grandes quantidades de dados mutáveis. As pessoas usam três maneiras
de lidar com grandes quantidades de dados:

» Armazenado: alguns dados são armazenados como estão porque podem ajudar a responder
perguntas mais tarde. Este método depende de técnicas para armazená-lo imediatamente e
processá-lo mais tarde muito rápido, não importa quão grande seja.

CAPÍTULO 12 Gerenciando Big Data 233


Machine Translated by Google

» Resumido: Alguns dados estão resumidos porque manter tudo como está não faz sentido;
apenas as informações importantes são mantidas.

» Consumido: Os dados restantes são consumidos porque seu uso é preterido


minado. Os algoritmos podem ler, digerir e transformar instantaneamente os dados em sinais
ou ações. Depois disso, o sistema esquece os dados para sempre.

O livro trata do primeiro ponto do Capítulo 13, que trata da distribuição de dados entre vários
computadores e da compreensão dos algoritmos usados para lidar com eles (uma estratégia de
dividir e conquistar). As seções a seguir abordam o segundo e o terceiro pontos, aplicando-os
aos dados que circulam nos sistemas.

Ao falar de dados massivos que chegam a um sistema de computador, muitas vezes você ouvirá
uma metáfora da água: fluxo de dados, fluxos de dados, mangueira de incêndio de dados. Você
descobre como trabalhar com fluxos de dados é como consumir água da torneira: para este
último, abrir a torneira permite coletar água para armazenamento em copos ou garrafas, ou você
pode usá-la para cozinhar, esfregar alimentos, limpar pratos ou lavar as mãos. De qualquer
forma, após qualquer um desses usos, a maior parte ou toda a água se foi, mas ela tem sido
muito útil e, de fato, vital.

Analisando streams com a receita certa


Os dados de streaming precisam de algoritmos de streaming, e o ponto chave a saber sobre
algoritmos de streaming é que, além de algumas medidas que ele pode calcular exatamente, um
algoritmo de streaming necessariamente fornece resultados aproximados. A saída do algoritmo
está quase correta; ele não adivinha a resposta exata, mas uma próxima a ela.

Além da metáfora da água da seção anterior, outra metáfora útil para entender os fluxos está
relacionada à combinação de ingredientes em uma receita.
(Na verdade, não estamos tentando deixá-lo encharcado ou faminto.) Ao lidar com fluxos, você
claramente precisa se concentrar apenas nas medidas de interesse e deixar de fora muitos
detalhes. Você pode estar interessado em uma medida estatística, como média, mínimo ou
máximo. Além disso, você pode querer contar elementos no fluxo ou distinguir informações
antigas das novas. Existem muitos algoritmos para usar, dependendo do problema, mas as
receitas sempre usam os mesmos ingredientes.
O truque de cozinhar o fluxo perfeito é usar uma ou todas essas ferramentas algorítmicas como
ingredientes:

» Amostragem: Reduza seu fluxo para um tamanho de dados mais gerenciável; representam
todo o fluxo ou as observações mais recentes usando uma janela de dados móvel.

234 PARTE 4 Lidando com Big Data


Machine Translated by Google

» Hashing: Reduza a variedade de fluxos infinitos a um conjunto limitado de números


inteiros simples (como visto na seção “Contando com Hashing” do Capítulo 7).

» Sketching: Crie um breve resumo da medida que você precisa, removendo os detalhes
menos úteis. Essa abordagem permite que você aproveite um armazenamento de trabalho
simples, que pode ser a memória principal do computador ou o disco rígido.

Outra característica a ser lembrada sobre algoritmos que operam em streams é sua simplicidade
e baixa complexidade computacional. Os fluxos de dados podem ser bastante rápidos.
Algoritmos que exigem muitos cálculos podem perder dados essenciais, o que significa que os
dados desaparecem para sempre. Quando você vê a situação sob esse prisma, pode apreciar
como as funções de hash se mostram úteis porque são rápidas em transformar entradas em
algo mais fácil de manusear e pesquisar. As funções de hash são imediatas porque, para ambas
as operações, a complexidade é O(1) (consulte a seção “Trabalhando com funções” do Capítulo
2 para uma discussão sobre complexidade). Você também pode apreciar as técnicas de esboço
e amostragem, que trazem a ideia de compressão com perdas (veja mais sobre compressão no
Capítulo 14). A compactação com perdas permite que você represente algo complexo usando
um formulário mais simples. Você perde alguns detalhes, mas economiza muito tempo e
armazenamento no computador.

Amostragem significa desenhar um conjunto limitado de exemplos de seu fluxo e tratá-los como
se representassem todo o fluxo. A amostragem é uma ferramenta bem conhecida em estatística
através da qual você pode fazer inferências sobre um contexto maior (tecnicamente chamado
de universo ou população) usando uma pequena parte dele.

Reservando os dados certos


A estatística nasceu numa época em que era quase impossível obter um censo completo. Um
censo é uma investigação sistemática de uma população, que inclui contá-la e obter dados
úteis dela. O governo pergunta a todas as pessoas de um país sobre onde moram, sua família,
sua vida cotidiana e seu trabalho. O censo tem suas origens em tempos antigos. Na Bíblia,
ocorre um censo no livro de Números; a população israelita é contada após o êxodo do Egito.
Para fins fiscais, os antigos romanos realizavam periodicamente um censo para contar a
população de seu grande império. Documentos históricos fornecem relatos de atividades
censitárias semelhantes no antigo Egito, Grécia, Índia e China.

A estatística, em particular o ramo da estatística chamado estatística inferencial, pode alcançar


o mesmo resultado que um censo, com uma margem de erro aceitável, interrogando um número
menor de indivíduos (chamado amostra). Assim, ao consultar

CAPÍTULO 12 Gerenciando Big Data 235


Machine Translated by Google

algumas pessoas, os pesquisadores podem determinar a opinião geral de uma população maior
sobre uma variedade de questões, como quem vencerá uma eleição. Nos Estados Unidos, por
exemplo, o estatístico Nate Silver foi notícia ao prever o vencedor da eleição presidencial de 2020 em
todos os 50 estados, usando dados de amostras (https://
projetos.fivethirtyeight.com/2020-election-forecast/).

Claramente, realizar um censo implica custos enormes (quanto maior a população, maiores os custos)
e requer muita organização (por isso os censos são pouco frequentes), enquanto uma amostra
estatística é mais rápida e barata. Custos reduzidos e requisitos organizacionais mais baixos também
tornam as estatísticas ideais para streaming de big data: os usuários de streaming de big data não
precisam de cada fragmento de informação e podem resumir a complexidade dos dados.

No entanto, há um problema com o uso de amostras estatísticas. No centro das estatísticas está a
amostragem, e a amostragem requer a escolha aleatória de alguns exemplos do conjunto de toda a
população. O elemento chave da receita é que cada elemento da população tem exatamente a
mesma probabilidade de fazer parte da amostra.
Se uma população consiste de um milhão de pessoas e o tamanho de sua amostra é um, a
probabilidade de cada pessoa fazer parte da amostra é uma em um milhão. Em termos matemáticos,
se você representar a população usando a variável N e o tamanho da amostra for n, a probabilidade
de fazer parte de uma amostra é n/N, conforme mostrado na Figura 12-2. A amostra representada é
uma amostra aleatória simples. (Outros tipos de amostra têm maior complexidade; este é o tipo de
amostra mais simples, e todos os outros se baseiam nele.)

FIGURA 12-2:
Como funciona a
amostragem de
um bucket.

Usar uma amostra aleatória simples é como jogar na loteria, mas você precisa ter todos os números
dentro de um recipiente para extrair alguns para representar o todo.
Você não pode colocar facilmente fluxos de dados em um repositório do qual pode extrair uma
amostra; em vez disso, você precisa extrair sua amostra em tempo real. O que você realmente precisa
é de outro tipo de amostra chamado amostragem de reservatório. Assim como um reservatório retém água

236 PARTE 4 Lidando com Big Data


Machine Translated by Google

para uso posterior, mas sua água não está parada porque alguns entram e outros saem, então
esse algoritmo funciona escolhendo aleatoriamente elementos para manter como amostras até
que outros elementos cheguem para substituí-los. Você pode encontrar esse algoritmo usado no
monitoramento de redes de comunicação na Internet, bem como na execução de sistemas de
consulta em bancos de dados e mecanismos de busca, como o Google. É mais útil para domar
grandes fluxos de dados em conjuntos mais gerenciáveis destinados a produzir informações
aproximadas, mas confiáveis.

O algoritmo de amostragem de reservatório é mais sofisticado do que outra estratégia algorítmica,


chamada janelamento, na qual você cria uma fila e permite que novos elementos entrem na fila
(veja a Figura 12-3). Elementos mais antigos saem da fila com base em um gatilho.
Esse método se aplica quando você deseja relatórios do fluxo em intervalos de tempo exatos.
Por exemplo, você pode querer saber quantas páginas os usuários solicitam de um servidor da
Internet a cada minuto. Usando janelas, você começa a enfileirar solicitações de página em um
determinado minuto, acumula elementos de contagem de páginas para um intervalo específico,
conta os elementos na fila, relata o número, descarta o conteúdo da fila e começa a enfileirar
novamente.

Outra motivação para usar janelas é ter uma quantidade fixa dos dados mais recentes. Nesse
caso, toda vez que você insere um elemento na fila, o elemento est antigo sai. Uma fila é uma
estrutura FIFO (first in/first out), que é mencionada no Capítulo 6.

FIGURA 12-3:
Um exemplo de
janelamento de
um fluxo de
dados de DNA.

A janela analisa amostras usando uma janela deslizante — ela mostra os elementos sob a janela,
que representam uma determinada fatia de tempo ou um determinado segmento do fluxo. A
amostragem de reservatório representa todo o escopo do fluxo, oferecendo uma quantidade
gerenciável de dados, que é uma amostra estatística do fluxo.

Aqui está como a amostra de reservatório funciona: Dado um fluxo de dados contendo muitos
elementos, você inicializa a amostra de reservatório com elementos retirados do fluxo

CAPÍTULO 12 Gerenciando Big Data 237


Machine Translated by Google

até que a amostra esteja completa. Por exemplo, se a amostra contiver 1.000 elementos,
um número que geralmente cabe na memória interna do computador, comece selecionando
os primeiros 1.000 elementos de fluxo. O número de elementos que você deseja na
amostra é k, e k implica em uma amostra que cabe na memória do computador. No ponto
em que você reserva os primeiros k elementos de fluxo, o algoritmo começa a fazer suas
seleções:

1. Desde o início do fluxo, o algoritmo conta cada novo elemento que chega. Ele
rastreia a contagem usando a variável denominada n. Quando o algoritmo entra
em ação, o valor de n é equivalente a k.

2. Agora, novos elementos chegam e eles incrementam o valor de n. Um novo elemento


vindo do córrego tem uma probabilidade de ser inserido na amostra do reservatório
de k/n e uma probabilidade de não ser inserido igual a (1 – k/n).

3. A probabilidade é verificada para cada novo elemento que chega. É como uma loteria:
se a probabilidade for verificada, o novo elemento é inserido. Por outro lado, se
não for inserido, o novo elemento será descartado. Se for inserido, o algoritmo
descarta um elemento antigo na amostra de acordo com alguma regra (a mais
fácil é escolher um elemento antigo aleatoriamente) e o substitui pelo novo elemento.

O código a seguir mostra um exemplo simples em Python para que você possa ver esse
algoritmo em ação. O exemplo se baseia em uma sequência de letras do alfabeto (finja
que são um fluxo de dados) e cria uma amostra de cinco elementos. (Você pode encontrar
esse código no arquivo de código-fonte para download A4D2E; 12; Managing Big
Data.ipynb ; consulte a Introdução para obter detalhes.)

seqüência de importação

fluxo de dados = list(string.ascii_uppercase)


fluxo de dados += list(string.ascii_lowercase)
print(fluxo de dados)

['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L',
'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X',
'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j',
'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v',
'W x y Z']

Além das strings, o exemplo usa funções do pacote random para criar uma semente (para
soluções estáveis e replicáveis) e, desenhando um número inteiro aleatório, verifica se
precisa alterar algum elemento no reservatório. Além do valor da semente, você pode
experimentar modificar o tamanho da amostra ou até mesmo alimentar o algoritmo com um
fluxo diferente (deve estar em uma lista do Python para que o exemplo funcione
corretamente).

238 PARTE 4 Lidando com Big Data


Machine Translated by Google

da semente de importação aleatória, randint


seed(9) # altera este valor para resultados diferentes
amostra_tamanho = 5
amostra = []

para índice, elemento em enumerate(datastream):


# Até que o reservatório esteja cheio, adicionamos elementos
se índice < sample_size:
sample.append(elemento)
senão:
# Após encher o reservatório, testamos um
# substituição aleatória com base nos elementos
# visto no fluxo de dados
desenhado = randint(0, índice)
# Se o número sorteado for menor ou igual ao
# tamanho da amostra, substituímos um anterior
# elemento com o que vem do
# fluxo
se desenhado < sample_size:
amostra[desenhada] = elemento

imprimir(amostra)

['y', 'e', 'v', 'F', 'i']

Este procedimento garante que, a qualquer momento, sua amostra de reservatório seja
uma boa amostra que represente o fluxo de dados geral. Nesta implementação, a variável
index desempenha o papel de n e a variável sample_size atua como k. Observe dois
aspectos particulares deste algoritmo:

» À medida que o índice da variável cresce porque o córrego inunda com dados, a
probabilidade de fazer parte da amostra diminui. Consequentemente, no início do
fluxo, muitos elementos entram e saem da amostra, mas a taxa de mudança diminui à
medida que o fluxo continua a fluir.

» Se você verificar a probabilidade de adicionar um elemento individual do fluxo de dados


para amostrar e calcular a média de todos eles, a média aproximará a
probabilidade de um determinado elemento encontrado no fluxo de dados (a
população) ser escolhido em qualquer amostra (não necessariamente essa amostra
específica), que é k/n.

CAPÍTULO 12 Gerenciando Big Data 239


Machine Translated by Google

Esboçando uma resposta a partir de dados de fluxo


A amostragem é uma excelente estratégia para lidar com fluxos, mas não responde a todas as
perguntas que você possa ter sobre seu fluxo de dados. Por exemplo, uma amostra não pode
dizer quando você já viu um elemento de fluxo porque a amostra não contém todas as
informações de fluxo. O mesmo vale para problemas como contar o número distinto de
elementos em um fluxo ou calcular a frequência do elemento.

Para obter tais resultados, você precisa de funções hash (como visto no Capítulo 7) e esboços,
que são resumos de dados simples e aproximados. As seções a seguir começam com hashes
e você descobrirá como estar correto ao descobrir quando um elemento de fluxo que chegou
apareceu antes, mesmo que seu fluxo seja infinito e você não consiga manter uma memória
exata de tudo o que fluiu antes.

Filtrando elementos de stream de cor


No coração de muitos algoritmos de streaming estão os filtros Bloom. Criado há quase 50 anos
por Burton H. Bloom, em uma época em que a ciência da computação ainda era muito jovem, a
intenção original do criador desse algoritmo era trocar espaço (memória) e/ou tempo
(complexidade) pelo que ele chamou de erros permitidos. Seu artigo original é intitulado Space/
Time Trade-offs in Hash Coding with Allowable Errors (ver: https://
dl.acm.org/doi/10.1145/362686.362692 para detalhes).

Você pode se perguntar sobre o espaço e o tempo que Bloom considera motivadores para seu
algoritmo. Imagine que você precisa determinar se um elemento já apareceu em um fluxo
usando alguma estrutura de dados criada ou processada anteriormente.
Isso pode ser útil para uma variedade de aplicações, especialmente em segurança cibernética,
navegação segura na Internet e até mesmo em carteiras eletrônicas (por exemplo, usando
moedas digitais como Bitcoin). Você pode encontrar uma lista detalhada em https://iq.opengenus.
org/applications-of-bloom-filter/.

Encontrar algo em um fluxo implica que a gravação e a pesquisa sejam rápidas, portanto, uma
tabela de hash parece a escolha ideal. As tabelas de hash, conforme discutido no Capítulo 7,
simplesmente requerem a adição dos elementos que você deseja gravá-los e armazená-los.
Recuperar um elemento de uma tabela de hash é rápido porque a tabela de hash usa valores
facilmente manipulados para representar o elemento, em vez do próprio elemento (que pode
ser bastante complexo). No entanto, armazenar ambos os elementos e um índice para esses
elementos tem limitações. Se uma tabela de hash enfrentar mais elementos do que pode
manipular, como os elementos em um fluxo contínuo e potencialmente infinito, você acabará
incorrendo em problemas de memória em algum momento.

240 PARTE 4 Lidando com Big Data


Machine Translated by Google

Uma consideração essencial para filtros Bloom é que falsos positivos podem ocorrer, mas
falsos negativos não. Por exemplo, um fluxo de dados pode conter dados de monitoramento
em tempo real para uma usina de energia. Ao usar um filtro Bloom, a análise do fluxo de dados
mostraria que as leituras esperadas provavelmente fazem parte do conjunto de leituras
permitidas, com alguns erros permitidos. No entanto, quando ocorre um erro no sistema, a
mesma análise mostra que as leituras não fazem parte do conjunto de leituras permitidas. É
improvável que os falsos positivos causem problemas, mas a ausência de falsos negativos
significa que todos permanecem seguros. Devido ao potencial de falsos positivos, filtros como
o filtro Bloom são estruturas de dados probabilísticas — eles não fornecem uma resposta certa,
mas sim uma resposta provável.

Hashes, as entradas individuais em uma tabela de hash, são rápidos porque agem como o
índice de um livro. Você usa uma função de hash para produzir o hash; a entrada é um
elemento que contém dados complexos e a saída é um número simples que atua como um
índice para esse elemento. Uma função hash é determinística porque produz o mesmo número
toda vez que você a alimenta com uma entrada de dados específica. Você usa o hash para
localizar as informações complexas de que precisa. Os filtros Bloom são úteis porque são uma
maneira frugal de registrar rastros de muitos elementos sem ter que armazená-los como uma
tabela de hash faz. Funcionam de forma simples e utilizam como ingredientes principais:

» Um vetor de bits: Uma lista de m elementos de bits, onde cada bit no elemento pode ter
um valor de 0 ou 1. Quanto maior m, melhor, embora existam maneiras de definir o seu
tamanho de forma otimizada.

» Uma série de funções de hash: Cada função de hash representa um valor diferente.
As funções de hash podem rapidamente processar dados como um índice em uma lista e
produzir resultados uniformemente distribuídos dentro da lista de m elementos de bits, que
são resultados que variam igualmente do valor mínimo ao máximo de saída do hash.

Adicionando elementos aos filtros Bloom


Geralmente, você cria filtros Bloom de tamanho fixo (versões desenvolvidas recentemente
permitem redimensionar o filtro). Você os opera adicionando novos elementos ao filtro e
procurando-os quando já estiverem presentes. Não é possível remover um elemento do filtro
após adicioná-lo (o filtro tem uma memória indelével). Ao adicionar um elemento a um vetor de
bits, o vetor de bits tem alguns bits definidos como 1, conforme mostrado na Figura 12-4. Nesse
caso, o filtro Bloom adiciona X ao vetor de bits.

Você pode adicionar quantos elementos forem necessários ao vetor de bits. Por exemplo, a
Figura 12-5 mostra o que acontece ao adicionar outro elemento, Y, ao vetor de bits. Observe
que o bit 7 é o mesmo para X e Y. Conseqüentemente, o bit 7 representa uma colisão entre X
e Y. Essas colisões são a fonte dos potenciais falsos positivos; por causa deles, o algoritmo
poderia dizer que um elemento já está

CAPÍTULO 12 Gerenciando Big Data 241


Machine Translated by Google

adicionado ao vetor de bits quando não é. O uso de um vetor de bits maior torna as
colisões menos prováveis e melhora o desempenho do filtro Bloom, mas faz isso ao
custo de espaço e tempo.

FIGURA 12-4:
Adicionando um
único elemento a um bit
vetor.

FIGURA 12-5:
Adicionar um segundo
elemento pode
causar colisões.

Pesquisando um filtro Bloom por um elemento


A pesquisa de um filtro Bloom permite determinar se um elemento específico aparece
no vetor de bits. Durante o processo de busca, o algoritmo procura a presença de um
0 no vetor de bits. Por exemplo, a seção anterior adicionou os elementos X e Y ao
vetor de bits. Na busca pelo elemento Z, o algoritmo encontra um 0 no segundo bit,
conforme mostrado na Figura 12-6. A presença de um 0 significa que Z não faz parte
do vetor de bits.

FIGURA 12-6:
Localizar um
elemento e
determinar que ele
existe significa
procurar 0s no vetor
de bits.

242 PARTE 4 Lidando com Big Data


Machine Translated by Google

Demonstrando o filtro Bloom


Este exemplo usa Python para demonstrar um filtro Bloom e mostra o resultado com
uma visualização gráfica. Digamos que você esteja usando um rastreador, que é um
software especializado que percorre a web para verificar se algo mudou nos sites
monitorados (o que pode implicar copiar parte dos dados do site, uma atividade
conhecida como raspagem). O exemplo usa um vetor de bits curto e três funções de
hash, que não é a melhor configuração para lidar com um grande número de
elementos (o vetor de bits será preenchido rapidamente), mas é suficiente para um exemplo funciona

funções_hash = 3
bit_vetor_comprimento = 10
bit_vector = [0] * bit_vector_length

de hashlib import md5, sha1

def hash_f(elemento, i, comprimento):


""" Esta é uma função mágica """
h1 = int(md5(element.encode('ascii')).hexdigest(),16)
h2 = int(sha1(element.encode('ascii')).hexdigest(),16)
return (h1 + i*h2) % comprimento

def insert_filter(site):
resultado = lista()
para hash_number no intervalo (hash_functions):
posição = hash_f(site, hash_number,
bit_vector_length)
resultado.append(posição)
bit_vetor[posição] = 1
print('Inserido nas posições: %s' % resultado)

def check_filter(site):
resultado = lista()
para hash_number no intervalo (hash_functions):
posição = hash_f(site, hash_number,
bit_vector_length)
result.append((posição, bit_vector[posição]))
print('Bytes nas posições: %s' % resultado)

O código começa criando um vetor de bits e algumas funções que podem fazer o
seguinte:

» Gere várias funções de hash (consulte “Criando sua própria função de hash”
seção no Capítulo 7 para detalhes) com base nos algoritmos de hash md5 e sha1

CAPÍTULO 12 Gerenciando Big Data 243


Machine Translated by Google

» Insira um objeto no vetor de bits


» Verifique se os bytes relativos a um objeto no vetor de bits estão ativados

Todos esses elementos juntos constituem um filtro Bloom (embora o vetor de bits seja a parte principal
dele). Este exemplo faz com que o rastreador primeiro visite o site wikipedia.
org para obter algumas informações de algumas páginas:

insert_filter('wikipedia.org')
print(bit_vetor)

Aqui estão os resultados:

Inserido nas posições: [0, 8, 6]


[1, 0, 0, 0, 0, 0, 1, 0, 1, 0]

Essa atividade ativa os bits nas posições 0, 6 e 8 do vetor de bits. O exemplo agora rastreia o site
youtube.com (que tem alguns vídeos novos de gatinhos) e assim o rastreador insere a informação da
visita no filtro Bloom:

insert_filter('youtube.com')
print(bit_vetor)

Aqui estão os resultados atualizados:

Inserido nas posições: [3, 0, 7]


[1, 0, 0, 1, 0, 0, 1, 1, 1, 0]

Aqui o filtro Bloom é ativado nas posições 0, 3 e 7. Dado o pequeno comprimento do vetor de bits, já
existe uma colisão na posição 0, mas as posições 3 e 7 são completamente novas. Neste ponto, como o
algoritmo não consegue lembrar o que visitou antes (mas os sites visitados podem ser verificados usando
o filtro Bloom), o exemplo verifica se ele não visitou yahoo.com para evitar refazer as coisas, como
mostrado na Figura 12-7:

check_filter('yahoo.com')

Bytes nas posições: [(7, 1), (5, 0), (3, 1)]

Conforme representado graficamente, neste caso, você pode ter certeza de que o exemplo nunca visitou
yahoo.com porque o filtro Bloom relata pelo menos uma posição, a posição 5, cujo bit nunca foi ativado.

244 PARTE 4 Lidando com Big Data


Machine Translated by Google

FIGURA 12-7:
Testando
a associação de
um site usando
um filtro Bloom.

Um rastreador geralmente se preocupa em obter novos conteúdos de sites e não


em copiar dados que já gravou e transmitiu. Em vez de fazer o hash do domínio ou
do endereço de uma única página, você pode preencher diretamente um filtro Bloom
usando parte do conteúdo do site e usá-lo para verificar as alterações no mesmo
site posteriormente.

Existe uma maneira simples e direta de diminuir a probabilidade de ter um falso


positivo. Você apenas aumenta o tamanho do vetor de bits que é o núcleo de um
filtro Bloom. Mais endereços equivalem a menos chances de colisão pelos resultados
das funções de hash. Idealmente, o tamanho m do vetor de bits pode ser calculado
estimando n, o número de objetos distintos que você espera adicionar mantendo m
muito maior que n. O número ideal k de funções hash a serem usadas para minimizar
colisões pode ser estimado usando a seguinte fórmula (ln é o logaritmo natural):

k = (m/n)*ln(2)

Depois de definir m, n e k, esta segunda fórmula ajuda a estimar a probabilidade de


uma colisão (uma taxa de falsos positivos) usando um filtro Bloom:

taxa de falsos positivos = (1-exp(-kn/m))^k

Se você não puder determinar n por causa da variedade de dados no fluxo, terá que
alterar m, o tamanho do vetor de bits (que equivale ao espaço de memória), ou k, o
número de funções de hash (que equivale ao tempo ), para ajustar a taxa de falsos
positivos. O trade-off reflete as relações que Bloom considera no artigo original entre
espaço, tempo e probabilidade de erro.

CAPÍTULO 12 Gerenciando Big Data 245


Machine Translated by Google

Encontrando o número de elementos distintos


Mesmo que um filtro Bloom possa rastrear objetos que chegam de um fluxo, ele não
pode dizer quantos objetos existem. Um vetor de bits preenchido por uns pode
(dependendo do número de hashes e da probabilidade de colisão) ocultar o verdadeiro
número de objetos sendo hash no mesmo endereço.

Saber o número distinto de objetos é útil em várias situações, como quando você deseja
saber quantos usuários distintos viram uma determinada página do site ou o número de
consultas distintas do mecanismo de pesquisa. Armazenar todos os elementos e
encontrar as duplicatas entre eles não funciona com milhões de elementos, especialmente
vindos de um stream. Quando você deseja saber o número de objetos distintos em um
fluxo, ainda precisa confiar em uma função hash, mas a abordagem envolve fazer um
esboço numérico.

Esboçar significa tomar uma aproximação, que é um valor inexato, mas não
completamente errado como resposta. A aproximação é aceitável porque o valor real não
está muito longe dele. Em um tipo de algoritmo de esboço, HyperLogLog, que é baseado
em probabilidade e aproximação, você observa as características dos números gerados
a partir do fluxo. HyperLogLog deriva dos estudos dos cientistas da computação Nigel
Martin e Philippe Flajolet. Flajolet melhorou seu algoritmo inicial, Flajolet–Martin (ou o
algoritmo LogLog), para a versão Hyper LogLog mais robusta, que funciona assim:

1. Um hash converte cada elemento recebido do fluxo em um número.

2. O algoritmo converte o número em binário, o padrão numérico de base 2 que os


computadores usam.

3. O algoritmo conta o número de zeros iniciais no número binário e


faixas do número máximo que ele vê, que é n.

4. O algoritmo estima o número de elementos distintos passados no fluxo usando n.


O número de elementos distintos é 2^n.

Por exemplo, o primeiro elemento na string é a palavra dog. O algoritmo o converte em


um valor inteiro e o converte em binário, com um resultado de 01101010. Apenas um
zero aparece no início do número, então o algoritmo o registra como o número máximo
de zeros à esquerda vistos. O algoritmo então vê as palavras papagaio e lobo, cujos
equivalentes binários são 11101011 e 01101110, deixando n inalterado. No entanto,
quando a palavra cat passa, a saída é 00101110, então n se torna 2. Para estimar o
número de elementos distintos, o algoritmo calcula 2^n, ou seja, 2^2=4. A Figura 12-8
mostra esse processo.

246 PARTE 4 Lidando com Big Data


Machine Translated by Google

FIGURA 12-8:
Contando apenas
zeros à esquerda.

O truque do algoritmo é que se seu hash está produzindo resultados aleatórios,


igualmente distribuídos (como em um filtro Bloom), olhando para a representação binária,
você pode calcular a probabilidade de que uma sequência de zeros apareça. Como a
probabilidade de um único número binário ser 0 é uma em duas, para calcular a
probabilidade de sequências de zeros, basta multiplicar essa probabilidade 1/2 tantas
vezes quanto o comprimento da sequência de zeros:

» 50 por cento (1/2) de probabilidade para números começando com 0

» 25 por cento (1/2


* 1/2) probabilidade para números começando com 00
» 12,5 por cento (1/2 * 1/2 * 1/2) de probabilidade para números começando com 000

» (1/2)^k probabilidade para números começando com k zeros (você usa potências para
cálculos mais rápidos de muitas multiplicações do mesmo número)

Quanto menos números o HyperLogLog vê, maior a imprecisão. A precisão aumenta


quando você usa o cálculo HyperLogLog muitas vezes usando diferentes funções de
hash e calcula a média das respostas de cada cálculo. Como alternativa, você pode
recorrer à média estocástica, o que significa usar o mesmo hash, mas dividir o fluxo em
grupos (como separar os elementos em grupos à medida que chegam com base em sua
ordem de chegada) e, para cada grupo, você acompanha do número máximo de zeros
à direita. No final, você calcula a estimativa de elemento distinto para cada grupo e
calcula a média aritmética de todas as estimativas.

Aprendendo a contar objetos em um fluxo


Este último algoritmo do capítulo também utiliza funções de hash e esboços aproximados.
Ele faz isso depois de filtrar objetos duplicados e contar elementos distintos que
apareceram no fluxo de dados. Aprender a contar objetos em um fluxo pode ajudá-lo a
encontrar os itens mais frequentes ou classificar eventos usuais e incomuns. Você usa
essa técnica para resolver problemas como encontrar as consultas mais frequentes em um

CAPÍTULO 12 Gerenciando Big Data 247


Machine Translated by Google

motor de busca, os itens mais vendidos de um varejista on-line, as páginas altamente


populares em um site ou as ações mais voláteis (contando as vezes que uma ação é
vendida e comprada).

Você aplica a solução para este problema, Count-Min Sketch, a um fluxo de dados. Requer
apenas uma passagem de dados e armazena o mínimo de informações possível. Esse
algoritmo é aplicado em muitas situações do mundo real (como análise de tráfego de rede
ou gerenciamento de fluxos de dados distribuídos). A receita requer o uso de várias funções
de hash, cada uma associada a um vetor de contagem, de maneira semelhante a um filtro
Bloom, conforme mostrado na Figura 12-9:

1. Inicialize todos os vetores de contagem com zeros em todas as posições.

2. Aplique a função hash para cada vetor de contagem ao receber um objeto de um fluxo. Use o endereço
numérico resultante para incrementar o valor nessa posição.

3. Aplique a função hash a um objeto e recupere o valor na posição associada quando solicitado a
estimar a frequência de um objeto. De todos os valores recebidos dos vetores de contagem, você
pega o menor como o número de vezes que o objeto ocorreu no fluxo.

FIGURA 12-9:
Como os valores são

atualizados em
um esboço de
contagem-mín.

Como as colisões são sempre possíveis ao usar uma função de hash, especialmente se o
vetor de contagem associado tiver poucos slots, ter vários vetores de contagem à mão
torna mais provável que pelo menos um deles mantenha o valor correto. O valor de escolha
deve ser o menor porque não é misturado com tantas contagens de falsos positivos devido
a colisões.

248 PARTE 4 Lidando com Big Data


Machine Translated by Google

NESTE CAPÍTULO

» Entender por que simplesmente


maior, maior e mais rápido nem
sempre é a solução certa

» Olhando dentro do armazenamento


e abordagens computacionais de
empresas de internet

» Descobrir como usar clusters de


hardware commodity reduz custos

» Reduzir algoritmos complexos em


operações paralelas separáveis por
MapReduce

Capítulo 13

Operações de Paralelização
gies tem vantagens claras (como discutido no Capítulo 12) quando você tem que lidar
Gerenciando
comimensas quantidades
processamento de dados
de dados usando
em massa. streaming
Usando ou estratégia
algoritmo de amostragem
de streaming e amostragem
rithms ajuda você a obter um resultado mesmo quando seu poder computacional é
limitado (por exemplo, ao usar seu próprio computador). No entanto, alguns custos estão
associados a essas abordagens:

» Streaming: lida com quantidades infinitas de dados. No entanto, seus algoritmos funcionam
em baixa velocidade porque processam dados individuais e a velocidade do fluxo determina
o ritmo.

» Amostragem: Aplica qualquer algoritmo em qualquer máquina. No entanto, o resultado obtido


é impreciso porque você tem apenas uma probabilidade, não uma certeza, de obter a
resposta certa. Na maioria das vezes, você acaba de obter algo plausível.

A primeira parte deste capítulo discute alguns problemas que exigem o manuseio de
grandes quantidades de dados de maneira precisa e oportuna, e como evitar custos de
streaming e amostragem. Os exemplos são abundantes no mundo digital, como fazer
uma consulta de palavra-chave entre bilhões de sites ou processar várias informações
(procurar uma imagem em um repositório de vídeo ou uma correspondência em várias
sequências de DNA). Fazer esses cálculos sequencialmente levaria uma vida inteira.

CAPÍTULO 13 Operações de Paralelização 249


Machine Translated by Google

A solução principal para resolver o problema de velocidade com precisão é usar computação
distribuída, conforme explicado na seção “Distribuindo arquivos e operações”, o que significa
interconectar muitos computadores em uma rede e usar seus recursos computacionais juntos,
combinados com algoritmos executados neles de forma independente e paralela. É aqui que
entra a segunda parte do capítulo. Após apresentar o MapReduce na seção “Empregando a
solução MapReduce”, o capítulo mostra em detalhes exemplos de como configurar o
processamento do MapReduce.

Você não precisa digitar o código-fonte para este capítulo manualmente. Na verdade, usar a
fonte para download é muito mais fácil. Você pode encontrar a fonte para este capítulo em
\A4D2E\A4D2E; 13; Arquivo Map_Reduce.ipynb da fonte para download. Consulte a Introdução
para obter detalhes sobre como localizar os arquivos de origem.

Gerenciando grandes quantidades de dados


O uso da Internet para realizar uma ampla gama de tarefas, juntamente com o aumento da
popularidade de seus aplicativos mais bem-sucedidos, como mecanismos de busca ou redes
sociais, exigiu que profissionais de várias áreas repensassem como aplicar algoritmos e
soluções de software para lidar com um dilúvio de dados. A busca por tópicos e a conexão de
pessoas impulsionam essa revolução.

Imagine a progressão, em termos de sites e páginas disponíveis, que ocorreu nos últimos 15
anos. Mesmo se você usar um algoritmo inteligente, como o PageRank (discutido e explorado
no Capítulo 11), lidar com dados cada vez maiores e mutáveis ainda é difícil. O mesmo vale
para os serviços de redes sociais oferecidos por empresas como Facebook, Twitter, Pinterest,
LinkedIn e assim por diante. À medida que o número de usuários aumenta e seus
relacionamentos recíprocos se desdobram, o gráfico subjacente que os conecta se torna
enorme em escala. Com uma escala tão grande, o manuseio de nós e links para encontrar
grupos e conexões se torna incrivelmente difícil. (A Parte 3 do livro discute os gráficos em
detalhes.)

Além dos dados baseados em comunicação, considere os varejistas on-line que fornecem
depósitos virtuais de milhares e milhares de produtos e serviços (livros, filmes, jogos e assim
por diante). Mesmo que você entenda por que comprou alguma coisa, o varejista vê os itens
em sua cesta como pequenas peças de um quebra-cabeça de tomada de decisão de compra
a ser resolvido para entender as preferências de compra. Resolver o quebra-cabeça permite
que um varejista sugira e venda produtos alternativos ou complementares.

250 PARTE 4 Lidando com Big Data


Machine Translated by Google

Entendendo o paradigma paralelo


Os fabricantes de CPU encontraram uma solução simples quando desafiados a colocar mais poder
de computação em microprocessadores (como previsto e parcialmente prescrito pela lei de Moore,
discutida no Capítulo 12). No entanto, maior, maior e mais rápido nem sempre é a solução certa.
Quando descobriram que a absorção de energia e a geração de calor limitavam a adição de mais
CPUs a um único chip, os engenheiros se comprometeram criando unidades de processamento
multicore, que são CPUs feitas empilhando duas ou mais CPUs juntas.
O uso da tecnologia multicore proporcionou acesso à computação paralela a um público maior.

A computação paralela existe há muito tempo, mas apareceu principalmente em computadores


de alto desempenho, como os supercomputadores Cray criados por Seymour Cray na Control
Data Corporation (CDC) a partir da década de 1960. Simplificando, as propriedades associativas
e comutativas em matemática expressam a ideia central do paralelismo. Em uma adição
matemática, por exemplo, você pode agrupar parte das somas ou adicionar as partes em uma
ordem diferente daquela mostrada pelas fórmulas:

Propriedade associativa
2 + (3 + 4) = (2 + 3) + 4

Propriedade comutativa
2+3+4=4+3+2

Os mesmos conceitos se aplicam aos algoritmos de computação, independentemente de você ter


uma série de operações ou uma função matemática. Na maioria das vezes, você pode reduzir o
algoritmo a uma forma mais simples ao aplicar propriedades associativas e comutativas, conforme
mostrado na Figura 13-1. Então você pode dividir as partes e fazer com que diferentes unidades
executem operações atômicas separadamente, somando o resultado no final.

Neste exemplo, duas CPUs dividem uma função simples com três entradas (x, yez) aproveitando
as propriedades associativas e comutativas. A solução da equação requer o compartilhamento de
dados comuns (CPU1 precisa de valores xey; CPU2 precisa de valores yez). O processamento
prossegue em paralelo até que as duas CPUs emitam seus resultados, que são somados para
obter a resposta.

O paralelismo permite o processamento de um grande número de cálculos simultaneamente.


Quanto mais processos, maior a velocidade de execução da computação, embora o tempo gasto
não seja linearmente proporcional ao número de executores paralelos. (Não é completamente
verdade que duas CPUs implicam em velocidade dupla, três CPUs implicam em três vezes a
velocidade e assim por diante.) Na verdade, você não pode esperar que as propriedades
associativas ou comutativas funcionem em todas as partes do seu algoritmo ou instruções do computador.
O algoritmo simplesmente não pode tornar algumas partes paralelas conforme declarado pela lei
de Amdahl, o que ajuda a determinar a vantagem da velocidade de paralelismo de sua computação

CAPÍTULO 13 Operações de Paralelização 251


Machine Translated by Google

(para obter detalhes, consulte https://www.geeksforgeeks.org/computer-


organization amdahls-law-and-its-proof/). Além disso, outros aspectos podem
atenuar o efeito positivo do paralelismo:

» Overhead: Você não pode somar os resultados em paralelo.

» Housekeeping: A conversão subjacente de uma linguagem legível por humanos para uma
linguagem de máquina requer tempo. Manter os processadores trabalhando juntos aumenta
os custos de conversão, tornando impossível ver um efeito de duplicação de dois
processadores, mesmo que você possa executar todas as partes da tarefa em paralelo.

» Saídas Assíncronas: Como os executores paralelos não realizam tarefas


a mesma velocidade exata, a velocidade total está vinculada à mais lenta. (Tal como
acontece com uma frota, a velocidade da frota é determinada pelo barco mais lento.)

FIGURA 13-1:
Propriedades
associativas e
comutativas
permitem paralelismo.

Mesmo que nem sempre seja tão benéfico quanto o esperado, o paralelismo pode
potencialmente resolver o problema de lidar com um grande número de operações mais
rapidamente do que usar um único processador (se um grande número de executores puder
processá-las em paralelo). No entanto, o paralelismo não pode lidar com as enormes
quantidades de dados por trás dos cálculos sem outra solução: computação distribuída em sistemas distribuídos.

252 PARTE 4 Lidando com Big Data


Machine Translated by Google

Quando você compra um novo computador, o vendedor provavelmente informa sobre núcleos e threads.
Os núcleos são as CPUs empilhadas dentro de um único chip de CPU e que funcionam em paralelo
usando multiprocessamento. Como cada núcleo é independente, as tarefas ocorrem simultaneamente.
Threads referem-se à capacidade de um único núcleo dividir sua atividade entre vários processos, de
maneira quase paralela. No entanto, neste caso, cada thread tem seu turno com o processador, para
que as tarefas não ocorram simultaneamente. Isso é chamado de multithreading.

Distribuindo arquivos e operações


Gráficos grandes; enormes quantidades de arquivos de texto, imagens e vídeos; e imensas matrizes
de relação de adjacência invocam a abordagem paralela. Felizmente, você não precisa mais de um
supercomputador para lidar com eles, mas pode confiar no paralelismo de um monte de computadores
muito menos poderosos. O fato de que essas grandes fontes de dados continuam crescendo significa
que você precisa de uma abordagem diferente do que usar um único computador especialmente
projetado para lidar com elas. Os dados crescem tão rápido que quando você termina de projetar e
produzir um supercomputador para processar os dados, ele pode não ser mais adequado porque os
dados já cresceram muito.

Tudo começou em 2004, quando o conceito MapReduce tornou-se público em “MapReduce: Simplified
Data Processing On Large Clusters”, escrito por Jeffery Dean e Sanjay Ghemawat (em https://
citeseerx.ist.psu.edu/viewdoc/down
load?doi=10.1.1.324.78&rep=rep1&type=pdf). O artigo descreve como o Google coletou e analisou
dados de sites para otimizar seu sistema de pesquisa.
A descrição dos autores da abordagem MapReduce, e como ela funcionava no Google File System
(GFS), despertou muito interesse da comunidade de código aberto, e a fundação de código aberto
Apache iniciou alguns projetos. Um desses projetos foi o Nutch (http://nutch.apache.org/), um motor de
busca de código aberto; outro foi o Apache Lucene (https://lucene.apache.org/), que foi usado para
alimentar pesquisas de texto em bancos de dados. As coisas ficaram mais interessantes em 2006,
quando Doug Cutting, um funcionário do Yahoo!, projetou o Hadoop e o nomeou em homenagem ao
elefante de brinquedo de seu filho. Lançado como um projeto de código aberto em 2007, ganhou
destaque e se tornou um projeto de alto nível na Apache Software Foundation. As mesmas ideias
centrais por trás do Hadoop também inspiraram muitos outros projetos análogos paralelos ou
processamento de dados em larga escala, com o Spark (https://spark.apache.org/), um projeto de
pesquisa na UC Berkeley AMPLab e depois passou para a Apache Software Foundation, sendo o mais
conhecido.

A maioria desses projetos para grandes processamentos de dados funciona de maneira semelhante.
Os engenheiros juntam muitas ideias tecnológicas existentes e criam algum tipo de Sistema de Arquivo
Distribuído (DFS). Ao usar um DFS, os dados não são armazenados em um único computador poderoso
com um disco rígido gigante; em vez disso, o DFS o distribui entre vários computadores menores,
semelhante a um computador pessoal. Os engenheiros organizam os computadores em um cluster, um
sistema físico de racks e conexões de cabos. Prateleiras

CAPÍTULO 13 Operações de Paralelização 253


Machine Translated by Google

são a verdadeira espinha dorsal da rede, na qual vários computadores são armazenados
próximos uns dos outros. Em um único rack da rede, você pode encontrar um número variável
de computadores, de oito a 64, cada um conectado ao outro. Cada rack se conecta a outros
racks por meio de uma rede de cabos, criada pela interconexão dos racks não diretamente
entre si, mas a várias camadas de switches, que são dispositivos de rede de computadores
capazes de manipular e gerenciar com eficiência a troca de dados entre os racks, conforme
mostrado na Figura 13-2.

FIGURA 13-2:
Um esquema

que representa um
cluster de
computação.

Você pode encontrar todo esse hardware em qualquer loja de informática, mas é exatamente
o que torna a infraestrutura DFS viável. Teoricamente, você poderia encontrar um milhão ou
mais de computadores interconectados em uma rede. (Você pode ler sobre a versão do
Google desta configuração, com base nas estimativas disponíveis mais recentes, em https://www.
datacenterknowledge.com/archives/2017/03/16/google-data-center-faq.)
O interessante é que esses serviços aumentam o poder computacional quando necessário
adicionando mais computadores, não criando novas redes.

Nesse sistema, à medida que os dados chegam, o DFS os divide em partes (cada uma com
tamanho de até 64 MB). O DFS copia os fragmentos em várias duplicatas e, em seguida,
distribui cada cópia para um computador na rede. A ação de dividir os dados em pedaços,
duplicá-los e distribuí-los é bastante rápida, não importa como os dados estejam estruturados
(informações organizadas e ordenadas ou conjuntos confusos). O único requisito diz respeito
ao registro do endereço dos chunks no DFS, que é obtido por um índice para cada arquivo
(replicado e distribuído), chamado de nó mestre.
A velocidade de execução do DFS se deve ao modo como o DFS lida com os dados. Ao
contrário das técnicas de armazenamento anteriores (como data warehouses), um DFS não
requer nenhuma operação específica de classificação, ordenação ou limpeza dos dados em
si; pelo contrário, faz o seguinte:

254 PARTE 4 Lidando com Big Data


Machine Translated by Google

» Lida com dados de qualquer tamanho porque os dados são divididos em partes gerenciáveis

» Armazena novos dados empilhando-os ao lado dos dados antigos; um DFS nunca atualiza nenhum
dados recebidos anteriormente

» Replica os dados de forma redundante para que você não precise fazer backup; a duplicação é em si um
backup

Os computadores falham de várias maneiras: disco rígido, CPU, sistema de energia ou algum
outro componente. Estatisticamente, você pode esperar que um computador servindo em uma
rede funcione por cerca de 1.000 dias (cerca de três anos). (O tempo médio preciso entre
falhas, ou MTBF, é um pouco complicado de calcular porque existem muitas variáveis, mas o
artigo em https://www.trentonsystems.com/blog/how-to-calculate a-rackmount-computer -taxa
de falha fornece uma idéia do que está envolvido.) Consequentemente, um serviço com um
milhão de computadores pode esperar que 1.000 de seus computadores falhem todos os dias.
É por isso que o DFS espalha três ou mais cópias de seus dados dentro de vários computadores
na rede. A replicação reduz a probabilidade de perda de dados devido a uma falha. A
probabilidade de ocorrer uma falha que envolva apenas computadores onde o mesmo bloco de
dados está armazenado é de cerca de um em um bilhão (assumindo que o DFS replica os
dados três vezes), tornando esse um risco pequeno e aceitável.

Empregando a solução MapReduce


Embora os sistemas distribuídos armazenem dados rapidamente, a recuperação de
dados é muito mais lenta, principalmente ao realizar análises e aplicar algoritmos. O
mesmo tipo de problema ocorre quando você quebra um quebra-cabeça em pedaços e
espalha as peças (fácil). Você deve então pegar as peças e recriar a imagem original
(difícil e demorada). Ao trabalhar com dados em um DFS:

1. Obtenha o nó mestre e leia-o para determinar a localização das partes do arquivo.

2. Envie uma ordem de busca aos computadores na rede para obter os blocos de dados armazenados
anteriormente.

3. Reúna os fragmentos de dados armazenados em vários computadores em um único computador (se for
possível; alguns arquivos podem ser muito grandes para serem armazenados em uma única
máquina).

Obviamente, esse processo pode se tornar complexo, então os engenheiros de serviços


da Web decidiram que é melhor não recompor os arquivos antes de processá-los. Uma
solução mais inteligente é deixá-los em pedaços nos computadores de origem e deixar
que o computador host os processe. Apenas uma versão reduzida , que já está quase
totalmente processada, teria que se movimentar pela rede, limitando a transmissão de
dados. MapReduce é a solução que fornece os meios para processar algoritmos em
paralelo em um sistema distribuído de dados. Como um algoritmo em si, o MapReduce
consiste em apenas duas partes, map e reduce.

CAPÍTULO 13 Operações de Paralelização 255


Machine Translated by Google

USANDO UMA SOLUÇÃO DE PACOTE


PARA MAPREDUCE
Embora o livro demonstre como criar uma solução MapReduce do zero,
você não precisa reinventar a roda toda vez que quiser realizar essa tarefa.
Pacotes como MrJob (na verdade significa “Map Reduce Job”: https://mrjob.
readthedocs.io/en/latest/index.html) permitem que você teste e execute tarefas
MapReduce de maneira rápida e fácil em seu computador local. Além disso, usando
um pacote como MrJob, você pode facilitar a transmissão e execução da tarefa
usando recursos baseados em nuvem, como Amazon Web Services usando Elastic
MapReduce (EMR) (https://aws.amazon.com/ emr/) ou com o Hadoop (http://hadoop.apache.org/).
A questão é que você precisa saber como o algoritmo funciona, que é o objetivo deste
livro, mas ter que escrever todo o código necessário pode ser desnecessário na
situação certa.

Mapa explicativo
A primeira fase do algoritmo MapReduce é a parte do mapa , uma função encontrada em
muitas linguagens de programação funcionais (um estilo de programação que trata a
computação como uma função matemática). map() é direto: você começa com um array
unidimensional (que, em Python, pode ser uma lista) e uma função. Ao aplicar a função em
cada elemento do array, você obtém um array de formato idêntico cujos valores são
transformados. O exemplo a seguir contém uma lista de dez números que a função transforma
em seu equivalente em potência:

def mapper(divertido, *iter):


para i em zip(*iter):
rendimento divertido(*i)

L = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
m = lista(mapeador(lambda x: x**2, L))
print(m)def mapeador(divertido, *iter):
para i em zip(*iter):

A saída mostra que cada uma das saídas é o quadrado da entrada.

[0, 1, 4, 9, 16, 25, 36, 49, 64, 81]

A função mapper() aplica a função lambda do Python Uma função lambda é uma função
definida em tempo real (consulte Programação funcional para leigos, de John Paul Mueller
[Wiley] para obter ajuda no entendimento das funções lambda). No exemplo anterior, a função
lambda transforma cada elemento da lista inicial em um elemento resultante. A Figura 13-3
mostra o resultado desse processo de mapeamento.

256 PARTE 4 Lidando com Big Data


Machine Translated by Google

FIGURA 13-3:
Mapeamento de uma
lista de números
por uma função quadrada.

Observe que cada transformação de elemento de lista é independente das outras. Você pode
aplicar a função aos elementos da lista em qualquer ordem. (No entanto, você deve armazenar
o resultado na posição correta na matriz final.) A capacidade de processar os elementos da
lista em qualquer ordem cria um cenário que é naturalmente paralelizado sem nenhum
esforço específico.

Nem todos os problemas são naturalmente paralelos, e alguns nunca serão. No entanto,
algumas vezes você pode repensar ou redefinir seu problema para obter um conjunto de
cálculos com os quais o computador pode lidar de maneira paralela.

Explicando a redução
A segunda fase do algoritmo MapReduce é a parte de redução (há também uma etapa
intermediária, shuffle e sort, explicada na próxima seção, mas não é importante por enquanto,
a menos que você sinta vontade de dançar). Ao receber uma lista, o reduce aplica uma
função em uma sequência que acumula os resultados. Assim, ao usar uma função de soma,
o reduce aplica a soma a todos os elementos da lista de entrada. reduzir
pega os dois primeiros elementos do array e os combina. Em seguida, ele combina esse
resultado parcial com o próximo elemento do array e assim por diante até completar o array.

Você também pode fornecer um número inicial. Ao fornecer um número inicial, reduza os
inícios combinando o número inicial com o primeiro elemento da lista para obter o primeiro
resultado parcial. O exemplo a seguir usa o resultado da fase de mapeamento e o reduz
usando uma função de soma (conforme exibido na Figura 13-4):

def redutor (diversão, seq):


se len(seq)==1:
return seq[0]
senão:
return fun(reducer(fun, seq[:-1]), seq[-1])

L = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
m = lista(mapeador(lambda x: x**2, L))
r = redutor(lambda x, y: x+y, m)
print(r)defreducer(diversão,seq):

CAPÍTULO 13 Operações de Paralelização 257


Machine Translated by Google

se len(seq)==1:
return seq[0]
senão:
return fun(reducer(fun, seq[:-1]), seq[-1])

A saída é a soma dos números ao quadrado anteriores, como mostrado aqui:

285

FIGURA 13-4:
Reduzindo uma lista
de números para
sua soma.

A função reducer() opera no array de entrada como se fosse um fluxo de dados (como
discutido no Capítulo 12). Geralmente trabalha com um elemento de cada vez e acompanha
os resultados intermediários.

Operações de distribuição
Entre as fases map() e reduce() há uma fase intermediária, embaralhar e classificar. Assim que
uma tarefa de mapa é concluída, o host redireciona as tuplas resultantes de pares de chave e
valor para o computador correto na rede para aplicar o método reduce()
Estágio. Isso geralmente é feito agrupando pares de chaves correspondentes em uma
única lista e usando uma função de hash na chave de maneira semelhante aos filtros Bloom
(consulte o Capítulo 12). A saída é um endereço no cluster de computação para transferir
as listas.

Na outra extremidade da transmissão, o computador que executa o método reduce()


fase começa a receber listas de tuplas de uma ou várias chaves. Várias chaves ocorrem
quando ocorre uma colisão de hash, que acontece quando diferentes chaves resultam no
mesmo valor de hash, então elas acabam indo para o mesmo computador. O computador
que executa a fase de redução os classifica em listas contendo a mesma chave antes de
alimentar cada lista na fase de redução , conforme mostrado na Figura 13-5.

Conforme mostrado na figura, o MapReduce recebe várias entradas em cada computador


no cluster de computação onde estão armazenados, mapeia os dados e os transforma em
tuplas de pares de chave e valor. Organizados em listas, o host transmite essas tuplas para
outros computadores pela rede, onde os computadores receptores operam operações de
classificação e redução que levam a um resultado.

258 PARTE 4 Lidando com Big Data


Machine Translated by Google

FIGURA 13-5:
Uma visão
geral da
computação
MapReduce completa.

Trabalhando Algoritmos para MapReduce


Ao contrário de outros exemplos neste livro, você pode pensar no MapReduce mais como um
estilo de computação ou uma estrutura de Big Data do que um algoritmo. Como uma estrutura,
ele permite combinar diferentes algoritmos distribuídos (algoritmos paralelos que espalham os
cálculos em diferentes computadores) e permitir que eles trabalhem de forma eficiente e bem-
sucedida com grandes quantidades de dados. Você pode encontrar algoritmos MapReduce
em muitos aplicativos, e você pode ler sobre eles no wiki do Apache sobre o Hadoop, com
detalhes sobre a empresa que o utiliza, como ele é usado e em que tipo de cluster de
computação: https://cwiki. apache.org/confluence/
display/HADOOP2/PoweredBy. Embora as possibilidades sejam muitas, na maioria das vezes você
encontra o MapReduce usado para realizar essas tarefas:

» Algoritmos de texto para dividir o texto em elementos (tokens), criar índices,

e pesquisando palavras e frases relevantes


» Criação de gráficos e algoritmos de gráficos

» Mineração de dados e aprendizado de novos algoritmos a partir de dados (aprendizado de máquina)

Um dos usos mais comuns do algoritmo MapReduce é processar texto. O exemplo


nesta seção demonstra como resolver uma tarefa simples, contando certas palavras
em uma passagem de texto usando uma abordagem de mapear e reduzir e alavancar
multithreading ou multiprocessamento (dependendo do sistema operacional instalado
em seu computador).

A linguagem de programação Python não é a linguagem de computador ideal para


operações paralelas. Tecnicamente, devido a problemas de sincronização e acesso à
memória compartilhada, o interpretador Python não é seguro para threads, o que
significa que pode ocorrer erros ao executar aplicativos usando vários processos ou threads em

CAPÍTULO 13 Operações de Paralelização 259


Machine Translated by Google

vários núcleos. Consequentemente, o Python limita o multithreading a um único thread (o


código é distribuído, mas não ocorre aumento de desempenho), e o paralelismo multicore por
vários processos é realmente difícil de alcançar, especialmente em computadores que
executam o Windows. Você pode aprender mais sobre a diferença em threads e processos
lendo o artigo da Microsoft em https://docs.microsoft.com/
windows/win32/procthread/about-processes-and-threads.

Configurando uma simulação MapReduce


Este exemplo processa texto de domínio público, obtido da organização sem fins lucrativos
Project Gutenberg (https://www.gutenberg.org/) local. O primeiro texto processado é o
romance Guerra e Paz, de Leo Tolstoi (que também é conhecido por outros nomes em outras
línguas, como Lev Tolstoj). O código a seguir carrega os dados na memória:

da solicitação de importação de urllib

url = 'https://github.com/lmassaron/datasets/releases/'
url += 'baixar/1.0/2600.txt'
resposta = request.urlopen(url)
text = response.read().decode('utf-8')[627:]
de urllibimportrequest

url='https://github.com/lmassaron/datasets/releases/
download/1.0/2600.txt'
resposta=request.urlopen(url)
text=response.read().decode('utf-8')[627:]

imprimir (texto[:37])

Aqui está a saída que você vê:

GUERRA E PAZ

Por Leo Tolstoi/Tolstoi

Seja paciente! Carregar o livro leva tempo (apenas tente lê-lo em tão pouco tempo quanto o
computador). Quando concluído, o código exibe as primeiras linhas com o título. O código
armazena os dados na variável de texto . Parte do processo divide o texto em palavras e as
armazena em uma lista, conforme mostrado no código a seguir:

palavras = texto.split()
print('Número de palavras: %i' % len(palavras))

260 PARTE 4 Lidando com Big Data


Machine Translated by Google

A saída mostra que há muitas palavras no texto (os dedos de Tolstoy devem estar muito cansados):

Número de palavras: 566218

A variável words agora contém palavras individuais do livro. É hora de importar os pacotes Python
necessários e as funções para o exemplo usando o seguinte código:

importar SO
if os.name == "nt":
#Multithreading mais seguro no Windows
do pool de importação multiprocessing.dummy
senão:
#Multiprocessamento no Linux,Mac
do Pool de importação de multiprocessamento
de importação de multiprocessamento cpu_count
de functools importar parcial
seqüência de importação

Dependendo do seu sistema operacional, o exemplo depende de multiprocessamento ou


multithreading. O Windows usa multithreading, que divide a tarefa em vários threads processados
ao mesmo tempo pelo mesmo núcleo. Em sistemas Linux e Mac, o código é executado em
paralelo, e cada operação é realizada por um núcleo de computador diferente.

O código que vem a seguir conta as palavras de uma lista que corresponde a um conjunto de
palavras-chave. Depois de remover qualquer pontuação, o código compara as palavras e, se
encontrar alguma correspondência com uma palavra-chave, a função retorna uma tupla que
consiste em uma chave, a palavra-chave correspondente e um valor unitário, que é uma contagem.
Esta saída representa o núcleo do mapa MapReduce:

def remove_punctuation(texto):
return ''.join([l for l in text if l in
string.ascii_letters])

def count_words(list_of_words, palavras-chave):


resultados = lista()
para palavra em list_of_words:
para palavra-chave em palavras-chave:
if palavra-chave == remove_punctuation(
palavra.superior()):
resultados.append((palavra-chave,1))
retornar resultados

CAPÍTULO 13 Operações de Paralelização 261


Machine Translated by Google

As funções que seguem particionam os dados. Essa abordagem é semelhante à maneira como
um sistema distribuído particiona os dados. O código distribui a computação e reúne os resultados:

def Partição(dados, tamanho):


return [data[x:x+size] para x em range(0, len(data), size)]

def Distribute(função, dados, núcleos):


pool = Pool(núcleos)
resultados = pool.map(função, dados)
pool.close()
retornar resultados

Finalmente, as seguintes funções embaralham e ordenam os dados para reduzir os resultados.


Esta etapa representa as duas últimas fases de um trabalho MapReduce:

def Shuffle_Sort(L):
# Embaralhar

Mapeamento = dict()
para sublista em L:
para key_pair na sublista:
key, value = key_pair if key
in Mapping:
Mapping[key].append(key_pair)
senão:
Mapping[key] = [key_pair] return
[Mapping[key] for key in Mapping]

def Reduce(Mapping):
return (Mapping[0][0], sum([value for (key, value ) in Mapping]))

Consulta por mapeamento


O código a seguir simula um ambiente distribuído usando vários núcleos de processador. Ele
começa solicitando o número de núcleos disponíveis do sistema operacional. O número de
núcleos que você vê varia de acordo com o número de núcleos disponíveis em seu computador.
A maioria dos computadores modernos fornece quatro ou oito núcleos.

n = cpu_count()
print('Você tem %i núcleos disponíveis para MapReduce' % n)

262 PARTE 4 Lidando com Big Data


Machine Translated by Google

Nesse caso, a saída mostra quatro núcleos (sua saída pode mostrar um número diferente de
núcleos):

Você tem 4 núcleos disponíveis para MapReduce

Se estiver executando o código no Windows, por motivos técnicos, você trabalha com um único
núcleo, para não aproveitar o número total de núcleos disponíveis.
A simulação ainda parece funcionar, mas você não verá nenhum aumento na velocidade.

Para começar, o código primeiro define a operação do mapa. Ele então distribui a função map
para threads, cada uma das quais processa uma partição dos dados iniciais (a lista contendo
as palavras de War and Peace). A operação do mapa encontra as palavras paz, guerra (há
mais guerra ou paz em Guerra e Paz?), Napoleão e Rússia:

Mapa = parcial(count_words,
keywords=['GUERRA', 'PAZ', 'RÚSSIA',
'NAPOLEÃO'])
map_result = Distribute(Mapa,
Partição(
palavras,len(palavras)//n+1), n)
print('map_resultéuma lista feita de %i elementos' %
len(map_result))
print('Visualização de um elemento: %s]'% map_result[0][:5])

Depois de um tempo, o código imprime os resultados (o número de elementos mostrados pode


variar):

Mapa é uma lista feita de 4 elementos


Visualização de um elemento: [('WAR', 1), ('PEACE', 1), ('WAR', 1),
('GUERRA', 1), ('RÚSSIA', 1)]]

Nesse caso, a lista resultante contém quatro elementos porque o sistema host possui quatro
núcleos (você pode ver mais ou menos elementos, dependendo do número de núcleos em sua
máquina). Cada elemento na lista é outra lista contendo os resultados do mapeamento naquela
parte dos dados da palavra. Ao visualizar uma dessas listas, você pode ver que é uma
sequência de chaves acopladas (dependendo da palavra-chave encontrada) e valores de
unidade. As chaves não estão em ordem; eles aparecem na ordem em que o código os gerou.
Consequentemente, antes de passar as listas para a redução
Na fase de soma dos resultados totais, o código organiza as chaves em ordem e as envia para
o núcleo apropriado para reduzir:

Embaralhado = Shuffle_Sort(map_result)
print('Embaralhado é uma lista feita de %i elementos' %

CAPÍTULO 13 Operações de Paralelização 263


Machine Translated by Google

len(embaralhado))
print('Visualização do primeiro elemento: %s]'% Embaralhado[0][:5])
print('Visualização do segundo elemento: %s]'% Embaralhado[1][:5])

Conforme mostrado no exemplo, a função Shuffle_Sort cria uma lista de quatro listas, cada
uma contendo tuplas com uma das quatro palavras-chave (sua lista pode ser diferente da
mostrada):

Embaralhado é uma lista feita de 4 elementos


Visualização do primeiro elemento: [('RUSSIA', 1), ('RUSSIA', 1),
('RÚSSIA', 1), ('RÚSSIA', 1), ('RÚSSIA', 1)]]
Visualização do segundo elemento: [('NAPOLEON', 1), ('NAPOLEON',
1), ('NAPOLEON', 1), ('NAPOLEON', 1), ('NAPOLEON', 1)]]

Em uma configuração de cluster, esse processamento equivale a fazer com que cada nó de
mapeamento passe pelos resultados emitidos e, usando algum tipo de endereçamento (por
exemplo, usando uma função hash, como visto no vetor de bits de um filtro Bloom Capítulo
12), eles enviam (fase de embaralhamento) os dados da tupla para o nó de redução
apropriado. O nó receptor coloca cada chave na lista apropriada (fase de pedido):

resultado = Distribuir(Reduzir, Embaralhado, n)


print('Os resultados emitidos são: %s' % resultado)

A fase de redução soma as tuplas distribuídas e ordenadas e reporta a soma total para cada
chave, conforme resultado impresso pelo código que replica uma
MapReduce (sua saída pode ser diferente da mostrada):

Os resultados emitidos são: [('RUSSIA', 162), ('NAPOLEON', 475),


('GUERRA', 295), ('PAZ', 111)]

Lendo os resultados, você pode ver que Tolstoi menciona a guerra mais do que a paz em
Guerra e paz, mas ele menciona Napoleão com ainda mais frequência.

Você pode facilmente repetir o experimento em outros textos ou até mesmo hackear a
função do mapa para aplicar uma função diferente ao texto. Por exemplo, você pode optar
por analisar alguns dos romances mais famosos de Sir Arthur Conan Doyle e tentar descobrir
quantas vezes Sherlock Holmes usou a frase “Elementar, Watson”:

da solicitação de importação de urllib

url = 'https://github.com/lmassaron/datasets/releases/'
url += 'baixar/1.0/1661-0.txt'

264 PARTE 4 Lidando com Big Data


Machine Translated by Google

resposta = request.urlopen(url) text =


response.read().decode('utf-8')[932:] words = text.split()

print (text[:60]) print


('\nTotal de palavras são %i' % len(palavras))

Mapa = parcial(count_words,
keywords=['WATSON', 'ELEMENTARY'])
resultado = Distribute(Reduzir,
Shuffle_Sort(Distribute(Mapa,
Partition(words,len(words)//n), n)), 1) print('Os
resultados emitidos são: %s'% resultado)

O resultado pode ser surpreendente!

As Aventuras de Sherlock Holmes

por Arthur Conan Doyle

O total de palavras é 107411

Os resultados emitidos são: [('WATSON', 81), ('ELEMENTARY', 1)]

Você nunca encontra essa frase nos romances; é um bordão que os autores inseriram
posteriormente nos roteiros dos filmes: https://www.phrases.org.uk/significados/elementary-my-
dear-watson.html .

CAPÍTULO 13 Operações de Paralelização 265


Machine Translated by Google
Machine Translated by Google

NESTE CAPÍTULO

» Criando codificações eficientes


e inteligentes

» Aproveitando as estatísticas e construindo


Árvores de Huffman

» Compactando e descompactando
usando o algoritmo Lempel-Ziv-Welch
(LZW)

» Executando tarefas de criptografia

Capítulo 14

Compressão e
Ocultando dados
petróleo, e especialistas de todos os tipos esperam extrair novos conhecimentos e riquezas
A última década
a partir viu resultado,
dele. Como o mundo inundado
você porempilhados
encontra dados dados.em Natodos
verdade,
os lugaresos dados são
e geralmente o novo
são arquivados
assim que chegar. A primeira parte deste capítulo se concentra no uso de várias técnicas
de compactação para armazenar dados com eficiência. Ele é dividido em duas subseções
principais: técnicas de codificação e compactação de dados. A discussão na primeira parte
começa com técnicas de codificação.

A primeira parte também discute a compactação de dados em uma segunda subseção. Os algoritmos
de compactação de dados oferecem a solução de compactar dados para armazenar mais dados em
um único dispositivo ao custo do tempo de processamento do computador. A troca de espaço em disco
por tempo de computador reduz os custos. A compactação também é útil em situações em que o
crescimento da infraestrutura de dados não corresponde ao crescimento dos dados, o que é
especialmente verdadeiro para a largura de banda móvel e sem fio nos países em desenvolvimento.
Além disso, a compactação ajuda a fornecer páginas da Web complexas com mais rapidez, transmitir
vídeos com eficiência, armazenar dados em um dispositivo móvel ou reduzir os custos de transmissão
de dados de telefones celulares. A discussão sobre compactação de dados baseia-se em duas técnicas
de compactação populares: codificação Huffman e o algoritmo Lempel-Ziv-Welch (LZW).

CAPÍTULO 14 Compactando e Ocultando Dados 267


Machine Translated by Google

A segunda parte deste capítulo discute o uso da codificação para outro propósito, a
criptografia. Tornar os dados ilegíveis para aqueles que não deveriam vê-los tem uma
longa história. Até os antigos egípcios usavam criptografia, conforme explicado em http://www.
cs.trincoll.edu/~crypto/historical/intro.html. É claro que as cifras modernas são muito mais
complicadas do que esses métodos antigos.

Você não precisa digitar o código-fonte para este capítulo manualmente. Na verdade, usar
a fonte para download é muito mais fácil. Você pode encontrar a fonte deste capítulo no
A4D2E; 14; Compressão.ipynb e A4D2E; 14; Arquivos Cryptography.ipynb da fonte para
download. Consulte a Introdução para obter detalhes sobre como localizar esse arquivo de
origem.

Tornando os dados menores


Os dados do computador são feitos de bits — sequências de zeros e uns. Este capítulo
explica o uso de zeros e uns para criar dados com mais profundidade do que os capítulos
anteriores, pois a compactação aproveita esses zeros e uns de várias maneiras. Para
entender a compactação, você deve saber como um computador cria e armazena números
binários. As seções a seguir discutem o uso de números binários em computadores.

Entendendo a codificação
Zeros e uns são os únicos números no sistema binário. Eles representam os dois estados
possíveis em um circuito elétrico: ausência e presença de eletricidade. Os computadores
começaram como circuitos simples feitos de tubos ou transistores; usar o sistema binário
em vez do sistema decimal humano facilitou as coisas. Os humanos usam dez dedos para
contar números de 0 a 9. Quando precisam contar mais, adicionam um número de unidade
à esquerda. Você pode nunca ter pensado nisso, mas pode expressar a contagem usando
potências de dez. Portanto, um número como 199 pode ser expresso como 102 * 1 + 101 *
9 + 100 * 9 = 199;cada
ou seja, você
algarismopode
pelaseparar
unidades; paracentenas
potência
101 de
de dez em
dezenas; dezenas
relação
102 à esua
unidades multiplicando
posição:
para centenas; 100por
e assim para
diante.

Conhecer essas informações ajuda a entender melhor os números binários porque eles
funcionam exatamente da mesma maneira. No entanto, os números binários usam
potências de dois em vez de potências de dez. Por exemplo, o número 11000111 é simplesmente

27*1+26*1+25*0+24*0+23*0+22*1+21*1+20*1 =
128*1+64*1+32*0+16*0+8*0+4*1+2*1+1*1 =
128+64+4+2+1 = 199

268 PARTE 4 Lidando com Big Data


Machine Translated by Google

Você pode representar qualquer número como um valor binário em um computador. Um valor
ocupa o espaço de memória exigido pelo seu comprimento total. Por exemplo, o binário 199
tem 8 figuras, cada figura é um bit e 8 bits são chamados de byte. O hardware do computador
conhece os dados apenas como bits porque os circuitos podem armazenar apenas bits. No
entanto, de um ponto de vista mais elevado, o software de computador pode interpretar bits
como letras, ideogramas, imagens, filmes e sons, que é onde a codificação entra em ação.

A codificação usa uma sequência de bits para representar algo diferente do número expresso
pela própria sequência. Por exemplo, você pode representar uma letra usando uma determinada
sequência de bits. O software de computador geralmente representa a letra A
usando o número 65, ou binário 01000001 ao trabalhar com o padrão de codificação American
Standard Code for Information Interchange (ASCII). Você pode ver as sequências usadas pelo
sistema ASCII em https://www.asciitable.com/. ASCII usa apenas 7 bits para sua codificação
(8 bits, ou um byte, na versão estendida), o que significa que você pode representar 128
caracteres diferentes (a versão estendida tem 256 caracteres). Python pode representar a
string “Hello World” usando bytes:

print(''.join(['{0:08b}'.format(ord(l)))
para l em "Hello World"]))

Este exemplo fornece a seguinte saída:

01001000011001010110110001101100011011110010000001010111
01101111011100100110110001100100

Ao usar ASCII estendido, um computador sabe que uma sequência de exatamente 8 bits
representa um caractere. Ele pode separar cada sequência em bytes de 8 bits e, usando uma
tabela de conversão chamada tabela simbólica, pode transformar esses bytes em caracteres.

A codificação ASCII pode representar o alfabeto ocidental padrão, mas não suporta a
variedade de caracteres europeus acentuados ou a riqueza de alfabetos não europeus, como
os ideogramas usados pelos idiomas chinês e japonês.
É provável que você esteja usando um sistema de codificação robusto, como UTF-8 ou outra
forma de codificação Unicode (consulte https://home.unicode.org/ Para maiores informações).
A codificação Unicode é a codificação padrão no Python 3.

O uso de um sistema de codificação complexo requer o uso de sequências mais longas do que
as exigidas pelo ASCII. Dependendo da codificação escolhida, a definição de um caractere
pode exigir até 4 bytes (32 bits). Ao representar informações textuais, um computador cria
longas sequências de bits. Ele decodifica cada letra facilmente porque a codificação usa
sequências de comprimento fixo em um único arquivo. Estratégias de codificação, como
Unicode Transformation Format 8 (UTF-8), podem usar números variáveis de bytes (1 a 4
neste caso). Você pode ler mais sobre como o UTF-8 funciona em https://www.
fileformat.info/info/unicode/utf8.htm.

CAPÍTULO 14 Comprimindo e Ocultando Dados 269


Machine Translated by Google

Considerando os efeitos da compressão


O uso de sequências de caracteres de tamanho fixo deixa muito espaço para melhorias.
Você pode não usar todas as letras de um alfabeto ou usar algumas letras mais do que outras. É aqui
que a compressão entra em ação. Usando sequências de caracteres de comprimento variável, você pode
reduzir bastante o tamanho de um arquivo. No entanto, o arquivo também requer processamento adicional
para transformá-lo novamente em um formato descompactado que os aplicativos entendam. A compressão
remove o espaço de forma organizada e metódica; descompressão adiciona o espaço de volta às cadeias
de caracteres.
É como aqueles que adicionam bebidas de água que você compra na loja: primeiro era uma bebida,
depois um pó e depois uma bebida novamente. Quando é possível compactar e descompactar dados de
impressão de uma maneira que não resulte em perda de dados, você está usando sem perdas
compressão.

A mesma ideia por trás da compressão vale para imagens e sons que envolvem sequências de frames
de bits de um determinado tamanho para representar detalhes de vídeo ou para reproduzir um segundo
de um som usando os alto-falantes do computador. Vídeos são simplesmente sequências de bits, e cada
sequência de bits é interpretada pelo software como um pixel, que é composto por pequenos pontos que
constituem uma imagem. Da mesma forma, o áudio é composto por sequências de bits que representam
uma amostra individual. Os arquivos de áudio armazenam um certo número de amostras por segundo
para recriar um som. As discussões em https://www.wowza.
com/blog/video-codecs-encoding e https://developer.mozilla.org/docs/
Web/Mídia/Formatos/Audio_codecs fornecer mais informações sobre armazenamento de vídeo e áudio.
Os computadores armazenam dados em muitos formatos predefinidos de longas sequências de bits
(comumente chamados de fluxos de bits). Os algoritmos de compactação podem explorar a maneira
como cada formato funciona para obter o mesmo resultado usando um formato mais curto e personalizado.

Você pode compactar ainda mais os dados que representam imagens e sons eliminando detalhes que
não podem ser processados. Os humanos têm limites visuais e auditivos, portanto, provavelmente não
perceberão a perda de detalhes imposta pela compactação dos dados de maneiras específicas. Você já
deve ter ouvido falar da compactação MP3 que permite armazenar coleções inteiras de CDs em seu
computador ou em um leitor portátil. O formato de arquivo MP3 simplifica o formato WAV complicado
original usado por computadores. Os arquivos WAV contêm todas as ondas sonoras recebidas pelo
computador, mas o MP3 economiza espaço removendo e compactando as ondas que você não consegue
ouvir. (Para obter mais informações sobre MP3, consulte o artigo em https://arstechnica.com/features/
2007/10/
the-audiofile-compreensão-mp3-compression/).

A remoção de detalhes dos dados cria compactação com perdas . JPEG, DjVu, MPEG, MP3 e WMA são
todos algoritmos de compressão com perdas especializados em um tipo específico de dados de mídia
(imagens, vídeo, som), e há muitos outros. A compactação com perdas é boa para dados destinados à
entrada humana; no entanto, ao remover os detalhes, você não pode reverter para a estrutura de dados
original. Assim, você pode obter uma boa foto digital

270 PARTE 4 Lidando com Big Data


Machine Translated by Google

compressão e representá-lo de forma útil na tela de um computador. No entanto, ao imprimir


a foto compactada em papel, você pode notar que a qualidade, embora aceitável, não é tão
boa quanto a da foto original. A tela fornece saída de 96 pontos por polegada (dpi), mas uma
impressora normalmente fornece saída de 300 a 1200 dpi (ou superior). Os efeitos da
compactação com perdas tornam-se óbvios porque uma impressora é capaz de exibi-los de
uma maneira que os humanos podem ver.

Escolher entre compressão sem perdas e com perdas é importante. Descartar detalhes é uma
boa estratégia para mídia, mas não funciona tão bem com texto, pois perder palavras ou letras
pode alterar o significado do texto. (Descartar detalhes não funciona para linguagens de
programação ou instruções de computador pelo mesmo motivo.)
Embora a compactação com perdas seja uma solução de compactação eficaz quando os
detalhes não são tão importantes, ela não funciona em situações em que o significado preciso
deve ser mantido.

Escolhendo um tipo específico de compressão


Algoritmos sem perdas simplesmente compactam os dados para reduzir seu tamanho e
descompactá-los para seu estado original. Os algoritmos sem perdas têm aplicações mais
gerais do que a compactação com perdas porque você pode usá-los para qualquer problema
de dados. (Mesmo ao usar a compactação com perdas, você remove alguns detalhes e
compacta ainda mais o que resta usando a compactação sem perdas.) explorando algumas
características dos dados. (Para ter uma ideia de quão grande é a família de algoritmos sem
perdas, leia mais detalhes em http://ethw.org/

History_of_Lossless_Data_Compression_Algorithms.)

UM EXEMPLO DE COMPRESSÃO COM PERDA


BENEFÍCIOS

Um exemplo da diferença que a compressão com perdas pode fazer está na fotografia. Um arquivo de imagem
em formato raw contém todas as informações fornecidas originalmente pelo sensor da câmera, portanto, não
inclui nenhum tipo de compactação. Ao trabalhar com uma determinada era de câmera, você pode descobrir
que esse arquivo consome 29,8 MB de espaço no disco rígido. Um arquivo bruto geralmente usa a extensão
de arquivo .raw para mostrar que nenhum processamento ocorreu. Abrir o arquivo e salvá-lo como um .jpeg
com perdas pode resultar em um tamanho de arquivo de apenas 3,7 MB, mas com a correspondente perda
de detalhes. Para salvar alguns detalhes, mas também economizar no tamanho do arquivo, você pode optar
por usar o formato de arquivo .jpeg com menos compactação. Nesse caso, o tamanho do arquivo pode ser de
12,4 MB, o que representa um bom compromisso na economia de tamanho do arquivo em relação à perda de
dados de imagem.

CAPÍTULO 14 Comprimindo e Ocultando Dados 271


Machine Translated by Google

É essencial lembrar que o objetivo da compactação com e sem perdas é reduzir a redundância
contida nos dados. Quanto mais redundâncias os dados contiverem, mais eficaz será a
compactação.

É provável que você tenha muitos programas de compactação de dados sem perdas instalados
em seu computador que produzem arquivos como ZIP, LHA, 7-Zip e RAR, e você não tem
certeza de qual é o melhor. Uma opção “melhor” pode não existir, porque você pode usar
sequências de bits de muitas maneiras diferentes para representar informações em um
computador; além disso, diferentes estratégias de compressão funcionam melhor com diferentes
sequências de bits. Este é o problema sem almoço grátis discutido no Capítulo 1. A opção que
você escolhe depende do conteúdo de dados que você precisa compactar.

Para ver como a compactação varia de acordo com a amostra fornecida, tente várias amostras
de texto usando o mesmo algoritmo. O exemplo de Python a seguir usa o algoritmo ZIP para
compactar o texto de The Adventures of Sherlock Holmes, de Arthur Conan Doyle, e depois
reduzir o tamanho de uma sequência de letras gerada aleatoriamente. (Você pode encontrar o
código completo para este exemplo na seção Desempenhos de compactação do arquivo A4D2E;
14; Compression.ipynb do código-fonte para download deste livro; consulte a Introdução para
obter detalhes.)

da solicitação de importação de urllib


importar zlib
de importação aleatória randint

url = 'https://github.com/lmassaron/datasets/releases/'
url += 'baixar/1.0/1661-0.txt'
resposta = request.urlopen(url)
sh = response.read().decode('utf-8')[932:]
sh_length = len(sh)
rnd = ''.join([chr(randint(0, 126)) para k em
intervalo(sh_length)])

def zipado(texto):
return len(zlib.compress(text.encode("utf-8'")))

print ("Tamanho original para ambos os textos: %s caracteres" %


sh_length)
print ("As Aventuras de Sherlock Holmes para %s" %
zipado(sh))
print ("Arquivo aleatório para %s " % zipado(rnd))

A saída do exemplo é esclarecedora. Embora o aplicativo de exemplo possa reduzir o tamanho


do conto para menos da metade de seu tamanho original, o tamanho

272 PARTE 4 Lidando com Big Data


Machine Translated by Google

redução para o texto aleatório é muito menor (ambos os textos têm o mesmo tamanho
original).

Tamanho original para ambos os textos: 592905 caracteres


As Aventuras de Sherlock Holmes para 227478
Arquivo aleatório para 519679

A saída implica que o algoritmo ZIP aproveita as características do texto escrito, mas não
funciona tão bem em texto aleatório que não possui uma estrutura previsível.

Ao realizar a compactação de dados, você pode medir o desempenho calculando a taxa


de compactação: basta dividir o novo tamanho compactado do arquivo pelo tamanho
original do arquivo. A taxa de compactação pode informar sobre a eficiência do algoritmo
na economia de espaço, mas os algoritmos de alto desempenho também exigem tempo
para executar a tarefa. Caso o tempo seja sua preocupação, a maioria dos algoritmos
permite que você troque alguma taxa de compactação por compactação e descompactação
mais rápidas. No exemplo anterior para o texto de Sherlock Holmes , a taxa de
compactação é 226824/594941; ou seja, cerca de 0,381. O método compress() encontrado
no exemplo tem um segundo parâmetro opcional, level, que controla o nível de
compactação. A alteração desse parâmetro controla a proporção entre o tempo de
execução da tarefa e a quantidade de compactação alcançada.

Escolhendo sua codificação com sabedoria


O exemplo na seção anterior mostra o que acontece quando você aplica o algoritmo ZIP
ao texto aleatório. Os resultados ajudam você a entender por que a compactação
funciona. Resumindo todos os algoritmos de compressão disponíveis, você descobre
quatro razões principais:

» Encolhimento da codificação de caracteres: a compactação força os caracteres a usar


menos bits codificando-os de acordo com algum recurso, como uso comum.
Por exemplo, se você usar apenas alguns dos caracteres em um conjunto de caracteres,
poderá reduzir o número de bits para refletir esse nível de uso. É a mesma diferença que
ocorre entre ASCII, que usa 7 bits, e ASCII estendido, que usa 8 bits. Essa solução é
particularmente eficaz com problemas como codificação de DNA, em que você pode criar
uma codificação melhor que a padrão.

» Encolhimento de longas sequências de bits idênticos: A compactação usa um


código especial para identificar várias cópias dos mesmos bits e substitui essas
cópias por apenas uma cópia, juntamente com o número de vezes para repeti-la. Esta
opção é muito eficaz com imagens (funciona bem com imagens em preto e branco de
fax) ou com quaisquer dados que você possa reorganizar para agrupar caracteres
semelhantes (os dados de DNA são desse tipo).

CAPÍTULO 14 Comprimindo e Ocultando Dados 273


Machine Translated by Google

» Aproveitando as estatísticas: a compactação codifica os caracteres usados com frequência


usando uma sequência numérica mais curta do que os caracteres usados com menos
frequência, que usam uma sequência numérica mais longa. Por exemplo, a letra E aparece
comumente em inglês, portanto, se a letra E usar apenas 3 bits, em vez de 8 bits completos,
você economizará um espaço considerável. Essa é a estratégia utilizada pela codificação
Huffman, na qual você recria a tabela simbólica e economiza espaço, em média, pois
caracteres comuns possuem codificações mais curtas.

» Codificar sequências longas frequentes de caracteres com eficiência: é semelhante à


redução de sequências longas de bits idênticos, mas funciona com sequências de caracteres
em vez de caracteres únicos. Essa é a estratégia usada pelo LZW, que aprende padrões de
dados em tempo real e cria uma codificação curta para longas sequências de caracteres.

Para entender como repensar a codificação pode ajudar na compactação, comece com o
primeiro motivo. Cientistas trabalhando no Projeto Genoma por volta de 2008 (https://
www.genome.gov/human-genome-project) conseguiram reduzir drasticamente o tamanho de seus dados
usando um simples truque de codificação. Usar esse truque simplificou a tarefa de mapear todo o DNA
humano, ajudando os cientistas a entender mais sobre a vida, a doença e a morte escritas em nossas
células do corpo.

Os cientistas descrevem o DNA usando sequências das letras A, C, T e G (representando os


quatro nucleotídeos presentes em todos os seres vivos). O genoma humano contém seis
bilhões de nucleotídeos (você os encontra associados em pares, chamados bases) que somam
mais de 50 GB usando a codificação ASCII. Na verdade, você pode representar A, C, T e G
na codificação ASCII da seguinte forma:

print(' '.join(['{0:08b}'.format(ord(l)))
para l em "ACTG"]))

01000001 01000011 01010100 01000111

A soma da linha anterior é de 32 bits, mas como o DNA mapeia apenas quatro caracteres,
você pode usar 2 bits cada, economizando 75% dos bits usados anteriormente:

00 01 10 11

Tal ganho demonstra a razão para escolher a codificação correta. A codificação funciona bem
neste caso porque o alfabeto do DNA é feito de quatro letras, e usar uma tabela ASCII
completa baseada em 8 bits é um exagero. Se um problema exigir que você use o alfabeto
ASCII completo, não será possível compactar os dados redefinindo a codificação usada. Em
vez disso, você deve abordar o problema usando a compactação Huffman.

274 PARTE 4 Lidando com Big Data


Machine Translated by Google

Se você não conseguir reduzir a codificação de caracteres (ou já o fez), ainda poderá reduzir
sequências longas, reduzindo-as a uma codificação mais simples. Observe como os dados
binários podem repetir longas sequências de uns e zeros:

00000000 00000000 01111111 11111111 10000011 11111111

Nesse caso, a sequência começa do zero. Você pode, portanto, contar o número de zeros,
e então contar o número de uns que se seguem, e então repetir com a próxima contagem de
zeros, e assim por diante. Como a sequência tem apenas zeros e uns, você pode contá-los
e obter uma sequência de contagens para compactar os dados. Nesse caso, os dados são
compactados em valores de 17 15 5 10. A tradução dessas contagens em bytes reduz os
dados iniciais de maneira facilmente reversível:

00010001 00001111 00000101 00001010

Em vez de usar 6 bytes para representar os dados, agora você precisa de apenas 4 bytes.
Para usar essa abordagem, você limita a contagem máxima a 255 valores consecutivos, o que
significa:

» Você pode codificar cada sequência em um byte.

» O primeiro valor é zero quando a sequência começa em 1 em vez de 0.

» Quando um bloco de valores tem mais de 255 elementos, você insere um valor 0 (portanto
o descodificador muda para o outro valor para 0 contagens e, em seguida, recomeça a
contar o primeiro valor).

Esse algoritmo, run-length encoding (RLE), é muito eficaz se seus dados tiverem muitas
repetições longas. Esse algoritmo teve grande sucesso na década de 1980 porque podia
reduzir os tempos de transmissão de fax. As máquinas de fax trabalhavam apenas com
imagens em preto e branco e por telefone fixo, portanto, encolher as longas sequências de
zeros e uns que compunham imagens e texto provou ser conveniente. Embora as empresas
raramente usem máquinas de fax agora, os cientistas ainda usam RLE para compressão de
DNA em combinação com o Burrows-Wheeler Transform (um algoritmo avançado que você
pode ler em https://marknelson.us/posts/1996/09/01/
bwt.html), que reorganiza (de forma reversível) a sequência do genoma em longas
sequências do mesmo nucleotídeo. Você também encontra RLE usado para compactação
de outros formatos de dados, como JPEG e MPEG (consulte https://motorscript.com/mpeg-
jpeg compression/ para detalhes adicionais).

As características dos dados determinam o sucesso de um algoritmo de compactação. Ao


saber como os algoritmos funcionam e explorar as características dos seus dados, você
pode escolher o algoritmo de melhor desempenho ou combinar mais algoritmos de maneira
eficaz. O uso de vários algoritmos juntos cria um conjunto de algoritmos.

CAPÍTULO 14 Compactando e Ocultando Dados 275


Machine Translated by Google

Codificação usando compressão Huffman


Redefinir uma codificação, como ao mapear nucleotídeos no DNA, é uma jogada inteligente
que funciona apenas quando você usa uma parte do alfabeto que a codificação representa.
Quando você usa todos os símbolos na codificação, não pode usar esta solução.
David A. Huffman descobriu outra maneira de codificar letras, números e símbolos de forma
eficiente, mesmo usando todos eles. Ele alcançou essa conquista quando era aluno do MIT
em 1952, como parte de um trabalho de conclusão de curso exigido por seu professor, Prof.
Robert M. Fano. Seu professor e outro cientista famoso, Claude Shannon (o pai da teoria da
informação), lutaram com o mesmo problema.

Em seu artigo, “Um método para a construção de códigos de redundância mínima”,


Huffman descreve em apenas três páginas seu método de codificação alucinante. Mudou a
forma como armazenávamos dados até o final dos anos 1990. Você pode ler os detalhes
sobre esse algoritmo incrível em um artigo da Scientific American de setembro de 1991 em
http://www.huffmancoding.com/my-uncle/scientific-american. Os códigos de Huffman têm três
ideias principais:

» Codifique símbolos frequentes com sequências de bits mais curtas. Por exemplo, se o seu
texto usa a letra a com frequência, mas raramente usa a letra z, você pode codificar um
usando alguns bits e reserve um byte inteiro (ou mais) para z. Usar sequências mais curtas
para letras comuns significa que, em geral, seu texto requer menos bytes do que quando você
confia na codificação ASCII.

» Codifique sequências mais curtas usando uma série exclusiva de bits. Ao usar
sequências de bits de comprimento variável, você deve garantir que não possa interpretar
erroneamente uma sequência mais curta no lugar de uma mais longa porque elas são
semelhantes. Por exemplo, se a letra a em binário for 110 e z for 110110, você poderá interpretar
erroneamente a letra z como uma série de caracteres a de duas letras . A codificação de Huffman
evita esse problema usando códigos sem prefixo: O algoritmo nunca reutiliza sequências mais
curtas como partes iniciais de sequências mais longas. Por exemplo, se a é 110, então z
será 101110 e não 110110.

» Gerencie a codificação sem prefixo usando uma estratégia específica. Codificação de Huffman
gerencia códigos sem prefixo usando árvores binárias de maneira inteligente. Árvores
binárias são uma estrutura de dados discutida nos Capítulos 6 e 7. O algoritmo de
Huffman usa árvores binárias (chamadas árvores de Huffman) de forma avançada. Você
pode ler mais sobre os componentes internos do algoritmo no tutorial em https://www.
tutorialspoint.com/huffman-trees-in-data-structure.

O algoritmo usado para realizar a codificação de Huffman usa um processo iterativo que
depende de heaps, que são estruturas de dados especializadas baseadas em árvore
(mencionadas no Capítulo 6). Um heap é uma estrutura de dados complexa. Devido à maneira
como você usa um heap para organizar os dados, é útil para alcançar uma estratégia gananciosa . No

276 PARTE 4 Lidando com Big Data


Machine Translated by Google

No próximo capítulo, que é dedicado a algoritmos gananciosos, você mesmo testa a codificação de
Huffman, usando os exemplos de trabalho no código para download que acompanha o livro (o
exemplo de compressão de Huffman no A4D2E; 15; Greedy Algorithms.
arquivo ipynb ; veja a Introdução para detalhes sobre onde arquivar este arquivo).

No momento, como exemplo de uma saída de codificação de Huffman, a Figura 14-1 mostra a
árvore binária de codificação de Huffman usada para codificar uma longa sequência de letras
ABCDE distribuídas de forma que A seja mais frequente que B, B mais que C, C mais do que D,
e D mais do que E.

FIGURA 14-1:
Uma árvore de
Huffman e sua
tabela
simbólica de conversão.

Os nós quadrados representam nós de ramificação, onde o algoritmo coloca o número de letras
restantes que ele distribui para os nós filhos (aqueles que estão abaixo dos nós de ramificação na
hierarquia). Os nós redondos representam nós folha, onde você encontra as letras codificadas com
sucesso. A árvore começa na raiz com 300 letras restantes para distribuir (o comprimento do texto).
Ele distribui as letras ramificando os bits 0 e 1, respectivamente, nos ramos esquerdo e direito até
atingir todas as folhas necessárias para a codificação. Ao ler a partir do topo da sequência de
ramificações até uma letra específica, você determina a sequência binária que representa essa letra.
Letras menos frequentes (D e E) obtêm as sequências binárias mais longas.

Seguir a árvore de Huffman de baixo para cima permite compactar um símbolo em uma sequência
binária. Seguindo a árvore de cima para baixo, você pode descomprimir uma sequência binária em
um símbolo (como representado pelo primeiro nó folha que você encontra).

Para descompressão, você precisa armazenar a sequência binária compactada e a árvore de


Huffman que tornou a compactação possível. Quando seu texto ou dados são muito curtos, a árvore
de Huffman pode exigir mais espaço do que os dados compactados, tornando a compactação
ineficaz. O código Huffman funciona melhor em arquivos de dados maiores.

CAPÍTULO 14 Comprimindo e Ocultando Dados 277


Machine Translated by Google

Lembrar sequências com LZW


A codificação Huffman aproveita os caracteres, números ou símbolos mais frequentes nos dados e reduz
sua codificação. O algoritmo LZW realiza uma tarefa semelhante, mas estende o processo de codificação
para as sequências de caracteres mais frequentes. O algoritmo LZW data de 1984 e foi criado por
Abraham Lempel, Jacob Ziv e Terry Welch com base em um algoritmo LZ78 anterior (desenvolvido em
1978 por Lempel e Ziv sozinhos). Tanto a compactação Unix quanto o formato de imagem GIF contam
com esse algoritmo. O LZW aproveita as repetições, por isso também é ideal para compactação de texto
de documentos e livros, porque os humanos geralmente usam as mesmas palavras ao escrever.

Além disso, o LZW pode operar em streaming de dados, mas o Huffman não; Huffman precisa do conjunto
de dados completo para construir sua tabela de mapeamento.

À medida que o algoritmo percorre o fluxo de bits de dados, ele aprende sequências de caracteres e
atribui cada sequência a um código curto. Assim, ao reencontrar posteriormente a mesma série de
caracteres, o LZW pode comprimi-los usando uma codificação mais simples. Curiosamente, esse algoritmo
começa a partir de uma tabela simbólica feita de caracteres únicos (geralmente a tabela ASCII) e, em
seguida, amplia essa tabela usando as sequências de caracteres que ele aprende com os dados
compactados.

Além disso, o LZW não precisa armazenar as sequências aprendidas em uma tabela para descompressão;
ele pode reconstruí-los facilmente lendo os dados compactados. O LZW pode reconstruir as etapas
realizadas ao compactar os dados originais e as sequências codificadas. Essa capacidade tem um preço:
o LZW não é eficiente no início. Funciona melhor com grandes pedaços de dados ou texto (uma
característica comum a outros algoritmos de compressão).

LZW não é um algoritmo complexo, mas você precisa ver vários exemplos para entendê-
lo completamente. Você pode encontrar alguns bons tutoriais em https://marknelson.us/
posts/2011/11/08/lzw-revisited.html e http://www.matthewflickinger.
com/lab/whatsinagif/lzw_image_data.asp. O segundo tutorial explica como usar o LZW para compactar
imagens. O exemplo a seguir mostra uma implementação do Python. (Você pode encontrar o código
completo para este exemplo na seção LZW do arquivo A4D2E; 14; Compression.ipynb do código-fonte
para download deste livro; consulte a Introdução para obter detalhes).

def lzw_compress(texto):
dicionário = {chr(k): k para k no intervalo(256)}
codificado = lista()
s = texto[0]
para c no texto[1:]:
se s+c no dicionário:
s = s+c

278 PARTE 4 Lidando com Big Data


Machine Translated by Google

senão:
print ('> %s' %s)
codificado.append(dicionário[s]) print
('found: %s compactado como %s' %
(s,dicionário[s]))
dicionário[s+c] = max(dicionário.valores()) + 1 print ('Nova
sequência %s indexada como %s' % (s+c, dicionário[s+c] ))

s=c
codificado.append(dicionário[s]) print
('found: %s compactado como %s' %(s,dictionary[s]))

retorno codificado

Neste exemplo, o algoritmo verifica o texto verificando o texto um caractere por vez. Ele começa
codificando caracteres usando a tabela simbólica inicial, que é na verdade a tabela ASCII neste
caso. A melhor maneira de ver como esse código funciona é ver uma série de mensagens de
saída e analisar o que aconteceu, conforme mostrado aqui:

text = "ABABCABCABC"
comprimido = lzw_compress(texto)
print('\nComprimido: %s \n' % comprimido)

Como a saída mostra, os dados realmente são compactados:

>A
encontrado: A comprimido como 65
Nova sequência AB indexada como 256
>B
encontrado: B comprimido como 66
Nova sequência BA indexada como 257
> AB
encontrado: AB comprimido como 256
Nova sequência ABC indexada como 258
>C
encontrado: C comprimido como 67
Nova sequência CA indexada como 259
> ABC
encontrado: ABC compactado como 258
Nova sequência ABCA indexada como 260
encontrada: ABC compactada como 258

CAPÍTULO 14 Compactando e Ocultando Dados 279


Machine Translated by Google

Aqui está uma breve sinopse do que essas mensagens de saída significam:

1. A primeira letra, A, aparece na tabela simbólica inicial, então o algoritmo codifica


isso como 65.

2. A segunda letra, B, é diferente de A , mas também aparece no símbolo inicial


tabela, então o algoritmo a codifica como 66.

3. A terceira letra é outro A, então o algoritmo lê a próxima letra, que é um B,


e codifica a combinação de duas letras, AB, como 256.

4. A quarta letra, um C, é diferente de qualquer uma das letras anteriores e também aparece
na tabela simbólica inicial, então o algoritmo a codifica como 67.

5. A próxima carta já apareceu antes; é um A. A próxima letra é um B,


que faz a combinação de letras AB ; isso também aparece na tabela simbólica.
No entanto, a próxima letra é um C, que faz uma nova sequência e que o algoritmo
agora codifica como 258.

6. As três letras finais são outro conjunto de ABC, então o código para elas é 258 novamente.
Consequentemente, a saída codificada para ABABCABCABC é

Comprimido: [65, 66, 256, 67, 258, 258]

Todas as operações de aprendizado e codificação se traduzem em dados de compactação finais


que consistem em apenas seis códigos numéricos (custando 8 bits cada) contra as 11 letras de
teste iniciais. A codificação resulta em uma boa taxa de compactação de cerca de metade dos
dados iniciais: 6/11 = 0,55.

Recuperar o texto original dos dados compactados requer um procedimento inverso diferente, que
responde pela única situação em que a decodificação LZW pode falhar na reconstrução da tabela
simbólica quando uma sequência começa e termina com o mesmo caractere. Este caso em
particular é cuidado pelo Python usando um bloco de comando if-then-else, para que você possa
usar o algoritmo com segurança para codificar e decodificar qualquer coisa:

def lzw_decompress(codificado):
reverse_dictionary = {k:chr(k) for k in range(256)}
atual = codificado[0]
saída = reverse_dictionary[atual]
print('Saída %s '% descompactada)
print('>%s'% saída)
para elemento em codificado[1:]:
anterior = atual
corrente = elemento
se atual em reverse_dictionary:
s = reverse_dictionary[atual]

280 PARTE 4 Lidando com Big Data


Machine Translated by Google

print ('Descompactado %s ' % s) output +=


s print ('>%s' % output) new_index =
max(reverse_dictionary.keys()) + 1
reverse_dictionary[new_index ] = reverse_dictionary[anterior] +
s[0] print('Nova entrada de dicionário %s no índice %s' %

(reverse_dictionary[anterior] + s[0], new_index))

senão:
print('Não encontrado:',atual,'Saída:',
dicionário_reverso[anterior] +
dicionário_reverso[anterior][0]) s =
dicionário_reverso[anterior] + dicionário_reverso[anterior][0]
print ('Nova entrada de dicionário %s no índice %s'
%
(s, max(reverse_dictionary.keys())+1))

reverse_dictionary[ max(reverse_dictionary.keys())+1] = s
print ('Descompactado %s' % s) output += s print ('>%s' %
output)

saída de retorno

A execução da função na sequência compactada anteriormente recupera as


informações originais varrendo a tabela simbólica, conforme mostrado aqui:

print('\ndecompressed string: %s'%


lzw_decompress(compressed)) print('string
original era: %s'% text)

A descompactado
>A
B descomprimido
> AB
Nova entrada de dicionário AB no índice 256
AB descompactado
> ABAB
Nova entrada de dicionário BA no índice 257
C descompactado
> ABABC
Nova entrada de dicionário ABC no índice 258
ABC descompactado
> ABABCABC

CAPÍTULO 14 Comprimindo e Ocultando Dados 281


Machine Translated by Google

Nova entrada de dicionário CA no índice 259


ABC descompactado
> ABABCABCABC
Nova entrada de dicionário ABCA no índice 260

string descompactada: ABABCABCABC


string original era: ABABCABCABC

Escondendo seus segredos com criptografia


Em uma época em que todos parecem estar se intrometendo nos negócios de todos, a
criptografia, a arte de tornar o texto ilegível de uma maneira que aqueles que sabem podem
reverter mais tarde, ganhou um significado adicional. A criptografia tem tudo a ver com
manter o texto secreto fora das mãos de pessoas que não precisam saber sobre ele. Como
mencionado anteriormente neste capítulo, a criptografia é uma arte antiga (porque é preciso
ser criativo para definir uma técnica criptográfica única) e ciência (porque a criptografia tem
que ser reversível), originada com os egípcios. Os fundamentos da criptografia não mudaram
em milhares de anos. O processo

1. Usa texto simples (os dados originais)

2. Criptografa (para se tornar ilegível)

3. Envia os dados para um destinatário como texto cifrado (que é ilegível)

4. Descriptografa (para torná-lo legível novamente)

A criptografia moderna depende de todos os tipos de métodos matemáticos avançados para


realizar as etapas de criptografia e descriptografia. Seriam necessários vários livros (ou até
mais) para discutir o assunto em profundidade, e então seu cérebro seria pura geleia
(acontece com todo mundo). As seções a seguir fornecem uma breve visão geral da
criptografia – apenas o suficiente para você agir de forma inteligente em sua próxima festa
no escritório, mas não o suficiente para transformar seu cérebro em geléia.

Os exemplos nas seções a seguir são exemplos de brinquedo que se destinam a ensinar
princípios, não a criar uma saída criptográfica inquebrável. Consequentemente, você deve
usar os exemplos para entender como a criptografia funciona, não criptografar sua próxima
missiva secreta para seu melhor amigo e esperar que ninguém mais possa lê-la. Afinal, quem
ler este livro poderá ler sua mensagem e deixar o mundo inteiro saber!

282 PARTE 4 Lidando com Big Data


Machine Translated by Google

Substituindo caracteres
A substituição de caracteres é um dos métodos mais simples e rápidos para criptografar dados.
É também uma das mais quebráveis porque essa forma de criptografia não esconde muito bem
os padrões naturais dos dados (como evidenciado por aqueles livros de quebra-cabeças que
permitem resolver essa forma de criptografia com nada mais do que reconhecimento de
padrões). Um dos primeiros métodos de substituição de caracteres foi desenvolvido por Julius
Cae sar, e você pode ler sobre isso em https://www.dcode.fr/caesar-cipher. Ainda assim, com
um pouco de engenhosidade, você pode criar uma cifra de substituição de caracteres usando
Python que fornece criptografia variável:

importar aleatório

random.seed(input("Forneça um valor de criptografia: "))

letras = ["a", "b", "c", "d", "e", "f", "g", "h", "i",
"j", "k", "l", "m", "n", "o", "p", "q", "r",
"s", "t", "u", "v", "w", "x", "y", "z", "!",
"A", "B", "C", "D", "E", "F", "G", "H", "I",
"J", "K", "L", "M", "N", "O", "P", "Q", "R",
"S", "T", "U", "V", "W", "X", "Y", "Z", " "]
letras aleatórias = letras.copy()
random.shuffle(letras aleatórias)
imprimir (letras)
print(letras aleatórias)

O exemplo começa com uma lista de letras, o espaço e o ponto de exclamação. Ele copia
essas letras para randomLetters e as embaralha com base no valor ran dom.seed() que você
fornece. O valor de semente é uma chave, essencialmente um valor secreto conhecido apenas
por você. Como não é codificado no aplicativo, você deve conhecer a chave para descriptografar
a string de entrada posteriormente. Ao executar esse código com um valor de semente de 5,
você vê a seguinte saída:

Forneça um valor de criptografia: 5


['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l',
'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y ', 'z', '!', 'A', 'B', 'C', 'D',
'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V',
'W ', 'X', 'Y', 'Z', ' ']

['x', 'q', 'i', 'y', 'l', 'B', 'u', 'a', 'z', 'L', 'v', 'A',
'P', 'O', 'X', 'e', 'c', 'D', 't', 'f', 'Z', 'p', 'k', 'V', 'm ', 'S', 's', '!', ' ', 'H', 'R',
'Q', 'U', 'E', 'h', 'j', 'Y', ' n', 'b', 'I', 'M', 'J', 'g', 'N', 'K', 'C', 'o', 'F', 'd', 'T' ,
'G', 'W', 'r', 'w']

CAPÍTULO 14 Comprimindo e Ocultando Dados 283


Machine Translated by Google

Este exemplo não usa nenhum tipo de algoritmo complexo para criptografar ou descriptografar a string
de entrada, mas as etapas são semelhantes a qualquer outro algoritmo. Para descriptografar uma string,
você deve reverter tudo o que fez para criptografá-la, conforme mostrado aqui:

def criptografar(texto):
resultado=""
para i em range(len(texto)):
caractere = texto[i]
resultado += randomLetters[letters.index(char)]
retornar resultado

def descriptografar(texto):
resultado=""
para i em range(len(texto)):
caractere = texto[i]
resultado += letras[letras aleatórias.index(char)]
retornar resultado

O algoritmo usa substituição simples para realizar a tarefa. Consequentemente, quando você executa
este código:

criptografado = encrypt("Olá!")
imprimir (criptografado)

você vê uma saída que tem exatamente o mesmo comprimento que a string de entrada, mas também
muito difícil de ler:

hlAAXwoalDls

O processo de descriptografia precisa ter certeza de que você é o originador da string criptografada,
portanto, ele solicita novamente o valor da semente, conforme mostrado aqui:

random.seed(input("Forneça um valor de descriptografia: "))


letras aleatórias = letras.copy()
random.shuffle(letras aleatórias)

descriptografado = descriptografar (criptografado)


imprimir (descriptografado)

Inserir o valor de semente errado, a chave errada, resultará em uma string que não é descriptografada
corretamente. Esse processo é muito parecido com o que as técnicas de criptografia mais complicadas
usam. A diferença está no algoritmo.
Por exemplo, o Advanced Encryption Standard (AES) oferece uma incrível

284 PARTE 4 Lidando com Big Data


Machine Translated by Google

algoritmo complexo, conforme descrito em https://www.geeksforgeeks.org/advanced


encryption-standard-aes/. Você pode encontrar outros exemplos de técnicas de criptografia
Python em https://www.tutorialspoint.com/cryptography_with_python/
cryptography_with_python_quick_guide.htm.

Trabalhando com criptografia AES


Agora que você viu um exemplo básico, é hora de ver o código básico que você
pode usar no mundo real. No entanto, antes de usar este exemplo, você precisa
instalar o pacote PyCrypto (https://pypi.org/project/pycrypto/) usando o seguinte
comando no prompt do Anaconda (não use o prompt normal e você pode ter que
abrir o prompt do Anaconda com privilégios administrativos):

conda instalar pycrypto

O exemplo desta seção usa um processo muito semelhante ao da seção anterior,


mas o algoritmo é mais complexo e os resultados muito mais difíceis de quebrar.
O código de criptografia e descriptografia é assim:

importar base64, re
de Crypto.Cipher importar AES
da importação de criptografia Aleatório

def encrypt(chave, blk_sz, raw):


raw = raw + '\0' * (blk_sz - len(raw) % blk_sz)
raw = raw.encode('utf-8')
chave secreta = Random.new().read(AES.block_size)
cifra = AES.new(key.encode('utf-8'), AES.MODE_CBC,
chave secreta)
return base64.b64encode(
secretKey + cipher.encrypt(raw) ).decode(
'utf-8')

def decrypt(chave, enc):


enc = base64.b64decode(enc)
chave secreta = enc[:16]
cifra = AES.new(key.encode('utf-8'), AES.MODE_CBC,
chave secreta)
return re.sub(b'\x00*$', b'',
cipher.decrypt(enc[16:])).decode(
'utf-8')

CAPÍTULO 14 Comprimindo e Ocultando Dados 285


Machine Translated by Google

CORRIGINDO O ERRO TIME.CLOCK()


Depois de instalar o PyCrypto, talvez seja necessário executar uma etapa adicional. O tempo.
clock() está obsoleta no Python 3.3 e removida no Python 3.8 e acima.
Se você receber um erro de AttributeError: o módulo 'time' não tem o atributo 'clock' ao
executar este exemplo, a correção é relativamente fácil. Você pode ler sobre isso em https://
stackoverflow.com/questions/58569361/attributeerror-module time-has-no-attribute-clock-in-
python-3-8. Essencialmente, você abre o C:\
Usuários\<Seu Nome>\anaconda3\Lib\site-packages\Crypto\Random\_
UserFriendlyRNG.py em seu sistema e, em seguida, procure por t = time.clock().
Altere cada uma dessas entradas para t = time.time() e salve o arquivo. Esse problema deve
ser corrigido quando uma nova versão da biblioteca PyCrypto for lançada.

O processo de criptografia começa preenchendo (adicionando nulos no final) o texto a ser


descriptografado para que ele seja dividido igualmente no tamanho do bloco. Por exemplo, se a
mensagem original tiver 25 caracteres (e o tamanho do bloco for 32 caracteres), esse processo de
preenchimento adiciona 7 nulos para tornar a mensagem com 32 caracteres. O texto também é
codificado como UTF-8, portanto, os nulos não aparecem como caractere de controle 0, mas como
\x00. A razão pela qual você precisa preencher a mensagem é que o exemplo está usando uma cifra de bloco.

A próxima etapa do processo gera uma chave secreta para criptografar a mensagem, conforme
descrito em https://www.dlitz.net/software/pycrypto/doc/#crypto publickey-public-key-algorithms.
Essa chave secreta contrasta com a chave pública que você fornece ao chamar a função
encrypt() . Como a chave secreta é desconhecida, é quase impossível adivinhar para tornar o
conteúdo da mensagem conhecido sem ter a chave pública (a que é conhecida). A chave
secreta agora é usada para codificar a mensagem usando AES.new() (descrito em https://
pycryptodome.
readthedocs.io/en/latest/src/cipher/aes.html) e produza-o como cifra.
Finalmente, a função encrypt() gera uma combinação da chave secreta (usada para descriptografar a
mensagem posteriormente) e a cifra decodificada no formato UTF-8 e codificada no formato base64.

A codificação Base64 garante que as strings permaneçam em formato legível por humanos e
não sejam prejudicadas por diferenças de plataforma ao lidar com tipos de caracteres especiais,
como caracteres de controle. Você pode ler mais sobre a codificação base64 em https://levelup.
gitconnected.com/what-is-base64-encoding-4b5ed1eb58a4. Os sites em https://www.base64encode.org/
e https://www.base64decode.org/ demonstre como a codificação base64 funciona.

286 PARTE 4 Lidando com Big Data


Machine Translated by Google

A descriptografia segue um processo um pouco oposto da criptografia. O código começa


decodificando a mensagem criptografada em base64, então extrai a secretKey dos primeiros 16
caracteres da mensagem combinada e usa a chave e a secretKey
para criar o objeto de cifra, que você usa para descriptografar a mensagem. A chamada para
re.sub() (consulte https://docs.python.org/3/library/re.html) remove o preenchimento adicionado
anteriormente e você acaba com a mensagem original. O código a seguir mostra como usar essas
duas funções:

encryp_msg = encrypt("1234567890ABCDEF", 16,


"Esta é uma mensagem secreta.")
print(encrypt_msg)

msg = decrypt("1234567890ABCDEF", encryp_msg)


imprimir(msg)

O código primeiro chama encrypt() com a chave pública, o tamanho do bloco e a mensagem a ser
criptografada. Observe que a chave pública tem 16 caracteres. Você não pode usar uma chave
menor a menos que você a preencha com 16 caracteres neste caso porque o tamanho do bloco é
de 16 caracteres. Ao executar este código, você vê uma saída confusa semelhante a esta:

r6KPOR0pbHqE7oZzCrpF4wVgV6/YrakgD7pSwHBVTPYUrOwmZpbknqZADtvxOxBN

A chamada para decrypt() fornece a chave pública novamente, junto com a mensagem criptografada.
Ao executar esse código, você verá sua mensagem original novamente.

CAPÍTULO 14 Comprimindo e Ocultando Dados 287


Machine Translated by Google
Machine Translated by Google

5 Desafiador
Difícil
Problemas
Machine Translated by Google

NESTA PARTE . . .

Usando algoritmos gananciosos, dinâmicos e aleatórios

Alcançando um compromisso usando a pesquisa local

Empregando técnicas de programação linear

Explorando os diferentes sabores de heurística


Machine Translated by Google

NESTE CAPÍTULO

» Entendendo como projetar novos


algoritmos e usar paradigmas de resolução

» Explicando como um algoritmo pode agir de


forma gananciosa e obter ótimos resultados

» Elaboração de um algoritmo ganancioso de


seu próprio

» Revisitando a codificação de Huffman e


ilustrando alguns outros exemplos
clássicos

Capítulo 15

Trabalhando com gananciosos


Algoritmos
algoritmos são e discutindo ordenação, pesquisa, gráficos e big data,
Depois de dar seus
é hora primeiros
de entrar passos
em uma no mundo
parte dos algoritmos,
mais geral apresentando
do livro. Nesta o que
última parte
do livro, você lida com alguns exemplos difíceis e vê abordagens algorítmicas
gerais que você pode usar em diferentes circunstâncias ao resolver problemas
do mundo real.

Ao adotar novas rotas e abordagens, este capítulo vai muito além da abordagem de
recursão de dividir e conquistar que domina a maioria dos problemas de ordenação.
Algumas das soluções discutidas não são completamente novas; você os viu nos
capítulos anteriores. No entanto, este capítulo discute os algoritmos anteriores em
maior profundidade, sob os novos paradigmas que o capítulo discute. Esses
paradigmas incluem uma análise das regras e condições do aplicativo, a abordagem
geral e as etapas para a solução de um problema e a análise da complexidade,
limitações e advertências do problema.

Generalizar algumas soluções e descrevê-las como paradigmas amplamente


aplicáveis é uma forma de oferecer dicas para resolver novos problemas práticos e faz parte do

CAPÍTULO 15 Trabalhando com Algoritmos Gananciosos 291


Machine Translated by Google

análise e projeto de algoritmos. O restante deste livro discute outras abordagens gerais.

Você não precisa digitar o código-fonte para este capítulo manualmente. Na verdade, usar a
fonte para download é muito mais fácil. Você pode encontrar a fonte deste capítulo no A4D2E;
15; Arquivo Greedy Algorithms.ipynb da fonte para download.
Consulte a Introdução para obter detalhes sobre como localizar esse arquivo de origem.

Decidindo quando é melhor ser ganancioso


Algoritmos gananciosos são úteis para resolver uma ampla gama de problemas, especialmente
quando é difícil elaborar uma solução global. Às vezes, vale a pena desistir de planos complicados
e simplesmente começar a procurar por frutos mais baratos que se assemelhem à solução que
você precisa. Em algoritmos, você pode descrever uma abordagem míope como essa como
gananciosa (diferentemente daquelas vezes em que você quer uma segunda sobremesa, que
também pode ser considerada gananciosa). Procurar soluções fáceis de entender constitui a
principal característica distintiva dos algoritmos gananciosos. Um algoritmo guloso alcança a
solução do problema usando etapas sequenciais durante as quais, a cada etapa, ele toma uma
decisão com base na melhor solução naquele momento, sem considerar consequências ou
implicações futuras.

Dois elementos são essenciais para distinguir um algoritmo guloso:

» Em cada turno, você sempre toma a melhor decisão possível naquele particular
grande instante

» Você espera que tomar uma série de melhores decisões resulte na melhor final
solução.

Algoritmos gananciosos são simples, intuitivos, pequenos e rápidos porque geralmente são
executados em tempo linear (o tempo de execução é proporcional ao número de entradas fornecidas).
Infelizmente, eles não oferecem a melhor solução para todos os problemas, mas quando o fazem,
fornecem os melhores resultados rapidamente. Mesmo quando eles não oferecem as principais
respostas, eles podem fornecer uma solução não ideal que pode ser suficiente ou que você pode
usar como ponto de partida para refinamento adicional por outra estratégia algorítmica. É como
aqueles momentos em que você precisa tomar uma decisão rápida que é boa o suficiente.

Curiosamente, os algoritmos gananciosos se assemelham a como os humanos resolvem muitos


problemas simples sem usar muito poder cerebral ou com informações limitadas. Por exemplo,
ao trabalhar como caixa e fazer o troco, um humano naturalmente usa uma abordagem
gananciosa. Você pode declarar o problema do troco como pagando uma determinada quantia
(o troco) usando o menor número de notas e moedas entre os disponíveis

292 PARTE 5 Desafiando Problemas Difíceis


Machine Translated by Google

denominações. O exemplo de Python a seguir demonstra a solução de mudança usando uma


abordagem gananciosa:

def change(to_be_changed, denominação):


result_change = lista()
para nota em denominação:

while to_be_changed >= bill:


result_change.append(conta)
to_be_changed = to_be_changed - conta
return result_change, len(resulting_change)

moeda = [100, 50, 20, 10, 5, 1]


quantidade = 367

print('Alterar: %s (usando %i contas)'


% (mudança (valor, moeda)))

Alteração: [100, 100, 100, 50, 10, 5, 1, 1] (usando 8 notas)

O algoritmo, encapsulado na função change() , varre as denominações disponíveis, da maior para


a menor. Ele usa a maior moeda disponível para fazer o troco até que o valor devido seja menor
que a denominação. Em seguida, ele se move para a próxima denominação e executa a mesma
tarefa até finalmente atingir a denominação mais baixa. Dessa forma, change() sempre fornece a
maior fatura possível para o valor a ser entregue. (Este é o princípio ganancioso em ação.)

Algoritmos gananciosos são apreciados para problemas de agendamento, armazenamento em


cache ideal (fazendo com que o sistema operacional do computador e o navegador da Web
funcionem melhor) e compactação usando a codificação Huffman. Eles também funcionam bem
para alguns problemas gráficos. Por exemplo, os algoritmos de Kruskal e Prim para encontrar uma
árvore geradora de custo mínimo e o algoritmo de caminho mais curto de Dijkstra são todos
gananciosos (veja o Capítulo 9 para detalhes). Uma abordagem gananciosa também pode oferecer
uma solução de primeira aproximação não ótima, mas aceitável, para problemas difíceis, como o
problema do caixeiro viajante (TSP). Além disso, os algoritmos gulosos também podem resolver
casos especiais de problemas que, de outra forma, são solucionáveis por algoritmos mais
complicados, como o problema da mochila quando as quantidades não são discretas (o Capítulo
16 discute ambos os problemas).

Entendendo por que ganancioso é bom


Não deveria surpreendê-lo que uma estratégia gananciosa funcione tão bem no problema da
mudança. De fato, alguns problemas não exigem estratégias de visão de longo prazo: a solução é
construída a partir de resultados intermediários (uma sequência de decisões), e a cada passo a
decisão certa é sempre a melhor de acordo com um critério inicialmente escolhido.

CAPÍTULO 15 Trabalhando com Algoritmos Gananciosos 293


Machine Translated by Google

Agir de forma gananciosa também é uma abordagem muito humana (e eficaz) para
resolver problemas econômicos. No filme Wall Street, de 1987, Gordon Gecko, o
protagonista, declara que “A ganância, por falta de uma palavra melhor, é boa” e celebra
a ganância como um ato positivo na economia. A ganância (não no sentido moral, mas no
sentido de usar um algoritmo ganancioso) está no cerne da economia neoclássica.
Economistas como Adam Smith, no século 18, teorizaram que a busca do interesse
próprio do indivíduo beneficia grandemente a sociedade como um todo e a torna próspera
na economia: https://plus.maths.org/content/adam-smith-and -mão invisível .

Detalhar como um algoritmo guloso funciona (e sob quais condições ele pode funcionar
corretamente) é simples, conforme explicado nas quatro etapas a seguir:

1. Você pode dividir o problema em problemas parciais. A soma (ou outra


combinação) desses problemas parciais fornece a solução certa. Nesse
sentido, um algoritmo guloso não é muito diferente de um algoritmo de divisão
e conquista (como Quicksort ou Mergesort, que aparecem no Capítulo 7).

2. A execução bem-sucedida do algoritmo depende da execução bem-sucedida de cada etapa parcial.


Esta é a característica da subestrutura ótima porque uma solução ótima é feita apenas de
subsoluções ótimas. Você pode confirmar que seu problema tem uma subestrutura ótima
analiticamente ou retraçando sistematicamente a partir de uma solução ótima.

3. Para obter sucesso em cada etapa, o algoritmo considera os dados de entrada apenas nessa etapa.
Ou seja, o status da situação (decisões anteriores) determina a decisão que o algoritmo toma,
mas o algoritmo não considera as consequências. Essa completa falta de uma estratégia global é
a propriedade da escolha gananciosa, porque ser ganancioso em todas as fases é suficiente para
oferecer o sucesso final. Como uma analogia, é como jogar o jogo de xadrez não olhando para a
frente mais de um lance e, ainda assim, vencendo o jogo.

4. A propriedade de escolha gulosa fornece esperança de sucesso, portanto, um algoritmo guloso


carece de uma regra de decisão complexa porque precisa, na pior das hipóteses, considerar
todos os elementos de entrada disponíveis em cada fase. Não há necessidade de computar
possíveis implicações de decisão; consequentemente, a complexidade computacional é na pior
das hipóteses linear O(n). Algoritmos gananciosos seguem o caminho simples para resolver
problemas altamente complexos que outros algoritmos levam uma eternidade para computar
porque são muito profundos.

Mantendo algoritmos gananciosos sob controle


Quando confrontado com um novo problema difícil, não é difícil encontrar uma solução
gananciosa usando as quatro etapas descritas na seção anterior. Tudo que você tem a fazer

294 PARTE 5 Desafiando Problemas Difíceis


Machine Translated by Google

é dividir seu problema em fases e determinar qual regra gulosa aplicar em cada etapa.
Ou seja, você faz o seguinte:

» Escolha como tomar sua decisão (determine qual abordagem é a


mais simples, mais intuitivo, menor e mais rápido).

» Comece a resolver o problema aplicando sua regra de decisão.

» Registre o resultado de sua decisão (se necessário) e determine o status do seu


problema.

» Aplicar repetidamente a mesma abordagem em todas as etapas até chegar ao problema


conclusão.

Não importa como você aplique as etapas anteriores, você deve determinar se está
atingindo seu objetivo confiando em uma série de decisões míopes. A abordagem
gananciosa funciona para alguns problemas, mas não funciona para todos os problemas.
Por exemplo, o problema do câmbio funciona perfeitamente com a moeda americana,
mas produz resultados abaixo do ideal com outras moedas. Para ver esse problema,
considere uma moeda fictícia (créditos) com denominações de 1, 15 e 25 créditos, o
algoritmo anterior não consegue entregar a mudança ideal para uma soma devida de 30 créditos:

print('Alterar: %s (usando %i contas)'


% (alteração(30, [25, 15, 1])))

Alteração: [25, 1, 1, 1, 1, 1] (usando 6 notas)

Claramente, a solução ótima é devolver duas notas de 15 créditos, mas o algoritmo,


sendo míope, começou com a maior denominação disponível (25 créditos) e depois
usou cinco notas de 1 crédito para compor os 5 créditos residuais.

Algumas estruturas matemáticas complexas chamadas matróides (consulte https://


jeremykun.com/2014/08/26/when-greedy-algorithms-are-perfect-the matroid / para obter
detalhes) pode ajudar a verificar se você pode usar uma solução gulosa para resolver de
maneira otimizada um problema específico. Se for possível formular um problema usando uma
estrutura matróide, uma solução gananciosa fornecerá um resultado ideal. No entanto, existem
problemas que têm soluções gananciosas ótimas que não obedecem à estrutura do matróide.
(Você pode ler sobre as estruturas matróides serem suficientes, mas não necessárias, para
uma solução gulosa ótima no artigo encontrado em http://cstheory.
stackexchange.com/questions/21367/does-every-greedy-algorithm-have matroid-structure.) Há
também um vídeo interessante, “Matroids for Greedy Algorithms”, em https://www.youtube.com/
watch?v=VWRm1IZAsVE.

CAPÍTULO 15 Trabalhando com Algoritmos Gananciosos 295


Machine Translated by Google

O usuário de algoritmos gananciosos deve saber que algoritmos gananciosos funcionam bem,
mas nem sempre fornecem os melhores resultados possíveis. Quando o fazem, é porque o
problema consiste em exemplos conhecidos ou porque o problema é compatível com a
estrutura matemática de matróides. Mesmo quando um algoritmo ganancioso funciona melhor
em uma configuração, alterar a configuração pode quebrar o brinquedo e gerar apenas
soluções boas ou aceitáveis. De fato, os casos de resultados apenas bons ou aceitáveis são
muitos, porque algoritmos gananciosos não costumam superar outras soluções, como mostrado por

» As soluções de problemas de mudança anterior mostram como uma mudança na configuração pode
fazer com que um algoritmo ganancioso pare de funcionar.

» O problema de agendamento (descrito na seção “Descobrindo como o ganancioso pode ser útil”,
mais adiante neste capítulo) ilustra como uma solução gananciosa funciona perfeitamente
com um trabalhador, mas não com mais de um.

» O algoritmo de caminho mais curto de Dijkstra funciona apenas com arestas com pesos
positivos. (Pesos negativos farão com que o algoritmo faça um loop em torno de alguns nós
indefinidamente.)

Demonstrar que um algoritmo guloso funciona melhor é uma tarefa difícil, exigindo um
conhecimento sólido de matemática. Caso contrário, você pode conceber uma prova de
maneira mais empírica testando a solução gulosa contra um dos seguintes:

» Uma das técnicas usadas para provar a correção de algoritmos gulosos é a


troca gananciosa. A prova de troca gulosa transforma uma solução produzida
por qualquer outro algoritmo na solução produzida pelo seu algoritmo guloso
de uma forma que não piora a qualidade da solução. O uso dessa técnica
mostra que qualquer outra solução não é melhor do que a solução gulosa, o
que prova que a solução gulosa retorna a solução ótima.

» Outro algoritmo quando, ao ver o desdobramento da solução gulosa, você


percebe que a solução gulosa fica à frente do algoritmo ótimo; ou seja, a
solução gulosa sempre fornece uma solução melhor em cada etapa do que a
fornecida por outro algoritmo.

Mesmo considerando que é mais uma exceção do que uma regra que uma abordagem
gananciosa bem-sucedida determinará a melhor solução, soluções gananciosas geralmente
superam outras soluções provisórias. Você pode nem sempre obter a melhor solução, mas a
solução fornecerá resultados bons o suficiente para atuar como ponto de partida (no mínimo),
e é por isso que você deve começar tentando soluções gananciosas primeiro em novos problemas.

296 PARTE 5 Desafiando Problemas Difíceis


Machine Translated by Google

Considerando problemas NP completos


Normalmente você pensa em um algoritmo guloso porque outras escolhas não computam a
solução que você precisa em um tempo viável. A abordagem gananciosa se adapta a
problemas para os quais você tem muitas opções e precisa combiná-las. À medida que o
número de combinações possíveis aumenta, a complexidade explode e até mesmo o
computador mais poderoso disponível deixa de fornecer uma resposta em um tempo razoável.
Por exemplo, ao tentar resolver um quebra-cabeça, você pode tentar resolvê-lo determinando
todas as maneiras possíveis de encaixar as peças disponíveis. Uma maneira mais razoável é
começar a resolver o problema escolhendo um único local e depois encontrando a peça mais
adequada para ele. Resolver o quebra-cabeça dessa maneira significa usar o tempo para
encontrar a peça mais adequada, mas você não precisa considerar esse local novamente,
reduzindo o número total de peças para cada iteração.

Problemas de quebra-cabeça, nos quais o número de decisões possíveis pode se tornar


enorme, são mais frequentes do que você espera. Alguns problemas desse tipo já foram
resolvidos, mas muitos outros não, e não é possível transformá-los (ainda) em versões com
soluções conhecidas. Até que alguém seja inteligente o suficiente para encontrar uma solução
genérica para esses problemas, uma abordagem gananciosa pode ser a maneira mais fácil
de abordá-los, desde que você aceite que nem sempre obterá a melhor solução, mas sim uma
mais ou menos aceitável (em muitos casos).

Esses problemas difíceis variam em características e domínio do problema. Um exemplo de


um problema difícil é o desdobramento de proteínas (que pode ajudar a curar o câncer e
recentemente fez grandes avanços graças ao Google DeepMind e sua IA baseada em
aprendizado profundo chamada AlphaFold 2: https://www.nature.com/articles/
d41586-020-03348-4). Outro exemplo de um problema difícil é o de quebrar a criptografia de
senha forte, como o popular sistema de criptografia RSA (https://
www.okta.com/identity-101/rsa-encryption/). Na década de 1960, os pesquisadores descobriram
um padrão comum para todas as criptografias de senhas fortes: todas são igualmente difíceis
de resolver. Esse padrão é chamado de teoria da NP-completude (NP significa polinômio não
determinístico). De certa forma, problemas difíceis se distinguem dos demais porque ainda
não é possível encontrar uma solução em um tempo razoável — ou seja, em tempo polinomial.

O tempo polinomial significa que, no pior caso, um algoritmo é executado em potências do


número de entradas (conhecidas como problemas P). Tempo linear é tempo polinomial porque
executa O(n1). Também as complexidades quadráticas O(n2) e cúbicas O(n3) são tempo
polinomial e, embora cresçam bastante rápido, não se comparam ao tempo exponencial, ou
seja, O(cn). A complexidade exponencial do tempo torna impossível encontrar uma solução
razoável para qualquer um desses problemas usando força bruta. De fato, se n for grande o suficiente,

CAPÍTULO 15 Trabalhando com Algoritmos Gananciosos 297


Machine Translated by Google

você pode facilmente ter que tentar um número de soluções maior do que o número de átomos
presentes no universo conhecido. Esse problema entra no território dos problemas NP e, no
momento, você pode esperar resolver qualquer um desses problemas em tempo polinomial
apenas se tiver uma máquina de Turing não determinística disponível. Tal máquina é uma
espécie de máquina mágica (portanto, é apenas teórica) que poderia funcionar graças a infinitos
processadores paralelos. Cada fluxo paralelo de cálculos, embora não se comunique com os
outros, pode cuidar de uma parte da solução, portanto, em teoria, pode resolver problemas
difíceis em tempo polinomial, escapando assim da armadilha do tempo exponencial.

Como as máquinas de Turing não determinísticas são teóricas, nenhuma maneira de resolver
rapidamente qualquer problema NP está disponível, mas elas são resolvidas de qualquer
maneira porque as pessoas são inteligentes o suficiente para conceber uma solução. Além
disso, quando encontramos uma solução para um problema NP, é fácil verificar se a solução é
válida porque, por definição, pode levar apenas tempo polinomial para verificar a solução. Por
exemplo, a programação linear há muito tem sido considerada um problema NP, mas em 1979
foi demonstrado que era um problema P pelo algoritmo elipsóide do matemático russo Leonid
Khachi yan. Portanto, não se desespere; mais cedo ou mais tarde, muitos problemas NP se
tornam problemas P graças aos esforços de muitos pesquisadores e cientistas.

Entre os problemas NP, há um subconjunto chamado problemas NP-completos (veja a Figura


15-1). Esses problemas são problemas muito especiais porque sabemos que resolvê-los pode
ser a chave para resolver em tempo polinomial muitos outros problemas NP simultaneamente.
A esperança dos especialistas em algoritmos é que alguém encontre uma maneira de resolver
qualquer um desses problemas no futuro, abrindo assim a porta para resolver todos os problemas
NP de uma só vez. Resolver problemas NP-completos é um dos “Problemas do Prêmio do
Milênio” proposto pelo Clay Mathematics Institute, que oferece um prêmio de um milhão de
dólares para quem conseguir encontrar uma solução (http://www.claymath.org/millennium-
problems /p-vs-np-problema).

No entanto, problemas NP-completos não completam realmente o número de problemas difíceis


que você pode resolver e desbloquear todos os problemas NP simultaneamente, como você
pode ver na Figura 15-1. Na verdade, NP-completos fazem parte de um conjunto muito maior de
problemas chamados problemas NP-Hard. O fato de que problemas NP-completos também
fazem parte de NP os torna razoavelmente verificáveis (como mencionado anteriormente, eles
podem ser verificados em tempo polinomial), mas NP-difíceis não estão em NP. Este fato implica
que a verificação de suas soluções também requer tempo exponencial, o que torna resolvê-las
e verificá-las um verdadeiro pesadelo para as capacidades algorítmicas atuais. Se os cientistas
conseguirem resolver um problema difícil e encontrar o Santo Graal dos algoritmos, provavelmente
isso acontecerá resolvendo um problema NP-completo, não um problema NP-difícil.

298 PARTE 5 Desafiando Problemas Difíceis


Machine Translated by Google

FIGURA 15-1:
Os conjuntos de
problemas P, NP,
NP-
completos e NP-difíceis.

Descobrindo como o ganancioso pode ser útil


As seções anteriores do capítulo oferecem uma compreensão geral dos algoritmos gananciosos.
Esta parte do capítulo mostra alguns desses algoritmos em detalhes para fornecer uma visão mais
profunda e ajudá-lo a determinar como reutilizar suas estratégias para resolver outros problemas.
As seções a seguir descrevem como funciona um cache de computador (um algoritmo sempre
encontrado sob o capô de qualquer computador). Além disso, você descobre como agendar tarefas
corretamente para cumprir prazos e prioridades. A produção de bens materiais depende fortemente
de algoritmos gananciosos para agendar recursos e atividades. Normalmente, os algoritmos de
atividade aparecem no núcleo do software Material Requirements Planning (MRP) e ajudam a
administrar uma fábrica com eficiência (http://searchmanufacturingerp.techtarget.com/definition/
Material requirements-planning-MRP). Finalmente, as seções a seguir revisam o algoritmo de
codificação de Huffman para fornecer mais informações sobre como ele funciona para criar novos
sistemas de codificação eficientes.

Organizando dados de computador em cache


Os computadores geralmente processam os mesmos dados várias vezes, fazendo com que
algumas pessoas pensem que são obsessivas, quando na verdade estão sendo minuciosas. A
obtenção de dados do disco ou da Internet requer largura de banda suficiente e custa tempo computacional.
Consequentemente, é útil armazenar dados usados com frequência em armazenamento local,
onde é mais fácil acessar (e talvez já pré-processado). Um cache, que geralmente é uma série de
slots de memória ou espaço em disco reservado para essa necessidade, cumpre o objetivo.

CAPÍTULO 15 Trabalhando com Algoritmos Gananciosos 299


Machine Translated by Google

Por exemplo, ao revisar o histórico do seu navegador, você provavelmente percebe que apenas
uma parte do tráfego é feita de novos sites, enquanto você gasta muito tempo e solicitações de
páginas em sites que conhece bem. Armazenar em cache algumas partes de sites comumente
vistos (como o cabeçalho, o plano de fundo, algumas imagens e algumas páginas que raramente
mudam) pode realmente melhorar sua experiência na web, pois reduz a necessidade de baixar
dados novamente. Tudo o que você precisa são os novos dados da Internet, porque a maior
parte do que você quer ver já está em algum lugar do seu computador. (O cache de um navegador
da Web é um diretório de disco.)

O problema não é novo. Na década de 1960, László Bélády, um cientista da computação


húngaro que trabalhava na IBM Research, levantou a hipótese de que a melhor maneira de
armazenar informações em um computador para reutilização imediata é saber quais dados são
necessários no futuro e por quanto tempo. Você não pode implementar essa previsão na prática
porque o uso do computador pode ser imprevisível e não predeterminado.

Ainda assim, como princípio, a ideia de antecipar o futuro pode inspirar uma estratégia de
substituição ótima, uma escolha gananciosa baseada na ideia de manter as páginas que você
espera usar em breve com base em solicitações anteriores ao cache. A política de substituição
de página ideal de Bélády (também conhecida como algoritmo de substituição clarividente)
funciona com base em um princípio ganancioso: descartar dados do cache cujo próximo uso
provavelmente ocorrerá no futuro para minimizar a chance de descartar algo que você precisa
em breve. Para implementar essa ideia, o algoritmo segue os seguintes passos:

1. Preencha o cache do computador registrando os dados de cada solicitação feita. Somente


quando o cache está cheio você começa a descartar coisas antigas para abrir espaço para
novos dados.

2. Defina um método para determinar o uso recente. Esse algoritmo pode usar carimbos de data
de arquivo ou um sistema de sinalizadores de memória (que sinaliza páginas usadas
recentemente e limpa todos os sinalizadores após um certo tempo) para fazer a determinação.

3. Quando você precisa ajustar novos dados, você descarta os dados que não foram usados
recentemente do cache. O algoritmo escolhe aleatoriamente um dado entre os não usados.

Por exemplo, se seu cache tiver apenas quatro slots de memória e for preenchido por quatro
letras do alfabeto que chegam na seguinte ordem:

UMA B C D

quando uma nova letra é processada, como a letra E, o computador abre espaço para ela
removendo uma das letras que são menos prováveis de serem solicitadas neste momento.
Neste exemplo, bons candidatos são A, B ou C (D é a adição mais recente). O algoritmo
escolherá um slot aleatoriamente e removerá seus dados do cache para permitir a entrada de E.

300 PARTE 5 Desafiando Problemas Difíceis


Machine Translated by Google

Competindo por recursos


Não, esta seção não é sobre um novo game show cujos concorrentes vencedores saem com prêmios incríveis. Em
vez disso, trata-se de resolver um problema comum que surge quando você deseja atingir um objetivo, como criar
um serviço ou produzir um objeto material. O problema é agendar várias atividades concorrentes que exigem acesso
exclusivo aos recursos. Os recursos podem incluir tempo em uma máquina de produção. Exemplos de tais situações
são abundantes no mundo real, desde agendar sua participação em cursos universitários até organizar os
suprimentos de um exército, ou desde montar um produto complexo, como um carro, até organizar uma sequência
de tarefas computacional em um data center. Invariavelmente, objetivos comuns em tais situações são

» Alcançar a realização do maior número de trabalhos em um determinado período de tempo

» Gerencie os trabalhos o mais rápido possível, em média

» Respeite algumas prioridades rígidas (prazos rígidos)

» Respeite algumas indicações de prioridade (prazos flexíveis)

O agendamento de tarefas se divide em duas categorias:

» Trabalhos difíceis de resolver corretamente e que exigem algoritmos avançados para resolver

» Trabalhos que são mais fáceis de lidar e podem ser resolvidos por simples gananciosos

algoritmos

A maior parte do agendamento que você executa, na verdade, está entre aqueles solucionáveis por algoritmos
gananciosos. Por exemplo, gerenciar trabalhos o mais rápido possível é um requisito comum para a produção
industrial ou o setor de serviços quando cada trabalho atende às necessidades de um cliente e você deseja fazer o
melhor para todos os seus clientes. Veja como você pode determinar um contexto para tal algoritmo:

» Você tem uma única máquina (ou trabalhador) que pode elaborar pedidos.

» Os pedidos chegam em lotes, então você tem muitos para escolher ao mesmo tempo.

» As ordens diferem em comprimento, cada uma exigindo um tempo de execução diferente.

Por exemplo, você recebe quatro trabalhos de quatro clientes comerciais que exigem 8, 4, 12 e 3 horas,
respectivamente, para serem executados. Embora o tempo total de execução permaneça o mesmo, alterar a ordem
de execução do trabalho altera o tempo em que você conclui os trabalhos e determina quanto tempo cada cliente de
negócios deve esperar antes de concluir seu trabalho. As seções a seguir consideram diferentes métodos para
atender às necessidades dos clientes de negócios com objetivos específicos.

CAPÍTULO 15 Trabalhando com Algoritmos Gananciosos 301


Machine Translated by Google

Abordando a satisfação do cliente


O negócio é manter os clientes satisfeitos. Se você executar os trabalhos na ordem apresentada,
o trabalho levará 8 + 4 + 12 + 3 = 27 horas para ser executado completamente. Ainda assim, o
primeiro cliente receberá seu trabalho após 8 horas, o último após 27 horas. De fato, o primeiro
trabalho é concluído em 8 horas, o segundo em 8 + 4 = 12 horas, o terceiro em 8 + 4 + 12 = 24
horas, o último em 8 + 4 + 12 + 3 = 27 horas.

Se você pretende manter todos os seus clientes felizes e satisfeitos, você deve se esforçar para
minimizar o tempo médio de espera para cada um deles. Esta medida é dada pela média dos
tempos de entrega: (8 + 12 + 24 + 27) / 4 = 17,75 horas em média para aguardar um serviço.
Para reduzir o tempo médio de espera, você pode começar a simular todas as combinações
possíveis de execução de pedidos e recalcular a estimativa. Isso é viável para alguns trabalhos
em uma única máquina, mas se você tiver centenas deles em várias máquinas, isso se tornará
um problema computacional muito pesado. Um algoritmo ganancioso pode salvar o dia sem
muito planejamento: apenas execute o mais curto primeiro. A média resultante será a menor
possível: (3 + (3 + 4) + (3 + 4 + 8) +
(3 + 4 + 8 + 12)) / 4 = 13 horas em média.

Para obter o tempo médio de espera, você obtém a média das somas acumuladas dos tempos
de execução. Se, em vez disso, você calcular a média dos tempos brutos, obterá a duração
média de uma tarefa, que não representa o tempo de espera do cliente.

O princípio ganancioso é simples: como você soma os tempos cumulativos, se você começar
executando as tarefas mais longas, estenderá a execução mais longa para todos os tempos de
execução sucessivos (porque é uma soma cumulativa). Se, em vez disso, você começa com os
trabalhos mais curtos, desenha primeiro os menores tempos, afetando positivamente a média (e
o nível médio de satisfação de seus clientes – mas o cliente com o trabalho mais longo ainda
pode não estar satisfeito).

Cumprimento de prazos
Às vezes, mais do que apenas querer que seus clientes esperem menos, você também precisa
respeitar seus requisitos de tempo, o que significa que você tem prazos. Quando você tem
prazos, o mecanismo ganancioso muda. Agora você não começa com a tarefa mais curta, mas
com a tarefa que você deve entregar o mais cedo, de acordo com o princípio , quanto mais
cedo, melhor. Este é o problema das duras linhas mortas, e é um problema que você pode
realmente deixar de resolver. (Alguns prazos são simplesmente impossíveis de cumprir.)

Se você tentar uma estratégia gananciosa e não conseguir resolver o problema, pode reconhecer
que não existe solução para o prazo exigido. Quando os prazos rígidos não funcionam, você
pode tentar resolver o problema usando prazos flexíveis, o que significa que você deve respeitar
uma prioridade (executar determinadas tarefas primeiro).

302 PARTE 5 Desafiando Problemas Difíceis


Machine Translated by Google

Neste exemplo, você tem os dois comprimentos das tarefas, conforme discutido na seção
anterior, e tem um valor (um peso) que define a importância da tarefa (pesos maiores têm
prioridade mais alta). É o mesmo problema, mas desta vez você deve minimizar o tempo
médio ponderado de conclusão. Para atingir esse objetivo, você cria uma pontuação de
prioridade dividindo as durações de tempo pelos pesos e começa com as tarefas que têm a
pontuação mais baixa. Se uma tarefa tem a pontuação mais baixa, é porque é de alta
prioridade ou muito curta.

Por exemplo, reprisando o exemplo anterior, agora você tem tuplas de pesos e comprimentos:
(40, 8), (30, 4), (20, 12), (10, 3), onde 40 na primeira tupla é um peso e 8 é um comprimento.
Divida cada comprimento pelo peso e você obterá pontuações de prioridade de 0,20, 0,13,
0,60 e 0,30. Comece com a pontuação de prioridade mais baixa e, adicionando a pontuação
de prioridade mais baixa que resta, você obtém um melhor cronograma que garante que você
minimize os tempos e respeite as prioridades: (30, 4), (40, 8), (10, 3) , (20, 12).

Revisitando a codificação Huffman


Como visto no capítulo anterior, a codificação de Huffman pode representar o conteúdo de
dados de uma forma mais compacta, explorando o fato de que alguns dados (como certos
caracteres do alfabeto) aparecem com mais frequência em um fluxo de dados. Ao usar
codificações de comprimentos diferentes (mais curtos para os caracteres mais frequentes,
mais longos para os menos frequentes), os dados consomem menos espaço. O Prof. Robert
M. Fano (professor de Huffman) e Claude Shannon já imaginaram tal estratégia de
compressão, mas não conseguiram encontrar uma maneira eficiente de determinar um arranjo
de codificação que tornasse impossível confundir um caractere com outro.

Códigos sem prefixo são necessários para evitar erros ao decodificar a mensagem. Isso
significa que nenhuma codificação de bits usada anteriormente deve ser usada como ponto
de partida para outra codificação de bits. Huffman encontrou uma solução simples e viável
para implementar códigos sem prefixo usando um algoritmo guloso. A solução para o problema
sem prefixo encontrado por Huffman é transformar a árvore originalmente balanceada (uma
estrutura de dados discutida no Capítulo 6) contendo a codificação de comprimento fixo em
uma árvore não balanceada, como mostrado na Figura 15-2.

Uma árvore não balanceada tem uma característica especial em que cada nó tem apenas um
ramo que continua se desenvolvendo em outros nós e ramos, enquanto o outro ramo termina
com um caractere codificado. Essa característica garante que nenhuma sequência de
codificação usada anteriormente possa iniciar uma nova sequência (graficamente, uma
ramificação que termina com um caractere codificado é um beco sem saída).

Além de desenhar graficamente a estrutura desbalanceada, um algoritmo guloso também


pode construir uma árvore desbalanceada. A ideia é construir a estrutura desde a raiz,
começando pelos caracteres menos usados. O algoritmo cria os níveis mais baixos da árvore
agregando caracteres menos frequentes em sequência até que não haja mais caracteres e
você chegue ao fundo.

CAPÍTULO 15 Trabalhando com Algoritmos Gananciosos 303


Machine Translated by Google

FIGURA 15-2:
De uma árvore

equilibrada
(esquerda) a uma

árvore desequilibrada (direita).

Para demonstrar a receita gananciosa por trás do algoritmo, esta seção fornece um exemplo
de código Python baseado em DNA. O DNA é representado como uma sequência das letras
A, C, T e G (os quatro nucleotídeos presentes em todos os seres vivos). Um bom truque é
usar apenas dois bits para representar cada uma das quatro letras, o que já é uma boa
estratégia de economia de memória quando comparado ao uso de uma codificação ASCII
completa (que é de pelo menos 7 bits).

Os nucleotídeos não são distribuídos uniformemente. A distribuição varia dependendo de


quais genes você estuda. A tabela a seguir mostra um gene com distribuição desigual,
permitindo a predominância de nucleotídeos A e C.

Nucleotídeos Percentagem Codificação Fixa Codificação Huffman

UMA 40,5% 00 0

C 29,2% 01 10

G 14,5% 10 110

T 15,8% 11 111

Média ponderada de bits: 2,00 1,90

Multiplicando o número de bits das duas codificações por sua porcentagem e somando tudo,
obtém-se a média ponderada dos bits utilizados por cada uma delas. Nesse caso, o resultado
é 1,9 para a codificação Huffman versus 2,0 para a codificação fixa. Isso significa que você
obtém uma economia de bits de cinco por cento neste exemplo. Você poderia economizar
ainda mais espaço ao ter genes com uma distribuição ainda mais desequilibrada em favor de
algum nucleotídeo.

O exemplo a seguir gera uma sequência de DNA aleatória e mostra como o código gera
sistematicamente a codificação. (Se você alterar o valor da semente, a geração aleatória das
sequências de DNA pode levar a um resultado diferente, tanto na distribuição de nucleotídeos
quanto na codificação de Huffman.)

304 PARTE 5 Desafiando Problemas Difíceis


Machine Translated by Google

de importação aleatória aleatória, semente


de coleções import defaultdict, Counter

gerador = ["A"]*6+["C"]*4+["G"]*2+["T"]*2
texto = ''

seed(4)
para i no intervalo (1000):
shuffle(generator) text +=
generator[0]

frequências = Contador(lista(texto))
print(f"{texto[:25]} ...\n{frequências}")

CAACCCCGACACGCCTCCATAGCCA...
Contador({'A': 405, 'C': 292, 'T': 158, 'G': 145})

Depois de deixar as entradas de dados prontas para compactação, o código prepara uma estrutura
de dados heap (consulte a seção “Executando pesquisas especializadas usando um heap binário”
do Capítulo 7 para obter detalhes) para organizar os resultados de maneira eficiente ao longo das
etapas que o algoritmo executa. Os elementos na pilha contêm os caracteres de nucleotídeos e o
número de frequência de nucleotídeos. Com uma complexidade de tempo log-linear, O(n*log(n)),
um heap é a estrutura correta a ser usada para ordenar os resultados e permitir que o algoritmo
desenhe os dois menores elementos rapidamente.class BinaryHeap():

def __init__(self): self.heap


= []

def swap(self, i, j): self.heap[i],


self.heap[j] = self.heap[j], self.heap[i]

def insert(self, key, value): index =


len(self.heap) self.heap.append([key,
value]) while index!=0:

parent = (index - 1) // 2 if
self.heap[parent][1] < self.heap[index][1]:
self.swap(pai, índice) índice = pai

heap = BinaryHeap() para


key_value_pair em frequency.items():
heap.insert(*key_value_pair) print(heap.heap)

[['A', 405], ['C', 292], ['G', 145], ['T', 158]]

CAPÍTULO 15 Trabalhando com Algoritmos Gananciosos 305


Machine Translated by Google

Quando você executa o algoritmo, com base na classificação feita pelo heap binário, ele
seleciona os nucleotídeos com menos frequências do heap (a escolha gananciosa). Em
seguida, agrega esses nucleotídeos em um novo elemento, substituindo os dois anteriores.
O processo continua até que a última agregação reduza o número de elementos restantes para
extrair do heap para um.

codificação = {item[0]:'' para item em heap.heap}


para i em range(1, len(heap.heap)):
agregado = heap.heap[-i:]
novo = heap.heap[-i-1]
# colocando um 1 na frente do elemento anterior
para o item no agregado:
codificação[item[0]] = '1' + codificação[item[0]]
# colocando um 0 na frente do seguinte elemento
codificação[novo[0]] = '0' + codificação[novo[0]]
print(f"{agregado} + {novo} =\n{codificação}\n")

[['T', 158]] + ['G', 145] =


{'A': '', 'C': '', 'G': '0', 'T': '1'}
[['G', 145], ['T', 158]] + ['C', 292] =
{'A': '', 'C': '0', 'G': '10', 'T': '11'}
[['C', 292], ['G', 145], ['T', 158]] + ['A', 405] =
{'A': '0', 'C': '10', 'G': '110', 'T': '111'}

À medida que as agregações unem os nucleotídeos, constituindo diferentes níveis da árvore


desequilibrada, sua codificação Huffman é sistematicamente modificada; adicionando um zero
na frente da codificação do elemento próximo a entrar no agregado e adicionando um na frente
dos elementos já no agregado. Desta forma, o algoritmo replica eficientemente a estrutura de
árvore desequilibrada anteriormente ilustrada.

A etapa final é imprimir o resultado, mostrando como o agregado foi construído e como as
codificações foram alteradas. A última agregação está apresentando a tabela de símbolos final
gerada.

306 PARTE 5 Desafiando Problemas Difíceis


Machine Translated by Google

NESTE CAPÍTULO

» Entendendo o que significa dinâmico


quando usado com programação

» Usando memoization de forma eficaz


para programação dinâmica

» Descobrindo como o problema


da mochila pode ser útil para
otimização

» Trabalhando com o problema do


caixeiro viajante NP-completo

Capítulo 16
Confiando na Dinâmica
Programação
Emproblema,
vez de usar a força gananciosos
os algoritmos bruta, o quefornecem
implica uma
tentar todasrápida
resposta as soluções
e muitas possíveis para um
vezes satisfatória.
Na verdade, um algoritmo guloso pode potencialmente resolver o problema completamente. Ainda,
algoritmos gananciosos também são limitados porque tomam decisões que não consideram as
consequências de suas escolhas. O Capítulo 15 mostra que nem sempre você pode resolver um
problema usando um algoritmo guloso.

Este capítulo apresenta a programação dinâmica. Ele explica como um algoritmo pode tomar uma
decisão aparentemente ótima em um determinado estágio, que mais tarde parece limitante e subótimo
para alcançar a melhor solução. Um algoritmo melhor, que não dependa da abordagem gananciosa,
pode revisar decisões passadas ou antecipar que uma decisão aparentemente boa não é tão
promissora quanto pode parecer. Essa é a abordagem adotada pela programação dinâmica.

Este capítulo oferece mais do que uma simples definição de programação dinâmica.
Na primeira parte, também explica por que a programação dinâmica tem um nome tão complicado.
Além disso, você descobre como transformar qualquer algoritmo (especialmente os recursivos) em
programação dinâmica usando Python e sua função

CAPÍTULO 16 Contando com Programação Dinâmica 307


Machine Translated by Google

decorators (ferramentas poderosas em Python que permitem alterar uma função existente
sem reescrever seu código). Aplicativos práticos explicam a programação dinâmica melhor
do que a teoria, então a segunda metade do capítulo mostra aplicativos de programação
dinâmica que otimizam recursos e retornos, criam tours curtos entre lugares e comparam
strings de forma aproximada. A programação dinâmica fornece uma abordagem natural
para lidar com muitos problemas que você encontra enquanto viaja pelo mundo dos
algoritmos.

Você não precisa digitar o código-fonte para este capítulo manualmente. Na verdade,
usar a fonte para download é muito mais fácil. Você pode encontrar a fonte para este
capítulo em \A4D2E\A4D2E; 16; Fibonacci.ipynb, \A4D2E\A4D2E; 16; Knapsack.ipynb, \
A4D2E\A4D2E; 16; Levenshtein.ipynb e \A4D2E\A4D2E; 16; TSP.ipynb
arquivos da fonte para download. Consulte a Introdução para obter detalhes sobre como localizar esses
arquivos de origem.

Explicando a programação dinâmica


A programação dinâmica é uma abordagem de algoritmo desenvolvida na década de
1950 por Richard Ernest Bellman (um matemático aplicado também conhecido por outras
descobertas no campo da matemática e algoritmos; leia mais em https://mathshistory.
st-andrews.ac.uk/Biographies/Bellman/) que testa mais soluções do que uma abordagem
gananciosa correspondente. Testar mais soluções oferece a capacidade de repensar e
ponderar as consequências das decisões. A programação dinâmica evita realizar cálculos
pesados graças a um sistema de cache inteligente chamado memoização, um termo
definido mais adiante neste capítulo. (Um cache é um sistema de armazenamento que
coleta dados ou informações.)

A programação dinâmica é tão eficaz quanto um algoritmo exaustivo - fornecendo assim


soluções corretas - mas muitas vezes é tão eficiente quanto uma solução aproximada (o
tempo computacional de muitos algoritmos de programação dinâmica é polinomial).
Parece funcionar como mágica porque a solução de que você precisa geralmente exige
que o algoritmo execute os mesmos cálculos muitas vezes. Ao modificar o algoritmo e
torná-lo dinâmico, você pode registrar os resultados da computação e reutilizá-los quando
necessário. A reutilização leva pouco tempo quando comparada ao recálculo, portanto, o
algoritmo finaliza as etapas rapidamente. As seções a seguir discutem o que a
programação dinâmica envolve com mais detalhes.

Obtendo uma base histórica


Você pode resumir a programação dinâmica a ter um algoritmo lembrando os resultados
do problema anterior, onde você teria que executar o mesmo

308 PARTE 5 Desafiando Problemas Difíceis


Machine Translated by Google

cálculo repetidamente. Embora a programação dinâmica possa parecer bastante complexa, a


implementação é realmente direta. No entanto, tem algumas origens históricas interessantes.

Bellman descreveu o nome programação dinâmica como resultado de necessidade e


conveniência em sua autobiografia, In the Eye of the Hurricane. Ele escreve que a escolha do
nome foi uma maneira de esconder a verdadeira natureza de sua pesquisa na RAND
Corporation (uma instituição de pesquisa e desenvolvimento financiada tanto pelo governo dos
EUA quanto por financiadores privados) de Charles Erwin Wilson, o Secretário de Defesa do
presidência de Eisenhower. Ocultar a verdadeira natureza de sua pesquisa ajudou Bellman a
permanecer empregado na RAND Corporation. Você pode ler sua explicação com mais
detalhes no trecho em: http://theory.cs.utah.edu/
fall18/algorithms/dy_birth.pdf. Alguns pesquisadores não concordam sobre a fonte do nome.
Por exemplo, Stuart Russell e Peter Norvig, em seu livro Artificial Intelligence: A Modern
Approach, argumentam que Bellman realmente usou o termo programação dinâmica em um
artigo datado de 1952, antes de Wilson se tornar secretário em 1953 (e o próprio Wilson foi
CEO da General Motors antes de se tornar um engenheiro envolvido em pesquisa e
desenvolvimento).

As linguagens de programação de computador não eram difundidas durante o tempo em que


Bellman trabalhou em pesquisa operacional, uma disciplina que aplica matemática para tomar
melhores decisões ao abordar principalmente problemas de produção ou logística (mas
também é usado para outros problemas práticos). A computação estava nos estágios iniciais
e usada principalmente para planejamento. A abordagem básica da programação dinâmica é
a mesma da programação linear, outra técnica algorítmica (veja o Capítulo 19) definida
naqueles anos em que programar significava planejar um processo específico para encontrar
uma solução ótima. O termo dinâmico lembra que o algoritmo move e armazena soluções
parciais. Programação dinâmica é um nome complexo para uma técnica inteligente e eficaz
para melhorar os tempos de execução de algoritmos.

Tornar os problemas dinâmicos


Como a programação dinâmica tira proveito de operações repetidas, ela funciona bem em
problemas que têm soluções construídas em torno da resolução de subproblemas que o
algoritmo monta posteriormente para fornecer uma resposta completa. Para funcionar
efetivamente, uma abordagem de programação dinâmica usa subproblemas aninhados em
outros subproblemas. (Essa abordagem é semelhante a algoritmos gananciosos, que também
exigem uma subestrutura ótima, conforme explicado no Capítulo 15.) Somente quando você
pode dividir um problema em subproblemas aninhados, a programação dinâmica pode vencer
as abordagens de força bruta que retrabalham repetidamente os mesmos subproblemas.

CAPÍTULO 16 Contando com Programação Dinâmica 309


Machine Translated by Google

Como conceito, a programação dinâmica é um grande guarda-chuva que cobre muitas aplicações
diferentes porque não é realmente um algoritmo específico para resolver um problema específico.
Em vez disso, é uma técnica geral que suporta a resolução de problemas. Você pode rastrear a
programação dinâmica para duas grandes famílias de soluções:

» De baixo para cima: cria uma série de resultados parciais que se agregam em uma
solução completa

» De cima para baixo: divide o problema em subproblemas, começando pela solução


completa (essa abordagem é típica de algoritmos recursivos) e usando memoização
(definida na próxima seção) para evitar cálculos repetidos

Normalmente, a abordagem de cima para baixo é mais eficiente computacionalmente porque


gera apenas os subproblemas necessários para a solução completa. A abordagem de baixo para
cima é mais exploratória e, usando tentativa e erro, geralmente obtém resultados parciais que
você não usará mais tarde. Por outro lado, as abordagens de baixo para cima refletem melhor a
abordagem que você adotaria na vida cotidiana ao enfrentar um problema (pensar recursivamente,
em vez disso, requer abstração e treinamento antes da aplicação).
As abordagens de cima para baixo e de baixo para cima não são tão fáceis de entender às
vezes. Isso porque o uso de programação dinâmica transforma a maneira como você resolve
problemas, conforme detalhado nestas etapas:

1. Crie uma solução de trabalho usando força bruta e possivelmente recursão. A solução
funciona, mas leva muito tempo ou não termina.

2. Armazene os resultados dos subproblemas para acelerar seus cálculos e alcançar um


solução em tempo razoável.

3. Mude a maneira como você aborda o problema e ganhe ainda mais velocidade.

4. Redefinir a abordagem do problema, de forma menos intuitiva, mas mais eficiente para
obter o maior proveito da programação dinâmica.

Transformar algoritmos usando programação dinâmica para fazê-los funcionar com eficiência
geralmente os torna mais difíceis de entender. Na verdade, você pode olhar para as soluções e
pensar que elas funcionam por mágica. Tornar-se proficiente em programação dinâmica requer
observações repetidas de soluções existentes e algum exercício prático. Essa proficiência vale
o esforço, no entanto, porque a programação dinâmica pode ajudá-lo a resolver problemas para
os quais você precisa comparar ou calcular sistematicamente soluções.

A programação dinâmica é especialmente conhecida por ajudar a resolver (ou pelo menos tornar
menos demorados) problemas de otimização combinatória, que são problemas que requerem a
obtenção de combinações de elementos de entrada como solução. Exemplos de tais problemas
resolvidos por programação dinâmica são os problemas do caixeiro viajante e da mochila,
descritos mais adiante neste capítulo.

310 PARTE 5 Desafiando Problemas Difíceis


Machine Translated by Google

Lançando recursão dinamicamente


A base da programação dinâmica é alcançar algo tão eficaz quanto a busca de força bruta sem
realmente gastar todo o tempo fazendo os cálculos exigidos por uma abordagem de força bruta. Você
obtém o resultado trocando tempo por espaço em disco ou memória, o que geralmente é feito criando
uma estrutura de dados (uma tabela de hash, uma matriz ou uma matriz de dados) para armazenar
os resultados anteriores. O uso de tabelas de pesquisa permite acessar os resultados sem ter que
realizar um cálculo uma segunda vez.

A técnica de armazenar resultados de funções anteriores e usá-los em vez da função é memoização,


um termo que você não deve confundir com memorização.
Memorização deriva de memorando, a palavra latina para “ser lembrado”.

Cache é outro termo que você encontra usado ao falar sobre memoização.
O armazenamento em cache refere-se ao uso de uma área especial da memória do computador para fornecer
dados mais rapidamente quando necessário, o que tem usos mais gerais do que a memorização.

Para ser eficaz, a programação dinâmica precisa de problemas que repetem ou refazem etapas
anteriores. Um bom exemplo de uma situação semelhante é usar a recursão, e o marco da recursão
é calcular os números de Fibonacci. A sequência de Fibonacci é simplesmente uma sequência de
números em que o próximo número é a soma dos dois anteriores. A sequência começa com 0
seguido de 1. Após definir os dois primeiros elementos, cada número seguinte na sequência é a
soma dos anteriores.
Aqui estão os primeiros onze números:

[0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55]

Assim como na indexação em Python, a contagem começa na posição zero e o último número
na sequência é a décima posição. O inventor da sequência, o matemático italiano Leonardo
Pisano, conhecido como Fibonacci, viveu em 1200. Fibo nacci pensava que o fato de cada
número ser a soma dos dois anteriores deveria ter tornado os números adequados para
representar os padrões de crescimento de um grupo de coelhos. A sequência não funcionou
muito bem para a demografia dos coelhos, mas ofereceu insights inesperados sobre a
matemática e a própria natureza, porque os números aparecem na botânica e na zoologia. Por
exemplo, você vê essa progressão na ramificação das árvores e nos arranjos de folhas em um
caule e de sementes em um girassol (você pode ler sobre esse arranjo em https://
www.goldennumber.
rede/espirais/).

Fibonacci também foi o matemático que introduziu os numerais hindu-arábicos na Europa, o sistema
que usamos diariamente hoje. Ele descreveu os números e a sequência em sua obra-prima, o Liber
Abaci, em 1202.

CAPÍTULO 16 Contando com Programação Dinâmica 311


Machine Translated by Google

Você pode calcular uma sequência numérica de Fibonacci usando recursão. Quando você
insere um número, a recursão divide o número na soma dos dois números Fibo nacci
anteriores na sequência. Após a primeira divisão, a recursão prossegue executando a mesma
tarefa para cada elemento da divisão, dividindo cada um dos dois números nos dois números
de Fibonacci anteriores. A recursão continua dividindo os números em suas somas até
finalmente encontrar as raízes da sequência, os números 0 e 1. Revisando os dois tipos de
algoritmos de programação dinâmica descritos na seção “Tornando os problemas dinâmicos”,
anteriormente neste capítulo, esta solução usa uma abordagem de cima para baixo. O código
a seguir mostra a abordagem recursiva em Python. (Você pode encontrar este código no
A4D2E; 16; Fibonacci.ipynb
arquivo de código-fonte para download; veja a Introdução para detalhes.)

def fib(n, tab = 0):


se n == 0:
retornar 0
elif n == 1:
retornar 1
senão:
print(f"lvl {tab}", end = ': ')
print(f"somando fib({n - 1}) e fib({n - 2})")
return fib(n - 1, tab + 1) + fib(n - 2, tab + 1)

O código imprime as divisões geradas por cada nível de recursão. A saída a seguir mostra o
que acontece quando você chama fib() com um valor de entrada de 7:

mentira(7)

lvl 0: somando fib(6) e fib(5)


lvl 1: somando fib(5) e fib(4)
lvl 2: somando fib(4) e fib(3)
lvl 3: somando fib(3) e fib(2)
lvl 4: somando fib(2) e fib(1)
lvl 5: somando fib(1) e fib(0)
lvl 4: somando fib(1) e fib(0)
lvl 3: somando fib(2) e fib(1)
lvl 4: somando fib(1) e fib(0)
lvl 2: somando fib(3) e fib(2)
lvl 3: somando fib(2) e fib(1)
lvl 4: somando fib(1) e fib(0)
lvl 3: somando fib(1) e fib(0)
lvl 1: somando fib(4) e fib(3)
lvl 2: somando fib(3) e fib(2)
lvl 3: somando fib(2) e fib(1)
lvl 4: somando fib(1) e fib(0)

312 PARTE 5 Desafiando Problemas Difíceis


Machine Translated by Google

lvl 3: somando fib(1) e fib(0) lvl 2: somando


fib(2) e fib(1) lvl 3: somando fib(1) e fib(0)

13

A saída mostra 20 divisões. Alguns números aparecem mais de uma vez como parte das
divisões. Parece um caso ideal para aplicar programação dinâmica. O código a seguir
adiciona um dicionário, chamado memo, que armazena os resultados anteriores. Depois
que a recursão divide um número, ela verifica se o resultado já aparece no dicionário
antes de iniciar a próxima ramificação recursiva. Se encontrar o resultado, o código usa
o resultado pré-computado, conforme mostrado aqui:

memo = dict() def


fib_mem(n, tab = 0):
se n == 0:
retornar 0
elif n == 1:
retornar 1
senão:
if (n-1, n-2) não está no memo:
print (f"lvl {tab}", end=': ') print (f"somando
fib({n-1}) e fib({n-2 })") memo[(n-1,n-2)] = fib_mem(n-1,tab+1

) + fib_mem(n-2,tab+1)
memorando de retorno[(n-1,n-2)]

Usando memoização, a função recursiva não calcula 20 adições, mas usa apenas seis,
as essenciais usadas como blocos de construção para resolver o requisito inicial para
calcular um certo número na sequência:

fib_mem(7)

lvl 0: somando fib(6) e fib(5) lvl 1: somando


fib(5) e fib(4) lvl 2: somando fib(4) e fib(3)
lvl 3: somando fib(3) e fib( 2) lvl 4: somando
fib(2) e fib(1) lvl 5: somando fib(1) e fib(0)

13

CAPÍTULO 16 Contando com Programação Dinâmica 313


Machine Translated by Google

Olhando dentro do dicionário de memorandos , você pode encontrar a sequência de somas que
definem a sequência de Fibonacci a partir de 1:

memorando

{(1, 0): 1, (2, 1): 2, (3, 2): 3, (4, 3): 5, (5, 4): 8,
(6, 5): 13}

Aproveitando a memorização
A memorização é a essência da programação dinâmica. Muitas vezes você encontra a
necessidade de usá-lo ao criar scripts de um algoritmo. Ao criar uma função, recursiva ou não,
você pode facilmente transformá-la usando um simples comando, um decorador,
que é uma função especial do Python que transforma funções. Para ver como trabalhar com um
decorador, comece com uma função recursiva, despojada de qualquer instrução de impressão:

def fib(n):
se n==0:
retornar 0
elif n == 1:
retornar 1
senão:
return fib(n-1) + fib(n-2)

Ao usar o Jupyter Notebook, você pode usar comandos mágicos integrados, como timeit, para
medir o tempo de execução de um comando em seu computador:

%timeit -n 1 -r 1 print(fib(36))

14930352
12,7 s ± 0 ns por loop (média ± desvio padrão de 1 execução, 1 loop cada)

A saída mostra que a função requer cerca de 12,7 segundos para ser executada no computador
usado para este teste (um sistema Windows executado em um processador Intel Core i3). No
entanto, dependendo da sua máquina, a execução da função pode exigir mais ou menos tempo.
Não importa a velocidade do seu computador, certamente levará alguns segundos para concluir,
porque o número de Fibonacci para 36 é bem grande: 14930352. Testar a mesma função para
números de Fibonacci mais altos leva ainda mais tempo.

Agora é hora de ver o efeito de decorar a função. Usando o lru_cache()


função do pacote functools pode reduzir radicalmente o tempo de execução. Essa função está
disponível apenas ao usar o Python 3. Ela transforma uma função adicionando automaticamente
um cache para armazenar seus resultados. Você também pode definir o tamanho do cache
usando o parâmetro maxsize (lru_cache() usa um cache com um

314 PARTE 5 Desafiando Problemas Difíceis


Machine Translated by Google

estratégia de substituição, conforme explicado no Capítulo 15). Se você definir maxsize=None,


o cache usará toda a memória disponível, sem limites.

de functools import lru_cache

@lru_cache(maxsize=Nenhum)
def fib(n):
se n==0:
retornar 0
elif n == 1:
retornar 1
senão:
return fib(n-1) + fib(n-2)

Observe que a função é a mesma de antes. A única adição é a função importada lru_cache ()
(https://docs.python.org/3/library/functools.
html), que você chama colocando um símbolo @ na frente dele. Qualquer chamada com o @
símbolo na frente é uma anotação e neste caso chama a função lru_cache() como um
decorador da função a seguir.

Usar decoradores é uma técnica avançada em Python. Os decoradores não precisam ser
explicados em detalhes neste livro, mas você ainda pode aproveitá-los porque são muito fáceis
de usar. (Você pode encontrar informações adicionais sobre decoradores em https://
pythonbasics.org/decorators/ e https://www.freecodecamp.
org/news/python-decorators-explained-with-examples/.) Apenas lembre-se de que você os
chama usando anotações (@ + nome da função do decorador) e que os coloca na frente da
função que deseja transformar. A função original é alimentada no decorador e sai transformada.
Neste exemplo de uma função recursiva simples, o decorador gera uma função de recursão
enriquecida pela memoização. É hora de testar a velocidade da função, como antes:

%timeit -n 1 -r 1 print(fib(36))

14930352
707 µs ± 0 ns por loop (média ± desvio padrão de 1 execução, 1 loop cada)

Mesmo que seu tempo de execução seja diferente, ele deve diminuir de segundos para
milissegundos. Tal é o poder da memorização. Você também pode explorar como sua função
usa seu cache chamando o método cache_info() da função decorada:

fib.cache_info()

CacheInfo(hits=34, misses=37, maxsize=Nenhum, currsize=37)

CAPÍTULO 16 Contando com Programação Dinâmica 315


Machine Translated by Google

A saída informa que existem 37 chamadas de função que não encontraram uma resposta no
cache. No entanto, 34 outras chamadas encontraram uma resposta útil no cache.

Apenas importando lru_cache() de functools e usando-o em anotações na frente de muitos


algoritmos pesados em Python, você experimentará um grande aumento no desempenho (a
menos que sejam algoritmos gananciosos).

Se em vez de usar uma função pré-criada, você gostaria de escrever uma função de
memorização própria, você pode usar a seguinte função:

def memoize(divertido):
cache = dict()

def memorizado(*args):
se args em cache:
retornar cache[args]
resultado = diversão(*args)
cache[args] = resultado
retornar resultado

retorno memorizado
memoized_fib = memoize(fib)

%timeit -n 1 -r 1 print(memoized_fib(36))

14930352
956 µs ± 0 ns por loop (média ± desvio padrão de 1 execução, 1 loop cada)

Em Python, quando uma função retorna outra função, a função retornada é associada a
todas as variáveis da função mãe. Desta forma, você pode enriquecer a função fib() com um
registro de dicionário de todos os resultados anteriores, que você pode recuperar quando a
função for novamente necessária com os mesmos parâmetros.

Descobrindo as melhores receitas dinâmicas


Mesmo a programação dinâmica tem limitações. A maior limitação de todas está relacionada
à sua principal força: se você acompanhar muitas soluções parciais para melhorar o tempo
de execução, poderá ficar sem memória. Você pode ter muitas soluções parciais
armazenadas porque o problema é complexo ou simplesmente porque a ordem que você
usa para produzir soluções parciais não é ótima e muitas das soluções não atendem aos
requisitos do problema.

316 PARTE 5 Desafiando Problemas Difíceis


Machine Translated by Google

A ordem usada para resolver subproblemas é algo que você deve acompanhar. A ordem
escolhida deve fazer sentido para a progressão eficiente do algoritmo (você resolve algo que vai
reutilizar imediatamente) porque o truque está na reutilização inteligente de blocos de construção
construídos anteriormente. Portanto, o uso de memoização pode não fornecer benefícios
suficientes. Reorganizar seus problemas na ordem certa pode melhorar os resultados. Você
pode aprender como ordenar corretamente seus subproblemas aprendendo diretamente com as
melhores receitas de programação dinâmica disponíveis: mochila, caixeiro-viajante e busca
aproximada de strings, conforme descrito nas seções a seguir.

Olhando dentro da mochila


O problema da mochila existe desde pelo menos 1897 e provavelmente é obra de Tobias Dantzig
(https://www.britannica.com/biography/Tobias-Dantzig).
Nesse caso, você deve arrumar sua mochila com o máximo de itens possível.
Cada item tem um valor, então você quer maximizar o valor total dos itens que você carrega. A
mochila tem um limite de capacidade, ou você tem um limite de peso que pode carregar, então
não pode carregar todos os itens.

A situação geral se encaixa em qualquer problema que envolva orçamento e recursos, que você
deseja alocar da maneira mais inteligente possível. Essa configuração do problema é tão comum
que muitas pessoas consideram o problema da mochila um dos problemas algorítmicos mais
populares. O problema da mochila encontra aplicações em ciência da computação, manufatura,
finanças, logística e criptografia. Por exemplo, as aplicações do mundo real do problema da
mochila são a melhor forma de carregar um navio de carga com mercadorias ou como cortar
matérias-primas de maneira otimizada, criando assim o menor desperdício possível.

Embora seja um problema tão popular, este livro não explora o problema da mochila novamente
porque a abordagem dinâmica é incontestavelmente uma das melhores abordagens de solução.
É importante lembrar, porém, que em casos específicos – por exemplo, quando os itens são
quantidades – outras abordagens, como o uso de algoritmos gulosos, podem funcionar
igualmente bem (ou até melhor).

Esta seção mostra como resolver o problema da mochila 0-1. Nesse caso, você tem um número
finito de itens e pode colocar cada um deles na mochila (o status um) ou não (o status zero). É
útil saber que existem outras variantes possíveis do problema:

» Problema da mochila fracionada: trata de quantidades. Por exemplo, um item pode


ser quilos de farinha e você deve escolher a melhor quantidade. Você pode resolver
esta versão usando um algoritmo ganancioso.

» Problema da mochila limitada: Coloca uma ou mais cópias do mesmo item na mochila.
Nesse caso, você deve lidar com os requisitos de número mínimo e máximo para
cada item escolhido.

CAPÍTULO 16 Contando com Programação Dinâmica 317


Machine Translated by Google

» Problema da mochila ilimitada: Coloca uma ou mais cópias do mesmo item na mochila sem
restrições. O único limite é que você não pode colocar um número negativo de itens na mochila.

O problema da mochila 0-1 depende de uma solução de programação dinâmica e é executado


em tempo pseudo-polinomial (o que é pior do que apenas tempo polinomial) porque o tempo de
execução depende do número de itens (n) multiplicado pelo número de frações da mochila
capacidade (W) que você usa ao construir sua solução parcial. Ao usar a notação big-O, você
pode dizer que o tempo de execução é O(nW).
A versão de força bruta do algoritmo é executada em O(2n). O algoritmo funciona assim:

1. Dada a capacidade da mochila, teste uma série de mochilas menores (subproblemas).


Nesse caso, dada uma mochila capaz de carregar 20 quilos, o algoritmo testa uma série de
mochilas que carregam de 0 a 20 quilos.

2. Para cada item, teste como ele se encaixa em cada uma das mochilas, desde a menor
mochila para o maior. Em cada teste, se o item couber, escolha o melhor valor entre os
seguintes:

(a) A solução oferecida pela mochila menor anterior

(b) O item de teste, mais você preenche o espaço residual com a solução de melhor valor
anteriormente que preencheu esse espaço

O programa de exemplo resolve o problema da mochila com o seguinte conjunto de seis itens
de diferentes combinações de peso e valor, bem como uma mochila de 20 kg:

Item 1 2 3 4 5 6

Peso em kg 2 3 4 4 5 9

Lucro em 100 USD 3 4 3 5 8 10

Aqui está o código para executar o procedimento de programação dinâmica descrito. (Você
pode encontrar esse código no arquivo de código-fonte para download A4D2E; 16;
Knapsack.ipynb ; consulte a Introdução para obter detalhes.)

valores = [3, 4, 3, 5, 8, 10]


pesos = [2, 3, 4, 4, 5, 9]
itens = len(pesos)
capacidade = 20

memorando = dict()
para tamanho no intervalo (0, capacidade+1, 1):
memorando[(-1, tamanho)] = ([], 0)

318 PARTE 5 Desafiando Problemas Difíceis


Machine Translated by Google

for item in range(items): for size


in range(0, capacidade+1, 1): # se o objeto não
couber na mochila if weights[item] > size:

memorando[item, tamanho] = memorando[item-1, tamanho]


senão:
# se o objeto se encaixa, verificamos o que melhor se encaixa #
no espaço residual previous_row, previous_row_value = memo[

item-1, size-weights[item]] if
memo[item-1, size][1] > values[item ] +
previous_row_value:
memo[item, size] = memo[item-1, size]
senão:
memo[item, tamanho] = (linha_anterior + [item ],
valor_linha_anterior + valores[item])

A melhor solução é o resultado em cache quando o código testa inserindo o último item com
a mochila com capacidade total (20 kg):

best_set, score = memo[items-1, capacity]


best_set_weights = [weights[item] for item in best_set] print(f'O melhor
conjunto é {best_set}', end= ', ') print(f'it weights { sum(best_set_weights)}',
end= ' ') print(f'and it value {score}')

O melhor conjunto é [0, 3, 4, 5], pesa 20 e vale 26

Você pode estar curioso para saber o que aconteceu dentro do dicionário de memorização:

print(len(memo))

147

print(memorando[2, 10])

([0, 1, 2], 10)

mochilas = len(intervalo(0, capacidade+1, 1))


print(f"testou {items} itens, testou {mochilas} mochilas")

testei 6 itens, testei 21 mochilas

CAPÍTULO 16 Contando com Programação Dinâmica 319


Machine Translated by Google

Ele contém 147 subproblemas. Na verdade, seis itens multiplicados por 21 mochilas (com
capacidades que variam de 0 a 20) são 126 soluções, mas é preciso adicionar outras 21
soluções ingênuas para permitir que o algoritmo funcione corretamente (ingênua significa
deixar a mochila vazia), o que aumenta o número de subproblemas para 147.

Você pode achar difícil resolver 147 subproblemas (mesmo que eles sejam incrivelmente
rápidos de resolver). Usar apenas força bruta para resolver o problema significa resolver menos
subproblemas neste caso específico. Resolver menos subproblemas requer menos tempo, fato
que você pode testar resolvendo o problema usando Python e a função comb() :

do pente de importação scipy.special

objetos = 6
print(sum([comb(objects,k+1) for k in range(objects)]))

63,0

objetos = 20
print(sum([comb(objects,k+1) for k in range(objects)]))

1048575.0

É preciso testar 63 combinações para resolver esse problema. No entanto, se você tentar usar
mais objetos, digamos, 20, os tempos de execução parecerão muito diferentes porque agora
existem 1.048.575 combinações para testar. Compare esse número enorme com a programação
dinâmica, que requer a resolução de apenas 20 * 21 + 21 = 441 subproblemas (é claro, esse
valor é válido apenas para mochilas com 20 itens).

Os resultados anteriores mostram a diferença entre o tempo quase polinomial e exponencial.


(Como lembrete, o livro discute a complexidade exponencial no Capítulo 2 ao ilustrar a
Notação O Grande. No Capítulo 15, você descobre o tempo polinomial como parte da discussão
sobre problemas NP completos.) Usar programação dinâmica torna-se proveitoso quando seus
problemas são complexo. Problemas de brinquedo são bons para aprender, mas não podem
demonstrar toda a extensão do emprego de técnicas de algoritmos inteligentes, como
programação dinâmica. Cada solução testa o que acontece após adicionar um determinado
item quando a mochila tem um determinado tamanho. O exemplo anterior adiciona o item 2
(peso = 4, valor = 3) e gera uma solução que coloca os itens 0, 1 e 2 na mochila (peso total 9
kg) para um valor de 10. Essa solução intermediária aproveita soluções anteriores e é a base
para muitas das seguintes soluções antes que o algoritmo chegue ao fim.

Você pode se perguntar se o resultado oferecido pelo script é realmente o melhor possível.
Infelizmente, a única maneira de ter certeza é saber a resposta certa, o que significa executar
um algoritmo de força bruta (quando viável em termos de tempo de execução em seu
computador). Este capítulo não usa força bruta para a mochila

320 PARTE 5 Desafiando Problemas Difíceis


Machine Translated by Google

problema, mas você verá uma abordagem de força bruta usada no problema do caixeiro viajante
que se segue.

Passeio pelas cidades


O problema do caixeiro viajante (TSP para abreviar) é pelo menos tão conhecido quanto o
problema da mochila. Você o usa principalmente em logística e transporte; por exemplo, para
entrega de mercadorias por um único veículo, bem como uma frota de veículos. O problema do
TSP pede que um caixeiro-viajante visite um certo número de cidades e depois volte para a
cidade inicial (por ser circular, chama-se tour) usando o caminho mais curto possível.

O TSP é semelhante aos problemas de grafos, mas sem as arestas porque as cidades estão
todas interconectadas. Por esse motivo, o TSP geralmente conta com uma matriz de distância
como entrada, que é uma tabela que lista as cidades nas linhas e nas colunas (uma matriz
quadrada porque o número de linhas é o mesmo que o de colunas). As interseções contêm a
distância de uma cidade de linha a uma cidade de coluna. As variantes do problema TSP podem
fornecer uma matriz contendo tempo ou consumo de combustível em vez de distâncias.

O TSP é um problema NP-difícil, mas você pode resolver o problema usando várias abordagens,
algumas aproximadas (heurística) e outras exatas (programação dinâmica). O problema, como
em qualquer outro problema NP-difícil, é o tempo de execução.
Embora você possa contar com soluções que você espera que sejam boas o suficiente para
resolver o problema (você não pode ter certeza, exceto ao resolver passeios curtos), você não
pode ter certeza com problemas tão complexos quanto passear pelo mundo: http:// www.matemática.
uwaterloo.ca/tsp/world/. O exemplo a seguir testa vários algoritmos, como força bruta,
programação gulosa e dinâmica, em um tour simples por seis cidades, representadas como um
gráfico ponderado (consulte a Figura 16-1). (Você pode encontrar esse código no arquivo de
código-fonte para download A4D2E; 16; TSP.ipynb ; consulte a Introdução para obter detalhes.)

importar networkx como nx


importar matplotlib.pyplot como plt
da semente de importação aleatória

D = [[0,20,16,25,24],[20,0,12,12,27],
[16,12,0,10,14],[25,12,10,0,20],
[24,27,14,20,0]]

Gráfico = nx.Gráfico()
nós = len(D[0])
Graph.add_nodes_from(range(nós))
para i no intervalo (nós):
para j no intervalo (nós):

CAPÍTULO 16 Contando com Programação Dinâmica 321


Machine Translated by Google

Graph.add_edge(i, j, weight=D[j][i])

semente(2)
pos=nx.shell_layout(Gráfico)
draw_params = {'with_labels':True,
'node_color':'skyblue',
'node_size':800, 'width':2,
'font_size':14}
nx.draw(Graph, pos, **draw_params)
rótulos = nx.get_edge_attributes(Gráfico, 'peso')
nx.draw_networkx_edge_labels(Gráfico,pos,
edge_labels=labels)
plt.show()

FIGURA 16-1:

Cidades
representadas
como nós em um gráfico ponderado.

Depois de definir a matriz D (distância), o exemplo testa a primeira solução mais


simples para determinar o passeio mais curto começando e terminando na cidade
zero. Esta solução conta com a força bruta, que gera todas as permutações de ordem
possíveis entre as cidades, deixando de fora o zero porque é o ponto de partida e de parada.
A distância de zero até a primeira cidade e da última cidade do passeio até zero é
somada após o cálculo da distância total de cada solução. Quando todas as soluções
estiverem disponíveis, basta escolher a mais curta.

de permutações de importação itertools

best_solution = [Nenhum, soma([soma(linha) para linha em D])]


para solução em list(permutations(range(1, nodes))):
início, distância = (0, 0)
para next_one na solução:

322 PARTE 5 Desafiando Problemas Difíceis


Machine Translated by Google

distância += D[next_one][start]
inicio = next_one
distância += D[0][início]
se a distância <= best_solution[1]:
best_solution = [[0]+list(solution)+[0], distance]
prt = str(melhor_solução)[1:-1]
print(f'Melhor solução até agora: {prt} kms')

Melhor solução até agora: [0, 1, 2, 3, 4, 0], 86 kms


Melhor solução até agora: [0, 1, 3, 2, 4, 0], 80 kms
Melhor solução até agora: [0, 4, 2, 3, 1, 0], 80 kms

O algoritmo de força bruta determina rapidamente a melhor solução e seu caminho simétrico.
No entanto, como resultado do pequeno tamanho do problema, você obtém uma resposta rápida,
pois, dadas quatro cidades, existem apenas 24 soluções possíveis. À medida que o número de
cidades aumenta, o número de permutações a testar torna-se intratável, mesmo depois de
remover os caminhos simétricos (que dividem as permutações pela metade) e usar um
computador rápido. Por exemplo, considere o número de cálculos ao trabalhar com 13 cidades
mais o ponto inicial/final:

de scipy.special importação permanente

print(perm(13, 13) / 2)

3113510400.0

A programação dinâmica pode simplificar o tempo de execução. O algoritmo de Held-Karp


(também conhecido como algoritmo de Bellman-Held-Karp porque Bellman o publicou em 1962,
mesmo ano que Michael Held e Richard Karp) pode reduzir a complexidade de tempo para
O(2n). Ainda é uma complexidade exponencial, mas requer menos tempo do que aplicar a
enumeração exaustiva de todos os passeios por força bruta, que têm uma complexidade de
O(n!).

Algoritmos aproximados e heurísticos podem fornecer resultados rápidos e úteis. (Mesmo que o
resultado nem sempre reflita a solução ótima, geralmente é bom o suficiente.) Você verá o TSP
novamente mais adiante no livro (veja os Capítulos 18 e 20), ao lidar com busca local e heurística.

Para encontrar a melhor solução TSP para n cidades, começando e terminando na cidade zero,
o algoritmo procede da cidade zero e mantém registros do caminho mais curto possível,
considerando diferentes configurações. Ele sempre usa uma cidade final diferente e toca apenas
um subconjunto de cidades. À medida que os subconjuntos se tornam maiores, o algoritmo
aprende a resolver o problema de forma eficiente. Portanto, ao resolver TSP para cinco cidades, o algoritmo

CAPÍTULO 16 Contando com Programação Dinâmica 323


Machine Translated by Google

primeiro considera soluções envolvendo duas cidades, depois três cidades, depois quatro e
finalmente cinco. (Os conjuntos têm dimensões de 1 a n.) Aqui estão as etapas que o algoritmo usa:

1. Inicialize uma tabela para rastrear as distâncias da cidade 0 para todas as outras cidades. Esses
conjuntos contêm apenas a cidade inicial e uma cidade de destino, pois representam a etapa
inicial.

2. Considere todos os tamanhos de conjuntos possíveis, de dois ao número de cidades turísticas. Esta é
uma primeira iteração, o loop externo.

3. Dentro do loop externo, para cada tamanho de conjunto, considere todas as combinações possíveis de
cidades desse tamanho, sem que nenhuma contenha a cidade inicial. Esta é uma iteração interna.

4. Dentro da iteração interna (Etapa 3), para cada combinação disponível, considere cada cidade
dentro da combinação como a cidade final. Esta é outra iteração interna.

5. Dentro da iteração interna (Passo 4), dada uma cidade de destino diferente, determine o caminho
mais curto conectando as cidades do conjunto a partir da cidade que inicia o passeio (cidade 0).
Ao encontrar o caminho mais curto, use qualquer informação útil previamente armazenada,
aplicando assim a programação dinâmica. Esta etapa economiza cálculos e fornece a lógica para
trabalhar aumentando subconjuntos de cidades.

Reutilizando subproblemas previamente resolvidos, você encontra os passeios mais curtos adicionando
a um caminho mais curto anterior a distância necessária para chegar à cidade de destino.
Dado um determinado conjunto de cidades, uma cidade inicial específica e uma cidade de destino
específica, o algoritmo armazena o melhor caminho e seu comprimento.

6. Quando todas as iterações terminam, você tem tantas soluções mais curtas diferentes quanto n-1 cidades,
com cada solução cobrindo todas as cidades, mas terminando em uma cidade diferente. Adicione um
ponto de fechamento, a cidade 0, a cada um para concluir o passeio.

7. Determine a solução mais curta e exiba-a como resultado.

A implementação deste algoritmo em Python não é muito simples porque envolve algumas
iterações e manipulação de conjuntos. É uma busca exaustiva reforçada por programação
dinâmica, e conta com uma abordagem iterativa, com subconjuntos de cidades e com candidatos
a serem adicionados a eles. O seguinte exemplo de Python comentado explora como essa
solução funciona. Você pode usá-lo para calcular passeios personalizados (possivelmente
usando cidades em sua região ou município como entradas na matriz de distância).
O script usa comandos avançados como frozenset() (um comando que torna um conjunto
utilizável como chave de dicionário) e operadores para conjuntos para obter a solução:

de combinações de importação do itertools

memorando = {(congelado([0, idx+1]), idx+1): (dist, [0,idx+1])

324 PARTE 5 Desafiando Problemas Difíceis


Machine Translated by Google

for idx,dist in enumerate(D[0][1:])}

cidades = nós
para subset_size em range(2, cidades):
# Aqui definimos o tamanho do subconjunto de cidades

new_memo = dict()
para subconjunto em [frozenset(comb) | {0} para pentear
combinações(intervalo(1, cidades),
subconjunto_size)]:
# Enumeramos os subconjuntos com um determinado subconjunto
# Tamanho

para terminar em subconjunto - {0}:


# Consideramos cada ponto final no subconjunto all_paths = list()

para k no subconjunto:

# Verificamos o caminho mais curto para cada


# elemento no subconjunto

se k != 0 e k!=final:
comprimento = memo[(subconjunto-{final},k)][0 ] +
D[k][final] índice =
memo[(subconjunto-{final},k)][1 ] + [final]

all_paths.append( (comprimento, índice))


new_memo[(subconjunto, final)] = min(all_paths)
# Para economizar memória, apenas gravamos os # subconjuntos anteriores,
pois não usaremos mais os mais curtos
memorando = new_memo

# Agora fechamos o ciclo e voltamos ao início do passeio # do passeio, city


zero tours = list() for distance, path in memo.values():

distância += D[0][caminho[-1]]
tours.append((distância, caminho + [0]))
# Agora podemos declarar o passeio mais curto

distance, path = min(tours) print('Tour


de programação dinâmica mais curto é:', end=' ') print(f'{path}, {distance} kms')

O passeio de programação dinâmica mais curto é:


[0, 1, 3, 2, 4, 0], 80 kms

CAPÍTULO 16 Confiando na Programação Dinâmica 325


Machine Translated by Google

Aproximando a pesquisa de string


Determinar quando uma palavra é semelhante a outra nem sempre é simples. As palavras podem
diferir ligeiramente devido a erros ortográficos ou diferentes maneiras de escrever a palavra em si,
tornando impossível qualquer correspondência exata. Este não é apenas um problema que levanta
questões interessantes durante uma verificação ortográfica. Por exemplo, juntar sequências de
texto semelhantes (como nomes, endereços ou identificadores de código) que se referem à mesma
pessoa pode ajudar a criar uma visão de cliente único da base de clientes de uma empresa ou
ajudar uma agência de segurança nacional a localizar um criminoso perigoso.

Aproximar pesquisas de strings tem muitas aplicações em tradução automática, reconhecimento


de fala, verificação ortográfica e processamento de texto, biologia computacional e recuperação
de informações. Pensando na maneira como as fontes inserem dados nos bancos de dados, você
sabe que existem muitas incompatibilidades entre os campos de dados que um algoritmo inteligente
deve resolver. Combinar uma série de letras semelhante, mas não exatamente igual, é uma
habilidade que encontra uso em campos como a genética ao comparar sequências de DNA
(expressas por letras que representam os nucleotídeos G, A, T e C) para determinar se duas
sequências são semelhantes e como eles se assemelham.

Vladimir Levenshtein, um cientista russo especialista em teoria da informação (ver http://


ethw.org/Vladimir_I._Levenshtein para detalhes), concebeu uma medida simples (em
homenagem a ele) em 1965 que calcula o grau de similaridade entre duas strings
contando quantas transformações são necessárias para mudar a primeira string para a
segunda. A distância Levenshtein (também conhecida como distância de edição) conta
quantas alterações são necessárias em uma palavra:

» Exclusão: Removendo uma letra de uma palavra

» Inserção: Inserir uma letra em uma palavra e obter outra palavra

» Substituição: Substituir uma letra por outra, como alterar a letra p


em uma letra f e obtendo o ventilador da panela

Cada edição tem um custo, que Levenshtein define como 1 para cada transformação.
No entanto, dependendo de como você aplica o algoritmo, você pode definir o custo de maneira
diferente para exclusão, inserção e substituição. Por exemplo, ao pesquisar nomes de ruas
semelhantes, erros de ortografia são mais comuns do que diferenças absolutas nas letras, portanto,
a substituição pode incorrer em apenas um custo de 1 e a exclusão ou inserção pode incorrer em
um custo de 2. Por outro lado, ao procurar por quantidades, valores semelhantes muito
possivelmente terão diferentes números de números. Alguém poderia inserir $ 123 ou $ 123,00 no
banco de dados. Os números são os mesmos, mas o número de números é diferente, portanto, a
inserção e a exclusão podem custar menos do que a substituição. (Um valor de $ 124 não é
exatamente o mesmo que um valor de $ 123, então substituir 3 por 4 deve custar mais.)

326 PARTE 5 Desafiando Problemas Difíceis


Machine Translated by Google

Você pode renderizar o algoritmo de contagem como uma recursão ou uma iteração. No
entanto, ele funciona muito mais rápido usando uma solução de programação dinâmica de
baixo para cima, conforme descrito no artigo de 1974 “The String-to-String Correction
Problem”, de Robert A. Wagner e Michael J. Fischer (http://www.inrg .csie.ntu.edu.tw/
algoritmo2014/homework/Wagner-74.pdf). A complexidade de tempo desta solução é O(mn),
onde n e m são os comprimentos em letras das duas palavras que estão sendo comparadas.
O código a seguir calcula o número de alterações necessárias para transformar a palavra
sábado em domingo usando programação dinâmica com uma matriz (consulte a Figura 16-2)
para armazenar resultados anteriores (a abordagem de baixo para cima). (Você pode
encontrar esse código no arquivo de código-fonte para download A4D2E; 16; Levenshtein.ipynb ;
consulte a Introdução para obter detalhes.)

s1 = 'sábado'
s2 = 'domingo'
m = apenas(s1)
n = len(s2)
D = [lista(intervalo(n+1))]
para i no intervalo (m):
D.append([i+1] + [0] * n)

para j no intervalo (1, n+1):


para i no intervalo (1, m+1):
se s1[i-1] == s2[j-1]:
D[i][j] = D[i-1][j-1]
senão:
deleção = D[i-1][j] + 1
inserção = D[i][j-1] + 1
substituição = D[i-1][j-1] + 1
D[i][j] = min(exclusão,
inserção,
substituição)
print (f'A distância de Levenshtein é {D[-1][-1]}')

A distância de Levenshtein é 3

Você pode plotar ou imprimir o resultado usando o Pandas (um pacote para análise e
visualização de dados). Os pandas imprimirão um bom cabeçalho e rótulos de linha para a
matriz representada pela lista de listas D:

importar pandas como pd


pd.DataFrame(D, colunas=lista(' '+s2), índice=lista(' '+s1))

CAPÍTULO 16 Contando com Programação Dinâmica 327


Machine Translated by Google

FIGURA 16-2:
Transformando
sábado em
domingo.

Voltando a O algoritmo constrói a matriz, colocando a melhor solução na última célula. Depois
de construir a matriz usando as letras da primeira string como linhas e as letras da segunda
como colunas, ela procede por colunas, computando as diferenças entre cada letra das linhas
em relação às das colunas. Dessa forma, o algoritmo faz um número de comparações
equivalente ao produto do número de letras nas duas strings. À medida que o algoritmo
continua, ele contabiliza o resultado das comparações anteriores e escolhe a solução com o
menor número de edições.

Quando a iteração da matriz é concluída, o número resultante representa o número mínimo


de edições necessárias para que a transformação ocorra - quanto menor o número, mais
semelhantes as duas strings. Refazer da última célula para a primeira movendo para a célula
anterior com o menor valor (se mais direções estiverem disponíveis, ele prefere mover na
diagonal) sugere quais transformações executar (veja a Figura 16-3). Observe que o caminho
não é necessariamente exclusivo. Os sublinhados mostram onde estão disponíveis duas
opções possíveis.

» Um movimento diagonal para trás sugere uma substituição na primeira string se as letras na
linha e na coluna forem diferentes (caso contrário, nenhuma edição precisa ser feita).

» Um movimento para cima determina a exclusão de uma letra na primeira string.

» Um movimento para trás à esquerda indica que a inserção de uma nova letra deve ser
feito na primeira corda.

328 PARTE 5 Desafiando Problemas Difíceis


Machine Translated by Google

FIGURA 16-3:
Destacando quais
transformações
são aplicadas.

Neste exemplo, o retrocesso aponta as seguintes transformações (duas


exclusões e uma substituição):

Sábado => Sábado => Domingo => Domingo

CAPÍTULO 16 Contando com Programação Dinâmica 329


Machine Translated by Google
Machine Translated by Google

NESTE CAPÍTULO

» Compreender como a aleatoriedade pode


ser mais inteligente do que formas mais
racionais

» Apresentando ideias-chave sobre


probabilidade e suas distribuições

» Descobrindo como um Monte Carlo


simulação funciona

» Aprendendo sobre seleção rápida e


revisitando algoritmos de classificação rápida

Capítulo 17

Usando Randomizado
Algoritmos
importante papel nas técnicas algorítmicas discutidas nesta parte do
Os geradores de números
livro. Conforme aleatórios
descrito são uma
na primeira partefunção chaveana
do capítulo, computaçãonão
randomização e desempenham
é apenas para um
jogos ou apostas; as pessoas também o empregam para resolver uma grande variedade de problemas.
A randomização às vezes se mostra mais eficaz durante a otimização do que
outras técnicas e na obtenção da solução certa do que formas mais racionais. Ele
ajuda diferentes técnicas a funcionarem melhor, por exemplo, pesquisa local,
recozimento simulado para heurística, criptografia e computação distribuída (sendo
a criptografia para ocultar informações a mais crítica).

A seção “Compreendendo como a probabilidade funciona” ilustra os princípios básicos


da probabilidade e, em seguida, explica como a distribuição de probabilidade pode se
conectar à randomização. Usando exemplos do jogo de cartas e do algoritmo de ordenação
de seleção rápida, a última parte do capítulo demonstra como a randomização pode
encontrar atalhos e chegar a um resultado de maneira mais fácil do que outros algoritmos.

Na verdade, a randomização torna mais simples encontrar uma solução, trocando tempo
por complexidade. A simplificação de tarefas não é sua única vantagem: a randomização
economiza recursos e opera de forma distribuída com menor necessidade de comunicação e

CAPÍTULO 17 Usando Algoritmos Aleatórios 331


Machine Translated by Google

coordenação. Este capítulo apresenta as informações necessárias para entender como


enriquecer seus algoritmos com aleatoriedade pode ajudar a resolver problemas (o capítulo
usa o termo injeção de aleatoriedade, como se fosse uma cura). Ainda mais aplicativos
aguardam nos capítulos seguintes, portanto, este capítulo também discute tópicos
importantes, como noções básicas de probabilidade, distribuições de probabilidade e
simulações de Monte Carlo.

Você não precisa digitar o código-fonte para este capítulo manualmente. Na verdade, usar
a fonte para download é muito mais fácil. Você pode encontrar a fonte deste capítulo no
A4D2E; 17; Probabilidade.ipynb e A4D2E; 17; Arquivos Quickselect.ipynb da fonte para
download. Consulte a Introdução para obter detalhes sobre como localizar esses arquivos
de origem.

Definindo como funciona a randomização


Você pode encontrar randomização inesperadamente incorporada em ferramentas do dia
a dia. As primeiras versões do aspirador de pó robô Roomba (projetado por uma empresa
fundada por pessoas do Instituto de Tecnologia de Massachusetts [MIT]) limpavam quartos
sem ter um plano preciso ou uma planta do local. A ferramenta funcionava na maior parte
do tempo vagando aleatoriamente pela sala e, de acordo com a patente original, depois de
bater em um obstáculo, girava um número aleatório de graus e começava em uma nova
direção. No entanto, o Roomba sempre completou suas tarefas de limpeza. (Se você está
curioso sobre como ele funcionava e como, ao longo dos anos, melhorou suas capacidades
de navegar pela casa, confira https://www.explainthatstuff.com/
how-roomba-works.html.)

De uma perspectiva histórica, os algoritmos aleatórios são uma inovação recente porque
o primeiro algoritmo desse tipo, o algoritmo do par mais próximo, foi desenvolvido por
Michael Rabin em 1976. O algoritmo do par mais próximo determina o par de pontos, entre
muitos em um plano geométrico, que possuem a menor distância entre eles, e o faz sem
ter que compará-los todos. O algoritmo do par mais próximo foi seguido no ano seguinte
pelo teste de primalidade aleatório (um algoritmo para determinar se um número é um
número composto ou um número primo provável), por Robert M. Solovay e Volker Strassen.
Logo depois, aplicações em criptografia e computação distribuída tornaram a randomização
mais popular e objeto de intensa pesquisa, embora o campo ainda seja novo e em grande
parte inexplorado.

A randomização depende da capacidade do seu computador de gerar números aleatórios,


o que significa criar o número sem um plano. Portanto, um número aleatório é imprevisível
e, à medida que você gera números aleatórios subsequentes, eles não devem se relacionar.

332 PARTE 5 Desafiando Problemas Difíceis


Machine Translated by Google

No entanto, a aleatoriedade é difícil de alcançar. Mesmo quando você joga dados, o resultado
não pode ser completamente inesperado por causa da maneira como você segura os dados,
da maneira como os joga e do fato de que os dados não são perfeitamente moldados. Os
computadores também não são bons em criar números aleatórios. Eles geram aleatoriedade
usando algoritmos ou tabelas pseudo-aleatórias (que funcionam usando um valor semente
como ponto de partida, um número equivalente a um índice) porque um computador não pode
criar um número verdadeiramente aleatório. Os computadores são máquinas determinísticas;
tudo dentro deles segue um padrão de resposta bem definido, o que significa que imita a
aleatoriedade de alguma forma.

Considerando por que a randomização é necessária


Mesmo que um computador não possa criar aleatoriedade verdadeira, fluxos de números
pseudoaleatórios (números que aparecem como aleatórios, mas são de alguma forma
predeterminados) ainda podem fazer a diferença em muitos problemas de ciência da
computação. Qualquer algoritmo que empregue aleatoriedade em sua lógica pode aparecer
como um algoritmo aleatório, não importa se a aleatoriedade determina seus resultados,
melhora o desempenho ou reduz o risco de falha fornecendo uma solução em determinados casos.

Normalmente, você encontra aleatoriedade empregada na seleção de dados de entrada, o


ponto de partida da otimização ou o número e tipo de operações a serem aplicadas aos dados.
Quando a aleatoriedade é uma parte central da lógica do algoritmo e não apenas uma ajuda
para seu desempenho, o tempo de execução esperado do algoritmo e até mesmo seus
resultados podem se tornar incertos e sujeitos à aleatoriedade também; por exemplo, um
algoritmo pode fornecer resultados diferentes, embora igualmente bons, durante cada execução.
Portanto, é útil distinguir entre tipos de soluções aleatórias, cada uma com o nome de locais de
jogo icônicos:

» Las Vegas: Esses algoritmos são notáveis por usar entradas aleatórias ou
recursos para fornecer sempre a resposta correta do problema. A obtenção de um
resultado pode levar um tempo incerto devido aos seus procedimentos aleatórios. Um
exemplo é o algoritmo de classificação rápida.

» Monte Carlo: Devido ao uso de aleatoriedade, os algoritmos de Monte Carlo podem não fornecer
uma resposta correta ou mesmo uma resposta, embora esses resultados raramente
aconteçam. Como o resultado é incerto, um número máximo de tentativas em seu tempo de
execução pode vinculá-los. Os algoritmos de Monte Carlo demonstram que os algoritmos nem
sempre resolvem com sucesso os problemas que deveriam resolver. Um exemplo é o teste de
primalidade Solovay–Strassen.

» Atlantic City: Esses algoritmos são executados em tempo polinomial, fornecendo uma
resposta correta do problema em pelo menos 75% das vezes. Os algoritmos de Monte
Carlo são sempre rápidos, mas nem sempre corretos, e os algoritmos de Las Vegas são
sempre corretos, mas nem sempre rápidos. As pessoas, portanto, pensam nos algoritmos
de Atlantic City como meio caminho entre os dois, porque geralmente são rápidos e corretos.

CAPÍTULO 17 Usando Algoritmos Aleatórios 333


Machine Translated by Google

Esta classe de algoritmos foi introduzida em 1982 por J. Finn em um manuscrito não
publicado intitulado Comparação de Teste Probabilístico para Primalidade. Criada
por razões teóricas para testar números primos, esta classe compreende soluções
difíceis de projetar, portanto, muito poucas delas existem hoje.

Entendendo como a probabilidade funciona


A probabilidade informa a probabilidade de um evento, que você normalmente expressa como
um número. Neste livro, e geralmente no campo dos estudos probabilísticos, a probabilidade
de um evento é medida no intervalo entre 0 (nenhuma probabilidade de que um evento ocorra)
e 1 (certeza de que um evento ocorrerá). Valores intermediários, como 0,25 ou 0,75, indicam
que o evento acontecerá com certa frequência sob condições que podem levar a esse evento
(referidos como tentativas). Mesmo que um intervalo numérico de 0 a 1 não pareça intuitivo
no início, trabalhar com probabilidade ao longo do tempo torna o motivo de usar esse intervalo
mais fácil de entender. Quando um evento ocorre com probabilidade de 0,25, você sabe que
em 100 tentativas, o evento provavelmente acontecerá em torno de 0,25 * 100 = 25 vezes.

Por exemplo, quando a probabilidade de seu time favorito vencer é 0,75, você pode usar o
número para determinar as chances de sucesso quando seu time jogar contra outro time.
Pode ainda obter informações mais específicas, como a probabilidade de ganhar um
determinado torneio (a sua equipa tem uma probabilidade de 0,65 de ganhar um jogo neste
torneio) ou condicionada por outro evento (quando um visitante, a probabilidade de ganhar
para a sua equipa diminui a 0,60).

As probabilidades podem dizer muito sobre um evento e também são úteis para algoritmos.
Em uma abordagem algorítmica aleatória, você pode se perguntar quando parar um algoritmo
porque ele deveria ter alcançado uma solução. É bom saber por quanto tempo procurar uma
solução antes de desistir. As probabilidades podem ajudá-lo a determinar quantas iterações
você pode precisar. A discussão do algoritmo de 2-satisfatibilidade (ou 2-SAT) no Capítulo 18
fornece um exemplo funcional do uso de probabilidades como regras de parada para um
algoritmo.

Você costuma ouvir falar de probabilidades como porcentagens em esportes e economia,


dizendo que um evento ocorre um certo número de vezes após 100 tentativas.
É exatamente a mesma probabilidade, não importa se você a expressa como 0,25 ou 25%.
Isso é apenas uma questão de convenções. No jogo, você até ouve falar de probabilidades,
que é outra maneira de expressar probabilidade, onde você compara a probabilidade de um
evento (por exemplo, um certo cavalo vencer a corrida) com o fato de o evento não acontecer.
Nesse caso, você expressa 0,25 como 25 contra 75, 25 a 75, ou de qualquer outra forma
resultando na mesma proporção.

Você pode multiplicar uma probabilidade para um número de tentativas e obter um número
estimado de ocorrências do evento, mas fazendo o inverso, você pode estimar empiricamente

334 PARTE 5 Desafiando Problemas Difíceis


Machine Translated by Google

uma probabilidade. Realize um certo número de tentativas, observe cada uma delas e conte o
número de vezes que um evento ocorre. A razão entre o número de ocorrências e o número de
tentativas é sua estimativa de probabilidade. Por exemplo, a probabilidade 0,25 é a probabilidade
de escolher um determinado naipe ao escolher uma carta aleatoriamente de um baralho de
cartas. As cartas de baralho francesas (o baralho mais usado; também aparecem nos Estados
Unidos e na Grã-Bretanha) fornecem um exemplo clássico para explicar probabilidades. (Os
italianos, alemães e suíços, por exemplo, usam baralhos com naipes diferentes, sobre os quais
você pode ler em http://healthy.uwaterloo.
ca/museum/VirtualExhibits/Playing%20Cards/decks/index.html.) O baralho contém 52 cartas
distribuídas igualmente em quatro naipes: paus e espadas, que são pretos, e ouros e copas,
que são vermelhos. Se você deseja determinar a probabilidade de pegar um ás, deve considerar
que, ao retirar cartas de um baralho, observará quatro ases. Suas tentativas de escolher as
cartas são 52 (o número de cartas), portanto a resposta em termos de probabilidade é 4/52 =
0,077.

Você pode obter uma estimativa mais confiável de uma probabilidade empírica usando um
número maior de tentativas. Ao usar algumas tentativas, você pode não obter uma estimativa
correta da probabilidade do evento devido à influência do acaso. À medida que o número de
tentativas aumenta, as observações do evento se aproximam da verdadeira probabilidade do próprio evento.
O princípio existe um processo gerador por trás dos eventos. Para entender como funciona o
processo de geração, você precisa de muitos testes. Usar tentativas dessa maneira também é
conhecido como amostragem de uma distribuição probabilística.

Entendendo as distribuições
A distribuição de probabilidade é outra ideia importante para elaborar algoritmos melhores. Uma
distribuição é uma tabela de valores ou uma função matemática que liga todos os valores
possíveis de uma entrada à probabilidade de que tais valores possam ocorrer.
As distribuições de probabilidade são geralmente (mas não apenas) representadas em gráficos
cujo eixo de abcissas representa os valores possíveis de uma entrada e cujo eixo ordinal
representa a probabilidade de ocorrência. A maioria dos modelos estatísticos baseia-se nas
distribuições normais, uma distribuição que é simétrica e tem uma forma de sino característica.
Representar uma distribuição normal em Python (como mostrado na Figura 17-1) requer algumas
linhas de código. (Você pode encontrar este código no arquivo de código fonte para download
A4D2E; 17; Probability.ipynb ; veja a Introdução para detalhes.) Observe como o código força o
computador a desenhar os mesmos números casuais usando o comando seed(0), que recupera
uma sequência específica de números aleatórios que é armazenada na memória do computador.
Como mencionado anteriormente, os computadores são determinísticos e a aleatoriedade só
pode ser imitada de alguma forma.

de sementes de importação aleatória, gauss, uniforme


importar matplotlib.pyplot como plt

semente(0)
distribuição_normal = [gauss(mu=25, sigma=100)

CAPÍTULO 17 Usando Algoritmos Aleatórios 335


Machine Translated by Google

para r no intervalo (10_000)]


pesos = [1./10_000] * 10_000

plt.figure(figsize=(12, 6))
plt.hist(normal_distribution, bins=20, weights=weights,
color='azul claro', edgecolor='preto',
largura de linha = 1,2)
plt.xlabel("Valor")
plt.ylabel("Probabilidade")
plt.show()

FIGURA 17-1:
Um histograma de
uma normal
distribuição.

A distribuição plotada representa uma entrada de 10.000 números cuja média é de cerca
de 100. Cada barra no histograma representa a probabilidade de que um determinado
intervalo de valores apareça na entrada. Se você somar todas as barras, obtém o valor 1,
que compreende todas as probabilidades expressas pela distribuição.

Em uma distribuição normal, a maioria dos valores está em torno do valor médio. Portanto,
se você escolher um número aleatório da entrada, provavelmente obterá um número ao
redor do centro da distribuição. Você também pode desenhar um número longe do centro
com menos frequência. Se o seu algoritmo funciona melhor usando a média do que com
qualquer outro número, escolher um número aleatoriamente de uma distribuição centrada
na média faz sentido e pode ser menos problemático do que criar uma maneira mais
sofisticada de elaborar valores para sua entrada.

Outra distribuição importante mencionada neste capítulo é a distribuição uniforme. Você


pode representá-lo usando algum código Python (a saída aparece na Figura 17-2) também:

336 PARTE 5 Desafiando Problemas Difíceis


Machine Translated by Google

semente(0)
distribuição_uniforme = [uniforme(a=0, b=100)
para r no intervalo (10_000)]
pesos = [1./10_000] * 10_000

plt.figure(figsize=(12, 6))
plt.hist(uniform_distribution, bins=20, weights=weights,
color='azul claro', edgecolor='preto',
largura de linha = 1,2)
plt.xlabel("Valor")
plt.ylabel("Probabilidade")
plt.show()

FIGURA 17-2:
Um histograma de
uma
distribuição uniforme.

A distribuição uniforme é visivelmente diferente da distribuição normal porque cada número tem
a mesma probabilidade de estar na entrada que qualquer outro.
Consequentemente, as barras do histograma são todas aproximadamente do mesmo tamanho,
e escolher um número em uma distribuição uniforme significa dar a todos os números a mesma
chance de aparecer. É uma maneira de evitar escolher sistematicamente os mesmos grupos de
números quando seu algoritmo funciona melhor com entradas variadas. Por exemplo,
distribuições uniformes funcionam bem quando seu algoritmo funciona bem com certos números,
mais ou menos com a maioria e mal com alguns outros, e você prefere escolher números
aleatoriamente para evitar escolher uma série de números ruins. Essa é a estratégia usada
pelos algoritmos de seleção rápida e classificação rápida aleatória, descritos mais adiante neste capítulo.

Como os algoritmos precisam de entradas numéricas, conhecer sua distribuição pode ajudar a
torná-los mais inteligentes. Não é apenas a distribuição inicial que conta. Você também pode
tirar proveito de como a distribuição de dados muda à medida que o algoritmo avança.

CAPÍTULO 17 Usando Algoritmos Aleatórios 337


Machine Translated by Google

Como exemplo de como uma distribuição variável pode melhorar seu algoritmo, o código a
seguir mostra como adivinhar uma carta em um baralho francês por escolha aleatória:

números = ['Ás', '2', '3', '4', '5', '6', '7', '8', '9',
'10', 'Jack', 'Rainha', 'Rei']
sementes = ['Paus','Espadas','Diamantes','Corações']
baralho = [s+'_'+n para n em números para s em sementes]

da escolha de importação aleatória

semente(0)
my_cards = deck.copy()
adivinhado = 0
para cartão no baralho:

if card == escolha(meus_cartões):
adivinhado += 1
print('Adivinha %i carta(s)' % adivinhou)

Adivinhou 2 cartas

O código começa criando um baralho usando uma combinação dos valores potenciais das
cartas: Ás a Rei e os naipes potenciais das cartas: Paus a Copas com compreensão de listas
em Python, de modo que o baralho resultante contenha valores como 'Hearts_King' e
'Clubs_Ace' . O código então importa a função choice() , que pode selecionar um item do
baralho aleatoriamente. A chamada para seed(0) garante que você obtenha o mesmo resultado
todas as vezes, mas normalmente você não definiria um valor de semente dessa maneira para
garantir que o resultado realmente pareça aleatório. O próximo passo é criar uma cópia do deck,
my_cards, para usar na comparação. O código então depende de um loop para passar por cada
carta no baralho em ordem e obtém uma carta aleatória de my_cards chamando choice().
Quando os dois coincidem, o valor de adivinhado é incrementado.

Essa estratégia traz poucos resultados e, em média, você adivinhará uma única carta em todas
as 52 tentativas. Na verdade, para cada tentativa, você tem uma probabilidade de 1/52 de
adivinhar a carta correta, o que equivale a 1 depois de escolher todas as cartas:
Em vez(1/52)
disso,*você
52 = 1.
pode alterar esse algoritmo aleatório simples por descartando as cartas que você viu de suas
escolhas possíveis:

semente(0)
my_cards = deck.copy()
adivinhado = 0
para cartão no baralho:

338 PARTE 5 Desafiando Problemas Difíceis


Machine Translated by Google

if card == escolha(meus_cartões):
adivinhado += 1
senão:
my_cards.pop(my_cards.index(card))
print('Adivinha %i carta(s)' % adivinhou)

Adivinhou 3 cartas

Agora, em média, você adivinhará a carta certa com mais frequência porque, à medida que o
baralho diminui, suas chances de adivinhar corretamente aumentam e você provavelmente
adivinhará corretamente com mais frequência quando estiver chegando ao final do jogo. (Suas
chances são 1 dividido pelo número de cartas restantes no baralho.)

Contar cartas pode fornecer uma vantagem em jogos de cartas. Uma equipe de estudantes do
MIT usou contagem de cartas e estimativas de probabilidade para ganhar grandes quantias em
Las Vegas até que a prática foi banida dos cassinos. A história até inspirou um filme de 2008
intitulado 21, estrelado por Kevin Spacey. Você pode ler mais sobre a história em https://
www.bbc.com/news/magazine-27519748.

Simulando o uso do
Método de Monte Carlo
Calcular probabilidades, além das operações discutidas anteriormente neste capítulo, está além
do escopo deste livro. Entender como funciona um algoritmo que incorpora aleatoriedade não é
uma tarefa fácil, mesmo quando você sabe calcular probabilidades, porque pode ser o resultado da
combinação de muitas distribuições de probabilidade diferentes. No entanto, uma discussão sobre
o método de Monte Carlo lança luz sobre os resultados dos algoritmos mais complexos e ajuda a
entender como eles funcionam.
Este método é usado tanto na matemática quanto na física para resolver muitos problemas.
Por exemplo, cientistas como Enrico Fermi e Edward Teller usaram simulações de Monte Carlo
em supercomputadores especialmente concebidos durante o projeto Manhattan (que desenvolveu
a bomba atômica durante a Segunda Guerra Mundial) para acelerar seus experimentos. Você
pode ler mais sobre esse uso em https://www.atomicheritage.org/
história/computação-e-manhattan-project.

Não confunda o método de Monte Carlo com um algoritmo de Monte Carlo. O método de Monte
Carlo é uma maneira de entender como uma distribuição de probabilidade afeta um problema,
enquanto, como discutido anteriormente, um algoritmo de Monte Carlo é um algoritmo aleatório que
não tem garantia de chegar a uma solução.

CAPÍTULO 17 Usando Algoritmos Aleatórios 339


Machine Translated by Google

Em uma simulação de Monte Carlo, você amostra repetidamente os resultados do


algoritmo. Você armazena um certo número de resultados e, em seguida, calcula
estatísticas, como a média, e as visualiza como uma distribuição. Por exemplo, se você
quiser entender melhor como reduzir o tamanho do baralho do qual você está desenhando
pode ajudá-lo a obter melhores resultados (como no script Python anterior), você itera o
algoritmo algumas vezes e registra a taxa de sucesso:

importar numpy como np


semente(0)
amostras = lista()
para teste no intervalo (10_000):
my_cards = deck.copy()
adivinhado = 0
para cartão no baralho:

if card == escolha(meus_cartões):
adivinhado += 1
senão:
my_cards.pop(my_cards.index(card))
samples.append(adivinhado)

A execução de uma simulação de Monte Carlo pode levar alguns segundos. O tempo
necessário depende da velocidade do algoritmo, do tamanho do problema e do número
de tentativas. No entanto, ao fazer amostragem de distribuições, quanto mais tentativas
você fizer, mais estável será o resultado. Este exemplo executa 10.000 tentativas. Você
pode estimar e visualizar o resultado esperado (veja a Figura 17-3) usando o seguinte código:

plt.figure(figsize=(12, 6))
plt.hist(amostras, bins=8, color='azul claro',
edgecolor='preto', largura da linha=1.2)
plt.xlabel("Suposições")
plt.ylabel("Frequência")
plt.show()
média = round(soma(amostras) / len(amostras), 2)
print(f'Em média, você pode esperar {mean} palpites a cada execução')

Em média, você pode esperar 3,13 palpites a cada execução

Observando o histograma resultante, você pode determinar que obtém um resultado de


três em cerca de 3.000 execuções das 10.000 tentativas, o que dá três a maior
probabilidade de acontecer. Curiosamente, você nunca recebe um resultado zero, mas
também é raro marcar sete ou mais acertos. Exemplos posteriores no capítulo usam
simulações de Monte Carlo para entender como algoritmos aleatórios mais sofisticados
funcionam.

340 PARTE 5 Desafiando Problemas Difíceis


Machine Translated by Google

FIGURA 17-3:
Exibindo os
resultados
de uma
simulação de Monte Carlo.

Colocando aleatoriedade em sua lógica


Aqui estão algumas das muitas razões para incluir aleatoriedade na lógica do seu algoritmo:

» Faz com que os algoritmos funcionem melhor e forneçam soluções mais inteligentes.

» Requer menos recursos, em termos de memória e computação.

» Cria algoritmos que têm uma saída distribuída com pouca ou nenhuma
supervisão.

No próximo capítulo, dedicado à busca local, você verá como a randomização e a probabilidade
podem ser úteis quando é difícil determinar a direção que seu algoritmo deve tomar. Os exemplos
nas seções a seguir demonstram como a aleatoriedade ajuda a encontrar rapidamente valores em
uma determinada posição em sua entrada de dados, bem como confiar na aleatoriedade pode
acelerar a classificação.

Calculando uma mediana usando a seleção rápida


Calcular uma medida estatística, a mediana, pode ser um desafio quando você trabalha em listas
de entrada não classificadas. Na verdade, uma mediana depende da posição de seus dados
quando eles são ordenados:

» Se as entradas de dados tiverem um número ímpar de elementos, a mediana é exatamente o


valor médio.

» Se as entradas de dados tiverem um número par de elementos, a mediana é a


média do par de números do meio na lista de entrada ordenada.

CAPÍTULO 17 Usando Algoritmos Aleatórios 341


Machine Translated by Google

Uma mediana é como uma média, um valor único que pode representar uma distribuição de valores.
A mediana, baseada na ordem dos elementos do vetor de entrada, não é muito influenciada pelos
valores presentes em sua lista. É simplesmente o valor médio. Em vez disso, os valores presentes
no início e no final da entrada podem influenciar a média quando são extremamente pequenos ou
grandes. Essa robustez torna a mediana muito útil em muitas situações ao usar estatísticas. Um
exemplo simples de cálculo de mediana usando funções do Python ajuda você a entender essa
medida. (Você pode encontrar esse código em A4D2E; 17; arquivo de código-fonte para download
Quickselect.ipynb; consulte a Introdução para obter detalhes.)

de importação aleatória randint, aleatório


da escolha de importação aleatória, semente
sistema de importação

sys.setrecursionlimit(1500)

n = 501
semente(0)
series = [randint(1,25) for i in range(n)]

da mediana de importação de estatísticas


# https://docs.python.org/3/library/statistics.html
print(f'Median is {median(series)}')

A mediana é 12,0

O código cria uma lista de 501 elementos e obtém a mediana da lista usando a função median() do
pacote NumPy. A mediana informada é, na verdade, o ponto médio da lista ordenada, que é o 251º
elemento:

elem_251 = classificado(série)[250]
print(f'251º elemento da série ordenada é {elem_251}')

251º elemento da série ordenada é 12

Ordenar a lista e extrair o elemento necessário demonstra como median() funciona. Como a
ordenação está envolvida no cálculo de uma mediana, você pode esperar um melhor tempo de
execução de O(n*log(n)). Usando a randomização fornecida pelo algoritmo de seleção rápida, você
pode obter um resultado ainda melhor, um tempo de execução de O(n). A seleção rápida funciona
recursivamente, e é por isso que você deve definir um limite de recursão mais alto no Python, dada
uma lista e a posição do valor necessário de uma lista ordenada. O índice de valor é chamado de k,
e o algoritmo também é conhecido como o algoritmo de maior k-ésimo valor. Ele usa as seguintes
etapas para obter um resultado:

1. Determine um número pivô na lista de dados e divida a lista em duas partes, uma lista à
esquerda cujos números são menores que o número pivô e uma lista à direita cujos
números são maiores.

342 PARTE 5 Desafiando Problemas Difíceis


Machine Translated by Google

2. Determine o comprimento de cada lista. Quando o comprimento da lista esquerda é maior


que a k-ésima posição, o valor mediano está dentro da parte esquerda. O algoritmo se
aplica recursivamente apenas a essa lista.

3. Calcule o número de duplicatas do número pivô na lista (subtraia do


comprimento da lista o comprimento dos lados esquerdo e direito).

4. Determine se o número de duplicatas é maior que k.


(a) Quando esta condição for verdadeira, significa que o algoritmo encontrou a solução
porque a k-ésima posição está contida nas duplicatas (é o número pivô).

(b) Quando esta condição não for verdadeira, remova o número de duplicatas de k e aplique o
resultado recursivamente ao lado direito, que deve conter o valor da k-ésima posição.

Agora que você entende o processo, você pode ver algum código. O exemplo a
seguir mostra como implementar um algoritmo de seleção rápida:

def quickselect(series, k):


pivô = escolha(série)

esquerda, direita = lista(), lista()


para o item em série:

if item < pivô:


left.append(item)
if item > pivô:
right.append(item)
comprimento_esquerda = len(esquerda)
se comprimento_esquerda > k:
return quickselect(esquerda, k)
k -= comprimento_esquerda

duplicatas = len(série) - (comprimento_esquerda + len(direita))


se duplicatas > k:
return float(pivô)
k -= duplicatas

return quickselect(direita, k)

quickselect(série, 250)

12,0

O algoritmo funciona bem porque continua reduzindo o tamanho do problema.


Funciona melhor quando o número do pivô aleatório é aproximado da k-ésima posição. (O

CAPÍTULO 17 Usando Algoritmos Aleatórios 343


Machine Translated by Google

a regra de parada é que o número pivô é o valor na k-ésima posição.) Infelizmente, porque
você não pode saber a k-ésima posição na lista não ordenada, desenhando aleatoriamente
usando uma distribuição uniforme (cada elemento na lista tem a mesma chance de ser
escolhido) é a melhor solução porque o algoritmo eventualmente encontra a solução certa.
Mesmo quando o acaso não funciona a favor do algoritmo, o algoritmo continua reduzindo o
problema, obtendo assim mais chances de encontrar a solução, como demonstrado
anteriormente no capítulo ao adivinhar as cartas escolhidas aleatoriamente de um baralho. À
medida que o baralho fica menor, adivinhar a resposta fica mais fácil. O código a seguir
mostra como usar a seleção rápida para determinar a mediana de uma lista de números:

def minha_mediana(série):
if len(série) % 2 != 0:
return quickselect(series, len(series)//2)
senão:
left = quickselect(series, (len(series)-1) // 2)
right = quickselect(series, (len(series)+1) // 2)
return (esquerda + direita) / 2

minha_mediana(série)

12,0

Fazendo simulações usando Monte Carlo


Como parte da compreensão do algoritmo de seleção rápida, vale a pena saber como ele
funciona internamente. Ao definir um contador dentro da função quickselect() , você pode
verificar o desempenho em diferentes condições usando uma simulação de Monte Carlo:

def quickselect(series, k, counter=0):


pivô = escolha(série)

esquerda, direita = lista(), lista()


para o item em série:

if item < pivô:


left.append(item)
if item > pivô:
right.append(item)

contador += len(série)
comprimento_esquerda = len(esquerda)
se comprimento_esquerda > k:

344 PARTE 5 Desafiando Problemas Difíceis


Machine Translated by Google

return quickselect(esquerda, k, contador)


k -= comprimento_esquerda

duplicatas = series.count(pivô)
se duplicatas > k:
return float(pivô), contador
k -= duplicatas

return quickselect(direita, k, contador)

O primeiro experimento tenta determinar quantas operações o algoritmo precisa, em


média, para encontrar a mediana de uma lista de entrada de 10.001 números:

resultados = lista()
n = 10_001
para executar no intervalo (1, n):
series = [randint(1, 25) for i in range(n)]
med, contagem = quickselect(series, n//2)
assert(med==median(série))
resultados.append(contagem)

avg_ops = soma(resultados) / len(resultados)


print(f"Operações médias: {avg_ops}")

Operações médias: 27564,3649

A exibição dos resultados em um histograma (veja a Figura 17-4) revela que o algoritmo
calcula de duas a quatro vezes o tamanho da entrada, sendo três vezes o número mais
provável de cálculos processados.

importar matplotlib.pyplot como plt

plt.figure(figsize=(12, 6))
plt.hist(resultados, bins=30, color='azul claro',
edgecolor='preto', largura da linha=1.2)
plt.xlabel("Cálculos")
plt.ylabel("Frequência")
plt.show()

Se em média demorar cerca de três vezes o tamanho da entrada, a seleção rápida está
proporcionando um bom desempenho. No entanto, você pode se perguntar se a proporção
entre entradas e cálculos se manterá quando o tamanho da entrada aumentar. Como visto
ao estudar problemas NP-completos, muitos problemas explodem quando o tamanho da
entrada aumenta. Você pode provar essa teoria usando outra simulação de Monte Carlo
sobre a anterior e plotando a saída, conforme mostrado na Figura 17-5.

CAPÍTULO 17 Usando Algoritmos Aleatórios 345


Machine Translated by Google

FIGURA 17-4:
Exibindo os
resultados
de uma
simulação de
Monte Carlo em seleção rápida.

input_size = [501, 1001, 5001, 10001, 20001, 50001]


cálculos = lista()
para n em input_size:
resultados = lista()
para executar no intervalo (1000):
series = [randint(1, 25) for i in range(n)]
med, contagem = quickselect(series, n//2)
assert(med==median(série))
resultados.append(contagem)
avg_ops = soma(resultados) / len(resultados)
computations.append(avg_ops)

plt.figure(figsize=(12, 6))
plt.plot(input_size, cálculos, '-o')
plt.xlabel("Tamanho de entrada")
plt.ylabel("Número de cálculos")
plt.show()

A conclusão dos cálculos deste exemplo pode levar até dez minutos (algumas simulações de
Monte Carlo podem consumir bastante tempo), mas o resultado ajuda a visualizar o que
significa trabalhar com um algoritmo que funciona com tempo linear. À medida que a entrada
cresce (representada em abcissa), os cálculos (representados no eixo ordinal) crescem
proporcionalmente, tornando a curva de crescimento uma linha perfeita.

346 PARTE 5 Desafiando Problemas Difíceis


Machine Translated by Google

FIGURA 17-5:
Exibindo simulações de
Monte Carlo à medida

que a entrada aumenta.

Encomendar mais rapidamente com a ordenação rápida

O Capítulo 7 explica os algoritmos de ordenação, a verdadeira base de todo o


conhecimento algorítmico moderno baseado em computador. O algoritmo de classificação
rápida, que pode ser executado em tempo logarítmico, mas às vezes falha e produz
resultados em tempo quadrático sob entradas mal condicionadas, certamente o
surpreenderá. Esta seção explora as razões pelas quais esse algoritmo pode falhar e
fornece uma solução eficaz injetando aleatoriedade nele. Comece examinando o seguinte código:

def quicksort(series, get):

tentar:
operações globais
operações += len(série)
exceto: passar
se len(série) <= 3:
retorno ordenado (série)

pivô = get(série)
duplicatas = series.count(pivô)

esquerda, direita = lista(), lista()


para o item em série:

if item < pivô:


left.append(item)
if item > pivô:
right.append(item)

return quicksort(esquerda, get) + [pivot


] * duplicatas + quicksort(right, get)

CAPÍTULO 17 Usando Algoritmos Aleatórios 347


Machine Translated by Google

Esta é outra implementação do algoritmo do Capítulo 7. Entretanto, desta vez o código extrai a
função que decide o pivô que o algoritmo usa para dividir recursivamente a lista inicial. O
algoritmo decide a divisão tomando o primeiro valor da lista. Ele também rastreia quantas
operações são necessárias para concluir a ordenação usando a variável global de operações ,
que é definida, redefinida e acessada como um contador fora da função. O código a seguir
testa o algoritmo, sob condições inusitadas, exigindo que ele processe uma lista já ordenada.
Observe seu desempenho:

série = lista(intervalo(25))
operações = 0
sorted_list = quicksort(series, choose_leftmost)
print(f"Operações: {operações}" )

Neste exemplo, choose_leftmost() fornece uma saída básica, conforme mostrado aqui, da
seleção do item mais à esquerda em uma lista:

def escolha_esquerda(l): return l[0]

A saída mostra o número de operações.

Operações: 322

Nesse caso, o algoritmo leva 322 operações para ordenar uma lista de 25 elementos, o que é
um desempenho horrível. Usar uma lista já ordenada causa o problema porque o algoritmo
divide a lista em duas listas: uma vazia e outra com os valores residuais. Ele precisa repetir
essa divisão inútil para todos os valores exclusivos presentes na lista. Normalmente, o algoritmo
de classificação rápida funciona bem porque funciona com listas não ordenadas, e escolher o
elemento mais à esquerda é equivalente a desenhar aleatoriamente um número como pivô.
Para evitar esse problema, você pode usar uma variação do algoritmo que fornece um sorteio
aleatório verdadeiro do valor do pivô.

def escolha_random(l): return escolha(l)

semente(0)
series = [randint(1,25) for i in range(25)]
operações = 0
sorted_list = quicksort(series, choose_random)
print(f"Operações: {operações}" )

Operações: 92

Agora, o algoritmo executa sua tarefa usando um número um pouco maior de operações,
próximo ao tempo de execução estimado de n * log(n), ou seja, 25 * log(25) = 80,5.

348 PARTE 5 Desafiando Problemas Difíceis


Machine Translated by Google

NESTE CAPÍTULO

» Determinando como realizar uma busca


local em um problema NP-difícil

» Trabalhando com heurísticas e


soluções vizinhas

» Descobrindo os muitos truques para aplicar


em uma pesquisa local

» Resolvendo o problema 2-SAT com busca


local e randomização

Capítulo 18

Realizando pesquisa local


para encontrar uma solução exata devido a restrições de tempo e recursos, ou
Os capítulos anteriores
por causa mostram como
da dificuldade a resolução
(ou talvez de algoritmos
impossibilidade) do nem sempre
problema corresponde
que você está
voltado para. Tais situações terríveis exigem compromisso, trocando soluções exatas
por aproximações viáveis. Este capítulo discute uma abordagem, a busca local (um tipo
de otimização), que ajuda a criar um compromisso com problemas difíceis e alcançar
soluções satisfatórias. A pesquisa local também é às vezes chamada de técnica de
melhoria local.

A primeira parte do capítulo apresenta os princípios sob os quais a pesquisa local funciona e,
em seguida, analisa os principais ingredientes de uma solução de pesquisa local. Também
apresenta os conceitos-chave de heurísticas e soluções vizinhas, sem os quais a busca local
não existiria. Por fim, você explora uma série de truques para fazer com que os algoritmos de
pesquisa local funcionem melhor. (A randomização é uma combinação muito boa para a
maioria das soluções de pesquisa local.)

A parte conclusiva se aprofunda em um problema, o problema de 2-satisfatibilidade (2-


SAT). Esse problema tem profundas implicações na produção industrial (para
personalização de produtos), planejamento de controle de tráfego aéreo e programação
de torneios esportivos.

CAPÍTULO 18 Realizando Pesquisa Local 349


Machine Translated by Google

Você não precisa digitar o código-fonte para este capítulo manualmente. Na verdade, usar a
fonte para download é muito mais fácil. Você pode encontrar a fonte deste capítulo no A4D2E;
18; Arquivo local Search.ipynb da fonte para download. Consulte a Introdução para obter
detalhes sobre como localizar esse arquivo de origem.

Noções básicas sobre a pesquisa local


Ao lidar com um problema NP-difícil, um problema para o qual nenhuma solução conhecida
tem uma complexidade de execução menor que exponencial (veja a discussão da teoria da NP-
completude no Capítulo 15), você tem algumas alternativas que valem a pena tentar. Com base
na ideia de que os problemas da classe NP exigem algum compromisso (como aceitar
resultados parciais ou não ótimos), as seguintes opções oferecem uma solução para esse
problema intratável:

» Identifique casos especiais em que você pode resolver o problema de forma eficiente
tempo polinomial usando um método exato ou um algoritmo guloso. Essa abordagem
simplifica o problema e limita o número de combinações de soluções para tentar.

» Empregar técnicas de programação dinâmica (descritas no Capítulo 16) que melhoram


a busca de força bruta e reduzem a complexidade do problema.

» Comprometa e esboce um algoritmo aproximado que encontre um


solução próxima do ideal. Quando você está satisfeito com uma solução parcial, você
reduz o tempo de execução do algoritmo. Algoritmos aproximados podem ser

• Algoritmos gananciosos (como discutido no Capítulo 15)

• Programação linear (o tópico do Capítulo 19)

• Escolha uma heurística ou meta-heurística (uma heurística que ajuda a determinar


qual heurística usar) que funcione bem para seu problema na prática.
No entanto, não tem garantia teórica e tende a ser empírico.

» Empregar busca local usando randomização ou alguma outra técnica heurística.

A busca local é uma abordagem geral de solução de problemas que compreende uma grande
variedade de algoritmos. Usar a busca local ajuda a escapar das complexidades exponenciais
de muitos problemas NP. Uma busca local começa a partir de uma solução de problema
imperfeita e se afasta dela, um passo de cada vez. Ele determina a viabilidade de soluções
próximas, potencialmente levando a uma solução perfeita, com base na escolha aleatória ou
em uma heurística astuta (nenhum método exato está envolvido).

350 PARTE 5 Desafiando Problemas Difíceis


Machine Translated by Google

Uma heurística é um palpite sobre uma solução, como uma regra prática que aponta para a
direção de um resultado desejado, mas não pode dizer exatamente como alcançá-lo.
É como estar perdido em uma cidade desconhecida e ter pessoas lhe dizendo para ir por um
certo caminho para chegar ao seu hotel (mas sem instruções precisas) ou a que distância você
está. Algumas soluções de busca local usam heurística, então você encontrará alguns exemplos
delas neste capítulo. O Capítulo 20 aprofunda todos os detalhes do uso de heurísticas para
realizar tarefas práticas.

Você não tem garantia de que uma pesquisa local chegará a uma solução de problema, mas suas
chances aumentam desde o ponto de partida quando você fornece tempo suficiente para que a
pesquisa execute seus cálculos. Ele só para depois de não encontrar mais nenhuma maneira de
melhorar a solução alcançada.

Conhecendo o bairro
Os algoritmos de busca local melhoram iterativamente a partir de uma solução inicial, movendo
um passo de cada vez pelas soluções vizinhas até que não seja possível melhorar a solução
mais. Como os algoritmos de busca local são tão simples e intuitivos quanto os algoritmos
gulosos, projetar uma abordagem de busca local para um problema algorítmico não é difícil. A
chave é definir o procedimento correto:

1. Comece com uma solução existente (geralmente uma solução aleatória ou uma
solução de outro algoritmo).

2. Procurar um conjunto de possíveis novas soluções dentro da vizinhança


da solução atual, que constitui a lista de candidatos.

3. Determine qual solução usar no lugar da solução atual com base na saída de uma
heurística que aceita a lista de candidatos como entrada.

4. Continue executando as Etapas 2 e 3 até não ver mais melhorias na solução, o que
significa que você tem a melhor solução disponível nesta vizinhança.

Embora fáceis de projetar, as soluções de busca local podem não encontrar uma solução em
um tempo razoável (você pode interromper o processo depois de um tempo e usar a solução
atual) ou produzir uma solução de qualidade mínima. Você pode empregar alguns truques do
comércio para garantir que você tire o máximo proveito dessa abordagem.

No início da pesquisa local, você escolhe uma solução inicial. Se você decidir por uma solução
aleatória, é útil agrupar a pesquisa em iterações repetidas nas quais você gera diferentes soluções
de início aleatório. Às vezes, chegar a uma boa solução final depende do ponto de partida. Se
você começar a partir de uma solução existente que deseja refinar, conectar a solução inicial
fornecida por um algoritmo guloso pode ser um bom compromisso, pois não demorará muito para
produzir o

CAPÍTULO 18 Realizando Pesquisa Local 351


Machine Translated by Google

solução gulosa e ajustar uma solução de busca local diretamente a partir dela. Depois de
escolher um ponto de partida, defina a vizinhança e determine seu tamanho. Definir uma
vizinhança requer descobrir a menor mudança que você pode impor à sua solução. Se uma
solução é um conjunto de elementos, todas as soluções vizinhas são os conjuntos em que um
dos elementos sofre mutação. Por exemplo, no problema do caixeiro viajante (TSP), as soluções
vizinhas podem envolver a mudança das cidades finais de duas (ou mais) viagens, conforme
mostrado na Figura 18-1.

FIGURA 18-1:
A comutação de viagens
finais em um
problema de TSP
pode trazer
melhores resultados.

Com base em como você cria o bairro, você pode ter uma lista de candidatos menor ou maior.
Listas maiores exigem mais tempo e cálculos, mas, ao contrário das listas curtas, podem
oferecer mais oportunidades para que o processo termine mais cedo e melhor.
O comprimento da lista envolve uma compensação que você refina usando a experimentação
após cada teste para determinar se aumentar ou diminuir a lista de candidatos traz uma
vantagem ou desvantagem em termos de tempo para conclusão e qualidade da solução.

Baseie a escolha da nova solução em uma heurística e, dado o problema, decida a melhor
solução. Por exemplo, no problema TSP, use os interruptores de viagem que encurtam mais a
duração total do passeio. Em certos casos, você pode usar uma escolha aleatória no lugar de
uma heurística (como você descobriu no problema SAT-2, também chamado de problema 2-
SAT, neste capítulo). Mesmo quando você tem uma heurística clara, o algoritmo pode encontrar
várias soluções melhores. Injetar alguma aleatoriedade pode tornar sua pesquisa local mais
eficiente. Quando confrontado com muitas soluções, você pode escolher uma aleatoriamente
com segurança.

Idealmente, em uma pesquisa local, você obtém os melhores resultados ao executar várias
pesquisas, injetando o máximo de aleatoriedade possível na solução inicial e ao longo do
caminho à medida que decide a próxima etapa do processo. Deixe a heurística decidir apenas
quando você vir uma clara vantagem em fazê-lo. A busca local e a aleatoriedade são bons amigos.

352 PARTE 5 Desafiando Problemas Difíceis


Machine Translated by Google

Sua pesquisa deve parar em um determinado ponto, portanto, você precisa escolher as regras de
parada para a pesquisa local. Quando sua heurística não consegue mais encontrar bons vizinhos
ou não consegue melhorar a qualidade da solução (por exemplo, computar uma função de custo,
como acontece no TSP, medindo a duração total do passeio). Dependendo do problema, se você
não criar uma regra de parada, sua pesquisa pode continuar indefinidamente ou levar um tempo
inaceitavelmente longo. Caso não consiga definir um ponto de parada, limite o tempo gasto na
busca de soluções ou conte o número de tentativas. No entanto, ao observar o relógio ou contar
as tentativas, você está baseando as decisões na probabilidade de sucesso do algoritmo em um
determinado ponto do processo. Embora esta seja uma abordagem conveniente, basear o
processo no tempo ou nos testes pode levar a soluções menos do que aceitáveis.

Apresentando truques de pesquisa local


A busca local rastreia a solução atual e move-se para soluções vizinhas uma de cada vez até
encontrar uma solução perfeita (ou não pode melhorar a solução atual). Apresenta algumas
vantagens importantes ao trabalhar em problemas NP-difíceis porque

» É simples de conceber e executar

» Usa pouca memória e recursos do computador (mas as pesquisas requerem a execução


Tempo)

» Encontra soluções de problemas aceitáveis ou mesmo boas ao iniciar de um


solução menos que perfeita (soluções vizinhas devem criar um caminho para a solução perfeita)

Você pode ver os problemas que uma pesquisa local pode resolver como um gráfico de soluções
interconectadas. O algoritmo percorre o grafo, movendo-se de nó em nó procurando o nó que
satisfaça os requisitos da tarefa. Usando essa perspectiva, uma busca local tira vantagem de
algoritmos de exploração de grafos, como busca em profundidade (DFS) ou busca em largura
(BFS), ambos discutidos no Capítulo 9.

A busca local fornece uma maneira viável de encontrar soluções aceitáveis para problemas NP-
difíceis. No entanto, a pesquisa local não pode funcionar corretamente sem a heurística correta.
A randomização pode fornecer uma boa correspondência com a pesquisa local e ajuda usando

» Amostragem aleatória: Gerando soluções para começar

» Caminhada aleatória: Escolhendo uma solução aleatória que seja vizinha da atual
(Você encontra mais sobre passeios aleatórios na seção “Resolvendo 2-SAT usando
randomização”, mais adiante neste capítulo.)

CAPÍTULO 18 Realizando Pesquisa Local 353


Machine Translated by Google

A randomização não é a única heurística disponível. Uma busca local pode contar com uma
exploração mais racional de soluções usando uma função objetivo para obter direções (como
na otimização de escalada ) e evitar a armadilha de soluções mais ou menos (como no
recozimento simulado e na Busca Tabu). Uma função objetivo é uma computação que pode
avaliar a qualidade de sua solução emitindo um número de pontuação. Se você precisa de
pontuações mais altas em escalada, você tem um problema de maximização; se você estiver
procurando por números de pontuação menores, terá um problema de minimização.

Explicando a escalada de colinas com n-rainhas


Você pode facilmente encontrar analogias das técnicas empregadas pela busca local porque
muitos fenômenos implicam uma transição gradual de uma situação para outra. A busca local
não é apenas uma técnica desenvolvida por especialistas em algoritmos, mas na verdade é
um processo que você vê na natureza e na sociedade humana. Na sociedade e na ciência,
por exemplo, você pode ver a inovação como uma busca local do próximo passo entre as
tecnologias atualmente disponíveis: https://www.technologyreview.com/
2017/01/13/154580/mathematical-model-reveals-the-patterns-of-how innovations-arise/.
Muitas heurísticas derivam do mundo físico, inspirando-se na força da gravidade, na fusão de
metais, na evolução do DNA em animais e no comportamento de enxames de formigas,
abelhas e vaga-lumes (o artigo em https://arxiv. org/pdf/1003.1464.pdf explica o algoritmo
Lévy-Flight Firefly e outros algoritmos inspirados na natureza).

A escalada de colinas se inspira na força da gravidade. Baseia-se na observação de que,


quando uma bola rola por um vale, ela faz a descida mais íngreme; então, quando encontra
uma colina, tende a tomar a direção mais direta para cima para chegar ao topo. Gradualmente,
um passo após o outro, não importa se está subindo ou descendo, a bola chega ao seu
destino, onde não é possível subir ou descer.

Na busca local, você pode imitar o mesmo procedimento com sucesso usando uma função
objetivo, uma medida que avalia as soluções vizinhas e determina qual delas melhora a atual.
Usando a analogia da escalada, ter uma função objetivo é como sentir a inclinação do terreno
e determinar o próximo melhor movimento. A partir da posição atual, um caminhante avalia
cada direção para determinar a inclinação do terreno. Quando o objetivo é chegar ao topo, o
caminhante escolhe a direção com maior inclinação ascendente para chegar ao topo. No
entanto, essa é apenas a situação ideal; os caminhantes geralmente encontram problemas
durante uma escalada e devem usar outras soluções para contorná-los.

Uma função objetivo é semelhante a um critério guloso (ver Capítulo 5). É cego em relação ao
seu destino final, então pode determinar a direção, mas não detectar

354 PARTE 5 Desafiando Problemas Difíceis


Machine Translated by Google

obstáculos. Pense no efeito da cegueira ao escalar as montanhas – é difícil dizer quando um caminhante
chega ao topo. Terreno plano sem qualquer oportunidade de movimento ascendente pode indicar que o
caminhante chegou ao topo.
Infelizmente, um local plano também pode ser uma planície, uma depressão ou até mesmo um buraco no
qual o caminhante caiu. Você não pode ter certeza porque o caminhante não pode ver.

O mesmo problema acontece quando se usa uma busca local guiada por uma heurística de escalada: ela
persegue soluções vizinhas progressivamente melhores até não encontrar uma solução melhor verificando
as soluções que existem em torno da atual. Neste ponto, o algoritmo declara que encontrou uma solução.
Ele também diz que encontrou uma solução global, embora, conforme ilustrado na Figura 18-2, possa ter
simplesmente encontrado um máximo local, uma solução que é a melhor porque está cercada por
soluções piores. Ainda é possível encontrar uma solução melhor através de uma exploração mais
aprofundada.

FIGURA 18-2:
A pesquisa
local explora
a paisagem por
escalada.

Um exemplo de subida de colina em ação (e dos riscos de ficar preso em um máximo local ou mínimo
local quando você está descendo, como neste exemplo) é o quebra-cabeça n-damas, criado pela primeira
vez pelo especialista em xadrez Max Bezzel, em 1848, como desafio para os amantes do xadrez. Neste
problema, você tem um número de rainhas (esse número é n) para colocar em um tabuleiro de xadrez de
n x n dimensões. Você deve colocá-los de modo que nenhuma rainha seja ameaçada por qualquer outra.
(No xadrez, uma rainha pode atacar em qualquer direção por linha, coluna ou diagonal.)

CAPÍTULO 18 Realizando Pesquisa Local 355


Machine Translated by Google

Este é realmente um problema NP-difícil. Se você tiver oito rainhas para colocar em um tabuleiro
de xadrez 8 x 8, existem 4.426.165.368 maneiras diferentes de colocá-las, mas apenas 92
configurações para resolver o problema. Claramente, você não pode resolver esse problema
usando apenas força bruta ou sorte. A pesquisa local resolve esse problema de uma maneira
muito simples usando a escalada:

1. Coloque as n rainhas aleatoriamente no tabuleiro de modo que cada uma fique em uma
coluna diferente (não há duas rainhas na mesma coluna).

2. Avalie o próximo conjunto de soluções movendo cada rainha uma casa para cima ou
para baixo em sua coluna. Esta etapa requer 2 * n movimentos.

3. Determine quantas rainhas estão atacando umas às outras após cada movimento.

4. Determine qual solução tem o menor número de rainhas atacando umas às outras e use
essa solução como ponto de partida para a próxima iteração.

5. Execute as etapas 2 a 4 até encontrar uma solução.

Infelizmente, essa abordagem funciona apenas cerca de 14% do tempo usando um tabuleiro
padrão de 8 x 8, porque geralmente fica preso em uma configuração de tabuleiro de xadrez que
não permite nenhuma melhoria adicional em 86% do tempo. (O número de rainhas sob ataque não
diminuirá para todos os 2 * n movimentos disponíveis comode
próximas
escaparsoluções.)
de tal bloqueio
A única
é reiniciar
maneiraa
busca local do zero, escolhendo outra configuração inicial aleatória do bloco. rainhas no tabuleiro
de xadrez. Em média, a perseverança será recompensada uma em cada dez tentativas, um
resultado menos gratificante do que tentar todas as soluções possíveis sistematicamente. A Figura
18-3 mostra uma solução bem-sucedida.

FIGURA 18-3:
Um quebra-cabeça

de 8 rainhas resolvido.

356 PARTE 5 Desafiando Problemas Difíceis


Machine Translated by Google

Apesar de sua fraqueza em encontrar a melhor solução (ou mesmo uma solução) na primeira
tentativa, algoritmos de escalada são usados em todos os lugares, especialmente em
inteligência artificial e aprendizado de máquina. As redes neurais que reconhecem sons ou
imagens, alimentam telefones celulares e motivam carros autônomos dependem principalmente
de uma otimização de escalada chamada gradiente descendente. Arranques aleatórios e
injeções aleatórias no procedimento de subida de encosta possibilitam escapar de qualquer
solução local e atingir o máximo global. Tanto o recozimento simulado quanto o Tabu Search
são maneiras inteligentes de usar decisões aleatórias na escalada.

Descobrindo o recozimento simulado


Em um determinado ponto da pesquisa, se sua função objetivo parar de fornecer as indicações
corretas, você pode usar outra heurística para controlar a situação e tentar encontrar um
caminho melhor para uma melhor solução de tarefa. É assim que o recozimento simulado e a
Busca Tabu funcionam: eles fornecem uma saída de emergência quando necessário.

O recozimento simulado leva o nome de uma técnica em metalurgia, que aquece o metal a
uma alta temperatura e depois o resfria lentamente para amolecer o metal para trabalho a frio
e remover defeitos cristalinos internos (consulte https://www.azom.
com/article.aspx?ArticleID=20195 para detalhes sobre este processo de trabalho de metal).
A pesquisa local replica essa ideia ao visualizar a pesquisa de solução como uma estrutura
atômica que muda para melhorar sua funcionalidade. A temperatura é o divisor de águas no
processo de otimização. Assim como altas temperaturas fazem a estrutura de um material
relaxar (sólidos derretem e líquidos evaporam em altas temperaturas), altas temperaturas em
um algoritmo de busca local induzem o relaxamento da função objetivo, permitindo que ele
prefira soluções piores às melhores. O recozimento simulado modifica o procedimento de
subida de colina, mantendo a função objetivo para avaliação da solução vizinha, mas permitindo
determinar a escolha da solução de busca de uma maneira diferente:

1. Obtenha uma temperatura expressa em probabilidade. (A função física de Gibbs-Boltzmann é uma


fórmula que converte temperatura em probabilidade, mas você também pode usar abordagens
mais simples que mapeiam temperatura em probabilidade.)

2. Defina uma programação de temperatura. A temperatura diminui a uma certa taxa à medida que
o tempo passa e a busca é executada.

3. Defina uma solução inicial (usando amostragem aleatória ou outro algoritmo) e inicie um loop. À
medida que o loop prossegue, a temperatura diminui.

4. Pare a otimização quando a temperatura for zero.

5. Proponha o resultado atual como solução.

CAPÍTULO 18 Realizando Pesquisa Local 357


Machine Translated by Google

Neste ponto, você deve iterar a busca de soluções. Para cada etapa da iteração anterior,
entre as Etapas 3 e 4 anteriores, faça o seguinte:

1. Liste as soluções vizinhas e escolha uma aleatoriamente.

2. Defina a solução vizinha como a solução atual quando a solução vizinha


solução é melhor que a atual.

3. Caso contrário, escolha um número aleatório entre 0 e 1 com base em um limite


probabilidade associada à temperatura real e determine se é menor que a probabilidade limite:

• Se for menor, defina a solução vizinha como a solução atual (mesmo que seja pior que a
solução atual, de acordo com a função objetivo).

• Se for mais, mantenha a solução atual.

O recozimento simulado é uma maneira inteligente de melhorar a subida de colinas, pois evita
que a busca seja interrompida em uma solução local. Quando a temperatura é alta o suficiente,
a pesquisa pode usar uma solução aleatória e encontrar outra maneira de otimizar melhor.
Como a temperatura é mais alta no início da busca, o algoritmo tem a chance de injetar
aleatoriedade na otimização. À medida que a temperatura esfria para zero, há cada vez menos
chance de escolher uma solução aleatória, e a busca local prossegue como na escalada. No
TSP, por exemplo, o algoritmo atinge o recozimento simulado desafiando a solução atual em
altas temperaturas por

» Escolher um segmento do passeio aleatoriamente e percorrê-lo na opo


direção do site

» Visitar uma cidade mais cedo ou mais tarde no passeio, deixando a ordem de visita para as demais
cidades iguais

Se os ajustes resultantes piorarem a duração do passeio, o algoritmo os mantém ou rejeita de


acordo com a temperatura no processo de recozimento simulado.

Evitando repetições usando a Pesquisa Tabu


Tabu é uma palavra antiga do tonganês polinésio que diz que certas coisas não podem ser
tocadas porque são sagradas. A palavra tabu (que se escreve tabu em inglês) passou dos
estudos antropológicos para a linguagem cotidiana para indicar algo que é proibido. Na
otimização de busca local, é comum ficar preso em uma vizinhança de soluções que não
oferecem nenhuma melhoria; ou seja, é uma solução local que aparece como a melhor solução,
mas está longe de ser a solução desejada. A Pesquisa Tabu relaxa algumas regras e impõe
outras para oferecer uma saída dos mínimos locais e ajudá-lo a alcançar melhores soluções.

358 PARTE 5 Desafiando Problemas Difíceis


Machine Translated by Google

A heurística Tabu Search envolve funções objetivo e funciona ao longo de muitas soluções vizinhas.
Ele intervém quando você não pode prosseguir porque as próximas soluções não melhoram seu
objetivo. Quando isso acontece, o Tabu Search faz o seguinte:

» Permite o uso de uma solução pejorativa por algumas vezes para ver se o
afastamento da solução local pode ajudar a busca a encontrar um caminho melhor
para a melhor solução.

» Lembra as soluções que a busca tenta e proíbe de usá-las mais, garantindo assim que a
busca não circule entre as mesmas soluções ao redor da solução local sem encontrar
uma rota de fuga.

» Cria uma memória de longo ou curto prazo de soluções Tabu modificando o comprimento
da fila usada para armazenar soluções anteriores. Quando a fila está cheia, a heurística
descarta o Tabu mais antigo para abrir espaço para o novo.

Você pode relacionar a Pesquisa Tabu ao armazenamento em cache e à memorização (consulte o


Capítulo 16) porque ela exige que o algoritmo rastreie suas etapas para economizar tempo e evitar
refazer as soluções usadas anteriormente. No TSP, pode ajudar quando você tenta otimizar sua
solução trocando a ordem de visita de duas ou mais cidades para evitar conjuntos de soluções repetidos.

Resolvendo a Satisfação de Circuitos Booleanos


Como uma visão prática de como uma busca local funciona, o exemplo desta seção aborda a
satisfatibilidade de circuitos, um problema clássico NP-completo com algumas aplicações no mundo
real, incluindo planejamento industrial, programação de esportes e controle de tráfego aéreo. O
algoritmo também é usado para testar o funcionamento dos circuitos eletrônicos, otimizando-os
removendo circuitos que não transportam sinais elétricos (são redundantes). Além disso, o algoritmo
de resolução é usado em algumas outras aplicações: rotulagem automática em mapas e gráficos;
tomografia discreta; agendamento com restrições; agrupamento de dados em grupos; e outros
problemas para os quais você tem que fazer escolhas conflitantes. Ele usa uma abordagem de
randomização e algoritmo de Monte Carlo. Conforme visto no Capítulo 17, um algoritmo de Monte
Carlo depende de escolhas aleatórias durante seu processo de otimização e não tem garantia de
sucesso em sua tarefa, embora tenha uma alta probabilidade de concluir a tarefa com sucesso.

O exemplo gira em torno de testes de circuitos de computador. Os circuitos de computador são


compostos por uma série de componentes conectados, cada um abrindo ou fechando um circuito
com base em suas entradas. Esses elementos são chamados de portas lógicas (fisicamente, seu
papel é desempenhado por transistores) e, se você construir um circuito com muitas portas lógicas,
precisará entender se a eletricidade pode passar por ele e em quais circunstâncias.

CAPÍTULO 18 Realizando Pesquisa Local 359


Machine Translated by Google

O Capítulo 14 discute a representação interna de um computador, baseada em zeros (ausência


de eletricidade no circuito) ou uns (presença de eletricidade). Você pode renderizar essa
representação 0/1 de uma perspectiva lógica, transformando sinais em False
(não há eletricidade no circuito) ou Verdadeiras (há de fato eletricidade). O Capítulo 4
examina os operadores booleanos (AND, OR e NOT), conforme mostrado na Figura 18-4, que
irão interagir com as entradas True e False para transformá-las em diferentes saídas. Todos
esses conceitos ajudam a representar um circuito elétrico físico como uma sequência de
operadores booleanos definindo portas lógicas. A combinação de todas as suas condições
determina se o circuito pode transportar eletricidade.

FIGURA 18-4:
Símbolos e
tabelas verdade
de operadores
lógicos
AND, OR e NOT.

Essa representação lógica é um circuito combinacional booleano, e o teste para verificar sua
funcionalidade é o problema de satisfatibilidade do circuito. No cenário mais fácil, o circuito
consiste apenas em condições NOT (chamadas de inversores) que aceitam entrada de um fio e OR
condições que aceitam dois fios como entradas. Este é um cenário de 2-satisfatibilidade (2-
SAT), e se o algoritmo passasse por ele usando uma busca exaustiva, seriam necessários no
mínimo 2k tentativas (tendo k como o número de fios de entrada) para encontrar um conjunto
de condições que fazem a eletricidade passar por todo o circuito, se houver.
Existem versões ainda mais complexas do problema, que aceitam mais entradas para cada
porta lógica OR e usam portas AND , mas estão além do escopo deste livro.

Resolvendo 2-SAT usando randomização


Não importa o circuito eletrônico que você tenha que testar usando uma representação
booleana, você pode renderizá-lo como um vetor de variáveis booleanas. Você também pode
criar outro vetor para conter as cláusulas, o conjunto de condições que o circuito precisa
satisfazer (por exemplo, que o fio A e o fio B devem ser True). Essa não é a única maneira de
representar o problema; na verdade, existem outras soluções que envolvem o uso de gráficos.
No entanto, para este exemplo, esses dois vetores são suficientes.

Você pode resolver o problema usando uma busca local aleatória em tempo polinomial.
Professor Christos H. Papadimitriou, lecionando na Universidade da Califórnia em

360 PARTE 5 Desafiando Problemas Difíceis


Machine Translated by Google

Berkeley (https://www.engineering.columbia.edu/faculty/christos-papad
imitar), desenvolveu este algoritmo, chamado RandomWalkSAT. Ele o apresentou em seu
artigo “On Selecting a Satisfying Truth Assignment”, publicado em 1991 pelo Proceedings of
the 32nd IEEE Symposium on the Foundations of Computer Science. O algoritmo é
competitivo quando comparado a formas mais racionais, e é uma abordagem de busca local
exemplar porque faz apenas uma mudança de cada vez na solução atual. Ele usa dois loops
aninhados, um para tentar a solução inicial várias vezes e outro para alterar aleatoriamente
a solução aleatória inicial.
Repita o log2(k) do loop externo (onde k é o número de fios). O loop interno usa as seguintes
etapas:

1. Escolha uma solução de problema aleatória.

2. Repita os seguintes passos 2*k2 vezes:

(a) Determine se a solução atual é a correta. Quando for a solução correta,


liberte-se de todos os loops e relate a solução.

(b) Escolha uma cláusula insatisfeita ao acaso. Escolha uma das condições nele em
aleatório e alterá-lo.

Implementando o código Python


Para resolver o problema do 2-SAT usando Python e o algoritmo RandomWalkSAT, você
precisa definir algumas funções úteis. O create_clauses() e assinado()
funções ajudam a gerar um problema de circuito para resolver manipulando o OR e o NOT
portões, respectivamente. Usando essas funções, você especifica o número de portas OR e
fornece um número de semente que garante que você possa recriar o problema resultante
posteriormente (permitindo que você tente o problema várias vezes e em computadores
diferentes).

A função create_random_solutions() fornece um início frio do problema fornecendo uma


solução aleatória que define as entradas. As chances de encontrar a solução certa usando
sorte aleatória são pequenas (uma potência de dois para o número de portões), mas, em
média, você pode esperar que três quartos dos portões estejam configurados corretamente
(porque, como visto usando a tabela verdade para a função OR , três entradas de quatro
possíveis são True). A função check_solution() determina quando o circuito está satisfeito
(indicando uma solução correta). Caso contrário, ele exibe quais condições não são
satisfeitas. (Você pode encontrar este código no A4D2E; 18; Local Search.
arquivo de código-fonte para download ipynb ; veja a Introdução para detalhes.)

importar aleatório
do log de importação de matemática2

def assinado(v):

CAPÍTULO 18 Realizando Pesquisa Local 361


Machine Translated by Google

return v se random.random() < 0.5 else -v

def create_clauses(i, seed=1):


random.seed(semente)
return [(assinado(aleatório.randint(0, i-1)),
assinado(random.randint(0, i-1)))
para j no intervalo(i)]

def create_random_solution(i, *kwargs):


return {j:signed(1)==1 para j no intervalo(i)}

def check_solution(solução, cláusulas):


violações = lista()
para k,(a,b) em enumerar(cláusulas):
se não (((solução[abs(a)]) == (a > 0)) |
((solução[abs(b)]) == (b > 0))):
violações.append(k)
devolva violações

Depois de definir essas funções, você tem todos os blocos de construção para uma função
sat2() para resolver o problema. Essa solução usa duas iterações aninhadas: a primeira replica
muitas partidas; o segundo escolhe condições insatisfeitas aleatoriamente e as torna Verdadeiras.
A solução é executada em tempo polinomial. A função não garante encontrar uma solução, se
existir, mas as chances são de que ela fornecerá uma solução quando existir. De fato, o loop de
iteração interno faz 2*k2 tentativas aleatórias para resolver o circuito, o que geralmente é
suficiente para um passeio aleatório em uma linha chegar ao seu destino.

Um passeio aleatório é uma série de cálculos representando um objeto que se afasta de


sua posição inicial tomando uma direção aleatória a cada passo. Você pode imaginar
uma caminhada aleatória como a jornada de uma pessoa bêbada de um poste de luz a
outro. Passeios aleatórios são úteis para representar um modelo matemático de muitos
aspectos do mundo real. Eles encontram aplicações em biologia, física, química, ciência
da computação e economia, especialmente na análise do mercado de ações. Se você
quiser saber mais sobre passeios aleatórios, acesse http://www.mit.edu/~kardar/
ensino/projetos/quimiotaxia(AndreaSchmidt)/random.htm.

Um passeio aleatório em uma linha é o exemplo mais fácil de um passeio aleatório. Em média, k2
passos de um passeio aleatório são necessários para chegar a uma distância k do ponto de
partida. Esse esforço esperado explica por que o RandomWalkSAT requer 2*k2 chances
aleatórias para corrigir a solução inicial. O número de chances fornece uma alta probabilidade de
que o algoritmo corrija as k cláusulas. Além disso, funciona da mesma maneira que o jogo de
adivinhação de cartas aleatórias discutido no capítulo anterior. À medida que o algoritmo avança,
fica mais fácil escolher a resposta certa. o

362 PARTE 5 Desafiando Problemas Difíceis


Machine Translated by Google

replicações externas garantem uma fuga de escolhas aleatórias de loop interno infelizes que podem
interromper o processo em uma solução local.

def sat2(cláusulas, n, start=create_random_solution):


not_solved = Verdadeiro
para loop_externo no intervalo(round(log2(n))): solução =
start(n, cláusulas) history = list() para loop_interno no
intervalo(2 * n**2): resposta = check_solution(solução,
cláusulas) insatisfeito = len(resposta)
history.append(insatisfeito)

se insatisfeito==0:
print ("Solução em %i loops externos," %
(external_loop + 1), end=" ") print ("%i
loops internos" % (internal_loop + 1))

not_solved = False
parar
senão:
r1 = random.choice(resposta) r2 =
random.randint(0, 1) cláusula_to_fix =
cláusulas[r1][r2] solução[abs(clause_to_fix)]
= ( cláusula_to_fix>0)

senão:
Prosseguir
parar
se not_solved for True:
print("Não solucionável")
histórico de retorno, solução

Agora que todas as funções estão configuradas corretamente, você pode executar o código para
resolver um problema. Aqui está o primeiro exemplo, que tenta o circuito criado pela semente 0 e
usa 1.000 portas lógicas:

n = 1000
cláusulas = create_clauses(n, seed=0) histórico,
solução = sat2(cláusulas, n,
start=create_random_solution)

Solução encontrada em 1 loops externos, 674 loops internos

CAPÍTULO 18 Realizando Pesquisa Local 363


Machine Translated by Google

Traçando a solução como um gráfico representando o número de passos na abcissa


(emendas aleatórias da solução) e as cláusulas que faltam fixar no eixo ordinal, pode-se
verificar que o algoritmo tende a encontrar a solução correta a longo prazo, pois mostrado
na Figura 18-5.

importar matplotlib.pyplot como plt

plt.figure(figsize=(12, 6))
plt.plot(história, 'b-')
plt.xlabel("Ajustes aleatórios")
plt.ylabel("Cláusulas não satisfeitas")
plt.grid(True)
plt.show()

FIGURA 18-5:
O número de
cláusulas
insatisfatíveis
diminui após
ajustes aleatórios.

Nem todas as cláusulas são realmente solucionáveis. Você pode tentar diferentes
sementes e encontrar algumas que demorem muito para processar, como o circuito com
1.000 portas que você pode criar definindo a semente igual a 12. Quando você cria um
circuito insolúvel, o script retorna uma mensagem informando que o circuito não é
solucionável depois de ter realizado o número correto de tentativas. Leva mais tempo
quando um conjunto insolúvel de cláusulas é atendido. Isso acontece por causa do loop
externo que precisa esgotar o equivalente de log2(k) o número de cláusulas para ter uma
garantia estatística de que você não está encontrando uma solução porque o circuito não
está funcionando, e não porque você está apenas azar na busca local aleatória.

364 PARTE 5 Desafiando Problemas Difíceis


Machine Translated by Google

Percebendo que o ponto de


partida é importante
Mesmo que o algoritmo RandomWalkSAT tenha uma complexidade de tempo de execução de
O(log2k * k2) na pior das hipóteses, com k o número de entradas, você pode acelerá-lo hackeando o
ponto de partida. Na verdade, embora começar com uma configuração aleatória signifique que um
quarto das cláusulas permanece insatisfeito no início, em média, você pode corrigir muitas delas
realizando uma passagem pelos dados.

O problema com as cláusulas é que muitas requerem uma entrada True e, simultaneamente, muitas
outras requerem uma entrada False . Quando todas as cláusulas exigem que uma entrada seja True
ou False, você pode configurá-lo para a condição necessária, que satisfaz um grande número de
cláusulas e facilita a resolução das restantes. A nova implementação do RandomWalkSAT a seguir
inclui uma fase inicial que resolve imediatamente as situações em que uma entrada requer uma
configuração específica de Verdadeiro ou Falso por todas as cláusulas com as quais interagem:

def better_start(n, cláusulas):


cláusula_dict = dict()
para par em cláusulas:
para cláusula em par:
if abs(cláusula) em cláusula_dict:
cláusula_dict[abs(cláusula)].add(cláusula)
senão:
cláusula_dict[abs(cláusula)] = {cláusula}

solução = create_random_solution(n)

para cláusula, valor em cláusula_dict.items():


se len(valor) == 1:
solução[cláusula] = valor.pop() > 0
solução de retorno

O código define uma nova função para a inicialização a frio, onde ele varre a solução e encontra
todas as entradas associadas a um único estado (True ou False) após gerar uma solução aleatória.
Ao configurá-los imediatamente para o estado necessário, você pode reduzir o número de cláusulas
que exigem alteração e fazer com que a pesquisa local faça menos trabalho e seja concluída mais
cedo:

n = 1000
cláusulas = create_clauses(n, seed=0)
history, solution = sat2(cláusulas, n, start=better_start)

Solução encontrada em 1 loops externos, 400 loops internos

CAPÍTULO 18 Realizando Pesquisa Local 365


Machine Translated by Google

Ao fornecer este novo ponto de partida simplificado, você pode ver imediatamente uma melhoria
após traçar os resultados. Em média, menos operações são necessárias para concluir a tarefa.

Em uma busca local, sempre considere que o ponto de partida é importante para permitir que
o algoritmo seja concluído mais cedo e com mais sucesso, conforme mostrado na Figura 18-6.
Em suma, tente fornecer o início da melhor qualidade possível para sua pesquisa.

FIGURA 18-6:
A execução
é mais rápida
porque o ponto de
partida é melhor.

366 PARTE 5 Desafiando Problemas Difíceis


Machine Translated by Google

NESTE CAPÍTULO

» Descobrindo como a otimização


acontece usando programação linear

» Transformando problemas do mundo


real em matemáticos e geométricos

» Aprendendo a usar Python para


resolver problemas de programação linear

Capítulo 19

Empregando Linear
Programação
encontrar o valor máximo ou mínimo de uma função chamada objetivo
A programação linear (PL)
função. A função também
objetivo é chamada
é restringida porde otimização
alguns limites,linear e é um
também métodode
chamados para
restrições. Como o nome sugere, LP funciona contando com funções e limites baseados
em linearidade, em que tudo pode ser representado como uma linha em um gráfico;
nenhuma curva admitida. Apesar dessas limitações, se você pode simplificar e definir
seu problema com base em funções lineares, o LP é poderoso. Comumente, suas
aplicações são vastas e importantes, abordando problemas cotidianos de produção e logística.

Este capítulo ajuda você a entender a programação linear. A primeira parte fornece
os conceitos necessários para saber como usar a programação linear de forma eficaz.
Ele inclui a matemática básica que você não pode evitar ao implementá-lo. Esta primeira parte
também mostra como problemas mais simples podem ser representados graficamente por um
gráfico.

Na segunda parte, você verá como aplicar programação linear a problemas do mundo
real usando Python como ferramenta para expressar esses problemas em código. Ele
apresenta o pacote PuLP Python, que é fácil de executar e de aplicar aos seus próprios
problemas. Usando este pacote, você tem a chance de resolver problemas que, embora
ainda lineares, são tão complexos que você não pode representá-los com papel e lápis
em um gráfico.

CAPÍTULO 19 Empregando Programação Linear 367


Machine Translated by Google

Você não precisa digitar o código-fonte para este capítulo manualmente. Na verdade, usar a
fonte para download é muito mais fácil. Você pode encontrar a fonte deste capítulo no A4D2E;
19; Arquivo Linear Programming.ipynb da fonte para download.
Consulte a Introdução para obter detalhes sobre como localizar esse arquivo de origem.

Usando funções lineares como uma ferramenta

A LP fez sua primeira aparição durante a Segunda Guerra Mundial, quando a logística se
mostrou crítica na manobra de exércitos de milhões de soldados, armas e suprimentos em
campos de batalha geograficamente variados. Tanques e aviões precisavam reabastecer e
rearmar, o que exigia um esforço organizacional maciço para ter sucesso, apesar das limitações
de tempo, recursos e ações do inimigo.

Você pode expressar a maioria desses problemas militares em forma matemática. O


matemático George Bernard Dantzig, que trabalhava no Escritório de Controle Estatístico da
Força Aérea dos EUA, concebeu uma maneira inteligente de resolver esses problemas usando
o algoritmo simplex . Simplex é a ideia central que criou interesse na otimização numérica após
a guerra e deu origem ao promissor campo da programação linear.
A disponibilidade dos primeiros computadores úteis da época também aumentou o interesse,
tornando os cálculos complexos solucionáveis de uma maneira nova e rápida. Você pode ver
o início da história da computação nas décadas de 1950 e 1960 como uma busca para otimizar
problemas logísticos usando o método simplex e aplicando computadores de alta velocidade e
linguagens de programação especializadas.

Dantzig morreu em 2005, e o campo que ele inaugurou ainda está em constante
desenvolvimento. Nos últimos anos, novas ideias e métodos relacionados à programação linear
continuam fazendo aparições de sucesso, como as seguintes:

» Programação de restrições: Expressa as relações entre as variáveis em um programa de


computador como restrições em programação linear.

» Algoritmos genéticos: Considera a ideia de que as fórmulas matemáticas podem se


replicar e sofrer mutações para resolver problemas da mesma forma que o DNA na
natureza por evolução. Os algoritmos genéticos também aparecem no Capítulo 20 por
causa de sua abordagem heurística para otimização.

Para começar a trabalhar com LP, primeiro você precisa entender sua abordagem e
terminologia diferentes para as soluções exploradas até agora. Esta seção mostra como
resolver um problema cujo objetivo e restrições foram transformados em funções lineares. O
objetivo de um problema é a representação de custo, lucro ou alguma outra quantidade para
maximizar ou minimizar, sujeito às restrições. As restrições são desigualdades lineares
derivadas da aplicação, como o limite de 40 horas

368 PARTE 5 Desafiando Problemas Difíceis


Machine Translated by Google

semana de trabalho. O objetivo do PL é fornecer uma solução numérica ótima, que pode ser um valor
máximo ou mínimo, e o conjunto de condições para obtê-lo.

Essa definição de programação linear pode parecer um pouco complicada porque tanto matemática
quanto alguma abstração estão envolvidas (objetivos e restrições como funções lineares), mas as
coisas ficam mais claras depois de considerar o que é uma função e quando você pode determinar se
uma função é linear. Além do jargão matemático, a programação linear é apenas um ponto de vista
diferente ao lidar com problemas algorítmicos: você troca operações e manipulações de entradas de
dados com funções matemáticas e realiza cálculos usando um programa de software chamado
otimizador.

Você não pode usar programação linear para resolver todos os problemas, mas um grande número
deles se encaixa nos requisitos de programação linear, especialmente problemas que exigem
otimização usando limites previamente definidos. Os capítulos anteriores discutem como a
programação dinâmica é a melhor abordagem quando você precisa otimizar problemas sujeitos a
restrições. A programação dinâmica funciona com problemas discretos; ou seja, os números com os
quais você trabalha são números inteiros. A programação linear trabalha principalmente com números
decimais, embora existam algoritmos especiais de otimização que fornecem soluções como números
inteiros; por exemplo, você pode resolver o problema do caixeiro viajante usando programação linear
inteira. A programação linear tem um escopo mais amplo, porque pode lidar com quase qualquer
problema de tempo polinomial.

A programação linear é usada em manufatura, logística, transporte (especialmente para companhias


aéreas, para definir rotas, horários e custo de passagens), marketing, finanças e telecomunicações.
Todos esses aplicativos exigem que você obtenha um resultado econômico máximo com um custo
mínimo, otimizando a alocação de recursos disponíveis e atendendo a todas as restrições e limitações.
Além disso, você pode aplicar programação linear a aplicativos comuns, como videogames e
visualização por computador, porque os jogos dependem de formas complexas bidimensionais e
tridimensionais, e você precisa determinar se alguma forma colide e garantir que elas respeitem as
regras do jogo . Você alcança esses objetivos através do algoritmo de casco convexo alimentado por
programação linear (consulte https://0fps.net/

categoria/programação/videogames/). Finalmente, a programação linear está funcionando nos motores


de busca para problemas de recuperação de documentos; você pode transformar palavras, frases e
documentos em funções e determinar como maximizar o resultado de sua pesquisa (obtendo os
documentos necessários para responder à sua consulta) ao procurar documentos com determinadas
características matemáticas.

Entendendo a matemática básica que você precisa


Na programação de computadores, as funções fornecem os meios para empacotar o código que você
pretende usar mais de uma vez. As funções transformam o código em uma caixa preta, uma entidade

CAPÍTULO 19 Empregando Programação Linear 369


Machine Translated by Google

para os quais você fornece entradas e espera certas saídas. (O Capítulo 4 discute como criar
funções em Python.) A matemática usa funções de maneira semelhante à programação; eles são
um conjunto de operações matemáticas que transformam alguma entrada em uma saída. A
entrada pode incluir uma ou mais variáveis, resultando em uma saída exclusiva com base na
entrada. Normalmente uma função tem esta forma:

f(x) = x * 2

» f: Determina o nome da função. Pode ser qualquer coisa; você pode usar qualquer letra de
o alfabeto ou mesmo uma palavra.

» (x): Especifica a entrada. Neste exemplo, a entrada é a variável x, mas você pode usar mais
entradas e de qualquer complexidade, incluindo múltiplas variáveis ou matrizes.

»x * 2: Define o conjunto de operações que a função realiza após o recebimento


a entrada. O resultado é a saída da função na forma de um número.

Se você conectar a entrada 2 como x neste exemplo, obterá:

f(2) = 4

Em termos matemáticos, ao chamar esta função, você mapeou a entrada 2 para a saída 4.

As funções podem ser simples ou complexas, mas cada função tem um e apenas um resultado
(mesmo que esse resultado seja uma tupla) para cada conjunto de entradas que você fornece
(mesmo quando a entrada é composta de várias variáveis).

A programação linear aproveita as funções para renderizar os objetivos que deve atingir de forma
matemática para resolver o problema em questão. Quando você transforma objetivos em uma
função matemática, o problema se traduz em determinar a entrada para a função que mapeia a
saída máxima (ou a mínima, dependendo do que você deseja alcançar). A função que representa
o objetivo de otimização é a função objetivo. Além disso, a programação linear usa funções e
desigualdades para expressar restrições ou limites que o impedem de conectar qualquer entrada
desejada na função objetivo. Por exemplo, as desigualdades

0 >= x <= 4
y + x < 10

A primeira dessas desigualdades se traduz em limitar a entrada da função objetivo a valores


entre 0 e 4. As desigualdades podem envolver mais de uma variável de entrada por vez. A
segunda dessas desigualdades vincula os valores de uma entrada a outros valores porque sua
soma não pode exceder 10.

370 PARTE 5 Desafiando Problemas Difíceis


Machine Translated by Google

Os limites implicam uma limitação de entrada em um valor, como no primeiro exemplo. Restrições
sempre envolvem uma expressão matemática que compreende mais de uma variável, como no
segundo exemplo.

O requisito final da programação linear é que tanto a função objetivo quanto as desigualdades
sejam expressões lineares. Isso significa que a função objetivo e as desigualdades não podem
conter variáveis que se multipliquem, ou conter variáveis elevadas a uma potência (quadrado ou
cubo, por exemplo).

Todas as funções em uma otimização devem ser expressões lineares porque o procedimento
as representa como linhas em um espaço cartesiano. (Se precisar rever o conceito de espaço
cartesiano, pode encontrar informações úteis em https://www.
mathsisfun.com/data/cartesian-coordinates.html.) Conforme explicado na seção “Usando
Programação Linear na Prática”, mais adiante neste capítulo, você pode imaginar trabalhar com
programação linear mais como resolver um problema geométrico do que matemático.

Aprendendo a simplificar ao planejar


Os problemas que o algoritmo simplex original resolveu eram todos do tipo que você costuma ler
como problemas de palavras em um livro didático. Em tais problemas, todos os dados, informações
e limitações são declarados claramente; não há informações irrelevantes ou redundantes; e você
claramente tem que aplicar uma fórmula matemática (e provavelmente a que você acabou de
estudar) para resolver o problema.

No mundo real, as soluções para os problemas nunca são tão bem definidas. Em vez disso, eles
geralmente aparecem de maneira confusa e algumas informações necessárias não estão
prontamente disponíveis para você processar. Ainda assim, você pode analisar o problema e
localizar os dados necessários e outras informações. Além disso, você pode descobrir limitações
como dinheiro, tempo ou alguma regra ou ordem que deve considerar. Ao resolver o problema,
você reúne as informações e cria os meios para simplificá-lo.

A simplificação implica alguma perda de realismo, mas torna a descrição do problema mais
simples, o que pode destacar os processos subjacentes que fazem as coisas se moverem,
ajudando você a decidir o que acontece. Um problema mais simples permite desenvolver um
modelo que represente a realidade. Um modelo pode aproximar o que acontece na realidade e
você pode usá-lo tanto para gerenciar simulações quanto para programação linear.

Por exemplo, se você trabalha em uma fábrica e precisa planejar um cronograma de produção,
sabe que quanto mais pessoas adicionar, mais rápida será a produção. No entanto, nem sempre
você obterá o mesmo ganho com a mesma adição de pessoas. Por exemplo, as habilidades dos
operadores que você adiciona ao trabalho afetam os resultados. Além disso, você pode descobrir
que adicionar mais pessoas ao trabalho traz resultados decrescentes quando essas pessoas
passam mais tempo se comunicando e coordenando entre si do que

CAPÍTULO 19 Empregando Programação Linear 371


Machine Translated by Google

realizando trabalho útil. No entanto, você pode tornar o modelo mais fácil fingindo que
cada pessoa que você adicionar à tarefa produzirá uma certa quantidade de bens finais
ou intermediários.

Trabalhando com geometria usando simplex


Exemplos clássicos de problemas de programação linear implicam na produção de bens
usando recursos limitados (tempo, trabalhadores ou materiais). Para um exemplo de
como a programação linear aborda esses desafios, imagine uma fábrica que monta dois
ou mais produtos que devem ser entregues em um determinado tempo. Os trabalhadores
da fábrica produzem dois produtos, xey, durante um turno de oito horas. Para cada
produto, eles obtêm um lucro diferente (que você calcula subtraindo os custos da receita),
diferentes taxas de produção por hora e diferentes demandas diárias do mercado:

» Receita em USD para cada produto: x = 15, y = 25

» Taxa de produção por hora: x = 50, y = 40

» Demanda diária por produto: x = 300, y = 200

Em essência, o problema do negócio é decidir se produzir mais x, que é mais fácil de


montar, mas paga menos, ou y, que garante mais receita, mas menos produção. Para
resolver o problema, primeiro determine a função objetivo. Expresse-o como a soma das
quantidades dos dois produtos, multiplicada pela receita unitária esperada, que você
sabe que deve maximizar (somente se o problema for sobre custos, você deve minimizar
a função objetivo):

f(x, y) = 15 * x + 25 * y

Este problema tem desigualdades, que são limitadas por valores x e y que devem ser
verdadeiros para obter um resultado válido da otimização:

0 <= x <= 300


0 <= e <= 200

Na verdade, você não pode produzir um número negativo de produtos, nem faz sentido
produzir mais produtos do que o mercado demanda. Outra limitação importante é o
tempo disponível, pois não se pode ultrapassar oito horas para cada turno de trabalho.
Isso significa calcular o tempo para produzir os produtos x e y e restringir o tempo total
a menos ou igual a oito horas.

x / 40 + y / 50 <= 8

372 PARTE 5 Desafiando Problemas Difíceis


Machine Translated by Google

Você pode representar funções em um plano cartesiano. (Para uma atualização sobre funções
de plotagem, consulte https://www.mathplanet.com/education/pre-algebra/
gráficos-e-funções/equações-lineares-no-plano-coordenado.)
Como você pode expressar tudo usando funções neste problema, você também pode resolver
os problemas de programação linear como problemas de geometria em um espaço de
coordenadas cartesianas. Se o problema não envolver mais de duas variáveis, você pode
plotar as duas variáveis e suas restrições como linhas em um plano e determinar como elas
delimitam uma forma geométrica. Você descobrirá que as linhas delimitam uma área, em
forma de polígono, chamada de região viável. Essa região é onde você encontra a solução,
que contém todas as entradas válidas (de acordo com as restrições) para o problema.

Quando o problema lida com mais de duas variáveis, você ainda pode imaginá-lo usando
linhas que se cruzam em um espaço, mas não pode representar isso visualmente porque
cada variável de entrada precisa de uma dimensão no gráfico, e os gráficos são vinculados
às três dimensões de o mundo em que vivemos.

Neste ponto, o algoritmo de programação linear explora a região viável delimitada de forma
inteligente e reporta com a solução. Na verdade, você não precisa verificar todos os pontos
da área delimitada para determinar a melhor solução do problema.
Imagine a função objetivo como outra linha que você representa no plano (afinal, até a função
objetivo é uma função linear). Você pode ver que a solução que está procurando são os
pontos de coordenadas onde a área viável e a linha da função objetivo se tocam pela primeira
vez (veja a Figura 19-1). Quando a linha da função objetivo desce de cima (chegando de fora
da região viável, onde ocorrem resultados que você não pode aceitar por causa das restrições),
em certo ponto ela tocará a área. Esse ponto de contato geralmente é um vértice da área,
mas pode ser um lado inteiro do polígono (nesse caso, cada ponto desse lado é uma solução
ótima).

Na prática, o algoritmo simplex não pode fazer as linhas descerem visualmente, como neste
exemplo. Em vez disso, ele caminha ao longo da fronteira da área viável (enumerando os
vértices) e testa os valores da função objetivo resultantes em cada vértice até encontrar a
solução. Consequentemente, o tempo de execução efetivo depende do número de vértices,
que por sua vez depende do número de restrições e variáveis envolvidas na solução. (Mais
variáveis significam mais dimensões e mais vértices.)

Entendendo as limitações
À medida que você ganha mais confiança com a programação linear e os problemas se
tornam mais desafiadores, você precisa de abordagens mais complexas do que o algoritmo
simplex básico apresentado neste capítulo. Na verdade, o simplex original não é mais usado
porque algoritmos mais sofisticados o substituíram - algoritmos que

CAPÍTULO 19 Empregando Programação Linear 373


Machine Translated by Google

cortar geometricamente o interior da região viável em vez de caminhar ao longo dela.


Esses algoritmos mais novos usam um atalho quando o algoritmo está claramente
procurando a solução no lado errado da região.

FIGURA 19-1:
Olhando onde
a função
objetivo vai tocar
a área viável.

Você também pode trabalhar com números de ponto flutuante limitando porque muitos
problemas exigem uma resposta binária (1/0) ou inteira. (No exemplo de dois produtos,
você não pode fazer números fracionários dos produtos. Os valores x e y devem ser
inteiros.) Além disso, outros problemas podem exigir o uso de curvas, não linhas, para
representar o espaço do problema e a região viável corretamente . Você encontra
algoritmos de programação linear inteira e programação não linear implementados em
software comercial. Esteja ciente de que tanto a programação inteira quanto a não linear
são problemas NP-completos e podem exigir tanto tempo quanto, se não mais, do que
outros algoritmos que você conhece.

Usando programação linear na prática


A melhor maneira de começar na programação linear é usar soluções predefinidas, em
vez de criar aplicativos personalizados por conta própria. A primeira seção a seguir ajuda
você a instalar uma solução predefinida usada para os exemplos a seguir.

374 PARTE 5 Desafiando Problemas Difíceis


Machine Translated by Google

Ao trabalhar com um produto de software, você pode encontrar diferenças significativas entre
software de código aberto e pacotes comerciais. Embora o software de código aberto ofereça
um amplo espectro de algoritmos, o desempenho pode ser decepcionante em problemas
grandes e complexos. Muita arte ainda está envolvida na implementação de algoritmos de
programação linear como parte do software de trabalho, e você não pode esperar que o
software de código aberto funcione tão rápido e suavemente quanto as ofertas comerciais.

Mesmo assim, o código aberto oferece algumas boas opções para aprender programa linear.
As seções a seguir usam uma solução Python de código aberto chamada PuLP, que permite
criar otimizações de programação linear após definir uma função de custo e restrições como
funções Python. É principalmente uma solução didática, adequada para ajudá-lo a testar como
a programação linear funciona em alguns problemas e obter informações sobre a formulação
de problemas em termos matemáticos.

O PuLP fornece uma interface para os programas solucionadores subjacentes. O Python vem
com um programa de resolução padrão, de código aberto, que o PuLP ajuda você a acessar.
O desempenho (velocidade, precisão e escalabilidade) que o PuLP fornece depende quase
inteiramente do solver e do otimizador que o usuário escolher. Os melhores solucionadores
são produtos comerciais, como CPLEX (https://www.ibm.com/analytics/cplex otimizador),
XPRESS (https://www.fico.com/fico-xpress-optimization/
docs/latest/overview.html), e GuRoBi (https://www.gurobi.com/), que fornecem uma enorme
vantagem de velocidade quando comparados aos solucionadores de código aberto.

Configurando o PuLP em casa


PuLP é um projeto de código aberto Python criado por Jean-Sebastien Roy, posteriormente
modificado e mantido por Stuart Antony Mitchell. O pacote PuLP ajuda você a definir
problemas de programação linear e resolvê-los usando o solucionador interno (que conta com
o algoritmo simplex). Você também pode usar outros solucionadores disponíveis em
repositórios de domínio público ou pagando por uma licença. O repositório do projeto
(contendo todo o código fonte e muitos exemplos) está em https://github.com/
moeda-ou/celulose. A documentação completa está localizada em https://coin-or.
github.io/pulp/.

O PuLP não está prontamente disponível como parte da distribuição do Anaconda, portanto,
você precisa instalá-lo por conta própria. Você deve usar o prompt de comando do Anaconda3
(ou superior) para instalar o PuLP porque as versões mais antigas do prompt de comando do
Anaconda não funcionarão. Abra um shell de linha de comando, digite pip install pulp e
pressione Enter. Se você tiver acesso à Internet, o comando pip baixa o pacote PuLP e o
instala em Python. (A versão usada pelos exemplos neste capítulo é PuLP 1.6.1, mas versões
posteriores devem fornecer a mesma funcionalidade.) O exemplo precisará operar em alguns
vetores de valores, então você também precisa ter o NumPy disponível (basta digitar pip install
numpy se não estiver presente em seu ambiente Python).

CAPÍTULO 19 Empregando Programação Linear 375


Machine Translated by Google

Otimização da produção e receita


O problema nesta seção é outra otimização relacionada à produção. Você trabalha com dois
produtos (porque isso implica apenas duas variáveis que você pode representar em um gráfico
bidimensional), produto A e B, que devem passar por uma série de transformações através de
três etapas. Cada estágio requer um número de operadores (o valor n), que podem ser
trabalhadores ou robôs, e cada estágio opera no máximo por um número de dias no mês
(representado pelo valor t). Cada estágio opera de maneira diferente em cada produto, exigindo
um número diferente de dias antes da conclusão. Por exemplo, um trabalhador no primeiro
estágio (chamado 'res_1') leva dois dias para terminar o produto A, mas três dias para o
produto B. Finalmente, cada produto tem um lucro diferente: o produto A traz US$ 3.000 cada
e o produto B US$ 2.500 cada . A tabela a seguir resume o problema:

Produção Tempo para o Produto Tempo para o Produto Tempo de atividade

Palco A por Trabalhador (Dias) B por Trabalhador (Dias) (Dias) Trabalhadores

res_1 2 3 30 2

res_2 3 2 30 2

res_3 3 3 22 3

Para encontrar a função objetivo, calcule a soma de cada quantidade de produto multiplicada
por seu lucro. Tem que ser maximizado. Embora não declarado explicitamente pelo problema,
existem algumas restrições. O primeiro é o fato de que o tempo de atividade limita a
produtividade em cada estágio. O segundo é o número de trabalhadores. A terceira é a
produtividade em relação ao tipo de produto processado. Você pode reafirmar o problema
como a soma do tempo usado para processar cada produto em cada estágio, que não pode
exceder o tempo de atividade multiplicado pelo número de trabalhadores disponíveis. O número
de trabalhadores multiplicado pelo número de dias úteis fornece os recursos de tempo que
você pode usar. Esses recursos não podem ser inferiores ao tempo necessário para produzir
todos os produtos que você planeja entregar. Aqui estão as formulações resultantes, com restrições para cada es

objetivo = 3000 * qty_A + 2500 * qty_B


taxa_produção_A * qty_A + taxa_produção_B * qty_B
<= uptime_days * trabalhadores

Você pode expressar cada restrição usando a quantidade de um produto para determinar o
outro (na verdade, se você produz A, não pode produzir B quando a produção de A não deixa
tempo):

qty_B <= ((uptime_days * trabalhadores) –


(production_rate_A * qty_A) ) / production_rate_B

376 PARTE 5 Desafiando Problemas Difíceis


Machine Translated by Google

Você pode registrar todos os valores relativos a cada estágio para production_rate_A,
production_rate_B, uptime_days e workers para facilitar o acesso a um dicionário Python.
Em vez disso, mantenha os lucros em variáveis. (Você pode encontrar esse código no
arquivo de código-fonte para download A4D2E; 19; Linear Programming.ipynb ; consulte
a Introdução para obter detalhes.)

importar numpy como np


importar matplotlib.pyplot como plt
importar celulose

res_1 = {'A':2, 'B':3, 't':30, 'n':2}


res_2 = {'A':3, 'B':2, 't':30, 'n':2}
res_3 = {'A':3, 'B':3, 't':22, 'n':3}
res = {'res_1':res_1, 'res_2':res_2, 'res_3':res_3}
lucro_A = 3000
lucro_B = 2500

Tendo enquadrado o problema em uma estrutura de dados adequada, tente visualizá-


lo usando as funções de plotagem do Python. Defina o produto A como a abcissa e,
por não conhecer a solução, represente a produção do produto A como um vetor de
quantidades que variam de 0 a 30 (quantidades não podem ser negativas). Quanto
ao produto B (como visto nas formulações acima), deduza-o da produção restante
após o término de A. Formule três funções, uma para cada estágio, de modo que,
ao decidir a quantidade de A, obtenha a quantidade conseqüente de B —
considerando as restrições:

a = np.linspace(0, 30, 30)


c1 = ((res['res_1']['t'] * res['res_1']['n'])-
res['res_1']['A']*a) / res['res_1']['B']
c2 = ((res['res_2']['t'] * res['res_2']['n'])-
res['res_2']['A']*a) / res['res_2']['B']
c3 = ((res['res_3']['t'] * res['res_3']['n'])-
res['res_3']['A']*a) / res['res_3']['B']

plt.figure(figsize=(12, 6))
plt.plot(a, c1, label='restrição #1')
plt.plot(a, c2, label='restrição #2')
plt.plot(a, c3, label='restrição #3')

eixos = plt.gca()
axes.set_xlim([0,30])
axes.set_ylim([0,30])
plt.xlabel('qtd modelo A')

CAPÍTULO 19 Empregando Programação Linear 377


Machine Translated by Google

plt.ylabel('quantidade modelo B')

borda = np.array((c1,c2,c3)).min(axis=0)

plt.fill_between(a, borda, cor='amarelo', alfa=0,5)


plt.scatter(*zip(*[(0,0), (20,0),
(0,20), (16,6), (6,16)]))
plt.legend()
plt.show()

As restrições se transformam em três linhas em um gráfico, conforme mostrado na


Figura 19-2. As linhas se cruzam entre si, mostrando a área viável. Esta é a área
delimitada pelas três linhas cujos valores A e B são sempre inferiores ou iguais aos
valores de qualquer uma das linhas de restrição. (As restrições representam uma
fronteira; você não pode ter valores A ou B além deles.)

FIGURA 19-2:
Quer saber qual
vértice é o
o certo.

De acordo com o método simplex, a solução ótima é um dos cinco vértices do polígono
(que são (0,0), (20,0), (0,20), (16,6) e (6,16) )). Você pode descobrir qual é a solução
configurando as funções necessárias no pacote PuLP. Primeiro, defina o problema e
chame-o de modelo. Ao fazer isso, você determina que é um problema de maximização
e que A e B devem ser positivos:

model = pulp.LpProblem("max_profit", pulp.LpMaximize)


A = polpa.LpVariable('A', lowBound=0)
B = polpa.LpVariable('B', lowBound=0)

378 PARTE 5 Desafiando Problemas Difíceis


Machine Translated by Google

O solver PuLP também pode procurar soluções inteiras, algo que o simple plex original não
pode fazer. Basta adicionar cat='Integer' como parâmetro ao definir uma variável: A =
pulp.LpVariable('A', lowBound=0, cat='Integer'), e você obterá apenas números inteiros como
solução. Esteja ciente de que, em certos problemas, os resultados de números inteiros podem
ser menos ideais do que os resultados de números decimais; portanto, use uma solução inteira
somente se fizer sentido para o seu problema (por exemplo, você não pode produzir uma
fração de um produto).

Em seguida, some a função objetivo somando a soma das duas variáveis definidas por
pulp.LpVariable e representando as quantidades ideais dos produtos A e B, multiplicadas por
cada valor de lucro unitário:

modelo += lucro_A * A + lucro_B * B

Finalmente, adicione as restrições, exatamente da mesma forma que a função objetivo. Você
cria a formulação usando os valores apropriados (retirados do dicionário de dados) e as
variáveis A e B predefinidas:

modelo += res['res_1']['A'] * A + res['res_1']['B'


] * B <= res['res_1']['t'] * res['res_1']['n']
modelo += res['res_2']['A'] * A + res['res_2']['B'
] * B <= res['res_2']['t'] * res['res_2']['n']
modelo += res['res_3']['A'] * A + res['res_3']['B'
] * B <= res['res_3']['t'] * res['res_3']['n']

O modelo está pronto para otimizar (ele ingeriu tanto a função objetivo quanto as restrições).
Chame o método solve e verifique seu status. (Às vezes, uma solução pode ser impossível de
encontrar ou pode não ser ótima.)

model.solve()
print(f"Status de conclusão: {pulp.LpStatus[model.status]}")

Status de conclusão: Ideal

Tendo recebido a confirmação de que o otimizador encontrou a solução ideal, você imprime as
quantidades relacionadas do produto A e B:

print(f"Produção do modelo A = {A.varValue:0.1f}")


print(f"Produção do modelo B = {B.varValue:0.1f}")

Produção do modelo A = 16,0


Produção do modelo B = 6,0

CAPÍTULO 19 Empregando Programação Linear 379


Machine Translated by Google

Além disso, você imprime o lucro total resultante obtido por esta solução.

print(f"Lucro máximo alcançado: {pulp.value(model.


objetivo):0.1f}")

Lucro máximo alcançado: 63.000,0

380 PARTE 5 Desafiando Problemas Difíceis


Machine Translated by Google

NESTE CAPÍTULO

» Entendendo quando as heurísticas são


útil para algoritmos

» Descobrindo como o pathfinding pode ser


difícil para um robô

» Começando rapidamente usando a melhor


primeira pesquisa

» Melhorando o algoritmo de Dijkstra e


tomando a melhor rota heurística usando
A*

Capítulo 20

Considerando Heurísticas
heurística começou no Capítulo 18 que descreve a heurística como uma
Como um tópico
meio deuma
de usar algoritmo de conclusão,
busca local este
para navegar em capítulo completaO aCapítulo
soluções vizinhas. visão geral
18 de
define heurística como suposições educadas sobre uma solução — ou seja, são conjuntos de
regras práticas que apontam para o resultado desejado, ajudando assim os algoritmos a dar os
passos certos em direção a ele; no entanto, a heurística por si só não pode dizer exatamente
como chegar à solução.

O capítulo começa examinando os diferentes tipos de heurística, alguns dos quais são bastante
populares agora por causa de aplicações de IA e robótica: heurísticas de enxames,
metaheurísticas; modelagem baseada em aprendizado de máquina; e roteamento heurístico.
Em seguida, o capítulo discute como as heurísticas são usadas por robôs móveis para navegar
com segurança e eficácia em territórios conhecidos e desconhecidos para realizar suas
tarefas. Todas essas heurísticas são baseadas em distância, e a seção “Usando medidas de
distância como heurísticas” apresenta como duas das medidas de distância mais comuns,
distância euclidiana e distância de Manhattan, são calculadas e podem ajudar em diferentes
configurações de problemas.

O capítulo termina demonstrando dois poderosos algoritmos de busca de caminhos para ajudar os
robôs a chegarem a seus destinos com segurança: o algoritmo de busca do melhor primeiro e o algoritmo A*
(pronuncia-se A-star). O algoritmo A* é famoso porque alimentou o robô
Shakey, conforme explicado no capítulo. Antes de mostrar o código Python

CAPÍTULO 20 Considerando Heurística 381


Machine Translated by Google

que explica como os algoritmos funcionam, o capítulo também discute como criar um labirinto
de maneira eficaz (que é um algoritmo em si) para simular um ambiente com alguns desafios
(obstáculos) para os algoritmos de busca de caminhos baseados em heurística navegar.

Você não precisa digitar o código-fonte para este capítulo manualmente. Na verdade, usar a
fonte para download é muito mais fácil. Você pode encontrar a fonte deste capítulo no A4D2E;
20; Arquivo Heuristic Algorithms.ipynb da fonte para download. Consulte a Introdução para
obter detalhes sobre como localizar esse arquivo de origem.

Heurísticas Diferenciadas
A palavra heurística vem do grego antigo heuriskein, que significava inventar ou descobrir.
Seu significado original sublinha o fato de que empregar heurísticas é um meio prático de
encontrar uma solução que não está bem definida, mas que é encontrada através da
exploração e uma compreensão intuitiva da direção geral a ser tomada. A heurística depende
do palpite da sorte ou de uma abordagem de tentativa e erro de tentar soluções diferentes.
Um algoritmo heurístico, que é um algoritmo alimentado por heurística, resolve um problema
de forma mais rápida e eficiente em termos de recursos computacionais, sacrificando a
precisão e completude da solução. Essas soluções contrastam com a maioria dos algoritmos
discutidos até agora no livro, que possuem certas garantias de saída. Quando um problema
se torna muito complexo, um algoritmo heurístico pode representar a única maneira de obter
uma solução.

Você deve considerar que existem nuances de heurística, assim como pode haver nuances
para a verdade. A heurística toca as margens do desenvolvimento de algoritmos hoje. A
revolução da IA baseia-se nos algoritmos apresentados até agora no livro que ordenam,
organizam, pesquisam e manipulam entradas de dados. No topo da hierarquia estão os
algoritmos heurísticos que potencializam a otimização, bem como as pesquisas que
determinam como as máquinas aprendem com os dados e se tornam capazes de resolver
problemas de forma autônoma a partir da intervenção direta.

Heurísticas não são balas de prata; nenhuma solução resolve todos os problemas. Algoritmos
heurísticos têm sérias desvantagens e você precisa saber quando usá-los. Além disso, a
heurística pode levar a conclusões erradas tanto para computadores quanto para humanos.

De fato, para os humanos, as heurísticas cotidianas que economizam tempo muitas vezes
podem se mostrar erradas, como quando avaliamos uma pessoa ou situação à primeira vista
de maneira preconceituosa. Regras de conduta ainda mais fundamentadas, tiradas da
experiência, obtêm a solução certa apenas sob certas circunstâncias. Por exemplo, considere
o hábito de bater em aparelhos elétricos quando eles não funcionam. Se o problema for uma
conexão frouxa, bater no aparelho pode ser benéfico ao restabelecer o

382 PARTE 5 Desafiando Problemas Difíceis


Machine Translated by Google

conexão, mas você não pode fazer uma heurística geral de bater em aparelhos porque, em
outros casos, essa “solução” pode se mostrar ineficaz ou até mesmo causar sérios danos
ao aparelho.

Considerando os objetivos da heurística


As heurísticas podem acelerar as buscas longas e exaustivas realizadas por outras
soluções, especialmente com problemas NP-difíceis que exigem um número exponencial
de tentativas com base no número de suas entradas. Por exemplo, considere o problema
do caixeiro viajante ou variantes do problema SAT (2-SAT aparece no Capítulo 18), como
o MAX-3SAT (consulte https://people.maths.bris.ac.uk/~csxam/bccs /Aproximadamente.
pdf para detalhes adicionais). As heurísticas determinam a direção da busca usando
uma estimativa, que elimina um grande número de combinações que teria que testar
de outra forma.

Como uma heurística é uma estimativa ou uma suposição, ela pode levar o algoritmo que
se baseia nela a uma conclusão errada, que pode ser uma solução inexata ou apenas uma
solução subótima. Uma solução subótima é uma solução que funciona, mas não é a melhor
possível. Por exemplo, em uma estimativa numérica, uma heurística pode fornecer uma
solução que está alguns números abaixo da resposta correta. Essa solução abaixo do ideal
pode ser aceitável para muitas situações, mas algumas situações exigem uma solução
exata. Outros problemas frequentemente associados à heurística são a impossibilidade de
encontrar todas as melhores soluções, bem como a variabilidade do tempo e dos cálculos
necessários para chegar a uma solução. Uma heurística fornece uma combinação perfeita
ao trabalhar com algoritmos que, de outra forma, incorreriam em um alto custo ao interagir
com outras técnicas algorítmicas. Por exemplo, você não pode resolver certos problemas
sem heurística devido à baixa qualidade e ao grande número de entradas de dados. O
problema do caixeiro-viajante (TSP) é um destes: se você tiver que percorrer um grande
número de cidades, não poderá usar nenhum método exato. TSP e outros problemas
excluem qualquer solução exata. Os aplicativos de IA se enquadram nessa categoria
porque muitos problemas de IA, como reconhecer palavras faladas ou o conteúdo de uma
imagem, não podem ser resolvidos em uma sequência exata de etapas e regras.

Indo da genética para a IA


A discussão de busca local do Capítulo 18 apresenta heurísticas como recozimento
simulado e Busca Tabu. Você usa essas heurísticas para ajudar na otimização de escalada
(uma otimização que ajuda a não ficar preso a soluções que não são ideais). Além destes,
a família de heurísticas compreende muitas aplicações diferentes, entre as quais as
seguintes:

» Inteligência de enxames: Um conjunto de heurísticas baseadas no estudo do comportamento


de enxames de insetos (como abelhas, formigas ou vaga-lumes) ou partículas. O método usa

CAPÍTULO 20 Considerando Heurística 383


Machine Translated by Google

múltiplas tentativas de encontrar uma solução usando agentes que interagem cooperativamente
entre si e o cenário do problema. Um agente é uma unidade computacional independente, como ao
executar várias instâncias independentes do mesmo algoritmo, cada uma trabalhando em uma parte
diferente do problema. O professor Marco Dorigo, um dos maiores especialistas e contribuidores no
estudo de algoritmos de inteligência de enxames, fornece mais informações sobre este tópico em https://
scholar.google.com/citations?user=PwYT6EMAAAAJ

(que contém uma lista de suas várias obras).

» Metaheurísticas: Heurísticas que ajudam a determinar (ou mesmo gerar) a heurística certa para o seu
problema. Dentre as metaheurísticas, as mais conhecidas são os algoritmos genéticos, inspirados
na evolução natural. Os algoritmos genéticos começam com um conjunto de possíveis soluções de
problemas e, em seguida, geram novas soluções usando mutação (eles adicionam ou removem algo na
solução) e cross-over (eles misturam partes de soluções diferentes quando uma solução é divisível).

Por exemplo, no problema das n-rainhas (Capítulo 18), você vê que pode dividir um tabuleiro de xadrez
verticalmente em partes porque as damas não se movem horizontalmente como parte da solução do
problema, tornando-o um problema adequado para cruzamento.
Quando o conjunto é grande o suficiente, os algoritmos genéticos selecionam as soluções
sobreviventes, descartando aquelas que não funcionam ou não são promissoras. O conjunto
selecionado passa então por outra iteração de mutação, cruzamento e seleção.
Depois de bastante tempo e iterações, os algoritmos genéticos podem encontrar soluções com
melhor desempenho e completamente diferentes das iniciais.

» Aprendizado de máquina: Abordagens como sistemas neuro-fuzzy, máquinas de vetor de suporte e


redes neurais são a base de como um computador aprende a estimar e classificar a partir de exemplos
de treinamento fornecidos como parte de conjuntos de dados. Da mesma forma que uma criança
aprende por experiência, os algoritmos de aprendizado de máquina determinam como fornecer a
resposta mais plausível sem usar regras precisas e regras de conduta detalhadas. (Consulte Machine
Learning For Dummies, 2nd Edition, de John Paul Mueller e Luca Massaron [Wiley], para obter detalhes
sobre como o aprendizado de máquina funciona.)

» Roteamento heurístico: Um conjunto de heurísticas que auxilia os robôs (mas também é encontrado em
rede de telecomunicações e logística de transporte) para escolher o melhor caminho para evitar
obstáculos ao se deslocar.

Robôs de roteamento usando heurística


Guiar um robô em um ambiente desconhecido significa evitar obstáculos e encontrar uma
maneira de atingir um alvo específico. É uma tarefa fundamental e desafiadora em inteligência
artificial. Os robôs podem contar com diferentes sensores, como telêmetro a laser, lidar
(dispositivos que permitem determinar a distância de um objeto por meio de

384 PARTE 5 Desafiando Problemas Difíceis


Machine Translated by Google

de um raio laser) ou conjuntos de sonar (dispositivos que usam sons para ver seu ambiente) para
navegar em seus arredores. No entanto, a sofisticação do hardware usado para equipar os robôs
não importa; os robôs ainda precisam de algoritmos adequados para:

» Encontre o caminho mais curto para um destino (ou pelo menos um caminho razoavelmente curto)

» Evite obstáculos no caminho

» Execute comportamentos personalizados, como minimizar curvas ou frenagens

Um algoritmo de pathfinding (também chamado de planejamento de caminho ou simplesmente


pathing) ajuda um robô a começar em um local e alcançar um objetivo usando o caminho mais
curto entre os dois, antecipando e evitando obstáculos ao longo do caminho. (Não é suficiente
reagir depois de bater em uma parede.) Pathfinding também é útil ao mover qualquer outro
dispositivo para um alvo no espaço, mesmo um virtual, como em um videogame ou páginas em sites.

O roteamento autônomo é uma capacidade essencial dos carros autônomos (SDC), que são
veículos que podem detectar o ambiente da estrada e dirigir até o destino sem qualquer
intervenção humana. (Você ainda precisa dizer ao carro para onde ir; ele não pode ler mentes.)
Este artigo recente da Automotive World Magazine oferece uma boa visão geral sobre os
desenvolvimentos e expectativas para carros autônomos: https://www.
automotivoworld.com/articles/the-futuro-da-condução-o-que-esperar-em-2021/ .

Escotismo em territórios desconhecidos


Os algoritmos de localização de caminhos realizam todas as tarefas discutidas anteriormente
para obter o roteamento mais curto, evitar obstáculos e outros comportamentos desejados. Os
algoritmos funcionam usando mapas esquemáticos básicos de seus arredores. Esses mapas são
de dois tipos:

» Mapas topológicos: Diagramas simplificados que eliminam todos os detalhes desnecessários.


Os mapas retêm os principais pontos de referência, direções corretas e algumas
proporções de escala para distâncias. Exemplos da vida real de mapas topológicos
incluem mapas de metrô de Tóquio (https://www.tokyometro.jp/en/subwaymap/) e Londres
(https://tfl.gov.uk/maps/track/tube).

» Mapas de grade de ocupação: Esses mapas dividem os arredores em pequenos quadrados ou


hexágonos vazios, preenchendo-os quando os sensores do robô encontram um obstáculo no local
que representam. Você pode ver um exemplo de tal mapa na Universidade Técnica Tcheca em Praga:
http://cmp.felk.cvut.cz/cmp/demos/Omni/
móvel/. Além disso, confira os vídeos que mostram como um robô constrói e visualiza
esse mapa em https://www.youtube.com/watch?v=zjl7NmutMIc
e https://www.youtube.com/watch?v=RhPlzIyTT58.

CAPÍTULO 20 Considerando Heurística 385


Machine Translated by Google

Você pode visualizar mapas topológicos e de grade de ocupação como diagramas gráficos.
No entanto, eles são melhor compreendidos por algoritmos quando renderizados em uma
estrutura de dados apropriada. A melhor estrutura de dados para este propósito é o gráfico
porque os vértices podem facilmente representar quadrados, hexágonos, marcos e pontos de referência.
As arestas podem conectar vértices da mesma forma que estradas, passagens e caminhos.

O seu dispositivo de navegação GPS funciona através de gráficos. Subjacente ao mapa


contínuo, detalhado e colorido que o dispositivo exibe na tela, os mapas de estradas são
elaborados nos bastidores como conjuntos de vértices e arestas percorridas por algoritmos que
ajudam você a encontrar o caminho, evitando engarrafamentos.

A representação do território do robô como um grafo reintroduz os problemas discutidos no


Capítulo 9, que examina como viajar de um vértice para outro usando o caminho mais curto. O
caminho mais curto pode ser o caminho que toca o menor número de vértices ou o caminho
que custa menos (dada a soma do custo dos pesos das arestas cruzadas, que pode representar
o comprimento da aresta ou algum outro custo). Assim como ao dirigir seu carro, você escolhe
uma rota com base não apenas na distância percorrida para chegar ao seu destino, mas
também no tráfego (estradas cheias de tráfego ou bloqueadas por engarrafamentos), condições
da estrada e limites de velocidade que podem influenciar a qualidade do seu jornada.

Ao encontrar o caminho mais curto para um destino em um grafo, os algoritmos mais simples e
básicos da teoria dos grafos são a busca em profundidade e o algoritmo de Dijkstra (descrito no
Capítulo 9). A pesquisa em profundidade explora o gráfico indo o mais longe possível desde o
início e, em seguida, refazendo seus passos para explorar outros caminhos até encontrar o
destino. O algoritmo de Dijkstra explora o grafo de forma inteligente e gulosa, considerando
apenas os caminhos mais curtos. Apesar de sua simplicidade, ambos os algoritmos são
extremamente eficazes na avaliação de um gráfico simples, como em uma visão aérea, com
total conhecimento das direções que você deve seguir para chegar ao destino e pouco custo na
avaliação dos diversos caminhos possíveis.

A situação com um robô é um pouco diferente porque ele não consegue perceber todos os
caminhos possíveis ao mesmo tempo, sendo limitado em visibilidade e alcance de visão (os
obstáculos podem esconder o caminho ou o alvo pode estar muito longe). Um robô descobre
seu ambiente à medida que se move e, na melhor das hipóteses, pode avaliar a distância e a
direção de seu destino final. É como resolver um labirinto, embora não como jogar em um
labirinto de quebra-cabeças, mas mais parecido com a imersão em um labirinto de cerca viva,
onde você pode sentir a direção que está tomando ou pode identificar o destino à distância.

Labirintos de cerca viva são encontrados em todo o mundo. Alguns dos mais famosos foram
construídos na Europa de meados do século XVI ao século XVIII. Em um labirinto de cerca viva,
você não pode ver para onde está indo porque as cercas são muito altas. Você pode

386 PARTE 5 Desafiando Problemas Difíceis


Machine Translated by Google

perceber a direção (se você puder ver o sol) e até mesmo localizar o alvo (consulte https://
www.venetoinside.com/hidden-treasures/post/maze-of-villa-pisani-in stra-venice/ como um
exemplo). Há também labirintos de cerca viva famosos em filmes como O Iluminado de
Stanley Kubrick e em Harry Potter e o Cálice de Fogo.

Usando medidas de distância como heurística


Quando você não pode resolver problemas da vida real de uma maneira algorítmica precisa
porque sua entrada está confusa, ausente ou instável, o uso de heurística pode ajudar. Ao
realizar a busca de caminhos usando coordenadas em um plano cartesiano (mapas planos
que dependem de um conjunto de coordenadas horizontais e verticais), duas medidas
simples podem fornecer as distâncias entre dois pontos nesse plano: a distância euclidiana
e a distância Man hattan.

As pessoas geralmente usam a distância euclidiana porque ela deriva do Teorema de


Pitágogo em triângulos e representa a distância mais curta entre dois pontos. Se você quer
saber a distância na linha de visão entre dois pontos em um plano, digamos, A e B, e você
conhece suas coordenadas, você pode fingir que eles são os extremos da hipotenusa (o
lado mais longo de um triângulo retângulo) . Conforme ilustrado na Figura 20-1, você calcula
a distância com base no comprimento dos outros dois lados criando um terceiro ponto, C,
cuja coordenada horizontal é derivada de B e cuja coordenada vertical é derivada de A.

FIGURA 20-1:
A e B são
pontos nas
coordenadas de um mapa.

Este processo se traduz em tomar a diferença entre as coordenadas horizontais e verticais


de seus dois pontos, elevando ao quadrado ambas as diferenças (de modo que elas

CAPÍTULO 20 Considerando Heurística 387


Machine Translated by Google

ambos se tornam positivos), some-os e, finalmente, extraindo a raiz quadrada do resultado.


Neste exemplo, ir de A para B usa as coordenadas de (1,2) e (3,3):

sqrt((1-3)2 + (2-3)2) = sqrt(22+12) = sqrt(5) = 2,236

Na verdade, você não pode medir distâncias na superfície da Terra exatamente usando a
distância euclidiana porque sua superfície não é plana, mas é quase esférica. À medida que
as distâncias entre os pontos que você mede na superfície da Terra aumentam, a subestimação
da distância euclidiana também aumentará. As medidas mais apropriadas quando longas
distâncias estão envolvidas são a distância de haversine com base nas coordenadas de
latitude e longitude (consulte https://www.igismap.com/haversine formula-calculate-geographic-
distance-earth/) ou a distância mais exata de Vincenty (consulte https://metacpan.org/pod/
GIS::Distance::Vincenty).

A distância de Manhattan funciona de forma diferente. Você começa somando os comprimentos


dos lados b e c, o que equivale a somar o valor absoluto das diferenças entre as coordenadas
horizontais e verticais dos pontos A e B.

|(1-3)| + |(2-3)| = 2 + 1 = 3

A distância euclidiana marca a rota mais curta, e a distância de Manhattan fornece a rota mais
longa, porém mais plausível, se você espera obstáculos ao seguir uma rota direta. Na verdade,
o movimento representa a trajetória de um táxi em Manhat tan (daí o nome), movendo-se ao
longo de um quarteirão para chegar ao seu destino (pegar o caminho curto pelos prédios nunca
funcionaria). Outros nomes para esta abordagem são a distância do quarteirão da cidade ou a
distância do táxi. Conseqüentemente, se você tem que se mover de A para B, mas não sabe
se encontrará obstáculos entre eles, fazer um desvio pelo ponto C é uma boa heurística porque
essa é a distância que você espera na pior das hipóteses.

Explicando algoritmos de localização de caminho

Esta última parte do capítulo concentra-se na explicação de dois algoritmos, a busca do melhor
primeiro e o A* (lido como A-star), ambos baseados em heurística. As seções a seguir
demonstram que ambos os algoritmos fornecem uma solução rápida para um problema de
labirinto que representa um mapa topológico ou de grade de ocupação que é representado
como um gráfico. Ambos os algoritmos são amplamente utilizados em robótica e videogames.

Criando um labirinto
Um mapa topológico ou de grade de ocupação assemelha-se a um labirinto de sebes, como
mencionado anteriormente, especialmente se existirem obstáculos entre o início e o final da rota.

388 PARTE 5 Desafiando Problemas Difíceis


Machine Translated by Google

Existem algoritmos especializados para criar e processar labirintos, mais notavelmente o


Wall Follower (conhecido desde a antiguidade: você coloca sua mão em uma parede e nunca
a puxa até sair do labirinto) ou o algoritmo Pledge.
(Leia mais sobre as sete classificações do labirinto em http://www.astrolog.org/
labyrnth/algrithm.htm.) No entanto, o pathfinding é fundamentalmente diferente da solução de
labirintos porque, no pathfinding, você sabe onde o alvo deve estar, enquanto os algoritmos de
resolução de labirintos tentam resolver o problema na completa ignorância de onde está a
saída.

Conseqüentemente, o procedimento de simulação de um labirinto de obstáculos que um robô


deve navegar tem uma abordagem diferente e mais simples. Em vez de criar um enigma de
obstáculos, você cria um gráfico de vértices dispostos em uma grade (semelhante a um
mapa) e remove conexões aleatoriamente para simular a presença de obstáculos. O gráfico
não é direcionado (você pode percorrer cada aresta em ambas as direções) e ponderado
porque leva tempo para se mover de um vértice para outro. Em particular, leva mais tempo
para se mover na diagonal do que t para cima/para baixo ou para a esquerda/direita.

O primeiro passo é importar os pacotes Python necessários. O código define as funções de


distância euclidiana e de Manhattan a seguir. (Você pode encontrar esse código no arquivo
de código-fonte para download A4D2E; 20; Heuristic Algorithms.ipynb ; consulte a Introdução
para obter detalhes.)

seqüência de importação

importar networkx como nx


importar matplotlib.pyplot como plt
importar aleatório

def euclidean_dist(a, b, coord):


(x1, y1) = coord[a]
(x2, y2) = coord[b]
return ((x1 - x2)**2 + (y1 - y2)**2)**0,5

def manhattan_dist(a, b, coord):


(x1, y1) = coord[a]
(x2, y2) = coord[b]
return abs(x1 - x2) + abs(y1 - y2)

def não_informativo(a,b):
retornar 0

def ravel(listasdelistas):
return [item para elem em listas de listas para item em elem]

CAPÍTULO 20 Considerando Heurística 389


Machine Translated by Google

A próxima etapa cria uma função para gerar labirintos aleatórios. Ele é baseado em
uma semente de número inteiro de sua escolha que permite recriar o mesmo labirinto
toda vez que você fornecer o mesmo número. Caso contrário, a geração do labirinto é
completamente aleatória. Há também uma função geral, node_neighbors(), para criar
porque fornece as direções a serem tomadas de um vértice em um gráfico e torna o
código que cria o labirinto mais legível.

def node_neighbors(graph, node): return


[exit for exit in graph.edges(node)
_,
if exit!=node]

def create_maze(seed=2, Drawing=True):


random.seed(seed)
letras = [l for l in string.ascii_uppercase[:25]] checkboard =
[letters[i:i+5] for i in range(0, len(letters), 5)]

Graph = nx.Graph()
for j, node in enumerate(letters):
Graph.add_nodes_from(node) x, y =
j // 5, j % 5 x_min = max(0, x-1) x_max
= min(4 , x+1)+1 y_min = max(0, y-1)
y_max = min(4, y+1)+1
nodes_adjacentes =
ravel( [row[y_min:y_max] for row in
checkboard[x_min:x_max]] )

saídas = random.sample(adjacent_nodes,
k=random.randint(1, 4))
para saída em saídas:

se a saída não estiver em node_neighbors(Graph,


node): Graph.add_edge(node, exit)
espaçamento = [0,0, 0,2, 0,4, 0,6, 0,8]
coordenadas = [[x, y] para x no espaçamento \ para
y no espaçamento] posição =
{l:c para l,c em zip(letras, coordenadas)}

para nó em Graph.nodes:
para sair em node_neighbors(Graph, node):
comprimento =

int(round( euclidean_dist( nó, saída, posição)*10,0))


Graph.add_edge(nó,saída,peso=comprimento)

390 PARTE 5 Desafiando Problemas Difíceis


Machine Translated by Google

se desenhar:
draw_params = {'with_labels':True,
'node_color':'skyblue',
'node_size':700, 'width':2,
'font_size':14}
nx.draw(Gráfico, posição, **draw_params)
rótulos = nx.get_edge_attributes(Gráfico,'peso')
nx.draw_networkx_edge_labels(Gráfico, posição,
edge_labels=labels)
plt.show()

retorno gráfico, posição

As funções retornam um gráfico NetworkX (Graph), uma estrutura de dados favorita para
representar gráficos, que contém 25 vértices (ou nós, se preferir) e um mapa Cartesiano
de pontos (posição). Os vértices são colocados em uma grade de 5 x 5, conforme mostrado
na Figura 20-2. A saída também aplica funções de distância e calcula a posição dos
vértices.

gráfico, coordenadas = create_maze(seed=7)

FIGURA 20-2:
Um labirinto

representando um

mapa topológico com


obstáculos.

No labirinto gerado por um valor de semente de 7, cada vértice se conecta com os outros.
Como o processo de geração é aleatório, alguns mapas podem conter vértices
desconectados, o que impede a passagem entre os vértices desconectados. Para ver como
isso funciona, tente um valor de semente de 6. Isso realmente acontece na realidade; por
exemplo, às vezes um robô não consegue chegar a um determinado destino porque está
cercado por obstáculos.

CAPÍTULO 20 Considerando Heurística 391


Machine Translated by Google

Procurando uma rota rápida de preferência


O algoritmo de busca em profundidade explora o grafo movendo-se de vértice para vértice e
adicionando direções a uma estrutura de dados de pilha. Quando é hora de se mover, o algoritmo
se move para a primeira direção encontrada na pilha. É como percorrer um labirinto de salas
tomando a primeira saída que você vê. Muito provavelmente, você chega a um beco sem saída,
que não é o seu destino. Você então refaz seus passos para as salas visitadas anteriormente para
ver se encontra outra saída, mas leva muito tempo quando está longe de seu alvo.

A heurística pode ajudar muito com a repetição criada por uma estratégia de busca em profundidade.
O uso da heurística pode dizer se você está se aproximando ou se afastando do seu alvo. Essa
combinação é chamada de algoritmo de busca do melhor primeiro (BFS).
Nesse caso, o melhor do nome indica o fato de que, ao explorar o gráfico, você não vê a primeira
aresta à vista. Em vez disso, você avalia qual vantagem tomar com base naquela que deve
aproximá-lo do resultado desejado com base na heurística. Esse comportamento se assemelha à
otimização gananciosa (o melhor primeiro), e algumas pessoas também chamam esse algoritmo
de pesquisa gananciosa do melhor primeiro. O BFS provavelmente errará o alvo no início, mas
devido à heurística, ele não terminará muito longe do alvo e retrairá menos do que faria se estivesse
usando apenas a pesquisa em profundidade.

Você usa o algoritmo BFS principalmente em rastreadores da web que procuram determinadas
informações na web. Na verdade, o BFS permite que um agente de software se mova em um
gráfico praticamente desconhecido, usando heurísticas para detectar o quão próximo o conteúdo
da próxima página se assemelha ao inicial (para explorar melhor conteúdo). O algoritmo também é
amplamente utilizado em videogames, ajudando personagens controlados pelo computador a se
moverem em busca de inimigos e recompensas, assemelhando-se a um comportamento ganancioso
e orientado a alvos.

Demonstrar BFS em Python usando o labirinto construído anteriormente ilustra como um robô pode
se mover em um espaço vendo-o como um gráfico. O código a seguir mostra algumas funções
gerais, que também são usadas para o próximo algoritmo desta seção. A função graph_weight()
determina o custo de ir de um vértice para outro.
O peso representa a distância ou o tempo.

def grafico_peso(grafo, a, b):


return graph.edges[(a, b)]['peso']

O algoritmo de planejamento de caminho simula o movimento do robô em um gráfico. Quando


encontra uma solução, o plano se traduz em movimento. Portanto, os algoritmos de planejamento
de caminho fornecem uma saída informando a melhor forma de mover de um vértice para outro;
você ainda precisa de uma função para traduzir as informações e determinar a rota a ser percorrida
e calcular a duração da viagem. As funções reconstruir_path() e compute_
path() fornece o plano em termos de etapas e custo esperado quando recebe o resultado do
algoritmo de planejamento de caminho.

392 PARTE 5 Desafiando Problemas Difíceis


Machine Translated by Google

def reconstruir_path(conexões, início, objetivo):


se objetivo nas conexões:
atual = caminho do
objetivo = [atual]
enquanto atual != iniciar:

atual = conexões[atual] caminho.append(atual)


caminho de retorno[::-1]

def compute_path_dist(caminho, gráfico):


se caminho:
correr = 0
para passo em range(len(path)-1):
A = caminho[passo]
B = caminho[passo+1]
executar += graph_weight(graph, A, B)
corrida de retorno
senão:
retornar 0

Tendo preparado todas as funções básicas, o exemplo cria um labirinto usando um valor
de semente de 30. Este labirinto apresenta algumas rotas principais que vão do vértice
A ao vértice Y porque existem alguns obstáculos no meio do mapa (como mostrado na
Figura 20 -3). Há também um beco sem saída no caminho (vértice I).

gráfico, coordenadas = create_maze(seed=30) start = 'A'

gol = 'Y'
pontuação = manhattan_dist

FIGURA 20-3:
Um labirinto intrincado
a ser resolvido por
heurística.

CAPÍTULO 20 Considerando Heurísticas 393


Machine Translated by Google

A implementação do BFS é um pouco mais complexa do que o código de busca em


profundidade encontrado no Capítulo 9. Ela usa duas listas: uma para conter os vértices
nunca visitados (chamado lista_aberta) e outra para conter os vértices visitados
(lista_fechada). A lista open_list atua como uma fila de prioridade, na qual uma prioridade
determina o primeiro elemento a ser extraído. Nesse caso, a heurística fornece a prioridade,
portanto, a fila de prioridade fornece uma direção mais próxima do destino. A heurística de
distância de Manhattan funciona melhor por causa dos obstáculos que obstruem o caminho para o destino:

# Melhor primeira pesquisa

caminho =
{} lista_aberta = set(graph.nodes())
lista_fechada = {início: manhattan_dist(início, objetivo, coordenadas)}

enquanto lista_aberta:

candidatos = open_list&closed_list.keys() if
len(candidates)==0: print ("Não foi possível encontrar um
caminho para a meta %s" % meta)
parar
fronteira = [(lista_fechada[nó],
node) para node em candidatos]
pontuação, min_node =sorted(frontier)[0]

if min_node==goal: print
("Chegou no vértice final %s" % objetivo) print ('Vértices não
visitados: %i' % (len(open_list)-1))

parar
senão:
print("Processando o vértice %s, " % min_node, end="")

open_list = open_list.difference(min_node) neighbors =


node_neighbors(graph, min_node) to_be_visited =
list(neighbors-closed_list.keys())

if len(to_be_visited) == 0: print ("não


encontrou nenhuma saída, refazendo para %s" %
path[min_node])
senão:
print ("descoberto %s" % str(to_be_visited))

para nó em vizinhos:
se o nó não estiver na lista_fechada:

394 PARTE 5 Desafiando Problemas Difíceis


Machine Translated by Google

lista_fechada[nó] = scoring(nó, objetivo,


coordenadas)
caminho[nó] = min_node

print('\nO melhor caminho é:', reconstruir_path(


caminho, início, objetivo))
print('Comprimento do caminho: %i' % compute_path_dist(
reconstruir_path(caminho, início, objetivo), gráfico))

A saída detalhada do exemplo informa como o algoritmo funciona:

Processando o vértice A, descoberto ['F', 'B', 'G']


Processando o vértice G, descoberto ['H', 'C', 'L', 'K']
Processando o vértice H, descoberto ['M']
Processando o vértice M, descoberto ['R', 'N']
Processando o vértice N, descoberto ['J']
Processando o vértice J, descoberto ['E', 'O']
Processando o vértice O, descoberto ['S', 'T']
Processando o vértice T, descoberto ['Y', 'X']
Chegou ao vértice final Y
Vértices não visitados: 16

O melhor caminho é: ['A', 'G', 'H', 'M', 'N', 'J', 'O', 'T', 'Y']
Comprimento do caminho: 18

O BFS continua se movendo até ficar sem vértices para explorar. Quando ele esgota os
vértices sem atingir o alvo, o código informa que não pode alcançar o alvo e o robô não se
moverá. Quando o código encontra o destino, ele para de processar os vértices, mesmo
que open_list ainda contenha vértices, o que economiza tempo.

Encontrar um beco sem saída, como terminar no vértice I, significa procurar uma rota
anteriormente não utilizada. A melhor alternativa aparece imediatamente graças à fila de
prioridade, e o algoritmo a aceita. Neste exemplo, o BFS ignora eficientemente 16 vértices
e segue a rota ascendente no mapa, completando sua jornada de A a Y em 18 etapas.

Você pode testar outros labirintos definindo um número de sementes diferente e


comparando os resultados do BFS com o algoritmo A* descrito na próxima seção. Você
descobrirá que às vezes o BFS é rápido e preciso na escolha da melhor maneira, e às
vezes não é. Se você precisa de um robô que pesquise rapidamente, o BFS é a melhor escolha.

CAPÍTULO 20 Considerando Heurística 395


Machine Translated by Google

Percorrendo heuristicamente por A*


O algoritmo A* produz rapidamente os melhores caminhos mais curtos em um grafo
combinando a busca gulosa de Dijikstra discutida no Capítulo 9 com uma parada inicial
(o algoritmo para quando atinge seu vértice de destino) e uma estimativa heurística
(geralmente baseada na distância de Manhattan) que indica a área do gráfico a ser
explorada primeiro. A* foi desenvolvido no Centro de Inteligência Artificial do Stanford
Research Institute (agora chamado SRI International) em 1968 como parte do projeto
Shakey, o robô. Shakey foi o primeiro robô móvel a decidir de forma autônoma como ir
a algum lugar, embora se limitasse a vagar por algumas salas dos laboratórios.
(Se você assistir ao vídeo em https://www.youtube.com/watch?v=qXdn6ynwpiI,
você vê que é a pessoa que está filmando que está trêmula, não o robô. Mas, como
sempre, é mais fácil culpar o robô. Shakey foi um marco na implementação robótica
que demonstrou como era tecnologicamente possível construir um robô móvel
operando sem supervisão humana no final da década de 1960.

Para tornar o Shakey totalmente autônomo, seus desenvolvedores criaram o algoritmo


A*, a transformação Hough (uma transformação de processamento de imagem para
detectar as bordas de um objeto) e o método gráfico de visibilidade (uma maneira de
representar um caminho como um gráfico). ). O artigo em http://www.ai.sri.com/shakey/
descreve Shakey com mais detalhes e até o mostra em ação. O algoritmo A* é
atualmente o melhor algoritmo disponível quando você procura a rota mais curta em
um gráfico e deve lidar com informações e expectativas parciais (conforme capturadas
pela função heurística que guia a pesquisa). A* é capaz de

» Encontre sempre a solução do caminho mais curto: O algoritmo pode fazer isso se
tal caminho existe e se A* for devidamente informado pela estimativa heurística. A* é
alimentado pelo algoritmo Dijkstra, que garante sempre encontrar a melhor solução.

» Encontre a solução mais rápido do que qualquer outro algoritmo: A* pode fazer isso se
tiver acesso a uma heurística justa — uma que forneça as direções certas para alcançar a
proximidade do alvo de maneira semelhante, embora ainda mais inteligente, ao BFS.

» Calcula pesos ao atravessar arestas: Os pesos são responsáveis pelo custo de se mover em
uma determinada direção. Por exemplo, virar pode demorar mais do que ir direto, como no
caso de Shakey, o robô.

Uma heurística adequada, justa e admissível fornece informações úteis a A* sobre a


distância até o alvo, nunca superestimando o custo de alcançá-lo. Além disso, A* faz
maior uso de sua heurística do que BFS, portanto a heurística deve realizar cálculos
rapidamente ou o tempo total de processamento será muito longo.

396 PARTE 5 Desafiando Problemas Difíceis


Machine Translated by Google

A implementação do Python neste exemplo usa o mesmo código e estruturas de


dados usados para BFS, mas há diferenças entre eles. As principais diferenças são
que, à medida que o algoritmo avança, ele atualiza o custo de alcance do vértice inicial
para cada um dos vértices explorados. Além disso, ao decidir sobre uma rota, A*
considera o caminho mais curto desde o início até o destino, passando pelo vértice
atual, pois soma a estimativa da heurística com o custo do caminho calculado até o
vértice atual. Esse processo permite que o algoritmo realize mais cálculos do que BFS
quando a heurística é uma estimativa adequada e determine o melhor caminho possível.

Encontrar o caminho mais curto possível em termos de custo é a função central do


algoritmo de Dijkstra. A* é simplesmente um algoritmo de Dijkstra no qual o custo de
alcançar um vértice é aumentado pela heurística da distância esperada até o alvo. O
Capítulo 9 descreve o algoritmo de Dijkstra em detalhes. Revisitar a discussão do
Capítulo 9 o ajudará a entender melhor como A* opera na alavancagem da heurística.

# UMA*

open_list = set(graph.nodes())
lista_fechada = {início: manhattan_dist(
início, objetivo, coordenadas)}
visitado = {início: 0}
caminho = {}

enquanto lista_aberta:

candidatos = open_list&closed_list.keys()
if len(candidatos)==0:
print ("Não foi possível encontrar o caminho para a meta %s" % meta)
parar
fronteira = [(lista_fechada[nó],
node) para node em candidatos]
pontuação, min_node =sorted(fronteira)[0]

if min_node==objetivo:
print ("Chegou ao vértice final %s" % meta)
print('Vértices não visitados: %i' % (len(
lista_aberta)-1))
parar
senão:
print("Processando o vértice %s, " % min_node, end="")

open_list = open_list.difference(min_node)
current_weight = visitado[min_node]

CAPÍTULO 20 Considerando Heurística 397


Machine Translated by Google

vizinhos = node_neighbors(graph, min_node) to_be_visited


= list(neighbors-visited.keys()) para nó em vizinhos:

new_weight = current_weight + graph_weight(


graph, min_node, node) se o
node não estiver em visitado ou \ new_weight <
visitado[node]:
visitado[nó] = new_weight
lista_fechada[nó] = manhattan_dist(nó, objetivo, coordenadas)
+ new_weight
caminho[nó] = min_node

se_ser_visitado:
print ("descoberto %s" % to_be_visited)
senão:
print ("voltando à lista aberta")

print ('\nO melhor caminho é:', reconstruir_path( caminho,


início, objetivo)) print ('Comprimento do caminho:
%i' % compute_path_dist(
reconstruir_path(caminho, início, objetivo), gráfico))

Quando o A* conclui a análise do labirinto, ele gera um melhor caminho que é


mais curto que a solução BFS:

Processando o vértice A, descoberto ['F', 'B', 'G']


Processando o vértice B, descoberto ['C']
Processando o vértice F, descoberto ['L', 'K']
Processando o vértice G, descoberto ['H']
Processando o vértice C, voltando à lista aberta
Processando o vértice K, descoberto ['Q', 'P']
Processando o vértice H, descoberto ['M']
Processando o vértice L, descoberto ['R']
Processando o vértice P, descoberto ['U', 'V']
Processando o vértice M, descoberto ['N']
Processando o vértice Q, descoberto ['W']
Processando o vértice R, descoberto ['S']
Processando o vértice U, voltando à lista aberta
Processando o vértice N, descoberto ['J']
Processando o vértice V, voltando à lista aberta
Processando o vértice S, descoberto ['Y', 'X', 'O']

398 PARTE 5 Desafiando Problemas Difíceis


Machine Translated by Google

Processando o vértice W, voltando à lista aberta


Processando o vértice X, descoberto ['T']
Processando o vértice J, descoberto ['E']
Chegou ao vértice final Y
Vértices não visitados: 5

O melhor caminho é: ['A', 'F', 'L', 'R', 'S', 'Y']


Comprimento do caminho: 13

Esta solução tem um custo: A* explora quase todos os vértices presentes, deixando
apenas cinco vértices não considerados. Assim como em Dijkstra, seu pior tempo de
execução é O(v2), onde v é o número de vértices no grafo; ou O(e + v*log(v)), onde e é
o número de arestas, ao usar filas de prioridade mínima, uma estrutura de dados eficiente
quando você precisa obter o valor mínimo para uma lista longa. O algoritmo A* não é
diferente em seu pior tempo de execução do que o de Dijkstra, embora em média ele
tenha um desempenho melhor em grafos grandes porque primeiro encontra o vértice
alvo quando guiado corretamente pela medição heurística (no caso de um robô de
roteamento, o Manhat tan distância).

CAPÍTULO 20 Considerando Heurística 399


Machine Translated by Google
Machine Translated by Google

6 A parte das dezenas


Machine Translated by Google

NESTA PARTE . . .

Desenvolvimento de abordagens avançadas de processamento de dados

Confiando mais fortemente na automação e

respostas

Executando tarefas de manipulação de dados mais rapidamente

Garantir que os algoritmos se comportem conforme necessário e desejado


Machine Translated by Google

NESTE CAPÍTULO

» Considerando rotinas de classificação e pesquisa

» Usando números aleatórios

» Tornando os dados menores

» Garantir que os dados permaneçam secretos


e muito mais. . .

Capítulo 21

Dez algoritmos que são


Mudando o mundo
É difícil
mudando imaginar umentanto,
o mundo. No algoritmo fazendo
os algoritmos hoje muita coisa
aparecem física,
em todos muitoemenos
os lugares, você
pode nem perceber o efeito que eles têm em sua vida.

A maioria das pessoas percebe que as lojas online e outros locais de vendas dependem de
algoritmos para determinar quais produtos complementares sugerir com base em compras
anteriores. No entanto, a maioria das pessoas desconhece os usos de algoritmos na medicina,
muitos dos quais ajudam um médico a decidir qual diagnóstico fornecer.

Algoritmos aparecem nos lugares mais estranhos. O tempo dos semáforos geralmente depende
dos cálculos dos algoritmos. Algoritmos ajudam seu smartphone a falar com você, e algoritmos
fazem sua televisão fazer mais do que qualquer televisão fez no passado. Consequentemente,
não é tão impossível acreditar que os algoritmos estão prontos para mudar o mundo. Este
capítulo destaca dez deles.

Para os puristas de algoritmos, você pode dizer que o algoritmo mudou o mundo ao longo dos
séculos, então nada realmente mudou por milhares de anos.
Os babilônios usavam algoritmos para realizar fatoração e encontrar raízes quadradas
desde 1800 a 1600 aC (https://www.historyofinformation.com/detail.
php?entryid=4379). Al-Khawarizmi descreveu algoritmos para resolver equações lineares
e quadráticas por volta de 820 dC (https://www.newscientist.com/people/
muhammad-ibn-musa-al-khwarizmi/). Este capítulo se concentra em algoritmos baseados em
computador, mas os algoritmos existem há muito tempo.

CAPÍTULO 21 Dez Algoritmos Que Estão Mudando o Mundo 403


Machine Translated by Google

Usando rotinas de classificação

Sem dados ordenados, a maior parte do mundo pararia. Para usar dados, você deve
ser capaz de encontrá-los. Você pode encontrar centenas de algoritmos de classificação
explicados em sites como https://betterexplained.com/articles/sorting-algorithms/ e
como parte deste livro (ver Capítulo 7).

No entanto, as três rotinas de classificação mais comuns são merge sort, quick sort e heapsort devido
à velocidade superior que eles fornecem (veja as comparações de tempo em https://
www.cprogramming.com/tutorial/computersciencetheory/sortcomp.
html). A rotina de classificação que funciona melhor para seu aplicativo depende
do seguinte:

» O que você espera que o aplicativo faça

» O tipo de dados com os quais você trabalha

» Os recursos de computação que você tem disponíveis

O ponto é que a capacidade de classificar dados em qualquer formato que um aplicativo precise para
realizar uma tarefa faz o mundo funcionar, e esse recurso está mudando a forma como o mundo
funciona.

Algumas empresas hoje prosperam como resultado do uso de algoritmos de classificação. Por
exemplo, considere o fato de que o Google existe porque ajuda as pessoas a encontrar coisas, e essa
capacidade reside substancialmente na capacidade de classificar dados para torná-los prontamente
acessíveis. Considere o quão difícil seria encontrar um item na Amazon sem a rotina de classificação.
Mesmo esse aplicativo de receita em seu computador em casa depende muito de rotinas de
classificação para manter os dados que ele contém em ordem. Na verdade, provavelmente não seria
muito difícil dizer que qualquer aplicativo substancial depende muito de rotinas de classificação.

Procurando coisas com rotinas de pesquisa

Assim como as rotinas de classificação, as rotinas de pesquisa aparecem em quase todos os


aplicativos de qualquer tamanho atualmente. Os aplicativos aparecem em todos os lugares, mesmo
em lugares nos quais você pode não pensar muito, como seu carro. Encontrar informações rapidamente
é uma parte essencial da vida diária. Por exemplo, imagine estar atrasado para um compromisso e de
repente descobrir que seu GPS não consegue encontrar o endereço que você precisa. Assim como
as rotinas de classificação, as rotinas de pesquisa vêm em todas as formas e tamanhos, e você pode encontrar

404 PARTE 6 A Parte das Dez


Machine Translated by Google

eles descritos em sites como https://tekmarathon.com/2012/10/05/best search-


algorithm-2/ e https://www.geeksforgeeks.org/searching algoritmos/. Na verdade, há
mais rotinas de pesquisa do que rotinas de classificação porque os requisitos de pesquisa
geralmente são mais árduos e complexos.
Você também encontra muitas rotinas de pesquisa discutidas neste livro (consulte o Capítulo 7).

Agitando as coisas com números aleatórios


Todos os tipos de coisas seriam muito menos divertidos sem aleatoriedade. Por exemplo, imagine
começar Paciência e ver exatamente o mesmo jogo toda vez que você o iniciar. Ninguém jogaria
um jogo desses. Consequentemente, a geração de números aleatórios é uma parte essencial da
experiência de jogo. Na verdade, conforme expresso em vários capítulos deste livro, alguns
algoritmos realmente exigem algum nível de aleatoriedade para funcionar corretamente (consulte a
seção “Considerando por que a aleatoriedade é necessária” do Capítulo 17 como exemplo). Você
também descobre que o teste funciona melhor ao usar valores aleatórios em alguns casos (consulte
a seção “Escolhendo um tipo específico de compactação” do Capítulo 14 como exemplo).

Os números que você obtém de um algoritmo geralmente são de Geradores de Números Pseudo-
Random (PRNGs), o que significa que você pode prever o próximo número em uma série
conhecendo o algoritmo e o valor de semente usado para gerar o número. É por isso que esta
informação é tão bem guardada. É possível instalar hardware em um computador para criar um
True Random Number Generator (TRNG), conforme descrito no artigo “Gerando números aleatórios
é muito mais difícil do que você pensa” em https://betterprogramming.pub/generating random-
numbers- é muito mais difícil do que você pensa b121c3e75d08. Infelizmente, TRNGs vêm com
alguns problemas, como criar criptografia insegura em alguns casos. Assim, o artigo também
descreve o Gerador de Números Pseudoaleatórios Criptograficamente Seguro (CSPRNG). Em
outras palavras, você pode encontrar todos os tipos de métodos de criação de números aleatórios,
cada um com seu próprio uso de destino.

Os métodos baseados em hardware de computador para criar números aleatórios podem depender
de ruído atmosférico ou mudanças de temperatura (consulte https://engineering.mit.edu/
engajar/pergunte-um-engenheiro/pode-um-computador-gerar-um- número-realmente-
aleatório/ para detalhes). Na verdade, você pode obter uma solução de números aleatórios
baseada em hardware, como TrueRNG (https://www.amazon.com/exec/obidos/ASIN/
B01KR2JHTA/datacservip0f-20/) e ChaosKey (https://altusmerum.org/
ChaosKey/) (somente esquema) e conecte-o ao seu slot USB para gerar o que provavelmente são
números aleatórios verdadeiros. O interessante do site ChaosKey é que ele fornece um esquema
para mostrar como ele coleta ruído aleatório e o transforma em um número aleatório.

CAPÍTULO 21 Dez Algoritmos Que Estão Mudando o Mundo 405


Machine Translated by Google

Executando a compactação de dados

O Capítulo 14 discute técnicas de compactação de dados e usa o tipo de compactação que você
normalmente encontra usado para arquivos. No entanto, a compactação de dados afeta todos os
aspectos da computação hoje. Por exemplo, a maioria dos arquivos gráficos, de vídeo e de áudio
dependem da compactação de dados. Sem compactação de dados, você não poderia obter o nível
de taxa de transferência necessário para fazer tarefas como filmes transmitidos funcionarem.

No entanto, a compactação de dados encontra ainda mais usos do que você poderia esperar.
Praticamente todo sistema de gerenciamento de banco de dados (DBMS) depende da compactação
de dados para fazer com que os dados caibam em uma quantidade razoável de espaço em disco. A
computação em nuvem não funcionaria sem compactação de dados porque o download de itens da
nuvem para máquinas locais levaria muito tempo. Mesmo as páginas da Web geralmente dependem
da compactação de dados para obter informações de um lugar para outro.

Mantendo os dados em segredo

O conceito de manter os dados em segredo não é novo. Na verdade, é uma das razões mais antigas
para usar algum tipo de algoritmo. A palavra criptografia na verdade vem de duas palavras gregas:
kryptós (oculto ou secreto) e graphein (escrita). De fato, os gregos foram provavelmente os primeiros
usuários de criptografia (ver https://www.britannica.
com/tópico/cifra), e textos antigos relatam que Júlio César usou missivas criptografadas para se
comunicar com seus generais. O ponto é que manter os dados em segredo é uma das batalhas mais
longas da história. No momento em que uma parte encontra uma maneira de manter um segredo,
outra pessoa encontra uma maneira de tornar o segredo público quebrando a criptografia. Os usos
gerais da criptografia orientada por computador hoje incluem:

» Confidencialidade: Garantir que ninguém possa ver as informações trocadas


entre duas partes

» Integridade dos dados: reduzindo a probabilidade de que alguém ou algo


possa alterar o conteúdo dos dados transmitidos entre duas partes

» Autenticação: Determinando a identidade de uma ou mais partes

» Não repúdio: Reduzir a capacidade de uma parte dizer que não cometeu um
ato particular

Além de guardar segredo ao usar computadores, a história dos algoritmos criptográficos baseados
em computador é longa e interessante. A seção “Ocultando seus segredos com criptografia” do
Capítulo 14 oferece uma boa visão geral das técnicas criptográficas. Você também pode encontrar
uma lista de algoritmos comumente usados (tanto presentes quanto

406 PARTE 6 A Parte das Dez


Machine Translated by Google

historic) em https://www.axel.org/2021/05/28/history-of-encryption/ e https://komodoplatform.com/en/


academy/history-of-cryptology/. O guia em https://www.owasp.org/index.php/Guide_to_Cryptography
fornece detalhes adicionais sobre como a criptografia funciona.

Alterando o domínio de dados


A Transformada de Fourier e a Transformada Rápida de Fourier (FFT) fazem uma enorme diferença
na forma como os aplicativos percebem os dados. Esses dois algoritmos transformam dados do
domínio da frequência (a rapidez com que um sinal oscila) para o domínio do tempo (o diferencial de
tempo entre as alterações do sinal). Na verdade, é impossível obter qualquer tipo de diploma em
hardware de computador sem ter passado muito tempo trabalhando com esses dois algoritmos
extensivamente. Tempo é tudo.

Ao saber com que frequência algo muda, você pode descobrir o intervalo de tempo entre
as alterações e, portanto, saber quanto tempo você tem para executar uma tarefa antes
que uma mudança de estado exija que você faça outra coisa. Esses algoritmos
geralmente são usados em filtros de todos os tipos. Sem os efeitos de filtragem desses
algoritmos, a reprodução fiel de vídeo e áudio por meio de uma conexão de streaming
seria impossível. Todos esses aplicativos parecem bastante avançados, e são, mas
alguns tutoriais incríveis dão uma noção melhor de como esses algoritmos funcionam
(veja o tutorial em https://w.astro.berkeley.edu/~jrg/ngst/fft/fft .html
como um exemplo). O tutorial em https://betterexplained.com/articles/
um-guia-interativo-para-a-transformada-de-fourier/ é possivelmente o mais interessante e
especialmente divertido se você gosta de smoothies.

Analisando links
A capacidade de analisar relacionamentos é algo que tornou a computação moderna única. A
capacidade de primeiro criar uma representação desses relacionamentos e depois analisá-los é o
assunto da Parte 3 deste livro. A ideia da web, na verdade, é criar conexões, e a conectividade foi
uma consideração no início do que se tornou um fenômeno mundial. Sem a capacidade de analisar
e utilizar links, aplicativos como bancos de dados e e-mail não funcionariam.

Você não conseguia se comunicar bem com amigos no Facebook.

À medida que a web amadureceu e as pessoas se tornaram mais sintonizadas com dispositivos que
tornam a conectividade mais simples e onipresente, aplicativos como o Facebook e sites de vendas
como a Amazon fizeram maior uso da análise de links para fazer as coisas.

CAPÍTULO 21 Dez Algoritmos Que Estão Mudando o Mundo 407


Machine Translated by Google

como vender mais produtos. Obviamente, parte dessa conectividade tem um resultado negativo (consulte
https://www.pcmag.com/opinions/facebook-a-tool-for-evil
e https://www.minotdailynews.com/opinion/national-columnists/2021/
10/facebook-o-novo-império-do-mal/ como exemplos), mas, na maioria das vezes, a análise de links permite
que as pessoas permaneçam mais bem informadas e em melhor contato com o mundo ao seu redor.

É claro que a análise de links faz mais do que informar de uma maneira conectada. Considere o uso da
análise de vínculos para fornecer instruções de direção ou para encontrar vínculos casuais entre a atividade
humana e a doença. A análise de links permite que você veja a conexão entre coisas que você normalmente
não considera, mas que têm um impacto em sua vida diária. Por causa da análise de links, você pode viver
mais porque um médico pode aconselhá-lo sobre quais hábitos mudar para corrigir problemas que podem se
tornar problemas mais tarde. O ponto é que as conexões existem em todos os lugares, e a análise de links
oferece um método para determinar onde essas conexões existem e se elas são realmente importantes.

Identificando padrões de dados


Os dados não existem no vácuo. Todos os tipos de fatores afetam os dados, incluindo vieses que influenciam
a forma como os humanos percebem os dados. O Capítulo 10 discute como os dados tendem a se agrupar
em determinados ambientes e como a análise desses agrupamentos pode revelar todo tipo de coisa sobre os
dados.

A análise de padrões está na vanguarda de alguns dos usos mais surpreendentes dos computadores
atualmente. Por exemplo, a estrutura de detecção de objetos Viola–Jones (consulte https://
towardsdatascience.com/understanding-face-detection-with the-viola-jones-object-detection-framework-
c55cc2a9da14 para detalhes) possibilita o reconhecimento facial em tempo real. Esse algoritmo pode permitir
que as pessoas criem melhor segurança em locais como aeroportos, onde indivíduos nefastos atualmente
exercem seu comércio. Algoritmos semelhantes podem ajudar seu médico a detectar cânceres de vários tipos
muito antes que o câncer seja realmente visível ao olho humano. A detecção precoce aumenta a probabilidade
de uma recuperação completa. O mesmo vale para todos os tipos de outros problemas médicos (como
encontrar fraturas ósseas que atualmente são pequenas demais para serem vistas, mas causam dor).

Você também encontra o reconhecimento de padrões usado para propósitos mais mundanos. Por exemplo, a
análise de padrões permite que as pessoas detectem possíveis problemas de tráfego antes que eles ocorram.
Também é possível usar a análise de padrões para ajudar os agricultores a produzir mais alimentos a um
custo menor, aplicando água e fertilizantes somente quando necessário. O uso do reconhecimento de padrões
também pode ajudar a mover os drones pelos campos para que o agricultor se torne

408 PARTE 6 A Parte das Dez


Machine Translated by Google

mais tempo eficiente e pode trabalhar mais terra a um custo menor. Sem algoritmos, esses tipos
de padrões, que têm um impacto tão alto na vida cotidiana, não podem ser reconhecidos.

Lidando com Automação e


Respostas automáticas
O algoritmo de derivada integral proporcional é bastante complicado. Apenas tente dizer isso três
vezes rápido! No entanto, é um dos algoritmos secretos mais importantes sobre os quais você
nunca ouviu falar, mas no qual confia todos os dias. Você pode encontrar alguns detalhes sobre
este algoritmo em https://www.ni.com/en-us/innovations/white-papers/06/
pid-theory-explained.html, incluindo um diagrama de blocos mostrando uma
implementação potencial.

O algoritmo derivativo integral proporcional conta com um mecanismo de feedback de malha de


controle para minimizar o erro entre o sinal de saída desejado e o sinal de saída real. Você o vê
usado em todo lugar para controlar a automação e as respostas automáticas. Por exemplo,
quando seu carro derrapa porque você freia com muita força, esse algoritmo ajuda a garantir que
o Sistema de Frenagem Automática (ABS) realmente funcione conforme o esperado. Caso
contrário, o ABS pode compensar demais e piorar a situação.

Quase todas as formas de maquinário hoje usam o algoritmo de derivação integral proporcional.
Na verdade, a robótica não seria possível sem ela. Imagine o que aconteceria com uma fábrica
se todos os robôs constantemente supercompensassem todas as atividades em que se
engajassem. O caos resultante convenceria rapidamente os proprietários a parar de usar
máquinas para qualquer finalidade.

Criando identificadores exclusivos


Parece que todo mundo é apenas um número. Na verdade, não apenas um número – muitos e
muitos números. Todos os cartões de crédito das pessoas têm um número, assim como a carteira
de motorista e o identificador do governo. Da mesma forma para todos os tipos de outras
empresas e organizações. As pessoas realmente precisam manter listas de todos os números
porque simplesmente têm muitos para rastrear. No entanto, cada um desses números deve
identificar a pessoa exclusivamente para alguma parte. Por trás de toda essa singularidade estão
vários tipos de algoritmos.

CAPÍTULO 21 Dez Algoritmos Que Estão Mudando o Mundo 409


Machine Translated by Google

O Capítulo 7 discute os hashes, que são uma maneira de garantir exclusividade. Por trás
de hashes e criptografia está a fatoração de inteiros, um tipo de algoritmo que quebra
números realmente grandes em números primos. Na verdade, a fatoração de inteiros é um
dos tipos de problemas mais difíceis de resolver com algoritmos, mas as pessoas estão
trabalhando no problema o tempo todo. Grande parte da sociedade hoje depende de sua
capacidade de se identificar de forma única, que os segredos ocultos da criação desses
identificadores são uma parte essencial de um mundo moderno.

410 PARTE 6 A Parte das Dez


Machine Translated by Google

NESTE CAPÍTULO

» Executando tarefas mais rápido e melhor

» Resolvendo novos tipos de problemas

» Considerando a viabilidade de
hipercomputadores

» Empregando funções unidirecionais, e


mais ...

Capítulo 22

Dez Algorítmicos
Problemas ainda por resolver

entistas teriam descoberto e aperfeiçoado todos os algoritmos até agora.


Os algoritmos existem
Infelizmente, háé séculos,
o oposto verdadeiro.então você
Resolver pensariausando
um problema que aum
ciência
algoritmo específico geralmente apresenta mais algumas questões que o algoritmo
não resolve e que não pareciam aparentes até que alguém apresentasse a solução.
Além disso, as mudanças nas tecnologias e no estilo de vida geralmente apresentam
novos desafios que exigem ainda mais algoritmos. Por exemplo, a natureza conectada
da sociedade e o uso de robôs aumentaram a necessidade de novos algoritmos.

Conforme apresentado no Capítulo 1, os algoritmos consistem em uma série de etapas usadas


para resolver um problema, e você não deve confundi-los com outras entidades, como equações.
Um algoritmo nunca é uma solução em busca de um problema. Ninguém criaria uma série
de etapas para resolver um problema que ainda não existe (ou pode nunca existir). Além
disso, muitos problemas são interessantes, mas não têm necessidade urgente de uma solução.
Consequentemente, mesmo que todos conheçam o problema e entendam que
alguém pode querer uma solução para ele, ninguém tem pressa em criar a
solução.

Este capítulo trata de problemas algorítmicos que serviriam a um propósito caso alguém
encontrasse uma solução para eles. Em suma, a razão pela qual você precisa se
preocupar com este capítulo é que você pode encontrar um problema que realmente
gostaria de resolver e pode até decidir fazer parte da equipe que o resolve.

CAPÍTULO 22 Dez Problemas Algorítmicos Ainda Para Resolver 411


Machine Translated by Google

Resolvendo problemas rapidamente

À medida que o aprendizado de máquina decola e as pessoas contam cada vez mais com
computadores para resolver problemas, a questão da rapidez com que um computador pode
resolver um problema se torna crítica. O problema P-versus-NP simplesmente pergunta se um
computador pode resolver um problema rapidamente quando pode verificar a solução para o
problema rapidamente (consulte a seção “Considerando problemas NP completos” do Capítulo 15
para obter detalhes). Em outras palavras, se o computador pode razoavelmente assegurar que
uma resposta humana a um problema está correta em tempo polinomial ou menos, ele também
pode resolver o problema em tempo polinomial ou menos?

John Nash discutiu originalmente essa questão na década de 1950 em cartas à Agência de
Segurança Nacional (NSA), e a discussão surgiu novamente em cartas entre Kurt Gödel e John
von Neumann. Além do aprendizado de máquina (e da IA em geral), esse problema específico é
uma preocupação para muitos outros campos, incluindo matemática, criptografia, pesquisa de
algoritmos, teoria dos jogos, processamento multimídia, filosofia e economia.

Resolvendo problemas do 3SUM com mais eficiência

O problema 3SUM pergunta se, dado um conjunto de números reais, quaisquer três desses
números somarão zero. O problema pode até ser generalizado para k números para propósitos
especiais, tornando-se assim um problema k-SUM. A princípio, o 3SUM não parece um problema
que valha a pena resolver. É geralmente usado em geometria computacional para resolver
problemas como estes:

» Determinar quando um conjunto de pontos contém três pontos colineares (pontos


situados na mesma linha reta)

» Calculando a área da união de um conjunto de triângulos

» Decidir se um polígono convexo pode ser colocado dentro de outro convexo


polígono

No entanto, muitas situações ocorrem na vida real que envolvem três atores, e a soma de suas
interações deve ser zero (como em um jogo de soma zero). Um exemplo fácil está em uma
transação financeira mediada. Você pode encontrar um exemplo ainda mais pertinente na troca de
créditos de carbono por neutralidade de carbono, que envolve a troca de emissões por créditos
entre empresas ou países para manter uma emissão estável de dióxido de carbono.

412 PARTE 6 A Parte das Dez


Machine Translated by Google

Embora você possa resolver facilmente o problema 3SUM em tempo O(n2) , vários
cientistas da computação trabalharam para diminuir a complexidade de tempo para
resolver o algoritmo 3SUM. Após várias melhorias, o melhor algoritmo atual pode resolver
o problema 3SUM em O(n2(log log n)O(1)/log2n), mas os cientistas acreditam que, em
bases teóricas, o limite pode ser ainda mais reduzido.

Tornando a multiplicação de matrizes mais rápida


A multiplicação de matrizes é usada em quase todos os lugares agora para resolver uma
ampla gama de problemas. O problema é que a multiplicação de matrizes é lenta – muito
lenta – porque leva muitos passos. O objetivo atual é atingir etapas de multiplicação de
n2, de acordo com o artigo em https://www.quantamagazine.org/
mathematicians-inch-closer-to-matrix-multiplication-goal-20210323/.
(A abordagem atual dos livros didáticos de matemática requer n3 etapas.) Alcançar a
meta significaria dar quatro etapas para multiplicar uma matriz 2-x=2 em vez das oito
etapas de multiplicação (e algumas adições) que são necessárias agora. De acordo com https://
iq.opengenus.org/unsolved-problems-in-algorithms/, a multiplicação de matrizes é um dos
principais problemas não resolvidos em algoritmos.

Então, onde você vê a multiplicação de matrizes usada? Muitos usos são o tipo de coisa que
matemáticos e artistas gráficos adoram, como inclinar a Mona Lisa para que ela pareça
estranha (veja https://betterexplained.com/articles/matrix multiplication/). A discussão em
https://www.quora.com/What-are-some of-the-real-time-applications-of-Matrix-multiplication dá-
lhe algumas idéias úteis do mundo real, como onde localizar um novo quartel.

Determinando se um
A aplicação terminará
Um dos problemas que Alan Turing propôs em 1936 é a questão de saber se um
algoritmo, dada uma descrição de um programa e uma entrada, poderia determinar se o
programa acabaria por parar (o problema da parada). Ao trabalhar com um aplicativo
simples, é fácil determinar em muitos casos se o programa irá parar ou continuar rodando
em um loop infinito. No entanto, à medida que a complexidade do programa aumenta,
torna-se mais difícil determinar o resultado da execução do programa com qualquer
entrada dada. Uma máquina de Turing não pode fazer essa determinação; o resultado é
um código com bugs com loops infinitos. Nenhuma quantidade de testes que usa a
tecnologia atual pode resolver esse problema.

CAPÍTULO 22 Dez Problemas Algorítmicos Ainda Para Resolver 413


Machine Translated by Google

Um hipercomputador é um modelo de computação que vai além da máquina de Turing para


resolver problemas como o problema da parada. No entanto, tais máquinas não são
possíveis usando a tecnologia atual. Se fossem possíveis, você seria capaz de perguntar a
eles todos os tipos de imponderáveis que os computadores não podem responder
atualmente. O artigo em https://www.newscientist.com/article/mg22329781-500-what-will
hypercomputers-let-us-do-good-question/ dá uma boa idéia do que aconteceria se alguém
fosse capaz de resolver este problema. Você pode ler sobre uma potencial implementação
futura em https://www.foprc.org/quantum-hyper
computação-por-meios-de-fótons-evanescentes.php.

Criando e usando funções unidirecionais


Uma função unidirecional é uma função fácil de usar para obter uma resposta em uma
direção, mas quase impossível de usar com o inverso dessa resposta. Em outras palavras,
você usa uma função unidirecional para criar algo como um hash que apareceria como
parte de uma solução para criptografia, identificação pessoal, autenticação ou outras
necessidades de segurança de dados.

A existência de uma função de mão única é menos mistério e mais uma questão de prova.
Muitos sistemas de telecomunicações, comércio eletrônico e bancos eletrônicos atualmente
contam com funções que são supostamente unidirecionais, mas ninguém sabe realmente
se elas realmente são unidirecionais. A existência de uma função unidirecional é atualmente
uma hipótese, não uma teoria (veja uma explicação da diferença entre as duas em https://
www.diffen.com/difference/Hypothesis_vs_Theory). Se alguém pudesse provar que existe
uma função unidirecional, os problemas de segurança de dados seriam mais fáceis de
resolver do ponto de vista da programação.

Multiplicando Números Realmente Grandes


Números realmente grandes existem em muitos lugares. Por exemplo, considere realizar
os cálculos envolvendo distâncias a Marte, ou talvez Plutão. Atualmente, existem métodos
para realizar multiplicação em números muito grandes, mas eles tendem a ser lentos
porque exigem várias operações para serem concluídos. O problema ocorre quando os
números são muito grandes para caber nos registradores do processador. Nesse ponto, a
multiplicação deve ocorrer em mais de uma etapa, o que torna as coisas consideravelmente
mais lentas. As soluções atuais incluem:

» Algoritmo de multiplicação complexo de Gauss

» multiplicação de Karatsuba

414 PARTE 6 A Parte das Dez


Machine Translated by Google

» Toom Cook

» Métodos de transformada de Fourier

Embora muitos dos métodos atualmente disponíveis produzam resultados aceitáveis, todos
eles levam tempo e, quando você tem muitos cálculos para realizar, o problema do tempo
pode se tornar crítico. Consequentemente, a multiplicação de grandes números é um
daqueles problemas que requer uma solução melhor do que as disponíveis hoje.

Dividindo um recurso igualmente


Dividir recursos igualmente pode não parecer difícil, mas os humanos, sendo do tipo
invejoso, podem ver o recurso como desigualmente dividido, a menos que você encontre
uma maneira de garantir a todos que a divisão é realmente justa. Este é o problema do
corte de bolo sem inveja. Quando você corta um bolo, não importa o quão justo você tente
fazê-lo, sempre há a percepção de que a divisão é injusta. Criar uma divisão justa de
recursos é importante no dia a dia para minimizar conflitos entre as partes interessadas em
qualquer organização, tornando todos mais eficientes.

Já existem duas soluções para o problema do corte de bolo sem inveja com um número específico de
pessoas, mas não existe uma solução geral. Quando há duas pessoas envolvidas, a primeira corta o bolo e
a segunda escolhe o primeiro pedaço. Desta forma, ambas as partes têm a garantia de uma divisão igual.
O problema se torna mais difícil com três pessoas, mas você pode encontrar a solução Selfridge-Conway
para o problema em https://archive.ochronus.com/cutting-the-pie/. (O site discute a torta, mas o processo é
o mesmo.) No entanto, depois que você chega a quatro pessoas, não existe solução.

Reduzindo o tempo de cálculo da distância de edição


A distância de edição entre duas strings é o número de operações necessárias para
transformar uma string em outra string. O cálculo da distância gira em torno das operações
de distância Levenshtein, que são a remoção, inserção ou substituição de um caractere na
string. Essa técnica em particular é usada em interfaces de linguagem natural, quantificação
de sequências de DNA e todos os tipos de outros lugares onde você pode ter duas strings
semelhantes que exigem algum tipo de comparação ou modificação.

Atualmente existem várias soluções para esse problema, todas bastante lentas. Na verdade,
a maioria deles leva tempo O(n2) , então o tempo necessário para realizar uma
transformação rapidamente chega ao ponto em que os humanos podem ver pausas no
processamento da entrada. A pausa não é tão ruim quando se usa um processador de texto que executa

CAPÍTULO 22 Dez problemas algorítmicos ainda para resolver 415


Machine Translated by Google

verificação automática de palavras e altera uma palavra incorreta para a correta. No entanto,
ao usar interfaces de voz, a pausa pode se tornar bastante perceptível e fazer com que o
operador humano cometa erros. O objetivo atual é permitir editar o cálculo da distância em
tempo subquadrático: O(n2ÿÿ ).

Jogando o jogo da paridade


A princípio, resolver um jogo pode não parecer tão útil na vida real. Sim, os jogos são
divertidos e interessantes, mas eles não fornecem um pano de fundo para fazer algo útil - pelo
menos, essa é a teoria geral. No entanto, a teoria dos jogos entra em jogo em um grande
número de cenários da vida real, muitos dos quais envolvem processos complexos que
alguém pode entender mais facilmente como jogos do que como processos reais. Nesse
caso, o jogo ajuda as pessoas a entender a verificação automatizada e a síntese do
controlador, entre outras coisas. Você pode ler mais sobre o jogo de paridade em http://
www.sciencedirect.com/science/article/pii/S089054011
5000723. Na verdade, você pode jogar se quiser, em https://www.abefehr.com/
paridade/.

Entendendo as questões espaciais


Para contextualizar esse problema específico, pense em mover caixas em um armazém ou
em outras situações que o levem a considerar o espaço em que as coisas se movem.
Obviamente, se você tem muitas caixas em um grande armazém e todas elas exigem uma
empilhadeira para serem recolhidas, você não quer tentar descobrir como armazená-las de
maneira ideal, reorganizando-as fisicamente. É aqui que você precisa resolver o problema
visualizando uma solução.

No entanto, a questão é se todos os problemas espaciais têm uma solução. Nesse caso,
pense em um daqueles quebra-cabeças infantis que faz você montar uma imagem deslizando
os pequenos ladrilhos. Parece que uma solução deveria existir em todos os casos, mas em
algumas situações, um ponto de partida ruim pode resultar em uma situação que não tem
solução. Você pode encontrar uma discussão sobre esse problema em https://math.stack
exchange.com/questions/754827/does-a-15-puzzle-always-have-a-solution.

Matemáticos como Sam Loyd (veja https://www.mathsisfun.com/puzzles/


sam-loyd-puzzles-index.html) costumam usar quebra-cabeças para demonstrar problemas
matemáticos complexos, alguns dos quais não têm solução hoje. Visitar esses sites é divertido
porque você não apenas recebe algum entretenimento gratuito, mas também o que pensa. As
questões que esses quebra-cabeças levantam têm aplicações práticas, mas são apresentadas
de uma forma divertida.

416 PARTE 6 A Parte das Dez


Machine Translated by Google

Índice
algoritmos inspirados na natureza,
Um
354 algoritmos de localização de
algoritmo A*, 35, 381, 388, 396–399
caminhos, 388–399 algoritmos
abscissa (coordenada x), 39 máquinas
aleatórios, 331–348 algoritmos
abstratas, 36–37 Adamaszek, Michal, 32
recursivos, 13, 40, 310 mesmo algoritmo com usos
Adamaszek, Anna, 32 heurísticas
diferentes, 21 algoritmo simplex, 368, 372–373 que
admissíveis, 396 Advanced Driver-Assist
estão mudando o mundo, 403 –410 usos de, 12–14
Systems (ADAS), 15 Advanced Encryption
Standard (AES), agente 284–287, definido, 18
Al-Khawarizmi, 403
AGraph, 149, 150 AI (inteligência artificial) 222, 383–
erros permitidos, 240
384 problemas algorítmicos, alguns ainda a serem
Amazon, análise de links em, 407
resolvidos, 411–415 algoritmos. Veja também algoritmos
Código Padrão Americano para Informações
específicos
Padrão de codificação de intercâmbio (ASCII), 269
“A Anatomia de uma Web Hipertextual de Grande Escala
Motor de busca” (Brin e Page), 210
como tudo sobre como obter uma resposta aceitável, Apache Lucene, 253
19–20 análise de, 36 categorização de, 119 comparação Apache Software Foundation, 253
de como usando medição, 24 complexos como Interface de programação de aplicativos (API), 150
geralmente menos favoráveis do que simples, 36 aplicativos, determinando se um terminará, 412–413
custos de, 24, 32–35 definidos, 11 descritos, 10 –15,
20–21 projeto de, 23–40 descobrindo os corretos Aristóteles, 27
para usar, 173–174 como distintos de equações, 411 A Arte da Programação de Computadores (Knuth),
elementos de, 15 avaliação de, 35–40 encontrando-os 13, 35–36

em todos os lugares, 14–15 algoritmos genéticos, 368, Inteligência Artificial: Uma Abordagem Moderna (Russell
e Norvig), 309 inteligência artificial (AI), 222, 383–
384 colocando-os no negócio, 231–232 algoritmos
384 neurônios artificiais, 17 astronomia, como campo
gananciosos, 24, 31, 32, 174, 291–306 algoritmos
onde o big data tem base, 228–229
heurísticos, 34–35, 382 criação histórica de, 10, 27–28,
403 importância da precisão no uso, 157 forma iterativa
de, 13 idiomas de, 21 criação moderna de, 10
saídas assíncronas, como aspecto que pode amortecer o
paralelismo, 252
Soluções aleatórias de Atlantic City, 333
autoridades, 211 respostas automáticas/
automação de tratamento, 409

B
Babbage, Charles, 35
backlinks, 212

Índice 417
Machine Translated by Google

codificação base64, 285, 287 César, Júlio, 283, 406

Teorema de Bayes, 20-21 Espaço cartesiano, 371, 373


Uma Mente Brilhante (filme), 28 censo, 235, 236

Bélady, László, 300 centralidade, 151, 154


Bellman, Richard Ernest, 308, 309 encadeamento, 135
Algoritmo de Bellman-Ford, 180, 187-190, 191 ChaosKey,
benchmarks, 37, 119 substituição de 405 caracteres, 283–285

Berkeley Open Infrastructure for Network Cheat Sheet,


Computação, 18 problema de satisfatibilidade de 4 circuitos,
algoritmo de melhor pesquisa (BFS), 381, 388, 392–395 algoritmo de substituição clarividente 360, classe
300, classe básica de construção, 82-90 cláusulas,
Bezzel, Max, 355
método de percolação de 360 cliques, 201-202
BFS (busca em largura), 34, 164–168 busca
cliques, camuflagem 199-201, 211 cluster, 253, 254
bidirecional, como exemplo de algoritmo de busca de força
computação em cluster, 18 agrupamento, 153
bruta, 34 big data, 225–248 notação Big O, 39–40 heap
código, 56, 92–93. Consulte também codificando
binário, 127, 128 . ., 240 filtros Bloom, 135, 240–245, 264
células de código, 52–53 Colab (Google) definindo,
circuitos booleanos, resolvendo a satisfatibilidade de, 359–365
42–46 executando código, 56 familiarizando-se com
circuito combinacional booleano, 360 tédio, 219–220 Borÿvka,
os recursos de, 44–46 obtendo ajuda para, 57
Otakar, 173, 170 árvore geradora mínima de Borÿvka (MST),
realizando tarefas comuns, 51–55 entendendo o que
173 limitada problema da mochila, 317 limites, 371 nós de
ele faz, 42–43 usando aceleração de hardware, 55–
ramificação (da árvore), 110, 277 busca em largura (BFS), 34,
56 trabalhando com, 41–57 trabalhando com
164–168 Brin, Sergey, 210–212, 213 Bron, Coenraad, 201
BST (árvore de busca binária) 127, 128, 129-130 solução de notebooks, 47–51 Colab Pro (guia de configurações

força bruta, 29, 34, 121 Transformada de Burrows-Wheeler, no Colab), 45 colisões, 133, 134–135, 248

275 “Combinatória da mudança Problema"

(Adamszek e Adamaszek), 32 comparação,

como fator em benchmarks de tempo, 119 Comparação de Teste


Probabilístico para Primalidade (Finn), 334

compressão, 270–273
técnicas de compressão, 267, 270 cache de
computador, 299–300 computadores

cache, definido, 299, 308 cache, Supercomputadores Cray, 251 cada


tarefa executada como envolvendo algoritmos, 15
definido, 311

418 Algoritmos para Leigos


Machine Translated by Google

falhas de, 255 compressão e ocultação de, 267–287 considerando


tirando o máximo proveito das CPUs modernas e a necessidade de remediação de, 102–105 consumo de,
GPUs, 16
234 lidando com duplicação de dados, 102–103 lidando
hipercomputador, 413 com valores ausentes, 103–104 determinando a
ponto de vista de compreensão, 22 uso de necessidade de estrutura de, 100–105 diferenciando
para resolver problemas, 15–19 variação permutações em, 68–69 enfrentando repetições, 70
em, 15–16
encontrando-os em todos os lugares, 228–231 descobrindo
Algoritmo de Sugestão de Conexão, 197 o uso de dicionários, 108–109 como encontrar os dados
programação de restrição, 368 restrições, 368– certos, 209–210 mantendo-os em segredo, 406 aproveitando
369, 371, 379 consumo (de dados), 234
os dados disponíveis, 18–19 tornando-os menores, 268 –
282 gerenciando imensas quantidades de, 250–259
custos
manipulação de, 59 correspondência de várias fontes, 101–
de algoritmos, 24 102 realizando compactação de, 406 dados brutos, 99
custos de computação e heurísticas seguintes, 32–35 representando relações de no gráfico, 112–115 reservando
contra-exemplos, encontrar na resolução de problemas, os dados corretos, 235–236 pesquisando Internet para,
26–27
208 combinações de embaralhamento de, 69–70 tamanho
Count-Min Sketch (algoritmo), 135, 248
e complexidade de fontes de dados que afetam muito a
CPLEX, 375
resolução da solução, 20 esboçando uma resposta a partir
CPUs, 16, 227
de dados de fluxo, 240–248 classificação usando
“Agrupando mais componentes em
Circuitos” (Moore), 226 classificação por mesclagem e classificação rápida, 118–
126
rastejando, 209
Cray, Seymour, 251
Supercomputadores Cray, 251
criar, ler, atualizar e excluir (CRUD), 117
Número pseudoaleatório criptograficamente seguro
Gerador (CSPRNG), 405
criptografia, 14, 268, 282–287, 406–407 satisfação
do cliente, endereçamento de, 302
detectar padrões em, 408–409
Corte, Doug, 253 ciclos
empilhamento e empilhamento em ordem, 105–
(armadilhas para aranhas), 219
109 armazenamento de, 105, 234 fluxos de
streaming de, 233–239 estrutura de obter solução,

D 21–22 estruturação de, 99–115 resumo de, 234

DAGs (Gráficos Acíclicos Dirigidos), 168, 169 fator transformação poder em, 226–233 compreensão

de amortecimento, 219 do conteúdo dos dados, 100–101 compreensão

Dantzig, George Bernard, 368 datas, da codificação de, 268–269 valor de está em como
67-70 é usado, 232 maneiras de lidar com grandes

organizando e pesquisando, 117–137 quantidades de, 233–234

organizando dados de computador em cache, 299–


300 organizando como fazendo a diferença, 22 big
data, 225–248 alterando o domínio de, 407
trabalhando com árvores, 109–112

Índice 419
Machine Translated by Google

domínio de dados, alteração de, 407 problema do caixeiro viajante (TSP). Ver viagem
padrões de dados, localização de, 408–409 problema do vendedor (TSP)

Database Management System (DBMS), 406 becos


sem saída (rank sinks), 219 deadlines, cumprimento
E
de, 302–303 editar distância, 326, 414–415
Dean, Jeffery, 253
Editor (aba de configurações no Colab),
decoradores, 315
44 efetivos, como requisito para o processo representar um
descriptografia, 284, 286 algoritmo, 11
matriz degenerada, 67 Elastic MapReduce (EMR), 256
graus de separação, contagem de, 202–204 busca em elementos, encontrando o número de elementos distintos,
246–247
profundidade (DFS), 34, 165–168 deque, uso de, 165
determinante, cálculo de, 90, 91–95 Decimal de Dewey abordagem de produto elementar, encapsulamento
System, 29 DFS (Distributed File System), 18, 250 87–88, como técnica OOP, codificação 81, 268–

dicionários, 108–109, 137 Dijkstra, Edsger W., 180 269, 273–275, 276–277, criptografia 278, uso de

algoritmo de Dijkstra, 180, 184–187, 191, 293, 386, codificação para, 268
397 dimensão, 63 gráficos acíclicos dirigidos ( DAGs), Enron corpus, 147

168, 169 grafos direcionados, 143, 182, 204 grafos conjunto, 25, 275

direcionais, 146 vértices descobertos, 162 computação equação, definido, 11


distribuída, 18, 250 Distributed File System Euclides, 10, 12
(DFS), 253, 254 distribuições, compreensão de, 335– Distância euclidiana, 387, 388, 389
339 divisão-e- abordagem de conquista, 28-30, 75-78, trocas, como fator de referência de tempo, 119 abordagem
82, 119, 122, 234 exaustiva, 29 tempo exponencial, 297, 298

F
Facebook, 196, 233, 250, 407
fatorial, 72
Fano, Robert M., 276, 303

DjVu, 270 Transformada Rápida de Fourier (FFT), 407

DNA, 237, 274, 304-305 Fermi, Enrico, 339

Dorigo, Marco, 384 pilha de Fibonacci, 179

produto escalar, 63, 65, 88–90 Números de Fibonacci, 40, 311–312, 314
arquivos
programação dinâmica
aproximadamente busca de string, 326–329 comparação de em Colab, 46

soluções de baixo para cima, 310 lançando distribuindo de, 253–255 finito,

recursão dinamicamente, 311–314 em como requisito para o processo representar um algoritmo, 11


Finn, J., 334 Firefox, 43 Fischer, Michael J., 326 Flajolet,
comparação com programação linear (LP), 369 definido,
Philippe, 246
308 descobrindo as melhores receitas dinâmicas, 316–
329 explicando, 308–316 problema da mochila, 312, 317–
321

420 Algoritmos para Leigos


Machine Translated by Google

Flajolet-Martin (algoritmo LogLog), 246 Sistema de Posição Global (GPS), 161


achatamento, 90, 95–96 Floyd, Robert W., 190 Gödel, Kurt, 411
algoritmo Floyd-Warhsall, 180, 190–194 dobra, soluções suficientemente boas, 24
como técnica para evitar colisões, 134 fórmulas, Google, mudança no algoritmo de, 13
11, 12–13 Fourier Transformação, 407, 414 problema Google Colaboratory (Colab), 41–57
da mochila fracionária, 317 detecção de fraude, uso Google DeepMind, 297
de PageRank in, 221 gráficos de amizade, 196 Google Drive, 48
algoritmo direcionado à força de Fruchterman- Sistema de arquivos do Google (GFS), 253

Reingold, 198 decoradores de função, 307–308 Algoritmo Google PageRank, 207, 209, 210–222, 232, 250
Programação Funcional para Leigos (Mueller), 256
GPUs (Graphics Processing Unit), 16, 17, 55–56
gradiente descendente, 357 análise de gráfico, como
uso para algoritmos, 14 funcionalidade de gráfico, 151
gráficos adicionados à matriz, 157–158 construção de,
114–115 centralidade de computação, 154 –157
linguagens de programação funcionais, 256 considerando a essência de, 142–145 contando
funções
arestas e vértices em, 152–153 criando de, 163–164
Notação Big O, 39–40 definido, 141 definindo como desenhar, 148–151 grafo
criando e usando funções unidirecionais, 413 direcionado, 143–144, 182, 204 grafo direcional, 146
formas de, 370 funções lambda, 256 em
descobrindo segredos do gráfico, 195–205 distinguindo
programação linear (LP), 370 funções objetivas,
atributos-chave de, 149–150 desenho de, 150–151
354–355, 376, 379 trabalhando com, 38–40 imaginando redes sociais como, 196–202 como
extensão de árvore, 113 encontrando-os em todos os
lugares, 145–146 formas de, 143 gráficos de amizade,
196

G
G = (V, E), 142-143
teoria dos jogos, 415
Gartner, 231
Gauss, Carl Friedrich, 10
Algoritmo de multiplicação complexa de Gauss, 413
GCD (Máximo Divisor Comum), 11, 12
Gecko, Gordon (personagem fictício em Wall
Rua), 294 Dispositivos de navegação GPS em operação,
processadores de uso geral, 16 386 com loops, 168 funções de medição de
“Gerar números aleatórios é muito mais difícil gráficos, 151–157 gráficos mistos, 144 de
do que você pensa”, 405 navegação, 202–205 colocando em formato
algoritmos genéticos, 368, 384 numérico, 157–159 representando relações em,
Projeto Genoma, compressão de dados em, 274 112–115 representando o território do robô como,
genômica, 229–230 386 mostrando o lado social de, 146-147
Ghemawat, Sanjay, 253
GitHub, 42, 48–49, 50–51

Índice 421
Machine Translated by Google

gráficos (continuação) funções de hash, 132, 135–137, 235, 240, 241, 264
ordenando elementos gráficos, 168–170 tabelas de hash, 108, 132, 133, 137, 240, 241
atravessando de, 202–205 atravessando hash, 241, 410
de forma eficiente, 162–168 hashing, 132–137, 235
compreendendo o básico de, 141–159 heaps, definidos, 276
compreendendo subgráficos, 147–148 labirintos de hedge, 386–
gráfico não direcionado, 143 gráfico não 387 algoritmos heurísticos, 34–35, 382
ponderado, 171 usando lista para armazenar abordagem heurística, 32–35 roteamento
um gráfico, 159 gráfico rotulado de vértice, heurístico, 384
145 andando em um gráfico aleatoriamente, heurística

204–205 web como exemplo de estrutura de heurística admissível, 396


gráfico, 207 gráfico ponderado, 144–145, 171, considerando, 381–399 criando
172, 173, 182 trabalhando com gráficos não ponderados labirintos, 388–391 definidos,
versus ponderados, 171 351, 381 diferenciando, 382–
384 explicando algoritmos de
Gráfico de amostra do Zachary's Karate Club, 197– busca de caminhos, 388–399 objetivos de, 383 indo
198, 200
de genética para IA, 383–384 indo heuristicamente por
Greatest Common Divisor (GCD), 11, 12
A*, 396–399 procurando a melhor rota rápida, 392–395
ganância, aprendendo que pode ser bom, 30–32
origem do termo, 382 robôs de roteamento usando,
ganancioso, decidindo quando é melhor ser, 292–299
384–388 explorando territórios desconhecidos, 385–
introdução de algoritmos gananciosos, 291–292 abordando
387 usando medidas de distância como, 387–388
a satisfação do cliente, 302 competindo por recursos , 301–
303 decidindo quando é melhor ser ganancioso, 292–299
definido, 24 descobrindo o quão ganancioso pode ser útil,
299–306 como eles funcionam, 294 mantendo-os sob
otimização de escalada, 354–357 HITS
controle, 294–296 cumprindo prazos, 302–303 como
(Pesquisa de tópico induzida por hipertexto), 211
fornecer uma solução particular, mas não uma solução
Hoare, Tony, 124 housekeeping, 252 hubs, 211
ótima, 32 como tomar arestas mais curtas entre as
Huffman, David A., 276 Huffman compressão, 274,
disponíveis entre todos os vértices, 174 entender por que
276–277, 278 Huffman codificação/codificação , 31,
guloso é bom, 293–294 abordagem/método guloso, uso de,
267, 303–306 Hyper Search, 211 hypercomputer, 413
24, 30–32, 174 guloso melhor -primeira busca, 35 troca
hyperlinks, 212 HyperLogLog (algoritmo), 135, 246
gananciosa, 296 raciocínio ganancioso, 31 estratégia
Hypertext-Induced Topic Search (HITS), 211
gananciosa, uso de heaps na realização, 276

EU

IBM, 231

H ícones, explicados, 3–4


Hadoop, 253, 256 matriz de identidade, 67
problema de parada, 412 No Olho do Furacão (Bellman), 309

422 Algoritmos para Leigos


Machine Translated by Google

links de entrada, Grande Colisor de Hádrons, 229, 233


índice 212, definido, Soluções aleatórias de Las Vegas, 333
117 estatísticas inferenciais, 235 telêmetros a laser, 384 nós de folha (de
“Um modelo de fluxo de informações para conflitos e árvore), 110, 277
Fission in Small Groups” (Zachary), 198 Lehmer, Derrick Henry, 12
herança, como técnica OOP, 81 injetando Lempel, Abraão, 278
aleatoriedade, 332 ordenação por inserção, Algoritmo de Lempel-Ziv-Welch (LZW), 267, 278–282
comutação para, 120-121 integração, processo de, Levenshtein, Vladimir, 326
226 Distância Levenshtein (também conhecida como distância de edição), 326

Intel, miniaturização do chip, 227 Algoritmo Lévy-Flight Firefly, 354


Processador Intel i9, 16 Liber Abaci (Fibonacci), 311
Internet
lidar, 384–385 funções lineares,
crescimento no uso de, 230 usando como ferramenta, 368–374 sondagem
busca de dados, 208 linear, como método para lidar com colisões, 135
Internet das Coisas (IoT), 230–231 programação linear (LP), 309, 367–380 tempo
inversores, 360 textos invisíveis, 211 linear, 292, 297 links (da árvore), 109 análises de
problemas, distintos das soluções, 19– links, 407–408
21

LinkedIn, 197
J compreensão de lista, 68
Jarnik, 173
listas, 142, 159 fator de
JPEG, 270, 275
carga, 133 técnica de
Jupyter Notebook, 41, 42–43. Veja também Caderno
melhoria local, 349 busca local

K implementando código Python, 361–364


Multiplicação Karatusba, 413 conhecendo a vizinhança, 351–353
Kerboschin, Joep, 201 atalhos de realizando, 349–366 apresentando truques
teclado, 45–46 preenchimento de de busca local, 353–359 percebendo que o
palavras-chave, 211 Khachiyan, ponto de partida é importante, 365–366
Leonid, 298 algoritmo de Kleene,
resolvendo 2-SAT usando randomização, 360–361
190 Kleinberg, Jon, 211 problema
resolvendo a satisfatibilidade de circuitos booleanos, 359–
da mochila, 310, 317–321 Knuth,
365 compreensão de, 350–353
Donald E., 13, 35 -36
Hashing sensível à localidade (LSH) (algoritmo), 136
lógica, colocando aleatoriedade na sua, 341–348
Kruskal, Joseph, 173
Árvore geradora mínima de Kruskal (MST), 31, 173–
174, 177–180, 293 portas lógicas,
359 loops, gráficos com,
algoritmo de valor kth, 342
168 compressão com perdas, 235, 270, 271–272
Lovelace, Ada, 35
eu Loyd, Sam, 415
função lambda, 256 Algoritmo LZW (Lempel-Ziv-Welch), 267, 278–
Laplace, Pierre-Simon, 92 282

Índice 423
Machine Translated by Google

como técnica para colocar gráfico em formato numérico,


142
Aprendizado de máquina M , 384 matriz de transição, 216, 217
Aprendizado de máquina para leigos, 2ª edição matróides, 295
(Mueller e Massaron), 25 problema
“Matroids for Greedy Algorithms” (vídeo), 295
e solução de mudança de configuração, 292–293 algoritmo
MAX-3SAT, 383
de fabricação de brinde, 12, 14
labirintos, 386–391, 393
Distância de Manhattan, 387, 388, 389, 394, 399
tempo médio entre falhas (MTBF), 255 medições,
mapeamento, perguntando por, 262–265
função de, 24
MapReduce, 253, 255–265
memorização, 308, 311, 313, 314
“MapReduce: Processamento de dados simplificado em grandes
Clusters” (Dean e Ghemawat), 253 merge sort, 122–124
metaheurísticas, 384
mapas, 256–257, 385–386, 388
Marchiori, Massimo, 211 Markham, J. meteorologia, 229
“Um Método para a Construção do Mínimo
David, 28 Martin, Nigel, 246 Massaron,
Códigos de redundância” (Huffman), 276 mid-
Luca, 25, 59 Software Material
square, como técnica para evitar colisões, 134
Requirements Planning (MRP), 299
“Problemas do Prêmio do Milênio” (Clay Mathematics
Instituto), 298

árvore geradora mínima (MST), 170–180. Veja também a árvore


coprocessadores matemáticos,
geradora mínima de Kruskal (MST); Árvore geradora
16, 17 matplotlib, 149, 198 mínima de Prim (MST)
Matriz Reshish, 92 Diversos (guia de configurações no Colab), 45
matrizes Mitchell, Stuart Antony, 375 gráfico
acessando elementos específicos de, 85–86 misto, 144
adicionando gráfico a, 157–158 criando de, 83– Método de Monte Carlo, 339-341, 344-347
84 criando um como o caminho certo para Soluções aleatórias de Monte Carlo, 333
começar, 63–64 definido, 60 definindo operações Moore, Gordon, 226
avançadas de, 65 matriz degenerada, 67 Lei de Moore, 226-228
desenvolvendo classe de computação de matriz, 79– MP3, 270
96 abordagem de produto elementar, 87–88 matriz MPEG, 270, 275
de identidade, 67 inversão de, 67 manipulação de, 90–96 MrJob (Map Reduce Job), 256 MRP
inversão de matriz, 67 multiplicação de, 64–65, 87–90, (Material Requirements Planning), 299 MST
412 realizando cálculos usando, 60–67 realizando adição (minimum spanning tree). Ver mínimo

escalar e de matriz, 86–87 impressão de, 84 matriz


árvore geradora (MST)
singular, 67 matriz esparsa, 213 matriz subestocástica,
Mueller, John Paul, 25, 59, 256
218
unidades de processamento multicore,
251 multithreading, 253

N
Napoleão para Leigos (Markham), 28
Nash, John, 28, 411
Equilíbrio de Nash, 28

424 Algoritmos para Leigos


Machine Translated by Google

algoritmos inspirados na natureza, 354 política de substituição de página ideal, 300


borda negativa, adição de, 182–184 pesos subestrutura ideal, 190 otimizador, 369, 379
negativos, 180–181 ordenada (coordenada y), 39 organograma, como
redes gráfico, 146 links de saída, 212 sobrecarga, como
agrupamento de em grupos, 196–199 aspecto que pode amortecer o paralelismo, 252
descobrindo comunidades em, 199–202
explicando a importância de, 142–148 como tipo
de gráfico que associa nomes a vértices, arestas ou
ambos, 142 redes neurais, 17 para agente em
execução, 18 redes sociais, 196-202
P
preenchimento, 285

Página, Larry, 210–212, 213


RedeX, 149, 150–151, 154, 157, 163, 164, 201
PageRank (algoritmo), 207, 209, 210–222, 232, 250
Neumann, John von, 411 redes
Verificador de PageRank, 213
neurais, como se beneficiando do uso de chips
especializados, 17 “The PageRank Citation Ranking: Bringing Order to the Web” (Brin
e Page), 213
Newton, Isaac, 10, 27 nó
Pandas, 102, 105, 107, 221, 192, 326
(da árvore), 109
Pandas DataFrame, 192
ruído, 26
Papadimitrious, Christos H., 360–361
distribuição normal, 336
paradigmas, 291 computação paralela, 251
Norvig, Pedro, 309
paradigma paralelo, compreensão de, 251–253
Notebook, realizando tarefas comuns em, 51–55 notebooks,
paralelismo, 252 operações de paralelização
trabalhando com, 47–51
Problemas NP-completos, 297-299
teoria NP-completude, 297
gerenciando imensas quantidades de dados, 250–259
Problema NP-difícil, 350, 353 n-
elaborando algoritmos para MapReduce, 259–265 jogo de
rainhas, 355–357, 384
paridade, jogando, 415 valores parciais, como técnica para evitar
NumPy, 61, 64, 79, 80–81
colisões, 134 algoritmos de localização de caminho, 388–399
Porca, 253
planejamento de caminho, 385 pathfinding, 385, 389 caminhos,
385 algoritmos de planejamento de caminhos, 392 análises de
O padrões, 408–409
objetivo (do problema), 368 função
objetivo, 354–355, 376, 379
Programação Orientada a Objetos (OOP), 81 objetos,
aprendendo a contar objetos em um fluxo, 247–248 Perlin, Ken, 26
Ruído Perlin, 26
mapas de grade de ocupação, 385-386, 388 permutações, distinção de dados, 68-69 física, como
oceanografia, 230 campo onde big data tem uma base em, 229
“Sobre a Seleção de uma Atribuição de Verdade Satisfatória” Algoritmo PID (derivada integral proporcional), 25, 409
(Papadimitriou), 361
funções unidirecionais, criação e uso de, 413 operações, Pisano, Leonardo (também conhecido como
distribuição de, 253–255, 258 Fibonacci), 311 pixel, descrito, 270

Índice 425
Machine Translated by Google

Platão, 27 números pseudoaleatórios, 14, 333


Algoritmo de promessa, PuLP, 375, 379

389 plotagem (de gráficos), busca heurística pura, 35


141 polimorfismo, como técnica OOP, 81 tempo PyCrypto, 284
polinomial, 173, 297, 298 números primos, 27 Vitória de Pirro, 30
Python
Árvore geradora mínima de Prim (MST), 31, 173, 175–177, como excelente em plotagem, 141
179, 293
implementando código Python, 361–364
fila de prioridade, 174–175, 185 implementando script de, 213–216 como não
probabilidade, 334–335 profundidade vindo com objeto de árvore embutido, 110 como
do problema, 33 instância do linguagem não ideal para operações paralelas, 259–260
problema, 33 solução do problema,
definido, 23 resolução do problema realizando manipulações de dados essenciais usando,
59–78

evitando soluções de força bruta, 29 quebra como fornecer acesso ao número de estruturas organizacionais
para dados, 99 como fornecer o número de metodologias
de problemas, 30 custos de computação e
de armazenamento, 105 uso de classes em, 81-82 uso de
seguindo heurísticas em,
bibliotecas Python, 79, 80
32–35

distinguir entre soluções possíveis, 78 abordagem de


dividir e conquistar, 23 dividir e conquistar, 28–30 encontrar
Notebook Python 3, criando, 47
soluções e contra-exemplos, 26–27 ir ao acaso e ser
Python para Ciência de Dados para Leigos (Mueller e
abençoado pela sorte, 34 abordagem/método ganancioso Massarón), 59
para, 24, 30– 32 mantendo-o simples, bobo (KISS), 29–30
modelando problemas do mundo real, 25–26 rapidez de
um problema algorítmico, 411 alcançando uma boa solução, Q
31–32 representando o problema como um espaço, 33 filas, 107–108, 165, 174–175, 185, 237 algoritmo de

início de, 24–28 entender o problema primeiro, 24 usando seleção rápida, 337, 341–344, 345, 346 classificação

uma heurística e uma função de custo, 34–35 rápida, 124–126, 347–348

R
Rabin, Michael, 332
Simulações de RAM, 38
espaço do problema,
Máquina de acesso aleatório (RAM), 36
33 gráfico do espaço do
números aleatórios, agitando as coisas com, 405
problema, 33 vértices
amostragem aleatória, 353 passeio aleatório, 353, 362
processados, 162 recomendações de produtos, 221
Projeto Gutenberg, algoritmo
randomização, 332-341, 353, 360-361
260 proporcional integral derivado (PID), 25, 409
algoritmos aleatórios
cálculo da mediana usando seleção rápida, 341–344
Proxima Centauri, 229
considerando por que a randomização é necessária, 333–
pseudocódigo, 21 334
Geradores de números pseudo-aleatórios
definindo como funciona a randomização, 332-341
(PRNGs), 405

426 Algoritmos para Leigos


Machine Translated by Google

fazendo simulações usando Monte Carlo, 344–347 problemas com, 236


ordenando mais rápido com classificação rápida, 347–348 amostragens aleatórias,
colocando aleatoriedade em sua lógica, 341–348 353 amostras de reservatórios,
simulando o uso do método de Monte Carlo, 339–341 236-238 amostras aleatórias
entendendo distribuições, 335–339 entendendo como a simples, 236 satélites, como campo onde big data tem
base, 230
probabilidade funciona, 334– 335
RandomWalkSAT, 361, 362, 365 escalar, 60, 61–62, 86–87

sumidouros de classificação (becos sem saída), 219


escalonamento, como uso para algoritmos, 14

RankBrain, 222 Pacote SciPy, 158

Raspberry Pi, 18 raspagem, 243

dados brutos, 99 SDC (carros autônomos), 385

números realmente grandes, multiplicação de, 413–414 search engine optimization (SEO), 209, 221 motores

problemas do mundo real, modelagem de, 25–26 de busca, 208–210, 220–221

recursão, 71–75, 78, 311–314 algoritmos recursivos, 13, Search for Extraterrestrial Intelligence (SETI), 18.228
rotinas de busca, 404–405 árvores de busca, 127–
40, 310 redução, explicação de redundância 256-257,
132
considerando 162 rehashing, como método para lidar
com colisões, estratégia de substituição 135, amostragem
Search-Engine Journal (SEJ), 221
de 300 reservatórios, 236-238
buscas/busca
considerando a necessidade de pesquisar
efetivamente, 127 realizando pesquisa local, 349–
Recursos
366 realizando pesquisas especializadas usando heap
competindo por, 301-303 binário, 131–132
dividindo um igualmente, 414
como uso para algoritmos, 13
RLE (codificação de comprimento de execução),
Algoritmos de Hash Seguro (SHA), 136
275 robôs
Seki, Takakazu, 211–212 tipo
empregando algoritmos em, 17 de seleção, uso de, 120 cartões
roteamento de uso de heurística, 384-388
autônomos (SDC), 385
Shakey o robô, 381, 396
Solução Selfridge-Conway, 414
Roomba, 332
consultas semânticas, 222
Roy, Bernardo, 190 Sentinela 1A, 230
Criptosistema RSA, 297
SEO (otimização para mecanismos de busca), 209,
Algoritmo MDS da RSA, codificação
221 sequências, lembrando com LZW, 278–282
de comprimento de execução 136 (RLE), 275
SETI (Search for Extraterrestrial Intelligence), 18, 228
Russel, Stuart, 309

Caixa de diálogo Configurações (no Colab), 45

SHA (Algoritmos de Hash Seguro), 136


Shakey o robô, 381, 396
Amostra S , definida, 235
Shannon, Claude, 276, 303
amostragem como
atalho, definido, 185 rota mais
ferramenta algorítmica, 234
curta, achado entre dois pontos, 180–194
definida, 225, 235, 249
limitações de, 240
Prata, Nate, 236

Índice 427
Machine Translated by Google

amostra aleatória simples, algoritmo ponto de partida, percebendo que é importante, 365-366

simplex 236, simplificação 368, 372–373,


média estocástica, 247
aprendendo a simplificar ao planejar, 371–372
armazenamento (de dados), 105, 233–234

recozimento simulado, 354, 357-358 simulação, Strassen, Volker, 332

na implementação PageRank, 212 matriz singular, 67 estratégias, adaptando-se ao problema, 20 elementos de

fluxo, filtragem de cor, 240–243 streaming, definido, 249 dados

Site (guia de configurações no Colab), 44 de streaming, 225, 233–235 streams, aprendendo a contar

esboços, 225, 235, 240–248 objetos, 247–248


Smith, Adam, 28, 294

Análise de Redes Sociais (SNA), 196 redes “The String-to-String Correction Problem” (Wagner e Fischer), 326
estrutura, como elemento essencial para fazer os algoritmos
sociais, visualização de como gráficos, 196-202 sociogramas, 196
funcionarem, 100 subgráficos, 147–148 solução subótima, 383
Solovay, Robert M., 332 soluções, distintas de problemas, 19-21
subestocástico, 218 sumarização (de dados), 234 viés de
matrizes de sonar, 385 rotinas de classificação, uso de, 404
sobrevivência, 25–26 inteligência de enxame, 383–384 interruptores,
classificação empregando melhores técnicas de classificação, 122–126
254
contando com hashing, 132–137 contando com classificação topológica,
169–170 alternando para inserir classificação, 120–121 entendendo

por que a classificação de dados é importante, 118–121

Calculadora Symbolab, 92 tabela

simbólica, 269

como uso para algoritmos, 13


T
usando uma ordenação por seleção, 120
Pesquisa Tabu, 354, 358–359
Compensações de espaço/ tempo na codificação de hash com permissão
chamada de
Erros (Bloom), 240
cauda, 74 tarefas, executando-as mais rapidamente, 75–78
spammers, 210
teletransporte, 219–220
spanning tree, 170–171. Veja também árvore geradora
Teller, Eduardo, 339
mínima (MST)

Spark, 253 Tensor Processing Unit (TPU), 55–56 células de

matrizes esparsas, 213 texto, criação de, 54 threads, 253

representações esparsas, 142, 158–159 questões

espaciais, compreensão de, 415 células especiais, Problemas 3SUM, resolvendo de forma mais eficiente, 411–
412
criação de, 54–55 processadores especiais/
TIROS 1, 230
processadores especializados, 17, 55–56
Toom Cook, 414

chips para fins especiais, 17 mapas topológicos, 385-386, 388

armadilhas de aranha (ciclos), 219 classificação topológica, 169-170

aranhas, definidas, 209 pilhas, transformação, como uso para algoritmos, 13 matriz

como estruturas de dados LIFO, 105–107 de transição, 216, 217

428 Algoritmos para Leigos


Machine Translated by Google

transposição, da matriz, 66, 90, 91 problema velocidade, como uma das quatro principais características de
big data, 231, 232 veracidade, como uma das quatro
do caixeiro viajante (TSP), 293, 310, 321-325, 352, 383
principais características de big data, 231, 232
árvores

árvores equilibradas, 112 gráfico rotulado de vértice, volume

edifícios de, 110-112 145, como uma das quatro características principais de big
data, 231
montes, 112
Árvore de Huffman, 277
Dentro
travessia de, 111
árvores desbalanceadas, Wagner, Robert A., 326 Wald,
112 noções básicas de compreensão, 109– Abraham, 25
110 usando árvores de busca, 127–132 Algoritmo de seguidor de parede, 389
trabalhando com, 109–112 tentativas, 334 Wall Street (filme), 294

Warshall, Stephen, 190


Gerador de Números Aleatórios Verdadeiros (TRNG), 405 Weather Analytics, 229 web,
Verdadeiro RNG, 405 encontrando o mundo em um mecanismo de busca,
208–210
TSP (problema do caixeiro viajante), 293, 310, 321–325,
352, 383 spammers da web, 209, 210–211
Turing, Alan, 412 gráfico ponderado, 144–145, 171, 172, 173, 182
Máquinas de Turing, 298, 412 Welch, Terry, 278 bem
Twitter, 146–147, 233 2- definidos, como requisito para o processo representar
problema de satisfatibilidade (2-SAT), 349, 352, 360– 361, um algoritmo, 11 espaços em branco, uso de na
383 saída, 154
Wilson, Charles Erwin, 309 janelas,

DENTRO
237 assistente, 146

problema da mochila ilimitada, 318 grafos não


WMA, como algoritmo de compressão com perdas, 270
direcionados, 143 vértices não descobertos,
162
X
Codificação Unicode, 269
Unicode Transformation Format 8 (UTF-8), 269 distribuição XPRESS, 375
uniforme, 337 identificadores exclusivos, criação de, 409–

410 gráfico não ponderado, 171 atualizações (para reservar),


A PARTIR DE

4
Zachary, Wayne W., 198
gráfico de amostra do Zachary's Karate Club, 197–198, 200
algoritmo ZIP, 272, 273 Ziv, Jacob, 278
DENTRO

variedade, como uma das quatro principais características de


big data, 231, 232 vetores, realizando cálculos usando,

60-67

Índice 429
Machine Translated by Google
Machine Translated by Google

sobre os autores
John Paul Mueller é um autor freelance e editor técnico. Tem a escrita no sangue, tendo produzido
121 livros e mais de 600 artigos até hoje. Os tópicos variam de rede a inteligência artificial e de
gerenciamento de banco de dados a programação de cabeça para baixo. Alguns de seus livros
atuais incluem discussões sobre ciência de dados, aprendizado de máquina e algoritmos. Ele
também escreve sobre linguagens de computador como C++, C# e Python. Suas habilidades
técnicas de edição ajudaram mais de 70 autores a refinar o conteúdo de seus manuscritos. John
prestou serviços de edição técnica para uma variedade de revistas, realizou vários tipos de
consultoria e escreve exames de certificação. Certifique-se de ler o blog de John em http://blog.

johnmuellerbooks. com/. Você pode entrar em contato com John na Internet em John@JohnMueller
Books. com. John também tem um site em http://www.johnmuellerbooks.com/.

Luca Massaron é cientista de dados e diretor de pesquisa de marketing especializado em análise


estatística multivariada, aprendizado de máquina e percepção do cliente, com mais de uma década
de experiência na solução de problemas do mundo real e na geração de valor para as partes
interessadas, aplicando raciocínio, estatísticas, dados mineração e algoritmos. Desde ser um
pioneiro da análise de audiência da web na Itália até alcançar a classificação dos dez melhores
Kaggler no kaggle.com, ele sempre foi apaixonado por tudo relacionado a dados e análises e por
demonstrar a potencialidade da descoberta de conhecimento orientada por dados para especialistas
e não especialistas . Favorecendo a simplicidade à sofisticação desnecessária, ele acredita que
muito pode ser alcançado em ciência de dados entendendo e praticando seus fundamentos. Luca
também é um Google Developer Expert (GDE) em aprendizado de máquina.

A dedicação de João
Este livro é dedicado a todas as pessoas que me apoiaram nas várias transições da minha vida.

A dedicação de Lucas
Dedico este livro à minha esposa, Yukiko, que sempre acho curiosa e pronta para me maravilhar
com as maravilhas desse mundo tecnológico incrível e desconcertante.
Com amor.
Machine Translated by Google

Agradecimentos de João
Obrigado a minha esposa, Rebeca. Mesmo que ela tenha partido agora, seu espírito está em cada
livro que escrevo e em cada palavra que aparece na página. Ela acreditou em mim quando ninguém
mais acreditaria.

Rod Stephens merece agradecimentos por sua edição técnica deste livro. Ele aumentou muito a
precisão e a profundidade do material que você vê aqui. As habilidades de pensamento crítico de
Rod me forçam a realmente pensar em todos os elementos do livro. Ele também é a verificação de
sanidade do meu trabalho.

Matt Wagner, meu agente, merece crédito por me ajudar a conseguir o contrato em primeiro lugar
e cuidar de todos os detalhes que a maioria dos autores não considera. Eu sempre aprecio sua
ajuda. É bom saber que alguém quer ajudar.

Várias pessoas leram todo ou parte deste livro para me ajudar a refinar a abordagem, testar os
exemplos de codificação e, em geral, fornecer informações que todos os leitores gostariam de ter.
Esses voluntários não remunerados ajudaram de maneiras numerosas demais para serem
mencionadas aqui. Aprecio especialmente os esforços de Eva Beattie, que forneceu informações
gerais, leu o livro inteiro e se dedicou abnegadamente a este projeto.

Finalmente, gostaria de agradecer a Kelsey Baird, Susan Christophersen, Michelle Hacker e o


restante da equipe editorial e de produção.

Agradecimentos de Lucas
Meu primeiro grande agradecimento à minha família, Yukiko e Amelia, por seu apoio, sacrifícios
e paciência amorosa durante os longos dias/noites, semanas e meses em que trabalhei neste livro.

Agradeço a toda a equipe editorial e de produção da Wiley, em particular Kelsey Baird e Susan
Christophersen, por seu grande profissionalismo e apoio em todas as fases da escrita deste livro
da série For Dummies .

Agradecimentos do editor
Editor sênior de aquisições: Kelsey Baird Editor de Produção: Mohammed Zafar Ali

Editor de projeto e cópia: Susan Christophersen Imagem da capa: © Kobol75/Shutterstock


Editor Técnico: Rod Stephens
Machine Translated by Google

Leve manequins com você


aonde quer que você vá!
Se você está empolgado com os e-books, quer mais da
web, precisa ter seus aplicativos para dispositivos móveis ou é
varrido pelas mídias sociais, os dummies tornam tudo mais fácil.

Encontre-nos on-line!

dummies.com
Machine Translated by Google

Aproveite o poder
Dummies é líder global na categoria de referência e uma das
marcas mais confiáveis e conceituadas do mundo. Não mais
focados apenas em livros, os clientes agora têm acesso ao
conteúdo fictício de que precisam no formato que desejam.
Juntos, criaremos uma solução que envolva seus clientes, se
destaque da concorrência e ajude você a atingir suas metas.

Publicidade e patrocínios

Conecte-se com um público engajado em um poderoso site multimídia,


e posicione sua mensagem ao lado de conteúdo de instruções especializado.
Dummies.com é um balcão único para informações on-line gratuitas
e know-how com curadoria de uma equipe de especialistas.

• Anúncios segmentados • Microsites


• Vídeo • Patrocínio de
• E-mail Marketing sorteios

20 MILHÃO
VISUALIZAÇÕES DE PÁGINA

TODOS OS MESES
MILHÃO
ÚNICO
15
VISITANTES POR MÊS

43% 700.000 BOLETIM DE NOTÍCIAS


ASSINATURAS
PARA AS CAIXAS DE ENTRADA DE
DE A LLVI SIDORES
ACESSE O SITE 300.000 ÚNICOS
INDIVÍDUOS
TODA SEMANA
ATRAVÉS DE SEUS DISPOSITIVOS MÓVEIS
Machine Translated by Google

de manequins
Publicação personalizada

você dos concorrentes, amplificar sua mensagem e incentivar os clientes a tomar uma decisão de compra.

• Aplicativos • e-books • Áudio


• Livros • Vídeo • Webinars

Licenciamento e conteúdo da marca

Aproveite a força da marca de referência mais popular do mundo para alcançar novos públicos e canais
de distribuição.

Para mais informações, visite dummies.com/biz


Machine Translated by Google

ENRIQUECIMENTO PESSOAL

9781119187790 EUA 9781119179030 EUA 9781119293354 EUA 9781119293347 EUA 9781119310068 EUA 9781119235606 EUA
$ 26,00 CAN $ $ 21,99 CAN $ $ 24,99 CAN $ $ 22,99 CAN $ $ 22,99 CAN $ $ 24,99 CAN $
31,99 Reino Unido 25,99 Reino Unido 29,99 Reino Unido 27,99 Reino Unido 27,99 Reino Unido 29,99 Reino Unido
£ 19,99 £ 16,99 £ 17,99 £ 16,99 £ 16,99 £ 17,99

9781119251163 EUA 9781119235491 9781119279952 EUA 9781119283133 EUA 9781119287117 EUA 9781119130246 EUA
$ 24,99 CAN $ EUA $ 26,99 $ 24,99 CAN $ $ 24,99 CAN $ $ 24,99 CAN $ $ 22,99 CAN $
29,99 Reino Unido CAN $ 31,99 29,99 Reino Unido 29,99 Reino Unido 29,99 Reino Unido 27,99 Reino Unido
£ 17,99 Reino Unido £ 19,99 £ 17,99 £ 17,99 £ 16,99 £ 16,99

DESENVOLVIMENTO PROFISSIONAL

9781119311041 9781119255796 EUA 9781119293439 EUA 9781119281467 EUA 9781119280651 9781119251132 EUA 9781119310563 EUA
EUA $ 24,99 $ 39,99 CAN $ $ 26,99 CAN $ $ 26,99 CAN $ EUA $ 29,99 $ 24,99 CAN $ $ 34,00 CAN $
CAN $ 29,99 47,99 Reino Unido 31,99 Reino Unido 31,99 Reino Unido CAN $ 35,99 29,99 Reino Unido 41,99 Reino Unido
Reino Unido £ 17,99 £ 27,99 £ 19,99 £ 19,99 Reino Unido £ 21,99 £ 17,99 £ 24,99

9781119181705 EUA 9781119263593 EUA 9781119257769 EUA 9781119293477 EUA 9781119265313 EUA 9781119239314 EUA 9781119293323 EUA
$ 29,99 CAN $ $ 26,99 CAN $ $ 29,99 CAN $ $ 26,99 CAN $ $ 24,99 CAN $ $ 29,99 CAN $ $ 29,99 CAN $
35,99 Reino Unido 31,99 Reino Unido 35,99 Reino Unido 31,99 Reino Unido 29,99 Reino Unido 35,99 Reino Unido 35,99 Reino Unido
£ 21,99 £ 19,99 £ 21,99 £ 19,99 £ 17,99 £ 21,99 £ 21,99

dummies.com
Machine Translated by Google

CONTRATO DE LICENÇA DE USUÁRIO FINAL WILEY

Acesse www.wiley.com/go/eula para acessar o ebook EULA da Wiley.

Você também pode gostar