Two Pointers

18 min de leituraFácilPython

Two Pointers é o primeiro truque que transforma um O(n²) em O(n) sem gastar memória nenhuma. Em vez de dois laços aninhados testando todo par possível, você usa dois índices que caminham de forma coordenada e resolve em uma passada. É a base da Sliding Window, da detecção de ciclo em lista ligada e de boa parte dos problemas de array que caem em entrevista.

Uma técnica, não um algoritmo

Vale começar por uma distinção que muda o jeito de estudar o assunto. Um algoritmo é uma sequência definida de passos: o Bubble Sort é sempre o Bubble Sort, o Merge Sort é sempre o Merge Sort. Two Pointers não é isso, e também não é uma estrutura de dados, porque não diz nada sobre como os dados ficam organizados na memória. É uma técnica: um jeito de abordar o problema, que aparece com formatos bem diferentes dependendo do que você precisa responder.

A definição é curta: você mantém dois índices apontando para posições diferentes e move cada um segundo uma regra que depende do que está lendo. O ganho vem sempre da mesma coisa: a cada passo você elimina possibilidades sem precisar testá-las.

Os nomes variam (i e j, left e right, p1 e p2, lento e rapido), mas os formatos que aparecem na prática são poucos:

Convergentes

Um ponteiro em cada ponta, caminhando um na direção do outro até se encontrarem. Costuma exigir ordem nos dados: Two Sum em array ordenado, palíndromo, container com mais água.

Mesma direção

Os dois começam à esquerda e andam em ritmos diferentes: leitor e escritor numa remoção in-place, lento e rápido numa lista ligada, começo e fim de uma janela.

Existe ainda um terceiro caso, que confunde no começo: um ponteiro em cada entrada. Quando o problema traz dois arrays ou duas strings (verificar se uma é subsequência da outra, fazer o merge de duas listas ordenadas), cada estrutura ganha o seu índice. Continua sendo Two Pointers, mesmo com os dois começando na posição zero.

Por que quase sempre array e string? Porque as duas dão acesso aleatório: nums[i] custa O(1) e você pula de qualquer posição para qualquer outra. Numa lista simplesmente ligada não dá para colocar um ponteiro no fim e andar para trás, porque o nó não sabe quem veio antes dele. Só uma lista duplamente ligada permitiria isso. Por consequência, o sabor que sobra para listas encadeadas é o de mesma direção, com um ponteiro andando mais rápido que o outro.

Em entrevista, diga o nome da especialização. Sliding Window é Two Pointers, detecção de ciclo é Two Pointers, mas se você chamar tudo de "dois ponteiros" o entrevistador pode achar que você não reconhece o padrão específico. Fale "janela deslizante" quando for janela e "ponteiro rápido e lento" quando for Floyd.

O que custa testar todos os pares

O problema motivador é o clássico: dado um array ordenado e um alvo, existem dois números que somam o alvo?

A primeira solução que vem à cabeça testa todo mundo com todo mundo:

Python
def dois_numeros_forca_bruta(nums, alvo):
    for i in range(len(nums)):
        for j in range(i + 1, len(nums)):
            if nums[i] + nums[j] == alvo:
                return [i, j]
    return []

Funciona, e é O(n²). O número exato de pares que esse código percorre no pior caso é n(n-1)/2:

Tamanho da entradaPares testadosFamília
n = 828O(n²)
n = 1.000499.500O(n²)
n = 1.000.000499.999.500.000O(n²)

Repare no que essa solução joga fora: ela sabe que o array está ordenado e não usa isso para nada. Testar nums[0] + nums[1] depois de já ter descoberto que nums[0] + nums[7] ficou pequeno demais é trabalho jogado no lixo, porque nums[1] é menor que nums[7] e a soma só pode ficar pior. É essa informação desperdiçada que o Two Pointers transforma em velocidade.

Convergentes: o Two Sum em array ordenado

A regra cabe em três linhas. Um ponteiro na primeira posição, outro na última, e olhe a soma:

  • Soma maior que o alvo: o único jeito de diminuir é trocar o número grande por um menor, então recue a direita.
  • Soma menor que o alvo: o único jeito de aumentar é trocar o número pequeno por um maior, então avance a esquerda.
  • Soma igual ao alvo: achou, pode devolver.

Com o array [1, 2, 3, 6, 8, 10, 20, 21] e alvo 16, a busca inteira cabe em seis somas:

PassoContaSomaDecisão
11 + 212222 > 16, recua a direita
21 + 202121 > 16, recua a direita
31 + 101111 < 16, avança a esquerda
42 + 101212 < 16, avança a esquerda
53 + 101313 < 16, avança a esquerda
66 + 1016achou, índices 3 e 5

