데이터 구조 활용 알고리즘 풀이: 시뮬레이션 대회 기법 분석

문제 풀이 개요

이번 기술 평가에서는 다양한 데이터 구조 및 알고리즘 패턴을 요구하는 네 가지 문제를 다뤘습니다. 전반적으로 전처리 기법과 효율적인 데이터 접근 방식이 핵심이었으며, 다음과 같이 분류할 수 있습니다.

  • T1: 수직선상에서의 가중치 구간 합 계산 (좌표 압축 및 누적 합)
  • T2: 트리의 서브트리 내 희귀 요소 카운팅 (DFS 및 비트집합 병합)
  • T3: 제한 조건 하의 최대 부분합 탐색 (우선순위 큐 슬라이딩 윈도우)
  • T4: 값에 따른 조작 횟수 질의 처리 (스캔라인 및 펜윅 트리는)

문제 1: 수직선상 구간 가중치 합

주어진 수직선상에 여러 점이 있으며, 각 점은 위치와 가중치를 갖습니다. 이때 특정 구간 [l, r] 내에서 포함된 점들의 가중치 합을 구하는 문제입니다. 좌표의 범위가 매우 크기 때문에 직접 배열을 사용할 수 없으며, 입력된 좌표 값을 정렬하고 중복을 제거하여 인덱스로 매핑하는 좌표 압축 기법이 필수적입니다.

입력 형식

  • n: 점의 개수
  • x_i, y_i: 점의 좌표와 가중치
  • q: 질의 수
  • l, r: 각각의 질의에 대한 구간 시작점과 끝점

해결 전략

  1. 점의 좌표들을 분리하여 추출한 뒤 정렬합니다.
  2. std::unique 를 통해 중복되는 좌표 위치를 제거하고 압축된 배열을 생성합니다.
  3. 원본 점 데이터를 압축된 좌표 인덱스에 대응시켜 가중치를 더합니다.
  4. 가중치들에 대해 누적 합 배열을 구성하여 임의 구간 합을 O(1) 시간 안에 반환합니다.
#include <iostream>
#include <algorithm>
#include <vector>

using namespace std;

const int MAXN = 200005;

int n, q_num;
long long coords[MAXN], temp_coords[MAXN];
long long weight_at_idx[MAXN];
long long prefix_sum[MAXN];

struct Point {
    long long x_val, y_val;
};

Point points[MAXN];

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    if (!(cin >> n)) return 0;

    vector<long long> sorted_x;
    sorted_x.reserve(n);

    for (int i = 1; i <= n; ++i) {
        cin >> points[i].x_val;
        sorted_x.push_back(points[i].x_val);
    }
    
    for (int i = 1; i <= n; ++i) {
        cin >> points[i].y_val;
    }

    sort(sorted_x.begin(), sorted_x.end());
    auto last = unique(sorted_x.begin(), sorted_x.end());
    sorted_x.erase(last, sorted_x.end());

    // 가중치 집계
    for (int i = 1; i <= n; ++i) {
        int idx = lower_bound(sorted_x.begin(), sorted_x.end(), points[i].x_val) - sorted_x.begin();
        weight_at_idx[idx] += points[i].y_val;
    }

    // 누적합 계산
    for (size_t i = 0; i < sorted_x.size(); ++i) {
        prefix_sum[i] = (i > 0 ? prefix_sum[i - 1] : 0) + weight_at_idx[i];
    }

    cin >> q_num;
    while (q_num--) {
        long long l, r;
        cin >> l >> r;

        // 좌표 압축된 배열에서 해당 구간의 인덱스 찾기
        // start_idx 는 l 이상인 첫 번째 위치
        size_t start_idx = lower_bound(sorted_x.begin(), sorted_x.end(), l) - sorted_x.begin();
        
        // end_idx 는 r 이하인 마지막 위치
        size_t end_idx = upper_bound(sorted_x.begin(), sorted_x.end(), r) - sorted_x.begin() - 1;

        if (start_idx > end_idx || end_idx >= sorted_x.size()) {
            cout << 0 << "\n";
        } else {
            long long ans = prefix_sum[end_idx] - (start_idx > 0 ? prefix_sum[start_idx - 1] : 0);
            cout << ans << "\n";
        }
    }

    return 0;
}

문제 2: 트리의 색칠 및 집합 유지

루트가 1 번 노드인 트리가 주어집니다. 각 노드는 특정 색상을 가지며, 모든 노드를 기준으로 해당 노드를 루트로 하는 서브트리에 등장하는 색상 중 '홀수 번' 나타난 색상의 개수를 구해야 합니다.

핵심 논리

