가장 긴 연속 수열과 LRU 캐시
집합을 사용해 O(n)에 longest-consecutive-sequence를 해결한 뒤 OrderedDict로 LRU 캐시를 설계합니다.
가장 긴 연속 수열과 LRU 캐시은(는) CoddyKit의 무료 DSA Interview Prep 강의입니다. 이것은 4개 중 4번째 강의입니다. 아래에서 전체 강의를 무료로 읽을 수 있으며, 내장 코드 에디터와 24/7 AI 튜터와 함께 브라우저에서 직접 실습할 수 있습니다. 이 강의는 DSA Interview Prep 학습 경로의 일부이며, 진행 상황이 웹과 CoddyKit 앱에 동기화됩니다. DSA Interview Prep 강의에는 총 4개의 강의가 포함되어 있습니다.
가장 긴 연속 수열 문제
LeetCode 128 '가장 긴 연속 수열': 정렬되지 않은 배열이 주어졌을 때 가장 긴 연속 정수 수열의 길이를 찾습니다. 예를 들어 [100,4,200,1,3,2]에는 길이가 4인 연속 수열 [1,2,3,4]가 포함되어 있습니다. 정렬 후 스캔하면 O(n log n)이 되지만, 이 문제는 그보다 빠른 O(n)에 해결해야 합니다.
핵심 통찰은 O(1) 멤버십 확인을 위해 집합을 사용하고, 수열의 가장 작은 원소에서만 수를 세기 시작하는 것입니다. 수열의 가장 작은 원소인지는 그 이전 값이 집합에 없는지 확인하여 판별합니다.
def longestConsecutive(nums):
num_set = set(nums)
best = 0
for n in num_set:
if n - 1 not in num_set: # n is the start of a sequence
curr_n = n
length = 1
while curr_n + 1 in num_set:
curr_n += 1
length += 1
best = max(best, length)
return best
print(longestConsecutive([100,4,200,1,3,2])) # 4
print(longestConsecutive([0,3,7,2,5,8,4,6,0,1])) # 9O(n) 증명이 성립하는 이유
바깥쪽 for 반복문의 모든 반복을 통틀어 각 숫자는 while 반복문에서 최대 한 번만 방문됩니다. for 반복문 안에 while 반복문이 있어도 모든 바깥쪽 반복에 걸친 while 반복 횟수의 총합은 최대 n입니다. 각 숫자는 최대 하나의 수열에서만 'curr_n + 1'이 될 수 있기 때문입니다. 이 분할 상환 분석으로 전체 시간 복잡도는 O(n)이 되며, 단조 스택 분석과 비슷합니다.
# Demonstrate O(n) total inner iterations
nums = list(range(1000)) # worst case: one long sequence
num_set = set(nums)
inner_iters = 0
for n in num_set:
if n - 1 not in num_set:
curr = n
while curr + 1 in num_set:
curr += 1
inner_iters += 1
print('n =', len(nums), ' total inner iterations =', inner_iters)
# inner_iters = n-1 <= n => O(n)대안: 정렬 기반 방법
대조적으로 정렬 후 스캔하는 방법은 O(n log n)에 실행됩니다. 배열을 정렬하고, 연속된 중복을 제거한 다음, 연속된 구간의 길이를 셉니다. 더 느리지만 제자리 정렬을 한다면 추가 공간을 O(1)만 사용합니다. 집합을 사용하는 방법은 O(n)의 추가 공간을 사용합니다. 면접에서는 두 방법을 모두 언급하고, 공간 제약을 고려할 때 O(n log n) 해법이 허용되는지 명확히 설명하세요.
def longestConsecutive_sort(nums):
if not nums:
return 0
nums.sort()
best = length = 1
for i in range(1, len(nums)):
if nums[i] == nums[i-1]:
continue # skip duplicates
if nums[i] == nums[i-1] + 1:
length += 1
best = max(best, length)
else:
length = 1
return best
print(longestConsecutive_sort([100,4,200,1,3,2])) # 4LRU 캐시란 무엇인가
LRU(가장 최근에 사용되지 않은 항목부터 제거하는) 캐시는 용량이 고정된 자료 구조입니다. 캐시가 가득 찬 상태에서 새 항목을 삽입해야 하면 가장 오랫동안 사용되지 않은 항목을 제거합니다. 연산은 다음과 같습니다. get(key)은 키가 있으면 값을 반환하고 해당 항목을 최근 사용 항목으로 표시하며, 키가 없으면 -1을 반환합니다. put(key, value)는 쌍을 삽입하고, 용량이 가득 찼다면 LRU 항목을 제거합니다.
LRU 캐시는 운영 체제(페이지 교체), 브라우저 캐시, 데이터베이스 질의 캐시에서 사용됩니다. LeetCode 146에서는 O(1)의 get 및 put 연산을 지원하는 캐시를 구현하도록 요구합니다.
OrderedDict를 사용한 LRU 캐시
파이썬의 collections.OrderedDict는 삽입 순서를 유지하며, move_to_end(key)(O(1))를 지원해 항목을 가장 최근에 사용된 항목으로 표시할 수 있습니다. put을 수행할 때 키를 끝으로 옮기고, 용량을 초과하면 첫 번째 항목을 꺼냅니다(LRU). 이 방식은 내부적으로 이중 연결 리스트와 해시 맵을 기반으로 하는 내장 기능을 사용하여 get과 put을 O(1)에 수행합니다.
from collections import OrderedDict
class LRUCache:
def __init__(self, capacity):
self.capacity = capacity
self.cache = OrderedDict()
def get(self, key):
if key not in self.cache:
return -1
self.cache.move_to_end(key) # mark as recently used
return self.cache[key]
def put(self, key, value):
if key in self.cache:
self.cache.move_to_end(key)
self.cache[key] = value
if len(self.cache) > self.capacity:
self.cache.popitem(last=False) # evict LRU (first item)
cache = LRUCache(2)
cache.put(1, 1); cache.put(2, 2)
print(cache.get(1)) # 1 (and 1 becomes most recently used)
cache.put(3, 3) # evict key 2 (LRU)
print(cache.get(2)) # -1
cache.put(4, 4) # evict key 1 (LRU)
print(cache.get(1)) # -1
print(cache.get(3)) # 3
print(cache.get(4)) # 4처음부터 구현하는 LRU 캐시: 이중 연결 리스트 + HashMap
처음부터 구현하는 방식에서는 이중 연결 리스트(노드를 O(1)에 제거하기 위해)와 해시 맵(키로 노드를 O(1)에 찾기 위해)을 사용합니다. 리스트는 LRU(head.next)부터 MRU(tail.prev)까지의 순서를 유지합니다. 더미 헤드와 테일 센티널을 사용하면 경계에서 삽입하거나 제거할 때 발생하는 예외적인 경우를 없앨 수 있습니다.
class DNode:
def __init__(self, key=0, val=0):
self.key = key
self.val = val
self.prev = None
self.next = None
class LRUCacheDLL:
def __init__(self, capacity):
self.cap = capacity
self.map = {} # key -> DNode
self.head = DNode() # dummy LRU end
self.tail = DNode() # dummy MRU end
self.head.next = self.tail
self.tail.prev = self.head
def _remove(self, node):
node.prev.next = node.next
node.next.prev = node.prev
def _add_to_tail(self, node):
node.prev = self.tail.prev
node.next = self.tail
self.tail.prev.next = node
self.tail.prev = node
def get(self, key):
if key not in self.map:
return -1
node = self.map[key]
self._remove(node)
self._add_to_tail(node)
return node.val
def put(self, key, val):
if key in self.map:
self._remove(self.map[key])
node = DNode(key, val)
self._add_to_tail(node)
self.map[key] = node
if len(self.map) > self.cap:
lru = self.head.next
self._remove(lru)
del self.map[lru.key]
cache = LRUCacheDLL(2)
cache.put(1,1); cache.put(2,2)
print(cache.get(1)) # 1
cache.put(3,3)
print(cache.get(2)) # -1 (evicted)LRU에 이중 연결 리스트를 사용하는 이유
단일 연결 리스트는 이전 노드를 알지 못하면 임의의 노드를 O(1)에 제거할 수 없습니다. 이중 연결 리스트는 prev와 next 포인터를 모두 저장하므로 노드 참조가 주어졌을 때 O(1)에 제거할 수 있습니다. 해시 맵은 키로 노드에 O(1)에 접근할 수 있게 합니다. 두 자료 구조를 함께 사용하면 get(key)은 노드를 찾고 테일로 옮기는 데 각각 O(1)이 걸리며, put(key)은 항목을 추가하는 데 O(1), 헤드에서 LRU 노드를 제거하는 데 O(1)이 걸립니다.
# Why not a singly linked list?
# To remove a node you need its predecessor
# With SLL: must traverse from head to find predecessor => O(n)
# With DLL: node.prev IS the predecessor => O(1) removal
print('SLL removal: O(n) — must find predecessor by traversal')
print('DLL removal: O(1) — node.prev is immediately available')
print('Hash map lookup: O(1) — get DNode reference by key')
print('Combined LRU get/put: O(1) average')LFU 캐시 (가장 적게 사용된 항목부터 제거)
더 어려운 변형은 LFU 캐시(LeetCode 460)입니다. 여기서는 접근 횟수가 가장 적은 항목을 제거합니다. 횟수가 같으면 최근 사용 여부로 순위를 정하며, 빈도가 같은 항목 중 가장 오랫동안 사용되지 않은 항목을 제거합니다. 구현에는 세 가지 자료 구조가 필요합니다. 키를 값에 매핑하는 맵, 키를 빈도에 매핑하는 맵, 그리고 각 빈도 그룹 안에서 삽입 순서를 유지하기 위한 빈도를 OrderedDict에 매핑하는 맵입니다. LFU의 get과 put은 분할 상환 기준으로 O(1)입니다.
from collections import defaultdict, OrderedDict
class LFUCache:
def __init__(self, capacity):
self.cap = capacity
self.min_f = 0
self.kv = {} # key -> val
self.kf = {} # key -> freq
self.fk = defaultdict(OrderedDict) # freq -> {key: None}
def _touch(self, key):
f = self.kf[key]
self.kf[key] = f + 1
del self.fk[f][key]
if not self.fk[f] and f == self.min_f:
self.min_f += 1
self.fk[f+1][key] = None
def get(self, key):
if key not in self.kv:
return -1
self._touch(key)
return self.kv[key]
def put(self, key, val):
if self.cap == 0: return
if key in self.kv:
self.kv[key] = val
self._touch(key)
else:
if len(self.kv) == self.cap:
lfu_key, _ = self.fk[self.min_f].popitem(last=False)
del self.kv[lfu_key]; del self.kf[lfu_key]
self.kv[key] = val; self.kf[key] = 1
self.fk[1][key] = None; self.min_f = 1설계 패턴: 해시 맵 + 연결 리스트
LRU 캐시는 강력한 설계 패턴을 보여 줍니다. O(1) 키 조회를 위한 해시 맵과 O(1) 순서 기반 연산을 위한 연결 리스트를 결합하는 것입니다. 이 패턴은 여러 면접 설계 문제에 나타납니다. LRU 캐시, LFU 캐시, 스킵 리스트, 일부 큐 변형 등이 그 예입니다. O(1) 조회와 O(1) 순서 기반 연산을 모두 요구하는 문제라면 이 조합을 고려하세요.
면접에서 이 패턴을 명시적으로 말하면 시스템 수준의 사고력과 고전적인 자료 구조 조합에 대한 이해를 보여 줄 수 있습니다.
행렬에서의 연속 수열
연속 수열 개념을 2차원으로 확장한 문제입니다. 정수 행렬이 주어졌을 때 각 단계에서 인접한 셀로 이동하며 추적할 수 있는 가장 긴 연속 수열의 길이를 찾습니다. 이 문제는 BFS/DFS와 연속 수열을 위한 집합 방식을 결합합니다. 각 값의 위치를 저장한 다음, 각 시작 값에 대해 값보다 1 큰 값이 이웃으로 존재하는지 확인합니다.
# Simpler: find longest consecutive values in a 2D matrix (no adjacency)
def longestConsecutiveMatrix(matrix):
all_vals = set()
for row in matrix:
for v in row:
all_vals.add(v)
best = 0
for v in all_vals:
if v - 1 not in all_vals: # start of sequence
length = 0
while v in all_vals:
v += 1
length += 1
best = max(best, length)
return best
m = [[1, 5, 3], [4, 6, 2], [8, 7, 9]]
print(longestConsecutiveMatrix(m)) # 9 (1..9 all present)면접 요약: 집합 + HashMap의 강력함
이 두 문제에는 공통된 주제가 있습니다. 적절한 해시 자료 구조를 사용해 O(n log n) 또는 O(n²) 문제를 O(n) 문제로 바꾸는 것입니다. 가장 긴 연속 수열 문제에서는 집합으로 '이전 값이 존재하는가?'를 O(1)에 확인합니다. LRU 캐시에서는 해시 맵으로 노드를 즉시 찾고, 이중 연결 리스트로 순서를 O(1)에 갱신합니다. 두 경우 모두 느린 순회를 O(1) 멤버십 확인 또는 조회로 대체합니다.
면접관이 'O(n log n)보다 더 개선할 수 있나요?'라고 묻는다면, 거의 항상 '정렬을 피하기 위해 해시 맵이나 해시 집합을 사용합니다'라고 답할 수 있습니다.
빠른 확인
이번 레슨에서 다룬 자료 구조 및 알고리즘 — 코딩 면접 준비 개념에 대한 이해도를 확인해 보세요.
레슨 요약
이번 레슨에서는 집합으로 O(1) 멤버십을 확인하고 수열의 시작점에서만 계산을 시작하여 가장 긴 연속 수열을 O(n)에 구하는 방법, OrderedDict 또는 처음부터 구현한 해시 맵과 이중 연결 리스트를 사용해 LRU 캐시의 get과 put을 O(1)에 수행하는 방법, 그리고 해시 맵과 연결 리스트 패턴이 순서에 민감한 O(1) 자료 구조를 만들 때 재사용할 수 있는 구성 요소라는 점을 배웠습니다. 다음에는 기본 사례, 신뢰, 구축 프레임워크를 사용해 재귀를 다시 살펴봅니다.
자주 묻는 질문
“가장 긴 연속 수열과 LRU 캐시” 강의는 무료인가요?
네 — “가장 긴 연속 수열과 LRU 캐시” 전체 내용을 이 웹사이트에서 무료로 읽을 수 있습니다. 인터랙티브하게 실습하려면(내장 코드 에디터와 24/7 AI 튜터), CoddyKit PRO로 업그레이드하면 DSA Interview Prep 강의 전체를 잠금 해제할 수 있습니다. DSA Interview Prep 강의에는 총 4개의 강의가 포함되어 있습니다.
“가장 긴 연속 수열과 LRU 캐시”에서 뭘 배우나요?
집합을 사용해 O(n)에 longest-consecutive-sequence를 해결한 뒤 OrderedDict로 LRU 캐시를 설계합니다. 브라우저에서 직접 실행하는 실습 코드로 DSA Interview Prep을(를) 배우며, 24/7 AI 튜터가 강의를 진행하면서 질문에 답변해줍니다.
DSA Interview Prep을(를) 시작하는 데 경험이 필요한가요?
사전 경험은 필요하지 않습니다. CoddyKit의 DSA Interview Prep은(는) 초급자부터 고급 학습자까지를 위해 구성되어 있으므로, 여기서 시작하거나 처음부터 시작할 수 있으며 자신의 속도대로 진행할 수 있습니다. 이것은 4개 중 4번째 강의입니다.
“가장 긴 연속 수열과 LRU 캐시” 강의는 얼마나 걸리나요?
대부분의 CoddyKit 강의는 약 5~10분이 소요됩니다. 각 강의는 간결하고 인터랙티브하여 꾸준한 진행이 가능하며, 웹과 앱에서 중단한 부분부터 바로 시작할 수 있습니다.
이 DSA Interview Prep 강의에서 코드를 작성하고 실행할 수 있나요?
네. 모든 DSA Interview Prep 강의에는 내장 코드 에디터가 포함되어 있으므로, 브라우저에서 바로 실제 코드를 작성하고 실행한 후 즉시 AI 피드백을 받을 수 있습니다 — 로컬 설정이 필요 없습니다.
이 강의의 모든 강의
- 해시 함수 내부와 충돌 처리
- 두 수의 합과 다양한 변형
- 빈도 계산과 그룹화
- 가장 긴 연속 수열과 LRU 캐시