Seis somas contra os 28 pares da força bruta, no mesmo array. O código é quase a tabela escrita em Python:

Python
def dois_ponteiros(nums, alvo):
    esquerda = 0
    direita = len(nums) - 1
    while esquerda < direita:
        soma = nums[esquerda] + nums[direita]
        if soma == alvo:
            return [esquerda, direita]
        if soma < alvo:
            esquerda += 1
        else:
            direita -= 1
    return []
Visualizador · ponteiros convergentes: dois números que somam o alvo
passo 1 de 13
0
1
E
1
2
·
2
3
·
3
6
·
4
8
·
5
10
·
6
20
·
7
21
D

esquerda no início, direita no fim do array ordenado.

solucao.py
1def dois_ponteiros(nums, alvo):
2 esquerda = 0
3 direita = len(nums) - 1
4 while esquerda < direita:
5 soma = nums[esquerda] + nums[direita]
6 if soma == alvo:
7 return [esquerda, direita]
8 if soma < alvo:
9 esquerda += 1
10 else:
11 direita -= 1
12 return []
Variáveis
esquerda0
direita7
soma-
alvo16
tamanho (n)8
somas avaliadas0
pares na força bruta28
memória extraO(1)

passo · espaço roda

Rode o visualizador passo a passo e acompanhe dois números do painel de baixo ao mesmo tempo: somas avaliadas e pares na força bruta. Com o preset "Alvo 16: os dois ponteiros andam" ele termina em 6 contra 28. Depois experimente:

  • Acerta de cara: alvo 22. O par está exatamente nas duas pontas, então uma única soma resolve. É o melhor caso, O(1).
  • Sem solução: alvo 100. Nenhum par existe e, mesmo assim, foram só 7 somas, ou seja n - 1. Esse é o pior caso da técnica: cada soma queima um índice de vez, e depois de n - 1 delas os ponteiros se encontram. A força bruta teria feito as 28 do mesmo jeito.
  • Tudo igual: alvo 11. Tente prever antes de rodar. Com [5, 5, 5, 5] toda soma dá 10, nunca 11. O algoritmo não trava nem repete: avança a esquerda três vezes e encerra.
  • Digite um array fora de ordem no campo de entrada. Ele é reordenado sozinho, de propósito, porque é exatamente o que você teria que fazer antes de aplicar a técnica. Sem ordem, o passo a passo não faz sentido nenhum.
  • Sortear monta um array e um alvo novos, que às vezes nem têm solução. Use para treinar a previsão: antes de apertar Rodar, diga em voz alta quantas somas vão sair. Você vai errar por cima nas primeiras vezes, porque a intuição ainda está calibrada na força bruta.

Por que mover um ponteiro só não perde solução

Esta é a parte que separa quem decorou de quem entendeu. Mover um ponteiro por vez parece arriscado: e se a resposta estivesse justamente no par que você descartou sem testar?

Não estava, e o motivo é a ordenação. Chame de E e D os índices atuais.

  • Se soma > alvo, então nums[D] é grande demais para qualquer parceiro à direita de E. Como nums[E] já é o menor valor ainda disponível, todo par formado com D só pode dar uma soma maior ou igual à atual. Logo o índice D está esgotado, e recuar a direita não perde nada.
  • Se soma < alvo, o argumento é simétrico: nums[D] já é o maior valor disponível, então nums[E] não fecha com ninguém. O índice E está esgotado, e avançar a esquerda não perde nada.

Cada passo elimina uma linha ou uma coluna inteira da tabela de pares, e não um par só. É por isso que n - 1 somas bastam para cobrir os n(n-1)/2 pares possíveis.

Esse mesmo raciocínio explica por que a ordenação é obrigatória. Num array desordenado, "a soma ficou grande" não diz qual dos dois lados é o culpado, e mover qualquer um dos ponteiros pode descartar a resposta certa.

Sobre o custo, dois pontos:

  • Tempo O(n). Cada índice é visitado no máximo uma vez, e os dois ponteiros juntos percorrem o array inteiro. Como cada iteração move um dos dois, o laço roda no máximo n - 1 vezes. Na prática cada ponteiro anda metade do array, mas n/2 é O(n): a constante cai, como em qualquer conta de Big O.
  • Espaço O(1). Não existe estrutura auxiliar, só duas variáveis inteiras. Sejam 10 elementos ou 1 milhão, o consumo extra é o mesmo.

