반응형

1. Segment Tree

값이 변하는 연속 구간에 대한 정보를 저장할 때 사용하는 자료구조이다.

값이 변하지 않는다면 구간합을 한 번 연산하고, 계속 질의하면서 쿼리에 대한 결과값을 가져오면 되는데 중간에 있는 값이 변하게 된다면 다시 구간합을 구해야 하므로 O(N) 시간만큼 시간 복잡도가 발생하게 된다. 그래서 조회, 수정 시에도 O(lgN) 시간만큼 줄일 수 있는 자료구조가 필요했다.

일반적인 세그먼트 트리는 입력되는 값 범위의 트리 높이만큼 계산하기 때문에 (N개의 원소가 있을 구현에 따라) 2배에서 4배정도의 추가 공간이 필요하지만 원소의 변경, 특정 범위 내의 연산을 logN 수행할 있다는 장점이 있다.

 

데이터 관점에서 보면 트리는 아래처럼 구성된다.

 

인덱스 관점에서 보면 트리는 아래처럼 구성된다.

 

트리의 인덱스가 0 이 아닌 1 인 이유는부터 시작할 때 좌측부터 차례로 인덱스를 부여하면 아래 공식이 성립한다.

 

좌측 노드 인덱스 = 부모 노드 인덱스 * 2 (짝수)

우측 노드 인덱스 = 부모 노드 인덱스 * 2 + 1 (홀수)

 

반대로, 데이터는 1 노드부터 전체 구간의 합이 적재되고, 내려오면서 반씩 범위가 쪼개져 구간의 합이 표시된다.

이진 탐색 트리 연산이므로 Top-Down 방식으로 이분할하여 데이터를 수정하고 조회하면 된다.

 

<템플릿>

class SegmentTree {
    static long[] tree;
    int treeSize;

    public SegmentTree(int arrSize){
        int h = (int) Math.ceil(Math.log(arrSize)/ Math.log(2));
        this.treeSize = (int) Math.pow(2,h+1);
        tree = new long[treeSize];
    }

    // 초기화와 수정을 update 에서 동시에
    static void update(int node, int index, int updateValue, int left, int right) {
        // 기저사례 (구간에 index 가 없는 경우)
        if (index < left || index > right)
            return;

        // 기저사례 (마지막 구간에 index 가 있는 경우)
        if (left == right) {
            tree[node] = updateValue;
        }

        tree[node] += updateValue;
        int mid = left + (right - left) / 2;
        update(node * 2, index, updateValue, left, mid);
        update(node * 2 + 1, index, updateValue, mid+1, right);
    }

    static void long query(int node, int startIndex, int endIndex, int left, int right){
        // 기저사례 (구간에 쿼리구간이 없는 경우)
        if (endIndex < left || startIndex > right)
            return 0;

        // 기저사례 (마지막 구간에 index 가 있는 경우)
        if (endIndex >= right && startIndex <= left) {
            return tree[node];
        }

        int mid = left + (right - left) / 2;
        return query(node*2, startIndex, endIndex, left, mid) + query(node*2 + 1, startIndex, endIndex, mid+1, right);
    }
}

 

2. Dynamic Segment Tree

일반적인 세그먼트 트리는 보통 입력 범위의 4배에 달하는 공간을 미리 할당한 , 모두 업데이트한다. (위 세그먼트 트리 참고) 입력의 범위만큼 모든 영역(logN) 업데이트해야 하며, 사용하지 않는 공간까지 낭비한다는 단점이 있다.

 

반면, Dynamic Segment Tree 이름에서도 있듯이 노드가 필요할 때만 생성해서 만들기 때문에 공간을 낭비하지 않는다. 값을 없데이트할 때 mid 노드의 자식 노드가 없는 경우 노드를 동적으로 생성해준다는 점이 다르다. 구간에 대한 쿼리 로직은 자식이 없는 경우만 예외처리해주면 일반적인 세그먼트 트리와 동일하다.

 

<템플릿>

static class SegmentTree {
    static class Node {
        long value;
        Node leftChild, rightChild;
    }

    static Node getNode() {
        Node temp = new Node();
        temp.value = 0;
        temp.leftChild = null;
        temp.rightChild = null;
        return temp;
    }

    static void initTree(Node node, int index, long updateValue, int left, int right) {
        // 기저사례 (구간에 index 가 없는 경우)
        if (index < left || index > right)
            return;

        // 기저사례 (마지막 구간에 index 가 있는 경우 (수정할 필요 없는 경우 값 셋팅))
        // 초기화 과정이 없기 때문에 필요
        if (left == right) {
            node.value = updateValue;
            return;
        }

        int mid = left + (right - left) / 2;
        long leftSum = 0, rightSum = 0;

        if (index <= mid) {
            if (node.leftChild == null) {
                node.leftChild = getNode();
            }
            update(node.leftChild, index, updateValue, left, mid);
        } else {
            if (node.rightChild == null) {
                node.rightChild = getNode();
            }
            update(node.rightChild, index, updateValue, mid+1, right);
        }

        if (node.leftChild != null)
            leftSum = node.leftChild.value;
        if (node.rightChild != null)
            rightSum = node.rightChild.value;
        node.value = leftSum + rightSum;
    }

