Blog

Dois ponteiros em C#

Problemas de maratona premiam quem reconhece um formato rápido. Dois ponteiros é o primeiro formato que vale aprender, porque não custa nada: nenhum segundo array, nenhum dicionário, nenhuma recursão. Duas variáveis int e o array que te deram.

Esta é a parte 1 de dez, cinco padrões cada. Todo programa abaixo é completo, rodou no .NET 10, e a saída dele está colada da execução.

Como rodar isto

O .NET 10 roda um único arquivo .cs sem projeto, sem classe e sem Main:

dotnet run twopointers.cs

Isso é novo, e é o jeito mais rápido de experimentar qualquer coisa disto. Tudo nesta página está escrito assim.

Padrão 1 — Fechando o cerco pelas duas pontas

Você tem um array ordenado. Encontre dois valores que somam um alvo.

A versão óbvia testa todos os pares. Num array de seis elementos, tudo bem. Conte o que ela faz de verdade:

int[] a = [2, 7, 11, 15, 19, 26];
int target = 30;

int bruteSteps = 0;
for (int i = 0; i < a.Length; i++)
    for (int j = i + 1; j < a.Length; j++)
    {
        bruteSteps++;
        if (a[i] + a[j] == target) goto done;
    }
done:

int twoPtrSteps = 0;
int lo = 0, hi = a.Length - 1;
while (lo < hi)
{
    twoPtrSteps++;
    int sum = a[lo] + a[hi];
    if (sum == target) break;
    if (sum < target) lo++; else hi--;
}

Console.WriteLine($"n = {a.Length}");
Console.WriteLine($"every pair      : {bruteSteps} pairs examined");
Console.WriteLine($"two pointers    : {twoPtrSteps} steps");

Ele imprime:

n = 6
every pair      : 11 pairs examined
two pointers    : 4 steps

Onze pares contra quatro passos não é uma diferença interessante. Com n = 100000 são cinco bilhões contra cem mil, e isso é a maratona inteira.

A jogada é esta. O array está ordenado, então a[hi] é o maior valor ainda em jogo. Se a[lo] + a[hi] é pequeno demais, então a[lo] não tem par em lugar nenhum — você acabou de juntá-lo com a maior coisa disponível e mesmo assim ficou curto. Então a[lo] não pode fazer parte de nenhuma resposta. Jogue fora e nunca mais olhe para ele.

A mesma lógica espelhada: se a soma é grande demais, a[hi] é grande demais para tudo que sobrou, então descarte.

0 1 2 3 4 5 1 2 7 11 15 19 26 lo hi 2 + 26 = 28 < 30 → lo++ 2 2 7 11 15 19 26 lo hi 7 + 26 = 33 > 30 → hi– 3 2 7 11 15 19 26 lo hi 7 + 19 = 26 < 30 → lo++ 4 2 7 11 15 19 26 lo hi 11 + 19 = 30  ✓

Dois ponteiros fechando o cerco pelas duas pontas de um array ordenado. Uma célula cinza foi descartada e não é olhada de novo.

int[] a = [2, 7, 11, 15, 19, 26];
int target = 30;

int lo = 0, hi = a.Length - 1;
while (lo < hi)
{
    int sum = a[lo] + a[hi];
    string move = sum == target ? "= target, stop"
                : sum < target  ? "< target, lo++"
                :                 "> target, hi--";
    Console.WriteLine($"lo={lo} hi={hi}   {a[lo],2} + {a[hi],2} = {sum,2}   {move}");
    if (sum == target) break;
    if (sum < target) lo++; else hi--;
}

Console.WriteLine($"\nindices ({lo}, {hi}) -> values ({a[lo]}, {a[hi]})");

Ele imprime:

lo=0 hi=5    2 + 26 = 28   < target, lo++
lo=1 hi=5    7 + 26 = 33   > target, hi--
lo=1 hi=4    7 + 19 = 26   < target, lo++
lo=2 hi=4   11 + 19 = 30   = target, stop

indices (2, 4) -> values (11, 19)

Cada passo joga fora exatamente um índice e nunca o reconsidera. Existem n índices, então existem no máximo n passos. É por isso que é linear, e vale dizer dessa forma em vez de “os ponteiros se encontram no meio” — o descarte é o motivo, o encontro é só a aparência.

Custo: tempo O(n) depois da ordenação, espaço O(1).

Use quando a entrada estiver ordenada ou você puder pagar para ordená-la, e mover lo para a direita aumentar sua quantidade enquanto mover hi para a esquerda a diminui. Essa monotonicidade é o requisito inteiro. Sem ela o passo de descarte é inválido e a coisa toda devolve respostas erradas em silêncio.