Vale guardar a ressalva: a notação assintótica não conta tudo. Dois algoritmos O(n) podem ter constantes bem diferentes, e andar metade do array é, na vida real, duas vezes mais rápido que andar o array inteiro. O Big O classifica a curva, não cronometra o relógio.

O mesmo argumento sem soma nenhuma: Container With Most Water

O raciocínio acima não é sobre soma, é sobre conseguir provar que um índice está esgotado. Trocar a soma por outra função mantém a técnica de pé, e o Container With Most Water é o exemplo canônico disso.

O enunciado dá alturas de barras verticais e pergunta qual par delas segura mais água. A área entre E e D é:

Python
area = min(altura[E], altura[D]) * (D - E)

A largura é a distância entre os índices e a altura é limitada pela barra menor, porque a água transborda por cima dela. E é justamente isso que dá a regra de movimento: mova sempre o ponteiro da barra menor.

A prova tem duas partes. Qualquer movimento diminui a largura, isso é inevitável, porque os ponteiros só andam para dentro. Então, para melhorar a área, é preciso ganhar altura. Se você mantiver a barra menor e mover a outra, a altura continua limitada pela mesma barra menor (ou piora), e a largura encolheu: a área só pode cair. Logo todo par que ainda envolve a barra menor está esgotado, e descartá-la não perde a resposta.

Com o array clássico [1, 8, 6, 2, 5, 4, 8, 3, 7], os dois ponteiros varrem tudo em 8 avaliações contra os 36 pares da força bruta, e a melhor área aparece logo na segunda:

PassoE (altura)D (altura)LarguraÁreaDecisão
10 (1)8 (7)88a barra 1 é a menor, avança a esquerda
21 (8)8 (7)749a barra 7 é a menor, recua a direita
31 (8)7 (3)618recua a direita
41 (8)6 (8)540empate, recua a direita

Repare no passo 4: quando as duas alturas empatam, tanto faz qual você move. Nesse caso as duas barras estão esgotadas ao mesmo tempo, porque qualquer par futuro com uma delas tem largura menor e altura no máximo igual. Por isso if altura[esq] < altura[dir] e if altura[esq] <= altura[dir] dão a mesma resposta, e dá até para mover os dois de uma vez.

Container With Most Water é o contraexemplo perfeito para a regra "Two Pointers exige array ordenado". Aqui a entrada é desordenada de propósito, e ordenar destruiria o problema, porque a largura depende dos índices originais. O que a técnica exige de verdade é um argumento de descarte, e a ordenação é só a forma mais comum de conseguir um.

Palíndromo: dois ponteiros em ritmos diferentes

Palavra palíndroma é aquela que se lê igual de trás para frente. Com dois ponteiros convergentes, a verificação é imediata: compare as pontas, feche os dois ponteiros, repita.

  • arara: a com a, depois r com r, os ponteiros se encontram no a do meio. É palíndromo, com 2 comparações.
  • banana: b com a já na primeira comparação. Não é palíndromo, e o algoritmo para na primeira iteração, sem olhar o resto.
  • Ana: A com a, ignorando maiúscula. Uma comparação e acabou.

Repare na condição do laço: esq < dir, com < estrito. Ela cobre os dois formatos de palavra sem nenhum if a mais. Numa palavra ímpar os ponteiros param no mesmo caractere, o do meio, e ele não precisa ser comparado porque é palíndromo de si mesmo. Numa palavra par eles se cruzam (esq fica um passo à frente de dir) e não sobra nada no meio. Trocar por <= só acrescentaria uma comparação de um caractere com ele mesmo, que é sempre verdadeira.

O problema fica interessante quando o enunciado manda ignorar espaço, pontuação e maiúscula, que é o caso do clássico "A man, a plan, a canal: Panama". A saída óbvia é limpar a string antes:

Python
def e_palindromo_ingenuo(s):
    limpa = ""
    for c in s:
        if c.isalnum():
            limpa += c.lower()
    return limpa == limpa[::-1]

Correto e legível, mas com dois custos escondidos. Primeiro, string em Python é imutável: cada concatenação cria uma string nova, e limpa[::-1] cria mais uma. A memória extra vira O(n), justamente o que a técnica prometia evitar. Segundo, é por aí que a limpeza anda em círculos: você troca espaço, depois vírgula, depois dois pontos, e sempre falta um caractere novo.

A saída é não tocar na string. Se o caractere atual não interessa, pule só aquele ponteiro e deixe o outro parado. É aqui que está o insight principal: os dois ponteiros não precisam andar no mesmo ritmo.

Python
def e_palindromo(s):
    esq, dir = 0, len(s) - 1
    while esq < dir:
        if not s[esq].isalnum():
            esq += 1
        elif not s[dir].isalnum():
            dir -= 1
        elif s[esq].lower() != s[dir].lower():
            return False
        else:
            esq += 1
            dir -= 1
    return True
