Aula 19 Algoritmos básicos de busca e classificação

Tamanho: px
Começar a partir da página:

Download "Aula 19 Algoritmos básicos de busca e classificação"

Transcrição

1 Dentre os vários algoritmos fundamentais, os algoritmos de busca em tabelas e classificação de tabelas estão entre os mais importantes. Considere por exemplo um sistema de banco de dados. O objetivo é garantir que a recuperação dos dados seja rápida e eficiente. Para isso é necessário realizar uma busca nestes dados para localizar o elemento procurado. Um banco de dados é na verdade uma grande tabela que permite que operações de busca e atualizações sejam feitas com eficiência. Uma tabela em Python é uma lista com um ou mais campos por elemento. Exemplos: tabnum = [5, 7, 10, 8, 45, 12, 63, 29, 17, 12, 45, 29] tabpessoas = [[ João, ], [ Antonio, ], [ Rafaela, ], [ Donald, ]] tablivros = [[ Algoritmos, A. Santos, 1973], [ Teoria dos Grafos, B. A. Souza, 1995], [ Algebra Linear, K. Oliveira, 1983], [ Cálculo Numérico, L. A. Medeiros, 1966], [ Lógica e Algoritmos, E. A N.. Amaral, 1985], [ Técnicas de Automação, N. Tomás, 2004]] Sem perda de generalidade, vamos considerar em nossos algoritmos uma tabela contendo apenas um campo por elemento. A linguagem Python já possui como funções intrínsecas, as funções count e index cujo objetivo é exatamente procurar e localizar elementos em uma tabela ou lista: lst.count(x) retorna quantas vezes o elemento x ocorre na lista lst. lst.index(x) retorna o índice da primeira ocorrência de x na lista lst. Se não existe x retorna ValueError. lst.index(x, i) retorna o índice da primeira ocorrência de x a partir de lst[i]. lst.index(x, i, f) retorna o índice da primeira ocorrência de x a partir de lst[i] até lst[f-1]. Possui ainda o operador in que verifica se determinado elemento está ou não numa tabela: if x in lst:... if x not in lst:... while x in list:... O nosso objetivo é entender como essas funções e operadores são implementados. A maneira de fazê-lo é percorrer a tabela de alguma forma, procurando o elemento. Vamos então verificar com mais profundidade os algoritmos que fazem a busca, ou os algoritmos usados nestas funções. Busca em tabelas Busca sequencial Consiste em varrer uma tabela a procura de um determinado elemento, verificando ao final se o mesmo foi ou não encontrado. Interessa apenas a primeira ocorrência do elemento. A função busca abaixo, procura elemento igual a x numa lista a de n elementos, devolvendo 1 se não encontrou ou o índice do primeiro elemento igual a x encontrado. def busca_seq(a, n, x): for i in range(n): if a[i] == x: return i # encontrou return -1 # não encontrou

2 Em geral não é necessário o parâmetro n. Podemos usar len(a). Existem várias maneiras possíveis de fazer o algoritmo da busca. Por exemplo: def busca_seq(a, n, x): i = 0 while i < n and a[i]!= x: i = i + 1 # verifica se saiu porque esgotou a contagem ou encontrou um igual if i == n: return -1 return i Observe que na expressão booleana i < n and a[i]!= x acima, a segunda comparação não é efetuada se a primeira comparação for falsa. Ou seja, embora o operador and seja comutativo a expressão a[i]!= x and i < n está errada. Fica como exercício, reescrever a função busca de outras formas. Usando o próprio comando for, usando o comando while, modificando a comparação, etc. Busca Sequencial análise simplificada Considere a primeira função busca_seq acima. A comparação (a[i] == x)é a quantidade de repetições do algoritmo. Dizemos então que o tempo que esse algoritmo leva para ser executado é proporcional a quantidade de vezes que essa repetição é executada. Máximo = n (quando não encontra o x ou x é igual ao último elemento) Mínimo = 1 (quando x é igual ao primeiro elemento) Podemos afirmar que o número médio é (n + 1) / 2? Sim se a probabilidade do número de comparações ser 1, 2,..., n for a mesma. Não se tal probabilidade for diferente. Mas será sempre dependente de n. Assim, o tempo que esse algoritmo leva, é proporcional a n. Quando existem elementos que podem ocorrer com maior frequência, o melhor que podemos fazer para melhorar o desempenho desse algoritmo é colocá-los no início da tabela, pois serão encontrados com menos comparações. No caso geral, é razoável supor que será mesmo (n + 1) / 2, pois não temos a informação sobre a frequência com que cada elemento será procurado. Busca binária em tabela ordenada Quando a tabela está ordenada, nem sempre é necessário ir até o fim da tabela para concluir que um elemento não está. Ao encontrarmos um elemento maior que o procurado, podemos parar a busca, pois dali para frente todos os demais serão maiores. No entanto, existe um algoritmo muito melhor quando a tabela está ordenada. Lembre-se de como fazemos para procurar uma palavra no dicionário. Não vamos verificando folha a folha (busca sequencial) até encontrar a palavra procurada. Ao contrário, abrimos o dicionário mais ou menos no meio e a partir daí só há 3 possibilidades: a) Encontramos a palavra procurada na página aberta (ou concluímos que ela não está no dicionário, pois se estivesse estaria nesta página). b) A palavra está na parte esquerda. c) A palavra está na parte direita. Se não ocorreu o caso a), repetimos o mesmo processo com as páginas remanescentes onde a palavra tem chance de ser encontrada, isto é, continuamos a busca com um número bem menor de páginas.

