Problema das mesas de credenciamento

Eventos com inscrição prévia, do Fórum Internacional do Software Livre às plenárias do Congresso do PSOL nas grandes cidades, costumam ter mesas de credenciamento pelas quais os presentes precisam passar para pegar um crachá.

Quando há muitos participantes é comum que as letras do alfabeto sejam separadas entre essas mesas, de forma a dividir as pessoas pelas iniciais dos seus nomes e assim reduzir o tempo de espera nas filas que se formam.

Exemplo: Se há três mesas de credenciamento, pode-se atribuir a elas os intervalos letras A-I, J-R e S-Z. Nesse caso, alguém com um nome que comece com B (como Bruno) deve se credenciar na mesa A-I, enquanto alguém com um nome que comece com T (como Tiago) deve se credenciar na mesa S-Z.

Uma dificuldade conhecida por todos que já organizaram eventos desse tipo é que alguns particionamentos do alfabeto podem levar a formar umas filas grandes e outras pequenas. Em particular, deixar o mesmo número de letras em cada conjunto costuma ser uma estratégia muito ineficiente, porque em português muitos nomes começam com as letras A, J e M, mas poucos começam com as letras Q, U e X.

O problema que surge a partir dessa constatação é: Como dividir as letras do alfabeto de forma a garantir que o número de participantes por mesa esteja o mais equilibrado possível?

Há uma restrição: As letras precisam ser divididas de forma que formem partições contíguas na ordem alfabética. Isso é, não se pode definir que A e C ficam num conjunto 1, enquanto B fica num conjunto 2. Se A e C estão no conjunto 1, todas as letras entre A e C (B, no caso) também precisam estar no conjunto 1.

Trata-se claramente de um problema de otimização. Para desenvolver um algoritmo (que nada mais é do que um procedimento computacional) para resolvê-lo, convém definir o que esperamos como entrada e o que esperamos como saída.

Entrada:

  • nn: número de letras no alfabeto
  • aia_i: número de participantes que começam com a ii-ésima letra do alfabeto (1in1 \leq i \leq n).
  • kk: número de mesas de credenciamento (partições que devem ser formadas)

Saída:

  • cic_i: primeira letra da ii-ésima partição do alfabeto (1cin1 \leq c_i \leq n, 1ik1 \leq i \leq k)

Um parênteses: Enquanto este texto era escrito, o governo do Distrito Federal começou uma campanha de vacinação contra a influenza dividindo idosos em 5 grupos a partir das suas iniciais. De acordo com a Agência Brasil,

  • Na segunda-feira (23), devem ser vacinados os idosos cujo nome comece com as letras A, B, C, D e E.
  • Na terça-feira (24), pessoas com 60 anos ou mais com nomes iniciados pelas letras F, G, H, I e J.
  • Na quarta-feira (25), idosos que tenham nas letras iniciais do nome K, L, M, N e O.
  • Na quinta-feira (26), pessoas com 60 ou mais com o primeiro nome iniciado em P, Q, R, S e T.
  • Na sexta-feira (27), devem se vacinar os idosos com nomes que comecem com U, V, W, X, Y e Z.

É fácil ver que esse governo teve que resolver o problema proposto aqui para separar as pessoas em 5 dias (que seriam equivalentes a mesas de credenciamento, na nossa formulação). Terá ele feito uma escolha ótima? Infelizmente acho difícil que os idosos que tenham nomes começando com letras entre A e E não tenham que esperar muito mais tempo na fila do que os idosos que tenham nomes começando com letras entre U e Z.

Como definir equilíbrio?

Idealmente gostaríamos de dividir os conjuntos de nomes igualmente. Se t=ait = \sum a_i é o número total de participantes do evento (a soma dos números de participantes com cada letra do alfabeto) e kk é o número de mesas de credenciamento, então o número de participantes que gostaríamos de colocar em cada mesa é tk\frac{t}{k}.

Há diferentes formas de medir o quão longe um valor se encontra do seu valor esperado. Uma das medidas mais populares, que decidi usar aqui, é o desvio padrão.

Se xix_i é o número de participantes que devem se credenciar na ii-ésima mesa (para ii variando de 1 a kk), então o desvio padrão dessa solução é:

σ:=1ki=1k(xitk)2.\sigma := \sqrt{\frac{1}{k} \sum_{i=1}^k \left(x_i - \frac{t}{k}\right)^2}.

Exemplo: Suponha que temos 42 nomes que começam com A, 25 nomes que começam com B e 33 nomes que começam com C. Então temos a1=42a_1 = 42, a2=25a_2 = 25, a3=33a_3 = 33 e t=a1+a2+a3=42+25+33=100t = a_1 + a_2 + a_3 = 42 + 25 + 33 = 100. Suponha que precisamos dividir esses nomes em duas mesas de credenciamento, portanto k=2k = 2. Podemos dividir as pessoas de duas formas diferentes:

  1. A e B-C.
  2. A-B e C.

Na primeira solução, temos uma mesa com 42 pessoas e outra mesa com 58 (25+33). Na segunda solução, termos uma mesa com 67 pessoas (42+25) e outra mesa com 33. Note que tk=1002=50\frac{t}{k} = \frac{100}{2} = 50.

Então o desvio padrão da primeira solução é

σ1=12((4250)2+(5850)2)=8\sigma_1 = \sqrt{\frac{1}{2} ((42-50)^2 + (58-50)^2)} = 8

e o desvio padrão da segunda solução é

σ2=12((6750)2+(3350)2)=17.\sigma_2 = \sqrt{\frac{1}{2} ((67-50)^2 + (33-50)^2)} = 17.

Como σ1<σ2\sigma_1 < \sigma_2, dizemos que a primeira solução é mais equilibrada do que a segunda. Nosso objetivo, portanto, é encontrar uma solução que minimize o desvio padrão.

(Note que o desvio padrão nunca é negativo e a solução ideal para o nosso problema, que divide os participantes perfeitamente, tem desvio padrão 0.)

