- Publicado em
Minha Obsessão pelo LeetCode 41: Um Mergulho Profundo no Enigma do Primeiro Positivo que Falta
- Autores
- Nome
- Imamuzzaki Abu Salam
- https://x.com/ImBIOS_Dev
O problema "First Missing Positive" do LeetCode (número 41) se tornou minha mais recente obsessão em programação. É um quebra-cabeça enganosamente simples que consumiu meus pensamentos, me levando a explorar suas intrincadas nuances e a criar a solução mais eficiente possível em TypeScript. Este post não é apenas sobre dominar entrevistas de codificação (embora isso seja um bom benefício). É sobre a alegria pura da resolução de problemas, a emoção da otimização e a beleza elegante de um código eficiente. E, como sou um pouco nerd em matemática, iremos até mesmo mergulhar nos fundamentos matemáticos da solução.
O Problema: Um Lobo em Pele de Cordeiro
O problema apresenta um array não ordenado de inteiros. Sua tarefa: encontrar o menor inteiro positivo faltante. Parece direto? Pense novamente. As restrições de tempo e complexidade de espaço transformam essa tarefa aparentemente simples em um desafio algorítmico.
Dificuldades Iniciais: A Armadilha da Força Bruta
Minhas tentativas iniciais envolveram ordenação (violando a restrição de tempo ) e conjuntos hash (excedendo o limite de espaço ). Claramente, uma abordagem mais sofisticada era necessária.
O Momento Eureka: Transformação In-Loco
A chave foi perceber que eu poderia usar o próprio array de entrada como uma tabela hash. Rearranjando os elementos, eu poderia colocar o número no índice (se , onde é o comprimento do array). Essa transformação in-loco destranca o caminho para uma solução eficiente.
Evolução do Código: Uma Saga TypeScript
Aqui está a crônica da evolução do meu código, com explicações detalhadas e insights matemáticos:
Versão 1: A Abordagem Ingênua (com Trocas Redundantes)
function firstMissingPositive(nums: number[]): number {
const n = nums.length
// Coloca cada número em sua posição correta se estiver no intervalo [1, n]
for (let i = 0; i < n; i++) {
while (nums[i] > 0 && nums[i] <= n && nums[nums[i] - 1] !== nums[i]) {
const correctIndex = nums[i] - 1
;[nums[i], nums[correctIndex]] = [nums[correctIndex], nums[i]] // Troca
}
}
// Encontra o primeiro positivo faltante
for (let i = 0; i < n; i++) {
if (nums[i] !== i + 1) {
return i + 1
}
}
// Se todos os números estão em suas posições corretas, o número faltante é n + 1
return n + 1
}
Esta versão, embora funcional, sofre com trocas redundantes. O número de trocas no pior cenário se aproxima de , embora o caso médio esteja mais próximo de .
Explicação:
Rearranja o array: Cada número
nums[i]
é colocado em seu índice "correto" (nums[i] - 1
) se estiver no intervalo[1, n]
e não estiver já na posição correta.Identifica o positivo faltante: Após o rearranjo, o primeiro índice
i
ondenums[i] !== i + 1
indica quei + 1
é o inteiro positivo faltante.Retorna n + 1 se necessário: Se todos os números de
1
an
estão em suas posições corretas, o número faltante én + 1
.
Pontos Chave:
- Rearranjo in-loco: Modificamos o próprio array para evitar o uso de espaço extra.
- Eficiência: Tanto o rearranjo quanto a varredura final levam tempo, garantindo desempenho ótimo.
Versão 2: Aumento de Desempenho (Eliminando Trocas Redundantes)
function firstMissingPositive(nums: number[]): number {
const n = nums.length
let idx = 0
// Rearranja os números para suas posições corretas se possível
while (idx < n) {
const correctIdx = nums[idx] - 1
if (correctIdx >= 0 && correctIdx < n && nums[idx] !== nums[correctIdx]) {
// Troca sem usar uma variável temporária
;[nums[idx], nums[correctIdx]] = [nums[correctIdx], nums[idx]]
} else {
idx++
}
}
// Identifica o primeiro positivo faltante
for (let i = 0; i < n; i++) {
if (nums[i] !== i + 1) {
return i + 1
}
}
return n + 1
}
Explicação:
Rearranjo Eficiente:
- O laço
while
troca elementos diretamente para suas posições corretas (nums[idx] - 1
) somente quando o número está no intervalo válido[1, n]
e não está já em sua posição correta. - A condição
if
garante que números inválidos ou já corretos sejam ignorados sem trocas desnecessárias.
- O laço
Verificação Direta do Índice:
- Durante a fase de rearranjo, trocas inválidas e operações redundantes são evitadas verificando o intervalo e o valor de
nums[idx]
.
- Durante a fase de rearranjo, trocas inválidas e operações redundantes são evitadas verificando o intervalo e o valor de
Troca Eficiente em Memória:
- O uso de desestruturação (
[nums[idx], nums[correctIdx]] = [nums[correctIdx], nums[idx]]
) elimina a necessidade de uma variável temporária, minimizando o uso de memória.
- O uso de desestruturação (
Loops Mínimos:
- O algoritmo usa apenas duas passagens lineares:
- Uma para rearranjar o array in-loco.
- Outra para encontrar o primeiro positivo faltante.
- O algoritmo usa apenas duas passagens lineares:
Análise de Complexidade:
- Complexidade de Tempo:
- O laço
while
e o laçofor
rodam em , tornando a complexidade de tempo total .
- O laço
- Complexidade de Espaço:
- O algoritmo opera in-loco sem usar estruturas de dados auxiliares, então a complexidade de espaço é .
Por que este Código é Ótimo:
- Desempenho: O laço
while
otimizado garante operações redundantes mínimas, maximizando a velocidade. - Eficiência de Memória: O uso de troca in-loco e a evitação de variáveis extras garantem a menor pegada de memória possível.
Versão 3: Otimizada para Memória (Uso Mínimo de Espaço)
function firstMissingPositive(nums: number[]): number {
const n = nums.length
let idx = 0
while (idx < n) {
const correctIdx = nums[idx] - 1
if (correctIdx >= 0 && correctIdx < n && nums[idx] !== nums[correctIdx]) {
nums[idx] = nums[correctIdx]
nums[correctIdx] = correctIdx + 1
} else {
idx++
}
}
for (let idx = 0; idx < n; idx++) {
if (nums[idx] !== idx + 1) {
return idx + 1
}
}
return nums.length + 1
}
Otimizações Chave de Memória:
Atualizações In-Loco com Variáveis Temporárias Mínimas:
- Em vez de usar uma variável
temp
para troca, este código modifica diretamente o array comnums[idx] = nums[correctIdx]; nums[correctIdx] = correctIdx + 1
. - Isso elimina a necessidade de uma variável temporária, reduzindo a sobrecarga de memória por uma quantidade constante.
- Em vez de usar uma variável
Menos Valores Temporários no Laço:
- O código calcula
correctIdx
diretamente no laço, evitando a necessidade de armazenar variáveis intermediárias adicionais fora da lógica principal. - Menos atribuições de variáveis significam um uso de memória ligeiramente menor por iteração.
- O código calcula
Passagem Única para Rearranjo:
- Ao contrário do primeiro código que usa condições aninhadas no laço
while
, esta implementação completa as trocas ou avança o índice de maneira mais simplificada, reduzindo a profundidade da pilha de execução e o uso de memória. - A estrutura do laço (
while
com um único incremento ou rearranjo) é mais direta, requerendo menos valores auxiliares.
- Ao contrário do primeiro código que usa condições aninhadas no laço
Sem Verificações Redundantes:
- As verificações
correctIdx >= 0 && correctIdx < n && nums[idx] !== nums[correctIdx]
são concisas e impedem operações desnecessárias que poderiam usar temporariamente memória de pilha ou cache da CPU. - Isso evita a necessidade de alocar recursos extras para operações que não contribuirão para o resultado.
- As verificações
Por que Isso Faz a Diferença na Memória:
- Armazenamento Temporário Reduzido: Ao não depender de uma variável
temp
e minimizar os cálculos intermediários, a pegada de memória é menor. - Execução Simplificada: Menos etapas e lógica mais simples resultam em menos uso de memória por operação.
- Utilização Melhorada do Cache: O padrão de acesso linear e previsível do algoritmo melhora o desempenho do cache da CPU, reduzindo indiretamente a sobrecarga de memória.
O foco desta solução em atualizações in-loco diretas, uso mínimo de variáveis e padrões eficientes de acesso à memória a tornam a melhor em termos de eficiência de memória. Embora as economias sejam constantes (), elas são significativas o suficiente para se registrar em plataformas competitivas como o LeetCode, especialmente para conjuntos de dados grandes.
Versão 4: A Obra-Prima Otimizada (Troca In-Loco, Sem Variável Temp)
function firstMissingPositive(nums: number[]): number {
for (let i = 0; i < nums.length; i++) {
while (nums[i] !== i + 1) {
if (nums[i] <= 0 || nums[i] > nums.length || nums[i] === nums[nums[i] - 1]) {
break
}
const temp = nums[i]
nums[i] = nums[temp - 1]
nums[temp - 1] = temp
}
}
for (let i = 0; i < nums.length; i++) {
if (nums[i] !== i + 1) {
return i + 1
}
}
return nums.length + 1
}
Esta versão final alcança tanto desempenho ótimo quanto uso mínimo de memória através de troca in-loco elegante sem uma variável temporária. A complexidade de tempo agora é firmemente , e a complexidade de espaço é , atendendo a todos os requisitos. Matematicamente, estamos realizando um tipo de permutação cíclica dentro do array para colocar os elementos em suas posições "corretas".
Ao adicionar uma verificação (nums[i] === nums[nums[i] - 1]
), prevenimos trocas desnecessárias. Isso melhora significativamente o desempenho, aproximando a complexidade de tempo do desejado.
Diferenças Chave na Implementação:
Evitar Trocas Redundantes:
- O código fornecido verifica explicitamente se
nums[i] === nums[nums[i] - 1]
antes de realizar a troca. Isso evita trocas desnecessárias quando o número já está na posição correta ou existem duplicatas. - Na primeira implementação, esta verificação é omitida, potencialmente levando a trocas redundantes mesmo quando desnecessárias. Cada troca adicional adiciona sobrecarga, especialmente para arrays maiores.
- O código fornecido verifica explicitamente se
Comparação Direta do Índice:
- O código fornecido usa
while (nums[i] !== i + 1)
para garantir que um número seja repetidamente trocado para sua posição correta até que esteja corretamente colocado ou uma condição inválida seja atendida. Isso elimina iterações desnecessárias do laço. - O primeiro código não compara explicitamente o número ao seu índice pretendido dentro da condição do laço. Ele pode executar mais operações em casos em que os números precisam de movimento mínimo.
- O código fornecido usa
Verificações Condicionais Otimizadas:
- Ao combinar condições em um único bloco
if
(por exemplo,nums[i] <= 0 || nums[i] > nums.length || nums[i] === nums[nums[i] - 1]
), o código evita sobrecarga adicional de verificações ou cálculos desnecessários.
- Ao combinar condições em um único bloco
Por que Isso Importa no LeetCode:
Consistência do Tempo de Execução:
- As medições do tempo de execução do LeetCode podem ser sensíveis a operações desnecessárias, como trocas redundantes ou comparações extras.
- O código fornecido minimiza essas operações, levando a um tempo de execução mais rápido em média.
Eficiência do Cache:
- Menos trocas e lógica de laço mais simples resultam em melhor utilização do cache da CPU. Os processadores modernos se beneficiam de padrões de acesso previsíveis e simplificados, que o código fornecido apresenta.
Resultados
Aqui está o resumo apresentado em formato de tabela:
Tentativa | Tempo de Execução | Uso de Memória | Notas |
---|---|---|---|
4 (Otimizada) | 1 ms | 58.8 MB | Melhor equilíbrio de desempenho e memória. |
3 (Melhor Memória) | 2 ms | 58.5 MB | Ligeiramente mais lento, mas menor uso de memória. |
2 (Melhor Desempenho) | 1 ms | 58.9 MB | Tempo de execução mais rápido. |
1 (Tentativa Inicial) | 3 ms | 59 MB | Mais lento e maior uso de memória. |
Esta tabela destaca as compensações e a otimalidade da solução final.
O resumo indica que a tentativa para o melhor desempenho e memória é de fato a mais otimizada, alcançando:
- Tempo de execução de 1 ms: Correspondendo ao resultado mais rápido em termos de desempenho.
- Uso de memória de 58.9 MB: Ligeiramente superior ao clone "melhor memória" (58.5 MB), mas inferior a outras tentativas.
Análise:
Tempo de Execução:
- O tempo de execução de 1 ms mostra que a abordagem otimizada elimina verificações redundantes e trocas desnecessárias.
- A complexidade de tempo consistente de garante escalabilidade para conjuntos de dados maiores.
Memória:
- 58.9 MB é competitivo, mostrando que a pegada de memória é baixa apesar da execução rápida. Este pequeno aumento em relação à "melhor memória" (58.5 MB) pode ser devido a diferenças na desestruturação ou otimizações específicas do mecanismo.
Compensações:
- A solução "melhor memória" sacrifica ligeiramente o tempo de execução para menor uso de memória.
- A solução "melhor desempenho" se concentra mais na velocidade, mas usa marginalmente mais memória.
Conclusão:
A solução otimizada atinge um bom equilíbrio:
- Tempo de execução de 1 ms é tão rápido quanto o código de melhor desempenho.
- Uso de memória de 58.9 MB está próximo do melhor resultado de memória, com sobrecarga insignificante.
É uma escolha completa, especialmente para cenários em que tanto o desempenho quanto a memória são críticos.
Fundamentos Matemáticos: Permutações Cíclicas e Princípio da Casa dos Pombos
A ideia central gira em torno do conceito de permutações cíclicas. Nosso objetivo é criar um ciclo onde cada número é colocado no índice . O laço while
percorre efetivamente esses ciclos, garantindo que cada elemento encontre seu lugar designado.
O Princípio da Casa dos Pombos desempenha sutilmente um papel aqui. Como temos posições possíveis (índices a ) e estamos procurando um inteiro positivo faltante no intervalo , se todos os números de 1 a estiverem presentes, o número faltante deve ser .
A Obsessão Continua...
Minha fascinação pelo LeetCode 41 permanece. Estou constantemente buscando otimizações adicionais e insights mais profundos. Junte-se a mim nesta busca! Compartilhe seus pensamentos, suas próprias soluções e vamos explorar juntos a elegante interseção entre matemática e algoritmos.