색상 조합을 집합으로 간주할 때, 두 집합의 대칭 차집합 (XOR 연산) 은 두 집합을 합쳤을 때 홀수 번 나타나는 원소를 모두 포함한다는 성질을 이용합니다. 따라서 하위 노드의 결과를 위로 올릴 때 비트셋을 사용하는 것이 효율적입니다.

실현 방법

  • 색상 값이 크므로 먼저 압축 처리 (Discretization) 를 수행합니다.
  • DFS 를 통해 자식 노드들의 결과 집합을 가져온 후 현재 노드의 색상 비트를 추가하고 XOR 연산을 수행합니다.
  • 최종적으로 각 노드에서 비어있는 비트의 개수를 카운팅하여 답변을 저장합니다.
#include <iostream>
#include <algorithm>
#include <vector>
#include <bitset>

using namespace std;

const int MAXN = 50005;
int n;
int raw_colors[MAXN], compressed_colors[MAXN];
int subtree_odd_count[MAXN];
vector<int> adj_list[MAXN];
bitset<MAXN> node_color_sets[MAXN];
bool visited[MAXN];

void dfs(int current_node) {
    visited[current_node] = true;
    
    // 자식 노드 처리
    for (int neighbor : adj_list[current_node]) {
        if (!visited[neighbor]) {
            dfs(neighbor);
            // 집합 병합 (XOR): 홀수 번出现的 색상만 남음
            node_color_sets[current_node] ^= node_color_sets[neighbor];
        }
    }
    
    // 현재 노드의 색상 반영
    node_color_sets[current_node][compressed_colors[current_node]] = 1;
    
    // 현재 집합내 1 의 개수 (오수 색상 수) 기록
    subtree_odd_count[current_node] = static_cast<int>(node_color_sets[current_node].count());
}

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    if (!(cin >> n)) return 0;

    // 색상 압축 준비
    vector<int> color_vals(n);
    for (int i = 1; i <= n; ++i) {
        cin >> raw_colors[i];
        color_vals[i - 1] = raw_colors[i];
    }

    sort(color_vals.begin(), color_vals.end());
    color_vals.erase(unique(color_vals.begin(), color_vals.end()), color_vals.end());

    for (int i = 1; i <= n; ++i) {
        compressed_colors[i] = lower_bound(color_vals.begin(), color_vals.end(), raw_colors[i]) - color_vals.begin();
    }

    for (int i = 1; i < n; ++i) {
        int u, v;
        cin >> u >> v;
        adj_list[u].push_back(v);
        adj_list[v].push_back(u);
    }

    dfs(1);

    for (int i = 1; i <= n; ++i) {
        cout << subtree_odd_count[i] << "\n";
    }

    return 0;
}

문제 3: 제한 조건 하의 최대 부분합

서로 다른 숫자로 이루어진 배열이 있고, 이를 선택하여 부분합을 최대화하는 문제입니다. 단, 선택된 인접한 두 원소의 원본 인덱스 차이는 k 보다 작아야 하며, 현재 선택하려는 원소는 과거 k 개의 원소 중에서 r 번째로 작은 값을 가진 위치여야 합니다. 이 문제는 '최대 합 DP' 에 '슬라이딩 윈도우 최솟값/최대값' 검색이 결합된 형태입니다.

구현 포인트

DP 상태 전이 시 전역 최소값이나 특정 순위의 요소를 빠르게 찾아야 합니다. 이때 두 개의 우선순위 큐 (Min-Heap, Max-Heap) 를 쌍으로 사용하여 힙 내부 요소를 관리하면 됩니다. 유효하지 않은 인덱스가 힙 상단에 도달했을 때 즉시 삭제하지 않고 나중에 처리하는 'Lazy Deletion' 방식을 적용하여 성능 저하를 방지합니다.

#include <iostream>
#include <algorithm>
#include <queue>
#include <vector>

using namespace std;

const int MAXN = 200005;

int n, k_limit, r_rank;
long long dp_val[MAXN];
long long arr_val[MAXN];
int active_window_cnt = 0;
vector<int> in_heap_pos(MAXN, 0);

// 현재 기준보다 큰 쪽 (Max Heap) 을 위한 Comparator
struct GreaterHeapComp {
    bool operator()(const int& a, const int& b) const {
        return arr_val[a] < arr_val[b];
    }
};

// 현재 기준보다 작은 쪽 (Min Heap) 을 위한 Comparator
struct LessHeapComp {
    bool operator()(const int& a, const int& b) const {
        return arr_val[a] > arr_val[b];
    }
};

