parte 3
Transcrição
parte 3
1 PESQUISA EM MEMÓRIA PRIMÁRIA Olá! Iremos neste Capítulo tratar do assunto pesquisa. A palavra pesquisa aqui, tem o significado estrito de busca. Iremos buscar objetos de informação. Você certamente já pesquisou por muitas coisas na sua vida, brinquedos, roupas, chaves, livros e pessoas estão entre as “coisas” que mais procuramos! Se você já fez isso, já deve ter se aborrecido ao não encontrar (ou demorar muito para encontrar) o objeto ou pessoa que procura. Mas, ao demorar a encontrar um objeto, você já se perguntou se está procurando da maneira correta? Normalmente, é difícil encontrar algo em um conjunto de elementos se não usarmos a estratégia de busca apropriada para o modelo de organização do conjunto? Neste Capítulo iremos discutir e aprender sobre estas estratégias de busca e discutir aspectos da pesquisa de diferentes organizações do conjunto onde procuramos. Mão a obra então! A pesquisa em memória primária envolve a busca por objetos de informação armazenados em estruturas de dados instanciadas na memória primária do computador. Não iremos tratar aqui da pesquisa em memória secundária, que envolve outras técnicas. Nosso estudo vai compreender a pesquisa simples, onde sabemos exatamente qual o objeto que queremos. Normalmente sabemos qual é este objeto por meio de uma informação que o diferencia de todos os demais objetos. Chamaremos esta informação de chave de pesquisa. Chave de Pesquisa A pesquisa em memória primária será feita com base em uma CHAVE DE PESQUISA. A chave de pesquisa pode ser um elemento de dado do objeto. Por exemplo, o RG de uma pessoa, o CNPJ de uma empresa ou o número de matrícula de um estudante. Pode ser também o nome de um país, ou o ISBN de um livro. O importante da chave de pesquisa é que ela seja única, de forma a permitir que cada elemento do conjunto seja unicamente identificável pela sua chave de pesquisa. A idéia básica da pesquisa portanto é dado um conjunto de dados e uma chave, localizar um elemento neste conjunto cujo valor da chave de pesquisa seja o mesmo da chave usada. A chave de pesquisa é escolhida e definida pelo programador com base no problema específico que ele está tratando. Por exemple, em um conjunto de dados que cataloga os computadores de uma empresa, a chave de pesquisa pode ser o número de série do computador ou de seu patrimônio. O conjunto pode ser organizado e armazenado usando diferentes estruturas e tipos abstratos de dados: Vetores, listas encadeadas, árvores. Já estudamos no capítulo anterior o algoritmo de pesquisa em listas e em árvores binárias de pesquisa. Neste capítulo iremos analisar em maiores detalhes estes e outros algoritmos. Em uma pesquisa, o conceito de eficiência e muito importante. A eficiência pode ser medida pelo número de inspeções realizadas até encontrar o objeto procurado. Evidentemente, quanto menos inspeções forem feitas, mais eficiente será a pesquisa. Inspeção Uma inspeção consiste na operação de comparação da chave procurada com a chave de busca de um objeto, visando verificar se o objeto em questão é que procuramos. Iremos estudar algoritmos de pesquisa para as três seguintes situações: 1. Os dados estão dispostos de forma desordenada e desconhecida em uma lista linear (um vetor ou uma lista encadeada). Este cenário pode ser comparado a uma busca onde não há pistas sobre em que lugar o objeto está. Dessa forma, a única alternativa que temos é realizar uma busca exaustiva visitando todos os lugares até o objeto ser encontrado. A única forma de determinar se o objeto não está presente é inspecionar todos os lugares. 2. Os dados estão dispostos de forma ordenada em uma lista linear (um vetor ou uma lista encadeada). Nesse cenário, a busca é facilitada porque os objetos foram guardados usando alguma forma de ordenação baseada na chave de busca. Se os objetos forem armazenados, por exemplo, colocando os objetos em uma seqüência iniciando pelo menor e terminando pelo maior, a busca poderá ser feita sem a necessidade de inspecionar todos os objetos. 3. Os dados estão dispostos em de forma não ordenada, porém as posições onde os dados estão localizados podem ser calculadas usando a chave de pesquisa. Esta situação é a que acontece nas chamadas tabelas Hash onde os elementos são inseridos em posições calculadas a partir de sua chave de pesquisa. Iremos estudar a aplicação de três métodos de ordenação aplicáveis a estes cenários. Os métodos consistem em Pesquisa Seqüencial, Pesquisa Binária e Hashing. As seções a seguir detalham estes métodos. Suponha um baralho contendo 40 cartas todas diferentes, distribuídas de forma totalmente aleatória. Pense em uma carta específica e responda: a) Qual a propobilidade da primeira carta do baralho ser a que você pensou? b) Qual a propobilidade da segunda carta, ser a que você pensou? E da terceira? c) Qual a probabilidade de encontrar a carta após 4 tentativas? d) Em média quantas cartas terão que ser inspecionadas para você encontrar a carta que pensou? 1.1 PESQUISA SEQUENCIAL A pesquisa seqüencial é realizada inspecionando-se o conjunto de objeto seqüencialmente, do início para o fim ou vice-versa. Duas situações podem ocorrer: Pesquisa com sucesso: O elemento é encontrado em uma das posições do conjunto Pesquisa sem sucesso: O conjunto e completamente percorrido e o elemento não é encontrado. Em um algum de pesquisa, normalmente fazemos dois testes: verificamos se o elemento procurado corresponde ao objeto inspecionado e verificamos se o limite do conjunto de objetos foi atingido. Por exemplo, o código abaixo faz isso: // procurando o x no vetor V while ((V[i]!= x) && (i<limiteVetor)){ i++; } Para melhorar esta implementação, iremos apresentar um algoritmo de pesquisa seqüencial que utiliza uma sentinela. O uso de sentinelas torna a pesquisa seqüencial mais eficiente. Sentinela Sentinela é um valor conhecido que é inserido em uma posição específica de um arranjo ou de uma lista. O objetivo é usarmos o valor do sentinela para pararmos um processamento ou uma busca. Por exemplo, quando buscamos um valor x em um vetor que vai de 0 a n, e inserimos o valor x como sentinela na posição n+1. Assim, a busca pode ser feita até encontrarmos o valor do sentinela, sem a necessidade de testarmos se chegamos até o limite do arranjo. O código abaixo ilustra este cenário: // buscando x v[n+1] = x; // atribui o sentinela i=0; while (v[i]!=x) { i++; } 1.1.1 Implementação da Pesquisa Seqüencial Continuaremos a apresentar nossas implementações respeitando o conceito de TAD. Neste capitulo, iremos usar o TAD Tabela, que nada mais é conjunto do tipo Elemento (já visto em listas encadeadas). O tipo tabela é apresentado na Figura 4.1. #include"elemento.h" #define TRUE 1 #define FALSE 0 #define MAXTAB 1000 typedef struct { int n; Elemento itens[MAXTAB+1]; } Tabela; typedef Tabela *pTabela; int inicializaTabela(pTabela); int tabelaCheia(pTabela); int insereElemento(pTabela, Elemento); int pesquisaSeqElemento(pTabela, int); Figura 4.1: Interface do TAD Tabela As implementações das operações inicializaTabela, tabelaCheia e insereElemento são apresentadas na Figura 4.2. A Figura 4.3 apresenta a implementação da operação de pesquisaSeqElemento. Esta operações possui como parâmetro a tabela onde pesquisar e o valor da chave utilizada para a pesquisa. A função retorna -1 em caso de pesquisa sem sucesso e o índice da posição do elemento procurado, em caso de pesquisa sem sucesso. Note que foi usado o sentinela (inserido na posição n+1 da tabela). #include "tabela.h" /* inicializa a tabela atribuindo 0 ao contador de elementos */ int inicializaTabela(pTabela t) { t->n = 0; return TRUE; } /*função que verifica se a tabela está cheia * retorna 1 se cheia e 0 se vazia*/ int tabelaCheia(pTabela t){ return (t->n == MAXTAB); } /* Função que insere um elemento na tabela * */ int insereElemento(pTabela t, Elemento e){ if (tabelaCheia(t)){ return FALSE; } t->itens[t->n]=e; t->n++; return TRUE; } Figura 4.2: Implementação das operações do TAD Tabela /** * Pesquisa o elemento usandoa um sentinela. * O sentila é colocada na * primeira possição vaga da tabela */ int pesquisaSequencial(pTabela t, int chave){ int i; t->itens[t->n+1].codigo = chave; i=0; while(t->itens[i].codigo!=chave) { i++; } if(i>t->n) { return -1; } return i; } Figura 4.3: Implementação da operação pesquisaSequencial 1.1.2 Tempo de execução de algoritmos Os métodos de pesquisa são um bom tópico em nosso curso para introduzirmos algumas noções sobre desempenho de algoritmos. Para iniciar esta discussão, vamos nos reportar a uma pergunta? É possível determinar quanto tempo a operação de pesquisa seqüencial leva para encontrar um elemento? Existem importantes elementos estão envolvidos nesta pergunta. A saber: 1. A Máquina que será usada para executar o programa. 2. O tamanho total dos dados Evidentemente, o tempo de uma pesquisa vai mudar dependendo do computador que for utilizado. Quanto mais rápido o computador, mais rápida será a pesquisa. A mudança de desempenho de uma computador para outro pode ser expresso por uma constante C. Assim diremos que o tempo gasto por um programa para executar pode ser expresso por uma função T Æ f(n).C Onde C é a constante associada ao computador. n, por sua vez é o tamanho da entrada de Dados. Ou seja, o tempo de execução da função de pesquisa é uma função do tamanho da entrada de dados. Função de complexidade de tempo Quando analisamos o tempo de execução é comum deixarmos a constante C de lado e nos concentramos na função f(n). Esta função é importante pois ela nos dá uma medida de tempo de execução independente do computador que será utilizado para executar a operação. A função f(n) é denominada de função de complexidade de tempo. Um outro aspecto importante na análise de desempenho de algoritmos são os diferentes casos ou cenários de execução possíveis. São geralmente considerados os seguintes casos: • Melhor Caso: compreende a melhor situação para o algoritmo. A situação em que a resposta será obtida de forma mais rápida. • Caso Médio: Compreende a média de todos os casos considerando as distribuições de probabilidade típicas para os dados em questão. • Pior Caso: Compreende a pior situação para o algoritmo em questão. Podemos determinar a função de complexidade de tempo para estes três casos para o algoritmo de pesquisa seqüencial: Melhor caso: f(n) = 1, ocorre quando o elemento procurado é o primeiro a ser inspecionado. Pior caso: f(n) = n, ocorre quando o elemento procurado é o último elemento do conjunto a ser inspecionado. Caso médio: f(n) = (n+1)/2 O caso médio é obtido da seguinte forma: seja x o elemento procurado e C = {c1,c2,c3..cn}, o conjunto de elementos a ser pesquisado. Considera-se para este cálculo que a possibilidade de encontrar o elemento procurado é igualmente provável para qualquer posição do conjunto de elementos pesquisado. Ou seja, P(x=c1))= P(x=c2)= .. P(x=cn)=1/n Temos ainda que para encontrar o elemento procurado na i-ésimo posição do conjunto são necessárias i inspeções. Por exemplo, para encontrar o elemento na primeira posição é necessário uma comparação. Para encontrar o elemento na segunda posição são necessárias duas comparações. E assim por diante. Sendo assim, temos que f(n) = 1 * P(x=c1) +2 * P(x=c2)+3* P(x=c3) + .. + n* P(x=cn) f(n) = 1 * 1/n + 2*1/n+3*1/n +.. n*1/n f(n) = (1+2+3+..n) *1/n f(n) = n*(n+1)/2 *1/n f(n) = (n+1)/2 Tarefa 4.2 1. Desenvolva um programa utilizando o TAD Tabela que meça o tempo de execução da pesquisa seqüencial. Altere o código da tabela para aceitar um conjunto de até 100 000 itens. Pesquise por alternativas para medir o tempo de execução de um programa. Planeje sua estratégia para medir o tempo. 2. No estudo da pesquisa seqüencial, a busca não leva em conta a organização dos dados no conjunto. Caso os dados se encontrem ordenados, por exemplo, em ordem crescente, a pesquisa seqüencial pode tirar partido desta ordenação interrompendo a busca ao encontrar um elemento maior que o que foi procurado. Implemente um nova operação no TAD Tabela que realize esta pesquisa considerando que os dados estão ordenados em ordem crescente. Para testar esta operação os dados devem ser inseridos ordenadamente na tabela. 1.2 PESQUISA BINÁRIA Caso o conjunto de dados de pesquisa esteja ordenado, podemos utilizar a pesquisa binária para encontrar elementos no conjunto. A Pesquisa binária consistem em uma técnica do tipo Dividir para Conquistar (Divide to Conquer), na qual o conjunto de dados pesquisado é sistematicamente reduzido à metade a cada inspeção realizada. Figura 4.4: Divisão dos dados na Pesquisa Binária A Figura 4.4 representa o método de divisão dos dados que ocorre na pesquisa binária. O quadro inteiro representa o conjunto completo dos dados. As inspeções são feitas no elemento central dos dados. Se o elemento inspecionado é menor que procurado, o algoritmo descarta todos os elementos que são maiores que o elemento inspecionado. Ou seja, a metade dos dados é descartada (parte amarela). O algoritmo segue o mesmo procedimento agora inspecionando o elemento central dos elementos que sobraram, até encontrar o elemento ou não restarem mais elementos a serem inspecionados. Utilizando este método, a pesquisa binária consegue localizar o elemento desejado com desempenho muito melhor que a pesquisa seqüencial. As divisões sucessivas levam a localização de elemento em tempo logarítmico, conforme veremos a seguir. Vejamos agora a pesquisa binária usando como exemplo um vetor ordenado de elementos a seguir: 0 1 2 3 4 A D E F G 5 6 J 7 8 M N O Suponha que o elemento procurado seja o ‘D’. Suponha também que os limites inferior e superior do vetor seja denotados pelas variáveis inicio e fim. No começo da pesquisa inicio = 0 e fim = 8 A pesquisa é iniciada inspecionando o elemento central do Vetor. Este elemento determinado pela média entre inicio e fim: meio = (inicio+fim)/2 = 4 Para primeira inspeção meio = 4; Como ‘D’ é menor que ‘G’, toda a metade superior do vetor é descartada. No algoritmo este descarte é feito recalculando o valor de fim: fim = meio-1 = 3 O valor de meio é recalculado agora para determinar o meio da metade inferior: meio = (inicio+fim)/2 = 1 Agora, com meio = 1, o item procurado é encontrado. O algoritmo completo da pesquisa binária é mostrado na implementação da operação pesquisaBinária para o TAD Tabela (Figura 4.5). int pesquisaBinaria( pTabela t, int chave) { int inicio,fim,meio; inicio=0; fim=t->n-1; while (inicio<=fim) { meio=(inicio+fim)/2; if (chave ==t->itens[meio].codigo){ return meio; } else if (chave < t->itens[meio].codigo){ fim=meio-1; } else inicio=meio+1; } return -1; /* não encontrado */ } Figura 4.5: Implementação da operação pesquisaBinária Conforme já mencionado, a pesquisa binária apresenta comportamento logarítmico. A Função de complexidade de tempo é dada por: f(n) = log2(n) Para ilustrar o que significa esta eficiência logarítmica, suponha que você esteja procurando registros em uma tabela com 1.000.000 de registros. Se você utiliza a pesquisa seqüencial, vai levar em média (n+1)/2 = ~ 500.000 inspeções para cada pesquisa. Utilizando a pesquisa binária, cada pesquisa vai ter um custo de log2 1.000.000 = ~20. Ou seja, usando pesquisa seqüencial realiza-se em media 500.000 inspeções para encontrar um elemento; ao passo que com a pesquisa binária serão necessárias, em média, apenas 20 (vinte) inspeções! Atividade 4.3 1. Desenvolva um programa utilizando o TAD Tabela que meça o tempo de execução da pesquisa seqüencial. Altere o código da tabela para aceitar um conjunto de até 100 000 itens. Pesquise por alternativas para medir o tempo de execução de um programa. Planeje sua estratégia para medir o tempo. 2. Implemente uma versão recursiva da pesquisa binária. 1.3 TABELAS HASH Até o momento, discutimos métodos de pesquisa baseados na comparação explícita da chave de busca. Neste métodos, se pesquisamos algum objeto com chave de pesquisa igual a X, procuramos até encontrar o objeto com o valor de identificador igual a X. Nesta seção iremos estudar uma abordagem completamente diferente. Trata-se das tabelas Hash, método também conhecido como espalhamento. Embora diferente, o método de espalhamento é bastante simples e intuitivo. A fixação dos conceitos de endereço e função hash vão facilitar o entendimento do método. Tabela Hash: O método de espalhamento permite o armazenamento e recuperação de dados em estruturas onde cada posição possui um índice. Por exemplo um vetor. Chamaremos estas estruturas simplesmente de tabela hash. Endereço: Corresponde ao valor de um índice na tabela. Os endereços irão variar de 0 até N-1, onde N é o tamanho máximo da tabela hash. Função hash: é uma função aritmética que tem como entrada uma chave e que calcula um valor inteiro de um endereço válido na tabela Hash. A função a seguir é um exemplo de função hash. int h (char * chave){ int tam, i,soma=0; tam = strlen(chave); for(i=0;i<tam;i++) { soma+=abs(chave[i]); } return soma%N; } Esta função recebe uma string como parâmetro e retorna valores entre 0 e N-1. A função abs é da biblioteca da linguagem C e retorna o valor inteiro correspondente ao código ASCII de um caractere. Para determinar o valor ser retornado, a função faz um cálculo aritmético baseado no código ASCII de cada caractere que forma a string. O valor calculado é o resto entre a soma obtida e N, que nos dá um valor entre 0 e N-1. Quando um registro vai ser inserido na tabela, a posição de inserção é determinada pela função Hash, tomando como entrada a chave de pesquisa utilizada. Normalmente, as chaves de pesquisa usadas em tabelas Hash são valores alfanuméricos (strings), por exemplo, nomes de pessoas. Um função Hash pode ser qualquer função aritmética que gere valores de endereços a partir da chave de pesquisa. No entanto, existem funções Hash melhores que as outras. As seguintes características devem ser buscadas na definição de uma função Hash: • Ser de computação simples e de baixo custo; • Gerar valores de endereço de forma uniforme, de maneira a tentar distribuir os dados uniformemente na tabela. Ou seja, a probabilidade de geração dos endereços entre 0 e N-1, pela função Hash deve é a mesma. Para o método re espalhamento a chave de pesquisa deve ser única. Conforme Figura 4.6, a função hash h calcula um endereço com base no valor da chave k. Se esta chave não for única para os registros a serem armazenados, o mesmo valor de endereço será fatalmente calculado para dois registros diferentes. O princípio básico do método de espalhamento é a geração de endereços dentro da tabela por meio da função Hash. Esta geração ocorre nas seguintes situações: (1) Inserção de um registro: Para inserir um registro, a chave (que deve se única) é usada para calcular o endereço de armazenamento do registro. (2) Recuperação de um registro: Para recuperar um registro armazenado, com base em um valor chave de pesquisa. Neste caso a chave informada é usada para determinar o possível endereço de armazenamento do registro procurado. Figura 4.6: A função hash determina o índice com base na chave de pesquisa. Um problema que fatalmente ocorre em funções Hash é o cálculo de um mesmo endereço para dois valores de chave diferentes. Este problema é chamado de colisão. Colisão A colisão em tabelas hash ocorre quando o mesmo endereço na tabela é calculado para diferentes valores de chave. Por exemplo, a função Hash int h (char * chave){ int tam, i,soma=0; tam = strlen(chave); for(i=0;i<tam;i++) { soma+=abs(chave[i]); } return soma%N; } Retorna o mesmo valor para strings contendo os mesmos caracteres. Por exemplo, as chaves “maria” e “maira”, iriam produzir um mesmo valor embora sejam valores diferentes. A colisão não pode ser evitada, mas deve ser tratada. A seguir veremos como tratar colisões em implementações de tabelas Hash 1.3.1 Operações de Inserção e Pesquisa em Tabelas Hash Veremos como é feita a inserção e a pesquisa em uma tabela Hash. Veremos primeiro uma versão sem tratamento de colisão. Implementaremos estas funções como operações do TAD tabelaHash. #include"elemento.h" #define TRUE 1 #define FALSE 0 #define MAXTAB 1000 typedef struct { int n; Elemento itens[MAXTAB+1]; } Tabela; typedef Tabela *pTabela; int inicializaTabela(pTabela); int insereElemento(pTabela, Elemento); int pesquisaElemento(pTabela, char *); Figura 4.7: TAD TabelaHash sem tratamento de colisão A Figura 4.7 apresenta o TAD TabelaHash sem tratamento de colisão. A tabela utilizada consiste de um vetor de registros do tipo Elemento. Apenas lembrando, o tipo Elemento possui os seguintes campos: char nome[20]; int codigo; char telefone[10]; O campo nome será usado como chave de pesquisa. Assim, fica convencionado que os registros armazenados na tabela não poderão ter o mesmo nome. Usaremos o campo codigo para determinar se um endereço da tabela está ocupado ou não. Para tanto, na inicialização da tabela, atribuiremos o valor -1 ao código. O valor -1 no campo código indicará que o endereço está desocupado. O código da operação inicializaTabela é mostrado na Figura 4.8. Conforme pode ser observado, a tabela possui também um contador n que mantém o número de elementos da tabela. Implementamos também uma função lógica chamada de enderecoOcupado, que retorna TRUE se um endereço (end) estiver ocupado e FALSE, caso contrário. Esta função é apresentada na Figura 4.8. int inicializaTabela(pTabela t) { int register i; t->n = 0; for(i=0;i<MAXTAB;i++) { t->itens[i].codigo=-1; } return TRUE; } int enderecoOcupado(pTabela t, int end){ if (t->itens[end].codigo==-1) { return TRUE; } return FALSE; } Figura 4.8: Inicialização da Tabela Hash e função enderecoOcupado. A função Hash utilizada é baseada na função h, já apresentada. No entanto, para calcular o valor da soma, cada caractere foi multiplicado pela sua posição (i+1), evitando a colisão de nomes com mesmo conjunto de caracteres. A Figura 4.9 apresenta esta implementação. /* função hash */ int h (char * chave){ int tam, i,soma=0; tam = strlen(chave); for(i=0;i<tam;i++) { soma+=abs(chave[i])*(i+1); } return soma%MAXTAB; } Figura 4.9: implementação da função hash A inserção de elementos na tabela segue o seguinte algoritmo: insere na tabela: calcula endereço com base na chave do elemento a ser inserido se endereço estiver ocupado então imprima("ocorreu uma colisão.") retorna FALSE fim se senão insere elemento no endereço calculado fim senão A implementação da operação de inserção é mostrada na Figura 4.10. int insereElemento(pTabela t, Elemento e){ int end; end = h(e.nome); if (enderecoOcupado(t,end)) { printf("Ocorreu uma colisão.\n"); return FALSE; } t->itens[end]=e; t->n++; return TRUE; } Figura 4.10: Implementação da operação insereElemento. Note que, se o endereço calculado já estiver ocupado, ocorre uma colisão que não é tratada. Um aviso de colisão é enviado ao usuário e a inserção do novo elemento simplesmente não ocorre. Conforme já mencionado, colisões em tabelas Hash são inevitáveis, mesmo para uma tabela 10 vezes maior que o número de registros a ser armazenado, a probabilidade de colisões é ainda muito grande. Este comportamento é ilustrado pelo paradoxo do aniversário. Paradoxo do aniversário: Se tomarmos um grupo de mais de 25 pessoas de forma totalmente aleatório, a probabilidade de que haja entre estas pessoas pelo menos duas que fazem aniversário na mesma data do ano é maior que 50%. Esta medida é curiosamente verdadeira, considerando-se que a data de aniversário de uma pessoa é uma variável aleatória, estas datas deveriam ser uniformemente distribuídas entre os dias do ano. O paradoxo do aniversário está associado com tabelas Hash. Considere uma tabela Hash com 365 endereços disponíveis. Cada endereço corresponde a um dia do ano. Ao inserirmos 25 registros nesta tabela, usando uma função h totalmente aleatória, a chance de haver uma colisão é de 50%. Na pesquisa por um elemento, um valor de chave deve ser informado. Com base neste valor o endereço é calculado. Se o endereço estiver ocupado, o valor do campo nome do elemento é comparado com a chave de pesquisa para verificar se o elemento que lá está é realmente o elemento procurado. Neste caso o valor de end é retornado. Caso contrário o elemento procurado não existe na tabela. A operação de pesquisa é apresentada na Figura 4.11. int pesquisa(pTabela t, char *chave){ int end; end = h(chave); if (enderecoOcupado(t,end)) { if(strcmp(t->itens[end].nome,chave)==0) { return end; } } return -1; } Figura 4.11: Implementação da operação pesquisa. 1.3.2 Tratamento de Colisões Na implementação que realizamos até agora, quando uma colisão ocorre, o dado simplesmente não é inserido. Evidentemente este não é o comportamento esperado de nenhum sistema de armazenamento de informações. Assim, ao invés de simplesmente abortar a inserção iremos tratar o evento da colisão permitindo a inserção de vários elementos com chaves diferentes que mapeiam para o mesmo endereço. Conforme observado na TAD TabelaHash apresentado, na inserção, quando o endereço vazio é obtido, o elemento é inserido no mesmo. A tabela itens é um vetor de Elemento e cada endereço se refere a apenas um elemento. Veremos duas formas de tratar a colisão: (1) Com listas encadeadas (2) Com endereçamento aberto No tratamento de colisões com listas encadeadas, a estrutura da tabela e alterada. Ao invés de uma tabela de registros é definida uma tabela de listas encadeadas, onde cada lista encadeada será o conjunto de registros inseridos cujas chaves de pesquisa foram mapeadas para o mesmo endereço. A Figura 4.12 ilustra esta abordagem. Figura 4.12: Tratamento de Colisão com Listas Encadeadas A Tabela é formada de um vetor de ponteiros para N listas encadeadas. Agora, na inserção, o endereço end é calculado e o elemento é inserido na lista encadeada apontada pelo ponteiro da posição end. A Figura 4.13 apresenta a implementação da interface do TAD tabelaHash com com tratamento de colisão por meio de lista encadeada. Este TAD utiliza como cliente o tadLista implementado no Capítulo 3. A única modificação neste Tad foi na operação pesquisa que agora utiliza o campo nome do registro Elemento como chave e recebe a string chave como parâmetro para realizar a pesquisa. O TadLista é reapresentado na Figura 4.14. #include "tadLista.h" #define TRUE 1 #define FALSE 0 #define MAXTAB 1000 typedef struct { int n; Lista itens[MAXTAB+1]; } Tabela; typedef Tabela *pTabela; int inicializaTabela(pTabela); int insereElemento(pTabela, Elemento); Elemento pesquisaElemento(pTabela, char *); Tabela: 4.13: Interface do TAD Tabela Hash /* tadLista.h */ #include"elemento.h" typedef struct nodo{ Elemento e; struct nodo* prox; } *Lista; void inicializaLista(Lista *); void insereNaLista(Lista *, Elemento ); Elemento pesquisaNaLista(Lista , char *chave); Elemento removeDaLista(Lista *, int ); Figura 4.14: Interface do TAD Lista Encadeada A Figura 4.15 apresenta a implementação das operações do TAD TabelaHash. int inicializaTabela(pTabela t) { int register i; t->n = 0; /* Inicializa todas as listas contidas na tabela */ for(i=0;i<MAXTAB;i++) { inicializaLista(&t->itens[i]); } return TRUE; } int insereElemento(pTabela t, Elemento e){ int end; end = h(e.nome); insereNaLista(&(t->itens[end]),e); t->n++; return TRUE; } Elemento pesquisaElemento(pTabela t, char *chave){ int end; end = h(chave); return pesquisaNaLista(t->itens[end],chave); } Figura 4.15: operações inicializaTabela, insereElemento e pesquisaElemento 1.3.3 Tratamento de Colisão usando endereçamento Aberto Outra abordagem para tratar as colisões é o uso de endereçamento aberto (open addressing) . Nesta abordagem, a estrutura de dados da tabela é a mesma originalmente estabelecida (um vetor de registros). A técnica de endereçamento aberto consiste nos seguintes procedimentos: Inserção: (1) Calcular o endereço usando a função Hash para o valor de chave do registro (2) Se o endereço estiver ocupado, procurar nas posições vizinhas ao endereço calculado uma posição vazia. Pesquisa: (1) Calcular o endereço usando a função Hash e a chave de pesquisa fornecida (2) Procurar, a partir do endereço até: a. Encontrar o elemento procurado (pesquisa com sucesso) b. Encontrar uma posição vazia ou o fim da tabela (pesquisa sem sucesso) Atividade Modificar a implementação inicial de Hash (sem tratamento de colisão) para suportar colisões com a técnica de endereçamento aberto. 2 ORDENAÇÃO EM MEMÓRIA PRIMÁRIA Olá. Neste capítulo chegaremos ao fim de nosso estudo na disciplina de TPA. Reservei para a última etapa o tema Ordenação. A ordenação em computação reúne um conjunto de algoritmos que permite ordenar um conjunto de dados segundo uma determinada chave de ordenação. Neste capítulo iremos estudar alguns desses principais algoritmos. Se você vasculhar a Internet vai encontrar a maioria destes algoritmos implementados. Assim você poderá se perguntar para que estudamos estes mecanismos se as suas implementações já estão prontas? E mais, porque estudar vários métodos de ordenação se existe um particularmente que é mais rápido que os outros? A resposta é simples: Porque estes algoritmos e suas variações reúnem técnicas de programação que são de muita valia na resolução de outros problemas, alem de tocarem em aspectos complexos que, ao serem absorvidos pelo estudante são capazes de aprofundar sua capacidade de raciocínio e habilidades em desenvolver programas. Vamos a ele então. 2.1 CONCEITOS BÁSICOS DE ORDENAÇÃO A ordenação consiste em rearranjar um conjunto de objetos em ordem crescente ou decrescente, levando-se em conta uma chave ordenação. A ordenação possui motivações óbvias. Em geral, colocamos dados de forma ordenada para: • Facilitar a obtenção de uma informação ao se observar um conjunto de dados. Por exemplo, a visualização da classificação de candidatos em um concurso fica mais fácil se a lista de candidatos estiver ordenada pelo número de pontos que fizeram. • Facilitar a recuperação de uma informação . Por exemplo: obter os pontos que um candidato em um concurso fez, sabendo o seu nome, fica mais fácil se a lista de candidatos estiver ordenada por nome. A ordenação pode ser feita considerando diferentes tipos de dados para a chave, inteiros, reais, strings. O que importa é estabelecer as comparações nos algoritmos de acordo com o tipo de dado da chave de ordenação. Na ordenação de um arranjo de registros, com a tabela de elementos usada no capítulo anterior, apenas a chave de ordenação é usada. Os demais campos do registro são irrelevantes. 2.1.1 Operações Típicas de processos de Ordenação Nos diferentes métodos de ordenação, algumas operações ocorrem com mais freqüência e o desempenho dos métodos está geralmente associado com a realização dessas operações. As duas operações que mais afetam o desempenho dos algoritmos de ordenação são as operações de troca e de comparação: • Troca: Consiste na troca de valores entre duas posições do arranjo que está sendo ordenado. Esta operação tem custo elevado e é a que mais afeta o desempenho dos algoritmos. A operação de troca geralmente é suportada por uma função. A Figura 5.1 apresenta uma implementação da função troca • Comparação: consiste nas comparações entre dois valores contidos em posições do arranjo com o objetivo de saber qual dentre as duas é menor. Embora de menor custo, esta operação também afeta bastante o desempenho dos algoritmos devido ao fato de serem feitas muitas comparações nos processos de ordenação. void troca(int *x, int *y){ int aux; aux = *x; *x=*y; *y=aux; } Figura 5.1: Implementação da função troca 2.2 MÉTODOS DE ORDENAÇÃO Iremos estudar 5 métodos de ordenação diferentes neste capítulo. A saber: (1) (2) (3) (4) (5) Seleção Inserção Bolha Shell Quicksort Dependendo da aplicação e situação, o uso de um método pode ser mais conveniente que o outro. Em geral, o quicksort apresenta-se com melhor desempenho na maioria das situações. O Método da bolha é o menos eficiente. Porem a sua simplicidade o torna conveniente para ordenar pequenos vetores (com tamanho menor que 20). A seguir discutimos cada um destes algoritmos individualmente. 2.2.1 Ordenação por Seleção Este método tem como estratégia selecionar o menor elemento remanescente do conjunto não ordenado e mover este elemento para sua posição correta. Por exemplo, suponha um arranjo com 4 elementos. O processo de ordenação por seleção determina quem é o menor elemento e o insere na primeira posição do vetor. Para tanto, o elemento que estava na posição inicial precisa ser colocado na posição onde estava o menor. Em seguida o segundo menor é encontrado e inserido na segunda posição do arranjo. O Algoritmo segue assim sucessivamente até encontrar o último elemento e inseri-lo na última posição. A Figura 5.2 apresenta a implementação do algoritmo de seleção. Implementação: void selecao (int a[], int n){ int register i,j; int min; for(i=0;i<n-1;i++) { min =i; for ( j=i+1;j<n;j++) { if (a[j] <a[min]) { min =j; } } troca(&a[min],&a[i]); imprimevetor(a,n); } } Figura 5.2: Implementação da Ordenação por Seleção Tomando como exemplo o arranjo int B[] = {10,3,7,20,1,2,11,0,5,4}; a execução da função seleção irá imprimir a seguinte sequencia de valores intermediários para o arranjo B até a sua ordenação completa: 10 0 0 0 0 0 0 0 0 0 3 3 1 1 1 1 1 1 1 1 7 7 7 2 2 2 2 2 2 2 20 1 20 1 20 3 20 3 3 20 3 4 3 4 3 4 3 4 3 4 2 2 2 7 7 7 5 5 5 5 11 11 11 11 11 11 11 7 7 7 0 5 4 10 5 4 (fim 10 5 4 (fim 10 5 4 (fim 10 5 4 (fim 10 5 20 (fim 10 7 20 (fim 10 11 20 (fim 10 11 20 (fim 10 11 20 (fim da da da da da da da da da interação interação interação interação interação interação interação interação interação i=0) i=1) i=2) i=3) i=4) i=5) i=6) i=7) i=8) O algoritmo de seleção utiliza o índice i como limite inferior da parte do arranjo que ainda não está ordenado (de i até n-1). A medida que o arranjo vai sendo ordenado este limite vai avançando (laço mais externo). O laço mais interno tem como objetivo encontrar o menor elemento dentre os elementos que ainda não foram ordenados. min é iniciado sempre com o valor de i. Em seguida o laço mais interno (do j) se encarrega de verificar se no restante do vetor (posições de i+1 até n-1) existe um valor menor que a[min]. Toda vez que isso ocorre, o valor de min é atualizado. Ao final de um laço mais interno, a variável min vai conter o índice do menor valor no arranjo dentre os elementos que não foram ainda ordenados. Conforme pode ser visto na saída do programa, na primeira interação de i, i=0, o menor elemento contido no vetor é localizado e inserido na posição 0. Esta inserção é feita pela operação de troca que ocorre ao final de cada iteração do laço mais externo. Note que as trocas ocorrem independentemente de haver sido encontrado um valor menor que a[i]. Neste caso haverá uma troca entre duas posições idênticas pois o valor de min vai ser igual a i. Isso ocorreu nas iterações 6,7 e 8. Caso seja importante melhorar o desempenho do algoritmo esta troca entre posições iguais pode ser evitada comparando-se o valor de min com i. Se estes forem iguais a troca não precisa ser feita. 2.2.2 Método da Inserção Este método é similar ao que o jogador de cartas utiliza: Cada elemento a ser ordenado é reposicionado entre os ordenados movendo-se os elementos maiores que ele uma posição para a direita e posteriormente inserindo-o na posição vaga. A Figura 5.3 apresenta a implementação do algoritmo de Inserção. void insercao(int a[], int n) { int i,j,x; for(i=1;i<n;i++) { x=a[i]; j=i-1; while((j>=0) && (a[j]>x)) { a[j+1]=a[j]; j = j-1; imprimevetor(a,n); } a[j+1]=x; imprimevetor(a,n); } } Figura 5.3: Implementação do método de ordenação por Inserção A função imprimevetor é chamada a cada iteração do laço while (mais interno) e ao fim de cada laço mais externo também . A execução desta função sobre o arranjo int B[] = {10,3,7,20,1,2,11,0,5,4}; produz a seguinte saída: 10 3 7 20 1 2 11 0 5 4 10 10 7 20 1 2 11 0 5 4 3 10 7 20 1 2 11 0 5 4 (fim 3 10 10 20 1 2 11 0 5 4 3 7 10 20 1 2 11 0 5 4 (fim 3 7 10 20 1 2 11 0 5 4 (fim 3 7 10 20 20 2 11 0 5 4 3 7 10 10 20 2 11 0 5 4 3 7 7 10 20 2 11 0 5 4 3 3 7 10 20 2 11 0 5 4 1 3 7 10 20 2 11 0 5 4 (fim 1 3 7 10 20 20 11 0 5 4 1 3 7 10 10 20 11 0 5 4 1 3 7 7 10 20 11 0 5 4 1 3 3 7 10 20 11 0 5 4 1 2 3 7 10 20 11 0 5 4 (fim 1 2 3 7 10 20 20 0 5 4 1 2 3 7 10 11 20 0 5 4 (fim 1 2 3 7 10 11 20 20 5 4 1 2 3 7 10 11 11 20 5 4 1 2 3 7 10 10 11 20 5 4 1 2 3 7 7 10 11 20 5 4 1 2 3 3 7 10 11 20 5 4 1 2 2 3 7 10 11 20 5 4 1 1 2 3 7 10 11 20 5 4 0 1 2 3 7 10 11 20 5 4 (fim 0 1 2 3 7 10 11 20 20 4 0 1 2 3 7 10 11 11 20 4 0 1 2 3 7 10 10 11 20 4 0 1 2 3 7 7 10 11 20 4 0 1 2 3 5 7 10 11 20 4 (fim 0 1 2 3 5 7 10 11 20 20 0 1 2 3 5 7 10 11 11 20 0 1 2 3 5 7 10 10 11 20 0 1 2 3 5 7 7 10 11 20 0 1 2 3 5 5 7 10 11 20 0 1 2 3 4 5 7 10 11 20 (fim da iteração i=1) da iteração i=2) da iteração i=3) da iteração i=4) da iteração i=5) da iteração i=6) da iteração i=7) da iteração i=8) da iteração i=9) Na execução da função o laço mais externo é usado para delimitar os elementos do arranjo ainda não inspecionados. A variável x é usada para guardar o valor do elemento a ser inserido (a[i]). Na primeira iteração este elemento é o 3. Na segunda é o 7. Na terceira é o 10 e assim por diante. Nem sempre o elemento em x será substituído. Por exemplo, na iteração i=3, o elemento x é igual a 10 e não terá a sua posição alterada. O laço while tem a finalidade de percorrer o arranjo nas posições entre (i-1 e 0) procurando valores menores que x. À medida que estes valores são encontrados eles vão sendo deslocados para a direita. O laço while termina quando é encontrado um elemento menor que x ou o limite do vetor é atingido. 2.2.3 Método da Bolha O Método da Bolha consiste em percorrer o vetor trocando os elementos adjacentes caso necessário. Por este motivo, este método realiza muitas trocas. A implementação deste método é apresentada na Figura 5.4. void bolha ( int a[],int n){ int register i,j; for(i=n-1;i>0;i--) { for(j=1;j<=i;j++) { if(a[j-1]>a[j]){ troca(&a[j-1],&a[j]); imprimevetor(a,n); } } } } Figura 5.4: Método da Bolha A execução do método da bolha sobre o vetor int B[] = {10,3,7,20,1,2,11,0,5,4}; produz a seguinte saída: 10 3 7 20 1 2 11 0 5 4 3 10 7 20 1 2 11 0 5 4 3 7 10 20 1 2 11 0 5 4 3 7 10 1 20 2 11 0 5 4 3 7 10 1 2 20 11 0 5 4 3 7 10 1 2 11 20 0 5 4 3 7 10 1 2 11 0 20 5 4 3 7 10 1 2 11 0 5 20 4 3 7 10 1 2 11 0 5 4 20 3 7 1 10 2 11 0 5 4 20 3 7 1 2 10 11 0 5 4 20 3 7 1 2 10 0 11 5 4 20 3 7 1 2 10 0 5 11 4 20 3 7 1 2 10 0 5 4 11 20 3 1 7 2 10 0 5 4 11 20 3 1 2 7 10 0 5 4 11 20 3 1 2 7 0 10 5 4 11 20 3 1 2 7 0 5 10 4 11 20 3 1 2 7 0 5 4 10 11 20 1 3 2 7 0 5 4 10 11 20 1 2 3 7 0 5 4 10 11 20 1 2 3 0 7 5 4 10 11 20 1 2 3 0 5 7 4 10 11 20 1 2 3 0 5 4 7 10 11 20 1 2 0 3 5 4 7 10 11 20 (troca (troca (troca (troca (troca (troca 10 10 20 20 20 20 e e e e e e 3) 7) 1) 2) 11) 0) 1 1 0 2 0 1 0 2 2 3 3 3 4 4 4 5 5 5 7 10 11 20 7 10 11 20 7 10 11 20 2.2.4 Desempenho dos métodos de Seleção, Inserção e Bolha Para a análise de desempenho de métodos de ordenação deve levar em contar o número de comparações e o número de trocas realizadas. O Método da Seleção realiza aproximadamente n2/2 comparações e n trocas, pois para cada i de 1 até n-1 ocorre uma troca e n-1 comparações. Assim temos que no final ocorrem n-1 trocas e (n-1 + (n-2) + ... 2 +1 = n(n-1)/2 comparações. O método da Inserção realiza aproximadamente n2/4 comparações e n2/8 trocas no caso médio. No entanto, esse método é linear para arranjos parcialmente ordenados, o que o torna um método conveniente para estes casos. O método da Bolha realiza n2/2 comparações e n2/2 trocas no caso médio e no pior caso. Realize testes de mesa para os métodos de seleção, inserção e bolha para os seguintes vetores: A = {10,0,3,2,5}, B={5,4,3,2,1}, C={1,1,2,2,0,0}. 2.2.5 Método de Shell O método que estudaremos agora foi inventado por Criado por Donald Shell em 1959. Conforme comentado na Seção 5.2.4, o método da Inserção é bastante eficiente ao ordenar arranjos que já estão parcialmente ordenados. Shell observou esta característica e criou um método onde o método de inserção é aplicado sucessivamente. Em seu método, o algoritmo de inserção é aplicado para ordenar subconjuntos do arranjo completo. Estes subconjuntos são pegos considerando uma distância entre os elementos. Para cada execução do método de Inserção é considerada um distância d. d é iniciado com um valor que vai sendo decrementado até chegar a 1. Para cada valor de d, os elementos do arranjo localizados a uma distância d entre si são ordenados. Por exemplo, em um arranjo, a = {a0,a1,a2,a3,a4,a5,a6,a7,a8,a9} e considerando uma série de distâncias d = {5,3,1}. Nas iterações com distância d = 5, o método da inserção será aplicado aos elementos do arranjo localizados a uma distância 5 entre si. Os elementos são pegos do arranjo a partir da posição a0, na primeira iteração, a1 na segunda, a2 na terceira e assim sucessivamente. Assim para a distância 5 teremos a ordenação dos elementos: {a0,a5} (primeira iteração) {a1,a6} (segunda iteração) {a2,a7} (terceira iteração) {a3,a8} (quarta iteração) {a4,a9} (quinta iteração) Para a distância 3 teremos a ordenação dos elementos: {a0,a3,a6,a9} (primeira iteração) {a1,a4,a7} (segunda iteração) {a2,a5,a8} (terceira iteração) {a3,a6,a9} (quarta iteração) {a4,a7} (quinta iteração) {a5,a8} (sexta iteração) {a6,a9} (sétima iteração) Para a distância 1 teremos a ordenação do arranjo completo {a0,a1,a2,a3,a4,a5,a6,a7,a8,a9} (primeira iteração) A cada iteração a distância d é reduzida de acordo com alguma seqüência. Na utiliza iteração esta distância é reduzida a 1. Este passo corresponde ao método de inserção original. Vejamos um exemplo concreto, considerando o arranjo a = {35,28,16,07,12,08,04} e utilizando a seqüência de distâncias {3,1}: Para d = 3 o método ordena os elementos que estão a uma distância 3 entre si. Na tabela abaixo estes elementos estão em negrito: 35 28 16 07 12 08 04 35 está ordenado, 7 é comparado 07 28 16 35 12 08 04 {07,35} está comparado 04 28 16 07 12 08 35 {04,07,35} está ordenado 04 28 16 07 12 08 35 28 está ordenado, 12 é comparado 04 12 16 07 28 08 35 {12,28} está ordenado 04 12 16 07 28 08 35 16 está ordenado, 35 é comparado 04 12 08 07 28 16 35 {08,16} está ordenado ordenado, 4 é Após a ordenação parcial feita com d=3, a distância é reduzida a 1 e o vetor é completamente ordenado. Para implementação do método de Shell, implementa-se um função (Shell) que corresponde ao método de inserção parametrizado pela distância. Esta implementação é apresentada na Figura 5.5. shell(int A[], int n, int d) { int i,j,x; for(i=d;i<n;i++) { x=A[i]; j=i-d; while((j>=0) && (A[j]>x)) { A[j+d]=A[j]; j = j-d; imprimevetor(A,n); } A[j+d]=x; imprimevetor(A,n); } } Figura 5.5: Implementação da função Shell Note que a função shell corresponde exatamente à função insercao, se substituirmos o d pelo valor 1. Para concluirmos a implementação do método de Shell basta agora implementarmos uma função que gere a seqüência de distâncias e a chave a função Shell para cada distância da série. A Figura 5.6 apresenta uma implementação da função shellSort, que executa o método com a série {5,2,1}. void shellsort1(int a[], int n) { int p; int d[3] = {5,2,1}; for (p=0;p<3;p++) { shell(a,n,d[p]); } } Figura 5.6: Implementação do método de Shell com distâncias 5,2 e 1 A execução de shellsort1 sobre o vetor int B[] = {10,3,7,20,1,2,11,0,5,4}; produz a seguinte saída: 10 3 7 20 Distancia d= 10 3 7 20 2 3 7 20 2 3 7 20 2 3 7 20 2 3 0 20 2 3 0 20 2 3 0 5 2 3 0 5 Distancia d= 2 3 2 5 0 3 2 5 0 3 2 5 0 3 2 5 0 3 1 5 0 3 1 5 0 3 1 5 0 3 1 5 0 3 1 5 0 3 1 5 0 3 1 5 0 3 1 5 0 3 1 5 0 3 1 4 Distancia d= 0 3 1 4 0 3 3 4 0 1 3 4 0 1 3 4 0 1 3 4 0 1 3 3 0 1 2 3 0 1 2 3 0 1 2 3 0 1 2 3 0 1 2 3 0 1 2 3 1 5 1 1 1 1 1 1 1 1 2 1 1 1 2 2 2 2 2 2 2 2 2 2 2 1 2 2 2 2 4 4 4 4 4 4 4 4 2 11 10 10 10 10 10 10 10 10 11 11 11 11 11 11 11 11 10 10 10 10 10 10 10 10 7 7 7 7 5 5 11 11 11 11 11 11 11 11 11 11 11 11 11 11 5 5 5 5 5 5 5 5 5 5 5 5 0 5 4 0 5 0 5 0 5 7 5 7 5 7 20 7 20 7 20 4 4 4 4 4 4 4 4 7 7 7 7 7 7 7 10 10 10 10 7 7 7 20 20 20 20 20 20 20 20 20 20 20 20 20 20 4 4 4 4 4 4 4 4 4 4 10 10 10 10 11 7 20 10 11 7 20 10 11 7 20 10 11 7 20 10 11 7 20 10 11 7 20 10 11 7 20 10 11 7 20 10 11 7 20 10 11 11 20 10 7 11 20 10 7 11 20 10 0 0 0 1 1 1 2 2 2 3 3 3 4 4 4 5 5 5 7 11 20 20 7 11 11 20 7 10 11 20 Conforme pode ser observado, a seqüência de distâncias pode variar. A única restriçã é que termine com 1. Não existe uma seqüência ideal. Donald Knuth (vale a pena ver http://pt.wikipedia.org/wiki/Donald_Knuth), mostrou empiricamente que a seqüência ( 1,4, 13,40,121,364,1093..) apresenta eficiência 20 % maior que outras seqüências. A Figura 5.7 apresenta a função shellsort2 que executa a função Shell usando esta seqüência. // executa o shell sort a seqüência de Knuth ( 1,4, 13,40,121,364,1093..) void shellsort2(int a[],int n) { int i,p=0; int d[MAX/9]; /* se o tamanho do vetor é menor que 9 */ if((n/9)<1){ p =1; d[0]=1; } else { /* gera a seqüência de distancias e guarda no vetor d */ for (i=1;i<=n/9;i=3*i+1) { d[p] =i; p++; } } for (i=p-1;i>=0;i--) { shell(a,n,d[i]); } } Figura 5.7: Implementação do método de Shell usando a seqüência de Knuth 2.2.6 O Método Quicksort Até o momento, foram estudados métodos de ordenação cuja função de complexidade é próxima de n2. Ou seja, complexidade quadrática. Nos melhores casos, o método da inserção e Shell pode chegar próximo de um comportamento linear. O método quicksort baseia-se em uma estratégia de dividir para conquistar (vide pesquisa binária), tornando-o assim um método que apresenta complexidade logarítimica (sublinear), no caso médio. Este comportamento do quicksort o torna um método de ordenação extremamente rápido. O algoritmo Quicksort foi inventado por C.A.R. Hoare em 1960, durante visita a Universidade de Moscou (Russia), ainda como estudante. A idéia fundamental do quicksort consiste em: First, the sequence to be sorted a is partitioned into two parts, such that all elements of the first part b are less than or equal to all elements of the second part c (divide). Then the two parts are sorted separately by recursive application of the same procedure (conquer). Recombination of the two parts yields the sorted sequence (combine). Figure 1 illustrates this approach. Primeiro dividir: a seqüência a ser ordenada a é particionada em duas partes b e c, de tal modo que todos os elementos da primeira parte b são menores ou iguais a todos os elementos da segunda parte c. Em seguida conquistar: as duas partes são ordenadas separadamente por meio da aplicação recursiva do mesmo procedimento. Por último recombinar. A recombinação das duas partes ordenadas em separado produz uma seqüência também ordenada, dado que os elementos da primeira parte b são menores ou iguais a todos os elementos da segunda parte c. O processo de particionamento da seqüência é feita por meio da escolha de um elemento pivô. Por exemplo, Seja o arranjo {f,e,d,h,a,c,g,b} e o valor d como o elemento escolhido para fazer a primeira partição. O elemento escolhido é chamado de pivô. Após a primeira iteração com o valor d como pivô teremos a seguinte configuração para o vetor: {b,c,a,d,h,e,g,f} Neste passo o processo é repetido para cada sub vetor [b,c,a] e [h,e,g,f] A Figura 5.8 apresenta uma implementação da operação de particionamento. Nesta implementação o pivô é sempre o elemento do meio do vetor. void particiona(int a[], int esq,int dir, int *pi, int *pj) { int x; int i,j; i = esq; j = dir; x = a[(i+j)/2]; // obtem o pivo desta particao printf("O pivo é %d da pos. %d\n",x,(i+j)/2); do { while((x > a[i]) && (i<dir)) { i++; } while ((x < a[j]) && (j>esq)){ j--; } if(i<=j) { troca(&a[i],&a[j]); i++; j--; } } while(i< j); *pi = i; *pj = j; } Figura 5.8: função particiona A Figura 5.9 apresenta a função ordenaQS que aplica o método sobre um arranjo com limites esq e dir. void ordenaQS(int a[], int esq, int dir) { int i,j; particiona(a, esq,dir, &i,&j); imprimevetor(a,16); if(esq<j) ordenaQS(a,esq,j); if (dir>i) ordenaQS(a,i,dir); } Figura 5.9: Implementação da função ordenaQS A implementação da função quicksort, apresentada na Figura 5.10 consiste apenas no encapsulamento da função ordenaQS em uma função cujos parâmetros são apenas o arranjo e seu tamanho. void quicksort(int a[],int n) { ordenaQS(a,0,n-1); } Figura 5.10: implementação da função quicksort 1 Realizar os experimentos com os 5 métodos de ordenação estudados. No caso do shellsort utilizar a seqüência de Knuth. a) Realizar testes com todos os métodos para vetores de tamanho 20000, 40000, 60000, 80000, 100000, 150000, 200000, 250000, 300000, 350000, 400000 e 500000. b) Utilizar um software que trace gráficos (Excel, por exemplo) e traçar um gráfico de desempenho dos métodos utilizados. O gráfico deve ter os seguintes eixos: eixo x: tamanho do vetor. Eixo y: tempo de execução. Colocar as curvas de todos os métodos no mesmo gráfico para facilitar a comparação. c) Fazer uma análise dos resultados obtidos (descritiva). 2. Sabemos que a eficiência do Shellsort depende da seqüência de distâncias utilizada. Assim, neste exercício, primeiramente você deve inventar uma seqüência de distâncias sua para ser utilizada no Shellsort. Posteriormente, escreva uma função myShellsort que execute o Shellsort com uma seqüência produzida por você. Invente uma seqüência sua! Você pode utilizar a mesma estrutura que foi utilizada para as funções shelsort1 e shellsort2. Compare os resultados de tempo obtidos com a sua seqüência com os obtidos com a seqüência de Knuth.