Blog

Entrada e saída rápida e desempenho em C# para programação competitiva

Nove partes de algoritmos, e então esta. Ela vem por último porque é a parte que ninguém quer até que algo já tenha falhado — uma solução comprovadamente correta, que passa em todos os testes que você escreveu, e que o juiz rejeita por tempo.

Em C#, a causa muitas vezes não é o algoritmo.

Todo programa abaixo é completo, foi rodado no .NET 10, e a saída está colada da execução. Quando um número é um tempo e não uma contagem, ele vem de uma execução em uma máquina e está ali pela proporção, não pelo valor.

Padrão 46 — Lendo a entrada

Console.ReadLine() dentro de um laço é a razão mais comum de uma solução C# correta ser lenta demais. Ele passa por um TextReader sincronizado e produz um objeto string por linha. Depois Split(' ') produz mais uma string por token.

Três formas de ler 300,000 inteiros:

a linha, já na memória 417 903 12 55 8801 640 … Split “417” “903” “12” “55” … um novo objeto string por token Span … um offset e um tamanho. Nada é copiado. 300,000 tokens: Split aloca 17 MB. A versão com span não aloca nada. int.Parse aceita ReadOnlySpan<char> desde o .NET Core 2.1 — a mudança é uma linha.

Um Span<T> é um ponteiro e um tamanho apontando para memória que já existe. Fatiar um span é aritmética; fatiar um array com a mesma sintaxe [a..b] aloca e copia.

using System.Text;

// A deterministic input: 300,000 numbers on one line, built in memory so this
// program is self-contained.
var rng = new Random(7);
var sb = new StringBuilder();
int n = 300_000;
for (int i = 0; i < n; i++) { if (i > 0) sb.Append(' '); sb.Append(rng.Next(1, 1_000_000_000)); }
string line = sb.ToString();
byte[] bytes = Encoding.ASCII.GetBytes(line);

static long Measure(string name, Func<long> parse)
{
    long before = GC.GetTotalAllocatedBytes(precise: true);
    long sum = parse();
    long allocated = GC.GetTotalAllocatedBytes(precise: true) - before;
    Console.WriteLine($"{name,-22} sum={sum}   allocated {allocated / 1024 / 1024,4} MB");
    return sum;
}

// 1. What everyone writes. One string object per token.
long a = Measure("Split + int.Parse", () =>
{
    long sum = 0;
    foreach (string tok in line.Split(' ')) sum += int.Parse(tok);
    return sum;
});

// 2. Same shape, but int.Parse accepts a ReadOnlySpan<char>, so no substrings.
long b = Measure("Span, no substrings", () =>
{
    ReadOnlySpan<char> span = line;
    long sum = 0;
    foreach (Range r in span.Split(' ')) sum += int.Parse(span[r]);
    return sum;
});

// 3. Read the bytes and build the number by hand. Nothing is allocated at all.
long c = Measure("bytes, hand-parsed", () =>
{
    long sum = 0;
    int i = 0;
    while (i < bytes.Length)
    {
        while (i < bytes.Length && bytes[i] == ' ') i++;
        int x = 0;
        while (i < bytes.Length && bytes[i] >= '0' && bytes[i] <= '9') x = x * 10 + (bytes[i++] - '0');
        sum += x;
    }
    return sum;
});

Console.WriteLine($"\nall three agree: {a == b && b == c}");

Ele imprime:

Split + int.Parse      sum=149553782500880   allocated   17 MB
Span, no substrings    sum=149553782500880   allocated    0 MB
bytes, hand-parsed     sum=149553782500880   allocated    0 MB

all three agree: True

Dezessete megabytes de lixo, ou nenhum, para o mesmo trabalho e a mesma resposta.

Tempos da mesma comparação nesta máquina: Split uns 55 ms, a versão com span uns 38 ms, os bytes parseados na mão uns 17 ms. Ler um número por linha com Console.ReadLine em vez de uma linha longa levou uns 40 ms contra 18 ms com buffer.

A opção do meio merece atenção porque adotá-la é quase de graça. int.Parse aceita um ReadOnlySpan<char> desde o .NET Core 2.1, e MemoryExtensions.Split devolve valores Range em vez de strings. Trocar string por ReadOnlySpan<char> e indexar com o range elimina toda substring.