priority_queue<int, vector<int>, GreaterHeapComp> left_heap;
priority_queue<int, vector<int>, LessHeapComp> right_heap;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    cin >> n >> k_limit >> r_rank;

    for (int i = 1; i <= n; ++i) {
        cin >> arr_val[i];
    }

    dp_val[1] = arr_val[1];
    left_heap.push(1);
    in_heap_pos[1] = 1;
    active_window_cnt++;

    for (int i = 2; i <= n; ++i) {
        // 유효한 윈도우 밖으로 밀려난 인덱스 확인
        // 주의: 실제 인덱스 보다는 heap 내부에 남은 쓰레기 인덱스를 처리함
        
        // 1. 왼쪽 힙에서 너무 오래된 (window out) 것들 제거
        while (!left_heap.empty()) {
            int top_idx = left_heap.top();
            if (top_idx <= i - k_limit) {
                // 만약 이 인덱스가 아직 유효하다고 표시된 경우라면 count 에서 빼줘야 함
                // 하지만 lazy deletion logic 을 위해 여기서 바로 pop 하지 않고 아래 로직 참조
                break; 
            }
            // 실제로 pop 하기 전에 유효성 체크 로직이 필요하지만 여기선 단순화
            // 올바른 LAZY DELETION 구현은 below loop 에서 관리됨
            
            // 위는 설명용이고 아래 코드가 실제 동작임
             if (top_idx <= i - k_limit) {
                 left_heap.pop();
                 // 이미 pop 되었으므로 cnt 조정 필요 없음 (in_heap_pos 참고)
                 // 하지만 active_window_cnt 는 실제 heap size 가 아님.
                 // 여기서는 heap size 자체를 관리하지 않고 logical count 를 따로 관리해야 함
                 // 재작성된 로직 적용:
             } else {
                 break;
             }
        }
        
        // 위의 복잡한 lazy logic 대신 명확하게 작성된 버전 적용
        int effective_start_idx = i - k_limit;
        // 오른쪽 힙 (작은 값들) 에서 유효한 요소들을 왼쪽 힙 (큰 값들) 로 이동시킴
        
        // 현재 단계에 필요한 후보들을 정리
        // 이전 과정에서의 left_heap 과 right_heap 상태 유지 필요
    }
    
    // 코드 복잡도 감소를 위해 재구조화한 알고리즘 흐름
    active_window_cnt = 1;
    left_heap.push(1);
    
    for (int i = 2; i <= n; ++i) {
        int window_boundary = i - k_limit;
        
        // 힙에서 유효하지 않은 인덱스를 먼저 제거하거나 고려
        // left_heap 에서 window_boundary 보다 작은 인덱스를 우측 힙으로 보내거나 무시
        
        // 1. right_heap 에서 유효한 가장 큰 값을 왼쪽 (left_heap) 으로 끌어옴
        // 현재 left_heap 에 r_rank 개 이상의 원소가 있어야 하고 그 중 r_rank 번째가 선택됨
        
        // 간단한 구현을 위해 priority_queue 를 적절히 혼합
        // 최적화된 로직은 아래와 같습니다
        
        // previous logic reset for accuracy
        // left_heap: 현재 window 에서 상위 (k-r) + 1 ~ k 번째 원소들
        // right_heap: 현재 window 에서 하위 1 ~ k-r 번째 원소들
        
        // 기존 로직 수정:
        // window slide 시 new element push, old element pop
        
        int oldest_valid_idx = i - k_limit;
        
        // 1. 오른쪽 힙 (크기가 작음) 에서 왼쪽 힙으로 옮기기
        // 목표: left_heap 에 r_rank 개 이상의 유요한 요소가 확보되도록
        while ((int)right_heap.size() + (int)left_heap.size() < r_rank || 
               ((!left_heap.empty()) && left_heap.size() > r_rank + left_heap.size())) {
           // This condition is messy. Switching to standard Two-Heap Median style logic adapted for R-th min/max
        }
    }
    
    // 정확한 두 힙 솔루션으로 다시 작성
    // left_heap: Min Heap, 크기 <= r_rank
    // right_heap: Max Heap, 나머지
    // 하지만 문제 요구사항: r 번째 작은 것
    
    // Resetting for clean implementation in block below
    active_window_cnt = 0;
    // Clear globals manually if run in function, here assuming fresh state
    while(!left_heap.empty()) left_heap.pop();
    while(!right_heap.empty()) right_heap.pop();

    left_heap.push(1);
    active_window_cnt = 1;
    
    for (int i = 2; i <= n; ++i) {
        int oldest = i - k_limit;
        
        // 유효하지 않은 이전 인덱스 처리
        // left_heap 의 top 이 oldest 보다 작으면 pop 하지만 실제로는 lazy deletion 을 위해 in_heap_pos 체크
        
        // 1. 오른쪽 힙 (왼쪽보다 큰 원소) 에서 왼쪽 힙 (r_th smaller) 으로 이동
        // left_heap 은 전체 window 에서 r번째 작은 것을 기준으로 오른쪽에 있는 것은 작은 것들이어야 함
        
        // Correct Logic:
        // We want to pick from [i-k, i-1] the r-th smallest element to come from.
        // Wait, logic check: f[i] depends on r-th smallest value in window [j-k, j-1] where j=i.
        // Actually input says: adjacent elements diff index <= k. And a[j] must be r-th smallest in range.
        
        // Re-implementation of Two Heap Logic strictly
        active_window_cnt = max(active_window_cnt, 0);
        
        // Clean up expired indices from heap tops
        while (!left_heap.empty() && left_heap.top() < oldest) {
            // Lazy removal: do nothing now, handled when pushing/popping counts if tracking size carefully
            // Actually simpler: maintain valid_count variable
            // For simplicity in this rewrite, assume we handle counts externally
             if(left_heap.top() == -1) {
                // Handle logic internally via separate count variables
             }
             left_heap.pop(); 
        }
        while (!right_heap.empty() && right_heap.top() < oldest) {
             right_heap.pop();
        }
        
        // Maintain heap sizes such that left_heap has exactly r_rank elements (mostly)
        // Actually we need the r-th smallest element available at the top of one of them.
        // If we use Left as Min Heap of size r_rank containing largest r values of smallest r, it's confusing.
        
        // Standard Approach for this specific problem:
        // Right Heap (Max Heap): Stores elements larger than pivot. Size approx k-r
        // Left Heap (Min Heap): Stores elements smaller than pivot. Size approx r
        // Pivot is Top of Left Heap.
        
        // Add candidate i-1
        // Determine which heap to put i-1 in based on size balance
        if (left_heap.size() < r_rank) {
             if (left_heap.empty() || arr_val[left_heap.top()] < arr_val[i-1]) {
                 left_heap.push(i-1);
             } else {
                 right_heap.push(i-1);
             }
        } else {
            right_heap.push(i-1);
            left_heap.push(right_heap.top());
            right_heap.pop();
        }
        
        // Remove invalid from tops (Window constraint)
        while(!left_heap.empty() && left_heap.top() < i - k_limit) left_heap.pop();
        while(!right_heap.empty() && right_heap.top() < i - k_limit) right_heap.pop();

        // Get the value for DP transition
        // The r-th smallest should be in Left Heap top (since Left holds smallest r elements)
        if (left_heap.empty()) {
             dp_val[i] = -1e18; // Should not happen
        } else {
             int prev_idx = left_heap.top();
             dp_val[i] = dp_val[prev_idx] + arr_val[i];
             
             // Maintain Size Balance again if pop happened above
             while (left_heap.size() < r_rank && !right_heap.empty()) {
                 left_heap.push(right_heap.top());
                 right_heap.pop();
             }
             
             // Clean invalid again after move
              while(!left_heap.empty() && left_heap.top() < i - k_limit) left_heap.pop();
        }
        
        // Push current i to heap for next iterations
        // Note: Logic adjustment for correct sliding window
        if(arr_val[i-1] >= arr_val[left_heap.top()]) { // Compare to maintain order properly
           // Already handled above logic flow roughly
        }
        
        // Fixing the final insertion step properly to match previous valid logic
        int cmp_top = left_heap.empty() ? i : left_heap.top(); 
        if(left_heap.size() < r_rank) {
             left_heap.push(i);
        } else {
             // Swap if needed
             if(right_heap.empty()) {
                  right_heap.push(i);
             } else {
                 if(arr_val[i] > arr_val[right_heap.top()]) {
                     right_heap.push(i);
                 } else {
                     left_heap.push(i);
                     right_heap.push(left_heap.top());
                     left_heap.pop();
                 }
             }
        }
    }

    long long max_ans = 0;
    for (int i = 1; i <= n; ++i) {
         if (dp_val[i] > max_ans) max_ans = dp_val[i];
    }
    cout << max_ans;

    return 0;
}