	static void updateRange(Node node, long updateValue, int updateStart, int updateEnd, int left, int right) {
        // 기저사례 (구간에 쿼리구간이 없는 경우)
        if (updateEnd < left || updateStart > right)
            return;

        int mid = left + (right - left) / 2;
        updateRange(node.leftChild, updateValue, updateStart, updateEnd, left, mid);
        updateRange(node.rightChild, updateValue, updateStart, updateEnd, mid + 1, right);
        node.value = node.leftChild.value + node.rightChild.value;
    }
        
    static void int query(Node node, int startIndex, int endIndex, int left, int right){
        // 기저사례 (할당되지 않은 경우)
        if (node == null)
            return 0;
        // 기저사례 (구간에 쿼리구간이 없는 경우)
        if (endIndex < left || startIndex > right)
            return 0;

        // 기저사례 (마지막 구간에 index 가 있는 경우)
        if (endIndex >= right && startIndex <= left) {
            return node.value;
        }

        int mid = left + (right - left) / 2;
        return query(node.leftChild, startIndex, endIndex, left, mid) + query(node.rightChild, startIndex, endIndex, mid+1, right);
    }
}

 

3. Dynamic Segment Tree (+ Lazy Propagation)

( 아무리 구글에 검색해봐도 메모리를 절약할 수 있는 Dynamic Segment Tree 와 연산을 줄이는 Lazy Propagation 을 같이 사용하는 코드가 없었다... 특히 Java 는 더욱 없었다. 그래서 내가 쓰려고 템플릿을 만들었다. )

 

Dynamic Segment Tree 에서 값을 업데이트할 때 자식노드들도 모두 변경하는데 굳이 지금 당장 자식노드의 조회나 수정이 필요 없는 경우라면 트리를 순회하면서 업데이트할 필요는 없다. lazy 라는 변수를 하나두고, 그 변수에 업데이트할 변수를 저장한 다음 나중에 조회하거나 수정할 때 한 단계씩 자식에게 전파하면 된다. 그래서 이름이 느린 전파이다.

 

업데이트 하고자 하는 범위를 updateStart, updateEnd 라고 할 때, 자식노드를 순회하면서 left, right 탐색 범위가 업데이트 하고자 하는 범위에 완전히 포함된다면 자식노드를 방문하지 lazy 값만 자식노드에게 전파한뒤 종료한다. 자식 노드들을 방문하지 않는다.

 

<템플릿>

static class SegmentTree {
    static class Node {
        long value;
        long lazy;
        Node leftChild, rightChild;
    }

    static Node getNode() {
        Node temp = new Node();
        temp.value = 0;
        temp.lazy = 0;
        temp.leftChild = null;
        temp.rightChild = null;
        return temp;
    }

    static void updateLazy(Node node, int left, int right) {
        // 만약 현재 노드에 lazy 값이 있다면
        if (node.lazy != 0) {
            // 만약 자식이 있다면 자식들에게 lazy 값을 전파한다.
            if (node.leftChild != null) {
                node.leftChild.lazy += node.lazy;
            }
            if (node.rightChild != null) {
                node.rightChild.lazy += node.lazy;
            }
            // 현재노드 업데이트 ( 부모노드이므로 구간 길이만큼 업데이트 )
            node.value += node.lazy * (right - left + 1);
            // lazy 값을 업데이트했으므로 0으로 만들어준다.
            node.lazy = 0;
        }
    }

    // 초기화
    static void initTree(Node node, int index, long updateValue, int left, int right) {
        // 기저사례 (구간에 index 가 없는 경우)
        if (index < left || index > right)
            return;

        // 기저사례 (마지막 구간에 index 가 있는 경우 (수정할 필요 없는 경우))
        // 초기화 과정이 없기 때문에 필요
        if (left == right) {
            node.value = updateValue;
            return;
        }

        int mid = left + (right - left) / 2;
        long leftSum = 0, rightSum = 0;

        if (index <= mid) {
            if (node.leftChild == null) {
                node.leftChild = getNode();
            }
            initTree(node.leftChild, index, updateValue, left, mid);
        } else {
            if (node.rightChild == null) {
                node.rightChild = getNode();
            }
            initTree(node.rightChild, index, updateValue, mid + 1, right);
        }

        if (node.leftChild != null)
            leftSum = node.leftChild.value;
        if (node.rightChild != null)
            rightSum = node.rightChild.value;
        node.value = leftSum + rightSum;
    }