Se você quiser tentar resolver o problema antes de ler sobre a solução, pare de ler aqui. Caso contrário, pode continuar a leitura.

Abordagem gulosa

Uma solução que que alguém poderia sugerir é inicialmente dividir a lista de nomes em kk partes e aí tentar, para cada um dos cortes, decidir se é melhor deslocá-lo para frente ou para trás. Talvez uma forma razoável de tomar essa decisão seja ver qual delas minimiza o desvio padrão para o conjunto que estamos deixando para trás.

Exemplo: Suponha que a1=42,a2=25,a3=27,a4=18,a5=38a_1 = 42, a_2 = 25, a_3 = 27, a_4 = 18, a_5 = 38 e k=3k = 3. Então tk=1503=50\frac{t}{k} = \frac{150}{3} = 50.

Ilustração do exemplo descrito acima. Nomes com A, B, C, D e E são representados, respectivamente, pelas cores azul, vermelho, verde, laranja e azul claro.

A solução proposta inicialmente divide esses nomes em 3 partes iguais. Os cortes das divisões estariam (1) entre o oitavo e o nono nome da letra B e (2) entre o sexto e o sétimo nome da letra D.

Ilustração das divisões entre os nomes. Note que o primeiro corte está entre o oitavo e o nono nome da letra B e o segundo corte está entre o sexto e o sétimo nome da letra D.

Para diminuir o desvio padrão do primeiro conjunto é melhor mover o primeiro corte para entre as letras A e B, porque 42 é mais próximo de 50 (diferença 8) do que 67 (diferença 17). Já para diminuir o desvio padrão do segundo conjunto é melhor mover o segundo corte para entre as letras C e D, porque 52 é mais próximo de 50 (diferença 2) do que 70 (diferença 20).

A solução encontrada portanto separa as letras em A, B-C e D-E.

Ilustração da solução encontrada.

Esse tipo de solução é chamado, na Ciência da Computação, de “guloso” (greedy, em inglês). As técnicas gulosas são aquelas que procuram soluções locais ótimas na esperança de encontrarem uma solução global ótima. Note que na solução sugerida otimizamos corte por corte (localmente), sem pensar muito sobre o que nos espera no futuro (global).

Algoritmos gulosos, quando encontram a solução ótima, são uma beleza: normalmente são fáceis de escrever e super rápidos. Porém, nem sempre algoritmos gulosos que funcionam para alguns casos funcionam para todos. Para não nos enganarmos, é sempre bom provar a corretude de um algoritmo guloso, isso é, provar matematicamente que ele funciona para todas as entradas. Já para provar que um algoritmo guloso não funciona basta apresentar um contra-exemplo, isso é, uma entrada para o qual o algoritmo guloso não produz a saída esperada.

Uma tentativa de explicar melhor, em termos lógicos, o parágrafo acima: Suponha que representemos nosso algoritmo como uma função ff. Uma entrada é representada por xx e a saída esperada para xx é representada por yy. Então, se o algoritmo funciona, esperamos que x(f(x)=y)\forall x (f(x) = y) (isso é, para todo xx, o algoritmo leve xx em yy). A negação dessa proposição lógica é x(f(x)y)\exists x (f(x) \neq y) (isso é, existe xx tal que o algoritmo não leva xx em yy). É por isso que para provar que um algoritmo não funciona para todas as entradas basta apresentar um contra-exemplo.

Afirmo que o algoritmo guloso apresentado acima não é capaz de encontrar a solução ótima para todas as entradas. Você é capaz de construir um contra-exemplo para provar isso?

Se quiser tentar construir um contra-exemplo por sua conta, pare de ler aqui. Caso contrário, pode continuar a leitura para ver o meu contra-exemplo.

Contra-exemplo: Suponha que temos 120 nomes, sendo que 35 começam com A, 8 com B, 27 com C e 50 com D. Queremos dividí-los em três mesas de credenciamento. Então temos a1=35,a2=8,a3=27,a4=50a_1 = 35, a_2 = 8, a_3 = 27, a_4 = 50 e k=3k = 3.

A abordagem gulosa inicialmente divide esses nomes em três partes iguais, conforme ilustração a seguir.

Ilustração da divisão inicial entre os nomes.

Então, corte por corte, vamos tentar minimizar o desvio padrão do conjunto de nomes que estamos deixando para trás. Para minimizar o desvio padrão do primeiro conjunto é melhor mover o primeiro corte para entre as letras B e C, porque 43 (35+8) é mais próximo de 40 do que 35. Já para minimizar o desvio padrão do segundo conjunto é melhor mover o segundo corte para entre as letras C e D. A solução encontrada pelo algoritmo guloso é portanto separar as letras nos conjuntos A-B, C e D.

Ilustração da solução encontrada pelo algoritmo guloso.

O desvio padrão dessa solução é:

σg=13(32+132+102)16.67.\sigma_g = \sqrt{\frac{1}{3} (3^2 + 13^2 + 10^2)} \approx 16.67.

Essa solução não é ótima para essa entrada, porque se tivéssemos colocado o primeiro corte entre A e B conseguiríamos um desvio padrão menor:

σ=13(52+52+102)12.25.\sigma_\star = \sqrt{\frac{1}{3} (5^2 + 5^2 + 10^2)} \approx 12.25.

Ilustração da solução ótima, que não foi encontrada pelo algoritmo guloso.

Logo, o algoritmo guloso não funciona para todas as entradas.

O contra-exemplo mostra como ótimos locais não implicam num ótimo global. A gulodice aqui não funcionou, por isso talvez seja melhor construir um algoritmo usando outras técnicas.

Força bruta

Como o computador é uma máquina capaz de executar bilhões de instruções por segundo (é exatamente isso que é medido pelos Hz que usamos para medir os processadores), uma forma de resolver problemas de otimização sem pensar muito é enumerar todas as soluções possíveis e compará-las. Essa abordagem é chamada, na Ciência da Computação, de força bruta (não confundir com bater nas pessoas para elas não reclamarem da fila de credenciamento).

