스터디를 진행하며 실시한 모의 면접을 정리한 내용입니다.
겹치는 내용을 고려하지 않고 면접을 실시하기 때문에 일자 별로 겹치는 내용이 많을 수 있습니다.

 

1. 가비지 컬렉션이란?

더보기

참조되지 않는 메모리를 자동으로 해제해주는 시스템입니다. C++에선 지원하지 않는 시스템이지만, 언리얼엔진에선 해당 기능을 제공해주고 있습니다. 메모리를 트리구조로 연결한 뒤, 일정 시간마다 트리를 순회하여 참조가 끊긴 메모리를 해제하는 방식으로 진행됩니다.

2. 가비지 컬렉션의 단점은? 

더보기

프로그래머가 직접 메모리를 해제하는 경우 메모리 해제 시점을 정확히 예측할 수 있지만, 가비지 컬렉터를 사용하는 경우 그 시점을 예측하기 힘들다는 단점이 있습니다. 또한, 가비지 컬렉션을 실행하기 위한 추가적인 메모리가 필요하며 메모리 컬렉션으로 인한 성능 저하도 발생할 수 있습니다. 또한, 가비지 컬렉션의 구현 방법에 따라 가비지 컬렉션이 실행되는 동안 가비지 컬렉션을 담당하는 스레드를 제외한 모든 스레드의 작업을 멈추는 과정을 거치기도 합니다. 이로 인해, 가비지 컬렉션이 실행되는 동안 성능이 급격히 저하되는 문제를 겪을 수도 있습니다.

3. 힙과 우선순위 큐의 차이는?

더보기

힙은 이진 트리의 형식을 활용해 빠르게 최대값과 최소값을 찾는 알고리즘입니다. 반면, 우선순위 큐는 우선순위가 가장 높은 대상이 가장 앞에 위치하도록 구현된 큐입니다. 우선순위 큐를 구현하는 여러 방법 중 힙을 사용하는 것이 가장 대표적이지만 힙이 아닌 다른 방식으로도 우선순위 큐를 구현할 수 있습니다.

4. 클래스, 객체, 인스턴스의 차이는?

더보기

구현하고자 하는 대상을 객체라고 하며 코드를 통해 객체의 속성을 구체화한 것을 클래스라고 합니다. 코드로 작성된 클래스가 생성되어 메모리에 적재된 것을 인스턴스라고 합니다. 

5. push_back과 emplace_back의 차이는?

더보기

push_back은 자료구조에 삽입하고자 하는 값을 인자로 받기 때문에 인자 전달 과정에서 복사가 발생할 수 있습니다. 반면, emplace_back은 생성자의 인자를 함수의 인자로 받아 내부에서 객체를 생성하게 됩니다. 인자 전달 과정에서 복사가 발생하지 않아 상황에 따라 성능상 이점을 노릴 수 있습니다.

6. 코루틴과 멀티스레드의 차이는?

더보기

멀티 스레드는 여러 스레드를 활용하여 작업을 물리적으로 병렬 처리하지만 코루틴은 단일 스레드에서 처리되기 때문에 물리적으로는 작업을 순차 처리하게 됩니다. 코루틴은 멀티 스레드에 비해 안전하고 컨텍스트 스위칭이 발생하지 않으며 일반적인 함수 호출에 비해 빠르다는 장점이 있지만 물리적 병렬 작업이 필요한 경우에는 사용할 수 없다는 단점이 있습니다.

7. 앵커란 무엇인가? (언리얼엔진)

더보기

UI가 스크린에 렌더링되는 위치의 기준을 잡아주는 기능입니다. 앵커를 활용하면 UI가 다양한 해상도에서 자연스럽게 렌더링될 수 있습니다.

8. 객체 지향이란?

더보기

현실에서 사몰을 보는 시각으로 프로그래밍을 하고자 하는 방법론입니다. 코드의 흐름이 직선적이지 않고 객체를 넘나들며 실행되기 때문에, 절차지향적 프로그래밍에 비해 처리 속력이 다소 느리지만 코드를 이해하는 것이 매우 직관적이라는 장점이 있습니다.

9. 메시와 머티리얼에 대해 설명하라.

더보기

메시란 대상이 화면에 어느만큼 그려져야 하는지를 결정짓는 데이터입니다. 여러 개의 버텍스로 구성되어 있으며, 버텍스의 위치, 회전, 크기 정보에 따라 렌더링되는 범위가 결정됩니다. 반면, 머티리얼은 메시에 의해 정해진 영역을 어떤 색상으로 그려야 하는지를 결정짓는 데이터입니다. 텍스쳐, 빛 연산 등 색상에 영향을 미치는 모든 요소를 포함하는 개념입니다.

10. 렌더링 파이프라인의 전체적인 과정에 대해 설명하라.

더보기

수치로만 가지고 있는 데이터 정보를 화면에 그려질 색상 정보로 변환하는 과정을 렌더링 파이프라인이라고 합니다. 필수 단계로는 인풋 어셈블러, 버텍스 쉐이더, 레스터라이저, 픽셀 쉐이더, 아웃풋 머저 단계가 있습니다.

 

인풋 어셈블러 단계에선 버텍스 버퍼와 인덱스 버퍼를 참조하여 도형을 조립해 버텍스 쉐이더로 전달하게 됩니다. 또한, CPU에서 전달받은 버텍스 정보와 쉐이더에서 사용할 버텍스 정보를 매칭하기 위해 데이터에 시맨틱스를 부여하는 작업도 거치게 됩니다.   

 

버텍스 쉐이더 단계에선 버텍스의 좌표에 월드 행렬, 뷰 행렬, 프로젝션 행렬을 곱해 원평면에 투영하는 과정을 거칩니다. 해당 과정은 프로그래밍 가능 단계이기 때문에 추가적인 연산이 필요하다면 코드를 작성하여 연산을 추가할 수 있습니다. 

 

레스터 라이저 단계에선 렌더타겟에서 실제로 렌더링 될 위치만 추출하는 과정을 거칩니다. 대상의 후면을 컬링하거나 렌더타겟의 범위 밖으로 넘어가는 부분을 클리핑합니다. 또한, 렌더타겟에서 실제로 렌더링이 될 위치만을 추출하여 픽셀 쉐이더에서 불필요한 연산을 거치지 않도록 도와주는 역할을 합니다. 또한, 버텍스의 데이터를 픽셀 단위로 사용할 수 있도록 보간해주는 역할도 합니다.

 

픽셀 쉐이더는 레스터라이저 단계에서 렌더링을 하기로 정해진 위치의 색상을 정하는 단계입니다. 프로그래밍 가능 단계이므로 텍스쳐를 사용하여 색상을 결정할 수도 있고, 빛 연산 등의 추가적인 연산을 수행할 수도 있습니다.

 

아웃풋 머저는 픽셀 쉐이더에 의해 결정된 색상과 기존에 그려져 있던 색상을 어떻게 처리할 지 결정하는 단계입니다. 알파 블렌딩, 뎁스 스텐실 테스트 등을 거쳐 최종적으로 렌더타겟에 저장될 값을 결정짓게 됩니다.

11. 오브젝트 풀링이란?

더보기

오브젝트가 필요할 때 마다 동적으로 할당하는 것은 런타임 성능의 저하를 유발할 수 있기 때문에 로드 중에 미리 대량의 메모리를 할당해놓고 필요할 때마다 사용하는 최적화 기법을 오브젝트 풀링이라고 합니다. 오브젝트 풀링은 동적 할당으로 인한 오버헤드를 줄일 수도 있지만 외부 단편화에 강하고 오브젝트의 캐시 적중률을 높일 수 있기 때문에 적절히 사용하면 높은 성능 향상을 기대할 수 있습니다. 다만, 메모리를 할당해놓고 일부밖에 사용하지 않는다면 내부 단편화가 발생할 수 있기 때문에 필요한 만큼만 할당하는 것이 중요합니다.

12. 반투명한 물체를 처리하는 방법은?

더보기

 반투명 물체의 경우 앞에 있는 물체가 먼저 그려질 경우 뒤에 있는 물체의 일부분이 의도치 않게 클리핑될 수 있습니다. 이를 막기 위해 카메라의 위치를 기준으로 멀리있는 물체부터 정렬하여 렌더링하는 알파 소팅 기법을 사용할 수 있습니다. 하지만, 알파 소팅의 경우에도 완벽하게 문제를 해결하지는 못합니다.

 

그러한 경우에는 물체가 불투명임을 가정하고 렌더링하여 렌더타겟에 깊이를 저장한 뒤, 해당 깊이 값을 활용해 불투명 물체를 다시 렌더링하여 문제를 해결할 수 있습니다. 하지만, 이 경우 드로우 콜이 2번 발생하기 때문에 반드시 필요한 상황에만 사용해야 합니다. 

13. UDP 와 TCP 의 차이에 대해 설명하라.

더보기

UDP는 비연결 지향형 프로토콜이며 TCP는 연결 지향형 프로토콜입니다. TCP의 경우 안전한 데이터 송수신을 위해 3 way HandShake로 서로 연결하며 4 Way handShake로 연결을 해제합니다. 또한, 패킷의 송수신 과정에서 흐름제어, 혼잡제어 등의 기법을 사용해 패킷의 신뢰성을 보장하고 있습니다. 반면, UDP의 경우 별도의 처리 없이 목적지로 데이터를 보내기 때문에 데이터의 신뢰성을 보장되어 있지 않습니다. 반면 UDP는 어떠한 처리도 거치지 않는 만큼 송수신 속력이 TCP에 비해 매우 빠르다는 장점이 있습니다. 이러한 특성으로 인해 TCP는 파일 전송 등 신뢰성이 중요한 경우에 사용되며 UDP는 동영상 스트리밍 등 데이터의 신뢰성보다 속력이 중요한 경우에 주로 사용됩니다.

14. 상속을 사용하는 이유는?

더보기

상속의 주 목적은 코드의 재사용입니다. 한 클래스에 정의된 내용을 다른 클래스에서도 동일하게 정의해야 한다면 클래스의 상속을 통해 기능을 물려줄 수 있습니다. 또한, 상속을 사용하면 객체지향의 특징인 다형성을 보장할 수 있다는 장점도 있습니다.

15. 템플릿과 오버로딩의 차이는?

더보기

템플릿은 동일한 기능이 다양한 자료형에 대해 대응할 수 있도록 사용하는 기능입니다. 동일한 기능을 여러 자료형을 대상으로 여러 개 정의하는 것은 매우 번거로운 일이지만, 템플릿을 활용하면 한 번의 정의를 통해 여러 자료형에 대응할 수 있게 됩니다.

 

반면 오버로딩은 동일한 이름의 함수를 다양한 파라미터로 사용할 수 있도록 하는 기능입니다. 본질적 기능이 유사한 경우 동일한 이름을 사용하여 작명의 번거로움을 피할 수 있습니다.

16. 오버로딩과 오버라이딩의 차이는?

더보기

오버로딩은 이름은 동일하되, 파라미터가 다른 경우에 사용하는 문법입니다. 반면, 오버라이딩은 상속 관계에서 자식클래스가 부모 클래스의 함수를 재정의하여 새롭게 사용하는 문법입니다.

17. 스마트포인터란?

더보기

메모리의 할당과 해제를 대신 관리해주는 객체입니다. 메모리를 동적으로 할당하고 해제할 때엔 malloc/free 혹은 new/delete를 사용해야 하지만, 프로그래머의 실수로 인해 메모리의 누수가 발생하거나 댕글링 포인터 문제가 발생할 수 있습니다. 이러한 위험없이 안전하게 메모리를 관리할 수 있도록 C++에서 제공해주는 기능입니다. 동적으로 할당된 메모리 영역을 참조하는 스마트 포인터 객체가 증가할 때마다 참조 카운트를 증가시키고 감소할 때마다 참조 카운트를 감소시키며 참조 카운트가 0이 되었을 때 메모리의 할당을 해제하여, 메모리 누수가 발생하지 않도록 관리해줍니다.

18. 32비트 , 64비트 운영체제 차이

더보기

32비트 운영체제는 메모리 주소를 32비트로 저장하며, 64비트 운영체제는 메모리 주소를 62비트로 저장합니다. 32비트로 메모리 주소를 저장하는 경우 표현할 수 있는 주소의 한계로 인해 메인 메모리의 용량이 아무리 크더라도 대략 4GB를 넘어서면 사용할 수 없게 됩니다. 반면, 64비트 운영체제는 표현할 수 있는 주소의 범위가 훨씬 넓기 때문에 메인 메모리의 용량을 최대한으로 사용할 수 있습니다.

19. 유저 영역 vs 커널 영역

더보기

유저 영역은 운영체제에서 유저가 사용하고 접근할 수 있는 영역을 의미하며, 커널 영역은 운영체제의 중요한 기능을 담고 있어 유저가 함부로 접근할 수 없도록 제한되어 있는 영역을 의미합니다.

20. 가상메모리란?

더보기

SSD, HDD 등 보조 기억 장치의 도움을 받아 메인 메모리의 물리적 용량보다 더 큰 용량을 사용할 수 있게 해주는 기법입니다. 프로세스의 데이터 중 실제로 사용되는 데이터만 메인 메모리에 적재하고 사용되지 않는 데이터는 보조 기억 장치에 적재하는 방식으로 동작합니다.

21. 페이징 기법이란?

더보기

가상 메모리를 구현하는 대표적인 방식입니다. 메인 메모리와 프로세스를 동일한 크기의 조각으로 잘게 쪼개놓은 뒤, 프로세스의 조각중 CPU에서 사용되어야 하는 조각을 메인 메모리에 순차적으로 적재하며 동작합니다. CPU에서 프로세스 조각의 물리적 주소에 접근할 수 있도록 운영체제는 페이지 테이블을 생성하여 프로세스의 가상 주소와 물리적 주소를 저장하여 보관합니다. 

 