    static void updateRange(Node node, long updateValue, int updateStart, int updateEnd, int left, int right) {
        updateLazy(node, left, right);
        // 기저사례 (구간에 쿼리구간이 없는 경우)
        if (updateEnd < left || updateStart > right)
            return;

        // 기저사례 (업데이트 구간이 완전히 포함되는 경우)
        if (updateEnd >= right && updateStart <= left) {
            node.value += (updateValue * (right - left + 1));
            if (node.leftChild != null) {
                node.leftChild.lazy += updateValue;
            }
            if (node.rightChild != null) {
                node.rightChild.lazy += updateValue;
            }
            return;
        }

        int mid = left + (right - left) / 2;
        updateRange(node.leftChild, updateValue, updateStart, updateEnd, left, mid);
        updateRange(node.rightChild, updateValue, updateStart, updateEnd, mid + 1, right);
        node.value = node.leftChild.value + node.rightChild.value;
    }

    static long query(Node node, int queryStart, int queryEnd, int left, int right) {
        updateLazy(node, left, right);
        // 기저사례 (할당되지 않은 경우)
        if (node == null)
            return 0;

        // 기저사례 (구간에 쿼리구간이 없는 경우)
        if (queryEnd < left || queryStart > right)
            return 0;

        // 기저사례 (쿼리 구간이 완전히 포함되는 경우)
        if (queryEnd >= right && queryStart <= left) {
            return node.value;
        }

        int mid = left + (right - left) / 2;
        return query(node.leftChild, queryStart, queryEnd, left, mid) + query(node.rightChild, queryStart, queryEnd, mid + 1, right);
    }
}

 

Q . updateRange 와 Query 함수에서 updateLazy 를 가장 먼저 호출해야 되는 이유?

A . 해당 템플릿이 완성되기 전, 구간에 쿼리 구간이 없는 경우 updateLazy 를 할 필요가 없지 않나? 라고 생각했었다. 그래서 "기저사례 (구간에 쿼리구간이 없는 경우)" 로직 뒷 부분에 updateLazy 를 배치하고 최적화를 잘 했다며 만족했었다.

 

 

그런데, Lazy Propagation 의 대표문제인 백준 10999 문제를 풀어보니 모두 틀렸다고 나오는 것이었다. 게시판에서 언급하고 있는 반례 케이스들은 모두 맞았는데 안 되니까 답답할 노릇이었다. 잘 생각해보면 잘못된 생각이었다.

 

만약 부모 노드에 lazy 값이 있는데 자식 노드가 범위를 벗어난다면 lazy 를 전파받지 못하고 계산이 되기 때문이다. 꼭 query 와 update 할 때 updateLazy 를 먼저 호출하자.

반응형
반응형

1. 문제요약

2096번: 내려가기 (acmicpc.net)

 

2096번: 내려가기

첫째 줄에 N(1 ≤ N ≤ 100,000)이 주어진다. 다음 N개의 줄에는 숫자가 세 개씩 주어진다. 숫자는 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 중의 하나가 된다.

www.acmicpc.net

N 줄에 0~9 숫자가 세 개씩 적혀있다. 첫 줄에서 시작해서 마지막 줄까지 내려오는 놀이이다.

별표는 현재 위치이고, 그 아랫 줄의 파란 동그라미는 주인공이 다음 줄로 내려갈 수 있는 위치라고 할 때,

얻을 수 있는 최대 점수, 최소 점수를 구하는 프로그램을 작성하라.

 

2. 문제예제

 

 

3. 팩트추출

Fact 1 : 3 X N 표를 그래프에서 첫째 줄부터 마지막 줄까지 선택하는 경우의 수를 그래프로 나타냈을 때 DFS, BFS 로 문제를 풀 수 있겠지만, DFS 및 BFS 로 문제를 풀려면 항상 이전의 값이 고정된 값이어야 한다. 여기서는 최대값, 최소값 문제이므로 줄을 타고 내려올수록 값이 갱신되기 때문에 DFS, BFS 로 문제를 풀 수 없다.

 

Fact 2 : 부분문제이면서 이전의 결과 값들을 재사용하기 때문에 DP 문제로 풀 수 있다.

 

Fact 3 : 문제를 DP 점화식으로 풀어보면 아래와 같다.

 

S[i][j] = i행 j열까지 내려오면서 얻을 수 있는 최대의 점수라 할 때,
S[i][0] = max(S[i-1][0], S[i-1][1]) + map[i][0]
S[i][1] = max(S[i-1][0], S[i-1][1], S[i-1][2]) + map[i][1]
S[i][2] = max(S[i-1][1], S[i-1][2]) + map[i][2]

 

이 점화식에는 특징이 있다. 현재 값이 이전 행 값만 사용한다. 따라서 DP 테이블을 모두 가지고 있을 필요가 없다.

 

4. 문제풀이(수동)

예제 입력 1 을 minDP 수동으로 풀어본다. maxDP 는 반대 연산이므로 생략한다.

minDP[i] : i 번째 열까지 계산된 최소 비용, maxDP[i] : i 번째 열까지 계산된 최대 비용이라고 하자.

 

-> 0번째 행

minDP[0][0] = 1

minDP[0][1] = 2

minDP[0][2] = 3

 

-> 1번째 행