Visualizador · palíndromo com ponteiros em ritmos diferentes
passo 1 de 31
0
A
E
1
·
2
m
·
3
a
·
4
n
·
5
,
·
6
·
7
a
·
8
·
9
p
·
10
l
·
11
a
·
12
n
·
13
,
·
14
·
15
a
·
16
·
17
c
·
18
a
·
19
n
·
20
a
·
21
l
·
22
:
·
23
·
24
P
·
25
a
·
26
n
·
27
a
·
28
m
·
29
a
D

caractere que não é letra nem dígito, o ponteiro pula por cima posição atual de esq e dir

esq no caractere 0 e dir no caractere 29. Vou fechando os dois até eles se encontrarem.

palindromo.py
1def e_palindromo(s):
2 esq, dir = 0, len(s) - 1
3 while esq < dir:
4 if not s[esq].isalnum():
5 esq += 1
6 elif not s[dir].isalnum():
7 dir -= 1
8 elif s[esq].lower() != s[dir].lower():
9 return False
10 else:
11 esq += 1
12 dir -= 1
13 return True
Variáveis
esq0
dir29
s[esq]"A"
s[dir]"a"
caracteres (n)30
comparações0
saltos de pontuação0
strings novas aqui0
strings novas limpando antes22

passo · espaço roda

O visualizador começa com a frase do Panamá, que tem 30 caracteres. Rode até o fim e compare os dois últimos cartões do painel: strings novas aqui = 0 contra strings novas limpando antes = 22. Esses 22 são as 21 concatenações do limpa += c.lower(), uma por caractere aproveitado, mais a cópia invertida do limpa[::-1]. É a diferença entre O(1) e O(n) de espaço, com número em cima da mesa. Confira também as 10 comparações e os 9 saltos: os saltos são exatamente os 9 caracteres que não são letra nem dígito (as vírgulas, os espaços e os dois pontos), e cada um deles move um ponteiro só, deixando o outro parado. Depois:

  • Clique em banana (falha no passo 1) e repare que a nota fica vermelha logo no passo 2 da animação. Falhar cedo é uma vantagem: você não paga o custo do resto da entrada.
  • Clique em race a car (falha no fim) para ver o contrário. Foram 4 comparações e 1 salto antes de descobrir que e não bate com a. Mesmo no pior caso a conta é n/2, não .
  • Clique em arara e conte as células: com 5 caracteres, só 2 comparações. Um palíndromo de n caracteres custa n/2 comparações, não n.
  • Clique em vazio (caso de borda). A string vazia é palíndromo por vacuidade, e repare que o while nem chega a rodar, porque esq = 0 já não é menor que dir = -1. Nenhum if extra foi preciso: a condição do laço cuida do caso sozinha, e é isso que você quer conferir em toda solução de dois ponteiros antes de submeter.
  • Antes de clicar em 0P, responda: "0P" é palíndromo? A resposta é não, 0 é diferente de p. Guarde isso, o próximo bloco explica por que essa entrada é famosa.

A pegadinha do % 32. Existe um atalho tentador para comparar letras ignorando a caixa: na tabela ASCII, A é 65 e a é 97, e como 65 % 32 e 97 % 32 dão os dois 1, o resto da divisão por 32 "normalizaria" a caixa de graça. Funciona para as 26 letras, mas o conjunto do problema tem 36 símbolos, contando os 10 dígitos. E aí P vale 80 e 0 vale 48, que dão 16 os dois. É exatamente por isso que o LeetCode tem "0P" entre os casos de teste: o atalho responde "é palíndromo" e a resposta certa é "não é". Micro-otimização que muda o resultado não é otimização, é bug.

Mesma direção: o leitor e o escritor

No sabor convergente os ponteiros vêm das pontas. No sabor de mesma direção, os dois saem da esquerda e um anda mais rápido que o outro. O uso mais comum é a remoção in-place, onde eles ganham nomes melhores: leitor e escritor.

O leitor percorre o array inteiro, sem pular nada. O escritor só anda quando encontra algo que merece ficar. No fim, o prefixo até o escritor é a resposta, e nenhum array novo foi alocado.

Python
def remove_duplicados(nums):
    if not nums:
        return 0
    escrita = 1
    for leitura in range(1, len(nums)):
        if nums[leitura] != nums[escrita - 1]:
            nums[escrita] = nums[leitura]
            escrita += 1
    return escrita

