Time complexity ※1초에 1억 번을 대략적인 기준으로 삼는다. $1 \le nums.size \le 100'000$이므로, $O(n log n)$ 이하의 복잡도부터 고려해 볼 수 있다.
해결 과정
먼저 만만한 완전탐색이 가능한지 확인, = 가능하다, 근데 시간이 $O(n^2)$이라 다른 방법 찾아야 함
그럼 DP로 변환 가능? = 생각 해봐야함
dp[i] = i번째 까지 최대 합의 값? 이라고 정의하면, 최대 값 부분배열의 마지막이 어딘지 같은 부가 데이터를 또 관리해야 한다.
= 다음 값 계산하기가 어러워 다른 정의가 필요하다.
dp[i] = i번째 원소에서 끝나는 부분 배열의 최대 합의 값? 이라고 하면 한번의 순회로 끝낼 수 있을 것 같았다.
해당 상태는 i번째 원소가 이전 부분배열을 잇거나, 부분배열의 시작이 되던지 두 상태중 하나로 표현가능하다.
즉, 점화식은 dp[i] = max(dp[i-1] + input[i], input[i]) 와 같다.
진행하다 보면 최대 합의 값이 낮아질 수 있는데, 역대 최대 합의 값 중 가장 큰 값을 저장하면 되기 때문에 별 문제없을 거라고 생각했다.
코드 구현
직전 상태만 참조하면 되기 때문에 굳이 테이블을 만들지 않고 진행했다.
int maxSubArray(vector<int>& nums)
{
// Initialize
int dp = nums[0];
int result = dp;
// Do dp
for (int i = 1; i < nums.size();++i)
{
dp = std::max(dp + nums[i], nums[i]);
result = std::max(result, dp);
}
// Return
return result;
}
되돌아보기
옛날에는 점화식 상태를 ~번째까지의 최대/최소 값으로만 생각했었는데, 다행히 이번에 상태를 잘 정의하여 수월하게 풀었던 것 같다.
점화식 이전에 어떤 것을 상태로 저장해야 다음 상태를 계산할 수 있는지 생각하는 것이 중요하다고 한 번 더 생각하게 되는 문제였다.
Constraints i번째 집을 훔치면, 인접한 i-1, i+1의 집은 훔칠 수 없다. 첫번째 집과 마지막집이 인접하다고 가정한다.
Time complexity ※1초에 1억 번을 대략적인 기준으로 삼는다. 입력값의 개수 범위가 $1 \le n \le 100$이기 때문에, $O(n^2)$부터 고려해볼 수 있다.
해결 과정
일일히 다 해보면 되지만, 이전 상태 값에서 업데이트하는 것도 괜찮아 보임. dp[i] = i번째 집까지 순회했을때 훔칠 수 있는 최대 금액
훔치거나 훔치지 않거나의 상태가 있으므로 최종적으로 아래처럼 가능 dp[i][0] = i번째 집까지 순회하고, i번째 집을 털었을 때의 최대 금액 dp[i][1] = i번째 집까지 순회하고, i번째 집을 털지 않았을 때의 최대 금액
i번째 집을 털고 난 후 의 최대 금액은 i-1번째 집을 털지 않은 최대 금액을 더해주면 되고, i번째 집을 털지 않은 후 의 최대 금액은 i-1번째 집을 털거나, 털지 않은 금액중 최대 값이다.
그래서 점화식은 아래와 같다. dp[i][0] = dp[i-1][1] + input[i] dp[i][1] = max(dp[i-1][0], dp[i-1][1])
근데 중요한 건 각 값에 첫 번째 집을 털었는지에 대해 알 수 없다는 거다; 그래서 마지막 집을 털수 있는지의 여부 또한 알기 힘들다.
그래서 첫 번째 집을 털었을때와 털지 않았을 경우로 DP를 두 번 돌릴 예정이다. 시간은 DP한 번당 O(n)정도니까 충분한 시간내에 통과 가능할 것이라고 생각한다.
구현
int rob(vector<int>& nums)
{
if (nums.size() == 1) return nums[0];
int retA, retB;
// 첫 번째 집을 털었을 때
{
// Initialize
int dp[101][2] = {0};
dp[0][0] = dp[1][1] = nums[0]; // 두 번째 집은 없다고 생각해도 된다.
// fill in dp table
for (int i = 2; i < nums.size() - 1; ++i) // 마지막 집은 찾지 않아도 된다.
{
dp[i][0] = dp[i-1][1] + nums[i];
dp[i][1] = std::max(dp[i-1][0], dp[i-1][1]);
}
// Set result candidate
retA = std::max(dp[nums.size() - 2][0], dp[nums.size() - 2][1]);
}
// 첫 번째 집을 털지 않았을 때
{
// Initialize
int dp[101][2] = {0};
dp[1][0] = nums[1]; // 첫번째 집은 없다고 생각해도 된다.
// fill in dp table
for (int i = 2; i < nums.size(); ++i) // 마지막 집까지 찾아야 한다.
{
dp[i][0] = dp[i-1][1] + nums[i];
dp[i][1] = std::max(dp[i-1][0], dp[i-1][1]);
}
// Set result candidate
retB = std::max(dp[nums.size() - 1][0], dp[nums.size() - 1][1]);
}
// Result
return std::max(retA, retB);
}
되돌아보기
통과는 했지만 아쉬운 점이 있다!
문제 해석
첫 번째 집을 터는 경우, 털지 않는 경우 이렇게 두 경우로 나누어 생각한 것이 방향은 맞지만, 표현을 굳이굳이 복잡하게 한 느낌이라 이를 코드로 표현하는 과정에서 오히려 복잡해졌었던 것 같다.
이 경우에는 첫 번 째 문제로, 초기 값의 논리가 어색한 문제가 있다.
첫 번째 집을 턴다고 가정할때, dp[1][0]의 경우는 두 번째 집을 턴 최대 금액? 이란 모순이 생긴다.
두 번째 문제로, 반복문 범위의 세부사항이다.
그래서 DP테이블을 채우는 알고리즘이 똑같아도 굳이 두 번을 작성한 셈이다.
이러한 문제는 표현을 간단하게 하면 해결되는데, 바로 구간을 두 가지로 생각하는 방법이다.
마지막 집을 제외하고 [0, n-2] 구간을 털기
첫 번째 집을 제외하고 [1, n-1] 구간을 털기
이렇게 하면 초기 값 논리도 어색하지 않고, 하나의 알고리즘 코드에 구간만 다르게 넣어 풀이를 수행할 수 있다.
개선 코드
아쉬운 부분을 개선하여 다시 풀어보았다.
int Solve(const vector<int>& nums, int s, int e)
{
// Initialize
int dp[101][2] = {0};
dp[s][0] = nums[s];
// fill in dp table
for (int i = s + 1; i < e; ++i)
{
dp[i][0] = dp[i-1][1] + nums[i];
dp[i][1] = std::max(dp[i-1][0], dp[i-1][1]);
}
// Return result
return std::max(dp[e - 1][0], dp[e - 1][1]);
}
int rob(vector<int>& nums)
{
if (nums.size() == 1) return nums[0];
// Do Solve
int candidate1 = Solve(nums, 0, nums.size() - 1);
int candidate2 = Solve(nums, 1, nums.size());
// Result
return std::max(candidate1, candidate2);
}
추가로 DP테이블에서 바로 직전상태만 참고하면 되기 때문에 배열을 사용하지않고도 풀이해봤다.
int Solve(const vector<int>& nums, int s, int e)
{
// Initialize
int robMax = nums[s];
int notRobMax = 0;
// Do DP
for (int i = s + 1; i < e; ++i)
{
int rob = notRobMax + nums[i];
int notRob = std::max(robMax, notRobMax);
robMax = rob;
notRobMax = notRob;
}
// Return
return std::max(robMax, notRobMax);
}
int rob(vector<int>& nums)
{
// Handling exceptions if number size is 1
if (nums.size() == 1) return nums[0];
int candidate1 = Solve(nums, 0, nums.size() - 1);
int candidate2 = Solve(nums, 1, nums.size());
// Result
return std::max(candidate1, candidate2);
}
n차 단위 행렬 $I_n$에 기본행연산을 한 번만 적용하여 얻는 행렬 $E$를 기본행렬 이라고 한다.
기본행 연산이 세 가지 있으니, 기본행렬또한 세 종류가 있다. 각 세가지는 아래와 같이 표현한다.
$E_{i,j}$ : 두 행을 교환하여 얻은 기본행렬
$E_i(c)$ : i번째 행에 0이 아닌 상수 $c$를 곱하여 얻은 기본행렬
$E_{i,j}(c)$ : i번째 행에 j번째 행의 $c$배를 더하여 얻은 기본행렬
행렬 $A$에 기본행연산 $R$을 적용한 결과를 $R(A)$라고 한다.
특징
먼저 행렬 $A$를 $n \times p$ 행렬이라고 가정하고 설명한다.
$R(A) = EA$ $R(A)$ : 행렬 $A$에 기본행연산 $R$을 적용한 결과 $E$ : $n$차 단위행렬 $I_n$에 같은 기본행연산 $R$을 적용하여 얻은 행렬
여기서 중요한 점은기본행렬 $E$를 왼쪽에 두고 곱한다는 것이다. $E$를 행렬 $A$의 왼쪽에 곱하면 $A$의 행이 변하고, 반대로 오른쪽에 곱하면 일반적으로 행이 아니라 열이 변한다.
따라서 행 연산은 왼쪽에 둔 곱셈으로 표현하고, 열 연산은 오른쪽에 둔 곱셈으로 표현할 수 있다.
A와 B가 행상등하다면 $A = E_1E_2 \cdots E_kB$ 가 성립한다. 두 행렬 $A$와 $B$가 행상등하다는 것은, 기본행연산을 유한 번 적용하여 한 행렬을 다른 행렬로 바꿀 수 있다는 뜻이다. 따라서 $A$와 $B$가 행상등하다면, 유한개의 기본행렬 $E_1, E_2, \cdots, E_k$가 존재하여 $A = E_1E_2 \cdots E_kB$ 같이 나타낼 수 있다. 즉, 행렬 $B$에 여러 기본행연산을 적용하면 $A$를 만들 수 있는 것이다.
반대로 $A$에서 $B$로 가는 방향으로 기본행연산을 잡으면 다음과 같이 쓸 수도 있다. $B = F_1F_2 \cdots F_mA$
성질
기본행렬은 모두 정칙행렬이며, 기본행렬의 역행렬도 같은 종류의 기본행렬이다. 예를 들어, 두 행을 교환하는 기본행렬은 같은 두 행을 다시 교환하면 원래대로 돌아간다. $E_{i,j}^{-1} = E_{i,j}$
A가 n차 정칙행렬이라면 A는 영행이나 영렬이 없다. 왜냐하면 영행이나 영렬이 존재한다면, 결과또한 영행이나 영령이 존재하게 되어, 단위행렬을 만들 수 없게된다. $AA^{-1}=\begin{pmatrix} a & b \\ 0 & 0 \end{pmatrix} \begin{pmatrix} c & d \\ e & f \end{pmatrix} = \begin{pmatrix} ? & ? \\ 0 & 0 \end{pmatrix} \not= I_2$
A가 n차 정방행렬일때 다음은 서로 동치다.
A는 정칙행렬이다.
A와 I_n은 행상등하다.
A는 유한개의 n차 기본행렬의 곱이다.
역행렬 구하는 방법
정방행렬 $A$의 역행렬을 구할 때는 $A$ 옆에 단위행렬 $I_n$을 붙인 확대행렬을 만들어 시작한다.
$(A \mid I_n)= \left( \begin{array}{cc|cc} a & b & 1 & 0 \\ c & d & 0 & 1 \end{array} \right)$
그다음 기본행연산을 이용하여, 왼쪽의 $A$를 단위행렬 $I_n$으로 바꾼다.
$(A \mid I_n) \rightarrow (I_n \mid C)$
이때 오른쪽에 남은 행렬 $C$가 바로 $A$의 역행렬이 된다.
$C = A^{-1}$
즉, 정리해보면 아래와 같다.
$(A \mid I_n) \rightarrow (I_n \mid A^{-1})$
해당 방법이 가능한 이유
우선 위에서 말했듯이, 행렬에 기본행연산을 적용하는 것은 기본행렬을 왼쪽에서 곱하는 것과 같다 행렬 A를 I_n으로 바꾸기 위해 k번 기본행연산을 수행한 것은 아래와 같다.
$E_k\dotsb E_2E_1A=I_n$
여기서 $E_k\dotsb E_2E_1$부분을 결합하여 하나의 행렬 $E$라고 정의한다면 아래와 같다.
$EA = I_n$
여기서 역행렬의 정의따라 어떤 행렬 A의 왼쪽에 곱하여 단위행렬 I_n을 만드는 행렬은 역행렬이므로,
$E=A^{-1}$임이 자명하다!
확대행렬에 적용
처음에 만들었던 확대행렬에서 A를 단위행렬로 만들기 위해 연산 행렬 E를 왼쪽에 곱하면 아래와 같은 식이 도출된다.
$AA^{-1} = I_n\\ (AA^{-1})^T = {I_n}^T\\ (A^{-1})^TA^T = I_n$또한, $A^{-1}A = I_n\\ (A^{-1}A)^T = {I_n}^T\\ A^T(A^{-1})^T = I_n$ 즉, $(A^{-1})^T$는 $A^T$의 양쪽 역행렬이고, $(A^T)^{-1} = (A^{-1})^T$ 이라고 할 수 있다.
2차 정방행렬의 역행렬 구하는 공식
아래와 같은 2차 정방행렬 A와, 그 역행렬이 있다고 가정한다.
$A =\begin{pmatrix}a & b \\c & d\end{pmatrix},\ A^{-1} = \begin{pmatrix}x & y \\z & w\end{pmatrix}$
$\bigg(x = \cfrac{d}{ad-bc},\ y = \cfrac{-b}{ad-bc},\ z = \cfrac{-c}{ad-bc}.\ w = \cfrac{a}{ad-bc} \bigg)$
여기서 $ad-bc$를 $D$라는 알파벳으로 두어 인수분해하면 최종적으로 다음과 같아진다.
$\bigg(x = \cfrac{d}{D},\ y = \cfrac{-b}{D},\ z = \cfrac{-c}{D}.\ w = \cfrac{a}{D} \bigg)$
그럼 구한 값을 역행렬에 대입하면 역행렬을 구할 수 있게 된다.
$A^{-1} = \begin{pmatrix} x & y \\ z & w \end{pmatrix} = \begin{pmatrix} \frac{d}{D} & \frac{-b}{D} \\ \frac{-c}{D} & \frac{a}{D} \end{pmatrix}=\cfrac{1}{D} \begin{pmatrix} d & -b \\ -c & a \end{pmatrix}$
여기서 당연하게도 $D$ 는 0이 되면 안되는데, 왜냐하면 0으로 나눌수가 없기 때문이다. 그래서 $D$가 0이라면 역행렬이 존재하지 않는다라고 할 수 있다.
이렇게 역행렬의 존재를 판별할수 있는 $D$를 행렬식(Determinant)라고 한다.
최종적으로 정리하면, 행렬의 역행렬은 다음과 같다.
$\frac{1}{ad-bc} \begin{pmatrix} d & -b \\ -c & a \end{pmatrix} (단,ad-bc \ne 0)$
지수가 음수인 행렬의 거듭제곱
이전에 행렬의 거듭제곱에 음이 아닌 정수 r과s에 대해 성립하는 특징이 있다고 했었다. 그때는 역행렬을 배우지 않아 불가능했지만 역행렬을 배운 지금은 가능하다.
수학자들이 지수가 음의 정수라면 역행렬을 곱해주는것으로 설정했기 때문이다. 예를들어 아래와 같이 사용할 수 있다.
포트폴리오 프로젝트로 이전부터 만들어 보고 싶었던 장르인 로그라이트를 개발해 보기로 했다.
이전 포트폴리오를 만들 때는 기능 구현을 우선시하다 보니, 결국 나중에는 각 시스템끼리 직접 참조가 많아지고 상태 전환 같은 로직이 여러 Manager와 연결되면서 복잡해지고 각 클래스의 책임이 점점 흐려지는 경험이 있었다.
그래서 이번에는 개발에 들어가기 전에, 게임 흐름, 오브젝트 생성, 데이터 같은 런타임 구성요소에 대한 시스템을 어떤 기준으로 어떻게 나누어 관리할 것인지 고민했다.
직접 구조를 만들어보는 경험은 이전 프로젝트에서 해보았기 때문에, 다른 프레임 워크들이 게임을 개발하면서 생기는 문제를 어떤 기준으로 나누고, 어떻게 관리를 했는지 참고하여, 내 프로젝트에 적용할 수 있는 기준을 찾고자 여러 프레임워크를 찾아 검토해 보았다.
Framework 후보
QFramework
QFramework는 Unity와 Godot을 대상으로 하는 시스템 설계 프레임워크이며, 공식 설명에서는 SOLID, DDD, 이벤트 기반, 데이터 기반, 계층 구조, MVC, CQRS, 모듈화, 확장 가능한 구조를 지원한다고 소개되어 있다.
BDFramework
BDFramework는 Unity 게임 개발을 위한 워크플로우 프레임워크에 가깝다. 개발부터 배포까지의 워크플로우를 제공하며, hot-fixing, asset management, automated building, editor enhancement에 초점을 둔다고 설명되어 있다.
ET Framework
ET Framework의 특징은 Unity3D 클라이언트와 C# 서버를 함께 다루는 프레임워크로 볼 수 있다. 공식 저장소에서도 Unity3D Client And C# Server Framework라고 소개되어 있으며, 최근 ET10 설명에서는 분산 MMO 아키텍처, Actor Runtime, 클라우드 기반 서버 인프라, Hot Reload, 자동화된 게임플레이 테스트 등을 제공한다고 설명되어 있다...
Unity Game Framework
UGF는 Unity 기반 게임 프레임워크로, 공식 설명에서는 게임 개발 과정에서 자주 사용되는 모듈을 캡슐화하여 개발 과정을 표준화하고, 개발 속도와 품질을 높이는 것을 목표로 한다고 설명되어 있다. 또한 Config, Data Node, Data Table, Entity, Event, FSM, Object Pool, Procedure, Resource, Scene, UI 등 여러 내장 모듈을 제공한다고 되어있다.
Framework 선택
이번 프로젝트의 목적은 빠르게 기능만 붙이는 것이 아니라, 이전 프로젝트에서 겪었던 구조 문제를 줄이는 것이다.
각 프레임워크마다 참고할 것이 많고 좋았지만 지금 내게 딱 맞는 프레임워크는 UGF(Unity Game Framework)라고 생각했다.
QFramework는 코드 구조와 책임 분리 같은 아키텍처 패턴을 참고하기 좋은 프레임워크라고 느꼈다. Model, System, Command, Event 같은 단위로 기능을 나누는 방식은 각 클래스의 책임을 정리하는 데 도움이 될 수 있어 보였다.
다만 이번 프로젝트에서 내가 먼저 고민하고 있던 것은 코드 아키텍처 자체보다는, "게임 흐름은 어떻게 나눌지, 적 사망이나 레벨업 같은 이벤트는 어떻게 전달할지" 같은 런타임 요소를 어떻게 분리하고 관리할 것인가에 가까웠기 때문에 우선순위를 낮게 두었다.
ET Framework는 서버를 함께 다룰 수 있다는 점이 매력적이었는데, 서버와 클라이언트 구조를 함께 고려해야 하는 온라인 게임이나 MMO 프로젝트라면 네트워크 통신, 서버 로직 분리, 분산 구조 등을 학습하기에 좋은 후보라고 생각했다. 하지만 이번 프로젝트는 싱글 플레이 로그라이트를 먼저 구현하는 것이 목표이기 때문에, 서버 구조와 분산 아키텍처까지 포함하는 것은 다소 무겁다고 생각했다.
그래서 남은 두 프레임워크 중에 고민을 했었는데,
BDFramework의 경우, 실제 프로젝트에서 필요한 기능들이 다양하게 포함되어 있었기 때문에 잘 익힌다면 게임 개발에서의 여러 방면으로 도움이 될 것 같았다.
다만 현재 시작하려는 프로젝트는 개발부터 배포까지의 전체 워크플로우를 잡는 단계라기보다는 게임의 핵심 구조를 잡는 게 우선이었기 때문에 목표에 비해 범위가 많이 넓다고 생각했다;
BDFramework가 게임의 제작부터 배포까지의 파이프라인(빌드, 에셋 관리 등)에 집중한다면, UGF는 제가 당장 해결하고자 하는 인게임 런타임 시스템의 구조화에 집중하고 있어 현재 목표에 더 적합했다고 생각했다.
게임 흐름은 Procedure, 런타임 오브젝트는 Entity, 시스템 간 통신은 Event, 밸런싱 데이터는 Data Table, 화면 전환은 UI라는 모듈로 나누어볼 수 있다고 되어 있는데, 이런 UGF의 구조는이전 프로젝트에서 경험했던 문제에 대해 내가 어떤 기준으로 시스템을 나누어야 할지 참고할 수 있는 최소한의 기준처럼 보여 현재 문제에서 가장 알맞은 프레임워크라고 판단했다.
설치
프로젝트의 경로에서 UGF을 clone 해준다.
cd /d E:\Project_Unity\Project_RogueLite\Packages
git clone https://github.com/EllanJiang/UnityGameFramework.git com.jiangyin.gameframework
그 후 UGF의 폴더의 package.json에서 version을 2021.05.31에서 2021.5.31로 변경해 준다
이는 UnityGameFramework 원본 package.json의 version 값이 SemVer 규칙을 벗어나있기 때문에 고쳐주기 위함이다.
오류 수정
UGF 코드가 오래된 Unity API를 사용해서 호환성 문제가 생겼다.
아래와 같은 수정을 거쳐 해결한다.
// File
// MyUnityProject\Packages\com.jiangyin.gameframework\Scripts\Editor\ResourceBuilder\ResourceBuilderController.cs
// 수정 전
BuildAssetBundleOptions buildOptions = BuildAssetBundleOptions.DeterministicAssetBundle;
// 수정 후
BuildAssetBundleOptions buildOptions = BuildAssetBundleOptions.None;
// File
// MyUnityProject\Packages\com.jiangyin.gameframework\Scripts\Editor\Misc\ScriptingDefineSymbols.cs
// 수정 전_1
public static string[] GetScriptingDefineSymbols(BuildTargetGroup buildTargetGroup)
{
return PlayerSettings.GetScriptingDefineSymbolsForGroup(buildTargetGroup).Split(';');
}
// 수정 후_1
public static string[] GetScriptingDefineSymbols(BuildTargetGroup buildTargetGroup)
{
NamedBuildTarget namedBuildTarget = NamedBuildTarget.FromBuildTargetGroup(buildTargetGroup);
string defines = PlayerSettings.GetScriptingDefineSymbols(namedBuildTarget);
return string.IsNullOrEmpty(defines)
? new string[0]
: defines.Split(';');
}
// 수정 전_2
public static void SetScriptingDefineSymbols(BuildTargetGroup buildTargetGroup, string[] scriptingDefineSymbols)
{
PlayerSettings.SetScriptingDefineSymbolsForGroup(buildTargetGroup, string.Join(";", scriptingDefineSymbols));
}
// 수정 후_2
public static void SetScriptingDefineSymbols(BuildTargetGroup buildTargetGroup, string[] scriptingDefineSymbols)
{
NamedBuildTarget namedBuildTarget = NamedBuildTarget.FromBuildTargetGroup(buildTargetGroup);
PlayerSettings.SetScriptingDefineSymbols(namedBuildTarget, string.Join(";", scriptingDefineSymbols));
}
적용 완료
UGF 프레임워크 오브젝트
UGF에 prefab이 있는데 해당 prefab을 Scene에 드래그해서 놓으면 사용할 준비가 완료된다.
이 집합 안의 행렬들은 서로 더할 수 있고, 스칼라배할 수 있다, 그리고 그 결과 또한 여전히 $2 \times 2$ 행렬이다. 그래서 행렬도 벡터처럼 다음과 같은 계산이 가능하다.
$c1A1+c2A2+c3A3$
이런 식을 행렬들의 선형결합(linear combination)이라고 한다.
성질
교환 법칙 불성립 행렬곱은 일반적으로 교환법칙이 성립하지 않지만, 특수하게 가능한 경우가 한 가지 있다. 우선 교환법칙을 적용했을 때의 경우가 네 가지를 알아본다. 행렬$m\times p$행렬인 A와 $p\times n$행렬인 B의 곱인 AB에서 교환법칙을 적용했다고 가정한다.
BA가 정의되지 않는 경우(n ≠ m인 경우) 행렬곱 자체를 수행할 수 없다.
BA는 정의되지만, AB의 결과와 크기가 다른 경우 행렬곱은 가능하나 기존 AB의 크기가 다르기에 상등하지 않다.