Monday, June 29, 2020

AtCoder Beginner Contest 172 업솔빙

대회 도중에 많이 고민했는데 못 풀었거나 관심이 생겨서 꼭 해결하고자 하는 문제들의 경우 에디토리얼을 보고 공부하였다. 본인 기준으로 수준이 너무 높다고 생각하여 도움이 되지 않을 것이라 판단한 문제들 혹은 그냥(?) 풀기 귀찮은 문제들은 대회 종료 후 별도로 공부하지 않았다. 업솔빙 관련 글도 올리고 싶은 경우에만 올릴듯.

C. Tsundoku (300 pt)

처음에 greedy 문제인줄 알고 삽질하다가 반례를 하나 발견하고나서 포기했던 문제이다. 풀이법은 구간합(prefix sum)과 탐색이다. a를 A의 구간합 배열, b도 B의 구간합 배열으로 정의하고 아래 방법으로 구한다.

a0 = 0
aN = A1 + A2 + ··· + AN = aN-1 + AN

그렇다면 우리는 문제의 답을 "ai + bj ≤ K를 만족하는 최대의 i + j 값" 으로 생각할 수 있다. 이제, 조건을 만족하는 (i, j) 쌍을 어떻게 찾을 것인가? 완전 탐색은 시간 복잡도가 O(NM) 이므로 TLE가 날 것이다. 이렇게 시도해보자. i가 0일 때 bj ≤ K - ai 조건에 맞춰서 최대의 j 값을 찾는 상황을 가정하면, j = M, M - 1, M - 2, ... 순으로 탐색할 것이며 이때 탐색된 j 값을 best라고 하자. K는 조건 상 반드시 양수이며, 구간합 배열 a와 b는 단조 증가한다. 그렇다면, i가 1일때 최대의 j 값은 반드시 best 미만일 것이다. 이렇게 완전 탐색을 피해갈 수 있으며 시간복잡도 O(N + M) 내로 정답을 구할 수 있다. i와 j의 경우를 바꿔서 생각해도 동일하다.

#include <bits/stdc++.h>

using namespace std;

int main(void) {
    ios::sync_with_stdio(false);
    cin.tie(0);
    int n, m, k;
    cin >> n >> m >> k;
    long long apsum[200001] = {0}, bpsum[200001] = {0};
    for(int i = 1; i <= n; i++) {
        cin >> apsum[i];
        apsum[i] += apsum[i - 1];
    }
    for(int i = 1; i <= m; i++) {
        cin >> bpsum[i];
        bpsum[i] += bpsum[i - 1];
    }
    long long ans = -1e9, j = m;
    for(int i = 0; i <= n; i++) {
        if(apsum[i] > k) {
            break;
        }
        while(bpsum[j] > k - apsum[i]) {
            j--;
        }
        ans = max(ans, i + j);
    }
    cout << ans << '\n';
    return 0;
}

D. Sum of Divisors (400 pt)

임의의 수 X의 약수의 개수를 O(√X) 내로 구할 수 있지만 정답을 구하기 위해 필요한 연산을 일일이 수행한다면 TLE가 발생한다. 그런데 이 문제는 각 수의 약수의 개수를 구하지 않고도 정답을 구할 수 있다! 문제가 요구하는 수식의 값을 구하는 의사코드는 에디토리얼에 나온 것처럼 아래와 같다.

ans = 0
for i = 1, ..., N:
   for j = 1, ..., N:
     if i % j == 0 then ans += i
print ans

그런데 위 코드는 모든 (i, j) 쌍에 대해 고려하기 때문에 결국 2, 3번째 줄을 바꿔서 아래와 같은 코드를 실행시켜도 같은 결과를 도출한다.

ans = 0
for j = 1, ..., N:
   for i = 1, ..., N:
     if i % j == 0 then ans += i
print ans

위 코드의 동작을 관찰했을 때, "N 이하 모든 j의 배수들의 합을 g(j)으로 정의하면, j = 1, 2, ..., N일 때 모든 g(j)의 합"을 구하는 것으로 이 문제를 다시 정의 해볼 수 있다. g(X)를 구하기 위해서 Y = ⌊N/X⌋ 으로 정의하면, g(X) = X + 2X + ··· + YX = (1 + 2 + ··· + Y)X = Y(Y + 1)X/2 로 표현할 수 있으며, 결국 정답을 O(N) 내로 구할 수 있다.