Uma forma intuitiva de resolver este problema testando todas as soluções é construir um algoritmo recursivo que, a cada passo, faça um novo corte dividindo as letras que restam. Isso é, no primeiro passo tentamos colocar um corte entre todo par adjacente de letras, nos restantes tentamos colocar um corte entre todo par adjacente de letras a direita dos cortes já feitos.

Construiremos uma função recursiva ff que recebe dois parâmetros: ss é o índice da primeira letra do que resta no alfabeto e mm é o número restante de mesas que devem ser formadas. f(s,m)f(s, m) vai retornar uma variância não normalizada (que para todos os efeitos é igual ao desvio padrão, porque minimiza ele) da solução ótima considerando as letras a partir de ss e dividindo-as em mm mesas de credenciamento.

Uma nota sobre desvio padrão e variância: A variância é o desvio padrão ao quadrado (isso é, sem tirar a raiz quadrada), ou seja, 1ki=1k(xitk)2\frac{1}{k} \sum_{i=1}^k \left(x_i - \frac{t}{k}\right)^2. Como minimizar um número é a mesma coisa que minimizar ele ao quadrado, usamos a variância para não precisarmos nos preocupar em ficar tirando raízes no código. Esse valor 1k\frac{1}{k} é constante. Então o que chamei de variância não normalizada aí em cima é simplesmente i=1k(xitk)2\sum_{i=1}^k \left(x_i - \frac{t}{k}\right)^2. Minimizar esse valor é a mesma coisa que minimizar o desvio padrão. Nos parágrafos seguintes, chamarei simplesmente de variância o que aqui defini como variância não normalizada.

Vamos considerar que o vetor aa de números de nomes por letra é global, assim como o tamanho do alfabeto nn e o valor μ\mu (mu, no código) que é a média tk\frac{t}{k} que usaremos para computar a variância da solução. Estamos interessados na solução de f(0,k)f(0, k), que é a solução do nosso problema porque é a variância da solução ótima considerando todas as letras e dividindo em kk mesas de credenciamento.

Essa recursão tem alguns casos-base para os quais podemos retornar imediatamente valores computados diretamente, a saber:

  • f(n,0)=0f(n, 0) = 0 — isso é, se não há letras para serem divididas em conjuntos e não há cortes a serem feitos, então a variância da solução é 0 (não há nada a ser feito).
  • f(s,0)=f(s, 0) = \infty para qualquer s0s \neq 0 — isso é, se há letras para serem divididas em conjuntos, mas não há mesas para colocá-las, então a variância da solução é infinita (o problema não tem solução).
  • f(n,m)=mμ2f(n, m) = m \mu^2 para qualquer mkm \neq k — isso é, se não há letras para serem divididas e há mm mesas, então a variância da solução é mμ2m\mu^2 porque estamos somando mm mesas vazias (com 0 pessoas).

Para os demais s,ms, m, temos:

f(s,m)=mini=sn1((μj=siaj)2+f(i+1,m1)).f(s, m) = \min_{i=s}^{n-1}\left(\left(\mu - \sum_{j=s}^{i} a_j\right)^2 + f(i+1, m-1)\right).

Ou seja, fazemos todos os cortes possíveis (eles são representados por ii) e minimizamos a variância correspondente a fazê-los: estamos somando a variância do conjunto [s,i][s, i] (primeiro parâmetro da soma) com a variância da solução ótima para o conjunto [i+1,n)[i+1, n) com m1m-1 mesas (segundo parâmetro da soma).

Então o código da solução usando força bruta, em Python (linguagem escolhida porque está na moda), ficaria como segue:

a = [...]  # parâmetro de entrada (vetor com número de nomes por letra)
k = ...    # parâmetro de entrada (número de mesas)

n = len(a)       # tamanho do alfabeto
mu = sum(a) / k  # média usada para calcular as variâncias (t/k)

inf = float('inf')  # infinito

def f(s, m):
    if s == n and m == 0:
        return 0
    if m == 0:
        return inf
    if s == n:
        return m * mu * mu

    menor = inf
    soma = 0
    for i in range(s, n):
        soma += a[i]
        variancia = (mu - soma) ** 2
        resto = f(i+1, m-1)
        if variancia + resto < menor:
            menor = variancia + resto

    return menor

print("A variância da solução ótima é:", f(0, k))

Essa solução funciona (provar isso é um exercício de indução matemática: prova-se a solução dos casos-base e depois que subsoluções ótimas garantem soluções ótimas). Note que ela tem uma semelhança com o algoritmo guloso na medida em que a cada passo nos preocupamos com uma mesa (corte), porém ela testa todas as possibilidades possíveis de corte em vez de escolher gulosamente onde fazer cada corte.

O problema da força bruta é seu tempo de execução. Por mais rápido que sejam nossos computadores, custos exponenciais não são razoáveis para resolver problemas com entradas suficientemente grandes.

Uma observação sobre o universo: Segundo o consenso científico, o número de átomos no universo observável é por volta de 108010^{80}. Já a idade do universo é cerca de 14 bilhões de anos (1.4×10101.4 \times 10^{10}), o que equivale a cerca de 4.4×10264.4 \times 10^{26} nanossegundos. Suponha que todos os átomos do universo estejam, desde o surgimento do universo, realizando uma instrução computacional por nanossegundo, ou seja, cada átomo do universo seja um computador operando a 1GHz. Então até agora eles teriam realizado cerca de 4.4×101064.4 \times 10^{106} operações. Se o tempo de execução de um algoritmo é exponencial no tamanho da entrada, o universo todo não é capaz de executá-lo para entradas suficientemente grandes — e “grande” nessa colocação são números que consideraríamos razoáveis, como 50 ou 100.