페이징 기법은 외부 단편화가 발생하지 않는다는 장점이 있지만, 프로세스의 가장 끝에 있는 조각에서 내부 단편화가 발생할 수 있습니다. 또한, CPU에서 메인 메모리에 2번이나 접근해야 하는 상황으로 인해 성능 저하가 발생할 수 있습니다. 

23. 변환색인버퍼란?

더보기

페이징 기법의 문제를 해결하기 위해 사용하는 하드웨어입니다. 페이징 기법은 CPU가 프로세스 데이터의 물리적 주소에 접근할 수 있도록, 가상 주소 테이블을 메인 메모리에 보관하며 사용합니다. CPU에서 프로세스 데이터의 물리적 주소에 접근하기 위해, 가상 주소 테이블에 접근하여 물리적 주소를 알아낸 뒤 물리적 주소에 접근하게 됩니다. CPU보다 처리 속도가 느린 메인 메모리에 2번이나 접근해야 하는 이유로 병목 현상이 크게 발생할 수 있습니다. 이러한 병목현상 문제를 최소화하기 위해 변환 색인 버퍼라는 캐시 메모리에 가상 주소와 물리적 주소를 캐싱하여 사용하게 됩니다.

22. 세그멘테이션 기법이란?

더보기

가상 메모리를 구현하는 기법 중 하나입니다. 프로세스의 데이터를 연관이 있는 것끼리 묶어 여러 세그멘트로 나눈 뒤 세그멘트 별로 메모리에 적재하여 사용하게 됩니다. 세그먼트의 크기가 일정하지 않아, 페이징 기법처럼 순차적으로 메모리에 적재할 수 없어 적재할 때마다 크기만큼 메모리를 할당하는 과정을 거치게 됩니다. 이로 인해, 내부 단편화는 전혀 발생하지 않지만 잦은 메모리 할당과 해제로 인해 외부 단편화가 발생할 수 있습니다. 세그멘테이션 기법도 페이징 기법처럼 가상 주소와 물리 주소를 매핑하는 테이블을 보유하지만 페이지 테이블에 비해 크기가 매우 작아 메인 메모리가 아닌 레지스터에 위치하고 있습니다.

24. 캐시 메모리란?

더보기

CPU와 메인 메모리의 작업 처리 속도 차이로 인해, CPU가 메인 메모리에 직접 접근하게 되면 병목 현상이 발생할 수 있습니다. 이러한 병목 현상을 완화하기 위해, 메인 메모리보다 속력이 빠른 캐시 메모리가 CPU와 메인 메모리 사이에 위치하고 있습니다. CPU가 메인 메모리에 접근하게 되면, 접근한 주소와 근접한 데이터를 캐시 메모리에 복사하여 저장합니다. 이후, CPU는 캐시 메모리를 우선적으로 탐색하게 되고 원하는 데이터가 캐시 메모리에 없다면 메인 메모리에 접근하여 다시 데이터를 가져와 캐시 메모리를 갱신하게 됩니다. 이러한 과정을 통, CPU가 메인 메모리에 직접 접근하는 횟수를 줄여 병목 현상으로 인한 성능 저하를 최소화할 수 있습니다.

25. 캐시적중률이란?

더보기

캐시 메모리에서 원하는 데이터를 발견할 확률을 의미합니다. CPU에서 필요한 데이터를 캐시 메모리에서 탐색할 때, 데이터를 발견하는 것을 캐시 적중이라고 하며 발견하지 못하는 것을 캐시 미스라고 합니다. 캐시 미스가 발생하게 되면, CPU는 다시 메인 메모리에 접근하여 캐시 메모리를 갱신하게 됩니다. 즉, 캐시 미스가 많이 발생할수록 CPU는 메인 메모리에 자주 접근하게 되고 캐시 적중이 많이 발생할수록 CPU는 메인 메모리에 적은 횟수로 접근하게 됩니다. 캐시 적중이 얼마나 많이 발생하는 가를 캐시 적중률이라고 하며, 성능 저하를 최소화하기 위해선 캐시 적중률을 높이는 방향으로 프로그래밍하는 것이 좋습니다.

26. volatile 키워드란?

더보기

volatile 키워드란, 컴파일러의 최적화를 제한하는 키워드입니다. 컴파일러는 프로그램이 최선의 속도로 실행될 수 있도록 프로그래머가 작성한 코드를 일부 변경하여 최적화를 달성하곤 합니다. 하지만, 이러한 최적화로 인해 프로그래머의 의도와 실행결과가 달라지는 경우가 있습니다. 이러한 상황을 방지하기 위해 volatile 키워드를 변수 앞에 붙혀 해당 변수에 대한 컴파일러 최적화를 제한할 수 있으며, 이로 인해 프로그래머의 의도와 실행 결과를 일치시킬 수 있습니다.

27. inline 키워드란?

더보기

함수 호출로 인한 오버헤드를 줄이기위해, 컴파일 타임에 함수의 호출 코드를 함수의 내부 코드로 치환하는 키워드입니다. 

28. 함수 뒤에 붙는 const 를 설명하라.

더보기

함수 뒤에 const를 붙이게 되면 해당 함수 내에서 멤버 변수의 값을 변경할 수 없게 됩니다. 함수 뒤에 const를 붙이게 되면 this 포인터가 가리키는 대상이 상수화되어, 함수 내부에서 멤버변수의 값을 변경할 수 없게 됩니다. 이러한 동작 원리 때문에, 내부에서 전역변수나 다른 클래스의 멤버 변수 값은 얼마든지 변경할 수 있으며 전역 함수의 뒤에는 const를 붙일 수 없습니다.

29. L-Value와 R-Value를 설명하라.

더보기

L-Value는 메인 메모리에 존재하며 스코프를 벗어나기 전까지 사라지지 않는 값입니다. 반면, R-Value는 메인 메모리에 존재하지 않으며 호출식 이후에는 사라지는 값입니다.

30. this 콜이란?

더보기

멤버 함수를 호출할 때, 함수를 호출하는 인스턴스를 식별하기 위해 첫 번째 인자로 객체의 주소를 대입하는 방식입니다. 

31. 멀티 스레드와 멀티 프로세스에 대해 설명하라.

더보기

멀티 스레드는 하나의 프로세스에서 여러 스레드를 사용하는 것을 의미하며, 멀티 프로세스는 동시에 여러 프로세스를 실행하는 것을 의미합니다.

 

프로세스는 각각 별도의 메모리 공간을 보유하고 있기 때문에 하나의 프로세스에 문제가 발생하더라도 다른 프로세스에 영향을 주지 않지만, 스레드는 스택 영역을 제외한 메모리를 모두 공유하기 때문에 하나의 스레드에서 문제가 발생하면 프로세스 전체에 영향을 줄 수 있다는 차이가 있습니다.


또한, 프로세스는 모든 메모리 영역이 독립되어있는 만큼 컨텍스트 스위칭의 비용이 크지만 스택 영역만을 독립적으로 보유하고 있는 스레드의 컨텍스트 스위칭은 상대적으로 가볍다는 차이가 있습니다. 

32. 뮤텍스와 세마포어의 차이는?

더보기

뮤텍스는 임계 영역에 1개의 스레드에 대해서만 접근을 허용하지만, 세마포어는 여러 개의 스레드가 접근하도록 설정할 수 있습니다. 또한, 뮤텍스는 뮤텍스 객체가 lock과 unlock 권한을 가지고 있지만, 세마포어는 자체적으로 lock과 unlock 권한을 가지고 있지 않습니다.

33. IPC란?

더보기

IPC는 프로세스 간의 통신을 의미합니다. 멀티 프로세스 환경에서 각 프로세스는 메모리 영역이 철저히 분리되어 있기 때문에 일반적인 방법으로는 서로 통신할 수 없지만, 커널 영역을 거치면 특수한 방법으로 통신할 수 있게 됩니다.

34. unnamed PIPE vs named PIPE

더보기

PIPE란, 멀티 프로세스 환경에서 프로세스 끼리 통신하기 위해 사용되는 기법 중 하나입니다. PIPE라는 공간에 한 프로세스가 쓰기 작업을 하면 다른 프로세스는 쓰여진 값을 읽는 방식으로 통신하게 됩니다. PIPE에 이름이 부여되어 있으면 named PIPE라고 하며 이름이 부여되어 있지 않다면 unnamed PIPE라고 합니다. unnamed PIPE의 경우 PIPE에 이름이 부여되어 있지 않으므로 하나의 PIPE에 프로세스가 연결되어 있어야 합니다. 이러한 문제로 인해, 자식 부모 관계로 얽혀 있는 프로세스 간의 통신에 사용하게 됩니다. 반면 named PIPE는 PIPE에 이름이 부여되어 있어 프로세스가 원하는 PIPE에 직접 접근할 수 있어 관련없는 프로세스 간의 통신에도 사용할 수 있습니다. 

35. IP와 MAC 이란?

더보기

네트워크에 연결되어 있는 장치들을 식별하기 위해 부여되는 식별 번호입니다. IP는 장치의 Lan Card가 연결되어 있는 회선에 부여되는 주소이며, MAC주소는 Lan Card 자체에 부여되어 있는 주소입니다. IP는 회선에 부여되어 있기 떄문에, 장치가 접속하는 인터넷이 변할 때마다 함께 변하게 됩니다. 반면, MAC주소는 하드웨어에 기록되어 있기 때문에 Lan Card를 교체하지 않는 한 변하지 않는 주소입니다. 

36. IPv4 과 IPv6의 차이는?

더보기

IPv4의 경우 IP를 8비트의 숫자 4개가 엮인 32비트의 형태로 주소를 표현합니다. 반면, IPv6의 경우 16비트의 숫자 8개가 엮인 128비트의 형태로 주소를 표현합니다. IPv4로 표현할 수 있는 주소 범위 내에선 현대에 사용되는 모든 장치에 고유 주소를 부여하는 것이 어렵기 때문에 보다 넓은 범위로 주소를 표현하기 위해 IPv6 방식이 탄생하였습니다. 하지만, 현대에 사용되고 있는 IPv4 기반의 네트워크 통신을 IPv6 기반으로 변경하는 것은 매우 큰 비용이 발생하기 때문에 IPv6는 매우 제한된 환경에서만 사용되고 있습니다. 

37. 포트넘버란?

더보기

포트 넘버란, IP, MAC 주소 만으로는 데이터의 도착지를 식별할 수 없는 경우 최종 목적지를 식별하기 위해 추가적으로 사용되는 주소입니다. 데이터가 도착한 PC에서 여러 개의 프로세스가 실행중인 경우 최종적으로 어느 프로세스에 도달해야 할 지 IP, MAC 주소 만으로는 식별할 수 없기 때문에 프로세스에 포트번호를 부여하여 최종적인 목적지를 판별하게 됩니다.

38. 프로세스의 메모리 구조를 설명해라.

더보기

프로세스는 코드 영역, 데이터 영역, 힙 영역, 스택 영역으로 분할되어 있습니다. 코드 영역엔 프로세스를 프로그래밍할 때 작성된 코드 전체가 저장되어 있으며, 데이터 영역엔 전역 변수, 문자열 리터럴 등 한 번 메모리에 저장되면 프로세스가 종료될 때 까지 삭제되지 않는 데이터들이 저장되어 있습니다. 힙 영역은 런타임에 동적으로 할당하여 사용할 수 있는 메모리 영역이며, 스택 영역엔 호출된 함수 내부에서 사용되는 로컬 데이터가 저장됩니다. 

39. 문자열 리터럴은 L-Value인가 R-Value인가?

더보기

문자열 리터럴은 일반적인 상수 리터럴과 다르게 데이터 영역에 저장되어 있으며 그 주소값을 참조할 수 있습니다. 즉, 문자열 리터럴은 L-Value 입니다.

40. 무브 시맨틱이란?

더보기

더이상 사용되지 않을 것이 확실한 객체에 대해 깊은 복사가 아닌 얕은 복사를 한 뒤 대상의 데이터를 지워 소유권을 완전히 이전시키는 기법입니다. 깊은 복사가 발생하지 않기 때문에 성능상의 이점이 있으며, 복사되는 객체의 데이터를 지워버림으로써 얕은 복사로 인한 댕글링 포인터 문제도 발생하지 않습니다.

41. 플레이어를 기준으로 몬스터가 어디에 있는지 알아내는 방법

더보기

대표적으로 내적과 외적을 사용하는 방법이 있습니다. 플레이어의 위치에서 몬스터를 향하는 벡터와 플레이어의 전방 벡터를 내적하여 몬스터가 앞에 있는지, 뒤에 있는지 구할 수 있습니다.

 

또한, 플레이어 위치에서 몬스터를 향하는 벡터와 플레이어의 위쪽을 향하는 벡터를 외적하면 몬스터가 좌측에 있는지, 우측에 있는지를 구할 수 있습니다. 

42. 행렬연산을 사용하는 이유는 무엇인가?

더보기

벡터를 사용해 연산하는 것보다 훨씬 간단하게 연산 결과를 구할 수 있으며, 하나의 행렬에 모든 정보를 누적하여 저장하기 때문에 메모리를 절약할 수 있기 때문입니다. 또한, 4x4 행렬의 경우 SIMD 연산을 통해 추가적인 성능 향상을 기대할 수 있습니다.

43. 노말맵이란?

더보기

색상 정보가 아닌 노말 정보를 가지고 있는 텍스쳐를 의미합니다. 주로 픽셀 단위로 빛을 계산하기 위해 사용합니다. 

44. 탄젠트 스페이스란?

더보기

폴리곤의 법선을 수직축으로 하는 평면공간입니다. 입체 도형의 법선 정보를 월드 좌표계의 값으로 저장하여 사용하게 되면, 오브젝트가 아무리 회전하더라도 법선 값은 변하지 않아 조명 효과가 부자연스럽게 적용됩니다. 반면, 노말 값을 탄젠트 공간의 값으로 저장하게 되면 현재 폴리곤의 접선과 종법선을 활용해 노말값을 동적으로 계산하여 물체의 회전 정보에 따라 자연스럽게 법선을 매핑할 수 있게 됩니다.

 

