[문제]

https://www.acmicpc.net/problem/11062

 

-근우(선발 주자)와 명우(후발 주자)가 가장 왼쪽이나 오른쪽 카드를 가져간다.

-최종적으로 근우가 큰 값을 가지도록 했을 때, 근우의 점수를 구하는 것이 목표.

 

[문제 쪼개기]

-큰 문제를 작은 문제로 쪼개보자.

큰 문제는 '모든 카드에서 최선의 선택'을 하는 것이다. 그런데 남아있는 카드가 [1, 2, 5, 2]라면, 근우는 첫 번째 카드를 가져갈지, 마지막 카드를 가져갈지 선택을 해야만한다.

 

① 카드가 1장 있을 때 

A

 

근우가 A을 가지고 간다.

근우 A점, 명우 0점

 

② 카드가 2장 있을 때

 

A B

근우는 고민없이 max(A, B) 값을 가지고 간다.

 

③ 카드가 3장 있을 때

A B C

근우가 A를 가져가면, 명우는 B,C 중에서 큰 값을 가지고 간다.

근우가 C를 가져가면 명우는 A,B 중에서 큰 값을 가지고 간다.

 

d[i][j] 는 i 번째 카드부터 j번째 카드까지 남아있을 때, 근우가 최적의 전략으로 얻을 수 있는 최대 점수를 의미한다.

 

1) 왼쪽에서 카드 선택

i 번째 카드를 선택하면, 근우는 cards[i]를 얻고, 명우는 [i+1, j] 구간에서 최적의 선택이 된다.

근우가 첫 번째 카드를 선택했을 때 최종 점수는,

cards[i] + (남은 카드에서 명우가 얻을 점수를 제외한 값)이 됩니다.

즉, dp[i][j] = cards[i] + (전체 점수 - dp[i+1][j])

 

2) 오른쪽에서 카드 선택

j 번째 카드를 선택하면, 근우는 cards[j]를 얻고, 명우는 [i, j-1] 구간에서 최적을 선택을 하게 된다.

즉, dp[i][j] = cards[i] + (전체 점수 - dp[i][j-1])

 

3) 최종 점화식

dp[i][j] = max(cards[i] + (전체 점수 - dp[i+1][j]), cards[j] + (전체 점수 - dp[i][j-1]))

 

=> 실제로 코드 쓸 때는, 내 점수를 현재 카드 점수 + min(근우가 왼쪽 카드 선택하고 남은 점수, 근우가 오른쪽 카드 선택하고 남은 점수) 로 계산함.

 

C++전체 코드

1. 위 로직대로, 전체 점수에서 명우가 더 큰 값을 가져간다는 로직
#include <iostream>
#include <vector>
#include <cstring> //memset

using namespace std;

int dp[1001][1001]; //dp테이블 정의
int sum[1001]; //카드 점수의 누적 합을 저장

//점수 계산을 위한 함수
int getMaxScore(vector<int>& cards, int left, int right) {
    //기저 조건 : 만약 left 가 right를 넘어서면, 유효하지 않은 범위이므로 0변환
    if (left > right) return 0;
    
    //이미 계산된 값이 있으면 그 값을 사용
    if (dp[left][right] != -1) return dp[left][right];
    
    //전체 카드의 총합 중에서, 상대방이 최선의 선택을 해서 얻을 수 있는 점수를 뺀 값을 계산
    int totalSum = sum[right + 1] - sum [left];
    int pickLeft = totalSum - getMaxScore(cards, left+1, right);
    int pickRight = totalSum - getMaxScore(cards, left, right-1);
    
    //두 경우 중 최대값을 DP 테이블에 저장
    return dp[left][right] = max(pickLeft, pickRight);
    
}