3 Para o caso de uma tabela, isso pode ser sistematizado da seguinte forma: a) Verifica o elemento do meio da tabela b) Se o elemento é igual, termina a busca, pois foi encontrado. c) Se o elemento do meio é maior, repete o processo considerando a tabela do inicio até o elemento anterior ao do meio. d) Se o elemento do meio é menor, repete o processo considerando a tabela do elemento seguinte ao do meio até o final. def busca_binaria(a, n, x): inicio, fim = 0, n - 1 # procura enquanto há algum elemento na tabela while inicio <= fim: meio = (inicio + fim) // 2 # compara com o elemento médio if x == a[meio]: return meio # redimensiona a tabela if x > a[meio]: inicio = meio + 1 else: fim = meio - 1 # saiu do while sem encontrar return -1 O programa abaixo exemplifica o uso da função busca_binaria. Para melhorar a visibilidade do funcionamento da função, colocamos um comando print interno a ela (veja abaixo) que a cada repetição mostra os novos valores das variáveis inicio, fim e meio. O programa que testa a função, gera uma tabela de valores em ordem crescente e em seguida faz algumas buscas nesta tabela. from random import randrange def busca_binaria(a, n, x): inicio, fim = 0, n - 1 # procura enquanto há algum elemento na tabela while inicio <= fim: meio = (inicio + fim) // 2 print("inicio = ", inicio, "fim = ", fim, "meio = ", meio) # compara com o elemento médio if x == a[meio]: return meio # redimensiona a tabela if x > a[meio]: inicio = meio + 1 else: fim = meio - 1 # saiu do while sem encontrar return -1 def geravet(n): ''' Gera vetor de n elementos com valores crescentes''' k = randrange(10) vet = [] for i in range(n): vet.append(k) k = k + randrange(10) + 1 # retorna return vet # Programa que testa a função # gera tabela com n elementos n = 100 tabela = geravet(n) print(tabela)

