Você está na página 1de 46

ESTRUTURAS DE DADOS

Prof. Cristhian Riaño


Tópicos
● Introdução;
● Complexidade no desempenho de algoritmos;
● Medidas de complexidade;
● Critérios de Complexidade;
● Ordens assintóticas ;
● Exemplos.
Objetivo
● O objetivo deste capítulo é introduzir os conceitos básicos de complexidade
(medidas e critérios) e de análise assintotica.
● A analise de algoritmos tem como objetivo melhorar, se possivel, seu
desempenho e escolher, entre os algoritmos disponiveis, o melhor.
Introdução
● Na prática, é fundamental que um programa produza a solução com
dispêndio de tempo e de memória razoáveis.
● Um algoritmo resolve um problema quando, para qualquer entrada, produz
uma resposta correta, se forem concedidos tempo e memória suficientes para
sua execução.
● O fato de um algoritmo resolver um problema não significa que seja aceitável
na prática. Os recursos de espaço e tempo requeridos têm grande
importância em casos práticos.
Algoritmos
Um algoritmo é qualquer procedimento computacional bem definido que toma
algum o conjunto de valores como entrada e produz algum valor ou conjunto de
valores como saída.
Um algoritmo é um sequencia de passos computacionais que transformam a
entrada na saída.
Exemplo problema de ordenação
● Entrada: Uma sequência de n números < 𝑎1 , 𝑎2 , . . . , 𝑎𝑛 >.
● Saída: Uma permutação (reordenação) < 𝑎1′ , 𝑎2′ , . . . , 𝑎𝑛′ > da sequencia de
entrada, tal que 𝑎1′ ≤ 𝑎2′ ≤ . . . ≤ 𝑎𝑛′ .
Dada uma sequencia de entrada, < 31,41,59,26,41,58 > um algoritmo retorna
como saída a sequencia < 26,31,41,41,58,59 > .
Análise de algoritmos
● Alguns autores restringem a definição de algoritmo para procedimentos que
(eventualmente) terminam

○ Um algoritmo pode repetir um procedimento ou ação infinitamente.


● Se o tamanho do procedimento não é conhecido, não é possível determinar se
ele terminará (Marvin Minsky)
● Para algoritmos que não terminam, o sucesso não pode ser determinado pela
interpretação da resposta e sim por condições impostas pelo próprio
desenvolvedor do algoritmo durante sua execução

○ Exemplo: um algoritmo que nunca termina mas sempre mantém algum


invariante
Análise de algoritmos
● Um algoritmo deve:

○ Funcionar corretamente

○ Executar o mais rápido possível

○ Utilizar a memória da melhor forma possível


● A fim de sabermos mais sobre um algoritmo, podemos analisá-lo

○ Precisamos estudar as suas especificações e tirar conclusões sobre como


a sua implementação (o programa) irá se comportar em geral.
Análise de algoritmos
De um algoritmo podemos determinar:

○ O tempo de processamento de um programa como função de seus dados


de entrada;

○ O espaço de memória máximo ou total requerido para os dados do


programa

○ O comprimento total do código do programa

○ Se o programa chega corretamente ao resultado desejado


Análise de algoritmos
○ A complexidade do programa

■ Facilidade em ler, entender e modificar

○ A robustez do programa

■ Exemplo: como ele lida com entradas errôneas ou inesperadas


Análise de algoritmos
● Estaremos particularmente interessados em analisar o tempo de execução e o espaço de
memória utilizado
● Como comparar algoritmos em função do custo de tempo?

○ Computadores diferentes podem funcionar em frequências diferentes

■ Ainda, diferente hardware (processador, memória, disco, etc.), diferente SO, etc.

○ Compiladores podem otimizar o código antes da execução

○ Um algoritmo pode ser escrito diferente, de acordo com a linguagem de programação


utilizada

○ Além disso, uma análise detalhada, considerando todos estes fatores, seria difícil,
demorada e pouco significativa

■ Tecnologias mudam rapidamente


Análise de algoritmos
● Podemos medir o custo de tempo contando quantas operações são realizadas pelo
algoritmo

○ Atribuições, comparações, operações aritméticas, instruções de retorno, etc.


● Cada operação demora o mesmo tempo ?

○ Não, mas podemos simplificar nossa análise

○ Exemplo: i = i + 1