#include <bits/stdc++.h>

using namespace std;

int main(void) {
    ios::sync_with_stdio(false);
    cin.tie(0);
    int n;
    cin >> n;
    long long ans = 0;
    for(int i = 1; i <= n; i++) {
        long long y = floor(n / i);
        ans += (i * y * (y + 1)) / 2;
    }
    cout << ans << '\n';
    return 0;
}

고등학교를 졸업한 이후로 수학을 하지 않았고 지금까지도 수학적 사고를 전혀 한 적이 없다. 그래서 문제 조건 속에서 수학적 사실을 도출하여 수식을 변형하는 작업이 필요하다거나 수학 지식이 필요한 경우 너무 풀기가 힘들고 자꾸 이상한 방향으로 생각이 빠진다. 계속 문제들을 열심히 풀어서 수학 문제의 경우는 모르는 개념을 하나씩 알아나가고 풀이 방향에 대한 감각을 익혀야 겠다. 물론 업솔빙도 열심히 하고.

Sunday, June 28, 2020

LIS를 구하는 두 가지 방법

LIS(Longest Increasing Subsequence) 문제를 해결하기 위하여 잘 알려진 방법으로는 두 가지 방법(동적 계획법, 이진 탐색)이 있다. 여기서 전체 배열의 길이가 n 일때, 동적 계획법을 사용할 경우 시간 복잡도는 O(n2)이며 이진 탐색을 사용할 경우 시간 복잡도는 O(nlog2n)이다. 세그먼트 트리를 사용해서도 LIS 문제를 해결할 수 있으나 시간 복잡도가 이진 탐색을 사용할 경우와 같으므로 아래에서는 동적 계획법과 이진 탐색만을 각각 사용하여 LIS 문제를 어떻게 해결하는지 살펴보겠다.

1. 동적 계획법
구현하기 가장 쉬우며 n이 그리 크지 않은 경우에만 사용할 수 있다. 길이가 6인 배열 A가 있다고 가정해보자. 그리고 메모이제이션을 위한 배열 dp도 함께 생각해보자. dp의 각 원소는 모두 1로 초기화되어 있다. 아래에서는 동적 계획법을 사용하여 어떻게 문제를 해결하는지에 대해 설명하였다.

A: 10 20 10 30 20 50  /  dp: 1
A의 첫 번째 원소 A1(10) 앞에는 아무런 원소가 없다. 따라서 아무런 변화가 일어나지 않는다.

A: 10 20 10 30 20 50  /  dp: 1 2
A2부터는 앞 원소와 크기를 비교해가며 메모이제이션을 진행한다.
A2(20)의 앞에는 10이 있으며 10 < 20 이므로 "A1(10)의 부분 수열 길이 + 1"을 저장한다. (1 → 2)

A: 10 20 10 30 20 50  /  dp: 1 2 1
A1(10)과 비교하면 10 = 10 이므로 아무런 변화가 없다.
A2(20)과 비교하면 20 > 10 이기 때문에 아무런 변화가 없다.

A: 10 20 10 30 20 50  /  dp: 1 2 1 3
A1(10)과 비교하면 10 < 30 이므로 "A1(10)의 부분 수열 길이 + 1"을 저장한다. (1 → 2)
A2(20)과 비교하면 20 < 30 이므로 "A2(20)의 부분 수열 길이 + 1"을 저장한다. (2 → 3)
A3(10)과 비교하면 10 < 30 이지만 현재 dp 값(3)이 "A3(10)의 부분 수열 길이 + 1"(2)보다 크기 때문에 아무런 변화가 없다.

A: 10 20 10 30 20 50  /  dp: 1 2 1 3 2
A1(10)과 비교하면 10 < 20 이므로 "A1(10)의 부분 수열 길이 + 1"을 저장한다. (1 → 2)
A2(20)과 비교하면 20 = 30 이므로 아무런 변화가 없다.
A3(10)과 비교하면 10 < 20 이지만 현재 dp 값(2)가 "A3(10)의 부분 수열 길이 + 1"(2)와 같기 때문에 아무런 변화가 없다.
A4(30)과 비교하면 30 > 20 이므로 아무런 변화가 없다.