int main() {
    int T;
    cin >> T;
    
    while(T--) {
        //각 카드 점수 입력
        int N;
        cin >> N;
        vector<int> cards(N);
        for (int i=0; i<N; i++){
            cin >> cards[i];
        }
        
        //DP테이블을 -1로 초기화
        memset(dp, -1, sizeof(dp));
        
        //카드 점수의 누적 합 계산
        sum[0]=0;
        for (int i=0; i<N; i++){
            sum[i+1] = sum[i] + cards[i];
        }
        
        //0번부터 N-1번까지 카드가 있을 때 근우가 얻는 최대 점수 계산
        cout << getMaxScore(cards, 0, N-1) << endl;
    }
    return 0;
}
2. 명우가 둘 중에 더 작은 것을 가져간다는 로직으로 짠 코드
#include <iostream>
#include <vector>
#include <cstring> //memset을 사용하기 위해

using namespace std;

int dp[1001][1001]; //dp table 정의

//점수 계산을 위한 함수

int getMaxScore(vector<int>& cards, int left, int right) {
    
    //기저조건 : 만약 left가 right를 넘어서면, 유효하지 않은 범위이므로 0반환
    if (left > right ) return 0;
    
    //기저조건 : 만약 왼쪽=오른쪽이 같으면, 카드가 하나 남았다는 의미
    if (left == right) return cards[left];
    
    //이미 계산된 값이 있으면 그 값 사용
    if (dp[left][right] != -1) return dp[left][right];
    
    //왼쪽을 선택하는 것은 = 준우가 왼쪽 값을 얻고, 다른애가 왼쪽을 고른 것과 오른쪽을 고른 것 중 더 작은 값을 가지는 것
    int pickLeft = cards[left] + min(getMaxScore(cards, left+2, right), getMaxScore(cards, left+1, right-1));
    int pickRight = cards[right] + min(getMaxScore(cards, left+1, right-1), getMaxScore(cards, left, right-2));
    
    //두 경우 중 최대값을 DP 테이블에 저장
    return dp[left][right] = max(pickLeft, pickRight);
}

int main() {
    int T;
    cin >> T;
    
    while (T--){
        //각 카드 점수 입력
        int N;
        cin >> N;
        vector<int> cards(N);
        for (int i = 0; i<N; i++){
            cin >> cards[i];
        }
        
        //DP 테이블을 -1로 초기화
        memset(dp, -1, sizeof(dp));
        
        //0번부터 N-1번까지 카드가 있을 때 근우가 얻는 최대 점수 계산
        cout << getMaxScore(cards, 0, N-1) <<endl;
    }
    return 0;
}

 

 

프로님 설명 받아 적은 내용 ...

 

[DP테이블 정의하기]

dp[i][j]를 i번째 카드부터 j번째 카드까지 있을 때 근우가 얻을 수 있는 최대 점수로 정의한다.

 

-초기화

'i==j'인 경우, 즉 카드가 하나만 남아 있을 때, 근우는 남아있는 카드의 점수를 모두 가져갑니다. 따라서 dp[i][i]=카드의 점수[i]가 됩니다.

 

-점화식

근우가 왼쪽 카드i를 가져가면, 명우는 i+1부터 j까지 카드를 가져가게 됩니다.

근우가 오른쪽 카드 j를 가져가면, 명우는 i부터 j-1까지의 카드를 선택하게 됩니다.

 

왼쪽카드를 선택하면

a[i] + s(i,j) - d(i+1, j)

 

오른쪽카드

a[j] + s(i,j) - d(i, j+1)

 

이런 문제는 4선 DP로 쌓아올리거나, top-down or bottom-up으로 풀 수 있다.

점화식이 어려운, 냅색(배낭문제, KnapSack) 이라는 유형을 변형한 문제이다.

 

맨 처음 출력/입력 조건을 본다.

문제 내용상 흐름을 파악해봤을 때, N은 100, M은 1억이다. 

완전탐색을 가정했을 때, 활성화됐는지 아닌지 쭉 파악하는 시간복잡도는 on/off 여부를 모두 체크하면 (2^N)이다.

입력값은 1억까지인데, 2^N으로 감당할 수 있는 입력값은 20이므로, 이는 시간복잡도 내에 해결할 수 없다.

