Voltar para Posts

Ciências da computação dia 113

Algoritmos de Ordenação: Bubble, Insertion, Selection, Merge e Quick Sort

Durante nossas aulas de Ciências da Computação, o dia 113 foi dedicado a um dos tópicos mais clássicos e importantes da área: algoritmos de ordenação. Compreender como ordenar dados de forma eficiente é essencial para qualquer cientista da computação, pois impacta diretamente a performance de sistemas que lidam com grandes volumes de informação. Exploramos desde algoritmos simples como Bubble Sort até soluções mais sofisticadas como Merge Sort e Quick Sort, analisando suas implementações, complexidades e cenários ideais de aplicação. Este post serve como um guia de referência para consolidar o que foi discutido em sala de aula.

Bubble Sort

O Bubble Sort é frequentemente o primeiro algoritmo de ordenação apresentado em cursos introdutórios devido à sua simplicidade conceitual. Ele funciona percorrendo a lista repetidamente, comparando elementos adjacentes e trocando-os se estiverem na ordem errada. Este processo é repetido até que a lista esteja completamente ordenada. Visualmente, os maiores elementos "borbulham" em direção ao final do array, de onde vem o nome do algoritmo.

Uma variação otimizada discutida em aula utiliza uma flag booleana swapped. Se nenhuma troca for realizada durante uma passada completa, o array já está ordenado e o algoritmo pode ser interrompido precocemente, resultando em complexidade O(n) no melhor caso (lista já ordenada).

Complexidade: O(n²) no pior e no caso médio. O(1) de memória adicional.

def bubble_sort(arr):
    n = len(arr)
    for i in range(n):
        swapped = False
        for j in range(0, n-i-1):
            if arr[j] > arr[j+1]:
                arr[j], arr[j+1] = arr[j+1], arr[j]
                swapped = True
        if not swapped:
            break

Insertion Sort

O Insertion Sort constrói a lista ordenada um elemento de cada vez, similar à forma como muitas pessoas organizam cartas de baralho em suas mãos. Ele percorre o array e insere cada elemento em sua posição correta em relação aos elementos já processados. A cada iteração, um elemento é retirado e inserido na posição adequada dentro da sublista ordenada à sua esquerda.

Uma característica importante do Insertion Sort é que ele é estável, ou seja, a ordem relativa de elementos com chaves iguais é preservada. Sua natureza adaptativa o torna extremamente eficiente para listas parcialmente ordenadas, sendo frequentemente utilizado como algoritmo auxiliar em estratégias híbridas como o Timsort.

Complexidade: O(n²) no pior caso, mas O(n) no melhor caso (lista já ordenada). O(1) de memória adicional.

def insertion_sort(arr):
    for i in range(1, len(arr)):
        key = arr[i]
        j = i - 1
        while j >= 0 and arr[j] > key:
            arr[j + 1] = arr[j]
            j -= 1
        arr[j + 1] = key

Selection Sort

O Selection Sort divide a lista em duas regiões virtuais: a parte ordenada (à esquerda) e a parte não ordenada (à direita). Inicialmente, a parte ordenada está vazia. O algoritmo itera sobre a parte não ordenada, encontra o menor elemento e o troca com o primeiro elemento dessa parte, expandindo a região ordenada. Este processo se repete até que toda a lista esteja ordenada.

Embora sua complexidade O(n²) o torne ineficiente para grandes conjuntos, o Selection Sort possui uma propriedade interessante: o número de trocas realizadas é O(n). Isso pode ser vantajoso em cenários onde a operação de escrita na memória é extremamente cara, como em certos sistemas embarcados com memória flash.

Complexidade: O(n²) em todos os casos (melhor, médio e pior). O(1) de memória adicional.

def selection_sort(arr):
    n = len(arr)
    for i in range(n):
        min_idx = i
        for j in range(i+1, n):
            if arr[j] < arr[min_idx]:
                min_idx = j
        arr[i], arr[min_idx] = arr[min_idx], arr[i]>

Merge Sort

Utilizando o paradigma de divisão e conquista, o Merge Sort divide a lista ao meio recursivamente até que cada sublista tenha um único elemento (considerado ordenado por definição). Em seguida, ele intercala (merge) essas sublistas de forma ordenada, construindo a solução final. A fase de merge combina duas listas ordenadas em uma única lista ordenada.

Exploramos a implementação top-down (recursiva) e discutimos a abordagem bottom-up (iterativa). A principal desvantagem do Merge Sort é o uso de O(n) de memória auxiliar para realizar a intercalação. Por outro lado, ele garante um desempenho consistente O(n log n) em todos os cenários e é um algoritmo estável.