A: 10 20 10 30 20 50  /  dp: 1 2 1 3 2 4
A1(10)과 비교하면 10 < 50 이므로 "A1(10)의 부분 수열 길이 + 1"을 저장한다. (1 → 2)
A2(20)과 비교하면 20 < 50 이므로 "A2(20)의 부분 수열 길이 + 1"을 저장한다. (2 → 3)
A3(10)과 비교하면 10 < 50 이지만 현재 dp 값(3)이 "A3(10)의 부분 수열 길이 + 1"(2)보다 크기 때문에 아무런 변화가 없다.
A4(30)과 비교하면 30 < 50 이므로 "A4(30)의 부분 수열 길이 + 1"을 저장한다. (3 → 4)
A5(20)과 비교하면 20 < 50 이지만 현재 dp 값(4)가 "A5(20)의 부분 수열 길이 + 1"(3)보다 크기 때문에 아무런 변화가 없다.

for (int i = 1; i <= n; i++) {
    dp[i] = 1;
    for (int j = 1; j < i; j++) {
        if (A[j] < A[i]) {
            dp[i] = max(dp[i], dp[j] + 1);
        }
    }
    ans = max(ans, dp[i]);
}
cout << ans << '\n';

최종적으로 dp 배열에서 가장 큰 값인 4가 최장 증가 수열의 길이 값이다.

2. 이진 탐색
위 1번 방법으로 LIS 풀이를 시도하면 LIS의 길이 값은 얻을 수 있지만, LIS를 구성하는 실제 원소들은 얻을 수 없다. 그리고 n이 10000이 넘어가기 시작하면 빠른 시간 내에 답을 얻기가 힘들다. 이진 탐색을 사용하면 시간 복잡도를 O(nlog2n)으로 줄이면서 실제 LIS도 얻을 수 있다. 실제 LIS를 얻을 수 있으므로 LIS 길이 값은 당연히 따라온다. 1번 방법때처럼 길이가 6인 배열 A가 있다고 가정하며 메모이제이션을 위한 벡터 dp도 함께 생각해보자. dp는 처음에 완전히 비어있는 벡터이다. 아래에서는 이진 탐색을 사용하여 어떻게 문제를 해결하는지에 대해 설명하였다.

LIS의 길이를 구하기 위해서 배열 A의 각 원소 Ai를 처음부터 끝까지 훑으며 아래의 조건에 따라 dp 벡터에 값을 넣는다.
1) dp 벡터의 맨 끝값이 Ai 미만인 경우 → dp 벡터의 맨 끝에 Ai를 삽입
2) dp 벡터의 맨 끝값이 Ai 이상인 경우 → dp 벡터의 lower_bound(Ai) 자리에 Ai를 삽입

A: 10 20 10 30 20 50  /  dp: 10
A: 10 20 10 30 20 50  /  dp: 10 20
A: 10 20 10 30 20 50  /  dp: 10 20
A: 10 20 10 30 20 50  /  dp: 10 20 30
A: 10 20 10 30 20 50  /  dp: 10 20 30
A: 10 20 10 30 20 50  /  dp: 10 20 30 50

최종적으로 dp 벡터의 길이 값(4)가 최장 증가 수열의 길이 값이다. 다만, dp 벡터의 내용(10, 20, 30, 50)이 반드시 LIS는 아니다. 왜냐하면, dp 벡터의 중간에 위치한 값들은 배열 A의 끝에 도달하기 전에 계속해서 바뀔 수 있기 때문이다. 위의 예에서는 운이 좋아서 실제 LIS가 도출되었지만 실제로는 위의 방법만으로 LIS를 구할 수 없다. 즉, 추가적인 과정이 필요하다. dp 벡터에 새로운 값이 들어갈 때 바로 앞 원소의 인덱스를 저장하는 backtrace 벡터를 새로 생성한다. 이후 dp 벡터에 새로운 값을 넣을 때 자신의 앞 원소의 인덱스를 backtrace 벡터에 저장하면 된다.

#include <bits/stdc++.h>

using namespace std;