=> 따라서 다른 방법을 생각해보자. DP로 해야한다.

 

DP로 하려면 3가지가 필요하다.

1)DP 배열

2)점화식

3)초깃값

 

dp[i][j]에서 i와 j를 뭐라고 생각할지 판단해야한다.

처음에는 i번째에 확보할 수 있는 메모리 j에 드는 비용 cost를 dp[i][j]라고 세웠다. 그런데 이렇게 되면, j의 범위가 1억까지이므로 메모이제이션이 불가능하다.

 

따라서 d[i][j]를 i번째 앱까지, j의 cost로 확보할 수 있는 메모리로 정의한다.

 

m[i] 30 10 20 35 40 비용합계
c[i] 3 0 3 5 4 15

 

실제로 문제 풀 때도 손으로 그려서 파악한다.

d[i][j] 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
d[1][j] 0 0 0 30 30 30 30 30 30 30 30 30 30 30 30 30
d[2][j] 10 10 10 40 40 40 40 40 40 40 40 40 40 40 40 40
d[3][j] 10 10 10 40 40 40 60 60 60 60 60 60 60 60 60 60
d[4][j] 10 10 10 40 40 45 60 60 75 75 75 95 95 95 95 95
d[5][j] 10 10 10 40 40 45 60 80 80 85 100 100 115 115 115 135

