Processing math: 100%

Comentário Noic OBI 2019 - Fase 3 - Programação Nível 2

Escrito por Lúcio Figueiredo, Leonardo Paes e Thiago Mota

Para conferir a prova na íntegra, clique aqui.

Metrô da Nlogônia

Conhecimento prévio necessário:

Note que os sistemas Círculo e Quadrado são ambos árvores, ou seja, grafos conexos e acíclicos. Assim, a tarefa do problema é encontrar dois vértices U e V de modo que U pertença ao sistema Círculo, V pertença ao sistema Quadrado e o diâmetro do grafo resultante ao ligar U e V (que será uma árvore) sejá o menor possível.

Para uma árvore T qualquer, defina h(T,x) (ou excentricidade de x em T) como a maior distância do vértice x para algum vértice de T, ou seja, a maior profundidade de algum vértice quando a árvore é enraizada no vértice x. Sendo C a árvore do sistema Círculo e Q a árvore do sistema quadrado, o seguinte lema vale:

Lema 1: Se U é um vértice de C e V é um vértice de Q, o diâmetro da árvore resultante ao ligar U e V será igual a max(diam(C),diam(Q),h(C,U)+h(Q,V)+1), onde diam(T) é o diâmetro de uma árvore T qualquer.

Na figura acima, obtemos que o diâmetro da árvore resultante ao ligar U e V é 6, já que diam(C)=3, diam(Q)=2 e h(C,U)+h(Q,V)+1=6.

Como o valor de diam(C) e diam(Q) não dependem da nossa escolha de U e V, queremos escolhê-los de forma que o valor de h(C,U)+h(Q,V) seja o menor possível, ou seja, de modo que U e V tenham a menor excentricidade possível nas suas respectivas árvores.

Para uma árvore qualquer, o vértice cuja excentricidade é a menor possível é chamado centro da árvore. Logo, o problema se resume em encontrar os centros de cada uma das duas árvores. Para entender como achar o centro de uma árvore, acesse o material do Noic sobre Diâmetro de uma Árvore.

Confira o código abaixo para melhor entendimento:

// Comentário Noic - OBI 2019 - Fase 3 - Nível 2
// Metrô da Nlogônia - Complexidade O(N + M)
// Escrito por Lúcio Figueiredo
#include <bits/stdc++.h>
using namespace std;
const int maxn = 1e5+10;
int n, m;
// distâncias calculadas de uma raíz da árvore para os outros vértices
// dist[0] -> sistema Círculo
// dist[1] -> sistema Quadrado
int dist[2][maxn];
// pai de cada vértice ao enraizar cada grafo em um vértice específico
int pai[2][maxn];
// grafo[0] representa o sistema Círculo, grafo[1] o Quadrado
vector<int> grafo[2][maxn];
// p é o pai do vértice atual
// q = 0 -> DFS no sistema Círculo
// q = 1 -> DFS no sistema Quadrado
void dfs(int u, int p, int q)
{
for (auto v: grafo[q][u])
{
if (v == p) continue;
dist[q][v] = dist[q][u]+1;
pai[q][v] = u;
dfs(v, u, q);
}
}
// retorna duas pontas de um diâmetro em um dos grafos
pair<int, int> get_diam(int q)
{
// qtd. de vértices no grafo
int t = (q == 0 ? n : m);
// 1a dfs em uma raíz arbitrária, digamos 1
dist[q][1] = 0;
dfs(1, 0, q);
// encontra o vértice u mais distante da raíz
int maior = -1, u;
for (int i = 1; i <= t; i++)
if (dist[q][i] > maior)
maior = dist[q][i], u = i;
// u será uma das pontas do diâmetro, fazemos dfs nele
dist[q][u] = 0;
dfs(u, 0, q);
// encontramos a segunda ponta do diâmetro
maior = -1;
int v;
for (int i = 1; i <= t; i++)
if (dist[q][i] > maior)
maior = dist[q][i], v = i;
return {u, v};
}
// retorna o centro de uma das árvores
// q = 0 -> sistema Círculo
// q = 1 -> sistema Quadrado
int find_center(int q)
{
pair<int, int> diam = get_diam(q);
// pontas do diâmetro
int u = diam.first, v = diam.second;
// após encontrar o diâmetro, a segunda dfs enraizou a árvore em u
// logo, iteramos o caminho de u para v pelos pais até encontrarmos o centro
// como o grafo não possui pesos, o centro será o vértice no "meio" do caminho entre u e v
// logo, subimos pelos pais partindo de u até encontrar esse vértice, guardando o tamanho do caminho em qtd
int at = v, qtd = 0;
while (true)
{
// encontramos o centro
if (qtd == dist[q][v]/2) return at;
at = pai[q][at], qtd++;
}
}
int main(void)
{
scanf("%d %d", &n, &m);
for (int i = 1; i < n; i++)
{
int u, v;
scanf("%d %d", &u, &v);
grafo[0][u].push_back(v);
grafo[0][v].push_back(u);
}
for (int i = 1; i < m; i++)
{
int u, v;
scanf("%d %d", &u, &v);
grafo[1][u].push_back(v);
grafo[1][v].push_back(u);
}
printf("%d %d\n", find_center(0), find_center(1));
}