Com [0, 0, 1, 1, 1, 2, 2, 3, 3, 4], o leitor dá 9 passos, o escritor para em 5, e as cinco primeiras posições viram [0, 1, 2, 3, 4]. O resto do array continua com lixo, e é por isso que esse tipo de problema pede o tamanho de volta, não o array.

O mesmo formato resolve o caso das duas entradas, com um ponteiro em cada uma. Verificar se uma palavra é subsequência da outra, por exemplo:

Python
def e_subsequencia(pequena, grande):
    i = j = 0
    while i < len(pequena) and j < len(grande):
        if pequena[i] == grande[j]:
            i += 1
        j += 1
    return i == len(pequena)

"ana" é subsequência de "banana": o ponteiro da esquerda anda quando bate a letra, o da direita anda sempre. E "ann" também é, porque subsequência não exige que as letras sejam vizinhas, só que a ordem seja respeitada. Já "ann" não é substring de "banana", porque substring é fatia contígua. Aqui as duas entradas têm tamanhos independentes, então a complexidade é O(n + m), e não O(n).

Rápido e lento: o ciclo da lista ligada

Numa lista simplesmente ligada você não consegue colocar um ponteiro no fim, e nem sempre existe fim: se algum nó apontar para um nó anterior, percorrer a lista vira um laço infinito. Descobrir isso gastando memória é fácil, basta um conjunto com os nós já visitados, mas isso custa O(n) de espaço. A versão com dois ponteiros custa O(1).

A ideia (conhecida como algoritmo da lebre e da tartaruga, ou Floyd) é soltar dois ponteiros do mesmo ponto de partida com velocidades diferentes: o lento anda 1 nó por iteração, o rápido anda 2.

Python
def tem_ciclo(cabeca):
    lento = rapido = cabeca
    while rapido and rapido.prox:
        lento = lento.prox
        rapido = rapido.prox.prox
        if lento is rapido:
            return True
    return False

Se não existe ciclo, o rápido chega ao fim e o while termina. Se existe ciclo, os dois acabam presos nele, e aí vem o argumento bonito: dentro do ciclo, o rápido se aproxima do lento em exatamente 1 posição por iteração. Uma distância que diminui de 1 em 1 nunca pula por cima do zero, então o encontro é garantido, e acontece em no máximo tantas iterações quanto o número de nós.

Visualizador · rápido e lento: existe ciclo na lista ligada?
passo 1 de 11
0L R1234567

L = lento, anda 1 nó por iteração R = rápido, anda 2 nós por iteração os dois no mesmo nó: achou o ciclo

lento e rápido começam os dois na cabeça, o nó 0. A lista tem 8 nós.

ciclo.py
1def tem_ciclo(cabeca):
2 lento = rapido = cabeca
3 while rapido and rapido.prox:
4 lento = lento.prox
5 rapido = rapido.prox.prox
6 if lento is rapido:
7 return True
8 return False
Variáveis
lentonó 0
rapidonó 0
iteração0
ciclo??
nós na lista8
iterações0
nós que o lento andou0
nós que o rápido andou0

passo · espaço roda

O visualizador abre no caso clássico: 3 nós de cauda e um ciclo de 5. Rode até o fim e acompanhe o painel: são 5 iterações, o lento anda 5 nós, o rápido anda 10, e os dois se encontram no nó 5. Repare na iteração 4, quando o rápido "dá a volta" no ciclo e reaparece atrás do lento, no nó 3 contra o nó 4. Depois:

  • Sem ciclo: 6 nós em fila. O rápido cai fora da lista e a resposta é não. Repare que bastaram 3 iterações para ele chegar ao fim de uma lista de 6 nós, enquanto o lento parou no nó 3: andar de dois em dois consome a lista na metade das iterações.
  • Laço em si mesmo: 3 + ciclo de 1. O último nó aponta para ele mesmo. É o menor ciclo possível, e o algoritmo o pega em 3 iterações, sem tratamento especial nenhum.
  • Só ciclo: 6 nós em roda. Sem cauda, a lista inteira é o ciclo. São 6 iterações, e não é coincidência: lista sem cauda é o único formato em que o encontro gasta exatamente o número de nós. Deixe a cauda em zero e varie só o tamanho do ciclo: o número de iterações vai bater com o tamanho do ciclo, sempre.
  • Arraste os dois controles e tente prever o número de iterações antes de rodar. A regra exata é bonita: o encontro acontece no primeiro múltiplo do tamanho do ciclo que já é grande o bastante para o lento ter entrado no ciclo. Com cauda 3 e ciclo 5, o primeiro múltiplo de 5 que chega a 3 é o próprio 5, e dá 5 iterações. Com cauda 6 e ciclo 5, o primeiro múltiplo de 5 que chega a 6 é 10, e o encontro demora 10. Teste os dois no visualizador.

