Você está na página 1de 303

David Kopec

Novatec
São Paulo | 2019
Original English language edition published by Manning Publications Co, Copyright © 2019 by
Manning Publications. Portuguese-language edition for Brazil copyright © 2019 by Novatec
Editora. All rights reserved.
Edição original em Inglês publicada pela Manning Publications Co, Copyright © 2019 pela
Manning Publications. Edição em Português para o Brasil copyright © 2019 pela Novatec Editora.
Todos os direitos reservados.
Todos os direitos reservados e protegidos pela Lei 9.610 de 19/02/1998. É proibida a reprodução
desta obra, mesmo parcial, por qualquer processo, sem prévia autorização, por escrito, do autor e
da Editora.
Editor: Rubens Prates
Tradução: Lúcia A. Kinoshita
Revisão gramatical: Tássia Carvalho
Editoração eletrônica: Carolina Kuwabata
ISBN: 978-85-7522-806-7
Histórico de edições impressas:
Setembro/2019 Primeira edição
Novatec Editora Ltda.
Rua Luís Antônio dos Santos 110
02460-000 – São Paulo, SP – Brasil
Tel.: +55 11 2959-6529
E-mail: novatec@novatec.com.br
Site: www.novatec.com.br
Twitter: twitter.com/novateceditora
Facebook: facebook.com/novatec
LinkedIn: linkedin.com/in/novatec
Dedicado à minha avó Erminia Antos, professora e aprendiz por toda a
vida.
Sumário

Agradecimentos
Sobre o autor
Sobre a ilustração da capa
Introdução
Capítulo 1 ■ Problemas pequenos
1.1 Sequência de Fibonacci
1.1.1 Uma primeira tentativa com recursão
1.1.2 Utilizando casos de base
1.1.3 Memoização para nos salvar
1.1.4 Memoização automática
1.1.5 Fibonacci simples
1.1.6 Gerando números de Fibonacci com um gerador
1.2 Compactação trivial
1.3 Criptogra a inquebrável
1.3.1 Deixando os dados em ordem
1.3.2 Criptografando e descriptografando
1.4 Calculando pi
1.5 Torres de Hanói
1.5.1 Modelando as torres
1.5.2 Solucionando as Torres de Hanói
1.6 Aplicações no mundo real
1.7 Exercícios
Capítulo 2 ■ Problemas de busca
2.1 Busca em DNA
2.1.1 Armazenando um DNA
2.1.2 Busca linear
2.1.3 Busca binária
2.1.4 Um exemplo genérico
2.2 Resolução de labirintos
2.2.1 Gerando um labirinto aleatório
2.2.2 Miscelânea de minúcias sobre labirintos
2.2.3 Busca em profundidade
2.2.4 Busca em largura
2.2.5 Busca A*
2.3 Missionários e canibais
2.3.1 Representando o problema
2.3.2 Solução
2.4 Aplicações no mundo real
2.5 Exercícios
Capítulo 3 ■ Problemas de satisfação de restrições
3.1 Construindo um framework para problemas de satisfação de
restrições
3.2 Problema de coloração do mapa da Austrália
3.3 Problema das oito rainhas
3.4 Caça-palavras
3.5 SEND+MORE=MONEY
3.6 Layout de placa de circuitos
3.7 Aplicações no mundo real
3.8 Exercícios
Capítulo 4 ■ Problemas de grafos
4.1 Mapa como um grafo
4.2 Construindo um framework de grafos
4.2.1 Trabalhando com Edge e Graph
4.3 Encontrando o caminho mínimo
4.3.1 Retomando a busca em largura (BFS)
4.4 Minimizando o custo de construção da rede
4.4.1 Trabalhando com pesos
4.4.2 Encontrando a árvore geradora mínima
4.5 Encontrando caminhos mínimos em um grafo com peso
4.5.1 Algoritmo de Dijkstra
4.6 Aplicações no mundo real
4.7 Exercícios
Capítulo 5 ■ Algoritmos genéticos
5.1 Background em biologia
5.2 Algoritmo genético genérico
5.3 Teste simples
5.4 Revendo SEND+MORE=MONEY
5.5 Otimizando a compactação de listas
5.6 Desa os para os algoritmos genéticos
5.7 Aplicações no mundo real
5.8 Exercícios
Capítulo 6 ■ Clustering k-means
6.1 Informações preliminares
6.2 Algoritmo de clustering k-means
6.3 Clustering de governadores por idade e longitude
6.4 Clustering de álbuns do Michael Jackson por tamanho
6.5 Problemas e extensões do clustering k-means
6.6 Aplicações no mundo real
6.7 Exercícios
Capítulo 7 ■ Redes neurais relativamente simples
7.1 Base biológica?
7.2 Redes neurais arti ciais
7.2.1 Neurônios
7.2.2 Camadas
7.2.3 Retropropagação
7.2.4 Visão geral
7.3 Informações preliminares
7.3.1 Produto escalar
7.3.2 Função de ativação
7.4 Construindo a rede
7.4.1 Implementando os neurônios
7.4.2 Implementando as camadas
7.4.3 Implementando a rede
7.5 Problemas de classi cação
7.5.1 Normalizando dados
7.5.2 Conjunto clássico de dados de amostras de íris
7.5.3 Classi cando vinhos
7.6 Agilizando as redes neurais
7.7 Problemas e extensões das redes neurais
7.8 Aplicações no mundo real
7.9 Exercícios
Capítulo 8 ■ Busca competitiva
8.1 Componentes básicos de jogos de tabuleiro
8.2 Jogo da velha
8.2.1 Administrando os estados do jogo da velha
8.2.2 Minimax
8.2.3 Testando o minimax com o jogo da velha
8.2.4 Desenvolvendo uma IA para o jogo da velha
8.3 Connect Four
8.3.1 Peças do jogo Connect Four
8.3.2 Uma IA para o Connect Four
8.3.3 Aperfeiçoando o minimax com a poda alfa-beta
8.4 Melhorias no minimax além da poda alfa-beta
8.5 Aplicações no mundo real
8.6 Exercícios
Capítulo 9 ■ Problemas diversos
9.1 Problema da mochila
9.2 Problema do Caixeiro-Viajante
9.2.1 Abordagem ingênua
9.2.2 Avançando para o próximo nível
9.3 Dados mnemônicos para números de telefone
9.4 Aplicações no mundo real
9.5 Exercícios
Apêndice A ■ Glossário
Apêndice B ■ Outros recursos
B.1 Python
B.2 Algoritmos e estruturas de dados
B.3 Inteligência arti cial
B.4 Programação funcional
B.5 Projetos de código aberto convenientes para aprendizado de
máquina
Apêndice C ■ Introdução rápida às dicas de tipo
C.1 O que são dicas de tipo?
C.2 Como é a aparência das dicas de tipo?
C.3 Por que as dicas de tipo são úteis?
C.4 Quais são as desvantagens das dicas de tipo?
C.5 Obtendo mais informações
Agradecimentos

Obrigado a todos da Manning que me ajudaram na produção deste livro:


Cheryl Weisman, Deirdre Hiam, Katie Tennant, Dottie Marsico, Janet
Vail, Barbara Mirecki, Aleksandar Dragosavljević, Mary Piergies e Marija
Tudor.
Agradeço ao editor de aquisições Brian Sawyer que, inteligentemente,
nos levou a atacar Python, depois que eu havia acabado de trabalhar com
o Swift. Obrigado à editora de desenvolvimento Jennifer Stout por ter
sempre mantido uma atitude positiva. Agradeço à editora técnica Frances
Buontempo, que analisou cuidadosamente cada um dos capítulos e deu
um feedback detalhado e útil a cada mudança de página. Agradeço ao
revisor de texto Andy Carroll, cuja esplêndida atenção aos detalhes, tanto
no livro do Swift como neste, identi caram vários de meus erros, e a Juan
Rufes, meu revisor técnico.
As seguintes pessoas também revisaram o livro: Al Krinker, Al Pezewski,
Alan Bogusiewicz, Brian Canada, Craig Henderson, Daniel Kenney-Jung,
Edmond Sesay, Ewa Baranowska, Gary Barnhart, Geo Clark, James
Watson, Je rey Lim, Jens Christian, Bredahl Madsen, Juan Jimenez, Juan
Rufes, Matt Lemke, Mayur Patil, Michael Bright, Roberto Casadei, Sam
Zaydel, Thorsten Weber, Tom Je ries e Will Lopez. Obrigado a todos os
que ofereceram críticas construtivas e especí cas durante o
desenvolvimento do livro. Seus feedbacks foram incorporados.
Agradeço à minha família e aos amigos e colegas que me incentivaram a
assumir o projeto deste livro logo após a publicação de Classic Computer
Science Problems in Swift. Agradeço aos meus amigos virtuais no Twitter e
em outros lugares, os quais me ofereceram palavras de incentivo e
ajudaram a promover o livro de todas as formas possíveis. Além disso,
agradeço à minha esposa Rebecca Kopec e à minha mãe Sylvia Kopec, que
sempre me apoiam em meus projetos.
Desenvolvemos este livro em um período razoavelmente curto. A maior
parte do manuscrito foi redigida durante o verão de 2018, com base na
versão anterior para Swift. Aprecio o fato de a Manning ter se disposto a
condensar o seu processo (em geral muito mais longo) a m de permitir
que eu trabalhasse de acordo com uma agenda que me fosse conveniente.
Sei que isso foi motivo de pressão para a equipe toda durante as três
rodadas de revisões em vários níveis diferentes, com várias pessoas
diferentes, em poucos meses. A maioria dos leitores caria impressionada
com a quantidade de diferentes revisões às quais um livro técnico é
submetido em uma editora tradicional, e com a quantidade de pessoas
que participam das críticas e revisões. Do revisor técnico ao revisor
gramatical ou editorial, todos os revisores o ciais e os que estão entre
eles, muito obrigado!
Por m, e acima de tudo, agradeço aos meus leitores por comprarem
este livro. Em um mundo cheio de tutoriais online monótonos, acho
importante dar apoio ao desenvolvimento de livros que deem voz ao
mesmo autor ao longo de um volume extenso. Tutoriais online podem ser
recursos magní cos; contudo, sua compra possibilita que livros
completos, analisados e cuidadosamente desenvolvidos ainda tenham
espaço no ensino da ciência da computação.
Sobre o autor

David Kopec é professor-assistente de Ciência da Computação &


Inovação no Champlain College em Burlington, Vermont. É um
desenvolvedor de software experiente e autor de Classic Computer Science
Problems in Swift (Manning, 2018) e Dart for Absolute Beginners (Apress,
2014). Tem graduação em economia e mestrado em ciência da
computação, ambos pelo Dartmouth College. É possível encontrar David
no Twitter como @davekopec.
Sobre a ilustração da capa

A imagem na capa de Problemas Clássicos de Ciência da Computação com


Python tem como legenda “Habit of a Bonza or Priest in China” (Hábito
de uma bonza ou sacerdotisa na China). A ilustração foi extraída da obra
A Collection of the Dresses of Di erent Nations, Ancient and Modern de
Thomas Je erys (quatro volumes), publicada em Londres entre 1757 e
1772. A página do título informa que são gravuras em chapas de cobre
com relevos em goma arábica.
Thomas Je erys (1719-1771), conhecido como “Geógrafo do Rei George
III”, era um cartógrafo inglês, líder no fornecimento de mapas em sua
época. Ele gravava e estampava mapas para o governo e outras entidades
o ciais e produzia diversos mapas e atlas comerciais, em especial, da
América do Norte. Seu trabalho como produtor de mapas despertou
interesse acerca dos hábitos associados a vestimentas locais, das terras
por ele pesquisadas e mapeadas, apresentados de forma brilhante em sua
coleção. A fascinação por terras distantes e viagens com vistas à recreação
era um fenômeno relativamente novo no nal do século XVIII, e coleções
como essa eram populares e apresentavam os habitantes de outros países
tanto para os turistas como para aqueles que viajavam com os livros sem
sair de casa.
A diversidade dos desenhos nos volumes da obra de Je erys mostra de
forma vívida a unicidade e a individualidade das nações do mundo há
cerca de duzentos anos. Desde então, os códigos de vestimenta mudaram,
e a diversidade por região e por país, tão rica naquela época, foi
desaparecendo aos poucos. Atualmente, de modo geral, é difícil
diferenciar os habitantes de um continente de outro. Tentando encarar de
forma otimista, talvez tenhamos trocado uma diversidade cultural e visual
por uma vida pessoal mais variada – ou por uma vida intelectual e técnica
mais diversi cada e interessante.
Em uma época em que é difícil diferenciar um livro de informática de
outro, a Manning celebra a inventividade e a iniciativa na área de
computação com capas de livros que se baseiam na rica diversidade dos
hábitos regionais de dois séculos atrás, trazidos de volta à vida pelas
imagens de Je reys.
Introdução

Obrigado pela compra de Problemas Clássicos de Ciência da Computação


com Python. Python é uma das linguagens de programação mais
conhecidas no mundo, e pessoas com experiências anteriores bem
variadas se tornam programadoras de Python. Algumas têm educação
formal em ciência da computação; outras aprendem Python como um
hobby; outras ainda usam Python em um ambiente pro ssional, mas seu
trabalho principal não é como desenvolvedor de software. Os problemas
neste livro de nível intermediário ajudarão programadores experientes a
refrescar a memória com ideias advindas dos cursos de ciência da
computação, ao mesmo tempo que lhes permitirão conhecer alguns
recursos avançados da linguagem. Programadores autodidatas acelerarão
sua educação em ciência da computação ao conhecer problemas clássicos
na linguagem de sua escolha: Python. Este livro inclui uma variedade
muito grande de técnicas de resolução de problemas, a ponto de
realmente haver algo que todos aproveitem.
Este livro não é uma introdução a Python. Há inúmeros livros excelentes
da Manning e de outras editoras nessa linha.1 Este livro, no entanto,
pressupõe que você já seja um programador Python de nível
intermediário ou avançado. Embora o livro exija Python 3.7, um domínio
de todos os aspectos da versão mais recente de Python não é exigido.
Com efeito, o conteúdo do livro foi escrito partindo-se do pressuposto de
que serviria como material de aprendizagem para ajudar os leitores a
alcançar esse domínio. Por outro lado, este livro não é apropriado para
leitores a quem Python seja totalmente uma novidade.

Por que Python?


Python é usado com objetivos muito diversos, por exemplo, na área de
ciência de dados, produção de lmes, educação em ciência da
computação, gerenciamento de TI e muito mais. Não há, realmente,
nenhuma área da computação com a qual Python não tenha entrado em
contato (exceto, talvez, a área de desenvolvimento de kernels). Python é
amado pela exibilidade, pela sintaxe bonita e sucinta, pela pureza na
orientação a objetos e pela comunidade vibrante. Uma comunidade forte
é importante porque implica que Python é acessível aos iniciantes e tem
um ecossistema grande de bibliotecas disponíveis para que os
desenvolvedores usem como base.
Pelos motivos já citados, Python às vezes é considerada uma linguagem
receptiva para quem está começando, e essa caracterização provavelmente
é verdadeira. A maioria das pessoas concordaria que é mais fácil aprender
Python do que C++, por exemplo, e que sua comunidade é mais afável
para com os iniciantes. Como resultado, muitas pessoas aprendem
Python porque é uma linguagem acessível, e começam a escrever os
programas que querem de modo razoavelmente rápido. Contudo, essas
pessoas talvez jamais venham a ter uma educação em ciência da
computação por meio da qual aprendam todas as técnicas e cazes
disponíveis de resolução de problemas. Se você é um desses
programadores que conhece Python, mas não conhece ciência da
computação, este livro foi escrito para você.
Outras pessoas aprendem Python como segunda, terceira, quarta ou
quinta linguagem, depois de muito tempo trabalhando com
desenvolvimento de software. Para elas, ver velhos problemas já
conhecidos de outra linguagem as ajudará a acelerar o aprendizado com
Python. Para essas pessoas, este livro pode ser muito bom a m de
refrescar a memória antes de uma entrevista de emprego ou para expô-las
a algumas técnicas de resolução de problemas as quais não haviam
pensado em explorar antes em seus trabalhos. Eu as incentivaria a passar
os olhos pelo índice para ver se há assuntos neste livro que as
empolguem.

O que é um problema clássico de ciência da computação?


Alguns dizem que os computadores estão para a ciência da computação
assim como os telescópios estão para a astronomia. Se for verdade, talvez
uma linguagem de programação seja então como a lente de um
telescópio. Qualquer que seja o caso, a expressão “problemas clássicos de
ciência da computação” neste livro signi ca “problemas de programação
tipicamente ensinados no currículo de um curso de graduação em ciência
da computação”.
Há certos problemas de programação que são apresentados aos novos
programadores para que sejam resolvidos e que se tornaram comuns a
ponto de serem considerados como clássicos – seja em um ambiente de
sala de aula durante uma graduação (em ciência da computação,
engenharia de software e em cursos semelhantes), seja no meio de livros
sobre programação de nível intermediário (por exemplo, um livro inicial
sobre inteligência arti cial ou algoritmos). Um conjunto selecionado
desses problemas é o que você encontrará neste livro.
Os problemas variam dos triviais, que podem ser solucionados com
algumas linhas de código, aos complexos, que exigem a construção de
sistemas ao longo de vários capítulos. Alguns problemas resvalam para o
lado da inteligência arti cial, enquanto outros apenas exigem bom senso.
Alguns problemas são práticos, enquanto outros são lúdicos.

Quais são os tipos de problemas incluídos neste livro?


O Capítulo 1 apresenta técnicas de resolução de problemas com as quais,
provavelmente, a maioria dos leitores terá familiaridade. Técnicas como
recursão, memoização (memoization) e manipulação de bits são blocos
de construção essenciais para outras técnicas exploradas em capítulos
subsequentes.
Essa introdução suave é seguida do Capítulo 2, que tem como foco os
problemas de pesquisa. A pesquisa é um assunto tão amplo que você
poderia, sem dúvida, colocar a maior parte dos problemas deste livro sob
a sua alçada. O Capítulo 2 apresenta os algoritmos básicos de pesquisa,
incluindo busca binária (binary search), busca em profundidade (depth-
rst search), busca em largura (breadth- rst search) e A*. Esses
algoritmos serão reutilizados no resto do livro.
No Capítulo 3, construiremos um framework para solucionar uma
in nidade de problemas que podem ser de nidos de modo abstrato por
variáveis com domínios limitados, que têm restrições entre si. Esses
problemas incluem clássicos como o problema das oito rainhas, o
problema de colorir o mapa da Austrália e o problema de criptoaritmética
SEND+MORE=MONEY.
O Capítulo 4 explora o mundo dos algoritmos de grafos que, para os
não iniciados, são surpreendentemente amplos quanto a sua
aplicabilidade. Nesse capítulo, você construirá uma estrutura de dados
para grafos e então usará essa estrutura para resolver vários problemas
clássicos de otimização.
O Capítulo 5 explora os algoritmos genéticos: uma técnica menos
determinística que a maioria das técnicas abordadas no livro, mas que,
ocasionalmente, possibilita resolver problemas que algoritmos
tradicionais não resolvem em um intervalo de tempo razoável.
O Capítulo 6 descreve o clustering (agrupamento) k-means, e talvez seja
o capítulo mais especí co do livro no que concerne aos algoritmos. Essa
técnica de clustering é simples de implementar, fácil de entender e
amplamente aplicável.
O Capítulo 7 visa explicar o que é uma rede neural e dar ao leitor uma
amostra da aparência de uma rede neural muito simples. O objetivo não é
fazer uma abordagem completa dessa área empolgante e em evolução.
Nesse capítulo, construiremos uma rede neural usando princípios
básicos, sem bibliotecas externas, para que você de fato veja como uma
rede neural funciona.
O Capítulo 8 trata da busca competitiva (adversarial search) em jogos
de informação perfeita (perfect information games) para dois jogadores.
Você verá um algoritmo de busca conhecido como minimax, que pode
ser usado para desenvolver um adversário arti cial, capaz de participar de
jogos como xadrez, damas e Connect Four2.
Por m, o Capítulo 9 aborda problemas interessantes (e divertidos) que
não se enquadram muito bem em outros capítulos do livro.
A quem este livro se destina?
Este livro foi escrito para programadores de nível intermediário e
programadores experientes. Programadores experientes que queiram
aprofundar seus conhecimentos em Python encontrarão problemas com
os quais terão bastante familiaridade advinda de seus estudos em ciência
da computação ou programação. Programadores de nível intermediário
serão apresentados a esses problemas clássicos na linguagem escolhida
por eles: Python. Desenvolvedores que estejam se preparando para
entrevistas provavelmente considerarão o livro como um material valioso
para preparação.
Além dos programadores pro ssionais, é bem provável que os alunos
matriculados em cursos de graduação em ciência da computação com
interesse em Python achem este livro útil. A obra não faz nenhuma
tentativa de ser uma introdução rigorosa às estruturas de dados e
algoritmos. Este não é um livro didático sobre estruturas de dados e
algoritmos. Você não encontrará provas nem um uso intensivo da notação
big-O nestas páginas. Em vez disso, o livro se apresenta como um tutorial
acessível e prático para as técnicas de resolução de problemas, que
deveriam ser o resultado das aulas de estrutura de dados, algoritmos e
inteligência arti cial.
Mais uma vez, partimos do pressuposto de que a sintaxe e a semântica
de Python são conhecidas. Um leitor sem nenhuma experiência com
programação aproveitará bem pouco este livro, e é quase certo que um
programador sem nenhuma experiência com Python tenha di culdades.
Em outras palavras, Problemas Clássicos de Ciência da Computação com
Python é um livro para programadores Python pro ssionais e para alunos
de ciência da computação.

Versões de Python, repositório de códigos-fontes e dicas de tipo


Os códigos-fontes que estão neste livro foram escritos de modo a serem
compatíveis com a versão 3.7 da linguagem Python. Eles utilizam recursos
de Python que se tornaram disponíveis somente na versão 3.7, portanto,
parte do código não executará em versões mais antigas. Em vez de lutar
com o código e tentar fazer com que os exemplos executem em uma
versão anterior, faça o download da versão mais recente de Python antes
de começar a ler o livro.
Este livro utiliza apenas a biblioteca-padrão de Python (com uma
pequena exceção no Capítulo 2, no qual o módulo typing_extensions é
instalado); desse modo, todos os códigos que estão no livro devem
executar em qualquer plataforma que aceite Python (macOS, Windows,
GNU/Linux e assim por diante). O código do livro foi testado somente
com CPython (o principal interpretador Python disponibilizado por
python.org), embora seja provável que a maior parte dele execute em
outro interpretador Python com versão compatível com Python 3.7.
Este livro não explica como usar ferramentas Python como editores,
IDEs, depuradores e o REPL de Python. Os códigos-fontes do livro estão
disponíveis online no repositório do GitHub:
https://github.com/davecom/ClassicComputerScienceProblemsInPython. Os
códigos estão organizados em pastas por capítulo. À medida que ler cada
capítulo, você verá o nome de um arquivo-fonte no cabeçalho de cada
listagem de código. Esse arquivo-fonte poderá ser encontrado em sua
respectiva pasta no repositório. Você deverá ser capaz de executar o
código para o problema digitando python3 nomedoarquivo.py ou python
nomedoarquivo.py , conforme a con guração de seu computador no que diz
respeito ao nome do interpretador de Python 3.
Todas as listagens de código neste livro fazem uso das dicas de tipo
(type hints) de Python, também conhecidas como anotações de tipo
(type annotations). Essas anotações são um recurso relativamente novo
na linguagem Python e podem parecer intimidadoras para um
programador Python que não as tenha visto antes. Elas são usadas por
três motivos:
1. Proporcionam clareza quanto aos tipos das variáveis, aos parâmetros
de função e aos valores de retorno das funções.
2. De certo modo, promovem uma documentação para o código, como
consequência do motivo listado em 1. Em vez de ter de procurar um
comentário ou uma docstring para descobrir o tipo devolvido por
uma função, você pode apenas olhar para a sua assinatura.
3. Permitem que seja feita uma veri cação de tipos no código para ver se
está correto. Uma ferramenta de veri cação de tipos conhecida para
Python é o mypy.
Nem todos são fãs das dicas de tipo e, honestamente, optar por usá-las
em todo o livro foi uma aposta. Espero que elas ajudem, em vez de
di cultar. É um pouco mais demorado para escrever um código Python
com dicas de tipo, mas elas proporcionam mais clareza quando
retornamos depois para ler esse código. Uma observação interessante é
que as dicas de tipo não têm nenhum efeito na execução propriamente
dita do código no interpretador Python. Você pode remover as dicas de
tipo de qualquer código deste livro, e esse código deverá continuar
funcionando. Se você ainda não conhecia as dicas de tipo e acha que
precisa de uma introdução mais completa antes de mergulhar de cabeça
no livro, consulte o Apêndice C, que contém um curso rápido sobre elas.

Sem saída grá ca ou código de UI: apenas a biblioteca-padrão


Não há nenhum exemplo neste livro que gere uma saída grá ca ou que
faça uso de uma GUI (Graphical User Interface, ou Interface Grá ca de
Usuário). Por quê? O objetivo é resolver os problemas propostos com
soluções que sejam tão concisas e legíveis quanto possível. Muitas vezes,
gerar saídas grá cas atrapalha ou deixa as soluções signi cativamente
mais complexas do que o necessário para demonstrar a técnica ou o
algoritmo em questão.
Além do mais, por não fazer uso de nenhum framework para GUI, todo
o código do livro é extremamente portável. Ele pode ser facilmente
executado em uma distribuição dedicada de Python executando no
Linux, assim como em um desktop com Windows. Além disso, uma
decisão consciente foi feita quanto a usar pacotes somente da biblioteca-
padrão de Python, em vez de usar alguma biblioteca externa, como faz a
maioria dos livros sobre Python. Por quê? O objetivo é ensinar as técnicas
de resolução de problemas começando do básico – não é efetuar um “pip
install de uma solução”. Ao ter de trabalhar com todos os problemas a
partir do zero, esperamos que você compreenda como as bibliotecas
conhecidas funcionam internamente. No mínimo, usar apenas a
biblioteca-padrão deixa o código deste livro mais portável e mais fácil de
ser executado.
Isso não equivale a dizer que soluções com saídas grá cas não sejam
ocasionalmente mais ilustrativas de um algoritmo do que soluções
baseadas em texto. Esse apenas não é o foco deste livro, pois outra
camada de complexidade desnecessária seria acrescentada.

Parte de uma série


Este é o segundo livro de uma série intitulada Problemas Clássicos de
Ciência da Computação (Classic Computer Science Problems) publicada
pela Manning. O primeiro livro foi Classic Computer Science Problems in
Swift, publicado em 2018. Em cada livro da série, nosso objetivo é
apresentar insights especí cos sobre uma linguagem, ao mesmo tempo
em que ensinamos através das lentes dos mesmos problemas (na maioria
das vezes) de ciência da computação.
Se você gostar deste livro e planeja conhecer outra linguagem abordada
na série, talvez ache que passar de um livro para outro seja um modo fácil
de aperfeiçoar o seu domínio sobre essa linguagem. Por enquanto, a série
inclui apenas Swift e Python. Escrevi pessoalmente os dois primeiros
livros porque tenho experiência signi cativa com essas duas linguagens,
mas já estamos discutindo planos para futuros livros da série em
coautoria com pessoas que são especialistas em outras linguagens.
Incentivo você a procurá-los, caso goste deste livro. Para obter outras
informações sobre a série, acesse https://classicproblems.com/.

1 Se você acabou de iniciar sua jornada com Python, talvez queira dar uma olhada no livro The
Quick Python Book, 3ª edição, de Naomi Ceder (Manning, 2018) antes de começar a ler este
livro.
2 N.T: Jogo tradicionalmente composto de um tabuleiro vertical contendo seis linhas e sete
colunas, para dois jogadores. Os jogadores se alternam para inserir discos coloridos na parte
superior de cada coluna, os quais ocuparão a primeira posição livre nessa coluna. O vencedor
será aquele que conseguir formar primeiro uma sequência vertical, horizontal ou diagonal de
quatro peças com a sua cor.
CAPÍTULO 1

Problemas pequenos

Para começar, exploraremos alguns problemas simples que, para serem


resolvidos, não precisam de nada além de algumas funções relativamente
pequenas. Apesar de serem pequenos, esses problemas nos permitirão
explorar algumas técnicas interessantes de resolução de problemas. Pense
neles como um bom aquecimento.

1.1 Sequência de Fibonacci


A sequência de Fibonacci é uma sequência de números tal que qualquer
número, exceto o primeiro e o segundo, é a soma dos dois números
anteriores:
0, 1, 1, 2, 3, 5, 8, 13, 21...
O valor do primeiro número de Fibonacci na sequência é 0. O valor do
quarto número de Fibonacci é 2. Segue-se daí que, para obter o valor de
qualquer número de Fibonacci n na sequência, a seguinte fórmula pode
ser usada:
fib(n) = fib(n - 1) + fib(n - 2)

1.1.1 Uma primeira tentativa com recursão


A fórmula anterior para calcular um número da sequência de Fibonacci
(mostrado na Figura 1.1) é uma espécie de pseudocódigo que pode ser
facilmente traduzida em uma função Python recursiva. (Uma função
recursiva é uma função que chama a si mesma.) Essa tradução mecânica
servirá como nossa primeira tentativa de escrever uma função que
devolva um dado valor da sequência de Fibonacci.
Figura 1.1 – A altura de cada homem-palito é a soma das alturas dos dois
homens-palito anteriores.
Listagem 1.1 – b1.py
def fib1(n: int) -> int:
return fib1(n - 1) + fib1(n - 2)

Vamos tentar executar essa função chamando-a com um valor.


Listagem 1.2 – Continuação de b1.py
if __name__ == "__main__":
print(fib1(5))
Ah, não! Se tentarmos executar b1.py, um erro será gerado:
RecursionError: maximum recursion depth exceeded

O problema é o fato de fib1() executar inde nidamente, sem devolver um


resultado de nitivo. Toda chamada a fib1() resultará em outras duas
chamadas para fib1(), sem que haja um nal à vista. Chamamos uma
situação como essa de recursão in nita (veja a Figura 1.2), e ela é análoga
a um loop in nito.
Figura 1.2 – A função recursiva fib(n) chama a si mesma com os
argumentos n-2 e n-1.

1.1.2 Utilizando casos de base


Observe que até executar fib1() não havia nenhuma informação de seu
ambiente Python dizendo que houvesse algo de errado com ela. Evitar
uma recursão in nita é responsabilidade do programador, e não do
compilador ou do interpretador. A recursão in nita se dá porque não
especi camos um caso de base. Em uma função recursiva, um caso de
base serve como ponto de parada.
No exemplo da função de Fibonacci, temos casos de base naturais na
forma dos dois primeiros valores especiais da sequência, 0 e 1. Nem 0
nem 1 são a soma dos dois números anteriores da sequência. Esses são os
dois primeiros valores especiais. Vamos tentar especi cá-los como casos
de base.
Listagem 1.3 – b2.py
def fib2(n: int) -> int:
if n < 2: # caso de base
return n
return fib2(n - 2) + fib2(n - 1) # caso recursivo
NOTA A versão fib2() da função de Fibonacci devolve 0 como o número da posição zero
(fib2(0) ), em vez de ser o primeiro número, como em nossa proposição original. Em um
contexto de programação, até faz sentido, pois estamos acostumados com sequências que
começam com o elemento na posição zero.
fib2()pode ser chamada com sucesso e devolverá resultados corretos.
Experimente chamá-la com alguns valores baixos.
Listagem 1.4 – Continuação de b2.py
if __name__ == "__main__":
print(fib2(5))
print(fib2(10))
Não tente chamar fib2(50). A execução jamais terminará! Por quê? Toda
chamada a fib2() resulta em outras duas chamadas de fib2() por causa
das chamadas recursivas fib2(n - 1) e fib2(n - 2) (veja a Figura 1.3).

Figura 1.3 – Toda chamada para fib2() que não seja um caso de base resulta
em outras duas chamadas de fib2().
Em outras palavras, a árvore de chamadas cresce exponencialmente. Por
exemplo, uma chamada a fib2(4) resulta no seguinte conjunto total de
chamadas:
fib2(4) -> fib2(3), fib2(2)
fib2(3) -> fib2(2), fib2(1)
fib2(2) -> fib2(1), fib2(0)
fib2(2) -> fib2(1), fib2(0)
fib2(1) -> 1
fib2(1) -> 1
fib2(1) -> 1
fib2(0) -> 0
fib2(0) -> 0

Se você contabilizar as chamadas (e, como veremos, se adicionar algumas


instruções para exibição), verá que há 9 chamadas para fib2() somente
para calcular o elemento de número 4! A situação piora. São 15 chamadas
necessárias para calcular o elemento de número 5, 177 chamadas para
calcular o elemento de número 10 e 21.891 chamadas para calcular o
elemento de número 20. Podemos fazer algo melhor que isso.

1.1.3 Memoização para nos salvar


A memoização (memoization) é a técnica segundo a qual armazenamos os
resultados de tarefas computacionais quando estas são concluídas, de
modo que, quando precisarmos novamente desses resultados, será
possível consultá-los, em vez de ter de calculá-los uma segunda vez (ou
pela milionésima vez) – veja a Figura 1.4.1

Figura 1.4 – A máquina de memoização humana.


Vamos criar outra versão da função de Fibonacci que utiliza um
dicionário Python para memoização.
Listagem 1.5 – b3.py
from typing import Dict
memo: Dict[int, int] = {0: 0, 1: 1} # nossos casos de base

def fib3(n: int) -> int:


if n not in memo:
memo[n] = fib3(n - 1) + fib3(n - 2) # memoização
return memo[n]
Agora você pode chamar fib3(50) de forma segura.
Listagem 1.6 – Continuação de b3.py
if __name__ == "__main__":
print(fib3(5))
print(fib3(50))

Uma chamada a fib3(20) resultará em apenas 39 chamadas para fib3(),


em oposição às 21.891 chamadas a fib2() resultantes da chamada a
fib2(20) . memo é preenchida previamente com os primeiros casos de base,
isto é, para 0 e 1, evitando a complexidade de outra instrução if em
fib3() .

1.1.4 Memoização automática


fib3() pode ser simpli cada mais ainda. Python tem um decorador
embutido para memoizar qualquer função “automagicamente”. Em fib4(),
o decorador @functools.lru_cache() é usado exatamente com o mesmo
código de fib2(). Sempre que fib4() for executada com um novo
argumento, o decorador fará com que o valor de retorno seja armazenado
em cache. Em futuras chamadas de fib4() com o mesmo argumento, o
valor de retorno anterior de fib4() para esse argumento será recuperado
do cache e devolvido.
Listagem 1.7 – b4.py
from functools import lru_cache

@lru_cache(maxsize=None)
def fib4(n: int) -> int: # mesma definição de fib2()
if n < 2: # caso de base
return n
return fib4(n - 2) + fib4(n - 1) # caso recursivo
if __name__ == "__main__":
print(fib4(5))
print(fib4(50))

Observe que podemos calcular fib4(50) instantaneamente, mesmo que o


corpo da função de Fibonacci seja igual ao corpo de fib2(). A
propriedade maxsize de @lru_cache indica quantas das chamadas mais
recentes da função que ela está decorando devem ser armazenadas em
cache. De ni-la com None signi ca que não há um limite.

1.1.5 Fibonacci simples


Há uma opção com um desempenho melhor ainda. Podemos solucionar
a sequência de Fibonacci usando uma abordagem iterativa tradicional.
Listagem 1.8 – b5.py
def fib5(n: int) -> int:
if n == 0: return n # caso especial
last: int = 0 # inicialmente definido para fib(0)
next: int = 1 # inicialmente definido para fib(1)
for _ in range(1, n):
last, next = next, last + next
return next

if __name__ == "__main__":
print(fib5(5))
print(fib5(50))
AVISO O corpo do laço for em fib5() utiliza desempacotamento de tuplas de uma
maneira talvez um pouco exageradamente inteligente. Algumas pessoas podem achar que a
legibilidade está sendo sacri cada em favor da concisão. Outras poderão achar que a própria
concisão deixa o código mais legível. O truque está no fato de last ser de nido com o valor
anterior de next , e next ser de nido com o valor anterior de last somado ao valor anterior
de next . Isso evita a criação de uma variável temporária para armazenar o valor antigo de
next depois que last é atualizada, mas antes de next ser atualizada. Usar
desempacotamento de tuplas dessa forma para fazer algum tipo de troca (swap) de variáveis
é comum em Python.
Com essa abordagem, o corpo do laço for executará um máximo de n-1
vezes. Em outras palavras, essa é a versão mais e ciente até agora.
Compare as 19 execuções do corpo do laço for com as 21.891 chamadas
recursivas de fib2() para o vigésimo número de Fibonacci. Isso poderia
fazer uma diferença enorme em uma aplicação do mundo real!
Nas soluções recursivas, trabalhamos no sentido inverso. Nessa solução
iterativa, trabalhamos seguindo em frente. Às vezes, a recursão é o modo
mais intuitivo para resolver um problema. Por exemplo, a parte principal
de fib1() e de fib2() é, basicamente, uma tradução mecânica da fórmula
de Fibonacci original. No entanto, soluções recursivas ingênuas também
podem ter custos signi cativos de desempenho. Lembre-se de que
qualquer problema que possa ser resolvido recursivamente também pode
ser solucionado de forma iterativa.

1.1.6 Gerando números de Fibonacci com um gerador


Até agora, escrevemos funções que geram um único valor da sequência
de Fibonacci. E se, em vez disso, quiséssemos gerar a sequência
completa, até certo valor? É fácil converter fib5() em um gerador Python
usando a instrução yield. Nas iterações do gerador, cada iteração gerará
um valor da sequência de Fibonacci usando uma instrução yield.
Listagem 1.9 – b6.py
from typing import Generator

def fib6(n: int) -> Generator[int, None, None]:


yield 0 # caso especial
if n > 0: yield 1 # caso especial
last: int = 0 # inicialmente definido para fib(0)
next: int = 1 # inicialmente definido para fib(1)
for _ in range(1, n):
last, next = next, last + next
yield next # passo principal da geração

if __name__ == "__main__":
for i in fib6(50):
print(i)

Se você executar b6.py, verá 51 números da sequência de Fibonacci


exibidos. Para cada iteração do laço for i in fib6(50):, fib6() executará
até uma instrução yield. Se o nal da função for alcançado e não houver
mais instruções yield, o laço terminará a iteração.
1.2 Compactação trivial
Economizar espaço (virtual ou real) muitas vezes é importante. Usar
menos espaço é mais e ciente, e é possível economizar dinheiro. Se você
estivesse alugando um apartamento que fosse maior do que o necessário
para suas coisas e a sua família, seria possível fazer um “downsize” para
um lugar menor, mais barato. Se você paga por byte para armazenar seus
dados em um servidor, talvez queira compactá-los para que a
armazenagem tenha um custo menor. A compactação é o ato de tomar os
dados e codi cá-los (modi car o seu formato) de modo que ocupem
menos espaço. A descompactação é o processo inverso, que faz com que
os dados retornem ao seu formato original.
Se compactar os dados é mais e caz para a armazenagem, por que não
se compactam todos os dados? Há uma relação de custo-benefício entre
tempo e espaço. Compactar uma porção de dados e descompactá-los de
volta para o formato original exige tempo. Desse modo, a compactação de
dados somente fará sentido em situações em que um tamanho menor
tenha mais prioridade em relação a uma execução rápida. Pense em
arquivos grandes sendo transmitidos pela internet. Compactá-los faz
sentido, pois demorará mais para transferir os arquivos do que para
descompactá-los depois que forem recebidos. Além do mais, o tempo
gasto para compactar os arquivos e armazená-los no servidor original terá
de ser considerado apenas uma vez.
Os ganhos mais simples com a compactação de dados surgem quando
você percebe que os tipos de dados armazenados usam mais bits do que
são estritamente necessários para o seu conteúdo. Por exemplo, pensando
no baixo nível, se um inteiro sem sinal que jamais excederia 65.535 é
armazenado como um inteiro de 64 bits sem sinal na memória, ele está
sendo armazenado de modo ine ciente. Esse dado poderia ser
armazenado como um inteiro de 16 bits sem sinal. Isso reduziria o
consumo de espaço do número propriamente dito em 75% (16 bits, em
vez de 64 bits). Se milhões desses números forem armazenados de modo
ine ciente, o espaço desperdiçado poderá totalizar megabytes.
Em Python, às vezes, por questões de simplicidade (o que, sem dúvida,
é um objetivo legítimo), o desenvolvedor é protegido para não pensar em
bits. Não há nenhum tipo inteiro de 64 bits sem sinal, e não há nenhum
tipo inteiro de 16 bits sem sinal. Há apenas um único tipo int que pode
armazenar números com precisão arbitrária. A função sys.getsizeof()
pode ajudar você a descobrir quantos bytes de memória seus objetos
Python estão consumindo. Contudo, em razão do overhead inerente do
sistema de objetos de Python, não há nenhuma maneira de criar um int
que ocupe menos de 28 bytes (224 bits) em Python 3.7. Um único int
pode ser estendido, um bit de cada vez (como faremos no próximo
exemplo), porém consome um mínimo de 28 bytes.
NOTA Se você estiver um pouco enferrujado no que diz respeito aos binários, lembre-se de
que um bit corresponde a um valor único que pode ser 1 ou 0. Uma sequência de 1s e 0s é
lida em base 2 para representar um número. Nesta seção, não será necessário fazer nenhuma
operação matemática em base 2, mas você deve compreender que o número de bits que um
tipo armazena determina quantos valores diferentes podem ser representados. Por exemplo, 1
bit é capaz de representar 2 valores (0 ou 1), 2 bits podem representar 4 valores (00, 01, 10,
11), 3 bits podem representar 8 valores e assim por diante.
Se o número de possíveis valores diferentes que um tipo deve representar
for menor que o número de valores que os bits usados para armazená-lo
podem representar, é provável que ele seja armazenado de modo mais
e ciente. Considere os nucleotídeos que formam um gene no DNA.2
Cada nucleotídeo pode assumir apenas um entre quatro valores: A, C, G
ou T. (Veremos mais sobre isso no Capítulo 2.) No entanto, se o gene for
armazenado como uma str, que pode ser imaginada como uma coleção
de caracteres Unicode, cada nucleotídeo será representado por um
caractere, o qual, em geral, exige 8 bits para armazenagem. Em binário,
apenas 2 bits são necessários para armazenar um tipo com quatro valores
possíveis: 00, 01, 10 e 11 são os quatro valores diferentes que podem ser
representados por 2 bits. Se atribuirmos o valor 00 a A, 01 a C, 10 a G e
11 a T, a área de armazenagem necessária para uma string de nucleotídeos
poderá ser reduzida em 75% (de 8 bits para 2 bits por nucleotídeo).
Em vez de armazenar nossos nucleotídeos como uma str, eles poderão
ser armazenados como uma cadeia de bits (veja a Figura 1.5). Uma cadeia
de bits é exatamente o que parece ser: uma sequência de 1s e 0s de
tamanho arbitrário. Infelizmente, a biblioteca-padrão de Python não
contém nenhuma construção pronta para trabalhar com cadeias de bits
de tamanho arbitrário. O código a seguir converte uma str composta de
As, Cs, Gs e Ts em uma cadeia de bits, e vice-versa. A cadeia de bits é
armazenada em um int. Como o tipo int de Python pode ter qualquer
tamanho, ele pode ser usado como uma cadeia de bits de qualquer
tamanho. Para fazer a conversão de volta para str, implementaremos o
método especial __str__() de Python.

Figura 1.5 – Compactando uma str que representa um gene em uma cadeia
de bits contendo 2 bits por nucleotídeo.
Listagem 1.10 – trivial_compression.py
class CompressedGene:
def __init__(self, gene: str) -> None:
self._compress(gene)
CompressedGenerecebe uma str de caracteres que representam os
nucleotídeos de um gene e, internamente, armazena a sequência de
nucleotídeos como uma cadeia de bits. A principal responsabilidade do
método __init__() é inicializar a cadeia de bits com os dados
apropriados. __init__() chama _compress() para fazer o trabalho sujo de
realmente converter a str de nucleotídeos fornecida em uma cadeia de
bits.
Observe que _compress() começa com um underscore. Python não tem o
conceito de métodos ou variáveis realmente privados. (Todas as variáveis
e métodos podem ser acessados por meio de re exão [re ection]; não há
nenhuma garantia rigorosa de privacidade.) Um underscore na frente é
usado como convenção para sinalizar que atores externos à classe não
deverão depender da implementação de um método. (Ela estará sujeita a
mudanças e o método deve ser tratado como privado.)
DICA Se você iniciar o nome de um método ou de uma variável de instância em uma classe
com dois underscores na frente, Python vai “embaralhar o nome”, modi cando o nome
implementado com um salt, fazendo com que ele não seja facilmente descoberto por outras
classes. Usamos um underscore neste livro para sinalizar uma variável ou um método
“privado”, mas você pode usar dois caso queira realmente enfatizar que algo é privado. Para
saber mais sobre nomenclatura em Python, consulte a seção “Descriptive Naming Styles”
(Estilos de nomes descritivos) da PEP 8: http://mng.bz/NA52.
A seguir, vamos ver como podemos fazer efetivamente a compactação.
Listagem 1.11 – Continuação de trivial_compression.py
def _compress(self, gene: str) -> None:
self.bit_string: int = 1 # começa com uma sentinela
for nucleotide in gene.upper():
self.bit_string <<= 2 # desloca dois bits para a esquerda
if nucleotide == "A": # muda os dois últimos bits para 00
self.bit_string |= 0b00
elif nucleotide == "C": # muda os dois últimos bits para 01
self.bit_string |= 0b01
elif nucleotide == "G": # muda os dois últimos bits para 10
self.bit_string |= 0b10
elif nucleotide == "T": # muda os dois últimos bits para 11
self.bit_string |= 0b11
else:
raise ValueError("Invalid Nucleotide:{}".format(nucleotide))
O método _compress() veri ca cada caractere da str de nucleotídeos
sequencialmente. Se vir um A, ele acrescentará 00 à cadeia de bits. Se vir
um C, acrescentará 01 e assim por diante. Lembre-se de que são
necessários dois bits para cada nucleotídeo. Como resultado, antes de
acrescentar cada novo nucleotídeo, deslocamos dois bits para a esquerda
na cadeia de bits (self.bit_string <<= 2).
Cada nucleotídeo é adicionado usando uma operação “ou” (|). Depois
do deslocamento à esquerda, dois 0s são acrescentados do lado direito da
cadeia de bits. Em operações bit a bit, fazer um “OR” (por exemplo,
self.bit_string |= 0b10 ) de 0s com qualquer outro valor resulta no outro
valor substituindo os 0s. Em outras palavras, acrescentamos
continuamente dois novos bits do lado direito da cadeia de bits. Os dois
bits acrescentados são determinados pelo tipo do nucleotídeo.
Por m, implementaremos a descompactação e o método especial
__str__() que a utiliza.

Listagem 1.12 – Continuação de trivial_compression.py


def decompress(self) -> str:
gene: str = ""
for i in range(0, self.bit_string.bit_length() - 1, 2): # - 1 para
excluir
# a sentinela
bits: int = self.bit_string >> i & 0b11 # obtém apenas 2 bits
relevantes
if bits == 0b00: # A
gene += "A"
elif bits == 0b01: # C
gene += "C"
elif bits == 0b10: # G
gene += "G"
elif bits == 0b11: # T
gene += "T"
else:
raise ValueError("Invalid bits:{}".format(bits))
return gene[::-1] # [::-1] inverte a string usando fatiamento com
inversão

def __str__(self) -> str: # representação em string para exibição elegante


return self.decompress()

decompress() lê dois bits de cada vez da cadeia de bits e utiliza esses dois
bits para determinar qual caractere deve ser adicionado no nal da
representação em str do gene. Como os bits estão sendo lidos na ordem
inversa se comparados com a ordem em que foram compactados (da
direita para a esquerda, e não da esquerda para a direita), a representação
em str, em última análise, está invertida (usamos a notação de fatiamento
para inversão [::-1]). Por m, observe como o método conveniente int
bit_length() ajudou no desenvolvimento de decompress() . Vamos testá-lo.

Listagem 1.13 – Continuação de trivial_compression.py


if __name__ == "__main__":
from sys import getsizeof
original: str =
"TAGGGATTAACCGTTATATATATATAGCCATGGATCGATTATATAGGGATTAACCGTTATATATATATAGCCA
TGGATCGATTATA" * 100
print("original is {} bytes".format(getsizeof(original)))
compressed: CompressedGene = CompressedGene(original) # compacta
print("compressed is {} bytes".format(getsizeof(compressed.bit_string)))
print(compressed) # descompacta
print("original and decompressed are the same: {}".format(original ==
compressed.decompress()))

Ao usar o método sys.getsizeof(), podemos mostrar na saída se realmente


zemos uma economia de quase 75% no custo de memória para
armazenar o gene utilizando esse esquema de compactação.
Listagem 1.14 – Saída de trivial_compression.py
original is 8649 bytes
compressed is 2320 bytes
TAGGGATTAACC…
original and decompressed are the same: True
NOTA Na classe CompressedGene , usamos instruções if intensamente a m de decidir
entre uma série de casos nos métodos tanto de compactação como de descompactação.
Como Python não tem uma instrução switch , de certo modo, isso é comum. O que você
verá ocasionalmente em Python é um uso intenso de dicionários no lugar de utilizar várias
instruções if para lidar com um conjunto de casos. Suponha, por exemplo, que tivéssemos
um dicionário no qual pudéssemos consultar os respectivos bits de cada nucleotídeo. Às
vezes, isso poderá ser mais legível, mas talvez venha acompanhado de um custo para o
desempenho. Mesmo que, tecnicamente, uma consulta em um dicionário tenha
complexidade O(1), o custo de executar uma função de hash signi ca que, às vezes, um
dicionário terá um pior desempenho do que uma série de if s. Isso dependerá do que as
instruções if de um programa em particular tiverem de avaliar para tomar suas decisões.
Você pode executar testes de desempenho nos dois métodos caso tenha de tomar uma
decisão entre if s e consultas em dicionário em uma seção crítica do código.

1.3 Criptogra a inquebrável


Um one-time pad (cifra de uso único) é uma forma de criptografar uma
porção de dados combinando-os com dados dummy aleatórios e não
signi cativos, de modo que os dados originais não sejam reconstituídos
sem acessar tanto o produto como os dados dummy. Basicamente, isso
deixa a criptogra a com um par de chaves. Uma chave é o produto e a
outra são os dados dummy aleatórios. Uma chave por si só é inútil;
somente a combinação das duas chaves permite descriptografar os dados
originais. Se for executado corretamente, um one-time pad é uma forma
de criptogra a inquebrável. A Figura 1.6 mostra o processo.

Figura 1.6 – Um one-time pad resulta em duas chaves que podem ser
separadas e então recombinadas para recriar os dados originais.

1.3.1 Deixando os dados em ordem


Neste exemplo, criptografaremos uma str usando um one-time pad. Uma
forma de pensar em uma str de Python 3 é como uma sequência de bytes
UTF-8 (em que o UTF-8 é uma codi cação de caracteres Unicode). Uma
str pode ser convertida em uma sequência de bytes UTF-8 (representada
com o tipo bytes) por meio do método encode(). De modo similar, uma
sequência de bytes UTF-8 pode ser convertida de volta em uma str
usando o método decode() no tipo bytes.
Há três critérios aos quais os dados dummy usados em uma operação
de criptogra a one-time pad devem obedecer para que o produto
resultante seja inquebrável. Os dados dummy devem ter o mesmo
tamanho dos dados originais, devem ser de fato aleatórios e totalmente
secretos. O primeiro e o terceiro critérios dizem respeito ao bom senso.
Se os dados dummy se repetirem por serem muito pequenos, poderá
haver um padrão observável. Se uma das chaves não for realmente secreta
(talvez seja reutilizada em outros lugares ou é parcialmente revelada), o
invasor terá uma pista. O segundo critério leva a uma questão particular:
podemos gerar dados realmente aleatórios? A resposta para a maior parte
dos computadores é não.
Neste exemplo, usaremos a função geradora de dados pseudoaleatórios
token_ bytes() , do módulo secrets (incluída pela primeira vez na
biblioteca-padrão de Python 3.6). Nossos dados não serão
verdadeiramente aleatórios, pois o pacote secrets utiliza um gerador de
números pseudoaleatórios internamente; para nós, porém, será su ciente.
Vamos gerar uma chave aleatória para ser usada como dados dummy.
Listagem 1.15 – unbreakable_encryption.py
from secrets import token_bytes
from typing import Tuple

def random_key(length: int) -> int:


# gera length bytes aleatórios
tb: bytes = token_bytes(length)
# converte esses bytes em uma cadeia de bits e a devolve
return int.from_bytes(tb, "big")

Essa função cria um int preenchido com length bytes aleatórios. O


método int.from_bytes() é usado para converter de bytes para int. De que
modo vários bytes podem ser convertidos em um único inteiro? A
resposta se encontra na Seção 1.2. Naquela seção, vimos que o tipo int
pode ter um tamanho arbitrário e pode ser usado como uma cadeia de
bits genérica. int é usado do mesmo modo neste caso. Por exemplo, o
método from_bytes() receberá 7 bytes (7 bytes * 8 bits = 56 bits) e os
converterá em um inteiro de 56 bits. Por que isso é conveniente?
Operações bit-a-bit podem ser executadas com mais facilidade e com um
melhor desempenho em um único int (leia-se “uma cadeia longa de
bits”), em comparação com vários bytes individuais em sequência. Além
disso, estamos prestes a usar a operação bit a bit XOR.

1.3.2 Criptografando e descriptografando


Como os dados dummy serão combinados com os dados originais que
queremos criptografar? A operação XOR servirá para isso. XOR é uma
operação lógica bit a bit (atua no nível dos bits) que devolve true se um de
seus operandos for verdadeiro, mas false se ambos ou nenhum deles for
verdadeiro. Como você deve ter adivinhado, XOR quer dizer exclusive or
(ou exclusivo).
Em Python, o operador XOR é ^. No contexto dos bits de números
binários, XOR devolve 1 para 0 ^ 1 e 1 ^ 0, mas 0 para 0 ^ 0 e 1 ^ 1. Se os
bits de dois números forem combinados com XOR, uma propriedade
conveniente é que o produto pode ser recombinado com um dos
operandos para gerar o outro operando:
A ^ B = C
C ^ B = A
C ^ A = B

Esse insight essencial é a base da criptogra a one-time pad. Para calcular


o nosso produto, simplesmente faremos um XOR de um int que
representa os bytes de nossa str original com um int gerado de forma
aleatória com o mesmo tamanho em bits (conforme gerado por
random_key() ). Nosso par de chaves devolvido será formado pelos dados
dummy e pelo produto.
Listagem 1.16 – Continuação de unbreakable_encryption.py
def encrypt(original: str) -> Tuple[int, int]:
original_bytes: bytes = original.encode()
dummy: int = random_key(len(original_bytes))
original_key: int = int.from_bytes(original_bytes, "big")
encrypted: int = original_key ^ dummy # XOR
return dummy, encrypted
NOTA int.from_bytes() recebe dois argumentos. O primeiro são os bytes que queremos
converter para um int . O segundo é o endianness desses bytes ("big" ). O endianness se
refere à ordem dos bytes, usada para armazenar os dados. O byte mais signi cativo vem
antes, ou é o byte menos signi cativo que vem antes? Em nosso caso, não importa, desde
que a mesma ordem seja usada tanto para criptografar como para descriptografar, pois
estamos manipulando os dados apenas no nível dos bits individuais. Em outras situações, se
você não tiver o controle das duas extremidades do processo de codi cação, a ordem poderá
ser muito importante, portanto, tome cuidado!
A descriptogra a é simplesmente uma questão de recombinar o par de
chaves que geramos com encrypt(). Isso é feito, mais uma vez, efetuando
uma operação de XOR entre todo e qualquer bit das duas chaves. A saída
de nitiva deve ser convertida de volta em uma str. Inicialmente, o int é
convertido em bytes usando int.to_bytes(). Esse método exige o número
de bytes a ser convertido do int. Para obter esse número, dividimos o
tamanho em bits por oito (o número de bits em um byte). Por m, o
método decode() de bytes nos devolve a str.
Listagem 1.17 – Continuação de unbreakable_encryption.py
def decrypt(key1: int, key2: int) -> str:
decrypted: int = key1 ^ key2 # XOR
temp: bytes = decrypted.to_bytes((decrypted.bit_length()+ 7) // 8,
"big")
return temp.decode()

Foi necessário somar 7 ao tamanho dos dados descriptografados antes de


usar a divisão por inteiro (//) para dividir por 8 a m de garantir que
“arredondaríamos para cima”, evitando um erro de o -by-one (erro por
um). Se nossa criptogra a one-time pad realmente funcionar, seremos
capazes de criptografar e descriptografar a mesma string Unicode sem
problemas.
Listagem 1.18 – Continuação de unbreakable_encryption.py
if __name__ == "__main__":
key1, key2 = encrypt("One Time Pad!")
result: str = decrypt(key1, key2)
print(result)

Se seu console exibir One Time Pad! , é sinal de que tudo funcionou
corretamente.

1.4 Calculando pi
O número pi (p ou 3,14159…), signi cativo na matemática, pode ser obtido
por meio de várias fórmulas. Uma das mais simples é a fórmula de
Leibniz, a qual postula que a convergência da seguinte série in nita é
igual a pi:
π = 4/1 - 4/3 + 4/5 - 4/7 + 4/9 - 4/11...

Você perceberá que o numerador da série in nita permanece igual a 4,


enquanto o denominador aumenta de 2, e a operação nos termos se
alterna entre uma adição e uma subtração.
Podemos modelar a série de forma direta, traduzindo partes da fórmula
em variáveis de uma função. O numerador pode ser uma constante 4. O
denominador pode ser uma variável que começa em 1 e é incrementada
de 2. A operação pode ser representada como -1 ou 1 de acordo com o
fato de estarmos somando ou subtraindo. Por m, a variável pi é usada na
Listagem 1.19 para armazenar a soma da série à medida que o laço for é
executado.
Listagem 1.19 – calculating_pi.py
def calculate_pi(n_terms: int) -> float:
numerator: float = 4.0
denominator: float = 1.0
operation: float = 1.0
pi: float = 0.0
for _ in range(n_terms):
pi += operation * (numerator / denominator)
denominator += 2.0
operation *= -1.0
return pi

if __name__ == "__main__":
print(calculate_pi(1000000))
DICA Na maioria das plataformas, os float s Python são números de ponto utuante de 64
bits (ou double em C).
Essa função é um exemplo de como uma conversão mecânica entre uma
fórmula e um código de programação pode ser simples e e caz para
modelar ou simular um conceito interessante. Uma conversão mecânica é
uma ferramenta útil, mas devemos ter em mente que ela não é,
necessariamente, a solução mais e caz. Certamente, a fórmula de Leibniz
para pi pode ser implementada com um código mais e ciente ou mais
compacto.
NOTA Quanto mais termos houver na série in nita (quanto maior o valor de n_terms
quando calculate_pi() for chamado), mais exato será o cálculo nal de pi.

1.5 Torres de Hanói


Três pinos verticais (daí o nome “torres”) encontram-se de pé. Nós os
chamaremos de A, B e C. Discos em formato de rosquinhas estão na torre
A. O disco maior está embaixo, e nós o chamaremos de disco 1. Os
demais discos acima do disco 1 recebem números crescentes e são cada
vez menores. Por exemplo, se trabalhássemos com três discos, o disco
maior, que está embaixo, seria o disco 1. O próximo disco maior, o disco
2, estaria sobre o disco 1. Por m, o disco menor, o disco 3, estaria sobre
o disco 2. Nosso objetivo é mover todos os discos da torre A para a torre
C, dadas as seguintes restrições:
• Somente um disco pode ser movido por vez.
• O disco mais acima em qualquer torre é o único disponível para ser
movido.
• Um disco maior não pode estar em cima de um disco menor.
A Figura 1.7 sintetiza o problema.
Figura 1.7 – O desa o consiste em mover os três discos, um de cada vez, da
torre A para a torre C. Um disco maior não pode estar em cima de um disco
menor.

1.5.1 Modelando as torres


Uma pilha é uma estrutura de dados modelada com base no conceito de
LIFO (Last-In-First-Out, ou o último que entra é o primeiro que sai). O
último item inserido na pilha será o primeiro a ser removido. As duas
operações básicas em uma pilha são push (inserir) e pop (remover). Um
push insere um novo item em uma pilha, enquanto um pop remove e
devolve o último item inserido. Podemos modelar facilmente uma pilha
em Python usando uma list como base para a armazenagem.
Listagem 1.20 – hanoi.py
from typing import TypeVar, Generic, List
T = TypeVar('T')

class Stack(Generic[T]):
def __init__(self) -> None:
self._container: List[T] = []

def push(self, item: T) -> None:


self._container.append(item)

def pop(self) -> T:


return self._container.pop()

def __repr__(self) -> str:


return repr(self._container)
NOTA Essa classe Stack implementa __repr__() para que possamos explorar facilmente o
conteúdo de uma torre. __repr__() é o que será exibido quando print() for aplicado em
uma Stack .
NOTA Conforme descrito na introdução, utilizamos as dicas de tipos (type hints) em todo o
livro. A importação de Generic do módulo typing permite que Stack seja genérica sobre
um tipo particular das dicas de tipos. O tipo arbitrário T está de nido em T =
TypeVar('T') . T pode ser qualquer tipo. Quando uma dica de tipo for usada mais tarde
com uma Stack para resolver o problema das Torres de Hanói, o tipo informado será
Stack[int] , o que signi ca que T será preenchido com o tipo int . Em outras palavras, a
pilha será uma pilha de inteiros. Se você está tendo di culdade com as dicas de tipo,
consulte o Apêndice C.
As pilhas são representantes perfeitas para as torres nas Torres de Hanói.
Se quisermos colocar um disco em uma torre, podemos apenas fazer um
push. Se quisermos mover um disco de uma torre para outra, podemos
fazer um pop da primeira torre e um push na segunda.
Vamos de nir nossas torres como Stacks e preencher a primeira torre
com os discos.
Listagem 1.21 – Continuação de hanoi.py
num_discs: int = 3
tower_a: Stack[int] = Stack()
tower_b: Stack[int] = Stack()
tower_c: Stack[int] = Stack()
for i in range(1, num_discs + 1):
tower_a.push(i)

1.5.2 Solucionando as Torres de Hanói


Como as Torres de Hanói podem ser resolvidas? Suponha que estamos
tentando mover apenas um disco. Saberíamos como fazer isso, certo? De
fato, mover um disco é o nosso caso de base para uma solução recursiva
das Torres de Hanói. O caso recursivo é mover mais de um disco. Assim,
o principal insight é o fato de termos, basicamente, dois cenários para os
quais devemos escrever um código: mover um disco (o caso de base) e
mover mais de um disco (o caso recursivo).
Vamos analisar um exemplo especí co para compreender o caso
recursivo. Suponha que temos três discos (um em cima, um no meio e um
embaixo) na torre A, e queremos movê-los para a torre C. (Fazer um
diagrama do problema à medida que o descrevemos talvez ajude.)
Poderíamos mover inicialmente o disco de cima para a torre C. Então
moveríamos o disco do meio para a torre B. Em seguida, poderíamos
mover o disco de cima da torre C para a torre B. Agora temos o disco de
baixo ainda na torre A e os dois discos de cima na torre B. Basicamente,
movemos dois discos de uma torre (A) para outra (B) com sucesso.
Mover o disco de baixo de A para C é o nosso caso de base (mover um
único disco). Agora podemos mover os dois discos de cima, de B para C,
usando o mesmo procedimento utilizado de A para B. Movemos o disco
de cima para A, o disco do meio para C e, por m, o disco de cima em A
para C.
DICA Em uma aula de ciência da computação, não é incomum ver um pequeno modelo das
torres feita de pinos e rosquinhas de plástico. Você pode construir o próprio modelo usando
três lápis e três pedaços de papel. Isso pode ajudar a visualizar a solução.
Em nosso exemplo com três discos, tínhamos um caso de base simples
que consistia em mover um único disco, e um caso recursivo, que
consistia em mover todos os outros discos (dois, no caso), usando a
terceira torre como temporária. Podemos separar o caso recursivo em três
passos:
1. Mover os n-1 discos de cima da torre A para a torre B (a torre
temporária), usando C como intermediária.
2. Mover o único disco que está mais embaixo, de A para C.
3. Mover os n-1 discos da torre B para a torre C usando A como
intermediária.
O incrível é que esse algoritmo recursivo funciona não só para três
discos, mas para qualquer quantidade deles. Escreveremos o código de
uma função chamada hanoi() que será responsável por mover discos de
uma torre para outra, dada uma terceira torre temporária.
Listagem 1.22 – Continuação de hanoi.py
def hanoi(begin: Stack[int], end: Stack[int], temp: Stack[int], n: int) ->
None:
if n == 1:
end.push(begin.pop())
else:
hanoi(begin, temp, end, n - 1)
hanoi(begin, end, temp, 1)
hanoi(temp, end, begin, n - 1)

Depois de chamar hanoi(), você deve analisar as torres A, B e C para


veri car se os discos foram movidos com sucesso.
Listagem 1.23 – Continuação de hanoi.py
if __name__ == "__main__":
hanoi(tower_a, tower_c, tower_b, num_discs)
print(tower_a)
print(tower_b)
print(tower_c)

Você perceberá que os discos foram movidos com sucesso. Ao escrever o


código para a solução das Torres de Hanói, não tivemos necessariamente
de entender todos os passos exigidos para mover vários discos da torre A
para a torre C. No entanto, conseguimos entender o algoritmo recursivo
genérico para mover qualquer quantidade de discos e escrevemos o
código, deixando que o computador zesse o resto. Eis a e cácia de
formular soluções recursivas para os problemas: muitas vezes, é possível
pensar nas soluções de modo abstrato, sem o trabalho complicado de ter
de entender todas as ações individuais em nossa mente.
A propósito, a função hanoi() executará um número exponencial de
vezes, como função do número de discos, o que faz com que solucionar
o problema para 64 discos seja inimaginável. Você pode testar a função
com várias outras quantidades de discos modi cando a variável num_
discs . O número exponencialmente crescente de passos necessários à
medida que a quantidade de discos aumenta está na origem da lenda das
Torres de Hanói; você pode ler mais sobre o assunto em inúmeras fontes.
Talvez esteja interessado também em ler mais sobre a matemática por trás
de sua solução recursiva; veja a explicação de Carl Burch em “About the
Towers of Hanoi” (Sobre as Torres de Hanói) em http://mng.bz/c1i2.

1.6 Aplicações no mundo real


As diversas técnicas apresentadas neste capítulo (recursão, memoização,
compactação e manipulação no nível de bits) são tão comuns no
desenvolvimento moderno de software que chega a ser impossível
imaginar o mundo da computação sem elas. Embora seja possível
resolver os problemas sem essas técnicas, em geral será mais lógico ou
haverá um melhor desempenho se forem solucionados com elas.
A recursão, em particular, está no coração não só de vários algoritmos,
mas até mesmo em linguagens de programação completas. Em algumas
linguagens de programação funcional, como Scheme e Haskell, a
recursão substitui os laços usados nas linguagens imperativas. Contudo,
vale a pena lembrar que tudo que pode ser feito com uma técnica
recursiva também pode sê-lo com uma técnica iterativa.
A memoização tem sido aplicada com sucesso para agilizar o trabalho
dos parsers (programas que interpretam linguagens). É útil para todos os
problemas nos quais o resultado de um cálculo recente será
provavelmente solicitado de novo. Outra aplicação da memoização está
nos runtimes de linguagens. Alguns runtimes de linguagens (versões de
Prolog, por exemplo) armazenam os resultados das chamadas de funções
automaticamente (automemoização), de modo que a função não precisará
executar da próxima vez que a mesma chamada for feita. É semelhante ao
modo como o decorador @lru_cache() funciona em fib6().
A compactação tem feito com que um mundo conectado pela internet
com limitações de largura de banda seja mais tolerável. A técnica de
cadeia de bits analisada na Seção 1.2 pode ser usada para tipos de dados
simples do mundo real que tenham um número limitado de valores
possíveis, para os quais mesmo um byte poderia ser um exagero. A
maioria dos algoritmos de compactação, porém, atua encontrando
padrões ou estruturas em um conjunto de dados, os quais permitem que
informações repetidas sejam eliminadas. São signi cativamente mais
complicados do que aquilo que descrevemos na Seção 1.2.
One-time pads (cifras de uso único) não são práticos para criptogra as
genéricas. Eles exigem que tanto quem criptografa como quem
descriptografa possuam uma das chaves (os dados dummy, em nosso
exemplo) para que os dados originais sejam reconstruídos, o que é
inconveniente e vai contra o objetivo da maior parte dos esquemas de
criptogra a (manter as chaves secretas). Entretanto, talvez você esteja
interessado em saber que o nome “one-time pad”3 tem origem nos
espiões que usavam bloquinhos de papel de verdade contendo dados
dummy para gerar comunicações criptografadas durante a Guerra Fria.
Essas técnicas formam os blocos de construção da programação, com
base nos quais outros algoritmos são construídos. Nos próximos
capítulos, veremos essas técnicas serem amplamente aplicadas.

1.7 Exercícios
1. Escreva outra função que forneça o elemento n da sequência de
Fibonacci usando uma técnica cujo design seja seu. Escreva testes de
unidade (unit tests) que avaliem se a função está correta, além de
mostrar o desempenho em comparação com outras versões
apresentadas neste capítulo.
2. Vimos como o tipo int simples de Python pode ser usado para
representar uma cadeia de bits. Escreva um wrapper ergonômico em
torno de int que seja usado de modo genérico como uma sequência
de bits (torne-o iterável e implemente __getitem__()). Reimplemente
CompressedGene usando o wrapper.
3. Escreva uma solução para as Torres de Hanói que funcione para
qualquer quantidade de torres.
4. Utilize um one-time pad para criptografar e descriptografar imagens.

1 Donald Michie, um famoso cientista britânico da área de computação, cunhou o termo


memoização (memoization). Donald Michie, Memo functions: a language feature with “rote-
learning” properties (Funções memo: um recurso da linguagem com propriedades de
“aprendizado por repetição”) (Universidade de Edimburgo, Departamento de Inteligência de
Máquina e Percepção, 1967).
2 Esse exemplo foi inspirado no livro Algorithms, 4ª edição, de Robert Sedgewick e Kevin Wayne
(Addison-Wesley Professional, 2011), página 819.
3 N.T.: One-time pad pode ser literalmente traduzido como uma folha de bloquinho de papel,
usado uma só vez.
CAPÍTULO 2

Problemas de busca

“Busca” é um termo tão amplo que este livro poderia ter recebido o título
de Problemas clássicos de busca com Python. Este capítulo descreve os
principais algoritmos de busca que todo programador deve conhecer.
Apesar do título declarado, ele não tem a pretensão de abranger tudo.

2.1 Busca em DNA


Os genes são comumente representados em um software de computador
por uma sequência de caracteres A, C, G e T. Cada letra representa um
nucleotídeo, e a combinação de três nucleotídeos é chamada de códon. A
Figura 2.1 mostra isso.

Figura 2.1 – Um nucleotídeo é representado por uma das letras A, C, G ou T.


Um códon é composto de três nucleotídeos, e um gene é composto de vários
códons.
Um códon codi ca um aminoácido especí co que, junto com outros
aminoácidos, pode compor uma proteína. Uma tarefa clássica dos
softwares de bioinformática consiste em encontrar um códon especí co
em um gene.

2.1.1 Armazenando um DNA


Podemos representar um nucleotídeo como um IntEnum simples, com
quatro casos.
Listagem 2.1 – dna_search.py
from enum import IntEnum
from typing import Tuple, List

Nucleotide: IntEnum = IntEnum('Nucleotide', ('A', 'C', 'G', 'T'))


Nucleotide é do tipo IntEnum, e não apenas Enum, porque IntEnum oferece
operadores de comparação(<, >= e assim por diante) “gratuitamente”. Ter
esses operadores em um tipo de dado é necessário para que os
algoritmos de busca que implementaremos atuem nesses dados. Tuple e
List são importados do pacote typing para assistência às dicas de tipos
(type hints).
Os códons podem ser de nidos como uma tupla de três Nucleotides. Um
gene pode ser de nido como uma lista de Codons.
Listagem 2.2 – Continuação de dna_search.py
Codon = Tuple[Nucleotide, Nucleotide, Nucleotide] # alias de tipo para
códons
Gene = List[Codon] # alias de tipo para genes
NOTA Ainda que, mais tarde, tenhamos de comparar um Codon com outro, não será
necessário de nir uma classe personalizada com o operador < explicitamente implementado
para Codon . Isso porque Python tem suporte embutido para comparações entre tuplas que
sejam compostas de tipos que também sejam comparáveis.
Em geral, os genes na internet estarão em um formato de arquivo
contendo uma string gigantesca que representa todos os nucleotídeos na
sequência do gene. De niremos uma string desse tipo para um gene
imaginário e a chamaremos de gene_str.
Listagem 2.3 – Continuação de dna_search.py
gene_str: str =
"ACGTGGCTCTCTAACGTACGTACGTACGGGGTTTATATATACCCTAGGACTCCCTTT"
Também precisaremos de uma função utilitária para converter uma str em
um Gene.
Listagem 2.4 – Continuação de dna_search.py
def string_to_gene(s: str) -> Gene:
gene: Gene = []
for i in range(0, len(s), 3):
if (i + 2) >= len(s): # não avança para além do final!
return gene
# inicializa codon a partir de três nucleotídeos
codon: Codon = (Nucleotide[s[i]], Nucleotide[s[i + 1]],
Nucleotide[s[i + 2]])
gene.append(codon) # adiciona condon em gene
return gene
string_to_gene() percorre continuamente a str fornecida e converte seus
três próximos caracteres em Codons que são adicionados no nal de um
novo Gene. Se for determinado que não há nenhum Nucleotide duas
posições após a posição atual no s sendo analisado (veja a instrução if no
laço), então a função saberá que chegou ao nal de um gene incompleto
e ignorará esses últimos um ou dois nucleotídeos.
string_to_gene() pode ser usado para converter a str gene_str em um
Gene .

Listagem 2.5 – Continuação de dna_search.py


my_gene: Gene = string_to_gene(gene_str)

2.1.2 Busca linear


Uma operação básica que podemos executar em um gene é procurar um
códon especí co. O objetivo é apenas descobrir se o códon está presente
ou não no gene.
Uma busca linear percorre todos os elementos em um espaço de busca,
na ordem em que está a estrutura de dados original, até que o dado
sendo procurado seja encontrado ou o nal da estrutura de dados seja
alcançado. Com efeito, uma busca linear é o modo mais simples, natural e
óbvio de procurar algo. No pior caso, uma busca linear exigirá passar por
todos os elementos de uma estrutura de dados, portanto sua
complexidade é O(n), em que n é o número de elementos da estrutura. A
Figura 2.2 mostra isso.
De nir uma função que faça uma busca linear é trivial. Basta que ela
percorra todos os elementos de uma estrutura de dados e veri que sua
equivalência com o item procurado. O código a seguir de ne uma função
desse tipo para um Gene e um Codon, e então faz um teste para my_gene e
para Codons de nomes acg e gat.

Figura 2.2 – No pior caso de uma busca linear, você veri cará
sequencialmente todos os elementos do array.
Listagem 2.6 – Continuação de dna_search.py
def linear_contains(gene: Gene, key_codon: Codon) -> bool:
for codon in gene:
if codon == key_codon:
return True
return False

acg: Codon = (Nucleotide.A, Nucleotide.C, Nucleotide.G)


gat: Codon = (Nucleotide.G, Nucleotide.A, Nucleotide.T)
print(linear_contains(my_gene, acg)) # True
print(linear_contains(my_gene, gat)) # False
NOTA Essa função tem apenas nalidade ilustrativa. Todos os tipos embutidos de Python
para sequências (list , tuple , range ) implementam o método __contains__() , que nos
permite fazer uma busca por um item especí co usando apenas o operador in . Na verdade,
o operador in pode ser utilizado com qualquer tipo que implemente __contains__() . Por
exemplo, poderíamos pesquisar my_gene em busca de acg e exibir o resultado escrevendo
print(acg in my_gene) .

2.1.3 Busca binária


Há um modo mais rápido de fazer uma busca do que veri car cada um
dos elementos, mas exige que saibamos algo sobre a ordem da estrutura
de dados com antecedência. Se soubermos que a estrutura está ordenada
e pudermos acessar instantaneamente qualquer item por meio de seu
índice, será possível fazer uma busca binária. Com base nesse critério,
uma list Python ordenada será uma candidata perfeita para uma busca
binária.
Em uma busca binária, observamos o elemento central em um conjunto
ordenado de elementos, comparamos esse elemento com o elemento
procurado, reduzimos o conjunto pela metade com base nessa
comparação e reiniciamos o processo. Vamos analisar um exemplo
concreto.
Suponha que temos uma list contendo palavras em ordem alfabética,
como ["cat", "dog", "kangaroo", "llama", "rabbit", "rat", "zebra"] e
estamos procurando a palavra “rat”:
1. Podemos determinar que o elemento central dessa lista de sete
palavra é “llama”.
2. Podemos determinar que “rat” vem depois de “llama” na ordem
alfabética, portanto ela deverá estar na metade (aproximadamente) da
lista que vem depois de “llama”. (Se tivéssemos achado “rat” neste
passo, devolveríamos a sua posição; se descobríssemos que nossa
palavra vem antes da palavra central que esamos veri cando, teríamos
a garantia de que ela estará na metade da lista que vem antes de
“llama”.)
3. Podemos executar novamente os passos 1 e 2 na metade da lista na
qual sabemos que “rat” possivelmente deve estar. Com efeito, essa
metade passará a ser a nossa nova lista de base. Esses passos são
continuamente executados até que “rat” seja encontrado ou o intervalo
que estamos veri cando não contenha mais nenhum elemento para
ser pesquisado, o que signi ca que “rat” não está presente na lista de
palavras.
A Figura 2.3 mostra uma busca binária. Observe que, diferente de uma
busca linear, ela não envolve uma busca em todos os elementos.
Figura 2.3 – No pior caso de uma busca binária, analisaremos apenas lg(n)
elementos da lista.
Uma busca binária reduz continuamente o espaço de pesquisa pela
metade, portanto, a execução de pior caso será O(lg n). Entretanto, há um
tipo de porém. De modo diferente de uma busca linear, uma busca
binária exige uma estrutura de dados ordenada para pesquisar, e uma
ordenação exige tempo. De fato, ordenar demora O(n lg n) nos melhores
algoritmos de ordenação. Se vamos executar nossa busca apenas uma vez
e nossa estrutura de dados original não está ordenada, provavelmente
fará sentido fazer apenas uma busca linear. Porém, se a busca vai ser
efetuada várias vezes, o custo de tempo para fazer a ordenação
compensará, permitindo colher os frutos do custo bastante reduzido de
tempo de cada busca individual.
Escrever uma função de busca binária para um gene e um códon não é
diferente de escrever uma função para qualquer outro tipo de dado, pois
o tipo Codon pode ser comparado com outros de seu tipo, e o tipo Gene é
apenas uma list.
Listagem 2.7 – Continuação de dna_search.py
def binary_contains(gene: Gene, key_codon: Codon) -> bool:
low: int = 0
high: int = len(gene) - 1
while low <= high: # enquanto ainda houver um espaço para pesquisa
mid: int = (low + high) // 2
if gene[mid] < key_codon:
low = mid + 1
elif gene[mid] > key_codon:
high = mid - 1
else:
return True
return False
Vamos analisar essa função linha a linha.
low: int = 0
high: int = len(gene) - 1

Começamos observando o intervalo que engloba a lista toda (o gene).


while low <= high:

Continuamos a busca enquanto houver um intervalo para ser pesquisado.


Quando low for maior do que high, é sinal de que não há mais nenhuma
posição a ser pesquisada na lista.
mid: int = (low + high) // 2

Calculamos o meio, mid, usando uma divisão por inteiro e a fórmula


simples de média que aprendemos na escola.
if gene[mid] < key_codon:
low = mid + 1

Se o elemento que estamos procurando estiver depois do elemento que


está no meio do intervalo pesquisado, modi camos o intervalo que será
analisado na próxima iteração do laço movendo low para que esteja um
elemento depois do elemento central atual. É nessa operação que
dividimos o intervalo ao meio para a próxima iteração.
elif gene[mid] > key_codon:
high = mid - 1

De modo semelhante, dividimos no meio na outra direção quando o


elemento que estamos procurando for menor que o elemento central.
else:
return True

Se o elemento em questão não for menor nem maior que o elemento


central, é sinal de que nós o encontramos! E, é claro, se não houver mais
iterações no laço, devolvemos false (não foi reproduzido aqui), indicando
que o elemento não foi encontrado.
Podemos tentar executar nossa função com o mesmo gene e os mesmos
códons, mas devemos nos lembrar de fazer uma ordenação antes.
Listagem 2.8 – Continuação de dna_search.py
my_sorted_gene: Gene = sorted(my_gene)
print(binary_contains(my_sorted_gene, acg)) # True
print(binary_contains(my_sorted_gene, gat)) # False
DICA Você pode implementar uma busca binária com um bom desempenho usando o
módulo bisect da biblioteca-padrão de Python: https://docs.python.org/3/library/bisect.html.

2.1.4 Um exemplo genérico


As funções linear_contains() e binary_contains() podem ser generalizadas
para funcionar com praticamente qualquer sequência Python. As versões
genéricas a seguir são quase idênticas às versões que vimos antes, apenas
com alguns nomes e dicas de tipo alterados.
NOTA Há muitos tipos importados na listagem de código a seguir. Reutilizaremos o arquivo
generic_search.py em vários algoritmos de busca genéricos deste capítulo e, com isso, as
importações estarão resolvidas.
NOTA Antes de prosseguir com a leitura do livro, será necessário instalar o módulo typing_
extensions usando pip install typing_extensions ou pip3 install
typing_extensions , dependendo de como estiver con gurado o seu interpretador Python.
Você precisará desse módulo para acessar o tipo Protocol , que estará na biblioteca-padrão
em uma versão futura de Python (conforme especi cado na PEP 544). Desse modo, em uma
versão futura de Python, importar o módulo typing_extensions será desnecessário, e
você poderá usar from typing import Protocol no lugar de from typing_extensions
import Protocol .

Listagem 2.9 – generic_search.py


from __future__ import annotations
from typing import TypeVar, Iterable, Sequence, Generic, List, Callable,
Set, Deque, Dict, Any, Optional
from typing_extensions import Protocol
from heapq import heappush, heappop
T = TypeVar('T')

def linear_contains(iterable: Iterable[T], key: T) -> bool:


for item in iterable:
if item == key:
return True
return False

C = TypeVar("C", bound="Comparable")

class Comparable(Protocol):

def __eq__(self, other: Any) -> bool:


...
def __lt__(self: C, other: C) -> bool:
...

def __gt__(self: C, other: C) -> bool:


return (not self < other) and self != other

def __le__(self: C, other: C) -> bool:


return self < other or self == other

def __ge__(self: C, other: C) -> bool:


return not self < other

def binary_contains(sequence: Sequence[C], key: C) -> bool:


low: int = 0
high: int = len(sequence) - 1
while low <= high: # enquanto ainda houver um espaço para pesquisa
mid: int = (low + high) // 2
if sequence[mid] < key:
low = mid + 1
elif sequence[mid] > key:
high = mid - 1
else:
return True
return False

if __name__ == "__main__":
print(linear_contains([1, 5, 15, 15, 15, 15, 20], 5)) # True
print(binary_contains(["a", "d", "e", "f", "z"], "f")) # True
print(binary_contains(["john", "mark", "ronald", "sarah"], "sheila")) #
False
Agora você pode tentar fazer buscas com outros tipos de dados. Essas
funções podem ser reutilizadas para praticamente qualquer coleção
Python. Eis a e cácia de escrever um código de forma genérica. O único
aspecto lamentável nesse exemplo são as partes confusas por causa das
dicas de tipos de Python, na forma da classe Comparable. Um tipo
Comparable é um tipo que implemnta os operadores de comparação (< , >= e
assim por diante). Em versões futuras de Python, deverá haver um modo
mais sucinto de criar uma dica de tipo para tipos que implementem esses
operadores comuns.
2.2 Resolução de labirintos
Encontrar um caminho em um labirinto é análogo a vários problemas
comuns de busca em ciência da computação. Então, por que não
encontrar literalmente um caminho em um labirinto para ilustrar os
algoritmos de busca em largura (breadth- rst search), busca em
profundidade (depth- rst search) e A*?
Nosso labirinto será uma grade bidimensional de Cells. Uma Cell é um
enumerado (enum) com valores str, em que " " representará um espaço
vazio e "X" representará um espaço bloqueado. Haverá também outros
casos para exibição de um labirinto ilustrado.
Listagem 2.10 – maze.py
from enum import Enum
from typing import List, NamedTuple, Callable, Optional
import random
from math import sqrt
from generic_search import dfs, bfs, node_to_path, astar, Node

class Cell(str, Enum):


EMPTY = " "
BLOCKED = "X"
START = "S"
GOAL = "G"
PATH = "*"

Mais uma vez, estamos fazendo várias importações para que elas já
estejam resolvidas. Observe que a última importação (de generic_search) é
de símbolos que ainda não de nimos. Foi incluída aqui por conveniência,
mas você pode comentá-la até que ela seja necessária.
Precisaremos de uma forma de referenciar uma posição individual no
labirinto. Essa será apenas uma NamedTuple com propriedades que
representam a linha e a coluna da posição em questão.
Listagem 2.11 – Continuação de maze.py
class MazeLocation(NamedTuple):
row: int
column: int
2.2.1 Gerando um labirinto aleatório
Nossa classe Maze manterá internamente o controle de uma grade (uma
lista de listas) que representa o seu estado. Ela também terá variáveis de
instância para o número de linhas, o número de colunas, a posição inicial
e a posição do objetivo. A grade será preenchida aleatoriamente com
células bloqueadas.
O labirinto gerado deve ser razoavelmente esparso para que quase
sempre haja um caminho de uma dada posição inicial até uma dada
posição de chegada. (A nal de contas, isso serve para testar nossos
algoritmos.) Deixaremos que quem zer a chamada para criar um
labirinto decida exatamente quão esparso ele será, mas forneceremos um
valor default igual a 20% de posições bloqueadas. Quando um número
aleatório for menor que o limiar representado pelo parâmetro sparseness
em questão, simplesmente substituiremos um espaço vazio por uma
parede. Se zermos isso para todas as posições possíveis do labirinto,
estatisticamente, o quão esparso está o labirinto estará próximo do
parâmetro sparseness fornecido.
Listagem 2.12 – Continuação de maze.py
class Maze:
def __init__(self, rows: int = 10, columns: int = 10, sparseness: float
= 0.2, start: MazeLocation = MazeLocation(0, 0), goal: MazeLocation =
MazeLocation(9, 9)) -> None:
# inicializa as variáveis de instância básicas
self._rows: int = rows
self._columns: int = columns
self.start: MazeLocation = start
self.goal: MazeLocation = goal
# preenche a grade com células vazias
self._grid: List[List[Cell]] =
[[Cell.EMPTY for c in range(columns)] for r in range(rows)]
# preenche a grade com células bloqueadas
self._randomly_fill(rows, columns, sparseness)
# preenche as posições inicial e final
self._grid[start.row][start.column] = Cell.START
self._grid[goal.row][goal.column] = Cell.GOAL

def _randomly_fill(self, rows: int, columns: int, sparseness: float):


for row in range(rows):
for column in range(columns):
if random.uniform(0, 1.0) < sparseness:
self._grid[row][column] = Cell.BLOCKED

Agora que temos um labirinto, também queremos exibi-lo de forma


sucinta no console. Queremos que caracteres estejam próximos para que
pareça um labirinto de verdade.
Listagem 2.13 – Continuação de maze.py
# devolve uma versão do labirinto com uma formatação elegante para exibição
def __str__(self) -> str:
output: str = ""
for row in self._grid:
output += "".join([c.value for c in row]) + "\n"
return output

Vá em frente e teste essas funções de labirinto.


maze: Maze = Maze()
print(maze)

2.2.2 Miscelânea de minúcias sobre labirintos


Mais tarde, será conveniente ter uma função que veri que se atingimos o
nosso objetivo durante a busca. Em outras palavras, queremos veri car se
um determinado MazeLocation alcançado pela busca é o objetivo. Podemos
acrescentar um método em Maze.
Listagem 2.14 – Continuação de maze.py
def goal_test(self, ml: MazeLocation) -> bool:
return ml == self.goal

Como podemos nos deslocar por nossos labirintos? Suponha que


podemos nos mover na horizontal e na vertical, uma posição de cada vez,
a partir de uma dada posição no labirinto. Usando esse critério, uma
função successors() poderá encontrar as próximas posições possíveis a
partir de um dado MazeLocation. No entanto, a função successors() será
diferente para cada Maze, pois cada Maze tem um tamanho e um conjunto
de paredes diferentes. Portanto, de niremos essa função como um
método de Maze.
Listagem 2.15 – Continuação de maze.py
def successors(self, ml: MazeLocation) -> List[MazeLocation]:
locations: List[MazeLocation] = []
if ml.row + 1 < self._rows and self._grid[ml.row + 1][ml.column] !=
Cell.BLOCKED:
locations.append(MazeLocation(ml.row + 1, ml.column))
if ml.row - 1 >= 0 and self._grid[ml.row - 1][ml.column] !=
Cell.BLOCKED:
locations.append(MazeLocation(ml.row - 1, ml.column))
if ml.column + 1 < self._columns and self._grid[ml.row][ml.column + 1]
!= Cell.BLOCKED:
locations.append(MazeLocation(ml.row, ml.column + 1))
if ml.column - 1 >= 0 and self._grid[ml.row][ml.column - 1] !=
Cell.BLOCKED:
locations.append(MazeLocation(ml.row, ml.column - 1))
return locations

successors() simplesmente veri ca as posições acima, abaixo, à direita e à


esquerda de um MazeLocation em um Maze para ver se é capaz de encontrar
espaços vazios para onde ir a partir dessa posição. A função também
evita que posições além das bordas de Maze sejam veri cadas. Todo
MazeLocation encontrado é inserido em uma lista que, em última análise, é
devolvida para quem chamou a função.

2.2.3 Busca em profundidade


Uma DFS (Depth-First Search, ou Busca em Prfundidade) é o que seu
nome sugere: uma busca que vai até a máxima profundidade possível
antes de retroceder ao seu último ponto de decisão caso encontre um
caminho sem saída. Implementaremos uma busca em profundidade
genérica, capaz de resolver o nosso problema de labirinto. Ela também
será reutilizável em outros problemas. A Figura 2.4 exibe uma busca em
profundidade em andamento em um labirinto.
Figura 2.4 – Em uma busca em profundidade, a busca se dá por um caminho
continuamente mais profundo, até que uma barreira seja atingida e seja
necessário retroceder ao último ponto de decisão.
Pilhas
O algoritmo de busca em profundidade faz uso de uma estrutura de
dados conhecida como pilha (stack). (Se você leu a respeito das pilhas no
Capítulo 1, sinta-se à vontade para ignorar esta seção.) Uma pilha é uma
estrutura de dados que funciona sob o princípio de LIFO (Last-In-First-
Out, ou o último que entra é o primeiro que sai). Pense em uma pilha de
papéis. A última folha de papel colocada no topo da pilha será a primeira
folha a ser retirada. É comum que uma pilha seja implementada com base
em uma estrutura de dados mais primitiva, como uma lista.
Implementaremos nossa pilha com base no tipo list de Python.
Em geral, as pilhas têm pelo menos duas operações:
• push()—insere um item no topo da pilha;
• pop()—remove o item do topo da pilha e o devolve.
Implementaremos as duas funções, assim como uma propriedade empty
para veri car se a pilha contém mais algum item. Adicionaremos o código
da pilha no arquivo generic_search.py com o qual estávamos trabalhando
antes neste capítulo. Já zemos todas as importações necessárias.
Listagem 2.16 – Continuação de generic_search.py
class Stack(Generic[T]):
def __init__(self) -> None:
self._container: List[T] = []

@property
def empty(self) -> bool:
return not self._container # negação é verdadeira para um contêiner
vazio

def push(self, item: T) -> None:


self._container.append(item)

def pop(self) -> T:


return self._container.pop() # LIFO

def __repr__(self) -> str:


return repr(self._container)

Observe que implementar uma pilha usando uma list Python é simples e
basta concatenar itens em sua extremidade direita e removê-los do ponto
mais extremo à direita. O método pop() em list falhará se não houver
mais itens na lista, portanto pop() falhará igualmente em uma Stack se ela
estiver vazia.
Algoritmo de DFS
Mais um pequeno detalhe será necessário antes de podermos
implementar a DFS. Precisamos de uma classe Node que usaremos para
manter o controle de como passamos de um estado para outro (ou de
uma posição para outra) à medida que fazemos a busca. Podemos pensar
em um Node como um wrapper em torno de um estado. No caso de nosso
problema de resolução de labirinto, esses estados são do tipo
MazeLocation . Chamaremos o Node do qual um estado se originou de seu
parent . Além disso, de niremos nossa classe Node com as propriedades
cost e heuristic, e com __lt__() implementado, para que possamos
reutilizá-la depois no algoritmo A*.
Listagem 2.17 – Continuação de generic_search.py
class Node(Generic[T]):
def __init__(self, state: T, parent: Optional[Node], cost: float = 0.0,
heuristic: float = 0.0) -> None:
self.state: T = state
self.parent: Optional[Node] = parent
self.cost: float = cost
self.heuristic: float = heuristic

def __lt__(self, other: Node) -> bool:


return (self.cost + self.heuristic) < (other.cost + other.heuristic)
DICA O tipo Optional indica que o valor de um tipo parametrizado pode ser referenciado
pela variável, ou a variável pode referenciar None .
DICA No início do arquivo, from __future__ import annotations permite que Node
referencie a si mesmo nas dicas de tipo de seus métodos. Sem ela, teríamos de colocar a dica
de tipo entre aspas, na forma de uma string (por exemplo, 'Node' ). Em futuras versões de
Python, não será necessário importar annotations . Consulte a PEP 563, “Postponed
Evaluation of Annotations” (Avaliação postergada das anotações) para obter mais
informações: http://mng.bz/pgzR.
Uma busca em profundidade em andamento deve manter o controle de
duas estruturas de dados: a pilha de estados (ou “lugares”) que estamos
considerando buscar, que chamaremos de frontier, e um conjunto de
estados que já buscamos, o qual chamaremos de explored. Enquanto
houver mais estados para visitar na fronteira, a DFS continuará
veri cando se eles são o objetivo (se um estado for o objetivo, a DFS
parará e o devolverá) e adicionando seus sucessores à fronteira. Ela
também marcará cada estado já pesquisado como explorado, de modo
que a busca não se torne cíclica, alcançando estados que tenham estados
visitados antes como sucessores. Se a fronteira estiver vazia, é sinal de que
não resta mais lugares para procurar.
Listagem 2.18 – Continuação de generic_search.py
def dfs(initial: T, goal_test: Callable[[T], bool], successors:
Callable[[T], List[T]]) -> Optional[Node[T]]:
# frontier correspondea os lugares que ainda não visitamos
frontier: Stack[Node[T]] = Stack()
frontier.push(Node(initial, None))
# explored representa os lugares em que já estivemos
explored: Set[T] = {initial}

# continua enquanto houver mais lugares para explorar


while not frontier.empty:
current_node: Node[T] = frontier.pop()
current_state: T = current_node.state
# se encontrarmos o objetivo, terminamos
if goal_test(current_state):
return current_node
# verifica para onde podemos ir em seguida e que ainda não tenha
sido explorado
for child in successors(current_state):
if child in explored: # ignora os filhos que já tenham sido
explorados
continue
explored.add(child)
frontier.push(Node(child, current_node))
return None # passamos por todos os lugares e não atingimos o objetivo

Se dfs() for bem-sucedida, ela devolverá o Node que encapsula o estado


referente ao objetivo. O caminho do início até o objetivo pode ser
reconstruído se zermos o caminho inverso, partindo desse Node e
caminhando em direção a seus antecessores usando a propriedade parent.
Listagem 2.19 – Continuação de generic_search.py
def node_to_path(node: Node[T]) -> List[T]:
path: List[T] = [node.state]
# trabalha no sentido inverso, do final para o início
while node.parent is not None:
node = node.parent
path.append(node.state)
path.reverse()
return path
Para exibição, será conveniente marcar o labirinto com o caminho que
teve sucesso, o estado inicial e o estado referente ao objetivo. Também
será conveniente ser capaz de remover um caminho para que possamos
testar algoritmos diferentes de busca no mesmo labirinto. Os dois
métodos a seguir devem ser acrescentados na classe Maze, em maze.py.
Listagem 2.20 – Continuação de maze.py
def mark(self, path: List[MazeLocation]):
for maze_location in path:
self._grid[maze_location.row][maze_location.column] = Cell.PATH
self._grid[self.start.row][self.start.column] = Cell.START
self._grid[self.goal.row][self.goal.column] = Cell.GOAL

def clear(self, path: List[MazeLocation]):


for maze_location in path:
self._grid[maze_location.row][maze_location.column] = Cell.EMPTY
self._grid[self.start.row][self.start.column] = Cell.START
self._grid[self.goal.row][self.goal.column] = Cell.GOAL

Foi uma longa jornada, mas, nalmente, estamos prontos para resolver o
labirinto.
Listagem 2.21 – Continuação de maze.py
if __name__ == "__main__":
# Teste da DFS
m: Maze = Maze()
print(m)
solution1: Optional[Node[MazeLocation]] = dfs(m.start, m.goal_test,
m.successors)
if solution1 is None:
print("No solution found using depth-first search!")
else:
path1: List[MazeLocation] = node_to_path(solution1)
m.mark(path1)
print(m)
m.clear(path1)

Uma solução bem-sucedida terá um aspecto semelhante a este:


S****X X
X *****
X*
XX******X
X*
X**X
X *****
*
X *X
*G

Os asteriscos representam o caminho que nossa função de busca em


profundidade encontrou, do início até o objetivo. Lembre-se de que,
como cada labirinto é gerado de modo aleatório, nem todos os labirintos
terão uma solução.

2.2.4 Busca em largura


Você pode ter percebido que os caminhos encontrados como solução
para os labirintos pela travessia em profundidade não parecem naturais.
Em geral, não são os caminhos mais curtos. Uma BFS (Breadth-First
Search, ou Busca em Largura) sempre encontra o caminho mais curto ao
analisar sistematicamente uma camada de nós mais distante do estado
inicial em cada iteração da busca. Há problemas especí cos em que uma
busca em profundidade tem mais chances de encontrar uma solução mais
rapidamente do que uma busca em largura, e vice-versa. Desse modo,
escolher entre as duas opções às vezes é uma solução de compromisso
entre a possibilidade de encontrar uma solução de forma rápida e a
certeza de encontrar o caminho mais curto até o objetivo (se houver um).
A Figura 2.5 exibe uma busca em largura em andamento em um labirinto.

Figura 2.5 – Em uma busca em largura, os elementos mais próximos da


posição inicial são pesquisados antes.
Para entender por que uma busca em profundidade às vezes devolve um
resultado de modo mais rápido que uma busca em largura, imagine que
você está procurando uma marca em uma camada especí ca de uma
cebola. Uma pessoa que esteja fazendo a procura com uma estratéga de
busca em profundidade poderia en ar uma faca até o meio da cebola e
analisar a esmo as partes cortadas. Se a camada marcada por acaso estiver
próxima da parte cortada, há uma chance de que quem está procurando a
encontre mais rapidamente do que outra pessoa que esteja usando uma
estratégia de busca em largura, pois ela descascará meticulosamente a
cebola, uma camada de cada vez.
Para obter uma imagem melhor sobre o motivo pelo qual uma busca em
largura sempre encontra a solução com o caminho mais curto, se houver,
considere tentar encontrar o caminho com o menor número de paradas
entre Boston e Nova York, de trem. Se você continuar avançando na
mesma direção e rando atingir um caminho sem saída (como na busca
em profundidade), poderá encontrar inicialmente um caminho até Seattle
antes que esse se conecte de volta a Nova York. No entanto, em uma
busca em largura, você veri cará antes todas as estações que estão a uma
estação de distância de Boston. Em seguida, veri cará todas as estações
que estão a duas paradas de distância de Boston. Então veri cará todas as
estações que estão a três paradas de distância de Boston. Você continuará
fazendo isso até encontrar Nova York. Assim, quando encontrá-la, você
saberá que encontrou a rota com o menor número de paradas, pois já
veri cou todas as estações com menos paradas a partir de Boston, e
nenhuma delas era Nova York.
Filas
Para implementar uma BFS, uma estrutura de dados conhecida como la
(queue) é necessária. Enquanto uma pilha é uma LIFO, uma la é uma
FIFO (First-In-First-Out, ou o primeiro que entra é o primeiro que sai).
Uma la é como uma la para usar um banheiro. A primeira pessoa que
entrar na la vai antes ao banheiro. Uma la tem, no mínimo, os mesmos
métodos push() e pop() de uma pilha. Com efeito, nossa implementação de
Queue (com base em um deque Python) é quase idêntica à nossa
implementação de Stack; as únicas mudanças estão na remoção de
elementos da extremidade esquerda do _container, em vez de remover da
extremidade direita, e a mudança de uma list para um deque. (Usei a
palavra “esquerda” nesse caso para me referir ao início da área de
armazenagem.) Os elementos da extremidade esquerda são os elementos
mais antigos que ainda estão no deque (quanto ao tempo de chegada),
portanto serão os primeiros elementos a serem removidos.
Listagem 2.22 – Continuação de generic_search.py
class Queue(Generic[T]):
def __init__(self) -> None:
self._container: Deque[T] = Deque()

@property
def empty(self) -> bool:
return not self._container # negação é verdadeira para um contêiner
vazio
def push(self, item: T) -> None:
self._container.append(item)

def pop(self) -> T:


return self._container.popleft() # FIFO

def __repr__(self) -> str:


return repr(self._container)
DICA Por que a implementação de Queue utiliza um deque como base para armazenagem,
enquanto a implementação de Stack usou uma list ? Isso tem a ver com o local de
remoção (pop). Em uma pilha, inserimos e removemos da direita. Em uma la, inserimos à
direita também, porém removemos da esquerda. A estrutura de dados list de Python tem
remoções e cientes da direita, mas não da esquerda. Um deque é capaz de remover itens de
modo e caz de qualquer lado. Como resultado, há um método embutido em deque chamado
popleft() , mas não há nenhum método equivalente em list . Sem dúvida, você poderia
encontrar outras maneiras de usar uma list como base para armazenagem de uma la, mas
seriam menos e cientes. Remover da esquerda em um deque é uma operação de
complexidade O(1), enquanto é uma operação O(n) em uma list . No caso da list , depois
de fazer uma remoção da esquerda, cada elemento subsequente deverá ser movido de uma
posição para a esquerda, fazendo com que ela seja ine ciente.

Algoritmo de BFS
Por incrível que pareça, o algoritmo para uma busca em largura é idêntico
ao algoritmo de uma busca em profundidade, com a fronteira alterada,
passando de uma pilha para uma la. Modi car a fronteira de uma pilha
para uma la altera a ordem com que os estados são pesquisados e
garante que os estados mais próximos ao estado inicial sejam pesquisados
antes.
Listagem 2.23 – Continuação de generic_search.py
def bfs(initial: T, goal_test: Callable[[T], bool], successors:
Callable[[T], List[T]]) -> Optional[Node[T]]:
# frontier corresponde aos lugares que ainda devemos visitar
frontier: Queue[Node[T]] = Queue()
frontier.push(Node(initial, None))
# explored representa os lugares em que já estivemos
explored: Set[T] = {initial}

# continua enquanto houver mais lugares para explorar


while not frontier.empty:
current_node: Node[T] = frontier.pop()
current_state: T = current_node.state
# se encontrarmos o objetivo, terminamos
if goal_test(current_state):
return current_node
# verifica para onde podemos ir em seguida e que ainda não tenha
sido explorado
for child in successors(current_state):
if child in explored: # ignora os filhos que já tenham sido
explorados
continue
explored.add(child)
frontier.push(Node(child, current_node))
return None # passamos por todos os lugares e não atingimos o objetivo

Se você tentar executar bfs(), verá que ele sempre encontrará a solução de
caminho mais curto para o labirinto em questão. O teste a seguir foi
adicionado logo após o anterior na seção if __name__ == "__main__": do
arquivo, de modo que os resultados para o mesmo labirinto poderão ser
comparados.
Listagem 2.24 – Continuação de maze.py
# Teste da BFS
solution2: Optional[Node[MazeLocation]] = bfs(m.start, m.goal_test,
m.successors)
if solution2 is None:
print("No solution found using breadth-first search!")
else:
path2: List[MazeLocation] = node_to_path(solution2)
m.mark(path2)
print(m)
m.clear(path2)

É incrível que você possa manter um algoritmo igual, alterando apenas a


estrutura de dados que ele acessa, e obtenha resultados extremamente
distintos. A seguir, apresentamos o resultado da chamada a bfs() no
mesmo labirinto para o qual chamamos dfs() antes. Observe como o
caminho marcado pelos asteriscos é mais direto, do início até o objetivo,
em comparação com o exemplo anterior.
S X X
*X
* X
*XX X
* X
* X X
*X
*
* X X
*********G

2.2.5 Busca A*
Descascar uma cebola, camada por camada, pode demorar bastante –
assim como uma busca em largura. Do mesmo modo que uma BFS, uma
busca A* tem como objetivo encontrar o caminho mais curto de um
estado inicial até um estado visado. De modo diferente da implementação
anterior de BFS, uma busca A* utiliza uma combinação entre uma função
de custo e uma função heurística para manter o foco da busca em
caminhos com mais chances de chegar ao objetivo rapidamente.
A função de custo, g(n), analisa o custo para chegar a um estado em
particular. No caso de nosso labirinto, seria a quantidade de passos
anteriores que tivemos de dar para chegar ao estado em questão. A
função heurística, h(n), fornece uma estimativa do custo para ir do estado
em questão até o estado que representa o objetivo. É possível provar que,
se h(n) é uma heurística admissível, o caminho nal encontrado será
ótimo. Uma heurística admissível é aquela que jamais superestima o custo
para alcançar o objetivo. Em um plano bidimensional, um exemplo é a
heurística da distância em linha reta, pois uma linha reta será sempre o
caminho mais curto.1
O custo total para qualquer estado considerado é f(n), que é
simplesmente a combinação entre g(n) e h(n). Com efeito, f(n) = g(n) +
h(n). Ao escolher o próximo estado da fronteira a ser explorado, uma
busca A* escolherá o estado com o menor f(n). É assim que ela se
distingue da BFS e da DFS.
Filas de prioridade
Para escolher o estado com o menor f(n) na fronteira, uma busca A* usa
uma la de prioridades como a estrutura de dados para a sua fronteira.
Uma la de prioridades mantém seus elementos em uma ordem interna,
de modo que o primeiro elemento removido será sempre o elemento de
mais alta prioridade. (Em nosso caso, o item de mais alta prioridade é
aquele com o menor f(n).) Em geral, isso implica o uso de um heap
binário internamente, o que resulta em inserções com complexidade O(lg
n) e remoções com O(lg n).
A biblioteca-padrão de Python contém funções heappush() e heappop()
que receberão uma lista e a manterão como um heap binário. Podemos
implementar uma la de prioridades construindo um wrapper no em
torno dessas funções da biblioteca-padrão. Nossa classe PriorityQueue será
semelhante às nossas classes Stack e Queue, com os métodos push() e pop()
modi cados de modo a usarem heappush() e heappop().
Listagem 2.25 – Continuação de generic_search.py
class PriorityQueue(Generic[T]):
def __init__(self) -> None:
self._container: List[T] = []

@property
def empty(self) -> bool:
return not self._container # negação é verdadeira para um contêiner
vazio
def push(self, item: T) -> None:
heappush(self._container, item) # insere de acordo com a prioridade

def pop(self) -> T:


return heappop(self._container) # remove de acordo com a prioridade

def __repr__(self) -> str:


return repr(self._container)

Para determinar a prioridade de um elemento em articular versus outro do


mesmo tipo, use heappush() e heappop() e compare-os com o operador <. É
por isso que tivemos de implementar __lt__() antes em Node. Um Node é
comparado com outro observando seus respectivos f(n), e f(n) é
simplesmente a soma das propriedades cost e heuristic.
Heurística
Uma heurística é uma intuição sobre o modo de resolver um problema.2
No caso da resolução de labirintos, uma heurística tem como objetivo
escolher o melhor local do labirinto para pesquisar na sequência, na
jornada para atingir o objetivo. Em outras palavras, é um palpite bem
fundamentado acerca dos nós da fronteira que estão mais próximos do
objetivo. Conforme mencionamos antes, se uma heurística usada com
uma busca A* gerar um resultado relativo preciso e for admissível (jamais
superestima a distância), então A* fornecerá o caminho mais curto.
Heurísticas que calculam valores menores acabam resultando em uma
busca que passa por mais estados, enquanto aquelas que se aproximam
mais da distância real exata (mas que não as ultrapassam, pois isso as
tornaria inadmissíveis) resultam em umanos estados. Desse modo, as
heurísticas ideais se aproximam o máximo possível da distância real, sem
jamais a ultrapassar.
Distância euclidiana
Conforme aprendemos em geometria, o caminho mais curto entre dois
pontos é uma linha reta. Então, faz sentido que a heurística de uma linha
reta seja sempre admissível para o problema da resolução de labirintos. A
distância euclidiana, derivada do teorema de Pitágoras, de ne que
distância = √((diferença em x) 2 + (diferença em y) 2) .
Em nossos labirintos, a
diferença em x é equivalente à diferença em colunas entre duas posições
do labirinto, e a diferença em y é equivalente à diferença em linhas.
Observe que implementamos isso em maze.py.
Listagem 2.26 – Continuação de maze.py
def euclidean_distance(goal: MazeLocation) -> Callable[[MazeLocation],
float]:
def distance(ml: MazeLocation) -> float:
xdist: int = ml.column - goal.column
ydist: int = ml.row - goal.row
return sqrt((xdist * xdist) + (ydist * ydist))
return distance

euclidean_distance() é uma função que devolve outra função. Linguagens


como Python com suporte para funções de primeira classe possibilitam
usar esse padrão interessante. distance() captura o goal MazeLocation
recebido por euclidean_distance(). Capturar signi ca que distance() pode
referenciar essa variável sempre que for chamada (permanentemente). A
função devolvida faz uso de goal para fazer seus cálculos. Esse padrão
permite a criação de uma função que exija menos parâmetros. A
fuistance() devolvida recebe apenas a posição inicial no labirinto como
argumento e “conhece” permanentemente o objetivo.
A Figura 2.6 ilustra a distância euclidiana no contexto de uma grade,
como as ruas de Manhattan.
Figura 2.6 – A distância euclidiana é o comprimento de uma linha reta do
ponto de partida até o objetivo.
Distância de Manhattan
A distância euclidiana é interessante, mas, em nosso problema especí co
(um labirinto no qual podemos nos deslocar apenas em uma de quatro
direções), podemos fazer algo melhor. A distância de Manhattan tem
origem na forma de percorrer as ruas de Manhattan, a região mais famosa
da cidade de Nova York, que está organizada em um padrão de grade.
Para ir de um lugar para outro lugar qualquer em Manhattan, uma pessoa
deve andar por um determinado número de quarteirões na horizontal e
determinado número de quarteirões na vertical. (Praticamente não há
ruas diagonais em Manhattan.) A distância de Manhattan é obtida
simplesmente calculando a diferença de linhas entre duas posições do
labirinto e somando-a com a diferença de colunas. A Figura 2.7 ilustra a
distância de Manhattan.
Figura 2.7 – Na distância de Manhattan, não há diagonais. O caminho deve
percorrer linhas paralelas ou perpendiculares.
Listagem 2.27 – Continuação de maze.py
def manhattan_distance(goal: MazeLocation) -> Callable[[MazeLocation],
float]:
def distance(ml: MazeLocation) -> float:
xdist: int = abs(ml.column - goal.column)
ydist: int = abs(ml.row - goal.row)
return (xdist + ydist)
return distance
Pelo fato de essa heurística estar mais em consonância com a realidade
exata de como navegar por nossos labirintos (mover-se na vertical e na
horizontal, em vez de percorrer linhas diagonais retas), ela se aproxima
mais da verdadeira distância entre qualquer ponto e o objetivo em um
labirinto, em comparação com a distância euclidiana. Assim, quando uma
busca A* é usada em conjunto com a distância de Manhattan, o resultado
será uma busca por menos estados do que em uma busca A* combinada
com a distância euclidiana em nossos labirintos. Os caminhos
encontrados como solução continuarão sendo ótimos, pois a distância de
Manhattan é admissível (jamais superestima a distância) para labirintos
em que apenas quatro direções para o movimento são permitidas.
Algoritmo A*
Para ir da BFS para a busca A*, precisamos fazer várias modi cações
pequenas. A primeira é modi car a fronteira, passando de uma la para
uma la de prioridades. Desse modo, os nós removidos da fronteira serão
aqueles com o menor f(n). A segunda é alterar o conjunto explorado para
que seja um dicionário. Um dicionário nos permitirá manter o controle
do menor custo (g(n)) de cada nó que possamos visitar. Com a função
heurística agora em ação, é possível que alguns nós sejam visitados duas
vezes se a heurística estiver inconsistente. Se o nó encontrado pela nova
rota tiver um custo menor para ser alcançado em comparação com a vez
anterior em que ele foi visitado, daremos preferência a essa nova rota.
Para simpli car, a função astar() não aceita uma função de cálculo de
custo como parâmetro. Em vez disso, consideramos apenas que cada
passo em nosso labirinto tem custo igual a 1. Cada novo Node tem um
custo atribuído com base nessa fórmula simples, bem como uma
pontuação heurística que utiliza uma nova função passada como
parâmetro para a função de busca, a qual se chama heuristic(). Afora
essas mudanças, astar() é muito semelhante a bfs(). Analise-as lado a lado
para comparar.
Listagem 2.28 – generic_search.py
def astar(initial: T, goal_test: Callable[[T], bool], successors:
Callable[[T], List[T]], heuristic: Callable[[T], float]) ->
Optional[Node[T]]:
# frontier corresponde aos lugares que ainda devemos visitar
frontier: PriorityQueue[Node[T]] = PriorityQueue()
frontier.push(Node(initial, None, 0.0, heuristic(initial)))
# explored representa os lugares em que já estivemos
explored: Dict[T, float] = {initial: 0.0}

# continua enquanto houver mais lugares para explorar


while not frontier.empty:
current_node: Node[T] = frontier.pop()
current_state: T = current_node.state
# se encontrarmos o objetivo, terminamos
if goal_test(current_state):
return current_node
# verifica para onde podemos ir em seguida e que ainda não tenha
sido explorado
for child in successors(current_state):
new_cost: float = current_node.cost + 1 # 1 supõe uma grade, é
necessária
# uma função de custo para aplicações mais sofisticadas

if child not in explored or explored[child] > new_cost:


explored[child] = new_cost
frontier.push(Node(child, current_node, new_cost,
heuristic()
return None # passamos por todos os lugares e não atingimos o objetivo

Parabéns. Se você nos acompanhou até agora, não só aprendeu a resolver


um labirinto, mas também conheceu algumas funções de busca genéricas
que poderão ser usadas em várias aplicações diferentes de busca. A DFS e
a BFS são apropriadas para vários conjuntos menores de dados e estados,
nos quais o desempenho não seja crítico. Em algumas situações, a DFS
terá um desempenho melhor que a BFS, mas a BFS tem a vantagem de
sempre fornecer um caminho ótimo. O interessante é que a BFS e a DFS
têm implementações idênticas, diferenciadas somente pelo uso de uma
la em vez de utilizar uma pilha para a fronteira. A busca A*, um pouco
mais complicada, em conjunto com uma boa heurística admissível e
consistente, não só fornece caminhos ótimos como também tem um
desempenho muito melhor que a BFS. Como todas essas três funções
foram implementadas de modo genérico, para usá-las em praticamente
qualquer espaço de busca, basta executar apenas um import
generic_search .
Vá em frente e experimente usar astar() com o mesmo labirinto que está
na seção de testes de maze.py.
Listagem 2.29 – Continuação de maze.py
# Teste de A*
distance: Callable[[MazeLocation], float] = manhattan_distance(m.goal)
solution3: Optional[Node[MazeLocation]] =
astar(m.start, m.goal_test, m.successors, distance)
if solution3 is None:
print("No solution found using A*!")
else:
path3: List[MazeLocation] = node_to_path(solution3)
m.mark(path3)
print(m)

De modo interessante, será um pouco diferente da saída de bfs(), ainda


que tanto bfs() como astar() encontremhos ótimos (equivalentes no
tamanho). Em razão de sua heurística, astar() leva imediatamente a uma
diagonal em direção ao objetivo. Em última instância, ela buscará menos
estados do que bfs(), resultando em um melhor desempenho. Adicione
um contador de estados para cada um se quiser comprovar por si mesmo.
S** X X
X**
* X
XX* X
X*
X**X
X ****
*
X * X
**G

2.3 Missionários e canibais


Três missionários e três canibais estão na margem oeste de um rio. Eles
têm uma canoa capaz de transportar duas pessoas, e todos devem passar
para a margem leste do rio. Não pode haver mais canibais do que
missionários em qualquer lado do rio; do contrário, os canibais
devorarão os missionários. Além disso, a canoa deve ter pelo menos uma
pessoa a bordo para atravessar o rio. Qual sequência de travessias será
bem-sucedida para levar todo o grupo para o outro lado do rio? A Figura
2.8 ilustra o problema.
Figura 2.8 – Os missionários e os canibais devem usar a única canoa para
que todos cruzem o rio, de oeste para leste. Se os canibais estiverem em um
número maior do que os missionários, eles devorarão os últimos.

2.3.1 Representando o problema


Representaremos o problema com uma estrutura que mantenha o
controle da margem oeste. Quantos missionários e canibais estão na
margem oeste? O barco está na margem oeste? Depois que tivermos
conhecimento dessas informações, será possível descobrir quem e o que
está na margem leste, pois tudo que não estiver na margem oeste estará na
margem leste.
Inicialmente, criaremos uma pequena variável conveniente para manter
o controle do número máximo de missionários ou de canibais. Em
seguida, de niremos a classe principal.
Listagem 2.30 – missionaries.py
from __future__ import annotations
from typing import List, Optional
from generic_search import bfs, Node, node_to_path

MAX_NUM: int = 3

class MCState:
def __init__(self, missionaries: int, cannibals: int, boat: bool) ->
None:
self.wm: int = missionaries # missionários na margem oeste
self.wc: int = cannibals # canibais na margem oeste
self.em: int = MAX_NUM - self.wm # missionários na margem leste
self.ec: int = MAX_NUM - self.wc # canibais na margem leste
self.boat: bool = boat

def __str__(self) -> str:


return ("On the west bank there are {} missionaries and {}
cannibals.\n"
"On the east bank there are {} missionaries and {}
cannibals.\n"
"The boat is on the {} bank.")\
.format(self.wm, self.wc, self.em, self.ec, ("west" if self.boat
else "east"))

A classe MCState é inicializada com base no número de missionários e de


canibais na margem oeste, bem como com a localização do barco. Ela
também sabe como exibir a si mesma de forma elegante, o que será
importante mais tarde, quando exibirmos a solução do problema.
Trabalhar com nossas funções de busca atuais signi ca que devemos
de nir uma função para testar se um estado corresponde ao objetivo, e
uma função para encontrar os sucessores de qualquer estado. A função
para testar o objetivo, como no problema da resolução de labirintos, é
bem simples. O objetivo é simplesmente atingir um estado permitido
(legal), com todos os missionários e canibais na margem leste.
Adicionaremos essa função como um método de MCState.
Listagem 2.31 – Continuação de missionaries.py
def goal_test(self) -> bool:
return self.is_legal and self.em == MAX_NUM and self.ec == MAX_NUM

Para criar uma função para os sucessores, é necessário percorrer todos os


movimentos possíveis que possam ser feitos de uma margem para a outra,
e então veri car se cada um desses movimentos resultará em um estado
permitido. Lembre-se de que um estado permitido é um estado em que os
canibais não estejam em um número maior do que o número de
missionários em qualquer margem. Para determinar isso, podemos de nir
uma propriedade conveniente (como um método de MCState) que
veri que se um estado é permitido.
Listagem 2.32 – Continuação de missionaries.py
@property
def is_legal(self) -> bool:
if self.wm < self.wc and self.wm > 0:
return False
if self.em < self.ec and self.em > 0:
return False
return True

A função de sucessores é um pouco extensa, por questões de clareza. Ela


tenta adicionar todas as combinações possíveis de uma ou duas pessoas
cruzando o rio, a partir da margem em que está a canoa no momento.
Depois de ter adicionado todos os movimentos possíveis, um ltro é
aplicado para obter aquelas que são realmente permitidas usando uma list
comprehension (abrangência de listas). Novamente, esse é um método de
MCState .

Listagem 2.33 – Continuação de missionaries.py


def successors(self) -> List[MCState]:
sucs: List[MCState] = []
if self.boat: # barco na margem oeste
if self.wm > 1:
sucs.append(MCState(self.wm - 2, self.wc, not self.boat))
if self.wm > 0:
sucs.append(MCState(self.wm - 1, self.wc, not self.boat))
if self.wc > 1:
sucs.append(MCState(self.wm, self.wc - 2, not self.boat))
if self.wc > 0:
sucs.append(MCState(self.wm, self.wc - 1, not self.boat))
if (self.wc > 0) and (self.wm > 0):
sucs.append(MCState(self.wm - 1, self.wc - 1, not self.boat))
else: # barco na margem leste
if self.em > 1:
sucs.append(MCState(self.wm + 2, self.wc, not self.boat))
if self.em > 0:
sucs.append(MCState(self.wm + 1, self.wc, not self.boat))
if self.ec > 1:
sucs.append(MCState(self.wm, self.wc + 2, not self.boat))
if self.ec > 0:
sucs.append(MCState(self.wm, self.wc + 1, not self.boat))
if (self.ec > 0) and (self.em > 0):
sucs.append(MCState(self.wm + 1, self.wc + 1, not self.boat))
return [x for x in sucs if x.is_legal]

2.3.2 Solução
Temos agora todos os ingredientes à disposição para resolver o problema.
Lembre-se de que, quando resolvemos um problema usando as funções
de busca bfs(), dfs() e astar(), recebemos um Node que, em última análise,
será convertido com node_to_path() em uma lista de estados que levará a
uma solução. O que precisamos ainda é de uma forma de converter essa
lista em uma sequência de passos compreensíveis a ser exibida,
resolvendo o problema dos missionários e canibais.
A função display_solution() converte o caminho de uma solução em
uma saída a ser exibida – uma solução para o problema, legível aos seres
humanos. Ela funciona iterando por todos os estados que estão no
caminho da solução, ao mesmo tempo que mantém o controle do último
estado também. A função observa a diferença entre o último estado e o
estado no qual está iterando no momento a m de descobrir quantos
missionários e canibais cruzaram o rio e em qual direção.
Listagem 2.34 – Continuação de missionaries.py
def display_solution(path: List[MCState]):
if len(path) == 0: # verificação de sanidade
return
old_state: MCState = path[0]
print(old_state)
for current_state in path[1:]:
if current_state.boat:
print("{} missionaries and {} cannibals moved from the east bank
to the west bank.\n"
.format(old_state.em - current_state.em, old_state.ec -
current_state.ec))
else:
print("{} missionaries and {} cannibals moved from the west bank
to the east bank.\n"
.format(old_state.wm - current_state.wm, old_state.wc -
current_state.wc))
print(current_state)
old_state = current_state

A função display_solution() tira proveito do fato de que MCState sabe como


exibir um resumo elegante de si mesma usando __str__().
Nossa última tarefa é resolver de fato o problema dosmissionários e
canibais. Para isso, podemos reutilizar convenientemente uma função de
busca, a qual já está implementada, pois nós as implementamos de forma
genérica. Essa solução utiliza bfs() (porque usar dfs() exigiria marcar
estados referencialmente diferentes com o mesmo valor como iguais, e
astar() exigiria uma heurística).

Listagem 2.35 – Continuação de missionaries.py


if __name__ == "__main__":
start: MCState = MCState(MAX_NUM, MAX_NUM, True)
solution: Optional[Node[MCState]] = bfs(start, MCState.goal_test,
MCState.successors)
if solution is None:
print("No solution found!")
else:
path: List[MCState] = node_to_path(solution)
display_solution(path)

É interessante ver quão exíveis podem ser nossas funções de busca


genéricas. Elas podem ser facilmente adaptadas para solucionar um
conjunto diversi cado de problemas. Você verá uma saída parecida com
esta (abreviada):
On the west bank there are 3 missionaries and 3 cannibals.
On the east bank there are 0 missionaries and 0 cannibals.
The boast is on the west bank.
0 missionaries and 2 cannibals moved from the west bank to the east bank.
On the west bank there are 3 missionaries and 1 cannibals.
On the east bank there are 0 missionaries and 2 cannibals.
The boast is on the east bank.
0 missionaries and 1 cannibals moved from the east bank to the west bank.

On the west bank there are 0 missionaries and 0 cannibals.
On the east bank there are 3 missionaries and 3 cannibals.
The boast is on the east bank.

2.4 Aplicações no mundo real


As buscas desempenham algum papel em todos os softwares úteis. Em
alguns casos, elas são o elemento central (Google Search, Spotlight,
Lucene); em outros, são a base para utilizar as estruturas subjacentes à
armazenagem de dados. Conhecer o algoritmo de busca correto a ser
aplicado em uma estrutura de dados é essencial para o desempenho. Por
exemplo, seria muito custoso usar uma busca linear em vez de uma busca
binária em uma estrutura de dados ordenada.
O A* é um dos algoritmos mais implantados para busca de caminhos
(path nding). Só é superado por algoritmos que fazem cálculos prévios
no espaço de busca. Em uma busca cega (blind search), o A* ainda está
para ser vencido em todos os cenários, e isso tem feito dele um
componente essencial para tudo, de planejamento de rotas a descobrir o
caminho mais curto ou fazer parse de uma linguagem de programação. A
maioria dos softwares de mapa que fornecem rotas (pense no Google
Maps) utiliza o algoritmo de Dijkstra (do qual o A* é uma variante) para
navegação. (Mais informações sobre o algoritmo de Dijkstra no Capítulo
4.) Sempre que uma personagem de IA em um jogo encontra o caminho
mais curto de uma extremidade a outra no mundo, sem intervenção
humana, provavelmente ela estará usando A*.
A busca em largura e a busca em profundidade muitas vezes são a base
para algoritmos de busca mais complexos, como a busca de custo
uniforme (uniform-cost search) e a busca com backtracking (qmos no
próximo capítulo). A busca em largura com frequência é uma técnica
su ciente para encontrar o caminho mais curto em um grafo
razoavelmente pequeno. Contudo, em razão de sua semelhança com o A*,
é fácil fazer uma troca para A* se houver uma boa heurística em um grafo
maior.
2.5 Exercícios
1. Mostre a vantagem da busca binária em comparação com a busca
linear quanto ao desempenho, criando uma lista com um milhão de
números e calculando o tempo que demora para que as funções
linear_ contains() e binary_contains() de nidas neste capítulo
encontrem diversos números na lista.
2. Acrescente um contador em dfs(), bfs() e astar() para ver quantos
estados cada uma busca no mesmo labirinto. Determine os
contadores para 100 labirintos diferentes a m de obter resultados
estatisticamente signi cativos.
3. Encontre uma solução para o problema dos missionários e canibais
para um número inicial diferente de missionários e canibais. Dica:
você talvez precise adicionar métodos para sobrescrever __eq__() e
__hash__() em MCState .

1 Para mais informações sobre heurística, veja o livro Arti cial Intelligence: A Modern Approach,
3ª edição (Pearson, 2010), página 94, de Stuart Russell e Peter Norvig (edição publicada no
Brasil: Inteligência Arti cial [Campus, 2013]).
2 Para mais informações sobre heurística em busca de caminhos (path nding) com A*, consulte o
capítulo “Heuristics” em Amit’s Thoughts on Path nding (Ideias de Amit sobre busca de
caminhos) de Amit Patel, em http://mng.bz/z7O4.
CAPÍTULO 3

Problemas de satisfação de restrições

Um grande número de problemas que as ferramentas de computação


estão acostumadas a resolver pode ser amplamente classi cado como
CSP (Constraint-Satisfaction Problems, ou Problemas de Satisfação de
Restrições). Os CSPs são compostos de variáveis com valores possíveis,
que se encontram em intervalos conhecidos como domínios. As restrições
entre as variáveis devem ser satisfeitas para que os problemas de satisfação
de restrições sejam resolvidos. Esses três conceitos básicos – variáveis,
domínios e restrições – são fáceis de entender, e o fato de serem
genéricos é fundamental para a ampla aplicabilidade da resolução de
problemas de satisfação de restrições.
Vamos considerar um problema como exemplo. Suponha que você
esteja tentando agendar uma reunião na sexta-feira para Joe, Mary e Sue.
Sue deve estar na reunião com pelo menos mais uma pessoa. Para esse
problema de agendamento, as três pessoas – Joe, Mary e Sue – podem ser
as variáveis. Os respectivos horários disponíveis podem ser o domínio
para cada variável. Por exemplo, a variável Mary tem como domínio 14h,
15h e 16h. Esse problema também tem duas restrições. Uma é o fato de
Sue ter de estar na reunião. A outra é que deve haver pelo menos duas
pessoas participando dela. Um código que resolva o problema da
satisfação de restrições receberá as três variáveis, os três domínios e as
duas restrições, e então resolverá o problema sem que o usuário tenha de
explicar exatamente como. A Figura 3.1 ilustra esse exemplo.
Linguagens de programação como Prolog e Picat têm recursos
embutidos para resolver problemas de satisfação de restrições. A técnica
usual em outras linguagens consiste em construir um framework que
incorpore uma busca com backtracking (backtracking search) e várias
heurísticas para melhorar o desempenho dessa busca. Neste capítulo,
construiremos inicialmente um framework para CSPs, que os resolva
utilizando uma busca recursiva simples com backtracking. Em seguida,
usaremos o framework para solucionar diversos exemplos de problemas
diferentes.

Figura 3.1 – Problemas de agendamento são uma aplicação clássica dos


frameworks de satisfação de restrições.

3.1 Construindo um framework para problemas de satisfação de


restrições
As restrições serão de nidas usando uma classe Constraint . Cada
Constraint é constituída das variables que ela restringe e um método,
satisfied() , que veri ca se ela é satisfeita. Determinar se uma restrição é
satisfeita é a lógica principal presente na de nição de um problema
especí co de satisfação de restrições. A implementação default deve ser
sobrescrita. Com efeito, ela deve ser, pois vamos de nir nossa classe
Constraint como uma classe-base abstrata. Classes-base abstratas não
devem ser instanciadas. Apenas as subclasses que sobrescrevem e
implementam seus @abstractmethods devem ser usadas.
Listagem 3.1 – csp.py
from typing import Generic, TypeVar, Dict, List, Optional
from abc import ABC, abstractmethod

V = TypeVar('V') # tipo para variável


D = TypeVar('D') # tipo para domínio

# Classe-base para todas as restrições


class Constraint(Generic[V, D], ABC):
# As variáveis sujeitas à restrição
def __init__(self, variables: List[V]) -> None:
self.variables = variables

# deve ser sobrescrito pelas subclasses


@abstractmethod
def satisfied(self, assignment: Dict[V, D]) -> bool:
...
DICA Classes-base abstratas servem como templates em uma hierarquia de classes. São mais
comuns em outras linguagens, como C++, como um recurso disponível ao usuário, em
comparação com Python. De fato, elas só foram introduzidas em Python aproximadamente
na metade do tempo de vida da linguagem. Apesar disso, muitas das classes de coleção da
biblioteca-padrão de Python são implementadas com classes-base abstratas. O conselho
geral é não utilizar essas classes em seu próprio código, a menos que você tenha certeza de
que está construindo um framework com base no qual outras pessoas desenvolverão seus
códigos, e não apenas uma hierarquia de classes para uso interno. Para mais informações,
consulte o Capítulo 11 de Python Fluente de Luciano Ramalho (Novatec, 2015).
A peça central de nosso framework de satisfação de restrições será uma
classe chamada CSP. Essa classe é o ponto de união entre variáveis,
domínios e restrições. Quanto às dicas de tipo, ela usa genéricos
(generics) para que seja su cientemente exível de modo a trabalhar com
qualquer tipo de variável e valores de domínio (chaves V e valores de
domínio D). Na CSP, os conjuntos variables, domains e constraints são dos
tipos esperados. A coleção variables é uma list de variáveis, domains é um
dict que mapeia variáveis a listas de possíveis valores (os domínios dessas
variáveis) e constraints é um dict que mapeia cada variável a uma list das
restrições impostas a ela.
Listagem 3.2 – Continuação de csp.py
# Um problema de satisfação de restrições é composto de variáveis do tipo V
# que têm intervalos de valores conhecidos como domínios do tipo D e
restrições
# que determinam se a escolha de domínio de uma variável em particular é
válida
class CSP(Generic[V, D]):
def __init__(self, variables: List[V], domains: Dict[V, List[D]]) ->
None:
self.variables: List[V] = variables # variáveis a serem restringidas
self.domains: Dict[V, List[D]] = domains # domínio de cada variável
self.constraints: Dict[V, List[Constraint[V, D]]] = {}
for variable in self.variables:
self.constraints[variable] = []
if variable not in self.domains:
raise LookupError("Every variable should have a domain
assigned to it.")

def add_constraint(self, constraint: Constraint[V, D]) -> None:


for variable in constraint.variables:
if variable not in self.variables:
raise LookupError("Variable in constraint not in CSP")
else:
self.constraints[variable].append(constraint)

A função de inicialização __init__() cria o dict constraints. O método


add_constraint() percorre todas as variáveis afetadas por uma dada
restrição e a adiciona no mapeamento constraints para cada uma delas.
Os dois métodos têm uma veri cação de erro básica implementada e
lançarão uma exceção se uma variable não tiver um domínio ou se uma
constraint for imposta a uma variável inexistente.
Como sabemos se uma dada con guração de variáveis e valores de
domínio selecionados satisfazem as restrições? Chamaremos uma dada
con guração desse tipo de “assignment” (atribuição). Precisamos de uma
função que veri que todas as restrições para uma dada variável em
relação a uma atribuição a m de ver se o valor da variável na atribuição
está de acordo com as restrições. A seguir, implementamos uma função
consistent() como um método de CSP .

Listagem 3.3 – Continuação de csp.py


# Verifica se a atribuição de valor é consistente consultando todas as
restrições
# para a dada variável em relação a essa atribuição
def consistent(self, variable: V, assignment: Dict[V, D]) -> bool:
for constraint in self.constraints[variable]:
if not constraint.satisfied(assignment):
return False
return True

consistent() percorre todas as restrições para uma dada variável (sempre


será a variável que acabou de ser adicionada na atribuição) e veri ca se a
restrição é satisfeita, dada a nova atribuição. Se a atribuição satisfaz todas
as restrições, True será devolvido. Se alguma restrição imposta à variável
não for satisfeita, False será devolvido.
O framework de satisfação de restrições usará uma busca simples com
backtracking a m de encontrar soluções para os problemas. Segundo a
ideia de backtracking (retroceder), uma vez atingido um obstáculo em sua
busca, você deverá retroceder até o último ponto conhecido, anterior a
esse obstáculo e no qual uma decisão foi tomada, e deverá escolher um
caminho diferente. Se achar que isso se assemelha à busca em
profundidade que vimos no Capítulo 2, então você é perspicaz. A busca
com backtracking implementada na função backtracking_search() a seguir
é uma espécie de busca em profundidade recursiva, que combina ideias
que vimos nos capítulos 1 e 2. Essa função foi adicionada como um
método da classe CSP.
Listagem 3.4 – Continuação de csp.py
def backtracking_search(self, assignment: Dict[V, D] = {}) ->
Optional[Dict[V, D]]:
# assignment estará completa se todas as variáveis receberem uma
# atribuição (nosso caso de base)
if len(assignment) == len(self.variables):
return assignment
# obtém todas as variáveis que estão na CSP, mas não em assignment
unassigned: List[V] = [v for v in self.variables if v not in assignment]

# obtém todos os valores possíveis no domínio da primeira variável sem


atribuição
first: V = unassigned[0]
for value in self.domains[first]:
local_assignment = assignment.copy()
local_assignment[first] = value
# se continuamos consistentes, fazemos uma recursão (prosseguimos)
if self.consistent(first, local_assignment):
result: Optional[Dict[V, D]] =
self.backtracking_search(local_assignment)
# se não encontramos o resultado, faremos um backtracking
if result is not None:
return result
return None

Vamos descrever backtracking_search() linha a linha.


if len(assignment) == len(self.variables):
return assignment

O caso de base da busca recursiva é ter encontrado uma atribuição válida


para todas as variáveis. Uma vez que isso aconteça, devolvemos a primeira
ocorrência de uma solução válida. (Não continuamos a busca.)
unassigned: List[V] = [v for v in self.variables if v not in assignment]
first: V = unassigned[0]

Para selecionar uma nova variável cujo domínio exploraremos, basta


percorrer todas as variáveis e encontrar a primeira que não tenha uma
atribuição. Para isso, criamos uma list de variáveis que estão em
self.variables , mas não estão em assignment usando uma list
comprehension, e chamamos essa lista de unassigned. Em seguida, lemos
o primeiro valor de unassigned.
for value in self.domains[first]:
local_assignment = assignment.copy()
local_assignment[first] = value

Tentamos atribuir todos os valores possíveis do domínio para essa


variável, um de cada vez. A nova atribuição de cada valor é armazenada
em um dicionário local chamado local_assignment.
if self.consistent(first, local_assignment):
result: Optional[Dict[V, D]] =
self.backtracking_search(local_assignment)
if result is not None:
return result

Se a nova atribuição em local_assignment for consistente em relação a


todas as restrições (é isso que consistent() veri ca), continuamos
buscando recursivamente, com a nova atribuição de nida. Se a nova
atribuição se mostrar completa (o caso de base), devolvemos a nova
atribuição para a cadeia de recursão.
return None # sem solução
Por m, se passarmos por todos os valores possíveis do domínio de uma
variável em particular, mas não houver uma solução utilizando o conjunto
existente de atribuições, devolveremos None, sinalizando que não há
solução. Isso levará a um backtracking na cadeia de recursão, até o ponto
em que uma atribuição anterior diferente poderia ter sido feita.

3.2 Problema de coloração do mapa da Austrália


Suponha que você tenha um mapa da Austrália que queira colorir por
estado/território (os quais chamaremos coletivamente de “regiões”). Duas
regiões adjacentes não devem ter a mesma cor. Você é capaz de colorir as
regiões usando apenas três cores diferentes?
A resposta é sim. Tente você mesmo. (O modo mais fácil é imprimir um
mapa da Austrália com um fundo branco.) Como seres humanos,
podemos descobrir rapidamente a solução fazendo uma inspeção e com
um pouco de tentativa e erro. É um problema trivial, na verdade, e um
ótimo problema inicial para o nosso código de resolução de satisfação de
restrições com backtracking. A Figura 3.2 ilustra esse problema.
Figura 3.2 – Em uma solução para o problema da coloração do mapa da
Austrália, duas partes adjacentes da Austrália não podem ter a mesma cor.
Para modelar o problema como um CSP, é necessário de nir as
variáveis, os domínios e as restrições. As variáveis são as sete regiões da
Austrália (ao menos, as sete às quais nos restringiremos): Western
Australia (Austrália Ocidental), Northern Territory (Territórios do Norte),
South Australia (Austrália Meridional ou do Sul), Queensland, New
South Wales (Nova Gales do Sul), Victoria (Vitória) e Tasmania
(Tasmânia). Em nosso CSP, elas podem ser modeladas como strings. O
domínio de cada variável são as três cores diferentes que podem ser
atribuídas. (Usaremos vermelho, verde e azul.) As restrições são a parte
complicada. Duas regiões adjacentes não podem ter a mesma cor,
portanto, nossas restrições dependerão de qual região faz fronteira com
qual região. Podemos usar o que é conhecido como restrições binárias
(restrições entre duas variáveis). Cada par de regiões que compartilhe
uma fronteira também compartilhará uma restrição binária informando
que elas não podem ter a mesma cor atribuída.
Para implementar essas restrições binárias no código, precisamos criar
uma subclasse da classe Constraint. A subclasse MapColoringConstraint
aceitará duas variáveis em seu construtor: as duas regiões que
compartilham uma fronteira. O método satisfied() que ela sobrescreve
veri cará inicialmente se as duas regiões têm valores de domínio (cores)
atribuídos a elas; se uma delas não tiver, a restrição será trivialmente
satisfeita até que tenham. (Não pode haver con ito se uma região ainda
não tem uma cor.) Em seguida, será veri cado se as duas regiões tiveram
a mesma cor atribuída. Obviamente, haverá um con ito, o que signi ca
que a restrição não está sendo satisfeita, se as cores forem iguais.
A classe será apresentada a seguir por completo. A MapColoringConstraint
em si não é genérica no que concerne às dicas de tipo, mas é uma
subclasse de uma versão parametrizada da classe Constraint genérica, a
qual informa que tanto as variáveis como os domínios são do tipo str.
Listagem 3.5 – map_coloring.py
from csp import Constraint, CSP
from typing import Dict, List, Optional

class MapColoringConstraint(Constraint[str, str]):


def __init__(self, place1: str, place2: str) -> None:
super().__init__([place1, place2])
self.place1: str = place1
self.place2: str = place2

def satisfied(self, assignment: Dict[str, str]) -> bool:


# se uma das regiões não está na atribuição, ainda não é
# possível que suas cores estejam em conflito
if self.place1 not in assignment or self.place2 not in assignment:
return True
# verifica se a cor atribuída a place1 não é igual à
# cor atribuída a place2
return assignment[self.place1] != assignment[self.place2]
DICA super() às vezes é usado para chamar um método da superclasse, mas você também
pode usar o próprio nome da classe, como em Constraint.__init__([place1,
place2]) . Isso é particularmente conveniente quando lidamos com herança múltipla, para
que você saiba o método de qual superclasse você está chamando.
Agora que temos um modo de implementar as restrições entre as regiões,
resolver o problema da coloração do mapa da Austrália com nosso código
de resolução de CSP é simplesmente uma questão de preencher os
domínios e as variáveis e, em seguida, acrescentar as restrições.
Listagem 3.6 – Continuação de map_coloring.py
if __name__ == "__main__":
variables: List[str] = ["Western Australia", "Northern Territory", "South
Australia", "Queensland", "New South Wales", "Victoria", "Tasmania"]
domains: Dict[str, List[str]] = {}
for variable in variables:
domains[variable] = ["red", "green", "blue"]
csp: CSP[str, str] = CSP(variables, domains)
csp.add_constraint(MapColoringConstraint("Western Australia", "Northern
Territory"))
csp.add_constraint(MapColoringConstraint("Western Australia", "South
Australia"))
csp.add_constraint(MapColoringConstraint("South Australia", "Northern
Territory"))
csp.add_constraint(MapColoringConstraint("Queensland", "Northern
Territory"))
csp.add_constraint(MapColoringConstraint("Queensland", "South
Australia"))
csp.add_constraint(MapColoringConstraint("Queensland", "New South
Wales"))
csp.add_constraint(MapColoringConstraint("New South Wales", "South
Australia"))
csp.add_constraint(MapColoringConstraint("Victoria", "South Australia"))
csp.add_constraint(MapColoringConstraint("Victoria", "New South Wales"))
csp.add_constraint(MapColoringConstraint("Victoria", "Tasmania"))

Por m, backtracking_search() é chamado para encontrar uma solução.


Listagem 3.7 – Continuação de map_coloring.py
solution: Optional[Dict[str, str]] = csp.backtracking_search()
if solution is None:
print("No solution found!")
else:
print(solution)
Uma solução correta incluirá uma cor atribuída a cada região.
{'Western Australia': 'red', 'Northern Territory': 'green', 'South
Australia': 'blue', 'Queensland': 'red', 'New South Wales': 'green',
'Victoria': 'red', 'Tasmania': 'green'}
3.3 Problema das oito rainhas
Um tabuleiro de xadrez é uma grade de quadrados de oito por oito. Uma
rainha é uma peça de xadrez que pode se mover por qualquer quantidade
de quadrados do tabuleiro em qualquer linha, coluna ou diagonal. Uma
rainha atacará outra peça se, em um único movimento, puder se mover
para o quadrado em que está essa peça, sem pular por cima de outras
peças. (Em outras palavras, se a outra peça estiver na linha de visão da
rainha, essa peça poderá ser atacada por ela.) O problema das oito
rainhas propõe a questão sobre como oito rainhas podem ser
posicionadas em um tabuleiro de xadrez sem que nenhuma rainha possa
atacar outra. A Figura 3.3 ilustra esse problema.

Figura 3.3 – Em uma solução para o problema das oito rainhas (há várias),
duas rainhas quaisquer não podem ameaçar uma à outra.
Para representar os quadrados do tabuleiro, atribuiremos uma linha e
uma coluna, na forma de valores inteiros, a cada quadrado. Podemos
garantir que cada uma das oito rainhas não está na mesma coluna
simplesmente atribuindo as colunas de 1 a 8 a elas, sequencialmente. As
variáveis em nosso problema de satisfação de restrições podem ser
apenas a coluna da rainha em questão. Os domínios podem ser as linhas
possíveis (novamente, de 1 a 8). A listagem de código a seguir mostra o
nal de nosso arquivo, onde essas variáveis e domínios são de nidos.
Listagem 3.8 – queens.py
if __name__ == "__main__":
columns: List[int] = [1, 2, 3, 4, 5, 6, 7, 8]
rows: Dict[int, List[int]] = {}
for column in columns:
rows[column] = [1, 2, 3, 4, 5, 6, 7, 8]
csp: CSP[int, int] = CSP(columns, rows)

Para resolver o problema, precisaremos de uma restrição que veri que se


duas rainhas quaisquer estão na mesma linha ou diagonal. (A cada uma
delas, foi atribuída uma coluna sequencial diferente no início.) Veri car se
estão na mesma linha é trivial, mas veri car se estão na mesma diagonal
exige um pouco de matemática. Se duas rainhas quaisquer estiverem na
mesma diagonal, a diferença entre suas linhas será igual à diferença entre
suas colunas. Você é capaz de ver em que local essas veri cações ocorrem
em QueensConstraint? Observe que o código a seguir está no início de
nosso arquivo-fonte.
Listagem 3.9 – Continuação de queens.py
from csp import Constraint, CSP
from typing import Dict, List, Optional

class QueensConstraint(Constraint[int, int]):


def __init__(self, columns: List[int]) -> None:
super().__init__(columns)
self.columns: List[int] = columns

def satisfied(self, assignment: Dict[int, int]) -> bool:


# q1c = coluna da rainha 1, q1r = linha da rainha 1
for q1c, q1r in assignment.items():
# q2c = coluna da rainha 2
for q2c in range(q1c + 1, len(self.columns) + 1):
if q2c in assignment:
q2r: int = assignment[q2c] # q2r = linha da rainha 2
if q1r == q2r: # mesma linha?
return False
if abs(q1r - q2r) == abs(q1c - q2c): # mesma diagonal?
return False
return True # não há conflito

Tudo que resta a fazer é adicionar a restrição e executar a busca.


Voltaremos agora para o nal do arquivo.
Listagem 3.10 – Continuação de queens.py
csp.add_constraint(QueensConstraint(columns))
solution: Optional[Dict[int, int]] = csp.backtracking_search()
if solution is None:
print("No solution found!")
else:
print(solution)

Observe que conseguimos reutilizar o framework de resolução de


problemas de satisfação de restrições que construímos para a coloração
do mapa de modo razoavelmente simples para um tipo de problema
totalmente diferente. Eis a e cácia de escrever um código de forma
genérica! Os algoritmos devem ser implementados de modo que sejam
amplamente aplicáveis o máximo possível, a menos que uma otimização
de desempenho para uma aplicação em particular exija uma
especialização.
Uma solução correta atribuirá uma coluna e uma linha para cada
rainha.
{1: 1, 2: 5, 3: 8, 4: 6, 5: 3, 6: 7, 7: 2, 8: 4}

3.4 Caça-palavras
Um caça-palavras é uma grade de letras com palavras ocultas posicionada
em linhas, colunas e diagonais. Um jogador de caça-palavras tenta
encontrar as palavras ocultas analisando atentamente a grade. Encontrar
lugares para inserir as palavras de modo que todas sejam inseridas na
grade é uma espécie de problema de satisfação de restrições. As variáveis
são as palavras e os domínios são os possíveis lugares para inserir essas
palavras. A Figura 3.4 ilustra esse problema.
Figura 3.4 – Um caça-palavras clássico, como aqueles que você veria em um
livro de passatempos para crianças.
Por conveniência, nosso caça-palavras não incluirá palavras que se
sobreponham. Você poderá aperfeiçoá-lo para permitir que haja
sobreposição de palavras, como um exercício.
A grade para esse problema de caça-palavras não é tão diferente dos
labirintos do Capítulo 2. Alguns dos tipos de dados a seguir deverão ser
familiares.
Listagem 3.11 – word_search.py
from typing import NamedTuple, List, Dict, Optional
from random import choice
from string import ascii_uppercase
from csp import CSP, Constraint

Grid = List[List[str]] # alias de tipo para grades

class GridLocation(NamedTuple):
row: int
column: int
Inicialmente, preencheremos a grade com as letras do alfabeto
(ascii_uppercase). Também precisaremos de uma função para exibir a
grade.
Listagem 3.12 – Continuação de word_search.py
def generate_grid(rows: int, columns: int) -> Grid:
# inicializa a grade com letras aleatórias
return [[choice(ascii_uppercase) for c in range(columns)] for r in
range(rows)]

def display_grid(grid: Grid) -> None:


for row in grid:
print("".join(row))

Para descobrir em que lugar as palavras poderão ser inseridas na grade,


vamos gerar seus domínios. O domínio de uma palavra é uma lista de
listas dos possíveis lugares para todas as suas letras
(List[List[GridLocation]]). No entanto, as palavras não podem ser
simplesmente colocadas em qualquer lugar. Elas devem estar em uma
linha, coluna ou diagonal que esteja dentro dos limites da grade. Em
outras palavras, as palavras não devem avançar para além das fronteiras
da grade. O propósito de generate_domain() é construir essas listas para
cada palavra.
Listagem 3.13 – Continuação de word_search.py
def generate_domain(word: str, grid: Grid) -> List[List[GridLocation]]:
domain: List[List[GridLocation]] = []
height: int = len(grid)
width: int = len(grid[0])
length: int = len(word)
for row in range(height):
for col in range(width):
columns: range = range(col, col + length + 1)
rows: range = range(row, row + length + 1)
if col + length <= width:
# da esquerda para a direita
domain.append([GridLocation(row, c) for c in columns])
# diagonal em direção ao canto inferior direito
if row + length <= height:
domain.append([GridLocation(r, col + (r - row)) for r in
rows])
if row + length <= height:
# de cima para baixo
domain.append([GridLocation(r, col) for r in rows])
# diagonal em direção ao canto inferior esquerdo
if col - length >= 0:
domain.append([GridLocation(r, col - (r - row)) for r in
rows])
return domain

Para o intervalo de lugares possíveis para uma palavra (em uma linha,
uma coluna ou na diagonal), as list comprehensions traduzem o intervalo
em uma lista de GridLocation usando o construtor dessa classe. Como
generate_domain() percorre todas as posições da grade em um laço, da
parte superior à esquerda até a parte inferior à direita para cada palavra,
muito processamento está envolvido. Você é capaz de pensar em um
modo mais e ciente de fazer isso? E se veri cássemos todas as palavras
de mesmo tamanho ao mesmo tempo, dentro do laço?
Para veri car se uma solução em potencial é válida, devemos
implementar uma restrição personalizada para o caça-palavras. O método
satisfied() de WordSearchConstraint simplesmente veri ca se algum dos
locais propostos para uma palavra é igual a um local proposto para outra.
Isso é feito com um set. Converter uma list em um set removerá todas as
duplicatas. Se houver menos itens em um set resultante da conversão de
uma list em comparação com o que havia na list original, é sinal de que
a list original continha algumas duplicatas. Para preparar os dados para
essa veri cação, usaremos uma list comprehension, de certa forma
complicada, para combinar várias sub-listas de posições para cada
palavra da atribuição em uma única lista maior de posições.
Listagem 3.14 – Continuação de word_search.py
class WordSearchConstraint(Constraint[str, List[GridLocation]]):
def __init__(self, words: List[str]) -> None:
super().__init__(words)
self.words: List[str] = words

def satisfied(self, assignment: Dict[str, List[GridLocation]]) -> bool:


# se houver alguma posição duplicada na grade, é sinal de que há uma
sobreposição
all_locations = [locs for values in assignment.values() for locs in
values]
return len(set(all_locations)) == len(all_locations)

Finalmente estamos prontos para executar o código. Neste exemplo, temos


cinco palavras em uma grade de nove por nove. A solução obtida deverá
conter mapeamentos entre cada palavra e as posições em que suas letras
podem ser inseridas na grade.
Listagem 3.15 – Continuação de word_search.py
if __name__ == "__main__":
grid: Grid = generate_grid(9, 9)
words: List[str] = ["MATTHEW", "JOE", "MARY", "SARAH", "SALLY"]
locations: Dict[str, List[List[GridLocation]]] = {}
for word in words:
locations[word] = generate_domain(word, grid)
csp: CSP[str, List[GridLocation]] = CSP(words, locations)
csp.add_constraint(WordSearchConstraint(words))
solution: Optional[Dict[str, List[GridLocation]]] =
csp.backtracking_search()
if solution is None:
print("No solution found!")
else:
for word, grid_locations in solution.items():
# inversão aleatória na metade das vezes
if choice([True, False]):
grid_locations.reverse()
for index, letter in enumerate(word):
(row, col) = (grid_locations[index].row,
grid_locations[index].column)
grid[row][col] = letter
display_grid(grid)

Há um toque nal no código que preenche a grade com as palavras.


Algumas palavras são escolhidas aleatoriamente para serem invertidas.
Isso é válido porque esse exemplo não permite que as palavras se
sobreponham. Sua saída deverá ter um aspecto semelhante àquela que
apresentamos a seguir. Você é capaz de encontrar Matthew, Joe, Mary,
Sarah e Sally?
LWEHTTAMJ
MARYLISGO
DKOJYHAYE
IAJYHALAG
GYZJWRLGM
LLOTCAYIX
PEUTUSLKO
AJZYGIKDU
HSLZOFNNR

3.5 SEND+MORE=MONEY
SEND+MORE=MONEY é uma charada criptoaritmética; isso signi ca
que se trata de encontrar dígitos que substituam as letras, de modo que
uma declaração matemática seja verdadeira. Cada letra no problema
representa um dígito (0–9). Duas letras diferentes não podem representar
o mesmo dígito. Quando uma letra se repete, signi ca que um dígito se
repetirá na solução.
Para resolver manualmente essa charada, colocar as palavras alinhadas
pode ajudar.
SEND
+MORE
=MONEY

Ela é totalmente solucionável manualmente, usando um pouco de álgebra


e intuição. Contudo, um programa de computador bem simples é capaz
de resolvê-la de modo mais rápido usando de força bruta ao considerar as
várias soluções possíveis. Vamos representar SEND+MORE=MONEY
como um problema de satisfação de restrições.
Listagem 3.16 – send_more_money.py
from csp import Constraint, CSP
from typing import Dict, List, Optional

class SendMoreMoneyConstraint(Constraint[str, int]):


def __init__(self, letters: List[str]) -> None:
super().__init__(letters)
self.letters: List[str] = letters

def satisfied(self, assignment: Dict[str, int]) -> bool:


# se houver valores duplicados, então não será uma solução
if len(set(assignment.values())) < len(assignment):
return False

# se uma atribuição foi feita para todas as variáveis,


# verifique se a soma está correta
if len(assignment) == len(self.letters):
s: int = assignment["S"]
e: int = assignment["E"]
n: int = assignment["N"]
d: int = assignment["D"]
m: int = assignment["M"]
o: int = assignment["O"]
r: int = assignment["R"]
y: int = assignment["Y"]
send: int = s * 1000 + e * 100 + n * 10 + d
more: int = m * 1000 + o * 100 + r * 10 + e
money: int = m * 10000 + o * 1000 + n * 100 + e * 10 + y
return send + more == money
return True # não há conflito

O método satisfied() de SendMoreMoneyConstraint executa algumas tarefas.


Inicialmente, ele veri ca se várias letras representam os mesmos dígitos.
Se isso ocorrer, a solução será inválida e False será devolvido. Em seguida,
ele veri ca se houve uma atribuição para todas as letras. Em caso
a rmativo, a função veri cará se a fórmula (SEND+MORE=MONEY)
está correta com a atribuição sendo considerada. Se estiver, é sinal de que
uma solução foi encontrada e True será devolvido. Do contrário, a função
devolverá False. Por m, se nem todas as letras tiveram um valor atribuído,
True será devolvido. Isso serve para garantir que o trabalho nessa solução
parcial tenha continuidade.
Vamos experimentar executar o código.
Listagem 3.17 – Continuação de send_more_money.py
if __name__ == "__main__":
letters: List[str] = ["S", "E", "N", "D", "M", "O", "R", "Y"]
possible_digits: Dict[str, List[int]] = {}
for letter in letters:
possible_digits[letter] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
possible_digits["M"] = [1] # para que não tenhamos respostas que comecem
com 0
csp: CSP[str, int] = CSP(letters, possible_digits)
csp.add_constraint(SendMoreMoneyConstraint(letters))
solution: Optional[Dict[str, int]] = csp.backtracking_search()
if solution is None:
print("No solution found!")
else:
print(solution)
Você perceberá que atribuímos previamente a resposta para a letra M.
Isso serve para garantir que a resposta não inclua um valor 0 para M,
pois, se você pensar no assunto, nossa restrição não tem nenhuma noção
do conceito de que um número não pode começar com zero. Sinta-se à
vontade para testar sem essa resposta previamente atribuída.
A solução deverá ter um aspecto semelhante a este:
{'S': 9, 'E': 5, 'N': 6, 'D': 7, 'M': 1, 'O': 0, 'R': 8, 'Y': 2}

3.6 Layout de placa de circuitos


Um fabricante precisa acomodar determinados chips retangulares em
uma placa de circuitos retangular. Basicamente, esse problema faz a
seguinte pergunta: “De que modo vários retângulos de tamanhos
diferentes podem se acomodar perfeitamente em outro retângulo?”. Um
código para resolução de problemas de satisfação de restrições é capaz de
encontrar a solução. A Figura 3.5 ilustra o problema.
O problema do layout da placa de circuitos é parecido com o problema
do caça-palavras. Em vez de retângulos de 1xN (palavras), o problema
apresenta retângulos de M×N. Assim como no problema do caça-
palavras, os retângulos não podem se sobrepor. Além disso, não podem
ser colocados em diagonais, portanto, nesse aspecto, o problema, na
verdade, é mais simples do que o problema do caça-palavras.
Figura 3.5 – O problema do layout da placa de circuitos é muito parecido
com o problema do caça-palavras, porém os retângulos têm largura
variada.
Experimente reescrever por conta própria a solução usada no caça-
palavras a m de adaptá-la para o layout da placa de circuitos. Você pode
reutilizar boa parte do código, incluindo o código para a grade.

3.7 Aplicações no mundo real


Conforme foi mencionado na introdução deste capítulo, os códigos para
resolução de problemas de satisfação de restrições são comuns em
agendamentos. Várias pessoas devem estar em uma reunião, e elas são as
variáveis. Os domínios são constituídos dos horários disponíveis em suas
agendas. As restrições podem envolver as combinações necessárias de
pessoas na reunião.
Códigos que resolvem problemas de satisfação de restrições também
são usados no planejamento de movimentos. Pense em um braço de robô
que deva ser encaixado dentro de um tudo. Ele tem restrições (as paredes
do tudo), variáveis (as junções) e os domínios (possíveis movimentos das
junções).
Há também aplicações em biologia computacional. Podemos pensar em
restrições entre moléculas necessárias em uma reação química. E, é claro,
como é comum em IA, há aplicações em jogos. Escrever um código para
resolver um Sudoku será um dos exercícios a seguir, mas muitas charadas
envolvendo lógica podem ser resolvidas usando resolução de problemas
de satisfação de restrições.
Neste capítulo, construímos um framework simples de resolução de
problemas com backtracking e busca em profundidade. No entanto, ele
pode ser bastante aperfeiçoado com o acréscimo de heurísticas (você se
lembra do A*?) – intuições que podem ajudar no processo de busca. Uma
técnica mais nova que o backtracking, conhecida como propagação de
restrições, é também uma opção e caz para aplicações no mundo real.
Para mais informações, consulte o Capítulo 6 do livro Arti cial
Intelligence: A Modern Approach, 3ª edição, de Stuart Russell e Peter
Norvig (Pearson, 2010)1.

3.8 Exercícios
1. Revise WordSearchConstraint de modo que a sobreposição de letras seja
permitida.
2. Implemente um código para solucionar o problema do layout da
placa de circuitos descrito na Seção 3.6, caso ainda não o tenha feito.
3. Implemente um programa capaz de resolver problemas de Sudoku
usando o framework de resolução de problemas de satisfação de
restrições deste capítulo.

1 N.T.: Edição publicada no Brasil: Inteligência Arti cial (Campus, 2013).


CAPÍTULO 4

Problemas de grafos

Um grafo é uma construção matemática abstrata usada para modelar um


problema do mundo real por meio do qual esse é dividido em um
conjunto de nós conectados. Chamamos a cada um dos nós de vértice e
cada uma das conexões de aresta. Por exemplo, podemos pensar em um
mapa de metrô como um grafo que representa uma rede de transporte.
Cada um dos pontos representa uma estação, e cada uma das linhas
representa uma rota entre duas estações. Na terminologia dos grafos,
chamaríamos as estações de “vértices” e as rotas de “arestas”.
Por que isso é conveniente? Os grafos não só nos ajudam a pensar de
forma abstrata em um problema, mas também nos permitem aplicar
várias técnicas de busca e otimização muito bem compreendidas e de
bom desempenho. Por exemplo, no caso do metrô, suponha que
quiséssemos saber qual é a rota mais curta de uma estação para outra. Ou
suponha que quiséssemos saber qual é a quantidade mínima de trilhos
necessária para conectar todas as estações. Os algoritmos de grafo que
veremos neste capítulo podem resolver esses dois problemas. Além do
mais, algoritmos de grafo podem ser aplicados em qualquer tipo de
problema de rede – não apenas em redes de transporte. Pense em redes
de computadores, redes de distribuição e redes de serviços públicos
essenciais. Problemas de busca e de otimização em todos esses domínios
podem ser resolvidos usando algoritmos de grafo.

4.1 Mapa como um grafo


Neste capítulo, não trabalharemos com um grafo de estações de metrô,
mas de cidades dos Estados Unidos e possíveis rotas entre elas. A Figura
4.1 é um mapa da parte continental dos Estados Unidos e as 15 maiores
MSAs (Metropolitan Statistical Areas, ou Áreas Metropolitanas) do país,
conforme estimativa do U.S. Census Bureau (Departamento de Censo dos
Estados Unidos).1

Figura 4.1 – Um mapa com as 15 maiores MSAs dos Estados Unidos.


O famoso empresário Elon Musk sugeriu a construção de uma nova
rede de transportes de alta velocidade composta de cápsulas que
trafegariam em tubos pressurizados. De acordo com Musk, as cápsulas
viajariam a aproximadamente 1.100 km/h e seriam adequadas para um
transporte de custo viável entre cidades que estejam a menos de cerca de
1.500 quilômetros de distância.2 Ele chamou esse novo sistema de
transportes de “Hyperloop”. Neste capítulo, exploraremos problemas
clássicos de grafos no contexto da construção dessa nova rede de
transporte.
Inicialmente, Musk propôs a ideia do Hyperloop para conectar Los
Angeles e San Francisco. Se uma rede Hyperloop nacional fosse
construída por alguém, faria sentido que fosse entre as maiores áreas
metropolitanas dos Estados Unidos. Na Figura 4.2, o contorno dos
estados que estavam na Figura 4.1 foram removidos. Além disso, cada
uma das MSAs está conectada a algumas MSAs vizinhas. Para deixar o
grafo um pouco mais interessante, essas vizinhas nem sempre são as
vizinhas mais próximas da MSA.
A Figura 4.2 mostra um grafo com vértices que representam as 15
maiores MSAs dos Estados Unidos e arestas representando possíveis
rotas do Hyperloop entre as cidades. As rotas foram escolhidas com
propósitos ilustrativos. Sem dúvida, outras rotas possíveis poderiam fazer
parte de uma nova rede Hyperloop.

Figura 4.2 – Um grafo com vértices que representam as 15 maiores MSAs


dos Estados Unidos. e as arestas representando possíveis rotas do Hyperloop
entre elas.
Essa representação abstrata de um problema do mundo real dá ênfase à
e cácia dos grafos. Com essa abstração, podemos ignorar a geogra a dos
Estados Unidos e nos concentrar em pensar na possível rede Hyperloop
apenas no contexto da conexão entre as cidades. De fato, desde que as
mesmas arestas sejam mantidas, podemos pensar no problema usando
um grafo de aspecto diferente. Na Figura 4.3, por exemplo, a localização
de Miami foi alterada. O grafo da Figura 4.3, por ser uma representação
abstrata, pode ser usado para os mesmos problemas fundamentais de
computação que o grafo da Figura 4.2, ainda que Miami não esteja no
local em que esperaríamos que estivesse. Contudo, para preservar a nossa
sanidade, vamos nos ater à representação que está na Figura 4.2.
Figura 4.3 – Um grafo equivalente ao grafo da Figura 4.2, com a localização
de Miami alterada.

4.2 Construindo um framework de grafos


Python pode ser programado usando vários estilos diferentes. Entretanto,
em sua essência, Python é uma linguagem de programação orientada a
objetos. Nesta seção, de niremos dois tipos diferentes de grafos: sem
peso (unweighted) e com peso (weighted). Grafos com peso, que serão
discutidos mais adiante neste capítulo, associam um peso (leia-se um
número, por exemplo, uma distância, em nosso exemplo) a cada aresta.
Faremos uso do modelo de herança, fundamental nas hierarquias de
classes orientadas a objetos de Python, para que não haja duplicação de
nossos esforços. As classes com peso em nosso modelo de dados serão
subclasses de suas contrapartidas sem peso. Isso lhes permitirá herdar
muitas das funcionalidades, com pequenos ajustes nas partes que tornam
um grafo com peso distinto de um grafo sem peso.
Queremos que esse framework de grafos tenha o máximo de
exibilidade para que ele represente o maior número possível de
problemas. Para alcançar esse objetivo, faremos uso de genéricos
(generics) com o intuito de abstrair o tipo dos vértices. Em última
instância, cada vértice terá um índice inteiro atribuído, mas será
armazenado com o tipo genérico de nido pelo usuário.
Vamos começar a trabalhar no framework de nindo a classe Edge , que
será o recurso mais simples de nosso framework de grafos.
Listagem 4.1 – edge.py
from __future__ import annotations
from dataclasses import dataclass

@dataclass
class Edge:
u: int # o vértice "de"
v: int # o vértice "para"

def reversed(self) -> Edge:


return Edge(self.v, self.u)

def __str__(self) -> str:


return f"{self.u} -> {self.v}"

Uma Edge é de nida como uma conexão entre dois vértices, cada qual
representado por um índice inteiro. Por convenção, u é usado para
referenciar o primeiro vértice e v é utilizado para representar o segundo.
Também podemos pensar em u como “de” e v como “para”. Neste
capítulo, trabalharemos apenas com grafos não direcionados (grafos com
arestas que permitem trafegar nas duas direções), mas em grafos
direcionados (ou, ainda, orientados ou dirigidos), também conhecidos
como digrafos, as arestas também podem ser unidirecionais. O método
reversed() devolve uma Edge que percorra a direção inversa da aresta na
qual ele for aplicado.
NOTA A classe Edge utiliza um novo recurso de Python 3.7: dataclasses. Uma classe marcada
com o decorador @dataclass evita um pouco de tédio ao criar automaticamente um
método __init__() que instancia variáveis de instância para qualquer variável declarada
com anotações de tipo no corpo da classe. As dataclasses também podem criar
automaticamente outros métodos especiais em uma classe. Os métodos especiais que serão
criados automaticamente podem ser con gurados usando o decorador. Consulte a
documentação de Python sobre as dataclasses para ver os detalhes (https://docs.python.org/
3/library/dataclasses.html). Em suma, uma dataclass é um modo de evitar um pouco de
digitação.
A classe Graph tem como foco o papel principal de um grafo: associar
vértices a arestas. Novamente, queremos que os vértices sejam de
qualquer tipo que o usuário do framework queira. Isso permite que o
framework seja usado em uma grande variedade de problemas, sem a
necessidade de criar estruturas de dados intermediárias para uni car
tudo. Por exemplo, em um grafo como aquele das rotas do Hyperloop,
poderíamos de nir o tipo dos vértices como str porque usaríamos
strings como “New York” e “Los Angeles” como vértices. Vamos dar
início à classe Graph.
Listagem 4.2 – graph.py
from typing import TypeVar, Generic, List, Optional
from edge import Edge

V = TypeVar('V') # tipo dos vértices no grafo

class Graph(Generic[V]):
def __init__(self, vertices: List[V] = []) -> None:
self._vertices: List[V] = vertices
self._edges: List[List[Edge]] = [[] for _ in vertices]

A lista _vertices é o coração de um Graph. Todos os vértices serão


armazenados na lista, porém, mais tarde, nós os referenciaremos pelo seu
índice, que é um inteiro. O vértice em si pode ser de um tipo de dado
complexo, porém seu índice sempre será um int, com o qual é mais fácil
trabalhar. Em outro nível, ao colocar esse índice entre os algoritmos de
grafo e o array _vertices, é possível ter dois vértices iguais no mesmo
grafo. (Pense em um grafo com as cidades de um país como vértices, cujo
país tenha mais de uma cidade chamada “Spring eld”.) Apesar de serem
iguais, elas terão índices inteiros distintos.
Há várias maneiras de implementar uma estrutura de dados de grafo,
mas as duas mais comuns são usar uma matriz de vértices ou utilizar listas
de adjacência. Em uma matriz de vértices, cada célula da matriz
representa a intersecção entre dois vértices do grafo, e o valor dessa célula
informa a conexão entre eles (ou a falta dela). A estrutura de dados de
nosso grafo utiliza listas de adjacência. Nessa representação de grafo,
cada vértice tem uma lista de vértices ao qual ele está conectado. Nossa
representação especí ca utiliza uma lista de listas de arestas, portanto,
para cada vértice, há uma lista de arestas por meio das quais o vértice está
conectado com outros vértices. _edges é essa lista de listas.
O resto da classe Graph será apresentado por completo a seguir. Você
notará o uso de métodos em sua maioria pequenos, de uma só linha, com
nomes longos e claros. Com isso, o resto da classe deverá ser
autoexplicativa em sua maior parte; no entanto, comentários breves foram
incluídos para que não haja espaço para problemas de interpretação.
Listagem 4.3 – Continuação de graph.py
@property
def vertex_count(self) -> int:
return len(self._vertices) # Número de vértices

@property
def edge_count(self) -> int:
return sum(map(len, self._edges)) # Número de arestas

# Adiciona um vértice ao grafo e devolve o seu índice


def add_vertex(self, vertex: V) -> int:
self._vertices.append(vertex)
self._edges.append([]) # Adiciona uma lista vazia para conter as arestas
return self.vertex_count - 1 # Devolve o índice do vértice adicionado

# Este é um grafo não direcionado,


# portanto, sempre adicionamos arestas nas duas direções
def add_edge(self, edge: Edge) -> None:
self._edges[edge.u].append(edge)
self._edges[edge.v].append(edge.reversed())

# Adiciona uma aresta usando índices dos vértices (método auxiliar)


def add_edge_by_indices(self, u: int, v: int) -> None:
edge: Edge = Edge(u, v)
self.add_edge(edge)

# Adiciona uma aresta consultando os índices dos vértices (método auxiliar)


def add_edge_by_vertices(self, first: V, second: V) -> None:
u: int = self._vertices.index(first)
v: int = self._vertices.index(second)
self.add_edge_by_indices(u, v)

# Encontra o vértice em um índice específico


def vertex_at(self, index: int) -> V:
return self._vertices[index]
# Encontra o índice de um vértice no grafo
def index_of(self, vertex: V) -> int:
return self._vertices.index(vertex)

# Encontra os vértices aos quais um vértice com determinado índice está


conectado
def neighbors_for_index(self, index: int) -> List[V]:
return list(map(self.vertex_at, [e.v for e in self._edges[index]]))

# Consulta o índice de um vértice e encontra seus vizinhos (método auxiliar)


def neighbors_for_vertex(self, vertex: V) -> List[V]:
return self.neighbors_for_index(self.index_of(vertex))

# Devolve todas as arestas associadas a um vértice em um índice


def edges_for_index(self, index: int) -> List[Edge]:
return self._edges[index]

# Consulta o índice de um vértice e devolve suas arestas (método auxiliar)


def edges_for_vertex(self, vertex: V) -> List[Edge]:
return self.edges_for_index(self.index_of(vertex))

# Facilita a exibição elegante de um Graph


def __str__(self) -> str:
desc: str = ""
for i in range(self.vertex_count):
desc += f"{self.vertex_at(i)} -> {self.neighbors_for_index(i)}\n"
return desc

Vamos parar um instante e considerar o motivo pelo qual essa classe tem
duas versões para a maioria de seus métodos. Com base na de nição da
classe, sabemos que a lista _vertices é uma lista de elementos do tipo V,
que pode ser qualquer classe Python. Portanto, temos vértices do tipo V
armazenados na lista _vertices. Contudo, se quisermos obtê-los ou
manipulá-los mais tarde, temos de saber em que local eles estão
armazenados nessa lista. Desse modo, todo vértice tem um índice de array
(um inteiro) associado a ele. Se não soubermos o índice de um vértice,
será necessário consultá-lo fazendo uma busca em _vertices. É por isso
que há duas versões para cada método. Uma atua em índices int,
enquanto a outra atua no próprio V. Os métodos que atuam em V
consultam os índices relevantes e chamam a função que trabalha com
índices. Desse modo, esses métodos podem ser considerados como
auxiliares.
A maioria das função é razoavelmente autoexplicativa, mas
neighbors_for_index() merece um pouco mais de explicações. Ela devolve
os vizinhos (neighbors) de um vértice. Os vizinhos de um vértice são
todos os demais vértices diretamente conectados a ele por uma aresta. Por
exemplo, na Figura 4.2, New York e Washington são os únicos vizinhos
de Philadelphia. Encontramos os vizinhos de um vértice consultando as
extremidades (os vs) de todas as arestas que partem dele.
def neighbors_for_index(self, index: int) -> List[V]:
return list(map(self.vertex_at, [e.v for e in self._edges[index]]))

_edges[index] é a lista de adjacências, isto é, a lista de arestas por meio das


quais o vértice em questão está conectado a outros vértices. Na list
comprehension passada para a chamada a map(), e representa uma aresta
em particular e e.v representa o índice do vizinho ao qual a aresta está
conectada. map() devolverá todos os vértices (e não apenas os seus
índices), pois map() aplica o método vertex_at() em cada e.v.
Outro fato importante a ser observado é o modo como add_edge()
funciona. add_edge()inicialmente adiciona uma aresta na lista de
adjacências do vértice “de” (u) e, em seguida, adiciona uma versão inversa
da aresta à lista de adjacências do vértice “para” (v). O segundo passo é
necessário porque esse grafo não é direcionado. Queremos que toda
aresta seja adicionada nas duas direções; isso signi ca que u será vizinho
de v, do mesmo modo que v é vizinho de u. Podemos pensar em um grafo
não direcionado como sendo “bidirecional” caso isso ajude você a
lembrar que isso signi ca que qualquer aresta pode ser percorrida nas
duas direções.
def add_edge(self, edge: Edge) -> None:
self._edges[edge.u].append(edge)
self._edges[edge.v].append(edge.reversed())
Conforme mencionamos antes, estamos trabalhando apenas com grafos
não direcionados neste capítulo. Afora o fato de ser não direcionado ou
direcionado, os grafos também podem ser sem peso ou com peso. Um
grafo com peso é um grafo que tem algum valor comparável, geralmente
numérico, associado a cada uma de suas arestas. Poderíamos pensar nos
pesos em nossa possível rede Hyperloop como as distâncias entre as
estações. Por enquanto, porém, trabalharemos com uma versão sem peso
do grafo. Uma aresta sem peso é simplesmente uma conexão entre dois
vértices; assim, a classe Edge não tem peso, e a classe Graph também não.
Outra forma de expressar isso é dizer que, em um grafo sem peso,
sabemos quais vértices estão conectados, enquanto, em um grafo com
peso, sabemos quais vértices estão conectados, além de conhecermos
também algo sobre essas conexões.

4.2.1 Trabalhando com Edge e Graph


Agora que temos uma implementação concreta de Edge e Graph, podemos
criar uma representação para a possível rede Hyperloop. Os vértices e as
arestas em city_graph correspondem aos vértices e arestas representados
na Figura 4.2. Fazendo uso de genéricos, podemos especi car que os
vértices serão do tipo str (Graph[str]). Em outras palavras, a variável de
tipo V será preenchida com o tipo str.
Listagem 4.4 – Continuação de graph.py
if __name__ == "__main__":
# teste para uma construção básica de Graph
city_graph: Graph[str] = Graph(["Seattle", "San Francisco", "Los
Angeles", "Riverside", "Phoenix", "Chicago", "Boston", "New York",
"Atlanta", "Miami", "Dallas", "Houston", "Detroit", "Philadelphia",
"Washington"])
city_graph.add_edge_by_vertices("Seattle", "Chicago")
city_graph.add_edge_by_vertices("Seattle", "San Francisco")
city_graph.add_edge_by_vertices("San Francisco", "Riverside")
city_graph.add_edge_by_vertices("San Francisco", "Los Angeles")
city_graph.add_edge_by_vertices("Los Angeles", "Riverside")
city_graph.add_edge_by_vertices("Los Angeles", "Phoenix")
city_graph.add_edge_by_vertices("Riverside", "Phoenix")
city_graph.add_edge_by_vertices("Riverside", "Chicago")
city_graph.add_edge_by_vertices("Phoenix", "Dallas")
city_graph.add_edge_by_vertices("Phoenix", "Houston")
city_graph.add_edge_by_vertices("Dallas", "Chicago")
city_graph.add_edge_by_vertices("Dallas", "Atlanta")
city_graph.add_edge_by_vertices("Dallas", "Houston")
city_graph.add_edge_by_vertices("Houston", "Atlanta")
city_graph.add_edge_by_vertices("Houston", "Miami")
city_graph.add_edge_by_vertices("Atlanta", "Chicago")
city_graph.add_edge_by_vertices("Atlanta", "Washington")
city_graph.add_edge_by_vertices("Atlanta", "Miami")
city_graph.add_edge_by_vertices("Miami", "Washington")
city_graph.add_edge_by_vertices("Chicago", "Detroit")
city_graph.add_edge_by_vertices("Detroit", "Boston")
city_graph.add_edge_by_vertices("Detroit", "Washington")
city_graph.add_edge_by_vertices("Detroit", "New York")
city_graph.add_edge_by_vertices("Boston", "New York")
city_graph.add_edge_by_vertices("New York", "Philadelphia")
city_graph.add_edge_by_vertices("Philadelphia", "Washington")
print(city_graph)
city_graph tem vértices do tipo str, e representamos cada vértice com o
nome da MSA que ele representa. A ordem na qual adicionamos as
arestas em city_graph não é relevante. Como implementamos __str__()
para que exiba uma bela descrição do grafo, podemos agora fazer uma
exibição elegante dele (um pretty-print). Você verá uma saída semelhante
a esta:
Seattle -> ['Chicago', 'San Francisco']
San Francisco -> ['Seattle', 'Riverside', 'Los Angeles']
Los Angeles -> ['San Francisco', 'Riverside', 'Phoenix']
Riverside -> ['San Francisco', 'Los Angeles', 'Phoenix', 'Chicago']
Phoenix -> ['Los Angeles', 'Riverside', 'Dallas', 'Houston']
Chicago -> ['Seattle', 'Riverside', 'Dallas', 'Atlanta', 'Detroit']
Boston -> ['Detroit', 'New York']
New York -> ['Detroit', 'Boston', 'Philadelphia']
Atlanta -> ['Dallas', 'Houston', 'Chicago', 'Washington', 'Miami']
Miami -> ['Houston', 'Atlanta', 'Washington']
Dallas -> ['Phoenix', 'Chicago', 'Atlanta', 'Houston']
Houston -> ['Phoenix', 'Dallas', 'Atlanta', 'Miami']
Detroit -> ['Chicago', 'Boston', 'Washington', 'New York']
Philadelphia -> ['New York', 'Washington']
Washington -> ['Atlanta', 'Miami', 'Detroit', 'Philadelphia']

4.3 Encontrando o caminho mínimo


O Hyperloop é tão rápido que, para otimizar o tempo de viagem de uma
estação para outra, provavelmente importarão menos as distâncias entre
as estações e mais a quantidade de paradas (quantidade de estações que
devem ser visitadas) necessárias para ir de uma estação a outra. Cada
estação pode envolver uma escala, portanto, assim como nos voos,
quanto menos paradas, melhor.
Na teoria de grafos, um conjunto de arestas que conectam dois vértices
é conhecido como um caminho (path). Em outras palavras, um caminho é
uma forma de ir de um vértice para outro. No contexto da rede
Hyperloop, um conjunto de tubos (arestas) representa o caminho de uma
cidade (vértice) a outra (vértice). Encontrar caminhos ótimos entre os
vértices é um dos problemas mais comuns para uso dos grafos.
De modo informal, podemos pensar também em um caminho como
uma lista de vértices sequencialmente conectados por meio de arestas.
Essa descrição é apenas o outro lado da mesma moeda. É como tomar
uma lista de arestas, descobrir quais vértices elas conectam, preservar
essa lista de vértices e jogar fora as arestas. No exemplo rápido a seguir,
encontraremos uma lista de vértices como essa, que conecta duas cidades
em nosso Hyperloop.

4.3.1 Retomando a busca em largura (BFS)


Em um grafo sem peso, encontrar o caminho mínimo signi ca encontrar
o caminho que tem o menor número de arestas entre o vértice de início e
o vértice de destino. Para construir a rede Hyperloop, talvez faça sentido
conectar inicialmente as cidades mais distantes nas costas marítimas mais
populosas. Isso leva à seguinte pergunta: “Qual é o caminho mínimo
entre Boston e Miami?”.
DICA Esta seção pressupõe que você leu o Capítulo 2. Antes de prosseguir, certi que-se de
estar à vontade com o conteúdo sobre a busca em largura (breadth- rst search) que está no
Capítulo 2.
Felizmente já temos um algoritmo para encontrar caminhos mínimos, e
podemos reutilizá-lo para responder a essa pergunta. A busca em largura,
apresentada no Capítulo 2, é tão viável para grafos quanto para labirintos.
De fato, os labirintos com os quais trabalhamos no Capítulo 2, na
verdade, são grafos. Os vértices são as posições no labirinto, e as arestas
são os movimentos que podem ser feitos de um local para outro. Em um
grafo sem peso, uma busca em largura encontrará o caminho mínimo
entre dois vértices quaisquer.
Podemos reutilizar a implementação da busca em largura do Capítulo 2
e usá-la para trabalhar com Graph. De fato, podemos reutilizá-la sem
nenhuma alteração. Eis a e cácia de escrever um código de forma
genérica!
Lembre-se de que a bfs() do Capítulo 2 exige três parâmetros: um
estado inicial, um Callable (leia-se um objeto do tipo função) para testar
um objetivo e um Callable que encontre os estados sucessores de um
dado estado. O estado inicial será o vértice representado pela string
“Boston”. O teste para veri car o objetivo será uma lambda que veri ca se
um vértice é equivalente a “Miami”. Por m, os vértices sucessores podem
ser gerados pelo método neighbors_for_vertex() de Graph.
Com esse plano em mente, podemos acrescentar um código no nal da
seção principal de graph.py para encontrar a rota mais curta entre Boston
e Miami em city_graph.
NOTA Na Listagem 4.5, bfs , Node e node_to_path foram importados do módulo
generic_search do pacote Chapter2 . Para isso, o diretório pai de graph.py foi adicionado
no path de busca de Python ('..' ). Isso funciona porque a estrutura de código do
repositório do livro inclui cada capítulo em seu próprio diretório; desse modo, nossa
estrutura de diretórios inclui, grosso modo, Book->Chapter2->generic_search.py e Book-
>Chapter4->graph.py. Se a sua estrutura de diretórios for signi cativamente diferente, será
necessário encontrar um modo de adicionar generic_search.py em seu path e, possivelmente,
modi car a instrução import . Em um cenário de pior caso, você poderia simplesmente
copiar generic_search.py para o mesmo diretório que contém graph.py e alterar a instrução
import para from generic_search import bfs, Node, node_to_path .

Listagem 4.5 – Continuação de graph.py


# Reutiliza a BFS do Capítulo 2 em city_graph
import sys
sys.path.insert(0, '..') # para que possamos acessar o pacote Chapter2 no
diretório pai
from Chapter2.generic_search import bfs, Node, node_to_path

bfs_result: Optional[Node[V]] = bfs("Boston", lambda x: x == "Miami",


city_graph.neighbors_for_vertex)
if bfs_result is None:
print("No solution found using breadth-first search!")
else:
path: List[V] = node_to_path(bfs_result)
print("Path from Boston to Miami:")
print(path)

A saída deverá ter um aspecto semelhante a este:


Path from Boston to Miami:
['Boston', 'Detroit', 'Washington', 'Miami']

De Boston para Detroit, depois para Washington e então para Miami,


composta de três arestas, é a rota mínima entre Boston e Miami no que
diz respeito ao número de arestas. A Figura 4.4 exibe essa rota em
destaque.

Figura 4.4 – A rota mínima entre Boston e Miami, no que diz respeito ao
número de arestas, está em destaque.

4.4 Minimizando o custo de construção da rede


Suponha que queremos conectar todas as 15 maiores MSAs na rede
Hyperloop. Nosso objetivo é minimizar o custo de construção da rede,
portanto, signi ca usar uma quantidade mínima de trilhos. Desse modo,
a pergunta é: “Como podemos conectar todas as MSAs usando a
quantidade mínima de trilhos?”.

4.4.1 Trabalhando com pesos


Para saber qual é a quantidade de trilhos que uma aresta em particular
poderia exigir, temos de saber qual é a distância que a aresta representa.
Essa é uma oportunidade de apresentar novamente o conceito de pesos.
Na rede Hyperloop, o peso de uma aresta é a distância entre as duas
MSAs que ela conecta. A Figura 4.5 é igual à Figura 4.2, exceto pelo fato
de ter um peso adicionado a cada aresta, o qual representa a distância em
milhas entre os dois vértices conectados pela aresta.

Figura 4.5 – Um grafo com peso com as 15 maiores MSAs dos Estados
Unidos, no qual cada um dos pesos representa a distância em milhas entre
duas MSAs.
Para lidar com pesos, precisaremos de uma subclasse de Edge
(WeightedEdge) e de uma subclasse de Graph (WeightedGraph). Toda
WeightedEdge terá um float associado, representando o seu peso. O
algoritmo de Jarník, que descreveremos em breve, exige que seja possível
comparar uma aresta com outra a m de determinar qual é a aresta de
menor peso. Isso é fácil de ser feito com pesos numéricos.
Listagem 4.6 – weighted_edge.py
from __future__ import annotations
from dataclasses import dataclass
from edge import Edge

@dataclass
class WeightedEdge(Edge):
weight: float

def reversed(self) -> WeightedEdge:


return WeightedEdge(self.v, self.u, self.weight)

# para que possamos ordenar as arestas por peso a fim de encontrar


# a aresta de menor peso
def __lt__(self, other: WeightedEdge) -> bool:
return self.weight < other.weight

def __str__(self) -> str:


return f"{self.u} {self.weight}> {self.v}"

A implementação de WeightedEdge não é muito diferente da implementação


de Edge. Ela só difere quanto ao acréscimo de uma propriedade weight e
na implementação do operador < por meio de __lt__(), de modo que
duas WeightedEdges sejam comparáveis. O operador < está preocupado
apenas em observar os pesos (em oposição a incluir as propriedades
herdadas u e v), pois o algoritmo de Jarník está interessado em encontrar
a menor aresta de acordo com o peso.
Uma WeightedGraph herda boa parte de suas funcionalidades de Graph.
Afora isso, ela tem métodos init, métodos auxiliares para adicionar
WeightedEdge s e implementa sua própria versão de __str__() . Há também
um novo método, neighbors_for_index_with_weights(), que devolve não só
cada vizinho, mas também o peso da aresta que conduz até ele. Esse
método será útil na nova versão de __str__().
Listagem 4.7 – weighted_graph.py
from typing import TypeVar, Generic, List, Tuple
from graph import Graph
from weighted_edge import WeightedEdge

V = TypeVar('V') # tipo dos vértices no grafo

class WeightedGraph(Generic[V], Graph[V]):


def __init__(self, vertices: List[V] = []) -> None:
self._vertices: List[V] = vertices
self._edges: List[List[WeightedEdge]] = [[] for _ in vertices]

def add_edge_by_indices(self, u: int, v: int, weight: float) -> None:


edge: WeightedEdge = WeightedEdge(u, v, weight)
self.add_edge(edge) # chama a versão da superclasse
def add_edge_by_vertices(self, first: V, second: V, weight: float) ->
None:
u: int = self._vertices.index(first)
v: int = self._vertices.index(second)
self.add_edge_by_indices(u, v, weight)

def neighbors_for_index_with_weights(self, index: int) -> List[Tuple[V,


float]]:
distance_tuples: List[Tuple[V, float]] = []
for edge in self.edges_for_index(index):
distance_tuples.append((self.vertex_at(edge.v), edge.weight))
return distance_tuples

def __str__(self) -> str:


desc: str = ""
for i in range(self.vertex_count):
desc += f"{self.vertex_at(i)} ->
{self.neighbors_for_index_with_weights(i)}\n"
return desc

Agora é possível de nir realmente um grafo com peso. O grafo com peso
com o qual trabalharemos se chama city_graph2 e é uma representação da
Figura 4.5.
Listagem 4.8 – Continuação de weighted_graph.py
if __name__ == "__main__":
city_graph2: WeightedGraph[str] = WeightedGraph(["Seattle", "San
Francisco", "Los Angeles", "Riverside", "Phoenix", "Chicago", "Boston",
"New York", "Atlanta", "Miami", "Dallas", "Houston", "Detroit",
"Philadelphia", "Washington"])
city_graph2.add_edge_by_vertices("Seattle", "Chicago", 1737)
city_graph2.add_edge_by_vertices("Seattle", "San Francisco", 678)
city_graph2.add_edge_by_vertices("San Francisco", "Riverside", 386)
city_graph2.add_edge_by_vertices("San Francisco", "Los Angeles", 348)
city_graph2.add_edge_by_vertices("Los Angeles", "Riverside", 50)
city_graph2.add_edge_by_vertices("Los Angeles", "Phoenix", 357)
city_graph2.add_edge_by_vertices("Riverside", "Phoenix", 307)
city_graph2.add_edge_by_vertices("Riverside", "Chicago", 1704)
city_graph2.add_edge_by_vertices("Phoenix", "Dallas", 887)
city_graph2.add_edge_by_vertices("Phoenix", "Houston", 1015)
city_graph2.add_edge_by_vertices("Dallas", "Chicago", 805)
city_graph2.add_edge_by_vertices("Dallas", "Atlanta", 721)
city_graph2.add_edge_by_vertices("Dallas", "Houston", 225)
city_graph2.add_edge_by_vertices("Houston", "Atlanta", 702)
city_graph2.add_edge_by_vertices("Houston", "Miami", 968)
city_graph2.add_edge_by_vertices("Atlanta", "Chicago", 588)
city_graph2.add_edge_by_vertices("Atlanta", "Washington", 543)
city_graph2.add_edge_by_vertices("Atlanta", "Miami", 604)
city_graph2.add_edge_by_vertices("Miami", "Washington", 923)
city_graph2.add_edge_by_vertices("Chicago", "Detroit", 238)
city_graph2.add_edge_by_vertices("Detroit", "Boston", 613)
city_graph2.add_edge_by_vertices("Detroit", "Washington", 396)
city_graph2.add_edge_by_vertices("Detroit", "New York", 482)
city_graph2.add_edge_by_vertices("Boston", "New York", 190)
city_graph2.add_edge_by_vertices("New York", "Philadelphia", 81)
city_graph2.add_edge_by_vertices("Philadelphia", "Washington", 123)
print(city_graph2)

Como WeightedGraph implementa __str__(), podemos fazer uma exibição


elegante de city_graph2. Na saída, vemos tanto os vértices aos quais cada
vértice está conectado como também os pesos dessas conexões.
Seattle -> [('Chicago', 1737), ('San Francisco', 678)]
San Francisco -> [('Seattle', 678), ('Riverside', 386), ('Los Angeles',
348)]
Los Angeles -> [('San Francisco', 348), ('Riverside', 50), ('Phoenix',
357)]
Riverside -> [('San Francisco', 386), ('Los Angeles', 50), ('Phoenix',
307), ('Chicago', 1704)]
Phoenix -> [('Los Angeles', 357), ('Riverside', 307), ('Dallas', 887),
('Houston', 1015)]
Chicago -> [('Seattle', 1737), ('Riverside', 1704), ('Dallas', 805),
('Atlanta', 588), ('Detroit', 238)]
Boston -> [('Detroit', 613), ('New York', 190)]
New York -> [('Detroit', 482), ('Boston', 190), ('Philadelphia', 81)]
Atlanta -> [('Dallas', 721), ('Houston', 702), ('Chicago', 588),
('Washington', 543), ('Miami', 604)]
Miami -> [('Houston', 968), ('Atlanta', 604), ('Washington', 923)]
Dallas -> [('Phoenix', 887), ('Chicago', 805), ('Atlanta', 721),
('Houston', 225)]
Houston -> [('Phoenix', 1015), ('Dallas', 225), ('Atlanta', 702),
('Miami', 968)]
Detroit -> [('Chicago', 238), ('Boston', 613), ('Washington', 396), ('New
York', 482)]
Philadelphia -> [('New York', 81), ('Washington', 123)]
Washington -> [('Atlanta', 543), ('Miami', 923), ('Detroit', 396),
('Philadelphia', 123)]
4.4.2 Encontrando a árvore geradora mínima
Uma árvore é um tipo especial de grafo que tem um, e somente um,
caminho entre dois vértices quaisquer. Isso implica que não há ciclos em
uma árvore (às vezes, essas árvores são chamadas de acíclicas). Podemos
pensar em um ciclo como um laço: se for possível percorrer um grafo a
partir de um vértice inicial e retornar até esse mesmo vértice sem repetir
nenhuma aresta, é sinal de que há um ciclo. Qualquer grafo que não seja
uma árvore poderá se tornar uma se “podarmos” algumas arestas. A
Figura 4.6 mostra a poda de uma aresta para transformar um grafo em
uma árvore.
Um grafo conectado é um grafo que permite ir de qualquer vértice para
qualquer outro vértice de alguma maneira. (Todos os grafos que estamos
analisando neste capítulo são conectados.) Uma árvore geradora
(spanning tree) é uma árvore que conecta todos os vértices em um grafo.
Uma árvore geradora mínima (minimum spanning tree) é uma árvore que
conecta todos os vértices em um grafo com peso, cujo peso total é
mínimo (em comparação com outras árvores geradoras). Para qualquer
grafo com peso, é possível encontrar a sua árvore geradora mínima.

Figura 4.6 – No grafo à esquerda, há um ciclo entre os vértices B, C e D,


portanto não é uma árvore. No grafo à direita, a aresta que conecta C e D
foi removida, portanto o grafo é uma árvore.
Ufa – tivemos um bocado de terminologia! O ponto principal é que
encontrar uma árvore geradora mínima é equivalente a encontrar um
modo de conectar todos os vértices em um grafo com peso, de modo que
o peso seja mínimo. É um problema prático e importante para qualquer
pessoa que esteja projetando uma rede (rede de transporte, rede de
computadores, e assim por diante): de que modo cada um dos nós da
rede pode ser conectado para que o custo seja mínimo? Esse custo pode
estar associado a os, trilhos, estradas ou qualquer outro elemento. Por
exemplo, em uma rede de telefonia, outra forma de apresentar o problema
é: “Qual é o comprimento mínimo de cabos necessário para conectar
todos os telefones?”.
Retomando as las de prioridade
As las de prioridades (priority queues) foram abordadas no Capítulo 2.
Precisaremos de uma la de prioridades para o algoritmo de Jarník. Você
pode importar a classe PriorityQueue do pacote do Capítulo 2 (consulte a
nota imediatamente antes da Listagem 4.5 para ver os detalhes), ou
poderá copiar a classe para um novo arquivo a ser usado com o pacote
deste capítulo. Para que o código esteja completo, recriaremos aqui a
PriorityQueue do Capítulo 2, com instruções import especí cas que
pressupõem que ela estará em um arquivo próprio.
Listagem 4.9 – priority_queue.py
from typing import TypeVar, Generic, List
from heapq import heappush, heappop

T = TypeVar('T')

class PriorityQueue(Generic[T]):
def __init__(self) -> None:
self._container: List[T] = []

@property
def empty(self) -> bool:
return not self._container # negação é verdadeira para um contêiner
vazio

def push(self, item: T) -> None:


heappush(self._container, item) # insere de acordo com a prioridade

def pop(self) -> T:


return heappop(self._container) # remove de acordo com a prioridade

def __repr__(self) -> str:


return repr(self._container)
Calculando o peso total de um grafo com peso
Antes de desenvolver um método para encontrar uma árvore geradora
mínima, implementaremos uma função que poderá ser usada para testar
o peso total de uma solução. A solução para o problema da árvore
geradora mínima será constituída de uma lista de arestas com peso que
compõem a árvore. Inicialmente de niremos uma WeightedPath como uma
lista de WeightedEdge. Em seguida, de niremos uma função total_weight()
que aceita uma lista de WeightedPath e encontra o peso total resultante da
soma dos pesos de todas as suas arestas.
Listagem 4.10 – mst.py
from typing import TypeVar, List, Optional
from weighted_graph import WeightedGraph
from weighted_edge import WeightedEdge
from priority_queue import PriorityQueue

V = TypeVar('V') # tipo dos vértices no grafo


WeightedPath = List[WeightedEdge] # alias de tipo para caminhos

def total_weight(wp: WeightedPath) -> float:


return sum([e.weight for e in wp])

Algoritmo de Jarník
O algoritmo de Jarník para encontrar uma árvore geradora mínima divide
um grafo em duas partes: os vértices da árvore geradora mínima que
ainda está sendo montada e os vértices que ainda não estão nessa árvore.
Os seguintes passos serão executados:
1. Escolha um vértice arbitrário para incluir na árvore geradora mínima.
2. Encontre a aresta de menor peso que conecta a árvore geradora
mínima aos vértices que ainda não estão nessa árvore.
3. Adicione o vértice que está no nal dessa aresta mínima à árvore
geradora mínima.
4. Repita os passos 2 e 3 até que todos os vértices do grafo estejam na
árvore geradora mínima.
NOTA O algoritmo de Jarník é comumente chamado de algoritmo de Prim. Dois
matemáticos tchecos, Otakar Borůvka e Vojtĕch Jarník, interessados em minimizar o custo
de instalação de ações para energia elétrica no nal dos anos 1920, criaram algoritmos para
resolver o problema de encontrar uma árvore geradora mínima. Seus algoritmos foram
“redescobertos” décadas depois, por outras pessoas.3
Para executar o algoritmo de Jarník de modo e caz, uma la de
prioridades será usada. Sempre que um novo vértice for adicionado à
árvore geradora mínima, todas as suas arestas de saída que se ligam aos
vértices fora da árvore serão adicionadas à la de prioridades. A aresta de
menor peso será sempre removida da la de prioridades, e o algoritmo
continuará executando até que essa la esteja vazia. Isso garante que as
arestas de menor peso sejam sempre adicionadas na árvore antes. As
arestas que se conectam aos vértices que já estão na árvore serão
ignoradas após serem removidas.
O código de mst() a seguir contém a implementação completa do
algoritmo de Jarník,4 junto com uma função utilitária para exibir um
WeightedPath .
AVISO O algoritmo de Jarník não funcionará necessariamente de forma correta em um grafo
com arestas direcionadas. Também não funcionará em um grafo que não seja conectado.
Listagem 4.11 – Continuação de mst.py
def mst(wg: WeightedGraph[V], start: int = 0) -> Optional[WeightedPath]:
if start > (wg.vertex_count - 1) or start < 0:
return None
result: WeightedPath = [] # armazena a MST final
pq: PriorityQueue[WeightedEdge] = PriorityQueue()
visited: [bool] = [False] * wg.vertex_count # locais já visitados

def visit(index: int):


visited[index] = True # marca como visitado
for edge in wg.edges_for_index(index):
# adiciona todas as arestas que partem daqui em pq
if not visited[edge.v]:
pq.push(edge)
visit(start) # o primeiro vértice é onde tudo começa
while not pq.empty: # continua enquanto houver arestas para processar
edge = pq.pop()
if visited[edge.v]:
continue # nunca visita mais de uma vez
# esta é a menor no momento, portanto adiciona à solução
result.append(edge)
visit(edge.v) # visita o vértice ao qual esta aresta se conecta
return result

def print_weighted_path(wg: WeightedGraph, wp: WeightedPath) -> None:


for edge in wp:
print(f"{wg.vertex_at(edge.u)} {edge.weight}>
{wg.vertex_at(edge.v)}")
print(f"Total Weight: {total_weight(wp)}")

Vamos descrever mst() linha a linha.


def mst(wg: WeightedGraph[V], start: int = 0) -> Optional[WeightedPath]:
if start > (wg.vertex_count - 1) or start < 0:
return None

O algoritmo devolve um WeightedPath opcional que representa a árvore


geradora mínima. Não importa em que ponto o algoritmo começa
(supondo que o grafo seja conectado e não direcionado), portanto o
default é de nido com o vértice de índice 0. Caso aconteça de start ser
inválido, mst()devolverá None.
result: WeightedPath = [] # holds the final MST
pq: PriorityQueue[WeightedEdge] = PriorityQueue()
visited: [bool] = [False] * wg.vertex_count # locais já visitados

No nal, result armazenará o caminho com peso, contendo a árvore


geradora mínima. É aí que adicionaremos as WeightedEdges, pois a aresta
de menor peso será removida da la e nos levará para uma nova parte do
grafo. O algoritmo de Jarník é considerado um algoritmo guloso (greedy)
porque sempre seleciona a aresta de menor peso. pq é o local em que
arestas recém-descoberta são armazenadas e do qual a próxima aresta de
menor peso será removida. visited mantém o controle dos índices dos
vértices que já visitamos. Isso poderia ter sido feito também com um Set,
semelhante ao explored de bfs().
def visit(index: int):
visited[index] = True # marca como visitado
for edge in wg.edges_for_index(index):
# adiciona todas as arestas que partem daqui
if not visited[edge.v]:
pq.push(edge)

visit() é uma função auxiliar interna que marca um vértice como visitado
e adiciona todas as arestas conectadas a vértices ainda não visitados em
pq .
Observe a facilidade com que o modelo de lista de adjacências permite
encontrar as arestas que pertencem a um vértice em particular.
visit(start) # o primeiro vértice é onde tudo começa

Não importa qual vértice será visitado antes, a menos que o grafo não
seja conectado. Se o grafo não for conectado, mas for composto de
componentes desconectados, mst() devolverá uma árvore que se entende
pelo componente em particular ao qual o vértice inicial pertence.
while not pq.empty: # continua enquanto houver arestas para processar
edge = pq.pop()
if visited[edge.v]:
continue # nunca visita mais de uma vez
# esta é a menor no momento, portanto adiciona à solução
result.append(edge)
visit(edge.v) # visita o vértice ao qual esta aresta se conecta
return result

Enquanto ainda houver arestas na la de prioridades, elas serão


removidas e veri cadas para saber se conduzem a vértices que ainda não
estão na árvore. Como a la de prioridades está em ordem crescente, as
arestas de menor peso são removidas antes. Isso garante que o resultado
tenha realmente o peso total mínimo. Qualquer aresta removida e que não
conduza a um vértice inexplorado será ignorada. Caso contrário, como a
aresta é a de menor peso vista até agora, ela será adicionada ao conjunto
resultante, e o novo vértice ao qual ela conduz será explorado. Quando
não houver mais arestas restantes para explorar, o resultado será
devolvido.
Vamos, por m, retornar ao problema de conectar todas as 15 maiores
MSAs dos Estados Unidos com o Hyperloop, usando uma quantidade
mínima de trilhos. A rota que faz isso é simplesmente a árvore geradora
mínima de city_graph2. Vamos experimentar executar mst() em city_graph2.
Listagem 4.12 – Continuação de mst.py
if __name__ == "__main__":
city_graph2: WeightedGraph[str] = WeightedGraph(["Seattle", "San
Francisco", "Los Angeles", "Riverside", "Phoenix", "Chicago", "Boston",
"New York", "Atlanta", "Miami", "Dallas", "Houston", "Detroit",
"Philadelphia", "Washington"])
city_graph2.add_edge_by_vertices("Seattle", "Chicago", 1737)
city_graph2.add_edge_by_vertices("Seattle", "San Francisco", 678)
city_graph2.add_edge_by_vertices("San Francisco", "Riverside", 386)
city_graph2.add_edge_by_vertices("San Francisco", "Los Angeles", 348)
city_graph2.add_edge_by_vertices("Los Angeles", "Riverside", 50)
city_graph2.add_edge_by_vertices("Los Angeles", "Phoenix", 357)
city_graph2.add_edge_by_vertices("Riverside", "Phoenix", 307)
city_graph2.add_edge_by_vertices("Riverside", "Chicago", 1704)
city_graph2.add_edge_by_vertices("Phoenix", "Dallas", 887)
city_graph2.add_edge_by_vertices("Phoenix", "Houston", 1015)
city_graph2.add_edge_by_vertices("Dallas", "Chicago", 805)
city_graph2.add_edge_by_vertices("Dallas", "Atlanta", 721)
city_graph2.add_edge_by_vertices("Dallas", "Houston", 225)
city_graph2.add_edge_by_vertices("Houston", "Atlanta", 702)
city_graph2.add_edge_by_vertices("Houston", "Miami", 968)
city_graph2.add_edge_by_vertices("Atlanta", "Chicago", 588)
city_graph2.add_edge_by_vertices("Atlanta", "Washington", 543)
city_graph2.add_edge_by_vertices("Atlanta", "Miami", 604)
city_graph2.add_edge_by_vertices("Miami", "Washington", 923)
city_graph2.add_edge_by_vertices("Chicago", "Detroit", 238)
city_graph2.add_edge_by_vertices("Detroit", "Boston", 613)
city_graph2.add_edge_by_vertices("Detroit", "Washington", 396)
city_graph2.add_edge_by_vertices("Detroit", "New York", 482)
city_graph2.add_edge_by_vertices("Boston", "New York", 190)
city_graph2.add_edge_by_vertices("New York", "Philadelphia", 81)
city_graph2.add_edge_by_vertices("Philadelphia", "Washington", 123)
result: Optional[WeightedPath] = mst(city_graph2)
if result is None:
print("No solution found!")
else:
print_weighted_path(city_graph2, result)

Graças ao método printWeightedPath() para exibição elegante, a árvore


geradora mínima é fácil de ler.
Seattle 678> San Francisco
San Francisco 348> Los Angeles
Los Angeles 50> Riverside
Riverside 307> Phoenix
Phoenix 887> Dallas
Dallas 225> Houston
Houston 702> Atlanta
Atlanta 543> Washington
Washington 123> Philadelphia
Philadelphia 81> New York
New York 190> Boston
Washington 396> Detroit
Detroit 238> Chicago
Atlanta 604> Miami
Total Weight: 5372

Em outras palavras, esse é o menor conjunto cumulativo de arestas que


conectam todas as MSAs do grafo com peso. O comprimento mínimo de
trilhos necessário para conectar todas as MSAs é de 5.372 milhas
(aproximadamente 8.645 quilômetros). A Figura 4.7 exibe a árvore
geradora mínima.

Figura 4.7 – As arestas em destaque representam a árvore geradora mínima


que conecta todas as 15 MSAs.

4.5 Encontrando caminhos mínimos em um grafo com peso


À medida que a rede Hyperloop for construída, é improvável que os
construtores tenham a ambição de conectar todo o país de uma só vez.
Em vez disso, os construtores provavelmente vão querer minimizar o
custo de instalação dos trilhos entre as principais cidades. O custo para
ampliar a rede até cidades especí cas obviamente dependerá do ponto em
que os construtores iniciarem.
Encontrar o custo para qualquer cidade, partindo de alguma cidade
inicial, é uma versão do problema do “caminho mínimo com origem
única” (single-source shortest path). Esse problema faz a seguinte
pergunta: “Qual é o caminho mínimo (no que diz respeito ao peso total
das arestas) de algum vértice para todos os demais vértices em um grafo
com peso?”.

4.5.1 Algoritmo de Dijkstra


O algoritmo de Dijkstra resolve o problema do caminho mínimo com
origem única. Um vértice inicial é fornecido ao algoritmo, e ele devolverá
o caminho de menor peso para qualquer outro vértice em um grafo com
peso. Também devolverá o peso total mínimo para todos os outros
vértices, partindo do vértice inicial. O algoritmo de Dijkstra começa no
vértice único de origem e, em seguida, explora continuamente os vértices
mais próximos ao vértice inicial. Por esse motivo, assim como o algoritmo
de Jarník, o algoritmo de Dijkstra é guloso (greedy). Quando o algoritmo
de Dijkstra encontra um novo vértice, ele armazena a distância do vértice
inicial até esse vértice e atualiza esse valor caso encontre um caminho
mais curto. Ele também armazena qual aresta conduziu a cada vértice,
como em uma busca em largura.
Eis os passos completos do algoritmo:
1. Adicione o vértice inicial em uma la de prioridades.
2. Remova o vértice mais próximo da la de prioridades (no início, será
apenas o vértice inicial); nós o chamaremos de vértice atual.
3. Observe todos os vizinhos conectados ao vértice atual. Caso ainda
não tenham sido registrados antes, ou se a aresta oferecer um novo
caminho mínimo até eles, para cada um deles, registre sua distância a
partir do início, armazene a aresta que gerou essa distância e
acrescente o novo vértice na la de prioridades.
4. Repita os passos 2 e 3 até que a la de prioridades esteja vazia.
5. Devolva a distância mínima para todos os vértices a partir do vértice
inicial, e o caminho para cada um deles.
O código do algoritmo de Dijkstra inclui DijkstraNode, que é uma
estrutura de dados simples para manter o controle dos custos associados
a cada vértice explorado até agora e compará-los. É semelhante à classe
Node do Capítulo 2. Ele também inclui funções utilitárias para converter o
array de distâncias devolvido em algo mais fácil de ser usado para
consultas por vértice e para calcular um caminho mínimo até um vértice
de destino especí co, a partir do dicionário de caminhos devolvido por
dijkstra() .
Sem mais demora, eis o código do algoritmo de Dijkstra. Vamos
descrevê-lo linha a linha.
Listagem 4.13 – dijkstra.py
from __future__ import annotations
from typing import TypeVar, List, Optional, Tuple, Dict
from dataclasses import dataclass
from mst import WeightedPath, print_weighted_path
from weighted_graph import WeightedGraph
from weighted_edge import WeightedEdge
from priority_queue import PriorityQueue

V = TypeVar('V') # tipo dos vértices no grafo

@dataclass
class DijkstraNode:
vertex: int
distance: float
def __lt__(self, other: DijkstraNode) -> bool:
return self.distance < other.distance
def __eq__(self, other: DijkstraNode) -> bool:
return self.distance == other.distance

def dijkstra(wg: WeightedGraph[V], root: V) -> Tuple[List[Optional[float]],


Dict[int, WeightedEdge]]:
first: int = wg.index_of(root) # encontra o índice inicial
# inicialmente, as distâncias são desconhecidas
distances: List[Optional[float]] = [None] * wg.vertex_count
distances[first] = 0 # a raiz está a uma distância 0 da raiz
path_dict: Dict[int, WeightedEdge] = {} # como chegamos até cada vértice
pq: PriorityQueue[DijkstraNode] = PriorityQueue()
pq.push(DijkstraNode(first, 0))
while not pq.empty:
u: int = pq.pop().vertex # explora o vértice mais próximo a seguir
dist_u: float = distances[u] # caso já tenha sido visto
# analisa todas as arestas/vértices a partir deste vértice
for we in wg.edges_for_index(u):
# a distância anterior até este vértice
dist_v: float = distances[we.v]
# não há distância anterior ou um caminho mais curto foi
encontrado
if dist_v is None or dist_v > we.weight + dist_u:
# atualiza a distância até este vértice
distances[we.v] = we.weight + dist_u
# atualiza a aresta no caminho mínimo até este vértice
path_dict[we.v] = we
# será explorado em breve
pq.push(DijkstraNode(we.v, we.weight + dist_u))
return distances, path_dict

# Função auxiliar para ter um acesso mais fácil aos resultados de dijkstra
def distance_array_to_vertex_dict(wg: WeightedGraph[V], distances:
List[Optional[float]]) -> Dict[V, Optional[float]]:
distance_dict: Dict[V, Optional[float]] = {}
for i in range(len(distances)):
distance_dict[wg.vertex_at(i)] = distances[i]
return distance_dict

# Recebe um dicionário de arestas para alcançar cada nó e devolve


# uma lista de arestas que vão de `start` até `end`
def path_dict_to_path(start: int, end: int, path_dict: Dict[int,
WeightedEdge]) -> WeightedPath:
if len(path_dict) == 0:
return []
edge_path: WeightedPath = []
e: WeightedEdge = path_dict[end]
edge_path.append(e)
while e.u != start:
e = path_dict[e.u]
edge_path.append(e)
return list(reversed(edge_path))

As primeiras linhas de dijkstra() utilizam estruturas de dados com as


quais já temos familiaridade, exceto por distances, que é uma área para
armazenar as distâncias de root até cada um dos vértices do grafo.
Inicialmente, todas essas distâncias são iguais a None porque ainda não
sabemos a distância até cada um desses vértices; estamos usando o
algoritmo de Dijkstra exatamente para descobrir essa informação!
def dijkstra(wg: WeightedGraph[V], root: V) ->
Tuple[List[Optional[float]], Dict[int, WeightedEdge]]:
first: int = wg.index_of(root) # encontra o índice inicial
# inicialmente, as distâncias são desconhecidas
distances: List[Optional[float]] = [None] * wg.vertex_count
distances[first] = 0 # a raiz está a uma distância 0 da raiz
path_dict: Dict[int, WeightedEdge] = {} # como chegamos até cada
vértice
pq: PriorityQueue[DijkstraNode] = PriorityQueue()
pq.push(DijkstraNode(first, 0))

O primeiro nó inserido na la de prioridades contém o vértice raiz.


while not pq.empty:
u: int = pq.pop().vertex # explora o vértice mais próximo a seguir
dist_u: float = distances[u] # caso já tenha sido visto

Continuamos executando o algoritmo de Dijkstra até que a la de


prioridades esteja vazia. u é o vértice atual a partir do qual estamos
pesquisando e dist_u é a distância armazenada para chegar até u por meio
de rotas conhecidas. Todo vértice explorado nessa etapa já foi
encontrado, portanto deverá ter uma distância conhecida.
# analisa todas as arestas/vértices a partir deste vértice
for we in wg.edges_for_index(u):
# a distância anterior até este vértice
dist_v: float = distances[we.v]

Em seguida, toda aresta conectada a u é explorada. dist_v é a distância


para qualquer vértice conhecido conectado por uma aresta a partir de u.
# não há distância anterior ou um caminho mais curto foi encontrado
if dist_v is None or dist_v > we.weight + dist_u:
# atualiza a distância até este vértice
distances[we.v] = we.weight + dist_u
# atualiza a aresta no caminho mínimo
path_dict[we.v] = we
# será explorado em breve
pq.push(DijkstraNode(we.v, we.weight + dist_u))

Se encontramos um vértice que ainda não tenha sido explorado (dist_v é


None ), ou se encontramos um novo caminho mínimo até ele, registramos
essa nova distância menor até v e a aresta que nos levou até ela. Por m,
inserimos qualquer vértice que tenha novos caminhos até eles na la de
prioridades.
return distances, path_dict

dijkstra()devolve tanto as distâncias até cada vértice do grafo com peso,


partindo do vértice raiz, e o path_dict que identi ca os caminhos mínimos
até eles.
É seguro executar o algoritmo de Dijkstra agora. Começaremos
encontrando a distância de Los Angeles até todas as demais MSAs no
grafo. Em seguida, encontraremos o caminho mínimo entre Los Angeles
e Boston. Por m, usaremos print_weighted_path() para exibir o resultado
de forma elegante.
Listagem 4.14 – Continuação de dijkstra.py
if __name__ == "__main__":
city_graph2: WeightedGraph[str] = WeightedGraph(["Seattle", "San
Francisco", "Los Angeles", "Riverside", "Phoenix", "Chicago", "Boston",
"New York", "Atlanta", "Miami", "Dallas", "Houston", "Detroit",
"Philadelphia", "Washington"])
city_graph2.add_edge_by_vertices("Seattle", "Chicago", 1737)
city_graph2.add_edge_by_vertices("Seattle", "San Francisco", 678)
city_graph2.add_edge_by_vertices("San Francisco", "Riverside", 386)
city_graph2.add_edge_by_vertices("San Francisco", "Los Angeles", 348)
city_graph2.add_edge_by_vertices("Los Angeles", "Riverside", 50)
city_graph2.add_edge_by_vertices("Los Angeles", "Phoenix", 357)
city_graph2.add_edge_by_vertices("Riverside", "Phoenix", 307)
city_graph2.add_edge_by_vertices("Riverside", "Chicago", 1704)
city_graph2.add_edge_by_vertices("Phoenix", "Dallas", 887)
city_graph2.add_edge_by_vertices("Phoenix", "Houston", 1015)
city_graph2.add_edge_by_vertices("Dallas", "Chicago", 805)
city_graph2.add_edge_by_vertices("Dallas", "Atlanta", 721)
city_graph2.add_edge_by_vertices("Dallas", "Houston", 225)
city_graph2.add_edge_by_vertices("Houston", "Atlanta", 702)
city_graph2.add_edge_by_vertices("Houston", "Miami", 968)
city_graph2.add_edge_by_vertices("Atlanta", "Chicago", 588)
city_graph2.add_edge_by_vertices("Atlanta", "Washington", 543)
city_graph2.add_edge_by_vertices("Atlanta", "Miami", 604)
city_graph2.add_edge_by_vertices("Miami", "Washington", 923)
city_graph2.add_edge_by_vertices("Chicago", "Detroit", 238)
city_graph2.add_edge_by_vertices("Detroit", "Boston", 613)
city_graph2.add_edge_by_vertices("Detroit", "Washington", 396)
city_graph2.add_edge_by_vertices("Detroit", "New York", 482)
city_graph2.add_edge_by_vertices("Boston", "New York", 190)
city_graph2.add_edge_by_vertices("New York", "Philadelphia", 81)
city_graph2.add_edge_by_vertices("Philadelphia", "Washington", 123)
distances, path_dict = dijkstra(city_graph2, "Los Angeles")
name_distance: Dict[str, Optional[int]] =
distance_array_to_vertex_dict(city_graph2, distances)
print("Distances from Los Angeles:")
for key, value in name_distance.items():
print(f"{key} : {value}")
print("") # linha em branco
print("Shortest path from Los Angeles to Boston:")
path: WeightedPath = path_dict_to_path(city_graph2.index_of("Los
Angeles"),
city_graph2.index_of("Boston"), path_dict)
print_weighted_path(city_graph2, path)

A saída deverá ter um aspecto semelhante a este:


Distances from Los Angeles:
Seattle : 1026
San Francisco : 348
Los Angeles : 0
Riverside : 50
Phoenix : 357
Chicago : 1754
Boston : 2605
New York : 2474
Atlanta : 1965
Miami : 2340
Dallas : 1244
Houston : 1372
Detroit : 1992
Philadelphia : 2511
Washington : 2388
Shortest path from Los Angeles to Boston:
Los Angeles 50> Riverside
Riverside 1704> Chicago
Chicago 238> Detroit
Detroit 613> Boston
Total Weight: 2605

Talvez você tenha percebido que o algoritmo de Dijkstra tem certa


semelhança com o algoritmo de Jarník. Ambos são gulosos (greedy) e é
possível implementá-los usando um código bastante parecido se houver
motivação su ciente. Outro algoritmo que se assemelha ao algoritmo de
Dijkstra é o A* do Capítulo 2. O A* pode ser considerada uma
modi cação do algoritmo de Dijkstra. Acrescente uma heurística e
restrinja o algoritmo de Dijkstra de modo que encontre um único
destino, e os dois algoritmos serão iguais.
NOTA O algoritmo de Dijkstra foi concebido para grafos com pesos positivos. Grafos com
arestas de pesos negativos podem representar um desa o para o algoritmo de Dijkstra e
exigirão modi cações ou um algoritmo alternativo.

4.6 Aplicações no mundo real


Uma parte enorme de nosso mundo pode ser representada com grafos.
Neste capítulo, vimos quão e cientes eles são para trabalhar com redes de
transporte, mas vários outros tipos de redes diferentes têm os mesmos
problemas básicos de otimização: redes de telefonia, de computadores,
de serviços públicos essenciais (energia elétrica, água e esgoto, e assim
por diante). Como resultado, os algoritmos de grafo são essenciais para
ter e ciência em telecomunicações, fretes, transportes e no mercado de
serviços públicos essenciais.
As lojas de varejo precisam lidar com problemas complexos de
distribuição. Podemos pensar nas lojas e armazéns de distribuição como
vértices e nas distâncias entre eles como as arestas. Os algoritmos são os
mesmos. A própria internet é um grafo gigantesco, em que cada
dispositivo conectado é um vértice e cada conexão com ou sem o é uma
aresta. Independentemente de uma empresa estar economizando
combustível ou os, a árvore geradora mínima (minimum spanning tree)
e a resolução do problema de caminhos mínimos são úteis para outras
situações que vão além de apenas jogos. Algumas das marcas globais
mais famosas se tornaram bem-sucedidas otimizando problemas de
grafos: pense na Walmart construindo uma rede e ciente de distribuição,
o Google indexando a web (um grafo gigante) e a FedEx encontrando o
conjunto correto de centros de distribuição para conectar os vários
endereços no mundo.
Algumas aplicações óbvias de algoritmos de grafos são as redes sociais e
os aplicativos de mapa. Em uma rede social, as pessoas são os vértices, e
as conexões (amizades no Facebook, por exemplo) são as arestas. Com
efeito, uma das ferramentas de desenvolvedor mais proeminentes do
Facebook é conhecida como Graph API, isto é, API de Grafos
(https://developers.facebook.com/docs/graph-api). Em aplicativos de mapa,
como o Apple Maps e o Google Maps, algoritmos de grafo são usados
para fornecer rotas e calcular tempos de viagem.
Vários videogames populares também fazem uso explícito de algoritmos
de grafos. O Mini-Metro e o Ticket to Ride são dois exemplos de jogos
que imitam de perto os problemas resolvidos neste capítulo.

4.7 Exercícios
1. Acrescente suporte no framework de grafos para remoção de arestas
e de vértices.
2. Acrescente suporte no framework de grafos para grafos direcionados
(digrafos).
3. Utilize o framework de grafos deste capítulo para comprovar ou
refutar o problema clássico das Pontes de Königsberg, conforme
descrito na Wikipédia:
https://en.wikipedia.org/wiki/Seven_Bridges_of_Königsberg.

1 Dados do American Fact Finder do United States Census Bureau, https://fact nder.census.gov/.
2 Elon Musk, “Hyperloop Alpha”, http://mng.bz/chmu.
3 Helena Durnová, “Otakar Borůvka (1899-1995) and the Minimum Spanning Tree” (Otakar
Borůvka (1899-1995) e a Árvore Geradora Mínima) (Instituto de Matemática da Academia
Tcheca de Ciências, 2006), http://mng.bz/O2vj.
4 Inspirado em uma solução de Robert Sedgewick e Kevin Wayne, Algorithms, 4ª edição
(Addison-Wesley Professional, 2011), p. 619.
CAPÍTULO 5

Algoritmos genéticos

Os algoritmos genéticos não são usados para problemas de programação


do cotidiano. Lançamos mão deles quando abordagens algorítmicas
tradicionais são insu cientes para se chegar à solução de um problema
em tempo razoável. Em outras palavras, algoritmos genéticos em geral
são reservados para problemas complexos, sem soluções fáceis. Se você
precisa ter uma noção de quais possam ser esses problemas complexos,
sinta-se à vontade para ler a Seção 5.7 antes de prosseguir. Um exemplo
interessante, porém, é o docking de proteína-ligante (protein-ligand
docking) e o design de fármacos. Pro ssionais em biologia
computacional precisam fazer o design de moléculas que se ligarão a
receptores para a condução de medicamentos. Talvez não haja algoritmos
óbvios para fazer o design de uma molécula em particular, mas, como
veremos, às vezes, os algoritmos genéticos podem fornecer uma resposta
sem que tenham muitas orientações além de uma de nição do objetivo de
um problema.

5.1 Background em biologia


Em biologia, a teoria da evolução é uma explicação sobre como a
mutação genética, junto com as limitações de um ambiente, levou a
mudanças em organismos com o passar do tempo (incluindo a
especiação, isto é, a criação de novas espécies). O mecanismo pelo qual
os organismos bem adaptados tiveram sucesso e os menos adaptados
falharam é conhecido como seleção natural. Cada geração de uma espécie
incluirá indivíduos com traços diferentes (às vezes, novos), que surgem
em decorrência de uma mutação genética. Todos os indivíduos
competem por recursos limitados para sobreviver, e, como há mais
indivíduos do que recursos, alguns devem perecer.
Um indivíduo com uma mutação que o torne mais bem adaptado para
sobreviver em seu ambiente terá uma probabilidade maior de viver e de se
reproduzir. Com o tempo, os indivíduos mais bem adaptados em um
ambiente terão mais descendentes e, por meio de herança, passarão suas
mutações a eles. Desse modo, uma mutação que seja vantajosa para a
sobrevivência provavelmente se proliferará no futuro, em uma população.
Por exemplo, se bactérias estiverem sendo mortas por um antibiótico
especí co, e uma bactéria individual na população tiver uma mutação em
um gene que a torne mais resistente ao antibiótico, é mais provável que
ela sobreviva e se reproduza. Se o antibiótico for continuamente aplicado,
os descendentes que tiverem herdado o gene para a resistência ao
antibiótico também terão mais chances de se reproduzir e ter
descendentes próprios. Em algum momento, toda a população poderá
adquirir a mutação, pois aplicações contínuas do antibiótico eliminarão
os indivíduos sem a mutação. O antibiótico não faz com que a mutação
se desenvolva, porém leva à proliferação de indivíduos com a mutação.
A seleção natural tem sido aplicada em esferas que vão além da biologia.
O darwinismo social é a seleção natural aplicada à esfera da teoria social.
Em ciência da computação, os algoritmos genéticos são uma simulação
da seleção natural para resolver desa os computacionais.
Um algoritmo genético inclui uma população (grupo) de indivíduos
conhecidos como cromossomos. Os cromossomos, cada qual composto
de genes que especi cam seus traços, competem para resolver algum
problema. A competência com que um cromossomo é capaz de resolver
um problema é de nida por uma função de aptidão ( tness function,
também chamada de função de avaliação).
O algoritmo genético passa por gerações. Em cada geração, há uma
chance maior de os cromossomos mais aptos serem selecionados para se
reproduzir. Há também uma probabilidade de que dois cromossomos
tenham seus genes misturados em cada geração. Isso é conhecido como
crossover. Por m, existe uma possibilidade relevante de que, em cada
geração, um gene em um cromossomo possa sofrer uma mutação
(modi car-se aleatoriamente).
Depois que a função de aptidão de alguns indivíduos da população
ultrapassar um limiar especi cado, ou o algoritmo executar um
determinado número máximo de gerações, o melhor indivíduo (aquele
com a maior pontuação na função de aptidão) será devolvido.
Os algoritmos genéticos não são uma boa solução para qualquer
problema. Eles dependem de três operações parcialmente ou totalmente
estocásticas (determinados aleatoriamente): seleção, crossover e mutação.
Desse modo, podem não encontrar uma solução ótima em tempo
razoável. Para a maioria dos problemas, há algoritmos mais
determinísticos, com garantias melhores. Contudo, há problemas para os
quais não há nenhum algoritmo determinístico rápido. Nesses casos, os
algoritmos genéticos são uma boa opção.

5.2 Algoritmo genético genérico


Os algoritmos genéticos muitas vezes são extremamente especializados e
ajustados para uma aplicação especí ca. Neste capítulo, de niremos um
algoritmo genético genérico que poderá ser usado em vários problemas,
apesar de não estar particularmente bem ajustado para nenhum deles. O
algoritmo incluirá algumas opções con guráveis, mas o objetivo é
demonstrar suas bases, e não a possibilidade de ajustes.
Começaremos de nindo uma interface para os indivíduos nos quais o
algoritmo poderá atuar. A classe abstrata Chromosome de ne quatro
recursos essenciais. Um cromossomo deve ser capaz de fazer o seguinte:
• determinar a sua própria aptidão;
• criar uma instância com genes selecionados aleatoriamente (para
preencher a primeira geração);
• implementar um crossover (combinar a si mesmo com outro do
mesmo tipo para gerar lhos) – em outras palavras, misturar a si
mesmo com outro cromossomo;
• efetuar uma mutação – fazer uma pequena modi cação razoavelmente
aleatória em si mesmo.
Eis o código de Chromosome, que implementa esses quatro recursos.
Listagem 5.1 – chromosome.py
from __future__ import annotations
from typing import TypeVar, Tuple, Type
from abc import ABC, abstractmethod

T = TypeVar('T', bound='Chromosome') # para devolver a si mesmo

# Classe-base para todos os cromossomos; todos os métodos devem ser


sobrescritos
class Chromosome(ABC):
@abstractmethod
def fitness(self) -> float:
...

@classmethod
@abstractmethod
def random_instance(cls: Type[T]) -> T:
...

@abstractmethod
def crossover(self: T, other: T) -> Tuple[T, T]:
...

@abstractmethod
def mutate(self) -> None:
...
DICA Você notará que o TypeVar T está limitado a Chromosome em seu construtor. Isso
signi ca que tudo que preencher uma variável do tipo T deve ser uma instância de
Chromosome ou de uma subclasse de Chromosome .
Implementaremos o algoritmo propriamente dito (o código que
manipulará os cromossomos) como uma classe genérica, passível de ter
subclasses para futuras aplicações especializadas. Antes de fazer isso,
porém, vamos rever a descrição de um algoritmo genético do início do
capítulo e de nir claramente os passos que esse algoritmo executa:
1. Crie uma população inicial de cromossomos aleatórios para a
primeira geração do algoritmo.
2. Avalie a aptidão de cada cromossomo nessa geração da população. Se
algum deles exceder o limiar, devolva-o, e o algoritmo terminará.
3. Selecione alguns indivíduos para se reproduzir, com uma
probabilidade maior de selecionar aqueles com as melhores aptidões.
4. Faça um crossover (combinação), com certa probabilidade, de alguns
cromossomos selecionados, a m de criar lhos que representem a
população para a próxima geração.
5. Faça uma mutação, geralmente com uma baixa probabilidade, em
alguns desses cromossomos. A população da nova geração agora
estará completa e substituirá a população da geração anterior.
6. Retorne ao passo 2, a menos que o número máximo de gerações
tenha sido alcançado. Se isso acontecer, devolva o melhor
cromossomo encontrado até então.
Há vários detalhes importantes que estão ausentes nessa descrição geral
de um algoritmo genético (ilustrado na Figura 5.1). Quantos
cromossomos deve haver na população? Qual é o limiar para interromper
o algoritmo? Como os cromossomos devem ser selecionados para
reprodução? Como devem ser combinados (crossover) e com qual
probabilidade? Com qual probabilidade as mutações devem ocorrer?
Quantas gerações deve haver?
Figura 5.1 – Esquema geral de um algoritmo genético.
Todos esses pontos serão con guráveis em nossa classe
GeneticAlgorithm . Vamos de ni-la por partes para que possamos discutir
cada uma separadamente.
Listagem 5.2 – genetic_algorithm.py
from __future__ import annotations
from typing import TypeVar, Generic, List, Tuple, Callable
from enum import Enum
from random import choices, random
from heapq import nlargest
from statistics import mean
from chromosome import Chromosome

C = TypeVar('C', bound=Chromosome) # tipo dos cromossomos

class GeneticAlgorithm(Generic[C]):
SelectionType = Enum("SelectionType", "ROULETTE TOURNAMENT")
GeneticAlgorithm recebe um tipo genérico que está em consonância com
Chromosome , e seu nome é C . O enum SelectionType é um tipo interno usado
para especi car o método de seleção utilizado pelo algoritmo. Os dois
métodos de seleção mais comuns em algoritmos genéticos são
conhecidos como seleção por roleta (roulette-wheel selection) – às vezes
chamada de seleção proporcional à aptidão ( tness proportionate
selection) – e a seleção por torneio (tournament selection). O primeiro dá
a todos os cromossomos uma chance de ser escolhido, proporcional à
sua aptidão. Na seleção por torneio, um determinado número de
cromossomos aleatórios é desa ado, uns contra os outros, e aquele com
a melhor aptidão será selecionado.
Listagem 5.3 – Continuação de genetic_algorithm.py
def __init__(self, initial_population: List[C], threshold: float,
max_generations: int = 100, mutation_chance: float = 0.01,
crossover_chance: float = 0.7, selection_type: SelectionType =
SelectionType.TOURNAMENT) -> None:
self._population: List[C] = initial_population
self._threshold: float = threshold
self._max_generations: int = max_generations
self._mutation_chance: float = mutation_chance
self._crossover_chance: float = crossover_chance
self._selection_type: GeneticAlgorithm.SelectionType = selection_type
self._fitness_key: Callable = type(self._population[0]).fitness

O código anterior inclui todas as propriedades do algoritmo genético, as


quais serão con guradas com __init__() no momento da criação.
initial_population são os cromossomos na primeira geração do algoritmo.
threshold é o nível de aptidão que indica que uma solução para o
problema que o algoritmo genético está tentando resolver foi encontrada.
max_generations é o número máximo de gerações que deve haver. Se
tivermos de executar esse número de gerações e nenhuma solução com
um nível de aptidão acima de threshold for encontrada, a melhor solução
encontrada será devolvida. mutation_chance é a probabilidade de cada
cromossomo em cada geração sofrer uma mutação. crossover_chance é a
probabilidade de dois pais selecionados para reproduzir gerarem lhos
que sejam uma mistura de seus genes; caso contrário, os lhos serão
apenas duplicatas dos pais. Por m, selection_type é o tipo do método de
seleção a ser usado, conforme representado pelo enum SelectionType.
O método init anterior aceita uma longa lista de parâmetros, em que a
maioria tem valores default. Eles de nem as versões de instância das
propriedades con guráveis que acabamos de discutir. Em nossos
exemplos, _population será inicializado com um conjunto aleatório de
cromossomos usando o método de classe random_instance() da classe
Chromosome . Em outras palavras, a primeira geração de cromossomos será
composta simplesmente de indivíduos aleatórios. Esse é um ponto de
possível otimização em um algoritmo genético mais so sticado. Em vez
de começar com indivíduos puramente aleatórios, a primeira geração
poderia conter indivíduos que estejam mais próximos da solução, o que
poderá ser feito com algum tipo de conhecimento acerca do problema.
Isso é conhecido como seeding (semeadura).
_fitness_key é uma referência ao método que usaremos em todo a classe
GeneticAlgorithm para calcular a aptidão de um cromossomo. Lembre-se de
que essa classe deve funcionar para qualquer subclasse de Chromosome.
Desse modo, _fitness_key será diferente conforme a subclasse. Para
acessá-lo, usamos type() para referenciar a subclasse especí ca de
Chromosome da qual estamos determinando a aptidão.
Analisaremos agora os dois métodos de seleção aceitos por nossa classe.
Listagem 5.4 – Continuação de genetic_algorithm.py
# Usa a roleta de distribuição de probabilidades para escolher dois pais
# Nota: não trabalharemos com resultados negativos de aptidão
def _pick_roulette(self, wheel: List[float]) -> Tuple[C, C]:
return tuple(choices(self._population, weights=wheel, k=2))

A seleção por roleta é baseada na proporção da aptidão de cada


cromossomo em relação à soma de todas as aptidões em uma geração. Os
cromossomos com as maiores aptidões terão mais chance de serem
escolhidos. Os valores que representam a aptidão de cada cromossomo
são fornecidos no parâmetro wheel. A escolha propriamente dita é feita de
modo conveniente pela função choices() do módulo random da biblioteca-
padrão de Python. Essa função aceita uma lista de itens entre os quais
queremos fazer uma escolha, uma lista de mesmo tamanho contendo
pesos para cada item da primeira lista e a quantidade de itens que
queremos escolher.
Se fôssemos implementar isso por conta própria, poderíamos calcular
porcentagens da aptidão total para cada item (aptidões proporcionais)
representadas por valores de ponto utuante entre 0 e 1. Um número
aleatório (pick) entre 0 e 1 poderia ser usado para determinar qual
cromossomo deve ser selecionado. O algoritmo funcionaria
decrementando pick do valor de aptidão proporcional de cada
cromossomo, sequencialmente. Quando pick chegar a 0, esse é o
cromossomo a ser selecionado.
Faz sentido para você o motivo pelo qual esse processo resulta em cada
cromossomo ser escolhido de acordo com a sua proporção? Se não zer,
pense nisso usando lápis e papel. Considere desenhar uma roleta com
proporções, como mostra a Figura 5.2.

Figura 5.2 – Exemplo de uma seleção por roleta em ação.


A forma básica de seleção por torneio é mais simples que a seleção por
roleta. Em vez de determinar as proporções, basta escolher k
cromossomos dentre toda a população de forma aleatória. Os dois
cromossomos com as melhores aptidões entre o grupo aleatoriamente
selecionado vencem.
Listagem 5.5 – Continuação de genetic_algorithm.py
# Escolhe num_participants aleatoriamente e seleciona os 2 melhores
def _pick_tournament(self, num_participants: int) -> Tuple[C, C]:
participants: List[C] = choices(self._population, k=num_participants)
return tuple(nlargest(2, participants, key=self._fitness_key))

O código de _pick_tournament() inicialmente utiliza choices() para escolher


aleatoriamente num_participants de _population. Em seguida, a função
nlargest() do módulo heapq é usada para encontrar os dois maiores
indivíduos de acordo com _fitness_key. Qual é o número correto para
num_participants ? Como ocorre com muitos parâmetros em um algoritmo
genético, o método de tentativa e erro talvez seja a melhor maneira de
determiná-lo. Um ponto a ser lembrado é que um número maior de
participantes no torneio resulta em menos diversidade na população, pois
é mais provável que os cromossomos com menos aptidão sejam
eliminados nas disputas.1 Formas mais so sticadas de seleção por torneio
podem escolher indivíduos que não são os melhores, mas os segundo ou
terceiro melhores, com base em algum tipo de modelo de probabilidade
decrescente.
Esses dois métodos, _pick_roulette() e _pick_tournament(), são usados
para seleção, que ocorre durante a reprodução. A reprodução está
implementada em _reproduce_ and_replace(), e ela também cuida de
garantir que uma nova população com o mesmo número de
cromossomos substitua os cromossomos da última geração.
Listagem 5.6 – Continuação de genetic_algorithm.py
# Substitui a população por uma nova geração de indivíduos
def _reproduce_and_replace(self) -> None:
new_population: List[C] = []
# continua até ter completada a nova geração
while len(new_population) < len(self._population):
# escolhe os 2 pais
if self._selection_type == GeneticAlgorithm.SelectionType.ROULETTE:
parents: Tuple[C, C] = self._pick_roulette([x.fitness() for x in
self._population])
else:
parents = self._pick_tournament(len(self._population) // 2)
# faz um possível crossover dos dois pais
if random() < self._crossover_chance:
new_population.extend(parents[0].crossover(parents[1]))
else:
new_population.extend(parents)
# se tivemos um número ímpar, teremos 1 extra, portanto ele será
removido
if len(new_population) > len(self._population):
new_population.pop()
self._population = new_population # substitui a referência

Em _reproduce_and_replace(), os passos a seguir, de modo geral, são


executados:
1. Dois cromossomos, chamados parents, são selecionados para
reprodução usando um dos dois métodos de seleção. Na seleção por
torneio, sempre executamos o torneio na metade da população total,
mas essa também poderia ser uma opção con gurável.
2. Há uma chance de _crossover_chance de os dois pais serem
combinados para gerar dois novos cromossomos, caso em que esses
serão adicionados em new_population. Se não houver lhos, os dois pais
serão simplesmente adicionados em new_population.
3. Se new_population tiver tantos cromossomos quanto em _population, ela
substituirá esse último. Caso contrário, retorne ao passo 1.
O método que implementa a mutação, _mutate(), é bem simples, e os
detalhes de como efetuar uma mutação são deixados a cargo dos
cromossomos individuais.
Listagem 5.7 – Continuação de genetic_algorithm.py
# Com uma probabilidade de _mutation_chance faz uma mutação em cada
indivíduo
def _mutate(self) -> None:
for individual in self._population:
if random() < self._mutation_chance:
individual.mutate()

Temos agora todos os blocos de construção necessários para executar o


algoritmo genético. run() coordena os passos de avaliação, reprodução
(que inclui a seleção) e mutação, que levam a população de uma geração
a outra. O método também mantém o controle do melhor cromossomo
(o mais apto) encontrado, em qualquer ponto da busca.
Listagem 5.8 – Continuação de genetic_algorithm.py
# Executa o algoritmo genético para max_generations iterações
# e devolve o melhor indivíduo encontrado
def run(self) -> C:
best: C = max(self._population, key=self._fitness_key)
for generation in range(self._max_generations):
# sai antes, se o limiar for atingido
if best.fitness() >= self._threshold:
return best
print(f"Generation {generation} Best {best.fitness()} Avg {
mean(map(self._fitness_key, self._population))}")
self._reproduce_and_replace()
self._mutate()
highest: C = max(self._population, key=self._fitness_key)
if highest.fitness() > best.fitness():
best = highest # encontrado um novo cromossomo melhor
return best # o melhor encontrado em _max_generations

best registra o melhor cromossomo encontrado até então. O laço


principal executa um número de vezes igual a _max_generations. Se algum
cromossomo exceder threshold em aptidão, ele será devolvido, e o método
terminará. Caso contrário, _reproduce_and_replace() será chamado, bem
como _mutate(), a m de criar a próxima geração e executar o laço
novamente. Se _max_generations for alcançado, o melhor cromossomo
encontrado até então será devolvido.

5.3 Teste simples


O algoritmo genético genérico GeneticAlgorithm funcionará com qualquer
tipo que implemente Chromosome. Para testar, começaremos implementando
um código para um problema simples, que pode ser facilmente
solucionado com métodos tradicionais. Tentaremos maximizar a equação
6x – x2 + 4y – y2. Em outras palavras, quais são os valores de x e de y
nessa equação que resultarão no maior número?
Os valores que maximizam a equação podem ser encontrados fazendo
uso de cálculo, utilizando derivadas parciais e de nindo cada uma com
zero. O resultado é x = 3 e y = 2. Nosso algoritmo genético será capaz de
chegar ao mesmo resultado sem usar cálculo? Vamos pôr mãos à obra.
Listagem 5.9 – simple_equation.py
from __future__ import annotations
from typing import Tuple, List
from chromosome import Chromosome
from genetic_algorithm import GeneticAlgorithm
from random import randrange, random
from copy import deepcopy

class SimpleEquation(Chromosome):
def __init__(self, x: int, y: int) -> None:
self.x: int = x
self.y: int = y

def fitness(self) -> float: # 6x - x^2 + 4y - y^2


return 6 * self.x - self.x * self.x + 4 * self.y - self.y * self.y

@classmethod
def random_instance(cls) -> SimpleEquation:
return SimpleEquation(randrange(100), randrange(100))

def crossover(self, other: SimpleEquation) -> Tuple[SimpleEquation,


SimpleEquation]:
child1: SimpleEquation = deepcopy(self)
child2: SimpleEquation = deepcopy(other)
child1.y = other.y
child2.y = self.y
return child1, child2

def mutate(self) -> None:


if random() > 0.5: # faz a mutação de x
if random() > 0.5:
self.x += 1
else:
self.x -= 1
else: # caso contrário, faz a mutação de y
if random() > 0.5:
self.y += 1
else:
self.y -= 1

def __str__(self) -> str:


return f"X: {self.x} Y: {self.y} Fitness: {self.fitness()}"

SimpleEquation está em consonância com Chromosome e, de modo el ao seu


nome, funciona do modo mais simples possível. Podemos pensar nos
genes de um cromossomo SimpleEquation como x e y. O método fitness()
avalia x e y usando a equação 6x – x2 + 4y – y2. Quanto maior o valor,
mais apropriado será o cromossomo individual, de acordo com
GeneticAlgorithm . No caso de uma instância aleatória, x e y são de nidos
inicialmente com inteiros aleatórios entre 0 e 100, portanto,
random_instance() não precisa fazer nada além de instanciar uma nova
SimpleEquation com esses valores. Para combinar uma SimpleEquation com
outra em crossover(), os valores de y das duas instâncias são
simplesmente trocados para criar os dois lhos. mutate() incrementa ou
decrementa aleatoriamente x ou y. E é basicamente isso.
Como SimpleEquation está em consonância com Chromosome, já podemos
utilizá-lo em GeneticAlgorithm.
Listagem 5.10 – Continuação de simple_equation.py
if __name__ == "__main__":
initial_population: List[SimpleEquation] =
[SimpleEquation.random_instance() for _ in range(20)]
ga: GeneticAlgorithm[SimpleEquation] =
GeneticAlgorithm(initial_population=initial_population,
threshold=13.0,
max_generations = 100, mutation_chance = 0.1, crossover_chance =
0.7)
result: SimpleEquation = ga.run()
print(result)

Os parâmetros usados nesse caso foram determinados por meio de


palpite e veri cação. Você pode experimentar outros valores. threshold foi
de nido com 13.0 porque já sabíamos a resposta correta. Quando x = 3 e
y = 2, a equação é avaliada com 13.
Se você não soubesse a resposta com antecedência, talvez quisesse ver o
melhor resultado possível de ser encontrado em determinado número de
gerações. Nesse caso, poderíamos de nir threshold com algum valor
arbitrariamente alto. Lembre-se de que, como os algoritmos genéticos são
estocásticos, cada execução será diferente.
Eis um exemplo de saída de uma execução na qual o algoritmo genético
solucionou a equação em nove gerações:
Generation 0 Best -349 Avg -6112.3
Generation 1 Best 4 Avg -1306.7
Generation 2 Best 9 Avg -288.25
Generation 3 Best 9 Avg -7.35
Generation 4 Best 12 Avg 7.25
Generation 5 Best 12 Avg 8.5
Generation 6 Best 12 Avg 9.65
Generation 7 Best 12 Avg 11.7
Generation 8 Best 12 Avg 11.6
X: 3 Y: 2 Fitness: 13

Como podemos ver, ele chegou à solução correta, conforme obtida antes
por meio de cálculo: x = 3 e y = 2. Você pode ter percebido também que,
quase sempre, a cada geração o algoritmo chegou mais próximo da
resposta correta.
Leve em consideração que o algoritmo genético exigiu mais capacidade
de processamento do que outros métodos utilizariam para encontrar a
solução. No mundo real, um problema de maximização simples como
esse não constituiria um bom uso de um algoritmo genético. Contudo,
sua implementação simples pelo menos é su ciente para comprovar que
o nosso algoritmo genético funciona.

5.4 Revendo SEND+MORE=MONEY


No Capítulo 3, resolvemos o problema clássico de criptoaritmética
SEND+MORE=MONEY usando um framework de satisfação de
restrições. (Para relembrar o que é o problema, reveja a sua descrição no
Capítulo 3.) O problema também pode ser resolvido em tempo razoável
usando um algoritmo genético.
Uma das principais di culdades em formular um problema para que
seja resolvido com um algoritmo genético é determinar o modo de
representá-lo. Uma representação conveniente para problemas de
criptoaritmética é utilizar índices de lista como dígitos.2 Desse modo, para
representar os dez dígitos possíveis (0, 1, 2, 3, 4, 5, 6, 7, 8, 9), uma lista de
dez elementos é necessária. Os caracteres a serem procurados no
problema podem ser então deslocados de um lugar para outro. Por
exemplo, se suspeitarmos que a solução para um problema inclui o
caractere “E” representando o dígito 4, então list[4] = "E".
SEND+MORE=MONEY tem oito letras distintas (S, E, N, D, M, O, R, Y),
deixando duas posições vazias no array. Essas posições podem ser
preenchidas com espaços, sinalizando a ausência de letras.
Um cromossomo que representa o problema SEND+MORE=MONEY
está descrito em SendMoreMoney2. Observe como o método fitness() é
bastante parecido com satisfied() de SendMoreMoneyConstraint, que vimos
no Capítulo 3.
Listagem 5.11 – send_more_money2.py
from __future__ import annotations
from typing import Tuple, List
from chromosome import Chromosome
from genetic_algorithm import GeneticAlgorithm
from random import shuffle, sample
from copy import deepcopy

class SendMoreMoney2(Chromosome):
def __init__(self, letters: List[str]) -> None:
self.letters: List[str] = letters

def fitness(self) -> float:


s: int = self.letters.index("S")
e: int = self.letters.index("E")
n: int = self.letters.index("N")
d: int = self.letters.index("D")
m: int = self.letters.index("M")
o: int = self.letters.index("O")
r: int = self.letters.index("R")
y: int = self.letters.index("Y")
send: int = s * 1000 + e * 100 + n * 10 + d
more: int = m * 1000 + o * 100 + r * 10 + e
money: int = m * 10000 + o * 1000 + n * 100 + e * 10 + y
difference: int = abs(money - (send + more))
return 1 / (difference + 1)

@classmethod
def random_instance(cls) -> SendMoreMoney2:
letters = ["S", "E", "N", "D", "M", "O", "R", "Y", " ", " "]
shuffle(letters)
return SendMoreMoney2(letters)

def crossover(self, other: SendMoreMoney2) -> Tuple[SendMoreMoney2,


SendMoreMoney2]:
child1: SendMoreMoney2 = deepcopy(self)
child2: SendMoreMoney2 = deepcopy(other)
idx1, idx2 = sample(range(len(self.letters)), k=2)
l1, l2 = child1.letters[idx1], child2.letters[idx2]
child1.letters[child1.letters.index(l2)], child1.letters[idx2] =
child1.letters[idx2], l2
child2.letters[child2.letters.index(l1)], child2.letters[idx1] =
child2.letters[idx1], l1
return child1, child2

def mutate(self) -> None: # troca as posições de duas letras


idx1, idx2 = sample(range(len(self.letters)), k=2)
self.letters[idx1], self.letters[idx2] = self.letters[idx2],
self.letters[idx1]

def __str__(self) -> str:


s: int = self.letters.index("S")
e: int = self.letters.index("E")
n: int = self.letters.index("N")
d: int = self.letters.index("D")
m: int = self.letters.index("M")
o: int = self.letters.index("O")
r: int = self.letters.index("R")
y: int = self.letters.index("Y")
send: int = s * 1000 + e * 100 + n * 10 + d
more: int = m * 1000 + o * 100 + r * 10 + e
money: int = m * 10000 + o * 1000 + n * 100 + e * 10 + y
difference: int = abs(money - (send + more))
return f"{send} + {more} = {money} Difference: {difference}"
Contudo, há uma diferença importante entre o método satisfied() do
Capítulo 3 e o método fitness() desta seção. Em nosso método,
devolvemos 1 / (difference + 1). difference é o valor absoluto da diferença
entre MONEY e SEND+MORE. Esse valor representa quão distante está
o cromossomo de resolver o problema. Se estivéssemos tentando
minimizar fitness(), poderíamos devolver apenas difference. Porém,
como GeneticAlgorithm tenta maximizar o valor de fitness(), o valor deve
ser invertido (de modo que valores menores pareçam maiores), e é por
isso que 1 é dividido por difference. O valor 1 é somado antes em
difference ; desse modo, uma difference igual a 0 não resultará em um
fitness() igual a 0, mas 1. A Tabela 5.1 mostra como isso funciona.

Tabela 5.1 – Como a equação 1 / (di erence + 1) resulta em valores de


aptidão ( tnesses) para maximização
difference difference + 1 fitness (1/(difference + 1))
0 1 1
1 2 0,5
2 3 0,25
3 4 0,125

Lembre-se de que diferenças menores são melhores, e valores maiores de


aptidão ( tnesses) são melhores. Como essa fórmula faz com que esses
dois fatos se alinhem, ela funciona bem. Dividir 1 por um valor de
aptidão é uma forma simples de converter um problema de minimização
em um problema de maximização. Contudo, isso introduz algumas
distorções, portanto não é um método infalível.3
random_instance() faz uso da função shuffle() do módulo random .
crossover() seleciona dois índices aleatórios nas listas letters dos dois
cromossomos e troca as letras, de modo que acabamos com uma letra do
primeiro cromossomo no mesmo lugar no segundo cromossomo, e vice-
versa. Essas trocas são feitas nos lhos, de modo que o posicionamento
das letras nos dois lhos é, no nal, uma combinação dos pais. mutate()
troca duas posições aleatórias na lista letters.
Podemos associar SendMoreMoney2 a GeneticAlgorithm de modo tão simples
como o zemos com SimpleEquation. No entanto, considere-se avisado:
este é um problema razoavelmente difícil, e demorará bastante para o
código executar caso os parâmetros não estejam bem ajustados. Além
disso, continua havendo certa aleatoriedade, mesmo que esses
parâmetros estejam corretos! O problema poderá ser resolvido em alguns
segundos ou em alguns minutos. Infelizmente, é da natureza dos
algoritmos genéticos.
Listagem 5.12 – Continuação de send_more_money2.py
if __name__ == "__main__":
initial_population: List[SendMoreMoney2] =
[SendMoreMoney2.random_instance() for _ in range(1000)]
ga: GeneticAlgorithm[SendMoreMoney2] =
GeneticAlgorithm(initial_population=initial_population,
threshold=1.0,
max_generations = 1000, mutation_chance = 0.2, crossover_chance =
0.7,
selection_type=GeneticAlgorithm.SelectionType.ROULETTE)
result: SendMoreMoney2 = ga.run()
print(result)

A saída a seguir é de uma execução que solucionou o problema em três


gerações usando mil indivíduos em cada geração (conforme criado no
código anterior). Veja se você consegue brincar com os parâmetros
con guráveis de GeneticAlgorithm e obter um resultado semelhante com
menos indivíduos. O algoritmo parece funcionar melhor com a seleção
por roleta do que com a seleção por torneio?
Generation 0 Best 0.0040650406504065045 Avg 8.854014252391551e-05
Generation 1 Best 0.16666666666666666 Avg 0.001277329479413134
Generation 2 Best 0.5 Avg 0.014920889170684687
8324 + 913 = 9237 Difference: 0

Essa solução mostra que SEND = 8324, MORE = 913 e MONEY = 9237.
Como isso é possível? Parece haver letras faltando na solução. De fato, se
M = 0, há diversas soluções para o problema, as quais não eram possíveis
com a versão que vimos no Capítulo 3. MORE é, na verdade, 0913 em
nosso caso, e MONEY é 09237. O 0 é simplesmente ignorado.

5.5 Otimizando a compactação de listas


Suponha que temos algumas informações que queremos compactar.
Imagine que seja uma lista de itens, e que não nos importamos com a
ordem dos itens, desde que todos continuem intactos. Qual é a ordem
dos itens que maximizará a taxa de compactação? Você ao menos sabia
que a ordem dos itens afeta a taxa de compactação na maioria dos
algoritmos de compactação?
A resposta dependerá do algoritmo de compactação usado. Nesse
exemplo, usaremos a função compress() do módulo zlib com suas
con gurações padrões. A solução será mostrada a seguir por completo,
para uma lista de 12 nomes. Se não executarmos o algoritmo genético e
executarmos apenas compress() nos 12 nomes na ordem em que foram
originalmente apresentados, os dados compactados resultantes terão 165
bytes.
Listagem 5.13 – list_compression.py
from __future__ import annotations
from typing import Tuple, List, Any
from chromosome import Chromosome
from genetic_algorithm import GeneticAlgorithm
from random import shuffle, sample
from copy import deepcopy
from zlib import compress
from sys import getsizeof
from pickle import dumps

# 165 bytes compactados


PEOPLE: List[str] = ["Michael", "Sarah", "Joshua", "Narine", "David",
"Sajid", "Melanie", "Daniel", "Wei", "Dean", "Brian", "Murat", "Lisa"]
class ListCompression(Chromosome):
def __init__(self, lst: List[Any]) -> None:
self.lst: List[Any] = lst

@property
def bytes_compressed(self) -> int:
return getsizeof(compress(dumps(self.lst)))

def fitness(self) -> float:


return 1 / self.bytes_compressed
@classmethod
def random_instance(cls) -> ListCompression:
mylst: List[str] = deepcopy(PEOPLE)
shuffle(mylst)
return ListCompression(mylst)
def crossover(self, other: ListCompression) -> Tuple[ListCompression,
ListCompression]:
child1: ListCompression = deepcopy(self)
child2: ListCompression = deepcopy(other)
idx1, idx2 = sample(range(len(self.lst)), k=2)
l1, l2 = child1.lst[idx1], child2.lst[idx2]
child1.lst[child1.lst.index(l2)], child1.lst[idx2] =
child1.lst[idx2], l2
child2.lst[child2.lst.index(l1)], child2.lst[idx1] =
child2.lst[idx1], l1
return child1, child2

def mutate(self) -> None: # troca duas posições


idx1, idx2 = sample(range(len(self.lst)), k=2)
self.lst[idx1], self.lst[idx2] = self.lst[idx2], self.lst[idx1]

def __str__(self) -> str:


return f"Order: {self.lst} Bytes: {self.bytes_compressed}"

if __name__ == "__main__":
initial_population: List[ListCompression] =
[ListCompression.random_instance() for _ in range(1000)]
ga: GeneticAlgorithm[ListCompression] =
GeneticAlgorithm(initial_population=initial_population,
threshold=1.0,
max_generations = 1000, mutation_chance = 0.2, crossover_chance =
0.7,
selection_type=GeneticAlgorithm.SelectionType.TOURNAMENT)
result: ListCompression = ga.run()
print(result)

Observe a semelhança dessa implementação com a implementação de


SEND+ MORE=MONEY da Seção 5.4. As funções crossover() e mutate()
são basicamente iguais. Nas soluções dos dois problemas, tomamos uma
lista de itens e as reorganizamos continuamente, testando essas listas
reorganizadas. Poderíamos escrever uma superclasse genérica para as
soluções de ambos os problemas, que funcionaria para uma grande
variedade de problemas. Qualquer problema que possa ser representado
como uma lista de itens para a qual uma ordem ótima tenha de ser
encontrada poderia ser solucionado do mesmo modo. O único ponto de
personalização para as subclasses seriam suas respectivas funções de
aptidão ( tness functions).
Se executássemos list_compression.py, ela poderia demorar bastante
para terminar. Isso ocorre porque não sabemos previamente o que
constitui a resposta “certa”, de modo diferente dos dois problemas
anteriores, portanto, não temos um verdadeiro limiar em direção ao qual
trabalharemos. Em vez disso, de nimos o número de gerações e o
número de indivíduos em cada geração com um número arbitrariamente
alto, e esperamos pelo melhor. Qual é o número mínimo de bytes que
resultará da compactação com a reorganização dos 12 nomes?
Honestamente, não sabemos a resposta para isso. Em minha melhor
execução, utilizando a con guração da solução anterior, após 546
gerações, o algoritmo genético encontrou uma ordem para os 12 nomes
que resultou em 159 bytes compactados.
É uma economia de apenas 6 bytes em relação à ordem original – uma
economia de ~4%. Poderíamos dizer que 4% é irrelevante, mas, se essa
fosse uma lista muito maior, a ser transmitida várias vezes por uma rede, a
economia total poderia ser alta. Suponha que essa lista tivesse 1 MB e
que, em algum momento, fosse transmitida pela internet 10 milhões de
vezes. Se o algoritmo genético conseguisse otimizar a ordem da lista para
compactá-la de modo a economizar 4%, isso representaria uma
economia de ~40 kilobytes por transferência e, em última análise, 400 GB
de largura de banda para todas as transferências. Não é uma quantidade
enorme, mas, talvez, pudesse ser su cientemente signi cativa, a ponto de
valer a pena executar o algoritmo uma vez a m de encontrar uma ordem
que estivesse próxima da ordem ideal para a compactação.
Considere, porém, o seguinte: não sabemos realmente se encontramos a
ordem ótima para os 12 nomes, muito menos para a lista hipotética de 1
MB. Como saberíamos se tivéssemos encontrado? A menos que
tenhamos uma compreensão profunda do algoritmo de compactação,
teríamos de tentar compactar todas as possíveis ordens da lista. Para uma
lista com apenas 12 itens, seriam 479.001.600 ordens possíveis, o que seria
praticamente inviável (12!, em que ! signi ca fatorial). Usar um algoritmo
genético que apenas tente encontrar uma solução ótima talvez seja mais
viável, mesmo se não soubermos se a solução nal é realmente ótima.
5.6 Desa os para os algoritmos genéticos
Os algoritmos genéticos não são uma panaceia. Na verdade, eles não são
apropriados para a maioria dos problemas. Para qualquer problema para
o qual haja um algoritmo determinístico rápido, uma abordagem com um
algoritmo genético não fará sentido. Sua natureza inerentemente
estocástica faz com que seus tempos de execução sejam imprevisíveis.
Para resolver esse problema, eles podem ser interrompidos após
determinado número de gerações. Porém, não estará claro se uma
solução realmente ótima foi encontrada.
Steven Skiena, autor de um dos textos mais conhecidos sobre
algoritmos, foi até mais longe e escreveu o seguinte:
Jamais encontrei um problema para o qual os algoritmos genéticos me
parecessem o modo correto de atacá-lo. Além disso, nunca vi nenhum
resultado de processamento fornecido por algoritmos genéticos que tenha
me impressionado favoravelmente.4
A visão de Skiena é um pouco radical, mas é um indício do fato de que os
algoritmos genéticos só deverão ser escolhidos quando você tiver uma
certeza razoável de que não há uma solução melhor. Outro problema
com os algoritmos genéticos é determinar como representar uma possível
solução para um problema na forma de um cromossomo. A prática
tradicional consiste em representar a maioria dos problemas como
cadeias binárias (sequências de 1s e 0s, isto é, bits brutos). Com
frequência, isso é ideal no que diz respeito ao uso de espaço, e implica
funções simples de crossover. Contudo, os problemas mais complexos
não são facilmente representados como cadeias de bits divisíveis.
Outro problema, mais especí co, que vale a pena ser mencionado são
os desa os relacionados ao método de seleção por roleta descrito neste
capítulo.
A seleção por roleta, às vezes chamada de seleção proporcional à
aptidão, pode levar a uma falta de diversidade em uma população em
virtude da dominância de indivíduos relativamente aptos, sempre que a
seleção é efetuada. Por outro lado, se os valores de aptidão estiverem
próximos, a seleção por roleta pode resultar em uma falta de pressão para
a seleção.5 Além do mais, a seleção por roleta, conforme implementada
neste capítulo, não funciona para problemas nos quais a aptidão pode ser
mensurada com valores negativos, como em nosso exemplo simples de
equação da seção 5.3.
Em suma, para a maioria dos problemas su cientemente grandes que
justi que usá-los, os algoritmos genéticos não são capazes de garantir a
descoberta de uma solução ótima em um intervalo de tempo previsível.
Por esse motivo, eles serão mais bem utilizados em situações que não
exijam uma solução ótima, mas uma solução que seja “boa o su ciente”.
Esses algoritmos são relativamente fáceis de implementar, mas ajustar
seus parâmetros con guráveis pode exigir muitas tentativas e erros.

5.7 Aplicações no mundo real


Apesar do que Skiena escreveu, os algoritmos genéticos são aplicados de
modo frequente e e caz em diversos domínios de problemas. São muitas
vezes usados em problemas difíceis, que não exigem soluções
perfeitamente ótimas, como problemas de satisfação de restrições que
sejam grandes demais para serem resolvidos com métodos tradicionais.
Um exemplo são os problemas complexos de agendamento.
Muitas aplicações para os algoritmos genéticos têm sido encontradas
em biologia computacional. Esses algoritmos têm sido usados com
sucesso para docking de proteína-ligante, que é a busca da con guração
de uma pequena molécula quando ela é ligada a um receptor. Isso é
usado em pesquisa farmacêutica e para compreender melhor os
mecanismos da natureza.
O Problema do Caixeiro-Viajante, que veremos novamente no Capítulo
9, é um dos problemas mais famosos em ciência da computação. Um
caixeiro-viajante quer encontrar o caminho mais curto em um mapa, por
meio do qual visite todas as cidades exatamente uma vez e que o leve de
volta ao seu ponto de partida. Essa situação pode lembrar as árvores
geradoras mínimas (minimum spanning trees) do Capítulo 4, mas é
diferente. No problema do Caixeiro-Viajante, a solução é um ciclo
gigantesco que minimiza o custo para percorrê-lo, enquanto uma árvore
geradora mínima minimiza o custo de conectar todas as cidades. Uma
pessoa que percorra uma árvore geradora mínima de cidades talvez tenha
de visitar a mesma cidade duas vezes para alcançar todas as cidades.
Apesar de soarem semelhantes, não há um algoritmo que execute em
tempo razoável para encontrar uma solução para o problema do Caixeiro-
Viajante, para um número arbitrário de cidades. Os algoritmos genéticos
mostram que podem encontrar soluções que não são ótimas, mas são
muito boas, em pouco tempo. O problema é amplamente aplicável na
distribuição e ciente de mercadorias. Por exemplo, os responsáveis pelo
despacho de caminhões da FedEx e da UPS usam softwares para resolver
o problema do Caixeiro-Viajante diariamente. Algoritmos que ajudam a
resolver o problema podem gerar economias em diversos mercados.
Na arte gerada por computador, os algoritmos genéticos às vezes são
usados para imitar fotogra as usando métodos estocásticos. Pense em 50
polígonos posicionados aleatoriamente em uma tela e gradualmente
distorcidos, girados, movidos, redimensionados e modi cados quanto à
cor, até que correspondam o máximo possível a uma fotogra a. O
resultado parecerá o trabalho de um artista abstrato ou, se formatos mais
angulares forem utilizados, se assemelhará a um vitral.
Os algoritmos genéticos fazem parte de uma área mais ampla chamada
computação evolucionária. Uma área da computação evolucionária,
intimamente relacionada aos algoritmos genéticos, é a programação
genética, na qual os programas usam as operações de seleção, crossover e
mutação para modi carem a si mesmos a m de encontrar soluções não
óbvias para problemas de programação. A programação genética não é
uma técnica amplamente usada, mas pense em um futuro em que os
programas escrevam a si mesmos.
Uma vantagem dos algoritmos genéticos é que eles permitem uma fácil
paralelização. No formato mais óbvio, cada população poderia ser
simulada em um processador distinto. Em uma forma mais granular, cada
indivíduo poderia sofrer mutação e crossover, e ter sua aptidão calculada
em uma thread separada. Há também várias possibilidades
intermediárias.
5.8 Exercícios
1. Acrescente suporte em GeneticAlgorithm para uma forma mais
so sticada de seleção por torneio que possa ocasionalmente escolher
o segundo ou o terceiro melhor cromossomo, com base em uma
probabilidade decrescente.
2. Acrescente uma nova função no framework de satisfação de
restrições do Capítulo 3, que resolva qualquer CSP arbitrário usando
um algoritmo genético. Uma possível medida de aptidão é o número
de restrições resolvidas por um cromossomo.
3. Crie uma classe BitString que implemente Chromosome. Lembre-se do
que é uma cadeia de bits revendo o Capítulo 1. Em seguida, use sua
nova classe para resolver o problema da equação simples da seção 5.3.
De que modo o problema pode ser codi cado como uma cadeia de
bits?

1 Artem Sokolov e Darrell Whitley, “Unbiased Tournament Selection” (Seleção por torneio sem
distorção), GECCO’05 (25 a 29 de junho de 2005, Washington, D.C., U.S.A.),
http://mng.bz/S7l6.
2 Reza Abbasian e Masoud Mazloom, “Solving Cryptarithmetic Problems Using Parallel Genetic
Algorithm” (Resolvendo problemas de criptoaritmética usando um algoritmo genético
paralelo), 2009 Second International Conference on Computer and Electrical Engineering
(Segunda Conferência Internacional de Engenharia Elétrica e Computação),
http://mng.bz/RQ7V.
3 Por exemplo, poderíamos acabar com mais números próximos de 0 do que próximos de 1 se
simplesmente dividíssemos 1 por uma distribuição uniforme de inteiros, o que – considerando
as sutilezas de como os microprocessadores típicos interpretam números de ponto utuante –
poderia levar a certos resultados inesperados. Um modo alternativo de converter um problema
de minimização em um problema de maximização é simplesmente inverter o sinal (deixá-lo
negativo, em vez de positivo). Contudo, essa solução só funcionará se os valores forem todos
positivos, para começar.
4 Steven Skiena, The Algorithm Design Manual, 2ª edição (Springer, 2009), p. 267.
5 A.E. Eiben e J.E. Smith, Introduction to Evolutionary Computation, 2ª edição (Springer, 2015), p.
80.
CAPÍTULO 6

Clustering k-means

A humanidade nunca teve tantos dados sobre diferentes aspectos da


sociedade como tem atualmente. Os computadores são ótimos para
armazenar conjuntos de dados, mas esses conjuntos só terão algum valor
para a sociedade se forem analisados por seres humanos. Técnicas de
computação podem orientar as pessoas no processo de compreender um
conjunto de dados de modo que faça sentido.
O clustering (agrupamento) é uma técnica de computação que divide os
pontos de um conjunto de dados em grupos. Um clustering bem-
sucedido resulta em grupos que contêm pontos relacionados entre si. O
fato de esses relacionamentos serem signi cativos, em geral, exige uma
veri cação feita por seres humanos.
No clustering, o grupo (ou seja, o cluster) ao qual um ponto de dado
pertence não está predeterminado, mas é decidido durante a execução do
algoritmo de clustering. De fato, o algoritmo não é orientado a colocar
nenhum ponto de dado em particular em um cluster especí co usando
informações preconcebidas. Por esse motivo, o clustering é considerado
um método não supervisionado no domínio do aprendizado de máquina
(machine learning). Podemos pensar em não supervisionado como não
orientado por conhecimento prévio.
O clustering é uma técnica útil quando queremos saber sobre a
estrutura de um conjunto de dados, mas não conhecemos suas partes
constituintes previamente. Por exemplo, suponha que você seja o
proprietário de um mercado e colete dados sobre os clientes e suas
transações. Você quer fazer anúncios em dispositivos móveis sobre
ofertas em horários relevantes da semana a m de atrair clientes para o
seu estabelecimento. Você poderia tentar fazer um clustering de seus
dados de acordo com o dia da semana e com informações demográ cas.
Talvez você encontre um cluster que indique que compradores mais
jovens pre ram fazer compras às terças-feiras, e poderia utilizar essa
informação para fazer um anúncio visando especi camente a essa
clientela nesse dia.

6.1 Informações preliminares


Nosso algoritmo de clustering exigirá algumas primitivas de estatística
(média, desvio-padrão e assim por diante). A partir da versão 3.4, a
biblioteca-padrão de Python disponibiliza várias primitivas úteis de
estatística no módulo statistics. Note que, apesar de nos atermos à
biblioteca-padrão neste livro, há outras bibliotecas de terceiros com
desempenho melhor para manipulações numéricas, como o NumPy, que
deverão ser utilizadas em aplicações nas quais o desempenho seja crítico
– especialmente aquelas que lidam com big data.
Para simpli car, os conjuntos de dados com os quais trabalharemos
neste capítulo serão todos representados com o tipo float, portanto
haverá muitas operações em listas e tuplas de floats. As primitivas de
estatística sum(), mean() e pstdev() estão de nidas na biblioteca-padrão.
Suas de nições re etem diretamente as fórmulas que você veria em um
livro didático sobre estatística. Além disso, precisaremos de uma função
para calcular escores z (z-scores).
Listagem 6.1 – kmeans.py
from __future__ import annotations
from typing import TypeVar, Generic, List, Sequence
from copy import deepcopy
from functools import partial
from random import uniform
from statistics import mean, pstdev
from dataclasses import dataclass
from data_point import DataPoint

def zscores(original: Sequence[float]) -> List[float]:


avg: float = mean(original)
std: float = pstdev(original)
if std == 0: # devolve tudo igual a zero se não houver variação
return [0] * len(original)
return [(x - avg) / std for x in original]
DICA pstdev() calcula o desvio-padrão de uma população, enquanto stdev() , que não
estamos usando, calcula o desvio-padrão de uma amostra.
zscores() converte uma sequência de números de ponto utuante ( oats)
em uma lista de números de ponto utuante com os respectivos escores z
dos números originais relativos a todos os números da sequência
original. Falaremos mais sobre os escores z posteriormente neste capítulo.
NOTA Ensinar estatística elementar está fora do escopo deste livro, mas você não precisará
de nada além de uma compreensão rudimentar sobre média e desvio-padrão para
acompanhar o resto do capítulo. Se você já aprendeu esses conceitos há muito tempo e
precisa recordá-los, ou se nunca os viu antes, talvez valha a pena consultar algum recurso
contendo informações sobre estatística que explique esses dois conceitos fundamentais.
Todos os algoritmos de clustering trabalham com pontos de dados, e
nossa implementação de k-means não será uma exceção. De niremos
uma interface comum chamada DataPoint. Para deixar o código mais
limpo, de niremos essa interface em um arquivo próprio.
Listagem 6.2 – data_point.py
from __future__ import annotations
from typing import Iterator, Tuple, List, Iterable
from math import sqrt

class DataPoint:
def __init__(self, initial: Iterable[float]) -> None:
self._originals: Tuple[float, ...] = tuple(initial)
self.dimensions: Tuple[float, ...] = tuple(initial)

@property
def num_dimensions(self) -> int:
return len(self.dimensions)

def distance(self, other: DataPoint) -> float:


combined: Iterator[Tuple[float, float]] = zip(self.dimensions,
other.dimensions)
differences: List[float] = [(x - y) ** 2 for x, y in combined]
return sqrt(sum(differences))

def __eq__(self, other: object) -> bool:


if not isinstance(other, DataPoint):
return NotImplemented
return self.dimensions == other.dimensions

def __repr__(self) -> str:


return self._originals.__repr__()
Todo ponto de dado deve ser comparável com outros pontos de dados do
mesmo tipo para saber se são iguais (__eq__()), e devem ser legíveis para
depuração (__repr__()). Um ponto de dado de qualquer tipo tem
determinado número de dimensões (num_dimensions). A tupla dimensions
armazena os valores propriamente ditos de cada uma dessas dimensões
na forma de floats. O método __init__() aceita um iterável de valores para
as dimensões necessárias. Essas dimensões poderão ser substituídas mais
tarde por escores z pelo k-means, portanto manteremos também uma
cópia dos dados iniciais em _originals para exibir posteriormente.
Uma última informação preliminar de que precisamos antes de explorar
o k-means é um modo de calcular a distância entre dois pontos de dados
quaisquer do mesmo tipo. Há várias maneiras de calcular a distância,
porém a mais comum utilizada com o k-means é a distância euclidiana.
Essa é a fórmula de distância que a maioria das pessoas conhece no curso
de geometria do ensino médio, e que pode ser derivada do teorema de
Pitágoras. Na verdade, já discutimos a fórmula e criamos uma versão dela
para espaços bidimensionais no Capítulo 2, quando a utilizamos para
calcular a distância entre duas posições quaisquer em um labirinto.
Nosso DataPoint exige uma versão mais so sticada, pois um DataPoint
pode envolver qualquer quantidade de dimensões.
Essa versão de distance() é particularmente compacta, e funcionará com
tipos DataPoint com qualquer quantidade de dimensões. A chamada a
zip() cria tuplas preenchidas com pares de cada dimensão dos dois
pontos, combinados em uma sequência. A list comprehension calcula a
diferença entre cada ponto de cada dimensão e eleva esse valor ao
quadrado. sum() soma todos esses valores, e o valor nal devolvido por
distance() é a raiz quadrada dessa soma.
6.2 Algoritmo de clustering k-means
O k-means é um algoritmo de clustering que tenta agrupar pontos de
dados em um certo número prede nido de clusters com base na distância
relativa de cada ponto até o centro do cluster. Em cada rodada do k-
means, a distância entre cada ponto de dado e o centro de cada cluster
(um ponto conhecido como centroide) é calculada. Os pontos são
atribuídos ao cluster de cujo centroide eles estiverem mais próximos. Em
seguida, o algoritmo recalcula todos os centroides, encontrando a média
dos pontos atribuídos a cada cluster e substituindo o antigo centroide
pela nova média. O processo de atribuir pontos e recalcular centroides
continua até que os centroides parem de se mover ou determinado
número de iterações ocorra.
Cada dimensão dos pontos iniciais fornecidos ao k-means deve ser
comparável em magnitude. Se não for, o k-means apresentará uma
distorção e fará um clustering com base em dimensões com as maiores
diferenças. O processo de deixar diferentes tipos de dados (em nosso
caso, diferentes dimensões) comparáveis é conhecido como
normalização. Um modo comum de normalizar dados é avaliar cada valor
com base em seu escore z (também conhecido como escore padrão)
relativo aos demais valores do mesmo tipo. Calcula-se um escore z
tomando um valor, subtraindo a média de todos os valores e dividindo
esse resultado pelo desvio-padrão de todos os valores. A função zscores()
apresentada próximo ao início da seção anterior faz exatamente isso para
cada valor em um iterável de floats.
A principal di culdade com o k-means é decidir como atribuir os
centroides iniciais. Na forma básica do algoritmo, que é a que
implementaremos, os centroides iniciais serão posicionados de modo
aleatório dentro da faixa dos dados. Outra di culdade é decidir em
quantos clusters os dados devem ser divididos (o “k” do k-means). No
algoritmo clássico, esse número é determinado pelo usuário, que pode ou
não saber qual é o número correto, e isso exigirá alguns experimentos.
Deixaremos que o usuário de na “k”.
Reunindo todos esses passos e considerações, eis o nosso algoritmo de
clustering k-means:
1. Inicialize todos os pontos de dados e “k” clusters vazios.
2. Normalize todos os pontos de dados.
3. Crie centroides aleatórios associados a cada cluster.
4. Atribua cada ponto de dado ao cluster de cujo centroide ele estiver
mais próximo.
5. Recalcule cada centroide de modo que esteja no centro (média) do
cluster ao qual ele está associado.
6. Repita os passos 4 e 5 até que um número máximo de iterações tenha
sido alcançado ou os centroides parem de se mover (haja uma
convergência).
Conceitualmente, o k-means é bem simples: a cada iteração, cada ponto
de dado é associado ao cluster de cujo centro ele está mais próximo. Esse
centro se move à medida que novos pontos são associados ao cluster. A
Figura 6.1 mostra isso.

Figura 6.1 – Um exemplo do k-means executando por três gerações em um


conjunto de dados arbitrário. As estrelas representam os centroides. As cores
e as formas representam os membros do cluster no momento (com
mudanças).
Implementaremos uma classe para manter os estados e executar o
algoritmo, de modo semelhante ao GeneticAlgorithm do Capítulo 5. Vamos
agora voltar para o arquivo kmeans.py.
Listagem 6.3 – Continuação de kmeans.py
Point = TypeVar('Point', bound=DataPoint)

class KMeans(Generic[Point]):
@dataclass
class Cluster:
points: List[Point]
centroid: DataPoint

KMeans é uma classe genérica. Ela funciona com DataPoint ou com


qualquer subclasse de DataPoint, conforme de nido pelo bound do tipo
Point . Ela tem uma classe interna, Cluster , que mantém o controle dos
clusters individuais na operação. Cada Cluster tem pontos de dados e um
centroide associado.
Prosseguiremos agora com o método __init__() da classe externa.
Listagem 6.4 – Continuação de kmeans.py
def __init__(self, k: int, points: List[Point]) -> None:
if k < 1: # k-means não trabalha com clusters negativos ou iguais a zero
raise ValueError("k must be >= 1")
self._points: List[Point] = points
self._zscore_normalize()
# inicializa clusters vazios com centroides aleatórios
self._clusters: List[KMeans.Cluster] = []
for _ in range(k):
rand_point: DataPoint = self._random_point()
cluster: KMeans.Cluster = KMeans.Cluster([], rand_point)
self._clusters.append(cluster)

@property
def _centroids(self) -> List[DataPoint]:
return [x.centroid for x in self._clusters]

KMeans tem um array _points associado. Esse array representa todos os


pontos do conjunto de dados. Os pontos são posteriormente divididos
entre os clusters, que são armazenados na variável devidamente nomeada
como _clusters. Quando KMeans é instanciada, ela precisa saber quantos
clusters deverá criar (k). Todo cluster tem inicialmente um centroide
aleatório. Todos os pontos de dados que serão usados no algoritmo são
normalizados pelo escore z. A propriedade _centroids calculada devolve
todos os centroides associados aos clusters no algoritmo.
Listagem 6.5 – Continuação de kmeans.py
def _dimension_slice(self, dimension: int) -> List[float]:
return [x.dimensions[dimension] for x in self._points]

_dimension_slice()é um método auxiliar que podemos imaginar como um


método que devolve uma coluna de dados. Ele devolverá uma lista
composta de todos os valores de um índice em particular, de cada ponto
de dado. Por exemplo, se os pontos de dados forem do tipo DataPoint,
_dimension_ slice(0) devolveria uma lista do valor da primeira dimensão
de cada ponto de dado. Isso será útil no método de normalização a
seguir.
Listagem 6.6 – Continuação de kmeans.py
def _zscore_normalize(self) -> None:
zscored: List[List[float]] = [[] for _ in range(len(self._points))]
for dimension in range(self._points[0].num_dimensions):
dimension_slice: List[float] = self._dimension_slice(dimension)
for index, zscore in enumerate(zscores(dimension_slice)):
zscored[index].append(zscore)
for i in range(len(self._points)):
self._points[i].dimensions = tuple(zscored[i])

_zscore_normalize() substitui os valores na tupla dimensions de cada ponto


de dado pelo seu escore z equivalente. Esse método utiliza a função
zscores() que de nimos antes para sequências de float . Embora os
valores na tupla dimensions sejam substituídos, o mesmo não ocorre na
tupla _originals de DataPoint. Isso é conveniente; o usuário do algoritmo
ainda poderá recuperar os valores originais das dimensões, anteriores à
normalização, após a execução do algoritmo, se esses estiverem
armazenados nos dois lugares.
Listagem 6.7 – Continuação de kmeans.py
def _random_point(self) -> DataPoint:
rand_dimensions: List[float] = []
for dimension in range(self._points[0].num_dimensions):
values: List[float] = self._dimension_slice(dimension)
rand_value: float = uniform(min(values), max(values))
rand_dimensions.append(rand_value)
return DataPoint(rand_dimensions)

O método _random_point() anterior é usado no método __init__() para


criar os centroides iniciais aleatórios para cada cluster. Ele limita os
valores aleatórios de cada ponto de modo que estejam no intervalo de
valores dos pontos de dados existentes. O método utiliza o construtor
que especi camos antes em DataPoint para criar um ponto a partir de um
iterável de valores.
Veremos agora o nosso método para encontrar o cluster apropriado ao
qual um ponto de dado pertence.
Listagem 6.8 – Continuação de kmeans.py
# Encontra o centroide de cluster mais próximo de cada ponto
# e atribui o ponto a esse cluster
def _assign_clusters(self) -> None:
for point in self._points:
closest: DataPoint = min(self._centroids,
key=partial(DataPoint.distance, point))
idx: int = self._centroids.index(closest)
cluster: KMeans.Cluster = self._clusters[idx]
cluster.points.append(point)

Ao longo do livro, criamos diversas funções que encontram o mínimo ou


o máximo em uma lista. Essa função não é diferente. Nesse caso, estamos
procurando o centroide do cluster que tenha a distância mínima para
cada ponto individual. O ponto é então atribuído a esse cluster. O único
detalhe intrincado é o uso de uma função partial() para a key de min().
partial() aceita uma função e lhe fornece alguns de seus parâmetros antes
que essa função seja aplicada. Nesse caso, fornecemos o método
DataPoint.distance() com o ponto a partir do qual estamos fazendo o
cálculo, como seu parâmetro other. Isso resultará no cálculo da distância
de cada centroide até o ponto, e o centroide com a menor distância será
devolvido por min().
Listagem 6.9 – Continuação de kmeans.py
# Encontra o centro de cada cluster e desloca o centroide para esse ponto
def _generate_centroids(self) -> None:
for cluster in self._clusters:
if len(cluster.points) == 0: # mantém o mesmo centroide se não
houver pontos
continue
means: List[float] = []
for dimension in range(cluster.points[0].num_dimensions):
dimension_slice: List[float] = [p.dimensions[dimension] for p in
cluster.points]
means.append(mean(dimension_slice))
cluster.centroid = DataPoint(means)
Depois que cada ponto é atribuído a um cluster, os novos centroides são
calculados. Isso envolve calcular a média de cada dimensão de todos os
pontos do cluster. As médias de cada dimensão são então combinadas a
m de determinar o “ponto médio” do cluster, que passa a ser o novo
centroide. Observe que não podemos usar _dimension_slice() nesse local,
pois os pontos em questão são um subconjunto de todos os pontos
(apenas aqueles que pertencem a um cluster em particular). De que modo
_dimension_slice() poderia ser reescrito para que seja mais genérico?
Vamos observar agora o método que executará realmente o algoritmo.
Listagem 6.10 – Continuação de kmeans.py
def run(self, max_iterations: int = 100) -> List[KMeans.Cluster]:
for iteration in range(max_iterations):
for cluster in self._clusters: # limpa todos os clusters
cluster.points.clear()
self._assign_clusters() # encontra o cluster do qual cada ponto está
mais próximo
old_centroids: List[DataPoint] = deepcopy(self._centroids) #
registra
self._generate_centroids() # encontra os novos centroides
if old_centroids == self._centroids: # os centroides se moveram?
print(f"Converged after {iteration} iterations")
return self._clusters
return self._clusters

run() é a expressão mais pura do algoritmo original. A única mudança no


algoritmo que você poderia achar inesperada é a remoção de todos os
pontos no início de cada iteração. Se isso não ocorresse, o método
_assign_clusters() , do modo como foi escrito, acabaria colocando pontos
duplicados em cada cluster.
Execute um teste rápido usando DataPoints de teste e k de nido com 2.
Listagem 6.11 – Continuação de kmeans.py
if __name__ == "__main__":
point1: DataPoint = DataPoint([2.0, 1.0, 1.0])
point2: DataPoint = DataPoint([2.0, 2.0, 5.0])
point3: DataPoint = DataPoint([3.0, 1.5, 2.5])
kmeans_test: KMeans[DataPoint] = KMeans(2, [point1, point2, point3])
test_clusters: List[KMeans.Cluster] = kmeans_test.run()
for index, cluster in enumerate(test_clusters):
print(f"Cluster {index}: {cluster.points}")

Como há aleatoriedade envolvida, seus resultados poderão variar. O


resultado esperado será algo na linha exibida a seguir:
Converged after 1 iterations
Cluster 0: [(2.0, 1.0, 1.0), (3.0, 1.5, 2.5)]
Cluster 1: [(2.0, 2.0, 5.0)]

6.3 Clustering de governadores por idade e longitude


Todo estado norte-americano tem um governador. Em junho de 2017,
esses governadores tinham idades que variavam de 42 a 79 anos. Se
considerarmos os Estados Unidos de leste para oeste e observarmos cada
estado de acordo com sua longitude, talvez possamos encontrar clusters
de estados com longitudes semelhantes e governadores com idades
semelhantes. A Figura 6.2 exibe um grá co de dispersão com todos os 50
governadores. O eixo x representa a longitude dos estados, e o eixo y
contém a idade dos governadores.
Há clusters evidentes na Figura 6.2? Nessa gura, os eixos não estão
normalizados. Estamos observando dados brutos. Se os clusters fossem
sempre óbvios, não haveria necessidade de algoritmos de clustering.
Figura 6.2 – Governadores dos estados, em junho de 2017, representados de
acordo com a longitude dos estados e a idade dos governadores.
Vamos experimentar submeter esse conjunto de dados ao k-means. Em
primeiro lugar, precisaremos de um modo de representar um ponto de
dado individual.
Listagem 6.12 – governors.py
from __future__ import annotations
from typing import List
from data_point import DataPoint
from kmeans import KMeans

class Governor(DataPoint):
def __init__(self, longitude: float, age: float, state: str) -> None:
super().__init__([longitude, age])
self.longitude = longitude
self.age = age
self.state = state
def __repr__(self) -> str:
return f"{self.state}: (longitude: {self.longitude}, age:
{self.age})"

Um Governor tem duas dimensões nomeadas e armazenadas: longitude e


age . Afora isso, Governor não faz nenhuma modi cação no funcionamento
de sua superclasse, DataPoint, exceto sobrescrever __repr__() para uma
exibição elegante. Seria pouco razoável inserir os dados a seguir
manualmente, portanto, faça um checkout do repositório do código-fonte
que acompanha este livro.
Listagem 6.13 – Continuação de governors.py
if __name__ == "__main__":
governors: List[Governor] =
[Governor(-86.79113, 72, "Alabama"), Governor(-152.404419, 66,
"Alaska"),
Governor(-111.431221, 53, "Arizona"), Governor(-92.373123, 66,
"Arkansas"),
Governor(-119.681564, 79, "California"), Governor(-105.311104, 65,
"Colorado"),
Governor(-72.755371, 61, "Connecticut"), Governor(-75.507141, 61,
"Delaware"),
Governor(-81.686783, 64, "Florida"), Governor(-83.643074, 74,
"Georgia"),
Governor(-157.498337, 60, "Hawaii"), Governor(-114.478828, 75,
"Idaho"),
Governor(-88.986137, 60, "Illinois"), Governor(-86.258278, 49,
"Indiana"),
Governor(-93.210526, 57, "Iowa"), Governor(-96.726486, 60, "Kansas"),
Governor(-84.670067, 50, "Kentucky"), Governor(-91.867805, 50,
"Louisiana"),
Governor(-69.381927, 68, "Maine"), Governor(-76.802101, 61,
"Maryland"),
Governor(-71.530106, 60, "Massachusetts"), Governor(-84.536095, 58,
"Michigan"),
Governor(-93.900192, 70, "Minnesota"), Governor(-89.678696, 62,
"Mississippi"),
Governor(-92.288368, 43, "Missouri"), Governor(-110.454353, 51,
"Montana"),
Governor(-98.268082, 52, "Nebraska"), Governor(-117.055374, 53,
"Nevada"),
Governor(-71.563896, 42, "New Hampshire"), Governor(-74.521011, 54,
"New Jersey"),
Governor(-106.248482, 57, "New Mexico"), Governor(-74.948051, 59, "New
York"),
Governor(-79.806419, 60, "North Carolina"), Governor(-99.784012, 60,
"North Dakota"),
Governor(-82.764915, 65, "Ohio"), Governor(-96.928917, 62, "Oklahoma"),
Governor(-122.070938, 56, "Oregon"), Governor(-77.209755, 68,
"Pennsylvania"),
Governor(-71.51178, 46, "Rhode Island"), Governor(-80.945007, 70,
"South Carolina"),
Governor(-99.438828, 64, "South Dakota"), Governor(-86.692345, 58,
"Tennessee"),
Governor(-97.563461, 59, "Texas"), Governor(-111.862434, 70, "Utah"),
Governor(-72.710686, 58, "Vermont"), Governor(-78.169968, 60,
"Virginia"),
Governor(-121.490494, 66, "Washington"), Governor(-80.954453, 66, "West
Virginia"),
Governor(-89.616508, 49, "Wisconsin"), Governor(-107.30249, 55,
"Wyoming")]

Executaremos o k-means com k de nido com 2.


Listagem 6.14 – Continuação de governors.py
kmeans: KMeans[Governor] = KMeans(2, governors)
gov_clusters: List[KMeans.Cluster] = kmeans.run()
for index, cluster in enumerate(gov_clusters):
print(f"Cluster {index}: {cluster.points}\n")

Como o algoritmo começa com centroides aleatórios, cada execução de


KMeans poderá devolver clusters distintos. É necessário um pouco de
análise por parte de um ser humano para ver se os clusters são realmente
relevantes. O resultado a seguir é de uma execução que apresentou
clusters interessantes:
Converged after 5 iterations
Cluster 0: [Alabama: (longitude: -86.79113, age: 72), Arizona: (longitude:
-111.431221, age: 53), Arkansas: (longitude: -92.373123, age: 66),
Colorado: (longitude: -105.311104, age: 65), Connecticut: (longitude:
-72.755371, age: 61), Delaware: (longitude: -75.507141, age: 61),
Florida: (longitude: -81.686783, age: 64), Georgia: (longitude:
-83.643074, age: 74), Illinois: (longitude: -88.986137, age: 60),
Indiana: (longitude: -86.258278, age: 49), Iowa: (longitude: -93.210526,
age: 57), Kansas: (longitude: -96.726486, age: 60), Kentucky:
(longitude: -84.670067, age: 50), Louisiana: (longitude: -91.867805,
age: 50), Maine: (longitude: -69.381927, age: 68), Maryland: (longitude:
-76.802101, age: 61), Massachusetts: (longitude: -71.530106, age: 60),
Michigan: (longitude: -84.536095, age: 58), Minnesota: (longitude:
-93.900192, age: 70), Mississippi: (longitude: -89.678696, age: 62),
Missouri: (longitude: -92.288368, age: 43), Montana: (longitude:
-110.454353, age: 51), Nebraska: (longitude: -98.268082, age: 52),
Nevada: (longitude: -117.055374, age: 53), New Hampshire: (longitude:
-71.563896, age: 42), New Jersey: (longitude: -74.521011, age: 54), New
Mexico: (longitude: -106.248482, age: 57), New York: (longitude:
-74.948051, age: 59), North Carolina: (longitude: -79.806419, age: 60),
North Dakota: (longitude: -99.784012, age: 60), Ohio: (longitude:
-82.764915, age: 65), Oklahoma: (longitude: -96.928917, age: 62),
Pennsylvania: (longitude: -77.209755, age: 68), Rhode Island:
(longitude: -71.51178, age: 46), South Carolina: (longitude: -80.945007,
age: 70), South Dakota: (longitude: -99.438828, age: 64), Tennessee:
(longitude: -86.692345, age: 58), Texas: (longitude: -97.563461, age:
59), Vermont: (longitude: -72.710686, age: 58), Virginia: (longitude:
-78.169968, age: 60), West Virginia: (longitude: -80.954453, age: 66),
Wisconsin: (longitude: -89.616508, age: 49), Wyoming: (longitude:
-107.30249, age: 55)]
Cluster 1: [Alaska: (longitude: -152.404419, age: 66), California:
(longitude: -119.681564, age: 79), Hawaii: (longitude: -157.498337, age:
60), Idaho: (longitude: -114.478828, age: 75), Oregon: (longitude:
-122.070938, age: 56), Utah: (longitude: -111.862434, age: 70),
Washington: (longitude: -121.490494, age: 66)]

O Cluster 1 representa os estados mais a oeste, todos geogra camente


próximos (se você considerar que o Alasca e o Havaí estão próximos dos
estados da costa do Pací co). Todos eles têm governadores relativamente
mais velhos e, desse modo, formaram um cluster interessante. As pessoas
da costa do Pací co gostam de governadores mais velhos? Não é possível
determinar nada conclusivo a partir desses clusters, além de uma
correlação. A Figura 6.3 exibe o resultado. Os quadrados são do cluster 1,
e os círculos, do cluster 0.
Figura 6.3 – Pontos de dados no cluster 0 estão representados por círculos,
enquanto pontos de dados do cluster 1 estão representados por quadrados.
DICA Nunca é demais enfatizar que seus resultados com o k-means usando inicialização
aleatória de centroides vai variar. Não se esqueça de executar o k-means várias vezes com
qualquer conjunto de dados.

6.4 Clustering de álbuns do Michael Jackson por tamanho


Michael Jackson lançou dez álbuns solo gravados em estúdio. No
exemplo a seguir, faremos o clustering desses álbuns observando duas
dimensões: duração do álbum (em minutos) e o número de faixas
musicais. Esse exemplo apresenta um contraste interessante com o
exemplo anterior dos governadores porque é fácil ver os clusters no
conjunto de dados originais, sem sequer executar o k-means. Um
exemplo como esse pode ser uma boa maneira de depurar uma
implementação de um algoritmo de clustering.
NOTA Os dois exemplos deste capítulo fazem uso de pontos de dados bidimensionais, mas o
k-means é capaz de trabalhar com pontos de dados com qualquer quantidade de dimensões.
O exemplo será apresentado por completo nesta seção, como uma única
listagem de código. Se você observar os dados sobre os álbuns na
listagem de código a seguir, mesmo antes de executar o exemplo, estará
claro que Michael Jackson gravou álbuns mais longos em direção ao nal
de sua carreira. Assim, os dois clusters de álbuns provavelmente serão
divididos entre os primeiros e os últimos álbuns. HIStory: Past, Present,
and Future, Book I é um dado discrepante, e poderá, sem dúvida, acabar
em seu próprio cluster único. Um dado discrepante é um ponto de dado
que está fora dos limites usuais de um conjunto de dados.
Listagem 6.15 – mj.py
from __future__ import annotations
from typing import List
from data_point import DataPoint
from kmeans import KMeans

class Album(DataPoint):
def __init__(self, name: str, year: int, length: float, tracks: float) -
> None:
super().__init__([length, tracks])
self.name = name
self.year = year
self.length = length
self.tracks = tracks

def __repr__(self) -> str:


return f"{self.name}, {self.year}"

if __name__ == "__main__":
albums: List[Album] =
[Album("Got to Be There", 1972, 35.45, 10), Album("Ben", 1972, 31.31,
10),
Album("Music & Me", 1973, 32.09, 10), Album("Forever, Michael", 1975,
33.36, 10),
Album("Off the Wall", 1979, 42.28, 10), Album("Thriller", 1982, 42.19,
9),
Album("Bad", 1987, 48.16, 10), Album("Dangerous", 1991, 77.03, 14),
Album("HIStory: Past, Present and Future, Book I", 1995, 148.58, 30),
Album("Invincible", 2001, 77.05, 16)]
kmeans: KMeans[Album] = KMeans(2, albums)
clusters: List[KMeans.Cluster] = kmeans.run()
for index, cluster in enumerate(clusters):
print(f"Cluster {index} Avg Length {cluster.centroid.dimensions[0]
} Avg Tracks {cluster.centroid.dimensions[1]}:
{cluster.points}\n")

Observe que os atributos name e year são registrados somente por questões
de nomenclatura, e não estão incluídos no clustering propriamente dito.
Eis um exemplo de saída:
Converged after 1 iterations
Cluster 0 Avg Length -0.5458820039179509 Avg Tracks -0.5009878988684237:
[Got to Be There, 1972, Ben, 1972, Music & Me, 1973, Forever, Michael,
1975, Off the Wall, 1979, Thriller, 1982, Bad, 1987]
Cluster 1 Avg Length 1.2737246758085523 Avg Tracks 1.1689717640263217:
[Dangerous, 1991, HIStory: Past, Present and Future, Book I, 1995,
Invincible, 2001]

As médias geradas nos clusters são interessantes. Observe que as médias


são escores z. Os três álbuns do Cluster 1, isto é, os três últimos álbuns
de Michael Jackson, foram aproximadamente um desvio-padrão maiores
do que a média de todos os seus dez álbuns solo.

6.5 Problemas e extensões do clustering k-means


Quando o clustering k-means é implementado com pontos de início
aleatórios, ele pode deixar totalmente de perceber pontos de divisão
importantes nos dados. Com frequência, isso resulta em muitas tentativas
e erros para o operador. Descobrir o valor correto de “k” (o número de
clusters) também é difícil e suscetível a erros caso o operador não tenha
um bom insight acerca da quantidade de grupos de dados que deve
haver.
Há versões mais so sticadas do k-means, capazes de dar palpites bem
fundamentados ou efetuar tentativas e erros automaticamente no que
concerne a essas variáveis problemáticas. Uma variante conhecida é o k-
means++, que tenta resolver o problema da inicialização escolhendo
centroides com base em uma distribuição de probabilidades para a
distância até cada ponto, em vez de usar apenas a aleatoriedade. Uma
opção melhor ainda para muitas aplicações é escolher boas regiões de
partida para cada um dos centroides, com base em informações sobre os
dados que sejam conhecidas previamente – em outras palavras, uma
versão do k-means em que o usuário do algoritmo escolhe os centroides
iniciais.
O tempo de execução do clustering k-means é proporcional ao número
de pontos de dados, de clusters e de dimensões dos pontos de dados. Ele
poderá se tornar inviável em sua forma básica se houver um número
elevado de pontos com muitas dimensões. Há extensões que tentam não
fazer tantos cálculos entre cada ponto e cada centro, avaliando se um
ponto de fato tem o potencial de se mover para outro cluster, antes de
fazer o cálculo. Outra opção para conjuntos de dados com muitos pontos
ou muitas dimensões é submeter apenas uma amostragem dos pontos de
dados ao k-means. Isso gerará uma aproximação dos clusters que o
algoritmo k-means completo encontraria.
Dados discrepantes em um conjunto de dados podem gerar resultados
inusitados com o k-means. Se um centroide inicial estiver, por acaso,
próximo de um valor discrepante, um cluster de um só elemento poderia
ser formado (como poderia possivelmente acontecer com o álbum
HIStory no exemplo com Michael Jackson). O k-means talvez tenha uma
melhor execução se os dados discrepantes forem removidos.
Por m, a média nem sempre é considerada uma boa medida do centro.
Os k-medians (k-medianas) observam a mediana de cada dimensão, e os
k-medoids utilizam um ponto do conjunto de dados como o centro de
cada cluster. Há explicações estatísticas que estão além do escopo deste
livro para escolher cada um desses métodos de centralização, mas o bom
senso diz que, para um problema complicado, pode valer a pena testar
cada um deles e obter uma amostragem do resultado. As implementações
de cada um desses métodos não são muito diferentes.

6.6 Aplicações no mundo real


O clustering, em geral, está no domínio dos cientistas de dados e dos
analistas de estatística. É amplamente usado como uma forma de
interpretar dados em diversos campos. O clustering k-means, em
particular, é uma técnica útil quando sabemos pouco sobre a estrutura do
conjunto de dados.
Em análise de dados, o clustering é uma técnica essencial. Pense em um
departamento de polícia que queira saber em que lugar deve colocar os
policiais em patrulhamento. Pense em uma franquia de fast food que
queira descobrir onde estão seus melhores clientes, para lhes enviar
promoções. Pense em um locador de barcos que queira minimizar os
acidentes analisando quando estes ocorrem e quem os causa. Agora
imagine como eles poderiam resolver seus problemas usando clustering.
O clustering ajuda no reconhecimento de padrões. Um algoritmo de
clustering é capaz de detectar um padrão que não é percebido pelo olhar
humano. Por exemplo, o clustering às vezes é usado em biologia para
identi car grupos de células incongruentes.
Em reconhecimento de imagens, o clustering ajuda a identi car traços
não óbvios. Pixels individuais podem ser tratados como pontos de dados,
com os relacionamentos entre si de nidos pela distância e pela diferença
de cores.
Em ciências políticas, o clustering é ocasionalmente utilizado para
encontrar eleitores visados. Um partido político poderia encontrar
eleitores sem partido concentrados em um único distrito no qual ele
deveria concentrar o dinheiro de sua campanha? Em quais problemas
eleitores semelhantes teriam mais chances de estar interessados?

6.7 Exercícios
1. Crie uma função capaz de importar dados de um arquivo CSV para
DataPoint s.
2. Crie uma função usando uma biblioteca externa, como a matplotlib,
que crie um grá co de dispersão colorido com os resultados de
qualquer execução de KMeans em um conjunto de dados bidirecional.
3. Crie outro código de inicialização de KMeans que aceite posições
iniciais para os centroides, em vez de atribuí-los aleatoriamente.
4. Faça pesquisas sobre o algoritmo k-means++ e o implemente.
CAPÍTULO 7

Redes neurais relativamente simples

Quando ouvimos falar dos avanços da inteligência arti cial atualmente –


no nal dos anos 2010 –, em geral, eles dizem respeito a uma subárea
especí ca conhecida como aprendizado de máquina (machine learning,
isto é, computadores que aprendem algumas informações novas sem que
lhes tenham sido ditas explicitamente). Com muita frequência, esses
avanços se devem a uma técnica especí ca de aprendizado de máquina,
conhecida como redes neurais (neural networks). Apesar de ter sido
inventada há décadas, as redes neurais têm passado por uma espécie de
renascimento, uma vez que hardwares melhores e técnicas recém-
descobertas de software, resultantes de pesquisas, possibilitam a
existência de um novo paradigma conhecido como aprendizagem
profunda (deep learning).
A aprendizagem profunda passou a ser uma técnica amplamente
aplicável. Ela tem se mostrado útil em tudo, de algoritmos para fundos
hedge a bioinformática. Duas aplicações da aprendizagem profunda com
as quais os usuários passaram a ter familiaridade são o reconhecimento
de imagens e o reconhecimento de fala. Se você já perguntou como está o
clima ao seu assistente digital ou já fez um programa de fotos reconhecer
o seu rosto, é provável que alguma dose de aprendizagem profunda
estivesse envolvida.
Técnicas de aprendizagem profunda fazem uso dos mesmos blocos de
construção utilizados pelas redes neurais mais simples. Neste capítulo,
exploraremos esses blocos construindo uma rede neural simples. Não
será o estado da arte, mas você verá os fundamentos para entender a
aprendizagem profunda (que é baseada em redes neurais mais complexas
do que a rede que construiremos). A maioria dos pro ssionais que
trabalham com aprendizado de máquina não constrói redes neurais do
zero. Em vez disso, eles utilizam frameworks populares prontos,
extremamente otimizados, que fazem o trabalho pesado. Embora este
capítulo não o ensine a usar nenhum framework especí co, e a rede que
construiremos não vá ser útil para nenhuma aplicação real, ele ajudará
você a entender como esses frameworks funcionam no nível básico.

7.1 Base biológica?


O cérebro humano é o dispositivo computacional mais incrível que existe.
Ele não é capaz de processar números de modo tão rápido quanto um
microprocessador, mas sua capacidade de se adaptar a novas situações,
adquirir novas habilidades e ser criativo ainda não foi superada por
nenhuma máquina conhecida. Desde o surgimento dos computadores, os
cientistas têm se interessado em modelar o sistema de funcionamento do
cérebro. Cada célula nervosa do cérebro é conhecida como neurônio. Os
neurônios do cérebro estão interligados em rede, por meio de conexões
conhecidas como sinapses. A eletricidade passa pelas sinapses
alimentando essas redes de neurônios – também conhecidas como redes
neurais.
NOTA A descrição anterior dos neurônios biológicos é uma simpli cação grosseira visando a
uma analogia. Na verdade, os neurônios biológicos têm outras partes, como axônios,
dendritos e núcleos, que você deve se lembrar de ter visto na disciplina de biologia do ensino
médio. Na verdade, as sinapses são lacunas entre os neurônios, onde neurotransmissores se
formam para permitir que esses sinais elétricos sejam transmitidos.
Embora os cientistas tenham identi cado as partes e as funções dos
neurônios, os detalhes de como as redes neurais biológicas formam
padrões de pensamento complexos ainda não foram bem compreendidos.
Como elas processam informações? Como compõem pensamentos
originais? A maior parte de nosso conhecimento sobre o funcionamento
do cérebro resulta da observação em nível macro. Imagens de ressonância
magnética funcional do cérebro mostram os pontos em que o sangue ui
quando um ser humano está fazendo alguma atividade em particular ou
está pensando em algo especí co (exibido na Figura 7.1). Essa e outras
técnicas no nível macro podem levar a inferências sobre como as diversas
partes estão conectadas, mas não explicam os mistérios de como os
neurônios individuais contribuem para o desenvolvimento de novos
pensamentos.
Equipes de cientistas em todo o mundo se apressam para desvendar os
segredos do cérebro; todavia, considere o seguinte: o cérebro humano
tem aproximadamente 100 bilhões de neurônios, e cada um deles pode ter
conexões com até dezenas de milhares de outros neurônios. Até mesmo
para um computador com bilhões de portas lógicas e terabytes de
memória, seria impossível modelar um único cérebro humano com a
tecnologia atual. Os seres humanos provavelmente continuarão sendo as
entidades de aprendizagem de propósito geral mais so sticadas no futuro
próximo.

Figura 7.1 – Um pesquisador estudando imagens de ressonância magnética


funcional do cérebro. Essas imagens não nos dizem muito sobre o
funcionamento de neurônios individuais ou como as redes neurais estão
organizadas. (Domínio público, U.S. National Institute for Mental Health
[Instituto Nacional de Saúde Mental dos Estados Unidos]).
NOTA Uma máquina de aprendizagem de propósito geral equivalente aos seres humanos em
capacidade é o objetivo da chamada IA forte (strong AI) – também conhecida como
inteligência arti cial genérica (arti cial general intelligence). Neste ponto da história, ainda é
assunto de cção cientí ca. A IA fraca (weak IA) é o tipo de IA que vemos no cotidiano:
computadores resolvendo tarefas especí cas para as quais foram pré-con gurados, de modo
inteligente.
Se as redes neurais biológicas não foram totalmente compreendidas, por
que as modelar é uma técnica computacional e caz? Apesar de as redes
neurais digitais, conhecidas como redes neurais arti ciais, se inspirarem
nas redes neurais biológicas, é na inspiração que as semelhanças
terminam. As redes neurais arti ciais modernas não alegam que
funcionam como suas contrapartidas biológicas. De fato, isso seria
impossível, pois, para começar, não compreendemos totalmente de que
modo as redes neurais biológicas funcionam.

7.2 Redes neurais arti ciais


Nesta seção, veremos o que é, sem dúvida, o tipo mais comum de rede
neural arti cial: uma rede feedforward com retropropagação
(backpropagation) – o mesmo tipo de rede que implementaremos mais
adiante. Feedforward signi ca que o sinal, de modo geral, se move em
uma única direção na rede. Retropropagação signi ca que
determinaremos os erros no nal do percurso de cada sinal pela rede e
tentaremos distribuir correções para esses erros de volta na rede,
afetando particularmente os neurônios que foram mais responsáveis por
eles. Há vários outros tipos de redes neurais arti ciais, e este capítulo
talvez faça você se interessar em explorá-los melhor.

7.2.1 Neurônios
A menor unidade em uma rede neural arti cial é o neurônio. Ele
armazena um vetor de pesos, que são apenas números de ponto utuante.
Um vetor de entradas (também composto apenas de números de ponto
utuante) é passado para o neurônio. O neurônio combina essas entradas
com seus pesos usando um produto escalar. Em seguida, ele executa uma
função de ativação nesse produto e disponibiliza esse resultado como
saída. Podemos pensar nessa ação como análoga ao disparo de um
verdadeiro neurônio.
Uma função de ativação é um transformador da saída do neurônio. A
função de ativação é quase sempre não linear, e isso permite que as redes
neurais representem soluções para problemas não lineares. Se não
houvesse funções de ativação, a rede neural completa seria apenas uma
transformação linear. A Figura 7.2 mostra um único neurônio e o seu
funcionamento.

Figura 7.2 – Um único neurônio combina seus pesos com sinais de entrada a
m de gerar um sinal de saída que é modi cado por uma função de
ativação.
NOTA Há alguns termos matemáticos nesta seção, os quais talvez você não tenha visto desde
um curso básico de cálculo ou de álgebra linear. Explicar o que são vetores ou produtos
escalares está além do escopo deste capítulo, mas você provavelmente terá uma intuição
sobre o que uma rede neural faz se acompanhar este capítulo, mesmo que não compreenda
toda a matemática. Mais adiante, haverá um pouco de cálculo, incluindo uso de derivadas e
derivadas parciais, mas, mesmo que não compreenda toda a matemática envolvida, você
deverá ser capaz de entender o código. Este capítulo, na verdade, não explicará como derivar
as fórmulas usando cálculo. Em vez disso, o foco estará no uso das derivadas.

7.2.2 Camadas
Em uma rede neural arti cial feedforward típica, os neurônios estão
organizados em camadas. Cada camada é composta de determinado
número de neurônios alinhados em uma linha ou coluna (dependerá do
diagrama; ambos são equivalentes). Em uma rede feedforward, que é a
rede que construiremos, os sinais sempre trafegam na mesma direção, de
uma camada para a próxima. Os neurônios de cada camada enviam seus
sinais de saída para que sejam utilizados como entrada para os neurônios
da próxima camada. Todo neurônio em qualquer camada está conectado
a todos os neurônios da próxima camada.
A primeira camada é conhecida como camada de entrada, e recebe seus
sinais de alguma entidade externa. A última camada é conhecida como
camada de saída, e sua saída em geral deve ser interpretada por um agente
externo para que um resultado inteligente seja obtido. As camadas entre
as camadas de entrada e de saída são conhecidas como camadas ocultas.
Em redes neurais simples como aquela que construiremos neste capítulo,
há apenas uma camada oculta, mas as redes de aprendizagem profunda
(redes deep learning) têm várias camadas. A Figura 7.3 mostra as camadas
funcionando em conjunto em uma rede simples. Observe como as saídas
de uma camada são utilizadas como entradas para todos os neurônios da
próxima camada.
Essas camadas simplesmente manipulam números de ponto utuante.
As entradas para a camada de entrada são números de ponto utuante, e
as saídas da camada de saída também são números de ponto utuante.
Obviamente esses números devem representar algo signi cativo.
Suponha que a rede tenha sido projetada para classi car pequenas
imagens de animais em preto e branco. A camada de entrada poderia ter
100 neurônios representando a intensidade na escala de cinzas para cada
pixel, em uma imagem de 10 x 10 pixels de um animal, e a camada de
saída teria 5 neurônios que representariam a probabilidade de a imagem
ser de um mamífero, um réptil, um anfíbio, um peixe ou uma ave. A
classi cação nal poderia ser determinada pelo neurônio de saída que
gerasse o maior número de ponto utuante. Se os números de saída
fossem 0,24, 0,65, 0,70, 0,12 e 0,21, respectivamente, a imagem seria
classi cada como de um anfíbio.
Figura 7.3 – Uma rede neural simples, com uma camada de entrada
contendo dois neurônios, uma camada oculta com quatro neurônios e uma
camada de saída com três neurônios. Nessa gura, o número de neurônios
em cada camada é arbitrário.

7.2.3 Retropropagação
A última peça do quebra-cabeça, e a parte inerentemente mais complexa,
é a retropropagação (backpropagation). A retropropagação encontra o
erro na saída de uma rede neural e utiliza esse dado para modi car os
pesos dos neurônios. Os neurônios mais responsáveis pelo erro são os
que sofrerão mais modi cação. Mas de onde vem o erro? Como é
possível saber qual é o erro? O erro é oriundo de uma fase do uso de
uma rede neural conhecida como treinamento.
DICA Há passos descritos (textualmente) para diversas fórmulas matemáticas nesta seção.
Pseudofórmulas (que não utilizam uma notação apropriada) estão nas guras que
acompanham a descrição. Essa abordagem deixará as fórmulas mais legíveis para as pessoas
que não tenham conhecimento da notação matemática (ou que estejam sem prática). Se
você tiver interesse na notação mais formal (e na derivação das fórmulas), consulte o
Capítulo 18 do livro Arti cial Intelligence de Norvig e Russell.1
Antes que seja possível usá-la, a maioria das redes neurais deve passar por
um treinamento. Devemos saber quais são as saídas corretas para algumas
entradas, de modo que possamos usar a diferença entre as saídas
esperadas e as saídas reais para identi car os erros e modi car os pesos.
Em outras palavras, as redes neurais não sabem de nada até que as
respostas corretas lhes sejam informadas para um determinado conjunto
de entradas; desse modo, elas poderão se preparar para outras entradas.
A retropropagação ocorre apenas durante o treinamento.
NOTA Como a maioria das redes neurais deve passar por um treinamento, elas são
consideradas como um tipo de aprendizagem de máquina supervisionada . Lembre-se de que,
conforme vimos no Capítulo 6, o algoritmo k-means e outros algoritmos de clustering
(agrupamento) são considerados como uma forma de aprendizagem de máquina não
supervisionada porque, depois de iniciados, nenhuma intervenção externa é necessária. Há
outros tipos de redes neurais além da rede descrita neste capítulo, as quais não exigem
treinamento prévio e são consideradas como uma forma de aprendizagem não
supervisionada.
O primeiro passo na retropropagação é calcular o erro entre a saída da
rede neural para uma entrada e a saída esperada. Esse erro está espalhado
por todos os neurônios da camada de saída. (Cada neurônio tem uma
saída esperada e a sua saída real.) A derivada da função de ativação do
neurônio de saída é então aplicada no valor que foi gerado pelo neurônio
como saída, antes de sua função de ativação ter sido aplicada.
(Armazenamos a saída em cache, antes da aplicação da função de
ativação.) Esse resultado é multiplicado pelo erro do neurônio para
calcular o seu delta. Essa fórmula para calcular o delta utiliza uma
derivada parcial, e o cálculo dessa derivada está além do escopo deste
livro; basicamente, porém, estamos calculando qual é a parcela de erro
pela qual cada neurônio de saída foi responsável. Veja a Figura 7.4 que
apresenta um diagrama desse cálculo.
Os deltas devem ser então calculados para cada neurônio da(s)
camada(s) oculta(s) da rede. Devemos determinar a parcela de erro pela
qual cada neurônio foi responsável ao gerar a saída incorreta na camada
de saída. Os deltas na camada de saída são usados para calcular os deltas
da camada oculta anterior. Para cada camada anterior, os deltas são
calculados tomando-se o produto escalar dos pesos da próxima camada
em relação ao neurônio especí co em questão e os deltas já calculados na
próxima camada. Esse valor é multiplicado pela derivada da função de
ativação aplicada à última saída de um neurônio (armazenada em cache
antes de a função de ativação ter sido aplicada) a m de obter o delta do
neurônio. Novamente, essa fórmula é obtida usando uma derivada
parcial, a respeito da qual você poderá ler em textos com enfoque maior
em matemática.

Figura 7.4 – Processo pelo qual o delta de um neurônio de saída é calculado


durante a fase de retropropagação do treinamento.
A Figura 7.5 mostra o cálculo propriamente dito dos deltas para os
neurônios das camadas ocultas. Em uma rede com várias camadas
ocultas, os neurônios O1, O2 e O3 poderiam ser os neurônios da
próxima camada oculta, e não da camada de saída.
Figura 7.5 – Como um delta é calculado para um neurônio em uma camada
oculta.
Por m, o mais importante é que os pesos de todos os neurônios da
rede devem ser atualizados multiplicando-se a última entrada do peso de
cada indivíduo pelo delta do neurônio e por algo chamado taxa de
aprendizagem, e somando-se esse valor ao peso atual. Esse método de
modi car o peso de um neurônio é conhecido como gradiente
descendente. É como descer uma colina, representando a função de erro
do neurônio em direção a um ponto de erro mínimo. O delta representa a
direção que queremos seguir, e a taxa de aprendizagem afeta a velocidade
com que seguimos. É difícil determinar o que seria uma boa taxa de
aprendizagem para um problema desconhecido se não houver tentativas e
erros. A Figura 7.6 mostra como cada peso da camada oculta e da camada
de saída é atualizado.
Figura 7.6 – Os pesos de cada neurônio da camada oculta e da camada de
saída são atualizados usando os deltas calculados nos passos anteriores, os
pesos anteriores, as entradas anteriores e uma taxa de aprendizagem
de nida pelo usuário.
Depois que os pesos forem atualizados, a rede neural estará pronta para
novo treinamento, com outra entrada e outra saída esperada. Esse
processo se repetirá até que a rede seja considerada bem treinada pelo
usuário da rede neural. Isso pode ser determinado testando a rede com
entradas cujas saídas corretas sejam conhecidas.
A retropropagação é complicada. Não se preocupe caso ainda não
tenha compreendido todos os detalhes. A explicação que está nesta seção
talvez não seja su ciente. A implementação da retropropagação deverá
levar a sua compreensão ao próximo patamar. Durante a implementação
de nossa rede neural e da retropropagação, tenha em mente a ideia geral a
seguir: a retropropagação é uma forma de ajustar o peso de cada
indivíduo da rede de acordo com a sua responsabilidade por uma saída
incorreta.
7.2.4 Visão geral
Abordamos vários assuntos nesta seção. Mesmo que os detalhes ainda
não façam sentido, é importante ter em mente as principais ideias de uma
rede feedforward com retropropagação:
• Os sinais (números de ponto utuante) se movem pelos neurônios
organizados em camadas em uma só direção. Todo neurônio de
qualquer camada está conectado a todos os neurônios da próxima
camada.
• Todo neurônio (exceto na camada de entrada) processa os sinais que
recebe combinando-os com os pesos (que também são números de
ponto utuante) e aplicando uma função de ativação.
• Durante um processo chamado treinamento, as saídas da rede são
comparadas com as saídas esperadas a m de calcular erros.
• Os erros são propagados para trás na rede (de volta, em direção ao
ponto de partida) para que os pesos sejam modi cados e,
consequentemente, haja mais chances de saídas corretas serem
geradas.
Há outros métodos para treinamento de redes neurais além daquele
explicado neste capítulo. Há também várias maneiras diferentes pelas
quais os sinais podem se mover pelas redes neurais. O método explicado
neste capítulo, e que será implementado, é apenas uma forma
particularmente comum, que servirá como uma introdução razoável. O
Apêndice B lista outros recursos para conhecer melhor as redes neurais
(incluindo outros tipos) e a matemática envolvida.

7.3 Informações preliminares


As redes neurais utilizam recursos matemáticos que exigem muitas
operações com números de ponto utuante. Antes de desenvolver as
estruturas para a nossa rede neural simples, precisaremos de algumas
primitivas de matemática. Essas primitivas simples serão intensamente
utilizadas no código a seguir, portanto, se você encontrar meios de
agilizá-las, sua rede neural terá realmente um ganho de desempenho.
AVISO A complexidade do código deste capítulo, sem dúvida, é maior do que de qualquer
outro capítulo do livro. Há muitas construções no código, e os resultados propriamente
ditos serão vistos apenas no nal. Vários textos sobre redes neurais podem ajudar você a
construir uma rede com poucas linhas de código; contudo, este exemplo tem como objetivo
explorar o funcionamento de uma rede e como os diferentes componentes atuam juntos, de
uma forma legível e expansível. Essa é a nossa meta, ainda que o código acabe cando um
pouco mais extenso e expressivo.

7.3.1 Produto escalar


Como você deve se lembrar, os produtos escalares são necessários tanto
na fase de feedforward como de retropropagação. Felizmente, um
produto escalar é fácil de implementar usando as funções embutidas de
Python zip() e sum(). Manteremos nossas funções preliminares em um
arquivo util.py.
Listagem 7.1 – util.py
from typing import List
from math import exp

# produto escalar de dois vetores


def dot_product(xs: List[float], ys: List[float]) -> float:
return sum(x * y for x, y in zip(xs, ys))

7.3.2 Função de ativação


Lembre-se de que a função de ativação transforma a saída de um
neurônio antes que o sinal seja passado para a próxima camada (veja a
Figura 7.2). A função de ativação tem duas nalidades: permite que a rede
neural represente soluções que não sejam apenas transformações lineares
(desde que a própria função de ativação não seja apenas uma
transformação linear), e consegue manter a saída de cada neurônio dentro
de determinada faixa. Uma função de ativação deve ter uma derivada
calculável para que essa seja usada na retropropagação.
Funções sigmoides são um conjunto popular de funções de ativação. A
Figura 7.7 mostra uma função sigmoide particularmente conhecida
(muitas vezes chamada de “a função sigmoide”) – referenciada na gura
como S(x) –, junto com a sua equação e a derivada (S’(x)). O resultado da
função sigmoide será sempre um valor entre 0 e 1. Ter o valor
consistentemente entre 0 e 1 é conveniente para a rede, conforme
veremos. Em breve, você verá as fórmulas da gura traduzidas no código.

Figura 7.7 – A função de ativação sigmoide (S(x)) sempre devolverá um


valor entre 0 e 1. Observe que é fácil calcular também a sua derivada
(S’(x)).
Há outras funções de ativação, mas utilizaremos a função sigmoide. Eis
uma conversão direta das fórmulas da Figura 7.7 para o código:
Listagem 7.2 – Continuação de util.py
# a clássica função de ativação sigmoide
def sigmoid(x: float) -> float:
return 1.0 / (1.0 + exp(-x))

def derivative_sigmoid(x: float) -> float:


sig: float = sigmoid(x)
return sig * (1 - sig)

7.4 Construindo a rede


Criaremos classes para modelar todas as três unidades organizacionais da
rede: neurônios, camadas e a própria rede. Para simpli car, começaremos
pela menor unidade (os neurônios), passaremos para o componente
central da organização (as camadas) e implementaremos a unidade maior
(a rede toda). À medida que avançarmos do componente menor para o
maior, encapsularemos o nível anterior. Os neurônios só conhecem a si
mesmos. As camadas conhecem os neurônios que elas contêm e outras
camadas. E a rede conhece todas as camadas.
NOTA Há várias linhas de código longas neste capítulo, que quase não cabem nos limites das
colunas de um livro impresso. Recomendo que você faça o download do código-fonte deste
capítulo a partir do repositório de códigos-fontes do livro e acompanhe na tela de seu
computador à medida que o ler: https://github.com/
davecom/ClassicComputerScienceProblemsInPython.

7.4.1 Implementando os neurônios


Vamos começar com um neurônio. Um neurônio individual armazenará
várias informações de estado, incluindo seus pesos, seu delta, sua taxa de
aprendizagem, um cache de sua última saída e sua função de ativação,
além da derivada dessa função. Alguns desses elementos poderiam ser
armazenados de modo mais e ciente um nível acima (na futura classe
Layer ), mas foram incluídos na classe Neuron para demonstração.

Listagem 7.3 – neuron.py


from typing import List, Callable
from util import dot_product

class Neuron:
def __init__(self, weights: List[float], learning_rate: float,
activation_function: Callable[[float], float],
derivative_activation_function: Callable[[float], float]) -> None:
self.weights: List[float] = weights
self.activation_function: Callable[[float], float] =
activation_function
self.derivative_activation_function: Callable[[float], float] =
derivative_activation_function
self.learning_rate: float = learning_rate
self.output_cache: float = 0.0
self.delta: float = 0.0
def output(self, inputs: List[float]) -> float:
self.output_cache = dot_product(inputs, self.weights)
return self.activation_function(self.output_cache)

A maior parte desses parâmetros é inicializada no método __init__().


Como delta e output_cache não são conhecidos quando um Neuron é
criado, eles são simplesmente inicializados com 0. Todas as variáveis do
neurônio são mutáveis. Na vida de um neurônio (de acordo com o modo
como o usaremos), seus valores talvez jamais mudem, mas há um motivo
para deixá-los mutáveis: a exibilidade. Se essa classe Neuron for usada
com outros tipos de redes neurais, é possível que alguns desses valores
sejam alterados durante a execução. Há redes neurais que alteram a taxa
de aprendizagem como parte das abordagens para a solução, e que
tentam diferentes funções de ativação automaticamente. Em nosso caso,
procuraremos manter a classe Neuron exível ao máximo para outras
aplicações de redes neurais.
O único método além de __init__() é output(). output() aceita os sinais
de entrada (inputs) que chegam até o neurônio e aplica a fórmula
discutida antes neste capítulo (veja a Figura 7.2). Os sinais de entrada são
combinados com os pesos por meio de um produto escalar, e esse
resultado é armazenado em output_cache. Lembre-se de que, conforme
vimos na seção sobre retropropagação, esse valor, obtido antes de a
função de ativação ter sido aplicada, é usado para calcular o delta. Por
m, antes de enviar o sinal para a próxima camada (o valor é devolvido
por output()), a função de ativação é aplicada.
É isso! Um neurônio individual nessa rede é razoavelmente simples. Ele
não faz muita coisa, além de aceitar um sinal de entrada, transformá-lo e
enviá-lo para ser processado adiante. O neurônio mantém vários
elementos para armazenar estados, que serão usados por outras classes.

7.4.2 Implementando as camadas


Uma camada de nossa rede deverá manter três informações de estado:
seus neurônios, a camada que a antecede e um cache de saída. O cache
de saída é semelhante ao cache de um neurônio, porém um nível acima.
Ele armazena as saídas (após as funções de ativação terem sido aplicadas)
de cada neurônio da camada.
No momento da criação, a principal responsabilidade de uma camada é
inicializar seus neurônios. O método __init__() de nossa classe Layer,
desse modo, precisa saber quantos neurônios devem ser inicializados,
quais são as funções de ativação e quais são as taxas de aprendizagem.
Nessa rede simples, todos os neurônios de uma camada têm a mesma
função de ativação e a mesma taxa de aprendizagem.
Listagem 7.4 – layer.py
from __future__ import annotations
from typing import List, Callable, Optional
from random import random
from neuron import Neuron
from util import dot_product

class Layer:
def __init__(self, previous_layer: Optional[Layer], num_neurons: int,
learning_rate: float, activation_function: Callable[[float], float],
derivative_activation_function: Callable[[float], float]) -> None:
self.previous_layer: Optional[Layer] = previous_layer
self.neurons: List[Neuron] = []
# todo o código a seguir poderia ser uma grande list comprehension
for i in range(num_neurons):
if previous_layer is None:
random_weights: List[float] = []
else:
random_weights = [random() for _ in
range(len(previous_layer.neurons))]
neuron: Neuron = Neuron(random_weights, learning_rate,
activation_function, derivative_activation_function)
self.neurons.append(neuron)
self.output_cache: List[float] = [0.0 for _ in range(num_neurons)]

À medida que os sinais avançarem pela rede, Layer deverá processá-los


por intermédio de cada neurônio. (Lembre-se de que cada neurônio em
uma camada recebe os sinais de todos os neurônios da camada anterior.)
outputs() faz exatamente isso. O método também devolve o resultado do
processamento dos neurônios (a ser passado pela rede para a próxima
camada) e armazena a saída em cache. Se não houver uma camada
anterior, é sinal de que a camada é uma camada de entrada, e ela apenas
passará os sinais para a frente, para a próxima camada.
Listagem 7.5 – Continuação de layer.py
def outputs(self, inputs: List[float]) -> List[float]:
if self.previous_layer is None:
self.output_cache = inputs
else:
self.output_cache = [n.output(inputs) for n in self.neurons]
return self.output_cache

Há dois tipos distintos de delta para calcular na retropropagação: deltas


para neurônios da camada de saída e deltas para neurônios das camadas
ocultas. As fórmulas estão descritas nas guras 7.4 e 7.5, e os dois
métodos a seguir são traduções diretas dessas fórmulas. Mais tarde, esses
métodos serão chamados pela rede durante a retropropagação.
Listagem 7.6 – Continuação de layer.py
# deve ser chamado somente na camada de saída
def calculate_deltas_for_output_layer(self, expected: List[float]) -> None:
for n in range(len(self.neurons)):
self.neurons[n].delta =
self.neurons[n].derivative_activation_function(
self.neurons[n].output_cache) * (expected[n] -
self.output_cache[n])

# não deve ser chamado na camada de saída


def calculate_deltas_for_hidden_layer(self, next_layer: Layer) -> None:
for index, neuron in enumerate(self.neurons):
next_weights: List[float] = [n.weights[index] for n in
next_layer.neurons]
next_deltas: List[float] = [n.delta for n in next_layer.neurons]
sum_weights_and_deltas: float = dot_product(next_weights,
next_deltas)
neuron.delta =
neuron.derivative_activation_function(neuron.output_cache)
* sum_weights_and_deltas

7.4.3 Implementando a rede


A rede em si tem apenas uma informação de estado: as camadas que ela
administra. A classe Network é responsável por inicializar as camadas que a
compõem.
O método __init__() aceita uma lista de ints que descreve a estrutura da
rede. Por exemplo, a lista [2, 4, 3] descreve uma rede com 2 neurônios
em sua camada de entrada, 4 neurônios em sua camada oculta e 3
neurônios em sua camada de saída. Nessa rede simples, partiremos do
pressuposto de que todas as camadas da rede farão uso da mesma função
de ativação para seus neurônios e terão a mesma taxa de aprendizagem.
Listagem 7.7 – network.py
from __future__ import annotations
from typing import List, Callable, TypeVar, Tuple
from functools import reduce
from layer import Layer
from util import sigmoid, derivative_sigmoid

T = TypeVar('T') # tipo da saída para interpretação da rede neural

class Network:
def __init__(self, layer_structure: List[int], learning_rate: float,
activation_function: Callable[[float], float] = sigmoid,
derivative_activation_function: Callable[[float], float] =
derivative_sigmoid) -> None:
if len(layer_structure) < 3:
raise ValueError("Error: Should be at least 3 layers (
1 input, 1 hidden, 1 output)")
self.layers: List[Layer] = []
# camada de entrada
input_layer: Layer = Layer(None, layer_structure[0], learning_rate,
activation_function, derivative_activation_function)
self.layers.append(input_layer)
# camadas ocultas e camada de saída
for previous, num_neurons in enumerate(layer_structure[1::]):
next_layer = Layer(self.layers[previous], num_neurons,
learning_rate, activation_function,
derivative_activation_function)
self.layers.append(next_layer)

As saídas da rede neural são o resultado dos sinais passando por todas as
suas camadas. Observe como reduce() é usado de modo compacto em
outputs() para passar sinais de uma camada para a próxima repetidamente,
por toda a rede.
Listagem 7.8 – Continuação de network.py
# Fornece dados de entrada para a primeira camada; em seguida, a saída da
primeira
# é fornecida como entrada para a segunda, a saída da segunda para a
terceira etc.
def outputs(self, input: List[float]) -> List[float]:
return reduce(lambda inputs, layer: layer.outputs(inputs), self.layers,
input)

O método backpropagate() é responsável por calcular deltas para todos os


neurônios da rede. Ele utiliza os métodos
calculate_deltas_for_output_layer() e calculate_deltas_for_hidden_layer() de
Layer , em sequência. (Lembre-se de que, na retropropagação, os deltas
são calculados na ordem inversa.) Os valores esperados de saída para um
dado conjunto de entrada são passados para
calculate_deltas_for_output_layer() . Esse método utiliza os valores
esperados para calcular o erro usado no cálculo dos deltas.
Listagem 7.9 – Continuação de network.py
# Calcula as mudanças em cada neurônio com base nos erros da saída
# em comparação com a saída esperada
def backpropagate(self, expected: List[float]) -> None:
# calcula delta para os neurônios da camada de saída
last_layer: int = len(self.layers) - 1
self.layers[last_layer].calculate_deltas_for_output_layer(expected)
# calcula delta para as camadas ocultas na ordem inversa
for l in range(last_layer - 1, 0, -1):
self.layers[l].calculate_deltas_for_hidden_layer(self.layers[l + 1])

backpropagate() é responsável pelo cálculo de todos os deltas, mas, na


verdade, ele não modi ca nenhum dos pesos da rede. update_weights()
deve ser chamado após backpropagate() porque a modi cação dos pesos
dependerá dos deltas. Esse método é diretamente derivado da fórmula
que está na Figura 7.6.
Listagem 7.10 – Continuação de network.py
# backpropagate() não modifica realmente nenhum peso;
# esta função utiliza os deltas calculados em backpropagate() para
# fazer as modificações nos pesos
def update_weights(self) -> None:
for layer in self.layers[1:]: # ignora a camada de entrada
for neuron in layer.neurons:
for w in range(len(neuron.weights)):
neuron.weights[w] = neuron.weights[w] +
(neuron.learning_rate
* (layer.previous_layer.output_cache[w]) * neuron.delta)

Os pesos dos neurônios são modi cados no nal de cada rodada do


treinamento. Os conjuntos para treinamento (as entradas, junto com as
saídas esperadas) devem ser fornecidos à rede. O método train() aceita
uma lista de listas de entrada e uma lista de listas de saídas esperadas.
Cada entrada é submetida à rede e, então, os pesos são atualizados
chamando backpropagate() com a saída esperada (e chamando
update_weights() depois). Experimente acrescentar código para exibir a
taxa de erros enquanto a rede processa um conjunto de treinamento a m
de ver como ela reduz gradualmente sua taxa de erros à medida que desce
em gradiente decrescente.
Listagem 7.11 – Continuação de network.py
# train() usa os resultados de outputs(), obtidos a partir de várias
entradas e
# comparados com expecteds, para fornecer a backpropagate() e a
update_weights()
def train(self, inputs: List[List[float]], expecteds: List[List[float]]) ->
None:
for location, xs in enumerate(inputs):
ys: List[float] = expecteds[location]
outs: List[float] = self.outputs(xs)
self.backpropagate(ys)
self.update_weights()

Por m, depois do treinamento de uma rede, é necessário testá-la.


validate() aceita entradas e saídas esperadas (não é diferente de train() ),
mas usa esses dados para calcular uma porcentagem exata, em vez de
efetuar um treinamento. Supõe-se que a rede já esteja treinada. validate()
também aceita uma função, interpret_output(), que é usada para
interpretar a saída da rede neural e compará-la com a saída esperada.
Talvez a saída esperada seja uma string como “anfíbio” em vez de um
conjunto de números de ponto utuante.) interpret_output() deve usar os
números de ponto utuante obtidos como saída da rede e convertê-los
em algo que seja comparável com as saídas esperadas. Será uma função
personalizada, especí ca para um conjunto de dados. validate() devolve o
número de classi cações corretas, o número total de amostras testadas e
a porcentagem de classi cações corretas.
Listagem 7.12 – Continuação de network.py
# para resultados genéricos que exijam classificação,
# esta função devolverá o número de tentativas corretas
# e a porcentagem delas em relação ao total
def validate(self, inputs: List[List[float]], expecteds: List[T],
interpret_output: Callable[[List[float]], T]) -> Tuple[int, int,
float]:
correct: int = 0
for input, expected in zip(inputs, expecteds):
result: T = interpret_output(self.outputs(input))
if result == expected:
correct += 1
percentage: float = correct / len(inputs)
return correct, len(inputs), percentage

A rede neural está pronta! Pronta para ser testada com alguns problemas
reais. Embora a arquitetura que construímos seja su cientemente
genérica e seja possível usá-la em diversos problemas, nosso foco estará
em um tipo conhecido de problemas: a classi cação.

7.5 Problemas de classi cação


No Capítulo 6, classi camos um conjunto de dados com o clustering k-
means, sem usar nenhuma noção preconcebida sobre o grupo ao qual
cada dado pertenceria. No clustering, sabemos que queremos descobrir
categorias de dados, mas não sabemos com antecedência quais são essas
categorias. Em um problema de classi cação, também tentamos
classi car um conjunto de dados, mas há categorias prede nidas. Por
exemplo, se estivéssemos tentando classi car um conjunto de imagens de
animais, poderíamos decidir previamente quais são as categorias, por
exemplo, mamíferos, répteis, anfíbios, peixes e aves.
Há várias técnicas de aprendizado de máquina que podem ser usadas
para resolver problemas de classi cação. Talvez você já tenha ouvido falar
de máquinas de suporte de vetores (support vector machines), árvores de
decisão ou classi cadores Naive Bayes. (Há outros também.)
Recentemente, as redes neurais têm sido amplamente utilizadas na área de
classi cação. Elas exigem mais processamento em comparação com
outros algoritmos de classi cação, mas sua capacidade de classi car tipos
aparentemente arbitrários de dados faz delas uma técnica e caz. Os
classi cadores baseados em redes neurais são responsáveis por muitas
das classi cações de imagens interessantes, usadas por softwares
modernos de fotogra a.
Por que há um interesse renovado no uso de redes neurais para
problemas de classi cação? O hardware tem se tornado su cientemente
rápido, a ponto de fazer com que o processamento extra envolvido, em
comparação com outros algoritmos, faça com que as vantagens
compensem.

7.5.1 Normalizando dados


Os conjuntos de dados com os quais queremos trabalhar, em geral,
exigem alguma “limpeza” antes de servirem como entrada para os nossos
algoritmos. A limpeza pode envolver remoção de caracteres irrelevantes,
eliminação de duplicatas, correção de erros e outras tarefas menores. A
tarefa relacionada à limpeza que teremos de executar nos dois conjuntos
de dados com os quais trabalharemos é a normalização. No Capítulo 6,
zemos isso com o método zscore_normalize() da classe KMeans. A
normalização diz respeito a converter atributos registrados em diferentes
escalas em uma escala comum.
Todo neurônio em nossa rede gera valores entre 0 e 1 como resultado
da função de ativação sigmoide. Parece lógico que uma escala entre 0 e 1
faria sentido para os atributos de nosso conjunto de dados de entrada
também. Converter uma escala em determinado intervalo para um
intervalo entre 0 e 1 não chega a ser um desa o. Para qualquer valor V em
um intervalo especí co de atributos, com um valor máximo igual a max e
um valor mínimo igual a min, basta usar a fórmula newV = (oldV - min) / (max
- min). Essa operação é conhecida como feature scaling (normalização de
características). Eis uma implementação Python que deve ser
acrescentada em util.py.
Listagem 7.13 – Continuação de util.py
# supõe que todas as linhas têm o mesmo tamanho
# e faz o feature scaling de cada coluna para que esteja no intervalo de 0 a
1
def normalize_by_feature_scaling(dataset: List[List[float]]) -> None:
for col_num in range(len(dataset[0])):
column: List[float] = [row[col_num] for row in dataset]
maximum = max(column)
minimum = min(column)
for row_num in range(len(dataset)):
dataset[row_num][col_num] = (dataset[row_num][col_num]
- minimum) / (maximum - minimum)

Observe o parâmetro dataset .


É uma referência a uma lista de listas que
será modi cada in-place. Em outras palavras,
normalize_by_feature_scaling() não recebe uma cópia do conjunto de
dados, mas uma referência ao conjunto de dados original. Essa é uma
situação na qual queremos fazer alterações em um valor, em vez de
receber de volta uma cópia transformada.
Observe também que nosso programa pressupõe que os conjuntos de
dados são listas bidimensionais de floats.

7.5.2 Conjunto clássico de dados de amostras de íris


Assim como há problemas clássicos em ciência da computação, há
conjuntos de dados clássicos em aprendizado de máquina. Esses
conjuntos de dados são usados para validar novas técnicas e compará-las
com as técnicas existentes. Também servem como bons pontos de partida
para pessoas que estão conhecendo o que é aprendizado de máquina.
Talvez o conjunto mais famoso seja aquele que contém dados da planta
íris. Originalmente coletado nos anos 1930, o conjunto de dados é
composto de 150 amostras de uma planta chamada íris (são ores
bonitas), separadas em três espécies diferentes (50 de cada). Cada planta
é avaliada segundo quatro atributos distintos: comprimento da sépala,
largura da sépala, comprimento da pétala e largura da pétala.
Vale a pena mencionar que uma rede neural não se preocupa com o que
os vários atributos representam. Seu modelo de treinamento não faz
nenhuma distinção entre o comprimento da sépala e o comprimento da
pétala no que concerne à importância. Se uma distinção como essa fosse
necessária, caberia ao usuário da rede neural fazer os ajustes
apropriados.
O repositório de código-fonte que acompanha este livro contém um
arquivo separado por vírgulas (CSV) com o conjunto de dados de
amostras de íris.2 O conjunto de dados de íris é do UCI Machine
Learning Repository da Universidade da Califórnia: M. Lichman, UCI
Machine Learning Repository (Irvine, CA: University of California,
School of Information and Computer Science, 2013),
http://archive.ics.uci.edu/ml. Um arquivo CSV é simplesmente um arquivo-
texto com valores separados por vírgulas. É um formato de intercâmbio
comum para dados de tabela, incluindo planilhas.
Eis algumas linhas de iris.csv:
5.1,3.5,1.4,0.2,Iris-setosa
4.9,3.0,1.4,0.2,Iris-setosa
4.7,3.2,1.3,0.2,Iris-setosa
4.6,3.1,1.5,0.2,Iris-setosa
5.0,3.6,1.4,0.2,Iris-setosa

Cada linha representa um ponto de dado. Os quatro números


representam os quatro atributos (comprimento da sépala, largura da
sépala, comprimento da pétala, largura da pétala), os quais, novamente,
são arbitrários para nós no que diz respeito ao que de fato representam.
O nome no nal de cada linha representa a espécie de íris em particular.
Todas as cinco linhas são da mesma espécie porque essa amostra foi
extraída do início do arquivo, e as três espécies estão agrupadas, com 50
linhas para cada uma.
Para ler o arquivo CSV de disco, usaremos algumas funções da
biblioteca-padrão de Python. O módulo csv nos ajudará a ler os dados de
forma estruturada. A função embutida open() cria um objeto arquivo que
é passado para csv.reader(). Afora essas poucas linhas, o resto da listagem
de código a seguir apenas reorganiza os dados do arquivo CSV a m de
prepará-los para serem consumidos pela nossa rede, para treinamento e
validação.
Listagem 7.14 – iris_test.py
import csv
from typing import List
from util import normalize_by_feature_scaling
from network import Network
from random import shuffle

if __name__ == "__main__":
iris_parameters: List[List[float]] = []
iris_classifications: List[List[float]] = []
iris_species: List[str] = []
with open('iris.csv', mode='r') as iris_file:
irises: List = list(csv.reader(iris_file))
shuffle(irises) # deixa nossas linhas de dados em ordem aleatória
for iris in irises:
parameters: List[float] = [float(n) for n in iris[0:4]]
iris_parameters.append(parameters)
species: str = iris[4]
if species == "Iris-setosa":
iris_classifications.append([1.0, 0.0, 0.0])
elif species == "Iris-versicolor":
iris_classifications.append([0.0, 1.0, 0.0])
else:
iris_classifications.append([0.0, 0.0, 1.0])
iris_species.append(species)
normalize_by_feature_scaling(iris_parameters)

iris_parameters representa a coleção de quatro atributos por amostra, que


estamos usando para classi car cada amostra de íris. iris_classifications
é a classi cação propriamente dita de cada amostra. Nossa rede neural
terá três neurônios de saída, cada um representando uma espécie
possível. Por exemplo, um conjunto nal de saída igual a [0.9, 0.3, 0.1]
representará uma classi cação como iris-setosa, pois o primeiro
neurônio representa essa espécie e contém o maior número.
Para treinamento, já sabemos as respostas corretas, portanto, cada
amostra de íris tem uma resposta prede nida. Para uma or que deva ser
iris-setosa, a entrada em iris_classifications será [1.0, 0.0, 0.0]. Esses
valores serão usados para calcular o erro após cada passo do treinamento.
iris_species corresponde diretamente à classi cação de cada or em
forma textual. Uma iris-setosa estará marcada como “Iris-setosa” no
conjunto de dados.
AVISO A ausência de código para veri cação de erros deixa esse código bastante perigoso.
Ele não é apropriado para um ambiente de produção do modo como está, porém é
apropriado para testes.
Vamos de nir a rede neural.
Listagem 7.15 – Continuação de iris_test.py
iris_network: Network = Network([4, 6, 3], 0.3)

O argumento layer_structure especi ca uma rede com três camadas (uma


camada de entrada, uma camada oculta e uma camada de saída) com [4,
6, 3] . A camada de entrada tem quatro neurônios, a camada oculta tem
seis neurônios e a camada de saída tem três neurônios. Os quatro
neurônios da camada de entrada são diretamente mapeados aos quatro
parâmetros usados para classi car cada espécime. Os três neurônios da
camada de saída mapeiam-se diretamente às três espécies diferentes de
acordo com as quais estamos tentando classi car cada entrada. Os seis
neurônios da camada oculta resultam mais de tentativa e erro do que de
alguma fórmula. Isso vale também para learning_rate. Podemos fazer
experimentos com esses dois valores (o número de neurônios da camada
oculta e a taxa de aprendizagem) caso a precisão da rede esteja abaixo de
um nível ideal.
Listagem 7.16 – Continuação de iris_test.py
def iris_interpret_output(output: List[float]) -> str:
if max(output) == output[0]:
return "Iris-setosa"
elif max(output) == output[1]:
return "Iris-versicolor"
else:
return "Iris-virginica"

iris_interpret_output() é uma função utilitária que será passada para o


método validate() da rede a m de ajudar a identi car as classi cações
corretas.
A rede nalmente está pronta para o treinamento.
Listagem 7.17 – Continuação de iris_test.py
# faz o treinamento com os 140 primeiros dados de amostras de íris do
conjunto, 50 vezes
iris_trainers: List[List[float]] = iris_parameters[0:140]
iris_trainers_corrects: List[List[float]] = iris_classifications[0:140]
for _ in range(50):
iris_network.train(iris_trainers, iris_trainers_corrects)
Fizemos o treinamento com os primeiros 140 dados de amostras de íris,
dos 150 do conjunto. Lembre-se de que as linhas lidas do arquivo CSV
foram embaralhadas. Isso garante que, sempre que o programa for
executado, faremos o treinamento em um subconjunto diferente do
conjunto de dados. Observe que zemos o treinamento em 140 dados de
amostras de íris, 50 vezes. Modi car esse valor terá um efeito signi cativo
no tempo que demora para fazer o treinamento da rede neural. Em geral,
quanto mais treinamento, maior será a precisão no funcionamento da
rede. O último teste será conferir se a classi cação dos 10 últimos dados
de amostras de íris do conjunto está correta.
Listagem 7.18 – Continuação de iris_test.py
# teste nos últimos 10 dados de amostras de íris do conjunto
iris_testers: List[List[float]] = iris_parameters[140:150]
iris_testers_corrects: List[str] = iris_species[140:150]
iris_results = iris_network.validate(iris_testers, iris_testers_corrects,
iris_interpret_output)
print(f"{iris_results[0]} correct of {iris_results[1]} = {iris_results[2] *
100}%")

Todo esse trabalho nos trouxe até a seguinte pergunta nal: dentre 10
amostras de íris escolhidas aleatoriamente do conjunto de dados, quantas
a nossa rede neural consegue classi car corretamente? Como há
aleatoriedade nos pesos iniciais de cada neurônio, diferentes execuções
poderão fornecer resultados distintos. Você pode tentar ajustar a taxa de
aprendizagem, o número de neurônios ocultos e a quantidade de
iterações no treinamento para deixar sua rede mais precisa.
Ao nal, você deverá ver um resultado semelhante a este:
9 correct of 10 = 90.0%

7.5.3 Classi cando vinhos


Testaremos nossa rede neural com outro conjunto de dados: um
conjunto baseado na análise química de vinhos de cultivares da Itália.3 Há
178 amostras no conjunto de dados. O modo de trabalhar com esse
conjunto será muito parecido com a forma como trabalhamos com o
conjunto de dados da planta de íris, mas o layout do arquivo CSV é um
pouco diferente. Eis uma amostra desse arquivo:
1,14.23,1.71,2.43,15.6,127,2.8,3.06,.28,2.29,5.64,1.04,3.92,1065
1,13.2,1.78,2.14,11.2,100,2.65,2.76,.26,1.28,4.38,1.05,3.4,1050
1,13.16,2.36,2.67,18.6,101,2.8,3.24,.3,2.81,5.68,1.03,3.17,1185
1,14.37,1.95,2.5,16.8,113,3.85,3.49,.24,2.18,7.8,.86,3.45,1480
1,13.24,2.59,2.87,21,118,2.8,2.69,.39,1.82,4.32,1.04,2.93,735

O primeiro valor em cada linha será sempre um inteiro entre 1 e 3,


representando um dos três cultivares de cujo tipo a amostra pode ser.
Observe, porém, quantos parâmetros adicionais existem para a
classi cação. No conjunto de dados de amostras de íris, havia apenas
quatro. Nesse conjunto de dados de vinho, há treze.
Nosso modelo de rede neural escalará apropriadamente. Bastará
aumentar o número de neurônios de entrada. wine_test.py é análogo a
iris_test.py, mas há algumas modi cações pequenas que deverão ser
levadas em consideração por causa dos diferentes layouts dos respectivos
arquivos.
Listagem 7.19 – wine_test.py
import csv
from typing import List
from util import normalize_by_feature_scaling
from network import Network
from random import shuffle

if __name__ == "__main__":
wine_parameters: List[List[float]] = []
wine_classifications: List[List[float]] = []
wine_species: List[int] = []
with open('wine.csv', mode='r') as wine_file:
wines: List = list(csv.reader(wine_file,
quoting=csv.QUOTE_NONNUMERIC))
shuffle(wines) # deixa nossas linhas de dados em ordem aleatória
for wine in wines:
parameters: List[float] = [float(n) for n in wine[1:14]]
wine_parameters.append(parameters)
species: int = int(wine[0])
if species == 1:
wine_classifications.append([1.0, 0.0, 0.0])
elif species == 2:
wine_classifications.append([0.0, 1.0, 0.0])
else:
wine_classifications.append([0.0, 0.0, 1.0])
wine_species.append(species)
normalize_by_feature_scaling(wine_parameters)

A con guração de camadas para a rede de classi cação de vinhos precisa


de 13 neurônios de entrada, conforme já havíamos mencionado (um para
cada parâmetro). Também são necessários três neurônios de saída. (Há
três cultivares de uvas para vinho, assim como havia três espécies de íris.)
O aspecto interessante é que a rede funciona bem com menos neurônios
na camada oculta do que na camada de entrada. Uma possível explicação
intuitiva é que alguns dos parâmetros de entrada não são de fato úteis
para a classi cação, e é conveniente eliminá-los durante o processamento.
Na verdade, essa não é exatamente a explicação para o fato de menos
neurônios na camada oculta funcionar, mas é uma ideia intuitiva
interessante.
Listagem 7.20 – Continuação de wine_test.py
wine_network: Network = Network([13, 7, 3], 0.9)

Novamente, pode ser interessante fazer experimentos com um número


diferente de neurônios na camada oculta ou com uma taxa de
aprendizagem distinta.
Listagem 7.21 – Continuação de wine_test.py
def wine_interpret_output(output: List[float]) -> int:
if max(output) == output[0]:
return 1
elif max(output) == output[1]:
return 2
else:
return 3

é análogo a iris_interpret_output(). Como não


wine_interpret_output()
temos nomes para os cultivares de uvas para vinho, estamos trabalhando
apenas com a atribuição de inteiros presente no conjunto original de
dados.
Listagem 7.22 – Continuação de wine_test.py
# faz o treinamento nos 150 primeiros vinhos, 10 vezes
wine_trainers: List[List[float]] = wine_parameters[0:150]
wine_trainers_corrects: List[List[float]] = wine_classifications[0:150]
for _ in range(10):
wine_network.train(wine_trainers, wine_trainers_corrects)

Faremos o treinamento nas 150 primeiras amostras do conjunto de dados,


deixando as últimas 28 para validação. Faremos o treinamento 10 vezes
nas amostras – signi cativamente menos que as 50 vezes no conjunto de
dados de amostras de íris. Por qualquer que seja o motivo (talvez pelas
qualidades inatas do conjunto de dados ou por causa do ajuste de
parâmetros, como a taxa de aprendizagem e o número de neurônios
ocultos), esse conjunto de dados exige menos treinamento para alcançar
uma precisão signi cativa, em comparação com o conjunto de dados de
amostras de íris.
Listagem 7.23 – Continuação de wine_test.py
# teste nos últimos 28 vinhos do conjunto de dados
wine_testers: List[List[float]] = wine_parameters[150:178]
wine_testers_corrects: List[int] = wine_species[150:178]
wine_results = wine_network.validate(wine_testers, wine_testers_corrects,
wine_interpret_output)
print(f"{wine_results[0]} correct of {wine_results[1]} = {wine_results[2] *
100}%")

Com um pouco de sorte, sua rede neural deverá ser capaz de classi car as
28 amostras com bastante precisão.
27 correct of 28 = 96.42857142857143%

7.6 Agilizando as redes neurais


As redes neurais exigem muitas operações matemáticas com
vetores/matrizes. Essencialmente, isso signi ca tomar uma lista de
números e efetuar uma operação em todos eles ao mesmo tempo.
Bibliotecas com bom desempenho para operações matemáticas
otimizadas em vetores/matrizes estão se tornando cada vez mais
importantes à medida que o aprendizado de máquina continua a permear
a nossa sociedade. Muitas dessas bibliotecas tiram proveito das GPUs
porque elas estão otimizadas para desempenhar essa função.
(Vetores/matrizes estão no coração das imagens de computador.) Uma
especi cação de biblioteca mais antiga da qual talvez você já tenha ouvido
falar é o BLAS (Basic Linear Algebra Subprograms ou Subprogramas
Básicos de Álgebra Linear). Uma implementação do BLAS está na base da
conhecida biblioteca numérica NumPy de Python.
Além da GPU, as CPUs têm extensões que podem agilizar o
processamento de vetores/matrizes. A NumPy inclui funções que fazem
uso de instruções SIMD (single instruction, multiple data, ou uma
instrução. vários dados). As instruções SIMD são instruções especiais do
microprocessador, as quais permitem que várias porções de dados sejam
processadas ao mesmo tempo. Às vezes, são chamadas de instruções de
vetor.
Diferentes microprocessadores incluem diferentes instruções SIMD. Por
exemplo, a extensão SIMD para o G4 (um processador de arquitetura
PowerPC presente nos Macs no início dos anos 2000) era conhecida
como AltiVec. Microprocessadores ARM, como aqueles que se
encontram nos iPhones, têm uma extensão conhecida como NEON. E
microprocessadores Intel modernos incluem extensões SIMD conhecidas
como MMX, SSE, SSE2 e SSE3. Felizmente, você não precisa saber quais
são as diferenças. Uma biblioteca como a NumPy selecionará
automaticamente as instruções corretas para um processamento e ciente
na arquitetura subjacente, no local em que seu programa estiver
executando.
Não é nenhuma surpresa, então, que bibliotecas de redes neurais do
mundo real (de modo diferente de nossa biblioteca simplória deste
capítulo) utilizem arrays NumPy como a estrutura de dados básica, em
vez de listas da biblioteca-padrão de Python. Contudo, elas vão mais
além. Bibliotecas Python populares para redes neurais como TensorFlow
e PyTorch não só fazem uso de instruções SIMD como também utilizam
intensamente o processamento da GPU. Como as GPUs foram
explicitamente projetadas para processamentos rápidos de vetores, isso
agiliza as redes neurais em uma ordem de magnitude, em comparação
com a execução apenas na CPU.
Vamos deixar claro o seguinte: você jamais deverá implementar
ingenuamente uma rede neural para um ambiente de produção usando
apenas a biblioteca-padrão de Python, como zemos neste capítulo. Você
deve usar uma biblioteca bem otimizada, que utilize SIMD e GPU, como
o TensorFlow. As únicas exceções seriam uma biblioteca de rede neural
projetada para ns didáticos, ou uma que tivesse de executar em um
dispositivo embarcado, sem instruções SIMD ou GPU.

7.7 Problemas e extensões das redes neurais


As redes neurais estão em efervescência atualmente, graças aos avanços
em aprendizagem profunda (deep learning), mas elas têm algumas
de ciências signi cativas. O maior problema é que uma solução com
rede neural para um problema é uma espécie de caixa-preta. Mesmo
quando funcionam bem, as redes neurais não fornecem muitos insights
ao usuário acerca de como elas resolveram o problema. Por exemplo, o
classi cador do conjunto de dados de amostras de íris com o qual
trabalhamos neste capítulo não mostra claramente quanto cada um dos
quatro parâmetros de entrada afeta a saída. O comprimento da sépala foi
mais importante que a largura para classi car cada amostra?
É possível que uma análise cuidadosa dos pesos nais da rede após o
treinamento pudesse oferecer alguns insights, mas uma análise desse tipo
não é trivial e não fornece o tipo de insight que, por exemplo, uma
regressão linear forneceria no que concerne ao signi cado de cada
variável na função sendo modelada. Em outras palavras, uma rede neural
pode resolver um problema, mas não explica como o problema é
resolvido.
Outro problema com as redes neurais é que, para serem precisas, em
geral, elas exigem conjuntos de dados bem grandes. Pense em um
classi cador de imagens para paisagens. Talvez ele tivesse de classi car
milhares de diferentes tipos de imagens ( orestas, vales, montanhas,
riachos, estepes e assim por diante). É possível que a rede exigisse
milhões de imagens para treinamento. Conjuntos de dados grandes como
esses não só são difíceis de encontrar como também, para algumas
aplicações, podem ser totalmente inexistentes. A tendência é que grandes
corporações e governos é que tenham instalações técnicas e de data-
warehousing (armazém de dados) para coletar e armazenar conjuntos de
dados gigantescos como esses.
Por m, as redes neurais são custosas do ponto de vista do
processamento. Como você provavelmente deve ter percebido, apenas o
treinamento com o conjunto de dados de amostras de íris pode deixar
seu interpretador Python de joelhos. Python puro não é um ambiente
com um bom desempenho quanto ao processamento (sem bibliotecas
com suporte em C, como a NumPy, pelo menos); entretanto, em
qualquer plataforma de processamento em que as redes neurais são
usadas, acima de tudo, é o enorme número de cálculos que devem ser
efetuados para o treinamento da rede que exige tanto tempo. Há truques
em abundância para fazer com que as redes neurais tenham um melhor
desempenho (como usar instruções SIMD ou GPUs), mas, em última
instância, fazer o treinamento de uma rede neural exige muitas operações
com números de ponto utuante.
Uma ressalva interessante é que, do ponto de vista do processamento, o
treinamento é muito mais custoso do que o próprio uso da rede. Algumas
aplicações não exigem um treinamento contínuo. Nesses casos, uma rede
treinada pode simplesmente ser utilizada em uma aplicação para
solucionar um problema. Por exemplo, a primeira versão do framework
Core ML da Apple nem sequer aceita treinamento. Ela apenas oferece
suporte para ajudar os desenvolvedores de aplicativos a executar modelos
de redes neurais previamente treinadas em seus aplicativos. Um
desenvolvedor de aplicativo que esteja criando um aplicativo para fotos
pode fazer download de um modelo para classi cação de imagens com
licença gratuita, colocá-lo no Core ML e começar a utilizar prontamente
um código de aprendizado de máquina com bom desempenho em um
aplicativo.
Neste capítulo, trabalhamos apenas com um único tipo de rede neural:
uma rede feedforward com retropropagação. Conforme mencionamos
antes, existem vários outros tipos de redes neurais. Redes neurais
convolucionais (convolutional neural networks) também são feedforward,
mas têm vários tipos diferentes de camadas ocultas, diferentes formas de
distribuir pesos e outras propriedades interessantes que as tornam
particularmente bem projetadas para classi cação de imagens. Nas redes
neurais recorrentes (recurrent neural networks), os sinais não trafegam
em uma só direção. Elas permitem feedback cíclicos e têm se mostrado
úteis para aplicações com entradas contínuas, como reconhecimento de
escrita cursiva e reconhecimento de fala.
Uma extensão simples para a nossa rede neural que a deixaria com um
desempenho melhor seria a inclusão de neurônios com bias
(tendenciosos). Um neurônio com bias é como um neurônio dummy em
uma camada, permitindo que a saída da próxima camada represente mais
funções fornecendo uma entrada constante (ainda modi cada por um
peso). Mesmo as redes neurais simples usadas em problemas do mundo
real em geral contêm neurônios com bias. Se neurônios com bias forem
acrescentados em nossa rede, é provável que você perceba que ela exigirá
menos treinamento para alcançar um nível de precisão similar.

7.8 Aplicações no mundo real


Embora tivessem sido inicialmente imaginadas em meados do século XX,
as redes neurais arti ciais não eram comuns até a última década. Sua
aplicação ampla foi di cultada pela falta de um hardware que tivesse um
desempenho su cientemente bom. Atualmente, as redes neurais arti ciais
passaram a ser a área de crescimento mais explosivo em aprendizado de
máquina porque elas funcionam!
Redes neurais arti ciais têm possibilitado a existência de algumas das
aplicações de computação mais empolgantes, voltadas a usuários, em
décadas. Essas aplicações incluem reconhecimento prático de fala
(prático no que diz respeito a ter precisão su ciente), reconhecimento de
imagens e reconhecimento de escrita cursiva. O reconhecimento de fala
está presente em sistemas de auxílio à digitação, como o Dragon Naturally
Speaking, e em assistentes digitais, como Siri, Alexa e Cortana. Um
exemplo especí co de reconhecimento de imagens está na atribuição de
identi cação automática do Facebook às pessoas em fotos usando
reconhecimento facial. Em versões recentes do iOS, você pode procurar
tarefas em suas notas, mesmo que elas tenham sido escritas à mão,
empregando o reconhecimento de escrita cursiva.
Uma tecnologia mais antiga de reconhecimento que pode funcionar
com base em redes neurais é o OCR (Optical Character Recognition, ou
Reconhecimento Óptico de Caracteres), usado sempre que você faz o
scanning de um documento e ele é devolvido na forma de um texto
selecionável, e não como uma imagem. O OCR permite que postos de
pedágio leiam placas dos automóveis e que envelopes sejam rapidamente
organizados pelo serviço postal.
Neste capítulo, vimos as redes neurais serem usadas com sucesso em
problemas de classi cação. Aplicações semelhantes nas quais as redes
neurais funcionam bem são os sistemas de recomendação. Pense no
Net ix sugerindo um lme a que talvez você gostasse de assistir, ou na
Amazon sugerindo um livro que você talvez quisesse ler. Há também
outras técnicas de aprendizado de máquina que funcionam bem em
sistemas de recomendações (Amazon e Net ix não usam necessariamente
as redes neurais com essas nalidades; os detalhes de seus sistemas
provavelmente são proprietários), portanto, as redes neurais só deverão
ser selecionadas depois que todas as demais opções tiverem sido
exploradas.
As redes neurais podem ser usadas em qualquer situação em que uma
função desconhecida precise de uma aproximação. Isso as torna úteis
para fazer previsões. As redes neurais podem ser empregadas para prever
o resultado de um evento esportivo, uma eleição ou o mercado de ações
(e elas são). É claro que sua precisão é resultado de quão bem treinadas
elas são, e isso está relacionado com o tamanho do conjunto de dados
disponível, que seja relevante para o evento cuja saída é desconhecida,
com quão bem ajustados estão os parâmetros da rede neural e quantas
iterações de treinamento são executadas. No que concerne a previsões,
como a maioria das aplicações de redes neurais, uma das partes mais
difíceis é decidir como será a estrutura da própria rede, a qual, em última
análise, é muitas vezes determinada por tentativa e erro.
7.9 Exercícios
1. Use o framework de rede neural desenvolvido neste capítulo para
classi car itens de outro conjunto de dados.
2. Crie uma função genérica parse_CSV(), com parâmetros
su cientemente exíveis, a ponto de ser possível fazer uma
substituição nos dois exemplos de parsing de CSV deste capítulo.
3. Experimente executar os exemplos com uma função de ativação
diferente. (Lembre-se de calcular também a sua derivada.) De que
modo a mudança na função de ativação afeta a precisão da rede? Ela
exige mais ou menos treinamento?
4. Com base nos problemas deste capítulo, recrie suas soluções usando
um framework conhecido para redes neurais, como o TensorFlow ou
o PyTorch.
5. Reescreva as classes Network, Layer e Neuron usando a NumPy para
agilizar a execução da rede neural desenvolvida neste capítulo.

1 Stuart Russell e Peter Norvig, Arti cial Intelligence: A Modern Approach, 3ª edição (Pearson,
2010) (N.T.: Edição publicada no Brasil: Inteligência Arti cial [Campus, 2013]).
2 O repositório está disponível no GitHub em
https://github.com/davecom/ClassicComputerScienceProblemsInPython.
3 M. Lichman, UCI Machine Learning Repository (Irvine, CA: University of California, School of
Information and Computer Science, 2013), http://archive.ics.uci.edu/ml.
CAPÍTULO 8

Busca competitiva

Um jogo de informação perfeita com soma zero para dois jogadores é um


jogo no qual os dois adversários têm todas as informações sobre o estado
do jogo à disposição, e qualquer ganho de vantagem para um implica
uma perda de vantagem para o outro. Jogos como esses incluem jogo da
velha, Connect Four, jogo de damas e xadrez. Neste capítulo, veremos
como criar um adversário arti cial que jogue esses tipos de jogos com
bastante habilidade. De fato, as técnicas discutidas neste capítulo, junto
com a moderna capacidade de processamento, permitem criar adversários
arti ciais que joguem perfeitamente esses tipos de jogos simples, além de
jogos complexos que estão além das habilidades de qualquer adversário
humano.

8.1 Componentes básicos de jogos de tabuleiro


Assim como para a maioria dos problemas mais complexos neste livro,
tentaremos deixar nossa solução o mais genérico possível. No caso da
busca competitiva (adversarial search), isso signi ca fazer com que
nossos algoritmos de busca não sejam especí cos para um jogo. Vamos
começar de nindo algumas classes-base simples que especi cam todos
os estados de que nossos algoritmos de busca precisarão. Mais tarde,
podemos criar subclasses dessas classes-base para os jogos especí cos
que implementaremos (jogo da velha e Connect Four), e passar as
subclasses para os algoritmos de busca a m de fazê-los “jogar”. Eis as
classes-base:
Listagem 8.1 – board.py
from __future__ import annotations
from typing import NewType, List
from abc import ABC, abstractmethod

Move = NewType('Move', int)

class Piece:
@property
def opposite(self) -> Piece:
raise NotImplementedError("Should be implemented by subclasses.")

class Board(ABC):
@property
@abstractmethod
def turn(self) -> Piece:
...

@abstractmethod
def move(self, location: Move) -> Board:
...

@property
@abstractmethod
def legal_moves(self) -> List[Move]:
...

@property
@abstractmethod
def is_win(self) -> bool:
...

@property
def is_draw(self) -> bool:
return (not self.is_win) and (len(self.legal_moves) == 0)

@abstractmethod
def evaluate(self, player: Piece) -> float:
...
O tipo Move representará um movimento em um jogo. Em sua essência, é
apenas um inteiro. Em jogos como jogo da velha e Connect Four, um
inteiro pode representar um movimento, indicando um quadrado ou uma
coluna no qual uma peça deve ser colocada. Piece é a classe-base para
uma peça no tabuleiro de um jogo. Ela também servirá como indicador
de turno. É por isso que a propriedade opposite é necessária. Precisamos
saber de quem é o turno que se segue a um dado turno.
DICA Como o jogo da velha e o Connect Four têm apenas um tipo de peça, a classe Piece
poderá servir também como um indicador de turno neste capítulo. Em um jogo mais
complexo, como xadrez, que tem diferentes tipos de peças, os turnos podem ser
representados por um inteiro ou um booleano. Como alternativa, o atributo de “cor” de um
tipo Piece mais complexo poderia ser usado para indicar o turno.
A classe-base abstrata Board é a verdadeira mantenedora do estado. Para
qualquer dado jogo que nossos algoritmos de busca processarão,
devemos ser capazes de responder a quatro perguntas:
• De quem é o turno?
• Quais movimentos permitidos podem ser feitos na posição atual?
• O jogo foi vencido?
• O jogo está empatado?
A última pergunta, sobre empates, na verdade é uma combinação das
duas perguntas anteriores em vários jogos. Se o jogo não foi vencido, mas
não há mais movimentos permitidos, é sinal de que houve um empate. É
por isso que a nossa classe-base abstrata Game já pode ter uma
implementação concreta da propriedade is_draw. Além do mais, há duas
ações que devemos ser capazes de executar:
• fazer um movimento para ir da posição atual para uma nova posição;
• avaliar a posição a m de ver qual jogador tem uma vantagem.
Cada um dos métodos e propriedades em Board é um proxy para uma das
perguntas ou ações anteriores. A classe Board poderia também ter sido
chamada de Position no linguajar dos jogos, mas usaremos essa
nomenclatura para algo mais especí co em cada uma de nossas
subclasses.

8.2 Jogo da velha


O jogo da velha é um jogo simples, mas pode ser usado para ilustrar o
mesmo algoritmo minimax que pode ser aplicado em jogos de estratégia
so sticados, como Connect Four, jogo de damas e xadrez.
Construiremos uma IA capaz de jogar perfeitamente o jogo da velha
usando o minimax.
NOTA Nesta seção, partimos do pressuposto de que você tenha familiaridade com o jogo da
velha e suas regras padrões. Caso não tenha, uma pesquisa rápida na internet deve permitir
que você o conheça.

8.2.1 Administrando os estados do jogo da velha


Vamos desenvolver algumas estruturas para manter o controle do estado
de um jogo da velha à medida que ele se desenrola.
Inicialmente, precisamos de um modo de representar cada quadrado do
tabuleiro do jogo da velha. Usaremos um enum chamado TTTPiece, que é
uma subclasse de Piece. Uma peça do jogo da velha pode ser um X, um O
ou vazio (representado por E no enum).
Listagem 8.2 – tictactoe.py
from __future__ import annotations
from typing import List
from enum import Enum
from board import Piece, Board, Move

class TTTPiece(Piece, Enum):


X = "X"
O = "O"
E = " " # para representação de vazio

@property
def opposite(self) -> TTTPiece:
if self == TTTPiece.X:
return TTTPiece.O
elif self == TTTPiece.O:
return TTTPiece.X
else:
return TTTPiece.E

def __str__(self) -> str:


return self.value
A classe TTTPiece tem uma propriedade opposite, que devolve outro
TTTPiece . Ela será conveniente para alternar o turno de um jogador para
outro após um movimento no jogo da velha. Para representar os
movimentos, usaremos apenas um inteiro, que corresponde a um
quadrado do tabuleiro no qual uma peça é colocada. Como você deve se
lembrar, Move foi de nido como um inteiro em board.py.
Um tabuleiro de jogo da velha tem nove posições organizadas em três
linhas e três colunas. Para simpli car, essas nove posições podem ser
representadas com uma lista unidimensional. A atribuição dos quadrados
às designações numéricas (isto é, ao índice do array) é arbitrária, mas
seguiremos o esquema representado na Figura 8.1.

Figura 8.1 – Os índices da lista unidimensional que correspondem a cada


quadrado no tabuleiro do jogo da velha.
A classe principal, responsável por manter o estado do jogo, é a classe
TTTBoard . TTTBoard controla dois estados diferentes: a posição
(representada pela lista unidimensional mencionada antes) e o jogador a
quem o turno pertence.
Listagem 8.3 – Continuação de tictactoe.py
class TTTBoard(Board):
def __init__(self, position: List[TTTPiece] = [TTTPiece.E] * 9,
turn: TTTPiece = TTTPiece.X) -> None:
self.position: List[TTTPiece] = position
self._turn: TTTPiece = turn

@property
def turn(self) -> Piece:
return self._turn
Um tabuleiro default é um tabuleiro no qual nenhum movimento foi feito
(um tabuleiro vazio). O construtor de Board tem parâmetros default que
inicializam uma posição como essa, com o movimento igual a X (em
geral, é o primeiro jogador no jogo da velha). Talvez você esteja se
perguntando por que temos a variável de instância _turn e a propriedade
turn . Foi um truque para garantir que todas as subclasses de Board
manterão o controle do jogador a quem o turno pertence. Não há
nenhuma maneira clara e óbvia em Python de especi car, em uma classe-
base abstrata, que as suas subclasses devem incluir uma variável de
instância especí ca, mas há um mecanismo desse tipo para as
propriedades.
TTTBoard é uma estrutura de dados informalmente imutável; TTTBoard s não
devem ser modi cados. Sempre que um movimento tiver de ser feito, um
novo TTTBoard com a posição alterada para acomodar o movimento será
gerado. Mais tarde, isso será conveniente em nosso algoritmo de busca.
Quando a busca tiver rami cações, não modi caremos inadvertidamente
a posição de um tabuleiro a partir do qual movimentos possíveis ainda
estão sendo analisados.
Listagem 8.4 – Continuação de tictactoe.py
def move(self, location: Move) -> Board:
temp_position: List[TTTPiece] = self.position.copy()
temp_position[location] = self._turn
return TTTBoard(temp_position, self._turn.opposite)

Um movimento permitido no jogo da velha é feito em qualquer quadrado


vazio. A propriedade a seguir, legal_moves, usa uma list comprehension a
m de gerar possíveis movimentos para uma dada posição.
Listagem 8.5 – Continuação de tictactoe.py
@property
def legal_moves(self) -> List[Move]:
return [Move(l) for l in range(len(self.position)) if self.position[l]
== TTTPiece.E]
Os índices nos quais a list comprehension atua são índices int da lista de
posições. De modo conveniente (e proposital), um Move também é
de nido com um tipo int, permitindo que essa de nição de legal_moves
seja sucinta.
Há várias maneiras de analisar as linhas, as colunas e as diagonais de
um tabuleiro de jogo da velha a m de veri car se houve uma vitória. A
implementação a seguir da propriedade is_win faz isso com uma
combinação aparentemente interminável de and, or e ==, com código xo.
Não é dos códigos mais elegantes, mas ele faz seu trabalho de modo
direto.
Listagem 8.6 – Continuação de tictactoe.py
@property
def is_win(self) -> bool:
# verificações para três linhas, três colunas e então para duas
diagonais
return self.position[0] == self.position[1] and self.position[0]
== self.position[2] and self.position[0] != TTTPiece.E or \
self.position[3] == self.position[4] and self.position[3] \
== self.position[5] and self.position[3] != TTTPiece.E or \
self.position[6] == self.position[7] and self.position[6] \
== self.position[8] and self.position[6] != TTTPiece.E or \
self.position[0] == self.position[3] and self.position[0] \
== self.position[6] and self.position[0] != TTTPiece.E or \
self.position[1] == self.position[4] and self.position[1] \
== self.position[7] and self.position[1] != TTTPiece.E or \
self.position[2] == self.position[5] and self.position[2] \
== self.position[8] and self.position[2] != TTTPiece.E or \
self.position[0] == self.position[4] and self.position[0] \
== self.position[8] and self.position[0] != TTTPiece.E or \
self.position[2] == self.position[4] and self.position[2] \
== self.position[6] and self.position[2] != TTTPiece.E

Se todos os quadrados de uma linha, de uma coluna ou de uma diagonal


não estiverem vazios e contiverem a mesma peça, o jogo terá sido vencido.
Um jogo estará empatado se não for vencido e não restarem mais
movimentos permitidos; essa propriedade já foi descrita na classe-base
abstrata Board. Por m, precisamos de uma maneira de avaliar uma
posição especí ca e fazer uma exibição elegante do tabuleiro.
Listagem 8.7 – Continuação de tictactoe.py
def evaluate(self, player: Piece) -> float:
if self.is_win and self.turn == player:
return -1
elif self.is_win and self.turn != player:
return 1
else:
return 0
def __repr__(self) -> str:
return f"""{self.position[0]}|{self.position[1]}|{self.position[2]}
-----
{self.position[3]}|{self.position[4]}|{self.position[5]}
-----
{self.position[6]}|{self.position[7]}|{self.position[8]}"""

Na maioria dos jogos, a avaliação de uma posição terá de ser uma


aproximação, pois não é possível pesquisar o jogo até o m para
descobrir, com certeza, quem vai ganhar ou quem perder, dependendo
dos movimentos efetuados. Contudo, o jogo da velha tem um espaço de
busca pequeno, que pode ser pesquisado a partir de qualquer posição até
o nal. Assim, o método evaluate() pode simplesmente devolver um
número se o jogador vencer, um número pior para um empate e um
número pior ainda para uma derrota.

8.2.2 Minimax
O minimax é um algoritmo clássico para encontrar o melhor movimento
em um jogo de informações perfeitas de soma zero para dois jogadores,
como o jogo da velha, o jogo de damas ou o xadrez. O algoritmo foi
expandido e modi cado para outros tipos de jogos também. O minimax
em geral é implementado com uma função recursiva, na qual cada
jogador é designado como o jogador maximizador ou o jogador
minimizador.
O jogador maximizador tem como objetivo encontrar o movimento que
resultará em ganhos máximos. No entanto, o jogador maximizador deve
levar em consideração os movimentos feitos pelo jogador minimizador.
Depois de cada tentativa de maximizar os ganhos do jogador
maximizador, o minimax é chamado recursivamente para encontrar a
resposta do adversário que minimize os ganhos do jogador maximizador.
Esse processo continua nos dois sentidos (maximizando, minimizando,
maximizando e assim por diante), até que se alcance um caso de base na
função recursiva. O caso de base é uma posição nal (uma vitória ou um
empate) ou uma profundidade máxima na busca.
O minimax devolverá uma avaliação da posição inicial para o jogador
maximizador. Para o método evaluate() da classe TTTBoard, se a melhor
jogada possível de ambos os lados vai resultar em uma vitória do jogador
maximizador, uma pontuação igual a 1 será devolvida. Se a melhor jogada
resultar em uma derrota, -1 será devolvido. Um 0 será devolvido se a
melhor jogada for um empate.
Esses números serão devolvidos quando um caso de base for alcançado.
Eles então se propagam por toda a cadeia de chamadas recursivas que
levaram até o caso de base. Para cada chamada recursiva para maximizar,
as melhores avaliações em um nível abaixo serão enviadas para cima. Para
cada chamada recursiva para minimizar, as piores avaliações em um nível
abaixo serão enviadas para cima. Desse modo, uma árvore de decisão será
construída. A Figura 8.2 mostra uma árvore desse tipo, que facilita que o
resultado se propague para cima na cadeia, em um jogo com dois
movimentos restantes.
Para jogos que tenham um espaço de busca muito profundo para
alcançar uma posição nal (como o jogo de damas e o xadrez), o
minimax será interrompido após certa profundidade (o número de
movimentos em profundidade a serem pesquisados, às vezes chamado de
ply [níveis]). Em seguida, a função de avaliação entra em cena, usando
dados heurísticos para dar uma pontuação ao estado do jogo. Quanto
melhor o jogo para o jogador inicial, maior será a pontuação atribuída.
Retomaremos esse conceito no Connect Four, que tem um espaço de
busca muito maior do que o jogo da velha.
Figura 8.2 – Uma árvore de decisão do minimax para um jogo da velha com
dois movimentos restantes. Para maximizar a probabilidade de vencer, o
primeiro jogador, 0, escolherá colocar 0 na parte inferior central. As setas
indicam as posições a partir das quais uma decisão é tomada.
Eis o minimax() completo:
Listagem 8.8 – minimax.py
from __future__ import annotations
from board import Piece, Board, Move

# Encontra o melhor resultado possível para o jogador inicial


def minimax(board: Board, maximizing: bool, original_player: Piece,
max_depth: int = 8) -> float:
# Caso de base – posição final ou profundidade máxima alcançada
if board.is_win or board.is_draw or max_depth == 0:
return board.evaluate(original_player)

# Caso recursivo - maximiza seus ganhos ou minimiza os ganhos do


adversário
if maximizing:
best_eval: float = float("-inf") # ponto de partida arbitrariamente
baixo
for move in board.legal_moves:
result: float = minimax(board.move(move), False,
original_player, max_depth - 1)
best_eval = max(result, best_eval)
return best_eval
else: # minimizando
worst_eval: float = float("inf")
for move in board.legal_moves:
result = minimax(board.move(move), True, original_player,
max_depth - 1)
worst_eval = min(result, worst_eval)
return worst_eval
Em cada chamada recursiva, devemos manter o controle da situação do
tabuleiro, se estamos maximizando ou minimizando e para quem estamos
tentando avaliar a posição (original_player). As primeiras linhas de
minimax() lidam com o caso de base: um nó terminal (uma vitória, uma
derrota ou um empate) ou a profundidade máxima foi alcançada. O resto
da função cuida dos casos recursivos.
Um caso recursivo é a maximização. Nessa situação, estamos
procurando um movimento que resulte na melhor avaliação possível. O
outro caso recursivo é a minimização, na qual procuramos o movimento
que resultará na pior avaliação possível. Qualquer que seja a situação, os
dois casos se alternarão até alcançarmos um estado nal ou a
profundidade máxima (caso de base).
Infelizmente, não podemos usar nossa implementação de minimax() do
modo como está para encontrar o melhor movimento para uma dada
posição. Ela devolve uma avaliação (um valor float). A função não nos
informa qual é o primeiro melhor movimento que resultou nessa
avaliação.
Criaremos uma função auxiliar, find_best_move(), que fará chamadas a
minimax() para cada movimento permitido em uma posição, a m de
encontrar o movimento cuja avaliação tenha o maior valor. Podemos
pensar em find_best_move() como a primeira chamada de maximização
para minimax(), mas na qual mantemos o controle dos movimentos
iniciais.
Listagem 8.9 – Continuação de minimax.py
# Encontra o melhor movimento possível na posição atual
# observando max_depth
def find_best_move(board: Board, max_depth: int = 8) -> Move:
best_eval: float = float("-inf")
best_move: Move = Move(-1)
for move in board.legal_moves:
result: float = minimax(board.move(move), False, board.turn,
max_depth)
if result > best_eval:
best_eval = result
best_move = move
return best_move

Temos tudo pronto agora para encontrar o melhor movimento possível


em qualquer situação no jogo da velha.

8.2.3 Testando o minimax com o jogo da velha


O jogo da velha é um jogo tão simples que para nós, seres humanos, é
fácil determinar o movimento correto de nitivo em uma dada posição.
Isso faz com que seja possível desenvolver testes de unidade (unit tests)
facilmente. No trecho de código a seguir, desa aremos o nosso algoritmo
minimax a encontrar o próximo movimento correto em três situações
diferentes do jogo da velha. O primeiro é fácil, e exige apenas que o
algoritmo pense no próximo movimento para uma vitória. O segundo
exige um bloqueio; a IA deve impedir que seu adversário pontue e
obtenha uma vitória. O último é um pouco mais desa ador e exige que a
IA pense em dois movimentos futuros.
Listagem 8.10 – tictactoe_tests.py
import unittest
from typing import List
from minimax import find_best_move
from tictactoe import TTTPiece, TTTBoard
from board import Move

class TTTMinimaxTestCase(unittest.TestCase):
def test_easy_position(self):
# vitória em um movimento
to_win_easy_position: List[TTTPiece] = [TTTPiece.X, TTTPiece.O,
TTTPiece.X,
TTTPiece.X, TTTPiece.E,
TTTPiece.O,
TTTPiece.E, TTTPiece.E,
TTTPiece.O]
test_board1: TTTBoard = TTTBoard(to_win_easy_position, TTTPiece.X)
answer1: Move = find_best_move(test_board1)
self.assertEqual(answer1, 6)

def test_block_position(self):
# deve bloquear a vitória de O
to_block_position: List[TTTPiece] = [TTTPiece.X, TTTPiece.E,
TTTPiece.E,
TTTPiece.E, TTTPiece.E,
TTTPiece.O,
TTTPiece.E, TTTPiece.X,
TTTPiece.O]
test_board2: TTTBoard = TTTBoard(to_block_position, TTTPiece.X)
answer2: Move = find_best_move(test_board2)
self.assertEqual(answer2, 2)

def test_hard_position(self):
# calcula o melhor movimento para ganhar em 2 movimentos
to_win_hard_position: List[TTTPiece] = [TTTPiece.X, TTTPiece.E,
TTTPiece.E,
TTTPiece.E, TTTPiece.E,
TTTPiece.O,
TTTPiece.O, TTTPiece.X,
TTTPiece.E]
test_board3: TTTBoard = TTTBoard(to_win_hard_position, TTTPiece.X)
answer3: Move = find_best_move(test_board3)
self.assertEqual(answer3, 1)

if __name__ == '__main__':
unittest.main()

Todos os três testes deverão passar quando você executar


tictactoe_tests.py.
DICA Não é necessário muito código para implementar o minimax, e ele funcionará para
vários outros jogos além do jogo da velha. Se você planeja implementar o minimax para
outro jogo, é importante visar ao sucesso criando estruturas de dados que funcionem bem
para o modo como o minimax foi projetado, por exemplo, usando a classe Board . Um erro
comum cometido pelos alunos que estão aprendendo o minimax é usar uma estrutura de
dados modi cável que é alterada por uma chamada recursiva ao minimax, a qual não poderá
ser restaurada ao seu estado original para outras chamadas.

8.2.4 Desenvolvendo uma IA para o jogo da velha


Com todos esses ingredientes prontos, será trivial dar o próximo passo e
desenvolver um adversário totalmente arti cial, capaz de jogar o jogo da
velha por completo. Em vez de avaliar uma posição de teste, a IA
simplesmente avaliará a posição gerada por cada movimento do
adversário. No pequeno trecho de código a seguir, a IA do jogo da velha
jogará contra um adversário humano que fará o primeiro movimento:
Listagem 8.11 – tictactoe_ai.py
from minimax import find_best_move
from tictactoe import TTTBoard
from board import Move, Board

board: Board = TTTBoard()

def get_player_move() -> Move:


player_move: Move = Move(-1)
while player_move not in board.legal_moves:
play: int = int(input("Enter a legal square (0-8):"))
player_move = Move(play)
return player_move

if __name__ == "__main__":
# laço principal do jogo
while True:
human_move: Move = get_player_move()
board = board.move(human_move)
if board.is_win:
print("Human wins!")
break
elif board.is_draw:
print("Draw!")
break
computer_move: Move = find_best_move(board)
print(f"Computer move is {computer_move}")
board = board.move(computer_move)
print(board)
if board.is_win:
print("Computer wins!")
break
elif board.is_draw:
print("Draw!")
break

Como o default de max_depth de find_best_move() é 8, essa IA de jogo da


velha sempre verá o nal do jogo. (O número máximo de movimentos no
jogo da velha é nove, e a IA joga em segundo lugar.) Assim, ela deverá
jogar todas as vezes de modo perfeito. Um jogo perfeito é aquele em que
os dois adversários fazem os melhores movimentos possíveis em cada
rodada. O resultado de um jogo da velha perfeito é um empate. Com isso
em mente, você jamais será capaz de vencer a IA do jogo da velha. Se você
jogar da melhor maneira possível, haverá um empate. Se cometer um erro,
a IA vencerá. Teste por conta própria. Você não conseguirá vencê-la.

8.3 Connect Four


No Connect Four,1 dois jogadores se alternam colocando peças de cores
distintas em um tabuleiro vertical de sete colunas e seis linhas. As peças
caem de cima para baixo no tabuleiro, até atingirem a parte inferior ou
alcançarem outra peça. Basicamente, a única decisão do jogador em cada
rodada é escolher em qual das sete colunas ele colocará uma peça. O
jogador não pode colocá-la em uma coluna cheia. O primeiro jogador
que tiver quatro peças de sua cor, uma ao lado da outra, em uma linha,
coluna ou diagonal, sem que haja lacunas, vencerá. Se nenhum jogador
conseguir isso e o tabuleiro estiver totalmente cheio, haverá um empate
no jogo.

8.3.1 Peças do jogo Connect Four


Em vários aspectos, o Connect Four é parecido com o jogo da velha. Os
dois jogos usam um tabuleiro e exigem que o jogador alinhe peças para
vencer. No entanto, como o tabuleiro do Connect Four é maior e há
muito mais maneiras de vencer, a avaliação de cada posição é
signi cativamente mais complexa.
Parte do código a seguir parecerá bastante familiar, mas as estruturas de
dados e o método de avaliação são bem diferentes daqueles do jogo da
velha. Os dois jogos são implementados como subclasses da mesmas
classes-base Piece e Board que vimos no início do capítulo, possibilitando
que minimax() seja usado nos dois jogos.
Listagem 8.12 – connectfour.py
from __future__ import annotations
from typing import List, Optional, Tuple
from enum import Enum
from board import Piece, Board, Move

class C4Piece(Piece, Enum):


B = "B"
R = "R"
E = " " # para representação de vazio

@property
def opposite(self) -> C4Piece:
if self == C4Piece.B:
return C4Piece.R
elif self == C4Piece.R:
return C4Piece.B
else:
return C4Piece.E

def __str__(self) -> str:


return self.value

A classe C4Piece é quase idêntica à classe TTTPiece.


Em seguida, temos uma função para gerar todos os possíveis segmentos
vitoriosos em um tabuleiro de determinado tamanho do Connect Four.
Listagem 8.13 – Continuação de connectfour.py
def generate_segments(num_columns: int, num_rows: int, segment_length: int)
-> List[List[Tuple[int, int]]]:
segments: List[List[Tuple[int, int]]] = []
# gera os segmentos verticais
for c in range(num_columns):
for r in range(num_rows - segment_length + 1):
segment: List[Tuple[int, int]] = []
for t in range(segment_length):
segment.append((c, r + t))
segments.append(segment)
# gera os segmentos horizontais
for c in range(num_columns - segment_length + 1):
for r in range(num_rows):
segment = []
for t in range(segment_length):
segment.append((c + t, r))
segments.append(segment)

# gera os segmentos diagonais da parte inferior à esquerda para


# a parte superior à direita
for c in range(num_columns - segment_length + 1):
for r in range(num_rows - segment_length + 1):
segment = []
for t in range(segment_length):
segment.append((c + t, r + t))
segments.append(segment)

# gera os segmentos diagonais da parte superior à esquerda


# para a parte inferior à direita
for c in range(num_columns - segment_length + 1):
for r in range(segment_length - 1, num_rows):
segment = []
for t in range(segment_length):
segment.append((c + t, r - t))
segments.append(segment)
return segments

Essa função devolve uma lista de listas de posições do tabuleiro (tuplas


de combinações de colunas/linhas). Cada lista da lista contém quatro
posições do tabuleiro. Chamamos a cada uma dessas listas de quatro
posições do tabuleiro de segmento. Se algum segmento do tabuleiro tiver
a mesma cor, essa cor será a vencedora do jogo.
Ser capaz de pesquisar rapidamente todos os segmentos do tabuleiro é
conveniente tanto para veri car se um jogo terminou (alguém venceu)
como para avaliar uma posição. Assim, no trecho de código a seguir, você
perceberá que armazenamos os segmentos em cache para um dado
tamanho de tabuleiro como uma variável de classe chamada SEGMENTS na
classe C4Board.
Listagem 8.14 – Continuação de connectfour.py
class C4Board(Board):
NUM_ROWS: int = 6
NUM_COLUMNS: int = 7
SEGMENT_LENGTH: int = 4
SEGMENTS: List[List[Tuple[int, int]]] = generate_segments(NUM_COLUMNS,
NUM_ROWS, SEGMENT_LENGTH)

A classe C4Board tem uma classe interna chamada Column. Essa classe não é
estritamente necessária porque poderíamos ter usado uma lista
unidimensional para representar o tabuleiro, como zemos no jogo da
velha, ou igualmente, uma lista bidimensional. Usar a classe Column
provavelmente reduzirá um pouco o desempenho, em comparação com
qualquer uma dessas soluções. Contudo, pensar no tabuleiro do Connect
Four como um grupo de sete colunas é conceitualmente e ciente e facilita
um pouco escrever o resto da classe C4Board.
Listagem 8.15 – Continuação de connectfour.py
class Column:
def __init__(self) -> None:
self._container: List[C4Piece] = []

@property
def full(self) -> bool:
return len(self._container) == C4Board.NUM_ROWS

def push(self, item: C4Piece) -> None:


if self.full:
raise OverflowError("Trying to push piece to full column")
self._container.append(item)

def __getitem__(self, index: int) -> C4Piece:


if index > len(self._container) - 1:
return C4Piece.E
return self._container[index]

def __repr__(self) -> str:


return repr(self._container)

def copy(self) -> C4Board.Column:


temp: C4Board.Column = C4Board.Column()
temp._container = self._container.copy()
return temp
A classe Column é bem parecida com a classe Stack que usamos em
capítulos anteriores. Isso faz sentido, pois, do ponto de vista conceitual,
durante o jogo, uma coluna do Connect Four é uma pilha na qual
podemos fazer uma inserção, mas nunca uma remoção. De modo
diferente das pilhas anteriores, porém, uma coluna do Connect Four tem
um limite absoluto de seis itens. Também interessante é o método especial
__getitem__() , que possibilita que uma instância de Column seja acessada
pelo índice. Isso permite que uma lista de colunas seja tratada como uma
lista bidimensional. Observe que, mesmo que o _container subjacente não
contenha um item em uma linha em particular, __getitem__() devolverá
uma peça vazia.
Os próximos quatro métodos são relativamente parecidos com seus
equivalentes no jogo da velha.
Listagem 8.16 – Continuação de connectfour.py
def __init__(self, position: Optional[List[C4Board.Column]] = None,
turn: C4Piece = C4Piece.B) -> None:
if position is None:
self.position: List[C4Board.Column] = [
C4Board.Column() for _ in range(C4Board.NUM_COLUMNS)]
else:
self.position = position
self._turn: C4Piece = turn

@property
def turn(self) -> Piece:
return self._turn

def move(self, location: Move) -> Board:


temp_position: List[C4Board.Column] = self.position.copy()
for c in range(C4Board.NUM_COLUMNS):
temp_position[c] = self.position[c].copy()
temp_position[location].push(self._turn)
return C4Board(temp_position, self._turn.opposite)

@property
def legal_moves(self) -> List[Move]:
return [Move(c) for c in range(C4Board.NUM_COLUMNS) if not
self.position[c].full]
Um método auxiliar _count_segment() devolve o número de peças pretas e
vermelhas em um segmento especí co. É seguido de um método para
veri car se há uma vitória, is_win(), o qual examina todos os segmentos
do tabuleiro e determina se há um vencedor usando _count_segment() para
ver se algum segmento tem quatro peças da mesma cor.
Listagem 8.17 Continuação de connectfour.py
# Devolve o número de peças pretas e vermelhas em um segmento
def _count_segment(self, segment: List[Tuple[int, int]]) -> Tuple[int, int]:
black_count: int = 0
red_count: int = 0
for column, row in segment:
if self.position[column][row] == C4Piece.B:
black_count += 1
elif self.position[column][row] == C4Piece.R:
red_count += 1
return black_count, red_count

@property
def is_win(self) -> bool:
for segment in C4Board.SEGMENTS:
black_count, red_count = self._count_segment(segment)
if black_count == 4 or red_count == 4:
return True
return False

Assim como TTTBoard, C4Board pode usar a propriedade is_draw da classe-


base abstrata Board, sem modi cação.
Por m, para avaliar uma posição, avaliaremos todos os seus segmentos
representativos, um segmento de cada vez, e somaremos essas avaliações
para devolver um resultado. Um segmento que tenha peças tanto
vermelhas quanto pretas será considerado sem valor. Um segmento que
tenha duas peças da mesma cor e duas posições vazias terá uma
pontuação igual a 1 atribuída. Um segmento com três peças da mesma
cor receberá uma pontuação igual a 100. Por m, um segmento com
quatro peças da mesma cor (uma vitória) terá pontuação igual a 1.000.000.
Se o segmento for do adversário, a pontuação será negativa.
_evaluate_segment() é um método auxiliar que avalia um único segmento
utilizando a fórmula anterior. A pontuação conjunta de todos os
segmentos obtida com _evaluate_segment() será gerada por evaluate().
Listagem 8.18 – Continuação de connectfour.py
def _evaluate_segment(self, segment: List[Tuple[int, int]], player: Piece) -
> float:
black_count, red_count = self._count_segment(segment)
if red_count > 0 and black_count > 0:
return 0 # segmentos com cores misturadas são neutros
count: int = max(red_count, black_count)
score: float = 0
if count == 2:
score = 1
elif count == 3:
score = 100
elif count == 4:
score = 1000000
color: C4Piece = C4Piece.B
if red_count > black_count:
color = C4Piece.R
if color != player:
return -score
return score

def evaluate(self, player: Piece) -> float:


total: float = 0
for segment in C4Board.SEGMENTS:
total += self._evaluate_segment(segment, player)
return total

def __repr__(self) -> str:


display: str = ""
for r in reversed(range(C4Board.NUM_ROWS)):
display += "|"
for c in range(C4Board.NUM_COLUMNS):
display += f"{self.position[c][r]}" + "|"
display += "\n"
return display

8.3.2 Uma IA para o Connect Four


Por incrível que pareça, as mesmas funções minimax() e find_best_move()
que desenvolvemos para o jogo da velha poderão ser usadas sem alteração
em nossa implementação do Connect Four. No trecho de código a seguir,
há apenas duas modi cações em comparação com o código da IA para o
jogo da velha. A principal diferença é que max_depth agora está de nida
com 3. Isso permite que o tempo para o computador pensar em cada
movimento seja razoável. Em outras palavras, nossa IA para o Connect
Four analisa (avalia) posições para até três movimentos futuros.
Listagem 8.19 – connectfour_ai.py
from minimax import find_best_move
from connectfour import C4Board
from board import Move, Board

board: Board = C4Board()

def get_player_move() -> Move:


player_move: Move = Move(-1)
while player_move not in board.legal_moves:
play: int = int(input("Enter a legal column (0-6):"))
player_move = Move(play)
return player_move

if __name__ == "__main__":
# laço principal do jogo
while True:
human_move: Move = get_player_move()
board = board.move(human_move)
if board.is_win:
print("Human wins!")
break
elif board.is_draw:
print("Draw!")
break
computer_move: Move = find_best_move(board, 3)
print(f"Computer move is {computer_move}")
board = board.move(computer_move)
print(board)
if board.is_win:
print("Computer wins!")
break
elif board.is_draw:
print("Draw!")
break

Experimente jogar com a IA do Connect Four. Você perceberá que ela


demora alguns segundos para fazer cada movimento, de modo diferente
da IA do jogo da velha. Provavelmente, ela continuará vencendo você, a
menos que você pense com muito cuidado em seus movimentos. Pelo
menos, ela não cometerá nenhum erro óbvio. Podemos melhorar o modo
de a IA jogar aumentando a profundidade que ela pesquisa, mas cada
movimento do computador exigirá um tempo exponencialmente maior
para ser calculado.
DICA Você sabia que o Connect Four foi “resolvido” por cientistas da área de computação?
Resolver um jogo signi ca saber qual é o melhor movimento a ser feito em qualquer posição.
O primeiro melhor movimento no Connect Four consiste em colocar sua peça na coluna do
meio.

8.3.3 Aperfeiçoando o minimax com a poda alfa-beta


O minimax funciona bem, mas não temos uma busca com muita
profundidade no momento. Há uma pequena extensão para o minimax,
conhecida como poda alfa-beta (alpha-beta pruning), capaz de melhorar
a profundidade da busca excluindo posições que não resultarão em
melhorias em relação às posições já analisadas. Essa mágica é feita
mantendo-se o controle de dois valores entre chamadas recursivas do
minimax: alfa e beta. Alfa representa a avaliação do melhor movimento
maximizador encontrado até esse ponto na árvore de busca, enquanto
beta representa a avaliação do melhor movimento minimizador
encontrado até então para o adversário. Se beta for menor ou igual a alfa,
não valerá a pena explorar esse ramo da busca, pois um movimento
melhor ou equivalente já foi encontrado, em comparação ao que será
encontrado mais adiante nesse ramo. Essa heurística decrementa
signi cativamente o espaço de busca.
A seguir, apresentamos uma função alphabeta() conforme acabamos de
descrevê-la. Ela deve ser inserida em nosso arquivo minimax.py atual.
Listagem 8.20 – Continuação de minimax.py
def alphabeta(board: Board, maximizing: bool, original_player: Piece,
max_depth: int = 8, alpha: float = float("-inf"), beta: float =
float("inf")) -> float:
# Caso de base – posição final ou profundidade máxima alcançada
if board.is_win or board.is_draw or max_depth == 0:
return board.evaluate(original_player)

# Caso recursivo - maximiza seus ganhos ou minimiza os ganhos do


adversário
if maximizing:
for move in board.legal_moves:
result: float = alphabeta(board.move(move), False,
original_player, max_depth - 1, alpha, beta)
alpha = max(result, alpha)
if beta <= alpha:
break
return alpha
else: # minimizando
for move in board.legal_moves:
result = alphabeta(board.move(move), True, original_player,
max_depth - 1, alpha, beta)
beta = min(result, beta)
if beta <= alpha:
break
return beta

Agora você pode fazer duas pequenas alterações para tirar proveito de
nossa nova função. Modi que find_best_move() em minimax.py para que
use alphabeta() no lugar de minimax(), e altere a profundidade da busca em
connectfour_ai.py, de 3 para 5. Com essas alterações, um jogador médio
de Connect Four não será capaz de derrotar a nossa IA. Em meu
computador, usando o minimax() com uma profundidade igual a 5, nossa
IA do Connect Four demorou aproximadamente 3 minutos por
movimento, enquanto usar o alphabeta() com a mesma profundidade
exigiu cerca de 30 segundos por movimento. Isso representa um sexto do
tempo! É uma melhoria incrível.

8.4 Melhorias no minimax além da poda alfa-beta


Os algoritmos apresentados neste capítulo foram intensamente estudados,
e várias melhorias foram identi cadas ao longo dos anos. Algumas dessas
melhorias são especí cas de um jogo, como as “bitboards” no xadrez,
que reduzem o tempo necessário para gerar movimentos permitidos; no
entanto, a maioria são técnicas genéricas, que podem ser utilizadas em
qualquer jogo.
Uma técnica comum é o aprofundamento iterativo. No aprofundamento
iterativo, inicialmente a função de busca é executada com uma
profundidade máxima de 1. Em seguida, é executado com uma
profundidade máxima de 2. Posteriormente, é executado com uma
profundidade máxima de 3, e assim sucessivamente. Quando um limite de
tempo especi cado for alcançado, a busca é interrompida. O resultado da
última profundidade concluída será devolvido.
Os exemplos deste capítulo usaram uma profundidade xa no código.
Isso é razoável quando o jogo não tem um relógio ou um limite de tempo,
ou se não nos importarmos com o tempo que o computador demora para
pensar. Um aprofundamento iterativo permite que uma IA demore um
tempo xo para calcular seu próximo movimento, em vez de ter um valor
de profundidade de busca xo com um tempo variável para completar a
busca.
Outra possível melhoria é a busca quiescente (quiescence search).
Nessa técnica, a árvore de busca do minimax será expandida por
caminhos que causam grandes mudanças de posição (capturas no
xadrez, por exemplo), em vez de caminhos que tenham posições
relativamente “quietas”. Dessa forma, não haverá desperdício de tempo de
processamento com posições inócuas na busca, que tenham poucas
chances de dar uma vantagem signi cativa ao jogador.
As duas melhores maneiras de melhorar a busca com o minimax é fazer
buscas em uma profundidade maior no tempo reservado, ou melhorar a
função de avaliação usada para veri car uma posição. Pesquisar mais
posições no mesmo intervalo de tempo exige gastar menos tempo com
cada posição. Isso pode resultar da escrita de um código mais e ciente ou
do uso de um hardware mais rápido, mas também pode ser uma
consequência da última técnica de aperfeiçoamento: melhorar a avaliação
de cada posição. Usar mais parâmetros ou dados heurísticos para avaliar
uma posição pode demorar mais, porém, em última análise, pode resultar
em uma engine melhor, que exija uma profundidade de busca menor para
identi car um bom movimento.
Algumas funções de avaliação usadas na busca minimax com a poda
alfa-beta no xadrez têm dezenas de heurísticas. Até mesmo algoritmos
genéticos têm sido usados para ajuste dessas heurísticas. Até que ponto a
captura de um cavalo compensa em um jogo de xadrez? Deveria valer
mais que um bispo? Essas heurísticas podem ser o ingrediente secreto
que distingue uma ótima engine de xadrez de outra que seja apenas boa.

8.5 Aplicações no mundo real


O minimax, em conjunto com outras extensões, como a poda alfa-beta, é
a base da maioria das engines modernas de xadrez. Ele tem sido aplicado
com bastante sucesso em uma ampla gama de jogos de estratégia. Com
efeito, a maioria dos adversários arti ciais nos jogos de tabuleiro que
você joga em seu computador provavelmente utiliza alguma forma de
minimax.
O minimax (com suas extensões, como a poda alfa-beta) tem sido tão
e ciente no xadrez, a ponto de ter levado à famosa derrota do campeão
mundial de xadrez, Gary Kasparov, pelo Deep Blue em 1997 – um
computador que jogava xadrez, criado pela IBM. Houve muita
expectativa para a disputa, e foi um evento que mudou o jogo. O xadrez
era visto como um domínio do mais elevado calibre intelectual. O fato de
que um computador pudesse superar a capacidade humana no xadrez,
para algumas pessoas, signi cou que a inteligência arti cial deveria ser
levada a sério.
Duas décadas mais tarde, a grande maioria das engines de xadrez ainda
tem o minimax como base. As engines de xadrez atuais, baseadas no
minimax, excedem de longe a capacidade dos melhores jogadores de
xadrez do mundo. Novas técnicas de aprendizado de máquina estão
começando a desa ar as engines de xadrez baseadas exclusivamente no
minimax (com extensões), mas elas ainda não comprovaram sua
superioridade no xadrez, de forma de nitiva.
Quanto maior o fator de rami cação de um jogo, menos e ciente será o
minimax. O fator de rami cação de um jogo é o número médio de
possíveis movimentos em uma posição. É por isso que avanços recentes
no jogo de Go por computador têm exigido explorações em outros
domínios, como na área de aprendizado de máquina. Uma IA para Go
baseada em aprendizado de máquina já derrotou o melhor jogador
humano de Go. O fator de rami cação (e, desse modo, o espaço de
busca) de Go é simplesmente absurdo para algoritmos que se baseiam no
minimax, os quais tentam gerar árvores contendo futuras posições.
Contudo Go é a exceção, e não a regra. Os jogos de tabuleiro mais
tradicionais (jogo de damas, xadrez, Connect Four, Scrabble e outros do
mesmo tipo) têm espaços de busca su cientemente pequenos, nos quais
as técnicas com base no minimax podem funcionar bem.
Se você estiver implementando um novo adversário arti cial de jogo de
tabuleiro, ou até mesmo uma IA para um jogo baseado em turnos,
totalmente orientado por computador, o minimax provavelmente será o
primeiro algoritmo do qual você deverá lançar mão. O minimax também
pode ser usado em simulações econômicas e políticas, assim como em
experimentos na teoria de jogos. A poda alfa-beta deve funcionar com
qualquer forma de minimax.

8.6 Exercícios
1. Acrescente testes de unidade no jogo da velha a m de garantir que as
propriedades legal_moves, is_win e is_draw funcionam corretamente.
2. Crie testes de unidade para o minimax no Connect Four.
3. Os códigos em tictactoe_ai.py e em connectfour_ai.py são quase
idênticos. Refatore-os em dois métodos que possam ser usados por
qualquer um dos jogos.
4. Modi que connectfour_ai.py para fazer o computador jogar contra si
mesmo. Quem vence é o primeiro ou é o segundo jogador? É sempre
o mesmo jogador?
5. Você é capaz de encontrar uma maneira (por meio do pro ling do
código existente, ou de outro modo) de otimizar o método de
avaliação em connectfour.py de modo a permitir uma profundidade
de busca maior no mesmo intervalo de tempo?
6. Use a função alphabeta() desenvolvida neste capitulo, junto com uma
biblioteca Python para gerar movimentos permitidos no xadrez e
manter o estado do jogo, a m de desenvolver uma IA para o xadrez.

1 Connect Four é uma marca registrada da Hasbro, Inc. Foi usada neste livro apenas com ns
descritivos e de modo favorável.
CAPÍTULO 9

Problemas diversos

Neste livro, abordamos diversas técnicas de resolução de problemas


relevantes às tarefas de desenvolvimento de software moderno. Para
estudar cada técnica, exploramos problemas famosos de ciência da
computação. Contudo, nem todo problema famoso se encaixa nos
moldes dos capítulos anteriores. Este capítulo reúne problemas famosos
que não se enquadraram muito bem em nenhum outro capítulo. Pense
nesses problemas como um bônus: mais problemas interessantes, porém
com menos explicações detalhadas.

9.1 Problema da mochila


O problema da mochila (knapsack problem) é um problema de
otimização que parte de uma necessidade comum em computação –
encontrar o melhor uso de recursos limitados, dado um conjunto nito
de opções de uso – e a transforma em uma história divertida. Um ladrão
entra em uma casa com o intuito de roubá-la. Ele tem uma mochila e, pela
capacidade dela, está limitado ao quanto pode roubar. Como ele faria
para calcular o que pode ser colocado na mochila? A Figura 9.1 ilustra o
problema.
Se o ladrão pudesse levar qualquer quantidade de qualquer item, ele
poderia apenas dividir o valor de cada item pelo seu peso a m de
descobrir quais são os itens mais valiosos para a capacidade disponível.
Contudo, para deixar o cenário mais realista, vamos supor que o ladrão
não possa levar a metade de um item (por exemplo, 2,5 televisões). Em vez
disso, pensaremos em uma forma de solucionar a variante 0/1 do
problema, assim chamada porque impõe outra regra: o ladrão só pode
levar um item inteiro, ou nenhum.
Figura 9.1 – O ladrão deve decidir quais itens roubará, pois a capacidade da
mochila é limitada.
Inicialmente, vamos de nir uma NamedTuple para armazenar nossos itens.
Listagem 9.1 – knapsack.py
from typing import NamedTuple, List

class Item(NamedTuple):
name: str
weight: int
value: float
Se tentássemos resolver esse problema usando uma abordagem com força
bruta, analisaríamos todas as combinações de itens disponíveis que
poderiam ser colocados na mochila. Para aqueles com inclinações
matemáticas, isso é conhecido como conjunto de partes (powerset), e um
conjunto de partes é um conjunto (em nosso caso, o conjunto de itens)
com 2^N possíveis subconjuntos diferentes, em que N é o número de
itens. Assim, teríamos de analisar 2^N combinações (O(2^N)). Não
haveria problemas com um número baixo de itens, mas será inviável para
um número grande. Qualquer abordagem que resolva um problema
usando um número exponencial de passos é uma abordagem que
devemos evitar.
Como alternativa, usaremos uma técnica conhecida como programação
dinâmica, que é conceitualmente semelhante à memoização (Capítulo 1).
Em vez de resolver o problema diretamente com uma abordagem de força
bruta, na programação dinâmica, resolvemos subproblemas que
compõem o problema maior, armazenamos seus resultados e os
utilizamos para solucionar o problema maior. Desde que a capacidade da
mochila seja considerada em passos discretos, o problema poderá ser
resolvido com a programação dinâmica.
Por exemplo, a m de resolver o problema para uma mochila com
capacidade de 3 libras e três itens, podemos resolver primeiro o problema
para uma capacidade de 1 libra e um item possível, para uma capacidade
de 2 libras e um item possível e para uma capacidade de 3 libras e um item
possível. Então podemos usar os resultados dessa solução a m de
resolver o problema para uma capacidade de 1 libra e dois itens possíveis,
para uma capacidade de 2 libras e dois itens possíveis e para uma
capacidade de 3 libras e dois itens possíveis. Por m, podemos resolver o
problema para todos os três itens possíveis.
Durante todo o processo, preencheremos uma tabela que nos informará
a melhor solução possível para cada combinação de itens e capacidade.
Nossa função inicialmente preencherá a tabela e, em seguida, encontrará
a solução com base nessa tabela.1
Listagem 9.2 – Continuação de knapsack.py
def knapsack(items: List[Item], max_capacity: int) -> List[Item]:
# constrói a tabela com programação dinâmica
table: List[List[float]] = [[0.0 for _ in range(max_capacity + 1)]
for _ in range(len(items) + 1)]
for i, item in enumerate(items):
for capacity in range(1, max_capacity + 1):
previous_items_value: float = table[i][capacity]
if capacity >= item.weight: # o item cabe na mochila
value_freeing_weight_for_item: float = table[i][capacity -
item.weight]
# pega somente se for mais valioso que o item anterior
table[i + 1][capacity] = max(value_freeing_weight_for_item
+ item.value, previous_items_value)
else: # o item não cabe na mochila
table[i + 1][capacity] = previous_items_value
# descobre a solução com base na tabela
solution: List[Item] = []
capacity = max_capacity
for i in range(len(items), 0, -1): # trabalha na ordem inversa
# este item foi usado?
if table[i - 1][capacity] != table[i][capacity]:
solution.append(items[i - 1])
# se o item foi usado, decrementa o seu peso
capacity -= items[i - 1].weight
return solution

O laço interno na primeira parte dessa função executará N * C vezes, em


que N é o número de itens e C é a capacidade máxima da mochila. Desse
modo, o algoritmo executa em um tempo de ordem O(N * C), que é uma
melhoria signi cativa em comparação com a abordagem de força bruta
para um número grande de itens. Por exemplo, para os 11 itens seguintes,
um algoritmo que usasse de força bruta teria de analisar 2^11, ou seja,
2.048 combinações. A função anterior com programação dinâmica
executará 825 vezes, pois a capacidade máxima da mochila em questão é
de 75 unidades arbitrárias (11 * 75). Essa diferença aumentaria
exponencialmente com mais itens.
Vamos observar a solução em ação.
Listagem 9.3 – Continuação de knapsack.py
if __name__ == "__main__":
items: List[Item] = [Item("television", 50, 500),
Item("candlesticks", 2, 300),
Item("stereo", 35, 400),
Item("laptop", 3, 1000),
Item("food", 15, 50),
Item("clothing", 20, 800),
Item("jewelry", 1, 4000),
Item("books", 100, 300),
Item("printer", 18, 30),
Item("refrigerator", 200, 700),
Item("painting", 10, 1000)]
print(knapsack(items, 75))

Se você inspecionar os resultados exibidos no console, verá que os itens


ideais a serem levados são: quadro (painting), joias (jewelry), roupas
(clothing), laptop, aparelho de som (stereo) e castiçais (candlesticks). Eis
um exemplo de saída mostrando os itens mais valiosos para o ladrão
roubar, considerando a capacidade limitada da mochila:
[Item(name='painting', weight=10, value=1000), Item(name='jewelry',
weight=1, value=4000), Item(name='clothing', weight=20, value=800),
Item(name='laptop', weight=3, value=1000), Item(name='stereo',
weight=35, value=400), Item(name='candlesticks', weight=2, value=300)]

Para ter uma ideia melhor de como tudo isso funciona, vamos analisar
algumas das particularidades da função:
for i, item in enumerate(items):
for capacity in range(1, max_capacity + 1):

Para cada número possível de itens, percorreremos todas as capacidades


em um laço, de forma linear, até a capacidade máxima da mochila.
Observe que eu disse “cada número possível de itens”, e não cada item.
Quando i é igual a 2, ele não representa apenas o item 2. Esse valor
representa as combinações possíveis dos dois primeiros itens para cada
capacidade explorada. item é o próximo item que estamos considerando
roubar:
previous_items_value: float = table[i][capacity]
if capacity >= item.weight: # o item cabe na mochila

previous_items_value é o valor da última combinação de itens para a


capacidade atual (capacity) sendo explorada. Para cada possível
combinação de itens, consideramos se adicionar o último “novo” item é,
no mínimo, possível.
Se o item pesar mais do que a capacidade da mochila que estamos
considerando, basta copiar o valor da última combinação de itens que
consideramos para a capacidade em questão:
else: # o item não cabe na mochila
table[i + 1][capacity] = previous_items_value
Caso contrário, veri camos se adicionar o “novo” item resultará em um
valor maior do que a última combinação de itens para a capacidade
considerada. Fazemos isso somando o valor do item ao valor já calculado
na tabela para a combinação anterior de itens, em uma capacidade igual
ao peso do item subtraído da capacidade que estamos considerando no
momento. Se esse valor for maior do que a última combinação de itens
para a capacidade atual, nós o inserimos; caso contrário, inserimos o
último valor:
value_freeing_weight_for_item: float = table[i][capacity - item.weight]
# pega somente se for mais valioso que o item anterior
table[i + 1][capacity] = max(value_freeing_weight_for_item + item.value,
previous_items_value)

Com isso, concluímos a construção da tabela. Para encontrar quais itens


correspondem à solução, porém, precisamos trabalhar na ordem inversa,
da capacidade mais alta e da última combinação de itens explorada:
for i in range(len(items), 0, -1): # trabalha na ordem inversa
# este item foi usado?
if table[i - 1][capacity] != table[i][capacity]:

Começamos pelo nal e percorremos nossa tabela da direita para a


esquerda, veri cando se houve uma mudança no valor inserido na tabela
em cada passo. Se houve, é sinal de que adicionamos o novo item que foi
considerado em uma combinação especí ca porque a combinação era
mais valiosa que a combinação anterior. Assim, adicionamos esse item na
solução. Além disso, subtraímos o peso do item da capacidade; podemos
pensar nisso como um movimento para cima na tabela:
solution.append(items[i - 1])
# se o item foi usado, decrementa o seu peso
capacity -= items[i - 1].weight
NOTA No processo de construção da tabela e da busca da solução, talvez você tenha notado
uma manipulação dos iteradores e do tamanho da tabela de 1. Isso é feito por questões de
conveniência, do ponto de vista da programação. Pense em como o problema é abordado de
baixo para cima. No início do problema, estamos lidando com uma mochila de capacidade
zero. Se você trabalhar de baixo para cima em uma tabela, cará claro o motivo de
precisarmos da linha e da coluna extras.
Você continua confuso? A Tabela 9.1 é a tabela construída pela função
knapsack() . Seria uma tabela bem grande para o problema anterior,
portanto, vamos observar uma tabela para uma mochila com capacidade
de 3 libras e três itens: fósforos (1 libra), lanterna (2 libras) e livro (1
libra). Suponha que esses itens estejam avaliados em 5, 10 e 15 dólares,
respectivamente.
Tabela 9.1 – Exemplo de um problema da mochila com três itens
0 libra 1 libra 2 libras 3 libras
Fósforos (1 libra, 5 dólares) 0 5 5 5
Lanterna (2 libras, 10 dólares) 0 5 10 15
Livro (1 libra, 15 dólares) 0 15 20 25
À medida que percorrer a tabela da esquerda para a direita, o peso
aumenta (quanto você está tentando carregar na mochila). À medida que
observar a tabela de cima para baixo, o número de itens que você está
tentando carregar aumenta. Na primeira linha, você está tentando
carregar apenas os fósforos. Na segunda linha, tenta carregar a
combinação mais valiosa de fósforos e lanterna, que possa ser carregada
na mochila. Na terceira linha, tenta carregar a combinação mais valiosa
de todos os três itens.
Como exercício para facilitar a sua compreensão, experimente preencher
uma versão em branco dessa tabela por conta própria, usando o
algoritmo descrito na função knapsack()com esses mesmos três itens. Em
seguida, utilize o algoritmo no nal da função para ler os itens corretos
da tabela. Essa tabela corresponde à variável table da função.

9.2 Problema do Caixeiro-Viajante


O Problema do Caixeiro-Viajante (Traveling Salesman Problem) é um dos
problemas mais clássicos e discutidos em toda a ciência da computação.
Um caixeiro-viajante deve visitar todas as cidades de um mapa exatamente
uma só vez, retornando à cidade da qual partiu no nal da jornada. Há
uma conexão direta entre cada cidade e todas as demais cidades, e o
caixeiro-viajante pode visitar as cidades em qualquer ordem. Qual é o
caminho mais curto para o ele?
Podemos pensar nesse problema como um problema de grafo (Capítulo
4), com as cidades sendo os vértices e as conexões entre elas, as arestas.
Seu instinto inicial poderia ser encontrar a árvore geradora mínima
(minimum spanning tree), conforme descrito no Capítulo 4. Infelizmente,
a solução para o Problema do Caixeiro-Viajante não é tão simples assim.
A árvore geradora mínima é o caminho mais curto para conectar todas as
cidades, mas não fornece o caminho mínimo de modo a visitar todas elas
exatamente uma só vez.
Apesar de o problema, conforme apresentado, parecer razoavelmente
simples, não há nenhum algoritmo capaz de resolvê-lo rapidamente para
um número arbitrário de cidades. O que eu quero dizer com
“rapidamente”? Quero dizer que esse é um problema conhecido como
NP-difícil (ou NP-complexo). Um problema NP-difícil (problema
polinomial difícil, não determinístico) é um problema para o qual não há
nenhum algoritmo com tempo polinomial. (O tempo que ele demora é
uma função polinomial do tamanho da entrada.) À medida que o número
de cidades que o caixeiro-viajante deve visitar aumenta, a di culdade para
resolver o problema aumentará excepcionalmente, de modo rápido. É
muito mais difícil resolver o problema para 20 cidades do que para 10. É
impossível (até onde sabemos atualmente) resolver o problema de modo
perfeito (ótimo) para milhões de cidades, em tempo razoável.
NOTA A abordagem ingênua para o Problema do Caixeiro Viajante tem complexidade O(n!).
O motivo para isso será discutido na Seção 9.2.2. Contudo, sugerimos que você leia a Seção
9.2.1 antes de ler a 9.2.2 porque a implementação de uma solução ingênua para o problema
deixará a sua complexidade evidente.

9.2.1 Abordagem ingênua


A abordagem ingênua para o problema é tentar simplesmente todas as
combinações possíveis de cidades. Uma tentativa de usar a abordagem
ingênua mostrará a di culdade do problema e a inadequação dessa
abordagem para tentativas usando força bruta em escalas maiores.
Dados para o nosso exemplo
Em nossa versão do Problema do Caixeiro-Viajante, o caixeiro-viajante
está interessado em visitar cinco das principais cidades do estado de
Vermont. Não especi caremos uma cidade inicial (e, portanto, nal). A
Figura 9.2 mostra as cinco cidades e as distâncias a serem percorridas
entre elas. Observe que há uma distância listada para a rota entre cada par
de cidades.
Figura 9.2 – Cinco cidades no estado de Vermont e as distâncias a serem
percorridas entre elas.
Talvez você já tenha visto distâncias de rotas em formato de tabela. Em
uma tabela com distâncias, podemos consultar facilmente a distância
entre duas cidades quaisquer. A Tabela 9.2 lista as distâncias das rotas
para as cinco cidades do problema.
Será necessário codi car tanto as cidades como as distâncias entre elas
em nosso problema. Para facilitar a consulta às distâncias entre as
cidades, usaremos um dicionário de dicionários, com o conjunto externo
de chaves representando o primeiro item de um par, e o conjunto interno
de chaves representando o segundo. Esse dicionário será do tipo
Dict[str, Dict[str, int]] , e permitirá fazer consultas como
vt_distances["Rutland"]["Burlington"] , que devolverá 67.
Tabela 9.2 – Distâncias das rotas entre as cidades no estado de Vermont
Rutland Burlington White River Junction Bennington Brattleboro
Rutland 0 67 46 55 75
Burlington 67 0 91 122 153
White River Junction 46 91 0 98 65
Bennington 55 122 98 0 40
Brattleboro 75 153 65 40 0

Listagem 9.4 – tsp.py


from typing import Dict, List, Iterable, Tuple
from itertools import permutations
vt_distances: Dict[str, Dict[str, int]] = {
"Rutland":
{"Burlington": 67,
"White River Junction": 46,
"Bennington": 55,
"Brattleboro": 75},
"Burlington":
{"Rutland": 67,
"White River Junction": 91,
"Bennington": 122,
"Brattleboro": 153},
"White River Junction":
{"Rutland": 46,
"Burlington": 91,
"Bennington": 98,
"Brattleboro": 65},
"Bennington":
{"Rutland": 55,
"Burlington": 122,
"White River Junction": 98,
"Brattleboro": 40},
"Brattleboro":
{"Rutland": 75,
"Burlington": 153,
"White River Junction": 65,
"Bennington": 40}
}
Encontrando todas as permutações
A abordagem ingênua para resolver o Problema do Caixeiro-Viajante
exige gerar todas as permutações possíveis das cidades. Há muitos
algoritmos para geração de permutações; eles são bem simples de
conceber, de modo que é quase certo que você poderia criar um por
conta própria.
Uma abordagem comum é usar o backtracking, o qual vimos
inicialmente no Capítulo 3, no contexto da resolução de um problema de
satisfação de restrições. Na resolução de problemas de satisfação de
restrições, o backtracking é usado depois que uma solução parcial é
encontrada, e que não satisfaça as restrições do problema. Nesse caso,
você deve voltar para um estado anterior e continuar a busca por um
caminho diferente daquele que o levou à solução parcial incorreta.
Para encontrar todas as permutações dos itens de uma lista (por
exemplo, de nossas cidades), o backtracking também poderia ser usado.
Depois de fazer uma troca (swap) entre elementos para percorrer um
caminho com outras permutações, você pode voltar ao estado anterior à
troca para que uma troca diferente seja feita a m de percorrer um
caminho diferente.
Felizmente, não há necessidade de reinventar a roda escrevendo um
algoritmo de geração de permutações, pois a biblioteca-padrão de Python
tem uma função permutations() em seu módulo itertools. No trecho de
código a seguir, geraremos todas as permutações das cidades de Vermont
que nosso caixeiro-viajante teria de visitar. Como há cinco cidades, isso
equivale e 5! (5 fatorial), ou seja, 120 permutações.
Listagem 9.5 – Continuação de tsp.py
vt_cities: Iterable[str] = vt_distances.keys()
city_permutations: Iterable[Tuple[str, ...]] = permutations(vt_cities)

Busca com o uso de força bruta


Podemos gerar agora todas as permutações da lista de cidades, mas elas
não serão exatamente o mesmo que o caminho para o Problema do
Caixeiro-Viajante. Lembre-se de que, no Problema do Caixeiro-Viajante,
no nal, o caixeiro-viajante deve retornar à mesma cidade na qual ele
iniciou seu percurso. Podemos facilmente acrescentar a primeira cidade
no nal de uma permutação usando uma list comprehension.
Listagem 9.6 – Continuação de tsp.py
tsp_paths: List[Tuple[str, ...]] = [c + (c[0],) for c in
city_permutations]

Agora estamos prontos para testar os caminhos com as permutações.


Uma abordagem de busca com o uso de força bruta analisa
pacientemente cada caminho em uma lista de caminhos e utiliza a tabela
para consultar a distância entre duas cidades (vt_distances) a m de
calcular a distância total de cada caminho. Serão exibidos tanto o
caminho mais curto como a distância total desse caminho.
Listagem 9.7 – Continuação de tsp.py
if __name__ == "__main__":
best_path: Tuple[str, ...]
min_distance: int = 99999999999 # número arbitrariamente alto
for path in tsp_paths:
distance: int = 0
last: str = path[0]
for next in path[1:]:
distance += vt_distances[last][next]
last = next
if distance < min_distance:
min_distance = distance
best_path = path
print(f"The shortest path is {best_path} in {min_distance} miles.")

Por m, podemos usar de força bruta nas cidades de Vermont,


encontrando o caminho mais curto para alcançar todas as cinco cidades.
O resultado deverá ter uma aparência semelhante àquela mostrada a
seguir, e a Figura 9.3 apresenta o melhor caminho.
The shortest path is ('Rutland', 'Burlington', 'White River Junction',
'Brattleboro', 'Bennington', 'Rutland') in 318 miles.

9.2.2 Avançando para o próximo nível


Não há uma resposta fácil para o Problema do Caixeiro-Viajante. Nossa
abordagem ingênua torna-se rapidamente impraticável. O número de
permutações geradas é n fatorial (n!), em que n é o número de cidades do
problema. Se fôssemos incluir apenas uma cidade a mais (seis, em vez de
cinco), o número de caminhos avaliados aumentaria em um fator de seis.
Em seguida, seria sete vezes mais difícil resolver o problema com apenas
uma cidade a mais depois disso. Não é uma abordagem escalável!

Figura 9.3 – A gura mostra o caminho mais curto para o caixeiro-viajante


visitar todas as cinco cidades de Vermont.
No mundo real, a abordagem ingênua para o Problema do Caixeiro-
Viajante raramente é usada. A maioria dos algoritmos para problemas
com um número maior de cidades gera aproximações. Eles tentam
resolver o problema fornecendo uma solução próxima da solução ótima.
Essa solução pode estar dentro de uma pequena faixa conhecida que
contém a solução perfeita. (Por exemplo, talvez não sejam mais do que
5% menos e cientes.)
Duas técnicas que já apareceram neste livro têm sido usadas para tentar
resolver o Problema do Caixeiro-Viajante com conjuntos grandes de
dados. A programação dinâmica, que usamos antes no problema da
mochila neste capítulo, é uma dessas abordagens. A outra são os
algoritmos genéticos, conforme foram descritos no Capítulo 5. Muitos
artigos cientí cos foram publicados classi cando os algoritmos genéticos
como soluções próximas das soluções ótimas para o problema do
caixeiro-viajante para um número grande de cidades.

9.3 Dados mnemônicos para números de telefone


Antes da existência dos smartphones com agendas telefônicas embutidas,
os telefones incluíam letras em cada uma das teclas numéricas. O motivo
para essas letras era oferecer dados mnemônicos que facilitassem lembrar
os números de telefone. Nos Estados Unidos, em geral a tecla 1 não teria
letras, a tecla 2 teria ABC, 3 teria DEF, 4 teria GHI, 5 teria JKL, 6 teria
MNO, 7 teria PQRS, 8 teria TUV, 9 teria WXYZ e 0 não teria letras. Por
exemplo, 1-800-MY-APPLE corresponde ao número de telefone 1-800-69-
27753. Ocasionalmente, você ainda encontrará esses dados mnemônicos
em anúncios e, desse modo, os números no teclado conseguiram chegar
até os aplicativos modernos para smartphones, conforme evidencia a
Figura 9.4.
Como é possível criar um dado mnemônico para um número de
telefone? Nos anos 1990, havia sharewares populares para ajudar nessa
tarefa. Esse tipo de software gerava todas as permutações das letras de
um número de telefone, e então consultava um dicionário para encontrar
palavras que estivessem contidas nessas permutações. Em seguida, as
permutações eram exibidas ao usuário, com as palavras mais completas.
Resolveremos a primeira metade do problema. A consulta ao dicionário
será deixada como exercício.
Figura 9.4 – O aplicativo Phone no iOS preserva as letras que havia nas
teclas de seus telefones ancestrais.
No último problema, quando vimos a geração das permutações,
utilizamos a função permutations() para gerar os possíveis caminhos no
Problema do Caixeiro-Viajante. No entanto, conforme mencionamos, há
várias maneiras diferentes de gerar as permutações. Nesse problema em
particular, em vez de trocar duas posições em uma permutação existente a
m de gerar uma nova permutação, geraremos cada permutação do zero.
Faremos isso observando as possíveis letras que correspondam a cada
dígito do número de telefone e adicionaremos continuamente mais
opções no nal, à medida que veri camos cada dígito sucessivo. É uma
espécie de produto cartesiano e, mais uma vez, o módulo itertools da
biblioteca-padrão de Python oferece suporte para nós.
Inicialmente, de niremos um mapeamento entre os dígitos e as
possíveis letras.
Listagem 9.8 – Continuação de tsp.py
from typing import Dict, Tuple, Iterable, List
from itertools import product

phone_mapping: Dict[str, Tuple[str, ...]] = {"1": ("1",),


"2": ("a", "b", "c"),
"3": ("d", "e", "f"),
"4": ("g", "h", "i"),
"5": ("j", "k", "l"),
"6": ("m", "n", "o"),
"7": ("p", "q", "r", "s"),
"8": ("t", "u", "v"),
"9": ("w", "x", "y", "z"),
"0": ("0",)}

A próxima função combina todas as possibilidades para cada numeral e


gera uma lista de possíveis dados mnemônicos para um dado número de
telefone. Isso é feito por meio da criação de uma lista de tuplas com as
letras possíveis para cada dígito do número de telefone e, em seguida,
combinando-as com a função de produto cartesiano product() do módulo
itertools . Observe o uso do operador de desempacotamento (* ) para usar
as tuplas de letter_tuples como argumentos para product().
Listagem 9.9 – Continuação de tsp.py
def possible_mnemonics(phone_number: str) -> Iterable[Tuple[str, ...]]:
letter_tuples: List[Tuple[str, ...]] = []
for digit in phone_number:
letter_tuples.append(phone_mapping.get(digit, (digit,)))
return product(*letter_tuples)
Agora podemos encontrar todos os possíveis dados mnemônicos para
um número de telefone.
Listagem 9.10 – Continuação de tsp.py
if __name__ == "__main__":
phone_number: str = input("Enter a phone number:")
print("Here are the potential mnemonics:")
for mnemonic in possible_mnemonics(phone_number):
print("".join(mnemonic))

O fato é que o número de telefone 1440787 também pode ser escrito


como 1GH0STS. Essa informação é mais fácil de lembrar.
9.4 Aplicações no mundo real
A programação dinâmica, conforme empregada no problema da mochila,
é uma técnica amplamente aplicável, capaz de fazer com que problemas
aparentemente intratáveis se tornem solucionáveis, dividindo-os em
problemas constituintes menores e construindo uma solução a partir
dessas partes. O próprio problema da mochila está relacionado com
outros problemas de otimização, nos quais uma quantidade nita de
recursos (a capacidade da mochila) deve ser alocada entre um conjunto
nito, porém completo, de opções (os itens a serem roubados). Pense em
uma faculdade que precise distribuir seu orçamento destinado a esportes.
Ela não tem dinheiro su ciente para patrocinar todas as equipes, e há
certa expectativa acerca do valor das doações de ex-alunos que cada
equipe conseguirá obter. A faculdade poderia resolver um problema
semelhante ao da mochila para otimizar a alocação do orçamento.
Problemas como esse são comuns no mundo real.
O Problema do Caixeiro-Viajante é uma ocorrência diária em empresas
de transporte e distribuição, como UPS e FedEx. Empresas que entregam
encomendas querem que seus motoristas percorram as menores rotas
possíveis. Isso não só deixa os trabalhos dos motoristas mais agradáveis,
mas também permite economizar combustível e custos com manutenção.
Todos viajamos a trabalho ou por lazer, e encontrar rotas ótimas ao visitar
vários destinos pode fazer com que economizemos recursos. Contudo, o
Problema do Caixeiro-Viajante não serve apenas para rotas de viagem; ele
surge em quase todos os cenários de roteamento que exijam visitas únicas
aos nós. Embora uma árvore geradora mínima (Capítulo 4) possa
minimizar a quantidade de os necessária para interligar um bairro, ela
não nos informa qual é quantidade ótima de os, se cada casa tiver de
estar conectada a apenas uma outra casa adiante, como parte de um
circuito gigantesco que retorne à sua origem. O Problema do Caixeiro-
Viajante faz isso.
As técnicas de geração de permutações, como aquela que usamos na
abordagem ingênua para o Problema do Caixeiro-Viajante e para o
problema dos dados mnemônicos para números de telefone, são
convenientes para testar todo tipo de algoritmos que fazem uso de força
bruta. Por exemplo, se você estivesse tentando quebrar uma senha
pequena, poderia gerar todas as permutações possíveis dos caracteres que
poderiam estar na senha. Para quem faz uso de tarefas que geram
permutação em larga escala como essas, usar um algoritmo
particularmente e ciente de geração de permutações, como o algoritmo
de Heap2, seria uma atitude inteligente.

9.5 Exercícios
1. Reescreva o código da abordagem ingênua para o Problema do
Caixeiro-Viajante usando o framework de grafos do Capítulo 4.
2. Implemente um algoritmo genético, conforme descrito no Capítulo 5,
para resolver o Problema do Caixeiro-Viajante. Comece com o
conjunto de dados simples das cidades de Vermont, descrito neste
capítulo. Você consegue fazer com que o algoritmo genético chegue
na solução ótima em pouco tempo? Em seguida, tente resolver o
problema com um número cada vez maior de cidades. Até que ponto
o algoritmo genético consegue manter um bom desempenho? É
possível encontrar muitos conjuntos de dados especi camente criados
para o Problema do Caixeiro-Viajante pesquisando na internet.
Desenvolva um framework de testes para testar a e ciência de seu
método.
3. Use um dicionário com o programa de dados mnemônicos para
números de telefone e devolva apenas as permutações que contenham
palavras válidas do dicionário.

1 Analisei diversos conteúdos para escrever esta solução, entre os quais o de maior competência
foi o livro Algorithms (Addison-Wesley, 1988), 2ª edição, de Robert Sedgewick (p. 596). Vi
diversos exemplos do problema 0/1 da mochila no site Rosetta Code, com ênfase na solução
com programação dinâmica em Python (http://mng.bz/kx8C), da qual essa função, em sua
maior parte, foi portada, lá da versão do livro para Swift. (Ela passou de Python para Swift e de
volta novamente para Python.)
2 Robert Sedgewick, “Permutation Generation Methods” (Métodos para geração de
permutações) (Universidade de Princeton), http://mng.bz/87Te.
APÊNDICE A

Glossário

Este apêndice de ne um conjunto de termos essenciais usados no livro.


acíclico Um grafo sem ciclos (Capítulo 4).
algoritmo guloso (greedy) Um algoritmo que sempre seleciona a melhor
opção imediata em qualquer ponto de decisão, na esperança de que
essa opção levará à solução global ótima (Capítulo 4).
aprendizagem não supervisionada Qualquer técnica de aprendizado de
máquina que não utiliza conhecimento prévio para chegar às suas
conclusões – em outras palavras, uma técnica que não é guiada, mas
executa por conta própria (Capítulo 6).
aprendizagem profunda (deep learning) Espécie de palavra da moda, a
aprendizagem profunda pode se referir a qualquer uma das diversas
técnicas que usam algoritmos so sticados de aprendizado de máquina
para análise de big data. O mais comum é a aprendizagem profunda se
referir ao uso de redes neurais arti ciais de várias camadas para
solucionar problemas usando conjuntos grandes de dados (Capítulo 7).
aprendizagem supervisionada Qualquer técnica de aprendizado de
máquina na qual o algoritmo, de algum modo, é guiado em direção aos
resultados corretos usando recursos externos (Capítulo 7).
aresta Uma conexão entre dois vértices (nós) em um grafo (Capítulo 4).
árvore Um grafo que tem um único caminho entre dois vértices
quaisquer. Uma árvore é acíclica (Capítulo 4).
árvore geradora (spanning tree) Uma árvore que conecta todos os
vértices em um grafo (Capítulo 4).
árvore geradora mínima (minimum spanning tree) Uma árvore geradora
que conecta todos os vértices usando o peso total mínimo das arestas
(Capítulo 4).
auto-memoization Uma versão da memoização implementada no nível da
linguagem, na qual os resultados de chamadas de função sem efeitos
colaterais são armazenados para consultas em caso de haver futuras
chamadas idênticas (Capítulo 1).
backtracking Retornar para um ponto de decisão anterior (a m de tomar
uma direção diferente daquela percorrida antes) depois de atingir um
obstáculo em um problema de busca (Capítulo 3).
cadeia de bits Uma estrutura de dados que armazena uma sequência de
1s e 0s representados por um único bit de memória para cada um. Às
vezes, são chamadas de vetor de bits ou array de bits (Capítulo 1).
camada de entrada A primeira camada de uma rede neural arti cial
feedforward, que recebe sua entrada de alguma espécie de entidade
externa (Capítulo 7).
camada de saída A última camada de uma rede neural arti cial
feedforward, usada para determinar o resultado da rede, para uma dada
entrada e um dado problema (Capítulo 7).
camada oculta Qualquer camada entre a camada de entrada e a camada
de saída em uma rede neural arti cial feedforward (Capítulo 7).
caminho Um conjunto de arestas que conectam dois vértices em um
grafo (Capítulo 4).
centroide O ponto central em um cluster. Em geral, cada dimensão desse
ponto é a média dos demais pontos dessa dimensão (Capítulo 6).
ciclo Um caminho em um grafo que visita o mesmo vértice duas vezes,
sem backtracking (Capítulo 4).
cluster Veja clustering (agrupamento) (Capítulo 6).
clustering (agrupamento) Uma técnica de aprendizado não
supervisionado que divide um conjunto de dados em grupos de pontos
relacionados, conhecidos como clusters (Capítulo 6).
códon Uma combinação de três nucleotídeos que compõem um
aminoácido (Capítulo 2).
compactação Codi cação de dados (mudando o seu formato) para que
menos espaço seja necessário (Capítulo 1).
conectado Uma propriedade de um grafo que indica que há um caminho
de qualquer vértice para qualquer outro vértice (Capítulo 4).
cromossomos Em um algoritmo genético, cada indivíduo da população é
chamado de cromossomo (Capítulo 5).
crossover Em um algoritmo genético, consiste em combinar indivíduos
da população a m de criar descendentes que sejam uma mistura dos
pais, e que farão parte da próxima geração (Capítulo 5).
CSV Um formato de intercâmbio de texto no qual linhas de conjuntos de
dados têm seus valores separados por vírgulas, e as próprias linhas, em
geral, são separadas por caracteres de mudança de linha. CSV signi ca
Comma-Separated Values (Valores Separados por Vírgula). É um formato
de exportação comum em planilhas e bancos de dados (Capítulo 7).
delta Um valor que é representativo de uma diferença entre o valor
esperado de um peso em uma rede neural e seu valor real. O valor
esperado é determinado por meio do uso de dados de treinamento e de
retropropagação (backpropagation) (Capítulo 7).
descompactação Inverter o processo da compactação, devolvendo os
dados ao seu formato original (Capítulo 1).
digrafo Veja grafo direcionado (Capítulo 4).
domínio Os possíveis valores de uma variável em um problema de
satisfação de restrições (Capítulo 3).
escore z O número de desvios-padrões que separa um ponto de dados da
média de um conjunto de dados (Capítulo 6).
feedforward Um tipo de rede neural em que os sinais se propagam em
uma única direção (Capítulo 7).
la Uma estrutura de dados abstrata que garante a ordem FIFO (First-In-
First-Out, ou o primeiro que entra é o primeiro que sai). Uma
implementação de la oferece no mínimo as operações de inserção e de
remoção para adicionar e remover elementos, respectivamente (Capítulo
2).
la de prioridades Uma estrutura de dados que remove itens com base
na ordem de “prioridades”. Por exemplo, uma la de prioridades pode
ser usada em um conjunto de chamadas de emergência para que
chamadas de mais alta prioridade sejam respondidas antes (Capítulo 2).
função de aptidão ( tness function) Uma função que avalia a e cácia de
uma possível solução para um problema (Capítulo 5).
função de ativação Uma função que transforma a saída de um neurônio
em uma rede neural arti cial, em geral para deixá-lo capaz de lidar com
transformações não lineares ou garantir que seu valor de saída esteja
limitado a algum intervalo (Capítulo 7).
função recursiva Uma função que chama a si mesma (Capítulo 1).
função sigmoide Uma função de um conjunto popular de funções de
ativação usadas em redes neurais arti ciais. A função sigmoide
homônima sempre devolve um valor entre 0 e 1. É conveniente também
para garantir que resultados que não sejam apenas transformações
lineares sejam representados pela rede (Capítulo 7).
geração Uma rodada na avaliação de um algoritmo genético; também
usado para se referir à população de indivíduos ativos em uma rodada
(Capítulo 5).
gradiente descendente O método para modi car os pesos de uma rede
neural arti cial usando os deltas calculados durante a retropropagação
(backpropagation) e a taxa de aprendizagem (Capítulo 7).
grafo Uma construção matemática abstrata usada para modelar um
problema do mundo real por meio do qual esse problema é dividido em
um conjunto de nós conectados. Os nós são conhecidos como vértices,
e as conexões são as arestas (Capítulo 4).
grafo direcionado Também conhecido como digrafo, um grafo
direcionado é um grafo no qual as arestas só podem ser percorridas em
uma direção (Capítulo 4).
heurística Uma intuição sobre o modo de resolver um problema, a qual
aponta para a direção correta (Capítulo 2).
heurística admissível Uma heurística para o algoritmo de busca A* que
jamais superestima o custo para alcançar o objetivo (Capítulo 2).
instruções SIMD Instruções de microprocessador otimizadas para fazer
cálculos usando vetores; às vezes, são chamadas também de instruções
de vetor. SIMD quer dizer single instruction, multiple data, isto é, uma
instrução, vários dados (Capítulo 7).
loop in nito Um laço que nunca termina (Capítulo 1).
memoização Uma técnica na qual os resultados de tarefas de
processamento são armazenados para que sejam posteriormente
recuperados da memória, economizando tempo adicional de
processamento para recriar os mesmos resultados (Capítulo 1).
mutação Em um algoritmo genético, modi car aleatoriamente alguma
propriedade de um indivíduo antes que ele seja incluído na próxima
geração (Capítulo 5).
neurônio Uma célula nervosa individual, como aquelas que existem no
cérebro humano (Capítulo 7).
normalização O processo de deixar diferentes tipos de dados
comparáveis (Capítulo 6).
NP-difícil Um problema que pertence a uma classe de problemas para os
quais não há nenhum algoritmo com tempo polinomial para resolvê-los
(Capítulo 9).
nucleotídeo Uma das quatro bases do DNA: adenina (A), citosina (C),
guanina (G) e timina (T) (Capítulo 2).
ou exclusivo Veja XOR (Capítulo 1).
pilha Uma estrutura de dados abstrata que garante a ordem Last-In-First-
Out (LIFO). Uma implementação de pilha oferece no mínimo as
operações de push e pop para inserção e remoção de elementos,
respectivamente (Capítulo 2).
ply (nível) Um turno (com frequência, pode ser pensado como um
movimento) em um jogo para dois jogadores (Capítulo 8).
população Em um algoritmo genético, a população é o conjunto de
indivíduos (cada um representando uma possível solução para um
problema) que competem para resolver o problema (Capítulo 5).
programação dinâmica Em vez de resolver um problema grande usando
uma abordagem de força bruta, na programação dinâmica, o problema
é dividido em subproblemas menores, mais fáceis de administrar
(Capítulo 9).
programação genética Programas que modi cam a si mesmos usando
operadores de seleção, crossover e mutação a m de encontrar soluções
não óbvias para problemas de programação (Capítulo 5).
recursão in nita Um conjunto de chamadas recursivas que não termina,
mas continua fazendo chamadas recursivas adicionais. É análoga a um
loop in nito. Em geral, é causada pela falta de um caso de base
(Capítulo 1).
rede neural Uma rede com vários neurônios que atuam de forma
coordenada para processar informações. Em geral, podemos pensar que
os neurônios estão organizados em camadas (Capítulo 7).
rede neural arti cial Uma simulação de uma rede neural biológica
usando ferramentas de computação para resolver problemas que não
são facilmente reduzíveis a formas mais propícias às abordagens
algorítmicas tradicionais. Observe que o funcionamento de uma rede
neural arti cial em geral se distancia signi cativamente de sua
contrapartida biológica (Capítulo 7).
restrição Um requisito que deve ser obedecido para que um problema de
satisfação de restrições seja resolvido (Capítulo 3).
retropropagação Uma técnica usada para treinamento de pesos de redes
neurais, com base em um conjunto de entradas cujas saídas corretas são
conhecidas. Derivadas parciais são usadas para calcular a
“responsabilidade” de cada peso pelo erro entre os resultados reais e os
resultados esperados. Esses deltas são utilizados para atualizar os pesos
em futuras execuções (Capítulo 7).
seleção Processo de selecionar indivíduos de uma geração para
reprodução e criação de indivíduos para a próxima geração , em um
algoritmo genético (Capítulo 5).
seleção natural O processo evolucionário pelo qual organismos bem
adaptados são bem-sucedidos e os organismos mal adaptados falham.
Dado um conjunto limitado de recursos no ambiente, os organismos
mais bem adaptados para tirar proveito desses recursos sobreviverão e
se propagarão. Ao longo de várias gerações, isso resultará na propagação
de características úteis entre uma população, que será, portanto,
naturalmente selecionada como consequência das limitações do
ambiente (Capítulo 5).
sinapses Lacunas entre neurônios, nas quais neurotransmissores são
liberados para permitir a condução de corrente elétrica. No linguajar
leigo, são as conexões entre os neurônios (Capítulo 7).
taxa de aprendizagem Um valor, em geral uma constante, usada para
ajustar a taxa com que os pesos são modi cados em uma rede neural
arti cial, com base em deltas calculados (Capítulo 7).
treinamento Uma fase na qual uma rede neural arti cial tem seus pesos
ajustados por meio da retropropagação (backpropagation), usando
saídas que se sabem ser corretas para determinadas entradas (Capítulo
7).
variável No contexto de um problema de satisfação de restrições, uma
variável é um parâmetro que deve ser resolvido como parte da solução
do problema. Os possíveis valores de uma variável compõem o seu
domínio. Os requisitos para uma solução correspondem a uma ou mais
restrições (Capítulo 3).
vértice Um único nó em um grafo (Capítulo 4).
XOR Uma operação lógica bit a bit que devolverá true se um de seus
operandos for verdadeiro, mas não quando ambos ou nenhum deles for
verdadeiro. A abreviatura quer dizer eXclusive OR, isto é, ou exclusivo.
Em Python, ^ é usado para representar o operador XOR (Capítulo 1).
APÊNDICE B

Outros recursos

Qual deve ser o seu próximo passo? O livro abordou diversos tópicos, e
este apêndice fará a conexão entre você e outros recursos ótimos que o
ajudarão a explorá-los melhor.

B.1 Python
Conforme a rmamos na introdução, Problemas Clássicos de Ciência da
Computação com Python parte do pressuposto de que você tenha pelo
menos um conhecimento intermediário da linguagem Python. A seguir,
listarei dois livros sobre Python que, pessoalmente, tenho usado e
recomendo, para que você leve seu conhecimento de Python ao próximo
nível. Esses títulos não são apropriados para iniciantes em Python (para
isso, dê uma olhada no livro The Quick Python Book de Naomi Ceder
[Manning, 2018]), mas podem transformar usuários intermediários de
Python em usuários avançados.
• Luciano Ramalho, Python Fluente (Novatec, 2015)
• Um dos únicos livros populares sobre a linguagem Python que não
deixa indistinta a linha entre usuários iniciantes e
intermediários/avançados; este livro está claramente voltado para
programadores intermediários/avançados.
• Aborda diversos tópicos avançados sobre Python.
• Apresenta as melhores práticas; é o livro que ensinará você a
escrever um código “pythônico”.
• Contém vários exemplos de código para cada assunto e explica o
funcionamento interno da biblioteca-padrão de Python.
• Pode ser um pouco extenso em algumas partes, mas você pode
facilmente as ignorar.
• David Beazley e Brian K. Jones, Python Cookbook, 3ª edição (O’Reilly,
2013)1
• Apresenta tarefas comuns do cotidiano por meio de exemplos.
• Algumas das tarefas estão muito além das tarefas para iniciantes.
• Faz uso intenso da biblioteca-padrão de Python.
• Está um pouco desatualizado (não inclui as ferramentas mais
recentes da biblioteca-padrão) por ter sido lançado há mais de cinco
anos; espero que a quarta edição seja lançada logo.

B.2 Algoritmos e estruturas de dados


Para citar a a rmação que está na introdução deste livro, “Este não é um
livro didático sobre estruturas de dados e algoritmos”. Há pouco uso da
notação big-O no livro, e não há provas matemáticas. O livro está mais
para um tutorial prático para técnicas importantes de programação, mas é
importante ter um livro didático de verdade também. Ele não só oferecerá
uma explicação mais formal sobre o motivo pelo qual certas técnicas
funcionam como também servirá como uma obra de referência útil.
Conteúdos online são ótimos, mas, às vezes, é bom ter informações que
tenham sido meticulosamente analisadas por acadêmicos e editoras.
• Thomas Cormen, Charles Leiserson, Ronald Rivest e Cli ord Stein,
Introduction to Algorithms, 3ª edição (MIT Press, 2009)2,
https://mitpress.mit.edu/books/introduction-algorithms-third-edition.
• Este é um dos textos mais citados em ciência da computação – tão
consagrado que, com frequência, é referenciado apenas pelas
iniciais de seus autores: CLRS.
• É abrangente e rigoroso em sua abordagem.
• Seu estilo de ensino às vezes é visto como menos acessível em
comparação com outros textos, mas continua sendo uma excelente
referência.
• Pseudocódigos são disponibilizados para a maioria dos algoritmos.
• Uma quarta edição está em desenvolvimento, e, como esse livro é
caro, pode valer a pena veri car quando a quarta edição deverá ser
lançada.
• Robert Sedgewick e Kevin Wayne, Algorithms, 4ª edição (Addison-
Wesley Professional, 2011), http://algs4.cs.princeton.edu/home/.
• Uma introdução acessível, ainda que abrangente, para algoritmos e
estruturas de dados.
• Bem organizado, com exemplos completos de todos os algoritmos
em Java.
• Popular nos cursos de algoritmos nas universidades.
• Steven Skiena, The Algorithm Design Manual, 2ª edição (Springer,
2011), http://www.algorist.com.
• Diferente de outros livros dessa área quanto à sua abordagem.
• Apresenta menos código e mais discussões descritivas dos usos
apropriados para cada algoritmo.
• Fornece um guia do tipo “escolha a sua própria aventura” para uma
grande variedade de algoritmos.
• Aditya Bhargava, Grokking Algorithms (Manning, 2016)3,
https://www.manning.com/books/grokking-algorithms.
• Uma abordagem grá ca para apresentar algoritmos básicos, com
ilustrações simpáticas, como introdução.
• Não é um livro para referência, mas um guia para conhecer alguns
assuntos básicos selecionados.

B.3 Inteligência arti cial


A inteligência arti cial está mudando o nosso mundo. Neste livro, você
não só foi introduzido a algumas técnicas tradicionais de inteligência
arti cial, como o A* e o minimax, como também a técnicas de sua
subárea empolgante, o aprendizado de máquina, como k-means e redes
neurais. Conhecer melhor a inteligência arti cial não só é interessante
como também garantirá que você esteja preparado para a próxima onda
da computação.
• Stuart Russell e Peter Norvig, Arti cial Intelligence: A Modern
Approach, 3ª edição (Pearson, 2009)4, http://aima.cs.berkeley.edu
• O livro consagrado sobre IA, usado com frequência em cursos
universitários.
• Abrangente em sua abordagem.
• Repositórios de código-fonte excelentes (versões implementadas dos
pseudocódigos que estão no livro), disponíveis online.
• Stephen Lucci e Danny Kopec, Arti cial Intelligence in the 21st Century,
2ª edição (Mercury Learning and Information, 2015),
http://mng.bz/1N46.
• Um livro acessível para aqueles que procuram um guia mais prático
e diversi cado que o livro de Russell e Norvig.
• Pequenas descrições interessantes sobre os pro ssionais da área e
muitas referências a aplicações no mundo real.
• Andrew Ng, curso de “Machine Learning” (Universidade de Stanford),
https://www.coursera.org/learn/machine-learning/.
• Um curso online gratuito que inclui vários dos algoritmos básicos
para aprendizado de máquina.
• Apresentado por um especialista mundialmente renomado.
• Com frequência, indicado pelos pro ssionais como um ótimo
ponto de partida para a área.

B.4 Programação funcional


Python pode ser programado em um estilo funcional, mas não foi
exatamente projetado para isso. Explorar o alcance da programação
funcional é possível com o próprio Python, mas também pode ser
conveniente trabalhar com uma linguagem puramente funcional, e então
trazer algumas das ideias aprendidas com essa experiência, de volta para
Python.
• Harold Abelson e Gerald Jay Sussman com Julie Sussman, Structure
and Interpretation of Computer Programs (MIT Press, 1996),
https://mitpress.mit.edu/ sicp/.
• Uma introdução clássica à programação funcional, muitas vezes
usada em disciplinas introdutórias de ciência da computação nas
universidades.
• Utiliza Scheme para ensinar – uma linguagem puramente funcional,
fácil de entender.
• Disponível online gratuitamente.
• Aslam Khan, Grokking Functional Programming (Manning, 2018),
https://www.manning.com/books/grokking-functional-programming.
• Uma introdução ilustrada e simpática à programação funcional.
• David Mertz, Functional Programming in Python (O’Reilly, 2015),
https://www.oreilly.com/programming/free/functional-programming-
python.csp.
• Apresenta uma introdução básica a alguns utilitários para
programação funcional da biblioteca-padrão de Python.
• É gratuito.
• Tem apenas 37 páginas – não é muito abrangente, mas é uma
introdução rápida.

B.5 Projetos de código aberto convenientes para aprendizado de


máquina
Há várias bibliotecas Python de terceiros, convenientes e otimizadas para
ter alto desempenho em aprendizado de máquina. Dois desses projetos
foram mencionados no Capítulo 7. Esses projetos oferecem mais recursos
e utilitários do que provavelmente você poderá desenvolver por conta
própria. Em aplicações sérias de aprendizado de máquina ou big data,
utilize essas bibliotecas (ou suas equivalentes).
• NumPy, http://www.numpy.org.
• A biblioteca numérica Python que é padrão de mercado.
• Implementada, em sua maior parte, em C, para ter melhor
desempenho.
• Está na base de muitas bibliotecas Python de aprendizado de
máquina, incluindo TensorFlow e scikit-learn.
• TensorFlow, https://www.tensor ow.org.
• Uma das bibliotecas Python mais populares para trabalhar com
redes neurais.
• pandas, https://pandas.pydata.org.
• Biblioteca popular para importar conjuntos de dados para Python e
manipulá-los.
• scikit-learn, http://scikit-learn.org/stable/.
• Versões bem testadas e completas de vários algoritmos de
aprendizado de máquina apresentados neste livro (e muito, muito
mais).

1 N.T.: Edição publicada no Brasil: Python Cookbook (Novatec, 2013).


2 N.T.: Edição publicada no Brasil: Algoritmos (Campus, 2012).
3 N.T.: Edição publicada no Brasil: Entendendo algoritmos (Novatec, 2017).
4 N.T.: Edição publicada no Brasil: Inteligência arti cial (Campus, 2013).
APÊNDICE C

Introdução rápida às dicas de tipo

Python introduziu as dicas de tipo (type hints), ou anotações de tipo,


como parte o cial da linguagem por meio da PEP 484 e da versão 3.5 de
Python. Desde então, as dicas de tipo têm se tornado mais comuns em
várias bases de código Python, e a linguagem tem acrescentado um
suporte mais robusto a elas. As dicas de tipo foram usadas em todas as
listagens de código deste livro. Neste breve apêndice, meu objetivo é
apresentar uma introdução às dicas de tipo, explicar por que elas são
úteis, apresentar alguns de seus problemas e fornecer referências para
conteúdos mais detalhados sobre elas.
AVISO Este apêndice não tem o intuito de ser completo. Na verdade, é uma introdução
rápida. Consulte a documentação o cial de
Python para ver os detalhes:
https://docs.python.org/3/library/typing.html.

C.1 O que são dicas de tipo?


Dicas de tipo são um modo de incluir anotações sobre os tipos esperados
de variáveis, parâmetros de função e tipos de retorno de funções em
Python. Em outras palavras, é um modo de um programador informar
qual é o tipo esperado em determinada parte de um programa Python. A
maioria dos programas Python é escrita sem dicas de tipo. Na verdade,
até ler este livro, mesmo sendo um programador Python de nível
intermediário, é bem possível que você jamais tenha visto um programa
Python com dicas de tipo.
Como Python não exige que o programador especi que o tipo de uma
variável, a única maneira de descobrir o tipo de uma variável sem dicas de
tipo é por meio de uma inspeção (literalmente, ler o código-fonte até o
ponto em questão ou executá-lo e exibir o tipo) ou uma consulta à
documentação. Isso é problemático, pois deixa o código Python mais
difícil de ler (embora algumas pessoas diriam o contrário, mas falaremos
disso mais adiante neste apêndice). Outro problema é que, por ser muito
exível, Python permite ao programador utilizar a mesma variável para
referenciar vários tipos de objetos, o que pode resultar em erros. As dicas
de tipo podem ajudar a evitar esse tipo de programação e reduzir esses
erros.
Agora que Python tem as dicas de tipo, podemos chamá-lo de
linguagem gradualmente tipada – isso signi ca que você pode usar
anotações de tipo quando quiser, mas elas não são obrigatórias. Nesta
rápida introdução, espero convencer você (apesar de sua possível
resistência pelo fato de elas mudarem essencialmente a aparência da
linguagem) de que ter dicas de tipo disponíveis é uma boa opção – uma
boa opção, da qual você deverá tirar proveito em seu código.

C.2 Como é a aparência das dicas de tipo?


As dicas de tipo são acrescentadas em uma linha de código na qual uma
variável ou função é declarada. Dois pontos (:) são usados para sinalizar
o início de uma dica de tipo para uma variável ou parâmetro de função, e
uma seta (->) sinaliza o início de uma dica de tipo para o tipo de retorno
de uma função. Por exemplo, considere a linha de código Python a
seguir:
def repeat(item, times):
Sem ler a de nição da função, você é capaz de dizer o que essa função
deveria fazer? Ela deveria exibir uma string um certo número de vezes?
Deveria fazer outra coisa? É claro que poderíamos ler a de nição da
função para descobrir o que ela deve fazer, mas isso exigiria mais tempo.
O autor dessa função, infelizmente, também não forneceu nenhuma
documentação. Vamos tentar de novo, usando dicas de tipo:
def repeat(item: Any, times: int) -> List[Any]:

Está muito mais claro agora. Apenas olhando as dicas de tipo, parece que
essa função aceita um item do tipo Any e devolve uma List preenchida
com esse item um número de vezes igual a times. É claro que a
documentação ajudaria a deixar essa função mais compreensível, mas, no
mínimo, o usuário dessa biblioteca sabe agora qual tipo de valores deve
fornecer e qual tipo de valor pode-se esperar que seja devolvido.
Suponha que a biblioteca com a qual essa função deveria ser usada
funcionasse apenas com números de ponto utuante, e essa função
tivesse sido criada para ser aplicada na preparação de listas a serem
utilizadas por outras funções. Podemos facilmente modi car as dicas de
tipo a m de informar a restrição sobre números de ponto utuante:
def repeat(item: float, times: int) -> List[float]:

Agora está claro que item deve ser um float, e que a lista devolvida estará
preenchida com floats. Bem, a palavra deve é bem forte. As dicas de tipo,
conforme tratadas na versão Python 3.7, não têm nenhuma função na
execução de um programa Python. Elas são de fato apenas dicas, e não
imposições. Em tempo de execução, um programa Python pode ignorar
totalmente suas dicas de tipo e violar qualquer uma de suas supostas
restrições. No entanto, ferramentas para veri cação de tipos (type
checkers) podem avaliar as dicas de tipo em um programa durante o
desenvolvimento, e informar o programador caso haja alguma chamada
ilegítima a uma função. Uma chamada para repeat("hello", 30) poderia
ser identi cada antes que fosse introduzida em um ambiente de produção
(porque "hello" não é um float).
Vamos ver outro exemplo. Dessa vez, analisaremos uma dica de tipo
para uma declaração de variável:
myStrs: List[str] = repeat(4.2, 2)

Essa dica de tipo não faz sentido. Ela diz que esperamos que myStrs seja
uma lista de strings. Contudo, sabemos, com base na dica de tipo
anterior, que repeat() devolve uma lista de números de ponto utuante.
Novamente, como Python, na versão 3.7, não faz nenhuma veri cação
para saber se as dicas de tipo estão corretas durante a execução, essa dica
de tipo incorreta não terá efeito algum na execução do programa.
Entretanto, um veri cador de tipos poderia identi car o erro ou a
concepção equivocada desse programador acerca do tipo correto, antes
que houvesse um desastre.
C.3 Por que as dicas de tipo são úteis?
Agora que você já sabe o que são as dicas de tipo, poderá estar se
perguntando por que todo esse trabalho compensaria. A nal de contas,
você também viu que as dicas de tipo são ignoradas por Python em
tempo de execução. Por que alguém gastaria todo esse tempo
acrescentando anotações de tipos no código, se o interpretador Python
não vai se importar? Como já mencionamos rapidamente, as dicas de tipo
são uma boa ideia por dois motivos principais: fornecem uma
documentação automática para o código e permitem que um veri cador
de tipos con ra se um programa está correto antes que seja executado.
Na maioria das linguagens de programação com tipagem estática (como
Java e Haskell), declarações de tipo obrigatórias deixam muito claro quais
parâmetros uma função (ou método) espera e qual é o tipo que ela
devolverá. Isso reduz um pouco o fardo da documentação para o
programador. Por exemplo, é totalmente desnecessário especi car o que o
método Java a seguir espera como parâmetros ou como tipo de retorno:
/* Consome world, devolvendo a quantidade monetária gerada como resultado.
*/
public float eatWorld(World w, Software s) { … }
Compare isso com a documentação necessária para o método equivalente
escrito em Python tradicional, sem dicas de tipo:
# Consome world
# Parameters:
# w – World a ser consumido
# s – Software com o qual World será consumido
# Returns:
# Quantidade monetária gerada ao consumir world como um número de ponto
flutuante
def eat_world(w, s):
Ao permitir que documentemos automaticamente o nosso código, as
dicas de tipo deixam a documentação Python tão sucinta quanto a
documentação das linguagens estaticamente tipadas:
/* Consome world, devolvendo a quantidade monetária gerada como resultado.
def eat_world(w: World, s: Software) -> float:

Considere um caso extremo. Suponha que você tenha herdado uma base
de código que não tenha nenhum comentário. Seria mais fácil entender
uma base de código sem comentários com ou sem dicas de tipo? As dicas
de tipo evitarão que você precise explorar o código de uma função sem
comentários para entender quais tipos devem ser passados para ela como
parâmetros, e qual é o tipo que se espera que ela devolva.
Lembre-se de que uma dica de tipo é, basicamente, um modo de dizer
qual é o tipo esperado em um ponto de um programa. Contudo, Python
não faz nada para conferir essas expectativas. É aí que um veri cador de
tipos entra em cena. Um veri cador de tipos pode tomar um arquivo com
código-fonte Python escrito com dicas de tipo e conferir se elas de fato
serão obedecidas quando o programa for executado.
Há vários tipos diferentes de veri cadores de tipos para dicas de tipo
em Python. Por exemplo, o popular PyCharm do IDE de Python tem um
veri cador de tipos incluído. Se você editar um programa com dicas de
tipo no PyCharm, ele apontará automaticamente os erros de tipo. Isso
ajudará você a identi car seus erros antes mesmo de terminar de escrever
uma função.
O principal veri cador de tipos Python atualmente, na ocasião em este
livro foi escrito, é o mypy. O projeto mypy é liderado por Guido van
Rossum – a mesma pessoa que criou originalmente o próprio Python.
Isso deixa alguma dúvida em sua mente de que as dicas de tipo podem
ter, potencialmente, um papel muito proeminente no futuro de Python?
Depois de instalar o mypy, utilizá-lo é muito simples e basta executar mypy
example.py , em que example.py é o nome do arquivo no qual você quer
fazer a veri cação de tipos. O mypy exibirá todos os erros de tipo de seu
programa no console, ou não exibirá nada se não houver erros.
É possível que haja outros modos pelos quais as dicas de tipo serão
úteis no futuro. No momento, as dicas de tipo não causam nenhum
impacto na execução de um programa Python. (Para reiterar uma última
vez, elas são ignoradas em tempo de execução.) Contudo, é possível que
futuras versões de Python venham a utilizar as informações de tipo das
dicas para fazer otimizações. Em um mundo como esse, talvez você seja
capaz de agilizar a execução de seu programa Python apenas
acrescentando dicas de tipo. É claro que isso é pura especulação. Não sei
de nenhum plano para implementar otimizações com base em dicas de
tipo em Python.

C.4 Quais são as desvantagens das dicas de tipo?


Há três desvantagens em potencial para o uso das dicas de tipo:
• Códigos com dicas de tipo exigem mais tempo para ser escritos em
comparação com códigos sem dicas de tipo.
• As dicas de tipo, sem dúvida, deixam o código menos legível, em
alguns casos.
• As dicas de tipo ainda não estão totalmente amadurecidas, e
implementar algumas restrições de tipos com as implementações
atuais de Python pode ser confuso.
Um código com dicas de tipo exige mais tempo para ser escrito por dois
motivos: há simplesmente mais digitação (literalmente, mais teclas para
pressionar no teclado), e você terá de pensar mais em seu código. Pensar
no código quase sempre é bom, mas pensar demasiadamente poderá
causar atrasos. No entanto, você deverá compensar esse tempo perdido
ao identi car erros com um veri cador de tipos, antes mesmo que seu
programa venha a executar. O tempo gasto depurando erros que
poderiam ter sido identi cados por um veri cador de tipos
provavelmente será maior que o tempo gasto pensando nos tipos durante
a escrita do código em qualquer base de código complexa.
Algumas pessoas acham que um código Python com dicas de tipo é
menos legível que um código Python sem elas. Os dois motivos para isso
provavelmente são falta de familiaridade e verbosidade. Qualquer sintaxe
com a qual você não tenha familiaridade será menos legível que uma
sintaxe que você conheça. As dicas de tipo de fato alteram a aparência de
programas Python, deixando-os possivelmente menos reconhecíveis à
primeira vista. Isso só poderá ser atenuado se você escrever e ler mais
código Python com dicas de tipo. O segundo problema, a verbosidade, é
mais básico. Python é famoso por sua sintaxe concisa. Com frequência, o
mesmo programa em Python é signi cativamente menor do que seu
equivalente em outra linguagem. Um código Python com dicas de tipo
não é compacto. Ele não poderá ser analisado com tanta rapidez por
meio de uma inspeção visual direta; simplesmente, há muito código para
ver. O custo-benefício está no fato de que haverá uma melhor
compreensão do código após a primeira leitura, apesar de essa leitura ser
mais demorada. Com as dicas de tipo, você verá imediatamente todos os
tipos esperados, o que é melhor do que ter de analisar o código para
saber quais são os tipos, ou ter de ler a documentação.
Por m, as dicas de tipo ainda estão em desenvolvimento. Sem dúvida,
houve melhorias desde a sua introdução em Python 3.5, mas ainda há
casos extremos em que elas não funcionam bem. Um exemplo disso está
no Capítulo 2. O tipo Protocol, que, em geral, é uma parte importante de
um sistema de tipos, ainda não está no módulo typing da biblioteca-
padrão de Pyhton, portanto foi necessário incluir o módulo de terceiros
typing_extensions no Capítulo 2. Não há planos para incluir Protocol em
uma versão futura da biblioteca-padrão o cial de Python, mas o fato de
não estar incluído é um testemunho de que ainda estamos no início da
era das dicas de tipo em Python. Durante a escrita deste livro, eu deparei
com vários casos extremos como esse, complicados para resolver,
considerando as primitivas existentes, disponíveis na biblioteca-padrão.
Como as dicas de tipo não são obrigatórias em Python, atualmente, não
há problemas em apenas ignorá-las nas áreas em que elas forem
inconvenientes para usar. Você ainda terá algumas vantagens, mesmo que
use as dicas de tipo apenas parcialmente.

C.5 Obtendo mais informações


Todos os capítulos deste livro estão repletos de exemplos de dicas de tipo,
mas ele não é um tutorial sobre como usá-las. O melhor lugar para uma
introdução às dicas de tipo é a documentação o cial de Python para o
módulo typing (https://docs.python.org/3/library/typing.html). Essa
documentação explica não só todos os diferentes tipos embutidos
disponíveis, mas também como usá-los em vários cenários so sticados,
que estão além do escopo desta introdução rápida.
O outro recurso para saber mais sobre dicas de tipo, que você deve
realmente consultar, é o projeto mypy (http://mypy-lang.org). O mypy é o
principal veri cador de tipos para Python. Em outras palavras, é o
software que você usará para de fato veri car a validade de suas dicas de
tipo. Além de instalá-lo e usá-lo, você também deve consultar a
documentação do mypy (https://mypy.readthedocs.io/). A documentação é
detalhada e explica como usar as dicas de tipo em alguns cenários não
incluídos na documentação da biblioteca-padrão. Por exemplo, uma área
particularmente confusa é a área de genéricos (generics). A
documentação do mypy sobre genéricos é um bom ponto de partida.
Outro recurso interessante é o “type hints cheat sheet” (folha de “cola”
para dicas de tipo), disponibilizada pelo mypy
(https://mypy.readthedocs.io/en/stable/cheat_sheet_py3.html).
Python para análise de dados
McKinney, Wes
9788575227510
616 p�ginas

Compre agora e leia

Obtenha instruções completas para manipular, processar, limpar e


extrair informações de conjuntos de dados em Python. Atualizada
para Python 3.6, este guia prático está repleto de casos de estudo
práticos que mostram como resolver um amplo conjunto de
problemas de análise de dados de forma eficiente. Você conhecerá
as versões mais recentes do pandas, da NumPy, do IPython e do
Jupyter no processo. Escrito por Wes McKinney, criador do projeto
Python pandas, este livro contém uma introdução prática e moderna
às ferramentas de ciência de dados em Python. É ideal para
analistas, para quem Python é uma novidade, e para programadores
Python iniciantes nas áreas de ciência de dados e processamento
científico. Os arquivos de dados e os materiais relacionados ao livro
estão disponíveis no GitHub. • utilize o shell IPython e o Jupyter
Notebook para processamentos exploratórios; • conheça os recursos
básicos e avançados da NumPy (Numerical Python); • comece a
trabalhar com ferramentas de análise de dados da biblioteca pandas;
• utilize ferramentas flexíveis para carregar, limpar, transformar,
combinar e reformatar dados; • crie visualizações informativas com a
matplotlib; • aplique o recurso groupby do pandas para processar e
sintetizar conjuntos de dados; • analise e manipule dados de séries
temporais regulares e irregulares; • aprenda a resolver problemas de
análise de dados do mundo real com exemplos completos e
detalhados.

Compre agora e leia


Manual de Análise Técnica
Abe, Marcos
9788575227022
256 p�ginas

Compre agora e leia

Este livro aborda o tema Investimento em Ações de maneira inédita e


tem o objetivo de ensinar os investidores a lucrarem nas mais
diversas condições do mercado, inclusive em tempos de crise.
Ensinará ao leitor que, para ganhar dinheiro, não importa se o
mercado está em alta ou em baixa, mas sim saber como operar em
cada situação. Com o Manual de Análise Técnica o leitor aprenderá:
- os conceitos clássicos da Análise Técnica de forma diferenciada,
de maneira que assimile não só os princípios, mas que desenvolva o
raciocínio necessário para utilizar os gráficos como meio de
interpretar os movimentos da massa de investidores do mercado; -
identificar oportunidades para lucrar na bolsa de valores, a longo e
curto prazo, até mesmo em mercados baixistas; um sistema de
investimentos completo com estratégias para abrir, conduzir e fechar
operações, de forma que seja possível maximizar lucros e minimizar
prejuízos; - estruturar e proteger operações por meio do
gerenciamento de capital. Destina-se a iniciantes na bolsa de valores
e investidores que ainda não desenvolveram uma metodologia
própria para operar lucrativamente.

Compre agora e leia


Avaliando Empresas, Investindo em
Ações
Debastiani, Carlos Alberto
9788575225974
224 p�ginas

Compre agora e leia

Avaliando Empresas, Investindo em Ações é um livro destinado a


investidores que desejam conhecer, em detalhes, os métodos de
análise que integram a linha de trabalho da escola fundamentalista,
trazendo ao leitor, em linguagem clara e acessível, o conhecimento
profundo dos elementos necessários a uma análise criteriosa da
saúde financeira das empresas, envolvendo indicadores de balanço e
de mercado, análise de liquidez e dos riscos pertinentes a fatores
setoriais e conjunturas econômicas nacional e internacional. Por meio
de exemplos práticos e ilustrações, os autores exercitam os
conceitos teóricos abordados, desde os fundamentos básicos da
economia até a formulação de estratégias para investimentos de
longo prazo.

Compre agora e leia


Microsserviços prontos para a produção
Fowler, Susan J.
9788575227473
224 p�ginas

Compre agora e leia

Um dos maiores desafios para as empresas que adotaram a


arquitetura de microsserviços é a falta de padronização de
arquitetura – operacional e organizacional. Depois de dividir uma
aplicação monolítica ou construir um ecossistema de microsserviços
a partir do zero, muitos engenheiros se perguntam o que vem a
seguir. Neste livro prático, a autora Susan Fowler apresenta com
profundidade um conjunto de padrões de microsserviço, aproveitando
sua experiência de padronização de mais de mil microsserviços do
Uber. Você aprenderá a projetar microsserviços que são estáveis,
confiáveis, escaláveis, tolerantes a falhas, de alto desempenho,
monitorados, documentados e preparados para qualquer catástrofe.
Explore os padrões de disponibilidade de produção, incluindo:
Estabilidade e confiabilidade – desenvolva, implante, introduza e
descontinue microsserviços; proteja-se contra falhas de
dependência. Escalabilidade e desempenho – conheça os
componentes essenciais para alcançar mais eficiência do
microsserviço. Tolerância a falhas e prontidão para catástrofes –
garanta a disponibilidade forçando ativamente os microsserviços a
falhar em tempo real. Monitoramento – aprenda como monitorar,
gravar logs e exibir as principais métricas; estabeleça procedimentos
de alerta e de prontidão. Documentação e compreensão – atenue os
efeitos negativos das contrapartidas que acompanham a adoção dos
microsserviços, incluindo a dispersão organizacional e a defasagem
técnica.

Compre agora e leia


Fundos de Investimento Imobiliário
Mendes, Roni Antônio
9788575226766
256 p�ginas

Compre agora e leia

Você sabia que o investimento em imóveis é um dos preferidos dos


brasileiros? Você também gostaria de investir em imóveis, mas tem
pouco dinheiro? Saiba que é possível, mesmo com poucos recursos,
investir no mercado de imóveis por meio dos Fundos de Investimento
Imobiliário (FIIs). Investir em FIIs representa uma excelente
alternativa para aumentar o patrimônio no longo prazo. Além disso,
eles são ótimos ativos geradores de renda que pode ser usada para
complementar a aposentadoria. Infelizmente, no Brasil, os FIIs são
pouco conhecidos. Pouco mais de 100 mil pessoas investem nesses
ativos. Lendo este livro, você aprenderá os aspectos gerais dos FIIs:
o que são; as vantagens que oferecem; os riscos que possuem; os
diversos tipos de FIIs que existem no mercado e como proceder
para investir bem e com segurança. Você também aprenderá os
princípios básicos para avaliá-los, inclusive empregando um método
poderoso, utilizado por investidores do mundo inteiro: o método do
Fluxo de Caixa Descontado (FCD). Alguns exemplos reais de FIIs
foram estudados neste livro e os resultados são apresentados de
maneira clara e didática, para que você aprenda a conduzir os
próprios estudos e tirar as próprias conclusões. Também são
apresentados conceitos gerais de como montar e gerenciar uma
carteira de investimentos. Aprenda a investir em FIIs. Leia este livro.

Compre agora e leia

Você também pode gostar