하지만, 탄젠트 공간의 노말맵을 사용하게 되면 런타임에 연산이 추가되는 만큼 성능 면에서 손해를 볼 수 있기 때문에, 회전할 가능성이 없는 정적 오브젝트에 대해선 월드 공간의 노말 맵을 사용하는 것이 좋습니다.

45. G버퍼란?

더보기

렌더타겟이 지니고 있는 데이터 버퍼를 의미합니다. 해당 버퍼에는 색상 값이 저장될 수도 있고, 노말 값이 저장될 수도 있으며, 깊이 값이 저장될 수도 있습니다.

46. AABB충돌이란? 문제점은?

더보기

두 사각형 혹은 정육면체 간의 충돌을 탐지하는 방법입니다. 두 도형의 좌표 차와 길이 합을 비교하여 충돌을 탐지합니다. 연산이 매우 간단하지만 회전한 사각형, 정육면체에 대해서는 올바르게 작동하지 않는다는 문제점이 있습니다.

47. OBB충돌이란?

더보기

AABB 충돌로 탐지할 수 없는 회전한 도형의 충돌을 탐지하기 위한 방법입니다.두 도형을 x, y, z 축에 대해 투영한 뒤, 두 도형의 투영 공간이 겹치는 지 여부를 통해 충돌을 탐지합니다. 회전한 도형에 대해서도 충돌을 탐지할 수 있지만 연산량이 많다는 단점이 있습니다. 

48. 드로우콜이란?

더보기

CPU에서 GPU에 렌더링 연산을 명령하는 것을 의미합니다. GPU가 이해할 수 있는 명령어 형태로 변환하는 과정이 상당히 오래걸리기 때문에 CPU에 병목 현상을 가져올 수 있습니다. 그렇기 때문에, 드로우 콜을 최소화하는 것은 최적화에 있어 매우 중요한 요소입니다.

49. 드로우콜 최적화는 CPU최적화인가 GPU최적화인가

더보기

드로우 콜은 CPU 최적화입니다. CPU에서 GPU로 명령을 보내는 작업이기 때문에, 드로우 콜을 줄일수록 CPU는 다른 연산에 시간을 더 투자할 수 있게 됩니다. GPU 최적화 기법은 대표적으로 LOD, 밉맵 등이 있습니다.

50. 쿼터니언이란?

더보기

오일러 각을 사용했을 때 발생할 수 있는 짐벌락 현상이나 회전 보간의 비정확성을 해결하기 위해 회전 연산에 사용하는 수학적 개념입니다.

51. 짐벌락이란?

더보기

회전 축이 겹침으로써 한 축이 회전 능력을 소실하는 현상입니다..

52. 픽셀충돌을 사용하는 이유는?

더보기

복잡한 지형에 대한 충돌 처리를 간단하게 수행할 수 있기 때문입니다.

53. 싱글톤 패턴이란?

더보기

객체의 인스턴스가 프로세스 내에 단 1개만 존재하도록 설계하는 디자인 패턴입니다. 생성자와 소멸자를 private 으로 설정하여외부에서 호출되는 것을 막은 뒤, 객체 내부에서 static 변수로 인스턴스를 생성하는 방식으로 구현됩니다. 

54. 멀티스레드 환경에서 싱글톤의 문제점

더보기

싱글톤 클래스의 인스턴스를 동적 할당하는 방식으로 구현한다면 멀티 스레드 환경에서 객체가 여러개 생성되는 문제가 발생할 수 있습니다. 여러 스레드가 동시에 인스턴스를 반환하는 함수를 호출하게 되면, nullptr 검사 조건문을 여러 스레드가 동시에 통과하게 되어 여러번 동적 할당이 발생하게 됩니다. 이러한 상황을 막기 위해 해당 함수 내부에 lock()과 unlock()을 추가하게 되면 오버헤드로 인해 멀티 스레딩의 능률이 매우 저하될 수 있습니다.

 

이를 해결하기 위한 대표적인 방법으로 싱글톤 클래스를 지역 static 변수로 생성하는 방법이 있습니다.  

55. 전방선언이란 무엇인가? 사용하는 이유는?

더보기

전방 선언이란, 헤더파일을 참조하지 않더라도 자료형을 사용할 수 있도록 전방에 미리 선언해주는 기법입니다. 다만, 자료형이 선언만 되어 있는 형태이기 때문에 멤버 함수 등 자료형에 포함된 기능을 사용할 수는 없습니다. 전방 선언을 사용하는 이유는 불필요한 헤더의 참조를 막아 순환 참조를 방지함과 동시에 빌드 시간을 단축시키기 위해서입니다.

56. 네임맹글링이란?

더보기

네임 맹글링이란 컴파일러가 함수나 변수의 이름을 변경하는 것을 의미합니다. C++의 경우 함수 오버로딩과 같이 동일한 이름의 함수가 존재할 수 있기 때문에 이를 구별하기 위해 이름을 의도적으로 변환하는 과정을 거치게 됩니다. 하지만, C언어의 경우 네임 맹글링을 사용하기 때문에 C언어 개발 환경에선 C++ 라이브러리를 사용할 수 없게 됩니다. 이를 해결하기 위해, C++ 로 코드를 작성할 때 extern "C" 키워드를 함수 앞에 붙여 함수의 네임 맹글링을 제한할 수 있으며, C언어에서도 C++ 컴파일러에 의해 컴파일된 라이브러리를 사용할 수 있게 됩니다.

57.다중상속의 문제점

더보기

상속받은 여러 클래스에 각각 동일한 이름의 함수 혹은 변수가 정의되어 있다면 하위 클래스에선 어떤 클래스의 함수 혹은 변수를 사용해야 할 지 예측할 수 없는 문제가 발생합니다. 

58. 라운드 로빈 알고리즘이란?

더보기

우선순위에 따라 차등하게 작업을 실행하는 것이 아니라, 모든 작업을 동일한 시간만큼 평등하게 실행하는 알고리즘입니다. 우선순위에 의한 기아 현상은 발생하지 않지만, 중요한 작업과 중요하지 않은 작업이 똑같이 실행되는 만큼 작업이 효율적으로 분배되지 못한다는 단점이 있습니다.

59. 컨텍스트 스위칭이란

더보기

CPU에서 작업중인 프로세스, 스레드를 다른 프로세스, 스레드로 교체하는 과정 전체를 의미합니다. 잦은 컨텍스트 스위칭은 오버헤드의 원인이 되기 때문에 멀티 프로세스, 멀티 스레드 프로그래밍에선 컨텍스트 스위칭을 최소화하는 방향으로 설계해야 합니다.

60. 가상 소멸자를 쓰는 이유는 무엇인가?

더보기

자식 클래스의 소멸자를 올바르게 호출하기 위해 사용합니다. 자식 클래스가 부모 클래스로 업캐스팅된 상태에서 객체가 소멸하게 되면, 자식 클래스의 소멸자가 아닌 부모 클래스의 소멸자를 호출하게 됩니다. 이러한 상황을 방지하기 위해, 소멸자를 가상화하여 업캐스팅 된 상황에서도 자식 소멸자가 올바르게 호출될 수 있도록 할 수 있습니다.

61. 클래스와 구조체의 차이는?

더보기

클래스는 접근 제한 지정자의 디폴트 값이 private이고, 구조체는 public이라는 차이가 있습니다.

62. RTTI란?

더보기

런타임에 객체의 실제 타입을 확인할 수 있도록 도와주는 기능입니다. 객체의 가상함수 테이블 가장 앞에 클래스의 정보를 저장하여 사용합니다. 주로, dynamic_cast에 사용됩니다. 

63. 람다함수란 무엇인가? 장단점은 무엇이 있는가?

더보기

람다 함수란 이름이 없는 함수를 의미합니다. 일반적인 함수처럼 미리 선언 및 정의하여 사용하는 것이 아니라, 필요할 때 즉석으로 정의하여 사용하게 됩니다. 여러 번 사용될 가능성이 없는 기능을 람다 함수로 사용하면 메모리를 절약할 수 있다는 장점이 있지만, 람다 함수를 무분별하게 사용하면 가독성이 크게 저하될 뿐더러 디버깅이 매우 어려워집니다.

64. string과 string_view의 차이

더보기

string은 문자열을 저장하는 자료구조입니다. 내부에 배열을 만들어 문자열을 데이터로 보유하고 있습니다. 반면, string_view는 특정 문자열에 대한 포인터만 보유하고 있습니다. string은 실제 문자열을 유연하게 관리하기 위해 사용하는 반면, string_view는 불필요한 문자열의 복사를 방지하기 위해 사용합니다. 

65. RTV란?

더보기

텍스쳐를 렌더타겟으로 사용하기 위해 필요한 GPU의 자원입니다. 렌더타겟 옵션, 플래그 및 텍스쳐 포멧 정보를 담고있습니다. 

 

 

 

상자엔 익은 토마토와 익지 않은 토마토가 있다.

하루가 지나면 익은 토마토 주위 4방향에 있는 익지 않은 토마토는 익는다.

모든 토마토가 익을 때까지 시간이 얼마나 걸릴까?

 

문제 풀이

배열에 토마토의 정보를 담고 (전체 순회 -> 토마토 정보 업데이트 -> 모두 익었나 확인)의 과정을 반복하면 쉽게 해결할 수 있다. 본인이 해결한 방법은 약간 다르다. 익은 토마토의 위치를 모두 queue에 삽입한 뒤, 하나씩 뽑으며 BFS 방식으로 문제를 해결하였다.

 

풀이 코드

struct Data
{
    std::pair<int, int> Pos;
    int Days = 0;
};

먼저, queue에 삽입할 데이터 구조체를 만들어주었다.

익은 토마토의 위치와 며칠째에 익었는지를 저장하는 구조체이다.

int Width = 0;
int Height = 0;

std::cin >> Width >> Height;

std::vector<std::vector<int>> Map(Height, std::vector<int>(Width, 0));
std::queue<Data> Grown;

for (int i = 0; i < Height; i++)
{
    for (int j = 0; j < Width; j++)
    {
        std::cin >> Map[i][j];

        if (Map[i][j] == 1)
        {
            Grown.push({ { i, j }, 0 });
        }
    }
}

다음은 입력을 받아주었다.

입력을 받으면서 익은 토마토는 queue에 따로 삽입해주었다.

std::vector<int> DirX = { 1, 0, -1, 0 };
std::vector<int> DirY = { 0, 1, 0, -1 };

int Answer = 0;

while (Grown.size() > 0)
{
    Data CurData = Grown.front();
    Grown.pop();

    Answer = std::max(Answer, CurData.Days);

    for (int i = 0; i < 4; i++)
    {
        std::pair<int, int> NearTomato = { CurData.Pos.first + DirY[i], CurData.Pos.second + DirX[i]};

        if (NearTomato.first < 0 || NearTomato.second < 0 || NearTomato.first >= Height || NearTomato.second >= Width)
        {
            continue;
        }

        if (Map[NearTomato.first][NearTomato.second] == 0)
        {
            Map[NearTomato.first][NearTomato.second] = 1;
            Grown.push({ NearTomato, CurData.Days + 1});
        }
    }
}

다음은 BFS 구현코드이다.

 

먼저, 익은 토마토 하나를 꺼내서 주변 4방향에 익지 않은 토마토가 있나 검사한 뒤, 있다면 해당 토마토를 다시 queue에 삽입해주었다. 토마토가 익으려면 하루가 지나므로 Days에 +1을 해서 삽입해주었다.

 

Answer은 답을 저장할 변수인데 이 값은 계속 갱신해주었다.

for (int i = 0; i < Height; i++)
{
    for(int j = 0; j < Width; j++)
    {
        if (Map[i][j] == 0)
        {
            std::cout << -1;
            return 0;
        }
    }
}

std::cout << Answer;

return 0;

마지막에 아직 익지 않은 토마토가 있나 검사한 뒤, 있다면 -1을 출력하고 없다면 Answer 을 출력하도록 해주었다.

 

코드 전문

#include <iostream>
#include <set>
#include <queue>
#include <vector>

void Init()
{
    std::ios::sync_with_stdio(false);
    std::cin.tie(nullptr);
    std::cout.tie(nullptr);
}

struct Data
{
    std::pair<int, int> Pos;
    int Days = 0;
};

int main()
{
    Init();

    int Width = 0;
    int Height = 0;

    std::cin >> Width >> Height;

    std::vector<std::vector<int>> Map(Height, std::vector<int>(Width, 0));
    std::queue<Data> Grown;

    for (int i = 0; i < Height; i++)
    {
        for (int j = 0; j < Width; j++)
        {
            std::cin >> Map[i][j];

            if (Map[i][j] == 1)
            {
                Grown.push({ { i, j }, 0 });
            }
        }
    }

    std::vector<int> DirX = { 1, 0, -1, 0 };
    std::vector<int> DirY = { 0, 1, 0, -1 };

    int Answer = 0;

    while (Grown.size() > 0)
    {
        Data CurData = Grown.front();
        Grown.pop();

        Answer = std::max(Answer, CurData.Days);

        for (int i = 0; i < 4; i++)
        {
            std::pair<int, int> NearTomato = { CurData.Pos.first + DirY[i], CurData.Pos.second + DirX[i]};

            if (NearTomato.first < 0 || NearTomato.second < 0 || NearTomato.first >= Height || NearTomato.second >= Width)
            {
                continue;
            }

            if (Map[NearTomato.first][NearTomato.second] == 0)
            {
                Map[NearTomato.first][NearTomato.second] = 1;
                Grown.push({ NearTomato, CurData.Days + 1});
            }
        }
    }

    for (int i = 0; i < Height; i++)
    {
        for(int j = 0; j < Width; j++)
        {
            if (Map[i][j] == 0)
            {
                std::cout << -1;
                return 0;
            }
        }
    }

    std::cout << Answer;

    return 0;
}
스터디를 진행하며 실시한 모의 면접을 정리한 내용입니다.
겹치는 내용을 고려하지 않고 면접을 실시하기 때문에 일자 별로 겹치는 내용이 많을 수 있습니다.

 

