Python에서는 변수와 객체가 메모리에 어떻게 저장되는지에 대해 처음부터 깊이 배우는 경우가 많지 않다.

나도 예전에 풀스택 쪽을 잠깐 본 적이 있었는데, 그 때 JavaScript(자바스크립트)를 배우면서 메모리 할당과 데이터 저장 방식에 대해 배우게 되었다. JavaScript는 원시 데이터 타입과 참조 타입을 구분하고, 얕은 복사와 깊은 복사의 차이도 눈에 띄게 다르게 작용하기 때문에 메모리 참조에 대해 더 많은 주의가 필요하다.

그러나 Python은 메모리 관리를 자동으로 처리해 주고, 변수와 객체는 직관적으로 사용할 수 있도록 설계되었기 때문에, 복잡한 메모리 관리를 사용자가 직접 다룰 일이 거의 없고, 따라서 초보자가 쉽게 접근할 수 있는 편이다.

이러한 차이는 언어의 철학과 설계 방식에서도 기인한다고 한다. JavaScript는 클라이언트 측에서 웹 성능과 자원을 관리해야 하므로 메모리 참조와 관련된 개념을 초반에 배워야 하는 경우가 많고, 파이썬에서는 성능 최적화보다는 코드의 가독성과 간결함을 중요시하는 경향이 있기 때문에, 메모리 관리에 대한 부담이 상대적으로 적게 느껴질 수 있다.

본 포스팅에서는 참조복사/얕은복사/깊은복사의 차이점을 다루고, 파이썬의 copy 라이브러리를 통해 각 복사의 차이점을 구현해 보도록 하겠다.


파이썬 기본 참조 복사

# 원본 리스트
a = ["떡볶이", "튀김", "순대"]

# a를 b에 복사
b = a

# b의 첫 번째 항목을 변경
b[0] = "라면"

# 결과 확인
print("a 리스트:", a)  # ['라면', '튀김', '순대']
print("b 리스트:", b)  # ['라면', '튀김', '순대']

b=a 구절에서 b가 a와 같은 메모리 위치를 참조하기 때문에 a, b는 실제로는 같은 객체가 된다.


  •  

copy 라이브러리

Python의 copy 라이브러리는 객체의 얕은 복사(shallow copy) 깊은 복사(deep copy)를 지원하는 유용한 라이브러리로, 특히 다차원 리스트나 중첩된 데이터 구조를 복사해야 할 때 유용하게 사용할 수 있다.

copy와 deepcopy의 차이점

copy 라이브러리에는 copy()와 deepcopy() 두 가지 주요 기능이 있다.

  • copy.copy(): 얕은 복사로, 최상위 레벨의 객체만 복사함. 즉, 리스트 안의 리스트와 같은 중첩 객체의 경우, 내부 객체는 같은 메모리 참조를 가지게 된다.
    • a를 복사해서 b를 만들었는데, b를 수정하면 a도 같이 수정됨
  • copy.deepcopy(): 깊은 복사로, 모든 객체의 중첩 레벨을 따라가며 재귀적으로 새 객체를 생성하여 완전히 독립적인 복사본을 만든다. 따라서 복사된 객체가 원본 객체와 독립적으로 수정될 수 있다.
    • a를 복사해서 b를 만들었는데, b를 수정해도 a가 변하지 않고 원본을 유지함

얕은 복사(shallow copy)

얕은 복사(shallow copy)는 새로운 객체를 생성하지만, 그 객체 안에 들어 있는 요소들은 원본 객체와 같은 참조를 가리킨다. 즉, 리스트의 최상위 레벨은 복사되지만, 내부의 중첩된 객체들은 원본을 참조하기 때문에.

깊은 복사(deepcopy)의 필요성

 

  • 리스트, 딕셔너리 등 다차원 데이터를 복사할 때 원본 데이터의 변경이 복사본에 영향을 주지 않도록 해야 할 때 - 즉, 원본 객체를 보호하고자 할 때 사용(deepcopy)
    • 작업 중간에 데이터를 보존하여 나중에 초기 상태로 복원해야 할 때 deepcopy를 사용하면 데이터 일관성을 유지할 수 있음
  • 특히 다수의 객체가 동일한 데이터를 참조하는 상황에서 유용

예제 코드

import copy

# 원본 리스트
a = [[["떡볶이"], "튀김"], "순대"]

b = a                 # 1. 참조 복사
c = copy.copy(a)      # 2. 얕은 복사
d = copy.deepcopy(a)  # 3. 깊은 복사

# 복사 방식의 차이 확인
b[0][0][0] = "라면"   # 참조 복사한 b의 첫 번째 요소의 첫 번째 리스트를 수정
c[0][1] = "김밥"      # 얕은 복사한 c의 첫 번째 리스트의 두 번째 요소를 수정
d[1] = "만두"         # 깊은 복사한 d의 두 번째 요소를 수정

# 결과 출력
print("원본 리스트 a:", a)  ### [[['라면'], '김밥'], '순대']

원본 리스트 [[['떡볶이'], '튀김'], '순대'] 가 [[['라면'], '김밥'], '순대']가 되었다.

  1. b[0][0][0]="라면"을 통해 참조 복사한 객체의 "떡볶이"를 "라면"으로 바꾸었다. 참조 복사로 인해 원본 객체의 떡볶이가 라면이 되었다.
  2. c[0][1]="김밥"을 통해 얕은 복사한 객체의 "튀김"을 "김밥"으로 바꾸었다. 얕은 복사로 인해 원본 객체의 튀김도 김밥이 되었다.
  3. d[1]="만두"를 통해 깊은 복사한 객체의 "순대"를 "만두"로 바꾸었다. 깊은 복사로 인해 원본 객체는 "순대"를 유지하게 되었다.

마무리

  • 참조 복사와 얕은 복사, 깊은 복사의 차이를 이해하고 
  • 필요에 따라 copy 라이브러리를 활용하여
  • 데이터의 독립성을 보장하고
  • 코드의 안정성을 높이는
  • 멋진 엔지니어가 되도록 하겠다 :)

 

 

서론

취준생에서 현업 AI 엔지니어로 전향하면서, 실무에 필요한 다양한 기술들을 빠르게 습득하고 있는 요즘입니다. 특히, 회사의 리눅스 서버에 접속해 코드를 작성하고 협업하는 일은 일상이 되었는데요.
다행히 저는 입사 전부터 리눅스 커맨드라인과 Git을 어느 정도 다룰 줄 알았기에, 이런 기초적인 지식이 업무에 큰 도움이 되고 있습니다. 아주 간단해서 입사 후 빠르게 배울 수도 있는 것들이기도 하지만, 이런 기본적인 것들을 모르고 시작하면 제일 불편한 건 결국 나 자신이더라고요.
특히 AI 엔지니어로서 GPU를 다루는 일은 필수적입니다. 그래서 저 역시 여러 개의 GPU를 병렬처리 하는 방법, 메모리 배치와 사용량 등에 대해 항상 고민하려고 노력하고 있어요. 그러면서 자연스럽게 서버에 연결된 GPU 사용량과 메모리 상태를 실시간으로 추적하는 것이 필요해졌는데요. 다양한 툴을 비교해보며 저에게 맞는 최적의 도구를 찾아가고 있습니다.
오늘은 이처럼 GPU 모니터링을 더욱 쉽게 해줄 수 있는 다양한 명령어 중에서, 제가 가장 자주 사용하는 3가지를 알려드리고 비교해 보려고 합니다.
참고로 저는 VScode를 사용해 작업합니다!


1. nvidia-smi

GPU 모니터링의 근본, 바로 nvidia-smi입니다. 엔비디아가 제공하는 이 툴은 GPU 사용량, 메모리 상태, 온도 등을 보여주는데요. 저는 간단하게 GPU ID 확인이나 특정 프로세스를 kill할 때 주로 사용합니다. 가장 기본이지만 또 근본이라서 다른 화려한 툴이 많지만 동시에 함께 자주 사용하게 되는 그런 툴입니다 :)
 

nvidia-smi

간단한 명령어로 실행할 수 있습니다.

nvidia-smi 출력 예시 (1)