■ Análise detalhada: 2 x tempo de recuperar uma variável (i e 1) + 1 x tempo da


soma + 1 x tempo para armazenar o valor na variável (i)

■ Análise simplificada: 4 operações


Análise de algoritmos
Como saberemos quantas vezes o loop é executado ?

● Os dados de entrada determinarão quantas vezes o loop é executado

○ Como não faz sentido analisar um algoritmo para apenas um determinado conjunto
de entradas e é impossível fazer esta análise para todas as entradas possíveis,
consideraremos apenas dois cenários: o pior caso e o melhor caso
Análise de algoritmos

Pior Caso

2
3*(n+1) //sendo n o tamanho do vetor, já calculado
3*(n) //considerando o acesso a vetor[i] como uma única operação
0
4*(n)

1
Total = 10n + 6
Análise de algoritmos
Melhor Caso

2
3*(1)
3*(1) //considerando o acesso a vetor[i] como uma única operação
1
0

0
Total = 9
Análise de algoritmos
● Vamos partir do seguinte algoritmo
<1> para i ← 1 até n, faça
<2> para j ← 1 até i, faça
<3> imprima i × j × n
<4> fim-para
<5> fim-para

Para medir o custo do algoritmo, nossa análise consistirá em ver quantas vezes cada passo
é executado.
● Linha <1> Será executada n + 1 vezes.
● Linha <2> Será executada 𝑛 ∗ σ𝑛𝑖=1 𝑖 + 𝑛
● Linha <3> Será executada 𝑛 ∗ σ𝑛𝑖=1 𝑖
● Linha <4> Sem custo
● Linha <5> Sem Custo
Análise de algoritmos
○ Então agora, vamos escrever o tempo de execução do algoritmo, que é a soma
dos tempos de execução para cada instrução executada.

○ 𝑇 𝑛 = 𝑛 + 1 + (𝑛 ∗ σ𝑛𝑖=1 𝑖 + 𝑛) + (σ𝑛𝑖=1 𝑖)
Classificação de Algoritmos
● Iterativo: estruturas de repetições (laços, pilhas, etc.)
● Recursivo: invoca a si mesmo até que certa condição seja satisfeita

● Serial: cada instrução é executada em sequência


● Paralela: várias instruções executadas ao mesmo tempo

● Determinístico: decisão exata a cada passo


● Probabilístico: decisão provável em algum(s) passo(s)

● Exato: resposta exata


● Aproximado: resposta próxima a verdadeira solução
Complexidade de Algoritmos
● Análise de um algoritmo particular

○ Qual é o custo de usar um dado algoritmo para resolver um


problema específico ?

○ Características de devem ser investigadas:

■ Tempo: análise do número de vezes que cada parte do


algoritmo deve ser executada

■ Espaço: estudo da quantidade de memória necessária


Complexidade de Algoritmos
● Análise de uma classe de algoritmos

○ Qual é o algoritmo de menor custo possível para resolver um


problema particular ?

■ Toda uma família de algoritmos é investigada (busca pelo


melhor possível)

○ Coloca-se limites para a complexidade computacional dos


algoritmos pertencentes à classe
Complexidade de Algoritmos
● Custo de um algoritmo

○ Ao determinar o menor custo possível para resolver problemas de uma


classe, tem-se a medida da dificuldade inerente para resolver o problema

○Quando o custo de um algoritmo é igual ao menor custo possível, o


algoritmo é considerado ótimo
● Podem existir vários algoritmos para resolver o mesmo problema

○ É possível compará-los e escolher o mais adequado

○ Se há vários algoritmos com custo de execução dentro da mesma ordem


de grandeza, pode-se considerar tanto os custos reais como os custos
não aparentes, como: alocação de memória, carga de arquivos, etc.
Complexidade de Algoritmos
Análise pela execução

○ A eficiência do programa depende da linguagem (compilada ou interpretada)

○ Depende do sistema operacional

○ Depende do hardware (quantidade de memória, velocidade do processador, etc.)


● Análise pelo modelo matemático

○ Não depende do computador nem da implementação

○ O custo das operações mais signicativas deve ser especificado, e algumas


operações são desprezadas

○ É possível analisar a complexidade do algoritmo dependendo dos dados de


entrada
Complexidade de Algoritmos
Exemplo: descobrir o maior número em uma lista

