Você está na página 1de 23

Coleções

➔ Coleção: um grupo de zero ou mais itens que podem ser tratados como uma unidade conceitual. -
usado por quase todos os softwares não triviais
➔ Princípios básicos da organização de coleções são os mesmos independente da tecnologia utilizada.
➔ Podem diferir em estrutura e uso
➔ Tem os mesmos propósitos fundamentais: ajudam os programadores a organizar dados em programas
de maneira eficaz e a modelar a estrutura e o comportamento dos objetos no mundo real.

Tipos de Coleção

➔ Python - tipos de coleção integrados: a string, a lista, a tupla, o conjunto e o dicionário.


➔ Outros tipos: pilhas, filas, filas de prioridade, árvores de pesquisa binárias, em grafos, sacolas e vários
tipos de coleções ordenadas.
➔ Podem ser:
◆ homogêneas: todos os itens devem ser do mesmo tipo
◆ heterogêneas: itens podem ser de tipos diferentes.

➔ Em muitas linguagens de programação, as coleções são homogêneas, a maioria das coleções Python
possa conter vários tipos de objetos.
➔ As coleções são tipicamente dinâmicas (podem aumentar ou diminuir devido às necessidades de um
problema e ter o seu conteúdo alterado ao longo de um programa) em vez de estáticas
➔ característica importante das coleções: maneira como são organizadas.
➔ A organização usada:
◆ Lineares;
◆ Hierárquicas
◆ Grafos;
◆ Não ordenadas;
◆ e coleções ordenadas.

Coleções Lineares

➔ São ordenados por posição.


➔ Exemplos: listas de compras, pilhas de pratos e uma fila de clientes
➔ Cada item, exceto o primeiro, possui um predecessor exclusivo e cada item, exceto o último, possui um
sucessor exclusivo.

Coleções Hierárquicas

➔ Itens de dados são organizados em uma estrutura semelhante a uma árvore de cabeça para baixo.
➔ Exemplos: sistema de diretório de arquivos, a estrutura organizacional de uma empresa e o índice de
um livro
➔ Cada item, exceto o do topo, tem um predecessor (pai), mas potencialmente muitos sucessores, (filhos)
Coleções de Grafos (Grafo)

➔ Cada item de dados pode ter muitos predecessores e muitos sucessores.


➔ Exemplos: mapas de rotas aéreas entre cidades, diagramas de fiação elétrica de edifícios e a rede
mundial de computadores.

Coleções não ordenadas

➔ Os itens não estão em uma ordem específica e não é possível falar de forma significativa sobre o
➔ predecessor ou sucessor de um item.
➔ Exemplo: sacola de bolinhas de gude ( você pode colocá-las em uma sacola e retirá-las na ordem em
que preferir, mas dentro da sacola, as bolinhas não estão em uma ordem específica).

Coleções Ordenadas

➔ Impõe uma ordenação natural em seus itens. para isso deve haver alguma regra para comparar os
➔ itens (como esse item1 < item2), para os itens visitados
➔ Exemplos: entradas dos nomes em uma lista de alunos.
➔ permite acesso ordenado
➔ Algumas operações, como pesquisa, podem ser mais eficientes

Taxonomia das Coleções

➔ Pode-se colocar os diferentes tipos de coleções comumente usados


em uma taxonomia.
➔ Ajuda a organizar as classes Python que representam esses tipos
➔ Observe que um nome de tipo nessa taxonomia não implica uma
implementação particular de uma coleção; pode haver mais de uma
implementação de um determinado tipo de coleção.
➔ Essas categorias serão úteis para organizar os recursos e o
comportamento que determinados tipos de coleções têm em comum

Operações nas Coleções

➔ As manipulações usadas variam com o tipo


de coleção, mas, geralmente, as operações
dividem-se em várias categorias amplas.
➔ Várias dessas operações estão associadas
a operadores, funções ou instruções de
controle padrão do Python, como in, +, len,
str e o for.
➔ Não há um nome único para as operações
de inserção, remoção, substituição ou acesso
no Python.
➔ No entanto, existem algumas variações padrão.
➔ Por exemplo, o método pop (remove itens em
determinadas posições de uma lista ou valores
em determinadas chaves de um dicionário Python), método remove (remove determinados itens de um
conjunto ou uma lista Python)
➔ À medida que novos tipos de coleções são desenvolvidos, ainda que não sejam suportados no Python,
todos os esforços serão feitos para usar nomes de operador, função ou método padrão para suas
operações.

Conversão de Tipo

➔ operação de coleção
➔ Pode-se converter um tipo de coleção em outro tipo de coleção de maneira semelhante (a que é feita na
entrada de números para int ou float)
➔ Exemplo: (em Python) pode converter uma string em uma lista e uma lista em uma tupla
➔ Parâmetro para a função list ou tupla não precisa ser outra coleção, pode ser qualquer objeto iterável
(permite que o programador visite uma sequência de itens com um laço for do Python)
➔ Pode criar uma lista a partir de um intervalo, da seguinte maneira:

➔ Clonagem: caso especial de conversão de tipo, que retorna uma cópia exata do parâmetro para a
função de conversão. - esse deve ser o caso quando o tipo do parâmetro é o mesmo da função de
conversão.
➔ O código faz uma cópia de uma lista e, em seguida, compara as duas usando os operadores is e ==.
◆ Como as duas listas não são o mesmo objeto, is retorna False.
◆ Como as duas listas são objetos distintos, mas são do mesmo tipo e têm a mesma estrutura (cada
par de elementos é o mesmo em cada posição nas duas listas), == retorna True.