위 이미지에서는 nvidia-smi 명령어를 통해 출력된 GPU 상태 정보가 나와 있는데요.
각 GPU의 명칭, 온도, 메모리 사용량, 전력 소모량 등 주요 정보를 확인할 수 있고, 시스템에 설치된 모든 GPU의 상태를 한눈에 보여주는 특징이 있습니다. 아주 기본적인 GPU 모니터링 방법이지요.

nvidia-smi 출력 예시 (2)

 특히 nvidia-smi 출력 하단의 Processes 섹션에서 현재 GPU 메모리를 사용 중인 프로세스 목록, 그리고 각 프로세스의 PID(프로세스 ID)를 확인할 수 있습니다. 특정 프로세스가 과도하게 GPU 메모리를 사용하거나 문제가 발생한 경우, kill [PID] 명령어로 해당 프로세스를 종료하여 메모리를 해제할 수 있습니다.

# 실행
nvidia-smi
nvidia-smi -l 2 (2초마다 업데이트)
watch nvidia-smi (실시간 모니터링)

 

  • -l 옵션은 nvidia-smi 자체에서 제공하는 기능으로, 지정한 초 단위로 GPU 상태를 자동으로 반복해서 업데이트해줍니다.
  • watch 명령어는 리눅스 기본 명령어로, 특정 명령을 주기적으로 실행하여 그 출력을 갱신하는 기능을 합니다. nvidia-smi 뿐만 아니라 모든 명령에 사용할 수 있습니다.

2. nvitop

화려한 모니터링의 시작! nvitop입니다.

nvitop을 처음 접하면 "우와, 이게 가능해?"라는 생각이 들 만큼 시각적으로 무척 화려합니다. 저도 오로지 nvidia-smi만 알다가, 처음으로 nvitop을 설치하고 실행했을 때, 무척 놀랐던 기억이 있습니다.
nvitop을 사용하면 실시간으로 GPU 사용량을 모니터링할 수 있어 매우 편리합니다. 비슷한 툴로 nvtop도 있는데, 둘 다 nvidia-smi에서 보여주는 기본 정보를 보다 직관적으로 시각화합니다. 특히 많은 GPU를 동시에 모니터링할 때 빛을 발합니다.

pip install nvitop
conda install -c conda-forge nvitop

# 실행
nvitop

위와 같이 2가지 방법으로 설치, 간단하게 실행할 수 있습니다. 가상환경을 사용중이시라면 conda를 이용해서 설치하고 실행하시는 것을 추천드립니다.


3. gpustat

gpustat은 GPU 상태를 실시간으로 모니터링할 수 있는 Python 기반 도구입니다. GPU 사용량, 메모리 사용량, 온도 등을 명령어 한 번으로 간편하게 확인할 수 있습니다. nvidia-smi 명령어를 기반으로 GPU 정보를 수집하기 때문에 NVIDIA GPU에서만 사용 가능한 특징이 있습니다.
아래는 gpustat 깃허브 링크입니다.
https://github.com/wookayin/gpustat

GitHub - wookayin/gpustat: 📊 A simple command-line utility for querying and monitoring GPU status

📊 A simple command-line utility for querying and monitoring GPU status - wookayin/gpustat

github.com

pip install gpustat
conda install -c conda-forge gpustat

# 실행
gpustat
gpustat -i 
gpustat -i 2 (2초마다 업데이트)
gpustat -cp (CPU사용량까지 포함해서 업데이트)