minDP[1][0] = min(minDP[0][0], minDP[0][1]) + map[1][0] = 1 + 4 = 5

minDP[1][1] = min(minDP[0][0], minDP[0][1], minDP[0][2]) + map[1][1] = 1 + 5 = 6

minDP[1][2] = min(minDP[0][1], minDP[0][2]) + map[1][2] = 2 + 6 = 8

 

-> 2번째 행

minDP[2][0] = min(minDP[1][0], minDP[1][1]) + map[2][0] = 5 + 4 = 9

minDP[2][1] = min(minDP[1][0], minDP[1][1], minDP[1][2]) + map[2][1] = 5 + 9 = 14

minDP[2][2] = min(minDP[1][1], minDP[1][2]) + map[2][2] = 6 + 0 = 6

 

따라서 제일 작은 6 이 정답이다.

 

5. 문제전략

문제 전략은 특별히 없다. 점화식을 단순히 구현하면 된다.

다른 DP 문제와 다른 점은 이전 행 정보만 필요하므로 dp[3] 테이블만 가지고 있으면 된다.

한 단계식 행을 계산할 때마다 테이블을 업데이트하면 된다.

 

6. 소스코드

import java.io.*;
import java.util.*;
class Main {
    static int[] maxDP;
    static int[] minDP;
    static StringTokenizer st;
    public static void main(String[] args) throws NumberFormatException, IOException {
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(System.out));

        maxDP = new int[3];
        minDP = new int[3];

        int row = Integer.parseInt(br.readLine());

        for (int i=0; i<row; i++) {
            st = new StringTokenizer(br.readLine());

            int left = Integer.parseInt(st.nextToken());
            int center = Integer.parseInt(st.nextToken());
            int right = Integer.parseInt(st.nextToken());
            solve(i, left,center,right);
        }
        bw.write(Math.max(maxDP[0], Math.max(maxDP[1], maxDP[2])) + " ");
        bw.write(Math.min(minDP[0], Math.min(minDP[1], minDP[2])) + "\n");
        bw.flush();
        bw.close();
        br.close();
    }
    private static void solve(int index, int left, int center, int right){
        if (index == 0) {
            maxDP[0] = minDP[0] = left;
            maxDP[1] = minDP[1] = center;
            maxDP[2] = minDP[2] = right;
        } else {
            int tempMaxDP0 = maxDP[0], tempMaxDP2 = maxDP[2];
            maxDP[0] = Math.max(maxDP[0], maxDP[1]) + left;
            maxDP[2] = Math.max(maxDP[1], maxDP[2]) + right;
            maxDP[1] = Math.max(Math.max(tempMaxDP0, maxDP[1]), tempMaxDP2) + center;

            int tempMinDP0 = minDP[0], tempMinDP2 = minDP[2];
            minDP[0] = Math.min(minDP[0], minDP[1]) + left;
            minDP[2] = Math.min(minDP[1], minDP[2]) + right;
            minDP[1] = Math.min(Math.min(tempMinDP0, minDP[1]), tempMinDP2) + center;
        }
    }
}
반응형

'알고리즘 > BOJ' 카테고리의 다른 글

[BOJ] 10999 구간 합 구하기 2  (0) 2022.08.30
[BOJ] 9426 중앙값 측정  (0) 2022.08.19
[BOJ] 2138 전구와 스위치  (0) 2022.08.17
[BOJ] 6549 히스토그램에서 가장 큰 직사각형  (0) 2022.08.17
[BOJ] 13263 나무 자르기  (0) 2021.05.01
반응형

LCA 알고리즘이란,

트리 상의 정점 U 와 V 가 주어질 때 U 와 V 에서 가장 가까운 공통 조상을 찾는 알고리즘이다.

 

LCA 그래프 예시 (사실 많은 블로그에서 루트 노드는 1로 설정되어 있음)

 

위 LCA 그래프 예시에서 24 와 26 의 LCA (최초 조상) 은 5 이다.

 

어떻게 조상을 찾는 것일까?

조상을 찾는 방법을 step-by-step 으로 표현한다면 다음과 같다.

 

0 . U 노드와 V 노드를 정한다. (깊이가 더 큰 노드를 V로 정한다.)

1 . U 노드의 깊이와 V 노드의 깊이가 같아질 때까지 깊이가 더 깊은 노드(V)를 자신의 조상으로 바꾸어 준다.

2 . U 노드와 V 노드의 조상이 같아질 때까지 자신의 조상으로 바꾸어 준다.

 

1 번 과정, 2 번 과정 모두 한 단계씩 조상을 올리게 되면 O(N) (깊이를 N이라 두었을 때) 의 복잡도를 가지게 된다.

조금 더 빠르게 트리를 탐색하기 위해 $2^{x}$ 번째에 해당하는 부모만 기억하고 중학교 때 배운 아래 지수법칙

$2^{x+1}=2^{x}*2$ 수식을 활용한다. 수식에서 * 2 부분을 만족하려면 조상노드를 한 번 더 그 크기만큼 올라가야 한다. 

 