문제 4: 전투 및 구간 조작 횟수

특정 값까지 반으로 줄이는 데 드는 회수 문제를 효율적으로 해결하기 위해 오프라인 처리 기법을 사용합니다. 각 숫자마다 0 또는 k 까지 도달하기 위해 필요한 나누기 횟수를 사전에 계산해두고, 이를 기반으로 스패닝 라인 (Scanline) 알고리즘을 적용합니다.

핵심 아이디어

  1. 각 수 x에 대하여 xk 이하가 될 때까지 반복해서 2 로 나눈 횟수를 리스트 v[x] 에 기록합니다.
  2. 쿼리는 k 값이 클수록 처리 대상이 적어지므로, k 값을 역순으로 처리합니다 (300,000 down to 0).
  3. 현재 k 값에 해당하는 조작 횟수가 필요한 위치를 펜윅 트리 (Fenwick Tree) 에 가산 업데이트합니다.
  4. 해당 k 와 관련된 모든 질의를 페네틱 트리를 활용해 O(log N) 만에 처리합니다.
#include <iostream>
#include <algorithm>
#include <vector>

using namespace std;

const int MAXN = 300005;
const int MAX_VAL = 300005;

int n, m;
int a_arr[MAXN];
int bit_tree[MAXN];

struct Query {
    int l, r, id;
};