Duas consequências que valem para a entrevista. A primeira: aumentar a cauda não deixa a busca mais rápida, e pode deixar bem mais lenta (3 e 5 dão 5 iterações, 6 e 5 dão 10). A segunda: por maior que fique, o encontro nunca passa de cauda mais ciclo, ou seja, nunca passa do número de nós da lista. É esse teto que faz o algoritmo ser O(n) de tempo com O(1) de espaço, contra o O(n) e O(n) da solução com conjunto de visitados.

O mesmo par lento e rápido resolve outro clássico: achar o meio da lista. Quando o rápido chega ao fim, o lento está exatamente no meio (numa lista de tamanho par, no primeiro nó da segunda metade), tudo isso numa única passada e sem precisar contar o tamanho antes. É o padrão por trás de dividir uma lista para ordenar ou verificar se ela é palíndroma.

Ordenar para usar dois ponteiros: o trade-off com hash

Toda a construção acima depende de o array estar ordenado. E quando ele não está?

Aí você tem uma escolha. O Two Sum clássico (o de array desordenado) tem a solução com tabela hash: você percorre uma vez guardando o complemento de cada número, e responde em O(n). Rápido, mas gasta O(n) de memória.

A alternativa é ordenar e aplicar dois ponteiros:

AbordagemTempoEspaço extra
Força bruta, dois laçosO(n²)O(1)
Hash de complementosO(n)O(n)
Ordenar + dois ponteirosO(n log n)O(1)

A leitura da tabela é a parte que importa. Ordenar piora o tempo, porque nenhum algoritmo de ordenação por comparação faz melhor que O(n log n), e esse termo passa a dominar tudo o que vem depois. O que você compra em troca é memória constante. Se o problema, o entrevistador ou o ambiente de produção impuserem um limite de memória, esse é o caminho.

Duas ressalvas honestas sobre essa última linha. A primeira: o O(1) da terceira linha vale para o par de ponteiros, e supõe uma ordenação in-place. O nums.sort() do Python usa Timsort, que no pior caso pede O(n) de área temporária, então se a pergunta for exatamente "quanta memória extra", diga "O(1) além da ordenação" em vez de só "O(1)". A segunda: ordenar modifica a entrada. Se quem chamou a função ainda precisa do array original, você acabou de estragá-lo, e o sorted(nums) que resolve isso já custa O(n) de memória.

A técnica é O(n), o seu algoritmo pode não ser. Dizer "usei two pointers, logo é O(n)" está errado se você ordenou antes. O custo total é o do maior termo, e nesse caso o maior termo é o sort. Analise o algoritmo inteiro, não o pedaço bonito dele.

E tem a armadilha que só aparece quando você roda: o Two Sum original pede os índices originais dos dois números. Ordenar destrói exatamente essa informação, e o sintoma é sempre o mesmo: os valores encontrados estão certos, os índices devolvidos estão errados. Duas saídas honestas:

  • Se o problema pede os valores (ou só um verdadeiro/falso), pode ordenar à vontade.
  • Se o problema pede os índices, ordene uma lista de pares (valor, indice). Só que isso aloca uma cópia do array, o espaço volta a ser O(n), e aí o hash costuma ser melhor mesmo.

3Sum: fixar um número e resolver um Two Sum no resto

O 3Sum é onde tudo isto se junta, e é o problema em que a maioria das pessoas trava. Pede as triplas únicas que somam zero, num array desordenado. A força bruta de três laços é O(n³), inviável já com alguns milhares de elementos.

A construção sai de uma pergunta só: se eu fixar o primeiro número da tripla, o que sobra? Sobra "achar dois números que somam -nums[i]", que é exatamente o Two Sum convergente que você já sabe fazer. Como três laços viram um laço externo mais uma varredura de dois ponteiros, o custo cai para O(n²).

Python
def tres_somas(nums):
    nums.sort()                       # O(n log n), e é o que habilita tudo
    n = len(nums)
    resultado = []
    for i in range(n - 2):
        if nums[i] > 0:               # ordenado: daqui para frente só cresce
            break
        if i > 0 and nums[i] == nums[i - 1]:
            continue                  # já resolvi este valor como fixo
        esq, dir = i + 1, n - 1
        while esq < dir:
            soma = nums[i] + nums[esq] + nums[dir]
            if soma < 0:
                esq += 1
            elif soma > 0:
                dir -= 1
            else:
                resultado.append([nums[i], nums[esq], nums[dir]])
                while esq < dir and nums[esq] == nums[esq + 1]:
                    esq += 1
                while esq < dir and nums[dir] == nums[dir - 1]:
                    dir -= 1
                esq += 1
                dir -= 1
    return resultado