O leitor de bytes é o que vale manter num template. São vinte linhas, ele nunca aloca, e não se importa com o espaçamento da entrada.

Use quando a entrada tiver mais que alguns milhares de tokens. Abaixo disso, escreva o que for mais claro.

Padrão 47 — Span<T> e stackalloc

Span<T> é um ponteiro e um tamanho. Fatiar um span é aritmética. Fatiar um array com a mesma sintaxe [a..b] aloca um novo array e copia.

stackalloc coloca um buffer pequeno, de tamanho fixo, na stack. Sem alocação, sem garbage collection, e ele some quando o método retorna.

// stackalloc puts a small array on the STACK. No allocation, no GC, and it
// disappears when the method returns. Bounded sizes only — this is not for
// anything that depends on input size.
static int LongestUniqueRun(string s)
{
    Span<int> lastSeen = stackalloc int[128];   // ASCII, fixed size, no heap
    lastSeen.Fill(-1);

    int lo = 0, best = 0;
    for (int r = 0; r < s.Length; r++)
    {
        int prev = lastSeen[s[r]];
        if (prev >= lo) lo = prev + 1;          // the guard from part 2
        lastSeen[s[r]] = r;
        best = Math.Max(best, r - lo + 1);
    }
    return best;
}

// A Span slice is a VIEW. No copy is made, so this allocates nothing at all.
static long SumOfHalves(int[] data)
{
    ReadOnlySpan<int> all = data;
    ReadOnlySpan<int> left = all[..(all.Length / 2)];
    ReadOnlySpan<int> right = all[(all.Length / 2)..];
    long a = 0, b = 0;
    foreach (int x in left) a += x;
    foreach (int x in right) b += x;
    return a + b;
}

int[] data = [.. Enumerable.Range(1, 1_000_000)];

long before = GC.GetTotalAllocatedBytes(precise: true);
Console.WriteLine($"longest unique run in \"abcabcbb\": {LongestUniqueRun("abcabcbb")}");
Console.WriteLine($"longest unique run in \"abba\"    : {LongestUniqueRun("abba")}");
Console.WriteLine($"sum via span slices             : {SumOfHalves(data):N0}");
long after = GC.GetTotalAllocatedBytes(precise: true);
Console.WriteLine($"allocated by all of the above   : {after - before} bytes");

// The array version of the same slicing DOES copy.
before = GC.GetTotalAllocatedBytes(precise: true);
int[] copy = data[..(data.Length / 2)];
after = GC.GetTotalAllocatedBytes(precise: true);
Console.WriteLine($"\nint[] range operator copies     : {(after - before) / 1024 / 1024} MB for {copy.Length:N0} ints");
Console.WriteLine($"the same slice as a Span        : 0 bytes — it is a pointer and a length");

Ele imprime:

longest unique run in "abcabcbb": 3
longest unique run in "abba"    : 2
sum via span slices             : 500,000,500,000
allocated by all of the above   : 8344 bytes

int[] range operator copies     : 1 MB for 500,000 ints
the same slice as a Span        : 0 bytes — it is a pointer and a length

A tabela de frequências é o uso do dia a dia. Um new int[128] dentro de uma função chamada cem mil vezes são cem mil alocações; stackalloc int[128] não é nenhuma.

O tamanho tem que ser uma constante pequena. O espaço da stack é cerca de 1MB por padrão — o mesmo orçamento que limitou a profundidade da recursão na parte 7 — então stackalloc em qualquer coisa que cresce com a entrada é como transformar um programa lento num programa morto.

E repare nas duas últimas linhas. data[..500000] num int[] copiou um megabyte. A expressão idêntica num ReadOnlySpan<int> não copiou nada. Mesma sintaxe, custo completamente diferente, e nada no código deixa isso visível.

Use quando precisar de um buffer pequeno e fixo dentro de uma função quente, ou quando estiver fatiando e só lendo.

Padrão 48 — Overflow

A aritmética do C# é unchecked por padrão. O overflow não lança nada; ele dá a volta, em silêncio, como comportamento definido.

−2,147,483,648 0 2,147,483,647 + 1 cai aqui. Sem exceção. Sem aviso. 2,000,000,000 + 2,100,000,000 não falha — vira −194,967,296, e a busca binária construída em cima lê um índice negativo, calada.