d[j-1][j-cost[i]+m[i]와 d[i-1][j] 비교

 

와! 점화식이 왜 d[i-1][j-cost[i]] + memory[i] 이런식인가 했더니, 해당하는 비용값을 하나 더하면 당연히 필요로 하는 j값에는 cost[i]만큼 빠진 것이 이제 필요로 되고, 대신 메모리를 얻으니까 이런거다.

 

강사님께서 주신 자바 코드.

package b7579;

import java.util.Scanner;

/*
 * 백준 7579 앱
 * updated 2024-02-17
 */
public class Main2 {
    static int N, M;    // 앱개수, 필요한 메모리
    static int[] m; // 앱 메모리
    static int[] c; // 비용
    static int sum;
    static int[][] d;       // 비용 i번째 앱까지 j 의 비용을 들였을 때 얻을 수 있는 최대 메모리양

    public static void main(String[] args) {
        Scanner sc = new Scanner(System.in);
        N = sc.nextInt();
        M = sc.nextInt();
        m = new int[N+1];
        c = new int[N+1];

        for (int i=1; i<=N; i++) {
            m[i] = sc.nextInt();
        }
        for (int i=1; i<=N; i++) {
            c[i] = sc.nextInt();
            sum += c[i];
        }
        d = new int[N+1][sum+1];
        for (int i=1; i<=N; i++) {
            for (int j=0; j<=sum; j++) {
                if (j - c[i] >=0) d[i][j] = Math.max(d[i][j], d[i-1][j-c[i]] + m[i]);
                d[i][j] = Math.max(d[i][j], d[i-1][j]);
            }
        }
        int cost = 0;
        for (int i=1; i<=N; i++)
            for (cost=0; cost<sum && d[i][cost]<M; cost++);
        System.out.println(cost);       
    }

}

 

그리고 C++코드로 변환한 것

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

using namespace std;

int main() {
	int N, M; // 앱 개수, 필요한 메모리;
    cin >> N >> M;
    
    vector<int> m(N+1); // 앱 메모리
    vector<int> c(N+1); //비용
    int sum = 0;
    
    //입력 받는 것 - python과 달리, 미리 공간을 파놓고 입력을 받는다.
    for (int i=1; i<=N; i++){
    	cin >> m[i];
    }
    
    for (int i=1; i<=N; i++){
    	cin >> c[i];
        sum += c[i];
    }
    
    //비용 1번째 앱까지 j의 비용을 들였을 때 얻을 수 있는 최대 메모리양
    vector<vector<int>> d(N+1, vector<int>(sum+1,0));
    
    for (int i=1; i<=N; i++){
    	for (int j=0; j <=sum; j++){
        	if (j-c[i] >=0) {
            	d[i][j] = max(d[i][j], d[i-1][j-c[i]] + m[i]);
            }
            d[i][j] = max(d[i][j], d[i-1][j]);
        }
    }
    
    int cost = sum;
    for (int i=0; i<=sum; i++){
    	if (d[N][i] >= M) {
        	cost = i;
            break;
        }
    }
    
    cout << cost << endl;
    
    return 0;
}

 

그래프 알고리즘에서 '최소 비용', '최단 거리'를 구해야 하는 경우,

사용할 수 있는 대표적인 알고리즘으로는

'다익스트라 알고리즘', '벨만-포드 알고리즘', '플로이드 워샬 알고리즘'이 있다.

 

이번 글에서는 '다익스트라 알고리즘'에 대해서 알아보겠다.

 

1. 언제 쓰이나요?

다익스트라 알고리즘은 '최소 비용' 중에서도,

주어진 두 노드(시작 노드, 도착 노드)사이의 최소 비용인 '경로'를 찾을 때 유용하게 사용된다.

가중치의 값이 모두 양수일 때(즉, 가중치에 음수가 없을 때) 사용된다.

 

하다보면 '당장 눈 앞에 드는 비용이 더 적다고, 해당 정점을 다시는 안쳐다봐도 될까?' 이런 질문이 생길 수 있다.

하지만 가중치 값이 양수이기만 하면 이는 만족하므로 걱정하지 않아도 된다.

 

2. 동작 원리

2-1.O(N^2) 반복문을 통한 시간 복잡도

지금부터 '비용' 이라는 말을 많이 사용하게 될 것이다. 이 '비용' 값은 1차원 배열 Dist[] 배열에 저장되어 있다고 생각하자.

 

0.초기 Dist[] 배열 모든 값은, 무한대(INF)로 초기화 되어있다.

1.시작 노드와 직접적으로 연결된 모든 정점들의 거리를 비교해서 업데이트 시켜주고, 시작 노드를 방문한 노드로 체크한다.

2.방문한 정점들과 연결되어 있는 정점들 중, 비용이 가장 적게 드는 정점을 선택하고, 해당 정점을 방문한 정점으로 선택해준다.

3. 2번 과정에 의해서 갱신될 수 있는 정점들의 거리를 갱신시켜준다.

4. 2-3번 과정을 반복한다.

 

1. 시작 노드와 직접적으로 연결된 모든 정점들의 거리를 비교해서 업데이트 시켜주고, 시작 노드를 방문한 노드로 체크한다.
//시작 노드-연결된 i노드의 가격을 Dist 배열에 업데이트
for (int i=1; i <= V; i++) Dist[i] = MAP[Start][i];
Dist[Start]=0; //시작 노드에서의 비용은 0으로 바뀐다.
Select[Start]=true;

 

2.방문한 정점들과 연결되어 있는 정점들 중, 비용이 가장 적게 드는 정점을 선택하고, 해당 정점을 방문한 정점으로 선택해준다.
int Find_Shortest_Node()
{
	int Min_Dist, Min_Idx;
    Min_Dist = INF;
    Min_Idx = -1;
    
    for (int i=1; i<=V; i++)
    {
    	//선택된 건 넘기자~
    	if (Select[i] == true) continue;
        //현재 제일 작은 값보다, Dist[i] 비용이 더 작으면, Dist[i] 비용을 최솟값으로 선택
        if (Dist[i] < Min_Dist)
        {
        	Min_Dist = Dist[i];
            Min_Idx = i;
        }
    }
    return Min_Idx;
}

 

3. 2번 과정에 의해서 갱신될 수 있는 정점들의 거리를 갱신시켜준다.
void Update_Dist(int NewNode)
{
	for (int i=1; i<=V; i++){
    	//선택됐던 것은 넘어가고~
    	if (Select[i] == true) continue;
        //아닌건
        if (Dist[i] > Dist[NewNode] + MAP[NewNode][i])
        {
        	Dist[i] = Dist[NewNode] + MAP[NewNode][i];
        }
    }
}

 

4.2-3번 과정을 반복한다.

몇 번 반복해야하는지 판단해야한다.

1번 정점에서 -> 3번 정점 선택하고 업데이트 1번
2번 정점 선택 -> 업데이트
...
6번 정점 선택 -> 종료!(업데이트 없음)

 

즉 업데이트는 N(정점의 수)-1번 진행한다.

 

[전체 코드]

//다음 노드 선택
int Find_Shortest_Node()
{
	int Min_Dist, Min_Idx;
    Min_Dist = INF;
    Min_Idx = -1;
    
    for (int i=1; i <=V; i++) //V는 정점의 수
    {
    	if (Select[i] == true) continue;
        if (Dist[i] < Min_Dist)
        {
        	Min_Dist = Dist[i];
            Min_Idx = i;
        }
    }
    return Min_Idx;
}

//선택한 노드에 대해 업데이트 해주기
void Update_Dist(int NewNode)
{
	for (int i=1; i <=V; i++)
    {
    	if (Select[i] == true) continue;
        if (Dist[i] > Dist[NewNode] + MAP[NewNode][i])
        {
        	Dist[i] = Dist[NewNode] + MAP[NewNode][i];
        }
    }
}

void Dijkstra()
{
	//시작노드 초기화
	for ( int i=1; i <= V; i++) Dist[i] = MAP[Start][i];
    Dist[Start] = 0;
    Select[Start] = true;
   	
    //반복해줌
    for (int i=0; i<V-1; i++)
    {
    	int NewNode = Find_Shortest_Node();
        
        Select[NewNode] = true;
        Update_Dist(NewNode);
    }
}

 

2-2. 우선순위 큐를 이용한 방법 O(E*logN) E=간선 수, N=노드 수

인접한 간선들을 모두 Queue에 집어 넣은 후, 최소힙을 구하는 연산을 통ㅎ서, 최소비용이 드는 정점부터 처리하는 방식이다.

시간복잡도가 O(E*logN)인 이유는

-모든 정점들에 대해서 최소힙으로 추출하는데 걸리는 시간복잡도가 O(logN)이다.

-각 노드마다 인접한 모든 간선들을 확인해봐야하므로 시간복잡도가 O(E)이다.

 

void Dijkstra_Using_Heap()
{
	priority_queue<pair<int, int>> PQ; //값이 클 수록 더 높은 우선순위를 갖게된다. 따라서 아래에서 -를 붙여서 값의 크기를 반전한다.
    PQ.push(make_pair(0, Start));
    Dist[Start] = 0;
    
    while (PQ.empty() ==0)
    {
    	int Cost = -PQ.top().first; //간선 길이에 -붙이는 이유는 최소힙 구현을 위해서
        int Cur = PQ.top().second;
        PQ.pop();
        
        for ( int i=0; i<Vertex[Cur].size(); i++)
        {
        	int Next = Vertex[Cur][i].first;
            int nCost = Vertex[Cur][i].second;
            
            if (Dist[Next] > Cost + nCost)
            {
            	Dist[Next] = Cost + nCost;
                PQ.push(make_pair(-Dist[Next], Next)); //간선 길이에 -붙이는 이유는 최소힙 구현을 위해서
            }
        }
    }
    
    for ( int i=1; i<=V;i++)
    {
    	if (Dist[i] == INF) cout << "INF" << endl;
        else cout << Dist[i] << endl;
    }
}

 

아래와 같이 선언하면, 값이 작을 수록 우선순위를 갖는 최소힙으로 사용이 가능하다.

priority_queue<int, vector<int>, greater<int>> PQ;

 

[출처]

개인적으로 공부를 위하여, 아래 블로그 내용을 상세하게 참고한 글이다.

https://yabmoons.tistory.com/364

+ Recent posts