Testar todas as possibilidades costuma levar um tempo exponencial no tamanho da entrada, e no nosso problema não é diferente.

Um esboço relaxado para computar o tempo de execução da força bruta: Para medir o custo de um algoritmo recursivo, temos que construir e resolver uma recorrência. No nosso caso, temos:

T(n,k)=i=1nT(ni,k1),T(n, k) = \sum_{i=1}^n T(n-i, k-1),

onde T(n,k)T(n, k) denota o número de operações necessárias para resolver o problema com entrada n,kn, k. Com alguma manipulação algébrica, podemos ver que, para n>1n > 1, vale:

T(n,k)=T(n1,k)+T(n1,k1),T(n, k) = T(n-1, k) + T(n-1, k-1),

que é exatamente a propriedade dos coeficientes binomiais conforme conferimos no Triângulo de Pascal. Os casos-base são um pouco diferentes dos coeficientes binomiais, por isso não se trata exatamente de dizer que o número de operações da solução seja (nk){n \choose k}, mas dá para dizer que se aproxima disso por alguma constante. Portanto, o custo é exponencial no tamanho da entrada, na ordem de 2n2^n.

Talvez seja razoável afirmar que a solução com força bruta é suficiente para o escopo do nosso problema quando estamos limitados ao alfabeto latino, que tem 26 letras. Porém, o que fazer caso o alfabeto seja maior — tenha, digamos, 1000 letras? Um algoritmo exponencial não é capaz de terminar na idade do universo neste caso nem com todos os átomos do universo trabalhando para isso. Isso nos motiva a usar programação dinâmica.

Programação dinâmica

Programação dinâmica, diferente do que o nome indica, não tem nada a ver (ao menos diretamente) com programação de computadores. Na verdade, há quem diga que um nome mais adequado para esse método de otimização de programas seria “recursão com uma tabela”.

