Aula 8 - Ordenação: Introdução, Insertion e Shell Sort
Nesta aula iniciamos o estudo dos algoritmos de ordenação, um dos temas mais clássicos de Estruturas de Dados. Começamos com uma revisão geral do assunto e um panorama de todos os algoritmos que veremos ao longo da disciplina, e então estudamos os dois primeiros em detalhe: Insertion Sort e Shell Sort.
1. O que é ordenação?
Ordenar é rearranjar os elementos de uma coleção (um vetor, uma lista) segundo uma relação de ordem (crescente ou decrescente), com base em uma chave — o campo usado para comparar dois elementos (por exemplo, o cod ou o preco de um Produto).
Alguns critérios usados para comparar algoritmos de ordenação entre si:
- Complexidade: quantas comparações/trocas o algoritmo realiza, em função do tamanho
nda entrada — o critério mais importante para decidir qual algoritmo usar em cada situação. - In-place: um algoritmo é in-place quando ordena usando apenas
O(1)de memória extra (rearranjando os elementos no próprio vetor), em vez de construir uma cópia auxiliar. - Estabilidade: um algoritmo é estável quando preserva a ordem relativa de elementos com chaves iguais. Isso importa, por exemplo, ao ordenar uma lista de alunos por nota: se dois alunos empatam, um algoritmo estável mantém a ordem em que eles apareciam antes (por exemplo, alfabética, se a lista já estivesse assim).
- Baseado em comparação, ou não: a maioria dos algoritmos decide a ordem comparando pares de elementos (
a < b); alguns poucos (Counting Sort, Radix Sort) ordenam sem comparar diretamente os elementos entre si, explorando a natureza dos valores das chaves.
Limite teórico. Prova-se que nenhum algoritmo de ordenação baseado em comparação pode, no pior caso, ser mais rápido que O(n log n) — é o melhor que se pode alcançar comparando elementos dois a dois. Algoritmos como Counting Sort e Radix Sort conseguem O(n) justamente porque não se baseiam em comparação.
|
2. Panorama: principais algoritmos de ordenação
A tabela abaixo resume todos os algoritmos de ordenação que vocês já viram (em Algoritmos e Programação) ou que ainda serão vistos ao longo desta disciplina, com a complexidade temporal de cada um nos três cenários possíveis:
| Algoritmo | Resumo | Melhor caso | Caso médio | Pior caso | Onde é visto |
|---|---|---|---|---|---|
| Bubble Sort | Compara pares de elementos adjacentes e os troca de posição quando estão fora de ordem, "borbulhando" o maior elemento até o final a cada passagem. | O(n) |
O(n²) |
O(n²) |
Algoritmos e Programação |
| Selection Sort | A cada passagem, procura o menor elemento restante e o troca de posição com o primeiro elemento ainda não ordenado. | O(n²) |
O(n²) |
O(n²) |
Algoritmos e Programação |
| Insertion Sort | Percorre os elementos um a um, inserindo cada um na posição correta em relação aos elementos já ordenados à sua esquerda. | O(n) |
O(n²) |
O(n²) |
Aula 8 (esta aula) |
| Shell Sort | Generaliza o Insertion Sort comparando elementos distantes entre si (a um gap), reduzindo o gap progressivamente até ordenar com gap 1. | O(n log n) |
~O(n¹·³) |
O(n²) |
Aula 8 (esta aula) |
| Heap Sort | Constrói um heap (fila de prioridade) com os elementos, e repetidamente remove o maior, reconstruindo o vetor ordenado. | O(n log n) |
O(n log n) |
O(n log n) |
Aula 17 |
| Merge Sort | Divide o vetor ao meio recursivamente até sobrarem elementos únicos, e então intercala (merge) as metades já ordenadas. | O(n log n) |
O(n log n) |
O(n log n) |
Aulas 21 e 22 |
| Quick Sort | Escolhe um elemento como pivô, particiona o vetor em "menores" e "maiores" que ele, e ordena cada partição recursivamente. | O(n log n) |
O(n log n) |
O(n²) |
Aulas 21 e 22 |
| Counting Sort | Conta quantas vezes cada valor de chave aparece, e usa essa contagem para calcular diretamente a posição final de cada elemento. Não compara elementos entre si. | O(n + k) |
O(n + k) |
O(n + k) |
Aula 26 |
| Radix Sort | Ordena repetidamente por dígito/caractere (do menos para o mais significativo), usando o Counting Sort como base de cada passagem. | O(d(n + k)) |
O(d(n + k)) |
O(d(n + k)) |
Aula 26 |
Onde n é o número de elementos, k é a faixa de valores possíveis das chaves (usada por Counting e Radix Sort), e d é o número de dígitos/caracteres de cada chave (usado por Radix Sort).
| Por que tantos algoritmos, se todos "só" ordenam? Cada um faz um conjunto diferente de concessões entre complexidade, uso de memória extra, estabilidade e comportamento em casos especiais (vetor quase ordenado, poucos valores distintos, chaves inteiras pequenas, etc.). Não existe um único "melhor" algoritmo de ordenação — existe o mais adequado para cada situação, e é exatamente esse julgamento que construiremos ao longo das próximas aulas. |
3. Insertion Sort
A ideia do Insertion Sort é a mesma usada para organizar cartas de baralho na mão: pega-se uma carta por vez e insere-se na posição correta entre as cartas já organizadas à esquerda. A "parte já ordenada" do vetor começa com um único elemento (vetor[0], trivialmente ordenado) e cresce uma posição a cada passo, até cobrir o vetor inteiro.
void insertionSort(int vetor[], int n) { int i, j, chave; // vetor[0..0] já está "ordenado" (um único elemento sempre está); // por isso começamos em i = 1, tentando inserir vetor[1] em relação a ele. for (i = 1; i < n; i++) { chave = vetor[i]; // guarda o elemento antes de sobrescrever sua posição j = i - 1; // j percorre a parte já ordenada, da direita para a esquerda // enquanto houver alguém à esquerda maior que "chave", abre espaço // deslocando esse alguém uma posição para a direita while (j >= 0 && vetor[j] > chave) { vetor[j + 1] = vetor[j]; // desloca vetor[j] para a direita j--; // continua olhando mais à esquerda } // o laço parou porque achou alguém <= chave (ou chegou ao início do vetor); // "chave" pertence logo depois dessa posição vetor[j + 1] = chave; } }
Note que o laço interno (while) só percorre a parte já ordenada do vetor (vetor[0..i-1]) — nunca a parte ainda não visitada. É por isso que dizemos que, "a cada passo, mais uma posição do vetor passa a fazer parte da região ordenada".
Acompanhando a execução, passo a passo
Vamos ordenar o vetor [50, 20, 40, 10] (n = 4), acompanhando o valor de i, chave, e como o while desloca elementos. Usamos valores como 50, 20, ... (em vez de 5, 2, ...) para não confundi-los com os índices (i, j) usados para percorrer o vetor:
Vetor inicial: [50, 20, 40, 10]
^ordenado(sozinho)
i=1, chave=20:
j=0: vetor[0]=50 > 20 -> desloca: [50, 50, 40, 10], j=-1
laço para (j < 0) -> vetor[j+1] = vetor[0] = chave
vetor: [20, 50, 40, 10]
^------^ ordenado
i=2, chave=40:
j=1: vetor[1]=50 > 40 -> desloca: [20, 50, 50, 10], j=0
j=0: vetor[0]=20 > 40? não -> laço para
vetor[j+1] = vetor[1] = chave
vetor: [20, 40, 50, 10]
^----------^ ordenado
i=3, chave=10:
j=2: vetor[2]=50 > 10 -> desloca: [20, 40, 50, 50], j=1
j=1: vetor[1]=40 > 10 -> desloca: [20, 40, 40, 50], j=0
j=0: vetor[0]=20 > 10 -> desloca: [20, 20, 40, 50], j=-1
laço para (j < 0) -> vetor[j+1] = vetor[0] = chave
vetor: [10, 20, 40, 50]
^--------------^ ordenado (vetor inteiro)
Repare como chave = 10 (o menor valor do vetor) precisou ser deslocada por todas as três posições já ordenadas — esse é justamente o cenário que deixa o Insertion Sort lento: um elemento pequeno "preso" muito à direita.
Análise de complexidade
O laço externo (for) sempre executa n - 1 vezes, independente da entrada — o que varia é quantas vezes o laço interno (while) desloca elementos a cada iteração:
- Melhor caso — vetor já ordenado: para cada
i, a condiçãovetor[j] > chavejá é falsa na primeira verificação (o elemento à esquerda nunca é maior), então owhilenão desloca nada. Só sobra o custo dofor, então o algoritmo éO(n). - Pior caso — vetor em ordem decrescente: para cada
i, achaveé sempre a menor já vista, então owhiledesloca todos osielementos já ordenados antes de encontrar seu lugar (no início). O total de deslocamentos é1 + 2 + ... + (n-1), uma soma de progressão aritmética que resulta emO(n²). - Caso médio: em uma entrada aleatória, espera-se que, em média, cada elemento precise ser deslocado por metade da parte já ordenada — ainda uma fração de
n, então a complexidade média continua sendoO(n²)(a constante multiplicativa é menor, mas a ordem de grandeza não muda).
In-place e estável. O Insertion Sort é in-place (usa apenas as variáveis i, j e chave, nenhum vetor auxiliar) e estável: a condição do while é vetor[j] > chave (estritamente maior) — um elemento igual à chave nunca é deslocado, então dois elementos iguais nunca trocam de ordem relativa entre si.
|
4. Shell Sort
O Shell Sort ataca a principal fraqueza do Insertion Sort: quando um elemento pequeno está muito à direita no vetor, ele precisa ser deslocado uma posição de cada vez até chegar ao início, o que é lento. A solução é comparar elementos distantes entre si primeiro, usando um gap (intervalo) que vai diminuindo a cada passagem, até terminar com um gap de 1 — nesse ponto, o vetor já está "quase ordenado", e essa última passagem (um Insertion Sort comum) é bem mais rápida do que seria sobre o vetor original.
void shellSort(int vetor[], int n) { int gap, i, j, chave; // a cada passagem, o gap diminui pela metade: n/2, n/4, ..., até chegar em 1 for (gap = n / 2; gap > 0; gap /= 2) { // este "for" é o mesmo Insertion Sort de antes, só que comparando // elementos a "gap" posições de distância, em vez de posições vizinhas for (i = gap; i < n; i++) { chave = vetor[i]; j = i - gap; // j "pula" de gap em gap para trás, não de 1 em 1 while (j >= 0 && vetor[j] > chave) { vetor[j + gap] = vetor[j]; // desloca a um "gap" de distância j -= gap; } vetor[j + gap] = chave; } } }
Compare com o Insertion Sort: a única diferença real é trocar 1 por gap em três lugares (i = gap em vez de i = 1; j = i - gap em vez de j = i - 1; j -= gap em vez de j--). Quando gap chega a 1, o código se torna idêntico ao Insertion Sort — a última passagem do Shell Sort é sempre um Insertion Sort comum, só que sobre um vetor já quase ordenado pelas passagens anteriores.
Acompanhando a execução, passo a passo
Vamos ordenar [50, 20, 40, 10, 60, 30] (n = 6), com gap começando em 3 (n/2). Novamente, usamos valores terminados em 0 só para não confundi-los com os índices:
Vetor inicial: [50, 20, 40, 10, 60, 30]
0 1 2 3 4 5 (índices)
--- gap = 3 ---
Compara/ordena os "subvetores" formados a cada 3 posições:
índices {0,3}: [50,10] -> fora de ordem -> vira [10,50]
índices {1,4}: [20,60] -> já em ordem -> continua [20,60]
índices {2,5}: [40,30] -> fora de ordem -> vira [30,40]
Vetor apos gap=3: [10, 20, 30, 50, 60, 40]
--- gap = 1 (Insertion Sort comum) ---
i=1, chave=20: 10 > 20? não -> [10, 20, 30, 50, 60, 40]
i=2, chave=30: 20 > 30? não -> [10, 20, 30, 50, 60, 40]
i=3, chave=50: 30 > 50? não -> [10, 20, 30, 50, 60, 40]
i=4, chave=60: 50 > 60? não -> [10, 20, 30, 50, 60, 40]
i=5, chave=40: 60>40 desloca, 50>40 desloca, 30>40? não
-> [10, 20, 30, 40, 50, 60]
Vetor final: [10, 20, 30, 40, 50, 60]
Note como, depois da passagem com gap = 3, o vetor já estava bem mais próximo do ordenado — a passagem final com gap = 1 precisou deslocar elementos só uma vez (para o 40), em vez de repetidamente a cada posição, como aconteceria num Insertion Sort "do zero" sobre o vetor original.
Análise de complexidade
A análise exata do Shell Sort é mais delicada que a do Insertion Sort, porque depende da sequência de gaps escolhida — mas a ideia central é sempre a mesma: quanto mais "pré-ordenado" o vetor fica nas passagens com gap grande, mais barata fica a passagem final com gap = 1.
- Melhor caso: vetor já ordenado — cada passagem, para qualquer
gap, não desloca nada (assim como no Insertion Sort), resultando emO(n log n)(uma passagem por valor degap, e háO(log n)valores degapaté chegar em1). - Pior caso: com a sequência de gaps
n/2, n/4, ..., 1usada aqui, o pior caso continua sendoO(n²)— mas, na prática, isso raramente se aproxima do comportamento real observado, que é bem mais rápido. - Caso médio (na prática): para essa mesma sequência de gaps, o comportamento observado empiricamente fica próximo de
O(n¹·³)— sequências de gaps diferentes (existem várias propostas na literatura) podem alcançar limites teóricos ainda melhores, mas o importante aqui é entender por quê o Shell Sort tende a ser mais rápido que o Insertion Sort puro na prática, não decorar uma fórmula exata.
In-place, mas não estável. O Shell Sort é in-place (mesmas variáveis auxiliares do Insertion Sort). Mas não é estável: como elementos são comparados a um gap de distância (não necessariamente vizinhos), é possível que um elemento "salte" por cima de outro igual a ele durante uma passagem com gap > 1, invertendo sua ordem relativa original — algo que nunca acontece no Insertion Sort puro, onde só há comparação com o vizinho imediato.
|
Resumo
|
Exercícios
- Explique a diferença entre um algoritmo de ordenação estável e um instável. Dê um exemplo prático de quando essa diferença importa.
- O que significa um algoritmo de ordenação ser in-place? O Insertion Sort apresentado nesta aula é in-place? Justifique observando o código.
-
Por que nenhum algoritmo de ordenação baseado em comparação consegue ser mais rápido que
O(n log n)no pior caso, enquanto Counting Sort consegueO(n + k)? -
Simule manualmente o Insertion Sort sobre o vetor
[8, 3, 7, 4, 2], mostrando o estado do vetor após cada iteração do laço externo (variáveli). -
Explique por que o Insertion Sort é
O(n)no melhor caso, masO(n²)no pior caso. Que tipo de entrada gera cada um desses casos? -
No Shell Sort, por que a última passagem (com
gap == 1) tende a ser muito mais rápida do que executar um Insertion Sort comum diretamente sobre o vetor original? - O Shell Sort apresentado nesta aula é estável? Dê um exemplo com dois elementos de chaves iguais em que a ordem relativa entre eles se inverte durante a execução.
Sugestões de Respostas dos Exercícios
Exercício 1
Um algoritmo estável preserva a ordem relativa entre elementos que possuem chaves iguais; um algoritmo instável não dá essa garantia, podendo trocar a ordem entre eles. Isso importa, por exemplo, ao ordenar uma lista de alunos por nota, quando dois alunos empatam: se a lista já estava em ordem alfabética antes de ordenar por nota, um algoritmo estável preserva essa ordem alfabética entre os empatados, enquanto um instável pode embaralhá-la.
Exercício 2
Um algoritmo é in-place quando ordena usando apenas uma quantidade constante (O(1)) de memória extra, rearranjando os elementos dentro do próprio vetor recebido. O Insertion Sort apresentado é in-place: ele recebe vetor e o modifica diretamente, usando apenas as variáveis auxiliares i, j e chave — nenhum vetor ou struct adicional de tamanho proporcional a n é alocado.
Exercício 3
Algoritmos baseados em comparação só têm acesso à relação a < b entre pares de elementos, e prova-se que, no pior caso, são necessárias pelo menos O(n log n) comparações para garantir que qualquer ordenação possível seja distinguida das demais. O Counting Sort não compara elementos entre si: ele usa diretamente o valor de cada chave como índice para contar ocorrências, o que só é possível porque as chaves são inteiras e estão dentro de uma faixa k conhecida — uma informação extra que os algoritmos baseados em comparação não podem assumir.
Exercício 4
Vetor inicial: [8, 3, 7, 4, 2] Apos i=1 (chave=3): [3, 8, 7, 4, 2] Apos i=2 (chave=7): [3, 7, 8, 4, 2] Apos i=3 (chave=4): [3, 4, 7, 8, 2] Apos i=4 (chave=2): [2, 3, 4, 7, 8]
A cada iteração de i, o elemento vetor[i] é deslocado para a esquerda até encontrar sua posição correta entre os elementos já ordenados (vetor[0..i-1]).
Exercício 5
No melhor caso (vetor já ordenado), a condição vetor[j] > chave do laço interno é sempre falsa na primeira verificação, então cada elemento é comparado apenas uma vez com seu vizinho à esquerda, custando O(n) no total. No pior caso (vetor em ordem estritamente decrescente), cada elemento precisa ser comparado e deslocado através de todos os elementos já ordenados antes dele, o que soma 1 + 2 + ... + (n-1) comparações, ou seja, O(n²).
Exercício 6
Porque, depois das passagens com gaps maiores, cada elemento já foi aproximado da sua posição final "de longe" — um elemento muito pequeno que estava muito à direita não precisa mais ser deslocado posição por posição até o início, pois passagens anteriores já o moveram várias posições de cada vez. Assim, quando chega a passagem final com gap == 1, o vetor já está "quase ordenado", e o Insertion Sort sobre um vetor quase ordenado se aproxima do seu melhor caso, O(n).
Exercício 7
Não, o Shell Sort não é estável. Considere o vetor [3a, 1, 3b] (onde 3a e 3b têm a mesma chave 3, mas 3a aparece primeiro) e gap = 2: nessa passagem, 3a (posição 0) é comparado com 3b (posição 2). Como vetor[0] > chave é falso (3 > 3 é falso), nada acontece nessa comparação especificamente — mas em vetores maiores, com mais elementos iguais espalhados a diferentes gaps, é possível construir casos em que um elemento igual "salta" por cima de outro em uma passagem de gap maior, e os dois terminam em ordem relativa diferente da original, o que nunca aconteceria no Insertion Sort puro (gap = 1 desde o início).