4.1해시
프로그래머스 해시 문제 정리
개요
프로그래머스에는 자료구조/알고리즘 유형별로 정리해둔 문제집인 코딩테스트 고득점 Kit이 있다. 이 중 해시와 관련된 5문제를 풀어본다.
Map
이라는 강력한 자바스크립트 내장 해시 테이블을 잘 이용하자는 것이다.1. 완주하지 못한 선수(lv.1)
완주하지 못한 선수는 참가자 목록에는 있는데 완주자 목록에는 없는 1명을 찾아내는 문제다.
자바스크립트에서 지원하는 Map
으로 frequency counter를 만들면 효율적으로 풀 수 있다. 즉, 참가자 목록에서 이름 당 몇번 등장했는지 세는 frequency counter를 만들고(동명이인이 있으니), 완주자 목록을 순회하며 frequency counter에서 차감한 후 counter가 여전히 1인 사람의 이름을 반환하면 된다.
그러면 여기서 해시는 어디에 쓰였나?라고 생각할 수 있는데, 자바스크립트에서 지원하는 Object
, Map
, Set
가 기본적으로 해시 구조다. 참고로, Array
역시 인덱스를 문자열 키처럼 해석해서 접근하는 방식이긴 하지만 순차적 데이터라는 측면에서 인덱스 기반이고, 키 기반인 해시 테이블과는 차이가 있다.
javascriptfunction solution(participant, completion) { const map = new Map(); for(const name of participant){ map.set(name, (map.get(name) || 0) + 1); } for(const name of completion){ map.set(name, map.get(name) - 1); } for(const [name, counter] of map){ if(counter > 0) return name; } }
2. 폰켓몬(lv.1)
폰켓몬은 문제 설명은 매우 긴데 생각해보면 그렇게 복잡하지 않은 문제다. 개인적으로는 풀어놓고도 이게 맞는건가, 내가 꼼수를 쓴건가 고심했다.
기본적으로 가장 다양하게 뽑을 수 있는건 n/2
가 한계다. 여기서, 중복되는 폰켓몬의 수만큼 뽑을 수 있는 다양성이 줄어드므로 set
을 활용해서 풀었다.
javascriptfunction solution(nums) { const set = new Set(nums); return Math.min(nums.length / 2, set.size) }
3. 전화번호 목록(lv.2)
전화번호 목록은 문제 자체는 간단하지만 풀이는 마냥 단순하진 않다. 우선, 게으른 접근 방식인 이중 for
문은 효율성 검사에서 탈락이다.
javascript// 효율성 검사에서 탈락 function solution(pb) { for(let i = 0; i < pb.length; i++){ for(let j = 1; j < pb.length; j++){ if(i !== j && pb[j].startsWith(pb[i])) return false; } } return true; }
여기서 가장 효율적인 방법은 내장된 sort
를 쓰는 것이다. 자바스크립트의 Array.prototype.sort
는 TimSort를 써서 O(n log n)
의 시간 복잡도를 가진다. TimSort는 이미 정렬된 구간(run)을 찾고, run이 너무 짧으면 Insertion Sort로, run이 충분하면 정렬된 run들을 Merge Sort처럼 합병한다.
sort
는 사전 순으로 정렬하기 때문에, 같은 숫자로 시작되는 데이터들은 인접하게 된다. 따라서 우선 정렬을 해두고, 인접한 두 요소들을 비교해 이전의 문자열이 다음 문자열의 시작 문자열이라면 false
를 반환하고, 전부 순회했을 때 이런 케이스가 없다면 true
를 반환하면 된다.
javascriptfunction solution(pb) { pb.sort(); for (let i = 0; i < pb.length - 1; i++) { if (pb[i + 1].startsWith(pb[i])) { return false; } } return true; }
다만 이 방법은 이 챕터의 주제인 해시를 활용한 방식은 아니다. 해시의 장점인 set
과 has
가 O(1)
이라는 점을 활용하여, Map
을 통해 아래와 같이 코드를 쓸 수 있다.
javascriptfunction solution(pb){ const map = new Map(); for(let num of pb){ map.set(num, true); } for(let num of pb){ for(let i = 1; i < num.length; i++){ const prefix = num.slice(0, i); if(map.has(prefix)) return false; } } return true; }
map.has()
는 O(1)의 시간 복잡도를 가지기 때문에, 앞서 본 이중 for
문과 비교하면 훨씬 빠르다. 좀 더 구체적인 숫자를 통해 비교해보면, 전화번호 100,000개가 주어진다고 하면 앞선 이중 for
문은 100,000 ** 2, 즉 1조번의 연산을 거친다. 반면 해시를 활용한 풀이는 전화번호의 최대 길이(num.length
)가 20이라고 가정하면 O(N * M)
, 즉 100,000 * 20 = 200만번 정도의 연산만 거치면 된다. 이는 map.has
가 O(N)이 아니라 O(1)이기 때문에 가능한 것이다.
4. 의상(lv.2)
의상은 약간의 수학적 사고가 필요하다. 각 카테고리별로 해당 카테고리의 옷들 중 하나 또는 아무것도 안 입는 선택을 할 수 있으므로, 조합은 각 카테고리의 옷들 수 + 1을 모두 곱하는 것이다.
다만, 이렇게 하면 모든 옷을 안 입을 경우가 포함되므로 결과에서 그 경우의 수 1개는 빼야한다. 코드를 작성하면 아래와 같다.
javascriptfunction solution(clothes) { const map = new Map(); for(const [name, type] of clothes){ map.set(type, (map.get(type) || 0) + 1); } let result = 1; for(const count of map.values()){ result *= (count + 1); } return result - 1; }
5. 베스트앨범(lv.3)
베스트앨범은 3단계 문제답게 지금까지의 문제들보다 복잡하다. 다만, Map
과 sort
를 충분히 이해하고 있다면 시킨대로만 해도 풀리는 문제다.
많이 들은 장르순으로 세우기 위한 frequency counter용 Map과, 그렇게 줄세운 장르에서 순서대로 많이 들은 곡 제목, 그리고 인덱스 순서(동일 재생수시 인덱스 조건에 의해)를 저장할 수 있는 Map 두 개를 만들면 해결할 수 있다.
javascriptfunction solution(genres, plays) { const genreMap = new Map(); const songMap = new Map(); for(let i = 0; i < genres.length; i++){ genreMap.set(genres[i], (genreMap.get(genres[i]) || 0) + plays[i]); if(!songMap.get(genres[i])){ songMap.set(genres[i], []); } songMap.get(genres[i]).push({index: i, plays: plays[i]}); } const genreSort = [...genreMap.entries()].sort((a, b) => b[1] - a[1]).map(([genre]) => genre) const result = []; for(const genre of genreSort){ const songs = songMap.get(genre).sort((a, b) => { if(a.plays === b.plays) return a.index - b.index; return b.plays - a.plays; }) result.push(...songs.slice(0, 2).map(song => song.index)); } return result }