Assintótica Notas de aula de maio de Ciência da Computação Estruturas de Dados I Prof. Leandro Zatesko Introdução Quando manipulamos estruturas de dados, dois problemas são bastante comuns: o problema de buscar por um dado numa estrutura, denominado BUSCA, e o problema de ordenar uma estrutura de dados, denominado ORDENAÇÃO. Há vários algoritmos que resolvem ambos os problemas, e sempre que há mais de uma opção para se fazer alguma coisa, uma pergunta nos vem: qual deles escolher? Precisamos, então, analisar cada algoritmo para, enfim, entendermos as vantagens e desvantagens de cada um deles. Neste contexto, deparamo-nos com uma das áreas de pesquisa mais centrais em Ciência da Computação: a área de Análise de Algoritmos. Analisamos um algoritmo em termos dos recursos que a execução daquele algoritmo consome, seja tempo, memória, banda de rede etc. Via de regra a quantificação desses recursos é função do tamanho da entrada do algoritmo. Algumas ferramentas de comparação e de classificação de funções se mostram nesse esforço bastante úteis, especialmente as ferramentas da Assintótica, disciplina da Matemática que estuda o comportamento assintótico das funções. A palavra assintótica etimologicamente significa o que não cai junto, o que não coincide. Uma assíntota de uma função é um limitante do qual a função se aproxima mas jamais atinge. Um clássico exemplo é o valor zero para a função x . À medida que x cresce, x se aproxima cada vez mais de zero, mas nunca o atinge. Por extensão, e até por transcendência, o campo da Assintótica engloba tudo o que diz respeito ao comportamento de funções quando os valores do domínio tendem ao infinito. Uma hierarquia para funções Consideremos as seguintes funções de n: f (n) = n e g(n) = n . Vejamos as imagens de alguns valores de n por essas funções: n f (n) g(n) 10000000 9000000 8000000 7000000 6000000 f g 5000000 4000000 3000000 2000000 1000000 0 2 52 102152202252 302352402452502552 602652702752802852 Figura : Uma comparação entre f e g. Olhando para esses resultados, vemos como a função f supera a função g, porque, embora ambas comecem com o valor zero na imagem, logo as imagens por f se tornam muito maiores que as imagens por g. O gráfico da Figura ?? ilustra o fenômeno. Agora, notemos o que acontece com as funções se expandirmos um pouco mais nosso intervalo no domínio: n f (n) g(n) Vejamos na Figura ?? o que aconteceu! O crescimento da função g, outrora inferior ao crescimento constante da função f , agora supera o crescimento da função f . De fato, sabemos que o crescimento da função f é dn/dn = , enquanto que o crescimento da função g é dn /dn = n. Logo, para n = , o crescimento da função g é de , que é maior que o crescimento constante de da função f . 60000000 50000000 40000000 f g 30000000 20000000 10000000 0 11 407 803 1199 1595 19912387 2783 3179 3575 3971 4367 47635159 Figura : Uma comparação entre f e g. Mas o crescimento da função g não para de crescer, enquanto o crescimento da função f permanece estagnado. Isso quer dizer que logo a função g ultrapassa a função f , como vemos a seguir. n f (n) g(n) A ultrapassagem ocorreu exatamente no ponto em que n = , como é ilustrado pela Figura ??. Claro, n = é solução para a equação f (n) = g(n). 450000000 400000000 350000000 300000000 250000000 f g 200000000 150000000 100000000 50000000 0 40 1920 3800 5680 7560 9440 11320 13200 15080 16960 18840 Figura : Uma comparação entre f e g. Mas não para por aí. Se expandimos ainda mais o intervalo no domínio, vemos como a função g deixa a função f no chinelo. n f (n) × g(n) , × Olhando para o gráfico da Figura ??, vemos que, quanto mais n se aproxima do infinito, mais f se torna insignificante em relação a g. Mais formalmente, f (n) = . n→+∞ g(n) lim 12000000000 10000000000 8000000000 6000000000 f g 4000000000 2000000000 0 20 0 12 20 0 24 20 0 36 20 0 48 20 0 60 20 0 72 20 0 84 20 0 96 20 0 Figura : Uma comparação entre f e g. Assintoticamente, dizemos que g domina f e escrevemos f g. Essa notação não quer dizer que o valor de g é sempre maior que o valor de f , mas significa que, não importando que haja intervalos onde f (n) > g(n), à medida que n → +∞ a função g não só supera a função f mas também anula a razão g(n)/f (n). Definição . quando Dizemos que uma função f : N → R domina uma função g : N → R, e escrevemos f g, f (n) = . n→+∞ g(n) lim Definimos aqui que o domínio das funções é N porque, em Análise de Algoritmos, as funções são geralmente sobre o tamanho da entrada dos algoritmos: um número natural. A mesma definição pode ser estendida sem problemas para R, ou até para um outro corpo qualquer munido de uma relação de ordem total . Vamos mostrar que de fato n n . Ora, lim n→+∞ n = lim = lim = · = . n→+∞ n→+∞ n n n Perceba que a relação estabelece uma hierarquia entre as funções de domínio N. Por exemplo, a Figura ?? ilustra como ln n n n n . Não entendeu bulhufas? Talvez você devesse considerar estudar Álgebra. As aplicações para a Ciência da Computação são maiores do que você pensa. Procure pelo livro Finite Fields And Their Applications, de G. L. Mullen, editora Elsevier. 70 60 50 40 30 20 10 0 0 1 2 3 4 5 6 Figura : Uma comparação entre quatro funções. Por fim, apenas comentamos que escrever f g é a mesma coisa que escrever g f . Classificações de funções com o uso de e Dada uma função g(n), podemos definir com a relação uma classe de funções: a classe de todas as funções f (n) tais que f (n) g(n). Essa classe denotamos por o(g(n)). De igual modo, a classe de todas as funções f (n) tais que f (n) g(n) denotamos por ω(g(n)). Definição . Sendo duas funções f , g : N → R, dizemos que f (n) é assintoticamente inferior a g(n), e escrevemos f (n) = o(g(n)), quando f (n) g(n). Analogamente, dizemos que f (n) é assintoticamente superior a g(n), e escrevemos f (n) = ω(g(n)) quando f (n) g(n). Fica claro na Definição ?? que o uso do símbolo = é um abuso. Muito mais apropriado seria usar o símbolo ∈, já que o(g(n)) e ω(g(n)) são classes de funções. No entanto, como o uso do símbolo = já está por demais consagrado na Literatura, mantemos a notação por convenção. Apenas advertimos que = não deve ser lido como é igual a, mas sim como é. Por exemplo, n = o(n ) deve ser lido como n é o(n ), não como n é igual a o(n ). Isso evita absurdos como: n = o(n ) n = o(n ) logo n = n É comum também as notações serem usadas em expressões, como T (n) = o(n) − . n () Neste caso, o(n) não deve ser entendida como uma classe de funções, o que não faz muito sentido, outrossim como uma função da classe, ou seja, como uma função que é o(n). Mais especificamente, na Equação ?? temos que T (n) é alguma função (qual função não sabemos, e nem importa) que é o(n) vezes a função ( − (/n)). Outras classes assintóticas de funções Dissemos que estabelece uma hierarquia para funções de domínio N. Com isso, alguém mais desavisado poderia subentender que para quaisquer funções distintas f , g : N → R vale que ou f g ou g f , o que nos permitiria ordenar todas as infinitas funções de RN , um absurdo, já que RN é incontável . Ora, tomemos o contraexemplo das funções n e n : n = . lim n→+∞ n Logo, não é verdade que n n nem que n n . Precisamos de uma notação mais poderosa que a notação . Vamos conhecer agora a notação O (em inglês, the Big O Notation ): Definição . Sendo duas funções f , g : N → R, dizemos que f (n) é assintoticamente no máximo g(n), e escrevemos f (n) = O(g(n)), se existem duas constantes n ∈ N e c ∈ R, c > , para as quais |f (n)| 6 c|g(n)|, para todo n > n . Vamos a alguns exemplos de funções O(n ): • n = O(n ), pois |n | 6 |n |, para todo n > . (c = , n = ) • n = O(n ), pois |n| 6 |n |, para todo n > . (c = , n = ) • n + n = O(n ), pois |n + n| 6 |n + n · n| 6 |n |, para todo n > . (c = , n = ) √ • n n + = O(n ), pois √ √ √ √ √ |n n + | 6 |n n| 6 ( )|n n| 6 ( )|n |, para todo n > . √ (c = , n = ) Para entendermos graficamente o que a notação O significa, observemos a Figura ??, a qual ilustra f (x) = O(g(x)) plotando em azul no gráfico a curva c|(|g(x)). Figura : Uma ilustração da notação O. Sendo A e B conjuntos, AB denota o conjunto de todas as funções f : B → A. A propósito, o é conhecida como the little o notation. Em contrapartida à notação O, temos: Definição . Sendo duas funções f , g : N → R, dizemos que f (n) é assintoticamente no mínimo g(n), e escrevemos f (n) = Ω(g(n)), se existem duas constantes n ∈ N e c ∈ R, c > , para as quais |f (n)| > c|g(n)|, para todo n > n . Lembremos que quando f (n) = o(g(n)) é impossível que f (n) = ω(g(n)), pois não pode ao mesmo tempo ocorrer de f (n) g(n) e f (n) g(n). No entanto, é possível sim que uma função f (n) seja tanto O(g(n)) quanto Ω(g(n)). Vejamos o caso da função n + n, a qual já sabemos ser O(n ): é imediato que |n + n| > |n | para todo n > e, portanto, que n + n = Ω(g(n)). Definição . Sendo duas funções f , g : N → R, dizemos que f (n) é assintoticamente equivalente a g(n), e escrevemos f (n) = Θ(g(n)), se f (n) = O(g(n)) e f (n) = Ω(g(n)). No entanto, nem sempre que f (n) = O(g(n)) vale que f (n) = Ω(g(n)). Por exemplo, já sabemos que √ √ n n + = O(n ). Contudo, se vale que n n + = Ω(n ), então, existem c e n tais que, √ para todo n > n , √ |n n +√| > c|n |. Mas isso implicaria que para todo n > max{n , } valeria que cn 6 n n e, portanto, que √ n 6 c , o que seria um absurdo. Exercícios Exercício . Mostre que p(n) = n + n + n + n = O(n ). Exercício . Mostre que: • logb n = O(log n) para qualquer base b > . • n log n = O(n ). • n log n , Ω(n ). • log n! = Θ(n log n). Exercício . Mostre que f (n) = o(g(n)) se e somente se para todo real não-nulo c existe um natural n tal que |f (n)| 6 c|g(n)|, para todo n > n . Dica: utilize a definição formal de limite. Exercício . que Mostre que f (n) = ω(g(n)) se e somente se para todo real não-nulo c existe um natural n tal |f (n)| > c|g(n)|, para todo n > n . Dica: utilize a definição formal de limite. Exercício . Mostre que f (n) = O(g(n)) e f (n) , o(g(n)) se e somente se f (n) = Θ(g(n)). Exercício . Mostre que f (n) = Ω(g(n)) e f (n) , ω(g(n)) se e somente se f (n) = Θ(g(n)). Exercício . Mostre que O(g )O(g ) = O(g g ). Exercício . Mostre que f O(g) = O(f g). Exercício . Mostre que O(g ) + O(g ) = O(|g + g |). Exercício . Exercício . Exercício . Mostre que, se k é uma constante real não-nula, então, O(kg) = O(g). Mostre que todo polinômio p(n) = O nd(p(n)) , sendo d(p(n)) o grau do polinômio p(n). Mostre que p(n) cn para todo polinômio p(n) e para toda constante c > . Métodos de ordenação quadráticos Notas de aula – de maio de Ciência da Computação Estruturas de Dados I Prof. Leandro Zatesko Introdução O problema da ordenação é um dos problemas mais clássicos da Ciência da Computação. Dada uma estrutura de dados E indexada por [..n − ] munida de uma relação de ordem total 6, o que se quer é permutar os elementos em E de tal modo que E[i] 6 E[i + ] para todo i. Há vários métodos de ordenação. Os mais intuitivos são o assunto destas notas, sendo todos eles de complexidade quadrática, como veremos. Método de ordenação por seleção O método de ordenação por seleção (Insertion Sort, em inglês) é talvez o mais intuitivo dos métodos de ordenação. Consiste em selecionar o menor elemento para o primeiro índice, o segundo menor elemento para o segundo índice e assim por diante. Abaixo exibimos uma implementação do método para a ordenação de um vetor de inteiros em linguagem C. void SelectionSort(int* V, int n) { int i, j, menor, aux; for (i = ; i <= n-; i++) { menor = i; for (j = i+; j <= n-; j++) if (V[j] < V[menor]) menor = j; aux = V[i]; V[i] = V[menor]; V[menor] = aux; } } . Esboço da análise do algoritmo O crucial na análise de qualquer algoritmo de ordenação está em contar o número de comparações e o número de trocas realizadas. Cada uma dessas operações tem seu custo dependendo da implementação e do contexto em que o método é empregado. Finalizar a análise do tempo dos métodos de ordenação apresentados neste texto é simplesmente multiplicar os resultados apresentados pelos respectivos custos de cada operação. Consideramos como comparação apenas a linha if (V[j] < V[menor]), pois as condicionais dos laços for não são sobre dados da estrutura propriamente dita. Para cada i em [..n−], faz-se (n−)−(i +)+ = n−i − comparações. Logo, o número de comparações é (n − − ) + (n − − ) + (n − − ) + (n − − ) + · · · + (n − (n − ) − ) = (n − ) + (n − ) + · · · + + = n(n − ) = Θ(n ) Mas o Selection Sort no número de trocas não é quadrático. Percebamos que ele realiza uma só troca para cada i em [..n − ], o que resulta em n − = Θ(n) trocas. Método de ordenação por inserção O método de ordenação por inserção (Insertion Sort, em inglês) parte de duas observâncias: . um vetor com um só elemento é trivialmente um vetor ordenado; . inserir um elemento num vetor já ordenado é fácil. Assim, o método vai inserindo cada α = V [i], i ∈ [..n − ], em V [..i − ] procurando o primeiro j de trás para frente tal que V [j] 6 α para pôr α em V [j + ]. Abaixo exibimos uma implementação do método para a ordenação de um vetor de inteiros em linguagem C. void InsertionSort(int* V, int n) { int i, j, alpha; for (i = ; i <= n-; i++) { alpha = V[i]; for (j = i-; j >= && V[j] > alpha; j--) V[j+] = V[j]; V[j+] = alpha; } } . Esboço da análise do algoritmo O Selection Sort faz o mesmo número de comparações e de trocas para todos os casos, mas isso não vale para o Insertion Sort. Perceba que inserir um elemento pode ser feito só com uma comparação, dando sorte, ou com todas, dando azar. No melhor caso do algoritmo, damos sorte sempre. É o caso em que a estrutura já está ordenada. Um V [j] nunca é maior que o elemento α que estamos querendo inserir. Logo, para cada i ∈ [..n − ], fazemos uma só comparação, totalizando n − = Θ(n) comparações. No pior caso, damos sempre azar. É o caso em que a estrutura está reversamente ordenada. Assim, para cada α = V [i] que vamos inserir no subvetor V [..i − ], fazemos todas as comparações possíveis para inserirmos α em V []. Neste caso, fazemos + + · · · + (n − ) = n(n − )/ = Θ(n ) comparações. O caso médio é um tanto que difícil de calcular, mas não é de se surpreender que para cada V [i] a ser inserido tenhamos um número esperado de Θ(n) comparações, o que também resulta num total de Θ(n ) comparações, igualando o caso médio ao pior caso. Concluímos que o método de ordenação por inserção costuma ser melhor que o por seleção em número de comparações, já que o método por inserção por ser, no melhor caso, linear, enquanto que o por seleção é sempre quadrático. Vejamos, entretanto, quantas trocas o método por inserção faz. Primeiramente, o conceito de troca é um conceito um tanto que torpe para o Insertion Sort, já que as trocas não são feitas por triangulação, como no Selection. Cada troca por triangulação envolve movimentos de dados. Podemos então contar os movimentos feitos pelo Insertion e depois dividir o resultado por (uma constante, o que não faz diferença em termos assintóticos) para obtermos o número de trocas. Note-se que o número de movimentos é sempre o número de comparações mais . Portanto, o número de (n−) trocas do Inertion Sort é n+ + = Θ(n ) no pior e Θ(n ) no caso médio. = Θ(n) no melhor caso, Método de ordenação por bolha O método de ordenação por bolha é talvez o mais simples. Fazemos uma varredura na estrutura e, cada vez que encontramos um par de elementos consecutivos desordenados (ou seja, um i tal que E[i] > E[i + ]), trocamos aquele par. Repetimos essa varredura tantas vezes quanto necessário até que o vetor esteja completamente ordenado. Percebemos que o vetor está ordenado quando fazemos uma varredura sem que ocorra troca alguma. Abaixo exibimos uma implementação do método para a ordenação de um vetor de inteiros em linguagem C. void BubbleSort(int* V, int n) { int houve_trocas, j, aux; do { houve_trocas = ; for (j = ; j <= n-; j++) if (V[j] > V[j+]) { aux = V[j]; V[j] = V[j+]; V[j+] = aux; houve_trocas = ; } } while(houve_trocas); } Simulemos a primeira varredura para o caso do vetor (, −, , , ): (, −, , , ) ⇒ (−, , , , ) ⇒ (−, , , , ) ⇒ (−, , , , ) ⇒ (−, , , , ) Vejamos como a varredura propagou numa bolha o elemento até o fim do vetor. Não é difícil perceber que logo após a primeira varredura, a última posição da estrutura já contém o maior elemento. Após a segunda, então, a penúltima posição contém o segundo maior, e assim por diante. Isso significa que na segunda varredura não precisamos levar i até a comparação de E[n−] com E[n−]. Podemos parar ao compararmos E[n−] com E[n − ]. Logo, podemos decrementar n a cada varredura, otimização esta que implementamos a seguir. void BubbleSort(int* V, int n) { int houve_trocas, j, aux; do { houve_trocas = ; for (j = ; j <= n-; j++) if (V[j] > V[j+]) { aux = V[j]; V[j] = V[j+]; V[j+] = aux; houve_trocas = ; } n--; } while(houve_trocas); } . Esboço da análise do algoritmo No melhor caso, o método de ordenação por bolha faz uma só varredura na estrutura, constatando que ela já está ordenada. Neste caso, faz n − = Θ(n) comparações e zero trocas. No pior caso, quando o vetor está ordenado reversamente, cada varredura acerta só uma posição, começando da última, à exceção da última varredura, a qual acerta tanto a primeira quanto a segunda posição. Finalmente, o pior caso do método de ordenação por bolha faz n − varreduras. Se consideramos a otimização do decremento de n a cada varredura, temos que o número de comparações é, portanto, (n−)+(n−)+· · ·+ = n(n − )/ = Θ(n ). Este é também o número de trocas, já que o pior caso sempre faz uma troca para cada comparação. Encerramos nosso esboço mencionando que Θ(n ) também é o comportamento do Bubble Sort tanto em número de comparações quanto em número de trocas. Exercícios Exercício . Sorteie vetores de elementos em [..], um pequeno, um médio e um grande. O pequeno deve ter elementos, o médio, , e o grande, . Ordene cada um dos vetores com os métodos de ordenação apresentados e compare o número de comparações, o número de trocas e o tempo de execução. Exercício . Crie uma struct chamada Produto contendo os campos nome (string com caracteres), codigo (um número inteiro), preco, descricao (string com caracteres), cor e peso. Crie um vetor com Produtos, preenchidos aleatoriamente. Use os métodos para ordenar esse vetor e compare o tempo de execução. Exercício . Implemente os métodos de ordenação apresentados para listas duplamente encadeadas. Exercício . O modo como o Insertion Sort insere cada α no subvetor já ordenado é linear. No entanto, como o subvetor já está ordenado, bem que poderíamos proceder de modo binário. Reimplemente o Insertion Sort utilizando essa nova estratégia. A propósito, este algoritmo já existe e se chama Binary Insertion Sort. MergeSort Notas de aula de junho de Ciência da Computação Estruturas de Dados I Prof. Leandro Zatesko Introdução Todos os métodos de ordenação que abordamos nas notas de aula anteriores (Insertion Sort, Selection Sort e Bubble Sort) chamamos de quadráticos. Assim o fizemos porque todos eles são de tempo Θ(n ) tanto no caso médio quanto no pior caso, sendo n o número de elementos da estrutura a ser ordenada e considerando o tempo como o número de comparações mais o número de trocas (ou o número de movimentos, dado que uma troca consiste de três movimentos) realizadas pelo algoritmo. A pergunta que fica é: dá para termos um algoritmo de ordenação melhor, que custe tempo o(n )? A resposta é: sim! É possível, entretanto, provar matematicamente que nenhum algoritmo de ordenação pode fazer o serviço em tempo o(n log n). Ou seja, todo algoritmo de ordenação leva tempo Ω(n log n). Dizemos que Ω(n log n) é a cota inferior para o problema da ordenação. Agora podemos fazer a pergunta de um outro jeito: é possível ter um algoritmo que custe tempo Θ(n log n)? E o sim permanece! Evidentemente, os algoritmos que já apresentamos não são Θ(n log n). Eles são Ω(n log n), como todo algoritmo de ordenação, mas não Θ(n log n). Hoje mostraremos que a cota Ω(n log n) é uma cota inferior justa. Ou seja, embora a cota diga ser impossível um algoritmo de tempo o(n log n), vamos mostrar um algoritmo de tempo Θ(n log n) inclusive para o pior caso. Trata-se do método de ordenação por intercalação (em inglês, Merge Sort). O Merge Sort parte de um procedimento bem simples conhecido como merge (intercalação), o qual, dados dois vetores já ordenados, constrói um terceiro vetor também ordenado a partir da intercalação dos dois vetores da entrada. Por exemplo, se temos como entrada os vetores A = (, , , ) e B = (−, , , ), o resultado é o vetor (−, , , , , , ). O Merge Sort é um clássico exemplo dos algoritmos desenvolvidos recursivamente através da estratégia dividir para conquistar (em inglês, divide and conquer). Quando queremos resolver um problema utilizando essa estratégia, fazemos o seguinte: . dividimos a instância do problema em subinstâncias menores do mesmo problema; . recursivamente, obtemos as soluções para as subinstâncias; . combinamos as soluções obtidas para as subsinstâncias de modo a construirmos a solução para a instância original. No caso do Merge Sort, o que fazemos é o seguinte: . dividimos o vetor que queremos ordenar em dois subvetores; . recursivamente, chamamos o Merge Sort para ordenar os subvetores; . utilizamos o procedimento merge para intercalar os dois subvetores ordenados. A recursão do MergeSort Em linguagem C, no contexto específico da ordenação de um vetor de int, podemos implementar o Merge Sort como segue: void MergeSort(int *V, int p, int r) { int q; if (p >= r) return; q = (p+r) / ; MergeSort(V, p, q); MergeSort(V, q+, r); merge(V, p, q, r); } Note-se que recebemos não apenas o vetor V , mas também qual é a porção do vetor V que devemos considerar: o subvetor que começa na posição p e termina na posição r. Assim, se queremos ordenar um vetor V com n posições, devemos simplesmente chamar: MergeSort(V, , n-); Nossa implementação divide o vetor V [p..r] em dois subvetores: V [p..q] e V [q + ..r], sendo q o meio do vetor: q = bp + rc/. Depois, chama recursivamente o Merge Sort duas vezes: uma para ordenar V [p..q] e outra para ordenar V [q + ..r]. Por fim, chama o procedimento merge para, a partir dos dois vetores ordenados, construir por intercalação a ordenação de V [p..r], o vetor origina. Vamos simular o que acontece quando chamamos o Merge Sort para ordenar um vetor com posições V [..]. Chamamos MergeSort(V, , ): • Esta chamada de função chama MergeSort(V, , ). – Esta chamada de função chama MergeSort(V, , ). * Esta chamada de função chama MergeSort(V, , ). · Esta chamada de função chama MergeSort(V, , ), mas, como > , nada ocorre. · Esta chamada de função chama MergeSort(V, , ), mas, como > , nada ocorre. · merge combina os vetores já ordenados V [..] e V [..] para obter a ordenação de V [..]. * Esta chamada de função chama MergeSort(V, , ), mas, como > , nada ocorre. * merge combina os vetores já ordenados V [..] e V [..] para obter a ordenação de V [..]. – Esta chamada de função chama MergeSort(V, , ). * Esta chamada de função chama MergeSort(V, , ), mas, como > , nada ocorre. * Esta chamada de função chama MergeSort(V, , ), mas, como > , nada ocorre. * merge combina os vetores já ordenados V [..] e V [..] para obter a ordenação de V [..]. – merge combina os vetores já ordenados V [..] e V [..] para obter a ordenação de V [..]. • Esta chamada de função chama MergeSort(V, , ). – Esta chamada de função chama MergeSort(V, , ). * Esta chamada de função chama MergeSort(V, , ). · Esta chamada de função chama MergeSort(V, , ), mas, como > , nada ocorre. · Esta chamada de função chama MergeSort(V, , ), mas, como > , nada ocorre. · merge combina os vetores já ordenados V [..] e V [..] para obter a ordenação de V [..]. * Esta chamada de função chama MergeSort(V, , ), mas, como > , nada ocorre. * merge combina os vetores já ordenados V [..] e V [..] para obter a ordenação de V [..]. – Esta chamada de função chama MergeSort(V, , ). * Esta chamada de função chama MergeSort(V, , ), mas, como > , nada ocorre. * Esta chamada de função chama MergeSort(V, , ), mas, como > , nada ocorre. * merge combina os vetores já ordenados V [..] e V [..] para obter a ordenação de V [..]. – merge combina os vetores já ordenados V [..] e V [..] para obter a ordenação de V [..]. • merge combina os vetores já ordenados V [..] e V [..] para obter a ordenação de V [..]. E o vetor V [..] volta ordenado! O procedimento merge Agora só falta implementar o procedimento de intercalação (merge). Recebemos um vetor V acompanhado de índices p, q e r. Sabemos que V [p..q] está ordenado e V [q + ..r] também. Precisamos então intercalar esses dois subvetores para compor todo um vetor V [p..r] ordenado. Para tanto: . criamos dois vetores auxiliares E (de esquerda) e D (de direita): E com |E | = q−p+ posições (a quantidade de posições em V [p..q]) e D com |D| = r − q (a quantidade de posições em V [q + ..r]); . copiamos V [p..q] para E[..|E | − ] e V [q + ..r] para D[..|D| − ]; . posicionamos um cabeçote e sobre E[] e um cabeçote d sobre D[] e fazemos um for para iterar uma variável j sobre as posições de V [p..r], de tal modo que, para cada iteração, fazemos V [j] ← E[e] se E[e] 6 D[d] ou V [j] ← D[d] caso contrário, andando o cabeçote usado para a direita. Se em algum momento o cabeçote de um dos vetores E ou D ultrapassar seus limitantes, passamos a usar apenas os elementos do outro vetor para construirmos V . Apresentamos a implementação da função merge. void merge(int *V, int p, int q, int r) { int *D, *E; int tamD, tamE, j, d, e; tamE = q-p+; tamD = r-q; /* cálculo de tamE = |E| e de tamD = |D| */ E = (int*)malloc(tamE*sizeof(int)); D = (int*)malloc(tamD*sizeof(int)); for (j = ; j <= tamE-; j++) E[j] = V[p+j]; /* cópia de V[p..q] para E[..tamE-] */ for (j = ; j <= tamD-; j++) D[j] = V[q++j]; /* cópia de V[q+..r] para D[..tamD-] */ e = d = ; /* inicialização dos cabeçotes */ for (j = p; j <= r; j++) { if (e <= tamE- && d <= tamD-) V[j] = (E[e] <= D[d]) ? E[e++] : D[d++]; else if (e <= tamE-) /* significa que d > tamD- */ V[j] = E[e++]; else /* significa que e > tamE- */ V[j] = D[d++]; /* perceba que jamais ocorrerá de ambos os cabeçotes ultrapassarem seus limitantes */ } free(E); free(D); } Esboço da análise Vamos esboçar uma análise apenas para intuitivamente percebermos que em todo caso o Merge Sort custa tempo Θ(n log n). Vamos primeiramente observar quanto custa o procedimento merge. Criar os vetores E e D e copiar para eles os subvetores V [p..q] e V [q + ..r] é algo que fazemos em tempo linear no tamanho de V [p..r]. Fazemos q − p + movimentos para preenchermos E e r − q movimentos para preenchermos D. Isso totaliza r − p + = n movimentos. Depois, para cada j ∈ [p..r], fazemos uma comparação entre E[e] e D[d] e um movimento para pôr em V [j] o elemento apropriado. Isso totaliza n comparações mais n movimentos. Finalmente, temos que o procedimento merge demanda n movimentos (ou n/ trocas) e n comparações, e dessarte podemos concluir que custa tempo Θ(n). Agora, quanto custa todo o Merge Sort para ordenar V [..n − ]? Chamemos esse tempo de T (n), pois é função do número de elementos no vetor. Se V só possui um elemento (se n = ), então, custa T () = , já que nada é feito, nem comparações nem movimentos. Senão (se n > ), custa: • o tempo que a chamada recursiva do Merge Sort leva para ordenar V [..m] (m = bn − c/), isto é, T (m + ) T (n/) • mais o tempo que a chamada recursiva do Merge Sort leva para ordenar V [m+..n−], isto é, T (n−m−) T (n/) • mais o tempo do merge, isto é, Θ(n). Chegamos então à uma recorrência: , T (n) = n + Θ(n), T se n = ; se n > . Vamos resolver a recorrência em alguns passos para percebermos o que ocorre. n T (n) = Θ(n) + T n n = Θ(n) + Θ + T n n n = Θ(n) + Θ + Θ + T n n n n = Θ(n) + Θ + Θ + Θ + T = ··· Logo, o. nível o. nível o. nível o. nível o. nível z }| { z }| { z }| { z }| { z}|{ n n n n + Θ + Θ + Θ +··· T (n) = Θ(n) + Θ Quantos termos tem esta soma, afinal? Por quantos níveis podemos descer até chegarmos a T () = ? Note-se que isso é o mesmo que perguntar quantas vezes podemos dividir sucessivamente n por até chegarmos a , e a resposta é blg nc. Ou seja, nesta soma há blg nc níveis. Mas note que cada nível adiciona à soma exatamente Θ(n). Por exemplo, o nível adiciona Θ(n/) = Θ(n), o nível adiciona Θ(n/) = Θ(n) etc. Enfim, se temos blg nc níveis cada um adicionando à soma Θ(n), temos como resultado que T (n) = blg ncΘ(n) = Θ(n log n), como queríamos mostrar. Exercícios Exercício . Refaça o Exercício das notas de aula sobre métodos de ordenação quadráticos ordenando os vetores também com o Merge Sort e compare o número de comparações, o número de trocas e o tempo de execução de todos os métodos. Exercício . Refaça o Exercício das notas de aula sobre métodos de ordenação quadráticos ordenando o vetor também com o Merge Sort e compare o tempo de execução de todos os métodos. Exercício . Implemente o Merge Sort para listas duplamente encadeadas. Quick Sort Notas de aula de junho de Ciência da Computação Estruturas de Dados I Prof. Leandro Zatesko Introdução Nas notas de aula anteriores, apresentamos o Merge Sort, um método de ordenação bastante simples que se mostra ótimo em tempo. No pior caso, o tempo do Merge Sort é Θ(n log n), precisamente a cota inferior de tempo conhecida para o problema de ordenação. Está resolvido, então? Sempre que queremos ordenar uma coleção de dados suficientemente grande, devemos utilizar o Merge Sort? A resposta é: Não! Se o Merge Sort é ótimo em tempo, ele é completamente indesejável em espaço. Toda chamada da função merge duplica o vetor passado como argumento, se bem que libera o espaço alocado antes de se encerrar. Na pior das chamadas, merge aloca Θ(n) bits adicionais, considerando que o tamanho de cada posição do vetor a ser ordenado é constante — se não for, a coisa fica pior ainda. Logo, o Merge Sort é O(n) em espaço. Em Análise de Espaço de Algoritmos, O(n) é uma complexidade completamente indesejável. Significa que toda a entrada precisa ser duplicada na memória. Pense só em algoritmos em bases de dados enormes, como toda a Internet, por exemplo. Assim como o desejável é que os algoritmos sejam polinomiais em tempo, é desejável que sejam O(log n) em espaço. Na verdade, sabemos que todos os problemas computáveis por algoritmos O(log n) em espaço são também computáveis por algoritmos polinomiais em tempo, embora a recíproca ainda não se saiba ser verdadeira nem falsa, sendo um dos tantos problemas em aberto em Teoria da Computação. Vamos estudar um algoritmo de ordenação que é Θ(n log n) em tempo no caso médio (e no melhor caso) e O() em espaço: o Quick Sort, proposto por um estudante em chamado Tony Hoare. No pior caso, todavia, o Quick Sort é Θ(n ). Entretanto, sabemos que o pior caso tem probabilidade /n! de ocorrer, e sabemos que n! n . Apresentação do Quick Sort Entender a ideia geral do Quick Sort é tão simples quanto entender a ideia do Merge Sort. O Quick Sort também é um algoritmo do tipo dividir para conquistar (divide and conquer), baseado num procedimento chamado partition (do inglês, particione), o qual seleciona um elemento pivô do vetor V [p..r] e rearranja V de modo que, sendo q a posição onde foi parar o pivô, todos os elementos em V [p..q − ] sejam no máximo V [q] e todos os elementos em V [q + ..p] sejam no mínimo V [q]. Após esse procedimento, perceba-se que V [q] já está na posição em que deve ficar até o final da ordenação completa de V . O que falta fazer, portanto, é ordenar V [p..q − ] e V [q + ..r]. Ambas as ordenações são conquistadas através de chamadas recursivas do Quick Sort. Classicamente, o pivô que escolhemos num vetor V [p..r] é sempre o último elemento: V [r]. Evidentemente, após a operação partition, é bem provável que esse elemento já não esteja mais na última posição do vetor. Portanto, o procedimento partition deve sempre retornar a posição q em que foi parar o pivô. Assim, podemos chamar o Quick Sort recursivamente para ordenar V [p..q − ] e V [q + ..r], como já mencionamos. Em linguagem C, no contexto específico da ordenação de um vetor de int, podemos implementar o Quick Sort como segue: void QuickSort(int *V, int p, int r) { int q; if (p >= r) return; q = partition(V, p, r); QuickSort(V, p, q-); QuickSort(V, q+, r); } Assim, se queremos ordenar um vetor V com n posições, devemos simplesmente chamar: QuickSort(V, , n-); A implementação da função partition Talvez a maior dificuldade em entender o Quick Sort esteja precisamente na implementação da função partition. Para que não precisemos duplicar o vetor, o que nos levaria a um resultado execrável em espaço similar ao do Merge Sort, valemo-nos de um truque simples, mas que pode causar certa confusão aos estudantes mais descuidados ou desatentos. O truque consiste em construir as duas partições do vetor (à esquerda e à direita do pivô) a partir de trocas entre as próprias partições e, por fim, entre o primeiro elemento da partição à direita e o próprio pivô, que é mantido no final do vetor até o fim do procedimento. Sejamos mais claros. Tomemos por exemplo o vetor − − Nosso pivô será o elemento da última posição do vetor, o elemento . A princípio, a partição à esquerda estará vazia e a partição à direita conterá todo o vetor, à exceção do pivô. Marcamos a divisão entre as partições através de uma variável i, inicialmente i = −, a qual informa a posição do último elemento da partição à esquerda. i = − - - Vamos começar a varredura pela partição à direita procurando elementos que são no máximo o pivô, ou seja, elementos que não deveriam estar na partição à direita, mas na partição à esquerda. Fazemos esta varredura através de uma variável j, a qual começa valendo (o primeiro elemento da partição à direita). i = − j = - - Ora, V [j] = > , portanto, está na partição certa. Prosseguimos a varredura incrementando j. i = − j = - - Agora temos um problema: V [j] = − 6 e não devia estar na partição à direita, mas na partição à esquerda. Vamos, então, incrementar i, o que faz com que a partição à esquerda aumente em seu tamanho e a partição à esquerda diminua em seu tamanho. Fazendo isso, porém, V [i] será V [] = , o primeiro elemento da partição à direita. Fazemos, então, uma troca entre V [i] e [j], pois V [i] seguramente deve ir para a partição à direita e V [j] seguramente deve ir para a partição à esquerda. Ficamos com o vetor abaixo e incrementamos j para prosseguirmos com a varredura. i= - j = - V [] = está no lugar certo. Incrementamos j. i= j = - - V [j] = − 6 está no lugar errado. Incrementando i, temos que V [i] = V [] = também passa a estar no lugar errado. Trocamos então V [i] com V [j], incrementamos j e ficamos com: i= - - j = V [j] = > está no lugar certo. Incrementamos j. i= - - j = V [j] = 6 está no lugar errado. Incrementando i, temos que V [i] = V [] = também passa a estar no lugar errado. Trocamos então V [i] com V [j], incrementamos j e ficamos com: i= - - j = V [j] = > está no lugar certo. Incrementamos j. i= - - j = V [j] = > está no lugar certo. Incrementamos j. i= - - j = V [j] = > está no lugar certo. Incrementando j, temos que j = ultrapassa os limites da partição à direita. Portanto, encerramos nossa varredura e ficamos com: i= - - Perceba que todos os elementos da partição à esquerda são no máximo o pivô, e que todos os elementos da partição à direita são no mínimo o pivô. Agora, basta pormos o pivô no lugar certo, trocando o pivô com o primeiro elemento da partição à direita (V [i + ]). Ficamos com: i= - - Finalmente, retornamos i + = como a posição onde foi parar o pivô. Apresentamos a implementação da função partition. int partition(int *V, int p, int r) { int i, j, pivo=V[r], aux; for (i = p-, j = p; j <= r-; j++) if (V[j] <= pivo) { i++; aux = V[i]; V[i] = V[j]; V[j] = aux; } V[r] = V[i+]; V[i+] = pivo; return i+; } Esboço da análise do QuickSort Nas próximas notas de aula! Não perca! Exercícios Exercício . Refaça o Exercício das notas de aula sobre Merge Sort ordenando os vetores também com o Quick Sort e compare o número de comparações, o número de trocas e o tempo de execução de todos os métodos. Exercício . Refaça o Exercício das notas de aula sobre Merge Sort ordenando o vetor também com o Quick Sort Sort e compare o tempo de execução de todos os métodos. Exercício . Implemente o Quick Sort para listas duplamente encadeadas. Esboço da análise do Quick Sort Notas de aula de junho de Ciência da Computação Estruturas de Dados I Prof. Leandro Zatesko Esboço da análise da função partition Reapresentamos a implementação da função partition. int partition(int *V, int p, int r) { int i, j, pivo=V[r], aux; for (i = p-, j = p; j <= r-; j++) if (V[j] <= pivo) { i++; aux = V[i]; V[i] = V[j]; V[j] = aux; } V[r] = V[i+]; V[i+] = pivo; return i+; } A função partition varre todos os elementos de V [p..r − ] comparando-os com o pivô, V [r]. Portanto, sempre faz r − p + comparações (o número de elementos no subvetor para o qual a função partition foi chamada). No pior caso, também faz o mesmo número de trocas. No melhor caso, não faz troca alguma. Portanto, não é difícil perceber que em todo caso o tempo da função partition (número de comparações mais número de trocas, como temos convencionado) é Θ(r − p + ). Esboço da análise do Quick Sort Quando discorremos sobre o Merge Sort, também constatamos que a função merge é sempre executada em tempo Θ(r − p + ). Como a recursão do Merge Sort sempre divide o vetor ao meio, observamos que a árvore de recursão do Merge Sort desce balanceadamente em blg nc níveis, e que em cada nível a soma dos tempos de todas as chamadas da função merge sempre dá Θ(n) (Θ(n) = Θ(n/) = Θ(n/) = · · · ). Portanto, foi fácil verificar que o tempo do Merge Sort em todo caso é blg ncΘ(n) = Θ(n log n). De igual modo, num mesmo nível da recursão do Quick Sort todas as chamadas da função partition somam um tempo total de Θ(n), pois cada chamada pega uma parte disjunta do vetor para particionar, como ilustrado abaixo. − ⇓ partition = Θ(n) . ⇓ partition = Θ n ⇓ - ↓ ⇓ partition = Θ n − (TOTAL= Θ(n)) ⇓ partition = Θ partition = Θ n ⇓ .. . ⇓ . ⇓ & - ⇓ ⇓ .. . n & (TOTAL= Θ(n)) ⇓ partition = Θ n (TOTAL= Θ(n)) ⇓ .. . O grande problema é que, diferentemente do Merge Sort, a razão segundo a qual os vetores são divididos em cada nível não é constante. No Merge Sort, a razão era constante: /. Portanto, a profundidade da árvore de recursão era blg nc. No Quick Sort, se ao menos a razão fosse constante, digamos /, teríamos que a profundidade da árvore seria blog nc, e poderíamos concluir que o tempo do Quick Sort seria sempre blog ncΘ(n) = Θ(n log n). Agora, como ficamos se a razão não é constante? O jeito é avaliarmos caso por caso. É intuitivo que o melhor caso é aquele em que o Quick Sort se comporta como o Merge Sort, dividindo os vetores sempre no meio. É o caso em que as chamadas da função partition sempre são bem-sucedidas. Nesse caso, temos que a profundidade da árvore de recursão é blg nc e concluímos que o tempo do Quick Sort é blg ncΘ(n) = Θ(n log n). No pior caso, todavia, as chamadas da função partition são sempre mal-sucedidas, deixando sempre um dos subvetores vazio e o outro com o número de elementos do vetor menos . Temos, então, que no primeiro nível o vetor tem n elementos, no segundo tem n − , no terceiro tem n − e, assim por diante, até elemento no último nível. Percebemos, então, um total de n níveis, o que implica no tempo do Quick Sort no pior caso ser nΘ(n) = Θ(n ). Isso quer dizer que podemos esperar do Quick Sort um comportamento similar ao dos outros algoritmos quadráticos, como Insertion, Selection e Bubble Sort, por exemplo? Não! Embora o pior caso do Quick Sort seja Θ(n ), não é esse o tempo do caso médio (ou caso esperado). Mostrar o caso médio do Quick Sort foge ao escopo deste curso. Então, limitamo-nos a anunciar que, no caso médio, o Quick Sort é Θ(n log n). O Quick Sort é um exemplo de um algoritmo cujo caso médio não é assintoticamente equivalente ao pior caso, mas ao melhor. Ademais, o pior caso do Quick Sort tem baixíssima probabilidade de ocorrer: cerca de /n!. Uma probabilidade tão baixa que pode ser considerada nula, já que a função /n! tende muito rapidamente a . Note-se que, para n = , /n! ,. Concluímos que o Quick Sort é um ótimo algoritmo de ordenação, pois, além de ter um desempenho em tempo esperado de Θ(n log n), conforme a cota inferior justa conhecida, ainda usa espaço O(), como os métodos quadráticos, ao contrário do Merge Sort, pois não aloca espaço adicional para duplicação de subvetores. Tabela comparativa dos métodos de ordenação Tempo do melhor caso Tempo do pior caso Tempo do caso médio Espaço Selection Sort Θ(n ) Θ(n ) Θ(n ) Θ() Insertion Sort Θ(n ) Θ(n ) Θ(n ) Θ() Bubble Sort Θ(n) Θ(n ) Θ(n ) Θ() Merge Sort Θ(n log n) Θ(n log n) Θ(n log n) Θ(n) Quick Sort Θ(n log n) Θ(n ) Θ(n log n) Θ() Exercícios Exercício . O caso médio Θ(n log n) do Quick Sort assume que todas as entradas possíveis têm igual probabilidade de ocorrerem, o que, na prática, não é bem assim. Um jeito de contornar esse problema e forçar uma distribuição uniforme sobre o conjunto das entradas é sortear o pivô na função partition, ao invés de pegar sempre o último elemento. É bem simples: basta sortear um elemento do vetor, trocá-lo com o último elemento e proceder com a função partition do mesmíssimo jeito. Essa versão prática do Quick Sort é conhecida como Randomized Quick Sort ou Probabilistic Quick Sort. Implemente-a e agregue-a aos exercícios das notas de aula anteriores, comparando o desempenho com os demais métodos já implementados. Métodos de busca (parte ) Notas de aula de maio de Ciência da Computação Estruturas de Dados I Prof. Leandro Zatesko Introdução Quando manipulamos estruturas de dados, dois problemas são bastante comuns: o problema de buscar por um dado numa estrutura, denominado BUSCA, e o problema de ordenar uma estrutura de dados, denominado ORDENAÇÃO. Há vários algoritmos que resolvem ambos os problemas. Estudaremos dois algoritmos para o problema da BUSCA. Definição do problema Antes de nos aventurarmos a estudar algoritmos que resolvam o problema da BUSCA, vamos primeiramente entendê-lo com um pouco mais de profundidade. Todo problema computacional é definível pelas entradas que assume e pelas saídas esperadas para cada entrada. Um algoritmo para um problema é um algoritmo que computa sempre a saída esperada para uma entrada de acordo com as especificações formais do problema. No caso da BUSCA, temos: • Problema: BUSCA. • Entrada: Uma estrutura de dados E endereçável e um dado x. • Saída: Um endereço em E onde se encontra x ou um endereço inválido (nulo), caso x não possa ser achado em toda a estrutura. Então, se x está em E, um algoritmo de busca deve sempre retornar um endereço onde podemos encontrar x. Note que esse endereço pode não ser único e que, portanto, dois algoritmos distintos podem trazer duas respostas distintas, desde que em ambos os endereços se encontre x. Mais que isso, se x não pode ser achado em E, um algoritmo de busca deve retornar um endereço inválido. No caso de E ser uma estrutura implementada com vetores, um endereço é meramente um índice (ou uma tupla de índices) para os vetores. No caso de ser uma estrutura implementada com encadeamentos de apontadores, um endereço é um apontador para um nodo da estrutura. Busca linear Vamos estudar agora o algoritmo mais trivial para o problema da BUSCA: o algoritmo da busca linear. A lógica é simples: varremos toda a estrutura linearmente até encontrarmos o elemento buscado. Por linearmente, entenda-se que começamos do primeiro endereço da estrutura, depois vamos para o segundo, depois para o terceiro etc. Apresentamos o algoritmo na implementação de uma função em C em que queremos encontrarmos um int x num vetor V de tamanho n. Perceba-se que o retorno também é um int, correspondente ao índice j de V onde encontramos x. Caso não encontremos x ao longo da estrutura, o endereço inválido que retornamos é n, já que V é preenchido só até n-. int busca_linear(int x, int* V, int n) { int j; for (j = ; j < n; j++) if (V[j] == x) return j; return n; } . Análise do algoritmo Vamos esboçar a análise do tempo do algoritmo da busca linear contando o número de comparações que ele faz. Para tanto, analisaremos três casos: o melhor, o pior e o médio. O melhor caso é aquele em que encontramos x logo na primeira posição do vetor. Neste caso, fazemos só uma comparação. O pior caso é aquele em que não encontramos x em V. Neste caso, varremos todas as posições, totalizando n comparações. O caso médio é o valor esperado para uma entrada aleatória, tomada sob distribuição uniforme de todas as entradas possíveis. Um palpite intuitivo seria n = Θ(n) comparações. Na verdade, fazendo-se as contas formalmente, verifica-se que no caso médio o algoritmo faz n− + − + ··· + n − = Θ(n) + − n n n n n n n comparações. Reunimos os resultados no quadro a seguir: Busca linear Melhor caso Caso médio Pior caso Número de comparações Θ() Θ(n) Θ(n) Perceba-se que assintoticamente o número de comparações do caso médio (o número que se espera para uma entrada tomada uniformemente) é o mesmo número de comparações do pior caso. Isso é até que bastante comum em Análise de Algoritmos. Embora no melhor caso o algoritmo tenha um desempenho formidável, não podemos esperar pelo melhor caso em geral. Via de regra, o algoritmo apresentado vai se comportar como no pior caso, retornando o endereço do elemento buscado depois de um número de comparações assintoticamente linear no número de elementos da estrutura. Se a estrutura for muito grande, como por exemplo o conjunto de todas as URLs da Internet, isso é terrível. Significa que não só no pior caso, mas também no caso médio (o caso esperado para uma entrada tomada uniformemente), vamos ter que esperar o algoritmo comparar a URL que buscamos com assintoticamente todas as URLs da estrutura. Veja que não necessariamente o número de comparações determina o tempo do algoritmo. Pode ser, por exemplo, que as comparações não sejam tão simples nem rápidas de serem executadas. No exemplo apresentado, cada comparação é uma mera operação de == em C, o que custa O() instruções do processador. Mas pode não ser assim. Pode ser que os dados da estrutura sejam registros gigantes, e que as comparações não sejam lá tão simples. Como tudo depende muito da aplicação e da implementação, limitaremos nossas análises apenas a contar os números de comparações, deixando em aberto a conclusão da análise de tempo dos algoritmos. Exercícios Exercício . Faça um programa que sorteia um vetor V de int com posições, sendo cada posição um número em [.. ]. Depois, sorteia um número x em [.. ] e utiliza o algoritmo da busca linear para buscar por x em V. Por fim, seu programa deverá imprimir o número de comparações realizadas na busca. Exercício . Faça um programa que sorteia um vetor V de int com posições, sendo cada posição um número em [.. ]. Depois, sorteia números x , x , . . . , x em [.. ] e utiliza o algoritmo da busca linear para buscar por cada xi em V. Por fim, seu programa deverá imprimir o número de comparações realizadas em cada uma das buscas realizadas e a média dos números de comparações. Execute seu programa mais de uma vez e perceba se a média se altera significativamente. Pense a respeito. Exercício . Implemente uma lista simplesmente encadeada de números int e o algoritmo da busca linear para buscar por um número na lista. Perceba que o retorno da função que implementa a busca deve ser um ponteiro para o nodo onde se encontrou o número buscado. Exercício . Faça um programa que lê um arquivo e armazena as linhas do arquivo num vetor de strings, devendo cada linha do arquivo ser uma posição no vetor. Os \n devem ser desconsiderados. Depois, leia da entrada padrão uma string e busque por ela, com o algoritmo da busca linear, no vetor. Exercício . matriz. Faça um programa que utiliza o algoritmo da busca linear para buscar por um float numa Métodos de busca (parte ) Notas de aula de maio de Ciência da Computação Estruturas de Dados I Prof. Leandro Zatesko Introdução Gertrudes está pensando num número em [..]. Mas antes de prosseguirmos com a história da Gertrudes, vamos antes esclarecer a notação [..] para os leitores mais desinformados. É bem conhecida a notação [, ], a qual denota o intervalo real fechado entre e . Pertencem a [, ] números como π, , e, ,, e . Analogamente, [..] denota o intervalo inteiro fechado, o que exclui da nossa lista os números π, e e ,. Voltemos aos pensamentos da Gertrudes. O número que ela mentaliza é o número , mas unicamente o leitor sabe disso. Seu amigo, Astrobaldo, só sabe que o número em que ela está pensando pertence ao intervalo [..]. Com segundas intenções, Astrobaldo tenta adivinhar o número através de chutes. Para cada chute que ele dá, Gertrudes responde: mais, menos ou acertou. As segundas intenções residem no fato de que Gertrudes prometeu beijos a Astrobaldo: menos o número de chutes que ele fizer para acertar a resposta, o que significa que quanto mais chutes Astrobaldo fizer, menos beijos vai ganhar. Se Astrobaldo utilizasse uma estratégia linear para seus chutes, com certeza demoraria muito para achar a resposta, pois começaria do número e só após haver feito chutes acertaria, ficando Gertrudes prometida a pagar apenas beijos, o que é muito pouco para as necessidades fisio-psicológicas de Astrobaldo. Mas é óbvio que Astrobaldo não utilizaria uma estratégia linear. Quem utilizaria? Só um algoritmo estúpido como o da busca linear o faria. O mais natural seria começar não por , mas por um outro número. Aliás, se são duas as possibilidades de resposta em caso de erro (mais ou menos) é natural começar pelo meio do intervalo, o número . Por quê? Porque se não for o número, independente de a resposta ser mais ou menos, Astrobaldo já descarta outros números. Bem que poderia chutar , por exemplo, para receber uma resposta mais e descartar não , mas números! O problema é que, chutando , ele também poderia ouvir um menos, e aí só conseguiria descartar números. O melhor mesmo é chutar o , com certeza. Assim procede Astrobaldo: • Chuta e recebe um mais. • Chuta o meio do intervalo [..], , e recebe um mais. • Chuta o meio do intervalo [..], , e recebe um menos. • Chuta o meio do intervalo [..], , e recebe um mais. • Chuta o meio do intervalo [..], , e recebe um mais. • Chuta o meio do intervalo [..], , e recebe um menos. • Chuta o meio do intervalo [..], , e recebe um acertou. Agora Astrobaldo está feliz da vida! Fez só chutes. Portanto, vai receber beijos e ir pra casa saciado. A estratégia de Astrobaldo segue exatamente a mesma lógica do algoritmo da busca binária, o qual apresentaremos hoje. Busca binária Na aula passada vimos um algoritmo bastante trivial para o problema da BUSCA: o algoritmo da busca linear. Vimos que, quando busca por um elemento x num vetor V [..n − ] (essa notação significa: um vetor V indexado por [..n − ]), começa a busca do índice e, caso não encontre x em V [], tenta o índice , depois o índice , e assim linearmente até o índice n − . Se, a cada tentativa fracassada de encontrar x em V [i], pudermos de algum modo saber se x não está em V antes de i ou depois de i, podemos descartar vários índices em [..]. Agora, note-se que naturalmente temos essa informação quando o vetor está ordenado. Dessarte, se procuramos pelo número em V [..] começando a busca por V [] e encontramos em V [] o número , podemos descartar todo o subintervalo de índices [..], pois todos os elementos de V [..] são no máximo e seguramente menores que . Assim, conseguimos restringir nosso vetor apenas ao subintervalo de índices [..], nas vias em que caminhamos no caso de Gertrudes e Astrobaldo, relatado na Introdução. Precisamos saber, então, dentro de um intervalo de índices [a..b], qual é o melhor índice para por ele começarmos. Já mostramos intuitivamente na Introdução que o melhor índice é o meio do intervalo, já que esse chute maximiza o tamanho do subintervalo descartado independentemente da resposta. O meio do intervalo [a..b], claro, é b(a+b)/c. Apresentamos, por fim, o algoritmo implementado numa função da linguagem C. Na implementação, os elementos do vetor são do tipo int, e o intervalo de índices começa com a = e b = n − . Mantemos a convenção da aula anterior segundo a qual n deve ser retornado caso x não possa ser encontrado no vetor V . int busca_binaria(int x, int* V, int n) { int a=, b=n-, meio; while (a <= b) { meio = (a+b) / ; if (x == V[meio]) return meio; if (x < V[meio]) b = meio - ; else a = meio + ; } return n; } Vamos simular o algoritmo para o caso em que buscamos por no vetor V [..] tal que: j V [j] • Começamos a busca com a = e b = . meio = . Como < V[] = , fazemos b = . • Agora a = e b = . meio = . Como > V[] = , fazemos a = . • Agora a = e b = . meio = . Como > V[] = , fazemos a = . • Agora a = e b = . meio = . Como = V[] = , retornamos . Vamos simular o algoritmo novamente para o caso em que buscamos por no mesmo vetor do exemplo anterior. • Começamos a busca com a = e b = . meio = . Como < V[] = , fazemos a = . • Agora a = e b = . meio = . Como < V[] = , fazemos b = . • Agora a = e b = . meio = . Como > V[] = , fazemos a = . • Agora a = e b = . meio = . Como < V[] = , fazemos b = . • Mas agora a = > = b, quebrando a condição do while, o que faz a função retornar . Esboço da análise do algoritmo Primeiramente, é importante frisar que esse algoritmo só funciona no caso de o vetor já estar ordenado. Não estando o vetor ordenado, não é uma boa ideia ordená-lo para depois aplicar o algoritmo da busca binária, já que ordenação custa, como veremos nas aulas seguintes, Ω(n log n) comparações, enquanto que o algoritmo da busca linear sozinho faz o serviço em O(n) comparações. Dediquemo-nos agora a esboçar a análise do algoritmo da busca binária. Ora, no melhor caso, o algoritmo encontra o elemento buscado logo no primeiro índice chutado, o que dá Θ() comparações, mesmo resultado do algoritmo da busca linear. O pior caso é aquele em que não é possível encontrar x em V . Neste caso, dividimos o intervalo ao meio tantas vezes quantas forem possíveis. Quantas vezes sucessivas é possível dividir o número n por , desprezando-se os restos? Vamos conjecturar algo a respeito: → → → → ∴ vezes → → → → ∴ vezes → → → → ∴ vezes → → → → → ∴ vezes → → → → → ∴ vezes → → → → → → ∴ vezes Percebemos que todos os números em [..] resultam em vezes. Todos os números em [..] resultam em vezes. Continuando as contas, percebemos também que todos os números em [..] resultam em vezes. Todos os números em [..] resultam em vezes etc. Ou seja, os saltos (incrementos) de vezes ocorrem sempre nas potências de . Percebemos também que lg = = − , lg = = − , lg = = − etc. Logo, quantas vezes sucessivas é possível dividir o número n por ? A resposta é blg nc + . Portanto, o número de comparações no pior caso do algoritmo da busca binária é Θ(log n). A saber, este é também o tempo do caso médio. Mas fazer esta análise está além dos pré-requisitos deste curso. Exercícios Exercício . Implemente a função busca_binaria recursivamente. Exercício . Refaça todos os exercícios da aula anterior implementando também a busca binária e comparando os resultados da busca binária com a busca linear. Atente agora para que as estruturas estejam sempre ordenadas.