Padrão 2 — O ponteiro de escrita

Remova as duplicatas de um array ordenado in-place, e informe quantos valores sobraram.

A versão tentadora monta uma List<int>, adiciona os que ficam, e copia de volta. Está correta. Também aloca um segundo array, e com 10⁶ elementos essa alocação é a diferença entre passar e não passar.

Aqui os dois ponteiros andam no mesmo sentido. r lê cada célula. w marca onde vai o próximo valor mantido. Nada é destruído porque w nunca consegue ultrapassar rw só avança quando r avança, e começa atrás.

0 1 2 3 4 5 6 7 8 r=1 1 1 2 2 2 3 4 4 5 w r a[1] = a[0] → pula r=2 1 2 2 2 2 3 4 4 5 w r novo → a[1] = 2 r=5 1 2 3 2 2 3 4 4 5 w r novo → a[2] = 3 r=6 1 2 3 4 2 3 4 4 5 w r novo → a[3] = 4 r=8 1 2 3 4 5 3 4 4 5 w r novo → a[4] = 5

Um array, duas tarefas. r lê cada célula, w marca onde vai o próximo valor mantido. w nunca ultrapassa r, então nada é sobrescrito antes de ser lido. Tudo depois de w no final está velho e é ignorado.

int[] a = [1, 1, 2, 2, 2, 3, 4, 4, 5];
Console.WriteLine($"before: [{string.Join(", ", a)}]\n");

int w = 1;
for (int r = 1; r < a.Length; r++)
{
    if (a[r] == a[w - 1])
    {
        Console.WriteLine($"r={r}  a[r]={a[r]}   {$"same as a[{w - 1}]",-12}   skip      w stays {w}");
        continue;
    }
    a[w] = a[r];
    w++;
    Console.WriteLine($"r={r}  a[r]={a[r]}   {"new value",-12}   a[{w - 1}]={a[r]}   w -> {w}");
}

Console.WriteLine($"\nkept {w}: [{string.Join(", ", a[..w])}]");
Console.WriteLine($"tail   : [{string.Join(", ", a[w..])}]   <- stale, and that is fine");

Ele imprime:

before: [1, 1, 2, 2, 2, 3, 4, 4, 5]

r=1  a[r]=1   same as a[0]   skip      w stays 1
r=2  a[r]=2   new value      a[1]=2   w -> 2
r=3  a[r]=2   same as a[1]   skip      w stays 2
r=4  a[r]=2   same as a[1]   skip      w stays 2
r=5  a[r]=3   new value      a[2]=3   w -> 3
r=6  a[r]=4   new value      a[3]=4   w -> 4
r=7  a[r]=4   same as a[3]   skip      w stays 4
r=8  a[r]=5   new value      a[4]=5   w -> 5

kept 5: [1, 2, 3, 4, 5]
tail   : [3, 4, 4, 5]   <- stale, and that is fine

Olhe a cauda. [3, 4, 4, 5] fica lá parado, velho. Isso não é um bug para limpar — sobrescrever custaria outra passada sem benefício nenhum. O contrato é que a resposta é a[..w], e tudo depois de w não é da conta de quem chamou.

Custo: tempo O(n), espaço O(1).

Use quando o problema disser in place, ou devolva o novo tamanho, ou sem alocar. Compactar, filtrar e particionar por um predicado são todos este mesmo padrão vestindo outras palavras.

Padrão 3 — Ordene, ancore, varra

Encontre toda tripla que soma zero, sem reportar nenhuma tripla duas vezes.

Três laços aninhados é O(n³) e não vai passar. Mas fixe o primeiro valor e olhe o que sobra: encontrar dois valores que somam -a[i]. Isso é o padrão 1, exatamente. Ordenar uma vez no começo custa O(n log n) e compra a monotonicidade de que o padrão 1 precisa.

A parte genuinamente chata não é a busca, são as duplicatas. Elas precisam ser puladas em dois lugares diferentes, por dois motivos diferentes:

  • Uma âncora repetida. Se a[i] == a[i-1], toda tripla começando em i já foi encontrada começando em i-1. Pule a âncora inteira.
  • Um lo ou hi repetido depois de um acerto. Depois de registrar uma tripla, ande com os dois ponteiros para além de qualquer cópia dos valores recém-usados, ou a iteração seguinte reporta a mesma tripla de novo.