따라서,

u 노드의 $2^{x}$ 번째에 해당하는 부모를 parent[u][x] 라고 할 때, parent[u][x] = parent[parent[u][x-1]][x-1] 라는 점화식이 성립한다. 점화식이 만들어졌으니 Bottom-Up DP 로 parent 테이블을 만들어주면 된다.

 

총 루틴 정리

알고리즘의 중요 부분은 모두 나왔다. 총 루틴을 정리해보자.

 

1 . 그래프의 깊이를 구한다.

이 LCA 에서 사용하는 트리는 이진 트리이므로 높이는 log2(N) + 1이다. 여기서 N은 총 그래프 노드의 개수.

(int)Math.ceil(Math.log(n)/Math.log(2)) +1;

 

2 . DFS 로 순회하면서 depth 배열과 parent[][0] 배열을 초기화한다.

 

3 . parent[x][y] 배열을 셋팅한다.

parent[x][y] = parent[parent[x][y-1]][y-1] 점화식이 성립하고 미래 값을 먼저 구하지 않아도 되므로 Bottom-up DP 로 구할 수 있다.

 

4 . LCA(a, b) 에서 b 가 더 깊도록 a 와 b 를 서로 바꾼다.

 

5 . b 깊이가 a 깊이와 같도록 부모 배열을 활용해 자신의 조상으로 바꾼다.

 

6 . 현재 부모가 같다면 리턴.

 

7 . 조상이 같을 때까지 조상을 거슬러 올라가기. 부모가 같다면 그 조상을 리턴.

 

자바 코드

자바코드는 다음과 같다.

public static class LCA {
    private int nodeCount;
    private List<Integer>[] adjacencyList;
    private int treeHigh;
    private int[] depth;
    private boolean[] memoization;
    private int[][] parent;

    public LCA(int nodeCount, List<Integer>[] adjacencyList) {
        this.nodeCount = nodeCount;
        this.adjacencyList = adjacencyList;
        this.memoization = new boolean[nodeCount+1];
        this.depth = new int[nodeCount+1];
        getTreeHigh();
        this.parent = new int[nodeCount+1][this.treeHigh];
    }

    private void getTreeHigh() {
        this.treeHigh = (int)Math.ceil(Math.log(nodeCount)/Math.log(2)) + 1;
    }
    private void init(int current, int treeHigh) {
        memoization[current] = true;
        depth[current] = treeHigh;
        for (int next : adjacencyList[current]) {
            // 메모이제이션
            if(memoization[next]) continue;
            init(next, treeHigh+1);
            parent[next][0] = current;
        }
    }
    private void makeParentArray() {
        for(int i=1; i<treeHigh; i++) {
            for(int j=1; j<nodeCount+1; j++) {
                parent[j][i] = parent[parent[j][i-1]][i-1];
            }
        }
    }
    public int run(int a, int b) {
        init(1,1);
        makeParentArray();
        // b 가 더 깊도록 설정
        if (depth[a] > depth[b]) {
            int temp = a;
            a = b;
            b = temp;
        }
        // 깊이가 동일하도록 b 를 옮김
        for (int i = treeHigh-1; i>=0; i--) {
            if ((1<<i) <= depth[b] - depth[a]) {
                b = parent[b][i];
            }
        }

        if(a==b) return a;

        for(int i=treeHigh-1; i>=0; i--) {
            if (parent[a][i] != parent[b][i]) {
                a = parent[a][i];
                b = parent[b][i];
            }
        }
        return parent[a][0];
    }
}

 

반응형

'알고리즘 > 개념' 카테고리의 다른 글

[알고리즘] Segment Tree  (0) 2022.08.29
[알고리즘] BFS & DFS  (0) 2022.07.12
[알고리즘] 투 포인터 알고리즘 (Two Pointer)  (0) 2022.06.29
[알고리즘] DP (Dynamic Programming)  (0) 2021.05.10
반응형

1. 문제요약

leetcode.com/problems/dungeon-game/

 

Dungeon Game - LeetCode

Level up your coding skills and quickly land a job. This is the best place to expand your knowledge and get prepared for your next interview.

leetcode.com

악마가 공주를 납치해 N x M 던전판 가장 우측 하단에 감금했다.

가장 좌측 상단에 있는 왕자가 공주를 구하기 위한 최소 체력은 얼마인지 구하시오.

 

룰은 다음과 같다.

① 왕자는 우측 또는 하단 방향으로만 한 칸씩 갈 수 있다.

② 각 방에는 음의 정수 또는 양의 정수가 있다. 왕자가 이동할 때 이 숫자에 영향을 받는다.

음수라면 숫자만큼 체력이 삭감되고, 양수라면 숫자만큼 체력이 상승한다.

③ 왕자 체력이 0 이거나 음수가 되면 게임은 종료된다.

 

2. 문제예제

 

3. 팩트추출