bool cmp(const pair<int, int>& a, const pair<int, int>& b) {
    return a.first < b.first;
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(0);
    int n;
    cin >> n;
    vector<pair<int, int>> A;
    for (int i = 0; i < n; i++) {
        int tmp;
        cin >> tmp;
        A.push_back({ tmp, i });
    }
    const int max_sz = 1e6 + 1;
    vector<pair<int, int>> dp(1, { -2e9, -1 });
    vector<int> backtrace(max_sz, -1);
    for (auto& p : A) {
        if (dp.back().first < p.first) {
            backtrace[p.second] = dp.back().second;
            dp.push_back(p);
        }
        else {
            auto iter = lower_bound(dp.begin(), dp.end(), p, cmp);
            backtrace[p.second] = (iter - 1)->second;
            *iter = p;
        }
    }
    cout << dp.size() - 1 << '\n';
    vector<int> ans;
    for (int i = dp.back().second; i >= 0; i = backtrace[i]) {
        ans.push_back(A[i].first);
    }
    for (int i = ans.size() - 1; i >= 0; i--) {
        cout << ans[i] << ' ';
    }
    cout << '\n';
    return 0;
}

이런 방법이 가능한 이유는 dp 배열에 Ai가 삽입되는 자리의 바로 앞 값을 끝으로 하는 가장 긴 수열이 backtrace 벡터에 저장된 인덱스를 따라 이어져 있을 것이기 때문이다.

Sunday, June 21, 2020

연쇄 행렬 곱셈 (Matrix chain multiplication)

행렬곱은 어떤 방향으로 결합하느냐에 따라 필요한 곱셈 횟수가 달라진다. 예를 들어, 크기가 각각 A = 2 x 5, B = 5 x 3, C = 3 x 4인 행렬이 있는 경우 A의 행과 열을 d0, d1, B의 행과 열을 d1, d2, C의 행과 열을 d2, d3으로 정의한다면 A(BC)와 (AB)C의 곱 연산 횟수는 아래와 같으며 곱 연산의 최소 횟수는 54이다.

A(BC) = d0d1d3 + d1d2d3 = 40 + 60 = 100
(AB)C = d0d1d2 + d0d2d3 = 30 + 24 = 54

동적 계획법을 이용하면 행렬의 개수가 n일때 수행될 곱 연산의 최소 횟수를 시간복잡도 O(n3)이내에 구할 수 있다. 점화식은 아래와 같다. dp[i][j]는 행렬 i에서 j까지 곱 연산의 최수 횟수이며, 만약 i = j일 경우 0으로 처리한다. 위의 A, B, C 행렬곱의 최소 연산 횟수가 계산되는 과정을 점화식과 함께 나타냈다.

dp[i][j] = mini≤k≤j-1(dp[i][k] + dp[k+1][j] + didkdj-1)
dp[1][2] = min1≤k≤1(dp[1][1] + dp[2][2] + d0d1d2) = 0 + 0 + 30 = 30
dp[2][3] = min2≤k≤2(dp[2][2] + dp[3][3] + d1d2d3) = 0 + 0 + 60 = 60
dp[1][3] = min1≤k≤2(dp[1][k] + dp[k+1][3] + d0dkd3) = min(dp[1][1] + dp[2][3] + d0d1d3dp[1][2] + dp[3][3] + d0d2d3)

아래는 동적 계획법으로 최소 곱 연산 횟수를 구하는 소스코드이다. 행렬의 최대 개수를 500개로 설정하였다.

#include <bits/stdc++.h>
 
using namespace std;
 
int main(void)
{
    ios::sync_with_stdio(false);
    cin.tie(0);
    int n;
    cin >> n;
    int d[501];
    for(int i = 0; i < n; i++) {
        cin >> d[i] >> d[i + 1];
    }
    int dp[501][501] = { 0 };
    for(int len = 2; len <= n; len++) {
        for(int i = 1; i <= n - len + 1; i++) {
            int j = i + len - 1;
            dp[i][j] = INT_MAX;
            for(int k = i; k <= j - 1; k++) {
                dp[i][j] = min(dp[i][j], dp[i][k] + dp[k + 1][j] + (d[i - 1] * d[k] * d[j]));
            }
        }
    }
    cout << dp[1][n] << endl;
    return 0;
}

Saturday, June 20, 2020

C++에서 유용한 print debugging 방법

문제를 해결할 때, VS의 디버깅 툴이나 gdb를 사용하지 않고 빠르게 오류를 잡아낼 수 있는 능력이 중요하다. 그 방법 중 가장 기본적인 것으로는 오류가 있다고 의심되는 변수의 값을 찍어보는 방법이 있는데, 아래는 print debugging을 편리하게 해줄 코드 snippet 이다.