A aritmética do C# é unchecked por padrão. Overflow não é um erro, é comportamento definido que dá a volta — e por isso a falha aparece longe da linha que a causou.

// 1. The classic binary-search overflow.
int lo = 2_000_000_000, hi = 2_100_000_000;
Console.WriteLine($"(lo + hi) / 2      = {(lo + hi) / 2}   <- negative, and silently so");
Console.WriteLine($"lo + (hi - lo) / 2 = {lo + (hi - lo) / 2}   <- correct");

// 2. Summing ints into an int.
int[] big = [.. Enumerable.Repeat(500_000_000, 6)];
int sumInt = 0;
foreach (int x in big) sumInt += x;
long sumLong = 0;
foreach (int x in big) sumLong += x;
Console.WriteLine($"\nsix values of 500,000,000");
Console.WriteLine($"  into an int  : {sumInt,20:N0}");
Console.WriteLine($"  into a long  : {sumLong,20:N0}");

// 3. C# does NOT check by default. It will if you ask.
try
{
    checked { int bad = int.MaxValue; bad++; Console.WriteLine(bad); }
}
catch (OverflowException)
{
    Console.WriteLine($"\nchecked { "{" } int.MaxValue + 1 { "}" } threw OverflowException");
}
int quiet = int.MaxValue;
unchecked { quiet++; }
Console.WriteLine($"unchecked int.MaxValue + 1 = {quiet}   (this is the default)");

// 4. The limits, for reference.
Console.WriteLine($"\nint  max {int.MaxValue,26:N0}   about 2.1 x 10^9");
Console.WriteLine($"long max {long.MaxValue,26:N0}   about 9.2 x 10^18");
Console.WriteLine($"\n1000 * 1000 * 1000 * 4 as int  = {unchecked(1000 * 1000 * 1000 * 4)}");
Console.WriteLine($"1000L * 1000 * 1000 * 4        = {1000L * 1000 * 1000 * 4:N0}");

Ele imprime:

(lo + hi) / 2      = -97483648   <- negative, and silently so
lo + (hi - lo) / 2 = 2050000000   <- correct

six values of 500,000,000
  into an int  :       -1,294,967,296
  into a long  :        3,000,000,000

checked { int.MaxValue + 1 } threw OverflowException
unchecked int.MaxValue + 1 = -2147483648   (this is the default)

int  max              2,147,483,647   about 2.1 x 10^9
long max  9,223,372,036,854,775,807   about 9.2 x 10^18

1000 * 1000 * 1000 * 4 as int  = -294967296
1000L * 1000 * 1000 * 4        = 4,000,000,000

Três hábitos saem dessa saída.

Escreva lo + (hi - lo) / 2. Toda busca binária da parte 4 usou isso. (lo + hi) / 2 é correto para valores pequenos e produz um índice negativo para os grandes.

Some dentro de um long. Seis valores que cabem folgados num int não têm uma soma que cabe. O tipo do acumulador é o que importa, não o dos elementos.

Faça o cast antes de multiplicar, não depois. (long)a * b alarga a primeiro, então a multiplicação acontece em 64 bits. (long)(a * b) faz a multiplicação em int, dá overflow, e depois alarga a resposta errada.

A regra prática: int acaba um pouco depois de dois bilhões. Se qualquer valor intermediário puder chegar lá — e um produto de dois valores perto de 10⁵ já chega — use long. Não custa nada num runtime de 64 bits.

checked { } é útil enquanto você depura, para fazer o overflow se anunciar na linha que o causou em vez de lá adiante.

Padrão 49 — Aritmética modular

Respostas “módulo 10⁹+7” aparecem o tempo todo, porque a resposta real precisaria de uma biblioteca de inteiros grandes.

Duas coisas são necessárias: exponenciação que não leve um bilhão de multiplicações, e divisão, que não existe.

const long Mod = 1_000_000_007;

// Fast exponentiation: square the base, halve the exponent.
static long Power(long b, long e, long m)
{
    long result = 1;
    b %= m;
    while (e > 0)
    {
        if ((e & 1) == 1) result = result * b % m;
        b = b * b % m;
        e >>= 1;
    }
    return result;
}