다양한 옵션들

  • --color : Force colored output (even when stdout is not a tty)
  • --no-color : Suppress colored output
  • -u, --show-user : Display username of the process owner
  • -c, --show-cmd : Display the process name
  • -f, --show-full-cmd : Display full command and cpu stats of running process
  • -p, --show-pid : Display PID of the process
  • -F, --show-fan : Display GPU fan speed
  • -e, --show-codec : Display encoder and/or decoder utilization
  • -P, --show-power : Display GPU power usage and/or limit (draw or draw,limit)
  • -a, --show-all : Display all gpu properties above
  • --id : Target and query specific GPUs only with the specified indices (e.g. --id 0,1,2)
  • --no-processes : Do not display process information (user, memory) (#133)
  • --watch, -i, --interval : Run in watch mode (equivalent to watch gpustat) if given. Denotes interval between updates
    • -i 옵션은 인터벌(interval)을 설정하는 옵션으로, 주기적으로 GPU 상태를 업데이트를 해주기 때문에 실시간 모니터링을 매우 편리하게 할 수 있습니다!
  • --json : JSON Output (#10)
  • --print-completion (bash|zsh|tcsh) : Print a shell completion script. See #131 for usage.

마무리

여러 가지 화려한 툴들이 있지만, 저는 gpustat -i를 가장 자주 사용하고 있습니다. 한 눈에 들어오는 간단하고 깔끔한 인터페이스가 무척 편리하고, GPU를 멈추거나 죽일 때도 금방 빠르게 확인할 수 있어서 속이 시원하거든요. nvitop처럼 화려한 툴도 좋지만, 아직까지는 실용성과 간편함 면에서 gpustat이 제 마음 속 GPU 모니터링 툴 1위입니다.
물론, 정답은 없습니다. 취향에 따라, task에 따라 다양한 툴을 비교해보며 본인에게 맞는 최적의 도구를 선택해서 사용하시면 되겠습니다.
그럼 여러분도 GPU 모니터링, 이제 걱정 없이 화이팅 하세요! 👾 감사합니다 :)

CPU란 무엇인가

CPU는 Central Processing Unit의 약자로, '중앙 처리 장치'라고도 불립니다. 인간으로 따지면 두뇌에 해당하는 역할을 하는데요. 컴퓨터의 두뇌로서 모든 명령을 처리하고 중요한 계산을 하는 역할을 합니다. 사용자가 "1+2를 계산해라"라는 명령을 내렸을 때, 이 명령을 받아 계산을 실행하고 "3"이라는 결과값을 도출해 내는 게 CPU가 하는 일이죠.

저는 올해 초 맥북 프로를 구매했는데요. 칩(SoC: System on a Chip)으로는 M3 Pro를 골랐습니다. M3 Pro에는 12코어 CPU(6개의 고성능 코어, 6개의 고효율 코어)와 18코어 GPU가 포함되어 있습니다. 여기서 말하는 '12코어'란 CPU 내부에 12개의 처리 유닛(코어)가 있다는 뜻인데요. 쉽게 설명하자면, 컴퓨터가 여러 가지 작업을 동시에 할 때 이 코어들이 나눠서 일을 처리하는 것이죠. 각 코어는 독립적으로 작업을 처리할 수 있기 때문에, 코어가 많을수록 한 번에 더 많은 작업을 할 수 있어요. 예를 들어서, 제가 구매한 M3 Pro보다 비싼 모델인 M3 Max의 경우 16코어 CPU와 40코어 GPU가 탑재되어 있기 때문에 더욱 성능이 좋습니다.

이렇게 코어가 많을수록 CPU가 '병렬 처리'라는 것을 잘 해낼 수 있어서, 컴퓨터는 멀티태스킹이나 고사양 작업을 잘 해낼 수 있게 됩니다. 병렬 처리는 여러 작업을 독립적으로, 동시에 처리하는 능력을 의미하는데요. 병렬 처리를 활용하면 작업을 빠르게, 효율적으로 완수할 수 있어요. 

그렇지만 병렬 처리가 항상 100% 반드시 효율적인 것은 아닙니다. 작업을 병렬로 나누는 과정에서 오히려 시간이 더 소모될 수도 있고요. 소프트웨어가 병렬 처리를 지원하지 않으면 모든 코어를 제대로 활용하지 못할 수 있습니다. 따라서 CPU 자원을 잘 활용할 수 있도록 프로그램을 구성하는 것이 무척 중요하다고 볼 수 있겠습니다.


CPU와 GPU의 차이

CPU는 마치 스포츠카와 같습니다. 한두 사람을 아주 빠르게 운송할 수 있죠. 반면에 GPU는 고속버스와 같습니다. 속도는 스포츠카에 미치지 못하지만, 한 번에 훨씬 더 많은 사람을 태울 수 있어요. 만약 같은 단위 시간 동안 운송할 수 있는 사람의 수가 차량의 성능에 해당한다면, 고속버스에 해당하는 GPU의 성능이 더 좋다고 볼 수 있는 것이죠.

그러나 CPU와 GPU는 마치 스포츠카와 고속버스처럼 그 용도와 목적, 성질이 무척 다릅니다. GPU는 CPU보다 병렬 처리를 수십 배 잘해낼 수 있지만 하나의 작업을 빠르게 실행하는 속도는 느리고, CPU는 OS를 실행하는 데 필요한 여러 가지 기능을 다양하게 탑재하고 있는 등, 차이점이 많아요. 따라서 내가 실행하고자 하는 작업이 CPU와 GPU - 둘 중 무엇에 더 최적화 되어 있는지 잘 판단하고 알맞은 것을 선택하는 것이 중요합니다.


CPU 집약적 task

AI 엔지니어로서 머신 러닝, 딥러닝 모델을 많이 다뤄온 저에게는 항상 GPU에 대한 고민이 먼저 이루어지곤 했습니다. 딥러닝 모델은 대량의 데이터와 수십억 개의 파라미터를 다루고, 행렬 곱셈(Matrix Multification/Dot Product) 같은 대규모 수학 연산을 자주 하기 때문에, 많은 메모리 대역폭과 고성능 연산 능력을 가지고 있는 GPU의 병렬 처리가 필수적으로 요구됩니다. 그래서 GPU 자원이 턱없이 부족한 환경에서 공부를 해오던 저에게는 좋은 사양의 GPU에 대한 갈증이 항상 있어 왔습니다.

그런데 이번에 처음으로 CPU 집약적 task, CPU의 병렬 처리에 대한 고민을 하게 되었어요. 저는 현재 맡은 업무에서 빅데이터셋의 품질 관리를 위해 문서간의 '편집 유사도(Edit Similarity / Levenshtein Distance)'를 계산하는 알고리즘과 코드를 작성하고 있는데요. 전체 과정에서 논리적이고 순차적인 연산이 매우 중요하기 때문에,이는 GPU보다 CPU를 잘 활용해야 하는 task에 해당했습니다. 또한 해당 과제는 가지고 있는 데이터 내의 모든 문서쌍에 대해 편집 유사도를 계산해야 하는 O(n * m)의 시간 복잡도를 가진 작업으로, 엄청난 연산량이 요구되는 만큼 CPU의 병렬 처리가 반드시 필요한 상황이었습니다.


CPU 병렬 처리를 지원하는 라이브러리

파이썬에는 CPU의 병렬 처리를 지원하는 다양한 라이브러리가 존재합니다. 저는 그 중에서 아래와 같이 4개의 라이브러리를 선택, 직접 비교해 보았습니다.

[1] Multiprocessing

  • Python의 표준 라이브러리로 추가 설치 없이 사용할 수 있음
  • CPU의 여러 코어를 활용해 병렬 프로세싱을 쉽게 구현할 수 있음. 각 프로세스는 독립된 메모리 공간을 사용하기 때문에, 안전한 병렬 처리가 가능함. 단순한 병렬 작업을 수행할 때, 특히 CPU 집약적인 작업에 적합함
  • docs : https://docs.python.org/ko/3/library/multiprocessing.html
from multiprocessing import Pool

def f(x):
    return x*x

if __name__ == '__main__':
    with Pool(5) as p:
        print(p.map(f, [1, 2, 3]))
        
# [1, 4, 9]

[2] Ray

  • Ray는 기본적으로 CPU 자원을 병렬로 활용할 수 있도록 설계되어 있으며, 여러 CPU 코어를 이용해 동시에 여러 작업을 실행할 수 있게 해 줌.CPU, GPU 자원을 자동으로 관리하고 최적화된 성능을 제공.
  • 기존 코드를 수정할 필요 없이 @ray.remote 데코레이터를 사용해 매우 쉽게 병렬 작업을 처리할 수 있어 사용이 편리함
  • 로컬 환경뿐만 아니라 클러스터 환경에서 대규모 데이터 처리를 효율적으로 분산 실행할 수 있음
  •  docs : https://docs.ray.io/en/latest/ray-core/walkthrough.html?_gl=1*1e3lt4l*_ga*ODU4NzkwMDUxLjE3MjY3OTQxMjE.*_up*MQ..
# Define the square task.
@ray.remote
def square(x):
    return x * x

# Launch four parallel square tasks.
futures = [square.remote(i) for i in range(4)]

# Retrieve results.
print(ray.get(futures))
# -> [0, 1, 4, 9]

[3] Dask

  • 대규모 데이터 분석과 병렬 처리를 위한 동적 작업 스케줄러를 제공하는 라이브러리
  • Pandas, NumPy와 호환이 되므로 PandasNumPy 작업을 확장할 필요가 있을 때 매우 유용함
  • docs : https://docs.dask.org/en/stable/
import dask.array as da

x = da.random.random((10000, 10000), chunks=(1000, 1000))
result = x.mean().compute()  # Dask로 병렬 처리
print(result)

[4] ProcessPoolExecutor

  • Python의 concurrent.futures 모듈에 있는 고급 병렬 처리 라이브러리로, 다중 프로세스를 쉽게 관리할 수 있는 기능을 제공
  • multiprocessing과 유사하지만 더 높은 수준의 API를 제공해 프로세스를 관리하고 병렬 처리를 쉽게 수행
  • docs : https://docs.python.org/3/library/concurrent.futures.html
from concurrent.futures import ProcessPoolExecutor

def square(x):
    return x * x

with ProcessPoolExecutor() as executor:
    futures = [executor.submit(square, i) for i in range(5)]
    results = [f.result() for f in futures]
    print(results)  # [0, 1, 4, 9, 16]

처리 결과 비교

처리 결과, 제게 주어진 task에서 가장 좋은 성능을 보인 라이브러리는 Ray였습니다.

기존 작업 (병렬 처리 X) 약 26분
Ray 약 6분 (성능 약 77% 향상)
MultiProcessing 약 10분 (성능 약 61.54% 향상)
Dask 기존 시간(26분)을 초과하여 중단
ProcessPoolExecutor 기존 시간(26분)을 초과하여 중단

위와 같은 실험 결과를 토대로 저는 주어진 과제에 레이를 활용하기로 결정했습니다. 실제로 해당 결과를 공유한 이후MultiProcessing보다 레이가 더 빠르다니, 흥미로운 실험 결과라는 코멘트를 받기도 하였습니다 :)

물론 모든 작업에서 반드시 위와 같은 결과를 보이는 것이 절대 아니기 때문에, 과제에 따라 실험을 통해 적합한 라이브러리를 잘 취사 선택하는 것이 중요하겠습니다.


자원 확인

nproc
#128

리눅스 명령어로 확인한 결과 제 서버가 가지고 있는 CPU 코어는 총 128개로 확인이 되었습니다.

ray.init(include_dashboard=True)

 

ray의 init함수를 쓸 때 대시보드 설정을 True로 할당하면, 브라우저에서 localhost:8265에 접속해 대시보드를 볼 수 있습니다. (저는 회사 서버 + 도커 컨테이너를 쓰고 있어서 이 부분은 복잡해서 pass)

import ray

ray.init()

# 현재 사용 가능한 리소스 보기
available_resources = ray.available_resources()
print(available_resources)

위와 같이 available_resources 함수를 실행하면, 사용 중인 CPU와 GPU 자원에 대한 정보가 출력됩니다.

(예) {'accelerator_type:G': 1.0, 'node:__internal_head__': 1.0, 'CPU': 128.0, 'memory': 1058863122432.0, 'object_store_memory': 10000000000.0, 'GPU': 6.0, 'node:172.17.0.8': 1.0}

  • 'CPU': 128.0 -> 이 시스템에 128개의 CPU 코어가 사용 가능하다는 것을 나타냅니다. nproc 명령어에서 확인한 128개의 코어 수와 일치하네요. Ray는 이 128개의 코어를 병렬 처리에 사용합니다.
  • 'memory': 1058863122432.0 -> 이 값은 총 사용 가능한 메모리를 바이트 단위로 나타내고, 제게 주어진 시스템에는 약 1TB의 메모리가 사용 가능하네요.
  • 'object_store_memory': 10000000000.0 -> 이 메모리는 Ray에서 객체를 저장하는 데 사용됩니다.  10GB가 설정되어 있네요.