Muitas vezes um subproblema é resolvido diversas vezes dentro de um procedimento recursivo; em particular, é fácil ver que isso acontece na força bruta proposta na seção acima. Note que f(1,3f(1, 3) precisa do resultado de f(2,2),f(3,2),f(4,2),f(2, 2), f(3, 2), f(4, 2), \cdots e também que f(2,3)f(2, 3) precisa do resultado de f(3,2),f(4,2),f(3, 2), f(4, 2), \cdots. A tabela abaixo, mostrando a árvore de recursão para resolver o problema para n=6n = 6 e k=4k = 4, mostra que várias computações estão sendo repetidas.

Árvore de computações da força bruta. Os nós em vermelho denotam computações repetidas. Nota-se que grande parte das computações está sendo feita várias vezes.

A ideia da programação dinâmica é simplesmente tabelar os resultados da recursão para não precisar recalculá-los várias vezes. Usando programação dinâmica, tudo que está em vermelho na ilustração acima pode ser diretamente retornado pela função — não é preciso recomputar f(s,m)f(s, m) mais de uma vez para nenhum par s,ms, m.

A forma mais direta de implementar programação dinâmica em cima de um algoritmo recursivo é simplesmente guardar cada resultado da recursão numa tabela e usar os resultados já guardados previamente. Modificando o código da força bruta, temos:

# A linha abaixo inicializa com -1 uma matriz (n+1)x(k+1). Python é esquisito.
pd = [[-1 for i in range(k+1)] for j in range(n+1)]

def f(s, m):
    if s == n and m == 0:
        return 0
    if m == 0:
        return inf
    if s == n:
        return m * mu * mu
    if pd[s][m] != -1:
        # Já temos o resultado de f(s, m) e podemos retorná-lo diretamente.
        return pd[s][m]

    menor = inf
    soma = 0
    for i in range(s, n):
        soma += a[i]
        variancia = (mu - soma) ** 2
        resto = f(i+1, m-1)
        if variancia + resto < menor:
            menor = variancia + resto

    return pd[s][m] = menor

O número de operações agora é proporcional a kn2kn^2, que é um valor polinomial no tamanho da entrada — ou seja, a programação dinâmica reduziu a complexidade exponencial do nosso problema e agora podemos resolvê-lo para alfabetos muito maiores. Para alfabetos de até mil letras e para até mil mesas de credenciamento um computador comum deve ser capaz de dar uma solução para o problema de forma eficiente em cerca de 1s. Mais importante, a medida que considerarmos alfabetos maiores o tempo de execução só aumenta por um fator polinomial — não mais exponencial.

A recursão, na solução com programação dinâmica, pode facilmente ser transformada em iterações. Para fazer isso, basta garantir que a tabela seja preenchida na ordem correta: não podemos tentar acessar alguma coisa que ainda não foi computada. O código abaixo mostra como poderia ficar uma implementação iterativa da programação dinâmica:

# Casos-base.
for m in range(k+1):
    pd[n][m] = m * mu * mu
for s in range(n+1):
    pd[s][0] = inf
pd[n][0] = 0

for s in range(n-1, -1, -1):
    for m in range(1, k+1):
        menor = inf
        soma = 0
        for i in range(s, n):
            soma += a[i]
            variancia = (mu - soma) ** 2
            resto = pd[i+1][m-1]
            if variancia + resto < menor:
                menor = variancia + resto
        pd[s][m] = menor

Por isso a programação dinâmica é muitas vezes apresentada como uma estratégia bottom-up e não top-down.

Programação dinâmica é uma técnica recorrente em problemas de olimpíadas de programação, como OBI, ACM ICPC, TopCoder, CodeForces, Google Code Jam e Facebook Hacker Cup, e possui muitas aplicações no mundo real. A Wikipedia tem uma lista de algoritmos clássicos que usam programação dinâmica.

Recuperando a solução

Nas seções anteriores apresentei alguns códigos que, embora encontrem o desvio padrão da solução ótima, não nos dizem qual é a solução — isso é, onde devem ser feitos os cortes. Há diversas formas de recuperar a solução a partir dos algoritmos mostrados. Uma que demandaria uma alteração simples nos códigos seria criar uma outra matriz n×kn \times k que guarda, para cada par (s,m)(s, m) o ponto em que fizemos o mm-ésimo corte.

Para recuperar a solução, basta usar essa informação. O código completo ficaria como segue:

from typing import List

def final(a: List[int], k: int):
    """Exemplo de uso: final([35, 8, 27, 50], 3)"""
    n = len(a)       # tamanho do alfabeto
    mu = sum(a) / k  # média usada para calcular as variâncias (t/k)

    inf = float('inf')  # infinito

    pd = [[-1 for i in range(k+1)] for j in range(n+1)]
    sol = [[0 for i in range(k+1)] for j in range(n+1)]

    for m in range(k+1):
        pd[n][m] = m * mu * mu
    for s in range(n+1):
        pd[s][0] = inf
    pd[n][0] = 0

    for s in range(n-1, -1, -1):
        for m in range(1, k+1):
            menor = inf
            soma = 0
            for i in range(s, n):
                soma += a[i]
                variancia = (mu - soma) ** 2
                resto = pd[i+1][m-1]
                if variancia + resto < menor:
                    menor = variancia + resto
                    sol[s][m] = i
            pd[s][m] = menor

    print("A variância da solução ótima é:", pd[0][k])

    # soma[i] vale a soma dos numeros de a[0...i-1].
    soma = [0 for i in range(n+1)]
    soma[0] = 0
    for i in range(1, n+1):
        soma[i] = soma[i-1] + a[i-1]

    print("Partições:")
    s = 0
    for l in range(k):
        e = sol[s][k-l]
        print("%d. %d-%d (tamanho: %d)" % (l+1, s, e, soma[e+1] - soma[s]))
        s = e + 1

A saída desse programa para final([35, 8, 27, 50], 3) (entrada usada como contra-exemplo para o algoritmo guloso) é, como esperado:

A variância da solução ótima é: 150.0
Partições:
1. 0-0 (tamanho: 35)
2. 1-2 (tamanho: 35)
3. 3-3 (tamanho: 50)

(No alfabeto latino, A=0, B=1, C=2, D=3, etc.)

Finalmente

A ideia desse artigo foi mostrar como um problema real pode ser solucionado por meio de diferentes técnicas de projeto de algoritmos. Conhecer essas técnicas é útil para se abordar diversos problemas; não à toa elas são tão valorizadas em competições de programação. Espero que o artigo tenha utilidade para os que se interessarem em estudar Ciência da Computação durante esse estranho período em que muitos estamos fisicamente isolados.

Agradecimentos ao Pedro, que me apresentou o problema; ao Ulysses, que comentou que o problema apareceu na distribuição das vacinas do governo do Distrito Federal; e a todo mundo que chegou até aqui, pela leitura. Comentários são muito bem-vindos.

There and back again…

Eu até tentei escrever um artigo por dia na semana passada, durante o curso da seletiva brasileira para a Olimpíada Internacional de Informática, mas não deu tempo… Então aqui vai um resumo feito uma semana depois do final do curso, separado em tópicos, com o título pouco criativo “There and back again…”

Nível mais alto

Comparando a prova da segunda fase da OBI2006 com a prova da OBI do ano passado, já era possível perceber que o nível esse ano subiu. E, como já esperado, o nível do curso e das pessoas também subiu, o que é excelente para o Brasil.

Mais prática, menos teoria

Neste ano não aconteceu a programação “normal” que tivemos nos últimos dois anos: aula teórica (só um professor apresentando slides e nós anotando, sem computadores) durante a manhã e aula prática durante a tarde.

Nos dois períodos nossas aulas foram no laboratório, com computadores, onde resolvemos uma porção de problemas do treinamento para a ACM da Universidade de Valladollid.

Criei uma pasta no meu servidor com todos os problemas que eu consegui resolver (alguns deles ficaram pela metade): tiagomadeira.net/pub/uva/ (essa pasta infelizmente foi perdida com o tempo).

Problemas sobre mais de um conteúdo

Uma característica interessante dos problemas desse ano (do treinamento e da prova) foi o uso de mais de um tipo de algoritmo para fazer a solução. A combinação mais comum foi geometria + grafos, que caiu inclusive na prova da Seletiva, no problema Labirinto.

A Prova

Primeiro Dia – Quadrado Romano

[Enunciado] 30 minutos tentando pensar em algum tipo de recorrência, 1h implementando uma solução força bruta! No final, pensei que faria uns 50 pontos (perderia um pouco por causa do tempo), mas perdi alguns pontos por resposta errada, ainda não sei por quê!

Solução: romano.c (infelizmente foi perdido com o tempo)

Pontos: 10/100

Segundo Dia – Euros

[Enunciado] Durante as duas horas da prova fiquei procurando uma recorrência. Descobri que estou muito newbie em programação dinâmica. Não programei uma linha de código…

Pontos: 0/100

Terceiro Dia – Labirinto

[Enunciado] Olhei o enunciado e já saquei o que o problema queria. Eu tenho uma boa noção de grafos (embora precise estudar fluxos) e o curso trabalhou bastante algoritmos geométricos, então sem maiores problemas fiz o algoritmo, testei várias vezes, achei que ia tirar 100. No fim, não sei o que houve, se foi falta de tempo ou resposta errada. Só vi que fiz 40 pontos…

Solução: labirinto.c (infelizmente foi perdido com o tempo)

Pontos: 40/100

Quarto Dia – Prova Final

[Caderno de Tarefas] Li todos os problemas e achei que poderia ir bem na prova (o primeiro problema eu tinha certeza que não conseguiria, mas o segundo e o terceiro dava pra fazer). Então, fui direto para o segundo problema, acreditando que era o mais fácil. Mas depois de bolar vários algoritmos, ter respostas erradas e tempos muito altos, tive que ficar com uma solução precária, um Floyd Warshall para cada troca de vértices (resultado: [tex]O(n^{5})[/tex]!) Aí depois não deu tempo de fazer o terceiro problema, mesmo eu tendo esboçado sua solução.

Solução do Teletransporte: tele.c (infelizmente foi perdido com o tempo)

Pontos: 40/300

Conclusão

O legal é que esse curso sempre dá vontade de estudar, além de ensinar bastante… Aqui ficam registrados meus objetivos e metas pro segundo semestre de 2006 e primeiro semestre de 2007.

Objetivos

Metas

  • Comprar e ler Programming Challenges.
  • Estudar programação dinâmica. Conhecer os algoritmos mais comuns.
  • Estudar fluxos em rede e ordenação topológica.
  • Estudar matemática, inclusive recorrências e geometria (que não ajudam só para olimpíada de matemática, mas pras olimpíadas de informática também)

Curso da Seletiva IOI 2006: Primeiro Dia

Estou hospedado na casa do meu irmão, em Campinas, desde ontem de manhã (viajei domingo a noite de ônibus). Hoje foi o primeiro dia do curso preparatório para a seletiva da IOI2006, que começou às 9h00 devagarinho.

O dia hoje serviu para “nivelar” os participantes e treinar um pouquinho a resolução de problemas. De manhã, algumas pessoas tiveram uma aula sobre estruturas de dados, grafos e o básico de programação dinâmica e outras (a maioria) resolveram três problemas da Universidade de Valladollid:

412 – Pi

EnunciadoMinha Solução

Não é um problema muito complexo, mas eu usava um algoritmo muito demorado para determinar se dois números tinham ou não um fator comum, que não passava por tempo limite excedido. Então, o Fábio Dias Moreira nos passou uma propriedade bem interessante de MDC (Máximo Divisor Comum):

mdc(x, y) = mdc(x%y, y)

(onde o % é o resto da divisão, no C)

Aí dá pra fazer uma função recursiva mdc bem rápida, que eu apliquei na [minha solução][3].

441 – Lotto

EnunciadoMinha Solução

Esse problema nem tem muito o que comentar. Implementação de dois minutos… hehehe… Eu podia ter feito uma função recursiva pra ficar um pouco mais “decente” (e poder mudar de 6 para outro número no futuro), mas não tinha necessidade, então ficou assim mesmo (e passou de primeira no site).

543 – Goldbach’s Conjecture

EnunciadoMinha Solução

O objetivo é provar a Conjectura de Goldbach para todos os pares menores que 1000000. Meu programa ainda não roda dentro do tempo, mas depois vou continuar a adapta-lo. Eu posso, por exemplo, ir fazendo a média entre o maior possível e o menor possível (aquele “algoritmo” que usamos quando alguém fala: “Pensei num número de 1 a 100. Tente advinhar…”) ao invés desses loops, o que já vai tornar o programa mais rápido (não sei se o suficiente).

Nesse problema, o Fábio me lembrou do Crivo de Eratóstenes. Bem interessante, nunca tinha implementado. :)