➔ As duas listas nesse exemplo não apenas têm a mesma estrutura, mas compartilham os mesmos itens.
➔ Ou seja, a função list faz uma cópia superficial de sua lista de argumentos.
➔ Esses itens não são clonados antes de serem adicionados à nova lista; em vez disso, meras referências
a esses objetos são copiadas.
◆ Não provoca problemas quando os itens são imutáveis (números, strings ou tuplas do Python).
◆ Quando as coleções compartilham itens mutáveis, podem ocorrer efeitos colaterais.
● Para evitar que isso aconteça, o programador pode criar uma cópia profunda escrevendo um
laço for sobre a coleção de origem, que clona explicitamente seus itens antes de adicioná-los à
nova coleção.

Iteradores e Funções de Ordem Superior

➔ Cada tipo de coleção suporta um iterador ou laço for, uma operação que itera sobre os itens da coleção.
➔ A ordem em que o laço for serve os itens de uma coleção depende da maneira como a coleção está
organizada.
➔ Exemplo: os itens em uma lista são acessados por posição, do primeiro ao último; os itens em uma
coleção ordenada são acessados em ordem crescente, do menor ao maior; e os itens em um conjunto
ou dicionário são acessados em nenhuma ordem específica.
➔ O iterador talvez seja a operação mais crucial e poderosa fornecida por uma coleção.
➔ O laço for é usado em muitos aplicativos e desempenha um papel útil na implementação de várias
outras operações básicas de coleção, como +, str e conversões de tipo, bem como em várias funções
Python padrão, como sum, max e min.
➔ Como essas funções usam um laço for em suas implementações, elas trabalharão automaticamente
com qualquer outro tipo de coleção, como um conjunto, uma sacola ou uma árvore, que também
fornece um laço for.
➔ Função de ordem superior: uma função que recebe outra função como argumento e a aplica de
alguma forma.
➔ E o laço for ou iterador também suporta o uso de funções de ordem superior map, filter e reduce.
◆ Cada uma dessas funções espera outra função e uma coleção como argumentos.
◆ Como todas as coleções suportam um laço for, as funções map, filter e reduce podem ser usadas
com qualquer tipo de coleção, não apenas listas.
➔ Suponha que você queira converter uma lista de inteiros em outra lista de representações de string
desses inteiros. Você pode usar um laço para visitar cada inteiro, convertê-lo em uma string e anexá-lo
a uma nova lista, da seguinte maneira:

Função MAP
➔ Alternativamente, você pode usar map.
➔ Espera como parametros: uma função e um objeto iterável; e retorna outro objeto iterável em que a
função passada como parâmetro é aplicada a cada item contido no objeto iterável.
➔ Em suma, essencialmente transforma cada item em um objeto iterável.
➔ Assim, o código ao lado cria o objeto iterável contendo as strings.
➔ E o código ao lado cria uma nova lista a partir do retorno da função
map.
➔ Outro exemplo: seria elevar ao quadrado os números
de uma lista, o que poderia ser feito assim:
➔ E que pode ser simplificado assim:

➔ Suponha que você queira remover todas as notas zero


de uma lista de notas de exames. O seguinte laço faria isso:

Função FILTER
➔ Como alternativa, você pode usar a função filter.
➔ Espera como parâmetros: função booleana e objeto iterável
➔ Retorna um objeto iterável no qual cada item é passado para a função booleana.
➔ Se retornar True, o item será retido no objeto iterável retornado; caso contrário, o item será retirado
dele.
➔ Em suma, filter essencialmente mantém os itens que passam em um teste em um objeto iterável.
➔ Assim, supondo que o programador já tenha definido a função
booleana isPositive, o código abaixo cria o objeto iterável contendo
as notas diferentes de zero.
➔ O código ao lado cria uma nova lista a partir do filter.
➔ A função isPositive pode ser substituída na função filter.
➔ Suponha que você deseja calcular o somatório dos elementos de uma lista.

Função REDUCE
➔ Esse processo pode ser simplificado pelo uso da função reduce do módulo functools.
➔ Recebe como parâmetros: uma função e uma coleção; retorna um valor único.
➔ Assim, o código anterior pode ser convertido para:

➔ E o código ainda pode ser simplificado para:

Implementação de Coleções

