Introdução
Após explorar o Bubble Sort em nossa “Série Preparação para Entrevistas“, agora nos voltamos para o Merge Sort, um algoritmo fundamental em entrevistas de programação. O Merge Sort é conhecido por sua eficiência e abordagem divide-and-conquer. Este artigo visa desmistificar o Merge Sort, tornando-o acessível e compreensível, mesmo para quem está apenas começando na programação.
Como Funciona o Merge Sort?
O Merge Sort é um algoritmo de ordenação que se destaca pela sua eficiência e abordagem de “dividir para conquistar”. Tradicionalmente, o Merge Sort funciona dividindo repetidamente um array em duas metades até que cada subparte contenha apenas um elemento e, em seguida, fundindo essas partes de volta de maneira ordenada.
Para ilustrar como o Merge Sort organiza e funde dados, vamos usar o array [6,3,8,5,2,1] como exemplo. O desenho abaixo mostra as etapas sucessivas de divisão do array:
- Primeira Divisão: O array completo é dividido ao meio, resultando em [6,3,8] e [5,2,1], preparando o terreno para que cada parte seja processada individualmente.
- Divisões Subsequentes: Cada um desses subarrays é então dividido mais uma vez, com [6,3,8] se separando em [6,3] e [8], e [5,2,1] se separando em [5,2] e [1]. Este processo continua até que cada subarray contenha um único elemento, refletindo a natureza ordenada de unidades individuais.
- Organização: A estrutura em árvore da imagem ilustra como o Merge Sort divide o problema de ordenação em partes menores e mais gerenciáveis.
A imagem abaixo, ilustra a fase de ordenação do algoritmo Merge Sort. Após dividir o array original [6,3,8,5,2,1] em subarrays individuais, começamos a fase de fusão e ordenação:
Primeira ordenação(esquerda): Observamos a ordenação dos números 3 e 6, estabelecendo a ordem correta dentro do subarray.
Segunda ordenação(esquerda): O elemento 8 é mantido, enquanto 3 e 6 são ordenados em relação a ele, formando um subarray ordenado [3,6,8].
Terceira Ordenação(direita): Abaixo, 2 e 5 são ordenados, preparando-os para a fusão subsequente com o elemento 1.
Quarta ordenação(direita): Finalmente, a ordenação de 1, 2 e 5 ocorre, configurando a ordem perfeita dentro desse subarray.
Com as etapas anteriores completas, os subarrays [3,6,8] e [1,2,5] passam pela última ordenação. Esta fusão final é o passo decisivo onde as duas sequências ordenadas são combinadas em um único array ordenado de forma eficiente. O resultado é um array que reúne todos os elementos em uma ordem crescente perfeita: [1, 2, 3, 5, 6, 8].
Este array é o produto do processo de ordenação do Merge Sort, demonstrando a potência e a elegância deste algoritmo. Cada elemento agora está no lugar certo, proporcionando uma solução ideal para o problema de ordenação.
Implementação Otimizada do Merge Sort em Java
Na versão tradicional do Mergesort, este processo de divisão e fusão envolve criar várias cópias de subarrays. Por exemplo, se tivermos um array de oito elementos, dividimos em dois subarrays de quatro elementos, depois em subarrays de dois elementos, e assim por diante. A cada divisão, são criados novos subarrays, e cada um desses subarrays é copiado de volta para o array original na fase de fusão. Isso pode resultar em um uso significativo de memória adicional, pois cada subarray é uma cópia separada de uma parte do array original.
No entanto, a versão otimizada do Merge Sort, que exploraremos neste artigo, aborda esse desafio de maneira mais eficiente. Em vez de criar múltiplas cópias de subarrays a cada divisão, utilizamos um único array temporário e ponteiros para controlar as partes do array que estamos atualmente ordenando e fundindo. Esta abordagem reduz o uso excessivo de memória, já que evitamos a criação repetida de novos subarrays, tornando o processo mais eficiente, especialmente para grandes conjuntos de dados.
Acompanhe o código abaixo para ver como essa otimização prática é aplicada.
// Método principal para iniciar o processo de Merge Sort
private static void mergeSort(int[] array) {
// Um array temporário é usado para auxiliar na fusão dos subarrays
mergeSort(array, new int[array.length], 0, array.length - 1);
}
// Método auxiliar de Merge Sort que utiliza a abordagem de dividir para conquistar
private static void mergeSort(int[] array, int[] temp, int inicioEsquerda, int fimDireita) {
// Condição de base: se o subarray tem um único elemento, não é necessário ordenar
if (inicioEsquerda >= fimDireita) {
return;
}
// Encontra o ponto médio para dividir o array em subarrays
int meio = (inicioEsquerda + fimDireita) / 2;
// Ordena recursivamente a metade esquerda do array
mergeSort(array, temp, inicioEsquerda, meio);
// Ordena recursivamente a metade direita do array
mergeSort(array, temp, meio + 1, fimDireita);
// Funde as duas metades ordenadas
merge(array, temp, inicioEsquerda, fimDireita);
}
// Funde duas metades ordenadas de um array em um único subarray ordenado
private static void merge(int[] array, int[] temp, int inicioEsquerda, int fimDireita) {
// Calcula o fim do subarray esquerdo e o início do subarray direito
int fimEsquerda = (inicioEsquerda + fimDireita) / 2;
int inicioDireita = fimEsquerda + 1;
// Inicializa os ponteiros para acompanhar a posição atual em cada subarray e no array temporário
int esquerdaAtual = inicioEsquerda;
int direitaAtual = inicioDireita;
int tempIndice = inicioEsquerda;
// Enquanto houver elementos em ambos os subarrays, compara e ordena no array temporário
while (esquerdaAtual <= fimEsquerda && direitaAtual <= fimDireita) {
if (array[esquerdaAtual] < array[direitaAtual]) {
temp[tempIndice++] = array[esquerdaAtual++];
} else {
temp[tempIndice++] = array[direitaAtual++];
}
}
// Copia os elementos restantes do subarray esquerdo para o array temporário
System.arraycopy(array, esquerdaAtual, temp, tempIndice, fimEsquerda - esquerdaAtual + 1);
// Copia os elementos restantes do subarray direito para o array temporário
System.arraycopy(array, direitaAtual, temp, tempIndice, fimDireita - direitaAtual + 1);
// Copia os elementos do array temporário de volta para o array original
System.arraycopy(temp, inicioEsquerda, array, inicioEsquerda, fimDireita - inicioEsquerda + 1);
}
Explicação Detalhada do Merge Sort Otimizado
Inicialização:
O processo começa com a chamada da função mergeSort, que prepara o terreno para o algoritmo, criando um array temporário temp que será usado para ajudar na fusão dos subarrays. Este array é do mesmo tamanho do array original e serve como um espaço de trabalho para combinar os subarrays ordenados.
Divisão Recursiva:
A função mergeSort é chamada recursivamente para dividir o array em subarrays cada vez menores. Isso é feito encontrando o ponto médio e dividindo o array em duas metades, uma à esquerda e outra à direita do meio. Ao invés de criar novos arrays para cada subdivisão, usamos índices para manter o controle dos subarrays.
Ordenação e Fusão:
Após a divisão, a função merge é responsável por ordenar e combinar os subarrays. Ela utiliza os índices inicioEsquerda e fimDireita para saber quais partes do array principal e do array temporário temp trabalhar.
A fusão ocorre comparando os elementos dos subarrays esquerdo e direito, colocando o menor deles no array temporário. Esse processo continua até que todos os elementos dos subarrays tenham sido verificados e ordenados no array temp.
Cópia Final:
Uma vez que os subarrays são completamente percorridos e os elementos ordenados estão no array temp, fazemos uma cópia final de volta para o array original. Isso efetiva a ordenação, pois agora o array temp contém os elementos ordenados nas posições corretas.
Vantagens da Otimização
Eficiência de Memória: Ao utilizar um único array temporário e trabalhar com índices, a versão otimizada evita a alocação desnecessária de memória. Em algoritmos de ordenação, especialmente com grandes conjuntos de dados, essa economia de memória pode ter um impacto significativo no desempenho.
Análise de Complexidade do Merge Sort
Complexidade de Tempo
O Merge Sort é conhecido pela sua complexidade de tempo estável de O(n log n), independente do estado inicial do array. Isso é devido à natureza do algoritmo de dividir o array ao meio a cada etapa (o que dá o componente log n da complexidade) e depois fazer um trabalho linear de fusão (o componente n).
- Divisão
- Cada vez que dividimos o array ao meio, contribuímos para a profundidade logarítmica da recursão. Isso acontece porque dividir um array de tamanho n ao meio repetidamente levará a log n divisões até que cheguemos a arrays de tamanho 1.
- Exemplo: Um array de 8 elementos é dividido 3 vezes (2^3 = 8) até que sejam alcançados subarrays de um único elemento.
- Cada vez que dividimos o array ao meio, contribuímos para a profundidade logarítmica da recursão. Isso acontece porque dividir um array de tamanho n ao meio repetidamente levará a log n divisões até que cheguemos a arrays de tamanho 1.
- Conquista e Combinação
- Após a divisão, cada nível de recursão envolve o processo de ordenação e fusão, que é linear em relação ao número de elementos. Cada elemento é considerado e colocado em sua posição correta durante a fusão.
- Exemplo: Se tivermos 8 elementos, cada fusão no nível mais baixo da recursão considerará 2 elementos, no próximo nível 4, e assim por diante, até que o array inteiro de 8 seja considerado. Esse trabalho de fusão contribui com um fator n para cada nível de recursão.
- Após a divisão, cada nível de recursão envolve o processo de ordenação e fusão, que é linear em relação ao número de elementos. Cada elemento é considerado e colocado em sua posição correta durante a fusão.
Portanto, a complexidade de tempo do Merge Sort é O(n log n), que é derivada da multiplicação do número de níveis de divisão (log n) pelo trabalho realizado em cada nível de fusão (n).
Complexidade de Espaço
A complexidade de espaço refere-se à quantidade de memória adicional que o algoritmo usa além do espaço de memória necessário para as entradas originais. Para o Merge Sort otimizado:
- Array Temporário:
- A versão otimizada utiliza um único array temporário do mesmo tamanho do array de entrada para realizar as fusões. Portanto, a complexidade de espaço é diretamente proporcional ao tamanho do array de entrada, o que nos dá O(n).
- Isso contrasta com a abordagem clássica do Merge Sort, onde múltiplos arrays temporários podem ser criados, potencialmente levando a uma complexidade de espaço maior em implementações menos eficientes. No pior caso, a implementação tradicional pode ser O(n log n).
- Pilha de Recursão:
- Além do array temporário, precisamos considerar o espaço usado pela pilha de chamadas de função devido à natureza recursiva do algoritmo. No entanto, devido à divisão do array, a profundidade máxima da pilha de chamadas de função será log n.
- Apesar disso, a complexidade de espaço total ainda é dominada pelo array temporário, mantendo-se em O(n).
Em resumo, a complexidade de espaço para o Merge Sort otimizado é O(n), o que significa que o espaço adicional necessário para executar o algoritmo é linearmente proporcional ao tamanho do array de entrada. Isso o torna muito mais eficiente em termos de memória do que algoritmos com complexidade de espaço O(n log n) ou maior.
Estabilidade do Merge Sort
A estabilidade de um algoritmo de ordenação é uma propriedade que determina se a ordem relativa de registros iguais é mantida ou não após a ordenação. Um algoritmo é considerado estável se ele preserva a ordem relativa dos registros com chaves iguais.
O Merge Sort é um exemplo de um algoritmo de ordenação estável.
Comparação entre Merge Sort e Bubble Sort
Complexidade de Tempo
Bubble Sort: É conhecido por sua complexidade de tempo O(n²) no pior e no caso médio devido aos loops aninhados que fazem várias passagens pelo array para ordená-lo. No melhor caso, quando o array já está ordenado, sua complexidade é O(n), pois apenas uma passagem será necessária para verificar que o array está ordenado.
Merge Sort: Este algoritmo tem uma complexidade de tempo consistente de O(n log n) em todos os casos – pior, médio e melhor. Isso se deve à divisão sistemática do array e à subsequente fusão eficiente.
Complexidade de Espaço
Bubble Sort: É mais vantajoso, uma complexidade de espaço O(1), porque ele ordena o array in-place sem precisar de memória adicional significativa.
Merge Sort: Mesmo na versão otimizada, o Merge Sort tem uma complexidade de espaço de O(n) devido ao array temporário necessário para realizar as operações de fusão.
Eficiência e Velocidade
Bubble Sort: É muito simples de implementar e entender. É um bom algoritmo didático para ensinar os conceitos básicos de ordenação e algoritmos, mas raramente é usado em aplicações práticas devido à sua lentidão.
Merge Sort: Embora seja mais complexo que o Bubble Sort, o Merge Sort é mais prático para aplicativos do mundo real onde a eficiência é crucial, como em sistemas de banco de dados e algoritmos de busca.
Conclusão
Em nossa jornada através do Merge Sort, exploramos as nuances deste poderoso algoritmo de ordenação. Desde sua eficiente abordagem divide-and-conquer até a implementação otimizada em Java, o Merge Sort se destaca como uma ferramenta essencial no arsenal de qualquer programador.
Com sua complexidade de tempo constante O(n log n) e a capacidade de lidar com grandes conjuntos de dados de maneira eficiente, o Merge Sort é uma escolha superior para muitas aplicações práticas, especialmente aquelas onde a estabilidade e a previsibilidade do desempenho são críticas.
Ao compará-lo com o Bubble Sort, vimos como o Merge Sort supera em eficiência, especialmente em arrays maiores, apesar de ser um pouco mais complexo em termos de implementação. Esta comparação ilustra a importância de escolher o algoritmo certo para o problema certo.
Esperamos que este artigo da “Série Preparação para Entrevistas” tenha esclarecido o Merge Sort, fornecendo uma compreensão sólida de seu funcionamento, eficiência e aplicabilidade. Com esta base, você está agora mais preparado para enfrentar desafios de ordenação em entrevistas de programação e além.
Próximos Passos: Aplicando e Compartilhando seu Conhecimento: Agora que você tem um entendimento abrangente do Merge Sort, o desafio está lançado! Experimente implementar e otimizar o Merge Sort em sua linguagem de programação preferida. Teste sua eficácia com diferentes tamanhos de arrays e compare-o com outros algoritmos de ordenação. Se você tiver dúvidas, ideias ou insights, compartilhe-os nos comentários abaixo. Adoraríamos ouvir sobre suas experiências e descobertas, e se este guia foi útil na sua jornada de aprendizado e preparação para entrevistas. Juntos, podemos continuar crescendo e aprendendo como uma comunidade de programadores apaixonados.
Referências
Cracking the Coding Interview’ é um best-seller renomado e um favorito pessoal – um livro indispensável na preparação para entrevistas técnicas!
Designing Data-Intensive Applications: é uma referência para criar aplicações escaláveis, robustas e de alto desempenho!




Pingback: Série Preparação para Entrevistas: #6 Quick Sort - Dominando a Ordenação Eficiente - A Arte de Programar
Pingback: Série Preparação para Entrevistas: #9 - Merge de Dois Arrays Ordenados - A Arte de Programar