Mesa Redonda

Conhecimento prévio necessário:

Primeiro temos que descobrir em qual posição Ana irá sentar. Para descobrir isto, basta checar o resto da divisão do número sorteado por 3. Com essa informação, iremos utilizar o mesmo procedimento para descobrir a posição de Ana, com a diferença de que se a posição que Beatriz for sentar já foi utilizada por Ana, ela terá que ser movida uma cadeira para frente. Desse modo, resta olhar qual cadeira sobrou e essa posição é a resposta.

#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false), cin.tie(0);
int a, b;
cin >> a >> b;
int pos_a = a % 3;
int pos_b = b % 3;
if (pos_a == pos_b) pos_b = (pos_b % 3) // Não esquecer o resto nesta etapa.
// Para descobrir a cadeira de Carolina iremos somar o valor de todas cadeiras e subtrair das cadeiras já utilizadas.
int sum = 0 + 1 + 2;
sum -= a; // Cadeira de Ana utilzada
sum -= b; // Cadeira de Beatriz utilizada
cout << sum << "\n"; // A que sobrou é a de Carolina
return 0;
}
view raw mesa.cpp hosted with ❤ by GitHub

Exploração do Capitão Levi

Conhecimento prévio necessário:

Inicialmente, vamos ordenar os pontos dados de maneira crescente em relação à coordenada x, ou seja, de modo que para qualquer par de pontos com índices i e j com i<j, vale que xixj.

A tarefa do problema se resume em calcular a quantidade de pares de pontos (i,j) de forma que yjyixjxiPQ. Como o par (i,j) é equivalente ao par (j,i), vamos calcular os pares de pontos de modo que i<j, para garantirmos que xjxi possuirá um valor positivo (devido à ordenação inicial).

Para simplificar o problema, caso Q seja negativo, vamos multiplicar o valor de Q e de P por 1; desta forma, tornamos Q positivo e não modificamos o valor de PQ. Feito isto, podemos trabalhar de maneira simples com a inequação principal do problema, da seguinte forma:

yjyixjxiPQQ(yjyi)P(xjxi)QyjPxjQyiPxi (1).

Sendo f(i)=QyiPxi, utilizando a inequação (1), reduzimos o problema para calcular, para cada ponto i, a quantidade de pontos j (i<j) de modo que f(i)f(j).

Podemos resolver este problema utilizando um vetor de frequências: Vamos iterar pelos pontos i desde N até 1, e armazenaremos em freq[x] a quantidade de pontos com índice maior que i cujo valor f() é igual a x. Assim, no ponto i, aumentamos nossa resposta em f(i)k=1freq[k], e após isso, adicionamos 1 no valor de freq[f(i)].

No entanto, note que a complexidade do método descrito não é suficiente para resolver o problema, já que o valor máximo de f(i) pode ser grande. Porém, perceba que podemos otimizar o nosso algoritmo utilizando uma BIT: A operação de calcular f(i)k=1freq[k] é idêntica à função soma(f(i)) da BIT e a operação de adicionar 1 a freq[f(i)] é equivalente a upd(f(i),1).

Agora, só resta uma dificuldade em nossa solução: O valor de f(i) pode ser grande (até 107109), e o tamanho do vetor da BIT deve ser grande o suficiente para armazenar valores em posições desta magnitude. Porém, perceba que existem, no máximo, N valores distintos de f(i). Logo, podemos utilizar a técnica de Compressão de Coordenadas nos valores f(i), e assim, utilizar a BIT com memória de tamanho O(N).