São quatro decisões, e cada uma resolve um problema específico:

LinhaPor que ela existe
nums.sort()sem ordem, "a soma está grande" não diz qual ponteiro mover
if nums[i] > 0: breakcom o fixo positivo num array ordenado, os outros dois são maiores ainda, a soma nunca volta a zero
if nums[i] == nums[i-1]: continueevita repetir a mesma tripla com um fixo de valor igual
os dois while de dentropula os valores repetidos depois de registrar a tripla, senão a mesma resposta entra várias vezes

Com [-1, 0, 1, 2, -1, -4], o array ordenado vira [-4, -1, -1, 0, 1, 2] e a resposta é [[-1, -1, 2], [-1, 0, 1]]. Repare que a tripla [-1, -1, 2] usa os dois -1, e mesmo assim ela aparece uma vez só: o continue do laço externo pula o segundo -1 como fixo, mas nada impede que ele seja usado como parceiro lá dentro. Confundir essas duas coisas é o bug mais comum do 3Sum.

Esse é o padrão "fixa um, dois ponteiros no resto", e ele escala. O 4Sum é o mesmo truque com dois laços externos e dois ponteiros no fim, saindo O(n³). Toda vez que você vir "k números que somam X", pense em k - 2 laços aninhados por fora e um par de ponteiros por dentro.

Onde o Two Pointers dá errado

Esquecer que precisa estar ordenado. É o erro número um. Se a solução depende de "a soma está grande demais, recuo a direita", ela depende de ordem. Sem ordem, o resultado fica errado sem dar exceção, que é o pior tipo de bug.

Confundir esquerda < direita com esquerda <= direita. Para formar um par de índices distintos, use <, senão o elemento do meio soma com ele mesmo e [3, 8] com alvo 6 devolve um par que não existe. Para palíndromo também é <, porque o caractere central não precisa de comparação. O <= aparece em outro contexto, o da busca binária, onde o intervalo pode ter um elemento só.

Mover os dois ponteiros quando só um devia andar. É o erro do palíndromo com pontuação. Se um lado tem um caractere para ignorar, quem anda é ele, sozinho. Fechar os dois de uma vez desalinha a comparação e passa pelos testes fáceis, quebrando só nos difíceis.

Errar a inicialização da direita. direita = len(nums) estoura o índice na primeira leitura. É sempre len(nums) - 1, e esse é literalmente o primeiro erro que aparece quando se escreve o laço.

Esquecer os duplicados no 3Sum. Quando o problema pede triplas ou pares únicos, o array ordenado coloca os valores repetidos lado a lado, e o laço vai gerar a mesma resposta várias vezes. A correção é pular os valores iguais ao anterior depois de registrar uma solução.

Chamar de Two Pointers o que tem nome próprio. Se a distância entre os ponteiros define uma janela cujo conteúdo interessa, isso é Sliding Window. Se um anda o dobro do outro, é lento e rápido. Reconhecer o nome certo é parte da resposta.

Os casos de borda que derrubam a submissão

Quase toda solução de dois ponteiros que passa nos exemplos e falha no LeetCode falha em um destes seis. Rode-os de cabeça antes de submeter:

EntradaO que tem que acontecerO que costuma quebrar
Array vazio, []direita = -1, esquerda < direita é falso, o laço não roda e devolve "não achei"ler nums[0] ou nums[-1] antes do laço
Um elemento, [7]esquerda = direita = 0, o laço não roda: não existe par com um elemento sóusar <= na condição e somar o elemento com ele mesmo
Dois elementosexatamente uma comparação, e acabouesquecer que aqui n - 1 = 1
Tudo igual, [5, 5, 5, 5]nenhuma soma muda, mas um ponteiro anda a cada passo e o laço terminaachar que "a soma não mudou" significa laço infinito
Todos negativos ou alvo negativonada muda, a regra é sobre ordem, não sobre sinalassumir que os valores são positivos
Duplicados quando a saída é únicapular os repetidos depois de registrar a respostapular antes, e perder triplas legítimas como [-1, -1, 2]

Nas variações de lista ligada, a lista tem três bordas próprias: vazia (cabeca is None, o while rapido and rapido.prox já devolve falso), um nó sem ciclo (rapido.prox é None na primeira checagem) e um nó apontando para si mesmo (encontro na primeira iteração). Nenhuma delas precisa de if extra: quem escreveu a condição do while certa ganha as três de graça, e é por isso que a ordem rapido and rapido.prox importa. Invertida, ela estoura com None.