vector<Query> queries_by_k[MAX_VAL];
int results[MAXN];

// BIT Operations
inline void update_bit(int idx, int val) {
    for (; idx <= n; idx += idx & (-idx)) {
        bit_tree[idx] += val;
    }
}

inline int query_bit(int idx) {
    int sum = 0;
    for (; idx > 0; idx -= idx & (-idx)) {
        sum += bit_tree[idx];
    }
    return sum;
}

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    if (!(cin >> n >> m)) return 0;

    for (int i = 1; i <= n; ++i) {
        cin >> a_arr[i];
    }

    for (int i = 1; i <= m; ++i) {
        int l, r, k;
        cin >> l >> r >> k;
        // k+1 indexing for array safety, though we iterate downwards
        if (k >= MAX_VAL) k = MAX_VAL - 1;
        queries_by_k[k + 1].push_back({l, r, i});
    }

    // Precompute positions for each value level
    // value_positions[v] contains list of indices where dividing to v happens?
    // No, logic is: value X needs steps to reach <= k.
    // If we process k from MAX down to 0. When k decreases by 1, some numbers need +1 step.
    // Actually, simpler: Store how many times each number needs to be divided to become <= k.
    // This is equivalent to counting indices where `val` contributes to answer for threshold k.
    // Contribution logic: Number x contributes to answer at index i if `x` reduced to <= k.
    // So we precalculate at which values (levels) each number becomes relevant.
    
    // Correct Optimization per original logic:
    // Each number arr[i] generates a sequence: arr[i], arr[i]/2, arr[i]/4...
    // These levels represent the thresholds below which it stops contributing further reductions.
    // We mark positions in BIT.
    
    // Prepare buckets for sweep line
    // For each element arr[i], we track thresholds t where it starts needing division.
    // Actually, simply: for each i, calculate path to 0.
    // e.g. 13 -> 6 -> 3 -> 1 -> 0.
    // Thresholds 13, 6, 3, 1 are where operations happen.
    // If query k=3, then 13 and 6 contribute.
    
    vector<int> contribution_levels[MAX_VAL];
    for (int i = 1; i <= n; ++i) {
        int val = a_arr[i];
        while (val > 0) {
            contribution_levels[val].push_back(i);
            val /= 2;
        }
    }

    // Process K from high to low
    for (int k_threshold = MAX_VAL; k_threshold >= 0; --k_threshold) {
        // Add indices that require division at this level or higher
        // Since we iterate down, when k reaches T, all numbers >= T that are in bucket T become relevant
        // Wait, original logic: Mark BIT at position i if arr[i] needs op for current k.
        // If k decreases, fewer ops needed? No. k=10 (stop if <=10). k=5 (stop if <=5).
        // Lower k means MORE divisions required.
        // So process k descending means we ADD divisions required.
        
        // contribution_levels[t] stores indices where value t is encountered in reduction chain
        // If current k < t, then these indices need an extra reduction.
        
        // Let's stick to the proven logic from input analysis:
        // For every number, store the values it passes through: 13 -> 6 -> 3.
        // Indices added to BIT when we drop below these values.
        
        for (int pos_idx : contribution_levels[k_threshold]) {
            update_bit(pos_idx, 1);
        }

        // Answer queries for k_threshold
        for (const auto& q : queries_by_k[k_threshold]) {
            int count = query_bit(q.r) - query_bit(q.l - 1);
            results[q.id] = count;
        }
    }

    for (int i = 1; i <= m; ++i) {
        cout << results[i] << "\n";
    }

    return 0;
}

태그: C++ Fenwick Tree Priority Queue Coordinate Compression bitset

8월 18일 01:36에 게시됨