4 # exemplo de busca de elemento existente z = tabela[75] print("\n\nprocurar elemento ", z, " na tabela:") print("o elemento", z, "está na posição ", busca_binaria(tabela, n, z)) # exemplo de busca de elemento existente z = tabela[25] print("\n\nprocurar elemento ", z, " na tabela:") print("o elemento", z, "está na posição ", busca_binaria(tabela, n, z)) # ache um elemento que não esteja na tabela z = tabela[50] + randrange(2) while z in tabela: z = z + randrange(2) # exemplo de busca de elemento inexistente print("\n\nprocurar elemento ", z, " na tabela:") if busca_binaria(tabela, n, z) == -1: print("o elemento", z, "não está na tabela") Veja a saída: [7, 17, 20, 24, 34, 35, 41, 46, 50, 53, 63, 65, 67, 71, 76, 86, 90, 99, 104, 109, 117, 124, 131, 141, 151, 158, 164, 169, 177, 182, 190, 191, 193, 199, 208, 214, 223, 231, 238, 247, 257, 266, 270, 280, 281, 289, 291, 293, 300, 308, 316, 317, 324, 330, 336, 339, 347, 348, 350, 355, 358, 365, 371, 377, 387, 391, 396, 401, 410, 416, 418, 424, 428, 430, 438, 446, 452, 458, 465, 473, 474, 481, 491, 496, 501, 511, 512, 515, 517, 524, 532, 533, 539, 542, 545, 547, 551, 561, 571, 580] procurar elemento 446 na tabela: inicio = 0 fim = 99 meio = 49 inicio = 50 fim = 99 meio = 74 inicio = 75 fim = 99 meio = 87 inicio = 75 fim = 86 meio = 80 inicio = 75 fim = 79 meio = 77 inicio = 75 fim = 76 meio = 75 O elemento 446 está na posição 75 procurar elemento 158 na tabela: inicio = 0 fim = 99 meio = 49 inicio = 0 fim = 48 meio = 24 inicio = 25 fim = 48 meio = 36 inicio = 25 fim = 35 meio = 30 inicio = 25 fim = 29 meio = 27 inicio = 25 fim = 26 meio = 25 O elemento 158 está na posição 25 procurar elemento 318 na tabela: inicio = 0 fim = 99 meio = 49 inicio = 50 fim = 99 meio = 74 inicio = 50 fim = 73 meio = 61 inicio = 50 fim = 60 meio = 55 inicio = 50 fim = 54 meio = 52 inicio = 50 fim = 51 meio = 50 inicio = 51 fim = 51 meio = 51

5 O elemento 318 não está na tabela Exercícios: 1) Considere a seguinte tabela ordenada, onde vamos procurar elementos usando a busca binária: 2, 5, 7, 11, 13, 17, 25 a) Diga quantas comparações são necessárias para procurar cada um dos 7 elementos da tabela? b) Diga quantas comparações serão necessárias para procurar os seguintes números que não estão na tabela 12, 3, 1, 28 2) E se a tabela tivesse os seguintes elementos: 2, 5, 7, 11, 13, 17, 25, 33, 42, 48, 54 a) Diga quantas comparações serão necessárias para procurar cada um dos 11 elementos da tabela? b) Diga quantas comparações serão necessárias para procurar os seguintes números que não estão na tabela 8, 43, 62, 1? Busca binária - análise simplificada A comparação (x == a[meio]) é bastante significativa para a análise do tempo que esse algoritmo demora. É executada a cada repetição. O tempo consumido pelo algoritmo é proporcional ao número de repetições (comando while). Quantas vezes a comparação (x == a[meio]) é executada, em função de n que é o tamanho da tabela? Mínimo = 1 (encontra na primeira) Máximo =? Médio =? Note que a cada iteração a tabela fica dividida aproximadamente ao meio, dependendo se n é par ou ímpar. Assim, numa aproximação, o tamanho da tabela é: n, n/2, n/4, n/8,... A busca terminará quando o tamanho da tabela chegar a zero, ou seja, o denominador for maior que n. Portanto o maior k tal que 2 k n < 2 k+1. Assim, k log2n < k+1. Ou, -log2n -k < -log2n + 1 Ou ainda, log2n k > log2n 1 E, log2n - 1 < k log2n Portanto o número máximo é um número próximo de log2n. Dizemos que esse algoritmo é O(log2n), ou ainda O(log n). A notação O(f(n)) denota que o tempo que o algoritmo gasta é proporcional a f(n). É um resultado surpreendente. Suponha uma tabela de de elementos. O número máximo de comparações será log2 ( ) = 20. Compare com a busca seqüencial, onde o máximo de comparações seria e o médio seria Veja abaixo alguns valores típicos para tabelas grandes.