Assim, a complexidade final da solução é O(NlogN), já que precisamos ordenar o nosso vetor e utilizar as funções da BIT.

Confira o código abaixo para melhor entendimento:

// Comentário Noic - OBI 2019 - Fase 3 - Nível 2
// Exploração do Capitão Levi - Complexidade O(N log N)
// Escrito por Lúcio Figueiredo
#include <bits/stdc++.h>
#define ff first
#define ss second
using namespace std;
typedef pair<int, int> pii;
typedef long long ll;
const int maxn = 5e5+10;
int n, p, q;
pii pt[maxn]; // pontos
ll f[maxn]; // f(i) = Q * y_i - P * x_i
int bit[maxn];
// realiza a compressão de coordenadas para cada f(i)
void compress(void)
{
map<ll, int> mp; // utilizaremos um map para a compressão
for (int i = 1; i <= n; i++)
{
f[i] = 1ll*q * pt[i].ss - 1ll*p * pt[i].ff;
mp[f[i]] = 0; // fazendo mp[f[i]] = 0, inserimos f[i] no map (o valor 0 é arbitrário)
}
int ind = 0;
for (auto &x: mp)
x.second = ++ind; // compressão
for (int i = 1; i <= n; i++)
f[i] = mp[f[i]]; // substituímos f(i) pelo valor comprimido
}
// soma v na posição x (função da BIT)
void upd(int x, int v)
{
for (; x < maxn; x += (x&-x))
bit[x] += v;
}
// soma dos valores de 1 a x (função da BIT)
int soma(int x)
{
int ans = 0;
for (; x > 0; x -= (x&-x))
ans += bit[x];
return ans;
}
int main(void)
{
scanf("%d %d %d", &n, &p, &q);
if (q < 0)
{
p *= -1;
q *= -1;
}
for (int i = 1; i <= n; i++)
scanf("%d %d", &pt[i].ff, &pt[i].ss);
sort(pt+1, pt+n+1); // ordenamos os pontos pela coordenada x
compress();
// resposta do problema
ll ans = 0;
for (int i = 1; i <= n; i++)
{
ans += 1ll*soma(f[i]);
upd(f[i], 1);
}
printf("%lld\n", ans);
}

Grand Prix da Nlogônia

Conhecimento prévio necessário:

Inicialmente, perceba que podemos realizar uma busca binária para encontrar o menor índice X tal que ao aceitar cada plano de 1 a X, o grafo resultante possuirá um ciclo. Porém, precisamos construir o grafo de maneira eficiente, já que cada plano pode adicionar até N novas arestas no grafo.

Construindo o grafo modificado

Note que cada plano adiciona arestas no grafo de uma forma específica: um plano (U,L,R) liga U ao intervalo de vértices [L,R]. Tendo em mente que estamos trabalhando com intervalos, vamos utilizar uma Árvore de Segmentos que nos auxiliará na construção do grafo.

A segment tree será utilizada como um grafo, em conjunto com os N vértices iniciais. A construção deste novo grafo será realizada da seguinte forma:

  • Cada vértice não-folha da segment tree terá uma aresta direcionada para os seus dois filhos, de modo que partindo de um vértice que representa um intervalo [L,R] na árvore, conseguimos chegar em cada folha neste intervalo.
  • A folha de intervalo [L,L] na segment tree possuirá uma aresta direcionada para o vértice L do grafo inicial.
  • Ao adicionarmos um plano (U,L,R), iremos criar arestas direcionadas do vértice U para cada nó da segment tree que está totalmente contido no intervalo [L,R] (ou seja, os mesmos nós utilizados ao fazer uma query no intervalo [L,R] da segment tree, quando a utilizamos em sua aplicação convencional).

Perceba que, ao adicionarmos as arestas desta forma, um vértice U que foi ligado ao intervalo [L,R] consegue alcançar todos vértice V[L,R]: Basta seguir a aresta de U ao vértice da segment tree que "cobre" o vértice V (ou seja, o nó da árvore cujo intervalo contém V), percorrer o caminho deste vértice até a folha da árvore que representa V e, por fim, percorrer a aresta desta folha para V.

Logo, se U consegue alcançar o vértice V no grafo original (o grafo resultante ao criar arestas do vértice de um plano a todos os vértices no intervalo neste plano), U também consegue alcançar V no grafo modificado com a segment tree. Além disso, como o intervalo [L,R] é representado pela união de O(logN) nós da segment tree, a quantidade de arestas criadas ao aceitarmos P planos é de complexidade O(PlogN).