➔ Programadores quando usam coleções têm uma perspectiva diferente sobre elas, em relação aos
responsáveis, acima de tudo, por implementá-las.
◆ precisam entender como instanciar e usar cada tipo de coleção.
◆ para eles, coleção é um meio para armazenar e acessar itens de dados de uma maneira
predeterminada, sem se preocupar com os detalhes de implementação da coleção.
◆ do ponto de vista do usuário, uma coleção é uma abstração e por isso, na ciência da computação,
também são chamadas de tipos de dados abstratos (TAD).
➔ Os desenvolvedores de coleções se preocupam em implementar o comportamento de uma coleção da
maneira mais eficiente possível, com o objetivo de fornecer o melhor desempenho para os usuários
➔ Inúmeras implementações são geralmente possíveis. No entanto, muitas delas ocupam tanto espaço ou
são executadas de maneira tão lenta que podem ser consideradas inúteis.
➔ As que permanecem, tendem a se basear em várias abordagens subjacentes para organizar e acessar
a memória do computador.
➔ Algumas linguagens de programação, como Python, fornecem apenas uma implementação de cada um
dos tipos de coleção disponíveis.
➔ Outras linguagens, como Java, fornecem várias.
➔ Por exemplo, o pacote java.util do Java inclui duas implementações de listas, chamadas ArrayList e
LinkedList; e duas implementações de conjuntos e mapas (semelhantes aos dicionários Python),
chamadas HashSet, TreeSet, HashMap e TreeMap.
➔ Na ciência da computação, a abstração é usada para ignorar ou ocultar detalhes que, no momento, não
são essenciais.
➔ Um sistema de software é geralmente construído camada por camada, com cada camada tratada como
uma abstração ou “tipo ideal” pelas camadas acima que o utilizam.
➔ Sem abstração, precisaria considerar todos os aspectos de um sistema de software simultaneamente, o
que é uma tarefa impossível.
➔ Com o tempo deve-se considerar os detalhes, mas pode fazê-lo em um contexto pequeno e gerenciável
➔ Em Python, funções e métodos são as menores unidades de abstração, as classes são as próximas
quanto ao tamanho e os módulos são os maiores.
Medindo a Eficiência de um Algoritmo
➔ Algoritmos é um dos blocos básicos de construção de programas de computador.
➔ descreve um processo computacional que é interrompido com a solução a um problema.
➔ Existem muitos critérios para avaliar a qualidade de um algoritmo.
◆ Exatidão: se de fato resolve o problema que pretende resolver. (mais essencial)
◆ Legibilidade e facilidade de manutenção também são qualidades importantes.
◆ Desempenho em tempo de execução.
Quando um processo algorítmico é executado em um computador real com recursos finitos, o
pensamento econômico entra em ação.
Esse processo consome dois recursos: tempo de processamento e espaço ou memória.
Quando executado com os mesmos problemas ou conjuntos de dados, um processo que consome
menos desses dois recursos é de maior qualidade do que um que consome mais e, assim, são os
algoritmos correspondentes.
Ferramentas para análise de complexidade — para avaliar o desempenho em tempo de execução ou a
eficiência dos algoritmos.
Você aplica essas ferramentas para pesquisar e ordenar algoritmos, que normalmente fazem grande
parte do trabalho em aplicativos de computador.

➔ Alguns algoritmos consomem uma quantidade de tempo ou memória abaixo de um limiar de tolerância.
➔ Por exemplo, a maioria dos usuários fica satisfeita com qualquer algoritmo que carrega um arquivo em
menos de um segundo.
➔ Para esses usuários, qualquer algoritmo que atenda esse requisito é tão bom quanto qualquer outro.
➔ Outros algoritmos levam uma quantidade de tempo que é ridiculamente impraticável (digamos, milhares
de anos) com grandes conjuntos de dados.
➔ Você não pode usar esses algoritmos e, em vez disso, precisa encontrar outros, se existirem, com
melhor desempenho.
➔ Ao escolher algoritmos, muitas vezes você tem de contentar-se com uma escolha entre espaço e
tempo.
➔ Um algoritmo pode ser projetado para obter tempos de execução mais rápidos ao custo de usar espaço
extra (memória) ou vice-versa.
➔ Alguns usuários podem estar dispostos a pagar por mais memória para obter um algoritmo mais rápido,
enquanto outros preferem um mais lento que economiza memória.
➔ Embora a memória agora seja bastante barata para desktops e laptops, a escolha entre espaço e tempo
continua a ser relevante para dispositivos em miniatura.
➔ Uma maneira de medir o custo de tempo de um algoritmo é usar o relógio do computador para obter um
tempo de execução real.
➔ Esse processo, denominado avaliação comparativa ou perfilamento, começa determinando o tempo
para vários conjuntos de dados diferentes do mesmo tamanho e, em seguida, calcula o tempo médio.
◆ Repetição do cálculo com vários os mesmos dados.
◆ Em seguida, dados semelhantes são coletados para conjuntos de dados cada vez maiores.
◆ Depois de vários desses testes, dados suficientes estão disponíveis para prever como o algoritmo
se comportará para um conjunto de dados de qualquer tamanho.
➔ Considere o seguinte exemplo:
◆ O programa implementa um algoritmo que conta de 1 a determinado número. Portanto, o tamanho
do problema é o número.
◆ Você começa com o número 10.000.000, cronometra o algoritmo e envia o tempo de execução
para a janela de terminal.
◆ Então dobra o tamanho desse número e repete o processo.
◆ Depois de cinco desses aumentos, há um conjunto de resultados a partir do qual você pode
generalizar.
Instruções de Contagem