Fact 1 : N X M 던전판을 작게 축소하여 작은 문제들의 결합으로 구성할 수 있고 이 작은 문제들의 최적해들을 결합하면 전체 문제의 최적해가 된다. 따라서 Dynamic Programming 문제이다.

 

Fact 2 : dp[i,j] 를 dungeon[i,j] 에서 최소값이라고 할 때, dp[i,j] 는 dp[i,j+1] 과 dp[i+1,j] 중 최소값에 dungeon[i,j] 를 더한 값이다. dungeon[i,j] 값이 양수라면 0을 음수라면 절대값을 더한다. 문제에서 최소값을 구하라고 명시하고 있으므로 양수일 때는 신경 쓸 필요가 없다. 음수를 다 더한 값이 그 경로의 최소값이 된다.

 

Fact 3 : 일반적인 DP 문제와는 달리 해당 점화식에서 바로 값을 구할 수 없다. 점화식으로 표현되는 dp[i,j+1] , dp[i+1,j] 이 두 항이 다른 값들이 먼저 계산되어야 계산할 수 있기 때문이다. 그래서 기저 사례부터 먼저 구하고, 기저 사례만 필요한 마지막 행과 열을 구해 나머지 부분들을 계산하여야 한다.

 

Fact 4 : Top-Down 방식이던 Bottom-Up 방식이던 무한 루프를 방지하기 위해 기저 사례가 필요하다. 점화식으로 표현된 dp[i,j+1] 과 dp[i,j+1] 이 두 항이 이전 항들로 표현되지 않을 수 있을 때 기저 사례라고 한다. 이 문제에서는 공주가 있는 위치가 기저 사례이다.

 

4. 문제풀이(수동)

예제 1번을 기준으로 문제풀이를 진행한다.

 

기저 사례를 먼저 구하면, 공주 위치에 있는 값이 -5 이므로 6 이라는 최소 체력이 필요하다.  

(만약 값이 양수였다면 1 이라는 최소 체력이 필요할 것이다.)

 

Fact 3 에서 정리한 바와 같이 공주 위치를 기준으로 좌측 행 상단 열을 구하면 다음과 같다.

마지막 행과 마지막 열은 각각 이전 행과 이전 열 한 개씩만 필요하기 때문에 풀 수 있다.

   

max(1, -3+5) = 2

   

max(1, -1+6) = 5

max(1, -10+1) = 1

max(1, -30+6) = 1

max(1, 5+1) = 6

 

이 값들을 기준으로 나머지 부분들도 계산하면,

max(1, 2+min(5, 6)) = 7 

max(1, 3+min(2, 11)) = 5

max(1, -3+5) = 2

max(1, 5+min(11, 1)) = 6

max(1, 10+min(5, 1)) = 11

max(1, -1+6) = 5

max(1, -10+1) = 1

max(1, -30+6) = 1

max(1, 5+1) = 6

 

최소 체력은 7 이라는 것을 알 수 있다.

 

5. 문제전략

위 수동풀이처럼 공주님 위치에서 마지막 행 열을 구한 후, 나머지 부분들을 계산할 수 밖에 없다.

Top-Down 방식, Bottom-Up 방식으로 모두 풀 수 있다.

 

6. 소스코드

Top-Down

class Solution {
public:
    int calculateMinimumHP(vector<vector<int>>& dungeon) {
        int row_size = dungeon.size();
        int column_size = dungeon[0].size();
        vector<vector<int>> dp(row_size, vector<int>(column_size));
        return calculate(dungeon, 0, 0, dp);
    }

    int calculate(vector<vector<int>>& dungeon, int i, int j, vector<vector<int>>& dp) {
        // 기저 사례
        if (i == dungeon.size() - 1 && j == dungeon[0].size() - 1)
            return dungeon[i][j] > 0 ? 1 : -dungeon[i][j] + 1;
        // 메모이제이션
        if (dp[i][j] != NULL)
            return dp[i][j];
        // 마지막 행
        if (i == dungeon.size() - 1)
            return dp[i][j] = max(1, calculate(dungeon, i, j + 1, dp) - dungeon[i][j]);
        // 우측 마지막 열
        if (j == dungeon[0].size() - 1)
            return dp[i][j] = max(1, calculate(dungeon, i + 1, j, dp) - dungeon[i][j]);

        return dp[i][j] = max(1, min(calculate(dungeon, i + 1, j, dp) - dungeon[i][j],
                calculate(dungeon, i, j + 1, dp) - dungeon[i][j]));
    }
}

Bottom-Up

class Solution {
public:
    int calculateMinimumHP(vector<vector<int>>& dungeon) {
        int r=dungeon.size();
        int c=dungeon[0].size();
        vector<vector<int>> dp(r,vector<int>(c));

        for(int i=r-1;i>=0;--i)
        {
            for(int j=c-1;j>=0;--j)
            {
                if(i==r-1 && j==c-1)     // 초기 값으로 맨 오른쪽 밑 (Princess Cell)
                    dp[i][j] = max(1,-dungeon[i][j]+1);
                else if(i==r-1)     // 마지막 행
                    dp[i][j] = max(1, dp[i][j+1]-dungeon[i][j]);
                else if(j==c-1)     // 우측 마지막 열
                    dp[i][j] = max(1, dp[i+1][j]-dungeon[i][j]);
                else
                    dp[i][j] = max(1,min(dp[i][j+1],dp[i+1][j])-dungeon[i][j]);
            }
        }
        return dp[0][0];
    }
};
반응형
반응형