6 n log2 n Será que o número médio é muito diferente da média entre o máximo e o mínimo? Vamos calculá-lo, supondo que sempre encontramos o elemento procurado. Note que quando não encontramos o elemento procurado, o número de comparações é igual ao máximo. Assim, no caso geral, a média estará entre o máximo e o número médio no caso em que sempre vamos encontrar o elemento. Supondo que temos N elementos e que a probabilidade de procurar cada um é sempre 1/N. Vamos considerar sem perda de generalidade N = 2 k - 1. Caso N não seja dessa forma, considere o N o menor número maior que N. Como fazemos 1 comparação na tabela de N elementos, 2 comparações em 2 tabelas de N/2 elementos, 3 comparações em 4 tabelas de N/4 elementos, 4 comparações em 8 tabelas de N/8 elementos, e assim por diante. = 1.1/N + 2.2/N + 3.4/N k.2 k-1 /N = 1/N. i.2 i-1 (i=1,k) = 1/N. ((k-1).2 k + 1) (a prova por indução está abaixo) Como N = 2 k - 1 então k = log2 (N+1) = 1/N. ((log2 (N+1) 1). (N+1) + 1) = 1/N. ((N+1).log2 (N+1) N) = (N+1)/N. log2 (N+1) - 1 ~ log2 (N+1) - 1 Resultado novamente surpreendente. A média é muito próxima do máximo. Prova por indução: i.2 i-1 (i=1,k) = (k-1).2 k + 1 Verdade para k=1 Supondo verdade para k, vamos calcular para k+1. i.2 i-1 (i=1,k+1) = i.2 i-1 (i=1,k) + (k+1).2 k = (k-1).2 k (k+1).2 k = k.2 k Tabelas estáticas e tabelas dinâmicas A busca binária em tabela ordenada é um algoritmo extremamente eficiente. Entretanto há um problema a se considerar no caso real. As tabelas muitas vezes não são estáticas, ou seja, durante o processamento ocorrem inserções e remoções de elementos (tabelas dinâmicas). Para manter a tabela ordenada, seriam necessários deslocamentos de grandes partes da tabela para acomodar as inserções e remoções de elementos e manter a tabela ordenada.

7 Portanto outros algoritmos são necessários e que têm que aliar a eficiência da busca binária com inserções e remoções. Não serão estudados neste curso, mas apenas como comentário, são algoritmos de hash (que dividem a tabela em sub-tabelas lógicas) ou que usam estrutura de dados ligada (listas ligadas e árvores). Classificação de tabelas A linguagem Python já possui uma função intrínseca que classifica uma tabela ou lista (sort). Supondo: lst = [ꞌmariaꞌ, ꞌanaꞌ, ꞌfernandoꞌ, ꞌantonioꞌ] O comando lst.sort() devolve a lista lst classificada em ordem crescente. lst = [ꞌanaꞌ, ꞌantonioꞌ, ꞌfernandoꞌ, ꞌmariaꞌ] Outros exemplos: lst.sort(reverse = True) devolve a lista lst classificada em ordem decrescente. O valor default do parâmetro reverse é False. lst.sort(key = func) a função func define qual a chave de classificação. Veja o exemplo abaixo: lst = [[ꞌmariaꞌ, 12], [ꞌanaꞌ, 23], [ꞌfernandoꞌ, 10], [ꞌantonioꞌ, 17]] def func(elem): return elem[1] # a chave é o segundo item do elemento Devolve: lst = [[ꞌfernandoꞌ, 10], [ꞌmariaꞌ, 12], [ꞌantonioꞌ, 17], [ꞌanaꞌ, 23]] Como no caso da busca, estamos interessados em estudar como os algoritmos de classificação funcionam. O algoritmo deve funcionar para tabelas de qualquer tamanho. Em particular, para tabelas muito grandes. Assim é conveniente que não usemos tabelas auxiliares e sim que a classificação seja feita pela permutação de posição dos elementos. Classificação método da seleção O algoritmo imediato para se classificar uma tabela é o seguinte: Determinar o mínimo a partir do primeiro e trocar com o primeiro Determinar o mínimo a partir do segundo e trocar com o segundo Determinar o mínimo a partir do terceiro e trocar com o terceiro... Determinar o mínimo a partir do penúltimo e trocar com o penúltimo Exemplo numa tabela de 5 elementos:

8 def Selecao(a): n = len(a) # i = 0, 1, 2,..., n - 2 for i in range(n - 1): # determina o índice do menor elemento a partir de i imin = i for j in range(i + 1, n): if (a[imin] > a[j]): imin = j # troca a posição do menor com a posição de i-ésimo a[i], a[imin] = a[imin], a[i] O programa abaixo exemplifica o uso da função Selecao. Gera uma tabela com números randômicos e classifica essa tabela. from random import randrange def Selecao(a): n = len(a) # i = 0, 1, 2,..., n - 2 for i in range(n - 1): # determina o índice do menor elemento a partir de i imin = i for j in range(i + 1, n): if a[imin] > a[j]: imin = j # troca a posição do menor com a posição de i-ésimo a[i], a[imin] = a[imin], a[i] def geratab(n): # gera tabela com n numeros randômicos entre tab = [] for i in range(n): tab.append(randrange(1000)) return tab # Programa para testar a função # gera tabela com N números N = 100 tabela = geratab(n) print("\n\ntabela original:\n", tabela) # classifica pelo método da seleção Selecao(tabela) print("\n\ntabela classificada:\n", tabela) Veja a saída: tabela original: [935, 815, 134, 931, 923, 797, 342, 398, 838, 248, 204, 2, 352, 669, 910, 398, 615, 734, 541, 565, 499, 386, 981, 282, 868, 121, 648, 612, 382, 241, 292, 379, 827, 221, 639, 250, 438, 891, 743, 924, 422, 986, 509, 409, 887, 845, 596, 692, 365, 988, 858, 591, 77, 333, 961, 936, 538, 185, 189, 57, 419, 967, 871, 589, 756, 140, 934, 359, 922, 721, 326, 715, 695, 261, 489, 600, 646, 791, 890, 791, 697, 153, 536, 441, 909, 521, 104, 417, 703, 254, 693, 17, 406, 749, 937, 72, 43, 20, 723, 308]

9 tabela classificada: [2, 17, 20, 43, 57, 72, 77, 104, 121, 134, 140, 153, 185, 189, 204, 221, 241, 248, 250, 254, 261, 282, 292, 308, 326, 333, 342, 352, 359, 365, 379, 382, 386, 398, 398, 406, 409, 417, 419, 422, 438, 441, 489, 499, 509, 521, 536, 538, 541, 565, 589, 591, 596, 600, 612, 615, 639, 646, 648, 669, 692, 693, 695, 697, 703, 715, 721, 723, 734, 743, 749, 756, 791, 791, 797, 815, 827, 838, 845, 858, 868, 871, 887, 890, 891, 909, 910, 922, 923, 924, 931, 934, 935, 936, 937, 961, 967, 981, 986, 988] Classificação método da seleção análise simplificada O número de trocas é sempre n 1. Não seria necessário trocar se o mínimo fosse o próprio elemento da i-ésima posição, mas para fazer isso teríamos de qualquer forma fazer outra comparação. O número de comparações (a[imin] > a[j])é sempre (n-1) + (n-2) = n.(n-1)/2. Esse número é exatamente a quantidade de repetições efetuadas. Portanto o tempo que esse algoritmo leva é proporcional a esse número, ou seja, o tempo é proporcional a n 2. Classificação - método da bolha (bubble) Outro método para fazer a classificação é pegar cada um dos elementos a partir do segundo (a[1] até a[n-1]) e subi-lo até que encontre o seu lugar Observe como cada elemento sobe até encontrar o seu lugar. Como uma bolha de ar num recipiente de água. Só que para quando encontrar o seu lugar, ou seja, encontrou um elemento menor ou igual ou chegou ao início da tabela. def Bolha(a): n = len(a) # i = 1, 2,..., n - 1 for i in range(1, n): # sobe com a[i] até encontrar o lugar adequado j = i while j > 0 and a[j] < a[j - 1]: # troca com o seu vizinho a[j], a[j - 1] = a[j - 1], a[j] # continua subindo j = j - 1 O programa abaixo exemplifica o uso da função Bolha. Gera uma tabela com números randômicos e classifica essa tabela. from random import randrange def Bolha(a): n = len(a) # i = 1, 2,..., n - 1 for i in range(1, n): # sobe com a[i] até encontrar o lugar adequado j = i while j > 0 and a[j] < a[j - 1]: # troca com o seu vizinho a[j], a[j - 1] = a[j - 1], a[j]