Na imagem acima, vértices azuis representam os nós da árvore de segmentos, e os vértices de cor rosa representam os vértices originais. A imagem indica as arestas presentes no grafo para N=4 e com os planos (1,2,4) e (3,4,4).

Encontrando um ciclo no novo grafo

O único problema que ainda precisamos resolver é como descobrir se o grafo resultante ao adicionar um prefixo de planos (durante a busca binária) é acíclico ou não. Podemos descobrir isso utilizando uma DFS e um vetor de marcação, digamos, mark[]. O vetor de marcação possuirá os seguintes valores:

  • Se o vértice u ainda não foi percorrido pela DFS, mark[u]=0.
  • Se o vértice u foi percorrido pela DFS, mas a sua DFS ainda não foi concluída, mark[u]=1.
  • Se a DFS de u foi concluída, mark[u]=2.

Note que um ciclo será encontrado se, e somente se, durante a DFS realizada em algum vértice u, existe um vértice uma aresta de u para algum vértice v de modo que mark[v]=1, pois neste caso, existe um caminho de v para u que não percorre a aresta (u,v). Assim, conseguimos encontrar um ciclo no grafo com complexidade O(N+M) (complexidade da DFS).

Complexidade final

Durante cada uma das O(logM) iterações da busca binária, serão construídas O(PlogN) arestas no grafo, onde P é a quantidade de planos aceitos; assim, a DFS realizada possuirá complexidade O(N+PlogN), e portanto, a complexidade final da solução é O(Mlog2M).

Confira o código abaixo para melhor entendimento:

// Comentário Noic - OBI 2019 - Fase 3 - Nível 2
// Grand Prix da Nlogônia - Complexidade O(M log^2 M)
// Escrito por Lúcio Figueiredo
#include <bits/stdc++.h>
using namespace std;
// como a segment tree possui aprox. 4*N vértices, o grafo possuirá aprox. 10*N vértices
const int maxn = 1e6+10;
// representa um plano (u, l, r)
struct Edge
{
int u, l, r;
} edge[maxn];
int n, m;
int ind[maxn], aux; // ind[] é o valor associado a um vértice da segment tree
int mark[maxn]; // usado para encontrar um ciclo no grafo durante a DFS
vector<int> grafo[maxn];
// inicia a segment tree, ligando arestas e numerando os nós da árvore
// semelhante à função build() na segment tree convencional
void build(int node, int l, int r)
{
// folha
if (l == r)
{
ind[node] = ++aux;
grafo[ind[node]].push_back(l); // ligamos a folha a l
return;
}
int mid = (l+r)>>1;
build(2*node, l, mid); build(2*node+1, mid+1, r);
ind[node] = ++aux; // damos um número ao nó "node" da segment tree
// ligamos o nó atual aos seus filhos
grafo[ind[node]].push_back(ind[2*node]);
grafo[ind[node]].push_back(ind[2*node+1]);
}
// adiciona as arestas de um plano (u, a, b)
// semelhante à função query() da segment tree convencional
void add_edge(int node, int l, int r, int a, int b, int u)
{
if (l > b || r < a) return;
if (l >= a && r <= b) // se o intervalo atual está contido em [a, b]
{
grafo[u].push_back(ind[node]);
return;
}
int mid = (l+r)>>1;
add_edge(2*node, l, mid, a, b, u); add_edge(2*node+1, mid+1, r, a, b, u);
}
// retorna verdadeiro caso um ciclo seja encontrado, falso caso contrário
bool dfs(int u)
{
// dfs(u) foi iniciada
mark[u] = 1;
for (auto v: grafo[u])
{
if (mark[v] == 1) return true; // encontramos um ciclo
if (mark[v] == 0 && dfs(v)) return true; // dfs(v) encontrou um ciclo
}
// dfs(u) foi concluída
mark[u] = 2;
return false;
}
// retorna verdadeiro se o grafo possui um ciclo ao aceitar os planos de 1 a x
bool ok(int x)
{
// inicialmente, limpamos o grafo e o vetor de marcação
memset(mark, 0, sizeof mark);
for (int i = 1; i <= n; i++)
grafo[i].clear();
// adicionamos as arestas de cada plano
for (int i = 1; i <= x; i++)
add_edge(1, 1, n, edge[i].l, edge[i].r, edge[i].u);
// fazemos a dfs para cada vértice não marcado
for (int i = 1; i <= n; i++)
if (!mark[i] && dfs(i))
return true;
return false;
}
// busca binária
// retorna a resposta do problema
int busca(void)
{
int ini = 1, fim = m, ans = -1;
while (ini <= fim)
{
int mid = (ini+fim)>>1;
if (ok(mid)) ans = mid, fim = mid-1;
else ini = mid+1;
}
return ans;
}
int main(void)
{
scanf("%d %d", &n, &m);
for (int i = 1; i <= m; i++)
scanf("%d %d %d", &edge[i].u, &edge[i].l, &edge[i].r);
// a variável global "aux" é usada para numerar os vértices da segment tree
// começamos de n pois os números [1, n] já foram usados para os vértices iniciais
aux = n;
build(1, 1, n);
printf("%d\n", busca());
}

