Escolar Documentos
Profissional Documentos
Cultura Documentos
Algoritmos
2ª edição
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.
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.
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 É
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
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.
Conteúdo em resumo
Introdução ........................................................ 1
CAPÍTULO 21: Dez algoritmos que estão mudando o mundo .................... 403
Í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
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
Índice v
Machine Translated by Google
.........................................
CAPÍTULO 6: Estruturando Dados 99
Índice vii
Machine Translated by Google
........................ 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
Índice ix
Machine Translated by Google
Índice xi
Machine Translated by Google
.... 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
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.
» 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
» 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.
» 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.
» 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.
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.
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.
» 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.
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 . . .
NESTE CAPÍTULO
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 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.
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.
• 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.
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.)
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:
» 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.
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.
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.
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.
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.
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.
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
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.
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
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.
P(B|E) = P(E|B)*P(B)/P(E)
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.
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.
NESTE CAPÍTULO
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.
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.
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
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.
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.
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
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.
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
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.
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.
» 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.
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/
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
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.
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.
» 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ó.
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/).
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:
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.
» Tempo de execução
» Consumo de energia
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.
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.
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.
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
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 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 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.
Capítulo 3
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.
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.
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.
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
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.
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.
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:
» 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
» Texto
» Cabeçalho da seção
• Lista suspensa
• Entrada
• 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.
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.
» 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.
» 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):
• 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.
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.
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.
FIGURA 3-10:
Use a GUI para
facilitar a
formatação do seu texto.
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.
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 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.
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:
» 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.
NESTE CAPÍTULO
» 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.
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.
» 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ê
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).
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)
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:
print(np.logical_or(a, b))
print(np.logical_and(a,b))
print(np.logical_not(a))
print(np.logical_xor(a,b))
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.
[[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.
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)
[[ 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.
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))
[[22 28 34]
[49 64 79]]
Para realizar uma multiplicação elemento por elemento usando dois objetos de matriz ,
você deve usar a função numpy multiplicar() .
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)
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.
changeIt = np.transpose(changeIt)
print(alterar)
[[1 2 3 4]
[5 6 7 8]]
[[1 5]
[2 6]
[3 7]
[4 8]]
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))
[[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.
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.
a = np.array([1,2,3])
para p em permutações(a):
imprimir(p)
(1, 2, 3)
(1, 3, 2)
(2, 1, 3)
(2, 3, 1)
(3, 1, 2)
(3, 2, 1)
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:
a = np.array([1,2,3,4])
(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
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 = []
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)
[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.
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.
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:
print(fatorial(5))
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.
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.
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))
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.
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
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:
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.
se médio == 0:
print("Chave não encontrada!")
chave de retorno
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:
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.
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.
NESTE CAPÍTULO
capítulo 5
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
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.
» 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.
» 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.
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.
» Encapsulamento
» Herança
» Polimorfismo
» 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.
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.
Matriz de classe:
linhas = 0
colunas = 0
matriz = []
matrizLinha = []
dataCount = 0
listamatriz = []
tempProduto = 0
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 .
minhaMatriz = Matriz(2, 3)
print(minhaMatriz.linhas)
print(minhaMatriz.colunas)
print(minhaMatriz.matriz)
2
3
[[Nenhum, Nenhum, Nenhum], [Nenhum, Nenhum, Nenhum]]
z = lista(intervalo(6))
imprimir(z)
minhaMatriz2 = Matriz(2, 3, z)
print(minhaMatriz2.matriz)
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.
print(minhaMatriz2[1])
print(minhaMatriz2[1][2])
[3, 4, 5]
5
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.
minhaMatriz2 += 2
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 .)
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 .
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:
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:
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:
1 2 1
Uma matriz, Vendas, tem o número de libras de cada item vendido a cada dia, conforme mostrado
aqui:
Maçãs 5 3 4 3 2
Cerejas 2 3 3 4 4
Peras 1 2 4 2 3
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.
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.
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:
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:
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)
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.
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)
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.
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.
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)
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]]
[[ 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
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:
if len(self.matrix) == 2:
twoOut = self.matrix[0][0] * self.matrix[1][1] - \ self.matrix[1][0] *
self.matrix[0][1]
retornar doisOut
linhas = list(range(len(self.matrix)))
submatriz.matriz = submatriz.matriz[1:]
retornar resultado
O código tem duas partes. O primeiro é o caso simples da matriz 2-x-2 que segue o processo
descrito anteriormente.
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 :
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.
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:
2 Compreensão
a necessidade de classificar
e Pesquisar
Machine Translated by Google
NESTA PARTE . . .
Dados de hash
Machine Translated by Google
NESTE CAPÍTULO
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.
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.
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.
» 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.
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.
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]})
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
df = pd.DataFrame({'A': [0,0,1,Nenhum],
'B': [1,2,3,4],
'C': [np.NAN,3,4,1]},
dtype=int)
print(df, "\n")
df = df.fillna(valores)
imprimir(df)
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.
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.
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
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.")
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:
2
3
A pilha está cheia!
Estalando: 3
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)
MinhaFila.put(1)
MinhaFila.put(2)
MyQueue.put(3)
print("Fila cheia: ", MyQueue.full())
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.
» 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.
print(Cores["Sarah"])
print(Cores.chaves())
Cores["Sarah"] = "Roxo"
Colors.update({"Harry": "Laranja"})
del Colors["Sam"]
imprimir(Cores)
Amarelo
dict_keys(['Sarah', 'Amy', 'Sam'])
Sarah gosta da cor amarela.
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.
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.
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
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
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)
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.
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.
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.
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:
se início == fim:
print("Fim")
caminho de retorno
para nó no gráfico[início]:
print("Verificando Nó ", nó)
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
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.
NESTE CAPÍTULO
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.
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.
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.
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:
» 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.
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.)
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]
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
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
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:
minIndex = scanIndex
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]
def mergeSort(lista):
# Determina se a lista está dividida em
# peças individuais.
se len(lista) < 2:
lista de retorno
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:
# já ordenado. se não
len(esquerda):
voltar à esquerda
se não len(direita):
retorna certo
leftIndex+= 1
senão:
result.append(right[rightIndex]) rightIndex+=
1
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:
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]
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).
modificadas da classificação rápida podem ter um tempo de classificação de pior caso de O(n2) quando um desses eventos
ocorre:
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 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:
rIndex = direito
enquanto Verdadeiro:
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:
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)
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.
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.
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.
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.
» 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:
» Construir um heap binário requer tempo O(n) , em contraste com BST, que requer
O(n log n) tempo.
» 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).
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.)
árvore = BinaryTree(dados)
tree.update({6:'Teal'})
tree.foreach(displayKeyValue)
print('Item 3 contém: ', tree.get(3))
print('O item máximo é: ', tree.max_item())
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.
importar heapq
pilha = []
para chave, valor em data.items():
heapq.heappush(heap, (chave, valor))
heapq.heappush(heap, (6, 'Teal'))
heap.sort()
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
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.
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.
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.)
print(tabela_hash)
[105, 31, Nenhum, Nenhum, Nenhum, 5, 6, 22, 23, Nenhum, 40, Nenhum,
102, Nenhum, Nenhum]
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.
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:
» 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.
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:
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.
Aqui está algum código para testar a função hash_f() com sua saída associada:
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.
3 Explorando o
mundo dos gráficos
Machine Translated by Google
NESTA PARTE . . .
NESTE CAPÍTULO
Capítulo 8
Entendendo o gráfico
Fundamentos
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).
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.
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.
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.
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
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.
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.
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.
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://
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
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/
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.
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:
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.)
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)]
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()
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.
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.
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))
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))
[{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
[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 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.
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.)
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)
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:
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)
FIGURA 8-6:
Traçar o gráfico
pode ajudá-lo a ver
a centralidade de
grau com maior facilidade.
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)
{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.
nx.betweenness_centrality(AGraph)
{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.
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.
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.)
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.to_numpy_matrix(AGraph)
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.
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))
(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.
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):
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.
NESTE CAPÍTULO
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.
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.
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.
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 = nx.Gráfico()
para nó no gráfico:
Graph.add_nodes_from(node)
para aresta no gráfico[nó]:
Graph.add_edge(node,edge)
FIGURA 9-1:
Representando o
gráfico de exemplo
do NetworkX.
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
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.
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.
enquanto pilha:
A pilha é: ['A']
Processando A
Adicionando B à pilha
Adicionando C à pilha
A pilha é: ['B', 'C']
Processando C
Adicionando D à pilha
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
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.
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.
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.
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.
À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:
• 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.
» 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.
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
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.
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
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.
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.
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)
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.
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.
» 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
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.
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)
Ao traduzir essas etapas em código Python, você pode testar o algoritmo no gráfico
ponderado de exemplo usando o seguinte código:
print(represent_tree(treepath))
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:
def kruskal(gráfico):
prioridade = priority_queue()
print("Enviando todas as arestas para a fila de prioridade")
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])
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.
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
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.
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.
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).
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}
plt.show()
FIGURA 9-3:
O gráfico de
exemplo torna-
se ponderado
e direcionado.
FIGURA 9-4:
Arestas negativas
são adicionadas ao
gráfico de exemplo.
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')
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.
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.
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
ultimo = inicio
enquanto durar != fim:
ultimo = atual_node
conhecido.adicionar(nó_real)
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:
Você também pode saber a distância mais curta para cada nó encontrado consultando o dicionário
dist :
imprimir(dist)
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.
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).
{'A': 0,0, 'B': 2,0, 'C': 3,0, 'D': 4,0, 'E': 5,0, 'F': 6,0} caminho: ['A', 'C', 'E ', 'F']
O código a seguir testa o algoritmo usando um gráfico contendo uma aresta negativa:
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']
Finalmente, você pode testar o algoritmo usando um gráfico que tenha um ciclo negativo.
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.
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,
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).
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):
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.
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.)
Você também pode inspecionar a matriz de distância do gráfico com a aresta negativa:
print_mat(floyd_warshall(ngraph))
[[' ', '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)
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.
NESTE CAPÍTULO
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 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.
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.
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:
» 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.
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
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.)
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.)
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
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])
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.
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).
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.
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')
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:
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:
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)
path_list = []
para caminho em caminhos:
path_list.append(caminho)
print("Caminho do Candidato: ", caminho)
sel_path = random.randint(0, len(path_list) - 1)
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.
NESTE CAPÍTULO
» Implementando o algoritmo
PageRank com teletransporte
Capítulo 11
Obtendo o direito
página da Internet
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).
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.
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.
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).
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.
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).
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:
» 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
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.
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:
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.)
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.
Graph_A = nx.DiGraph()
Graph_B = nx.DiGraph()
Graph_C = nx.DiGraph()
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.
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()
FIGURA 11-3:
Uma rede com uma
armadilha de
aranha nos nós 4, 5 e 6.
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
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 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:
(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
(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.
imprimir(p)
print(np.dot(G, p))
return np.round(p,3)
print(PageRank_naive(Graph_A))
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
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.
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)
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}
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.
» Recomendação de produto: sugerir produtos que uma pessoa com certa afinidade possa gostar
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.
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.
4 Disputa
Big Data
Machine Translated by Google
NESTA PARTE . . .
NESTE CAPÍTULO
» Aproveitamento de amostragem,
hashing e esboços para dados de fluxo
Capítulo 12
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.)
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.
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/. )
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.
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:
» 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).
» 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
» 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
» 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
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.
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.
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.
» Resumido: Alguns dados estão resumidos porque manter tudo como está não faz sentido;
apenas as informações importantes são mantidas.
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.
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.
» 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.
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
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.
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
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.
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
['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).
imprimir(amostra)
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.
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.
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.
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.
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á
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.
FIGURA 12-6:
Localizar um
elemento e
determinar que ele
existe significa
procurar 0s no vetor
de bits.
funções_hash = 3
bit_vetor_comprimento = 10
bit_vector = [0] * bit_vector_length
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
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)
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 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')
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.
FIGURA 12-7:
Testando
a associação de
um site usando
um filtro Bloom.
k = (m/n)*ln(2)
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.
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:
FIGURA 12-8:
Contando apenas
zeros à esquerda.
» (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)
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:
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.
NESTE CAPÍTULO
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.
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.
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.
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.
Propriedade associativa
2 + (3 + 4) = (2 + 3) + 4
Propriedade comutativa
2+3+4=4+3+2
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.
» 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.
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.
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.
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
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:
» 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.
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).
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:
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 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.
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):
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):
se len(seq)==1:
return seq[0]
senão:
return fun(reducer(fun, seq[:-1]), seq[-1])
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.
FIGURA 13-5:
Uma visão
geral da
computação
MapReduce completa.
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])
GUERRA E PAZ
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))
A saída mostra que há muitas palavras no texto (os dedos de Tolstoy devem estar muito cansados):
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
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])
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 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]))
n = cpu_count()
print('Você tem %i núcleos disponíveis para MapReduce' % n)
Nesse caso, a saída mostra quatro núcleos (sua saída pode mostrar um número diferente de
núcleos):
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])
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' %
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):
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):
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):
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”:
url = 'https://github.com/lmassaron/datasets/releases/'
url += 'baixar/1.0/1661-0.txt'
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)
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 .
NESTE CAPÍTULO
» Compactando e descompactando
usando o algoritmo Lempel-Ziv-Welch
(LZW)
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).
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.
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
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"]))
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.
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
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.
History_of_Lossless_Data_Compression_Algorithms.)
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.
É 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.)
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'")))
redução para o texto aleatório é muito menor (ambos os textos têm o mesmo tamanho
original).
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.
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.
print(' '.join(['{0:08b}'.format(ord(l)))
para l em "ACTG"]))
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.
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:
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:
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:
» 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).
» 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
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).
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
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)
>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
Aqui está uma breve sinopse do que essas mensagens de saída significam:
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.
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 é
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]
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 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
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!
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
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:
['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']
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:
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
importar base64, re
de Crypto.Cipher importar AES
da importação de criptografia Aleatório
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.
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.
5 Desafiador
Difícil
Problemas
Machine Translated by Google
NESTA PARTE . . .
NESTE CAPÍTULO
Capítulo 15
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.
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.
» 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.
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:
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.
é dividir seu problema em fases e determinar qual regra gulosa aplicar em cada etapa.
Ou seja, você faz o seguinte:
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:
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:
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.
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.
FIGURA 15-1:
Os conjuntos de
problemas P, NP,
NP-
completos e NP-difíceis.
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.)
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:
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.
» 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.
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.
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).
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).
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).
FIGURA 15-2:
De uma árvore
equilibrada
(esquerda) a uma
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).
UMA 40,5% 00 0
C 29,2% 01 10
G 14,5% 10 110
T 15,8% 11 111
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.)
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():
parent = (index - 1) // 2 if
self.heap[parent][1] < self.heap[index][1]:
self.swap(pai, índice) índice = pai
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.
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.
NESTE CAPÍTULO
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
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.
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
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.
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.
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:
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.
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.)
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)
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:
) + 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)
13
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.
@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()
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.
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.
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.
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 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.
» 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.
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:
(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
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.)
memorando = dict()
para tamanho no intervalo (0, capacidade+1, 1):
memorando[(-1, tamanho)] = ([], 0)
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):
Você pode estar curioso para saber o que aconteceu dentro do dicionário de memorização:
print(len(memo))
147
print(memorando[2, 10])
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() :
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).
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
problema, mas você verá uma abordagem de força bruta usada no problema do caixeiro viajante
que se segue.
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.)
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):
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.
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')
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:
print(perm(13, 13) / 2)
3113510400.0
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
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.
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:
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 k no subconjunto:
se k != 0 e k!=final:
comprimento = memo[(subconjunto-{final},k)][0 ] +
D[k][final] índice =
memo[(subconjunto-{final},k)][1 ] + [final]
distância += D[0][caminho[-1]]
tours.append((distância, caminho + [0]))
# Agora podemos declarar o passeio mais curto
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.)
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)
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:
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.
» 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 trás à esquerda indica que a inserção de uma nova letra deve ser
feito na primeira corda.
FIGURA 16-3:
Destacando quais
transformações
são aplicadas.
NESTE CAPÍTULO
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).
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
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.
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.
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.
» 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.
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.
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ê 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
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.
semente(0)
distribuição_normal = [gauss(mu=25, sigma=100)
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.
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.
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]
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:
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.
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')
FIGURA 17-3:
Exibindo os
resultados
de uma
simulação de Monte Carlo.
» Faz com que os algoritmos funcionem melhor e forneçam soluções mais inteligentes.
» 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.
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.)
sys.setrecursionlimit(1500)
n = 501
semente(0)
series = [randint(1,25) for i in range(n)]
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}')
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.
(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:
return quickselect(direita, k)
quickselect(série, 250)
12,0
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
contador += len(série)
comprimento_esquerda = len(esquerda)
se comprimento_esquerda > k:
duplicatas = series.count(pivô)
se duplicatas > k:
return float(pivô), contador
k -= duplicatas
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)
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.
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.
FIGURA 17-4:
Exibindo os
resultados
de uma
simulação de
Monte Carlo em seleção rápida.
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.
FIGURA 17-5:
Exibindo simulações de
Monte Carlo à medida
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ô)
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:
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ô.
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.
NESTE CAPÍTULO
Capítulo 18
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.)
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.
» 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.
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).
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).
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
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.
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.
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
» 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.)
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.
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
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.)
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.
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.
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.
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:
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.
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:
• 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).
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
» Visitar uma cidade mais cedo ou mais tarde no passeio, deixando a ordem de visita para as demais
cidades iguais
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.
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.
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
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:
(b) Escolha uma cláusula insatisfeita ao acaso. Escolha uma das condições nele em
aleatório e alterá-lo.
importar aleatório
do log de importação de matemática2
def assinado(v):
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 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
replicações externas garantem uma fuga de escolhas aleatórias de loop interno infelizes que podem
interromper o processo em uma solução local.
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)
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.
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:
solução = create_random_solution(n)
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)
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.
NESTE CAPÍTULO
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.
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.
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.
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:
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
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.
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.
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
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.
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
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.
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:
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
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
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.
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.
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).
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
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):
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.)
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')
borda = np.array((c1,c2,c3)).min(axis=0)
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:
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:
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:
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]}")
Tendo recebido a confirmação de que o otimizador encontrou a solução ideal, você imprime as
quantidades relacionadas do produto A e B:
Além disso, você imprime o lucro total resultante obtido por esta solução.
NESTE CAPÍTULO
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
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
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.
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.
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
» 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.
» 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.
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)
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/ .
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.
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
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.
FIGURA 20-1:
A e B são
pontos nas
coordenadas de um mapa.
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).
|(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.
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.
seqüência de importação
def não_informativo(a,b):
retornar 0
def ravel(listasdelistas):
return [item para elem em listas de listas para item em elem]
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.
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:
para nó em Graph.nodes:
para sair em node_neighbors(Graph, node):
comprimento =
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()
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.
FIGURA 20-2:
Um labirinto
representando um
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.
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.
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).
gol = 'Y'
pontuação = manhattan_dist
FIGURA 20-3:
Um labirinto intrincado
a ser resolvido por
heurística.
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="")
para nó em vizinhos:
se o nó não estiver na lista_fechada:
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.
» 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*
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]
se_ser_visitado:
print ("descoberto %s" % to_be_visited)
senão:
print ("voltando à lista aberta")
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).
NESTA PARTE . . .
respostas
NESTE CAPÍTULO
Capítulo 21
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.
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 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.
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.
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.
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:
» 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
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.
À 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.
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.
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
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.
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.
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.
NESTE CAPÍTULO
» Considerando a viabilidade de
hipercomputadores
Capítulo 22
Dez Algorítmicos
Problemas ainda por resolver
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.
À 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.
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:
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.
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.
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.
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.
» multiplicação de Karatsuba
» Toom Cook
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.
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.
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
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ÿÿ ).
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.
Í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
força bruta, 29, 34, 121 Transformada de Burrows-Wheeler, no Colab), 45 colisões, 133, 134–135, 248
compressão, 270–273
técnicas de compressão, 267, 270 cache de
computador, 299–300 computadores
DAGs (Gráficos Acíclicos Dirigidos), 168, 169 fator transformação poder em, 226–233 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
Í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)
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
F
Facebook, 196, 233, 250, 407
fatorial, 72
Fano, Robert M., 276, 303
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,
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
EU
IBM, 231
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
Índice 423
Machine Translated by Google
N
Napoleão para Leigos (Markham), 28
Nash, John, 28, 411
Equilíbrio de Nash, 28
Índice 425
Machine Translated by Google
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
início de, 24–28 entender o problema primeiro, 24 usando seleção rápida, 337, 341–344, 345, 346 classificação
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
números realmente grandes, multiplicação de, 413–414 search engine optimization (SEO), 209, 221 motores
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
Índice 427
Machine Translated by Google
amostra aleatória simples, algoritmo ponto de partida, percebendo que é importante, 365-366
Site (guia de configurações no Colab), 44 de streaming, 225, 233–235 streams, aprendendo a contar
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
simbólica, 269
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
aranhas, definidas, 209 pilhas, transformação, como uso para algoritmos, 13 matriz
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
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
DENTRO
237 assistente, 146
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
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/.
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.
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
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
20 MILHÃO
VISUALIZAÇÕES DE PÁGINA
TODOS OS MESES
MILHÃO
ÚNICO
15
VISITANTES POR MÊS
de manequins
Publicação personalizada
você dos concorrentes, amplificar sua mensagem e incentivar os clientes a tomar uma decisão de compra.
Aproveite a força da marca de referência mais popular do mundo para alcançar novos públicos e canais
de distribuição.
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