1. 깊이 버퍼와 스텐실 버퍼란?

더보기

깊이 버퍼란, 카메라의 시야를 기준으로 대상이 얼마나 멀리있는가를 픽셀 단위로 저장하는 버퍼입니다.

0~1사이의 실수값으로 저장되며, 깊이 버퍼를 통해 여러 물체가 겹쳐있는 픽셀의 색상을 결정하게 됩니다.

 

스텐실 버퍼란, 대상을 그릴지 말지를 저장하는 버퍼입니다. 0과 1의 값을 가질 수 있으며, 0인 경우 색상을 저장하지 않고, 1인 경우 색상을 저장하게 됩니다. 스텐실 버퍼를 이용해 거울, TV 등을 구현할 수 있습니다.

2. std::function과 C스타일 함수 포인터의 차이

더보기

std::function은 모든 Callable을 저장할 수 있는 객체입니다. 함수 포인터 뿐만 아니라 람다 함수, 함수 객체도 저장이 가능합니다. std::bind 함수와 함께 사용하면 파라미터와 함수를 묶어 더욱 유연하게 사용이 가능합니다.

 

반면, C스타일의 함수 포인터는 일반적인 함수만 저장 가능할 뿐 람다 함수, 함수 객체 등은 저장할 수 없습니다. 또한, 클래스의 멤버 변수를 사용할 땐 항상 인스턴스의 주소를 함께 사용해야 한다는 불편함이 있습니다.

3. 얕은 복사와 깊은 복사

더보기

얕은 복사란, 값을 그대로 복사하는 것입니다. 변수가 가지고 있는 값을 그대로 복사하기 때문에, 포인터 변수의 경우 가리키는 주소를 복사하는 객체와 복사되는 객체가 동시에 가리키게 됩니다. 이로 인해, 어느 한 쪽에서 가리키고 있던 메모리를 해제할 경우 댕글링 포인터 문제가 발생할 수 있습니다.

 

깊은 복사란, 얕은 복사의 위험성을 해결하기 위해 복사 생성자, 복사 대입자를 새롭게 정의하여 안전하게 사용하는 것입니다. 가리키고 있는 주소를 그대로 복사하는 것이 아니라 새롭게 메모리를 할당하여 복사되는 객체가 가리키고 있던 곳의 값을 복사하게 됩니다. 두 객체는 서로 다른 메모리를 가리키기 때문에, 어느 한 쪽에서 메모리를 해제하더라도 댕글링 포인터 문제가 발생하지 않습니다. 반면, 복사에 추가적인 연산이 필요한 만큼 성능 부분에선 얕은 복사에 비해 불리합니다.

4. 컨텍스트 스위칭이란?

더보기

컨텍스트 스위칭이란 CPU에서 작업 중인 프로세스, 스레드가 다른 프로세스, 스레드로 변환되는 과정을 의미합니다. 컨텍스트 스위칭은 오버헤드를 유발하기 때문에, 멀티 프로세스, 멀티 스레드를 설계할 때엔 컨텍스트 스위칭을 최소화하는 방향으로 설계해야 합니다.

5.  멀티스레드 프로그래밍이 유리한 경우는?

더보기

구현하고자 하는 작업을 병렬로 처리하는 것이 어렵지 않고, 큰 성능 향상을 가져올 수 있을 때 유리합니다. 예를 들어, 서버에서 게임을 실행하는 것과 유저의 접속을 기다리는 것은 멀티 스레드를 활용하여 병렬로 처리하는 것이 유리합니다. 

6. 빅 앤디언 방식과 비교했을 때, 리틀 앤디언 방식의 장점

더보기

데이터를 하위 주소부터 저장하기 때문에, 덧셈 연산 등 하위 주소의 연산을 먼저 실행하는 경우에 높은 성능을 기대할 수 있습니다.

7. 그래픽스 행렬 변환 중 W 나누기를 실행하는 단계는 어디인가?  W를 나누는 이유는?

더보기

W 나누기는 프로젝션 행렬을 곱한 이후에 실행하게 됩니다. 프로젝션 행렬을 곱하게 되면, 물체는 원평면에 투영됩니다. 원평면에 위치한 물체를 정규화된 좌표로 변환하기 위해 W 값으로 나눠 대상의 원근을 제거하게 됩니다.

8. 추상 클래스란?

더보기

순수가상함수가 선언된 클래스를 추상클래스라고 합니다. 해당 객체를 상속받을 객체들의 공통된 속성을 추려내어 미리 선언한 뒤, 추상 클래스를 상속받은 클래스는 해당 함수의 구체적인 내용을 정의하게 됩니다. 예를 들면, 모든 탈것은 움직인다는 기능이 있기 때문에, 부모 클래스에는 Move()라는 순수 가상함수를 선언하여 추상화할 수 있습니다. 이를 상속받은 모든 클래스는 반드시 Move()를 정의해야만 합니다.

9. 코루틴이란?

더보기

실행중인 함수를 원하는 시점에 멈추고 원하는 시점에 다시 실행할 수 있는 기능입니다. 일반적인 함수는 return 문을 만나기 전까지 모든 코드를 연속적으로 실행하지만, 코루틴을 사용한 함수는 원하는 시점에 중단한 뒤 원하는 시점에 돌아와서 작업을 이어나갈 수 있습니다. 코루틴을 통해 작업 사이의 대기 시간을 효율적으로 활용할 수 있기 때문에, 성능의 향상을 기대할 수 있습니다. 

10. Pawn 과 Character의 차이 (언리얼 엔진)

더보기

Character는 Pawn 클래스를 상속받은 클래스입니다. Pawn과 다르게 스켈레탈 매쉬 컴포넌트와 캡슐 컴포넌트, 캐릭터 무브먼트 컴포넌트를 소유하고 있습니다. Character 클래스는 인간형, 동물형 등 복잡한 움직임을 가진 오브젝트를 대상으로 기능을 확장하고 구체화한 클래스입니다. 

11. C++ 타입 캐스팅의 종류

더보기

C++의 타입 캐스팅은 static_cast, dynamic_cast, reinterpret_cast, const_cast 총 4가지가 있습니다. static_cast는 컴파일 타임에 이루어지는 타입 캐스팅입니다. 정수 자료형을 실수형으로 바꾸거나, 상속 관계의 캐스팅을 할 수 있습니다.

 

dynamic_cast는 런타임에 이루어지는 타입 캐스팅입니다. RTTI를 통해 컴파일 타임에 확보된 클래스 정보를 참조하여 캐스팅을 실행하며, 타입 캐스팅이 실패한 경우엔 nullptr을 반환하여 안전하게 사용할 수 있습니다. RTTI 테이블은 가상 함수 테이블의 가장 앞에 위치하기 때문에 가상함수를 포함하고 있지 않은 클래스는 dynamic_cast를 사용할 수 없습니다.

 

반면, static_cast의 경우는 캐스팅이 실패하여도 이를 식별할 방법을 제공하지 않기 때문에 안전하지 않지만 가상 함수 테이블이 필요하지 않고, dynamic_cast에 비해 속도가 빠르다는 장점이 있습니다.   

 

reinterpret_cast는 가장 광범위하게 사용할 수 있는 타입 캐스팅입니다. 포인터 간의 형변환도 가능하며, 값형과 포인터 간의 형변환도 가능합니다. 다만, 정수형을 포인터형으로 변환할 경우 정수값이 그대로 주소값으로 저장되기 때문에 잘못된 메모리 영역을 참조할 수 있다는 위험성이 있습니다.

 

const_cast는 const로 선언된 포인터 변수의 상수성을 일시적으로 제거해주는 타입 캐스팅입니다. 가리키고 있던 변수의 값이 지닌 상수성은 제거되지 않지만, 포인터 변수의 상수성은 제거되어 다른 대상을 가리킬 수 있게 됩니다.

12. 부동소수점이란?

더보기

부동소수점이란 컴퓨터에서 실수를 표현하는 대표적인 방법입니다. 부호부, 지수부, 가수부를 나누어 값을 저장하게 되며 지수부와 가수부의 곱에 부호를 적용하여 실수값을 표현하게 됩니다. 다만, 가수부의 크기를 넘어가는 소수를 표현할 수가 없어 실제 계산 결과와 비교했을 때 오차가 발생할 수 있다는 문제점이 있습니다.

13. 템플릿을 사용하는 이유와 장단점에 대해 설명하라.

더보기

템플릿을 사용하는 이유는 중복되는 코드를 방지하기 위해서입니다. 템플릿을 사용하지 않으면 동일한 기능의 함수를 여러 자료형을 대상으로 여러 번 선언해야 하지만, 템플릿을 사용하면 단 한번의 선언으로 해결할 수 있습니다.

다만, 헤더파일에서만 사용이 가능하고 컴파일 시간이 느려진다는 단점이 있습니다. 또한, 디버깅이 다소 어렵다는 단점이 있습니다.

14. 매크로 함수와 인라인 함수의 차이

더보기

매크로 함수는 사용자가 작성한 코드를 전처리기 단계에서 그대로 치환하여 컴파일됩니다. 반면, 인라인 함수는 컴파일 단계에서 함수가 호출된 곳에 함수의 내부 코드를 그대로 옮겨서 작성하게 됩니다. 인라인 함수는 이를 통해 함수 호출 과정의 오버헤드를 줄이는 것이 목적에 있지만, 매크로 함수는 가독성이나 개발 편의성에 목적을 둔다는 차이가 있습니다.

15. 시스템 콜이란?

더보기

운영체제는 유저가 마음대로 접근할 수 있는 유저 영역과 접근이 제한되어 있는 커널 영역으로 구분되어 있습니다. 커널 영역에 직접적으로 접근할 수는 없지만 커널 영역의 기능을 사용할 수 있도록 운영체제에선 인터페이스를 제공해줍니다. 이 인터페이스를 사용해 커널 영역에 필요한 기능을 요청하는 것을 시스템 콜이라고 합니다. 

16. 빌드 과정에 대해 설명하라.

더보기

빌드는 전처리기, 컴파일, 어셈블러, 링커 순으로 실행됩니다. 전처리기 과정에선 유저가 작성한 코드를 저수준 언어로 변환하기 위한 준비를 하게 됩니다. 주석, 공백 등 불필요한 요소를 제거하고 매크로 구문을 치환하고 헤더파일의 코드 전체를 소스파일 내에 추가하게 됩니다.  

 

다음은 컴파일 과정을 거치게 됩니다. 전처리기 과정을 거친 코드를 저수준의 어셈블리어로 번역하는 동시에 문법상의 오류를 검출하기도 합니다. 이를 통해 어셈블리어로 번역된 코드는 어셈블러 과정을 거치게 됩니다.

 

어셈블러 과정에선 어셈블리어를 0과 1로 이루어진 바이너리 코드로 변환하게 됩니다. 변환된 바이너리 코드는여러개의 오브젝트 파일로 저장됩니다. 

 

이렇게 여러개로 나뉘어진 오브젝트 파일은 링커 과정에서 하나의 프로그램으로 연결됩니다. 또한, 이 과정에서 정적 라이브러리가 프로그램에 함께 묶이게 됩니다. 하나로 묶인 프로그램은 exe파일로 저장되어 빌드를 완료하게 됩니다.

17. 포워드 렌더링과 디퍼드 렌더링에 대해 설명하라.

더보기

포워드 렌더링에선 물체를 그리는 동시에 빛 연산을 함께 실행하게 됩니다. 반면 디퍼드 렌더링은 빛 연산을 바로 수행하지 않고 색, 위치, 노말 정보를 별도의 렌더타겟에 저장한 뒤 마지막에 1번의 빛 연산을 실행하게 됩니다.

 

이로 인해 앞의 물체에 가려지는 부위처럼 빛 연산이 필요없는 곳에 빛 연산을 하지 않을 수 있으며, 빛 연산 횟수가 오브젝트 개수와 독립적이기 때문에 그려지는 오브젝트가 많을수록 빛 연산으로 인한 성능 감소를 줄일 수 있게 됩니다.

 

하지만, 물체의 수가 너무 적거나 앞의 물체에 가려지는 부분이 거의 없다면 포워드 렌더링보다 오히려 낮은 성능을 보이기도 하며, 깊이 버퍼를 제대로 활용할 수 없어 반투명 물체를 처리하기도 매우 힘들다는 단점도 잇습니다.

18. 네임스페이스란?

더보기

변수, 객체, 함수 등에 소속을 부여하는 문법입니다. 예를 들어, A객체와 B객체는 몬스터로 분류가 되고 C객체와 D객체는 플레이어로 분류가 된다면 각각 Monster와 Player라는 네임스페이스를 사용하여 객체를 분리할 수 있습니다. 이로 인해, 변수, 객체, 함수 등을 직관적으로 판단할 수 있게 됩니다.

19. 포인터를 사용하는 이유

더보기

포인터를 사용하면 지역 밖의 변수를 참조할  수 있습니다. 이를 통해 더욱 유연한 프로그래밍이 가능해집니다. 뿐만 아니라, 함수의 인자 등으로 객체를 전달할 때 포인터를 활용하면 복사가 발생하지 않아 성능상 이점이 있습니다.

20. 벡터의 외적과 내적

더보기

백터의 외적은 두 벡터가 포함된 평면의 법선 벡터를 구하기 위해 사용하는 공식이고, 벡터의 내적은 두 벡터의 방향이 얼마나 일치하는 가를 구하기 위해 사용하는 공식입니다.

 

벡터의 내적과 외적을 사용하면 대상이 기준점으로부터 어느 위치에 존재하는지를 구할 수 있습니다. 예를 들어, 몬스터와 플레이어가 충돌했을 때 몬스터가 플레이어를 기준으로 앞에 있는가 뒤에 있는가를 판별하기 위해 내적을 사용할 수 있으며 왼쪽에 있는가 오른쪽에 있는가를 판별하기 위해 외적을 사용할 수 있습니다.