이상으로 CPU의 병렬 처리에 대한 간략한 포스팅을 마치도록 하겠습니다. 주어진 리소스를 잘 파악하고 100% 활용할 수 있도록 늘 고민하는 엔지니어가 될 수 있도록 오늘도 내일도 열심히 공부하고 노력하겠습니다 :)

감사합니다!

 

 

한동안 코테를 잠시 안풀었더니

효율성 테스트를 통과하는데 조금 헤맸던 문제입니다..!

 

Stack 알고리즘을 활용하지 않고 문제를 풀었을 때 테스트 케이스 정확성은 통과하는 데 문제가 없으나, 효율성 테스트를 통과하기 어려우실 수 있습니다. 효율성 테스트까지 통과할 수 있는 문제 풀이 방법을 알려드릴게요 :)

 

 

https://school.programmers.co.kr/learn/courses/30/lessons/12909

 

프로그래머스

코드 중심의 개발자 채용. 스택 기반의 포지션 매칭. 프로그래머스의 개발자 맞춤형 프로필을 등록하고, 나와 기술 궁합이 잘 맞는 기업들을 매칭 받으세요.

programmers.co.kr

 

문제 상황 : 괄호가 바르게 짝지어졌다는 것은 '(' 문자로 열렸으면 반드시 짝지어서 ')' 문자로 닫혀야 한다는 뜻입니다. 예를 들어

"()()" 또는 "(())()" 는 올바른 괄호입니다.
")()(" 또는 "(()(" 는 올바르지 않은 괄호입니다.


'(' 또는 ')' 로만 이루어진 문자열 s가 주어졌을 때, 문자열 s가 올바른 괄호이면 true를 return 하고, 올바르지 않은 괄호이면 false를 return 하는 solution 함수를 완성해 주세요.


 

1.

먼저 빠른 탈출을 위해 다음 4가지 경우에는 함수를 False로 얼리 리턴하도록 설정해주겠습니다.

  • 주어진 문자열이 ")" 로 시작하는 경우
  • 주어진 문자열이 "(" 로 끝나는 경우
  • 주어진 문자열의 길이가 홀수인 경우
  • 주어진 문자열이 ")" 또는 "(" 한 종류로만 이루어진 경우
def solution(s):
    if s[0] == ")" or s[-1] == "(" or len(s) % 2 == 1 or len(set(s)) == 1:
        return False

 

 

 

2.

다음으로 stack 리스트를 선언해주도록 하겠습니다.

    stack = []

 

우리는 stack 리스트에 "(" 열기 괄호만 넣어줄 것입니다.

왜 열기 괄호만 넣어줄까요? 예시를 통해 한번 확인해 봅시다.

 

문자열 s = "(((())"가 있다고 합시다. 딱 봐도 열기 괄호는 4개인데 닫기 괄호는 2개여서 짝이 맞지 않는다는 것을 알 수 있습니다. 우리는 for루프를 통해 0번째 자릿값부터 차례대로 돌면서 해당 자리의 값이 "("이면 stack에 넣어주고, ")"이면 stack에 있는 열기괄호를 하나 빼 줄 것입니다.

    for char in s:
        if char == '(':
            stack.append(char)
        elif char == ')':
            if not stack:
                return False
            stack.pop()

s = "(((())"

  1. 첫 번째 자리 "(" -> stack = "(" 
  2. 두 번째 자리 "(" -> stack = "(", "("
  3. 세 번째 자리 "(" -> stack = "(", "(", "("
  4. 네 번째 자리 "(" -> stack = "(", "(", "(", "("
  5. 다섯 번째 자리 ")" -> stack이 비어있지 않으므로 stack.pop()
    -> stack = "(", "(", "("
  6. 여섯 번째 자리 ")" -> stack이 비어있지 않으므로 stack.pop()
    -> stack = "(", "("
    if stack:
        return False
    else:
        return True

 

이터레이션을 다 돌고 났을 때

stack이 비어 있으면 짝이 맞으므로 True

stack에 남은 값이 있다면 짝이 맞지 않는 것이므로 False를 리턴합니다.

 

결국 "("의 갯수와 ")"의 갯수가 동일한지 확인하는 것과 다름 없습니다. 다른 방법으로도 정확성 문제는 통과하는 데 어려움이 없으나, 효율성에 문제가 생기는 관계로 stack 알고리즘을 통해 효율성을 높여준 것이라고 보시면 되겠습니다.

 

 

3.

마지막으로 한 번에 코드를 보겠습니다.

def solution(s):
    if s[0] == ")" or s[-1] == "(" or len(s) % 2 == 1 or len(set(s)) == 1:
        return False

    stack = []
    for char in s:
        if char == '(':
            stack.append(char)
        elif char == ')':
            if not stack:
                return False
            stack.pop()
    
    if stack:
        return False
    else:
        return True

 

 

감사합니다!

 

https://school.programmers.co.kr/learn/courses/30/lessons/12945

 

프로그래머스

코드 중심의 개발자 채용. 스택 기반의 포지션 매칭. 프로그래머스의 개발자 맞춤형 프로필을 등록하고, 나와 기술 궁합이 잘 맞는 기업들을 매칭 받으세요.

programmers.co.kr

 

 

오랜만에 피보나치 수열 문제 풀이를 했습니다. 처음에 간단한 재귀함수를 적용해서 풀이를 했더니 테스트는 모두 통과하는데 효율성 테스트에서 시간 초과로 계속 실패를 하는 문제가 발생했습니다. 그래서 효율성을 올리기 위해 메모화를 적용해서 풀이를 했더니 효율성 테스트도 모두 통과할 수가 있었습니다 :-)

 

간단하게 풀이방법을 설명해 보겠습니다!

 

 

피보나치 수열

 


1. 메모화 없이 기본 피보나치 수열 함수 작성하기

def basic_fibonacci(n):
    if n in [1, 2]:
        return 1
    if n == 3:
        return 2

    return basic_fibonacci(n-2) + basic_fibonacci(n-1)

 

만약 주어진 정수가 1 또는 2라면 바로 1을 리턴,

주어진 정수가 3이라면 바로 2를 리턴하도록 초기 설정을 해 줍니다.

4 이상의 정수 n이 주어졌을 때 재귀함수 형식으로 n-2와 n-1에 basic_fibonacci 함수를 적용한 값을 구해 더한 값을 리턴합니다.

basic_fibonacci(8), basic_fibonacci(9), basic_fibonacci(10), basic_fibonacci(11)
# (21, 34, 55, 89)

 

위 함수는 제대로 작동하지만, n의 값이 커질수록 효율성이 급격하게 떨어진다는 단점이 있어요. 메모화를 이용해서 함수의 효율을 높여보겠습니다.


2. 메모화를 적용한 피보나치 수열 함수 작성하기

def fibonacci(n, memo = {1:1, 2:1, 3:2, 4:3, 5:5, 6:8}):
    if n in [2, 3, 4, 5, 6]:
        return memo[n]
    
    if n > 6:
        for i in range(7, n+1):
            if i in memo: pass
            else:
                memo[i] = memo[i-2] + memo[i-1]
    
    return memo[n]

 

하나씩 뜯어보겠습니다 :)

def fibonacci(n, memo = {1:1, 2:1, 3:2, 4:3, 5:5, 6:8}):

 

가장 먼저 함수를 선언할 때 파라미터 값으로 memo라는 이름으로 딕셔너리를 포함해 주었습니다. 저는 간단하게 n이 1부터 6번째일때 피보나치 수열 값을 딕셔너리에 초기값으로 미리 메모를 해 두었습니다.

def fibonacci(n, memo = {1:1, 2:1, 3:2, 4:3, 5:5, 6:8}):
    if n in [2, 3, 4, 5, 6]:
        return memo[n]

 

내가 구하고자 하는 것은 피보나치 수열의 n번째 값입니다. 만약 n이 2, 3, 4, 5, 6인 경우 (문제 조건에서 n은 2 이상의 정수라고 했으므로 1은 제외합니다) memo 딕셔너리의 해당하는 value 값을 바로 리턴하고 함수를 종료하게 됩니다.

    if n > 6:
        for i in range(7, n+1):
            if i in memo: pass
            else:
                memo[i] = memo[i-2] + memo[i-1]
    
    return memo[n]

 

