Apostila ED Python
Apostila ED Python
net/publication/353192371
CITATIONS READS
7 6,404
1 author:
Alexandre L M Levada
Universidade Federal de São Carlos
124 PUBLICATIONS 365 CITATIONS
SEE PROFILE
Some of the authors of this publication are also working on these related projects:
All content following this page was uploaded by Alexandre L M Levada on 13 July 2021.
“Experiência não é o que acontece com um homem; é o que ele faz com o que lhe acontece”
(Aldous Huxley)
A linguagem Python
Porque Python 3?
- Evolução do Python 2 (mais moderno)
- Sintaxe simples e de fácil aprendizagem
- Linguagem de propósito geral que mais cresce na atualidade
- Bibliotecas para programação científica e aprendizado de máquina
Dentre as principais vantagens de se aprender Python, podemos citar a enorme gama de bibliotecas
existentes para a linguagem. Isso faz com que Python seja extremamente versátil. É possível
desenvolver aplicações científicas como métodos matemáticos numéricos, processamento de sinais
e imagens, aprendizado de máquina até aplicações mais comerciais, como sistemas web com acesso
a bancos de dados.
Plataformas Python para desenvolvimento
a) Anaconda - https://www.anaconda.com/products/individual
Uma ferramenta multiplataforma com versões para Windows, Linux e MacOS. Inclui mais de uma
centena de pacotes para programação científica, o que o torna um ambiente completo para o
desenvolvimento de aplicações em Python. Inclui diversos IDE’s, como o idle, ipython e spyder.
b) WinPython - http://winpython.github.io/
Uma plataforma exclusiva para Windows que contém inúmeros pacotes indispensáveis, bem como
um ambiente integrado de desenvolvimento muito poderoso (Spyder).
c) Pyzo (Python to people) - http://www.pyzo.org/
Um projeto que visa simplificar o acesso à plataforma Python para computação científica. Instala os
pacotes standard, uma base pequena de bibliotecas e ferramentas de atualização, permitindo que
novas bibliotecas sejam incluídas sob demanda.
d) Canopy - https://store.enthought.com/downloads
Mais uma opção multiplataforma para usuários Windows, Linux e MacOS.
Repl.it
Uma opção muito interessante é o interpretador Python na nuvem repl.it
Você pode desenvolver e armazenar seus códigos de maneira totalmente online sem a necessidade
de instalar em sua máquina um ambiente de desenvolvimento local.
Após concluir a instalação, basta digitar anaconda na barra de busca do Windows. A opção
Anaconda Prompt deve ser selecionada. Ela nos leva a um terminal onde ao digitar o comando idle
e pressionarmos enter, seremos diretamente redirecionados ao ambiente IDLE. Um vídeo tutorial
mostrando esse processo pode ser assistido no link a seguir:
https://www.youtube.com/watch?v=PWdrdWDmJIY&t=8s
“Não trilhe apenas os caminhos já abertos. Por serem conhecidos eles nos levam somente até onde alguém já foi
um dia.” (Alexander Graham Bell)
Aula 1 – Programação estruturada em Python
A recorrência logística
x n+1=r x n
onde r > 0 denota a taxa de crescimento. Porém, na natureza sabemos que devido a limitação de
espaço e a disputa pelos recursos, populações não tendem a crescer indefinidamente. Há um ponto
de equilíbrio em que o número de indivíduos tende a se estabilizar ao redor. Sendo assim, podemos
definir a seguinte equação:
x n+1=r x n (1−x n )
em que o x n∈[0,1] a porcentagem de indivíduos vivos e o termo (1−x n ) tende a zero quando
essa porcentagem se aproxima do valor máximo de 100%. Esse modelo é conhecido como
recorrência logística. Veremos a seguir que fenômenos caóticos emergem desse simples modelo,
que aparentemente possui um comportamento bastante previsível.
Primeiramente, note que o número de indivíduos no tempo n+1 é uma função quadrática do número
de indivíduos no tempo n, pois:
ou seja, temos uma equação do segundo grau com a=−r , b=r e c=0 . Como a < 0, a
concavidade da parábola é para baixo, ou seja, ela admite um ponto de máximo. Derivando
f ( x n) em relação a x n e igualando a zero, temos o ponto de máximo:
−2 r x n +r =0
* 1 * r r r
o que nos leva a x n= . Note que nesse ponto o valor da função vale: f ( x n)=− + =
2 4 2 4
Note também que como c = 0, f (0)=0 , ou seja, a parábola passa pela origem. Note ainda que
f (1)=0 , ou seja a parábola corta o eixo x no ponto x = 1. De forma gráfica temos para alguns
valores de r as seguintes parábolas:
r=1 r=2
r=4
Vamos simular várias iterações do método em Python para analisar o comportamento do tamanho
da população em função do tempo t. O script em Python a seguir mostra uma implementação
computacional do modelo utilizando 100 iterações.
population = [x]
a) r = 1 e x0 = 0.4 (extinção)
b) r = 2 e x0 = 0.4 (equilíbrio em 50%)
c) r = 2.4 e x0 = 0.6 (pequena oscilação, mas atinge equilíbrio em 58%)
d) r = 3 e x0 = 0.4 (não há equilíbrio, população oscila, mas em torno de uma média)
e) r = 4 e x0 = 0.4 (comportamento caótico, totalmente imprevisível)
Em seguida, iremos estudar o que acontece com a população de equilíbrio conforme variamos o
valor do parâmetro r. A ideia é que no eixo x iremos plotar os possíveis valores de r e no eixo y
iremos plotar a população de equilíbrio para aquele valor de r específico. Iremos considerar que a
população do equilíbrio é obtida depois de 1000 iterações. O script em Python a seguir mostra a
implementação computacional dessa análise.
m = 0.5
Y.append(x)
O gráfico plotado pelo script acima é conhecido como bifurcation map. Esse fenômeno da
bifurcação ocorre como uma manifestação do comportamento caótico da população de equilíbrio
para valores de r maiores que 3. Na prática, o que temos é que para um valor de r = 3.49999, a
população de equilíbrio é muito diferente daquela obtida para r = 3.50000 por exemplo. Pequenas
perturbações no parâmetro r causam um efeito devastador na população de equilíbrio. Esse é o lema
da teoria do caos, que pode ser parafraseado pela célebre sentença: o simples bater de asas de uma
borboleta pode levar ao surgimento de um furação, conhecido também como o efeito borboleta.
Uma das propriedades do caos é que é possível encontrar ordem e padrões em comportamentos
caóticos. Por exemplo, a seguir iremos desenvolver um script em Python para plotar uma sequencia
de populações, começando de uma população inicial arbitrária e utilizando o valor de r = 3.99.
def atrator(X):
A = X[:len(X)-2]
B = X[1:len(X)-1]
C = X[2:]
#Plota atrator em 3D
fig = plt.figure(2)
ax = fig.add_subplot(111, projection='3d')
ax.plot(A, B, C, '.', c='red')
plt.show()
# Início do script
r = 3.99
x = np.random.random()
X = [x]
for i in range(1000):
x = r*x*(1 - x)
X.append(x)
A seguir, iremos plotar cada subsequência ( x n , x n+1 , xn +2) como um ponto no R3. Na prática, isso
significa que no eixo X iremos plotar a sequência original, no eixo Y iremos plotar a sequência
deslocada de uma unidade e no eixo Z iremos plotar a sequência deslocada de duas unidades. Qual
será o gráfico formado? Se de fato a sequência for completamente aleatória, nenhum padrão deverá
ser observado, apenas pontos dispersos aleatoriamente pelo espaço. Mas, surpreendentemente,
temos a formação do seguinte padrão, conhecido como o atrator do modelo.
Podemos repetir a análise anterior, mas agora plotando o ponto (x n , x n+1 ) no plano. Conforme
discutido anteriormente, vimos que x n+1=f ( x n ) é uma função quadrática, ou melhor, uma
parábola. O experimento prático apenas comprova a teoria. Note que o gráfico obtido pelo script a
seguir é exatamente a parábola. Conforme a teoria, note que o ponto de máximo ocorre em x n = 0.5,
e o valor da função nesse ponto, f(xn), é praticamente 1 (r/4 = 3.99/4).
r = 3.99
x = np.random.random() # a população é x minúsculo!
X = [x] # a lista é X maiúsculo!
for i in range(1000):
x = r*x*(1 - x)
X.append(x)
A = X[:len(X)-1]
B = X[1:len(X)]
#Plota atrator em 3D
plt.figure(2)
plt.plot(A, B, '.', c='blue')
plt.show()
Interessante, não é mesmo? Dentro do caos, há ordem. Muitos fenômenos que observamos no
mundo real parecem ser aleatórios, mas na verdade exibem comportamento caótico. A pergunta que
fica é justamente essa: como distinguir um sistema aleatório de um sistema caótico? Como
identificar os padrões que nos permitem enxergar a ordem em um sistema caótico? Para responder a
esse questionamento precisamos mergulhar fundo na matemática dos sistemas complexos e da
teoria do caos.
Autômatos celulares
Uma pergunta recorrente no estudo de tais sistemas é: porque e como padrões complexos emergem
a partir da interação entre os elementos? Como esses padrões evoluem com o tempo? Respostas a
essas perguntas não são totalmente conhecidas, mas o estudo de modelos de autômatos celulares
nos auxiliam no estudo e análise de tais sistemas. Aplicações práticas são muitas e incluem:
- Autômatos celulares e composição musical
- Autômatos celulares e modelagem urbana
- Autômatos celulares e propagação de epidemias
- Autômatos celulares e crescimento de câncer
Segundo Wolfram, autômatos celulares são formados por uma rede de células que possuem seus
estados alterados num tempo disscreto de acordo com seu estado anterior e o estado de suas células
vizinhas. Algumas características importantes e comuns a todos os autômatos celulares são:
- Homogeneidade: as regras são iguais para todas as células
- Estados discretos: cada célula pode estar em um dos finitos estados
- Interações locais: o estado de uma célula depende apenas das células mais próximas (vizinhas)
- Processo dinâmico: a cada instante de tempo as células podem sofrer uma atualização de estado
A=( R , S , S0 , V , F ) onde
Um autômato celular é dito elementar se o reticulado de células R é unidimensional (ou seja, pode
ser representado por um vetor), o conjunto de estados S = {0, 1} e o conjunto vizinhança engloba
apenas duas células: a anterior (i-1) e a posterior (i+1). Tipicamente, se uma célula assume estado 0
dizemos que ela está morta e se ela assume estado 1 dizemos que está viva. A figura a seguir ilustra
as primeiras 20 gerações de um autômato celular elementar, em que no início apenas uma célula
está viva (preto = vivo, branco = morto)
A questão é: como evoluir uma configuração de forma a construir esse padrão? Que regras são
aplicadas para definir quais células vivem ou morrem na próxima geração?
Note que não existem mais combinações possíveis de 0’s e 1’s usando apenas 3 bits, pois
conseguimos contar em binário de 0 a 7, o que resulta em 8 possibilidades. Essa regra tem um
nome: é a regra 50, pois o número binário correspondente a última coluna da função de transição
vale 00110010, que em binário é justamente o número 50. Sendo assim, quantas possíveis regras
existem para um autômato celular elementar? Basta computar 28, que resulta em 256. Portanto, o
número total de regras distintas é 256. Por essa razão dizemos que existem 256 autômatos celulares
elementares distintos, um para cada regra. O interessante é estudar e simular o que acontece com
cada um desses autômatos durante sua evolução. De acordo com Wolfram, existem 4 classes de
regras para um autômato celular elementar:
Exercício: Construa a função de transição do autômato celular elementar definido pela regra 30.
Aplique a regra para evoluir a condição inicial idêntica a da figura da regra 50 (apenas uma célula
viva) por 20 gerações. Repita o exercício mas agora para a regra 110.
nova_geração = vetor(N)
Plotar resultados
Ex: Baseado no algoritmo anterior, implementar um script em Python que, dado uma regra (número
de 0 a 255), evolua uma configuração inicial de tamanho N = 1000 até a geração 500.
import numpy as np
import matplotlib.pyplot as plt
# Início do script
MAX = 500
g = np.zeros(1000)
ng = np.zeros(1000)
matriz_evolucao[i,:] = g
Autômatos celulares 2D
O Jogo da Vida
O autômato 2D mais conhecido sem dúvida é o jogo da vida, criado por Conway para simular a
evolução de sistemas complexos a partir de regras determinísticas. O reticulado 2D é representado
computacionalmente por uma matriz geralmente quadrada de células que podem estar vivas ou
mortas. A função de vizinhança é definida pela vizinhança de Moore, ou seja, pelas 8 células mais
próximas a uma dada célula i, conforme ilustra a figura a seguir.
A função de transição do jogo da vida tem como conceito imitar processos de nascimento de morte.
A ideia básica é que um ser vivo necessita de outros seres vivos para sobreviver e procriar, mas um
excesso de densidade populacional provoca a morte do ser vivo devido à escassez de recursos.
São 4 regras básicas:
R1 (Sobrevivência) – uma célula viva com 2 ou 3 células vizinhas vivas, permanece viva na
próxima geração.
R2 (Morte por isolamento) – uma célula viva com 0 ou 1 vizinho vivo morre de solidão na próxima
geração.
R3 (Morte por sufocamento) – uma célula viva co 4 ou mais vizinhos vivos morre por sufocamento
na próxima geração.
R4 (Renascimento) – uma célula morta com exatamente 3 vizinhos vivos, renasce na próxima
geração.
Isso nos leva a regra de transição conhecida como B3S23, uma vez que no código proposto B
significa born (nascer) e S significa survive (sobreviver). Em outras palavras, nesse autômato em
particular, uma célula nasce sempre que possui 3 vizinhas vivas ao redor e sobrevive se possui 2 ou
3 vizinhas vivas ao redor. Outras variantes de regras incluem: B15S257, B147S256, B34S4567, etc.
Cada uma das regras define um autômato diferente. Ao autômato cuja regra é B3S23 dá-se o nome
de Jogo da Vida.
É interessante perceber que a regra B3S23 a partir de diversas inicializações simples exibe um
comportamento altamente complexo, onde padrões complexos e “seres vivos” passam a interagir de
maneira bastante inesperada. Trata-se de um conjunto de regras totalmente determinísticas que
levam a um comportamento completamente imprevisível (ordem x caos).
Ex: Implementar um script em Python que, dada uma configuração inicial, simule o jogo da Vida
num tabuleiro de dimensões 100 x 100 por 200 gerações.
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.animation as animation
import time
# Início do script
inicio = time.time()
MAX = 200
SIZE = 100
for j in range(SIZE):
if (geracao[i,j] == 1):
if (vivos == 2 or vivos == 3):
nova_geracao[i,j] = 1
else:
nova_geracao[i,j] = 0
else:
if (vivos == 3):
nova_geracao[i,j] = 1
geracao = nova_geracao.copy()
fim = time.time()
print('Tempo gasto na simulação: %.2f s' %(fim-inicio))
ani.save('r_pentomino.gif', writer='imagemagick')
plt.show()
Aula 2 – Complexidade de algoritmos
Quanto menores as quantidades definidas acima, melhor será um algoritmo, sendo que o tempo de
execução na verdade é estimado indiretamente a partir do número de instruções a serem executadas.
import time
def sum_of_n(n):
start = time.time()
the_sum = 0
for i in range(1, n+1):
the_sum = the_sum + i
end = time.time()
return (the_sum, end-start)
for i in range(5):
print("Sum is %d required %.7f seconds" % sum_of_n(10000))
teremos como resultado os seguintes valores:
Esse tempo depende diretamente do processador utilizado na execução. O que é certo é que se
aumentarmos o valor de n para 1 milhão, ou seja, n = 1000000, o tempo gasto será maior. Portanto,
há uma relação entre o tamanho da entrada n e o tempo gasto: isso ocorre porque o número de
iterações na repetição aumenta, fazendo com que o número de instruções executadas pelo
processador aumente.
for i in range(5):
print("Sum is %d required %.7f seconds" % sum_of_n(1000000))
Para contornar os problemas citados acima, a análise de algoritmos busca fornecer uma maneira
objetiva de estimar o tempo de execução de um programa pelo número de instruções que serão
executadas. Dessa forma, quanto maior o número de repetições existentes no programa, maior será
o tempo de execução. Claramente, calcular de maneira precisa o exato número de instruções
necessárias para um programa pode ser extremamente complicado, ou até mesmo impossível. É
aqui que entra a notação Big-O. Conforme mencionado previamente, o número de operações a
serem executadas em um programa é uma função do tamanho da entrada, ou seja, f(n). Assim,
podemos estudar o comportamento assintótico de tais funções, ou seja, o que ocorre com elas
quando n cresce muito (tende a infinito).
Notação Big-O
Isso significa que para um problema de tamanho n, essa função executa n + 1 instruções. O que
ocorre na prática, é que cientistas da computação verificaram que quando n cresce, apenas uma
parte dominante da função é importante. Essa parte dominante é o que motiva a definição na
notação O( f(n) ). No exemplo da função T(n), no que conforme n cresce o termo 1 torna-se
desprezível. Por isso, dizemos que O( T(n) ) = n, ou seja, esse é uma algoritmo de ordem linear.
Para definirmos a notação Big-O, vejamos um outro exemplo. Considere o programa em Python a
seguir, em que matrix é uma matriz n x n preenchida com números aleatórios.
totalSum = 0
for i in range(n):
rowSum[i] = 0
for j in range(n):
rowSum[i] = rowSum[i] + matrix[i,j]
totalSum = totalSum + rowSum[i]
T (n)=n( n+2)=n2 +2 n
Suponha que exista uma função f(n), definida para todos os inteiros maiores ou iguais a zero, tal
que para uma constante c e uma constante m:
T ( n)≤c f ( n)
para todos os valores suficientemente grandes n≥m (quando n é grande). Então, dizemos que
esse algoritmo tem complexidade de O( f(n) ). A função f(n) indica a taxa de crescimento na qual a
execução de uma algoritmo aumenta, conforme o tamanho da entrada n aumenta. Voltemos ao
exemplo anterior. Vimos que T ( n)=n2 +n . Note que para c = 2 e f (n)=n2 , temos:
o que implica em dizer que o algoritmo em questão é O(n2 ) . A função f (n)=n2 não é a única
escolha que satisfaz T ( n)≤c f (n) . Por exemplo, poderíamos ter escolhido f (n)=n3 , o que
nos levaria a dizer que o algoritmo tem complexidade O(n3 ) . Porém, o objetivo da notação Big-
O é o limite superior mais apertado ou justo possível. Trata-se de uma maneira de estudar a taxa de
crescimento assintótico de funções.
As vezes, o desempenho de um algoritmo não depende apenas do tamanho da entrada, mas também
dos elementos que compõem o vetor/matriz. Veremos isso no caso dos algoritmos de ordenação. Se
o vetor de entrada está quase ordenado, o algoritmo leva menos tempo, ou seja, é mais rápido. Em
cenários como esse, podemos realizar a análise do algoritmo em três situações distintas: melhor
caso (seria o vetor já ordenado), caso médio (valores aleatórios) e pior caso (o vetor em ordem
decrescente, totalmente desordenado). Costuma ignorar o melhor caso, pois ele é muito raro de
acontecer na prática. Em geral, realizamos as análises no caso médio ou pior caso. Costuma-se
dividir os algoritmos nas seguintes classes de complexidade:
f(n) Classe
1 Constante
log n Logarítmica
n Linear
n log n Log-linear
n2 Quadrática
n3 Cúbica
2n Exponencial
O comportamento assintótico dessas funções é muito diferente. De modo geral, é raro um algoritmo
ter complexidade constante (mas há estruturas de dados em que a inserção ou remoção de
elementos possui tempo constante), sendo que a classe logarítmica é quase sempre o melhor que
podemos obter. Quando um algoritmo é da classe exponencial, ele é praticamente inviável, pois
para n = 100, já temos uma valor extremamente elevado de operações, o que seria suficiente para
fazer o tempo de execução superar dezenas de anos nos computadores mais rápidos do planeta. A
figura a seguir mostra a taxa de crescimento dessas funções.
Construindo T(n)
então ela toda equivale a uma instrução básica, mesmo envolvendo 3 multiplicações e 2 adições.
Assume-se que cada instrução básica possui tempo constante, ou seja, possui O(1). O número total
de operações de um algoritmo é dado pela somatória dos tempos individuais f i(n) = 1
sequencialmente, ou seja:
Somatórios
Para iniciar com um exemplo simples, suponha que desejamos somar todas as potências de 2,
iniciando em 21 e terminando em 210. A maneira explícita de escrever essa soma é:
2 3 4 10
2+2 + 2 + 2 +...+2
∑ 2k
k=1
1) Substituição de variáveis
∑ 2k
k=1
∑ 2i +1
i=0
∑ c f (k )=c ( ∑ f (k ))
k∈ A k∈ A
Em outras palavras, é possível mover as constantes para fora do somatório colocando-as em
evidência.
Em outras palavras, podemos decompor o somatório em dois somatórios menores, desde que cada
valor de índice apareça no domínio de uma dos subconjuntos. Por exemplo, se A é o conjunto dos
naturais, A1 é o conjunto dos pares e A2 é o conjunto dos ímpares, todo índice em A1 não está em A2.
∑ f (k )= ∑ f ( p(k ))
k∈ A k ∈A
ou seja, podemos embaralhar os termos de um somatório que o valor final não muda.
ou seja, o valor do somatório das diferenças é igual a diferença entre o último elemento e o primeiro
Prova:
def ex2(n):
count = 0
for i in range(n):
count += 1
for j in range(n):
count += 1
return count
Note que temos uma atribuição inicial (1) e logo dois loops com n iterações. Cada um deles,
contribui com n para o total, de modo que no total temos T(n) = 2n + 1, o que resulta em uma
complexidade O(n).
def ex3(n):
count = 0
for i in range(n):
for j in range(n):
count += 1
return count
Nesse caso, o loop interno tem n operações. Como o loop externo é executado n vezes, e temos
uma inicialização, o total de operações é T (n)=n2 +1 , o que resulta em O(n2 ) .
Note que nem todos os loops aninhados possuem custo quadrático. Considere o código a seguir:
def ex3(n):
count = 0
for i in range(n):
for j in range(10):
count += 1
return count
O loop mais interno é executado 10 vezes (número constante de vezes). Sendo assim, o total de
operações é T(n) = 10n + 1, o que resulta em O(n).
Ex: Considere esse código em que o loop interno executa um número variável de vezes.
def ex5(n):
count = 0
for i in range(n) :
for j in range(i+1) :
count += 1
return count
Note que quando i = 1, o loop interno executa uma vez, quando n = 2, o loop interno executa duas
vezes, quando n = 3, o loop interno executa 3 vezes, e assim sucessivamente. Assim, o número de
vezes que a variável count é incrementada é igual a: 1 + 2 + 3 + 4 + + … n
Devemos resolver esse somatório para calcular a complexidade dessa função.
n
∑k
k=1
o que implica em
(k +1)2−k 2=2 k +1
n n
2 2
Assim, ∑ [(k +1) −k ]=∑ [2 k +1] . Porém, o lado esquerdo é uma soma telescópica e temos:
k=1 k=1
1 2
T ( n)= (n + n)+1
2
Essa função calcula quantas vezes o número pode ser dividido por 2. Por exemplo, considere a
entrada n = 16. Em cada iteração esse valor será dividido por 2, até que atinja o zero.
16 → 8 → 4 → 2 → 1
Se n = 25, temos:
25 → 12 → 6 → 3 → 1
Se n = 40, temos:
40 → 20 → 10 → 5 → 2 → 1
Portanto, o número de iterações do loop é log 2 n. Dentro do loop existem duas instruções, portanto
neste caso teremos:
def ex7(n):
count = 0
for i in range(n):
count += ex6(n)
return count
Note que, como a função ex6(n) tem complexidade logarítmica, e o loop tem n iterações, temos que
a complexidade da função em questão é O(n log2 n).
a = 5
b = 6
c = 10
for i in range(n):
for j in range(n):
x = i * i
y = j * j
z = i * j
for k in range(n):
w = a * k + 45
v = b * b
d = 33
Iniciamos com os loops aninhados (i e j), onde temos 3 operações de ordem constante, resultando
em 3n2 operações, pois são n operações no loop mais interno vezes as n vezes do loop mais externo.
No segundo loop (k), temos 2 operações de ordem constante, o que resulta em 2n operações. Por
fim, há 4 operações de tempo constante fora dos loops. Sendo assim, temos:
2
T ( n)=3 n +2 n+4
Quando temos uma expressão polinomial, é fácil perceber que o termo com o maior grau domina os
demais. Neste caso, o termo dominante é quadrático, portanto a complexidade do algoritmo em
questão é O(n2 ) .
Ex: Escreva duas funções para encontrar o menor elemento de uma lista, uma que compara cada
elemento com cada outro elemento da lista e outra que percorre a lista uma única vez. Calcule a
complexidade de cada um deles, analisando o pior caso.
a) Vamos analisar o algoritmo menor_A: note que, para cada elemento x da lista L, ele verifica se x
é menor ou igual a todos os demais. Ele faz a marcação com o número 1 na posição de x na lista
menor. Se x for menor ou igual a todos os elementos de L, teremos exatamente n 1’s na lista menor,
o que fará com que a soma dos elementos de L seja igual a n.
Assim, temos:
T(n) = 1 + n(2 + n) = n2 + 2n + 1
Assim, temos:
T(n) = 3 + 2n
Imagine que temos 3 hastes (A, B e C) e inicialmente n discos de tamanhos distintos empilhados na
haste A, de modo que discos maiores não podem ser colocados acima de discos menores.
O objetivo consiste em mover todos os discos para uma outra haste. Há apenas duas regras:
Utilizando uma abordagem recursiva, note que são 3 movimentos para os dois menores discos, 1
para o maior e mais 3 movimentos para os dois menores
Se n= 5, teremos 15 + 1 + 15 = 31 movimentos
T n=2T n−1 +1
T 1 =1
onde Tn denota o número de instruções necessárias para resolvermos o problema das n torres de
Hanói. Porém, se quisermos descobrir o número de movimentos para n = 100, devemos calcular
todos os termos da sequência de 2 até 100.
Como resolver essa recorrência, ou seja, obter uma fórmula fechada? Vamos expandir a recorrência.
T1 = 1
T2 = 2T1 + 1
T3 = 2T2 + 1
T4 = 2T3 + 1
T5 = 2T4 + 1
T6 = 2T5 + 1
…
Tn-2 = 2Tn-3 + 1
Tn-1 = 2Tn-2 + 1
Tn = 2Tn-1 + 1
A ideia consiste em somar tudo do lado esquerdo e somar tudo do lado direito e utilizar a igualdade
para chegar em uma expressão fechada. Porém, gostaríamos que a soma fosse telescópica, para
simplificar os cálculos. Partindo de baixo para cima, note que para o termo T n-1 ser cancelado, a
penúltima equação precisa ser multiplicada por 2. Para que o termo T n-2 seja cancelado, a
antepenúltima equação precisa ser multiplicada por 22. E assim sucessivamente, o que nos leva ao
seguinte conjunto de equações:
2n-1T1 = 2n-1
2n-2T2 = 2n-1T1 + 2n-2
2n-3T3 = 2n-2T2 + 2n-3
2n-4T4 = 2n-3T3 + 2n-4
2n-5T5 = 2n-4T4 + 2n-5
2n-6T6 = 2n-5T5 + 2n-6
…
22Tn-2 = 23Tn-3 + 22
2Tn-1 = 22Tn-2 + 2
Tn = 2Tn-1 + 1
Somando todas as linhas, temos uma soma telescópica, pois os mesmos termos aparecem do lado
esquerdo e direto das igualdades, o que resulta em:
Note que 2k+1 =2k 2 , o que implica em 2k+1 =2k +2k , e portanto, 2k =2 k+1−2k .
Portanto, esse é a fórmula fechada para o número de movimentos necessários para resolver a torre
de Hanói com n discos. Trata-se de um algoritmo exponencial. Por exemplo, se n = 100, o número
de movimentos a ser executados é:
1267650600228229401496703205376
Fazendo um rápido teste, a função a seguir mede o tempo gasto pelo Python para executar uma
única instrução:
import time
def tempo():
inicio = time.time()
x = 1 + 2 + 3
print(x)
fim = time.time()
return(fim-inicio)
o que é igual a
Sabendo que a idade do planeta Terra é estimada em 4.543 × 10 9 anos, se esse programa tivesse sua
execução iniciada no momento da criação do planeta, estaria executando até hoje. Estima-se que o
Big Bang (origem do universo) tenha ocorrido a cerca de 13 bilhões de anos. Isso é menos tempo do
que seria necessário para resolver a Torre de Hanói com 100 discos. Por essa razão, dizemos que
algoritmos exponenciais são inviáveis computacionalmente, pois eles só podem ser executados para
valores muito pequenos de n.
Em Python, é muito comum utilizarmos funções nativas da linguagem para manipulas listas, vetores
e matrizes. A seguir apresentamos uma lista com algumas das principais funções que operam sobre
listas e sua respectiva complexidade.
Uma tarefa fundamental na computação consiste em dado uma lista e um valor qualquer, verificar
se aquele valor pertence a lista ou não. Essa funcionalidade é usada por exemplo em qualquer
sistema que exige o login de um usuário (para verificar se o CPF da pessoa está cadastrada). Faça
uma função que, dada uma lista de inteiros L e um número inteiro x, verifique se x está ou não em
L. A função deve retornar o índice do elemento (posição) caso ele pertença a ele ou o valor lógico
False se ele não pertence a L. (isso equivale ao operador in de Python)
if achou:
return pos
else:
return achou
Vamos analisar a complexidade da busca sequencial no pior caso, ou seja, quando o elemento a ser
buscado encontra-se na última posição do vetor. Por exemplo,
L = [3, 1, 8, 2, 9, 6, 7] e x=7
Note que o loop executa n - 1 vezes a instrução de incremento no valor de i e uma vez as duas
instruções para atualizar os valores de achou e pos.
T(n) = 2 + n – 1 + 2 = n + 3
A busca binária requer uma lista ordenada de elementos para funcionar. Ela imita o processo que
nós utilizamos para procurar uma palavra no dicionário. Como as palavras estão ordenadas, a ideia
é abrir o dicionário mais ou menos no meio. Se a palavra que desejamos inicia com uma letra que
vem antes, então nós já descartamos toda a metade final do dicionário (não precisamos procurar lá,
pois é certeza que a palavra estará na primeira metade.
No algoritmo, temos uma lista com números ordenados. Basicamente, a ideia consiste em acessar o
elemento do meio da lista. Se ele for o que desejamos buscar, a busca se encerra. Caso contrário, se
o que desejamos é menor que o elemento do meio, a busca é realizada na metade a esquerda. Senão,
a busca é realizada na metade a direita. A seguir mostramos um script em Python que implementa a
versão recursiva da busca binária.
# Função recursiva (ela chama a si própria)
def binary_search(L, x, ini, fim):
meio = ini + (fim - ini) // 2
if ini > fim:
return -1 # elemento não encontrado
elif L[meio] == x:
return meio
elif L[meio] > x:
print('Buscar na metade inferior')
return binary_search(L, x, ini, meio-1)
else:
print('Buscar na metade superior')
return binary_search(L, x, meio+1, fim)
Uma comparação enter o pior caso da busca sequencial e da busca binária, mostra a significativa
diferença entre os métodos. Na busca sequencial, faremos n acessos para encontrar o valor
procurado na última posição. Costuma-se dizer que o custo é O(n) (é da ordem de n, ou seja, linear).
Na busca binária, como a cada acesso descartamos metade das amostras restantes. Supondo, por
motivos de simplificação, que o tamanho do vetor n é uma potência de 2, ou seja, n = 2m, note que:
Acessos Descartados
m=1 → n/2
m=2 → n/4
m=3 → n/8
m=4 → n/16
e assim sucessivamente. É possível notar um padrão?
Quantos acessos devemos realizar para que descartemos todo o vetor? Devemos ter n / 2m = 1, o que
significa ter n = 2m , o que implica em m = log 2 n, ou seja, temos um custo O(log2 n) o que é bem
menor do que n quando n cresce muito, pois a função log(n) tem uma curva de crescimento bem
mais lento do que a função linear n. Veja que a derivada (taxa de variação) da função linear n é
constante e igual a 1 sempre. A derivada da função log(n) é 1/n, ou seja, quando n cresce, a taxa de
variação, que é o que controla o crescimento da função, decresce.
Na prática, isso significa que em uma lista com 1024 elementos, a busca sequencial fará no pior
caso 1023 acessos até encontrar o elemento desejado. Na busca binária, serão necessários apenas
log2 1024 = 10 acessos, o que corresponde a aproximadamente 1% do necessário na busca
sequencial! É uma ganho muito grande.
Porém, na busca binária precisamos gastar um tempo para ordenar a lista! Para isso precisaremos de
algoritmos de ordenação, o que é o assunto da nossa próxima aula. Bons estudos e até mais.
Aula 3 - Algoritmos de ordenação de dados
Ser capaz de ordenar os elementos de um conjunto de dados é uma das tarefas básicas mais
requisitadas por aplicações computacionais. Como exemplo, podemos citar a busca binária, um
algoritmo de busca muito mais eficiente que a simples busca sequencial. Buscar elementos em
conjuntos ordenados é bem mais rápido do que em conjuntos desordenados. Existem diversos
algoritmos de ordenação, sendo alguns mais eficientes do que outros. Neste curso, iremos
apresentar alguns deles: Bubblesort, Selectionsort, Insertionsort, Quicksort e Mergesort.
Bubblesort
O algoritmo Bubblesort é uma das abordagens mais simplistas para a ordenação de dados. A ideia
básica consiste em percorrer o vetor diversas vezes, em cada passagem fazendo flutuar para o topo
da lista (posição mais a direita possível) o maior elemento da sequência. Esse padrão de
movimentação lembra a forma como as bolhas em um tanque procuram seu próprio nível, e disso
vem o nome do algoritmo (também conhecido como o método bolha)
Embora no melhor caso esse algoritmo necessite de apenas n operações relevantes, onde n
representa o número de elementos no vetor, no pior caso são feitas n 2 operações. Portanto, diz-se
que a complexidade do método é de ordem quadrática. Por essa razão, ele não é recomendado para
programas que precisem de velocidade e operem com quantidade elevada de dados. A seguir
veremos uma implementação em Python desse algoritmo.
import numpy as np
import time
# Início do script
n = 5000
X = list(np.random.random(n))
# Imprime vetor
print('Vetor não ordenado: ')
print(X)
print()
# Aplica Bubblesort
inicio = time.time()
BubbleSort(X)
fim = time.time()
4a passagem
5a passagem
6a passagem
7a passagem
8a passagem
9a passagem
Fim: garantia de que vetor está ordenado só é obtida após todos os passos.
Observando a função definida anteriormente, note que no pior caso o segundo loop vai de zero a i,
sendo que na primeira vez i = n – 1, na segunda vez i = n – 2 e até i = 0. Sendo assim, o número de
operações é dado por:
T(n) = ( n + (n – 1) + (n - 2) + … + 1 )
Já vimos na aula anterior que o somatório 1 + 2 + … + n é igual a n(n + 1)/2. Assim, temos:
n(n+1) n2+ n
T ( n)= =
2 2
No melhor caso, é possível fazer uma pequena modificação no Bubblesort para contar quantas
inversões (trocas) ele realiza. Dessa forma, se uma lista já está ordenada e o Bubblesort não realiza
nenhuma troca, o algoritmo pode terminar após o primeiro passo. Com essa modificação, se o
algoritmo encontra uma lista ordenada, sua complexidade é O(n), pois ele percorre a lista de n
elementos uma única vez. Porém, para fins didáticos, a versão apresentada acima tem complexidade
2
O(n ) mesmo no melhor caso, pois não faz a checagem de quantas inversões são realizadas.
Finalmente, no caso médio, precisamos calcular a média dos custos entre todos os casos possíveis.
O primeiro caso (de menor custo) ocorre quando o laço mais externo realiza uma única iteração e o
último caso (de maior custo) ocorre quando o laço externo realiza o número máximo de iterações,
isto é, n-1 iterações. Como todos os casos são igualmente prováveis, isto é, todas as diferentes
configurações do vetor de entrada têm a mesma probabilidade de ocorrer (distribuição uniforme),
então a probabilidade de cada caso é de 1/(n – 1) e a média é dada por:
n −1
1
∑ f (k)
n−1 k =1
onde f(k) denota o número de execuções do loop mais externo para a k-ésima configuração.
Lembrando que quando k = 1 o Bubblesort realiza uma única passagem na lista L, quando k = 2 o
Bubblesort realiza duas passagens na lista L, e assim sucessivamente. Dessa forma, podemos definir
a função f(k) de maneira a contar o número de trocas em cada configuração possível como:
k i
f (k )=∑ ∑ 1
i=1 j=1
uma vez que o loop mais interno realiza i trocas, sendo que esse número de trocas depende de qual
configuração estamos, ou sjea, quanto maior for o k, mais vezes o loop mais interno será executado.
n−1 k i
1
T ( n)= ∑
n−1 k=1 (∑ ∑ )
i=1 j=1
1
n−1 k
1
T (n)= ∑
n−1 k=1 ( ) ∑i
i=1
n−1 n−1
T ( n)=
1
2(n−1) [∑ k=1
2
k +∑ k
k=1
] (*)
Vamos chamar o primeiro somatório de A e o segundo de B. O valor de B é facilmente calculado
pois sabemos que 1 + 2 + 3 + … + n – 1 = n(n-1)/2. Vamos agora calcular o valor de A, dado por:
n−1
A=∑ k 2
k=1
(k +1)3=k 3+3 k 2+ 3 k +1
Note que o somatório do lado esquerdo é uma soma telescópica, ou seja, vale n 3 – 1 (é a diferença
entre o último elemento e o primeiro, uma vez todos os termos intermediários se cancelam:
n−1
3 2 n(n−1)
n −1=3 ∑ k +3 +n−1
k=1 2
3 3 n(n−1)
n−1 n −1−n+1− 3 2
2 2 n −n n( n−1) n(n −1) n(n−1) n(n−1)(n+1) n(n−1)
∑k = 3
=
3
−
2
=
3
−
2
=
3
−
2
k=1
1 n(n−1)(n+ 1)
T ( n)=
2(n−1) 3( )
Cancelando os termos n – 1, finalmente chegamos em:
1 1 2
T (n)= n( n+1)= (n +n)
6 6
Selection sort
A ordenação por seleção é um método baseado em se passar o menor valor do vetor para a primeira
posição mais a esquerda disponível, depois o de segundo menor valor para a segunda posição e
assim sucessivamente, com os n – 1 elementos restantes. Esse algoritmo compara a cada iteração
um elemento com os demais, visando encontrar o menor. A complexidade desse algoritmo será
sempre de ordem quadrática, isto é o número de operações realizadas depende do quadrado do
tamanho do vetor de entrada. Algumas vantagens desse método são: é um algoritmo simples de ser
implementado, não usa um vetor auxiliar e portanto ocupa pouca memória, é um dos mais velozes
para vetores pequenos. Como desvantagens podemos citar o fato de que ele não é muito eficiente
para grandes vetores.
import numpy as np
import time
# Início do script
n = 5000
X = list(np.random.random(n))
# Imprime vetor
print('Vetor não ordenado: ')
print(X)
print()
# Aplica Bubblesort
inicio = time.time()
SelectionSort(X)
fim = time.time()
Observando a função definida anteriormente, note que no pior caso o segundo loop vai de i +1 até
n, sendo que na primeira vez i = 1, na segunda vez i = 2 e até i = n - 1. Sendo assim, o número de
operações é dado por:
Note que:
5
∑ 1=(5−2)+1=4
i=2
o que resulta em O(n2 ) . Uma desvantagem do algoritmo Selectionsort é que mesmo no melhor
caso, para encontrar o menor elemento, devemos percorrer todo o restante do vetor no loop mais
interno. Isso significa que, mesmo no melhor caso, a complexidade é O(n2 ) Isso implica que no
caso médio, a complexidade também é O(n2 ) .
1a passagem: [5, 2, 13, 7, -3, 4, 15, 10, 1, 6] → [-3, 2, 13, 7, 5, 4, 15, 10, 1, 6]
2a passagem: [-3, 2, 13, 7, 5, 4, 15, 10, 1, 6] → [-3, 1, 13, 7, 5, 4, 15, 10, 2, 6]
3a passagem: [-3, 1, 13, 7, 5, 4, 15, 10, 2, 6] → [-3, 1, 2, 7, 5, 4, 15, 10, 13, 6]
4a passagem: [-3, 1, 2, 7, 5, 4, 15, 10, 13, 6] → [-3, 1, 2, 4, 5, 7, 15, 10, 13, 6]
5a passagem: [-3, 1, 2, 4, 5, 7, 15, 10, 13, 6] → [-3, 1, 2, 4, 5, 7, 15, 10, 13, 6]
6a passagem: [-3, 1, 2, 4, 5, 7, 15, 10, 13, 6] → [-3, 1, 2, 4, 5, 6, 15, 10, 13, 7]
7a passagem: [-3, 1, 2, 4, 5, 6, 15, 10, 13, 7] → [-3, 1, 2, 4, 5, 6, 7, 10, 13, 15]
8a passagem: [-3, 1, 2, 4, 5, 6, 7, 10, 13, 15] → [-3, 1, 2, 4, 5, 6, 7, 10, 13, 15]
9a passagem: [-3, 1, 2, 4, 5, 6, 7, 10, 13, 15] → [-3, 1, 2, 4, 5, 6, 7, 10, 13, 15]
Fim: garantia de que vetor está ordenado só é obtida após todos os passos.
Insertion sort
Insertion sort, ou ordenação por inserção, é o algoritmo de ordenação que, dado um vetor inicial
constrói um vetor final com um elemento de cada vez, uma inserção por vez. Assim como
algoritmos de ordenação quadráticos, é bastante eficiente para problemas com pequenas entradas,
sendo o mais eficiente entre os algoritmos desta ordem de classificação.
Podemos fazer uma comparação do Insertion sort com o modo de como algumas pessoas organizam
um baralho num jogo de cartas. Imagine que você está jogando cartas. Você está com as cartas na
mão e elas estão ordenadas. Você recebe uma nova carta e deve colocá-la na posição correta da sua
mão de cartas, de forma que as cartas obedeçam a ordenação.
A cada nova carta adicionada a sua mão de cartas, a nova carta pode ser menor que algumas das
cartas que você já tem na mão ou maior, e assim, você começa a comparar a nova carta com todas
as cartas na sua mão até encontrar sua posição correta. Você insere a nova carta na posição correta,
e, novamente, sua mão é composta de cartas totalmente ordenadas. Então, você recebe outra carta e
repete o mesmo procedimento. Então outra carta, e outra, e assim por diante, até você não receber
mais cartas. Esta é a ideia por trás da ordenação por inserção. Percorra as posições do vetor,
começando com o índice zero. Cada nova posição é como a nova carta que você recebeu, e você
precisa inseri-la no lugar correto no sub-vetor ordenado à esquerda daquela posição.
import numpy as np
import time
# Início do script
n = 5000
X = list(np.random.random(n))
# Imprime vetor
print('Vetor não ordenado: ')
print(X)
print()
# Aplica Bubblesort
inicio = time.time()
InsertionSort(X)
fim = time.time()
Observando a função definida anteriormente, note que no pior caso a posição correta do pivô será
sempre em k = 0 de modo que o segundo loop vai ter de percorrer todo vetor (de i até 0), sendo que
na primeira vez i = 1, na segunda vez i = 2 e até i = n - 1. Sendo assim, o número de operações é
dado por:
n−1 i
(
T (n)=∑ 1+ ∑ 2
i=1 j=0
)
Expandindo os somatórios, temos:
n−1 n−1 n−1 n−1
T ( n)=∑ 1+2 ∑ (i+1)=n−1+ 2 ∑ i+2 ∑ 1
i=1 i=1 i=1 i=1
O valor do primeiro somatório é n(n-1)/2 e o valor do segundo somatório é n – 1, o que nos leva a:
n(n−1) 2 2
T ( n)=n−1+ 2 +2(n−1)=3(n−1)+ n −n=n +2 n−3
2
Para o melhor caso, note que o pivô sempre está na posição correta, sendo que o loop mais interno
não será executado nenhuma vez. Assim temos que dentro do loop mais externo apenas uma
instrução será executada, o que nos leva a:
n−1
T (n)=∑ 1=n−1
i=1
mostrando que a complexidade é linear, ou seja, O(n) . Para o caso médio, podemos aplicar uma
estratégia muito similar àquela adotada na análise do Bubblesort. A ideia consiste em considerar que
todos os casos são igualmente prováveis e calcular uma média de todos eles. Assim, temos:
n−1 k i
T ( n)=
1
n−1 ∑
k=1
[∑ ( ) ]
i=1
1+ ∑ 2
j=0
Do pior caso, sabemos que a soma entre colchetes pode ser simplificada, o que nos leva a:
n−1
1
T ( n)= ∑ (k 2+2 k −3)
n−1 k=1
Deixaremos o restante dos cálculos para o leitor (basta seguir os mesmos passos algébricos da
análise do caso médio do Bubblesort). É possível verificar então que a complexidade é O(n ²) .
1a passagem: [5, 2, 13, 7, -3, 4, 15, 10, 1, 6] → [2, 5, 13, 7, -3, 4, 15, 10, 1, 6]
2a passagem: [2, 5, 13, 7, -3, 4, 15, 10, 1, 6] → [2, 5, 13, 7, -3, 4, 15, 10, 1, 6]
3a passagem: [2, 5, 13, 7, -3, 4, 15, 10, 1, 6] → [2, 5, 7, 13, -3, 4, 15, 10, 1, 6]
4a passagem: [2, 5, 7, 13, -3, 4, 15, 10, 1, 6] → [-3, 2, 5, 7, 13, 4, 15, 10, 1, 6]
5a passagem: [-3, 2, 5, 7, 13, 4, 15, 10, 1, 6] → [-3, 2, 4, 5, 7, 13, 15, 10, 1, 6]
6a passagem: [-3, 2, 4, 5, 7, 13, 15, 10, 1, 6] → [-3, 2, 4, 5, 7, 13, 15, 10, 1, 6]
7a passagem: [-3, 2, 4, 5, 7, 13, 15, 10, 1, 6] → [-3, 2, 4, 5, 7, 10, 13, 15, 1, 6]
8a passagem: [-3, 2, 4, 5, 7, 10, 13, 15, 1, 6] → [-3, 1, 2, 4, 5, 7, 10, 13, 15, 6]
9a passagem: [-3, 1, 2, 4, 5, 7, 10, 13, 15, 6] → [-3, 1, 2, 4, 5, 6, 7, 10, 13, 15]
Fim: garantia de que vetor está ordenado só é obtida após todos os passos.
Recursão
Dizemos que uma função é recursiva se ela é definida em termos dela mesma. Em matemática e
computação uma classe de objetos ou métodos exibe um comportamento recursivo quando pode ser
definido por duas propriedades:
1. Um caso base: condição de término da recursão em que o processo produz uma resposta.
2. Um passo recursivo: um conjunto de regras que reduz todos os outros casos ao caso base.
def fib(n):
if n == 0 or n == 1:
return 1
else:
return fib(n-1) + fib(n-2)
Os dois próximos algoritmos de ordenação que iremos estudar são exemplos de abordagens
recursicas, onde a cada passo, temos um subproblema menor para ser resolvido pela mesma função.
Por isso, a função é definida em termos de si própria.
Quicksort
O algoritmo Quicksort segue o paradigma conhecido como “Dividir para Conquistar” pois ele
quebra o problema de ordenar um vetor em subproblemas menores, mais fáceis e rápidos de serem
resolvidos. Primeiramente, o método divide o vetor original em duas partes: os elementos menores
que o pivô (tipicamente escolhido como o primeiro ou último elemento do conjunto). O método
então ordena essas partes de maneira recursiva. O algoritmo pode ser dividido em 3 passos
principais:
2. Particionamento: reorganizar o vetor de modo que todos os elementos menores que o pivô
apareçam antes dele (a esquerda) e os elementos maiores apareçam após ele (a direita). Ao término
dessa etapa o pivô estará em sua posição final (existem várias formas de se fazer essa etapa)
a) 2 metades
b) 4 metades
Note que a metade 3 possui apenas um único elemento: [7] → já está ordenadas
c) 4 metades: Note que cada uma das 4 metades restantes contém um único elemento e portanto já
estão ordenadas. Fim.
No Quicksort há significativas diferenças entre o caso melhor e o pior caso. Veremos primeiramente
o pior caso.
a) Pior caso: acontece quando o pivô é sempre o maior ou menor elemento, o que gera partições
totalmente desbalanceadas:
Na primeira chamada recursiva, temos uma lista de tamanho n, então para criar as novas listas L,
teremos n – 1 elementos na primeira lista, o pivô e uma lista vazia.
Note que:
T(n) = n + T(n – 1)
T(n - 1) = n - 1 + T(n – 2)
T(n - 2) = n - 2 + T(n – 3)
T(n - 3) = n - 3 + T(n – 4)
…
T(2) = 2 + T(1)
T(1) = 1 + T(0)
onde T(0) = 0.
n(n+1) n2+ n
T ( n)= =
2 2
b) Melhor caso: acontece quando as partições tem exatamente o mesmo tamanho, ou seja, são n/2
elementos, o pivô e mais n/2 - 1 elementos.
( n2 )+ n
T ( n)=2 T
n n
T ( )=2 T ( )+ n
2 4
n n
T ( )=2 T ( )+ n
4 8
n n
T ( )=2 T ( ) +n
8 16
…
Voltando com as substituições, temos:
[[[
T ( n)=2 2 2 2T ( 16n )+n ]+n ]+ n]+n=2 T ( 2n )+4 n
4
4
T ( n)=2 T
k
( 2n )+ kn
k
Para que tenhamos T(1), é preciso que n = 2k, ou seja, k =log 2 n . Quando k =log 2 n , temos:
log 2 n
T ( n)=2 T (1)+ n log 2 n=n T (1)+n log 2 n
Como T(1) é constante (pois não depende de n), temos finalmente que a complexidade do Quicksort
no melhor caso é O(n log 2 n) .
Veremos a seguir que o fato de que o pior caso é O(n2 ) , não é um problema, pois o caso médio
está muito mais próximo do melhor caso do que do pior caso. Para considerar o caso médio,
suponha que alternemos as recorrências de melhor caso, M(n), com pior caso, P(n). Então, iniciando
com o melhor caso:
T ( n)=2 T ( n2 )+ n
Expandindo agora o pior caso, temos:
n n
T (n)=2 T
[( ) ] 2
−1 + +n
2
T ( n)=2 T ( n2 −1)+O(n)
que é uma relação de recorrência da mesma forma que a obtida para o melhor caso. A solução da
recorrência implica que a complexidade do caso médio é O(n log 2 n) .
Uma estratégia empírica que, em geral, melhora o desempenho do algoritmo Quicksort consiste em
escolher como pivô a mediana entre o primeiro elemento, o elemento do meio e o último elemento.
O algoritmo Mergesort utiliza a abordagem Dividir para Conquistar. A ideia básica consiste em
dividir o problema em vários subproblemas e resolver esses subproblemas através da recursividade
e depois conquistar, o que é feito após todos os subproblemas terem sido resolvidos através da união
das resoluções dos subproblemas menores.
Trata-se de um algoritmo recursivo que divide uma lista continuamente pela metade. Se a lista
estiver vazia ou tiver um único elemento, ela está ordenada por definição (o caso base). Se a lista
tiver mais de um elemento, dividimos a lista e invocamos recursivamente um Mergesort em ambas
as metades. Assim que as metades estiverem ordenadas, a operação fundamental, chamada de
intercalação, é realizada. Intercalar é o processo de pegar duas listas menores ordenadas e
combiná-las de modo a formar uma lista nova, única e ordenada.
A figura a seguir ilustra as duas fases principais do algoritmo Mergesort: a divisão e a intercalação.
if len(L) > 1:
meio = len(L)//2
LE = L[:meio] # Lista Esquerda
LD = L[meio:] # Lista Direita
# Início do script
n = 5000
X = list(np.random.random(n))
# Imprime vetor
print('Vetor não ordenado: ')
print(X)
print()
# Aplica Bubblesort
inicio = time.time()
MergeSort(X)
fim = time.time()
Quando a função MergeSort retorna da recursão (após a chamada nas metades esquerda, LE, e
direita, LD), elas já estão ordenadas. O resto da função (linhas 11-31) é responsável por intercalar as
duas listas ordenadas menores em uma lista ordenada maior. Note que a operação de intercalação
coloca um item por vez de volta na lista original (L) ao tomar repetidamente o menor item das listas
ordenadas.
Ex: Mostre o passo a passo da ordenação do vetor a seguir pelo algoritmo MergeSort
meio = 10 // 2 = 5
meio = 5 // 2 = 2
meio = 2 // 2 = 1 ou meio = 3 // 2 = 1
meio = 2 // 2 = 1
Parte 2: Intercalar listas (Merge) – as últimas a serem divididas serão as primeiras a fazer o merge
Os três passos úteis dos algoritmos de dividir para conquistar, ou divide and conquer, que se
aplicam ao MergeSort são:
1. Dividir: Calcula o ponto médio do sub-arranjo, o que demora um tempo constante O(1);
2. Conquistar: Recursivamente resolve dois subproblemas, cada um de tamanho n/2, o que
contribui com T(n/2) + T(n/2) para o tempo de execução;
3. Combinar: Unir os sub-arranjos em um único conjunto ordenado, que leva o tempo O(n);
O(1), se n=1
T ( n)=
{
2T
n
2 ()
+O(n), se n> 1
( n2 )+ n
T ( n)=2 T
n n
T ( )=2 T ( )+ n
2 4
n n
T ( )=2 T ( )+ n
4 8
n n
T ( )=2 T ( ) +n
8 16
…
[[[
T ( n)=2 2 2 2T ( 16n )+n ]+n ]+ n]+n=2 T ( 2n )+4 n
4
4
T ( n)=2 T
k
( 2n )+ kn
k
Para que tenhamos T(1), é preciso que n = 2k, ou seja, k =log 2 n . Quando k =log 2 n , temos:
log 2 n
T ( n)=2 T (1)+ n log 2 n=n T (1)+n log 2 n
Como T(1) é O(1), temos que a complexidade do Quicksort no melhor caso é O(n log 2 n) .
Uma das maiores limitações do algoritmo MergeSort é que esse método passa por todo o longo
processo mesmo se a lista L já estiver ordenada. Por essa razão, a complexidade de melhor caso é
idêntica a complexidade de pior caso, ou seja, O(n log n) .
Para o caso de n muito grande, e listas compostas por números gerados aleatoriamente, pode-se
mostrar que o número médio de comparações realizadas pelo algoritmo MergeSort é
aproximadamente α n menor que o número de comparações no pior caso, onde:
∞
1
α =−1+ ∑ k
≈0.2645
k=0 2 +1
Em resumo, a tabela a seguir faz uma comparação das complexidades dos cinco algoritmos de
ordenação apresentados aqui, no pior caso, caso médio e melhor caso.
Algoritmo Tempo
Melhor Médio Pior
MergeSort O(n log n) O(n log n) O(n log n)
2
QuickSort O(n log n) O(n log n) O(n )
2 2
BubbleSort O(n) O(n ) O(n )
InsertionSort O(n) O(n2 ) O(n2 )
2 2 2
SelectionSort O(n ) O(n ) O(n )
Como pode ser visto, fica claro que os dois melhores algoritmos são O QuickSort e o MergeSort.
Existem vários outros algoritmos de ordenação que não apresentaremos aqui. Dentre eles, podemos
citar o HeapSort, o ShellSort e RadixSort. Para os interessados em aprender mais sobre o assunto, a
internet contém diversos materiais sobre algoritmos de ordenação.
Um Tipo Abstrato de Dados, ou TAD, é um tipo de dados definido pelo programador que especifica
um conjunto de variáveis que são utilizadas para armazenar informação e um conjunto de operações
bem definidas sobre essas variáveis. TAD’s são definidos de forma a ocultar a sua implementação,
de modo que um programador deve interagir com as variáveis internas a partir de uma interface,
definida em termos do conjunto de operações. A Figura a seguir ilustra essa ideia.
Um exemplo de TAD implementado nativamente pela linguagem Python são as listas. Uma lista em
Python consiste basicamente de uma variável composta heterogênea (pode armazenar informações
de tipos de dados distintos) utilizada para armazenar as informações mais um conjunto de operações
para manipular essa variável.
Como exemplo de operações encapsuladas em um TAD lista, temos:
Note que para o programador, a implementação desses processos fica encapsulada, sendo que não é
preciso saber os detalhes, basta conhecer a interface das funções, ou seja, quais os parâmetros
necessários para invocá-las. Por exemplo, na função L.insert(i, x) o primeiro parâmetro deve ser o
índice da posição do elemento na lista e o segundo parâmetro deve ser o valor a ser armazenado.
Há uma diferença entre os processos encapsulados em um TAD e funções genéricas. Por exemplo,
ao trabalharmos com uma lista L, em diversas ocasiões desejamos saber quantos elementos estão no
momento em L. Podemos utilizar a função
len(L)
para retornar essa informação. Porém, a função len() não está encapsulada na definição do TAD.
Trata-se de uma função genérica, como uma macro da linguagem, uma vez que ela opera não
somente sobre listas, mas outros tipos de coleções também (como conjuntos, dicionários,…)
Outro exemplo de TAD que podemos citar são os vetores definidos pelo pacote Numpy. Em Python,
vetores são variáveis compostas homogêneas (pois todos elementos devem ser do mesmo tipo de
dados: int ou float), com diversas funções encapsuladas. Algumas das principais são listadas aqui:
Um TAD é uma abstração, mas sua implementação em uma linguagem de programação é chamada
de classe. Uma classe é composta por um conjunto de atributos, que são as variáveis internas
utilizadas para armazenar informações, e um conjunto de métodos, que são as operações (funções)
utilizadas para processar seus atributos (variáveis internas). Uma instância de uma classe é o que
chamamos de objeto.
A ideia desse conceito é bastante simples. Por exemplo, podemos definir uma classe Carro, com os
seguintes atributos: nome, marca, cor, ano, km e valor. Podemos criar alguns métodos, como por
exemplo: muda_cor(nova_cor), muda_valor(novo_valor), verifica_marca(), verifica_km(), etc.
Quando definimos um objeto específico da classe Carro, temos uma instância dessa classe. Por
exemplo, um objeto dessa classe poderia ter as seguintes informações:
nome = ‘Onix’
marca = ‘Chevrolet’
cor = ‘Prata’
ano = 2020
km = 20000
valor = 55.600,00
Em resumo, a classe define a implementação do TAD, enquanto que o objeto é a variável alocada na
memória, que representa uma instância específica da classe.
Classes em Python
Iremos criar nossa primeira classe em Python utilizando como exemplo, o TAD que define uma
fração matemática. Lembe-se que toda fração é composta por dois números números: um
numerador e um denominador. O numerador pode assumir qualquer valor, mas o denominador não
pode ser zero.
Para que um objeto da classe Fracao possa ser criado, é necessário definirmos um método
construtor, que é a função chamada toda vez que um novo objeto for instanciado. Iremos apresentar
a segui a definição da Fracao com dois métodos: o construtor, que em Python deve se chamar
__init__ e receber como parâmetro o próprio objeto, denominado de self, e a função show() que
imprime na tela a fração em forma de string.
class Fracao:
# Construtor (usado para instanciar novos objetos)
def __init__(self, numerador, denominador):
self.num = numerador
self.den = denominador
# Imprime fração na tela como string
def show(self):
print('%s/%s' %(self.num, self.den))
Dessa forma, ao carregar o arquivo .py no ambiente, podemos criar um objeto da classe Fracao e
imprimir na tela como:
frac = Fracao(3, 5)
frac.show()
Note que se usarmos o comando print diretamente objeto, não iremos ver o conteúdo de suas
variáveis:
print(frac)
O comando print retorna o endereço na memória dessa instância específica da classe, ou seja, desse
objeto. Há uma maneira de dizer para o interpretador Python que quando fizermos uma referência
ao objeto dentro de um comando print, desejamos imprimir alguma informação interna como texto.
Trata-se do método __str__, que nesse caso pode ser definido como:
def __str__(self):
return str(self.num) + "/" + str(self.den)
Na verdade, estamos fazendo uma sobrecarga na função __str__ que já existe por padrão em todo
objeto criado em Python. Porém, por padrão, essa função imprime o endereço de memória do
objeto. Ao redefini-la, podemos imprimir as informações internas da maneira que desejarmos. A
classe então pode ser definida como:
class Fracao:
Vamos tentar calcular o produto entre duas matrizes com o operador *. Note que se executarmos:
f1 = Fraction(1,4)
f2 = Fraction(1,2)
f1 * f2
Traceback (most recent call last):
File "<pyshell#26>", line 1, in <module>
f1 * f2
TypeError: unsupported operand type(s) for +: 'Fraction' and
'Fraction'
veremos uma mensagem de erro. Isso porque precisamos dizer ao interpretador Python como
calcular o produto entre duas frações com o operador *. Para isso, devemos criar o método __mul__
Por exemplo, utilizar f1 * f2 será equivalente a chamar f1.__add__(f2).
Para testar o método, basta executar o arquivo .py em que se encontra a definição da classe Fracao,
para carregar as definições na memória e digitar os comandos no modo interativo:
f1 = Fracao(3, 5)
print(f1)
f2 = Fracao(2, 3)
print(f2)
f3 = f1 * f2
print(f3)
Vamos agora implementar uma função para calcular a soma de duas frações. Da mesma forma que
fizemos com a multiplicação, podemos criar a função __add__ para sobrecarregar o operador +.
Note que matematicamente a definição da soma é dada por:
a c ad +bc
+ =
b d bd
Lembre-se de executar o script para carregar as alterações feitas na classe para a memória.
f1 = Fracao(1, 4)
print(f1)
1/4
f2 = Fracao(1, 2)
print(f2)
1/2
f3 = f1 + f2
print(f3)
6/8
Note que o resultado está correto, mas a fração não encontra-se em sua forma simplificada. Se
dividirmos tanto o numerador quanto o denominador pelo MDC (máximo divisor comum) entre
eles, teremos a fração em sua forma simplificada. Vamos criar um método chamado simplifica, que
utiliza o algoritmos de Euclides para encontrar o MDC. O algoritmo de Euclides nos diz que o
MDC entre dois inteiros m e n é o próprio n se n divide m. Caso contrário, a resposta deve ser o
MDC de n e o resto da divisão de m por n. A função a seguir implementa a simplificação de uma
fração utilizando essa ideia:
f1 = Fracao(1, 4)
f2 = Fracao(1, 2)
f3 = f1 + f2
print(f3)
6/8
print(f3.simplifica())
3/4
Como podemos verificar se duas frações são iguais? Basta checarmos se as suas formas
simplificadas são idênticas, ou seja, os dois numeradores devem ser iguais e os denominadores
também devem ser iguais. Para que possamos utilizar o operador == para comparar frações, temos
que definir o nome da função como __eq__ (sobrecarga do operador ==).
f1 = Fracao(1, 2)
f2 = Fracao(2, 4)
f1 == f2
True
f3 = Fracao(3, 5)
f1 == f3
False
f2 == f3
False
Podemos continuar criando métodos para operar sobre frações, como por exemplo, subtração de
duas frações, inverter uma fração, elevar uma fração a uma potência, calcular a raiz quadrada de
uma fração, racionalizar uma fração (tornar o denominador inteiro), etc. Não iremos fazer isso aqui,
mas aos interessados, é um bom exercício para praticar a programação orientada a objetos na
linguagem Python.
Antes de passarmos para o próximo exemplo, uma observação importante: no exemplo anterior,
nada impede que alguém acesse as variáveis internas da classe (num e den) de maneira direta, pois
por padrão essas informações são públicas em Python. Se desejarmos torná-las privadas, ou seja,
elas só podem ser acessadas diretamente a partir de um método interno a classe, devemos adicionar
o prefixo __ (dois underscores antes do nome da variável). Em aplicações de pequeno porte, como
as que veremos no curso, não iremos nos preocupar muito com essa questão, pois podemos deixar
todas as variáveis públicas. Porém, ao tornar todas as informações públicas, é mais fácil ocorrer
acessos de fora, no sentido de que manter a consistência interna dos dados torna-se bem mais
complicado. No exemplo a seguir, adotaremos atributos privados. Para isso, devemos criar métodos
get e set, para serem interfaces que permitem ao desenvolvedor acessar as variáveis privadas. O
programador pode assim controlar quais tipos de valores podem ser atribuídos as variáveis internas,
o que é útil para evitar bugs e problemas indesejados no futuro. A seguir iremos fazer alguns
exercícios que envolvem outros exemplos numéricos de programação orientada a objetos.
Começaremos com uma classe para equações do segundo grau.
2
Fórmula de Bhaskara: Seja uma equação do segundo grau a x + b x+ c=0 , com a, b e c números
reais arbitrários. Então, as soluções x1 e x2 são dadas por:
2 2
−b− √ b −4 a c −b+ √ b −4 ac
x 1= e x 2=
2a 2a
Prova:
1. Partindo da equação do segundo grau a x 2+ b x+ c=0 , podemos dividir tudo por a (pois a não
pode ser zero) e isolar os termos em x, chegando em:
2 b c
x + x=−
a a
b2
2. Note que podemos completar o quadrado, adicionando em ambos os lados:
4 a2
2 b b2 b2 c
x + x+ 2 = 2 −
a 4a 4a a
Ex: Implemente uma classe em Python para representar uma equação do segundo grau. Ela deve ter
como atributos três números reais - a, b e c - que denotam os coeficientes da equação quadrática,
delta, x1 e x2. Inicialmente, delta, x1 e x2 recebem o valor nan (que significa Not a Number).
from math import sqrt
from math import isnan
class Equacao_Quadratica:
# Obtém valor de a
def getA(self):
return self.__a
# Obtém valor de b
def getB(self):
return self.__b
# Obtém valor de c
def getA(self):
return self.__c
if __name__ == '__main__':
# Cria equação do segundo grau
equacao = Equacao_Quadratica(1, -5, 6)
# Note que se tentarmos acessar o valor de a diretamente
# ocasionará um erro, pois variável é privada
# print(equacao.__a)
# Imprime na tela
print(equacao)
# Resolve a equação utilizando a fórmula de Bhaskara
print(equacao.resolve())
A seguir iremos discutir um pouco mais sobre os princípios da programação orientada a objetos
(POO). Na programação estruturada, os dados a serem manipulados são globais e diversas funções
operam sobre eles. Na orientação a objetos, como cada objeto tem seus próprios métodos, eles são
aplicados somente aos dados daquele objeto. O diagrama a seguir ilustra essa diferença fundamental
entre os paradigmas.
FONTE:https://www.devmedia.com.br/os-4-pilares-da-programacao-orientada-a-objetos/9264
1. Abstração: a ideia é que uma classe seja a implementação computacional de um TAD, com um conjunto
de variáveis que representam o estado interno e métodos que operam sobre esses dados
2. Encapsulamento: consiste em ocultar as variáveis que armazenam as informações internas dos objetos,
tornando-as acessíveis apenas através dos métodos. Assim, cria-se uma espécie de caixa preta com a qual
podemos interagir a partir de suas interfaces.
3. Herança: é basicamente um mecanismo da POO que permite criar novas classes, mais especializadas, a
partir de classes mais gerias já existentes. Essa característica é muito útil, pois promove um grande
reaproveitamento de código. Por exemplo, podemos definir uma superclasse Pessoa, que possui os atributos,
nome, peso, altura e data de nascimento. Em seguida, podemos criar uma subclasse Funcionário, que herda
os atributos e métodos já existentes em uma pessoa, e adiciona novos atributos como cargo, salário e ano de
admissão, além de métodos como receber_abono(), receber_promocao(), etc...
4. Polimorfismo: é o princípio pelo qual duas ou mais classes derivadas da mesma superclasse podem
invocar métodos que têm a mesma assinatura, mas comportamentos distintos. Em outras palavras, consiste
na alteração do funcionamento interno de um método herdado de um objeto pai. Isso significa que um
método com o mesmo nome em duas classes, pode ser definido de maneira diferente em cada uma delas. É a
ideia da sobrecarga dos operadores que vimos no exemplo da classe Fracao, em que utilizamos o operador +
e * de forma diferente do que eles funcionam com objetos da classe int ou float.
Para ilustrar os conceitos de herança e polimorfismo, veremos alguns exemplos práticos de como
implementá-los em Python a seguir.
Inicialmente, vamos definir uma classe CreditCard, que cria um cartão de crédito com base nos
atributos: cliente, banco, conta e limite. Além disso, criamos os métodos get() para acessar as
variáveis (que são privadas), além dos métodos compra, que adiciona um valor a fatura do cartão e
pagamento, que remove um valor da fatura do cartão.
class CreditCard:
def imprime_dados(self):
print('Cliente: %s' %self._cliente)
print('Banco: %s' %self._banco)
print('Conta: %s' %self._conta)
print('Limite: %s' %self._limite)
print()
# Obtém cliente
def get_cliente(self):
return self._cliente
# Obtém banco
def get_banco(self):
return self._banco
# Obtém conta
def get_conta(self):
return self._conta
# Obtém limite
def get_limite(self):
return self._limite
# Obtém fatura
def get_fatura(self):
return self._fatura
Para ilustrar o conceito de herança, iremos criar uma subclasse chamada PredatoryCreditCard, que
além dos atributos originais, contém uma taxa de juros anual, além de um método adicional
processa_mes(), que aplica os juros no final de cada mês. Além disso, para ilustra o conceito de
polimorfismo, o método compra será modificado para aplicar uma penalização de 10 reais sempre
que o valor da compra ultrapassar o limite de 1000 reais. Note que na definição da classe
CreditCard, os atributos possuem um único underscore (_) como prefixo. Isso indica ao Python, que
essas informações são protegidas (nem públicas, nem privadas). Variáveis protegidas são visíveis
não apenas dentro da classe base (superclasse), mas também dentro de todas as suas subclasses, ou
seja, aquelas classes mais especializadas que herdam da superclasse. Iremos considerar juros
próximos aos cobrados no Brasil, cerca de 300% ao ano!
def imprime_dados(self):
print('Cliente: %s' %self._cliente)
print('Banco: %s' %self._banco)
print('Conta: %s' %self._conta)
print('Limite: %.2f' %self._limite)
print('Juros/ano: %.2f' %self._juros)
print()
Aplicações matemáticas
Nesta seção, mostraremos algumas implementações baseadas em programação orientada a objetos
(POO) utilizando a linguagem Python em aplicações matemáticas. Iniciaremos com uma aplicação
para implementar sequências matemáticas como progressões aritméticas e geométricas.
Sequências e recorrências
Recorrências são sequências matemáticas que obedecem a uma lei de formação, ou seja, o próximo
elemento da série é uma função de um ou mais elementos anteriores. Existem diversas recorrências
que surgem naturalmente na matemática e outras ciências exatas como a física e a computação. Um
dos exemplos mais conhecidos de recorrência é a sequência de Fibonacci, em que dados os dois
primeiros termos iguais a 1, cada novo termo é computado pela soma dos 2 termos anteriores.
Def: Recorrência
Uma recorrência é uma regra que nos permite calcular um termo qualquer de uma sequência em
função de termos anteriores.
T n+1=T n +r
T 1 =a
T n+1=qT n
T 1 =a
Iremos criar uma superclasse Progression que gera todo o conjuntos dos números naturais: 0, 1, 2, 3
,… etc. Em seguida, iremos criar 3 subclasses, uma para cada sequência específica: progressão
aritmética, progressão geométrica e sequência de Fibonacci, conforme ilustra a figura a seguir.
Para essa aplicação em particular, iremos construir um tipo de classe especial: um iterador. Toda
classe que implementa um iterador deve conter um método __next__, que retorna o próximo da
coleção ou gera uma exceção do tipo StopIteration, que indica que não há mais elementos. A seguir
apresentamos a implementação da superclasse Progression.
class Progression:
if __name__ == '__main__':
# Instancia objeto
sequencia = Progression(0)
print(sequencia.gera_sequencia(20))
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19]
Iremos agora definir uma subclasse que implementa uma progressão aritmética. Note que o único
método que precisará ser modificado é o _avancar, que aplica a regra para geração do próximo
termo da PA. Essa é a vantagem da herança, que permite um grande reuso de código.
Note que utilizamos o construtor da classe pai, adicionando apenas a variável razão, que armazena o
incremento dado a cada passo na PA. Note que estamos utilizando aqui o conceito de polimorfismo
para sobrecarregar o método _avancar herdado da classe pai. É o mesmo método, mas com
comportamentos distintos em cada classe.
A classe definida a seguir mostra a implementação de uma progressão geométrica. Assim como a
PA, iremos herdar da classe Progression.
if __name__ == '__main__':
# Instancia objeto
a0 = int(input('Entre com o valor de a0: '))
razao = float(input('Entre com o valor da razão: '))
n = int(input('Enter com o número de termos: '))
PG = GeometricProgression(a0, razao)
print(PG.gera_sequencia(n))
A classe definida a seguir mostra a implementação de uma sequencia de Fibonacci, também herdade
da classe pai Progression (superclasse).
if __name__ == '__main__':
# Instancia objeto
n = int(input('Entre com o número de termos: '))
Fib = FibonacciProgression()
print(Fib.gera_sequencia(n))
Ex: Implemente uma classe derivada de Progression para gerar os termos de uma recorrência linear
do tipo:
T n+2=aT n+1 +b T n
onde a e b são números inteiros arbitrários (positivos ou negativos) e a condição inicial é dada por:
T 0=x
T1= y
O Método de Newton
y− y 0 =m(x−x 0)
o que implica em
f ( x0 )
x=x 0−
f ' (x 0)
Como o processo é iterativo (deve ser repetido várias vezes), chega-se na seguinte relação de
recorrência:
f ( xk )
x k+1=x k −
f ' ( xk )
Suponha a equação f ( x)=x 2−a=0 . Assim, a derivada vale f ' ( x)=2 x e temos:
x 2k −a xk a 1 a
x k+1=x k −
2 xk
→ x k+1=x k − +
2 2 xk
→ x k+1= (x+
2 k xk )
Método da secante
Um problema com o método de Newton é que ele depende explicitamente da derivada da função
f(x). Em alguns casos, ela pode ser difícil ou até mesmo impossível de calcular. Um outro método
para encontrar raízes de funções que não requer derivadas é o método das secantes. Esse método
pode ser pensado como uma aproximação do método de Newton utilizando a técnica de diferenças
finitas para o computo numérico das derivadas.
Iniciando pelos pontos x0 e x1 é possível construir uma linha entre (x0, f(x0)) e (x1, f(x1)), como
indicado na figura acima. A equação dessa reta é dada por:
f (x 1 )−f (x 0)
onde m= (inclinação da reta)
x 1−x 0
Assim, temos
f ( x1 )−f ( x 0)
y= ( x−x 1 )+ f (x 1 )
x 1−x 0
Para encontrar o ponto em que essa reta intercepta o eixo x, basta atribuir valor zero a y:
f ( x 1)−f ( x0 )
(x−x 1)+f ( x 1)=0
x 1−x 0
x 1−x 0
Multiplicando ambos os lados por temos:
f ( x 1)−f ( x0 )
x 1−x 0
x−x 1+f ( x 1 ) =0
f ( x1 )−f (x 0)
x 1−x 0
x=x 1−f ( x 1)
f ( x 1)−f (x 0 )
Como o processo é iterativo e deve ser repetido por várias vezes, chega-se a seguinte relação de
recorrência:
x k−1−x k−2
x k =x k−1−f (x k−1)
f (x k−1)−f ( x k−2 )
A seguir apresentamos uma classe que implementa os métodos para encontrar raízes de funções não
lineares arbitrárias: Newton e Secante. Utilizamos as funções lambda (lambda functions) para
definir a função cuja raiz desejamos encontrar.
import numpy as np
class RootFinder:
# Método de Newton
# x0: chute inicial
# epson: tolerância
def Newton(self, x0, epson):
x = x0
novo_x = np.random.random()
erro = abs(x - novo_x)
return x
# Método da Secante
# x0, x1: chutes iniciais
# epson: tolerância
def Secante(self, x0, x1, epson):
erro = abs(x0 - x1)
return novo_x
Integração numérica
Ser capaz de calcular a integral definida de uma função numericamente é muito importante, pois
nos permite computar áreas sob curvas. Dentre aos métodos mais conhecidos para esse fim,
encontra-se a regra de Simpson. Veremos a seguir como essa regra funciona e como podemos criar
uma classe Integration para resolver esse problema com a linguagem de programação Python.
A Regra de Simpson
A regra de Simpson é um método numérico que aproxima o valor de uma integral definida através
de polinômios quadráticos (parábolas). É muito utilizado como uma forma computacional de se
calcular a área sob uma curva. Primeiro, iremos derivar uma fórmula para calcular a área sob uma
parábola definida pela equação y=ax2 +bx +c passando por 3 pontos: (-h, y0), (0, y1) e (h, y2),
conforme ilustra a figura a seguir.
A área sob a curva nada mais é que a integral definida da função y = f(x) de -h a h:
Como os 3 pontos (-h, y0), (0, y1) e (h, y2) pertencem a parábola, eles satisfazem a equação
2
y=ax +bx +c e portanto:
Note porém que a seguinte igualdade é válida:
Para aplicar a regra de Simpson para a integração numérica de uma função f(x) qualquer, deseja-se
b
resolver a seguinte integral: ∫ f ( x) . Assumindo f(x) contínua no intervalo [a, b] e dividindo o
a
b−a
intervalo em um número par n de subintervalos de tamanhos iguais a Δ x= , definimos n+1
n
pontos, para os quais podemos computar os valores da função f(x):
Podemos estimar a integral pela soma das áreas sob os arcos parabólicos formados por cada 3
pontos sucessivos:
Exercício: Utilizando uma calculadora, aplique a regra de Simpson com n = 6 para aproximar a
integral
4
∫ √ 1+ x 3 dx
1
b−a 4−1
Para n = 6, temos subintervalos de largura Δ x= = =0.5 . Assim, os pontos ficam:
n 6
Existe uma variação da regra de Simpson que utiliza interpolação cúbica ao invés de interpolação
quadrática, denominada de regra de Simpson 3/8. A expressão para a integral definida de uma
função f(x) no intervalo [a, b] fica:
onde h = (b – a)/n. A lógica aqui consiste em multiplicar por 3 todos os valores da função nos
pontos xi, exceto os pontos em que i é múltiplo de 3, como i = 3, 6, 9, … etc, o que gera um padrão
simples de ser programado computacionalmente.
class Integration:
# Método de Simpson
def Simpson(self, a, b, n):
h = (b - a)/n
soma_pares, soma_impares = 0, 0
# Soma os pares
for i in range(2, n, 2):
k = a + i*h
soma_pares += self.f(k)
# Soma os ímpares
for i in range(1, n, 2):
k = a + i*h
soma_impares += self.f(k)
area = (h/3)*(self.f(a) + 4*soma_impares + \
2*soma_pares + self.f(b))
return area
if __name__ == '__main__':
# Instancia objeto e faz operações
f = lambda x: np.sqrt((1 + x**3))
g = lambda x: 1/np.sqrt((1 + x**4))
# Densidade Normal(0, 1)
p = lambda x: (1/(2*np.pi)**0.5)*np.exp(-0.5*x**2)
# Função f
metodo = Integration(f)
print('Simpson: ', metodo.Simpson(1, 4, 20))
print('Simpson 3/8: ', metodo.Simpson_3_8(1, 4, 21))
print()
# Função g
metodo = Integration(g)
print('Simpson: ', metodo.Simpson(0, 2, 20))
print('Simpson 3/8: ', metodo.Simpson_3_8(0, 2, 21))
print()
# Função p
metodo = Integration(p)
print('Simpson: ', metodo.Simpson(0, 3, 20))
print('Simpson 3/8: ', metodo.Simpson_3_8(0, 3, 21))
print()
Ex: A regra de Simpson estendida é uma formulação alternativa para quando o número de pontos n
é maior que 8. Ela é dada por:
Iniciaremos nosso estudo com Pilhas e Filas, que são coleções em que os elementos são organizados
dependendo de como eles são adicionados ou removidos do conjunto. Uma vez que um elemento é
adicionado ele permanece na mesma posição relativa ao elemento que veio antes e o elemento que
veio depois. Essa é a característica comum de todas as estruturas lineares.
Sendo bastante simplista, estruturas de dados lineares são aquelas que possuem duas extremidades:
início e fim, ou esquerda e direita, ou base e topo. A nomenclatura não é relevante para nós, pois o
que realmente importa é como são realizadas as inserções e remoções de elementos na estrutura.
Pilhas (LIFO)
Uma pilha (stack) é uma estrutura de dados linear em que a inserção e a remoção de elementos é
realizada sempre na mesma extremidade, comumente de chamada de topo. O oposto do topo é a
base. Quanto mais próximo da base está um elemento, há mais tempo ele está armazenado na
estrutura. Por outro lado, um item inserido agora estará sempre no topo, o que significa que ele será
o primeiro a ser removido (se não empilharmos novos elementos antes). Por esse princípio de
ordenação inerente das pilhas, elas são conhecidas como a estrutura LIFO, do inglês, Last In First
Out, ou seja, primeiro a entrar é último a sair. Essa intuição faz total sentido com uma pilha de
livros por exemplo. O primeiro livro a ser empilhado fica na base da pilha e será o último a ser
retirado. A ordem de remoção é o inverso da ordem de inserção. A figura a seguir ilustra uma pilha
de objetos em Python.
Para que possamos implementar um classe Pilha, devemos ter em mente quais suas variáveis
internas e quais as operações que podemos aplicar sobre os seus elementos. A listagem a seguir
mostra como podemos construir a pilha da figura acima, a partir de uma pilha inicialmente vazia.
Note que podemos utilizar uma lista P para armazenar os elementos da pilha.
Operação Conteúdo Retorno Descrição
s.is_empty() [] True Verifica se pilha está vazia
s.push(4) [4] Insere elemento no topo
s.push(‘dog’) [4, ‘dog’] Insere elemento no topo
s.peek() [4, ‘dog’] ‘dog’ Consulta o elemento do topo (mantém)
s.push(True) [4, ‘dog’, True] Insere elemento no topo
s.size() [4, ‘dog’, True] 3 Retorna número de elementos da pilha
s.is_empty() [4, ‘dog’, True] False Verifica se pilha está vazia
s.puch(8.4) [4, ‘dog’, True, 8.4] Insere elemento no topo
s.pop() [4, ‘dog’, True] 8.4 Remove elemento do topo
s.pop() [4, ‘dog’] True Remove elemento do topo
s.size() [4, ‘dog’] 2 Retorna número de elementos da pilha
Para implementar uma pilha em Python, iremos utilizar como atributo uma lista chamada items, que
inicia vazia. Definiremos os métodos push() e pop() para inserção e remoção de elementos do topo,
bem como peek(), size() e is_empty(), para consultar o elemento do topo, obter o número de
elementos da pilha e verifica se a pilha está vazia. Note que na nossa implementação de pilha, tanto
a inserção quanto a remoção tem complexidade O(1).
S = Stack()
S.print_stack()
S.push(1)
S.push(2)
S.push(3)
S.print_stack()
S.pop()
S.pop()
S.print_stack()
S.push(7)
S.push(8)
S.push(9)
S.print_stack()
print(S.is_empty())
Ex: Uma aplicação interessante que utiliza uma estrutura de dados do tipo Pilha é a verificação do
balanceamento dos parêntesis de uma expressão matemática. Uma expressão matemática é bem
formada se o número de parêntesis é par, sendo que para cada ( deve existir um ). Por exemplo, a
expressão a seguir é válida:
( (1 + 2) * (3 + 4) ) - (5 + 6)
( (1 + 2) * (3 + 4) – (5 + 6)
Como implementar uma função em Python que verifique se uma dada expressão é válida ou não? E
se a expressão permitir parêntesis, colchetes e chaves, como em:
{ [ (1 + 2) + (3 + 4) ] * 2 }
Iremos apresentar um código Python para resolver esses problemas. A ideia consiste em utilizar
uma pilha para empilhar cada abre parêntesis que aparece na expressão e ao encontrar um fecha
parêntesis, devemos desempilhar o seu par da pilha. Se ao final do processo a pilha estiver vazia, ou
seja, para cada ( existe um respectivo ), então a fórmula é considerada válida. No caso de
expressões compostas, em que existem vários tipos de símbolos, como {, [ e (, ao desempilhar o
fechamento, devemos nos atentar se o par é bem formado, ou seja, se temos ( ), [ ] ou { }.
print('Expressões simples')
print(verifica_expressao_simples('((1 + 2) * (3 + 4)) - (5 + 6)'))
print()
print(verifica_expressao_simples('((1 + 2) * (3 + 4))))'))
print()
print(verifica_expressao_simples('(((((1 + 2) * (3 + 4))'))
print()
print('Expressões compostas')
print(verifica_expressao_composta('{ [ (1 + 2) + (3 + 4) ] * 2 }'))
print()
print(verifica_expressao_composta('{ [ (1 + 2} + [3 + 4} ) * 2 )'))
Um outro problema interessante que pode ser resolvido com uma estrutura de dados do tipo Pilha é
a conversão de um número decimal (base 10) para binário (base 2). A representação de números
inteiros por computadores digitais é feita na base 2 e não na base 10. Sabemos que um número na
base 10 (decimal) é representado como:
Veja o quanto mais a esquerda estiver o dígito, maior o seu valor. O 2 em 23457 vale na verdade
20000 pois é igual a 2 x 104 .
110101 = 1 x 25 + 1 x 24 + 0 x 23 + 1 x 22 + 0 x 21 + 1 x 20 = 53
Essa é a regra para convertermos um número binário para sua notação decimal.
Veremos agora o processo inverso: como converter um número decimal para binário. O processo é
simples. Começamos dividindo o número decimal por 2:
53 / 2 = 26 e sobra resto 1 → esse 1 será nosso bit mais a direita (menos significativo no binário)
Continuamos o processo até que a divisão por 2 não seja mais possível:
26 / 2 = 13 e sobra resto 0 → esse 0 será nosso segundo bit mais a direita no binário
13 / 2 = 6 e sobra resto 1 → esse 1 será nosso terceiro bit mais a direita no binário
6 / 2 = 3 e sobra resto 0 → esse 0 será nosso quarto bit mais a direita no binário
3 / 2 = 1 e sobra resto 1 → esse 1 será nosso quinto bit mais a direita no binário
Note que de agora em diante não precisamos continuar com o processo pois
0 / 2 = 0 e sobra 0
0 / 2 = 0 e sobra 0
ou seja a esquerda do sexto bit teremos apenas zeros, e como no sistema decimal, zeros a esquerda
não possuem valor algum. Portanto, 53 em decimal equivale a 110101 em binário. Note que se
empilharmos os restos em uma pilha, ao final, basta desempilharmos para termos o número binário
na sua forma correta. A figura a seguir ilustra essa ideia.
O código em Python a seguir mostra a implementação de uma função que converte um número
inteiro arbitrário na base 10 (decimal) para a representação binária.
return binario
if __name__ == '__main__':
n = int(input('Entre com um número inteiro: '))
print(decimal_binario(n))
Ex: Escreva uma função para determinar se uma cadeia de caracteres (string) é da forma: xCy onde
x e y são cadeias de caracteres compostas por letras ‘A’ ou ‘B’, e y é o inverso de x. Isto é, se:
x = ‘ABABBA’
Em cada ponto, você só poderá ler o próximo caractere da cadeia (use uma pilha).
Filas (FIFO)
Uma fila é uma estrutura de dados linear em que a inserção de elementos é realizada em uma
extremidade (final) e a remoção é realizada na outra extremidade (início). Assim como em uma fila
de pessoas no mundo real, o primeiro a entrar será o primeiro a sair, o que define o princípio de
ordenação FIFO, ou do inglês, First In First Out. Além disso, filas devem ser restritivas no sentido
de que um elemento não pode passar na frente de seu antecessor. A figura abaixo ilustra o processo.
Na ciência da computação há diversos exemplos de aplicações que utilizam filas para gerenciar a
ordem de acesso aos recursos. Por exemplo, um servidor de impressão localizado em um laboratório
de pesquisa em um departamento da universidade precisar lidar com o gerenciamento da ordem das
impressões. Para isso, é usual o software da impressora criar uma fila de impressões. Assim,
múltiplas requisições são tratadas sequencialmente de acordo com a ordem em que chegam.
Para que possamos implementar um classe Fila, devemos ter em mente quais suas variáveis internas
e quais as operações que podemos aplicar sobre os seus elementos. A listagem a seguir mostra como
podemos construir uma fila a partir de uma lista inicialmente vazia. As operações são bastante
parecidas com as operações de uma pilha.
Operação Conteúdo Retorno Descrição
q.is_empty() [] True Verifica se fila está vazia
q.enqueue(4) [4] Insere elemento no final
q.enqueue(‘dog’) [‘dog’, 4] Insere elemento no final
q.enqueue(True) [True, ‘dog’, 4] Insere elemento no final
q.size() [True, ‘dog’, 4] 3 Retorna número de elementos da fila
q.is_empty() [True, ‘dog’, 4] False Verifica se fila está vazia
q.enqueue(8.4) [8.4, True, ‘dog’, 4] Insere elemento no final
q.dequeue() [8.4, True, ‘dog’] 4 Remove elemento do início
q.dequeue() [8.4, True] ‘dog’ Remove elemento do início
q.size() [8.4, True] 2 Retorna número de elementos da fila
Para implementar uma fila em Python, iremos utilizar a mesma estratégia utilizada com a pilha:
como atributo teremos uma lista chamada items, que inicia vazia. Definiremos os métodos
enqueue() e dequeue() para inserção de elementos no final e remoção de elementos no início, bem
como, size() e is_empty(), obter o número de elementos da fila e verificar se a fila está vazia. Note
que em nossa implementação de fila uma fila, a inserção tem custo O(n), pois para inserir na
posição zero (início), utilizamos o método insert de uma lista, que é O(n) (é como se precisasse
mover todos os elementos da lista uma posição a direita), mas a remoção tem custo O(1).
if __name__ == '__main__':
Q = Queue()
Q.print_queue()
Q.enqueue(1)
Q.enqueue(2)
Q.enqueue(3)
Q.print_queue()
Q.dequeue()
Q.dequeue()
Q.print_queue()
Q.enqueue(7)
Q.enqueue(8)
Q.enqueue(9)
Q.print_queue()
print(Q.is_empty())
Para ilustra algumas aplicações que utilizam estruturas de dados do tipo Fila. A primeira delas é
uma simples simulação do jogo infantil Batata Quente. Neste jogo, as crianças formam um círculo e
passam um item qualquer (batata) cada um para o seu vizinho da frente o mais rápido possível. Em
um certo momento do jogo, essa ação é interrompida (queimou) e a criança que estiver com o item
(batata) na mão é excluída da roda. O jogo então prossegue até que reste apenas uma única criança,
que é a vencedora.
Para simular um círculo (roda), utilizaremos uma fila da seguinte maneira: a criança que está com a
batata na mão será sempre a quela que estiver no início da fila. Após passar a batata, a simulação
deve instantaneamente remover e inserir a criança, colocando-a novamente no final da fila. Ela
então vai esperar até que todas as outras assumam o início da fila, antes de assumir essa posição
novamente. Após um número pré estabelecido MAX de operações enqueue/dequeue, a criança que
ocupar o início da fila será removida e outro ciclo da brincadeira é realizado. O processo continua
até que a fila tenha possua tamanho um. A figura a seguir ilustra o processo.
return vencedor
if __name__ == '__main__':
# Chama a função para simular o jogo
v = batata_quente(['Alex','Julia','Carlos','Maria','Ana','Caio'], 7)
print(‘O vencedor é %s’ %v)
Ex: Para um dado número inteiro n > 1, o menor inteiro d > 1 que divide n é chamado de fator
primo. É possível determinar a fatoração prima de n achando-se o fator primo d e substituindo n
pelo quociente n / d, repetindo essa operação até que n seja igual a 1. Utilizando uma das estruturas
de dados lineares (pilha ou fila) para auxiliá-lo na manipulação de dados, implemente uma função
que compute a fatoração prima de um número imprimindo os seus fatores em ordem decrescente.
Por exemplo, para n=3960, deverá ser impresso 11 * 5 * 3 * 3 * 2 * 2 * 2. Justifique a escolha do
TAD utilizado.
Ex: Modifique a simulação do jogo batata quente de modo a permitir que o número passagens
MAX seja um número aleatório de 4 até 15. Assim, em cada rodada, teremos um valor de MAX
diferente.
Ex: Usando uma pilha P inicialmente vazia, implemente um método para inverter uma fila Q com n
elementos utilizando apenas os métodos is_empty(), push(), pop(), enqueue() e dequeue().
def inverte_fila(Q):
# Cria pilha vazia
S = Stack()
# Enquanto tiver elementos na fila
while not Q.is_empty():
S.push(Q.dequeue())
# Enquanto tiver elementos na pilha
while not S.is_empty():
Q.enqueue(S.pop())
return Q
if __name__ == '__main__':
# Cria fila vazia
Q = Queue()
# Adiciona elementos na fila
Q.enqueue(1)
Q.enqueue(2)
Q.enqueue(3)
Q.enqueue(4)
Q.enqueue(5)
# Imprime fila
Q.print_queue()
# Cria fila invertida
IQ = inverte_fila(Q)
# imprime fila invertida
IQ.print_queue()
Deque (pronuncia-se deck para diferenciar da operação dequeue), ou Double-Ended Queue, nome
que traduziremos como Dupla Fila, é uma estrutura de dados linear bastante similar a uma fila
tradicional, porém com dois inícios e dois finais. O que a torna diferente da fila é justamente a
possibilidade de inserir e remover elementos tanto do início quanto do fim da lista. Neste sentido,
esse TAD híbrido prove todas as funcionalidades de filas e pilhas em uma única estrutura de dados.
Apenas para deixar claro, denotaremos por front a parte da frente da fila (direita) e por rear a parte
de trás da fila (esquerda), ou seja:
if __name__ == '__main__':
D = Deque()
D.print_deque()
D.add_front(1)
D.add_front(2)
D.add_rear(3)
D.add_rear(4)
D.print_deque()
D.remove_front()
D.remove_rear()
D.print_deque()
D.add_front(7)
D.add_rear(8)
D.print_deque()
print(D.is_empty())
Um aspecto interessante com essa implementação que utiliza como base a classe lista pré-definida
em Python é que inserção e remoção de elementos na direita da fila (add_front e remove_front)
possuem complexidade O(1), enquanto que a inserção e remoção de elementos na esquerda da fila
(add_rear e remove_rear) possuem complexidade O(n) (pois precisa deslocar os outros elementos).
Um exemplo de aplicação de uma estrutura linear do tipo Deque é no problema de verificar se uma
dada palavra é palíndroma, ou seja, se ela é igual a sua versão reversa. Um exemplo de palavra
palíndroma é RADAR. A figura a seguir ilustra como uma estrutura Deque pode ser utilizada nesse
tipo de problema.
A ideia consiste em processar cada caractere da string de entrada da esquerda para a direita
adicionando cada caractere na esquerda do Deque. No próximo passo, iremos remover os caracteres
das duas extremidades (esquerda e direita), comparando-as. Se os caracteres forem iguais repetimos
o processo ate que não haja mais caracteres no Deque, se a string tiver um número par de caracteres,
ou sobre apenas 1 caractere, se a string tiver um número ímpar de caracteres. A função a seguir
implementa a solução em Python.
return iguais
if __name__ == '__main__':
print(palindrome('radar'))
print(palindrome('arara'))
print(palindrome('ovo'))
print(palindrome('bicicleta'))
print(palindrome('bola'))
print(palindrome('carro'))
Uma fila de prioridades é uma extensão da fila tradicional em que, para cada elemento inserido,
uma prioridade p deve ser associada. Por convenção, inteiros positivos são utilizados para
representar a prioridade, sendo que quanto menor o inteiro, maior a prioridade (ou seja, p = 2 tem
prioridade sobre p = 5).
A principal diferença em relação a fila tradicional é o método enqueue(), que todo elemento deve
ser inserido no final da fila, mas a remoção não é feita no início da fila e sim descobrindo o
elemento que possui a maior prioridade. Quanto maior a prioridade, menor o inteiro que a codifica.
A primeira coisa que devemos fazer é criar uma classe para definir a estrutura interna de um
elemento da fila de prioridades. Isso é necessário pois além de armazenar a informação referente ao
elemento em si (um número, uma string, etc), precisamos de uma variável para armazenar o
prioridade. O código em Python a seguir implementa uma fila de prioridades.
def __str__(self):
return str(self.item) + ' ' + str(self.prioridade)
return removido.item
if __name__ == '__main__':
PQ = PriorityQueue()
PQ.print_queue()
PQ.enqueue('a', 4)
PQ.enqueue('b', 2)
PQ.enqueue('c', 3)
PQ.print_queue()
PQ.dequeue()
PQ.print_queue()
Nesta aula, vimos as principais estruturas de dados lineares: Pilha, Fila, Deque e Fila de prioridades.
Na próxima aula estudaremos outra estrutura linear muito importante para a computação, as listas
encadeadas. Listas encadeadas diferem de vetores tradicionais em um aspecto primordial: enquanto
em um vetor tradicional os elementos subsequentes são armazenados de maneira contígua na
memória, em uma lista encadeada, cada nó da estrutura é armazenado independente dos demais e a
conexão entre os nós é realizada por um encadeamento lógico.
Aula 6 – Listas Encadeadas
Primeiramente, antes de definirmos uma lista encadeada, devemos definir o bloco básico de
construção de uma lista: o nó. Cada nó de uma lista encadeada deve conter duas informações: o
dado propriamente dito e uma referência para o próximo nó da lista (para quem esse nó aponta).
Podemos definir a classe Node, que representa um nó da lista, como segue:
class Node:
# Construtor
def __init__(self, init_data):
self.data = init_data
self.next = None # inicialmente não aponta para ninguém
temp = Node(93)
temp.get_data()
93
Internamente, ao criarmos um nó, temos uma representação típica como o diagrama a seguir.
Em uma lista ordenada, a principal característica é que a inserção de um novo nó é feita sempre no
início ou no final da lista, ou seja, os elementos do conjunto não encontram-se ordenados.
Iniciaremos apresentando como criar uma lista encadeada não ordenada vazia. Adotaremos o
seguinte construtor, em que head (cabeça) é uma referência para o primeiro nó da lista:
def __init__(self):
self.head = None
mylist = UnorderedList()
O primeiro método que iremos desenvolver é o mais simples deles. Ele verifica se a lista é vazia,
condição que requer que a cabeça da lista seja igual a None.
def is_empty(self):
return self.head == None
A próxima função é a responsável por adicionar um elemento no início da lista. A lógica dessa
operação consiste em apontar o novo nó para a cabeça da lista (head) e fazer a cabeça da lista
apontar para esse novo n´o recém inserido (pois ele será o primeiro elemento da lista). O diagrama a
seguir ilustra o resultado da execução dos comandos a seguir:
mylist.add_head(31)
mylist.add_head(77)
mylist.add_head(17)
mylist.add_head(93)
mylist.add_head(26)
De modo análogo, podemos realizar a inserção no final da lista (tail). Para isso, devemos criar um
novo nó e posicionar uma referência no último elemento da lista. Para isso, é preciso apontar temp
para a cabeça da lista e percorrer a lista até atingir um nó tal que o próximo elemento seja definido
como None. Isso significa que estamos no último elemento da lista.
Ao percorrermos uma lista encadeada, devemos iniciar na cabeça da lista (head) e a cada iteração
fazer a referência apontar para o seu sucessor (get_next). A figura a seguir ilustra esse processo.
Precisamos ainda implementar uma função para retornar quantos elementos existem na lista
encadeada. Como ela é dinâmica, precisamos percorrê-la toda vez que desejarmos contar o número
de elementos. A ideia é simples: iniciando na cabeça da lista, devemos percorrer todos os nós, sendo
que a cada nó visitado, incrementamos uma unidade no contador. A função a seguir mostra uma
implementação em Python.
def size(self):
# Aponta para cabeça da lista
temp = self.head
count = 0
while temp != None:
count = count + 1
temp = temp.get_next()
return count
return found
Por fim, uma operação importante é a remoção de um dado elemento da lista encadeada. Note que
ao remover um nó da lista, precisamos religar o antecessor com o sucessor, de modo a evitar que
elementos fiquem inacessíveis pela quebra do encadeamento sequencial. Primeiramente, preciamos
ter duas referências se movendo ao longo da lista: current, que aponta para o elemento corrente da
lista encadeada e previous, que aponta para seu antecessor. Eles devem se mover conjuntamente, até
que current aponte diretamente para o nó a ser removido. A figura a seguir ilustra o processo.
Em seguida, devemos apontar o valor de next da referência previous para o mesmo local apontado
pelo valor de next da referência corrente. Por fim, apontamos o valor de next da referência corrente
para None, de modo a excluir completamente o nó da lista encadeada. A figura a seguir mostra uma
ilustração gráfica do processo.
# Implementação da classe nó
class Node:
# Construtor
def __init__(self, init_data):
self.data = init_data
self.next = None # inicialmente não aponta para ninguém
if __name__ == '__main__':
# Cria lista vazia
L = UnorderedList()
print(L.is_empty())
# Insere no início
L.add_head(1)
L.add_head(2)
L.add_head(3)
print(L.print_list())
print(L.size())
print(L.is_empty())
# Insere no final
L.add_tail(4)
L.add_tail(5)
L.add_tail(6)
L.add_tail(12)
print(L.print_list())
print(L.size())
print(L.search(5))
print(L.search(29))
L.remove(5)
print(L.print_list())
print(L.size())
Listas ordenadas
Quando trabalhamos com listas ordenadas, os dois métodos que precisam de ajustes em relação as
listas encadeadas não ordenadas são search() e add(). Na inserção de um novo nó, devemos
primeiramente encontrar sua posição na lista. Na busca pelo elemento, não precisamos percorrer
toda a lista encadeada, pois se o elemento buscado é maior que o atual e ainda não o encontramos,
significa que ele não pertence a lista. A seguir apresentamos a função que verifica se um elemento
faz parte de uma lista ordenada ou não.
Devemos também modificar o método add(), que insere um novo elemento a lista ordenada. A ideia
consiste em encontrar a posição correta do elemento na lista ordenada, então para isso é mais fácil
iniciar pela cabeça da lista. A figura a seguir ilustra o processo.
Para encontrar a posição correta precisamos de duas referências, assim como na remoção de um
elemento. A posição correta da inserção na lista ordenada ocorre exatamente quando o valor da
referência prévia é menor que o valor do novo elemento, que por sua vez é menor que o valor da
referência atual. Note na figura que 31 está entre 26 e 54.
A diferença em relação a complexidade da lista encadeada não ordenada é que na lista ordenada
inserção é O(n).
Listas duplamente encadeadas
Conforme vimos anteriormente, a inserção no final de uma lista encadeada tem complexidade O(n).
Uma maneira de melhorar essa operação consiste na definição de listas duplamente encadeadas. Em
listas duplamente encadeadas, tanto a inserção quanto a remoção no final são operações O(1).
Em uma lista duplamente encadeada, cada nó possui uma informação e duas referências: uma para o
nó antecessor e outra para o nó sucessor.
class Node:
# Construtor
def __init__(self, init_data, prev, next):
self.data = init_data
self.prev = prev # inicialmente não aponta para ninguém
self.next = next
Assim como a lista encadeada possui uma cabeça (head) que sempre aponta para o início do
encadeamento lógico, uma lista duplamente encadeada possui duas referências especiais: a própria
cabeça, que chamaremos de header, e a cauda, que chamaremos de trailer. A figura a seguir ilustra a
estrutura de uma lista duplamente encadeada.
Para essa classe, adotaremos a estratégia de a cada nó inserido, incrementar em uma unidade o seu
tamanho e a cada nó removido, decrementar em uma unidade o seu tamanho, assim não precisamos
de uma função para contar quantos elementos existem na lista.
Com relação a operação de inserção, a principal diferença em relação a lista encadeada é que aqui
devemos ligar o novo nó tanto ao seu elemento sucessor quanto ao seu elemento antecessor,
conforme ilustra a figura a seguir.
A mesma observação vale para a remoção de um nó. Para desconectá-lo completamente da lista
duplamente encadeada, devemos ligar o antecessor ao sucessor e vice-versa, conforme ilustra a
figura a seguir.
# Implementação da classe nó
class Node:
# Construtor
def __init__(self, init_data, prev, prox):
self.data = init_data
self.prev = prev # inicialmente não aponta para ninguém
self.next = prox
Até o presente momento, estudamos estruturas de dados lineares, como listas, pilhas e filas. A partir
de agora, iremos estudar estruturas consideradas não lineares, no sentido de que a o encadeamento
lógico dos elementos permite organizações mais complexas e otimizadas para a busca. Esse é o caso
das árvores binárias, assunto das próximas aulas.
Aula 7 – Árvores binárias
Árvores são estruturas de dados muito utilizadas em diversas áreas da ciência da computação, como
sistemas operacionais, bancos de dados e redes de computadores. Uma estrutura de dados do tipo
árvore possui uma raiz a partir da qual diferentes ramos conectam um conjunto de nós
intermediários até as folhas da árvore. A figura a seguir ilustra uma simples árvore composta por 6
nós, sendo que a é a raiz e d, e, f são as folhas.
Sendo assim, podemos definir formalmente uma árvore. Existem diversas formas de se definir o que
é uma árvore. Veremos aqui duas delas: uma baseada nas propriedades e outra definição recursiva.
Def: Uma árvore consiste em um conjunto de nós e um conjunto de arestas que conectam pares de
nós. Uma árvore possui as seguintes propriedades:
i) Toda árvore tem um nó designado de raiz, por onde a busca, a inserção e a remoção de elementos
deve iniciar. Em outras palavras, é a porta de entrada para o conjunto de dados.
ii) Todo nó n, com exceção da raiz, é conectado por uma aresta a exatamente um nó pai p. Ou seja,
cada nó da árvore tem precisamente um único pai.
Iii) Existe um único caminho saindo da raiz e chegando em um nó arbitrário da árvore. Ou seja,
sempre que desejarmos acessar um determinado nó, iremos sempre pelo mesmo caminho.
iv) Se cada nó da árvore possui no máximo dois nós filhos, dizemos que a árvore é uma árvore
binária.
Def: Uma árvore ou é vazia ou consiste de uma raiz com zero ou mais subárvores, cada uma sendo
uma árvore. A raiz de cada subárvore é conectada a raiz da árvore pai por uma aresta.
Pela definição recursiva, sabemos que a árvore acima possui pelo menos 4 nós, uma vez que cada
triângulo representando uma subárvore deve possuir uma raiz. Na verdade, ela pode ter muito mais
nós do que isso, mas não sabemos pois não conhecemos a estrutura interna de cada subárvore.
Nessa aula, por motivos didáticos, o foco será no estudo e implementação de árvores binárias.
Existem basicamente duas formas de implementar árvores binárias em Python: utilizando uma lista
de sublistas, ou utilizando encadeamento lógico (como na lista encadeada).
Antes de implementarmos uma árvore binária em Python, vamos listar os principais métodos que
essa estrutura de dados deve suportar.
Métodos Descrição
BinaryTree() cria uma nova instância da árvore binária
get_left_child() retorna a subárvore a esquerda do nó corrente
get_right_child() retorna a subárvore a direita do nó corrente
set_root_val(val) armazena um valor no nó corrente
get_root_val() retorna o valor armazenado no nó corrente
insert_left(val) cria uma nova árvore binária a esquerda do nó corrente
insert_right(val) cria uma nova árvore binária a direita do nó corrente
Representação com lista de listas
Uma propriedade muito interessante desta representação é que a estrutura de uma lista
representando uma subárvore possui a mesma estrutura de uma árvore (lista), de modo que define
uma representação recursiva.
print(my_tree)
print('Subárvore esquerda = ', my_tree[1])
print('Raiz = ', my_tree[0])
print('Subárvore direita = ', my_tree[2])
A implementação a seguir ilustra um conjunto de funções que nos permite manipular uma lista de
listas de modo a simular o comportamento de uma árvore binária.
return root
# Insere novo ramo a direita da raiz
def insert_right(root, new_branch):
# Analisa a subárvore a direita
t = root.pop(2)
# Se a subárvore a direita não é vazia
if len(t) > 1:
# Insere na posição 2 da raiz (direita)
# Novo ramo será a raiz da subárvore a direita
# Adiciona t na direita do novo ramo
root.insert(2, [new_branch, [], t])
else:
# Se t for vazia, não há subárvore a direita
root.insert(2, [new_branch, [], []])
return root
def get_root_val(root):
return root[0]
def get_left_child(root):
return root[1]
def get_right_child(root):
return root[2]
if __name__ == '__main__':
# Cria árvore binária
r = binary_tree(3)
# Adiciona subárvore a esquerda
insert_left(r, 4)
# Adiciona subárvore a esquerda
insert_left(r, 5)
# Adiciona subárvore a direita
insert_right(r, 6)
# Adiciona subárvore a direita
insert_right(r, 7)
print(r)
[‘a’, [‘b’, [], []], [‘c’, [], [‘d’, [‘e’, [], []], []]]]
Apesar de interessante, a representação de árvores binárias utilizando lista de listas não é muito
intuitiva, principalmente quando o número de nós da árvore cresce. O número de sublistas fica tão
elevado que é muito fácil cometer erros e equívocos durante a manipulação da estrutura. Sendo
assim, veremos agora, como representar árvores binárias utilizando referências e um encadeamento
lógico similar ao adotado nas listas duplamente encadeadas, em que cada nó possui duas
referências: uma para o nó filho a esquerda e outra para o nó filho a direita. O código fonte em
Python a seguir ilustra uma implementação da classe BinaryTree utilizando referências.
# Insere nó a esquerda
def insert_left(self, valor):
# Se nó corrente não tem filho a esquerda, OK
if self.left_child == None:
self.left_child = BinaryTree(valor)
else:
# Se tem filho a esquerda, pendura subárvore
# a esquerda do nó corrente na esquerda do
# novo nó receḿ criado
temp = BinaryTree(valor)
temp.left_child = self.left_child
self.left_child = temp
# Insere nó a direita
def insert_right(self, valor):
# Se nó corrente não tem filho a direita, OK
if self.right_child == None:
self.right_child = BinaryTree(valor)
else:
# Se tem filho a direita, pendura subárvore
# a direita do nó corrente na direita do
# novo nó receḿ criado
temp = BinaryTree(valor)
temp.right_child = self.right_child
self.right_child = temp
if __name__ == '__main__':
# Cria nó com valor 'a'
r = BinaryTree('a')
print(r.get_root_val())
print(r.get_left_child())
print(r.get_right_child())
# Insere nó com valor 'b' a esquerda da raiz
r.insert_left('b')
print(r.get_left_child().get_root_val())
# Insere nó com valor 'c' na direita da raiz
r.insert_right('c')
print(r.get_right_child().get_root_val())
# Insere nó com valor 'd' a esquerda no filho a esquerda da raiz
r.get_left_child().insert_left('d')
print(r.get_left_child().get_left_child().get_root_val())
# Insere nó com valor 'e' a direita no filho a esquerda da raiz
r.get_left_child().insert_right('e')
print(r.get_left_child().get_right_child().get_root_val())
Há basicamente 3 formas de navegar por uma árvore binária: preorder, inorder e postorder.
Inorder: visitamos recursivamente a subárvore a esquerda, depois passamos pela raiz da árvore e
por fim visitamos recursivamente a subárvore a direita.
O código a seguir ilustra uma implementação recursiva do método preorder da classe BinaryTree.
Note que, antes de realizar a chamada recursiva, devemos verificar se as subárvores a esquerda e a
direita existem, ou seja, se não são vazias. Essa é a condição de parada da recursão. A seguir
apresentamos uma implementação recursiva do método inorder.
# Percorre uma árvore binária em Inorder
def inorder(self):
# Visita subárvore a esquerda
if self.left_child:
self.left.inorder()
# Imprime valor da raiz
print(self.key)
# Visita subárvore a direita
if self.right_child:
self.right.inorder()
# Insere nó a esquerda
def insert_left(self, valor):
# Se nó corrente não tem filho a esquerda, OK
if self.left_child == None:
self.left_child = BinaryTree(valor)
else:
# Se tem filho a esquerda, pendura subárvore
# a esquerda do nó corrente na esquerda do
# novo nó receḿ criado
temp = BinaryTree(valor)
temp.left_child = self.left_child
self.left_child = temp
# Insere nó a direita
def insert_right(self, valor):
# Se nó corrente não tem filho a direita, OK
if self.right_child == None:
self.right_child = BinaryTree(valor)
else:
# Se tem filho a direita, pendura subárvore
# a direita do nó corrente na direita do
# novo nó receḿ criado
temp = BinaryTree(valor)
temp.right_child = self.right_child
self.right_child = temp
Note que a árvore criada no exemplo acima é ilustrada pela figura a seguir.
Como esperado, os percursos obtidos com os métodos preorder, inorder e postorder foram:
preorder: a, b, d, e, c
inorder: d, b, e, a, c
postorder: d, e, b, c, a
Árvores binárias de busca
Apesar de funcional, a estrutura de dados implementada pela classe BunaryTree não é otimizada
para busca de elementos no conjunto. Para essa finalidade, veremos que existe uma classe de
árvores mais adequada: as árvores binárias de busca (BynarySearchTree). Uma árvore binária de
busca implementa um TAD Map, que é uma estrutura que mapeia uma chave a um valor. Neste tipo
de estrutura de dados, nós não estamos interessados na localização exata dos elementos na árvore,
mas sim em utilizar a estrutura da árvore binária para realizar busca de maneira eficiente. A seguir
apresentamos os principais métodos da classe BinarySearchTree.
Método Operação
Map() cria um mapeamento vazio
put(key, val) adiciona um novo par chave-valor ao mapeamento (se chave já existe,
atualiza o valor referente a ela)
get(key) retorna o valor associado a chave
del map[key] deleta o par chave-valor do mapeamento
len() retorna o número de pares chave-valor no mapeamento
in retorna True se chave pertence ao mapeamento (key in map)
Propriedade chave: em uma árvore binária de busca, chaves menores que a chave do nó pai devem
estar na subárvore a esquerda e chaves maiores que a chave do nó pai devem estar na subárvore a
direita. Por exemplo, suponha que desejamos criar uma árvore binária de busca com os seguintes
valores: 70, 31, 93, 94, 14, 23, 70, nessa ordem.
Antes de definirmos a classe BinarySearchTree, vamos definir a classe TreeNode responsável por
organizar todas as informações e métodos relacionados a um nó da arvore de busca.
import random
# Verifica se nó é raiz
def is_root(self):
# Raiz não pode ter pai
return not self.parent
# Verifica se é nó folha
def is_leaf(self):
# Folha não tem'filho a esquerda nem a direita
return not (self.right_child or self.left_child)
# Atualiza dados do nó
def replace_node_data(self, key, value, lc, rc):
self.key = key # nova chave
self.payload = value # novo valor
self.left_child = lc # novo filho a esquerda
self.right_child = rc # novo filho a direita
if self.has_left_child(): # é pai de seu novo filho a esquerda
self.left_child.parent = self
if self.has_right_child(): # é pai de seu novo filho a direita
self.right_child.parent = self
'''Esse método irá checar se a árvore já tem uma raiz. Se ela não
tiver, então será criado um novo TreeNode e ele será a raiz da árvore.
Se uma raiz já existe, então o método chama a função auxiliar _put para
procurar o local correto do elemento na árvore de maneira recursiva.'''
def put(self, key, val):
# Se raiz existe
if self.root:
# Adiciona elemento a partir da raiz (vai achar posição correta)
self._put(key, val, self.root)
else:
# Se não tem raíz, cria novo nó raiz
self.root = TreeNode(key, val)
# Incrementa número de nós
self.size = self.size + 1
if __name__ == '__main__':
# Cria nova árvore de busca
T = BinarySearchTree()
# Insere 10 elementos na árvore com chaves aleatórias
for i in range(10):
chave = random.randint(0, 100)
# Se a chave ainda não pertence a árvore
if not chave in T:
# Armazena o valor de i
T[chave] = random.random()
# Percorre a árvore, imprimindo as chaves
print('Percurso inorder')
T.inorder(T.root)
Note que em uma árvore binária de busca, ao percorrermos os nós inorder, as chaves aparecem
ordenadas do menor para o maior.
Percurso inorder: 9 24 25 34 35 52 66 67 77 86
Uma observação acerca da complexidade das operações é que para a inserção, busca e remoção de
nós, pode-se mostrar que no caso médio elas são O(log2 n), onde h = log2 n representa a altura da
árvore binária. Basta notar que o número de nós no nível d da árvore é sempre 2 d. Sendo assim, n =
20 + 21 + 22 + … + 2h-1 = 2h. Logo, h = log2 n.
No pior caso, quando a árvore se degenera para uma lista encadeada (quando as chaves são
inseridas em ordem crescente), a complexidade das operações torna-se O(n). A principal vantagem
das árvores de busca sobre as listas encadeadas é justamente quando os dados estão bagunçados, ou
seja, sem uma ordem específica. Em outras palavras, dados armazenados em estruturas de dados do
tipo árvore de busca não precisam ser ordenados, pois elas são otimizadas para a recuperação da
informação armazenada.
Remoção de elementos
A operação de remoção de nós de uma árvore de busca é bastante complicada, pois exige a análise
de diversos casos específicos. Se a árvore tem mais de um nó, o primeiro passo consiste em
pesquisamos com a função get() qual é o nó a ser removido. Se a árvore tem apenas um único nó
(raiz), devemos nos certificar que ela contém o elemento a ser removido. Em ambos os casos, se o
elemento não for encontrado, a operação deve retornar uma mensagem de erro.
Agora, o próximo passo consiste na implementação da função remove(). Para isso, existem 3 casos
que devem ser analisados:
O primeiro caso é trivial pois basta remover a referência a essa folha no nó pai.
O segundo caso é um pouco mais complicado, mas ainda assim é relativamente fácil de tratar. Se o
nó a ser removido possui apenas um único filho, podemos simplesmente promover esse filho para
ocupar a posição do pai que será removido, conforme ilustra a figura a seguir.
Existem 6 casos a serem tratados, porém como eles são simétricos em relação ao nó corrente ter um
filho a esquerda ou um filho a direita, iremos discutir apenas o caso em que o nó corrente possui um
filho a esquerda. Então, o processo deve ser o seguinte:
ii. Se o nó a ser removido é um filho a direita, precisamos apontar a referência parent do nó filho a
direita para o pai do nó a ser removido e também atualizar a referência right_child do nó pai para
apontar para o filho a direita do nó a ser removido.
iii. Se o nó a ser removido não tem pai, então se trata da raiz da árvore. Neste caso, apenas
atualizaremos os campos key e payload com os valores dos campos de seu único filho (esquerda ou
direita), e atualizamos as referências left_child e right_child para aquelas do seu único filho.
O terceiro caso é o mais complexo de todos e surge quando o nó a ser removido possui ambos os
filhos esquerdo e direito. Nesse caso, devemos procurar na árvore por um nó que possa substituir o
nó a ser removido. O nó substituto deve preservar as propriedades da árvore binária de busca tanto
para a subárvore a esquerda quanto para a subárvore a direita. O nó que permite preservar essa
propriedade é o nó que possui com a menor chave que seja maior que a chave do nó corrente (a ser
removido). Denotaremos esse nó especial como o sucessor (successor) e iremos discutir como
encontrá-lo mais adiante. Uma propriedade interessante é que o successor tem no máximo um único
filho (0 ou 1 filho), de modo que já sabemos como removê-lo utilizando os dois casos discutidos
previamente. Após a remoção do successor, nós simplesmente o colocamos no lugar do nó a ser
removido.
Uma ilustração gráfica do processo pode ser visualizada na figura a seguir, em que o nó a ser
removido é o 5, e o successor neste caso é o nó 7 (menor chave que seja maior que o nó a ser
removido). As informações do nó 7 são copiadas para o nó 5 e ele então é removido da árvore.
Sendo assim, o código em Python para tratar a remoção de um nó com dois filhos segue abaixo.
if current_node.has_both_children():
succ = current_node.find_successor()
succ.splice_out()
current_node.key = succ.key
current_node.payload = succ.payload
Note que estamos utilizando duas funções auxiliares: find_successor() e splice_out(). Primeiro,
vamos discutir o funcionamento do método para encontrar o sucessor. Essa função é implementada
como um método da classe TreeNode e utiliza as propriedades das árvores binárias de busca. Note
que se estamos neste caso, é porque o nó corrente (que deve ser removido) possui obrigatoriamente
dois filhos (um a esquerda e outro a direita). Sendo assim, o elemento que desejamos é o que possui
a menor chave dentre todos pertencentes a subárvore a direita do nó corrente. Veja que na figura
anterior, o nó com chave 7 é o menor elemento da subárvore a direita. O código em Python a seguir
implementa o método em questão.
def find_successor(self):
succ = self.right_child.find_min()
A função find_min() simplesmente visita o filho a esquerda até que um nó não tenha mais filho a
esquerda. Note que pela definição de árvore binária de busca, esse será o menor elemento da
subárvore a direita.
def find_min(self):
current = self
while current.has_left_child():
current = current.left_child
return current
Após encontrar o menor elemento da subárvore a direita, devemos removê-lo. Para isso, utilizamos
a função splice_out(), definida a seguir.
# Remove o nó successor
def splice_out(self):
# Se é nó folha
if self.is_leaf():
# Se é filho a esquerda
if self.is_left_child():
self.parent.left_child = None
else:
# senão, é filho a direita
self.parent.right_child = None
# Caso o nó tenha algum filho
elif self.has_any_children():
# Se nó tem filho a direita
if self.has_left_child():
# Se está a esquerda do pai
if self.is_left_child():
self.parent.left_child = self.left_child
else:
# Senão, está a direita do pai
self.parent.right_child = self.left_child
self.left_child.parent = self.parent
else:
# Senão, o nó tem filho a direita
Se está a esquerda do pai
if self.is_left_child():
self.parent.left_child = self.right_child
else:
# Senão, está a direita do pai
self.parent.right_child = self.right_child
self.right_child.parent = self.parent
Dessa forma, a função remove completa é apresentada a seguir. Note que ela é a união de todos os
casos discutidos previamente. Esse é um método da classe BinarySearchTree.
Grafos são estruturas matemáticas que representam relações binárias entre elementos de um
conjunto finito. Em termos gerais, um grafo consiste em um conjunto de vértices que podem estar
ligados dois a dois por arestas. Se dois vértices são unidos por uma aresta, então eles são vizinhos.
É uma estrutura fundamental para a computação, uma vez que diversos problemas do mundo real
podem ser modelados com grafos, como encontrar caminhos mínimos entre dois pontos, alocação
de recursos e modelagem de sistemas complexos.
Obs: Convenção
Grafo não direcionado: (a,b) = (b,a)
Grafo direcionado ou dígrafo: <a,b> != <b,a>
Denotamos por N(v) o conjunto vizinhança do vértice v. Por exemplo, N(b) = {a, c}
Handshaking Lema: A soma dos graus dos vértices de G é igual a duas vezes o número de arestas.
n
BASE: Note que nesse caso, n = 1, o que implica dizer que temos um único vértice. Então, para
todo m≥0 (número de arestas), a soma dos graus será sempre um número par pois ambas as
extremidades das arestas incidem sobre o único vértice de G. (OK)
Ao passar de k para k + 1 vértices, temos duas opções: a) o grau do novo vértice é zero; b) o grau
do novo vértice é maior que zero.
Caso a): Nessa situação, temos o número de arestas permanece inalterado, ou seja, m = m’. Pela
hipótese de indução e sabendo que o grau no novo vértice é zero, podemos escrever:
k+1 k
Caso b): Nessa situação, temos que o número de arestas m’ > m. Seja m’ = m + a, onde a denota o
número de arestas adicionadas ao inserir o novo vértice k + 1. Então, pela hipótese de indução e
sabendo que o grau do novo vértice será a, podemos escrever:
k+1 k
ou seja, P(k + 1) é valida. Note que mesmo que alguma das arestas inseridas possuam ambas as
extremidades no novo vértice k + 1, a soma dos graus também será igual a 2m + 2 a, o que
continuará validando P(k + 1). Portanto, a prova está concluída.
Teorema: Em um grafo G = (V, E) o número de vértices com grau ímpar é sempre par.
Podemos particionar V em 2 conjuntos: P (grau par) e I (grau ímpar). Assim,
n
Isso implica em
∑ d (u)=2 m−∑ d ( v)
u ∈I v∈ P
Como 2m é par e a sima de números pares é sempre par, resulta que a soma dos números ímpares
também é par. Para que isso ocorra temos que ter |I| par (número de elementos do conjunto I é par)
K4
K5
n! n (n−1)
O número de arestas do grafo Kn é dado por
(n2)= (n−2)! 2!
=
2
Obs: G+ Ḡ=K n
Def: Subgrafo
Seja G = (V, E) um grafo. Dizemos que H = (V’, E’) é um subgrafo de G se V ' ⊆V e E '⊆ E
Em outras palavras, é todo grafo que pode ser obtido a partir de G através de remoção de vértices
e/ou arestas.
Caminhos e ciclos
P = v1 e1 v2 e1 v1 e1 v2
T = v1 e1 v2 e3 v3 e4 v2
C = v1 e1 v2 e3 v3 e6 v4 e7 v5
A i , j= 1, i←→ j
{0, c . c
0 1 1 1
A=
1
1
1
[ ] 0
0
1
0
0
0
1
0
0
Propriedades básicas
i) diag(A) = 0
ii) matriz binária
iii) A= A T (com exceção de dígrafos)
iv) ∑ Ai , j=d (v i )
j
v) Esparsa
vi) O(n2) em espaço
e 1 e 2 e 3 e 4 e5 e6
1 1 1 0 0 0 a
M= 1
0
0
[ 0
1
0
0
0
1
1
1
0
1
0
1
0
1
1
] b
c
d
A estrutura de dados básica utilizada é um dicionário de dicionários de dicionários, que simula uma
tabela hash na forma de uma lista de adjacências. As chaves do dicionário são os nós de modo que
G[u] retorna um dicionário cuja chave é o extremo da respectiva aresta e o campo valor é um
dicionário para os atributos da aresta. A expressão G[u][v] retorna o dicionário que armazena os
atributos da aresta (u,v). O código em Python a seguir mostra como podemos criar e manipular um
grafo utilizando a biblioteca NetworkX.
# Adiciona vértices
G.add_node('v1')
G.add_node('v2')
G.add_node('v3')
G.add_node('v4')
G.add_node('v5')
# Adiciona arestas
G.add_edge('v1', 'v2')
G.add_edge('v2', 'v3')
G.add_edge('v3', 'v4')
G.add_edge('v4', 'v5')
G.add_edge('v5', 'v1')
G.add_edge('v2', 'v4')
# Lista os vértices
print('Lista de vértices')
print(G.nodes())
input()
# Lista as arestas
print('Lista de arestas')
print(G.edges())
input()
plt.figure(1)
# Há vários layouts, mas spring é um dos mais bonitos
nx.draw_networkx(G, pos=nx.spring_layout(G), with_labels=True)
plt.show()
Um excelente guia de referência oficial para a biblioteca NetworkX pode ser encontrada em:
https://networkx.github.io/documentation/stable/_downloads/networkx_reference.pdf
Um problema recorrente no estudo dos grafos consiste em determinar sob quais condições 2 grafos
são de fato idênticos, ou seja, queremos saber se G1 “é igual” a G2. Dizemos que grafos que
satisfazem essa condição de igualdade são isomorfos (iso = mesma, morfos = forma).
v 1 ←e →v 2 ⇔ f (v 1)← g(e)→f ( v 2)
v a b c d e
f(v) A C E B D
e e1 e2 e3 e4 e5
f(e) z2 z3 z4 z5 z1
i) e1 = (a, b) → g(e1) deve ser a aresta que une f(a) com f(b): (A, C) = z2
ii) e2 = (b, c) → g(e2) deve ser a aresta que une f(b) com f(c): (C, E) = z3
iii) e3 = (c, d) → g(e3) deve ser a aresta que une f(c) com f(d): (C, E) = z4
iv) e4 = (d, e) → g(e4) deve ser a aresta que une f(d) com f(e): (B, D) = z5
v) e3 = (e, a) → g(e5) deve ser a aresta que une f(e) com f(a): (D, A) = z1
Portanto, os grafos G1 e G2 são isomorfos.
Propriedades Invariantes
Ex:
Note que G é isomorfo a G1. Porém, H não é isomorfo a H1 pois enquanto H admite ciclo de
comprimento 3, H1 não admite.
Ex:
Número de vértices: 8
Número de arestas: 8
Lista de graus: (3, 3, 3, 3, 4, 4, 4, 4)
Ciclos de comprimentos 3, 4, 5, 6, 7 e 8 → OK
Note que, apesar de não ferir nenhuma das propriedades invariantes, não podemos afirmar que os
grafos são isomorfos. Na verdade, os grafos em questão não são isomorfos. Note que em um deles,
todos os vértices de grau 4 (vermelho) possuem como vizinhos exatamente um outro vértice
vermelho, enquanto no outro, cada vértice de grau 4 possui exatamente 2 vértices vermelhos. Isso
significa uma ruptura na topologia, o que implica em corte e posterior religação de aresta.
Note que no primeiro grafo os vértices de grau 2 são adjacentes, o que não ocorre no segundo.
Algoritmo: Dadas duas matrizes de adjacências A1 e A2, permutar linhas e colunas de A1 de modo
a chegar em A2. Em termos de complexidade, note que para as linhas temos o seguinte cenário
o que resulta num total de n! possibilidades. O mesmo vale para as colunas, de forma que a
complexidade do algoritmo é da ordem de O(n!), tornando o método inviável para a maioria dos
grafos. Atualmente, ainda não se conhecem algoritmos polinomiais para esse problema. Para
algumas classes de grafos o problema é polinomial (árvores, grafos planares). Esse é o primeiro dos
problemas que veremos que ainda não tem solução: o problema do isomorfismo entre grafos pode
ser resolvido em tempo polinomial? Não se conhece resposta completa para a pergunta.
Ex: Os seis grafos a seguir consistem de três pares isomorfos. Quais são esses pares?
Ex: Os dois grafos a seguir são isomorfos ou não? Prove sua resposta.
Ex: Um grafo simples G é chamado de autocomplementar se ele for isomorfo ao seu próprio
complemento. Quais dos grafos a seguir são autocomplementares?
Buscar elementos num grafo G é basicamente o processo de extrair uma árvore T a partir de G. Mas
porque? Como busca se relaciona com uma árvore? Isso vem de uma das propriedades das árvores
Numa arvore existe um único caminho entre 2 vértices u, v. Considere uma árvore binária
Pode-se criar um esquema de indexamento baseado nos nós a esquerda e a direita. Cada elemento
do conjunto possui um índice único que o recupera. No caso de árvores genéricas, o caminho faz o
papel do índice único
Portanto, dado um grafo G, extrair uma árvore T com raiz r a partir dele, significa indexar
unicamente cada elemento do conjunto.
Busca em Largura (Breadth-First Search – BFS)
Ideia geral: a cada novo nível descoberto, todos os vértices daquele nível devem ser visitados antes
de prosseguir para o próximo nível
PSEUDOCÓDIGO
BFS(G, s)
{
for each v ∈V −{s } {
v.color = WHITE
λ (v )=∞
π (v )=nil
}
s.color = GRAY
λ (s )=0
π (v )=nil
Q=∅
push(Q, s)
while Q≠∅ {
u = pop(Q)
for each v ∈ N (u) // para todo vizinho de u
{
if v.color == WHITE // se ainda não passei por aqui, processo vértice v
{
λ (v )=λ(u)+1 // v é descendente de u então distancia +1
π (v )=u
v.color = GRAY
push(Q, v) // adiciona v no final da fila
}
}
u. color = BLACK // Após explorar todos vizinhos de u, finalizo u
}
}
O algoritmo BFS recebe um grafo não ponderado G e retorna uma árvore T, conhecida como BFS-
tree. Essa árvore possui uma propriedade muito especial: ela armazena os menores caminhos da raíz
s a todos os demais vértices de T (menor caminho de s a v, ∀ v∈V )
v s a b c d e f t
π (v ) --- s a b a a s b
λ (v ) 0 1 2 3 2 2 1 3
Note que a árvore nada mais é que a união dos caminhos mínimos de s (origem) a qualquer um dos
vértices do grafo (destinos). A BFS-tree geralmente não é única, porém todas possuem a mesma
profundidade (mínima distância da raiz ao mais distante)
Pergunta: Como podemos implementar um script para computar o diâmetro de um grafo G usando a
BFS? Pense em termos do cálculo da excentricidade de cada vértice.
Teorema: A BFS sempre termina com λ (v )=d(s , v) para ∀ v∈V , onde d(s,v) é a
distância geodésica (menor distância entre s e v).
2. Suponha que ∃v ∈V tal que λ (v )>d (s , v ) , onde v é o primeiro vértice que isso ocorre ao
sair da fila Q
7. Assim, temos
λ (v )>d (s , v )=d (s , u)+1=λ(u)+1
(2) (6) (5)
e portanto λ (v )>λ (u)+1 (*) , o que é uma contradição pois só existem 3 possibilidades quando
u sai da fila Q, ou seja, u = pop(Q)
ii) v é BLACK: se isso ocorre significa que v sai da fila Q antes de u, ou seja, λ (v )<λ (u)
(contradição)
iii) v é GRAY: então v foi descoberto por um w removido de Q antes de u, ou seja, λ (w)≤λ (u) .
Além disso, λ (v )=λ( w)+ 1 . Assim, temos λ (w)+1≤λ (u)+1 , o que finalmente implica em
λ (v )≤λ(u)+1 (contradição)
Ideia geral: a cada vértice descoberto, explorar um de seus vizinhos não visitados (sempre que
possível). Imita exploração de labirinto, aprofundando sempre que possível.
DFS(G, s)
{
for each u∈V
{
u.color = WHITE
π (u)=nil
}
time = 0 // variável global para armazenar o tempo
DFS_visit(G, s)
}
Para implementar a versão iterativa (não recursiva) da Busca em Profundidade (DFS), basta usar o
mesmo algoritmo da Busca em Largura (BFS) trocando a fila por uma pilha.
Da mesma forma que o algoritmo BFS, esse método recebe um grafo G não ponderado e retorna
uma árvore, a DFS_tree.
Ex:
u u.color u.d V’ = { v ∈N (u) / v.color = WHITE} π (u) u.f
s G 1 {a, f} -- 16
a G 2 {b, d, e} s 15
b G 3 {c, t} a 14
c G 4 {d, t} b 13
d G 5 {e, f} c 12
e G 6 {f, t} d 11
f G 7 ∅ e 8
t G 9 ∅ e 10
BFS x DFS
- aspecto espacial - aspecto temporal
- caminhos mínimos - vértices de corte, ordenação topológica
- Fila - Pilha
Def: v é um vértice de corte ⇔ v tem um filho s tal que ∄ fb_edge ligando s ou qualquer
descendente de s a um ancestral de v
Perguntas:
a) b é vértice de corte? Não pois fb_edge (a, d) liga um sucessor a um antecessor
b) d é vértice de corte? Sim, pois não há fb_edge entre sucessor e antecessor
c) e é vértice de corte? Sim, pois não há fb_edge
# atualizar cor de u
G.nodes[u]['color'] = 'black'
print('Plotando grafo...')
# Cria figura para plotagem do grafo
plt.figure(1)
# Há vários layouts, mas spring é um dos mais bonitos
nx.draw_networkx(G, pos=nx.spring_layout(G), with_labels=True)
# Exibir figura
plt.show()
As figuras a seguir mostram os resultados da execução do script anterior: na primeira vemos o grafo
reticulado 2D com 25 vértices (grade 5 x 5), e na segunda vemos a árvore de busca em largura.
Note que a raiz da árvore é o vértice (0, 0). Além disso, note que a árvore resultante não é binária,
no sentido de que todo nó tem exatamente dois filhos. A definição mais geral de árvore é a seguinte:
Uma árvore T = (V, E) é um grafo acíclico (sem ciclos) e conexo (existe um caminho entre qualquer
par de vértices). Note que o grafo indicado na figura acima satisfaz essas duas condições, sendo
portanto uma árvore.
Aula 10 – Grafos: O problema da árvore geradora mínima
Árvores são grafos especiais com diversas propriedades únicas. Devido a essas propriedades são
extremamente importantes na resolução de vários tipos de problemas práticos. Veremos ao longo do
curso que vários problemas que estudaremos se resumem a: dado um grafo G, extrair uma árvore T
a partir de G, de modo que T satisfaça uma certa propriedade (como por exemplo, mínima
profundidade, máxima profundidade, mínimo peso, mínimos caminhos, etc).
Teoremas e propriedades
(ida) p → q = !q → !p
∄ um único caminho entre quaisquer u,v → G não é uma árvore
a) Pode existir um par u,v tal que ∄ caminho (zero caminhos). Isso implica em G desconexo, o
que implica que G não é uma árvore
b) Pode existir um par u,v tal que ∃ mais de um caminho.
Porém neste caso temos a formação de um ciclo e portanto G não pode ser árvore.
(volta) q → p = !p → !q
G não é árvore → ∄ único caminho entre quaisquer u,v
Para G não ser árvore, G deve ser desconexo ou conter um ciclo. Note que no primeiro caso existe
um par u,v tal que não há caminho entre eles. Note que no segundo caso existem 2 caminhos entre u
e v, conforme ilustra a figura
Def: Uma aresta e∈ E é ponte se G−e é desconexo
Ou seja, a remoção de uma aresta ponte desconecta o grafo
(ida) p → q = !q → !p
e∈C → aresta não é ponte
Como aresta pertence a um ciclo C, há 2 caminhos entre u e v. Logo a remoção da aresta e = (u,v)
não impede que o grafo seja conexo, ou seja, G – e ainda é conexo. Portanto, e não é ponte
(volta) q → p = !p → !q
aresta não é ponte → e∈C
Se aresta não é ponte então G – e ainda é conexo. Se isso ocorre, deve-se ao fato de que em G – e
ainda existe um caminho entre u e v que não passa por e. Logo, em G existem existem 2 caminhos,
o que nos leva a conclusão de que a união entre os 2 caminhos gera um ciclo C.
A existência de uma aresta não ponte implica na existência de ciclo. A presença de um ciclo C faz
com que G não seja um árvore
(volta) q → p = !p → !q
G não á árvore → ∃ aresta não ponte
Para G não ser uma árvore, deve existir um ciclo em G. Logo, todas as arestas pertencentes ao ciclo
não são pontes.
Teorema: Se G = (V, E) é uma árvore com |V| = n então |E| = n – 1
4. Note que T’ é uma árvore, pois como T é conexo, T’ também é e como T é acíclico, T’ também é.
Logo, podemos dizer que T’ = (V’T, E’T) possui n’ vértices e m’ arestas: |V’T| = n’ e |E’T| = m’.
5. Mas pela hipótese de indução, toda árvore de n’ vértices tem n’-1 arestas, então m’ = n’ – 1
6. Como T’ tem exatamente uma aresta e um vértice a menos que T:
m’ = m – 1 = n’ – 1 = (n – 1) – 1
Teorema: A soma dos graus de uma árvore de n vértices não depende da lista de graus, sendo dada
por 2n – 2
n
∑i=1 d ( v i)=2|E|=2(n−1)=2 n−2
Def: Árvore geradora (spanning tree)
Seja G = (V, E) um grafo. Dizemos que T = (V, E T) é uma árvore geradora de G se T é um subgrafo
de G que é uma árvore (ou seja tem que conectar todos os vértices)
Como pode ser visto, um grafo G admite inúmeras árvores geradoras. A pergunta que surge é:
Quantas árvores geradoras existem num grafo G = (V, E) de n vértices?
Até o presente momento, estamos lidando com grafos sem pesos nas arestas. Isso significa que dado
um grafo G, temos inúmeras formas de obter uma árvore geradora T (vimos que existem muitas
dessas árvores num grafo de n vértices). A partir de agora, iremos lidar com grafos ponderados, ou
seja, grafos em que as arestas possuem um peso/custo de conexão. O objetivo da seção em questão
consiste em fornecer algoritmos para resolver o seguinte problema: dado um grafo ponderado G,
obter dentre todas as árvores geradoras possíveis, aquela com o menor peso.
Definição do problema: Dado G = (V, E, w), onde w: E → R + (peso da aresta e), obter a árvore
geradora T que minimiza o seguinte critério:
w (T )=∑e∈T w (e) (soma dos pesos das arestas que compõem a árvore)
A i , j= w ij , i←→ j
{ ∞, c.c
Ideia geral: a cada passo escolher a aresta de menor peso que seja segura
Aresta segura = aresta que ao ser inserida não faz a árvore deixar de ser árvore
Como determinar se uma aresta é segura?
Cada algoritmo propõe suas especificações próprias para isso
Algoritmo de Kruskal
Objetivo: escolher a cada passo a aresta de menor peso que não forme um ciclo
Entrada: G = (V, E, w) conexo
Saída: T = (V, ET)
1. Defina T = (V, ET) como o grafo nulo de n vértices
2. Enquanto |ET| < n - 1
a)T = T + {e}, com e sendo a aresta de menor peso em G que não forma m ciclo em T
b) E = E - {e}
Segundo Cormen, uma metodologia para detecção de ciclos pode ser implementada utilizando a
seguinte ideia: inicialmente cada vértice é colocado num grupo distinto e cada vez que uma aresta é
inserida, os dois vértices extremidades passam a fazer parte do mesmo grupo. Assim, arestas que
ligam vértices do mesmo grupo, formam ciclos e devem ser evitadas.
Trata-se de um algoritmo Guloso (segue estratégia de resolver problemas fazendo sempre a escolha
ótima em cada passo. Nesse caso, sempre tenta a aresta de menor peso). A seguir iremos realizar um
trace do algoritmo (simulação passo a passo). Para isso iremos considerar a seguinte notação:
Ex: Suponha que os vértices representem bairros e as arestas com pesos os custos de interligação
desses bairros (fibra ótica)
k E
-
E
+
ek
1 - {(g,h)} (g,h)
2 - {(c,i), (f,g)} (c,i)
3 - {(g,f)} (g,f)
4 - {(a,b), (c,f)} (a,b)
5 - {(c,f)} (c,f)
6 {(g,i)} - -
7 {(h,i)} {(c,d)} (c,d)
8 - {(a,h), (b,c)} (a,h)
9 {(b,c)} - -
10 - {(d,e)} (d,e)
2. Seja e k ∈T a primeira aresta adicionada em T que não está em S (pois árvores são diferentes)
3. Faça H=(S +e k ) . Note que H não é mais uma árvore e contém um ciclo
4. Note que no ciclo C, ∃e∈ S tal que e∉T (pois senão C existiria em T). O subgrafo
H−e é conexo, possui n - 1 arestas e define uma árvore geradora de G.
5. Porém, w (e k )≤w(e) e assim w (H−e )≤w (S ) (pois de acordo com Kruskal ek vem
antes de e na lista ordenada de arestas, é garantido pela ordenação)
6. Repetindo o processo usado para gerar H−e a partir de S é possível produzir uma sequência
de árvores que se aproximam cada vez mais de T.
de modo que
Portanto, não existe árvore com peso menor que T, mostrando que T tem peso mínimo. (Não há
como S ter peso menor que T). A complexidade do algoritmo de Kruskal depende das primitivas
utilizadas, mas pode-se mostrar que é possível ter complexidade O(m log n), com |V| = n e |E| = m.
Algoritmo de Prim
Ideia geral: Inicia em uma raiz r. Enquanto T não contém todos os vértices de G, o algoritmo
iterativamente adiciona a T a aresta de menor peso que sai do conjunto dos vértices finalizados (S) e
chega no conjunto dos vértices em aberto (V - S)
Definições de variáveis
λ (v ) : menor custo estimado de entrada para o vértice v (até o presente momento)
π (v ) : predecessor de v na árvore (vértice pelo qual entrei em v)
Q: fila de prioridades dos vértices (maior prioridade = menor λ (v ) )
MST_Prim(G, w, r) {
for each v ∈V {
λ (v )=∞
π (v )=nil
}
λ (r )=0 (raiz deve iniciar com custo zero pois é primeira a sair da fila)
Q=V (fila de prioridades inicial é todo conjunto de vértices)
S=∅
while Q≠∅ {
u = ExtractMin(Q) (remove da fila o vértice de menor prioridade)
S=S∪{u } (já obtive o menor custo de entrada para u)
for each v ∈ N (u) {
(se não estiver na fila Q, não pode mais modificar λ (v ) )
if ( v ∈Q and λ (v )>w (u , v ) ) { (entrada de menor custo)
λ (v )=w(u , v ) (atualiza o custo para o menor valor)
π (v )=u (muda o predecessor)
}
}
}
}
if ( v ∈Q and λ (v )>w (u , v ) ) { if v ∈Q {
λ (v )=w(u , v ) λ (v )=min {λ( v ), w (u , v )}
π (v )=u if ( λ (v ) was updated )
} π (v )=u
}
Fila
a b c d e f g h i
∞ ∞ ∞ ∞ ∞ ∞ ∞ ∞
λ(0) (v ) 0
4 ∞ ∞ ∞ ∞ ∞ 8 ∞
λ(1) ( v)
(2)
8 ∞ ∞ ∞ ∞ 8 ∞
λ (v)
7 ∞ 4 ∞ 8 2
λ(3) (v)
(4 )
7 ∞ 4 6 7
λ (v )
(5)
λ (v) 7 10 2 7
(6)
λ (v ) 7 10 1
λ(7) (v ) 9
MST
w(T) = 37
v a b c d e f g h i
π (v ) -- a b c d c f g c
λ (v ) 0 4 8 7 9 4 2 1 2
A seguir veremos um resultado fundamental para provar a otimalidade do algoritmo de Prim.
Prova por contradição: Seja T* uma MST de G. Suponha que e∉T * . Ao adicionar e em T* cria-
se um único ciclo C. Para que T* - e fosse uma MST, tem que haver alguma outra aresta f com
apenas uma extremidade em S (senão T* seria desconexo). Então T =T * + e−f também é árvore
geradora. Como, w(e) < w(f), segue que T tem peso mínimo. Portanto, T* não pode ser MST de G.
Quanto a complexidade computacional do algoritmo de Prim, pode-se mostrar que no pior caso, em
que temos um grafo completo Kn, onde cada vértice se liga a todos os outros vértices, em cada
iteração são verificadas n – 1 arestas. Como temos n iterações, o custo computacional é quadrático,
ou seja O(n2).
O código em Python a seguir mostra uma implementação do algoritmo de Prim para encontrar a
MST de uma grafo em que as arestas são ponderadas aleatoriamente.
# Adiciona bibliotecas auxiliares
import random
import networkx as nx
import matplotlib.pyplot as plt
# Extrai o vértice de menor lambda da fila Q
def extractMin(Q, H):
# Encontra o vértice de menor lambda em Q
u = Q[0]
for node in Q:
# O vértice u armazena o vértice de menor lambda até o momento
if H.nodes[node]['lambda'] < H.nodes[u]['lambda']:
u = node
# Remove u da fila
del Q[Q.index(u)]
# Retorna u
return u
if __name__ == '__main__':
# Cria um grafo de exemplo
G = nx.krackhardt_kite_graph()
# Adicionando peso nas arestas
for u, v in G.edges():
G[u][v]['weight'] = random.randint(1, 10)
print('Plotando grafo...')
# Cria figura para plotagem
plt.figure(1)
# Há vários layouts, mas spring é um dos mais bonitos
pos = nx.spring_layout(G)
nx.draw_networkx(G, pos, with_labels=True)
labels = nx.get_edge_attributes(G, 'weight')
nx.draw_networkx_edge_labels(G, pos, edge_labels=labels)
# Exibir figura
plt.show()
Portanto, com o algoritmo de Dijkstra podemos resolver qualquer tipo de problema de caminhos
mínimos em grafos ponderados. Essa é uma das grandes vantagens desse método.
é o menor possível.
2. Suponha que subcaminhos de caminhos ótimos não sejam ótimos (negação da conclusão), ou
seja, que exista um caminho mínimo de s a c que não passa por a e b mas sim por x, ou seja, P’ =
sxc.
3. Ora, se isso é verdade, então claramente P* não é ótimo pois é possível minimizá-lo ainda mais,
criando P = sxcdz, o que gera uma contradição. Assim, subcaminhos de caminhos ótimos devem ser
ótimos
Primitiva relax
Ideia geral: é uma boa ideia passar por u para chegar em v sabendo que o custo de ir de u até v é w?
PSEUDOCODIGO
relax(u, v, w) relax(u, v, w)
{ {
if λ (v )>λ (u)+ w(u , v) λ (v )=min {λ (v ), λ(u)+w (u , v )}
{ if λ (v ) was updated
λ (v )=λ(u)+ w(u , v ) π (v )=u
π (v )=u }
}
}
O que varia nos diversos algoritmos para encontrar caminhos mínimos são os seguintes aspectos:
i) Quantas e quais arestas devemos relaxar?
ii) Quantas vezes devemos relaxar as arestas?
iii) Em que ordem devemos relaxar as arestas?
A seguir veremos um algoritmo muito mais eficiente para resolver o problema: o algoritmo de
Dijkstra. Basicamente, esse algoritmo faz uso de uma política de gerenciamento de vértices baseada
em aspectos de programação dinâmica. O que o método faz é basicamente criar uma fila de
prioridades para organizar os vértices de modo que quanto menor o custo λ (v ) maior a
prioridade do vértice em questão. Assim, a ideia é expandir primeiramente os menores ramos da
árvore de caminhos mínimos, na expectativa de que os caminhos mínimos mais longos usarão como
base os subcaminhos obtidos anteriormente. Trata-se de um mecanismo de reaproveitar soluções de
subproblemas para a solução do problema como um todo.
PSEUDOCODIGO
Dijkstra(G, w, s)
{
for each v ∈V
{
λ (v )=∞
π (v )=nil
}
λ (s )=0
π (s)=nil
Q=V (fila de prioridades)
while Q≠∅
{
u = ExtractMin(Q)
S=S∪{u }
for each v ∈ N (u)
relax(u, v, w) // relaxa toda aresta incidente a u
} // usa sub-caminho ótimo para gerar o caminho maior
} // por isso não precisa relaxar todas as arestas
Ex:
Fila
s a b c d t
∞ ∞ ∞ ∞ ∞
λ (v ) 0
(0)
(1)
18 ∞ 15 ∞ ∞
λ ( v)
(2)
17 29 22 ∞
λ (v)
(3)
26 22 ∞
λ (v)
λ(4 ) (v ) 26 58
(5)
λ (v) 54
v s a b c d t
π (v ) --- c a s c b
3. Assim, existe um caminho Psu pois senão λ (u)=d (s , u)=∞ . Portanto, existe um caminho
mínimo P*su
*
4. Antes de adicionar u a S, Psu possui s ∈S e u∈V −S
P*su = s → xy → u
p1 p2
6. Como x∈ S , λ (x)=d (s , x ) e no momento em que ele foi inserido a S, a aresta (x,y) foi
relaxada, ou seja:
d ( s , y)≤d (s , u)
e portanto
9. Como λ ( y )≤λ (u) e λ (u)≤λ ( y ) então temos que λ (u)=λ ( y ) , o que implica em:
o que gera uma contradição. Portanto ∄u ∈V tal que λ (u)≠d ( s , u) quando u entra em S.
Assim como o algoritmo de Prim, no pior caso, o algoritmo de Dijkstra possui complexidade
quadrática, ou seja, O(n2), uma vez que em grafos completos, cada vértice se liga a todos os demais
(possuem grau n – 1) e como temos n vértices na fila de prioridades, o número de operações é uma
função quadrática do número de vértices n
Dijkstra multisource
Processo de competição: cada vértice pode ser conquistado por apenas uma das sementes (pois ao
fim, um vértice só pode estar pendurado em uma única árvore)
Durante a execução do algoritmo, nesse processo de conquista, uma semente pode “roubar” um nó
de seus concorrentes, oferecendo a ele um caminho menor que o atual
Ao final temos o que se chama de floresta de caminhos ótimos, composta por várias árvores (uma
para cada semente)
Fila
a b c d e f g h i
∞ ∞ ∞ ∞ ∞ ∞ ∞
λ (v ) 0
(0)
0
4 ∞ ∞ 0 ∞ ∞ 8 ∞
λ(1) ( v)
4 ∞ 9 10 ∞ 8 ∞
λ(2) (v)
12 9 10 ∞ 8 ∞
λ(3) (v)
λ(4 ) (v ) 12 9 10 9 15
(5)
λ (v) 11 10 9 15
λ(6) (v ) 11 10 14
(7)
λ (v ) 11 14
(8)
λ (v ) 13
A heurística A*
É uma técnica aplicada para acelerar a busca por caminhos mínimos em certos tipos de grafos. Pode
ser considerado uma generalização do algoritmo de Dijkstra. Um dos problemas com o algoritmo de
Dijkstra é não levar em consideração nenhuma informação sobre o destino. Em grafos densamente
conectados esse problema é amplificado devido ao alto número de arestas e aos muitos caminhos a
serem explorados. Em suma, o algoritmo A* propõe uma heurística para dizer o quanto estamos
chegando próximos do destino através da modificação da prioridades dos vértices na fila Q. É um
algoritmo muito utilizado na IA de jogos eletrônicos.
o que significa que no Dijkstra, todos eles teriam a mesma prioridade. Note porém que, utilizando a
distância Euclidiana para obter uma estimativa de distância até a origem, temos:
γ(a)=γ(d)= √ 4+ 9=√ 13
γ(b)=γ( c)= √ 9+16=√ 25=5
1+ √ 13<1+5
e portanto a e d saem da fila de prioridades antes. Isso ocorre pois no A* eles são considerados mais
importantes. O mesmo ocorre nos demais níveis
A ideia é que o alvo t atraia o caminho. Se t se move, a busca por caminhos mínimos usando A*
costuma ser bem mais eficiente que o Dijkstra em casos como esse.
Ex: O grafo ponderado a seguir ilustra um conjunto de cidades e os pesos das arestas são as
distâncias entre elas. Estamos situados na cidade s e deseja-se encontrar um caminho mínimo até a
cidade e. O números em vermelho indicam o valor de γ(v) , ou seja, são uma estimativa para a
distância de v até o destino e. Execute o algoritmo A* para obter o caminho mínimo de s a e.
Fila
s a b c d e f g h i j k l
∞ ∞ ∞ ∞ ∞ ∞ ∞ ∞ ∞ ∞ ∞ ∞
γ ( v) 0
(0)
(1 )
7+9 2+7 3+8 ∞ ∞ ∞ ∞ ∞ ∞ ∞ ∞ ∞
γ (v )
(2)
5+9 3+8 6+8 ∞ ∞ ∞ 3+6 ∞ ∞ ∞ ∞
γ (v )
(3 )
5+9 3+8 6+8 ∞ 6+6 5+3
γ (v )
(4)
γ ( v) 5+9 3+8 6+8 7 6+6
if __name__ == '__main__':
# Cria um grafo de exemplo
G = nx.krackhardt_kite_graph()
# Adicionando peso nas arestas
for u, v in G.edges():
G[u][v]['weight'] = random.randint(1, 10)
print('Plotando grafo...')
# Cria figura para plotagem
plt.figure(1)
# Há vários layouts, mas spring é um dos mais bonitos
pos = nx.spring_layout(G)
nx.draw_networkx(G, pos, with_labels=True)
labels = nx.get_edge_attributes(G, 'weight')
nx.draw_networkx_edge_labels(G, pos, edge_labels=labels)
# Exibir figura
plt.show()
MILLER, B. e RANUM, D. Problem Solving with Algorithms and Data Structures using Python,
2011. Disponível em online em:
https://runestone.academy/runestone/books/published/pythonds3/index.html
MILLER, B.; RANUM, D.; Como Pensar como um Cientista da Computação: Versão Interativa.
Disponível em: https://panda.ime.usp.br/pensepy/static/pensepy/index.html
LEE, K. D., HUBBARD, S., Data structures and algorithms with Python, Undergraduate Topics in
Computer Science, Springer, 2015.
NECAISE, R. D., Data structures and algorithms using Python, John Wiley & Sons, 2011.
MEDINA, M., FERTIG, C. Algoritmos e programação: teoria e prática. 2. ed. São Paulo: Novatec
Editora, 2006.
Autômatos celulares
https://en.wikipedia.org/wiki/Rule_110,
http://www.complex-systems.com/pdf/15-1-1.pdf
MAY, R. M. "Simple mathematical models with very complicated dynamics". Nature. 261 (5560):
459–467, 1976. Available at: http://abel.harvard.edu/archive/118r_spring_05/docs/may.pdf
Sobre o autor