Top K Frequent Elements
정수 배열 nums와 정수 k가 주어집니다. nums에서 가장 자주 나타나는 순서대로, 가장 빈번한 값부터 k개의 값을 반환하세요. 두 값의 등장 횟수가 같으면 더 작은 값이 먼저 옵니다.
각 값은 nums에서 몇 번 나타나든 답에 한 번씩만 포함되며, k는 서로 다른 값의 개수보다 클 수 없습니다.
함수
- numsinteger-array
- 계산할 값
- kinteger
- 반환할 값의 개수
- 반환값integer-array
- 빈도가 가장 높은 k개 값(빈도가 높은 순서), 빈도가 같으면 더 작은 값이 먼저
제약 조건
1 ≤ nums.length ≤ 104-104 ≤ nums[i] ≤ 1041 ≤ k이며k는nums의 서로 다른 값의 개수보다 클 수 없습니다.
예제
- 입력
- nums = [4, 1, 4, 2, 1, 4, 3, 1, 4]k = 2
- 출력
- [4, 1]
- 설명
4는 네 번,1은 세 번,2와3은 각각 한 번씩 나타납니다. 가장 자주 나타나는 두 값은4, 그다음은1입니다.
- 입력
- nums = [5, -2, 7, -2, 7, 5, 9]k = 2
- 출력
- [-2, 5]
- 설명
-2,5,7은 각각 두 번,9는 한 번 나타납니다. 세 값이 최다 횟수로 동률이므로, 그중 더 작은 두 값인-2와5가 답입니다.
- 입력
- nums = [8]k = 1
- 출력
- [8]
- 설명
- 값이 하나뿐이므로 가장 자주 나타나는 값입니다.
제출 시 숨은 테스트 +16개
힌트
하나씩 열어 보세요. 열 때마다 조금 더 알려 줍니다.
먼저 각 값이 얼마나 자주 나타나는지 알아보세요. 한 번의 순회로 값을 해당 개수에 매핑하는 자료 구조는 무엇인가요?
개수를 파악했으니, 하나의 정렬 기준에 따라 가장 좋은
k개의 값을 원합니다. 개수가 많을수록 먼저 오고, 개수가 같으면 값이 더 작은 쪽이 먼저 옵니다. 서로 다른 값을 모두 정렬해도 됩니다. 크기가k인 최소 힙은 정답에 포함될 가능성이 있는 값만 유지합니다.횟수는 1부터
n까지의 정수입니다. 각 횟수마다 버킷 하나를 만들고, 버킷c에는 정확히c번 나타나는 값들을 담은 다음, 횟수가 가장 큰 버킷부터 읽으세요. 값을 가장 작은 것부터 가장 큰 것까지 차례로 살펴보며 버킷을 채우면, 각 버킷은 이미 동률일 때의 순서대로 정렬되어 있습니다.
풀이
개수를 세는 것은 간단한 절반에 불과합니다. 해시 맵으로 한 번 순회하면 각 값의 개수를 구할 수 있습니다. 진짜 문제는 필요 이상으로 작업하지 않고 가장 좋은 k개의 값을 고르는 방법입니다. 서로 다른 d개의 값을 개수순으로 정렬하면 O(d log d)의 비용이 들고, 크기가 k인 최소 힙을 사용하면 이를 O(d log k)로 줄일 수 있습니다. 또한 개수는 1부터 n까지의 정수이므로, 버킷 정렬을 사용하면 비교 연산 없이 값들을 개수순으로 정렬할 수 있습니다.
개수를 센 다음, 개수별로 정렬하기
핵심 아이디어
먼저 개수를 셉니다. 값에서 개수로 대응하는 해시 맵을 사용해 한 번 순회하면 [4, 1, 4, 2, 1, 4, 3, 1, 4]가 4 → 4, 1 → 3, 2 → 1, 3 → 1로 바뀝니다.
그런 다음 서로 다른 값들을 답의 순서대로 배치합니다. 개수가 많은 값이 먼저 오고, 개수가 같으면 값이 작은 것이 먼저 옵니다. 정렬에는 이 비교 기준을 그대로 적용합니다. 개수를 첫 번째 키로, 값을 두 번째 키로 두면 정렬된 목록의 첫 k개 항목이 답입니다. 여기서는 순서가 4, 1, 2, 3이고, k = 2이므로 4와 1을 선택합니다.
개수를 세는 데는 O(n)이 듭니다. 서로 다른 d개의 값을 정렬하는 데는 O(d log d)가 들며, 모든 값이 서로 다르면 최대 O(n log n)입니다. 값이 10^4개라면 비교 횟수는 약 1.3 × 10^5회로, 빠른 편입니다. 낭비되는 부분은 첫 k개만 필요한데도 모든 값을 정렬한다는 점입니다.
알고리즘
- 해시 맵에서 각 값의 개수를 셉니다.
- 서로 다른 값을 목록에 넣습니다.
- 개수를 기준으로 내림차순 정렬하고, 개수가 같으면 값을 기준으로 오름차순 정렬합니다.
- 처음
k개의 값을 반환합니다.
from collections import Counter
def topKFrequent(nums, k):
counts = Counter(nums)
# Most frequent first; equal counts put the smaller value first.
ordered = sorted(counts, key=lambda value: (-counts[value], value))
return ordered[:k]k개의 최상위 항목을 최소 힙에 유지하기
핵심 아이디어
가장 좋은 값 k개만 필요하므로 후보도 k개만 유지합니다. 새로운 값이 들어올 때마다, 보유한 후보 중 가장 약한 후보보다 나은지 확인합니다. 여기서 더 약하다는 것은 개수가 더 적거나, 개수가 같고 값이 더 큰 경우를 의미합니다. 이 규칙에 따라 정렬된 최소 힙을 사용하면 가장 약한 후보가 맨 위에 놓이므로, 이를 O(1)에 확인하고 O(log k)에 교체할 수 있습니다.
서로 다른 값들을 순회합니다. 힙에 k개보다 적은 값이 들어 있는 동안에는 값을 추가합니다. 그다음부터는 맨 위의 값보다 나은 값이 들어오면 그 값을 교체하고, 그렇지 않은 값은 버립니다. 이미 더 나은 값 k개를 유지하고 있기 때문입니다. 라이브러리 힙을 사용하면 모든 값을 힙에 넣고, 힙 크기가 k를 초과할 때마다 한 번 꺼내는 방식이 더 간단하며, 이렇게 해도 같은 k개 값이 유지됩니다.
마지막에 힙에는 정답이 들어 있지만, 정답 순서대로 정렬되어 있지는 않습니다. 힙은 부분적으로만 정렬되어 있기 때문입니다. 값을 꺼내면 가장 약한 값이 먼저 나오므로, 정답은 마지막 위치부터 첫 번째 위치까지 거꾸로 기록합니다.
서로 다른 값 d개 각각에 대해 k개 항목을 담은 힙 연산을 최대 한 번 수행하므로, 선택에는 O(d log k)가 걸립니다. k가 d보다 훨씬 작을 때는 정렬하는 것보다 빠릅니다. 예를 들어 서로 다른 값 8000개 중 상위 10개를 찾는 경우입니다.
알고리즘
- 해시 맵에서 각 값의 개수를 셉니다.
- 각 고유한 값에 대해 힙에
k개 미만의 값이 들어 있을 동안 해당 값을 추가합니다. - 힙이 가득 차면 해당 값을 맨 위에 있는 값, 즉 유지 중인 값 중 가장 약한 값과 비교합니다. 새 값이 더 강하면 맨 위에 놓고 아래로 내려가도록 조정합니다.
- 힙에서
k번 값을 꺼내고, 각 값을 마지막 위치부터 첫 번째 위치까지 답에 씁니다.
import heapq
from collections import Counter
def topKFrequent(nums, k):
counts = Counter(nums)
# Entries are (count, -value). heapq keeps the smallest entry on top, which is
# the weakest value kept: the lowest count, and on a tie the larger value.
heap = []
for value, count in counts.items():
heapq.heappush(heap, (count, -value))
if len(heap) > k:
heapq.heappop(heap)
# Pops come out weakest first, so fill the answer from the back.
result = [0] * k
for i in range(k - 1, -1, -1):
result[i] = -heapq.heappop(heap)[1]
return result개수를 세고, 개수별로 버킷 정렬하기
핵심 아이디어
횟수는 아무 숫자나 될 수 없습니다. n까지의 1부터 시작하는 정수입니다. 따라서 버킷 정렬을 사용할 수 있습니다. 각 횟수마다 버킷 하나를 만들고, 버킷 c에는 정확히 c번 나타나는 값들을 담은 다음, 버킷 n부터 아래로 버킷을 읽습니다. 값은 가장 자주 나오는 것부터 나오며, 횟수끼리는 비교할 필요가 없습니다.
동률 규칙에는 한 가지 조건이 더 있습니다. 버킷 안에서는 더 작은 값이 먼저 와야 합니다. 값의 범위는 -10^4부터 10^4까지이므로, R = 2 × 10^4 + 1개의 카운터로 이루어진 배열을 사용해 횟수를 셀 수 있습니다. 값 v는 인덱스 v + 10^4에 둡니다. 이 배열을 가장 작은 값부터 가장 큰 값까지 순회하며 각 값을 해당 횟수의 버킷에 추가합니다. 각 버킷은 오름차순으로 채워지므로 동률 규칙을 만족하고, 따로 정렬할 필요가 없습니다.
[5, -2, 7, -2, 7, 5, 9]의 경우 순회하면서 -2, 5, 7을 그 순서대로 버킷 2에 넣고, 9는 버킷 1에 넣습니다. 버킷 7부터 아래로 읽으면 값이 들어 있는 첫 번째 버킷은 버킷 2이며, k = 2이면 -2와 5를 선택합니다.
작업은 nums를 한 번 순회하고, R개의 카운터를 한 번 순회하며, 버킷을 한 번 순회하는 것으로, 총 O(n + R)입니다. 값의 범위가 고정되어 있으면 선형 시간입니다. 카운팅 배열 대신 해시 맵을 사용해도 횟수 세기는 선형 시간으로 유지되지만, 버킷은 맵의 순서대로 채워지므로 동률 규칙을 지키려면 각 버킷을 정렬해야 합니다.
알고리즘
value + 10^4를 인덱스로 사용하는 배열에 각 값의 개수를 센다.- 1부터
n까지의 버킷을 만들고, 가능한 각 개수마다 하나의 리스트를 둔다. - 가장 작은 값부터 가장 큰 값까지 카운팅 배열을 순회하면서, 각 값이 나타나는 횟수에 해당하는 버킷에 그 값을 추가한다.
- 개수가
n인 버킷부터 1인 버킷까지 읽어 내려가며,k개를 얻을 때까지 값을 가져온다.
def topKFrequent(nums, k):
OFFSET = 10000 # values run from -10^4 to 10^4
counts = [0] * (2 * OFFSET + 1)
for x in nums:
counts[x + OFFSET] += 1
# buckets[c] lists the values that occur exactly c times. Walking the
# values from smallest to largest fills every bucket in ascending order.
buckets = [[] for _ in range(len(nums) + 1)]
for i, c in enumerate(counts):
if c > 0:
buckets[c].append(i - OFFSET)
# Read the buckets from the highest count down until k values are taken.
result = []
for c in range(len(nums), 0, -1):
for value in buckets[c]:
result.append(value)
if len(result) == k:
return result
return result
함정과 경계 사례
개수를 세는 과정이 틀리는 경우는 드뭅니다. 답의 순서가 틀리는 경우가 많습니다.
- 처음 나타난 순서나 해시 맵의 순서로 동률을 처리합니다. 두 번째 예시에서는
-2,5,7이 모두 두 번씩 나타나며, 값이 더 작은 것을 선택하는 규칙을 적용해야만[-2, 5]가 유일한 정답이 됩니다. - 힙의 배열을 그대로 반환합니다. 힙은 부분적으로만 정렬되어 있고, 맨 위에는 가장 약한 값, 즉 가장 마지막에 와야 할 값이 있습니다.
- 힙의 동률 규칙을 반대로 적용합니다. 개수가 같은 두 값 중에서는 더 큰 값이 더 약하므로,
(count, value)를 기준으로 하는 최소 힙은 잘못된 값을 내보냅니다.(count, -value)를 사용하거나 해당 규칙에 맞게 비교를 작성하세요. - 서로 다른 값의 개수만큼만 버킷을 만듭니다.
[3, 3, 3, 3]처럼 하나의 값이n번 나타날 수 있으므로, 버킷n이 있어야 합니다. - Java에서 두
Integer개수를!=로 비교합니다. 이는 참조를 비교하므로, 개수가 127을 넘으면 문제가 발생합니다. 먼저int로 언박싱하세요. - 마지막에 버킷 하나를 통째로 가져옵니다. 버킷을 처리하는 도중이라도 값이
k개가 되는 즉시 멈추세요.
자주 묻는 질문4
상위 K개 빈도 요소의 시간 복잡도는 무엇인가요?
세는 데는 O(n)이 걸립니다. 상위 k개를 선택하는 데는 서로 다른 d개의 값에 대해 정렬할 경우 O(d log d), 크기가 k인 최소 힙을 사용할 경우 O(d log k), 버킷 정렬을 사용할 경우 O(n)에 값 범위를 한 번 순회하는 비용이 듭니다. d는 n까지 커질 수 있으므로, 최악의 경우 정렬은 O(n log n)이고 버킷 정렬은 선형 시간입니다.
상위 K 빈도 요소 문제를 O(n) 시간에 해결할 수 있나요?
네, 버킷 정렬을 사용하면 됩니다. 횟수는 n까지의 양의 정수이므로 각 값은 해당 횟수의 버킷에 들어가고, 횟수가 가장 높은 버킷부터 읽으면 비교 정렬 없이 빈도순으로 값이 나열됩니다. 횟수에 퀵셀렉트를 적용하는 방법도 평균적으로 O(n)이지만, 최악의 경우에는 시간 복잡도가 제곱입니다.
왜 최대 힙이 아니라 최소 힙을 사용하나요?
모든 d 값으로 최대 힙을 만들어도 됩니다. O(d) 시간에 힙을 구성한 다음 k번 꺼내면 총 O(d + k log d)입니다. 크기가 k인 최소 힙은 항목을 k개만 보관하며, 값이 하나씩 들어오는 경우에 적합합니다. 최상위 값이 버릴 후보이기 때문입니다. 단점은 결과가 역순으로 나오므로, 결과를 뒤에서부터 채워야 한다는 것입니다.
Top K Frequent Elements에서 동점은 어떻게 처리하나요?
규칙 하나를 선택해 모든 곳에 적용하세요. 여기서는 개수가 같으면 더 작은 값을 먼저 놓으므로 답이 유일해집니다. 정렬할 때는 개수를 비교한 다음 값을 비교하세요. 힙에서는 개수가 같을 경우 더 큰 값이 우선순위가 낮습니다. 버킷 정렬에서는 값을 오름차순으로 버킷에 채우면 각 버킷이 이미 동점 순서대로 정렬됩니다.
비슷한 문제
같은 아이디어를 쓰는 문제입니다. 두세 개를 풀면 패턴이 몸에 익습니다.
Python
def topKFrequent(nums, k):
# 여기에 코드를 작성하세요케이스 1
케이스 2
케이스 3
입력
nums = [4, 1, 4, 2, 1, 4, 3, 1, 4] k = 2
기대값
[4, 1]