만약 n이 6보다 크면 어떻게 해야 할까요?

 

주어진 n의 값이 50일 때를 예시로 생각해 봅시다. 우리는 50번째 값을 구하기 위해 48, 49번째 값이 필요합니다.

49번째 값을 구하기 위해서는 47, 48번째가 필요하고

48번째 값을 구하기 위해서는 46, 47번째가 필요합니다.

결국 50번째 값을 구하기 위해서는 1부터 49번째까지 피보나치 수열 값을 모두 알아야 합니다.

 

그럼 메모화는 어떻게 작동할까요?

제가 만약 50번째 피보나치 수열 값을 구하기 전에,

40번째 피보나치 수열 값을 먼저 구했다고 생각해 보겠습니다.

fibonacci(40)
# 102334155

 

우리는 40번째 피보나치 수열 값을 구하기 위해 지금 1번째~39번째 피보나치 수열의 값을 열심히 구했습니다.

이제 다음으로 50번째 피보나치 수열 값을 구하려고 합니다.

 

 

먼저 메모화를 해놓지 않은 경우를 생각해 봅시다.

우리는 이미 1번부터 40번째까지의 값을 이전에 미리 구한 전적이 있지만

따로 기록을 해 두지 않았기 때문에 같은 계산을 또 반복해야 합니다.

1번째~49번째 피보나치 수열 값을 열심히 또 구하는 거죠.

 

하지만 제가 기록을(메모화를) 해 두었다면 얘기가 달라집니다.

즉 처음에 fibonacci(40)을 계산했을 때

memo 딕셔너리에 1번부터 40번까지 피보나치 수열 값을

key(n) : value(값) 형식으로 미리 저장을 해 두었다면,

우리는 fibonacci(50)을 구할 때 

1. 1번부터 40번까지는 간단히 딕셔너리에서 값을 찾아 가져올 수 있습니다.

2. 41번부터 49번째 값은 새로 구하고 딕셔너리에 값을 추가해주는 방식으로 계속해서 메모를 해나갈 수 있습니다.

 

+ 참고로 함수를 여러번 작동하면서 메모장에 추가하는 key와 value 값은 매번 초기화되지 않고 계속해서 누적 기록을 보관합니다.

 

def fibonacci(n, memo = {1:1, 2:1, 3:2, 4:3, 5:5, 6:8}):
    # 주어진 정수가 2-6중 하나일 경우 바로 메모장에서 수열값을 찾아 리턴, 함수종료
    if n in [2, 3, 4, 5, 6]:
        return memo[n]
    
    # 주어진 정수가 6보다 큰 경우(7 이상)
    if n > 6:
    	# 7부터 n까지의 정수 i에 대해서
        for i in range(7, n+1):
            # 만약 메모장에 i값이 이미 메모되어 있다면 그냥 넘어가고
            if i in memo: 
                pass
            # 만약 메모장에 i값이 없다면 피보나치 수를 구해서 추가해주세요
            else:
                memo[i] = memo[i-2] + memo[i-1]
    
    # 메모장에서 n번째 피보나치 수열 값을 찾아 리턴해주세요
    return memo[n]

 

최종 피보나치 함수입니다 :)

내가 구하고자 하는 n의 피보나치 값을 찾기 위해 7부터 n-1번째의 수열값을 계속해서 차례대로 메모장에 누적 메모해나가는 방식입니다. 재귀함수랑 약간 비슷한듯 다른 느낌이네요!


3. 최종 문제풀이

마지막으로 문제 해결을 위해 피보나치 수열을 활용해서 soluton()함수를 작성했습니다. 저는 코딩테스트 문제를 풀 때 이렇게 함수를 나누어 작성하는것을 좋아합니다. 함수 안에 함수 작성하는거 시러요.....(개취)

def fibonacci(n, memo = {1:1, 2:1, 3:2, 4:3, 5:5, 6:8}):
    if n in [2, 3, 4, 5, 6]:
        return memo[n]
    
    if n > 6:
        for i in range(7, n+1):
            if i in memo: 
                pass
            else:
                memo[i] = memo[i-2] + memo[i-1]
    
    return memo[n]

def solution(n):
    fibo_num = fibonacci(n)
    
    # fibonacci(30)< 1234567 < fibonacci(31)
    # 만약 n이 30 이하라면 바로 fibo_num을 리턴하고(몫이 0이므로 나머지와 동일)
    if n <= 30 : 
        return fibo_num
    # 만약 n이 30보다 크다면 fibo_num을 1234567으로 나눈 나머지를 리턴
    else :
        return fibo_num % 1234567

 

정확성: 100.0
합계: 100.0 / 100.0

 

 

 

 

풀이나 설명에 오류가 있다면 댓글로 알려주세요!

감사합니다! :-)

 

 

FutureWarning: use_inf_as_na option is deprecated and will be removed in a future version. Convert inf values to NaN before operating instead. with pd.option_context('mode.use_inf_as_na', True): 

 

주피터 노트북이나 코랩에서 pandas, seaborn, matplotlib 등 라이브러리를 사용할 때 위와 같이 퓨처 워닝 어쩌구 하면서 경고 메세지가 나타나서 꼴보기 싫은 경우가 있습니다.

 

이럴 때 warning 라이브러리를 임포트하는 방식으로 간단하게 해결이 가능합니다.

 

import warnings
warnings.simplefilter(action='ignore', category=FutureWarning)

 

before

 

after

 

 

 

 

 

코딩테스트 문제를 해결할 때면 2개 이상의 정수의 최대공약수 또는 최소공배수를 구해야 하는 경우가 종종 있습니다. 파이썬에서 최대공약수와 최소공배수는 넘파이 라이브러리를 이용해서 아주 간단하게 구해낼 수 있는데요. 

 

먼저 함수를 소개해 드리기 전에, 최대공약수와 최소공배수를 영어로 뭐라고 부르는지 알고 넘어갈게요.

  • gcd : greatest common division (최대공약수)
  • lcm : lowest common multiple (최소공배수)

이런 간단한 영어 정도는 숙지해 두시면 좀더 직관적으로 쉽게 프로그래밍하실 수 있어요 :-)

 

[1] 최대공약수 구하기

import numpy as np

 

먼저 넘파이 라이브러리를 임포트해줍니다.

np.gcd(12, 20)
# 4

np.gcd(30, 45)
# 15

 

np.gcd() 함수 안에 두 정수를 넣으면 두 정수의 최대공약수를 리턴합니다. gcd인 이유는 위에서 설명한 대로 greatest common divison의 약자 gcd가 최대공약수를 의미하기 때문입니다.

 

만약 3개 이상의 정수의 최대공약수를 구하고 싶다면 어떻게 할 수 있을까요?

np.gcd.reduce([15, 25, 35])
# 5

np.gcd.reduce([15, 27, 18])
# 3

before = np.arange(0, 20, 5)
after = np.gcd.reduce(before)
after
# 5

 

np.gcd.reduce() 함수 안에 리스트 또는 넘파이 어레이를 넣어주면 리스트 또는 어레이 안의 모든 정수의 최대 공약수를 리턴합니다.

 

 

[2] 최소공배수 구하기

np.lcm(10, 14)
# 70

np.lcm.reduce([10, 15, 20])
# 60

before = np.array([3, 7, 10])
after = np.lcm.reduce(before)
after
# 210

 

np.lcm() 함수 안에 두 정수를 넣으면 두 정수의 최소공배수를 리턴합니다. lcm인 이유는 위에서 설명한 대로 lowest common multiple의 약자 lcm이 최소공배수를 의미하기 때문입니다.

 

마찬가지로 np.lcm.reduce()  함수 안에 리스트 또는 넘파이 어레이를 넣어서 3개 이상의 정수의 최소공배수도 구해낼 수 있습니다.

 

 


 

 

알고 있으면 아주아주 유용한 파이썬 넘파이 모듈 이용해서 최소공배수/최대공약수 구하는 방법이었습니다.

 

감사합니다 :-)

 

 