21. 로컬 좌표와 월드 좌표에 대해 설명하라.

더보기

로컬 좌표란 특정 피봇을 (0,0,0)으로 기준잡는 좌표공간입니다. 부모로 설정된 대상이 있을 경우 부모의 좌표가 (0,0)이 되며, 설정되지 않았다면 월드 좌표와 동일한 좌표계에 위치하게 됩니다.

 

반면, 월드 좌표는 부모관계와 상관 없이 항상 정해진 위치를 (0,0,0)으로 기준잡는 좌표공간입니다. 모든 물체가 동일한 기준에 따라 위치하게 됩니다.

22. 포인터와 참조자의 차이

더보기

포인터는 대상의 주소값을 참조하며, 참조자는 대상 그 자체를 참조하게 됩니다. 포인터는 메모리 주소를 참조하는 만큼 8바이트의 고정 메모리 공간이 필요합니다. 반면, 참조자는 상황에 따라 컴파일 타임에 변수 자체로 치환되기 때문에 항상 8바이트의 크기를 가지는 것이 아니라 메모리를 아예 할당하지 않을 수도 있습니다. 

 

또한, 포인터는 const 타입으로 선언되지 않았다면 가리키는 대상을 언제든 바꿀 수 있지만 참조자는 가리키는 대상을 바꿀 수 없으며, 포인터는 nullptr을 가리킬 수 있지만 참조자는 반드시 메모리에 존재하는 대상을 가리켜야만 합니다. 

 

이처럼, 포인터보다는 참조자가 더욱 안전하고 메모리 부분에서도 이점이 있습니다. 그러므로 가능하다면 참조자를 사용하는 것이 좋지만 포인터만큼 유연하게 사용하는 것이 불가능하므로 상황에 맞게 적절하게 사용해야 합니다. 

23. RAII 패턴에 대해 설명하라.

더보기

RAII패턴이란, 자원의 할당과 해제를 객체의 생성, 소멸과 결합시키는 디자인 패턴입니다. 객체의 생성자에서 자원을 할당하고 소멸자에서 해제하도록 하여 객체에 접근 가능한 시점엔 항상 자원이 존재함을 보장할 수 있고, 객체에 접근이 불가능한 시점엔 항상 자원이 해제되었음을 보장할 수 있게 됩니다. 이로 인해, 안정적으로 메모리 관리를 할 수 있게 됩니다.

24. virtual을 생성자에 붙이지 않는 이유는?

더보기

virtual 키워드는 함수를 가상화하는 키워드입니다. 가상화된 함수는 가상함수 테이블을 기반으로 작동합니다. 하지만, 생성자가 호출되기 전에는 객체가 생성되기 전이기 때문에 가상함수 테이블 또한 존재하지 않습니다. 그로 인해, 생성자를 가상함수로 사용하는 것은 불가능합니다. 

 

또한, 생성자는 소멸자와 다르게 자식 클래스의 생성자가 항상 올바르게 호출되므로 가상함수로 사용할 이유도 없습니다.

25. 절차지향과 객체지향에 대해 설명하라.

더보기

절차지향은 컴퓨터의 시선으로 프로그래밍하고자 하는 것이며, 객체지향은 사람의 시선으로 프로그래밍하고자 하는 것입니다. 절차지향의 경우 컴퓨터에서 명령어를 처리하는 순서와 작성된 코드의 순서가 거의 일치하기 때문에, 성능 부분에서 유리함이 있습니다.

 

반면, 객체지향의 경우 객체를 오가며 코드가 실행되기 때문에 절차지향에 비해 느린 처리 속도를 가지고 있습니다. 하지만, 객체지향적으로 설계된 코드는 현실세계와 유사하게 설계되어 있어 절차지향적으로 설계된 코드에 비해 직관적으로 이해할 수 있다는 장점이 있습니다.

26. 임계영역이란?

더보기

멀티스레드 환경에서 여러 스레드가 동시에 같은 메모리 영역에 접근하게 되면 메모리에 저장된 데이터의 무결성을 훼손할 수 있습니다. 이러한 위험 가능성을 배제하기 위해 반드시 고립성을 보장해야 하는 메모리 영역을 임계영역이라고 합니다. 

27. 직렬화와 역직렬화에 대해 설명하라.

더보기

메모리에 저장되어 있는 데이터를 연속된 바이트 형태로 저장하는 것을 직렬화라고 합니다. 반대로, 연속된 바이트 형태로 저장되어 있던 것을 메모리에 저장하는 것을 역직렬화라고 합니다. 이는 네트워크 패킷에 사용되기도 하며, 세이브 데이터에 사용되기도 합니다. 

28. 프리컴파일헤더란? 장단점은?

더보기

프리컴파일헤더란 컴파일 속도를 높이기 위해 자주 사용되는 헤더를 하나의 헤더파일에 몰아서 선언해놓은 뒤, 이 헤더파일을 미리 컴파일하여 컴파일 타임에 제외하도록 하는 기법입니다.

 

자주 사용되는 헤더파일에 대해 여러 번 컴파일 하지 않아도 되기 때문에 컴파일 타임을 획기적으로 줄일 수 있지만 프리 컴파일 헤더에 포함된 헤더 파일의 내용이 변경될 때마다 결국 새로 컴파일 해야하기 때문에 변경될 가능성이 적은 헤더파일만 포함하는 것이 유리합니다.

29. FSM과 행동트리의 장단점은?

더보기

FSM은 매우 직관적이고 간단하다는 장점이 있습니다. 하지만, 상태의 개수가 늘어나고 상태 간의 관계가 복잡해질수록 가독성이 매우 떨어지고 설계가 복잡해진다는 단점이 있습니다. 이런 이유로, 복잡하지 않은 상태 변화를 구현할 때 용이합니다.

 

행동트리는 하나의 상태를 추가할 때 상태간 전환을 복잡하게 설계할 필요가 없이 특정 행동 노드 뒤에 필요한 노드만 추가하면 된다는 장점이 있습니다. 이로 인해, FSM보다 높은 가독성과 유지보수성을 지니게 됩니다. AI와 같이 복잡한 행동을 설계할 때 용이합니다.

30. 더블버퍼링 기법에 대해 설명하라.

더보기

더블버퍼링이란, 싱글버퍼링에서 발생할 수 있는 플리커링 문제를 해결하기 위한 기법입니다. 그림을 모니터에 출력하는 것과 CPU에서 그리는 작업이 독립되어 있어, 한 개의 버퍼를 사용할 경우 아직 그려지지 않은 버퍼 혹은 지우고 있는 버퍼를 출력하는 경우가 발생할 수 있습니다.

 

이를 해결하기 위해 두개의 버퍼를 사용하는 것이 더블버퍼링입니다. 버퍼에 그리는 작업이 완료되었을 때 모니터에 출력중인 버퍼와 교체하도록 하여 항상 완전히 그려진 버퍼만 모니터에 출력하도록 합니다. 이로 인해, 플리커링 현상을 해결할 수 있습니다.

31. 뮤텍스와 스핀락의 차이

더보기

뮤텍스와 스핀락은 공통적으로 멀티 스레드의 동기화를 달성하기 위한 기법입니다. 두 기법 모두 임계영역을 보호하기 위해, 하나의 스레드에 대해서만 작업을 허용하게 됩니다. 

 

하지만, 뮤텍스의 경우엔 임계영역에 접근하기 위해 대기중인 스레드를 모두 슬립상태로 만들기 때문에 작업 권한을 얻은 이후 컨텍스트 스위칭이 발생하게 됩니다.

 

반면, 스핀락의 경우엔 대기중인 스레드를 무한 루프와 같은 반복문을 통해 작업 상태를 유지하기 때문에, 작업 권한을 얻은 이후에도 컨텍스트 스위칭이 발생하지 않게 됩니다. 하지만, 대기중인 스레드가 계속 작업 상태를 유지하기 때문에 대기 스레드가 CPU를 독점하는 상황이 발생할 수 있습니다. 그렇기 때문에, 대기 시간이 매우 짧은 경우에 한해서 사용하는 것이 좋습니다.

32. 트라이 자료구조에 대해 설명하라.

더보기

문자열을 효율적으로 탐색하기 위한 자료구조입니다. 한 노드에 모든 문자를 배열 형태로 저장한 뒤, 존재하는 문자의 경우 다음 노드와 포인터로 연결하여 문자열을 저장합니다.

 

트리 구조로 되어있기 때문에 문자열의 탐색이 매우 빠르지만, 노드 별로 모든 문자를 배열 형태로 저장하는 탓에 메모리를 많이 사용한다는 단점이 잇습니다.

 

 

간만에 재밌게 한 게임!

소울라이크 특유의 느낌을 가장 잘 살린 게임인듯?

회차 플레이를 선호하지는 않지만 다음에 2회차도 한 번 해봐야겠다. 진엔딩이 궁금해졌다

STL에선 벡터, 리스트, 맵, 셋, 해시 맵 등 다양한 자료구조를 제공해준다. 

그 중에서 벡터와 리스트는 자주 비교되는 자료구조이다. 두 자료구조의 차이를 알아보자.

 

std::vector

std;:vector는 배열 기반의 자료구조이다. 배열 기반인 만큼 모든 데이터가 메모리 상에 연속적으로 위치한다.

벡터의 특징은 대부분 메모리에 연속적으로 위치한다는 특성에서 시작된다.

 

먼저, 벡터는 임의 접근(random access)이 가능하다. 임의 접근이란 쉽게 말해서 원하는 원소에 바로 접근하는 것이다.

std::vector<int> Arr;

for(int i = 0; i < 10; i++)
{
    Arr.push_back(i);
}

위 코드와 같이 Arr이라는 벡터에 10개의 원소를 삽입했다고 해보자.

이 때, 5번째의 원소에 접근하고 싶다면, Arr[4]와 같이 인덱스를 통해 접근할 수 있다. 

(인덱스는 0번부터 시작하므로 5번째 원소는 4번 인덱스에 위치하게 된다.)

 

왜 이런 접근이 가능할까? 원소가 위치하는 주소를 특정할 수 있기 때문이다.

배열은 선언되면, 배열의 시작점을 주소값으로 보유하게 된다.

 

1번 원소 = 시작주소

2번 원소 = 시작 주소 + 원소의 메모리 크기

3번 원소 = (시작 주소 + 원소의 메모리 크기 * 2)

 

모든 원소의 메모리 크기는 일정하기 때문에, 위와 같은 방식으로 원소의 위치를 특정할 수 있는 것이다.

인덱스가 0번부터 시작하는 이유도 이 때문이다.

 

시작 주소 + (인덱스 * 원소의 메모리 크기) 의 공식을 세우려면 인덱스가 0부터 시작해야 하기 때문이다.

 

이처럼 데이터가 메모리에 연속적으로 위치한다는 특성 덕분에, 높은 캐시적중률을 자랑하기도 한다.

(캐시 적중률을 모른다면 여기를 참고)

 

하지만, 메모리에 연속적으로 위치한다는 특성때문에 몇 가지 문제점도 발생한다.

먼저 원소의 삽입과 삭제에 있어서 추가적인 연산이 발생한다는 것이다.

특정 원소를 삭제하게 되면, 배열 안에 비어있는 공간이 발생하게 된다. 이 빈 공간을 메꾸지 않으면 해당 인덱스에 접근할 때 어떤 오류가 발생할지 예측할 수 없으며 메모리 연속적이라는 특성도 사라지게 된다.

 

이로 인해, 벡터는 중간의 데이터를 삭제할 경우 모든 원소를 앞으로 끌어오게 된다. 이 과정에서 추가적인 연산이 발생하게 된다. 중간에 데이터를 삽입할 때에도 마찬가지이다. 원소를 모두 뒤로 밀어내야 하기 때문에 추가적인 연산이 발생하게 된다.

 

원소의 개수가 많아질수록 삽입 삭제 연산이 무거워지고 오버헤드가 커질 것이다.

 

두 번째 문제는 데이터를 메모리에 연속적으로 위치하게 하기 위해서,메모리 공간을 미리 확보해야 한다는 것이다. 10개의 원소를 삽입하려면 원소의 크기 * 10만큼 메모리 공간이 존재해야 하며 그 만큼을 선점해야 한다.

 

그런데, 메모리 공간은 원소 10개의 크기만큼만 확보하였는데 11번째 원소가 삽입된다면 어떻게 될까?

 

일반적인 배열의 경우엔 아예 불가능한 연산이지만, 벡터는 새롭게 메모리 공간을 동적으로 할당한 뒤 기존의 데이터를 모두 옮기고 기존의 메모리 영역을 해제하는 방식으로 해결한다. 즉, 기존에 할당한 메모리 공간보다 더 큰 공간을 필요로 하게 되면 메모리 할당 -> 데이터 복사 -> 메모리 해제의 과정을 거치므로 아주 커다란 오버헤드가 발생하게 된다.

 

이를 해결하기 위해선 어떻게 해야할까? 메모리 크기를 충분히 크게 할당해놓는 것이다. 벡터엔 reserve라는 멤버함수가 존재한다. 이 reserve를 통해 미리 충분한 크기의 메모리를 할당한 뒤에 그 범위 안에서 사용하게 되면, reserve를 호출할 때 메모리 할당 -> 데이터 복사 -> 메모리 해제의 연산을 1번 실행하고 이후에는 추가적으로 실행되지 않는다.

(할당해놓은 범위를 넘어서지 않는다는 조건 하에)

 

하지만, 메모리를 너무 많은 크기로 할당해놓게 되면 메모리 내부 단편화가 발생하므로 어느 정도의 메모리가 필요한지 미리 예측해놓고 필요한 만큼만 할당하는 것이 중요하다.

 

벡터의 특징을 정리해보자.

 

핵심 : 데이터가 메모리에 연속적으로 위치한다.

 

장점 1 : 임의 접근이 가능하다.

장점 2 : 캐시 적중률이 매우 높다.

 

단점 1 : 삽입, 삭제에 추가적인 연산이 필요하다.