Provavelmente pela primeira vez, o almoço dos participantes do curso não foi no “bandeijão”, mas sim num restaurante chique perto do IC. Achei bem legal… Além de andar menos, o ambiente é melhor e a comida também.

Durante a tarde, o Fábio nos passou o algoritmo de Longest Common Subsequence (esse eu já sabia… hehehe) e depois o algoritmo que resolve o problema Subseqüências que caiu na prova da segunda fase (esse um pouquinho mais complicado!). Esse segundo eu pretendo implementar e depois até fazer um artigo sobre…

Depois, o Fábio nos deu algumas dicas, principalmente sobre os cálculos problemáticos que o computador faz com pontos flutuantes (algo bem interessante, que eu não entendia porque acontecia). Por exemplo, quando criamos um double x=0.1 o seu valor não é 0.1, mas sim o 1/1010 (em binário) que dá uma dízima periódica! E isso não é muito agradável, pode gerar até loops infinitos… Então, ele sugeriu que criássemos uma função para comparar dois números reais, com um código como esse:

/*
USO: Substituir...
x == y <==> cmp(x, y) == 0
x != y <==> cmp(x, y) != 0
x < y <==> cmp(x, y) < 0
x ### y <==> cmp(x, y) ### y

O "###" é "qualquer-coisa". Hehehe...
*/

#include <math.h>

const double EPS = 1.0e-10

int cmp(double x, double y) {
	if (fabs(x-y)<EPS) {
		return 0;
	} else if (x>y) {
		return 1;
	} else {
		return -1;
	}
}

Foi um bom ponto de partida legal, começamos de leve. Ainda não sei sobre o quê será a aula de amanhã…

Seletiva IOI

Neste ano vão haver quatro provas para selecionar os quatro participantes que irão representar o Brasil na Olimpíada Internacional de Informática em agosto, no México. As três primeiras, identificadas como “testes” no calendário da seletiva, terão apenas um problema e durarão duas horas cada uma. A última será no domingo, às 7h45min, terá três questões e durará cinco horas (pô, vou perder o comecinho do jogo de Brasil contra Austrália!). Achei legal esse método, mas como o César Kawakami disse: dessa maneira, não treinamos a estratégia, que é algo importante para a prova da IOI.


Por enquanto é só. Se der tempo, pretendo colocar um post por dia sobre o curso até o final dessa semana. :)

Férias!

Hoje foi minha última aula desse ano e abertura da OLIS (olimpíada do meu colégio). Começaram extra-oficialmente as férias. Finalmente vou ter um tempinho pra poder estudar informática, matemática e música; aproveitar a praia, viajar, ler… Demorou, hein?