0 1 2 3 4 5 i=0 -4 -1 -1 0 1 2 i lo hi −4 −1 +2 = −3 < 0 → lo++ i=1 -4 -1 -1 0 1 2 i lo hi −1 −1 +2 = 0  ✓ i=1 -4 -1 -1 0 1 2 i lo hi −1 +0 +1 = 0  ✓ i=2 -4 -1 -1 0 1 2 i igual a a[1] → pula âncora

Ordene uma vez, fixe um valor e rode dois ponteiros no que sobra. Pular uma âncora repetida é o que impede a mesma tripla de ser reportada duas vezes.

int[] a = [-1, 2, -4, -1, 1, 0];
Array.Sort(a);
Console.WriteLine($"sorted: [{string.Join(", ", a)}]\n");

List<(int, int, int)> found = [];

for (int i = 0; i < a.Length - 2; i++)
{
    if (i > 0 && a[i] == a[i - 1])
    {
        Console.WriteLine($"anchor i={i} a[i]={a[i],2}   duplicate anchor, skip");
        continue;
    }

    int lo = i + 1, hi = a.Length - 1;
    Console.WriteLine($"anchor i={i} a[i]={a[i],2}   need a[lo] + a[hi] == {-a[i]}");

    while (lo < hi)
    {
        int sum = a[i] + a[lo] + a[hi];
        Console.WriteLine($"    lo={lo} hi={hi}   {a[i],2} + {a[lo],2} + {a[hi],2} = {sum,2}");
        if (sum == 0)
        {
            found.Add((a[i], a[lo], a[hi]));
            while (lo < hi && a[lo] == a[lo + 1]) lo++;
            while (lo < hi && a[hi] == a[hi - 1]) hi--;
            lo++; hi--;
        }
        else if (sum < 0) lo++;
        else hi--;
    }
}

Console.WriteLine();
foreach (var t in found) Console.WriteLine($"triplet: {t}");

Ele imprime:

sorted: [-4, -1, -1, 0, 1, 2]

anchor i=0 a[i]=-4   need a[lo] + a[hi] == 4
    lo=1 hi=5   -4 + -1 +  2 = -3
    lo=2 hi=5   -4 + -1 +  2 = -3
    lo=3 hi=5   -4 +  0 +  2 = -2
    lo=4 hi=5   -4 +  1 +  2 = -1
anchor i=1 a[i]=-1   need a[lo] + a[hi] == 1
    lo=2 hi=5   -1 + -1 +  2 =  0
    lo=3 hi=4   -1 +  0 +  1 =  0
anchor i=2 a[i]=-1   duplicate anchor, skip
anchor i=3 a[i]= 0   need a[lo] + a[hi] == 0
    lo=4 hi=5    0 +  1 +  2 =  3

triplet: (-1, -1, 2)
triplet: (-1, 0, 1)

A âncora em i=2 é pulada sem um único passo interno, porque -1 já teve a vez dele em i=1. Esse pulo é o que faz a saída ser um conjunto em vez de uma lista com repetições.

Custo: tempo O(n²) — n âncoras, cada uma rodando uma varredura linear — mais a ordenação. Espaço O(1) além da saída.

Use quando você precisar de uma combinação de tamanho fixo que satisfaça uma condição. O four-sum é isto de novo com mais um laço por fora.

Padrão 4 — Mande cada valor para o índice dele

Um array guarda n valores no intervalo 1..n. Um valor aparece duas vezes, um está faltando. Encontre os dois, sem usar memória extra.

Um HashSet<int> resolve e custa memória O(n). O truque da soma dos valores te dá uma equação e você precisa de duas. Mas olhe a restrição de novo: os valores são 1..n e o array tem tamanho n. O array é uma hash table, e a função de hash é v - 1.

Então coloque cada valor onde ele pertence. O valor 3 vai para o índice 2. Se dois valores querem a mesma casa, o array para de conseguir se mexer, e a célula que nunca foi preenchida te diz o que está faltando.

O laço é um while, não um for, e isso importa. Depois de uma troca, o índice i guarda um valor diferente que ainda não foi colocado, então i não pode avançar. Ele avança só quando o valor em i está em casa.

0 1 2 3 4 5 início 3 1 5 4 3 2 a[0]=3 pertence ao índice 2 troca 5 1 3 4 3 2 a[0]=5 pertence ao índice 4 troca 3 1 3 4 5 2 a[0]=3, a[2]=3 → resolvido, i++ fim 1 2 3 4 5 3 índice 5 tem 3, quer 6