단점 2 : 동적 할당과 데이터 이동으로 인한 오버헤드를 줄이기 위해 메모리를 미리 할당해야 한다.

(사실 Reserve, Resize 등의 함수를 한 번만 호출하면 되기 때문에 단점이라고 하기 애매하긴 하다.) 

 

std::list

std::list는 포인터 기반의 자료구조이다.

 

백터는 미리 메모리를 할당해 놓고, 그 안에서 순차적으로 데이터를 저장하는 반면 list는 새로운 원소가 추가될 때마다 새롭게 동적 할당을 하여 포인터로 이전의 원소와 연결하는 과정으로 데이터를 저장하게 된다.

 

이러한 특성 때문에, list는 데이터의 삽입과 삭제가 간단하다.

 새로운 원소를 추가하게 되면, 메모리를 동적으로 할당한 뒤에 포인터만 연결하는 과정을 거치면 되기 때문에, 원소가 어느 위치에 삽입되더라도 데이터를 뒤로 밀거나 앞으로 당기는 등의 추가적인 연산이 필요하지 않게 된다.

 

또한, 원소가 추가되는 만큼만 동적으로 할당하기 때문에 벡터처럼 미리 메모리를 할당해놓을 필요가 없고, 메모리 내부 단편화 또한 발생하지 않는다.

 

하지만, 원소가 추가될 때마다 계속 동적 할당을 실행하기 때문에 원소의 삽입 자체가 벡터에 비해 낮은 성능을 보이게 된다. 또한, 데이터가 메모리에 연속적으로 위치하지 않기 때문에 캐시 적중률 또한 매우 낮은 편이다. 

 

그 뿐만이 아니라, 포인터로 연결되어 있다는 특성 때문에 특정 원소의 주소값을 바로 유추할 수가 없어 임의 접근이 불가능하다. 첫 번째 원소부터 포인터를 타고 가며 접근하는 순차 접근만 가능하다.

 

리스트의 특징을 정리해보자.

 

핵심 : 데이터가 메모리에 연속적으로 위치하지 않고, 포인터를 이용해 논리적으로 연결되어 있다. 

 

장점 1 : 삽입, 삭제가 간단하다.

장점 2 : 메모리를 미리 할당할 필요가 없다. 

 

단점 1 : 임의 접근이 불가능하다.

단점 2 : 원소가 삽입될 때마다 동적 할당의 오버헤드가 발생한다.

단점 3 : 캐시 적중률이 매우 낮다.



삽입 삭제는 list가 vector보다 더 유리할까?

삽입, 삭제 연산을 빅오 표기법으로 표현하면 std::vector의 시간 복잡도는 O(n)이며, std::list의 시간 복잡도는 O(1)이다. 이 때문에, 리스트의 장점을 인터넷에서 찾아보면 삽입 삭제가 빠르다는 말이 항상 나온다.

 

하지만, 실제로는 그렇다고 단정지을 수는 없다. 

 

std::vector가 std::list에 비해 삽입 삭제가 느리다고 말하는 이유는 std::vector에선 데이터를 삽입, 삭제할 때 데이터를 뒤로 밀거나 앞으로 당기는 추가적인 연산이 필요하고, std::list에선 그러한 과정이 필요하지 않기 때문이다.

 

하지만 생각해보면 std::list는 데이터를 삽입, 삭제할 때 메모리를 할당하거나 해제하는 과정이 필요하다.

즉, std::list에서도 삽입, 삭제 시에 포인터만 끊고 연결하는 것이 아니라 메모리의 할당과 해제라는 추가적인 연산이 필요하다는 것이다.

 

그러므로 std::vector와 std::list의 삽입 삭제의 연산 속도를 비교하려면 원소를 앞으로 당기거나 뒤로 미는 연산 속도와 동적 할당, 메모리 해제의 속도를 비교해야 하는 것이다. 

 

또한, 시간 복잡도는 항상 최악의 경우만을 가정하지만, 실무에선 최악의 경우만 발생하는 것이 아니기 때문이다.

최선의 경우만 발생하는 로직에서도 과연 list가 vector보다 유리할까?

 

이를 직접 비교해보도록 하겠다.

#include <vector>
#include <list>
#include <iostream>
#include <chrono>

int main()
{
	std::vector<int> Vector;
	Vector.reserve(1000);

	std::list<int> List;
	
	{
		std::chrono::system_clock::time_point InsertStart = std::chrono::system_clock::now();

		for (int i = 0; i < 1000; i++)
		{
			Vector.insert(Vector.begin(), i);
		}

		std::chrono::system_clock::time_point InsertEnd = std::chrono::system_clock::now();
		std::chrono::nanoseconds VectorInsertDuration = std::chrono::duration_cast<std::chrono::nanoseconds>(InsertEnd - InsertStart);

		std::cout << "VectorInsertDuration : " << VectorInsertDuration << std::endl;

		std::chrono::system_clock::time_point EraseStart = std::chrono::system_clock::now();

		for (int i = 0; i < 1000; i++)
		{
			Vector.erase(Vector.begin());
		}

		std::chrono::system_clock::time_point EraseEnd = std::chrono::system_clock::now();
		std::chrono::nanoseconds VectorEraseDuration = std::chrono::duration_cast<std::chrono::nanoseconds>(EraseEnd - EraseStart);

		std::cout << "VectorEraseDuration : " << VectorEraseDuration << std::endl;
	}

	{
		std::chrono::system_clock::time_point InsertStart = std::chrono::system_clock::now();

		for (int i = 0; i < 1000; i++)
		{
			List.insert(List.begin(), i);
		}

		std::chrono::system_clock::time_point InsertEnd = std::chrono::system_clock::now();
		std::chrono::nanoseconds ListInsertDuration = std::chrono::duration_cast<std::chrono::nanoseconds>(InsertEnd - InsertStart);

		std::cout << "ListInsertDuration : " << ListInsertDuration << std::endl;

		std::chrono::system_clock::time_point EraseStart = std::chrono::system_clock::now();

		for (int i = 0; i < 1000; i++)
		{
			List.erase(List.begin());
		}

		std::chrono::system_clock::time_point EraseEnd = std::chrono::system_clock::now();
		std::chrono::nanoseconds ListEraseDuration = std::chrono::duration_cast<std::chrono::nanoseconds>(EraseEnd - EraseStart);

		std::cout << "ListEraseDuration : " << ListEraseDuration << std::endl;
	}
}

 

위는 1000개의 원소를 삽입, 삭제하는 속도를 비교하는 코드이다.

벡터에서 최악의 경우를 확인하기 위해 가장 앞에 원소를 삽입, 삭제하도록 하였다.

삽입,삭제 속도만을 비교하기 위해 reserve는 미리 해주었다.

 

1000개의 원소에 대한 결과는 아래와 같다.

최악의 경우임에도 불구하고 vector가 list에 비해 속도가 앞서는 것을 볼 수 있다.

 

10000개의 원소에 대해서도 체크해보자.

10000개로 원소가 늘어나니 list가 두 배 이상 앞서는 것을 볼 수 있다.

 

하지만, 벡터의 삽입 삭제를 맨 뒤에서 하도록 하면 어떨까?

위는 1000개의 원소에 대한 것이고, 아래는 10000개의 원소에 대한 것이다.

최선의 경우엔 벡터가 list보다 압도적으로 빠른 것을 알 수 있다.

 

원소의 개수가 많아질수록 최악의 경우엔 삽입, 삭제가 list가 훨씬 유리하다고 볼 수 있지만 최선의 경우엔 list가 vector보다 훨씬 빠른 것을 알 수 있다. 즉, 삽입 삭제가 list가 vector보다 항상 유리하냐고 한다면 답은 아니라는 것이다.

 

특정 로직에 사용할 자료구조를 선택할 때, 삽입 삭제가 자주 발생한다는 이유로 list를 선택하는 것은 정답이 아니다. 

현재 내가 구현하고 있는 로직이 어느 위치에서 삽입, 삭제가 자주 발생하는지 혹은 원소의 개수는 어느정도인지 파악한 뒤에 사용하는 것이 중요하다. 

 

또한 캐시 적중률의 차이로 순회의 경우 벡터가 압도적으로 빠르기 때문에, 삽입, 삭제 여부만 판단하는 것이 아니라 순회의 속도도 고려해야 한다. 아래는 10000개 원소의 순회 속도이다.

 

벡터 vs 리스트

두 자료구조의 차이를 표로 정리해보겠다.

벡터 리스트
원소가 메모리에 연속적으로 위치한다. 원소가 메모리에 불연속적으로 위치한다.
임의 접근이 가능하다. 임의 접근이 불가능하다.
삽입, 삭제 시 원소를 당기거나 미는 등의 작업이 필요하다. 삽입, 삭제 시 메모리의 할당과 해제가 발생한다
원소 저장에 필요한 메모리를 미리 할당해주어야 한다. 원소 저장에 필요한 메모리를 미리 할당하지 않아도 된다.
캐시 적중률이 높다. 캐시 적중률이 낮다.

 

CPU는 컴퓨터를 구성하는 하드웨어중에서 가장 빠른 작업 처리 속도를 가지고 있다. 반면, 메인 메모리는 CPU에 비해 매우 낮은 작업 처리 속도를 가지고 있다.

 

두 하드웨어간의 속도 차이 때문에, CPU에서 메인 메모리에 직업 접근을 하게 되면 CPU에선 병목 현상이 발생하게 된다.

메인 메모리에 접근하여 데이터를 얻어오는 작은 작업의 시간이 CPU에선 수도 없이 많은 명령어를 처리할 수 있는 시간이기 때문이다.

 

이를 해결하기 위해 존재하는 것이 캐시메모리이다.

 

캐시 메모리

 

CPU는 필요한 데이터를 가져오기 위해서 메인 메모리에 접근을 할 수 밖에 없다. 하지만, 그 횟수를 최소화하기 위해 캐시 메모리를 사용하게 된다. CPU에서 메모리의 특정 주소에 접근하게 되면 그 주소를 시작으로 일정 크기만큼의 데이터를 한 번에 가져와 캐시 메모리에 저장하게 된다.

 

캐시 메모리는 메인 메모리에 비해 속력이 매우 빠르기 때문에, CPU에선 캐시 메모리에서 데이터를 탐색하는 것이 훨씬 효율적이기 때문이다.

 

하지만, 캐시 메모리는 메인 메모리에 비해 용량이 매우 작기 때문에 필요한 데이터가 캐시 메모리에 없을 경우 CPU는 다시 메인 메모리에 접근하여 캐시 메모리의 데이터를 새로 갱신하게 된다. 위에서 말했듯이, 메인 메모리에 접근하는 것은 성능상 손해가 크기 때문에 최소화하는 것이 좋다. 그렇다면, 캐시 메모리에 원하는 데이터가 최대한 많이 있을수록 메인 메모리에 접근하는 횟수가 적어질 것이며 성능의 손해를 최소화할 수 있을 것이다.

 

이 것을 캐시 적중률이라고 한다.

 

캐시 적중률

 

캐시 메모리에 현재 A,B,C,D,E,F 이렇게 6개의 데이터가 있다고 해보자.

CPU에서 A, B, C, X 이렇게 4개의 데이터를 필요로 한다면, A,B,C는 캐시 메모리에서 찾을 수 있지만, X의 경우엔 캐시 메모리에서 찾을 수가 없다.

 

이 경우, A,B,C 의 경우 캐시 적중이라고 표현하며, X에 대해선 캐시 미스(miss)라고 표현하게 된다.

캐시 적중이 얼마나 많이 발생하는가를 캐시 적중률이라고 표현하는 것이다.

 

그렇다면, 캐시 미스가 최대한 적게 발생해야 메인 메모리에 접근해서 캐시 메모리를 새로 채우는 과정이 적게 발생할 것이며 성능상 손해가 적게 될 것이다. 즉, 캐시 적중률이 높을수록 성능상 손해를 적게 보고 있다고 말할 수 있는 것이다.

 

그렇다면, 캐시 적중률을 높이기 위해선 어떻게 해야할까?

 

캐시 적중률을 높이는 법

방법은 간단하다. 관련된 데이터를 메모리에 연속적으로 위치하게 하면 된다. 캐시 메모리를 채우는 방식은 CPU가 메인 메모리에 접근한 주소를 시작으로 특정 크기만큼 연속적으로 가져오는 방식이기 때문이다.

 

예를 들어, 특정 자료구조의 원소를 순회한다고 해보자. 1번째 원소와 2번째 원소가 메모리 상에서 먼 위치에 있다면, 각 원소에 접근할 때마다 캐시를 새로 채워야 하는 상황이 발생할 수 있다. 반면, 모든 원소가 순차적으로 붙어있다면 자료구조의 원소가 캐시 메모리에 존재할 가능성이 매우 높아지며, 캐시 적중률을 최대로 높일 수 있는 것이다.

 

이 때문에 배열과 리스트의 성능 차이가 발생한다. 배열은 그 구조상 모든 데이터가 메모리에 연속적으로 위치하게 되지만, 리스트의 경우 모든 원소가 동적으로 할당되어 포인터로 연결되어 있기 때문에 메모리의 다양한 위치에 분산되어 있다. 그렇기 때문에, 동일한 시간 복잡도의 연산이라도 배열 기반의 자료구조가 포인터 기반의 자료구조보다 일반적으로 우수한 성능을 보이게 된다.

 

배열 뿐만이 아니라 메모리 풀링 또한 캐시 적중률을 높이는데 도움이 될 수 있다. 미리 메모리를 할당해놓고 필요한 만큼 가져다 쓰는 방식이기 때문에, 모든 데이터가 메모리 상에 연속적으로 위치하게 된다. 메모리 풀링은 캐시 적중률을 높일 뿐만 아니라 메모리 외부 단편화를 해결하는 데도 큰 도움이 되기 때문에 잘 활용하면 성능상 큰 이점을 얻을 수 있을 것이다.

 

 