문자열을 분리하고 변형해야 하는 코딩 테스트 문제에서 공백 문자가 연속으로 나오는 경우가 많이 있습니다. 공백을 기준으로 문자열을 분리할 때 자연스럽게 split() 함수를 사용하게 되는데, 이 경우 연속 공백을 상실하게 되는 문제점이 발생합니다.

 

연속 공백을 유지하면서 문자열을 분리하여 리스트로 만들기 위해 정규식(regular expression)을 사용할 수 있습니다.

 

 

https://school.programmers.co.kr/learn/courses/30/lessons/12951#

 

프로그래머스

코드 중심의 개발자 채용. 스택 기반의 포지션 매칭. 프로그래머스의 개발자 맞춤형 프로필을 등록하고, 나와 기술 궁합이 잘 맞는 기업들을 매칭 받으세요.

programmers.co.kr

 

프로그래머스 JadenCase 문자열 만들기 문제를 가지고 방법을 설명해 보겠습니다. (다른 다양한 문제에서도 활용할 수 있습니다!)


 

문제 : JadenCase란 모든 단어의 첫 문자가 대문자이고, 그 외의 알파벳은 소문자인 문자열입니다. 단, 첫 문자가 알파벳이 아닐 때에는 이어지는 알파벳은 소문자로 쓰면 됩니다. 문자열 s가 주어졌을 때, s를 JadenCase로 바꾼 문자열을 리턴하는 함수, solution을 완성해주세요.

 

이렇게 공백문자가 연속해서 나올 수 있다는 조건이 있는 경우 split() 함수를 사용하면 공백 여러개가 모두 없어져버리므로 조심해야 합니다.

 

 

저는 먼저 단어 1개를 JadenCase로 바꾸어 리턴하는 함수 jaden을 작성했습니다.

def jaden(w):
    try:
    	# 첫 번째 글자가 정수일 때
        int(w[0]) 
        return w[0] + w[1:].lower() 
    except:
    	# 첫 번째 글자가 정수가 아닐 때
        return w[0].upper() + w[1:].lower()

 

이렇게 작성한 jaden 함수를 가지고 최종 solution 함수를 작성해 볼게요.

 

import re

 

먼저 정규식 패키지를 불러옵니다.

 

word = "Hello       World   Bye"

 

위의 예시를 단어와 공백문자로 구분한 리스트를 어떻게 만들 수 있을까요?

 

word = "Hello       World   Bye"
re.findall("\S+|\s+",word)
# ['Hello', '       ', 'World', '   ', 'Bye']
  • re.findall() : RE가 일치하는 모든 부분 문자열을 찾아 리스트로 반환합니다. 활용해서 원하는 구성요소를 찾아 리스트로 만들어줍니다.
  • \s+ : 일치하는 모든 공백 문자를 찾아줍니다. (여러 개의 공백도 하나로 평탄화하지 않고 유지한 채로 찾아줍니다.)
  • \S+ : 일치하는 모든 비공백 문자를 찾아줍니다.

결과를 보면 여러 개의 공백 문자가 잘 보존되어 있는 것을 볼 수 있습니다. 이제 최종 솔루션 함수를 작성해 볼게요.

 

def jaden(w):
    try:
    	# 첫 번째 글자가 정수일 때
        int(w[0]) 
        return w[0] + w[1:].lower() 
    except:
    	# 첫 번째 글자가 정수가 아닐 때
        return w[0].upper() + w[1:].lower()

import re

def solution(s):
	# 입력 받은 s를 whitespace와 nonwhitespace로 구분합니다
    words = re.findall("\S+|\s+", s)
    answer = ""
    for word in words:
    	# 만약 공백문자가 아니라면 jaden 함수를 적용해서 answer에 추가합니다.
        if word.isalnum(): 
        	answer += jaden(word)
        # 만약 공백문자라면 그냥 바로 answer에 추가합니다.
        else: 
        	answer += word
    return answer

 

 
정확성: 100.0
합계: 100.0 / 100.0
 

 

끝입니다! 정규식이 기억에 잘 남지는 않지만,
잘만 숙지해 두면 연속 공백문자 처리를 해야하는 문제에서 요긴하게 잘 쓸 수 있답니다 :-) 
읽어주셔서 감사합니다!

 

프로그래머스에 비트 연산을 해야하는 MySQL문제가 종종 보이는데요. 정리해놓으면 좋을 것 같아서 문제 풀이를 한번 작성해 보겠습니다.

 


https://school.programmers.co.kr/learn/courses/30/lessons/301646

 

프로그래머스

코드 중심의 개발자 채용. 스택 기반의 포지션 매칭. 프로그래머스의 개발자 맞춤형 프로필을 등록하고, 나와 기술 궁합이 잘 맞는 기업들을 매칭 받으세요.

programmers.co.kr

 

문제 : 2번 형질을 보유하지 않으면서 1번이나 3번 형질을 보유하고 있는 대장균 개체의 수(COUNT)를 출력하는 SQL 문을 작성해주세요. 1번과 3번 형질을 모두 보유하고 있는 경우도 1번이나 3번 형질을 보유하고 있는 경우에 포함합니다.


[1] 2진법으로 계산해 보기

먼저 각 열의 GENOTYPE의 수를 2진법으로 바꾸어 출력해 보겠습니다. 주어진 정수 데이터에 BIN() 함수를 사용하면 정수를 2진법 수로 변환한 결과를 출력합니다.

SELECT BIN(GENOTYPE) AS GENOTYPE_BIN
FROM ECOLI_DATA

 

그럼 이제 출력된 결과를 CAST함수를 이용해서 CHAR 데이터타입으로 바꾼 뒤, WHERE문을 사용해서 필터링을 해 보겠습니다.

SELECT COUNT(*) AS `COUNT` 
FROM (SELECT ID,
             CAST(BIN(GENOTYPE) AS CHAR) AS GENOTYPE_BIN
             FROM ECOLI_DATA) A
WHERE A.GENOTYPE_BIN LIKE '1' OR
      A.GENOTYPE_BIN LIKE '%10_' OR
      A.GENOTYPE_BIN LIKE '%01'

 

WHERE문의 각 줄은 다음과 같은 결과를 필터링합니다.

  1. 서브쿼리문의 수가 1인 경우
  2. 서브쿼리가 101 또는 100인 경우
  3. 서브쿼리가 01로 끝나는 경우

마지막으로 SELECT COUNT(*) AS `COUNT`를 통해 필터링된 데이터 열의 갯수를 COUNT라는 컬럼명으로 출력하도록 해 주었습니다. 정답 통과입니다.

 

 


[2] 비트 연산으로 풀어보기

MySQL에는 비트(Bit) 단위로 논리 연산을 수행하는 연산자가 있습니다. 챗지피티한테 비트 연산자의 종류에 뭐가 있는지 테이블을 만들어 달라고 했는데요.

비트 AND (&) 비트별 AND 연산을 수행합니다. 두 비트가 모두 1이면 결과는 1이 되고, 그렇지 않으면 결과는 0이 됩니다.
비트 OR ( | ) 비트별 OR 연산을 수행합니다. 두 비트 중 하나라도 1이면 결과는 1이 되고, 둘 다 0이면 결과는 0이 됩니다.
비트 XOR (^) 비트별 XOR 연산을 수행합니다. 두 비트가 같으면 결과는 0이 되고, 다르면 결과는 1이 됩니다.
비트 NOT (~) 비트를 반전시킵니다. 0은 1로, 1은 0으로 변환됩니다.
비트 왼쪽 시프트 (<<) 모든 비트를 왼쪽으로 이동시킵니다. 오른쪽에 0으로 채워집니다.
비트 오른쪽 시프트 (>>) 모든 비트를 오른쪽으로 이동시킵니다. 왼쪽에 부호 비트와 같은 값으로 채워집니다.

 

네. 이런게 있다네요. (^_^;;) 저는 여기서 &연산자와 NOT 연산자를 사용해서 위의 문제를 풀어보겠습니다.

 

SELECT * FROM ECOLI_DATA
WHERE
    GENOTYPE & 5
    AND NOT GENOTYPE & 2

 

GENOTYPE & 5

  • 정수 5를 2진법 비트로 변환하면 101입니다. 따라서 &5는 값의 첫 번째, 세 번째 비트가 1인지 여부를 확인합니다.