Etiquetas

Conhecimento prévio necessário:

Para resolvermos esse problema, utilizaremos uma técnica conhecida como Programação Dinâmica. A ideia será calcular a soma dos valores dos inteiros que iremos tirar ao usar as etiquetas, com o objetivo de minimizá-la, já que queremos maximizar a soma dos números que restaram.

Seja dp[i][j] o mínimo valor possível considerando apenas a fita formada pelos i primeiros inteiros e utilizando j etiquetas de tamanho c. Então, as transições da programação dinâmica consistem de duas escolhas:

  • Colocar uma etiqueta na posição atual (i), cobrindo os números de índice ic+1 até i. O valor deste caso é igual a soma[ic+1][i]+dp[ic][j+1], onde soma[l][r] é igual à soma dos inteiros no intervalo [l,r]. Podemos encontrar esta soma em O(1) utilizando a técnica de soma de prefixos.
  • Não colocar uma etiquta na posição i. O valor deste caso é igual a dp[i1][j].

Logo, a resposta para o problema será a soma de todos os inteiros do vetor dado subtraída de dp[n][k]. A complexidade final da solução é O(NK), já que dp possui estados de complexidade O(N) e O(K), com transições em O(1).

Abaixo se encontram duas maneiras diferentes de se implementar a programação dinâmica; a primeira versão calcula dp recursivamente, e a segunda, iterativamente.

Os casos bases para dp na solução recursiva são: dp[i][k]=0 e dp[i][j]=inf , i0,jk.

Os casos bases para dp na solução iterativa são: dp[0][0]=0 e dp[0][j]=inf , j0.

Confira os códigos abaixo para melhor entendimento:

Solução Recursiva:

#include <bits/stdc++.h>
using namespace std;
const int maxn = 1e4+10, inf = 1e8+10;
int n, k, c, pref[maxn], dp[maxn][maxn];
inline int solve(int i, int j){
if(i <= 0 and j == k) return 0;
if(i <= 0 and j != k) return inf;
if(dp[i][j] != 4*inf) return dp[i][j];
int tot = solve(i-1, j);
if(j<k and i>=c) tot = min(tot, solve(i-c, j+1) + pref[i] - pref[max(i-c, 0)]);
return dp[i][j] = tot;
}
int main(){
ios_base::sync_with_stdio(false);cin.tie(NULL);cout.tie(NULL);
cin >> n >> k >> c;
for(int i=1; i<=n; i++){
cin >> pref[i];
pref[i] += pref[i-1];
for(int j=0; j<=k; j++){
dp[i][j] = 4*inf;
}
}
cout << pref[n] - solve(n, 0) << endl;
return 0;
}

Solução Iterativa:

#include <bits/stdc++.h>
using namespace std;
const int maxn = 1e4+10, inf = 1e8+10;
int n, k, c, pref[maxn], dp[maxn][maxn];
int main(){
ios_base::sync_with_stdio(false);cin.tie(NULL);cout.tie(NULL);
cin >> n >> k >> c;
for(int i=1; i<=n; i++){
cin >> pref[i];
pref[i] += pref[i-1];
}
for(int j=1; j<=k; j++) dp[0][j] = inf;
dp[0][0] = 0;
for(int i=1; i<=n; i++){
for(int j=0; j<=k; j++){
dp[i][j] = dp[i-1][j];
if(j and i >= c){
dp[i][j] = min(dp[i][j], dp[i-c][j-1] + pref[i] - pref[i-c]);
}
}
}
cout << pref[n] - dp[n][k] << endl;
return 0;
}