특정 마을에서 파티가 열린다고 했을 때, 모든 학생은 본인이 사는 마을에서 파티가 열리는 마을로 갔다가 다시 원래 마을로 돌아올 것이다.

 

이 때, 사는 마을 -> 파티 마을 -> 사는 마을의 경로를 통해 이동하게 될 것이다.

이 경로를 통해 이동할 때, 가장 큰 시간을 소비하는 학생의 소요 시간을 출력하면 된다.

 

모든 길은 단방향이기 때문에, 사는 마을 -> 파티 마을로 갈 때 소요되는 시간과 파티 마을 -> 사는 마을로 갈 때 소요되는 시간은 서로 다를 수 있다는 것을 주의해야 한다.

 

문제 풀이

먼저, 이 문제는 다익스트라를 이용하면 쉽게 해결할 수 있다.

 

사는 마을 -> 파티 마을로 가는 경로에 대해 다익스트라 알고리즘을 적용하여 최소 비용을 구하고,

파티 마을 -> 사는 마을로 가는 경로에 대해 다익스트라 알고리즘을 적용하여 최소 비용을 구할 수 있다.

 

두 경우의 비용을 더해서 각 학생의 소요 시간을 구할 수 있을 것이다.

 

다만, 입력으로 주어진 값을 평범하게 다익스트라 알고리즘을 적용하게 되면 시간초과가 발생할 수 있다.

 

왜냐하면, 1번 마을을 시작점으로 다익스트라 알고리즘을 수행해야 하고, 2번 마을을 시작점으로 다익스트라 알고리즘을 수행해야 하고, N번 마을까지 모두 다익스트라 알고리즘을 수행해야 한다.

 

파티 마을에서 원래 마을로 돌아올 떄엔 시작점이 파티 마을로 고정이므로 1번의 다익스트라로 해결할 수 있지만, 기존의 마을에서 파티 마을로 가는 경우에는 마을의 개수만큼 다익스트라 알고리즘을 수행해야 할 것이다.

 

하지만, 주어진 입력을 뒤집는다면 한 번의 다익스트라를 통해 답을 구할 수 있게 된다.

 

예를 들어, 5개의 마을이 있고 5번 마을에서 파티가 열린다고 해보자.

 

1번 마을 -> 5번 마을 : 비용 5

2번 마을 -> 5번 마을 : 비용 3

 

이렇게 입력이 주어진다면,

 

5번 마을 -> 1번 마을 : 비용 5

5번 마을 -> 2번 마을 : 비용 3 

 

이렇게 마을의 시작점과 도착점을 뒤집어서 저장하는 것이다.

 

이 문제에선 특정 마을에서 파티 마을을 향해 가는 경로의 방향이 중요한 것이 아니라 비용이 중요한 것이므로 뒤집어서 파티 마을 -> 특정 마을로 해석해도 문제가 발생하지 않을 것이다.

 

그러므로, 입력을 그대로 저장한 뒤 파티 마을을 시작점으로 다익스트라 알고리즘을 1번 수행하고 입력을 반대로 저장한 뒤 파티 마을을 시작점으로 다익스트라 알고리즘을 1번 수행하게 되면 총 2번의 다익스트라 알고리즘으로 답을 구할 수 있게 되는 것이다.

 

코드 풀이

void Dijk(std::vector<int>& _Cost, std::vector<std::vector<std::pair<int, int>>>& _Link, int _StartNode)
{
    std::priority_queue<std::pair<int, int>, std::vector<std::pair<int, int>>, std::greater<std::pair<int, int>>> Queue;
    
    Queue.push({ 0, _StartNode });

    while (Queue.size() > 0)
    {
        int CurNode = Queue.top().second;
        int CurCost = Queue.top().first;

        Queue.pop();

        if (_Cost[CurNode] < CurCost)
        {
            continue;
        }

        for (int j = 0; j < _Link[CurNode].size(); j++)
        {
            int NextNode = _Link[CurNode][j].second;
            int NextCost = _Link[CurNode][j].first;

            int NextCostSum = CurCost + NextCost;

            if (NextCostSum < _Cost[NextNode])
            {
                _Cost[NextNode] = NextCostSum;
                Queue.push({ NextCostSum, NextNode });
            }
        }
    }
}

다익스트라 알고리즘 구현 코드이다. 우선 순위 큐를 활용하여 구현하였다.

int NumMan = 0;
int NumRoad = 0;
int PartyNode = 0;

std::cin >> NumMan >> NumRoad >> PartyNode;

std::vector<std::vector<std::pair<int, int>>> Link(NumMan);
std::vector<std::vector<std::pair<int, int>>> ReverseLink(NumMan);

for (int i = 0; i < NumRoad; i++)
{
    int Start = 0;
    int End = 0;
    int Cost = 0;
    
    std::cin >> Start >> End >> Cost;

    Link[Start - 1].push_back({ Cost, End - 1});
    ReverseLink[End - 1].push_back({ Cost, Start - 1 });
}

위에서 말했던 것처럼 정방향으로 입력을 저장하고, 역방향으로도 입력을 저장해주었다.

std::vector<int> Costs(NumMan, INT_MAX);
std::vector<int> ReverseCosts(NumMan, INT_MAX);

다익스트라를 사용해 갱신할 비용 배열도 정방향, 역방향 2개를 만들어주었다.

Dijk(Costs, Link, PartyNode - 1);
Dijk(ReverseCosts, ReverseLink, PartyNode - 1);

정방향, 역방향으로 다익스트라를 1번씩 수행해 주었다.

int MaxCost = -1;

Costs[PartyNode - 1] = 0;
ReverseCosts[PartyNode - 1] = 0;

for (int i = 0; i < NumMan; i++)
{
    int CurCost = Costs[i] + ReverseCosts[i];
    MaxCost = std::max(MaxCost, CurCost);
}

std::cout << MaxCost;

return 0;

정방향 비용은 파티 마을 -> 시작 마을의 비용이며 역방향 비용은 시작 마을 -> 파티 마을의 비용이다.

두 비용을 합한 값이 가장 큰 값을 구해주었고, 그 값을 출력해주었다.

 

여기서 중요한 것은 파티가 시작되는 마을의 값을 0으로 초기화해주어야 한다는 것이다.

다익스트라 알고리즘을 수행하게 되면, 파티 마을도 의도치 않게 비용이 갱신될 수 있으므로 반드시 제외해주어야 한다.

 

 

문제 풀이

더보기
#include <iostream>
#include <vector>
#include <queue>
#include <climits>

void Init()
{
    std::ios::sync_with_stdio(false);
    std::cin.tie(nullptr);
    std::cout.tie(nullptr);
}

void Dijk(std::vector<int>& _Cost, std::vector<std::vector<std::pair<int, int>>>& _Link, int _StartNode)
{
    std::priority_queue<std::pair<int, int>, std::vector<std::pair<int, int>>, std::greater<std::pair<int, int>>> Queue;
    
    Queue.push({ 0, _StartNode });

    while (Queue.size() > 0)
    {
        int CurNode = Queue.top().second;
        int CurCost = Queue.top().first;

        Queue.pop();

        if (_Cost[CurNode] < CurCost)
        {
            continue;
        }

        for (int j = 0; j < _Link[CurNode].size(); j++)
        {
            int NextNode = _Link[CurNode][j].second;
            int NextCost = _Link[CurNode][j].first;

            int NextCostSum = CurCost + NextCost;

            if (NextCostSum < _Cost[NextNode])
            {
                _Cost[NextNode] = NextCostSum;
                Queue.push({ NextCostSum, NextNode });
            }
        }
    }
}

int main()
{
    Init();

    int NumMan = 0;
    int NumRoad = 0;
    int PartyNode = 0;
    
    std::cin >> NumMan >> NumRoad >> PartyNode;
    
    std::vector<std::vector<std::pair<int, int>>> Link(NumMan);
    std::vector<std::vector<std::pair<int, int>>> ReverseLink(NumMan);

    for (int i = 0; i < NumRoad; i++)
    {
        int Start = 0;
        int End = 0;
        int Cost = 0;
        
        std::cin >> Start >> End >> Cost;

        Link[Start - 1].push_back({ Cost, End - 1});
        ReverseLink[End - 1].push_back({ Cost, Start - 1 });
    }

    std::vector<int> Costs(NumMan, INT_MAX);
    std::vector<int> ReverseCosts(NumMan, INT_MAX);

    Dijk(Costs, Link, PartyNode - 1);
    Dijk(ReverseCosts, ReverseLink, PartyNode - 1);

    int MaxCost = -1;

    Costs[PartyNode - 1] = 0;
    ReverseCosts[PartyNode - 1] = 0;

    for (int i = 0; i < NumMan; i++)
    {
        int CurCost = Costs[i] + ReverseCosts[i];
        MaxCost = std::max(MaxCost, CurCost);
    }

    std::cout << MaxCost;

    return 0;
}

 

 

 

규칙에 맞게 게임을 했을 때, 플레이어의 점수를 예측하는 문제이다.

A의 카드 숫자가 B의 카드 숫자로 나누어진다면 A의 패배, B의 승리이며 B의 카드 숫자가 A의 카드 숫자로 나누어진다면 A의 승리, B의 패배이다.

 

문제 풀이

한 쪽의 숫자가 다른 한 쪽의 숫자로 나누어진다면 승패가 결정나는 게임이기 때문에, 배수관계를 통해 승패를 파악할 수 있다. 하지만, 최대 10만명의 플레이어가 참가한 상황에서 모든 플레이어간의 대결 경우를 계산하게 되면 50억번의 경우를 계산해야한다. 

 

이를 해결하기 위해, 플레이어가 가진 숫자에 접근하여 계산하는 것이 아니라 숫자 자체의 존재 여부를 파악하도록 하였다. 무슨 말이냐면, 입력받은 플레이어의 카드 숫자 배열 외에 100만개의 boolean 배열을 만들어 인덱스에 해당하는 숫자가 있는지를 저장하는 배열을 하나 만들었다는 의미이다.

 

예를 들어, (2, 4, 6, 8)의 카드가 현재 플레이어들이 보유하고 있는 카드라면, boolean 배열 A에서 A[2] = true, A[4] = true, A[6] = true, A[8] = true 인 것이다. 이를 제외한 나머지 모든 값은 false이다.

 

이렇게 값을 별도로 저장한 이유는 연산 횟수를 줄이기 위해서이다. 플레이어 간의 대결을 이중 반복문으로 구현하여 모든 대결을 하게 되면, N^2의 시간복잡도를 가진 연산을 해야한다. 반면, 위의 boolean 배열을 통해 대결을 하면 NlogN의 시간복잡도를 보유하게 된다. 뭐가 다른지 아래 식을 통해 보자.

//플레이어간의 대결을 하는 경우
for(int i = 0; i < 플레이어의 수; i++)
{
    for(int j = i + 1; j < 플레이어의 수; j++)
    {
        //대결
    }
}

//boolean배열을 통해 숫자끼리 대결을 하는 경우
std::vector<bool> Array(1000001);

for(int i = 0; i < 플레이어의 수; i++)
{
    int Num = 플레이어의 카드 숫자;
    
    for(int j = Num * 2; j < 1000001; j += Num)
    {
        if(Array[j] == true)
        {
            //대결
        }
    }
}

 

두 방식의 차이는 내부에 있는 반복문의 차이이다.

 

위에선 모든 플레이어에 대해 반복문을 돌고 있다. j를 1씩 증가하면 반복문을 돌기 때문에 10만명의 플레이어가 있다고 했을 땐 99999 + 99998 + 99997 + 99996 .... + 1 번의 반복문을 돌게 될 것이다. 대략 50억번의 반복문을 돌게 된다.

 

반면, 아래의 방식은 j를 Num씩 증가시키며 배수에 대해서만 체크하고 있기 때문에, 1000000 + 500000 + 333333 + 250000 + 200000 + 133333 ..... + 1 번의 반복문을 돌게 된다. 대략 1400만번의 반복문을 돌게 된다.

 

이를 통해, 숫자간의 대결을 진행한 뒤, 점수를 저장해야 하는데 플레이어의 카드 숫자 배열에선 해당 숫자로 접근할 수가 없다. (다시 순차탐색 과정을 거쳐야 하는데, 그렇게 하면 결국 시간초과가 발생할 수 밖에 없게 된다.)

 

그러므로, 점수를 저장할 배열을 하나 더 만들어 주어야 한다.

boolean 배열과 동일하게 1000000개의 원소를 저장하도록 배열을 만들어서 각 인덱스에 맞는 숫자의 점수를 저장하면 된다.

 

코드 풀이

int PlayerSize = 0;
std::cin >> PlayerSize;

std::vector<int> PlayerNums(PlayerSize);
std::vector<bool> isExist(1000001);
std::vector<int> Score(1000001);

for (int i = 0; i < PlayerSize; i++)
{
    std::cin >> PlayerNums[i];
    isExist[PlayerNums[i]] = true;
}

입력과 초기화이다.

 

먼저, Player의 수를 입력받은 뒤 그 수에 맞게 PlayerNum을 생성하였고, 각 플레이어의 카드 숫자를 입력받아주었다. 

isExist는 해당 숫자가 있는가를 저장하는 배열이며, Score는 점수를 저장하는 배열이다.

isExist는 플레이어의 점수를 저장한 뒤, 바로 값을 갱신해주었다.

for (int i = 0; i < PlayerSize; i++)
{
    int CurNum = PlayerNums[i];

    for (int i = CurNum * 2; i < 1000001; i += CurNum)
    {
        if (isExist[i] == true)
        {
            Score[CurNum]++;
            Score[i]--;
        }
    }
}

탐색과정이다.

먼저, 현재 플레이어의 숫자를 기준으로 그 배수에 대해 모두 탐색하였다.

CurNum의 배수가 존재하는 숫자라면, 대결을 실행하였다.

대결은 항상 나의 점수 + 1, 상대방의 점수 -1로 끝난다. (상대방의 숫자가 나의 숫자의 배수이기 때문에)

for (int i = 0; i < PlayerSize; i++)
{
    std::cout << Score[PlayerNums[i]] << " ";
}