NOT GENOTYPE & 2

  • 비트 NOT 연산은 비트를 반전시키는 역할을 합니다. 여기서 2의 이진 표현은 10입니다. 따라서 이 비트 연산은 GENOTYPE 열의 값에서 2번째 비트를 확인하고, 그 값을 반전시키므로, 2번째 비트가 0인지 여부를 확인합니다.
SELECT COUNT(ID) AS `COUNT`
FROM ECOLI_DATA
WHERE
    GENOTYPE & 5
    AND NOT GENOTYPE & 2

 

마지막으로 카운트한 값을 출력해 주면 정답 통과입니다.


 

비트 연산자는 간단하게 풀 수 있지만 까먹기 쉽다는 단점이 있는 것 같습니다.

그래도 이런 게 있다는 걸 알아두고 필요할 때마다 열심히 꺼내 보면서 익숙해 져야겠습니다.

 

 

 

 

MySQL에서 JOIN을 이용하여 두 테이블 간의 정보를 조회할 때, 차집합(Set Difference)을 구해야 할 경우가 있습니다. 이럴 때 LEFT JOIN,RIGHT JOIN에 WHERE구문을 추가 활용하여 쉽게 표현해볼 수 있습니다.

 

 

https://school.programmers.co.kr/learn/courses/30/lessons/59044

 

프로그래머스

코드 중심의 개발자 채용. 스택 기반의 포지션 매칭. 프로그래머스의 개발자 맞춤형 프로필을 등록하고, 나와 기술 궁합이 잘 맞는 기업들을 매칭 받으세요.

programmers.co.kr

 

관련 문제로 프로그래머스 MySQL 코딩테스트 3단계 문제 오랜 기간 보호한 동물(1) 풀이를 함께 첨부해 보겠습니다.

 

 


 

 

문제 상황 : 아직 입양을 못 간 동물 중, 가장 오래 보호소에 있었던 동물 3마리의 이름과 보호 시작일을 조회하는 SQL문을 작성해주세요. 이때 결과는 보호 시작일 순으로 조회해야 합니다.

 

 

SQL문을 실행하면 다음과 같이 나와야 합니다.


 

먼저 ANIMAL_INS 테이블에 ANIMAL_OUTS 테이블을 LEFT JOIN한 결과를 보겠습니다.

SELECT I.NAME, I.DATETIME
FROM ANIMAL_INS I
	LEFT JOIN ANIMAL_OUTS O
	ON I.ANIMAL_ID = O.ANIMAL_ID

 

아직 아무 조건을 걸어주지 않은 관계로 위 코드가 출력하는 결과는

SELECT NAME, DATETIME

FROM ANIMAL_INS와 동일합니다.

 

 

저는 여기서 A-B를 구하기 위해

A와 B의 교집합을 제거해 주려고 합니다.

어떻게 해볼 수 있을까요?

SELECT I.NAME, I.DATETIME
FROM ANIMAL_INS I
	LEFT JOIN ANIMAL_OUTS O
	ON I.ANIMAL_ID = O.ANIMAL_ID
WHERE O.ANIMAL_ID IS NULL

 

OUT 테이블의 아이디가 없는 데이터만 출력하도록 WHERE O.ANIMAL_ID IS NULL을 추가해 주었습니다.

 

이제 마지막으로 이 결과에서 가장 오래 보호소에 있었던 동물 3마리의 이름을 고르고, 결과는 보호 시작일 순으로 조회하도록 조건을 추가하겠습니다.

SELECT I.NAME, I.DATETIME
FROM ANIMAL_INS I
	LEFT JOIN ANIMAL_OUTS O
	ON I.ANIMAL_ID = O.ANIMAL_ID
WHERE O.ANIMAL_ID IS NULL
ORDER BY I.DATETIME ASC LIMIT 3;

 

정답 통과 입니다.

 

 

참고한 다이어그램

출처: 구글 이미지

 


 

비슷한 문제로 프로그래머스 3단계 '없어진 기록 찾기' 문제도 풀어보시면 좋을 것 같습니다 :-)

 

 

https://school.programmers.co.kr/learn/courses/30/lessons/59042

 

프로그래머스

코드 중심의 개발자 채용. 스택 기반의 포지션 매칭. 프로그래머스의 개발자 맞춤형 프로필을 등록하고, 나와 기술 궁합이 잘 맞는 기업들을 매칭 받으세요.

programmers.co.kr

 

특정 열의 값에 대해 순위를(랭킹을) 매기기 위해서 다음과 같은 함수를 사용해볼 수 있습니다.

  • RANK
  • DENSE_RANK
  • ROW_NUMBER
  • NTILE

기본적인 함수 형식은 아래과 같습니다.

RANK() OVER ( [PARTITION BY colName1] ORDER BY colName2 [DESC] )
DENSE_RANK() OVER ( [PARTITION BY colName1] ORDER BY colName2 [DESC] )
ROW_NUMBER() OVER ( [PARTITION BY colName1] ORDER BY colName2 [DESC] )
NTILE() OVER ( [PARTITION BY colName1] ORDER BY colName2 [DESC] )

# [대괄호] 안의 값은 선택사항입니다

 

이렇게 보면 외계어같지만 실제로는 사용이 쉬운 함수들 입니다. (진짜루요)

제가 갖고 있는 데이터베이스에서 Employee의 사번, 이름, 젠더, 샐러리(연봉) 4개를 저장한 임시 테이블 TEMP를 가지고 차례차례 함수를 적용시켜 보겠습니다.

 

1. RANK()

RANK()함수는 내가 저장한 행(column)에 순위를 매겨서 정렬한 결과값을 보여주는 함수입니다.

SELECT emp_no, 
	CONCAT(first_name, ' ', last_name) AS full_name,
	salary,
	RANK() OVER(ORDER BY SALARY DESC) AS `rank`
FROM TEMP;

 

위의 쿼리에는 SALARY 행 내림차순을 기준으로 랭킹함수를 적용해서 rank라는 새로운 이름의 행(column)을 반환하도록 했습니다.

 

 

결과를 살펴봅시다. 잘 보시면 rank 행에 128등이 두 명입니다. 이유는 두 명의 salary 값이 중복되기 때문인데요. 이렇게 RANK() 함수에서는 tie : 중복값이 있는 데이터끼리는 같은 등수를 쉐어하게 됩니다. 그리고 그 등수를 쉐어하는 만큼 그 다음 등수는 밀려서 사라지게 되는데요. 128등이 두 명 오고 난 다음에 129등 없이 130등으로 시작하는 것을 보면 알 수 있습니다. 만약 128등이 3명이었다면 그 다음 등수는 131등으로 시작하겠죠.

 

2. DENSE_RANK()

DENSE_RANK() 함수는 RANK()와 거의 비슷하지만 약간 다릅니다.

SELECT emp_no, 
	CONCAT(first_name, ' ', last_name) AS full_name,
	salary,
	DENSE_RANK() OVER(ORDER BY SALARY DESC) AS `rank`
FROM TEMP;

 

 

DENSE_RANK()에서는 같은 값, 중복값의 존재나 갯수와 관계없이 무조건 1씩 차례로 증가합니다. 랭킹이 밀리지 않아요. 127등이 두 명 있다고 그 다음 등수가 129로 시작하지 않고, 128으로 시작하는 것을 보면 알 수 있어요.

 

예를 들어 올림픽에서 금메달을 2명이 공동 수상한다고 가정을 했을 때, 금메달이 2명이라고 은메달은 아무도 주지 않고 동메달을 주면 안 되잖아요? 금메달을 받은 사람의 수와 관계 없이 은메달도 반드시 준다, 라고 보면 됩니다. (금메달 공동 수상이 실제로 가능한지는.... 저도 모르지만요...)

 

그런데 보니까 아까 RANK()에서는 128등이 두 명이었는데 이번엔 127등이 두 명이네요, 왜 그럴까요? 데이터 이미지 중간에 생략된 부분에서 같은 값을 가지는 tie가 두 명 있었습니다. 그래서 RANK()는 숫자가 하나 밀려서 128등 두 명이 되었고, DENSE_RANK()는 숫자가 밀리지 않아서 127등 두 명이 되었어요.

 

3. ROW_NUMBER()

