Skip to Content
WebJavaScriptJavaScript 코딩테스트03. 배열·문자열 기본 조작

이번 편의 결과물: coding-test/03-arrays-and-strings/에 문제 4개 풀이를 작성하고 node --test를 통과시킵니다. · 다루는 개념: 배열 순회·변형 재확인, 문자열 메서드, 프리픽스 합, 빈도수 세기

이 편에서 만드는 파일

coding-test/ └── 03-arrays-and-strings/ ├── 01-rotate-array/ │ ├── solution.js (+) │ └── solution.test.js (+) ├── 02-snake-to-camel/ │ ├── solution.js (+) │ └── solution.test.js (+) ├── 03-range-sum-with-prefix-sum/ │ ├── solution.js (+) │ └── solution.test.js (+) └── 04-most-frequent-char/ ├── solution.js (+) └── solution.test.js (+)

개념 정리

ECMAscript 과목에서 배운 배열·문자열 메서드를 문제 풀이 관점에서 정리합니다.

메서드역할
slice(start, end)원본을 바꾸지 않고 일부를 잘라 새 배열(문자열)로 반환
split(구분자)문자열을 구분자 기준으로 잘라 배열로 만듦
join(구분자)배열을 구분자로 이어 붙여 문자열로 만듦
charAt(index) / [index]특정 위치의 문자 한 개를 꺼냄

프리픽스 합(구간 누적합)은 nums[0]부터 nums[i-1]까지의 합을 미리 계산해둔 배열입니다. prefix[i]를 “인덱스 0부터 i-1까지의 합”으로 정의하면, 구간 [l, r]의 합은 prefix[r+1] - prefix[l] 한 번의 뺄셈으로 구합니다. 쿼리마다 배열을 순회하는 O(n) 대신 쿼리당 O(1)로 끝나는, “미리 계산해두고 빠르게 꺼내 쓴다”는 전략입니다.

빈도수 세기는 각 값이 몇 번 등장했는지 객체에 저장하는 패턴입니다. 이 편은 일반 객체({})로 세고, 04편에서 같은 패턴을 Map으로 다시 다루며 차이를 비교합니다.

실습

1. 배열 회전시키기

문제: 정수 배열 nums와 자연수 k가 주어질 때, 배열을 오른쪽으로 k칸 회전한 배열을 반환합니다.

입출력 예

numsk결과
[1, 2, 3, 4, 5]2[4, 5, 1, 2, 3]
[1, 2, 3]4[3, 1, 2]

접근: 한 칸씩 k번 회전하면 O(n * k)입니다. knums.length로 나눈 나머지(shift)만큼만 두 조각을 잘라 이어 붙이면 O(n)으로 끝납니다.

// coding-test/03-arrays-and-strings/01-rotate-array/solution.js // 시간복잡도: O(n) — slice 두 번으로 배열을 한 번씩만 복사한다 // 공간복잡도: O(n) — 회전된 새 배열을 반환한다 export function solution(nums, k) { const n = nums.length; if (n === 0) return []; const shift = k % n; if (shift === 0) return [...nums]; return [...nums.slice(-shift), ...nums.slice(0, n - shift)]; }
// coding-test/03-arrays-and-strings/01-rotate-array/solution.test.js import { test } from 'node:test'; import assert from 'node:assert/strict'; import { solution } from './solution.js'; test('오른쪽으로 2칸 회전한다', () => { assert.deepStrictEqual(solution([1, 2, 3, 4, 5], 2), [4, 5, 1, 2, 3]); }); test('k가 배열 길이보다 크면 나머지만큼만 회전한다', () => { assert.deepStrictEqual(solution([1, 2, 3], 4), [3, 1, 2]); }); test('k가 배열 길이의 배수이면 원래 배열과 같다', () => { assert.deepStrictEqual(solution([1, 2, 3], 3), [1, 2, 3]); });

