Vous êtes sur la page 1sur 206

Introdução

Os computadores são máquinas incríveis e, de fato, podemos ver aplicações das mais
diversas possíveis. Por exemplo, ele pode te ajudar a fazer contas simples aritméticas,
mas de maneira muito mais rápida do que feito no papel, podem simular processos
químicos, sintetizar voz e traduzir documentos, sequenciar problemas genômicos,
resolver problemas de trânsito, e uma outra incontável variedade de aplicações!

O que é surpreendente que tudo que o computador faz é manipular um conjunto (muito
grande) de chaves. Essas chaves são chamadas de bits. Um bit é um valor armazenado
que pode estar em dois estados, 0 ou 1. Tipicamente, nos computadores de hoje, esses
estados são determinados por características elétricas, como carga positiva, ou negativa,
etc.

Na verdade, embora utilizemos os computadores para coisas incríveis como as listadas


acima, ele é projetado para executar tarefas bem simples, como inverta esse bit, faça um
bit virar 1, ou realizar ações dependendo do valor do bit, como se o bit for 1, modifique
o próximo bit, etc.

Os computadores podem ser muito diferentes, tanto na forma (um PC, um celular, um
chip automotivo...), quanto na representação interna. Mas todos eles são baseado nos
mesmos princípios gerais, sempre temos esse espírito de um conjunto de "botões" ou
"manivelas" que manipulam os bits subjacentes.

Entendendo os termos
Para entender alguns termos importantes, vamos apelas para uma analogia
gastronômica. Imagine que queremos fazer um bolo. Então vamos ter que considerar
diversos elementos:

 Os ingrediente são as entradas do processo.


 O bolo é a saída.
 A receita do bolo que lista as atividades que serem executadas é o algoritmo.

Ainda podemos distinguir duas partes importantes nesse processo

 As receitas, ou algoritmos, correspondem ao que chamamos de software.


 Já os utensílios utilizados (panela, forno, talheres e mesmo o cozinheiro)
correspondem ao hardware.

Algoritmos e Computação
Aqui temos que diferenciar algoritmos de Computação. Algoritmos são os texto
propriamente dito; já a teoria dos Algoritmos é a área da computação ue estuda os
algoritmos.

Podemos pensar numa analogia com Literatura: enquanto os objetos de estudo dessa
disciplina são os poemas, contos, romances, etc., a LIteratura é a ciência que estuda a
estrutura, a forma, o modo, o tamanho, o ritmo desse conteúdo. Da mesma forma, na
teoria dos algoritmos, queremos estudar a estrutura, a forma, o modo, a velocidade, as
limitações, etc. dos algoritmos.

É preciso entender e diferenciar também o nosso papel nesse contexto. Enquanto nós
queremos resolver problemas, o computador é um mero instrumento de trabalho. Uma
frase bem conhecida que descreve esse sentimento, de origem desconhecida, é a
seguinte:

Ciência da Computação é tanto sobre computadores, quanto Astronomia é sobre


telescópios.

Isso é particularmente crítico quando lemos os termos em inglês, em que Ciência da


Computação e Engenharia da Computação são traduzidos como "Computer Science" e
"Computer Engineering". É bem verdade que nós vamos aprender no decorrer do curso
como funciona um computador em um nível muito grande de detalhes e muitos podem
até projetar a arquitetura de um computador. Mas é importante saber — e explicar! —
que quando falamos de Computação estamos falando desde as simples contas de uma
planilha de gastos no seu computador, até os enormes genomas calculados por
computadores, aos diversos problemas de logística gigantescos da indústria, aos
incríveis e surpreendentes avanços do reconhecimento de padrões, até as respostas a
perguntas fundamentais matemáticas que atiçam a curiosidade humana.

Um pouco de história
Um dos primeiros algoritmos não triviais é o chamado algoritmo de Euclides, que vocês
já devem ter estudado. Ele foi escrito provavelmente entre 400 e 300 a.C. pelo
matemático grego Euclides (que talvez o tenha inventando ou apenas formalizado um
algoritmo conhecido). Esse algoritmo server para encontrar o maior divisor comum
(MDC) entre dois números inteiros positivos. Por exemplo, o MDC de 80 e 32 é 16

A palavra "algoritmo" é derivada do nome do matemático persa Mohammed al-


Khowârizmı̂ , do século nono, a quem são atribuídos os algoritmos de adição, subtração,
multiplicação e divisão com números decimais. Aqueles que aprendemos nas primeiras
séries da escola.

Uma das primeiras máquinas automáticas que poderiam ser controladas, ou


digamos, programadas foram máquinas de tecer criadas pelo francês Joseph Jacquard in
1801 A forma dos padrões dos tecidos era determinada por cartões perfurados em vários
locais!

Uma das primeiras máquinas que fizeram computação numérica foram as máquinas
diferenciais, de Charles Babbage em 1833. Assim como as máquinas de Jacquard, a
máquina de Babbage era de natureza mecânica, baseada em alavancas, rodas dentadas e
engrenagens, ao invés eletrônicos e silício como os computadores de hoje.

Ada Byron, condessa de Lovelace, foi programadora de Babbage. Ela é uma das figuras
mais interessantes da história da Computação e é creditada por lançar as bases da
programação, mais de cem anos antes do primeiro computador em funcionamento estar
disponível.
No entanto, os primeiros computadores de uso geral foram construídos apenas na
década de 1940, em parte, como resposta às necessidades computacionais de físicos e
astrônomos e, em parte, como resultado natural da disponibilidade dos dispositivos
eletromecânicos e eletrônicos apropriados. Alguns nomes de destaque nessa evolução
são o inglês Alan Turing, os americanos Howard Aiken, John Mauchly, J. Presper
Eckert, and Herman Goldstine, e o famoso matemático americano e alemão John von
Neumann.

A figura a seguir é um computador ENIAC desenvolvido por Mauchly, Eckert e


Goldstine:

Com relação à teria dos algoritmos, a década de 1930 experienciou uma rápida
disseminação de conhecimento. Mesmo antes de haver máquinas viáveis (que só viriam
anos mais tarde), várias matemáticos criaram as bases fundamentais da Computação!
Algumas figuras-chaves são o inglês Alan Turing, o britânico Kurt Gödel, o russo
Andreı̆ A. Markov e os americanos Alonzo Church, Emil Post e Stephen Kleene.

Depois disso, houve uma enormidade de descobertas e evolução, que acabaram por
culminar na definição formal de um curso de computação por volta de meados de 1960,
criando-se vários curso de Ciência da Computação em várias universidades americanas.

Alguns links interessantes que vocês podem ver:

 https://www.livescience.com/20718-computer-history.html
 https://www.computerhistory.org/timeline/computers/

Arquitetura de von Neumann


A maioria dos computadores modernos são organizados seguindo a arquitetura proposta
por Von Neumann, que reúne os seguintes componentes:

A processing unit that contains an arithmetic logic unit and processor registers A control
unit that contains an instruction register and program counter Input and output
mechanisms[1][2]

 Uma memória que guarda dados e instruções


 Uma memória externa que guarda grandes quantidades de dados
permanentemente
 Uma unidade central de processamento, composta por diversos registradores e
que realiza operações aritméticas e lógicas
 Uma unidade de controle, cuja função é buscar um programa na memória,
instrução por instrução, e executá-lo sobre os dados de entrada.

Atualmente, a arquitetura de von Neumann refere-se a qualquer computador em que o


conjunto de instruções está armazenado na memória. O que liga os diversos
componentes em um computador é o que chamamos de bus.
Algoritmos
Vamos tentar melhorar nossa definição de algoritmo. Primeiro, vamos ver uma receita
de mousseline au chocolat (retirada da bibliografia).

Os ingredientes, ou a entrada desse algoritmo são: 8 onças de pedaços de chocolate


meio amargo, 2 colheres de sopa de água, 14 xícaras de açúcar em pó, 6 ovos separados
e assim por diante. As saídas são as seis ou oito porções da deliciosa mousseline au
chocolat. Aqui está a receita ou o algoritmo para isso:

Derreta o chocolate e 2 colheres de sopa de água em banho-maria. Quando derretido,


misture o açúcar em pó; adicione a manteiga pouco a pouco. Deixe descansar. Bata as
gemas até engrossar e cor de limão, cerca de 5 minutos. Dobre delicadamente no
chocolate. Reaqueça levemente para derreter o chocolate, se necessário. Misture o rum
e a baunilha. Bata as claras em neve até formar espuma. Bata 2 colheres de sopa de
açúcar; bata até formar picos duros. Dobre delicadamente as claras em uma mistura
de gema de chocolate. Despeje em pratos individuais. Refrigere por pelo menos 4
horas. Sirva com chantilly, se desejar. Faz 6 a 8 porções.

Nível de detalhes
Considere a instrução "misture o açúcar em pó". Por que a receita não diz “pegue um
pouco de açúcar em pó, despeje no chocolate derretido, mexa, tome um pouco mais,
despeje, mexa,...”? A resposta é que o hardware sabe como executar a instrução e não
precisa de detalhes adicionais.

Mas por que não dizer que ele sabe como fazer mistura de chocolate com açúcar e
manteiga? Ou mesmo que o hardware sabe preparar mousseline au chocolat? Quando
escrevemos um algoritmo, escrevemos pensando no hardware utilizado. Sempre
devemos escrever uma instrução bem definida para o "computador" que executará o
algoritmo.

Isso acontece, por exemplo, quando utilizamos um algoritmo para multiplicar 528 por
46, mas supomos que já sabemos multiplicar 8 por 6, etc.

Abstração
Enquanto nós dizemos que um computador realiza atividades extremamente simples, os
nossos algoritmos vão utilizar instruções em mais diferentes níveis de detalhes. Por
exemplo, um cozinheiro aprendiz pode necessitar da recita de mousseline au chocolat,
mas para um chef experiente, a instrução prepare um mousseline au chocolat já é
suficientemente clara.

Abstrair significa esconder os detalhes de que não precisamos. Do mesmo jeito, no


computador, ao invés de operarmos sempre sobre bits — o que seria muito tedioso,
vamos agrupar os bits em grupos de 8 bits, chamados bytes. Ainda, agruparemos um ou
mais bytes em caracteres, depois um ou mais caracteres em *palavras" e assim por
diante.

Texto finito
Suponha que queremos calcular quanto iremos gastar no supermercado. Para isso,
receberemos como entrada uma lista de itens e valores. Podemos escrever o seguinte
algoritmo para essa tarefa:

1. tome nota do número 0


2. percorra a lista, adicionando o valor de cada item ao número anotado
3. quando terminar de percorrer a lista, responda o número anotado

Primeiro, precisamos nos convencer de que esse algoritmo simples realmente realiza a
tarefa solicitada. Para isso, vamos testá-lo com algumas listas. Observe que precisamos
reservar um espaço no papel para guardar o "valor" atualmente sendo computado.

Executando esse algoritmo, fazemos algumas observações.

1. Enquanto o texto do algoritmo é um texto curto e de tamanho fixo definido (3


linhas), o processo que esse texto descreve pode variar em duração dependendo do
tamanho da lista de exercícios.

2. Além disso, mesmo que o valor possa ser bem diferente para listas de diferentes
famílias (pense numa pessoa que mora sozinha em contraste com uma família seis
pessoas!), o espaço no papel que separamos para executar esse algoritmo é sempre
o mesmo. O que isso quer dizer é que mesmo que o processo possa produzir
valores diferentes, os recursos utilizar por ele são sempre limitados.

O problema algorítmico
Observe que o algoritmo para calcular o valor da lista de compras é genérico e não
interessa qual o tamanho família ou lista de compras em particular que é fornecida como
entrada. Assim, esse algoritmo funciona para um conjunto infinito de entradas
diferentes.

Repare que isso é em contraste com o a receita de mousseline au chocolat, que sempre
que executado por um cozinheiro vai produzir as mesmas porções de mousseline. Mas
mesmo esse algoritmo pode ser tornado genérico, se mudarmos os ingredientes para
algo como "X onças de pedaços de chocolate, X / 4 colheres de sopa de água, X / 32
xícaras de açúcar em pó, etc.". Assim, o resultado produzido pela receita poderia ser
"rende de 3/4 X a X porções de X".

Um outro aspecto importante é que a entrada deve ser válida. Assim, devemos fornecer
uma lista de compras para o algoritmo da soma, e não podemos tentar executar esse
algoritmo com uma lista dos livros mais vendidos no ano, já que isso não faria sentido.
Isso significa que as entradas do nosso problema devem ser especificadas de alguma
maneira.
Observe que as saídas produzidas por cada algoritmo podem ter natureza diferentes.
Enquanto as saídas da receita são "porções de mousseline", as saídas do algoritmo da
soma são números. Assim, também devemos ter uma especificação das saídas.

Além disso, observe que para cada entrada, queremos encontrar uma saída
correspondente. A descrição dessa saída independe do algoritmo que a obtém. Desse
modo, separamos problemas dos algoritmos que os resolvem! Veja a imagem extraída
da bibliografia.

Agora já estamos em posição de definir problemas e algoritmos. Vamos definir


um problema algorítmico --- algumas vezes também vamos chamá-los de problema
computacional --- uma relação formada por

1. uma caracterização de uma coleção válida, possivelmente infinita, das possíveis


entradas do problema,

2. uma especificação das saídas desejadas como função das entradas.

Uma vez entendido o que é um problema algorítmico, você pode escrever uma definição
de algoritmo da sua maneira preferida. Aqui, eu vou dizer que um algoritmo é uma
sequência de instruções que resolve um determinado problema. Mas,
independentemente da forma com que você defina seu algoritmo, temos que considerar
algumas propriedades fundamentais:
 Essa sequência de instruções deve ser finita. Isso é porque não queremos
considerar sequências infinitas de instruções, já que elas sequer podem ser escritos
ou armazenadas em um computador.

 O algoritmo deve conter apenas instruções elementares. Aqui, temos que


lembrar que um algoritmo é escrito para determinado hardware — ou computador,
assim apenas instruções que podem ser compreendidas por esse hardware de forma
clara e inambígua podem ser utilizadas.

 O texto do algoritmo deve ser uma sequência sistemática de passos. Isso quer


dizer que sabemos exatamente qual a ordem em que as instruções são executadas.
Dito de outra maneira, após realizar cada uma das instruções, devemos saber se
ainda há alguma instrução a ser executada ou se o processo deve terminar.

 Sempre que for fornecida uma entrada válida, o algoritmo deve sempre


terminar com uma saída correspondente. Isso implica duas coisas: primeiro, que o
tempo gasto pelo algoritmo é finito e, segundo, que os demais recursos utilizados,
como espaço no papel, ingredientes, etc., também são limitados.

VOLTAR
PRÓXIMA →

Copyright © 2020

Escrevendo um algoritmo
Nós já sabemos o que é um algoritmo. Ele é um texto com diversas instruções.
Normalmente, podemos imaginar que um algoritmo será executado por um certo
robozinho. Esse robô é o processador que irá executar as instruções do algoritmo, então
é fundamental que só demos a ele ordens bem básicas, que ele seja capaz de cumprir.

Assim, queremos escrever um algoritmo que só contenha instruções elementares. Mais


do que isso, também é importante projetar um mecanismo que diga ao robô qual
instrução ele deverá executar em seguida e quando o algoritmo termina. Por isso, nosso
algoritmo terá algumas instruções de controle que digam a ordem e a forma em que as
ações são executadas.

Estruturas de controle
Vamos listar as estruturas de controle mais comumente utilizadas ao escrever um
algoritmo:

 O sequenciamento direto é a convenção do nosso algoritmo que determina que


uma instrução escrita antes no texto deve ser executada antes. Normalmente é
determinada implicitamente. Por exemplo, se escrevermos uma instrução por linha,
então sabemos que cada linha é executada em ordem; se separarmos instruções por
ponto-e-vírgula, então podemos imaginar que esse ponto-e-vírgula significa "e
depois faça", etc.
 O desvio condicional é uma estrutura de controle que permite que a execução
de um algoritmo tome caminhos diferentes, dependendo da entrada e dos dados já
computados. Normalmente tem a forma "se Q, então faça A, do contrário faça B",
ou apenas "se Q, então faça A". Nessas frases, Q é uma condição, ou uma
pergunta, cuja a resposta é sim ou não e pode ser testada por nosso robô.

Você pode se perguntar como a execução de um algoritmo pode levar tempo diferente
se o tamanho do texto que o descreve é fixo. Se tivéssemos apenas os tipos de controle
acima, então toda execução levaria o mesmo tempo. Para executarmos uma instrução ou
um conjunto de instruções por um número variável de vezes, precisamos das
chamadas estruturas iterativas. Cada parte da execução em que executamos esse
conjunto de instruções uma vez é chamada de iteração.

 A iteração limitada é a estrutura mais comum de iteração e é normalmente da


forma "repita A exatamente N vezes" ou muitas outras vezes pode ser da forma
"para cada item I da lista L, faça A". Isso foi útil, por exemplo, no exemplo da
soma dos preços da lista de compra, que vimos anteriormente.

 A iteração condicional é outra estrutura de controle iterativa que é utilizada


quando não sabemos no início quantas vezes precisamos iterar. Normalmente, tem
as formas "repita A até que Q valha" ou "enquanto Q, faça A". De novo, Q é uma
condição que pode ser testada por nosso robô.
As estruturas iterativas normalmente são chamadas de laços ou loops.

Vamos reescrever o algoritmo para exemplo da lista de compras. Nós vamos supor que
nosso algoritmo recebe, não somente a lista de compras, como o número de itens que
ela contém, que representaremos pela letra N.

(1) tome nota do número zero; aponte para o primeiro item da lista;
(2) faça o seguinte N - 1 vezes:
(2.1) adicione o valor do item atual ao número anotado;
(2.2) aponte para o próximo item da lista;
(3) adicione o valor do item atual ao número anotado;
(4) devolva como saída o número anotado.

Combinando estruturas
O que torna os algoritmos particularmente ricos é a possibilidade de combinar as
diversas estruturas de controle. Por exemplo, podemos executar um laço dentro do
conjunto de ações de uma estrutura condicional. Mais do que isso, podemos executar
um laço dentro do conjunto de ações de outro laço. Nesse último exemplo, chamamos o
primeiro de laço interno e o segundo de laço externo. E assim por diante!

Já deve dar para perceber que os algoritmos podem ficar cada vez mais diversos e mas
ricos — e, algumas vezes, mais complexos! Cada algoritmo pode ser mais ou menos
complicado. Isso vai depender do problema que queremos resolver.

Vamos tentar fazer um algoritmo que ordena um conjunto de cartas!

Uma maneira de fazer isso é primeiro colocar as cartas uma do lado da outra. Depois,
basta percorrer as cartas várias vezes, da esquerda para a direita trocando cartas
adjacentes que estejam fora de ordem. Quando não encontrarmos nenhum par de cartas
fora de ordem, sabemos que as cartas estão ordenadas. Esse é um algoritmo chamado
de bubble sort, ou ordenação da bolha. A ideia é que as cartas maiores vão sendo
empurradas para o final, como se fossem bolhas.

Na verdade, o bubblesort é um algoritmo bem lento e você ainda vai aprender diversos
algoritmos que são muitas vezes mais rápidos. Mas, por enquanto, vamos nos
concentrar em escrever esse algoritmo. Primeiro, precisamos saber quantas vezes
precisamos percorrer a sequência de cartas. Na primeira vez que percorremos as cartas,
deve ser fácil convencer-se de que a maior carta estará no final. Na segunda vez, a
segunda maior carta já estará na posição correta, e assim por diante. Assim, se o número
de cartas é N, então basta percorrer o baralho N - 1 vezes.

(1) repita o seguinte N − 1 vezes:


(1.1) aponte para a primeira carta;
(1.2) repita o seguinte N - 1 vezes:
(1.2.1) compare a carta apontada atualmente com a seguinte;
(1.2.2) se as cartas comparadas estão fora de ordem, inverta-as;
(1.2.3) aponte para a próxima carta.

Esse é um exemplo de algoritmo em que utilizamos e combinamos diversas estruturas


de controle. Tente identificá-las!

Desenhando um algoritmo
Ao invés de escrever um algoritmo, também podemos representá-los utilizando
desenhos. Há diversas maneiras de desenhá-los, mas talvez a maneira mais comum é
criar o que chamamos de "diagrama de fluxo", ou em inglês, "flowchart".

Em um diagrama de fluxo, cada instrução elementar é representada por um retângulo e


cada desvio condicional é representado por um losango. O objetivo é entender qual
o fluxo de execução do algoritmo. Assim, saindo de cada retângulo há um arco que
aponta para a próxima instrução a ser executada. Como a instrução executada depois de
um desvio condicional depende da resposta, sim ou não, a partir de um losango há dois
arcos, um para cada possibilidade.

Vamos tentar fazer um desenho do nosso algoritmo.

Perceba que as estruturas iterativas correspondem ao desenho de um ciclo no diagrama


de fluxo. Isso explica a nomenclatura de laço.

Sub-rotinas
Algumas vezes, os algoritmos podem ficar mais e mais complicados. Assim nossos
algoritmo ou diagramas de fluxo podem ficar cada vez maiores. Vamos ver um exemplo
de como isso pode acontecer.

Suponha que, na nossa lista de compras, há, não somente o valor de cada item, mas
também o tipo. Assim, cada item pode
ser alimentação, limpeza, vestuário, eletrônico etc. Dependendo do supermercado a que
vamos, não vamos conseguir comprar todos os itens. Se no supermercado mais próximo
pudermos comprar apenas itens de alimentação e limpeza, então precisamos saber o
quanto vamos gastar com esses tipos de item. Vamos modificar o algoritmo anterior.

(1) tome nota do número zero


(2) aponte para o primeiro item da lista;
(3) faça o seguinte N - 1 vezes:
(3.1) se o item atual é do tipo "alimentação"
(3.1.1) adicione o valor do item atual ao número anotado;
(3.2) aponte para o próximo item da lista;
(4) se o item atual é do tipo "alimentação"
(4.1) adicione o valor do item atual ao número anotado;
(5) aponte para o primeiro item da lista;
(6) faça o seguinte N - 1 vezes:
(6.1) se o item atual é do tipo "limpeza"
(6.1.1) adicione o valor do item atual ao número anotado;
(6.2) aponte para o próximo item da lista;
(7) se o item atual é do tipo "limpeza"
(7.1) adicione o valor do item atual ao número anotado;
(8) devolva como saída o número anotado.

É claro que há algoritmos mais simples para essa tarefa, mas esse podemos fazer
diversas observações do algoritmo que escrevemos acima. Primeiro, o texto do
algoritmo ficou muito maior. Mais importante do que isso, é muito mais difícil entender
o que está fazendo esse algoritmo. Tente desenhar o diagrama de fluxo correspondente.

Além disso, observamos que há dois trechos do algoritmo que são muito parecidos e
tudo que muda é o tipo de alimento. Podemos então tentar escrever esse trecho de
código apenas uma vez utilizando um parâmetro ao invés do tipo de alimento. Esse
trecho de código é chamado de sub-rotina ou procedimento.

SOMAR ITENS DA CATEGORIA (X)


(1) aponte para o primeiro item da lista;
(2) faça o seguinte N - 1 vezes:
(2.1) se o item atual é do tipo X
(2.1.1) adicione o valor do item atual ao número anotado;
(2.2) aponte para o próximo item da lista;
(3) se o item atual é do tipo "X"
(3.1) adicione o valor do item atual ao número anotado;

No texto acima, X é um parâmetro que será substituído pelo tipo de alimento quando
invocarmos essa sub-rotina. Com esse texto, agora podemos invocar ou chamar a nossa
sub-rotina duas vezes no algoritmo principal.

(1) tome nota do número zero


(2) SOMAR ITENS DA CATEGORIA ("alimentação")
(3) SOMAR ITENS DA CATEGORIA ("limpeza")
(4) devolva como saída o número anotado.

Compare esse novo algoritmo com o algoritmo anterior. Ele não ficou muito mais
simples? É importante observar que agora utilizamos uma instrução que antes não era
permitida, que é SOMAR ITENS DA CATEGORIA. Esta não é uma instrução
elementar do nosso processador original, mas utilizamos como se fosse. O que estamos
fazendo é análogo a ensinar o nosso robozinho a realizar uma nova atividade, de forma
que não precisamos explicar de novo como fazê-la. Dizemos que estamos criando uma
nova abstração, escondendo os detalhes de implementação.

Podemos desenhar agora um diagrama de fluxo substituindo o trecho do algoritmo da


sub-rotina por um retângulo, como uma instrução elementar. Faça isso.

Tipos e estrutura de de dados


Por enquanto falamos apenas de números anotados, de listas de itens de compra, de
apontamentos para itens, de ingredientes. Alguns desses elementos aparecem na entrada
e na saída, enquanto outros aparecem como objetos intermediários computados durante
a execução do algoritmo. A todos esses objetos, chamaremos de dados.
Tipos
Os vários dados aparecem nos mais diversos sabores, ou mais precisamente, nos mais
diversos tipos. Os tipos mais comuns utilizados nos computadores são os números, que
podem ter diversos subtipos, e strings, que representam palavras ou textos nos mais
diversos alfabetos.

Nos computadores que consideraremos, os dados são sempre representados de alguma


maneira em particular na memória do computador. Assim, precisamos adotar
convenções para representar os objetos tratados pelos algoritmos como uma sequência
bem definida de dados. Por exemplo, enquanto tratamos números inteiros no formato
decimal, a representação desses números na memória é binária. Se quisermos
representar objetos mais complicados, como uma imagem, vamos precisar definir
representações apropriadas — algumas vezes um tanto quanto complicadas, outras
vezes mais simples.

É importante que entendamos exatamente qual é o tipo de cada dado que tratamos no
algoritmo, pois cada tipo permite operações distintas. Por exemplo, podemos dividir
dois números, mas não podemos dividir duas strings!

Variáveis
No algoritmo da soma, referimo-nos a um dado simplesmente como "valor anotado",
que é inicializado com 0 e depois acumula o valor dos itens de compra. O que estamos
fazendo é utilizar uma variável. Uma variável não é o valor de um dado em si; ao invés
disso, podemos entender uma variável como uma pequena caixa ou célula onde um
determinado valor pode ser armazenado. Cada variável tem um tipo correspondente;
assim, podemos imaginar que nessa caixa só cabem valores do tipo correspondente.

Normalmente, damos um nome a essa caixa. Poderíamos, por exemplo, ao invés de


dizer "número anotado", chamar essa variável de "subtotal".

Em Computação, chamamos de variável uma célula que pode conter diversos valores
distintos em diferentes momentos, ao contrário da Matemática, em que uma variável é
apenas um símbolo que representa um valor desconhecido.

Como o valor de uma variável pode mudar, necessitamos de instruções específicas para
alterar o valor de uma variável. Essa instrução é chamada de atribuição. Normalmente
escrevemos algo como "Atribua valor V à variável X", ou utilizamos uma notação mais
simples, como X ← V, que devemos ler como "a variável X recebe o valor de V".

Também podemos atribuir um valor de uma expressão, como em X ← V+1, em que


queremos atribuir à variável X o valor de V mais uma unidade. Devemos tomar um
cuidado especial, já que muitas vezes o novo valor depende do valor anterior da
variável. Assim, podemos ter a instrução X ← X+1 que significa que o valor da variável
X deve ser mudado e que o novo valor é o valor anterior mais um.
Coleções de dados
Retomemos o exemplo da nossa lista de compras. Para representar a entrada, falamos de
uma "lista de compras". Assim, cada item a ser comprado é uma parte da lista. Podemos
querer armazenar cada item em um conjunto de variáveis. Por exemplo, o primeiro
item na variável X, o segundo na variável Y e o terceiro na variável Z. Mas logo você
deve perceber que essa estratégia não é boa se tivermos um lista de compras com mais
ou menos do que três itens. Para listas maiores, precisaríamos definir mais variáveis e,
para listas menores, teríamos variáveis que não fazem sentido!

Como nosso objetivo é escrever um texto de tamanho fixo, mas que possa ser executado
com listas de qualquer tamanho, precisamos de uma maneira de nos referir a esses
elementos de maneira uniforme. Para isso, vamos utilizar o que chamamos de lista.
Dependendo do contexto, também serão chamadas de vetores ou arranjos
unidimensionais.

Para tomar um exemplo, vamos tentar representar a nossa sequência de cartas usando
uma lista. Por simplicidade, todas as cartas tem valores diferentes, então vamos ignorar
os naipes e supor que o ás é representado pelo número 1. Assim, a sequência anterior
poderia ser representada pela seguinte lista.

Na figura acima, a variável cartas representa toda a sequência de cartas, mas ainda


precisamos de algum mecanismo para nos referir a cada um dos elementos. Poderíamos
dizer simplesmente "o primeiro elemento de cartas", mas há um jeito melhor. Vamos
adotar uma notação de colchetes, assim a primeira carta será cartas[1], a segunda
será cartas[2] e assim por diante.

Com isso, podemos tentar reescrever nosso algoritmo bubblesort.

(1) repita o seguinte N − 1 vezes:


(1.1) X <-- 1;
(1.2) enquanto X < N
(1.2.1) se cartas[X + 1] < cartas[X], então inverta esses
elementos;
(1.2.3) X <-- X + 1.
← ANTERIOR
VOLTAR
PRÓXIMA →

Copyright © 2020

Pensar em termos de algoritmos é útil para as diversas atividades cotidianas, seja


cozinhar, procurar um número telefônico, agendar um compromisso, fazer compras.
Mas queremos escrever algoritmos principalmente para utilizar um computador. Por
isso, precisamos entender como fazer com que o algoritmo saia do papel e passe a ser
executado pelo computador.
Lembre-se de que dissemos que os computadores são máquinas burras que só são
capazes de realizar tarefas bem simples, como inverter ou zerar bits na memória. Esse
conjunto de instruções que o computador é capaz de realizar é o que chamamos
de linguagem de máquina. Um algoritmo escrito em linguagem de máquina, por sua
vez, é uma sequência de bits e bytes. Para que possamos visualizar um algoritmo em
linguagem de máquina, normalmente escrevemos uma representação em texto, em uma
linguagem de montagem, ou assembly language. Por exemplo, um trecho de código em
assembly se parece com

LOOP: MOV A, 3
INC A
JMP LOOP

Pode parecer surpreendente que essas máquinas realizem tarefas tão elaboradas, como
simulações químicas, controle de tráfego aéreo, etc. O que permite que consigamos
instruir os computadores a executar tarefas tão elaboradas, mesmo que eles só realizem
tarefas bem simples é que nós escrevemos os algoritmos para os computadores em um
idioma mais abstrato do que as linguagens de máquina.

Mas quando escrevemos um algoritmo, digamos, em português, estamos escrevendo


para uma outra pessoa. Não há computador que genuinamente entenda o que significa
"percorrer uma lista", "adicionar ao número anotado", etc. Que entenda, por exemplo,
"percorra a lista somando os valores". A principal dificuldade aqui é que o português é
uma linguagem ambígua — e o computador não pode adivinhar qual o significado de
cada uma dessas instruções.

Para escrever um algoritmo de forma que o computador entenda, usamos


uma linguagem de programação. Assim, depois de escrito o algoritmo (normalmente
em português ou em algum formato parecido), uma pessoa, a programadora traduz
esse algoritmo para uma linguagem de programação. Assim, ela transforma
um algoritmo em um programa correspondente. Observe que a programadora pode,
inclusive, não ser a mesma pessoa que criou o algoritmo.

Uma linguagem que é inambígua para o computador é a própria linguagem de máquina,


ou a linguagem de montagem correspondente. Mas isso não deixaria feliz a
programadora, que teria que escrever programas imensos, mesmo para tarefas bem
simples. Por isso, utilizamos uma linguagem de programação de alto nível, para que
escrevamos programas mais parecidos com o algoritmo que nós normalmente
escreveríamos, mas que seja inambíguo como exige um computador.

Sintaxe
Uma linguagem de programação normalmente tem uma sintaxe rígida, que é o conjunto
de regras que determina quais combinações de símbolos e palavras-chaves podem ser
utilizadas. Por exemplo, se em uma linguagem a palavra-chave para ler um número e
guardá-lo na variável X for input X, escrever read X iria resultar em um erro de
sintaxe. Por mais que uma pessoa entenderia qual o objetivo da programadora ao
escrever a instrução errada, um computador não entenderia. A razão para escolher uma
palavra input em inglês é para facilitar a leitura, mas uma palavra-chave poderia ser
qualquer sequência de símbolos.
Considere o programa a seguir um uma linguagem hipotética.

input N ;
X := 0 ;
for Y from 1 to N do
X := X + Y
end ;
output X

Nessa linguagem, o símbolo := representa uma atribuição, assim escrevemos X := 0,


quando normalmente escreveríamos X ← 0 nos nossos algoritmos anteriores. A
linguagem pode ter várias estruturas de controle, como a estrutura que começa com for,
que realiza um conjunto de instruções um determinado número de vezes.

Além disso, a ordem com que os símbolos e palavras chaves aparecem é de fundamental
importância. Essa ordem é determinada pela sintaxe. Nessa linguagem hipotética
retirada da bibliografia, a sintaxe é definida pelo seguinte diagrama

Repare que depois de for esperamos uma variável, depois a palavra-chave for e assim


por diante. Portanto, se tivéssemos escrito

input N ;
X := 0 ;
for Y to N from 1 do
X := X + Y
end ;
output X

o computador se recusaria a continuar, acusando um erro de sintaxe! Existem várias


outras maneiras de representar a sintaxe de uma linguagem de programação, mas não
precisamos delas agora.

A maioria das linguagens utilizam apenas símbolos e palavras-chaves para determinar


sua sintaxe. Assim, o conjunto de instruções que correspondem a uma iteração
do for acima é terminado com a palavra-chave end. Algumas linguagens, no entanto,
utilizam outros mecanismos. Por exemplo, um código em Python correspondente seria

N = int(input())
X = 0
for Y in range(1, N + 1):
X = X + Y
print(X)

Enquanto esses algoritmos são equivalentes, a sintaxe das linguagens de programação


diferentes levaram a programas muito diferentes. Observe que o conjunto de instruções
de uma iteração do for nesse caso é identificado por meio do recuo (ou indentação).
Além disso, utilizamos um símbolo diferente para representar atribuição: ao invés de :=,
utilizamos =. A escrever ou ler um programa, é imprescindível atentar-se à sintaxe da
linguagem de programação!

Semântica
Além da sintaxe, uma linguagem de programação deve definir
uma semântica inambígua, isso é, a linguagem de programação deve definir o
que significa cada uma das frases permitidas. Se a sintaxe não tivesse acompanhada de
semântica, então o segmento

for Y from 1 to N do

poderia muito bem significar "subtraia Y de 1 e armazene o resultado em N". Nada


garante, por exemplo, que as palavras for, to e from tenham o mesmo significado que
em inglês. Quem garante que que := significa atribuição, ou mesmo que + é o operador
de soma? É a semântica que determina o que significa cada frase escrita em uma
linguagem de programação.

Mais do que isso, a semântica deve ser precisa e completa. Mesmo que as palavras-
chaves tenham o significado usual em inglês, pode haver instruções que são ambíguas
ou indefinidas. Por exemplo, se N é um número inteiro positivo 10, então é razoável
presumir que a iteração do for acima irá executar 10 vezes, com valores de Y = 1, 2, ...,
10. Mas se N for -314.1592? O corpo do laço não deve ser executado, ou será que ele
deve ser executado para valores 1, 0, −1, −2, ..., -313, and -314?

Enquanto a definição do que é um programa válido sintaticamente é normalmente fácil


e, em geral, definido por gramáticas precisas e outros tipos de especificação, como os
diagramas acima, definir a semântica de uma linguagem de programação não é uma
tarefa trivial. Ela é normalmente definida na documentação fornecida, normalmente
nos manuais de linguagem, que são compostos por capítulos e capítulos de descrição.
Pode ser surpreendente descobrir que mesmo linguagens de programação modernas
ainda não tenham uma semântica completamente definida.

Compilação
Uma vez que temos um algoritmo escrito em uma linguagem de programação, ainda
precisamos de um processo chamado de compilação, que é responsável por converter
nosso programa de uma linguagem de programação de alto nível para a linguagem de
montagem. Já falamos um pouco antes sobre a linguagem de montagem; essa linguagem
contém instruções muito simples e análogas à linguagem de máquina. Ela contém
instruções como ler ou armazenar uma palavra na memória, fazer operações aritméticas,
desviar o fluxo de execução (goto ou if-then) etc.

Por exemplo, um programa típico

for Y from 1 to N do
{corpo do laço}
end

seria traduzido um código em linguagem de montagem que se parece com

MVC 0, Y # guarda a constante 0 na posição Y


LOOP: CMP N , Y # compara os valores nas posições N e Y
JEQ REST # se forem iguais, pula à instrução rotulada REST
ADC 1, Y # adiciona a constante 1 ao valor na posição Y

# ... corpo do laço traduzido ...

JMP LOOP # volta à instrução rotulada LOOP

REST: # ... restante do programa ...

Essa tarefa de tradução de uma linguagem de programação de alto nível para uma
linguagem de programação de baixo nível é realizada por um programa bem sofisticado
chamado compilador. O compilador é normalmente fornecido pelo desenvolvedor da
linguagem ou pelo vendedor do hardware e deve ser específico para cada processador.
Depois de obtido um programa em linguagem de montagem, ainda é necessário
convertê-lo em linguagem de máquina. Isso normalmente é feito automaticamente pelo
compilador, que invoca uma série de programas de sistemas (o chamado system
software), como montadores, carregadores etc.

Esses programas de sistema têm uma série de funções, com o papel de facilitar um
conjunto de modos de operação de alto nível do computador. Isso permite isolar o
usuários de vários detalhes de baixo nível envolvidos. Um dos principais programas de
sistema é o chamado sistema operacional, que é responsável por gerenciar recursos e
periféricos, fornecendo ao programa de usuário nesses modos de operação de alto nível.
Simplificadamente, podemos então entender a execução de um programa em camadas,
em que camadas mais acima têm abstrações de alto nível, enquanto camadas mais
abaixo tem abstrações mais próximas do hardware.

Programas de Aplicação
Compiladores
Sistema operacional
Hardware

Interpretação e execução imediata


Existe ainda uma outra maneira de executar programas escritos em uma determinada
linguagem de programação. Ao invés de converter o código-fonte do programa em uma
linguagem de máquina, para depois executar o programa traduzido, podemos executar
cada instrução da linguagem de programação de alto nível assim que ela for
reconhecida. Assim, ao invés de usarmos um compilador, usamos um programa que é
responsável por essa tradução local e execução imediada, chamado de interpretador.

A forma com que cada interpretador é criado pode variar bastante de implementação
para implementação. Em muitos casos, você pode imaginar simplesmente que o
interpretador realiza todo o processo de compilação e executa o programa obtido
diretamente, sem a necessidade de invocá-lo. Algumas vezes, no entanto, as instruções
são executadas à medida em que as escrevemos. Isso acontece, por exemplo, quando
utilizamos o modo interativo de linguagens de programação de script, como Python,
Ruby, etc.

Existem algumas razões para se criar um interpretador ao invés de um compilador, entre


as quais:

 normalmente é mais fácil escrever um interpretador para uma linguagem de


programação, do que um compilador completo;

 é mais fácil entender o que está acontecendo durante a execução do programa


em linguagens interpretadas, particularmente quando trabalhando interativamente
através de um um terminal conectado a tela do computador.

Do problema à execução
Desde o conhecimento do problema até a execução do programa no computador para
obter uma solução, existe um processo que passa por diversas etapas. Por isso, é
importante entender bem esse processo e realizar bem cada uma das etapas, sem tentar
pular passos. Assim, faça sempre o seguinte:

1. Entenda o problema! Descreva qual é o conjunto de entradas válidas e qual é o


conjunto de saídas. Procure especificar também qual saída deve corresponder a
cada entrada válida.
2. Estude o problema e tente resolver algumas instâncias desse problema. Tome
nota de que passos você realiza. Com isso, você terá uma ideia de como resolver
uma instância genérica do problema.

3. Tente expressar suas ideias na forma de um algoritmo. Lembre-se de sempre de


utilizar apenas instruções elementares. Também, sempre teste seu algoritmo para
instâncias pequenas, até se convencer de que ele está correto.

4. Apenas depois de ter escrito e testado seu pequeno algoritmo, reescreva-o em


uma linguagem de programação.

5. Você irá compilar o código-fonte e executar o programa criado, ou irá


interpretar o código-fonte diretamente, dependendo da linguagem. Você pode
encontrar uma série de erros:

o Muitas vezes, haverá erros de sintaxe e você precisará corrigir seu


programa para que ele seja um texto válido.

o Outras vezes, embora o programa seja válido sintaticamente, ocorrerão


erros. Isso pode acontecer por uma série de motivos. Esses são
chamados erros de execução. Você precisará revisar seu código-fonte e
voltar ao passo 4.

o Finalmente, pode ser que você não encontre nenhum erro, mas os
resultados obtidos pelo seu programa não estejam corretos. Esses são os
chamados erros de lógica. Isso pode acontecer porque você escreveu um
código-fonte que não corresponde ao seu algoritmo, ou porque seu
algoritmo está incorreto. Nesse caso, será preciso voltar ao passo 3.

A figura extraída do livro representa o processo de compilação e execução.


← ANTERIOR
VOLTAR
PRÓXIMA →

Instruções: Enquanto você lê, você deve acompanhar os exemplos utilizando um


console Python. Experimente escrever outras instruções análogas e vá além do que está
escrito. Após a leitura do conteúdo abaixo, você deve ler os capítulos 1, 2 e o capítulo 3
até 3.1.2 do tutorial Python.

Estruturas elementares em Python


Suponha que precisamos somar dois números de vários algarismos, digamos, 124682 e
2468. Esses números são pequenos, então você pode facilmente escrever os dois
números no papel, alinhados pelo último dígito, e executar um algoritmo de soma
tradicional.

124682
+ 2468
--------
127150
Melhor ainda, para esse problema pode ser razoável tomar uma calculadora de mesa.
Uma calculadora nada mais é do que um computador que realiza operações aritméticas
e em que escrevemos as instruções diretamente no teclado. Se quisermos utilizar um
computador moderno, então precisamos decidir duas coisas:

1. Em que linguagem de programação escreveremos nossas instruções?


2. Qual o mecanismo utilizaremos para executar essas instruções?

A resposta da primeira pergunta para essa disciplina é Python 3. Como Python é uma
linguagem interpretada, a resposta da segunda pergunta é invocando um interpretador.
Existem duas maneiras de invocar o interpretador do Python: interativa e não interativa.

Interpretador de comandos interativo


Usamos o interpretador de comandos interativos quando queremos realizar operações
simples e curtas apenas uma vez. Muitas vezes também utilizamos esse interpretadora
para experimentar e explorar a linguagem, algum algoritmos ou alguma biblioteca de
funções existente. Para isso, primeiro invocamos o interpretador de
comandos python3 sem argumentos a partir do terminal do computador.

python3

Iremos ver o console do Python 3 em que podemos digitar comandos, como

Python 3.7.3 (default, Oct 7 2019, 12:56:13)


[GCC 8.3.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>>

Podemos digitar expressões aritméticas usuais (soma, subtração, divisão e


multiplicação) nesse console. Experimente várias delas.

>>> 124682 + 2468


127150

Ao contrário do computador moderno, a nossa calculadora é bem limitada. Você deve


adivinhar que uma das principais limitações da calculadora é que a memória dela é
muito limitada — só armazena os operandos e, normalmente, um resultado anterior. Se
você tiver realizando operações com várias números, você precisa anotar cada resultado
intermediário no papel. Em Python, podemos criar uma variável. Por exemplo, o IMC é
dado pela seguinte fórmula

IMC=massa(altura⋅altura)

Então podemos calcular nosso índice como o seguinte

>>> peso = 73
>>> altura = 1.75
>>> imc = peso / (altura * altura)
>>> imc
23.836734693877553
Devemos ler "peso recebe 73", "altura recebe 1.75", etc. O que as três primeiras linhas
fazem é criar variáveis que guardam os valores do lado direto. Lembrem-se, uma
variável corresponde a uma caixa na memória que guarda um determinado valor. Cada
uma dessas três linhas faz o seguinte:

 reserva um espaço na memória suficiente para um valor


 guardo o valor do lado direito de = nesse espaço
 associa o nome do lado esquerdo de = a esse valor

Observe que apenas após a último linha obtemos algum resultado: atribuição não é uma
expressão em Python e apenas expressões têm o seu valor impresso no console
interativo.

Tipos de variáveis
Você deve se lembrar de que todo valor armazenado na memória do computador tem
um tipo associado. Para descobrir o tipo associado a cada uma das variáveis, usamos o
seguinte:

>>> type(peso)
<class 'int'>
>>> type(altura)
<class 'float'>
>>> type(imc)
<class 'float'>

Vemos que peso tem tipo int. Um tipo int corresponde a um número inteiro (positivo,


negativo ou zero). Em Python 3 (mas não em Python 2), um número inteiro pode ter
quantos dígitos forem necessários. Experimente, por exemplo, calcular a décima
potência de -99999999 em uma calculadora comum. Certamente ela terminará com um
erro. Em Python, podemos fazer o seguinte

>>> -99999999 ** 10
-
9999999000000044999998800000020999999748000002099999988000000044999999900
0000001

Você acabou de aprender que ** corresponde ao operador de exponenciação de Python.


Mas tem algo estranho: a potência par de um número não pode ser negativa, mas o
resultado obtido foi negativo! Na verdade, o que acabamos de calcular foi -(99999999
** 10). O operador ** tem precedência ou prioridade sobre o operador de negação -.
Deveríamos escrever, portanto,

>>> (-99999999) ** 10
9999999000000044999998800000020999999748000002099999988000000044999999900
0000001

Já as variáveis altura e imc têm tipo float. Um float é um tipo numérico de ponto


flutuante, que é utilizada para guardar uma aproximação de um número real. Observe
que falamos ponto, e não vírgula: ao contrário do português do Brasil, na maioria das
linguagens de programação utilizamos um ponto . para indicar a parte fracionária de
um número.

O fato de que guardamos uma aproximação ao usarmos um número fracionário é


importante. Por exemplo, é evidente que a soma 0.1 + 0.2 deve valer exatamente 0.3,
mas o interpretador Python parece discordar:

>>> 0.1 + 0.2


0.30000000000000004

Ao contrário dos números inteiros, em que sempre guardamos uma representação exato
no número, não é possível guardar uma representação exata de cada número real. Pense,
por exemplo, em como representar o número π e cada um dos outros números
irracionais: não podemos enumerar todos os números irracionais! Para a maioria das
nossas aplicações que veremos, usar uma aproximação é mais do que suficiente, mas
devemos tomar cuidado sempre que:

1. compararmos números de ponto flutuante


2. armazenarmos números astronomicamente grandes ou pequenos

As variáveis de ponto flutuante são armazenadas guardando três números inteiros: sinal,
mantissa e expoente e representam um número(−1)sinal⋅mantissa⋅2expoente

Por exemplo,0.5=(−1)0⋅1⋅2−1

Para a programadora, normalmente é indiferente essa representação, desde que ele se


atente ao fato de que cada número de ponto flutuante é uma aproximação de algum
número representado!

É claro que o tipo da variável peso é inteiro, pois o valor atribuído é 73. Podemos forçar
a utilização de float adicionando um ponto

>>> peso_float = 73.


>>> type(peso_float)
<class 'float'>

Mas por que imc tem tipo float? O motivo disso é que a expressão do lado direito é
avaliada para um número float: sempre que dividimos dois números, obtemos um float
em Python 3, mesmo que a divisão seja exata! Se quisermos obter apenas o quociente
inteiro de uma divisão, usamos o operador //

>>> 5 / 2
2.5
>>> 6 / 2
3.0
>>> 6 // 2
3

Erros
Quando programamos pode acontecer uma série de erros. Um dos erros mais comuns é
o erro de sintaxe e pode acontecer mesmo com operações bem simples como as que
aprendermos. Por exemplo, se nos esquecermos de um operador

>>> imc = peso / (altura altura)


File "<stdin>", line 1
imc = peso / (altura altura)
^
SyntaxError: invalid syntax

O símbolo ^ está apontando para o elemento do programa (token) que não era esperado
naquela posição. De fato, ali deveríamos ter um símbolo de multiplicação *.

Outro erro de programação bastante comum é utilizar uma variável não definida,
algumas vezes porque escrevemos o nome da variável com a grafia incorreta

>>> imc = peso / (altura * autura)


Traceback (most recent call last):
File "<stdin>", line 1, in <module>
NameError: name 'autura' is not defined

Outros erros são chamados erros de execução e acontecem apenas no momento da


execução. Por exemplo, se tivéssemos digitado a altura erradamente:

>>> peso = 73
>>> altura = 0
>>> imc = peso / (altura * altura)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
ZeroDivisionError: division by zero

Os erros acima são chamados de exceções. Quando uma exceção ocorre, o interpretador
para a execução do seu programa com uma mensagem de erro e se recursa a continuar.
Algumas vezes, é preciso tratar exceções, mas por agora não falaremos disso. Nos
nossos primeiros programas, as exceções indicam simplesmente que o programa está
incorreto é deve ser corrigido.

Finalmente, podemos ter erros de lógica, que são erros que não são detectados pelo
interpretadores. Ao contrário dos erros anteriores, pode ser difícil encontrar esse erro e,
algumas vezes, um erro de lógica pode passar desapercebido por muito tempo. Já vimos
esse problema com a precedência acima. Vamos ver outro caso de descuido com a
ordem das operações:

>>> peso = 73
>>> altura = 1.75
>>> imc = peso / altura*altura
>>> imc
73.0

Alguém que tomasse esse resultado como correto iria com certeza estar bastante
preocupado com sua saúde!
Um parênteses: módulos
Uma razão comum para se usar uma sessão interativa do interpretador é experimentar
com novos módulos e funções. Não vamos ver como criar módulos agora, mas podemos
desde já como utilizá-los. Suponha que queremos calcular a raiz quadrada de um
número. Não existe operador em Python dedicado para isso, mas podemos importar esse
operador de um módulo math. Por exemplo, para calcular o tamanho da diagonal de um
retângulo, fazemos o seguinte:

>>> import math


>>> cateto1 = 10
>>> cateto2 = 5
>>> hipotenusa = math.sqrt(cateto1**2 + cateto2**2)
>>> hipotenusa
11.180339887498949

Dica: nas sessões interativas, as teclas para cima e para baixo navegam no histórico de
comandos digitados. Além disso, você pode usar o tab para completar uma palavra,
depois de escrever o prefixo. Por exemplo, para ver quais nomes ou operadores estão
disponíveis no módulo importado, digite math. e aperte tab duas vezes. Para ver o que
cada operação faz, use help: help(math.cos) irá mostrar a documentação dessa
operação. Talvez seja necessário digitar q para sair da documentação.

Escrevendo um programa
Embora o modo interativo do interpretador Python seja útil, ele só serve em situações
limitadas, quando queremos executar as instruções uma única vez. Na maior parte das
vezes, quando queremos executar instruções Python, primeiro escrevemos um programa
em Python. Os programas em Python também são chamados de script, em parte porque
Python é utilizado muitas vezes com ferramenta de automação de tarefas cotidianas, em
parte para distinguir de outros programas já compilados em linguagem de máquina.

Vamos construir um programa que

1. pede para o usuário digitar um nome


2. imprime uma mensagem de bom dia para o usuário

Criamos o seguinte programa, i.e., criarmos um arquivo de texto chamado bomdia.py e


digitamos

usuario = input("Digite seu nome: ")


print("Bom dia", usuario)

Após salvar o arquivo, podemos executar o programa invocando o interpretador Python


em um terminal de execução. Esse programa irá imprimir uma mensagem pedindo o
nome. Ao escrevermos algum texto, digamos, Maria, e apertarmos Enter, obteremos o
seguinte terminal
user@host:$ python3 bomdia.py
Digite seu nome: Maria
Bom dia Maria

No programa vemos dois comandos que fazem parte da Linguagem Python. A


função input lê um texto digitado pelo usuário no teclado até o momento em que ele
digita a tecla Enter. O que essa função devolve é uma variável que guarda a sequência
de caracteres digitada pelo usuário. Essa variável, que é do tipo string, ou str, é
associada ao identificador usuario, que é o nome do usuário.

A função print recebe uma sequência de argumentos, cada um pode ter um valor e um


tipo diferente. O que ela faz é mostrar (ou imprimir) na tela os valores na forma de
texto, isso é, de uma string. Se houver vários argumentos, a função print irá mostra um
espaço entre eles.

Strings
No código acima, temos "Bom dia" que é uma string literal. As strings literais
representam valores do tipo str que não mudam e correspondem à sequência de
caracteres entre as aspas. Se quisermos inserir um caractere de aspas dentro da string,
precisamos escapar do significado especial que esse caractere tem utilizando uma
contra-barra, ou backslash antes.

Por exemplo, considere um programa pensamento.py com o seguinte conteúdo

frase = "Descartes disse:\n\"penso, logo existo\""


print(frase)

O primeiro comando de pensamento.py cria uma string com aspas. Poderíamos também


utilizar aspas simples,

frase = 'Descartes disse:\n"penso, logo existo"'


print(frase)

já que o caractere " não têm significado especial dentro da string literal. Se executarmos


esse programa, iremos obter

Descartes disse:
"penso, logo existo"

Repare que o \n não foi impresso, mas sim foi mostrada uma quebra de linha: de
fato, \n é um caractere de controle que instrui o terminal a saltar uma linha. Se
quiséssemos a própria barra, deveríamos escapar do significado especial da barra,
usando

frase = 'Descartes disse:\\n"penso, logo existo"'


print(frase)

que imprimiria

Descartes disse:\n"penso, logo existo"


Operações de string
Assim como podemos fazer operações com números inteiros e números de ponto
flutuante, também podemos fazer várias operações com string. Uma operação comum é
criar uma substring ou acessar um determinado caractere. Veja o exemplo:

frase = "O essencial é invisível aos olhos"


print("O primeiro caractere é: ", frase[0])
print("O segundo é um espaço: ", frase[1])
print("A segunda palavra é: ", frase[2:11])
print("Um sufixo é:", frase[16:])
print("A última palavra é:", frase[-5:])

Executando o código acima, obtemos

O primeiro caractere é: O
O segundo é um espaço:
A segunda palavra é: essencial
Um sufixo é: visível aos olhos
A última palavra é: olhos

Execute o exemplo, releia o código e reflita. Procure entender porque essa saída é
executada e procure a documentação se necessário.

Uma outra operação comum com strings é a concatenação. Por exemplo, poderíamos
reescrever o exemplo acima da seguinte maneira; dessa vez, usando a pontuação
adequadamente.

usuario = input("Digite seu nome: ")


mensagem = "Bom dia, " + usuario + "."
print(mensagem)

Reutilizamos o símbolo de +. Mas perceba que nesse caso ambos operandos são strings,
assim o significado de + é concatenar as duas strings, em ordem. Qual a saída será
obtida?

Convertendo tipos
Vamos agora usar o que aprendemos para criar um programa que calcula a hipotenusa
de um triângulo retângulo. Escrevemos o seguinte

import math

cateto1 = input()
cateto2 = input()

hipotenusa = math.sqrt(cateto1*cateto1 + cateto2*cateto2)

print("A hipotenusa é " + hipotenusa)


Como você já deve imaginar, esse programa não funciona. Experimente. O motivo é
fácil de entender: ninguém garante que o usuário irá escrever dois números válidos! E
de fato, input apenas lê uma sequência de caracteres e não tenta interpretá-la de
nenhuma maneira. O tipo de ambos cateto1 e cateto2 no programa acima é str. Ou
seja, são strings: não podemos multiplicar duas strings!

Para que nosso programa funcione, precisamos que o valor das variáveis que
representam os catetos sejam números de ponto flutuante! Mas como transformar uma
string, que contém a sequência de dígitos decimais e, possivelmente um ponto, no
número fracionário que ele representa em ponto flutuante? Para isso, usamos uma
função de conversão para float.

import math

cateto1 = float(input())
cateto2 = float(input())

hipotenusa = math.sqrt(cateto1*cateto1 + cateto2*cateto2)

print("A hipotenusa é " + hipotenusa)

Agora sim, a hipotenusa será calculada corretamente! Mas o programa ainda está
incorreto. O erro é que tentamos fazer uma concatenação com um operando que não é
uma string! Para converter um valor em uma string, usamos a função str. Corrija esse
programa usando conversão para string e depois fazendo a concatenação adequada!

Formatando a saída
Em Python 3, existe uma maneira bem conveniente de converter valores em strings, que
são as strings de modelo para formatação. A ideia é escrever um texto e deixar
marcações, placeholders, para substituir com o valor formatado. Por exemplo, se
quiséssemos sempre escrever o valor da hipotenusa com duas casas decimais,
poderíamos fazer o seguinte

import math

cateto1 = float(input())
cateto2 = float(input())

hipotenusa = math.sqrt(cateto1*cateto1 + cateto2*cateto2)

print("Os catetos são {} e {}!".format(cateto1, cateto2))


print("A hipotenusa é {:0.2f}.".format(hipotenusa))

Procure na documentação as várias formas de usar format. Nas versões mais novas de


Python, há também uma maneira mais compacta de escrever esse código, usando as f-
strings:

import math

cateto1 = float(input())
cateto2 = float(input())
hipotenusa = math.sqrt(cateto1*cateto1 + cateto2*cateto2)

print(f"Os catetos são {cateto1} e {cateto2}!")


print(f"A hipotenusa é {hipotenusa:0.2f}.")

Repare que as f-strings começam com um símbolo f.

Comandos condicionais
Vamos resolver o seguinte exercício:

Escreva um programa que lê um número inteiro do teclado e imprime "sim" se o


número for par e maior do que 10, ou for ímpar e menor do que 50. Caso contrário o
programa deve imprimir "não".

Primeiro, vamos tentar escrever um algoritmo em português

numero <-- leia um número do teclado


se número for par
se numero > 10
imprima sim
senão
imprima não
do contrário, se for ímpar
se numero < 50
imprima sim
senão
imprima não

Parece razoável supor que esse problema é bem simples, então fazendo algumas
simulações rápidas nos convencemos de que o programa está correto. Vamos reescrever
o algoritmo, agora em Python

numero = int(input())
if numero % 2 == 0:
if numero > 10:
print("sim")
else:
print("não")
else:
if numero < 50:
print("sim")
else:
print("não")

Há muitas novidades aqui, vamos parte a parte. Primeiro, o recuo em que os comandos
são escritos é fundamental para identificar a estrutura do algoritmo. Temos duas
palavras-chaves novas: if e else. O comando if significa se e é seguido por uma
condição. Se essa condição for verdadeira, e somente nesse caso, o corpo de comandos
que segue os dois pontos e que está recuado é executado. O comando else é opcional; o
corpo desse comando é executando sempre que o a condição do if falhar.
Valores booleanos
Primeiro precisamos entender o que é uma condição: uma condição é uma expressão
cujo resultado é um valor do tipo bool. Um tipo bool, por sua vez, é um valor de
verdade que pode ser ou True ou False e nada mais.

Obtemos valores booleanos normalmente fazendo perguntas! Em uma linguagem de


programação, essas perguntas estão na forma de comparações e outras funções. Por
exemplo, se quisermos verificar se o usuário digitou apenas números decimais,
podemos usar

numero_string = input()
if numero_string.isdigit():
numero = int(numero_string)
print(f"O texto digitado {numero_string} contém apenas dígitos
decimais.")
else:
print(f"O texto digitado {numero_string} contém apenas dígitos
decimais.")

Poderíamos reescrever o programa anterior da seguinte maneira:

numero = int(input())

par = numero % 2 == 0
maior_10 = numero > 10
menor_50 = numero < 50

if par:
if maior_10:
print("sim")
else:
print("não")
else:
if menor_50:
print("sim")
else:
print("não")

O tipo dessas três novas variáveis é bool. Confira usando um console Python e
digitando

var = True
type(var)
par = numero % 2 == 0
type(var)

Operadores booleanos
Às vezes, queremos fazer duas perguntas ao mesmo tempo, outras vezes precisamos
satisfazer apenas uma de várias condições. Pensando nisso, vamos reescrever o
programa
numero = int(input())

par = numero % 2 == 0
maior_10 = numero > 10
menor_50 = numero < 50

if par and maior_10:


print("sim")
elif not par and menor_50:
print("sim")
else:
print("não")

Observamos algumas novas palavras-chaves: a primeira é and (a conjunção e escrita em


inglês) que significa que queremos que ambas condições sejam verdadeiras, a da
esquerda e a da direita; a segunda é not (não em inglês), que nega o valor e verdade de
uma expressão; a última é elif, que significa, do contrário, verifique a condição e
execute. O elif nada mais é do que uma forma mais compacta de escrever um código
equivalente:

numero = int(input())

par = numero % 2 == 0
maior_10 = numero > 10
menor_50 = numero < 50

if par and maior_10:


print("sim")
else:
if par and menor_50:
print("sim")
else:
print("não")

Mas, como essa sequência de if seguido de else é bastante comum, fica mais fácil
escrever todos os corpos de comando no mesmo recuo. Podemos usar
quantos elif após um if quanto forem necessários.

No nosso exemplo, o corpo de if e elif são iguais. Assim, podemos simplificar ainda


mais o programa acima.

numero = int(input())

par = numero % 2 == 0
maior_10 = numero > 10
menor_50 = numero < 50

if par and maior_10 or not par and menor_50:


print("sim")
else:
print("não")

A palavra-chave or é um operador que devolve verdadeiro bastando que pelo menos um


dos seus operando seja True.
Note que nem sempre é fácil entender qual a ordem em que as operações serão
executadas. Para isso é necessário conhecer a precedência dos operadores (e um bocado
de experiência). No caso do if acima, not é executado primeiro e or é executado por
último. Em expressões complicadas, como a acima, é sempre mais claro (e mais
prático), usar parênteses:

numero = int(input())

par = numero % 2 == 0
maior_10 = numero > 10
menor_50 = numero < 50

if (par and maior_10) or (not par and menor_50):


print("sim")
else:
print("não")

O nome booleano, que em Python corresponde a bool, é uma homenagem a um


matemático George Bool, a quem é frequentemente atribuída a criação da álgebra
booleana. Por ora, basta conhecermos bem as chamadas tabelas-verdades das
operações:

A not A
True False
False True
A B A and B
True True True
True False False
False True False
False False False
A B A or B
True True True
True False True
False True True
False False False

Relembrar algumas equivalências bem conhecidas também é importante:

 not (a == b) é equivalente a a != b


 not (a != b) é equivalente a a == b

 not (a > b) é equivalente a a <= b

 not (a < b) é equivalente a a >= b

 not (a >= b) é equivalente a a < b

 not (a <= b) é equivalente a a > b

E refletir sobre algumas formas que às vezes passam desapercebido:

 not (a and b) é equivalente a not a or not b


 not (a or b) é equivalente a not a and not b

Repetindo
O corpo de comandos de if e de else pode ser executado uma ou nenhuma vez
dependendo da condição. Muitas vezes, queremos repetir um conjunto de comandos
enquanto determinada condição é satisfeita.

Iremos ver comandos repetitivos com calma depois. Por enquanto, resolvamos um
exercício:

Escreva um programa que leia ma sequência de pares de números inteiros e pare


apenas quando a soma de ambos for 42.

print("Escreva dois números")


a = int(input())
b = int(input())
soma = a + b

while soma != 42:


print("Escreva dois números")
a = int(input())
b = int(input())
soma = a + b

print(f"Parabéns, a soma de {a} e {b} é um número fundamental")

Há uma novidade: a palavra-chave while é como if, mas é um comando repetitivo,


isso significa que o corpo de comandos do while deve mudar o valor das variáveis a
mudar a expressão de condição.

Há um problema desse código: repetimos a mesma coisa duas vezes. Como sempre
precisamos executar uma vez, podemos substituir a expressão da condição por uma
variável, assim:

procurando = True

while procurando:
print("Escreva dois números")
a = int(input())
b = int(input())
soma = a + b

if soma == 42:
procurando = False

print(f"Parabéns, a soma de {a} e {b} é um número fundamental")


← ANTERIOR
VOLTAR
PRÓXIMA →

Copyright © 2
Instruções: Enquanto você lê, você deve acompanhar os exemplos utilizando um
console e Python. Para essa unidade, você deve ler o restante do capítulo 3 a partir de
3.1.2 e as seções do capítulo 4 até 4.5 do tutorial Python.

Vamos refazer nosso algoritmo para a lista de compras, mas dessa vez vamos guardar os
valores dos itens na memória do computador. Vamos resolver o seguinte exercício:

Escreva um programa que leia uma sequência de valores de itens de compra e mostre o
valor da soma de todos os itens. O usuário deverá escrever o valor de cada item, um
por linha. Quando não houver mais itens, o usuário irá indicar esse fato escrevendo um
número negativo qualquer.

Para ter certeza de que entendemos o problema, primeiro escrevemos um exemplo de


entrada:

3.50
5.00
16.36
-1

Assim, usando uma calculadora fica claro que devemos somar os valores das três
primeiras linhas e obter o seguinte resultado.

24.86

Como de costume, vamos escrever primeiro um algoritmo em português.

lista_compras <-- crie uma lista de compras


valor <-- leia um valor de item
enquanto valor > 0:
adicionar valor a lista_compras
valor <-- leia um valor de item

soma <-- 0
para cada valor na lista_compras:
soma <-- soma + valor
mostrar o valor da soma total

Já sabemos implementar todas as linhas, com exceção da primeira: criar uma lista de
compras: criar uma lista de compras. Como você deve imaginar, não existe uma tal
abstração lista de compras na linguagem de programação Python. Então para
implementar esse programa, teremos que responder duas perguntas:

1. como representar um item da lista de compras no contexto desse algoritmo?


2. como armazenar um conjunto de itens da lista de compras em uma única
variável?

A resposta da primeira pergunta reforça que o que armazenamos de fato na memória do


computador não são itens de compra, mas dados relacionados a um item. Nesse caso, o
único dado de interesse é o valor desse item, que devemos representar como um número
de ponto flutuante. Assim, para responder a segunda pergunta precisamos de um
mecanismo para armazenar um conjunto de números de ponto flutuante. Em Python, a
maneira natural de armazenar uma coleção de dados é criando uma lista. Uma
implementação do algoritmo seria a seguinte:

# leia uma sequência de valores de itens


lista_compras = []
valor = float(input())
while valor >= 0:
lista_compras.append(valor)
valor = float(input())

# somar todos os valores da lista


soma = 0.0
for valor in lista_compras:
soma += valor
print(soma)

Há diversas novidades nesse trecho de código. Vamos explorá-lo em partes.

Listas
A expressão [] cria uma nova variável do tipo lista que inicialmente está vazia. Há
várias maneiras de criar uma lista. Vamos experimentar algumas, experimente:

>>> lista_vazia = []
>>> outra_lista_vazia = list()
>>> primos = [2, 3, 5, 7, 11]
>>> cinco_zeros = [0] * 5
>>> escritores = ["Vinicius de Moraes",
... "Cecília Meireles",
... "Mary Shelley",
... "Cora Coralina",
... "Pedro dos Anjos",]
>>> escritoras = escritores[1:4]
>>> notas = [10.0, 7.5, 3.14]

O número de referências que estão armazenadas em uma lista pode ser variável. Ela
pode começar vazia ou com alguns elementos. Podemos inserir um elemento no final da
lista com a operação append e remover um elemento do final da lista com a
operação pop.

>>> primos = [2, 3, 5, 7, 11]


>>> type(primos)
<class 'list'>
>>> len(primos)
5
>>> primos.append(17)
>>> primos
[2, 3, 5, 7, 11, 17]
>>> primos.extend([19, 23])
>>> primos
[2, 3, 5, 7, 11, 17, 19, 23]
>>> primos.pop()
23
>>> primos
[2, 3, 5, 7, 11, 17, 19]
>>> primos = primos + [29, 31]
>>> primos
[2, 3, 5, 7, 11, 17, 19, 29, 31]

Procure na documentação outras funções para remover ou inserir em uma posição


arbitrária e para remover um determinado elemento. Experimente tentar remover
elementos de listas vazias ou remover elementos que não existem na lista.

Uma lista é uma variável que contém um conjunto de referências para outras variáveis.
Você pode desenhar lista_compras como grande quadrado na memória que contém
referências para diversas variáveis. Ao executar o programa com o exemplo de entrada,
obteremos uma figura parecida com a seguinte:
Você pode imaginar que na verdade há diversos nomes distintos referenciando as
variáveis distintas, como na figura:
Os motivos por que usamos uma lista ao invés de diversas variáveis soltas são:

1. podemos armazenar um número variável de elementos na memória, todos


representados por um mesmo nome
2. podemos acessar uma variável distinta dessa coleção por meio de um índice não
constante

Isso é importante porque, no momento em que estamos escrevendo um algoritmo, não


sabemos quantos elementos a coleção deverá ter, nem qual posição será acessada.
Vamos experimentar o seguinte programa:

n = input("Digite quantos amigos você tem? ")


amigos = []
i = 0
while i < n:
nome = input(f"Digite o nome do amigo número {i}: ")
amigos.append(nome)
i += 1

j = int(input("Digite um número: "))


print(f"Seu amigo número {j} chama-se {amigos[j]}")

Experimente executar esse programa. Observe que nada garante que o número
armazenado na variável j corresponde a um número de amigo válido. O que acontece
quando você digita um número negativo? E quando digita um número maior ou igual o
número n?

Assim como as variáveis simples, também podemos mudar os valores a que se referem
os elementos de uma lista. O seguinte programa multiplica por um número diferente
cada elemento de uma lista de inteiros:

sequencia = [1] * 5
i = 1
while i < 10:
sequencia[i] = sequencia[i - 1] * i
i += 1
print(sequencia)

Um parêntese
Você consegue dizer o que esse programa faz? Tente simular no papel e depois
verifique executando esse programa com o auxílio de um debugger. Para isso, você
pode utilizar uma IDE configurada apropriadamente como o VSCode.
Alternativamente, salve o programa seguinte como sequencia.py

sequencia = [1] * 5
i = 1
while i < 10:
breakpoint()
sequencia[i] = sequencia[i - 1] * i
i += 1
print(sequencia)
e execute em um terminal usando python3 sequencia.py. Isso irá iniciar um sessão do
Python debugger padrão (pdb) toda vez que a linha que contém breakpoint() for
executada. Nessa sessão, você pode inspecionar os valores atuais das variáveis, assim
como num terminal interativo. Depois de ver o valor das variáveis, digite continue,
para continuar executando a próxima linha até a próxima instrução breakpoint().

Listas heterogêneas
Na grande maioria da vezes, vamos considerar apenas listas que contêm elementos de
um determinado tipo. As listas em Python, no entanto, permitem listas que contêm
elementos de tipos heterogêneos. Vejamos um exemplo:

>>> numeros = ["um", 2, 3.0]


>>> type(numeros[0])
<class 'str'>
>>> type(numeros[1])
<class 'int'>
>>> type(numeros[2])
<class 'float'>

Enquanto isso pode ser conveniente às vezes, você não deve criar listas desse tipo, pelo
menos por enquanto. Um dos motivos é que não podemos tratar os elementos dessa lista
de maneira uniforme. Qual seria o resultado de numeros[0] + numeros[1]?

Percorrendo listas
Voltando ao exemplo da lista de compras, agora precisamos percorrer os elementos da
lista. Para isso, usamos a construção for como seguinte:

# somar todos os valores da lista


soma = 0.0
for valor in lista_compras:
soma += valor
print(soma)

Você pode ler como _para cada valor de lista*compras*, execute as seguinte


instruções_. O que esse trecho faz é criar um novo nome de variável valore
executar bloco de código indentado do foruma vez para cada elemento da
lista, em ordem. Em cada iteração, valor` estará referenciando um elemento da
lista.

Observe que embora não faça parte do for, a variável soma está associada a esse laço.
Ao final de cada iteração ela terá o valor da soma parcial dos valores até o item
corrente, por isso é chamada de variável acumuladora. Faça os exercícios de fixação
para descobrir mais usos de variáveis acumuladoras.

Na verdade, você pode usar o for para percorrer qualquer iterador ou sequência em


Python. Não vamos estudar iteradores em detalhes. Um iterador bastante comum é
obtido pela função range, que representa um intervalo inteiro. Podemos usá-lo da
seguinte forma:
for i in range(10):
print(f"Executando iteração com i = {i}")

Isso irá imprimir a seguinte saída

Executando iteração com i = 0


Executando iteração com i = 1
Executando iteração com i = 2
Executando iteração com i = 3
Executando iteração com i = 4
Executando iteração com i = 5
Executando iteração com i = 6
Executando iteração com i = 7
Executando iteração com i = 8
Executando iteração com i = 9

Procure a documentação de range para ver outras formas de usá-la. Tente descobrir o


que faz o seguinte programa:

for i in range(5, 11):


for j in range(10-i):
print(" ", end="")
for j in range(2*i):
print("*", end="")
for j in range(10-i):
print(" ", end="")
print()

Enquanto o valor devolvido por range funciona como uma lista, ele não é uma lista.
Mas se quiser pode converter um intervalo (ou qualquer sequência) em uma lista. Isso
na maioria das vezes não é necessário, mas você pode querer verificar isso:

>>> intervalo_pares = range(2, 11, 2)


>>> intervalo_pares
range(2, 11, 2)
>>> type(intervalo_pares)
<class 'range'>
>>> pares = list(intervalo_pares)
>>> pares
[2, 4, 6, 8, 10]
>>> minha_string = "Uma string"
>>> type(minha_string)
<class 'str'>
>>> sequencia_caracteres = list(minha_string)
>>> sequencia_caracteres
['U', 'm', 'a', ' ', 's', 't', 'r', 'i', 'n', 'g']
>>> type(sequencia_caracteres)
<class 'list'>

Um outro exemplo
Você pode se questionar porque precisamos guardar todos os valores da nossa lista de
compras se apenas gostaríamos de somá-los. De fato, não precisamos: nesse exemplo,
ter criado uma lista apenas fez com que utilizássemos mais memória (para armazenar
uma lista) do que era necessário. Nessa disciplina as entradas não serão tão grandes a
ponto de precisarmos economizar memória, então sempre que for conveniente, vamos
armazenar os dados em uma lista. Há algumas vantagens de escrever programas assim:
primeiro, é mais fácil pensar sobre uma lista e, segundo, há uma série de funções
prontas para tratar listas em Python. O último trecho de código poderia ser substituído
pela seguinte linha

print(sum(lista_compras))

Pesquise sobre a função sum e outras funções agregadoras de Python, mas tenha em


mente que nessa nessa disciplina queremos aprender e exercitar os algoritmos
explicitamente.

Um exercícios em que é necessário manter uma lista em memória é o seguinte:

Escreva um programa que receba uma sequência de nomes digitados no teclado e


imprima as iniciais. Para sinalizar que não há mais nomes, o usuário irá digitar um
traço  -.

Um exemplo de entrada é

Maria
João
Pedro
Catarina
Carlos
-

Com o que já aprendemos, podemos escrever o seguinte:

# ler uma sequência de nomes


lista_nomes = []
nome = input()
while nome != "-":
lista_nomes.append(nome)
nome = input()

# guardar as iniciais
lista_iniciais = []
for nome in lista_nomes:
inicial = nome[0]
lista_iniciais.append(inicial)

print(" ".join(lista_iniciais))

Preste atenção no argumento de print e pesquise sobre a função join, que nos auxilia a
criar uma string separada por espaços. Ao testarmos esse programa e analisarmos a
saída, no entanto, iremos notar um problema.

M J P C C
A saída contém iniciais repetidas. Isso é fácil de corrigir quando guardamos a lista de
iniciais: basta testar se já encontramos a inicial antes de inserir. Podemos fazer isso
usando um operador novo.

# ler uma sequência de nomes


lista_nomes = []
nome = input()
while nome != "-":
lista_nomes.append(nome)
nome = input()

# guardar as iniciais
lista_iniciais = []
for nome in lista_nomes:
inicial = nome[0]
if inicial not in lista_iniciais:
lista_iniciais.append(inicial)

print(" ".join(lista_iniciais))

O operador in (ou sua versão negada not in) devolve um valor booleano indicando se


o item à esquerda está na lista à direita.

Copiando uma lista e referências


Você deve ter reparado que sempre que falamos do operador de atribuição =, nós
distinguimos entre o nome da variável e o valor da variável. Isso é particularmente
importante quando trabalhamos com lista. Por exemplo, tente descobrir o que é
impresso pelo seguinte trecho:

mamiferos = ["golfinho", "humano", "cachorro"]


animais = mamiferos
animais.append("sapo")
print(mamiferos)

Ao executar esse código você verá que não teremos impresso apenas espécies
mamíferas, mas também um anfíbio. Muito embora uma tradução ingênua para o
português poderia dizer que "sapo" foi adicionado apenas a lista de animais, na verdade
a lista animais e mamiferos são uma só! Uma representação em memória desse trecho é
Quando escrevemos a linha animais = mamiferos o que fazemos é dar um novo nome
à mesma lista que foi criada antes. Para fazer uma cópia de uma lista, precisamos de um
pouco mais de trabalho. Observe e procure entender o código abaixo

mamiferos = ["golfinho", "humano", "cachorro"]


mammals = mamiferos

animais = []

for m in mamiferos:
animais.append(m)

animais.append("sapo")

mammals[2] = "elefante"

print(mamiferos)
print(mammals)
print(animais)

Agora, a saída será:

['golfinho', 'humano', 'elefante']


['golfinho', 'humano', 'elefante']
['golfinho', 'humano', 'cachorro', 'sapo']

Uma representação da memória poderia ser:


List comprehension
Criar uma lista a partir de outra sequência é tão comum que Python tem uma maneira
mais curta de escrever o mesmo código, que é chamada de list comprehension. Por
exemplo, podemos criar uma cópia de uma lista de números, mas multiplicando por
dois.

notas = [3.5, 6.0, 1.9, 10, 7.4, 4.3]


dobros = [2 * nota for nota in notas]
print(dobros)

Podemos, inclusive, filtrar um subconjunto de números:

notas = [3.5, 6.0, 1.9, 10, 7.4, 4.3]


dobros_notas_vermelhas = [2 * nota for nota in notas if nota < 5]
print(dobros_notas_vermelhas)

Estudo e experimente trabalhar com list comprehensions. No entanto, por enquanto,


prefira as versões mais explícitas apresentadas anteriormente quando for resolver os
exercícios e as tarefas.

Saindo de um laço antecipadamente


Vimos que um for executa uma iteração para todo elemento da sequência. Vamos ver
mais um exemplo:

Escreva um programa que imprima todos os divisores de um número não triviais (isso
é, os divisores que não são um ou o próprio número.)

Como sempre, queremos escrever um algoritmo para esse problema em português.

n <-- leia um número do teclado


divisores <-- crie uma lista vazia
para d de 2 até n - 1:
se d divide n:
adicione d aos divisores
devolva divisores

Nesse ponto, deve ser trivial traduzir esse algoritmo em um código em Python:

n = int(input())
divisores = []
for d in range(2, n):
if n % d == 0:
divisores.append(d)

print(divisores)

Um número é primo se ele é maior do que um e não tém divisores não triviais. É fácil
modificar o código acima e verificar se um número é primo:

n = int(input())
divisores = []
for d in range(2, n):
if n % d == 0:
divisores.append(d)

if n == 1 or divisores:
print("O número é 1 ou tem divisores não triviais")
else:
print("O número é primo")

Para entender esse código precisamos perceber uma sutileza: divisores é uma lista,
mas ela foi usada no lugar em que esperaríamos um valor booleano. Em Python,
coleções (como listas) podem ser usadas como valores de verdade: elas são
consideradas True sempre que não forem vazias, e False caso contrário. Pesquise sobre
as várias formas de testar valores de verdade (Truth Value Testing) em Python 3.

Se você é impaciente deve estar incomodado com o código acima: ele executa mais
operações do que é necessário. Parece latente que um número como 1000000000 não é
primo. Ainda assim, se executarmos o código acima e digitarmos esse valor, teremos
uma surpresa desagradável — e não interessa que você tenha um computador top de
linha ou mesmo um supercomputador!

O motivo é que desde o momento em que testamos o primeiro divisor, já sabíamos que
o número 1000000000 não era primo, mas o programa é alheio ao seu sofrimento e
continua obstinado em executar todas as iterações. Para terminar um laço antes do final,
usamos um comando especial break. Isso irá terminar o laço e continuar na instrução
imediatamente posterior.

n = int(input())
divisor_encontrado = False
for d in range(2, n):
if n % d == 0:
divisor_encontrado = True
break

if n == 1 or divisor_encontrado:
print("O número é 1 ou tem divisores não triviais")
else:
print("O número é primo")

O comando for permite um comando else opcional. Essa é uma peculiaridade de


Python (não há muitas outras linguagens com esse tipo de construção) e você deve
evitá-la até ter mais experiência. Poderíamos reescrever o código assim:

n = int(input())

for d in range(2, n):


if n % d == 0:
divisor_encontrado = True
break
else:
divisor_encontrado = False

if n == 1 or divisor_encontrado:
print("O número é 1 ou tem divisores não triviais")
else:
print("O número é primo")

Construindo um menu de opções


Vamos ver outro exemplo em que utilizar um break pode facilitar escrever um
programa. Vamos resolver o seguinte exercício

Escreva uma caculadora que realiza soma e subtração. Uma instrução começa com o
operador e uma linha seguido de duas linhas com os operandos. O programa deve
executar quantas operações forem fornecidas pelo usuário, que digitá  F quando quiser
terminar.

Aqio está um exemplo de entrada:

+
3
5
-
4
1
-
5
0
F

e a saída correspondente:

8
3
5

Tente escrever um algoritmo e um código em Python para esse exercício. Depois estude
como eu escreveria:

while True:
operador = input()
if operador == "+":
num1 = float(input())
num2 = float(input())
soma = num1 + num2
print(soma)
elif operador == "-":
num1 = float(input())
num2 = float(input())
diferenca = num1 - num2
print(diferenca)
elif operador == "F":
break
else:
print("Operação inválida")

Experimente adicionar outras operações a sua calculadora.


← ANTERIOR
VOLTAR
PRÓXIMA →

Copyright © 2020

Instruções: Enquanto você lê, você deve acompanhar os exemplos utilizando um


console e Python. Para essa unidade, você deve ler a seção 4.6 sobre funções
do tutorial Python. Discutiremos alguns exemplos sobre a importância de funções e
sobre como utilizá-las efetivamente. Depois, você deverá ler sobra as demais
funcionalidades de função em Python (do início da seção 4.7 até 4.7.3).

Vamos retomar o exemplo de nossa calculadora, mas agora vamos adicionar outras
operações operações.

while True:
operador = input()
if operador == "+":
num1 = float(input())
num2 = float(input())
soma = num1 + num2
print(soma)
elif operador == "-":
num1 = float(input())
num2 = float(input())
diferenca = num1 - num2
print(diferenca)
elif operador == "*":
num1 = float(input())
num2 = float(input())
produto = num1 * num2
print(produto)
elif operador == "/":
num1 = float(input())
num2 = float(input())
if num2 == 0:
print("Divisor não pode ser zero")
continue
produto = num1 / num2
print(produto)
elif operador == "F":
break
else:
print("Operação inválida")

Observe que usamos um comando continue. Essa instrução termina a iteração atual do


laço, mas ao contrário de break, continua na próxima iteração. Assim, imediatamente
após executar continue, o interpretador irá voltar a verificar a condição do while.

Perceba que o programa começa a ficar muito grande e já não cabe em muitas telas, mas
ainda é razoavelmente simples e a maioria dos programadores não teria dificuldades em
ler esse código. No entanto, à medida que adicionamos operações, a situação fica um
pouco mais complicada.

Queremos que nossa calculadora seja mais útil do que as calculadoras de mesa
tradicionais, então vamos adicionar duas operações, uma menos trivial do que a outra: a
raiz quadrada e as raízes de uma equação do segundo grau.

Para calcular a raiz quadrada, vamos de novo importar um módulo math, então


adicionamos no início no arquivo

import math

Com isso, basta basta escolher um nome para a operação apropriado e adicionar mais
algumas linhas no corpo do while.

# ....
elif operador == "sqrt":
num = float(input())
raiz = math.sqrt(num)
print(raiz)
# ...

A segunda operação — encontrar as raízes de uma equação do segundo grau — diverge


das operações anteriores, pois não há função prontamente disponível em Python, e
precisamos de um número de instruções um pouco maior. Primeiro, lembramos que
uma equação do segundo grau é escrita como

ax2+bx+c=0

Desde muito sabemos como encontrar o valor de x. Primeiro, calculamos o valor do
discriminante

Δ=b2−4ac

Com o valor de Δ, podemos determinar quantas e quais são as soluções da equação. A
fórmula para isso é

x=−b±Δ2a

que é popularmente conhecida como fórmula de Bhaskara. Adicionamos o seguinte ao


corpo da função

while True:
# ....
elif operador == "bhaskara":
a = float(input())
b = float(input())
c = float(input())
delta = b*b - 4 * a * c
if delta < 0:
print("Não há raízes reais")
continue
elif delta == 0:
print("Há uma raiz distinta apenas")
else:
print("Há duas raízes distintas")
x1 = (-b + math.sqrt(delta)) / (2 * a)
x1 = (-b - math.sqrt(delta)) / (2 * a)
print(f"As raízes são {x1} e {x2}")
# ...

Se tentarmos olhar para todo o programa, teremos uma surpresa:

while True:
operador = input()
if operador == "+":
num1 = float(input())
num2 = float(input())
soma = num1 + num2
print(soma)
elif operador == "-":
num1 = float(input())
num2 = float(input())
diferenca = num1 - num2
print(diferenca)
elif operador == "*":
num1 = float(input())
num2 = float(input())
produto = num1 * num2
print(produto)
elif operador == "/":
num1 = float(input())
num2 = float(input())
if num2 == 0:
print("Divisor não pode ser zero")
continue
produto = num1 / num2
print(produto)
elif operador == "sqrt":
num = float(input())
raiz = math.sqrt(num)
print(raiz)
elif operador == "bhaskara":
a = float(input())
b = float(input())
c = float(input())
delta = b * b - 4 * a * c
if delta < 0:
print("Não há raízes reais")
continue
elif delta == 0:
print("Há uma raiz distinta apenas")
else:
print("Há duas raízes distintas")
x1 = (-b + math.sqrt(delta)) / (2 * a)
x1 = (-b - math.sqrt(delta)) / (2 * a)
print(f"As raízes são {x1} e {x2}")
elif operador == "F":
break
else:
print("Operação inválida")

Dessa vez, tenho certeza de que todo o código não cabe em uma única tela de seu editor.
Mais importante do que isso, é extremamente difícil ler esse código e entender o que
está acontecendo! O motivo, além do tamanho, é que para entender o código acima
precisamos nos preocupar, ao mesmo tempo, com diversos problemas distintos:

 como controlar um menu de operações ao usuário?


 como ler e mostrar os dados de uma operação de soma, subtração etc.?
 como obter as raízes de uma equação de segundo grau?

Como estamos fazendo tudo isso de maneira intercalada, ao lermos esse código, ora nos
preocumos com o while, ora com a entrada e saída, ora com a fórmula de Bhaskara.
Pior! Se houver um erro na lógica do nosso programa, vamos gastar bastante tempo
tentando descobrir onde ele está.

Funções
Para resolver esse problema, vamos criar uma abstração para conjunto de instruções
dedicadas a resolver uma determinada tarefa. Vamos reescrever ou refatorar o
programa. Vamos adotar uma estratégia de resolver os problemas mais gerais primeiro,
e depois os mais específicos (algumas pessoas chamam a isso de estratégia top-down).

Primeiro precisamos descobrir qual a tarefa principal do programa, isso é, qual é o


conjunto de instruções deve ser executado ao iniciar o programa. Nesse exemplo, a
primeira instrução é while, que é responsável pelo controle das operações digitadas pelo
usuário. Vamos então nos concentrar nesse problema e esconder todos os demais.
Escrevemos o seguinte:

while True:
operador = input()
if operador == "+":
operacao_soma()
elif operador == "-":
operacao_diferenca()
elif operador == "*":
operacao_multiplicacao()
elif operador == "/":
operacao_divisao()
elif operador == "sqrt":
operacao_raiz()
elif operador == "bhaskara":
operacao_bhaskara()
elif operador == "F":
break
else:
print("Operação inválida")

Agora fica muito mais simples entender o que esse código faz: ele lê uma linha do
teclado e realiza uma operação de acordo com o que o usuário digitar. É claro
que operacao_soma, operacao_diferenca etc. não são instruções da linguagem Python.
Cada um desses nomes é uma abstração: aqui, abstrair significa esconder os detalhes de
como realizar um determinada operação.

Se tentarmos executar um código assim iríamos obter uma mensagem de erro do


interpretador dizendo que algum nome não está definido, como se ele reclamasse, "não
sei do que você está falando". Vamos então definir cada um desses nomes. Para isso,
antes do while, adicione

def operacao_soma():
pass

def operacao_diferenca():
pass

def operacao_produto():
pass

def operacao_divisao():
pass

def operacao_raiz():
pass

def operacao_bhaskara():
pass

O que estamos fazendo é definir diversas novas funções. Uma função é um conjunto de


instruções realiza determinada tarefa identificada por um nome. As funções acima são
só stubs, que são funções incompletas. O motivo para criar stub é poder testar o código
do programa principal enquanto ainda estamos desenvolvendo nosso programa.

Depois de terminado o trecho de código do programa principal, devemos implementar


cada uma das funções definidas acima. Implementar uma função significa escrever o
conjunto de instruções que realiza a tarefa correspondente. Uma das vantagens de
definir e usar funções é que é muito mais fácil implementar tarefas pequenas, uma de
cada vez, do que tentar resolver todo o problema de uma vez. Para a
função operacao_soma basta copiar as instruções do código original:

def operacao_soma():
num1 = float(input())
num2 = float(input())
soma = num1 + num2
print(soma)

Você deve fazer o mesmo para as operações de diferença e produto. Para a operação de
divisão, no entanto, precisamos tomar um cuidado a mais. Copiando o código original,
teríamos:

def operacao_divisao():
num1 = float(input())
num2 = float(input())
if num2 == 0:
print("Divisor não pode ser zero")
continue
produto = num1 / num2
print(produto)

O problema do código acima é que a instrução continue só faz sentido dentre do corpo


de um comando for ou while. O que queremos, nesse caso, é terminar a função e
retornar ao menu. Para isso, existe a instrução return que termina a função que a
contém e continua a execução imediatamente depois do ponto de chamada.

def operacao_divisao():
num1 = float(input())
num2 = float(input())
if num2 == 0:
print("Divisor não pode ser zero")
return
produto = num1 / num2
print(produto)

Faça o mesmo para operação para obter raízes da equação.

Passando argumentos e devolvendo


valores
Vamos olhar como ficaria a função para a operação da raiz:

def operacao_raiz():
num = float(input())
raiz = math.sqrt(num)
print(raiz)

A expressão math.sqrt(num) é uma chamada a uma função de nome sqrt importada


com o módulo math. Repare que o radiando é passado como parâmetro da função é a
raiz é obtida, assim, a função sqrt funciona, de fato, como uma função matemática, que
tem uma entrada e devolve uma saída.

Embora calcular a raiz quadrada já é uma função própria de uma biblioteca padrão do
Python, ela normalmente não é uma instrução elementar dos nossos computadores
modernos. Como bons computeiros, devemos ter o espírito curioso de descobrir como
uma tal operação complexa pode ser computada a partir de operações elementares
(soma, subtração, multiplicação e divisão).

Há vários métodos conhecidos e você deve ter já estudado pelo menos um na escola.
Nesse exemplo, vamos usar o método de Newton. Como essa é uma operação
complicada, primeiro vamos criar um stub para poder modificar o código
de operacao_raiz. Fazemos o seguinte:

def minha_sqrt(radiando):
raiz = radiando / 2 # TODO: implementar método de Newton
return raiz
# ...

def operacao_raiz():
num = float(input())
raiz = minha_sqrt(num)
print(raiz)

A função minha_sqrt é só um stub, mas ela ilustra dois conceitos novos:

 Uma função pode receber um ou mais parâmetros. Um parâmetro é uma


variável que se refere a algum valor passado entre os parênteses quando chamamos
a função. Esse pode ser o valor de qualquer expressão, seja uma simples variável
como em minha_sqrt(num), mas pode ser também uma expressão mais
complicada, como em minha_sqrt(b*b - 4*a*c).

 Uma função pode devolver um valor ao retornar ao local de origem. Esse valor


é escrito após o return. O valor devolvido por uma função tem um tipo
determinado e pode ser utilizado em qualquer expressão onde uma variável do
mesmo tipo poderia ser utilizada. Por exemplo, o valor pode ser atribuído a uma
variável, como em raiz = minha_sqrt(num) ou utilizado em uma expressão mais
complicada, como em x = (-b + minha_sqrt(b*b - 4*a*c)) / (2*a) .

Agora que já organizamos nosso programa em funções, podemos nos concentrar em


implementar o método de Newton. Observe que fizemos um comentário que começa
com # TODO: .... É comum criar comentários como esse ou # FIXME: .... para
indicar trechos de código que merecem atenção posterior. Outra maneira, para projetos
grandes, é anotar um bug num gerenciador de tarefas (como bugzilla, issues, etc.).
Revise o método de Newton e implemente a função (você pode achar mais fácil resolver
o exercício correspondente).

Repetição de código
Vamos resolver mais um exercício:

Escreva um programa que recebe um número inteiro e decide se ele é um produto de


dois números primos.

Antes de começar, vamos listar as duas pequenas tarefas que devemos fazer:

1. controlar a entrada e saída do programa


2. verificar se um número é produto de dois primos

Novamente, precisamos identificar qual delas é a tarefa principal, isso é, que será
executada primeiro quando o programa começar. Em grande parte de nossos problemas
essa vai ser sempre a tarefa de ler os dados de entrada, realizar algumas operações e
mostrar os dados de saída. Nesse caso, vamos fazer o seguinte::

def main():
n = int(input("Digite um número inteiro: "))
if eh_produto_dois_primos(n):
print(f"O número {n} é produto de dois primos.")
else:
print(f"O número {n} não é produto de dois primos.")

main()

Observe que definimos uma função chamada main e que adicionamos uma chamada a
essa função na última linha do programa, que é a única instrução do programa que não
está dentro de uma função. Sempre que nosso problema não for trivial, vamos preferir
organizar nossos programas dessa maneira. O nome main é uma convenção e serve para
identificar facilmente qual é a função principal do programa.

Para entender esse programa, precisamos investigar a linha que contém if


eh_produto_dois_primos(n):. O que está implícito aqui é
que eh_produto_dois_primos é uma função que devolve um valor booleano
dependendo se o número n passado como parâmetro é produto de dois primos. Para
podermos deduzir isso foi fundamental que o nome da função fosse bem representativo
do que ela faz e do que ela devolve.

Para poder testar a função main, faremos um stub.

def eh_produto_dois_primos(n):
"""Devolve True se o argumento n puder
ser escrito como um produto de dois primos"""
return True

A novidade nesse programa é que adicionamos uma string na primeira linha da função.
Essa string não é associada a nenhuma variável e não tem nenhum efeito. O motivo de
adicionarmos é que ela serve para documentar o que a função faz. Por mais que uma
função tenha (e deva ter) um bom nome, nem sempre é claro o que cada função faz,
particularmente se voltarmos a ler nosso código depois de alguns dias. Essas strings são
denominadas strings de documentação ou documentation strings.

Quando testarmos o programa com qualquer número, digamos, 100, obteremos sempre
uma mensagem como

O número 100 é produto de dois primos.

independentemente do número lido. Para testar o conjunto de instruções


correspondentes ao else da função main podemos mudar o nosso stub. Mas agora, já
podemos implementar a função de fato. Antes, vamos escrever um algoritmo, em bem
alto nível.

1. para cada número q em 1,2,....,n:


o se n for divisível por q:

 faça r←n/q
 verifique se q é primo
 verifique se r é primo
 se ambos forem primos
 responda SIM
2. se não tiver terminado, responda NÃO

Você pode só acreditar que esse algoritmo está correto, ou pode tentar se convencer de
que está. Um bom computeiro tenta entender bem o que um algoritmo faz antes de
tentar codificá-lo. Para isso, teste com algumas instâncias pequenas utilizando lápis e
papel. Quando estivermos confiantes de que o o algoritmo está correto, podemos passar
à implementação.

def eh_produto_dois_primos(n):
"""Devolve True se o argumento n puder
ser escrito como um produto de dois primos"""

produto_primos = False
for q in range(1, n + 1):
if n % q == 0:
r = n // q

r_eh_primo = True
for d in range(2, r):
if r % d == 0:
r_eh_primo = False
break

q_eh_primo = True
for d in range(2, q):
if r % d == 0:
q_eh_primo = False
break

if r_eh_primo and q_eh_primo:


produto_primos = True
break

return produto_primos

Leia com atenção e copie essa função no seu programa. Podemos testar o programa
digitando o número 15 que é um produto de dois primos 3 e 5. Ao verificar a saída
iremos ver que o programa imprimiu corretamente

O número 15 é produto de dois primos.

Se testarmos com outros 10 ou 4, o programa também responderá corretamente. Mas


devolver a resposta correta para alguns exemplos não significa que o programa está
correto. Na verdade, não importa qual número inteiro fornecermos, o programa sempre
responderá que ele é produto de dois primos. Isso não é verdade quando a resposta é
não, como para o número 8 ou o número 20.

Concluímos duas coisas: primeiro, nosso programa está errado, segundo, é importante
testar nossos programas com vários exemplos de entrada, particularmente para entradas
que fornecem saídas diferentes.
Para descobrir onde está o erro do programa, podemos usar diversas estratégias, como
simulá-lo com um debugger, ou ler o código lentamente com atenção. Se você já não
descobriu o erro, pare um pouco e tendo descobri-lo.

Uma vez descoberto o erro, precisamos corrigi-lo. Mais importante, precisamos


entender porque esse erro ocorreu para início de conversa. O erro é um erro de digitação
na segunda ocorrência de if r % d == 0: que deveria ser if q % d == 0:. Como
você deve adivinhar, isso ocorreu porque temos dois trechos de código muito parecidos
e o segundo foi obtido copiando e modificando o primeiro, mas esquecemos de
modificar uma linha.

Esse exemplo descreve o que chamamos de duplicidade de código, que é uma situação
extremamente comum no desenvolvimento de software. Muitas vezes, cada trecho de
código tem papeis similares, mas para parâmetros diferentes. No exemplo acima,
queremos decidir se um dado número (r ou q) é primo. Em situações como essa,
devemos refatorar o código e definirmos uma função. Reescrevemos assim.

def eh_primo(n):
"""Verifica se n é primo"""

eh_primo = True
for d in range(2, n):
if n % d == 0:
eh_primo = False
break
return eh_primo

def eh_produto_dois_primos(n):
"""Devolve True se o argumento n puder
ser escrito como um produto de dois primos"""

produto_primos = False
for q in range(1, n + 1):
if n % q == 0:
r = n // q
if eh_primo(r) and eh_primo(q):
produto_primos = True
break

return produto_primos

Deve ser evidente que a nova versão é mais simples e muito mais compacta. Dessa vez,
não há repetição de código. Testando para o número 8 vemos que, agora sim, ele
responde corretamente que não é produto de dois primos.

Infelizmente, como você já pode desconfiar, esse programa não está correto. Estude o
programa e tente determinar para que exemplos esse programa devolve a saída
incorreta! Depois, corrija seu programa. Ao terminar esse exercício você vai descobri
mais uma vantagem de ter refatorado o programa com uma nova função, ao invés de
manter as duas cópias praticamente idênticas de um conjunto de instruções.

Criando e organizando seu programa


Vamos resolver mais um problema para nos exercitar.

Crie um programa que leia duas listas do teclado, correspondentes às notas de provas
e de exercícios dos estudantes, normaliza cada nota dividindo-se a nota pelo máximo
da lista correspondente e computa a média final de cada estudante, que é dada pela
média geométrica entre a nota de prova e de exercícios.

Você deve tentar fazer todo esse programa por conta própria. Para isso, procure seguir a
mesma estratégia que seguimos antes:

1. Leia o enunciado e procure entender o que é pedido. Para isso, tente formalizar
entrada e saída e crie alguns exemplos. Depois, faça uma lista de todas as tarefas
curtas que precisam ser realizadas para se resolver esse problema.
2. Identifique a tarefa principal responsável por controlar entrada e saída e crie uma
função main utilizando chamadas para funções auxiliares quando quiser abstrair
pequenas tarefas.
3. Crie funções stubs para cada função auxiliar necessária. Certifique-se de que os
nomes das funções sejam adequados para as tarefas que abstraídas. Não se esqueça
de documentar as funções identificando a entrada e a saída sempre que necessário.
4. Teste a função principal verificando as instruções que controlam a entrada e a
saída da função.
5. Implemente cada stub definido. Lembre-se de escrever antes um algoritmo em
português, testar e, só depois, traduzi-los a linguagem Python. Procure testar seu
programa a cada função implementada.

Em seguida eu mostro como eu resolveria esse problema. Não leia esse código


enquanto não tiver terminado o seu próprio programa. Seu programa pode divergir
completamente do código abaixo, mas isso não significa que uma maneira é mais
correta do que a outra. Procure analisar criticamente as diferenças. Uma peculiaridade
da Computação é que, embora seja uma ciência dita exata, os algoritmos são tão
distintos quanto seus próprios programadores. Tanto que alguns diriam que
programação é uma arte!

"""
Calcula as médias finais dos estudantes.

Entrada:
- uma linha com o número n de estudantes
- n linhas correspondentes às notas de provas
- n linhas correspondentes às notas de exercícios

Saída:
- n linhas correspondentes às medias finais
"""

import math

def ler_lista_numeros(n):
"""Lê uma lista de n números do teclado"""
lista = []
for _ in range(n):
numero = float(input())
lista.append(numero)
return lista

def imprimir_lista_numeros(lista):
"""Imprime cada número de lista em um linha,
com duas casas decimais"""
for valor in lista:
print(f"{valor:.2f}")

def obter_maximo(lista):
"""Devolve o valor máximo da lista"""
assert lista, "Lista não pode ser vazia"
maximo = lista[0]
for numero in lista:
if numero > maximo:
maximo = numero
return maximo

def criar_lista_normalizada(lista_antiga):
"""Devolve uma nova lista com os elementos
de lista_antiga normalizados pelo máximo"""
maximo = obter_maximo(lista_antiga)
lista_nova = []
for valor in lista_antiga:
novo_valor = valor / maximo
lista_nova.append(novo_valor)
return lista_nova

def calcular_medias_finais(notas_provas, notas_exercicios):


"""Devolve uma nova lista com as médias geométricas
dos elementos de notas_provas e notas_exercicios"""
medias_finais = []
assert len(notas_provas) == len(notas_exercicios), \
"As listas de notas devem ter o mesmo tamanho"
n = len(notas_provas)
for i in range(n):
media_final = math.sqrt(notas_provas[i] * notas_exercicios[i])
medias_finais.append(media_final)
return medias_finais

def main():
n = int(input(n))
notas_provas = ler_lista_numeros(n)
notas_exercicios = ler_lista_numeros(n)

notas_provas = criar_lista_normalizada(notas_provas)
notas_exercicios = criar_lista_normalizada(notas_exercicios)

medias_finais = calcular_medias_finais(notas_provas,
notas_exercicios)
imprimir_lista_numeros(medias_finais)

main()
Há vários detalhe nesse programas e talvez alguns sejam novos para você. Você deve
pesquisar as instruções que não conhecer e descobrir o objetivo de elas estarem ali. O
que queremos destacar nesse exemplo, no entanto, é a forma como está organizado. É
uma boa prática (embora nem sempre seja seguida no mercado) criar programas bem
documentados e com formatação padronizada, como acima. O programa acima está
organizado de acordo com algumas convenções:

1. O programa começa com um comentário descrevendo o que ele faz. No caso de


Python, usamos uma string de múltiplas linhas que é mais convenientes. Repare
que a documentação descreve precisamente como utilizar o programa.
2. Em seguida, há uma seção de import. Por enquanto só conhecemos e
precisamos de uma função no módulo math, mas podemos ter uma sequência bem
grande de instruções desse tipos quando tivermos programas mais complexos que
precisam de mais bibliotecas.
3. Logo em seguida há uma sequência de funções auxiliares para se resolver nosso
problema, cada uma bem documentada. As funções estão organizadas nessa ordem
não por acaso. Nesse nosso exemplo, primeiro estão as funções mais gerais (de
entrada e saída) e depois funções mais específicas para nosso problema. Cada
desenvolvedor (ou grupo) normalmente adota uma estratégia particular.
4. Por último, vem a função principal, denominada main, seguida de uma chamada
para ela. O programa começa a executar por ali e a maioria dos programadores
também começará a ler programas a partir da função main.

Escopo de variáveis e ciclo de vida de


funções
Quando aprendemos a instrução de atribuição, vimos que ela tem a seguinte forma

<nome de variável> = <expressão que computa um valor na memória>

Assim, o nome do lado esquerdo deve ser um nome que referencia algum valor
armazenado na memória. Essa associação, no jargão de Python é chamada de binding.
Uma vez definido o binding, podemos usar o nome da variável em um expressão para
representar o valor referenciado. O nome de uma variável, no entanto, só pode ser usado
quando satisfeitas determinadas condições:

1. A atribuição correspondente ao binding tiver sido executada antes do uso da


variável. Se houver mais de uma atribuição correspondente ao mesmo nome, então
o nome corresponderá ao último valor referenciado.

2. O nome da variável ser visível dentro do escopo em que foi criada. O escopo de
uma variável é um conjunto de nomes de variáveis correspondentes a uma região
do código bem definida. Uma atribuição sempre corresponde ao nome do escopo
atual.

Vamos estudar dois tipos de escopo.


 Escopo global: O escopo global é criado quando o interpretador Python inicia a
execução de seu programa. São adicionados ao escopo global nome definidos
diretamente no programa, que não estejam no corpo de nenhuma função. Essas
variáveis são visíveis em qualquer parte do seu programa.

 Escopo de função: O escopo de uma função é criado somente quando uma


função é chamada. São adicionados ao escopo dessa chamada os nomes definidos
dentro do corpo da função. Essas variáveis são visíveis apenas dentro do corpo da
função.

Para deixar esses conceitos um pouco mais concretos, vejamos um exemplo de código:

PI = 3.141592653589793

def calcular_area_disco(raio):
raio_quadrado = raio ** 2
area = PI * raio_quadrado

def calcular_volume_esfera(raio):
raio_cubo = raio ** 3
volume = 4.0/3.0 * PI * raio_cubo
return volume

def main():
raio = float(input("Digite o raio de uma esfera: "))
peso = float(input("Digite o peso dessa esfera: "))

volume = calcular_volume_esfera(raio)
densidade = peso / volume

print(f"A densidade da esfera á {densidade}")

main()

Existe uma única variável global denominada PI. Essa variável pode ser utilizada em
qualquer ponto do programa que execute após sua definição. Observe quem ambas as
funções calcular_area_disco e calcular_area_disco fazem uso de PI.

As variáveis locais de calcular_area_disco são o parâmetro raio e as demais


variáveis raio_quadrado e area. Analogamente, as variáveis locais
de calcular_volume_esfera são raio, raio_cubo e volume. Finalmente, as variáveis
locais de main são raio, peso, volume e densidade.

Cada função enxerga apenas as variáveis globais e suas variáveis locais! Assim, o


nome raio_cubo não está no escopo de main nem de calcular_area_disco. Mas e os
nomes de variáveis que são comuns a mais de uma função? Cada função têm suas
próprias variáveis locais, assim há três nomes raio distintos. Você pode pensar que há
o raio-da-função-calcular_area_disco, o raio-da-função-
calcular_volume_esfera e o raio-da-função-main. O mesmo acontece para a
variável volume que é comum a calcular_volume_esfera e main.

Para entender melhor, façamos um exercício. O que será impresso pelo programa a
seguir:
INCREMENTO = 3

def somar(x):
x = x + INCREMENTO

def main():
x = 10
somar(x)
print(x)

main()

Você deve ter respondido correto: 10. Embora ambas funções somar e main tenham uma


variável de nome x, elas são nomes que se referem a variáveis distintas.

Fazemos uma modificação. O que será impresso?

INCREMENTO = 3

def somar(x):
x = x + INCREMENTO
return x

def main():
x = 10
soma1 = somar(x)
INCREMENTO = 4
soma2 = somar(x)
print(soma1)
print(soma2)

main()

Agora o valor calculado pela primeira chamada de somar foi devolvido e armazenado


em um variável referenciada por soma1 e o valor calculado pela segunda chamada
em soma2. Nesse caso, não é tão simples descobrir o que será impresso. Se você simular
esse programa, obterá 13 e 13. Por que não foi impresso 13 e 14? Sabemos
que somar depende da variável global INCREMENTO, mas a função main faz uma
atribuição a uma variável local INCREMENTO. Lembrem-se de que atribuições feitas em
funções são sempre locais!

Por esse motivo (e por alguns outros que você ainda vai descobrir), nunca use ou faça
modificações em variáveis globais! Repetindo: nunca! A única razão para usarmos uma
variável global é para dar um nome a um valor que nunca deverá ser mudado durante a
execução do algoritmo. Chamamos essas variáveis de constantes. Aliás, é por esse
motivo que convencionamos escrever todas as variáveis globais em maiúsculas, para
indicar que elas são constantes e não devem ser alteradas.

Para descobrir o que está acontecendo internamente no interpretador Python,


precisamos entender o ciclo de vida de uma função. Para isso, vamos fazer mais um
exercício.
Crie um programa que leia a lista de notas dos estudantes e normalize as notas de
forma que a maior seja 10. Em seguida, determine para cada estudante da lista se ele
foi aprovado.

Experimente resolver esse exercício. Para os impacientes, segue o código que eu faria:

NOTA_MINIMA = 5.0

def obter_maximo(lista):
assert lista, "Lista não pode ser vazia"
maximo = lista[0]
for valor in lista:
if maximo < valor:
maximo = valor
# breakpoint()
return maximo

def multiplicar_fator(lista, fator):


n = len(lista)
for i in range(n):
lista[i] = lista[i] * fator

def ler_lista_notas():
n = int(input("Digite o número de estudantes: "))
lista_notas = []
for _ in range(n):
lista_notas.append(float(input()))
return lista_notas

def imprimir_lista_aprovacao(lista_notas):
for nota in lista_notas:
if nota < NOTA_MINIMA:
print("reprovado")
else:
print("aprovado")

def main():
lista_notas = ler_lista_notas()

maximo = obter_maximo(lista_notas)
fator = 10.0 / maximo
multiplicar_fator(lista_notas, fator)

imprimir_lista_aprovacao(lista_notas)

main()

Vamos fazer um desenho representando a memória do computador no momento


imediatamente anterior em que obter_maximo devolve o valor máximo da lista.
Há varias coisas a se notar. Nesse momento, a função ler_lista_notas já foi
executada e terminada, então todas as variáveis locais dessa função já não estão mais
disponíveis na função, isso é, não há escopo para as variáveis dessa função. Do mesmo
modo, a função multiplicar_fator a função imprimir_lista_aprovacao também não
foram chamadas ainda, e portanto suas variáveis ainda não foram criadas.

Há exatamente duas funções sendo executadas nesse momento: a função main e a


função obter_maximo, então existem exatamente dois escopos de função, além do
escopo global.

Opcional: Se você quiser verificar isso, faça o seguinte: descomente a linha


com breakpoint() na função obter_maximo() e execute o seu programa. Você entrará
no mode de debug com uma mensagem mostrando a próxima instrução. Digite bt (de
backtrace) para mostrar a trajetória do seu programa até essa instrução e explore
investigando os valores das variáveis, e.g., digite maximo para ver o valor associado a
esse nome no escopo atual. Se você não gosta de usar o terminal, então pode fazer o
mesmo configurando sua IDE preferida e adicionado um breakpoint na linha
correspondente ao return.

Digite o número de estudantes: 4


4.8
3.5
8.0
7.5
> /home/user/ra123456/funcoes/notas.py(10)obter_maximo()
-> return maximo
(Pdb) bt
/home/user/ra123456/funcoes/notas.py(40)<module>()
-> main()
/home/user/ra123456/funcoes/notas.py(34)main()
-> maximo = obter_maximo(lista_notas)
> /home/user/ra123456/funcoes/notas.py(10)obter_maximo()
-> return maximo
(Pdb) valor
7.5
(Pdb) maximo
8.0
(Pdb) continue
aprovado
reprovado
aprovado
aprovado

Imediatamente depois que a função obter_maximo termina, o valor referenciado na


frente de return é devolvido para a função main. Nesse momento, o escopo da
função obter_máximo é destruído. A próxima instrução de main é uma atribuição ao
nome de variável máximo, que recebe o valor devolvido. Podemos olhar para a seguinte
figura:
Com isso, podemos resumir o ciclo de vida de uma função:

1. quando uma função é chamada:

o criamos um novo escopo para essa chamada

o para cada argumento, associamos os valores passados entre parênteses

o continuamos executando a partir da primeira instrução da função

2. quando uma função termina:

o devolvemos o valor após return, se houver

o destruímos o escopo da chamada de função

o continuamos executando a próxima instrução imediatamente após a


chamada

O mecanismo de chamadas de função em Python tem uma consequência especial para


listas passadas como argumentos. Quando alteramos uma lista passada por argumento,
essas mudanças ficarão visíveis para a função que realizou a chamada. Isso acontece
quando chamamos a função multiplicar_fator. Para ver o motivo disso, repare que
tanto a variável lista_notas de main quanto a
variável lista de multiplicar_fator são associadas à mesma lista. O desenho a seguir
representa a memória imediatamente após a primeira iteração da linha lista[i] =
lista[i] * fator.
Módulos
Observação: Depois de ler o texto a seguir, procure ler e estudar a seção 6 do tutorial
Python.

À medida em que nossos projetos ficam maiores e mais complexos, copiar e colar um
conjunto de funções em nossos arquivos Python torna-se bastante difícil. Mais dos que
isso, pode ser que um conjunto de funções possa ser útil a diferentes programas. A
maneira de tratar isso no universo Python é criando-se módulos, que agrupam um
conjunto de funções e variáveis relacionadas.

Um módulo é um arquivo Python que é executado quando executamos o


comando import. Os módulos podem vir de várias partes, dependendo de como foram
instalados:

1. Já vimos um exemplo de módulo quando digitamos import math. Esse módulo


é um módulo da biblioteca padrão Python, o que significa que está disponível em
qualquer instalação Python.
2. Também há módulos adicionais instalados no sistema, que são responsáveis por
tarefas comuns, mas de domínios específicos, como manipulação de imagens,
comunicação com a Internet, computação científica, etc.
3. Finalmente, há módulos pessoais, que são aqueles criados pelo próprio
desenvolvedor para seus próprios projetos.

Por enquanto, só iremos falar em como criar um módulo pessoal. Para isso, vamos ver
um exemplo.

Em uma determinada disciplina, há 2 exercícios. A média parcial de um estudante é


dada pela média geométrica das notas dos exercícios. Escreva um programa que leia
as listas de notas de exercicios e mostre a lista de notas parciais.

Isso é bem parecido com o que já fizemos, então nada melhor do que copiar e colar
algumas funções auxiliares.
Copiamos ler_lista_numeros e imprimir_lista_numeros. Para calcular as médias, já
temos uma função que faz isso, calcular_medias_finais, mas melhor ajustar os
nomes, para não nos confundirmos. Com isso, escrevemos um programa
chamado notas_parciais.py.

import math

def ler_lista_numeros(n):
"""Lê uma lista de n números do teclado"""
lista = []
for _ in range(n):
numero = float(input())
lista.append(numero)
return lista

def imprimir_lista_numeros(lista):
"""Imprime cada número de lista em um linha,
com duas casas decimais"""
for valor in lista:
print(f"{valor:.2f}")

def calcular_medias_geometricas(lista1, lista2):


"""Devolve uma nova lista com as médias geométricas
dos elementos de lista1 e lista2"""
medias_geometricas = []
assert len(lista1) == len(lista2), "As listas de devem ter o mesmo
tamanho"
n = len(lista1)
for i in range(n):
media_geometrica = math.sqrt(lista1[i] * lista2[i])
medias_geometricas.append(media_geometrica)
return medias_geometricas

def main():
n = int(input())
notas_exercicios1 = ler_lista_numeros(n)
notas_exercicios2 = ler_lista_numeros(n)
medias_parciais = calcular_medias_geometricas(notas_exercicios1,
notas_exercicios2)
imprimir_lista_numeros(medias_parciais)

main()

Repare que, enquanto nesse programa resolvemos o problema de repetição de código,


afinal, só definimos ler_lista_numeros uma vez, temos que escrever exatamente as
mesmas instruções que estavam em outro programa anterior. Nessas situações, é mais
conveniente criar um módulo que possa ser compartilhado entre os dois programas.
Primeiro, criamos um arquivo, no mesmo diretório, chamado utilidades.py e
movemos as funções utilitárias para lá:

import math

def ler_lista_numeros(n):
"""Lê uma lista de n números do teclado"""
lista = []
for _ in range(n):
numero = float(input())
lista.append(numero)
return lista

def imprimir_lista_numeros(lista):
"""Imprime cada número de lista em um linha,
com duas casas decimais"""
for valor in lista:
print(f"{valor:.2f}")

def calcular_medias_geometricas(lista1, lista2):


"""Devolve uma nova lista com as médias geométricas
dos elementos de lista1 e lista2"""
medias_geometricas = []
assert len(lista1) == len(lista2), "As listas de devem ter o mesmo
tamanho"
n = len(lista1)
for i in range(n):
media_geometrica = math.sqrt(lista1[i] * lista2[i])
medias_geometricas.append(media_geometrica)
return medias_geometricas

Com isso podemos modificar nosso arquivo notas_parciais.py para conter apenas:

from utilidades import ler_lista_numeros, imprimir_lista_numeros,


calcular_medias_geometricas

def main():
n = int(input())
notas_exercicios1 = ler_lista_numeros(n)
notas_exercicios2 = ler_lista_numeros(n)
medias_parciais = calcular_medias_geometricas(notas_exercicios1,
notas_exercicios2)
imprimir_lista_numeros(medias_parciais)

main()

A primeira linha desse programa faz o seguinte:

1. procura o módulo chamado utilidades na sua lista de módulos e encontrar um


arquivo utilidades.py;
2. executa todas as instruções nesse módulo; nesse caso, há apenas instruções de
definição de funções;
3. torna disponível no escopo global do programa (notas_parciais.py) os nomes
das funções selecionadas.

Agora, essa as funções podem ser utilizadas em vários programas, mas sem a
necessidade de copiar e colar. Por exemplo, suponha que, no final do semestre,
tenhamos que escrever outro programa.

Além dos exercícios, há uma prova. A média final da disciplina é dada pela
média  aritmética entre a média parcial e a nota da prova. Escreva um programa que
leia as listas de notas marciais e das provas e mostre a lista de notas finais.

Podemos, agora escrever o seguinte programa, notas_finais.py

import utilidades

def calcular_medias_aritmeticas(lista1, lista2):


"""Devolve uma nova lista com as médias aritméticas
dos elementos de lista1 e lista2"""
medias_aritmeticas = []
assert len(lista1) == len(lista2), "As listas de devem ter o mesmo
tamanho"
n = len(lista1)
for i in range(n):
media_aritmetica = (lista1[i] + lista2[i]) / 2
medias_aritmeticas.append(media_aritmetica)
return medias_aritmeticas
def main():
n = int(input())
notas_exercicios1 = utilidades.ler_lista_numeros(n)
notas_exercicios2 = utilidades.ler_lista_numeros(n)
medias_parciais = calcular_medias_aritmeticas(notas_exercicios1,
notas_exercicios2)
utilidades.imprimir_lista_numeros(medias_parciais)

main()

Observe que agora utilizamos uma sintaxe alternativa para importar o módulo. O
módulo utilidades.py continua sendo executado e importado como antes. A única
diferença é que o nome da função ler_lista_numeros e demais não estarão disponíveis
no escopo global do nosso programa. Ao invés disso, estará disponível o
nome utilidades, por onde acessamos as funções do módulo.

Finalmente, repare que a função auxiliar calcular_medias_aritmeticas que tivemos


que criar é suficientemente genérica e pode ser que ela seja útil em outro programa.
Assim, pode ser razoável movê-la para o módulo de utilidades. Faça isso como
exercício. A decisão de quando uma função deve estar disponível em um módulo para
reuso ou mesmo como organizar os módulos não é trivial. Só com a experiência você
ganhará mais confiança para essa tarefa.

← ANTERIOR
VOLTAR
PRÓXIMA →

Copyright 

Já aprendemos razoavelmente bem a linguagem de Python. Vimos como escrever


programas que comandos sequenciais e condicionais if, comandos
iterativos for e while e a organizar e chamar funções. Também vimos como declarar
variáveis e criar listas. Nesta unidade, iremos explorar um pouco como utilizar tudo isso
para resolver problemas, alguns mais simples, outros um pouco mais complicados.

Iterações simples e variáveis iteradoras


Vamos revisar alguns trechos de códigos triviais, mas vamos estudar um pouco mais os
detalhes do que está acontecendo. Primeiro, vamos imprimir os 100 primeiros números
inteiros.

for i in range(100):
print(i)

Esse trecho usa diretamente o comando for, que é utilizado quando queremos percorrer
sequências, no caso o intervalo range(100) que corresponde a uma sequência de
números 0,1,…,99. Essa é a maneira natural de resolver esse problema em Python,
assim ela esconde uma série de detalhes sobre o nosso algoritmo.
Para entender o que o computador faz quando executamos um código como esse, é
melhor reescrever o trecho de uma maneira equivalente, mas utilizando instruções mais
simples, i.e., de mais baixo nível de abstração.

i = 0
while i < 100:
print(i)
i += 1

Agora podemos fazer várias observações. A variável i está intimamente ligada ao laço


iterativo; como no exemplo ela conta o número de iterações executadas, damos a ela o
nome de variável contadora. Podemos identificar partes relevantes que acessam ou
modificam o valor de i.

1. Inicialização. A variável contadora é inicializada em i = 0; inicializar significa


associar um valor inicial adequado antes do primeiro uso.

2. Condição. Testamos uma condição para continuar executando o laço em i <
100. Alguma vezes é útil pensar que o teste irá falhar apenas quando atingirmos
uma condição desejada (ter impresso 100 números). Observe que imediatamente
depois do laço, o valor de i é igual a 100.

3. Atualização. A variável é atualizada com i += 1. Dentro do corpo do laço deve


haver algum mecanismo para atualizar a variável contadora de forma que, em
algum momento, a condição falhe.

A figura abaixo tem um código escrito em outra linguagem de programação. Você é


capaz de identificar a inicialização, a condição e a atualização?

De maneira mais geral, podemos ter várias variáveis associadas a um laço. Como nem
sempre essas variáveis contam o número de iterações executadas, costumamos chamá-
las de variáveis acumuladoras, já que elas acumulam os resultados das operações de
atualização. A seguinte função imprime as primeiras n potências na base dois.

def imprimir_potencias(n):
i = 0
pot = 0
while i < 10:
print(f"2^{i} = {pot}")
i = i + 1
pot = 2 * pot

Reflita sobre qual é o valor de i e pot imediatamente depois de terminado o laço.

Vamos tentar responder uma pergunta um pouco mais fundamental: será que um
computador que tem operação de divisão é mais poderoso do que um que não tem? Para
responder isso, vamos resolver um exercício rápido.

Calcule a divisão inteira de dois números usando apenas soma e subtração.

Aqui estamos deliberadamente limitando o nosso computador para não utilizar a


operação de divisão. Primeiro, vamos escrever um algoritmo. A entrada são dois
números, um dividendo e um divisor.

1. residuo←dividendo
2. contador←0
3. Enquanto residuo≥divisor:
1. residuo←residuo−divisor
2. contador←contador+1
4. Exiba contador

Repare que nesse pequeno algoritmo, temos uma variável acumuladora que decresce de
valor. Reflita sobre a correção desse algoritmo e faça alguns testes pequenos para se
convencer. Uma possível implementação em Python seria:

def divisao_inteira(dividendo, divisor):


residuo = dividendo
contador = 0
while residuo >= divisor:
residuo = residuo - divisor
contador += 1
return contador

Você poderia resolver esse problema usando um for ao invés de while?

Comandos iterativos aninhados


Eventualmente, queremos executar um comando iterativo no corpo de um outro
comando iterativo. Na maioria das vezes, iremos lidar com duas variáveis contadoras
simultaneamente. Por isso, é importante prestar atenção no nomes das variáveis e como
e quando elas são alteradas.

Observe e procure entender o seguinte trecho:

def imprimir(m, n):


for i in range(m):
print(f"Linha {i}:", end="")
for j in range(n):
print(f" ({i},{j})", end="")
print()

Essa função deve imprimir algo como

Linha 0: (0,0) (0,1) (0,2) (0,3)


Linha 1: (1,0) (1,1) (1,2) (1,3)
Linha 2: (2,0) (2,1) (2,2) (2,3)

Quando temos comandos iterativos aninhados como o anterior, normalmente falamos do


laço externo e do laço interno. Iremos dizer que para cada valor fixado da variável i,
percorremos com variável j. Vamos ver outro exemplo, uma pouco mais concreto, mas
igualmente entediante:

media_provas = 0.0
for prova in range(1, 4):
nota_prova = 0.0
for questao in range(1, 11):
print(f"Digite a nota questao {questao} da prova {prova}: ")
nota_questao = float(input())
nota_prova += nota_questao
media_provas += nota_prova
media_provas = media_provas / 3.0
print(f"A média das provas foi {media_provas}")

Laços infinitos
Quando um programa executa indefinidamente um mesmo conjunto de instruções de
um laço, então esse é um laço infinito. Por esse motivo, algumas vezes dizemos que o
programa está ou entrou em loop. Isso ocorre por um erro no programa, que faz com
que o laço nunca atinja a sua condição de parada. A causa pode ser um mero erro de
digitação, ou alguma condição especial não tratada pelo algoritmo.

Vamos criar um programa para imprimir o triângulo como o abaixo, mas com n linhas.

**********
*********
********
*******
******
*****
****
***
**
*

Um candidato a programa seria.

def triangulo(n):
i = 1
j = n
while i <= n:
i = 0
while i < j:
print('*', end="")
i = i + 1
print()
j = j - 1

Esse programa imprime o triângulo desejado, mas continua executando


indefinidamente. Se você quiser parar a execução terá que instruir o seu terminal a
finalizar o processo, normalmente apertando-se as teclas CTRL + C no seu terminal. O
problema não está na ideia do algoritmo, mas no fato de que reusamos uma variável
para representar dois valores distintos! Descubra qual é esse erro, explique porque a
condição de parada nunca é alcançada e corrija o programa.

Comandos não estruturados


Algumas vezes, a condição de parada escrita logo depois do comando while nunca é
alcançada, mas o programa não entra em loop. Já vimos um caso desses, aqui está outro
exemplo:

def ler_inteiro():
while True:
string_lida = input("Digite um número inteiro não negativo: ")
if string_lida.isdigit():
return int(string_lida)

Essa função insiste em ler um número do teclado até que o usuário digite uma entrada
válida composta somente de números decimais. Repare que saímos do laço com um
comando return. O mesmo efeito poderia ser utilizado com o comando break.

Uma atenção especial deve ser dada a esses


comandos: break, continue, return (quando utilizado dentro de um laço). Eles são
comandos não estruturados, o que significa que algumas das propriedades dos laços
que normalmente esperaríamos não serão satisfeitas. Por exemplo, considere o trecho:

i = 1
while i < 10:
print(f"Linha {i}:", end="")
if i % 2 == 0:
continue
if i == 4:
break
for j in range(i):
print(f" {j}", end="")
print()
i += 1

Esse programa entra em loop e tem um comportamento bem difícil de entender. O


motivo é que a variável contadora não é atualizada em toda iteração. Para corrigir isso,
mova a linha i += 1 para o início do corpo do laço. Qual o valor de i quando o
programa termina? Tente determinar a saída desse programa.
Fauna
Vamos ver um problema um pouco mais interessante do que os anteriores — pelo
menos um pouco mais animal.

Um coelho está a dois metros de sua esposa. Para chegar até ela, ele salta uma vez a
cada minuto. Primeiro dá um saldo de um metro, depois de meio metro, depois de um
quarto de metro e assim por diante. Em quanto tempo ele chegará até ela?

Parece fácil resolver esse problema. Basta uma variável acumuladora para guardar a
distância percorrida e outra para guardar o tamanho do próximo passo. Tente resolver
esse exercício. Eu escreveria o seguinte:

def tempo_gasto_coelho():
numero_saltos = 0
distancia = 0.0
proximo_salto = 1.0

while distancia < 2.0:


numero_saltos += 1
distancia += proximo_salto
proximo_salto = proximo_salto / 2

return numero_saltos

def main():
tempo = tempo_gasto_coelho()
print(f"O coelho gasta {tempo} minutos")

main()

Você já deve estar desconfiado — e com razão — de que esse programa tem algum
erro. De fato, não faz muito tempo você deve ter aprendido a calcular soma de uma
progressão geométrica. Nesse problema, a distância percorrida pelo coelho é dada pela
soma dos inversos de n potências na base dois,

âdistância=1+12+14+⋯+12n,

onde n é o número de saltos do coelho. Se você tem boa memória, deve se lembrar de
que essa soma é sempre menor que 2 e só é igual a 2 quando n=∞. Parece razoável
então supor que esse é mais um exemplo de programa que entra em laço infinito.
Execute esse programa e explique o seu comportamento!

Vamos agora mudar de animal.

Uma tartaruga está a 22m de sua casa. No primeiro minuto, ela anda um metro, no
segundo minuto, mais cansada, meio metro, no terceiro, um terço de metro e assim por
diante. Em quanto tempo ela chegará até a casa?
Não é difícil modificar o programa anterior para calcular o tempo que a tartaruga irá
gastar. Faça isso.

def tempo_gasto_tartaruga():
numero_passo = 0
distancia = 0
proximo_passo = 0

while distancia < 22:


numero_passo += 1
distancia += proximo_passo
proximo_passo = 1.0 / numero_passo

return numero_passo

Dessa vez, devemos esperar que o programa pare. Isso porque você já sabe ou irá
aprender em breve que a soma da série harmônica diverge, isso é, para qualquer número
real D, sempre existe um número n tal que

1+12+13+⋯+1n>D.

Assim, existe um número n0 para o qual a soma é maior do que 22. Portanto, no


momento que nossa função tempo_gasto_tartaruga tiver executado n0 iterações, a
condição do while irá falhar e o programa irá terminar.

Execute o programa acima e descubra e verifique se o programa realmente para e


explique o comportamento do programa. Algumas vezes, quando estamos estudando um
programa, é útil investigar como as variáveis contadoras e acumularas estão se
modificando. Para isso usamos um debugger ou adicionamos instruções de impressão
no corpo do código. Nesse exemplo, eu adicionaria as seguintes linhas no final do corpo
do while:

if numero_passo % 1000000 == 0:
print(distancia)

Lendo e simulando código


Enquanto aprender a programar implica em aprender a escrever um programa, na maior
parte do tempo em que estivermos programando vamos estar fazendo o inverso: lendo
código. Os trechos de códigos que lemos algumas vezes são trechos de código que
escrevemos recentemente, mas serão principalmente trechos de códigos escritos por
outra pessoa, ou que nós mesmos escrevemos há muito tempo.

A razão para termos de ler programas são diversas. Em particular, lemos código porque
ele não faz o que esperávamos que fizesse. Entre os motivos para isso ocorrer, já
sabemos que estão os erros de programação, quando utilizamos instruções de maneira
equivocada, ou erros de lógica, quando o algoritmo que projetamos não resolve o
problema correspondente.
O nosso desafio é, portanto, descobrir um erro. Independente do tipo de erro, a principal
ferramenta para identificá-lo é a simulação de código. Sabemos que ela pode ser feita
de duas maneiras distintas

1. Automaticamente, utilizando um debugger. Normalmente chamamos esse


processo de depuração ou debugging.

2. Manualmente, utilizando lápis e papel. Normalmente, chamamos esse processo


de teste de mesa.

Vamos ver um exemplo:

def desenho(n):
m = 2 * n - 1
for i in range(n):
j = 2 * i + 1
for k in range((m-j)//2):
print(" ", end="")
for k in range(j):
print("*", end="")
print()

Para entender o que essa função faz, podemos usar a seguinte estratégia:

1. Faça um teste de mesa. Use valores razoáveis para os dados da entrada. Por
exemplo, se n = 1 então talvez não iremos simular todas as linhas de código; se n
= 100, então o teste de mesa será muito lento e entediante e não iremos conseguir
simular no papel. Usar n = 4 parece uma boa tentativa para essa função.

2. Procure descrever em alto nível o que cada laço faz independentemente,


ignorando os detalhes. Por exemplo, ao final do laço mais externo temos a
impressão de uma quebra de linha, então sabemos que cada iteração corresponde a
uma linha; o primeiro laço interno tem um único comando que imprime um
espaço, então sabemo que esse laço imprime uma sequência de espaços, etc.

Usando essa estratégia, descreva o que essa função faz.

Desenhando na tela
Vamos criar um programa que desenha um disco na tela, usando caracteres, como o
seguinte:

*
* * * * * * * * *
* * * * * * * * * * * * *
* * * * * * * * * * * * * * *
* * * * * * * * * * * * * * * * *
* * * * * * * * * * * * * * * * *
* * * * * * * * * * * * * * * * * * *
* * * * * * * * * * * * * * * * * * *
* * * * * * * * * * * * * * * * * * *
* * * * * * * * * * * * * * * * * * *
* * * * * * * * * * * * * * * * * * * * *
* * * * * * * * * * * * * * * * * * *
* * * * * * * * * * * * * * * * * * *
* * * * * * * * * * * * * * * * * * *
* * * * * * * * * * * * * * * * * * *
* * * * * * * * * * * * * * * * *
* * * * * * * * * * * * * * * * *
* * * * * * * * * * * * * * *
* * * * * * * * * * * * *
* * * * * * * * *
*

Repare que o raio do disco é 10, então o número de linhas é 21. Antes de escrever um
código, vamos pensar em um algoritmo simples em alto nível:

1. para cada linha de 1 até 2∗RAIO+1

1. calcule o número de espaços para a linha


2. calcule o número de asteriscos para linha
3. imprima uma string de espaço
4. imprima uma string de asteriscos com quebra de linha

Nem todos os passos estão bem definidos, então precisamos detalhar em como executar
cada um dos passos. Vamos arriscar escrever o algoritmo principal e deixar os detalhes
para depois.

RAIO = 10

def desenhar_disco():
for linha in range(2 * RAIO + 1):
num_espaco = calcular_num_espacos(linha)
num_asterisco = calcular_num_asteriscos(linha)
str_espaco = " " * num_espaco
str_asterisco = "* " * num_asterisco
print(str_espaco, end="")
print(str_asterisco, end="")
print()

Enquanto o algoritmo é bem simples, pulamos a definição de duas funções importantes.


Executar essas instruções não é uma tarefa trivial e, para isso precisamos de algum
conhecimento em geometria e alguma paciência. Com um pouco de álgebra,
descobrimos o número de asteriscos em uma linha e depois o número de espaços. Você
não precisa se preocupar em como chegar nessas contas se não quiser.

def calcular_num_asteriscos_eixo(linha):
y = RAIO - linha
x = math.sqrt(RAIO ** 2 - y ** 2)
return int(x)

def calcular_num_asteriscos(linha):
return 2 * calcular_num_asteriscos_eixo(linha) + 1
def calcular_num_espacos(linha):
return RAIO - calcular_num_asteriscos_eixo(linha)

Teste esse programa. Enquanto nosso algoritmo funciona e resolve a tarefa, a solução é
bem insatisfatória. Parece muito difícil ter que pensar em tantos detalhes e, se quisermos
mudar a figura geométrica, teremos que escrever outro algoritmo completamente
diferente.

Para criar um programa um pouco mais simples e mais fácil de modificar, podemos
tentar resolver a mesma tarefa com um algoritmo diferente. Olhar para para um mesmo
problema por diferentes perspectivas pode nos trazer algoritmos mais simples.

Podemos interpretar a tela do computador como uma tela de pintura. Assim, cada
espaço na tela representa um lugar ou uma célula onde não pintamos e cada asterisco
uma célula que pintamos. Além disso, vamos imaginar que temos um sistema de
coordenadas, como na figura:

Com isso, tudo que precisamos fazer é percorrer toda a tela e imprimir asterisco ou
espaço, dependendo se a célula deve ou não ser pintada. Repare que podemos batizar
cada célula da figura com um par de números (i,j) correspondente à abscissa e à
ordenada do nosso sistema de coordenadas. Criamos o seguinte programa:

RAIO = 10

def esta_disco(i, j):


"""Devolve true se (i,j) estiver
no disco"""
return i ** 2 + j ** 2 <= RAIO ** 2

def desenhar_disco2():
for i in range(-RAIO, RAIO + 1):
for j in range(-RAIO, RAIO + 1):
no_disco = esta_disco(i, j)
if no_disco:
print("* ", end="")
else:
print(" ", end="")
print()

Comparando com o algoritmo anterior, embora a função desenhar_disco2 tenha dois


laços aninhados, ela parece mais simples de entender. Mais importante, é mais fácil
modificá-la. Modifique o programa para que ele desenhe uma elipse ao invés de um
disco. Depois experimente desenhar outras figuras geométricas.

Ordenação
Em seguida, vamos tratar de um problema clássico em Computação.
Escreva uma função que recebe uma lista de números inteiros e ordene-os de maneira
crescente.

Comparando elementos
De maneira um pouco mais geral, estamos interessados em estudar algoritmos para
ordenar conjuntos de elementos. Esses elementos podem ser de vários tipos. A única
restrição que fazemos sobre eles é que possamos compará-los:

 números reais,
 nomes de pessoas,
 times de futebol... :)

Poder comparar significa que dados dois elementos, podemos dizer se um é menor do
que o outro. Para ser um pouco mais preciso, uma comparação ≤ é uma relação entre os
elementos de um conjunto A de forma que dados dois elementos quaisquer x,y∈A,
podemos decidir se x≤y ou não.

Isso é bastante claro para números reais, afinal basta compararmos de acordo com a reta
real. Mas isso não é claro se formos trabalhar com números complexos. Vamos fazer
alguns testes. Em Python, podemos escrever um número imaginário adicionando o
prefixo j ao número. Faça o seguinte em um console Python e tente experimentar com
números reais, números complexos e comparações.

>>> inteiro_x = 23
>>> flutuante_y = 3.6
>>> inteiro_x <= flutuante_y
False
>>> complexo_w = 1j
>>> complexo_w ** 2
(-1+0j)
>>> complexo_z = 1 - 3j
>>> complexo_w <= complexo_z
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: '<=' not supported between instances of 'complex' and
'complex'
>>> complexo_w <= flutuante_y
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: '<=' not supported between instances of 'complex' and 'float'

Para comparar nomes de pessoas queremos comparar strings. Não é tão evidente como
comparar duas strings assim como comparar dois números. Para isso, precisamos
entender como uma string é representada em memória: uma string é uma sequência de
caracteres e um caractere é representado por um ou mais bytes. Esses bytes representam
números em uma grande tabela padronizada chamada Unicode, assim, se compararmos
duas strings com exatamente um caractere cada uma, basta comparar os números
correspondentes.

>>> caractere_a = 'a'


>>> ord(caractere_a)
97
>>> chr(97)
'a'
>>> caractere_b = 'B'
>>> ord(caractere_b)
66
>>> caractere_a <= caractere_b
False

Perceba que o caractere 'B' maiúsculo vem antes do caractere 'a' minúsculo porque o


código dos caracteres maiúsculos vêm antes na tabela. Uma vez que sabemos comparar
dois caracteres, podemos compara caractere por caractere lexicograficamente usando a
mesma estratégia dos dicionários. Tente ordenar as palavras abaixo lexicograficamente
e depois verifique a sua ordenação usando o console Python.

Zumbi
zebra
zumba
tumba
almanaque
alma

Da mesma maneira que podemos ordenar strings lexicograficamente, Python permite


comparar listas. Existem algumas restrições no entanto, a principal delas é que
possamos comparar os elementos das listas individualmente. Experimente:

>>> lista_x = [1, 2, 3]


>>> lista_y = [0.4, 100.0, 200, 1000]
>>> lista_x < lista_y
False
>>> lista_z = [1, 2, 3j]
>>> lista_x < lista_z
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: '<' not supported between instances of 'int' and 'complex'

Finalmente, queremos comparar times de futebol. Obviamente Python não toma partido
de nenhum time e sequer entende o que é um time de futebol. Para que possamos
comparar então, precisamos duas coisas:

1. como representar um time?


2. como comparar duas dessas representações?

Por exemplo, pode ser que queremos representar os dados estatísticos do time em um
campeonato. Se no campeonato a ordenação dos times dos melhores para os piores
seguir a ordem de mais pontos, maior saldo de gols e menor número de cartões
amarelos, podemos representar um time usando uma tupla:

(−pontos,−saldo_gols,cartoes_amarelos)

Com isso, podemos usar o fato de que Python já sabe comparar tuplas de números e
utilizar o operador nativo.
>>> flamingo = (-15, -10, 3)
>>> botachamas = (-15, -10, 1)
>>> mangueiras = (-10, -12, 0)
>>> flamingo <= botachamas
False

Porque utilizamos números negativos? Quem é o primeiro colocado entre os três times
anteriores?

É claro que dependendo do campeonato, a ordenação será diferente. Mais do que isso,
pode ser que queremos ordenar times de maneira geral, assim vamos representar um
time apenas por uma string contendo o nome do time. Como não queremos ordenar os
times por ordem alfabética, não podemos mais utilizar o operador <= de Python. Por
isso, precisamos de algum mecanismo alternativo para decidir se um elemento vem
antes de outro elemento.

O mecanismo que normalmente adotamos é criar uma função comparar que recebe dois


argumentos x e y e simula o papel da x <= y. Assim comparar(x,y) é True sempre
que x <= y. Repare que definimos apenas um operador correspondendo a <=, mas se
quisermos saber ser x > y então bastaria escrever not comparar(x,y).

Dada a natureza subjetiva de comparação de times, cada torcedor teria critérios


diferentes para comparar seus times. Por exemplo, um flaminguista pode acreditar que
seu time vem antes de qualquer outro e que todos os outros são iguais. Ele escreveria:

def comparar_times(time_x, time_y):


if time_x == time_y:
return True
elif time_y == 'Flamingo':
return False
else:
return True

Experimente passando vários argumentos distintos e tente explicar o comportamento


dessa função.

A discussão anterior deve ter deixado claro que a relação de comparação é apenas um
conceito abstrato e a maneira como implementamos essa comparação é indiferente para
os algoritmos de ordenação.

Algoritmos de ordenação
Existem várias estratégias para ordenar uma lista de números. Vamos estudar três
estratégias, que levam a três algoritmos distintos.

1. Percorrer os elementos dois a dois e trocar pares de elementos fora de ordem e


continuar esse processo até que todos estejam ordenados. Já vimos o algoritmo que
faz isso, que é o algoritmo ordenação da bolha ou bubble sort.

2. Selecionar o menor elemento e trocá-lo com o primeiro e repetir esse processo


com os demais. Esse é o algoritmo de ordenação por seleção ou selection sort.
3. Percorrer os elementos e inserir cada um deles na posição correta. Esse é o
algoritmo de ordenação por inserção ou insertion sort.

Ordenação da bolha

Já vimos o algoritmo de ordenação da bolha. Vamos reescrever o algoritmo em


português, dessa vez em mais alto nível.

1. Repita n−1 vezes:

1. Para cada índice i do primeiro ao penúltimo


1. Compare o elemento de i com o de i+1
2. Se estiverem fora de ordem, troque-os

Com o algoritmo escrito, fica fácil escrever uma função em Python.

def bubble_sort(lista):
n = len(lista)
for _ in range(n-1):
for i in range(n - 1):
if lista[i] > lista[i+1]:
aux = lista[i]
lista[i] = lista[i+1]
lista[i+1] = aux

As últimas três linhas realizam a troca dos elementos. Elas são instruções bem simples,
então dificilmente alguma programadora iria convertê-las em uma função em um código
real. Na nossa discussão, poderia ser mais claro se pudéssemos dizer trocar(lista, i,
i+1), então vamos reescrever nossa função por apelo a clareza.

def trocar(lista, i, j):


aux = lista[i]
lista[i] = lista[j]
lista[j] = aux

def bubble_sort(lista):
n = len(lista)
for _ in range(n-1):
for i in range(n - 1):
if lista[i] > lista[i+1]:
trocar(lista, i, i+1)

Nunca devemos nos esquecer de testar. Façamos isso adicionando e executando.

def main():
lista = [3, 5, 2, 0, 9, 6]
bubble_sort(lista)
print(lista)

main()

Ordenação por seleção


O algoritmo de ordenação por seleção pode ser resumido como colocar cada item no seu
devido lugar. Assim, primeiro colocamos o primeiro elemento na primeira posição, em
seguida colocamos o segundo elemento na segunda posição e assim por diante.

Pode ser útil colorir a lista em duas cores: uma parte verde no início da lista que já
contém todos os elementos ordenados e uma parte preta, que contém os demais
elemento, todos eles maiores do que os elementos na lista verde. Portanto, para
aumentar o tamanho da parte verde da lista, basta encontrar a posição do menor
elemento da lista preta e trocá-lo de posição com o primeiro elemento da lista preta.

Veja a animação a seguir que exemplifica a execução desse algoritmo. Para animar,
clique na figura e segure as setas para direita ou para a esquerda.


Já podemos escrever o algoritmo em português. No algoritmo a seguir, iremos falar de


lista preta. Para deixar esse termo preciso, iremos dizer que a lista preta é a parte da
lista original que começa no índice i e vai até o último índice.

1. para cada índice i do primeiro até o último

1. encontrar o menor elemento da lista preta


2. troque esse elemento com o primeiro da lista preta

Simples, claro e conciso. Agora podemos implementar; como sempre, iremos utilizar
stubs para simplificar o processo de desenvolvimento.

def encontrar_indice_menor(lista, i):


"""Devolve o índice do menor elemento em lista[i:]"""
pass

def selection_sort(lista):
for i in range(len(lista)):
indice_menor = encontrar_indice_menor(lista, i)
trocar(i, indice_menor)

Uma pergunta, o que acontece quando i corresponde ao último índice da lista?

Uma observação é importante. Para que possamos trocar dois elementos da lista com a
função trocar, precisamos do índice onde está o menor elemento da lista preta, não o
valor. Agora implementemos o stub.

def encontrar_indice_menor(lista, i):


"""Devolve o índice do menor elemento em lista[i:]"""
indice_menor = i
for j in range(i, len(lista)):
if lista[j] < lista[indice_menor]:
indice_menor = j
return indice_menor
Não foi muito mais difícil do que encontrar o mínimo a lista inteira. Você viu como é
muito mais simples utilizar funções, sempre nos preocupamos com tarefas pequeninas.
Mas com a experiência, a maioria dos programadores iria escrever todas as instruções
do algoritmo diretamente no corpo de selection_sort. Tente fazer isso. Claro, não
deixe de testar sua função adicionando uma chamada na função main.

Ordenação por inserção

Para explicar o algoritmo de inserção, pode ser útil fazer um exercício de pensamento.
Imagine você com um baralho de cartas. Para ordenar, você coloca o deck de cartas do
lado esquerdo da mesa e pega a carta do topo, uma por vez. A cada carta retirada, vc
insere em um novo deck de cartas do lado direito da mesa, já na posição correta. É claro
que no final, todas as cartas estarão ordenadas no deck da direita.

Enquanto essa intuição é simples, não queremos utilizar esse algoritmo. O motivo é que
não queremos criar duas listas simplesmente para ordenar os elementos. Usar duas
listas, além de gastar mais memória e mais tempo de execução, é completamente
desnecessário para esse algoritmo. Para utilizar apenas uma lista, vamos de novo pintá-
lo com duas cores: uma parte verde ordenada e outra preta com os demais elementos.


Vamos escrever o algoritmo. Mais uma vez, vamos usar i para representar o início da
lista preta e dizer que a lista verde é a parte da lista do primeiro elementos até o último
antes de i.

1. para índice i do segundo até o último:

1. chave←lista[i]
2. encontre a posição de inserção j de chave na lista verde
3. desloque para direita os elementos do índice j até i−1
4. lista[j]←chave

De novo, temos algumas instruções ainda não completamente especificadas. Vamos


escrever o algoritmo usando stubs. Vamos aproveitar a descrição dos passos do nosso
algoritmo para documentar essas funções.

def encontrar_posicao(lista, i, chave):


"""
devolve a posição de inserção de chave em lista[:i]
"""
pass

def deslocar_lista(lista, i, j):


"""
desloca para direita os elementos de lista
do índice j até i-1
"""
pass

def insertion_sort(lista):
for i in range(1, len(lista)):
chave = lista[i]
j = encontrar_posicao(lista, i, chave)
deslocar_lista(lista, i, j)
lista[j] = chave

Agora não deve ser difícil implementar cada subtarefa independentemente. Fazemo-lo!

def encontrar_posicao(lista, i, chave):


"""
devolve a posição de inserção de chave em lista[:i]
"""
j = 0
while j != i and chave > lista[j]:
j += 1
return j

def deslocar_lista(lista, i, j):


"""
desloca para direita os elementos de lista
do índice j até i-1
"""
k = i
while k > j:
lista[k] = lista[k-1]
k -= 1

Vamos refletir um pouco sobre esse algoritmo. Em cada iteração, queremos descobrir
em qual posição j da lista verde iremos inserir o valor de chave. Assim, percorremos do
primeiro até o índice j. Depois, temos que deslocar a parte da lista de j até o índice i-1.
Isso significa que devemos acessar todos os elementos da lista verde em toda iteração!
Faça o seguinte, com essa preocupação em mente, tente simular o algoritmo de
ordenação por inserção para a seguinte lista:

lista = [1, 2, 3, 5, 4, 6, 8, 7]

Simule todos os passos na mão. Tente descobrir uma melhoria nesse algoritmo de forma
a evitar ter de percorrer toda a lista verde em toda iteração! Implemente essa melhoria,
dessa vez sem utilizar sub-rotinas.

← ANTERIOR
VOLTAR
PRÓXIMA →

Copyright © 2020

Quando aprendemos listas, nossos exemplos todos tratavam de listas com valores
escalares. Dizemos que uma variável é de um tipo escalar porque os valores possíveis
são simples e indivisíveis, como um número inteiro ou um número de ponto flutuante.
Muitas vezes, também vamos olhar para strings como uma unidade (sem se preocupar
com quais partes a formam), então também chamaremos as strings de valores escalares.

Restringir-nos a listas de valores escalares pode simplificar nossos algoritmos, mas


também limita o tipo de estrutura que conseguimos representar. Para muitas aplicações,
elas são tudo do que precisamos.

Escreva um programa que leia as notas de 10 exercícios de um estudante e calcule a


média dos 9 exercícios com maiores notas.

Esse problema é mais simples do que muitos outros que já fizemos, mas vamos resolvê-
lo agora com uma atenção especial à representação dos dados. Queremos representar as
notas de um aluno, então vamos armazená-las em um lista de notas.

Em Python, os tipos das variáveis não estão anotadas juntamente com os nomes das
variáveis. Por isso, precisamos tomar bastante cuidado em como nomeamos nossas
variáveis, para ficar claro quando estamos lidando com um valor escalar, ou com uma
lista de escalares. Para isso, devemos utilizar os nomes consistentemente:

 Uma nota é um escalar do tipo float. Vamos denotar variáveis de


tipos escalares sempre pelo nome nota.
 Uma lista de notas é do tipo list e cada elemento dessa lista é um escalar que
representa uma nota. Vamos denotar variáveis de tipo lista de escalares sempre
pelo nome lista_notas.

Uma vez que já sabemos como representar os dados na memória do computador, já


podemos passar a escrita do algoritmo. Você deve escrever um algoritmo e construir o
seu programa incrementalmente. Eu vou adiantar tudo isso e mostrar o programa já
pronto.

NUMERO_EXERCICIOS = 3

def ler_lista_notas(n):
"""Devolve uma lista de n notas lidas do teclado"""
lista_notas = []
for _ in range(n):
print("Digite a próxima nota: ")
nota = float(input())
lista_notas.append(nota)
return lista_notas

def calcular_media_excluida(lista_notas, indice_excluida):


"""Devolve a média de lista_notas excluindo-se
a nota de índice indice_excluida"""
soma = 0.0
for i, nota in enumerate(lista_notas):
if i != indice_excluida:
soma = soma + nota
media = soma / (len(lista_notas) - 1)
return media

def obter_indice_menor(lista_notas):
"""Devolve o índice da menor nota"""
indice_menor = 0
menor_nota = lista_notas[0]
for i, nota in enumerate(lista_notas):
if nota < menor_nota:
menor_nota = nota
indice_menor = i
return indice_menor

def main():
print("Digite as notas dos exercícios:")
lista_notas = ler_lista_notas(NUMERO_EXERCICIOS)
indice_menor = obter_indice_menor(lista_notas)
media = calcular_media_excluida(lista_notas, indice_menor)
print(f"A média excluindo a pior nota é {media}")

main()

Leia esse programa com atenção. Estude o que cada função faz. Não continue lendo este
texto até que tenha entendido e internalizado esse programa.

É claro que uma professora não gostaria de usar esse programa, porque ela não tem
apenas um estudante. É bem possível que sua turma tenha 100 estudantes. Então ela
teria que executar esse programa 100 vezes e tomar nota manualmente da média de cada
um. Pior, pode ser que a professora decida que irá excluir a nota do mesmo exercício
para a turma inteira, então esse programa já não seria mais útil. Vejamos por quê:

1. para descobrir a média da turma, fixamos um exercício, e percorremos a lista de


notas de todos os estudantes para esse exercício

2. para calcular a média de um estudante, fixamos esse estudante e percorremos a


lista de notas de todos os exercícios para esse estudante

Agora deve estar claro porque nos restringir a listas de escalares é insuficiente:
precisamos tanto da lista de notas de todos os exercícios para um estudante, quanto da
lista de notas de um exercício para todos estudantes. Para deixar esse problema mais
concreto, vamos resolver o seguinte problema:

Escreva um programa que leia a notas de 10 exercícios de 100 estudantes e depois:

1. calcule a média da turma para cada exercício;


2. descubra o exercício com menor média da turma;
3. calcule a média de cada estudante, excluindo-se esse exercício.

Antes de começar a escrever nosso algoritmo e nosso programa, precisamos pensar na


maneira como vamos representar os dados na memória. O que muda é que agora, além
de representar as notas dos exercícios de um estudantes, precisamos guardar a tabela de
notas da turma inteira. Vamos estender a nossa convenção:

 Uma nota é um escalar. Vamos denotar variáveis de tipos escalares sempre pelo


nome nota.
 Um estudante tem um lista de notas. Vamos denotar variáveis de tipo lista de
escalares sempre pelo nome lista_notas;
 A tabela de notas da turma é uma lista de lista de notas. Vamos denotar variáveis
de tipo lista de lista de escalares pelo nome tabela_notas.

De novo, escrevemos um algoritmo e implementamos esse programa incrementalmente.


Vamos omitir esse processo e ver o resultado.

NUMERO_EXERCICIOS = 10
NUMERO_ESTUDANTES = 100

def ler_lista_notas(n):
"""Devolve uma lista de n notas lidas do teclado"""
lista_notas = []
for _ in range(n):
print("Digite a próxima nota: ")
nota = float(input())
lista_notas.append(nota)
return lista_notas

def ler_tabela_notas(m, n):


"""Devolve a tabela de notas de m estudantes
com n notas de exercícios cada, lidas do teclado"""
tabela_notas = []
for _ in range(m):
print("Digite as notas dos exercicios do proximo estudante: ")
lista_notas = ler_lista_notas(n)
tabela_notas.append(lista_notas)
return tabela_notas

def calcular_lista_medias(tabela_notas):
"""Devolve uma lista com as médias dos exercício"""
m = len(tabela_notas) # número de estudantes
n = len(tabela_notas[0]) # número de exercícios

lista_medias = []
for j in range(n):
soma = 0
for i in range(m):
soma = soma + tabela_notas[i][j]
media = soma / m
lista_medias.append(media)

return lista_medias

def obter_indice_menor(lista_notas):
"""Devolve o índice da menor nota"""
indice_menor = 0
menor_nota = lista_notas[0]
for i, nota in enumerate(lista_notas):
if nota < menor_nota:
menor_nota = nota
indice_menor = i
return indice_menor

def calcular_media_excluida(lista_notas, indice_excluida):


"""Devolve a média de lista_notas excluindo-se
a nota de índice indice_excluida"""
soma = 0.0
for i, nota in enumerate(lista_notas):
if i != indice_excluida:
soma = soma + nota
media = soma / (len(lista_notas) - 1)
return media

def main():
tabela_notas = ler_tabela_notas(NUMERO_ESTUDANTES, NUMERO_EXERCICIOS)
lista_medias = calcular_lista_medias(tabela_notas)
indice_menor = obter_indice_menor(lista_medias)
for i in range(NUMERO_ESTUDANTES):
media = calcular_media_excluida(tabela_notas[i], indice_menor)
print(f"O estudante {i} tem média {media}")

main()

Mantivemos as três funções do programa anterior. Ser organizado e usar nomes de


funções e variáveis consistentes realmente salva a vida! Vamos estudar então as demais
funções, a começar pela ler_tabela_notas.

Repare que função ler_tabela_notas é surpreendente parecida com a


função ler_lista_notas. Isso não acontece por um acaso. O que ler_lista_notas faz
é construir e devolver uma lista de elementos, cada um do tipo float. Do mesmo
modo, ler_tabela_notas constrói e devolve uma lista de elementos, mas dessa vez,
cada elemento da lista é uma outra lista.

Agora investiguemos calcular_lista_medias. Essa função tem dois laços aninhados.


O for externo pode ser lido como para cada exercício  j, calcule a média da turma
para esse exercício. Então vamos nos concentrar em como calcular a média de um
exercício. Já vimos como calcular a média de uma lista de floats antes; aqui, queremos
fazer algo parecido. A diferença é que as notas de um exercício em particular estão
espalhadas nas várias listas dos alunos. Assim, primeiro precisamos acessar a lista de
notas de um aluno, por isso escrevemos tabela_notas[i]. Depois, nesta lista,
precisamos acessar a nota do exercício j, então escrevemos tabela_notas[i][j].
Respire um pouco e releia essa função. Agora deve fazer sentido.

Por último, vamos olhar para a função main. As instruções dela já devem ser
autoexplicativas (repararam que ela não tem comentários?). Vamos olhar apenas para a
chamada à função calcular_media_excluida. Essa função recebe como primeiro
parâmetro uma lista de notas. De fato, é exatamente isso que passamos a
ela: tabela_notas[i] é a lista de notas do estudante de índice i. Se você se sentir mais
confortável, poderia substituir essa linha pelas linhas abaixo. É completamente
equivalente!

lista_notas = tabela_notas[i]
media = calcular_media_excluida(lista_notas, indice_menor)

Agora que já entendeu o programa acima, execute-o e teste-o. Eu se fosse você


modificaria os valores de NUMERO_EXERCICIOS e NUMERO_ESTUDANTES e criaria alguns
arquivos de teste. Reflita sobre testes automatizados.
Matrizes
A principal estrutura de dados que criamos no exemplo anterior foi uma lista de lista de
escalares! Se quisermos, podemos escrever a nossa tabela de notas como uma tabela de
fato. A variável a seguir representa três alunos, cada aluno tem quatro notas.

>>> tabela_notas = [
... [4.5, 7.6, 8.5, 4.5],
... [9.9, 8.0, 8.0, 6.0],
... [0.0, 3.3, 7.0, 8.0],
... ]

Certa uniformidade é relevante. Repare que tabela_notas é uma lista com três listas,
cada uma delas com quatro números do tipo float. Uma tabela como essa é
normalmente chamada de matriz na Matemática. Em Python, representamos matrizes
usando lista de listas. Na verdade, Python não sabe nada sobre matrizes, então
poderíamos escrever algo como

>>> destabela_notas = [
>>> [4.5, 4.5],
>>> [9.9, 8.0, 8.0, 6.0],
>>> ["zero", 3],
>>> ]

que o interpretador iria executar, indiferente ao sofrimento dos obsessivos-compulsivos.


Então devemos definir matriz de um jeito um pouco mais preciso. Uma matriz é uma
lista de listas tal que:

1. todas as listas internas têm o mesmo número de elementos;


2. todos os elementos têm o mesmo tipo escalar.

Algumas vezes, também chamamos listas de arranjos, do inglês arrays (ou vetores, a


depender da linguagem). Assim, podemos dizer que uma matriz é um arranjo
bidimensional. Também podemos definir arranjos multidimensionais, embora eles
sejam menos comuns. Se temos dois estudantes, cada estudante fez três provas e cada
prova tem duas questões, então podemos representar as notas das questões como:

>>> notas_questoes = [
... [[4.0, 5.0], [3.5, 5.0], [4.5, 4.0]],
... [[3.0, 2.0], [2.5, 0.0], [0.0, 0.0]],
... ]

Uma variável só é útil quando acessamos o seu valor. Para isso, basta acessar uma lista
de cada vez:

>>> # nota do segundo exercício do primeiro estudante


>>> tabela_notas[0][1]
7.6
>>> # nota da primeira questão da segunda prova do segundo estudante
>>> notas_questoes[1][1][0]
2.5
Operações em matrizes
Na Álgebra Linear é comum realizar operações com matrizes, como soma, produto por
escalar, produto de matrizes, etc. Como Python não entende o conceito de matriz,
tampouco entende como realizar essas operações. Por isso, temos de implementar cada
uma delas. Vamos implementar a operação de soma e fazer uma função para testá-la.

def somar_matrizes(a, b):


"""Devolve a soma das matrizes a e b"""
assert len(a) == len(b), "Números de linhas devem ser iguais"
assert len(a[0]) == len(b[0]), "Números de colunas devem ser iguais"

m = len(a)
n = len(a[0])

soma = []
for i in range(m):
linha = []
soma.append(linha)
for j in range(n):
celula = a[i][j] + b[i][j]
linha.append(celula)
return soma

def main():
a = [
[5.3, 4.0, 7.5],
[9.0, 0.0, 9.5],
[7.0, 6.9, 7.8]
]
b = [
[1.0, 1.0, 1.0],
[1.0, 1.0, 1.0],
[1.0, 1.0, 1.0]
]
soma = somar_matrizes(a, b)
print(soma)

main()

Na função somar_matrizes decidimos criar a matriz soma linha por linha. Repare que


adicionamos linha à matriz soma antes mesmo de adicionar as células que irão compor
essa linha. Poderíamos adicionar a linha somente depois, é indiferente.

Para entender essa função, é útil simular e olhar para a representação em memória. A
figura a seguir mostra a memória do programa ao executar a
função somar_matrizes durante a iteração i = 1 do laço externo e ao final da
iteração j = 0 do laço interno.
Agora vamos começar a implementar o produto de matrizes. Para isso, vamos
relembrar. O produto de uma matriz A de dimensões m×l por uma matriz B de
dimensões l×n é a matriz C de dimensões m×n, denotada como

C=A×B

em que um elemento cij é definido pelo produto interno

cij=(linha i de A)⋅(coluna j de B).

Dessa vez, vamos primeiro criar uma matriz com zeros C de dimensões m×n e depois
preenchê-la com os valores corretos.

def multiplicar_matrizes(A, B):


assert len(A[0]) == len(B), "Matrizes devem ser compatíveis"
m = len(A)
l = len(B)
n = len(B[0])
C = [[0 for _ in range(n)] for _ in range(m)]
for i in range(m):
for j in range(n):
C[i][j] = calcular_produto_interno(A, B, i, j)
return C

Leia com atenção, faça um desenho da memória e complete a função


implementando calcular_produto_interno.

Representação de matrizes
Você deve ter percebido que alguns nomes de variáveis são recorrentes. Isso é
intencional para manter a consistência com a notação normalmente utilizada em
Álgebra Linear. Assim,

 as matrizes têm dimensão m×n, ou seja, m linhas e n colunas;


 os índices das linhas são normalmente denotados pela letra i;
 os índices das colunas são normalmente denotados pela letra j.

Enquanto essa convenção é puramente cosmética, utilizar sempre essa notação pode
evitar confusões que levam a um grande perda de tempo.

Há uma outra convenção que adotamos quando decidimos representar matrizes:


representamos uma matriz como uma lista de linhas. Dessa vez, essa convenção não é
meramente cosmética e tem consequências para a forma com que você acessa os
elementos de uma matriz e para a maneira com que seu algoritmo manipula a matriz.
Não impede que você represente uma matriz como uma lista de colunas, mas só faça
isso se tiver uma justificativa.

Uma última palavrinha sobre matrizes em Python: elas não foram feitas pensando em
manipular grandes volumes de dados numéricos, nem para realizar operações algébricas
facilmente. Por esse motivo, quando precisamos de fato manipular e realizar operações
sobre matrizes, normalmente utilizamos uma biblioteca. A mais popular para essa
finalidade é a NumPy. Nesta disciplina não iremos utilizá-la, já que para isso seria
necessário entender bem programação orientada a objetos (o que não faremos!). Por
isso, a não ser que você precise, deixe para estudar essa e outras bibliotecas mais tarde.

Arquivos
Agora que já sabemos trabalhar com coleções de dados um pouco mais complexas do
que listas de números ou listas de strings, deve ficar mais latente a necessidade de
armazenar dados de maneira permanente. A estratégia de sempre digitar os dados pelo
teclado não funciona. Assim, queremos distinguir a memória do computador em

1. Memória volátil. É a memória RAM, utilizada para armazenar variáveis durante


a execução do programa. Como regra geral, devemos carregar na memória (i.e.,
criar variáveis) apenas os dados necessários para realizar a computação. Quando o
programa termina, o sistema operacional libera a memória utilizada para outros
programas, os dados que não forem armazenados serão perdidos.

2. Memória persistente. É a memória dos discos rígidos, cartões de memória e


outros tipos de periféricos que mantém os dados mesmo após o desligamento do
computador. Queremos guardar os dados que poderão ser lidos mesmo depois que
o programa termina.

Enquanto organizamos a memória RAM utilizando variáveis, a abstração utilizada para


organizar a memória persistente são os arquivos. Um arquivo é uma sequência de
bytes, armazenados em um dispositivo de memória persistente (disco rígido, CD, fita de
dados, USB-Drive etc.) e acessados por meio de um nome.

Os arquivos são identificados por um nome, assim, cada nome deve corresponder a um


único arquivo. Normalmente, o nome contém um sufixo, chamado de extensão que
correspondente ao tipo dos dados armazenados no arquivo.

Exemplo Tipo
arq.txt texto simples
arq.svg imagem vetorial
arq.c código-fonte em C
arq.py código-fonte em Python
arq.html página da Internet
arq.exe executável

Enquanto a extensão pode ser utilizada pelo sistema operacional para classificar os
arquivos, é importante saber que nada impede que arquivos tenham conteúdo que não
correspondem à extensão. Assim, um arquivo arq.txt pode ser o nome de um
programa executável e assim por diante.

Os arquivos são organizados no sistema de arquivo por meio de diretórios. Um


diretório é um arquivo especial que contém uma lista de arquivos. Esses arquivos
podem ser arquivos comuns ou outros diretórios. Assim, os diretórios formam uma
hierarquia.
Todo programa executa a partir de algum diretório. Esse é o chamado diretório de
trabalho. Para saber qual é o diretório de trabalho em um terminal, digit pwd (ou cd no
Windows). Assim, para referenciar outro arquivo, utilizamos o nome simples ou o nome
completo na hierarquia de diretórios, dependendo do diretório atual.

 Usamos barra / para separar diretórios (ou contrabarra \ no Windows)

 Usamos uma única barra / para representar a raiz da hierarquia

Vejamos alguns exemplos de caminhos.

 Absolutos, a partir do diretório raiz:

 /home/maria/imagem.jpg
 /home/pedro/arquivo.txt
 /home/pedro/mc102/lab.c
 Relativo, a partir do diretório corrente (por exemplo, /home/pedro):

 ../maria/imagem.jpg
 arquivo.txt
 mc102/lab.py

Além da sequência de bytes, existem alguns dados associados a um arquivo, que são
chamados de atributos de arquivos, entre os quais o proprietário do arquivo, as datas de
criação, alteração e acesso, o tamanho em bytes, permissões de acesso etc.

Arquivos de texto e arquivos binários


Os arquivos podem ser classificados em dois grupos:

1. Arquivos de texto
2. Arquivos binários

Um arquivo de texto é uma sequência de caracteres. Como no caso das strings, cada
caractere é representado por um ou mais bytes de acordo com alguma tabela de
codificação. A codificação mais comum nas aplicações modernas é a chamada UTF-8.
Essa codificação representa a tabela de caracteres Unicode, que contém caracteres de
quase todas as línguas, além de outros caracteres de controle e, claro, emojis!

Você deveria salvar todos os seus arquivos de texto na codificação UTF-8, mas pode ser
que você precise lidar com outras codificações. O importante é entender que arquivos de
texto são representados em alguma codificação e, se precisar ler um arquivo
armazenado em uma codificação diferente de sua aplicação, será necessário antes
converter esse arquivo.

Nem sempre é possível fazer essa conversão sem perda de informação, sobretudo
quando usamos aplicações legadas. Por exemplo, pode ser que seu ambiente só
reconheça a codificação ASCII, que não possui acentos ou outros caracteres
modificados. Pode ser que você tenha um conjunto de arquivos de texto antigos e
precise ler esses arquivos por um programa em Python. Quase sempre é preciso se
preocupar com a codificação, ou dito de outra forma, quando ignoramos a codificação,
quase sempre as coisas vão dar errado.

Um arquivo binário é uma sequência qualquer de bytes. Lembre-se de que um byte é


uma sequência de 8 bits. Não podemos armazenar menos de byte em um arquivo. Se
você é atento, deverá ter percebido que um arquivo de texto também é um arquivo
binário! Na verdade, todos os arquivos são binários, mas normalmente só chamamos de
binários aqueles cujos bytes não podem ser interpretados como um arquivo de texto.
Isso não significa que os bits e bytes não estejam organizados; significa apenas que a
organização do arquivo depende do formato.

Normalmente, mas nem sempre, os primeiros bytes dos arquivos são suficientes para
identificar qual é o formato do arquivo. Por exemplo, o comando file de sistemas Unix
tenta adivinhar o tipo dos dados de um arquivo, mesmo que a extensão tenha sido
modificada.

user@notebook:~/unidades$ file 08-matrizes.html


08-matrizes.html: HTML document, UTF-8 Unicode text, with very long lines
user@notebook:~/unidades$ file virus.jpg
virus.jpg: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV)

Manipulando arquivos
Instruções: Para essa unidade, você deve ler a seção 7 sobre entrada e saída e arquivos
do tutorial Python.

Vamos resolver o seguinte exercício:


Escreva um programa que leia um arquivo chamado  "palavras.txt" e escreva as
palavras que terminam com  "s" em um arquivo chamado  "plurais.txt" e as demais
em um arquivo chamado  "singulares.txt".

Abrindo e fechando arquivos

Para acessar um arquivo, precisamos abrir esse arquivo usando uma


chamada open(nome_arquivo). Abrir um arquivo é um processo que envolve diversas
etapas. Por exemplo, o sistema operacional deve verificar se o arquivo com o nome
dado de fato existe e se o usuário tem permissão de acesso a esse arquivo em particular,
etc. Quando o arquivo é aberto com sucesso, a função open devolve uma variável com
metadados do arquivo. Em particular, essa variável contém um número descritor do
sistema operacional para o seu arquivo aberto.

>>> arquivo = open("palavras.txt")


>>> arquivo
<_io.TextIOWrapper name='palavras.txt' mode='r' encoding='UTF-8'>
>>> arquivo.fileno()
3

Uma vez terminado o acesso a um arquivo, é necessário fechá-lo. Fechar um arquivo é


muito importante, pois é nesse momento que salvamos as eventuais alterações no disco
(a tradução de save como salvar é bastante infeliz, o que queremos fazer é guardar as
alterações no disco). Além disso, fechar um arquivo também pede ao sistema
operacional para liberar esse arquivo. Dependendo do sistema e do modo como o
arquivo foi aberto, outros programas ficaram impedidos de manipular o arquivo
enquanto ele não for fechado. Por isso, sempre depois de trabalhar com um aquivo,
chamamos a função close

arquivo = open("palavras.txt")
# ...
# acessamos os dados do arquivo
# ...
arquivo.close()

Liberar um recurso depois de usado é tão importante que existe uma sintaxe especial em
Python para isso: o bloco with. Assim, ao invés de usar open e close como acima,
sempre iremos escrever algo como

with open("palavras.txt") as arquivo:


# ...
# acessamos os dados do arquivo
# ...

Repare que o valor devolvido por open é associado a arquivo e que não precisamos
fechar o arquivo explicitamente.

Lendo os dados do arquivo

Quando abrimos um arquivo em um editor de texto, vemos um cursor piscando. Esse


cursor indica a posição de leitura e escrita atual do arquivo. Do mesmo modo, quando
abrimos um arquivo, o sistema operacional cria um cursor de arquivo, que indica qual
é a posição atual do arquivo sendo acessado. O cursor é utilizado para informar a
posição no arquivo em estão os próximos bytes a serem lidos, ou a posição no arquivo
em que serão escritos os próximos bytes.

O acesso aos dados do arquivo pode se dar de dois modos:

1. Sequencialmente, na ordem em que os dados foram armazenados.


2. Diretamente, obtendo diretamente o dado desejado a partir de sua posição.

Quando lemos um arquivo sequencialmente, lemos cada byte do arquivo, do primeiro ao


último, assim como lemos um livro de literatura. No acesso sequencial, o cursor de
arquivo nunca retrocede. Quando acessamos um arquivo diretamente, primeiros
descobrimos em qual posição está o dado requerido, assim como consultamos o índice
de uma lista telefônica. No acesso direto, posicionamos o cursor de arquivo na posição
do dado desejado. A cada operação de leitura ou escrita no arquivo, o cursor move-se
automaticamente para a próxima posição.

A maneira mais comum de ler um arquivo de dados é interpretar o arquivo como um


sequência de linhas e elr . Por esse motivo, Python permite percorrer as linhas de um
arquivo como se ele fosse uma lista de strings.

Vamos criar um arquivo de texto com os dados de uma estudante. Não é porque o
arquivo é de texto que ele não tem uma estrutura bem definida. No arquivo seguinte,
adotamos a seguinte convenção:

1. a primeira linha contém o número de RA;


2. a segunda linha contém o nome completo;
3. a terceira linha contém a data de nascimento;
4. a quarta linha contém o nome da mãe.

123456
Ana Viva Mariana
29/2/2000
Maria Viva

Para ler esses dados, podemos fazer o seguinte

>>> with open("dados.txt") as arquivo:


... ra = arquivo.readline()
... nome_completo = arquivo.readline()
... nascimento = arquivo.readline()
... nome_mae = arquivo.readline()
...
>>> ra
' 123456\n'
>>> nome_completo
' Ana Viva Mariana\n'
>>> nascimento
'29/2/2000 \n'
>>> nome_mae
'Maria Viva'
Cada uma das variáveis lidas corresponde a uma linha e é do tipo str. Repare que todas
as variáveis lidas terminam com um caractere de nova linha \n, com exceção da última.
Isso ocorreu porque quando criei esse arquivo não adicionei um caractere \n na no fim
do arquivo, i.e., eu não "dei enter" após a última letra da última linha.

É prática comum terminar todas as linhas com \n, tanto que muitas vezes esse caractere
é chamado de caractere de fim de linha. Sempre adicione um caractere de fim de linha
no final do seus arquivos de texto.

Observe também que nome_completo começa com um espaço, assim como há alguns


espaços no final de nascimento. Isso porque quando copiei o arquivo em meu editor de
texto adicionei alguns arquivos em branco. Muitas vezes não vemos esses caracteres,
então eles passam desapercebidos. É uma boa prática não deixar esses caracteres no
final das linhas de seus arquivos de texto. Descubra como configurar seu editor de texto.

Acontece que quando criamos um programa, não temos controle sobre os arquivos que
leremos. Então o que a maioria das programadoras faz é livrar-se desses caracteres em
branco. As strings em Python têm um função para isso, strip:

>>> nome_completo.strip()
'Ana Viva Mariana'

Pode ser que você queira ler as linhas de um arquivo, mas não conheça quantas linhas
deverá ler até que o arquivo termine. Para isso, Python permite percorrer as linhas do
arquivo como se ele fosse uma lista de strings — com a diferença crucial de que não
podemos voltar nem acessar uma linha com colchetes. Já podemos ler nosso arquivo de
palavras.

def ler_aquivo_palavras(nome_arquivo):
"""
Lê um arquivo e devolve a lista de palavras,
uma por linha
"""

with open(nome_arquivo) as arquivo:


palavras = []
for linha in arquivo:
palavra = linha.strip()
palavras.append(palavra)

return palavras

Agora já podemos criar duas listas separadas, uma com as palavras "plurais" e outras
com as palavras "singulares" (é claro que isso não é correto gramaticalmente, decidir se
uma palavra está em plural é muito mais desafiador do que simplesmente verificar se a
última letra é um "s").

def separar_plurais(palavras):
"""
Devolve a lista das palavras que terminam em s
"""
plurais = []
for palavra in palavras:
if palavra[-1] == "s":
plurais.append(palavra)
return plurais

def calcular_diferenca(lista1, lista2):


"""
Devolve uma lista com os elementos
de lista1 que não estão em lista2
"""
diferenca = []
for valor in lista1:
if valor not in lista2:
diferenca.append(valor)
return diferenca

def main():
palavras = ler_aquivo_palavras("palavras.txt")
plurais = separar_plurais(palavras)
singulares = calcular_diferenca(palavras, plurais)

print(plurais)
print(singulares)

# criar_arquivo_palavras("plural.txt", plurais)
# criar_arquivo_palavras("singular.txt", singulares)

main()

Isso deve ser suficiente para testar a leitura do arquivo. Experimente com o arquivo
seguinte.

feijão
arroz
limões
batata
beterrabas
pizzas
lasanha quatro-queijos
rapadura

Escrevendo dados em um arquivo

Para completar o nosso exercício, precisamos implementar a


função criar_arquivo_palavras(nome_arquivo, palavras) , cujas chamadas estão
comentadas no trecho acima. O que essa função deve fazer é:

1. criar um arquivo chamado nome_arquivo;


2. escrever as palavras no arquivo, uma por linha.

Quando chamamos open("palavras.txt") acima, abrimos esse arquivo no chamado modo


de leitura. Para poder escrever em um arquivo, precisamos abrir um aquivo no modo de
escrita. Para isso faremos algo como open("plurais.txt", "w"). Esse caractere "w" está
indicando que o mode abertura do arquivo é de leitura. Quando não passamos esse
parâmetro, o padrão é modo de leitura, que também pode ser indicado pelo
caractere "r". Dependendo do objetivo, existem vários modos de abertura de um
arquivo, como os do exemplo abaixo. Mas os modos "r" e "w" são os mais comuns.

modo operações posição do cursor do arquivo


"r" leitura início
"r+" leitura e escrita início
"w" escrita início (trunca arquivo)
"w+" escrita e leitura início (trunca arquivo)
"a" escrita final
"a+" leitura início

O modo que iremos usar para nossa função é o "w", o que esse modo significa é o
seguinte:

1. se o arquivo sendo aberto não existir, então um aquivo com esse nome é criado;
2. se um arquivo com esse nome existir, então esse arquivo é truncado a 0 bytes,
descartando quaisquer dados armazenados anteriormente;
3. o cursor de arquivo é posicionado em modo de escrita no início do arquivo, que
nesse momento está vazio.

Tome cuidado ao usar o modo de escrita "w", já que ele pode levar a perda de dados.
Pode ser necessário verificar se o arquivo já existe, ou renomeá-lo se já existir. Para
isso, procure no módulo os as funções adequadas, como os.rename ou os.remove.

Para entender o significado de cada modo disponível, consulte a documentação de open.


Aqui, só precisamos escrever uma linha por vez. Fazemos isso com a função write, que
está disponível para os arquivos.

def criar_arquivo_palavras(nome_arquivo, palavras):


"""
Cria um arquivo nome_arquivo com as palavras,
uma por linha
"""

with open(nome_arquivo, "w") as arquivo:


for palavra in palavras:
linha = palavra + "\n"
arquivo.write(linha)

Repare que para escrever uma linha, precisamos adicionar uma quebra de linha no final
de cada linha manualmente. Se não fizermos isso, então todas as palavras apareceriam
coladas. Se preferir, também é possível utilizar a função print, que irá escrever no
arquivo da mesma maneira que escreveria na tela. A vantagem é que print converte a
variável para uma string automaticamente.

>>> lista = [1, 2, 3]


>>> with open("lista.txt", "w") as arquivo:
... print(lista, file=arquivo)

Experimente e descubra qual o conteúdo foi criado no arquivo "lista.txt".


Entrada e saída padrão

Quando vimos como abrir arquivos, aprendemos que uma variável do tipo arquivo tem
uma função fileno que devolve um descritor do arquivo, que é um número que designa
um arquivo aberto de seu programa para o sistema operacional. Se você executou o
exemplo, é muito provável que arquivo.fileno() devolveu o mesmo
número 3 mostrado acima. Mas por que o primeiro arquivo que abrimos tem descritor 3,
e não 0 ou 1?

A resposta é que quando nossas instruções começam a executar, o processo associado a


nosso programa já tem três arquivos abertos. Esses arquivos podem ser acessados a
partir do módulo sys, i.e, se escrevermos import sys no início do programa. São eles:

 A entrada padrão sys.stdin, que tem descritor 0. Esse arquivo começa aberto


no modo de leitura e representa os dados digitados pelo usuário. Normalmente
acessamos esse arquivo através da função input.
 A entrada padrão sys.stdout, que tem descritor 1. Esse arquivo começa aberto
no modo de escrita e representa os dados mostrados na tela. Normalmente
acessamos esse arquivo através da função print.
 A entrada padrão sys.stderr, que tem descritor 2. Esse arquivo começa aberto
no modo de escrita e representa as mensagens de erro mostradas na tela.
Normalmente acessamos esse arquivo através da função print, mas passando o
parâmetro file=sys.stderr.

Nesta disciplina não usamos ainda mensagens de erro, mas elas podem ser úteis para
distinguir a saída do seu programa de uma mensagem, particularmente uma mensagem
de quando estamos testando o nosso programa.

Entender e conhecer os arquivos de entrada e saída padrão é bastante conveniente,


particularmente quando nos cansarmos de testar nossos programas digitando a entrada
na mão, de novo e de novo. Em um terminal podemos escrever

user@notebook$ python3 programa.py < entrada.txt > saida.txt

Isso irá fazer com que sys.stdin se refira ao arquivo entrada.txt e sys.stdout se


refira ao arquivo saida.txt. Se também quisermos guardar as mensagens de erro,
poderíamos escrever

user@notebook$ python3 programa.py < entrada.txt > saida.txt 2> erros.txt

mas é útil deixar que as mensagens de erro sejam mostradas na tela. Vamos fazer isso
em breve.

Um exemplo com matrizes


Vamos resolver o seguinte exercício:
Escreva um programa que dada uma matriz de caracteres e uma palavra, conte o
número de vezes que a palavra aparece na matriz, tanto na direção vertical quanto na
horizontal.

Vamos ver um exemplo.

OEAIAGBOOL
IIWAXHHLHN
PADUCAPNOC
ZBMOUIZSAS
OXEZOKOEUA
QCRMAAPAOH
DHOMEMTUFO
HOOAJCMVGM
NMFOANGMAE
JEVJVCCSNM

Se estivermos procurando HOMEM, você deve encontrar duas ocorrências dessa palavra na


matriz acima.

Antes de tudo, precisamos formalizar o problema que nos é dado. O enunciado não fala
de onde essa matriz será obtida, nem de onde vamos ler a palavra. Não é razoável
digitar toda a matriz sempre que formos testar nosso programa — acho que você deve
concordar comigo, então não devemos sequer cogitar testar esse programa digitando a
entrada.

A solução agora deve ser evidente: vamos criar um arquivo para armazenar a entrada de
nosso programa. Nossa entrada é uma matriz de caracteres e uma palavra, então é
natural utilizarmos um arquivo de texto. Precisamos de alguma convenção para
organizar os dados no nosso arquivo, assim definiremos a seguinte estrutura do arquivo,
bem simples:

 a primeira linha contém a palavra sendo buscada;


 as demais linhas contém as linhas da matriz, sem espaço entre os caracteres.

Vamos criar um arquivo chamado caca_palavras.txt.

HOMEM
OEAIAGBOOL
IIWAXHHLHN
PADUCAPNOC
ZBMOUIZSAS
OXEZOKOEUA
QCRMAAPAOH
DHOMEMTUFO
HOOAJCMVGM
NMFOANGMAE
JEVJVCCSNM

Enquanto esse exercício não é difícil, ele tampouco é trivial. Assim, precisamos de
método e organização, senão iremos gastar muito tempo tentando resolvê-lo e a
experiência de programação será frustrante. Como sempre, vamos fazer uma lista de
funções a serem implementadas e, para cada uma delas, escrever um algoritmo em
português antes de programá-la. Precisamos de pelo menos duas funções, com os
seguintes objetivos:

1. ler um arquivo e devolver uma palavra e a matriz de caracteres;


2. procurar uma ocorrência de uma palavra na matriz.

A primeira tarefa é mecânica: basta ler a primeira linha e depois percorrer as demais
linhas adicionado as palavras a uma lista.

def ler_arquivo_entrada(nome_arquivo):
"""Lê um arquivo com os dados de entrada
e devolve a palavra e a matriz de caracteres"""

with open(nome_arquivo) as arquivo:


palavra = arquivo.readline().strip()
matriz = []
for linha in arquivo:
matriz.append(linha.strip())
return palavra, matriz

Representamos uma matriz de caracteres como uma lista de strings! Assim, podemos
acessar um caractere na linha i e coluna j normalmente, digitando matriz[i][j].
Enquanto isso é conveniente, há uma consequência em termos de desempenho. Para
acessar um elemento de uma lista em uma posição j dada, o interpretador Python pode
acessar essa posição na memória diretamente, isso é muito muito rápido.

Já para acessar um caractere de uma string em uma dada posição j, é necessário


percorrer toda a string desde o início contando os caracteres. Para strings pequenas
como do nosso exemplo isso não é um problema, mas para strings maiores ou para
aplicações críticas, isso pode ser extremamente ineficiente. Por sorte, é fácil corrigir
esse problema, basta converter a string em uma lista, mudando a iteração
do for para matriz.append(list(linha.strip())) . Sutil, mas importante!

Para a segunda tarefa, queremos procurar as ocorrências de uma palavra na matriz. Um


algoritmo em alto nível, que é talvez o que a maioria das pessoas faria, é o seguinte:

 para cada linha i da matriz

o para cada coluna j da matriz

1. verifique se a palavra aparece horizontalmente a partir de (i,j)


2. verifique se a palavra aparece verticalmente a partir de (i,j)
3. atualize o contador de ocorrências

Agora escrevemos a função. Vamos postergar as tarefas mais difíceis usando stubs.

def contar_ocorrencias(palavra, matriz):


"""Conta o número de vezes que palavra
ocorre em matriz, horizontal ou verticalmente"""

m = len(matriz)
n = len(matriz[0])

ocorrencias = 0
for i in range(m):
for j in range(n):
if ocorre_horizontal(palavra, matriz, i, j):
ocorrencias += 1
if ocorre_vertical(palavra, matriz, i, j):
ocorrencias += 1

return ocorrencias

Essa função não morde. Vamos passar à função main.

def main():
palavra, matriz = ler_arquivo_entrada("caca_palavras.txt")
ocorrencias = contar_ocorrencias(palavra, matriz)
print(f"Há {ocorrencias} ocorrencias")

A mágica realmente acontece quando procuramos a ocorrência de uma palavra. Vamos


implementar ocorre_vertical. Um algoritmo seria o seguinte:

1. ocorre←True
2. para cada índice k de palavra:
o compare os caracteres palavra[k] com matriz[i][j+k]

o se forem diferentes, faça ocorre←False

3. devolva ocorre

Isso verifica se cada caractere de palavra é corresponde a algum caractere na linha i da


matriz começando pela coluna j.

def ocorre_horizontal(palavra, matriz, i, j):


ocorre = True
tamanho = len(palavra)
for k in range(tamanho):
if palavra[k] != matriz[i][j + k]:
ocorre = False
return ocorre

def ocorre_vertical(palavra, matriz, i, j):


return False

Juntamos todas as partes e executamos o nosso programa, digamos caca_palavras.py,


apenas para descobrir que ele tem um erro.

user@notebook:~/ra123456/matrizes$ python3 caca_palavras.py


Traceback (most recent call last):
File "caca_palavras.py", line 46, in <module>
main()
File "caca_palavras.py", line 43, in main
ocorrencias = contar_ocorrencias(palavra, matriz)
File "caca_palavras.py", line 22, in contar_ocorrencias
if ocorre_horizontal(palavra, matriz, i, j):
File "caca_palavras.py", line 33, in ocorre_horizontal
if palavra[k] != matriz[i][j + k]:
IndexError: list index out of range

Existe um erro ao acessar a coluna j+k da linha i da matriz. Para poder corrigir esse
erro, utilizamos um debugger, possivelmente colocando configurando breakpoint na
linha em que aconteceu o erro, ou um pouco antes. Muitas vezes, é útil também colocar
alguma mensagem de debug. Para distinguir a mensagem de debug da saída normal do
nosso programa, vamos escrever no arquivo sys.stderr, que é para onde esse tipo de
mensagem deve ir.

import sys

# ...

def ocorre_horizontal(palavra, matriz, i, j):


ocorre = True
tamanho = len(palavra)
for k in range(tamanho):
print(f"Comparando palavra[{k}] com matriz[{i}][{j + k}]",
file=sys.stderr)
if palavra[k] == matriz[i][j + k]:
ocorre = False
return ocorre

Executando novamente, temos um bocado de informação na tela, resumida a seguir:

user@notebook:~/ra123456/matrizes$ python3 caca_palavras.py


Comparando palavra[0] com matriz[0][0]
Comparando palavra[1] com matriz[0][1]
Comparando palavra[2] com matriz[0][2]
....
Comparando palavra[0] com matriz[0][6]
Comparando palavra[1] com matriz[0][7]
Comparando palavra[2] com matriz[0][8]
Comparando palavra[3] com matriz[0][9]
Comparando palavra[4] com matriz[0][10]
Traceback (most recent call last):
File "caca_palavras.py", line 49, in <module>
main()
File "caca_palavras.py", line 46, in main
ocorrencias = contar_ocorrencias(palavra, matriz)
File "caca_palavras.py", line 24, in contar_ocorrencias
if ocorre_horizontal(palavra, matriz, i, j):
File "caca_palavras.py", line 36, in ocorre_horizontal
if palavra[k] != matriz[i][j + k]:
IndexError: list index out of range

Se você contar, irá descobrir que a matriz tem apenas 10 colunas, mas matriz[0]
[10] se refere à décima-primeira coluna da primeira linha, que não existe. Encontramos
o erro. A vantagem de mostrar mensagens de debug na saída de erro é que, enquanto
escrevemos o programa, podemos omitir essas mensagens, sem precisar remover as
instruções do código. Depois de modificada a função para tentar corrigir o erro, fazemos
o seguinte:

user@notebook:~/ra123456/matrizes$ python3 caca_palavras.py 2> /dev/null


Há 1 ocorrencias

Vemos somente a saída padrão. Nesse caso, eu executei o programa depois de corrigir a
função ocorre_horizontal. Corrija essa função e implemente a função que falta.

← ANTERIOR
VOLTAR
PRÓXIMA →

Copyright © 2020

Instruções: Para essa unidade, você deve ler as seções 5.3 a 5.6 do tutorial Python. Só
leia a seção de classes do tutorial depois que estiver confortável com os conceitos de
conjuntos e dicionários. Quando se sentir pronto, leia a seção 9 até a subseção 9.4.

Para guardar dados na memória do computador, quase sempre utilizamos variáveis ou


listas de variáveis de tipos básicos. Como não tínhamos muitas opções, não nos
preocupávamos com a forma com que os dados eram armazenados. Nesta unidade,
veremos que as escolhas que fazemos para representar dados na memória têm
consequências importantes para os algoritmos que lemos e escrevemos. Essas
consequências são percebidas tanto quando queremos dar significado aos dados, quanto
quando queremos executar algum algoritmo.

Iremos falar de dois conceitos que devem acompanhar uma programadora durante toda
a vida: abstração e representação. Desde que começamos a falar de algoritmos, vimos
que preferimos falar de bytes ao invés de bits, de palavras ao invés de sequência de
caracteres e assim por diante. Enquanto um computador só manipula dados (ou bits, em
última instância), nos nossos algoritmos, preferimos falar de objetos mais próximos do
nosso cotidiano. Assim, queremos falar de estudantes, ao invés de um número de RA
associado a um nome. Nesse caso, criamos uma abstração para um estudante que é
representada em memória por um inteiro e uma string.

O dados que precisamos armazenar para uma determinada abstração dependem do


problema. Por exemplo, se queremos descobrir se um estudante foi aprovado, uma
prova poderia muito bem ser representada por um número de ponto flutuante
correspondente à nota. Se quisermos calcular a nota da prova em si, então
provavelmente representaríamos a prova como uma lista de números correspondente às
questões. A representação de uma abstração corresponde tanto à lista de dados que
armazenamos na memória bem como a forma com que esses dados estão organizados.

Por exemplo, no momento da inscrição em uma universidade, cada estudante precisa


preencher um fomulário. Esse formulário é uma tabela padronizada com espaço
reservado para vários campos, como nome, data de nascimento, endereço etc. Nesse
exemplo, a representação é o leiaute do formulário. Já cada cópia do formulário
preenchida corresponde a um objeto ou indivíduo de nossa abstração "estudante". Nem
todo conjunto de dados compatível com a representação corresponde a um indivíduo da
abstração, afinal, não basta preencher um formulário de inscrição para ser universitário.
Similarmente, nem todo trio de números inteiros corresponde a uma data.

Os dados sozinhos não servem para muita coisa se não tivermos o que fazer com eles.
Assim, além da forma com que os dados são armazenados, também precisamos de uma
lista de operações que permitem acessá-los e modificá-los. De maneira mais ampla,
chamamos de estrutura de dados o conjunto de regras e convenções que definem a
representação de uma abstração associado a uma lista de operações permitidas.

A primeira vez que utilizamos uma abstração razoavelmente sofisticada e que não fosse
um tipo padrão de Python foi quando estudamos matrizes. Naquele momento,
representamos uma matriz como uma lista de listas de escalares. Poderíamos também
ter representado uma matriz como uma única lista de escalares, lidos da esquerda para
direita e de cima para baixo. A razão para escolhermos listas de listas é que essa
organização facilita bastante a operação mais comum de uma matriz: acessar um
determinado elemento.

A escolha ou o projeto de uma estrutura de dados não é tarefa trivial e não é nosso
objetivo aprender a projetar estruturas de dados avançadas — há uma disciplina só para
isso. Por enquanto, o que é importante é entender que cada estrutura de dados é
projetada para executar bem e eficientemente um conjunto próprio de operações. Assim,
para escolher uma estrutura de dados, vamos comparar as operações de que precisamos
com as operações que cada estrutura de dados que conhecemos oferece. Isso não é fácil.
Não vou mentir.

Coleções dinâmicas de dados


Em diversos dos problemas que queremos resolver com um computador, precisamos
lidar com uma grande quantidade de dados. Ter muitos dados implica que que
precisamos tratar a maioria deles de maneira uniforme, já que não podemos escrever um
algoritmo para cada indivíduo. Assim, vamos imaginar que o conjunto de dados de
entrada de nosso problema corresponde a uma coleção que cada elemento dessa coleção
é um indivíduo da mesma abstração.

Muitas vezes as coleções de dados são dinâmicas, i.e., elas mudam com o tempo. As
operações que podemos fazer sobre coleções dinâmicas são variadas, mas quase sempre
queremos pelos menos

 inserir um novo elemento;


 buscar ou alterar um elemento existente;
 remover um elemento existente.

Até então, estudamos coleções dinâmicas bem simples: listas de inteiros, listas de
strings, etc. Dessa vez, vamos falar de uma coleção de objetos mais elaborados.

Considere um dicionarista. Ele é responsável estudar e investigar um conjunto de


palavras. Ele pode catalogar novas palavras, alterar a definição de palavras
existentes... Como representar o dicionário manipulado por ele?
Normalmente, sempre falamos de um problema com entrada e saída bem definidas.
Nesse caso, não temos uma entrada nem saída, mas falamos das operações que
desejamos realizar com uma coleção de dados. O motivo é que queremos criar uma
estrutura de dados que possa ser utilizada pelo dicionarista em diversas situações.

Primeiro, precisamos listar que dados precisamos guardar. Ora, os dados que precismos
guardar são as palavras do idioma. O problema é que Python não tem ideia do que é
uma palavra — aliás, Python não tem ideia nenhuma, apenas tipos. Podemos dizer então
que uma palavra é uma string. Mas isso seria desvalorizar o trabalho do dicionarista,
que pesquisa a definição de cada uma palavra. Então vamos guardar também a definição
da palavra. Mas e o ano em que a palavra foi catalogada pela primeira vez? Também é
importante conhecer a história das palavras.

O fato é que, associado a uma palavra, há um conjunto enorme de outras informações e


qualquer subconjunto pequeno de dados que escolhermos não irá representar uma
palavra em sua plenitude. Por isso, iremos omitir todos os dados que não interessam à
nossa aplicação. Pode ser que esse dicionarista em particular esteja interessado apenas
na palavra, na definição e no ano em que ela foi catalogada. Uma palavra e sua
definição são facilmente representadas por strings e o ano, é claro, por um inteiro, como
2020. Hum... não seria tão claro assim se você tivesse nascido no milênio passado.

Agora podemos pensar no dicionário. Queremos armazenar uma lista de palavras, então
uma escolha óbvia parece ser uma lista de strings palavras. Do mesmo modo, criamos
uma lista de strings definicoes para representar as definições e uma lista de
inteiros anos para representar os anos. Portanto, uma palavra palavras[i] teria sido
catalogada no ano anos[i] e assim por diante. Isso é suficiente para guardar todos os
dados de nossa aplicação, mas é uma péssima escolha de estrutura de dados. O motivo é
que uma regra de ouro das estruturas de dados diz que dados relacionados devem andam
juntos. Imagine, por exemplo, o que acontece se quisermos ordenar a lista de palavras
alfabeticamente. Dá nervoso só de pensar.

Como vimos matrizes recentemente, é tentador remediar a situação e dizer que devemos
representar um dicionário como uma matriz dos dados. Afinal, o que queremos
representar é uma tabela de dados. Digamos então que dicionario é uma matriz, isso é,
uma lista de listas. Isso é definitivamente muito melhor do que a representação anterior,
mas tampouco é uma boa escolha. Uma razão é que chamamos de matriz uma estrutura
bidimensional de dados escalares do mesmo tipo, mas um inteiro representando um ano
é bem distinto de uma string representando uma definição. Outra razão é que não é claro
o que significa uma entrada da matriz dicionario[i][j]. Seria o j-ésimo dado da i-
ésima palavra, ou seria o i-ésimo dado da j-ésima palavra? Em uma matriz, não há
motivo nenhum para preferir uma forma a outra.

Na verdade, um dicionário é uma coleção de verbetes, então seria muito melhor


representá-lo como uma lista de verbetes — mas, ao contrário de uma palavra que pode
ser facilmente representada por uma string, não há tipo nativo em Python que
corresponda a um verbete. Isso sugere que devamos criar nossa própria abstração para
representar um verbete. Um verbete consiste de exatamente três dados distintos, a
palavra, a definição e o ano, então é natural representá-lo como uma tupla:

(palavra, definição, ano)


Uma tupla é parecida com a uma lista, mas não podemos adicionar ou remover
elementos, nem mudar os objetos a que ela se refere. Poderíamos dizer também que um
verbete é uma lista [palavra, definicao, ano], mas há algumas vantagens em
utilizar uma tupla. Como a tupla é imutável, comunicamos claramente que há três, e só
três, dados em um verbete. Também, como já discutimos, preferimos trabalhar com
listas cujos elementos tenham o mesmo tipo e o mesmo significado. O fato de não
podermos modificar os objetos a que uma tupla se refere pode ser uma desvantagem,
mas é uma vantagem nesse exemplo: não queremos que um verbete lido seja alterado
pelo seu usuário (da mesma forma que não queremos adulterar os livros emprestados de
uma biblioteca).
Para deixar o exemplo um pouco mais concreto e para exemplificar como o dicionarista
poderia utilizar a estrutura de dados, suponha que ele deseja fazer uma sequencia de
operações que inclui criar um dicionário, adicionar um verbete, buscar um verbete e
alterar a definição de um verbete.

def main():
dicionario = criar_dicionario()

verbete = ("amor", "fogo que arde sem se ver", 1595)


adicionar_verbete(dicionario, verbete)

verbete = procurar_verbete(dicionario, "amor")


palavra, definicao, ano = verbete
print(f"{palavra} significa {definicao}")

nova_definicao = input("O que você acha que é o amor?\n")


atualizar_definicao(dicionario, "amor", nova_definicao)

palavra, definicao, ano = procurar_verbete(dicionario, "amor")


print(f"{palavra} agora significa {definicao}")

Primeiro, vamos nos concentrar em criar um dicionário e adicionar um verbete.

def criar_dicionario():
"""cria um dicionário vazio"""

return []

def adicionar_verbete(dicionario, verbete):


"""adiciona um novo verbete ao dicionário"""

dicionario.append(verbete)

Pode parecer bobagem criar uma função apenas pare devolver uma lista vazia, ou só
para adicionar em elemento no final da lista, mas não é. Mesmo programadores
experientes podem dizer que bastaria ter criado uma lista vazia na função main. Não dê
ouvido a eles. Há algumas razões importantes para termos criado essas funções. Por
exemplo, é mais claro escrever dicionario = criar_dicionario() para dizer que
estamos criando um novo indivíduo da abstração "dicionário" do que
escrever dicionario = []. Mais importante do que isso, quando
escrevemos dicionario = [], temos que nos preocupar em como um dicionário está
representado na memória, mas é exatamente isso que nossa abstração está tentando
esconder.

Vamos agora implementar a busca e a atualização de um verbete.

def procurar_verbete(dicionario, palavra):


"""devolve o verbete correspondente à palavra;
se palavra não for encontrada, devolve None"""

for verbete in dicionario:


if verbete[0] == palavra:
return verbete
return None
def atualizar_definicao(dicionario, palavra, nova_definicao):
"""atualiza a definição de uma palavra"""

for verbete in dicionario:


if verbete[0] == palavra:
verbete[1] = nova_definicao

Na função procurar_verbete, usamos None, que é um valor especial em Python para


representar ausência. Mas a implementação acima está errada. O motivo é que não
podemos alterar tuplas. Você pode pensar nisso como uma desvantagem, mas imagine
se alguma outra função tivesse um verbete e o alterasse ignorando que ele é parte do
dicionário. Essa função estaria alterando o dicionário, então qualquer outra função que
buscasse por esse verbete no dicionário iria ser afetada pela alteração. Muito
provavelmente, isso causaria um bug muito difícil de encontrar. Para poder atualizar o
dicionário então, precisamos substituir o verbete por um novo, mas para alterar um
elemento referenciado por uma lista, precisamos do índice correspondente.

def procurar_indice(dicionario, palavra):


"""devolve o índice correspondente à palavra
ou None se ela não existir no dicionário"""

for i, verbete in enumerate(dicionario):


if verbete[0] == palavra:
return i
return None

def atualizar_definicao(dicionario, palavra, nova_definicao):


"""atualiza a definição de uma palavra"""

i = procurar_indice(dicionario, palavra)
verbete_antigo = dicionario[i]
verbete_novo = (verbete_antigo[0], nova_definicao, verbete_antigo[2])
dicionario[i] = verbete_novo

Para o interpretador Python, dicionario é só mais uma lista, então ele não se


importaria se o dicionarista escrevesse a função abaixo.

def main():
dicionario = criar_dicionario()

verbete = ("amor", "fogo que arde sem se ver", 1595)


adicionar_verbete(dicionario, verbete)

outro = ("amor", "é sofrer amargamente", 2020)


adicionar_verbete(dicionario, outro)

palavra, definicao, _ = procurar_verbete(dicionario, "amor")


print(f"{palavra} significa {definicao}")

Teríamos um dicionário com dois verbetes correspondendo à mesma palavra. Não


parece algo muito confiável: quando alguém procurar por uma palavra, qual definição
seria devolvida? Nesse caso, a função procurar_verbete percorre a lista do início ao
fim, então ela devolveria a primeira definição. Uma outra implementação poderia
devolver a segunda definição e ainda assim cumprir o combinado em sua
documentação.

user@notebook:~/ra123456/colecoes$ python3 dicionario_tuplas.py


amor significa fogo que arde sem se ver

Onde está o erro? Deve estar na função procurar_verbete, ou na função main. Nesse


caso, está na função main, então o dicionarista deveria certificar-se de que a palavra não
está no dicionário antes de adicioná-la. Mas isso é pedir demais a ele. A função que
realmente deixou a nossa representação na memória inconsistente
foi adicionar_verbete. Seria muito bom se, quando o dicionarista tentasse adicionar
um verbete para uma palavra existente, o informássemos desse erro.

def adicionar_verbete(dicionario, verbete):


"""adiciona um novo verbete ao dicionário"""

i = procurar_indice(dicionario, verbete[0])
if i is None:
dicionario.append(verbete)
else:
raise Exception(f"Palavra {verbete[0]} já existe.")

Há mais uma novidade aqui. A instrução raise Exception(...) serve para criar uma


nova exceção sinalizando um erro. Já lidamos com exceções antes; o que há de diferente
aqui é que, dessa vez, nós criamos nosso próprio erro. Assim, quando o dicionarista
executar a função main com problemas, a execução do programa será interrompida no
momento em que ele tentasse adicionar um verbete para uma palavra que já está no
dicionário.

user@notebook:~/ra123456/colecoes$ python3 dicionario_tuplas.py


Traceback (most recent call last):
File "dicionario_tuplas.py", line 69, in <module>
main()
File "dicionario_tuplas.py", line 53, in main
adicionar_verbete(dicionario, outro)
File "dicionario_tuplas.py", line 14, in adicionar_verbete
raise Exception(f"Palavra {verbete[0]} já existe.")
Exception: Palavra amor já existe.

Há vários tipos de exceção, como ValueError, IndexError, ZeroDivisionError e


podemos criar a nossa própria hierarquia de tipos. Nesta disciplina é suficiente utilizar
um tipo de erro genérico e levantar uma exceção. Para isso, basta fazer uma chamada
a Exception passando a mensagem de erro como argumento. Você deveria ter se
convencido de que criar uma função apenas para adicionar um elemento à lista não é tão
bobo assim.

Uma operação sobre a qual não discutimos é a remoção de um verbete. Pode ser que
uma palavra se torne obsoleta, então, embora incomum, essa é uma operação que o
dicionarista desejaria realizar. Implemente a operação para remover um verbete.

Conjuntos
Em uma lista, temos uma sequência de elementos, ou seja, existe a noção de primeiro
elemento, de segundo etc. Enquanto lista é o tipo em Python mais comum para
representar coleções de dados, muitas vezes a ordem em que eles estão armazenados é
irrelevante. Isso acontece quando falamos de conjuntos, no sentido matemático: não
existe uma ordem dos elementos e nenhum elemento aparece mais do que uma vez.

Escreva uma função que receba dois conjuntos de strings e devolva a diferença entre
esses conjuntos.

Não é muito difícil escrever um algoritmo para esse problema e depois programá-lo.

def calcular_diferenca(a, b):


"""recebe conjuntos a e b e devolve a - b"""

diferenca = []
for elemento_a in a:
if elemento_a not in b:
diferenca.append(elemento_a)
return diferenca

def main():
a = ["ana", "maria", "pedro", "raul"]
b = ["sérgio", "gustavo", "maria", "ana"]
diferenca = calcular_diferenca(a, b)
print(diferenca)

Esse é só um exemplo. Não precisávamos de um computador para descobrir que a


diferença desses conjuntos corresponde a ["pedro", "raul"]. Os computadores são
mais úteis quando trabalhamos com grandes volumes de dados.

Você já deve ter ouvido falar que português e espanhol são muito parecidos. É bem
possível que você entenda uma pessoa falando espanhol — se ela falar bem devagar,
mesmo que nunca tenha estudado o idioma. Vamos tirar isso a prova e calcular a
diferença das 1000 palavras mais comuns dos dois idiomas.

Primeiro, com um pouco de paciência, precisamos encontrar duas listas razoavelmente


confiáveis de palavras frequentes em português e espanhol. Você pode achar dois
arquivos pt_br.txt e es.txt aqui. Vamos investigar um pouco esses arquivos de texto
olhando para as primeiras linhas.

user@notebook:~/ra123456/colecoes$ ls -lh *.txt


-rw-rw-r-- 1 user user 6,6M set 25 2013 es.txt
-rw-rw-r-- 1 user user 4,8M set 25 2013 pt_br.txt
user@notebook:~/ra123456/colecoes$ head pt_br.txt
que 3097124
não 2582821
o 2582602
de 2075204
a 1948955
é 1786757
você 1513922
e 1508432
eu 1416433
um 1197384
user@notebook:~/ra123456/colecoes$ head es.txt
de 3405234
que 3349162
no 3166057
a 2368719
la 2288023
el 1922428
y 1774259
es 1729448
en 1640089
lo 1429314

O comando head mostra as primeiras dez linhas de um arquivo. Você não quer abrir um


arquivo com vários megabytes em um editor de textos, acredite. Agora podemos fazer
um programa que leia as palavras mais frequêntes de cada idioma e calcule a diferença.

def ler_palavras_frequentes(nome_arquivo, n):


"""lê o arquivo de frequência de palavras
e devolve as n palavras mais frequentes"""

lista = []
with open(nome_arquivo) as arquivo:
i = 0
for linha in arquivo:
palavra, frequencia = linha.strip().split()
lista.append(palavra)
i += 1
if i == n:
break
return lista

def calcular_diferenca(a, b):


"""recebe conjuntos a e b e devolve a - b"""

diferenca = []
for elemento_a in a:
if elemento_a not in b:
diferenca.append(elemento_a)
return diferenca

def mostrar_lista(lista):
for elemento in lista:
print(elemento)

def main():
n = 10
palavras_es = ler_palavras_frequentes('es.txt', n)
palavras_pt_br = ler_palavras_frequentes('pt_br.txt', n)
diferenca = calcular_diferenca(palavras_es, palavras_pt_br)
mostrar_lista(diferenca)

main()

Para testar, começamos comparando as 10 palavras mais frequentes de cada idioma.

user@notebook:~/ra123456/colecoes$ python3 diferenca.py


no
la
el
y
es
en
lo

Bueno. Não há tanta interseção assim com as 10 palavras mais frequentes do espanhol,
então precisamos de fato aprender quase todas. O lado bom é que são palavras fáceis.
Vamos contar quantas palavras novas precisamos aprender se quisermos conhecer as
1000 mais frequentes do espanhol. Como só estamos interessados na contagem, vamos
substituir a instrução mostrar_lista(diferenca) por algo como print(f"Queremos
aprender {len(diferenca)} palavras.") . Executando para n = 1000, obtemos a
saída quase que imediatamente.

user@notebook:~/ra123456/colecoes$ python3 diferenca.py


Queremos aprender 742 palavras.

Pelo menos não são todas as 1000 palavras. Para alguém que nasceu antes dos
computadores se popularizarem, parece um grade feito calcular a diferença entre
conjuntos de 1000 palavras em tão pouco tempo — em algumas dezenas de
milissegundos. Para quem já nasceu com um computador na mão, isso não tem nada de
impressionante. Vamos tentar com 100.000 palavras. Alteramos a função fazendo n =
100000, salvamos e executamos novamente. Execute você mesmo, não acredite em tudo
que lê!

user@notebook:~/ra123456/colecoes$ python3 diferenca.py


Queremos aprender 57529 palavras.

No meu computador, essa execução levou cerca de um minuto e meio! Isso é muito
tempo para os padrões atuais. Então precisamos parar e pensar: por que o programa
gastou tanto tempo? Será que os computadores de hoje em dia não são rápidos para um
problema desse tipo? Com certeza você já viu tarefas muito mais complicadas serem
realizadas por um computador em muito menos tempo, então temos que desconfiar de
nosso algoritmo.

Quando estamos numa situação como essa, temos que descobrir quais instruções de
nosso programa são executadas mais vezes. Revise o programa e tente descobrir quais
instruções são executadas mais vezes. Você deve se convencer de que uma delas é a
instrução in na linha que contém if elemento not in b:. Essa instrução é executada
100.000 vezes, uma vez para cada palavra frequente em espanhol. Mas meu computador
executa milhões de instruções elementares por segundo, então para realmente entender
porque o programa gastou tanto tempo vamos reescrever essa linha explicitando o que o
interpretador python tem que fazer sempre que a executa.

def calcular_diferenca(a, b):


"""recebe conjuntos a e b e devolve a - b"""

diferenca = []
for elemento_a in a:
encontrou = False
for elemento_b in b:
if elemento_a == elemento_b:
encontrou = True
break
if not encontrou:
diferenca.append(elemento_a)
return diferenca

Como representamos o conjunto de palavras usando uma lista, para encontrar uma
determinada palavra na lista, temos que percorrê-la desde o início, não há outro jeito.
Pior, no nosso exemplo, em 57.529 da vezes que procuramos alguma palavra, tivemos
que percorrer toda a lista de palavras em português, só para descobrir que a palavra em
espanhol não estava ali.

A escolha da representação do nosso conjunto de dados nesse caso teve um impacto


tremendo no tempo de execução do algoritmo. Listas foram feitas para armazenar
conjuntos de dados que são acessados sequencialmente na ordem armazenada --- elas
não são boas para procurar valores arbitrários. Por esse motivo, existe um outro tipo de
coleção de dados para representar conjuntos, o set. A vocação de uma coleção do
tipo set é verificar pertinência eficientemente, então essa é uma escolha de estrutura de
dados ideal para nosso algoritmo. Vamos criar uma implementação alternativa de nosso
programa, dessa vez usando conjuntos.

def ler_palavras_frequentes(nome_arquivo, n):


"""lê o arquivo de frequência de palavras
e devolve as n palavras mais frequentes"""

conjunto = set()
with open(nome_arquivo) as arquivo:
i = 0
for linha in arquivo:
palavra, _ = linha.strip().split()
conjunto.add(palavra)
i += 1
if i == n:
break
return conjunto

def calcular_diferenca(a, b):


"""recebe conjuntos a e b e devolve a - b"""

diferenca = []
for elemento_a in a:
if elemento_a not in b:
diferenca.append(elemento_a)
return diferenca

def mostrar_conjunto(conjunto):
for elemento in conjunto:
print(elemento)

def main():
n = 100000
palavras_es = ler_palavras_frequentes("es.txt", n)
palavras_pt_br = ler_palavras_frequentes("pt_br.txt", n)
diferenca = calcular_diferenca(palavras_es, palavras_pt_br)
# mostrar_conjunto(diferenca)
print(f"Queremos aprender {len(diferenca)} palavras.")

main()

As funções para manipular conjuntos são ligeiramente diferentes das funções de lista,
mas não é difícil se acostumar. Executando, devemos obter a mesma resposta -- o
algoritmo é o mesmo, só mudamos a escolha da estrutura de dados.

user@notebook:~/ra123456/colecoes$ python3 diferenca_conjuntos.py


Queremos aprender 57529 palavras.

A resposta é mostrada em pouco menos de 150ms, quase não dá pra perceber. Essa é
uma diferença espetacular! Dessa vez escolhemos uma estrutura de dados mais
adequada às operações de que nosso algoritmo necessita. O motivo para essa diferença é
que a representação dos dados quando armazenamos um conjunto é cuidadosamente
pensada para executar a operação in eficientemente. Nós não estudaremos essa
representação aqui, há toda uma disciplina dedicada a essas questões.

Uma pergunta deve inquietar quem sempre usou o tipo list e aprendeu que existe o
tipo set: se a operação in em uma variável do tipo conjunto é tão mais rápida, por que
não usamos set sempre? A resposta é que, embora ambos tipos sirvam para armazenar
conjuntos de dados, eles são abstrações diferentes. Quando utilizamos uma lista, a
ordem em que os elementos são armazenados é importante. Quando utilizamos um
conjunto, abrimos mão dessa informação para construir uma representação mais
eficiente para o operador in.

Uma última palavra: além da operação in, o tipo set permite diversas outras operações


sobre os conjuntos. Consulte a documentação do Python e experimente utilizar várias
delas. Em particular, poderíamos fazer a diferença de conjuntos usando simplesmente o
operador -. Por exemplo, faça o seguinte.

>>> a = {1, 3, 7, 2}
>>> b = {7, 1, 8}
>>> a - b
{2, 3}

Dicionários
Agora vamos mudar um pouco e falar de números.

Crie um programa que calcule o histograma de uma lista de números inteiros.

Primeiro precisamos relembrar o que é um histograma. Um histograma sobre uma dada


lista de elementos é uma tabela de contagem que associa cada elemento distinto ao
número de vezes que ele aparece na lista. Quando estamos falando de superconjuntos
(conjuntos que permitem a repetição de elementos), o número de vezes também é
chamado de multiplicidade.
Esse problema parece muito simples. De fato, não é muito difícil escrever um algoritmo
para ele.

1. Para cada número n da entrada:

a) se n não apareceu ainda:

o faça a multiplicidade de n receber 1


o armazene a contagem de n

b) se n já apareceu:

o encontre a contagem de n


o incremente a multiplicidade de n

Pare que esse algoritmo esteja bem definido de fato, antes precisamos dizer como
vamos armazenar os números e as multiplicidades. Vimos que para escrever um bom
algoritmo, é fundamental pensar com cuidado na representação dos dados que
utilizaremos. Queremos guardar os números, então uma lista de números é sempre uma
opção. Como não estamos preocupados com a ordem em que os números são
armazenados e no nosso algoritmo precisamos determinar repetidamente se um dado
número está na nossa coleção , utilizar um conjunto parece uma escolha muito melhor.

Acontece que também queremos guardar os dados relativos à multiplicidades.


Poderíamos pensar em uma lista ou em um conjunto de multiplicidades, mas como
descobriríamos a qual número se refere cada multiplicidade? Além disso, já sabemos
que dados relacionados devem andar juntos. Então queremos criar alguma abstração que
associe um número à sua multiplicidade. Digamos que uma contagem é uma lista com
dois elementos.

contagem = [número, multiplicidade]

A maneira com que utilizamos uma lista aqui não é muito usual, afinal os dois
elementos são do mesmo tipo, mas têm significados bem diferentes. Como a contagem
de um número muda no decorrer do algoritmo (e não queremos recriar a contagem cada
vez que reencontrarmos um número), utilizar uma lista dessa maneira nesse caso traz
mais vantagens do que desvantagens.

Agora, voltamos a pergunta de como representar um histograma. Uma boa tentativa


seria um conjunto de contagens, já que nosso algoritmo precisa acessar uma contagem
rapidamente. Mas isso tem um problema. Quando estamos percorrendo a lista de
entrada e encontramos um número, queremos atualizar a multiplicidade desse número,
mas não há como acessar o par [número, multiplicidade] diretamente. Temos que
que percorrer cada par de contagem até encontrarmos a contagem correspondente. Para
entender isso, tente descobrir qual a saída do trecho abaixo.

conjunto = {[1, 100], [7, 21], [3, 45], [2,200]}


if 7 in histograma:
print('O número 7 está no conjunto')
else:
print('O número 7 não está no conjunto')
Claro que o número não está no conjunto. A variável referenciada por conjunto é do
tipo set e contém elementos do tipo lista; não há nenhum número nesse conjunto.
Como não há nenhuma vantagem em usar conjuntos nesse exemplo, vamos utilizar
listas, já que estamos mais familiarizados com elas. Assim, vamos representar um
histograma como uma lista de contagens.

Com isso, podemos implementar o algoritmo acima.

def calcular_histograma(lista_numeros):
histograma = []
for numero in lista_numeros:
for par in histograma:
if par[0] == numero:
par[1] += 1
break
else:
par = [numero, 1]
histograma.append(par)

return histograma

def mostrar_histograma(histograma):
for numero, multiplicidade in histograma:
print(f"Multiplicidade de {numero}: {multiplicidade}")

def main():
lista_numeros = [1, 2, 7, 3, 2, 2, 6, 2, 1, 6]
histograma = calcular_histograma(lista_numeros)
mostrar_histograma(histograma)

main()

Testando o programa para essa entrada pequena, parece tudo certo. O que acontece
quando o conjunto de dados é grande é que é interessante. Vamos testar com um
conjunto de 100.000 números, distribuídos entre 0 e 9999. Dessa vez, vamos usar
alguns números aleatórios. Para descobrir como criar esse arquivo, faça o exercício
correspondente ao módulo random aqui.

user@notebook:~/ra123456/colecoes$ ls -lh muitos.txt


-rw-rw-r-- 1 user user 478K jun 13 22:46 muitos.txt
user@notebook:~/ra123456/colecoes$ head muitos_numeros.txt
15962
10902
8314
14623
3304
4029
12455
3304
19918
8353

Adaptamos o programa para ler o arquivo.

def ler_arquivo(nome_arquivo):
with open(nome_arquivo) as arquivo:
lista_numeros = []
for linha in arquivo:
numero = int(linha)
lista_numeros.append(numero)
return lista_numeros

def main():
lista_numeros = ler_arquivo("muitos.txt")
histograma = calcular_histograma(lista_numeros)
mostrar_histograma(histograma)

Executamos e esperamos cerca de 15 segundos para o programa terminar. Nada


surpreendente. Já sabemos que a estrutura de dados utilizada para representar os dados
não é muito boa para as operações de que nosso algoritmo precisa. Veremos que Python
tem uma estrutura de dados chamada dict, ou tipo dicionário, que é especialmente
adequada para nosso algoritmo. Antes de introduzir dicionários, vamos resolver uma
versão bem mais simples do problema. A esperança é que esse exercício mais simples
nos dê uma intuição sobre qual seria a estrutura de dados ideal.

Crie um programa que calcule o histograma de uma lista de números inteiros entre 0 e
9999.

Esse é quase o mesmo problema, mas agora podemos supor que todos os números da
entrada estão nesse intervalo. Isso sugere que podemos guardar as multiplicidades dos
números em um vetor de 10000 posições: os números da entrada correspondem a
índices desse vetor. Isso é conveniente pois podemos acessar o dado associado a cada
número diretamente!

def ler_arquivo(nome_arquivo):
with open(nome_arquivo) as arquivo:
lista_numeros = []
for linha in arquivo:
numero = int(linha)
lista_numeros.append(numero)
return lista_numeros

def calcular_histograma_simplificado(lista_numeros):
histograma = [0] * 10000

for numero in lista_numeros:


histograma[numero] += 1

return histograma

def mostrar_histograma(histograma):
for numero, multiplicidade in enumerate(histograma):
print(f"Multiplicidade de {numero}: {multiplicidade}")

def main():
lista_numeros = ler_arquivo("muitos.txt")
histograma = calcular_histograma_simplificado(lista_numeros)
mostrar_histograma(histograma)
main()

Observe atentamente como utilizamos cada número como um índice do vetor. Dessa
vez, o programa gastou cerca de 70ms para calcular o histograma de 100.000 números.
Acessar um índice de uma lista é muito rápido! Para que isso tenha dado certo, foram
fundamentais algumas propriedades do problema simplificado.

1. os elementos de entrada que queremos armazenar são números;

2. esses números estão no intervalo de 0 a 9999.

Esse tipo de representação não funciona para o problema geral porque podemos querer
calcular histogramas de conjuntos de dados não numéricos. Por exemplo, se quisermos
criar um arquivo das palavras mais frequentes de um idioma, o que estamos fazendo na
verdade é um histograma de palavras. Mesmo que os dados sejam números inteiros,
pode ser que esses números sejam muito grandes. Não queremos criar um vetor que
ocupa vários gigabytes de memória apenas para computar um histograma.

Agora já podemos descrever o que queremos de uma estrutura de dados para representar
um histograma:

1. queremos armazenar um conjunto de pares (chave, valor) de forma que cada


chave só apareça uma vez no conjunto;

2. queremos decidir se uma determinada chave está na coleção, independentemente


do valor associado;

3. queremos modificar o valor associado a uma chave rapidamente, assim como


modificamos o valor associado a um índice de uma lista.

Uma estrutura de dados que satisfaz todos esses requisitos é um dict. Vejamos alguns
exemplos.

>>> idades = dict()


>>> idades["Ana"] = 18
>>> idades["João"] = 17
>>> idades["Mariana"] = 18
>>> idades["Mariana"] = 19
>>> idades
{'Ana': 18, 'João': 17, 'Mariana': 19}
>>> tipos_triangulos = {
... (3, 3, 3): "equilátero",
... (2, 1, 2): "isósceles",
... (2, 2, 1): "isósceles",
... (3, 4, 5): "escaleno",
... }
>>> tipos_triangulos
{(3, 3, 3): 'equilátero', (2, 1, 2): 'isósceles', (2, 2, 1): 'isósceles',
(3, 4, 5): 'escaleno'}
>>> (5,5,5) in tipos_triangulos
False
>>> (5,4,3) in tipos_triangulos
False
>>> tipos_triangulos[(5,5,5)] = "equilátero"
>>> (5,5,5) in tipos_triangulos
True

Veja o trecho acima com cuidado e, se tiver dúvidas sobre a sintaxe, consulte o tutorial
Python. Na primeira linha, a atribuição idades = dict() cria um dicionário vazio e
associa ao nome idades. Poderíamos escrever apenas idades = {}, mas preferi
escrever dict() para explicitar que estamos criando um dicionário. Com isso, já
podemos ajustar nossa função para calcular histogramas.

def calcular_histograma(lista):
histograma = {}

for elemento in lista:


if elemento not in histograma:
histograma[elemento] = 1
else:
histograma[elemento] += 1

return histograma

def mostrar_histograma(histograma):
for elemento, multiplicidade in histograma.items():
print(f"Multiplicidade de {elemento}: {multiplicidade}")

Repare que para percorrer os pares (chave, valor) de um dicionário, chamamos a


função items. Se não tivéssemos usado essa função, iríamos iterar apenas sobre as
chaves. Executamos a versão atualizada do programa com o arquivo de 100.000
inteiros. Ela gasta cerca de 70ms apenas e ainda funciona com entradas arbitrárias.

Registros e mutabilidade
Agora que já conhecemos o tipo dicionário, podemos repensar a representação de um
verbete do nosso primeiro exemplo. Lá, dissemos que um verbete era uma tupla da
forma (palavra, definição, ano). Há algumas desvantagens em se utilizar uma tupla
dessa maneira. A principal delas é que para acessar uma dado associado ao verbete
precisamos utilizar um índice numérico que não tem nada a ver com o significado
daquele dado.

Por exemplo, na nossa representação, a definição de uma palavra é verbete[1], mas se


tivéssemos abstraído um verbete como uma tupla da forma (palavra, ano,
definição), deveríamos escrever verbete[2]. Pior, se algum dia quisermos adicionar
outro dado associado a um verbete, então teremos que revisitar todos os trechos de
código que lidam com a representação de um verbete! Digamos que depois de um
tempo decidamos armazenar juntamente com um verbete a classe associada a
determinada palavra (substantivo, adjetivo, etc.). Atualizamos a representação de um
verbete para uma tupla da forma (palavra, classe, definição, ano) .

def atualizar_definicao(dicionario, palavra, nova_definicao):


"""atualiza a definição de uma palavra"""

i = procurar_indice(dicionario, palavra)
verbete_antigo = dicionario[i]
verbete_novo = (verbete_antigo[0], verbete_antigo[1], nova_definicao,
verbete_antigo[3])
dicionario[i] = verbete_novo

def main():
dicionario = criar_dicionario()

verbete = ("amor", "substantivo", "fogo que arde sem se ver", 1595)


adicionar_verbete(dicionario, verbete)

verbete = procurar_verbete(dicionario, "amor")


palavra, classe, definicao, ano = verbete
print(f"{palavra} significa {definicao}")

nova_definicao = input("O que você acha que é o amor?\n")


atualizar_definicao(dicionario, "amor", nova_definicao)

palavra, definicao, ano = procurar_verbete(dicionario, "amor")


print(f"{palavra} agora significa {definicao}")

Tivemos que alterar praticamente toda a função main, já que ela cria e manipula os
verbetes. O que foi desagradável é que tivemos que alterar também a
função atualizar_definicao, mesmo que a definição não tenha nada a ver com a
classe da palavra. Sempre que mudamos a representação em memória de uma certa
abstração precisamos revisar todas as instruções que acessam essa representação
diretamente. Aff... esqueci de atualizar as variáveis recebidas na última chamada
a procurar_verbete.

Em uma boa abstração, gostaríamos de acessar a definição associada ao verbete sem nos
preocupar com a forma com que ele é representado. Uma estratégia bastante comum em
Python é criar um dicionário que representa um registro de uma coleção de dados.
Assim, ao invés de utilizar uma tupla, representamos um verbete por um dicionário
como no exemplo

verbete = {
"palavra": "amor",
"classe": "substantivo",
"definicao": "ferida que dói e não se sente",
"ano": 1595,
}

Vamos adaptar, mais uma vez, o nosso programa.

def criar_dicionario():
"""cria um dicionário vazio"""

return []

def adicionar_verbete(dicionario, verbete):


"""adiciona um novo verbete ao dicionário"""

i = procurar_indice(dicionario, verbete["palavra"])
if i is None:
dicionario.append(verbete)
else:
raise Exception(f"Palavra {verbete['palavra']} já existe.")

def procurar_verbete(dicionario, palavra):


"""devolve o verbete correspondente à palavra;
se palavra não for encontrada, devolve None"""

for verbete in dicionario:


if verbete["palavra"] == palavra:
return verbete
return None

def procurar_indice(dicionario, palavra):


"""devolve o índice correspondente à palavra
ou None se ela não existir no dicionário"""

for i, verbete in enumerate(dicionario):


if verbete["palavra"] == palavra:
return i
return None

def atualizar_definicao(dicionario, palavra, nova_definicao):


"""atualiza a definição de uma palavra"""

i = procurar_indice(dicionario, palavra)
verbete = dicionario[i]
verbete["definicao"] = nova_definicao

def main():
dicionario = criar_dicionario()

verbete = {
"palavra": "amor",
"classe": "substantivo",
"definicao": "fogo que arde sem se ver",
"ano": 1595,
}
adicionar_verbete(dicionario, verbete)

verbete = procurar_verbete(dicionario, "amor")


print(f"{verbete['palavra']} significa {verbete['definicao']}")

nova_definicao = input("O que você acha que é o amor?\n")


atualizar_definicao(dicionario, "amor", nova_definicao)

verbete = procurar_verbete(dicionario, "amor")


print(f"{verbete['palavra']} agora significa {verbete['definicao']}")

main()

Você deve comparar essa implementação com a anterior e decidir qual é mais fácil de
ler e entender. Se compararmos com atenção, no entanto, vamos ver que a maneira que
implementamos alterar_definicao na versão com dicionários é ligeiramente diferente
da maneira que implementamos essa mesma função na versão com lista. Antes, como
não podíamos alterar os dados de uma tupla, criamos um novo verbete e substituímos o
verbete antigo pelo novo. Agora, apenas alteramos o verbete diretamente.

Vamos investigar as consequências disso. Primeiro vamos executar o seguinte trecho de


código, utilizando a representação de nossa coleção de verbetes como uma lista de
tuplas.

def main():
dicionario = criar_dicionario()

anterior = ("amor", "substantivo", "fogo que arde sem se ver", 1595)


adicionar_verbete(dicionario, anterior)
atualizar_definicao(dicionario, "amor", "um contentamento
descontente")
atual = procurar_verbete(dicionario, "amor")

print(f"antes, amor era {anterior[2]}")


print(f"agora, amor é {atual[2]}")
user@notebook:~/ra123456/colecoes$ python3 dicionario_tuplas.py
antes, amor era fogo que arde sem se ver
agora, amor é um contentamento descontente

Tudo dentro do esperado. Vamos utilizar a implementação com dicionário.

def main():
dicionario = criar_dicionario()

anterior = {
"palavra": "amor",
"classe": "substantivo",
"definicao": "fogo que arde sem se ver",
"ano": 1595,
}
adicionar_verbete(dicionario, anterior)
atualizar_definicao(dicionario, "amor", "um contentamento
descontente")
atual = procurar_verbete(dicionario, "amor")

print(f"antes, amor era {anterior['definicao']}")


print(f"agora, amor é {atual['definicao']}")
user@notebook:~/ra123456/colecoes$ python3 dicionario_dict.py
antes, amor era um contentamento descontente
agora, amor é um contentamento descontente

A definição do verbete anterior é a mesma do verbete atual! Isso acontece porque o


verbete alterado atualizar_definicao é o mesmo verbete adicionado anteriormente.
Em outras palavras, anterior e atual são o mesmo objeto.

Vamos fazer um desenho da memória das variáveis desses programas no momento em


que a função atualizar_definicao termina. Lembre-se de que, nos nossos desenhos,
costumamos representar os valores das variáveis por retângulos, então cada retângulo
corresponde a um objeto distinto. Para a implementação que usa lista de tuplas, temos o
seguinte desenho.
E para a implementação que usa lista de dicionário, o seguinte.
Para entender a diferença, precisamos aprender o conceito de identidade. Já falamos
que toda variável em Python é um objeto na memória com determinado tipo e,
normalmente, referenciado pelo nome de uma variável. A identidade de um objeto é um
número inteiro associado a esse objeto e a nenhum outro. Podemos pensar que cada
objeto é um pequeno espaço reservado na memória. Assim, se dois objetos tiverem a
mesma identidade, eles são os mesmos objetos.

Para verificar se dois objetos são o mesmo, utilizamos o operador is. Para descobrir a
identidade de um objeto, utilizamos a função id. Experimente adicionar o trecho abaixo
às funções acima e executar com cada implementação. Depois faça alterações no código
e experimente até internalizar esses conceitos. Por exemplo, chame a
função atualizar_definicao passando como parâmetro a definição original.

if atual == anterior:
print("objetos são iguais")
else:
print("objetos são diferentes")

if atual is anterior:
print("atual e anterior são o mesmo objeto")
else:
print("atual e anterior são objetos distintos")

print(f"o identificador de anterior é {id(anterior)}")


print(f"o identificador de atual é {id(atual)}")

Esses exemplos ilustram bem um conceito fundamental em Python e de linguagens de


programação em geral: mutabilidade. Um tipo de variável é imutável se um objeto
desse tipo não muda nunca.

São exemplos de tipos imutáveis: int, str, tuple. Você pode desconfiar dessa


afirmação, afinal de conta, inúmeras vezes já escrevemos programas que modificam
variáveis inteiras. Por exemplo, para calcular o dobro de um número podemos escrever.

n = int(input())
n = 2 * n
print(f"O dobro é {n}")

Lembremos: quando fazemos uma atribuição, primeiro calculamos o valor da expressão


do lado direito do = e depois associamos o nome a esquerda a esse valor. Portanto,
quando escrevemos n = 2 * n (leia-se, n recebe 2 * n), o que estamos fazendo é
substituir o valor referenciado pelo seu dobro. Isso pode ser ilustrado pela figura abaixo.
São exemplos de tipos mutáveis list e dict. Devemos tomar cuidado: quando dizemos
que uma lista é mutável, estamos falando que estamos alterando o objeto
correspondente à lista. Se fizermos uma atribuição, então, estaremos alterando a
referência do nome, não o objeto. Considere o exemplo que mostra duas maneiras
distintas de ordenar uma lista em Python.

lista_strings = ["ana", "maria", "beto"]


apelido_lista_strings = lista_strings
lista_inteiros = [3, 4, 2, 1]
apelido_lista_inteiros = lista_strings
lista_strings.sort()
lista_inteiros = sorted(lista_inteiros)
print(apelido_lista_strings)
print(apelido_lista_inteiros)

Descubra qual é a saída deste trecho. Para isso, faça um desenho que representa a
memória do processo. Depois, confira sua resposta simulando esse código no terminal
interativo do Python.

Um parêntese: JSON

Normalmente, não guardamos os dados diretamente no código de um programa. Para


armazená-los permanentemente podemos utilizar diferentes mecanismos, como banco
de dados, arquivos binários ou arquivos texto. A escolha depende da aplicação. Em
diversas situações, quando a quantidade de dados não é tão grande e queremos apenas
consultar os dados, podemos usar um arquivo JSON. Um arquivo JSON é um formato
estruturado de guardar conjuntos de dados bastante popular na internet. A grande
vantagem desse formato (e de outros similares, como XML, Yaml, Toml etc.) é que ele
se mapeia muito facilmente com as representações de coleções de dados que
normalmente utilizamos em nossos programa. Depois, não deixe de fazer o exercício de
fixação correspondente.

Classes
Um aviso: Você não precisa criar ou usar classes nesta disciplina, mas eventualmente
precisará lidar com elas quando estiver trabalhando com Python. Aqui, iremos utilizar
classes apenas como meio para guardar um conjunto de dados associados, assim como
fizemos com tuplas e dicionários. Há diversas outros motivos pelos quais se usa uma
classe, mas não vamos colocar o carro na frente dos bois.

Suponha que o dicionarista decida adicionar mais uma palavra ao dicionário.

verbete = {
"palavra": "liberdade",
"definicao": "palava que o sonho humano alimenta",
"ano": 1953,
}
adicionar_verbete(dicionario, anterior)

Este trecho é inofensivo e quando executado não irá causar nenhum erro, mas ele tem
um problema de consistência. Um verbete deveria ter todos as
chaves "palavra", "classe", "definicao" e "ano", mas esquecemos de "classe".
Esse é um tipo de erro muito comum que aconteceu porque, quando criamos o verbete,
precisamos nos preocupar com os detalhes de como ele é representado na memória. Para
evitar esse tipo de problema, podemos criar uma função cujo único objetivo é criar um
verbete. Assim, se esquecermos de algum dado, identificaremos o erro, antes mesmo de
executarmos o programa.

def criar_verbete(palavra, classe, definicao, ano):


verbete = {
"palavra": palavra,
"classe": classe,
"definicao": definicao,
"ano": ano,
}
return verbete

Quando representamos um verbete usando um dicionário, estamos fazendo uma


abstração nova, mas não estamos criando um novo tipo de variável. O problema de usar
um dicionário diretamente é que, se não formos cuidados, podemos acaber realizando
operações sobre o objeto de forma a deixar a representação inconsistente. Para
abstrações simples como essa, um dicionário é suficiente. Mas, para abstrações mais
complicadas precisamos criar um novo tipo de variável. Em Python, fazemos isso
usando usamos classes.
Em seguida, criamos uma classe que representa um verbete e uma função main que cria
um objeto desse novo tipo.

class Verbete:
def __init__(self, palavra, classe, definicao, ano):
self.palavra = palavra
self.classe = classe
self.definicao = definicao
self.ano = ano

def main():
verbete = Verbete("amor", "substantivo", "estar-se preso por
vontade", 1595)
print(f"amor significa {verbete.definicao}")
print(type(objeto))

Há vários detalhes aqui que precisamos entender. Primeiro, observe que chamamos o
nome da classe como se ela fosse uma função. Quando essa instrução é executada, o que
acontece é o seguinte:

1. O interpretador Python cria um novo objeto na memória do tipo Verbete.


2. A função Verbete.__init__ é chamada para inicializar esse objeto. O primeiro
parâmetro deve ser self e corresponde ao objeto recém criado. Os demais
parâmetros são os parâmetros passados na chamada Verbete(...).
3. A função é executada e cria os diversos atributos contendo os dados associados
ao verbete.

Além dos atributos, uma classe pode definir funções que operam sobre o objeto. Por
exemplo, digamos que classificamos uma palavra como neologismo se ela for
catalogada a partir de certo ano.

class Verbete:
def __init__(self, palavra, classe, definicao, ano):
self.palavra = palavra
self.classe = classe
self.definicao = definicao
self.ano = ano

def eh_neologismo(self):
if self.ano >= 1990:
return True
else:
return False

def main():
verbete = Verbete("smartphone", "substantivo", "um telefone, só que
mais caro", 1997)
if verbete.eh_neologismo():
print("a palavra é um neologismo")
else:
print("a palavra não é um neologismo")
Enquanto o termo classe possa ser uma novidade, já estamos lidando com elas há muito
tempo. Todos os valores em Python são objetos de alguma classe! Por exemplos, listas
são objetos da classe list.

lista1 = []
lista2 = list()

O trecho acima cria dois objetos do tipo list. A expressão [] nada mais é do que um


atalho para uma chamada list(). Quando usamos o nome de um tipo como se fosse
uma função, reservamos um espaço na memória para um novo objeto. Esse espaço é
preenchido por uma função especial de inicialização; nesse caso, list.__init__.

Agora, modifique nosso programa para que a coleção de verbetes seja representada
como uma lista de objetos do tipo Verbete. Isso não deve ser difícil, basta mudar
expressões como verbete["palavra"] para expressões como verbete.palavra.

Alternativas

Adicionar um tipo novo parece trivial, mas se feito sem cuidado pode complicar o nosso
programa e prejudicar o entendimento ou a execução de nosso algoritmo. Se tudo o que
queremos é acessar os dados de uma tupla através de um nome, então poderíamos usar
uma namedtuple, ou se precisarmos de um objeto mutável, um SimpleNamespace. Não
vamos estudar todos os tipos da linguagem, nem é necessário para a disciplina. Mas é
sempre bom saber que eles existem.

Finalmente, nossa abstração do dicionário de palavras representado como uma lista


sofre dos mesmos problemas que nossa primeira versão da função para calcular um
histograma. A solução deve ser clara agora: utilizar uma estrutura de dados mais
adequada. Dessa vez, vou deixar para você fazer — ainda mais uma — implementação
do dicionário.

← ANTERIOR
VOLTAR
PRÓXIMA →

Copyright © 2020

Se compararmos os primeiros computadores digitais com os computadores de hoje,


iremos descobrir que os supercomputadores antigos, que ocupavam várias salas de um
andar de um prédio são muito mais lentos do que os celulares que hoje seguramos em
nossas mãos. Isso é um feito extraordinário, que merece ser contemplado. O
computador em que escrevo este texto tem na verdade quatro processadores, cada um
executando 1,8 bilhão de instruções elementares por segundo. Esse número é tão
grande, que a ideia de que o poder computacional das máquinas atuais é ilimitado é
bastante sedutora.

A realidade, no entanto, não demorou em bater na nossa porta e nem precisamos tentar
resolver problemas muito complicados para descobrir que, mais vezes sim do que não,
os nossos programas demoram mais do que gostaríamos. Foi assim quando tentamos
estimar o número de passos no programa da tartaruga e descobrimos que melhor que ela
more perto de casa. Foi assim quando percebemos que procurar uma palavra em
uma lista de tamanho moderado não é uma tarefa trivial. Vai ser assim quando você
tentar ordenar mais do que umas centenas de números com os algoritmos que já
aprendemos, vai ser assim quando você tentar realizar alguma operação em
uma matriz que representa uma imagem de alta resolução...

Ao relembrarmos o que estudamos desde o início do semestre, vamos perceber que


sempre repetimos as mesmas atividades: dado um problema, com entrada e saída bem
definidas, primeiro queremos escrever um algoritmo que resolva o problema e, depois,
queremos implementar esse algoritmo em uma linguagem de programação. Pode ser
frustrante, portanto, descobrir que isso não é suficiente para encontrar uma solução do
problema.

Por mais que nosso algoritmo esteja correto e que tenhamos uma implementação
impecável desse algoritmo, não resolveremos um problema se o programa
implementado precisar de mais memória do que temos disponível, ou se ele gastar
vários anos executando. Se quisermos controlar e utilizar os computadores
eficientemente, precisamos compreender e identificar quais problemas nossos
algoritmos podem resolver — e quais eles não podem. Para isso, precisamos descobrir
de que recursos nossos que algoritmos precisam e estimar em que quantidades eles são
necessários. Em seguida, vamos começar a estudar o principal recurso utilizado por um
algoritmo: o tempo.

Uma lista de números primos


Para discutir melhor sobre o tempo de execução, vamos voltar a um tema recorrente na
nossa disciplina.

Escreva um programa que, dado um número inteiro n, liste e conte todos os números
primos que estão entre 0 e n−1.

A primeira missão é dar uma estimativa grosseira de quanto tempo é necessário para
resolver o problema. Antes disso, precisamos construir e implementar algum algoritmo.
Se não conhecermos algum algoritmo, não haverá muito o que discutir. Já conversamos
várias vezes sobre como decidir se um número é primo ou não, então eu omitirei o
algoritmo em português.

def eh_primo(p):
if p == 0 or p == 1:
return False

tem_divisor = False
for divisor in range(2, p):
if p % divisor == 0:
tem_divisor = True

if tem_divisor:
return False
else:
return True
def contar_primos(n):
m = 0
for p in range(n):
if eh_primo(p):
print(p)
m += 1

return m

def main():
n = int(input("Digite o número n: "))
m = contar_primos(n)
print(f"Há {m} primos de 0 até {n-1}")

main()

Se testarmos esse programa digitando 10, iremos ver que ele devolve uma resposta
imediatamente após pressionarmos enter.

user@notebook:~/ra123456/eficiencia$ python3 primos.py


Digite o número n: 10
2
3
5
7
Há 4 primos de 0 até 9

Como estamos suficientemente confiantes de que esse programa está correto, podemos
remover ou comentar a linha print(p). Podemos executar o programa passando valores
de n cada vez maiores. Se fizermos isso, o tempo de resposta do programa aumentará
sucessivamente, até que consigamos perceber alguma demora.

user@notebook:~/ra123456/eficiencia$ python3 primos.py


Digite o número n: 100
Há 25 primos de 0 até 99
user@notebook:~/ra123456/eficiencia$ python3 primos.py
Digite o número n: 1000
Há 168 primos de 0 até 999
user@notebook:~/ra123456/eficiencia$ python3 primos.py
Digite o número n: 10000
Há 1229 primos de 0 até 9999
user@notebook:~/ra123456/eficiencia$ python3 primos.py
Digite o número n: 100000
^CTraceback (most recent call last):
File "primos.py", line 32, in <module>
main()
File "primos.py", line 28, in main
m = contar_primos(n)
File "primos.py", line 19, in contar_primos
if eh_primo(p):
File "primos.py", line 7, in eh_primo
if p % divisor == 0:
KeyboardInterrupt
Para n=100000 a demora do programa é suficientemente grande para que eu tenha
perdido a paciência e interrompido a execução. Assim, poderíamos dizer que para
valores de n até 10000 o programa executou rápidamente, enquanto para n=100000 a
execução foi lenta. Essa noção de rapidez não é muito melhor do que tentarmos decidir
se alguém tem febre encostando a mão em sua testa. Para medir temperatura, usamos
um termômetro; para medir tempo, usamos um cronômetro. É claro que o tempo que eu
demoro para acionar ou parar um cronômetro é significativo, então vamos utilizar uma
ferramenta chamada time, que é um comando comum para executar outros programas e
medir o tempo de execução.

user@notebook:~/ra123456/eficiencia$ time python3 primos.py


Digite o número n: 100
Há 25 primos de 0 até 99

real 0m5,203s
user 0m0,026s
sys 0m0,008s

Além da saída normal de nosso programa, o comando time mostra três medidas de


tempo. O tempo de sistema (indicado por sys) é o tempo que o sistema operacional
utilizou para responder a chamadas de nosso programa; isso inclui, por exemplo, o
tempo para carregar o programa na memória. O tempo real é o tempo total gasto, como
se de fato tivéssemos utilizando um cronômetro para medir o tempo de execução do
programa. Isso inclui o tempo em que gastei para ler a mensagem na tela, digitar o
número e apertar a tecla enter. Durante esse tempo, o programa estava parado esperando
alguma entrada. O tempo em que de fato instruções de nosso programa estavam sendo
executadas corresponde ao tempo indicado por user. Portanto, é essa a medida que
iremos utilizar para estimar quanto tempo gasta nosso algoritmo.

Se executarmos outra vez, podemos ter uma surpresa.

user@notebook:~/ra123456/eficiencia$ time python3 primos.py


Digite o número n: 100
Há 25 primos de 0 até 99

real 0m0,833s
user 0m0,052s
sys 0m0,005s

O tempo devolvido por time dobrou, mesmo que a sequência de instruções executadas


seja exatamente a mesma. Será que o processador ficou mais preguiçoso? Há muitos
fatores que influenciam o tempo de execução medido. Por exemplo, além de nosso
programa, há diversos outros processos executando ao mesmo tempo e, como o número
de processadores é pequeno, o sistema operacional alterna sucessivamente a lista de
processos sendo executados. Isso pode afetar o tempo que cada instrução gasta, por
causa do tempo que gastamos para carregar um dado a partir da memória, entre outras
razões. Pode ser que a bateria esteja acabando e a velocidade do processador foi
reduzida. Muita coisa pode interferir nos valores medidos.

O fato é que time não fornece um tempo preciso. Mas como ele é a única ferramenta
que conhecemos até agora, vamos utilizar algumas estratégias experimentais para
melhorar nossas medidas. Primeiro vamos substituir o comando input() por uma
atribuição n = ..., assim a variação do tempo gasto digitando não irá atrapalhar a
medição. Depois, vamos manter constante todos os outros fatores que pudermos
controlar (como número de processos executando, etc.) e calcular a mediana dos tempos
de três execuções para cada valor de n. Poderíamos também usar a média dos tempos,
mas a primeira execução de um programa costuma ser muito mais lenta, pois o arquivo
precisa ser lido do disco e carregado na memória RAM. Obtemos uma tabela.

n tempo 1 tempo 2 tempo 3 median


2000 0,111s 0,104s 0,107s 0,107s
3000 0,234s 0,215s 0,210s 0,215s
4000 0,383s 0,386s 0,365s 0,383s
5000 0,567s 0,554s 0,557s 0,557s
6000 0,825s 0,833s 0,827s 0,827s
7000 1,111s 1,104s 1,078s 1,104s
8000 1,422s 1,409s 1,410s 1,410s
9000 1,787s 1,800s 1,815s 1,815s
10000 2,240s 2,256s 2,215s 2,240s
15000 5,070s 5,175s 5,223s 5,175s

É muito mais fácil entender esses dados plotando um gráfico.

Essa figura se parece muito com uma parábola. É tentador fazer uma regressão sobre
esse gráfico, mas adivinhar o tipo de função e ajustar os parâmetros não é uma boa
prática. Precismos antes investigar quais elementos afetam o tempo de execução. Só
depois de termos evidências podemos dizer que essa função cresce dessa ou daquela
maneira.

Como o número de instruções é finito, deve haver algumas que executam muitas vezes.
Queremos saber qual instrução executa mais vezes. Já fizemos isso antes e descobrimos
que as instruções executadas dentro dos laços são sempre suspeitas. Nesse algoritmo, é
fácil descobrir que o teste if p % divisor == 0 é a linha que mais vezes é executada.
Em aplicações complexas, pode ser que precisemos utilizar algumas ferramentas
chamadas profilers, mas não precisamos delas nessa disciplina.

Vamos tentar contar quantas vezes executamos essa linha. Primeiro, observamos que a
função eh_primo é chamada para p variando de 0 até n−1. Para cada uma dessas
chamadas, contamos o número de vezes, começando do 0.

p 0 1 2 3 4 5 ... n-1
# iterações/chamada 0 0 0 1 2 3 ... n-3

Olhando a linha de baixo, identificamos uma progressão aritmética (PA) que começa
em 1 e termina em n−3. Assim, para descobrir o número de vezes que a linha é
executada no total, basta fazer a soma. Podemos consultar a fórmula da soma em uma
tabela — ou podemos nós mesmos deduzi-la.

çõ# iterações=(n−3)(n−2)2=n2−5n+62≈n22.

O valor exato da soma não interessa; só precisávamos de alguma evidência para que
pudéssemos afirmar que o gráfico acima é de fato uma parábola. Se todo o tempo do
programa fosse gasto somente nessa linha, então isso era tudo que precisaríamos.
Acontece que nosso programa realiza diversas outras atividades. Por exemplo, o
interpretador gasta um tempo considerável analisando o código fonte e transformando-o
em alguma representação executável pelo processador.

Para que nossa estimativa faça sentido, precisamos garantir que a maior parte do tempo
de execução é realmente gasta executando o laço dessa linha em particular. Como os
valores testados de n são razoavelmente grandes, temos confiança de que isso é verdade
e essa simplificação não é tão ruim assim. Então, podemos dividir o tempo total de
execução para um certo n e dividir por n2/2. Isso deve dar uma estimativa (bastante
grosseira) de quanto tempo uma iteração da linha if p % divisor == 0 leva para
executar. Testamos para o tempo de n=15000:

çãtempo/iteração=tempon22=5,175s1500022=0,000000046s=46ns

Ou seja, o tempo gasto por iteração é cerca de 46 nanosegundos. Agora, se formos


executar nosso programa para n=30000, temos pelo menos alguma estimativa de quanto
tempo o programa irá levar, mesmo que tenhamos feito diversas simplificações.

çõçãtempo=(# iterações)⋅(tempo/iteração)=3000022⋅46ns=20,7s

Talvez seja mais fácil pensar que, se dobrarmos o valor de n, então quadruplicaremos o
tempo de execução.

user@notebook:~/ra123456/eficiencia$ time python3 primos.py


Há 3245 primos de 0 até 29999

real 0m20,706s
user 0m20,700s
sys 0m0,004s
Voilà. O fato de termos acertado o tempo exatamente é mera coincidência, juro!

Comparando algoritmos
O algoritmo anterior é bom? Se só conhecermos um algoritmo, então não podemos dizer
muito, além de estimar quanto tempo iremos esperar sofrendo. Quando tempo eu iria
esperar se não tivesse interrompido a execução quando testei o programa
para n=100000?

Só podemos dizer que algoritmo é bom ou ruim, se estivermos comparando com algum
outro algoritmo. Pode ser que listar números primos seja um problema tão difícil que
podemos sentar resignados de que o algoritmo anterior era o melhor a ser feito. Mas
você já deve ter percebido que esse algoritmo é, na verdade, bem ruim. O motivo de sua
certeza é que há várias formas de melhorá-lo a fim de torná-lo (muito) mais rápido.

Por exemplo, é claro que um número par maior do que dois não é primo, mas ainda
assim a função eh_primo acima insiste em testar números pares. Mais do que isso, já
vimos que, se um número não tem divisores maiores que um e menores ou iguais à raiz,
então ele só pode ser primo! Nós chamamos esses tipos de alterações de otimizações,
porque, em certo sentido, são alterações que deixam o algoritmo mais próximo de
“ótimo”. Há muitas outras otimizações possíveis, mas por ora vamos nos concentrar
nessas duas.

Sempre que possível, não faça várias otimizações de uma só vez. Ou melhor, nunca
tente fazer duas alterações quaisquer no seu programa de uma só vez. É muito mais fácil
pensarmos numa coisa só por vez. Se a alteração não der certo, então sabemos
exatamente o que aconteceu. Mais importante, fazendo alterações incrementais,
podemos medir e verificar se cada uma delas teve o efeito esperado. Primeiro, vamos
parar de testar números pares maiores do que dois, adicionado algumas linhas no início
da função eh_primo

if p > 2 and p % 2 == 0:
return False

Testamos, novamente para n=30000.

user@notebook:~/ra123456/eficiencia$ time python3 primos.py


Há 3245 primos de 0 até 29999

real 0m10,402s
user 0m10,395s
sys 0m0,004s

O tempo praticamente diminui pela metade. Isso era esperado, afinal, deixamos de
verificar se metade dos números era primo ou não. Mas esse algoritmo ainda parece
insatisfatório, então vamos implementar a segunda otimização sugerida, que parece um
pouco mais promissora.

import math

def eh_primo(p):
if p == 0 or p == 1:
return False

if p > 2 and p % 2 == 0:
return False

raiz = int(math.sqrt(p))

tem_divisor = False
for divisor in range(2, raiz + 1):
if p % divisor == 0:
tem_divisor = True

if tem_divisor:
return False
else:
return True

Na função atualizada, precisamos gastar algum tempo calculando a raiz de p, mas a
esperança é que esse tempo seja bem menor do que o tempo que economizaremos nas
iterações. O motivo de somarmos um à raiz nos limites de range(2, raiz + 1) é que
precisamos testar os divisores até a raiz, inclusive.

user@notebook:~/ra123456/eficiencia$ time python3 primos.py


Há 3245 primos de 0 até 29999

real 0m0,104s
user 0m0,101s
sys 0m0,004s

Isso foi rápido! Mas não vamos comemorar ainda. Se os números em que estamos
interessados não são muito maiores do 30000, então não há muito mais o que melhorar,
vida que segue. Mas se os números forem cem mil, ou, quem sabe, um milhão? Vamos
tentar com n=1000000.

user@notebook:~/ra123456/eficiencia$ time python3 primos.py


Há 78498 primos de 0 até 999999

real 0m14,418s
user 0m14,413s
sys 0m0,005s

Comparando algoritmos, não implementações


Sem ideias para melhorar, podemos apelar e reescrever o mesmo algoritmo em uma
linguagem de programação “mais rápida”. Mas não podemos misturar bananas com
laranjas, ou podemos?

Você deve se lembrar de que Python é uma linguagem interpretada de alto nível. Isso
traz algumas vantagens, como a facilidade de uso, tempos de desenvolvimento e teste
mais curtos, menos problemas de sintaxe e uma série de outras vantagens.
Enquanto as abstrações de uma linguagem de alto nível são úteis, algumas vezes elas
podem sobrecarregar a execução do programa. O motivo é que elas escondem diversas
instruções que devem ser executadas, como verificar se índices de uma lista estão dentro
dos limites, gerenciar e liberar a memória de objetos etc. Em Python, tudo isso é feito
automaticamente. Quando escrevemos em uma linguagem de mais baixo nível, esses
detalhes devem ser explicitados. Embora preocupar-se com eles traga mais
responsabilidades para o programador — e mais possibilidades de erro, essas diferenças
permitem controlar melhor a execução do programa. Em certos casos, evitamos algumas
operações desnecessárias e diminuímos o tempo de execução.

Isso quase nunca é verdade para aplicações e algoritmos complexos, mas para
algoritmos simples como esse pode fazer diferença. Para tirar à prova, eu reescrevi o
algoritmo anterior, dessa vez na linguagem de programação C.

#include <math.h>
#include <stdio.h>

int eh_primo(int p) {
if (p == 0 || p == 1)
return 0;

if (p > 2 && p % 2 == 0)
return 0;

int raiz = sqrt(p);

int tem_divisor = 0;
for (int divisor = 2; divisor < raiz + 1; divisor += 1) {
if (p % divisor == 0)
tem_divisor = 1;
}

if (tem_divisor)
return 0;
else
return 1;
}

int contar_primos(int n) {
int m = 0;
for (int p = 0; p < n; p++) {
if (eh_primo(p)) {
m += 1;
}
}
return m;
}

int main() {
int n = 1000000;
int m = contar_primos(n);
printf("Há %d primos de 0 até %d\n", m, n-1);
return 0;
}
Você não precisa entendê-lo, mas como esse algoritmo é muito simples, não é difícil
vislumbrar o que cada instrução faz. O importante aqui é que esse programa implementa
o mesmo algoritmo que o programa anterior. Vamos executá-lo.

user@notebook:~/ra123456/eficiencia$ gcc -o primos primos.c -lm


user@notebook:~/ra123456/eficiencia$ time ./primos
Há 78498 primos de 0 até 999999

real 0m0,799s
user 0m0,799s
sys 0m0,000s

Eita! Para tarefas fundamentais em nossas aplicações, que são realizadas milhares e
milhares de vezes, reescrever o algoritmo em uma linguagem de mais baixo nível e
evitar os custos escondidos nas abstrações de alto nível pode valer a pena. Mas esse não
é o caso de nosso problema. Percebemos isso tão logo tentamos executar o programa
para valores de n muito maiores. Mudamos o programa fazendo n = 4000000,
compilamos e executamos novamente.

user@notebook:~/ra123456/eficiencia$ gcc -o primos primos.c -lm


user@notebook:~/ra123456/eficiencia$ time ./primos
Há 283146 primos de 0 até 3999999

real 0m6,215s
user 0m6,215s
sys 0m0,001s

É um tempo razoável, mas ainda na ordem de alguns segundos. Se me perguntarem


porque o programa demora tanto, já não poderei culpar a linguagem de programação.
Terei que admitir que meu algoritmo não é bom.

Podemos ainda fazer várias outras otimizações, mas dessa vez vamos mudar nossa
estratégia de contagem. Para isso, perceba que na primeira otimização, evitamos testar
múltiplos de 2 maiores de 2. Do mesmo modo, poderíamos evitar múltiplos de 3
maiores que 3 e assim por diante. Isso sugere o seguinte algoritmo, que é chamado de
crivo de Eratóstenes desde há muito tempo.

1. escreva os números de 0 até n−1.


2. risque os números 0 e 1, que não são primos
3. para cada número p da lista:
o se o número p não estiver riscado

 adicione p à lista de primos


 risque cada múltiplo 2p,3p,4p,5p,…
4. devolva os números primos encontrados

Para representar os números riscados e não riscados, podemos utilizar uma lista de
booleanos. Adicionamos a função seguinte.

def contar_crivo_estatostenes(n):
m = 0
riscados = [False] * n

riscados[0] = True
riscados[1] = True

for p in range(n):
if not riscados[p]:
m += 1
for q in range(2*p, n, p):
riscados[q] = True

return m

Ajustando a função main e executando o programa em Python,

user@notebook:~/ra123456/eficiencia$ time python3 primos.py


Há 283146 primos de 0 até 3999999

real 0m0,960s
user 0m0,925s
sys 0m0,036s

Muito mais rápido que o algoritmo anterior, mesmo quando comparando com a versão
em C. É claro que a linguagem de programação é importante para o tempo que um
algoritmo leva, mas a escolha da linguagem de programação não é desculpa para
escrever algoritmos ruins.

Jogos
Vamos parar de resolver problemas por um momento e tentar nos entreter com alguns
jogos. Sua amiga Diná não está para brincadeira e decide apostar valendo.

Diná pensa em um número e diz:

— Se você acertar o número que estou pensando, ganha R$ 10,00, mas, se errar,


paga R$ 1,00.

Você responde:

— Não! Nem sei quais números são permitidos. Se for um número de 0 até 9, então eu
jogo.

— Aí também não. Penso em um número de 0 até 99.

— Pode ser. Sou sortudo.

Você é muito insistente, então pensa em jogar até acertar.

Se você deveria ou não apostar em jogos de azar é uma questão. Outra questão é se você
ganhará ou perderá com esse jogo. Vamos criar um programa para simulá-lo. Um
otimista escreveria o seguinte.
import random

def jogar(numero):
pago = 0
recebido = 0
while True:
chute = int(input("Qual é seu chute? "))
if chute == numero:
recebido += 10
print(f"O número é o {chute}. Você acertou!")
break
else:
pago += 1
print(f"Não é o {chute}.")

return recebido - pago

def main():
numero = random.randint(0, 99)
ganho = jogar(numero)
print(f"Ganhei {ganho} reais!!!")

main()

Eu disse otimista por causa da mensagem de saída. O número pensado por Diná é
simulado por um número sorteado aleatoriamente. Vamos ver quanto você irá “ganhar”.

user@notebook:~/ra123456/eficiencia$ python3 adivinhacao.py


Qual é seu chute? 5
Não é o 5.
Qual é seu chute? 86
Não é o 86.
Qual é seu chute? 67
Não é o 67.
Qual é seu chute? 58
Não é o 58.
Qual é seu chute? 65
Não é o 65.
Qual é seu chute? 48
Não é o 48.
Qual é seu chute? 29
Não é o 29.
Qual é seu chute? 99
Não é o 99.
Qual é seu chute? 85
Não é o 85.
Qual é seu chute? 11
Não é o 11.
Qual é seu chute? 15
Não é o 15.
Qual é seu chute? 24
Não é o 24.
Qual é seu chute? 93
Não é o 93.
Qual é seu chute? 72
Não é o 72.
Qual é seu chute? 10
Não é o 10.
Qual é seu chute? 61
Não é o 61.
Qual é seu chute? 78
Não é o 78.
Qual é seu chute? 51
Não é o 51.
Qual é seu chute? 84
Não é o 84.
Qual é seu chute? 69
Não é o 69.
Qual é seu chute? 13
Não é o 13.
Qual é seu chute? 32
Não é o 32.
Qual é seu chute? 83
Não é o 83.
Qual é seu chute? 96
Não é o 96.
Qual é seu chute? 17
Não é o 17.
Qual é seu chute? 1
Não é o 1.
Qual é seu chute? 64
Não é o 64.
Qual é seu chute? 8
Não é o 8.
Qual é seu chute? 35
Não é o 35.
Qual é seu chute? 95
Não é o 95.
Qual é seu chute? 2
Não é o 2.
Qual é seu chute? 75
Não é o 75.
Qual é seu chute? 49
Não é o 49.
Qual é seu chute? 53
Não é o 53.
Qual é seu chute? 70
Não é o 70.
Qual é seu chute? 4
Não é o 4.
Qual é seu chute? 68
Não é o 68.
Qual é seu chute? 14
Não é o 14.
Qual é seu chute? 50
Não é o 50.
Qual é seu chute? 18
Não é o 18.
Qual é seu chute? 34
Não é o 34.
Qual é seu chute? 9
Não é o 9.
Qual é seu chute? 71
Não é o 71.
Qual é seu chute? 28
Não é o 28.
Qual é seu chute? 38
Não é o 38.
Qual é seu chute? 19
Não é o 19.
Qual é seu chute? 37
Não é o 37.
Qual é seu chute? 33
Não é o 33.
Qual é seu chute? 90
Não é o 90.
Qual é seu chute? 42
Não é o 42.
Qual é seu chute? 73
Não é o 73.
Qual é seu chute? 88
Não é o 88.
Qual é seu chute? 22
Não é o 22.
Qual é seu chute? 59
Não é o 59.
Qual é seu chute? 54
Não é o 54.
Qual é seu chute? 97
Não é o 97.
Qual é seu chute? 92
Não é o 92.
Qual é seu chute? 77
Não é o 77.
Qual é seu chute? 0
Não é o 0.
Qual é seu chute? 55
Não é o 55.
Qual é seu chute? 26
Não é o 26.
Qual é seu chute? 27
Não é o 27.
Qual é seu chute? 39
Não é o 39.
Qual é seu chute? 87
Não é o 87.
Qual é seu chute? 82
Não é o 82.
Qual é seu chute? 91
Não é o 91.
Qual é seu chute? 40
Não é o 40.
Qual é seu chute? 81
Não é o 81.
Qual é seu chute? 43
Não é o 43.
Qual é seu chute? 74
Não é o 74.
Qual é seu chute? 56
Não é o 56.
Qual é seu chute? 31
Não é o 31.
Qual é seu chute? 45
O número é o 45. Você acertou!
Ganhei -62 reais!!!

Um belo de um prejuízo. Se o número pensado por Diná é mesmo aleatório, poderíamos


muito bem testar cada um dos números de 0 a 99 em ordem que ainda teríamos a mesma
chance de acerto.

user@notebook:~/ra123456/eficiencia$ python3 adivinhacao.py


Qual é seu chute? 0
Não é o 0.
Qual é seu chute? 1
Não é o 1.
Qual é seu chute? 2
Não é o 2.
Qual é seu chute? 3
Não é o 3.
Qual é seu chute? 4
Não é o 4.
Qual é seu chute? 5
Não é o 5.
Qual é seu chute? 6
Não é o 6.
Qual é seu chute? 7
Não é o 7.
Qual é seu chute? 8
Não é o 8.
Qual é seu chute? 9
Não é o 9.
Qual é seu chute? 10
Não é o 10.
Qual é seu chute? 11
Não é o 11.
Qual é seu chute? 12
Não é o 12.
Qual é seu chute? 13
Não é o 13.
Qual é seu chute? 14
Não é o 14.
Qual é seu chute? 15
Não é o 15.
Qual é seu chute? 16
Não é o 16.
Qual é seu chute? 17
Não é o 17.
Qual é seu chute? 18
Não é o 18.
Qual é seu chute? 19
Não é o 19.
Qual é seu chute? 20
Não é o 20.
Qual é seu chute? 21
Não é o 21.
Qual é seu chute? 22
Não é o 22.
Qual é seu chute? 23
Não é o 23.
Qual é seu chute? 24
Não é o 24.
Qual é seu chute? 25
Não é o 25.
Qual é seu chute? 26
Não é o 26.
Qual é seu chute? 27
Não é o 27.
Qual é seu chute? 28
Não é o 28.
Qual é seu chute? 29
Não é o 29.
Qual é seu chute? 30
Não é o 30.
Qual é seu chute? 31
Não é o 31.
Qual é seu chute? 32
Não é o 32.
Qual é seu chute? 33
Não é o 33.
Qual é seu chute? 34
Não é o 34.
Qual é seu chute? 35
Não é o 35.
Qual é seu chute? 36
Não é o 36.
Qual é seu chute? 37
Não é o 37.
Qual é seu chute? 38
Não é o 38.
Qual é seu chute? 39
Não é o 39.
Qual é seu chute? 40
Não é o 40.
Qual é seu chute? 41
Não é o 41.
Qual é seu chute? 42
Não é o 42.
Qual é seu chute? 43
Não é o 43.
Qual é seu chute? 44
Não é o 44.
Qual é seu chute? 45
Não é o 45.
Qual é seu chute? 46
Não é o 46.
Qual é seu chute? 47
Não é o 47.
Qual é seu chute? 48
Não é o 48.
Qual é seu chute? 49
Não é o 49.
Qual é seu chute? 50
Não é o 50.
Qual é seu chute? 51
O número é o 51. Você acertou!
Ganhei -41 reais!!!

Agora pelo menos não precisamos anotar os palpites. Só mais uma vez. Estou sentindo
que a sorte vai chegar.

user@notebook:~/ra123456/eficiencia$ python3 adivinhacao.py


Qual é seu chute? 0
Não é o 0.
Qual é seu chute? 1
Não é o 1.
Qual é seu chute? 2
Não é o 2.
Qual é seu chute? 3
Não é o 3.
Qual é seu chute? 4
Não é o 4.
Qual é seu chute? 5
Não é o 5.
Qual é seu chute? 6
Não é o 6.
Qual é seu chute? 7
Não é o 7.
Qual é seu chute? 8
Não é o 8.
Qual é seu chute? 9
Não é o 9.
Qual é seu chute? 10
Não é o 10.
Qual é seu chute? 11
Não é o 11.
Qual é seu chute? 12
Não é o 12.
Qual é seu chute? 13
Não é o 13.
Qual é seu chute? 14
Não é o 14.
Qual é seu chute? 15
Não é o 15.
Qual é seu chute? 16
Não é o 16.
Qual é seu chute? 17
Não é o 17.
Qual é seu chute? 18
Não é o 18.
Qual é seu chute? 19
Não é o 19.
Qual é seu chute? 20
Não é o 20.
Qual é seu chute? 21
Não é o 21.
Qual é seu chute? 22
O número é o 22. Você acertou!
Ganhei -12 reais!!!
— Cansei! Vamos jogar outro. Te dou uma sacola com números. Você escolhe um. Eu
tenho que acertar.

Diná, indiferente:

— Se você prefere...

Para justificar a indiferença de sua amiga, vamos mais uma vez simular o jogo.

import random

def criar_sacola(n):
sacola = []
for _ in range(n):
sacola.append(random.randint(1, 5000))
return sacola

def jogar_sacola(sacola, numero):


pago = 0
recebido = 0
for chute in sacola:
if chute == numero:
recebido += 10
print(f"O número é o {chute}. Você acertou!")
break
else:
pago += 1
print(f"Não é o {chute}.")

return recebido - pago

def main():
sacola = criar_sacola(100)
numero = random.choice(sacola)
ganho = jogar_sacola(sacola, numero)
print(f"Ganhei {ganho} reais!!!")

main()

Criamos uma lista com 100 números aleatórios entre 1 e 5000 e depois selecionamos
um número aleatório dessa lista com random.choice(sacola). Ele representa o número
pensado por Diná. Você deveria ter percebido que a chance de ganhar alguma coisa é a
mesma que antes, mas a vontade de jogar é mania incontrolável.

user@notebook:~/ra123456/eficiencia$ python3 adivinhacao.py


Não é o 1353.
Não é o 4843.
Não é o 2722.
Não é o 1692.
Não é o 2487.
Não é o 569.
Não é o 1969.
Não é o 2223.
Não é o 3901.
Não é o 3010.
Não é o 3808.
Não é o 2159.
Não é o 3159.
Não é o 4547.
Não é o 1138.
Não é o 1366.
Não é o 700.
Não é o 1332.
Não é o 2385.
Não é o 4670.
Não é o 3527.
Não é o 3780.
Não é o 1607.
Não é o 4930.
Não é o 1334.
Não é o 1796.
Não é o 1733.
Não é o 1001.
Não é o 2469.
Não é o 1238.
Não é o 1674.
Não é o 510.
Não é o 1079.
Não é o 127.
Não é o 2963.
Não é o 1418.
Não é o 4096.
Não é o 751.
Não é o 4093.
Não é o 3611.
Não é o 77.
Não é o 4378.
O número é o 2557. Você acertou!
Ganhei -32 reais!!!
— Ah... também não gostei. Vou mudar o jogo. Agora temos as seguintes regras:

1. a lista de números que te dou estará ordenada;


2. para cada chute, você me dirá se ele é  menor ou maior que o número
pensado.

— Ok, mas eu mesma irei criar a lista e você deverá adivinhar a posição do número!

— Combinado.

Vacinado, você já sabe que testar todos os números sequencialmente não irá melhorar
sua sorte. O fato de não conhecer a lista e ter que adivinhar a posição do número não lhe
assusta. Ao menos, agora saberá quando der um bom chute.

import random

def criar_sacola(n):
sacola = []
for _ in range(n):
sacola.append(random.randint(1, 5000))
sacola.sort()
return sacola

def jogar_sacola(sacola, numero):


pago = 0
recebido = 0
while True:
chute = int(input("Em que posição está o número? "))
print(f"Na posição {chute} o valor é {sacola[chute]}, ", end="")
if sacola[chute] == numero:
recebido += 10
print(f"e pensei nesse número mesmo. Você o encontrou!")
break
elif sacola[chute] < numero:
pago += 1
print(f"mas pensei em um número maior.")
else:
pago += 1
print(f"mas pensei em um número menor.")
print()

return recebido - pago

def main():
sacola = criar_sacola(100)
numero = random.choice(sacola)
ganho = jogar_sacola(sacola, numero)
print(f"Ganhei {ganho} reais!!!")

main()

Ordenamos a sacola de números antes de começar a jogar. Nesse programa, voltamos a


pedir ao usuário que digite um chute; dessa vez, estamos interessados na posição do
número pensado. Vamos tentar uma vez, para aprender as regras.

user@notebook:~/ra123456/eficiencia$ python3 adivinhacao.py


Em que posição está o número? 50
Na posição 50 o valor é 2383, mas pensei em um número maior.

Em que posição está o número? 90


Na posição 90 o valor é 4605, mas pensei em um número menor.

Em que posição está o número? 40


Na posição 40 o valor é 2043, mas pensei em um número maior.

Em que posição está o número? 80


Na posição 80 o valor é 4049, mas pensei em um número menor.

Em que posição está o número? 55


Na posição 55 o valor é 2825, mas pensei em um número maior.

Em que posição está o número? 70


Na posição 70 o valor é 3638, mas pensei em um número menor.

Em que posição está o número? 60


Na posição 60 o valor é 3164, mas pensei em um número maior.

Em que posição está o número? 65


Na posição 65 o valor é 3480, mas pensei em um número maior.

Em que posição está o número? 69


Na posição 69 o valor é 3537, e pensei nesse número mesmo. Você o
encontrou!
Ganhei 2 reais!!!

O nervosismo pode deixar as pessoas desatentas. Vamos analisar os chutes com


cuidado. De nada adiantou tentar a posição 40 no terceiro chute, pois já sabíamos que o
número pensado era maior do que o valor da posição 50 e, mais do que isso, que a lista
de números estava ordenada. De fato, toda vez que Diná diz maior, descobrimos
um limitante inferior para a posição do número pensado e toda vez que Diná diz
menor, descobrimos um limitante superior. Isso lhe dá uma ideia para melhorar sua
estratégia.

def jogar_sacola(sacola, numero):


pago = 0
recebido = 0

limitante_inferior = 0
limitante_superior = len(sacola) - 1

while True:
chute = (limitante_inferior + limitante_superior) // 2
print(f"Na posição {chute} o valor é {sacola[chute]}, ", end="")
if sacola[chute] == numero:
recebido += 10
print(f"e pensei nesse número mesmo. Você o encontrou!")
break
elif sacola[chute] < numero:
pago += 1
print(f"mas pensei em um número maior.")
limitante_inferior = chute + 1
else:
pago += 1
print(f"mas pensei em um número menor.")
limitante_superior = chute - 1
print()

return recebido - pago

Repare como escolhemos a posição de chute como uma posição intermediária entre os
limitantes conhecidos. Observe também como atualizamos os limitantes sempre que
fazemos algum chute. Parece que sua sorte finalmente está mudando. Vamos jogar.

user@notebook:~/ra123456/eficiencia$ python3 adivinhacao.py


Na posição 49 o valor é 2591, mas pensei em um número maior.

Na posição 74 o valor é 3733, mas pensei em um número menor.

Na posição 61 o valor é 3109, mas pensei em um número maior.


Na posição 67 o valor é 3324, mas pensei em um número maior.

Na posição 70 o valor é 3450, mas pensei em um número maior.

Na posição 72 o valor é 3548, e pensei nesse número mesmo. Você o


encontrou!
Ganhei 5 reais!!!
Diná se estremece.

— Também quero mudar.

— Como?

— Simples, a lista agora terá 1000 números.

Já sem dinheiro na carteira, melhor você pensar duas vezes antes de aceitar essa
proposta. Você aceitará?

Buscas
Os jogos acima ilustram duas tarefas muito comuns em computação. A primeira tarefa
corresponde ao problema de, dada uma lista de valores, encontrar um determinado
elemento. A segunda tarefa corresponde ao problema de, dada uma lista de
valores ordenada, encontrar um determinado elemento.

Busca sequencial
Em diversas situações precisamos fazer buscas sequênciais. Já fizemos isso várias
vezes, como quando tivemos que procurar uma palavra em um conjunto de palavras, ou
quando procuramos a posição de inserção no vetor para o algoritmo insertion-sort.
Recorrentemente, uma função que implementa uma busca sequencial será parecida com
a seguinte.

def busca_sequencial(lista, valor_procurado):


for i, valor in enumerate(lista):
if valor == valor_procurado:
return i
return None

Nessa função, devolvemos None quando não existe um elemento na lista de


valor valor_procurado. Dependendo da situação, pode ser mais conveniente levantar
uma exceção quando isso acontece.

def busca_sequencial(lista, valor_procurado):


for i, valor in enumerate(lista):
if valor == valor_procurado:
return i
raise ValueError(f"Valor {valor_procurado} não existe na lista.")

A diferença é que, na segunda versão, faz parte das premissas da função que o valor
procurado esteja de fato na lista.
Na verdade, não necessariamente realizamos buscas sequenciais apenas em dados
armazenados em uma lista. Por exemplo, para encontrar o máximo de vários números
digitados no teclado, não conhecemos todos os números a priori e nem temos controle
sobre quais valores serão digitados.

def descobrir_maximo(n):
maximo = int(input())
for _ in range(1, n):
numero = int(input())
if numero > maximo:
maximo = numero
return maximo

Enquanto muitas vezes queremos buscar o valor de um elemento, em outras vezes


estamos interessados no índice em que o elemento está. Também, pode ser que não
queiramos encontrar um valor exato, mas o primeiro elemento da lista que é maior ou
igual a esse número. São diversas as tarefas que resolvemos percorrendo linearmente
uma lista de elementos.

Quanto tempo gastamos para procurar um elemento sequencialmente em uma lista


depende de quantos elementos há na lista e de onde esse elemento está na lista. Quando
temos azar, pode ser que o elemento não esteja presente ou que ele esteja apenas na
última posição. Nesses casos, que chamamos de piores casos, temos que acessar todos
os elementos.

Busca binária
Algumas vezes temos alguma informação adicional sobre a lista da entrada e sabemos
que os elementos estão ordenados. Nesses casos, não precisamos necessariamente
acessar todos os elementos da lista para descobrir a posição de um algum valor.

def busca_binaria(lista, valor_procurado):


limitante_inferior = 0
limitante_superior = len(lista) - 1
while limitante_inferior <= limitante_superior:
m = (limitante_inferior + limitante_superior) // 2
if lista[m] == valor_procurado:
return m
elif lista[m] < valor_procurado:
limitante_inferior = m + 1
else:
limitante_superior = m - 1
return None

Não tem nada em particular nesse código que nos restrinja a números. A única restrição
é de que os elementos sejam comparáveis. Assim podemos comparar strings, tuplas, ou
mesmo utilizar uma função particular para comparação como discutido quando falamos
de ordenção.

Quanto tempo gasta a função busca_binaria quando passamos uma lista de tamanho n?


Se você prestar atenção, perceberá que toda vez que atualizamos um limitante,
descartamos pelo menos metade das possibilidades. Quantas vezes podemos dividir uma
lista em 2 para que reste apenas um elemento? Essa é justamente a definição do
logaritmo na base 2. Portanto, o número de vezes que testamos alguma posição até
encontrar o elemento procurado é no máximo log2⁡n. Você pode agora decidir com mais
tranquilidade se aceita ou não a última proposta de Diná.

Na verdade, a busca binária é apenas um exemplo de uma estratégia mais geral bastante
útil. Para utilizar busca binária em uma sequência, basta que:

1. conheçamos limitantes inferiores e superiores para a posição de alguma estrutura


procurada;

2. dada uma posição, podemos decidir se existe uma estrutura antes ou depois
dessa posição.

Um exemplo dessa ideia pode ser encontrado no chamado método da bissecção, cujo


objetivo é encontrar uma aproximação de uma raiz de uma função contínua f(x).
Suponha que sabemos que f(a)<0 e que f(b)>0. Como f é contínua, deve haver algum
valor x tal que f(x)=0. A função metodo_bisseccao a seguir encontra um valor y tal
que f(y) está muito muito próximo de 0.

ERRO = 0.00000001

def f(x):
return x**2 - 2

def metodo_bisseccao(a, b, funcao):


while b - a > ERRO:
m = (a + b) / 2
if funcao(m) < 0:
a = m
else:
b = m
return m

def main():
a = 0.0
b = 4.0
y = metodo_bisseccao(a, b, f)
print(f"f({y}) = {f(y)}")

main()

Para a função f definida nesse exemplo, uma solução é 2. Esse programa nos dá uma
aproximação dessa raiz.

user@notebook:~/ra123456/eficiencia$ python3 bisseccao.py


f(1.4142135605216026) = -5.236811428943611e-09

Um outro exemplo de aplicação de busca binária é dado no exercício sobre os sentidos


das ruas na lista de exercícios de fixação.

← ANTERIOR
VOLTAR
PRÓXIMA →

Copyright © 2020

Construir algoritmos do jeito que fizemos até agora é algo intuitivo: repetimos um
conjunto de instruções até que obtenhamos uma resposta desejada. Se você parar para
pensar, a própria definição que fizemos no início do curso sugere que devemos escrever
algoritmos assim: uma sequência de passos bem definidos para se resolver um
determinado problema. Mais tarde, quando você estudar linguagens de programação,
descobrirá que estamos falando de linguagens imperativas.

Para resolvermos problemas mais complicados de maneira estruturada, definimos laços


e aprendemos a escrever algoritmos iterativos. Assim, temos que estabelecer um
subconjunto de instruções, chamado de iteração, que altera os dados de entrada
sucessivamente. Isso nos obriga a pensar em um resultado intermediário dessas
operações, ao invés de pensar no resultado do algoritmo. Por exemplo, para multiplicar
uma lista de números, precisamos definir uma variável acumuladora que, em cada
iteração, guarda o produto dos primeiros números da lista.

Essa não é a única maneira de se resolver esse problema. Iremos aprender que recursão
é uma estratégia para se pensar e escrever algoritmos que utiliza a estrutura recursiva do
problema. Hum, antes de podermos explicar o que é recursão, precisamos entender o
que é recursão.

Introdução
Observe a figura a seguir. Provavelmente você já construiu uma estrutura parecida. Ela
é a foto de um castelo de cartas.
Repare que os alicerces do castelo são, eles mesmo, castelos de carta. Assim, antes de
construir um castelo com 4 andares, tivemos antes que construir um castelo com 3
andares e assim por diante. A estrutura desse castelo na verdade inclui diversos outros
castelos menores. Podemos desenhar alguns.

Podemos então fazer a seguinte pergunta.

Quantos castelos há em um castelo de cartas com quatro andares?

Para formalizar o nosso problema, vamos definir uma grade de triângulos como a figura
definida à esquerda, que tem altura quatro. O nosso objetivo é contar o número de
triângulos que tem a base na posição inferior (ver exemplos coloridos). Há alguns outros
triângulos de ponta a cabeça, mas eles não são castelos de carta e não estamos
interessados neles. Denotemos por t(n) o número de triângulos de pé em um castelo de
altura n.
Para o castelo de quatro andares, há muitos triângulos escondidos. Mas quando estamos
começando a construir o castelo, é fácil contar diretamente.

Para um castelo de altura um, temos somente um triângulo e para um castelo de altura
dois, temos quatro triângulos, três pequenos e um grande. Assim, sabemos
que t(1)=1 e t(2)=4. Mas, à medida em que os castelos crescem, essa contagem torna-se
mais difícil.

Para contar os triângulos de um castelo de altura n=4, precisamos de um pouco mais de


cuidado. Podemos pensar na estrutura que sustenta as duas cartas superiores e lembrar
que um castelo é fundado sobre outros castelos menores. Se quisermos contar apenas os
triângulos com a ponta no parte superior, teremos 4 triângulos.

Além desses, ainda faltam os triângulos do lado esquerdo e do lado direito. Vamos
colori-los para poder enxergar melhor.
Assim, precisamos somar os triângulos pintados de laranja e os pintados de verde. Há
alguns triângulos que pintamos duas vezes, então também precisamos remover da conta
os triângulos pintados de rosa.
Mas como calcular t(3)? Caímos no mesmo problema anterior, mas agora para uma
instância menor! De maneira mais geral, podemos escrever

t(n)={1se n=14se n=2n+2⋅t(n−1)−t(n−2)se n≥3

Agora fica mais fácil calcular t(4)? Vamos supor que já conhecemos o valor
de t(m) para cada número m<4, ou seja, suponha que já sabemos quanto
vale t(1),t(2) e t(3). Você pode pensar que conhecemos um oráculo que nos dá o valor
correto de t(m) sempre que m seja estritamente menor que 4.
Assim,t(4)=4+2⋅t(3)−t(2)=4+2⋅10−4=20.

A maneira com que o oráculo descobre o valor de t(3) ou de t(2) é indiferente, contando
que ele nos dê uma resposta correta. Assim, digamos que o oráculo é uma
função oraculo(m) que devolve o número de triângulos de um castelo de cartas de
altura m<n. Se você confia que essa função oraculo está correta, então podemos
escrever uma função para calcular o número de triângulos de um castelo de cartas de
altura n. Para testar, vamos imprimir uma tabela com os 10 primeiros valores de t(n).

def triangulos(n):
if n == 1:
return 1
elif n == 2:
return 4
else:
return n + 2 * oraculo(n - 1) - oraculo(n - 2)

def main():
for i in range(1, 11):
print(f"t({i}) = {triangulos(i)}")

main()

Se tentarmos executar, não vai dar certo.

user@notebook:~/ra123456/recursao$ python3 triangulos.py


t(1) = 1
t(2) = 4
Traceback (most recent call last):
File "triangulos.py", line 13, in <module>
main()
File "triangulos.py", line 11, in main
print(f"t({i}) = {triangulos(i)}")
File "triangulos.py", line 7, in triangulos
return n + 2 * oraculo(n - 1) - oraculo(n - 2)
NameError: name 'oraculo' is not defined

É claro que para podemos executar esse programa, precisamos implementar a


função oraculo. Vejamos o que é preciso: queremos uma função para calcular o
número de triângulos de um castelo de cartas de altura m. Mas essa é justamente a
descrição da função triangulos que estamos construindo! Vamos
substituir oraculo por triangulos.

def triangulos(n):
if n == 1:
return 1
elif n == 2:
return 4
else:
return n + 2 * triangulos(n - 1) - triangulos(n - 2)

Pronto! Agora o interpretador Python não poderá reclamar que a função chamada não
existe.

user@notebook:~/ra123456/recursao$ python3 triangulos.py


t(1) = 1
t(2) = 4
t(3) = 10
t(4) = 20
t(5) = 35
t(6) = 56
t(7) = 84
t(8) = 120
t(9) = 165
t(10) = 220

E funciona! Na primeira vez que vemos isso, pode ser que pareça mágica, mas há um
nome mais apropriado. Observe que a função triangulos chama a própria
função triangulos. Chamamos isso de recursão.

Recursão

Recursão é a estratégia para se resolver um problema da seguinte maneira:


1. Começamos identificando casos básicos e computando suas soluções
diretamente.

2. Em seguida, tentamos resolver um caso geral fazendo o seguinte:

a) primeiro construímos uma ou mais instâncias menores do mesmo problema;

b) depois, obtemos soluções para essas instâncias fazendo chamadas recursivas;

c) finalmente, transformamos as soluções para as instâncias menores a fim de se


obter um resultado do problema original.

Vamos ver um exemplo. Você deve se lembrar de que o fatorial de um número n é o


produto 1⋅2⋅…⋅(n−1)⋅n. Se escrevermos esse produto com parênteses, podemos ver que
para calcular o fatorial de n, precisamos antes calcular o fatorial de n−1:

n!=(1⋅2⋅…⋅(n−1))⋅n=(n−1)!⋅n

Na verdade, quando definimos o fatorial de n de uma maneira mais formal, fazemos isso
recursivamente:

n!={1se n=0n⋅(n−1)!se n>0.

Com essa definição, é trivial escrever um programa recursivo para calcular o fatorial de
um número. Vamos fazer isso destacando cada parte da estratégia recursiva descrita
acima.

def fatorial(n):
# 1 caso básico
if n == 0:
resposta = 1

# 2. caso geral
else:
# a) instância menor
m = n - 1

# b) chamada recursiva
solucao = fatorial(m)

# c) transformando a solução
resposta = n * solucao

return resposta

Normalmente escolhemos como casos básicos as instâncias irredutíveis, isso é, cujo o


tamanho não podemos diminuir. Nesse exemplo, só há um caso básico, que corresponde
à entrada n=0. O caso geral corresponde a uma entrada arbitrária n tal que n>0. O fato
de que no caso geral n é diferente de zero é importante para que possamos construir uma
instância menor do problema, m=n−1. Resolvemos o subproblema recursivamente e
obtemos uma solução para a instância menor. Finalmente, transformamos a solução da
instância menor e obtemos a resposta do problema original.
Na função acima, criamos diversas variáveis para explicitar que estamos utilizando uma
estratégia recursiva. Mas muitas pessoas escreveriam menos.

def fatorial(n):
if n == 0:
return 0
else:
return fatorial(n - 1) * n

Com o tempo, você irá preferir essa segunda versão.

Pensando recursivamente
Vamos ver mais um exemplo para praticar.

Suponha que queremos cortar um pedaço de papel retangular, digamos, para fazer um
cartão ou um bilhete. É bem provável que não exista folha disponível na papelaria com
exatamente esse tamanho. Então precisamos descobrir qual o  menor formato de papel
em que cabe nosso retângulo.

O formato de papel mais utilizado no Brasil (e no mundo) é o formato A4. Na verdade,


esse é apenas um formato de uma série cuidadosamente pensada, A0, A1, A2... Uma das
vantagens dessa série é que podemos cortar uma folha A0 no meio e obter duas folhas
A1 e assim por diante. Todas as medida são em milímetros, então descartamos a fração
de milímetro quando dividirmos um número ímpar por dois.

O maior formato da série é o A0, que tem 841mm de largura e 1189mm de altura. Se


dividirmos o maior lado de uma folha sucessivamente, podemos construir uma tabela
com as dimensões dos primeiros formatos.

Formato Largura Altura


A0 841 1189
A1 594 841
A2 420 594
A3 297 420
A4 210 297
A5 148 210

Podemos fazer um desenho para visualizar os vários tamanhos.


Agora podemos tentar resolver nosso problema. Não é muito difícil resolver esse
problema iterativamente, mas nesse exemplo queremos insistir numa solução recursiva.
Utilizar recursão nem sempre é trivial como foi para a função fatorial, cuja a definição é
ela mesma recursiva. Então, pode não parecer intuitivo tentar resolver esse problema de
maneira recursiva. De qualquer forma, vamos tentar.

Suponha que recebemos uma folha de papel A0 e nos perguntam qual o menor subtipo
de A0 em que cabe o retângulo? Vamos supor que o retângulo recebido cabe na folha
A0, já que do contrário não há muito o que fazer. Será que a folha em mãos é de fato a
menor possível? Se o retângulo não couber em uma folha A1, então a reposta é sim, a
folha A0 é o menor subtipo. Mas se ele couber, então podemos voltar a nos perguntar:
qual o menor subtipo de A1 em que cabe o retângulo? Repare que fizemos a mesma
pergunta, mas agora para uma folha A1.

A primeira coisa para se escrever uma função recursiva — ou qualquer outra — é


entender exatamente que problema queremos resolver. Aqui, queremos responder qual o
menor subtipo de um papel An em que cabe o retângulo. Com isso, não é difícil
escrever a seguinte função recursiva.

LARGURA_A0 = 841
ALTURA_A0 = 1189

def menor_subtipo(larg_folha, alt_folha, tipo_folha, larg_retangulo,


alt_retangulo):
"""
Devolve o menor subtipo da folha em que cabe o retângulo.
"""

# cria instância menor


larg_menor = alt_folha // 2
alt_menor = larg_folha
tipo_menor = tipo_folha + 1

# se não cabe na folha menor


if larg_retangulo > larg_menor or alt_retangulo > alt_menor:
return tipo_folha
else:
return menor_subtipo(larg_menor, alt_menor, tipo_menor,
larg_retangulo, alt_retangulo)

def main():
larg_retangulo = int(input("digite a largura do retângulo: "))
alt_retangulo = int(input("digite a altura do retângulo: "))

tipo = menor_subtipo(LARGURA_A0, ALTURA_A0, 0, larg_retangulo,


alt_retangulo)

print(f"Utilize um papel A{tipo}")

main()

Escrever algoritmos recursivos não é um aprendizado que ganhamos de graça,


principalmente porque primeiro aprendemos algoritmos iterativos. Essa dificuldade
inicial vale a pena porque normalmente os algoritmos recursivos que construímos são
muito mais simples do que alguns algoritmos iterativos. Mais importante, talvez, é que é
muito mais fácil nos convencermos de que os algoritmos recursivos estão corretos.

Como não podemos deixar de praticar, vamos resolver mais um problema.

Suponha que queremos construir nossa própria série de papeis quadrados, a série Q1,
Q2, etc. Dessa vez, quanto maior o número do tipo, maior o papel. Construímos essa
série assim, os papeis Q1 e Q2 são iguais e têm 1mm de lado. Para definir Q3,
reusamos o lado de Q1 e Q2, formando um quadrado de lado 2mm. Para construir Q4,
usamos o lado de Q2 e Q3 e assim por diante, como na figura. Qual o lado do papel
Qn?
Você consegue identificar essa série? Implemente uma função recursiva para resolver
esse problema.

Pilha de chamadas
Depois que já nos acostumamos a escrever funções recursivas, podemos tentar
investigar a dinâmica de execução de uma função recursiva. Quando estudamos
funções, descobrimos que existe um mecanismo para executar e retornar de uma função.
O mesmo mecanismo funciona para funções recursivas, não há nada de especial para
elas.

Vamos simular uma chamada à primeira função fatorial definida antes. Copiamos e


adicionamos uma função main para teste.

def fatorial(n):
if n == 0:
resposta = 1
else:
m = n - 1
solucao = fatorial(m)
resposta = n * solucao
return resposta

def main():
resultado = fatorial(4)
print(resultado)

main()

A primeira instrução a ser executada nesse programa é uma chamada a função main.


Sempre que fazemos uma chamada, criamos um novo escopo para a chamada. Logo em
seguida, é feita uma chamada a fatorial(4) e a mesma coisa acontece: criamos um
novo escopo para a chamada e associamos os parâmetros. Assim, imediatamente antes
da chamada fatorial(4) começar a executar, a memória do computador deve se
parecer com a seguinte figura.

Como nesse caso n=4, é executado o ramo do else que define uma nova variável m=3 e
é feita uma nova chamada fatorial(3). Toda vez que chamamos uma função, criamos
um novo escopo e associamos os parâmetros, então não é diferente para essa. Esse
processo se repete até que n seja igual a 0, quando chegamos a um caso básico.

No momento em que a chamada fatorial(0) retorna, o seu escopo é destruído e o


valor devolvido é guardado na variável solucao da chamada fatorial(1). Essa
chamada calcula o resultado, a execução retorna à chamada fatorial(2) e assim ocorre
sucessivamente. Por exemplo, logo depois que a chamada fatorial(2) termina, a
memória do programa estaria assim:

Quando a última chamada termina, a execução continua na função main, que recebe o


resultado e o mostra na tela.

Entender o mecanismo que faz uma função recursiva funcionar é importante quando
queremos avaliar o impacto de usar recursão ou quando queremos descobrir um erro em
nosso programa. Mas quando estivermos criando um algoritmo recursivo para um
problema não devemos nos preocupar com todas essas chamadas. Em outras palavras,
quando estivermos pensando recursivamente, devemos nos concentrar somente no
escopo da chamada inicial.

Estruturas recursivas
Algumas vezes, tratamos de objetos que têm estruturas recursivas. Essas estruturas
podem representar as soluções de algum problema, ou podem ser algum objetos
concretos. Por exemplo, os ramos e sub-ramos de algumas plantas podem ter a mesma
estrutura que a planta inteira.

Entre as estruturas recursivas bem estudadas estão os fractais. Vamos tentar desenhar
alguns fractais usando recursão. Antes, vamos aprender a usar um módulo de Python
chamado turtle, que foi feito para ensinar programação para crianças. Para usar esse
módulo, você precisa ter instalado Python com o módulo de interfaces gráficas tk.
Imagine uma grande tela de pintura e, sobre ela, uma tartaruga carregando uma caneta.
Essa tartaruga é treinada e responde a alguns comandos simples, como andar por uma
certa distância e virar à esquerda ou à direita por um certo número de graus. Mas não
sabe fazer muito mais do que isso.

Podemos ensinar a tartaruga a desenhar um quadrado na tela. Vejamos.

from turtle import *

PASSO = 100

def quadrado():
forward(PASSO)
right(90)
forward(PASSO)
right(90)
forward(PASSO)
right(90)
forward(PASSO)
right(90)

def main():
pensize(3)
shapesize(2)
color("green")
speed(3)

quadrado()

done()

main()

Deve ser fácil descobrir o que cada uma das instruções faz. Para ter certeza, executamos
e vemos que uma janela é aberta. A tartaruga anda, vagarosamente, desenhando um
quadrado verde na tela.
 

 
 
É importante perceber que a tartaruga está em determinada posição virada em alguma
direção. Então, se pedirmos para a tartaruga desenhar dois quadrados em seguida, ela
obedecerá, mas o resultado não vai ser muito mais interessante.
 
Então vamos prestar atenção na posição inicial e na posição final da tartaruga. Essas
posições fazem parte da descrição do problema sendo resolvido pela função de desenho.

Em seguida, faremos algumas figuras mais interessantes desenhadas por Koch.

Repare que, para desenhar K2, substituímos cada risco de K1 por uma cópia
de K1. Podemos fazer uma função que recebe um parâmetro n=0,1,2 e faz o desenho
correspondente.

def koch(n):
if n == 0:
forward(PASSO)
elif n == 1:
forward(PASSO)
left(60)
forward(PASSO)
right(120)
forward(PASSO)
left(60)
forward(PASSO)
elif n == 2:
koch(1)
left(60)
koch(1)
right(120)
koch(1)
left(60)
koch(1)

Qual seria a figura correspondente à K3? Ou melhor, como será a figura Kn, para
um n arbitrário? A estrutura recursiva pode ser visualizada nas figuras, mas é mais
interessante que a descubramos olhando o código da função. Olhando com atenção,
perceberemos que para obter uma figura Kn, temos que fazer quatro figuras Kn−1.
Assim, podemos fazer uma implementação recursiva.

def koch(n):
if n == 0:
forward(PASSO)
else:
koch(n - 1)
left(60)
koch(n - 1)
right(120)
koch(n - 1)
left(60)
koch(n - 1)

Executamos koch(3) ajustando o tamanho do passo apropriadamente.

Experimente outros parâmetros. Como a figura cresce muito à medida em


que n aumenta, pode ser útil aumentar a velocidade da tartaruga. A
instrução speed(10) pede para que a tartaruga ande o mais rápido possível.

Vamos desenhar mais um fractal, chamado de triângulo de Sierpinski. A primeira


imagem da sequência do fractal é um triângulo equilátero. Para obter o n-ésimo
elemento da sequência, fazemos três cópias da figura anterior, uma em cada ponta de
um triângulo. Veja os exemplos.
Nesse exemplo, vamos ter que tomar cuidado redobrado com as posições de início e de
fim da figura. Por exemplo, suponha que para desenhar S3, primeiro fazemos duas
cópias de S2, como na figura abaixo.
Para desenhar a terceira cópia de S2, precisamos posicionar a tartaruga acima do
segundo S2. Como sempre que nossa tartaruga anda, ela deixa sua marca, vamos girar a
cabeça da tartaruga e desenhar mais duas cópias de S2 em sequência.
Nesse momento já desenhamos S3, então podemos escrever a seguinte função recursiva.

def sierpinski(n):
if n == 1:
forward(PASSO)
left(120)
forward(PASSO)
left(120)
forward(PASSO)
left(120)
forward(PASSO)
else:
sierpinski(n-1)
sierpinski(n-1)
left(120)
sierpinski(n-1)
sierpinski(n-1)

Mas essa função recursiva tem um erro crucial, você sabe identificar qual é? Vamos
testar executando sierpinski(3).

Nós não cumprimos o combinado na definição do problema! Quando fizemos uma


chamada recursiva para a instância de tamanho n−1, supusemos que tartaruga terminaria
o desenho na ponta inferior direita, mas nós não garantimos esse propriedade para a
instância de tamanho n. Podemos entender recursão como um contrato: se exigimos
alguma propriedade da solução obtida na chamada recursiva, então também nós
devemos garantir essa propriedade na solução que construirmos.

Nesse exemplo, você deve se convencer de que basta repetir as instruções para que a
tartaruga termine na ponta inferior direita.

def sierpinski(n):
if n == 1:
forward(PASSO)
left(120)
forward(PASSO)
left(120)
forward(PASSO)
left(120)
forward(PASSO)
else:
sierpinski(n-1)
sierpinski(n-1)
left(120)
sierpinski(n-1)
sierpinski(n-1)
left(120)
sierpinski(n-1)
sierpinski(n-1)
left(120)
sierpinski(n-1)
sierpinski(n-1)

Parece um exagero termos oito chamadas recursivas, enquanto a definição do triângulo


de Sierpinski fala apenas em três cópias. Mas funciona.
O que talvez seja frustrante é que o tempo que essa função leva é muito muito grande —
e não podemos culpar a tartaruga por isso.

Tempo de execução e sobreposição de problemas


A razão pela qual nossa função recursão demora tanto é que ela realiza uma série de
passos desnecessários. Quando para resolver um problema recursivamente resolvemos
várias vezes uma mesma instância menor do problema, dizemos que há sobreposição do
problema. No caso da função sierpinski, o desenho de uma chamada literalmente
sobrepõe-se ao outro.

Uma consequência é que o tempo de funções com várias chamadas recursivas pode ser
proibitivamente alto. Por exemplo, vamos executar nosso programa que imprime a
tabela de número de triângulos no castelo de carta, mas agora queremos uma tabela com
40 linhas. Depois de ajustar a função main, esperamos

user@notebook:~/ra123456/recursao$ python3 triangulos.py


t(1) = 1
t(2) = 4
t(3) = 10
...
t(29) = 4495
t(30) = 4960
t(31) = 5456
t(32) = 5984
t(33) = 6545
t(34) = 7140
t(35) = 7770
t(36) = 8436
t(37) = 9139
t(38) = 9880
t(39) = 10660
t(40) = 11480

No meu computador isso demorou pouco menos de um minuto. A partir de linha t(30)


= 4960, já é possível contar o tempo que a função triangulos demora para executar —
o que praticamente dobra a cada nova linha. Para entender porque essa função demora
tanto, vamos fazer um desenho que representa as chamadas da função quando a
chamada inicial é triangulos(6).

Se fizermos as contas, descobriremos que o número de chamadas cresce


exponencialmente com o valor de n. Mas, na grande maioria das vezes, executamos a
função passando os mesmos valores de entrada.

Uma maneira de evitar fazer chamadas desnecessárias é guardar os resultados em uma


tabela. Repare que se não fizermos uma chamada de função na segunda vez em que
fôssemos executar triangulos(3), então evitaríamos também as chamadas
a triangulos(2) e triangulos(1).

A estratégia de guardar o resultado das chamadas da função em uma tabela para evitar o
recálculo é chamada de memorização ou memoização. Como os valores de entradas da
função t são números de 1 a n, podemos representar essa tabela usando uma lista. Para
inicializar essa lista, precisamos de uma função auxiliar, que será a função chamada
pela main. A função recursiva agora, além da entrada do problema, receberá a tabela de
valores.

def triangulos_rec(n, t):


if t[n] is None:
if n == 1:
t[n] = 1
elif n == 2:
t[n] = 4
else:
t[n] = n + 2 * triangulos_rec(n - 1, t) - triangulos_rec(n -
2, t)
return t[n]

def triangulos(n):
# cria tabela com índices de 0 a n
t = [None for i in range(n + 1)]
return triangulos_rec(n, t)

def main():
for i in range(1, 41):
print(f"t({i}) = {triangulos(i)}")

main()

Na função triangulos criamos uma tabela t com n+1 elementos. Inicializamos todos


os valores dessa lista com None para representar que ainda não computamos a função
para um determinado índice. Executando a versão atualizada, toda a tabela é impressa
imediatamente.

Já que, para calcular t(n), precisamos preencher toda entrada da tabela, não é necessário
chamar a função triangulos para cada valor de i. Modifique o programa de forma a
fazer uso dessa ideia.

Comparando funções recursivas e iterativas


Já vimos implementações iterativas e recursivas da função fatorial e da função de
Fibonacci. Assim, você pode se perguntar se deve usar recursão ou iteração. Como
esperado, não existe resposta universal, então essa é uma pergunta que iremos nos fazer
para cada problema encontrado.

Algumas vezes, pensamos primeiro em um algoritmo iterativo para o problema,


particularmente quando ainda estamos começando a entender recursão. Mas, muitas
vezes, é mais fácil e mais simples escrever uma função recursiva, principalmente
quando o problema sendo resolvido é definido recursivamente, ou tem alguma estrutura
recursiva. Também, pode acontecer de só sabermos resolver um determinado problema
de maneira recursiva.

Muitas pessoas alegam que funções recursivas são mais elegantes. Por exemplo,
podemos comparar duas implementações da função de Fibonacci, uma iterativa e outra
recursiva.

def fibonacci(n):
a = 1
b = 1
for _ in range(2, n):
c = a + b
a = b
b = c
return a

def fibonacci(n):
if n == 1 or n == 2:
return 1
else:
return fibonacci(n - 1) + fibonacci(n - 2)

Eu não sei você, mas eu acho a segunda versão muito mais bonita. Mas um algoritmo
mais simples não é questão meramente cosmética. Quanto mais simples, mais fácil é
entender o algoritmo e mais fácil é convencer alguém de que ele não contém erros. Ah,
você testou a função iterativa acima?

O principal motivo para preferirmos uma versão iterativa quando temos uma versão
recursiva mais simples é o tempo de execução. A função de Fibonacci recursiva gasta
um tempo muito grande — e, de fato, sabemos que o tempo cresce exponencialmente
com n. Podemos utilizar memorização, o que resolve grande parte da lentidão. Ainda
assim, a versão iterativa será bem mais rápida por causa da sobrecarga das chamadas de
função.

Tudo é uma questão de escolhas e balanceamento (ou em inglês, tradeoff). Uma


estratégia é escrever uma versão recursiva sempre que for mais fácil resolver o
problema assim. Se por um acaso essa função for uma função crítica para o
desempenho, então tentamos reescrevê-la de maneira iterativa depois.

Funções co-recursivas
Vamos voltar ao problema do triangulo de Sierpinski. Nesse caso, tentar usar
memorização não irá evitar que o tempo que a tartaruga gasta desenhando cresça
rapidamente, já que o número de riscos de uma figura da sequência cresce
exponencialmente. Mas podemos pelo menos tentar evitar repetir os mesmos riscos.

Repensar a nossa estratégia pode ajudar a escrever um algoritmo mais rápido. Não
vamos escrever um algoritmo iterativo; pode existir vários algoritmos recursivos que
resolvem o mesmo problema. Na função sierpinski(n) acima, primeiro desenhamos
uma cópia de Sn−1 à esquerda, depois desenhamos uma cópia à direita.
Só depois, desenhamos a cópia de cima. O motivo por que redesenhamos Sn−1 à direita
foi para que a tartaruga se deslocasse à posição de um vértice da cópia superior
de Sn−1. Para melhorar, podemos primeiro desenhar o triangulo de baixo à esquerda,
depois o triângulo de cima e depois o triângulo de baixo à direita.
Para isso, depois de desenhar o primeiro triângulo, precisamos que a tartaruga termine
em um vértice do triângulo de cima. Uma ideia é girar a tartaruga antes de desenhar o
primeiro triângulo.

def sierpinski(n):
if n == 1:
forward(PASSO)
left(120)
forward(PASSO)
left(120)
forward(PASSO)
left(120)
forward(PASSO)
else:
left(60)
sierpinski(n - 1)
right(60)
sierpinski(n - 1)
right(60)
sierpinski(n - 1)
left(60)

Executando para n=2.
O problema é que, embora garantimos que a tartaruga será posicionada corretamente
antes e depois de cada chamada recursiva, os triângulos da esquerda e da direita estão
desenhados no lado oposto ao que precisávamos. Isso sugere que precisamos tanto de
uma função que desenha o triângulo de Sierpinski virado cima, quanto de uma função
que o desenha virado para baixo.
Suponha que existe uma função sierpinski_baixo que desenha o triângulo de
Sierpinski virado para baixo. Então podemos escrever a seguinte função.

def sierpinski_cima(n):
if n == 1:
forward(PASSO)
left(120)
forward(PASSO)
left(120)
forward(PASSO)
left(120)
forward(PASSO)
else:
left(60)
sierpinski_baixo(n - 1)
right(60)
sierpinski_cima(n - 1)
right(60)
sierpinski_baixo(n - 1)
left(60)

Para de fato completar o programa, precisamos implementar sierpinski_baixo.


Usando uma ideia simétrica da função anterior, não é difícil escrever o seguinte.

def sierpinski_baixo(n):
if n == 1:
forward(PASSO)
right(120)
forward(PASSO)
right(120)
forward(PASSO)
right(120)
forward(PASSO)
else:
right(60)
sierpinski_cima(n - 1)
left(60)
sierpinski_baixo(n - 1)
left(60)
sierpinski_cima(n - 1)
right(60)

Se exercutarmos agora sierpinski_cima(3), deve ser gratificante ver como a tartaruga


desenha o triângulo de Sirpinski muito mais rápido e ordenadamente.

O curioso é que a função sierpinski_cima utiliza a função sierpinski_baixo e a


função sierpinski_baixo utiliza a função sierpinski_cima. Esse é um tipo de
recursão indireta. Chamamos essas funções de co-recursivas. Não há nada que impeça
que tenhamos mais do que duas funções co-recursivas. Além disso, o relacionamento
entre essas chamadas pode ser tão sofisticado quanto necessário.

Algoritmos baseados em funções co-recursivas aparecem quando há dois ou mais


problemas intimamente relacionados (muitas vezes, um é uma pequena modificação de
outro). Eles não são tão frequentes quanto algoritmos recursivos em geral, então você
não deverá escrever muitos deles. Mas há pelo menos uma aplicação em que algoritmos
co-recursivos são imbatíveis: implementar um analisador sintático de um compilador.

Generalizando problemas
Considere o seguinte quebra-cabeça.

A torre de Hanói é um brinquedo com três estacas A, B e C e discos de tamanhos


diferentes. O objetivo é mover todos os discos da estaca A para a estaca C respeitando
as seguintes regras:

 Apenas um disco pode ser movido de cada vez.

 Um disco só pode ser colocado sobre um disco maior.

 Queremos realizar o menor número de movimentos possível.


Você talvez já tenha ouvido falar das torres de Hanoi ou talvez já tenha ate tido a
oportunidade de manuseá-las. Se não, então tente resolver esse quebra-cabeça antes de
continuar lendo. Você pode clicar aqui ou procurar alguma outra versão interativa na
internet.

Sempre devemos prestar atenção na definição do problema que queremos resolver. Para
definir um problema precisamente, precisamos descrever a entrada e a saída. Enquanto
isso é claro na maioria dos exemplos que estudamos, nesse exemplo nosso objetivo é
descrito apenas como “mover todos os discos da estaca A para a estaca C”. Como não
temos disponível alguma máquina que mova os discos para nós, diremos que a saída do
problema é uma sequência de instruções para se resolver o quebra-cabeça. Como
entrada, vamos receber um número n, que é toda a informação de que precisamos para
descrever uma instância do problema.

Como de costume, podemos esboçar o nosso programa criando um stub da função que
resolve o problema e uma função principal.

def hanoi(n):
"""
Imprime uma sequência de instruções para mover n discos
da estaca A para a estaca C com ajuda da estaca B.
"""
pass

def main():
n = int(input("Digite o número de discos: "))
hanoi(n)

main()

Veja que a documentação da função descreve o problema precisamente.

Primeiro, é sempre bom tentar resolver instâncias pequenas. Se só temos um disco,


então tudo que precisamos fazer é mover um disco da estaca A para a estaca C. Se
tivermos dois discos, também não é difícil se convencer de que a forma mais rápida de
resolver o quebra-cabeça é movendo um disco de A para B, depois de A para C e depois
de B para C.

Para encontrar um algoritmo para uma instância geral, devemos partir de uma torre
inicial com n discos, digamos 5. Como temos que retirar todos os discos da estaca A,
em particular, precisamos retirar o maior disco. A primeira regra diz que só podemos
mover um disco de cada vez, então no momento em que formos mover o maior disco,
todos os outro discos estarão na estaca B ou na estaca C. Mas a segunda regra diz que só
podemos colocar um disco sobre um disco maior, então nesse momento todos os outros
discos estarão somente na estaca B, ou somente estaca C.

Como queremos utilizar a menor quantidade de movimentos possível, o que queremos


é mover  n−1 discos da estaca A para a estaca B usando a estaca C como auxiliar. Essa
é exatamente a descrição do problema que estamos tentando resolver, com a exceção de
que as estacas B e C estão com papeis trocados. Por esse motivo, não podemos
simplesmente fazer uma chamada recursiva a hanoi(n-1) para resolver o subproblema,
já que isso moveria os discos para a estaca C.

Para utilizar recursão, poderíamos criar uma outra função co-recursiva que resolve esse
problema um pouco diferente, como fizemos antes. Dessa vez, é mais fácil adotar uma
ideia diferente e generalizar o problema. Generalizar um problema significa que
aumentamos o conjunto de entradas válidas. Nesse caso, iremos fazer o seguinte.

def hanoi(n, origem, destino, auxiliar):


"""
Imprime uma sequência de instruções para mover n discos
da estaca origem para a estaca destino com ajuda
da estaca auxiliar.
"""
pass
A generalização é um problema novo, mas que contém o problema original. Para
resolver o problema original, podemos chamar hanoi(n, 'A', 'C', 'B'), mas agora
podemos resolver vários outros problemas ligeiramente distintos, dependendo da
escolha das estacas. Assim, primeiro podemos mover n−1 discos da estaca de origem
para a estaca auxiliar.

Depois, movemos o maior disco da estaca de origem para a estaca de destino.

Finalmente, movemos n−1 discos da estaca auxiliar para a estaca de destino.


Agora fica fácil terminar nosso programa.

def hanoi(n, origem, destino, auxiliar):


"""
Imprime uma sequência de instruções para mover n discos
da estaca origem para a estaca destino com ajuda
da estaca auxiliar.
"""
if n > 0:
hanoi(n - 1, origem, auxiliar, destino)
print(f"Mova um disco de {origem} para {destino}")
hanoi(n - 1, auxiliar, destino, origem)

Leia e releia o algoritmo com atenção. Uma pergunta que você pode se fazer é quais são
os casos básicos da função hanoi? Uma outra pergunta é se essa função realmente
realiza o menor número de movimentos.

Divisão e conquista
Parte importante da estratégia recursiva é decompor a instância do problema em uma ou
mais instâncias menores. Intuitivamente, quanto menor for a instância, mais fácil é o
problema. Uma forma de recursão recorrente é a divisão e conquista. Nela, queremos
dividir os dados da entrada em instâncias do problema substancialmente menores.

Por exemplo, vamos considerar o problema de multiplicar os elementos de uma lista de


números. Primeiro, vamos relembrar um algoritmo iterativo.

def multiplicar(lista):
produto = 1
for valor in lista:
produto = produto * valor
return produto

Não é muito difícil escrever um algoritmo recursivo para esse problema.


def multiplicar(lista, n):
"""
Devolve o produto dos n primeiros elementos de lista.
"""
if n == 1:
return lista[0]
else:
return lista[n - 1] * multiplicar(lista, n - 1)

def main():
lista = [1, 2, 3, 4, 5]
produto = multiplicar(lista, len(lista))
print(f"O produto é {produto}")

main()

Identifique o caso básico e o caso geral. Nós alteramos os parâmetros de entrada para
permitir distinguir entre os subproblemas. Repare que o problema que queremos
resolver é multiplicar os n primeiros elementos da lista e a instância menor do problema
a que reduzimos a instância original corresponde a multiplicar os n−1 primeiros
elementos da lista.

Para poder fazer uma chamada recursiva, basta diminuir o tamanho da instância.
Enquanto a função acima cria um subproblema de tamanho uma unidade menor,
poderíamos também considerar dois subproblemas, cujo tamanho de cada um
corresponde a metade do tamanho da instância original.

def multiplicar(lista, inicio, fim):


"""
Devolve o produto dos elementos de lista[inicio:fim].
"""
if inicio == fim:
return lista[inicio]
else:
meio = (inicio + fim) // 2
return (multiplicar(lista, inicio, meio) *
multiplicar(lista, meio + 1, fim))

def main():
lista = [1, 2, 3, 4, 5]
produto = multiplicar(lista, 0, len(lista) - 1)
print(f"O produto é {produto}")

main()

Mudamos a entrada do problema para que pudéssemos representar subproblemas mais


convenientemente. Vamos chamar os parâmetros inicio e fim de guardas da sublista.

Dessa vez, criamos subproblemas muito menores do que a instância original. Para o
problema de multiplicar elementos de uma lista, ambas as funções recursivas irão
executar exatamente o mesmo número de multiplicações que a função iterativa, então
não há vantagem em utilizar recursão nesse caso. Mas a segunda função recursiva é um
exemplo simples de como podemos resolver um problema usando divisão e conquista.
Um caso em que é vantajoso usar divisão e conquista ocorre quando não precisemos
resolver um dos subproblemas. Por exemplo, se todos os números da lista são iguais a
um número b, então o produto calculado corresponderá à potência bn. Nesse caso,
podemos evitar uma das chamadas recursivas, diminuindo significativamente o número
de multiplicações quando comparado com o algoritmo iterativo. Esse algoritmo é
chamado de algoritmo de potenciação rápida.

Em um outro exemplo, suponha que queremos encontrar um elemento em uma lista


ordenada. Relembre que na busca binária, sempre dividimos essa lista em duas partes.
Esse algoritmo de busca binária pode ser implementado como um algoritmo recursivo
de divisão e coquista, em que cada metade da lista corresponde a um subproblema.

Implemente funções recursivas para a potenciação rápida e busca binária!

Na prática, utilizamos divisão e conquista quando for mais fácil combinar o resultado de
subproblemas menores ou quando for mais rápido resolver subproblemas muito
menores. Para ver isso, vamos voltar ao problema da ordenação. Vamos ver que usando
divisão e conquista obtemos um algoritmo muito mais rápido do que os algoritmos de
ordenação que já conhecemos.

Para podermos comparar, aqui está o algoritmo de ordenação por inserção, na versão
mais rápida que conseguimos.

def insertion_sort(lista):
n = len(lista)
for i in range(1, n):
chave = lista[i]
j = i - 1
while j >= 0 and lista[j] > chave:
lista[j + 1] = lista[j]
j = j - 1
lista[j + 1] = chave

Primeiro, vamos criar uma lista de números razoavelmente grande. Para termos um
tempo base com o que comparar, vamos executar o algoritmo de ordenação por inserção
com essa lista.

def ler_arquivo(nome_arquivo):
with open(nome_arquivo) as arquivo:
lista = []
for linha in arquivo:
numero = int(linha)
lista.append(numero)
return lista

def guardar_arquivo(nome_arquivo, lista):


with open(nome_arquivo, "w") as arquivo:
for valor in lista:
print(valor, file=arquivo)

def main():
lista = ler_arquivo("muitos.txt")
insertion_sort(lista)
guardar_arquivo("muitos_ordenados.txt", lista)

main()

Damos uma olhada no arquivo, contamos o número de linhas e em seguida executamos


esse programa com ajuda do comando time.

user@notebook:~/ra123456/recursao$ head muitos.txt


5164
9405
3687
8847
4689
4362
3895
6247
5601
6540
user@notebook:~/ra123456/recursao$ wc -l muitos.txt
100000 muitos.txt
user@notebook:~/ra123456/recursao$ time python3 insertion_sort.py

real 4m44,423s
user 4m44,409s
sys 0m0,040s

Vamos utilizar índices de guarda para representar as sublistas sendo ordenadas.


A animação acima sugere um algoritmo de divisão e conquista. Vamos detalhar esse


algoritmo. Primeiro, precisamos definir um caso básico. Assim como no
algoritmo multiplicar_lista, definiremos como caso básico as instâncias do
problema em que inicio == fim. Nesses casos, a sublista contém apenas um elemento,
então não há nada a ser feito. As instâncias menores são duas, a primeira e a segunda
metades da lista. Resolvemos essas duas instâncias recursivamente e, para combinar as
soluções dos subproblemas, basta intercalar as sublistas ordenadas.

Por causa da forma com que combinamos as soluções dos subproblemas, chamamos
esse algoritmo de ordenação por intercalação ou merge sort. Podemos esboçar uma
implementação.

def intercalar(lista, inicio, meio, fim):


pass

def merge_sort(lista, inicio, fim):


if inicio < fim:
meio = (inicio + fim) // 2
merge_sort(lista, inicio, meio)
merge_sort(lista, meio + 1, fim)
intercalar(lista, inicio, meio, fim)

def main():
lista = ler_arquivo("muitos.txt")
merge_sort(lista, 0, len(lista) - 1)
guardar_arquivo("muitos_ordenados.txt", lista)

main()

No fundo, todo o trabalho de ordenação é feito pela função intercalar, então é


importante que ela seja implementada com cuidado. Faça isso.

Depois de já termos resolvido o problema e implementado a função recursiva, podemos


entender melhor as instruções sendo executadas investigando as chamadas recursivas.
Na etapa de divisão, cada chamada de merge_sort realiza duas outras chamadas
recursivas.

Cada retângulo da figura corresponde a uma sub-instância do problema de ordenação a


ser resolvida recursivamente. Há 19 retângulos; você consegue dizer a ordem em que
essas chamadas são realizadas? Além disso, cada chamada que não corresponde a um
caso básico realiza uma operação de intercalação; quantas vezes a função intercalar é
chamada?

Finalmente, podemos ordenar nossa lista de números pelo algoritmo de ordenação por
intercalação utilizando o programa que acabamos de implementar.

user@notebook:~/ra123456/recursao$ time python3 merge_sort.py

real 0m0,453s
user 0m0,445s
sys 0m0,008s

Mais de 600 vezes mais rápido! Nunca nos esqueçamos da estratégia de divisão e
conquista.

← ANTERIOR
VOLTAR

Copyright © 2020

Vous aimerez peut-être aussi