10 # continua subindo j = j - 1 def geratab(n): # gera tabela com n numeros randômicos entre tab = [] for i in range(n): tab.append(randrange(1000)) return tab # gera tabela com N números N = 100 tabela = geratab(n) print("\n\ntabela original:\n", tabela) # classifica pelo método da seleção Bolha(tabela) print("\n\ntabela classificada:\n", tabela) Veja a saída: tabela original: [277, 99, 95, 476, 315, 887, 699, 522, 694, 40, 656, 794, 211, 33, 426, 6, 136, 22, 750, 715, 85, 95, 17, 325, 207, 278, 194, 455, 34, 861, 910, 200, 22, 769, 416, 648, 148, 914, 544, 251, 380, 750, 617, 443, 163, 957, 839, 221, 745, 227, 801, 265, 959, 253, 731, 809, 742, 969, 470, 917, 791, 750, 356, 111, 495, 119, 857, 621, 639, 323, 163, 766, 657, 722, 595, 732, 14, 672, 812, 535, 539, 555, 499, 133, 500, 236, 555, 980, 328, 224, 441, 618, 750, 694, 145, 627, 939, 795, 28, 710] tabela classificada: [6, 14, 17, 22, 22, 28, 33, 34, 40, 85, 95, 95, 99, 111, 119, 133, 136, 145, 148, 163, 163, 194, 200, 207, 211, 221, 224, 227, 236, 251, 253, 265, 277, 278, 315, 323, 325, 328, 356, 380, 416, 426, 441, 443, 455, 470, 476, 495, 499, 500, 522, 535, 539, 544, 555, 555, 595, 617, 618, 621, 627, 639, 648, 656, 657, 672, 694, 694, 699, 710, 715, 722, 731, 732, 742, 745, 750, 750, 750, 750, 766, 769, 791, 794, 795, 801, 809, 812, 839, 857, 861, 887, 910, 914, 917, 939, 957, 959, 969, 980] Exercícios Considere as 120 (5!) permutações de : 1) Encontre algumas que precisem exatamente de 5 trocas para classificá-la pelo método da bolha. 2) Idem para 7 trocas 3) Qual a sequência que possui o número máximo de trocas e quantas trocas são necessárias? Classificação - método da bolha (bubble) análise simplificada O comportamento do método da bolha depende se a tabela está mais ou menos ordenada, mas em média, o seu tempo também é proporcional a n 2 como no método anterior. Quantas vezes o comando de troca ( a[j], a[j - 1] = a[j - 1], a[j] ) é executado? O pior caso ocorre quando a sequência está invertida e os elementos terão que subir sempre até a primeira posição. Nesse caso, a troca é executada i vezes para cada valor de i = 1, 2, 3,..., n-1. Portanto n 1 = n.(n 1)/2. Portanto, no pior caso o método da bolha é proporcional a n 2. Usando a notação de análise de algoritmos, dizemos que este método é O(n 2 ).

11 E quanto ao número médio? Vamos calcular. Inversões Seja P = a 1 a 2... a n, uma permutação de n. O par (i,j) é uma inversão quando i < j e a i > a j. Exemplo: tem 4 inversões: (3,2) (5,4) (5,2) e (4,2) No método da bolha o número de trocas é igual ao número de inversões da sequência. O algoritmo se resume a: for i in range(1, n): # elimine as inversões de a[i] até a[0]... Veja também o exemplo: elimine as inversões do elimine as inversões do elimine as inversões do elimine as inversões do Total de 7 inversões que é exatamente a quantidade de trocas para classificar a sequência. Para calcularmos então o número de trocas do método da bolha, basta calcular o número de inversões da sequência. É equivalente a calcular o número de inversões de uma permutação de n. Qual o número médio de inversões em todas as sequências possíveis? É equivalente a calcular o número médio de inversões em todas as permutações de n. Não vamos demonstrar, mas esse número é n.(n-1)/4. Note que é exatamente a média entre o mínimo e o máximo. Portanto o método da bolha é O(n 2 ). Classificação - outros métodos Existem vários outros métodos melhores que os métodos acima que não veremos neste curso. Podemos citar: Quick que usa o fato de existir algoritmo rápido para particionar a tabela. Merge que usa o fato de existir algoritmo rápido para intercalar 2 sequências ordenadas. Heap que organiza a tabela como uma árvore hierárquica. Nesses métodos, o tempo é proporcional a (n. log n)