Console.WriteLine($"2^10 mod {Mod}         = {Power(2, 10, Mod)}");
Console.WriteLine($"2^1000000 mod {Mod}    = {Power(2, 1_000_000, Mod)}");
Console.WriteLine($"steps for e=1000000    : {(int)Math.Log2(1_000_000) + 1} squarings, not a million multiplications");

// Division does not exist mod p. Multiply by the modular inverse instead.
// Fermat: a^(p-1) = 1 mod p, so a^(p-2) is the inverse when p is prime.
static long Inverse(long a, long m) => Power(a, m - 2, m);

long inv3 = Inverse(3, Mod);
Console.WriteLine($"\ninverse of 3           = {inv3}");
Console.WriteLine($"3 * inverse(3) mod p   = {3 * inv3 % Mod}   <- 1, so it really is the inverse");
Console.WriteLine($"10 / 3 mod p           = {10 * inv3 % Mod}");
Console.WriteLine($"check: that * 3 mod p  = {10 * inv3 % Mod * 3 % Mod}   <- back to 10");

// The trap: int arithmetic overflows BEFORE the modulus is applied.
int a = 1_000_000_006, b = 1_000_000_006;
Console.WriteLine($"\n(int)a * b % Mod       = {unchecked(a * b) % Mod}   <- wrong, a*b overflowed int first");
Console.WriteLine($"(long)a * b % Mod      = {(long)a * b % Mod}   <- correct");

Ele imprime:

2^10 mod 1000000007         = 1024
2^1000000 mod 1000000007    = 235042059
steps for e=1000000    : 20 squarings, not a million multiplications

inverse of 3           = 333333336
3 * inverse(3) mod p   = 1   <- 1, so it really is the inverse
10 / 3 mod p           = 333333339
check: that * 3 mod p  = 10   <- back to 10

(int)a * b % Mod       = 923446813   <- wrong, a*b overflowed int first
(long)a * b % Mod      = 1   <- correct

Power eleva a base ao quadrado e corta o expoente pela metade, então 2^1000000 leva vinte elevações ao quadrado.

A divisão é substituída pela multiplicação pelo inverso modular. O pequeno teorema de Fermat diz que, para um primo p, a^(p-1) ≡ 1, então a^(p-2) é o inverso de a. Como 10⁹+7 é primo, Inverse é uma chamada a Power. A saída confirma: 3 * inverse(3) ≡ 1, e dividir e depois multiplicar devolve o original.

As duas últimas linhas são o bug de que este padrão realmente trata. a * b com os dois int dá overflow antes de % Mod rodar, e o resultado é errado parecendo totalmente razoável. Mantenha tudo em long, e tire o módulo depois de cada multiplicação em vez de só no fim.

Use quando o problema disser “módulo 10⁹+7”. Problemas de contagem, contagem de caminhos, combinatória.

Padrão 50 — Escrevendo a saída

A imagem espelhada do padrão 46, e o efeito é maior.

Console.Out tem AutoFlush ligado. Cada WriteLine empurra para o stream por baixo, então 200,000 linhas não são 200,000 operações de string — são 200,000 viagens pelo sistema operacional.

using System.Text;

// The cost of Console.WriteLine is not allocation — it is that AutoFlush
// pushes to the underlying stream on EVERY call. Count those pushes.
int n = 200_000;

static void Run(string name, Action<Stream> write)
{
    var s = new CountingStream();
    write(s);
    Console.WriteLine($"{name,-26} {s.Writes,9:N0} writes to the stream   {s.Bytes,10:N0} bytes");
}

Run("AutoFlush = true", s =>
{
    var w = new StreamWriter(s) { AutoFlush = true };   // what Console.Out does
    for (int i = 0; i < n; i++) w.WriteLine(i);
    w.Flush();
});

Run("64KB buffer, no AutoFlush", s =>
{
    var w = new StreamWriter(s, bufferSize: 1 << 16) { AutoFlush = false };
    for (int i = 0; i < n; i++) w.WriteLine(i);
    w.Flush();
});

Run("one StringBuilder", s =>
{
    var sb = new StringBuilder();
    for (int i = 0; i < n; i++) sb.Append(i).Append('\n');
    var w = new StreamWriter(s, bufferSize: 1 << 16) { AutoFlush = false };
    w.Write(sb);
    w.Flush();
});