Como toda pessoa organizada (categoria que eu não me enquadro, mas estou tentando), fiz meu “plano” para aproveitar bem essas férias e também para decidir o que eu vou querer no ano que vem. Aqui embaixo está publicado, e sujeito a mudanças (porque meus objetivos sempre podem mudar). Notem também que eu coloquei algumas coisas como “ganhar olimpíada” que seriam conseqüência das outras ações. Além disso, eu coloquei alguns objetivos que podem parecer “sonhos”, mas acho que sempre é bom traçar objetivos difíceis pra tentar ir o mais longe possível.

Informática

Acho que foi a área em que eu menos evoluí nesse ano. É que é incrível que quanto mais eu aprendo, mais percebo que ainda tenho cada vez mais coisa a aprender. Isso não faz sentido matematicamente falando… A informática é desafiante e a gente sempre tem a impressão de que somos ignorantes. É como o Zeh falou num post em seu blog: “O mais legal de ser programador é olhar pra certas coisas que você fez no passado, que achava uma grande idéia, e perceber que aquilo era algo extremamente fedorento.”

Mas vamos lá…

  • Dominar os algoritmos mais básicos de grafos, programação dinâmica e geometria (saber implementá-los sem consulta em C).
  • Obter medalha de ouro na Olimpíada Brasileira de Informática.
  • Participar da Olimpíada Internacional de Informática.
  • Dominar o básico da linguagem C (saber gerenciar memória, usar bibliotecas como ncurses, usar sockets, etc.)
  • Aprender de vez a programar em C/GTK, para criar interfaces gráficas.
  • Dominar conceitos da orientação a objetos (abstração, encapsulamento, herança, poliformismo) e saber implementá-los em Java, C++ e PHP 4 e 5.
  • Aprender um JavaScript mais avançado (saber criar aqueles marquees por exemplo, ou como o cara pode arrastar um div pela tela) e exercitar essas linguagens client-side e Ajax dentro dos padrões web.
  • Saber diferenciar Unix/Linux/FreeBSD/OpenSolaris. Instalar estes outros sistemas no meu laptop.
  • Exorcizar o laptop. Não usar mais nem Flash, abolir o Windows.
  • Converter o laboratório de informática do Colégio Salesiano pra Linux (Edubuntu, que eu conheci essa semana e achei muito massa!).
  • Programar com frameworks.
  • Aprender Awk.
  • Aprender alguma coisa de hardware e de baixo nível (Assembler).

Matemática

Nesse ano, fui mal nas duas olimpíadas (brasileira e catarinense) e mesmo ganhando medalha de bronze na Olimpíada de Maio, não fiquei muito contente. De qualquer maneira, sinto que estou evoluindo na matemática graças as aulas do Vavá e mesmo as do Fabiano, que são lerdas mas às vezes trazem uma novidade.

  • Obter medalha de ouro na Olimpíada Regional de Matemática.
  • Obter medalha na Olimpíada Brasileira de Matemática.
  • Dominar geometria básica (decorar fórmulas dos volumes dos objetos, por exemplo).
  • Fazer exercícios dos Eureka!s
  • Fazer contas mentalmente mais rápido (exemplo: resolver uma Bháskara mentalmente em menos de 15 segundos)
  • Trabalhar com matrizes.
  • Trabalhar com funções de terceiro grau e superiores.
  • Trabalhar com números complexos.
  • Gabaritar a prova de matemática do vestibular do ITA no final do ano.
  • Prosseguir com treinamento para olimpíadas com o professor Vavá.

Física

Física depois desse ano entrando na minha lista de matérias legais e que eu preciso estudar bastante pra passar no ITA… Vamos à lista…

  • Dominar conceitos básicos e conhecer fórmulas básicas (Newton, Kelpler, Galileu, Einstein).
  • Revisar meu livro de física desse ano (2005).
  • Participar da Olimpíada Brasileira de Física.
  • Participar da Olimpíada Brasileira de Astronomia.
  • Prosseguir com grupo de estudos físicos com o professor Valdir.
  • Acertar 75% da prova de física do vestibular do ITA no final do ano.

Trabalho

Resolvi parar de trabalhar no Colégio, porque o salário era muito baixo (cerca de 200 reais é pouco, mesmo pra trabalhar 10 horas por semana) e o emprego fixo é muito chato (tem dias que eu vou lá e não faço nada, outros dias que tem um monte de coisa pra fazer e eu não consigo acabar nada; fora os alunos que vão lá no Lab. de Informática encher o saco – hehehe). Vou pegar mais freelances e acho que vou lucrar mais me dedicando só a isso e aos estudos, tanto financeiramente quanto nos aprendizados. Mas vou fazer uma proposta ao Colégio que é continuar mantendo o site deles (afinal, eles precisam de alguém pra fazer isso), mas fazer de casa e com isso só perder tempo quando precisar de alguma mudança, em casa!

Compras

Compras prioritárias que estou querendo fazer de livros e acessórios nesse ano… Aceito presentes! :D

Passeios e Cursos

Viagens [sendo] programadas…

  • Campinas – SP: Se tudo der certo, pra visitar meu irmão na UNICAMP e participar do Curso de Programação da OBI
  • Porto Alegre – RS: Fórum Internacional do Software Livre
  • Rio de Janeiro – RJ: Não tem nenhum evento não, mas eu queria conhecer.
  • México: Se tudo der certo, estamos lá na olimpíada internacional!

Música

No ano que vem, quero voltar a fazer aula de piano. Acho que farei com a mãe de uma amiga, que dá aula na ADMITA.


Nesse final de semana fomos pra Curitiba (quem acompanha meu feed viu as fotos no Flickr). Meu irmão Bruno fez vestibular pra música/violão na UNICAMP. Ele não achou a prova muito difícil e falou que acertou uns 80%. Ainda tem mais uma fase de prova de conhecimentos gerais e depois é a prova de aptidão (violão). Acho que ele passa… :)

Alguém tem notícias dos caras da UFSC? Já fizemos a final da Olimpíada Regional Catarinense de Matemática (por que eles não mudam o nome pra quem é de fora saber de onde que é e quem é de dentro não pensar que toda a Região Sul participa da olimpíada?) há dois meses, o ano já vai acabar, e NADA! (nem mesmo o gabarito da prova, mesmo sem o resultado final…)