○ Considerando somente atribuições como operações relevantes e ainda que todas


as atribuições possuem o mesmo custo

○ Como veremos adiante, conforme n aumenta, o valor de n passa a determinar o


custo destas funções

■ Podemos dizer que ambas possuem um custo O(n)


Complexidade de Algoritmos
● Qual o custo do seguinte algoritmo de soma ?

○ Considerando somente atribuições como operações relevantes e ainda que todas


as atribuições possuem o mesmo custo

○ Custo tempo: g(n) = 2 + 2n

■ 2 na inicialização e 2 por repetição

○ Custo (espaço): g(n) = 3

■ 3 variáveis (soma, i, n)
Complexidade no desempenho de algoritmos
Complexidade de um algoritmo é o esforço (quantidade de Trabalho) de um
algoritmo. As principais medidas de complexidade são tempo e espaço,
relacionadas à velocidade e quantidade de memória, respectivamente. A
complexidade depende da particular entrada: Os principais critérios são o pior
caso e caso médio.

A complexidade é determinada com base em operações fundamentais e no


tamanho da entrada. Ambos devem ser apropriados ao algoritmo ou problema. A
complexidade experimental costuma depender de detalhes de implementação,
variando de maquina a maquina.

Análise matemática fornece a complexidade intrínseca do algoritmo.


Complexidade no desempenho de algoritmos
Complexidade de um algoritmo é o esforço (quantidade de Trabalho) de um
algoritmo. As principais medidas de complexidade são tempo e espaço,
relacionadas à velocidade e quantidade de memória, respectivamente. A
complexidade depende da particular entrada: Os principais critérios são o pior
caso e caso médio.

A complexidade é determinada com base em operações fundamentais e no


tamanho da entrada. Ambos devem ser apropriados ao algoritmo ou problema. A
complexidade experimental costuma depender de detalhes de implementação,
variando de maquina a maquina.

Análise matemática fornece a complexidade intrínseca do algoritmo.


Análise de algoritmos
Tendo em vista que o comportamento de um algoritmo pode ser diferente para
cada entrada possível, precisamos de um médio para resumir este
comportamento em formulas simples, de fácil compreensão.

Um objetivo imediato é encontrar um médio de expressão que seja simples de


escrever e manipular, que mostre as características importantes de requisitos de
recursos de um algoritmo e que suprima os detalhes tediosos.

Em geral o tempo de duração de um algoritmo cresce com o tamanho da


entrada; assim, é tradicional descrever o tempo de execução de um programa
como função do tamanho de sua entrada. Para isso, precisamos definir os termos
“tempo de execução” e “tamanho da entrada” com mais cuidado.
Análise de algoritmos
Tamanho de entrada: Depende do problema que está sendo
estudado.
○ Ordenação ou calculo de transformações discretas de Fourier, a medida
mais natural é o número de itens na entrada – por exemplo o tamanho de
Arango n para ordenação;

○ multiplicação de dois inteiros a melhor medida do tamanho da entrada é o


número total de bits necessários para representar a entrada em notação
binaria comum;

○ Se a entrada para um algoritmo é um grafo, o tamanho da entrada pode


ser descrito pelos números de vértices e arestas no grafo.
Análise de algoritmos
Tempo de Execução: É o número de operações primitivas ou “Etapas”
executadas. É conveniente definir a noção de etapa (ou passos) de forma que
ela seja tão independente da máquina quanto possível.

○ Um período constante de tempo é exigido para executar cada linha de nosso pseudocódigo.
Uma única linha pode demorar um período diferente de outra linha. Vamos a considerar que
cada execução da i-ésima linha leva um tempo 𝑐𝑖 , onde 𝑐𝑖 é uma constante.

Para determinar o tempo de execução é necessário conhecer o numero de


instruções que ele realiza.
● Contar o número de instruções

○ Simples e Complexas
● Ccrescimento em função da entrada
Análise de algoritmos
Instruções simples: São aquelas podem ser executadas em linguagem de
maquina e diremos que medem 1 unidade de tempo. Exemplo de instruções
simples:
● Atribuições de valores de forma geral < a=0; >;
● Incremento de valores < i++; > ;
● Ooperações aritméticas mais complexas < math.sqrt(8); >;
● Acesso ao valor de um elemento em um vetor < x=A[1]; >;
● Eexpressões logica de forma geral;
● Ooperações de leitura e escrita;

