Two Pointers

Arrays e Strings⏱ 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 n².
  • 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 faz parte de

Ver todos →

Two Pointers aparece num percurso com objetivo próprio. O conteúdo é o mesmo; o que muda é a pergunta que ele responde ali, e o que vem antes e depois.