string to_string(string s) {
  return '"' + s + '"';
}

string to_string(const char* s) {
  return to_string((string) s);
}
 
string to_string(bool b) {
  return (b ? "true" : "false");
}
 
template <typename A, typename B>
string to_string(pair<A, B> p) {
  return "(" + to_string(p.first) + ", " + to_string(p.second) + ")";
}

template <typename A>
string to_string(A v) {
  bool first = true;
  string res = "{";
  for (const auto &x : v) {
    if (!first) {
      res += ", ";
    }
    first = false;
    res += to_string(x);
  }
  res += "}";
  return res;
}

void debug_out() { cerr << endl; }
 
template <typename Head, typename... Tail>
void debug_out(Head H, Tail... T) {
  cerr << " " << to_string(H);
  debug_out(T...);
}

#ifdef LOCAL
#define debug(...) cerr << "[" << #__VA_ARGS__ << "]:", debug_out(__VA_ARGS__)
#else
#define debug(...) 42
#endif

작성된 코드의 여러 부분에서 debug() 함수를 호출하여 변수 값들을 출력하고 오류를 잡아냈다면 코드를 최종 제출하기 위해서는 여러 군데 위치해 있는 debug() 함수들을 다시 지워야 한다. 이는 굉장히 번거롭기 때문에 이 과정을 거치지 않고도 바로 제출하기를 원할 것이다. 이를 위해서 "LOCAL" 매크로가 존재하는 경우에만 debug() 함수의 동작을 정의한다. 코드를 제출할 환경이 컴파일 시에 "LOCAL" 매크로를 포함하지 않는다면, debug() 함수를 호출한 흔적을 없애지 않고 그대로 제출해도 문제가 없을 것이다. 컴파일러마다 다르겠지만 예를 들어 g++을 이용한다면, 컴파일 할때 -D 옵션을 사용하여 "LOCAL" 매크로를 선언해주어야 한다. (그게 아니라면 코드 내에 "LOCAL" 매크로를 선언해야 함) 이 코드 snippet은 tourist가 작성한 소스 코드로부터 가져왔다.

g++의 유용한 built-in 함수

* bit operation
1) 
__builtin_ffs(x)
x의 least significant 1-bit에 1을 더해준 값을 반환한다. x는 기본적으로 int 이며, suffix "l"을 주면 long 인자, "ll"을 주면 long long 인자로 대입된다.
e.g. __builtin_ffs(10) = 2. 10은 2진수로 "1010"인데 오른쪽에서부터 왼쪽으로 오면서 최초로 나타나는 1-bit는 인덱스 1에 (0-based) 위치해있기 때문이다.

2)
__builtin_clz(x)
MSB로부터 시작하는 x의 leading-0 비트 수를 반환한다. x는 기본적으로 unsigned int 이며, suffix "l"을 주면 unsigned long 인자, "ll"을 주면 unsigned long long 인자로 대입된다.
e.g. __builtin_clz(16) = 27. 인자가 unsigned int 이므로 32비트 크기를 가지고 있고 16은 2진수로 "...10000" 이기 때문에 32 - 5 = 27.

3)
__builtin_ctz(x)
LSB로부터 시작하는 x의 trailing-0 비트 수를 반환한다. x는 기본적으로 unsigned int 이며, suffix "l"을 주면 unsigned long 인자, "ll"을 주면 unsigned long long 인자로 대입된다.
e.g. __builtin_ctz(16) = 4. 16은 2진수로 "10000" 이므로 trailing-0 비트 수는 4이다.

4)
__builtin_popcount(x)
x의 비트 1의 개수를 반환한다. x는 기본적으로 unsigned int 이며, suffix "l"을 주면 unsigned long 인자, "ll"을 주면 unsigned long long 인자로 대입된다. (__builtin_popcountl, __builtin_popcountll)
e.g. __builtin_popcount(14) = 3. 14는 2진수로 "1110" 이므로 비트 1의 개수는 3이다.

* 그 외
1)
__gcd(a, b)
a와 b의 최대공약수(gcd)를 구해준다.
e.g. __gcd(18, 27) = 9