Iinstruções complexas: Combinação de instruções simples, construídas através


de instruções de controle de fluxo e diremos que serão a soma das instruções
simples.
Análise de algoritmos
int k=0,l=0,i,j,valor; 1
printf ("\nDigite valor para n \n\n"); 1
scanf ("%d", &n);
for ( i=1; i<=n; i++ ){
l=l+1;
for ( j=1; j<=i; j++ )
{
valor = i*j*n;
printf ("\nvalor = ", valor);
k=k+1;
}}
printf ("\nvezes = %d", k);
printf ("\nvezes = %d", l);
printf ("\nvalor = %d", valor);
Análise de algoritmos
Procedimento de inserção: Custo Vezes

<1> For j←2 to comprimento [A] 𝑐1 𝑛


<2> do chave ← 𝐴[j] 𝑐2 𝑛 −1
<3> ⊳ Inserir A[j] na sequencia ordenada A[1..j-1]. 0 𝑛−1
<4> i ← j-1 𝑐4 𝑛−1
<5> while i>0 e A[i]> chave 𝑐5 σ𝑛𝑗=2 𝑡𝑗
<6> do A[i+1] ← A[i] 𝑐6 σ𝑛𝑗=2(𝑡𝑗 −1)
<7> i ←i-1 𝑐7 σ𝑛𝑗=2(𝑡𝑗 −1)
<8> A[i+1] ← chave 𝑐8 𝑛−1

O tempo de execução do algoritmo é a soma dos tempos de execução para cada


instrução executada; uma instrução que demanda 𝑐𝑖 passos para ser executada e é
executada n vezes, contribuirá com 𝑐1 𝑛 para o tempo de execução total.
Análise de algoritmos
Procedimento de inserção: Custo Vezes

<1> For j←2 to comprimento [A] 𝑐1 𝑛


<2> do chave ← 𝐴[j] 𝑐2 𝑛 −1
<3> ⊳ Inserir A[j] na sequencia ordenada A[1..j-1]. 0 𝑛−1
<4> i ← j-1 𝑐4 𝑛−1
<5> while i>0 e A[i]> chave 𝑐5 σ𝑛𝑗=2 𝑡𝑗
<6> do A[i+1] ← A[i] 𝑐6 σ𝑛𝑗=2(𝑡𝑗 −1)
<7> i ←i-1 𝑐7 σ𝑛𝑗=2(𝑡𝑗 −1)
<8> A[i+1] ← chave 𝑐8 𝑛−1

𝑇 𝑛 = 𝑐1 𝑛 + 𝑐2 𝑛 − 1 + 𝑐4 𝑛 − 1 + 𝑐5 𝑛 − 1 + 𝑐8 (𝑛 − 1)
𝑇 𝑛 = (𝑐1 +𝑐2 + 𝑐4 + 𝑐5 + 𝑐8 )𝑛 − (𝑐2 + 𝑐4 + 𝑐5 + 𝑐8 )

𝑇 𝑛 = 𝑐1 𝑛 + 𝑐2 𝑛 − 1 + 𝑐4 𝑛 − 1 + 𝑐5 σ𝑛𝑗=2 𝑡𝑗 + 𝑐6 σ𝑛𝑗=2(𝑡𝑗 −1) + 𝑐7 σ𝑛𝑗=2(𝑡𝑗 −1)- 𝑐8 (𝑛 − 1)


Análise de algoritmos
𝑇 𝑛 = 𝑐1 𝑛 + 𝑐2 𝑛 − 1 + 𝑐4 𝑛 − 1 + 𝑐5 σ𝑛𝑗=2 𝑡𝑗 + 𝑐6 σ𝑛𝑗=2(𝑡𝑗 −1) + 𝑐7 σ𝑛𝑗=2(𝑡𝑗 −1)- 𝑐8 (𝑛 − 1)

෍ 𝑡𝑗 = 1 + 2 + ⋯ + 𝑛 É uma serie aritmética e tem o valor


𝑛
𝑗=2 1
෍ 𝑡𝑗 = 𝑛 𝑛 + 1 − 1
2
𝑗=2

𝑛(𝑛−1) 𝑛(𝑛−1) 𝑛(𝑛−1)