➔ Técnica para estimar a eficiência de um algoritmo é contar as instruções executadas com problemas de
tamanhos diferentes.
➔ Essas contagens fornecem um bom indicador da quantidade de trabalho abstrato que um algoritmo
realiza, independentemente da plataforma em que o algoritmo é executado.
➔ Lembre-se, porém, de que ao contar instruções, você está contando as instruções no código de alto
nível em que o algoritmo foi escrito, e não as instruções no programa de linguagem de máquina
executável.
➔ Ao analisar um algoritmo dessa forma, você distingue entre duas classes de instruções:
◆ Instruções que executam o mesmo nº de vezes, independentemente do tamanho do problema (por
enquanto, ignoradas, pois não são consideradas de maneira significativa nesse tipo de análise
◆ Instruções cuja contagem de execução varia de acordo com o tamanho do problema
● Encontradas em laços ou funções recursivas
● No caso de laços, você também se concentra nas instruções executadas em quaisquer laços
aninhados ou, de modo mais simples, apenas no número de iterações que um laço aninhado
executa.
● Por exemplo, tente conectar o programa anterior para rastrear e exibir o número de iterações
que o laço interno executa com os diferentes conjuntos de dados.

Análise de Complexidade

➔ Uma análise completa dos recursos usados por um algoritmo inclui a quantidade de memória
necessária.
➔ Mais uma vez, o foco deve ser nas taxas de crescimento potencial.
➔ Alguns algoritmos requerem a mesma quantidade de memória para resolver qualquer problema.
➔ Outros algoritmos exigem mais memória à medida que o tamanho do problema aumenta.
➔ Vai ser apresentado um método para determinar a eficiência dos algoritmos que permite avaliá-los
independentemente de temporizações dependentes de plataforma ou contagens impraticáveis de
instruções.
➔ Envolve a leitura do algoritmo e o uso de lápis e papel para elaborar algumas álgebras simples.
Ordens de Complexidade

➔ Considere os dois laços de contagem do exemplo anterior.


➔ O primeiro laço é executando n vezes enquanto o segundo
é executado n2.
➔ A quantidade de trabalho realizado por esses dois algoritmos
é semelhante para pequenos valores de n, mas é muito diferente
para grandes valores de n.
➔ Observe que “trabalho” nesse caso se refere ao número
de iterações do laço aninhado mais profundamente.

➔ Os desses algoritmos diferem por uma ordem de


complexidade.
◆ do 1º algoritmo: é linear - trabalho cresce em
proporção direta ao tamanho do problema
(tamanho do problema de 10, trabalho de 10;
20 e 20 e assim por diante).
◆ do 2º algoritmo: é quadrático - trabalho
cresce em função do quadrado do tamanho
do problema (tamanho do problema de 10, trabalho de 100).
➔ À medida que o tamanho do problema aumenta, o desempenho de um algoritmo com a ordem de
complexidade mais alta piora mais rapidamente.

➔ Várias outras ordens de complexidade são comumente usadas na análise de algoritmos.


◆ Um algoritmo tem desempenho constante se requer o mesmo número de operações para qualquer
tamanho de problema.
◆ Outra ordem de complexidade que é melhor do que linear, mas pior do que constante é chamada
logarítmica.
● A quantidade de trabalho de um algoritmo logarítmico é proporcional ao log2 do tamanho do
problema.
● Assim, quando o problema dobra de tamanho, a quantidade de trabalho aumenta apenas em
1 (isto é, basta adicionar 1).
➔ Várias outras ordens de complexidade são comumente usadas na análise de algoritmos.
◆ Um algoritmo de tempo polinomial cresce proporcionalmente ao problema: n2, n3, etc.
◆ Embora n3 seja pior do que n2, ambos são polinomiais e são melhores do que algoritmos
exponenciais.
● Um exemplo crescimento exponencial é 2n
Notação Big-O

➔ Um algoritmo raramente executa um número de operações exatamente igual a n, n2 ou n^k


➔ Um algoritmo geralmente realiza outro trabalho no corpo de um laço, acima do laço e abaixo do laço.
➔ Por exemplo, você pode dizer com mais precisão que um algoritmo executa 2n + 3 ou 2n2 operações.

➔ A quantidade de trabalho em um algoritmo normalmente é a soma de vários termos em um polinômio.


➔ Sempre que a quantidade de trabalho é expressa como um polinômio, um termo é dominante.

➔ Como n torna-se grande, o termo dominante torna-se tão grande que você pode ignorar a quantidade de
trabalho representada pelos outros termos.
➔ Você também pode diminuir o coeficiente 1⁄2 porque a razão entre não muda à medida que n
cresce.
◆ Por exemplo, se você dobrar o tamanho do problema, os tempos de execução dos algoritmos que
são aumentam por um fator de 4.

◆ Esse tipo de análise às vezes é chamado análise assintótica porque o valor de um polinômio
assintoticamente se aproxima ou se acerca do valor de seu maior termo à medida que n torna-se
muito grande.
➔ Uma notação que os cientistas da computação usam para expressar a eficiência ou complexidade
computacional de um algoritmo é chamada notação big-O.
➔ “O” significa “na ordem de”, uma referência à ordem de complexidade do trabalho do algoritmo.
➔ Assim, por exemplo, a ordem de complexidade de um algoritmo de tempo linear é O(n).
Algoritmos de Pesquisa

➔ Agora veremos vários algoritmos que podem ser utilizados para pesquisar em listas.
➔ Veremos o design de um algoritmo e verá a implementação como uma função Python.
➔ Por fim, veremos uma análise da complexidade computacional do algoritmo.

Pesquisa pelo Valor Mínimo

➔ A função min do Python retorna o menor valor de uma lista.


➔ Para a análise da complexidade, vamos utilizar uma versão que retorna o índice do valor mínimo.
➔ Para isso, supõe-se que a lista não está vazia e os itens não estão ordenados.
➔ O algoritmo começa tratando a primeira posição como a do item mínimo.
➔ Em seguida, procura à direita um item que é menor e, se encontrado, redefine a posição do item mínimo
como a posição atual.
➔ Quando o algoritmo chega ao final da lista, ele retorna a posição do item mínimo.
➔ 3 instruções fora do laço que podem ser desconsideradas (serão executadas a mesma quantidade de
vezes independente do tamanho do problema)
➔ No laço são executadas mais 3 instruções.
➔ Dessas, as linhas do if e a do incremento são executadas todas as vezes.
➔ Não há laços aninhados.
➔ É necessário percorrer a lista inteira para identificar o índice do menor elemento.
◆ Essa operação ocorre realmente na instrução if.
➔ Assim, são executadas n-1 comparações.
➔ Portanto, a complexidade é O(n).
Pesquisa Sequencial de uma Lista

➔ O operador in em Python é implementado como um método chamado __contains__ na classe list.


➔ Esse método procura um item específico (chamado de item-alvo) dentro de uma lista não ordenada.
➔ A única maneira de pesquisar nessa situação é começar comparando o primeiro elemento da lista com
o item-alvo.
◆ Se os itens forem iguais, o método retorna True.
◆ Se chegar ao final da lista e o item-alvo não for encontrado, o método retorna False.
➔ Esse tipo de pesquisa (busca) é chamado de pesquisa sequencial ou pesquisa linear.
➔ Uma função de pesquisa sequencial mais interessante retornaria o índice do item na lista ou -1 se o
item não existir.

Desempenho dos Algoritmos

➔ A análise de uma pesquisa sequencial é um pouco diferente da análise de uma pesquisa por um
mínimo.
➔ O desempenho de alguns algoritmos depende do posicionamento dos dados que são processados.
➔ O algoritmo de pesquisa sequencial trabalha menos para encontrar um alvo no início de uma lista do
que no final dela.
➔ Para esses algoritmos, você pode determinar o desempenho no melhor caso possível, no pior caso
possível e o desempenho no caso médio.
➔ Em geral, sugere-se que você se preocupe mais com os desempenhos nos casos médios e
piores possíveis do que com o desempenho no melhor caso possível.
➔ Uma análise de uma pesquisa sequencial considera três casos:
◆ 1. No pior caso, o item-alvo está no final da lista ou não está na lista. Então, o algoritmo deve
visitar cada item e executar n iterações para uma lista de tamanho n. Assim, o pior caso de
complexidade de uma pesquisa sequencial é O(n).
◆ 2. No melhor caso, o algoritmo encontra o alvo na primeira posição, após fazer uma iteração,
para uma complexidade O(1).
◆ 3. Para determinar o caso médio, você adiciona o número de iterações necessárias para
encontrar o alvo em cada posição possível e divide a soma por n. Assim, o algoritmo executa
(n 1 n 2 1 1 n 2 2 1 ... 1 1)/n, ou (n 1 1)/2 iterações. Para um n muito grande, o fator constante de
2 é insignificante, assim a complexidade média ainda é O(n).
Pesquisa Binária em uma Lista Ordenada

➔ Uma pesquisa sequencial é necessária para listas não ordenadas.


➔ Para uma lista ordenada, pode ser utilizada uma pesquisa binária.
➔ Essa pesquisa binária funciona como uma procura por um nome em uma lista.
➔ Os nomes estão ordenados e, assim, não é feita uma pesquisa sequencial.
➔ Para procurar um nome, você vai procurando seguindo a ordem alfabética.
➔ Caso seja um documento com muitas páginas, você pode ir passando para frente ou para trás de
acordo com a ordem alfabética.
➔ Esse processo é repetido várias vezes até encontrar o nome ou verificar que ele não está presente.
➔ Considere uma pesquisa binária em Python, em que um item-alvo é pesquisado em uma lista ordenada
comparando com o valor da posição intermediária.
➔ Caso haja uma correspondência, o algoritmo retorna a posição.
◆ Exemplo: valor = 10
➔ Do contrário, se o alvo é menor que o item atual, o algoritmo pesquisa a parte da lista antes da posição
intermediária.
◆ Pesquisará entre os índices 0 e n/2.
➔ Se o alvo é maior que o item atual, o algoritmo pesquisa a parte da lista após a posição intermediária.
◆ Exemplo: Pesquisará entre os índices n/2+1 e n.
➔ O processo de pesquisa é interrompido quando o alvo é encontrado, ou a posição inicial atual é maior
do que a posição final atual.

➔ Há apenas 1 laço sem laço aninhado.


➔ O pior caso é quando o item-alvo não está na lista.
➔ Quantas vezes o laço é executado no pior caso?
◆ É igual ao número de vezes em que a lista pode ser dividida até ter o tamanho igual a 1.
➔ Suponha que k é o número de vezes em que você divide n/2 até obter quociente igual 1.
➔ Isso é igual a: n/2^k = 1.
➔ Também é igual a: n = 2^k
➔ k = log2 n .
➔ Portanto, a complexidade é O(log2 n).
➔ Considere uma lista com 9 itens e um item-alvo, o número 10, que não está na lista.
➔ Os itens comparados são sombreados e observe que nenhum dos itens na metade à esquerda da lista
original é visitado.
➔ A pesquisa binária (busca binária) para o item-alvo
10 requer 4 comparações, enquanto na pesquisa
sequencial seria exigido 10 comparações.
➔ Esse algoritmo possui um desempenho melhor a
medida que o tamanho do problema aumenta.
➔ Essa lista com 9 itens requer no máximo 4
comparações, enquanto uma lista com 1.000.000
de itens, são requeridas no máximo 20
comparações.
➔ A pesquisa binária é certamente mais eficiente do
que a sequencial.
➔ Entretanto, o tipo de algoritmo de pesquisa que
você escolhe depende da organização dos dados
na lista.
➔ Há um custo geral adicional para uma pesquisa binária, relacionado à manutenção da lista ordenada.

Comparando Itens de Dados

➔ Tanto a pesquisa binária como a pesquisa pelo mínimo supõem que os itens na lista sejam comparáveis
entre si.
➔ No Python, isso significa que os itens são do mesmo tipo e reconhecem os operadores de comparação
==, < e >.
➔ Objetos de vários tipos Python integrados, como números, strings e listas, podem ser comparados
usando esses operadores.
➔ Para permitir que algoritmos usem os operadores de comparação ==, < e > com uma nova classe de
objetos, o programador deve definir os métodos __eq__, __lt__ e __gt__ nessa classe.
➔ Se você fizer isso, os métodos para os outros operadores de comparação serão fornecidos
automaticamente.
➔ O cabeçalho de __lt__ é o seguinte:
➔ Esse método retorna True se self for menor do que Other ou False se for de outra forma.
➔ Os critérios para comparação de objetos dependem da estrutura interna e da maneira como devem ser
ordenados.
➔ Para o exemplo ao lado, a classe SavingAccount
possui três atributos: nome, pin e saldo.
➔ Supondo que as contas devem ser ordenadas
alfabeticamente, o método __lt__ deve ser a
implementação ao lado.
➔ O método __lt__ utiliza o operador < com os
campos nome dos dois objetos conta.
➔ Esses nomes são strings e o tipo string já possui
um método __lt__.
➔ Assim, o Python chama o método __lt__ quando o
operador < for aplicado.
Algoritmos de Ordenação

➔ Os cientistas da computação desenvolveram muitas estratégias engenhosas para ordenar uma lista de
itens.
➔ Os algoritmos examinados agora são fáceis de escrever, mas, mais ineficientes; os algoritmos
discutidos na próxima seção são mais difíceis de escrever, mas, mais eficientes.
◆ Isso é um equilíbrio comum.
➔ Cada uma das funções de ordenação do Python desenvolvidas opera em uma lista de inteiros e usa
uma função swap para trocar as posições de dois itens da lista.

SelectionSort

➔ Talvez a estratégia mais simples seja pesquisar em toda a lista a posição do menor item.
➔ Se essa posição não é igual à primeira posição, o algoritmo permuta os itens nessas posições.
➔ O algoritmo então retorna à segunda posição e repete o processo, trocando o menor item pelo item na
segunda posição se necessário.
➔ Quando o algoritmo alcança a última posição no processo geral, a lista está ordenada.
➔ O algoritmo chama-se ordenação por seleção porque cada passagem pelo laço principal seleciona um
único item a ser movido.
➔ A Figura ao lado mostra os estados de uma lista de cinco itens após cada pesquisa e passagem de
troca de uma ordenação por seleção.
➔ Os dois itens recém-trocados em cada passagem têm asteriscos
ao lado deles e a parte ordenada da lista é sombreada.

➔ Essa função inclui um laço aninhado. - para uma lista de tamanho n, o laço externo executa n-1 vez.
➔ Na primeira passagem pelo laço externo, o laço interno executa n-1 vez.
➔ Na segunda passagem pelo laço externo, o laço interno executa n-2 vezes.
➔ Na última passagem pelo laço externo, o laço interno é executado uma vez.
➔ Assim, o número total de comparações para uma lista de tamanho n é o seguinte:
➔ Para n grande, você pode escolher o termo com o maior grau e descartar o coeficiente, assim a
ordenação por seleção é O(n2) em todos os casos.
➔ Para grandes conjuntos de dados, o custo de trocar itens também pode ser significativo.
➔ Como os itens de dados são trocados apenas no laço externo, esse custo adicional da ordenação por
seleção é linear nos casos pior e médio possíveis.
BubbleSort

➔ Outro algoritmo de ordenação relativamente fácil de conceber e codificar chama-se ordenação por bolha
(bubblesort).
➔ A estratégia dele é começar no início da lista e comparar pares de itens de dados à medida que alcança
o final.
➔ Sempre que os itens no par estão fora de ordem, o algoritmo permuta-os.
➔ Esse processo tem o efeito de borbulhar os maiores
itens no final da lista.
➔ O algoritmo então repete o processo do início da lista
e passa para o penúltimo item etc., até começar com
o último item.
➔ Nesse ponto, a lista está ordenada.

➔ Assim como acontece com a ordenação por seleção, uma ordenação por bolha tem um laço aninhado.
➔ A parte ordenada da lista agora cresce do final da lista ao início, mas o desempenho da ordenação por
bolha é bastante semelhante ao comportamento de uma ordenação por seleção: 1⁄2n2 + 1⁄2n o laço
interno é executado vezes para uma lista de tamanho n.
➔ Assim, a ordenação por bolha é O(n2).
➔ Como a ordenação por seleção, a ordenação por bolha não realizará nenhuma troca se a lista já estiver
ordenada.
➔ Um pequeno ajuste na ordenação por bolha para melhorar o desempenho no melhor caso para linear
pode ser feito.
➔ Se nenhuma troca ocorrer durante uma passagem pelo laço principal, a lista será ordenada.
➔ Isso pode acontecer em qualquer passagem e, na melhor das hipóteses, acontecerá na primeira
passagem.
➔ Você pode rastrear a presença da troca com um flag booleano e retornar da função quando o laço
interno não definir esse flag.
➔ Observe que essa modificação apenas melhora o comportamento no melhor caso possível.
➔ Em média, o comportamento dessa versão da ordenação por bolha ainda é O(n2).
InsertionSort

➔ A ordenação por bolha modificada tem um desempenho melhor do que uma ordenação por seleção
para listas que já estão ordenadas.
➔ Mas a ordenação por bolha modificada ainda pode ter um desempenho ruim se muitos itens estiverem
fora de ordem na lista.
➔ Outro algoritmo, denominado ordenação por inserção, tenta explorar a ordenação parcial da lista de
maneira diferente.
➔ A estratégia é como a seguir:
◆ Na i-ésima passagem pela lista, onde i varia de 1 a n-1, o i-ésimo item deve ser inserido em seu
devido lugar entre os primeiros itens i na lista.
◆ Depois de i-ésima passagem, os primeiros itens i devem estar em ordem.
◆ Esse processo é análogo à maneira como muitas pessoas organizam cartas de baralho nas
mãos.
● Ou seja, se você segurar as primeiras i-1 cartas em ordem, você escolhe a i-ésima carta e
a compara com essas cartas até que seu lugar apropriado seja encontrado.
➔ A estratégia é como a seguir:
◆ Assim como acontece com nossos outros algoritmos de ordenação, a ordenação por inserção
consiste em dois laços.
● O laço externo percorre as posições de 1 a n-1.
● Para cada posição i nesse laço, você salva o item e inicia o laço interno na posição i-1.
● Para cada posição j nesse laço, você move o item para a posição j+1 até encontrar o ponto
de inserção para o (i-ésimo) item salvo.

➔ O algoritmo mostra os estados de uma lista de cinco itens após cada passagem pelo laço externo de
uma ordenação por inserção.
➔ O item a ser inserido na próxima passagem é marcado com uma seta; depois de inserido, esse item é
marcado com um asterisco.
➔ O laço externo executa n − 1 vez.
➔ No pior caso, quando todos os dados estão fora de ordem, o laço interno itera uma vez na primeira
passagem pelo laço externo, duas vezes na segunda passagem e assim por diante, para um total de
1⁄2n2 - 1⁄2n.
➔ Assim, o comportamento no pior caso possível da ordenação por inserção é O(n2).
➔ Quanto mais itens na lista estão em ordem, melhor será a ordenação por inserção até que, no melhor
caso de uma lista ordenada, o comportamento da ordenação seja linear.
➔ No caso médio, porém, a ordenação por inserção ainda é quadrática.

Ordenação Mais Rápida

➔ Os três algoritmos de ordenação considerados até agora têm O(n2) tempos de execução.
➔ Existem muitas variações desses algoritmos de ordenação, alguns dos quais são ligeiramente mais
rápidos, mas também são O(n2) nos piores casos e nos casos médios.
➔ No entanto, você pode tirar proveito de alguns algoritmos melhores que são O(n log n).
➔ O segredo desses algoritmos melhores é uma estratégia de dividir e conquistar.
➔ Isto é, cada algoritmo encontra uma maneira de dividir a lista em sublistas menores.
➔ Essas sublistas são então ordenadas recursivamente.
➔ Idealmente, se o número dessas subdivisões for
log(n) e a quantidade de trabalho necessária para
reorganizar os dados em cada subdivisão for n,
então a complexidade total de tal algoritmo de
ordenação será O(n log n).
➔ Você pode ver que a taxa de crescimento do
trabalho de um algoritmo O(n log n) é muito mais
lenta do que a de um algoritmo O(n2).

QuickSort

➔ Comece selecionando o item no ponto médio da lista. Esse item chama-se pivô.
➔ Particione os itens na lista de tal forma que todos os itens menores que o pivô sejam movidos para a
esquerda do pivô e o restante seja movido para a direita.
◆ A posição final do próprio pivô varia, dependendo dos itens reais envolvidos. Por exemplo, o pivô
acaba ficando mais à direita na lista se for o maior item e mais à esquerda se for o menor. Mas
onde quer que o pivô termine, essa é a posição final na lista totalmente ordenada.
➔ Divida e conquiste. Reaplique o processo recursivamente às sublistas formadas pela divisão da lista no
pivô.
◆ Uma sublista consiste em todos os itens à esquerda do pivô (agora as menores) e a outra
sublista tem todos os itens à direita (agora os maiores).
➔ O processo termina sempre que encontra uma sublista com menos de dois itens.

Particionamento

➔ Da perspectiva do programador, a parte mais complicada do algoritmo é a operação de particionar os


itens em uma sublista.
➔ Existem duas maneiras principais de fazer isso.
➔ Informalmente, o que se segue é uma descrição do método mais fácil aplicado a qualquer sublista:
◆ Troque o pivô pelo último item na sublista.
◆ Estabeleça um limite entre os itens conhecidos por serem menores que o pivô e o restante dos
itens.
● Inicialmente, esse limite é posicionado imediatamente antes do primeiro item.
➔ Da perspectiva do programador, a parte mais complicada do algoritmo é a operação de particionar os
itens em uma sublista.
➔ Existem duas maneiras principais de fazer isso.
➔ Informalmente, o que se segue é uma descrição do método mais fácil aplicado a qualquer sublista:
◆ Começando com o primeiro item na sublista após o limite, percorrer a sublista. Sempre que você
encontrar um item menor que o pivô, troque-o pelo primeiro item após o limite e avance o limite.
◆ Fechar trocando o pivô pelo primeiro item após o limite.

Agora será vista uma análise informal da complexidade do


quicksort.
Durante a primeira operação de partição, você percorre
todos os itens do início ao fim da lista.
•Portanto, a quantidade de trabalho durante essa operação
é proporcional a n, o comprimento da lista.

A quantidade de trabalho após essa partição é proporcional ao comprimento da sublista à esquerda mais o comprimento da
sublista à direita, que juntos resultam em n-1.
E quando essas sublistas são divididas, há quatro partes cujo comprimento combinado é aproximadamente n, portanto o trabalho
combinado é proporcional a n novamente.
À medida que a lista é dividida em mais partes, o trabalho total permanece proporcional a n.
Para completar a análise, você precisa determinar quantas vezes as listas são particionadas.
Faça a suposição otimista de que, cada vez, a linha de divisão entre as novas sublistas acaba sendo a mais próxima possível do
centro da sublista atual.
Na prática, geralmente não é esse o caso.
Você já conhece a discussão sobre o algoritmo de pesquisa binária que, ao dividir uma lista pela metade repetidamente,
chega-se a um único elemento em cerca de log2n passos.
•Assim, o algoritmo é O(n logn) na melhor das hipóteses de desempenho.
Para obter o desempenho no pior caso, considere uma lista que já está ordenada.
• Se o elemento pivô escolhido for o primeiro, então há n-1 elemento à sua direita na primeira
partição, n-2 elementos à sua direita na segunda partição e assim por diante, conforme mostra a
Figura.
➔ Embora nenhum elemento seja trocado, o número total de partições é n-1 e o número total de
comparações realizadas é 1⁄2n^2 - 1⁄2n o mesmo número que na SelectionSort e na BubbleSort
➔ No pior caso: o algoritmo quicksort é O(n2).
➔ Se implementar um quicksort rápido como um algoritmo recursivo, sua análise também deve considerar
o uso de memória para a pilha de chamadas.
➔ Cada chamada recursiva requer uma quantidade constante de memória para um quadro de pilha, e há
duas chamadas recursivas após cada partição.
➔ Assim, o uso de memória é O(log n) no melhor caso e O(n) no pior caso.
➔ Embora o desempenho no pior caso possível seja raro, certamente prefere-se evitá-lo.
➔ Escolher o pivô na primeira ou última posição não é uma estratégia inteligente.
➔ Outros métodos para escolher o pivô, como selecionar uma posição aleatória ou escolher a mediana do
primeiro, intermediário e último elemento, podem ajudar a aproximar o desempenho O(n log n) no caso
médio.
➔ O algoritmo quicksort é mais facilmente codificado usando-se uma abordagem recursiva.
➔ O script a seguir define uma função quicksort de alto nível para o cliente, uma função recursiva
quicksortHelper para ocultar os argumentos extras para os pontos finais de uma sublista e uma função
partição.
MergeSort (ordenação por mesclagem)
➔ Emprega uma estratégia recursiva de dividir e conquistar para quebrar a barreira O(n2).
➔ Resumo do algoritmo:
◆ Calcular a posição intermediária de uma lista e ordenar recursivamente suas sublistas à
esquerda e à direita (dividir e conquistar).
◆ Mesclar as duas sublistas ordenadas de volta em uma única lista ordenada.
◆ Interromper o processo quando as sublistas não podem mais ser subdivididas.
➔ Três funções Python colaboram nessa estratégia de design de nível superior:
◆ mergeSort — A função chamada pelos usuários.
◆ mergeSortHelper — Uma função auxiliar que oculta os parâmetros extras exigidos pelas
chamadas recursivas.
◆ merge — Uma função que implementa o processo de mesclagem.
➔ O processo de mesclagem usa um array(copyBuffer) do mesmo tamanho da lista.
➔ Para evitar a sobrecarga de alocar e desalocar o copyBuffer a cada vez que merge é chamada, o
buffer é alocado 1x em mergeSort e passado como um argumento p/ mergeSortHelper e merge.
➔ Cada vez que mergeSortHelper é chamado, precisa entender os limites da sublista com a qual
está trabalhando. Esses limites são fornecidos por dois outros parâmetros: inferior e superior.

➔ Depois de verificar se recebeu uma sublista de pelo menos dois itens, mergeSortHelper calcula o
ponto médio da sublista, ordena recursivamente as partes abaixo e acima do ponto médio e
chama merge para mesclar os resultados.

➔ Observe que, nesse exemplo, as sublistas são sub-divididas


uniformemente em cada nível e há sublistas 2 k a serem
mescladas no nível k.
➔ Se o comprimento da lista inicial não fosse uma potência de
dois, uma subdivisão exatamente uniforme não teria sido
alcançada em cada nível e o último nível não teria contido um
complemento completo das sublistas.
➔ A função merge combina duas sublistas ordenadas em uma
sublista maior ordenada.
➔ A primeira sublista encontra-se entre inferior e metade e a segunda entre metade + 1 e superior.
➔ O processo consiste em três etapas:
◆ 1. Configurar ponteiros de índice para os primeiros itens em cada sublista. Estes estão nas
posições inferior e metade + 1.
◆ 2. Começando com o primeiro item em cada sublista, comparar os itens repetidamente.
Copiar o item menor da sublista para o buffer de cópia e avançar para o próximo item na
sublista. Repetir até que todos os itens tenham sido copiados de ambas as sublistas. Se o
final de uma sublista for alcançado antes daquele da outra, encerrar copiando os itens
restantes da outra sublista.
◆ 3. Copiar a parte de copyBuffer entre inferior e superior de volta às posições
correspondentes em lista.

➔ Tempo de execução da merge é dominado pelas 2 instruções


for, cada uma faz um laço (superior − inferior + 1) vezes.
➔ Consequentemente, o tempo de execução: O(superior − inferior)
e todas as mesclagens em um único nível levam tempo O(n).
➔ Como mergeSortHelper divide as sublistas tão uniformemente
quanto possível em cada nível, o número de níveis é (log n) e
o tempo máximo de execução para esta função é O(n log n)
em todos os casos.
➔ o MergeSort tem dois requisitos de espaço que dependem do
tamanho da lista.
◆ Primeiro, o espaço O(log n) é necessário na pilha de chamadas para fornecer suporte a
chamadas recursivas.
◆ Segundo, O(n) é usado pelo buffer de cópia.

Você também pode gostar