함정: shift === 0 조건 없이 바로 nums.slice(-shift)를 호출하면 -shift-0이 되어 slice(0)처럼 배열 전체를 반환합니다. 뒤 조각과 앞 조각이 둘 다 전체 배열이 되어 길이가 2배로 늘어나는 버그가 생기므로 shift === 0을 먼저 걸러야 합니다.

2. 스네이크케이스를 카멜케이스로 변환하기

문제: 스네이크케이스 문자열(user_first_name)을 카멜케이스(userFirstName)로 변환합니다.

입출력 예

입력결과
user_first_nameuserFirstName
idid

접근: _ 기준으로 나눈 뒤 첫 단어를 제외한 나머지 단어의 첫 글자만 대문자로 바꿔 이어 붙입니다. O(n)입니다.

// coding-test/03-arrays-and-strings/02-snake-to-camel/solution.js // 시간복잡도: O(n) — 문자열 길이만큼 한 번 순회한다 // 공간복잡도: O(n) — 변환된 새 문자열을 만든다 export function solution(snake) { const words = snake.split('_'); return words .map((word, index) => (index === 0 ? word : word.charAt(0).toUpperCase() + word.slice(1))) .join(''); }
// coding-test/03-arrays-and-strings/02-snake-to-camel/solution.test.js import { test } from 'node:test'; import assert from 'node:assert/strict'; import { solution } from './solution.js'; test('여러 단어를 카멜케이스로 합친다', () => { assert.strictEqual(solution('user_first_name'), 'userFirstName'); }); test('언더스코어가 없으면 그대로 반환한다', () => { assert.strictEqual(solution('id'), 'id'); }); test('연속된 언더스코어는 빈 조각을 만들어 글자가 하나 사라질 수 있다', () => { assert.strictEqual(solution('a__b'), 'aB'); });

함정: 연속된 언더스코어(a__b)는 split('_')에서 빈 문자열 조각(['a', '', 'b'])을 만듭니다. 오류는 안 나지만 단어 경계가 사라져 결과가 aB처럼 예상과 달라지므로, 입력이 항상 올바르다고 가정할 수 없다면 빈 조각을 미리 걸러내야 합니다.

3. 프리픽스 합으로 구간 합 빠르게 구하기

문제: 정수 배열 nums와 구간 쿼리 목록 queries(각 쿼리는 [left, right], right 포함)가 주어질 때 각 구간의 합을 배열로 반환합니다.

입출력 예

numsqueries결과
[1, 2, 3, 4, 5][[0, 2], [1, 3]][6, 9]
[1, 2, 3, 4, 5][[2, 2]][3]

접근: 매 쿼리마다 다시 순회하면 O(n * q)입니다. 프리픽스 합을 먼저 O(n)에 만들면 쿼리당 O(1)로 끝나 전체 O(n + q)가 됩니다.

// coding-test/03-arrays-and-strings/03-range-sum-with-prefix-sum/solution.js // 시간복잡도: O(n + q) — 프리픽스 합 계산 O(n) + 쿼리 q개 각각 O(1) // 공간복잡도: O(n) — 프리픽스 합 배열을 저장한다 export function solution(nums, queries) { const prefix = [0]; for (const num of nums) { prefix.push(prefix[prefix.length - 1] + num); } return queries.map(([left, right]) => prefix[right + 1] - prefix[left]); }
// coding-test/03-arrays-and-strings/03-range-sum-with-prefix-sum/solution.test.js import { test } from 'node:test'; import assert from 'node:assert/strict'; import { solution } from './solution.js'; test('여러 구간의 합을 한 번에 구한다', () => { assert.deepStrictEqual(solution([1, 2, 3, 4, 5], [[0, 2], [1, 3]]), [6, 9]); }); test('구간이 원소 하나뿐이어도 정확히 계산한다', () => { assert.deepStrictEqual(solution([1, 2, 3, 4, 5], [[2, 2]]), [3]); });

함정: prefix 길이를 nums.length로만 만들면 prefix[right + 1]에서 인덱스가 하나 어긋나는 오프바이원 오류가 생깁니다. prefix는 항상 0부터 시작해 nums.length + 1개 원소를 가져야 합니다.