𝑇 𝑛 = 𝑐1 𝑛 + 𝑐2 𝑛 − 1 + 𝑐4 𝑛 − 1 + 𝑐5 −1 +𝑐6 + 𝑐7 − 1 - 𝑐8 (𝑛 − 1)
2 2 2

𝑐5 𝑐6 𝑐7 2 𝑐5 𝑐6 𝑐7
𝑇 𝑛 = + + 𝑛 + 𝑐1 + 𝑐2 + 𝑐4 + − − + 𝑐8 𝑛 − 𝑐2 + 𝑐4 + 𝑐5 + 𝑐8
2 2 2 2 2 2

Esse tempo de execução no pior caso pode ser expresso como 𝑎𝑛2 + 𝑏𝑛 + 𝑐 para
constantes 𝑎, 𝑏 𝑒 𝑐 que, mais uma vez depende dos custos de instrução 𝑐𝑖 ; por
tanto, ele é uma função quadrática de n
Análise do pior caso e caso médio
Em nossa análise da ordenação por inserção, observamos tanto o melhor caso,
no qual o arranjo de entrada já estava ordenado, quanto o pior caso, no qual o
arranjo de entrada estava ordenado em ordem inversa.

● O tempo de execução do pior caso se um algoritmo é um limite superior sobre


o tempo de execução para qualquer entrada. Conhece-lo nos dá uma garantia
de que o algoritmo nunca irá demorar mais tempo de execução.
Ordem de crescimento
Agora, faremos mais uma abstração simplificadora. É a taxa de crescimento, ou
ordem de crescimento do tempo de execução.
● Consideramos apenas o termo inicial de uma formula (por exemplo, 𝑎𝑛2 ), pois o
termo de mais baixa ordem são relativamente insignificantes para grandes
valores de n.
● Ignoramos o coeficiente constante do termo inicial, tendo em vista que fatores
constantes são menos significativos que a taxa de crescimento na determinação
da eficiência computacional para grandes entradas.
Exemplo: No procedimento de inserção, tem um tempo de execução do pior caso
igual 𝜃 𝑛2 .

Se o tempo de execução do seu pior caso apresenta uma ordem de crescimento


mais baixa o consideramos um algoritmo mais eficiente.
Ordens assintóticas
● A complexidade assintótica é definida pelo crescimento da complexidade para
entradas suficientemente grandes.
● O algoritmo assintóticamente mais eficiente é melhor para todas as entradas
exceto para entradas relativamente pequenas.
● Uma cota assintótica superior é uma função que cresce mais rapidamente o
que outro: está acima a partir de certo ponto

𝑓 𝑛 = Θ(𝑔(𝑛)) 𝑓 𝑛 = 𝑂(𝑔(𝑛)) 𝑓 𝑛 = Ω(𝑔(𝑛))


Notação ϴ
Para uma dada função de 𝑔 𝑛 denotamos por Θ 𝑔 𝑛 o conjunto de funções.

Θ 𝑔 𝑛 = {𝑓 𝑛 : 𝑒𝑥𝑖𝑠𝑡𝑒𝑚 𝑐𝑜𝑛𝑠𝑡𝑎𝑛𝑡𝑒𝑠 𝑝𝑜𝑠𝑖𝑡𝑖𝑣𝑎𝑠 𝑐1 , 𝑐2 𝑒 𝑛0 𝑡𝑎𝑖𝑠 𝑞𝑢𝑒


0 ≤ 𝑐1 𝑔 𝑛 ≤ 𝑓 𝑛 ≤ 𝑐2 𝑔 𝑛 𝑝𝑎𝑟𝑎 𝑡𝑜𝑑𝑜 𝑛 ≥ 𝑛0 }

Uma função de 𝑔 𝑛 pertence ao conjunto de Θ 𝑔 𝑛 se existem constantes


positivas 𝑐1 e 𝑐2 tais que ela possa ser “imprensada” entre 𝑐1 𝑔 𝑛 e 𝑐2 𝑔 𝑛 para
um valor de n suficientemente grande.
Notação O
A notação Θ limita assintoticamente uma função acima e abaixo. Quando temos
apenas um limite assintótico superior, usamos a notação O. Para uma dada
função 𝑔 𝑛 , denotamos por 𝑂 𝑔 𝑛 o conjunto de funções.

