[목차]
너무 유용하게 읽고 있는 책 파이썬 알고리즘 인터뷰 내용을 정리합니다.
a: str = "1"
b: int = 1
def fn(a: int) -> bool: ...
list(map(lambda x: x + 10, [1, 2, 3]))
(10씩 더한 리스트 만들기)[n * 2 for n in range(1, 11) if n % 2 == 1]
(홀수 리스트 만들기)a = {key: value for key, value in original.items()}
(딕셔너리 만들기) def get_natural_number():
n = 0
while True:
n += 1
yield n
list(range(5))
, for i in range(5): ...
print('A1', 'B2', sep=',')
출력 결과는 “A1, B2”a = 'A1'
, b = 'B2'
, print(f'{a}, {b}')
출력 결과는 “A1, B2”print(', '.join(['A1', 'B2']))
출력 결과는 “A1, B2”[2, 5, 1, 9, 7].sort()
출력값 없이 객체가 정렬됨sorted([2, 5, 1, 9, 7])
[1, 2, 5, 7, 9] 출력 sorted(['cde', 'cfc', 'abc'], key=lambda x: (x[0], x[-1])) # ['abc', 'cfc', 'cde']
"""No"""
def foo(a, b=[]):
...
"""Yes"""
def foo(a, b: Optional[Sequence] = None):
if b is None:
b = []
collections.defaultdict(int)
: 존재하지 않는 키 조회 시 기본값 반환collections.Counter([1, 2, 2, 3, 4, 4, 4])
: 각 아이템 개수를 계산해 dict 로 반환collections.OrderedDict(dict)
: 순서가 보장되는 dict파이썬에서 모든 요소들은 객체입니다.
심지어 bool, int, str, tuple 등의 원시primitive
데이터 타입처럼 보이는 기본 자료형들도 그렇습니다.
이들은 call by value
가 아닙니다. 이들 포함 파이썬의 모든 변수들은 call by reference
입니다.
다만 주의할 점은 위와 같은 기본 자료형들은 객체임에도 불변성immutable
을 갖는다는 사실입니다.(변경시 객체가 새로 할당됨)
나머지 모든 객체는 당연히 변경 가능mutable
합니다.
따라서, 특정 힙 영역의 값을 변경하면 그것의 객체 주소를 참조하는 모든 변수의 값이 변화된다고 봐야 합니다.
이런 예상치 못한 변화를 방지하기 위해 동일 객체를 참조하지 않고 copy 하는 기법을 숙지해야 합니다. copy 기법은 동일 값을 갖는 새 객체를 생성해서 할당하는 것입니다.
a = [1, 2, 3]
b = a
c = a[:] # copy
print(id(a)) # 1991522371976
print(id(b)) # 1991522371976, same address
print(id(c)) # 1991521639688, copy 된 새 객체
위와 같이 list 타입 객체를 [:] 로 슬라이싱하면 새 객체가 복사(copy)됩니다.
a = [1, 2, 3]
d = a.copy()
print(id(d)) # 1991522268808, copy() 메서드 활용
위와 같이 좀더 직관적으로 copy() 메서드를 사용할 수도 있습니다.
그렇다면 [1, 2, [3, 4], 5]
처럼 중첩된 list 는 어떨까요?
단순 copy 만 하면 겉껍데기(?) 객체는 새 영역에 할당되지만, 내부 요소의 객체는 이전 참조 주소가 그대로 사용됩니다.
이 문제를 해결하기 위해 깊은 복사(copy.deepcopy) 기능을 사용합니다.
import copy
a = [1, 2, [3, 4], 5]
b = a.copy() # 단순 copy
c = copy.deepcopy(a) # 깊은 copy, 내부 객체까지 copy
a[2].append(6)
print(id(a)) # 1991522293064
print(id(b)) # 1991522373384, 겉껍데기 객체는 copy 됨
print(id(c)) # 1991522354632, 동일하게 copy 된 것으로 나타남
print(a) # 1991522293064, [1, 2, [3, 4, 6], 5]
print(b) # 1991522373384, [1, 2, [3, 4, 6], 5], 분명 copy 되었으나 내부 객체는 이전 객체를 참조함
print(c) # 1991522354632, [1, 2, [3, 4], 5] 깊은 복사로 인해 내부 객체도 copy 되어 영향을 받지 않음
이런 기본적인 메커니즘을 분명히 알고 있어야 합니다.
copy 기법은 순열 구현 등 특정 객체를 복사해서 재사용하는 경우 자주 사용됩니다.
펠린드롬이란 뒤집어도 같은 말이 되는 단어 또는 문장을 의미합니다.
"""
Palindrome:
제약사항: 영문숫자, 대소문자 구분하지 않음.
"""
import re
from collections import deque
def palindrome_deque(string: str) -> bool:
deq = deque([s.lower() for s in string if s.isalnum()])
while len(deq) > 1:
if deq.popleft() != deq.pop():
return False
return True
var1 = 'A man, a plan, a canal: Panama'
var2 = 'race a car'
print(palindrome_deque(string=var1)) # True
print(palindrome_deque(string=var2)) # False
def palindrome_slicing(string: str) -> bool:
string = string.lower() # 소문자로 통일
string = re.sub('[^a-z0-9]', '', string) # 특수문자 필터
return string == string[::-1] # 역 슬라이싱!
print(palindrome_slicing(string=var1)) # True
print(palindrome_slicing(string=var2)) # False
덱(deque)
자료구조 사용 시 list 자료구조보다 성능이 나음list.pop(0)
연산의 경우 O(n) 의 시간복잡도를 가지지만, (재정렬을 위한 전체 탐색)deque.popleft()
연산은 O(1) 이다. (연결 리스트이므로 재정렬 불필요)문자열 슬라이싱
파이썬에서 문자열은 배열처럼 곧바로 인덱싱하여 다룰 수 있습니다. 별도의 자료구조에 매핑하여 사용하는 것보다 거의 항상 나은 성능을 나타냅니다.
조건은 다음과 같습니다.
[1] Input
["digl 8 1 5 1", "letl art can", "dig2 3 6", "let2 own kit dig", "let3 art zero"]
[2] Output
["letl art can", "let3 art zero", "let2 own kit dig", "digl 8 1 5 1", "dig2 3 6"]
def reorder_log_files(logs: list) -> list:
letters, digits = [], []
for log in logs:
if log.split()[1].isdigit():
digits.append(log)
else:
letters.append(log)
letters.sort(key=lambda x: (x.split()[1:], x.split()[0]))
return letters + digits
logs = ["digl 8 1 5 1", "letl art can", "dig2 3 6", "let2 own kit dig", "let3 art zero"]
reorder_log_files(logs=logs) # ["letl art can", "let3 art zero", "let2 own kit dig", "digl 8 1 5 1", "dig2 3 6"]
숫자로 된 로그는 입력 순서대로 맨 뒤에 붙고, 문자로 된 로그는 (1)문자열 순 (2)키순
우선순위대로 정렬합니다.
(이 정렬부위가 정렬 키로 람다함수가 쓰여진 곳)
금지된 단어를 제외한 가장 흔하게 등장하는 단어를 출력합니다. 대소문자 구분하지 않으며, 구두점 또한 무시합니다.
import re
import collections
from typing import List
def most_common_word(paragraph: str, banned: List[str]) -> str:
words = [
word
for word in re.sub(r'[^\w]', ' ', paragraph).lower().split()
if word not in banned
]
# [CASE 1] Using collections.Counter
counts = collections.Counter(words)
return counts.most_common(1)[0][0]
# [CASE 2] Using collections.defaultdict
# counts = collections.defaultdict(int)
# for word in words:
# counts[word] += 1
# return max(counts, key=counts.get)
paragraph = 'Bob hit a ball, the hit BALL flew far after it was hit.'
banned = ['hit']
print(most_common_word(paragraph=paragraph, banned=banned)) # 'ball'
문자열 배열을 입력받아 애너그램 단위로 그룹핑합니다.
애너그램?
일종의 언어유희로, 동일한 문자열을 재배열하여 다른 뜻을 가진 단어로 바꾸는 것을 말합니다.
애너그램을 판단하는 가장 간단한 방법은 정렬하여 비교하는 것입니다. 동일 문자열로 구성되어 결국 같아지기 때문입니다.
from collections import defaultdict
def make_anagram(anagrams: defaultdict, word: str) -> dict:
anagrams[''.join(sorted(word))].append(word)
return anagrams
words = ['eat', 'tea', 'tan', 'ate', 'nat', 'bat']
anagrams = defaultdict(list)
for word in words:
make_anagram(anagrams=anagrams, word=word)
print([sorted(value) for value in anagrams.values()]) # [['ate', 'eat', 'tea'], ['nat', 'tan'], ['bat']]
가장 긴 팰린드롬 부분 문자열의 길이를 출력합니다.
Longest Common Substring
최장 공통 부분 문자열은 여러 입력 문자열 중 공통된 가장 긴 부분 문자열을 찾는 문제입니다. 보통은 다이나믹 프로그래밍으로 풀지만, 하나의 입력 문자열 중 가장 긴 팰린드롬을 찾는 문제이므로, 투 포인터 방식을 활용할 수 있습니다.
import re
def get_sub_palindrome(string: str, left: int, right: int) -> str:
# Expanding from substring length 2 or 3
while left >= 0 and right < len(string) and string[left] == string[right]:
left -= 1
right += 1
return string[left + 1:right]
def solution(string):
# 영소문자, 특수문자 제거
string = string.lower()
string = re.sub('[^a-z0-9]', '', string)
# 한 글자이거나, 문자열 전체가 펠린드롬인 경우
if len(string) < 2 or string == string[::-1]:
return len(string)
# 가장 긴 부분 펠린드롬 구하기
answer = ''
for i in range(0, len(string) - 1):
answer = max(
answer,
get_sub_palindrome(string, i, i + 1),
get_sub_palindrome(string, i, i + 2),
key=len
)
return len(answer)
선형 자료구조는 순차적(sequential)인 객체를 다룹니다.
파이썬에서의 배열은 동적 자료형입니다.
이 말은, 주어지는 데이터에 따라서 할당된 메모리 공간이 가변적이라는 의미입니다. 이는 정적 할당 자료형보다는 처리속도 면에서 불리하긴 하지만, 사용자 편의성 측면에서의 이점이 더 크다고 볼 수 있습니다.
현실적으로 막대하게 큰 데이터나 가변성이 너무 높은 데이터가 아닌 이상 성능적인 이슈는 큰 문제가 되지 않습니다. (충분히 빠름)
파이썬에서 동적 배열은 초기 할당영역을 작게 잡았다가, 모두 채워지면 더블링Doubling 이후 모두 복사하는 방식으로 처리합니다. 초반에는 2배씩 늘리다가 전체적으로는 약 1.125배 수준으로 늘려갑니다.
파이썬에서 대표적인 동적 배열은 list 자료구조입니다.
덧셈하여 타겟을 만들 수 있는 배열의 두 숫자 인덱스를 리턴합니다.
[1] Input
nums, target = [2, 7, 11, 15], 9
[2] Output
[0, 1]
"""
Brute-Force, O(n^2)
"""
def get_two_sum_index_that_match_target1(nums: list, target: int) -> list:
for i in range(len(nums)):
for j in range(i + 1, len(nums)):
if nums[i] + nums[j] == target:
return [i, j]
return []
nums, target = [2, 7, 11, 15], 9
print(get_two_sum_index_that_match_target1(nums, target))
"""
Use keyword "in", O(n)
"""
def get_two_sum_index_that_match_target2(nums: list, target: int) -> list:
for idx, num in enumerate(nums):
complement = target - num
if complement in nums[idx + 1:]:
return [idx, nums.index(complement)]
return []
nums, target = [2, 7, 11, 15], 9
print(get_two_sum_index_that_match_target2(nums, target))
"""
Use reverse dict, advanced O(n)
-> find the index directly by dict
풀이 3: 첫 번째 수를 뺀 결과 키 조회
-> 인덱스와 값을 바꿔 값을 key 로 갖는 dict 자료형을 활용합니다.
-> 첫 번째 수를 뺀 나머지 key 값에 해당하는 value 가 곧 찾는 인덱스가 되도록 합니다.
-> 나머지 key 값이 주어진 데이터에 없을 수도 있으므로, 조건처리를 잘 해줍니다.
"""
def get_two_sum_index_by_dict(nums: list, target: int) -> list:
nums_map = {}
for idx, num in enumerate(nums):
if target - num in nums_map:
return [nums_map[target - num], idx] # find directly using hash key
nums_map[num] = idx
return []
nums, target = [2, 7, 11, 15], 9
print(get_two_sum_index_by_dict(nums, target))
"""
Two pointer, optimized O(n)
풀이 4: 투 포인터 활용하기
-> 양 끝 포인터에서부터 탐색합니다.
-> 탐색 이전에 정렬이 필요합니다.
-> 그러나, 정렬하면 인덱스가 흐트러지기 때문에 답을 구할 수 없습니다.
따라서, 인덱스를 구하는 것이 아닌 매칭되는 값을 구하는 문제라면 투 포인터를 활용할 수 있습니다.
"""
def get_two_sum_index_by_dict(nums: list, target: int) -> list:
nums_map = {}
for idx, num in enumerate(nums):
if target - num in nums_map:
return [nums_map[target - num], idx] # find directly using hash key
nums_map[num] = idx
return []
nums, target = [2, 7, 11, 15], 9
print(get_two_sum_index_by_dict(nums, target))
높이를 입력 받아 비 온 후 얼마나 많은 물이 쌓일 수 있는지 계산합니다.
[1] Input
[0, 1, 0, 2, 1, 0, 1, 3, 2, 1, 2, 1]
[2] Output
6
"""
Two pointer
투 포인터를 활용해 양 끝점 중 더 낮은 높이에서 높은 높이로 포인터를 이동시키며 진행합니다. (같은 경우 왼쪽 포인터 진행)
포인터가 이동할 때마다 각 사이드에서의 높이의 차이만큼씩 빗물의 양을 더해 나갑니다.
새로운 위치에서의 높이가 이전 위치에서의 최대 높이보다 높을 경우 최대 높이와 포인터 인덱스를 그것으로 교체합니다.
종료조건은 두 포인터가 만나는 지점입니다.
"""
def trap(height: list) -> int:
if not height:
return 0
result = 0
start, end = 0, len(height) - 1
left_max, right_max = height[start], height[end]
while start < end:
left_max = max(left_max, height[start])
right_max = max(right_max, height[end])
if left_max <= right_max:
result += left_max - height[start]
start += 1
else:
result += right_max - height[end]
end -= 1
return result
배열을 입력받아 합으로 0을 만들 수 있는 3개의 엘리먼트를 모두 출력합니다.
브루트 포스 방식으로 풀면 항이 세개이므로 O(n^3)
의 시간 복잡도가 걸립니다.
따라서 투 포인터 방식으로 해결하도록 합니다. (O(n^2)
)
[1] Input
nums = [-1, 0, 1, 2, -1, -4]
[2] Output
[
[-1, 0, 1],
[-1, -1, 2],
]
정렬 이후, 기준 인덱스를 잡고 투 포인터를 이동시키며 해답을 찾습니다.
from typing import List
def get_three_sum(nums: List[int]) -> List[List[int]]:
results = []
nums.sort()
for i in range(len(nums) - 2): # Two pointer
# 중복된 값 건너뛰기
if i > 0 and nums[i] == nums[i - 1]:
continue
# 간격을 좁혀가며 합 sum 계산
left, right = i + 1, len(nums) - 1
while left < right:
three_sum = nums[i] + nums[left] + nums[right]
if three_sum < 0:
left += 1
elif three_sum > 0:
right -= 1
else:
# 0 인 경우 정답 처리
results.append([nums[i], nums[left], nums[right]])
# 다음 항에 동일한 값이 있는 경우 한칸 더 이동
while left < right and nums[left] == nums[left + 1]:
left += 1
while left < right and nums[right] == nums[right - 1]:
right -= 1
# 포인터를 다음 항으로 이동
left += 1
right -= 1
return results
nums = [-1, 0, 1, 2, -1, -4]
print(get_three_sum(nums=nums))
투 포인터 해법은 정렬된 리스트를 대상으로 한다는 점을 기억합시다.
한 번의 거래로 낼 수 있는 최대 이익을 산출합니다.
[1] Input
prices = [7, 1, 5, 3, 6, 4]
[2] Output
# 1 일때 사서 6 일때 팔면 최대 수익 5
5
이 문제도 브루트 포스는 O(n^2)
의 시간 복잡도로 효율적이지 않습니다.
따라서 그래프상 시계열 우측으로 이동하면서 최저점과 현재 값과의 차이를 계산하여 그 값이 최대치인 경우를 해답으로 구할 수 있는데, 기술 통계적으로 그림을 그려보면 직관적으로 이해하기 쉽습니다.
여기서는 최저점과 최대값을 초기화한 후 각각 갱신해 나아가는 방식으로 풉니다. 전체 그래프에서 최저점 대비 최대값일 때가 가장 높은 수익입니다.
import sys
from typing import List
INF = sys.maxsize
def max_profit(prices: List[int]) -> int:
profit = 0
min_price = INF
# 최저점(min_price)과 최대값(profit)을 계속 갱신
for price in prices:
min_price = min(min_price, price)
profit = max(profit, price - min_price)
return profit
prices = [7, 1, 5, 3, 6, 4]
print(max_profit(prices=prices))
시계열로 진행하며 최저점이 바뀌면 결과 최대 profit 도 바뀝니다.
연결 리스트는 대표적인 선형 자료구조로 모든 노드가 앞뒤 노드를 참조하는 방식으로 구현됩니다.
따라서 삽입, 삭제 시 해당 노드의 연결 관계만 바꿔주면 되어 전체적인 재정렬이 필요 없습니다.(배열은 재정렬됨)
그러나 꼬리를 무는 구조이기 때문에 특정 노드의 값을 탐색하기 위해서는 앞에서부터 순차적으로 찾아들어가야 합니다. 이는 배열이 특정 인덱스의 값을 한 번에 찾는 것과는 대조됩니다.
연결 리스트가 팰린드롬 구조인지 판별합니다.
팰린드롬은 뒤집어도 같은 말이 되는 단어 또는 문장을 의미합니다.
파이썬 문자열은 기본적으로 배열의 특징을 갖기 때문에 역 슬라이싱으로 쉽게 풀 수 있지만,
string == string[::-1]
이 문제에서는 연결 리스트로 이루어져 있다고 가정합니다.
[1] Input
1->2
[2] Output
False
[1] Input
1->2->3->2->1
[2] Output
True
물론 연결 리스트도 리니어Linear한 자료구조이므로, 배열(deque)로 변환하여 동일하게 풀 수 있습니다. 하지만 이 때는 배열로 변환하는 비용이 추가적으로 발생하므로, 다른 더 최적화된 방식을 고려해볼 수 있습니다.
더 최적화된 방식은 런너Runner를 활용하는 것입니다.
아이디어는 두 배의 속도차이를 갖는 빠른 런너와 느린 런너를 초기화해 연결 리스트 head 에서부터 탐색해나가는 것입니다.
런너 기법
런너Runner는 연결 리스트 순회 시 두 개의 포인터를 동시에 사용하는 기법입니다. 한 포인터가 다른 포인터보다 앞서게 하여 병합 지점이나 중간 위치, 길이 등을 판별할 때 사용합니다. 보통 fast 런너는 두 칸씩, slow 런너는 한 칸씩 이동하여 fast 가 끝에 도달하면 slow 는 정확히 중간 지점에 도달합니다. 이렇게 중간 위치를 찾아내면, 값을 비교하거나 뒤집기 등에 활용 가능합니다. (리스트 문제에서 반드시 쓰이는 기법)
class ListNode:
def __init__(self, value: int, node: ListNode = None):
self.value = value
self.next = node
def is_palindrome(head: ListNode) -> bool:
if head is None:
return False
# rev 는 연결 리스트의 역순. 엄밀히는 slow 포인터의 역순!
# 따라서 마지막에 rev 와 slow 의 값을 순차적으로 비교하면 됨.
rev = None
# 동일 출발점인 head 에서 시작
slow = fast = head
# 런너를 이용해 역순 연결 리스트 구성
while fast and fast.next:
fast = fast.next.next
rev, rev.next, slow = slow, rev, slow.next
# fast 가 None 인지 여부에 따라 중앙값이 있는지 없는지 확인
# 연결 리스트의 노드가 홀수 개인 경우 중앙값이 존재하게 되므로, slow 포인터의 개수가 rev 보다 하나 많게 됨
# 따라서 한 칸 진행한 slow.next 부터 rev 와 비교 시작!!
if fast:
slow = slow.next
# rev 와 slow 를 하나씩 탐색하며 팰린드롬 여부 확인
while rev and rev.value == slow.value:
slow, rev = slow.next, rev.next
return not rev
# Test!
nums = [1, 2, 3, 2, 1]
head = ListNode(nums[0])
node = head
for num in nums[1:]:
next = ListNode(num)
node.next = next
node = next
# Palindrome??
print(is_palindrome(head=head)) # True
스택은 LIFO, 큐는 FIFO 입니다.
파이썬에서는 list 자료형으로 스택과 큐 모두를 사용할 수 있지만, 큐의 경우는 더 나은 성능인 연결 리스트를 활용한 deque 객체로 구현합니다. deque 는 첫 번째 인덱스의 값을 pop 하거나 중간값을 제거해도 전체적인 재정렬이 필요 없는 장점이 있습니다.
스택을 연결 리스트로 구현하면 다음과 같습니다.
class Node:
def __init__(self, item = None, next = None):
self.item = item
self.next = next
class Stack:
def __init__(self):
self.last = None
def push(self, item):
self.last = Node(item, self.last)
def pop(self):
item = self.last.item
self.last = self.last.next
return item
stack = Stack()
stack.push(1)
stack.push(2)
stack.push(3)
stack.push(4)
stack.push(5)
for _ in range(5):
print(stack.pop()) # 5, 4, 3, 2, 1 순으로 출력
큐Queue 는 시퀀스의 한쪽 끝에는 엔티티를 추가하고, 다른 반대쪽에는 제거할 수 있는 엔티티 컬렉션이다.
큐 자체보다는 이를 응용한 데크Deque 나 우선순위 큐Priority Queue 가 자주 사용됩니다.
큐는 BFS 나 캐시 구현, 다익스트라 알고리즘에서 우선순위 방문 노드를 결정할 때 사용됩니다.
파이썬에서 LIFO 인 스택은 list 이고, 연결 리스트를 활용해서 구현하기도 하지만, 이 문제에서는 큐(FIFO)로 스택(LIFO)을 구현하라고 하는 점이 흥미롭습니다.
따라서 큐를 활용한 스택에 값을 push 하면 해당 값을 맨 뒤가 아닌 맨 앞으로 보내 pop 시 우선적으로 리턴되도록 해야 합니다.
주의할 점
파이썬에서 deque 는 pop 과 leftpop 양쪽을 지원하기 때문에, 문제의 취지를 살리고자 본래 큐의 기능인 leftpop 만을 사용하도록 구현합니다.
from collections import deque
class MyStack:
def __init__(self):
"""대표적인 큐 구현체인 데크 활용"""
self.q = deque()
def push(self, value):
"""스택이므로 마지막에 push 한다는 개념"""
# Case 1: Use loop
self.q.append(value)
for _ in range(len(self.q) - 1):
"""맨 앞에 있던 값이 맨 뒤로 가도록 데크의 길이보다 1 적게 순회"""
self.q.append(self.q.popleft())
def advanced_push(self, value):
# Case 2: Use extend method
new_q = deque([value])
new_q.extend(self.q)
self.q = new_q
def pop(self):
"""스택이므로 마지막 값을 pop 한다는 개념"""
return self.q.popleft()
def top(self):
"""마지막 값을 가져옴"""
return self.q[0]
def empty(self):
"""스택이 비었는지 확인"""
return len(self.q) == 0
이번엔 반대로 스택(LIFO)으로 큐(FIFO)를 구현합니다.
풀이는 [1] 스택 한 개를 활용한 풀이
와 [2] 스택 두 개를 활용한 풀이
두 방법이 있습니다.
"""
[1] 스택 한 개를 활용한 풀이
-> push 할 때 입력된 값을 스택의 제일 하단으로 보내고
-> 기존의 스택은 뒤집어서(reversed) 새 스택의 뒤에 연결(extend) 해줍니다.
-> 말 그대로 reversed 모듈과 extend 메서드를 사용할 수도 있습니다.
"""
class MyQueueOne:
def __init__(self):
"""오리지날 스택인 list 활용"""
self.stack = []
def push(self, value):
"""큐이므로 마지막에 push 한다는 개념"""
# Case 1: Reverse and append self.stack to new stack
new_stack = [value]
self.stack = list(reversed(self.stack)) # 직접 구현 reversed 기능을 사용하도록.
for _ in range(len(self.stack)):
new_stack.append(self.stack.pop())
self.stack = new_stack
def advanced_push(self, value):
# Case 2: Extend self.stack to new stack
new_stack = [value]
new_stack.extend(self.stack)
self.stack = new_stack
def pop(self):
"""큐이므로 첫 번째 값을 pop 한다는 개념"""
return self.stack.pop()
def peek(self):
"""첫 번째 값을 가져옴"""
return self.stack[-1]
def empty(self):
"""큐가 비었는지 확인"""
return len(self.stack) == 0
"""
[2] 스택 두 개를 활용한 풀이
-> 여기서는 reversed 된 출력용 output 스택을 하나 더 씁니다.
-> output 은 peek 시에 한번 초기화됩니다.
"""
class MyQueueTwo:
def __init__(self):
self.input = []
self.output = []
def push(self, value):
self.input.append(value)
def pop(self):
self.peek() # output 한번 초기화!
return self.output.pop()
def peek(self):
"""output 이 없으면 모두 재입력 (한번 초기화)"""
if not self.output:
while self.input:
self.output.append(self.input.pop())
return self.output[-1]
def empty(self):
return len(self.stack) == 0
데크Deque 는 Double-Ended Queue 의 줄임말입니다.
구현시에는 이중 연결 리스트를 활용하여 역방향 탐색도 가능하게 합니다.
우선순위 큐Priority Queue 는 큐 또는 스택과 같은 ADT 와 유사하지만, 추가로 각 요소의 “우선순위”와 연관되어 있습니다.
따라서 우선순위 큐에서는 어떠한 특정 조건에 따라 우선순위가 가장 높은 요소가 추출됩니다.
파이썬에서 우선순위 큐는 heapq 객체가 사용됩니다.
주어진 k 개의 정렬된 리스트를 1 개의 정렬된 리스트로 병합합니다.
[1] Input
[
1->4->5,
1->3->4,
2->6
]
[2] Output
1->1->2->3->4->4->5->6
여기서 k 는 3.
import heapq
from typing import List
class ListNode:
def __init__(self, value: int, node: ListNode = None):
self.value = value
self.next = node
def merge_K_lists(lists: List[ListNode]) -> ListNode:
root = result = ListNode(None)
heap = []
# 각 연결 리스트의 루트를 힙에 저장
for i in range(len(lists)):
if lists[i]:
# (value, index, node) 형태로 heappush 하면 value 우선순위대로 오름차순 정렬됨
heapq.heappush(heap, (lists[i].value, i, lists[i]))
# 힙 추출 이후 다음 노드는 다시 저장
while heap:
node = heapq.heappop(heap)
idx = node[1]
result.next = node[2]
result = result.next
if result.next:
heapq.heappush(heap, (result.next.value, idx, result.next))
return root.next
생일 문제를 보면 비둘기집 원리에 따라 충돌이 발생할 확률이 생각보다 높다는 사실을 알 수 있습니다.
비둘기집 원리
n 개 아이템을 m 개 컨테이너에 넣을 때, n > m 이라면 적어도 하나의 컨테이너에는 반드시 2개 이상의 아이템이 들어 있다는 원리
해시 함수의 효율성을 측정할 때 로드 팩터를 사용합니다.
로드 팩터
해시 테이블에 저장된 데이터 개수 n 을 버킷의 개수 k 로 나눈 것 (n / k)
자바 10에서는 디폴트 로드 팩터를 0.75로 정해 '시간과 공간의 적절한 절충안'
이라고 이야기합니다.
해시 함수
해시 함수를 이용해 해시 테이블 인덱싱하는 것을 해싱이라고 합니다.
대표적인 해싱 기법은 나눗셈 기법Modulo-Division Method 입니다.
h(x) = x mod m
입력값 x 를 해시 테이블의 크기 m 으로 나눈 나머지를 인덱스로 삼습니다. (항상 고정값!)
아무리 좋은 해시 함수도 결국 충돌은 발생하게 됩니다. 따라서 충돌 시 어떻게 처리할 것인지에 대한 기법이 필요하게 되었습니다.
개별 체이닝
해싱 결과 동일한 인덱스를 가지는 데이터들을 연결 리스트로 체이닝합니다.
개별 체이닝 방식을 사용하면 보통의 탐색은 O(1) 의 시간복잡도를 가지지만, 최악의 경우 모두 충돌이 발생했다 가정하면 O(n) 이 걸립니다. 이는 모든 데이터가 하나의 인덱스에 체이닝됨을 의미합니다.
오픈 어드레싱
…
다음의 기능을 제공하는 해시맵을 디자인합니다.
해싱은 모듈로 기법으로 구현하며 충돌 시 개별 체이닝 방식을 사용합니다.
from collections import defaultdict
class ListNode:
def __init__(self, key: int = None, value: int = None):
self.key = key
self.value = value
self.next = None
class MyHashMap:
def __init__(self):
self.size = 1000
self.table = defaultdict(ListNode)
def put(self, key: int, value: int) -> None:
index = key % self.size
# 인덱스에 노드가 없다면 삽입 후 종료
if self.table[index].value is None:
self.table[index] = ListNode(key, value)
return
# 인덱스에 노드가 존재하는 경우 연결 리스트 처리
node = self.table[index]
while node:
# 같은 키라면 업데이트 후 종료
if node.key == key:
node.value = value
return
# 마지막 노드까지 오면 break
if node.next is None:
break
node = node.next
node.next = ListNode(key, value)
def get(self, key: int) -> int:
index = key % self.size
if self.table[index].value is None:
return -1
# 노드가 존재할 때 일치하는 키 탐색
node = self.table[index]
while node:
# 일치하는 키가 있으면 해당 값 반환
if node.key == key:
return node.value
node = node.next
return -1
def remove(self, key: int) -> None:
index = key % self.size
if self.table[index].value is None:
return
# 인덱스의 첫 번째 노드일 때 삭제 처리
node = self.table[index]
if node.key == key:
self.table[index] = ListNode() if node.next is None else node.next
return
# 연결 리스트 노드 삭제
prev = node
while node:
if node.key == key:
prev.next = node.next
return
prev, node = node, node.next
hashMap = MyHashMap()
hashMap.put(1, 1)
hashMap.put(2, 2)
hashMap.get(1) # return 1
hashMap.get(3) # return -1
hashMap.put(2, 1) # update 2's value to 1
hashMap.get(2) # return 1
hashMap.remove(2) # remove key 2 node
hashMap.get(2) # return -1
오일러 경로는 대표적인 그래프 이론으로 한 붓 그리기라고도 합니다.
오일러 경로
연결된 그래프에서 모든 정점의 차수Degree 가 짝수이면 성립합니다.
해밀턴 경로는 각 정점을 한 번씩 방문하는 무방향 또는 방향 그래프 경로를 말합니다.
오일러 경로는 간선을 기준으로 한다면, 해밀턴 경로는 정점을 기준으로 합니다. 그리고 이것은 경로를 찾는 최적 알고리즘이 없는 대표적인 NP-완전Complete 문제입니다.
다시 원점으로까지 돌아오는 경로를 해밀턴 순환이라 하며, 이는 외판원 문제로 빈번하게 출제됩니다.(해밀턴 순환 중 최단경로)
NP 복잡도
…
그래프 순회는 그래프를 탐색하는 기법으로 깊이 우선 탐색DFS 와 너비 우선 탐색BFS 이 있습니다.
DFS 는 주로 스택으로 구현하는데, 백트래킹 문제에서 뛰어난 효용을 보입니다.
BFS 는 큐를 이용하여 그래프의 최단 경로 구하기 문제에서 사용됩니다.
백트래킹은 탐색할 노드가 많은 트리에서 탐색을 진행하다 가능성이 없다고 판단되는 즉시 후보를 포기하면서 정답을 찾아가는 방식입니다.
최악의 경우를 제외하고, 모든 노드를 탐색하는 브루트 포스보다 나은 성능이 나타납니다.(가지치기 방식)
백트래킹은 제약 충족 문제CSP를 풀이하는 데 필수적인 알고리즘입니다.
제약 충족 문제Constraint Satisfaction Problems
수많은 제약 조건을 충족하는 상태를 찾아내는 수학 문제
1을 육지로, 0을 물로 갖어한 2D 그리드 맵이 주어졌을 때, 섬의 개수를 계산합니다.
[1] Input
11110
11010
11000
00000
[2] Output
1
[1] Input
11000
11000
00100
00011
[2] Output
3
이 문제는 DFS 로 풀이하면 됩니다.
값이 1인 지점을 노드로 하는 그래프로 볼 수 있기 때문에 시작점부터 DFS 로 탐색이 완료되면 하나의 섬이고, 방문하지 않은 다른 노드로부터 DFS 를 수행해 섬의 개수를 카운팅합니다.
따라서 방문 노드 스택을 만들고, 값이 1인 모든 노드에 방문하면 마치도록 합니다.
from typing import List
def num_is_islands(grid: List[List[str]]) -> int:
def dfs(i, j):
# 더 이상 땅이 아닌 경우 종료
if i < 0 or i >= len(grid) or \
j < 0 or j >= len(grid[0]) or \
grid[i][j] != '1':
return
# 방문 처리하고 동서남북 탐색
grid[i][j] = '0'
dfs(i + 1, j)
dfs(i - 1, j)
dfs(i, j + 1)
dfs(i, j - 1)
count = 0
for i in range(len(grid)):
for j in range(len(grid[0])):
if grid[i][j] == '1':
# 재귀로 dfs 를 호출하며 인접한 모든 지점을 탐색 후 방문처리
# 더 이상 인접한 지점이 없다면 하나의 섬으로 간주하고 count 1 증가
# 다음 탐색 시에 방문했던 노드를 또 가게 되면 이미 방문처리 되었기 때문에 또 방문하지 않음
dfs(i, j)
count += 1
return count # 방문한 섬의 개수 반환
grid = [
['1', '1', '0', '0', '0'],
['1', '1', '0', '0', '0'],
['0', '0', '1', '0', '0'],
['0', '0', '0', '1', '1'],
]
print(num_is_islands(grid=grid)) # 3
서로 다른 정수를 입력받아 가능한 모든 순열을 리턴합니다.
순열은 순서를 고려하고, 조합은 순서를 고려하지 않습니다. 순열은 순서가 있어서 순열입니다.
순열Permutation
nPk = n! / (n - k)!
"""
[1] 순열: 직접 구현하기!
"""
from typing import List
def permute(nums: List[int]) -> List[List[int]]:
results = []
prev_elems = []
def dfs(elems):
# 리프 노드일 때 결과 추가
if len(elems) == 0:
results.append(prev_elems[:]) # copy data
# 순열 생성 재귀 호출
for e in elems:
next_elems = elems[:]
next_elems.remove(e)
prev_elems.append(e)
dfs(next_elems)
prev_elems.pop()
dfs(nums)
return results
"""
[2] 순열: itertools 활용하기~
"""
from typing import List
from itertools import permutations
def permute(nums: List[int]) -> List[List[int]]:
return list(permutations(nums))
전체 수 n 을 입력받아 k 개의 조합Combination 을 반환합니다.
[1] Input
n = 4
k = 2
[2] Output
[
[2, 4],
[3, 4],
[2, 3],
[1, 2],
[1, 3],
[1, 4],
]
조합은 순서를 고려하지 않으므로, 동일한 수로 구성되어 있다면 순서에 상관 없이 다시 결과에 포함시키지 않습니다.
조합Combination
nCk = cPk / k!
"""
[1] 조합: 직접 구현하기!
"""
from typing import List
def combine(n: int, k: int) -> List[List[int]]:
results = []
def dfs(elems, start: int, k: int):
if k == 0:
results.append(elems[:])
return
# 자기 자신 이전의 모든 값을 고정하여 재귀 호출
for i in range(start, n + 1):
elems.append(i)
dfs(elems, i + 1, k - 1)
elems.pop()
dfs([], 1, k)
return results
"""
[2] 조합: itertools 활용하기~
"""
from typing import List
from itertools import combinations
def combine(n: int, k: int) -> List[List[int]]:
return list(combinations(range(1, n + 1), k))
최단 경로 문제
각 간선의 가중치의 합이 최소가 되는 두 정점(또는 노드) 사이의 경로를 찾는 문제입니다.
가장 유명한 최단경로 알고리즘은 다익스트라 인데, 항상 노드 주변의 최단 경로만을 택하는 그리디Greedy 방식으로 구현됩니다.
네트워크 딜레이 타임 문제는 다익스트라 알고리즘으로 풉니다.
[1] Input
K = 2
N = 4
times = [[2, 1, 1], [2, 3, 1], [3, 4, 1]]
[2] Output
2
다익스트라 구현 시 인접 노드들을 우선순위 큐인 collections.heapq
에 최소 힙Min Heap
방식으로 저장합니다.
import heapq
from typing import List
from collections import defaultdict
def network_delay_time(times: List[List[int]], N: int, K: int) -> int:
graph = defaultdict(int)
# 그래프 인접 리스트 구성
for u, v, w in times:
graph[u].append((v, w))
# 큐 변수: [(소요시간, 정점)]
queue = [(0, K)]
dist = defaultdict(int)
# 우선순위 큐 최소값 기준으로 정점까지 최단 경로 삽입
while queue:
time, node = heapq.heappop(queue)
if node not in dist:
for v, w in graph[node]:
alt = time + w
heapq.heappush(queue, (alt, v))
# 모든 노드의 최단 경로 존재 여부 판별
if len(dist) == N:
return max(dist.values())
return -1
트리는 루트 값과 부모-자식 관계의 서브트리로 구성되며, 서로 연결된 노드의 집합입니다. 역방향은 없으며, 자식 노드는 하나의 부모 노드만을 갖습니다.
트리는 또한 순환구조를 갖지 않는 그래프입니다.
나아가 이진 트리는 모든 노드의 차수Degree가 2 이하인 트리입니다. 따라서 대소 비교 및 정렬에 유용하게 사용됩니다.
Binary Search Tree 는 이진 트리를 (정렬 및) 탐색을 위한 도구로써 사용하는 기법을 말합니다. 이것의 특징은 노드의 왼쪽에는 노드의 값보다 작은 값들로만 이루어진 서브트리가, 오른쪽에는 큰 값들로만 이루어진 서브트리가 존재한다는 것입니다.
이로써 새로운 값이 삽입되면 노드의 어느 방향으로 가야 하는지 판별이 가능해 자동 정렬이 이루어지며,
트리의 각 레벨 당 노드의 최대 개수는 항상 정해져 있기 때문에(-> 2의 배수)
O(logn)
의 탐색 성능을 갖는 효율적인 알고리즘이 됩니다.
BST 는 최적의 경우 이진 탐색 알고리즘과 동일한 성능을 나타냅니다.
힙은 트리 기반의 자료구조로써, 최소 힙의 특성을 만족하는 거의 완전 트리입니다.
최소 힙의 특성이란, 부모 노드의 값이 자식 노드의 값보다 항상 같거나 작다
는 것입니다.
(최소 힙의 원리는 다익스트라의 우선순위 큐 자동정렬에 적용됨)
이 원리를 기반으로 새로운 값이 삽입되면, 완전 이진 트리의 맨 마지막 자리에 노드를 추가하고 해당 노드의 값을 부모 노드의 값과 반복 비교하며 재배치해 나갑니다.
class BinaryHeap(object):
def __init__(self):
self.items = [None]
def __len__(self):
# except index 0
return len(self.items) - 1
# 삽입 시 실행, 반복 구조 구현
def _percolate_up(self):
i = len(self)
parent = i // 2 # 이진 트리 레벨에 따라 부모 탐색
while parent > 0:
if self.items[i] < self.items[parent]:
self.items[parent], self.items[i] = self.items[i], self.items[parent]
i = parent
parent = i // 2
def insert(self, k):
self.items.append(k)
self._percolate_up()
# 추출시 실행, 재귀 구조 구현
def _percolate_down(self, idx):
left = idx * 2
right = idx * 2 + 1
smallest = idx
if left <= len(self) and self.items[left] < self.items[smallest]:
smallest = left
if right <= len(self) and self.items[right] < self.items[smallest]:
smallest = right
if smallest != idx:
self.items[idx], self.items[smallest] = self.items[smallest], self.items[idx]
self._percolate_down(smallest)
def extract(self):
extracted = self.items[1]
self.items[1] = self.items[len(self)]
self.items.pop()
self._percolate_down(1)
return extracted
본격적인 알고리즘 풀이입니다. 알고리즘은 다음의 순서로 풉시다.
O(logn)
의 좋은 성능def bubble_sort(A):
for i in range(1, len(A)):
for j in range(0, len(A) - 1):
cnt = 0
if A[j] > A[j + 1]:
A[j], A[j + 1] = A[j + 1], A[j]
cnt += 1
if cnt > 0:
return
고정 사이즈의 윈도우가 이동하면서 윈도우 내에 있는 데이터를 이용해 문제를 풀이하는 알고리즘을 슬라이딩 윈도우라고 합니다.
투 포인터 기법과의 차이는 고정 윈도우를 사용하며, 굳이 정렬되지 않아도 적용 가능하다는 것입니다.
슬라이딩 윈도우는 2개의 네트워크 호스트 간의 패킷 흐름을 제어하기 위한 방법을 지칭하는 네트워크 용어이기도 합니다.
배열 nums 가 주어졌을 때 k 크기의 슬라이딩 윈도우를 오른쪽 끝까지 이동하면서 최대 슬라이딩 윈도우를 구합니다.
[1] Input
nums = [1, 2, -1, -3, 5, 3, 6, 7]
k = 3
[2] Output
[3, 3, 5, 5, 6, 7]
"""
슬라이딩 윈도우 1: 브루트 포스로 풀기
"""
from typing import List
def max_sliding_window1(nums: List[int], k: int) -> List[int]:
if not nums:
return nums
r = []
for i in range(len(nums) - k + 1):
r.append(max(nums[i:i + k]))
return r
"""
슬라이딩 윈도우 2: 큐 활용하기
"""
from typing import List
def max_sliding_window2(nums: List[int], k: int) -> List[int]:
pass
def edit_distance(str1, str2, insert, delete, change):
n, m = len(str1), len(str2)
# Make memoization
answer = [[0 for _ in range(m + 1)] for _ in range(n + 1)]
# Initialize
for i in range(1, n + 1):
answer[1][0] = i
for j in range(1, m + 1):
answer[0][j] = j
# Calc distance
for i in range(n):
for j in range(m):
answer[i + 1][j + 1] = min(
answer[i][j + 1] + delete,
answer[i + 1][j] + insert,
answer[i][j] + (0 if str1[i] == str2[j] else change),
)
return answer[n][m]
암호화된 비밀 지도 두 개가 주어지고, 다음의 규칙을 통해 비상금이 숨겨진 경로를 해독합니다.
[INPUT type]
1 <= n <= 16
0 <= x <= 2^n - 1
을 만족한다.[OUTPUT type]
[1] Input
> n = 5
> arr1 = [9, 20, 28, 18, 11]
> arr2 = [30, 1, 21, 17, 28]
["#####", "# # #", "### #", "# ##", "#####"]
정수 암호는 이진수로 변환하고, 두 지도 매핑은 비트 연산자 or 를 활용합니다.
"""
비밀 지도:
이차원 배열로 이루어진 지도1, 지도2를 겹쳐서 전체 지도를 얻는 문제
지도는 한 변의 길이가 n 인 정사각형
주어진 두 정수 배열의 각 숫자를 이진수로 변환하면 이차원 배열의 행이 됨
1은 벽("#"), 0은 공백(" ")
"""
from typing import List
def secret_map(n: int, arr1: List[int], arr2: List[int]) -> List[str]:
answer = []
arr1 = [f'{int(num):b}'.zfill(n) for num in arr1]
arr2 = [f'{int(num):b}'.zfill(n) for num in arr2]
for row in range(n):
answer.append(''.join([
str(
int(arr1[row][idx]) or int(arr2[row][idx])
).replace('0', ' ').replace('1', '#')
for idx in range(n)
]))
return answer
다트 게임의 점수 계산 로직은 다음과 같습니다.
0~10의 정수와 문자 S, D, T, *, # 로 구성된 문자열 입력 시 총점 반환하는 함수를 작성합니다.
[1] Input
"1S2D*3T"
"1D2S#10S"
[2] Output
37 # 1^1 * 2 + 2^2 * 2 + 3^3
9 # 1^2 + 2^1 * (-1) + 10^1
def dart(dart_result: str) -> int
nums = [0]
for s in dart_result:
if s == 'S':
nums[-1] **= 1
nums.append(0)
elif s == 'D':
nums[-1] **= 2
nums.append(0)
elif s == 'T':
nums[-1] **= 3
nums.append(0)
elif s == '*':
# 이전 값, 그 이전 값 모두 두 배 처리
nums[-2] *= 2
if len(nums) > 2:
nums[-3] *= 2
elif s == '#':
nums[-2] *= -1
else:
# 자릿수 올림
nums[-1] = nums[-1] * 10 + int(s)
return sum(nums)
프렌즈4블록 게임에서 블록의 첫 배치가 주어졌을 때, 지워지는 블록의 개수를 반환합니다.
[1] Input
m = 4
n = 5
board = ["CCBDE", "AAADE", "AAABF", "CCBBF"]
[2] Output
14
from typing import List
def friends_4_block(m: int, n: int, board: List[str]) -> int:
board = [list(x) for x in board]
matched = True
while matched:
# 1) 일치 여부 판별
matched = []
for i in range(m - 1):
for j in range(n - 1):
if board[i][j] == board[i][j + 1] == board[i + 1][j] == board[i + 1][j + 1] != '#':
matched.append([i, j])
# 2) 일치한 위치 삭제
for i, j in matched:
board[i][j] = board[i][j + 1] = board[i + 1][j] = board[i + 1][j + 1] = '#'
# 3) 빈공간 블럭 처리
for _ in range(m):
for i in range(m - 1):
for j in range(n):
if board[i + 1][j] == '#':
board[i + 1][j], board[i][j] = board[i][j], '#'
return sum(x.count('#') for x in board)
로그를 참조하여 초당 최대 처리량을 계산합니다.
초당 최대 처리량은 요청의 응답 완료 여부에 관계없이 임의 시간부터 1초(=1,000밀리초)간 처리하는 요청의 최대 개수를 의미합니다.
로그 데이터 lines 배열에 대해 초당 최대 처리량을 반환합니다.
[1] Input
["2016-09-15 01:00:04.001 2.0s", "2016-09-15 01:00:07.000 2s"]
[2] Output
1