BOJ 17410 수열과 쿼리 1.5
알고리즘 문제풀이/BOJ 2019. 10. 30. 23:20Solved.ac 난이도 기준 플5 이상 문제를 하루 하나씩 풀어보려고 시작했다. (시험기간엔 못 하겠지만...)
(사실 플레는 아니고, 푼 시점에서는 다4로 되어 있었다.)
문제설명
n개짜리 수열에 대해 두가지 쿼리를 합하여 q개 처리해야 한다. (n≤1e5,q≤2e5)
1 i v 쿼리 : Ai 를 v로 바꾼다.
2 i j k 쿼리 : Ai 부터 Aj 까지의 Subsegment에서, k보다 큰 원소의 개수를 센다.
풀이
Naive하게 모두 센다면, 2번 쿼리 하나를 처리하는데 O(n) 시간이 걸려서 O(qn) 이 된다. 즉, 핵심은 2번 쿼리를 얼마나 빠르게 답할 수 있는가에 달려 있음을 알 수 있다.
Sqrt decomposition을 사용하자 (이름이 이렇긴 하지만, 실제로는 약간의 디테일이 있다)
전체 10만개짜리 배열을 적당히 u=√n 개씩 쪼개어 저장해 둔다. 10만의 제곱근은 대략 300이 조금 넘고, 굳이 정확할 필요는 없으니 330개짜리 벡터 330개로 나눈다고 생각하자. 하나하나를 버킷이라고 보통 많이 부른다.
즉, 0번 벡터는 0~329, 1번 벡터는 330~659번 원소들을 '정렬된 상태' 로 가지고 있는다.
이때, 1번 쿼리가 들어오면 다음과 같이 진행한다.
- 먼저 몇번 벡터에 해당하는 인덱스인지를 확인하고.
- 해당 벡터에서 바뀌기 전 Ai 값을 erase한 다음
- 벡터에 바뀐 후 Ai 값을 '정렬성을 유지하면서' insert 한다.
시간을 분석해 보면, 먼저 몇번 버킷(벡터) 인지 찾는것은 당연히 나눗셈 한번, erase에 드는 시간은 원소를 찾는 시간 + delete 하는 시간인데 찾는 시간은 정렬된 벡터에서 찾을 것이므로 logu (u 는 버킷의 크기), delete 시간은 vector delete가 size만큼 걸리므로 u 정도 시간이 든다. insert도 자리 찾는데 logu, 실제 삽입에 u 정도 시간이 든다. 종합해서 대충 u 정도 시간이 드는 셈이다.
2번 쿼리가 들어오면, 크게 세 구간으로 나눈다.
- [i,j] 구간에 버킷 전체가 들어오는 경우 : 버킷에서 upper bound를 이용하면 k보다 큰 원소의 개수를 쉽게 셀 수 있다.
- 버킷이 아예 안 들어오는 경우 : 제낀다.
- 버킷에 걸쳐서 들어오는 경우 : 직접 하나씩 돌려서 k보다 큰 원소의 개수를 센다.
첫번째 경우, 버킷 최대 n/u 개가 세진다. 이때 각 버킷에 upperbound 연산을 하는 시간을 생각하면 logu 정도 걸릴 것이다.
두번째 경우는 버킷 최대 n/u 개를 보면 된다.
세번째 경우는, 생각해 보면 한 버킷을 온전히 포함하지 않으면서 양쪽에 남을 수 있는 부분의 길이는 최대 2u 이다.
즉, 최악의 경우 (nlogu)/u 정도 시간이 걸린다.
2번 쿼리가 많이 들어오는 경우를 생각하면 최악의 경우 시간 복잡도는 O((nlogu)/u) 이다. 즉, u를 √n 정도 잡으면 O(√nlog√n) 정도에 한 쿼리를 처리할 수 있음을 의미하며, 이는 시간에 잘 들어오는 숫자이다.
코드
#include <bits/stdc++.h>
using namespace std;
#define ll long long
#define all(x) ((x).begin()),((x).end())
#pragma GCC optimize("Ofast")
#pragma GCC target("avx,avx2,fma")
#pragma GCC optimization ("unroll-loops")
#define usecppio ios::sync_with_stdio(0);cin.tie(0);cout.tie(0);
int arr[101000];
vector <int> bucket[42];
int bsize;
int n, q;
int main()
{
usecppio
cin >> n;
bsize = 2500;
for (int i = 0; i < n; i++)
{
cin >> arr[i];
bucket[i/bsize].push_back(arr[i]);
}
for (int i = 0; i<42; i++)
sort(all(bucket[i]));
cin >> q;
while (q--)
{
int qt;
cin >> qt;
if (qt == 1)
{
int ind, val;
cin >> ind >> val;
ind--;
int u = arr[ind];
int p = ind/bsize;
bucket[p].erase(lower_bound(all(bucket[p]),u));
arr[ind] = val;
bucket[p].insert(lower_bound(all(bucket[p]),val),val);
}
else if (qt == 2)
{
int l, r, val;
cin >> l >> r >> val;
l--; r--;
int lbs = l/bsize + 1 - (l%bsize==0); lbs *= bsize;
int rbe = (r/bsize) * bsize;
int ct = 0;
if (lbs < rbe)
{
for (int i = l; i<lbs; i++)
if (arr[i] > val) ct++;
for (int i = rbe; i<=r; i++)
if (arr[i] > val) ct++;
int lm = lbs/bsize;
int rm = (rbe/bsize);
for (int i = lm; i<rm; i++)
ct += (bucket[i].end()- upper_bound(all(bucket[i]),val));
}
else
{
for (int i = l; i<=r; i++)
if (arr[i] > val) ct++;
}
cout << ct << '\n';
}
}
}
여담
코드를 보면, 풀이에서 설명한대로 버킷사이즈를 $\sqrt{n]$ 으로 잡지 않았다. 사실 약간의 실험이었는데,

1.5배 쯤 빨라지더라~
((nlogu)/u) 의 값을 최소화하는 u의 값을 찾는다고 생각해 보자. 단, 1번 쿼리는 O(u) 이므로, 정확히 말하면 max((nlogu)/u,u) 를 최소화하려는 것이다.
첫번째 식을 미분해 보고, 대략적인 그래프의 유형을 그려 보면 u=e 까지는 값이 매우 빠르게 커지고, 그 이후로는 계속 감소함수이다. 즉 한 버킷의 크기가 클수록 2번 쿼리를 빨리 처리할 수 있다. (사실 자명하다)
버킷의 크기가 클수록 1번 쿼리는 당연히 느려지므로, 두 쿼리의 비율이 거의 균등하다고 치면 버킷 사이즈가 √n 보다 조금 더 커야 이득이다. 다만 실제로는 양쪽 다 상수가 붙으므로, 조금 달라지는데 어차피 이걸 요구하는 문제는 너무 :kudeki: 인거 같으므로 그냥 이런 생각도 해볼 수 있다~ 는 정도 느낌.
(어차피 상수 1.5배 차이는 저런 것보다도 캐시히트가 얼마나 잘 나는지 등등의 이슈가 더 클 수 있다)
'알고리즘 문제풀이 > BOJ' 카테고리의 다른 글
BOJ 1557 제곱 ㄴㄴ / BOJ 8464 Non-Squarefree Numbers (2) | 2020.01.04 |
---|---|
BOJ 2465 줄 세우기 (0) | 2019.10.31 |
BOJ 13505 두 수 XOR (0) | 2019.09.21 |
BOJ 12972 GCD 테이블 (0) | 2019.09.13 |
BOJ 900문제 달성! (0) | 2019.09.01 |