Console.WriteLine("\nIn a real program the fix is one line at the top:");
Console.WriteLine("  Console.SetOut(new StreamWriter(Console.OpenStandardOutput(),");
Console.WriteLine("                                  bufferSize: 1 << 16) { AutoFlush = false });");
Console.WriteLine("and Console.Out.Flush() before returning, or the output never arrives.");

class CountingStream : Stream
{
    public int Writes { get; private set; }
    public long Bytes { get; private set; }
    public override void Write(byte[] buffer, int offset, int count) { Writes++; Bytes += count; }
    public override void Write(ReadOnlySpan<byte> buffer) { Writes++; Bytes += buffer.Length; }
    public override bool CanWrite => true;
    public override bool CanRead => false;
    public override bool CanSeek => false;
    public override long Length => Bytes;
    public override long Position { get => Bytes; set => throw new NotSupportedException(); }
    public override void Flush() { }
    public override int Read(byte[] b, int o, int c) => throw new NotSupportedException();
    public override long Seek(long o, SeekOrigin s) => throw new NotSupportedException();
    public override void SetLength(long v) => throw new NotSupportedException();
}

Ele imprime:

AutoFlush = true             400,000 writes to the stream    1,288,890 bytes
64KB buffer, no AutoFlush         20 writes to the stream    1,288,890 bytes
one StringBuilder                 20 writes to the stream    1,288,890 bytes

In a real program the fix is one line at the top:
  Console.SetOut(new StreamWriter(Console.OpenStandardOutput(),
                                  bufferSize: 1 << 16) { AutoFlush = false });
and Console.Out.Flush() before returning, or the output never arrives.

Quatrocentas mil escritas contra vinte, para uma saída idêntica byte a byte. (Quatrocentas mil e não duzentas mil, porque WriteLine escreve o valor e a quebra de linha separadamente.)

Medido nesta máquina, 200,000 linhas levaram uns 190 ms sem buffer, uns 24 ms através de um buffer de 64KB, e uns 15 ms acumulando primeiro num StringBuilder. É a mesma ordem de economia que todo o resto desta parte, a partir de uma linha de configuração.

A coisa que vai te morder: dê flush antes de sair. Com AutoFlush = false, tudo que ainda estiver no buffer quando o processo termina simplesmente se perde, e o sintoma é uma solução que tira zero enquanto funciona perfeitamente na sua máquina — porque uma sessão de debug dá flush ao sair e o juiz pode não dar.

O que lembrar

  • Console.ReadLine num laço é a razão de sempre para uma solução C# correta estourar o limite de tempo. Não o algoritmo.

  • int.Parse aceita um ReadOnlySpan<char>. Trocar o tipo e indexar com um Range elimina toda alocação de substring com quase nenhum esforço.

  • array[a..b] copia. span[a..b] não. Sintaxe idêntica, e o custo é invisível no ponto da chamada.

  • stackalloc é só para tamanhos constantes pequenos. Ele divide a stack de 1MB que limita a recursão.

  • Use lo + (hi - lo) / 2, acumule num long, e faça o cast antes de multiplicar. (long)a * b, nunca (long)(a * b).

  • C# não avisa sobre overflow. Ele dá a volta. checked { } enquanto você depura faz ele falar na linha certa.

  • a^(p-2) mod p é o inverso modular quando p é primo, e 10⁹+7 é. Tire o módulo depois de cada multiplicação, em aritmética long.

  • Desligue o AutoFlush, e dê flush antes de sair. Esquecer o flush perde a saída inteira, e parece uma resposta errada em vez de uma resposta faltando.

São todos os cinquenta

Dez partes, cinquenta padrões, de dois inteiros caminhando um na direção do outro até o buffer que decide se alguma coisa disso termina a tempo.

Nada disso vale muito como uma lista para decorar. O que torna esses padrões úteis é reconhecer a forma — minimizar o máximo significa busca binária sobre a resposta, valores de 1 a n significa que o array é sua própria tabela hash, exatamente K significa contar “no máximo” duas vezes e subtrair. O código é a parte fácil quando a forma já tem nome.

A série completa é Padrões de programação competitiva em C#, e todo programa nela foi rodado no .NET 10 antes de ser publicado.

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.