return 0;

모든 대결이 끝난 뒤, 각 플레이어의 카드 숫자에 해당하는 Score의 값을 출력해주면 된다.

 

코드 전문

더보기
#include <iostream>
#include <vector>
#include <algorithm>
#include <cmath>

void Init()
{
	std::ios::sync_with_stdio(false);
	std::cin.tie(nullptr);
	std::cout.tie(nullptr);
}

int main()
{
    Init();

    int PlayerSize = 0;
    std::cin >> PlayerSize;

    std::vector<int> PlayerNums(PlayerSize);
    std::vector<bool> isExist(1000001);
    std::vector<int> Score(1000001);
    
    for (int i = 0; i < PlayerSize; i++)
    {
        std::cin >> PlayerNums[i];
        isExist[PlayerNums[i]] = true;
    }

    for (int i = 0; i < PlayerSize; i++)
    {
        int CurNum = PlayerNums[i];

        for (int i = CurNum * 2; i < 1000001; i += CurNum)
        {
            if (isExist[i] == true)
            {
                Score[CurNum]++;
                Score[i]--;
            }
        }
    }

    for (int i = 0; i < PlayerSize; i++)
    {
        std::cout << Score[PlayerNums[i]] << " ";
    }

    return 0;
}

 

 

각 학생들이 싫어하는 사람의 목록이 주어졌을 때, 모든 학생이 싫어하는 사람과 같은 팀이 되지 않도록 팀을 배분하면 된다.

5
1 3
1 5
2 1 4
1 3
1 2

 

위와 같이 입력이 주어진다면 아래와 같이 여러개의 정답이 나올 수 있다.

 

백팀 : 1, 2, 4 / 청팀 : 3, 5

백팀 : 3, 5 / 청팀 : 1, 2, 4

백팀 : 1, 4, 5 / 청팀 : 2, 3

백팀 : 2, 3 / 청팀 : 1, 4, 5

(실제로는 더 있을 수도 있다.)

 

이 중, 하나의 경우를 구한 뒤 출력하면 된다.

 

문제 풀이

각 학생들이 싫어하는 학생의 목록을 확인한 뒤, 하나씩 팀에 배분해주면 된다.

위에서 보여주었던 입력에 대해 생각해보자.

 

1번 학생은 3번 학생을 싫어한다.

=> 청팀 : 1 / 백팀 : 3

 

2번 학생은 5번 학생을 싫어한다.

=> 청팀 : 1, 2 / 백팀 : 3, 5 

 

3번 학생은 1번 학생, 4번 학생을 싫어한다.

=> 청팀 : 1, 2, 4 / 백팀 : 3, 5 

 

4번 학생은 3번 학생을 싫어한다.

=> 청팀 : 1, 2, 4 / 백팀 : 3, 5 (이미 팀 배분이 되어 있어, 변화가 없다.)

 

5번 학생은 2번 학생을 싫어한다.

=> 청팀 : 1, 2, 4 / 백팀 : 3, 5 (이미 팀 배분이 되어 있어, 변화가 없다.)

 

이렇게, 선택된 학생이 싫어하는 학생의 목록을 기반으로 팀에 차례대로 배분해주면 된다.

 

풀이 코드

std::vector<std::vector<int>> Hates;

std::vector<bool> isDevided;

//-1이면 청, 1이면 백
std::vector<int> DevidedTeam;

std::vector<int> BlueTeam;
std::vector<int> WhiteTeam;

 

먼저, 이렇게 5개의 벡터를 선언해주었다.

 

Hates는 인덱스에 해당하는 학생이 싫어하는 학생들의 목록을 저장한 벡터이다.

isDevided는 인덱스에 해당하는 학생이 싫어하는 학생들을 팀에 배분했는가를 담고 있는 벡터이다.

DevidedTeam은 학생들의 현재 팀을 저장한 벡터이다. 0이면 아직 팀 배분이 되지 않은 것이고, -1이면 청팀 1이면 백팀이다. 

BlueTeam과 WhiteTeam은 각각 팀에 포함된 학생들의 목록이다.

void Recursive(int _CurIndex)
{
    if (isDevided[_CurIndex] == true)
    {
        return;
    }

    isDevided[_CurIndex] = true;
    
    if (DevidedTeam[_CurIndex] == 0)
    {
        DevidedTeam[_CurIndex] = -1;
    }

    for (int i = 0; i < Hates[_CurIndex].size(); i++)
    {
        int CurHateIndex = Hates[_CurIndex][i];

        if (DevidedTeam[CurHateIndex] != 0)
        {
        	continue;
        }

        DevidedTeam[CurHateIndex] = -DevidedTeam[_CurIndex];
        
        Recursive(CurHateIndex);
    }
}

학생들이 현재 싫어하는 목록을 학생의 반대 팀에 배치한 뒤, 배치된 학생을 기준을 기준으로 함수를 재귀적으로 호출하여 다시 해당 학생이 싫어하는 학생들을 모두 반대 팀에 배치하도록 하였다.

싫어하는 학생을 배치한 학생의 인덱스에 대해 isDevided 를 true로 만들어, 같은 학생에 대해 여러 번 실행되지 않도록 하였다.

isDevided.resize(NumMan, false);
DevidedTeam.resize(NumMan, 0);

for (int i = 0; i < NumMan; i++)
{
    Recursive(i);
}

위의 함수를 모든 학생에 대해 실행하였다.

BlueTeam.reserve(NumMan);
WhiteTeam.reserve(NumMan);

for (int i = 0; i < NumMan; i++)
{
    if (DevidedTeam[i] == -1)
    {
        BlueTeam.push_back(i + 1);
    }
    else if (DevidedTeam[i] == 1)
    {
        WhiteTeam.push_back(i + 1);
    }
}

std::cout << BlueTeam.size() << "\n";

for (int i = 0; i < BlueTeam.size(); i++)
{
    std::cout << BlueTeam[i] << " ";
}

std::cout << "\n";
std::cout << WhiteTeam.size() << "\n";

for (int i = 0; i < WhiteTeam.size(); i++)
{
    std::cout << WhiteTeam[i] << " ";
}

학생들이 배치된 팀에 따라, 각 벡터에 삽입하였고, 해당 벡터의 사이즈와 원소를 모두 출력해주었다.

 

코드 전문

#include <iostream>
#include <vector>

void Init()
{
    std::ios::sync_with_stdio(false);
    std::cin.tie(nullptr);
    std::cout.tie(nullptr);
}

std::vector<std::vector<int>> Hates;

std::vector<bool> isDevided;

//-1이면 청, 1이면 백
std::vector<int> DevidedTeam;

std::vector<int> BlueTeam;
std::vector<int> WhiteTeam;

void Recursive(int _CurIndex)
{
    if (isDevided[_CurIndex] == true)
    {
        return;
    }

    isDevided[_CurIndex] = true;

    if (DevidedTeam[_CurIndex] == 0)
    {
        DevidedTeam[_CurIndex] = -1;
    }

    for (int i = 0; i < Hates[_CurIndex].size(); i++)
    {
        int CurHateIndex = Hates[_CurIndex][i];

        if (DevidedTeam[CurHateIndex] != 0)
        {
            continue;
        }

        DevidedTeam[CurHateIndex] = -DevidedTeam[_CurIndex];

        Recursive(CurHateIndex);
    }
}

int main()
{
    Init();

    int NumMan = 0;
    std::cin >> NumMan;

    Hates.resize(NumMan);

    for (int i = 0; i < NumMan; i++)
    {
        int NumHate = 0;
        std::cin >> NumHate;

        Hates[i].reserve(NumHate);

        for (int j = 0; j < NumHate; j++)
        {
            int HateMan = 0;
            std::cin >> HateMan;

            Hates[i].push_back(HateMan - 1);
        }
    }

    isDevided.resize(NumMan, false);
    DevidedTeam.resize(NumMan, 0);

    for (int i = 0; i < NumMan; i++)
    {
        Recursive(i);
    }

    BlueTeam.reserve(NumMan);
    WhiteTeam.reserve(NumMan);

    for (int i = 0; i < NumMan; i++)
    {
        if (DevidedTeam[i] == -1)
        {
            BlueTeam.push_back(i + 1);
        }
        else if (DevidedTeam[i] == 1)
        {
            WhiteTeam.push_back(i + 1);
        }
    }

    std::cout << BlueTeam.size() << "\n";

    for (int i = 0; i < BlueTeam.size(); i++)
    {
        std::cout << BlueTeam[i] << " ";
    }

    std::cout << "\n";
    std::cout << WhiteTeam.size() << "\n";

    for (int i = 0; i < WhiteTeam.size(); i++)
    {
        std::cout << WhiteTeam[i] << " ";
    }

    return 0;
}

 

 

 

N개의 앱이 켜있다고 해보자.

 

각 앱은 m_1, m_2, m_3....m_n의 메모리를 사용하고 있으며, 각 앱을 비활성화하게 되면 각 c_1, c_2, c_3, c_4...c_n의 비용이 발생하게 된다.

 

M바이트를 확보하기 위해, 임의의 앱을 비활성화 했을 때 발생하는 비용의 최소값을 구하자.

 

문제 풀이

문제 자체는 간단한 배낭 문제이다. 다만, 일반적인 배낭 문제와는 다르게 최소값을 구하는 문제이기 때문에 떠올리는 것이나 구현하는 것이 다소 어려울 수도 있었을 것 같다.

 

배낭문제는 보통 개수와 수치(무게, 비용 등)을 이용해서 2차원 배열을 구성하게 된다. 위의 문제에서 개수, 메모리를 이용해서 2차원 배열을 구성하게 되면 최대 1000000000개의 원소를 가지는 배열을 만들어야 하므로 메모리 초과가 발생할 수 밖에 없다. 그러므로 2차원 배열은 (개수, 비용)을 이용해서 구성할 것이다.

 

그렇다면, 배열의 원소는 어떤 값을 저장해야 할까?

문제에서 구하는 것은 M바이트를 확보하기 위한 최소 비용이다.

 

반대로, N의 비용을 사용했을 때 최대한으로 확보할 수 있는 메모리를 구하면 어떨까?

문제에서 주어진 최대 비용은 10000이다. (100개의 앱 * 100의 비용)

 

그러므로, N의 비용을 사용했을 때 최대한으로 확보할 수 있는 메모리를 배열에 저장한 뒤 0 ~ 10000 까지의 비용 중 확보할 수 있는 메모리가 입력값보다 큰 비용을 구한 뒤, 그 값의 최소를 출력하면 되는 것이다.

 

배열의 원소를 갱신하는 것은 일반 배낭 문제와 동일하게 하면 된다.

 

풀이 코드

int NumApp = 0;
int TargetMem = 0;

std::cin >> NumApp >> TargetMem;

std::vector<int> AppMemories(NumApp + 1, 0);
std::vector<int> AppCosts(NumApp + 1, 0);

for (int i = 1; i <= NumApp; i++)
{
    std::cin >> AppMemories[i];
}

for (int i = 1; i <= NumApp; i++)
{
    std::cin >> AppCosts[i];
}

먼저, 입력을 모두 받아주자.

앱의 개수와 확보할 메모리를 입력받은 뒤, 각 앱의 메모리 사용량과 비활성화시 발생하는 비용을 모두 저장해주었다.

std::vector<std::vector<int>> DP(NumApp + 1, std::vector<int>(10001, 0));

다음으로 배낭 문제 해결에 사용할 DP 배열을 만들어주었다.

발생할 수 있는 최대 비용은 10000이므로, 배열의 size를 위와 같이 만들어주었다.

for (int i = 1; i <= NumApp; i++)
{
    for (int j = 0; j <= 10000; j++)
    {
        if (j >= AppCosts[i])
        {
            DP[i][j] = std::max(DP[i - 1][j], DP[i - 1][j - AppCosts[i]] + AppMemories[i]);
        }
        else
        {
            DP[i][j] = DP[i - 1][j];
        }
    }
}

일반적인 배낭문제와 동일하게 반복문을 돌려주면 된다.

각 원소의 값은 i개의 앱을 비활성화하여 j의 비용이 발생했을 때 확보할 수 있는 최대 메모리양이다.

int Answer = 0;

for (int i = 0; i <= 10000; i++)
{
    if (DP[NumApp][i] >= TargetMem)
    {
        Answer = i;
        break;
    }
}

이후, i를 0부터 10000까지 반복문을 돌며 TargetMem을 확보할 수 있는 최소 비용을 구해주면 된다.

std::cout << Answer;

답을 출력해주면 된다.

 

 

코드 전문

더보기
#include <iostream>
#include <vector>
#include <algorithm>

void Init()
{
    std::ios::sync_with_stdio(false);
    std::cin.tie(nullptr);
    std::cout.tie(nullptr);
}

int main()
{
    Init();

    int NumApp = 0;
    int TargetMem = 0;
    
    std::cin >> NumApp >> TargetMem;

    std::vector<int> AppMemories(NumApp + 1, 0);
    std::vector<int> AppCosts(NumApp + 1, 0);

    for (int i = 1; i <= NumApp; i++)
    {
        std::cin >> AppMemories[i];
    }

    for (int i = 1; i <= NumApp; i++)
    {
        std::cin >> AppCosts[i];
    }

    std::vector<std::vector<int>> DP(NumApp + 1, std::vector<int>(10001, 0));

    for (int i = 1; i <= NumApp; i++)
    {
        for (int j = 0; j <= 10000; j++)
        {
            if (j >= AppCosts[i])
            {
                DP[i][j] = std::max(DP[i - 1][j], DP[i - 1][j - AppCosts[i]] + AppMemories[i]);
            }
            else
            {
                DP[i][j] = DP[i - 1][j];
            }
        }
    }

    int Answer = 0;

    for (int i = 0; i <= 10000; i++)
    {
        if (DP[NumApp][i] >= TargetMem)
        {
            Answer = i;
            break;
        }
    }

    std::cout << Answer;

    return 0;
}

+ Recent posts