ROW_NUMBER() 함수는 기준에 따라 랭킹 정렬을 하되, 중복값과 관계 없이 무조건 1부터 차례대로 행 번호를 매겨 반환합니다.

SELECT emp_no, 
	CONCAT(first_name, ' ', last_name) AS full_name,
	salary,
	ROW_NUMBER() OVER(ORDER BY SALARY DESC) AS `rank`
FROM TEMP;

 

보시는 것처럼 같은 값이 있던 말던 무조건 1부터 시작해서 하나씩 줄번호를 매겨서 반환합니다.

 

직관적인 예시로 출석 번호나 키번호를 생각해볼 수 있을 것 같아요. 학급에 동명이인이 있다고 둘이 같은 출석번호를 쉐어하지는 않죠. 키가 같다고 키번호를 똑같이 쓰지도 않을 거구요. 이런 상황에서 ROW_NUMBER() 함수를 사용하면 되겠습니다.

 

4. NTILE()

NTILE 함수는 랭킹을 매기되, 내가 지정한 블럭 갯수만큼 구간을 나누어 등급 랭킹을 부여합니다.

SELECT emp_no, 
	CONCAT(first_name, ' ', last_name) AS full_name,
	salary,
	NTILE(5) OVER(ORDER BY SALARY DESC) AS `rank`
FROM TEMP;

 

위의 코드에서 NTILE(5)와 같이 NTILE 함수 안에 정수를 넣어 줬는데요. 이 말은 1, 2, 3, 4, 5등 구간으로 나누어 5개의 등급을 매겨 랭킹을 반환하라는 뜻이에요.

 

데이터 갯수가 많아 이렇게 초반부에는 1등급만 보이지만

샐러리를 내림차순으로 정렬한 다음 5개의 등급으로 나누어 랭킹이 1부터 5까지 차례대로 부여가 되었습니다.

 

5. PARTITION BY 추가 응용

그럼 PARTITION BY는 어떻게 활용할 수 있을까요?

SELECT emp_no, 
	CONCAT(first_name, ' ', last_name) AS full_name,
	salary,
	RANK() OVER(PARTITION BY emp_no ORDER BY SALARY DESC) AS `rank`
FROM TEMP;

 

 

 

제가 가지고 있던 TEMP 테이블에는 같은 사람의 샐러리가 매년 업데이트 되며 누적되어 있어서, 이렇게 이름에 따라서 샐러리 값을 여러 열(row)이 저장 되어 있었어요. OVER() 내부의 시작 부분에 PARTITION BY emp_no 를 추가해 주면서 직원 번호에 따라 파티션을 나누고 그 파티션 내부에서 랭킹을 매긴 값을 반환받았습니다. 이를 통해 직원별로 샐러리가(연봉이) 얼마나 상승했는지를 한 눈에 알아볼 수 있게 되었습니다.

 

<문제 설명>
어느 한 게임에서 사용되는 아이템들은 업그레이드가 가능합니다.
'ITEM_A'->'ITEM_B'와 같이 업그레이드가 가능할 때
'ITEM_A'를 'ITEM_B' 의 PARENT 아이템,
PARENT 아이템이 없는 아이템을 ROOT 아이템이라고 합니다.

(중략)....

 

<문제>
아이템의 희귀도가 'RARE'인 아이템들의 모든 다음 업그레이드 아이템의 아이템 ID(ITEM_ID), 아이템 명(ITEM_NAME), 아이템의 희귀도(RARITY)를 출력하는 SQL 문을 작성해 주세요. 이때 결과는 아이템 ID를 기준으로 내림차순 정렬해 주세요.

 


 

 

처음에 대충 생각하고 접근했더니 원하는 정답을 얻어내기 어려웠던 문제입니다!

 

다시 정신을 집중하고, RIGHT 조인과 서브쿼리를 이용해서 문제를 바로 해결했어요.

 

제가 풀이한 방법을 설명해 보겠습니다.



https://school.programmers.co.kr/learn/courses/30/lessons/273711

 

프로그래머스

코드 중심의 개발자 채용. 스택 기반의 포지션 매칭. 프로그래머스의 개발자 맞춤형 프로필을 등록하고, 나와 기술 궁합이 잘 맞는 기업들을 매칭 받으세요.

programmers.co.kr

 

 


 

 

먼저 아이템의 희귀도가 'RARE'인 아이템들을 뽑아내서 시각화해 봅니다.

SELECT * FROM ITEM_INFO WHERE RARITY = 'RARE'

 

우리는 이 4개의 아이템들의 다음 업그레이드 아이템을 찾아야 합니다. 이를 위해서는 ITEM_TREE 테이블을 확인해 보아야 해요.

 

 

ITEM_TREE 테이블을 보고 직관적으로 이해하기가 어려웠어요. 딱 봤을 때 구조가 한눈에 들어오지 않았습니다. (쉽게 보이시는 분이 계시다면.... 멋지십니다! 따봉!)

 

 

그래서 이렇게 간단히 그래프를 그려 봤더니 쉽게 이해가 되었습니다. 우리가 구한 RARE 아이템은 0, 1, 3, 4였어요. 이 아이템들의 다음 업그레이드 아이템은 (0으로부터) 1, 2번 아이템 + (2로부터) 3, 4번 아이템 => 총 1, 2, 3, 4번 아이템이 해당됩니다. 이해가 되셨을까요?

 

그렇다면 일반화된 쿼리문으로 작성하려면 어떻게 해볼 수 있을까요? 우선 우리가 구한 RARE 아이템 0, 1, 3, 4가 부모가 되는 아이템, 즉 0, 1, 3, 4의 자식 아이템을 찾아야 해요. 이를 위해서 ITEM_TREE 테이블의 PARENT_ITEM_ID와, ITEM_INFO 테이블의 ITEM_ID가 동일한 부분을 연결해 주면 됩니다.

 

SELECT * FROM ITEM_INFO I
	RIGHT JOIN ITEM_TREE T
	ON I.ITEM_ID = T.PARENT_ITEM_ID
WHERE RARITY = 'RARE'

 

1) ITEM_INFO 테이블에서 RARITY가 'RARE'에 해당하는 아이템 중에서,

2) 그 아이템이 ITEM_TREE 테이블에 있는 어떤 놈의 PARENT_ITEM_ID에 해당한다면,

3) 그 놈들을 모두 뽑아내봐요.

 

라고 명령한 것과 같습니다. 그럼 위와 같이 결과를 시각화할 수 있는데요. 여기서 우리에게 필요한 것은 무엇일까요?

 

 

우리한테 필요한 것은 저기 저 1, 2, 3, 4번 아이템 번호밖에 없어요. T 테이블(ITEM_TREE)의 ITEM_ID만 뽑아내 주면 됩니다. SELECT 뒤에만 간단하게 바꾸어 줘 봅시다.

 

SELECT T.ITEM_ID FROM ITEM_INFO I
        RIGHT JOIN ITEM_TREE T
        ON I.ITEM_ID = T.PARENT_ITEM_ID
WHERE RARITY = 'RARE'

 

 

이제 우리가 원하는 아이디를 뽑아냈으니, 이 테이블을 서브쿼리로 기존의 ITEM_INFO 테이블과 조인해 주면 끝입니다!

 

SELECT X.ITEM_ID, I.ITEM_NAME, I.RARITY
FROM (SELECT T.ITEM_ID
      FROM ITEM_INFO I
      RIGHT JOIN ITEM_TREE T
      ON I.ITEM_ID = T.PARENT_ITEM_ID
      WHERE RARITY = 'RARE') X
JOIN ITEM_INFO I ON X.ITEM_ID = I.ITEM_ID
ORDER BY ITEM_ID DESC

 

서브쿼리는 X로 이름을 지정해 주었습니다. X의 아이템 아이디와 ITEM_INFO 테이블(I)의 아이템 아이디가 같다면 아이디/이름/레어리티를 셀렉하여 프린트하고, 아이템 아이디를 기준으로 내림차순 하여 정렬하도록 지정했습니다.

 

채점 결과 100.0

 

코드 통과, 정답입니다 :)

 

이해가 안 되는 부분이 있으시다면 댓글 달아주세요!

 

서브쿼리와 RIGHT JOIN 연습을 할 수 있었던 좋은 문제였습니다.

 

 

 

+ Recent posts