𝑂 𝑔 𝑛 = {𝑓 𝑛 : 𝑒𝑥𝑖𝑠𝑡𝑒𝑚 𝑐𝑜𝑛𝑠𝑡𝑎𝑛𝑡𝑒𝑠 𝑝𝑜𝑠𝑖𝑡𝑖𝑣𝑎𝑠 𝑐 𝑒 𝑛0 𝑡𝑎𝑖𝑠 𝑞𝑢𝑒


0 ≤ 𝑓(𝑛) ≤ 𝑐𝑔 𝑛 𝑝𝑎𝑟𝑎 𝑡𝑜𝑑𝑜 𝑛 ≥ 𝑛0 }
● Usamos a notação O para dar um limite superior sobre uma função, dentro de
um fator constante. Para todos os valores n a direita de 𝑛𝑜 , o valor da função
𝑓 𝑛 está em ou abaixo de 𝑔 𝑛 .

● Usando a notação de O podemos descrever frequentemente o tempo de


execução de um algoritmo apenas inspecionado a estrutura global do algoritmo.
Notação O
Suponha que estejamos escolhendo entre dos algoritmos para determinada tarefa
computacional. Um toma 𝑓1 𝑛 = 𝑛2 passos, enquanto o outro toma 𝑓2 𝑛 = 2𝑛 +
20 passos.
Qual é melhor?
Qual tempo de execução é melhor?
Bem, isso depende do valor de n. 100
90
80
Essa superioridade é capturada 70
pela notação 60

𝑂: 𝑓2 = 𝑂(𝑓1 ) porque 50
40
30

𝑓2 (𝑛) 2𝑛 + 20 20

= ≤ 22 10
𝑓1 (𝑛) 𝑛2 0
1 2 3 4 5 6 7 8 9 10

Series1 Series2
Notação O
● Nomes de expressões comuns de O

○ O(1) = constante

○ O(log n): logarítimica

○ O(log2 n): log quadrado

○ O(n): linear

○ O(n log n): n log n

○ O(n2): quadrática

○ O(n3): cúbica

○ O(2n): exponencial

○ O(n!): fatorial
Notação Ω
A notação Ω fornece um limite assintótico inferior para uma determinada função
𝑔 𝑛 , denotamos por Ω 𝑔 𝑛 .

Ω 𝑔 𝑛 = {𝑓 𝑛 : 𝑒𝑥𝑖𝑠𝑡𝑒𝑚 𝑐𝑜𝑛𝑠𝑡𝑎𝑛𝑡𝑒𝑠 𝑝𝑜𝑠𝑖𝑡𝑖𝑣𝑎𝑠 𝑐 𝑒 𝑛0 𝑡𝑎𝑖𝑠 𝑞𝑢𝑒


0 ≤ 𝑐𝑔 𝑛 ≤ 𝑓(𝑛) 𝑝𝑎𝑟𝑎 𝑡𝑜𝑑𝑜 𝑛 ≥ 𝑛0 }

Para todos os valores n a direita de 𝑛0 , o valor de 𝑓 𝑛 está em ou acima de 𝑔(𝑛)


Exemplo
Qual o custo do seguinte algoritmo de divisão de elementos ?
Considerando somente atribuições como operações relevantes e ainda que todas
as atribuições possuem o mesmo custo
Exemplo
● Considerando a função g(n) = 1 + 2n + 2n2, temos o seguinte crescimento em função de n:

○ g(1) = 1 + 2*1 + 2*(1)2 = 5

○ g(5) = 1 + 2*5 + 2*(5)2 = 61

○ g(10) = 1 + 2*10 + 2*(10)2 = 221

○ g(100) = 1 + 2*100 + 2*(100)2 = 20201

○ g(1000) = 1 + 2*1000 + 2*(1000)2 = 2002001

○ ...

○ O último termo é dominante, portanto, conforme o valor de n aumenta, os dois primeiros


termos podem ser desprezados
Exemplo

○ g(n) = 2n e f(n) = n

■ |2n| ≤ 3*|n|, para todo n ≥ 0

■ g(n) = O(f(n)) = O(n)

○ g(n) = (n + 1)2 e f(n)=n2

■ |(n + 1)2| ≤ 4* |n2|, para todo n ≥ 1

■ g(n) = O(f(n)) = O(n2)


Exemplo
Exemplo

Você também pode gostar