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; | |
} |
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 xi≤xj.
A tarefa do problema se resume em calcular a quantidade de pares de pontos (i,j) de forma que yj−yixj−xi≥PQ. Como o par (i,j) é equivalente ao par (j,i), vamos calcular os pares de pontos de modo que i<j, para garantirmos que xj−xi 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:
yj−yixj−xi≥PQ⟹Q⋅(yj−yi)≥P⋅(xj−xi)⟹Q⋅yj−P⋅xj≥Q⋅yi−P⋅xi (1).
Sendo f(i)=Q⋅yi−P⋅xi, 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é 107⋅109), 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(P⋅logN).
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(P⋅logN) arestas no grafo, onde P é a quantidade de planos aceitos; assim, a DFS realizada possuirá complexidade O(N+P⋅logN), e portanto, a complexidade final da solução é O(M⋅log2M).
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 i−c+1 até i. O valor deste caso é igual a soma[i−c+1][i]+dp[i−c][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[i−1][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(N⋅K), 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 , i≤0,j≠k.
Os casos bases para dp na solução iterativa são: dp[0][0]=0 e dp[0][j]=inf , j≠0.
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; | |
} |