1. 문제요약

www.acmicpc.net/problem/13263

 

13263번: 나무 자르기

첫째 줄에 n(1 ≤ n ≤ 100,000)이 주어진다. 둘째 줄에는 a1, a2, ..., an이, 셋째 줄에는 b1, b2, ..., bn이 주어진다. (1 ≤ ai ≤ 109, 0 ≤ bi ≤ 109) a1 = 1이고, bn = 0이며, a1 < a2 < ... < an, b1 > b2 > ... > bn을 만족

www.acmicpc.net

높이가 a 나무 n 개를 전기톱을 이용해서 모든 나무를 완전히 자르려고 한다.

모든 나무를 완전히 자르는데 필요한 충전 비용의 최솟값을 구하는 프로그램을 작성하라.

 

룰은 다음과 같다.

① 전기톱을 사용할 마다 나무의 높이는 1만큼 감소한다. 쓰고 뒤에는 바로 충전해야 한다.

② 완전히 잘려진 나무가 없다면 전기톱은 충전할 없으며 높이가 0 되어버린 나무들 번호 최대값의 충전비용을 사용한다.

③ A 배열은 나무들의 높이이고 B 배열은 전기톱의 충전비용이다.

④ a1 = 1 이고 Bn = 0 이고 a1<a2<…<an, b1>b2>…>bn 만족한다.

 

2. 문제예제

 

3. 팩트추출

Fact 1 : 문제지문에서 Bn 0 이라고 명시하고 있다. Bn 나무의 높이를 0 으로 만들 있는 최소 비용을 구할 있다면 나머지 나무들은 0 비용으로 모두 자를 있다. , 문제가 "모든 나무를 완전히 자르는데 필요한 최소 비용" 에서 "Bn 나무의 높이를 0 으로 만들 있는 최소 비용" 으로 바뀐다.

 

Fact 2 : 전기톱을 사용해서 나무의 높이를 칸씩 줄일 때마다 충전해야 된다. 나무의 높이가 0 것이 하나라도 있어야 최소한 나무의 충전 비용으로 모든 나무를 완전히 자를 있다. 문제지문에서 A1 = 1 이고 A 배열은조증가배열 점을 감안할 , 번째 나무를 먼저 베어야 게임 시작이 가능하다.

 

Fact 3 : 최소 비용을 구하는 것이기에 나무를 베기로 정했으면 중간에 다른 나무를 필요는 없다.

 

Fact 4 : 사실들을 기반으로 문제를 정리하면 다음과 같다.

1번부터 N번까지의 나무가 있었을 1번부터 베기 시작해서 N번까지 베는 최소비용을 찾는다.

문제풀이 부분경로는 순차적이며 중간 경로를 건너 있다는 특징을 가지고 있다. 예를 들어 1번 2번 3번 … N번까지 차례대로 베거나, 1번에서 N번으로 바로 있다.

 

4. 문제풀이(수동)

dp[i] : i 번째 나무를 높이 1로 만드는데 필요한 최소비용

a[i] : i 번째 나무 높이 , b[j] : j 번째 나무 충전비용라고 ,

점화식은 dp[i] = min(1<j<i) dp[j] + a[i]b[j] 같다. i 1부터 N까지이다.

다음은 예제 입력 2번을 기준으로 수동풀이이다.

 

dp[0] = 0

dp[1] = dp[0] + a[1]b[0] = 0 + 2*6 = 12

dp[2] = dp[0] + a[2]b[0] = 0 + 3*6 = 18

        = dp[1] + a[2]b[1] = 12 + 3*5 = 27

dp[3] = dp[0] + a[3]b[0] = 0 + 10*6 = 60

        = dp[1] + a[3]b[1] = 12 + 10*5 = 62

        = dp[2] + a[3]b[2] = 18 + 10*4 = 58

dp[4] = dp[0] + a[4]b[0] = 0 + 20*6 = 120

        = dp[1] + a[4]b[1] = 12 + 20*5 = 112

        = dp[2] + a[4]b[2] = 18 + 20*4 = 98

        = dp[3] + a[4]b[3] = 58 + 20*3 = 118

dp[5] = dp[0] + a[5]b[0] = 0 + 30*6 = 180     --->

        = dp[1] + a[5]b[1] = 12 + 30*5 = 162     --->

        = dp[2] + a[5]b[2] = 18 + 30*4 = 138     --->

        = dp[3] + a[5]b[3] = 58 + 30*3 = 148     --->

        = dp[4] + a[5]b[4] = 98 + 30*2 = 158     --->

 

