알고리즘 문제를 처음 봤을 때는 정말 막막했다. 문제를 읽어도 무슨 말인지 모르겠고, 코드를 어떻게 짜야 할지도 감이 안 왔다. 하지만 체계적으로 접근하니까 점점 풀 수 있는 문제가 늘어났다.
개발자라면 알고리즘을 피할 수 없다. 코딩테스트에서도 나오고, 실제 개발에서도 성능을 고려할 때 필수다.
더 중요한 건 사고력이다. 복잡한 문제를 작은 단위로 나누고, 논리적으로 접근하는 능력이 생긴다. 이건 개발뿐만 아니라 일상생활에서도 도움이 된다.
문제를 제대로 이해하지 못하면 절대 풀 수 없다. 다음 순서로 접근하자.
예를 들어 이런 문제가 있다고 하자.
정수 배열이 주어졌을 때, 두 수를 더해서 특정 값이 되는 인덱스를 찾아라.
이 문제를 분석해보면:
문제를 이해했으면 어떻게 풀지 생각해보자. 여러 방법이 있을 수 있다.
브루트 포스 (완전 탐색)
정렬 후 투 포인터
해시맵 사용
접근 방법을 정했으면 코드를 작성하자. 이때 주의할 점들:
가장 기본적인 자료구조다. 인덱스로 접근할 수 있어서 빠르다.
public class ArrayExample {
public static void main(String[] args) {
int[] arr = {1, 2, 3, 4, 5};
// 배열 순회
for (int i = 0; i < arr.length; i++) {
System.out.println(arr[i]);
}
// 향상된 for문
for (int num : arr) {
System.out.println(num);
}
}
}
배열의 장점:
배열의 단점:
동적 크기를 가진 자료구조다. Java에서는 ArrayList가 대표적이다.
import java.util.ArrayList;
import java.util.List;
public class ListExample {
public static void main(String[] args) {
List<Integer> list = new ArrayList<>();
// 요소 추가
list.add(1);
list.add(2);
list.add(3);
// 요소 접근
System.out.println(list.get(0)); // 1
// 요소 삭제
list.remove(1); // 인덱스 1의 요소 삭제
// 크기 확인
System.out.println(list.size()); // 2
}
}
LIFO(Last In, First Out) 구조다. 재귀를 반복문으로 바꿀 때 유용하다.
import java.util.Stack;
public class StackExample {
public static void main(String[] args) {
Stack<Integer> stack = new Stack<>();
// 요소 추가
stack.push(1);
stack.push(2);
stack.push(3);
// 요소 확인 (제거하지 않음)
System.out.println(stack.peek()); // 3
// 요소 제거
System.out.println(stack.pop()); // 3
System.out.println(stack.pop()); // 2
// 비어있는지 확인
System.out.println(stack.isEmpty()); // false
}
}
FIFO(First In, First Out) 구조다. BFS에서 자주 사용한다.
import java.util.LinkedList;
import java.util.Queue;
public class QueueExample {
public static void main(String[] args) {
Queue<Integer> queue = new LinkedList<>();
// 요소 추가
queue.offer(1);
queue.offer(2);
queue.offer(3);
// 요소 확인 (제거하지 않음)
System.out.println(queue.peek()); // 1
// 요소 제거
System.out.println(queue.poll()); // 1
System.out.println(queue.poll()); // 2
// 비어있는지 확인
System.out.println(queue.isEmpty()); // false
}
}
키-값 쌍을 저장하는 자료구조다. 빠른 검색이 필요할 때 사용한다.
import java.util.HashMap;
import java.util.Map;
public class HashMapExample {
public static void main(String[] args) {
Map<String, Integer> map = new HashMap<>();
// 키-값 추가
map.put("apple", 100);
map.put("banana", 200);
map.put("orange", 150);
// 값 조회
System.out.println(map.get("apple")); // 100
// 키 존재 확인
System.out.println(map.containsKey("grape")); // false
// 모든 키-값 순회
for (Map.Entry<String, Integer> entry : map.entrySet()) {
System.out.println(entry.getKey() + ": " + entry.getValue());
}
}
}
가장 기본적인 문제다. 여러 방법으로 풀 수 있다.
문제: 정수 배열과 목표 값이 주어졌을 때, 두 수의 합이 목표 값이 되는 인덱스를 찾아라.
브루트 포스 방법:
public int[] twoSum(int[] nums, int target) {
for (int i = 0; i < nums.length; i++) {
for (int j = i + 1; j < nums.length; j++) {
if (nums[i] + nums[j] == target) {
return new int[]{i, j};
}
}
}
return new int[0]; // 해가 없는 경우
}
해시맵 방법:
public int[] twoSum(int[] nums, int target) {
Map<Integer, Integer> map = new HashMap<>();
for (int i = 0; i < nums.length; i++) {
int complement = target - nums[i];
if (map.containsKey(complement)) {
return new int[]{map.get(complement), i};
}
map.put(nums[i], i);
}
return new int[0];
}
동적 프로그래밍의 대표적인 문제다.
문제: 정수 배열에서 연속된 부분 배열의 최대 합을 구해라.
Kadane’s Algorithm:
public int maxSubArray(int[] nums) {
int maxSoFar = nums[0];
int maxEndingHere = nums[0];
for (int i = 1; i < nums.length; i++) {
maxEndingHere = Math.max(nums[i], maxEndingHere + nums[i]);
maxSoFar = Math.max(maxSoFar, maxEndingHere);
}
return maxSoFar;
}
정렬된 배열에서 특정 값을 찾는 효율적인 방법이다.
문제: 정렬된 배열에서 특정 값의 인덱스를 찾아라.
public int binarySearch(int[] nums, int target) {
int left = 0;
int right = nums.length - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (nums[mid] == target) {
return mid;
} else if (nums[mid] < target) {
left = mid + 1;
} else {
right = mid - 1;
}
}
return -1; // 찾지 못한 경우
}
연결 리스트의 기본 연산이다.
문제: 연결 리스트를 뒤집어라.
public ListNode reverseList(ListNode head) {
ListNode prev = null;
ListNode current = head;
while (current != null) {
ListNode next = current.next;
current.next = prev;
prev = current;
current = next;
}
return prev;
}
가장 간단하지만 비효율적인 정렬이다.
public void bubbleSort(int[] arr) {
int n = arr.length;
for (int i = 0; i < n - 1; i++) {
for (int j = 0; j < n - i - 1; j++) {
if (arr[j] > arr[j + 1]) {
// 스왑
int temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;
}
}
}
}
매번 최솟값을 찾아서 앞으로 보내는 정렬이다.
public void selectionSort(int[] arr) {
int n = arr.length;
for (int i = 0; i < n - 1; i++) {
int minIdx = i;
for (int j = i + 1; j < n; j++) {
if (arr[j] < arr[minIdx]) {
minIdx = j;
}
}
// 스왑
int temp = arr[i];
arr[i] = arr[minIdx];
arr[minIdx] = temp;
}
}
분할 정복을 사용하는 효율적인 정렬이다.
public void quickSort(int[] arr, int low, int high) {
if (low < high) {
int pivotIndex = partition(arr, low, high);
quickSort(arr, low, pivotIndex - 1);
quickSort(arr, pivotIndex + 1, high);
}
}
private int partition(int[] arr, int low, int high) {
int pivot = arr[high];
int i = low - 1;
for (int j = low; j < high; j++) {
if (arr[j] <= pivot) {
i++;
swap(arr, i, j);
}
}
swap(arr, i + 1, high);
return i + 1;
}
private void swap(int[] arr, int i, int j) {
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
재귀나 스택을 사용해서 깊이 우선으로 탐색한다.
public void dfs(int[][] graph, int start, boolean[] visited) {
visited[start] = true;
System.out.print(start + " ");
for (int neighbor : graph[start]) {
if (!visited[neighbor]) {
dfs(graph, neighbor, visited);
}
}
}
큐를 사용해서 너비 우선으로 탐색한다.
import java.util.LinkedList;
import java.util.Queue;
public void bfs(int[][] graph, int start, boolean[] visited) {
Queue<Integer> queue = new LinkedList<>();
queue.offer(start);
visited[start] = true;
while (!queue.isEmpty()) {
int current = queue.poll();
System.out.print(current + " ");
for (int neighbor : graph[current]) {
if (!visited[neighbor]) {
visited[neighbor] = true;
queue.offer(neighbor);
}
}
}
}
문제를 풀기 전에 시간복잡도를 고려하자.
메모리 사용량도 고려해야 한다.
코딩테스트에서 예외 상황을 놓치면 틀린다.
자신만의 테스트 케이스를 만들어보자.
public static void main(String[] args) {
// 정상 케이스
int[] nums1 = {2, 7, 11, 15};
int target1 = 9;
System.out.println(Arrays.toString(twoSum(nums1, target1))); // [0, 1]
// 예외 케이스
int[] nums2 = {3, 3};
int target2 = 6;
System.out.println(Arrays.toString(twoSum(nums2, target2))); // [0, 1]
// 해가 없는 경우
int[] nums3 = {1, 2, 3};
int target3 = 7;
System.out.println(Arrays.toString(twoSum(nums3, target3))); // []
}
비슷한 문제들은 비슷한 패턴을 가진다.
한 번 풀었다고 끝이 아니다. 나중에 다시 풀어보자.
알고리즘 문제 해결은 하루아침에 늘지 않는다. 꾸준한 연습이 필요하다. 하지만 체계적으로 접근하면 분명히 실력이 늘 것이다.
처음에는 어려워도 포기하지 말자. 한 문제씩 풀어가다 보면 어느새 코딩테스트도 통과하고, 실제 개발에서도 더 효율적인 코드를 짤 수 있게 된다.