Publicado em

Minha Obsessão pelo LeetCode 41: Um Mergulho Profundo no Enigma do Primeiro Positivo que Falta

Autores

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 O(n)O(n) e complexidade de espaço O(1)O(1) 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 O(n)O(n)) e conjuntos hash (excedendo o limite de espaço O(1)O(1)). 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 xx no índice x1x - 1 (se 1xn1 \le x \le n, onde nn é 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 O(n2)O(n^2), embora o caso médio esteja mais próximo de O(n)O(n).

Explicação:

  1. 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.

  2. Identifica o positivo faltante: Após o rearranjo, o primeiro índice i onde nums[i] !== i + 1 indica que i + 1 é o inteiro positivo faltante.

  3. Retorna n + 1 se necessário: Se todos os números de 1 a n 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 O(n)O(n) 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:

  1. 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.
  2. 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].
  3. 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.
  4. Loops Mínimos:

    • O algoritmo usa apenas duas passagens lineares:
      • Uma para rearranjar o array in-loco.
      • Outra para encontrar o primeiro positivo faltante.

Análise de Complexidade:

  • Complexidade de Tempo:
    • O laço while e o laço for rodam em O(n)O(n), tornando a complexidade de tempo total O(n)O(n).
  • Complexidade de Espaço:
    • O algoritmo opera in-loco sem usar estruturas de dados auxiliares, então a complexidade de espaço é O(1)O(1).

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:

  1. 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 com nums[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.
  2. 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.
  3. 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.
  4. 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.

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 (O(1)O(1)), 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 O(n)O(n), e a complexidade de espaço é O(1)O(1), 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 O(n)O(n) desejado.

Diferenças Chave na Implementação:

  1. 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.
  2. 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.
  3. 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.

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:

TentativaTempo de ExecuçãoUso de MemóriaNotas
4 (Otimizada)1 ms58.8 MBMelhor equilíbrio de desempenho e memória.
3 (Melhor Memória)2 ms58.5 MBLigeiramente mais lento, mas menor uso de memória.
2 (Melhor Desempenho)1 ms58.9 MBTempo de execução mais rápido.
1 (Tentativa Inicial)3 ms59 MBMais 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:

  1. 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 O(n)O(n) garante escalabilidade para conjuntos de dados maiores.
  2. 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.
  3. 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 xx é colocado no índice x1x - 1. 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 nn posições possíveis (índices 00 a n1n-1) e estamos procurando um inteiro positivo faltante no intervalo [1,n+1][1, n+1], se todos os números de 1 a nn estiverem presentes, o número faltante deve ser n+1n + 1.

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.