따라서 정답은 138 이다. 계산은 15 소요되었다.

 

5. 문제전략

수동풀이처럼 순수 DP(점화식) 푼다면, O({N*(N-1)}/2) 복잡도가 발생한다.

문제에서 N 100,000 이기 때문에 시간 제한(2) 이내에 없다. 다른 전략이 필요하다.

 

Solving Strategy Item 1 :

dp[i] = min(1<j<i) dp[j] + a[i]b[j] 점화식에서 변화하지 않는 부분 a[i] 를 이용한다.

a[i] 부분을 x 로 치환하면 y = bx+c 꼴 일차방정식으로 표현할 수 있는데

a 단조증가 b 단조감소 이기 때문에 마치 볼록한 껍질 형태의 그래프들로 표현할 수 있다.

convex hull 이라고 부른다. 예제 입력 2번을 실제 그래프로 표시해보자.

 

convex hull 그래프 이미지
예제 입력 2번 그래프

4번 문제풀이(수동) 을 자세히 보면 ① ② ③ ④ ⑤ 번 함수들이 계속 반복되는데 이 함수들을 그래프로 표시하면 위 그래프와 같다. 여기서 특징점들이 보인다.

 

파랑색 함수는 비교대상이 아니라는 점이다. 녹색 함수와 빨간 함수들만 보면 어느 X 값이라도 최소값을 확인할 수 있다.

일차함수이고 기울기가 낮아지는 함수들이다보니, 두 함수의 교점 X 좌표들을 비교만 해보아도 제외 대상을 가려낼 수 있다. 다시 말해 A 함수와 B 함수의 교점보다 B 함수와 C 함수의 교점이 낮다면 B 함수는 볼 필요 없는 것이다.

 

필요 없는 함수들을 제외하면서 그래프들의 교점들을 모아 두었다가 그 교점들과 A[i] 즉 x 값을 비교한다. x 값이 커질 때의 함수를 찾으면 x 값을 대입하여 y 값을 구할 수 있다.

 

Solving Strategy Item 2 :

그래프의 교점들과 비교 이분탐색을 이용하면 O(NlogN) 시간복잡도까지 줄일 있다.

Solving Strategy Item 1번을 통해 교점들 x 좌표가 오름차순으로 정렬이 되기 때문에 순차적으로 값을 찾기 보다는 이분탐색으로 찾는 것이 훨씬 빠르다.

 

6. 소스코드

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

using namespace std;

const int MAX = 100000;

// 일차 함수 표현 : f(x) = ax+b
struct Line {
	long long a, b;
	double cross_x;

	Line(long long a_, long long b_, double cross_x_ = 0) :a(a_), b(b_), cross_x(cross_x_) {}
};

// 두 함수의 교차점 
double cross(const Line& f, const Line& g)
{
	// f 를 f.ax+f.b 라 하고, g 를 g.ax + g.b 라 할 때 x 를 왼쪽에 남겨두고 이항하면,
	return (g.b - f.b) / (f.a - g.a);
}

// 이분 탐색
int binary_search(vector<Line>& line, long long key)
{
	int position = 0;
	int left = 0, right = line.size() - 1;

	while (left <= right)
	{
		int mid = (left + right) / 2;
		if (line[mid].cross_x < key) {
			position = mid;
			left = mid + 1;
		}
		else
			right = mid - 1;
	}
	return position;
}

long long dp[MAX];

int main()
{
	// 입력 예제 2번
	int n = 6; // N 나무
	vector<int> a = { 1,2,3,10,20,30 }; // 나무 높이
	vector<int> b = { 6,5,4,3,2,0 }; // 충전 비용

	vector<Line> line_stack;

	// dp[0] 은 항상 0 이므로, 1부터 시작
	for (int i = 1; i < n; ++i)
	{
		// 나무가 N 개이면, N-1 개의 점화식이 존재함
		Line g(b[i - 1], dp[i - 1]);    // y = ax+b 에서 차례대로 (a, b)

		while (line_stack.size() >= 1)
		{
			g.cross_x = cross(g, line_stack.back());

			if (g.cross_x < line_stack.back().cross_x) // 교점을 통해 비교. 위에 있는 함수는 버림.
				line_stack.pop_back();
			else
				break;
		}
		line_stack.push_back(g);

		int x_position = 0;
		x_position = binary_search(line_stack, a[i]);
		// y 값 계산
		dp[i] = line_stack[x_position].a * a[i] + line_stack[x_position].b;

	}
	cout << dp[n - 1] << endl;
	return 0;
}
반응형

'알고리즘 > BOJ' 카테고리의 다른 글

[BOJ] 10999 구간 합 구하기 2  (0) 2022.08.30
[BOJ] 9426 중앙값 측정  (0) 2022.08.19
[BOJ] 2138 전구와 스위치  (0) 2022.08.17
[BOJ] 6549 히스토그램에서 가장 큰 직사각형  (0) 2022.08.17
[BOJ] 2096 내려가기  (0) 2022.08.11

+ Recent posts