A ordenação cíclica manda cada valor para o índice a que ele pertence. Quando dois valores querem a mesma casa o array para de se mexer, e o que sobra fora do lugar revela o duplicado e o número que falta.

int[] a = [3, 1, 5, 4, 3, 2];
Console.WriteLine($"start: [{string.Join(", ", a)}]   values are 1..{a.Length}\n");

int i = 0;
while (i < a.Length)
{
    int home = a[i] - 1;
    if (a[i] != a[home])
    {
        Console.WriteLine($"i={i}  a[i]={a[i]} belongs at index {home}, which holds {a[home]}   swap");
        (a[i], a[home]) = (a[home], a[i]);
        Console.WriteLine($"      -> [{string.Join(", ", a)}]");
    }
    else
    {
        Console.WriteLine($"i={i}  a[i]={a[i]} is already home (or its twin is)          i++");
        i++;
    }
}

Console.WriteLine($"\nsorted as far as it can be: [{string.Join(", ", a)}]\n");

for (int j = 0; j < a.Length; j++)
    if (a[j] != j + 1)
        Console.WriteLine($"index {j} holds {a[j]}, should hold {j + 1}   ->  duplicate = {a[j]}, missing = {j + 1}");

Ele imprime:

start: [3, 1, 5, 4, 3, 2]   values are 1..6

i=0  a[i]=3 belongs at index 2, which holds 5   swap
      -> [5, 1, 3, 4, 3, 2]
i=0  a[i]=5 belongs at index 4, which holds 3   swap
      -> [3, 1, 3, 4, 5, 2]
i=0  a[i]=3 is already home (or its twin is)          i++
i=1  a[i]=1 belongs at index 0, which holds 3   swap
      -> [1, 3, 3, 4, 5, 2]
i=1  a[i]=3 is already home (or its twin is)          i++
i=2  a[i]=3 is already home (or its twin is)          i++
i=3  a[i]=4 is already home (or its twin is)          i++
i=4  a[i]=5 is already home (or its twin is)          i++
i=5  a[i]=2 belongs at index 1, which holds 3   swap
      -> [1, 2, 3, 4, 5, 3]
i=5  a[i]=3 is already home (or its twin is)          i++

sorted as far as it can be: [1, 2, 3, 4, 5, 3]

index 5 holds 3, should hold 6   ->  duplicate = 3, missing = 6

Parece que poderia ser quadrático — um laço com uma troca dentro que às vezes não avança. Não é. Toda troca coloca pelo menos um valor na casa definitiva dele, e um valor que está em casa nunca é movido de novo. Existem n valores, então existem no máximo n trocas na execução inteira.

Quando os valores são uma permutação de um intervalo conhecido, o array já é uma hash table e você tem permissão para usá-lo como uma.

Custo: tempo O(n), espaço O(1).

Use quando o problema disser valores de 1 a n, ou de 0 a n, e pedir um que falta ou um duplicado. Essa frase é a pista, e ela está te fazendo um favor por estar ali.

Padrão 5 — Três regiões em uma passada

Ordene um array que contém só 0, 1 e 2. Uma passada, sem ordenação por comparação.

Contar cada valor e reescrever funciona, e são duas passadas. Também não generaliza, que é a objeção de verdade — a versão abaixo é como você particiona em torno de um pivô quando há muitas chaves iguais, que é o que impede o quicksort de degradar numa entrada cheia de duplicatas.

Três índices dividem o array em quatro regiões. Tudo à esquerda de low é um 0 resolvido. Tudo à direita de high é um 2 resolvido. Entre mid e high está a parte que ninguém olhou ainda, e ela encolhe em um a cada iteração.

só 0 só 1 ainda não olhado só 2 resolvido resolvido desconhecido resolvido low mid high a[mid] = 0 → troca com a[low], depois low++ e mid++ a[mid] = 1 → já está na região certa, mid++ a[mid] = 2 → troca com a[high], depois high– e mid não anda

O algoritmo inteiro são essas três regras mais uma invariante: tudo à esquerda de low é um 0, tudo à direita de high é um 2, e a região desconhecida encolhe em um a cada iteração. Essa última regra é a que as pessoas escrevem errado — o valor que volta na troca com high nunca foi examinado, então mid não pode passar por cima dele.

Leia a terceira regra de novo, porque é a que sai escrita errada. Quando você troca a[mid] com a[high], o valor que chega em mid veio da região não examinada. Ninguém testou ele. Avance mid por cima dele e você acabou de empurrar um valor não testado para dentro dos 1 resolvidos. Avançar mid no caso 0 está certo pelo motivo oposto: o valor que chega veio de low, e tudo antes de mid já foi testado.