Complexidade: O(n log n) em todos os casos. O(n) de memória adicional.

def merge_sort(arr):
    if len(arr) > 1:
        mid = len(arr) // 2
        L = arr[:mid]
        R = arr[mid:]
        merge_sort(L)
        merge_sort(R)
        i = j = k = 0
        while i < len(L) and j >< len(R):
            if L[i] >< R[j]:
                arr[k] = L[i]
                i += 1
            else:
                arr[k] = R[j]
                j += 1
            k += 1
        while i >< len(L):
            arr[k] = L[i]
            i += 1
            k += 1
        while j >< len(R):
            arr[k] = R[j]
            j += 1
            k += 1>

Quick Sort

O Quick Sort também se baseia no princípio de divisão e conquista, mas com uma abordagem diferente. Ele seleciona um elemento da lista como pivô e particiona os outros elementos ao redor dele: elementos menores que o pivô vão para a esquerda, e elementos maiores vão para a direita. Este processo é repetido recursivamente para cada partição.

A escolha do pivô é um tópico crítico discutido em aula. Se o array já está ordenado e escolhemos o primeiro elemento como pivô, o Quick Sort degenera para O(n²). Para mitigar este risco, utilizamos estratégias como a escolha aleatória do pivô ou a mediana de três. A implementação in-place é a mais comum e eficiente, modificando o array original sem criar novas listas. Na prática, o Quick Sort é geralmente mais rápido que o Merge Sort para a maioria dos conjuntos de dados devido à sua boa localidade de referência.

Complexidade: O(n log n) no caso médio, O(n²) no pior caso. O(log n) de memória na pilha de recursão.

def quick_sort(arr):
    if len(arr) <= 1:
        return arr
    pivot = arr[len(arr) // 2]
    left = [x for x in arr if x >< pivot]
    middle = [x for x in arr if x == pivot]
    right = [x for x in arr if x > pivot]
    return quick_sort(left) + middle + quick_sort(right)

A aula terminou com uma comparação prática entre os algoritmos. Em sistemas embarcados com memória limitada, o Insertion Sort ou Selection Sort podem ser preferíveis ao Merge Sort. Em aplicações de larga escala, o Quick Sort é geralmente a primeira escolha, enquanto o Merge Sort é preferido quando a estabilidade é um requisito fundamental ou quando se lida com dados armazenados em mídia sequencial. O Timsort, utilizado pelo Python e JavaScript, é um exemplo de algoritmo híbrido que combina Merge Sort e Insertion Sort. Você pode encontrar mais discussões sobre Python e algoritmos em nossa página de Tags.

Tabela Comparativa de Complexidade

Algoritmo Melhor Caso Caso Médio Pior Caso Memória
Bubble Sort O(n) O(n²) O(n²) O(1)
Insertion Sort O(n) O(n²) O(n²) O(1)
Selection Sort O(n²) O(n²) O(n²) O(1)
Merge Sort O(n log n) O(n log n) O(n log n) O(n)
Quick Sort O(n log n) O(n log n) O(n²) O(log n)

Perguntas Frequentes (FAQ)

O que significa um algoritmo de ordenação ser estável?

Um algoritmo de ordenação é estável se ele mantém a ordem relativa de elementos com chaves iguais. Por exemplo, ao ordenar uma lista de alunos por nota, dois alunos com a mesma nota manterão a ordem original da lista. Merge Sort e Insertion Sort são estáveis, enquanto Quick Sort (na implementação apresentada) e Selection Sort geralmente não são.

O que é ordenação in-place?

Algoritmos in-place ordenam os dados utilizando apenas O(1) ou O(log n) de memória extra, modificando diretamente a estrutura de dados original. Insertion Sort, Selection Sort, Bubble Sort e Quick Sort (em sua versão clássica) são exemplos de algoritmos in-place. Merge Sort, por sua vez, não é in-place devido à necessidade do array auxiliar de tamanho O(n).

Existe um algoritmo de ordenação perfeito para todas as situações?

Não existe um único algoritmo de ordenação perfeito para todos os cenários. A escolha ideal depende de diversos fatores: tamanho do conjunto de dados, nível de ordenação inicial, restrições de memória, necessidade de estabilidade, tipo de hardware e se a ordenação pode ser paralelizada. O estudo aprofundado desses algoritmos nos capacita a fazer a análise crítica necessária para escolher a melhor ferramenta para cada problema específico.

Continue acompanhando as aulas na página de Posts para mais conteúdos e anotações como esta.