4. 가장 많이 등장한 문자 찾기

문제: 소문자 알파벳으로만 이루어진 문자열에서 가장 많이 등장한 문자를 반환합니다. 등장 횟수가 같으면 알파벳 순서가 빠른 문자를 반환합니다.

입출력 예

입력결과
bananaa
ababa

접근: 객체에 문자별 등장 횟수를 세고 가장 큰 값을 찾습니다. 동점일 때 알파벳 순서를 보장하려면 키를 정렬한 뒤 비교합니다. O(n)입니다.

// coding-test/03-arrays-and-strings/04-most-frequent-char/solution.js // 시간복잡도: O(n) — 문자열을 한 번 순회하며 개수를 센다 // 공간복잡도: O(1) — 알파벳 26개로 크기가 고정된 카운터를 쓴다 export function solution(text) { const counts = {}; for (const char of text) { counts[char] = (counts[char] ?? 0) + 1; } let bestChar = ''; let bestCount = 0; for (const char of Object.keys(counts).sort()) { if (counts[char] > bestCount) { bestChar = char; bestCount = counts[char]; } } return bestChar; }
// coding-test/03-arrays-and-strings/04-most-frequent-char/solution.test.js import { test } from 'node:test'; import assert from 'node:assert/strict'; import { solution } from './solution.js'; test('가장 많이 등장한 문자를 찾는다', () => { assert.strictEqual(solution('banana'), 'a'); }); test('등장 횟수가 같으면 알파벳 순서가 빠른 문자를 반환한다', () => { assert.strictEqual(solution('abab'), 'a'); });

함정: Object.keys(counts)를 정렬하지 않고 그대로 순회하면, 대부분 삽입 순서로 나오긴 하지만 이는 동점 처리까지 보장하는 규칙이 아닙니다. 동점 시 특정 순서가 필요하면 항상 명시적으로 sort()를 호출해야 안전합니다.

직접 해보기

  1. 배열 회전 문제를 왼쪽으로 k칸 회전하도록 바꿔봅니다(힌트: slice로 자르는 두 조각의 순서를 반대로 합니다).
  2. 가장 많이 등장한 문자 찾기를 응용해 “가장 적게 등장한 문자”를 찾는 함수를 작성하고 테스트를 추가해봅니다.

정답 보기(1번)

// 왼쪽으로 k칸 회전 export function solution(nums, k) { const n = nums.length; if (n === 0) return []; const shift = k % n; if (shift === 0) return [...nums]; return [...nums.slice(shift), ...nums.slice(0, shift)]; }

왼쪽 회전은 앞의 shift개를 뒤로 보내면 되므로, 오른쪽 회전과 반대로 뒷부분을 앞에 두고 앞부분을 뒤에 둡니다.

자주 하는 실수

증상원인고치는 법
회전 결과의 길이가 원본의 2배가 됨k가 배열 길이의 배수일 때 slice(-0)이 전체를 반환함shift === 0일 때 원본 복사본을 바로 반환
구간 합 결과가 하나씩 밀려서 나옴prefixnums.length 크기로 만들어 오프바이원 발생prefix0으로 시작해 nums.length + 1개로 만들기
동점 처리 결과가 실행마다 다르게 느껴짐객체 키 순서에 기대어 정렬을 생략함Object.keys(counts).sort()로 명시적으로 정렬

확인 문제

문제 14지선다
배열을 오른쪽으로 k칸 회전할 때 k를 nums.length로 나눈 나머지를 먼저 구하는 이유는?
문제 24지선다
프리픽스 합 배열 prefix에서 구간 [left, right]의 합을 구하는 올바른 식은?
문제 34지선다
프리픽스 합을 미리 계산해두는 방식이 매 쿼리마다 배열을 순회하는 방식보다 유리한 이유는?
문제 44지선다
Object.keys(counts)를 정렬하지 않고 동점 문자 중 알파벳 순서를 기대하면 안 되는 이유는?

참고 자료

Last updated on