int[] a = [2, 0, 2, 1, 1, 0, 2, 1, 0];
Console.WriteLine($"start: [{string.Join(", ", a)}]\n");

int low = 0, mid = 0, high = a.Length - 1;
while (mid <= high)
{
    switch (a[mid])
    {
        case 0:
            (a[low], a[mid]) = (a[mid], a[low]);
            Console.WriteLine($"a[mid]=0  swap into the 0s   low {low}->{low + 1}  mid {mid}->{mid + 1}  high {high}   [{string.Join(", ", a)}]");
            low++; mid++;
            break;
        case 1:
            Console.WriteLine($"a[mid]=1  already correct     low {low}     mid {mid}->{mid + 1}  high {high}   [{string.Join(", ", a)}]");
            mid++;
            break;
        default:
            (a[mid], a[high]) = (a[high], a[mid]);
            Console.WriteLine($"a[mid]=2  swap into the 2s   low {low}     mid {mid}     high {high}->{high - 1}   [{string.Join(", ", a)}]");
            high--;
            break;
    }
}

Console.WriteLine($"\ndone:  [{string.Join(", ", a)}]");

Ele imprime:

start: [2, 0, 2, 1, 1, 0, 2, 1, 0]

a[mid]=2  swap into the 2s   low 0     mid 0     high 8->7   [0, 0, 2, 1, 1, 0, 2, 1, 2]
a[mid]=0  swap into the 0s   low 0->1  mid 0->1  high 7   [0, 0, 2, 1, 1, 0, 2, 1, 2]
a[mid]=0  swap into the 0s   low 1->2  mid 1->2  high 7   [0, 0, 2, 1, 1, 0, 2, 1, 2]
a[mid]=2  swap into the 2s   low 2     mid 2     high 7->6   [0, 0, 1, 1, 1, 0, 2, 2, 2]
a[mid]=1  already correct     low 2     mid 2->3  high 6   [0, 0, 1, 1, 1, 0, 2, 2, 2]
a[mid]=1  already correct     low 2     mid 3->4  high 6   [0, 0, 1, 1, 1, 0, 2, 2, 2]
a[mid]=1  already correct     low 2     mid 4->5  high 6   [0, 0, 1, 1, 1, 0, 2, 2, 2]
a[mid]=0  swap into the 0s   low 2->3  mid 5->6  high 6   [0, 0, 0, 1, 1, 1, 2, 2, 2]
a[mid]=2  swap into the 2s   low 3     mid 6     high 6->5   [0, 0, 0, 1, 1, 1, 2, 2, 2]

done:  [0, 0, 0, 1, 1, 1, 2, 2, 2]

Nove valores, nove iterações, e ele nunca compara dois elementos do array entre si.

Custo: tempo O(n), espaço O(1), e é estável no sentido que importa aqui — uma passada, sem recursão.

Use quando você estiver particionando em três grupos por um teste barato em cada elemento. O enquadramento clássico são as cores da bandeira; o enquadramento útil é less than pivot, equal to pivot, greater than pivot.

O que lembrar

  • Pontas opostas exige monotonicidade, não só estar ordenado. Mover lo para a direita tem que empurrar sua quantidade para um lado e mover hi para a esquerda tem que empurrar para o outro. Confira isso antes de confiar no descarte.

  • Dois ponteiros no mesmo sentido é a edição in-place. w vai atrás de r, então nada é sobrescrito antes de ser lido, e a cauda velha depois de w é proposital.

  • Uma âncora repetida e um ponteiro repetido são dois bugs de duplicata diferentes. Consertar um não conserta o outro, e só um deles aparece num caso de teste pequeno.

  • 1..n no enunciado significa que o array pode ser a própria hash table dele. Espaço O(1) em vez de O(n), e o laço de trocas é linear porque toda troca resolve um valor para sempre.

  • Depois de trocar com high, mid não anda. O valor que você acabou de receber nunca foi examinado. É um caractere de diferença e é o bug mais comum do padrão inteiro.

A parte 2 pega a mesma ideia de dois índices e faz os dois ponteiros viajarem no mesmo sentido, onde a distância entre eles é a resposta: janelas deslizantes, e o truque do no-máximo-K que transforma “exatamente K” numa subtração.

How useful was this post?

Click on a heart to rate it!

Average rating 0 / 5. Vote count: 0

No votes so far! Be the first to rate this post.