Sempre que eu escrevo posts grandes, eu me perco no meio. Então se alguma parte ficou difícil de entender ou se tem algum erro de português aí, me avisem! :)

Mensageiros Instantâneos, OBM, Novos Programas, Música

Em primeiro lugar, venho por meio deste post comunicar que não uso mais MSN Messenger. O mensageiro instantâneo da Microsoft saiu da minha lista de contas do CenterICQ para a entrada de dois novos e melhores: IRC e GoogleTalk/Jabber. Cheguei a conclusão de que quem quer falar comigo deve usar o que eu uso e não ao contrário, por um motivo óbvio: o meu é melhor que o deles.

Esta decisão fez com que eu perdesse centenas de contatos, mas acho que foi a decisão certa a ser tomada. Quem quiser me contatar agora, pode me adicionar no ICQ como 147330555, GoogleTalk como tmadeira em gmail.com e no IRC/Freenode, como tiagomadeira.

O segundo ponto importante deste post é o anúncio da OBM, Segunda Fase. A prova acontecerá no sábado que vem, dia 03 de setembro. Acho difícil eu conseguir medalha nesse ano (Terceira Fase é difícil!), mas vou tentar me esforçar o máximo possível… Esta semana tivemos o treino para olimpíadas com o Vavá, aprendi algumas coisas úteis. E ontem conversei com o César Kawakami no ICQ que me deu umas dicas interessantes também sobre Teoria dos Números. Vou tentar aprender alguma coisa sobre isso nos próximos dias…

As aulas de matemática dessa semana foram pouco produtivas porque eu andei faltando algumas para a divulgação do fórum do colégio. Então, só deu pra fazer dois problemas: a implementação da Máxima Subcadeia Comum (LCS/Programação Dinâmica):

//LCS - Longest Common Subsequence
//Programação Dinâmica - MSC - Maior Subcadeia Comum

#include <stdio.h>
#define SMAX 1001
#define DIAGONAL 1
#define LADO 2
#define CIMA 3


//n = tamanho de x
//m = tamanho de y

int c[SMAX][SMAX], b[SMAX][SMAX], n, m;
char x[SMAX], y[SMAX];

int lcs_recupera(int i, int j) {
	if (i==0||j==0) {
		return 0;
	}
	if (b[i][j]==DIAGONAL) {
		lcs_recupera(i-1, j-1);
		printf("%c", x[i]);
	} else if (b[i][j]==CIMA) {
		lcs_recupera(i-1, j);
	} else {
		lcs_recupera(i, j-1);
	}
}

int main() {
	int i, j;


	printf("LCS - Longest Common Subsequence\nPor Tiago Madeira\n\n");
	printf("Digite o tamanho da string X: ");
	scanf("%d", &m);
	printf("Digite o tamanho da string Y: ");
	scanf("%d", &n);

	scanf("%*c");
	printf("Digite a string X: ");
	for (i=1; i<=m; i++) {
		scanf("%c", &x[i]);
		c[i][0]=0;
	}
	scanf("%*c");
	printf("Digite a string Y: ");
	for (i=1; i<=n; i++) {
		scanf("%c", &y[i]);
		c[0][i]=0;
	}

	printf("\nPrograma raciocinando...\n");
	for (i=1; i<=m; i++) {
		for (j=1; j<=n; j++) {
			if (x[i]==y[j]) {
				c[i][j]=c[i-1][j-1]+1;
				b[i][j]=DIAGONAL;
			} else {
				if (c[i][j-1]>c[i-1][j]) {
					c[i][j]=c[i][j-1];
					b[i][j]=LADO;
				} else {
					c[i][j]=c[i-1][j];
					b[i][j]=CIMA;
				}
			}
		}
	}

/*	printf("\nMATRIX C\n");
	for (i=1; i<=m; i++) {
		for (j=1; j<=n; j++) {
			printf("%d ", c[i][j]);
		}
		printf("\n");
	}

	printf("\nMATRIX B\n");
	for (i=1; i<=m; i++) {
		for (j=1; j<=n; j++) {
			printf("%d ", b[i][j]);
		}
		printf("\n");
	}
*/

	lcs_recupera(m, n);
	printf("\n");
}

e um programa bem ridículo para calcular os termos e a soma de uma PA (é o que o prof. tá ensinando, aí achei bom pra fazer os exercícios mais rápido…):

//Aplicar as fórmulas das PAs

//Progressão Aritmética
//Programa desenvolvido por Tiago Madeira (c) 2005.

#include <stdio.h>
#define MAX 1000001

long double a[MAX], s[MAX];

int main() {
	long double r;
	int n;

	printf("Primeiro termo da PA: ");
	scanf("%Lf", &a[1]);
	printf("Razão da PA: ");
	scanf("%Lf", &r);

	//Eu podia fazer só pros que vão ser usados, mas não sei porquê, deu vontade de fazer assim... =)
	printf("\nAguarde o problema raciocinar tudo que ele tem para raciocinar...\n");
	for (n=2; n<MAX; n++) {
		a[n]=a[1]+r*(n-1);
		s[n]=(a[1]+a[n])*n/2;
	}

	printf("\nE agora, digite números para o programa dizer A e S dele.\n");
	do {
		printf("Número: ");
		scanf("%d", &n);
		if (!n) {
			break;
		}
		printf("Número na posição N = %.Lf\nSoma de 1 a N = %.Lf\n\n", a[n], s[n]);
	} while (n);

}

No começo do mês que vem é o Festival de Música de Itajaí. Acho que vou fazer oficina de Piano Popular avançado com o Prof. Michel Freidenson, que foi quem me deu aulas numa oficina semelhante há dois anos. A semana da música vai contar também com uns shows bem legais e o site oficial é este aqui.

© 2005–2020 Tiago Madeira