Exemplo 7
Exemplo 7
Exemplo 7
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.
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:
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:
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
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.
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.
https://www.livescience.com/20718-computer-history.html
https://www.computerhistory.org/timeline/computers/
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]
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.
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:
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.
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.
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.
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.
Estruturas de controle
Vamos listar as estruturas de controle mais comumente utilizadas ao escrever um
algoritmo:
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.
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.
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.
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".
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.
É 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.
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.
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.
É 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.
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".
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.
Copyright © 2020
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.
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
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
input N ;
X := 0 ;
for Y to N from 1 do
X := X + Y
end ;
output X
N = int(input())
X = 0
for Y in range(1, N + 1):
X = X + Y
print(X)
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
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?
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.
for Y from 1 to N do
{corpo do laço}
end
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
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.
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:
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.
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:
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.
python3
IMC=massa(altura⋅altura)
>>> 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:
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'>
>>> -99999999 ** 10
-
9999999000000044999998800000020999999748000002099999988000000044999999900
0000001
>>> (-99999999) ** 10
9999999000000044999998800000020999999748000002099999988000000044999999900
0000001
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:
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
É 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
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
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
>>> 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:
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.
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.
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
que imprimiria
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.
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()
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())
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())
import math
cateto1 = float(input())
cateto2 = float(input())
hipotenusa = math.sqrt(cateto1*cateto1 + cateto2*cateto2)
Comandos condicionais
Vamos resolver o seguinte exercício:
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.
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.")
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
numero = int(input())
par = numero % 2 == 0
maior_10 = numero > 10
menor_50 = numero < 50
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.
numero = int(input())
par = numero % 2 == 0
maior_10 = numero > 10
menor_50 = numero < 50
numero = int(input())
par = numero % 2 == 0
maior_10 = numero > 10
menor_50 = numero < 50
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
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:
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
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.
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
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:
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.
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:
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:
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:
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.
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:
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))
Um exemplo de entrada é
Maria
João
Pedro
Catarina
Carlos
-
# 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.
# 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))
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
animais = []
for m in mamiferos:
animais.append(m)
animais.append("sapo")
mammals[2] = "elefante"
print(mamiferos)
print(mammals)
print(animais)
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.)
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")
n = int(input())
if n == 1 or divisor_encontrado:
print("O número é 1 ou tem divisores não triviais")
else:
print("O número é primo")
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.
+
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")
Copyright © 2020
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")
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.
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)
# ...
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
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}")
# ...
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 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).
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.
def operacao_soma():
pass
def operacao_diferenca():
pass
def operacao_produto():
pass
def operacao_divisao():
pass
def operacao_raiz():
pass
def operacao_bhaskara():
pass
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)
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)
def operacao_raiz():
num = float(input())
raiz = math.sqrt(num)
print(raiz)
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)
Repetição de código
Vamos resolver mais um exercício:
Antes de começar, vamos listar as duas pequenas tarefas que devemos fazer:
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.
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
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
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
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.
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.
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.
"""
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 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:
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:
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.
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
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.
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()
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()
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.
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 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()
À 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.
Por enquanto, só iremos falar em como criar um módulo pessoal. Para isso, vamos ver
um exemplo.
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 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()
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 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()
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.
import utilidades
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.
← ANTERIOR
VOLTAR
PRÓXIMA →
Copyright
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
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.
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
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.
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:
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.
**********
*********
********
*******
******
*****
****
***
**
*
def triangulo(n):
i = 1
j = n
while i <= n:
i = 0
while i < j:
print('*', end="")
i = i + 1
print()
j = j - 1
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.
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
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
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!
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
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.
if numero_passo % 1000000 == 0:
print(distancia)
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
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.
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:
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()
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 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()
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.
Zumbi
zebra
zumba
tumba
almanaque
alma
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:
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.
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.
Ordenação da bolha
1. Repita n−1 vezes:
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 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)
def main():
lista = [3, 5, 2, 0, 9, 6]
bubble_sort(lista)
print(lista)
main()
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.
←
→
Simples, claro e conciso. Agora podemos implementar; como sempre, iremos utilizar
stubs para simplificar o processo de desenvolvimento.
def selection_sort(lista):
for i in range(len(lista)):
indice_menor = encontrar_indice_menor(lista, i)
trocar(i, indice_menor)
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.
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. 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
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!
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.
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:
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 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ê:
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:
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 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 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()
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)
>>> 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],
>>> ]
>>> 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:
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()
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
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.
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,
Enquanto essa convenção é puramente cosmética, utilizar sempre essa notação pode
evitar confusões que levam a um grande perda de tempo.
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
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.
/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.
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.
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.
Manipulando arquivos
Instruções: Para essa unidade, você deve ler a seção 7 sobre entrada e saída e arquivos
do tutorial Python.
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
Repare que o valor devolvido por open é associado a arquivo e que não precisamos
fechar o arquivo explicitamente.
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:
123456
Ana Viva Mariana
29/2/2000
Maria Viva
É 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.
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
"""
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 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
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.
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.
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?
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.
mas é útil deixar que as mensagens de erro sejam mostradas na tela. Vamos fazer isso
em breve.
OEAIAGBOOL
IIWAXHHLHN
PADUCAPNOC
ZBMOUIZSAS
OXEZOKOEUA
QCRMAAPAOH
DHOMEMTUFO
HOOAJCMVGM
NMFOANGMAE
JEVJVCCSNM
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:
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:
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"""
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.
Agora escrevemos a função. Vamos postergar as tarefas mais difíceis usando stubs.
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
def main():
palavra, matriz = ler_arquivo_entrada("caca_palavras.txt")
ocorrencias = contar_ocorrencias(palavra, matriz)
print(f"Há {ocorrencias} ocorrencias")
1. ocorre←True
2. para cada índice k de palavra:
o compare os caracteres palavra[k] com matriz[i][j+k]
3. devolva ocorre
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
# ...
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:
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.
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.
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.
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
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.
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.
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.
def main():
dicionario = criar_dicionario()
def criar_dicionario():
"""cria um dicionário vazio"""
return []
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.
i = procurar_indice(dicionario, palavra)
verbete_antigo = dicionario[i]
verbete_novo = (verbete_antigo[0], nova_definicao, verbete_antigo[2])
dicionario[i] = verbete_novo
def main():
dicionario = criar_dicionario()
i = procurar_indice(dicionario, verbete[0])
if i is None:
dicionario.append(verbete)
else:
raise Exception(f"Palavra {verbete[0]} já existe.")
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.
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)
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.
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
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()
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.
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ê!
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.
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.
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
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.
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.
>>> 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.
b) se n já apareceu:
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.
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.
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.
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)
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
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.
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:
Uma estrutura de dados que satisfaz todos esses requisitos é um dict. Vejamos alguns
exemplos.
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 = {}
return histograma
def mostrar_histograma(histograma):
for elemento, multiplicidade in histograma.items():
print(f"Multiplicidade de {elemento}: {multiplicidade}")
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.
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()
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,
}
def criar_dicionario():
"""cria um dicionário vazio"""
return []
i = procurar_indice(dicionario, verbete["palavra"])
if i is None:
dicionario.append(verbete)
else:
raise Exception(f"Palavra {verbete['palavra']} já existe.")
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)
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.
def main():
dicionario = criar_dicionario()
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")
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")
n = int(input())
n = 2 * n
print(f"O dobro é {n}")
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
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.
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.
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:
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()
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.
← ANTERIOR
VOLTAR
PRÓXIMA →
Copyright © 2020
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...
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.
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.
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.
real 0m5,203s
user 0m0,026s
sys 0m0,008s
real 0m0,833s
user 0m0,052s
sys 0m0,005s
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.
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
çõçã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.
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
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.
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.
real 0m14,418s
user 0m14,413s
sys 0m0,005s
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 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.
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.
real 0m6,215s
user 0m6,215s
sys 0m0,001s
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.
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
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.
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.
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}.")
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”.
Agora pelo menos não precisamos anotar os palpites. Só mais uma vez. Estou sentindo
que a sorte vai chegar.
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 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.
— 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 main():
sacola = criar_sacola(100)
numero = random.choice(sacola)
ganho = jogar_sacola(sacola, numero)
print(f"Ganhei {ganho} reais!!!")
main()
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()
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.
— Como?
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.
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
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.
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.
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:
2. dada uma posição, podemos decidir se existe uma estrutura antes ou depois
dessa posição.
ERRO = 0.00000001
def f(x):
return x**2 - 2
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.
← 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.
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.
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.
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()
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.
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
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
def fatorial(n):
if n == 0:
return 0
else:
return fatorial(n - 1) * n
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.
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.
LARGURA_A0 = 841
ALTURA_A0 = 1189
def main():
larg_retangulo = int(input("digite a largura do retângulo: "))
alt_retangulo = int(input("digite a altura do retângulo: "))
main()
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.
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()
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.
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.
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.
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)
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).
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)
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
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(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()
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.
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.
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)
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)
Generalizando problemas
Considere o seguinte quebra-cabeça.
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()
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.
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.
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.
def multiplicar(lista):
produto = 1
for valor in lista:
produto = produto * valor
return produto
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 main():
lista = [1, 2, 3, 4, 5]
produto = multiplicar(lista, 0, len(lista) - 1)
print(f"O produto é {produto}")
main()
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.
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 main():
lista = ler_arquivo("muitos.txt")
insertion_sort(lista)
guardar_arquivo("muitos_ordenados.txt", lista)
main()
real 4m44,423s
user 4m44,409s
sys 0m0,040s
←
→
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 main():
lista = ler_arquivo("muitos.txt")
merge_sort(lista, 0, len(lista) - 1)
guardar_arquivo("muitos_ordenados.txt", lista)
main()
Finalmente, podemos ordenar nossa lista de números pelo algoritmo de ordenação por
intercalação utilizando o programa que acabamos de implementar.
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