O teste mental que pega quase tudo: o laço sempre move pelo menos um ponteiro? Se existir um caminho pelo if em que nenhum dos dois anda, você escreveu um laço infinito. É o mesmo erro que aparece no palíndromo quando alguém trata o caractere ignorado e esquece o continue.

Como praticar

Antes da lista, guarde o gatilho de cada sabor. Na entrevista você tem uns segundos para escolher, e a escolha vem do enunciado, não da inspiração:

O enunciado diz...SaborComo começa
"array ordenado" e pede um par com uma soma, diferença ou produtoconvergenteesq, dir = 0, len(nums) - 1
pede par de índices e a métrica piora quando a distância encolheconvergentemova o ponteiro do lado que limita a métrica
"in-place", "sem alocar outro array", "devolva o novo tamanho"leitor e escritorescrita = 0, for leitura in range(len(nums))
duas entradas, e a ordem relativa importa (merge, subsequência)um ponteiro em cadai = j = 0, dois len diferentes
lista ligada, ciclo, meio da lista, k-ésimo do fimlento e rápidolento = rapido = cabeca
interessa o conteúdo do trecho entre os ponteirosnão é isto, é Sliding Windowexpandir pela direita, encolher pela esquerda

O esqueleto convergente cabe na memória, e é dele que saem quatro dos seis problemas abaixo. Decore a forma, não o problema:

Python
esq, dir = 0, len(dados) - 1
while esq < dir:
    valor = metrica(dados[esq], dados[dir])   # soma, área, comparação...
    if resolve(valor):
        return resposta(esq, dir)
    if precisa_de_mais(valor):
        esq += 1        # descarta o índice esq, ele não fecha com ninguém
    else:
        dir -= 1        # descarta o índice dir, pelo mesmo motivo
return nao_achou

A única coisa que muda de problema para problema são as três funções do meio. Se você não conseguir escrever, em uma frase, por que o índice descartado está esgotado, ainda não é hora de codar: é aí que nascem as soluções que passam nos exemplos e falham nos testes escondidos.

Feito isso, siga a ordem da lista de problemas abaixo, que vai do formato mais direto ao mais elaborado. O caminho é este:

  1. Valid Palindrome é a versão exata do que você viu no visualizador do meio, pontuação e tudo. Resolva primeiro limpando a string e depois sem criar string nenhuma, e compare a memória que o LeetCode reporta nas duas.
  2. Remove Duplicates treina o leitor e o escritor. Se você conseguir dizer, sem rodar, o que sobra no array depois do índice devolvido, entendeu o padrão.
  3. Linked List Cycle é o lento e rápido puro. Teste antes os casos de borda: lista vazia, um nó só, e um nó apontando para ele mesmo.
  4. Two Sum II é o convergente clássico. Atenção: esse enunciado usa índices começando em 1, então some 1 na resposta.
  5. Container With Most Water é o convergente sem soma nenhuma, e com entrada desordenada. Antes de olhar o código da seção sobre o argumento de descarte, tente reconstruir sozinho por que mover a barra menor é a escolha certa. Meta: 8 avaliações no array de exemplo.
  6. 3Sum junta tudo: ordenar, fixar um elemento e resolver um Two Sum com dois ponteiros no resto, cuidando dos duplicados. Se travar, volte ao esqueleto da seção sobre o trade-off com hash e refaça linha a linha, explicando em voz alta por que cada continue existe.

Antes de cada problema, faça o exercício de prever: quantas comparações a força bruta faria, e quantas os dois ponteiros fazem? Se a diferença for grande, você está no problema certo.

Daqui, os vizinhos naturais são a Sliding Window, que é o mesmo par de ponteiros com uma regra de janela, e as tabelas hash, que resolvem em O(n) o que aqui custou uma ordenação. Se a conta de complexidade ainda escorrega, vale revisitar o Big O.

Vídeo da aula

Direto do canal da comunidade Craft & Code Club · 1:11:13.

Problemas para praticar

Na ordem em que recomendamos resolver. Marque os que você já fez, fica salvo aqui.

FácilValid PalindromeLeetCode 125
FácilLinked List CycleLeetCode 141
Médio3SumLeetCode 15
GuiaTwo Pointers TechniqueGeeksforGeeks

Referências

Artigos e materiais externos para se aprofundar.

Travou em algum passo? Traga sua questão para o Discord da comunidade ou para os encontros semanais.

Entrar
Concluiu este tópico?
Marque para acompanhar seu progresso.

Este tópico também tem página própria, fora deste roadmap: Two Pointers.